From 53fd97264db5c7f5ce7ea47ae086401cb878e7ac Mon Sep 17 00:00:00 2001 From: Natsumi Date: Sat, 14 Oct 2023 21:17:32 +1300 Subject: [PATCH] Backup/restore VRC registry --- Dotnet/AppApi/RegistryPlayerPrefs.cs | 312 +++++++++++++++++++++++++++ VRCX.csproj | 3 +- html/src/app.js | 300 +++++++++++++++++++++++++- html/src/index.pug | 23 ++ html/src/localization/en/en.json | 15 ++ html/src/mixins/tabs/settings.pug | 1 + 6 files changed, 642 insertions(+), 12 deletions(-) create mode 100644 Dotnet/AppApi/RegistryPlayerPrefs.cs diff --git a/Dotnet/AppApi/RegistryPlayerPrefs.cs b/Dotnet/AppApi/RegistryPlayerPrefs.cs new file mode 100644 index 00000000..99e1761d --- /dev/null +++ b/Dotnet/AppApi/RegistryPlayerPrefs.cs @@ -0,0 +1,312 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Windows.Forms; +using Microsoft.Win32; + +namespace VRCX +{ + public partial class AppApi + { + [DllImport("advapi32.dll", CharSet = CharSet.Ansi, SetLastError = true)] + public static extern int RegSetValueExA( + IntPtr hKey, + string lpValueName, + int reserved, + RegistryValueKind dwType, + byte[] lpData, + int cbData + ); + + [DllImport("advapi32.dll", CharSet = CharSet.Ansi, SetLastError = true)] + public static extern int RegOpenKeyExA( + IntPtr hKey, + string lpSubKey, + int ulOptions, + int samDesired, + out IntPtr phkResult + ); + + [DllImport("advapi32.dll")] + public static extern int RegCloseKey(IntPtr hKey); + + public string AddHashToKeyName(string key) + { + // https://discussions.unity.com/t/playerprefs-changing-the-name-of-keys/30332/4 + // VRC_GROUP_ORDER_usr_032383a7-748c-4fb2-94e4-bcb928e5de6b_h2810492971 + uint hash = 5381; + foreach (var c in key) + hash = (hash * 33) ^ c; + return key + "_h" + hash; + } + + /// + /// Retrieves the value of the specified key from the VRChat group in the windows registry. + /// + /// The name of the key to retrieve. + /// The value of the specified key, or null if the key does not exist. + public object GetVRChatRegistryKey(string key) + { + var keyName = AddHashToKeyName(key); + using (var regKey = Registry.CurrentUser.OpenSubKey(@"SOFTWARE\VRChat\VRChat")) + { + var data = regKey?.GetValue(keyName); + if (data == null) + return null; + + var type = regKey.GetValueKind(keyName); + switch (type) + { + case RegistryValueKind.Binary: + return Encoding.ASCII.GetString((byte[])data); + + case RegistryValueKind.DWord: + if (data.GetType() != typeof(long)) + return data; + + long.TryParse(data.ToString(), out var longValue); + var bytes = BitConverter.GetBytes(longValue); + var doubleValue = BitConverter.ToDouble(bytes, 0); + return doubleValue; + } + } + + return null; + } + + /// + /// Sets the value of the specified key in the VRChat group in the windows registry. + /// + /// The name of the key to set. + /// The value to set for the specified key. + /// The RegistryValueKind type. + /// True if the key was successfully set, false otherwise. + public bool SetVRChatRegistryKey(string key, object value, int typeInt) + { + var type = (RegistryValueKind)typeInt; + var keyName = AddHashToKeyName(key); + using (var regKey = Registry.CurrentUser.OpenSubKey(@"SOFTWARE\VRChat\VRChat", true)) + { + if (regKey == null) + return false; + + object setValue = null; + switch (type) + { + case RegistryValueKind.Binary: + setValue = Encoding.ASCII.GetBytes(value.ToString()); + break; + + case RegistryValueKind.DWord: + setValue = value; + break; + } + + if (setValue == null) + return false; + + regKey.SetValue(keyName, setValue, type); + } + + return true; + } + + /// + /// Sets the value of the specified key in the VRChat group in the windows registry. + /// + /// The name of the key to set. + /// The value to set for the specified key. + public void SetVRChatRegistryKey(string key, byte[] value) + { + var keyName = AddHashToKeyName(key); + var hKey = (IntPtr)0x80000001; // HKEY_LOCAL_MACHINE + const int keyWrite = 0x20006; + const string keyFolder = @"SOFTWARE\VRChat\VRChat"; + var openKeyResult = RegOpenKeyExA(hKey, keyFolder, 0, keyWrite, out var folderPointer); + if (openKeyResult != 0) + throw new Exception("Error opening registry key. Error code: " + openKeyResult); + + var setKeyResult = RegSetValueExA(folderPointer, keyName, 0, RegistryValueKind.DWord, value, value.Length); + if (setKeyResult != 0) + throw new Exception("Error setting registry value. Error code: " + setKeyResult); + + RegCloseKey(hKey); + } + + public Dictionary> GetVRChatRegistry() + { + var output = new Dictionary>(); + using (var regKey = Registry.CurrentUser.OpenSubKey(@"SOFTWARE\VRChat\VRChat")) + { + if (regKey == null) + throw new Exception("Nothing to backup."); + + var keys = regKey.GetValueNames(); + foreach (var key in keys) + { + var data = regKey.GetValue(key); + var index = key.LastIndexOf("_h", StringComparison.Ordinal); + if (index <= 0) + continue; + + var keyName = key.Substring(0, index); + if (data == null) + continue; + + var type = regKey.GetValueKind(key); + switch (type) + { + case RegistryValueKind.Binary: + var binDict = new Dictionary + { + { "data", Encoding.ASCII.GetString((byte[])data) }, + { "type", type } + }; + output.Add(keyName, binDict); + break; + + case RegistryValueKind.DWord: + if (data.GetType() != typeof(long)) + { + var dwordDict = new Dictionary + { + { "data", data }, + { "type", type } + }; + output.Add(keyName, dwordDict); + break; + } + + Span spanLong = stackalloc long[] { (long)data }; + var doubleValue = MemoryMarshal.Cast(spanLong)[0]; + var floatDict = new Dictionary + { + { "data", doubleValue }, + { "type", 100 } // it's special + }; + output.Add(keyName, floatDict); + break; + + default: + Debug.WriteLine($"Unknown registry value kind: {type}"); + break; + } + } + } + return output; + } + + public void SetVRChatRegistry(string json) + { + CreateVRChatRegistryFolder(); + Span spanDouble = stackalloc double[1]; + var dict = System.Text.Json.JsonSerializer.Deserialize>>(json); + foreach (var item in dict) + { + var data = (JsonElement)item.Value["data"]; + if (!int.TryParse(item.Value["type"].ToString(), out var type)) + throw new Exception("Unknown type: " + item.Value["type"]); + + if (data.ValueKind == JsonValueKind.Number) + { + if (type == 100) + { + // fun handling of double to long to byte array + spanDouble[0] = data.Deserialize(); + var valueLong = MemoryMarshal.Cast(spanDouble)[0]; + const int dataLength = sizeof(long); + var dataBytes = new byte[dataLength]; + Buffer.BlockCopy(BitConverter.GetBytes(valueLong), 0, dataBytes, 0, dataLength); + SetVRChatRegistryKey(item.Key, dataBytes); + continue; + } + + if (int.TryParse(data.ToString(), out var intValue)) + { + SetVRChatRegistryKey(item.Key, intValue, type); + continue; + } + + throw new Exception("Unknown number type: " + item.Key); + } + + SetVRChatRegistryKey(item.Key, data, type); + } + } + + public bool HasVRChatRegistryFolder() + { + using (var regKey = Registry.CurrentUser.OpenSubKey(@"SOFTWARE\VRChat\VRChat")) + { + return regKey != null; + } + } + + public void CreateVRChatRegistryFolder() + { + if (HasVRChatRegistryFolder()) + return; + + using (var key = Registry.CurrentUser.CreateSubKey(@"SOFTWARE\VRChat\VRChat")) + { + if (key == null) + throw new Exception("Error creating registry key."); + } + } + + public void DeleteVRChatRegistryFolder() + { + using (var regKey = Registry.CurrentUser.OpenSubKey(@"SOFTWARE\VRChat\VRChat")) + { + if (regKey == null) + return; + + Registry.CurrentUser.DeleteSubKeyTree(@"SOFTWARE\VRChat\VRChat"); + } + } + + /// + /// Opens a file dialog to select a VRChat registry backup JSON file. + /// + public void OpenVrcRegJsonFileDialog() + { + if (dialogOpen) return; + dialogOpen = true; + + var thread = new Thread(() => + { + using (var openFileDialog = new OpenFileDialog()) + { + openFileDialog.DefaultExt = ".json"; + openFileDialog.Filter = "JSON Files (*.json)|*.json"; + openFileDialog.FilterIndex = 1; + openFileDialog.RestoreDirectory = true; + + if (openFileDialog.ShowDialog() != DialogResult.OK) + { + dialogOpen = false; + return; + } + + dialogOpen = false; + + var path = openFileDialog.FileName; + if (string.IsNullOrEmpty(path)) + return; + + // return file contents + var json = File.ReadAllText(path); + ExecuteAppFunction("restoreVrcRegistryFromFile", json); + } + }); + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + } + } +} \ No newline at end of file diff --git a/VRCX.csproj b/VRCX.csproj index bb766201..a980618d 100644 --- a/VRCX.csproj +++ b/VRCX.csproj @@ -1,4 +1,4 @@ - + @@ -86,6 +86,7 @@ + diff --git a/html/src/app.js b/html/src/app.js index 49531caf..10fdae2f 100644 --- a/html/src/app.js +++ b/html/src/app.js @@ -5267,6 +5267,7 @@ speechSynthesis.getVoices(); this.refreshCustomCss(); this.refreshCustomScript(); this.checkVRChatDebugLogging(); + this.checkAutoBackupRestoreVrcRegistry(); this.migrateStoredUsers(); this.$nextTick(function () { this.$el.style.display = ''; @@ -14553,6 +14554,16 @@ speechSynthesis.getVoices(); this.autoStateChange ); }; + $app.data.vrcRegistryAutoBackup = configRepository.getBool( + 'VRCX_vrcRegistryAutoBackup', + true + ); + $app.methods.saveVrcRegistryAutoBackup = function () { + configRepository.setBool( + 'VRCX_vrcRegistryAutoBackup', + this.vrcRegistryAutoBackup + ); + }; $app.data.orderFriendsGroup0 = configRepository.getBool( 'orderFriendGroup0', true @@ -23850,11 +23861,14 @@ speechSynthesis.getVoices(); var D = this.VRCXUpdateDialog; var url = this.branches[this.branch].urlReleases; this.checkingForVRCXUpdate = true; - var response = await webApiService.execute({ - url, - method: 'GET' - }); - this.checkingForVRCXUpdate = false; + try { + var response = await webApiService.execute({ + url, + method: 'GET' + }); + } finally { + this.checkingForVRCXUpdate = false; + } var json = JSON.parse(response.data); if (this.debugWebRequests) { console.log(json, response); @@ -23919,12 +23933,15 @@ speechSynthesis.getVoices(); } var url = this.branches[this.branch].urlLatest; this.checkingForVRCXUpdate = true; - var response = await webApiService.execute({ - url, - method: 'GET' - }); + try { + var response = await webApiService.execute({ + url, + method: 'GET' + }); + } finally { + this.checkingForVRCXUpdate = false; + } this.pendingVRCXUpdate = false; - this.checkingForVRCXUpdate = false; var json = JSON.parse(response.data); if (this.debugWebRequests) { console.log(json, response); @@ -27667,7 +27684,6 @@ speechSynthesis.getVoices(); return false; }); }; - // group members @@ -28441,6 +28457,268 @@ speechSynthesis.getVoices(); }); }; + // #endregion + // #region | Dialog: registry backup dialog + + $app.data.registryBackupDialog = { + visible: false + }; + + $app.data.registryBackupTable = { + data: [], + tableProps: { + stripe: true, + size: 'mini', + defaultSort: { + prop: 'date', + order: 'descending' + } + }, + layout: 'table' + }; + + $app.methods.showRegistryBackupDialog = function () { + this.$nextTick(() => + adjustDialogZ(this.$refs.registryBackupDialog.$el) + ); + var D = this.registryBackupDialog; + D.visible = true; + this.updateRegistryBackupDialog(); + }; + + $app.methods.updateRegistryBackupDialog = function () { + var D = this.registryBackupDialog; + this.registryBackupTable.data = []; + if (!D.visible) { + return; + } + var backupsJson = configRepository.getString( + 'VRCX_VRChatRegistryBackups' + ); + if (!backupsJson) { + backupsJson = JSON.stringify([]); + } + this.registryBackupTable.data = JSON.parse(backupsJson); + }; + + $app.methods.promptVrcRegistryBackupName = async function () { + var name = await this.$prompt( + 'Enter a name for the backup', + 'Backup Name', + { + confirmButtonText: 'Confirm', + cancelButtonText: 'Cancel', + inputPattern: /\S+/, + inputErrorMessage: 'Name is required', + inputValue: 'Backup' + } + ); + if (name.action === 'confirm') { + this.backupVrcRegistry(name.value); + } + }; + + $app.methods.backupVrcRegistry = async function (name) { + var regJson = await AppApi.GetVRChatRegistry(); + var newBackup = { + name, + date: new Date().toJSON(), + data: regJson + }; + var backupsJson = configRepository.getString( + 'VRCX_VRChatRegistryBackups' + ); + if (!backupsJson) { + backupsJson = JSON.stringify([]); + } + var backups = JSON.parse(backupsJson); + backups.push(newBackup); + configRepository.setString( + 'VRCX_VRChatRegistryBackups', + JSON.stringify(backups) + ); + this.updateRegistryBackupDialog(); + }; + + $app.methods.deleteVrcRegistryBackup = function (row) { + var backups = this.registryBackupTable.data; + removeFromArray(backups, row); + configRepository.setString( + 'VRCX_VRChatRegistryBackups', + JSON.stringify(backups) + ); + this.updateRegistryBackupDialog(); + }; + + $app.methods.restoreVrcRegistryBackup = function (row) { + this.$confirm('Continue? Restore Backup', 'Confirm', { + confirmButtonText: 'Confirm', + cancelButtonText: 'Cancel', + type: 'warning', + callback: (action) => { + if (action !== 'confirm') { + return; + } + var data = JSON.stringify(row.data); + AppApi.SetVRChatRegistry(data) + .then(() => { + this.$message({ + message: 'VRC registry settings restored', + type: 'success' + }); + }) + .catch((e) => { + console.error(e); + this.$message({ + message: `Failed to restore VRC registry settings, check console for full error: ${e}`, + type: 'error' + }); + }); + } + }); + }; + + $app.methods.saveVrcRegistryBackupToFile = function (row) { + this.downloadAndSaveJson(row.name, row.data); + }; + + $app.methods.restoreVrcRegistryFromFile = function (json) { + try { + var data = JSON.parse(json); + if (!data || typeof data !== 'object') { + throw new Error('Invalid JSON'); + } + // quick check to make sure it's a valid registry backup + for (var key in data) { + var value = data[key]; + if ( + typeof value !== 'object' || + typeof value.type !== 'number' || + typeof value.data === 'undefined' + ) { + throw new Error('Invalid JSON'); + } + } + AppApi.SetVRChatRegistry(json) + .then(() => { + this.$message({ + message: 'VRC registry settings restored', + type: 'success' + }); + }) + .catch((e) => { + console.error(e); + this.$message({ + message: `Failed to restore VRC registry settings, check console for full error: ${e}`, + type: 'error' + }); + }); + } catch { + this.$message({ + message: 'Invalid JSON', + type: 'error' + }); + } + }; + + $app.methods.deleteVrcRegistry = function () { + this.$confirm('Continue? Delete VRC Registry Settings', 'Confirm', { + confirmButtonText: 'Confirm', + cancelButtonText: 'Cancel', + type: 'warning', + callback: (action) => { + if (action !== 'confirm') { + return; + } + AppApi.DeleteVRChatRegistryFolder().then(() => { + this.$message({ + message: 'VRC registry settings deleted', + type: 'success' + }); + }); + } + }); + }; + + $app.methods.clearVrcRegistryDialog = function () { + this.registryBackupTable.data = []; + }; + + $app.methods.checkAutoBackupRestoreVrcRegistry = async function () { + if (!this.vrcRegistryAutoBackup) { + return; + } + + // check for auto restore + var hasVRChatRegistryFolder = await AppApi.HasVRChatRegistryFolder(); + if (!hasVRChatRegistryFolder) { + var lastBackupDate = configRepository.getString( + 'VRCX_VRChatRegistryLastBackupDate' + ); + var lastRestoreCheck = configRepository.getString( + 'VRCX_VRChatRegistryLastRestoreCheck' + ); + if ( + lastRestoreCheck && + lastBackupDate && + lastRestoreCheck === lastBackupDate + ) { + // only ask to restore once + return; + } + // popup message about auto restore + this.$alert( + $t('dialog.registry_backup.restore_prompt'), + $t('dialog.registry_backup.header') + ); + this.showRegistryBackupDialog(); + AppApi.FocusWindow(); + configRepository.setString( + 'VRCX_VRChatRegistryLastRestoreCheck', + lastBackupDate + ); + } else { + this.autoBackupVrcRegistry(); + } + }; + + $app.methods.autoBackupVrcRegistry = function () { + var date = new Date(); + var lastBackupDate = configRepository.getString( + 'VRCX_VRChatRegistryLastBackupDate' + ); + if (lastBackupDate) { + var lastBackup = new Date(lastBackupDate); + var diff = date.getTime() - lastBackup.getTime(); + var diffDays = Math.floor(diff / (1000 * 60 * 60 * 24)); + if (diffDays < 7) { + return; + } + } + var backupsJson = configRepository.getString( + 'VRCX_VRChatRegistryBackups' + ); + if (!backupsJson) { + backupsJson = JSON.stringify([]); + } + var backups = JSON.parse(backupsJson); + backups.forEach((backup) => { + if (backup.name === 'Auto Backup') { + // remove old auto backup + removeFromArray(backups, backup); + } + }); + configRepository.setString( + 'VRCX_VRChatRegistryBackups', + JSON.stringify(backups) + ); + this.backupVrcRegistry('Auto Backup'); + configRepository.setString( + 'VRCX_VRChatRegistryLastBackupDate', + date.toJSON() + ); + }; + // #endregion $app = new Vue($app); diff --git a/html/src/index.pug b/html/src/index.pug index 9545a9ee..d94794d5 100644 --- a/html/src/index.pug +++ b/html/src/index.pug @@ -2761,6 +2761,29 @@ html el-button(type="default" size="mini" icon="el-icon-download" circle @click="downloadAndSaveImage(fullscreenImageDialog.imageUrl)" style="margin-left:5px") img(v-lazy="fullscreenImageDialog.imageUrl" style="width:100%;height:100vh;object-fit:contain") + el-dialog.x-dialog(:before-close="beforeDialogClose" @closed="clearVrcRegistryDialog" @mousedown.native="dialogMouseDown" @mouseup.native="dialogMouseUp" ref="registryBackupDialog" :visible.sync="registryBackupDialog.visible" :title="$t('dialog.registry_backup.header')" width="600px") + div(v-if="registryBackupDialog.visible" style="margin-top:10px") + div.options-container + div.options-container-item + span.name {{ $t('dialog.registry_backup.auto_backup') }} + el-switch(v-model="vrcRegistryAutoBackup" @change="saveVrcRegistryAutoBackup") + el-button(@click="promptVrcRegistryBackupName" size="small") {{ $t('dialog.registry_backup.backup') }} + el-button(@click="AppApi.OpenVrcRegJsonFileDialog()" size="small") {{ $t('dialog.registry_backup.restore_from_file') }} + el-button(@click="deleteVrcRegistry" size="small") {{ $t('dialog.registry_backup.reset') }} + data-tables(v-bind="registryBackupTable" style="margin-top:10px") + el-table-column(:label="$t('dialog.registry_backup.name')" prop="name") + el-table-column(:label="$t('dialog.registry_backup.date')" prop="date") + template(v-once #default="scope") + span {{ scope.row.date | formatDate('long') }} + el-table-column(:label="$t('dialog.registry_backup.action')" width="90" align="right") + template(v-once #default="scope") + el-tooltip(placement="top" :content="$t('dialog.registry_backup.restore')" :disabled="hideTooltips") + el-button(type="text" icon="el-icon-upload2" size="mini" @click="restoreVrcRegistryBackup(scope.row)") + el-tooltip(placement="top" :content="$t('dialog.registry_backup.save_to_file')" :disabled="hideTooltips") + el-button(type="text" icon="el-icon-download" size="mini" @click="saveVrcRegistryBackupToFile(scope.row)") + el-tooltip(placement="top" :content="$t('dialog.registry_backup.delete')" :disabled="hideTooltips") + el-button(type="text" icon="el-icon-delete" size="mini" @click="deleteVrcRegistryBackup(scope.row)") + //- dialog: open source software notice el-dialog.x-dialog(:before-close="beforeDialogClose" @mousedown.native="dialogMouseDown" @mouseup.native="dialogMouseUp" :visible.sync="ossDialog" :title="$t('dialog.open_source.header')" width="650px") div(v-if="ossDialog" style="height:350px;overflow:hidden scroll;word-break:break-all") diff --git a/html/src/localization/en/en.json b/html/src/localization/en/en.json index 78c7be8c..cbc9ddc2 100644 --- a/html/src/localization/en/en.json +++ b/html/src/localization/en/en.json @@ -379,6 +379,7 @@ "header": "Advanced", "launch_options": "Launch Options", "screenshot_metadata": "Screenshot Metadata", + "vrc_registry_backup": "VRC Registry Backup", "common_folders": "Common Folders", "pending_offline": { "header": "Pending Offline", @@ -1230,6 +1231,20 @@ "copy_image": "Copy Image", "open_folder": "Open Folder", "upload": "Upload" + }, + "registry_backup": { + "header": "VRC Registry Settings Backup", + "backup": "Backup", + "restore": "Restore", + "save_to_file": "Save to file", + "delete": "Delete", + "restore_from_file": "Restore from file", + "reset": "Reset", + "name": "Name", + "date": "Date", + "action": "Action", + "auto_backup": "Weekly Auto Backup", + "restore_prompt": "VRCX has noticed auto backup of VRC registry settings is enabled but this computer dosn't have any, if you'd like to restore from backup you can do so from here." } }, "prompt": { diff --git a/html/src/mixins/tabs/settings.pug b/html/src/mixins/tabs/settings.pug index 20718a5c..e4fbcaca 100644 --- a/html/src/mixins/tabs/settings.pug +++ b/html/src/mixins/tabs/settings.pug @@ -379,6 +379,7 @@ mixin settingsTab() el-button(size="small" icon="el-icon-s-operation" @click="showVRChatConfig()") VRChat config.json el-button(size="small" icon="el-icon-s-operation" @click="showLaunchOptions()") {{ $t('view.settings.advanced.advanced.launch_options') }} el-button(size="small" icon="el-icon-picture" @click="showScreenshotMetadataDialog()") {{ $t('view.settings.advanced.advanced.screenshot_metadata') }} + el-button(size="small" icon="el-icon-goods" @click="showRegistryBackupDialog()") {{ $t('view.settings.advanced.advanced.vrc_registry_backup') }} //- Advanced | Common Folders div.options-container span.header {{ $t('view.settings.advanced.advanced.common_folders') }}