diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index f9535ec..e7ef485 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -2182,7 +2182,13 @@ public sealed class MainForm : Form // and the arrow keys. menu.Items.Add(fileMenu); menu.Items.Add(recordMenu); - menu.Items.Add(BuildServiceMenu()); + // The send-only Windows service is a Windows 10+ feature: it was built and tested there, and on + // older Windows the System.ServiceProcess assembly it relies on won't even load under .NET 10. Only + // offer the Service menu where it can actually work — on Win7/8 it simply isn't shown and no service + // code is ever reached. (Mirrors how "capture individual apps" is gated to the versions that support + // it.) The startup path is already load-safe via ServiceEntry; this keeps the menu load-safe too. + if (OperatingSystem.IsWindowsVersionAtLeast(10, 0)) + menu.Items.Add(BuildServiceMenu()); menu.Items.Add(optionsMenu); menu.Items.Add(helpMenu); return menu; @@ -2218,19 +2224,29 @@ public sealed class MainForm : Form }); serviceMenu.DropDownOpening += (_, _) => { - var state = ServiceControl.Query(); - status.Text = "Service: " + DescribeServiceState(state); - // Surface the running version + when it (re)started, so a self-update is visible at a glance. - if (state is ServiceState.Running or ServiceState.Stopped && ServiceStore.LoadStatus() is { Version: { } ver } st) + // Never let a status-query failure crash the menu (and with it the app). The menu only appears + // on Win10+ where the service assembly loads fine, but a defensive net here is cheap insurance. + try { - status.Text += $" — version {ver}"; - if (state == ServiceState.Running && st.StartedUtc != default) status.Text += $", running since {DescribeAgo(st.StartedUtc)}"; + var state = ServiceControl.Query(); + status.Text = "Service: " + DescribeServiceState(state); + // Surface the running version + when it (re)started, so a self-update is visible at a glance. + if (state is ServiceState.Running or ServiceState.Stopped && ServiceStore.LoadStatus() is { Version: { } ver } st) + { + status.Text += $" — version {ver}"; + if (state == ServiceState.Running && st.StartedUtc != default) status.Text += $", running since {DescribeAgo(st.StartedUtc)}"; + } + var installed = state != ServiceState.NotInstalled; + install.Enabled = !installed; + uninstall.Enabled = installed; + start.Enabled = installed && state is ServiceState.Stopped; + stop.Enabled = installed && state is ServiceState.Running; + } + catch (Exception ex) + { + status.Text = "Service: status unavailable"; + logFile.Event($"service menu: status query failed {ex.GetType().Name}: {ex.Message}"); } - var installed = state != ServiceState.NotInstalled; - install.Enabled = !installed; - uninstall.Enabled = installed; - start.Enabled = installed && state is ServiceState.Stopped; - stop.Enabled = installed && state is ServiceState.Running; }; return serviceMenu; } diff --git a/src/RemSound.App/Program.cs b/src/RemSound.App/Program.cs index 3316701..6c6ee12 100644 --- a/src/RemSound.App/Program.cs +++ b/src/RemSound.App/Program.cs @@ -16,6 +16,15 @@ internal static class Program private static bool HasArg(string[] args, string flag) => Array.Exists(args, a => string.Equals(a, flag, StringComparison.OrdinalIgnoreCase)); + /// True when any Windows-service verb is present. Uses only the inlined const verb strings, so + /// it references no service type and loads no service assembly — see the dispatch call in + /// for why that matters on older Windows. When this is false (every normal launch) the service code is + /// never reached. + internal static bool IsServiceInvocation(string[] args) => + HasArg(args, ServiceControl.RunVerb) || HasArg(args, ServiceControl.InstallVerb) + || HasArg(args, ServiceControl.UninstallVerb) || HasArg(args, ServiceControl.StartVerb) + || HasArg(args, ServiceControl.StopVerb); + // Writes an otherwise-fatal exception to a timestamped crash file in the logs folder, so a // "RemSound just disappeared, no dialog" report (#16) leaves a stack behind to diagnose instead // of nothing. Best-effort and self-contained — a crash handler must never throw. @@ -64,21 +73,18 @@ internal static class Program // The service is a SEPARATE role — it must never take the interactive single-instance lock — and // the elevated one-shot verbs (install/uninstall/start/stop) just do their SCM work and exit with // a status code. --run-service blocks in the SCM dispatcher until Windows stops the service. - if (args.Length > 0) + // + // CRITICAL (2026-07-14): the dispatch lives in ServiceEntry, NOT inline here. RemSoundService + // derives from ServiceBase, so naming it in THIS method's body would make the JIT load the + // System.ServiceProcess assembly the instant Main is compiled — at the very start of every launch, + // before any argument is read. On older Windows (Win7) that assembly won't load under .NET 10, so + // the app crashed before it could open a window. The check below uses only inlined const verb + // strings, and ServiceEntry.Dispatch is only reached when a service verb is genuinely present, so a + // normal launch never touches that assembly. + if (args.Length > 0 && IsServiceInvocation(args)) { - if (HasArg(args, ServiceControl.RunVerb)) - { - // Point the service's data (its log) at the machine-wide ProgramData location, next to its - // profile — otherwise a headless SYSTEM service logs into the SYSTEM account's AppData, - // which is near-impossible to find. (The profile itself always comes from ServiceStore.) - AppConfig.SetUserDataDirectoryOverride(ServiceStore.Directory); - RemSoundService.RunAsService(); - return; - } - if (HasArg(args, ServiceControl.InstallVerb)) { Environment.ExitCode = ServiceControl.DoInstall(); return; } - if (HasArg(args, ServiceControl.UninstallVerb)) { Environment.ExitCode = ServiceControl.DoUninstall(); return; } - if (HasArg(args, ServiceControl.StartVerb)) { Environment.ExitCode = ServiceControl.DoStart(); return; } - if (HasArg(args, ServiceControl.StopVerb)) { Environment.ExitCode = ServiceControl.DoStop(); return; } + Environment.ExitCode = ServiceEntry.Dispatch(args); + return; } // --config-dir (test / portable isolation): redirect ALL user state - config, diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index cfd4b25..e7afc74 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -79,6 +79,7 @@ internal static class SelfTest RunStep(results, "Main window coverage (all tabs + controls)", MainWindowCoverage); RunStep(results, "Main window profile round-trip (controls load + save)", MainWindowProfileRoundTrip); RunStep(results, "Auto-save non-read-only profiles (options + guard + silent timer)", AutoSaveNonReadOnlyProfiles); + RunStep(results, "Service verb gate (normal launch stays load-safe)", ServiceVerbGate); var failed = results.Count(r => r.Status == "FAIL"); var skipped = results.Count(r => r.Status == "SKIP"); @@ -1262,6 +1263,56 @@ internal static class SelfTest return "options, persistence, guard (read-only/blank/unchanged skipped), and silent timer all verified"; } + /// Guards the fix for the 2026-07-14 Win7 launch crash: RemSoundService derives from ServiceBase, + /// so if Program.Main reached the service dispatch on a normal launch, the JIT would load the + /// System.ServiceProcess assembly at startup — which won't load on Win7 under .NET 10 and crashed the app + /// before it could open. A normal launch must therefore NOT be treated as a service invocation (so the + /// dispatch — the only place that names the service types — is never JIT-compiled), while every real + /// service verb must be recognised. + private static string? ServiceVerbGate() + { + // Real launches that must NOT route to the service dispatch. + string[][] normal = + { + Array.Empty(), + new[] { "--silent" }, + new[] { "--profile", "My Profile" }, + new[] { "--connect", "10.0.0.5" }, + new[] { "--minimized" }, + new[] { "--config-dir", @"C:\temp\x" }, + }; + foreach (var args in normal) + Check(!Program.IsServiceInvocation(args), + $"a normal launch ({(args.Length == 0 ? "no args" : string.Join(' ', args))}) must not be treated as a service invocation"); + + // Every service verb must be recognised, case-insensitively (so it DOES route to the dispatch). + foreach (var verb in new[] + { + ServiceControl.RunVerb, ServiceControl.InstallVerb, ServiceControl.UninstallVerb, + ServiceControl.StartVerb, ServiceControl.StopVerb, + }) + { + Check(Program.IsServiceInvocation(new[] { verb }), $"'{verb}' must be recognised as a service invocation"); + Check(Program.IsServiceInvocation(new[] { verb.ToUpperInvariant() }), $"'{verb}' must be recognised case-insensitively"); + // A service verb mixed in with other args still counts. + Check(Program.IsServiceInvocation(new[] { "--silent", verb }), $"'{verb}' must be recognised even alongside other args"); + } + + // Belt-and-braces: evaluating the gate on a normal launch must not itself drag in the service + // assembly. (If nothing loaded it yet — most likely — this proves the gate references no service + // type; if an earlier step already loaded it, we can't re-check and just pass.) + const string svcAsm = "System.ServiceProcess.ServiceController"; + bool loadedBefore = IsAssemblyLoaded(svcAsm); + _ = Program.IsServiceInvocation(new[] { "--silent" }); + if (!loadedBefore) + Check(!IsAssemblyLoaded(svcAsm), "deciding a normal launch must not load the Windows-service assembly"); + + return "normal launches stay load-safe; all five service verbs recognised (case-insensitive)"; + } + + private static bool IsAssemblyLoaded(string simpleName) => + AppDomain.CurrentDomain.GetAssemblies().Any(a => string.Equals(a.GetName().Name, simpleName, StringComparison.OrdinalIgnoreCase)); + private static int CountControls(Control root, Func predicate) { var n = 0; diff --git a/src/RemSound.App/ServiceEntry.cs b/src/RemSound.App/ServiceEntry.cs new file mode 100644 index 0000000..3dd96e0 --- /dev/null +++ b/src/RemSound.App/ServiceEntry.cs @@ -0,0 +1,43 @@ +using RemSound.Core; + +namespace RemSound.App; + +/// +/// Runs the one-shot Windows-service CLI verbs (--run-service, --install-service, …). +/// +/// Deliberately a SEPARATE class from . derives +/// from ServiceBase (in the System.ServiceProcess assembly), so if Program.Main named +/// it directly the JIT would have to load that assembly the instant Main is compiled — at the very +/// start of EVERY launch, before a single argument is read. On older Windows (Windows 7) that assembly +/// won't load under the .NET 10 runtime, so the app crashed before it could open a window (reported +/// 2026-07-14). Keeping every service-type reference in here, reached only once a service verb is confirmed +/// present (), means a normal launch never loads the service +/// assembly and starts exactly as before. +/// +internal static class ServiceEntry +{ + /// Runs whichever service verb contains and returns the process exit + /// code. --run-service blocks in the SCM dispatcher until Windows stops the service; the others + /// do their elevated SCM work and return. The caller must have already confirmed a verb is present via + /// . + public static int Dispatch(string[] args) + { + if (Has(args, ServiceControl.RunVerb)) + { + // Point the service's data (its log) at the machine-wide ProgramData location, next to its + // profile — otherwise a headless SYSTEM service logs into the SYSTEM account's AppData, which + // is near-impossible to find. (The profile itself always comes from ServiceStore.) + AppConfig.SetUserDataDirectoryOverride(ServiceStore.Directory); + RemSoundService.RunAsService(); + return 0; + } + if (Has(args, ServiceControl.InstallVerb)) return ServiceControl.DoInstall(); + if (Has(args, ServiceControl.UninstallVerb)) return ServiceControl.DoUninstall(); + if (Has(args, ServiceControl.StartVerb)) return ServiceControl.DoStart(); + if (Has(args, ServiceControl.StopVerb)) return ServiceControl.DoStop(); + return 0; + } + + private static bool Has(string[] args, string flag) => + Array.Exists(args, a => string.Equals(a, flag, StringComparison.OrdinalIgnoreCase)); +}