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; }
+ }
}