From 6aed88e67f62985c05afa021527aeb4e6886940f Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sat, 7 Feb 2026 13:12:16 +0800 Subject: [PATCH 1/5] fix(platform): dispatch BigBox PlayGame calls to WPF UI thread (#509) LaunchGameById is called from a background thread (named pipe reader), but BigBoxMainViewModel.PlayGame requires the WPF UI thread. Capture the Dispatcher during plugin init and use Invoke() to marshal PlayGame calls onto it. Closes #509 --- .../ZaparooLaunchBoxPlugin.csproj | 6 +-- .../windows/launchbox-plugin/ZaparooPlugin.cs | 48 +++++++++++++------ 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/scripts/windows/launchbox-plugin/ZaparooLaunchBoxPlugin.csproj b/scripts/windows/launchbox-plugin/ZaparooLaunchBoxPlugin.csproj index 24d45dfe5..0aa7b9c41 100644 --- a/scripts/windows/launchbox-plugin/ZaparooLaunchBoxPlugin.csproj +++ b/scripts/windows/launchbox-plugin/ZaparooLaunchBoxPlugin.csproj @@ -11,9 +11,9 @@ bin\$(Configuration)\ - 1.0.0 - 1.0.0 - 1.0.0 + 1.1.0 + 1.1.0 + 1.1.0 Zaparoo LaunchBox Plugin diff --git a/scripts/windows/launchbox-plugin/ZaparooPlugin.cs b/scripts/windows/launchbox-plugin/ZaparooPlugin.cs index 4b76bfe7b..aafbe818b 100644 --- a/scripts/windows/launchbox-plugin/ZaparooPlugin.cs +++ b/scripts/windows/launchbox-plugin/ZaparooPlugin.cs @@ -45,6 +45,7 @@ public class ZaparooPlugin : ISystemEventsPlugin, IGameLaunchingPlugin, IGameMen private static readonly object _pipeLock = new(); private static bool _isShuttingDown; private static bool _isBigBoxRunning; + private static System.Windows.Threading.Dispatcher? _uiDispatcher; // Instance-specific state private IGame? _currentGame; @@ -69,14 +70,20 @@ public ZaparooPlugin() public void OnEventRaised(string eventType) { - if (eventType == SystemEventTypes.LaunchBoxStartupCompleted) + if (eventType == SystemEventTypes.PluginInitialized) { + _uiDispatcher ??= System.Windows.Threading.Dispatcher.CurrentDispatcher; + } + else if (eventType == SystemEventTypes.LaunchBoxStartupCompleted) + { + _uiDispatcher ??= System.Windows.Threading.Dispatcher.CurrentDispatcher; _isBigBoxRunning = false; _isShuttingDown = false; StartConnectionTask(); } else if (eventType == SystemEventTypes.BigBoxStartupCompleted) { + _uiDispatcher ??= System.Windows.Threading.Dispatcher.CurrentDispatcher; _isBigBoxRunning = true; _isShuttingDown = false; StartConnectionTask(); @@ -453,14 +460,13 @@ private void LaunchGameById(string gameId) var game = PluginHelper.DataManager.GetGameById(gameId); if (game != null) { - if (_isBigBoxRunning) + InvokeOnUiThread(() => { - PluginHelper.BigBoxMainViewModel.PlayGame(game, null, null, null); - } - else - { - PluginHelper.LaunchBoxMainViewModel.PlayGame(game, null, null, null); - } + if (_isBigBoxRunning) + PluginHelper.BigBoxMainViewModel?.PlayGame(game, null, null, null); + else + PluginHelper.LaunchBoxMainViewModel?.PlayGame(game, null, null, null); + }); return; } @@ -468,14 +474,13 @@ private void LaunchGameById(string gameId) var (parentGame, additionalApp) = FindAdditionalApplicationById(gameId); if (additionalApp != null && parentGame != null) { - if (_isBigBoxRunning) - { - PluginHelper.BigBoxMainViewModel.PlayGame(parentGame, additionalApp, null, null); - } - else + InvokeOnUiThread(() => { - PluginHelper.LaunchBoxMainViewModel.PlayGame(parentGame, additionalApp, null, null); - } + if (_isBigBoxRunning) + PluginHelper.BigBoxMainViewModel?.PlayGame(parentGame, additionalApp, null, null); + else + PluginHelper.LaunchBoxMainViewModel?.PlayGame(parentGame, additionalApp, null, null); + }); return; } @@ -487,6 +492,19 @@ private void LaunchGameById(string gameId) } } + private static void InvokeOnUiThread(Action action) + { + var dispatcher = _uiDispatcher ?? System.Windows.Application.Current?.Dispatcher; + if (dispatcher != null && !dispatcher.CheckAccess()) + { + dispatcher.Invoke(action); + } + else + { + action(); + } + } + private (IGame?, IAdditionalApplication?) FindAdditionalApplicationById(string id) { foreach (var game in PluginHelper.DataManager.GetAllGames()) From 5b7042b10dd72aa1ebefd0640f11d897cec01caf Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sun, 8 Feb 2026 17:26:39 +0800 Subject: [PATCH 2/5] fix(platform): call ShowGame before PlayGame for LEDBlinky support ShowGame navigates BigBox UI to the NFC-scanned game, firing LEDBlinky Event 9 (game selection). Without this, LEDBlinky shows controls for whichever game the user had highlighted in the BigBox menu rather than the game actually being launched. --- scripts/windows/launchbox-plugin/ZaparooPlugin.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/scripts/windows/launchbox-plugin/ZaparooPlugin.cs b/scripts/windows/launchbox-plugin/ZaparooPlugin.cs index aafbe818b..318a4edf6 100644 --- a/scripts/windows/launchbox-plugin/ZaparooPlugin.cs +++ b/scripts/windows/launchbox-plugin/ZaparooPlugin.cs @@ -463,9 +463,17 @@ private void LaunchGameById(string gameId) InvokeOnUiThread(() => { if (_isBigBoxRunning) + { + // ShowGame navigates BigBox UI to the game, which fires + // LEDBlinky Event 9 (game selection). Without this, LEDBlinky + // shows controls for whichever game was highlighted in the menu. + PluginHelper.BigBoxMainViewModel?.ShowGame(game, FilterType.None); PluginHelper.BigBoxMainViewModel?.PlayGame(game, null, null, null); + } else + { PluginHelper.LaunchBoxMainViewModel?.PlayGame(game, null, null, null); + } }); return; } @@ -477,9 +485,14 @@ private void LaunchGameById(string gameId) InvokeOnUiThread(() => { if (_isBigBoxRunning) + { + PluginHelper.BigBoxMainViewModel?.ShowGame(parentGame, FilterType.None); PluginHelper.BigBoxMainViewModel?.PlayGame(parentGame, additionalApp, null, null); + } else + { PluginHelper.LaunchBoxMainViewModel?.PlayGame(parentGame, additionalApp, null, null); + } }); return; } From 0d7a7d5fd9b507d3f4428dbbd10934551aca2ded Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Fri, 24 Apr 2026 09:51:53 +0800 Subject: [PATCH 3/5] fix(platform): restore BigBox view after plugin launches --- .../ZaparooLaunchBoxPlugin.csproj | 6 +- .../windows/launchbox-plugin/ZaparooPlugin.cs | 92 ++++++++++++++++++- 2 files changed, 91 insertions(+), 7 deletions(-) diff --git a/scripts/windows/launchbox-plugin/ZaparooLaunchBoxPlugin.csproj b/scripts/windows/launchbox-plugin/ZaparooLaunchBoxPlugin.csproj index 0aa7b9c41..25044e095 100644 --- a/scripts/windows/launchbox-plugin/ZaparooLaunchBoxPlugin.csproj +++ b/scripts/windows/launchbox-plugin/ZaparooLaunchBoxPlugin.csproj @@ -11,9 +11,9 @@ bin\$(Configuration)\ - 1.1.0 - 1.1.0 - 1.1.0 + 1.1.1 + 1.1.1 + 1.1.1 Zaparoo LaunchBox Plugin diff --git a/scripts/windows/launchbox-plugin/ZaparooPlugin.cs b/scripts/windows/launchbox-plugin/ZaparooPlugin.cs index 318a4edf6..433bd88ce 100644 --- a/scripts/windows/launchbox-plugin/ZaparooPlugin.cs +++ b/scripts/windows/launchbox-plugin/ZaparooPlugin.cs @@ -46,6 +46,11 @@ public class ZaparooPlugin : ISystemEventsPlugin, IGameLaunchingPlugin, IGameMen private static bool _isShuttingDown; private static bool _isBigBoxRunning; private static System.Windows.Threading.Dispatcher? _uiDispatcher; + private static bool _hasPendingBigBoxRestore; + private static bool _shouldRestoreBigBoxView; + private static IPlatform? _previousBigBoxPlatform; + private static string? _pendingRestoreGameId; + private static string? _pendingRestoreAdditionalAppId; // Instance-specific state private IGame? _currentGame; @@ -72,18 +77,18 @@ public void OnEventRaised(string eventType) { if (eventType == SystemEventTypes.PluginInitialized) { - _uiDispatcher ??= System.Windows.Threading.Dispatcher.CurrentDispatcher; + _uiDispatcher ??= System.Windows.Application.Current?.Dispatcher; } else if (eventType == SystemEventTypes.LaunchBoxStartupCompleted) { - _uiDispatcher ??= System.Windows.Threading.Dispatcher.CurrentDispatcher; + _uiDispatcher ??= System.Windows.Application.Current?.Dispatcher; _isBigBoxRunning = false; _isShuttingDown = false; StartConnectionTask(); } else if (eventType == SystemEventTypes.BigBoxStartupCompleted) { - _uiDispatcher ??= System.Windows.Threading.Dispatcher.CurrentDispatcher; + _uiDispatcher ??= System.Windows.Application.Current?.Dispatcher; _isBigBoxRunning = true; _isShuttingDown = false; StartConnectionTask(); @@ -92,6 +97,9 @@ public void OnEventRaised(string eventType) eventType == SystemEventTypes.BigBoxShutdownBeginning) { _isShuttingDown = true; + ClearPendingBigBoxRestore(); + _shouldRestoreBigBoxView = false; + _previousBigBoxPlatform = null; DisconnectFromPipe(); } } @@ -104,6 +112,16 @@ public void OnBeforeGameLaunching(IGame game, IAdditionalApplication? app, IEmul { // Track the game that's about to launch _currentGame = game; + + if (MatchesPendingBigBoxRestore(game, app)) + { + _shouldRestoreBigBoxView = true; + ClearPendingBigBoxRestore(); + } + else + { + ClearPendingBigBoxRestore(); + } } public void OnAfterGameLaunched(IGame game, IAdditionalApplication? app, IEmulator? emulator) @@ -132,6 +150,8 @@ public void OnGameExited() }); _currentGame = null; } + + RestoreBigBoxView(); } #endregion @@ -464,6 +484,8 @@ private void LaunchGameById(string gameId) { if (_isBigBoxRunning) { + CaptureBigBoxLaunchContext(game.Id, null); + // ShowGame navigates BigBox UI to the game, which fires // LEDBlinky Event 9 (game selection). Without this, LEDBlinky // shows controls for whichever game was highlighted in the menu. @@ -486,6 +508,7 @@ private void LaunchGameById(string gameId) { if (_isBigBoxRunning) { + CaptureBigBoxLaunchContext(parentGame.Id, additionalApp.Id); PluginHelper.BigBoxMainViewModel?.ShowGame(parentGame, FilterType.None); PluginHelper.BigBoxMainViewModel?.PlayGame(parentGame, additionalApp, null, null); } @@ -507,7 +530,9 @@ private void LaunchGameById(string gameId) private static void InvokeOnUiThread(Action action) { - var dispatcher = _uiDispatcher ?? System.Windows.Application.Current?.Dispatcher; + _uiDispatcher ??= System.Windows.Application.Current?.Dispatcher; + + var dispatcher = _uiDispatcher; if (dispatcher != null && !dispatcher.CheckAccess()) { dispatcher.Invoke(action); @@ -518,6 +543,65 @@ private static void InvokeOnUiThread(Action action) } } + private static void CaptureBigBoxLaunchContext(string gameId, string? additionalAppId) + { + _previousBigBoxPlatform = PluginHelper.StateManager?.GetSelectedPlatform(); + _pendingRestoreGameId = gameId; + _pendingRestoreAdditionalAppId = additionalAppId; + _hasPendingBigBoxRestore = true; + _shouldRestoreBigBoxView = false; + } + + private static bool MatchesPendingBigBoxRestore(IGame game, IAdditionalApplication? app) + { + if (!_hasPendingBigBoxRestore || _pendingRestoreGameId != game.Id) + { + return false; + } + + return _pendingRestoreAdditionalAppId == app?.Id; + } + + private static void ClearPendingBigBoxRestore() + { + _hasPendingBigBoxRestore = false; + _pendingRestoreGameId = null; + _pendingRestoreAdditionalAppId = null; + } + + private static void RestoreBigBoxView() + { + if (!_shouldRestoreBigBoxView || !_isBigBoxRunning) + { + return; + } + + InvokeOnUiThread(() => + { + try + { + if (_previousBigBoxPlatform != null) + { + PluginHelper.BigBoxMainViewModel?.ShowGames(_previousBigBoxPlatform); + } + else + { + PluginHelper.BigBoxMainViewModel?.ShowPlatforms(); + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Zaparoo plugin: Failed to restore BigBox view: {ex.Message}"); + } + finally + { + ClearPendingBigBoxRestore(); + _previousBigBoxPlatform = null; + _shouldRestoreBigBoxView = false; + } + }); + } + private (IGame?, IAdditionalApplication?) FindAdditionalApplicationById(string id) { foreach (var game in PluginHelper.DataManager.GetAllGames()) From 51d611f531a440a43850692a394337ef3605379a Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sat, 25 Apr 2026 16:01:10 +0800 Subject: [PATCH 4/5] fix(platform): improve BigBox plugin launch handling --- scripts/windows/launchbox-plugin/Dockerfile | 2 +- .../ZaparooLaunchBoxPlugin.csproj | 6 +- .../windows/launchbox-plugin/ZaparooPlugin.cs | 797 +++++++++++++++--- 3 files changed, 695 insertions(+), 110 deletions(-) diff --git a/scripts/windows/launchbox-plugin/Dockerfile b/scripts/windows/launchbox-plugin/Dockerfile index 1d4e5d33b..ef2a975ca 100644 --- a/scripts/windows/launchbox-plugin/Dockerfile +++ b/scripts/windows/launchbox-plugin/Dockerfile @@ -17,4 +17,4 @@ RUN dotnet restore COPY . . # Publish the plugin (creates complete artifact) -RUN dotnet publish -c Release -o /build --no-restore +RUN dotnet publish -c Release -o /build diff --git a/scripts/windows/launchbox-plugin/ZaparooLaunchBoxPlugin.csproj b/scripts/windows/launchbox-plugin/ZaparooLaunchBoxPlugin.csproj index 25044e095..650f957b6 100644 --- a/scripts/windows/launchbox-plugin/ZaparooLaunchBoxPlugin.csproj +++ b/scripts/windows/launchbox-plugin/ZaparooLaunchBoxPlugin.csproj @@ -11,9 +11,9 @@ bin\$(Configuration)\ - 1.1.1 - 1.1.1 - 1.1.1 + 1.2.0 + 1.2.0 + 1.2.0 Zaparoo LaunchBox Plugin diff --git a/scripts/windows/launchbox-plugin/ZaparooPlugin.cs b/scripts/windows/launchbox-plugin/ZaparooPlugin.cs index 433bd88ce..dfcb8cf8d 100644 --- a/scripts/windows/launchbox-plugin/ZaparooPlugin.cs +++ b/scripts/windows/launchbox-plugin/ZaparooPlugin.cs @@ -20,9 +20,14 @@ using System.IO; using System.IO.Pipes; using System.Reflection; +using System.Runtime.InteropServices; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using System.Windows.Input; +using System.Windows.Threading; +using System.Xml.Linq; +using System.Xml.XPath; using Unbroken.LaunchBox.Plugins; using Unbroken.LaunchBox.Plugins.Data; @@ -35,6 +40,8 @@ namespace ZaparooLaunchBoxPlugin; public class ZaparooPlugin : ISystemEventsPlugin, IGameLaunchingPlugin, IGameMenuItemPlugin { private const string PipeName = "zaparoo-launchbox-ipc"; + private const uint WinEventOutOfContext = 0; + private const uint EventSystemForeground = 3; // Static connection state - shared across all plugin instances private static NamedPipeClientStream? _pipeClient; @@ -43,23 +50,61 @@ public class ZaparooPlugin : ISystemEventsPlugin, IGameLaunchingPlugin, IGameMen private static CancellationTokenSource? _cancellationTokenSource; private static Task? _connectionTask; private static readonly object _pipeLock = new(); + private static readonly object _stateLock = new(); private static bool _isShuttingDown; private static bool _isBigBoxRunning; - private static System.Windows.Threading.Dispatcher? _uiDispatcher; - private static bool _hasPendingBigBoxRestore; - private static bool _shouldRestoreBigBoxView; - private static IPlatform? _previousBigBoxPlatform; - private static string? _pendingRestoreGameId; - private static string? _pendingRestoreAdditionalAppId; - - // Instance-specific state - private IGame? _currentGame; + private static Dispatcher? _uiDispatcher; + private static string _launchBoxRoot = string.Empty; + private static IGame? _currentGame; + private static IAdditionalApplication? _currentAdditionalApp; + private static string? _pendingLaunchGameId; + private static string? _pendingLaunchAdditionalAppId; + private static bool _pendingLaunchShouldNavigate; + private static bool _shouldNavigateBackAfterLaunch; + private static bool _shouldShowGameAfterFocusLoss; + private static WinEventDelegate? _foregroundDelegate; + private static nint _foregroundHook; public string Name => "Zaparoo LaunchBox Integration"; + private delegate void WinEventDelegate( + nint hWinEventHook, + uint eventType, + nint hwnd, + int idObject, + int idChild, + uint dwEventThread, + uint dwmsEventTime + ); + + [DllImport("user32.dll")] + private static extern nint SetWinEventHook( + uint eventMin, + uint eventMax, + nint hmodWinEventProc, + WinEventDelegate lpfnWinEventProc, + uint idProcess, + uint idThread, + uint dwFlags + ); + + [DllImport("user32.dll")] + private static extern bool UnhookWinEvent(nint hWinEventHook); + + [DllImport("user32.dll")] + private static extern nint GetForegroundWindow(); + + [DllImport("user32.dll")] + private static extern int GetWindowText(nint hWnd, StringBuilder text, int count); + + [DllImport("user32.dll", SetLastError = true)] + private static extern void keybd_event(byte bVk, byte bScan, int dwFlags, int dwExtraInfo); + // Constructor - try to connect immediately as fallback public ZaparooPlugin() { + CaptureApplicationContext(); + // Try to connect immediately in case system events don't fire System.Threading.Tasks.Task.Run(() => { @@ -75,6 +120,8 @@ public ZaparooPlugin() public void OnEventRaised(string eventType) { + CaptureApplicationContext(); + if (eventType == SystemEventTypes.PluginInitialized) { _uiDispatcher ??= System.Windows.Application.Current?.Dispatcher; @@ -91,15 +138,23 @@ public void OnEventRaised(string eventType) _uiDispatcher ??= System.Windows.Application.Current?.Dispatcher; _isBigBoxRunning = true; _isShuttingDown = false; + EnsureForegroundHook(); StartConnectionTask(); } + else if (eventType == SystemEventTypes.SelectionChanged) + { + ClearCurrentGameIfSelectionChanged(); + } + else if (eventType == SystemEventTypes.GameExited) + { + NavigateBackAfterZaparooLaunch(); + } else if (eventType == SystemEventTypes.LaunchBoxShutdownBeginning || eventType == SystemEventTypes.BigBoxShutdownBeginning) { _isShuttingDown = true; - ClearPendingBigBoxRestore(); - _shouldRestoreBigBoxView = false; - _previousBigBoxPlatform = null; + ClearPendingLaunchState(); + StopForegroundHook(); DisconnectFromPipe(); } } @@ -110,18 +165,18 @@ public void OnEventRaised(string eventType) public void OnBeforeGameLaunching(IGame game, IAdditionalApplication? app, IEmulator? emulator) { - // Track the game that's about to launch - _currentGame = game; - - if (MatchesPendingBigBoxRestore(game, app)) - { - _shouldRestoreBigBoxView = true; - ClearPendingBigBoxRestore(); - } - else + lock (_stateLock) { - ClearPendingBigBoxRestore(); + _currentGame = game; + _currentAdditionalApp = app; + _shouldNavigateBackAfterLaunch = MatchesPendingLaunch(game, app) && _pendingLaunchShouldNavigate; + _shouldShowGameAfterFocusLoss = _shouldNavigateBackAfterLaunch; + _pendingLaunchGameId = null; + _pendingLaunchAdditionalAppId = null; + _pendingLaunchShouldNavigate = false; } + + Log($"Game launching: {game.Title} ({game.Id})"); } public void OnAfterGameLaunched(IGame game, IAdditionalApplication? app, IEmulator? emulator) @@ -130,28 +185,37 @@ public void OnAfterGameLaunched(IGame game, IAdditionalApplication? app, IEmulat SendEvent(new GameStartedEvent { Event = "MediaStarted", - Id = game.Id, - Title = game.Title, + Id = app?.Id ?? game.Id, + Title = app?.Name ?? game.Title, Platform = game.Platform, - ApplicationPath = game.ApplicationPath + ApplicationPath = app?.ApplicationPath ?? game.ApplicationPath }); } public void OnGameExited() { - // Send game exited event to Zaparoo - if (_currentGame != null) + IGame? game; + IAdditionalApplication? app; + lock (_stateLock) + { + game = _currentGame; + app = _currentAdditionalApp; + _currentGame = null; + _currentAdditionalApp = null; + _shouldShowGameAfterFocusLoss = false; + } + + if (game != null) { SendEvent(new GameExitedEvent { Event = "MediaStopped", - Id = _currentGame.Id, - Title = _currentGame.Title + Id = app?.Id ?? game.Id, + Title = app?.Name ?? game.Title }); - _currentGame = null; } - RestoreBigBoxView(); + NavigateBackAfterZaparooLaunch(); } #endregion @@ -207,6 +271,70 @@ public void OnSelected(IGame[] games) #endregion + #region LaunchBox Context and Logging + + private static void CaptureApplicationContext() + { + _uiDispatcher ??= System.Windows.Application.Current?.Dispatcher; + + if (!string.IsNullOrEmpty(_launchBoxRoot)) + { + return; + } + + try + { + string? root = Path.GetDirectoryName(System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName); + if (string.IsNullOrEmpty(root)) + { + root = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + } + + if (!string.IsNullOrEmpty(root) && + (root.EndsWith($"{Path.DirectorySeparatorChar}core", StringComparison.OrdinalIgnoreCase) || + root.EndsWith($"{Path.AltDirectorySeparatorChar}core", StringComparison.OrdinalIgnoreCase))) + { + root = Directory.GetParent(root)?.FullName; + } + + _launchBoxRoot = root ?? string.Empty; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Zaparoo plugin: Failed to capture LaunchBox root: {ex.Message}"); + } + } + + private static void Log(string message) + { + try + { + CaptureApplicationContext(); + string root = _launchBoxRoot; + if (string.IsNullOrEmpty(root)) + { + root = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? string.Empty; + } + + if (string.IsNullOrEmpty(root)) + { + System.Diagnostics.Debug.WriteLine($"Zaparoo plugin: {message}"); + return; + } + + string logDir = Path.Combine(root, "Logs"); + Directory.CreateDirectory(logDir); + string logPath = Path.Combine(logDir, "ZaparooLaunchBoxPlugin.txt"); + File.AppendAllText(logPath, $"[{DateTime.Now}] {message}{Environment.NewLine}"); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Zaparoo plugin: {message} (log failed: {ex.Message})"); + } + } + + #endregion + #region Named Pipe Communication private void StartConnectionTask() @@ -434,41 +562,77 @@ private void HandleCommand(string json) return; } - switch (command.Command) + string commandName = command.Command ?? string.Empty; + switch (commandName.ToLowerInvariant()) { - case "Launch": + case "launch": if (!string.IsNullOrEmpty(command.Id)) { LaunchGameById(command.Id); } break; - case "GetPlatforms": + case "showplatforms": + ShowPlatforms(); + break; + + case "showallgames": + ShowAllGames(); + break; + + case "showplatform": + if (!string.IsNullOrEmpty(command.Platform)) + { + ShowPlatform(command.Platform); + } + break; + + case "showplaylist": + if (!string.IsNullOrEmpty(command.Playlist)) + { + ShowPlaylist(command.Playlist); + } + break; + + case "search": + if (!string.IsNullOrEmpty(command.Query)) + { + Search(command.Query); + } + break; + + case "openmanual": + OpenManual(command.Id); + break; + + case "getplatforms": SendPlatformsList(); break; - case "GetGames": + case "getgames": SendGamesList(); break; - case "GetGamesForPlatform": + case "getgamesforplatform": if (!string.IsNullOrEmpty(command.Platform)) { SendGamesForPlatform(command.Platform); } break; - case "Ping": + case "ping": // Heartbeat to keep connection alive - no action needed break; default: + SendCommandError(commandName, "Unknown command"); break; } } - catch (Exception) + catch (Exception ex) { - // Ignore command errors + Log($"Command failed: {ex.Message}"); + SendCommandError("Command", ex.Message); } } @@ -476,27 +640,18 @@ private void LaunchGameById(string gameId) { try { + if (IsGameRunning()) + { + Log($"Ignoring launch for {gameId}: a game is already running"); + SendCommandError("Launch", "A game is already running"); + return; + } + // First try to find as a regular game var game = PluginHelper.DataManager.GetGameById(gameId); if (game != null) { - InvokeOnUiThread(() => - { - if (_isBigBoxRunning) - { - CaptureBigBoxLaunchContext(game.Id, null); - - // ShowGame navigates BigBox UI to the game, which fires - // LEDBlinky Event 9 (game selection). Without this, LEDBlinky - // shows controls for whichever game was highlighted in the menu. - PluginHelper.BigBoxMainViewModel?.ShowGame(game, FilterType.None); - PluginHelper.BigBoxMainViewModel?.PlayGame(game, null, null, null); - } - else - { - PluginHelper.LaunchBoxMainViewModel?.PlayGame(game, null, null, null); - } - }); + LaunchGame(game, null); return; } @@ -504,102 +659,355 @@ private void LaunchGameById(string gameId) var (parentGame, additionalApp) = FindAdditionalApplicationById(gameId); if (additionalApp != null && parentGame != null) { - InvokeOnUiThread(() => - { - if (_isBigBoxRunning) - { - CaptureBigBoxLaunchContext(parentGame.Id, additionalApp.Id); - PluginHelper.BigBoxMainViewModel?.ShowGame(parentGame, FilterType.None); - PluginHelper.BigBoxMainViewModel?.PlayGame(parentGame, additionalApp, null, null); - } - else - { - PluginHelper.LaunchBoxMainViewModel?.PlayGame(parentGame, additionalApp, null, null); - } - }); + LaunchGame(parentGame, additionalApp); return; } - System.Diagnostics.Debug.WriteLine($"Zaparoo plugin: Game or additional app not found: {gameId}"); + Log($"Game or additional app not found: {gameId}"); + SendCommandError("Launch", $"Game or additional app not found: {gameId}"); } catch (Exception ex) { - System.Diagnostics.Debug.WriteLine($"Zaparoo plugin: Failed to launch game {gameId}: {ex.Message}"); + Log($"Failed to launch game {gameId}: {ex.Message}"); + SendCommandError("Launch", ex.Message); } } - private static void InvokeOnUiThread(Action action) + private void LaunchGame(IGame game, IAdditionalApplication? app) { - _uiDispatcher ??= System.Windows.Application.Current?.Dispatcher; + InvokeOnUiThread(() => + { + if (_isBigBoxRunning || PluginHelper.StateManager?.IsBigBox == true) + { + EnsureForegroundHook(); + BeginPendingLaunch(game, app, navigateAfterLaunch: true); + + try + { + Log($"Launching BigBox game: {game.Title} ({game.Id})"); + PluginHelper.BigBoxMainViewModel.PlayGame(game, app, null, null); + } + catch + { + ClearPendingLaunch(game, app); + throw; + } + return; + } + + BeginPendingLaunch(game, app, navigateAfterLaunch: false); + try + { + Log($"Launching LaunchBox game: {game.Title} ({game.Id})"); + PluginHelper.LaunchBoxMainViewModel.PlayGame(game, app, null, null); + } + catch + { + ClearPendingLaunch(game, app); + throw; + } + }, "Launch game"); + } - var dispatcher = _uiDispatcher; - if (dispatcher != null && !dispatcher.CheckAccess()) + private static void InvokeOnUiThread(Action action, string description) + { + CaptureApplicationContext(); + Dispatcher? dispatcher = _uiDispatcher ?? System.Windows.Application.Current?.Dispatcher; + if (dispatcher == null) { - dispatcher.Invoke(action); + Log($"Cannot run UI action without dispatcher: {description}"); + return; } - else + + if (dispatcher.CheckAccess()) { action(); + return; + } + + dispatcher.Invoke(action); + } + + private static bool IsGameRunning() + { + lock (_stateLock) + { + return _currentGame != null || _pendingLaunchGameId != null; } } - private static void CaptureBigBoxLaunchContext(string gameId, string? additionalAppId) + private static bool MatchesPendingLaunch(IGame game, IAdditionalApplication? app) { - _previousBigBoxPlatform = PluginHelper.StateManager?.GetSelectedPlatform(); - _pendingRestoreGameId = gameId; - _pendingRestoreAdditionalAppId = additionalAppId; - _hasPendingBigBoxRestore = true; - _shouldRestoreBigBoxView = false; + return _pendingLaunchGameId == game.Id && _pendingLaunchAdditionalAppId == app?.Id; } - private static bool MatchesPendingBigBoxRestore(IGame game, IAdditionalApplication? app) + private static void ClearPendingLaunchState() { - if (!_hasPendingBigBoxRestore || _pendingRestoreGameId != game.Id) + lock (_stateLock) { - return false; + _pendingLaunchGameId = null; + _pendingLaunchAdditionalAppId = null; + _pendingLaunchShouldNavigate = false; + _shouldNavigateBackAfterLaunch = false; + _shouldShowGameAfterFocusLoss = false; + _currentGame = null; + _currentAdditionalApp = null; } - - return _pendingRestoreAdditionalAppId == app?.Id; } - private static void ClearPendingBigBoxRestore() + private static void BeginPendingLaunch(IGame game, IAdditionalApplication? app, bool navigateAfterLaunch) { - _hasPendingBigBoxRestore = false; - _pendingRestoreGameId = null; - _pendingRestoreAdditionalAppId = null; + lock (_stateLock) + { + _pendingLaunchGameId = game.Id; + _pendingLaunchAdditionalAppId = app?.Id; + _pendingLaunchShouldNavigate = navigateAfterLaunch; + _shouldNavigateBackAfterLaunch = false; + _shouldShowGameAfterFocusLoss = false; + } + + _ = Task.Run(async () => + { + await Task.Delay(TimeSpan.FromSeconds(30)); + lock (_stateLock) + { + if (_pendingLaunchGameId == game.Id && _pendingLaunchAdditionalAppId == app?.Id) + { + Log($"Clearing stale pending launch: {game.Title} ({game.Id})"); + _pendingLaunchGameId = null; + _pendingLaunchAdditionalAppId = null; + _pendingLaunchShouldNavigate = false; + _shouldNavigateBackAfterLaunch = false; + _shouldShowGameAfterFocusLoss = false; + } + } + }); } - private static void RestoreBigBoxView() + private static void ClearPendingLaunch(IGame game, IAdditionalApplication? app) { - if (!_shouldRestoreBigBoxView || !_isBigBoxRunning) + lock (_stateLock) { - return; + if (_pendingLaunchGameId == game.Id && _pendingLaunchAdditionalAppId == app?.Id) + { + _pendingLaunchGameId = null; + _pendingLaunchAdditionalAppId = null; + _pendingLaunchShouldNavigate = false; + _shouldNavigateBackAfterLaunch = false; + _shouldShowGameAfterFocusLoss = false; + } } + } - InvokeOnUiThread(() => + private static void ClearCurrentGameIfSelectionChanged() + { + // CLI Launcher uses selection changes to repair stale in-game state, but Zaparoo relies on + // OnGameExited to emit MediaStopped and to keep duplicate launches blocked while a game runs. + // Leave running state intact here; stale pending launches are handled by their timeout. + } + + private static void EnsureForegroundHook() + { + lock (_stateLock) { + if (_foregroundHook != 0) + { + return; + } + try { - if (_previousBigBoxPlatform != null) + _foregroundDelegate = OnForegroundChanged; + _foregroundHook = SetWinEventHook( + EventSystemForeground, + EventSystemForeground, + 0, + _foregroundDelegate, + 0, + 0, + WinEventOutOfContext + ); + + if (_foregroundHook == 0) { - PluginHelper.BigBoxMainViewModel?.ShowGames(_previousBigBoxPlatform); + _foregroundDelegate = null; + Log("Failed to install foreground hook"); } - else + } + catch (Exception ex) + { + _foregroundDelegate = null; + _foregroundHook = 0; + Log($"Failed to install foreground hook: {ex.Message}"); + } + } + } + + private static void StopForegroundHook() + { + lock (_stateLock) + { + try + { + if (_foregroundHook != 0) { - PluginHelper.BigBoxMainViewModel?.ShowPlatforms(); + UnhookWinEvent(_foregroundHook); + _foregroundHook = 0; } + + _foregroundDelegate = null; } catch (Exception ex) { - System.Diagnostics.Debug.WriteLine($"Zaparoo plugin: Failed to restore BigBox view: {ex.Message}"); + Log($"Failed to remove foreground hook: {ex.Message}"); } - finally + } + } + + private static void OnForegroundChanged( + nint hWinEventHook, + uint eventType, + nint hwnd, + int idObject, + int idChild, + uint dwEventThread, + uint dwmsEventTime + ) + { + if (IsLaunchBoxOrBigBoxActiveWindow()) + { + return; + } + + IGame? gameToShow = null; + lock (_stateLock) + { + if (_shouldShowGameAfterFocusLoss && _currentGame != null) { - ClearPendingBigBoxRestore(); - _previousBigBoxPlatform = null; - _shouldRestoreBigBoxView = false; + _shouldShowGameAfterFocusLoss = false; + gameToShow = _currentGame; } - }); + } + + if (gameToShow == null) + { + return; + } + + InvokeOnUiThread(() => + { + Log($"Showing launched game after focus loss: {gameToShow.Title}"); + PluginHelper.BigBoxMainViewModel.ShowGame(gameToShow, FilterType.Local); + }, "Show launched BigBox game"); + } + + private static bool IsLaunchBoxOrBigBoxActiveWindow() + { + try + { + string title = GetActiveWindowTitle(); + return title.Equals("launchbox", StringComparison.OrdinalIgnoreCase) || + title.Equals("launchbox big box", StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } + + private static string GetActiveWindowTitle() + { + nint window = GetForegroundWindow(); + if (window == 0) + { + return string.Empty; + } + + var title = new StringBuilder(256); + return GetWindowText(window, title, title.Capacity) > 0 ? title.ToString() : string.Empty; + } + + private static void NavigateBackAfterZaparooLaunch() + { + bool shouldNavigate; + lock (_stateLock) + { + shouldNavigate = _shouldNavigateBackAfterLaunch; + _shouldNavigateBackAfterLaunch = false; + _shouldShowGameAfterFocusLoss = false; + } + + if (!shouldNavigate || !IsBigBox()) + { + return; + } + + InvokeOnUiThread(() => + { + if (!SendBigBoxBackKey()) + { + Log("Falling back to ShowAllGames after BigBox Back was unavailable"); + PluginHelper.BigBoxMainViewModel.ShowAllGames(); + } + }, "Navigate back after BigBox launch"); + } + + private static bool SendBigBoxBackKey() + { + string? setting = ReadBigBoxSetting("KeyboardBack"); + if (string.IsNullOrWhiteSpace(setting) || setting == "0") + { + return false; + } + + if (!int.TryParse(setting, out int keyValue)) + { + Log($"Invalid KeyboardBack setting: {setting}"); + return false; + } + + byte virtualKey = (byte)KeyInterop.VirtualKeyFromKey((Key)keyValue); + if (virtualKey == 0) + { + return false; + } + + if (!IsLaunchBoxOrBigBoxActiveWindow()) + { + Log("Skipping BigBox Back key because BigBox is not foreground"); + return false; + } + + Log($"Navigating BigBox back with virtual key: {virtualKey}"); + keybd_event(virtualKey, 0, 0, 0); + keybd_event(virtualKey, 0, 2, 0); + return true; + } + + private static string? ReadBigBoxSetting(string name) + { + try + { + CaptureApplicationContext(); + if (string.IsNullOrEmpty(_launchBoxRoot)) + { + return null; + } + + string path = Path.Combine(_launchBoxRoot, "Data", "BigBoxSettings.xml"); + if (!File.Exists(path)) + { + return null; + } + + XDocument doc = XDocument.Load(path); + return doc.XPathSelectElement($"/LaunchBox/BigBoxSettings/{name}")?.Value; + } + catch (Exception ex) + { + Log($"Failed to read BigBox setting {name}: {ex.Message}"); + return null; + } } private (IGame?, IAdditionalApplication?) FindAdditionalApplicationById(string id) @@ -617,6 +1025,164 @@ private static void RestoreBigBoxView() return (null, null); } + private void ShowPlatforms() + { + InvokeOnUiThread(() => + { + if (!IsBigBox()) + { + SendCommandError("ShowPlatforms", "Platform navigation only works in BigBox"); + return; + } + + if (IsGameRunning()) + { + SendCommandError("ShowPlatforms", "A game is already running"); + return; + } + + PluginHelper.BigBoxMainViewModel.ShowPlatforms(); + }, "Show BigBox platforms"); + } + + private void ShowAllGames() + { + InvokeOnUiThread(() => + { + if (!IsBigBox()) + { + SendCommandError("ShowAllGames", "All Games navigation only works in BigBox"); + return; + } + + if (IsGameRunning()) + { + SendCommandError("ShowAllGames", "A game is already running"); + return; + } + + PluginHelper.BigBoxMainViewModel.ShowAllGames(); + }, "Show BigBox all games"); + } + + private void ShowPlatform(string platformName) + { + InvokeOnUiThread(() => + { + if (!IsBigBox()) + { + SendCommandError("ShowPlatform", "Platform navigation only works in BigBox"); + return; + } + + if (IsGameRunning()) + { + SendCommandError("ShowPlatform", "A game is already running"); + return; + } + + IPlatform? platform = PluginHelper.DataManager.GetPlatformByName(platformName); + if (platform == null) + { + SendCommandError("ShowPlatform", $"Platform not found: {platformName}"); + return; + } + + PluginHelper.BigBoxMainViewModel.ShowGames(platform); + }, "Show BigBox platform"); + } + + private void ShowPlaylist(string playlistName) + { + InvokeOnUiThread(() => + { + if (!IsBigBox()) + { + SendCommandError("ShowPlaylist", "Playlist navigation only works in BigBox"); + return; + } + + if (IsGameRunning()) + { + SendCommandError("ShowPlaylist", "A game is already running"); + return; + } + + IPlaylist? playlist = PluginHelper.DataManager.GetAllPlaylists() + .FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase)); + if (playlist == null) + { + SendCommandError("ShowPlaylist", $"Playlist not found: {playlistName}"); + return; + } + + PluginHelper.BigBoxMainViewModel.ShowGames(FilterType.PlatformOrCategoryOrPlaylist, playlist.Name); + }, "Show BigBox playlist"); + } + + private void Search(string query) + { + InvokeOnUiThread(() => + { + if (!IsBigBox()) + { + SendCommandError("Search", "Search command only works in BigBox"); + return; + } + + if (IsGameRunning()) + { + SendCommandError("Search", "A game is already running"); + return; + } + + PluginHelper.BigBoxMainViewModel.Search(query); + }, "Search BigBox"); + } + + private void OpenManual(string? gameId) + { + InvokeOnUiThread(() => + { + IGame? game = null; + if (!string.IsNullOrEmpty(gameId)) + { + game = PluginHelper.DataManager.GetGameById(gameId); + } + + if (game == null) + { + lock (_stateLock) + { + game = _currentGame; + } + } + + if (game == null) + { + IGame[]? selectedGames = PluginHelper.StateManager?.GetAllSelectedGames(); + game = selectedGames is { Length: > 0 } ? selectedGames[0] : null; + } + + if (game == null) + { + SendCommandError("OpenManual", "No game is selected or running"); + return; + } + + string? result = game.OpenManual(); + if (!string.IsNullOrEmpty(result)) + { + Log($"OpenManual result for {game.Title}: {result}"); + } + }, "Open manual"); + } + + private static bool IsBigBox() + { + return _isBigBoxRunning || PluginHelper.StateManager?.IsBigBox == true; + } + private void SendPlatformsList() { try @@ -727,6 +1293,16 @@ private void SendGamesForPlatform(string platform) } } + private void SendCommandError(string command, string message) + { + Log($"{command} error: {message}"); + SendEvent(new ErrorEvent + { + Command = command, + Error = message + }); + } + #endregion #region Message Types @@ -760,6 +1336,15 @@ private class PluginCommand public string? Command { get; set; } public string? Id { get; set; } public string? Platform { get; set; } + public string? Playlist { get; set; } + public string? Query { get; set; } + } + + private class ErrorEvent + { + public string Event { get; set; } = "Error"; + public string Command { get; set; } = string.Empty; + public string Error { get; set; } = string.Empty; } private class PlatformInfo From a9059ddd08457ad1d7898a57821727d46377a968 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sat, 25 Apr 2026 16:14:17 +0800 Subject: [PATCH 5/5] fix(platform): report invalid plugin commands --- .../windows/launchbox-plugin/ZaparooPlugin.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/scripts/windows/launchbox-plugin/ZaparooPlugin.cs b/scripts/windows/launchbox-plugin/ZaparooPlugin.cs index dfcb8cf8d..2e4120170 100644 --- a/scripts/windows/launchbox-plugin/ZaparooPlugin.cs +++ b/scripts/windows/launchbox-plugin/ZaparooPlugin.cs @@ -570,6 +570,10 @@ private void HandleCommand(string json) { LaunchGameById(command.Id); } + else + { + SendCommandError("Launch", "Missing required field 'Id' for launch"); + } break; case "showplatforms": @@ -585,6 +589,10 @@ private void HandleCommand(string json) { ShowPlatform(command.Platform); } + else + { + SendCommandError("ShowPlatform", "Missing required field 'Platform' for showplatform"); + } break; case "showplaylist": @@ -592,6 +600,10 @@ private void HandleCommand(string json) { ShowPlaylist(command.Playlist); } + else + { + SendCommandError("ShowPlaylist", "Missing required field 'Playlist' for showplaylist"); + } break; case "search": @@ -599,6 +611,10 @@ private void HandleCommand(string json) { Search(command.Query); } + else + { + SendCommandError("Search", "Missing required field 'Query' for search"); + } break; case "openmanual": @@ -618,6 +634,10 @@ private void HandleCommand(string json) { SendGamesForPlatform(command.Platform); } + else + { + SendCommandError("GetGamesForPlatform", "Missing required field 'Platform' for getgamesforplatform"); + } break; case "ping":