diff --git a/src/RemSound.App/RemSoundService.cs b/src/RemSound.App/RemSoundService.cs index f604e45..bae748d 100644 --- a/src/RemSound.App/RemSoundService.cs +++ b/src/RemSound.App/RemSoundService.cs @@ -48,6 +48,10 @@ public sealed class RemSoundService : ServiceBase try { ServiceStore.SaveStatus(new ServiceStore.ServiceStatus { Version = versionText, StartedUtc = DateTime.UtcNow }); } catch { } // If this start is the completion of a self-update restart, close the loop in the update log. try { if (ServiceStore.ConsumeUpdatePending()) ServiceStore.AppendUpdateLog($"update complete: now running version {versionText}"); } catch { } + // Startup volume (Additional service options): unmute + set the default output's level, + // either on the first start after each boot or on every start. Before the host spins up so + // the machine is audible by the time audio flows. + StartupVolume.ApplyIfConfigured(msg => log?.Event(msg)); host = ServiceSendHost.FromConfig(msg => log?.Event(msg)); worker = new Thread(() => { diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index 7922cde..5ee3d80 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -115,6 +115,7 @@ internal static class SelfTest RunStep(results, "Sealed remote control (auth + replay + skew) + nonce discipline", SealedRemoteControl); RunStep(results, "Service folder lockdown args (cross-user LPE hardening)", ServiceDirHardeningArgs); RunStep(results, "Long-run hygiene (log rotation, crash-report cap, priority-mode scope)", LongRunHygiene); + RunStep(results, "Service startup volume (boot-once decision + settings round-trip)", ServiceStartupVolume); var failed = results.Count(r => r.Status == "FAIL"); var skipped = results.Count(r => r.Status == "SKIP"); @@ -2374,6 +2375,46 @@ internal static class SelfTest return "sealed + replay/stale/wrong-key/plaintext all rejected; skew tolerated; nonces counter-based"; } + /// The service's startup-volume option (2026-07-26 feature): the boot-once decision + /// core (apply on the FIRST start after each boot, or every start), and the machine-wide + /// settings round-trip — including that saving the volume option never clobbers the logging + /// flag sharing its file (load-modify-save). + private static string? ServiceStartupVolume() + { + // Decision core. Boot instants within the tolerance are the SAME boot. + var boot = new DateTime(2026, 7, 26, 6, 0, 0, DateTimeKind.Utc); + Check(!StartupVolume.ShouldApply(false, true, null, boot), "disabled → never applies"); + Check(StartupVolume.ShouldApply(true, true, null, boot), "boot-only with no marker yet → applies (first ever start)"); + Check(!StartupVolume.ShouldApply(true, true, boot.AddSeconds(-30), boot), "boot-only, marker from THIS boot → skipped (a same-boot service restart must not re-blast the volume)"); + Check(StartupVolume.ShouldApply(true, true, boot.AddHours(-9), boot), "boot-only, marker from a PREVIOUS boot → applies again"); + Check(StartupVolume.ShouldApply(true, false, boot.AddSeconds(-30), boot), "every-start mode ignores the marker entirely"); + + // Settings round-trip in a throwaway store; the volume save must preserve the logging flag. + var savedOverride = ServiceStore.TestDirectoryOverride; + var tmp = Path.Combine(Path.GetTempPath(), "remsound-selftest-vol-" + Guid.NewGuid().ToString("N")); + try + { + ServiceStore.TestDirectoryOverride = tmp; + var defaults = ServiceStore.LoadStartupVolume(); + Check(!defaults.Enabled && defaults.BootOnly, "defaults: off (house rule) and boot-only"); + ServiceStore.SaveLoggingEnabled(true); + ServiceStore.SaveStartupVolume(enabled: true, percent: 140, bootOnly: false); + var v = ServiceStore.LoadStartupVolume(); + Check(v.Enabled && v.Percent == 100 && !v.BootOnly, "volume settings round-trip, percent clamped to 100"); + Check(ServiceStore.LoadLoggingEnabled(), "saving the volume option must NOT clobber the logging flag in the shared file"); + ServiceStore.SaveLoggingEnabled(false); + Check(ServiceStore.LoadStartupVolume().Enabled, "saving the logging flag must NOT clobber the volume settings either"); + + // Boot marker round-trip. + Check(ServiceStore.LoadStartupVolumeBootMarker() is null, "no marker yet → null"); + ServiceStore.SaveStartupVolumeBootMarker(boot); + Check(ServiceStore.LoadStartupVolumeBootMarker() == boot, "the boot marker must round-trip exactly"); + } + finally { ServiceStore.TestDirectoryOverride = savedOverride; try { Directory.Delete(tmp, recursive: true); } catch { } } + + return "boot-once semantics exact; settings + marker round-trip; shared file never clobbered"; + } + /// 2026-07-26 resource audit trio: (1) the diagnostic log rolls to a fresh file at its /// size cap, so a multi-day always-logging session can never grow one unbounded file; (2) crash /// reports are capped at the newest N (nothing ever pruned crash-*.txt before); (3) the diff --git a/src/RemSound.App/ServiceProfileDialog.cs b/src/RemSound.App/ServiceProfileDialog.cs index 42ed22e..2b8f8cf 100644 --- a/src/RemSound.App/ServiceProfileDialog.cs +++ b/src/RemSound.App/ServiceProfileDialog.cs @@ -334,12 +334,17 @@ internal sealed class ServiceProfileDialog : Form private void ShowAdditionalOptions() { - var (dlg, logging) = BuildAdditionalOptions(ServiceLoggingEnabled); + var saved = ServiceStore.LoadStartupVolume(); + var (dlg, logging, volume) = BuildAdditionalOptions(ServiceLoggingEnabled, saved.Enabled, saved.Percent, saved.BootOnly); using (dlg) { if (ForegroundDialog.Show(owner => dlg.ShowDialog(owner)) == DialogResult.OK) { ServiceLoggingEnabled = logging.Checked; + // Startup volume persists straight to the machine-wide store (like the logging flag + // it sits beside, it's service behaviour, not part of the audio profile). The + // service reads it fresh on every start, so it takes effect from the next start. + ServiceStore.SaveStartupVolume(volume.Enabled.Checked, (int)volume.Percent.Value, volume.When.SelectedIndex == 0); } } } @@ -347,8 +352,12 @@ internal sealed class ServiceProfileDialog : Form /// Construction split from ShowDialog so the accessibility audit can inspect this inner /// dialog too. No connect/disconnect cue checkboxes here (removed 2026-07-19, review sweep): the /// headless service NEVER plays cues — nothing in the service host touches CuePlayer, and a - /// logged-out session couldn't render them anyway. The cue fields stay on Profile for the app. - internal static (Form Dialog, AccessibleCheckBox Logging) BuildAdditionalOptions(bool loggingEnabled) + /// logged-out session couldn't render them anyway. The cue fields stay on Profile for the app. + /// Startup volume (2026-07-26 feature): unmute + set the default output's level when the service + /// starts — the WHEN list picks "first start after each boot" (default) or "every start". + internal static (Form Dialog, AccessibleCheckBox Logging, + (AccessibleCheckBox Enabled, NumericUpDown Percent, ComboBox When) Volume) + BuildAdditionalOptions(bool loggingEnabled, bool volumeEnabled = false, int volumePercent = 50, bool volumeBootOnly = true) { var dlg = new Form { @@ -358,17 +367,63 @@ internal sealed class ServiceProfileDialog : Form MaximizeBox = false, ShowInTaskbar = false, StartPosition = FormStartPosition.CenterParent, - ClientSize = new Size(460, 110), + ClientSize = new Size(560, 240), AccessibleName = "Additional service options", }; var logging = new AccessibleCheckBox { Text = "Enable service &logging (Alt+L)", AccessibleName = "Enable service logging", AutoSize = true, Checked = loggingEnabled }; + + var volEnabled = new AccessibleCheckBox + { + Text = "Set the machine's &volume when the service starts", + AccessibleName = "Set the machine's volume when the service starts", + AutoSize = true, + Checked = volumeEnabled, + }; + var volPercent = new NumericUpDown + { + Minimum = 0, + Maximum = 100, + Value = Math.Clamp(volumePercent, 0, 100), + Width = 70, + AccessibleName = "Volume percent", + }; + var volPercentLabel = new MnemonicLabel { Text = "Volume &percent (also unmutes):", MnemonicTarget = volPercent, AutoSize = true }; + var volWhen = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Width = 280, AccessibleName = "When to set the volume" }; + volWhen.Items.Add("Only the first start after each boot"); + volWhen.Items.Add("Every time the service starts"); + volWhen.SelectedIndex = volumeBootOnly ? 0 : 1; + var volWhenLabel = new MnemonicLabel { Text = "&When:", MnemonicTarget = volWhen, AutoSize = true }; + void SyncVolumeEnabled() + { + volPercent.Enabled = volEnabled.Checked; + volWhen.Enabled = volEnabled.Checked; + } + volEnabled.CheckedChanged += (_, _) => SyncVolumeEnabled(); + SyncVolumeEnabled(); + var ok = new Button { Text = "&OK", AutoSize = true, DialogResult = DialogResult.OK }; var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, Padding = new Padding(12), AutoSize = true }; - foreach (var c in new Control[] { logging, ok }) { var w = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill }; w.Controls.Add(c); layout.Controls.Add(w); } + foreach (var single in new Control[] { logging, volEnabled }) + { + var w = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill }; + w.Controls.Add(single); + layout.Controls.Add(w); + } + var percentRow = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill }; + percentRow.Controls.Add(volPercentLabel); + percentRow.Controls.Add(volPercent); + layout.Controls.Add(percentRow); + var whenRow = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill }; + whenRow.Controls.Add(volWhenLabel); + whenRow.Controls.Add(volWhen); + layout.Controls.Add(whenRow); + var okRow = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill }; + okRow.Controls.Add(ok); + layout.Controls.Add(okRow); dlg.Controls.Add(layout); dlg.AcceptButton = ok; - return (dlg, logging); + return (dlg, logging, (volEnabled, volPercent, volWhen)); } private static Profile CloneProfile(Profile p) => diff --git a/src/RemSound.App/StartupVolume.cs b/src/RemSound.App/StartupVolume.cs new file mode 100644 index 0000000..995c7aa --- /dev/null +++ b/src/RemSound.App/StartupVolume.cs @@ -0,0 +1,65 @@ +using RemSound.Core; + +namespace RemSound.App; + +/// +/// The service's "unmute the machine and set volume to X% when the service starts" option +/// (Additional service options; feature request 2026-07-26). Two timing modes, chosen by a list in +/// the dialog: only the FIRST service start after each boot (the default — a mid-day manual +/// restart then never blasts the volume back up while someone is using the machine), or EVERY +/// service start. Boot identity comes from the system uptime clock, persisted in a marker file, so +/// "first start after boot" survives same-boot service restarts and still re-fires after a reboot. +/// +internal static class StartupVolume +{ + /// Two boot instants within this window are the SAME boot. Generous: the instant is + /// computed from now-minus-uptime, which drifts a little between computations (timer + /// granularity, clock adjustments); a real reboot separates instants by minutes at least. + internal static readonly TimeSpan SameBootTolerance = TimeSpan.FromMinutes(2); + + /// When THIS boot began (UTC), from the monotonic uptime counter. + public static DateTime CurrentBootUtc() => DateTime.UtcNow - TimeSpan.FromMilliseconds(Environment.TickCount64); + + /// Pure decision core, pinned by the self-test: apply when enabled, and — in boot-only + /// mode — when the recorded marker belongs to a DIFFERENT boot (or there is no marker yet). + internal static bool ShouldApply(bool enabled, bool bootOnly, DateTime? markerBootUtc, DateTime currentBootUtc) + { + if (!enabled) return false; + if (!bootOnly) return true; + if (markerBootUtc is null) return true; + return (currentBootUtc - markerBootUtc.Value).Duration() > SameBootTolerance; + } + + /// Called from the service's OnStart. Reads the option, decides, applies via the same + /// endpoint-volume helper the remote-control commands use, and records the boot marker on a + /// successful apply. Never throws — a volume hiccup must not stop the service starting. + public static void ApplyIfConfigured(Action? log) + { + try + { + var (enabled, percent, bootOnly) = ServiceStore.LoadStartupVolume(); + if (!enabled) return; + var boot = CurrentBootUtc(); + if (!ShouldApply(enabled, bootOnly, ServiceStore.LoadStartupVolumeBootMarker(), boot)) + { + log?.Invoke("service: startup volume skipped — already applied this boot (boot-only mode)"); + return; + } + var ok = SystemVolumeHelper.TrySetVolumeAndUnmute(percent); + var outcome = ok + ? $"startup volume applied — default output set to {percent}% and unmuted ({(bootOnly ? "first start after boot" : "every service start")})" + : "startup volume FAILED — could not reach the default output device (will retry on the next qualifying start)"; + log?.Invoke($"service: {outcome}"); + // Also into the always-on events log (not gated on the logging toggle): one line per + // qualifying start, so "did it fire?" is answerable without turning full logging on. + ServiceStore.AppendServiceEvent(outcome); + // Marker only on success: a boot-time failure (audio stack not up yet) leaves the next + // same-boot restart eligible to retry rather than silently never applying. + if (ok) ServiceStore.SaveStartupVolumeBootMarker(boot); + } + catch (Exception ex) + { + log?.Invoke($"service: startup volume error {ex.GetType().Name}: {ex.Message}"); + } + } +} diff --git a/src/RemSound.App/SystemVolumeHelper.cs b/src/RemSound.App/SystemVolumeHelper.cs index 7625f02..dd4f0c7 100644 --- a/src/RemSound.App/SystemVolumeHelper.cs +++ b/src/RemSound.App/SystemVolumeHelper.cs @@ -64,6 +64,16 @@ internal static class SystemVolumeHelper } } + /// Sets the default render device's master volume to and + /// unmutes it, in one call. The service's startup-volume option uses this so a machine that + /// booted muted (or was left turned down) is audible again without anyone at the keyboard. + /// Master endpoint volume is device-global, so this works from session 0 too. + public static bool TrySetVolumeAndUnmute(int percent) => TryDo(v => + { + v.MasterVolumeLevelScalar = Math.Clamp(percent, 0, 100) / 100f; + v.Mute = false; + }); + private static bool TryDo(Action action) { lock (cacheLock) diff --git a/src/RemSound.Core/ServiceStore.cs b/src/RemSound.Core/ServiceStore.cs index ad9b94f..5dad9a3 100644 --- a/src/RemSound.Core/ServiceStore.cs +++ b/src/RemSound.Core/ServiceStore.cs @@ -57,20 +57,13 @@ public static class ServiceStore /// Whether the service writes its own log. Machine-wide (the service can't read the user's /// per-account setting). Off by default. - public static bool LoadLoggingEnabled() - { - try - { - if (!File.Exists(SettingsPath)) return false; - return (JsonSerializer.Deserialize(File.ReadAllText(SettingsPath))?.LoggingEnabled) ?? false; - } - catch { return false; } - } + public static bool LoadLoggingEnabled() => LoadSettings().LoggingEnabled; public static void SaveLoggingEnabled(bool enabled) { - System.IO.Directory.CreateDirectory(Directory); - File.WriteAllText(SettingsPath, JsonSerializer.Serialize(new ServiceSettings { LoggingEnabled = enabled })); + var s = LoadSettings(); // load-modify-save: the file also carries the startup-volume option + s.LoggingEnabled = enabled; + SaveSettings(s); } /// True once a service profile has been configured. @@ -218,5 +211,72 @@ public static class ServiceStore return false; } - private sealed class ServiceSettings { public bool LoggingEnabled { get; set; } } + private sealed class ServiceSettings + { + public bool LoggingEnabled { get; set; } + // Startup volume (Additional service options): unmute the machine and set the default + // output's volume when the service starts. Off by default (house rule: persistence + // defaults off); boot-only by default so a mid-day manual service restart doesn't blast + // the volume back up while someone's using the machine. + public bool StartupVolumeEnabled { get; set; } + public int StartupVolumePercent { get; set; } = 50; + public bool StartupVolumeBootOnly { get; set; } = true; + } + + private static ServiceSettings LoadSettings() + { + try + { + return File.Exists(SettingsPath) + ? JsonSerializer.Deserialize(File.ReadAllText(SettingsPath)) ?? new ServiceSettings() + : new ServiceSettings(); + } + catch { return new ServiceSettings(); } + } + + private static void SaveSettings(ServiceSettings s) + { + System.IO.Directory.CreateDirectory(Directory); + File.WriteAllText(SettingsPath, JsonSerializer.Serialize(s)); + } + + /// The startup-volume option: (enabled, percent 0-100, boot-only vs every start). + public static (bool Enabled, int Percent, bool BootOnly) LoadStartupVolume() + { + var s = LoadSettings(); + return (s.StartupVolumeEnabled, Math.Clamp(s.StartupVolumePercent, 0, 100), s.StartupVolumeBootOnly); + } + + public static void SaveStartupVolume(bool enabled, int percent, bool bootOnly) + { + var s = LoadSettings(); // load-modify-save: never clobber the other settings in the file + s.StartupVolumeEnabled = enabled; + s.StartupVolumePercent = Math.Clamp(percent, 0, 100); + s.StartupVolumeBootOnly = bootOnly; + SaveSettings(s); + } + + // === Startup-volume boot marker === + // Records WHICH boot the volume was last applied in, so "only the first start after boot" + // survives service restarts within the same boot (a plain "did I run already" flag would + // reset on every restart and a boot-time crash-restart would re-apply forever). + private static string StartupVolumeMarkerPath => Path.Combine(Directory, "startup-volume-boot.txt"); + + public static void SaveStartupVolumeBootMarker(DateTime bootUtc) + { + try { System.IO.Directory.CreateDirectory(Directory); File.WriteAllText(StartupVolumeMarkerPath, bootUtc.ToString("o")); } + catch { /* best-effort */ } + } + + public static DateTime? LoadStartupVolumeBootMarker() + { + try + { + return File.Exists(StartupVolumeMarkerPath) + && DateTime.TryParse(File.ReadAllText(StartupVolumeMarkerPath).Trim(), null, + System.Globalization.DateTimeStyles.RoundtripKind, out var t) + ? t : null; + } + catch { return null; } + } }