Service startup volume: unmute + set level on boot or on every service start
Feature (requested 2026-07-26): Additional service options gains 'Set the machine's volume when the service starts' - a checkbox, a percent field (0-100, also unmutes), and a WHEN list: 'Only the first start after each boot' (default) or 'Every time the service starts'. For an unattended machine that boots muted or turned down, the service makes it audible again with nobody at the keyboard; boot-only mode means a mid-day manual service restart never blasts the volume while someone's using the box. Mechanics: settings live machine-wide beside the service logging flag (the service reads them fresh each start - no restart needed to change them); boot identity comes from now-minus-uptime persisted in a marker file, so 'first start after boot' survives same-boot service restarts and re-fires after a real reboot; the marker is only written on a SUCCESSFUL apply, so a boot-time audio-stack race retries on the next qualifying start. Applies via the same endpoint-volume helper the remote-control commands use (master endpoint volume is device-global, so session 0 works). Outcome logged to the always-on service events log. The new round-trip test immediately caught SaveLoggingEnabled clobbering the volume fields in the shared settings file - rewired both savers to load-modify-save. The dialog audit picks up the new controls (mnemonics + names) automatically. Gate 65/65. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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(() =>
|
||||
{
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
/// <summary>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).</summary>
|
||||
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";
|
||||
}
|
||||
|
||||
/// <summary>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
|
||||
|
||||
@@ -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
|
||||
/// <summary>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.</summary>
|
||||
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".</summary>
|
||||
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) =>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static class StartupVolume
|
||||
{
|
||||
/// <summary>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.</summary>
|
||||
internal static readonly TimeSpan SameBootTolerance = TimeSpan.FromMinutes(2);
|
||||
|
||||
/// <summary>When THIS boot began (UTC), from the monotonic uptime counter.</summary>
|
||||
public static DateTime CurrentBootUtc() => DateTime.UtcNow - TimeSpan.FromMilliseconds(Environment.TickCount64);
|
||||
|
||||
/// <summary>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).</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public static void ApplyIfConfigured(Action<string>? 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,16 @@ internal static class SystemVolumeHelper
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sets the default render device's master volume to <paramref name="percent"/> 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.</summary>
|
||||
public static bool TrySetVolumeAndUnmute(int percent) => TryDo(v =>
|
||||
{
|
||||
v.MasterVolumeLevelScalar = Math.Clamp(percent, 0, 100) / 100f;
|
||||
v.Mute = false;
|
||||
});
|
||||
|
||||
private static bool TryDo(Action<AudioEndpointVolume> action)
|
||||
{
|
||||
lock (cacheLock)
|
||||
|
||||
@@ -57,20 +57,13 @@ public static class ServiceStore
|
||||
|
||||
/// <summary>Whether the service writes its own log. Machine-wide (the service can't read the user's
|
||||
/// per-account setting). Off by default.</summary>
|
||||
public static bool LoadLoggingEnabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(SettingsPath)) return false;
|
||||
return (JsonSerializer.Deserialize<ServiceSettings>(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);
|
||||
}
|
||||
|
||||
/// <summary>True once a service profile has been configured.</summary>
|
||||
@@ -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<ServiceSettings>(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));
|
||||
}
|
||||
|
||||
/// <summary>The startup-volume option: (enabled, percent 0-100, boot-only vs every start).</summary>
|
||||
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; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user