Release v3.6: rebuild the self-updater (in-app, rollback-safe); warnings to front

Replace the generated cmd.exe + robocopy update helper — which silently
failed on some machines — with an in-app C# installer:
  * Stage the new version to a per-user temp folder off the install and run
    the new RemSound.exe from there, so nothing in the install is locked by
    the updater itself.
  * Wait for the old process to fully exit (real WaitForExit), then
    back-up-and-swap files in C# with retry + rename-aside; roll the install
    back to the previous version on any failure, so a failed update can never
    leave a half-installed RemSound.
  * Log every step to updater.log; clean up old stages and legacy batch
    artefacts on launch. Removed the old BuildInstallScript batch generator.

Route the "RemSound is already running" dialog and its follow-up message
through ForegroundDialog so they surface in front from a background-relaunched
copy, matching the earlier post-update fix.

Docs: rewrite the readme update sections for the new mechanism, regenerate
MANUAL.md, refresh the About-box changelog and RELEASE_NOTES.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-06-10 13:37:19 +01:00
co-authored by Claude Opus 4.8
parent 4dbe9a47a0
commit 55bdcde0af
10 changed files with 470 additions and 258 deletions
+14
View File
@@ -20,6 +20,20 @@ internal sealed class AboutDialog : Form
/// updates" path.</summary>
private const string ReleaseNotes =
"""
RemSound v3.6
Updating is far more reliable. RemSound now installs its
own updates, step by step, instead of handing off to a
Windows script that could quietly fail. If an update can't
finish for any reason, RemSound puts your previous version
back exactly as it was and tells you so a failed update
can never leave you stuck or half-installed. Your settings,
profiles, logs and sounds are never touched.
A couple more notices now come to the front too: the
"RemSound is already running" message, and the notes shown
after an update when RemSound was tucked away in the tray.
RemSound v3.5
Recover a sound card you unplug: if a USB sound card
+27 -3
View File
@@ -39,13 +39,23 @@ internal static class ForegroundDialog
Show<object?>(owner => { show(owner); return null; });
/// <summary>Force <paramref name="hWnd"/> to the foreground even when RemSound isn't the active
/// app. A plain SetForegroundWindow from a background process is refused by Windows; attaching
/// our input queue to the current foreground thread for the call lifts that restriction. The
/// owner is top-most regardless, so this is belt-and-braces for focus.</summary>
/// app — including the hardest case: a process the auto-updater RELAUNCHED in the background while
/// RemSound was minimised. Windows refuses SetForegroundWindow to a background-spawned process (it
/// only flashes the taskbar button — invisible to a screen-reader user), so we (1) drop the system
/// foreground-lock timeout to 0 for the duration of the call, and (2) attach our input queue to the
/// current foreground thread. Both the timeout and the attachment are restored immediately after.</summary>
private static void ForceForeground(IntPtr hWnd)
{
uint savedTimeout = 0;
var loweredTimeout = false;
try
{
// Lift Windows' foreground-stealing lock for this one call — the key fix for a dialog that
// must surface from a background-relaunched (post-update) process, where AttachThreadInput
// alone isn't enough and the dialog would otherwise open behind everything.
if (SystemParametersInfoGet(SPI_GETFOREGROUNDLOCKTIMEOUT, 0, out savedTimeout, 0))
loweredTimeout = SystemParametersInfoSet(SPI_SETFOREGROUNDLOCKTIMEOUT, 0, IntPtr.Zero, SPIF_SENDCHANGE);
var foreThread = GetWindowThreadProcessId(GetForegroundWindow(), out _);
var thisThread = GetCurrentThreadId();
if (foreThread != 0 && foreThread != thisThread)
@@ -65,12 +75,26 @@ internal static class ForegroundDialog
}
}
catch { /* best-effort; the owner is top-most anyway */ }
finally
{
if (loweredTimeout)
{
try { SystemParametersInfoSet(SPI_SETFOREGROUNDLOCKTIMEOUT, 0, new IntPtr((long)savedTimeout), SPIF_SENDCHANGE); }
catch { /* restoring the user's setting is best-effort */ }
}
}
}
private const uint SPI_GETFOREGROUNDLOCKTIMEOUT = 0x2000;
private const uint SPI_SETFOREGROUNDLOCKTIMEOUT = 0x2001;
private const uint SPIF_SENDCHANGE = 0x0002;
[DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
[DllImport("user32.dll")] private static extern uint GetCurrentThreadId();
[DllImport("user32.dll")] private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach);
[DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")] private static extern bool BringWindowToTop(IntPtr hWnd);
[DllImport("user32.dll", EntryPoint = "SystemParametersInfoW")] private static extern bool SystemParametersInfoGet(uint uiAction, uint uiParam, out uint pvParam, uint fWinIni);
[DllImport("user32.dll", EntryPoint = "SystemParametersInfoW")] private static extern bool SystemParametersInfoSet(uint uiAction, uint uiParam, IntPtr pvParam, uint fWinIni);
}
+22 -7
View File
@@ -13,8 +13,18 @@ internal static class Program
private static volatile MainForm? activeMainForm;
[STAThread]
private static void Main()
private static void Main(string[] args)
{
// The auto-updater relaunches a temp copy of the NEW RemSound.exe in this mode to swap the
// new files over the install while the old copy exits (see UpdateApplier / RemSoundUpdater).
// Handle it first and return: this process is the installer, not a normal launch, so it must
// not touch the single-instance lock, audio devices, or the migration steps below.
if (args.Length > 0 && Array.Exists(args, a => string.Equals(a, "--apply-update", StringComparison.OrdinalIgnoreCase)))
{
UpdateApplier.Run(args);
return;
}
// SustainedLowLatency tells the GC to avoid full (gen 2) collections while audio is streaming.
// Gen 0/1 collections still happen but are sub-millisecond; the long pauses that were causing
// the receiver to fall behind in clusters of 4-5 underruns at a time were almost certainly
@@ -33,8 +43,8 @@ internal static class Program
// Remove cue WAVs (and their .sfk peak files) left loose in the install ROOT by pre-
// 2026-05-28 builds, where the cues lived next to RemSound.exe before they moved into
// sounds\. A robocopy update copies the new sounds\ tree but uses /E (not /PURGE), so it
// never deletes these orphans — they just linger in the root. Best-effort + idempotent:
// sounds\. An update copies the new sounds\ tree but never purges, so it never deletes
// these orphans — they just linger in the root. Best-effort + idempotent:
// a no-op once they're gone. 2026-06-08.
CleanUpLegacyRootSounds();
@@ -66,12 +76,13 @@ internal static class Program
// a killed copy is slow to release the abandoned mutex / its audio devices.
if (!instance.TryAcquire(TimeSpan.FromSeconds(5)))
{
MessageBox.Show(
ForegroundDialog.Show(owner => MessageBox.Show(
owner,
cleared
? "RemSound closed the other copy but couldn't start cleanly. Please launch RemSound again."
: "RemSound couldn't close the copy that's already running — it may be running as administrator. Close it from Task Manager (or restart Windows), then try again.",
"RemSound is already running",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
MessageBoxButtons.OK, MessageBoxIcon.Warning));
return;
}
break;
@@ -83,6 +94,10 @@ internal static class Program
instance.StartActivationListener();
instance.ActivateRequested += () => activeMainForm?.RestoreFromTray();
// Best-effort: clear leftover update temp stages (and any relics of the old batch updater).
// We hold the single-instance lock here, so only the live copy does this — no sibling race.
RemSoundUpdater.CleanUpUpdateStages();
// F1 anywhere = open the bundled manual. Installed *before* the first ShowDialog so
// it works on the profile picker (the very first thing the user sees). The filter
// is per-thread and modifier-aware: bare F1 only, so Shift/Ctrl/Alt+F1 stay free.
@@ -288,8 +303,8 @@ internal static class Program
}
/// <summary>Delete cue WAVs and their .sfk peak files left loose in the install ROOT by
/// pre-2026-05-28 builds (the cues moved into <c>sounds\</c> then; a robocopy update copies
/// the new tree but never removes the old root copies). Best-effort and idempotent — runs
/// pre-2026-05-28 builds (the cues moved into <c>sounds\</c> then; an update copies the new
/// tree but never removes the old root copies). Best-effort and idempotent — runs
/// every launch and no-ops once the orphans are gone. Only the known default cue names are
/// touched, never anything else in the folder.</summary>
private static void CleanUpLegacyRootSounds()
+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>3.5.0</Version>
<Version>3.6.0</Version>
</PropertyGroup>
<ItemGroup>
+108 -201
View File
@@ -12,18 +12,19 @@ namespace RemSound.App;
/// to the running assembly's version, and (optionally) downloads and installs the new build.
///
/// Update install flow (Windows-only): RemSound.exe can't overwrite itself while it's running,
/// so a successful install does the swap via a detached <c>cmd.exe</c> helper:
/// so a successful install hands off to a SEPARATE copy of the new RemSound.exe (see
/// <see cref="UpdateApplier"/>):
/// <list type="number">
/// <item>Download the release ZIP to <c>%TEMP%\RemSound-update-&lt;tag&gt;.zip</c>.</item>
/// <item>Extract to <c>&lt;exe&gt;\_update\</c>.</item>
/// <item>Write a one-shot batch file at <c>&lt;exe&gt;\_apply-update.cmd</c> that waits for
/// RemSound.exe to exit, robocopies the staged folder over the publish folder, deletes
/// the staging area, restarts RemSound.exe, and removes itself.</item>
/// <item>Start the batch with <c>CreateNoWindow</c> + detached, then call
/// <see cref="Application.Exit"/>.</item>
/// <item>Download the release ZIP and extract it to a per-user temp stage OFF the install
/// folder (<c>&lt;LocalAppData&gt;\RemSound\update\&lt;guid&gt;\app</c>).</item>
/// <item>Launch the staged <c>RemSound.exe --apply-update</c> from there, then exit.</item>
/// <item>That process waits for this one to fully exit, then back-up-and-swaps the new files
/// over the install in plain C# (retry + rename-aside, rolling back on any failure),
/// restarts RemSound, and the next launch clears the temp stage.</item>
/// </list>
/// The batch survives RemSound's exit because <c>cmd.exe</c> is its own process. Robocopy's
/// retry/wait flags handle the brief moment between RemSound exit and the file unlock.
/// Running the installer from temp means nothing in the install folder is locked by the updater
/// itself; doing the copy in C# rather than a generated batch + robocopy removes the whole class
/// of silent batch/robocopy failures the old helper hit on some machines.
///
/// The GitHub repo to poll is hard-coded — the App was designed to be redistributed from a
/// single canonical release stream, not to be re-pointed at a fork. If you need to publish
@@ -161,46 +162,43 @@ 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>
/// sentinel. Written into the install folder by <see cref="UpdateApplier"/> just before it
/// relaunches RemSound (the profile title is handed to it via <c>--resume-profile</c>); read
/// and deleted by <c>Program.Main</c> on the next startup, so a silent or mid-session update
/// drops the user back into the same profile they were running rather than at the picker.</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>Download the update ZIP, stage it to a per-user temp folder, and launch the new
/// version's in-app installer (<see cref="UpdateApplier"/>) to take over once this process
/// exits. Returns true if the installer was launched (caller should Application.Exit
/// immediately afterwards); false on any failure earlier in the pipeline. A false return
/// leaves the running instance — and the install — 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>
/// <paramref name="activeProfileTitle"/> — when non-empty, it's passed to the installer via
/// <c>--resume-profile</c>; the installer writes the one-shot <see cref="ResumeProfileSentinelName"/>
/// sentinel into the install folder just before it relaunches RemSound. On the next startup,
/// Program.Main reads it, loads that profile silently (skipping the picker), and deletes the
/// sentinel — so a silent / mid-session update behaves like the session never ended. 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
{
var baseDir = AppContext.BaseDirectory;
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);
var installDir = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
// 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. 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);
// Stage the new version into a LOCAL, per-user temp folder OUTSIDE the install (and off any
// Dropbox/OneDrive folder the install might sit in). The new RemSound.exe is launched FROM
// here to do the swap, so nothing in the install folder is locked by the updater itself, and
// a sync engine can't hold the staged files mid-update.
var stageRoot = Path.Combine(UpdateStageParentDir, Guid.NewGuid().ToString("N"));
var appDir = Path.Combine(stageRoot, "app");
var zipPath = Path.Combine(stageRoot, $"RemSound-update-{info.Tag}.zip");
Directory.CreateDirectory(appDir);
// This attempt starts clean: clear any stale failure marker / resume sentinel in the install.
TryDelete(Path.Combine(installDir, "update-failed.txt"));
TryDelete(Path.Combine(installDir, ResumeProfileSentinelName));
Log?.Invoke($"updater: downloading {info.DownloadUrl}");
await using (var src = await http.GetStreamAsync(info.DownloadUrl, token).ConfigureAwait(false))
@@ -209,47 +207,48 @@ internal sealed class RemSoundUpdater : IDisposable
await src.CopyToAsync(dst, token).ConfigureAwait(false);
}
Log?.Invoke($"updater: extracting to {stagingDir}");
Directory.CreateDirectory(stagingDir);
System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, stagingDir, overwriteFiles: true);
Log?.Invoke($"updater: extracting to {appDir}");
System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, appDir, overwriteFiles: true);
// Some release zips wrap everything in a single top-level folder
// (e.g. "RemSound-v1.1/RemSound.exe"). Flatten if that's the case so the
// robocopy step copies the right tree over the install location.
var stagingRoot = ResolveStagingRoot(stagingDir);
// Some release zips wrap everything in a single top-level folder (e.g. "RemSound-v1.1/").
// Flatten to the level that actually holds RemSound.exe.
var appRoot = ResolveStagingRoot(appDir);
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))
var stagedExe = Path.Combine(appRoot, "RemSound.exe");
if (!File.Exists(stagedExe))
{
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}");
}
Log?.Invoke("updater: staged RemSound.exe not found — aborting, install left untouched");
TryDeleteDirectory(stageRoot);
return false;
}
// Hand off to the NEW version's in-app installer (UpdateApplier). It waits for THIS process
// to exit, then back-up-and-swaps the files over the install in C# and restarts RemSound.
// ArgumentList quotes paths with spaces/odd characters correctly for us — no batch escaping.
var pid = System.Environment.ProcessId;
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/c \"\"{batchPath}\" {pid}\"",
FileName = stagedExe,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = baseDir,
WorkingDirectory = appRoot,
};
Log?.Invoke($"updater: launching install helper, parent PID {pid}");
psi.ArgumentList.Add("--apply-update");
psi.ArgumentList.Add("--update-source");
psi.ArgumentList.Add(appRoot);
psi.ArgumentList.Add("--update-target");
psi.ArgumentList.Add(installDir);
psi.ArgumentList.Add("--update-wait-pid");
psi.ArgumentList.Add(pid.ToString());
psi.ArgumentList.Add("--update-stage-root");
psi.ArgumentList.Add(stageRoot);
if (!string.IsNullOrWhiteSpace(activeProfileTitle))
{
psi.ArgumentList.Add("--resume-profile");
psi.ArgumentList.Add(activeProfileTitle);
}
Log?.Invoke($"updater: launching in-app installer from {appRoot}, parent PID {pid}");
System.Diagnostics.Process.Start(psi);
return true;
}
@@ -260,135 +259,6 @@ internal sealed class RemSoundUpdater : IDisposable
}
}
/// <summary>One-shot installer batch. Waits for the supplied PID to exit (so file locks
/// release), robocopies the staged folder over the install folder, removes the staging
/// area, restarts RemSound.exe, and self-deletes.
///
/// History: v1.0 of this helper used <c>/R:5 /W:1</c> on robocopy and unconditionally
/// restarted RemSound regardless of whether the copy actually succeeded. On
/// Dropbox-installed copies this failed silently — Dropbox held write locks on the
/// existing install files for ~1030 seconds after extraction kicked the sync off, robocopy
/// gave up after 5 seconds, and the helper relaunched the OLD binary. The user saw the same
/// version after "update".
///
/// v1.3 hardening (2026-05-15):
/// * Robocopy retries bumped to <c>/R:60 /W:1</c> — up to 60 seconds per file. Dropbox
/// locks reliably release inside that window.
/// * Robocopy exit code is captured and checked. Anything ≥ 8 is a true failure; the
/// helper writes <c>update-failed.txt</c> to the install dir with diagnostic detail,
/// does NOT relaunch the old binary, and leaves the staging folder intact so the
/// user (or a re-run of the updater) can recover. Codes 07 are robocopy's
/// "success-ish" range (0 = nothing changed, 1 = copied, 2 = extras, 3 = both, etc).
/// * Helper writes a step-by-step log to <c>_update-helper.log</c> in the install dir
/// for post-mortem when the copy goes wrong. Robocopy's own output is appended via
/// <c>/LOG+:</c>.
///
/// 2026-05-18 changes:
/// * Robocopy now also excludes <c>remsound.config.json</c> (the user's machine-local
/// config) and the <c>logs</c> / <c>profiles</c> / <c>recordings</c> folders, so an
/// update can never overwrite the user's own state — only app files are replaced.
/// * On SUCCESS the helper now also deletes <c>_update-helper.log</c> and any stale
/// <c>update-failed.txt</c> (the <c>_update</c> folder was already removed), leaving
/// a tidy install folder. The FAILURE branch still keeps all of them for diagnosis.
///
/// The helper is detached from RemSound at start time, so it survives the parent's exit.</summary>
private static string BuildInstallScript(string stagingRoot, string installDir)
{
var helperLog = Path.Combine(installDir, "_update-helper.log");
var failureMarker = Path.Combine(installDir, "update-failed.txt");
var stagingDir = Path.Combine(installDir, "_update");
var remsoundExe = Path.Combine(installDir, "RemSound.exe");
// Robocopy source/destination, with any trailing directory separator stripped.
// CRITICAL BUG FIX (2026-05-19): installDir is AppContext.BaseDirectory, which ends
// in a backslash. A quoted path that ends in a backslash — "D:\dir\" — is mis-parsed
// on the command line: the \" is read as an ESCAPED quote, so robocopy never receives
// a valid destination argument and exits immediately with code 16 (no files copied).
// That silently broke EVERY auto-update in every release to date. The robocopy line
// below MUST use these trimmed forms, never the raw {installDir} / {stagingRoot}.
var stagingArg = stagingRoot.TrimEnd('\\', '/');
var installArg = installDir.TrimEnd('\\', '/');
return $"""
@echo off
setlocal
rem RemSound auto-installer helper. Generated by RemSoundUpdater. Self-deleting on success.
set "PID=%~1"
set "LOG={helperLog}"
set "MARKER={failureMarker}"
echo. >> "%LOG%"
echo === %DATE% %TIME% update helper started, parent PID=%PID% === >> "%LOG%"
echo %DATE% %TIME% install dir=[%~dp0] >> "%LOG%"
:wait_loop
tasklist /FI "PID eq %PID%" 2>nul | find "%PID%" >nul
if not errorlevel 1 (
timeout /t 1 /nobreak >nul
goto wait_loop
)
echo %DATE% %TIME% parent exited, starting robocopy (R:60 W:1) >> "%LOG%"
rem /XF + /XD keep the update from ever overwriting the USER's own state: everything under
rem "user settings and logs" (global config, profiles, logs, sounds including any custom cue
rem WAVs the user dropped in) plus the legacy loose config. An update replaces APP files only.
rem build-release.ps1 keeps those out of the release zip; this is the second line of defence so
rem a bad zip still can't clobber them. The bare logs/profiles/recordings excludes stay for any
rem older layout still mid-migration.
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 "user settings and logs" /LOG+:"%LOG%"
set "ROBO_EXIT=%ERRORLEVEL%"
rem Guard against an empty exit code (e.g. robocopy never ran / ERRORLEVEL was clobbered):
rem an empty %ROBO_EXIT% turns the GEQ test below into a parse error. Default it to a
rem clear non-zero so the failure path is taken cleanly and logged with a real number.
if not defined ROBO_EXIT set "ROBO_EXIT=99"
echo %DATE% %TIME% robocopy exit=%ROBO_EXIT% >> "%LOG%"
if %ROBO_EXIT% GEQ 8 (
echo RemSound could not finish updating. > "%MARKER%"
echo. >> "%MARKER%"
echo The new version downloaded correctly, but RemSound could not >> "%MARKER%"
echo replace its program files with it. Nothing is broken - your >> "%MARKER%"
echo current version still works and has been left as it was. >> "%MARKER%"
echo. >> "%MARKER%"
echo What to do: >> "%MARKER%"
echo. >> "%MARKER%"
echo 1. Close RemSound completely. >> "%MARKER%"
echo 2. Wait about 30 seconds. A file-syncing, backup or antivirus >> "%MARKER%"
echo program may have been using RemSound's files; this gives it >> "%MARKER%"
echo time to finish and let go of them. >> "%MARKER%"
echo 3. Start RemSound again, open the Help menu, and choose >> "%MARKER%"
echo Check for updates to try once more. It usually works on the >> "%MARKER%"
echo second attempt. >> "%MARKER%"
echo. >> "%MARKER%"
echo If it still will not update: the new version's files are ready >> "%MARKER%"
echo and waiting in the folder named _update, next to RemSound.exe. >> "%MARKER%"
echo You can finish the update yourself by copying everything from >> "%MARKER%"
echo inside that _update folder into this folder, replacing the older >> "%MARKER%"
echo files when asked. >> "%MARKER%"
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%
)
rmdir /S /Q "{stagingDir}" 2>nul
echo %DATE% %TIME% update applied OK, cleaning up and restarting RemSound >> "%LOG%"
del "%MARKER%" 2>nul
start "" "{remsoundExe}"
rem Success cleanup: the staged _update folder is already gone (rmdir above). Now drop
rem the helper log and the failure marker too, so a clean update leaves the install
rem folder tidy with no _update / _update-helper.log / update-failed.txt left behind.
rem (The FAILURE branch above deliberately keeps all of these for diagnosis.)
rem The helper log is deleted last, after the final line is written to it.
del "%LOG%" 2>nul
del "%~f0"
""";
}
/// <summary>If the zip extracted to a single subfolder (typical when GitHub zips a tag),
/// return that subfolder so the copy works from the inner level. Otherwise return the
/// staging dir itself.</summary>
@@ -400,6 +270,43 @@ internal sealed class RemSoundUpdater : IDisposable
return stagingDir;
}
/// <summary>Per-user, local temp parent for update staging: &lt;LocalAppData&gt;\RemSound\update.
/// Deliberately OFF the install folder (which may be Dropbox/OneDrive-synced) and writable
/// without admin, so the new RemSound.exe can run from here to swap files over the install.</summary>
internal static string UpdateStageParentDir
{
get
{
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (string.IsNullOrWhiteSpace(local)) local = Path.GetTempPath();
return Path.Combine(local, "RemSound", "update");
}
}
/// <summary>Best-effort cleanup of leftover update artefacts, called on a normal launch. The
/// in-app installer can't delete the temp stage it's running from, so the next launch clears it;
/// this also sweeps away relics of the OLD (pre-3.6) batch updater that staged into the install
/// folder (the <c>_update</c> tree, <c>_apply-update.cmd</c>, <c>_update-helper.log</c>).</summary>
public static void CleanUpUpdateStages()
{
try
{
var parent = UpdateStageParentDir;
if (Directory.Exists(parent))
foreach (var dir in Directory.GetDirectories(parent)) TryDeleteDirectory(dir);
}
catch { /* best-effort */ }
try
{
var installDir = AppContext.BaseDirectory;
TryDeleteDirectory(Path.Combine(installDir, "_update"));
TryDelete(Path.Combine(installDir, "_apply-update.cmd"));
TryDelete(Path.Combine(installDir, "_update-helper.log"));
}
catch { /* best-effort */ }
}
/// <summary>Parses a release tag like <c>v1.2</c> or <c>1.2.3</c> into a <see cref="Version"/>.
/// Leading "v" is stripped. Missing minor/build parts get filled with zeros so the result
/// always compares meaningfully against <see cref="Assembly.GetName"/>.Version.</summary>
+8 -2
View File
@@ -20,7 +20,9 @@ internal enum SingleInstanceDecision
/// can't accidentally kill a healthy session that might be mid-recording. The force-close
/// option is the deliberate recovery path for a stuck copy (Andre's "make it go away"). Built
/// as a native TaskDialog to match the rest of RemSound's dialogs and because NVDA reads its
/// heading, body and buttons cleanly. No owner window — the main window doesn't exist yet.
/// heading, body and buttons cleanly. Shown through <see cref="ForegroundDialog"/> so it surfaces
/// front-and-centre with focus even when this copy was relaunched in the background by the updater;
/// ForegroundDialog supplies its own momentary owner, since the main window doesn't exist yet.
/// </summary>
internal static class SingleInstanceDialog
{
@@ -48,7 +50,11 @@ internal static class SingleInstanceDialog
AllowCancel = true,
};
var clicked = TaskDialog.ShowDialog(page);
// Front-and-centre with focus, even when this copy was relaunched in the background by the
// auto-updater — a foreground-refused process would otherwise open this behind everything,
// dinging away where a screen-reader user can't find it. ForegroundDialog supplies its own
// momentary owner, so this works before any main window exists.
var clicked = ForegroundDialog.Show(owner => TaskDialog.ShowDialog(owner, page));
if (clicked == forceButton) return SingleInstanceDecision.ForceClose;
if (clicked == switchButton) return SingleInstanceDecision.SwitchToRunning;
return SingleInstanceDecision.Cancel;
+254
View File
@@ -0,0 +1,254 @@
using System.Diagnostics;
namespace RemSound.App;
/// <summary>
/// In-process update installer — the robust replacement for the old generated cmd.exe + robocopy
/// helper, modelled on how a battle-tested portable app does it.
///
/// It runs as a SEPARATE RemSound.exe process launched from a TEMP copy of the NEW version (so
/// nothing in the install folder is locked by the updater itself), waits for the old app to FULLY
/// exit (a real process-handle wait, not a task-list poll), then back-up-and-swaps the new files
/// over the install in plain C#:
/// * each existing target file is RENAMED aside into a backup folder first (renaming survives a
/// lock that an overwrite can't), then the new file is copied into place;
/// * every move/copy retries for a while, to ride out a transient lock (e.g. Dropbox / OneDrive
/// finishing a sync) — the equivalent of robocopy's /R:60, but it can't silently swallow the
/// exit code;
/// * if any step can't complete, the whole swap is ROLLED BACK from the backup, so a failed
/// update never leaves a broken half-installed folder — the old version is restored intact.
/// User data is untouched: only files present in the new release are written, so the
/// "user settings and logs" folder and recordings (which aren't in the release) are left alone.
///
/// Invoked by Program.Main when the command line carries <c>--apply-update</c>. Args:
/// --apply-update --update-source &lt;dir&gt; --update-target &lt;dir&gt; --update-wait-pid &lt;pid&gt;
/// --update-stage-root &lt;dir&gt; [--resume-profile &lt;title&gt;] [--update-no-restart]
/// </summary>
internal static class UpdateApplier
{
private const int CopyRetryAttempts = 60; // ~ matches robocopy /R:60
private const int CopyRetryDelayMs = 1000; // 1 s between attempts → up to ~60 s per file
private const int ProcessExitWaitMs = 30000; // wait up to 30 s for the old app to exit
private const int PostExitSettleMs = 1000; // let the OS release file handles after exit
public static void Run(string[] args)
{
var source = GetArg(args, "--update-source");
var target = GetArg(args, "--update-target");
var stageRoot = GetArg(args, "--update-stage-root");
var pidText = GetArg(args, "--update-wait-pid");
var resumeProfile = GetArg(args, "--resume-profile");
var noRestart = Array.Exists(args, a => string.Equals(a, "--update-no-restart", StringComparison.OrdinalIgnoreCase));
// Log to a single file in the install root (NOT the "user settings and logs" folder — the
// updater runs before the new version's folder-migration, so it must not pre-create that
// folder). Persists for diagnosis; survives the temp stage being cleaned up.
var logPath = string.IsNullOrWhiteSpace(target)
? Path.Combine(Path.GetTempPath(), "RemSound-updater.log")
: Path.Combine(target, "updater.log");
void Log(string m) => AppendLog(logPath, m);
try
{
if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(target))
throw new InvalidOperationException("missing --update-source / --update-target");
Log($"=== apply-update started. source=[{source}] target=[{target}] pid=[{pidText}] ===");
if (int.TryParse(pidText, out var pid) && pid > 0) WaitForExit(pid, Log);
var backupDir = Path.Combine(target, "_update-backup");
TryDeleteDirectory(backupDir);
var moved = new List<(string backup, string dest)>(); // old file renamed aside → restore on rollback
var created = new List<string>(); // brand-new file (no old to restore) → delete on rollback
try
{
SwapInNewFiles(source, target, backupDir, moved, created, Log);
}
catch (Exception swapEx)
{
Log($"SWAP FAILED: {swapEx.GetType().Name}: {swapEx.Message} — rolling back");
RollBack(moved, created, Log);
TryDeleteDirectory(backupDir); // restored files were moved back out; drop the empty backup tree
WriteFailureMarker(target, logPath);
Log("rolled back to previous version" + (noRestart ? "" : "; restarting it"));
if (!noRestart) RestartApp(target, resumeProfile, Log); // old version restored intact — safe to relaunch
CleanupStage(stageRoot, Log);
return;
}
TryDeleteDirectory(backupDir);
WriteResumeSentinel(target, resumeProfile, Log);
Log("apply-update OK" + (noRestart ? "" : " — restarting RemSound"));
if (!noRestart) RestartApp(target, resumeProfile, Log);
CleanupStage(stageRoot, Log);
}
catch (Exception ex)
{
Log($"apply-update FATAL: {ex.GetType().Name}: {ex.Message}");
try
{
System.Windows.Forms.MessageBox.Show(
"RemSound could not finish updating, and has left your current version exactly as it was.\n\n"
+ "Please reopen RemSound and try Help → Check for updates again.\n\n" + ex.Message,
"RemSound update", System.Windows.Forms.MessageBoxButtons.OK,
System.Windows.Forms.MessageBoxIcon.Warning);
}
catch { /* headless / no message loop — the log has it */ }
}
}
/// <summary>Wait for the parent RemSound to fully exit so its files release. A real wait on the
/// process handle, then a short settle for the OS to drop the exe's image lock.</summary>
private static void WaitForExit(int pid, Action<string> log)
{
try
{
using var p = Process.GetProcessById(pid);
log($"waiting for parent PID {pid} to exit");
if (!p.WaitForExit(ProcessExitWaitMs)) log($"parent PID {pid} still running after {ProcessExitWaitMs} ms — proceeding anyway");
else log("parent exited");
}
catch
{
log($"parent PID {pid} already gone");
}
Thread.Sleep(PostExitSettleMs);
}
/// <summary>Rename each existing target file aside into the backup folder, then copy the new
/// one in. Throws if a file genuinely can't be replaced after the retry window — the caller
/// rolls back. Only writes files that exist in the new release, so user data is left untouched.</summary>
private static void SwapInNewFiles(string source, string target, string backupDir,
List<(string backup, string dest)> moved, List<string> created, Action<string> log)
{
var srcFull = Path.GetFullPath(source);
var copiedCount = 0;
foreach (var srcFile in Directory.GetFiles(srcFull, "*", SearchOption.AllDirectories))
{
var rel = Path.GetRelativePath(srcFull, srcFile);
var dest = Path.Combine(target, rel);
Directory.CreateDirectory(Path.GetDirectoryName(dest)!);
if (File.Exists(dest))
{
var bak = Path.Combine(backupDir, rel);
Directory.CreateDirectory(Path.GetDirectoryName(bak)!);
RetryFileOp(() => File.Move(dest, bak), $"move-aside {rel}"); // frees + backs up the old file
moved.Add((bak, dest));
RetryFileOp(() => File.Copy(srcFile, dest, overwrite: true), $"copy {rel}");
}
else
{
RetryFileOp(() => File.Copy(srcFile, dest, overwrite: true), $"new {rel}");
created.Add(dest);
}
copiedCount++;
}
log($"swap complete: {copiedCount} files in, {moved.Count} replaced, {created.Count} new");
}
/// <summary>Restore the old files (and remove the partial new ones) after a failed swap.</summary>
private static void RollBack(List<(string backup, string dest)> moved, List<string> created, Action<string> log)
{
foreach (var dest in created)
{
try { if (File.Exists(dest)) File.Delete(dest); } catch { /* best-effort */ }
}
for (var i = moved.Count - 1; i >= 0; i--)
{
var (backup, dest) = moved[i];
try
{
if (File.Exists(dest)) File.Delete(dest); // remove the partial new copy if it landed
File.Move(backup, dest); // put the old one back
}
catch (Exception ex) { log($"rollback could not restore {dest}: {ex.Message}"); }
}
}
/// <summary>Run a file operation, retrying while the target is locked (a sync/AV/handle still
/// closing). Renaming-aside already beats most locks; this rides out the transient ones.</summary>
private static void RetryFileOp(Action op, string what)
{
for (var attempt = 1; ; attempt++)
{
try { op(); return; }
catch (Exception ex) when ((ex is IOException || ex is UnauthorizedAccessException) && attempt < CopyRetryAttempts)
{
Thread.Sleep(CopyRetryDelayMs);
}
}
}
private static void WriteResumeSentinel(string target, string? profile, Action<string> log)
{
if (string.IsNullOrWhiteSpace(profile)) return;
try
{
File.WriteAllText(Path.Combine(target, RemSoundUpdater.ResumeProfileSentinelName), profile);
log($"wrote resume sentinel for profile '{profile}'");
}
catch (Exception ex) { log($"could not write resume sentinel: {ex.Message}"); }
}
private static void RestartApp(string target, string? profile, Action<string> log)
{
try
{
var exe = Path.Combine(target, "RemSound.exe");
if (!File.Exists(exe)) { log($"cannot restart — {exe} missing"); return; }
Process.Start(new ProcessStartInfo { FileName = exe, WorkingDirectory = target, UseShellExecute = true });
log("RemSound restarted");
}
catch (Exception ex) { log($"could not restart RemSound: {ex.Message}"); }
}
/// <summary>Best-effort temp cleanup. We're running FROM the stage, so we can't delete our own
/// exe's folder here — the restarted app finishes that on startup (see Program.CleanUpUpdateStages).</summary>
private static void CleanupStage(string? stageRoot, Action<string> log)
{
if (string.IsNullOrWhiteSpace(stageRoot)) return;
try
{
foreach (var zip in Directory.GetFiles(stageRoot, "*.zip")) { try { File.Delete(zip); } catch { } }
}
catch (Exception ex) { log($"stage cleanup (partial): {ex.Message}"); }
}
private static void WriteFailureMarker(string target, string logPath)
{
try
{
File.WriteAllText(Path.Combine(target, "update-failed.txt"),
"RemSound could not finish updating, so it put your previous version back exactly as it was.\r\n\r\n"
+ "Nothing is broken. Reopen RemSound and try Help → Check for updates again; it usually works\r\n"
+ "on the next attempt. Technical details are in:\r\n " + logPath + "\r\n\r\n"
+ "You can delete this file once RemSound has updated successfully.\r\n");
}
catch { /* marker is best-effort */ }
}
private static void AppendLog(string path, string message)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.AppendAllText(path, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}{Environment.NewLine}");
}
catch { /* never let logging break the update */ }
}
private static string? GetArg(string[] args, string name)
{
for (var i = 0; i < args.Length - 1; i++)
if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase)) return args[i + 1];
return null;
}
private static void TryDeleteDirectory(string path)
{
try { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); } catch { /* best-effort */ }
}
}