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 24d45dfe5..650f957b6 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.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 4b76bfe7b..2e4120170 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,17 +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; - - // 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(() => { @@ -69,22 +120,41 @@ public ZaparooPlugin() public void OnEventRaised(string eventType) { - if (eventType == SystemEventTypes.LaunchBoxStartupCompleted) + CaptureApplicationContext(); + + if (eventType == SystemEventTypes.PluginInitialized) + { + _uiDispatcher ??= System.Windows.Application.Current?.Dispatcher; + } + else if (eventType == SystemEventTypes.LaunchBoxStartupCompleted) { + _uiDispatcher ??= System.Windows.Application.Current?.Dispatcher; _isBigBoxRunning = false; _isShuttingDown = false; StartConnectionTask(); } else if (eventType == SystemEventTypes.BigBoxStartupCompleted) { + _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; + ClearPendingLaunchState(); + StopForegroundHook(); DisconnectFromPipe(); } } @@ -95,8 +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; + lock (_stateLock) + { + _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) @@ -105,26 +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; } + + NavigateBackAfterZaparooLaunch(); } #endregion @@ -180,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() @@ -407,41 +562,97 @@ 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); } + else + { + SendCommandError("Launch", "Missing required field 'Id' for launch"); + } break; - case "GetPlatforms": + case "showplatforms": + ShowPlatforms(); + break; + + case "showallgames": + ShowAllGames(); + break; + + case "showplatform": + if (!string.IsNullOrEmpty(command.Platform)) + { + ShowPlatform(command.Platform); + } + else + { + SendCommandError("ShowPlatform", "Missing required field 'Platform' for showplatform"); + } + break; + + case "showplaylist": + if (!string.IsNullOrEmpty(command.Playlist)) + { + ShowPlaylist(command.Playlist); + } + else + { + SendCommandError("ShowPlaylist", "Missing required field 'Playlist' for showplaylist"); + } + break; + + case "search": + if (!string.IsNullOrEmpty(command.Query)) + { + Search(command.Query); + } + else + { + SendCommandError("Search", "Missing required field 'Query' for search"); + } + 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); } + else + { + SendCommandError("GetGamesForPlatform", "Missing required field 'Platform' for getgamesforplatform"); + } 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); } } @@ -449,18 +660,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) { - if (_isBigBoxRunning) - { - PluginHelper.BigBoxMainViewModel.PlayGame(game, null, null, null); - } - else - { - PluginHelper.LaunchBoxMainViewModel.PlayGame(game, null, null, null); - } + LaunchGame(game, null); return; } @@ -468,22 +679,354 @@ private void LaunchGameById(string gameId) var (parentGame, additionalApp) = FindAdditionalApplicationById(gameId); if (additionalApp != null && parentGame != null) { - if (_isBigBoxRunning) + LaunchGame(parentGame, additionalApp); + return; + } + + Log($"Game or additional app not found: {gameId}"); + SendCommandError("Launch", $"Game or additional app not found: {gameId}"); + } + catch (Exception ex) + { + Log($"Failed to launch game {gameId}: {ex.Message}"); + SendCommandError("Launch", ex.Message); + } + } + + private void LaunchGame(IGame game, IAdditionalApplication? app) + { + 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 { - PluginHelper.BigBoxMainViewModel.PlayGame(parentGame, additionalApp, null, null); + ClearPendingLaunch(game, app); + throw; } - else + 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"); + } + + private static void InvokeOnUiThread(Action action, string description) + { + CaptureApplicationContext(); + Dispatcher? dispatcher = _uiDispatcher ?? System.Windows.Application.Current?.Dispatcher; + if (dispatcher == null) + { + Log($"Cannot run UI action without dispatcher: {description}"); + return; + } + + if (dispatcher.CheckAccess()) + { + action(); + return; + } + + dispatcher.Invoke(action); + } + + private static bool IsGameRunning() + { + lock (_stateLock) + { + return _currentGame != null || _pendingLaunchGameId != null; + } + } + + private static bool MatchesPendingLaunch(IGame game, IAdditionalApplication? app) + { + return _pendingLaunchGameId == game.Id && _pendingLaunchAdditionalAppId == app?.Id; + } + + private static void ClearPendingLaunchState() + { + lock (_stateLock) + { + _pendingLaunchGameId = null; + _pendingLaunchAdditionalAppId = null; + _pendingLaunchShouldNavigate = false; + _shouldNavigateBackAfterLaunch = false; + _shouldShowGameAfterFocusLoss = false; + _currentGame = null; + _currentAdditionalApp = null; + } + } + + private static void BeginPendingLaunch(IGame game, IAdditionalApplication? app, bool navigateAfterLaunch) + { + 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) { - PluginHelper.LaunchBoxMainViewModel.PlayGame(parentGame, additionalApp, null, null); + Log($"Clearing stale pending launch: {game.Title} ({game.Id})"); + _pendingLaunchGameId = null; + _pendingLaunchAdditionalAppId = null; + _pendingLaunchShouldNavigate = false; + _shouldNavigateBackAfterLaunch = false; + _shouldShowGameAfterFocusLoss = false; } + } + }); + } + + private static void ClearPendingLaunch(IGame game, IAdditionalApplication? app) + { + lock (_stateLock) + { + if (_pendingLaunchGameId == game.Id && _pendingLaunchAdditionalAppId == app?.Id) + { + _pendingLaunchGameId = null; + _pendingLaunchAdditionalAppId = null; + _pendingLaunchShouldNavigate = false; + _shouldNavigateBackAfterLaunch = false; + _shouldShowGameAfterFocusLoss = false; + } + } + } + + 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; } - System.Diagnostics.Debug.WriteLine($"Zaparoo plugin: Game or additional app not found: {gameId}"); + try + { + _foregroundDelegate = OnForegroundChanged; + _foregroundHook = SetWinEventHook( + EventSystemForeground, + EventSystemForeground, + 0, + _foregroundDelegate, + 0, + 0, + WinEventOutOfContext + ); + + if (_foregroundHook == 0) + { + _foregroundDelegate = null; + Log("Failed to install foreground hook"); + } + } + 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) + { + UnhookWinEvent(_foregroundHook); + _foregroundHook = 0; + } + + _foregroundDelegate = null; + } + catch (Exception ex) + { + Log($"Failed to remove foreground hook: {ex.Message}"); + } + } + } + + 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) + { + _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) { - System.Diagnostics.Debug.WriteLine($"Zaparoo plugin: Failed to launch game {gameId}: {ex.Message}"); + Log($"Failed to read BigBox setting {name}: {ex.Message}"); + return null; } } @@ -502,6 +1045,164 @@ private void LaunchGameById(string gameId) 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 @@ -612,6 +1313,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 @@ -645,6 +1356,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