diff --git a/readme.html b/readme.html
index 80fa9a8..c70710c 100644
--- a/readme.html
+++ b/readme.html
@@ -1047,6 +1047,8 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
The feature is symmetric: both computers can both send and accept. If you set up hotkeys on both ends and tick “Accept remote volume commands” on both ends, either side can adjust the other's volume. There's no fixed “controller” and “controlled” computer.
+
@@ -1184,6 +1186,8 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
| Then check every (drop-down) | Alt+U | How often RemSound checks for a newer version in the background after launch. Choices: Never, Every hour, Every 6 hours, Every 24 hours. The default is Every 24 hours. Your choice is remembered between launches; if you set it to Never and you've also unticked the startup check, the only way an update arrives is through the manual button below. |
| Check for updates now (button) | Alt+N | Checks for a newer version straight away. If you're already up to date you get a small popup saying so. If there's a newer version, you get a confirmation dialog with the release notes and a Yes / No to install. The same button is in the Help menu (Alt+H, C). |
| Silently install updates when available (checkbox) | Alt+I | When ticked, the background and startup checks install any available update without asking — RemSound downloads it, closes briefly, swaps the files, and reopens itself. Off by default. The startup check shows a brief notice first so you can see what's about to happen (see below). The manual “Check for updates now” button always asks first, no matter how this checkbox is set. |
+| Only install updates within this time range (checkbox) | Alt+T | When ticked, automatic installs (the startup check and the background timer) only go ahead inside the daily time range you set below — so an update never closes RemSound and kills your sound while you're mid-session. If an update is found outside the range, RemSound notes it and quietly retries the moment the range opens. Off by default. The manual “Check for updates now” button ignores the range — if you ask by hand, you get it straight away. |
+| Start time / End time (drop-downs) | Alt+A / Alt+D | The daily range for the checkbox above, in 15-minute steps from 00:00 to 23:45. They only light up when the range is enabled. The default is 01:00 to 06:00 — overnight. A range whose end is at or before its start runs past midnight: 22:00 to 06:00 covers late evening straight through to early morning. The start minute counts as inside the range; the end minute is outside it. |
| Show what's new after each update (checkbox) | Alt+H | When ticked, the first time RemSound opens after an update has installed, it pops up the About box — which starts with the notes for the version you just got — so you can see what changed. Off by default. It only happens once per update, never on an ordinary restart, and never on a fresh install. |
@@ -1411,7 +1415,7 @@ RemSound.exe --connect 192.168.1.42
Setting it up
Everything lives in the Service menu on the menu bar:
-- Configure service profile… — opens a small window with two tabs (Connectivity and Audio send) where you choose who to send to (plus a password) and what to send. On the Audio send tab, the first output choice is Use Windows default audio device, follows Windows changes — tick that to send whatever this machine is currently playing and keep following the Windows default if it later changes, rather than pinning one named card. (You can still pick specific devices, or a specific application, exactly as in the normal app.) There is no “send my audio” switch because the service always sends, and there is no audio-quality tab to fiddle with: the service always uses the settings that work best for live streaming (the Opus live-latency codec, small packets, locked to the audio clock), so it just sounds right. This is a separate profile from your normal ones and does not appear in the usual profile list. The Additional options button lets you turn the service's own log on or off. (The service itself never plays sounds — it streams silently in the background — so there are no cue options here.)
+- Configure service profile… — opens a small window with two tabs (Connectivity and Audio send) where you choose who to send to (plus a password) and what to send. On the Audio send tab, the first output choice is Use Windows default audio device, follows Windows changes — tick that to send whatever this machine is currently playing and keep following the Windows default if it later changes, rather than pinning one named card. (You can still pick specific devices, or a specific application, exactly as in the normal app.) There is no “send my audio” switch because the service always sends, and there is no audio-quality tab to fiddle with: the service always uses the settings that work best for live streaming (the Opus live-latency codec, small packets, locked to the audio clock), so it just sounds right. This is a separate profile from your normal ones and does not appear in the usual profile list. The Additional options button holds two extras. First, a switch for the service's own log. Second, Set the machine's volume when the service starts: tick it, pick a volume percent, and the service unmutes the machine and sets its Windows volume to that level when it starts — handy for an unattended PC that booted muted or was left turned down, so it's audible again with nobody at the keyboard. The When list chooses between Only the first start after each boot (the default — a mid-day service restart then leaves the volume alone while someone's using the machine) and Every time the service starts. Changes take effect from the service's next start, no reinstall needed. (The service itself never plays sounds — it streams silently in the background — so there are no cue options here.)
- Install service — registers it with Windows so it starts automatically at every boot. Windows asks for administrator permission (one prompt). Do this once. Straight after installing, RemSound asks whether you'd like to start it now (otherwise it waits until the next reboot). (When you first install RemSound on a PC, the app installer also offers to set the service up — and start it — for you, so you may have done this already.)
- Start service / Stop service — run or halt it now without waiting for a reboot.
- Uninstall service — removes it entirely.
diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs
index 3fb76c4..cd2a4b0 100644
--- a/src/RemSound.App/MainForm.cs
+++ b/src/RemSound.App/MainForm.cs
@@ -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;
+
+ /// 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).
+ 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;
+ }
+
/// Apply (or stop) the background update-poll timer based on
/// . Called at startup and whenever the user
/// changes the dropdown in Preferences. The first tick fires after one interval — we
diff --git a/src/RemSound.App/PreferencesDialog.cs b/src/RemSound.App/PreferencesDialog.cs
index 04ddef5..c3e12d6 100644
--- a/src/RemSound.App/PreferencesDialog.cs
+++ b/src/RemSound.App/PreferencesDialog.cs
@@ -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:00–06: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,
diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs
index 5ee3d80..52c8ea6 100644
--- a/src/RemSound.App/SelfTest.cs
+++ b/src/RemSound.App/SelfTest.cs
@@ -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";
}
+ /// 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.
+ 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:00–06: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:00–06: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";
+ }
+
/// 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
diff --git a/src/RemSound.Core/AppConfig.cs b/src/RemSound.Core/AppConfig.cs
index 314d3ab..ea14b2d 100644
--- a/src/RemSound.Core/AppConfig.cs
+++ b/src/RemSound.Core/AppConfig.cs
@@ -278,6 +278,19 @@ public sealed class AppConfig
/// RemSound. Default false — the user gets a confirmation dialog before each install.
public bool SilentlyInstallUpdates { get; set; }
+ /// If true, AUTOMATIC update installs (background poll + startup check) only happen
+ /// inside the daily local-time window below — so an update never kills someone's sound while
+ /// they're using the machine; it lands overnight instead. Manual "Check for updates now" is
+ /// not gated. Default false (house rule: persistence defaults off). See UpdateWindow (Core)
+ /// for the semantics (start inclusive, end exclusive, wraps past midnight).
+ public bool UpdateWindowEnabled { get; set; }
+
+ /// Window start, minutes-of-day local time, 15-minute granularity. Default 01:00.
+ public int UpdateWindowStartMinutes { get; set; } = 60;
+
+ /// Window end, minutes-of-day local time, 15-minute granularity. Default 06:00.
+ public int UpdateWindowEndMinutes { get; set; } = 360;
+
/// If true (the default), RemSound runs an update check shortly after launch in
/// addition to whatever drives in the background. The
/// startup check is what catches users who quit and re-open the app within the polling
diff --git a/src/RemSound.Core/UpdateWindow.cs b/src/RemSound.Core/UpdateWindow.cs
new file mode 100644
index 0000000..61577cb
--- /dev/null
+++ b/src/RemSound.Core/UpdateWindow.cs
@@ -0,0 +1,41 @@
+namespace RemSound.Core;
+
+///
+/// The "only install updates within this time range" option (Preferences → updates; feature request
+/// 2026-07-26). RemSound streams live audio, so an automatic update mid-session kills someone's
+/// sound; this gates AUTOMATIC installs (background poll + startup check) to a daily window — e.g.
+/// 01:00 to 06:00 — so updates land overnight. A manual "Check for updates now" is deliberately NOT
+/// gated: the user asking by hand knows what they're doing. Times are minutes-of-day in local time,
+/// picked from 15-minute slots; a window whose end is at or before its start wraps past midnight
+/// (22:00 → 06:00 covers late evening THROUGH early morning).
+///
+public static class UpdateWindow
+{
+ public const int SlotMinutes = 15;
+ public const int SlotsPerDay = 24 * 60 / SlotMinutes; // 96
+
+ /// "HH:mm" for a minutes-of-day value (the Preferences list text).
+ public static string FormatMinutes(int minutesOfDay) => $"{minutesOfDay / 60:D2}:{minutesOfDay % 60:D2}";
+
+ /// Is this local time-of-day inside the window? Start inclusive, end exclusive.
+ /// start == end means an empty selection — treated as "no restriction" rather than "never",
+ /// because a window that silently blocks every update forever is a support trap.
+ public static bool IsWithin(TimeSpan timeOfDay, int startMinutes, int endMinutes)
+ {
+ var m = (int)timeOfDay.TotalMinutes;
+ if (startMinutes == endMinutes) return true;
+ return startMinutes < endMinutes
+ ? m >= startMinutes && m < endMinutes // same-day window (01:00–06:00)
+ : m >= startMinutes || m < endMinutes; // wraps midnight (22:00–06:00)
+ }
+
+ /// How long from this time-of-day until the window next OPENS — drives the deferred
+ /// retry timer, so an update found outside the window installs when the window starts instead
+ /// of waiting for the next (possibly 24-hourly) poll to happen to land inside it.
+ public static TimeSpan UntilNextStart(TimeSpan timeOfDay, int startMinutes)
+ {
+ var mins = startMinutes - timeOfDay.TotalMinutes;
+ if (mins <= 0) mins += 24 * 60;
+ return TimeSpan.FromMinutes(mins);
+ }
+}