Move activity watcher to separate process (#810)

this was done to:
- ensure robloxplayerbeta launches as an orphaned process
- help alleviate problems with multiple instances
- alleviate problems with the notifyicon causing blocking conflicts on the bootstrapper ui thread
- help reduce functional dependency on the bootstrapper, makes it less monolithic and more maintainable

ive always wanted to do this for a long while, but have always put it off because of how painful it would be

this may genuinely be the most painful refactoring i've ever had to do, but after 2 days, i managed to do it, and it works great!
This commit is contained in:
pizzaboxer 2024-08-28 22:39:49 +01:00
parent cf45d9c808
commit fd290f9ff7
No known key found for this signature in database
GPG Key ID: 59D4A1DBAD0F2BA8
17 changed files with 329 additions and 227 deletions

View File

@ -8,6 +8,7 @@ using Microsoft.Win32;
using Bloxstrap.Models.SettingTasks.Base; using Bloxstrap.Models.SettingTasks.Base;
using Bloxstrap.UI.Elements.About.Pages; using Bloxstrap.UI.Elements.About.Pages;
using Bloxstrap.UI.Elements.About; using Bloxstrap.UI.Elements.About;
using System;
namespace Bloxstrap namespace Bloxstrap
{ {
@ -37,8 +38,6 @@ namespace Bloxstrap
public static readonly MD5 MD5Provider = MD5.Create(); public static readonly MD5 MD5Provider = MD5.Create();
public static NotifyIconWrapper? NotifyIcon { get; set; }
public static readonly Logger Logger = new(); public static readonly Logger Logger = new();
public static readonly Dictionary<string, BaseTask> PendingSettingTasks = new(); public static readonly Dictionary<string, BaseTask> PendingSettingTasks = new();
@ -55,19 +54,23 @@ namespace Bloxstrap
) )
); );
#if RELEASE
private static bool _showingExceptionDialog = false; private static bool _showingExceptionDialog = false;
#endif
private static bool _terminating = false;
public static void Terminate(ErrorCode exitCode = ErrorCode.ERROR_SUCCESS) public static void Terminate(ErrorCode exitCode = ErrorCode.ERROR_SUCCESS)
{ {
if (_terminating)
return;
int exitCodeNum = (int)exitCode; int exitCodeNum = (int)exitCode;
Logger.WriteLine("App::Terminate", $"Terminating with exit code {exitCodeNum} ({exitCode})"); Logger.WriteLine("App::Terminate", $"Terminating with exit code {exitCodeNum} ({exitCode})");
NotifyIcon?.Dispose(); Current.Dispatcher.Invoke(() => Current.Shutdown(exitCodeNum));
// Environment.Exit(exitCodeNum);
Environment.Exit(exitCodeNum); _terminating = true;
} }
void GlobalExceptionHandler(object sender, DispatcherUnhandledExceptionEventArgs e) void GlobalExceptionHandler(object sender, DispatcherUnhandledExceptionEventArgs e)
@ -79,24 +82,28 @@ namespace Bloxstrap
FinalizeExceptionHandling(e.Exception); FinalizeExceptionHandling(e.Exception);
} }
public static void FinalizeExceptionHandling(Exception exception, bool log = true) public static void FinalizeExceptionHandling(AggregateException ex)
{
foreach (var innerEx in ex.InnerExceptions)
Logger.WriteException("App::FinalizeExceptionHandling", innerEx);
FinalizeExceptionHandling(ex.GetBaseException(), false);
}
public static void FinalizeExceptionHandling(Exception ex, bool log = true)
{ {
if (log) if (log)
Logger.WriteException("App::FinalizeExceptionHandling", exception); Logger.WriteException("App::FinalizeExceptionHandling", ex);
#if DEBUG
throw exception;
#else
if (_showingExceptionDialog) if (_showingExceptionDialog)
return; return;
_showingExceptionDialog = true; _showingExceptionDialog = true;
if (!LaunchSettings.QuietFlag.Active) if (!LaunchSettings.QuietFlag.Active)
Frontend.ShowExceptionDialog(exception); Frontend.ShowExceptionDialog(ex);
Terminate(ErrorCode.ERROR_INSTALL_FAILURE); Terminate(ErrorCode.ERROR_INSTALL_FAILURE);
#endif
} }
protected override void OnStartup(StartupEventArgs e) protected override void OnStartup(StartupEventArgs e)
@ -208,10 +215,6 @@ namespace Bloxstrap
State.Load(); State.Load();
FastFlags.Load(); FastFlags.Load();
// we can only parse them now as settings need
// to be loaded first to know what our channel is
// LaunchSettings.ParseRoblox();
if (!Locale.SupportedLocales.ContainsKey(Settings.Prop.Locale)) if (!Locale.SupportedLocales.ContainsKey(Settings.Prop.Locale))
{ {
Settings.Prop.Locale = "nil"; Settings.Prop.Locale = "nil";
@ -228,7 +231,7 @@ namespace Bloxstrap
LaunchHandler.ProcessLaunchArgs(); LaunchHandler.ProcessLaunchArgs();
} }
Terminate(); // you must *explicitly* call terminate when everything is done, it won't be called implicitly
} }
} }
} }

View File

@ -3,8 +3,6 @@ using System.Windows.Forms;
using Microsoft.Win32; using Microsoft.Win32;
using Bloxstrap.Integrations;
using Bloxstrap.Resources;
using Bloxstrap.AppData; using Bloxstrap.AppData;
namespace Bloxstrap namespace Bloxstrap
@ -289,9 +287,6 @@ namespace Bloxstrap
_launchCommandLine = _launchCommandLine.Replace("robloxLocale:en_us", $"robloxLocale:{match.Groups[1].Value}", StringComparison.InvariantCultureIgnoreCase); _launchCommandLine = _launchCommandLine.Replace("robloxLocale:en_us", $"robloxLocale:{match.Groups[1].Value}", StringComparison.InvariantCultureIgnoreCase);
} }
// whether we should wait for roblox to exit to handle stuff in the background or clean up after roblox closes
bool shouldWait = false;
var startInfo = new ProcessStartInfo() var startInfo = new ProcessStartInfo()
{ {
FileName = _playerLocation, FileName = _playerLocation,
@ -308,19 +303,16 @@ namespace Bloxstrap
// v2.2.0 - byfron will trip if we keep a process handle open for over a minute, so we're doing this now // v2.2.0 - byfron will trip if we keep a process handle open for over a minute, so we're doing this now
int gameClientPid; int gameClientPid;
using (Process gameClient = Process.Start(startInfo)!) using (var gameClient = Process.Start(startInfo)!)
{ {
gameClientPid = gameClient.Id; gameClientPid = gameClient.Id;
} }
List<Process?> autocloseProcesses = new();
ActivityWatcher? activityWatcher = null;
DiscordRichPresence? richPresence = null;
App.Logger.WriteLine(LOG_IDENT, $"Started Roblox (PID {gameClientPid})"); App.Logger.WriteLine(LOG_IDENT, $"Started Roblox (PID {gameClientPid})");
using (var startEvent = new SystemEvent(AppData.StartEvent)) using (var startEvent = new SystemEvent(AppData.StartEvent))
{ {
// TODO: get rid of this
bool startEventFired = await startEvent.WaitForEvent(); bool startEventFired = await startEvent.WaitForEvent();
startEvent.Close(); startEvent.Close();
@ -330,40 +322,14 @@ namespace Bloxstrap
return; return;
} }
if (App.Settings.Prop.EnableActivityTracking && _launchMode == LaunchMode.Player) var autoclosePids = new List<int>();
App.NotifyIcon?.SetProcessId(gameClientPid);
if (App.Settings.Prop.EnableActivityTracking)
{
activityWatcher = new(gameClientPid);
shouldWait = true;
App.NotifyIcon?.SetActivityWatcher(activityWatcher);
if (App.Settings.Prop.UseDisableAppPatch)
{
activityWatcher.OnAppClose += (_, _) =>
{
App.Logger.WriteLine(LOG_IDENT, "Received desktop app exit, closing Roblox");
using var process = Process.GetProcessById(gameClientPid);
process.CloseMainWindow();
};
}
if (App.Settings.Prop.UseDiscordRichPresence)
{
App.Logger.WriteLine(LOG_IDENT, "Using Discord Rich Presence");
richPresence = new(activityWatcher);
App.NotifyIcon?.SetRichPresenceHandler(richPresence);
}
}
// launch custom integrations now // launch custom integrations now
foreach (CustomIntegration integration in App.Settings.Prop.CustomIntegrations) foreach (var integration in App.Settings.Prop.CustomIntegrations)
{ {
App.Logger.WriteLine(LOG_IDENT, $"Launching custom integration '{integration.Name}' ({integration.Location} {integration.LaunchArgs} - autoclose is {integration.AutoClose})"); App.Logger.WriteLine(LOG_IDENT, $"Launching custom integration '{integration.Name}' ({integration.Location} {integration.LaunchArgs} - autoclose is {integration.AutoClose})");
int pid = 0;
try try
{ {
var process = Process.Start(new ProcessStartInfo var process = Process.Start(new ProcessStartInfo
@ -372,48 +338,34 @@ namespace Bloxstrap
Arguments = integration.LaunchArgs.Replace("\r\n", " "), Arguments = integration.LaunchArgs.Replace("\r\n", " "),
WorkingDirectory = Path.GetDirectoryName(integration.Location), WorkingDirectory = Path.GetDirectoryName(integration.Location),
UseShellExecute = true UseShellExecute = true
}); })!;
if (integration.AutoClose) pid = process.Id;
{
shouldWait = true;
autocloseProcesses.Add(process);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
App.Logger.WriteLine(LOG_IDENT, $"Failed to launch integration '{integration.Name}'!"); App.Logger.WriteLine(LOG_IDENT, $"Failed to launch integration '{integration.Name}'!");
App.Logger.WriteLine(LOG_IDENT, $"{ex.Message}"); App.Logger.WriteLine(LOG_IDENT, $"{ex.Message}");
} }
if (integration.AutoClose && pid != 0)
autoclosePids.Add(pid);
}
using (var proclock = new InterProcessLock("Watcher"))
{
string args = gameClientPid.ToString();
if (autoclosePids.Any())
args += $";{String.Join(',', autoclosePids)}";
if (proclock.IsAcquired)
Process.Start(Paths.Process, $"-watcher \"{args}\"");
} }
// event fired, wait for 3 seconds then close // event fired, wait for 3 seconds then close
await Task.Delay(3000); await Task.Delay(3000);
Dialog?.CloseBootstrapper(); Dialog?.CloseBootstrapper();
// keep bloxstrap open in the background if needed
if (!shouldWait)
return;
activityWatcher?.StartWatcher();
App.Logger.WriteLine(LOG_IDENT, "Waiting for Roblox to close");
while (Utilities.GetProcessesSafe().Any(x => x.Id == gameClientPid))
await Task.Delay(1000);
App.Logger.WriteLine(LOG_IDENT, $"Roblox has exited");
richPresence?.Dispose();
foreach (var process in autocloseProcesses)
{
if (process is null || process.HasExited)
continue;
App.Logger.WriteLine(LOG_IDENT, $"Autoclosing process '{process.ProcessName}' (PID {process.Id})");
process.Kill();
}
} }
public void CancelInstall() public void CancelInstall()

View File

@ -19,7 +19,6 @@
private const string GameJoinedEntryPattern = @"serverId: ([0-9\.]+)\|[0-9]+"; private const string GameJoinedEntryPattern = @"serverId: ([0-9\.]+)\|[0-9]+";
private const string GameMessageEntryPattern = @"\[BloxstrapRPC\] (.*)"; private const string GameMessageEntryPattern = @"\[BloxstrapRPC\] (.*)";
private int _gameClientPid;
private int _logEntriesRead = 0; private int _logEntriesRead = 0;
private bool _teleportMarker = false; private bool _teleportMarker = false;
private bool _reservedTeleportMarker = false; private bool _reservedTeleportMarker = false;
@ -27,6 +26,7 @@
public event EventHandler<string>? OnLogEntry; public event EventHandler<string>? OnLogEntry;
public event EventHandler? OnGameJoin; public event EventHandler? OnGameJoin;
public event EventHandler? OnGameLeave; public event EventHandler? OnGameLeave;
public event EventHandler? OnLogOpen;
public event EventHandler? OnAppClose; public event EventHandler? OnAppClose;
public event EventHandler<Message>? OnRPCMessage; public event EventHandler<Message>? OnRPCMessage;
@ -47,14 +47,9 @@
public bool IsDisposed = false; public bool IsDisposed = false;
public ActivityWatcher(int gameClientPid) public async void Start()
{ {
_gameClientPid = gameClientPid; const string LOG_IDENT = "ActivityWatcher::Start";
}
public async void StartWatcher()
{
const string LOG_IDENT = "ActivityWatcher::StartWatcher";
// okay, here's the process: // okay, here's the process:
// //
@ -84,23 +79,26 @@
{ {
logFileInfo = new DirectoryInfo(logDirectory) logFileInfo = new DirectoryInfo(logDirectory)
.GetFiles() .GetFiles()
.Where(x => x.CreationTime <= DateTime.Now) .Where(x => x.Name.Contains("Player", StringComparison.OrdinalIgnoreCase) && x.CreationTime <= DateTime.Now)
.OrderByDescending(x => x.CreationTime) .OrderByDescending(x => x.CreationTime)
.First(); .First();
if (logFileInfo.CreationTime.AddSeconds(15) > DateTime.Now) if (logFileInfo.CreationTime.AddSeconds(15) > DateTime.Now)
break; break;
// TODO: report failure after 10 seconds of no log file
App.Logger.WriteLine(LOG_IDENT, $"Could not find recent enough log file, waiting... (newest is {logFileInfo.Name})"); App.Logger.WriteLine(LOG_IDENT, $"Could not find recent enough log file, waiting... (newest is {logFileInfo.Name})");
await Task.Delay(1000); await Task.Delay(1000);
} }
OnLogOpen?.Invoke(this, EventArgs.Empty);
LogLocation = logFileInfo.FullName; LogLocation = logFileInfo.FullName;
FileStream logFileStream = logFileInfo.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite); FileStream logFileStream = logFileInfo.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
App.Logger.WriteLine(LOG_IDENT, $"Opened {LogLocation}"); App.Logger.WriteLine(LOG_IDENT, $"Opened {LogLocation}");
AutoResetEvent logUpdatedEvent = new(false); var logUpdatedEvent = new AutoResetEvent(false);
FileSystemWatcher logWatcher = new() var logWatcher = new FileSystemWatcher()
{ {
Path = logDirectory, Path = logDirectory,
Filter = Path.GetFileName(logFileInfo.FullName), Filter = Path.GetFileName(logFileInfo.FullName),
@ -108,7 +106,7 @@
}; };
logWatcher.Changed += (s, e) => logUpdatedEvent.Set(); logWatcher.Changed += (s, e) => logUpdatedEvent.Set();
using StreamReader sr = new(logFileStream); using var sr = new StreamReader(logFileStream);
while (!IsDisposed) while (!IsDisposed)
{ {
@ -117,13 +115,13 @@
if (log is null) if (log is null)
logUpdatedEvent.WaitOne(250); logUpdatedEvent.WaitOne(250);
else else
ExamineLogEntry(log); ReadLogEntry(log);
} }
} }
private void ExamineLogEntry(string entry) private void ReadLogEntry(string entry)
{ {
const string LOG_IDENT = "ActivityWatcher::ExamineLogEntry"; const string LOG_IDENT = "ActivityWatcher::ReadLogEntry";
OnLogEntry?.Invoke(this, entry); OnLogEntry?.Invoke(this, entry);
@ -302,7 +300,7 @@
var ipInfo = await Http.GetJson<IPInfoResponse>($"https://ipinfo.io/{ActivityMachineAddress}/json"); var ipInfo = await Http.GetJson<IPInfoResponse>($"https://ipinfo.io/{ActivityMachineAddress}/json");
if (ipInfo is null) if (ipInfo is null)
return $"? ({Resources.Strings.ActivityTracker_LookupFailed})"; return $"? ({Strings.ActivityTracker_LookupFailed})";
if (string.IsNullOrEmpty(ipInfo.Country)) if (string.IsNullOrEmpty(ipInfo.Country))
location = "?"; location = "?";
@ -312,7 +310,7 @@
location = $"{ipInfo.City}, {ipInfo.Region}, {ipInfo.Country}"; location = $"{ipInfo.City}, {ipInfo.Region}, {ipInfo.Country}";
if (!ActivityInGame) if (!ActivityInGame)
return $"? ({Resources.Strings.ActivityTracker_LeftGame})"; return $"? ({Strings.ActivityTracker_LeftGame})";
GeolocationCache[ActivityMachineAddress] = location; GeolocationCache[ActivityMachineAddress] = location;
@ -323,7 +321,7 @@
App.Logger.WriteLine(LOG_IDENT, $"Failed to get server location for {ActivityMachineAddress}"); App.Logger.WriteLine(LOG_IDENT, $"Failed to get server location for {ActivityMachineAddress}");
App.Logger.WriteException(LOG_IDENT, ex); App.Logger.WriteException(LOG_IDENT, ex);
return $"? ({Resources.Strings.ActivityTracker_LookupFailed})"; return $"? ({Strings.ActivityTracker_LookupFailed})";
} }
} }

View File

@ -194,6 +194,8 @@ namespace Bloxstrap.Integrations
App.Logger.WriteLine(LOG_IDENT, $"Setting presence for Place ID {placeId}"); App.Logger.WriteLine(LOG_IDENT, $"Setting presence for Place ID {placeId}");
// TODO: move this to its own function under the activity watcher?
// TODO: show error if information cannot be queried instead of silently failing
var universeIdResponse = await Http.GetJson<UniverseIdResponse>($"https://apis.roblox.com/universes/v1/places/{placeId}/universe"); var universeIdResponse = await Http.GetJson<UniverseIdResponse>($"https://apis.roblox.com/universes/v1/places/{placeId}/universe");
if (universeIdResponse is null) if (universeIdResponse is null)
{ {
@ -282,6 +284,7 @@ namespace Bloxstrap.Integrations
// this is used for configuration from BloxstrapRPC // this is used for configuration from BloxstrapRPC
_currentPresenceCopy = _currentPresence.Clone(); _currentPresenceCopy = _currentPresence.Clone();
// TODO: use queue for stashing messages
if (_stashedRPCMessage is not null) if (_stashedRPCMessage is not null)
{ {
App.Logger.WriteLine(LOG_IDENT, "Found stashed RPC message, invoking presence set command now"); App.Logger.WriteLine(LOG_IDENT, "Found stashed RPC message, invoking presence set command now");

View File

@ -1,11 +1,11 @@
using System.Windows; using System.Windows;
using Bloxstrap.UI.Elements.Dialogs;
using Microsoft.Win32; using Microsoft.Win32;
using Windows.Win32; using Windows.Win32;
using Windows.Win32.Foundation; using Windows.Win32.Foundation;
using Bloxstrap.UI.Elements.Dialogs;
namespace Bloxstrap namespace Bloxstrap
{ {
public static class LaunchHandler public static class LaunchHandler
@ -19,6 +19,7 @@ namespace Bloxstrap
break; break;
case NextAction.LaunchRoblox: case NextAction.LaunchRoblox:
App.LaunchSettings.RobloxLaunchMode = LaunchMode.Player;
LaunchRoblox(); LaunchRoblox();
break; break;
@ -120,6 +121,8 @@ namespace Bloxstrap
Installer.DoUninstall(keepData); Installer.DoUninstall(keepData);
Frontend.ShowMessageBox(Strings.Bootstrapper_SuccessfullyUninstalled, MessageBoxImage.Information); Frontend.ShowMessageBox(Strings.Bootstrapper_SuccessfullyUninstalled, MessageBoxImage.Information);
App.Terminate();
} }
public static void LaunchSettings() public static void LaunchSettings()
@ -131,7 +134,7 @@ namespace Bloxstrap
if (interlock.IsAcquired) if (interlock.IsAcquired)
{ {
bool showAlreadyRunningWarning = Process.GetProcessesByName(App.ProjectName).Length > 1; bool showAlreadyRunningWarning = Process.GetProcessesByName(App.ProjectName).Length > 1;
new UI.Elements.Settings.MainWindow(showAlreadyRunningWarning).ShowDialog(); new UI.Elements.Settings.MainWindow(showAlreadyRunningWarning).Show();
} }
else else
{ {
@ -156,7 +159,6 @@ namespace Bloxstrap
{ {
const string LOG_IDENT = "LaunchHandler::LaunchRoblox"; const string LOG_IDENT = "LaunchHandler::LaunchRoblox";
if (!File.Exists(Path.Combine(Paths.System, "mfplat.dll"))) if (!File.Exists(Path.Combine(Paths.System, "mfplat.dll")))
{ {
Frontend.ShowMessageBox(Strings.Bootstrapper_WMFNotFound, MessageBoxImage.Error); Frontend.ShowMessageBox(Strings.Bootstrapper_WMFNotFound, MessageBoxImage.Error);
@ -191,8 +193,6 @@ namespace Bloxstrap
} }
} }
App.NotifyIcon = new();
// start bootstrapper and show the bootstrapper modal if we're not running silently // start bootstrapper and show the bootstrapper modal if we're not running silently
App.Logger.WriteLine(LOG_IDENT, "Initializing bootstrapper"); App.Logger.WriteLine(LOG_IDENT, "Initializing bootstrapper");
var bootstrapper = new Bootstrapper(installWebView2); var bootstrapper = new Bootstrapper(installWebView2);
@ -206,45 +206,53 @@ namespace Bloxstrap
dialog.Bootstrapper = bootstrapper; dialog.Bootstrapper = bootstrapper;
} }
Task bootstrapperTask = Task.Run(async () => await bootstrapper.Run()).ContinueWith(t => Task.Run(bootstrapper.Run).ContinueWith(t =>
{ {
App.Logger.WriteLine(LOG_IDENT, "Bootstrapper task has finished"); App.Logger.WriteLine(LOG_IDENT, "Bootstrapper task has finished");
// notifyicon is blocking main thread, must be disposed here
App.NotifyIcon?.Dispose();
if (t.IsFaulted) if (t.IsFaulted)
{
App.Logger.WriteLine(LOG_IDENT, "An exception occurred when running the bootstrapper"); App.Logger.WriteLine(LOG_IDENT, "An exception occurred when running the bootstrapper");
if (t.Exception is null) if (t.Exception is not null)
return; App.FinalizeExceptionHandling(t.Exception, false);
}
App.Logger.WriteException(LOG_IDENT, t.Exception); App.Terminate();
Exception exception = t.Exception;
#if !DEBUG
if (t.Exception.GetType().ToString() == "System.AggregateException")
exception = t.Exception.InnerException!;
#endif
App.FinalizeExceptionHandling(exception, false);
}); });
// this ordering is very important as all wpf windows are shown as modal dialogs, mess it up and you'll end up blocking input to one of them
dialog?.ShowBootstrapper(); dialog?.ShowBootstrapper();
if (!App.LaunchSettings.NoLaunchFlag.Active && App.Settings.Prop.EnableActivityTracking)
App.NotifyIcon?.InitializeContextMenu();
App.Logger.WriteLine(LOG_IDENT, "Waiting for bootstrapper task to finish");
bootstrapperTask.Wait();
} }
public static void LaunchWatcher() public static void LaunchWatcher()
{ {
const string LOG_IDENT = "LaunchHandler::LaunchWatcher";
// this whole topology is a bit confusing, bear with me:
// main thread: strictly UI only, handles showing of the notification area icon, context menu, server details dialog
// - server information task: queries server location, invoked if either the explorer notification is shown or the server details dialog is opened
// - discord rpc thread: handles rpc connection with discord
// - discord rich presence tasks: handles querying and displaying of game information, invoked on activity watcher events
// - watcher task: runs activity watcher + waiting for roblox to close, terminates when it has
var watcher = new Watcher();
Task.Run(watcher.Run).ContinueWith(t =>
{
App.Logger.WriteLine(LOG_IDENT, "Watcher task has finished");
watcher.Dispose();
if (t.IsFaulted)
{
App.Logger.WriteLine(LOG_IDENT, "An exception occurred when running the watcher");
if (t.Exception is not null)
App.FinalizeExceptionHandling(t.Exception);
}
App.Terminate();
});
} }
} }
} }

View File

@ -28,7 +28,7 @@ namespace Bloxstrap
public LaunchFlag StudioFlag { get; } = new("studio"); public LaunchFlag StudioFlag { get; } = new("studio");
public LaunchMode RobloxLaunchMode { get; private set; } = LaunchMode.None; public LaunchMode RobloxLaunchMode { get; set; } = LaunchMode.None;
public string RobloxLaunchArgs { get; private set; } = ""; public string RobloxLaunchArgs { get; private set; } = "";

View File

@ -26,6 +26,10 @@
"Bloxstrap (Studio Launch)": { "Bloxstrap (Studio Launch)": {
"commandName": "Project", "commandName": "Project",
"commandLineArgs": "-studio" "commandLineArgs": "-studio"
},
"Bloxstrap (Watcher)": {
"commandName": "Project",
"commandLineArgs": "-watcher"
} }
} }
} }

View File

@ -61,7 +61,7 @@
</Grid> </Grid>
</MenuItem.Header> </MenuItem.Header>
</MenuItem> </MenuItem>
<MenuItem x:Name="CloseRobloxMenuItem" Visibility="Collapsed" Click="CloseRobloxMenuItem_Click"> <MenuItem Click="CloseRobloxMenuItem_Click">
<MenuItem.Header> <MenuItem.Header>
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
@ -73,7 +73,7 @@
</Grid> </Grid>
</MenuItem.Header> </MenuItem.Header>
</MenuItem> </MenuItem>
<MenuItem x:Name="LogTracerMenuItem" Click="LogTracerMenuItem_Click"> <MenuItem x:Name="LogTracerMenuItem" Visibility="Collapsed" Click="LogTracerMenuItem_Click">
<MenuItem.Header> <MenuItem.Header>
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>

View File

@ -22,32 +22,28 @@ namespace Bloxstrap.UI.Elements.ContextMenu
{ {
// i wouldve gladly done this as mvvm but turns out that data binding just does not work with menuitems for some reason so idk this sucks // i wouldve gladly done this as mvvm but turns out that data binding just does not work with menuitems for some reason so idk this sucks
private readonly ActivityWatcher? _activityWatcher; private readonly Watcher _watcher;
private readonly DiscordRichPresence? _richPresenceHandler;
private ActivityWatcher? _activityWatcher => _watcher.ActivityWatcher;
private ServerInformation? _serverInformationWindow; private ServerInformation? _serverInformationWindow;
private int? _processId;
public MenuContainer(ActivityWatcher? activityWatcher, DiscordRichPresence? richPresenceHandler, int? processId) public MenuContainer(Watcher watcher)
{ {
InitializeComponent(); InitializeComponent();
_activityWatcher = activityWatcher; _watcher = watcher;
_richPresenceHandler = richPresenceHandler;
_processId = processId;
if (_activityWatcher is not null) if (_activityWatcher is not null)
{ {
_activityWatcher.OnLogOpen += ActivityWatcher_OnLogOpen;
_activityWatcher.OnGameJoin += ActivityWatcher_OnGameJoin; _activityWatcher.OnGameJoin += ActivityWatcher_OnGameJoin;
_activityWatcher.OnGameLeave += ActivityWatcher_OnGameLeave; _activityWatcher.OnGameLeave += ActivityWatcher_OnGameLeave;
} }
if (_richPresenceHandler is not null) if (_watcher.RichPresence is not null)
RichPresenceMenuItem.Visibility = Visibility.Visible; RichPresenceMenuItem.Visibility = Visibility.Visible;
if (_processId is not null)
CloseRobloxMenuItem.Visibility = Visibility.Visible;
VersionTextBlock.Text = $"{App.ProjectName} v{App.Version}"; VersionTextBlock.Text = $"{App.ProjectName} v{App.Version}";
} }
@ -55,7 +51,7 @@ namespace Bloxstrap.UI.Elements.ContextMenu
{ {
if (_serverInformationWindow is null) if (_serverInformationWindow is null)
{ {
_serverInformationWindow = new ServerInformation(_activityWatcher!); _serverInformationWindow = new ServerInformation(_watcher);
_serverInformationWindow.Closed += (_, _) => _serverInformationWindow = null; _serverInformationWindow.Closed += (_, _) => _serverInformationWindow = null;
} }
@ -65,17 +61,23 @@ namespace Bloxstrap.UI.Elements.ContextMenu
_serverInformationWindow.Activate(); _serverInformationWindow.Activate();
} }
private void ActivityWatcher_OnGameJoin(object? sender, EventArgs e) public void ActivityWatcher_OnLogOpen(object? sender, EventArgs e) =>
Dispatcher.Invoke(() => LogTracerMenuItem.Visibility = Visibility.Visible);
public void ActivityWatcher_OnGameJoin(object? sender, EventArgs e)
{ {
if (_activityWatcher is null)
return;
Dispatcher.Invoke(() => { Dispatcher.Invoke(() => {
if (_activityWatcher?.ActivityServerType == ServerType.Public) if (_activityWatcher.ActivityServerType == ServerType.Public)
InviteDeeplinkMenuItem.Visibility = Visibility.Visible; InviteDeeplinkMenuItem.Visibility = Visibility.Visible;
ServerDetailsMenuItem.Visibility = Visibility.Visible; ServerDetailsMenuItem.Visibility = Visibility.Visible;
}); });
} }
private void ActivityWatcher_OnGameLeave(object? sender, EventArgs e) public void ActivityWatcher_OnGameLeave(object? sender, EventArgs e)
{ {
Dispatcher.Invoke(() => { Dispatcher.Invoke(() => {
InviteDeeplinkMenuItem.Visibility = Visibility.Collapsed; InviteDeeplinkMenuItem.Visibility = Visibility.Collapsed;
@ -100,7 +102,7 @@ namespace Bloxstrap.UI.Elements.ContextMenu
private void Window_Closed(object sender, EventArgs e) => App.Logger.WriteLine("MenuContainer::Window_Closed", "Context menu container closed"); private void Window_Closed(object sender, EventArgs e) => App.Logger.WriteLine("MenuContainer::Window_Closed", "Context menu container closed");
private void RichPresenceMenuItem_Click(object sender, RoutedEventArgs e) => _richPresenceHandler?.SetVisibility(((MenuItem)sender).IsChecked); private void RichPresenceMenuItem_Click(object sender, RoutedEventArgs e) => _watcher.RichPresence?.SetVisibility(((MenuItem)sender).IsChecked);
private void InviteDeeplinkMenuItem_Click(object sender, RoutedEventArgs e) => Clipboard.SetDataObject($"roblox://experiences/start?placeId={_activityWatcher?.ActivityPlaceId}&gameInstanceId={_activityWatcher?.ActivityJobId}"); private void InviteDeeplinkMenuItem_Click(object sender, RoutedEventArgs e) => Clipboard.SetDataObject($"roblox://experiences/start?placeId={_activityWatcher?.ActivityPlaceId}&gameInstanceId={_activityWatcher?.ActivityJobId}");
@ -110,12 +112,7 @@ namespace Bloxstrap.UI.Elements.ContextMenu
{ {
string? location = _activityWatcher?.LogLocation; string? location = _activityWatcher?.LogLocation;
if (location is null) if (location is not null)
{
Frontend.ShowMessageBox(Strings.ContextMenu_RobloxNotRunning, MessageBoxImage.Information);
return;
}
Utilities.ShellExecute(location); Utilities.ShellExecute(location);
} }
@ -130,9 +127,7 @@ namespace Bloxstrap.UI.Elements.ContextMenu
if (result != MessageBoxResult.Yes) if (result != MessageBoxResult.Yes)
return; return;
using Process process = Process.GetProcessById((int)_processId!); _watcher.KillRobloxProcess();
process.Kill();
process.Close();
} }
} }
} }

View File

@ -22,9 +22,13 @@ namespace Bloxstrap.UI.Elements.ContextMenu
/// </summary> /// </summary>
public partial class ServerInformation public partial class ServerInformation
{ {
public ServerInformation(ActivityWatcher activityWatcher) public ServerInformation(Watcher watcher)
{ {
DataContext = new ServerInformationViewModel(this, activityWatcher); var viewModel = new ServerInformationViewModel(watcher);
viewModel.RequestCloseEvent += (_, _) => Close();
DataContext = viewModel;
InitializeComponent(); InitializeComponent();
} }
} }

View File

@ -94,7 +94,7 @@
<ui:Button Content="{x:Static resources:Strings.Menu_Save}" Appearance="Primary" Command="{Binding SaveSettingsCommand, Mode=OneWay}" /> <ui:Button Content="{x:Static resources:Strings.Menu_Save}" Appearance="Primary" Command="{Binding SaveSettingsCommand, Mode=OneWay}" />
</StatusBarItem> </StatusBarItem>
<StatusBarItem Grid.Column="2" Padding="4,0,0,0"> <StatusBarItem Grid.Column="2" Padding="4,0,0,0">
<ui:Button Content="{x:Static resources:Strings.Common_Close}" IsCancel="True" /> <ui:Button Content="{x:Static resources:Strings.Common_Close}" Command="{Binding CloseWindowCommand, Mode=OneWay}" />
</StatusBarItem> </StatusBarItem>
</StatusBar> </StatusBar>
</Grid> </Grid>

View File

@ -17,7 +17,9 @@ namespace Bloxstrap.UI.Elements.Settings
public MainWindow(bool showAlreadyRunningWarning) public MainWindow(bool showAlreadyRunningWarning)
{ {
var viewModel = new MainWindowViewModel(); var viewModel = new MainWindowViewModel();
viewModel.RequestSaveNoticeEvent += (_, _) => SettingsSavedSnackbar.Show(); viewModel.RequestSaveNoticeEvent += (_, _) => SettingsSavedSnackbar.Show();
viewModel.RequestCloseWindowEvent += (_, _) => Close();
DataContext = viewModel; DataContext = viewModel;
@ -64,6 +66,9 @@ namespace Bloxstrap.UI.Elements.Settings
if (result != MessageBoxResult.Yes) if (result != MessageBoxResult.Yes)
e.Cancel = true; e.Cancel = true;
} }
if (!e.Cancel)
App.Terminate();
} }
} }
} }

View File

@ -1,4 +1,5 @@
using Bloxstrap.Integrations; using Bloxstrap.Integrations;
using Bloxstrap.UI.Elements.About;
using Bloxstrap.UI.Elements.ContextMenu; using Bloxstrap.UI.Elements.ContextMenu;
namespace Bloxstrap.UI namespace Bloxstrap.UI
@ -10,18 +11,21 @@ namespace Bloxstrap.UI
private bool _disposing = false; private bool _disposing = false;
private readonly System.Windows.Forms.NotifyIcon _notifyIcon; private readonly System.Windows.Forms.NotifyIcon _notifyIcon;
private MenuContainer? _menuContainer;
private ActivityWatcher? _activityWatcher; private readonly MenuContainer _menuContainer;
private DiscordRichPresence? _richPresenceHandler;
private int? _processId; private readonly Watcher _watcher;
private ActivityWatcher? _activityWatcher => _watcher.ActivityWatcher;
EventHandler? _alertClickHandler; EventHandler? _alertClickHandler;
public NotifyIconWrapper() public NotifyIconWrapper(Watcher watcher)
{ {
App.Logger.WriteLine("NotifyIconWrapper::NotifyIconWrapper", "Initializing notification area icon"); App.Logger.WriteLine("NotifyIconWrapper::NotifyIconWrapper", "Initializing notification area icon");
_watcher = watcher;
_notifyIcon = new() _notifyIcon = new()
{ {
Icon = Properties.Resources.IconBloxstrap, Icon = Properties.Resources.IconBloxstrap,
@ -30,52 +34,18 @@ namespace Bloxstrap.UI
}; };
_notifyIcon.MouseClick += MouseClickEventHandler; _notifyIcon.MouseClick += MouseClickEventHandler;
}
#region Handler registers
public void SetRichPresenceHandler(DiscordRichPresence richPresenceHandler)
{
if (_richPresenceHandler is not null)
return;
_richPresenceHandler = richPresenceHandler;
}
public void SetActivityWatcher(ActivityWatcher activityWatcher)
{
if (_activityWatcher is not null) if (_activityWatcher is not null)
return; _activityWatcher.OnGameJoin += OnGameJoin;
_activityWatcher = activityWatcher; _menuContainer = new(_watcher);
_menuContainer.Show();
if (App.Settings.Prop.ShowServerDetails)
_activityWatcher.OnGameJoin += (_, _) => Task.Run(OnGameJoin);
} }
public void SetProcessId(int processId)
{
if (_processId is not null)
return;
_processId = processId;
}
#endregion
#region Context menu #region Context menu
public void InitializeContextMenu()
{
if (_menuContainer is not null || _disposing)
return;
App.Logger.WriteLine("NotifyIconWrapper::InitializeContextMenu", "Initializing context menu");
_menuContainer = new(_activityWatcher, _richPresenceHandler, _processId);
_menuContainer.ShowDialog();
}
public void MouseClickEventHandler(object? sender, System.Windows.Forms.MouseEventArgs e) public void MouseClickEventHandler(object? sender, System.Windows.Forms.MouseEventArgs e)
{ {
if (e.Button != System.Windows.Forms.MouseButtons.Right || _menuContainer is null) if (e.Button != System.Windows.Forms.MouseButtons.Right)
return; return;
_menuContainer.Activate(); _menuContainer.Activate();
@ -84,9 +54,12 @@ namespace Bloxstrap.UI
#endregion #endregion
#region Activity handlers #region Activity handlers
public async void OnGameJoin() public async void OnGameJoin(object? sender, EventArgs e)
{ {
string serverLocation = await _activityWatcher!.GetServerLocation(); if (_activityWatcher is null)
return;
string serverLocation = await _activityWatcher.GetServerLocation();
string title = _activityWatcher.ActivityServerType switch string title = _activityWatcher.ActivityServerType switch
{ {
ServerType.Public => Strings.ContextMenu_ServerInformation_Notification_Title_Public, ServerType.Public => Strings.ContextMenu_ServerInformation_Notification_Title_Public,
@ -99,7 +72,7 @@ namespace Bloxstrap.UI
title, title,
String.Format(Strings.ContextMenu_ServerInformation_Notification_Text, serverLocation), String.Format(Strings.ContextMenu_ServerInformation_Notification_Text, serverLocation),
10, 10,
(_, _) => _menuContainer?.ShowServerInformationWindow() (_, _) => _menuContainer.ShowServerInformationWindow()
); );
} }
#endregion #endregion
@ -151,9 +124,8 @@ namespace Bloxstrap.UI
App.Logger.WriteLine("NotifyIconWrapper::Dispose", "Disposing NotifyIcon"); App.Logger.WriteLine("NotifyIconWrapper::Dispose", "Disposing NotifyIcon");
_menuContainer?.Dispatcher.Invoke(_menuContainer.Close); _menuContainer.Dispatcher.Invoke(_menuContainer.Close);
_notifyIcon?.Dispose(); _notifyIcon.Dispose();
GC.SuppressFinalize(this); GC.SuppressFinalize(this);
} }

View File

@ -7,20 +7,23 @@ namespace Bloxstrap.UI.ViewModels.ContextMenu
{ {
internal class ServerInformationViewModel : NotifyPropertyChangedViewModel internal class ServerInformationViewModel : NotifyPropertyChangedViewModel
{ {
private readonly Window _window;
private readonly ActivityWatcher _activityWatcher; private readonly ActivityWatcher _activityWatcher;
public string InstanceId => _activityWatcher.ActivityJobId; public string InstanceId => _activityWatcher.ActivityJobId;
public string ServerType => Resources.Strings.ResourceManager.GetStringSafe($"Enums.ServerType.{_activityWatcher.ActivityServerType}");
public string ServerLocation { get; private set; } = Resources.Strings.ContextMenu_ServerInformation_Loading; public string ServerType => Strings.ResourceManager.GetStringSafe($"Enums.ServerType.{_activityWatcher.ActivityServerType}");
public string ServerLocation { get; private set; } = Strings.ContextMenu_ServerInformation_Loading;
public ICommand CopyInstanceIdCommand => new RelayCommand(CopyInstanceId); public ICommand CopyInstanceIdCommand => new RelayCommand(CopyInstanceId);
public ICommand CloseWindowCommand => new RelayCommand(_window.Close);
public ServerInformationViewModel(Window window, ActivityWatcher activityWatcher) public ICommand CloseWindowCommand => new RelayCommand(RequestClose);
public EventHandler? RequestCloseEvent;
public ServerInformationViewModel(Watcher watcher)
{ {
_window = window; _activityWatcher = watcher.ActivityWatcher!;
_activityWatcher = activityWatcher;
Task.Run(async () => Task.Run(async () =>
{ {
@ -30,5 +33,7 @@ namespace Bloxstrap.UI.ViewModels.ContextMenu
} }
private void CopyInstanceId() => Clipboard.SetDataObject(InstanceId); private void CopyInstanceId() => Clipboard.SetDataObject(InstanceId);
private void RequestClose() => RequestCloseEvent?.Invoke(this, EventArgs.Empty);
} }
} }

View File

@ -22,6 +22,6 @@ namespace Bloxstrap.UI.ViewModels.Installer
private void LaunchRoblox() => CloseWindowRequest?.Invoke(this, NextAction.LaunchRoblox); private void LaunchRoblox() => CloseWindowRequest?.Invoke(this, NextAction.LaunchRoblox);
private void LaunchAbout() => new MainWindow().Show(); private void LaunchAbout() => new MainWindow().ShowDialog();
} }
} }

View File

@ -10,10 +10,16 @@ namespace Bloxstrap.UI.ViewModels.Settings
public ICommand SaveSettingsCommand => new RelayCommand(SaveSettings); public ICommand SaveSettingsCommand => new RelayCommand(SaveSettings);
public ICommand CloseWindowCommand => new RelayCommand(CloseWindow);
public EventHandler? RequestSaveNoticeEvent; public EventHandler? RequestSaveNoticeEvent;
public EventHandler? RequestCloseWindowEvent;
private void OpenAbout() => new MainWindow().ShowDialog(); private void OpenAbout() => new MainWindow().ShowDialog();
private void CloseWindow() => RequestCloseWindowEvent?.Invoke(this, EventArgs.Empty);
private void SaveSettings() private void SaveSettings()
{ {
const string LOG_IDENT = "MainWindowViewModel::SaveSettings"; const string LOG_IDENT = "MainWindowViewModel::SaveSettings";
@ -35,7 +41,7 @@ namespace Bloxstrap.UI.ViewModels.Settings
App.PendingSettingTasks.Clear(); App.PendingSettingTasks.Clear();
RequestSaveNoticeEvent?.Invoke(this, new EventArgs()); RequestSaveNoticeEvent?.Invoke(this, EventArgs.Empty);
} }
} }
} }

147
Bloxstrap/Watcher.cs Normal file
View File

@ -0,0 +1,147 @@
using Bloxstrap.Integrations;
using System.CodeDom;
using System.Security.Permissions;
namespace Bloxstrap
{
public class Watcher : IDisposable
{
private int _gameClientPid = 0;
private readonly InterProcessLock _lock = new("Watcher");
private readonly List<int> _autoclosePids = new();
private readonly NotifyIconWrapper? _notifyIcon;
public readonly ActivityWatcher? ActivityWatcher;
public readonly DiscordRichPresence? RichPresence;
public Watcher()
{
const string LOG_IDENT = "Watcher";
if (!_lock.IsAcquired)
{
App.Logger.WriteLine(LOG_IDENT, "Watcher instance already exists");
return;
}
string? watcherData = App.LaunchSettings.WatcherFlag.Data;
#if DEBUG
if (String.IsNullOrEmpty(watcherData))
{
string path = Path.Combine(Paths.Versions, App.State.Prop.PlayerVersionGuid, "RobloxPlayerBeta.exe");
using var gameClientProcess = Process.Start(path);
_gameClientPid = gameClientProcess.Id;
}
#else
if (String.IsNullOrEmpty(watcherData))
throw new Exception("Watcher data not specified");
#endif
if (!String.IsNullOrEmpty(watcherData) && _gameClientPid == 0)
{
var split = watcherData.Split(';');
if (split.Length == 0)
_ = int.TryParse(watcherData, out _gameClientPid);
if (split.Length >= 1)
_ = int.TryParse(split[0], out _gameClientPid);
if (split.Length >= 2)
{
foreach (string strPid in split[0].Split(';'))
{
if (int.TryParse(strPid, out int pid) && pid != 0)
_autoclosePids.Add(pid);
}
}
}
if (_gameClientPid == 0)
throw new Exception("Watcher data is invalid");
if (App.Settings.Prop.EnableActivityTracking)
{
ActivityWatcher = new();
if (App.Settings.Prop.UseDisableAppPatch)
{
ActivityWatcher.OnAppClose += (_, _) =>
{
App.Logger.WriteLine(LOG_IDENT, "Received desktop app exit, closing Roblox");
using var process = Process.GetProcessById(_gameClientPid);
process.CloseMainWindow();
};
}
if (App.Settings.Prop.UseDiscordRichPresence)
RichPresence = new(ActivityWatcher);
}
_notifyIcon = new(this);
}
public void KillRobloxProcess() => KillProcess(_gameClientPid);
public void KillProcess(int pid)
{
using var process = Process.GetProcessById(pid);
App.Logger.WriteLine("Watcher::KillProcess", $"Killing process '{process.ProcessName}' (PID {process.Id})");
if (process.HasExited)
{
App.Logger.WriteLine("Watcher::KillProcess", $"PID {process.Id} has already exited");
return;
}
process.Kill();
process.Close();
}
public void CloseProcess(int pid)
{
using var process = Process.GetProcessById(pid);
App.Logger.WriteLine("Watcher::CloseProcess", $"Closing process '{process.ProcessName}' (PID {process.Id})");
if (process.HasExited)
{
App.Logger.WriteLine("Watcher::CloseProcess", $"PID {process.Id} has already exited");
return;
}
process.CloseMainWindow();
process.Close();
}
public async Task Run()
{
if (!_lock.IsAcquired)
return;
ActivityWatcher?.Start();
while (Utilities.GetProcessesSafe().Any(x => x.Id == _gameClientPid))
await Task.Delay(1000);
foreach (int pid in _autoclosePids)
CloseProcess(pid);
}
public void Dispose()
{
App.Logger.WriteLine("Watcher::Dispose", "Disposing Watcher");
_notifyIcon?.Dispose();
RichPresence?.Dispose();
GC.SuppressFinalize(this);
}
}
}