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));
+}