Offer the service on Win7+ by feature-detecting, not version-gating

Replaces the hardcoded "Windows 10 or newer" gate on the Service menu with a
runtime capability probe (ServiceCapability). It tries to load the .NET
service machinery (System.ServiceProcess) and offers the Service menu wherever
that succeeds -- including Windows 7 if the service layer loads there, which is
what Ed asked for ("install on Win7 or above"). If the assembly can't load on
an older/unsupported Windows, the probe catches it and the menu is simply
hidden, so such a machine can never be crashed by it.

Safety by construction: the reference to the service types lives only in the
NoInlining Probe method, so the assembly load is triggered by the CALL from
IsAvailable (inside its try/catch) and is catchable -- not during JIT of the
caller, which would be fatal. The startup path stays service-type-free via
ServiceEntry, so this probe runs only at window construction, never at launch.

Probe uses the parameterless ServiceController ctor: it forces the assembly to
load but touches no service and no SCM. (First cut read .ServiceName on a
made-up name, which actually queries the SCM and threw "service not found" --
the self-test caught that it was hiding the menu on Windows 11 too.)

New self-test "Service capability probe": available + cached + never throws on
the Win10/11 gate runner. Gate 30/30.

Note: this makes the service INSTALLABLE on Win7 wherever the layer loads; it
does not prove the service RUNS there (Session-0 capture on an unsupported
runtime) -- only a real test on the Win7 box can confirm that.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-14 08:55:16 +01:00
co-authored by Claude Opus 4.8
parent 9392cc1fcc
commit 2dad00c42f
3 changed files with 72 additions and 6 deletions
+8 -6
View File
@@ -2182,12 +2182,14 @@ public sealed class MainForm : Form
// and the arrow keys. // and the arrow keys.
menu.Items.Add(fileMenu); menu.Items.Add(fileMenu);
menu.Items.Add(recordMenu); menu.Items.Add(recordMenu);
// The send-only Windows service is a Windows 10+ feature: it was built and tested there, and on // Offer the Service menu wherever the send-only service can actually run — feature-detected, not
// older Windows the System.ServiceProcess assembly it relies on won't even load under .NET 10. Only // gated on a Windows version, so it appears on Windows 7 too if the .NET service layer loads there
// offer the Service menu where it can actually work — on Win7/8 it simply isn't shown and no service // (Ed's ask: install on "Win7 or above" wherever it's supported). ServiceCapability probes safely:
// code is ever reached. (Mirrors how "capture individual apps" is gated to the versions that support // if System.ServiceProcess can't load on this OS, it catches that and the menu is simply hidden, so
// it.) The startup path is already load-safe via ServiceEntry; this keeps the menu load-safe too. // an older Windows can never be crashed by it. The startup path is already service-type-free via
if (OperatingSystem.IsWindowsVersionAtLeast(10, 0)) // 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(BuildServiceMenu());
menu.Items.Add(optionsMenu); menu.Items.Add(optionsMenu);
menu.Items.Add(helpMenu); menu.Items.Add(helpMenu);
+16
View File
@@ -80,6 +80,7 @@ internal static class SelfTest
RunStep(results, "Main window profile round-trip (controls load + save)", MainWindowProfileRoundTrip); 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, "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 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 failed = results.Count(r => r.Status == "FAIL");
var skipped = results.Count(r => r.Status == "SKIP"); var skipped = results.Count(r => r.Status == "SKIP");
@@ -1313,6 +1314,21 @@ internal static class SelfTest
private static bool IsAssemblyLoaded(string simpleName) => private static bool IsAssemblyLoaded(string simpleName) =>
AppDomain.CurrentDomain.GetAssemblies().Any(a => string.Equals(a.GetName().Name, simpleName, StringComparison.OrdinalIgnoreCase)); AppDomain.CurrentDomain.GetAssemblies().Any(a => string.Equals(a.GetName().Name, simpleName, StringComparison.OrdinalIgnoreCase));
/// <summary>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.</summary>
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<Control, bool> predicate) private static int CountControls(Control root, Func<Control, bool> predicate)
{ {
var n = 0; var n = 0;
+48
View File
@@ -0,0 +1,48 @@
using System.Runtime.CompilerServices;
namespace RemSound.App;
/// <summary>
/// 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.
///
/// <para>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 (<c>System.ServiceProcess</c>) 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.</para>
///
/// <para>The load attempt is isolated in <see cref="Probe"/> and marked NoInlining on purpose: the reference
/// to the <c>System.ServiceProcess</c> types lives ONLY there, so the assembly load is triggered by the
/// <em>call</em> to Probe (inside <see cref="IsAvailable"/>'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.</para>
/// </summary>
internal static class ServiceCapability
{
private static bool? cached;
/// <summary>True when the Windows service machinery loads and is usable on this OS/runtime. Cached after
/// the first check; never throws.</summary>
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;
}
}