diff --git a/src/RemSound.App/Program.cs b/src/RemSound.App/Program.cs index a41cf86..3316701 100644 --- a/src/RemSound.App/Program.cs +++ b/src/RemSound.App/Program.cs @@ -66,7 +66,15 @@ internal static class Program // a status code. --run-service blocks in the SCM dispatcher until Windows stops the service. if (args.Length > 0) { - if (HasArg(args, ServiceControl.RunVerb)) { RemSoundService.RunAsService(); return; } + if (HasArg(args, ServiceControl.RunVerb)) + { + // Point the service's data (its log) at the machine-wide ProgramData location, next to its + // profile — otherwise a headless SYSTEM service logs into the SYSTEM account's AppData, + // which is near-impossible to find. (The profile itself always comes from ServiceStore.) + AppConfig.SetUserDataDirectoryOverride(ServiceStore.Directory); + RemSoundService.RunAsService(); + return; + } if (HasArg(args, ServiceControl.InstallVerb)) { Environment.ExitCode = ServiceControl.DoInstall(); return; } if (HasArg(args, ServiceControl.UninstallVerb)) { Environment.ExitCode = ServiceControl.DoUninstall(); return; } if (HasArg(args, ServiceControl.StartVerb)) { Environment.ExitCode = ServiceControl.DoStart(); return; } diff --git a/src/RemSound.App/RemSoundService.cs b/src/RemSound.App/RemSoundService.cs index 2411ccf..cf49cbc 100644 --- a/src/RemSound.App/RemSoundService.cs +++ b/src/RemSound.App/RemSoundService.cs @@ -22,6 +22,17 @@ public sealed class RemSoundService : ServiceBase CanStop = true; CanShutdown = true; CanPauseAndContinue = false; + CanHandlePowerEvent = true; // so we can re-open capture after the machine wakes from sleep + } + + protected override bool OnPowerEvent(PowerBroadcastStatus powerStatus) + { + if (powerStatus is PowerBroadcastStatus.ResumeSuspend or PowerBroadcastStatus.ResumeAutomatic or PowerBroadcastStatus.ResumeCritical) + { + log?.Event("service: power resume — re-opening capture"); + try { host?.ReopenAfterResume(); } catch (Exception ex) { log?.Event($"service: resume re-open failed {ex.GetType().Name}: {ex.Message}"); } + } + return true; } protected override void OnStart(string[] args) diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index 3eea62d..b283492 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -676,7 +676,13 @@ internal static class SelfTest Check(args.Contains("\\\"" + exe + "\\\" " + ServiceControl.RunVerb), $"exe path must be escaped-quoted with the run verb (got: {args})"); Check(args.Contains($"DisplayName= \"{ServiceControl.DisplayName}\""), "must set the display name"); - return "sc create args quoted correctly for a spaced path"; + Check(args.Contains("depend= Audiosrv"), "must depend on the audio service so it starts after audio is up"); + + // Auto-restart-on-crash failure actions. + var fail = ServiceControl.BuildFailureArgs(); + Check(fail.StartsWith($"failure {ServiceControl.ServiceName} ") && fail.Contains("actions= restart/"), + $"failure args must configure auto-restart (got: {fail})"); + return "sc create + failure args are well-formed"; } /// The service profile is fully isolated from the normal profile machinery: it lives in a diff --git a/src/RemSound.App/ServiceControl.cs b/src/RemSound.App/ServiceControl.cs index 44d6e4d..9a7ee92 100644 --- a/src/RemSound.App/ServiceControl.cs +++ b/src/RemSound.App/ServiceControl.cs @@ -98,9 +98,17 @@ public static class ServiceControl if (rc != 0) return rc; // Best-effort description; failure here doesn't fail the install. RunSc($"description {ServiceName} \"{Description}\""); + // Auto-restart on crash: without this a crashed service stays dead until reboot, which defeats + // an always-on streamer. Restart 5s / 10s / then every 60s; reset the failure counter daily. + RunSc(BuildFailureArgs()); return 0; } + /// The sc.exe "failure" args that make the service auto-restart on a crash. Pure, so a + /// self-test can verify the format. + internal static string BuildFailureArgs() => + $"failure {ServiceName} reset= 86400 actions= restart/5000/restart/10000/restart/60000"; + /// Stops (if running) and deletes the service. Must be run elevated. Returns 0 on success or /// if it wasn't installed. public static int DoUninstall() diff --git a/src/RemSound.App/ServiceSendHost.cs b/src/RemSound.App/ServiceSendHost.cs index 8c45cdd..9acb9d0 100644 --- a/src/RemSound.App/ServiceSendHost.cs +++ b/src/RemSound.App/ServiceSendHost.cs @@ -166,6 +166,20 @@ public sealed class ServiceSendHost : IDisposable Suspend(); } + /// Force a re-open of capture if we intend to send — called after a power resume, when the + /// audio devices have re-initialised and the current capture may be dead. The device-change watcher + /// usually catches this too, but a resume doesn't always fire an endpoint change, so we re-open + /// explicitly to be safe. + public void ReopenAfterResume() + { + if (!wantSending || disposed) return; + var profile = loadProfile(); + if (profile is null) return; + log?.Invoke("service: re-opening capture after power resume"); + Suspend(); + ApplyProfile(profile); + } + /// Device-set change callback (COM thread). While we intend to send, (re)open capture — this /// is the event that fires when the audio stack finishes coming up at boot, a device is plugged or /// unplugged, or the audio service restarts. Debounced: a single hot-plug fires several notifications.