Add systems for local world persistence (#553)

* chore: Change vscode workspace settings to work with omnisharp

* refactor(.NET): Use connection string builder to init sqlite database

* docs(.NET): Add method documentation to most things that matter

* docs(.NET): Add more docs I forgot to commit apparently

* feat: Add PoC world database structure ^& http listener

* fix: Send a response if VRCX isn't initialized rather than hanging

* feat: Initialize world db schema on startup

* feat: Allow worlds to store data in db through logfile

* use existing current location for worldDB

* Add tooltips

* chore: Make it so vscode can format C# files without prettier

* refactor: Add sqlite-net to (eventually) replace sqlite impl

* refactor: Make use of sqlite-net for world database

* docs: Add todo for fixing some random exception

* refactor: Remove now-unused SQLiteWorld

* refactor: Fix DB init query and change table structure again

* refactor: Add WorldDataRequest, add attributes for camelcase json keys

* Support current user location from API in addition to gameLog

* Change current location check for worldDB

* feat: Take store requests in JSON, identify worlds by GUID on store.

* refactor: Remove unused worldId param from connection key generator

* docs: Add more documentation to the methods for the world database

* fix: Hey wait that's not a primary key

* feat: Add a 10MB data cap for worlds shared across all of their rows.

* fix: Don't calculate size of world date twice when inserting

* refactor: Discard the guid variable since we only check for validity

* docs: Add docs/comments for new data cap functionality

* feat: Implement /getbulk API endpoint

* fix: Correct WorldDB init query typo

* fix: Update data entries properly instead of using 'OR REPLACE'

* refactor: Move endpoint processing to separate methods

* refactor: Add another check for error 503, remove old code

* feat: Add debug capability to /vrcx/getbulk

* fix: Correct the usage of getUser in actuallyGetCurrentLocation

* feat: Add store errors, implement external reading, stop 404ing

* docs: Add docs for new world db funcs

* refactor: Change world db server listen port to 22500

* fix: Use getUser correctly, dumb dumb

* fix: This error set shouldn't be here

* feat: Future-proof api endpoints. Add /status endpoint

---------

Co-authored-by: Natsumi <cmcooper123@hotmail.com>
This commit is contained in:
Teacup
2023-05-31 18:34:50 -04:00
committed by GitHub
parent b06cba0669
commit 0101f3474f
17 changed files with 6193 additions and 147 deletions

View File

@@ -3,5 +3,10 @@
"i18n-ally.keystyle": "nested",
"i18n-ally.sourceLanguage": "en",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
"editor.formatOnSave": true,
"omnisharp.enableRoslynAnalyzers": true,
"omnisharp.useModernNet": false,
"[csharp]": {
"editor.defaultFormatter": "ms-dotnettools.csharp"
}
}

218
AppApi.cs
View File

@@ -51,6 +51,11 @@ namespace VRCX
CheckGameRunning();
}
/// <summary>
/// Computes the MD5 hash of the file represented by the specified base64-encoded string.
/// </summary>
/// <param name="Blob">The base64-encoded string representing the file.</param>
/// <returns>The MD5 hash of the file as a base64-encoded string.</returns>
public string MD5File(string Blob)
{
var fileData = Convert.FromBase64CharArray(Blob.ToCharArray(), 0, Blob.Length);
@@ -58,6 +63,11 @@ namespace VRCX
return Convert.ToBase64String(md5);
}
/// <summary>
/// Computes the signature of the file represented by the specified base64-encoded string using the librsync library.
/// </summary>
/// <param name="Blob">The base64-encoded string representing the file.</param>
/// <returns>The signature of the file as a base64-encoded string.</returns>
public string SignFile(string Blob)
{
var fileData = Convert.FromBase64CharArray(Blob.ToCharArray(), 0, Blob.Length);
@@ -68,12 +78,21 @@ namespace VRCX
return Convert.ToBase64String(sigBytes);
}
/// <summary>
/// Returns the length of the file represented by the specified base64-encoded string.
/// </summary>
/// <param name="Blob">The base64-encoded string representing the file.</param>
/// <returns>The length of the file in bytes.</returns>
public string FileLength(string Blob)
{
var fileData = Convert.FromBase64CharArray(Blob.ToCharArray(), 0, Blob.Length);
return fileData.Length.ToString();
}
/// <summary>
/// Reads the VRChat config file and returns its contents as a string.
/// </summary>
/// <returns>The contents of the VRChat config file as a string, or an empty string if the file does not exist.</returns>
public string ReadConfigFile()
{
var logPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + @"Low\VRChat\VRChat\";
@@ -87,6 +106,10 @@ namespace VRCX
return json;
}
/// <summary>
/// Writes the specified JSON string to the VRChat config file.
/// </summary>
/// <param name="json">The JSON string to write to the config file.</param>
public void WriteConfigFile(string json)
{
var logPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + @"Low\VRChat\VRChat\";
@@ -94,6 +117,11 @@ namespace VRCX
File.WriteAllText(configFile, json);
}
/// <summary>
/// Gets the VRChat application data location by reading the config file and checking the cache directory.
/// If the cache directory is not found in the config file, it returns the default cache path.
/// </summary>
/// <returns>The VRChat application data location.</returns>
public string GetVRChatAppDataLocation()
{
var json = ReadConfigFile();
@@ -114,21 +142,34 @@ namespace VRCX
return cachePath;
}
/// <summary>
/// Gets the VRChat cache location by combining the VRChat application data location with the cache directory name.
/// </summary>
/// <returns>The VRChat cache location.</returns>
public string GetVRChatCacheLocation()
{
return Path.Combine(GetVRChatAppDataLocation(), "Cache-WindowsPlayer");
}
/// <summary>
/// Shows the developer tools for the main browser window.
/// </summary>
public void ShowDevTools()
{
MainForm.Instance.Browser.ShowDevTools();
}
/// <summary>
/// Deletes all cookies from the global cef cookie manager.
/// </summary>
public void DeleteAllCookies()
{
Cef.GetGlobalCookieManager().DeleteCookies();
}
/// <summary>
/// Checks if the VRChat game and SteamVR are currently running and updates the browser's JavaScript function $app.updateIsGameRunning with the results.
/// </summary>
public void CheckGameRunning()
{
var isGameRunning = false;
@@ -144,10 +185,16 @@ namespace VRCX
isSteamVRRunning = true;
}
// 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.ExecuteScriptAsync("$app.updateIsGameRunning", isGameRunning, isSteamVRRunning);
}
/// <summary>
/// Kills the VRChat process if it is currently running.
/// </summary>
/// <returns>The number of processes that were killed (0 or 1).</returns>
public int QuitGame()
{
var processes = Process.GetProcessesByName("vrchat");
@@ -157,6 +204,10 @@ namespace VRCX
return processes.Length;
}
/// <summary>
/// Starts the VRChat game process with the specified command-line arguments.
/// </summary>
/// <param name="arguments">The command-line arguments to pass to the VRChat game.</param>
public void StartGame(string arguments)
{
// try stream first
@@ -204,6 +255,12 @@ namespace VRCX
}
}
/// <summary>
/// Starts the VRChat game process with the specified command-line arguments from the given path.
/// </summary>
/// <param name="path">The path to the VRChat game executable.</param>
/// <param name="arguments">The command-line arguments to pass to the VRChat game.</param>
/// <returns>True if the game was started successfully, false otherwise.</returns>
public bool StartGameFromPath(string path, string arguments)
{
if (!path.EndsWith(".exe"))
@@ -222,6 +279,11 @@ namespace VRCX
return true;
}
/// <summary>
/// Opens the specified URL in the default browser.
/// </summary>
/// <param name="url">The URL to open.</param>
public void OpenLink(string url)
{
if (url.StartsWith("http://") ||
@@ -264,21 +326,43 @@ namespace VRCX
VRCXVR.Instance.Restart();
}
/// <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 VRCXVR.Instance.GetDevices();
}
/// <summary>
/// Returns the current CPU usage as a percentage.
/// </summary>
/// <returns>The current CPU usage as a percentage.</returns>
public float CpuUsage()
{
return CpuMonitor.Instance.CpuUsage;
}
/// <summary>
/// Retrieves an image from the VRChat API and caches it for future use. The function will return the cached image if it already exists.
/// </summary>
/// <param name="url">The URL of the image to retrieve.</param>
/// <param name="fileId">The ID of the file associated with the image.</param>
/// <param name="version">The version of the file associated with the image.</param>
/// <returns>A string representing the file location of the cached image.</returns>
public string GetImage(string url, string fileId, string version)
{
return ImageCache.GetImage(url, fileId, version);
}
/// <summary>
/// Displays a desktop notification with the specified bold text, optional text, and optional image.
/// </summary>
/// <param name="BoldText">The bold text to display in the notification.</param>
/// <param name="Text">The optional text to display in the notification.</param>
/// <param name="Image">The optional image to display in the notification.</param>
public void DesktopNotification(string BoldText, string Text = "", string Image = "")
{
var toastXml = ToastNotificationManager.GetTemplateContent(ToastTemplateType.ToastImageAndText02);
@@ -297,6 +381,13 @@ namespace VRCX
ToastNotificationManager.CreateToastNotifier("VRCX").Show(toast);
}
/// <summary>
/// Displays an XSOverlay notification with the specified title, content, and optional image.
/// </summary>
/// <param name="Title">The title of the notification.</param>
/// <param name="Content">The content of the notification.</param>
/// <param name="Timeout">The duration of the notification in milliseconds.</param>
/// <param name="Image">The optional image to display in the notification.</param>
public void XSNotification(string Title, string Content, int Timeout, string Image = "")
{
bool UseBase64Icon;
@@ -332,6 +423,10 @@ namespace VRCX
broadcastSocket.SendTo(byteBuffer, endPoint);
}
/// <summary>
/// Downloads the VRCX update executable from the specified URL and saves it to the AppData directory.
/// </summary>
/// <param name="url">The URL of the VRCX update to download.</param>
public void DownloadVRCXUpdate(string url)
{
var Location = Path.Combine(Program.AppDataDirectory, "update.exe");
@@ -340,6 +435,9 @@ namespace VRCX
client.DownloadFile(new Uri(url), Location);
}
/// <summary>
/// Restarts the VRCX application for an update by launching a new process with the "/Upgrade" argument and exiting the current process.
/// </summary>
public void RestartApplication()
{
var VRCXProcess = new Process();
@@ -350,6 +448,10 @@ namespace VRCX
Environment.Exit(0);
}
/// <summary>
/// Checks if the VRCX update executable exists in the AppData directory.
/// </summary>
/// <returns>True if the update executable exists, false otherwise.</returns>
public bool CheckForUpdateExe()
{
if (File.Exists(Path.Combine(Program.AppDataDirectory, "update.exe")))
@@ -357,6 +459,9 @@ namespace VRCX
return false;
}
/// <summary>
/// Sends an IPC packet to announce the start of VRCX.
/// </summary>
public void IPCAnnounceStart()
{
IPCServer.Send(new IPCPacket
@@ -365,6 +470,11 @@ namespace VRCX
});
}
/// <summary>
/// Sends an IPC packet with a specified message type and data.
/// </summary>
/// <param name="type">The message type to send.</param>
/// <param name="data">The data to send.</param>
public void SendIpc(string type, string data)
{
IPCServer.Send(new IPCPacket
@@ -397,6 +507,10 @@ namespace VRCX
VRCXVR._browser2.ExecuteScriptAsync($"$app.{function}", json);
}
/// <summary>
/// Gets the launch command from the startup arguments and clears the launch command.
/// </summary>
/// <returns>The launch command.</returns>
public string GetLaunchCommand()
{
var command = StartupArgs.LaunchCommand;
@@ -404,11 +518,18 @@ namespace VRCX
return command;
}
/// <summary>
/// Focuses the main window of the VRCX application.
/// </summary>
public void FocusWindow()
{
MainForm.Instance.Invoke(new Action(() => { MainForm.Instance.Focus_Window(); }));
}
/// <summary>
/// Returns the file path of the custom user CSS file, if it exists.
/// </summary>
/// <returns>The file path of the custom user CSS file, or an empty string if it doesn't exist.</returns>
public string CustomCssPath()
{
var output = string.Empty;
@@ -418,6 +539,10 @@ namespace VRCX
return output;
}
/// <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 CustomScriptPath()
{
var output = string.Empty;
@@ -442,6 +567,10 @@ namespace VRCX
return Program.Version;
}
/// <summary>
/// Returns whether or not the VRChat client was last closed gracefully. According to the log file, anyway.
/// </summary>
/// <returns>True if the VRChat client was last closed gracefully, false otherwise.</returns>
public bool VrcClosedGracefully()
{
return LogWatcher.Instance.VrcClosedGracefully;
@@ -457,6 +586,10 @@ namespace VRCX
WinformThemer.DoFunny();
}
/// <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()
{
using (var uptime = new PerformanceCounter("System", "System Up Time"))
@@ -466,12 +599,23 @@ namespace VRCX
}
}
/// <summary>
/// Returns a color value derived from the given user ID.
/// This is, essentially, and is used for, random colors.
/// </summary>
/// <param name="userId">The user ID to derive the color value from.</param>
/// <returns>A color value derived from the given user ID.</returns>
public int GetColourFromUserID(string userId)
{
var hash = _hasher.ComputeHash(Encoding.UTF8.GetBytes(userId));
return (hash[3] << 8) | hash[4];
}
/// <summary>
/// Returns a dictionary of color values derived from the given list of user IDs.
/// </summary>
/// <param name="userIds">The list of user IDs to derive the color values from.</param>
/// <returns>A dictionary of color values derived from the given list of user IDs.</returns>
public Dictionary<string, int> GetColourBulk(List<object> userIds)
{
var output = new Dictionary<string, int>();
@@ -483,6 +627,10 @@ namespace VRCX
return output;
}
/// <summary>
/// Retrieves the current text from the clipboard.
/// </summary>
/// <returns>The current text from the clipboard.</returns>
public string GetClipboard()
{
var clipboard = string.Empty;
@@ -493,6 +641,11 @@ namespace VRCX
return clipboard;
}
/// <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 object GetVRChatRegistryKey(string key)
{
// https://answers.unity.com/questions/177945/playerprefs-changing-the-name-of-keys.html?childToView=208076#answer-208076
@@ -528,6 +681,12 @@ namespace VRCX
return null;
}
/// <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>
/// <returns>True if the key was successfully set, false otherwise.</returns>
public bool SetVRChatRegistryKey(string key, string value)
{
uint hash = 5381;
@@ -562,6 +721,11 @@ namespace VRCX
return true;
}
/// <summary>
/// Retrieves a dictionary of moderations for the specified user from the VRChat LocalPlayerModerations folder.
/// </summary>
/// <param name="currentUserId">The ID of the current user.</param>
/// <returns>A dictionary of moderations for the specified user, or null if the file does not exist.</returns>
public Dictionary<string, short> GetVRChatModerations(string currentUserId)
{
var filePath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + $@"Low\VRChat\VRChat\LocalPlayerModerations\{currentUserId}-show-hide-user.vrcset";
@@ -590,6 +754,12 @@ namespace VRCX
return output;
}
/// <summary>
/// Retrieves the moderation type for the specified user from the VRChat LocalPlayerModerations folder.
/// </summary>
/// <param name="currentUserId">The ID of the current user.</param>
/// <param name="userId">The ID of the user to retrieve the moderation type for.</param>
/// <returns>The moderation type for the specified user, or 0 if the file does not exist or the user is not found.</returns>
public short GetVRChatUserModeration(string currentUserId, string userId)
{
var filePath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + $@"Low\VRChat\VRChat\LocalPlayerModerations\{currentUserId}-show-hide-user.vrcset";
@@ -615,6 +785,13 @@ namespace VRCX
return 0;
}
/// <summary>
/// Sets the moderation type for the specified user in the VRChat LocalPlayerModerations folder.
/// </summary>
/// <param name="currentUserId">The ID of the current user.</param>
/// <param name="userId">The ID of the user to set the moderation type for.</param>
/// <param name="type">The moderation type to set for the specified user.</param>
/// <returns>True if the operation was successful, false otherwise.</returns>
public bool SetVRChatUserModeration(string currentUserId, string userId, int type)
{
var filePath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + $@"Low\VRChat\VRChat\LocalPlayerModerations\{currentUserId}-show-hide-user.vrcset";
@@ -648,6 +825,10 @@ namespace VRCX
return true;
}
/// <summary>
/// Sets whether or not the application should start up automatically with Windows.
/// </summary>
/// <param name="enabled">True to enable automatic startup, false to disable it.</param>
public void SetStartup(bool enabled)
{
try
@@ -679,6 +860,13 @@ namespace VRCX
AutoAppLaunchManager.Instance.KillChildrenOnExit = killOnExit;
}
/// <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 or not to rename the screenshot file to include the world ID.</param>
public void AddScreenshotMetadata(string path, string metadataString, string worldId, bool changeFilename = false)
{
var fileName = Path.GetFileNameWithoutExtension(path);
@@ -696,7 +884,10 @@ namespace VRCX
ScreenshotHelper.WritePNGDescription(path, metadataString);
}
// Create a function that opens a file dialog so a user can choose a .png file. Print the name of the file after it is chosen
/// <summary>
/// Opens a file dialog to select a PNG screenshot file.
/// The resulting file path is passed to <see cref="GetScreenshotMetadata(string)"/>.
/// </summary>
public void OpenScreenshotFileDialog()
{
if (dialogOpen) return;
@@ -737,6 +928,10 @@ namespace VRCX
thread.Start();
}
/// <summary>
/// Retrieves metadata from a PNG screenshot file and send the result to displayScreenshotMetadata in app.js
/// </summary>
/// <param name="path">The path to the PNG screenshot file.</param>
public void GetScreenshotMetadata(string path)
{
if (string.IsNullOrEmpty(path))
@@ -818,6 +1013,9 @@ namespace VRCX
ExecuteAppFunction("displayScreenshotMetadata", metadata.ToString(Formatting.Indented));
}
/// <summary>
/// Gets the last screenshot taken by VRChat and retrieves its metadata.
/// </summary>
public void GetLastScreenshot()
{
// Get the last screenshot taken by VRChat
@@ -836,6 +1034,10 @@ namespace VRCX
GetScreenshotMetadata(lastScreenshot);
}
/// <summary>
/// Copies an image file to the clipboard if it exists and is of a supported image file type.
/// </summary>
/// <param name="path">The path to the image file to copy to the clipboard.</param>
public void CopyImageToClipboard(string path)
{
// check if the file exists and is any image file type
@@ -849,6 +1051,9 @@ namespace VRCX
}
}
/// <summary>
/// Opens the folder containing user-defined shortcuts, if it exists.
/// </summary>
public void OpenShortcutFolder()
{
var path = AutoAppLaunchManager.Instance.AppShortcutDirectory;
@@ -858,6 +1063,11 @@ namespace VRCX
OpenFolderAndSelectItem(path, true);
}
/// <summary>
/// Opens the folder containing the specified file or folder path and selects the item in the folder.
/// </summary>
/// <param name="path">The path to the file or folder to select in the folder.</param>
/// <param name="isFolder">Whether the specified path is a folder or not. Defaults to false.</param>
public void OpenFolderAndSelectItem(string path, bool isFolder = false)
{
// 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.
@@ -901,11 +1111,17 @@ namespace VRCX
}
}
/// <summary>
/// Flashes the window of the main form.
/// </summary>
public void FlashWindow()
{
MainForm.Instance.BeginInvoke(new MethodInvoker(() => { WinformThemer.Flash(MainForm.Instance); }));
}
/// <summary>
/// Sets the user agent string for the browser.
/// </summary>
public void SetUserAgent()
{
using (var client = MainForm.Instance.Browser.GetDevToolsClient())

View File

@@ -59,6 +59,12 @@ namespace VRCX
return AppApi.Instance.GetVRChatCacheLocation();
}
/// <summary>
/// Gets the full location of the VRChat cache for a specific asset bundle.
/// </summary>
/// <param name="id">The ID of the asset bundle.</param>
/// <param name="version">The version of the asset bundle.</param>
/// <returns>The full location of the VRChat cache for the specified asset bundle.</returns>
public string GetVRChatCacheFullLocation(string id, int version)
{
var cachePath = GetVRChatCacheLocation();
@@ -67,6 +73,12 @@ namespace VRCX
return Path.Combine(cachePath, idHash, versionLocation);
}
/// <summary>
/// Checks the VRChat cache for a specific asset bundle.
/// </summary>
/// <param name="id">The ID of the asset bundle.</param>
/// <param name="version">The version of the asset bundle.</param>
/// <returns>An array containing the file size and lock status of the asset bundle.</returns>
public long[] CheckVRChatCache(string id, int version)
{
long FileSize = -1;
@@ -155,6 +167,11 @@ namespace VRCX
DownloadProgress = -16;
}
/// <summary>
/// Deletes the cache directory for a specific asset bundle.
/// </summary>
/// <param name="id">The ID of the asset bundle to delete.</param>
/// <param name="version">The version of the asset bundle to delete.</param>
public void DeleteCache(string id, int version)
{
var FullLocation = GetVRChatCacheFullLocation(id, version);
@@ -162,6 +179,9 @@ namespace VRCX
Directory.Delete(FullLocation, true);
}
/// <summary>
/// Deletes the entire VRChat cache directory.
/// </summary>
public void DeleteAllCache()
{
var cachePath = GetVRChatCacheLocation();
@@ -172,6 +192,9 @@ namespace VRCX
}
}
/// <summary>
/// Removes empty directories from the VRChat cache directory and deletes old versions of cached asset bundles.
/// </summary>
public void SweepCache()
{
var cachePath = GetVRChatCacheLocation();
@@ -201,6 +224,10 @@ namespace VRCX
}
}
/// <summary>
/// Returns the size of the VRChat cache directory in bytes.
/// </summary>
/// <returns>The size of the VRChat cache directory in bytes.</returns>
public long GetCacheSize()
{
var cachePath = GetVRChatCacheLocation();
@@ -214,6 +241,12 @@ namespace VRCX
}
}
/// <summary>
/// Recursively calculates the size of a directory and all its subdirectories.
/// </summary>
/// <param name="d">The directory to calculate the size of.</param>
/// <returns>The size of the directory and all its subdirectories in bytes.</returns>
public long DirSize(DirectoryInfo d)
{
long size = 0;

View File

@@ -22,7 +22,7 @@ namespace VRCX
private DateTime startTime = DateTime.Now;
private Dictionary<string, Process> startedProcesses = new Dictionary<string, Process>();
private static readonly byte[] shortcutSignatureBytes = { 0x4C, 0x00, 0x00, 0x00 }; // signature for ShellLinkHeader\
private static readonly byte[] shortcutSignatureBytes = { 0x4C, 0x00, 0x00, 0x00 }; // signature for ShellLinkHeader
private const uint TH32CS_SNAPPROCESS = 2;
@@ -123,6 +123,11 @@ namespace VRCX
// This is a recursive function that kills a process and all of its children.
// It uses the CreateToolhelp32Snapshot winapi func to get a snapshot of all running processes, loops through them with Process32First/Process32Next, and kills any processes that have the given pid as their parent.
/// <summary>
/// Kills a process and all of its child processes.
/// </summary>
/// <param name="pid">The process ID of the parent process.</param>
public static void KillProcessTree(int pid)
{
IntPtr snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);

View File

@@ -14,6 +14,9 @@ using CefSharp;
namespace VRCX
{
/// <summary>
/// Monitors the VRChat log files for changes and provides access to the log data.
/// </summary>
public class LogWatcher
{
public static readonly LogWatcher Instance;
@@ -88,6 +91,9 @@ namespace VRCX
}
}
/// <summary>
/// Updates the log watcher by checking for new log files and updating the log list.
/// </summary>
private void Update()
{
if (m_ResetLog)
@@ -157,6 +163,11 @@ namespace VRCX
m_FirstRun = false;
}
/// <summary>
/// Parses the log file starting from the current position and updates the log context.
/// </summary>
/// <param name="fileInfo">The file information of the log file to parse.</param>
/// <param name="logContext">The log context to update.</param>
private void ParseLog(FileInfo fileInfo, LogContext logContext)
{
try
@@ -224,6 +235,7 @@ namespace VRCX
ParseLogUsharpVideoPlay(fileInfo, logContext, line, offset) ||
ParseLogUsharpVideoSync(fileInfo, logContext, line, offset) ||
ParseLogWorldVRCX(fileInfo, logContext, line, offset) ||
ParseLogWorldDataVRCX(fileInfo, logContext, line, offset) ||
ParseLogOnAudioConfigurationChanged(fileInfo, logContext, line, offset) ||
ParseLogScreenshot(fileInfo, logContext, line, offset) ||
ParseLogStringDownload(fileInfo, logContext, line, offset) ||
@@ -593,6 +605,19 @@ namespace VRCX
return true;
}
private bool ParseLogWorldDataVRCX(FileInfo fileInfo, LogContext logContext, string line, int offset)
{
// [VRCX-World] store:test:testvalue
if (string.Compare(line, offset, "[VRCX-World] ", 0, 13, StringComparison.Ordinal) != 0)
return false;
var data = line.Substring(offset + 13);
WorldDBManager.Instance.ProcessLogWorldDataRequest(data);
return true;
}
private bool ParseLogVideoChange(FileInfo fileInfo, LogContext logContext, string line, int offset)
{
// 2021.04.20 13:37:69 Log - [Video Playback] Attempting to resolve URL 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
@@ -927,7 +952,7 @@ namespace VRCX
private bool ParseOpenVRInit(FileInfo fileInfo, LogContext logContext, string line, int offset)
{
// 2022.07.29 02:52:14 Log - OpenVR initialized!
// 2023.04.22 16:52:28 Log - Initializing VRSDK.
// 2023.04.22 16:52:29 Log - StartVRSDK: Open VR Loader
@@ -944,7 +969,7 @@ namespace VRCX
return true;
}
private bool ParseDesktopMode(FileInfo fileInfo, LogContext logContext, string line, int offset)
{
// 2023.04.22 16:54:18 Log - VR Disabled

View File

@@ -6,6 +6,7 @@
using System;
using System.IO;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace VRCX
@@ -78,8 +79,12 @@ namespace VRCX
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
// I'll re-do this whole function eventually I swear
var worldDBServer = new WorldDBManager("http://127.0.0.1:22500/");
Task.Run(worldDBServer.Start);
ProcessMonitor.Instance.Init();
SQLite.Instance.Init();
SQLiteLegacy.Instance.Init();
VRCXStorage.Load();
LoadFromConfig();
CpuMonitor.Instance.Init();
@@ -99,14 +104,18 @@ namespace VRCX
AutoAppLaunchManager.Instance.Exit();
LogWatcher.Instance.Exit();
WebApi.Instance.Exit();
worldDBServer.Stop();
Discord.Instance.Exit();
CpuMonitor.Instance.Exit();
VRCXStorage.Save();
SQLite.Instance.Exit();
SQLiteLegacy.Instance.Exit();
ProcessMonitor.Instance.Exit();
}
/// <summary>
/// Sets GPUFix to true if it is not already set and the VRCX_GPUFix key in the database is true.
/// </summary>
private static void LoadFromConfig()
{
if (!GPUFix)

5069
SQLite.cs

File diff suppressed because it is too large Load Diff

151
SQLiteLegacy.cs Normal file
View File

@@ -0,0 +1,151 @@
using CefSharp;
using System;
using System.Collections.Generic;
using System.Data.SQLite;
using System.IO;
using System.Threading;
namespace VRCX
{
public class SQLiteLegacy
{
public static readonly SQLiteLegacy Instance;
private readonly ReaderWriterLockSlim m_ConnectionLock;
private readonly SQLiteConnection m_Connection;
static SQLiteLegacy()
{
Instance = new SQLiteLegacy();
}
public SQLiteLegacy()
{
m_ConnectionLock = new ReaderWriterLockSlim();
var dataSource = Program.ConfigLocation;
m_Connection = new SQLiteConnection($"Data Source=\"{dataSource}\";Version=3;PRAGMA locking_mode=NORMAL;PRAGMA busy_timeout=5000", true);
}
internal void Init()
{
m_Connection.Open();
}
internal void Exit()
{
m_Connection.Close();
m_Connection.Dispose();
}
public void Execute(IJavascriptCallback callback, string sql, IDictionary<string, object> args = null)
{
try
{
m_ConnectionLock.EnterReadLock();
try
{
using (var command = new SQLiteCommand(sql, m_Connection))
{
if (args != null)
{
foreach (var arg in args)
{
command.Parameters.Add(new SQLiteParameter(arg.Key, arg.Value));
}
}
using (var reader = command.ExecuteReader())
{
while (reader.Read() == true)
{
var values = new object[reader.FieldCount];
reader.GetValues(values);
if (callback.CanExecute == true)
{
callback.ExecuteAsync(null, values);
}
}
}
}
if (callback.CanExecute == true)
{
callback.ExecuteAsync(null, null);
}
}
finally
{
m_ConnectionLock.ExitReadLock();
}
}
catch (Exception e)
{
if (callback.CanExecute == true)
{
callback.ExecuteAsync(e.Message, null);
}
}
callback.Dispose();
}
public void Execute(Action<object[]> callback, string sql, IDictionary<string, object> args = null)
{
m_ConnectionLock.EnterReadLock();
try
{
using (var command = new SQLiteCommand(sql, m_Connection))
{
if (args != null)
{
foreach (var arg in args)
{
command.Parameters.Add(new SQLiteParameter(arg.Key, arg.Value));
}
}
using (var reader = command.ExecuteReader())
{
while (reader.Read() == true)
{
var values = new object[reader.FieldCount];
reader.GetValues(values);
callback(values);
}
}
}
}
catch
{
}
finally
{
m_ConnectionLock.ExitReadLock();
}
}
public int ExecuteNonQuery(string sql, IDictionary<string, object> args = null)
{
int result = -1;
m_ConnectionLock.EnterWriteLock();
try
{
using (var command = new SQLiteCommand(sql, m_Connection))
{
if (args != null)
{
foreach (var arg in args)
{
command.Parameters.Add(new SQLiteParameter(arg.Key, arg.Value));
}
}
result = command.ExecuteNonQuery();
}
}
finally
{
m_ConnectionLock.ExitWriteLock();
}
return result;
}
}
}

View File

@@ -181,7 +181,11 @@ namespace VRCX
return new PNGChunk(type, chunkData);
}
// parse LFS screenshot PNG metadata
/// <summary>
/// Parses the metadata string of a vrchat screenshot with taken with LFS and returns a JObject containing the parsed data.
/// </summary>
/// <param name="metadataString">The metadata string to parse.</param>
/// <returns>A JObject containing the parsed data.</returns>
public static JObject ParseLfsPicture(string metadataString)
{
var metadata = new JObject();

View File

@@ -1,3 +1,4 @@
using System;
using CefSharp;
namespace VRCX
@@ -11,7 +12,7 @@ namespace VRCX
repository.Register("SharedVariable", SharedVariable.Instance, false);
repository.Register("WebApi", WebApi.Instance, true);
repository.Register("VRCXStorage", VRCXStorage.Instance, true);
repository.Register("SQLite", SQLite.Instance, true);
repository.Register("SQLite", SQLiteLegacy.Instance, true);
repository.Register("LogWatcher", LogWatcher.Instance, true);
repository.Register("Discord", Discord.Instance, true);
repository.Register("AssetBundleCacher", AssetBundleCacher.Instance, true);

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
@@ -87,11 +87,13 @@
<Compile Include="CefCustomDownloadHandler.cs" />
<Compile Include="CefCustomDragHandler.cs" />
<Compile Include="CefCustomMenuHandler.cs" />
<Compile Include="WorldDatabase.cs" />
<Compile Include="ImageCache.cs" />
<Compile Include="IPCClient.cs" />
<Compile Include="IPCServer.cs" />
<Compile Include="ProcessMonitor.cs" />
<Compile Include="ScreenshotHelper.cs" />
<Compile Include="SQLite.cs" />
<Compile Include="StartupArgs.cs" />
<Compile Include="Update.cs" />
<Compile Include="CefService.cs" />
@@ -101,7 +103,7 @@
<Compile Include="NoopDragHandler.cs" />
<Compile Include="OpenVR\openvr_api.cs" />
<Compile Include="WebApi.cs" />
<Compile Include="SQLite.cs" />
<Compile Include="SQLiteLegacy.cs" />
<Compile Include="SharedVariable.cs" />
<Compile Include="Util.cs" />
<Compile Include="VRForm.cs">
@@ -123,6 +125,8 @@
<Compile Include="AppApi.cs" />
<Compile Include="VRCXStorage.cs" />
<Compile Include="JsonSerializer.cs" />
<Compile Include="WorldDataRequestResponse.cs" />
<Compile Include="WorldDBManager.cs" />
<Compile Include="WinApi.cs" />
<Compile Include="WinformBase.cs">
<SubType>Form</SubType>
@@ -222,6 +226,7 @@
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<PropertyGroup>
<PostBuildEvent>xcopy /y "$(ProjectDir)OpenVR\win64\openvr_api.dll"</PostBuildEvent>
<PostBuildEvent>xcopy /y "$(ProjectDir)OpenVR\win64\openvr_api.dll"
xcopy /y "$(ProjectDir)lib\sqlite3.dll"</PostBuildEvent>
</PropertyGroup>
</Project>

View File

@@ -60,8 +60,8 @@ namespace VRCX
internal void LoadCookies()
{
SQLite.Instance.ExecuteNonQuery("CREATE TABLE IF NOT EXISTS `cookies` (`key` TEXT PRIMARY KEY, `value` TEXT)");
SQLite.Instance.Execute((values) =>
SQLiteLegacy.Instance.ExecuteNonQuery("CREATE TABLE IF NOT EXISTS `cookies` (`key` TEXT PRIMARY KEY, `value` TEXT)");
SQLiteLegacy.Instance.Execute((values) =>
{
try
{
@@ -92,7 +92,7 @@ namespace VRCX
using (var memoryStream = new MemoryStream())
{
new BinaryFormatter().Serialize(memoryStream, _cookieContainer);
SQLite.Instance.ExecuteNonQuery(
SQLiteLegacy.Instance.ExecuteNonQuery(
"INSERT OR REPLACE INTO `cookies` (`key`, `value`) VALUES (@key, @value)",
new Dictionary<string, object>() {
{"@key", "default"},

423
WorldDBManager.cs Normal file
View File

@@ -0,0 +1,423 @@
using System.Linq;
using System.Text;
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Runtime.Serialization.Formatters.Binary;
using System.Threading.Tasks;
using CefSharp;
using Newtonsoft.Json;
namespace VRCX
{
public class WorldDBManager
{
public static WorldDBManager Instance;
private readonly HttpListener listener;
private readonly WorldDatabase worldDB;
private string currentWorldId = null;
private string lastError = null;
public WorldDBManager(string url)
{
Instance = this;
// http://localhost:22500
listener = new HttpListener();
listener.Prefixes.Add(url);
worldDB = new WorldDatabase(Path.Combine(Program.AppDataDirectory, "VRCX-WorldData.db"));
}
public async Task Start()
{
listener.Start();
while (true)
{
var context = await listener.GetContextAsync();
var request = context.Request;
var responseData = new WorldDataRequestResponse(false, null, null);
if (MainForm.Instance?.Browser == null || MainForm.Instance.Browser.IsLoading || !MainForm.Instance.Browser.CanExecuteJavascriptInMainFrame)
{
responseData.Error = "VRCX not yet initialized. Try again in a moment.";
responseData.StatusCode = 503;
SendJsonResponse(context.Response, responseData);
continue;
};
switch (request.Url.LocalPath)
{
case "/vrcx/data/init":
responseData = await HandleInitRequest(context);
SendJsonResponse(context.Response, responseData);
break;
case "/vrcx/data/get":
responseData = await HandleDataRequest(context);
SendJsonResponse(context.Response, responseData);
break;
case "/vrcx/data/lasterror":
responseData.OK = lastError == null;
responseData.Data = lastError;
lastError = null;
SendJsonResponse(context.Response, responseData);
break;
case "/vrcx/data/getbulk":
responseData = await HandleBulkDataRequest(context);
SendJsonResponse(context.Response, responseData);
break;
case "/vrcx/status":
context.Response.StatusCode = 200;
context.Response.Close();
break;
default:
responseData.Error = "Invalid VRCX endpoint.";
responseData.StatusCode = 404;
SendJsonResponse(context.Response, responseData);
break;
}
}
}
/// <summary>
/// Handles an HTTP listener request to initialize a connection to the world db manager.
/// </summary>
/// <param name="context">The HTTP listener context object.</param>
/// <returns>A <see cref="WorldDataRequestResponse"/> object containing the response data.</returns>
private async Task<WorldDataRequestResponse> HandleInitRequest(HttpListenerContext context)
{
var request = context.Request;
var responseData = new WorldDataRequestResponse(false, null, null);
if (request.QueryString["debug"] == "true")
{
if (!worldDB.DoesWorldExist("wrld_12345"))
{
worldDB.AddWorld("wrld_12345", "12345");
worldDB.AddDataEntry("wrld_12345", "test", "testvalue");
}
currentWorldId = "wrld_12345";
responseData.OK = true;
responseData.StatusCode = 200;
responseData.Data = "12345";
return responseData;
}
string worldId = await GetCurrentWorldID();
if (String.IsNullOrEmpty(worldId))
{
responseData.Error = "Failed to get/verify current world ID.";
responseData.StatusCode = 500;
return responseData;
}
currentWorldId = worldId;
var existsInDB = worldDB.DoesWorldExist(currentWorldId);
string connectionKey;
if (!existsInDB)
{
connectionKey = GenerateWorldConnectionKey();
worldDB.AddWorld(currentWorldId, connectionKey);
}
else
{
connectionKey = worldDB.GetWorldConnectionKey(currentWorldId);
}
responseData.OK = true;
responseData.StatusCode = 200;
responseData.Data = connectionKey;
return responseData;
}
/// <summary>
/// Handles an HTTP listener request for data from the world database.
/// </summary>
/// <param name="context">The HTTP listener context object.</param>
/// <returns>A <see cref="WorldDataRequestResponse"/> object containing the response data.</returns>
private async Task<WorldDataRequestResponse> HandleDataRequest(HttpListenerContext context)
{
var request = context.Request;
var responseData = new WorldDataRequestResponse(false, null, null);
var key = request.QueryString["key"];
if (key == null)
{
responseData.Error = "Missing key parameter.";
responseData.StatusCode = 400;
return responseData;
}
var worldIdOverride = request.QueryString["world"];
if (worldIdOverride != null)
{
var world = worldDB.GetWorld(worldIdOverride);
if (world == null)
{
responseData.OK = false;
responseData.Error = $"World ID '{worldIdOverride}' not initialized in this user's database.";
responseData.StatusCode = 200;
responseData.Data = null;
return responseData;
}
if (!world.AllowExternalRead)
{
responseData.OK = false;
responseData.Error = $"World ID '{worldIdOverride}' does not allow external reads.";
responseData.StatusCode = 200;
responseData.Data = null;
return responseData;
}
}
if (currentWorldId == "wrld_12345" && worldIdOverride == null)
worldIdOverride = "wrld_12345";
var worldId = worldIdOverride ?? await GetCurrentWorldID();
if (worldIdOverride == null && (String.IsNullOrEmpty(currentWorldId) || worldId != currentWorldId))
{
responseData.Error = "World ID not initialized.";
responseData.StatusCode = 400;
return responseData;
}
var value = worldDB.GetDataEntry(worldId, key);
responseData.OK = true;
responseData.StatusCode = 200;
responseData.Error = null;
responseData.Data = value?.Value;
return responseData;
}
/// <summary>
/// Handles an HTTP listener request for bulk data from the world database.
/// </summary>
/// <param name="context">The HTTP listener context object.</param>
/// <returns>A <see cref="WorldDataRequestResponse"/> object containing the response data.</returns>
private async Task<WorldDataRequestResponse> HandleBulkDataRequest(HttpListenerContext context)
{
var request = context.Request;
var responseData = new WorldDataRequestResponse(false, null, null);
var keys = request.QueryString["keys"];
if (keys == null)
{
responseData.Error = "Missing/invalid keys parameter.";
responseData.StatusCode = 400;
return responseData;
}
var keyArray = keys.Split(',');
var worldId = await GetCurrentWorldID();
if (String.IsNullOrEmpty(currentWorldId) || (worldId != currentWorldId && currentWorldId != "wrld_12345"))
{
responseData.Error = "World ID not initialized.";
responseData.StatusCode = 400;
return responseData;
}
var values = worldDB.GetDataEntries(currentWorldId, keyArray).ToList();
/*if (values == null)
{
responseData.Error = $"No data found for keys '{keys}' under world id '{currentWorldId}'.";
responseData.StatusCode = 404;
return responseData;
}*/
// Build a dictionary of key/value pairs to send back. If a key doesn't exist in the database, the key will be included in the response as requested but with a null value.
var data = new Dictionary<string, string>();
for (int i = 0; i < keyArray.Length; i++)
{
string dataKey = keyArray[i];
string dataValue = values?.Where(x => x.Key == dataKey).FirstOrDefault()?.Value; // Get the value from the list of data entries, if it exists, otherwise null
data.Add(dataKey, dataValue);
}
responseData.OK = true;
responseData.StatusCode = 200;
responseData.Error = null;
responseData.Data = JsonConvert.SerializeObject(data);
return responseData;
}
/// <summary>
/// Generates a unique identifier for a world connection request.
/// </summary>
/// <returns>A string representation of a GUID that can be used to identify the world on requests.</returns>
private string GenerateWorldConnectionKey()
{
// Ditched the old method of generating a short key, since we're just going with json anyway who cares about a longer identifier
// Since we can rely on this GUID being unique, we can use it to identify the world on requests instead of trying to keep track of the user's current world.
// I uhh, should probably make sure this is actually unique though. Just in case. I'll do that later.
return Guid.NewGuid().ToString();
}
/// <summary>
/// Gets the ID of the current world by evaluating a JavaScript function in the main browser instance.
/// </summary>
/// <returns>The ID of the current world as a string, or null if it could not be retrieved.</returns>
private async Task<string> GetCurrentWorldID()
{
JavascriptResponse funcResult = await MainForm.Instance.Browser.EvaluateScriptAsync("$app.API.actuallyGetCurrentLocation();", TimeSpan.FromSeconds(5));
try
{
funcResult = await MainForm.Instance.Browser.EvaluateScriptAsync("$app.API.actuallyGetCurrentLocation();", TimeSpan.FromSeconds(5));
}
catch (Exception ex)
{
return null;
}
string worldId = funcResult?.Result?.ToString();
if (String.IsNullOrEmpty(worldId))
{
// implement
// wait what was i going to do here again
// seriously i forgot, hope it wasn't important
return null;
}
return worldId;
}
/// <summary>
/// Sends a JSON response to an HTTP listener request with the specified response data and status code.
/// </summary>
/// <param name="response">The HTTP listener response object.</param>
/// <param name="responseData">The response data to be serialized to JSON.</param>
/// <param name="statusCode">The HTTP status code to be returned.</param>
/// <returns>The HTTP listener response object.</returns>
private HttpListenerResponse SendJsonResponse(HttpListenerResponse response, WorldDataRequestResponse responseData)
{
response.ContentType = "application/json";
response.StatusCode = responseData.StatusCode;
response.AddHeader("Cache-Control", "no-cache");
// Use newtonsoft.json to serialize WorldDataRequestResponse to json
var json = JsonConvert.SerializeObject(responseData);
var buffer = System.Text.Encoding.UTF8.GetBytes(json);
response.ContentLength64 = buffer.Length;
response.OutputStream.Write(buffer, 0, buffer.Length);
response.Close();
return response;
}
/// <summary>
/// Processes a JSON request containing world data and logs it to the world database.
/// </summary>
/// <param name="json">The JSON request containing the world data.</param>
public async void ProcessLogWorldDataRequest(string json)
{
// Current format:
// {
// "requestType": "store",
// "connectionKey": "abc123",
// "key": "example_key",
// "value": "example_value"
// }
// * I could rate limit the processing of this, but I don't think it's necessary.
// * At the amount of data you'd need to be spitting out to lag vrcx, you'd fill up the log file and lag out VRChat far before VRCX would have any issues; at least in my testing.
// As long as malicious worlds can't permanently *store* stupid amounts of unculled data, this is pretty safe with the 10MB cap. If a world wants to just fill up a users HDD with logs, they can do that already anyway.
WorldDataRequest request;
try // try to deserialize the json into a WorldDataRequest object
{
request = JsonConvert.DeserializeObject<WorldDataRequest>(json);
}
catch (JsonReaderException ex)
{
this.lastError = ex.Message;
// invalid json
return;
}
catch (Exception ex)
{
this.lastError = ex.Message;
// something else happened lol
return;
}
if (String.IsNullOrEmpty(request.Key))
{
this.lastError = "`key` is missing or null";
return;
}
if (String.IsNullOrEmpty(request.Value))
{
this.lastError = "`value` is missing or null";
return;
}
if (String.IsNullOrEmpty(request.ConnectionKey))
{
this.lastError = "`connectionKey` is missing or null";
return;
}
// Make sure the connection key is a valid GUID. No point in doing anything else if it's not.
if (!Guid.TryParse(request.ConnectionKey, out Guid _))
{
this.lastError = "Invalid GUID provided as connection key";
// invalid guid
return;
}
// Get the world ID from the connection key
string worldId = worldDB.GetWorldByConnectionKey(request.ConnectionKey);
if (worldId == null)
{
this.lastError = "Invalid connection key";
// invalid connection key
return;
}
// Get/calculate the old and new data sizes for this key/the world
int oldTotalDataSize = worldDB.GetWorldDataSize(worldId);
int oldDataSize = worldDB.GetDataEntrySize(worldId, request.Key);
int newDataSize = Encoding.UTF8.GetByteCount(request.Value);
int newTotalDataSize = oldTotalDataSize + newDataSize - oldDataSize;
// Make sure we don't exceed 10MB total size for this world
// This works, I tested it. Hopefully this prevents/limits any possible abuse.
if (newTotalDataSize > 1024 * 1024 * 10)
{
this.lastError = $"You have hit the 10MB total data cap. The previous data entry was *not* stored. Your request was {newDataSize} bytes, your current shared byte total is {oldTotalDataSize} and you went over the table limit by {newTotalDataSize - (1024 * 1024 * 10)} bytes.";
// too much data
//throw new Exception("Too much data");
return;
}
worldDB.AddDataEntry(worldId, request.Key, request.Value, newDataSize);
worldDB.UpdateWorldDataSize(worldId, newTotalDataSize);
}
public void Stop()
{
listener.Stop();
listener.Close();
worldDB.Close();
}
}
}

View File

@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;
namespace VRCX
{
public class WorldDataRequestResponse
{
/// <summary>
/// Gets or sets a value indicating whether the request was successful.
/// </summary>
[JsonProperty("ok")]
public bool OK { get; set; }
/// <summary>
/// Gets or sets the error message if the request was not successful.
/// </summary>
[JsonProperty("error")]
public string Error { get; set; }
/// <summary>
/// Gets or sets the data returned by the request.
/// </summary>
[JsonProperty("data")]
public string Data { get; set; }
/// <summary>
/// Gets or sets the response code.
/// </summary>
/// <value></value>
[JsonProperty("statusCode")]
public int StatusCode { get; set; }
public WorldDataRequestResponse(bool ok, string error, string data)
{
OK = ok;
Error = error;
Data = data;
}
}
public class WorldDataRequest
{
[JsonProperty("requestType")]
public string RequestType;
[JsonProperty("connectionKey")]
public string ConnectionKey;
[JsonProperty("key")]
public string Key;
[JsonProperty("value")]
public string Value;
}
}

270
WorldDatabase.cs Normal file
View File

@@ -0,0 +1,270 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using SQLite;
namespace VRCX
{
[Table("data")]
public class WorldData
{
[PrimaryKey, AutoIncrement]
[Column("id")]
public int Id { get; set; }
[Column("world_id"), NotNull]
public string WorldId { get; set; }
[Column("key"), NotNull]
public string Key { get; set; }
[Column("value"), NotNull]
public string Value { get; set; }
[Column("value_size"), NotNull]
public int ValueSize { get; set; }
[Column("last_accessed")]
public DateTimeOffset LastAccessed { get; set; }
[Column("last_modified")]
public DateTimeOffset LastModified { get; set; }
}
[Table("worlds")]
public class World
{
[PrimaryKey, AutoIncrement]
[Column("id")]
public int Id { get; set; }
[Column("world_id"), NotNull]
public string WorldId { get; set; }
[Column("connection_key"), NotNull]
public string ConnectionKey { get; set; }
[Column("total_data_size"), NotNull]
public int TotalDataSize { get; set; }
[Column("allow_external_read")]
public bool AllowExternalRead { get; set; }
}
internal class WorldDatabase
{
private static SQLiteConnection sqlite;
private readonly static string dbInitQuery = @"
CREATE TABLE IF NOT EXISTS worlds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
world_id TEXT NOT NULL UNIQUE,
connection_key TEXT NOT NULL,
total_data_size INTEGER DEFAULT 0,
allow_external_read INTEGER DEFAULT 0
);
\
CREATE TABLE IF NOT EXISTS data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
world_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
value_size INTEGER NOT NULL DEFAULT 0,
last_accessed INTEGER DEFAULT (strftime('%s', 'now')),
last_modified INTEGER DEFAULT (strftime('%s', 'now')),
FOREIGN KEY (world_id) REFERENCES worlds(world_id) ON DELETE CASCADE,
UNIQUE (world_id, key)
);
\
CREATE TRIGGER IF NOT EXISTS data_update_trigger
AFTER UPDATE ON data
FOR EACH ROW
BEGIN
UPDATE data SET last_modified = (strftime('%s', 'now')) WHERE id = OLD.id;
END;
\
CREATE TRIGGER IF NOT EXISTS data_insert_trigger
AFTER INSERT ON data
FOR EACH ROW
BEGIN
UPDATE data SET last_accessed = (strftime('%s', 'now')), last_modified = (strftime('%s', 'now')) WHERE id = NEW.id;
END;";
public WorldDatabase(string databaseLocation)
{
var options = new SQLiteConnectionString(databaseLocation, true);
sqlite = new SQLiteConnection(options);
sqlite.Execute(dbInitQuery);
// TODO: Split these init queries into their own functions so we can call/update them individually.
var queries = dbInitQuery.Split('\\');
sqlite.BeginTransaction();
foreach (var query in queries)
{
sqlite.Execute(query);
}
sqlite.Commit();
}
/// <summary>
/// Checks if a world with the specified ID exists in the database.
/// </summary>
/// <param name="worldId">The ID of the world to check for.</param>
/// <returns>True if the world exists in the database, false otherwise.</returns>
public bool DoesWorldExist(string worldId)
{
var query = sqlite.Table<World>().Where(w => w.WorldId == worldId).Select(w => w.WorldId);
return query.Any();
}
/// <summary>
/// Gets the ID of the world with the specified connection key from the database.
/// </summary>
/// <param name="connectionKey">The connection key of the world to get the ID for.</param>
/// <returns>The ID of the world with the specified connection key, or null if no such world exists in the database.</returns>
public string GetWorldByConnectionKey(string connectionKey)
{
var query = sqlite.Table<World>().Where(w => w.ConnectionKey == connectionKey).Select(w => w.WorldId);
return query.FirstOrDefault();
}
/// <summary>
/// Gets the connection key for a world from the database.
/// </summary>
/// <param name="worldId">The ID of the world to get the connection key for.</param>
/// <returns>The connection key for the specified world, or null if the world does not exist in the database.</returns>
public string GetWorldConnectionKey(string worldId)
{
var query = sqlite.Table<World>().Where(w => w.WorldId == worldId).Select(w => w.ConnectionKey);
return query.FirstOrDefault();
}
/// <summary>
/// Sets the connection key for a world in the database. If the world already exists in the database, the connection key is updated. Otherwise, a new world is added to the database with the specified connection key.
/// </summary>
/// <param name="worldId">The ID of the world to set the connection key for.</param>
/// <param name="connectionKey">The connection key to set for the world.</param>
/// <returns>The connection key that was set.</returns>
public string SetWorldConnectionKey(string worldId, string connectionKey)
{
var query = sqlite.Table<World>().Where(w => w.WorldId == worldId).Select(w => w.ConnectionKey);
if (query.Any())
{
sqlite.Execute("UPDATE worlds SET connection_key = ? WHERE world_id = ?", connectionKey, worldId);
}
else
{
sqlite.Insert(new World() { WorldId = worldId, ConnectionKey = connectionKey });
}
return connectionKey;
}
/// <summary>
/// Adds a new world to the database.
/// </summary>
/// <param name="worldId">The ID of the world to add.</param>
/// <param name="connectionKey">The connection key of the world to add.</param>
/// <exception cref="SQLiteException">Thrown if a world with the specified ID already exists in the database.</exception>
public void AddWorld(string worldId, string connectionKey)
{
// * This will throw an error if the world already exists.. so don't do that
sqlite.Insert(new World() { WorldId = worldId, ConnectionKey = connectionKey });
}
/// <summary>
/// Gets the world with the specified ID from the database.
/// </summary>
/// <param name="worldId">The ID of the world to get.</param>
/// <returns>The world with the specified ID, or null if no such world exists in the database.</returns>
public World GetWorld(string worldId)
{
var query = sqlite.Table<World>().Where(w => w.WorldId == worldId);
return query.FirstOrDefault();
}
/// <summary>
/// Gets the total data size shared across all rows, in bytes, for the world with the specified ID from the database.
/// </summary>
/// <param name="worldId">The ID of the world to get the total data size for.</param>
/// <returns>The total data size for the world, in bytes.</returns>
public int GetWorldDataSize(string worldId)
{
var query = sqlite.Table<World>().Where(w => w.WorldId == worldId).Select(w => w.TotalDataSize);
return query.FirstOrDefault();
}
/// <summary>
/// Updates the total data size, in bytes for the world with the specified ID in the database.
/// </summary>
/// <param name="worldId">The ID of the world to update the total data size for.</param>
/// <param name="size">The new total data size for the world, in bytes.</param>
public void UpdateWorldDataSize(string worldId, int size)
{
sqlite.Execute("UPDATE worlds SET total_data_size = ? WHERE world_id = ?", size, worldId);
}
/// <summary>
/// Adds or updates a data entry in the database with the specified world ID, key, and value.
/// </summary>
/// <param name="worldId">The ID of the world to add the data entry for.</param>
/// <param name="key">The key of the data entry to add or replace.</param>
/// <param name="value">The value of the data entry to add or replace.</param>
/// <param name="dataSize">The size of the data entry to add or replace, in bytes. If null, the size is calculated from the value automatically.</param>
public void AddDataEntry(string worldId, string key, string value, int? dataSize = null)
{
int byteSize = dataSize ?? Encoding.UTF8.GetByteCount(value);
// check if entry already exists;
// INSERT OR REPLACE(InsertOrReplace method) deletes the old row and creates a new one, incrementing the id, which I don't want
var query = sqlite.Table<WorldData>().Where(w => w.WorldId == worldId && w.Key == key);
if (query.Any())
{
sqlite.Execute("UPDATE data SET value = ?, value_size = ? WHERE world_id = ? AND key = ?", value, byteSize, worldId, key);
}
else
{
sqlite.Insert(new WorldData() { WorldId = worldId, Key = key, Value = value, ValueSize = byteSize });
}
}
/// <summary>
/// Gets the data entry with the specified world ID and key from the database.
/// </summary>
/// <param name="worldId">The ID of the world to get the data entry for.</param>
/// <param name="key">The key of the data entry to get.</param>
/// <returns>The data entry with the specified world ID and key, or null if no such data entry exists in the database.</returns>
public WorldData GetDataEntry(string worldId, string key)
{
var query = sqlite.Table<WorldData>().Where(w => w.WorldId == worldId && w.Key == key);
return query.FirstOrDefault();
}
/// <summary>
/// Gets the data entries with the specified world ID and keys from the database.
/// </summary>
/// <param name="worldId">The ID of the world to get the data entries for.</param>
/// <param name="keys">The keys of the data entries to get.</param>
/// <returns>An enumerable collection of the data entries with the specified world ID and keys.</returns>
public IEnumerable<WorldData> GetDataEntries(string worldId, string[] keys)
{
var query = sqlite.Table<WorldData>().Where(w => w.WorldId == worldId && keys.Contains(w.Key));
return query.ToList();
}
/// <summary>
/// Gets the size of the data entry, in bytes, with the specified world ID and key from the database.
/// </summary>
/// <param name="worldId">The ID of the world to get the data entry size for.</param>
/// <param name="key">The key of the data entry to get the size for.</param>
/// <returns>The size of the data entry with the specified world ID and key, or 0 if no such data entry exists in the database.</returns>
public int GetDataEntrySize(string worldId, string key)
{
var query = sqlite.Table<WorldData>().Where(w => w.WorldId == worldId && w.Key == key).Select(w => w.ValueSize);
return query.FirstOrDefault();
}
public void Close()
{
sqlite.Close();
}
}
}

View File

@@ -2092,6 +2092,47 @@ speechSynthesis.getVoices();
});
});
API.getUserApiCurrentLocation = function () {
return this.currentUser?.presence?.world;
};
// TODO: traveling to world checks
API.actuallyGetCurrentLocation = async function () {
const gameLogLocation = $app.lastLocation.location;
console.log('gameLog Location', gameLogLocation);
const presence = this.currentUser.presence;
let presenceLocation = this.currentUser.$locationTag;
if (presenceLocation === 'traveling') {
console.log("User is traveling, using $travelingToLocation", this.currentUser.$travelingToLocation);
presenceLocation = this.currentUser.$travelingToLocation;
}
console.log('presence Location', presenceLocation);
// We want to use presence if it's valid to avoid extra API calls, but its prone to being outdated when this function is called.
// So we check if the presence location is the same as the gameLog location; If it is, the presence is (probably) valid and we can use it.
// If it's not, we need to get the user manually to get the correct location.
// If the user happens to be offline or the api is just being dumb, we assume that the user logged into VRCX is different than the one in-game and return the gameLog location.
// This is really dumb.
if (presenceLocation === gameLogLocation) {
console.log('ok presence return');
return presence.world;
}
let args = await this.getUser({ userId: this.currentUser.id });
let user = args.json
console.log('presence bad, got user', user);
if (!$app.isRealInstance(user.location)) {
console.warn(
'presence invalid, user offline and/or instance invalid. returning gamelog location: ',
gameLogLocation
);
return gameLogLocation;
}
console.warn('presence outdated, got user api location instead: ', user.location);
return this.parseLocation(user.location).worldId;
};
API.applyWorld = function (json) {
var ref = this.cachedWorlds.get(json.id);
if (typeof ref === 'undefined') {

BIN
lib/sqlite3.dll Normal file

Binary file not shown.