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