diff --git a/Editor/ModIO.EditorCode/CacheOpener.cs b/Editor/ModIO.EditorCode/CacheOpener.cs
new file mode 100644
index 0000000..0b3b261
--- /dev/null
+++ b/Editor/ModIO.EditorCode/CacheOpener.cs
@@ -0,0 +1,30 @@
+using System;
+using System.IO;
+using UnityEngine;
+
+#if UNITY_EDITOR
+using UnityEditor;
+
+public static class CacheOpener
+{
+ [MenuItem("Tools/mod.io/Open Cache")]
+ public static void OpenCache()
+ {
+ try
+ {
+ string path = Path.GetFullPath($"{Application.persistentDataPath}/mod.io");
+#if UNITY_EDITOR_WIN || UNITY_EDITOR_LINUX
+ // Supposedly Linux uses the same executable name as windows, though not 100% confident
+ // so wrapping all this in a try catch.
+ System.Diagnostics.Process.Start("explorer.exe", path);
+#elif UNITY_EDITOR_OSX
+ System.Diagnostics.Process.Start("open", $"-R \"{path}\"");
+#endif
+ }
+ catch (Exception exception)
+ {
+ Debug.LogError($"Exception opening local cache: {exception.Message}\n{exception.StackTrace}");
+ }
+ }
+}
+#endif
diff --git a/Runtime/ModIO.Implementation/Implementation.API/Implementation.API.Objects/CheckoutProcess.cs.meta b/Editor/ModIO.EditorCode/CacheOpener.cs.meta
similarity index 83%
rename from Runtime/ModIO.Implementation/Implementation.API/Implementation.API.Objects/CheckoutProcess.cs.meta
rename to Editor/ModIO.EditorCode/CacheOpener.cs.meta
index e3ab948..b059b67 100644
--- a/Runtime/ModIO.Implementation/Implementation.API/Implementation.API.Objects/CheckoutProcess.cs.meta
+++ b/Editor/ModIO.EditorCode/CacheOpener.cs.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: 625f62107dca44888b2ee98a558a6434
+guid: f6aef5a773e41824bbdbda3eedf11807
MonoImporter:
externalObjects: {}
serializedVersion: 2
diff --git a/Editor/ModIO.EditorCode/EditorMenu.cs b/Editor/ModIO.EditorCode/EditorMenu.cs
index fe98ff5..3d1b6ad 100644
--- a/Editor/ModIO.EditorCode/EditorMenu.cs
+++ b/Editor/ModIO.EditorCode/EditorMenu.cs
@@ -1,13 +1,12 @@
#if UNITY_EDITOR
-using UnityEngine;
-using UnityEditor;
using ModIO.Implementation;
using ModIO.Implementation.Platform;
+using UnityEditor;
+using UnityEngine;
namespace ModIO.EditorCode
{
-
/// summary
public static class EditorMenu
{
@@ -16,10 +15,11 @@ static EditorMenu()
new MenuItem("Tools/mod.io/Edit Settings", false, 0);
}
+
[MenuItem("Tools/mod.io/Edit Settings", false, 0)]
public static void EditSettingsAsset()
{
- var settingsAsset = GetConfigAsset();
+ SettingsAsset settingsAsset = GetConfigAsset();
EditorGUIUtility.PingObject(settingsAsset);
Selection.activeObject = settingsAsset;
@@ -28,23 +28,19 @@ public static void EditSettingsAsset()
internal static SettingsAsset GetConfigAsset()
{
- var settingsAsset = Resources.Load(SettingsAsset.FilePath);
+ SettingsAsset settingsAsset = Resources.Load(SettingsAsset.FilePath);
// if it doesnt exist we create one
- if(settingsAsset == null)
+ if (settingsAsset == null)
{
// create asset
settingsAsset = ScriptableObject.CreateInstance();
// ensure the directories exist before trying to create the asset
- if(!AssetDatabase.IsValidFolder("Assets/Resources"))
- {
+ if (!AssetDatabase.IsValidFolder("Assets/Resources"))
AssetDatabase.CreateFolder("Assets", "Resources");
- }
- if(!AssetDatabase.IsValidFolder("Assets/Resources/mod.io"))
- {
+ if (!AssetDatabase.IsValidFolder("Assets/Resources/mod.io"))
AssetDatabase.CreateFolder("Assets/Resources", "mod.io");
- }
AssetDatabase.CreateAsset(settingsAsset, $@"Assets/Resources/{SettingsAsset.FilePath}.asset");
diff --git a/Editor/ModIO.EditorCode/ModioSettingProvider.cs b/Editor/ModIO.EditorCode/ModioSettingProvider.cs
new file mode 100644
index 0000000..6b2d5f9
--- /dev/null
+++ b/Editor/ModIO.EditorCode/ModioSettingProvider.cs
@@ -0,0 +1,42 @@
+#if UNITY_EDITOR
+using System.Collections.Generic;
+using ModIO.Implementation;
+using UnityEditor;
+using UnityEditor.UIElements;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+namespace ModIO.EditorCode
+{
+ public class ModioSettingProvider : SettingsProvider
+ {
+ SettingsAsset _config;
+ SerializedObject _serializedConfig;
+
+ ModioSettingProvider() :
+ base("mod.io/Settings", SettingsScope.Project, new HashSet(new[] { "modio", "gameId", "gameKey", "apiKey", "Server URL" }))
+ {
+ }
+
+ public override void OnActivate(string searchContext, VisualElement rootElement)
+ {
+ _config = EditorMenu.GetConfigAsset();
+ _serializedConfig = new SerializedObject(_config);
+
+ rootElement.Add(new Label("mod.io Settings")
+ {
+ style =
+ {
+ marginLeft = 4,
+ fontSize = 19,
+ unityFontStyleAndWeight = FontStyle.Bold,
+ },
+ });
+ rootElement.Add(new InspectorElement(_serializedConfig));
+ }
+
+ [SettingsProvider]
+ public static SettingsProvider OpenModioSettingsProvider() => new ModioSettingProvider();
+ }
+}
+#endif
diff --git a/Editor/ModIO.EditorCode/ModioSettingProvider.cs.meta b/Editor/ModIO.EditorCode/ModioSettingProvider.cs.meta
new file mode 100644
index 0000000..45c2974
--- /dev/null
+++ b/Editor/ModIO.EditorCode/ModioSettingProvider.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 8a697167d8082cd43931d8db442ac2bb
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Editor/ModIO.EditorCode/SettingsAssetEditor.cs b/Editor/ModIO.EditorCode/SettingsAssetEditor.cs
index 7bc5791..b2ba88a 100644
--- a/Editor/ModIO.EditorCode/SettingsAssetEditor.cs
+++ b/Editor/ModIO.EditorCode/SettingsAssetEditor.cs
@@ -4,49 +4,68 @@
using UnityEditor;
using UnityEngine;
-[CustomEditor(typeof(SettingsAsset))]
+[CustomEditor(typeof( SettingsAsset ))]
public class SettingsAssetEditor : Editor
{
- SerializedProperty serverURL;
- SerializedProperty gameId;
- SerializedProperty gameKey;
- SerializedProperty languageCode;
- int previousGameId = 0;
-
- void OnEnable()
+ SerializedProperty gameId;
+ SerializedProperty gameKey;
+ SerializedProperty languageCode;
+ int previousGameId;
+ SerializedProperty serverURL;
+ SerializedProperty useCommandLineArgumentOverrides;
+ SerializedProperty _showMonetizationUIProperty;
+ SerializedProperty _showEnabledModToggleProperty;
+
+ void OnEnable()
{
//get references to SerializedProperties
- var serverSettingsProperty = serializedObject.FindProperty("serverSettings");
+ SerializedProperty serverSettingsProperty = serializedObject.FindProperty("serverSettings");
serverURL = serverSettingsProperty.FindPropertyRelative("serverURL");
gameId = serverSettingsProperty.FindPropertyRelative("gameId");
gameKey = serverSettingsProperty.FindPropertyRelative("gameKey");
languageCode = serverSettingsProperty.FindPropertyRelative("languageCode");
+ useCommandLineArgumentOverrides = serverSettingsProperty.FindPropertyRelative("useCommandLineArgumentOverrides");
+
+ var uiSettingsProperty = serializedObject.FindProperty("uiSettings");
+
+ _showMonetizationUIProperty = uiSettingsProperty.FindPropertyRelative("ShowMonetizationUI");
+ _showEnabledModToggleProperty = uiSettingsProperty.FindPropertyRelative("ShowEnabledModToggle");
}
public override void OnInspectorGUI()
- {
+ {
//Grab any changes to the original object data
serializedObject.UpdateIfRequiredOrScript();
SettingsAsset myTarget = (SettingsAsset)target;
- base.OnInspectorGUI();
+ DrawPropertiesExcluding(serializedObject, "m_Script");
- EditorGUILayout.Space();
+ EditorGUILayout.Space();
- GUIStyle labelStyle = new GUIStyle();
- labelStyle.alignment = TextAnchor.MiddleCenter;
- labelStyle.fontStyle = FontStyle.Bold;
- labelStyle.normal.textColor = Color.white;
+ GUIStyle labelStyle = new GUIStyle
+ {
+ alignment = TextAnchor.MiddleCenter,
+ fontStyle = FontStyle.Bold,
+ normal =
+ {
+ textColor = Color.white,
+ },
+ };
- EditorGUILayout.LabelField("Server Settings", labelStyle);
+ EditorGUILayout.LabelField("Server Settings", labelStyle);
EditorGUILayout.Space();
- EditorGUILayout.PropertyField(gameId,new GUIContent("Game ID"));
- gameKey.stringValue = EditorGUILayout.PasswordField("API Key", gameKey.stringValue);
+ EditorGUILayout.DelayedIntField(gameId, new GUIContent("Game ID"));
+ using (EditorGUI.ChangeCheckScope passwordChange = new EditorGUI.ChangeCheckScope())
+ {
+ string tempPassword = EditorGUILayout.PasswordField("API Key", gameKey.stringValue);
+ if (passwordChange.changed)
+ gameKey.stringValue = tempPassword;
+ }
- if(myTarget.serverSettings.gameId == 0 || string.IsNullOrWhiteSpace(myTarget.serverSettings.gameKey))
+ if (myTarget.serverSettings.gameId == 0 || string.IsNullOrWhiteSpace(myTarget.serverSettings.gameKey))
{
EditorGUILayout.Space();
@@ -76,11 +95,14 @@ public override void OnInspectorGUI()
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space();
- } else {
+ }
+ else
+ {
EditorGUILayout.Space();
- EditorGUILayout.PropertyField(serverURL, new GUIContent("Server URL"));
- EditorGUILayout.PropertyField(languageCode, new GUIContent("Language code"));
+ EditorGUILayout.DelayedTextField(serverURL, new GUIContent("Server URL"));
+ EditorGUILayout.DelayedTextField(languageCode, new GUIContent("Language code"));
+ EditorGUILayout.PropertyField(useCommandLineArgumentOverrides, new GUIContent("Use Command Line Argument Override"));
EditorGUILayout.Space();
@@ -96,16 +118,23 @@ public override void OnInspectorGUI()
}
// If the gameId has been changed, update the url
- if (gameId.intValue != previousGameId)
+ if (gameId.intValue != previousGameId)
{
if (IsURLProduction(serverURL.stringValue))
serverURL.stringValue = GetURLProduction(gameId.intValue);
- previousGameId = gameId.intValue;
- }
+ previousGameId = gameId.intValue;
+ }
+
+ EditorGUILayout.Space();
+ EditorGUILayout.LabelField("UI Settings", labelStyle);
+
+ EditorGUILayout.PropertyField(_showMonetizationUIProperty);
+ EditorGUILayout.PropertyField(_showEnabledModToggleProperty);
//Save the new values
serializedObject.ApplyModifiedProperties();
+ AssetDatabase.SaveAssetIfDirty(serializedObject?.targetObject);
return;
@@ -123,6 +152,7 @@ void SetURLTest()
}
internal static string GetURLProduction(int gameId) => $"https://g-{gameId}.modapi.io/v1";
+
static string GetURLTest(int gameId) => "https://api.test.mod.io/v1";
static bool IsURLProduction(string url) => Regex.IsMatch(url, @"https:\/\/g-\d*.modapi.io\/v1");
diff --git a/Experimental.meta b/Experimental.meta
new file mode 100644
index 0000000..7ad13c2
--- /dev/null
+++ b/Experimental.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: f8d3bc05a3a2cf14bac6b730941c412b
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Experimental/modio-ui.unitypackage b/Experimental/modio-ui.unitypackage
new file mode 100644
index 0000000..5c7a31e
Binary files /dev/null and b/Experimental/modio-ui.unitypackage differ
diff --git a/Experimental/modio-ui.unitypackage.meta b/Experimental/modio-ui.unitypackage.meta
new file mode 100644
index 0000000..fd4af56
--- /dev/null
+++ b/Experimental/modio-ui.unitypackage.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 035d155cfa7885544a15ee38ad359565
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Platform/Steam/Facepunch.meta b/Platform/Steam/Facepunch.meta
new file mode 100644
index 0000000..7ccc3b7
--- /dev/null
+++ b/Platform/Steam/Facepunch.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 586739700705453bbc54d537c51bdceb
+timeCreated: 1721713416
\ No newline at end of file
diff --git a/Platform/Steam/Facepunch/ModioPlatformExampleFacepunch.cs b/Platform/Steam/Facepunch/ModioPlatformExampleFacepunch.cs
new file mode 100644
index 0000000..e573bca
--- /dev/null
+++ b/Platform/Steam/Facepunch/ModioPlatformExampleFacepunch.cs
@@ -0,0 +1,54 @@
+#if UNITY_FACEPUNCH
+using Steamworks;
+#endif
+using UnityEngine;
+
+namespace ModIO.Implementation.Platform
+{
+ public class ModioPlatformExampleFacepunch : MonoBehaviour
+ {
+ [SerializeField] int appId;
+
+ void Awake()
+ {
+ bool supportedPlatform = !Application.isConsolePlatform;
+
+#if !UNITY_FACEPUNCH
+ supportedPlatform = false;
+#endif
+
+ if (!supportedPlatform)
+ {
+ Destroy(this);
+ return;
+ }
+
+#if UNITY_FACEPUNCH
+ try
+ {
+ SteamClient.Init((uint)appId, true);
+ }
+ catch (System.Exception e)
+ {
+ Debug.Log(e);
+ return;
+ }
+#endif
+
+ // --- This is the important line to include in your own implementation ---
+ ModioPlatformFacepunch.SetAsPlatform();
+ }
+
+#if UNITY_FACEPUNCH
+ void OnDisable()
+ {
+ SteamClient.Shutdown();
+ }
+
+ void Update()
+ {
+ SteamClient.RunCallbacks();
+ }
+#endif
+ }
+}
diff --git a/Platform/Steam/Facepunch/ModioPlatformExampleFacepunch.cs.meta b/Platform/Steam/Facepunch/ModioPlatformExampleFacepunch.cs.meta
new file mode 100644
index 0000000..3568bc7
--- /dev/null
+++ b/Platform/Steam/Facepunch/ModioPlatformExampleFacepunch.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: bd773d0afa144083a32a50f7fcfce3bc
+timeCreated: 1721708917
\ No newline at end of file
diff --git a/Platform/Steam/Facepunch/ModioPlatformFacepunch.cs b/Platform/Steam/Facepunch/ModioPlatformFacepunch.cs
new file mode 100644
index 0000000..d359fdd
--- /dev/null
+++ b/Platform/Steam/Facepunch/ModioPlatformFacepunch.cs
@@ -0,0 +1,72 @@
+using System;
+using System.Threading.Tasks;
+using UnityEngine;
+
+#if UNITY_FACEPUNCH
+using Steamworks;
+#endif
+
+namespace ModIO.Implementation.Platform
+{
+ public class ModioPlatformFacepunch : ModioPlatform, IModioSsoPlatform
+ {
+ public static void SetAsPlatform()
+ {
+ ActivePlatform = new ModioPlatformFacepunch();
+ }
+
+ public async void PerformSso(TermsHash? displayedTerms, Action onComplete, string optionalThirdPartyEmailAddressUsedForAuthentication = null)
+ {
+#if UNITY_FACEPUNCH
+
+ byte[] encryptedAppTicket = await SteamUser.RequestEncryptedAppTicketAsync();
+ string base64Ticket = Util.Utility.EncodeEncryptedSteamAppTicket(encryptedAppTicket, (uint)encryptedAppTicket.Length);
+
+ ModIOUnity.AuthenticateUserViaSteam(base64Ticket,
+ optionalThirdPartyEmailAddressUsedForAuthentication,
+ displayedTerms,
+ result =>
+ {
+ onComplete(result.Succeeded());
+ });
+#endif
+ }
+
+ public override async Task OpenPlatformPurchaseFlow()
+ {
+#if UNITY_FACEPUNCH
+ SteamFriends.OpenStoreOverlay(SteamClient.AppId);
+
+ float timeoutAt = Time.unscaledTime + 5f;
+ while (!SteamUtils.IsOverlayEnabled && Time.unscaledTime < timeoutAt)
+ {
+ await Task.Yield();
+ }
+
+ if (!SteamUtils.IsOverlayEnabled)
+ {
+ Logger.Log(LogLevel.Error, "Steam overlay never opened");
+ return ResultBuilder.Unknown;
+ }
+
+ while (SteamUtils.IsOverlayEnabled)
+ {
+ await Task.Yield();
+ }
+
+ return ResultBuilder.Success;
+#else
+ return ResultBuilder.Unknown;
+#endif
+ }
+
+ public override void OpenWebPage(string url)
+ {
+#if UNITY_FACEPUNCH
+ SteamFriends.OpenWebOverlay(url);
+#else
+ base.OpenWebPage(url);
+#endif
+ }
+ }
+}
diff --git a/Platform/Steam/Facepunch/ModioPlatformFacepunch.cs.meta b/Platform/Steam/Facepunch/ModioPlatformFacepunch.cs.meta
new file mode 100644
index 0000000..84e2526
--- /dev/null
+++ b/Platform/Steam/Facepunch/ModioPlatformFacepunch.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 7f6005548d8c4d3eabd30a125107fde3
+timeCreated: 1721708223
\ No newline at end of file
diff --git a/Platform/Steam/Facepunch/Resources.meta b/Platform/Steam/Facepunch/Resources.meta
new file mode 100644
index 0000000..8067606
--- /dev/null
+++ b/Platform/Steam/Facepunch/Resources.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 6a55c9f139a4d3a4e808940fee50440e
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Platform/Steam/Facepunch/Resources/ModioPlatformExampleFacepunch.prefab b/Platform/Steam/Facepunch/Resources/ModioPlatformExampleFacepunch.prefab
new file mode 100644
index 0000000..07a30a5
--- /dev/null
+++ b/Platform/Steam/Facepunch/Resources/ModioPlatformExampleFacepunch.prefab
@@ -0,0 +1,46 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!1 &890429863644987089
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 24952581316947064}
+ - component: {fileID: 6862582328560317445}
+ m_Layer: 0
+ m_Name: ModioPlatformExampleFacepunch
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!4 &24952581316947064
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 890429863644987089}
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 0, y: 0, z: 0}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_Children: []
+ m_Father: {fileID: 0}
+ m_RootOrder: 0
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!114 &6862582328560317445
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 890429863644987089}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: bd773d0afa144083a32a50f7fcfce3bc, type: 3}
+ m_Name:
+ m_EditorClassIdentifier:
+ appId: 1550360
diff --git a/Platform/Steam/Facepunch/Resources/ModioPlatformExampleFacepunch.prefab.meta b/Platform/Steam/Facepunch/Resources/ModioPlatformExampleFacepunch.prefab.meta
new file mode 100644
index 0000000..f1aaeff
--- /dev/null
+++ b/Platform/Steam/Facepunch/Resources/ModioPlatformExampleFacepunch.prefab.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: b8db8dd0830e763498d09d261fa977b3
+PrefabImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Platform/Steam/Steam Marketplace.md b/Platform/Steam/Steam Marketplace.md
index ee3c10e..71a150b 100644
--- a/Platform/Steam/Steam Marketplace.md
+++ b/Platform/Steam/Steam Marketplace.md
@@ -2,7 +2,7 @@
id: unity-steam-marketplace-example
title: Unity Steam Marketplace Example
sidebar_label: Unity Steam Marketplace Example
-slug: /unity-plugin/unity-steam-marketplace-example
+slug: /unity/unity-steam-marketplace-example/
sidebar_position: 7
---
diff --git a/Platform/SystemIO/ModIO.Implementation.Platform/SystemIODataService.cs b/Platform/SystemIO/ModIO.Implementation.Platform/SystemIODataService.cs
index 55a289c..26c0206 100644
--- a/Platform/SystemIO/ModIO.Implementation.Platform/SystemIODataService.cs
+++ b/Platform/SystemIO/ModIO.Implementation.Platform/SystemIODataService.cs
@@ -256,7 +256,7 @@ public bool TryCreateParentDirectory(string path)
//TODO: Write native code to properly check for disk space for ILLCPP builds
public async Task IsThereEnoughDiskSpaceFor(long bytes)
{
-#if !ENABLE_IL2CPP
+#if ENABLE_IL2CPP
#if UNITY_ANDROID
AndroidJNI.AttachCurrentThread();
var statFs = new AndroidJavaObject("android.os.StatFs", PersistentDataRootDirectory);
diff --git a/README.md b/README.md
index 2ee5c6e..0a08e20 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,19 @@
-
-# mod.io Unity Plugin v2024.3.1
+---
+id: unity-introduction
+title: Unity Introduction
+sidebar_label: Unity Introduction
+slug: /unity/unity-introduction/
+sidebar_position: 0
+---
+
+
+# mod.io Unity Plugin v2024.7.1
[![License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](https://github.com/modio/modio-unity/blob/master/LICENSE)
[![Discord](https://img.shields.io/discord/389039439487434752.svg?label=Discord&logo=discord&color=7289DA&labelColor=2C2F33)](https://discord.mod.io)
-[![Master docs](https://img.shields.io/badge/docs-master-green.svg)](https://docs.mod.io/unity)
-[![Unity](https://img.shields.io/badge/Unity-2020.3+-lightgrey.svg)](https://assetstore.unity.com/packages/tools/integration/mod-browser-manager-by-mod-io-138866)
+[![Master docs](https://img.shields.io/badge/docs-master-green.svg)](https://docs.mod.io/unity/)
+[![Unity 3D](https://img.shields.io/badge/Unity-2020.3+-lightgrey.svg)](https://unity3d.com)
-Welcome to the mod.io Unity Engine plugin repository!
+Welcome to the mod.io Unity Engine plugin [repository](https://github.com/modio/modio-unity)!
mod.io enables game developers of all sizes to integrate user-generated content directly into their games quickly and easily. This includes hosting, user profiles and subscriptions, moderation tools, file delivery, and *more*:
@@ -22,19 +30,18 @@ A custom built [ready-made UI](#browser-ui) for mod discovery is included, along
## Platform Support
To access console platforms and documentation, see [Supporting Console Platforms](https://docs.mod.io/platforms/).
-| Platform | Support |
-|-------------------|:-------:|
-| Windows | ✓ |
-| macOS | ✓ |
-| Linux | ✓ |
-| Xbox One | ✓ |
-| Xbox Series X | ✓ |
-| PlayStation®4 | ✓ |
-| PlayStation®5 | ✓ |
-| Nintendo Switch | ✓ |
-| iOS | ✓ |
-| Android | ✓ |
-
+| Platform | Support |
+|-----------------|:-------:|
+| Windows | ✓ |
+| macOS | ✓ |
+| Linux | ✓ |
+| Xbox One | ✓ |
+| Xbox Series X | ✓ |
+| PlayStation 4 | ✓ |
+| PlayStation 5 | ✓ |
+| Nintendo Switch | ✓ |
+| iOS | ✓ |
+| Android | ✓ |
## Game Studios and Publishers
If you need assistance with first-party approval, or require a private, white-label UGC solution. [Contact us](mailto:developers@mod.io)!
@@ -1248,93 +1255,225 @@ If you want a fuller understanding of the plugin and its features, we recommend
+### Terms of Use Localization and RTL Languages
+
+When a user authenticates, the Browser UI will localize mod.io's terms of use based on the language code set in your config file (`Tools > mod.io > Edit Settings`, or `Settings.server.languageCode`).
+
+To avoid the plugin conflicting with an existing solution, in the case of right-to-left languages you will need to apply your current implementation for mixed RTL and LTR text to the terms text-elements in the browser.
+
# Marketplace
-The mod.io SDK supports full monetization features, allowing you to sell a per-game virtual currency to your players that
-they can use to purchase mods, with a share of the revenue split between creators and your studio. Every platform
-requires specific setup for monetization features to work, with regards to the virtual currency configuration and API
-calls. The following documentation walks you through the setup process and gives example usages. The mod.io monetization
-features are enabled as part of the onboarding process on your game profile. Once that is setup, there is nothing
-further you need to do for initialization in the SDK.
+The mod.io SDK supports full monetization features, allowing you to sell a per-game virtual currency to your players that they can use to purchase mods, with a share of the revenue split between creators and your studio. Every platform requires specific setup for monetization features to work, with regards to the virtual currency configuration and API calls.
+
+The following documentation walks you through the setup process and gives example usages. The mod.io monetization features are enabled as part of the onboarding process on your game profile. Once that is setup, there is nothing further you need to do for initialization in the SDK.
### Enable Marketplace in the Plugin
The first thing you will need to do is enable the marketplace in the mod.io portal for your game under Admin->Monetization->Settings->Enable Marketplace.
### Get User Wallet Balance
-Returns the current user's token balance
+Returns the current user's token balance.
+
+> [!NOTE]
+> This function creates a wallet for the user the first time it is called so this must be called before any sync entitlements calls.
```csharp
async void GetUserWalletBalanceExample()
{
- var response = await ModIOUnityAsync.GetUserWalletBalance();
- if (response.result.Succeeded())
- {
- Debug.Log($"User has a balance of {response.value.balance } tokens.");
- }
- else
- {
- Debug.Log("failed to get balance");
- }
+ var response = await ModIOUnityAsync.GetUserWalletBalance();
+ if (response.result.Succeeded())
+ Debug.Log($"User has a balance of {response.value.balance} tokens.");
+ else
+ Debug.Log("failed to get balance");
}
```
### Purchase Item
-Purchases a mod using Tokens
+Purchases a mod using tokens.
```csharp
async void PurchaseItemExample()
{
- string idempotent = $"aUniqueKey";//Unique key used to prevent duplicate purchases
- ModId modId = new ModId(1234);//Mod to purchase
- int displayAmount = 12;//Price displayed to the player (Must match mod price)
- var response = await ModIOUnityAsync.PurchaseItem(modId, displayAmount, idempotent);
+ ModId modId = new ModId(1234); // Mod to purchase
+ int displayedAmount = 12; // Price displayed to the player (must match mod price)
+ string idempotent = $"aUniqueKey"; // Unique key used to prevent duplicate purchases
+
+ var response = await ModIOUnityAsync.PurchaseItem(modId, displayedAmount, idempotent);
if (response.result.Succeeded())
- {
Debug.Log("Completed Purchase");
- }
else
- {
Debug.Log("failed to complete purchase");
- }
}
```
### Get User Purchases
-Returns the current user's purchased Mods
+Returns the current user's purchased mods.
```csharp
async void GetUserPurchases()
{
- ModIOUnity.GetPurchasedMods(out Result result);
+ ModProfile[] purchased = ModIOUnity.GetPurchasedMods(out Result result);
+ if (result.Succeeded())
+ foreach (ModProfile mod in purchased)
+ Debug.Log($"User owns mod with id: {mod.id}");
+ else
+ Debug.Log("Failed to get purchases");
+}
+```
+
+### Syncing Purchases with Steam
+> [!NOTE]
+> Setup token pack SKUs from your game's mod.io website dashboard by navigating to `Admin -> Monetization -> Manage SKUs`.
+
+> [!NOTE]
+> > The GetUserWalletBalanceExample function creates a wallet for the user the first time it is called so this must be called before any sync entitlements calls.
+
+Once you have setup SKUs for your users to purchase tokens through Steam, you can sync these purchases with the mod.io server using the `SyncEntitlments()` method.
+
+After a user purchases a token pack on Steam, calling `SyncEntitlements()` will consume the purchased item, and add those tokens to the user's wallet. Below is a very simple example of how to use the method:
+
+> [!WARNING]
+> It is highly recommended that you call `SyncEntitlements()` after any successful external purchase.
+
+```csharp
+async void SyncEntitlements()
+{
+ Result result = await ModIOUnityAsync.SyncEntitlements();
+ if (response.result.Succeeded())
+ Debug.Log("Entitlements are synced");
+ else
+ Debug.Log("Failed to sync entitlements");
+}
+```
+
+> [!NOTE]
+> `SyncEntitlements()` is automatically run during `ModIOUnity.FetchUpdates()`.
+
+> [!NOTE]
+> `SyncEntitlements()` can also be used for consuming purchases on console platforms.
+
+# Service to Service API
+To facilitate HTTP requests to mod.io's Service To Service (S2S) API's, your backend server must first authenticate and generate
+credentials that your backend service will use. Credentials required by S2S API's are separate from mod.io's public API
+endpoints and cannot be used interchangeably.
+
+### Requesting a User Delegation Token
+Some service-to-service endpoints require user context to be able to make requests on behalf of a user, such as creating a transaction.
+To facilitate this, mod.io hosts a public endpoint which can be called by an authenticated user with their bearer token which returns
+what we call a User Delegation Token. This token should then be sent to your secure backend server from your game client, where you
+can then use it for specific endpoints in conjunction with a valid service token.
+
+> [!NOTE]
+> User must me authenticated to request a User Delegation Token.
+
+```csharp
+async void Example()
+{
+ ResultAnd response = await ModIOUnityImplementation.RequestUserDelegationToken();
+
+ if (response.result.Succeeded())
+ {
+ Debug.Log("successful.");
+ //TODO: Send response.value.token to server
+ }
+ else
+ {
+ Debug.Log("failed.");
+ }
+ }
+```
+# Temp Mod Sets
+
+Temp Mod sets allow users to download mods that they are not subscribed to. This can be helpful in multiplayer situations, when a player might join a game that requires specific mods to be downloaded.
+The intended flow in this situation would be to Create a temp mod set when a player joins the game and Delete the temp mod set when the game is over.
+The following documentation walks you through the setup process and gives example usages.
+
+Creating a temp mod set starts to download mods in the set in a temporary location unassociated with their subscribed mods.
+
+> [!NOTE]
+> Mods that the user is subscribed to will be not be re-downloaded and will remain in the installed mods location for that user.
+
+```csharp
+ModId[] modIds;
+void Example()
+{
+ Result result = await ModIOUnityAsync.CreateTempModSet(modIds);
if (result.Succeeded())
{
- foreach (var modProfile in response.value.modProfiles)
- {
- Debug.Log($"User owns mod with id: {modProfile.id}");
- }
+ Debug.Log("Successful");
}
else
{
- Debug.Log("Failed to get purchases");
+ Debug.Log("Failed");
}
}
```
-### Syncing Purchases with Steam
-If you setup SKUs for your users to purchase tokens through steam, you can sync these purchases with the mod.io server with the `SyncEntitlments` method. If a user purchases a token pack on steam, you can add the SKU used for that token pack on the Web by going to Admin > Monetization > Manage SKUs. Then when you use SyncEntitlments it will consume the purchased item and add those tokens to the user's wallet. Below is a very simple example of how to use the method.
+Destroying a temp mod set removes the temporary installed mods in that set and allows for uninstallation of temporary mods.
+
+```csharp
+void Example()
+{
+ Result result = await ModIOUnityAsync.ModIOUnity.DeleteTempModSet();
+ if (result.Succeeded())
+ {
+ Debug.Log("Successful");
+ }
+ else
+ {
+ Debug.Log("Failed");
+ }
+}
+```
+
+Adds mods to an existing Temp mod set and downloads/installs them if needed.
> [!NOTE]
-> SyncEntitlements will automatically be run when using ModIOUnity.FetchUpdates as well
+> Mods that the user is subscribed to will be not be re-downloaded and will remain in the installed mods location for that user.
```csharp
-async void SyncEntitlements()
+ModId[] modIds;
+void Example()
+{
+ Result result = await ModIOUnityAsync.AddModToTempModSet(modIds);
+ if (result.Succeeded())
{
- Result result = await ModIOUnityAsync.SyncEntitlements();
- if (response.result.Succeeded())
- {
- Debug.Log("Entitlements are synced");
- }
- else
- {
- Debug.Log("failed to sync");
- }
- }
+ Debug.Log("Successful");
+ }
+ else
+ {
+ Debug.Log("Failed");
+ }
+}
```
+
+Removes mods from an existing Temporary Mod Set. This removes them from the list in the set but does not uninstall them, that is done when the set is destroyed.
+```csharp
+ModId[] modIds;
+void Example()
+{
+ Result result = await ModIOUnityAsync.RemoveModsFromTempModSet(modIds);
+ if (result.Succeeded())
+ {
+ Debug.Log("Successful");
+ }
+ else
+ {
+ Debug.Log("Failed");
+ }
+}
+```
+
+Gets an array of temp mods that are installed on the current device.
+
> [!NOTE]
-> This method will also work with console platforms
+> These will not be subscribed by the current user. If you wish to get all the current user's installed mods use ModIOUnity.GetSubscribedMods() and check the SubscribedMod.status equals SubscribedModStatus.Installed.
+```csharp
+void Example()
+{
+ InstalledMod[] mods = await ModIOUnityAsync.GetTempSystemInstalledMods(out Result result);
+ if (result.Succeeded())
+ {
+ Debug.Log("found " + mods.Length.ToString() + " temp mods installed");
+ }
+ else
+ {
+ Debug.Log("failed to get temp installed mods");
+ }
+}
+```
\ No newline at end of file
diff --git a/Runtime/Classes/ProgressHandle.cs b/Runtime/Classes/ProgressHandle.cs
index 021ed1a..efde247 100644
--- a/Runtime/Classes/ProgressHandle.cs
+++ b/Runtime/Classes/ProgressHandle.cs
@@ -1,5 +1,4 @@
-
-namespace ModIO
+namespace ModIO
{
///
@@ -16,7 +15,7 @@ public partial class ProgressHandle
/// The ModId of the mod that this operation pertains to.
///
public ModId modId { get; internal set; }
-
+
///
/// The type of operation being performed, eg. Download, Upload, Install
///
@@ -26,7 +25,7 @@ public partial class ProgressHandle
/// The progress of the operation being performed, float range from 0.0f - 1.0f
///
- public float Progress { get; internal set; }
+ public float Progress { get; internal set; }
///
/// The average number of bytes being processed per second by the operation
@@ -47,5 +46,9 @@ public partial class ProgressHandle
///
public bool Failed { get; internal set; }
+ public int UiHashCode()
+ {
+ return modId.id.GetHashCode() + OperationType.GetHashCode() + Progress.GetHashCode() + BytesPerSecond.GetHashCode() + Completed.GetHashCode() + Failed.GetHashCode();
+ }
}
}
diff --git a/Runtime/Classes/Report.cs b/Runtime/Classes/Report.cs
index e57dffd..d5cffb8 100644
--- a/Runtime/Classes/Report.cs
+++ b/Runtime/Classes/Report.cs
@@ -8,6 +8,11 @@ namespace ModIO
///
public class Report
{
+ public Report()
+ {
+ resourceType = ReportResourceType.Mods;
+ }
+
///
/// convenience constructor for making a report. All of the parameters are mandatory to make
/// a successful report.
@@ -17,12 +22,10 @@ public class Report
/// CANNOT BE NULL explanation of the issue being reported
/// CANNOT BE NULL user reporting the issue
/// CANNOT BE NULL user email address
- public Report(ModId modId, ReportType type, string summary, string user,
- string contactEmail)
- {
+ public Report(ModId modId, ReportType type, string summary, string user, string contactEmail) : base()
+ {
id = modId;
- this.type = type;
- resourceType = ReportResourceType.Mods;
+ this.type = type;
this.summary = summary;
this.user = user;
this.contactEmail = contactEmail;
@@ -37,8 +40,7 @@ public Report(ModId modId, ReportType type, string summary, string user,
public bool CanSend()
{
- if(id == null || summary == null || type == null || resourceType == null || user == null
- || contactEmail == null)
+ if(id == null || summary == null || type == null || resourceType == null || contactEmail == null)
{
return false;
}
diff --git a/Runtime/Classes/SearchFilter.cs b/Runtime/Classes/SearchFilter.cs
index 9c5c567..853a45e 100644
--- a/Runtime/Classes/SearchFilter.cs
+++ b/Runtime/Classes/SearchFilter.cs
@@ -65,6 +65,7 @@ public RevenueType RevenueType
}
public void ShowMatureContent(bool value) => showMatureContent = value;
+ public bool GetShowMatureContent() => showMatureContent;
///
/// Adds a phrase into the filter to be used when filtering mods in a request.
@@ -73,39 +74,13 @@ public RevenueType RevenueType
/// (Optional) type of filter to be used with the text, defaults to Full text search
public void AddSearchPhrase(string phrase, FilterType filterType = FilterType.FullTextSearch)
{
- string url = string.Empty;
- switch (filterType)
- {
- case FilterType.FullTextSearch:
- url += $"&{Filtering.FullTextSearch}{phrase}";
- break;
- case FilterType.NotEqualTo:
- url += $"&{Filtering.NotEqualTo}{phrase}";
- break;
- case FilterType.Like:
- url += $"&{Filtering.Like}{phrase}";
- break;
- case FilterType.NotLike:
- url += $"&{Filtering.NotLike}{phrase}";
- break;
- case FilterType.In:
- url += $"&{Filtering.In}{phrase}";
- break;
- case FilterType.NotIn:
- url += $"&{Filtering.NotIn}{phrase}";
- break;
- case FilterType.Max:
- url += $"&{Filtering.Max}{phrase}";
- break;
- case FilterType.Min:
- url += $"&{Filtering.Min}{phrase}";
- break;
- case FilterType.BitwiseAnd:
- url += $"&{Filtering.BitwiseAnd}{phrase}";
- break;
- default:
- break;
- }
+ //Don't add a search phrase if it's empty as the server will ignore it anyway
+ if(string.IsNullOrEmpty(phrase))
+ return;
+
+ var filterText = GetFilterPrefix(filterType);
+
+ var url = !string.IsNullOrEmpty(filterText) ? $"{filterText}{phrase}" : string.Empty;
if (searchPhrases.ContainsKey(filterType))
{
@@ -117,6 +92,44 @@ public void AddSearchPhrase(string phrase, FilterType filterType = FilterType.Fu
}
}
+ public void ClearSearchPhrases()
+ {
+ searchPhrases.Clear();
+ }
+
+ public void ClearSearchPhrases(FilterType filterType)
+ {
+ searchPhrases.Remove(filterType);
+ }
+
+ public string[] GetSearchPhrase(FilterType filterType)
+ {
+ searchPhrases.TryGetValue(filterType, out var value);
+ if(string.IsNullOrEmpty(value))
+ return Array.Empty();
+
+ var filterText = GetFilterPrefix(filterType);
+ return value.Split(new []{filterText}, StringSplitOptions.RemoveEmptyEntries);
+ }
+
+ static string GetFilterPrefix(FilterType filterType)
+ {
+ string filterText = filterType switch
+ {
+ FilterType.FullTextSearch => Filtering.FullTextSearch,
+ FilterType.NotEqualTo => Filtering.NotEqualTo,
+ FilterType.Like => Filtering.Like,
+ FilterType.NotLike => Filtering.NotLike,
+ FilterType.In => Filtering.In,
+ FilterType.NotIn => Filtering.NotIn,
+ FilterType.Max => Filtering.Max,
+ FilterType.Min => Filtering.Min,
+ FilterType.BitwiseAnd => Filtering.BitwiseAnd,
+ _ => null,
+ };
+ return filterText != null ? $"&{filterText}" : string.Empty;
+ }
+
///
/// Adds a tag to be used in filtering mods for a request.
///
@@ -128,6 +141,21 @@ public void AddTag(string tag)
tags.Add(tag);
}
+ ///
+ /// Adds multiple tags used in filtering mods for a request.
+ ///
+ /// the tags to be added to the filter
+ ///
+ ///
+ public void AddTags(IEnumerable tags) => this.tags.AddRange(tags);
+
+ public void ClearTags()
+ {
+ tags.Clear();
+ }
+
+ public IEnumerable GetTags => tags;
+
///
/// Determines what category mods should be sorted and returned by. eg if the category
/// SortModsBy.Downloads was used, then the results would be returned by the number of
@@ -183,6 +211,11 @@ public void AddUser(long userId)
users.Add(userId);
}
+ public IReadOnlyList GetUserIds()
+ {
+ return users;
+ }
+
///
/// You can use this method to check if a search filter is setup correctly before using it
/// in a GetMods request.
@@ -202,5 +235,18 @@ public bool IsSearchFilterValid(out Result result)
return true;
}
+
+
+ ///
+ /// Use this method to fetch the page index
+ ///
+ /// Returns the current value of the page index
+ public int GetPageIndex() => pageIndex;
+
+ ///
+ /// Use this method to fetch the page size
+ ///
+ /// Returns the current value of the page size
+ public int GetPageSize() => pageSize;
}
}
diff --git a/Runtime/Enums/AuthenticationServiceProvider.cs b/Runtime/Enums/AuthenticationServiceProvider.cs
index aa0d695..d9a4e0e 100644
--- a/Runtime/Enums/AuthenticationServiceProvider.cs
+++ b/Runtime/Enums/AuthenticationServiceProvider.cs
@@ -13,6 +13,7 @@ public enum AuthenticationServiceProvider
Google,
PlayStation,
OpenId,
+ AppleId,
None,
}
@@ -51,6 +52,9 @@ public static string GetProviderName(this AuthenticationServiceProvider provider
case AuthenticationServiceProvider.Google:
providerName = "googleauth";
break;
+ case AuthenticationServiceProvider.AppleId:
+ providerName = "appleauth";
+ break;
case AuthenticationServiceProvider.PlayStation:
providerName = "psnauth";
break;
@@ -98,6 +102,9 @@ public static string GetTokenFieldName(this AuthenticationServiceProvider provid
case AuthenticationServiceProvider.Google:
tokenFieldName = "id_token";
break;
+ case AuthenticationServiceProvider.AppleId:
+ tokenFieldName = "id_token";
+ break;
case AuthenticationServiceProvider.PlayStation:
tokenFieldName = "auth_code";
break;
diff --git a/Runtime/Enums/ModPriority.cs b/Runtime/Enums/ModPriority.cs
new file mode 100644
index 0000000..dcecbef
--- /dev/null
+++ b/Runtime/Enums/ModPriority.cs
@@ -0,0 +1,10 @@
+namespace Runtime.Enums
+{
+ public enum ModPriority
+ {
+ Low = -100,
+ Normal = 0,
+ High = 100,
+ Urgent = 200,
+ }
+}
diff --git a/Runtime/Enums/ModPriority.cs.meta b/Runtime/Enums/ModPriority.cs.meta
new file mode 100644
index 0000000..7644bd5
--- /dev/null
+++ b/Runtime/Enums/ModPriority.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 8b5f330a8405847cb81e84f6f5ffe34c
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/Enums/SortModsBy.cs b/Runtime/Enums/SortModsBy.cs
index e75a629..48d2a40 100644
--- a/Runtime/Enums/SortModsBy.cs
+++ b/Runtime/Enums/SortModsBy.cs
@@ -16,6 +16,6 @@ public enum SortModsBy
Popular,
Downloads,
Subscribers,
- DateSubmitted
+ DateSubmitted,
}
}
diff --git a/Runtime/Enums/SubscribedModStatus.cs b/Runtime/Enums/SubscribedModStatus.cs
index dcf51d7..7430644 100644
--- a/Runtime/Enums/SubscribedModStatus.cs
+++ b/Runtime/Enums/SubscribedModStatus.cs
@@ -1,4 +1,6 @@
-namespace ModIO
+using System;
+
+namespace ModIO
{
///
/// The current state of a subscribed mod. Useful for checking whether or not a mod has been
@@ -19,4 +21,33 @@ public enum SubscribedModStatus
ProblemOccurred,
None,
}
+
+ public static class SubscribedModStatusExtensions
+ {
+ public static bool IsSubscribed(this SubscribedModStatus value)
+ {
+ switch(value)
+ {
+ case SubscribedModStatus.Installed:
+ case SubscribedModStatus.WaitingToDownload:
+ case SubscribedModStatus.WaitingToInstall:
+ case SubscribedModStatus.WaitingToUpdate:
+ case SubscribedModStatus.Downloading:
+ case SubscribedModStatus.Installing:
+ case SubscribedModStatus.Updating:
+ case SubscribedModStatus.None:
+ return true;
+
+ case SubscribedModStatus.WaitingToUninstall:
+ case SubscribedModStatus.Uninstalling:
+ case SubscribedModStatus.ProblemOccurred:
+ return false;
+
+ default:
+ break;
+ }
+
+ throw new NotImplementedException($"Unable to translate {value} of SubscribedMod");
+ }
+ }
}
diff --git a/Runtime/ModIO.Implementation/Classes/ExtractOperation.cs b/Runtime/ModIO.Implementation/Classes/ExtractOperation.cs
index 44ca8b9..008bac8 100644
--- a/Runtime/ModIO.Implementation/Classes/ExtractOperation.cs
+++ b/Runtime/ModIO.Implementation/Classes/ExtractOperation.cs
@@ -37,14 +37,16 @@ public async Task Extract()
async Task ExtractAll()
{
Logger.Log(LogLevel.Verbose, $"EXTRACTING [{modId}_{fileId}]");
-
+
+ DataStorage.DeleteExtractionDirectory(modId);
+
// First we need to check that we have enough disk space to complete this operation
Result result = await IsThereEnoughSpaceForExtracting();
if(!result.Succeeded())
{
return result;
}
-
+
using(Stream fileStream = DataStorage.OpenArchiveReadStream(modId, fileId, out result))
{
if(result.Succeeded())
@@ -78,7 +80,7 @@ async Task ExtractAll()
}
using(Stream streamWriter =
- DataStorage.OpenArchiveEntryOutputStream(entry.Name,
+ DataStorage.OpenArchiveEntryOutputStream(modId, entry.Name,
out result))
{
if(result.Succeeded())
@@ -159,7 +161,8 @@ async Task ExtractAll()
//--------------------------------------------------------------------------------------
// FINISH and/or CLEANUP
-
+ DataStorage.TryDeleteModfileArchive(modId, fileId, out _);
+
if(cancel)
{
return CancelAndCleanup(result);
@@ -175,6 +178,8 @@ Result CancelAndCleanup(Result result)
Logger.Log(LogLevel.Verbose,
$"FAILED EXTRACTION [{result.code}] MODFILE [{modId}_{fileId}]");
+ DataStorage.DeleteExtractionDirectory(modId);
+
// Delete any files we may have already extracted
DataStorage.TryDeleteInstalledMod(modId, fileId, out result);
@@ -187,7 +192,7 @@ Result CancelAndCleanup(Result result)
return result;
}
- async Task IsThereEnoughSpaceForExtracting()
+ internal async Task IsThereEnoughSpaceForExtracting()
{
// Get the extracted size first
using(Stream fileStream = DataStorage.OpenArchiveReadStream(modId, fileId, out Result result))
diff --git a/Runtime/ModIO.Implementation/Classes/ModCollectionEntry.cs b/Runtime/ModIO.Implementation/Classes/ModCollectionEntry.cs
index dcb38c4..5b21f7f 100644
--- a/Runtime/ModIO.Implementation/Classes/ModCollectionEntry.cs
+++ b/Runtime/ModIO.Implementation/Classes/ModCollectionEntry.cs
@@ -1,4 +1,5 @@
using ModIO.Implementation.API.Objects;
+using Runtime.Enums;
namespace ModIO.Implementation
{
@@ -8,6 +9,6 @@ internal class ModCollectionEntry
public ModfileObject currentModfile;
public ModObject modObject;
public bool uninstallIfNotSubscribedToCurrentSession;
- public int priority = 100;
+ public ModPriority priority = ModPriority.Normal;
}
}
diff --git a/Runtime/ModIO.Implementation/Classes/ModCollectionManager.cs b/Runtime/ModIO.Implementation/Classes/ModCollectionManager.cs
index 1121dbc..65cc48a 100644
--- a/Runtime/ModIO.Implementation/Classes/ModCollectionManager.cs
+++ b/Runtime/ModIO.Implementation/Classes/ModCollectionManager.cs
@@ -4,6 +4,11 @@
using ModIO.Implementation.API;
using ModIO.Implementation.API.Objects;
using ModIO.Implementation.API.Requests;
+using Runtime.Enums;
+
+#if UNITY_IOS || UNITY_ANDROID
+using Plugins.mod.io.Platform.Mobile;
+#endif
#if UNITY_GAMECORE
using Unity.GameCore;
@@ -21,6 +26,8 @@ internal static class ModCollectionManager
{
public static ModCollectionRegistry Registry;
+ static readonly List InstalledTempMods = new List();
+
public static async Task LoadRegistryAsync()
{
ResultAnd response = await DataStorage.LoadSystemRegistryAsync();
@@ -102,6 +109,16 @@ public static void ClearUserData()
SaveRegistry();
}
+ public static long? GetModFileId(ModId modId)
+ {
+ if (Registry.mods.TryGetValue(modId, out var mod))
+ {
+ return mod.currentModfile.id;
+ }
+
+ return null;
+ }
+
public static void AddUserToRegistry(UserObject user)
{
// Early out
@@ -180,6 +197,8 @@ public static async Task FetchUpdates()
// If failed, cancel the entire update operation
if(gameTagsResponse.result.Succeeded())
{
+ await DataStorage.SaveTags(gameTagsResponse.value.data);
+
// Put these in the Response Cache
var tags = ResponseTranslator.ConvertGameTagOptionsObjectToTagCategories(gameTagsResponse.value.data);
ResponseCache.AddTagsToCache(tags);
@@ -245,9 +264,7 @@ public static async Task FetchUpdates()
//--------------------------------------------------------------------------------//
// UPDATE ENTITLEMENTS //
//--------------------------------------------------------------------------------//
-
- await ModIOUnityAsync.SyncEntitlements();
-
+ var resultAnd = await ModIOUnityAsync.SyncEntitlements();
//--------------------------------------------------------------------------------//
// GET PURCHASES //
@@ -264,8 +281,8 @@ public static async Task FetchUpdates()
if (r.result.Succeeded())
{
- ResponseCache.AddModsToCache(API.Requests.GetUserPurchases.UnpaginatedURL(filter), 0, r.value);
- AddModsToUserPurchases(r.value);
+ ResponseCache.AddModsToCache(API.Requests.GetUserPurchases.UnpaginatedURL(filter), pageSize * pageIndex, r.value);
+ AddModsToUserPurchases(r.value, pageIndex == 0);
long totalResults = r.value.totalSearchResultsFound;
int resultsFetched = (pageIndex + 1) * pageSize;
if (resultsFetched > totalResults)
@@ -396,7 +413,7 @@ public static async Task> TryRequestAllResults(
public static bool HasModCollectionEntry(ModId modId) => Registry.mods.ContainsKey(modId);
- public static void AddModCollectionEntry(ModId modId)
+ private static void AddModCollectionEntry(ModId modId)
{
// Check an entry exists for this modObject, if not create one
if(!Registry.mods.ContainsKey(modId))
@@ -407,15 +424,14 @@ public static void AddModCollectionEntry(ModId modId)
}
}
- public static void UpdateModCollectionEntry(ModId modId, ModObject modObject, int priority = 0)
+ public static void UpdateModCollectionEntry(ModId modId, ModObject modObject, ModPriority priority = ModPriority.Normal)
{
AddModCollectionEntry(modId);
Registry.mods[modId].modObject = modObject;
Registry.mods[modId].priority = priority;
// Check this in case of UserData being deleted
- if(DataStorage.TryGetInstallationDirectory(modId, modObject.modfile.id,
- out string notbeingusedhere))
+ if(DataStorage.TryGetInstallationDirectory(modId, modObject.modfile.id, out _))
{
Registry.mods[modId].currentModfile = modObject.modfile;
}
@@ -423,8 +439,22 @@ public static void UpdateModCollectionEntry(ModId modId, ModObject modObject, in
SaveRegistry();
}
- private static void AddModsToUserPurchases(ModPage modPage)
+ private static void AddModsToUserPurchases(ModPage modPage, bool clearPreviousPurchases)
{
+ long user = GetUserKey();
+
+ // Early out
+ if(!IsRegistryLoaded() || !DoesUserExist(user))
+ {
+ return;
+ }
+
+ if(clearPreviousPurchases)
+ {
+ // Clear existing purchases, as otherwise we never remove them (even across multiple sessions)
+ Registry.existingUsers[user].purchasedMods.Clear();
+ }
+
foreach (ModProfile modProfile in modPage.modProfiles)
{
AddModToUserPurchases(modProfile.id);
@@ -558,8 +588,7 @@ public static void UpdateModCollectionEntryFromModObject(ModObject modObject, bo
Registry.mods[modId].modObject = modObject;
// Check this in case of UserData being deleted
- if(DataStorage.TryGetInstallationDirectory(modId, modObject.modfile.id,
- out string notbeingusedhere))
+ if(DataStorage.TryGetInstallationDirectory(modId, modObject.modfile.id, out _))
{
Registry.mods[modId].currentModfile = modObject.modfile;
}
@@ -584,6 +613,7 @@ public static bool EnableModForCurrentUser(ModId modId)
if (Registry.existingUsers[currentUser].disabledMods.Contains(modId))
{
Registry.existingUsers[currentUser].disabledMods.Remove(modId);
+ SaveRegistry();
}
Logger.Log(LogLevel.Verbose, $"Enabled Mod {((long)modId).ToString()}");
@@ -604,6 +634,7 @@ public static bool DisableModForCurrentUser(ModId modId)
if (!Registry.existingUsers[currentUser].disabledMods.Contains(modId))
{
Registry.existingUsers[currentUser].disabledMods.Add(modId);
+ SaveRegistry();
}
Logger.Log(LogLevel.Verbose, $"Disabled Mod {((long)modId).ToString()}");
@@ -656,7 +687,7 @@ public static InstalledMod[] GetInstalledMods(out Result result, bool excludeSub
return null;
}
- List mods = new List();
+ InstalledTempMods.Clear();
long currentUser = GetUserKey();
@@ -683,20 +714,39 @@ public static InstalledMod[] GetInstalledMods(out Result result, bool excludeSub
}
// check if current modfile is correct
- if(DataStorage.TryGetInstallationDirectory(
- enumerator.Current.Key.id, enumerator.Current.Value.currentModfile.id,
- out string directory))
+ if(DataStorage.TryGetInstallationDirectory(enumerator.Current.Key.id, enumerator.Current.Value.currentModfile.id, out string directory))
{
InstalledMod mod = ConvertModCollectionEntryToInstalledMod(enumerator.Current.Value, directory);
mod.enabled = Registry.existingUsers.ContainsKey(currentUser)
&& !Registry.existingUsers[currentUser].disabledMods.Contains(mod.modProfile.id);
- mods.Add(mod);
+ InstalledTempMods.Add(mod);
}
}
}
result = ResultBuilder.Success;
- return mods.ToArray();
+ return InstalledTempMods.ToArray();
+ }
+
+ public static InstalledMod[] GetTempInstalledMods()
+ {
+ var mods = TempModSetManager.GetMods(true);
+ InstalledTempMods.Clear();
+ foreach (var id in mods)
+ {
+ var modId = new ModId(id);
+ var fileId = GetModFileId(modId);
+ if (fileId == null)
+ continue;
+ var directoryExists = DataStorage.TryGetInstallationDirectory(modId, fileId.Value, out string directory);
+ if (!directoryExists || !Registry.mods.ContainsKey(modId))
+ continue;
+
+ InstalledMod mod = ConvertModCollectionEntryToInstalledMod(Registry.mods[modId], directory);
+ mod.enabled = true;
+ InstalledTempMods.Add(mod);
+ }
+ return InstalledTempMods.ToArray();
}
///
@@ -747,7 +797,19 @@ public static SubscribedMod[] GetSubscribedModsForUser(out Result result)
return subscribedMods.ToArray();
}
- static SubscribedMod ConvertModCollectionEntryToSubscribedMod(ModCollectionEntry entry)
+ public static bool TryGetModProfile(ModId modId, out ModProfile modProfile)
+ {
+ if (!IsRegistryLoaded() || !Registry.mods.TryGetValue(modId, out ModCollectionEntry entry))
+ {
+ modProfile = default;
+ return false;
+ }
+
+ modProfile = ResponseTranslator.ConvertModObjectToModProfile(entry.modObject);
+ return true;
+ }
+
+ public static SubscribedMod ConvertModCollectionEntryToSubscribedMod(ModCollectionEntry entry)
{
SubscribedMod mod = new SubscribedMod
{
diff --git a/Runtime/ModIO.Implementation/Classes/ModIOCommandLineArgs.cs b/Runtime/ModIO.Implementation/Classes/ModIOCommandLineArgs.cs
new file mode 100644
index 0000000..f59fb42
--- /dev/null
+++ b/Runtime/ModIO.Implementation/Classes/ModIOCommandLineArgs.cs
@@ -0,0 +1,53 @@
+using System.Collections.Generic;
+
+namespace ModIO.Implementation
+{
+ internal static class ModIOCommandLineArgs
+ {
+ const string PREFIX = "-modio-";
+
+ static Dictionary argumentCache;
+
+ ///
+ /// Attempt to get a mod.io command line argument and its encoded value from the environment.
+ ///
+ /// true if the argument was successfully found.
+ ///
+ /// All arguments need to be in the format:
+ /// -modio-arg=value
+ ///
+ internal static bool TryGet(string argument, out string value)
+ {
+ if (argumentCache == null) GetArguments();
+
+ return argumentCache.TryGetValue(argument, out value);
+ }
+
+ static void GetArguments()
+ {
+ if (argumentCache != null) return;
+
+ argumentCache = new Dictionary();
+
+ string[] launchArgs = System.Environment.GetCommandLineArgs();
+
+ foreach (string argument in launchArgs)
+ {
+ if (!argument.StartsWith(PREFIX)) continue;
+
+ string[] argumentValue = argument.Split('=');
+
+ if (argumentValue.Length != 2)
+ {
+ Logger.Log(LogLevel.Warning, $"Mod.IO Launch Argument {argument} does not match format of [argument]=[value]. Ignoring argument.");
+ continue;
+ }
+
+ string key = argumentValue[0].Substring(PREFIX.Length);
+ string value = argumentValue[1];
+
+ argumentCache[key] = value;
+ }
+ }
+ }
+}
diff --git a/Runtime/ModIO.Implementation/Classes/ModIOCommandLineArgs.cs.meta b/Runtime/ModIO.Implementation/Classes/ModIOCommandLineArgs.cs.meta
new file mode 100644
index 0000000..25e0c95
--- /dev/null
+++ b/Runtime/ModIO.Implementation/Classes/ModIOCommandLineArgs.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 2bfcd002a59b33e43b4489ad80577e78
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/ModIO.Implementation/Classes/ModIOUnityImplementation.cs b/Runtime/ModIO.Implementation/Classes/ModIOUnityImplementation.cs
index 8470a7b..0b0b6f0 100644
--- a/Runtime/ModIO.Implementation/Classes/ModIOUnityImplementation.cs
+++ b/Runtime/ModIO.Implementation/Classes/ModIOUnityImplementation.cs
@@ -11,12 +11,19 @@
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
+using Plugins.mod.io;
+using Plugins.mod.io.Runtime.ModIO.Implementation.Classes;
+using Runtime.Enums;
using UnityEngine;
using GameObject = ModIO.Implementation.API.Objects.GameObject;
+#if UNITY_IOS || UNITY_ANDROID
+using Plugins.mod.io.Platform.Mobile;
+using Newtonsoft.Json.Linq;
+#endif
+
namespace ModIO.Implementation
{
-
///
/// The actual implementation for methods called from the ModIOUnity interface
///
@@ -87,6 +94,12 @@ public static bool AutoInitializePlugin
/// Has the plugin been initialized.
public static bool IsInitialized(out Result result)
{
+ if (shuttingDown)
+ {
+ result = ResultBuilder.Create(ResultCode.Internal_OperationCancelled);
+ return false;
+ }
+
if (isInitialized)
{
result = ResultBuilder.Success;
@@ -149,6 +162,7 @@ public static async Task EnsureGameProfileHasBeenRetrieved()
}
Logger.Log(LogLevel.Error, "Unable to retrieve Game Profile from the server.");
+
return false;
}
@@ -249,7 +263,8 @@ public static void SetLoggingDelegate(LogMessageDelegate loggingDelegate)
/// specified user has installed on this device.
public static Result InitializeForUser(string userProfileIdentifier,
ServerSettings serverSettings,
- BuildSettings buildSettings)
+ BuildSettings buildSettings,
+ UISettings uiSettings = default)
{
TaskCompletionSource callbackConfirmation = new TaskCompletionSource();
openCallbacks_dictionary.Add(callbackConfirmation, null);
@@ -257,8 +272,12 @@ public static Result InitializeForUser(string userProfileIdentifier,
// clean user profile identifier in case of filename usage
userProfileIdentifier = IOUtil.CleanFileNameForInvalidCharacters(userProfileIdentifier);
+ //clear gameProfile in case we're changing games
+ GameProfile = null;
+
Settings.server = serverSettings;
Settings.build = buildSettings;
+ Settings.ui = uiSettings;
// - load data services -
// NOTE(@jackson):
@@ -313,6 +332,8 @@ public static Result InitializeForUser(string userProfileIdentifier,
// Set response cache size limit
ResponseCache.maxCacheSize = buildSettings.requestCacheLimitKB * 1024;
+ DataStorage.DeleteAllTempImages();
+
// If we fail to load the registry we simply create a new one. It may be corrupted
// if(!result.Succeeded())
// {
@@ -330,6 +351,8 @@ public static Result InitializeForUser(string userProfileIdentifier,
Logger.Log(LogLevel.Message, $"Initialized User[{userProfileIdentifier}]");
+ ModIOUnityEvents.OnPluginInitialized();
+
return result;
}
@@ -343,12 +366,16 @@ public static Result InitializeForUser(string userProfileIdentifier)
ServerSettings serverSettings;
BuildSettings buildSettings;
+ UISettings uiSettings;
- Result result = SettingsAsset.TryLoad(out serverSettings, out buildSettings);
+ Result result = SettingsAsset.TryLoad(out serverSettings, out buildSettings, out uiSettings);
+
+ if (serverSettings.useCommandLineArgumentOverrides)
+ ApplyLaunchArguments(ref userProfileIdentifier, ref serverSettings);
if (result.Succeeded())
{
- result = InitializeForUser(userProfileIdentifier, serverSettings, buildSettings);
+ result = InitializeForUser(userProfileIdentifier, serverSettings, buildSettings, uiSettings);
}
callbackConfirmation.SetResult(true);
@@ -356,6 +383,21 @@ public static Result InitializeForUser(string userProfileIdentifier)
return result;
}
+ static void ApplyLaunchArguments(ref string userProfileIdentifier, ref ServerSettings serverSettings)
+ {
+ if (ModIOCommandLineArgs.TryGet("gameid", out string serializedId))
+ serverSettings.gameId = uint.Parse(serializedId);
+
+ if (ModIOCommandLineArgs.TryGet("apikey", out string apiKey))
+ serverSettings.gameKey = apiKey;
+
+ if (ModIOCommandLineArgs.TryGet("user", out string user))
+ userProfileIdentifier = user;
+
+ if (ModIOCommandLineArgs.TryGet("url", out string url))
+ serverSettings.serverURL = url;
+ }
+
///
/// Cancels any running public operations, frees plugin resources, and invokes
/// any pending callbacks with a cancelled result code.
@@ -424,6 +466,7 @@ static async Task ShutdownTask()
// Settings.build = default;
ResponseCache.ClearCache();
ModCollectionManager.ClearRegistry();
+ DataStorage.DeleteAllTempImages();
// get new instance of dictionary so it's thread safe
Dictionary, Task> tasks =
@@ -474,6 +517,9 @@ public static async Task IsAuthenticated()
{
result = task.result;
UserData.instance.SetUserObject(task.value);
+
+ var userProfile = ResponseTranslator.ConvertUserObjectToUserProfile(task.value);
+ ModIOUnityEvents.OnUserAuthenticated(userProfile);
}
}
@@ -575,8 +621,9 @@ public static async Task SubmitEmailSecurityCode(string securityCode)
// helps to keep track fo what WE are calling and what the user might be
// calling, the following line of code is a perfect example of how we'd expect
// slightly different behaviour)
- await GetCurrentUser(delegate
- { });
+ var resultAnd = await GetCurrentUser();
+
+ ModIOUnityEvents.OnUserAuthenticated(resultAnd.value);
// continue to invoke at the end of this method
}
@@ -686,8 +733,9 @@ public static async Task AuthenticateUser(
UserData.instance.SetOAuthToken(response.value, serviceProvider);
// TODO @Steve (see other example, same situation in email auth)
- await GetCurrentUser(delegate
- { });
+ var userResultAnd = await GetCurrentUser();
+
+ ModIOUnityEvents.OnUserAuthenticated(userResultAnd.value);
}
else
{
@@ -790,7 +838,7 @@ public static async Task> GetGameTags()
var callbackConfirmation = openCallbacks.New();
Result result;
- TagCategory[] tags = new TagCategory[0];
+ TagCategory[] tags = Array.Empty();
if (IsInitialized(out result) && !ResponseCache.GetTagsFromCache(out tags))
{
@@ -802,9 +850,25 @@ public static async Task> GetGameTags()
result = task.result;
if (result.Succeeded())
{
+ await DataStorage.SaveTags(task.value.data);
+
tags = ResponseTranslator.ConvertGameTagOptionsObjectToTagCategories(task.value.data);
ResponseCache.AddTagsToCache(tags);
}
+ else if (result.IsCancelled())
+ {
+ //do nothing; we need to pass this one through as is (the plugin is probably shutting down)
+ }
+ else
+ {
+ ResultAnd resultDataStorage = await DataStorage.LoadTags();
+ result = resultDataStorage.result;
+ if (result.Succeeded())
+ {
+ tags = ResponseTranslator.ConvertGameTagOptionsObjectToTagCategories(resultDataStorage.value);
+ ResponseCache.AddTagsToCache(tags);
+ }
+ }
}
openCallbacks.Complete(callbackConfirmation);
@@ -827,6 +891,8 @@ public static async void GetGameTags(Action> callback)
callback?.Invoke(result);
}
+ public static string GetTagLocalized(string tag, string languageCode) => ResponseCache.GetTagLocalized(tag, languageCode);
+
public static async Task> GetMods(SearchFilter filter)
{
var callbackConfirmation = openCallbacks.New();
@@ -879,6 +945,39 @@ public static async void GetMods(SearchFilter filter, Action>
callback?.Invoke(result);
}
+ public static async Task Ping()
+ {
+ if (!IsInitialized(out Result initializedResult))
+ return initializedResult;
+
+ var callbackConfirmation = openCallbacks.New();
+
+ Result result = await openCallbacks.Run(
+ callbackConfirmation,
+ WebRequestManager.Request(API.Requests.Ping.Request())
+ );
+
+ openCallbacks.Complete(callbackConfirmation);
+
+ return result;
+ }
+
+ public static async void Ping(Action callback)
+ {
+ //Early out
+ if (callback == null)
+ {
+ Logger.Log(
+ LogLevel.Error,
+ "No callback was given to the Ping method."
+ + " Operation has been cancelled.");
+ return;
+ }
+
+ Result pingResult = await Ping();
+ callback.Invoke(pingResult);
+ }
+
public static async Task> GetModComments(ModId modId, SearchFilter filter)
{
var callbackConfirmation = openCallbacks.New();
@@ -1117,8 +1216,9 @@ public static async Task> GetCurrentUserRatingFor(ModId mod
if (!response.result.Succeeded())
{
- result = response.result;
- goto End;
+ callbackConfirmation.SetResult(true);
+ openCallbacks.Remove(callbackConfirmation);
+ return ResultAnd.Create(response.result, ModRating.None);
}
}
@@ -1129,12 +1229,9 @@ public static async Task> GetCurrentUserRatingFor(ModId mod
}
}
- End:
-
// FINAL SUCCESS / FAILURE depending on callback params set previously
callbackConfirmation.SetResult(true);
openCallbacks.Remove(callbackConfirmation);
-
return ResultAnd.Create(result, rating);
}
@@ -1158,12 +1255,16 @@ public static async void GetCurrentUserRatingFor(ModId modId, Action dependencies, Action callback)
@@ -1350,6 +1467,101 @@ public static async Task RemoveDependenciesFromMod(ModId modId, ICollect
return result;
}
+ public static async Task>> GetModKvpMetadata(long modId)
+ {
+ ResultAnd> response = ResultAnd.Create>(ResultBuilder.Unknown, default);
+
+ var callbackConfirmation = openCallbacks.New();
+ if (IsInitialized(out response.result) && IsAuthenticatedSessionValid(out response.result))
+ {
+ var config = API.Requests.GetModKvpMetadata.Request(modId);
+ var r = await openCallbacks.Run(callbackConfirmation, WebRequestManager.Request(config));
+ response.result = r.result;
+ if (r.result.Succeeded())
+ {
+ response.value = ResponseTranslator.ConvertMetadataKvpObjects(r.value);
+ }
+ }
+
+ openCallbacks.Complete(callbackConfirmation);
+ return response;
+ }
+
+ public static async void GetModKvpMetadata(long modId, Action>> callback)
+ {
+ if (callback == null)
+ {
+ Logger.Log(
+ LogLevel.Warning,
+ "No callback was given to the GetModKvpMetadata method. It is "
+ + "possible that this operation will not resolve successfully and should be "
+ + "checked with a proper callback.");
+ }
+
+ ResultAnd> result = await GetModKvpMetadata(modId);
+ callback?.Invoke(result);
+ }
+
+ public static async Task AddModKvpMetadata(long modId, Dictionary metadataKvps)
+ {
+ Result result = ResultBuilder.Unknown;
+
+ var callbackConfirmation = openCallbacks.New();
+ if (IsInitialized(out result) && IsAuthenticatedSessionValid(out result))
+ {
+ var config = API.Requests.AddModKvpMetadata.Request(modId, metadataKvps);
+ result = await openCallbacks.Run(callbackConfirmation, WebRequestManager.Request(config));
+ }
+
+ openCallbacks.Complete(callbackConfirmation);
+ return result;
+ }
+
+ public static async void AddModKvpMetadata(long modId, Dictionary metadataKvps, Action callback)
+ {
+ if (callback == null)
+ {
+ Logger.Log(
+ LogLevel.Warning,
+ "No callback was given to the AddModKvpMetadata method. It is "
+ + "possible that this operation will not resolve successfully and should be "
+ + "checked with a proper callback.");
+ }
+
+ Result result = await AddModKvpMetadata(modId, metadataKvps);
+ callback?.Invoke(result);
+ }
+
+ public static async Task DeleteModKvpMetadata(long modId, Dictionary metadataKvps)
+ {
+ Result result = ResultBuilder.Unknown;
+
+ var callbackConfirmation = openCallbacks.New();
+ if (IsInitialized(out result) && IsAuthenticatedSessionValid(out result))
+ {
+ var config = API.Requests.DeleteModKvpMetadata.Request(modId, metadataKvps);
+ result = await openCallbacks.Run(callbackConfirmation, WebRequestManager.Request(config));
+ }
+
+ openCallbacks.Complete(callbackConfirmation);
+ return result;
+ }
+
+ public static async void DeleteModKvpMetadata(long modId, Dictionary metadataKvps, Action callback)
+ {
+ if (callback == null)
+ {
+ Logger.Log(
+ LogLevel.Warning,
+ "No callback was given to the DeleteModKvpMetadata method. It is "
+ + "possible that this operation will not resolve successfully and should be "
+ + "checked with a proper callback.");
+ }
+
+ Result result = await DeleteModKvpMetadata(modId, metadataKvps);
+ callback?.Invoke(result);
+ }
+
#endregion // Mod Management
#region User Management
@@ -1358,9 +1570,7 @@ public static async Task AddModRating(ModId modId, ModRating modRating)
{
var callbackConfirmation = openCallbacks.New();
- Result result;
-
- if (IsInitialized(out result) && IsAuthenticatedSessionValid(out result))
+ if (IsInitialized(out Result result) && IsAuthenticatedSessionValid(out result))
{
var config = API.Requests.AddModRating.Request(modId, modRating);
@@ -1368,19 +1578,19 @@ public static async Task AddModRating(ModId modId, ModRating modRating)
result = response.result;
- var rating = new Rating
- {
- dateAdded = DateTime.Now,
- rating = modRating,
- modId = modId
- };
- ResponseCache.AddCurrentUserRating(modId, rating);
-
- if (result.code_api == ResultCode.RESTAPI_ModRatingAlreadyExists
+ if (result.Succeeded()
+ || result.code_api == ResultCode.RESTAPI_ModRatingAlreadyExists
|| result.code_api == ResultCode.RESTAPI_ModRatingNotFound)
{
// SUCCEEDED
result = ResultBuilder.Success;
+
+ ResponseCache.AddCurrentUserRating(modId, new Rating
+ {
+ modId = modId,
+ rating = modRating,
+ dateAdded = DateTime.Now,
+ });
}
}
@@ -1388,7 +1598,6 @@ public static async Task AddModRating(ModId modId, ModRating modRating)
return result;
}
-
public static async void AddModRating(ModId modId, ModRating rating,
Action callback)
{
@@ -1406,7 +1615,7 @@ public static async void AddModRating(ModId modId, ModRating rating,
callback?.Invoke(result);
}
- public static async Task> GetCurrentUser()
+ public static async Task> GetCurrentUser(bool allowOfflineUser = false)
{
var callbackConfirmation = openCallbacks.New();
@@ -1429,6 +1638,10 @@ public static async Task> GetCurrentUser()
// Add UserProfile to cache (lasts for the whole session)
ResponseCache.AddUserToCache(userProfile);
}
+ else if (allowOfflineUser && result.IsNetworkError())
+ {
+ userProfile = ResponseTranslator.ConvertUserObjectToUserProfile(UserData.instance.userObject);
+ }
}
callbackConfirmation.SetResult(true);
@@ -1437,7 +1650,7 @@ public static async Task> GetCurrentUser()
return ResultAnd.Create(result, userProfile);
}
- public static async Task GetCurrentUser(Action> callback)
+ public static async Task GetCurrentUser(Action> callback, bool allowOfflineUser = false)
{
// Early out
if (callback == null)
@@ -1449,7 +1662,7 @@ public static async Task GetCurrentUser(Action> callback)
return;
}
- var result = await GetCurrentUser();
+ var result = await GetCurrentUser(allowOfflineUser);
callback(result);
}
@@ -1461,6 +1674,8 @@ public static async Task UnsubscribeFrom(ModId modId)
if (IsInitialized(out result) && IsAuthenticatedSessionValid(out result))
{
+ ModIOUnityEvents.InvokeModSubscriptionChanged(modId, false); // Invoke the event immediately, as the plugin will keep attempting to unsubscribe on fail
+
var config = API.Requests.UnsubscribeFromMod.Request(modId);
var task = await openCallbacks.Run(callbackConfirmation, WebRequestManager.Request(config));
@@ -1485,6 +1700,8 @@ public static async Task UnsubscribeFrom(ModId modId)
}
ModCollectionManager.RemoveModFromUserSubscriptions(modId, success);
+
+ ModIOUnityEvents.InvokeModSubscriptionInfoChanged(modId); // We need an event afterwards, so we can grab updated file states and such that have changed in response
}
openCallbacks.Complete(callbackConfirmation);
@@ -1529,6 +1746,8 @@ public static async Task SubscribeTo(ModId modId)
if (IsInitialized(out result) && IsAuthenticatedSessionValid(out result))
{
+ ModIOUnityEvents.InvokeModSubscriptionChanged(modId, true); // Invoke the event immediately, as the plugin will keep attempting to subscribe on fail
+
var config = API.Requests.SubscribeToMod.Request(modId);
var taskResult = await openCallbacks.Run(callbackConfirmation, WebRequestManager.Request(config));
@@ -1561,6 +1780,8 @@ public static async Task SubscribeTo(ModId modId)
result = getModConfigResult.result;
}
+
+ ModIOUnityEvents.InvokeModSubscriptionInfoChanged(modId);
}
openCallbacks.Complete(callbackConfirmation);
@@ -1583,7 +1804,6 @@ public static async void SubscribeTo(ModId modId, Action callback)
callback?.Invoke(result);
}
-
//Should this be exposed in ModIOUnity/ModIOUnityAsync?
public static async Task> GetUserSubscriptions(SearchFilter filter)
{
@@ -1633,7 +1853,7 @@ public static SubscribedMod[] GetSubscribedMods(out Result result)
public static InstalledMod[] GetInstalledMods(out Result result)
{
- if (IsInitialized(out result) /* && AreCredentialsValid(false, out result)*/)
+ if (IsInitialized(out result))
{
InstalledMod[] mods = ModCollectionManager.GetInstalledMods(out result, true);
return mods;
@@ -1642,6 +1862,17 @@ public static InstalledMod[] GetInstalledMods(out Result result)
return null;
}
+ public static InstalledMod[] GetTempInstalledMods(out Result result)
+ {
+ if (IsInitialized(out result))
+ {
+ InstalledMod[] mods = ModCollectionManager.GetTempInstalledMods();
+ return mods;
+ }
+
+ return Array.Empty();
+ }
+
public static UserInstalledMod[] GetInstalledModsForUser(out Result result, bool includeDisabledMods)
{
//Filter for user
@@ -1684,6 +1915,8 @@ public static Result RemoveUserData()
? ResultBuilder.Create(ResultCode.User_NotRemoved)
: ResultBuilder.Success;
+ ModIOUnityEvents.OnUserRemoved();
+
return result;
}
@@ -1813,7 +2046,11 @@ public static async Task> DownloadTexture(DownloadReference
if (result.Succeeded())
{
- IOUtil.TryParseImageData(resultAnd.value, out texture, out result);
+ if (!IOUtil.TryParseImageData(resultAnd.value, out texture, out result))
+ {
+ //If we can't parse the image, clear it off disk
+ Result cleanupResult = DataStorage.DeleteStoredImage(downloadReference.url);
+ }
}
return ResultAnd.Create(result, texture);
@@ -1875,8 +2112,22 @@ static async Task> DownloadImage(DownloadReference downloadRef
// CACHE SUCCEEDED
result = cacheResponse.result;
image = cacheResponse.value;
+
+ if (image?.Length == 0)
+ {
+ Result cleanupResult = DataStorage.DeleteStoredImage(downloadReference.url);
+ if(!cleanupResult.Succeeded())
+ {
+ Logger.Log(LogLevel.Error,
+ $"[Internal] Failed to clean up zero byte cached image for modId {downloadReference.modId}"
+ + $" (cleanup result {cleanupResult.code}:{cleanupResult.code_api})");
+ }
+
+ image = null;
+ }
}
- else
+
+ if(image == null)
{
// GET FILE STREAM TO DOWNLOAD THE IMAGE FILE TO
// This stream is a direct write to the file location we will cache the
@@ -2085,7 +2336,6 @@ public static async Task> CreateModProfile(CreationToken token,
ResponseCache.ClearCache();
modDetails.modId = (ModId)response.value.id;
- result = await ValidateModProfileMarketplaceTeam(modDetails);
}
}
}
@@ -2156,8 +2406,6 @@ public static async Task EditModProfile(ModProfileDetails modDetails)
+ " The 'tags' array in the ModProfileDetails will be ignored.");
}
- result = await ValidateModProfileMarketplaceTeam(modDetails);
-
var config = modDetails.logo != null
? API.Requests.EditMod.RequestPOST(modDetails)
: API.Requests.EditMod.RequestPUT(modDetails);
@@ -2193,50 +2441,6 @@ public static async void EditModProfile(ModProfileDetails modDetails,
callback?.Invoke(result);
}
- ///
- /// If a user attempts to create or edit a mod's monetization options to 'Live',
- /// this method will ensure the monetization team and revenue split is set correctly
- /// by setting the existing user's revenue share to 100%.
- ///
- ///
- /// This first attempts to GET the monetization team before setting it. The reason we dont
- /// just force it to always be the existing user with 100% revenue share is because the
- /// revenue split can be edited and set with multiple users elsewhere.
- /// (It's rare but possible, and we dont want to override an existing team)
- ///
- ///
- ///
- static async Task ValidateModProfileMarketplaceTeam(ModProfileDetails modDetails)
- {
- // If not setting monetization to live, we don't need to validate that a marketplace team has been setup
- if (!modDetails.monetizationOptions.HasValue || modDetails.monetizationOptions.Value == MonetizationOption.None)
- {
- return ResultBuilder.Success;
- }
-
- Result result = ResultBuilder.Unknown;
-
- if (modDetails.modId == null)
- {
- return result;
- }
-
- var getResponse = await GetModMonetizationTeam(modDetails.modId.Value);
-
- if (getResponse.result.Succeeded())
- {
- return ResultBuilder.Success;
- }
-
- List team = new List
- {
- new ModMonetizationTeamDetails(UserData.instance.userObject.id, 100)
- };
- result = await AddModMonetizationTeam(modDetails.modId.Value, team);
-
- return result;
- }
-
public static async void DeleteTags(ModId modId, string[] tags,
Action callback)
{
@@ -2840,12 +3044,10 @@ public static async void ArchiveModProfile(ModId modId, Action callback)
static bool IsModfileDetailsValid(ModfileDetails modfile, out Result result)
{
// Check directory exists
- if (modfile.uploadId == null && !DataStorage.TryGetModfileDetailsDirectory(modfile.directory,
- out string _))
+ if (modfile.uploadId == null && !DataStorage.IsDirectoryValid(modfile.directory))
{
Logger.Log(LogLevel.Error,
- "The provided directory in ModfileDetails could not be found or"
- + $" does not exist ({modfile.directory}).");
+ $"The provided directory does not exist ({modfile.directory}).");
result = ResultBuilder.Create(ResultCode.IO_DirectoryDoesNotExist);
return false;
}
@@ -3184,42 +3386,17 @@ public static async void CompleteMultipartUploadSession(ModId modId, string uplo
#region Monetization
- public static async Task> GetTokenPacks(Action> callback = null)
- {
- var callbackConfirmation = openCallbacks.New();
-
- TokenPack[] tokenPacks = Array.Empty();
-
- if (IsInitialized(out Result result) && !ResponseCache.GetTokenPacksFromCache(out tokenPacks))
- {
- var config = API.Requests.GetTokenPacks.Request();
- var task = await openCallbacks.Run(callbackConfirmation, WebRequestManager.Request(config));
-
- result = task.result;
- if (result.Succeeded())
- {
- tokenPacks = ResponseTranslator.ConvertTokenPackObjectsToTokenPacks(task.value.data);
- ResponseCache.AddTokenPacksToCache(tokenPacks);
- }
- }
-
- openCallbacks.Complete(callbackConfirmation);
-
- var resultAnd = ResultAnd.Create(result, tokenPacks);
-
- callback?.Invoke(resultAnd);
-
- return resultAnd;
- }
-
public static async Task> SyncEntitlements()
{
+ Logger.Log(LogLevel.Verbose, $"Sync Entitlements called");
Task> requestTask = null;
Entitlement[] entitlements = null;
WebRequestConfig config = null;
#if UNITY_GAMECORE && !UNITY_EDITOR
var token = await GamecoreHelper.GetToken();
+ if(token == null)
+ return ResultAnd.Create(ResultCode.RESTAPI_XboxLiveTokenInvalid, entitlements);
config = API.Requests.SyncEntitlements.XboxRequest(token);
requestTask = WebRequestManager.Request(config);
#elif UNITY_PS4 && !UNITY_EDITOR
@@ -3236,8 +3413,20 @@ public static async Task> SyncEntitlements()
config = API.Requests.SyncEntitlements.PsnRequest(token.code, token.environment, psb.serviceLabel);
requestTask = WebRequestManager.Request(config);
}
-#elif UNITY_STANDALONE
+#elif UNITY_STANDALONE && !UNITY_EDITOR
config = API.Requests.SyncEntitlements.SteamRequest();
+ requestTask = WebRequestManager.Request(config);
+#elif (UNITY_ANDROID || UNITY_IOS) && !UNITY_EDITOR
+ var purchaseData = MobilePurchaseHelper.GetNextPurchase();
+ var walletResponse = await ModIOUnityAsync.GetUserWalletBalance();
+ if (!walletResponse.result.Succeeded())
+ Logger.Log(LogLevel.Verbose, "Failed to get wallet balance.");
+
+ if(Application.platform == RuntimePlatform.Android)
+ config = API.Requests.SyncEntitlements.GoogleRequest(purchaseData.PayloadJson);
+ else
+ config = API.Requests.SyncEntitlements.AppleRequest(purchaseData.Payload);
+
requestTask = WebRequestManager.Request(config);
#else
return ResultAnd.Create(ResultBuilder.Create(ResultCode.User_NotAuthenticated), null);
@@ -3253,13 +3442,20 @@ public static async Task> SyncEntitlements()
try
{
var task = await openCallbacks.Run(callbackConfirmation, requestTask);
-
result = task.result;
if (result.Succeeded())
{
+ Logger.Log(LogLevel.Verbose, $"Entitlement Synced. New Balance: {task.value.wallet.balance}");
+ ResponseCache.UpdateWallet(task.value.wallet.balance);
entitlements = ResponseTranslator.ConvertEntitlementObjectsToEntitlements(task.value.data);
ResponseCache.ReplaceEntitlements(entitlements);
+ ResponseCache.ClearWalletFromCache();
+#if (UNITY_ANDROID || UNITY_IOS) && !UNITY_EDITOR
+ MobilePurchaseHelper.CompleteValidation(entitlements);
+#endif
+
+ ModIOUnityEvents.OnUserEntitlementsChanged();
}
}
catch (Exception e)
@@ -3292,7 +3488,7 @@ public static async void SyncEntitlements(Action> callb
callback(result);
}
- public static async Task> PurchaseMod(ModId modId, int displayAmount, string idempotent)
+ public static async Task> PurchaseMod(ModId modId, int displayAmount, string idempotent, bool subscribeOnPurchase)
{
if (Regex.IsMatch(idempotent, "^[a-zA-Z0-9-]+$"))
{
@@ -3308,7 +3504,7 @@ public static async Task> PurchaseMod(ModId modId, in
checkoutProcess.result = await IsMarketplaceEnabled();
if (checkoutProcess.result.Succeeded())
{
- var config = API.Requests.PurchaseMod.Request(modId, displayAmount, idempotent);
+ var config = API.Requests.PurchaseMod.Request(modId, displayAmount, idempotent, subscribeOnPurchase);
var resultAnd = await openCallbacks.Run(callbackConfirmation, WebRequestManager.Request(config));
if (resultAnd.result.Succeeded())
@@ -3317,8 +3513,30 @@ public static async Task> PurchaseMod(ModId modId, in
ResponseCache.UpdateWallet(resultAnd.value.balance);
ModCollectionManager.UpdateModCollectionEntry(modId, resultAnd.value.mod);
ModCollectionManager.AddModToUserPurchases(modId);
+ ModIOUnityEvents.InvokeModPurchasedChanged(modId, true);
+
+ //Now make sure we reflect that we're subscribed to it
+ ModCollectionManager.AddModToUserSubscriptions(modId);
+
+ var getModConfig = API.Requests.GetMod.Request(modId);
+ var getModConfigResult = await openCallbacks.Run(callbackConfirmation, WebRequestManager.Request(getModConfig));
+
+ if (getModConfigResult.result.Succeeded())
+ {
+ var profile = ResponseTranslator.ConvertModObjectToModProfile(getModConfigResult.value);
+ ResponseCache.AddModToCache(profile);
+
+ ModCollectionManager.UpdateModCollectionEntry(modId, getModConfigResult.value);
+ }
+
+ ModIOUnityEvents.InvokeModSubscriptionInfoChanged(modId);
+
ModManagement.WakeUp();
}
+ else
+ {
+ checkoutProcess.result = resultAnd.result;
+ }
}
}
@@ -3327,7 +3545,7 @@ public static async Task> PurchaseMod(ModId modId, in
return checkoutProcess;
}
- public static async void PurchaseMod(ModId modId, int displayAmount, string idempotent, Action> callback)
+ public static async void PurchaseMod(ModId modId, int displayAmount, string idempotent, bool subscribeOnPurchase, Action> callback)
{
// Check for callback
if (callback == null)
@@ -3339,7 +3557,7 @@ public static async void PurchaseMod(ModId modId, int displayAmount, string idem
+ "provide a valid callback.");
}
- ResultAnd result = await PurchaseMod(modId, displayAmount, idempotent);
+ ResultAnd result = await PurchaseMod(modId, displayAmount, idempotent, subscribeOnPurchase);
callback?.Invoke(result);
}
@@ -3364,7 +3582,13 @@ public static async Task> GetUserPurchases(SearchFilter filte
if (result.Succeeded())
{
+ foreach (ModObject modObject in task.value.data)
+ {
+ ModCollectionManager.UpdateModCollectionEntryFromModObject(modObject, false);
+ }
+
page = ResponseTranslator.ConvertResponseSchemaToModPage(task.value, filter);
+ ResponseCache.AddModsToCache(unpaginatedURL, offset, page);
// Return the exact number of mods that were requested (not more)
if (page.modProfiles.Length > filter.pageSize)
@@ -3412,7 +3636,7 @@ public static async Task> GetUserWalletBalance()
if (resultAnd.result.Succeeded())
{
wallet = ResponseTranslator.ConvertWalletObjectToWallet(resultAnd.value);
- ResponseCache.UpdateWallet(resultAnd.value);
+ ResponseCache.ReplaceWallet(resultAnd.value);
}
}
}
@@ -3437,72 +3661,194 @@ public static async void GetUserWalletBalance(Action> callback
callback?.Invoke(result);
}
- public static async Task> GetModMonetizationTeam(ModId modId)
+ #endregion //Monetization
+
+ #region TempModSet
+ public static async Task CreateTempModSet(IEnumerable modIds)
{
- var callbackConfirmation = openCallbacks.New();
+ Result result;
+ if (IsInitialized(out result) && IsAuthenticatedSessionValid(out result))
+ {
+ TempModSetManager.CreateTempModSet(modIds);
- Result result;
- MonetizationTeamAccount[] teamAccounts = default;
+ foreach (var id in modIds)
+ {
+ var r = await GetModObject(id);
+ if (r.result.Succeeded())
+ {
+ ModCollectionManager.UpdateModCollectionEntry(new ModId(id), r.value, ModPriority.High);
+ }
+ else
+ {
+ Logger.Log(LogLevel.Error, "Failed to get mod.");
+ }
+ }
+ ModManagement.WakeUp();
+ }
- if (IsInitialized(out result) && IsAuthenticatedSessionValid(out result)
- && !ResponseCache.GetModMonetizationTeamCache(modId, out teamAccounts))
+ return result;
+ }
+
+ public static async void CreateTempModSet(IEnumerable modIds, Action callback)
+ {
+ if (callback == null)
{
- var task = await API.Requests.GetModMonetizationTeam.Request(modId).RunViaWebRequestManager();
+ Logger.Log(
+ LogLevel.Warning,
+ "No callback was given to the CreateTempModSet method. It is "
+ + "possible that this operation will not resolve successfully and should be "
+ + "checked with a proper callback.");
+ }
- result = task.result;
- if (task.result.Succeeded())
+ Result result = await CreateTempModSet(modIds);
+ callback?.Invoke(result);
+ }
+
+ public static Result DeleteTempModSet()
+ {
+ Result result = ResultBuilder.Unknown;
+ if (TempModSetManager.IsTempModSetActive())
+ {
+ var allTempModSetMods = TempModSetManager.GetMods(true).ToList();
+ var subscribedMods = ModIOUnity.GetSubscribedMods(out result);
+ var moveMods = new List();
+ foreach (var subscribedMod in subscribedMods)
+ {
+ if (allTempModSetMods.Contains(subscribedMod.modProfile.id))
+ {
+ moveMods.Add(subscribedMod.modProfile.id);
+ allTempModSetMods.Remove(subscribedMod.modProfile.id);
+ }
+ }
+ foreach (var modId in moveMods)
{
- teamAccounts = ResponseTranslator.ConvertGameMonetizationTeamObjectsToGameMonetizationTeams(task.value.data);
+ var fileId = ModCollectionManager.GetModFileId(modId);
+ if (fileId != null)
+ {
+ if (DataStorage.MoveTempModToInstallDirectory(modId, fileId.Value))
+ {
+ Logger.Log(LogLevel.Error, $"Could not move mod from temp mod install directory to subscribed mod install directory");
+ }
+ }
+ else
+ {
+ Logger.Log(LogLevel.Error, $"Could not file fileId");
+ }
+ }
+ foreach (var modId in allTempModSetMods)
+ {
+ var fileId = ModCollectionManager.GetModFileId(new ModId(modId));
+ if (fileId != null)
+ {
+ DataStorage.TryDeleteInstalledMod(modId, fileId.Value, out result);
+ if (!result.Succeeded())
+ {
+ Logger.Log(LogLevel.Error, $"Failed to delete modfile[{modId}_{fileId}]");
+ }
+ else
+ {
+ Logger.Log(LogLevel.Verbose, $"DELETED MODFILE[{modId}_{fileId}");
+ }
+ }
+ else
+ {
+ Logger.Log(LogLevel.Error, $"Could not file fileId for mod id: {modId}");
+ }
+ }
+
+ TempModSetManager.DeleteTempModSet();
+ ModManagement.WakeUp();
+ }
+
+ return result;
+ }
- ResponseCache.AddModMonetizationTeamToCache(modId, teamAccounts);
+ public static async Task AddModsToTempModSet(IEnumerable modIds)
+ {
+ var callbackConfirmation = openCallbacks.New();
+
+ Result result = ResultBuilder.Unknown;
+
+ if (TempModSetManager.IsTempModSetActive())
+ {
+ TempModSetManager.AddMods(modIds);
+ foreach (var id in modIds)
+ {
+ var r = await GetModObject(id);
+ if (r.result.Succeeded())
+ {
+ ModCollectionManager.UpdateModCollectionEntry(new ModId(id), r.value);
+ }
+ else
+ {
+ Debug.LogError("Failed to get mod.");
+ }
}
+ ModManagement.WakeUp();
}
openCallbacks.Complete(callbackConfirmation);
- return ResultAnd.Create(result, teamAccounts);
+ return result;
}
- public static async void GetModMonetizationTeam(Action> callback, ModId modId)
+ public static async void AddModsToTempModSet(IEnumerable mods, Action callback)
{
- // Early out
if (callback == null)
{
Logger.Log(
LogLevel.Warning,
- "No callback was given to the GetModMonetizationTeam method, any response "
- + "returned from the server wont be used. This operation has been cancelled.");
- return;
+ "No callback was given to the AddModsToTempModSet method. It is "
+ + "possible that this operation will not resolve successfully and should be "
+ + "checked with a proper callback.");
}
- ResultAnd result = await GetModMonetizationTeam(modId);
+
+ Result result = await AddModsToTempModSet(mods);
callback?.Invoke(result);
}
+ public static Result RemoveModsFromTempModSet(IEnumerable mods)
+ {
+ Result result = ResultBuilder.Unknown;
+ if (TempModSetManager.IsTempModSetActive())
+ TempModSetManager.RemoveMods(mods);
+ return result;
+ }
- public static async Task AddModMonetizationTeam(ModId modId, ICollection team)
+ #endregion //TempModSet
+
+ #region Service to Service
+
+ public static async Task> RequestUserDelegationToken()
{
var callbackConfirmation = openCallbacks.New();
-
- if (IsInitialized(out Result result) && IsAuthenticatedSessionValid(out result))
+ ResultAnd resultAnd = ResultAnd.Create(ResultBuilder.Unknown, default);
+ if (IsInitialized(out resultAnd.result) && IsAuthenticatedSessionValid(out resultAnd.result))
{
- var config = API.Requests.AddModMonetizationTeam.Request(modId, team);
- result = await openCallbacks.Run(callbackConfirmation,
- WebRequestManager.Request(config));
-
- ResponseCache.ClearModMonetizationTeamFromCache(modId);
+ var config = API.Requests.RequestUserDelegationToken.Request();
+ resultAnd = await openCallbacks.Run(callbackConfirmation, WebRequestManager.Request(config));
}
openCallbacks.Complete(callbackConfirmation);
- return result;
+ return resultAnd;
}
- public static async void AddModMonetizationTeam(Action callback, ModId modId, ICollection team)
+ public static async void RequestUserDelegationToken(Action> callback)
{
- Result result = await AddModMonetizationTeam(modId, team);
+ // Early out
+ if (callback == null)
+ {
+ Logger.Log(
+ LogLevel.Warning,
+ "No callback was given to the RequestUserDelegationToken method, any response "
+ + "returned from the server wont be used. This operation has been cancelled.");
+ return;
+ }
+ ResultAnd result = await RequestUserDelegationToken();
callback?.Invoke(result);
}
- #endregion //Monetization
+ #endregion
}
}
diff --git a/Runtime/ModIO.Implementation/Classes/ModIOVersion.cs b/Runtime/ModIO.Implementation/Classes/ModIOVersion.cs
index 683d39c..abc6152 100644
--- a/Runtime/ModIO.Implementation/Classes/ModIOVersion.cs
+++ b/Runtime/ModIO.Implementation/Classes/ModIOVersion.cs
@@ -5,35 +5,23 @@ internal struct ModIOVersion : System.IComparable
{
// ---------[ Singleton ]---------
/// Singleton instance for current version.
- public static readonly ModIOVersion Current = new ModIOVersion(2024, 3, 1, "beta");
+ public static readonly ModIOVersion Current = new ModIOVersion(2024, 8, 1, "");
// ---------[ Fields ]---------
/// Main Version number.
- public int year;
+ public readonly int year;
- // ---------[ Fields ]---------
/// Major version number.
- /// Represents the major version number. Increases when there is a breaking change
- /// to the interface.
- /// Changing between versions of the codebase with a different X value, will require changes
- /// to a consumer codebase in order to integrate.
- public int month;
+ public readonly int month;
/// Version build number.
- /// Represents the build version number. Increases when a new release is created
- /// for to the Asset Store/GitHub.
- /// Changing between versions of the codebase with a different Y value, will never require
- /// changes to a consumer codebase in order to integrate, but may offer additional
- /// functionality if changes are made.
- public int patch;
+ public readonly int patch;
/// Suffix for the current version.
- /// Represents additional, non-incremental version information about a build.
- /// This will never represent a difference in functionality or behaviour, but instead
- /// semantic information such as the production-readiness of a build, or the platform it was
- /// built for. Always written in lower-case, using underscore as a name break as necessary.
- ///
- public string suffix;
+ public readonly string suffix;
+
+ /// Header string containing all version information.
+ readonly string headerString;
// ---------[ Initialization ]---------
/// Constructs an object with the given version values.
@@ -48,6 +36,8 @@ public ModIOVersion(int year, int month, int patch, string suffix = null)
suffix = string.Empty;
}
this.suffix = suffix;
+
+ headerString = $"modio-{year}.{month}.{patch}{(suffix != string.Empty ? ("-" + suffix) : string.Empty)}";
}
// ---------[ IComparable Interface ]---------
@@ -97,7 +87,7 @@ public int CompareTo(ModIOVersion other)
#region Utility
/// Creates the request header representation of the version.
- public string ToHeaderString() => $"modio-{year.ToString()}.{month.ToString()}.{patch.ToString()}-{suffix}";
+ public readonly string ToHeaderString() => headerString;
#endregion // Utility
diff --git a/Runtime/ModIO.Implementation/Classes/ModManagement.cs b/Runtime/ModIO.Implementation/Classes/ModManagement.cs
index d6974c3..b586f47 100644
--- a/Runtime/ModIO.Implementation/Classes/ModManagement.cs
+++ b/Runtime/ModIO.Implementation/Classes/ModManagement.cs
@@ -2,6 +2,7 @@
using System.Threading.Tasks;
using ModIO.Implementation.API;
using ModIO.Implementation.API.Objects;
+using Runtime.Enums;
namespace ModIO.Implementation
{
@@ -74,9 +75,10 @@ internal static class ModManagement
///
/// Delegate that gets invoked whenever mod management starts, fails or ends a task/job
///
- public static ModManagementEventDelegate modManagementEventDelegate;
+ public static event ModManagementEventDelegate modManagementEventDelegate;
+ internal static void ClearModManagementEventDelegate() => modManagementEventDelegate = null; // Quick fix to allow for multiple subscriptions to the delegate
- static HashSet abortingDownloadsModObjectIds = new HashSet();
+ static HashSet abortingDownloadsOrInstallsModObjectIds = new HashSet();
#region Creation Tokens
public static CreationToken GenerateNewCreationToken()
@@ -150,6 +152,7 @@ public static void AbortCurrentInstallJob()
$"Aborting installation of Mod[{currentJob.modEntry.modObject.id}_" +
$"{currentJob.modEntry.modObject.modfile.id}]");
ModManagement.currentJob.zipOperation.Cancel();
+ abortingDownloadsOrInstallsModObjectIds.Add(currentJob.modEntry.modObject.id);
//I'm guessing this might put it into the tainted mods?
//Let's write a test.
@@ -161,12 +164,12 @@ public static void AbortCurrentDownloadJob()
$"Aborting download of Mod[{currentJob.modEntry.modObject.id}_" +
$"{currentJob.modEntry.modObject.modfile.id}]");
currentJob.downloadWebRequest?.cancel?.Invoke();
- abortingDownloadsModObjectIds.Add(currentJob.modEntry.modObject.id);
+ abortingDownloadsOrInstallsModObjectIds.Add(currentJob.modEntry.modObject.id);
}
static bool DownloadIsAborting(long id)
{
- return abortingDownloadsModObjectIds.Contains(id);
+ return abortingDownloadsOrInstallsModObjectIds.Contains(id);
}
static async Task PerformJobs()
@@ -184,7 +187,7 @@ static async Task PerformJobs()
if(DownloadIsAborting(currentJob.modEntry.modObject.id))
{
//clean this up, we shouldn't get here again
- abortingDownloadsModObjectIds.Remove(currentJob.modEntry.modObject.id);
+ abortingDownloadsOrInstallsModObjectIds.Remove(currentJob.modEntry.modObject.id);
if(previousJobs.ContainsKey(currentJob.modEntry.modObject.id))
previousJobs.Remove(currentJob.modEntry.modObject.id);
}
@@ -385,16 +388,6 @@ public static async Task PerformJob(ModManagementJob job)
ModManagementEventType.Uninstalled,
ResultBuilder.Success);
- // Re-check mods that may not have installed from low storage space
- if(notEnoughStorageMods.Count > 0)
- {
- // Also remove the temp archive file if we know we ran into storage issues
- // We do not need to check the result
- DataStorage.TryDeleteModfileArchive(modId, job.modEntry.currentModfile.id, out Result _);
- // the reason we dont always delete the archive is because a user may
- // accidentally hit unsubscribe or change their mind a few seconds later
- // and if it is a large mod it would take a long time to re-download it.
- }
// Flush not enough space mods from the tainted list so they will be re-attempted
foreach(var mod in notEnoughStorageMods)
{
@@ -434,8 +427,12 @@ static async Task PerformOperation_Download(ModManagementJob job)
long modId = job.modEntry.modObject.id;
long fileId = job.modEntry.modObject.modfile.id;
+ long totalArchiveFileSize = job.modEntry.modObject.modfile.filesize + job.modEntry.modObject.modfile.filesize_uncompressed;
+
// Check for enough storage space
- if(!await DataStorage.temp.IsThereEnoughDiskSpaceFor(job.modEntry.modObject.modfile.filesize))
+ // Since we need both the archive & the mods install, we check if we can fit both
+ // Should prevent rogue archives from stealing away our storage space
+ if(!await DataStorage.temp.IsThereEnoughDiskSpaceFor(totalArchiveFileSize))
{
Logger.Log(LogLevel.Error, $"INSUFFICIENT STORAGE FOR DOWNLOAD [{modId}_{fileId}]");
notEnoughStorageMods.Add((ModId)modId);
@@ -485,20 +482,29 @@ static async Task PerformOperation_Download(ModManagementJob job)
string md5 = job.modEntry.modObject.modfile.filehash.md5;
string downloadFilepath = DataStorage.GenerateModfileArchiveFilePath(modId, fileId);
- Result downloadResult = ResultBuilder.Unknown;
+ var downloadToFileHandle = WebRequestManager.DownloadToFile(fileURL, downloadFilepath, job.progressHandle);
- using (ModIOFileStream downloadStream = DataStorage.CreateArchiveDownloadStream(downloadFilepath, out Result openStreamResult))
+ Result downloadResult;
+ if (downloadToFileHandle != null)
+ {
+ job.downloadWebRequest = downloadToFileHandle;
+ downloadResult = await downloadToFileHandle.task;
+ }
+ else
{
- if(!openStreamResult.Succeeded())
+ using ModIOFileStream downloadStream = DataStorage.CreateArchiveDownloadStream(downloadFilepath, out Result openStreamResult);
+
+ if (!openStreamResult.Succeeded())
{
// Failed to open file stream to download to
return DownloadCleanup(result, modId, fileId);
}
// downloadResult = await ModioCommunications.DownloadBinary(fileURL, downloadStream, job.progressHandle);
- var handle = WebRequestManager.Download(fileURL, downloadStream, job.progressHandle);
- job.downloadWebRequest = handle;
- downloadResult = await handle.task;
+ downloadToFileHandle = WebRequestManager.Download(fileURL, downloadStream, job.progressHandle);
+
+ job.downloadWebRequest = downloadToFileHandle;
+ downloadResult = await downloadToFileHandle.task;
}
// Begin download
@@ -506,7 +512,6 @@ static async Task PerformOperation_Download(ModManagementJob job)
// ResultAnd downloadResponse = await RESTAPI.Request(
// fileURL, DownloadBinary.Template, null, downloadHandler,
// job.progressHandle);
-
// Check download result
if(downloadResult.Succeeded())
{
@@ -558,12 +563,8 @@ static async Task PerformOperation_Download(ModManagementJob job)
static Result DownloadCleanup(Result result, long modId, long fileId)
{
- if(!result.Succeeded())
- {
- // cleanup any file that may or may not have downloaded because it's corrupted
- DataStorage.TryDeleteModfileArchive(
- modId, fileId, out Result _);
- }
+ DataStorage.TryDeleteModfileArchive(modId, fileId, out Result _);
+
return result;
}
@@ -572,21 +573,18 @@ static async Task PerformOperation_Install(ModManagementJob job)
long modId = job.modEntry.modObject.id;
long fileId = job.modEntry.modObject.modfile.id;
- // Check for enough storage space
- // TODO update this later when we can confirm actual extracted file size
- // For now we are just making sure we have double the available space of the archive size as the estimate for the extracted file
- if(!await DataStorage.persistent.IsThereEnoughDiskSpaceFor(job.modEntry.modObject.modfile.filesize * 2L))
+ var extractOperation = new ExtractOperation(modId, fileId, job.progressHandle);
+ Result resultStorageTest = await extractOperation.IsThereEnoughSpaceForExtracting();
+
+ if (!resultStorageTest.Succeeded())
{
Logger.Log(LogLevel.Error, $"INSUFFICIENT STORAGE FOR INSTALLATION [{modId}_{fileId}]");
notEnoughStorageMods.Add((ModId)modId);
- return ResultBuilder.Create(ResultCode.IO_InsufficientStorage);
+ return DownloadCleanup(resultStorageTest, job.modEntry.modObject.id, job.modEntry.modObject.modfile.id);
}
Logger.Log(LogLevel.Verbose, $"INSTALLING MODFILE[{modId}_{fileId}]");
- ExtractOperation extractOperation =
- new ExtractOperation(modId, fileId, job.progressHandle);
-
// Cached so it can be cancelled on shutdown
job.zipOperation = extractOperation;
@@ -713,7 +711,7 @@ public static async Task DownloadNow(ModId modId)
}
else
{
- ModCollectionManager.UpdateModCollectionEntry(modId, modResponse.value, -1);
+ ModCollectionManager.UpdateModCollectionEntry(modId, modResponse.value, ModPriority.Urgent);
}
if (currentJob != null && currentJob.progressHandle.OperationType == ModManagementOperationType.Download)
@@ -843,7 +841,7 @@ static ModManagementJob FilterJob(ModManagementJob job, ModCollectionEntry mod,
job = new ModManagementJob { modEntry = mod, type = jobType };
}
- if(mod.priority < job.modEntry.priority)
+ if(mod.priority > job.modEntry.priority)
{
job = new ModManagementJob { modEntry = mod, type = jobType };
}
@@ -868,8 +866,7 @@ static async Task GetNextJobTypeForModCollectionEntr
if(delete )
{
- if(DataStorage.TryGetInstallationDirectory(modId, currentFileId,
- out string _))
+ if(DataStorage.TryGetInstallationDirectory(modId, currentFileId, out string _))
{
return ModManagementOperationType.Uninstall;
}
@@ -877,8 +874,7 @@ static async Task GetNextJobTypeForModCollectionEntr
// NOT INSTALLED (Tag entry for cleanup)
uninstalledModsWithNoUserSubscriptions.Add(modId);
}
- else if(DataStorage.TryGetInstallationDirectory(modId, currentFileId,
- out string _))
+ else if(DataStorage.TryGetInstallationDirectory(modId, currentFileId, out string _))
{
// INSTALLED (Check for update)
if(currentFileId != fileId)
@@ -909,6 +905,9 @@ static async Task GetNextJobTypeForModCollectionEntr
}
static bool ShouldThisModBeUninstalled(ModId modId)
{
+ if (TempModSetManager.IsPartOfModSet(modId))
+ return false;
+
List users = new List();
using(var enumerator = ModCollectionManager.Registry.existingUsers.GetEnumerator())
@@ -924,7 +923,7 @@ static bool ShouldThisModBeUninstalled(ModId modId)
}
}
- if(users.Count == 0)
+ if (users.Count == 0)
{
// No subscribed users, we can uninstall this mod
return true;
@@ -994,9 +993,11 @@ static bool ShouldModManagementBeRunning()
return true;
}
- public static void RemoveModFromTaintedJobs(ModId modid)
+ public static void RetryFailedDownload(ModId modid)
{
+ previousJobs.Remove(modid);
taintedMods.Remove(modid);
+ WakeUp();
}
}
}
diff --git a/Runtime/ModIO.Implementation/Classes/ModioPlatform.cs b/Runtime/ModIO.Implementation/Classes/ModioPlatform.cs
new file mode 100644
index 0000000..ab1fab5
--- /dev/null
+++ b/Runtime/ModIO.Implementation/Classes/ModioPlatform.cs
@@ -0,0 +1,93 @@
+using System.IO;
+using System.Threading.Tasks;
+using ModIO.Implementation.API;
+using UnityEngine;
+
+namespace ModIO.Implementation
+{
+ public class ModioPlatform
+ {
+ static ModioPlatform _activePlatform;
+ public static ModioPlatform ActivePlatform
+ {
+ get
+ {
+ if(_activePlatform != null)
+ return _activePlatform;
+
+ if (!Application.isConsolePlatform)
+ _activePlatform = new ModioPlatform();
+ else
+ Logger.Log(LogLevel.Error, "You must ser a ModioPlatform before calling some Modio classes on consoles");
+
+ return _activePlatform;
+ }
+ protected set
+ {
+ if (_activePlatform != null)
+ {
+ Logger.Log(LogLevel.Warning, $"Overriding active ModioPlatform to {value} after it was already set to {_activePlatform}."
+ + "Any previously called methods may have been called on the previous one");
+ }
+ _activePlatform = value;
+ }
+ }
+
+
+ ///
+ /// Set to true if you need smaller applet friendly pages on your platform
+ ///
+ public virtual bool WebBrowserNeedsSimplePages => false;
+
+ public virtual void OpenWebPage(string url)
+ {
+ Application.OpenURL(url);
+ }
+
+ public virtual bool TokenPackAvailableOnPlatform(TokenPack tokenPack)
+ {
+ if (GetPortalForPlatform(tokenPack, out _)) return true;
+
+ return false;
+ }
+
+ protected static bool GetPortalForPlatform(TokenPack tokenPack, out TokenPack.Portal portal)
+ {
+ var portalShortCode = ServerConstants.ConvertUserPortalToHeaderValue(Settings.build.userPortal);
+ foreach (var tokenPackPortal in tokenPack.portals)
+ {
+ if (tokenPackPortal.portal == portalShortCode)
+ {
+ portal = tokenPackPortal;
+ return true;
+ }
+ }
+ portal = default;
+ return false;
+ }
+
+ public virtual Task OpenPlatformPurchaseFlow()
+ {
+ Debug.LogError($"not yet implemented: opening platform store");
+ return Task.FromResult(ResultBuilder.Unknown);
+ }
+
+ public virtual bool TryGetAvailableDiskSpace(out long availableFreeSpace)
+ {
+ availableFreeSpace = 0;
+#if !ENABLE_IL2CPP
+ string persistentRootDirectory = DataStorage.persistent?.RootDirectory;
+ if (persistentRootDirectory == null)
+ {
+ return false;
+ }
+ FileInfo f = new FileInfo(persistentRootDirectory);
+ string drive = Path.GetPathRoot(f.FullName);
+ DriveInfo d = new DriveInfo(drive);
+ availableFreeSpace = d.AvailableFreeSpace;
+ return true;
+#endif
+ return false;
+ }
+ }
+}
diff --git a/Runtime/ModIO.Implementation/Classes/ModioPlatform.cs.meta b/Runtime/ModIO.Implementation/Classes/ModioPlatform.cs.meta
new file mode 100644
index 0000000..e5dd54f
--- /dev/null
+++ b/Runtime/ModIO.Implementation/Classes/ModioPlatform.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 22c8ab08f41943df848627cf3e9cda6f
+timeCreated: 1715566804
\ No newline at end of file
diff --git a/Runtime/ModIO.Implementation/Classes/ModioUnityPlatformExampleLoader.cs b/Runtime/ModIO.Implementation/Classes/ModioUnityPlatformExampleLoader.cs
new file mode 100644
index 0000000..2023b75
--- /dev/null
+++ b/Runtime/ModIO.Implementation/Classes/ModioUnityPlatformExampleLoader.cs
@@ -0,0 +1,58 @@
+using System;
+using System.Linq;
+using UnityEngine;
+
+namespace ModIO.Implementation
+{
+ public class ModioUnityPlatformExampleLoader : MonoBehaviour
+ {
+ [Serializable]
+ class PlatformExamples
+ {
+ public RuntimePlatform[] Platforms;
+ public string[] PrefabNames;
+ }
+
+ [SerializeField]
+ PlatformExamples[] _platformExamplesPerPlatform;
+
+ void Awake()
+ {
+ var runtimePlatform = Application.platform;
+ foreach (var platformExamples in _platformExamplesPerPlatform)
+ {
+ if (!platformExamples.Platforms.Contains(runtimePlatform))
+ continue;
+
+ foreach (string prefabName in platformExamples.PrefabNames)
+ {
+ GameObject prefab = Resources.Load(prefabName);
+ if (prefab != null)
+ Instantiate(prefab, transform);
+ else
+ Debug.LogError($"Couldn't find expected platformExample {prefabName} for platform {runtimePlatform}");
+ }
+ }
+ }
+
+ [ContextMenu("TestAllPrefabNamesAreFound")]
+ void TestAllPrefabNamesAreFound()
+ {
+ bool issues = false;
+ foreach (var platformExamples in _platformExamplesPerPlatform)
+ {
+ foreach (string prefabName in platformExamples.PrefabNames)
+ {
+ GameObject prefab = Resources.Load(prefabName);
+ if (prefab == null)
+ {
+ Debug.LogError($"Couldn't find expected platformExample {prefabName} for platform {platformExamples.Platforms.FirstOrDefault()}");
+ issues = true;
+ }
+ }
+ }
+ if (!issues)
+ Debug.Log("No issues found");
+ }
+ }
+}
diff --git a/Runtime/ModIO.Implementation/Classes/ModioUnityPlatformExampleLoader.cs.meta b/Runtime/ModIO.Implementation/Classes/ModioUnityPlatformExampleLoader.cs.meta
new file mode 100644
index 0000000..09d9fff
--- /dev/null
+++ b/Runtime/ModIO.Implementation/Classes/ModioUnityPlatformExampleLoader.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: d893cb02e65f4c99a4a0fab027d0c666
+timeCreated: 1722313750
\ No newline at end of file
diff --git a/Runtime/ModIO.Implementation/Classes/ResultAnd.cs b/Runtime/ModIO.Implementation/Classes/ResultAnd.cs
index 5d2209e..630c5d8 100644
--- a/Runtime/ModIO.Implementation/Classes/ResultAnd.cs
+++ b/Runtime/ModIO.Implementation/Classes/ResultAnd.cs
@@ -2,7 +2,7 @@
namespace ModIO.Implementation
{
/// Convenience wrapper for creating a ResultAnd.
- internal static class ResultAnd
+ public static class ResultAnd
{
public static ResultAnd Create(Result result, U value)
{
diff --git a/Runtime/ModIO.Implementation/Classes/ResultCode.cs b/Runtime/ModIO.Implementation/Classes/ResultCode.cs
index fff9840..be40907 100644
--- a/Runtime/ModIO.Implementation/Classes/ResultCode.cs
+++ b/Runtime/ModIO.Implementation/Classes/ResultCode.cs
@@ -141,11 +141,20 @@ internal static class ResultCode
// 11009 You have been rate limited from calling this endpoint again, for making too many requests. See Rate Limiting.
public const uint RESTAPI_RateLimitExceededEndpoint = 11009;
- // 11012 Invalid security code.
- public const uint RESTAPI_11012 = 11012;
+ // 11011 You have already redeemed this security code.
+ public const uint RESTAPI_EmailExchangeCodeAlreadyRedeemed = 11011;
- // 11014 security code has expired. Please request a new code
- public const uint RESTAPI_11014 = 11014;
+ // 11012 This security code has expired. Please request a new code.
+ public const uint RESTAPI_EmailExchangeCodeExpired = 11012;
+
+ // 11013 This security code is for a different API Key. Please contact support.
+ public const uint RESTAPI_EmailExchangeDifferentApiKey = 11013;
+
+ // 11014 Invalid security code. Please request a new code.
+ public const uint RESTAPI_EmailExchangeInvalidCode = 11014;
+
+ // 11015 Email link already used to auth.
+ public const uint RESTAPI_AuthLinkEmailAlreadyUsed = 11015;
// 11069 error.monetization_iap_connected_portal_account_not_found
public const uint RESTAPI_PortalAccountNotFound = 11069;
@@ -155,7 +164,6 @@ internal static class ResultCode
// 13002 The submitted binary file is unreadable.
public const uint RESTAPI_SubmittedBinaryUnreadable = 13002;
-
// 13004 You have used the input_json parameter with semantically incorrect JSON.
public const uint RESTAPI_JSONMalformed = 13004;
@@ -209,6 +217,9 @@ internal static class ResultCode
// 15028 The mod rating is already positive/negative
public const uint RESTAPI_ModRatingAlreadyExists = 15028;
+ // 15030 The reported mod is currently unauthorized or deleted and not eligible for reporting.
+ public const uint REPORT_RESOURCE_NOT_AVAILABLE_FOR_REPORT = 15030;
+
// 15043 The mod rating is already removed
public const uint RESTAPI_ModRatingNotFound = 15043;
@@ -218,15 +229,11 @@ internal static class ResultCode
// Codes I need to to do for auth/response cache:
// from
- // https://docs.mod.io/#authenticate-via-steam
+ // https://docs.mod.io/restapiref/#authenticate-via-steam
// 11018 The steam encrypted app ticket was invalid.
public const uint RESTAPI_InvalidSteamEncryptedAppTicket = 11018;
- // 11032 mod.io was unable to verify the credentials against the external service
- // provider.
- public const uint RESTAPI_CantVerifyCredentialsExternally = 11032;
-
// 11016 The api_key supplied in the request must be associated with a game.
public const uint RESTAPI_KeyNotAssociatedWithGame = 11016;
@@ -237,10 +244,10 @@ internal static class ResultCode
// 11019 The secret steam app ticket associated with this game has not been configured.
public const uint RESTAPI_SecretSteamAppTicketNotConfigured = 11019;
- // 11051 The user has not agreed to the mod.io Terms of Use.
- // Please see terms_agreed parameter description and the Terms endpoint for more
- // information.
- public const uint RESTAPI_UserMustAgreeToModIoTerms = 11051;
+ // 11020 Unable to get steam account data.
+ public const uint RESTAPI_SteamUnableToGetAccountData = 11020;
+
+ /* GOG */
// 11021 The GOG Galaxy encrypted app ticket was invalid.
public const uint RESTAPI_GogInvalidAppTicket = 11021;
@@ -249,8 +256,10 @@ internal static class ResultCode
// configured.
public const uint RESTAPI_GogGameNotConfigured = 11022;
- // 11031 mod.io was unable to get account data from itch.io servers.
- public const uint RESTAPI_UnableToFetchAccountDataFromItchIo = 11031;
+ // 11023 Unable to get GOG account data.
+ public const uint RESTAPI_GogUnableToGetAccountData = 11023;
+
+ /* Oculus */
// 11024 The secret Oculus Rift app ticket associated with this game has not been
// configured.
@@ -260,19 +269,40 @@ internal static class ResultCode
// configured.
public const uint RESTAPI_OculusQuestAppTicketNotConfigured = 11025;
+ // 11026 Unable to get Oculus account data.
+ public const uint RESTAPI_OculusUnableToGetAccountData = 11026;
+
+ /* XBOX */
+
// 11027 The Xbox Live token supplied in the request is invalid.
public const uint RESTAPI_XboxLiveTokenInvalid = 11027;
- // 11029 The Xbox Live token supplied has expired.
- public const uint RESTAPI_XboxLiveTokenExpired = 11029;
-
// 11028 The user is not permitted to interact with UGC. This can be modified in the
// user's Xbox Live profile.
public const uint RESTAPI_XboxNotAllowedToInteractWithUGC = 11028;
+ // 11029 The Xbox Live token supplied has expired.
+ public const uint RESTAPI_XboxLiveTokenExpired = 11029;
+
// 11030 Xbox Live users with 'Child' accounts are not permitted to use mod.io.
public const uint RESTAPI_XboxLiveChildAccountNotPermitted = 11030;
+ // 11042 Unable to get Json Web key signature from Xbox Live.
+ public const uint RESTAPI_XboxLiveUnableToGetJwkSignature = 11042;
+
+
+ // 11031 mod.io was unable to get account data from itch.io servers.
+ public const uint RESTAPI_UnableToFetchAccountDataFromItchIo = 11031;
+
+ // 11032 mod.io was unable to verify the credentials against the external service
+ // provider.
+ public const uint RESTAPI_CantVerifyCredentialsExternally = 11032;
+
+ // 11034 User is already verified by mod.io servers.
+ public const uint RESTAPI_UserAlreadyVerified = 11034;
+
+ /* Nintendo Switch */
+
// 11035 The NSA ID token was invalid/malformed.
public const uint RESTAPI_NsaIdTokenInvalid = 11035;
@@ -296,6 +326,58 @@ internal static class ResultCode
// authentication request.
public const uint RESTAPI_NintendoSwitchNotPermittedToAuthUsers = 11041;
+ /* Epic Games */
+
+ // 11044 Invalid Epic Games token.
+ public const uint RESTAPI_EpicGamesInvalidToken = 11044;
+
+ // 11045 Attempted to redeem Epic Games token before it's valid.
+ // Expired.
+ public const uint RESTAPI_EpicGamesInvalidTokenNotValidBefore = 11045;
+
+ // 11046 Attempted to redeem Epic Games token after it's valid.
+ // Expired.
+
+ public const uint RESTAPI_EpicGamesINvalidTokenNotValidAFter = 11046;
+
+ // 11048 Unable to get Epic Games json web key Signature
+ public const uint RESTAPI_EpicGamesUnableToGetJwkSignature = 11048;
+
+ // 11049 Unable to get Epic Games account data
+ public const uint RESTAPI_EpicGamesUnableToGetAccountData = 11049;
+
+ /* PSN */
+
+ // 11080 Invalid PSN Token
+ public const uint RESTAPI_PsnInavalidToken = 11080;
+
+ // 11081 Attempting to redeem PSN token before it's valid.
+ // Expired.
+ public const uint RESTAPI_PsnInvalidTokenNotValidBefore = 11081;
+
+ // 11082 Attempted to redeem PSN token after it's expired.
+ // Expired.
+ public const uint RESTAPI_PsnInvalidTokenNotValidAfter = 11082;
+
+ // 11083 Unable to get Json Web Key from PSN servers
+ public const uint RESTAPI_PsnUnableToGetJwkSignature = 11083;
+
+ // 11084 Unable to get account data from PSN servers
+ public const uint RESTAPI_PsnUnableToGetAccountData = 11084;
+
+ // 11085 PSN child profile is not permitted to access this service
+ public const uint RESTAPI_PsnChildProfileNotPermitted = 11085;
+
+ // 11096 The user is not permitted to interact with UGC. This can be modified in the
+ // user's PSN profile.
+ public const uint RESTAPI_PsnUgcInteractionNotPermitted = 11096;
+
+
+ // 11051 The user has not agreed to the mod.io Terms of Use.
+ // Please see terms_agreed parameter description and the Terms endpoint for more
+ // information.
+ public const uint RESTAPI_UserMustAgreeToModIoTerms = 11051;
+
// 11052 The access token was invalid/malformed.
public const uint RESTAPI_AccessTokenInvalid = 11052;
@@ -312,6 +394,15 @@ internal static class ResultCode
// 11043 mod.io was unable to get account data from the Discord servers.
public const uint RESTAPI_DiscordUnableToGetAccountData = 11043;
+ // 11058 The Facebook access token is invalid.
+ public const uint RESTAPI_FacebookInvalidToken = 11058;
+
+ // 11067 Unable to get Facebook account data.
+ public const uint RESTAPI_FacebookUnableToGetAccountData = 11067;
+
+ // 11059 User must be logged in before making requests.
+ public const uint RESTAPI_UserMustBeLoggedIn = 11059;
+
public const uint FILEUPLOAD_Error = 30033;
#endregion
@@ -436,8 +527,12 @@ internal static class ResultCode
"You have been rate limited globally for making too many requests. See Rate Limiting." },
{ RESTAPI_RateLimitExceededEndpoint,
"You have been rate limited from calling this endpoint again, for making too many requests. See Rate Limiting." },
- { RESTAPI_11012, "Invalid security code." },
- { RESTAPI_11014, "Security code has expired. Please request a new code." },
+
+ // EMAIL CODES
+ { RESTAPI_EmailExchangeCodeAlreadyRedeemed, "Security code already redeemed. Please request a new code."},
+ { RESTAPI_EmailExchangeCodeExpired, "Security code has expired. Please request a new code." },
+ { RESTAPI_EmailExchangeDifferentApiKey, "Security code is for a different API Key. Please contact support." },
+ { RESTAPI_EmailExchangeInvalidCode, "Invalid security code. Please request a new code." },
{ RESTAPI_SubmittedBinaryCorrupt, "The submitted binary file is corrupted." },
{ RESTAPI_SubmittedBinaryUnreadable, "The submitted binary file is unreadable." },
{ RESTAPI_JSONMalformed,
@@ -466,46 +561,102 @@ internal static class ResultCode
{ RESTAPI_ModRatingAlreadyExists, "The mod rating is already positive/negative" },
{ RESTAPI_ModRatingNotFound, "The mod rating is already removed" },
{ RESTAPI_UserIdNotFound, "The requested user could not be found." },
+ { RESTAPI_UserAlreadyVerified, "User is already verified with mod.io servers." },
+ { RESTAPI_UserMustBeLoggedIn, "User is not logged in yet. Please log in to complete requests." },
+
- { RESTAPI_InvalidSteamEncryptedAppTicket,
- "The steam encrypted app ticket was invalid." },
{ RESTAPI_CantVerifyCredentialsExternally,
"mod.io was unable to verify the credentials against the external service provider." },
{ RESTAPI_KeyNotAssociatedWithGame,
"The api_key supplied in the request must be associated with a game." },
{ RESTAPI_TestKeyForTestEnvOnly,
"The api_key supplied in the request is for test environment purposes only and cannot be used for this functionality." },
+ { RESTAPI_UserMustAgreeToModIoTerms,
+ "The user has not agreed to the mod.io Terms of Use. Please see terms_agreed parameter description and the Terms endpoint for more information." },
+
+ /* Steam */
+ { RESTAPI_InvalidSteamEncryptedAppTicket,
+ "The steam encrypted app ticket was invalid." },
{ RESTAPI_SecretSteamAppTicketNotConfigured,
"The secret steam app ticket associated with this game has not been configured." },
- { RESTAPI_UserMustAgreeToModIoTerms,
- "The user has not agreed to the mod.io Terms of Use. Please see terms_agreed parameter description and the Terms endpoint for more information." },
- { RESTAPI_GogInvalidAppTicket, "The GOG Galaxy encrypted app ticket was invalid." },
+ { RESTAPI_SteamUnableToGetAccountData,
+ "Unable to get account data from Steam, please try again later." },
+
+ /* GOG */
+ { RESTAPI_GogInvalidAppTicket,
+ "The GOG Galaxy encrypted app ticket was invalid." },
+ { RESTAPI_GogUnableToGetAccountData,
+ "Unable to get account data from GOG, please try again later." },
{ RESTAPI_GogGameNotConfigured,
"The secret GOG Galaxy app ticket associated with this game has not been configured." },
+
{ RESTAPI_UnableToFetchAccountDataFromItchIo,
"mod.io was unable to get account data from itch.io servers." },
+
+ /* Oculus */
{ RESTAPI_OculusRiftAppTicketNotConfigured,
"The secret Oculus Rift app ticket associated with this game has not been configured." },
{ RESTAPI_OculusQuestAppTicketNotConfigured,
"The secret Oculus Quest app ticket associated with this game has not been configured." },
+ { RESTAPI_OculusUnableToGetAccountData,
+ "Unable to get account data from Oculus. Please try again later." },
+
+ /* XBOX */
{ RESTAPI_XboxLiveTokenInvalid,
"The Xbox Live token supplied in the request is invalid." },
- { RESTAPI_XboxLiveTokenExpired, "The Xbox Live token supplied has expired." },
+ { RESTAPI_XboxLiveTokenExpired,
+ "The Xbox Live token supplied has expired." },
{ RESTAPI_XboxNotAllowedToInteractWithUGC,
"The user is not permitted to interact with UGC. This can be modified in the user's Xbox Live profile." },
{ RESTAPI_XboxLiveChildAccountNotPermitted,
"Xbox Live users with 'Child' accounts are not permitted to use mod.io." },
- { RESTAPI_NsaIdTokenInvalid, "The NSA ID token was invalid/malformed." },
+ { RESTAPI_XboxLiveUnableToGetJwkSignature,
+ "Unable to get the JSON Web Key from Xbox Live. Please try again later." },
+
+ /* PSN */
+ { RESTAPI_PsnInavalidToken,
+ "The PSN token supplied in the request is invalid." },
+ { RESTAPI_PsnInvalidTokenNotValidAfter,
+ "The PSN token has expired. Please request a new token and ensure it is delivered to mod.io before it expires." },
+ { RESTAPI_PsnInvalidTokenNotValidBefore,
+ "The PSN token is not valid yet." },
+ { RESTAPI_PsnUnableToGetAccountData,
+ "Unable to get account data from PSN. Please try again later." },
+ { RESTAPI_PsnUnableToGetJwkSignature,
+ "Unable to get JSON Web Key from PSN. Please try again later." },
+ { RESTAPI_PsnChildProfileNotPermitted,
+ "Child accounts are not permitted to use external services such as mod.io." },
+ { RESTAPI_PsnUgcInteractionNotPermitted,
+ "This user is not permitted to interact with UGC. This can be modified in the user's PSN profile." },
+
+ /* Nintendo Switch */
+ { RESTAPI_NsaIdTokenInvalid,
+ "The NSA ID token was invalid/malformed." },
{ RESTAPI_UnableToVerifyNintendoCredentials,
"mod.io was unable to validate the credentials with Nintendo Servers." },
- { RESTAPI_NsaIdTokenNotValidYet, "The NSA ID token is not valid yet." },
+ { RESTAPI_NsaIdTokenNotValidYet,
+ "The NSA ID token is not valid yet." },
{ RESTAPI_NsaIdTokenExpired,
"The NSA ID token has expired. You should request another token from the Switch SDK and ensure it is delivered to mod.io before it expires." },
{ RESTAPI_NintendoSwitchAppIdNotConfigured,
"The application ID for the Nintendo Switch title has not been configured, this can be setup in the 'Options' tab within your game profile." },
{ RESTAPI_NintendoSwitchNotPermittedToAuthUsers,
"The application ID of the originating Switch title is not permitted to authenticate users. Please check the Switch application id submitted on your games' 'Options' tab and ensure it is the same application id of the Switch title making the authentication request." },
- { RESTAPI_AccessTokenInvalid, "//11052 The access token was invalid/malformed." },
+
+ /* Epic Games */
+ { RESTAPI_EpicGamesInvalidToken,
+ "The Epic Games token supplied in the request is invalid." },
+ { RESTAPI_EpicGamesINvalidTokenNotValidAFter,
+ "The Epic Games token has expired. Please request a new token and ensure it is delivered to mod.io before it expires." },
+ { RESTAPI_EpicGamesInvalidTokenNotValidBefore,
+ "The Epic Games token is not valid yet." },
+ { RESTAPI_EpicGamesUnableToGetAccountData,
+ "Unable to get account data from Epic Games. Please try again later." },
+ { RESTAPI_EpicGamesUnableToGetJwkSignature,
+ "Unable to get JSON Web Key from Epic Games. Please try again later." },
+
+ { RESTAPI_AccessTokenInvalid,
+ "//11052 The access token was invalid/malformed." },
{ RESTAPI_UnableToValidateCredentialsWithGoogle,
"mod.io was unable to validate the credentials with Google's servers." },
{ RESTAPI_GoogleAccessTokenNotValidYet, "The Google access token is not valid yet." },
@@ -513,6 +664,10 @@ internal static class ResultCode
"The Google access token has expired. You should request another token from the Google SDK and ensure it is delivered to mod.io before it expires." },
{ RESTAPI_DiscordUnableToGetAccountData,
"mod.io was unable to get account data from the Discord servers." },
+ { RESTAPI_FacebookInvalidToken,
+ "The Facebook access token is invalid." },
+ { RESTAPI_FacebookUnableToGetAccountData,
+ "Unable to get account data from Facebook. Please try again later." },
};
public static bool IsInvalidSession(ErrorObject errorObject)
diff --git a/Runtime/ModIO.Implementation/Classes/SessionData.cs b/Runtime/ModIO.Implementation/Classes/SessionData.cs
new file mode 100644
index 0000000..2fb8a30
--- /dev/null
+++ b/Runtime/ModIO.Implementation/Classes/SessionData.cs
@@ -0,0 +1,12 @@
+using System.Threading;
+
+namespace Plugins.mod.io.Runtime.ModIO.Implementation.Classes
+{
+ internal class SessionData
+ {
+ public string SessionId;
+ public long[] ModIds;
+ public int CurrentNonce;
+ public CancellationTokenSource HeartbeatCancellationToken;
+ }
+}
diff --git a/Runtime/ModIO.Implementation/Classes/SessionData.cs.meta b/Runtime/ModIO.Implementation/Classes/SessionData.cs.meta
new file mode 100644
index 0000000..a9a3161
--- /dev/null
+++ b/Runtime/ModIO.Implementation/Classes/SessionData.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 0edac64d32e394e0d8a53e72710112a7
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/ModIO.Implementation/Classes/Settings.cs b/Runtime/ModIO.Implementation/Classes/Settings.cs
index 4a067e4..ebf3fa9 100644
--- a/Runtime/ModIO.Implementation/Classes/Settings.cs
+++ b/Runtime/ModIO.Implementation/Classes/Settings.cs
@@ -8,5 +8,8 @@ public static class Settings
/// Build settings.
public static BuildSettings build;
+
+ /// UI settings.
+ public static UISettings ui;
}
}
diff --git a/Runtime/ModIO.Implementation/Classes/SettingsAsset.cs b/Runtime/ModIO.Implementation/Classes/SettingsAsset.cs
index be38dba..f061900 100644
--- a/Runtime/ModIO.Implementation/Classes/SettingsAsset.cs
+++ b/Runtime/ModIO.Implementation/Classes/SettingsAsset.cs
@@ -38,7 +38,7 @@ private void Awake()
/// Loads the settings asset at the default path.
public static Result TryLoad(out ServerSettings serverSettings,
- out BuildSettings buildSettings)
+ out BuildSettings buildSettings, out UISettings uiSettings)
{
SettingsAsset asset = Resources.Load(FilePath);
@@ -46,11 +46,13 @@ public static Result TryLoad(out ServerSettings serverSettings,
{
serverSettings = new ServerSettings();
buildSettings = new BuildSettings();
+ uiSettings = new UISettings();
return ResultBuilder.Create(ResultCode.Init_FailedToLoadConfig);
}
serverSettings = asset.serverSettings;
buildSettings = asset.GetBuildSettings();
+ uiSettings = asset.uiSettings;
Resources.UnloadAsset(asset);
return ResultBuilder.Success;
@@ -67,6 +69,23 @@ public static Result TryLoad(out bool autoInitializePlugin)
}
autoInitializePlugin = asset.autoInitializePlugin;
+
+ Resources.UnloadAsset(asset);
+ return ResultBuilder.Success;
+ }
+
+ public static Result TryLoad(out string analyticsPrivateKey)
+ {
+ SettingsAsset asset = Resources.Load(FilePath);
+
+ if(asset == null)
+ {
+ analyticsPrivateKey = String.Empty;
+ return ResultBuilder.Create(ResultCode.Init_FailedToLoadConfig);
+ }
+
+ analyticsPrivateKey = asset.analyticsPrivateKey;
+
Resources.UnloadAsset(asset);
return ResultBuilder.Success;
}
@@ -79,6 +98,9 @@ public static Result TryLoad(out bool autoInitializePlugin)
[HideInInspector]
public ServerSettings serverSettings;
+ [HideInInspector]
+ public UISettings uiSettings;
+
// NOTE(@jackson):
// The following section is the template for what a platform-specific implementation
// should look like. The platform partial will include a BuildSettings field
@@ -87,7 +109,8 @@ public static Result TryLoad(out bool autoInitializePlugin)
//Initializes the ModIO plugin, with default settings, the first time it is used
[SerializeField] private bool autoInitializePlugin = true;
-
+ //Private key used to generate analytics hash
+ [SerializeField, Delayed] private string analyticsPrivateKey;
/// Level to log at.
[SerializeField] private LogLevel playerLogLevel;
/// Level to log at.
diff --git a/Runtime/ModIO.Implementation/Classes/TaskQueueRunner.cs b/Runtime/ModIO.Implementation/Classes/TaskQueueRunner.cs
index ab071ca..609da23 100644
--- a/Runtime/ModIO.Implementation/Classes/TaskQueueRunner.cs
+++ b/Runtime/ModIO.Implementation/Classes/TaskQueueRunner.cs
@@ -118,6 +118,7 @@ public async Task PerformTasks()
/// The function that represents the task to be executed.
/// The priority of the task (TaskPriority).
/// The size of the task.
+ ///
/// Returns a Task of type T for awaiting purposes.
public Task AddTask(TaskPriority prio, int taskSize, Func> taskFunc, bool useSeparateThread = false)
{
diff --git a/Runtime/ModIO.Implementation/Classes/TempModSetManager.cs b/Runtime/ModIO.Implementation/Classes/TempModSetManager.cs
new file mode 100644
index 0000000..789f255
--- /dev/null
+++ b/Runtime/ModIO.Implementation/Classes/TempModSetManager.cs
@@ -0,0 +1,72 @@
+using System.Collections.Generic;
+using System.Linq;
+using ModIO;
+using ModIO.Implementation;
+
+internal static class TempModSetManager
+{
+ static HashSet tempModSetMods = new HashSet();
+ static readonly HashSet removedTempModSetMods = new HashSet();
+ static bool tempModSetActive = false;
+
+ internal static void CreateTempModSet(IEnumerable modIds)
+ {
+ DeleteTempModSet();
+ tempModSetActive = true;
+ tempModSetMods = new HashSet(modIds);
+ }
+
+ internal static void DeleteTempModSet()
+ {
+ tempModSetMods.Clear();
+ removedTempModSetMods.Clear();
+ tempModSetActive = false;
+ }
+
+ internal static IEnumerable GetMods(bool includeRemovedMods = false) => includeRemovedMods ? tempModSetMods.Concat(removedTempModSetMods) : tempModSetMods;
+
+ public static bool IsTempModSetActive() => tempModSetActive;
+
+ internal static bool IsPartOfModSet(ModId modId)
+ {
+ return tempModSetMods.Contains(modId) || removedTempModSetMods.Contains(modId);
+ }
+
+ internal static bool IsUnsubscribedTempMod(ModId modId)
+ {
+ if (!IsPartOfModSet(modId))
+ return false;
+
+ var mods = ModIOUnity.GetSubscribedMods(out Result r);
+ if (!r.Succeeded())
+ {
+ Logger.Log(LogLevel.Error, "Unable to get subscribed mods aborting IsTempInstall function.");
+ return false;
+ }
+
+ foreach (var mod in mods)
+ {
+ if (mod.modProfile.id == modId)
+ return false;
+ }
+ return true;
+ }
+
+ internal static void AddMods(IEnumerable modIds)
+ {
+ foreach (var modId in modIds)
+ {
+ removedTempModSetMods.Remove(modId);
+ tempModSetMods.Add(modId);
+ }
+ }
+
+ internal static void RemoveMods(IEnumerable modIds)
+ {
+ foreach (var modId in modIds)
+ {
+ if (tempModSetMods.Remove(modId))
+ removedTempModSetMods.Add(modId);
+ }
+ }
+}
diff --git a/Runtime/ModIO.Implementation/Classes/TempModSetManager.cs.meta b/Runtime/ModIO.Implementation/Classes/TempModSetManager.cs.meta
new file mode 100644
index 0000000..fa4fbb5
--- /dev/null
+++ b/Runtime/ModIO.Implementation/Classes/TempModSetManager.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: a8f7ef9a816c78f4591e16a802ad3d11
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/ModIO.Implementation/Implementation.API/Classes/DownloadHandlerStream.cs b/Runtime/ModIO.Implementation/Implementation.API/Classes/DownloadHandlerStream.cs
new file mode 100644
index 0000000..b0f1149
--- /dev/null
+++ b/Runtime/ModIO.Implementation/Implementation.API/Classes/DownloadHandlerStream.cs
@@ -0,0 +1,22 @@
+using System.IO;
+using UnityEngine.Networking;
+
+namespace ModIO.Implementation.API
+{
+ internal class DownloadHandlerStream : DownloadHandlerScript
+ {
+ const int BufferSize = 1024*1024;
+
+ readonly Stream _writeTo;
+ public DownloadHandlerStream(Stream writeTo) : base(new byte[BufferSize])
+ {
+ _writeTo = writeTo;
+ }
+
+ protected override bool ReceiveData(byte[] data, int dataLength)
+ {
+ _writeTo.Write(data, 0, dataLength);
+ return true;
+ }
+ }
+}
diff --git a/Runtime/ModIO.Implementation/Implementation.API/Classes/DownloadHandlerStream.cs.meta b/Runtime/ModIO.Implementation/Implementation.API/Classes/DownloadHandlerStream.cs.meta
new file mode 100644
index 0000000..df4ba20
--- /dev/null
+++ b/Runtime/ModIO.Implementation/Implementation.API/Classes/DownloadHandlerStream.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: c6c149a24f4e4e5c96c8438cb1b1fdc5
+timeCreated: 1718954099
\ No newline at end of file
diff --git a/Runtime/ModIO.Implementation/Implementation.API/Classes/IWebRequestRunner.cs b/Runtime/ModIO.Implementation/Implementation.API/Classes/IWebRequestRunner.cs
new file mode 100644
index 0000000..142ad0b
--- /dev/null
+++ b/Runtime/ModIO.Implementation/Implementation.API/Classes/IWebRequestRunner.cs
@@ -0,0 +1,11 @@
+using System.IO;
+using System.Threading.Tasks;
+
+namespace ModIO.Implementation.API
+{
+ interface IWebRequestRunner
+ {
+ RequestHandle Download(string url, Stream downloadTo, ProgressHandle progressHandle);
+ Task> Execute(WebRequestConfig config, RequestHandle> handle, ProgressHandle progressHandle);
+ }
+}
diff --git a/Runtime/ModIO.Implementation/Implementation.API/Classes/IWebRequestRunner.cs.meta b/Runtime/ModIO.Implementation/Implementation.API/Classes/IWebRequestRunner.cs.meta
new file mode 100644
index 0000000..9ec0fe9
--- /dev/null
+++ b/Runtime/ModIO.Implementation/Implementation.API/Classes/IWebRequestRunner.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: c47817012d6040ce9df70fc64a491a3c
+timeCreated: 1718332748
\ No newline at end of file
diff --git a/Runtime/ModIO.Implementation/Implementation.API/Classes/IWebRequestRunnerDownloadToFile.cs b/Runtime/ModIO.Implementation/Implementation.API/Classes/IWebRequestRunnerDownloadToFile.cs
new file mode 100644
index 0000000..8efa098
--- /dev/null
+++ b/Runtime/ModIO.Implementation/Implementation.API/Classes/IWebRequestRunnerDownloadToFile.cs
@@ -0,0 +1,7 @@
+namespace ModIO.Implementation.API
+{
+ interface IWebRequestRunnerDownloadToFile
+ {
+ RequestHandle Download(string url, string downloadToFilepath, ProgressHandle progressHandle);
+ }
+}
diff --git a/Runtime/ModIO.Implementation/Implementation.API/Classes/IWebRequestRunnerDownloadToFile.cs.meta b/Runtime/ModIO.Implementation/Implementation.API/Classes/IWebRequestRunnerDownloadToFile.cs.meta
new file mode 100644
index 0000000..ff54ecb
--- /dev/null
+++ b/Runtime/ModIO.Implementation/Implementation.API/Classes/IWebRequestRunnerDownloadToFile.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 9057b68e894046b09f66272c3cc5f2d0
+timeCreated: 1718948950
\ No newline at end of file
diff --git a/Runtime/ModIO.Implementation/Implementation.API/Classes/ResponseCache.cs b/Runtime/ModIO.Implementation/Implementation.API/Classes/ResponseCache.cs
index 3486feb..c284d19 100644
--- a/Runtime/ModIO.Implementation/Implementation.API/Classes/ResponseCache.cs
+++ b/Runtime/ModIO.Implementation/Implementation.API/Classes/ResponseCache.cs
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Serialization.Formatters.Binary;
+using System.Threading;
using System.Threading.Tasks;
using ModIO.Implementation.API.Objects;
@@ -41,8 +42,6 @@ public static bool
// milliseconds (60,000 being 60 seconds)
const int modLifetimeInCache = 60000;
- static double lastWalletUpdateTime = 0;
-
///
/// stores md5 hashes generated after retrieving Terms of Use from the RESTAPI
///
@@ -72,7 +71,7 @@ public static bool
static Dictionary entitlementsCache = new Dictionary();
static Dictionary modsMonetizationTeams = new Dictionary();
static bool currentRatingsCached = false;
- static WalletObject walletObject;
+ static Wallet wallet;
///
/// the terms of use, cached for the entire session.
@@ -83,6 +82,14 @@ public static bool
/// The game tags, cached for the entire session.
///
static TagCategory[] gameTags;
+ ///
+ /// Tag localizations, keyed by English tags.
Value is a second dictionary keyed by language code, with value being the localized tag.
+ ///
+ /// string tag = "Hello";
+ /// string french = gameTagToLocalizations[tag]["fr"]; // "Bonjour"
+ ///
+ ///
+ static readonly Dictionary> GameTagToLocalizations = new Dictionary>();
/// The token packs, cached for the entire session.
static TokenPack[] tokenPacks;
@@ -202,12 +209,32 @@ public static void AddModToCache(ModProfile mod)
public static void AddUserToCache(UserProfile profile)
{
currentUser = profile;
- lastWalletUpdateTime = DateTime.UtcNow.TimeOfDay.TotalMilliseconds;
}
- public static void AddTagsToCache(TagCategory[] tags)
+ public static void AddTagsToCache(TagCategory[] tagCategories)
+ {
+ gameTags = tagCategories;
+
+ foreach (var tagCategory in tagCategories)
+ foreach (var tagLocalized in tagCategory.tagsLocalized)
+ AddTagLocalizations(tagLocalized["en"], tagLocalized);
+ }
+
+ public static void AddTagLocalization(string tag, string languageCode, string value)
+ {
+ if (!GameTagToLocalizations.TryGetValue(tag, out Dictionary languageCodes))
+ languageCodes = GameTagToLocalizations[tag] = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ languageCodes[languageCode] = value;
+ }
+
+ public static void AddTagLocalizations(string tag, Dictionary localizations)
{
- gameTags = tags;
+ if (!GameTagToLocalizations.TryGetValue(tag, out Dictionary languageCodes))
+ languageCodes = GameTagToLocalizations[tag] = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (KeyValuePair localization in localizations)
+ languageCodes[localization.Key] = localization.Value;
}
public static void AddTokenPacksToCache(TokenPack[] tokenPacks) => ResponseCache.tokenPacks = tokenPacks;
@@ -256,15 +283,18 @@ private static void AddEntitlement(string transactionId, Entitlement entitlement
entitlementsCache.Add(transactionId, entitlement);
}
- public static void UpdateWallet(WalletObject wo)
+ public static void ReplaceWallet(WalletObject wo)
{
- walletObject = wo;
+ wallet = ResponseTranslator.ConvertWalletObjectToWallet(wo);
+ ClearWalletFromCacheAfterDelay();
}
public static void UpdateWallet(int balance)
{
- if (walletObject != null)
- walletObject.balance = balance;
+ if (wallet != null)
+ {
+ wallet.balance = balance;
+ }
}
#endregion // Adding entries to Cache
@@ -366,9 +396,11 @@ public static bool GetUserProfileFromCache(out UserProfile userProfile)
return false;
}
+ public static bool AreTagsCached() => gameTags != null;
+
public static bool GetTagsFromCache(out TagCategory[] tags)
{
- if(gameTags != null)
+ if(AreTagsCached())
{
if(logCacheMessages)
{
@@ -382,19 +414,17 @@ public static bool GetTagsFromCache(out TagCategory[] tags)
return false;
}
- public static bool GetTokenPacksFromCache(out TokenPack[] tokenPacks)
+ public static string GetTagLocalized(string tag, string languageCode)
{
- if (ResponseCache.tokenPacks != null)
- {
- if(logCacheMessages)
- Logger.Log(LogLevel.Verbose, "[CACHE] retrieved token packs from cache");
+ if (
+ GameTagToLocalizations.TryGetValue(tag, out Dictionary languageCodes)
+ && languageCodes.TryGetValue(languageCode, out string result)
+ )
+ return result;
- tokenPacks = ResponseCache.tokenPacks;
- return true;
- }
+ if (gameTags == null) Logger.Log(LogLevel.Error, $@"A translation for tag ""{tag}"" was not found for language code ""{languageCode}"", though it may exist. Ensure {nameof(ModIOUnityAsync.FetchUpdates)} or {nameof(ModIOUnityAsync.GetTagCategories)} has been called once before attempting to access localization.");
- tokenPacks = null;
- return false;
+ return tag;
}
public static bool GetModCommentsFromCache(string url, out CommentPage commentObjs)
@@ -509,18 +539,6 @@ public static bool GetCurrentUserRatingFromCache(ModId modId, out ModRating modR
public static bool HaveRatingsBeenCachedThisSession() => currentRatingsCached;
- public static bool GetWalletFromCache(out Wallet wo)
- {
- if(walletObject != null && DateTime.UtcNow.TimeOfDay.TotalMilliseconds - lastWalletUpdateTime >= modLifetimeInCache)
- {
- wo = ResponseTranslator.ConvertWalletObjectToWallet(walletObject);
- return true;
- }
-
- wo = default;
- return false;
- }
-
public static bool GetModMonetizationTeamCache(ModId modId, out MonetizationTeamAccount[] teamAccounts)
{
if(modsMonetizationTeams.TryGetValue(modId, out teamAccounts))
@@ -534,6 +552,7 @@ public static bool GetModMonetizationTeamCache(ModId modId, out MonetizationTeam
return false;
}
+ public static bool GetWalletFromCache(out Wallet w) => (w = wallet) != null;
#endregion // Getting entries from Cache
#region Clearing Cache entries
@@ -574,6 +593,17 @@ static async void ClearModFromCacheAfterDelay(ModId modId)
}
}
+ static CancellationTokenSource cancellationTokenSource;
+ static async void ClearWalletFromCacheAfterDelay()
+ {
+ if (cancellationTokenSource != null)
+ cancellationTokenSource.Cancel();
+
+ cancellationTokenSource = new CancellationTokenSource();
+ await Task.Delay(modLifetimeInCache, cancellationTokenSource.Token); // 60 second cache
+ wallet = null;
+ }
+
static async void ClearModsFromCacheAfterDelay(List modIds)
{
// Use this list to mark modIds that need to be cleared
@@ -635,6 +665,11 @@ public static void ClearModMonetizationTeamFromCache(ModId modId)
modsMonetizationTeams.Remove(modId);
}
+ public static void ClearWalletFromCache()
+ {
+ wallet = null;
+ }
+
///
/// Clears the entire cache, used when performing a shutdown operation.
///
@@ -645,12 +680,13 @@ public static void ClearCache()
termsHash = default;
termsOfUse = null;
gameTags = null;
+ GameTagToLocalizations.Clear();
commentObjectsCache.Clear();
modsDependencies?.Clear();
modsMonetizationTeams.Clear();
currentUserRatings?.Clear();
currentRatingsCached = false;
- walletObject = null;
+ wallet = null;
ClearUserFromCache();
}
#endregion // Clearing Cache entries
diff --git a/Runtime/ModIO.Implementation/Implementation.API/Classes/ResponseTranslator.cs b/Runtime/ModIO.Implementation/Implementation.API/Classes/ResponseTranslator.cs
index 2eb17c4..9a489f3 100644
--- a/Runtime/ModIO.Implementation/Implementation.API/Classes/ResponseTranslator.cs
+++ b/Runtime/ModIO.Implementation/Implementation.API/Classes/ResponseTranslator.cs
@@ -39,7 +39,10 @@ public static TermsOfUse ConvertTermsObjectToTermsOfUse(TermsObject termsObject)
termsOfUse = termsObject.plaintext,
agreeText = termsObject.buttons.agree.text,
disagreeText = termsObject.buttons.disagree.text,
- links = GetLinks(termsObject.links.website, termsObject.links.terms, termsObject.links.privacy, termsObject.links.manage),
+ links = GetLinks((termsObject.links.website, nameof(TermsLinksObject.website)),
+ (termsObject.links.terms, nameof(TermsLinksObject.terms)),
+ (termsObject.links.privacy, nameof(TermsLinksObject.privacy)),
+ (termsObject.links.manage, nameof(TermsLinksObject.manage))),
hash = new TermsHash
{
md5hash = IOUtil.GenerateMD5(termsObject.plaintext),
@@ -48,13 +51,14 @@ public static TermsOfUse ConvertTermsObjectToTermsOfUse(TermsObject termsObject)
return terms;
- TermsOfUseLink[] GetLinks(params TermsLinkObject[] links)
+ TermsOfUseLink[] GetLinks(params (TermsLinkObject linkObject, string fieldName)[] links)
{
return links.Select(link => new TermsOfUseLink
{
- name = link.text,
- url = link.url,
- required = link.required,
+ apiName = link.fieldName,
+ name = link.linkObject.text,
+ url = link.linkObject.url,
+ required = link.linkObject.required,
}).ToArray();
}
}
@@ -68,6 +72,7 @@ public static TagCategory[] ConvertGameTagOptionsObjectToTagCategories(
{
categories[i] = new TagCategory();
categories[i].name = gameTags[i].name ?? "";
+ categories[i].nameLocalized = gameTags[i].name_localization == null ? new Dictionary() : new Dictionary(gameTags[i].name_localization, StringComparer.OrdinalIgnoreCase);
Tag[] tags = new Tag[gameTags[i].tags.Length];
for (int ii = 0; ii < tags.Length; ii++)
{
@@ -78,6 +83,7 @@ public static TagCategory[] ConvertGameTagOptionsObjectToTagCategories(
}
categories[i].tags = tags;
+ categories[i].tagsLocalized = gameTags[i].tags_localization == null ? Array.Empty>() : gameTags[i].tags_localization.Select(tagLocalization => new Dictionary(tagLocalization.translations, StringComparer.OrdinalIgnoreCase)).ToArray();
categories[i].multiSelect = gameTags[i].type == "checkboxes";
categories[i].hidden = gameTags[i].hidden;
categories[i].locked = gameTags[i].locked;
@@ -172,11 +178,19 @@ public static ModDependencies[] ConvertModDependenciesObjectToModDependencies(Mo
int index = 0;
foreach (var modDepObj in modDependenciesObjects)
{
+ ModId modId = new ModId(modDepObj.mod_id);
modDependencies[index] = new ModDependencies
{
- modId = new ModId(modDepObj.mod_id),
- modName = modDepObj.mod_name,
- dateAdded = GetUTCDateTime(modDepObj.date_added)
+ modId = modId,
+ modName = modDepObj.name,
+ modNameId = modDepObj.name_id,
+ dateAdded = GetUTCDateTime(modDepObj.date_added),
+ dependencyDepth = modDepObj.dependency_depth,
+ logoImage_320x180 = CreateDownloadReference(modDepObj.logo.filename, modDepObj.logo.thumb_320x180, modId),
+ logoImage_640x360 = CreateDownloadReference(modDepObj.logo.filename, modDepObj.logo.thumb_640x360, modId),
+ logoImage_1280x720 = CreateDownloadReference(modDepObj.logo.filename, modDepObj.logo.thumb_1280x720, modId),
+ logoImageOriginal = CreateDownloadReference(modDepObj.logo.filename, modDepObj.logo.original, modId),
+ modfile = ConvertModfileObjectToModfile(modDepObj.modfile),
};
index++;
}
@@ -292,6 +306,7 @@ public static ModProfile ConvertModObjectToModProfile(ModObject modObject)
int galleryImagesCount = modObject.media.images?.Length ?? 0;
DownloadReference[] galleryImages_320x180 = new DownloadReference[galleryImagesCount];
DownloadReference[] galleryImages_640x360 = new DownloadReference[galleryImagesCount];
+ DownloadReference[] galleryImages_1280x720 = new DownloadReference[galleryImagesCount];
DownloadReference[] galleryImages_Original = new DownloadReference[galleryImagesCount];
for (int i = 0; i < galleryImagesCount; i++)
{
@@ -301,9 +316,12 @@ public static ModProfile ConvertModObjectToModProfile(ModObject modObject)
galleryImages_640x360[i] = CreateDownloadReference(
modObject.media.images[i].filename, modObject.media.images[i].thumb_320x180.Replace("320x180", "640x360"),
modId);
- galleryImages_Original[i] =
- CreateDownloadReference(modObject.media.images[i].filename,
- modObject.media.images[i].original, modId);
+ galleryImages_1280x720[i] = CreateDownloadReference(
+ modObject.media.images[i].filename, modObject.media.images[i].thumb_1280x720,
+ modId);
+ galleryImages_Original[i] = CreateDownloadReference(
+ modObject.media.images[i].filename, modObject.media.images[i].original,
+ modId);
}
KeyValuePair[] metaDataKvp = modObject.metadata_kvp == null
@@ -326,9 +344,11 @@ public static ModProfile ConvertModObjectToModProfile(ModObject modObject)
dateAdded: GetUTCDateTime(modObject.date_added),
dateUpdated: GetUTCDateTime(modObject.date_updated),
dateLive: GetUTCDateTime(modObject.date_live),
+ dependencies: modObject.dependencies,
galleryImagesOriginal: galleryImages_Original,
galleryImages_320x180: galleryImages_320x180,
galleryImages_640x360: galleryImages_640x360,
+ galleryImages_1280x720: galleryImages_1280x720,
logoImage_320x180: CreateDownloadReference(modObject.logo.filename, modObject.logo.thumb_320x180, modId),
logoImage_640x360: CreateDownloadReference(modObject.logo.filename, modObject.logo.thumb_640x360, modId),
logoImage_1280x720: CreateDownloadReference(modObject.logo.filename, modObject.logo.thumb_1280x720, modId),
@@ -362,8 +382,8 @@ public static ModProfile ConvertModObjectToModProfile(ModObject modObject)
private static ModPlatform[] ConvertModPlatformsObjectsToModPlatforms(ModPlatformsObject[] modPlatformsObjects)
{
- ModPlatform[] modPlatforms = new ModPlatform[modPlatformsObjects.Length];
- for (int i = 0; i < modPlatformsObjects.Length; i++)
+ ModPlatform[] modPlatforms = new ModPlatform[modPlatformsObjects?.Length ?? 0];
+ for (int i = 0; i < modPlatforms.Length; i++)
{
modPlatforms[i] = new ModPlatform()
{
@@ -385,7 +405,8 @@ private static Modfile ConvertModfileObjectToModfile(ModfileObject modfileObject
virusStatus = modfileObject.virus_status,
virusPositive = modfileObject.virus_positive,
virustotalHash = modfileObject.virustotal_hash,
- filesize = modfileObject.filesize,
+ filesize = modfileObject.filesize_uncompressed,
+ archiveFileSize = modfileObject.filesize,
filehashMd5 = modfileObject.filehash.md5,
filename = modfileObject.filename,
version = modfileObject.version,
@@ -468,6 +489,19 @@ public static MonetizationTeamAccount ConvertGameMonetizationTeamObjectToGameMon
};
}
+ public static Dictionary ConvertMetadataKvpObjects(MetadataKvpObjects metadataKvpObjects)
+ {
+ if (metadataKvpObjects.data == null)
+ return null;
+
+ Dictionary kvps = new Dictionary();
+ foreach (var o in metadataKvpObjects.data)
+ {
+ kvps.Add(o.metakey, o.metavalue);
+ }
+ return kvps;
+ }
+
#region Utility
public static DateTime GetUTCDateTime(long serverTimeStamp)
diff --git a/Runtime/ModIO.Implementation/Implementation.API/Classes/TestWebRequestRunner.cs b/Runtime/ModIO.Implementation/Implementation.API/Classes/TestWebRequestRunner.cs
new file mode 100644
index 0000000..c0112b7
--- /dev/null
+++ b/Runtime/ModIO.Implementation/Implementation.API/Classes/TestWebRequestRunner.cs
@@ -0,0 +1,61 @@
+using System.IO;
+using System.Threading.Tasks;
+using UnityEngine;
+
+namespace ModIO.Implementation.API
+{
+ ///
+ /// A WebRequestRunner that can simulate some different failure conditions such as offline status and interrupted downloads
+ ///
+ internal class TestWebRequestRunner : IWebRequestRunner
+ {
+ // Set this to true to cause all requests to timeout with failure after 1.5s
+ internal bool TestReturnFailedToConnect = false;
+
+ // Set this to true to cause downloads to show some progress and then fail
+ internal bool DownloadsInterruptPartWay = true;
+
+ IWebRequestRunner _fallbackTo = new UnityWebRequestRunner();
+
+ public RequestHandle Download(string url, Stream downloadTo, ProgressHandle progressHandle)
+ {
+ if (TestReturnFailedToConnect || DownloadsInterruptPartWay)
+ {
+ return new RequestHandle
+ {
+ progress = progressHandle,
+ task = DelayAndReturnError(progressHandle),
+ cancel = null,
+ };
+ }
+ return _fallbackTo.Download(url, downloadTo, progressHandle);
+ }
+ public Task> Execute(WebRequestConfig config, RequestHandle> handle, ProgressHandle progressHandle)
+ {
+ if (TestReturnFailedToConnect)
+ return DelayAndReturnError(progressHandle);
+
+ return _fallbackTo.Execute(config, handle, progressHandle);
+ }
+ static async Task> DelayAndReturnError(ProgressHandle progressHandle)
+ {
+ return new ResultAnd { result = await DelayAndReturnError(progressHandle) };
+ }
+ static async Task DelayAndReturnError(ProgressHandle progressHandle)
+ {
+ const int millisecondsDelay = 30;
+ const int totalDelay = 1500;
+ for (int i = 0; i < totalDelay; i += millisecondsDelay)
+ {
+ await Task.Delay(millisecondsDelay);
+ if (progressHandle != null)
+ {
+ progressHandle.Progress = 0.8f * (i / (float)totalDelay);
+ progressHandle.BytesPerSecond = Random.Range(150_000, 300_000);
+ }
+ }
+ Debug.LogWarning("TestWebRequestRunner is simulating a network failure");
+ return ResultBuilder.Create(ResultCode.API_FailedToConnect);
+ }
+ }
+}
diff --git a/Runtime/ModIO.Implementation/Implementation.API/Classes/TestWebRequestRunner.cs.meta b/Runtime/ModIO.Implementation/Implementation.API/Classes/TestWebRequestRunner.cs.meta
new file mode 100644
index 0000000..da81bf7
--- /dev/null
+++ b/Runtime/ModIO.Implementation/Implementation.API/Classes/TestWebRequestRunner.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: ece4c4a4c1ff4bd0a316659b66363fca
+timeCreated: 1720749294
\ No newline at end of file
diff --git a/Runtime/ModIO.Implementation/Implementation.API/Classes/ThrottledStreamWriteAsync.cs b/Runtime/ModIO.Implementation/Implementation.API/Classes/ThrottledStreamWriteAsync.cs
new file mode 100644
index 0000000..a7b3912
--- /dev/null
+++ b/Runtime/ModIO.Implementation/Implementation.API/Classes/ThrottledStreamWriteAsync.cs
@@ -0,0 +1,63 @@
+using System.Diagnostics;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace ModIO.Implementation.API
+{
+ public class ThrottledStreamWriteAsync : Stream
+ {
+ readonly Stream _stream;
+ readonly long _maxBytesPerSecond;
+ readonly Stopwatch _stopwatch = new Stopwatch();
+ long _bytes;
+
+ public long BytesPerSecond { get; private set; }
+
+ public override bool CanRead => _stream.CanRead;
+ public override bool CanSeek => _stream.CanSeek;
+ public override bool CanWrite => _stream.CanWrite;
+ public override long Length => _stream.Length;
+ public override long Position { get => _stream.Position; set => _stream.Position = value; }
+
+ public ThrottledStreamWriteAsync(Stream stream, long maxBytesPerSecond)
+ {
+ _stream = stream;
+ _maxBytesPerSecond = maxBytesPerSecond;
+ }
+
+ public override void Flush() => _stream.Flush();
+
+ public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+ {
+ while (true)
+ {
+ if (!_stopwatch.IsRunning || _stopwatch.ElapsedMilliseconds >= 1000)
+ {
+ _stopwatch.Restart();
+ BytesPerSecond = _bytes;
+ _bytes = 0;
+ }
+
+ if (count <= _maxBytesPerSecond - _bytes)
+ {
+ await _stream.WriteAsync(buffer, offset, count, cancellationToken);
+ _bytes += count;
+
+ return;
+ }
+
+ await Task.Delay(1000 - (int)_stopwatch.ElapsedMilliseconds, cancellationToken);
+ }
+ }
+
+ public override long Seek(long offset, SeekOrigin origin) => _stream.Seek(offset, origin);
+ public override void SetLength(long value) => _stream.SetLength(value);
+ public override int Read(byte[] buffer, int offset, int count) => _stream.Read(buffer, offset, count);
+
+ public override void Write(byte[] buffer, int offset, int count)
+ {
+ throw new System.NotImplementedException();
+ }
+ }
+}
diff --git a/Runtime/ModIO.Implementation/Implementation.API/Classes/ThrottledStreamWriteAsync.cs.meta b/Runtime/ModIO.Implementation/Implementation.API/Classes/ThrottledStreamWriteAsync.cs.meta
new file mode 100644
index 0000000..5ce58ac
--- /dev/null
+++ b/Runtime/ModIO.Implementation/Implementation.API/Classes/ThrottledStreamWriteAsync.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: fd9d66c77e8e415389a41755f804d483
+timeCreated: 1706748810
\ No newline at end of file
diff --git a/Runtime/ModIO.Implementation/Implementation.API/Classes/UnityWebRequestRunner.cs b/Runtime/ModIO.Implementation/Implementation.API/Classes/UnityWebRequestRunner.cs
new file mode 100644
index 0000000..b8c5ff9
--- /dev/null
+++ b/Runtime/ModIO.Implementation/Implementation.API/Classes/UnityWebRequestRunner.cs
@@ -0,0 +1,635 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net;
+using System.Text;
+using System.Threading.Tasks;
+using ModIO.Implementation.API.Objects;
+using ModIO.Implementation.Platform;
+using Newtonsoft.Json;
+using UnityEngine;
+using UnityEngine.Networking;
+
+namespace ModIO.Implementation.API
+{
+ internal class UnityWebRequestRunner : IWebRequestRunner, IWebRequestRunnerDownloadToFile
+ {
+ static readonly Queue> LiveTasks = new Queue>();
+ static readonly object QueueLock = new object();
+ static bool isRunning;
+
+#region Main Request Handling
+ public RequestHandle Download(string url, Stream downloadTo, ProgressHandle progressHandle)
+ {
+ var downloadHandler = new DownloadHandlerStream(downloadTo);
+ return RunDownload(url, progressHandle, downloadHandler);
+ }
+
+ public RequestHandle Download(string url, string downloadToFilepath, ProgressHandle progressHandle)
+ {
+ var downloadHandler = new DownloadHandlerFile(downloadToFilepath);
+ return RunDownload(url, progressHandle, downloadHandler);
+ }
+
+ static RequestHandle RunDownload(string url, ProgressHandle progressHandle, DownloadHandler downloadHandler)
+ {
+ var handle = new RequestHandle();
+ handle.task = RunDownload(BuildWebRequestForDownload(url, downloadHandler), handle, progressHandle);
+
+ return handle;
+ }
+
+ static async Task RunDownload(UnityWebRequest request, RequestHandle handle, ProgressHandle progressHandle)
+ {
+ handle.cancel = request.Abort;
+ handle.progress = progressHandle;
+
+ await GetDownloadResponse(request, progressHandle);
+
+ WebRequestManager.ShutdownEvent -= request.Abort;
+
+ Result result;
+ if (ModIOUnityImplementation.shuttingDown)
+ {
+ result = ResultBuilder.Create(ResultCode.Internal_OperationCancelled);
+ Logger.Log(LogLevel.Error, $"SHUTDOWN EXCEPTION"
+ + $"\n{request.result}\n");
+ }
+ else if (request.result != UnityWebRequest.Result.Success)
+ {
+ result = ResultBuilder.Unknown;
+ Logger.Log(LogLevel.Error, $"Unhandled result when downloading"
+ + $"\n{request.result}\n{request.responseCode}");
+ }
+ else
+ result = await ProcessDownloadResponse(request);
+
+ if(progressHandle != null)
+ {
+ progressHandle.Failed = !result.Succeeded();
+ progressHandle.Completed = true;
+ }
+
+ request.Dispose();
+
+ return result;
+ }
+
+ public RequestHandle> Upload(WebRequestConfig config, ProgressHandle progressHandle)
+ {
+ RequestHandle> handle = new RequestHandle>();
+ var task = Execute(config, handle, progressHandle);
+ handle.task = task;
+
+ return handle;
+ }
+
+ public async Task> Execute(WebRequestConfig config,
+ RequestHandle> handle, ProgressHandle progressHandle)
+ {
+ UnityWebRequest request = BuildWebRequestCommon(config);
+
+ if (handle != null)
+ {
+ handle.progress = progressHandle;
+ handle.cancel = request.Abort;
+ }
+
+ if(config.IsUpload)
+ await SendUpload(request, config, progressHandle);
+ else
+ await SendWebRequest(request, config);
+
+ if (progressHandle != null)
+ {
+ progressHandle.Progress = 1f;
+ progressHandle.Completed = true;
+ }
+
+ ResultAnd result;
+ try
+ {
+ if(ModIOUnityImplementation.shuttingDown)
+ {
+ if (request != null) LogRequestBeingAborted(request, config);
+ result = ResultAnd.Create(ResultCode.Internal_OperationCancelled, default(TResult));
+ }
+ else
+ result = await ProcessResponse(request, config);
+ }
+ catch(Exception e)
+ {
+ Logger.Log(LogLevel.Error, $"Unknown exception caught trying to process"
+ + $" web request response.\nException: {e.Message}\n"
+ + $"Stacktrace: {e.StackTrace}");
+ result = ResultAnd.Create(ResultCode.Unknown, default(TResult));
+ }
+
+ if(progressHandle != null)
+ progressHandle.Failed = !result.result.Succeeded();
+
+ if(request != null)
+ {
+ // this event is added in BuildWebRequest(), we remove it here
+ WebRequestManager.ShutdownEvent -= request.Abort;
+ request.Dispose();
+ }
+
+ return result;
+ }
+ static Task SendWebRequest(UnityWebRequest request, WebRequestConfig config)
+ {
+ if (config.RawBodyData != null)
+ SetupRequestBodyData(request, config.RawBodyData, "application/json");
+ else if (config.HasStringData)
+ SetupUrlEncodedRequest(request, config, "application/x-www-form-urlencoded");
+ else
+ {
+ // We still need to set content-type for the server to be happy, but Unity's UploadHandler isn't here to do it
+ request.SetRequestHeader("Content-Type", "application/x-www-form-urlencoded");
+ }
+
+ LogRequestBeingSent(request, config);
+
+ var asyncOperation = request.SendWebRequest();
+
+ if (asyncOperation.isDone)
+ return Task.CompletedTask;
+
+ var completionSource = new TaskCompletionSource();
+ asyncOperation.completed += op =>
+ {
+ completionSource.TrySetResult(true);
+ };
+
+ return completionSource.Task;
+ }
+
+#endregion
+
+
+#region Creating WebRequests
+ static void LogRequestBeingSent(UnityWebRequest request, WebRequestConfig config)
+ {
+ string log = $"\n{config.Url}"
+ + $"\nMETHOD: {config.RequestMethodType}"
+ + $"\n{GenerateLogForRequestMessage(request)}"
+ + $"\n{GenerateLogForWebRequestConfig(config)}";
+ Logger.Log(LogLevel.Verbose, $"SENDING{log}");
+ }
+ static void LogRequestBeingSent(UnityWebRequest request)
+ {
+ string log = $"\n{request.url}"
+ + $"\nMETHOD: {request.method}"
+ + $"\n{GenerateLogForRequestMessage(request)}";
+ Logger.Log(LogLevel.Verbose, $"SENDING{log}");
+ }
+
+ static void LogRequestBeingAborted(UnityWebRequest request, WebRequestConfig config)
+ {
+ string log = $"\n{config.Url}"
+ + $"\nMETHOD: {config.RequestMethodType}"
+ + $"\n{GenerateLogForRequestMessage(request)}"
+ + $"\n{GenerateLogForWebRequestConfig(config)}";
+ Logger.Log(LogLevel.Verbose, $"ABORTED{log}");
+ }
+
+ static async Task ProcessDownloadResponse(UnityWebRequest request)
+ {
+ int statusCode = (int)(request.responseCode);
+
+ string completeRequestLog = $"{GenerateLogForStatusCode(statusCode)}"
+ + $"\n{request.url}"
+ + $"\nMETHOD: GET"
+ + $"\n{GenerateLogForRequestMessage(request)}"
+ + $"\n{GenerateLogForResponseMessage(request)}";
+
+ if(IsSuccessStatusCode(statusCode))
+ {
+ Logger.Log(LogLevel.Verbose, $"DOWNLOAD SUCCEEDED {completeRequestLog}");
+ return ResultBuilder.Success;
+ }
+
+ Logger.Log(LogLevel.Verbose, $"DOWNLOAD FAILED [{completeRequestLog}]");
+ return await HttpStatusCodeError("binary download omitted", completeRequestLog, statusCode);
+
+ }
+
+ static async Task> ProcessResponse(UnityWebRequest request, WebRequestConfig config)
+ {
+ int statusCode = (int)(request.responseCode);
+ string downloadHandlerText = null;
+
+ if (request.downloadHandler != null && statusCode != 204)
+ downloadHandlerText = request.downloadHandler.text;
+
+ string completeRequestLog = $"{GenerateLogForStatusCode(statusCode)}"
+ + $"\n{config.Url}"
+ + $"\nMETHOD: {config.RequestMethodType}"
+ + $"\n{GenerateLogForRequestMessage(request)}"
+ + $"\n{GenerateLogForWebRequestConfig(config)}"
+ + $"\n{GenerateLogForResponseMessage(request)}";
+
+ if(IsSuccessStatusCode(statusCode))
+ {
+ Logger.Log(LogLevel.Verbose, $"SUCCEEDED {completeRequestLog}");
+
+ return await FormatResult(downloadHandlerText);
+ }
+
+ return ResultAnd.Create(await HttpStatusCodeError(downloadHandlerText, completeRequestLog, statusCode), default(TResult));
+ }
+
+ static bool IsSuccessStatusCode(int code) => code >= 200 && code < 300;
+
+ static Task GetDownloadResponse(UnityWebRequest request, ProgressHandle progressHandle)
+ {
+ LogRequestBeingSent(request);
+
+ var asyncOperation = request.SendWebRequest();
+
+ var completionSource = new TaskCompletionSource();
+ asyncOperation.completed += op =>
+ {
+ completionSource.TrySetResult(true);
+ };
+
+ _ = MonitorProgress(request, progressHandle, true);
+
+ return completionSource.Task;
+ }
+ static async Task MonitorProgress(UnityWebRequest request, ProgressHandle progressHandle, bool monitorDownload)
+ {
+ float startedAt = Time.time;
+ ulong lastCalculatedSpeedAtBytes = 0;
+
+ while (progressHandle != null && !request.isDone)
+ {
+ // Cap the progress, so it doesn't get to 100% while we wait for the server response
+ progressHandle.Progress = 0.99f * (monitorDownload ? request.downloadProgress : request.uploadProgress);
+
+ ulong currentBytes = monitorDownload ? request.downloadedBytes : request.uploadedBytes;
+ float currentTime = Time.time;
+
+ // update BytesPerSecond continuously for the first second, then once per second
+ if (currentTime - startedAt > 1 || lastCalculatedSpeedAtBytes == 0)
+ {
+ progressHandle.BytesPerSecond =(long)((currentBytes - lastCalculatedSpeedAtBytes) / (currentTime - startedAt));
+
+ if(currentTime - startedAt > 1)
+ {
+ startedAt = currentTime;
+ lastCalculatedSpeedAtBytes = currentBytes;
+ }
+ }
+
+ await Task.Yield();
+ }
+ }
+
+ static Task SendUpload(UnityWebRequest request, WebRequestConfig config, ProgressHandle progressHandle)
+ {
+ return EnqueueTask(() =>
+ {
+ LogRequestBeingSent(request, config);
+ if(config.RawBinaryData != null)
+ return SendOctetUploadRequest(request, config, progressHandle);
+ return SendMultipartUploadRequest(request, config, progressHandle);
+ });
+ }
+
+ static Task EnqueueTask(Func taskFunc)
+ {
+ TaskCompletionSource taskCompletionSource = new TaskCompletionSource();
+
+ lock (QueueLock)
+ {
+ LiveTasks.Enqueue(async () =>
+ {
+ await taskFunc();
+ taskCompletionSource.SetResult(true);
+ });
+
+ // TODO: This could cause RunTasks to start on a non-main thread, which will apply to all following tasks until there's a break
+ // Is this an issue? It's matching previous behaviour for now
+ if (!isRunning)
+ RunTasks();
+ }
+
+ return taskCompletionSource.Task;
+ }
+
+ static async void RunTasks()
+ {
+ isRunning = true;
+ while (true)
+ {
+ Func taskFunc;
+
+ lock (QueueLock)
+ {
+ if (LiveTasks.Count == 0)
+ {
+ isRunning = false;
+ break;
+ }
+
+ taskFunc = LiveTasks.Dequeue();
+ }
+
+ await taskFunc();
+ }
+ }
+
+ static UnityWebRequest BuildWebRequestCommon(WebRequestConfig config)
+ {
+ // Add API key or Access token
+ if (UserData.instance.IsOAuthTokenValid() && !config.DontUseAuthToken)
+ config.AddHeader("Authorization", $"Bearer {UserData.instance.oAuthToken}");
+ else
+ config.Url += $"&api_key={Settings.server.gameKey}";
+
+ var request = new UnityWebRequest(config.Url, config.RequestMethodType, new DownloadHandlerBuffer(), null);
+ SetModioHeaders(request);
+ SetConfigHeaders(request, config);
+
+ request.timeout = config.ShouldRequestTimeout ? 30 : 0;
+
+ // Add request to shutdown method
+ WebRequestManager.ShutdownEvent += request.Abort;
+ return request;
+ }
+
+ static UnityWebRequest BuildWebRequestForDownload(string url, DownloadHandler downloadHandler)
+ {
+ Logger.Log(LogLevel.Verbose, $"DOWNLOADING [{url}]");
+
+ var request = new UnityWebRequest(url, UnityWebRequest.kHttpVerbGET, downloadHandler, null);
+ SetModioHeaders(request);
+ request.timeout = 0;
+
+ // Add API key or Access token
+ if (UserData.instance?.IsOAuthTokenValid() ?? false)
+ request.SetRequestHeader("Authorization", $"Bearer {UserData.instance.oAuthToken}");
+
+ // Add request to shutdown method
+ WebRequestManager.ShutdownEvent += request.Abort;
+
+ return request;
+ }
+
+ static void SetModioHeaders(UnityWebRequest request)
+ {
+ // Set default headers for all requests
+ request.SetRequestHeader("User-Agent", $"unity-{Application.unityVersion}-{ModIOVersion.Current.ToHeaderString()}");
+ request.SetRequestHeader("Accept", "application/json");
+
+ request.SetRequestHeader(ServerConstants.HeaderKeys.LANGUAGE, Settings.server.languageCode ?? "en");
+ request.SetRequestHeader(ServerConstants.HeaderKeys.PLATFORM, PlatformConfiguration.RESTAPI_HEADER);
+ request.SetRequestHeader(ServerConstants.HeaderKeys.PORTAL, ServerConstants.ConvertUserPortalToHeaderValue(Settings.build.userPortal));
+ }
+
+ static void SetConfigHeaders(UnityWebRequest request, WebRequestConfig config)
+ {
+ foreach(var header in config.HeaderData)
+ request.SetRequestHeader(header.Key, header.Value);
+ }
+
+ static void SetupUrlEncodedRequest(UnityWebRequest request, WebRequestConfig config, string contentType)
+ {
+ string kvpData = "";
+ foreach(var kvp in config.StringKvpData)
+ kvpData += $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}&";
+ kvpData = kvpData.Trim('&');
+
+ SetupRequestBodyData(request, kvpData, contentType);
+ }
+
+ static void SetupRequestBodyData(UnityWebRequest request, string data, string contentType)
+ {
+ byte[] bytes = Encoding.UTF8.GetBytes(data);
+ var uploadHandler = new UploadHandlerRaw(bytes);
+ uploadHandler.contentType = contentType;
+ request.uploadHandler = uploadHandler;
+ }
+
+ static Task SendMultipartUploadRequest(UnityWebRequest request, WebRequestConfig config, ProgressHandle progressHandle)
+ {
+ var multipartFormSections = new List();
+
+ foreach(var binary in config.BinaryData)
+ {
+ string contentType = "form-data";
+ multipartFormSections.Add(new MultipartFormFileSection(binary.key, binary.data, binary.fileName, contentType));
+ }
+ foreach(var kvp in config.StringKvpData)
+ {
+ if(string.IsNullOrEmpty(kvp.Value))
+ continue;
+
+ multipartFormSections.Add(new MultipartFormDataSection(kvp.Key, kvp.Value));
+ }
+
+ string boundary = "---------------------------" + DateTime.Now.Ticks.ToString("x");
+ byte[] boundaryBytes = Encoding.UTF8.GetBytes(boundary);
+ var rawData = UnityWebRequest.SerializeFormSections(multipartFormSections, boundaryBytes);
+
+ var uploadHandler = new UploadHandlerRaw(rawData);
+ uploadHandler.contentType = "multipart/form-data; boundary=" + boundary;
+
+ request.uploadHandler = uploadHandler;
+ return SendWebRequest(request, progressHandle);
+ }
+
+ static Task SendOctetUploadRequest(UnityWebRequest request, WebRequestConfig config, ProgressHandle progressHandle)
+ {
+ string boundary = "---------------------------" + DateTime.Now.Ticks.ToString("x");
+
+ var uploadHandler = new UploadHandlerRaw(config.RawBinaryData);
+ uploadHandler.contentType = "application/octet-stream; boundary=" + boundary;
+
+ request.uploadHandler = uploadHandler;
+ return SendWebRequest(request, progressHandle);
+ }
+
+ static async Task SendWebRequest(UnityWebRequest request, ProgressHandle progressHandle)
+ {
+ request.SendWebRequest();
+
+ await MonitorProgress(request, progressHandle, false);
+
+ while (progressHandle != null && !request.isDone)
+ {
+ // Cap the progress, so it doesn't get to 100% while we wait for the server response
+ progressHandle.Progress = request.uploadProgress * 0.99f;
+ await Task.Yield();
+ }
+ }
+
+#endregion
+
+#region Processing Response Body
+
+
+ static async Task> FormatResult(string rawResponse)
+ {
+ //int? is used as a nullable type to denote that we are ignoring type in the response
+ //ie - some commands are sent without expect any useful response aside from the response code itself
+ if(typeof(T) == typeof(int?))
+ {
+ //OnWebRequestResponse
+ return ResultAnd.Create(ResultCode.Success, default(T));
+ }
+
+ // If the response is empty it was likely 204: NoContent
+ if(rawResponse == null)
+ return ResultAnd.Create(ResultBuilder.Success, default(T));
+
+ try
+ {
+ T deserialized = await Task.Run(()=> JsonConvert.DeserializeObject(rawResponse));
+ return ResultAnd.Create(ResultBuilder.Success, deserialized);
+ }
+ catch(Exception e)
+ {
+ Logger.Log(LogLevel.Error,
+ $"UNRECOGNISED RESPONSE"
+ + $"\nFailed to deserialize a response from the mod.io server.\nThe data"
+ + $" may have been corrupted or isn't a valid Json format.\n\n[JsonUtility:"
+ + $" {e.Message}] - {e.InnerException}"
+ + $"\nRaw Response: {rawResponse}");
+
+ return ResultAnd.Create(
+ ResultBuilder.Create(ResultCode.API_FailedToDeserializeResponse), default(T));
+ }
+ }
+
+ #endregion
+
+#region Error Handling
+ static async Task HttpStatusCodeError(string rawResponse, string requestLog, int status)
+ {
+ var result = await FormatResult(rawResponse);
+
+ string errors = GenerateErrorsIntoSingleLog(result.value.error.errors);
+ Logger.Log(LogLevel.Error,
+ $"HTTP ERROR [{status} {((HttpStatusCode)status).ToString()}]"
+ + $"\n Error ref [{result.value.error.code}] {result.value.error.error_ref} - {result.value.error.message}\n{errors}\n\n{requestLog}");
+
+ if(ResultCode.IsInvalidSession(result.value))
+ {
+ UserData.instance?.SetOAuthTokenAsRejected();
+ ResponseCache.ClearCache();
+
+ return ResultBuilder.Create(ResultCode.User_InvalidToken,
+ (uint)result.value.error.error_ref);
+ }
+
+ return ResultBuilder.Create(ResultCode.API_FailedToCompleteRequest,
+ (uint)result.value.error.error_ref);
+
+ }
+
+#endregion
+
+#region Logging formatting
+ static string GenerateLogForWebRequestConfig(WebRequestConfig config)
+ {
+ string log = "\nFORM BODY\n------------------------\n";
+ if(config.StringKvpData.Count > 0)
+ {
+ log += "String KVPs\n";
+ foreach(var kvp in config.StringKvpData)
+ log += $"{kvp.Key}: {kvp.Value}\n";
+ }
+ else
+ log += "--No String Data\n";
+
+ if((config.BinaryData == null || config.BinaryData.Count > 0) && (config.RawBinaryData == null || config.RawBinaryData.Length > 0))
+ log += "--No Binary Data\n";
+ else
+ log += "Binary files\n";
+
+ if(config.BinaryData != null && config.BinaryData.Count > 0)
+ {
+ log += "Binary files\n";
+ foreach(var binData in config.BinaryData)
+ log += $"{binData.key}: {binData.data.Length} bytes\n";
+ }
+
+ if(config.RawBinaryData != null && config.RawBinaryData.Length > 0)
+ log += $"Raw Binary data: {config.RawBinaryData.Length}\n";
+
+
+ return log;
+ }
+
+ static string GenerateLogForRequestMessage(UnityWebRequest request)
+ {
+ if(request == null)
+ return "\n\n------------------------ \nWebRequest is null";
+ string log = "\n\n------------------------";
+ string headers = $"\nREQUEST HEADERS";
+
+ LogHeader("Accept");
+ LogHeader("User-Agent");
+ LogHeader("Connection");
+ LogHeader("accept-language");
+ LogHeader("x-modio-platform");
+ LogHeader("x-modio-portal");
+ LogHeader("Authorization");
+ LogHeader("Content-Type");
+
+ if (request.uploadHandler != null)
+ headers += $"uploadHandler.ContentType: {request.uploadHandler.contentType}";
+
+ log += headers;
+ return log;
+
+ void LogHeader(string header)
+ {
+ string requestHeader = request.GetRequestHeader(header);
+ if (!string.IsNullOrEmpty(requestHeader))
+ {
+ if(header == "Authorization")
+ headers += $"\n{header}: [OAUTH-TOKEN]";
+ else
+ headers += $"\n{header}: {requestHeader}";
+ }
+ }
+ }
+
+ static string GenerateLogForResponseMessage(UnityWebRequest response)
+ {
+ if(response == null)
+ return "\n\n------------------------\n WebResponse is null";
+
+ string log = "\n\n------------------------";
+ string headers = $"\nRESPONSE HEADERS";
+ foreach(var kvp in response.GetResponseHeaders())
+ headers += $"\n{kvp.Key}: {kvp.Value}";
+ log += headers;
+ return log;
+ }
+
+ static string GenerateLogForStatusCode(int code) => $"[Http: {code} {(HttpStatusCode)code}]";
+
+ static string GenerateErrorsIntoSingleLog(Dictionary errors)
+ {
+ if(errors == null || errors.Count == 0)
+ return "";
+
+ string log = "errors:";
+ int count = 1;
+ foreach(var error in errors)
+ {
+ log += $"\n{count}. {error.Key}: {error.Value}";
+ count++;
+ }
+
+ return log;
+ }
+ #endregion
+
+ }
+}
diff --git a/Runtime/ModIO.Implementation/Implementation.API/Classes/UnityWebRequestRunner.cs.meta b/Runtime/ModIO.Implementation/Implementation.API/Classes/UnityWebRequestRunner.cs.meta
new file mode 100644
index 0000000..914d66a
--- /dev/null
+++ b/Runtime/ModIO.Implementation/Implementation.API/Classes/UnityWebRequestRunner.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 49c40e6ccf7f4438b2de82e1a636b1d9
+timeCreated: 1718340381
\ No newline at end of file
diff --git a/Runtime/ModIO.Implementation/Implementation.API/Classes/WebRequestConfig.cs b/Runtime/ModIO.Implementation/Implementation.API/Classes/WebRequestConfig.cs
index 1dc7f1d..c1c76e2 100644
--- a/Runtime/ModIO.Implementation/Implementation.API/Classes/WebRequestConfig.cs
+++ b/Runtime/ModIO.Implementation/Implementation.API/Classes/WebRequestConfig.cs
@@ -1,4 +1,5 @@
-using System.Collections.Generic;
+using System;
+using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
@@ -39,6 +40,8 @@ internal class WebRequestConfig
public byte[] RawBinaryData { get; set; }
+ public string RawBodyData { get; set; }
+
public bool ForceIsUpload = false;
public void AddField(string key, TInput data)
diff --git a/Runtime/ModIO.Implementation/Implementation.API/Classes/WebRequestManager.cs b/Runtime/ModIO.Implementation/Implementation.API/Classes/WebRequestManager.cs
index 7a79a74..888c898 100644
--- a/Runtime/ModIO.Implementation/Implementation.API/Classes/WebRequestManager.cs
+++ b/Runtime/ModIO.Implementation/Implementation.API/Classes/WebRequestManager.cs
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.IO;
+using JetBrains.Annotations;
namespace ModIO.Implementation.API
{
@@ -17,6 +18,13 @@ static class WebRequestManager
internal static event Action ShutdownEvent = () => { };
+ static readonly bool UseTestFailureWebRequest = false;
+ static readonly bool UseUnityWebRequest = true;
+ static readonly IWebRequestRunner WebRequestRunner =
+ UseTestFailureWebRequest ? new TestWebRequestRunner():
+ UseUnityWebRequest ? (IWebRequestRunner)new UnityWebRequestRunner()
+ : new WebRequestRunner();
+
public static async Task Shutdown()
{
// Subscribe WebRequests here to abort when the event is called
@@ -36,6 +44,21 @@ public static RequestHandle