mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-05 06:16:05 +02:00
Folder cs files
This commit is contained in:
+1262
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,287 @@
|
||||
// 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.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
public class AssetBundleCacher
|
||||
{
|
||||
public static readonly AssetBundleCacher Instance;
|
||||
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
|
||||
|
||||
static AssetBundleCacher()
|
||||
{
|
||||
Instance = new AssetBundleCacher();
|
||||
}
|
||||
|
||||
public static string DownloadTempLocation;
|
||||
public static string DownloadDestinationLocation;
|
||||
public static string DownloadHashLocation;
|
||||
public static int DownloadProgress;
|
||||
public static int DownloadSize;
|
||||
public static bool DownloadCanceled;
|
||||
public static WebClient client;
|
||||
public static Process process;
|
||||
|
||||
public string GetAssetId(string id)
|
||||
{
|
||||
using(var sha256 = SHA256.Create())
|
||||
{
|
||||
byte[] hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(id));
|
||||
StringBuilder idHex = new StringBuilder(hash.Length * 2);
|
||||
foreach (byte b in hash)
|
||||
{
|
||||
idHex.AppendFormat("{0:x2}", b);
|
||||
}
|
||||
return idHex.ToString().ToUpper().Substring(0, 16);
|
||||
}
|
||||
}
|
||||
|
||||
public string GetAssetVersion(int version)
|
||||
{
|
||||
byte[] bytes = BitConverter.GetBytes(version);
|
||||
string versionHex = string.Empty;
|
||||
foreach (byte b in bytes)
|
||||
{
|
||||
versionHex += b.ToString("X2");
|
||||
}
|
||||
return versionHex.PadLeft(32, '0');
|
||||
}
|
||||
|
||||
public string GetVRChatCacheLocation()
|
||||
{
|
||||
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();
|
||||
var idHash = GetAssetId(id);
|
||||
var versionLocation = GetAssetVersion(version);
|
||||
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>A Tuple containing the file size, lock status and path of the asset bundle.</returns>
|
||||
public Tuple<long, bool, string> CheckVRChatCache(string id, int version)
|
||||
{
|
||||
long fileSize = -1;
|
||||
var isLocked = false;
|
||||
var fullLocation = GetVRChatCacheFullLocation(id, version);
|
||||
var fileLocation = Path.Combine(fullLocation, "__data");
|
||||
var cachePath = string.Empty;
|
||||
if (File.Exists(fileLocation))
|
||||
{
|
||||
cachePath = fullLocation;
|
||||
FileInfo data = new FileInfo(fileLocation);
|
||||
fileSize = data.Length;
|
||||
}
|
||||
if (File.Exists(Path.Combine(fullLocation, "__lock")))
|
||||
{
|
||||
isLocked = true;
|
||||
}
|
||||
return new Tuple<long, bool, string>(fileSize, isLocked, cachePath);
|
||||
}
|
||||
|
||||
// old asset bundle cacher downloader method reused for updating, it's not pretty
|
||||
public void DownloadFile(string fileUrl, string hashUrl, int size)
|
||||
{
|
||||
client = new WebClient();
|
||||
client.Headers.Add("user-agent", Program.Version);
|
||||
DownloadProgress = 0;
|
||||
DownloadSize = size;
|
||||
DownloadCanceled = false;
|
||||
DownloadTempLocation = Path.Combine(Program.AppDataDirectory, "tempDownload.exe");
|
||||
DownloadDestinationLocation = Path.Combine(Program.AppDataDirectory, "update.exe");
|
||||
DownloadHashLocation = Path.Combine(Program.AppDataDirectory, "sha256sum.txt");
|
||||
if (File.Exists(DownloadHashLocation))
|
||||
File.Delete(DownloadHashLocation);
|
||||
if (!string.IsNullOrEmpty(hashUrl))
|
||||
client.DownloadFile(new Uri(hashUrl), DownloadHashLocation);
|
||||
|
||||
client.DownloadProgressChanged += new DownloadProgressChangedEventHandler(DownloadProgressCallback);
|
||||
client.DownloadFileCompleted += new AsyncCompletedEventHandler(DownloadCompletedCallback);
|
||||
client.DownloadFileAsync(new Uri(fileUrl), DownloadTempLocation);
|
||||
}
|
||||
|
||||
public void CancelDownload()
|
||||
{
|
||||
DownloadCanceled = true;
|
||||
try
|
||||
{
|
||||
client?.CancelAsync();
|
||||
if (File.Exists(DownloadTempLocation))
|
||||
File.Delete(DownloadTempLocation);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
DownloadProgress = -4;
|
||||
}
|
||||
|
||||
public int CheckDownloadProgress()
|
||||
{
|
||||
return DownloadProgress;
|
||||
}
|
||||
|
||||
private static void DownloadProgressCallback(object sender, DownloadProgressChangedEventArgs e)
|
||||
{
|
||||
DownloadProgress = e.ProgressPercentage;
|
||||
}
|
||||
|
||||
private static void DownloadCompletedCallback(object sender, AsyncCompletedEventArgs e)
|
||||
{
|
||||
if (DownloadCanceled)
|
||||
{
|
||||
if (File.Exists(DownloadTempLocation))
|
||||
File.Delete(DownloadTempLocation);
|
||||
return;
|
||||
}
|
||||
if (!File.Exists(DownloadTempLocation))
|
||||
{
|
||||
DownloadProgress = -15;
|
||||
return;
|
||||
}
|
||||
FileInfo data = new FileInfo(DownloadTempLocation);
|
||||
if (data.Length != DownloadSize)
|
||||
{
|
||||
File.Delete(DownloadTempLocation);
|
||||
DownloadProgress = -15;
|
||||
return;
|
||||
}
|
||||
if (File.Exists(DownloadHashLocation))
|
||||
{
|
||||
logger.Info("Updater: Checking hash");
|
||||
var lines = File.ReadAllLines(DownloadHashLocation);
|
||||
var hash = lines.Length > 0 ? lines[0].Split(' ') : new[] { "" };
|
||||
using (var sha256 = SHA256.Create())
|
||||
using (var stream = File.OpenRead(DownloadTempLocation))
|
||||
{
|
||||
var hashBytes = sha256.ComputeHash(stream);
|
||||
var hashString = BitConverter.ToString(hashBytes).Replace("-", "");
|
||||
if (!hashString.Equals(hash[0], StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logger.Error($"Updater: Hash check failed file:{hashString} remote:{hash[0]}");
|
||||
// can't delete file yet because it's in use
|
||||
DownloadProgress = -14;
|
||||
return;
|
||||
}
|
||||
}
|
||||
logger.Info("Updater: Hash check passed");
|
||||
}
|
||||
|
||||
if (File.Exists(DownloadDestinationLocation))
|
||||
File.Delete(DownloadDestinationLocation);
|
||||
File.Move(DownloadTempLocation, DownloadDestinationLocation);
|
||||
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);
|
||||
if (Directory.Exists(FullLocation))
|
||||
Directory.Delete(FullLocation, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the entire VRChat cache directory.
|
||||
/// </summary>
|
||||
public void DeleteAllCache()
|
||||
{
|
||||
var cachePath = GetVRChatCacheLocation();
|
||||
if (Directory.Exists(cachePath))
|
||||
{
|
||||
Directory.Delete(cachePath, true);
|
||||
Directory.CreateDirectory(cachePath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes empty directories from the VRChat cache directory and deletes old versions of cached asset bundles.
|
||||
/// </summary>
|
||||
public void SweepCache()
|
||||
{
|
||||
var cachePath = GetVRChatCacheLocation();
|
||||
if (!Directory.Exists(cachePath))
|
||||
return;
|
||||
var directories = new DirectoryInfo(cachePath);
|
||||
DirectoryInfo[] cacheDirectories = directories.GetDirectories();
|
||||
foreach (DirectoryInfo cacheDirectory in cacheDirectories)
|
||||
{
|
||||
var VersionDirectories = cacheDirectory.GetDirectories().OrderBy(d => Convert.ToInt32(d.Name, 16));
|
||||
int i = 0;
|
||||
foreach (DirectoryInfo VersionDirectory in VersionDirectories)
|
||||
{
|
||||
i++;
|
||||
if (VersionDirectory.GetDirectories().Length + VersionDirectory.GetFiles().Length == 0)
|
||||
{
|
||||
VersionDirectory.Delete();
|
||||
}
|
||||
else if (i < VersionDirectories.Count())
|
||||
{
|
||||
if (!File.Exists(Path.Combine(VersionDirectory.FullName, "__lock")))
|
||||
VersionDirectory.Delete(true);
|
||||
}
|
||||
}
|
||||
if (cacheDirectory.GetDirectories().Length + cacheDirectory.GetFiles().Length == 0)
|
||||
cacheDirectory.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
/// <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();
|
||||
|
||||
if (!Directory.Exists(cachePath)) return 0;
|
||||
|
||||
return DirSize(new DirectoryInfo(cachePath));
|
||||
}
|
||||
|
||||
|
||||
/// <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;
|
||||
FileInfo[] files = d.GetFiles("*.*", SearchOption.AllDirectories);
|
||||
foreach (FileInfo file in files)
|
||||
{
|
||||
size += file.Length;
|
||||
}
|
||||
return size;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Timers;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
/// <summary>
|
||||
/// The class responsible for launching user-defined applications when VRChat opens/closes.
|
||||
/// </summary>
|
||||
public class AutoAppLaunchManager
|
||||
{
|
||||
public static AutoAppLaunchManager Instance { get; private set; }
|
||||
public static readonly string VRChatProcessName = "VRChat";
|
||||
|
||||
public bool Enabled = false;
|
||||
/// <summary> Whether or not to kill child processes when VRChat closes. </summary>
|
||||
public bool KillChildrenOnExit = true;
|
||||
public readonly string AppShortcutDirectory;
|
||||
|
||||
private DateTime startTime = DateTime.Now;
|
||||
private Dictionary<string, HashSet<int>> startedProcesses = new Dictionary<string, HashSet<int>>();
|
||||
private readonly Timer childUpdateTimer;
|
||||
private int timerTicks = 0;
|
||||
private static readonly byte[] shortcutSignatureBytes = { 0x4C, 0x00, 0x00, 0x00 }; // signature for ShellLinkHeader
|
||||
|
||||
private const uint TH32CS_SNAPPROCESS = 2;
|
||||
|
||||
// Requires access rights 'PROCESS_QUERY_INFORMATION' and 'PROCESS_VM_READ'
|
||||
[DllImport("kernel32.dll")]
|
||||
public static extern IntPtr CreateToolhelp32Snapshot(uint dwFlags, uint th32ProcessID);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
public static extern bool Process32First(IntPtr hSnapshot, ref PROCESSENTRY32 lppe);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
public static extern bool Process32Next(IntPtr hSnapshot, ref PROCESSENTRY32 lppe);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct PROCESSENTRY32
|
||||
{
|
||||
public uint dwSize;
|
||||
public uint cntUsage;
|
||||
public uint th32ProcessID;
|
||||
public IntPtr th32DefaultHeapID;
|
||||
public uint th32ModuleID;
|
||||
public uint cntThreads;
|
||||
public uint th32ParentProcessID;
|
||||
public int pcPriClassBase;
|
||||
public uint dwFlags;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
|
||||
public string szExeFile;
|
||||
};
|
||||
|
||||
static AutoAppLaunchManager()
|
||||
{
|
||||
Instance = new AutoAppLaunchManager();
|
||||
}
|
||||
|
||||
public AutoAppLaunchManager()
|
||||
{
|
||||
AppShortcutDirectory = Path.Combine(Program.AppDataDirectory, "startup");
|
||||
|
||||
if (!Directory.Exists(AppShortcutDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(AppShortcutDirectory);
|
||||
}
|
||||
|
||||
ProcessMonitor.Instance.ProcessStarted += OnProcessStarted;
|
||||
ProcessMonitor.Instance.ProcessExited += OnProcessExited;
|
||||
|
||||
childUpdateTimer = new Timer();
|
||||
childUpdateTimer.Interval = 60000;
|
||||
childUpdateTimer.Elapsed += ChildUpdateTimer_Elapsed;
|
||||
}
|
||||
|
||||
private void OnProcessExited(MonitoredProcess monitoredProcess)
|
||||
{
|
||||
if (!monitoredProcess.HasName(VRChatProcessName))
|
||||
return;
|
||||
|
||||
lock (startedProcesses)
|
||||
{
|
||||
if (KillChildrenOnExit)
|
||||
{
|
||||
childUpdateTimer.Stop();
|
||||
|
||||
KillChildProcesses();
|
||||
}
|
||||
else
|
||||
UpdateChildProcesses();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnProcessStarted(MonitoredProcess monitoredProcess)
|
||||
{
|
||||
if (!Enabled || !monitoredProcess.HasName(VRChatProcessName) || monitoredProcess.Process.StartTime < startTime)
|
||||
return;
|
||||
|
||||
lock (startedProcesses)
|
||||
{
|
||||
if (KillChildrenOnExit)
|
||||
KillChildProcesses();
|
||||
else
|
||||
UpdateChildProcesses();
|
||||
|
||||
var shortcutFiles = FindShortcutFiles(AppShortcutDirectory);
|
||||
|
||||
foreach (var file in shortcutFiles)
|
||||
{
|
||||
if (!IsChildProcessRunning(file))
|
||||
StartChildProcess(file);
|
||||
}
|
||||
|
||||
if (shortcutFiles.Length == 0)
|
||||
return;
|
||||
|
||||
timerTicks = 0;
|
||||
childUpdateTimer.Interval = 1000;
|
||||
childUpdateTimer.Start();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kills all running child processes.
|
||||
/// </summary>
|
||||
internal void KillChildProcesses()
|
||||
{
|
||||
UpdateChildProcesses(); // Ensure the list contains all current child processes.
|
||||
|
||||
foreach (var pair in startedProcesses)
|
||||
{
|
||||
var processes = pair.Value;
|
||||
|
||||
foreach (var pid in processes)
|
||||
{
|
||||
if (!WinApi.HasProcessExited(pid))
|
||||
KillProcessTree(pid);
|
||||
}
|
||||
}
|
||||
|
||||
startedProcesses.Clear();
|
||||
}
|
||||
|
||||
// TODO: Proper error handling for winapi calls.
|
||||
// Will fail if process is protected, has admin rights, user account is limited(?), or process is dead
|
||||
// catch win32exceptions
|
||||
|
||||
// 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>
|
||||
/// Returns the child processes of a process.
|
||||
/// </summary>
|
||||
/// <param name="pid">The process ID of the parent process.</param>
|
||||
public static List<int> FindChildProcesses(int pid, bool recursive = true)
|
||||
{
|
||||
List<int> pids = new List<int>();
|
||||
|
||||
IntPtr snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
||||
if (snapshot == IntPtr.Zero)
|
||||
{
|
||||
return pids;
|
||||
}
|
||||
|
||||
// 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));
|
||||
|
||||
if (Process32First(snapshot, ref procEntry))
|
||||
{
|
||||
do
|
||||
{
|
||||
if (procEntry.th32ParentProcessID == pid)
|
||||
{
|
||||
pids.Add((int)procEntry.th32ProcessID);
|
||||
|
||||
if(recursive) // Recursively find child processes
|
||||
pids.AddRange(FindChildProcesses((int)procEntry.th32ProcessID));
|
||||
}
|
||||
}
|
||||
while (Process32Next(snapshot, ref procEntry));
|
||||
}
|
||||
|
||||
return pids;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
var pids = FindChildProcesses(pid);
|
||||
pids.Add(pid); // Kill parent
|
||||
|
||||
foreach (int p in pids)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (Process proc = Process.GetProcessById(p))
|
||||
proc.Kill();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a new child process.
|
||||
/// </summary>
|
||||
/// <param name="path">The path.</param>
|
||||
internal void StartChildProcess(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var process = Process.Start(path))
|
||||
if (process != null)
|
||||
startedProcesses.Add(path, new HashSet<int>() { process.Id });
|
||||
} catch { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the child processes list.
|
||||
/// Removes any processes that have exited.
|
||||
/// </summary>
|
||||
internal void UpdateChildProcesses()
|
||||
{
|
||||
foreach (var pair in startedProcesses.ToArray())
|
||||
{
|
||||
var processes = pair.Value;
|
||||
foreach (var pid in processes.ToArray())
|
||||
{
|
||||
bool recursiveChildSearch = processes.Count == 1; // Disable recursion when this list may already contain the entire process tree
|
||||
var childProcesses = FindChildProcesses(pid, recursiveChildSearch);
|
||||
|
||||
foreach (int childPid in childProcesses) // Monitor child processes
|
||||
processes.Add(childPid); // HashSet will prevent duplication
|
||||
|
||||
if (WinApi.HasProcessExited(pid))
|
||||
processes.Remove(pid);
|
||||
}
|
||||
|
||||
if (processes.Count == 0) // All processes associated with the shortcut have exited.
|
||||
startedProcesses.Remove(pair.Key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks to see if a given file matches a current running child process.
|
||||
/// </summary>
|
||||
/// <param name="path">The path.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> if child process running; otherwise, <c>false</c>.
|
||||
/// </returns>
|
||||
internal bool IsChildProcessRunning(string path)
|
||||
{
|
||||
return startedProcesses.ContainsKey(path);
|
||||
}
|
||||
|
||||
internal void Init()
|
||||
{
|
||||
// What are you lookin at? :eyes:
|
||||
}
|
||||
|
||||
internal void Exit()
|
||||
{
|
||||
childUpdateTimer.Stop();
|
||||
|
||||
Enabled = false;
|
||||
|
||||
// people thought this behavior was a bug
|
||||
// lock (startedProcesses)
|
||||
// KillChildProcesses();
|
||||
}
|
||||
|
||||
private void ChildUpdateTimer_Elapsed(object sender, ElapsedEventArgs e)
|
||||
{
|
||||
lock (startedProcesses)
|
||||
UpdateChildProcesses();
|
||||
|
||||
if (timerTicks < 5)
|
||||
{
|
||||
timerTicks++;
|
||||
|
||||
if(timerTicks == 5)
|
||||
childUpdateTimer.Interval = 60000;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds windows shortcut files in a given folder.
|
||||
/// </summary>
|
||||
/// <param name="folderPath">The folder path.</param>
|
||||
/// <returns>An array of shortcut paths. If none, then empty.</returns>
|
||||
private static string[] FindShortcutFiles(string folderPath)
|
||||
{
|
||||
DirectoryInfo directoryInfo = new DirectoryInfo(folderPath);
|
||||
FileInfo[] files = directoryInfo.GetFiles();
|
||||
List<string> ret = new List<string>();
|
||||
|
||||
foreach (FileInfo file in files)
|
||||
{
|
||||
if (IsShortcutFile(file.FullName))
|
||||
{
|
||||
ret.Add(file.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
return ret.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the specified file path is a shortcut by checking the file header.
|
||||
/// </summary>
|
||||
/// <param name="filePath">The file path.</param>
|
||||
/// <returns><c>true</c> if the given file path is a shortcut, otherwise <c>false</c></returns>
|
||||
private static bool IsShortcutFile(string filePath)
|
||||
{
|
||||
byte[] headerBytes = new byte[4];
|
||||
using (FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
{
|
||||
if (fileStream.Length >= 4)
|
||||
{
|
||||
fileStream.Read(headerBytes, 0, 4);
|
||||
}
|
||||
}
|
||||
|
||||
return headerBytes.SequenceEqual(shortcutSignatureBytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// 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 CefSharp;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
public class CustomDownloadHandler : IDownloadHandler
|
||||
{
|
||||
public bool CanDownload(IWebBrowser chromiumWebBrowser, IBrowser browser, string url, string requestMethod)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public void OnBeforeDownload(IWebBrowser chromiumWebBrowser, IBrowser browser, DownloadItem downloadItem, IBeforeDownloadCallback callback)
|
||||
{
|
||||
if (callback.IsDisposed)
|
||||
return;
|
||||
|
||||
using (callback)
|
||||
{
|
||||
callback.Continue(
|
||||
downloadItem.SuggestedFileName,
|
||||
showDialog: true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnDownloadUpdated(IWebBrowser chromiumWebBrowser, IBrowser browser, DownloadItem downloadItem, IDownloadItemCallback callback)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using CefSharp.Enums;
|
||||
using CefSharp;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
/// <summary>
|
||||
/// This class is used to 'handle' drag and drop events.
|
||||
/// All it does is call a function in the app with the file name of the file being dragged into the window, since chromium doesn't have access to the full file path on regular drop events.
|
||||
/// </summary>
|
||||
public class CustomDragHandler : IDragHandler
|
||||
{
|
||||
public bool OnDragEnter(IWebBrowser chromiumWebBrowser, IBrowser browser, IDragData dragData, DragOperationsMask mask)
|
||||
{
|
||||
if (dragData.IsFile && dragData.FileNames != null && dragData.FileNames.Count > 0)
|
||||
{
|
||||
string file = dragData.FileNames[0];
|
||||
if (!file.EndsWith(".png") && !file.EndsWith(".jpg") && !file.EndsWith(".jpeg"))
|
||||
{
|
||||
dragData.Dispose();
|
||||
return true;
|
||||
}
|
||||
|
||||
// forgive me father for i have sinned once again
|
||||
AppApi.Instance.ExecuteAppFunction("dragEnterCef", dragData.FileNames[0]);
|
||||
dragData.Dispose();
|
||||
return false;
|
||||
}
|
||||
|
||||
dragData.Dispose();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void OnDraggableRegionsChanged(IWebBrowser chromiumWebBrowser, IBrowser browser, IFrame frame, IList<DraggableRegion> regions)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright(c) 2019 pypy. All rights reserved.
|
||||
//
|
||||
// This work is licensed under the terms of the MIT license.
|
||||
// For a copy, see <https://opensource.org/licenses/MIT>.
|
||||
|
||||
using CefSharp;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
public class CustomMenuHandler : IContextMenuHandler
|
||||
{
|
||||
public void OnBeforeContextMenu(IWebBrowser browserControl, IBrowser browser, IFrame frame, IContextMenuParams parameters, IMenuModel model)
|
||||
{
|
||||
// remove default right click
|
||||
if (!parameters.TypeFlags.HasFlag(ContextMenuType.Selection) && !parameters.TypeFlags.HasFlag(ContextMenuType.Editable))
|
||||
model.Clear();
|
||||
}
|
||||
|
||||
public bool OnContextMenuCommand(IWebBrowser browserControl, IBrowser browser, IFrame frame, IContextMenuParams parameters, CefMenuCommand commandId, CefEventFlags eventFlags)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public void OnContextMenuDismissed(IWebBrowser browserControl, IBrowser browser, IFrame frame)
|
||||
{
|
||||
}
|
||||
|
||||
public bool RunContextMenu(IWebBrowser browserControl, IBrowser browser, IFrame frame, IContextMenuParams parameters, IMenuModel model, IRunContextMenuCallback callback)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// 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.Collections.Generic;
|
||||
using CefSharp;
|
||||
using CefSharp.Enums;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
public class CefNoopDragHandler : IDragHandler
|
||||
{
|
||||
bool IDragHandler.OnDragEnter(IWebBrowser chromiumWebBrowser, IBrowser browser, IDragData dragData, DragOperationsMask mask)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
void IDragHandler.OnDraggableRegionsChanged(IWebBrowser chromiumWebBrowser, IBrowser browser, IFrame frame, IList<DraggableRegion> regions)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using CefSharp;
|
||||
using CefSharp.SchemeHandler;
|
||||
using CefSharp.WinForms;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
public class CefService
|
||||
{
|
||||
public static readonly CefService Instance;
|
||||
|
||||
static CefService()
|
||||
{
|
||||
Instance = new CefService();
|
||||
}
|
||||
|
||||
internal void Init()
|
||||
{
|
||||
var userDataDir = Path.Combine(Program.AppDataDirectory, "userdata");
|
||||
var cefSettings = new CefSettings
|
||||
{
|
||||
RootCachePath = userDataDir,
|
||||
CachePath = Path.Combine(userDataDir, "cache"),
|
||||
LogSeverity = LogSeverity.Disable,
|
||||
WindowlessRenderingEnabled = true,
|
||||
PersistSessionCookies = true,
|
||||
PersistUserPreferences = true,
|
||||
UserAgent = Program.Version
|
||||
};
|
||||
|
||||
cefSettings.RegisterScheme(new CefCustomScheme
|
||||
{
|
||||
SchemeName = "file",
|
||||
DomainName = "vrcx",
|
||||
SchemeHandlerFactory = new FolderSchemeHandlerFactory(
|
||||
Path.Combine(Program.BaseDirectory, "html"),
|
||||
"file",
|
||||
defaultPage: "index.html"
|
||||
),
|
||||
IsLocal = true
|
||||
});
|
||||
|
||||
// cefSettings.CefCommandLineArgs.Add("allow-universal-access-from-files");
|
||||
// cefSettings.CefCommandLineArgs.Add("ignore-certificate-errors");
|
||||
// cefSettings.CefCommandLineArgs.Add("disable-plugins");
|
||||
cefSettings.CefCommandLineArgs.Add("disable-spell-checking");
|
||||
cefSettings.CefCommandLineArgs.Add("disable-pdf-extension");
|
||||
cefSettings.CefCommandLineArgs["autoplay-policy"] = "no-user-gesture-required";
|
||||
cefSettings.CefCommandLineArgs.Add("disable-web-security");
|
||||
cefSettings.SetOffScreenRenderingBestPerformanceArgs(); // causes white screen sometimes?
|
||||
|
||||
if (Program.LaunchDebug)
|
||||
{
|
||||
cefSettings.RemoteDebuggingPort = 8088;
|
||||
cefSettings.CefCommandLineArgs["remote-allow-origins"] = "*";
|
||||
}
|
||||
|
||||
CefSharpSettings.WcfEnabled = true; // TOOD: REMOVE THIS LINE YO (needed for synchronous configRepository)
|
||||
CefSharpSettings.ShutdownOnExit = false;
|
||||
|
||||
if (Cef.Initialize(cefSettings) == false)
|
||||
{
|
||||
throw new Exception("Cef.Initialize()");
|
||||
}
|
||||
}
|
||||
|
||||
internal void Exit()
|
||||
{
|
||||
Cef.Shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// 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.Diagnostics;
|
||||
using System.Threading;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
public class CpuMonitor
|
||||
{
|
||||
public static readonly CpuMonitor Instance;
|
||||
public float CpuUsage;
|
||||
private readonly PerformanceCounter _performanceCounter;
|
||||
private readonly Timer _timer;
|
||||
|
||||
static CpuMonitor()
|
||||
{
|
||||
Instance = new CpuMonitor();
|
||||
}
|
||||
|
||||
public CpuMonitor()
|
||||
{
|
||||
try
|
||||
{
|
||||
_performanceCounter = new PerformanceCounter(
|
||||
"Processor Information",
|
||||
"% Processor Utility",
|
||||
"_Total",
|
||||
true
|
||||
);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
// fallback
|
||||
if (_performanceCounter == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_performanceCounter = new PerformanceCounter(
|
||||
"Processor",
|
||||
"% Processor Time",
|
||||
"_Total",
|
||||
true
|
||||
);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
_timer = new Timer(TimerCallback, null, -1, -1);
|
||||
}
|
||||
|
||||
internal void Init()
|
||||
{
|
||||
_timer.Change(1000, 1000);
|
||||
}
|
||||
|
||||
internal void Exit()
|
||||
{
|
||||
lock (this)
|
||||
{
|
||||
_timer.Change(-1, -1);
|
||||
_performanceCounter?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void TimerCallback(object state)
|
||||
{
|
||||
lock (this)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_performanceCounter != null)
|
||||
{
|
||||
CpuUsage = _performanceCounter.NextValue();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// 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 DiscordRPC;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
public class Discord
|
||||
{
|
||||
public static readonly Discord Instance;
|
||||
private readonly ReaderWriterLockSlim m_Lock;
|
||||
private readonly RichPresence m_Presence;
|
||||
private DiscordRpcClient m_Client;
|
||||
private readonly Timer m_Timer;
|
||||
private bool m_Active;
|
||||
public static string DiscordAppId;
|
||||
|
||||
static Discord()
|
||||
{
|
||||
Instance = new Discord();
|
||||
}
|
||||
|
||||
public Discord()
|
||||
{
|
||||
m_Lock = new ReaderWriterLockSlim();
|
||||
m_Presence = new RichPresence();
|
||||
m_Timer = new Timer(TimerCallback, null, -1, -1);
|
||||
}
|
||||
|
||||
internal void Init()
|
||||
{
|
||||
m_Timer.Change(0, 1000);
|
||||
}
|
||||
|
||||
internal void Exit()
|
||||
{
|
||||
lock (this)
|
||||
{
|
||||
m_Timer.Change(-1, -1);
|
||||
m_Client?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void TimerCallback(object state)
|
||||
{
|
||||
lock (this)
|
||||
{
|
||||
try
|
||||
{
|
||||
Update();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (m_Client == null && m_Active)
|
||||
{
|
||||
m_Client = new DiscordRpcClient(DiscordAppId);
|
||||
if (m_Client.Initialize() == false)
|
||||
{
|
||||
m_Client.Dispose();
|
||||
m_Client = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (m_Client != null && !m_Active)
|
||||
{
|
||||
m_Client.Dispose();
|
||||
m_Client = null;
|
||||
}
|
||||
|
||||
if (m_Client != null && !m_Lock.IsWriteLockHeld)
|
||||
{
|
||||
m_Lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
m_Client.SetPresence(m_Presence);
|
||||
}
|
||||
finally
|
||||
{
|
||||
m_Lock.ExitReadLock();
|
||||
}
|
||||
m_Client.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
public bool SetActive(bool active)
|
||||
{
|
||||
m_Active = active;
|
||||
return m_Active;
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/questions/1225052/best-way-to-shorten-utf8-string-based-on-byte-length
|
||||
private static string LimitByteLength(string str, int maxBytesLength)
|
||||
{
|
||||
if (str == null)
|
||||
return string.Empty;
|
||||
var bytesArr = Encoding.UTF8.GetBytes(str);
|
||||
var bytesToRemove = 0;
|
||||
var lastIndexInString = str.Length - 1;
|
||||
while (bytesArr.Length - bytesToRemove > maxBytesLength)
|
||||
{
|
||||
bytesToRemove += Encoding.UTF8.GetByteCount(new char[] { str[lastIndexInString] });
|
||||
--lastIndexInString;
|
||||
}
|
||||
return Encoding.UTF8.GetString(bytesArr, 0, bytesArr.Length - bytesToRemove);
|
||||
}
|
||||
|
||||
public void SetText(string details, string state)
|
||||
{
|
||||
if (m_Client != null && !m_Lock.IsReadLockHeld)
|
||||
{
|
||||
m_Lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
m_Presence.Details = LimitByteLength(details, 127);
|
||||
m_Presence.State = LimitByteLength(state, 127);
|
||||
}
|
||||
finally
|
||||
{
|
||||
m_Lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void SetAssets(string largeKey, string largeText, string smallKey, string smallText, string partyId, int partySize, int partyMax, string buttonText, string buttonUrl, string appId)
|
||||
{
|
||||
m_Lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(largeKey) == true &&
|
||||
string.IsNullOrEmpty(smallKey) == true)
|
||||
{
|
||||
m_Presence.Assets = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (m_Presence.Assets == null)
|
||||
m_Presence.Assets = new Assets();
|
||||
if (m_Presence.Party == null)
|
||||
m_Presence.Party = new Party();
|
||||
m_Presence.Assets.LargeImageKey = largeKey;
|
||||
m_Presence.Assets.LargeImageText = largeText;
|
||||
m_Presence.Assets.SmallImageKey = smallKey;
|
||||
m_Presence.Assets.SmallImageText = smallText;
|
||||
m_Presence.Party.ID = partyId;
|
||||
m_Presence.Party.Size = partySize;
|
||||
m_Presence.Party.Max = partyMax;
|
||||
Button[] Buttons = { };
|
||||
if (!string.IsNullOrEmpty(buttonUrl))
|
||||
{
|
||||
Buttons = new Button[]
|
||||
{
|
||||
new Button() { Label = buttonText, Url = buttonUrl }
|
||||
};
|
||||
}
|
||||
m_Presence.Buttons = Buttons;
|
||||
if (DiscordAppId != appId)
|
||||
{
|
||||
DiscordAppId = appId;
|
||||
if (m_Client != null)
|
||||
{
|
||||
m_Client.Dispose();
|
||||
m_Client = null;
|
||||
}
|
||||
Update();
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
m_Lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetTimestamps(double startUnixMilliseconds, double endUnixMilliseconds)
|
||||
{
|
||||
var _startUnixMilliseconds = (ulong)startUnixMilliseconds;
|
||||
var _endUnixMilliseconds = (ulong)endUnixMilliseconds;
|
||||
|
||||
m_Lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
if (_startUnixMilliseconds == 0)
|
||||
{
|
||||
m_Presence.Timestamps = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (m_Presence.Timestamps == null)
|
||||
{
|
||||
m_Presence.Timestamps = new Timestamps();
|
||||
}
|
||||
|
||||
m_Presence.Timestamps.StartUnixMilliseconds = _startUnixMilliseconds;
|
||||
|
||||
if (_endUnixMilliseconds == 0)
|
||||
{
|
||||
m_Presence.Timestamps.End = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
m_Presence.Timestamps.EndUnixMilliseconds = _endUnixMilliseconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
m_Lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// 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.Globalization;
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using CefSharp;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
internal class IPCClient
|
||||
{
|
||||
private static readonly UTF8Encoding noBomEncoding = new UTF8Encoding(false, false);
|
||||
private readonly NamedPipeServerStream _ipcServer;
|
||||
private readonly byte[] _recvBuffer = new byte[1024 * 8];
|
||||
private readonly MemoryStream memoryStream;
|
||||
private readonly byte[] packetBuffer = new byte[1024 * 1024];
|
||||
private readonly Newtonsoft.Json.JsonSerializer serializer = new Newtonsoft.Json.JsonSerializer();
|
||||
private string _currentPacket;
|
||||
|
||||
public IPCClient(NamedPipeServerStream ipcServer)
|
||||
{
|
||||
memoryStream = new MemoryStream(packetBuffer);
|
||||
serializer.Culture = CultureInfo.InvariantCulture;
|
||||
serializer.Formatting = Formatting.None;
|
||||
|
||||
_ipcServer = ipcServer;
|
||||
}
|
||||
|
||||
public void BeginRead()
|
||||
{
|
||||
_ipcServer.BeginRead(_recvBuffer, 0, _recvBuffer.Length, OnRead, _ipcServer);
|
||||
}
|
||||
|
||||
public void Send(IPCPacket ipcPacket)
|
||||
{
|
||||
try
|
||||
{
|
||||
memoryStream.Seek(0, SeekOrigin.Begin);
|
||||
using (var streamWriter = new StreamWriter(memoryStream, noBomEncoding, 65535, true))
|
||||
using (var writer = new JsonTextWriter(streamWriter))
|
||||
{
|
||||
serializer.Serialize(writer, ipcPacket);
|
||||
streamWriter.Write((char)0x00);
|
||||
streamWriter.Flush();
|
||||
}
|
||||
|
||||
var length = (int)memoryStream.Position;
|
||||
_ipcServer?.BeginWrite(packetBuffer, 0, length, OnSend, null);
|
||||
}
|
||||
catch
|
||||
{
|
||||
IPCServer.Clients.Remove(this);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRead(IAsyncResult asyncResult)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytesRead = _ipcServer.EndRead(asyncResult);
|
||||
|
||||
if (bytesRead <= 0)
|
||||
{
|
||||
IPCServer.Clients.Remove(this);
|
||||
_ipcServer.Close();
|
||||
return;
|
||||
}
|
||||
|
||||
_currentPacket += Encoding.UTF8.GetString(_recvBuffer, 0, bytesRead);
|
||||
|
||||
if (_currentPacket[_currentPacket.Length - 1] == (char)0x00)
|
||||
{
|
||||
var packets = _currentPacket.Split((char)0x00);
|
||||
|
||||
foreach (var packet in packets)
|
||||
{
|
||||
if (string.IsNullOrEmpty(packet))
|
||||
continue;
|
||||
|
||||
MainForm.Instance.Browser.ExecuteScriptAsync("$app.ipcEvent", packet);
|
||||
}
|
||||
|
||||
_currentPacket = string.Empty;
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine(e);
|
||||
}
|
||||
|
||||
BeginRead();
|
||||
}
|
||||
|
||||
public static void OnSend(IAsyncResult asyncResult)
|
||||
{
|
||||
var ipcClient = (NamedPipeClientStream)asyncResult.AsyncState;
|
||||
ipcClient?.EndWrite(asyncResult);
|
||||
}
|
||||
|
||||
public static void Close(IAsyncResult asyncResult)
|
||||
{
|
||||
var ipcClient = (NamedPipeClientStream)asyncResult.AsyncState;
|
||||
ipcClient?.EndWrite(asyncResult);
|
||||
ipcClient?.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace VRCX
|
||||
{
|
||||
public class IPCPacket
|
||||
{
|
||||
public string Type { get; set; }
|
||||
public string Data { get; set; }
|
||||
public string MsgType { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// 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.IO.Pipes;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
internal class IPCServer
|
||||
{
|
||||
public static readonly IPCServer Instance;
|
||||
public static readonly List<IPCClient> Clients = new List<IPCClient>();
|
||||
|
||||
static IPCServer()
|
||||
{
|
||||
Instance = new IPCServer();
|
||||
}
|
||||
|
||||
public void Init()
|
||||
{
|
||||
new IPCServer().CreateIPCServer();
|
||||
}
|
||||
|
||||
public static void Send(IPCPacket ipcPacket)
|
||||
{
|
||||
foreach (var client in Clients)
|
||||
{
|
||||
client.Send(ipcPacket);
|
||||
}
|
||||
}
|
||||
|
||||
public void CreateIPCServer()
|
||||
{
|
||||
var ipcServer = new NamedPipeServerStream("vrcx-ipc", PipeDirection.InOut, NamedPipeServerStream.MaxAllowedServerInstances, PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
|
||||
ipcServer.BeginWaitForConnection(DoAccept, ipcServer);
|
||||
}
|
||||
|
||||
private void DoAccept(IAsyncResult asyncResult)
|
||||
{
|
||||
var ipcServer = (NamedPipeServerStream)asyncResult.AsyncState;
|
||||
|
||||
try
|
||||
{
|
||||
ipcServer.EndWaitForConnection(asyncResult);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine(e);
|
||||
}
|
||||
|
||||
var ipcClient = new IPCClient(ipcServer);
|
||||
Clients.Add(ipcClient);
|
||||
ipcClient.BeginRead();
|
||||
CreateIPCServer();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Linq;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
class ImageCache
|
||||
{
|
||||
private static readonly string cacheLocation = Path.Combine(Program.AppDataDirectory, "ImageCache");
|
||||
private static readonly WebClient webClient = new WebClient();
|
||||
|
||||
private const string IMAGE_HOST1 = "api.vrchat.cloud";
|
||||
private const string IMAGE_HOST2 = "files.vrchat.cloud";
|
||||
private const string IMAGE_HOST3 = "d348imysud55la.cloudfront.net";
|
||||
|
||||
public static string GetImage(string url, string fileId, string version)
|
||||
{
|
||||
var directoryLocation = Path.Combine(cacheLocation, fileId);
|
||||
var fileLocation = Path.Combine(directoryLocation, $"{version}.png");
|
||||
|
||||
if (File.Exists(fileLocation))
|
||||
{
|
||||
Directory.SetLastWriteTimeUtc(directoryLocation, DateTime.UtcNow);
|
||||
return fileLocation;
|
||||
}
|
||||
|
||||
if (Directory.Exists(directoryLocation))
|
||||
Directory.Delete(directoryLocation, true);
|
||||
Directory.CreateDirectory(directoryLocation);
|
||||
|
||||
Uri uri = new Uri(url);
|
||||
if (uri.Host != IMAGE_HOST1 && uri.Host != IMAGE_HOST2 && uri.Host != IMAGE_HOST3)
|
||||
throw new ArgumentException("Invalid image host", url);
|
||||
|
||||
string cookieString = string.Empty;
|
||||
if (WebApi.Instance != null && WebApi.Instance._cookieContainer != null)
|
||||
{
|
||||
CookieCollection cookies = WebApi.Instance._cookieContainer.GetCookies(new Uri($"https://{IMAGE_HOST1}"));
|
||||
foreach (Cookie cookie in cookies)
|
||||
cookieString += $"{cookie.Name}={cookie.Value};";
|
||||
}
|
||||
|
||||
webClient.Headers.Add(HttpRequestHeader.Cookie, cookieString);
|
||||
webClient.Headers.Add("user-agent", Program.Version);
|
||||
webClient.DownloadFile(url, fileLocation);
|
||||
|
||||
int cacheSize = Directory.GetDirectories(cacheLocation).Length;
|
||||
if (cacheSize > 1100)
|
||||
CleanImageCache();
|
||||
|
||||
return fileLocation;
|
||||
}
|
||||
|
||||
private static void CleanImageCache()
|
||||
{
|
||||
DirectoryInfo dirInfo = new DirectoryInfo(cacheLocation);
|
||||
var folders = dirInfo.GetDirectories().OrderByDescending(p => p.LastWriteTime).Skip(1000);
|
||||
foreach (DirectoryInfo folder in folders)
|
||||
{
|
||||
folder.Delete(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// 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 Newtonsoft.Json;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
public static class JsonSerializer
|
||||
{
|
||||
public static void Serialize<T>(string path, T obj)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var file = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.ReadWrite))
|
||||
using (var stream = new StreamWriter(file, Encoding.UTF8))
|
||||
using (var writer = new JsonTextWriter(stream))
|
||||
{
|
||||
var serializer = Newtonsoft.Json.JsonSerializer.CreateDefault();
|
||||
serializer.Formatting = Formatting.Indented;
|
||||
serializer.Serialize(writer, obj, typeof(T));
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public static bool Deserialize<T>(string path, ref T obj) where T : new()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var file = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
using (var stream = new StreamReader(file, Encoding.UTF8))
|
||||
using (var reader = new JsonTextReader(stream))
|
||||
{
|
||||
var o = Newtonsoft.Json.JsonSerializer.CreateDefault().Deserialize<T>(reader);
|
||||
if (o == null)
|
||||
{
|
||||
o = new T();
|
||||
}
|
||||
obj = o;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+117
@@ -0,0 +1,117 @@
|
||||
// Copyright(c) 2019 pypy. All rights reserved.
|
||||
//
|
||||
// This work is licensed under the terms of the MIT license.
|
||||
// For a copy, see <https://opensource.org/licenses/MIT>.
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
partial class MainForm
|
||||
{
|
||||
/// <summary>
|
||||
/// 필수 디자이너 변수입니다.
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// 사용 중인 모든 리소스를 정리합니다.
|
||||
/// </summary>
|
||||
/// <param name="disposing">관리되는 리소스를 삭제해야 하면 true이고, 그렇지 않으면 false입니다.</param>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows Form 디자이너에서 생성한 코드
|
||||
|
||||
/// <summary>
|
||||
/// 디자이너 지원에 필요한 메서드입니다.
|
||||
/// 이 메서드의 내용을 코드 편집기로 수정하지 마세요.
|
||||
/// </summary>
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.components = new System.ComponentModel.Container();
|
||||
this.TrayMenu = new System.Windows.Forms.ContextMenuStrip(this.components);
|
||||
this.TrayMenu_Open = new System.Windows.Forms.ToolStripMenuItem();
|
||||
this.TrayMenu_DevTools = new System.Windows.Forms.ToolStripMenuItem();
|
||||
this.TrayMenu_Separator = new System.Windows.Forms.ToolStripSeparator();
|
||||
this.TrayMenu_Quit = new System.Windows.Forms.ToolStripMenuItem();
|
||||
this.TrayIcon = new System.Windows.Forms.NotifyIcon(this.components);
|
||||
this.TrayMenu.SuspendLayout();
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// TrayMenu
|
||||
//
|
||||
this.TrayMenu.Items.AddRange(new System.Windows.Forms.ToolStripItem[] {
|
||||
this.TrayMenu_Open,
|
||||
this.TrayMenu_DevTools,
|
||||
this.TrayMenu_Separator,
|
||||
this.TrayMenu_Quit});
|
||||
this.TrayMenu.Name = "TrayMenu";
|
||||
this.TrayMenu.Size = new System.Drawing.Size(132, 54);
|
||||
//
|
||||
// TrayMenu_Open
|
||||
//
|
||||
this.TrayMenu_Open.Name = "TrayMenu_Open";
|
||||
this.TrayMenu_Open.Size = new System.Drawing.Size(131, 22);
|
||||
this.TrayMenu_Open.Text = "Open";
|
||||
this.TrayMenu_Open.Click += new System.EventHandler(this.TrayMenu_Open_Click);
|
||||
//
|
||||
// TrayMenu_DevTools
|
||||
//
|
||||
this.TrayMenu_DevTools.Name = "TrayMenu_DevTools";
|
||||
this.TrayMenu_DevTools.Size = new System.Drawing.Size(131, 22);
|
||||
this.TrayMenu_DevTools.Text = "DevTools";
|
||||
this.TrayMenu_DevTools.Click += new System.EventHandler(this.TrayMenu_DevTools_Click);
|
||||
//
|
||||
// TrayMenu_Separator
|
||||
//
|
||||
this.TrayMenu_Separator.Name = "TrayMenu_Separator";
|
||||
this.TrayMenu_Separator.Size = new System.Drawing.Size(128, 6);
|
||||
//
|
||||
// TrayMenu_Quit
|
||||
//
|
||||
this.TrayMenu_Quit.Name = "TrayMenu_Quit";
|
||||
this.TrayMenu_Quit.Size = new System.Drawing.Size(131, 22);
|
||||
this.TrayMenu_Quit.Text = "Quit VRCX";
|
||||
this.TrayMenu_Quit.Click += new System.EventHandler(this.TrayMenu_Quit_Click);
|
||||
//
|
||||
// TrayIcon
|
||||
//
|
||||
this.TrayIcon.ContextMenuStrip = this.TrayMenu;
|
||||
this.TrayIcon.Text = "VRCX";
|
||||
this.TrayIcon.Visible = true;
|
||||
this.TrayIcon.MouseClick += new System.Windows.Forms.MouseEventHandler(this.TrayIcon_MouseClick);
|
||||
//
|
||||
// MainForm
|
||||
//
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi;
|
||||
this.ClientSize = new System.Drawing.Size(842, 561);
|
||||
this.MinimumSize = new System.Drawing.Size(320, 240);
|
||||
this.Name = "MainForm";
|
||||
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen;
|
||||
this.Text = Program.Version;
|
||||
this.FormClosing += new System.Windows.Forms.FormClosingEventHandler(this.MainForm_FormClosing);
|
||||
this.FormClosed += new System.Windows.Forms.FormClosedEventHandler(this.MainForm_FormClosed);
|
||||
this.Load += new System.EventHandler(this.MainForm_Load);
|
||||
this.Move += new System.EventHandler(this.MainForm_Move);
|
||||
this.Resize += new System.EventHandler(this.MainForm_Resize);
|
||||
this.TrayMenu.ResumeLayout(false);
|
||||
this.ResumeLayout(false);
|
||||
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private System.Windows.Forms.ContextMenuStrip TrayMenu;
|
||||
private System.Windows.Forms.ToolStripMenuItem TrayMenu_Open;
|
||||
private System.Windows.Forms.ToolStripMenuItem TrayMenu_DevTools;
|
||||
private System.Windows.Forms.ToolStripSeparator TrayMenu_Separator;
|
||||
private System.Windows.Forms.ToolStripMenuItem TrayMenu_Quit;
|
||||
private System.Windows.Forms.NotifyIcon TrayIcon;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// 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.Drawing;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Windows.Forms;
|
||||
using CefSharp;
|
||||
using CefSharp.WinForms;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
public partial class MainForm : WinformBase
|
||||
{
|
||||
public static MainForm Instance;
|
||||
private static NLog.Logger jslogger = NLog.LogManager.GetLogger("Javascript");
|
||||
public ChromiumWebBrowser Browser;
|
||||
private int LastLocationX;
|
||||
private int LastLocationY;
|
||||
private int LastSizeWidth;
|
||||
private int LastSizeHeight;
|
||||
|
||||
public MainForm()
|
||||
{
|
||||
Instance = this;
|
||||
InitializeComponent();
|
||||
|
||||
try
|
||||
{
|
||||
var location = Assembly.GetExecutingAssembly().Location;
|
||||
var icon = Icon.ExtractAssociatedIcon(location);
|
||||
Icon = icon;
|
||||
TrayIcon.Icon = icon;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
Browser = new ChromiumWebBrowser("file://vrcx/index.html")
|
||||
{
|
||||
DragHandler = new CustomDragHandler(),
|
||||
MenuHandler = new CustomMenuHandler(),
|
||||
DownloadHandler = new CustomDownloadHandler(),
|
||||
BrowserSettings =
|
||||
{
|
||||
DefaultEncoding = "UTF-8",
|
||||
},
|
||||
Dock = DockStyle.Fill
|
||||
};
|
||||
|
||||
Browser.IsBrowserInitializedChanged += (A, B) =>
|
||||
{
|
||||
if (Program.LaunchDebug)
|
||||
Browser.ShowDevTools();
|
||||
};
|
||||
|
||||
Util.ApplyJavascriptBindings(Browser.JavascriptObjectRepository);
|
||||
Browser.ConsoleMessage += (_, args) =>
|
||||
{
|
||||
jslogger.Debug(args.Message + " (" + args.Source + ":" + args.Line + ")");
|
||||
};
|
||||
|
||||
Controls.Add(Browser);
|
||||
}
|
||||
|
||||
private void MainForm_Load(object sender, System.EventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
int.TryParse(VRCXStorage.Instance.Get("VRCX_LocationX"), out LastLocationX);
|
||||
int.TryParse(VRCXStorage.Instance.Get("VRCX_LocationY"), out LastLocationY);
|
||||
int.TryParse(VRCXStorage.Instance.Get("VRCX_SizeWidth"), out LastSizeWidth);
|
||||
int.TryParse(VRCXStorage.Instance.Get("VRCX_SizeHeight"), out LastSizeHeight);
|
||||
var location = new Point(LastLocationX, LastLocationY);
|
||||
var size = new Size(LastSizeWidth, LastSizeHeight);
|
||||
var screen = Screen.FromPoint(location);
|
||||
if (screen.Bounds.Contains(location.X, location.Y))
|
||||
{
|
||||
Location = location;
|
||||
}
|
||||
Size = new Size(1920, 1080);
|
||||
if (size.Width > 0 && size.Height > 0)
|
||||
{
|
||||
Size = size;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var state = WindowState;
|
||||
if (int.TryParse(VRCXStorage.Instance.Get("VRCX_WindowState"), out int v))
|
||||
{
|
||||
state = (FormWindowState)v;
|
||||
}
|
||||
if (state == FormWindowState.Minimized)
|
||||
{
|
||||
state = FormWindowState.Normal;
|
||||
}
|
||||
if ("true".Equals(VRCXStorage.Instance.Get("VRCX_StartAsMinimizedState")))
|
||||
{
|
||||
state = FormWindowState.Minimized;
|
||||
}
|
||||
if ("true".Equals(VRCXStorage.Instance.Get("VRCX_StartAsMinimizedState")) &&
|
||||
"true".Equals(VRCXStorage.Instance.Get("VRCX_CloseToTray")))
|
||||
{
|
||||
BeginInvoke(new MethodInvoker(Hide));
|
||||
}
|
||||
else
|
||||
{
|
||||
WindowState = state;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
// 가끔 화면 위치가 안맞음.. 이걸로 해결 될지는 모르겠음
|
||||
Browser.Invalidate();
|
||||
}
|
||||
|
||||
private void MainForm_Resize(object sender, System.EventArgs e)
|
||||
{
|
||||
if (WindowState != FormWindowState.Normal)
|
||||
{
|
||||
return;
|
||||
}
|
||||
LastSizeWidth = Size.Width;
|
||||
LastSizeHeight = Size.Height;
|
||||
}
|
||||
|
||||
private void MainForm_Move(object sender, System.EventArgs e)
|
||||
{
|
||||
if (WindowState != FormWindowState.Normal)
|
||||
{
|
||||
return;
|
||||
}
|
||||
LastLocationX = Location.X;
|
||||
LastLocationY = Location.Y;
|
||||
}
|
||||
|
||||
private void MainForm_FormClosing(object sender, FormClosingEventArgs e)
|
||||
{
|
||||
if (e.CloseReason == CloseReason.UserClosing &&
|
||||
"true".Equals(VRCXStorage.Instance.Get("VRCX_CloseToTray")))
|
||||
{
|
||||
e.Cancel = true;
|
||||
Hide();
|
||||
}
|
||||
}
|
||||
|
||||
private void MainForm_FormClosed(object sender, FormClosedEventArgs e)
|
||||
{
|
||||
VRCXStorage.Instance.Set("VRCX_LocationX", LastLocationX.ToString());
|
||||
VRCXStorage.Instance.Set("VRCX_LocationY", LastLocationY.ToString());
|
||||
VRCXStorage.Instance.Set("VRCX_SizeWidth", LastSizeWidth.ToString());
|
||||
VRCXStorage.Instance.Set("VRCX_SizeHeight", LastSizeHeight.ToString());
|
||||
VRCXStorage.Instance.Set("VRCX_WindowState", ((int)WindowState).ToString());
|
||||
}
|
||||
|
||||
private void TrayIcon_MouseClick(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (e.Button == MouseButtons.Left)
|
||||
{
|
||||
Focus_Window();
|
||||
}
|
||||
}
|
||||
|
||||
private void TrayMenu_Open_Click(object sender, System.EventArgs e)
|
||||
{
|
||||
Focus_Window();
|
||||
}
|
||||
|
||||
public void Focus_Window()
|
||||
{
|
||||
if (WindowState == FormWindowState.Minimized)
|
||||
{
|
||||
WindowState = FormWindowState.Normal;
|
||||
}
|
||||
Show();
|
||||
// Focus();
|
||||
Activate();
|
||||
}
|
||||
|
||||
private void TrayMenu_DevTools_Click(object sender, System.EventArgs e)
|
||||
{
|
||||
Instance.Browser.ShowDevTools();
|
||||
}
|
||||
|
||||
private void TrayMenu_Quit_Click(object sender, System.EventArgs e)
|
||||
{
|
||||
Application.Exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 2.0
|
||||
|
||||
The primary goals of this format is to allow a simple XML format
|
||||
that is mostly human readable. The generation and parsing of the
|
||||
various data types are done through the TypeConverter classes
|
||||
associated with the data types.
|
||||
|
||||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||
<resheader name="version">2.0</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||
<comment>This is a comment</comment>
|
||||
</data>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
mimetype set.
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
extensible. For a given mimetype the value must be set accordingly:
|
||||
|
||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||
that the ResXResourceWriter will generate, however the reader can
|
||||
read any of the formats listed below.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.binary.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.soap.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||
value : The object must be serialized into a byte array
|
||||
: using a System.ComponentModel.TypeConverter
|
||||
: and then encoded with base64 encoding.
|
||||
-->
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<metadata name="TrayMenu.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
|
||||
<value>17, 17</value>
|
||||
</metadata>
|
||||
<metadata name="TrayIcon.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
|
||||
<value>124, 17</value>
|
||||
</metadata>
|
||||
</root>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,201 @@
|
||||
// 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 CefSharp;
|
||||
using CefSharp.Enums;
|
||||
using CefSharp.OffScreen;
|
||||
using CefSharp.Structs;
|
||||
using SharpDX.Direct3D11;
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
public class OffScreenBrowser : ChromiumWebBrowser, IRenderHandler
|
||||
{
|
||||
private readonly ReaderWriterLockSlim _paintBufferLock;
|
||||
private GCHandle _paintBuffer;
|
||||
private int _width;
|
||||
private int _height;
|
||||
|
||||
public OffScreenBrowser(string address, int width, int height)
|
||||
: base(
|
||||
address,
|
||||
new BrowserSettings()
|
||||
{
|
||||
DefaultEncoding = "UTF-8"
|
||||
}
|
||||
)
|
||||
{
|
||||
_paintBufferLock = new ReaderWriterLockSlim();
|
||||
|
||||
Size = new System.Drawing.Size(width, height);
|
||||
RenderHandler = this;
|
||||
|
||||
Util.ApplyJavascriptBindings(JavascriptObjectRepository);
|
||||
}
|
||||
|
||||
public new void Dispose()
|
||||
{
|
||||
RenderHandler = null;
|
||||
base.Dispose();
|
||||
|
||||
_paintBufferLock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
if (_paintBuffer.IsAllocated == true)
|
||||
{
|
||||
_paintBuffer.Free();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_paintBufferLock.ExitWriteLock();
|
||||
}
|
||||
|
||||
_paintBufferLock.Dispose();
|
||||
}
|
||||
|
||||
public void RenderToTexture(Texture2D texture)
|
||||
{
|
||||
_paintBufferLock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
if (_width > 0 &&
|
||||
_height > 0)
|
||||
{
|
||||
var context = texture.Device.ImmediateContext;
|
||||
var dataBox = context.MapSubresource(
|
||||
texture,
|
||||
0,
|
||||
MapMode.WriteDiscard,
|
||||
MapFlags.None
|
||||
);
|
||||
if (dataBox.IsEmpty == false)
|
||||
{
|
||||
var sourcePtr = _paintBuffer.AddrOfPinnedObject();
|
||||
var destinationPtr = dataBox.DataPointer;
|
||||
var pitch = _width * 4;
|
||||
var rowPitch = dataBox.RowPitch;
|
||||
if (pitch == rowPitch)
|
||||
{
|
||||
WinApi.CopyMemory(
|
||||
destinationPtr,
|
||||
sourcePtr,
|
||||
(uint)(_width * _height * 4)
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var y = _height; y > 0; --y)
|
||||
{
|
||||
WinApi.CopyMemory(
|
||||
destinationPtr,
|
||||
sourcePtr,
|
||||
(uint)pitch
|
||||
);
|
||||
sourcePtr += pitch;
|
||||
destinationPtr += rowPitch;
|
||||
}
|
||||
}
|
||||
}
|
||||
context.UnmapSubresource(texture, 0);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_paintBufferLock.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
ScreenInfo? IRenderHandler.GetScreenInfo()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
bool IRenderHandler.GetScreenPoint(int viewX, int viewY, out int screenX, out int screenY)
|
||||
{
|
||||
screenX = viewX;
|
||||
screenY = viewY;
|
||||
return false;
|
||||
}
|
||||
|
||||
Rect IRenderHandler.GetViewRect()
|
||||
{
|
||||
return new Rect(0, 0, Size.Width, Size.Height);
|
||||
}
|
||||
|
||||
void IRenderHandler.OnAcceleratedPaint(PaintElementType type, Rect dirtyRect, IntPtr sharedHandle)
|
||||
{
|
||||
// NOT USED
|
||||
}
|
||||
|
||||
void IRenderHandler.OnCursorChange(IntPtr cursor, CursorType type, CursorInfo customCursorInfo)
|
||||
{
|
||||
}
|
||||
|
||||
void IRenderHandler.OnImeCompositionRangeChanged(Range selectedRange, Rect[] characterBounds)
|
||||
{
|
||||
}
|
||||
|
||||
void IRenderHandler.OnPaint(PaintElementType type, Rect dirtyRect, IntPtr buffer, int width, int height)
|
||||
{
|
||||
if (type == PaintElementType.View)
|
||||
{
|
||||
_paintBufferLock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
if (_width != width ||
|
||||
_height != height)
|
||||
{
|
||||
_width = width;
|
||||
_height = height;
|
||||
if (_paintBuffer.IsAllocated == true)
|
||||
{
|
||||
_paintBuffer.Free();
|
||||
}
|
||||
_paintBuffer = GCHandle.Alloc(
|
||||
new byte[_width * _height * 4],
|
||||
GCHandleType.Pinned
|
||||
);
|
||||
}
|
||||
|
||||
WinApi.CopyMemory(
|
||||
_paintBuffer.AddrOfPinnedObject(),
|
||||
buffer,
|
||||
(uint)(width * height * 4)
|
||||
);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_paintBufferLock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void IRenderHandler.OnPopupShow(bool show)
|
||||
{
|
||||
}
|
||||
|
||||
void IRenderHandler.OnPopupSize(Rect rect)
|
||||
{
|
||||
}
|
||||
|
||||
void IRenderHandler.OnVirtualKeyboardRequested(IBrowser browser, TextInputMode inputMode)
|
||||
{
|
||||
}
|
||||
|
||||
bool IRenderHandler.StartDragging(IDragData dragData, DragOperationsMask mask, int x, int y)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
void IRenderHandler.UpdateDragCursor(DragOperationsMask operation)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
// 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.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using CefSharp;
|
||||
using SharpDX;
|
||||
using SharpDX.Direct3D;
|
||||
using SharpDX.Direct3D11;
|
||||
using SharpDX.DXGI;
|
||||
using Valve.VR;
|
||||
using Device = SharpDX.Direct3D11.Device;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
public class VRCXVR
|
||||
{
|
||||
public static VRCXVR Instance;
|
||||
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
|
||||
private static readonly float[] _rotation = { 0f, 0f, 0f };
|
||||
private static readonly float[] _translation = { 0f, 0f, 0f };
|
||||
private static readonly float[] _translationLeft = { -7f / 100f, -5f / 100f, 6f / 100f };
|
||||
private static readonly float[] _translationRight = { 7f / 100f, -5f / 100f, 6f / 100f };
|
||||
private static readonly float[] _rotationLeft = { 90f * (float)(Math.PI / 180f), 90f * (float)(Math.PI / 180f), -90f * (float)(Math.PI / 180f) };
|
||||
private static readonly float[] _rotationRight = { -90f * (float)(Math.PI / 180f), -90f * (float)(Math.PI / 180f), -90f * (float)(Math.PI / 180f) };
|
||||
public static OffScreenBrowser _browser1;
|
||||
public static OffScreenBrowser _browser2;
|
||||
private readonly List<string[]> _deviceList;
|
||||
private readonly ReaderWriterLockSlim _deviceListLock;
|
||||
private bool _active;
|
||||
private Device _device;
|
||||
private bool _hmdOverlayActive;
|
||||
private bool _menuButton;
|
||||
private int _overlayHand;
|
||||
private Texture2D _texture1;
|
||||
private Texture2D _texture2;
|
||||
private Thread _thread;
|
||||
private bool _wristOverlayActive;
|
||||
|
||||
static VRCXVR()
|
||||
{
|
||||
Instance = new VRCXVR();
|
||||
}
|
||||
|
||||
public VRCXVR()
|
||||
{
|
||||
_deviceListLock = new ReaderWriterLockSlim();
|
||||
_deviceList = new List<string[]>();
|
||||
_thread = new Thread(ThreadLoop)
|
||||
{
|
||||
IsBackground = true
|
||||
};
|
||||
}
|
||||
|
||||
// NOTE
|
||||
// 메모리 릭 때문에 미리 생성해놓고 계속 사용함
|
||||
internal void Init()
|
||||
{
|
||||
_thread.Start();
|
||||
}
|
||||
|
||||
internal void Exit()
|
||||
{
|
||||
var thread = _thread;
|
||||
_thread = null;
|
||||
thread.Interrupt();
|
||||
thread.Join();
|
||||
}
|
||||
|
||||
public void Restart()
|
||||
{
|
||||
Exit();
|
||||
Instance = new VRCXVR();
|
||||
Instance.Init();
|
||||
MainForm.Instance.Browser.ExecuteScriptAsync("console.log('VRCXVR Restarted');");
|
||||
}
|
||||
|
||||
private void ThreadLoop()
|
||||
{
|
||||
var active = false;
|
||||
var e = new VREvent_t();
|
||||
var nextInit = DateTime.MinValue;
|
||||
var nextDeviceUpdate = DateTime.MinValue;
|
||||
var nextOverlay = DateTime.MinValue;
|
||||
var overlayIndex = OpenVR.k_unTrackedDeviceIndexInvalid;
|
||||
var overlayVisible1 = false;
|
||||
var overlayVisible2 = false;
|
||||
var dashboardHandle = 0UL;
|
||||
var overlayHandle1 = 0UL;
|
||||
var overlayHandle2 = 0UL;
|
||||
|
||||
// REMOVE THIS
|
||||
// nextOverlay = DateTime.MaxValue;
|
||||
// https://stackoverflow.com/questions/38312597/how-to-choose-a-specific-graphics-device-in-sharpdx-directx-11/38596725#38596725
|
||||
Factory f = new Factory1();
|
||||
var a = f.GetAdapter(1);
|
||||
|
||||
var flags = DeviceCreationFlags.BgraSupport;
|
||||
|
||||
_device = Program.GPUFix ? new Device(a, flags) : new Device(DriverType.Hardware, DeviceCreationFlags.SingleThreaded | DeviceCreationFlags.BgraSupport);
|
||||
|
||||
_texture1 = new Texture2D(
|
||||
_device,
|
||||
new Texture2DDescription
|
||||
{
|
||||
Width = 512,
|
||||
Height = 512,
|
||||
MipLevels = 1,
|
||||
ArraySize = 1,
|
||||
Format = Format.B8G8R8A8_UNorm,
|
||||
SampleDescription = new SampleDescription(1, 0),
|
||||
Usage = ResourceUsage.Dynamic,
|
||||
BindFlags = BindFlags.ShaderResource,
|
||||
CpuAccessFlags = CpuAccessFlags.Write
|
||||
}
|
||||
);
|
||||
|
||||
_texture2 = new Texture2D(
|
||||
_device,
|
||||
new Texture2DDescription
|
||||
{
|
||||
Width = 1024,
|
||||
Height = 1024,
|
||||
MipLevels = 1,
|
||||
ArraySize = 1,
|
||||
Format = Format.B8G8R8A8_UNorm,
|
||||
SampleDescription = new SampleDescription(1, 0),
|
||||
Usage = ResourceUsage.Dynamic,
|
||||
BindFlags = BindFlags.ShaderResource,
|
||||
CpuAccessFlags = CpuAccessFlags.Write
|
||||
}
|
||||
);
|
||||
|
||||
_browser1 = new OffScreenBrowser(
|
||||
"file://vrcx/vr.html?1",
|
||||
512,
|
||||
512
|
||||
);
|
||||
|
||||
_browser2 = new OffScreenBrowser(
|
||||
"file://vrcx/vr.html?2",
|
||||
1024,
|
||||
1024
|
||||
);
|
||||
|
||||
while (_thread != null)
|
||||
{
|
||||
if (_wristOverlayActive)
|
||||
_browser1.RenderToTexture(_texture1);
|
||||
if (_hmdOverlayActive)
|
||||
_browser2.RenderToTexture(_texture2);
|
||||
try
|
||||
{
|
||||
Thread.Sleep(32);
|
||||
}
|
||||
catch (ThreadInterruptedException)
|
||||
{
|
||||
}
|
||||
|
||||
if (_active)
|
||||
{
|
||||
var system = OpenVR.System;
|
||||
if (system == null)
|
||||
{
|
||||
if (DateTime.UtcNow.CompareTo(nextInit) <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var _err = EVRInitError.None;
|
||||
system = OpenVR.Init(ref _err, EVRApplicationType.VRApplication_Background);
|
||||
nextInit = DateTime.UtcNow.AddSeconds(5);
|
||||
if (system == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
active = true;
|
||||
}
|
||||
|
||||
while (system.PollNextEvent(ref e, (uint)Marshal.SizeOf(e)))
|
||||
{
|
||||
var type = (EVREventType)e.eventType;
|
||||
if (type == EVREventType.VREvent_Quit)
|
||||
{
|
||||
active = false;
|
||||
OpenVR.Shutdown();
|
||||
nextInit = DateTime.UtcNow.AddSeconds(10);
|
||||
system = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (system != null)
|
||||
{
|
||||
if (DateTime.UtcNow.CompareTo(nextDeviceUpdate) >= 0)
|
||||
{
|
||||
overlayIndex = OpenVR.k_unTrackedDeviceIndexInvalid;
|
||||
UpdateDevices(system, ref overlayIndex);
|
||||
if (overlayIndex != OpenVR.k_unTrackedDeviceIndexInvalid)
|
||||
{
|
||||
nextOverlay = DateTime.UtcNow.AddSeconds(10);
|
||||
}
|
||||
|
||||
nextDeviceUpdate = DateTime.UtcNow.AddSeconds(0.1);
|
||||
}
|
||||
|
||||
var overlay = OpenVR.Overlay;
|
||||
if (overlay != null)
|
||||
{
|
||||
var dashboardVisible = overlay.IsDashboardVisible();
|
||||
var err = ProcessDashboard(overlay, ref dashboardHandle, dashboardVisible);
|
||||
if (err != EVROverlayError.None &&
|
||||
dashboardHandle != 0)
|
||||
{
|
||||
overlay.DestroyOverlay(dashboardHandle);
|
||||
dashboardHandle = 0;
|
||||
logger.Error(err);
|
||||
}
|
||||
|
||||
err = ProcessOverlay1(overlay, ref overlayHandle1, ref overlayVisible1, dashboardVisible, overlayIndex, nextOverlay);
|
||||
if (err != EVROverlayError.None &&
|
||||
overlayHandle1 != 0)
|
||||
{
|
||||
overlay.DestroyOverlay(overlayHandle1);
|
||||
overlayHandle1 = 0;
|
||||
logger.Error(err);
|
||||
}
|
||||
|
||||
err = ProcessOverlay2(overlay, ref overlayHandle2, ref overlayVisible2, dashboardVisible);
|
||||
if (err != EVROverlayError.None &&
|
||||
overlayHandle2 != 0)
|
||||
{
|
||||
overlay.DestroyOverlay(overlayHandle2);
|
||||
overlayHandle2 = 0;
|
||||
logger.Error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (active)
|
||||
{
|
||||
active = false;
|
||||
OpenVR.Shutdown();
|
||||
_deviceListLock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
_deviceList.Clear();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_deviceListLock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_browser2.Dispose();
|
||||
_browser1.Dispose();
|
||||
_texture2.Dispose();
|
||||
_texture1.Dispose();
|
||||
_device.Dispose();
|
||||
}
|
||||
|
||||
public void SetActive(bool active, bool hmdOverlay, bool wristOverlay, bool menuButton, int overlayHand)
|
||||
{
|
||||
_active = active;
|
||||
_hmdOverlayActive = hmdOverlay;
|
||||
_wristOverlayActive = wristOverlay;
|
||||
_menuButton = menuButton;
|
||||
_overlayHand = overlayHand;
|
||||
}
|
||||
|
||||
public void Refresh()
|
||||
{
|
||||
_browser1.Reload();
|
||||
_browser2.Reload();
|
||||
}
|
||||
|
||||
public string[][] GetDevices()
|
||||
{
|
||||
_deviceListLock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
return _deviceList.ToArray();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_deviceListLock.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
internal void UpdateDevices(CVRSystem system, ref uint overlayIndex)
|
||||
{
|
||||
_deviceListLock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
_deviceList.Clear();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_deviceListLock.ExitWriteLock();
|
||||
}
|
||||
|
||||
var sb = new StringBuilder(256);
|
||||
var state = new VRControllerState_t();
|
||||
var poses = new TrackedDevicePose_t[OpenVR.k_unMaxTrackedDeviceCount];
|
||||
system.GetDeviceToAbsoluteTrackingPose(ETrackingUniverseOrigin.TrackingUniverseStanding, 0, poses);
|
||||
for (var i = 0u; i < OpenVR.k_unMaxTrackedDeviceCount; ++i)
|
||||
{
|
||||
var devClass = system.GetTrackedDeviceClass(i);
|
||||
if (devClass == ETrackedDeviceClass.Controller ||
|
||||
devClass == ETrackedDeviceClass.GenericTracker ||
|
||||
devClass == ETrackedDeviceClass.TrackingReference)
|
||||
{
|
||||
var err = ETrackedPropertyError.TrackedProp_Success;
|
||||
var batteryPercentage = system.GetFloatTrackedDeviceProperty(i, ETrackedDeviceProperty.Prop_DeviceBatteryPercentage_Float, ref err);
|
||||
if (err != ETrackedPropertyError.TrackedProp_Success)
|
||||
{
|
||||
batteryPercentage = 1f;
|
||||
}
|
||||
|
||||
err = ETrackedPropertyError.TrackedProp_Success;
|
||||
var isCharging = system.GetBoolTrackedDeviceProperty(i, ETrackedDeviceProperty.Prop_DeviceIsCharging_Bool, ref err);
|
||||
if (err != ETrackedPropertyError.TrackedProp_Success)
|
||||
{
|
||||
isCharging = false;
|
||||
}
|
||||
|
||||
sb.Clear();
|
||||
system.GetStringTrackedDeviceProperty(i, ETrackedDeviceProperty.Prop_TrackingSystemName_String, sb, (uint)sb.Capacity, ref err);
|
||||
var isOculus = sb.ToString().IndexOf("oculus", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
// Oculus : B/Y, Bit 1, Mask 2
|
||||
// Oculus : A/X, Bit 7, Mask 128
|
||||
// Vive : Menu, Bit 1, Mask 2,
|
||||
// Vive : Grip, Bit 2, Mask 4
|
||||
var role = system.GetControllerRoleForTrackedDeviceIndex(i);
|
||||
if (role == ETrackedControllerRole.LeftHand || role == ETrackedControllerRole.RightHand)
|
||||
{
|
||||
if (_overlayHand == 0 ||
|
||||
(_overlayHand == 1 && role == ETrackedControllerRole.LeftHand) ||
|
||||
(_overlayHand == 2 && role == ETrackedControllerRole.RightHand))
|
||||
{
|
||||
if (system.GetControllerState(i, ref state, (uint)Marshal.SizeOf(state)) &&
|
||||
(state.ulButtonPressed & (_menuButton ? 2u : isOculus ? 128u : 4u)) != 0)
|
||||
{
|
||||
if (role == ETrackedControllerRole.LeftHand)
|
||||
{
|
||||
Array.Copy(_translationLeft, _translation, 3);
|
||||
Array.Copy(_rotationLeft, _rotation, 3);
|
||||
}
|
||||
else
|
||||
{
|
||||
Array.Copy(_translationRight, _translation, 3);
|
||||
Array.Copy(_rotationRight, _rotation, 3);
|
||||
}
|
||||
|
||||
overlayIndex = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var type = string.Empty;
|
||||
if (devClass == ETrackedDeviceClass.Controller)
|
||||
{
|
||||
if (role == ETrackedControllerRole.LeftHand)
|
||||
{
|
||||
type = "leftController";
|
||||
}
|
||||
else if (role == ETrackedControllerRole.RightHand)
|
||||
{
|
||||
type = "rightController";
|
||||
}
|
||||
else
|
||||
{
|
||||
type = "controller";
|
||||
}
|
||||
}
|
||||
else if (devClass == ETrackedDeviceClass.GenericTracker)
|
||||
{
|
||||
type = "tracker";
|
||||
}
|
||||
else if (devClass == ETrackedDeviceClass.TrackingReference)
|
||||
{
|
||||
type = "base";
|
||||
}
|
||||
|
||||
var item = new[]
|
||||
{
|
||||
type,
|
||||
system.IsTrackedDeviceConnected(i)
|
||||
? "connected"
|
||||
: "disconnected",
|
||||
isCharging
|
||||
? "charging"
|
||||
: "discharging",
|
||||
(batteryPercentage * 100).ToString(),
|
||||
poses[i].eTrackingResult.ToString()
|
||||
};
|
||||
_deviceListLock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
_deviceList.Add(item);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_deviceListLock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal EVROverlayError ProcessDashboard(CVROverlay overlay, ref ulong dashboardHandle, bool dashboardVisible)
|
||||
{
|
||||
var err = EVROverlayError.None;
|
||||
|
||||
if (dashboardHandle == 0)
|
||||
{
|
||||
err = overlay.FindOverlay("VRCX", ref dashboardHandle);
|
||||
if (err != EVROverlayError.None)
|
||||
{
|
||||
if (err != EVROverlayError.UnknownOverlay)
|
||||
{
|
||||
return err;
|
||||
}
|
||||
|
||||
ulong thumbnailHandle = 0;
|
||||
err = overlay.CreateDashboardOverlay("VRCX", "VRCX", ref dashboardHandle, ref thumbnailHandle);
|
||||
if (err != EVROverlayError.None)
|
||||
{
|
||||
return err;
|
||||
}
|
||||
|
||||
var iconPath = Path.Combine(Program.BaseDirectory, "VRCX.png");
|
||||
err = overlay.SetOverlayFromFile(thumbnailHandle, iconPath);
|
||||
if (err != EVROverlayError.None)
|
||||
{
|
||||
return err;
|
||||
}
|
||||
|
||||
err = overlay.SetOverlayWidthInMeters(dashboardHandle, 1.5f);
|
||||
if (err != EVROverlayError.None)
|
||||
{
|
||||
return err;
|
||||
}
|
||||
|
||||
err = overlay.SetOverlayInputMethod(dashboardHandle, VROverlayInputMethod.Mouse);
|
||||
if (err != EVROverlayError.None)
|
||||
{
|
||||
return err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var e = new VREvent_t();
|
||||
|
||||
while (overlay.PollNextOverlayEvent(dashboardHandle, ref e, (uint)Marshal.SizeOf(e)))
|
||||
{
|
||||
var type = (EVREventType)e.eventType;
|
||||
if (type == EVREventType.VREvent_MouseMove)
|
||||
{
|
||||
var m = e.data.mouse;
|
||||
var s = _browser1.Size;
|
||||
_browser1.GetBrowserHost().SendMouseMoveEvent((int)(m.x * s.Width), s.Height - (int)(m.y * s.Height), false, CefEventFlags.None);
|
||||
}
|
||||
else if (type == EVREventType.VREvent_MouseButtonDown)
|
||||
{
|
||||
var m = e.data.mouse;
|
||||
var s = _browser1.Size;
|
||||
_browser1.GetBrowserHost().SendMouseClickEvent((int)(m.x * s.Width), s.Height - (int)(m.y * s.Height), MouseButtonType.Left, false, 1, CefEventFlags.LeftMouseButton);
|
||||
}
|
||||
else if (type == EVREventType.VREvent_MouseButtonUp)
|
||||
{
|
||||
var m = e.data.mouse;
|
||||
var s = _browser1.Size;
|
||||
_browser1.GetBrowserHost().SendMouseClickEvent((int)(m.x * s.Width), s.Height - (int)(m.y * s.Height), MouseButtonType.Left, true, 1, CefEventFlags.None);
|
||||
}
|
||||
}
|
||||
|
||||
if (dashboardVisible)
|
||||
{
|
||||
var texture = new Texture_t
|
||||
{
|
||||
handle = _texture1.NativePointer
|
||||
};
|
||||
err = overlay.SetOverlayTexture(dashboardHandle, ref texture);
|
||||
if (err != EVROverlayError.None)
|
||||
{
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
return err;
|
||||
}
|
||||
|
||||
internal EVROverlayError ProcessOverlay1(CVROverlay overlay, ref ulong overlayHandle, ref bool overlayVisible, bool dashboardVisible, uint overlayIndex, DateTime nextOverlay)
|
||||
{
|
||||
var err = EVROverlayError.None;
|
||||
|
||||
if (overlayHandle == 0)
|
||||
{
|
||||
err = overlay.FindOverlay("VRCX1", ref overlayHandle);
|
||||
if (err != EVROverlayError.None)
|
||||
{
|
||||
if (err != EVROverlayError.UnknownOverlay)
|
||||
{
|
||||
return err;
|
||||
}
|
||||
|
||||
overlayVisible = false;
|
||||
err = overlay.CreateOverlay("VRCX1", "VRCX1", ref overlayHandle);
|
||||
if (err != EVROverlayError.None)
|
||||
{
|
||||
return err;
|
||||
}
|
||||
|
||||
err = overlay.SetOverlayAlpha(overlayHandle, 0.9f);
|
||||
if (err != EVROverlayError.None)
|
||||
{
|
||||
return err;
|
||||
}
|
||||
|
||||
err = overlay.SetOverlayWidthInMeters(overlayHandle, 1f);
|
||||
if (err != EVROverlayError.None)
|
||||
{
|
||||
return err;
|
||||
}
|
||||
|
||||
err = overlay.SetOverlayInputMethod(overlayHandle, VROverlayInputMethod.None);
|
||||
if (err != EVROverlayError.None)
|
||||
{
|
||||
return err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (overlayIndex != OpenVR.k_unTrackedDeviceIndexInvalid)
|
||||
{
|
||||
// http://www.opengl-tutorial.org/beginners-tutorials/tutorial-3-matrices
|
||||
// Scaling-Rotation-Translation
|
||||
var m = Matrix.Scaling(0.25f);
|
||||
m *= Matrix.RotationX(_rotation[0]);
|
||||
m *= Matrix.RotationY(_rotation[1]);
|
||||
m *= Matrix.RotationZ(_rotation[2]);
|
||||
m *= Matrix.Translation(_translation[0], _translation[1], _translation[2]);
|
||||
var hm34 = new HmdMatrix34_t
|
||||
{
|
||||
m0 = m.M11,
|
||||
m1 = m.M21,
|
||||
m2 = m.M31,
|
||||
m3 = m.M41,
|
||||
m4 = m.M12,
|
||||
m5 = m.M22,
|
||||
m6 = m.M32,
|
||||
m7 = m.M42,
|
||||
m8 = m.M13,
|
||||
m9 = m.M23,
|
||||
m10 = m.M33,
|
||||
m11 = m.M43
|
||||
};
|
||||
err = overlay.SetOverlayTransformTrackedDeviceRelative(overlayHandle, overlayIndex, ref hm34);
|
||||
if (err != EVROverlayError.None)
|
||||
{
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
if (!dashboardVisible &&
|
||||
DateTime.UtcNow.CompareTo(nextOverlay) <= 0)
|
||||
{
|
||||
var texture = new Texture_t
|
||||
{
|
||||
handle = _texture1.NativePointer
|
||||
};
|
||||
err = overlay.SetOverlayTexture(overlayHandle, ref texture);
|
||||
if (err != EVROverlayError.None)
|
||||
{
|
||||
return err;
|
||||
}
|
||||
|
||||
if (!overlayVisible)
|
||||
{
|
||||
err = overlay.ShowOverlay(overlayHandle);
|
||||
if (err != EVROverlayError.None)
|
||||
{
|
||||
return err;
|
||||
}
|
||||
|
||||
overlayVisible = true;
|
||||
}
|
||||
}
|
||||
else if (overlayVisible)
|
||||
{
|
||||
err = overlay.HideOverlay(overlayHandle);
|
||||
if (err != EVROverlayError.None)
|
||||
{
|
||||
return err;
|
||||
}
|
||||
|
||||
overlayVisible = false;
|
||||
}
|
||||
|
||||
return err;
|
||||
}
|
||||
|
||||
internal EVROverlayError ProcessOverlay2(CVROverlay overlay, ref ulong overlayHandle, ref bool overlayVisible, bool dashboardVisible)
|
||||
{
|
||||
var err = EVROverlayError.None;
|
||||
|
||||
if (overlayHandle == 0)
|
||||
{
|
||||
err = overlay.FindOverlay("VRCX2", ref overlayHandle);
|
||||
if (err != EVROverlayError.None)
|
||||
{
|
||||
if (err != EVROverlayError.UnknownOverlay)
|
||||
{
|
||||
return err;
|
||||
}
|
||||
|
||||
overlayVisible = false;
|
||||
err = overlay.CreateOverlay("VRCX2", "VRCX2", ref overlayHandle);
|
||||
if (err != EVROverlayError.None)
|
||||
{
|
||||
return err;
|
||||
}
|
||||
|
||||
err = overlay.SetOverlayAlpha(overlayHandle, 0.9f);
|
||||
if (err != EVROverlayError.None)
|
||||
{
|
||||
return err;
|
||||
}
|
||||
|
||||
err = overlay.SetOverlayWidthInMeters(overlayHandle, 1f);
|
||||
if (err != EVROverlayError.None)
|
||||
{
|
||||
return err;
|
||||
}
|
||||
|
||||
err = overlay.SetOverlayInputMethod(overlayHandle, VROverlayInputMethod.None);
|
||||
if (err != EVROverlayError.None)
|
||||
{
|
||||
return err;
|
||||
}
|
||||
|
||||
var m = Matrix.Scaling(1f);
|
||||
m *= Matrix.Translation(0, -0.3f, -1.5f);
|
||||
var hm34 = new HmdMatrix34_t
|
||||
{
|
||||
m0 = m.M11,
|
||||
m1 = m.M21,
|
||||
m2 = m.M31,
|
||||
m3 = m.M41,
|
||||
m4 = m.M12,
|
||||
m5 = m.M22,
|
||||
m6 = m.M32,
|
||||
m7 = m.M42,
|
||||
m8 = m.M13,
|
||||
m9 = m.M23,
|
||||
m10 = m.M33,
|
||||
m11 = m.M43
|
||||
};
|
||||
err = overlay.SetOverlayTransformTrackedDeviceRelative(overlayHandle, OpenVR.k_unTrackedDeviceIndex_Hmd, ref hm34);
|
||||
if (err != EVROverlayError.None)
|
||||
{
|
||||
return err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!dashboardVisible)
|
||||
{
|
||||
var texture = new Texture_t
|
||||
{
|
||||
handle = _texture2.NativePointer
|
||||
};
|
||||
err = overlay.SetOverlayTexture(overlayHandle, ref texture);
|
||||
if (err != EVROverlayError.None)
|
||||
{
|
||||
return err;
|
||||
}
|
||||
|
||||
if (!overlayVisible)
|
||||
{
|
||||
err = overlay.ShowOverlay(overlayHandle);
|
||||
if (err != EVROverlayError.None)
|
||||
{
|
||||
return err;
|
||||
}
|
||||
|
||||
overlayVisible = true;
|
||||
}
|
||||
}
|
||||
else if (overlayVisible)
|
||||
{
|
||||
err = overlay.HideOverlay(overlayHandle);
|
||||
if (err != EVROverlayError.None)
|
||||
{
|
||||
return err;
|
||||
}
|
||||
|
||||
overlayVisible = false;
|
||||
}
|
||||
|
||||
return err;
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+110
@@ -0,0 +1,110 @@
|
||||
// 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>.
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
partial class VRForm
|
||||
{
|
||||
/// <summary>
|
||||
/// 필수 디자이너 변수입니다.
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// 사용 중인 모든 리소스를 정리합니다.
|
||||
/// </summary>
|
||||
/// <param name="disposing">관리되는 리소스를 삭제해야 하면 true이고, 그렇지 않으면 false입니다.</param>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows Form 디자이너에서 생성한 코드
|
||||
|
||||
/// <summary>
|
||||
/// 디자이너 지원에 필요한 메서드입니다.
|
||||
/// 이 메서드의 내용을 코드 편집기로 수정하지 마세요.
|
||||
/// </summary>
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.components = new System.ComponentModel.Container();
|
||||
this.timer = new System.Windows.Forms.Timer(this.components);
|
||||
this.panel1 = new System.Windows.Forms.Panel();
|
||||
this.panel2 = new System.Windows.Forms.Panel();
|
||||
this.button_refresh = new System.Windows.Forms.Button();
|
||||
this.button_devtools = new System.Windows.Forms.Button();
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// panel1
|
||||
//
|
||||
this.panel1.Location = new System.Drawing.Point(0, 0);
|
||||
this.panel1.Margin = new System.Windows.Forms.Padding(4, 4, 4, 4);
|
||||
this.panel1.Name = "panel1";
|
||||
this.panel1.Size = new System.Drawing.Size(731, 768);
|
||||
this.panel1.TabIndex = 0;
|
||||
//
|
||||
// panel2
|
||||
//
|
||||
this.panel2.Location = new System.Drawing.Point(740, 0);
|
||||
this.panel2.Margin = new System.Windows.Forms.Padding(4, 4, 4, 4);
|
||||
this.panel2.Name = "panel2";
|
||||
this.panel2.Size = new System.Drawing.Size(731, 768);
|
||||
this.panel2.TabIndex = 1;
|
||||
//
|
||||
// button_refresh
|
||||
//
|
||||
this.button_refresh.Location = new System.Drawing.Point(17, 777);
|
||||
this.button_refresh.Margin = new System.Windows.Forms.Padding(4, 4, 4, 4);
|
||||
this.button_refresh.Name = "button_refresh";
|
||||
this.button_refresh.Size = new System.Drawing.Size(107, 34);
|
||||
this.button_refresh.TabIndex = 27;
|
||||
this.button_refresh.Text = "Refresh";
|
||||
this.button_refresh.UseVisualStyleBackColor = true;
|
||||
this.button_refresh.Click += new System.EventHandler(this.button_refresh_Click);
|
||||
//
|
||||
// button_devtools
|
||||
//
|
||||
this.button_devtools.Location = new System.Drawing.Point(133, 777);
|
||||
this.button_devtools.Margin = new System.Windows.Forms.Padding(4, 4, 4, 4);
|
||||
this.button_devtools.Name = "button_devtools";
|
||||
this.button_devtools.Size = new System.Drawing.Size(107, 34);
|
||||
this.button_devtools.TabIndex = 27;
|
||||
this.button_devtools.Text = "DevTools";
|
||||
this.button_devtools.UseVisualStyleBackColor = true;
|
||||
this.button_devtools.Click += new System.EventHandler(this.button_devtools_Click);
|
||||
//
|
||||
// VRForm
|
||||
//
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(144F, 144F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi;
|
||||
this.ClientSize = new System.Drawing.Size(1483, 830);
|
||||
this.Controls.Add(this.button_devtools);
|
||||
this.Controls.Add(this.button_refresh);
|
||||
this.Controls.Add(this.panel2);
|
||||
this.Controls.Add(this.panel1);
|
||||
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
|
||||
this.Margin = new System.Windows.Forms.Padding(4, 4, 4, 4);
|
||||
this.MaximizeBox = false;
|
||||
this.Name = "VRForm";
|
||||
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen;
|
||||
this.Text = "VR";
|
||||
this.ResumeLayout(false);
|
||||
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private System.Windows.Forms.Timer timer;
|
||||
private System.Windows.Forms.Panel panel1;
|
||||
private System.Windows.Forms.Panel panel2;
|
||||
private System.Windows.Forms.Button button_refresh;
|
||||
private System.Windows.Forms.Button button_devtools;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright(c) 2019 pypy. 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.IO;
|
||||
using System.Windows.Forms;
|
||||
using CefSharp;
|
||||
using CefSharp.WinForms;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
public partial class VRForm : WinformBase
|
||||
{
|
||||
public static VRForm Instance;
|
||||
private ChromiumWebBrowser _browser1;
|
||||
private ChromiumWebBrowser _browser2;
|
||||
|
||||
public VRForm()
|
||||
{
|
||||
Instance = this;
|
||||
InitializeComponent();
|
||||
|
||||
_browser1 = new ChromiumWebBrowser(
|
||||
Path.Combine(Program.BaseDirectory, "html/vr.html?1")
|
||||
)
|
||||
{
|
||||
DragHandler = new CefNoopDragHandler(),
|
||||
BrowserSettings =
|
||||
{
|
||||
DefaultEncoding = "UTF-8",
|
||||
},
|
||||
Dock = DockStyle.Fill
|
||||
};
|
||||
|
||||
_browser2 = new ChromiumWebBrowser(
|
||||
Path.Combine(Program.BaseDirectory, "html/vr.html?2")
|
||||
)
|
||||
{
|
||||
DragHandler = new CefNoopDragHandler(),
|
||||
BrowserSettings =
|
||||
{
|
||||
DefaultEncoding = "UTF-8",
|
||||
},
|
||||
Dock = DockStyle.Fill
|
||||
};
|
||||
|
||||
Util.ApplyJavascriptBindings(_browser1.JavascriptObjectRepository);
|
||||
Util.ApplyJavascriptBindings(_browser2.JavascriptObjectRepository);
|
||||
|
||||
panel1.Controls.Add(_browser1);
|
||||
panel2.Controls.Add(_browser2);
|
||||
}
|
||||
|
||||
private void button_refresh_Click(object sender, System.EventArgs e)
|
||||
{
|
||||
_browser1.ExecuteScriptAsync("location.reload()");
|
||||
_browser2.ExecuteScriptAsync("location.reload()");
|
||||
VRCXVR.Instance.Refresh();
|
||||
}
|
||||
|
||||
private void button_devtools_Click(object sender, System.EventArgs e)
|
||||
{
|
||||
_browser1.ShowDevTools();
|
||||
_browser2.ShowDevTools();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 2.0
|
||||
|
||||
The primary goals of this format is to allow a simple XML format
|
||||
that is mostly human readable. The generation and parsing of the
|
||||
various data types are done through the TypeConverter classes
|
||||
associated with the data types.
|
||||
|
||||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||
<resheader name="version">2.0</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||
<comment>This is a comment</comment>
|
||||
</data>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
mimetype set.
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
extensible. For a given mimetype the value must be set accordingly:
|
||||
|
||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||
that the ResXResourceWriter will generate, however the reader can
|
||||
read any of the formats listed below.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.binary.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.soap.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||
value : The object must be serialized into a byte array
|
||||
: using a System.ComponentModel.TypeConverter
|
||||
: and then encoded with base64 encoding.
|
||||
-->
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<metadata name="timer.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
|
||||
<value>17, 17</value>
|
||||
</metadata>
|
||||
</root>
|
||||
@@ -0,0 +1,711 @@
|
||||
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 static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
|
||||
|
||||
private string lastError = null;
|
||||
private bool debugWorld = false;
|
||||
|
||||
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()
|
||||
{
|
||||
// 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)
|
||||
{
|
||||
var context = await listener.GetContextAsync();
|
||||
var request = context.Request;
|
||||
var responseData = new WorldDataRequestResponse(false, null, null);
|
||||
|
||||
try
|
||||
{
|
||||
if (MainForm.Instance?.Browser == null || MainForm.Instance.Browser.IsLoading || !MainForm.Instance.Browser.CanExecuteJavascriptInMainFrame)
|
||||
{
|
||||
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;
|
||||
responseData.ConnectionKey = null;
|
||||
SendJsonResponse(context.Response, responseData);
|
||||
continue;
|
||||
};
|
||||
|
||||
logger.Debug("Received a request to '{0}'", request.Url);
|
||||
|
||||
// TODO: Maybe an endpoint for getting a group of arbitrary keys by a group 'name'? eg; /getgroup?name=testgroup1 would return all keys with the column group set to 'testgroup1'
|
||||
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/getall":
|
||||
responseData = await HandleAllDataRequest(context);
|
||||
SendJsonResponse(context.Response, responseData);
|
||||
break;
|
||||
case "/vrcx/data/lasterror":
|
||||
responseData.OK = lastError == null;
|
||||
responseData.StatusCode = 200;
|
||||
responseData.Data = lastError;
|
||||
responseData.ConnectionKey = null;
|
||||
lastError = null;
|
||||
SendJsonResponse(context.Response, responseData);
|
||||
break;
|
||||
case "/vrcx/data/getbulk":
|
||||
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;
|
||||
default:
|
||||
responseData.Error = "Invalid VRCX endpoint.";
|
||||
responseData.StatusCode = 404;
|
||||
responseData.ConnectionKey = null;
|
||||
SendJsonResponse(context.Response, responseData);
|
||||
break;
|
||||
}
|
||||
|
||||
if (context.Response.StatusCode != 200)
|
||||
{
|
||||
logger.Warn("Received a request to '{0}' that returned a non-successful response. Error: {1} - {2}", request.Url, responseData.StatusCode, responseData.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
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;
|
||||
responseData.ConnectionKey = null;
|
||||
SendJsonResponse(context.Response, responseData);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private async Task<WorldDataRequestResponse> HandleSetSettingsRequest(HttpListenerContext context)
|
||||
{
|
||||
var request = context.Request;
|
||||
|
||||
string worldId = await GetCurrentWorldID();
|
||||
string set = request.QueryString["set"];
|
||||
string value = request.QueryString["value"];
|
||||
|
||||
if (!TryInitializeWorld(worldId, out string connectionKey))
|
||||
{
|
||||
return ConstructErrorResponse(500, "Failed to get/verify current world ID.");
|
||||
}
|
||||
|
||||
if (set != null && value != null)
|
||||
{
|
||||
switch (set)
|
||||
{
|
||||
case "externalReads":
|
||||
if (request.QueryString["value"] == "true")
|
||||
{
|
||||
worldDB.SetWorldAllowExternalRead(worldId, true);
|
||||
}
|
||||
else if (request.QueryString["value"] == "false")
|
||||
{
|
||||
worldDB.SetWorldAllowExternalRead(worldId, false);
|
||||
}
|
||||
else
|
||||
{
|
||||
return ConstructErrorResponse(400, "Invalid value for 'externalReads' setting.");
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return ConstructErrorResponse(400, "Invalid setting name.");
|
||||
}
|
||||
}
|
||||
|
||||
return ConstructSuccessResponse(null, connectionKey);
|
||||
}
|
||||
|
||||
|
||||
/// <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;
|
||||
|
||||
if (request.QueryString["debug"] == "true")
|
||||
{
|
||||
debugWorld = true;
|
||||
}
|
||||
else if (request.QueryString["debug"] == "false")
|
||||
{
|
||||
debugWorld = false;
|
||||
}
|
||||
|
||||
string worldId = await GetCurrentWorldID();
|
||||
|
||||
if (TryInitializeWorld(worldId, out string connectionKey))
|
||||
{
|
||||
logger.Info("Initialized a connection to the world database for world ID '{0}' with connection key {1}.", worldId, connectionKey);
|
||||
return ConstructSuccessResponse(connectionKey, connectionKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
return ConstructErrorResponse(500, "Failed to get/verify current world ID.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <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 key = request.QueryString["key"];
|
||||
if (key == null)
|
||||
{
|
||||
return ConstructErrorResponse(400, "Missing key parameter.");
|
||||
}
|
||||
|
||||
var worldId = await GetCurrentWorldID();
|
||||
|
||||
if (!TryInitializeWorld(worldId, out string connectionKey))
|
||||
{
|
||||
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);
|
||||
// This value is intended to be null if the key doesn't exist.
|
||||
return ConstructSuccessResponse(value?.Value, connectionKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles an HTTP listener request for all data from the world database for a given world.
|
||||
/// </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> HandleAllDataRequest(HttpListenerContext context)
|
||||
{
|
||||
var request = context.Request;
|
||||
var worldId = await GetCurrentWorldID();
|
||||
|
||||
if (!TryInitializeWorld(worldId, out string connectionKey))
|
||||
{
|
||||
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<string, string>();
|
||||
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<string, string>();
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
data.Add(entry.Key, entry.Value);
|
||||
}
|
||||
|
||||
logger.Debug("Serving a request for all data ({0} entries) for world ID '{1}' with connection key {2}.", data.Count, worldId, connectionKey);
|
||||
return ConstructSuccessResponse(JsonConvert.SerializeObject(data), connectionKey);
|
||||
}
|
||||
|
||||
/// <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 keys = request.QueryString["keys"];
|
||||
if (keys == null)
|
||||
{
|
||||
return ConstructErrorResponse(400, "Missing/invalid keys parameter.");
|
||||
}
|
||||
|
||||
var keyArray = keys.Split(',');
|
||||
|
||||
var worldId = await GetCurrentWorldID();
|
||||
|
||||
if (!TryInitializeWorld(worldId, out string connectionKey))
|
||||
{
|
||||
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<string, string>();
|
||||
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.
|
||||
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);
|
||||
}
|
||||
|
||||
logger.Debug("Serving a request for bulk data with keys '{0}' from world ID '{1}' with connection key {2}.", keys, worldId, connectionKey);
|
||||
return ConstructSuccessResponse(JsonConvert.SerializeObject(data), connectionKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="worldId">The ID of the world to initialize.</param>
|
||||
/// <param name="connectionKey">The connection key generated for the world.</param>
|
||||
/// <returns>True if the world was successfully initialized, false otherwise.</returns>
|
||||
private bool TryInitializeWorld(string worldId, out string connectionKey)
|
||||
{
|
||||
if (string.IsNullOrEmpty(worldId))
|
||||
{
|
||||
connectionKey = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
var existsInDB = worldDB.DoesWorldExist(worldId);
|
||||
|
||||
if (!existsInDB)
|
||||
{
|
||||
connectionKey = GenerateWorldConnectionKey();
|
||||
worldDB.AddWorld(worldId, connectionKey);
|
||||
logger.Info("Added new world ID '{0}' with connection key '{1}' to the database.", worldId, connectionKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
connectionKey = worldDB.GetWorldConnectionKey(worldId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <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()
|
||||
{
|
||||
if (debugWorld) return "12345";
|
||||
|
||||
// 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()
|
||||
{
|
||||
if (debugWorld) return "wrld_12345";
|
||||
|
||||
JavascriptResponse funcResult;
|
||||
|
||||
try
|
||||
{
|
||||
funcResult = await MainForm.Instance.Browser.EvaluateScriptAsync("$app.API.actuallyGetCurrentLocation();", TimeSpan.FromSeconds(5));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error(ex, "Failed to evaluate actuallyGetCurrentLocation JS function to get current world ID.");
|
||||
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
|
||||
logger.Warn("actuallyGetCurrentLocation returned null or empty.");
|
||||
return null;
|
||||
}
|
||||
|
||||
return worldId;
|
||||
}
|
||||
|
||||
private WorldDataRequestResponse ConstructSuccessResponse(string data = null, string connectionKey = null)
|
||||
{
|
||||
var responseData = new WorldDataRequestResponse(true, null, null);
|
||||
|
||||
responseData.StatusCode = 200;
|
||||
responseData.Error = null;
|
||||
responseData.OK = true;
|
||||
responseData.Data = data;
|
||||
responseData.ConnectionKey = connectionKey;
|
||||
return responseData;
|
||||
}
|
||||
|
||||
private WorldDataRequestResponse ConstructErrorResponse(int statusCode, string error)
|
||||
{
|
||||
var responseData = new WorldDataRequestResponse(true, null, null);
|
||||
|
||||
responseData.StatusCode = statusCode;
|
||||
responseData.Error = error;
|
||||
responseData.OK = false;
|
||||
responseData.Data = null;
|
||||
responseData.ConnectionKey = null;
|
||||
|
||||
return responseData;
|
||||
}
|
||||
|
||||
/// <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 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)
|
||||
{
|
||||
logger.Error(ex, json.ToString());
|
||||
this.lastError = ex.Message;
|
||||
// invalid json
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error(ex, json.ToString());
|
||||
this.lastError = ex.Message;
|
||||
// something else happened lol
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(request.RequestType))
|
||||
{
|
||||
logger.Warn("World tried to store data with no request type provided. Request: ", json);
|
||||
this.lastError = "`requestType` 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 (!debugWorld && !Guid.TryParse(request.ConnectionKey, out Guid _))
|
||||
{
|
||||
logger.Warn("World tried to store data with an invalid GUID as a connection key '{0}'", request.ConnectionKey);
|
||||
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);
|
||||
|
||||
// World ID is null, which means the connection key is invalid (or someone just deleted a world from the DB while VRCX was running lol).
|
||||
if (worldId == null)
|
||||
{
|
||||
logger.Warn("World tried to store data under {0} with an invalid connection key {1}", request.Key, request.ConnectionKey);
|
||||
this.lastError = "Invalid connection key";
|
||||
// invalid connection key
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
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 (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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a property for a given world in the world database.
|
||||
/// </summary>
|
||||
/// <param name="worldId">The ID of the world to set the property for.</param>
|
||||
/// <param name="key">The key of the property to set.</param>
|
||||
/// <param name="value">The value to set the property to.</param>
|
||||
public void SetWorldProperty(string worldId, string key, string value)
|
||||
{
|
||||
switch (key)
|
||||
{
|
||||
case "externalReads":
|
||||
if (bool.TryParse(value, out bool result))
|
||||
{
|
||||
worldDB.SetWorldAllowExternalRead(worldId, result);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Warn("World {0} tried to set externalReads to an invalid value '{1}'", worldId, value);
|
||||
this.lastError = "Invalid value for externalReads";
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores a data entry for a given world in the world database.
|
||||
/// </summary>
|
||||
/// <param name="worldId">The ID of the world to store the data entry for.</param>
|
||||
/// <param name="key">The key of the data entry to store.</param>
|
||||
/// <param name="value">The value of the data entry to store.</param>
|
||||
public void StoreWorldData(string worldId, string key, string value)
|
||||
{
|
||||
// Get/calculate the old and new data sizes for this key/the world
|
||||
int oldTotalDataSize = worldDB.GetWorldDataSize(worldId);
|
||||
int oldDataSize = worldDB.GetDataEntrySize(worldId, key);
|
||||
int newDataSize = Encoding.UTF8.GetByteCount(value);
|
||||
int newTotalDataSize = oldTotalDataSize + newDataSize - oldDataSize;
|
||||
|
||||
// Make sure we don't exceed 10MB total size for a world
|
||||
// This works, I tested it. Hopefully this prevents/limits any possible abuse.
|
||||
if (newTotalDataSize > 1024 * 1024 * 10)
|
||||
{
|
||||
logger.Warn("World {0} exceeded 10MB total data size trying to store key {0}. {1}:{2} + {3} = {4}", key, worldId, oldTotalDataSize - oldDataSize, newDataSize, newTotalDataSize);
|
||||
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.";
|
||||
return;
|
||||
}
|
||||
|
||||
worldDB.AddDataEntry(worldId, key, value, newDataSize);
|
||||
worldDB.UpdateWorldDataSize(worldId, newTotalDataSize);
|
||||
logger.Info("World {0} stored data entry {1} with size {2} bytes", worldId, key, newDataSize);
|
||||
logger.Debug("{0} : {1}", key, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a data entry for a given world from the world database.
|
||||
/// </summary>
|
||||
/// <param name="worldId">The ID of the world to delete the data entry from.</param>
|
||||
/// <param name="key">The key of the data entry to delete.</param>
|
||||
public void DeleteWorldData(string worldId, string key)
|
||||
{
|
||||
int oldTotalDataSize = worldDB.GetWorldDataSize(worldId);
|
||||
int oldDataSize = worldDB.GetDataEntrySize(worldId, key);
|
||||
int newTotalDataSize = oldTotalDataSize - oldDataSize;
|
||||
|
||||
worldDB.DeleteDataEntry(worldId, key);
|
||||
worldDB.UpdateWorldDataSize(worldId, newTotalDataSize);
|
||||
logger.Info("World {0} deleted data entry {1} with size {2} bytes.", worldId, key, oldDataSize);
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
listener?.Stop();
|
||||
listener?.Close();
|
||||
worldDB?.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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; }
|
||||
[JsonProperty("connectionKey")]
|
||||
public string ConnectionKey { 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
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>
|
||||
/// Sets the value of the allow_external_read field for the world with the specified ID in the database.
|
||||
/// </summary>
|
||||
/// <param name="worldId">The ID of the world to set the allow_external_read field for.</param>
|
||||
/// <param name="allowExternalRead">The value to set for the allow_external_read field.</param>
|
||||
public void SetWorldAllowExternalRead(string worldId, bool allowExternalRead)
|
||||
{
|
||||
sqlite.Execute("UPDATE worlds SET allow_external_read = ? WHERE world_id = ?", allowExternalRead, worldId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the value of the allow_external_read field for the world with the specified ID from the database.
|
||||
/// </summary>
|
||||
/// <param name="worldId">The ID of the world to get the allow_external_read field for.</param>
|
||||
/// <returns>The value of the allow_external_read field for the specified world.</returns>
|
||||
public bool GetWorldAllowExternalRead(string worldId)
|
||||
{
|
||||
var query = sqlite.Table<World>().Where(w => w.WorldId == worldId).Select(w => w.AllowExternalRead);
|
||||
|
||||
return query.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <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 all data entries for the world with the specified ID from the database.
|
||||
/// </summary>
|
||||
/// <param name="worldId">The ID of the world to get the data entries for.</param>
|
||||
/// <returns>An enumerable collection of all data entries for the world with the specified ID.</returns>
|
||||
public IEnumerable<WorldData> GetAllDataEntries(string worldId)
|
||||
{
|
||||
var query = sqlite.Table<WorldData>().Where(w => w.WorldId == worldId).Take(10000);
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the data entry with the specified world ID and key from the database.
|
||||
/// </summary>
|
||||
/// <param name="worldId">The ID of the world to delete the data entry from.</param>
|
||||
/// <param name="key">The key of the data entry to delete.</param>
|
||||
public void DeleteDataEntry(string worldId, string key)
|
||||
{
|
||||
sqlite.Execute("DELETE FROM data WHERE world_id = ? AND key = ?", worldId, key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all data entries for the world with the specified ID from the database.
|
||||
/// </summary>
|
||||
/// <param name="worldId">The ID of the world to delete all data entries for.</param>
|
||||
public void DeleteAllDataEntriesForWorld(string worldId)
|
||||
{
|
||||
sqlite.Execute("DELETE FROM data WHERE world_id = ?", worldId);
|
||||
}
|
||||
|
||||
public void Close()
|
||||
{
|
||||
sqlite.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Timers;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
// I don't think this applies to our use case, but I'm leaving it here for reference.
|
||||
// https://stackoverflow.com/questions/2519673/process-hasexited-returns-true-even-though-process-is-running
|
||||
// "When a process is started, it is assigned a PID. If the User is then prompted with the User Account Control dialog and selects 'Yes', the process is re-started and assigned a new PID."
|
||||
// There's no docs for this, but Process.HasExited also seems to be checked every time the property is accessed, so it's not cached. Which means Process.Refresh() is not needed for our use case.
|
||||
|
||||
/// <summary>
|
||||
/// A class that monitors given processes and raises events when they are started or exited.
|
||||
/// Intended to be used to monitor VRChat and VRChat-related processes.
|
||||
/// </summary>
|
||||
internal class ProcessMonitor
|
||||
{
|
||||
private readonly Dictionary<string, MonitoredProcess> monitoredProcesses;
|
||||
private readonly Timer monitorProcessTimer;
|
||||
|
||||
static ProcessMonitor()
|
||||
{
|
||||
Instance = new ProcessMonitor();
|
||||
}
|
||||
|
||||
public ProcessMonitor()
|
||||
{
|
||||
monitoredProcesses = new Dictionary<string, MonitoredProcess>();
|
||||
|
||||
monitorProcessTimer = new Timer();
|
||||
monitorProcessTimer.Interval = 1000;
|
||||
monitorProcessTimer.Elapsed += MonitorProcessTimer_Elapsed;
|
||||
}
|
||||
|
||||
public static ProcessMonitor Instance { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a monitored process is started.
|
||||
/// </summary>
|
||||
public event Action<MonitoredProcess> ProcessStarted;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a monitored process is exited.
|
||||
/// </summary>
|
||||
public event Action<MonitoredProcess> ProcessExited;
|
||||
|
||||
public void Init()
|
||||
{
|
||||
AddProcess("vrchat");
|
||||
AddProcess("vrserver");
|
||||
monitorProcessTimer.Start();
|
||||
}
|
||||
|
||||
public void Exit()
|
||||
{
|
||||
monitorProcessTimer.Stop();
|
||||
monitoredProcesses.Values.ToList().ForEach(x => x.ProcessExited());
|
||||
}
|
||||
|
||||
private void MonitorProcessTimer_Elapsed(object sender, ElapsedEventArgs e)
|
||||
{
|
||||
var processesNeedingUpdate = new List<MonitoredProcess>();
|
||||
|
||||
// Check if any of the monitored processes have been opened or closed.
|
||||
foreach (var keyValuePair in monitoredProcesses)
|
||||
{
|
||||
var monitoredProcess = keyValuePair.Value;
|
||||
|
||||
if (monitoredProcess.IsRunning)
|
||||
{
|
||||
if (monitoredProcess.Process == null || WinApi.HasProcessExited(monitoredProcess.Process.Id))
|
||||
{
|
||||
monitoredProcess.ProcessExited();
|
||||
ProcessExited?.Invoke(monitoredProcess);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
processesNeedingUpdate.Add(monitoredProcess);
|
||||
}
|
||||
}
|
||||
|
||||
// We do it this way so we're not constantly polling for processes if we don't actually need to (aka, all processes are already accounted for).
|
||||
if (processesNeedingUpdate.Count == 0)
|
||||
return;
|
||||
|
||||
var processes = Process.GetProcesses();
|
||||
foreach (var monitoredProcess in processesNeedingUpdate)
|
||||
{
|
||||
var process = processes.FirstOrDefault(p => string.Equals(p.ProcessName, monitoredProcess.ProcessName, StringComparison.OrdinalIgnoreCase));
|
||||
if (process == null)
|
||||
continue;
|
||||
|
||||
monitoredProcess.ProcessStarted(process);
|
||||
ProcessStarted?.Invoke(monitoredProcess);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a process if currently being monitored and if it is running.
|
||||
/// </summary>
|
||||
/// <param name="processName">The name of the process to check for.</param>
|
||||
/// <param name="ensureCheck">If true, will manually check if the given process is running should the the monitored process not be initialized yet.</param>
|
||||
/// <returns>Whether the given process is monitored and currently running.</returns>
|
||||
public bool IsProcessRunning(string processName, bool ensureCheck = false)
|
||||
{
|
||||
processName = processName.ToLower();
|
||||
if (!monitoredProcesses.TryGetValue(processName, out var process))
|
||||
return false;
|
||||
|
||||
if (ensureCheck && process.Process == null)
|
||||
return Process.GetProcessesByName(processName).FirstOrDefault() != null;
|
||||
|
||||
return process.IsRunning;
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a process to be monitored.
|
||||
/// </summary>
|
||||
/// <param name="process"></param>
|
||||
public void AddProcess(Process process)
|
||||
{
|
||||
var processName = process.ProcessName.ToLower();
|
||||
if (monitoredProcesses.ContainsKey(processName))
|
||||
return;
|
||||
|
||||
monitoredProcesses.Add(processName, new MonitoredProcess(process));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a process to be monitored.
|
||||
/// </summary>
|
||||
/// <param name="processName"></param>
|
||||
public void AddProcess(string processName)
|
||||
{
|
||||
processName = processName.ToLower();
|
||||
if (monitoredProcesses.ContainsKey(processName))
|
||||
return;
|
||||
|
||||
monitoredProcesses.Add(processName, new MonitoredProcess(processName));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a process from being monitored.
|
||||
/// </summary>
|
||||
/// <param name="processName"></param>
|
||||
public void RemoveProcess(string processName)
|
||||
{
|
||||
processName = processName.ToLower();
|
||||
monitoredProcesses.Remove(processName);
|
||||
}
|
||||
}
|
||||
|
||||
internal class MonitoredProcess
|
||||
{
|
||||
public MonitoredProcess(Process process)
|
||||
{
|
||||
Process = process;
|
||||
ProcessName = process.ProcessName.ToLower();
|
||||
|
||||
if (!WinApi.HasProcessExited(process.Id))
|
||||
IsRunning = true;
|
||||
}
|
||||
|
||||
public MonitoredProcess(string processName)
|
||||
{
|
||||
ProcessName = processName;
|
||||
IsRunning = false;
|
||||
}
|
||||
|
||||
public Process Process { get; private set; }
|
||||
public string ProcessName { get; private set; }
|
||||
public bool IsRunning { get; private set; }
|
||||
public DateTime LastExitTime { get; private set; }
|
||||
|
||||
public bool HasName(string processName)
|
||||
{
|
||||
return ProcessName.Equals(processName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public void ProcessExited()
|
||||
{
|
||||
IsRunning = false;
|
||||
Process?.Dispose();
|
||||
Process = null;
|
||||
LastExitTime = DateTime.Now;
|
||||
}
|
||||
|
||||
public void ProcessStarted(Process process)
|
||||
{
|
||||
// check last exit time to prevent status flapping
|
||||
if (LastExitTime != DateTime.MinValue && DateTime.Now - LastExitTime < TimeSpan.FromSeconds(5))
|
||||
return;
|
||||
|
||||
Process = process;
|
||||
ProcessName = process.ProcessName.ToLower();
|
||||
IsRunning = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// 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 NLog;
|
||||
using NLog.Targets;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
public static class Program
|
||||
{
|
||||
public static string BaseDirectory { get; private set; }
|
||||
public static readonly string AppDataDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VRCX");
|
||||
public static string ConfigLocation;
|
||||
public static string Version { get; private set; }
|
||||
public static bool LaunchDebug;
|
||||
public static bool GPUFix;
|
||||
private static readonly NLog.Logger logger = NLog.LogManager.GetLogger("VRCX");
|
||||
static Program()
|
||||
{
|
||||
BaseDirectory = AppDomain.CurrentDomain.BaseDirectory;
|
||||
ConfigLocation = Path.Combine(Program.AppDataDirectory, "VRCX.sqlite3");
|
||||
|
||||
if (!Directory.Exists(AppDataDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(AppDataDirectory);
|
||||
|
||||
// Migrate config to AppData
|
||||
if (File.Exists(Path.Combine(BaseDirectory, "VRCX.json")))
|
||||
{
|
||||
File.Move(Path.Combine(BaseDirectory, "VRCX.json"), Path.Combine(AppDataDirectory, "VRCX.json"));
|
||||
File.Copy(Path.Combine(AppDataDirectory, "VRCX.json"), Path.Combine(AppDataDirectory, "VRCX-backup.json"));
|
||||
}
|
||||
if (File.Exists(Path.Combine(BaseDirectory, "VRCX.sqlite3")))
|
||||
{
|
||||
File.Move(Path.Combine(BaseDirectory, "VRCX.sqlite3"), Path.Combine(AppDataDirectory, "VRCX.sqlite3"));
|
||||
File.Copy(Path.Combine(AppDataDirectory, "VRCX.sqlite3"), Path.Combine(AppDataDirectory, "VRCX-backup.sqlite3"));
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate cache to userdata for Cef 115 update
|
||||
var oldCachePath = Path.Combine(AppDataDirectory, "cache");
|
||||
if (Directory.Exists(oldCachePath))
|
||||
{
|
||||
var newCachePath = Path.Combine(AppDataDirectory, "userdata", "cache");
|
||||
if (Directory.Exists(newCachePath))
|
||||
Directory.Delete(newCachePath, true);
|
||||
Directory.Move(oldCachePath, newCachePath);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureLogger()
|
||||
{
|
||||
NLog.LogManager.Setup().LoadConfiguration(builder =>
|
||||
{
|
||||
|
||||
var fileTarget = new FileTarget("fileTarget")
|
||||
{
|
||||
FileName = Path.Combine(AppDataDirectory, "logs", "VRCX.log"),
|
||||
//Layout = "${longdate} [${level:uppercase=true}] ${logger} - ${message} ${exception:format=tostring}",
|
||||
// Layout with padding between the level/logger and message so that the message always starts at the same column
|
||||
Layout = "${longdate} [${level:uppercase=true:padding=-5}] ${logger:padding=-20} - ${message} ${exception:format=tostring}",
|
||||
ArchiveFileName = Path.Combine(AppDataDirectory, "logs", "VRCX.{#}.log"),
|
||||
ArchiveNumbering = ArchiveNumberingMode.DateAndSequence,
|
||||
ArchiveEvery = FileArchivePeriod.Day,
|
||||
MaxArchiveFiles = 4,
|
||||
MaxArchiveDays = 7,
|
||||
ArchiveAboveSize = 10000000,
|
||||
ArchiveOldFileOnStartup = true,
|
||||
ConcurrentWrites = true,
|
||||
KeepFileOpen = true,
|
||||
AutoFlush = true,
|
||||
Encoding = System.Text.Encoding.UTF8
|
||||
};
|
||||
|
||||
if (Program.LaunchDebug)
|
||||
{
|
||||
builder.ForLogger().FilterMinLevel(LogLevel.Debug).WriteTo(fileTarget);
|
||||
}
|
||||
else
|
||||
{
|
||||
#if DEBUG
|
||||
// Archive maximum of 3 files 10MB each, kept for a maximum of 7 days
|
||||
builder.ForLogger().FilterMinLevel(LogLevel.Debug).WriteTo(fileTarget);
|
||||
#else
|
||||
builder.ForLogger().FilterMinLevel(LogLevel.Debug).WriteTo(fileTarget);
|
||||
#endif
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[STAThread]
|
||||
private static void Main()
|
||||
{
|
||||
ConfigureLogger();
|
||||
|
||||
try
|
||||
{
|
||||
Run();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.Fatal(e, "Unhandled Exception, program dying");
|
||||
MessageBox.Show(e.ToString(), "PLEASE REPORT IN https://vrcx.pypy.moe/discord", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
Environment.Exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
private static void GetVersion()
|
||||
{
|
||||
var buildName = "VRCX";
|
||||
try
|
||||
{
|
||||
Version = $"{buildName} {File.ReadAllText(Path.Combine(BaseDirectory, "Version"))}";
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
Version = $"{buildName} Build";
|
||||
}
|
||||
}
|
||||
|
||||
private static void Run()
|
||||
{
|
||||
Update.Check();
|
||||
StartupArgs.ArgsCheck();
|
||||
GetVersion();
|
||||
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
|
||||
logger.Info("{0} Starting...", Version);
|
||||
|
||||
// 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();
|
||||
SQLiteLegacy.Instance.Init();
|
||||
VRCXStorage.Load();
|
||||
LoadFromConfig();
|
||||
CpuMonitor.Instance.Init();
|
||||
Discord.Instance.Init();
|
||||
WebApi.Instance.Init();
|
||||
LogWatcher.Instance.Init();
|
||||
AutoAppLaunchManager.Instance.Init();
|
||||
|
||||
CefService.Instance.Init();
|
||||
IPCServer.Instance.Init();
|
||||
VRCXVR.Instance.Init();
|
||||
Application.Run(new MainForm());
|
||||
logger.Info("{0} Exiting...", Version);
|
||||
WebApi.Instance.SaveCookies();
|
||||
VRCXVR.Instance.Exit();
|
||||
CefService.Instance.Exit();
|
||||
|
||||
AutoAppLaunchManager.Instance.Exit();
|
||||
LogWatcher.Instance.Exit();
|
||||
WebApi.Instance.Exit();
|
||||
worldDBServer.Stop();
|
||||
|
||||
Discord.Instance.Exit();
|
||||
CpuMonitor.Instance.Exit();
|
||||
VRCXStorage.Save();
|
||||
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)
|
||||
GPUFix = VRCXStorage.Instance.Get("VRCX_GPUFix") == "true";
|
||||
}
|
||||
}
|
||||
}
|
||||
+4956
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NLog;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
internal static class ScreenshotHelper
|
||||
{
|
||||
private static readonly ILogger logger = LogManager.GetCurrentClassLogger();
|
||||
private static readonly byte[] pngSignatureBytes = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
|
||||
private static readonly ScreenshotMetadataDatabase cacheDatabase = new ScreenshotMetadataDatabase(Path.Combine(Program.AppDataDirectory, "metadataCache.db"));
|
||||
private static readonly Dictionary<string, ScreenshotMetadata> metadataCache = new Dictionary<string, ScreenshotMetadata>();
|
||||
|
||||
public enum ScreenshotSearchType
|
||||
{
|
||||
Username,
|
||||
UserID,
|
||||
WorldName,
|
||||
WorldID,
|
||||
}
|
||||
|
||||
public static bool TryGetCachedMetadata(string filePath, out ScreenshotMetadata metadata)
|
||||
{
|
||||
if (metadataCache.TryGetValue(filePath, out metadata))
|
||||
return true;
|
||||
|
||||
int id = cacheDatabase.IsFileCached(filePath);
|
||||
|
||||
if (id != -1)
|
||||
{
|
||||
string metadataStr = cacheDatabase.GetMetadataById(id);
|
||||
var metadataObj = metadataStr == null ? null : JsonConvert.DeserializeObject<ScreenshotMetadata>(metadataStr);
|
||||
|
||||
metadataCache.Add(filePath, metadataObj);
|
||||
|
||||
metadata = metadataObj;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static List<ScreenshotMetadata> FindScreenshots(string query, string directory, ScreenshotSearchType searchType)
|
||||
{
|
||||
var result = new List<ScreenshotMetadata>();
|
||||
|
||||
var files = Directory.GetFiles(directory, "*.png", SearchOption.AllDirectories);
|
||||
|
||||
var addToCache = new List<MetadataCache>();
|
||||
|
||||
int amtFromCache = 0;
|
||||
foreach (var file in files)
|
||||
{
|
||||
ScreenshotMetadata metadata = null;
|
||||
|
||||
if (TryGetCachedMetadata(file, out metadata))
|
||||
{
|
||||
amtFromCache++;
|
||||
}
|
||||
else
|
||||
{
|
||||
metadata = GetScreenshotMetadata(file, false);
|
||||
var dbEntry = new MetadataCache()
|
||||
{
|
||||
FilePath = file,
|
||||
Metadata = null,
|
||||
CachedAt = DateTimeOffset.Now
|
||||
};
|
||||
|
||||
if (metadata == null || metadata.Error != null)
|
||||
{
|
||||
addToCache.Add(dbEntry);
|
||||
metadataCache.Add(file, null);
|
||||
continue;
|
||||
}
|
||||
|
||||
dbEntry.Metadata = JsonConvert.SerializeObject(metadata);
|
||||
addToCache.Add(dbEntry);
|
||||
metadataCache.Add(file, metadata);
|
||||
}
|
||||
|
||||
if (metadata == null) continue;
|
||||
|
||||
switch (searchType)
|
||||
{
|
||||
case ScreenshotSearchType.Username:
|
||||
if (metadata.ContainsPlayerName(query, true, true))
|
||||
result.Add(metadata);
|
||||
|
||||
break;
|
||||
case ScreenshotSearchType.UserID:
|
||||
if (metadata.ContainsPlayerID(query))
|
||||
result.Add(metadata);
|
||||
|
||||
break;
|
||||
case ScreenshotSearchType.WorldName:
|
||||
if (metadata.World.Name.IndexOf(query, StringComparison.OrdinalIgnoreCase) != -1)
|
||||
result.Add(metadata);
|
||||
|
||||
break;
|
||||
case ScreenshotSearchType.WorldID:
|
||||
if (metadata.World.Id == query)
|
||||
result.Add(metadata);
|
||||
|
||||
break;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
if (addToCache.Count > 0)
|
||||
cacheDatabase.BulkAddMetadataCache(addToCache);
|
||||
|
||||
logger.ConditionalDebug("Found {0}/{1} screenshots matching query '{2}' of type '{3}'. {4}/{5} pulled from cache.", result.Count, files.Length, query, searchType, amtFromCache, files.Length);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves metadata from a PNG screenshot file and attempts to parse it.
|
||||
/// </summary>
|
||||
/// <param name="path">The path to the PNG screenshot file.</param>
|
||||
/// <returns>A JObject containing the metadata or null if no metadata was found.</returns>
|
||||
public static ScreenshotMetadata GetScreenshotMetadata(string path, bool includeJSON = false)
|
||||
{
|
||||
// Early return if file doesn't exist, or isn't a PNG(Check both extension and file header)
|
||||
if (!File.Exists(path) || !path.EndsWith(".png") || !IsPNGFile(path))
|
||||
return null;
|
||||
|
||||
///if (metadataCache.TryGetValue(path, out var cachedMetadata))
|
||||
// return cachedMetadata;
|
||||
|
||||
string metadataString;
|
||||
|
||||
// Get the metadata string from the PNG file
|
||||
try
|
||||
{
|
||||
metadataString = ReadPNGDescription(path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error(ex, "Failed to read PNG description for file '{0}'", path);
|
||||
return ScreenshotMetadata.JustError(path, "Failed to read PNG description. Check logs.");
|
||||
}
|
||||
|
||||
// If the metadata string is empty for some reason, there's nothing to parse.
|
||||
if (string.IsNullOrEmpty(metadataString))
|
||||
return null;
|
||||
|
||||
// Check for specific metadata string start sequences
|
||||
if (metadataString.StartsWith("lfs") || metadataString.StartsWith("screenshotmanager"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = ScreenshotHelper.ParseLfsPicture(metadataString);
|
||||
result.SourceFile = path;
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error(ex, "Failed to parse LFS/ScreenshotManager metadata for file '{0}'", path);
|
||||
return ScreenshotMetadata.JustError(path, "Failed to parse LFS/ScreenshotManager metadata.");
|
||||
}
|
||||
}
|
||||
|
||||
// If not JSON metadata, return early so we're not throwing/catching pointless exceptions
|
||||
if (!metadataString.StartsWith("{"))
|
||||
{
|
||||
logger.ConditionalDebug("Screenshot file '{0}' has unknown non-JSON metadata:\n{1}\n", path, metadataString);
|
||||
return ScreenshotMetadata.JustError(path, "File has unknown non-JSON metadata.");
|
||||
}
|
||||
|
||||
// Parse the metadata as VRCX JSON metadata
|
||||
try
|
||||
{
|
||||
var result = JsonConvert.DeserializeObject<ScreenshotMetadata>(metadataString);
|
||||
result.SourceFile = path;
|
||||
|
||||
if (includeJSON)
|
||||
result.JSON = metadataString;
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger.Error(ex, "Failed to parse screenshot metadata JSON for file '{0}'", path);
|
||||
return ScreenshotMetadata.JustError(path, "Failed to parse screenshot metadata JSON. Check logs.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a text description into a PNG file at the specified path.
|
||||
/// Creates an iTXt PNG chunk in the target file, using the Description tag, with the specified text.
|
||||
/// </summary>
|
||||
/// <param name="path">The file path of the PNG file in which the description is to be written.</param>
|
||||
/// <param name="text">The text description that is to be written into the PNG file.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the text description is successfully written to the PNG file;
|
||||
/// otherwise, <c>false</c>.
|
||||
/// </returns>
|
||||
public static bool WritePNGDescription(string path, string text)
|
||||
{
|
||||
if (!File.Exists(path) || !IsPNGFile(path)) return false;
|
||||
|
||||
var png = File.ReadAllBytes(path);
|
||||
|
||||
var newChunkIndex = FindEndOfChunk(png, "IHDR");
|
||||
if (newChunkIndex == -1) return false;
|
||||
|
||||
// If this file already has a text chunk, chances are it got logged twice for some reason. Stop.
|
||||
var existingiTXt = FindChunkIndex(png, "iTXt");
|
||||
if (existingiTXt != -1) return false;
|
||||
|
||||
var newChunk = new PNGChunk("iTXt");
|
||||
newChunk.InitializeTextChunk("Description", text);
|
||||
|
||||
var newFile = png.ToList();
|
||||
newFile.InsertRange(newChunkIndex, newChunk.ConstructChunkByteArray());
|
||||
|
||||
File.WriteAllBytes(path, newFile.ToArray());
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a text description from a PNG file at the specified path.
|
||||
/// Reads any existing iTXt PNG chunk in the target file, using the Description tag.
|
||||
/// </summary>
|
||||
/// <param name="path">The file path of the PNG file in which the description is to be read from.</param>
|
||||
/// <returns>
|
||||
/// The text description that is read from the PNG file.
|
||||
/// </returns>
|
||||
public static string ReadPNGDescription(string path)
|
||||
{
|
||||
if (!File.Exists(path) || !IsPNGFile(path)) return null;
|
||||
|
||||
using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 512))
|
||||
{
|
||||
var existingiTXt = FindChunk(stream, "iTXt", true);
|
||||
if (existingiTXt == null) return null;
|
||||
|
||||
return existingiTXt.GetText("Description");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the PNG resolution.
|
||||
/// </summary>
|
||||
/// <param name="path">The path.</param>
|
||||
/// <returns></returns>
|
||||
public static string ReadPNGResolution(string path)
|
||||
{
|
||||
if (!File.Exists(path) || !IsPNGFile(path)) return null;
|
||||
|
||||
using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 512))
|
||||
{
|
||||
var existingiHDR = FindChunk(stream, "IHDR", false);
|
||||
if (existingiHDR == null) return null;
|
||||
|
||||
return existingiHDR.GetResolution();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the specified file is a PNG file. We do this by checking if the first 8 bytes in the file path match the PNG signature.
|
||||
/// </summary>
|
||||
/// <param name="path">The path of the file to check.</param>
|
||||
/// <returns></returns>
|
||||
public static bool IsPNGFile(string path)
|
||||
{
|
||||
// Read only the first 8 bytes of the file to check if it's a PNG file instead of reading the entire thing into memory just to see check a couple bytes.
|
||||
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
{
|
||||
if (fs.Length < 33) return false; // I don't remember how I came up with this number, but a PNG file below this size is not going to be valid for our purposes.
|
||||
|
||||
var signature = new byte[8];
|
||||
fs.Read(signature, 0, 8);
|
||||
return signature.SequenceEqual(pngSignatureBytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the index of the first of a specified chunk type in the specified PNG file.
|
||||
/// </summary>
|
||||
/// <param name="png">Array of bytes representing a PNG file.</param>
|
||||
/// <param name="type">Type of PMG chunk to find</param>
|
||||
/// <returns></returns>
|
||||
private static int FindChunkIndex(byte[] png, string type)
|
||||
{
|
||||
int chunksProcessed = 0;
|
||||
int chunkSeekLimit = 5;
|
||||
|
||||
bool isLittleEndian = BitConverter.IsLittleEndian;
|
||||
|
||||
// The first 8 bytes of the file are the png signature, so we can skip them.
|
||||
var index = 8;
|
||||
|
||||
while (index < png.Length)
|
||||
{
|
||||
var chunkLength = new byte[4];
|
||||
Array.Copy(png, index, chunkLength, 0, 4);
|
||||
|
||||
// BitConverter wants little endian(unless your system is big endian for some reason), PNG multi-byte integers are big endian. So we reverse the array.
|
||||
if (isLittleEndian) Array.Reverse(chunkLength);
|
||||
|
||||
var length = BitConverter.ToInt32(chunkLength, 0);
|
||||
|
||||
// We don't need to reverse strings since UTF-8 strings aren't affected by endianess, given that they're a sequence of bytes.
|
||||
var chunkName = new byte[4];
|
||||
Array.Copy(png, index + 4, chunkName, 0, 4);
|
||||
var name = Encoding.UTF8.GetString(chunkName);
|
||||
|
||||
if (name == type)
|
||||
{
|
||||
return index;
|
||||
}
|
||||
|
||||
if (name == "IEND") // Nothing should exist past IEND in a normal png file, so we should stop parsing here to avoid trying to parse junk data.
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
// The chunk length is 4 bytes, the chunk name is 4 bytes, the chunk data is length bytes, and the chunk CRC is 4 bytes.
|
||||
// We add 12 to the index to get to the start of the next chunk in the file on the next loop.
|
||||
index += length + 12;
|
||||
chunksProcessed++;
|
||||
|
||||
if (chunksProcessed > chunkSeekLimit) break;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int FindChunkIndex(FileStream fs, string type, bool seekEnd)
|
||||
{
|
||||
int chunksProcessed = 0;
|
||||
int chunkSeekLimit = 5;
|
||||
|
||||
bool isLittleEndian = BitConverter.IsLittleEndian;
|
||||
|
||||
fs.Seek(8, SeekOrigin.Begin);
|
||||
|
||||
byte[] buffer = new byte[8];
|
||||
|
||||
while (fs.Position < fs.Length)
|
||||
{
|
||||
int chunkIndex = (int)fs.Position;
|
||||
|
||||
fs.Read(buffer, 0, 8); // Read both chunkLength and chunkName at once into this buffer
|
||||
|
||||
// BitConverter wants little endian(unless your system is big endian for some reason), PNG multi-byte integers are big endian. So we reverse the array.
|
||||
if (isLittleEndian) Array.Reverse(buffer, 0, 4); // Only reverse the chunkLength part
|
||||
|
||||
int chunkLength = BitConverter.ToInt32(buffer, 0);
|
||||
string chunkType = Encoding.UTF8.GetString(buffer, 4, 4); // We don't need to reverse strings since UTF-8 strings aren't affected by endianess, given that they're a sequence of bytes.
|
||||
|
||||
if (chunkType == type) return chunkIndex;
|
||||
if (chunkType == "IEND") return -1; // Nothing should exist past IEND in a normal png file, so we should stop parsing here to avoid trying to parse junk data.
|
||||
|
||||
// The chunk length is 4 bytes, the chunk name is 4 bytes, the chunk data is chunkLength bytes, and the chunk CRC after chunk data is 4 bytes.
|
||||
// We've already read the length/type which is the first 8 bytes, so we'll seek the chunk length + 4(CRC) to get to the start of the next chunk in the file.
|
||||
fs.Seek(chunkLength + 4, SeekOrigin.Current);
|
||||
chunksProcessed++;
|
||||
|
||||
if (chunksProcessed > chunkSeekLimit) break;
|
||||
}
|
||||
|
||||
// If we've processed more than 5 chunks and still haven't found the chunk we're looking for, we'll start searching from the end of the file.
|
||||
|
||||
// We start at an offset of 12 since the IEND chunk (should) always be the last chunk in the file, be 12 bytes, and we don't need to check it.
|
||||
fs.Seek(-12, SeekOrigin.End);
|
||||
|
||||
// We're going to read the last 4096 bytes of the file, which (should) be enough to find any trailing iTXt chunks we're looking for.
|
||||
// If an LFS screenshots has the metadata of like 80 players attached to it, this likely won't be enough to find the iTXt chunk.
|
||||
// I don't have any screenshots with that much metadata to test with and will not create them manually, so I'm not going to worry about it for now.
|
||||
var chunkNameBytes = Encoding.UTF8.GetBytes(type);
|
||||
fs.Seek(-4096, SeekOrigin.Current);
|
||||
|
||||
byte[] trailingBytes = new byte[4096];
|
||||
fs.Read(trailingBytes, 0, 4096);
|
||||
|
||||
// At this scale we can just brute force/naive search for the chunk name in the trailing bytes and performance will be fine.
|
||||
for (int i = 0; i <= trailingBytes.Length - chunkNameBytes.Length; i++)
|
||||
{
|
||||
bool isMatch = true;
|
||||
for (int j = 0; j < chunkNameBytes.Length; j++)
|
||||
{
|
||||
if (trailingBytes[i + j] != chunkNameBytes[j])
|
||||
{
|
||||
isMatch = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isMatch)
|
||||
{
|
||||
return (int)fs.Position - 4096 + i - 4;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the index of the end of the specified chunk type in the specified PNG file.
|
||||
/// </summary>
|
||||
/// <param name="png">Array of bytes representing a PNG file.</param>
|
||||
/// <param name="type">Type of PMG chunk to find</param>
|
||||
/// <returns></returns>
|
||||
private static int FindEndOfChunk(byte[] png, string type)
|
||||
{
|
||||
var index = FindChunkIndex(png, type);
|
||||
if (index == -1) return index;
|
||||
|
||||
var chunkLength = new byte[4];
|
||||
Array.Copy(png, index, chunkLength, 0, 4);
|
||||
Array.Reverse(chunkLength);
|
||||
var length = BitConverter.ToInt32(chunkLength, 0);
|
||||
|
||||
return index + length + 12;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the specified chunk type in the specified PNG file and returns it as a PNGChunk.
|
||||
/// </summary>
|
||||
/// <param name="png">Array of bytes representing a PNG file</param>
|
||||
/// <param name="type">Type of PMG chunk to find</param>
|
||||
/// <returns>PNGChunk</returns>
|
||||
private static PNGChunk FindChunk(byte[] png, string type)
|
||||
{
|
||||
var index = FindChunkIndex(png, type);
|
||||
if (index == -1) return null;
|
||||
|
||||
var chunkLength = new byte[4];
|
||||
Array.Copy(png, index, chunkLength, 0, 4);
|
||||
Array.Reverse(chunkLength);
|
||||
var length = BitConverter.ToInt32(chunkLength, 0);
|
||||
|
||||
var chunkData = new byte[length];
|
||||
Array.Copy(png, index + 8, chunkData, 0, length);
|
||||
|
||||
return new PNGChunk(type, chunkData);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the specified chunk type in the specified PNG file and returns it as a PNGChunk.
|
||||
/// </summary>
|
||||
/// <param name="fs">FileStream of a PNG file.</param>
|
||||
/// <param name="type">Type of PMG chunk to find</param>
|
||||
/// <returns>PNGChunk</returns>
|
||||
private static PNGChunk FindChunk(FileStream fs, string type, bool seekFromEnd)
|
||||
{
|
||||
var index = FindChunkIndex(fs, type, seekFromEnd);
|
||||
if (index == -1) return null;
|
||||
|
||||
// Seek back to start of found chunk
|
||||
fs.Seek(index, SeekOrigin.Begin);
|
||||
|
||||
var chunkLength = new byte[4];
|
||||
fs.Read(chunkLength, 0, 4);
|
||||
Array.Reverse(chunkLength);
|
||||
var length = BitConverter.ToInt32(chunkLength, 0);
|
||||
|
||||
// Skip the chunk type bytes
|
||||
fs.Seek(4, SeekOrigin.Current);
|
||||
|
||||
var chunkData = new byte[length];
|
||||
fs.Read(chunkData, 0, length);
|
||||
|
||||
return new PNGChunk(type, chunkData);
|
||||
}
|
||||
|
||||
/// <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 ScreenshotMetadata ParseLfsPicture(string metadataString)
|
||||
{
|
||||
var metadata = new ScreenshotMetadata();
|
||||
// LFS v2 format: https://github.com/knah/VRCMods/blob/c7e84936b52b6f476db452a37ab889eabe576845/LagFreeScreenshots/API/MetadataV2.cs#L35
|
||||
// Normal entry
|
||||
// lfs|2|author:usr_032383a7-748c-4fb2-94e4-bcb928e5de6b,Natsumi-sama|world:wrld_b016712b-5ce6-4bcb-9144-c8ed089b520f,35372,pet park test|pos:-60.49379,-0.002925932,5.805772|players:usr_9d73bff9-4543-4b6f-a004-9e257869ff50,-0.85,-0.17,-0.58,Olivia.;usr_3097f91e-a816-4c7a-a625-38fbfdee9f96,12.30,13.72,0.08,Zettai Ryouiki;usr_032383a7-748c-4fb2-94e4-bcb928e5de6b,0.68,0.32,-0.28,Natsumi-sama;usr_7525f45f-517e-442b-9abc-fbcfedb29f84,0.51,0.64,0.70,Weyoun
|
||||
// Entry with image rotation enabled (rq:)
|
||||
// lfs|2|author:usr_8c0a2f22-26d4-4dc9-8396-2ab40e3d07fc,knah|world:wrld_fb4edc80-6c48-43f2-9bd1-2fa9f1345621,35341,Luminescent Ledge|pos:8.231676,0.257298,-0.1983307|rq:2|players:usr_65b9eeeb-7c91-4ad2-8ce4-addb1c161cd6,0.74,0.59,1.57,Jakkuba;usr_6a50647f-d971-4281-90c3-3fe8caf2ba80,8.07,9.76,0.16,SopwithPup;usr_8c0a2f22-26d4-4dc9-8396-2ab40e3d07fc,0.26,1.03,-0.28,knah;usr_7f593ad1-3e9e-4449-a623-5c1c0a8d8a78,0.15,0.60,1.46,NekOwneD
|
||||
|
||||
// LFS v1 format: https://github.com/knah/VRCMods/blob/23c3311fdfc4af4b568eedfb2e366710f2a9f925/LagFreeScreenshots/LagFreeScreenshotsMod.cs
|
||||
// Why support this tho
|
||||
// lfs|1|world:wrld_6caf5200-70e1-46c2-b043-e3c4abe69e0f:47213,The Great Pug|players:usr_290c03d6-66cc-4f0e-b782-c07f5cfa8deb,VirtualTeacup;usr_290c03d6-66cc-4f0e-b782-c07f5cfa8deb,VirtualTeacup
|
||||
|
||||
// LFS CVR Edition v1 format: https://github.com/dakyneko/DakyModsCVR/blob/48eecd1bccd1a5b2ea844d899d59cf1186ec9912/LagFreeScreenshots/API/MetadataV2.cs#L41
|
||||
// lfs|cvr|1|author:047b30bd-089d-887c-8734-b0032df5d176,Hordini|world:2e73b387-c6d4-45e9-b998-0fd6aa122c1d,i+efec20004ef1cd8b-404003-93833f-1aee112a,Bono's Basement (Anime) (#816724)|pos:2.196716,0.01250899,-3.817466|players:5301af21-eb8d-7b36-3ef4-b623fa51c2c6,3.778407,0.01250887,-3.815876,DDAkebono;f9e5c36c-41b0-7031-1185-35b4034010c0,4.828233,0.01250893,-3.920135,Natsumi
|
||||
|
||||
var lfsParts = metadataString.Split('|');
|
||||
if (lfsParts[1] == "cvr")
|
||||
lfsParts = lfsParts.Skip(1).ToArray();
|
||||
|
||||
var version = int.Parse(lfsParts[1]);
|
||||
var application = lfsParts[0];
|
||||
metadata.Application = application;
|
||||
metadata.Version = version;
|
||||
|
||||
bool isCVR = application == "cvr";
|
||||
|
||||
if (application == "screenshotmanager")
|
||||
{
|
||||
// ScreenshotManager format: https://github.com/DragonPlayerX/ScreenshotManager/blob/33950b98003e795d29c68ce5fe1d86e7e65c92ad/ScreenshotManager/Core/FileDataHandler.cs#L94
|
||||
// screenshotmanager|0|author:usr_290c03d6-66cc-4f0e-b782-c07f5cfa8deb,VirtualTeacup|wrld_6caf5200-70e1-46c2-b043-e3c4abe69e0f,47213,The Great Pug
|
||||
var author = lfsParts[2].Split(',');
|
||||
|
||||
metadata.Author.Id = author[0];
|
||||
metadata.Author.DisplayName = author[1];
|
||||
|
||||
var world = lfsParts[3].Split(',');
|
||||
|
||||
metadata.World.Id = world[0];
|
||||
metadata.World.Name = world[2];
|
||||
metadata.World.InstanceId = string.Join(":", world[0], world[1]); // worldId:instanceId format, same as vrcx format, just minimal
|
||||
return metadata;
|
||||
}
|
||||
|
||||
for (var i = 2; i < lfsParts.Length; i++)
|
||||
{
|
||||
var split = lfsParts[i].Split(':');
|
||||
var key = split[0];
|
||||
var value = split[1];
|
||||
|
||||
if (String.IsNullOrEmpty(value)) // One of my LFS files had an empty value for 'players:'. not pog
|
||||
continue;
|
||||
|
||||
var parts = value.Split(',');
|
||||
|
||||
switch (key)
|
||||
{
|
||||
case "author":
|
||||
metadata.Author.Id = isCVR ? string.Empty : parts[0];
|
||||
metadata.Author.DisplayName = isCVR ? $"{parts[1]} ({parts[0]})" : parts[1];
|
||||
break;
|
||||
|
||||
case "world":
|
||||
metadata.World.Id = isCVR || version == 1 ? string.Empty : parts[0];
|
||||
metadata.World.InstanceId = isCVR || version == 1 ? string.Empty : string.Join(":", parts[0], parts[1]); // worldId:instanceId format, same as vrcx format, just minimal
|
||||
metadata.World.Name = isCVR ? $"{parts[2]} ({parts[0]})" : (version == 1 ? value : parts[2]);
|
||||
break;
|
||||
|
||||
case "pos":
|
||||
float.TryParse(parts[0], out float x);
|
||||
float.TryParse(parts[1], out float y);
|
||||
float.TryParse(parts[2], out float z);
|
||||
|
||||
metadata.Pos = new Vector3(x, y, z);
|
||||
break;
|
||||
|
||||
// We don't use this, so don't parse it.
|
||||
/*case "rq":
|
||||
// Image rotation
|
||||
metadata.Add("rq", value);
|
||||
break;*/
|
||||
|
||||
case "players":
|
||||
var playersArray = metadata.Players;
|
||||
var players = value.Split(';');
|
||||
|
||||
foreach (var player in players)
|
||||
{
|
||||
var playerParts = player.Split(',');
|
||||
|
||||
float.TryParse(playerParts[1], out float x2);
|
||||
float.TryParse(playerParts[2], out float y2);
|
||||
float.TryParse(playerParts[3], out float z2);
|
||||
|
||||
var playerDetail = new ScreenshotMetadata.PlayerDetail
|
||||
{
|
||||
Id = isCVR ? string.Empty : playerParts[0],
|
||||
DisplayName = isCVR ? $"{playerParts[4]} ({playerParts[0]})" : playerParts[4],
|
||||
Pos = new Vector3(x2, y2, z2)
|
||||
};
|
||||
|
||||
playersArray.Add(playerDetail);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
|
||||
// See http://www.libpng.org/pub/png/spec/1.2/PNG-Chunks.html 4.2.3
|
||||
// Basic PNG Chunk Structure: Length(int, 4 bytes) | Type (string, 4 bytes) | chunk data (Depends on type) | 32-bit CRC code (4 bytes)
|
||||
// basic tEXt data structure: Keyword (1-79 bytes string) | Null separator (1 byte) | Text (x bytes)
|
||||
// basic iTXt data structure: Keyword (1-79 bytes string) | Null separator (1 byte) | Compression flag (1 byte) | Compression method (1 byte) | Language tag (0-x bytes) | Null separator | Translated keyword (0-x bytes) | Null separator | Text (x bytes)
|
||||
|
||||
// Proper practice here for arbitrary image processing would be to check the PNG file being passed for any existing iTXt chunks with the same keyword that we're trying to use; If we find one, we replace that chunk's data instead of creating a new chunk.
|
||||
// Luckily, VRChat should never do this! Bugs notwithstanding, we should never re-process a png file either. So we're just going to skip that logic.
|
||||
// This code would be HORRIBLE for general parsing of PNG files/metadata. It's not really meant to do that, it's just meant to do exactly what we need it to do.
|
||||
internal class PNGChunk
|
||||
{
|
||||
// crc lookup table
|
||||
private static uint[] crcTable;
|
||||
|
||||
// init lookup table and store crc for iTXt
|
||||
private static readonly uint iTXtCrc = Crc32(new[] { (byte)'i', (byte)'T', (byte)'X', (byte)'t' }, 0, 4, 0);
|
||||
private readonly Encoding keywordEncoding = Encoding.GetEncoding("ISO-8859-1"); // ISO-8859-1/Latin1 is the encoding used for the keyword in text chunks.
|
||||
public List<byte> ChunkDataBytes;
|
||||
public int ChunkDataLength;
|
||||
public string ChunkType;
|
||||
|
||||
public PNGChunk(string chunkType)
|
||||
{
|
||||
ChunkType = chunkType;
|
||||
ChunkDataBytes = new List<byte>();
|
||||
}
|
||||
|
||||
public PNGChunk(string chunkType, byte[] bytes)
|
||||
{
|
||||
ChunkType = chunkType;
|
||||
ChunkDataBytes = bytes.ToList();
|
||||
ChunkDataLength = bytes.Length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes this PNGChunk's data in the format of an iTXt chunk with the specified keyword and text.
|
||||
/// </summary>
|
||||
/// <param name="keyword">Keyword for text chunk</param>
|
||||
/// <param name="text">Text data for text chunk</param>
|
||||
public void InitializeTextChunk(string keyword, string text)
|
||||
{
|
||||
// Create our chunk data byte array
|
||||
ChunkDataBytes.AddRange(keywordEncoding.GetBytes(keyword)); // keyword
|
||||
ChunkDataBytes.Add(0x0); // Null separator
|
||||
ChunkDataBytes.Add(0x0); // Compression flag
|
||||
ChunkDataBytes.Add(0x0); // Compression method
|
||||
ChunkDataBytes.Add(0x0); // Null separator (skipping over language tag byte)
|
||||
ChunkDataBytes.Add(0x0); // Null separator (skipping over translated keyword byte)
|
||||
ChunkDataBytes.AddRange(Encoding.UTF8.GetBytes(text)); // our text
|
||||
|
||||
ChunkDataLength = ChunkDataBytes.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constructs and returns a full, coherent PNG chunk from this PNGChunk's data.
|
||||
/// </summary>
|
||||
/// <returns>PNG chunk byte array</returns>
|
||||
public byte[] ConstructChunkByteArray()
|
||||
{
|
||||
var chunk = new List<byte>();
|
||||
|
||||
var chunkLengthBytes = BitConverter.GetBytes(ChunkDataLength);
|
||||
var chunkCRCBytes = BitConverter.GetBytes(Crc32(ChunkDataBytes.ToArray(), 0, ChunkDataLength, iTXtCrc));
|
||||
|
||||
// Reverse the chunk length bytes/CRC bytes if system is little endian since PNG integers are big endian
|
||||
if (BitConverter.IsLittleEndian)
|
||||
{
|
||||
Array.Reverse(chunkLengthBytes);
|
||||
Array.Reverse(chunkCRCBytes);
|
||||
}
|
||||
|
||||
chunk.AddRange(chunkLengthBytes); // add data length
|
||||
chunk.AddRange(Encoding.UTF8.GetBytes(ChunkType)); // add chunk type
|
||||
chunk.AddRange(ChunkDataBytes); // Add chunk data
|
||||
chunk.AddRange(chunkCRCBytes); // Add chunk CRC32 hash.
|
||||
|
||||
return chunk.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the text from an iTXt chunk
|
||||
/// </summary>
|
||||
/// <param name="keyword">Keyword of the text chunk</param>
|
||||
/// <returns>Text from chunk.</returns>
|
||||
public string GetText(string keyword)
|
||||
{
|
||||
var offset = keywordEncoding.GetByteCount(keyword) + 5;
|
||||
return Encoding.UTF8.GetString(ChunkDataBytes.ToArray(), offset, ChunkDataBytes.Count - offset);
|
||||
}
|
||||
|
||||
public string GetResolution()
|
||||
{
|
||||
var x = BitConverter.ToInt32(ChunkDataBytes.Take(4).Reverse().ToArray(), 0);
|
||||
var y = BitConverter.ToInt32(ChunkDataBytes.Skip(4).Take(4).Reverse().ToArray(), 0);
|
||||
return $"{x}x{y}";
|
||||
}
|
||||
|
||||
// Crc32 implementation from
|
||||
// https://web.archive.org/web/20150825201508/http://upokecenter.dreamhosters.com/articles/png-image-encoder-in-c/
|
||||
private static uint Crc32(byte[] stream, int offset, int length, uint crc)
|
||||
{
|
||||
uint c;
|
||||
if (crcTable == null)
|
||||
{
|
||||
crcTable = new uint[256];
|
||||
for (uint n = 0; n <= 255; n++)
|
||||
{
|
||||
c = n;
|
||||
for (var k = 0; k <= 7; k++)
|
||||
{
|
||||
if ((c & 1) == 1)
|
||||
c = 0xEDB88320 ^ ((c >> 1) & 0x7FFFFFFF);
|
||||
else
|
||||
c = (c >> 1) & 0x7FFFFFFF;
|
||||
}
|
||||
|
||||
crcTable[n] = c;
|
||||
}
|
||||
}
|
||||
|
||||
c = crc ^ 0xffffffff;
|
||||
var endOffset = offset + length;
|
||||
for (var i = offset; i < endOffset; i++)
|
||||
{
|
||||
c = crcTable[(c ^ stream[i]) & 255] ^ ((c >> 8) & 0xFFFFFF);
|
||||
}
|
||||
|
||||
return c ^ 0xffffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
public class ScreenshotMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the application writing to the screenshot. Should be VRCX.
|
||||
/// </summary>
|
||||
public string Application { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The version of this schema. If the format changes, this number should change.
|
||||
/// </summary>
|
||||
public int Version { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The details of the user that took the picture.
|
||||
/// </summary>
|
||||
public AuthorDetail Author { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Information about the world the picture was taken in.
|
||||
/// </summary>
|
||||
public WorldDetail World { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A list of players in the world at the time the picture was taken.
|
||||
/// </summary>
|
||||
public List<PlayerDetail> Players { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// If this class was serialized from a file, this should be the path to the file.
|
||||
/// </summary>
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
internal string SourceFile;
|
||||
|
||||
/// <summary>
|
||||
/// The position of the player that took the picture when the shot was taken. Not written by VRCX, this is legacy support for reading LFS files.
|
||||
/// </summary>
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
public Vector3? Pos { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Any error that occurred while parsing the file. This being true implies nothing else is set.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
internal string Error;
|
||||
|
||||
[JsonIgnore]
|
||||
internal string JSON;
|
||||
|
||||
public ScreenshotMetadata()
|
||||
{
|
||||
Application = "VRCX";
|
||||
Version = 1;
|
||||
Author = new AuthorDetail();
|
||||
World = new WorldDetail();
|
||||
Players = new List<PlayerDetail>();
|
||||
}
|
||||
|
||||
public static ScreenshotMetadata JustError(string sourceFile, string error)
|
||||
{
|
||||
return new ScreenshotMetadata
|
||||
{
|
||||
Error = error,
|
||||
SourceFile = sourceFile
|
||||
};
|
||||
}
|
||||
|
||||
public bool ContainsPlayerID(string id)
|
||||
{
|
||||
return Players.Any(p => p.Id == id);
|
||||
}
|
||||
|
||||
public bool ContainsPlayerName(string playerName, bool partial, bool ignoreCase)
|
||||
{
|
||||
var comparisonType = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
|
||||
if (partial)
|
||||
{
|
||||
return Players.Any(p => p.DisplayName.IndexOf(playerName, comparisonType) != -1);
|
||||
}
|
||||
|
||||
return Players.Any(p => p.DisplayName.Equals(playerName, comparisonType));
|
||||
}
|
||||
|
||||
public class AuthorDetail
|
||||
{
|
||||
/// <summary>
|
||||
/// The ID of the user.
|
||||
/// </summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The display name of the user.
|
||||
/// </summary>
|
||||
public string DisplayName { get; set; }
|
||||
}
|
||||
|
||||
public class WorldDetail
|
||||
{
|
||||
/// <summary>
|
||||
/// The ID of the world.
|
||||
/// </summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The name of the world.
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The full ID of the game instance.
|
||||
/// </summary>
|
||||
public string InstanceId { get; set; }
|
||||
}
|
||||
|
||||
public class PlayerDetail
|
||||
{
|
||||
/// <summary>
|
||||
/// The ID of the player in the world.
|
||||
/// </summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The display name of the player in the world.
|
||||
/// </summary>
|
||||
public string DisplayName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The position of the player in the world. Not written by VRCX, this is legacy support for reading LFS files.
|
||||
/// </summary>
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
public Vector3? Pos { get; set; } = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using SQLite;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
[Table("cache")]
|
||||
public class MetadataCache
|
||||
{
|
||||
[PrimaryKey, AutoIncrement]
|
||||
[Column("id")]
|
||||
public int Id { get; set; }
|
||||
[Column("file_path"), NotNull, Indexed]
|
||||
public string FilePath { get; set; }
|
||||
[Column("metadata")]
|
||||
public string Metadata { get; set; }
|
||||
[Column("cached_at"), NotNull]
|
||||
public DateTimeOffset CachedAt { get; set; }
|
||||
}
|
||||
|
||||
// Imagine using SQLite to store json strings in one table lmao
|
||||
// Couldn't be me... oh wait
|
||||
internal class ScreenshotMetadataDatabase
|
||||
{
|
||||
private SQLiteConnection sqlite;
|
||||
|
||||
public ScreenshotMetadataDatabase(string databaseLocation)
|
||||
{
|
||||
var options = new SQLiteConnectionString(databaseLocation, true);
|
||||
sqlite = new SQLiteConnection(options);
|
||||
|
||||
sqlite.CreateTable<MetadataCache>();
|
||||
}
|
||||
|
||||
public void AddMetadataCache(string filePath, string metadata)
|
||||
{
|
||||
var cache = new MetadataCache()
|
||||
{
|
||||
FilePath = filePath,
|
||||
Metadata = metadata,
|
||||
CachedAt = DateTimeOffset.Now
|
||||
};
|
||||
sqlite.Insert(cache);
|
||||
}
|
||||
|
||||
public void BulkAddMetadataCache(IEnumerable<MetadataCache> cache)
|
||||
{
|
||||
sqlite.InsertAll(cache, runInTransaction: true);
|
||||
}
|
||||
|
||||
public int IsFileCached(string filePath)
|
||||
{
|
||||
var query = sqlite.Table<MetadataCache>().Where(c => c.FilePath == filePath).Select(c => c.Id);
|
||||
|
||||
if (query.Any())
|
||||
{
|
||||
return query.First();
|
||||
}
|
||||
else
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
public string GetMetadata(string filePath)
|
||||
{
|
||||
var query = sqlite.Table<MetadataCache>().Where(c => c.FilePath == filePath).Select(c => c.Metadata);
|
||||
return query.FirstOrDefault();
|
||||
}
|
||||
|
||||
public string GetMetadataById(int id)
|
||||
{
|
||||
var query = sqlite.Table<MetadataCache>().Where(c => c.Id == id).Select(c => c.Metadata);
|
||||
return query.FirstOrDefault();
|
||||
}
|
||||
|
||||
public void Close()
|
||||
{
|
||||
sqlite.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// 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.Collections.Generic;
|
||||
using System.Threading;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
public class SharedVariable
|
||||
{
|
||||
public static readonly SharedVariable Instance;
|
||||
private readonly ReaderWriterLockSlim m_MapLock;
|
||||
private readonly Dictionary<string, string> m_Map;
|
||||
|
||||
static SharedVariable()
|
||||
{
|
||||
Instance = new SharedVariable();
|
||||
}
|
||||
|
||||
public SharedVariable()
|
||||
{
|
||||
m_MapLock = new ReaderWriterLockSlim();
|
||||
m_Map = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
m_MapLock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
m_Map.Clear();
|
||||
}
|
||||
finally
|
||||
{
|
||||
m_MapLock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
public string Get(string key)
|
||||
{
|
||||
m_MapLock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
if (m_Map.TryGetValue(key, out string value) == true)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
m_MapLock.ExitReadLock();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public void Set(string key, string value)
|
||||
{
|
||||
m_MapLock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
m_Map[key] = value;
|
||||
}
|
||||
finally
|
||||
{
|
||||
m_MapLock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
public bool Remove(string key)
|
||||
{
|
||||
m_MapLock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
return m_Map.Remove(key);
|
||||
}
|
||||
finally
|
||||
{
|
||||
m_MapLock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// 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.Diagnostics;
|
||||
using System.IO.Pipes;
|
||||
using System.Text;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
internal class StartupArgs
|
||||
{
|
||||
public static string LaunchCommand;
|
||||
public static Process[] processList;
|
||||
|
||||
public static void ArgsCheck()
|
||||
{
|
||||
var args = Environment.GetCommandLineArgs();
|
||||
processList = Process.GetProcessesByName("VRCX");
|
||||
|
||||
var isDebug = false;
|
||||
Debug.Assert(isDebug = true);
|
||||
|
||||
foreach (var arg in args)
|
||||
{
|
||||
if (arg.Contains("--gpufix"))
|
||||
Program.GPUFix = true;
|
||||
|
||||
if (arg.Length > 12 && arg.Substring(0, 12) == "/uri=vrcx://")
|
||||
LaunchCommand = arg.Substring(12);
|
||||
|
||||
if (arg.Length > 8 && arg.Substring(0, 8) == "--config")
|
||||
Program.ConfigLocation = arg.Substring(9);
|
||||
|
||||
if ((arg.Length >= 7 && arg.Substring(0, 7) == "--debug") || isDebug)
|
||||
Program.LaunchDebug = true;
|
||||
}
|
||||
|
||||
if (processList.Length > 1 && string.IsNullOrEmpty(LaunchCommand))
|
||||
{
|
||||
var result = MessageBox.Show("VRCX is already running, start another instance?", "VRCX", MessageBoxButtons.YesNo, MessageBoxIcon.Question);
|
||||
if (result == DialogResult.Yes)
|
||||
return;
|
||||
}
|
||||
|
||||
if (processList.Length > 1)
|
||||
{
|
||||
IPCToMain();
|
||||
Environment.Exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
private static void IPCToMain()
|
||||
{
|
||||
new IPCServer().CreateIPCServer();
|
||||
var ipcClient = new NamedPipeClientStream(".", "vrcx-ipc", PipeDirection.InOut);
|
||||
ipcClient.Connect();
|
||||
|
||||
if (ipcClient.IsConnected)
|
||||
{
|
||||
var buffer = Encoding.UTF8.GetBytes($"{{\"type\":\"LaunchCommand\",\"command\":\"{LaunchCommand}\"}}" + (char)0x00);
|
||||
ipcClient.BeginWrite(buffer, 0, buffer.Length, IPCClient.Close, ipcClient);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// 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.IO;
|
||||
using System.Windows.Forms;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
internal class Update
|
||||
{
|
||||
private static readonly string VRCX_Setup_Executable = Path.Combine(Program.AppDataDirectory, "VRCX_Setup.exe");
|
||||
private static readonly string Update_Executable = Path.Combine(Program.AppDataDirectory, "update.exe");
|
||||
|
||||
public static void Check()
|
||||
{
|
||||
if (Process.GetProcessesByName("VRCX_Setup").Length > 0)
|
||||
Environment.Exit(0);
|
||||
var setupHash = Path.Combine(Program.AppDataDirectory, "sha256sum.txt");
|
||||
if (File.Exists(setupHash))
|
||||
File.Delete(setupHash);
|
||||
var tempDownload = Path.Combine(Program.AppDataDirectory, "tempDownload.exe");
|
||||
if (File.Exists(tempDownload))
|
||||
File.Delete(tempDownload);
|
||||
if (File.Exists(VRCX_Setup_Executable))
|
||||
File.Delete(VRCX_Setup_Executable);
|
||||
if (File.Exists(Update_Executable))
|
||||
Install();
|
||||
}
|
||||
|
||||
public static void Install()
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Move(Update_Executable, VRCX_Setup_Executable);
|
||||
var VRCXProcess = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = VRCX_Setup_Executable,
|
||||
Arguments = "/S"
|
||||
}
|
||||
};
|
||||
VRCXProcess.Start();
|
||||
Environment.Exit(0);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
MessageBox.Show(e.ToString(), "Update failed", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using CefSharp;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
public static class Util
|
||||
{
|
||||
public static void ApplyJavascriptBindings(IJavascriptObjectRepository repository)
|
||||
{
|
||||
repository.NameConverter = null;
|
||||
repository.Register("AppApi", AppApi.Instance, true);
|
||||
repository.Register("SharedVariable", SharedVariable.Instance, false);
|
||||
repository.Register("WebApi", WebApi.Instance, true);
|
||||
repository.Register("VRCXStorage", VRCXStorage.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// 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.IO;
|
||||
using System.Threading;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
public class VRCXStorage
|
||||
{
|
||||
public static readonly VRCXStorage Instance;
|
||||
private static readonly ReaderWriterLockSlim m_Lock = new ReaderWriterLockSlim();
|
||||
private static Dictionary<string, string> m_Storage = new Dictionary<string, string>();
|
||||
private static readonly string m_JsonPath = Path.Combine(Program.AppDataDirectory, "VRCX.json");
|
||||
private static bool m_Dirty;
|
||||
|
||||
static VRCXStorage()
|
||||
{
|
||||
Instance = new VRCXStorage();
|
||||
}
|
||||
|
||||
public static void Load()
|
||||
{
|
||||
m_Lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
JsonSerializer.Deserialize(m_JsonPath, ref m_Storage);
|
||||
m_Dirty = false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
m_Lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
public static void Save()
|
||||
{
|
||||
m_Lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
if (m_Dirty)
|
||||
{
|
||||
JsonSerializer.Serialize(m_JsonPath, m_Storage);
|
||||
m_Dirty = false;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
m_Lock.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
public void Flush()
|
||||
{
|
||||
Save();
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
m_Lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
if (m_Storage.Count > 0)
|
||||
{
|
||||
m_Storage.Clear();
|
||||
m_Dirty = true;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
m_Lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
public bool Remove(string key)
|
||||
{
|
||||
m_Lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
var result = m_Storage.Remove(key);
|
||||
if (result)
|
||||
{
|
||||
m_Dirty = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
m_Lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
public string Get(string key)
|
||||
{
|
||||
m_Lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
return m_Storage.TryGetValue(key, out string value)
|
||||
? value
|
||||
: string.Empty;
|
||||
}
|
||||
finally
|
||||
{
|
||||
m_Lock.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
public void Set(string key, string value)
|
||||
{
|
||||
m_Lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
m_Storage[key] = value;
|
||||
m_Dirty = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
m_Lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
public string GetAll()
|
||||
{
|
||||
m_Lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
return System.Text.Json.JsonSerializer.Serialize(m_Storage);
|
||||
}
|
||||
finally
|
||||
{
|
||||
m_Lock.ExitReadLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
using CefSharp;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Runtime.Serialization.Formatters.Binary;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
public class WebApi
|
||||
{
|
||||
public static readonly WebApi Instance;
|
||||
public CookieContainer _cookieContainer;
|
||||
private bool _cookieDirty;
|
||||
private Timer _timer;
|
||||
|
||||
static WebApi()
|
||||
{
|
||||
Instance = new WebApi();
|
||||
ServicePointManager.DefaultConnectionLimit = 10;
|
||||
ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12;
|
||||
}
|
||||
|
||||
public WebApi()
|
||||
{
|
||||
_cookieContainer = new CookieContainer();
|
||||
_timer = new Timer(TimerCallback, null, -1, -1);
|
||||
}
|
||||
|
||||
private void TimerCallback(object state)
|
||||
{
|
||||
try
|
||||
{
|
||||
SaveCookies();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
internal void Init()
|
||||
{
|
||||
LoadCookies();
|
||||
_timer.Change(1000, 1000);
|
||||
}
|
||||
|
||||
internal void Exit()
|
||||
{
|
||||
_timer.Change(-1, -1);
|
||||
SaveCookies();
|
||||
}
|
||||
|
||||
public void ClearCookies()
|
||||
{
|
||||
_cookieContainer = new CookieContainer();
|
||||
SaveCookies();
|
||||
}
|
||||
|
||||
internal void LoadCookies()
|
||||
{
|
||||
SQLiteLegacy.Instance.ExecuteNonQuery("CREATE TABLE IF NOT EXISTS `cookies` (`key` TEXT PRIMARY KEY, `value` TEXT)");
|
||||
SQLiteLegacy.Instance.Execute((values) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var stream = new MemoryStream(Convert.FromBase64String((string)values[0])))
|
||||
{
|
||||
_cookieContainer = (CookieContainer)new BinaryFormatter().Deserialize(stream);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
},
|
||||
"SELECT `value` FROM `cookies` WHERE `key` = @key",
|
||||
new Dictionary<string, object>() {
|
||||
{"@key", "default"}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
internal void SaveCookies()
|
||||
{
|
||||
if (_cookieDirty == false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
using (var memoryStream = new MemoryStream())
|
||||
{
|
||||
new BinaryFormatter().Serialize(memoryStream, _cookieContainer);
|
||||
SQLiteLegacy.Instance.ExecuteNonQuery(
|
||||
"INSERT OR REPLACE INTO `cookies` (`key`, `value`) VALUES (@key, @value)",
|
||||
new Dictionary<string, object>() {
|
||||
{"@key", "default"},
|
||||
{"@value", Convert.ToBase64String(memoryStream.ToArray())}
|
||||
}
|
||||
);
|
||||
}
|
||||
_cookieDirty = false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public string GetCookies()
|
||||
{
|
||||
_cookieDirty = true; // force cookies to be saved for lastUserLoggedIn
|
||||
|
||||
using (var memoryStream = new MemoryStream())
|
||||
{
|
||||
new BinaryFormatter().Serialize(memoryStream, _cookieContainer);
|
||||
return Convert.ToBase64String(memoryStream.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
public void SetCookies(string cookies)
|
||||
{
|
||||
using (var stream = new MemoryStream(Convert.FromBase64String(cookies)))
|
||||
{
|
||||
_cookieContainer = (CookieContainer)new BinaryFormatter().Deserialize(stream);
|
||||
}
|
||||
|
||||
_cookieDirty = true; // force cookies to be saved for lastUserLoggedIn
|
||||
}
|
||||
|
||||
private static async Task LegacyImageUpload(HttpWebRequest request, IDictionary<string, object> options)
|
||||
{
|
||||
request.Method = "POST";
|
||||
string boundary = "---------------------------" + DateTime.Now.Ticks.ToString("x");
|
||||
request.ContentType = "multipart/form-data; boundary=" + boundary;
|
||||
Stream requestStream = request.GetRequestStream();
|
||||
if (options.TryGetValue("postData", out object postDataObject) == true)
|
||||
{
|
||||
Dictionary<string, string> postData = new Dictionary<string, string>();
|
||||
postData.Add("data", (string)postDataObject);
|
||||
string FormDataTemplate = "--{0}\r\nContent-Disposition: form-data; name=\"{1}\"\r\n\r\n{2}\r\n";
|
||||
foreach (string key in postData.Keys)
|
||||
{
|
||||
string item = string.Format(FormDataTemplate, boundary, key, postData[key]);
|
||||
byte[] itemBytes = Encoding.UTF8.GetBytes(item);
|
||||
await requestStream.WriteAsync(itemBytes, 0, itemBytes.Length);
|
||||
}
|
||||
}
|
||||
var imageData = options["imageData"] as string;
|
||||
byte[] fileToUpload = Convert.FromBase64CharArray(imageData.ToCharArray(), 0, imageData.Length);
|
||||
string fileFormKey = "image";
|
||||
string fileName = "image.png";
|
||||
string fileMimeType = "image/png";
|
||||
string HeaderTemplate = "--{0}\r\nContent-Disposition: form-data; name=\"{1}\"; filename=\"{2}\"\r\nContent-Type: {3}\r\n\r\n";
|
||||
string header = string.Format(HeaderTemplate, boundary, fileFormKey, fileName, fileMimeType);
|
||||
byte[] headerbytes = Encoding.UTF8.GetBytes(header);
|
||||
await requestStream.WriteAsync(headerbytes, 0, headerbytes.Length);
|
||||
using (MemoryStream fileStream = new MemoryStream(fileToUpload))
|
||||
{
|
||||
byte[] buffer = new byte[1024];
|
||||
int bytesRead = 0;
|
||||
while ((bytesRead = fileStream.Read(buffer, 0, buffer.Length)) != 0)
|
||||
{
|
||||
await requestStream.WriteAsync(buffer, 0, bytesRead);
|
||||
}
|
||||
fileStream.Close();
|
||||
}
|
||||
byte[] newlineBytes = Encoding.UTF8.GetBytes("\r\n");
|
||||
await requestStream.WriteAsync(newlineBytes, 0, newlineBytes.Length);
|
||||
byte[] endBytes = Encoding.UTF8.GetBytes("--" + boundary + "--");
|
||||
await requestStream.WriteAsync(endBytes, 0, endBytes.Length);
|
||||
requestStream.Close();
|
||||
}
|
||||
|
||||
private static async Task UploadFilePut(HttpWebRequest request, IDictionary<string, object> options)
|
||||
{
|
||||
request.Method = "PUT";
|
||||
request.ContentType = options["fileMIME"] as string;
|
||||
var fileData = options["fileData"] as string;
|
||||
var sentData = Convert.FromBase64CharArray(fileData.ToCharArray(), 0, fileData.Length);
|
||||
request.ContentLength = sentData.Length;
|
||||
using (var sendStream = request.GetRequestStream())
|
||||
{
|
||||
await sendStream.WriteAsync(sentData, 0, sentData.Length);
|
||||
sendStream.Close();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ImageUpload(HttpWebRequest request, IDictionary<string, object> options)
|
||||
{
|
||||
request.Method = "POST";
|
||||
string boundary = "---------------------------" + DateTime.Now.Ticks.ToString("x");
|
||||
request.ContentType = "multipart/form-data; boundary=" + boundary;
|
||||
Stream requestStream = request.GetRequestStream();
|
||||
if (options.TryGetValue("postData", out object postDataObject))
|
||||
{
|
||||
var jsonPostData = (JObject)JsonConvert.DeserializeObject((string)postDataObject);
|
||||
Dictionary<string, string> postData = new Dictionary<string, string>();
|
||||
string formDataTemplate = "--{0}\r\nContent-Disposition: form-data; name=\"{1}\"\r\n\r\n{2}\r\n";
|
||||
if (jsonPostData != null)
|
||||
{
|
||||
foreach (var data in jsonPostData)
|
||||
{
|
||||
string item = string.Format(formDataTemplate, boundary, data.Key, data.Value);
|
||||
byte[] itemBytes = Encoding.UTF8.GetBytes(item);
|
||||
await requestStream.WriteAsync(itemBytes, 0, itemBytes.Length);
|
||||
}
|
||||
}
|
||||
}
|
||||
var imageData = options["imageData"] as string;
|
||||
byte[] fileToUpload = Convert.FromBase64CharArray(imageData.ToCharArray(), 0, imageData.Length);
|
||||
string fileFormKey = "file";
|
||||
string fileName = "blob";
|
||||
string fileMimeType = "image/png";
|
||||
string HeaderTemplate = "--{0}\r\nContent-Disposition: form-data; name=\"{1}\"; filename=\"{2}\"\r\nContent-Type: {3}\r\n\r\n";
|
||||
string header = string.Format(HeaderTemplate, boundary, fileFormKey, fileName, fileMimeType);
|
||||
byte[] headerbytes = Encoding.UTF8.GetBytes(header);
|
||||
await requestStream.WriteAsync(headerbytes, 0, headerbytes.Length);
|
||||
using (MemoryStream fileStream = new MemoryStream(fileToUpload))
|
||||
{
|
||||
byte[] buffer = new byte[1024];
|
||||
int bytesRead = 0;
|
||||
while ((bytesRead = fileStream.Read(buffer, 0, buffer.Length)) != 0)
|
||||
{
|
||||
await requestStream.WriteAsync(buffer, 0, bytesRead);
|
||||
}
|
||||
fileStream.Close();
|
||||
}
|
||||
byte[] newlineBytes = Encoding.UTF8.GetBytes("\r\n");
|
||||
await requestStream.WriteAsync(newlineBytes, 0, newlineBytes.Length);
|
||||
byte[] endBytes = Encoding.UTF8.GetBytes("--" + boundary + "--");
|
||||
await requestStream.WriteAsync(endBytes, 0, endBytes.Length);
|
||||
requestStream.Close();
|
||||
}
|
||||
|
||||
#pragma warning disable CS4014
|
||||
|
||||
public async void Execute(IDictionary<string, object> options, IJavascriptCallback callback)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = WebRequest.CreateHttp((string)options["url"]);
|
||||
request.CookieContainer = _cookieContainer;
|
||||
request.KeepAlive = true;
|
||||
request.UserAgent = Program.Version;
|
||||
|
||||
if (options.TryGetValue("headers", out object headers))
|
||||
{
|
||||
foreach (var header in (IEnumerable<KeyValuePair<string, object>>)headers)
|
||||
{
|
||||
var key = header.Key;
|
||||
var value = header.Value.ToString();
|
||||
|
||||
if (string.Compare(key, "Content-Type", StringComparison.OrdinalIgnoreCase) == 0)
|
||||
{
|
||||
request.ContentType = value;
|
||||
}
|
||||
else if (string.Compare(key, "Referer", StringComparison.OrdinalIgnoreCase) == 0)
|
||||
{
|
||||
request.Referer = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
request.Headers.Add(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.TryGetValue("method", out object method))
|
||||
{
|
||||
var _method = (string)method;
|
||||
request.Method = _method;
|
||||
|
||||
if (string.Compare(_method, "GET", StringComparison.OrdinalIgnoreCase) != 0 &&
|
||||
options.TryGetValue("body", out object body) == true)
|
||||
{
|
||||
using (var stream = await request.GetRequestStreamAsync())
|
||||
using (var streamWriter = new StreamWriter(stream))
|
||||
{
|
||||
await streamWriter.WriteAsync((string)body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.TryGetValue("uploadImage", out _))
|
||||
{
|
||||
await ImageUpload(request, options);
|
||||
}
|
||||
|
||||
if (options.TryGetValue("uploadFilePUT", out _))
|
||||
{
|
||||
await UploadFilePut(request, options);
|
||||
}
|
||||
|
||||
if (options.TryGetValue("uploadImageLegacy", out _))
|
||||
{
|
||||
await LegacyImageUpload(request, options);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using (var response = await request.GetResponseAsync() as HttpWebResponse)
|
||||
{
|
||||
if (response.Headers["Set-Cookie"] != null)
|
||||
{
|
||||
_cookieDirty = true;
|
||||
}
|
||||
using (var stream = response.GetResponseStream())
|
||||
using (var streamReader = new StreamReader(stream))
|
||||
{
|
||||
if (callback.CanExecute == true)
|
||||
{
|
||||
if (response.ContentType.Contains("image/") || response.ContentType.Contains("application/octet-stream"))
|
||||
{
|
||||
// base64 response data for image
|
||||
using (var memoryStream = new MemoryStream())
|
||||
{
|
||||
await stream.CopyToAsync(memoryStream);
|
||||
callback.ExecuteAsync(null, new
|
||||
{
|
||||
data = $"data:image/png;base64,{Convert.ToBase64String(memoryStream.ToArray())}",
|
||||
status = response.StatusCode
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
callback.ExecuteAsync(null, new
|
||||
{
|
||||
data = await streamReader.ReadToEndAsync(),
|
||||
status = response.StatusCode
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (WebException webException)
|
||||
{
|
||||
if (webException.Response is HttpWebResponse response)
|
||||
{
|
||||
if (response.Headers["Set-Cookie"] != null)
|
||||
{
|
||||
_cookieDirty = true;
|
||||
}
|
||||
using (var stream = response.GetResponseStream())
|
||||
using (var streamReader = new StreamReader(stream))
|
||||
{
|
||||
if (callback.CanExecute == true)
|
||||
{
|
||||
callback.ExecuteAsync(null, new
|
||||
{
|
||||
data = await streamReader.ReadToEndAsync(),
|
||||
status = response.StatusCode
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (callback.CanExecute == true)
|
||||
{
|
||||
callback.ExecuteAsync(webException.Message, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
if (callback.CanExecute == true)
|
||||
{
|
||||
// FIXME: 브라우저는 종료되었는데 얘는 이후에 실행되면 터짐
|
||||
callback.ExecuteAsync(e.Message, null);
|
||||
}
|
||||
}
|
||||
|
||||
callback.Dispose();
|
||||
}
|
||||
|
||||
#pragma warning restore CS4014
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// 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.Runtime.InteropServices;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
public static class WinApi
|
||||
{
|
||||
[DllImport("kernel32.dll", SetLastError = false)]
|
||||
public static extern void CopyMemory(IntPtr destination, IntPtr source, uint length);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
public static extern IntPtr FindWindow(string lpClassName, string lpWindowName);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||
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);
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern bool CloseHandle(IntPtr hObject);
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
private const int PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
/// <param name="process">The process to check.</param>
|
||||
/// <returns>true if the process has exited; otherwise, false.</returns>
|
||||
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;
|
||||
|
||||
try
|
||||
{
|
||||
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;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Imagine closing process handles.
|
||||
WinApi.CloseHandle(hProcess);
|
||||
}
|
||||
|
||||
|
||||
return exited;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
public class WinformBase : Form
|
||||
{
|
||||
protected override void OnHandleCreated(EventArgs e)
|
||||
{
|
||||
WinformThemer.SetThemeToGlobal(this);
|
||||
base.OnHandleCreated(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
//Based off DWMWA_USE_IMMERSIVE_DARK_MODE, documentation: https://docs.microsoft.com/en-us/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute
|
||||
//dwAttribute was 19 before Windows 20H1, 20 after Windows 20H1
|
||||
|
||||
internal static class WinformThemer
|
||||
{
|
||||
/// <summary>
|
||||
/// Flash both the window caption and taskbar button.
|
||||
/// This is equivalent to setting the FLASHW_CAPTION | FLASHW_TRAY flags.
|
||||
/// </summary>
|
||||
public const uint FLASHW_ALL = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Flash continuously until the window comes to the foreground.
|
||||
/// </summary>
|
||||
public const uint FLASHW_TIMERNOFG = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Private holder of current theme
|
||||
/// </summary>
|
||||
private static int currentTheme;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the global theme of the app
|
||||
/// Light = 0
|
||||
/// Dark = 1
|
||||
/// </summary>
|
||||
public static void SetGlobalTheme(int theme)
|
||||
{
|
||||
currentTheme = theme;
|
||||
|
||||
//Make a seperate list for all current forms (causes issues otherwise)
|
||||
var forms = new List<Form>();
|
||||
foreach (Form form in Application.OpenForms)
|
||||
{
|
||||
forms.Add(form);
|
||||
}
|
||||
|
||||
SetThemeToGlobal(forms);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the global theme of the app
|
||||
/// Light = 0
|
||||
/// Dark = 1
|
||||
/// </summary>
|
||||
public static int GetGlobalTheme()
|
||||
{
|
||||
return currentTheme;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set given form to the current global theme
|
||||
/// </summary>
|
||||
/// <param name="form"></param>
|
||||
public static void SetThemeToGlobal(Form form)
|
||||
{
|
||||
SetThemeToGlobal(new List<Form> { form });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set a list of given forms to the current global theme
|
||||
/// </summary>
|
||||
/// <param name="forms"></param>
|
||||
public static void SetThemeToGlobal(List<Form> forms)
|
||||
{
|
||||
MainForm.Instance.Invoke(new Action(() =>
|
||||
{
|
||||
//For each form, set the theme, then move focus onto it to force refresh
|
||||
foreach (var form in forms)
|
||||
{
|
||||
//Set the theme of the window
|
||||
SetThemeToGlobal(form.Handle);
|
||||
|
||||
//Change opacity to foce full redraw
|
||||
form.Opacity = 0.99999;
|
||||
form.Opacity = 1;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private static void SetThemeToGlobal(IntPtr handle)
|
||||
{
|
||||
if (GetTheme(handle) != currentTheme)
|
||||
{
|
||||
if (PInvoke.DwmSetWindowAttribute(handle, 19, new[] { currentTheme }, 4) != 0)
|
||||
PInvoke.DwmSetWindowAttribute(handle, 20, new[] { currentTheme }, 4);
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetTheme(IntPtr handle)
|
||||
{
|
||||
//Allocate needed memory
|
||||
var curThemePtr = Marshal.AllocHGlobal(4);
|
||||
|
||||
//See what window state it currently is
|
||||
if (PInvoke.DwmGetWindowAttribute(handle, 19, curThemePtr, 4) != 0)
|
||||
PInvoke.DwmGetWindowAttribute(handle, 20, curThemePtr, 4);
|
||||
|
||||
//Read current theme (light = 0, dark = 1)
|
||||
var theme = Marshal.ReadInt32(curThemePtr);
|
||||
|
||||
//Free previously allocated
|
||||
Marshal.FreeHGlobal(curThemePtr);
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
||||
public static void DoFunny()
|
||||
{
|
||||
foreach (Form form in Application.OpenForms)
|
||||
{
|
||||
PInvoke.SetWindowLong(form.Handle, -20, 0x00C00000);
|
||||
// PInvoke.SetWindowLong(form.Handle, -20, 0x00050100);
|
||||
}
|
||||
}
|
||||
|
||||
private static FLASHWINFO Create_FLASHWINFO(IntPtr handle, uint flags, uint count, uint timeout)
|
||||
{
|
||||
var fi = new FLASHWINFO();
|
||||
fi.cbSize = Convert.ToUInt32(Marshal.SizeOf(fi));
|
||||
fi.hwnd = handle;
|
||||
fi.dwFlags = flags;
|
||||
fi.uCount = count;
|
||||
fi.dwTimeout = timeout;
|
||||
return fi;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flash the spacified Window (Form) until it receives focus.
|
||||
/// </summary>
|
||||
/// <param name="form">The Form (Window) to Flash.</param>
|
||||
/// <returns></returns>
|
||||
public static bool Flash(Form form)
|
||||
{
|
||||
var fi = Create_FLASHWINFO(form.Handle, FLASHW_ALL | FLASHW_TIMERNOFG, uint.MaxValue, 0);
|
||||
return PInvoke.FlashWindowEx(ref fi);
|
||||
}
|
||||
|
||||
internal static class PInvoke
|
||||
{
|
||||
[DllImport("DwmApi")]
|
||||
internal static extern int DwmSetWindowAttribute(IntPtr hwnd, int dwAttribute, int[] pvAttribute, int cbAttribute);
|
||||
|
||||
[DllImport("DwmApi")]
|
||||
internal static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, IntPtr pvAttribute, int cbAttribute);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
|
||||
internal static extern int SetWindowLong(IntPtr hwnd, int index, int newStyle);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static extern bool FlashWindowEx(ref FLASHWINFO pwfi);
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct FLASHWINFO
|
||||
{
|
||||
/// <summary>
|
||||
/// The size of the structure in bytes.
|
||||
/// </summary>
|
||||
public uint cbSize;
|
||||
|
||||
/// <summary>
|
||||
/// A Handle to the Window to be Flashed. The window can be either opened or minimized.
|
||||
/// </summary>
|
||||
public IntPtr hwnd;
|
||||
|
||||
/// <summary>
|
||||
/// The Flash Status.
|
||||
/// </summary>
|
||||
public uint dwFlags;
|
||||
|
||||
/// <summary>
|
||||
/// The number of times to Flash the window.
|
||||
/// </summary>
|
||||
public uint uCount;
|
||||
|
||||
/// <summary>
|
||||
/// The rate at which the Window is to be flashed, in milliseconds. If Zero, the function uses the default cursor blink
|
||||
/// rate.
|
||||
/// </summary>
|
||||
public uint dwTimeout;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user