diff --git a/src/RemSound.App/AppInstaller.cs b/src/RemSound.App/AppInstaller.cs index d675720..b39bff5 100644 --- a/src/RemSound.App/AppInstaller.cs +++ b/src/RemSound.App/AppInstaller.cs @@ -186,7 +186,7 @@ internal static class AppInstaller if (wantService == DialogResult.Yes) { log?.Invoke("install: user opted to install the service too"); - var rc = ServiceControl.RunElevated(ServiceControl.InstallVerb); + var rc = RunElevatedResponsive(owner, ServiceControl.InstallVerb, "Installing the RemSound service..."); if (rc == 0) { // Offer to start it now — otherwise it only comes up at the next boot, so a @@ -199,7 +199,7 @@ internal static class AppInstaller if (startNow == DialogResult.Yes) { log?.Invoke("install: user opted to start the service now"); - var startRc = ServiceControl.RunElevated(ServiceControl.StartVerb); + var startRc = RunElevatedResponsive(owner, ServiceControl.StartVerb, "Starting the RemSound service..."); MessageBox.Show(owner, startRc == 0 ? "The RemSound service is running." @@ -226,6 +226,45 @@ internal static class AppInstaller Environment.Exit(0); } + /// Run an elevated service verb while keeping the UI thread ALIVE. The install flow is + /// sequential (offer → install → offer start → start → relaunch), so a fire-and-forget task doesn't + /// fit — but blocking the UI thread in WaitForExit froze the window and any live audio (the same + /// hang class as the service install-freeze). This runs the elevated helper on a worker while a tiny + /// modal "working…" shell pumps messages: the app stays responsive, the user can't double-trigger + /// anything, NVDA announces what's happening, and the call still returns the exit code in-line. + private static int RunElevatedResponsive(IWin32Window owner, string verb, string statusText) + { + var rc = -1; + using var wait = new Form + { + Text = "RemSound", + FormBorderStyle = FormBorderStyle.FixedDialog, + StartPosition = FormStartPosition.CenterParent, + MinimizeBox = false, + MaximizeBox = false, + ControlBox = false, + ShowInTaskbar = false, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + Padding = new Padding(24), + AccessibleName = statusText, + }; + wait.Controls.Add(new Label + { + Text = statusText + Environment.NewLine + "Windows may ask for administrator permission.", + AutoSize = true, + AccessibleName = statusText, + }); + wait.Shown += (_, _) => Task.Run(() => + { + try { rc = ServiceControl.RunElevated(verb); } + catch { rc = -1; } + try { wait.BeginInvoke(new Action(wait.Close)); } catch { /* already closed */ } + }); + wait.ShowDialog(owner); + return rc; + } + // ---------------- uninstall ---------------- /// Options → "Uninstall RemSound from this PC", chosen from inside the running installed diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index 5823207..1b02a5a 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -2367,12 +2367,42 @@ public sealed class MainForm : Form logFile.Event($"service: profile saved (service logging {(dlg.ServiceLoggingEnabled ? "on" : "off")})"); // Remove any stray copy the old design left in the user's profiles folder. try { profileStore?.Delete(ServiceControl.ServiceProfileTitle); } catch { /* best-effort */ } - if (ServiceControl.Query() == ServiceState.Running) + var restartNeeded = ServiceControl.Query() == ServiceState.Running; + if (restartNeeded) { - ServiceControl.RunElevated(ServiceControl.StopVerb); - ServiceControl.RunElevated(ServiceControl.StartVerb); + // Restart OFF the UI thread — the old inline RunElevated stop+start pair here blocked the + // window (and its audio) through two UAC prompts; same bug class as the install hang. + // No-UAC first: the installer granted this account start/stop rights, so a plain SCM + // restart normally needs no elevation at all. Elevated verbs are the fallback (service + // installed by a different account, grant missing). Only a FAILURE is reported back; + // success needs no second popup. + ServiceStore.AppendServiceEvent("restart requested (service profile changed)"); + Task.Run(() => + { + var ok = ServiceControl.TryRestartNoAdmin(); + if (!ok) + { + ServiceControl.RunElevated(ServiceControl.StopVerb); + ok = ServiceControl.RunElevated(ServiceControl.StartVerb) == 0; + } + ServiceStore.AppendServiceEvent(ok + ? "restart finished (new service profile is live)" + : "restart FAILED after profile change"); + if (ok || IsDisposed) return; + try + { + BeginInvoke(new Action(() => MessageBox.Show(this, + "The service profile was saved, but the running service could not be restarted to pick it up. Use the Service menu to stop and start it.", + AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning))); + } + catch { /* window closing */ } + }); } - MessageBox.Show(this, "Service profile saved.", AppName, MessageBoxButtons.OK, MessageBoxIcon.Information); + MessageBox.Show(this, + restartNeeded + ? "Service profile saved. The running service is restarting to pick it up." + : "Service profile saved.", + AppName, MessageBoxButtons.OK, MessageBoxIcon.Information); } catch (Exception ex) { diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index 75ef55c..718c825 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -65,6 +65,7 @@ internal static class SelfTest RunStep(results, "Service app-yield token", ServiceInteractivePresence); RunStep(results, "Service sender parity (crypto + Opus frame)", ServiceSenderParity); RunStep(results, "Elevated helper: no pipe deadlock on flooded output", ServiceProcessCaptureNoDeadlock); + RunStep(results, "No-admin service restart fails safe (missing service)", ServiceRestartNoAdminFailsSafe); RunStep(results, "Default-output follower (service follows Windows default)", DefaultOutputFollower); RunStep(results, "Default follower exclusivity (locks out specific cards)", DefaultFollowerExclusivity); RunStep(results, "Service profile isolation (location + hidden from pickers)", ServiceProfileIsolation); @@ -945,6 +946,20 @@ internal static class SelfTest return "follower flagged + sentinel shared with the app; service resolves it to the live default render endpoint"; } + /// The no-UAC restart used after a service-profile save (TryRestartNoAdmin) must FAIL SAFE: + /// against a service that doesn't exist it returns false, promptly, and never throws — that false is + /// what routes the caller onto the elevated fallback. (The success path needs the real installed + /// service + granted rights, so it's covered by hand-testing, not the gate.) + private static string? ServiceRestartNoAdminFailsSafe() + { + var sw = Stopwatch.StartNew(); + var ok = ServiceControl.TryRestartNoAdmin("RemSoundSelfTestNoSuchService"); + sw.Stop(); + Check(!ok, "restarting a non-existent service must report false, not throw"); + Check(sw.ElapsedMilliseconds < 5000, $"the failure must be prompt (took {sw.ElapsedMilliseconds} ms)"); + return $"missing service → false in {sw.ElapsedMilliseconds} ms, no throw"; + } + /// Reproduces the install-hang condition and proves it's fixed: a child that floods BOTH /// stdout and stderr far past the ~4 KB pipe buffer (a big directory listing plus a failing dir). The /// old "read stderr to end, then stdout" order deadlocked exactly here (icacls /T over the 100-file diff --git a/src/RemSound.App/ServiceControl.cs b/src/RemSound.App/ServiceControl.cs index 0c5a52f..6e645f1 100644 --- a/src/RemSound.App/ServiceControl.cs +++ b/src/RemSound.App/ServiceControl.cs @@ -251,6 +251,33 @@ public static class ServiceControl return rc; } + /// Restart the service WITHOUT elevation, using the start/stop rights the installer granted + /// the installing user (the SDDL ACE from ). Returns true when the + /// service ends up Running. No UAC prompt, no elevated helper — so it's safe to run from a background + /// thread after a profile save. Returns false (never throws) when the caller lacks rights, the service + /// isn't installed, or a state wait times out; callers fall back to the elevated verbs then. + /// exists for the self-test (probe a non-existent name). + public static bool TryRestartNoAdmin(string? serviceNameOverride = null) + { + try + { + using var sc = new ServiceController(serviceNameOverride ?? ServiceName); + if (sc.Status is not (ServiceControllerStatus.Stopped or ServiceControllerStatus.StopPending)) + { + sc.Stop(); + sc.WaitForStatus(ServiceControllerStatus.Stopped, TimeSpan.FromSeconds(15)); + } + else + { + sc.WaitForStatus(ServiceControllerStatus.Stopped, TimeSpan.FromSeconds(15)); + } + sc.Start(); + sc.WaitForStatus(ServiceControllerStatus.Running, TimeSpan.FromSeconds(15)); + return true; + } + catch { return false; } + } + /// Starts the service. Must be run elevated. Returns 0 on success. public static int DoStart() {