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:
co-authored by
Claude Opus 4.8
parent
4dbe9a47a0
commit
55bdcde0af
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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-<tag>.zip</c>.</item>
|
||||
/// <item>Extract to <c><exe>\_update\</c>.</item>
|
||||
/// <item>Write a one-shot batch file at <c><exe>\_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><LocalAppData>\RemSound\update\<guid>\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 ~10–30 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 0–7 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: <LocalAppData>\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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <dir> --update-target <dir> --update-wait-pid <pid>
|
||||
/// --update-stage-root <dir> [--resume-profile <title>] [--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 */ }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user