diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs
index e7ef485..be68c30 100644
--- a/src/RemSound.App/MainForm.cs
+++ b/src/RemSound.App/MainForm.cs
@@ -2182,12 +2182,14 @@ public sealed class MainForm : Form
// and the arrow keys.
menu.Items.Add(fileMenu);
menu.Items.Add(recordMenu);
- // 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))
+ // Offer the Service menu wherever the send-only service can actually run — feature-detected, not
+ // gated on a Windows version, so it appears on Windows 7 too if the .NET service layer loads there
+ // (Ed's ask: install on "Win7 or above" wherever it's supported). ServiceCapability probes safely:
+ // if System.ServiceProcess can't load on this OS, it catches that and the menu is simply hidden, so
+ // an older Windows can never be crashed by it. The startup path is already service-type-free via
+ // ServiceEntry, so this probe (at window construction, never at launch) is the only place that
+ // decides visibility.
+ if (ServiceCapability.IsAvailable())
menu.Items.Add(BuildServiceMenu());
menu.Items.Add(optionsMenu);
menu.Items.Add(helpMenu);
diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs
index e7afc74..2a149a1 100644
--- a/src/RemSound.App/SelfTest.cs
+++ b/src/RemSound.App/SelfTest.cs
@@ -80,6 +80,7 @@ internal static class SelfTest
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);
+ RunStep(results, "Service capability probe (feature-detect, cached, safe)", ServiceCapabilityProbe);
var failed = results.Count(r => r.Status == "FAIL");
var skipped = results.Count(r => r.Status == "SKIP");
@@ -1313,6 +1314,21 @@ internal static class SelfTest
private static bool IsAssemblyLoaded(string simpleName) =>
AppDomain.CurrentDomain.GetAssemblies().Any(a => string.Equals(a.GetName().Name, simpleName, StringComparison.OrdinalIgnoreCase));
+ /// The Service menu is shown by FEATURE-DETECTING the Windows service machinery (so it can
+ /// appear on Win7 too if the .NET service layer loads there), not by a hardcoded Windows version. The
+ /// probe must be stable/cached and must never throw. On this Win10/11 gate runner the machinery loads,
+ /// so it must report available; the "can't load → hidden" path can only be exercised on an OS where the
+ /// assembly genuinely won't load, but the try/catch that guarantees it degrades safely is verified here
+ /// by the probe never throwing.
+ private static string? ServiceCapabilityProbe()
+ {
+ bool first = ServiceCapability.IsAvailable();
+ bool second = ServiceCapability.IsAvailable();
+ Check(first == second, "the capability result must be stable across calls (cached)");
+ Check(first, "the Windows service machinery must be detected as available on this Windows 10/11 test runner");
+ return "service machinery feature-detected as available, and the result is cached";
+ }
+
private static int CountControls(Control root, Func predicate)
{
var n = 0;
diff --git a/src/RemSound.App/ServiceCapability.cs b/src/RemSound.App/ServiceCapability.cs
new file mode 100644
index 0000000..d2524e7
--- /dev/null
+++ b/src/RemSound.App/ServiceCapability.cs
@@ -0,0 +1,48 @@
+using System.Runtime.CompilerServices;
+
+namespace RemSound.App;
+
+///
+/// Feature-detects whether the send-only Windows service can be offered on THIS machine — by actually
+/// trying to load the service machinery, not by checking a Windows version number.
+///
+/// Why feature-detect instead of "Windows 10 or newer": Windows 7 has a full Service Control Manager,
+/// so the service could work there IF the .NET service layer (System.ServiceProcess) loads on it.
+/// That's the real unknown — .NET 10 isn't officially supported on Win7 — so we simply try, and offer the
+/// service wherever the attempt succeeds (Win7 included) while hiding it safely wherever it doesn't.
+///
+/// The load attempt is isolated in and marked NoInlining on purpose: the reference
+/// to the System.ServiceProcess types lives ONLY there, so the assembly load is triggered by the
+/// call to Probe (inside 's try/catch) and is therefore catchable — instead
+/// of happening when IsAvailable itself is compiled, which would be an unrecoverable crash on an OS where
+/// that assembly won't load. This is the same failure that took the app down at launch before the startup
+/// path was made service-type-free; here it degrades to "hide the menu" instead.
+///
+internal static class ServiceCapability
+{
+ private static bool? cached;
+
+ /// True when the Windows service machinery loads and is usable on this OS/runtime. Cached after
+ /// the first check; never throws.
+ public static bool IsAvailable()
+ {
+ if (cached is { } c) return c;
+ bool ok;
+ try { ok = Probe(); }
+ catch { ok = false; } // System.ServiceProcess couldn't load here — offer nothing, stay safe
+ cached = ok;
+ return ok;
+ }
+
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ private static bool Probe()
+ {
+ // Constructing a ServiceController forces the System.ServiceProcess assembly to load — the exact
+ // step that fails on an OS the .NET service layer doesn't support. The PARAMETERLESS ctor touches
+ // no service and no Service Control Manager, so it can't throw for a benign reason (naming a made-up
+ // service and reading a property WOULD hit the SCM and throw "not found"). The construction succeeding
+ // is all we need to know the machinery loads here; a real service is opened later via ServiceControl.
+ using var probe = new System.ServiceProcess.ServiceController();
+ return true;
+ }
+}