Update install window: automatic updates only within a chosen daily time range

Feature (requested 2026-07-26): Preferences' update settings gain 'Only install
updates within this time range' - a checkbox plus Start/End time lists in 15-minute
steps (00:00-23:45, default 01:00-06:00). RemSound streams live audio, so an
automatic update mid-session kills someone's sound; with the range on, the startup
check and the background poll DEFER an available update until the range opens (a
one-shot timer retries right at the range start, so a 24-hourly poll can't keep
missing the window for days). Manual 'Check for updates now' is deliberately never
gated - asking by hand means now. End at-or-before start wraps past midnight
(22:00-06:00); start minute inclusive, end exclusive; an equal start/end means
unrestricted rather than a silent never-install trap. The service inherits the
window for free: its self-update follows the app's install.

readme: the two new Preferences rows, the service startup-volume option, and a
security note on password-locked remote volume (both ends need 5.6).

New gate step pins the window maths (same-day, wraparound, boundaries, empty-range
rule, retry arithmetic). Dialog audit covers the new controls. Gate 66/66.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-26 23:52:21 +01:00
co-authored by Claude Fable 5
parent bb94a56109
commit 9574d9d08b
6 changed files with 203 additions and 2 deletions
+40
View File
@@ -1425,6 +1425,7 @@ public sealed partial class MainForm : Form
deviceRefreshTimer.Stop(); deviceRefreshTimer.Dispose();
continuousTuneTimer.Stop(); continuousTuneTimer.Dispose();
updateCheckTimer.Stop(); updateCheckTimer.Dispose();
deferredUpdateTimer.Stop(); deferredUpdateTimer.Dispose();
asioDriverChangeDebounce.Stop(); asioDriverChangeDebounce.Dispose();
try { sendAppsReconcileTimer?.Stop(); sendAppsReconcileTimer?.Dispose(); } catch { }
DisposeSessionStartWatcher();
@@ -3138,6 +3139,7 @@ public sealed partial class MainForm : Form
// via the updater's Log callback.
if (result is not UpdateAvailable available) return;
var info = available.Info;
if (AutoInstallDeferredByWindow(info.Tag, "background poll")) return;
if (AppConfig.Load().SilentlyInstallUpdates)
{
// Notice the user before the app vanishes and the helper takes over. Hidden from
@@ -3198,6 +3200,7 @@ public sealed partial class MainForm : Form
if (result is not UpdateAvailable available) return;
var info = available.Info;
logFile.Event($"updater: startup check found {info.Tag}");
if (AutoInstallDeferredByWindow(info.Tag, "startup check")) return;
if (AppConfig.Load().SilentlyInstallUpdates)
{
// Heads-up the user before we exit and the helper takes over. The notice is its
@@ -3294,6 +3297,43 @@ public sealed partial class MainForm : Form
return s[..max] + "\n…";
}
// One-shot retry for an update found OUTSIDE the install window: without it, a 24-hourly
// poll could keep landing outside the window and defer the same update for days. Armed by
// AutoInstallDeferredByWindow to fire shortly after the window next opens.
private readonly System.Windows.Forms.Timer deferredUpdateTimer = new();
private bool deferredUpdateTimerWired;
/// <summary>The "only install updates within this time range" gate (Preferences). Applies to
/// AUTOMATIC installs only — the background poll and the startup check; a manual "Check for
/// updates now" is the user asking by hand and is never gated. When the window is closed,
/// logs, arms the retry for when it opens, and returns true (caller bails out).</summary>
private bool AutoInstallDeferredByWindow(string tag, string source)
{
var cfg = AppConfig.Load();
if (!cfg.UpdateWindowEnabled) return false;
var now = DateTime.Now.TimeOfDay;
if (UpdateWindow.IsWithin(now, cfg.UpdateWindowStartMinutes, cfg.UpdateWindowEndMinutes)) return false;
var wait = UpdateWindow.UntilNextStart(now, cfg.UpdateWindowStartMinutes) + TimeSpan.FromMinutes(1);
if (!deferredUpdateTimerWired)
{
deferredUpdateTimerWired = true;
deferredUpdateTimer.Tick += (_, _) =>
{
deferredUpdateTimer.Stop();
logFile.Event("updater: install window opened — re-running the deferred update check");
CheckForUpdatesInBackground();
};
}
deferredUpdateTimer.Stop();
deferredUpdateTimer.Interval = (int)Math.Clamp(wait.TotalMilliseconds, 60_000, int.MaxValue);
deferredUpdateTimer.Start();
logFile.Event($"updater: {source} found {tag}, but it's outside the install window "
+ $"({UpdateWindow.FormatMinutes(cfg.UpdateWindowStartMinutes)}{UpdateWindow.FormatMinutes(cfg.UpdateWindowEndMinutes)}) "
+ $"— deferred; retrying in {wait.TotalMinutes:0} min when the window opens");
return true;
}
/// <summary>Apply (or stop) the background update-poll timer based on
/// <see cref="AppConfig.UpdateCheckFrequency"/>. Called at startup and whenever the user
/// changes the dropdown in Preferences. The first tick fires after one interval — we
+70 -1
View File
@@ -291,6 +291,28 @@ internal sealed class PreferencesDialog : Form
AutoSize = true,
};
// "Only install updates within this time range" (2026-07-26 feature): automatic installs are
// deferred to a daily window — e.g. overnight — so an update never kills the sound while
// someone's mid-session. Ticking enables the two time lists (hours in 15-minute steps).
private readonly AccessibleCheckBox updateWindowBox = new()
{
Text = "Only install updates within this &time range",
AccessibleName = "Only install updates within this time range",
AutoSize = true,
};
private readonly ComboBox updateWindowStartBox = new()
{
DropDownStyle = ComboBoxStyle.DropDownList,
Width = 100,
AccessibleName = "Update window start time",
};
private readonly ComboBox updateWindowEndBox = new()
{
DropDownStyle = ComboBoxStyle.DropDownList,
Width = 100,
AccessibleName = "Update window end time",
};
// After an update installs and RemSound restarts, opening the About box once lets the user
// see what changed. Off by default (opt-in). 'h' mnemonic — 'w' is taken by "Write logs now".
private readonly AccessibleCheckBox showWhatsNewAfterUpdateBox = new()
@@ -719,6 +741,36 @@ internal sealed class PreferencesDialog : Form
cfg.SilentlyInstallUpdates = silentlyInstallUpdatesBox.Checked;
try { cfg.Save(); } catch { /* harmless */ }
};
// Update install window: 96 quarter-hour slots in each list ("00:00" … "23:45"). The lists
// only light up when the range is enabled; every change persists immediately like the
// neighbouring update options. End at-or-before start wraps past midnight (22:0006:00).
for (var slot = 0; slot < UpdateWindow.SlotsPerDay; slot++)
{
var text = UpdateWindow.FormatMinutes(slot * UpdateWindow.SlotMinutes);
updateWindowStartBox.Items.Add(text);
updateWindowEndBox.Items.Add(text);
}
updateWindowBox.Checked = cfgForLoad.UpdateWindowEnabled;
updateWindowStartBox.SelectedIndex = Math.Clamp(cfgForLoad.UpdateWindowStartMinutes / UpdateWindow.SlotMinutes, 0, UpdateWindow.SlotsPerDay - 1);
updateWindowEndBox.SelectedIndex = Math.Clamp(cfgForLoad.UpdateWindowEndMinutes / UpdateWindow.SlotMinutes, 0, UpdateWindow.SlotsPerDay - 1);
void SyncUpdateWindowEnabled()
{
updateWindowStartBox.Enabled = updateWindowBox.Checked;
updateWindowEndBox.Enabled = updateWindowBox.Checked;
}
SyncUpdateWindowEnabled();
void SaveUpdateWindow()
{
var cfg = AppConfig.Load();
cfg.UpdateWindowEnabled = updateWindowBox.Checked;
cfg.UpdateWindowStartMinutes = Math.Max(0, updateWindowStartBox.SelectedIndex) * UpdateWindow.SlotMinutes;
cfg.UpdateWindowEndMinutes = Math.Max(0, updateWindowEndBox.SelectedIndex) * UpdateWindow.SlotMinutes;
try { cfg.Save(); } catch { /* harmless */ }
}
updateWindowBox.CheckedChanged += (_, _) => { SyncUpdateWindowEnabled(); SaveUpdateWindow(); };
updateWindowStartBox.SelectedIndexChanged += (_, _) => SaveUpdateWindow();
updateWindowEndBox.SelectedIndexChanged += (_, _) => SaveUpdateWindow();
showWhatsNewAfterUpdateBox.Checked = cfgForLoad.ShowWhatsNewAfterUpdate;
showWhatsNewAfterUpdateBox.CheckedChanged += (_, _) =>
{
@@ -937,6 +989,23 @@ internal sealed class PreferencesDialog : Form
freqRow.Controls.Add(updateFrequencyLabel);
freqRow.Controls.Add(updateFrequencyBox);
// The install-window time range: start + end lists on one row, under the checkbox that
// enables them. Labels carry the mnemonics and focus their list.
var updateWindowRangeRow = new FlowLayoutPanel
{
AutoSize = true,
Dock = DockStyle.Fill,
FlowDirection = FlowDirection.LeftToRight,
WrapContents = false,
Padding = new Padding(0, 4, 0, 0),
};
var updateWindowStartLabel = new MnemonicLabel { Text = "St&art time:", MnemonicTarget = updateWindowStartBox, AutoSize = true, Padding = new Padding(0, 6, 8, 0) };
var updateWindowEndLabel = new MnemonicLabel { Text = "En&d time:", MnemonicTarget = updateWindowEndBox, AutoSize = true, Padding = new Padding(12, 6, 8, 0) };
updateWindowRangeRow.Controls.Add(updateWindowStartLabel);
updateWindowRangeRow.Controls.Add(updateWindowStartBox);
updateWindowRangeRow.Controls.Add(updateWindowEndLabel);
updateWindowRangeRow.Controls.Add(updateWindowEndBox);
// Group the cue label + list + the two action buttons into a single panel that
// occupies one row in the outer layout. The action buttons sit side-by-side under
// the list so they read as "buttons that act on the list above" without taking up
@@ -1035,7 +1104,7 @@ internal sealed class PreferencesDialog : Form
tabs.TabPages.Add(MakeTab("Startup behaviour",
startMinimisedBox, startWithUserBox, startWithProfileBox, startupListPanel));
tabs.TabPages.Add(MakeTab("Update settings",
checkForUpdatesOnStartupBox, freqRow, checkForUpdatesNowButton, silentlyInstallUpdatesBox, showWhatsNewAfterUpdateBox));
checkForUpdatesOnStartupBox, freqRow, checkForUpdatesNowButton, silentlyInstallUpdatesBox, updateWindowBox, updateWindowRangeRow, showWhatsNewAfterUpdateBox));
tabs.TabPages.Add(MakeTab("Logging",
loggingBox, writeLogsNowButton,
warnIfLogsExceedBox, logsSizeRow,
+34
View File
@@ -116,6 +116,7 @@ internal static class SelfTest
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);
RunStep(results, "Update install window (same-day, wraparound, retry timing)", UpdateInstallWindow);
var failed = results.Count(r => r.Status == "FAIL");
var skipped = results.Count(r => r.Status == "SKIP");
@@ -2375,6 +2376,39 @@ internal static class SelfTest
return "sealed + replay/stale/wrong-key/plaintext all rejected; skew tolerated; nonces counter-based";
}
/// <summary>The "only install updates within this time range" gate (2026-07-26 feature):
/// same-day windows, past-midnight wraparound, boundary semantics (start in, end out),
/// the empty-selection rule, the deferred-retry arithmetic, and the list text format.</summary>
private static string? UpdateInstallWindow()
{
static TimeSpan At(int h, int m) => new(h, m, 0);
const int t0100 = 60, t0600 = 360, t2200 = 1320;
// Same-day window 01:0006:00.
Check(UpdateWindow.IsWithin(At(3, 0), t0100, t0600), "03:00 is inside 01:00-06:00");
Check(UpdateWindow.IsWithin(At(1, 0), t0100, t0600), "the start minute is INSIDE (inclusive)");
Check(!UpdateWindow.IsWithin(At(6, 0), t0100, t0600), "the end minute is OUTSIDE (exclusive)");
Check(!UpdateWindow.IsWithin(At(14, 30), t0100, t0600), "mid-afternoon is outside an overnight window");
// Wraparound 22:0006:00 (end before start = spans midnight).
Check(UpdateWindow.IsWithin(At(23, 30), t2200, t0600), "23:30 is inside 22:00-06:00 (wraps midnight)");
Check(UpdateWindow.IsWithin(At(2, 0), t2200, t0600), "02:00 is inside 22:00-06:00 (the morning side)");
Check(!UpdateWindow.IsWithin(At(12, 0), t2200, t0600), "noon is outside 22:00-06:00");
// start == end → no restriction, never a silent 'updates can never install' trap.
Check(UpdateWindow.IsWithin(At(12, 0), t0100, t0100), "an empty range must mean unrestricted, not never");
// Deferred-retry arithmetic: at 14:30 the 01:00 window opens in 10.5 hours; at 00:30 in 30 min.
Check(UpdateWindow.UntilNextStart(At(14, 30), t0100) == TimeSpan.FromMinutes(630), "14:30 → 01:00 is 10.5 h away");
Check(UpdateWindow.UntilNextStart(At(0, 30), t0100) == TimeSpan.FromMinutes(30), "00:30 → 01:00 is 30 min away");
Check(UpdateWindow.FormatMinutes(0) == "00:00" && UpdateWindow.FormatMinutes(1425) == "23:45",
"slot text runs 00:00 through 23:45");
Check(UpdateWindow.SlotsPerDay == 96, "24 hours in 15-minute steps = 96 list entries");
return "same-day + wraparound exact; start in, end out; empty = unrestricted; retry timing right";
}
/// <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