diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a437a65 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +*.swp +*.*~ +project.lock.json +.DS_Store +*.pyc +nupkg/ + +# Visual Studio Code +.vscode + +# Rider +.idea + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +build/ +bld/ +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +msbuild.log +msbuild.err +msbuild.wrn + +# Visual Studio 2015 +.vs/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..659925c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 nnaaa-vr + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..05973fc --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +Simple VRChat log parser leveraging the XSNotifications library at https://github.com/nnaaa-vr/XSNotifications to display toast notifications in VR. Currently supported events include world changes, player joined, player left, portal dropped, and shader keywords exceeded. Logged events are also saved with timestamps to session logs, so previous sessions can be referenced if necessary (see who was where, what world you were in, et cetera). + +On first run, a config.json file will be generated at %AppData%\..\LocalLow\XSOverlay VRChat Parser\config.json. Please see this file for configuration options, and then restart the process after making any changes. + +The process runs in the background and does not have an active window. +The parsing is a bit messy at the moment (but functional). VRChat's log output is very inconsistent and has changed fairly frequently. The parsing function was written around two years ago and has just been patched as time went on. + +There are plans to expand the feature set of this application, including additional event types for those running extended logging in VRChat. Of course, feel free to make PRs yourselves! + +More detailed documentation will come soon. \ No newline at end of file diff --git a/XSOverlay VRChat Parser.sln b/XSOverlay VRChat Parser.sln new file mode 100644 index 0000000..cd6c1b6 --- /dev/null +++ b/XSOverlay VRChat Parser.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.31105.61 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XSOverlay VRChat Parser", "XSOverlay VRChat Parser\XSOverlay VRChat Parser.csproj", "{02DC8D37-5DAC-448B-BAFE-14C8FEFBD782}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {02DC8D37-5DAC-448B-BAFE-14C8FEFBD782}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {02DC8D37-5DAC-448B-BAFE-14C8FEFBD782}.Debug|Any CPU.Build.0 = Debug|Any CPU + {02DC8D37-5DAC-448B-BAFE-14C8FEFBD782}.Release|Any CPU.ActiveCfg = Release|Any CPU + {02DC8D37-5DAC-448B-BAFE-14C8FEFBD782}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {459F651C-259C-421A-9E69-346F3636C4E4} + EndGlobalSection +EndGlobal diff --git a/XSOverlay VRChat Parser/Helpers/Annotation.cs b/XSOverlay VRChat Parser/Helpers/Annotation.cs new file mode 100644 index 0000000..0e410ac --- /dev/null +++ b/XSOverlay VRChat Parser/Helpers/Annotation.cs @@ -0,0 +1,18 @@ +using System; + +namespace XSOverlay_VRChat_Parser.Helpers +{ + [AttributeUsage(AttributeTargets.Property)] + public class Annotation : Attribute + { + public string description; + public string groupDescription; + public bool startsGroup; + public Annotation(string _description, bool _startsGroup = false, string _groupDescription = "") + { + this.description = _description; + this.startsGroup = _startsGroup; + this.groupDescription = _groupDescription; + } + } +} diff --git a/XSOverlay VRChat Parser/Helpers/EventType.cs b/XSOverlay VRChat Parser/Helpers/EventType.cs new file mode 100644 index 0000000..8c3ca5b --- /dev/null +++ b/XSOverlay VRChat Parser/Helpers/EventType.cs @@ -0,0 +1,11 @@ +namespace XSOverlay_VRChat_Parser.Helpers +{ + public enum EventType + { + PlayerJoin, + PlayerLeft, + WorldChange, + KeywordsExceeded, + PortalDropped + } +} diff --git a/XSOverlay VRChat Parser/Helpers/LogEventType.cs b/XSOverlay VRChat Parser/Helpers/LogEventType.cs new file mode 100644 index 0000000..f5e6718 --- /dev/null +++ b/XSOverlay VRChat Parser/Helpers/LogEventType.cs @@ -0,0 +1,9 @@ +namespace XSOverlay_VRChat_Parser.Helpers +{ + public enum LogEventType + { + Error, + Event, + Info + } +} diff --git a/XSOverlay VRChat Parser/Helpers/TailSubscription.cs b/XSOverlay VRChat Parser/Helpers/TailSubscription.cs new file mode 100644 index 0000000..b58e826 --- /dev/null +++ b/XSOverlay VRChat Parser/Helpers/TailSubscription.cs @@ -0,0 +1,62 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; + +namespace XSOverlay_VRChat_Parser.Helpers +{ + public class TailSubscription : IDisposable + { + private string _filePath { get; set; } + public delegate void OnUpdate(string content); + private OnUpdate updateFunc { get; set; } + private Timer timer { get; set; } + private long lastSize { get; set; } + + public TailSubscription(string filePath, OnUpdate func, long dueTimeMilliseconds, long frequencyMilliseconds) + { + _filePath = filePath; + updateFunc = func; + lastSize = new FileInfo(filePath).Length; + timer = new Timer(new TimerCallback(ExecOnUpdate), null, dueTimeMilliseconds, frequencyMilliseconds); + } + + private void ExecOnUpdate(object timerState) + { + if (!File.Exists(_filePath)) + { + Dispose(); + return; + } + + long size = new FileInfo(_filePath).Length; + + if (size > lastSize) + { + using (FileStream fs = new FileStream(_filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + using (StreamReader sr = new StreamReader(fs)) + { + sr.BaseStream.Seek(lastSize, SeekOrigin.Begin); + + StringBuilder outputContent = new StringBuilder(); + string line = string.Empty; + + while ((line = sr.ReadLine()) != null) + outputContent.Append(line + "\n"); + + lastSize = sr.BaseStream.Position; + + updateFunc(outputContent.ToString()); + } + } + } + } + + public void Dispose() + { + timer.Dispose(); + updateFunc = null; + } + } +} diff --git a/XSOverlay VRChat Parser/Models/ConfigurationModel.cs b/XSOverlay VRChat Parser/Models/ConfigurationModel.cs new file mode 100644 index 0000000..f24eb74 --- /dev/null +++ b/XSOverlay VRChat Parser/Models/ConfigurationModel.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text; +using System.Text.Json; +using XSNotifications.Enum; +using XSNotifications.Helpers; +using XSOverlay_VRChat_Parser.Helpers; + +namespace XSOverlay_VRChat_Parser.Models +{ + public class ConfigurationModel + { + [Annotation("Polling frequency for individual log file updates.", true, "GENERAL CONFIGURATION")] + public long ParseFrequencyMilliseconds { get; set; } + [Annotation("Polling frequency for new logs in OutputLogRoot")] + public long DirectoryPollFrequencyMilliseconds { get; set; } + [Annotation("Absolute path to output log root for VRChat. Environment variables will be expanded.")] + public string OutputLogRoot { get; set; } + [Annotation("Determines whether or not logs of parsed events will be written to the session log in the user folder. Valid values: true, false")] + public bool LogNotificationEvents { get; set; } + [Annotation("Volume for incoming notification sounds. Valid values: 0.0 -> 1.0.")] + public float NotificationVolume { get; set; } + [Annotation("Opacity for toast notifications. Valid values: 0.0 -> 1.0.")] + public float Opacity { get; set; } + + [Annotation("Determines whether or not player join notifications are delivered. Valid values: true, false", true, "PLAYER JOINED")] + public bool DisplayPlayerJoined { get; set; } + [Annotation("Period of time in seconds for the player join notification to remain on screen. Valid values: 0.0 -> float32 max")] + public float PlayerJoinedNotificationTimeoutSeconds { get; set; } + [Annotation("Relative path to icon for player joins. Other valid values include: \"\", \"default\", \"warning\", \"error\"")] + public string PlayerJoinedIconPath { get; set; } + [Annotation("Relative path to ogg-formatted audio for player joins. Other valid values include: \"\", \"default\", \"warning\", \"error\"")] + public string PlayerJoinedAudioPath { get; set; } + + [Annotation("Determines whether or not player left notifications are delivered. Valid values: true, false", true, "PLAYER LEFT")] + public bool DisplayPlayerLeft { get; set; } + [Annotation("Period of time in seconds for the player left notification to remain on screen. Valid values: 0.0 -> float32 max")] + public float PlayerLeftNotificationTimeoutSeconds { get; set; } + [Annotation("Relative path to icon for player left. Other valid values include: \"\", \"default\", \"warning\", \"error\"")] + public string PlayerLeftIconPath { get; set; } + [Annotation("Relative path to ogg-formatted audio for player left. Other valid values include: \"\", \"default\", \"warning\", \"error\"")] + public string PlayerLeftAudioPath { get; set; } + + [Annotation("Determines whether or not world change notifications are delivered. Valid values: true, false", true, "WORLD CHANGED")] + public bool DisplayWorldChanged { get; set; } + [Annotation("Period of time in seconds for player join/leave notifications to be silenced on world join. This is to avoid spam from enumerating everyone currently in the target world. Valid values: 0.0 -> float32 max")] + public long WorldJoinSilenceSeconds { get; set; } + [Annotation("Determines whether or not player join/leave notifications are silenced on world join. Warning, this gets spammy if on! Valid values: true, false")] + public bool DisplayJoinLeaveSilencedOverride { get; set; } + [Annotation("Period of time in seconds for the world changed notification to remain on screen. Value values: 0.0 -> float32 max")] + public float WorldChangedNotificationTimeoutSeconds { get; set; } + [Annotation("Relative path to icon for world changed. Other valid values include: \"\", \"default\", \"warning\", \"error\"")] + public string WorldChangedIconPath { get; set; } + [Annotation("Relative path to ogg-formatted audio for world changed. Other valid values include: \"\", \"default\", \"warning\", \"error\"")] + public string WorldChangedAudioPath { get; set; } + + [Annotation("Determines whether or not shader keywords exceeded notifications are delivered. Valid values: true, false", true, "SHADER KEYWORDS EXCEEDED")] + public bool DisplayMaximumKeywordsExceeded { get; set; } + [Annotation("Period of time in seconds for shader keywords exceeded notification to remain on screen. Valid values: 0.0 -> float32 max")] + public float MaximumKeywordsExceededTimeoutSeconds { get; set; } + [Annotation("Period of time in seconds after a shader keywords exceeded notification is sent to ignore shader keywords exceeded events. Valid values: 0.0 -> float32 max")] + public float MaximumKeywordsExceededCooldownSeconds { get; set; } + [Annotation("Relative path to icon for shader keywords exceeded. Other valid values include: \"\", \"default\", \"warning\", \"error\"")] + public string MaximumKeywordsExceededIconPath { get; set; } + [Annotation("Relative path to ogg-formatted audio for shader keywords exceeded. Other valid values include: \"\", \"default\", \"warning\", \"error\"")] + public string MaximumKeywordsExceededAudioPath { get; set; } + + [Annotation("Determines whether or not portal dropped notifications are delivered. Valid values: true, false", true, "PORTAL DROPPED")] + public bool DisplayPortalDropped { get; set; } + [Annotation("Period of time in seconds for portal dropped notification to remain on screen. Valid values: 0.0 -> float32 max")] + public float PortalDroppedTimeoutSeconds { get; set; } + [Annotation("Relative path to icon for portal dropped. Other valid values include: \"\", \"default\", \"warning\", \"error\"")] + public string PortalDroppedIconPath { get; set; } + [Annotation("Relative path to ogg-formatted audio for portal dropped. Other valid values include: \"\", \"default\", \"warning\", \"error\"")] + public string PortalDroppedAudioPath { get; set; } + + public ConfigurationModel() + { + ParseFrequencyMilliseconds = 300; + DirectoryPollFrequencyMilliseconds = 5000; + OutputLogRoot = @"%AppData%\..\LocalLow\VRChat\vrchat"; + LogNotificationEvents = true; + NotificationVolume = 0.2f; + Opacity = 0.75f; + + DisplayPlayerJoined = true; + PlayerJoinedNotificationTimeoutSeconds = 2.5f; + PlayerJoinedIconPath = @"\Resources\Icons\player_joined.png"; + PlayerJoinedAudioPath = @"\Resources\Audio\player_joined.ogg"; + + DisplayPlayerLeft = true; + PlayerLeftNotificationTimeoutSeconds = 2.5f; + PlayerLeftIconPath = @"\Resources\Icons\player_left.png"; + PlayerLeftAudioPath = @"\Resources\Audio\player_left.ogg"; + + DisplayWorldChanged = true; + WorldJoinSilenceSeconds = 20; + DisplayJoinLeaveSilencedOverride = false; + WorldChangedNotificationTimeoutSeconds = 3.0f; + WorldChangedIconPath = @"\Resources\Icons\world_changed.png"; + WorldChangedAudioPath = XSGlobals.GetBuiltInAudioSourceString(XSAudioDefault.Default); + + DisplayMaximumKeywordsExceeded = false; + MaximumKeywordsExceededTimeoutSeconds = 3.0f; + MaximumKeywordsExceededCooldownSeconds = 600.0f; + MaximumKeywordsExceededIconPath = @"\Resources\Icons\keywords_exceeded.png"; + MaximumKeywordsExceededAudioPath = XSGlobals.GetBuiltInAudioSourceString(XSAudioDefault.Warning); + + DisplayPortalDropped = true; + PortalDroppedTimeoutSeconds = 3.0f; + PortalDroppedIconPath = @"\Resources\Icons\portal_dropped.png"; + PortalDroppedAudioPath = XSGlobals.GetBuiltInAudioSourceString(XSAudioDefault.Default); + } + + public string GetLocalResourcePath(string path) => Path.GetDirectoryName(Assembly.GetEntryAssembly().Location) + path; + + public string AsJson(bool annotated = true) + { + string asJson = JsonSerializer.Serialize(this, new JsonSerializerOptions() { WriteIndented = true }); + + // JSON teeeeeeeeechnically doesn't support comments, but we can add them and there's a flag in our json reader for skipping them, so we're good. Theoretically. + if (annotated) + { + string[] lines = asJson.Split('\n'); + + StringBuilder sb = new StringBuilder(); + + PropertyInfo[] properties = this.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance); + + Dictionary> propertyAnnotations = new Dictionary>(); + + foreach (PropertyInfo p in properties) + propertyAnnotations.Add(p.Name.ToLower(), new Tuple(p, + (Annotation)p.GetCustomAttributes(typeof(Annotation), true).GetValue(0))); + + StringBuilder commentBuilder = new StringBuilder(); + StringBuilder propertyBuilder = new StringBuilder(); + foreach (string line in lines) + { + if (line.Trim() == "}") + { + if (propertyBuilder.ToString() != string.Empty) + { + if (commentBuilder.Length > 0) + sb.Append('\n' + commentBuilder.ToString() + '\n'); + sb.Append(propertyBuilder.ToString() + "}"); + continue; + } + } + else if (!line.Contains(':')) + { + sb.Append(line + '\n'); + continue; + } + + string propertyNameLower = line.Substring(0, line.IndexOf(':')).Trim().Replace("\"", "").ToLower(); + string whitespace = line.Substring(0, line.IndexOf('\"')); + + if (propertyAnnotations.ContainsKey(propertyNameLower)) + { + Tuple pa = propertyAnnotations[propertyNameLower]; + + if (pa.Item2.startsGroup) + { + if (commentBuilder.Length > 0) + sb.Append('\n' + commentBuilder.ToString() + '\n'); + if (propertyBuilder.Length > 0) + sb.Append(propertyBuilder.ToString()); + + commentBuilder.Clear(); + propertyBuilder.Clear(); + + commentBuilder.Append($"{whitespace}// {pa.Item2.groupDescription}\n"); + } + + commentBuilder.Append($"{whitespace}// {pa.Item1.Name} : {pa.Item2.description}\n"); + propertyBuilder.Append(line + '\n'); + } + else + propertyBuilder.Append(line + '\n'); + } + + asJson = sb.ToString(); + } + + return asJson; + } + } +} diff --git a/XSOverlay VRChat Parser/Program.cs b/XSOverlay VRChat Parser/Program.cs new file mode 100644 index 0000000..7db39b3 --- /dev/null +++ b/XSOverlay VRChat Parser/Program.cs @@ -0,0 +1,417 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using XSNotifications; +using XSNotifications.Enum; +using XSNotifications.Helpers; +using XSOverlay_VRChat_Parser.Helpers; +using XSOverlay_VRChat_Parser.Models; + +namespace XSOverlay_VRChat_Parser +{ + class Program + { + static ConfigurationModel Configuration { get; set; } + + static HashSet IgnorableAudioPaths = new HashSet(); + static HashSet IgnorableIconPaths = new HashSet(); + + static string UserFolderPath { get; set; } + static string LogFileName { get; set; } + static string LastKnownLocationName { get; set; } // World name + static string LastKnownLocationID { get; set; } // World ID + + static readonly object logMutex = new object(); + + static Timer LogDetectionTimer { get; set; } + + static Dictionary Subscriptions { get; set; } + + static DateTime SilencedUntil = DateTime.Now, + LastMaximumKeywordsNotification = DateTime.Now; + + static XSNotifier Notifier { get; set; } + + static async Task Main(string[] args) + { + UserFolderPath = Environment.ExpandEnvironmentVariables(@"%AppData%\..\LocalLow\XSOverlay VRChat Parser"); + if (!Directory.Exists(UserFolderPath)) + Directory.CreateDirectory(UserFolderPath); + + if (!Directory.Exists($@"{UserFolderPath}\Logs")) + Directory.CreateDirectory($@"{UserFolderPath}\Logs"); + + DateTime now = DateTime.Now; + LogFileName = $"Session_{now.Year:0000}{now.Month:00}{now.Day:00}{now.Hour:00}{now.Minute:00}{now.Second:00}.log"; + Log(LogEventType.Info, $@"Log initialized at {UserFolderPath}\Logs\{LogFileName}"); + + try + { + if (!File.Exists($@"{UserFolderPath}\config.json")) + { + Configuration = new ConfigurationModel(); + File.WriteAllText($@"{UserFolderPath}\config.json", Configuration.AsJson()); + } + else + Configuration = JsonSerializer.Deserialize(File.ReadAllText($@"{UserFolderPath}\config.json"), new JsonSerializerOptions { ReadCommentHandling = JsonCommentHandling.Skip }); + + // Rewrite configuration to update it with any new fields not in existing configuration. Useful during update process and making sure the config always has updated annotations. + // Users shouldn't need to re-configure every time they update the software. + File.WriteAllText($@"{UserFolderPath}\config.json", Configuration.AsJson()); + } + catch (Exception ex) + { + Log(LogEventType.Error, "An exception occurred while attempting to read or write the configuration file."); + Log(ex); + return; + } + + IgnorableAudioPaths.Add(string.Empty); + IgnorableAudioPaths.Add(XSGlobals.GetBuiltInAudioSourceString(XSAudioDefault.Default)); + IgnorableAudioPaths.Add(XSGlobals.GetBuiltInAudioSourceString(XSAudioDefault.Warning)); + IgnorableAudioPaths.Add(XSGlobals.GetBuiltInAudioSourceString(XSAudioDefault.Error)); + + IgnorableIconPaths.Add(string.Empty); + IgnorableIconPaths.Add(XSGlobals.GetBuiltInIconTypeString(XSIconDefaults.Default)); + IgnorableIconPaths.Add(XSGlobals.GetBuiltInIconTypeString(XSIconDefaults.Warning)); + IgnorableIconPaths.Add(XSGlobals.GetBuiltInIconTypeString(XSIconDefaults.Error)); + + Subscriptions = new Dictionary(); + LogDetectionTimer = new Timer(new TimerCallback(LogDetectionTick), null, 0, Configuration.DirectoryPollFrequencyMilliseconds); + + Log(LogEventType.Info, $"Log detection timer initialized with poll frequency {Configuration.DirectoryPollFrequencyMilliseconds} and parse frequency {Configuration.ParseFrequencyMilliseconds}."); + + XSGlobals.DefaultSourceApp = "XSOverlay VRChat Parser"; + XSGlobals.DefaultOpacity = Configuration.Opacity; + XSGlobals.DefaultVolume = Configuration.NotificationVolume; + + try + { + Notifier = new XSNotifier(); + } + catch (Exception ex) + { + Log(LogEventType.Error, "An exception occurred while constructing XSNotifier."); + Log(ex); + Exit(); + } + + Log(LogEventType.Info, $"XSNotifier initialized."); + + try + { + Notifier.SendNotification(new XSNotification() + { + AudioPath = XSGlobals.GetBuiltInAudioSourceString(XSAudioDefault.Default), + Title = "Application Started", + Content = $"VRChat Log Parser has initialized.", + Height = 110.0f + }); + } + catch (Exception ex) + { + Log(LogEventType.Error, "An exception occurred while sending initialization notification."); + Log(ex); + Exit(); + } + + await Task.Delay(-1); // Shutdown should be managed by XSO, so just... hang around. Maybe implement periodic checks to see if XSO is running to avoid being orphaned. + } + + static void Exit() + { + Log(LogEventType.Info, "Disposing notifier and exiting application."); + + Notifier.Dispose(); + + foreach (var item in Subscriptions) + item.Value.Dispose(); + + Subscriptions.Clear(); + + Environment.Exit(-1); + } + + static void Log(LogEventType type, string message) + { + DateTime now = DateTime.Now; + string dateTimeStamp = $"[{now.Year:0000}/{now.Month:00}/{now.Day:00} {now.Hour:00}:{now.Minute:00}:{now.Second:00}]"; + + lock (logMutex) + { + switch (type) + { + case LogEventType.Error: + File.AppendAllText($@"{UserFolderPath}\Logs\{LogFileName}", $"{dateTimeStamp} [ERROR] {message}\r\n"); + break; + case LogEventType.Event: + if (Configuration.LogNotificationEvents) + File.AppendAllText($@"{UserFolderPath}\Logs\{LogFileName}", $"{dateTimeStamp} [EVENT] {message}\r\n"); + break; + case LogEventType.Info: + File.AppendAllText($@"{UserFolderPath}\Logs\{LogFileName}", $"{dateTimeStamp} [INFO] {message}\r\n"); + break; + } + } + } + + static void Log(Exception ex) + { + Log(LogEventType.Error, $"{ex.Message}\r\n{ex.InnerException}\r\n{ex.StackTrace}"); + } + + static void SendNotification(XSNotification notification) + { + try + { + Notifier.SendNotification(notification); + } + catch (Exception ex) + { + Log(LogEventType.Error, "An exception occurred while sending a routine event notification."); + Log(ex); + Exit(); + } + } + + static void LogDetectionTick(object timerState) + { + string[] allFiles = Directory.GetFiles(Environment.ExpandEnvironmentVariables(Configuration.OutputLogRoot)); + foreach (string fn in allFiles) + if (!Subscriptions.ContainsKey(fn) && fn.Contains("output_log")) + { + Subscriptions.Add(fn, new TailSubscription(fn, ParseTick, 0, Configuration.ParseFrequencyMilliseconds)); + Log(LogEventType.Info, $"A tail subscription was added to {fn}"); + } + } + + /// + /// This is messy, but they've changed format on me often enough that it's difficult to care! + /// + /// + static void ParseTick(string content) + { + List> ToSend = new List>(); + + if (!string.IsNullOrWhiteSpace(content)) + { + string[] lines = content.Split('\n'); + + foreach (string dirtyLine in lines) + { + string line = Regex.Replace(dirtyLine + .Replace("\r", "") + .Replace("\n", "") + .Replace("\t", "") + .Trim(), + @"\s+", " ", RegexOptions.Multiline); + + if (!string.IsNullOrWhiteSpace(line)) + { + int tocLoc = 0; + string[] tokens = line.Split(' '); + + // Get new LastKnownLocationName here + if (line.Contains("Joining or")) + { + for (int i = 0; i < tokens.Length; i++) + { + if (tokens[i] == "Room:") + { + tocLoc = i; + break; + } + } + + if (tokens.Length > tocLoc + 1) + { + string name = ""; + + for (int i = tocLoc + 1; i < tokens.Length; i++) + name += tokens[i] + " "; + + name = name.Trim(); + + LastKnownLocationName = name.Trim(); + } + } + // Get new LastKnownLocationID here + else if (line.Contains("Joining w")) + { + for (int i = 0; i < tokens.Length; i++) + { + if (tokens[i] == "Joining") + { + tocLoc = i; + break; + } + } + + if (tokens.Length > tocLoc + 1) + LastKnownLocationID = tokens[tocLoc + 1]; + } + // At this point, we have the location name/id and are transitioning. + else if (line.Contains("Successfully joined room")) + { + SilencedUntil = DateTime.Now.AddSeconds(Configuration.WorldJoinSilenceSeconds); + + ToSend.Add(new Tuple(EventType.WorldChange, new XSNotification() + { + Timeout = Configuration.WorldChangedNotificationTimeoutSeconds, + Icon = IgnorableIconPaths.Contains(Configuration.WorldChangedIconPath) ? Configuration.WorldChangedIconPath : Configuration.GetLocalResourcePath(Configuration.WorldChangedIconPath), + AudioPath = IgnorableAudioPaths.Contains(Configuration.WorldChangedAudioPath) ? Configuration.WorldChangedAudioPath : Configuration.GetLocalResourcePath(Configuration.WorldChangedAudioPath), + Title = LastKnownLocationName, + Content = $"{(Configuration.DisplayJoinLeaveSilencedOverride ? "" : $"Silencing notifications for {Configuration.WorldJoinSilenceSeconds} seconds.")}", + Height = 110 + })); + + Log(LogEventType.Event, $"[VRC] World changed to {LastKnownLocationName} -> {LastKnownLocationID}"); + } + // Get player joins here + else if (line.Contains("[Behaviour] OnPlayerJoined")) + { + for (int i = 0; i < tokens.Length; i++) + { + if (tokens[i] == "OnPlayerJoined") + { + tocLoc = i; + break; + } + } + + string message = ""; + string displayName = ""; + + if (tokens.Length > tocLoc + 1) + { + string name = ""; + + for (int i = tocLoc + 1; i < tokens.Length; i++) + name += tokens[i] + " "; + + displayName = name.Trim(); + + message += displayName; + } + else + { + message += "No username was provided."; + } + + ToSend.Add(new Tuple(EventType.PlayerJoin, new XSNotification() + { + Timeout = Configuration.PlayerJoinedNotificationTimeoutSeconds, + Icon = IgnorableIconPaths.Contains(Configuration.PlayerJoinedIconPath) ? Configuration.PlayerJoinedIconPath : Configuration.GetLocalResourcePath(Configuration.PlayerJoinedIconPath), + AudioPath = IgnorableAudioPaths.Contains(Configuration.PlayerJoinedAudioPath) ? Configuration.PlayerJoinedAudioPath : Configuration.GetLocalResourcePath(Configuration.PlayerJoinedAudioPath), + Title = message + })); + + Log(LogEventType.Event, $"[VRC] Join: {message}"); + } + // Get player leaves + else if (line.Contains("[Behaviour] OnPlayerLeft ")) + { + for (int i = 0; i < tokens.Length; i++) + { + if (tokens[i] == "OnPlayerLeft") + { + tocLoc = i; + break; + } + } + + string message = ""; + string displayName = ""; + + if (tokens.Length > tocLoc + 1) + { + string name = ""; + + for (int i = tocLoc + 1; i < tokens.Length; i++) + name += tokens[i] + " "; + + displayName = name.Trim(); + + message += displayName; + } + else + { + message += "No username was provided."; + } + + ToSend.Add(new Tuple(EventType.PlayerLeft, new XSNotification() + { + Timeout = Configuration.PlayerLeftNotificationTimeoutSeconds, + Icon = IgnorableIconPaths.Contains(Configuration.PlayerLeftIconPath) ? Configuration.PlayerLeftIconPath : Configuration.GetLocalResourcePath(Configuration.PlayerLeftIconPath), + AudioPath = IgnorableAudioPaths.Contains(Configuration.PlayerLeftAudioPath) ? Configuration.PlayerLeftAudioPath : Configuration.GetLocalResourcePath(Configuration.PlayerLeftAudioPath), + Title = message + })); + + Log(LogEventType.Event, $"[VRC] Leave: {message}"); + } + // Shader keyword limit exceeded + else if (line.Contains("Maximum number (256)")) + { + ToSend.Add(new Tuple(EventType.KeywordsExceeded, new XSNotification() + { + Timeout = Configuration.MaximumKeywordsExceededTimeoutSeconds, + Icon = IgnorableIconPaths.Contains(Configuration.MaximumKeywordsExceededIconPath) ? Configuration.MaximumKeywordsExceededIconPath : Configuration.GetLocalResourcePath(Configuration.MaximumKeywordsExceededIconPath), + AudioPath = IgnorableAudioPaths.Contains(Configuration.MaximumKeywordsExceededAudioPath) ? Configuration.MaximumKeywordsExceededAudioPath : Configuration.GetLocalResourcePath(Configuration.MaximumKeywordsExceededAudioPath), + Title = "Maximum shader keywords exceeded!" + })); + + Log(LogEventType.Event, $"[VRC] Maximum shader keywords exceeded!"); + } + // Portal dropped + else if (line.Contains("[Behaviour]") && line.Contains("Portals/PortalInternalDynamic")) + { + ToSend.Add(new Tuple(EventType.PortalDropped, new XSNotification() + { + Timeout = Configuration.PortalDroppedTimeoutSeconds, + Icon = IgnorableIconPaths.Contains(Configuration.PortalDroppedIconPath) ? Configuration.PortalDroppedIconPath : Configuration.GetLocalResourcePath(Configuration.PortalDroppedIconPath), + AudioPath = IgnorableAudioPaths.Contains(Configuration.PortalDroppedAudioPath) ? Configuration.PortalDroppedAudioPath : Configuration.GetLocalResourcePath(Configuration.PortalDroppedAudioPath), + Title = "A portal has been spawned." + })); + + Log(LogEventType.Event, $"[VRC] Portal dropped."); + } + } + } + } + + if (ToSend.Count > 0) + foreach (Tuple notification in ToSend) + { + if ( + (!CurrentlySilenced() && Configuration.DisplayPlayerJoined && notification.Item1 == EventType.PlayerJoin) + || (!CurrentlySilenced() && Configuration.DisplayPlayerLeft && notification.Item1 == EventType.PlayerLeft) + || (Configuration.DisplayWorldChanged && notification.Item1 == EventType.WorldChange) + || (Configuration.DisplayPortalDropped && notification.Item1 == EventType.PortalDropped) + ) + SendNotification(notification.Item2); + else if (Configuration.DisplayMaximumKeywordsExceeded && notification.Item1 == EventType.KeywordsExceeded + && DateTime.Now > LastMaximumKeywordsNotification.AddSeconds(Configuration.MaximumKeywordsExceededCooldownSeconds)) + { + LastMaximumKeywordsNotification = DateTime.Now; + SendNotification(notification.Item2); + } + } + } + + static bool CurrentlySilenced() + { + if (Configuration.DisplayJoinLeaveSilencedOverride) + return false; + + if (DateTime.Now > SilencedUntil) + return false; + + return true; + } + + } +} diff --git a/XSOverlay VRChat Parser/Properties/PublishProfiles/FolderProfile.pubxml b/XSOverlay VRChat Parser/Properties/PublishProfiles/FolderProfile.pubxml new file mode 100644 index 0000000..1c8443c --- /dev/null +++ b/XSOverlay VRChat Parser/Properties/PublishProfiles/FolderProfile.pubxml @@ -0,0 +1,18 @@ + + + + + Release + Any CPU + C:\VS2019 Projects\XSOverlay VRChat Parser Deploy + FileSystem + net5.0 + win-x64 + true + False + False + False + + \ No newline at end of file diff --git a/XSOverlay VRChat Parser/Resources/Audio/player_joined.ogg b/XSOverlay VRChat Parser/Resources/Audio/player_joined.ogg new file mode 100644 index 0000000..d7d19c5 Binary files /dev/null and b/XSOverlay VRChat Parser/Resources/Audio/player_joined.ogg differ diff --git a/XSOverlay VRChat Parser/Resources/Audio/player_left.ogg b/XSOverlay VRChat Parser/Resources/Audio/player_left.ogg new file mode 100644 index 0000000..2988768 Binary files /dev/null and b/XSOverlay VRChat Parser/Resources/Audio/player_left.ogg differ diff --git a/XSOverlay VRChat Parser/Resources/Icons/keywords_exceeded.png b/XSOverlay VRChat Parser/Resources/Icons/keywords_exceeded.png new file mode 100644 index 0000000..4ac41de Binary files /dev/null and b/XSOverlay VRChat Parser/Resources/Icons/keywords_exceeded.png differ diff --git a/XSOverlay VRChat Parser/Resources/Icons/player_joined.png b/XSOverlay VRChat Parser/Resources/Icons/player_joined.png new file mode 100644 index 0000000..6d13b30 Binary files /dev/null and b/XSOverlay VRChat Parser/Resources/Icons/player_joined.png differ diff --git a/XSOverlay VRChat Parser/Resources/Icons/player_left.png b/XSOverlay VRChat Parser/Resources/Icons/player_left.png new file mode 100644 index 0000000..0a23649 Binary files /dev/null and b/XSOverlay VRChat Parser/Resources/Icons/player_left.png differ diff --git a/XSOverlay VRChat Parser/Resources/Icons/portal_dropped.png b/XSOverlay VRChat Parser/Resources/Icons/portal_dropped.png new file mode 100644 index 0000000..6705940 Binary files /dev/null and b/XSOverlay VRChat Parser/Resources/Icons/portal_dropped.png differ diff --git a/XSOverlay VRChat Parser/Resources/Icons/world_changed.png b/XSOverlay VRChat Parser/Resources/Icons/world_changed.png new file mode 100644 index 0000000..b3d2da8 Binary files /dev/null and b/XSOverlay VRChat Parser/Resources/Icons/world_changed.png differ diff --git a/XSOverlay VRChat Parser/XSOverlay VRChat Parser.csproj b/XSOverlay VRChat Parser/XSOverlay VRChat Parser.csproj new file mode 100644 index 0000000..4f0e30c --- /dev/null +++ b/XSOverlay VRChat Parser/XSOverlay VRChat Parser.csproj @@ -0,0 +1,37 @@ + + + + WinExe + net5.0 + XSOverlay_VRChat_Parser + + + + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + +