diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index 073b33c..0b6ccd0 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -2182,14 +2182,16 @@ public sealed class MainForm : Form // and the arrow keys. menu.Items.Add(fileMenu); menu.Items.Add(recordMenu); - // 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()) + // Offer the Service menu on Windows 10+ only. This is a VERSION check on purpose, not a runtime + // probe: a probe would have to construct a ServiceController to test it, which loads + // System.ServiceProcess — the exact assembly that won't load on Windows 7 — during window + // construction on EVERY launch. Deciding by version is free (it touches no service type), so on + // Windows 7/8 the menu is simply hidden and that assembly is never referenced at launch at all, + // which is what keeps the app launching there. On Windows 10+ the assembly loads only later, if the + // user actually opens the Service menu (the DropDownOpening handler, wrapped in try/catch). Feature- + // detecting on Win7 and keeping Win7 launch-safe are mutually exclusive — you can't test whether the + // assembly loads without loading it — so we choose guaranteed launch safety. + if (OperatingSystem.IsWindowsVersionAtLeast(10, 0)) 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 145f581..4e196cf 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -81,7 +81,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); + RunStep(results, "Main window builds without loading the service assembly (Win7-safe)", MainWindowServiceAssemblyFree); RunStep(results, "Menu shortcuts don't clash with controls", MenuShortcutsDontClashWithControls); RunStep(results, "Service log discovery (newest activity log)", ServiceLogDiscovery); @@ -1356,13 +1356,26 @@ internal static class SelfTest /// 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() + /// The Win7 launch guarantee, directly: building the main window (which builds the menu bar) + /// must NOT load System.ServiceProcess. The Service menu's visibility is decided by a Windows-VERSION + /// check, which touches no service type — so the service assembly is only ever loaded later, if a + /// Windows-10+ user opens the Service menu. On Windows 7 that decision hides the menu and the assembly + /// (which won't load there) is never referenced at launch, which is what keeps the app starting. + private static string? MainWindowServiceAssemblyFree() { - 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"; + const string svcAsm = "System.ServiceProcess.ServiceController"; + bool loadedBefore = IsAssemblyLoaded(svcAsm); + + Form form; + try { form = new MainForm(null, RemSound.Core.Profile.NewBlank(), null, null, headless: true); } + catch (Exception ex) { return Skip($"headless MainForm could not be constructed: {ex.GetType().Name}: {ex.Message}"); } + using (form) { } + + if (loadedBefore) + return "service assembly already loaded by an earlier step; main-window load-safety not re-checked this run"; + Check(!IsAssemblyLoaded(svcAsm), + "constructing the main window must NOT load System.ServiceProcess (Service menu is version-gated, not probed) — this is what keeps the app launching on Windows 7"); + return "the main window builds without loading the Windows-service assembly (Win7 launch-safe)"; } /// A top-level menu opens on Alt+<its mnemonic> — but a VISIBLE control that owns the same diff --git a/src/RemSound.App/ServiceCapability.cs b/src/RemSound.App/ServiceCapability.cs deleted file mode 100644 index d2524e7..0000000 --- a/src/RemSound.App/ServiceCapability.cs +++ /dev/null @@ -1,48 +0,0 @@ -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; - } -}