Electron support for Linux (#1074)

* init

* SQLite changes

* Move html folder, edit build scripts

* AppApi interface

* Build flags

* AppApi inheritance

* Finishing touches

* Merge upstream changes

* Test CI

* Fix class inits

* Rename AppApi

* Merge upstream changes

* Fix SQLiteLegacy on Linux, Add Linux interop, build tools

* Linux specific localisation strings

* Make it run

* Bring back most of Linux functionality

* Clean up

* Fix TTS voices

* Fix UI var

* Changes

* Electron minimise to tray

* Remove separate toggle for WlxOverlay

* Fixes

* Touchups

* Move csproj

* Window zoom, Desktop Notifications, VR check on Linux

* Fix desktop notifications, VR check spam

* Fix building on Linux

* Clean up

* Fix WebApi headers

* Rewrite VRCX updater

* Clean up

* Linux updater

* Add Linux to build action

* init

* SQLite changes

* Move html folder, edit build scripts

* AppApi interface

* Build flags

* AppApi inheritance

* Finishing touches

* Merge upstream changes

* Test CI

* Fix class inits

* Rename AppApi

* Merge upstream changes

* Fix SQLiteLegacy on Linux, Add Linux interop, build tools

* Linux specific localisation strings

* Make it run

* Bring back most of Linux functionality

* Clean up

* Fix TTS voices

* Changes

* Electron minimise to tray

* Remove separate toggle for WlxOverlay

* Fixes

* Touchups

* Move csproj

* Window zoom, Desktop Notifications, VR check on Linux

* Fix desktop notifications, VR check spam

* Fix building on Linux

* Clean up

* Fix WebApi headers

* Rewrite VRCX updater

* Clean up

* Linux updater

* Add Linux to build action

* Test updater

* Rebase and handle merge conflicts

* Fix Linux updater

* Fix Linux app restart

* Fix friend order

* Handle AppImageInstaller, show an install message on Linux

* Updates to the AppImage installer

* Fix Linux updater, fix set version, check for .NET, copy wine prefix

* Handle random errors

* Rotate tall prints

* try fix Linux restart bug

* Final

---------

Co-authored-by: rs189 <35667100+rs189@users.noreply.github.com>
This commit is contained in:
Natsumi
2025-01-11 13:09:44 +13:00
committed by GitHub
parent a39eb9d5ed
commit 938fff63d0
223 changed files with 15841 additions and 9562 deletions

View File

@@ -0,0 +1,247 @@
// Copyright(c) 2019-2022 pypy, Natsumi and individual contributors.
// All rights reserved.
//
// This work is licensed under the terms of the MIT license.
// For a copy, see <https://opensource.org/licenses/MIT>.
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics;
using System.Drawing;
using System.Globalization;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using CefSharp;
using librsync.net;
using Microsoft.Toolkit.Uwp.Notifications;
using Microsoft.Win32;
using NLog;
namespace VRCX
{
public partial class AppApiCef : AppApi
{
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
/// <summary>
/// Shows the developer tools for the main browser window.
/// </summary>
public override void ShowDevTools()
{
MainForm.Instance.Browser.ShowDevTools();
}
/// <summary>
/// Deletes all cookies from the global cef cookie manager.
/// </summary>
public override void DeleteAllCookies()
{
Cef.GetGlobalCookieManager().DeleteCookies();
}
public override void SetVR(bool active, bool hmdOverlay, bool wristOverlay, bool menuButton, int overlayHand)
{
Program.VRCXVRInstance.SetActive(active, hmdOverlay, wristOverlay, menuButton, overlayHand);
}
public override void RefreshVR()
{
Program.VRCXVRInstance.Restart();
}
public override void RestartVR()
{
Program.VRCXVRInstance.Restart();
}
public override void SetZoom(double zoomLevel)
{
MainForm.Instance.Browser.SetZoomLevel(zoomLevel);
}
public override async Task<double> GetZoom()
{
return await MainForm.Instance.Browser.GetZoomLevelAsync();
}
public override void DesktopNotification(string BoldText, string Text = "", string Image = "")
{
try
{
ToastContentBuilder builder = new ToastContentBuilder();
if (Uri.TryCreate(Image, UriKind.Absolute, out Uri uri))
builder.AddAppLogoOverride(uri);
if (!string.IsNullOrEmpty(BoldText))
builder.AddText(BoldText);
if (!string.IsNullOrEmpty(Text))
builder.AddText(Text);
builder.Show();
}
catch (System.AccessViolationException ex)
{
logger.Warn(ex, "Unable to send desktop notification");
}
catch (Exception ex)
{
logger.Error(ex, "Unknown error when sending desktop notification");
}
}
public override void RestartApplication(bool isUpgrade)
{
var args = new List<string>();
if (isUpgrade)
args.Add(StartupArgs.VrcxLaunchArguments.IsUpgradePrefix);
if (StartupArgs.LaunchArguments.IsDebug)
args.Add(StartupArgs.VrcxLaunchArguments.IsDebugPrefix);
if (!string.IsNullOrWhiteSpace(StartupArgs.LaunchArguments.ConfigDirectory))
args.Add($"{StartupArgs.VrcxLaunchArguments.ConfigDirectoryPrefix}={StartupArgs.LaunchArguments.ConfigDirectory}");
if (!string.IsNullOrWhiteSpace(StartupArgs.LaunchArguments.ProxyUrl))
args.Add($"{StartupArgs.VrcxLaunchArguments.ProxyUrlPrefix}={StartupArgs.LaunchArguments.ProxyUrl}");
var vrcxProcess = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = Path.Combine(Program.BaseDirectory, "VRCX.exe"),
Arguments = string.Join(' ', args),
UseShellExecute = true,
WorkingDirectory = Program.BaseDirectory
}
};
vrcxProcess.Start();
Environment.Exit(0);
}
public override bool CheckForUpdateExe()
{
return File.Exists(Path.Combine(Program.AppDataDirectory, "update.exe"));
}
public override void ExecuteAppFunction(string function, string json)
{
if (MainForm.Instance?.Browser != null && !MainForm.Instance.Browser.IsLoading && MainForm.Instance.Browser.CanExecuteJavascriptInMainFrame)
MainForm.Instance.Browser.ExecuteScriptAsync($"$app.{function}", json);
}
public override void ExecuteVrFeedFunction(string function, string json)
{
Program.VRCXVRInstance.ExecuteVrFeedFunction(function, json);
}
public override void ExecuteVrOverlayFunction(string function, string json)
{
Program.VRCXVRInstance.ExecuteVrOverlayFunction(function, json);
}
public override string GetLaunchCommand()
{
var command = StartupArgs.LaunchArguments.LaunchCommand;
StartupArgs.LaunchArguments.LaunchCommand = string.Empty;
return command;
}
public override void FocusWindow()
{
MainForm.Instance.Invoke(new Action(() => { MainForm.Instance.Focus_Window(); }));
}
public override void ChangeTheme(int value)
{
WinformThemer.SetGlobalTheme(value);
}
public override void DoFunny()
{
WinformThemer.DoFunny();
}
public override string GetClipboard()
{
var clipboard = string.Empty;
var thread = new Thread(() => clipboard = Clipboard.GetText());
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
thread.Join();
return clipboard;
}
public override void SetStartup(bool enabled)
{
try
{
using var key = Registry.CurrentUser.OpenSubKey("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run", true);
if (key == null)
{
logger.Warn("Failed to open startup registry key");
return;
}
if (enabled)
{
var path = Application.ExecutablePath;
key.SetValue("VRCX", $"\"{path}\" --startup");
}
else
{
key.DeleteValue("VRCX", false);
}
}
catch (Exception e)
{
logger.Warn(e, "Failed to set startup");
}
}
public override void CopyImageToClipboard(string path)
{
if (!File.Exists(path) ||
(!path.EndsWith(".png") &&
!path.EndsWith(".jpg") &&
!path.EndsWith(".jpeg") &&
!path.EndsWith(".gif") &&
!path.EndsWith(".bmp") &&
!path.EndsWith(".webp")))
return;
MainForm.Instance.BeginInvoke(new MethodInvoker(() =>
{
var image = Image.FromFile(path);
// Clipboard.SetImage(image);
var data = new DataObject();
data.SetData(DataFormats.Bitmap, image);
data.SetFileDropList(new StringCollection { path });
Clipboard.SetDataObject(data, true);
}));
}
public override void FlashWindow()
{
MainForm.Instance.BeginInvoke(new MethodInvoker(() => { WinformThemer.Flash(MainForm.Instance); }));
}
public override void SetUserAgent()
{
using var client = MainForm.Instance.Browser.GetDevToolsClient();
_ = client.Network.SetUserAgentOverrideAsync(Program.Version);
}
public override bool IsRunningUnderWine()
{
return Wine.GetIfWine();
}
}
}

View File

@@ -0,0 +1,89 @@
using System;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using CefSharp;
namespace VRCX
{
public class AppApiVr
{
public static readonly AppApiVr Instance;
static AppApiVr()
{
Instance = new AppApiVr();
}
public void Init()
{
// Create Instance before Cef tries to bind it
}
public void VrInit()
{
if (MainForm.Instance?.Browser != null && !MainForm.Instance.Browser.IsLoading && MainForm.Instance.Browser.CanExecuteJavascriptInMainFrame)
MainForm.Instance.Browser.ExecuteScriptAsync("$app.vrInit", "");
}
public void ToggleSystemMonitor(bool enabled)
{
SystemMonitor.Instance.Start(enabled);
}
/// <summary>
/// Returns the current CPU usage as a percentage.
/// </summary>
/// <returns>The current CPU usage as a percentage.</returns>
public float CpuUsage()
{
return SystemMonitor.Instance.CpuUsage;
}
/// <summary>
/// Returns an array of arrays containing information about the connected VR devices.
/// Each sub-array contains the type of device and its current state
/// </summary>
/// <returns>An array of arrays containing information about the connected VR devices.</returns>
public string[][] GetVRDevices()
{
return Program.VRCXVRInstance.GetDevices();
}
/// <summary>
/// Returns the number of milliseconds that the system has been running.
/// </summary>
/// <returns>The number of milliseconds that the system has been running.</returns>
public double GetUptime()
{
return SystemMonitor.Instance.UpTime;
}
/// <summary>
/// Returns the current language of the operating system.
/// </summary>
/// <returns>The current language of the operating system.</returns>
public string CurrentCulture()
{
return CultureInfo.CurrentCulture.ToString();
}
/// <summary>
/// Returns the file path of the custom user js file, if it exists.
/// </summary>
/// <returns>The file path of the custom user js file, or an empty string if it doesn't exist.</returns>
public string CustomVrScriptPath()
{
var output = string.Empty;
var filePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VRCX\\customvr.js");
if (File.Exists(filePath))
output = filePath;
return output;
}
public bool IsRunningUnderWine()
{
return Wine.GetIfWine();
}
}
}

View File

@@ -0,0 +1,342 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Microsoft.Win32;
using System.Threading;
using System.Windows.Forms;
using System.Threading.Tasks;
namespace VRCX
{
public partial class AppApiCef
{
public override string GetVRChatAppDataLocation()
{
return Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + @"Low\VRChat\VRChat";
}
public override string GetVRChatCacheLocation()
{
var json = ReadConfigFile();
if (!string.IsNullOrEmpty(json))
{
var obj = JsonConvert.DeserializeObject<JObject>(json);
if (obj["cache_directory"] != null)
{
var cacheDir = (string)obj["cache_directory"];
if (!string.IsNullOrEmpty(cacheDir) && Directory.Exists(cacheDir))
{
return cacheDir;
}
}
}
return Path.Combine(GetVRChatAppDataLocation(), "Cache-WindowsPlayer");
}
public override string GetVRChatPhotosLocation()
{
var json = ReadConfigFile();
if (!string.IsNullOrEmpty(json))
{
var obj = JsonConvert.DeserializeObject<JObject>(json);
if (obj["picture_output_folder"] != null)
{
var photosDir = (string)obj["picture_output_folder"];
if (!string.IsNullOrEmpty(photosDir) && Directory.Exists(photosDir))
{
return photosDir;
}
}
}
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyPictures), "VRChat");
}
public override string GetUGCPhotoLocation(string path = "")
{
if (string.IsNullOrEmpty(path))
{
return GetVRChatPhotosLocation();
}
try
{
if (!Directory.Exists(path))
{
Directory.CreateDirectory(path);
}
return path;
}
catch (Exception e)
{
logger.Error(e);
return GetVRChatPhotosLocation();
}
}
private string GetSteamUserdataPathFromRegistry()
{
string steamUserdataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), @"Steam\userdata");
try
{
using (RegistryKey key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\WOW6432Node\Valve\Steam"))
{
if (key != null)
{
object o = key.GetValue("InstallPath");
if (o != null)
{
steamUserdataPath = Path.Combine(o.ToString(), @"userdata");
}
}
}
}
catch (Exception e)
{
logger.Error($"Failed to get Steam userdata path from registry: {e}");
}
return steamUserdataPath;
}
public override string GetVRChatScreenshotsLocation()
{
// program files steam userdata screenshots
var steamUserdataPath = GetSteamUserdataPathFromRegistry();
var screenshotPath = string.Empty;
var latestWriteTime = DateTime.MinValue;
if (!Directory.Exists(steamUserdataPath))
return screenshotPath;
var steamUserDirs = Directory.GetDirectories(steamUserdataPath);
foreach (var steamUserDir in steamUserDirs)
{
var screenshotDir = Path.Combine(steamUserDir, @"760\remote\438100\screenshots");
if (!Directory.Exists(screenshotDir))
continue;
var lastWriteTime = File.GetLastWriteTime(screenshotDir);
if (lastWriteTime <= latestWriteTime)
continue;
latestWriteTime = lastWriteTime;
screenshotPath = screenshotDir;
}
return screenshotPath;
}
public override bool OpenVrcxAppDataFolder()
{
var path = Program.AppDataDirectory;
if (!Directory.Exists(path))
return false;
OpenFolderAndSelectItem(path, true);
return true;
}
public override bool OpenVrcAppDataFolder()
{
var path = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + @"Low\VRChat\VRChat";
if (!Directory.Exists(path))
return false;
OpenFolderAndSelectItem(path, true);
return true;
}
public override bool OpenVrcPhotosFolder()
{
var path = GetVRChatPhotosLocation();
if (!Directory.Exists(path))
return false;
OpenFolderAndSelectItem(path, true);
return true;
}
public override bool OpenUGCPhotosFolder(string ugcPath = "")
{
var path = GetUGCPhotoLocation(ugcPath);
if (!Directory.Exists(path))
return false;
OpenFolderAndSelectItem(path, true);
return true;
}
public override bool OpenVrcScreenshotsFolder()
{
var path = GetVRChatScreenshotsLocation();
if (!Directory.Exists(path))
return false;
OpenFolderAndSelectItem(path, true);
return true;
}
public override bool OpenCrashVrcCrashDumps()
{
var path = Path.Combine(Path.GetTempPath(), "VRChat", "VRChat", "Crashes");
if (!Directory.Exists(path))
return false;
OpenFolderAndSelectItem(path, true);
return true;
}
public override void OpenShortcutFolder()
{
var path = AutoAppLaunchManager.Instance.AppShortcutDirectory;
if (!Directory.Exists(path))
return;
OpenFolderAndSelectItem(path, true);
}
public override void OpenFolderAndSelectItem(string path, bool isFolder = false)
{
path = Path.GetFullPath(path);
// I don't think it's quite meant for it, but SHOpenFolderAndSelectItems can open folders by passing the folder path as the item to select, as a child to itself, somehow. So we'll check to see if 'path' is a folder as well.
if (!File.Exists(path) && !Directory.Exists(path))
return;
var folderPath = isFolder ? path : Path.GetDirectoryName(path);
IntPtr pidlFolder;
IntPtr pidlFile;
uint psfgaoOut;
// Convert our managed strings to PIDLs. PIDLs are essentially pointers to the actual file system objects, separate from the "display name", which is the human-readable path to the file/folder. We're parsing the display name into a PIDL here.
// The windows shell uses PIDLs to identify objects in winapi calls, so we'll need to use them to open the folder and select the file. Cool stuff!
var result = WinApi.SHParseDisplayName(folderPath, IntPtr.Zero, out pidlFolder, 0, out psfgaoOut);
if (result != 0)
{
OpenFolderAndSelectItemFallback(path);
return;
}
result = WinApi.SHParseDisplayName(path, IntPtr.Zero, out pidlFile, 0, out psfgaoOut);
if (result != 0)
{
// Free the PIDL we allocated earlier if we failed to parse the display name of the file.
Marshal.FreeCoTaskMem(pidlFolder);
OpenFolderAndSelectItemFallback(path);
return;
}
IntPtr[] files = { pidlFile };
try
{
// Open the containing folder and select our file. SHOpenFolderAndSelectItems will respect existing explorer instances, open a new one if none exist, will properly handle paths > 120 chars, and work with third-party filesystem viewers that hook into winapi calls.
// It can select multiple items, but we only need to select one.
WinApi.SHOpenFolderAndSelectItems(pidlFolder, (uint)files.Length, files, 0);
}
catch
{
OpenFolderAndSelectItemFallback(path);
}
finally
{
// Free the PIDLs we allocated earlier
Marshal.FreeCoTaskMem(pidlFolder);
Marshal.FreeCoTaskMem(pidlFile);
}
}
private void OpenFolderAndSelectItemFallback(string path)
{
if (!File.Exists(path) && !Directory.Exists(path))
return;
if (Directory.Exists(path))
{
Process.Start("explorer.exe", path);
}
else
{
// open folder with file highlighted
Process.Start("explorer.exe", $"/select,\"{path}\"");
}
}
public override async Task<string> OpenFolderSelectorDialog(string defaultPath = "")
{
var tcs = new TaskCompletionSource<string>();
var staThread = new Thread(() =>
{
try
{
using var openFolderDialog = new FolderBrowserDialog();
openFolderDialog.InitialDirectory = Directory.Exists(defaultPath) ? defaultPath : GetVRChatPhotosLocation();
var dialogResult = openFolderDialog.ShowDialog(MainForm.nativeWindow);
if (dialogResult == DialogResult.OK)
{
tcs.SetResult(openFolderDialog.SelectedPath);
}
else
{
tcs.SetResult(defaultPath);
}
}
catch (Exception ex)
{
tcs.SetException(ex);
}
});
staThread.SetApartmentState(ApartmentState.STA);
staThread.Start();
return await tcs.Task;
}
public override async Task<string> OpenFileSelectorDialog(string defaultPath = "", string defaultExt = "", string defaultFilter = "All files (*.*)|*.*")
{
var tcs = new TaskCompletionSource<string>();
var staThread = new Thread(() =>
{
try
{
using (var openFileDialog = new System.Windows.Forms.OpenFileDialog())
{
if (Directory.Exists(defaultPath))
{
openFileDialog.InitialDirectory = defaultPath;
}
openFileDialog.DefaultExt = defaultExt;
openFileDialog.Filter = defaultFilter;
var dialogResult = openFileDialog.ShowDialog(MainForm.nativeWindow);
if (dialogResult == DialogResult.OK && !string.IsNullOrEmpty(openFileDialog.FileName))
{
tcs.SetResult(openFileDialog.FileName);
}
else
{
tcs.SetResult("");
}
}
}
catch (Exception ex)
{
tcs.SetException(ex);
}
});
staThread.SetApartmentState(ApartmentState.STA);
staThread.Start();
return await tcs.Task;
}
}
}

View File

@@ -0,0 +1,140 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using CefSharp;
using Microsoft.Win32;
namespace VRCX
{
public partial class AppApiCef
{
public override void OnProcessStateChanged(MonitoredProcess monitoredProcess)
{
if (!monitoredProcess.HasName("VRChat") && !monitoredProcess.HasName("vrserver"))
return;
CheckGameRunning();
}
public override void CheckGameRunning()
{
var isGameRunning = false;
var isSteamVRRunning = false;
var isHmdAfk = false;
if (ProcessMonitor.Instance.IsProcessRunning("VRChat"))
isGameRunning = true;
if (Wine.GetIfWine())
{
var wineTmpPath = Path.Combine(Program.AppDataDirectory, "wine.tmp");
if (File.Exists(wineTmpPath))
{
var wineTmp = File.ReadAllText(wineTmpPath);
if (wineTmp.Contains("isGameRunning=true"))
isGameRunning = true;
}
}
if (ProcessMonitor.Instance.IsProcessRunning("vrserver"))
isSteamVRRunning = true;
if (Program.VRCXVRInstance != null)
isHmdAfk = Program.VRCXVRInstance.IsHmdAfk;
// TODO: fix this throwing an exception for being called before the browser is ready. somehow it gets past the checks
if (MainForm.Instance?.Browser != null && !MainForm.Instance.Browser.IsLoading && MainForm.Instance.Browser.CanExecuteJavascriptInMainFrame)
MainForm.Instance.Browser.ExecuteScriptAsync("$app.updateIsGameRunning", isGameRunning, isSteamVRRunning, isHmdAfk);
}
public override bool IsGameRunning()
{
// unused
return ProcessMonitor.Instance.IsProcessRunning("VRChat");
}
public override bool IsSteamVRRunning()
{
// unused
return ProcessMonitor.Instance.IsProcessRunning("vrserver");
}
public override int QuitGame()
{
var processes = Process.GetProcessesByName("vrchat");
if (processes.Length == 1)
processes[0].Kill();
return processes.Length;
}
public override bool StartGame(string arguments)
{
// try stream first
try
{
using var key = Registry.ClassesRoot.OpenSubKey(@"steam\shell\open\command");
// "C:\Program Files (x86)\Steam\steam.exe" -- "%1"
var match = Regex.Match(key.GetValue(string.Empty) as string, "^\"(.+?)\\\\steam.exe\"");
if (match.Success)
{
var path = match.Groups[1].Value;
// var _arguments = Uri.EscapeDataString(arguments);
Process.Start(new ProcessStartInfo
{
WorkingDirectory = path,
FileName = $"{path}\\steam.exe",
UseShellExecute = false,
Arguments = $"-applaunch 438100 {arguments}"
})
?.Close();
return true;
}
}
catch
{
logger.Warn("Failed to start VRChat from Steam");
}
// fallback
try
{
using var key = Registry.ClassesRoot.OpenSubKey(@"VRChat\shell\open\command");
// "C:\Program Files (x86)\Steam\steamapps\common\VRChat\launch.exe" "%1" %*
var match = Regex.Match(key.GetValue(string.Empty) as string, "(?!\")(.+?\\\\VRChat.*)(!?\\\\launch.exe\")");
if (match.Success)
{
var path = match.Groups[1].Value;
return StartGameFromPath(path, arguments);
}
}
catch
{
logger.Warn("Failed to start VRChat from registry");
}
return false;
}
public override bool StartGameFromPath(string path, string arguments)
{
if (!path.EndsWith(".exe"))
path = Path.Combine(path, "launch.exe");
if (!path.EndsWith("launch.exe") || !File.Exists(path))
return false;
Process.Start(new ProcessStartInfo
{
WorkingDirectory = Path.GetDirectoryName(path),
FileName = path,
UseShellExecute = false,
Arguments = arguments
})?.Close();
return true;
}
}
}

View File

@@ -0,0 +1,279 @@
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 AppApiCef
{
[DllImport("advapi32.dll", CharSet = CharSet.Ansi, SetLastError = true)]
private static extern uint RegSetValueEx(
UIntPtr hKey,
[MarshalAs(UnmanagedType.LPStr)] string lpValueName,
int Reserved,
RegistryValueKind dwType,
byte[] lpData,
int cbData);
[DllImport("advapi32.dll", CharSet = CharSet.Ansi, SetLastError = true)]
private static extern int RegOpenKeyEx(
UIntPtr hKey,
string subKey,
int ulOptions,
int samDesired,
out UIntPtr hkResult);
[DllImport("advapi32.dll")]
private static extern int RegCloseKey(UIntPtr hKey);
private 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;
}
/// <summary>
/// Retrieves the value of the specified key from the VRChat group in the windows registry.
/// </summary>
/// <param name="key">The name of the key to retrieve.</param>
/// <returns>The value of the specified key, or null if the key does not exist.</returns>
public override 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;
}
public override string GetVRChatRegistryKeyString(string key)
{
// for electron
return GetVRChatRegistryKey(key)?.ToString();
}
/// <summary>
/// Sets the value of the specified key in the VRChat group in the windows registry.
/// </summary>
/// <param name="key">The name of the key to set.</param>
/// <param name="value">The value to set for the specified key.</param>
/// <param name="typeInt">The RegistryValueKind type.</param>
/// <returns>True if the key was successfully set, false otherwise.</returns>
public override 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;
}
/// <summary>
/// Sets the value of the specified key in the VRChat group in the windows registry.
/// </summary>
/// <param name="key">The name of the key to set.</param>
/// <param name="value">The value to set for the specified key.</param>
public override void SetVRChatRegistryKey(string key, byte[] value)
{
var keyName = AddHashToKeyName(key);
var hKey = (UIntPtr)0x80000001; // HKEY_LOCAL_MACHINE
const int keyWrite = 0x20006;
const string keyFolder = @"SOFTWARE\VRChat\VRChat";
var openKeyResult = RegOpenKeyEx(hKey, keyFolder, 0, keyWrite, out var folderPointer);
if (openKeyResult != 0)
throw new Exception("Error opening registry key. Error code: " + openKeyResult);
var setKeyResult = RegSetValueEx(folderPointer, keyName, 0, RegistryValueKind.DWord, value, value.Length);
if (setKeyResult != 0)
throw new Exception("Error setting registry value. Error code: " + setKeyResult);
RegCloseKey(hKey);
}
public override Dictionary<string, Dictionary<string, object>> GetVRChatRegistry()
{
var output = new Dictionary<string, Dictionary<string, object>>();
using var regKey = Registry.CurrentUser.OpenSubKey(@"SOFTWARE\VRChat\VRChat");
if (regKey == null)
throw new Exception("Nothing to backup.");
var keys = regKey.GetValueNames();
Span<long> spanLong = stackalloc long[1];
Span<double> doubleSpan = MemoryMarshal.Cast<long, double>(spanLong);
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<string, object>
{
{ "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<string, object>
{
{ "data", data },
{ "type", type }
};
output.Add(keyName, dwordDict);
break;
}
spanLong[0] = (long)data;
var doubleValue = doubleSpan[0];
var floatDict = new Dictionary<string, object>
{
{ "data", doubleValue },
{ "type", 100 } // it's special
};
output.Add(keyName, floatDict);
break;
default:
Debug.WriteLine($"Unknown registry value kind: {type}");
break;
}
}
return output;
}
public override void SetVRChatRegistry(string json)
{
CreateVRChatRegistryFolder();
Span<double> spanDouble = stackalloc double[1];
var dict = JsonSerializer.Deserialize<Dictionary<string, Dictionary<string, object>>>(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<double>();
var valueLong = MemoryMarshal.Cast<double, long>(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 override bool HasVRChatRegistryFolder()
{
using var regKey = Registry.CurrentUser.OpenSubKey(@"SOFTWARE\VRChat\VRChat");
return regKey != null;
}
private 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 override void DeleteVRChatRegistryFolder()
{
using var regKey = Registry.CurrentUser.OpenSubKey(@"SOFTWARE\VRChat\VRChat");
if (regKey == null)
return;
Registry.CurrentUser.DeleteSubKeyTree(@"SOFTWARE\VRChat\VRChat");
}
public override string ReadVrcRegJsonFile(string filepath)
{
if (!File.Exists(filepath))
return string.Empty;
var json = File.ReadAllText(filepath);
return json;
}
}
}

View File

@@ -0,0 +1,40 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Windows.Forms;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
namespace VRCX
{
public partial class AppApiCef
{
/// <summary>
/// Adds metadata to a PNG screenshot file and optionally renames the file to include the specified world ID.
/// </summary>
/// <param name="path">The path to the PNG screenshot file.</param>
/// <param name="metadataString">The metadata to add to the screenshot file.</param>
/// <param name="worldId">The ID of the world to associate with the screenshot.</param>
/// <param name="changeFilename">Whether to rename the screenshot file to include the world ID.</param>
public override string AddScreenshotMetadata(string path, string metadataString, string worldId, bool changeFilename = false)
{
var fileName = Path.GetFileNameWithoutExtension(path);
if (!File.Exists(path) || !path.EndsWith(".png") || !fileName.StartsWith("VRChat_"))
return string.Empty;
if (changeFilename)
{
var newFileName = $"{fileName}_{worldId}";
var newPath = Path.Combine(Path.GetDirectoryName(path), newFileName + Path.GetExtension(path));
File.Move(path, newPath);
path = newPath;
}
ScreenshotHelper.WritePNGDescription(path, metadataString);
return path;
}
}
}