diff --git a/AppApi.cs b/AppApi.cs index 51d24f5f..654648d3 100644 --- a/AppApi.cs +++ b/AppApi.cs @@ -186,7 +186,7 @@ namespace VRCX } // 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) + if (MainForm.Instance?.Browser != null && !MainForm.Instance.Browser.IsLoading && MainForm.Instance.Browser.CanExecuteJavascriptInMainFrame) MainForm.Instance.Browser.ExecuteScriptAsync("$app.updateIsGameRunning", isGameRunning, isSteamVRRunning); } diff --git a/AutoAppLaunchManager.cs b/AutoAppLaunchManager.cs index c6d410a9..10946a5d 100644 --- a/AutoAppLaunchManager.cs +++ b/AutoAppLaunchManager.cs @@ -77,6 +77,8 @@ namespace VRCX if (KillChildrenOnExit) KillChildProcesses(); + else + UpdateChildProcesses(); } private void OnProcessStarted(MonitoredProcess monitoredProcess) @@ -86,6 +88,8 @@ namespace VRCX if (KillChildrenOnExit) KillChildProcesses(); + else + UpdateChildProcesses(); var shortcutFiles = FindShortcutFiles(AppShortcutDirectory); @@ -107,7 +111,7 @@ namespace VRCX { var process = pair.Value; - if (!process.HasExited) + if (!WinApi.HasProcessExited(process.Id)) { KillProcessTree(process.Id); //process.Kill(); @@ -137,6 +141,7 @@ namespace VRCX } // Gonna be honest, not gonna spin up a 32bit windows VM to make sure this works. but it should. + // Does VRCX even run on 32bit windows? PROCESSENTRY32 procEntry = new PROCESSENTRY32(); procEntry.dwSize = (uint)Marshal.SizeOf(typeof(PROCESSENTRY32)); @@ -182,7 +187,7 @@ namespace VRCX foreach (var pair in startedProcesses.ToList()) { var process = pair.Value; - if (process.HasExited) + if (WinApi.HasProcessExited(process.Id)) startedProcesses.Remove(pair.Key); } } diff --git a/ProcessMonitor.cs b/ProcessMonitor.cs index 1df87495..4547cc53 100644 --- a/ProcessMonitor.cs +++ b/ProcessMonitor.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Runtime.InteropServices; using System.Timers; namespace VRCX @@ -72,7 +73,7 @@ namespace VRCX if (monitoredProcess.IsRunning) { - if (monitoredProcess.Process == null || monitoredProcess.Process.HasExited) + if (monitoredProcess.Process == null || WinApi.HasProcessExited(monitoredProcess.Process.Id)) { monitoredProcess.ProcessExited(); ProcessExited?.Invoke(monitoredProcess); @@ -168,7 +169,7 @@ namespace VRCX Process = process; ProcessName = process.ProcessName.ToLower(); - if (!process.HasExited) + if (process != null && !WinApi.HasProcessExited(process.Id)) IsRunning = true; } diff --git a/WinApi.cs b/WinApi.cs index 641948a1..fbfa967a 100644 --- a/WinApi.cs +++ b/WinApi.cs @@ -23,5 +23,45 @@ namespace VRCX public static extern int SHParseDisplayName([MarshalAs(UnmanagedType.LPWStr)] string pszName, IntPtr pbc, out IntPtr ppidl, uint sfgaoIn, out uint psfgaoOut); [DllImport("shell32.dll", CharSet = CharSet.Auto)] public static extern IntPtr SHOpenFolderAndSelectItems(IntPtr pidlFolder, uint cidl, IntPtr[] apidl, uint dwFlags); + [DllImport("kernel32.dll", SetLastError = true)] + public static extern IntPtr OpenProcess(int dwDesiredAccess, bool bInheritHandle, int dwProcessId); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode); + + /// + /// Flag that specifies the access rights to query limited information about a process. + /// This won't throw an exception when we try to access info about an elevated process + /// + private const int PROCESS_QUERY_LIMITED_INFORMATION = 0x1000; + + /// + /// Determines whether the specified process has exited using WinAPI's GetExitCodeProcess running with PROCESS_QUERY_LIMITED_INFORMATION. + /// We do this because Process.HasExited in .net framework opens a handle with PROCESS_QUERY_INFORMATION, which will throw an exception if the process is elevated. + /// GetExitCodeProcess works with PROCESS_QUERY_LIMITED_INFORMATION, which will not throw an exception if the process is elevated. + /// https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-getexitcodeprocess + /// + /// The process to check. + /// true if the process has exited; otherwise, false. + internal static bool HasProcessExited(int processId) + { + IntPtr hProcess = WinApi.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, processId); + if (hProcess == IntPtr.Zero) + { + // this is probably fine + return true; + //throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); + } + + bool exited; + if (!WinApi.GetExitCodeProcess(hProcess, out uint exitCode)) + { + throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); + } + + // Fun fact, If a program uses STILL_ACTIVE (259) as an exit code, GetExitCodeProcess will return 259, since it returns... the exit code. This would break this function. + exited = exitCode != 259; + return exited; + } } } diff --git a/WorldDBManager.cs b/WorldDBManager.cs index 0a2948f9..6536f85f 100644 --- a/WorldDBManager.cs +++ b/WorldDBManager.cs @@ -29,11 +29,21 @@ namespace VRCX listener.Prefixes.Add(url); worldDB = new WorldDatabase(Path.Combine(Program.AppDataDirectory, "VRCX-WorldData.db")); + } public async Task Start() { - listener.Start(); + // typing this in vr gonna kms + try + { + listener.Start(); + } + catch (HttpListenerException e) + { + logger.Error(e, "Failed to start HTTP listener. Is VRCX already running?"); + return; + } logger.Info("Listening for requests on {0}", listener.Prefixes.First()); while (true) @@ -46,7 +56,7 @@ namespace VRCX { if (MainForm.Instance?.Browser == null || MainForm.Instance.Browser.IsLoading || !MainForm.Instance.Browser.CanExecuteJavascriptInMainFrame) { - logger.Warn("Received a request to {0} while VRCX is still initializing the browser window. Responding with error 503.", request.Url); + logger.Error("Received a request to {0} while VRCX is still initializing the browser window. Responding with error 503.", request.Url); responseData.Error = "VRCX not yet initialized. Try again in a moment."; responseData.StatusCode = 503; @@ -84,15 +94,15 @@ namespace VRCX responseData = await HandleBulkDataRequest(context); SendJsonResponse(context.Response, responseData); break; + case "/vrcx/data/settings": + responseData = await HandleSetSettingsRequest(context); + SendJsonResponse(context.Response, responseData); + break; case "/vrcx/status": // Send a blank 200 response to indicate that the server is running. context.Response.StatusCode = 200; context.Response.Close(); break; - case "/vrcx/data/settings": - responseData = await HandleSetSettingsRequest(context); - SendJsonResponse(context.Response, responseData); - break; default: responseData.Error = "Invalid VRCX endpoint."; responseData.StatusCode = 404; @@ -108,7 +118,7 @@ namespace VRCX } catch (Exception ex) { - logger.Error(ex, $"Exception while processing the url '{request.Url}'."); + logger.Error(ex, $"Exception while processing a request to endpoint '{request.Url}'."); responseData.Error = $"VRCX has encountered an exception while processing the url '{request.Url}': {ex.Message}"; responseData.StatusCode = 500; @@ -212,6 +222,23 @@ namespace VRCX return ConstructErrorResponse(500, "Failed to get/verify current world ID."); } + var worldOverride = request.QueryString["world"]; + if (worldOverride != null && worldId != worldOverride) + { + var allowed = worldDB.GetWorldAllowExternalRead(worldOverride); + if (!allowed) + { + return ConstructSuccessResponse(null, connectionKey); + } + + var otherValue = worldDB.GetDataEntry(worldOverride, key); + + logger.Debug("Serving a request for data with key '{0}' from world ID '{1}' requested by world ID '{2}' with connection key {3}.", key, worldOverride, worldId, connectionKey); + + // This value is intended to be null if the key doesn't exist. + return ConstructSuccessResponse(otherValue?.Value, connectionKey); + } + var value = worldDB.GetDataEntry(worldId, key); logger.Debug("Serving a request for data with key '{0}' from world ID '{1}' with connection key {2}.", key, worldId, connectionKey); @@ -227,8 +254,6 @@ namespace VRCX private async Task HandleAllDataRequest(HttpListenerContext context) { var request = context.Request; - - var worldId = await GetCurrentWorldID(); if (!TryInitializeWorld(worldId, out string connectionKey)) @@ -236,6 +261,27 @@ namespace VRCX return ConstructErrorResponse(500, "Failed to get/verify current world ID."); } + var worldOverride = request.QueryString["world"]; + if (worldOverride != null && worldId != worldOverride) + { + var allowed = worldDB.GetWorldAllowExternalRead(worldOverride); + if (!allowed) + { + return ConstructSuccessResponse(null, connectionKey); + } + + var otherEntries = worldDB.GetAllDataEntries(worldOverride); + + var otherData = new Dictionary(); + foreach (var entry in otherEntries) + { + otherData.Add(entry.Key, entry.Value); + } + + logger.Debug("Serving a request for all data ({0} entries) for world ID '{1}' requested by {2} with connection key {3}.", otherData.Count, worldOverride, worldId, connectionKey); + return ConstructSuccessResponse(JsonConvert.SerializeObject(otherData), connectionKey); + } + var entries = worldDB.GetAllDataEntries(worldId); var data = new Dictionary(); @@ -272,6 +318,27 @@ namespace VRCX return ConstructErrorResponse(500, "Failed to get/verify current world ID."); } + var worldOverride = request.QueryString["world"]; + if (worldOverride != null && worldId != worldOverride) + { + var allowed = worldDB.GetWorldAllowExternalRead(worldOverride); + if (!allowed) + { + return ConstructSuccessResponse(null, connectionKey); + } + + var otherEntries = worldDB.GetAllDataEntries(worldOverride); + + var otherData = new Dictionary(); + foreach (var entry in otherEntries) + { + otherData.Add(entry.Key, entry.Value); + } + + logger.Debug("Serving a request for all data ({0} entries) for world ID '{1}' requested by {2} with connection key {3}.", otherData.Count, worldOverride, worldId, connectionKey); + return ConstructSuccessResponse(JsonConvert.SerializeObject(otherData), connectionKey); + } + var values = worldDB.GetDataEntries(worldId, keyArray).ToList(); // 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. @@ -288,6 +355,12 @@ namespace VRCX return ConstructSuccessResponse(JsonConvert.SerializeObject(data), connectionKey); } + /// + /// Attempts to initialize a world with the given ID by generating a connection key and adding it to the world database if it does not already exist. + /// + /// The ID of the world to initialize. + /// The connection key generated for the world. + /// True if the world was successfully initialized, false otherwise. private bool TryInitializeWorld(string worldId, out string connectionKey) { if (String.IsNullOrEmpty(worldId)) @@ -476,73 +549,94 @@ namespace VRCX return; } - string requestType = request.RequestType.ToLower(); - - switch (requestType) + try { - case "store": - if (String.IsNullOrEmpty(request.Key)) - { - logger.Warn("World {0} tried to store data with no key provided", worldId); - this.lastError = "`key` is missing or null"; - return; - } + string requestType = request.RequestType.ToLower(); + switch (requestType) + { + case "store": + if (String.IsNullOrEmpty(request.Key)) + { + logger.Warn("World {0} tried to store data with no key provided", worldId); + this.lastError = "`key` is missing or null"; + return; + } - if (String.IsNullOrEmpty(request.Value)) - { - logger.Warn("World {0} tried to store data under key {1} with no value provided", worldId, request.Key); - this.lastError = "`value` is missing or null"; - return; - } + if (request.Key.Length > 255) + { + logger.Warn("World {0} tried to store data with a key that was too long ({1}/256 characters)", worldId, request.Key.Length); + this.lastError = "`key` is too long. Keep it below <256 characters."; + return; + } - StoreWorldData(worldId, request.Key, request.Value); - break; - case "delete": - if (String.IsNullOrEmpty(request.Key)) - { - logger.Warn("World {0} tried to delete data with no key provided", worldId); - this.lastError = "`key` is missing or null"; - return; - } + if (String.IsNullOrEmpty(request.Value)) + { + logger.Warn("World {0} tried to store data under key {1} with no value provided", worldId, request.Key); + this.lastError = "`value` is missing or null"; + return; + } - DeleteWorldData(worldId, request.Key); - break; - case "delete-all": - logger.Info("World {0} requested to delete all data.", worldId); - worldDB.DeleteAllDataEntriesForWorld(worldId); - break; - case "set-setting": - if (String.IsNullOrEmpty(request.Key)) - { - logger.Warn("World {0} tried to delete data with no key provided", worldId); - this.lastError = "`key` is missing or null"; - return; - } + StoreWorldData(worldId, request.Key, request.Value); - if (String.IsNullOrEmpty(request.Value)) - { - logger.Warn("World {0} tried to set settings with no value provided", worldId); - this.lastError = "`value` is missing or null"; - return; - } + break; + case "delete": + if (String.IsNullOrEmpty(request.Key)) + { + logger.Warn("World {0} tried to delete data with no key provided", worldId); + this.lastError = "`key` is missing or null"; + return; + } - SetWorldProperty(worldId, request.Key, request.Value); - break; - default: - logger.Warn("World {0} sent an invalid request type '{0}'", worldId, request.RequestType); - this.lastError = "Invalid request type"; - // invalid request type - return; + + DeleteWorldData(worldId, request.Key); + break; + case "delete-all": + + logger.Info("World {0} requested to delete all data.", worldId); + + + worldDB.DeleteAllDataEntriesForWorld(worldId); + worldDB.UpdateWorldDataSize(worldId, 0); + break; + case "set-setting": + if (String.IsNullOrEmpty(request.Key)) + { + logger.Warn("World {0} tried to delete data with no key provided", worldId); + this.lastError = "`key` is missing or null"; + return; + } + + if (String.IsNullOrEmpty(request.Value)) + { + logger.Warn("World {0} tried to set settings with no value provided", worldId); + this.lastError = "`value` is missing or null"; + return; + } + + SetWorldProperty(worldId, request.Key, request.Value); + break; + default: + logger.Warn("World {0} sent an invalid request type '{0}'", worldId, request.RequestType); + this.lastError = "Invalid request type"; + // invalid request type + return; + } + } + catch (Exception ex) + { + logger.Error(ex, "Failed to process world data request for world {0}", worldId); + logger.Error("Failed Request: {0}", json); + this.lastError = ex.Message; + return; } } - private void LogWarning(string message, params object[] args) - { - logger.Warn("World {0} - " + message, args); - this.lastError = String.Format(message, args); - } - - + /// + /// Sets a property for a given world in the world database. + /// + /// The ID of the world to set the property for. + /// The key of the property to set. + /// The value to set the property to. public void SetWorldProperty(string worldId, string key, string value) { switch (key) @@ -562,6 +656,12 @@ namespace VRCX } } + /// + /// Stores a data entry for a given world in the world database. + /// + /// The ID of the world to store the data entry for. + /// The key of the data entry to store. + /// The value of the data entry to store. public void StoreWorldData(string worldId, string key, string value) { // Get/calculate the old and new data sizes for this key/the world @@ -585,6 +685,11 @@ namespace VRCX logger.Debug("{0} : {1}", key, value); } + /// + /// Deletes a data entry for a given world from the world database. + /// + /// The ID of the world to delete the data entry from. + /// The key of the data entry to delete. public void DeleteWorldData(string worldId, string key) { int oldTotalDataSize = worldDB.GetWorldDataSize(worldId); diff --git a/WorldDatabase.cs b/WorldDatabase.cs index 2113c1f3..e6e746cd 100644 --- a/WorldDatabase.cs +++ b/WorldDatabase.cs @@ -164,6 +164,18 @@ END;"; sqlite.Execute("UPDATE worlds SET allow_external_read = ? WHERE world_id = ?", allowExternalRead, worldId); } + /// + /// Gets the value of the allow_external_read field for the world with the specified ID from the database. + /// + /// The ID of the world to get the allow_external_read field for. + /// The value of the allow_external_read field for the specified world. + public bool GetWorldAllowExternalRead(string worldId) + { + var query = sqlite.Table().Where(w => w.WorldId == worldId).Select(w => w.AllowExternalRead); + + return query.FirstOrDefault(); + } + /// /// Adds a new world to the database. /// @@ -266,7 +278,7 @@ END;"; /// An enumerable collection of all data entries for the world with the specified ID. public IEnumerable GetAllDataEntries(string worldId) { - var query = sqlite.Table().Where(w => w.WorldId == worldId); + var query = sqlite.Table().Where(w => w.WorldId == worldId).Take(10000); return query.ToList(); } @@ -283,11 +295,20 @@ END;"; return query.FirstOrDefault(); } + /// + /// Deletes the data entry with the specified world ID and key from the database. + /// + /// The ID of the world to delete the data entry from. + /// The key of the data entry to delete. public void DeleteDataEntry(string worldId, string key) { sqlite.Execute("DELETE FROM data WHERE world_id = ? AND key = ?", worldId, key); } + /// + /// Deletes all data entries for the world with the specified ID from the database. + /// + /// The ID of the world to delete all data entries for. public void DeleteAllDataEntriesForWorld(string worldId) { sqlite.Execute("DELETE FROM data WHERE world_id = ?", worldId);