Bump to v3.0.0: Opus live latency, codec rename, profile-resume after update, save-through-lock

* New "Opus, live latency" codec mode: 2.5 ms frames (120 samples/ch at 48 kHz)
  via the float-input encode path. End-to-end codec delay drops to ~5 ms (vs
  ~12.5 ms at standard 10 ms Opus). Test on LAN: 400 pps/lane, zero missed /
  reordered / duplicate packets, ~15 ms one-way saved end-to-end.

* Wire-format change: AudioFormatInfo.FrameDurationMilliseconds renamed to
  FrameSamplesPerChannel (int sample-count at announced sample rate). Removes
  the lossy 48000*ms/1000 conversion that couldn't represent 2.5 ms. v3 <-> v3
  exact; v3 <-> v2 still passes audio (Opus decoder is self-describing from
  packet TOC) but v2 side over-sizes its buffer wildly. v2.x profiles auto-
  migrate via <120 sentinel rule in RemSoundSettingsStore (anything below 120
  is treated as legacy ms and multiplied by 48). Profile JSON key kept as
  OpusFrameMilliseconds via [JsonPropertyName] so old profile files still load.

* Codec dropdown rebuilt with use-case names: "PCM 48K 24 bit - uncompressed",
  "Opus, broadcast quality - loss tolerant", "Opus, live latency - for jamming
  and monitoring". Middle 10 ms option retired; saved 480-sample profiles
  collapse to broadcast quality (safer-side default).

* Profile auto-resume after self-update: RemSoundUpdater writes a one-shot
  _resume-after-update.txt sentinel containing the active profile title before
  exit; Program.Main reads + deletes it on next start and silently loads that
  profile, skipping the picker. Helper batch's robocopy /XF excludes the
  sentinel and the failure-branch cleans it up if the install aborts. Falls
  through to normal startup behaviour (StartWithProfileTitle or picker) if the
  sentinel is missing, empty, or names a profile that no longer exists.

* Read-only profile saves now go through on explicit Ctrl+S / File -> Save
  with a one-time TaskDialog warning ("Save anyway" / "Cancel" + Do-not-show-
  again). Lock continues to suppress the automatic unsaved-changes prompt on
  close / profile switch (its main job). AppConfig.SaveOnReadOnlyMessageSuppressed
  renamed to SaveOnReadOnlyWarningSuppressed; v2.x suppression flag is silently
  discarded since the behaviour changed and the user needs to see the warning
  once on each machine.

* Manual (readme.html) updated: codec table rewritten with the three new
  choices and corrected bandwidth figures, send-rate description updated, new
  sections "The same profile picks up automatically after an update" and
  "Saving on purpose while a profile is locked".

* Subsumes the never-separately-released v2.2 work: native Opus encoder
  (~97% less per-second memory churn on Opus send path via Concentus.Native),
  efficiency tidy-ups (item 4 ASIO probe rate, item 6 WaitHandle, item 7
  snapshot cache, items 14/16 heartbeat + discovery), legacy cleanup
  (items 30/34/35/36: KeepAlive infrastructure, drift drop/repeat/accumulator
  fields, fan-out cache stat). New diagnostic columns cpu/memMB/wsMB/
  allocKBps/captureMs/sendMs/recvMs/renderMs gated on Enable-logs.

About dialog updated with v3.0 block at top; v2.2 block retained for the
subsumed work.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-05-23 23:31:55 +01:00
co-authored by Claude Opus 4.7
parent 6d6d6897e4
commit af0e7c3fff
19 changed files with 559 additions and 166 deletions
+75
View File
@@ -20,6 +20,81 @@ internal sealed class AboutDialog : Form
/// updates" path.</summary>
private const string ReleaseNotes =
"""
RemSound v3.0
A big release with two things you'll actually notice:
1) A new "live latency" Opus mode. RemSound now sends
sound in tiny 2.5-millisecond chunks instead of the
usual 10 or 20 ms. End-to-end delay drops to about
5 ms of codec delay close to PCM perfect for
playing along with someone in real time. Uses a bit
more network bandwidth than the regular Opus mode
but still a fraction of PCM. Best on a clean wired
network. Pick it from the codec list as "Opus, live
latency for jamming and monitoring".
2) After RemSound updates itself, it now reopens on
the same profile you were using. So if a silent
update fires mid-session, the session drops briefly
while the new files swap in, then RemSound reopens
with the same devices, peers and settings you
don't see the profile picker, and you don't have to
be at the computer when it happens. The next time
you launch RemSound yourself, your normal startup
choice applies as before.
Other changes:
* The codec list is now three choices, with clearer
names: PCM 48K 24 bit (uncompressed); Opus,
broadcast quality (loss tolerant); Opus, live
latency (the new low-latency mode). The old
"Opus lower quality (10 ms)" middle option has
been retired it sat between the other two
without a clear reason to pick it. If your saved
profile was using it, RemSound silently picks
broadcast quality for you slightly more delay,
more loss tolerance.
* Save on a locked (read-only) profile now goes
through when you ask on purpose. The lock still
suppresses the automatic "save your changes?"
prompt on close (its main job), but if you press
Save deliberately, a one-time warning explains
what's about to happen and lets you confirm or
cancel. Once you tick "do not show again", future
deliberate saves on a locked profile go through
silently. Save as... is unchanged it always
works.
Also includes everything from the never-separately-
released v2.2 work: a native Opus encoder that puts
much less load on Windows' memory manager (about 97 %
less per-second memory churn while sending Opus),
various small CPU and memory tidy-ups, and new
diagnostic log columns (cpu, memMB, wsMB, allocKBps,
captureMs / sendMs / recvMs / renderMs).
Compatibility note please read:
v3.0 changes how RemSound describes audio frame sizes
to other RemSound machines on the wire. Two v3.0
machines talk to each other perfectly. A v3.0 machine
talking to a v2.x machine will still pass audio, but
the v2.x side will build up too much buffer and
latency will be very high. To avoid this, update BOTH
machines to v3.0. The auto-updater on v1.9 and later
will handle the upgrade for you, but the timing
matters: if one machine updates before the other,
expect a short period of high latency until the
second machine catches up.
If you had previously ticked "do not show me this
message again" on the v2.x "save was blocked on a
read-only profile" dialog, that suppression doesn't
carry over you'll see the new one-time warning once
per machine. That's deliberate; the behaviour changed
and you need to know.
RemSound v2.2
A maintenance release that makes RemSound use less of
+98 -51
View File
@@ -722,19 +722,26 @@ public sealed class MainForm : Form
maxLatencyBox.AccessibleName = "Audio latency in milliseconds (Alt+L)";
// --- Populate static choices ---
// Order: PCM first (LAN), Opus 20 ms (higher quality, more robust to loss), Opus 10 ms
// (lower latency at the cost of slightly less audio quality and loss tolerance). Labels
// intentionally avoid all numbers and ms jargon — the slider is the only place ms
// should appear in the UI.
// Three transport choices, ordered most-tolerant-of-bad-networks to most-demanding:
// * PCM 48K 24-bit — uncompressed, ~2.3 Mbps
// * Opus broadcast quality — 20 ms frame (960 samples/ch at 48 kHz), loss tolerant
// * Opus live latency — 2.5 ms frame (120 samples/ch at 48 kHz), 8× the packet
// rate of broadcast quality, for jamming / live monitoring
// The 10 ms middle option (480 samples/ch) that lived here in v2.x has been retired —
// it sat between the other two without a clear use case (saved only 5 ms over 20 ms
// and gave up loss tolerance for no clearly audible win). Frame size on the wire is
// samples-per-channel at 48 kHz (v3.0 unit). Labels avoid numbers and ms jargon per
// the manual's "use case in words" convention; the per-peer status line surfaces the
// actual ms figure for users who want to verify.
codecBox.Items.AddRange(new object[]
{
new CodecChoice("PCM 48K 24 bit for very fast connections", AudioTransportCodec.Pcm, 0),
new CodecChoice("Opus high quality for fast connections", AudioTransportCodec.Opus, 20),
new CodecChoice("Opus lower quality for slower connections", AudioTransportCodec.Opus, 10),
new CodecChoice("PCM 48K 24 bit — uncompressed", AudioTransportCodec.Pcm, 0),
new CodecChoice("Opus, broadcast quality — loss tolerant", AudioTransportCodec.Opus, 960),
new CodecChoice("Opus, live latency — for jamming and monitoring", AudioTransportCodec.Opus, 120),
});
codecBox.SelectedIndex = ResolveCodecIndex(settings.LoadCodec(), settings.LoadOpusFrameMilliseconds());
codecBox.SelectedIndex = ResolveCodecIndex(settings.LoadCodec(), settings.LoadOpusFrameSamplesPerChannel());
var initialCodec = (CodecChoice)codecBox.SelectedItem!;
sender.ConfigureCodec(initialCodec.Codec, EffectiveOpusFrameMs(initialCodec.Codec, initialCodec.OpusFrameMs, settings.LoadSendRate()));
sender.ConfigureCodec(initialCodec.Codec, EffectiveOpusFrameSamples(initialCodec.Codec, initialCodec.OpusFrameSamples, settings.LoadSendRate()));
sender.SetSendRate(settings.LoadSendRate());
// Relay-mode plumbing. The sender's UDP socket is always-receiving from form construction
@@ -859,10 +866,10 @@ public sealed class MainForm : Form
if (codecBox.SelectedItem is CodecChoice item)
{
settings.SaveCodec(item.Codec);
if (item.Codec == AudioTransportCodec.Opus) settings.SaveOpusFrameMilliseconds(item.OpusFrameMs);
var effectiveFrameMs = EffectiveOpusFrameMs(item.Codec, item.OpusFrameMs, settings.LoadSendRate());
sender.ConfigureCodec(item.Codec, effectiveFrameMs);
logFile.Event($"codec changed to {item.Codec}{(item.Codec == AudioTransportCodec.Opus ? $" {effectiveFrameMs}ms" : "")}");
if (item.Codec == AudioTransportCodec.Opus) settings.SaveOpusFrameSamplesPerChannel(item.OpusFrameSamples);
var effectiveSamples = EffectiveOpusFrameSamples(item.Codec, item.OpusFrameSamples, settings.LoadSendRate());
sender.ConfigureCodec(item.Codec, effectiveSamples);
logFile.Event($"codec changed to {item.Codec}{(item.Codec == AudioTransportCodec.Opus ? $" {effectiveSamples / 48.0:0.##}ms" : "")}");
MarkProfileDirty();
}
};
@@ -1572,50 +1579,70 @@ public sealed class MainForm : Form
/// <summary>Ctrl+S / File → Save behaviour: if a profile is currently loaded, overwrite
/// it; if we're on the blank template (no current profile), fall through to Save as.
/// Read-only profiles refuse here with a hint pointing at Save As — that's the whole
/// point of read-only mode, so silently ignoring Ctrl+S would be more confusing than
/// a one-time message explaining why nothing happened. The message is suppressible
/// via the "Do not show again" tick (same pattern as the Save-success popup).</summary>
///
/// Read-only profiles: the lock suppresses the automatic "you have unsaved changes"
/// prompt on close / profile switch (the user has declared "anything I changed this
/// session is throwaway"), but it does NOT block explicit Ctrl+S / File → Save — if the
/// user asks to save on purpose, the save goes through. First time they do this we show
/// a one-time warning explaining the situation, with a "Do not show again" tick so the
/// warning self-suppresses for power users. Changed 2026-05-23 from the v2.x hard-block
/// behaviour after Ed's feedback that the lock should protect against accident, not
/// against intent.</summary>
private void SaveOrSaveAs()
{
if (currentProfileReadOnly)
{
if (!AppConfig.Load().SaveOnReadOnlyMessageSuppressed)
if (!AppConfig.Load().SaveOnReadOnlyWarningSuppressed)
{
ShowSaveBlockedByReadOnlyDialog();
if (!ShowSaveOnReadOnlyWarningDialog()) return;
}
// Read-only profiles always have a title — read-only is meaningless on the blank
// template — so we go straight to UpdateExistingProfile without the
// string-null-check that the unlocked path needs.
UpdateExistingProfile();
return;
}
if (string.IsNullOrEmpty(currentProfileTitle)) SaveProfileAs();
else UpdateExistingProfile();
}
/// <summary>Native TaskDialog explaining why Ctrl+S / File → Save did nothing on a
/// read-only profile. Verification checkbox lets the user suppress future occurrences;
/// same shape as <see cref="ShowSaveConfirmationDialog"/>. NVDA reads the heading +
/// body + checkbox as part of the normal tab order. 2026-05-22.</summary>
private void ShowSaveBlockedByReadOnlyDialog()
/// <summary>Native TaskDialog warning the user that they're about to overwrite a profile
/// marked read-only. Returns true if the user confirmed the save, false if they
/// cancelled. Verification checkbox lets the user suppress future occurrences via
/// <see cref="AppConfig.SaveOnReadOnlyWarningSuppressed"/>; same shape as
/// <see cref="ShowSaveConfirmationDialog"/>. NVDA reads the heading + body + checkbox
/// as part of the normal tab order. 2026-05-23 (rewrite of the v2.x hard-block dialog).
/// </summary>
private bool ShowSaveOnReadOnlyWarningDialog()
{
var verification = new TaskDialogVerificationCheckBox("Do not show me this message again");
var saveButton = new TaskDialogButton("Save anyway");
var cancelButton = new TaskDialogButton("Cancel") { AllowCloseDialog = true };
var page = new TaskDialogPage
{
Caption = AppName,
Heading = "This profile is read-only",
Text = "This profile is locked, so Save was skipped. Use File → Save as... to save your changes to a new profile, or untick File → Lock profile (read-only) to unlock this one.",
Icon = TaskDialogIcon.Information,
Heading = "Saving onto a read-only profile",
Text = "You're about to save changes onto a profile that's marked as read-only. "
+ "RemSound allows this because you asked to save on purpose — the lock only "
+ "stops the automatic \"save your changes?\" prompt; it doesn't stop you "
+ "saving when you mean to.\n\n"
+ "Click Save anyway to overwrite this profile, or Cancel and use "
+ "File → Save as... if you'd rather save your changes to a new profile.",
Icon = TaskDialogIcon.Warning,
Verification = verification,
Buttons = { TaskDialogButton.OK },
DefaultButton = TaskDialogButton.OK,
Buttons = { saveButton, cancelButton },
DefaultButton = cancelButton,
AllowCancel = true,
};
TaskDialog.ShowDialog(this, page);
var clicked = TaskDialog.ShowDialog(this, page);
if (verification.Checked)
{
var cfg = AppConfig.Load();
cfg.SaveOnReadOnlyMessageSuppressed = true;
cfg.SaveOnReadOnlyWarningSuppressed = true;
try { cfg.Save(); } catch { /* harmless — preference just won't persist */ }
AppendLogEntry("save-blocked-by-read-only message suppressed by user");
AppendLogEntry("save-on-read-only warning suppressed by user");
}
return clicked == saveButton;
}
/// <summary>Rename the currently-active profile JSON on disk. No-op on the blank
@@ -1877,7 +1904,13 @@ public sealed class MainForm : Form
/// untouched.</summary>
private async Task InstallUpdateAsync(UpdateInfo info)
{
var ok = await updater.DownloadAndStageInstallAsync(info).ConfigureAwait(true);
// Pass the currently-loaded profile title so the updater drops a resume-after-update
// sentinel; the relaunched RemSound.exe will pick this up in Program.Main and silently
// re-open the same profile, skipping the picker. Without this, a silent or
// mid-session update would drop the session AND leave the user back at the picker —
// the session never resumes by itself. Null/empty title (blank template, no profile
// saved yet) skips the sentinel and the relaunch falls through to normal startup.
var ok = await updater.DownloadAndStageInstallAsync(info, currentProfileTitle).ConfigureAwait(true);
if (!ok)
{
MessageBox.Show(this,
@@ -2658,7 +2691,7 @@ public sealed class MainForm : Form
}
}
var sendingNow = connected && IsSendEnabled && sender.IsRunning;
var codecLabel = FormatCodecLabel(sender.Codec, sender.OpusFrameMilliseconds);
var codecLabel = FormatCodecLabel(sender.Codec, sender.OpusFrameSamplesPerChannel);
for (int i = 0; i < connectedPeersList.Items.Count; i++)
{
@@ -5272,7 +5305,8 @@ public sealed class MainForm : Form
var rate = settings.LoadSendRate();
if (item.Codec == AudioTransportCodec.Opus)
{
return EffectiveOpusFrameMs(item.Codec, item.OpusFrameMs, rate) / 2.0;
// EffectiveOpusFrameSamples is samples-per-channel at 48 kHz; ÷ 48 → ms, ÷ 2 → half-frame.
return EffectiveOpusFrameSamples(item.Codec, item.OpusFrameSamples, rate) / 96.0;
}
// PCM
if (settings.LoadTightLatencyMode() && settings.LoadAudioMode() == AudioMode.AsioOnly)
@@ -5304,28 +5338,29 @@ public sealed class MainForm : Form
private double RenderBufferEstimateMs() => 10;
/// <summary>
/// Translates a codec choice + the user's Send Rate into the effective Opus frame size.
/// PCM frame size is set separately in AudioSender.SetSendRate (it's a sample-count, not
/// a milliseconds value). Standard returns the codec's natural frame; Tight halves it
/// (Opus 20 → 10, Opus 10 → 5, PCM frame size handled in AudioSender). Opus codec accepts
/// 2.5/5/10/20/40/60 ms — never goes below 5 here so we don't need sub-millisecond Opus.
/// Translates a codec choice + the user's Send Rate into the effective Opus frame size in
/// samples-per-channel at 48 kHz. PCM frame size is set separately in AudioSender.SetSendRate.
/// Standard returns the codec's natural frame; Tight halves it (Opus 960 → 480 → 240 → 120
/// floored). Floor is 120 samples = 2.5 ms = standard libopus's RESTRICTED_LOWDELAY minimum.
/// </summary>
private static int EffectiveOpusFrameMs(AudioTransportCodec codec, int opusFrameMs, SendRate rate)
private static int EffectiveOpusFrameSamples(AudioTransportCodec codec, int opusFrameSamples, SendRate rate)
{
if (codec != AudioTransportCodec.Opus) return opusFrameMs;
return rate == SendRate.Tight ? Math.Max(5, opusFrameMs / 2) : opusFrameMs;
if (codec != AudioTransportCodec.Opus) return opusFrameSamples;
return rate == SendRate.Tight ? Math.Max(120, opusFrameSamples / 2) : opusFrameSamples;
}
/// <summary>
/// Short codec label for the per-peer line in the connectivity dialog. e.g. "PCM",
/// "Opus 10ms", "Opus 20ms". Uses the same EffectiveOpusFrameMs the encoder uses so the
/// label reflects the actually-encoded frame size, not the codec menu choice.
/// "Opus 10ms", "Opus 20ms", "Opus 2.5ms". Input is samples-per-channel at 48 kHz; the
/// label derives ms from samples / 48 with up to one decimal place. Uses the same
/// EffectiveOpusFrameSamples the encoder uses so the label reflects the actually-encoded
/// frame size, not the codec menu choice.
/// </summary>
private static string FormatCodecLabel(AudioTransportCodec codec, int opusFrameMs)
private static string FormatCodecLabel(AudioTransportCodec codec, int opusFrameSamples)
{
return codec switch
{
AudioTransportCodec.Opus => $"Opus {Math.Max(1, opusFrameMs)}ms",
AudioTransportCodec.Opus => $"Opus {Math.Max(1, opusFrameSamples) / 48.0:0.##}ms",
AudioTransportCodec.Pcm => "PCM",
_ => codec.ToString(),
};
@@ -5342,8 +5377,9 @@ public sealed class MainForm : Form
{
if (codecBox.SelectedItem is CodecChoice item && item.Codec == AudioTransportCodec.Opus)
{
sender.ConfigureCodec(item.Codec, EffectiveOpusFrameMs(item.Codec, item.OpusFrameMs, rate));
logFile.Event($"send rate changed to {rate} → Opus frame {EffectiveOpusFrameMs(item.Codec, item.OpusFrameMs, rate)}ms");
var effectiveSamples = EffectiveOpusFrameSamples(item.Codec, item.OpusFrameSamples, rate);
sender.ConfigureCodec(item.Codec, effectiveSamples);
logFile.Event($"send rate changed to {rate} → Opus frame {effectiveSamples / 48.0:0.##}ms");
}
else
{
@@ -5351,10 +5387,21 @@ public sealed class MainForm : Form
}
}
private static int ResolveCodecIndex(AudioTransportCodec codec, int opusFrameMs)
private static int ResolveCodecIndex(AudioTransportCodec codec, int opusFrameSamples)
{
if (codec == AudioTransportCodec.Pcm) return 0;
return opusFrameMs == 20 ? 1 : 2; // Opus 20 = index 1, Opus 10 (default) = index 2
// Opus 120 (2.5 ms — live latency) = index 2. Anything else (including the retired
// 10 ms middle (480) and the never-exposed 5 ms (240)) collapses to index 1
// (broadcast quality / 20 ms), the safer default — losing a little latency is the
// less surprising outcome on upgrade than losing loss tolerance. v2.x profiles that
// saved OpusFrameMilliseconds=10 (which the settings store migrates to 480 samples
// via the <120 sentinel) land here on the broadcast side; users who specifically
// want low latency re-pick "live latency" from the dropdown.
return opusFrameSamples switch
{
120 => 2,
_ => 1,
};
}
// ===================== Auto-tune =====================
+4 -2
View File
@@ -16,9 +16,11 @@ internal sealed class LiveCheckedListBox : CheckedListBox
/// <summary>
/// User-facing choice that maps a friendly label to a codec + Opus frame size pair. The frame
/// size is only meaningful when Codec == Opus; for PCM it's ignored.
/// size is in samples-per-channel at 48 kHz (v3.0 wire-format unit — see
/// <see cref="AudioFormatInfo"/>). 120 = 2.5 ms, 240 = 5 ms, 480 = 10 ms, 960 = 20 ms.
/// Only meaningful when Codec == Opus; for PCM it's ignored.
/// </summary>
internal sealed record CodecChoice(string Label, AudioTransportCodec Codec, int OpusFrameMs)
internal sealed record CodecChoice(string Label, AudioTransportCodec Codec, int OpusFrameSamples)
{
public override string ToString() => Label;
}
+45 -10
View File
@@ -35,18 +35,53 @@ internal static class Program
Profile? profile;
string? title;
// Auto-load shortcut: if AppConfig.StartWithProfileTitle is set and the named
// profile actually exists in the current store, skip the picker entirely and
// load that profile directly. This is what the Startup behaviour dialog's
// "Start with a specific profile" toggle drives. Combined with the Windows
// auto-start registry entry and the StartMinimised flag, it lets the user
// boot a machine and have RemSound up and streaming with no clicks. Falls
// through to the normal picker if the configured profile no longer exists
// (deleted since it was selected, or the profiles folder changed) so the user
// isn't stuck.
// Auto-load shortcut: two paths.
//
// (a) Resume-after-update sentinel — a one-shot file written by the updater
// just before it relaunches RemSound.exe (see RemSoundUpdater
// ResumeProfileSentinelName). Holds the title of whichever profile was
// loaded at the moment the update fired. If present, we load that profile
// silently and delete the sentinel — so a silent or mid-session update
// restores the same session the user was running, without dropping them
// at the picker. This takes precedence over StartWithProfileTitle because
// a mid-session update may have moved the user away from their configured
// startup profile.
//
// (b) AppConfig.StartWithProfileTitle — the persistent "Start with a specific
// profile" preference set via the Startup behaviour dialog. Loaded if (a)
// didn't fire. Combined with the Windows auto-start registry entry and the
// StartMinimised flag, it lets the user boot a machine and have RemSound
// up and streaming with no clicks.
//
// Either path falls through to the normal picker if the named profile no longer
// exists (deleted since it was selected, or the profiles folder changed) so the
// user isn't stuck.
Profile? autoLoaded = null;
string? autoLoadedTitle = null;
if (!string.IsNullOrWhiteSpace(appConfig.StartWithProfileTitle))
var resumeSentinelPath = Path.Combine(AppContext.BaseDirectory, RemSoundUpdater.ResumeProfileSentinelName);
string? resumeTitle = null;
if (File.Exists(resumeSentinelPath))
{
try { resumeTitle = File.ReadAllText(resumeSentinelPath).Trim(); }
catch { resumeTitle = null; }
// Delete the sentinel unconditionally — it's a one-shot. If the load fails
// below, the user gets the picker on this launch and a normal start next
// time, rather than the sentinel re-firing on every relaunch forever.
try { File.Delete(resumeSentinelPath); } catch { /* ignore */ }
}
if (!string.IsNullOrWhiteSpace(resumeTitle))
{
try
{
autoLoaded = store.Load(resumeTitle!);
if (autoLoaded is not null) autoLoadedTitle = resumeTitle;
}
catch { /* fall through to StartWithProfileTitle / picker */ }
}
if (autoLoaded is null && !string.IsNullOrWhiteSpace(appConfig.StartWithProfileTitle))
{
try
{
+1 -1
View File
@@ -14,7 +14,7 @@
tag_name on the latest GitHub release; bump it on every public release. The
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
is what the About dialog and the updater both read. -->
<Version>2.2.0</Version>
<Version>3.0.0</Version>
</PropertyGroup>
<ItemGroup>
+48 -4
View File
@@ -134,12 +134,29 @@ internal sealed class RemSoundUpdater : IDisposable
}
}
/// <summary>Filename of the one-shot "after the update restart, silently load this profile"
/// sentinel. Written next to RemSound.exe by <see cref="DownloadAndStageInstallAsync"/>
/// when the caller supplies a non-empty <c>activeProfileTitle</c>; read and deleted by
/// <c>Program.Main</c> on the next startup. Lives in the install directory (not %TEMP%
/// or %APPDATA%) because the helper batch's robocopy step needs to know to skip it —
/// see the <c>/XF</c> list in <see cref="BuildInstallScript"/>.</summary>
public const string ResumeProfileSentinelName = "_resume-after-update.txt";
/// <summary>Download the update ZIP, stage it next to RemSound.exe, spawn the detached
/// install helper, and ask the App to exit so the helper can take over. Returns true if
/// the helper was launched (caller should Application.Exit immediately afterwards);
/// false on any failure earlier in the pipeline. A false return leaves the running
/// instance untouched.</summary>
public async Task<bool> DownloadAndStageInstallAsync(UpdateInfo info, CancellationToken token = default)
/// instance untouched.
///
/// <paramref name="activeProfileTitle"/> — when non-empty, a one-shot sentinel file
/// <see cref="ResumeProfileSentinelName"/> is written next to RemSound.exe just before
/// the helper is launched. On the next startup, Program.Main reads it, loads that profile
/// silently (skipping the picker), and deletes the sentinel. This makes a silent / manual
/// update behave like the session never ended — the user is back in the same profile
/// they were running, without having to remember which one it was. When null/empty, no
/// sentinel is written and the post-update launch uses whatever startup behaviour
/// AppConfig has configured.</summary>
public async Task<bool> DownloadAndStageInstallAsync(UpdateInfo info, string? activeProfileTitle = null, CancellationToken token = default)
{
try
{
@@ -147,13 +164,17 @@ internal sealed class RemSoundUpdater : IDisposable
var stagingDir = Path.Combine(baseDir, "_update");
var zipPath = Path.Combine(Path.GetTempPath(), $"RemSound-update-{info.Tag}.zip");
var batchPath = Path.Combine(baseDir, "_apply-update.cmd");
var resumeSentinelPath = Path.Combine(baseDir, ResumeProfileSentinelName);
// Tidy any leftover from a previous failed attempt before we start. Also clear
// the failure marker — the new attempt starts clean and only re-creates the
// marker if THIS run fails.
// marker if THIS run fails. The resume sentinel from a previous run (if any) is
// also cleared here; if the caller supplies a profile title, the new sentinel is
// written below after staging succeeds.
TryDelete(zipPath);
TryDeleteDirectory(stagingDir);
TryDelete(Path.Combine(baseDir, "update-failed.txt"));
TryDelete(resumeSentinelPath);
Log?.Invoke($"updater: downloading {info.DownloadUrl}");
await using (var src = await http.GetStreamAsync(info.DownloadUrl, token).ConfigureAwait(false))
@@ -174,6 +195,25 @@ internal sealed class RemSoundUpdater : IDisposable
Log?.Invoke($"updater: writing install helper {batchPath}");
File.WriteAllText(batchPath, BuildInstallScript(stagingRoot, baseDir));
// Write the resume-after-update sentinel so the post-restart launch loads the
// same profile silently (no picker, no missed session). Only when the caller
// supplied a title — blank-template sessions and explicit "no profile yet" cases
// fall through to the normal startup logic.
if (!string.IsNullOrWhiteSpace(activeProfileTitle))
{
try
{
File.WriteAllText(resumeSentinelPath, activeProfileTitle);
Log?.Invoke($"updater: wrote resume sentinel for profile '{activeProfileTitle}'");
}
catch (Exception ex)
{
// Sentinel is best-effort. If we can't write it (disk full, ACL change),
// the update still proceeds; the user gets the picker on relaunch.
Log?.Invoke($"updater: could not write resume sentinel: {ex.GetType().Name}: {ex.Message}");
}
}
var pid = System.Environment.ProcessId;
var psi = new System.Diagnostics.ProcessStartInfo
{
@@ -265,7 +305,7 @@ internal sealed class RemSoundUpdater : IDisposable
rem startup settings) and their data folders (logs / profiles / recordings). An update
rem replaces APP files only. build-release.ps1 already keeps those out of the release
rem zip; this is the second line of defence so a bad zip still can't clobber them.
robocopy "{stagingArg}" "{installArg}" /E /IS /IT /NFL /NDL /NJH /NJS /R:60 /W:1 /XF _apply-update.cmd /XF _update-helper.log /XF update-failed.txt /XF remsound.config.json /XD logs profiles recordings _update /LOG+:"%LOG%"
robocopy "{stagingArg}" "{installArg}" /E /IS /IT /NFL /NDL /NJH /NJS /R:60 /W:1 /XF _apply-update.cmd /XF _update-helper.log /XF update-failed.txt /XF remsound.config.json /XF {ResumeProfileSentinelName} /XD logs profiles recordings _update /LOG+:"%LOG%"
set "ROBO_EXIT=%ERRORLEVEL%"
echo %DATE% %TIME% robocopy exit=%ROBO_EXIT% >> "%LOG%"
@@ -294,6 +334,10 @@ internal sealed class RemSoundUpdater : IDisposable
echo. >> "%MARKER%"
echo Once RemSound has updated successfully you can delete this file. >> "%MARKER%"
echo Technical details for support are in _update-helper.log in this folder. >> "%MARKER%"
rem Drop the resume-after-update sentinel on failure too there's no restart
rem happening, so a stale sentinel would mis-direct the user's next manual launch
rem into auto-loading a profile they may have moved on from in the meantime.
del "{installArg}\{ResumeProfileSentinelName}" 2>nul
echo %DATE% %TIME% FAILURE: robocopy exit=%ROBO_EXIT%, update folder kept, NOT restarting RemSound >> "%LOG%"
del "%~f0"
exit /b %ROBO_EXIT%