Electron support for Linux (#1074)

* init

* SQLite changes

* Move html folder, edit build scripts

* AppApi interface

* Build flags

* AppApi inheritance

* Finishing touches

* Merge upstream changes

* Test CI

* Fix class inits

* Rename AppApi

* Merge upstream changes

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

* Linux specific localisation strings

* Make it run

* Bring back most of Linux functionality

* Clean up

* Fix TTS voices

* Fix UI var

* Changes

* Electron minimise to tray

* Remove separate toggle for WlxOverlay

* Fixes

* Touchups

* Move csproj

* Window zoom, Desktop Notifications, VR check on Linux

* Fix desktop notifications, VR check spam

* Fix building on Linux

* Clean up

* Fix WebApi headers

* Rewrite VRCX updater

* Clean up

* Linux updater

* Add Linux to build action

* init

* SQLite changes

* Move html folder, edit build scripts

* AppApi interface

* Build flags

* AppApi inheritance

* Finishing touches

* Merge upstream changes

* Test CI

* Fix class inits

* Rename AppApi

* Merge upstream changes

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

* Linux specific localisation strings

* Make it run

* Bring back most of Linux functionality

* Clean up

* Fix TTS voices

* Changes

* Electron minimise to tray

* Remove separate toggle for WlxOverlay

* Fixes

* Touchups

* Move csproj

* Window zoom, Desktop Notifications, VR check on Linux

* Fix desktop notifications, VR check spam

* Fix building on Linux

* Clean up

* Fix WebApi headers

* Rewrite VRCX updater

* Clean up

* Linux updater

* Add Linux to build action

* Test updater

* Rebase and handle merge conflicts

* Fix Linux updater

* Fix Linux app restart

* Fix friend order

* Handle AppImageInstaller, show an install message on Linux

* Updates to the AppImage installer

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

* Handle random errors

* Rotate tall prints

* try fix Linux restart bug

* Final

---------

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

View File

@@ -0,0 +1,148 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using librsync.net;
using NLog;
namespace VRCX
{
public partial class AppApi
{
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
private static readonly MD5 _hasher = MD5.Create();
public void Init()
{
}
public string MD5File(string blob)
{
var fileData = Convert.FromBase64CharArray(blob.ToCharArray(), 0, blob.Length);
using var md5 = MD5.Create();
var md5Hash = md5.ComputeHash(fileData);
return Convert.ToBase64String(md5Hash);
}
public int GetColourFromUserID(string userId)
{
var hash = _hasher.ComputeHash(Encoding.UTF8.GetBytes(userId));
return (hash[3] << 8) | hash[4];
}
public string SignFile(string blob)
{
var fileData = Convert.FromBase64String(blob);
using var sig = Librsync.ComputeSignature(new MemoryStream(fileData));
using var memoryStream = new MemoryStream();
sig.CopyTo(memoryStream);
var sigBytes = memoryStream.ToArray();
return Convert.ToBase64String(sigBytes);
}
public string FileLength(string blob)
{
var fileData = Convert.FromBase64String(blob);
return fileData.Length.ToString();
}
public void OpenLink(string url)
{
if (url.StartsWith("http://") ||
url.StartsWith("https://"))
{
Process.Start(new ProcessStartInfo(url)
{
UseShellExecute = true
});
}
}
public void IPCAnnounceStart()
{
IPCServer.Send(new IPCPacket
{
Type = "VRCXLaunch",
MsgType = "VRCXLaunch"
});
}
public void SendIpc(string type, string data)
{
IPCServer.Send(new IPCPacket
{
Type = "VrcxMessage",
MsgType = type,
Data = data
});
}
public string CustomCssPath()
{
var output = string.Empty;
var filePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VRCX\\custom.css");
if (File.Exists(filePath))
output = filePath;
return output;
}
public string CustomScriptPath()
{
var output = string.Empty;
var filePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VRCX\\custom.js");
if (File.Exists(filePath))
output = filePath;
return output;
}
public string CurrentCulture()
{
return CultureInfo.CurrentCulture.ToString();
}
public string CurrentLanguage()
{
return CultureInfo.InstalledUICulture.Name;
}
public string GetVersion()
{
return Program.Version;
}
public bool VrcClosedGracefully()
{
return LogWatcher.Instance.VrcClosedGracefully;
}
public Dictionary<string, int> GetColourBulk(List<object> userIds)
{
var output = new Dictionary<string, int>();
foreach (string userId in userIds)
{
output.Add(userId, GetColourFromUserID(userId));
}
return output;
}
public void SetAppLauncherSettings(bool enabled, bool killOnExit)
{
AutoAppLaunchManager.Instance.Enabled = enabled;
AutoAppLaunchManager.Instance.KillChildrenOnExit = killOnExit;
}
public string GetFileBase64(string path)
{
if (File.Exists(path))
{
return Convert.ToBase64String(File.ReadAllBytes(path));
}
return null;
}
}
}

View File

@@ -0,0 +1,77 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using NLog;
namespace VRCX
{
public abstract partial class AppApi
{
// AppApi
public abstract void ShowDevTools();
public abstract void DeleteAllCookies();
public abstract void SetVR(bool active, bool hmdOverlay, bool wristOverlay, bool menuButton, int overlayHand);
public abstract void RefreshVR();
public abstract void RestartVR();
public abstract void SetZoom(double zoomLevel);
public abstract Task<double> GetZoom();
public abstract void DesktopNotification(string BoldText, string Text = "", string Image = "");
public abstract void RestartApplication(bool isUpgrade);
public abstract bool CheckForUpdateExe();
public abstract void ExecuteAppFunction(string function, string json);
public abstract void ExecuteVrFeedFunction(string function, string json);
public abstract void ExecuteVrOverlayFunction(string function, string json);
public abstract string GetLaunchCommand();
public abstract void FocusWindow();
public abstract void ChangeTheme(int value);
public abstract void DoFunny();
public abstract string GetClipboard();
public abstract void SetStartup(bool enabled);
public abstract void CopyImageToClipboard(string path);
public abstract void FlashWindow();
public abstract void SetUserAgent();
public abstract bool IsRunningUnderWine();
// Folders
public abstract string GetVRChatAppDataLocation();
public abstract string GetVRChatPhotosLocation();
public abstract string GetUGCPhotoLocation(string path = "");
public abstract string GetVRChatScreenshotsLocation();
public abstract string GetVRChatCacheLocation();
public abstract bool OpenVrcxAppDataFolder();
public abstract bool OpenVrcAppDataFolder();
public abstract bool OpenVrcPhotosFolder();
public abstract bool OpenUGCPhotosFolder(string ugcPath = "");
public abstract bool OpenVrcScreenshotsFolder();
public abstract bool OpenCrashVrcCrashDumps();
public abstract void OpenShortcutFolder();
public abstract void OpenFolderAndSelectItem(string path, bool isFolder = false);
public abstract Task<string> OpenFolderSelectorDialog(string defaultPath = "");
public abstract Task<string> OpenFileSelectorDialog(string defaultPath = "", string defaultExt = "",
string defaultFilter = "All files (*.*)|*.*");
// GameHandler
public abstract void OnProcessStateChanged(MonitoredProcess monitoredProcess);
public abstract void CheckGameRunning();
public abstract bool IsGameRunning();
public abstract bool IsSteamVRRunning();
public abstract int QuitGame();
public abstract bool StartGame(string arguments);
public abstract bool StartGameFromPath(string path, string arguments);
// RegistryPlayerPrefs
public abstract object GetVRChatRegistryKey(string key);
public abstract string GetVRChatRegistryKeyString(string key);
public abstract bool SetVRChatRegistryKey(string key, object value, int typeInt);
public abstract void SetVRChatRegistryKey(string key, byte[] value);
public abstract Dictionary<string, Dictionary<string, object>> GetVRChatRegistry();
public abstract void SetVRChatRegistry(string json);
public abstract bool HasVRChatRegistryFolder();
public abstract void DeleteVRChatRegistryFolder();
public abstract string ReadVrcRegJsonFile(string filepath);
// Screenshot
public abstract string AddScreenshotMetadata(string path, string metadataString, string worldId, bool changeFilename = false);
}
}

View File

@@ -0,0 +1,237 @@
using System;
using System.Drawing;
using System.IO;
using System.Threading.Tasks;
namespace VRCX
{
public partial class AppApi
{
public async Task<string> GetImage(string url, string fileId, string version)
{
return await ImageCache.GetImage(url, fileId, version);
}
public string ResizeImageToFitLimits(string base64data)
{
return Convert.ToBase64String(ResizeImageToFitLimits(Convert.FromBase64String(base64data), false));
}
public byte[] ResizeImageToFitLimits(byte[] imageData, bool matchingDimensions, int maxWidth = 2000, int maxHeight = 2000, long maxSize = 10_000_000)
{
using var fileMemoryStream = new MemoryStream(imageData);
var image = new Bitmap(fileMemoryStream);
// for APNG, check if image is png format and less than maxSize
if ((!matchingDimensions || image.Width == image.Height) &&
image.RawFormat.Equals(System.Drawing.Imaging.ImageFormat.Png) &&
imageData.Length < maxSize &&
image.Width <= maxWidth &&
image.Height <= maxHeight)
{
return imageData;
}
if (image.Width > maxWidth)
{
var sizingFactor = image.Width / (double)maxWidth;
var newHeight = (int)Math.Round(image.Height / sizingFactor);
image = new Bitmap(image, maxWidth, newHeight);
}
if (image.Height > maxHeight)
{
var sizingFactor = image.Height / (double)maxHeight;
var newWidth = (int)Math.Round(image.Width / sizingFactor);
image = new Bitmap(image, newWidth, maxHeight);
}
if (matchingDimensions && image.Width != image.Height)
{
var newSize = Math.Max(image.Width, image.Height);
var newImage = new Bitmap(newSize, newSize);
using var graphics = Graphics.FromImage(newImage);
graphics.Clear(Color.Transparent);
graphics.DrawImage(image, new Rectangle((newSize - image.Width) / 2, (newSize - image.Height) / 2, image.Width, image.Height));
image.Dispose();
image = newImage;
}
SaveToFileToUpload();
for (int i = 0; i < 250 && imageData.Length > maxSize; i++)
{
SaveToFileToUpload();
if (imageData.Length < maxSize)
break;
int newWidth;
int newHeight;
if (image.Width > image.Height)
{
newWidth = image.Width - 25;
newHeight = (int)Math.Round(image.Height / (image.Width / (double)newWidth));
}
else
{
newHeight = image.Height - 25;
newWidth = (int)Math.Round(image.Width / (image.Height / (double)newHeight));
}
image = new Bitmap(image, newWidth, newHeight);
}
if (imageData.Length > maxSize)
{
throw new Exception("Failed to get image into target filesize.");
}
return imageData;
void SaveToFileToUpload()
{
using var imageSaveMemoryStream = new MemoryStream();
image.Save(imageSaveMemoryStream, System.Drawing.Imaging.ImageFormat.Png);
imageData = imageSaveMemoryStream.ToArray();
}
}
public byte[] ResizePrintImage(byte[] imageData)
{
const int desiredWidth = 1920;
const int desiredHeight = 1080;
using var fileMemoryStream = new MemoryStream(imageData);
var image = new Bitmap(fileMemoryStream);
if (image.Height > image.Width)
image.RotateFlip(RotateFlipType.Rotate90FlipNone);
// increase size to 1920x1080
if (image.Width < desiredWidth || image.Height < desiredHeight)
{
var newHeight = image.Height;
var newWidth = image.Width;
if (image.Width < desiredWidth)
{
var testHeight = (int)Math.Round(image.Height / (image.Width / (double)desiredWidth));
if (testHeight <= desiredHeight)
{
newWidth = desiredWidth;
newHeight = testHeight;
}
}
if (image.Height < desiredHeight)
{
var testWidth = (int)Math.Round(image.Width / (image.Height / (double)desiredHeight));
if (testWidth <= desiredWidth)
{
newHeight = desiredHeight;
newWidth = testWidth;
}
}
var resizedImage = new Bitmap(desiredWidth, desiredHeight);
using var graphics1 = Graphics.FromImage(resizedImage);
graphics1.Clear(Color.White);
var x = (desiredWidth - newWidth) / 2;
var y = (desiredHeight - newHeight) / 2;
graphics1.DrawImage(image, new Rectangle(x, y, newWidth, newHeight));
image.Dispose();
image = resizedImage;
}
// limit size to 1920x1080
if (image.Width > desiredWidth)
{
var sizingFactor = image.Width / (double)desiredWidth;
var newHeight = (int)Math.Round(image.Height / sizingFactor);
image = new Bitmap(image, desiredWidth, newHeight);
}
if (image.Height > desiredHeight)
{
var sizingFactor = image.Height / (double)desiredHeight;
var newWidth = (int)Math.Round(image.Width / sizingFactor);
image = new Bitmap(image, newWidth, desiredHeight);
}
// add white border
// wtf are these magic numbers
const int xOffset = 64; // 2048 / 32
const int yOffset = 69; // 1440 / 20.869
var newImage = new Bitmap(2048, 1440);
using var graphics = Graphics.FromImage(newImage);
graphics.Clear(Color.White);
// graphics.DrawImage(image, new Rectangle(xOffset, yOffset, image.Width, image.Height));
var newX = (2048 - image.Width) / 2;
var newY = yOffset;
graphics.DrawImage(image, new Rectangle(newX, newY, image.Width, image.Height));
image.Dispose();
image = newImage;
using var imageSaveMemoryStream = new MemoryStream();
image.Save(imageSaveMemoryStream, System.Drawing.Imaging.ImageFormat.Png);
return imageSaveMemoryStream.ToArray();
}
public async Task CropAllPrints(string ugcFolderPath)
{
var folder = Path.Combine(GetUGCPhotoLocation(ugcFolderPath), "Prints");
var files = Directory.GetFiles(folder, "*.png", SearchOption.AllDirectories);
foreach (var file in files)
{
await CropPrintImage(file);
}
}
public async Task<bool> CropPrintImage(string path)
{
var tempPath = path + ".temp";
var bytes = await File.ReadAllBytesAsync(path);
var ms = new MemoryStream(bytes);
Bitmap print = new Bitmap(ms);
// validation step to ensure image is actually a print
if (print.Width != 2048 || print.Height != 1440)
{
return false;
}
var point = new Point(64, 69);
var size = new Size(1920, 1080);
var rectangle = new Rectangle(point, size);
Bitmap cropped = print.Clone(rectangle, print.PixelFormat);
cropped.Save(tempPath);
if (ScreenshotHelper.HasTXt(path))
{
var success = ScreenshotHelper.CopyTXt(path, tempPath);
if (!success)
{
File.Delete(tempPath);
return false;
}
}
File.Move(tempPath, path, true);
return true;
}
public async Task<string> SavePrintToFile(string url, string ugcFolderPath, string monthFolder, string fileName)
{
var folder = Path.Combine(GetUGCPhotoLocation(ugcFolderPath), "Prints", MakeValidFileName(monthFolder));
Directory.CreateDirectory(folder);
var filePath = Path.Combine(folder, MakeValidFileName(fileName));
if (File.Exists(filePath))
return null;
var success = await ImageCache.SaveImageToFile(url, filePath);
return success ? filePath : null;
}
public async Task<string> SaveStickerToFile(string url, string ugcFolderPath, string monthFolder, string fileName)
{
var folder = Path.Combine(GetUGCPhotoLocation(ugcFolderPath), "Stickers", MakeValidFileName(monthFolder));
Directory.CreateDirectory(folder);
var filePath = Path.Combine(folder, MakeValidFileName(fileName));
if (File.Exists(filePath))
return null;
var success = await ImageCache.SaveImageToFile(url, filePath);
return success ? filePath : null;
}
}
}

View File

@@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
namespace VRCX
{
public partial class AppApi
{
public Dictionary<string, short> GetVRChatModerations(string currentUserId)
{
// 004 = hideAvatar
// 005 = showAvatar
var filePath = Path.Combine(GetVRChatAppDataLocation(), @$"LocalPlayerModerations\{currentUserId}-show-hide-user.vrcset");
if (!File.Exists(filePath))
return null;
var output = new Dictionary<string, short>();
using var reader = new StreamReader(filePath);
string line;
while ((line = reader.ReadLine()) != null)
{
var index = line.IndexOf(' ');
if (index <= 0)
continue;
var userId = line.Substring(0, index);
var type = short.Parse(line.Substring(line.Length - 3));
output.Add(userId, type);
}
return output;
}
public short GetVRChatUserModeration(string currentUserId, string userId)
{
var filePath = Path.Combine(GetVRChatAppDataLocation(), @$"LocalPlayerModerations\{currentUserId}-show-hide-user.vrcset");
if (!File.Exists(filePath))
return 0;
using var reader = new StreamReader(filePath);
string line;
while ((line = reader.ReadLine()) != null)
{
var index = line.IndexOf(' ');
if (index <= 0)
continue;
if (userId == line.Substring(0, index))
{
return short.Parse(line.Substring(line.Length - 3));
}
}
return 0;
}
public bool SetVRChatUserModeration(string currentUserId, string userId, int type)
{
var filePath = Path.Combine(GetVRChatAppDataLocation(), @$"LocalPlayerModerations\{currentUserId}-show-hide-user.vrcset");
if (!File.Exists(filePath))
return false;
var lines = File.ReadAllLines(filePath).ToList();
var index = lines.FindIndex(x => x.StartsWith(userId));
if (index >= 0)
lines.RemoveAt(index);
if (type != 0)
{
var sb = new StringBuilder(userId);
while (sb.Length < 64)
sb.Append(' ');
sb.Append(type.ToString("000"));
lines.Add(sb.ToString());
}
try
{
File.WriteAllLines(filePath, lines);
}
catch (Exception)
{
return false;
}
return true;
}
}
}

View File

@@ -0,0 +1,162 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.WebSockets;
using System.Text.Json;
using Websocket.Client;
namespace VRCX
{
public partial class AppApi
{
private static readonly Uri _ovrtWebsocketUri = new("ws://127.0.0.1:11450/api");
private static readonly byte[] _vrcxIcon = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAAsTAAALEwEAmpwYAAAHaGlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHhtcDpDcmVhdGVEYXRlPSIyMDIxLTA0LTA4VDE0OjU3OjAxKzEyOjAwIiB4bXA6TW9kaWZ5RGF0ZT0iMjAyMS0wNC0wOFQxNjozMzoxMCsxMjowMCIgeG1wOk1ldGFkYXRhRGF0ZT0iMjAyMS0wNC0wOFQxNjozMzoxMCsxMjowMCIgZGM6Zm9ybWF0PSJpbWFnZS9wbmciIHBob3Rvc2hvcDpDb2xvck1vZGU9IjMiIHBob3Rvc2hvcDpJQ0NQcm9maWxlPSJzUkdCIElFQzYxOTY2LTIuMSIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDo2YTY5MmQzYi03ZTJkLTNiNGUtYTMzZC1hN2MwOTNlOGU0OTkiIHhtcE1NOkRvY3VtZW50SUQ9ImFkb2JlOmRvY2lkOnBob3Rvc2hvcDo1NTE2MWIyMi1hYzgxLTY3NDYtODAyYi1kODIzYWFmN2RjYjciIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDo3ZjJjNTA2ZS02YTVhLWRhNGEtOTg5Mi02NDZiMzQ0MGQxZTgiPiA8cGhvdG9zaG9wOkRvY3VtZW50QW5jZXN0b3JzPiA8cmRmOkJhZz4gPHJkZjpsaT5hZG9iZTpkb2NpZDpwaG90b3Nob3A6NmJmOGE5MTgtY2QzZS03OTRjLTk3NzktMzM0YjYwZWJiNTYyPC9yZGY6bGk+IDwvcmRmOkJhZz4gPC9waG90b3Nob3A6RG9jdW1lbnRBbmNlc3RvcnM+IDx4bXBNTTpIaXN0b3J5PiA8cmRmOlNlcT4gPHJkZjpsaSBzdEV2dDphY3Rpb249ImNyZWF0ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6N2YyYzUwNmUtNmE1YS1kYTRhLTk4OTItNjQ2YjM0NDBkMWU4IiBzdEV2dDp3aGVuPSIyMDIxLTA0LTA4VDE0OjU3OjAxKzEyOjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiLz4gPHJkZjpsaSBzdEV2dDphY3Rpb249InNhdmVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOmJhM2ZjODI3LTM0ZjQtYjU0OC05ZGFiLTZhMTZlZmQzZjAxMSIgc3RFdnQ6d2hlbj0iMjAyMS0wNC0wOFQxNTowMTozMSsxMjowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIiBzdEV2dDpjaGFuZ2VkPSIvIi8+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJzYXZlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDo2YTY5MmQzYi03ZTJkLTNiNGUtYTMzZC1hN2MwOTNlOGU0OTkiIHN0RXZ0OndoZW49IjIwMjEtMDQtMDhUMTY6MzM6MTArMTI6MDAiIHN0RXZ0OnNv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");
private static readonly object _ovrtLock = new();
private static WebsocketClient _ovrtWebsocketClient;
private static void InitializeOvrTk()
{
lock (_ovrtLock)
{
if (_ovrtWebsocketClient != null)
return;
var dotnetWebsocketClientFactory = new Func<ClientWebSocket>(() =>
{
var client = new ClientWebSocket
{
Options =
{
KeepAliveInterval = TimeSpan.FromSeconds(5),
}
};
client.Options.SetRequestHeader("user-agent", Program.Version);
return client;
});
_ovrtWebsocketClient = new WebsocketClient(_ovrtWebsocketUri, dotnetWebsocketClientFactory)
{
Name = "OVRToolkit Websocket",
// Swap ReconnectTimeout when Ping is implemented
// ReconnectTimeout = TimeSpan.FromSeconds(20),
ReconnectTimeout = null,
ErrorReconnectTimeout = TimeSpan.FromSeconds(30),
};
_ovrtWebsocketClient.ReconnectionHappened.Subscribe(info =>
{
logger.ConditionalDebug("[OVRToolkit Websocket] Reconnection happened, type: {0}", info?.Type.ToString());
});
_ovrtWebsocketClient.DisconnectionHappened.Subscribe(info =>
{
logger.ConditionalDebug("[OVRToolkit Websocket] Disconnection happened, type: {0}", info?.Type.ToString());
});
_ovrtWebsocketClient.MessageReceived.Subscribe(msg =>
{
logger.ConditionalDebug("[OVRToolkit Websocket] Message received: {0}", msg.Text);
});
_ovrtWebsocketClient.Start().Wait();
}
}
private static void SendMessages(IEnumerable<OvrtMessage> ovrtMessages)
{
if (ovrtMessages != null && ovrtMessages.Any())
{
if (_ovrtWebsocketClient == null)
InitializeOvrTk();
if (_ovrtWebsocketClient.IsRunning)
{
foreach (var message in ovrtMessages)
{
_ovrtWebsocketClient.Send(JsonSerializer.Serialize(message));
}
}
}
}
/// <summary>
/// Displays an OVRToolkit notification with the specified title and body.
/// HUD notification - Visible in the lower part of the HMD view and moves with the head.
/// Wrist notification - Visible above the wristwatch until cleared by the user via the 'X' icon.
/// </summary>
/// <param name="hudNotification">Whether or not to display a HUD notification.</param>
/// <param name="wristNotification">Whether or not to display a Wrist notification.</param>
/// <param name="title">The title of the notification.</param>
/// <param name="body">The content of the notification.</param>
/// <param name="timeout">[CURRENTLY UNUSED]The timeout of the notification.</param>
/// <param name="image">The image of the notification.</param>
public void OVRTNotification(bool hudNotification, bool wristNotification, string title, string body, int timeout, string image = "")
{
List<OvrtMessage> messages = [];
byte[] imageBytes;
if(!string.IsNullOrWhiteSpace(image) && File.Exists(image))
{
imageBytes = File.ReadAllBytes(image);
}
else
{
imageBytes = _vrcxIcon;
}
if (wristNotification)
{
messages.Add(new OvrtMessage
{
MessageType = "SendWristNotification",
Json = JsonSerializer.Serialize(new OvrtWristNotificationMessage
{
Body = title + " - " + body
})
});
}
if (hudNotification)
{
messages.Add(new OvrtMessage
{
MessageType = "SendNotification",
Json = JsonSerializer.Serialize(new OvrtHudNotificationMessage
{
Title = title,
Body = body,
Icon = imageBytes
})
});
}
SendMessages(messages);
}
private struct OvrtMessage
{
[System.Text.Json.Serialization.JsonPropertyName("messageType")]
public string MessageType { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("json")]
public string Json { get; set; }
}
private struct OvrtHudNotificationMessage
{
[System.Text.Json.Serialization.JsonPropertyName("title")]
public string Title { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("body")]
public string Body { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("icon")]
public byte[] Icon { get; set; }
}
private struct OvrtWristNotificationMessage
{
[System.Text.Json.Serialization.JsonPropertyName("body")]
public string Body { get; set; }
}
}
}

View File

@@ -0,0 +1,130 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
namespace VRCX;
public partial class AppApi
{
public string GetExtraScreenshotData(string path, bool carouselCache)
{
var fileName = Path.GetFileNameWithoutExtension(path);
var metadata = new JObject();
if (!File.Exists(path) || !path.EndsWith(".png"))
return null;
var files = Directory.GetFiles(Path.GetDirectoryName(path), "*.png");
// Add previous/next file paths to metadata so the screenshot viewer carousel can request metadata for next/previous images in directory
if (carouselCache)
{
var index = Array.IndexOf(files, path);
if (index > 0)
{
metadata.Add("previousFilePath", files[index - 1]);
}
if (index < files.Length - 1)
{
metadata.Add("nextFilePath", files[index + 1]);
}
}
metadata.Add("fileResolution", ScreenshotHelper.ReadPNGResolution(path));
var creationDate = File.GetCreationTime(path);
metadata.Add("creationDate", creationDate.ToString("yyyy-MM-dd HH:mm:ss"));
var fileSizeBytes = new FileInfo(path).Length;
metadata.Add("fileSizeBytes", fileSizeBytes.ToString());
metadata.Add("fileName", fileName);
metadata.Add("filePath", path);
metadata.Add("fileSize", $"{(fileSizeBytes / 1024f / 1024f).ToString("0.00")} MB");
return metadata.ToString(Formatting.Indented);
}
public string GetScreenshotMetadata(string path)
{
if (string.IsNullOrEmpty(path))
return null;
var metadata = ScreenshotHelper.GetScreenshotMetadata(path);
if (metadata == null)
{
var obj = new JObject
{
{ "sourceFile", path },
{ "error", "Screenshot contains no metadata." }
};
return obj.ToString(Formatting.Indented);
};
if (metadata.Error != null)
{
var obj = new JObject
{
{ "sourceFile", path },
{ "error", metadata.Error }
};
return obj.ToString(Formatting.Indented);
}
return JsonConvert.SerializeObject(metadata, Formatting.Indented, new JsonSerializerSettings
{
ContractResolver = new DefaultContractResolver
{
NamingStrategy = new CamelCaseNamingStrategy() // This'll serialize our .net property names to their camelCase equivalents. Ex; "FileName" -> "fileName"
}
});
}
public string FindScreenshotsBySearch(string searchQuery, int searchType = 0)
{
var stopwatch = new Stopwatch();
stopwatch.Start();
var searchPath = GetVRChatPhotosLocation();
var screenshots = ScreenshotHelper.FindScreenshots(searchQuery, searchPath, (ScreenshotHelper.ScreenshotSearchType)searchType);
JArray json = new JArray();
foreach (var screenshot in screenshots)
{
json.Add(screenshot.SourceFile);
}
stopwatch.Stop();
logger.Info($"FindScreenshotsBySearch took {stopwatch.ElapsedMilliseconds}ms to complete.");
return json.ToString();
}
public string GetLastScreenshot()
{
// Get the last screenshot taken by VRChat
var path = GetVRChatPhotosLocation();
if (!Directory.Exists(path))
return null;
var lastDirectory = Directory.GetDirectories(path).OrderByDescending(Directory.GetCreationTime).FirstOrDefault();
if (lastDirectory == null)
return null;
var lastScreenshot = Directory.GetFiles(lastDirectory, "*.png").OrderByDescending(File.GetCreationTime).FirstOrDefault();
if (lastScreenshot == null)
return null;
return lastScreenshot;
}
}

View File

@@ -0,0 +1,21 @@
using System.Threading.Tasks;
namespace VRCX;
public partial class AppApi
{
public async Task DownloadUpdate(string fileUrl, string fileName, string hashUrl, int downloadSize)
{
await Update.DownloadUpdate(fileUrl, fileName, hashUrl, downloadSize);
}
public void CancelUpdate()
{
Update.CancelUpdate();
}
public int CheckUpdateProgress()
{
return Update.UpdateProgress;
}
}

View File

@@ -0,0 +1,23 @@
using System.IO;
using System.Text.RegularExpressions;
namespace VRCX;
public partial class AppApi
{
private static readonly Regex _folderRegex = new Regex(string.Format(@"([{0}]*\.+$)|([{0}]+)",
Regex.Escape(new string(Path.GetInvalidPathChars()))));
private static readonly Regex _fileRegex = new Regex(string.Format(@"([{0}]*\.+$)|([{0}]+)",
Regex.Escape(new string(Path.GetInvalidFileNameChars()))));
private static string MakeValidFileName(string name)
{
name = name.Replace("/", "");
name = name.Replace("\\", "");
name = _folderRegex.Replace(name, "");
name = _fileRegex.Replace(name, "");
return name;
}
}

View File

@@ -0,0 +1,28 @@
using System;
using System.IO;
namespace VRCX
{
public partial class AppApi
{
public string ReadConfigFile()
{
var path = GetVRChatAppDataLocation();
var configFile = Path.Combine(path, "config.json");
if (!Directory.Exists(path) || !File.Exists(configFile))
{
return string.Empty;
}
var json = File.ReadAllText(configFile);
return json;
}
public void WriteConfigFile(string json)
{
var path = GetVRChatAppDataLocation();
var configFile = Path.Combine(path, "config.json");
File.WriteAllText(configFile, json);
}
}
}

View File

@@ -0,0 +1,70 @@
using System.Net;
using System.Net.Sockets;
using System.Text.Json;
namespace VRCX
{
public partial class AppApi
{
/// <summary>
/// Displays an XSOverlay notification with the specified title, content, and optional image.
/// </summary>
/// <param name="title">The title of the notification.</param>
/// <param name="content">The content of the notification.</param>
/// <param name="timeout">The duration of the notification in milliseconds.</param>
/// <param name="image">The optional image to display in the notification.</param>
public void XSNotification(string title, string content, int timeout, string image = "")
{
var broadcastSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
var endPoint = new IPEndPoint(IPAddress.Loopback, 42069);
var useBase64Icon = false;
var icon = image;
if (string.IsNullOrEmpty(image))
{
useBase64Icon = true;
icon = "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAAsTAAALEwEAmpwYAAAHaGlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHhtcDpDcmVhdGVEYXRlPSIyMDIxLTA0LTA4VDE0OjU3OjAxKzEyOjAwIiB4bXA6TW9kaWZ5RGF0ZT0iMjAyMS0wNC0wOFQxNjozMzoxMCsxMjowMCIgeG1wOk1ldGFkYXRhRGF0ZT0iMjAyMS0wNC0wOFQxNjozMzoxMCsxMjowMCIgZGM6Zm9ybWF0PSJpbWFnZS9wbmciIHBob3Rvc2hvcDpDb2xvck1vZGU9IjMiIHBob3Rvc2hvcDpJQ0NQcm9maWxlPSJzUkdCIElFQzYxOTY2LTIuMSIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDo2YTY5MmQzYi03ZTJkLTNiNGUtYTMzZC1hN2MwOTNlOGU0OTkiIHhtcE1NOkRvY3VtZW50SUQ9ImFkb2JlOmRvY2lkOnBob3Rvc2hvcDo1NTE2MWIyMi1hYzgxLTY3NDYtODAyYi1kODIzYWFmN2RjYjciIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDo3ZjJjNTA2ZS02YTVhLWRhNGEtOTg5Mi02NDZiMzQ0MGQxZTgiPiA8cGhvdG9zaG9wOkRvY3VtZW50QW5jZXN0b3JzPiA8cmRmOkJhZz4gPHJkZjpsaT5hZG9iZTpkb2NpZDpwaG90b3Nob3A6NmJmOGE5MTgtY2QzZS03OTRjLTk3NzktMzM0YjYwZWJiNTYyPC9yZGY6bGk+IDwvcmRmOkJhZz4gPC9waG90b3Nob3A6RG9jdW1lbnRBbmNlc3RvcnM+IDx4bXBNTTpIaXN0b3J5PiA8cmRmOlNlcT4gPHJkZjpsaSBzdEV2dDphY3Rpb249ImNyZWF0ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6N2YyYzUwNmUtNmE1YS1kYTRhLTk4OTItNjQ2YjM0NDBkMWU4IiBzdEV2dDp3aGVuPSIyMDIxLTA0LTA4VDE0OjU3OjAxKzEyOjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiLz4gPHJkZjpsaSBzdEV2dDphY3Rpb249InNhdmVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOmJhM2ZjODI3LTM0ZjQtYjU0OC05ZGFiLTZhMTZlZmQzZjAxMSIgc3RFdnQ6d2hlbj0iMjAyMS0wNC0wOFQxNTowMTozMSsxMjowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIiBzdEV2dDpjaGFuZ2VkPSIvIi8+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJzYXZlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDo2YTY5MmQzYi03ZTJkLTNiNGUtYTMzZC1hN2MwOTNlOGU0OTkiIHN0RXZ0OndoZW49IjIwMjEtMDQtMDhUMTY6MzM6MTArMTI6MDAiIHN0RXZ0OnNv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";
}
var height = 110f;
if (content.Length > 300)
height = 250f;
else if (content.Length > 200)
height = 200f;
else if (content.Length > 100)
height = 150f;
var msg = new XSOMessage
{
messageType = 1,
title = title,
content = content,
height = height,
sourceApp = "VRCX",
timeout = timeout,
audioPath = string.Empty,
useBase64Icon = useBase64Icon,
icon = icon
};
var byteBuffer = JsonSerializer.SerializeToUtf8Bytes(msg);
broadcastSocket.SendTo(byteBuffer, endPoint);
broadcastSocket.Close();
}
private struct XSOMessage
{
public int messageType { get; set; }
public int index { get; set; }
public float volume { get; set; }
public string audioPath { get; set; }
public float timeout { get; set; }
public string title { get; set; }
public string content { get; set; }
public string icon { get; set; }
public float height { get; set; }
public float opacity { get; set; }
public bool useBase64Icon { get; set; }
public string sourceApp { get; set; }
}
}
}