using System; using System.IO; using System.IO.Pipes; using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Win32; namespace LANCommander.Launcher.Services; /// /// Ensures only one launcher instance runs at a time and lets a second instance /// (spawned when the user clicks a notification) pass a navigation request to /// the already-running process via a named pipe. /// /// Protocol: "navigate-game:{guid}" /// public class SingleInstanceService : IDisposable { private const string PipeName = "lancommander-launcher"; private const string ProtocolScheme = "lancommander"; private const string MutexName = "lancommander-launcher-singleton"; // Held for the lifetime of the process to mark this as the owning instance. private static Mutex? _instanceMutex; private readonly ILogger _logger; private CancellationTokenSource? _cts; public event EventHandler? NavigateToGameRequested; /// Raised when a secondary instance asks the running one to surface. public event EventHandler? RestoreRequested; public SingleInstanceService(ILogger logger) { _logger = logger; } /// /// Claims the single-instance lock for this process. Returns true when this is the first /// instance, false when another instance already holds the lock. The mutex is intentionally /// kept alive for the lifetime of the process and released by the OS on exit. /// public static bool TryAcquireInstanceLock() { _instanceMutex = new Mutex(initiallyOwned: true, MutexName, out var createdNew); return createdNew; } // ── Server (first instance) ────────────────────────────────────────────── /// Start listening for messages from secondary instances. public void StartServer() { _cts = new CancellationTokenSource(); _ = ListenLoopAsync(_cts.Token); } private async Task ListenLoopAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { try { using var server = new NamedPipeServerStream( PipeName, PipeDirection.In, NamedPipeServerStream.MaxAllowedServerInstances, PipeTransmissionMode.Byte, PipeOptions.Asynchronous); await server.WaitForConnectionAsync(ct).ConfigureAwait(false); using var reader = new StreamReader(server, Encoding.UTF8); var message = await reader.ReadToEndAsync(ct).ConfigureAwait(false); HandleMessage(message); } catch (OperationCanceledException) { break; } catch (Exception ex) { _logger.LogWarning(ex, "Named pipe server error"); await Task.Delay(1000, ct).ConfigureAwait(false); } } } private void HandleMessage(string message) { if (message.Equals("restore", StringComparison.OrdinalIgnoreCase)) { _logger.LogInformation("Restore request received from a secondary instance"); RestoreRequested?.Invoke(this, EventArgs.Empty); return; } if (message.StartsWith("navigate-game:", StringComparison.OrdinalIgnoreCase)) { var idStr = message["navigate-game:".Length..].Trim(); if (Guid.TryParse(idStr, out var gameId)) { _logger.LogInformation("Navigation request received for game {GameId}", gameId); NavigateToGameRequested?.Invoke(this, gameId); } } } // ── Client (secondary instance) ────────────────────────────────────────── /// /// Try to send a message to the already-running instance. /// Returns true if the message was delivered. /// public static bool TrySendToServer(string message) { try { using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out); client.Connect(timeout: 2000); using var writer = new StreamWriter(client, Encoding.UTF8, leaveOpen: true); writer.Write(message); writer.Flush(); return true; } catch { return false; } } /// /// Parse a protocol URL of the form lancommander://game/{guid}. /// Returns the game GUID if parsed successfully. /// public static Guid? ParseProtocolArg(string arg) { var prefix = $"{ProtocolScheme}://game/"; if (!arg.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return null; var idStr = arg[prefix.Length..].TrimEnd('/'); return Guid.TryParse(idStr, out var id) ? id : null; } // ── Windows protocol registration ──────────────────────────────────────── /// /// Registers lancommander:// in HKCU (no elevation required) so /// Windows routes toast-notification click-actions to this executable. /// No-op on non-Windows. /// public void RegisterProtocolHandler() { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; try { var exePath = Environment.ProcessPath ?? AppContext.BaseDirectory; using var key = Registry.CurrentUser.CreateSubKey( $@"Software\Classes\{ProtocolScheme}"); key.SetValue("", "URL:LANCommander Launcher"); key.SetValue("URL Protocol", ""); using var cmd = key.CreateSubKey(@"shell\open\command"); cmd.SetValue("", $"\"{exePath}\" \"%1\""); _logger.LogInformation("Registered {Scheme}:// protocol handler", ProtocolScheme); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to register protocol handler"); } } public void Dispose() => _cts?.Cancel(); }