├── Packages ├── com.bocud.vrcapitools │ ├── version.txt │ ├── Runtime │ │ ├── Constants.cs.meta │ │ ├── Logger.cs.meta │ │ ├── VRChatApiTools.cs.meta │ │ ├── BlueprintExtensions.cs.meta │ │ ├── BocuD.VRChatApiTools.Runtime.asmdef.meta │ │ ├── VRCLogin.cs.meta │ │ ├── BocuD.VRChatApiTools.Runtime.asmdef │ │ ├── Constants.cs │ │ ├── VRCLogin.cs │ │ ├── Logger.cs │ │ ├── BlueprintExtensions.cs │ │ └── VRChatApiTools.cs │ ├── Editor │ │ ├── VRChatApiToolsGUI.cs.meta │ │ ├── ApiFileAsyncExtensions.cs.meta │ │ ├── VRChatApiToolsUploadStatus.cs.meta │ │ ├── VRChatApiUploaderAsync.cs.meta │ │ ├── BocuD.VRChatApiTools.Editor.asmdef.meta │ │ ├── ApiFileHelperAsync.cs.meta │ │ ├── VRChatApiToolsEditor.cs.meta │ │ ├── BocuD.VRChatApiTools.Editor.asmdef │ │ ├── VRChatApiToolsEditor.cs │ │ ├── VRChatApiToolsUploadStatus.cs │ │ ├── ApiFileAsyncExtensions.cs │ │ ├── VRChatApiUploaderAsync.cs │ │ ├── VRChatApiToolsGUI.cs │ │ └── ApiFileHelperAsync.cs │ ├── LICENSE.txt.meta │ ├── version.txt.meta │ ├── package.json.meta │ ├── Editor.meta │ ├── Runtime.meta │ ├── PackageExportConfiguration_VRChatApiTools.asset.meta │ ├── package.json │ ├── LICENSE.txt │ └── PackageExportConfiguration_VRChatApiTools.asset ├── vpm-manifest.json ├── .gitignore ├── manifest.json └── packages-lock.json ├── .gitattributes ├── Website ├── banner.png ├── favicon.ico ├── styles.css ├── app.js └── index.html ├── README.md ├── LICENSE ├── .gitignore └── .github └── workflows ├── release.yml └── build-listing.yml /Packages/com.bocud.vrcapitools/version.txt: -------------------------------------------------------------------------------- 1 | 0.4.0 -------------------------------------------------------------------------------- /Packages/vpm-manifest.json: -------------------------------------------------------------------------------- 1 | {"dependencies": {}, "locked": {}} -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /Website/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BocuD/VRChatApiTools/HEAD/Website/banner.png -------------------------------------------------------------------------------- /Website/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BocuD/VRChatApiTools/HEAD/Website/favicon.ico -------------------------------------------------------------------------------- /Packages/.gitignore: -------------------------------------------------------------------------------- 1 | /*/ 2 | !com.vrchat.core.* 3 | 4 | # Change this to match your new package name 5 | !com.bocud.vrcapitools -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/Runtime/Constants.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 75b9a5c7f1974863ade38be4019d5e48 3 | timeCreated: 1647185342 -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/Runtime/Logger.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 46aae377143c4a6e83d042eec25684f5 3 | timeCreated: 1642412270 -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/Runtime/VRChatApiTools.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: f9e20e71bda4495ab10bfa3154ee32cc 3 | timeCreated: 1642415703 -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/Editor/VRChatApiToolsGUI.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 0e93ad2b7ab74acb80a9b3cbb457232e 3 | timeCreated: 1639475530 -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/Runtime/BlueprintExtensions.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 83c0d6d74e274205a04da5999c1c46de 3 | timeCreated: 1648229795 -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/Editor/ApiFileAsyncExtensions.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 1c7f0f8765e548cead9e695fdd8f2d6a 3 | timeCreated: 1642626589 -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/Editor/VRChatApiToolsUploadStatus.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 21db4c9d153a47009d52ab274e157fda 3 | timeCreated: 1642383521 -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/Editor/VRChatApiUploaderAsync.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 734fd573c46249a585a1d1310a754a8a 3 | timeCreated: 1642377986 -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/LICENSE.txt.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 8b109ac28d17ea84db13035a34650eff 3 | TextScriptImporter: 4 | externalObjects: {} 5 | userData: 6 | assetBundleName: 7 | assetBundleVariant: 8 | -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/version.txt.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 95d31b6c7d47d5347805a31088c2fec3 3 | TextScriptImporter: 4 | externalObjects: {} 5 | userData: 6 | assetBundleName: 7 | assetBundleVariant: 8 | -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/package.json.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 3eb2e1c2468e7594b97012d9f97aa60b 3 | PackageManifestImporter: 4 | externalObjects: {} 5 | userData: 6 | assetBundleName: 7 | assetBundleVariant: 8 | -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/Editor.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: c00b8bc8945f1a0498737306f5a8bc2b 3 | folderAsset: yes 4 | DefaultImporter: 5 | externalObjects: {} 6 | userData: 7 | assetBundleName: 8 | assetBundleVariant: 9 | -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/Runtime.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: e4e4b888961402a4496a84bc78b88e8f 3 | folderAsset: yes 4 | DefaultImporter: 5 | externalObjects: {} 6 | userData: 7 | assetBundleName: 8 | assetBundleVariant: 9 | -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/Editor/BocuD.VRChatApiTools.Editor.asmdef.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 741dcf4a3fd320d4c810671446d1d73e 3 | AssemblyDefinitionImporter: 4 | externalObjects: {} 5 | userData: 6 | assetBundleName: 7 | assetBundleVariant: 8 | -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/Runtime/BocuD.VRChatApiTools.Runtime.asmdef.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 7063658d1af907f47920fa8c5bc9e924 3 | AssemblyDefinitionImporter: 4 | externalObjects: {} 5 | userData: 6 | assetBundleName: 7 | assetBundleVariant: 8 | -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/PackageExportConfiguration_VRChatApiTools.asset.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 5a97937e0bca67d49afbb8233bde3481 3 | NativeFormatImporter: 4 | externalObjects: {} 5 | mainObjectFileID: 11400000 6 | userData: 7 | assetBundleName: 8 | assetBundleVariant: 9 | -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/Runtime/VRCLogin.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: b671e56ca8eb45be8dceda6f44b75436 3 | MonoImporter: 4 | externalObjects: {} 5 | serializedVersion: 2 6 | defaultReferences: [] 7 | executionOrder: 0 8 | icon: {instanceID: 0} 9 | userData: 10 | assetBundleName: 11 | assetBundleVariant: 12 | -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/Editor/ApiFileHelperAsync.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 7b8b22514a5444628c7e630674744569 3 | MonoImporter: 4 | externalObjects: {} 5 | serializedVersion: 2 6 | defaultReferences: [] 7 | executionOrder: 0 8 | icon: {instanceID: 0} 9 | userData: 10 | assetBundleName: 11 | assetBundleVariant: 12 | -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/Editor/VRChatApiToolsEditor.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 9b80c41299d31cd49b2f92265b765e71 3 | MonoImporter: 4 | externalObjects: {} 5 | serializedVersion: 2 6 | defaultReferences: [] 7 | executionOrder: 0 8 | icon: {instanceID: 0} 9 | userData: 10 | assetBundleName: 11 | assetBundleVariant: 12 | -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/Runtime/BocuD.VRChatApiTools.Runtime.asmdef: -------------------------------------------------------------------------------- 1 | { 2 | "name": "BocuD.VRChatApiTools.Runtime", 3 | "references": [ 4 | "VRC.SDKBase" 5 | ], 6 | "includePlatforms": [], 7 | "excludePlatforms": [], 8 | "allowUnsafeCode": false, 9 | "overrideReferences": false, 10 | "precompiledReferences": [], 11 | "autoReferenced": true, 12 | "defineConstraints": [], 13 | "versionDefines": [], 14 | "noEngineReferences": false 15 | } -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "com.bocud.vrcapitools", 3 | "version": "0.4.1", 4 | "displayName": "VRChatApiTools", 5 | "author": { 6 | "name": "BocuD", 7 | "url": "https://github.com/BocuD" 8 | }, 9 | "vpmDependencies": { 10 | "com.vrchat.base": "^3.4.x", 11 | "com.vrchat.worlds": "^3.4.x" 12 | }, 13 | "description": "A library to interact with and extend the VRChat SDK inside Unity", 14 | "type": "library", 15 | "unity": "2019.4", 16 | "unityRelease": "31f1" 17 | } -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/Editor/BocuD.VRChatApiTools.Editor.asmdef: -------------------------------------------------------------------------------- 1 | { 2 | "name": "BocuD.VRChatApiTools.Editor", 3 | "references": [ 4 | "VRC.SDKBase", 5 | "VRC.SDKBase.Editor", 6 | "BocuD.VRChatApiTools.Runtime" 7 | ], 8 | "includePlatforms": [ 9 | "Editor" 10 | ], 11 | "excludePlatforms": [], 12 | "allowUnsafeCode": false, 13 | "overrideReferences": false, 14 | "precompiledReferences": [], 15 | "autoReferenced": true, 16 | "defineConstraints": [], 17 | "versionDefines": [], 18 | "noEngineReferences": false 19 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VRChatApiTools 2 | A library to interact with and extend the VRChat SDK. Doesn't do anything on its own, but provides editor tool developers with an interface to more easily interact with VRChat SDK data, and contains an alternate uploader that doesn't run in playmode. 3 | 4 | Documentation: soon™ 5 | 6 | For implementation examples, look at [VRBuildHelper](https://github.com/BocuD/VRBuildHelper) and [AvatarImageReader](https://github.com/Miner28/AvatarImageReader/) 7 | 8 | ## Requirements 9 | - Unity 2019.4.31f1 10 | - Latest version of Package Manager version of VRChat SDK (installed by [VRChat Creator Companion](https://vcc.docs.vrchat.com/)) 11 | 12 | # Installation 13 | ## Installation via VRChat Creator Companion (recommended): 14 | Add [this](https://bocud.github.io/BocuDPackages/) repository to your CreatorCompanion sources. From there VRChatApiTools should show up within the list of available packages. 15 | 16 | ## Installation through a UnityPackage 17 | 1. Download latest unitypackage release from [here](https://github.com/BocuD/VRChatApiTools/releases/latest) 18 | 2. Install the downloaded unitypackage 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 BocuD 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 BocuD 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/PackageExportConfiguration_VRChatApiTools.asset: -------------------------------------------------------------------------------- 1 | %YAML 1.1 2 | %TAG !u! tag:unity3d.com,2011: 3 | --- !u!114 &11400000 4 | MonoBehaviour: 5 | m_ObjectHideFlags: 0 6 | m_CorrespondingSourceObject: {fileID: 0} 7 | m_PrefabInstance: {fileID: 0} 8 | m_PrefabAsset: {fileID: 0} 9 | m_GameObject: {fileID: 0} 10 | m_Enabled: 1 11 | m_EditorHideFlags: 0 12 | m_Script: {fileID: 11500000, guid: 5b9200ff32d5b6b4a8662912ce248828, type: 3} 13 | m_Name: PackageExportConfiguration_VRChatApiTools 14 | m_EditorClassIdentifier: 15 | Configurations: 16 | - Name: VRChatApiTools 17 | ExportDirectory: C:/Users/BocuD/Downloads 18 | FileNameTemplate: '{n}_v{v}' 19 | PackageManifest: {fileID: 8385607101436470782, guid: 3eb2e1c2468e7594b97012d9f97aa60b, 20 | type: 3} 21 | VersionFile: {fileID: 4900000, guid: 95d31b6c7d47d5347805a31088c2fec3, type: 3} 22 | AssetInclusions: [] 23 | FolderInclusions: 24 | - Folder: {fileID: 102900000, guid: 107c334855a29659707265dee0ec2de5, type: 3} 25 | IncludeSubfolders: 1 26 | DependencyOptions: 0 27 | Expanded: 1 28 | AssetExclusions: [] 29 | FolderExclusions: [] 30 | ShowPackageInFileBrowserAfterExport: 1 31 | ExpandInInspector: 1 32 | ParentStorage: {fileID: 11400000} 33 | -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/Runtime/Constants.cs: -------------------------------------------------------------------------------- 1 | using UnityEngine; 2 | 3 | namespace BocuD.VRChatApiTools 4 | { 5 | public class Constants 6 | { 7 | //delay in milliseconds after each write 8 | public const int postWriteDelay = 750; 9 | 10 | //constants from the sdk 11 | public const int kMultipartUploadChunkSize = 50 * 1024 * 1024; // 50 MB <- is 100MB in the SDK, modified here because 25MB makes more sense 12 | 13 | public const int SERVER_PROCESSING_WAIT_TIMEOUT_CHUNK_SIZE = 50 * 1024 * 1024; 14 | public const float SERVER_PROCESSING_WAIT_TIMEOUT_PER_CHUNK_SIZE = 120.0f; 15 | public const float SERVER_PROCESSING_MAX_WAIT_TIMEOUT = 600.0f; 16 | public const float SERVER_PROCESSING_INITIAL_RETRY_TIME = 2.0f; 17 | public const float SERVER_PROCESSING_MAX_RETRY_TIME = 10.0f; 18 | 19 | public static float GetServerProcessingWaitTimeoutForDataSize(int size) 20 | { 21 | float timeoutMultiplier = Mathf.Ceil(size / (float)SERVER_PROCESSING_WAIT_TIMEOUT_CHUNK_SIZE); 22 | return Mathf.Clamp(timeoutMultiplier * SERVER_PROCESSING_WAIT_TIMEOUT_PER_CHUNK_SIZE, 23 | SERVER_PROCESSING_WAIT_TIMEOUT_PER_CHUNK_SIZE, SERVER_PROCESSING_MAX_WAIT_TIMEOUT); 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/Runtime/VRCLogin.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using VRC.Core; 3 | 4 | namespace BocuD.VRChatApiTools 5 | { 6 | public static class VRCLogin 7 | { 8 | private static bool loginInProgress; 9 | 10 | public static void AttemptLogin(Action> onSucces, 11 | Action> onError) 12 | { 13 | if (loginInProgress) return; 14 | 15 | if (!ApiCredentials.Load()) 16 | { 17 | Logger.LogError("You are currently not logged in. Please log in using the VRChat SDK Control panel."); 18 | loginInProgress = false; 19 | } 20 | else 21 | { 22 | loginInProgress = true; 23 | APIUser.InitialFetchCurrentUser( 24 | c => 25 | { 26 | onSucces(c); 27 | loginInProgress = false; 28 | }, 29 | c => 30 | { 31 | onError(c ?? new ApiModelContainer { Error = "Please try logging in through the VRChat control panel" }); 32 | loginInProgress = false; 33 | } 34 | ); 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # This .gitignore file should be placed at the root of your Unity project directory 2 | # 3 | # Get latest from https://github.com/github/gitignore/blob/master/Unity.gitignore 4 | # 5 | /[Ll]ibrary/ 6 | /[Tt]emp/ 7 | /[Oo]bj/ 8 | /[Bb]uild/ 9 | /[Bb]uilds/ 10 | /[Ll]ogs/ 11 | /[Mm]emoryCaptures/ 12 | 13 | # Asset meta data should only be ignored when the corresponding asset is also ignored 14 | !/[Aa]ssets/**/*.meta 15 | 16 | # Uncomment this line if you wish to ignore the asset store tools plugin 17 | # /[Aa]ssets/AssetStoreTools* 18 | 19 | # Autogenerated Jetbrains Rider plugin 20 | [Aa]ssets/Plugins/Editor/JetBrains* 21 | 22 | # Visual Studio cache directory 23 | .vs/ 24 | 25 | # Gradle cache directory 26 | .gradle/ 27 | 28 | # Autogenerated VS/MD/Consulo solution and project files 29 | ExportedObj/ 30 | .consulo/ 31 | *.csproj 32 | *.unityproj 33 | *.sln 34 | *.suo 35 | *.tmp 36 | *.user 37 | *.userprefs 38 | *.pidb 39 | *.booproj 40 | *.svd 41 | *.pdb 42 | *.mdb 43 | *.opendb 44 | *.VC.db 45 | 46 | # Unity3D generated meta files 47 | *.pidb.meta 48 | *.pdb.meta 49 | *.mdb.meta 50 | 51 | # Unity3D generated file on crash reports 52 | sysinfo.txt 53 | 54 | # Builds 55 | *.apk 56 | *.unitypackage 57 | 58 | # Crashlytics generated file 59 | crashlytics-build.properties 60 | 61 | .idea/.idea.vpm-package-maker/.idea 62 | Assets/PackageMakerWindowData.asset* 63 | .idea 64 | .vscode 65 | -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/Runtime/Logger.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using UnityEngine; 3 | using Object = UnityEngine.Object; 4 | 5 | namespace BocuD.VRChatApiTools 6 | { 7 | public static class Logger 8 | { 9 | public static Action statusWindowHook; 10 | 11 | private const string prefix = "[VRChatApiTools] "; 12 | 13 | public static void Log(string contents, Object context = null) 14 | { 15 | statusWindowHook?.Invoke($"{contents}"); 16 | 17 | if (context != null) Debug.Log(prefix + contents, context); 18 | else Debug.Log(prefix + contents); 19 | } 20 | 21 | public static void LogWarning(string contents, Object context = null) 22 | { 23 | statusWindowHook?.Invoke($"{contents}"); 24 | 25 | if (context != null) Debug.LogWarning(prefix + contents, context); 26 | else Debug.LogWarning(prefix + contents); 27 | } 28 | 29 | public static void LogError(string contents, Object context = null) 30 | { 31 | statusWindowHook?.Invoke($"{contents}"); 32 | 33 | if (context != null) Debug.LogError(prefix + contents, context); 34 | else Debug.LogError(prefix + contents); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Packages/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "com.unity.ide.rider": "1.2.1", 4 | "com.unity.ide.visualstudio": "2.0.11", 5 | "com.unity.ide.vscode": "1.2.4", 6 | "com.unity.test-framework": "1.1.29", 7 | "com.unity.textmeshpro": "2.1.6", 8 | "com.unity.timeline": "1.2.18", 9 | "com.unity.ugui": "1.0.0", 10 | "com.unity.modules.ai": "1.0.0", 11 | "com.unity.modules.androidjni": "1.0.0", 12 | "com.unity.modules.animation": "1.0.0", 13 | "com.unity.modules.assetbundle": "1.0.0", 14 | "com.unity.modules.audio": "1.0.0", 15 | "com.unity.modules.cloth": "1.0.0", 16 | "com.unity.modules.director": "1.0.0", 17 | "com.unity.modules.imageconversion": "1.0.0", 18 | "com.unity.modules.imgui": "1.0.0", 19 | "com.unity.modules.jsonserialize": "1.0.0", 20 | "com.unity.modules.particlesystem": "1.0.0", 21 | "com.unity.modules.physics": "1.0.0", 22 | "com.unity.modules.physics2d": "1.0.0", 23 | "com.unity.modules.screencapture": "1.0.0", 24 | "com.unity.modules.terrain": "1.0.0", 25 | "com.unity.modules.terrainphysics": "1.0.0", 26 | "com.unity.modules.tilemap": "1.0.0", 27 | "com.unity.modules.ui": "1.0.0", 28 | "com.unity.modules.uielements": "1.0.0", 29 | "com.unity.modules.umbra": "1.0.0", 30 | "com.unity.modules.unityanalytics": "1.0.0", 31 | "com.unity.modules.unitywebrequest": "1.0.0", 32 | "com.unity.modules.unitywebrequestassetbundle": "1.0.0", 33 | "com.unity.modules.unitywebrequestaudio": "1.0.0", 34 | "com.unity.modules.unitywebrequesttexture": "1.0.0", 35 | "com.unity.modules.unitywebrequestwww": "1.0.0", 36 | "com.unity.modules.vehicles": "1.0.0", 37 | "com.unity.modules.video": "1.0.0", 38 | "com.unity.modules.vr": "1.0.0", 39 | "com.unity.modules.wind": "1.0.0", 40 | "com.unity.modules.xr": "1.0.0" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build Release 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | env: 7 | packageName: "com.bocud.vrcapitools" 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | 20 | - name: get version 21 | id: version 22 | uses: notiz-dev/github-action-json-property@7c8cf5cc36eb85d8d287a8086a39dac59628eb31 23 | with: 24 | path: "Packages/${{env.packageName}}/package.json" 25 | prop_path: "version" 26 | 27 | - name: Set Environment Variables 28 | run: | 29 | echo "zipFile=${{ env.packageName }}-${{ steps.version.outputs.prop }}".zip >> $GITHUB_ENV 30 | echo "unityPackage=${{ env.packageName }}-${{ steps.version.outputs.prop }}.unitypackage" >> $GITHUB_ENV 31 | 32 | - name: Create Zip 33 | uses: thedoctor0/zip-release@09336613be18a8208dfa66bd57efafd9e2685657 34 | with: 35 | type: "zip" 36 | directory: "Packages/${{env.packageName}}/" 37 | filename: "../../${{env.zipFile}}" # make the zip file two directories up, since we start two directories in above 38 | 39 | - run: find "Packages/${{env.packageName}}/" -name \*.meta >> metaList 40 | 41 | - name: Create UnityPackage 42 | uses: pCYSl5EDgo/create-unitypackage@cfcd3cf0391a5ef1306342794866a9897c32af0b 43 | with: 44 | package-path: ${{ env.unityPackage }} 45 | include-files: metaList 46 | 47 | 48 | - name: Make Release 49 | uses: softprops/action-gh-release@1e07f4398721186383de40550babbdf2b84acfc5 50 | with: 51 | tag_name: ${{ steps.version.outputs.prop }} 52 | files: | 53 | ${{ env.zipFile }} 54 | ${{ env.unityPackage }} 55 | Packages/${{ env.packageName }}/package.json 56 | -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/Runtime/BlueprintExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using VRC.Core; 3 | 4 | namespace BocuD.VRChatApiTools 5 | { 6 | public static class BlueprintExtensions 7 | { 8 | public static void PublishWorldToCommunityLabs(this ApiWorld world, 9 | Action> successCallback, 10 | Action errorCallback) 11 | { 12 | ApiModelContainer apiModelContainer = new ApiModelContainer 13 | { 14 | OnSuccess = c => 15 | { 16 | if (successCallback == null) 17 | return; 18 | successCallback(c as ApiModelContainer); 19 | }, 20 | OnError = c => 21 | { 22 | if (errorCallback == null) 23 | return; 24 | errorCallback(c.Error); 25 | } 26 | }; 27 | 28 | API.SendPutRequest("worlds/" + world.id + "/publish", apiModelContainer); 29 | } 30 | 31 | public static void UnPublishWorld(this ApiWorld world, 32 | Action> successCallback, 33 | Action errorCallback) 34 | { 35 | ApiModelContainer apiModelContainer = new ApiModelContainer 36 | { 37 | OnSuccess = c => 38 | { 39 | if (successCallback == null) 40 | return; 41 | successCallback(c as ApiModelContainer); 42 | }, 43 | OnError = c => 44 | { 45 | if (errorCallback == null) 46 | return; 47 | errorCallback(c.Error); 48 | } 49 | }; 50 | 51 | API.SendDeleteRequest("worlds/" + world.id + "/publish", apiModelContainer); 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /.github/workflows/build-listing.yml: -------------------------------------------------------------------------------- 1 | name: Build Repo Listing 2 | 3 | env: 4 | CurrentPackageName: com.bocud.vrcapitools 5 | listPublishDirectory: Website 6 | pathToCi: ci 7 | 8 | on: 9 | workflow_dispatch: 10 | workflow_run: 11 | workflows: [Build Release] 12 | types: 13 | - completed 14 | release: 15 | types: [published, created, edited, unpublished, deleted, released] 16 | 17 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 18 | permissions: 19 | contents: read 20 | pages: write 21 | id-token: write 22 | 23 | # Allow one concurrent deployment 24 | concurrency: 25 | group: "pages" 26 | cancel-in-progress: true 27 | 28 | jobs: 29 | 30 | build-listing: 31 | name: build-listing 32 | environment: 33 | name: github-pages 34 | url: ${{ steps.deployment.outputs.page_url }} 35 | runs-on: ubuntu-latest 36 | steps: 37 | 38 | - uses: actions/checkout@v3 # check out this repo 39 | - uses: actions/checkout@v3 # check out automation repo 40 | with: 41 | repository: vrchat-community/package-list-action 42 | path: ${{env.pathToCi}} 43 | clean: false # otherwise the local repo will no longer be checked out 44 | 45 | - name: Restore Cache 46 | uses: actions/cache@v3 47 | with: 48 | path: | 49 | ${{env.pathToCi}}/.nuke/temp 50 | ~/.nuget/packages 51 | key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj') }} 52 | 53 | - name: Build Package Version Listing 54 | run: ${{env.pathToCi}}/build.cmd BuildRepoListing --root ${{env.pathToCi}} --list-publish-directory $GITHUB_WORKSPACE/${{env.listPublishDirectory}} --current-package-name ${{env.CurrentPackageName}} 55 | env: 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | 58 | - name: Setup Pages 59 | uses: actions/configure-pages@v3 60 | 61 | - name: Upload artifact 62 | uses: actions/upload-pages-artifact@v1 63 | with: 64 | path: ${{env.listPublishDirectory}} 65 | 66 | - name: Deploy to GitHub Pages 67 | id: deployment 68 | uses: actions/deploy-pages@v2 69 | -------------------------------------------------------------------------------- /Website/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | color-scheme: light dark; 3 | } 4 | 5 | * { 6 | box-sizing: border-box; 7 | } 8 | 9 | body { 10 | padding: 0; 11 | margin: 0; 12 | min-width: 100vw; 13 | min-height: 100vh; 14 | display: flex; 15 | align-items: center; 16 | justify-content: center; 17 | font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; 18 | color: var(--neutral-foreground-rest); 19 | } 20 | 21 | .hidden { 22 | display: none !important; 23 | } 24 | 25 | .row { 26 | display: flex; 27 | flex-direction: row; 28 | } 29 | 30 | .col { 31 | display: flex; 32 | flex-direction: column; 33 | } 34 | 35 | .content { 36 | max-width: 1000px; 37 | width: 100%; 38 | margin: 0 auto; 39 | } 40 | 41 | .align-items-center { 42 | align-items: center; 43 | } 44 | 45 | .justify-content-between { 46 | justify-content: space-between; 47 | } 48 | 49 | .justify-content-end { 50 | justify-content: flex-end; 51 | } 52 | 53 | h1 { 54 | margin-bottom: 0.5rem; 55 | } 56 | 57 | .caption1 { 58 | font-size: 1rem; 59 | color: var(--neutral-foreground-hover); 60 | } 61 | 62 | .caption2 { 63 | font-size: 0.8rem; 64 | margin-top: 0.25rem; 65 | color: var(--neutral-foreground-rest); 66 | } 67 | 68 | .packages { 69 | margin: 0.5rem 0 1rem 0; 70 | max-width: 90%; 71 | padding: 0.25rem; 72 | display: flex; 73 | flex: 1; 74 | } 75 | 76 | #packageGrid { 77 | overflow-y: auto; 78 | width: 100%; 79 | max-height: 40rem; 80 | } 81 | 82 | .packages .packageName { 83 | font-size: 1.1rem; 84 | font-weight: 600; 85 | margin: 0.25rem 0; 86 | } 87 | 88 | .searchBlock { 89 | margin-top: 1rem; 90 | width: 100%; 91 | max-width: 90%; 92 | } 93 | 94 | .searchBlock .root { 95 | width: 100%; 96 | } 97 | 98 | #searchInput { 99 | width: 100%; 100 | } 101 | 102 | .vccUrlField { 103 | min-width: 450px; 104 | max-width: 90%; 105 | flex-grow:1; 106 | } 107 | 108 | #addListingToVccHelp { 109 | z-index: 11; 110 | } 111 | 112 | #packageInfoModal { 113 | z-index: 10; 114 | } 115 | 116 | #rowMoreMenu { 117 | top: 0; 118 | left: 0; 119 | position: absolute; 120 | z-index: 10; 121 | } 122 | 123 | #rowMoreMenu a { 124 | display: block; 125 | text-decoration: none; 126 | color: var(--neutral-foreground-rest); 127 | } 128 | 129 | .bannerImage { 130 | aspect-ratio: 5 / 1; 131 | border-radius: 6px; 132 | max-width: 90%; 133 | width: 100%; 134 | background-size: cover; 135 | background-position: center; 136 | background-repeat: no-repeat; 137 | margin-bottom: 0.25rem; 138 | } 139 | 140 | .badge { 141 | border-radius: 4px; 142 | padding: 0.25rem 0.5rem; 143 | background-color: var(--neutral-fill-hover); 144 | } 145 | 146 | .m-0 { 147 | margin: 0; 148 | } 149 | 150 | .m-1 { 151 | margin: 0.25rem; 152 | } 153 | 154 | .m-2 { 155 | margin: 0.5rem; 156 | } 157 | 158 | .m-3 { 159 | margin: 0.75rem; 160 | } 161 | 162 | .m-4 { 163 | margin: 1rem; 164 | } 165 | 166 | .m-5 { 167 | margin: 2rem; 168 | } 169 | 170 | .mt-1 { 171 | margin-top: 0.25rem; 172 | } 173 | 174 | .mt-2 { 175 | margin-top: 0.5rem; 176 | } 177 | 178 | .mt-3 { 179 | margin-top: 0.75rem; 180 | } 181 | 182 | .mt-4 { 183 | margin-top: 1rem; 184 | } 185 | 186 | .mt-5 { 187 | margin-top: 2rem; 188 | } 189 | 190 | .mb-1 { 191 | margin-bottom: 0.25rem; 192 | } 193 | 194 | .mb-2 { 195 | margin-bottom: 0.5rem; 196 | } 197 | 198 | .mb-3 { 199 | margin-bottom: 0.75rem; 200 | } 201 | 202 | .mb-4 { 203 | margin-bottom: 1rem; 204 | } 205 | 206 | .mb-5 { 207 | margin-bottom: 2rem; 208 | } 209 | 210 | .ms-1 { 211 | margin-left: 0.25rem; 212 | } 213 | 214 | .ms-2 { 215 | margin-left: 0.5rem; 216 | } 217 | 218 | .ms-3 { 219 | margin-left: 0.75rem; 220 | } 221 | 222 | .ms-4 { 223 | margin-left: 1rem; 224 | } 225 | 226 | .ms-5 { 227 | margin-left: 2rem; 228 | } 229 | 230 | .me-1 { 231 | margin-right: 0.25rem; 232 | } 233 | 234 | .me-2 { 235 | margin-right: 0.5rem; 236 | } 237 | 238 | .me-3 { 239 | margin-right: 0.75rem; 240 | } 241 | 242 | .me-4 { 243 | margin-right: 1rem; 244 | } 245 | 246 | .me-5 { 247 | margin-right: 2rem; 248 | } 249 | 250 | .p-1 { 251 | padding: 0.25rem; 252 | } 253 | 254 | .p-2 { 255 | padding: 0.5rem; 256 | } 257 | 258 | .p-3 { 259 | padding: 0.75rem; 260 | } 261 | 262 | .p-4 { 263 | padding: 1rem; 264 | } 265 | 266 | .p-5 { 267 | padding: 2rem; 268 | } 269 | 270 | .pt-1 { 271 | padding-top: 0.25rem; 272 | } 273 | 274 | .pt-2 { 275 | padding-top: 0.5rem; 276 | } 277 | 278 | .pt-3 { 279 | padding-top: 0.75rem; 280 | } 281 | 282 | .pt-4 { 283 | padding-top: 1rem; 284 | } 285 | 286 | .pt-5 { 287 | padding-top: 2rem; 288 | } 289 | 290 | .pb-1 { 291 | padding-bottom: 0.25rem; 292 | } 293 | 294 | .pb-2 { 295 | padding-bottom: 0.5rem; 296 | } 297 | 298 | .pb-3 { 299 | padding-bottom: 0.75rem; 300 | } 301 | 302 | .pb-4 { 303 | padding-bottom: 1rem; 304 | } 305 | 306 | .pb-5 { 307 | padding-bottom: 2rem; 308 | } 309 | 310 | .ps-1 { 311 | padding-left: 0.25rem; 312 | } 313 | 314 | .ps-2 { 315 | padding-left: 0.5rem; 316 | } 317 | 318 | .ps-3 { 319 | padding-left: 0.75rem; 320 | } 321 | 322 | .ps-4 { 323 | padding-left: 1rem; 324 | } 325 | 326 | .ps-5 { 327 | padding-left: 2rem; 328 | } 329 | 330 | .pe-1 { 331 | padding-right: 0.25rem; 332 | } 333 | 334 | .pe-2 { 335 | padding-right: 0.5rem; 336 | } 337 | 338 | .pe-3 { 339 | padding-right: 0.75rem; 340 | } 341 | 342 | .pe-4 { 343 | padding-right: 1rem; 344 | } 345 | 346 | .pe-5 { 347 | padding-right: 2rem; 348 | } 349 | 350 | .w-100 { 351 | width: 100%; 352 | } 353 | 354 | .flex-1 { 355 | flex: 1; 356 | } -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/Editor/VRChatApiToolsEditor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Reflection; 6 | using System.Threading.Tasks; 7 | using UnityEditor; 8 | using VRC.Core; 9 | 10 | namespace BocuD.VRChatApiTools 11 | { 12 | [InitializeOnLoad] 13 | public static class VRChatApiToolsEditor 14 | { 15 | public static EditorCoroutine fetchingWorlds; 16 | public static EditorCoroutine fetchingAvatars; 17 | 18 | static VRChatApiToolsEditor() 19 | { 20 | //gotta love this 21 | VRChatApiTools.DownloadImage = DownloadImage; 22 | 23 | //get access to AsyncProgressBar methods through reflection https://answers.unity.com/questions/1104823/using-editors-built-in-progress-bar.html 24 | Type type = typeof(Editor).Assembly.GetTypes().FirstOrDefault(t => t.Name == "AsyncProgressBar"); 25 | if (type == null) return; 26 | 27 | _display = type.GetMethod("Display"); 28 | _clear = type.GetMethod("Clear"); 29 | } 30 | 31 | //todo: use gi progress bar for fetch status information display 32 | private static MethodInfo _display = null; 33 | private static MethodInfo _clear = null; 34 | 35 | public static void ShowGIProgressBar(string aText, float aProgress) 36 | { 37 | _display?.Invoke(null, new object[] { aText, aProgress }); 38 | } 39 | public static void ClearGIProgressBar() 40 | { 41 | _clear?.Invoke(null, null); 42 | } 43 | 44 | #region Menu Items 45 | [MenuItem("Tools/VRChatApiTools/Clear caches")] 46 | private static void MIClearCaches() 47 | { 48 | VRChatApiTools.ClearCaches(); 49 | } 50 | 51 | [MenuItem("Tools/VRChatApiTools/Attempt login")] 52 | private static async void MILoginAttempt() 53 | { 54 | if (APIUser.IsLoggedIn) 55 | { 56 | Logger.Log("You are already logged in."); 57 | return; 58 | } 59 | 60 | Logger.Log("Attempting login..."); 61 | 62 | bool result = await VRChatApiTools.TryAutoLoginAsync(); 63 | 64 | Logger.Log(result ? "Login successful." : "Login failed."); 65 | } 66 | 67 | [MenuItem("Tools/VRChatApiTools/Refresh data")] 68 | public static void RefreshData() 69 | { 70 | Logger.Log("Refreshing data..."); 71 | VRChatApiTools.ClearCaches(); 72 | EditorCoroutine.Start(FetchUploadedData()); 73 | } 74 | #endregion 75 | 76 | //Almost 1:1 reimplementation of SDK methods 77 | #region Fetch worlds and avatars owned by user 78 | public static IEnumerator FetchUploadedData() 79 | { 80 | VRChatApiTools.uploadedWorlds = new List(); 81 | VRChatApiTools.uploadedAvatars = new List(); 82 | 83 | if (!ConfigManager.RemoteConfig.IsInitialized()) 84 | ConfigManager.RemoteConfig.Init(); 85 | 86 | if (!APIUser.IsLoggedIn) 87 | yield break; 88 | 89 | ApiCache.Clear(); 90 | VRCCachedWebRequest.ClearOld(); 91 | 92 | if (fetchingAvatars == null) 93 | fetchingAvatars = EditorCoroutine.Start(() => FetchAvatars()); 94 | 95 | if (fetchingWorlds == null) 96 | fetchingWorlds = EditorCoroutine.Start(() => FetchWorlds()); 97 | } 98 | 99 | public static void FetchWorlds(int offset = 0) 100 | { 101 | ApiWorld.FetchList( 102 | delegate(IEnumerable worlds) 103 | { 104 | if (worlds.FirstOrDefault() != null) 105 | fetchingWorlds = EditorCoroutine.Start(() => 106 | { 107 | List list = worlds.ToList(); 108 | int count = list.Count; 109 | VRChatApiTools.SetupWorldData(list); 110 | FetchWorlds(offset + count); 111 | }); 112 | else 113 | { 114 | fetchingWorlds = null; 115 | 116 | foreach (ApiWorld w in VRChatApiTools.uploadedWorlds) 117 | DownloadImage(w.id, w.thumbnailImageUrl); 118 | } 119 | }, 120 | delegate(string obj) 121 | { 122 | Logger.LogError("Couldn't fetch world list:\n" + obj); 123 | fetchingWorlds = null; 124 | }, 125 | "updated", 126 | ApiWorld.SortOwnership.Mine, 127 | ApiWorld.SortOrder.Descending, 128 | offset, 129 | 20, 130 | "", 131 | null, 132 | null, 133 | null, 134 | null, 135 | "", 136 | ApiWorld.ReleaseStatus.All, 137 | null, 138 | null, 139 | true, 140 | false); 141 | } 142 | 143 | public static void FetchAvatars(int offset = 0) 144 | { 145 | ApiAvatar.FetchList( 146 | delegate(IEnumerable avatars) 147 | { 148 | if (avatars.FirstOrDefault() != null) 149 | fetchingAvatars = EditorCoroutine.Start(() => 150 | { 151 | List list = avatars.ToList(); 152 | int count = list.Count; 153 | VRChatApiTools.SetupAvatarData(list); 154 | FetchAvatars(offset + count); 155 | }); 156 | else 157 | { 158 | fetchingAvatars = null; 159 | 160 | foreach (ApiAvatar a in VRChatApiTools.uploadedAvatars) 161 | DownloadImage(a.id, a.thumbnailImageUrl); 162 | } 163 | }, 164 | delegate(string obj) 165 | { 166 | Logger.LogError("Couldn't fetch avatar list:\n" + obj); 167 | fetchingAvatars = null; 168 | }, 169 | ApiAvatar.Owner.Mine, 170 | ApiAvatar.ReleaseStatus.All, 171 | null, 172 | 20, 173 | offset, 174 | ApiAvatar.SortHeading.None, 175 | ApiAvatar.SortOrder.Descending, 176 | null, 177 | null, 178 | true, 179 | false, 180 | null, 181 | false 182 | ); 183 | } 184 | #endregion 185 | 186 | public static void DownloadImage(string blueprintID, string url) 187 | { 188 | if (string.IsNullOrEmpty(url)) return; 189 | if (VRChatApiTools.ImageCache.ContainsKey(blueprintID) && VRChatApiTools.ImageCache[blueprintID] != null) return; 190 | 191 | EditorCoroutine.Start(VRCCachedWebRequest.Get(url, texture => 192 | { 193 | if (texture != null) 194 | { 195 | VRChatApiTools.ImageCache[blueprintID] = texture; 196 | } 197 | else if (VRChatApiTools.ImageCache.ContainsKey(blueprintID)) 198 | { 199 | VRChatApiTools.ImageCache.Remove(blueprintID); 200 | } 201 | })); 202 | } 203 | } 204 | } -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/Editor/VRChatApiToolsUploadStatus.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using UnityEditor; 4 | using UnityEngine; 5 | 6 | namespace BocuD.VRChatApiTools 7 | { 8 | public class VRChatApiToolsUploadStatus : EditorWindow 9 | { 10 | public static VRChatApiToolsUploadStatus ShowStatus() 11 | { 12 | VRChatApiToolsUploadStatus window = GetWindow(true); 13 | 14 | window.titleContent = new GUIContent("VRChat Api Tools Uploader"); 15 | window.maxSize = new Vector2(400, 200); 16 | window.minSize = window.maxSize; 17 | window.autoRepaintOnSceneChange = true; 18 | 19 | window.Show(); 20 | window.Repaint(); 21 | 22 | return window; 23 | } 24 | 25 | public static VRChatApiToolsUploadStatus GetNew() 26 | { 27 | VRChatApiToolsUploadStatus window = CreateInstance(); 28 | window.ShowUtility(); 29 | 30 | window.titleContent = new GUIContent("VRChat Api Tools Uploader"); 31 | window.maxSize = new Vector2(400, 200); 32 | window.minSize = window.maxSize; 33 | window.autoRepaintOnSceneChange = true; 34 | 35 | window.Show(); 36 | window.Repaint(); 37 | 38 | return window; 39 | } 40 | 41 | private Vector2 logScroll; 42 | private string log; 43 | private string _header; 44 | private string _status; 45 | private string _subStatus; 46 | private float currentProgress; 47 | public bool cancelRequested; 48 | public bool uploadInProgress = true; 49 | 50 | public bool userConfirm = false; 51 | public string confirmHeader = ""; 52 | public string confirmButton = ""; 53 | public Action confirmAction; 54 | public Action failAction; 55 | public Action confirmUI; 56 | 57 | public enum UploadState 58 | { 59 | uploading, 60 | aborting, 61 | aborted, 62 | finished, 63 | failed 64 | } 65 | 66 | private UploadState uploadState = UploadState.uploading; 67 | 68 | private void OnGUI() 69 | { 70 | if (userConfirm) 71 | { 72 | DrawUserConfirmUI(); 73 | return; 74 | } 75 | 76 | EditorGUILayout.BeginHorizontal(); 77 | EditorGUILayout.LabelField("Upload Status"); 78 | 79 | if (uploadInProgress) 80 | { 81 | EditorGUI.BeginDisabledGroup(cancelRequested); 82 | if (GUILayout.Button("Cancel")) 83 | { 84 | uploadState = UploadState.aborting; 85 | cancelRequested = true; 86 | } 87 | 88 | EditorGUI.EndDisabledGroup(); 89 | } 90 | else 91 | { 92 | if (GUILayout.Button("Close")) 93 | { 94 | Close(); 95 | } 96 | } 97 | 98 | EditorGUILayout.EndHorizontal(); 99 | 100 | GUIContent icon = EditorGUIUtility.IconContent("d_console.infoicon@2x"); 101 | GUIStyle stateLabel = new GUIStyle(GUI.skin.label) { fontSize = 24, wordWrap = true }; 102 | GUIStyle iconStyle = new GUIStyle(GUI.skin.label) { fixedHeight = 30 }; 103 | 104 | GUILayout.BeginHorizontal(); 105 | 106 | switch (uploadState) 107 | { 108 | case UploadState.uploading: 109 | icon = EditorGUIUtility.IconContent("UpArrow"); 110 | GUILayout.Label(_header, stateLabel); 111 | break; 112 | 113 | case UploadState.aborting: 114 | icon = EditorGUIUtility.IconContent("Error@2x"); 115 | GUILayout.Label("Aborting...", stateLabel); 116 | break; 117 | 118 | case UploadState.failed: 119 | icon = EditorGUIUtility.IconContent("Error@2x"); 120 | GUILayout.Label("Failed", stateLabel); 121 | break; 122 | 123 | case UploadState.aborted: 124 | icon = EditorGUIUtility.IconContent("d_console.infoicon@2x"); 125 | GUILayout.Label("Aborted", stateLabel); 126 | break; 127 | 128 | case UploadState.finished: 129 | icon = EditorGUIUtility.IconContent("d_Toggle Icon"); 130 | GUILayout.Label("Finished!", stateLabel); 131 | break; 132 | } 133 | 134 | GUILayout.FlexibleSpace(); 135 | GUILayout.Label(icon, iconStyle); 136 | GUILayout.EndHorizontal(); 137 | 138 | if (!uploadInProgress) 139 | { 140 | EditorGUI.ProgressBar(EditorGUILayout.GetControlRect(GUILayout.Height(15)), 1, ""); 141 | if (uploadState == UploadState.failed) 142 | EditorGUILayout.HelpBox(_status, MessageType.Error); 143 | } 144 | else 145 | { 146 | EditorGUI.ProgressBar(EditorGUILayout.GetControlRect(GUILayout.Height(15)), currentProgress, _status); 147 | } 148 | 149 | GUIStyle logStyle = new GUIStyle(EditorStyles.label) 150 | { 151 | wordWrap = true, richText = true, alignment = TextAnchor.LowerLeft, fixedWidth = position.width - 33 152 | }; 153 | 154 | EditorGUILayout.BeginVertical(EditorStyles.helpBox); 155 | GUILayout.FlexibleSpace(); 156 | logScroll = EditorGUILayout.BeginScrollView(logScroll); 157 | EditorGUILayout.LabelField(log, logStyle); 158 | EditorGUILayout.EndVertical(); 159 | EditorGUILayout.EndScrollView(); 160 | } 161 | 162 | public void ConfirmButton(string header, string button, Action onSucces, Action onFail, Action contentDrawer = null) 163 | { 164 | userConfirm = true; 165 | confirmHeader = header; 166 | confirmButton = button; 167 | confirmAction = onSucces; 168 | failAction = onFail; 169 | confirmUI = contentDrawer; 170 | } 171 | 172 | private void DrawUserConfirmUI() 173 | { 174 | GUIStyle bigLabel = new GUIStyle(GUI.skin.label) { fontSize = 24, wordWrap = true }; 175 | EditorGUILayout.LabelField(confirmHeader, bigLabel); 176 | confirmUI?.Invoke(); 177 | GUILayout.FlexibleSpace(); 178 | EditorGUILayout.BeginHorizontal(); 179 | if (GUILayout.Button(confirmButton)) 180 | { 181 | userConfirm = false; 182 | confirmAction?.Invoke(); 183 | } 184 | 185 | if (GUILayout.Button("Abort")) 186 | { 187 | userConfirm = false; 188 | uploadState = UploadState.aborted; 189 | uploadInProgress = false; 190 | failAction?.Invoke(); 191 | } 192 | EditorGUILayout.EndHorizontal(); 193 | } 194 | 195 | private void Awake() 196 | { 197 | Logger.statusWindowHook = AddLog; 198 | } 199 | 200 | private void OnDestroy() 201 | { 202 | Logger.statusWindowHook = null; 203 | if(userConfirm) 204 | failAction?.Invoke(); 205 | } 206 | 207 | public void SetUploadState(UploadState newState) 208 | { 209 | uploadState = newState; 210 | if (uploadState == UploadState.aborted || uploadState == UploadState.failed || 211 | uploadState == UploadState.finished) 212 | uploadInProgress = false; 213 | 214 | Repaint(); 215 | } 216 | 217 | public void SetStatus(string header, string status = null, string subStatus = null) 218 | { 219 | AddLog($"{(string.IsNullOrWhiteSpace(_status) ? $"{_header}" : $"{_status}{(string.IsNullOrWhiteSpace(_subStatus) ? "" : $": {_subStatus}")}")}"); 220 | 221 | _header = header; 222 | _status = status; 223 | _subStatus = subStatus; 224 | Repaint(); 225 | } 226 | 227 | public void SetUploadProgress(long uploaded, long total) 228 | { 229 | currentProgress = (float)uploaded / total; 230 | _status = $"Uploading: {uploaded.ToReadableBytes()} / {total.ToReadableBytes()} ({(currentProgress * 100):N1} %)"; 231 | Repaint(); 232 | } 233 | 234 | public void SetErrorState(string header, string details) 235 | { 236 | SetUploadState(UploadState.failed); 237 | _header = header; 238 | _status = details; 239 | AddLog($"{header}{(details.Length > 0 ? " " + details : "")}"); 240 | Repaint(); 241 | Logger.statusWindowHook = null; 242 | } 243 | 244 | public void AddLog(string contents) 245 | { 246 | log += $"\n[{DateTime.Now:HH:mm:ss}]: {contents}"; 247 | 248 | logScroll.y += 1000; 249 | } 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /Website/app.js: -------------------------------------------------------------------------------- 1 | import { baseLayerLuminance, StandardLuminance } from 'https://unpkg.com/@fluentui/web-components'; 2 | 3 | const LISTING_URL = "{{ listingInfo.Url }}"; 4 | 5 | const PACKAGES = { 6 | {{~ for package in packages ~}} 7 | "{{ package.Name }}": { 8 | name: "{{ package.Name }}", 9 | displayName: "{{ if package.DisplayName; package.DisplayName; end; }}", 10 | description: "{{ if package.Description; package.Description; end; }}", 11 | version: "{{ package.Version }}", 12 | author: { 13 | name: "{{ if package.Author.Name; package.Author.Name; end; }}", 14 | url: "{{ if package.Author.Url; package.Author.Url; end; }}", 15 | }, 16 | dependencies: { 17 | {{~ for dependency in package.Dependencies ~}} 18 | "{{ dependency.Name }}": "{{ dependency.Version }}", 19 | {{~ end ~}} 20 | }, 21 | keywords: [ 22 | {{~ for keyword in package.Keywords ~}} 23 | "{{ keyword }}", 24 | {{~ end ~}} 25 | ], 26 | license: "{{ package.License }}", 27 | licensesUrl: "{{ package.LicensesUrl }}", 28 | }, 29 | {{~ end ~}} 30 | }; 31 | 32 | const setTheme = () => { 33 | const isDarkTheme = () => window.matchMedia("(prefers-color-scheme: dark)").matches; 34 | if (isDarkTheme()) { 35 | baseLayerLuminance.setValueFor(document.documentElement, StandardLuminance.DarkMode); 36 | } else { 37 | baseLayerLuminance.setValueFor(document.documentElement, StandardLuminance.LightMode); 38 | } 39 | } 40 | 41 | (() => { 42 | setTheme(); 43 | 44 | window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { 45 | setTheme(); 46 | }); 47 | 48 | const packageGrid = document.getElementById('packageGrid'); 49 | 50 | const searchInput = document.getElementById('searchInput'); 51 | searchInput.addEventListener('input', ({ target: { value = '' }}) => { 52 | const items = packageGrid.querySelectorAll('fluent-data-grid-row[row-type="default"]'); 53 | items.forEach(item => { 54 | if (value === '') { 55 | item.style.display = 'grid'; 56 | return; 57 | } 58 | if ( 59 | item.dataset?.packageName?.toLowerCase()?.includes(value.toLowerCase()) || 60 | item.dataset?.packageId?.toLowerCase()?.includes(value.toLowerCase()) 61 | ) { 62 | item.style.display = 'grid'; 63 | } else { 64 | item.style.display = 'none'; 65 | } 66 | }); 67 | }); 68 | 69 | const urlBarHelpButton = document.getElementById('urlBarHelp'); 70 | const addListingToVccHelp = document.getElementById('addListingToVccHelp'); 71 | urlBarHelpButton.addEventListener('click', () => { 72 | addListingToVccHelp.hidden = false; 73 | }); 74 | const addListingToVccHelpClose = document.getElementById('addListingToVccHelpClose'); 75 | addListingToVccHelpClose.addEventListener('click', () => { 76 | addListingToVccHelp.hidden = true; 77 | }); 78 | 79 | const vccListingInfoUrlFieldCopy = document.getElementById('vccListingInfoUrlFieldCopy'); 80 | vccListingInfoUrlFieldCopy.addEventListener('click', () => { 81 | const vccUrlField = document.getElementById('vccListingInfoUrlField'); 82 | vccUrlField.select(); 83 | navigator.clipboard.writeText(vccUrlField.value); 84 | vccUrlFieldCopy.appearance = 'accent'; 85 | setTimeout(() => { 86 | vccUrlFieldCopy.appearance = 'neutral'; 87 | }, 1000); 88 | }); 89 | 90 | const vccAddRepoButton = document.getElementById('vccAddRepoButton'); 91 | vccAddRepoButton.addEventListener('click', () => window.location.assign(`vcc://vpm/addRepo?url=${encodeURIComponent(LISTING_URL)}`)); 92 | 93 | const vccUrlFieldCopy = document.getElementById('vccUrlFieldCopy'); 94 | vccUrlFieldCopy.addEventListener('click', () => { 95 | const vccUrlField = document.getElementById('vccUrlField'); 96 | vccUrlField.select(); 97 | navigator.clipboard.writeText(vccUrlField.value); 98 | vccUrlFieldCopy.appearance = 'accent'; 99 | setTimeout(() => { 100 | vccUrlFieldCopy.appearance = 'neutral'; 101 | }, 1000); 102 | }); 103 | 104 | const rowMoreMenu = document.getElementById('rowMoreMenu'); 105 | const hideRowMoreMenu = e => { 106 | if (rowMoreMenu.contains(e.target)) return; 107 | document.removeEventListener('click', hideRowMoreMenu); 108 | rowMoreMenu.hidden = true; 109 | } 110 | 111 | const rowMenuButtons = document.querySelectorAll('.rowMenuButton'); 112 | rowMenuButtons.forEach(button => { 113 | button.addEventListener('click', e => { 114 | if (rowMoreMenu?.hidden) { 115 | rowMoreMenu.style.top = `${e.clientY + e.target.clientHeight}px`; 116 | rowMoreMenu.style.left = `${e.clientX - 120}px`; 117 | rowMoreMenu.hidden = false; 118 | 119 | const downloadLink = rowMoreMenu.querySelector('#rowMoreMenuDownload'); 120 | const downloadListener = () => { 121 | window.open(e?.target?.dataset?.packageUrl, '_blank'); 122 | } 123 | downloadLink.addEventListener('change', () => { 124 | downloadListener(); 125 | downloadLink.removeEventListener('change', downloadListener); 126 | }); 127 | 128 | setTimeout(() => { 129 | document.addEventListener('click', hideRowMoreMenu); 130 | }, 1); 131 | } 132 | }); 133 | }); 134 | 135 | const packageInfoModal = document.getElementById('packageInfoModal'); 136 | const packageInfoModalClose = document.getElementById('packageInfoModalClose'); 137 | packageInfoModalClose.addEventListener('click', () => { 138 | packageInfoModal.hidden = true; 139 | }); 140 | 141 | // Fluent dialogs use nested shadow-rooted elements, so we need to use JS to style them 142 | const modalControl = packageInfoModal.shadowRoot.querySelector('.control'); 143 | modalControl.style.maxHeight = "90%"; 144 | modalControl.style.transition = 'height 0.2s ease-in-out'; 145 | modalControl.style.overflowY = 'hidden'; 146 | 147 | const packageInfoName = document.getElementById('packageInfoName'); 148 | const packageInfoId = document.getElementById('packageInfoId'); 149 | const packageInfoVersion = document.getElementById('packageInfoVersion'); 150 | const packageInfoDescription = document.getElementById('packageInfoDescription'); 151 | const packageInfoAuthor = document.getElementById('packageInfoAuthor'); 152 | const packageInfoDependencies = document.getElementById('packageInfoDependencies'); 153 | const packageInfoKeywords = document.getElementById('packageInfoKeywords'); 154 | const packageInfoLicense = document.getElementById('packageInfoLicense'); 155 | 156 | const rowAddToVccButtons = document.querySelectorAll('.rowAddToVccButton'); 157 | rowAddToVccButtons.forEach((button) => { 158 | button.addEventListener('click', () => window.location.assign(`vcc://vpm/addRepo?url=${encodeURIComponent(LISTING_URL)}`)); 159 | }); 160 | 161 | const rowPackageInfoButton = document.querySelectorAll('.rowPackageInfoButton'); 162 | rowPackageInfoButton.forEach((button) => { 163 | button.addEventListener('click', e => { 164 | const packageId = e.target.dataset?.packageId; 165 | const packageInfo = PACKAGES?.[packageId]; 166 | if (!packageInfo) { 167 | console.error(`Did not find package ${packageId}. Packages available:`, PACKAGES); 168 | return; 169 | } 170 | 171 | packageInfoName.textContent = packageInfo.displayName; 172 | packageInfoId.textContent = packageId; 173 | packageInfoVersion.textContent = `v${packageInfo.version}`; 174 | packageInfoDescription.textContent = packageInfo.description; 175 | packageInfoAuthor.textContent = packageInfo.author.name; 176 | packageInfoAuthor.href = packageInfo.author.url; 177 | 178 | if ((packageInfo.keywords?.length ?? 0) === 0) { 179 | packageInfoKeywords.parentElement.classList.add('hidden'); 180 | } else { 181 | packageInfoKeywords.parentElement.classList.remove('hidden'); 182 | packageInfoKeywords.innerHTML = null; 183 | packageInfo.keywords.forEach(keyword => { 184 | const keywordDiv = document.createElement('div'); 185 | keywordDiv.classList.add('me-2', 'mb-2', 'badge'); 186 | keywordDiv.textContent = keyword; 187 | packageInfoKeywords.appendChild(keywordDiv); 188 | }); 189 | } 190 | 191 | if (!packageInfo.license?.length && !packageInfo.licensesUrl?.length) { 192 | packageInfoLicense.parentElement.classList.add('hidden'); 193 | } else { 194 | packageInfoLicense.parentElement.classList.remove('hidden'); 195 | packageInfoLicense.textContent = packageInfo.license ?? 'See License'; 196 | packageInfoLicense.href = packageInfo.licensesUrl ?? '#'; 197 | } 198 | 199 | packageInfoDependencies.innerHTML = null; 200 | Object.entries(packageInfo.dependencies).forEach(([name, version]) => { 201 | const depRow = document.createElement('li'); 202 | depRow.classList.add('mb-2'); 203 | depRow.textContent = `${name} @ v${version}`; 204 | packageInfoDependencies.appendChild(depRow); 205 | }); 206 | 207 | packageInfoModal.hidden = false; 208 | 209 | setTimeout(() => { 210 | const height = packageInfoModal.querySelector('.col').clientHeight; 211 | modalControl.style.setProperty('--dialog-height', `${height + 14}px`); 212 | }, 1); 213 | }); 214 | }); 215 | 216 | const packageInfoVccUrlFieldCopy = document.getElementById('packageInfoVccUrlFieldCopy'); 217 | packageInfoVccUrlFieldCopy.addEventListener('click', () => { 218 | const vccUrlField = document.getElementById('packageInfoVccUrlField'); 219 | vccUrlField.select(); 220 | navigator.clipboard.writeText(vccUrlField.value); 221 | vccUrlFieldCopy.appearance = 'accent'; 222 | setTimeout(() => { 223 | vccUrlFieldCopy.appearance = 'neutral'; 224 | }, 1000); 225 | }); 226 | 227 | const packageInfoListingHelp = document.getElementById('packageInfoListingHelp'); 228 | packageInfoListingHelp.addEventListener('click', () => { 229 | addListingToVccHelp.hidden = false; 230 | }); 231 | })(); -------------------------------------------------------------------------------- /Packages/packages-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "com.unity.ext.nunit": { 4 | "version": "1.0.6", 5 | "depth": 1, 6 | "source": "registry", 7 | "dependencies": {}, 8 | "url": "https://packages.unity.com" 9 | }, 10 | "com.unity.ide.rider": { 11 | "version": "1.2.1", 12 | "depth": 0, 13 | "source": "registry", 14 | "dependencies": { 15 | "com.unity.test-framework": "1.1.1" 16 | }, 17 | "url": "https://packages.unity.com" 18 | }, 19 | "com.unity.ide.visualstudio": { 20 | "version": "2.0.11", 21 | "depth": 0, 22 | "source": "registry", 23 | "dependencies": { 24 | "com.unity.test-framework": "1.1.9" 25 | }, 26 | "url": "https://packages.unity.com" 27 | }, 28 | "com.unity.ide.vscode": { 29 | "version": "1.2.4", 30 | "depth": 0, 31 | "source": "registry", 32 | "dependencies": {}, 33 | "url": "https://packages.unity.com" 34 | }, 35 | "com.unity.nuget.newtonsoft-json": { 36 | "version": "2.0.2", 37 | "depth": 1, 38 | "source": "registry", 39 | "dependencies": {}, 40 | "url": "https://packages.unity.com" 41 | }, 42 | "com.unity.test-framework": { 43 | "version": "1.1.29", 44 | "depth": 0, 45 | "source": "registry", 46 | "dependencies": { 47 | "com.unity.ext.nunit": "1.0.6", 48 | "com.unity.modules.imgui": "1.0.0", 49 | "com.unity.modules.jsonserialize": "1.0.0" 50 | }, 51 | "url": "https://packages.unity.com" 52 | }, 53 | "com.unity.textmeshpro": { 54 | "version": "2.1.6", 55 | "depth": 0, 56 | "source": "registry", 57 | "dependencies": { 58 | "com.unity.ugui": "1.0.0" 59 | }, 60 | "url": "https://packages.unity.com" 61 | }, 62 | "com.unity.timeline": { 63 | "version": "1.2.18", 64 | "depth": 0, 65 | "source": "registry", 66 | "dependencies": { 67 | "com.unity.modules.director": "1.0.0", 68 | "com.unity.modules.animation": "1.0.0", 69 | "com.unity.modules.audio": "1.0.0", 70 | "com.unity.modules.particlesystem": "1.0.0" 71 | }, 72 | "url": "https://packages.unity.com" 73 | }, 74 | "com.unity.ugui": { 75 | "version": "1.0.0", 76 | "depth": 0, 77 | "source": "builtin", 78 | "dependencies": { 79 | "com.unity.modules.ui": "1.0.0", 80 | "com.unity.modules.imgui": "1.0.0" 81 | } 82 | }, 83 | "com.vrchat.core.bootstrap": { 84 | "version": "file:com.vrchat.core.bootstrap", 85 | "depth": 0, 86 | "source": "embedded", 87 | "dependencies": { 88 | "com.unity.nuget.newtonsoft-json": "2.0.2" 89 | } 90 | }, 91 | "com.vrchat.core.vpm-resolver": { 92 | "version": "file:com.vrchat.core.vpm-resolver", 93 | "depth": 0, 94 | "source": "embedded", 95 | "dependencies": { 96 | "com.unity.nuget.newtonsoft-json": "2.0.2" 97 | } 98 | }, 99 | "com.vrchat.demo-template": { 100 | "version": "file:com.vrchat.demo-template", 101 | "depth": 0, 102 | "source": "embedded", 103 | "dependencies": {} 104 | }, 105 | "com.unity.modules.ai": { 106 | "version": "1.0.0", 107 | "depth": 0, 108 | "source": "builtin", 109 | "dependencies": {} 110 | }, 111 | "com.unity.modules.androidjni": { 112 | "version": "1.0.0", 113 | "depth": 0, 114 | "source": "builtin", 115 | "dependencies": {} 116 | }, 117 | "com.unity.modules.animation": { 118 | "version": "1.0.0", 119 | "depth": 0, 120 | "source": "builtin", 121 | "dependencies": {} 122 | }, 123 | "com.unity.modules.assetbundle": { 124 | "version": "1.0.0", 125 | "depth": 0, 126 | "source": "builtin", 127 | "dependencies": {} 128 | }, 129 | "com.unity.modules.audio": { 130 | "version": "1.0.0", 131 | "depth": 0, 132 | "source": "builtin", 133 | "dependencies": {} 134 | }, 135 | "com.unity.modules.cloth": { 136 | "version": "1.0.0", 137 | "depth": 0, 138 | "source": "builtin", 139 | "dependencies": { 140 | "com.unity.modules.physics": "1.0.0" 141 | } 142 | }, 143 | "com.unity.modules.director": { 144 | "version": "1.0.0", 145 | "depth": 0, 146 | "source": "builtin", 147 | "dependencies": { 148 | "com.unity.modules.audio": "1.0.0", 149 | "com.unity.modules.animation": "1.0.0" 150 | } 151 | }, 152 | "com.unity.modules.imageconversion": { 153 | "version": "1.0.0", 154 | "depth": 0, 155 | "source": "builtin", 156 | "dependencies": {} 157 | }, 158 | "com.unity.modules.imgui": { 159 | "version": "1.0.0", 160 | "depth": 0, 161 | "source": "builtin", 162 | "dependencies": {} 163 | }, 164 | "com.unity.modules.jsonserialize": { 165 | "version": "1.0.0", 166 | "depth": 0, 167 | "source": "builtin", 168 | "dependencies": {} 169 | }, 170 | "com.unity.modules.particlesystem": { 171 | "version": "1.0.0", 172 | "depth": 0, 173 | "source": "builtin", 174 | "dependencies": {} 175 | }, 176 | "com.unity.modules.physics": { 177 | "version": "1.0.0", 178 | "depth": 0, 179 | "source": "builtin", 180 | "dependencies": {} 181 | }, 182 | "com.unity.modules.physics2d": { 183 | "version": "1.0.0", 184 | "depth": 0, 185 | "source": "builtin", 186 | "dependencies": {} 187 | }, 188 | "com.unity.modules.screencapture": { 189 | "version": "1.0.0", 190 | "depth": 0, 191 | "source": "builtin", 192 | "dependencies": { 193 | "com.unity.modules.imageconversion": "1.0.0" 194 | } 195 | }, 196 | "com.unity.modules.subsystems": { 197 | "version": "1.0.0", 198 | "depth": 1, 199 | "source": "builtin", 200 | "dependencies": { 201 | "com.unity.modules.jsonserialize": "1.0.0" 202 | } 203 | }, 204 | "com.unity.modules.terrain": { 205 | "version": "1.0.0", 206 | "depth": 0, 207 | "source": "builtin", 208 | "dependencies": {} 209 | }, 210 | "com.unity.modules.terrainphysics": { 211 | "version": "1.0.0", 212 | "depth": 0, 213 | "source": "builtin", 214 | "dependencies": { 215 | "com.unity.modules.physics": "1.0.0", 216 | "com.unity.modules.terrain": "1.0.0" 217 | } 218 | }, 219 | "com.unity.modules.tilemap": { 220 | "version": "1.0.0", 221 | "depth": 0, 222 | "source": "builtin", 223 | "dependencies": { 224 | "com.unity.modules.physics2d": "1.0.0" 225 | } 226 | }, 227 | "com.unity.modules.ui": { 228 | "version": "1.0.0", 229 | "depth": 0, 230 | "source": "builtin", 231 | "dependencies": {} 232 | }, 233 | "com.unity.modules.uielements": { 234 | "version": "1.0.0", 235 | "depth": 0, 236 | "source": "builtin", 237 | "dependencies": { 238 | "com.unity.modules.imgui": "1.0.0", 239 | "com.unity.modules.jsonserialize": "1.0.0" 240 | } 241 | }, 242 | "com.unity.modules.umbra": { 243 | "version": "1.0.0", 244 | "depth": 0, 245 | "source": "builtin", 246 | "dependencies": {} 247 | }, 248 | "com.unity.modules.unityanalytics": { 249 | "version": "1.0.0", 250 | "depth": 0, 251 | "source": "builtin", 252 | "dependencies": { 253 | "com.unity.modules.unitywebrequest": "1.0.0", 254 | "com.unity.modules.jsonserialize": "1.0.0" 255 | } 256 | }, 257 | "com.unity.modules.unitywebrequest": { 258 | "version": "1.0.0", 259 | "depth": 0, 260 | "source": "builtin", 261 | "dependencies": {} 262 | }, 263 | "com.unity.modules.unitywebrequestassetbundle": { 264 | "version": "1.0.0", 265 | "depth": 0, 266 | "source": "builtin", 267 | "dependencies": { 268 | "com.unity.modules.assetbundle": "1.0.0", 269 | "com.unity.modules.unitywebrequest": "1.0.0" 270 | } 271 | }, 272 | "com.unity.modules.unitywebrequestaudio": { 273 | "version": "1.0.0", 274 | "depth": 0, 275 | "source": "builtin", 276 | "dependencies": { 277 | "com.unity.modules.unitywebrequest": "1.0.0", 278 | "com.unity.modules.audio": "1.0.0" 279 | } 280 | }, 281 | "com.unity.modules.unitywebrequesttexture": { 282 | "version": "1.0.0", 283 | "depth": 0, 284 | "source": "builtin", 285 | "dependencies": { 286 | "com.unity.modules.unitywebrequest": "1.0.0", 287 | "com.unity.modules.imageconversion": "1.0.0" 288 | } 289 | }, 290 | "com.unity.modules.unitywebrequestwww": { 291 | "version": "1.0.0", 292 | "depth": 0, 293 | "source": "builtin", 294 | "dependencies": { 295 | "com.unity.modules.unitywebrequest": "1.0.0", 296 | "com.unity.modules.unitywebrequestassetbundle": "1.0.0", 297 | "com.unity.modules.unitywebrequestaudio": "1.0.0", 298 | "com.unity.modules.audio": "1.0.0", 299 | "com.unity.modules.assetbundle": "1.0.0", 300 | "com.unity.modules.imageconversion": "1.0.0" 301 | } 302 | }, 303 | "com.unity.modules.vehicles": { 304 | "version": "1.0.0", 305 | "depth": 0, 306 | "source": "builtin", 307 | "dependencies": { 308 | "com.unity.modules.physics": "1.0.0" 309 | } 310 | }, 311 | "com.unity.modules.video": { 312 | "version": "1.0.0", 313 | "depth": 0, 314 | "source": "builtin", 315 | "dependencies": { 316 | "com.unity.modules.audio": "1.0.0", 317 | "com.unity.modules.ui": "1.0.0", 318 | "com.unity.modules.unitywebrequest": "1.0.0" 319 | } 320 | }, 321 | "com.unity.modules.vr": { 322 | "version": "1.0.0", 323 | "depth": 0, 324 | "source": "builtin", 325 | "dependencies": { 326 | "com.unity.modules.jsonserialize": "1.0.0", 327 | "com.unity.modules.physics": "1.0.0", 328 | "com.unity.modules.xr": "1.0.0" 329 | } 330 | }, 331 | "com.unity.modules.wind": { 332 | "version": "1.0.0", 333 | "depth": 0, 334 | "source": "builtin", 335 | "dependencies": {} 336 | }, 337 | "com.unity.modules.xr": { 338 | "version": "1.0.0", 339 | "depth": 0, 340 | "source": "builtin", 341 | "dependencies": { 342 | "com.unity.modules.physics": "1.0.0", 343 | "com.unity.modules.jsonserialize": "1.0.0", 344 | "com.unity.modules.subsystems": "1.0.0" 345 | } 346 | } 347 | } 348 | } 349 | -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/Runtime/VRChatApiTools.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Security.Cryptography; 6 | using System.Threading.Tasks; 7 | using JetBrains.Annotations; 8 | using UnityEditor; 9 | using UnityEngine; 10 | using UnityEngine.SceneManagement; 11 | using VRC.Core; 12 | using VRC.SDKBase; 13 | using Object = UnityEngine.Object; 14 | 15 | namespace BocuD.VRChatApiTools 16 | { 17 | public static class VRChatApiTools 18 | { 19 | public static Dictionary ImageCache = new Dictionary(); 20 | 21 | public static List uploadedWorlds; 22 | public static List uploadedAvatars; 23 | 24 | [NonSerialized] public static List currentlyFetching = new List(); 25 | [NonSerialized] public static List invalidBlueprints = new List(); 26 | 27 | [NonSerialized] public static Dictionary blueprintCache = new Dictionary(); 28 | 29 | public static Action DownloadImage; 30 | 31 | public const string avatar_regex = "avtr_[A-Za-z0-9]{8}-[A-Za-z0-9]{4}-[A-Za-z0-9]{4}-[A-Za-z0-9]{4}-[A-Za-z0-9]{12}"; 32 | public const string world_regex = "wrld_[A-Za-z0-9]{8}-[A-Za-z0-9]{4}-[A-Za-z0-9]{4}-[A-Za-z0-9]{4}-[A-Za-z0-9]{12}"; 33 | 34 | #region Fetching data 35 | 36 | public static void ClearCaches() 37 | { 38 | uploadedWorlds = null; 39 | uploadedAvatars = null; 40 | 41 | ImageCache.Clear(); 42 | currentlyFetching.Clear(); 43 | invalidBlueprints.Clear(); 44 | blueprintCache.Clear(); 45 | } 46 | 47 | public static void FetchApiWorld(string blueprintID) 48 | { 49 | if (currentlyFetching.Contains(blueprintID)) return; 50 | 51 | currentlyFetching.Add(blueprintID); 52 | 53 | ApiWorld world = API.FromCacheOrNew(blueprintID); 54 | 55 | world.Fetch(null, 56 | c => AddWorldToCache(blueprintID, c.Model as ApiWorld), 57 | c => 58 | { 59 | if (c.Code == 404) 60 | { 61 | currentlyFetching.Remove(world.id); 62 | invalidBlueprints.Add(world.id); 63 | Logger.Log($"World '{blueprintID}' doesn't exist so couldn't be loaded."); 64 | ApiCache.Invalidate(blueprintID); 65 | } 66 | else 67 | currentlyFetching.Remove(world.id); 68 | }); 69 | } 70 | 71 | public static async Task FetchApiWorldAsync(string blueprintID) 72 | { 73 | ApiWorld world = API.Fetch(blueprintID); 74 | ApiContainer result = new ApiContainer(); 75 | bool wait = true; 76 | 77 | world.Fetch(null, 78 | c => 79 | { 80 | result = c; 81 | wait = false; 82 | }, 83 | c => 84 | { 85 | if (c.Code == 404) 86 | { 87 | Logger.Log($"World '{blueprintID}' doesn't exist so couldn't be loaded."); 88 | ApiCache.Invalidate(blueprintID); 89 | } 90 | else 91 | { 92 | Logger.LogError(c.Error); 93 | } 94 | 95 | result = c; 96 | wait = false; 97 | }); 98 | 99 | while (wait) 100 | { 101 | await Task.Delay(100); 102 | } 103 | 104 | return result.Model as ApiWorld; 105 | } 106 | 107 | public static void FetchApiAvatar(string blueprintID) 108 | { 109 | if (currentlyFetching.Contains(blueprintID)) return; 110 | 111 | currentlyFetching.Add(blueprintID); 112 | 113 | ApiAvatar avatar = API.FromCacheOrNew(blueprintID); 114 | 115 | avatar.Fetch(c => AddAvatarToCache(blueprintID, c.Model as ApiAvatar), 116 | c => 117 | { 118 | if (c.Code == 404) 119 | { 120 | currentlyFetching.Remove(avatar.id); 121 | invalidBlueprints.Add(avatar.id); 122 | Logger.Log($"Avatar '{blueprintID}' doesn't exist so couldn't be loaded."); 123 | ApiCache.Invalidate(blueprintID); 124 | } 125 | else 126 | currentlyFetching.Remove(avatar.id); 127 | }); 128 | } 129 | 130 | public static async Task FetchApiAvatarAsync(string blueprintID) 131 | { 132 | ApiAvatar avatar = API.Fetch(blueprintID); 133 | ApiContainer result = new ApiContainer(); 134 | bool wait = true; 135 | 136 | avatar.Fetch( 137 | c => 138 | { 139 | result = c; 140 | wait = false; 141 | }, 142 | c => 143 | { 144 | if (c.Code == 404) 145 | { 146 | Logger.Log($"Avatar '{blueprintID}' doesn't exist so couldn't be loaded."); 147 | ApiCache.Invalidate(blueprintID); 148 | } 149 | 150 | result = c; 151 | wait = false; 152 | }); 153 | 154 | while (wait) 155 | { 156 | await Task.Delay(100); 157 | } 158 | 159 | return result.Model as ApiAvatar; 160 | } 161 | 162 | private static void AddWorldToCache(string blueprintID, ApiWorld world) 163 | { 164 | currentlyFetching.Remove(world.id); 165 | blueprintCache.Add(blueprintID, world); 166 | 167 | DownloadImage?.Invoke(blueprintID, world.thumbnailImageUrl); 168 | } 169 | 170 | private static void AddAvatarToCache(string blueprintID, ApiAvatar avatar) 171 | { 172 | currentlyFetching.Remove(avatar.id); 173 | blueprintCache.Add(blueprintID, avatar); 174 | 175 | DownloadImage?.Invoke(blueprintID, avatar.thumbnailImageUrl); 176 | } 177 | 178 | public static bool autoLoginFailed; 179 | public static void TryAutoLogin([CanBeNull]Action onSucces = null) 180 | { 181 | if (!APIUser.IsLoggedIn) 182 | { 183 | if (!autoLoginFailed) 184 | { 185 | VRCLogin.AttemptLogin( 186 | c => 187 | { 188 | Logger.Log($"Succesfully logged in as user: {((APIUser)c.Model).displayName}"); 189 | onSucces?.Invoke(); 190 | }, 191 | 192 | c => 193 | { 194 | Logger.LogError($"Automatic login failed: {c.Error}"); 195 | autoLoginFailed = true; 196 | }); 197 | } 198 | } 199 | else 200 | { 201 | autoLoginFailed = false; 202 | } 203 | } 204 | 205 | public static void SetupWorldData(List worlds) 206 | { 207 | if (worlds == null || uploadedWorlds == null) 208 | return; 209 | 210 | worlds.RemoveAll(w => w?.name == null || uploadedWorlds.Any(w2 => w2.id == w.id)); 211 | 212 | if (worlds.Count <= 0) return; 213 | 214 | uploadedWorlds.AddRange(worlds); 215 | 216 | foreach (ApiWorld world in uploadedWorlds) 217 | { 218 | if (!blueprintCache.TryGetValue(world.id, out ApiModel test)) 219 | { 220 | blueprintCache.Add(world.id, world); 221 | } 222 | } 223 | } 224 | 225 | public static void SetupAvatarData(List avatars) 226 | { 227 | if (avatars == null || uploadedAvatars == null) 228 | return; 229 | 230 | avatars.RemoveAll(a => a?.name == null || uploadedAvatars.Any(a2 => a2.id == a.id)); 231 | 232 | if (avatars.Count <= 0) return; 233 | 234 | uploadedAvatars.AddRange(avatars); 235 | 236 | foreach (ApiAvatar avatar in uploadedAvatars) 237 | { 238 | if (!blueprintCache.TryGetValue(avatar.id, out ApiModel test)) 239 | { 240 | blueprintCache.Add(avatar.id, avatar); 241 | } 242 | } 243 | } 244 | 245 | #endregion 246 | 247 | public static async Task TryAutoLoginAsync() 248 | { 249 | bool succes = false; 250 | bool wait = true; 251 | 252 | if (!APIUser.IsLoggedIn) 253 | { 254 | if (!autoLoginFailed) 255 | { 256 | VRCLogin.AttemptLogin( 257 | c => 258 | { 259 | Logger.Log($"Succesfully logged in as user: {((APIUser)c.Model).displayName}"); 260 | succes = true; 261 | wait = false; 262 | }, 263 | 264 | c => 265 | { 266 | Logger.LogError($"Automatic login failed: {c.Error}"); 267 | autoLoginFailed = true; 268 | wait = false; 269 | }); 270 | } 271 | } 272 | else 273 | { 274 | autoLoginFailed = false; 275 | succes = true; 276 | wait = false; 277 | 278 | ClearCaches(); 279 | } 280 | 281 | while (wait) 282 | { 283 | await Task.Delay(100); 284 | } 285 | 286 | return succes; 287 | } 288 | 289 | public static PipelineManager FindPipelineManager() 290 | { 291 | Scene currentScene = SceneManager.GetActiveScene(); 292 | 293 | VRC_SceneDescriptor[] sceneDescriptors = Object.FindObjectsOfType() 294 | .Where(x => x.gameObject.scene == currentScene).ToArray(); 295 | 296 | if (sceneDescriptors.Length == 0) return null; 297 | 298 | if (sceneDescriptors.Length == 1) 299 | return sceneDescriptors[0].GetComponent(); 300 | if (sceneDescriptors.Length > 1) 301 | { 302 | Logger.LogError("Multiple scene descriptors found. Make sure you only have one scene descriptor."); 303 | return sceneDescriptors[0].GetComponent(); 304 | } 305 | 306 | return null; 307 | } 308 | 309 | public static string DisplayTags(List tags) 310 | { 311 | string output = ""; 312 | 313 | foreach (string tag in tags) 314 | { 315 | output += $"{tag.ReplaceFirst("author_tag_", "")}, "; 316 | } 317 | 318 | if (output.Contains(", ")) 319 | output = output.Substring(0, output.Length - 2); 320 | 321 | return output; 322 | } 323 | 324 | public static string ReplaceFirst(this string text, string search, string replace) 325 | { 326 | int pos = text.IndexOf(search, StringComparison.Ordinal); 327 | if (pos < 0) 328 | { 329 | return text; 330 | } 331 | return text.Substring(0, pos) + replace + text.Substring(pos + search.Length); 332 | } 333 | 334 | public static string ToReadableBytes(this long i) 335 | { 336 | // Get absolute value 337 | long absolute_i = (i < 0 ? -i : i); 338 | // Determine the suffix and readable value 339 | string suffix; 340 | double readable; 341 | if (absolute_i >= 0x1000000000000000) // Exabyte 342 | { 343 | suffix = "EB"; 344 | readable = (i >> 50); 345 | } 346 | else if (absolute_i >= 0x4000000000000) // Petabyte 347 | { 348 | suffix = "PB"; 349 | readable = (i >> 40); 350 | } 351 | else if (absolute_i >= 0x10000000000) // Terabyte 352 | { 353 | suffix = "TB"; 354 | readable = (i >> 30); 355 | } 356 | else if (absolute_i >= 0x40000000) // Gigabyte 357 | { 358 | suffix = "GB"; 359 | readable = (i >> 20); 360 | } 361 | else if (absolute_i >= 0x100000) // Megabyte 362 | { 363 | suffix = "MB"; 364 | readable = (i >> 10); 365 | } 366 | else if (absolute_i >= 0x400) // Kilobyte 367 | { 368 | suffix = "KB"; 369 | readable = i; 370 | } 371 | else 372 | { 373 | return i.ToString("0 B"); // Byte 374 | } 375 | // Divide by 1024 to get fractional value 376 | readable = (readable / 1024); 377 | // Return formatted number with suffix 378 | return readable.ToString("0.### ") + suffix; 379 | } 380 | 381 | public static string GetFileName(this string filePath) 382 | { 383 | string[] split = filePath.Split('/'); 384 | return split[split.Length - 1]; 385 | } 386 | 387 | [Serializable] 388 | public class BlueprintInfo 389 | { 390 | public string name = ""; 391 | public string blueprintID = ""; 392 | public string description = ""; 393 | public Texture2D newImage; 394 | public string newImagePath = ""; 395 | } 396 | 397 | [Serializable] 398 | public class WorldInfo : BlueprintInfo 399 | { 400 | public List tags = new List(); 401 | public int capacity; 402 | } 403 | 404 | public static Platform CurrentPlatform() 405 | { 406 | #if UNITY_EDITOR 407 | BuildTarget target = EditorUserBuildSettings.activeBuildTarget; 408 | switch (target) 409 | { 410 | case BuildTarget.Android: 411 | return Platform.Android; 412 | 413 | case BuildTarget.StandaloneWindows: 414 | case BuildTarget.StandaloneWindows64: 415 | return Platform.Windows; 416 | 417 | default: 418 | return Platform.unknown; 419 | } 420 | #else 421 | #if UNITY_ANDROID 422 | return Platform.Android; 423 | #endif 424 | #if UNITY_STANDALONE_WIN 425 | return Platform.Windows; 426 | #endif 427 | #endif 428 | } 429 | 430 | public static string ToApiString(this Platform input) 431 | { 432 | switch (input) 433 | { 434 | case Platform.Windows: 435 | return "standalonewindows"; 436 | case Platform.Android: 437 | return "android"; 438 | default: 439 | return "unknownplatform"; 440 | } 441 | } 442 | 443 | public static string ToPrettyString(this ApiModel.SupportedPlatforms platforms) 444 | { 445 | switch (platforms) 446 | { 447 | case ApiModel.SupportedPlatforms.None: 448 | return "None"; 449 | 450 | case ApiModel.SupportedPlatforms.StandaloneWindows: 451 | return "Windows"; 452 | 453 | case ApiModel.SupportedPlatforms.Android: 454 | return "Android"; 455 | 456 | case ApiModel.SupportedPlatforms.All: 457 | return "Cross Platform"; 458 | 459 | default: 460 | return "Unknown"; 461 | } 462 | } 463 | 464 | public static string GetFriendlyAvatarFileName(string type, string blueprintID, Platform platform) => 465 | $"Avatar - {blueprintID} - {type} - {Application.unityVersion}_{ApiWorld.VERSION.ApiVersion}_{platform.ToApiString()}_{API.GetServerEnvironmentForApiUrl()}"; 466 | 467 | public static string GetFriendlyWorldFileName(string type, ApiWorld apiWorld, Platform platform) => 468 | $"World - {apiWorld.name} - {type} - {Application.unityVersion}_{ApiWorld.VERSION.ApiVersion}_{platform.ToApiString()}_{API.GetServerEnvironmentForApiUrl()}"; 469 | 470 | public static string ComputeFileMD5(string filepath) 471 | { 472 | if (!File.Exists(filepath)) return ""; 473 | 474 | using (MD5 md5 = MD5.Create()) 475 | { 476 | using (FileStream stream = File.OpenRead(filepath)) 477 | { 478 | byte[] hash = md5.ComputeHash(stream); 479 | return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); 480 | } 481 | } 482 | } 483 | } 484 | 485 | public enum Platform 486 | { 487 | Windows, 488 | Android, 489 | unknown 490 | } 491 | } 492 | -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/Editor/ApiFileAsyncExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using UnityEngine; 7 | using VRC; 8 | using VRC.Core; 9 | 10 | namespace BocuD.VRChatApiTools 11 | { 12 | using static Constants; 13 | 14 | public static class ApiFileAsyncExtensions 15 | { 16 | //todo: merge StartSimpleUploadAsync and StartMultiPartUploadAsync into one function, then use proxy functions to do the actual calls 17 | public static async Task StartSimpleUploadAsync(this ApiFile apiFile, 18 | ApiFile.Version.FileDescriptor.Type fileDescriptorType, Func cancelQuery) 19 | { 20 | string uploadUrl = ""; 21 | 22 | if (!apiFile.IsInitialized) 23 | { 24 | throw new Exception("Unable to upload file: file not initialized"); 25 | } 26 | 27 | int latestVersionNumber = apiFile.GetLatestVersionNumber(); 28 | 29 | if (apiFile.GetFileDescriptor(latestVersionNumber, fileDescriptorType) == null) 30 | { 31 | throw new Exception("Version record doesn't exist"); 32 | } 33 | 34 | ApiFile.UploadStatus uploadStatus = new ApiFile.UploadStatus(apiFile.id, latestVersionNumber, fileDescriptorType, "start"); 35 | 36 | bool wait = true; 37 | 38 | ApiDictContainer apiDictContainer = new ApiDictContainer("url") 39 | { 40 | OnSuccess = c => 41 | { 42 | wait = false; 43 | uploadUrl = (c as ApiDictContainer)?.ResponseDictionary["url"].Value as string; 44 | }, 45 | OnError = c => throw new Exception(c.Error) 46 | }; 47 | 48 | API.SendPutRequest(uploadStatus.Endpoint, apiDictContainer); 49 | 50 | while (wait) 51 | { 52 | if (cancelQuery()) 53 | { 54 | throw new OperationCanceledException(); 55 | } 56 | await Task.Delay(33); 57 | } 58 | 59 | if (string.IsNullOrEmpty(uploadUrl)) 60 | { 61 | throw new Exception("Invalid URL provided by API"); 62 | } 63 | 64 | return uploadUrl; 65 | } 66 | 67 | public static async Task StartMultiPartUploadAsync(this ApiFile apiFile, int partNumber, 68 | ApiFile.Version.FileDescriptor.Type fileDescriptorType, Func cancelQuery) 69 | { 70 | string uploadUrl = ""; 71 | 72 | if (!apiFile.IsInitialized) 73 | { 74 | throw new Exception("Unable to upload file: file not initialized."); 75 | } 76 | 77 | int latestVersionNumber = apiFile.GetLatestVersionNumber(); 78 | 79 | if (apiFile.GetFileDescriptor(latestVersionNumber, fileDescriptorType) == null) 80 | { 81 | throw new Exception("Version record doesn't exist"); 82 | } 83 | 84 | ApiFile.UploadStatus uploadStatus = new ApiFile.UploadStatus(apiFile.id, latestVersionNumber, fileDescriptorType, "start"); 85 | 86 | bool wait = true; 87 | 88 | ApiDictContainer apiDictContainer = new ApiDictContainer("url") 89 | { 90 | OnSuccess = c => 91 | { 92 | wait = false; 93 | uploadUrl = (c as ApiDictContainer)?.ResponseDictionary["url"].Value as string; 94 | }, 95 | OnError = c => throw new Exception("Failed to start multipart upload", new Exception(c.Error)) 96 | }; 97 | 98 | API.SendPutRequest($"{uploadStatus.Endpoint}?partNumber={partNumber}", apiDictContainer); 99 | 100 | while (wait) 101 | { 102 | if (cancelQuery()) 103 | { 104 | throw new OperationCanceledException(); 105 | } 106 | await Task.Delay(33); 107 | } 108 | 109 | if (string.IsNullOrEmpty(uploadUrl)) 110 | { 111 | throw new Exception("Invalid URL provided by API while uploading multipart file"); 112 | } 113 | 114 | await Task.Delay(postWriteDelay); 115 | 116 | return uploadUrl; 117 | } 118 | 119 | public static async Task PutSimpleFileAsync(this ApiFile apiFile, string filename, string md5Base64, ApiFile.Version.FileDescriptor.Type fileDescriptorType, 120 | Action onProgress, Func cancelQuery) 121 | { 122 | string uploadUrl = await apiFile.StartSimpleUploadAsync(fileDescriptorType, cancelQuery); 123 | 124 | bool wait = true; 125 | 126 | HttpRequest req = ApiFile.PutSimpleFileToURL(uploadUrl, filename, 127 | ApiFileHelper.GetMimeTypeFromExtension(Path.GetExtension(filename)), md5Base64, true, 128 | () => wait = false, 129 | error => throw new Exception($"Failed to upload file: {error}"), 130 | (uploaded, length) => onProgress?.Invoke(uploaded, length) 131 | ); 132 | 133 | while (wait) 134 | { 135 | if (cancelQuery()) 136 | { 137 | req?.Abort(); 138 | throw new OperationCanceledException(); 139 | } 140 | 141 | await Task.Delay(33); 142 | } 143 | } 144 | 145 | public static async Task FinishUploadAsync(this ApiFile apiFile, 146 | ApiFile.Version.FileDescriptor.Type fileDescriptorType, 147 | List multipartEtags, Func cancelQuery) 148 | { 149 | if (!apiFile.IsInitialized) 150 | { 151 | throw new Exception("Unable to finish upload of file: file not initialized."); 152 | } 153 | 154 | int latestVersionNumber = apiFile.GetLatestVersionNumber(); 155 | 156 | if (apiFile.GetFileDescriptor(latestVersionNumber, fileDescriptorType) == null) 157 | { 158 | throw new Exception("Version record doesn't exist"); 159 | } 160 | 161 | bool wait = true; 162 | 163 | new ApiFile.UploadStatus(apiFile.id, latestVersionNumber, fileDescriptorType, "finish") 164 | { 165 | etags = multipartEtags 166 | }.Put(c => wait = false, 167 | c => 168 | throw new Exception("Unable to finish upload of file", new Exception(c.Error))); 169 | 170 | while (wait) 171 | { 172 | if (cancelQuery()) 173 | { 174 | throw new OperationCanceledException(); 175 | } 176 | await Task.Delay(33); 177 | } 178 | 179 | await Task.Delay(postWriteDelay); 180 | } 181 | 182 | public static async Task GetUploadStatus(this ApiFile apiFile, 183 | ApiFile.Version.FileDescriptor.Type fileDescriptorType, Func cancelQuery) 184 | { 185 | bool wait = true; 186 | ApiFile.UploadStatus result = null; 187 | 188 | apiFile.GetUploadStatus(apiFile.GetLatestVersionNumber(), fileDescriptorType, 189 | c => 190 | { 191 | wait = false; 192 | result = (ApiFile.UploadStatus)c.Model; 193 | }, 194 | c => throw new Exception("Failed to query multipart upload status", new Exception(c.Error))); 195 | 196 | while (wait) 197 | { 198 | if (cancelQuery()) 199 | { 200 | throw new OperationCanceledException(); 201 | } 202 | await Task.Delay(33); 203 | } 204 | 205 | if (result == null) 206 | { 207 | throw new Exception("Failed to query multipart upload status", new Exception("Got null status from api")); 208 | } 209 | 210 | return result; 211 | } 212 | 213 | public static async Task PutMultipartDataAsync(this ApiFile apiFile, int partNumber, ApiFile.Version.FileDescriptor.Type fileDescriptorType, 214 | byte[] buffer, string mimeType, int bytesRead, Action onProgress, Func cancelQuery) 215 | { 216 | string uploadUrl = await apiFile.StartMultiPartUploadAsync(partNumber, fileDescriptorType, cancelQuery); 217 | 218 | bool wait = true; 219 | string resultTag = ""; 220 | 221 | HttpRequest req = ApiFile.PutMultipartDataToURL(uploadUrl, buffer, bytesRead, mimeType, true, 222 | etag => 223 | { 224 | if (!string.IsNullOrEmpty(etag)) 225 | resultTag = etag; 226 | wait = false; 227 | }, 228 | error => throw new Exception("Failed to upload data part", new Exception(error)), 229 | (uploaded, length) => onProgress?.Invoke(uploaded, length) 230 | ); 231 | 232 | while (wait) 233 | { 234 | if (cancelQuery()) 235 | { 236 | req?.Abort(); 237 | throw new OperationCanceledException(); 238 | } 239 | 240 | await Task.Delay(33); 241 | } 242 | 243 | return resultTag; 244 | } 245 | 246 | public static async Task DeleteLatestVersion(this ApiFile apiFile) 247 | { 248 | bool wait = true; 249 | 250 | if (!apiFile.IsInitialized) 251 | { 252 | throw new Exception("Unable to delete file: file not initialized."); 253 | } 254 | 255 | int latestVersionNumber = apiFile.GetLatestVersionNumber(); 256 | if (latestVersionNumber <= 0 || latestVersionNumber >= apiFile.versions.Count) 257 | throw new Exception($"ApiFile ({apiFile.id}): version to delete is invalid: {latestVersionNumber}"); 258 | 259 | if (latestVersionNumber == 1) 260 | throw new Exception("There is only one version. Deleting version that would delete the file. Please use another method."); 261 | 262 | apiFile.DeleteVersion(latestVersionNumber, 263 | c => wait = false, 264 | c => throw new Exception(c.Error)); 265 | 266 | while (wait) 267 | { 268 | await Task.Delay(33); 269 | } 270 | 271 | await Task.Delay(postWriteDelay); 272 | } 273 | 274 | public static async Task UploadFileComponentDoSimpleUpload(this ApiFile apiFile, ApiFile.Version.FileDescriptor.Type fileDescriptorType, 275 | string filename, string md5Base64, Action onProgress, Func cancelQuery) 276 | { 277 | Logger.Log($"Starting simple upload for {apiFile.name}..."); 278 | 279 | // delay to let write get through servers 280 | await Task.Delay(postWriteDelay); 281 | 282 | //PUT file to url 283 | await apiFile.PutSimpleFileAsync(filename, md5Base64, fileDescriptorType, onProgress, cancelQuery); 284 | 285 | //finish upload 286 | await apiFile.FinishUploadAsync(fileDescriptorType, null, cancelQuery); 287 | } 288 | 289 | public static async Task UploadFileComponentDoMultipartUpload(this ApiFile apiFile, ApiFile.Version.FileDescriptor.Type fileDescriptorType, 290 | string filename, long fileSize, Action onProgress, Func cancelQuery) 291 | { 292 | //get existing multipart upload status in case there is one 293 | ApiFile.UploadStatus uploadStatus = await apiFile.GetUploadStatus(fileDescriptorType, cancelQuery); 294 | 295 | FileStream fs = File.OpenRead(filename); 296 | 297 | byte[] buffer = new byte[kMultipartUploadChunkSize * 2]; 298 | 299 | long totalBytesUploaded = 0; 300 | List etags = new List(); 301 | if (uploadStatus != null) 302 | etags = uploadStatus.etags.ToList(); 303 | 304 | //why is this a FloorToInt? what the fuck? so 100MB parts on a 250MB world gives you... a 100MB and a 150MB part? 305 | int numParts = Mathf.Max(1, Mathf.FloorToInt(fs.Length / (float)kMultipartUploadChunkSize)); 306 | 307 | try 308 | { 309 | for (int partNumber = 1; partNumber <= numParts; partNumber++) 310 | { 311 | Logger.Log($"Uploading part {partNumber}/{numParts}..."); 312 | 313 | // read chunk 314 | int bytesToRead = partNumber < numParts 315 | ? kMultipartUploadChunkSize 316 | : (int)(fs.Length - fs.Position); 317 | int bytesRead = fs.Read(buffer, 0, bytesToRead); 318 | 319 | if (bytesRead != bytesToRead) 320 | { 321 | throw new Exception($"Uploading part {partNumber} failed", new Exception("Couldn't read file: read incorrect number of bytes from stream")); 322 | } 323 | 324 | // check if this part has been upload already 325 | // NOTE: uploadStatus.nextPartNumber == number of parts already uploaded 326 | if (uploadStatus != null && partNumber <= uploadStatus.nextPartNumber) 327 | { 328 | totalBytesUploaded += bytesRead; 329 | continue; 330 | } 331 | 332 | void OnMultiPartUploadProgress(long uploadedBytes, long totalBytes) 333 | { 334 | onProgress(totalBytesUploaded + uploadedBytes, fileSize); 335 | } 336 | 337 | //PUT file 338 | string etag = await apiFile.PutMultipartDataAsync(partNumber, fileDescriptorType, buffer, 339 | ApiFileHelper.GetMimeTypeFromExtension(Path.GetExtension(filename)), bytesRead, OnMultiPartUploadProgress, 340 | cancelQuery); 341 | 342 | etags.Add(etag); 343 | totalBytesUploaded += bytesRead; 344 | } 345 | } 346 | catch (Exception) 347 | { 348 | fs.Close(); 349 | throw; 350 | } 351 | 352 | await Task.Delay(postWriteDelay); 353 | 354 | //finish upload 355 | try 356 | { 357 | await apiFile.FinishUploadAsync(fileDescriptorType, etags, cancelQuery); 358 | } 359 | catch (Exception) 360 | { 361 | fs.Close(); 362 | throw; 363 | } 364 | 365 | await Task.Delay(postWriteDelay); 366 | } 367 | 368 | public static async Task UploadFileComponentVerifyRecord(this ApiFile apiFile, 369 | ApiFile.Version.FileDescriptor.Type fileDescriptorType, ApiFile.Version.FileDescriptor fileDesc) 370 | { 371 | float initialStartTime = Time.realtimeSinceStartup; 372 | float startTime = initialStartTime; 373 | float timeout = GetServerProcessingWaitTimeoutForDataSize(fileDesc.sizeInBytes); 374 | float waitDelay = SERVER_PROCESSING_INITIAL_RETRY_TIME; 375 | 376 | while(true) 377 | { 378 | if (apiFile == null) 379 | { 380 | throw new Exception("ApiFile is null"); 381 | } 382 | 383 | ApiFile.Version.FileDescriptor desc = apiFile.GetFileDescriptor(apiFile.GetLatestVersionNumber(), fileDescriptorType); 384 | if (desc == null) 385 | { 386 | throw new Exception($"File descriptor is null ('{fileDescriptorType}')"); 387 | } 388 | 389 | if (desc.status != ApiFile.Status.Waiting) 390 | { 391 | // upload completed or is processing 392 | break; 393 | } 394 | 395 | // wait for next poll 396 | while (Time.realtimeSinceStartup - startTime < waitDelay) 397 | { 398 | if (Time.realtimeSinceStartup - initialStartTime > timeout) 399 | { 400 | throw new TimeoutException("Couldn't verify upload status: Timed out wait for server processing"); 401 | } 402 | 403 | await Task.Delay(33); 404 | } 405 | 406 | while (true) 407 | { 408 | bool wait = true; 409 | bool worthRetry = false; 410 | 411 | apiFile.Refresh( 412 | (c) => 413 | { 414 | wait = false; 415 | }, 416 | (c) => 417 | { 418 | if (c.Code == 400) 419 | { 420 | worthRetry = true; 421 | wait = false; 422 | } 423 | else 424 | { 425 | throw new Exception($"Couldn't verify upload status", new Exception(c.Error)); 426 | } 427 | }); 428 | 429 | while (wait) 430 | { 431 | await Task.Delay(33); 432 | } 433 | 434 | if (!worthRetry) 435 | break; 436 | } 437 | 438 | waitDelay = Mathf.Min(waitDelay * 2, SERVER_PROCESSING_MAX_RETRY_TIME); 439 | startTime = Time.realtimeSinceStartup; 440 | } 441 | } 442 | } 443 | } 444 | -------------------------------------------------------------------------------- /Website/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | VCC Listing 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 | {{~ if listingInfo.BannerImage; ~}} 15 |
16 | {{~ end; ~}} 17 |

18 | {{~ listingInfo.Name ~}} 19 |

20 | {{~ if listingInfo.Description; ~}} 21 |
{{ listingInfo.Description }}
22 | {{~ end; ~}} 23 |
24 | {{~ if listingInfo.Author.Email; ~}} 25 | 26 | {{ listingInfo.Author.Email }} 27 | 28 | {{~ end; ~}} 29 | 30 | {{~ if listingInfo.InfoLink.Url ~}} 31 | 34 | {{~ end; ~}} 35 |
36 |
37 |
38 | 39 | 40 | Add to VCC 41 | 42 | 43 | 44 | 45 | Copy 46 | 47 | 48 | How to add a listing to your VCC 49 | 50 | 51 | 52 | 53 | 54 | 55 |
56 | 97 |
98 | 99 |
100 | 110 | 172 | 173 | 174 | 175 | 176 | Name 177 | 178 | 179 | Type 180 | 181 | 182 | 183 | 184 | {{~ for package in packages ~}} 185 | 186 | 187 |
188 |
{{ package.DisplayName }}
189 |
{{ package.Description }}
190 |
{{ package.Name }}
191 |
192 |
193 | 194 | {{ package.Type }} 195 | 196 | 197 | Add to VCC 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 |
210 | {{~ end ~}} 211 |
212 |
213 | {{~ if listingInfo.InfoLink.Url ~}} 214 | 217 | {{~ end; ~}} 218 |
219 | 220 | 221 | 222 | -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/Editor/VRChatApiUploaderAsync.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Threading.Tasks; 7 | using UnityEditor; 8 | using UnityEngine; 9 | using VRC.Core; 10 | using Debug = UnityEngine.Debug; 11 | 12 | namespace BocuD.VRChatApiTools 13 | { 14 | public class VRChatApiUploaderAsync 15 | { 16 | public delegate void SetStatusFunc(string header, string status = null, string subStatus = null); 17 | public delegate void SetUploadProgressFunc(long done, long total); 18 | public delegate void SetUploadStateFunc(VRChatApiToolsUploadStatus.UploadState state); 19 | public delegate void SetErrorStateFunc(string header, string details); 20 | public delegate void LoggerFunc(string contents); 21 | 22 | public VRChatApiToolsUploadStatus uploadStatus; 23 | 24 | public SetStatusFunc OnStatus = (header, status, subStatus) => { }; 25 | public SetUploadProgressFunc OnUploadProgress = (done, total) => { }; 26 | public SetUploadStateFunc OnUploadState = state => { }; 27 | public SetErrorStateFunc OnError = (header, details) => { Debug.LogError($"{header}: {details}"); }; 28 | public LoggerFunc Log = contents => Logger.Log(contents); 29 | public LoggerFunc LogWarning = contents => Logger.LogWarning(contents); 30 | public LoggerFunc LogError = contents => Logger.LogError(contents); 31 | 32 | public Func cancelQuery = () => false; 33 | 34 | public void UseStatusWindow() 35 | { 36 | uploadStatus = VRChatApiToolsUploadStatus.GetNew(); 37 | 38 | OnStatus = uploadStatus.SetStatus; 39 | OnUploadProgress = uploadStatus.SetUploadProgress; 40 | OnUploadState = uploadStatus.SetUploadState; 41 | OnError = uploadStatus.SetErrorState; 42 | cancelQuery = () => uploadStatus.cancelRequested; 43 | } 44 | 45 | public async Task UpdateBlueprintImage(ApiModel blueprint, Texture2D newImage) 46 | { 47 | if (!(blueprint is ApiAvatar) && !(blueprint is ApiWorld)) 48 | return false; 49 | 50 | string newImagePath = SaveImageTemp(newImage); 51 | 52 | if (blueprint is ApiWorld world) 53 | { 54 | world.imageUrl = await UploadImage(world, newImagePath); 55 | } 56 | else if (blueprint is ApiAvatar avatar) 57 | { 58 | avatar.imageUrl = await UploadImage(avatar, newImagePath); 59 | } 60 | 61 | bool success = await ApplyBlueprintChanges(blueprint); 62 | 63 | if (success) 64 | OnUploadState(VRChatApiToolsUploadStatus.UploadState.finished); 65 | else OnUploadState(VRChatApiToolsUploadStatus.UploadState.failed); 66 | 67 | return success; 68 | } 69 | 70 | public async Task ApplyBlueprintChanges(ApiModel blueprint) 71 | { 72 | if (!(blueprint is ApiAvatar) && !(blueprint is ApiWorld)) 73 | return false; 74 | 75 | bool doneUploading = false; 76 | bool success = false; 77 | 78 | OnStatus("Applying Blueprint Changes"); 79 | 80 | blueprint.Save( 81 | c => 82 | { 83 | //if (blueprint is ApiAvatar) AnalyticsSDK.AvatarUploaded(blueprint.id, true); 84 | //else AnalyticsSDK.WorldUploaded(blueprint.id, true); 85 | doneUploading = true; 86 | success = true; 87 | }, 88 | c => 89 | { 90 | OnError("Applying blueprint changes failed", c.Error); 91 | doneUploading = true; 92 | }); 93 | 94 | while (!doneUploading) 95 | await Task.Delay(33); 96 | 97 | return success; 98 | } 99 | 100 | /// 101 | /// Upload a World AssetBundle to VRChat 102 | /// 103 | /// World AssetBundle path 104 | /// UnityPackage path (can be left empty) 105 | /// Data structure containing world name, description, etc 106 | /// blueprint ID of the uploaded world 107 | /// 108 | public async Task UploadWorld(string assetBundlePath, string unityPackagePath, VRChatApiTools.WorldInfo worldInfo = null) 109 | { 110 | if (string.IsNullOrWhiteSpace(assetBundlePath)) 111 | throw new Exception("Invalid null or empty AssetBundle path provided"); 112 | 113 | VRChatApiTools.ClearCaches(); 114 | 115 | await Task.Delay(100); 116 | 117 | if (!await VRChatApiTools.TryAutoLoginAsync()) 118 | throw new Exception("Failed to login"); 119 | 120 | PipelineManager pipelineManager = VRChatApiTools.FindPipelineManager(); 121 | if (pipelineManager == null) 122 | throw new Exception("Couldn't find Pipeline Manager"); 123 | 124 | pipelineManager.user = APIUser.CurrentUser; 125 | 126 | bool isUpdate = true; 127 | bool wait = true; 128 | 129 | ApiWorld apiWorld = new ApiWorld 130 | { 131 | id = pipelineManager.blueprintId 132 | }; 133 | 134 | apiWorld.Fetch(null, 135 | (c) => 136 | { 137 | Log("Updating an existing world."); 138 | apiWorld = c.Model as ApiWorld; 139 | pipelineManager.completedSDKPipeline = !string.IsNullOrEmpty(apiWorld.authorId); 140 | isUpdate = true; 141 | wait = false; 142 | }, 143 | (c) => 144 | { 145 | Log("World record not found, creating a new world."); 146 | apiWorld = new ApiWorld { capacity = 16 }; 147 | pipelineManager.completedSDKPipeline = false; 148 | apiWorld.id = pipelineManager.blueprintId; 149 | isUpdate = false; 150 | wait = false; 151 | }); 152 | 153 | while (wait) await Task.Delay(100); 154 | 155 | if (apiWorld == null) 156 | throw new Exception("Couldn't fetch or create world record"); 157 | 158 | //Prepare asset bundle 159 | string blueprintId = apiWorld.id; 160 | int version = Mathf.Max(1, apiWorld.version + 1); 161 | string uploadVrcPath = PrepareVRCPathForS3(assetBundlePath, blueprintId, version, VRChatApiTools.CurrentPlatform(), ApiWorld.VERSION); 162 | 163 | //Prepare unity package if it exists 164 | bool shouldUploadUnityPackage = !string.IsNullOrEmpty(unityPackagePath) && File.Exists(unityPackagePath); 165 | string uploadUnityPackagePath = shouldUploadUnityPackage ? PrepareUnityPackageForS3(unityPackagePath, blueprintId, version, VRChatApiTools.CurrentPlatform(), ApiWorld.VERSION) : ""; 166 | if (shouldUploadUnityPackage) Logger.LogWarning("Found UnityPackage. Why are you building with future proof publish enabled?"); 167 | 168 | //Assign a new blueprint ID if this is a new world 169 | if (string.IsNullOrEmpty(apiWorld.id)) 170 | { 171 | pipelineManager.AssignId(); 172 | apiWorld.id = pipelineManager.blueprintId; 173 | } 174 | 175 | if (apiWorld.capacity < apiWorld.recommendedCapacity) 176 | apiWorld.capacity = apiWorld.recommendedCapacity; 177 | 178 | await UploadWorldData(apiWorld, uploadUnityPackagePath, uploadVrcPath, isUpdate, VRChatApiTools.CurrentPlatform(), worldInfo); 179 | 180 | return apiWorld.id; 181 | } 182 | 183 | public async Task UploadWorldData(ApiWorld apiWorld, string uploadUnityPackagePath, string uploadVrcPath, bool isUpdate, Platform platform, VRChatApiTools.WorldInfo worldInfo = null) 184 | { 185 | string unityPackageUrl = ""; 186 | string assetBundleUrl = ""; 187 | 188 | // upload unity package 189 | if (!string.IsNullOrEmpty(uploadUnityPackagePath)) 190 | { 191 | unityPackageUrl = await UploadFile(uploadUnityPackagePath, 192 | isUpdate ? apiWorld.unityPackageUrl : "", 193 | VRChatApiTools.GetFriendlyWorldFileName("Unity package", apiWorld, platform), "Unity package"); 194 | } 195 | 196 | // upload asset bundle 197 | if (!string.IsNullOrEmpty(uploadVrcPath)) 198 | { 199 | assetBundleUrl = await UploadFile(uploadVrcPath, isUpdate ? apiWorld.assetUrl : "", 200 | VRChatApiTools.GetFriendlyWorldFileName("Asset bundle", apiWorld, platform), "Asset bundle"); 201 | } 202 | 203 | if (string.IsNullOrWhiteSpace(assetBundleUrl)) 204 | { 205 | OnStatus("Failed", "Asset bundle upload failed"); 206 | return; 207 | } 208 | 209 | bool appliedSucces = false; 210 | 211 | if (isUpdate) 212 | appliedSucces = await UpdateWorldBlueprint(apiWorld, assetBundleUrl, unityPackageUrl, worldInfo); 213 | else 214 | appliedSucces = await CreateWorldBlueprint(apiWorld, assetBundleUrl, unityPackageUrl, worldInfo); 215 | 216 | if (appliedSucces) 217 | { 218 | OnUploadState(VRChatApiToolsUploadStatus.UploadState.finished); 219 | } 220 | else 221 | { 222 | OnUploadState(VRChatApiToolsUploadStatus.UploadState.failed); 223 | } 224 | } 225 | 226 | public async Task UpdateWorldBlueprint(ApiWorld apiWorld, string newAssetUrl, string newPackageUrl, VRChatApiTools.WorldInfo worldInfo = null) 227 | { 228 | bool applied = false; 229 | 230 | if (worldInfo != null) 231 | { 232 | apiWorld.name = worldInfo.name; 233 | apiWorld.description = worldInfo.description; 234 | apiWorld.tags = worldInfo.tags.ToList(); 235 | apiWorld.capacity = worldInfo.capacity; 236 | 237 | if (worldInfo.newImagePath != "") 238 | { 239 | string newImageUrl = await UploadImage(apiWorld, worldInfo.newImagePath); 240 | apiWorld.imageUrl = newImageUrl; 241 | } 242 | } 243 | 244 | apiWorld.assetUrl = string.IsNullOrWhiteSpace(newAssetUrl) ? apiWorld.assetUrl : newAssetUrl; 245 | apiWorld.unityPackageUrl = string.IsNullOrWhiteSpace(newPackageUrl) ? apiWorld.unityPackageUrl : newPackageUrl; 246 | 247 | OnStatus("Applying Blueprint Changes"); 248 | 249 | bool success = false; 250 | apiWorld.Save(c => 251 | { 252 | applied = true; 253 | success = true; 254 | }, c => 255 | { 256 | applied = true; 257 | LogError(c.Error); 258 | OnError("Applying blueprint changes failed", c.Error); 259 | success = false; 260 | }); 261 | 262 | while (!applied) 263 | await Task.Delay(33); 264 | 265 | return success; 266 | } 267 | 268 | private async Task CreateWorldBlueprint(ApiWorld apiWorld, string newAssetUrl, string newPackageUrl, VRChatApiTools.WorldInfo worldInfo = null) 269 | { 270 | bool success = false; 271 | 272 | PipelineManager pipelineManager = VRChatApiTools.FindPipelineManager(); 273 | if (pipelineManager == null) 274 | { 275 | LogError("Couldn't find Pipeline Manager"); 276 | OnError("Creating blueprint failed", "Couldn't find Pipeline Manager"); 277 | return false; 278 | } 279 | 280 | ApiWorld newWorld = new ApiWorld 281 | { 282 | id = apiWorld.id, 283 | authorName = pipelineManager.user.displayName, 284 | authorId = pipelineManager.user.id, 285 | name = "New VRChat world", //temp 286 | imageUrl = "", 287 | assetUrl = newAssetUrl, 288 | unityPackageUrl = newPackageUrl, 289 | description = "A description", //temp 290 | tags = new List(), //temp 291 | releaseStatus = ("private"), //temp 292 | capacity = Convert.ToInt16(16), //temp 293 | occupants = 0, 294 | shouldAddToAuthor = true, 295 | isCurated = false 296 | }; 297 | 298 | if (worldInfo != null) 299 | { 300 | newWorld.name = worldInfo.name; 301 | newWorld.description = worldInfo.description; 302 | newWorld.tags = worldInfo.tags.ToList(); 303 | newWorld.capacity = worldInfo.capacity; 304 | 305 | if (worldInfo.newImagePath != "") 306 | { 307 | newWorld.imageUrl = await UploadImage(newWorld, worldInfo.newImagePath);; 308 | } 309 | } 310 | 311 | if (string.IsNullOrWhiteSpace(newWorld.imageUrl)) 312 | { 313 | newWorld.imageUrl = await UploadImage(newWorld, SaveImageTemp(new Texture2D(1200, 900))); 314 | } 315 | 316 | bool applied = false; 317 | 318 | newWorld.Post( 319 | (c) => 320 | { 321 | ApiWorld savedBlueprint = (ApiWorld)c.Model; 322 | pipelineManager.blueprintId = savedBlueprint.id; 323 | EditorUtility.SetDirty(pipelineManager); 324 | applied = true; 325 | if (worldInfo != null) worldInfo.blueprintID = savedBlueprint.id; 326 | success = true; 327 | }, 328 | (c) => 329 | { 330 | applied = true; 331 | Debug.LogError(c.Error); 332 | success = false; 333 | OnError("Creating blueprint failed", c.Error); 334 | }); 335 | 336 | while (!applied) 337 | await Task.Delay(100); 338 | 339 | return success; 340 | } 341 | 342 | public async Task UploadImage(ApiModel blueprint, string newImagePath) 343 | { 344 | string friendlyFileName; 345 | string existingFileUrl; 346 | 347 | switch (blueprint) 348 | { 349 | case ApiWorld world: 350 | friendlyFileName = VRChatApiTools.GetFriendlyWorldFileName("Image", world, VRChatApiTools.CurrentPlatform()); 351 | existingFileUrl = world.imageUrl; 352 | break; 353 | case ApiAvatar avatar: 354 | friendlyFileName = VRChatApiTools.GetFriendlyAvatarFileName("Image", avatar.id, VRChatApiTools.CurrentPlatform()); 355 | existingFileUrl = avatar.imageUrl; 356 | break; 357 | default: 358 | throw new ArgumentException("Unsupported ApiModel passed"); 359 | } 360 | 361 | Log($"Preparing image upload for {newImagePath}..."); 362 | 363 | string newUrl = null; 364 | 365 | if (!string.IsNullOrEmpty(newImagePath)) 366 | { 367 | newUrl = await UploadFile(newImagePath, existingFileUrl, friendlyFileName, "Image"); 368 | } 369 | 370 | return newUrl; 371 | } 372 | 373 | public static string SaveImageTemp(Texture2D input) 374 | { 375 | byte[] png = input.EncodeToPNG(); 376 | string path = ImageName(input.width, input.height, "image", Application.temporaryCachePath); 377 | File.WriteAllBytes(path, png); 378 | return path; 379 | } 380 | 381 | private static string ImageName(int width, int height, string name, string savePath) => 382 | $"{savePath}/{name}_{width}x{height}_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.png"; 383 | 384 | public async Task UploadFile(string filePath, string existingFileUrl, string friendlyFileName, string fileType) 385 | { 386 | string newFileUrl = ""; 387 | 388 | if (string.IsNullOrEmpty(filePath)) 389 | { 390 | LogError("Null file passed to UploadFileAsync"); 391 | return newFileUrl; 392 | } 393 | 394 | Log($"Uploading {fileType} ({filePath.GetFileName()}) ..."); 395 | 396 | OnStatus($"Uploading {fileType}..."); 397 | 398 | string fileId = ApiFile.ParseFileIdFromFileAPIUrl(existingFileUrl); 399 | 400 | ApiFileHelperAsync fileHelperAsync = new ApiFileHelperAsync(); 401 | 402 | Stopwatch stopwatch = Stopwatch.StartNew(); 403 | newFileUrl = await fileHelperAsync.UploadFile(filePath, fileId, fileType, friendlyFileName, 404 | (status, subStatus) => OnStatus(status, subStatus), (done, total) => OnUploadProgress(done, total), 405 | cancelQuery); 406 | 407 | Log($"{fileType} upload succeeded"); 408 | stopwatch.Stop(); 409 | OnStatus("Upload Succesful", $"Finished upload in {stopwatch.Elapsed:mm\\:ss}"); 410 | 411 | return newFileUrl; 412 | } 413 | 414 | private static string PrepareUnityPackageForS3(string packagePath, string blueprintId, int version, Platform platform, AssetVersion assetVersion) 415 | { 416 | string uploadUnityPackagePath = 417 | $"{Application.temporaryCachePath}/{blueprintId}_{version}_{Application.unityVersion}_{assetVersion.ApiVersion}_{platform.ToApiString()}_{API.GetServerEnvironmentForApiUrl()}.unitypackage"; 418 | 419 | if (File.Exists(uploadUnityPackagePath)) 420 | File.Delete(uploadUnityPackagePath); 421 | 422 | File.Copy(packagePath, uploadUnityPackagePath); 423 | 424 | return uploadUnityPackagePath; 425 | } 426 | 427 | private static string PrepareVRCPathForS3(string assetBundlePath, string blueprintId, int version, Platform platform, AssetVersion assetVersion) 428 | { 429 | string uploadVrcPath = 430 | $"{Application.temporaryCachePath}/{blueprintId}_{version}_{Application.unityVersion}_{assetVersion.ApiVersion}_{platform.ToApiString()}_{API.GetServerEnvironmentForApiUrl()}{Path.GetExtension(assetBundlePath)}"; 431 | 432 | if (File.Exists(uploadVrcPath)) 433 | File.Delete(uploadVrcPath); 434 | 435 | File.Copy(assetBundlePath, uploadVrcPath); 436 | 437 | return uploadVrcPath; 438 | } 439 | } 440 | } 441 | -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/Editor/VRChatApiToolsGUI.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text.RegularExpressions; 5 | using UnityEditor; 6 | using UnityEngine; 7 | using VRC.Core; 8 | using VRC.SDKBase.Editor; 9 | 10 | namespace BocuD.VRChatApiTools 11 | { 12 | public static class VRChatApiToolsGUI 13 | { 14 | /// 15 | /// Draws inspector for VRChat avatar or world from blueprint ID 16 | /// 17 | /// The blueprint ID to be displayed 18 | /// When disabled, adds copy id and open in website buttons 19 | /// Action params that can be implemented to add extra GUI functionality to inspector 20 | public static void DrawBlueprintInspector(string blueprintID, bool small = true, params Action[] secondaryButtons) 21 | { 22 | if (string.IsNullOrWhiteSpace(blueprintID)) 23 | { 24 | EditorGUILayout.BeginHorizontal(EditorStyles.helpBox, GUILayout.Height(108)); 25 | EditorGUILayout.BeginHorizontal(); 26 | GUILayout.Box("Can't load image: empty blueprint", GUILayout.Width(128), GUILayout.Height(99)); 27 | 28 | EditorGUILayout.BeginVertical(); 29 | EditorGUILayout.LabelField("No selected blueprint", EditorStyles.boldLabel); 30 | 31 | GUILayout.FlexibleSpace(); 32 | 33 | EditorGUILayout.BeginHorizontal(); 34 | 35 | GUILayout.FlexibleSpace(); 36 | 37 | foreach (Action b in secondaryButtons) 38 | { 39 | b.Invoke(); 40 | } 41 | 42 | EditorGUILayout.EndHorizontal(); 43 | 44 | EditorGUILayout.EndVertical(); 45 | EditorGUILayout.EndHorizontal(); 46 | EditorGUILayout.EndHorizontal(); 47 | EditorGUILayout.Space(); 48 | return; 49 | } 50 | 51 | if (!HandleLogin(null, false)) return; 52 | 53 | //already cached 54 | if (VRChatApiTools.blueprintCache.TryGetValue(blueprintID, out ApiModel model)) 55 | { 56 | DrawBlueprintInspector(model, small, secondaryButtons); 57 | } 58 | else 59 | { 60 | //loading 61 | if (VRChatApiTools.currentlyFetching.Contains(blueprintID)) 62 | { 63 | EditorGUILayout.LabelField($"Loading blueprint information..."); 64 | } 65 | else 66 | { 67 | //its not invalidated yet, so try loading 68 | if (!VRChatApiTools.invalidBlueprints.Contains(blueprintID)) 69 | { 70 | if (Regex.IsMatch(blueprintID, VRChatApiTools.world_regex)) 71 | { 72 | VRChatApiTools.FetchApiWorld(blueprintID); 73 | } 74 | else if (Regex.IsMatch(blueprintID, VRChatApiTools.avatar_regex)) 75 | { 76 | VRChatApiTools.FetchApiAvatar(blueprintID); 77 | } 78 | else 79 | { 80 | VRChatApiTools.invalidBlueprints.Add(blueprintID); 81 | } 82 | } 83 | //invalid avatar 84 | else 85 | { 86 | EditorGUILayout.HelpBox("Couldn't load specified blueprint ID.", MessageType.Error); 87 | } 88 | } 89 | } 90 | } 91 | 92 | /// 93 | /// Draws inspector for VRChat blueprint 94 | /// 95 | /// Valid ApiWorld or ApiAvatar to be drawn 96 | /// When disabled, adds copy id and open in website buttons 97 | /// Action params that can be implemented to add extra GUI functionality to inspector 98 | public static void DrawBlueprintInspector(ApiModel model, bool small = true, params Action[] secondaryButtons) 99 | { 100 | string modelName = ""; 101 | string releaseStatus = ""; 102 | string authorName = ""; 103 | bool isWorld = false; 104 | 105 | switch (model) 106 | { 107 | case ApiWorld world: 108 | modelName = world.name; 109 | releaseStatus = world.releaseStatus; 110 | authorName = world.authorName; 111 | isWorld = true; 112 | break; 113 | 114 | case ApiAvatar avatar: 115 | modelName = avatar.name; 116 | releaseStatus = avatar.releaseStatus; 117 | authorName = avatar.authorName; 118 | break; 119 | 120 | default: 121 | if (model == null) 122 | EditorGUILayout.HelpBox("Null ApiModel passed to DrawBlueprintInspector", MessageType.Warning); 123 | else 124 | EditorGUILayout.HelpBox("Non world or avatar ApiModel passed to DrawBlueprintInspector", MessageType.Warning); 125 | 126 | return; 127 | } 128 | 129 | EditorGUILayout.BeginHorizontal(EditorStyles.helpBox, GUILayout.Height(108)); 130 | EditorGUILayout.BeginHorizontal(); 131 | 132 | if (VRChatApiTools.ImageCache.ContainsKey(model.id)) 133 | { 134 | GUILayout.Box(VRChatApiTools.ImageCache[model.id], GUILayout.Width(128), GUILayout.Height(99)); 135 | } 136 | else 137 | { 138 | GUILayout.Box("Loading image...", GUILayout.Width(128), GUILayout.Height(99)); 139 | } 140 | 141 | EditorGUILayout.BeginVertical(); 142 | EditorGUILayout.LabelField(modelName, EditorStyles.boldLabel); 143 | EditorGUILayout.LabelField(model.id); 144 | 145 | EditorGUILayout.LabelField("Release Status: " + releaseStatus); 146 | EditorGUILayout.LabelField("Author: " + authorName); 147 | 148 | GUILayout.FlexibleSpace(); 149 | 150 | EditorGUILayout.BeginHorizontal(); 151 | 152 | GUILayout.FlexibleSpace(); 153 | 154 | if (!small) 155 | { 156 | if (GUILayout.Button("Open in browser", GUILayout.Width(140))) 157 | { 158 | Application.OpenURL(isWorld 159 | ? $"https://vrchat.com/home/world/{model.id}" 160 | : $"https://vrchat.com/home/avatar/{model.id}"); 161 | } 162 | 163 | if (GUILayout.Button("Copy ID to clipboard", GUILayout.Width(160))) 164 | { 165 | GUIUtility.systemCopyBuffer = model.id; 166 | } 167 | } 168 | 169 | foreach (Action b in secondaryButtons) 170 | { 171 | b.Invoke(); 172 | } 173 | 174 | EditorGUILayout.EndHorizontal(); 175 | 176 | EditorGUILayout.EndVertical(); 177 | EditorGUILayout.EndHorizontal(); 178 | EditorGUILayout.EndHorizontal(); 179 | } 180 | 181 | public static bool HandleLogin(EditorWindow repaintOnSucces = null, bool displayLoginStatus = true) 182 | { 183 | if (!APIUser.IsLoggedIn) 184 | { 185 | if(VRChatApiTools.autoLoginFailed) 186 | { 187 | EditorGUILayout.HelpBox( 188 | "You need to be logged in to access VRChat data. Automatically logging in failed, probably because the SDK control panel isn't logged in. Try logging in in the SDK control panel.", 189 | MessageType.Error); 190 | 191 | if (GUILayout.Button("Open VRCSDK Control Panel")) 192 | { 193 | VRCSettings.ActiveWindowPanel = 0; 194 | EditorWindow.GetWindow(); 195 | } 196 | } 197 | else 198 | { 199 | if (repaintOnSucces != null) VRChatApiTools.TryAutoLogin(repaintOnSucces.Repaint); 200 | else VRChatApiTools.TryAutoLogin(); 201 | 202 | EditorGUILayout.BeginVertical(GUI.skin.box); 203 | 204 | EditorGUILayout.LabelField("Logging in..."); 205 | 206 | EditorGUILayout.EndVertical(); 207 | } 208 | } 209 | else if(displayLoginStatus) 210 | { 211 | EditorGUILayout.HelpBox($"Currently logged in as {APIUser.CurrentUser.displayName}", MessageType.Info); 212 | } 213 | 214 | return APIUser.IsLoggedIn; 215 | } 216 | } 217 | 218 | public class BlueprintPicker : EditorWindow 219 | { 220 | private ApiModel selection; 221 | private Action onComplete; 222 | private Type targetType; 223 | private string targetName; 224 | private bool confirm; 225 | private string lastUserID; 226 | 227 | public static void BlueprintSelector(Action onComplete, bool confirm = false, string initialSelection = null) where T : ApiModel 228 | { 229 | Type type = typeof(T); 230 | BlueprintPicker blueprintPicker; 231 | 232 | if (type == typeof(ApiWorld)) 233 | { 234 | blueprintPicker = GetWindow(); 235 | blueprintPicker.titleContent = new GUIContent("World Picker"); 236 | blueprintPicker.targetName = "World"; 237 | } 238 | else if (type == typeof(ApiAvatar)) 239 | { 240 | blueprintPicker = GetWindow(); 241 | blueprintPicker.titleContent = new GUIContent("Avatar Picker"); 242 | blueprintPicker.targetName = "Avatar"; 243 | } 244 | else 245 | { 246 | throw new ArgumentException("The specified ApiModel type is not supported"); 247 | } 248 | 249 | blueprintPicker.lastUserID = APIUser.CurrentUser?.id; 250 | 251 | blueprintPicker.onComplete = m => onComplete((T)m); 252 | blueprintPicker.targetType = type; 253 | blueprintPicker.minSize = new Vector2(640, 400); 254 | blueprintPicker.confirm = confirm; 255 | if (initialSelection != null && 256 | VRChatApiTools.blueprintCache.TryGetValue(initialSelection, out ApiModel model)) 257 | { 258 | blueprintPicker.selection = model; 259 | } 260 | blueprintPicker.UpdateListContent(); 261 | } 262 | 263 | private void OnGUI() 264 | { 265 | //close on domain reload as type is not serialized 266 | if (targetType == null) Close(); 267 | 268 | if (!VRChatApiToolsGUI.HandleLogin(this, false)) return; 269 | 270 | EditorGUILayout.BeginHorizontal(); 271 | EditorGUILayout.LabelField($"{APIUser.CurrentUser.displayName}'s Uploaded {targetName}s", EditorStyles.boldLabel); 272 | 273 | //invalidate cache if we switched users 274 | if (APIUser.CurrentUser?.id != lastUserID) 275 | { 276 | if (APIUser.CurrentUser != null) 277 | { 278 | VRChatApiTools.ClearCaches(); 279 | lastUserID = APIUser.CurrentUser.id; 280 | } 281 | } 282 | 283 | if (targetName == "Avatar") 284 | { 285 | if (VRChatApiTools.uploadedAvatars == null) 286 | { 287 | VRChatApiTools.uploadedAvatars = new List(); 288 | EditorCoroutine.Start(VRChatApiToolsEditor.FetchUploadedData()); 289 | } 290 | 291 | if (VRChatApiToolsEditor.fetchingAvatars != null) 292 | { 293 | GUILayout.FlexibleSpace(); 294 | EditorGUILayout.LabelField("Fetching data from VRChat Api"); 295 | } 296 | } 297 | else if (targetName == "World") 298 | { 299 | if (VRChatApiTools.uploadedWorlds == null) 300 | { 301 | VRChatApiTools.uploadedWorlds = new List(); 302 | EditorCoroutine.Start(VRChatApiToolsEditor.FetchUploadedData()); 303 | } 304 | 305 | if (VRChatApiToolsEditor.fetchingAvatars != null) 306 | { 307 | GUILayout.FlexibleSpace(); 308 | EditorGUILayout.LabelField("Fetching data from VRChat Api"); 309 | } 310 | } 311 | 312 | if (VRChatApiToolsEditor.fetchingWorlds == null || VRChatApiToolsEditor.fetchingAvatars == null) 313 | { 314 | GUILayout.FlexibleSpace(); 315 | 316 | if (GUILayout.Button("Enter ID", GUILayout.Width(120))) 317 | { 318 | if (targetName == "Avatar") 319 | { 320 | ManualBlueprintSelector.BlueprintSelector(OnSelected); 321 | } 322 | else if (targetName == "World") 323 | { 324 | ManualBlueprintSelector.BlueprintSelector(OnSelected); 325 | } 326 | } 327 | 328 | if (GUILayout.Button("Refresh", GUILayout.Width(120))) 329 | { 330 | VRChatApiTools.ClearCaches(); 331 | } 332 | } 333 | 334 | EditorGUILayout.EndHorizontal(); 335 | 336 | RenderListContents(); 337 | 338 | if (confirm) 339 | { 340 | EditorGUILayout.LabelField("Current selection:"); 341 | if (selection == null) 342 | { 343 | EditorGUILayout.BeginVertical("Helpbox"); 344 | EditorGUILayout.LabelField($"No {targetName} is currently selected"); 345 | 346 | EditorGUILayout.BeginHorizontal(); 347 | GUILayout.FlexibleSpace(); 348 | if (GUILayout.Button($"Finish selection")) 349 | { 350 | onComplete(selection); 351 | Close(); 352 | } 353 | EditorGUILayout.EndHorizontal(); 354 | EditorGUILayout.EndVertical(); 355 | } 356 | else 357 | { 358 | VRChatApiToolsGUI.DrawBlueprintInspector(selection, true, () => 359 | { 360 | if (GUILayout.Button($"Remove selection")) 361 | { 362 | selection = null; 363 | } 364 | 365 | if (GUILayout.Button($"Finish selection")) 366 | { 367 | onComplete(selection); 368 | Close(); 369 | } 370 | }); 371 | } 372 | } 373 | } 374 | 375 | private Vector2 listScroll; 376 | private string searchString = ""; 377 | private List displayedBlueprints = new List(); 378 | 379 | private void RenderListContents() 380 | { 381 | if (VRChatApiTools.uploadedWorlds == null || VRChatApiTools.uploadedAvatars == null) return; 382 | 383 | GUILayout.BeginHorizontal(GUI.skin.FindStyle("Toolbar")); 384 | EditorGUILayout.LabelField("Search ", GUILayout.Width(75)); 385 | 386 | searchString = GUILayout.TextField(searchString, GUI.skin.FindStyle("ToolbarSeachTextField")); 387 | UpdateListContent(); 388 | 389 | if (GUILayout.Button("", GUI.skin.FindStyle("ToolbarSeachCancelButton"))) 390 | { 391 | // Remove focus if cleared 392 | searchString = ""; 393 | GUI.FocusControl(null); 394 | } 395 | 396 | GUILayout.EndHorizontal(); 397 | 398 | EditorGUILayout.Space(); 399 | 400 | listScroll = EditorGUILayout.BeginScrollView(listScroll); 401 | 402 | if (displayedBlueprints.Count > 0) 403 | { 404 | foreach (ApiModel m in displayedBlueprints) 405 | { 406 | VRChatApiToolsGUI.DrawBlueprintInspector(m, false, () => 407 | { 408 | if (GUILayout.Button($"Select {targetName}", GUILayout.Width(140))) 409 | { 410 | OnSelected(m); 411 | } 412 | }); 413 | } 414 | } 415 | 416 | EditorGUILayout.EndScrollView(); 417 | } 418 | 419 | private void UpdateListContent() 420 | { 421 | if (targetType == typeof(ApiWorld) && VRChatApiTools.uploadedWorlds != null) 422 | { 423 | displayedBlueprints = VRChatApiTools.uploadedWorlds.OrderByDescending(x => x.updated_at) 424 | .Where(w => w.name.IndexOf(searchString, StringComparison.InvariantCultureIgnoreCase) >= 0 || w.id.Contains(searchString)).Cast().ToList(); 425 | } 426 | else if (targetType == typeof(ApiAvatar) && VRChatApiTools.uploadedAvatars != null) 427 | { 428 | displayedBlueprints = VRChatApiTools.uploadedAvatars.OrderByDescending(x => x.updated_at) 429 | .Where(a => a.name.IndexOf(searchString, StringComparison.InvariantCultureIgnoreCase) >= 0 || a.id.Contains(searchString)).Cast().ToList(); 430 | } 431 | else 432 | { 433 | displayedBlueprints = new List(); 434 | } 435 | } 436 | 437 | private void OnSelected(ApiModel model) 438 | { 439 | if (confirm) 440 | { 441 | selection = model; 442 | } 443 | else 444 | { 445 | onComplete(model); 446 | Close(); 447 | } 448 | } 449 | } 450 | 451 | public class ManualBlueprintSelector : EditorWindow 452 | { 453 | private Action OnSelected; 454 | private Type type; 455 | public static void BlueprintSelector(Action onSelected) where T : ApiModel 456 | { 457 | ManualBlueprintSelector blueprintPicker; 458 | Type t = typeof(T); 459 | 460 | if (t == typeof(ApiWorld)) 461 | { 462 | blueprintPicker = GetWindow(); 463 | blueprintPicker.titleContent = new GUIContent("Manual World Selector"); 464 | } 465 | else if (t == typeof(ApiAvatar)) 466 | { 467 | blueprintPicker = GetWindow(); 468 | blueprintPicker.titleContent = new GUIContent("Manual Avatar Selector"); 469 | } 470 | else 471 | { 472 | throw new ArgumentException("The specified blueprint type is not supported"); 473 | } 474 | 475 | blueprintPicker.type = t; 476 | blueprintPicker.OnSelected = model => onSelected((T)model); 477 | blueprintPicker.minSize = new Vector2(500, 150); 478 | blueprintPicker.maxSize = blueprintPicker.minSize; 479 | } 480 | 481 | private string blueprintID = ""; 482 | private bool ranFetch = false; 483 | 484 | private void OnGUI() 485 | { 486 | //close on domain reload as type is not serialized 487 | if(type == null) Close(); 488 | 489 | if (!VRChatApiToolsGUI.HandleLogin(this, false)) return; 490 | 491 | EditorGUILayout.BeginHorizontal(); 492 | EditorGUILayout.LabelField("Enter Blueprint ID", GUILayout.Width(100)); 493 | string oldID = blueprintID; 494 | blueprintID = EditorGUILayout.TextField(blueprintID); 495 | if (oldID != blueprintID) ranFetch = false; 496 | 497 | bool valid = Regex.IsMatch(blueprintID, VRChatApiTools.world_regex) && type == typeof(ApiWorld) || 498 | Regex.IsMatch(blueprintID, VRChatApiTools.avatar_regex) && type == typeof(ApiAvatar); 499 | 500 | using (new EditorGUI.DisabledScope(!valid)) 501 | { 502 | if (GUILayout.Button("Load", GUILayout.Width(60))) 503 | { 504 | if (!VRChatApiTools.blueprintCache.ContainsKey(blueprintID)) 505 | { 506 | if (blueprintID.StartsWith("wrld")) 507 | VRChatApiTools.FetchApiWorld(blueprintID); 508 | else 509 | VRChatApiTools.FetchApiAvatar(blueprintID); 510 | } 511 | 512 | ranFetch = true; 513 | } 514 | } 515 | 516 | EditorGUILayout.EndHorizontal(); 517 | 518 | if (ranFetch) 519 | { 520 | if (VRChatApiTools.blueprintCache.TryGetValue(blueprintID, out ApiModel model)) 521 | { 522 | VRChatApiToolsGUI.DrawBlueprintInspector(model, true, () => 523 | { 524 | if (GUILayout.Button("Select this blueprint")) 525 | { 526 | OnSelected(model); 527 | Close(); 528 | } 529 | }); 530 | } 531 | } 532 | } 533 | } 534 | } -------------------------------------------------------------------------------- /Packages/com.bocud.vrcapitools/Editor/ApiFileHelperAsync.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Security.Cryptography; 4 | using System.Threading.Tasks; 5 | using librsync.net; 6 | using UnityEngine; 7 | using VRC.Core; 8 | 9 | using static VRC.Core.ApiFileHelper; 10 | using Tools = VRC.Tools; 11 | 12 | namespace BocuD.VRChatApiTools 13 | { 14 | using static Constants; 15 | 16 | public class ApiFileHelperAsync 17 | { 18 | //status strings 19 | private const string prepareFileMessage = "Preparing file for upload..."; 20 | private const string prepareRemoteMessage = "Preparing server for upload..."; 21 | private const string postUploadMessage = "Processing upload..."; 22 | 23 | //global flow control 24 | private ApiFile apiFile; 25 | private static string errorStr; 26 | 27 | public delegate void UploadStatus(string status, string subStatus); 28 | public delegate void UploadProgress(long done, long total); 29 | 30 | public async Task UploadFile(string filepath, string existingFileId, string fileType, string friendlyName, UploadStatus onStatus, UploadProgress onProgress, Func cancelQuery) 31 | { 32 | //Init remote config 33 | await InitRemoteConfig(); 34 | 35 | bool deltaCompression = ConfigManager.RemoteConfig.GetBool("sdkEnableDeltaCompression"); 36 | 37 | //Check filename 38 | CheckFile(filepath); 39 | 40 | //Fetch Api File Record 41 | apiFile = await FetchRecord(filepath, existingFileId, friendlyName, onStatus, cancelQuery); 42 | if (apiFile == null) 43 | throw new Exception("Fetching or creating record failed"); 44 | 45 | Logger.Log($"Fetched record succesfully: {apiFile.name}"); 46 | 47 | if (apiFile.HasQueuedOperation(deltaCompression)) 48 | { 49 | //delete last version 50 | onStatus?.Invoke(prepareRemoteMessage, "Cleaning up previous version"); 51 | 52 | await ApiFileAsyncExtensions.DeleteLatestVersion(apiFile); 53 | } 54 | 55 | // check for server side errors from last upload 56 | await HandleFileErrorState(onStatus); 57 | 58 | // verify previous file op is complete 59 | if (apiFile.HasQueuedOperation(deltaCompression)) 60 | throw new Exception("Can't initiate upload: A previous upload is still being processed. Please try again later."); 61 | 62 | //gemerate file md5 63 | string fileMD5 = await GenerateMD5Base64(filepath, onStatus); 64 | 65 | // check if file has been changed 66 | bool isPreviousUploadRetry = false; 67 | try 68 | { 69 | isPreviousUploadRetry = await CheckForExistingVersion(onStatus, fileMD5); 70 | } 71 | catch (Exception e) 72 | { 73 | if (e.Message == "The file to upload matches the remote file already.") return apiFile.GetFileURL(); 74 | } 75 | 76 | //generate file signature 77 | string signatureFilename = await GenerateSignatureFile(filepath, onStatus); 78 | 79 | //generate signature md5 and file size 80 | string signatureMD5 = await GenerateMD5Base64(signatureFilename, onStatus); 81 | 82 | if (!Tools.GetFileSize(signatureFilename, out long sigFileSize, out errorStr)) 83 | { 84 | CleanupTempFiles(apiFile.id); 85 | throw new Exception($"Failed to generate file signature: Couldn't get filesize", new Exception(errorStr)); 86 | } 87 | 88 | // download previous version signature (if exists) 89 | string existingFileSignaturePath = ""; 90 | if (deltaCompression && apiFile.HasExistingVersion()) 91 | { 92 | existingFileSignaturePath = await GetExistingFileSignature(onStatus, onProgress, cancelQuery); 93 | } 94 | 95 | // create delta if needed 96 | string deltaFilepath = ""; 97 | 98 | if (deltaCompression && !string.IsNullOrEmpty(existingFileSignaturePath)) 99 | { 100 | deltaFilepath = await CreateFileDelta(filepath, onStatus, existingFileSignaturePath); 101 | } 102 | 103 | // upload smaller of delta and new file 104 | long deltaFileSize = 0; 105 | 106 | //get filesize 107 | if (!Tools.GetFileSize(filepath, out long fullFileSize, out errorStr) || 108 | !string.IsNullOrEmpty(deltaFilepath) && !Tools.GetFileSize(deltaFilepath, out deltaFileSize, out errorStr)) 109 | { 110 | CleanupTempFiles(apiFile.id); 111 | throw new Exception("Failed to create file delta for upload", new Exception(errorStr)); 112 | } 113 | 114 | bool uploadDeltaFile = deltaCompression && deltaFileSize > 0 && deltaFileSize < fullFileSize; 115 | Logger.Log(deltaCompression 116 | ? $"Delta size {deltaFileSize} ({(deltaFileSize / (float)fullFileSize)} %), full file size {fullFileSize}, uploading {(uploadDeltaFile ? " DELTA" : " FULL FILE")}" 117 | : $"Delta compression disabled, uploading FULL FILE, size {fullFileSize}"); 118 | 119 | //generate MD5 for delta file 120 | string deltaMD5 = ""; 121 | if (uploadDeltaFile) 122 | { 123 | deltaMD5 = await GenerateMD5Base64(deltaFilepath, onStatus); 124 | } 125 | 126 | bool versionAlreadyExists = false; 127 | 128 | // validate existing pending version info, if we're retrying an older upload 129 | if (isPreviousUploadRetry) 130 | { 131 | bool isValid; 132 | 133 | ApiFile.Version version = apiFile.GetVersion(apiFile.GetLatestVersionNumber()); 134 | if (version == null) 135 | { 136 | isValid = false; 137 | } 138 | else 139 | { 140 | //make sure fileSize for file and signature need to match their respective remote versions 141 | //make sure MD5 for file and signature match their respective remote versions 142 | if (uploadDeltaFile) 143 | { 144 | isValid = deltaFileSize == version.delta.sizeInBytes && 145 | string.Compare(deltaMD5, version.delta.md5, StringComparison.Ordinal) == 0 && 146 | sigFileSize == version.signature.sizeInBytes && 147 | string.Compare(signatureMD5, version.signature.md5, StringComparison.Ordinal) == 0; 148 | } 149 | else 150 | { 151 | isValid = fullFileSize == version.file.sizeInBytes && 152 | string.Compare(fileMD5, version.file.md5, StringComparison.Ordinal) == 0 && 153 | sigFileSize == version.signature.sizeInBytes && 154 | string.Compare(signatureMD5, version.signature.md5, StringComparison.Ordinal) == 0; 155 | } 156 | } 157 | 158 | if (isValid) 159 | { 160 | versionAlreadyExists = true; 161 | Logger.Log("Using existing version record"); 162 | } 163 | else 164 | { 165 | // delete previous invalid version 166 | onStatus?.Invoke(prepareRemoteMessage, "Cleaning up previous version"); 167 | await ApiFileAsyncExtensions.DeleteLatestVersion(apiFile); 168 | } 169 | } 170 | 171 | //create new file record 172 | if (!versionAlreadyExists) 173 | { 174 | if (uploadDeltaFile) 175 | { 176 | await CreateFileRecord(deltaMD5, deltaFileSize, signatureMD5, sigFileSize, onStatus, cancelQuery); 177 | } 178 | else 179 | { 180 | await CreateFileRecord(fileMD5, fullFileSize, signatureMD5, sigFileSize, onStatus, cancelQuery); 181 | } 182 | } 183 | 184 | // upload components 185 | string uploadFilepath = uploadDeltaFile ? deltaFilepath : filepath; 186 | string uploadMD5 = uploadDeltaFile ? deltaMD5 : fileMD5; 187 | long uploadFileSize = uploadDeltaFile ? deltaFileSize : fullFileSize; 188 | 189 | Logger.Log($"ApiFile name: {apiFile.name}"); 190 | 191 | switch (uploadDeltaFile) 192 | { 193 | case true when apiFile.GetLatestVersion().delta.status == ApiFile.Status.Waiting: 194 | case false when apiFile.GetLatestVersion().status == ApiFile.Status.Waiting: 195 | onStatus?.Invoke(prepareFileMessage, $"Uploading file{(uploadDeltaFile ? " delta..." : "...")}"); 196 | onStatus?.Invoke($"Uploading {fileType}...", $"Uploading file{(uploadDeltaFile ? " delta..." : "...")}"); 197 | 198 | await UploadFileComponentInternal(apiFile, uploadDeltaFile ? ApiFile.Version.FileDescriptor.Type.delta : ApiFile.Version.FileDescriptor.Type.file, 199 | uploadFilepath, uploadMD5, uploadFileSize, 200 | file => 201 | { 202 | Logger.Log($"Successfully uploaded file{(uploadDeltaFile ? " delta" : "")}"); 203 | apiFile = file; 204 | }, (done, total) => onProgress?.Invoke(done, total), cancelQuery); 205 | break; 206 | } 207 | 208 | // upload signature 209 | if (apiFile.GetLatestVersion().signature.status == ApiFile.Status.Waiting) 210 | { 211 | onStatus?.Invoke(prepareFileMessage, $"Uploading file signature..."); 212 | onStatus?.Invoke($"Uploading file signature...", "Uploading file signature..."); 213 | 214 | await UploadFileComponentInternal(apiFile, 215 | ApiFile.Version.FileDescriptor.Type.signature, signatureFilename, signatureMD5, sigFileSize, 216 | file => 217 | { 218 | Logger.Log("Successfully uploaded file signature."); 219 | apiFile = file; 220 | }, (done, total) => onProgress?.Invoke(done, total), cancelQuery); 221 | } 222 | 223 | ValidateRecord(fileType, onStatus, uploadDeltaFile); 224 | 225 | await CheckFileStatus(onStatus, cancelQuery, uploadDeltaFile); 226 | 227 | // cleanup and wait for it to finish 228 | await CleanupTempFilesInternal(apiFile.id); 229 | 230 | return apiFile.GetFileURL(); 231 | } 232 | 233 | private void ValidateRecord(string fileType, UploadStatus onStatus, bool uploadDeltaFile) 234 | { 235 | // Validate file records queued or complete 236 | onStatus?.Invoke($"Uploading {fileType}...", "Validating upload..."); 237 | 238 | bool isUploadComplete = uploadDeltaFile 239 | ? apiFile.GetFileDescriptor(apiFile.GetLatestVersionNumber(), ApiFile.Version.FileDescriptor.Type.delta) 240 | .status == ApiFile.Status.Complete 241 | : apiFile.GetFileDescriptor(apiFile.GetLatestVersionNumber(), ApiFile.Version.FileDescriptor.Type.file) 242 | .status == ApiFile.Status.Complete; 243 | 244 | isUploadComplete = isUploadComplete && apiFile.GetFileDescriptor(apiFile.GetLatestVersionNumber(), 245 | ApiFile.Version.FileDescriptor.Type.signature).status == ApiFile.Status.Complete; 246 | 247 | if (!isUploadComplete) 248 | { 249 | CleanupTempFiles(apiFile.id); 250 | throw new Exception("Upload validation failed"); 251 | } 252 | 253 | bool isServerOpQueuedOrComplete = uploadDeltaFile 254 | ? apiFile.GetFileDescriptor(apiFile.GetLatestVersionNumber(), ApiFile.Version.FileDescriptor.Type.file) 255 | .status != ApiFile.Status.Waiting 256 | : apiFile.GetFileDescriptor(apiFile.GetLatestVersionNumber(), ApiFile.Version.FileDescriptor.Type.delta) 257 | .status != ApiFile.Status.Waiting; 258 | 259 | if (!isServerOpQueuedOrComplete) 260 | { 261 | CleanupTempFiles(apiFile.id); 262 | throw new Exception("Failed to upload file", new Exception("Previous version is still in waiting status")); 263 | } 264 | } 265 | 266 | private async Task CheckFileStatus(UploadStatus onStatus, Func cancelQuery, 267 | bool uploadDeltaFile) 268 | { 269 | // wait for server processing to complete 270 | onStatus?.Invoke(postUploadMessage, "Checking file status"); 271 | 272 | float checkDelay = SERVER_PROCESSING_INITIAL_RETRY_TIME; 273 | float timeout = GetServerProcessingWaitTimeoutForDataSize(apiFile.GetLatestVersion().file.sizeInBytes); 274 | double initialStartTime = Time.realtimeSinceStartup; 275 | double startTime = initialStartTime; 276 | 277 | while (apiFile.HasQueuedOperation(uploadDeltaFile)) 278 | { 279 | // wait before polling again 280 | onStatus?.Invoke(postUploadMessage, $"Checking status in {Mathf.CeilToInt(checkDelay)} seconds"); 281 | 282 | while (Time.realtimeSinceStartup - startTime < checkDelay) 283 | { 284 | if (Time.realtimeSinceStartup - initialStartTime > timeout) 285 | { 286 | CleanupTempFiles(apiFile.id); 287 | throw new TimeoutException("Couldn't verify upload: Timed out waiting for upload processing to complete"); 288 | } 289 | 290 | await Task.Delay(33); 291 | } 292 | 293 | while (true) 294 | { 295 | // check status 296 | onStatus?.Invoke(postUploadMessage, "Checking status..."); 297 | 298 | bool wait = true; 299 | errorStr = ""; 300 | API.Fetch(apiFile.id, c => 301 | { 302 | apiFile = c.Model as ApiFile; 303 | wait = false; 304 | }, c => 305 | { 306 | CleanupTempFiles(apiFile.id); 307 | throw new Exception(c.Error); 308 | }); 309 | 310 | while (wait) 311 | { 312 | if (cancelQuery()) 313 | { 314 | CleanupTempFiles(apiFile.id); 315 | throw new OperationCanceledException(); 316 | } 317 | 318 | await Task.Delay(33); 319 | } 320 | 321 | break; 322 | } 323 | 324 | checkDelay = Mathf.Min(checkDelay * 2, SERVER_PROCESSING_MAX_RETRY_TIME); 325 | startTime = Time.realtimeSinceStartup; 326 | } 327 | } 328 | 329 | private async Task CreateFileDelta(string filename, UploadStatus onStatus, string existingFileSignaturePath) 330 | { 331 | onStatus?.Invoke(prepareFileMessage, "Creating file delta"); 332 | 333 | string deltaFilename = Tools.GetTempFileName(".delta", out errorStr, apiFile.id); 334 | 335 | if (string.IsNullOrEmpty(deltaFilename)) 336 | { 337 | CleanupTempFiles(apiFile.id); 338 | throw new Exception("Failed to create file delta for upload", new Exception($"Failed to create temp file: {errorStr}")); 339 | } 340 | 341 | await CreateFileDeltaInternal(filename, existingFileSignaturePath, deltaFilename); 342 | 343 | return deltaFilename; 344 | } 345 | 346 | private async Task CreateFileRecord(string fileMD5Base64, long fileSize, string sigMD5Base64, 347 | long sigFileSize, UploadStatus onStatus, Func cancelQuery) 348 | { 349 | while (true) 350 | { 351 | onStatus?.Invoke(prepareRemoteMessage, "Creating file version record..."); 352 | 353 | bool wait = true; 354 | 355 | apiFile.CreateNewVersion(ApiFile.Version.FileType.Full, fileMD5Base64, fileSize, 356 | sigMD5Base64, sigFileSize, c => 357 | { 358 | apiFile = c.Model as ApiFile; 359 | wait = false; 360 | }, c => 361 | { 362 | CleanupTempFiles(apiFile.id); 363 | throw new Exception("Creating file version record failed", new Exception(c.Error)); 364 | }); 365 | 366 | while (wait) 367 | { 368 | if (cancelQuery()) 369 | { 370 | CleanupTempFiles(apiFile.id); 371 | throw new OperationCanceledException(); 372 | } 373 | 374 | await Task.Delay(33); 375 | } 376 | 377 | // delay to let write get through servers 378 | await Task.Delay(postWriteDelay); 379 | 380 | break; 381 | } 382 | } 383 | 384 | private async Task GetExistingFileSignature(UploadStatus onStatus, UploadProgress onProgress, Func cancelQuery) 385 | { 386 | string existingFileSignaturePath = ""; 387 | onStatus?.Invoke(prepareRemoteMessage, "Downloading previous version signature"); 388 | 389 | bool wait = true; 390 | errorStr = ""; 391 | apiFile.DownloadSignature( 392 | data => 393 | { 394 | // save to temp file 395 | existingFileSignaturePath = Tools.GetTempFileName(".sig", out errorStr, apiFile.id); 396 | if (string.IsNullOrEmpty(existingFileSignaturePath)) 397 | { 398 | throw new Exception($"Couldn't create temp file: {errorStr}"); 399 | } 400 | 401 | File.WriteAllBytes(existingFileSignaturePath, data); 402 | 403 | wait = false; 404 | }, 405 | error => throw new Exception(error), 406 | (downloaded, length) => onProgress?.Invoke(downloaded, length) 407 | ); 408 | 409 | while (wait) 410 | { 411 | if (cancelQuery()) 412 | { 413 | throw new OperationCanceledException(); 414 | } 415 | 416 | await Task.Delay(33); 417 | } 418 | 419 | if (string.IsNullOrEmpty(errorStr)) return existingFileSignaturePath; 420 | 421 | CleanupTempFiles(apiFile.id); 422 | throw new Exception($"Failed to download previous file version signature: {errorStr}"); 423 | } 424 | 425 | private async Task GenerateMD5Base64(string filename, UploadStatus onStatus) 426 | { 427 | onStatus?.Invoke(prepareFileMessage, "Generating file hash"); 428 | 429 | string result = await Task.Run(() => 430 | { 431 | try 432 | { 433 | return Convert.ToBase64String(MD5.Create().ComputeHash(File.OpenRead(filename))); 434 | } 435 | catch (Exception) 436 | { 437 | CleanupTempFiles(apiFile.id); 438 | throw; 439 | } 440 | }); 441 | 442 | if (string.IsNullOrWhiteSpace(result)) throw new Exception("File MD5 generation failed"); 443 | 444 | return result; 445 | } 446 | 447 | private static void CheckFile(string filename) 448 | { 449 | if (string.IsNullOrEmpty(filename)) 450 | { 451 | throw new FileNotFoundException(); 452 | } 453 | 454 | if (!Path.HasExtension(filename)) 455 | { 456 | throw new Exception("Specified file doesn't have an extension"); 457 | } 458 | 459 | FileStream fileStream = null; 460 | try 461 | { 462 | fileStream = File.OpenRead(filename); 463 | fileStream.Close(); 464 | } 465 | catch (Exception) 466 | { 467 | fileStream?.Close(); 468 | throw; 469 | } 470 | } 471 | 472 | private async Task CheckForExistingVersion(UploadStatus onStatus, string fileMD5Base64) 473 | { 474 | onStatus?.Invoke(prepareFileMessage, "Checking for changes"); 475 | 476 | //if this is a new file, we can be sure that this is not a retry 477 | if (!apiFile.HasExistingOrPendingVersion()) return false; 478 | 479 | //if the file we are about to upload matches the existing file on the remote server 480 | if (string.CompareOrdinal(fileMD5Base64, apiFile.GetFileMD5(apiFile.GetLatestVersionNumber())) == 0) 481 | { 482 | Logger.Log("New file hash matches remote file hash, aborting upload"); 483 | //the MD5 of the new file matches MD5 of existing file 484 | 485 | //if the last upload was successful, we can terminate the upload process early 486 | if (!apiFile.IsWaitingForUpload()) 487 | { 488 | CleanupTempFiles(apiFile.id); 489 | throw new Exception("The file to upload matches the remote file already."); 490 | } 491 | 492 | //the previous upload wasn't succesful 493 | Logger.Log("Retrying previous upload"); 494 | return true; 495 | } 496 | 497 | //if the newest version doesn't have pending changes, return 498 | if (!apiFile.IsWaitingForUpload()) return false; 499 | 500 | //if it does, clean up 501 | Logger.Log("Latest version of remote file has pending changes, cleaning up..."); 502 | await ApiFileAsyncExtensions.DeleteLatestVersion(apiFile); 503 | 504 | return false; 505 | } 506 | 507 | private static async Task InitRemoteConfig() 508 | { 509 | //If remoteconfig is already initialised, return true 510 | if (ConfigManager.RemoteConfig.IsInitialized()) return; 511 | 512 | bool done = false; 513 | ConfigManager.RemoteConfig.Init(() => done = true, () => done = true); 514 | 515 | //god i hate these.. why can't vrc use proper async programming 516 | while (!done) 517 | await Task.Delay(33); 518 | 519 | if (ConfigManager.RemoteConfig.IsInitialized()) return; 520 | 521 | throw new Exception("Failed to fetch remote configuration"); 522 | } 523 | 524 | private async Task HandleFileErrorState(UploadStatus onStatus) 525 | { 526 | if (!apiFile.IsInErrorState()) return; 527 | 528 | Logger.LogWarning($"ApiFile: {apiFile.id}: server failed to process last uploaded, deleting failed version"); 529 | 530 | // delete previous failed version 531 | onStatus?.Invoke(prepareRemoteMessage, "Cleaning up previous version"); 532 | 533 | await ApiFileAsyncExtensions.DeleteLatestVersion(apiFile); 534 | } 535 | 536 | public static async Task FetchRecord(string filePath, string existingFileId, string friendlyName, 537 | UploadStatus onStatus, Func cancelQuery) 538 | { 539 | ApiFile apiFile; 540 | 541 | Logger.Log(string.IsNullOrEmpty(existingFileId) ? "Creating new file record for asset bundle..." : "Fetching file record for asset bundle..."); 542 | 543 | string extension = Path.GetExtension(filePath); 544 | string mimeType = GetMimeTypeFromExtension(extension); 545 | 546 | onStatus?.Invoke(prepareRemoteMessage, string.IsNullOrEmpty(existingFileId) ? "Creating file record..." : "Getting file record..."); 547 | 548 | if (string.IsNullOrEmpty(friendlyName)) 549 | friendlyName = filePath; 550 | 551 | //Get file record 552 | while (true) 553 | { 554 | apiFile = null; 555 | bool wait = true; 556 | errorStr = ""; 557 | 558 | if (string.IsNullOrEmpty(existingFileId)) 559 | ApiFile.Create(friendlyName, mimeType, extension, (c) => 560 | { 561 | apiFile = c.Model as ApiFile; 562 | wait = false; 563 | }, (c) => throw new Exception(c.Error)); 564 | else 565 | API.Fetch(existingFileId, (c) => 566 | { 567 | apiFile = c.Model as ApiFile; 568 | wait = false; 569 | }, (c) => throw new Exception(c.Error), true); 570 | 571 | while (wait) 572 | { 573 | if (apiFile != null && cancelQuery()) 574 | throw new OperationCanceledException(); 575 | 576 | await Task.Delay(33); 577 | } 578 | 579 | if (!string.IsNullOrEmpty(errorStr)) 580 | { 581 | if (errorStr.Contains("File not found")) 582 | { 583 | Logger.LogWarning($"Couldn't find file record: {existingFileId}, creating new file record"); 584 | 585 | existingFileId = ""; 586 | continue; 587 | } 588 | 589 | throw new Exception(string.IsNullOrEmpty(existingFileId) 590 | ? "Failed to create file record" 591 | : $"Failed to get file record: {errorStr}"); 592 | } 593 | 594 | break; 595 | } 596 | 597 | return apiFile; 598 | } 599 | 600 | private async Task GenerateSignatureFile(string filename, UploadStatus onStatus) 601 | { 602 | //generate signature file for new upload 603 | onStatus?.Invoke(prepareFileMessage, "Generating signature file"); 604 | 605 | string signatureFilename = Tools.GetTempFileName(".sig", out errorStr, apiFile.id); 606 | 607 | if (string.IsNullOrEmpty(signatureFilename)) 608 | { 609 | CleanupTempFiles(apiFile.id); 610 | throw new Exception($"Failed to generate file signature: Failed to create temp file", new Exception(errorStr)); 611 | } 612 | 613 | // create file signature 614 | try 615 | { 616 | Logger.Log($"Generating signature for {filename.GetFileName()}"); 617 | 618 | await Task.Delay(33); 619 | 620 | byte[] buf = new byte[512 * 1024]; 621 | 622 | Stream inStream = Librsync.ComputeSignature(File.OpenRead(filename)); 623 | FileStream outStream = File.Open(signatureFilename, FileMode.Create, FileAccess.Write); 624 | 625 | while (true) 626 | { 627 | IAsyncResult asyncRead = inStream.BeginRead(buf, 0, buf.Length, null, null); 628 | 629 | while (!asyncRead.IsCompleted) 630 | { 631 | 632 | } 633 | 634 | int read = inStream.EndRead(asyncRead); 635 | 636 | if (read <= 0) 637 | { 638 | break; 639 | } 640 | 641 | IAsyncResult asyncWrite = outStream.BeginWrite(buf, 0, read, null, null); 642 | 643 | while (!asyncWrite.IsCompleted) 644 | { 645 | 646 | } 647 | 648 | outStream.EndWrite(asyncWrite); 649 | } 650 | 651 | inStream.Close(); 652 | outStream.Close(); 653 | } 654 | catch (Exception) 655 | { 656 | CleanupTempFiles(apiFile.id); 657 | throw; 658 | } 659 | 660 | return signatureFilename; 661 | } 662 | 663 | private static async Task CreateFileDeltaInternal(string newFilename, string existingFileSignaturePath, string outputDeltaFilename) 664 | { 665 | Logger.Log($"CreateFileDelta: {newFilename} (delta) {existingFileSignaturePath} => {outputDeltaFilename}"); 666 | 667 | await Task.Delay(33); 668 | 669 | byte[] buf = new byte[64 * 1024]; 670 | Stream inStream = Librsync.ComputeDelta(File.OpenRead(existingFileSignaturePath), File.OpenRead(newFilename)); 671 | FileStream outStream = File.Open(outputDeltaFilename, FileMode.Create, FileAccess.Write); 672 | 673 | while (true) 674 | { 675 | IAsyncResult asyncRead = inStream.BeginRead(buf, 0, buf.Length, null, null); 676 | 677 | while (!asyncRead.IsCompleted) 678 | await Task.Delay(33); 679 | 680 | int read = inStream.EndRead(asyncRead); 681 | 682 | if (read <= 0) 683 | break; 684 | 685 | IAsyncResult asyncWrite = outStream.BeginWrite(buf, 0, read, null, null); 686 | 687 | while (!asyncWrite.IsCompleted) 688 | await Task.Delay(33); 689 | 690 | outStream.EndWrite(asyncWrite); 691 | } 692 | 693 | inStream.Close(); 694 | outStream.Close(); 695 | 696 | await Task.Delay(33); 697 | } 698 | 699 | private static void CleanupTempFiles(string subFolderName) 700 | { 701 | Task unused = CleanupTempFilesInternal(subFolderName); 702 | } 703 | 704 | private static async Task CleanupTempFilesInternal(string subFolderName) 705 | { 706 | if (string.IsNullOrEmpty(subFolderName)) return; 707 | 708 | string folder = Tools.GetTempFolderPath(subFolderName); 709 | 710 | while (Directory.Exists(folder)) 711 | { 712 | try 713 | { 714 | if (Directory.Exists(folder)) 715 | Directory.Delete(folder, true); 716 | } 717 | catch (Exception) 718 | { 719 | //ignored as removing temp files can be supressed 720 | } 721 | 722 | await Task.Delay(33); 723 | } 724 | } 725 | 726 | private static async Task UploadFileComponentInternal(ApiFile apiFile, 727 | ApiFile.Version.FileDescriptor.Type fileDescriptorType, 728 | string filepath, string md5Base64, long fileSize, Action onSuccess, Action onProgress, 729 | Func cancelQuery) 730 | { 731 | Logger.Log($"UploadFileComponent: {fileDescriptorType} ({apiFile.id}): {filepath.GetFileName()}"); 732 | 733 | ApiFile.Version.FileDescriptor fileDesc = 734 | apiFile.GetFileDescriptor(apiFile.GetLatestVersionNumber(), fileDescriptorType); 735 | 736 | //validate file descriptor 737 | if (fileDesc.status != ApiFile.Status.Waiting) 738 | { 739 | // nothing to do (might be a retry) 740 | Logger.Log("UploadFileComponent: (file record not in waiting status, done)"); 741 | onSuccess?.Invoke(apiFile); 742 | return; 743 | } 744 | 745 | if (fileSize != fileDesc.sizeInBytes) 746 | { 747 | throw new Exception("File size does not match version descriptor"); 748 | } 749 | 750 | if (string.CompareOrdinal(md5Base64, fileDesc.md5) != 0) 751 | { 752 | throw new Exception("File MD5 does not match version descriptor"); 753 | } 754 | 755 | // make sure file is right size 756 | if (!Tools.GetFileSize(filepath, out long tempSize, out string errorStr)) 757 | { 758 | throw new Exception($"Couldn't get file size : {errorStr}"); 759 | } 760 | 761 | if (tempSize != fileSize) 762 | { 763 | throw new Exception("File size does not match input size"); 764 | } 765 | 766 | switch (fileDesc.category) 767 | { 768 | case ApiFile.Category.Simple: 769 | await apiFile.UploadFileComponentDoSimpleUpload(fileDescriptorType, filepath, md5Base64, 770 | onProgress, cancelQuery); 771 | break; 772 | case ApiFile.Category.Multipart: 773 | await apiFile.UploadFileComponentDoMultipartUpload(fileDescriptorType, filepath, 774 | fileSize, onProgress, cancelQuery); 775 | break; 776 | default: 777 | throw new NotSupportedException($"Unsupported file category type: {fileDesc.category}"); 778 | } 779 | 780 | await apiFile.UploadFileComponentVerifyRecord(fileDescriptorType, fileDesc); 781 | } 782 | } 783 | } 784 | --------------------------------------------------------------------------------