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
@@ -940,13 +940,12 @@ A short countdown picks _Install now_ automatically if you don't choose anything
|
|||||||
|
|
||||||
### What happens during an install
|
### What happens during an install
|
||||||
|
|
||||||
RemSound can't replace its own program file while it's running, so an install starts a small helper that finishes the job once RemSound has closed:
|
RemSound can't replace its own program file while it's running, so it hands the job to a fresh copy of the new version, which does the swap once RemSound has closed:
|
||||||
|
|
||||||
1. RemSound downloads the new version into a holding folder next to the running program.
|
1. RemSound downloads the new version into a temporary folder kept on your own machine, away from the install folder.
|
||||||
2. It writes a tiny helper alongside it that watches for RemSound to close.
|
2. It starts the new copy from that temporary folder, and closes itself.
|
||||||
3. RemSound closes.
|
3. The new copy waits for RemSound to close fully, then moves your old program files aside and copies the new ones into place. If a file is briefly in use, it waits and retries rather than giving up.
|
||||||
4. The helper notices, copies the new files over the install folder, deletes the holding folder, and reopens RemSound on the same profile you were running.
|
4. It reopens RemSound on the same profile you were running, and clears the temporary folder away.
|
||||||
5. The helper deletes itself.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -962,11 +961,11 @@ If the profile that was running can't be found after the update (you'd renamed o
|
|||||||
|
|
||||||
### If an install fails
|
### If an install fails
|
||||||
|
|
||||||
The update download is best-effort: a flaky network, a locked install folder, or a temporarily-unavailable version will pop up a message saying it couldn't finish, and leave your running version untouched. The address of the download page is in that message, so you can get the new version in a browser and install it by hand if you need to. If you installed RemSound into `Program Files` without giving your account permission to write to that folder, the install helper's copy step will fail too — either fix the permission or move RemSound to a folder you can write to (somewhere inside your own user folder, for instance).
|
The update download is best-effort: a flaky network, or a temporarily-unavailable version, will pop up a message saying it couldn't finish, and leave your running version untouched. The address of the download page is in that message, so you can get the new version in a browser and install it by hand if you need to. If you installed RemSound into `Program Files` without giving your account permission to write to that folder, the install can't replace the files there — either fix the permission or move RemSound to a folder you can write to (somewhere inside your own user folder, for instance).
|
||||||
|
|
||||||
**If the helper's copy step itself fails** — most often because Dropbox or another sync app was holding the install folder's files open when the helper tried to replace them — the helper leaves a file called `update-failed.txt` next to the RemSound program, with details, and leaves the new files sitting in an `_update` subfolder. It does _not_ reopen the old version in that case — so the next time you start RemSound by hand, you'll either get the still-old version with that note telling you what to do, or you can copy the contents of the `_update` folder over the install folder yourself. The most reliable fix is: close RemSound, wait 30 seconds for Dropbox to settle, start it again, and try **Help → Check for updates** once more — it almost always works on the second try.
|
**If the file swap itself can't finish** — for example because a sync app or another program was holding one of the files open and wouldn't let go — RemSound _puts your previous version back exactly as it was_ and reopens it, rather than leaving you with a half-finished install. It writes a short note called `update-failed.txt` next to the program explaining what happened. Nothing is broken — just try **Help → Check for updates** again, and it almost always goes through on the next attempt. If a sync app like Dropbox is involved, closing RemSound and giving it half a minute to settle before retrying helps.
|
||||||
|
|
||||||
A step-by-step record of every helper run is kept in a helper log file in the install folder — useful if a failure keeps happening and you want to share it for diagnosis.
|
A step-by-step record of every update is kept in a file called `updater.log` in the install folder — useful if a failure keeps happening and you want to share it for diagnosis.
|
||||||
|
|
||||||
### The About dialog and release notes
|
### The About dialog and release notes
|
||||||
|
|
||||||
|
|||||||
+20
-26
@@ -1,45 +1,39 @@
|
|||||||
# RemSound v3.5
|
# RemSound v3.6
|
||||||
|
|
||||||
A reliability-and-tidiness release: sound cards recover on their own if you unplug them, the audio cushion now sizes itself to each card, everything you own lives in one neat folder that updates can't touch, and a stack of under-the-hood fixes from a top-to-bottom code review.
|
A reliability release centred on updating. RemSound now installs its own updates instead of handing the job to a Windows script — so updates that used to quietly fail on some machines go through, and a failed update can never leave you half-installed.
|
||||||
|
|
||||||
## Recover a sound card you unplug
|
## Updating is far more reliable
|
||||||
|
|
||||||
If a USB sound card you're listening through is unplugged and plugged back in, **RemSound now re-opens it automatically and the sound resumes** — you don't have to re-tick it. (This works when the card comes back as the same Windows device, which is the usual case when you plug it into the same socket.)
|
RemSound used to finish an update by handing the file-swap to a small Windows batch script. On some machines that step would silently fail — you'd "update" and still be on the old version, sometimes with nothing on screen to say why.
|
||||||
|
|
||||||
## The audio cushion sizes itself to each card
|
**RemSound now installs updates itself, in its own code:**
|
||||||
|
|
||||||
RemSound keeps a small cushion at the sound card to smooth over the tiny timing difference between two machines' sound clocks. It now **sizes that cushion to each card automatically** — a card that moves sound in bigger chunks gets a little more room, a fast interface stays tight — so there's nothing to fiddle with. It just settles on the right amount.
|
- It runs the install from a fresh copy of the new version in a temporary folder, so nothing in the program folder is locked while the swap happens.
|
||||||
|
- It waits for the old copy to fully close, then moves the old files aside and copies the new ones in — **retrying** if a file is briefly held open (by a sync app, say) rather than giving up.
|
||||||
|
- **If anything goes wrong, it puts your previous version back exactly as it was** and reopens it. A failed update can never leave a broken, half-installed RemSound.
|
||||||
|
- Every step is written to a plain `updater.log` next to the program, so if a problem ever does recur there's a real reason to read, not a blank.
|
||||||
|
|
||||||
## A microphone-privacy heads-up
|
Your settings, profiles, logs and cue sounds are never touched by any of this.
|
||||||
|
|
||||||
Windows can silently block apps from using your microphone, and when it does, a mic sends silence rather than failing — easy to miss. RemSound now **warns you** when you switch on a mic Windows is blocking (including when a profile loads with one already on), and points you at the exact two Windows settings to turn on.
|
## More warnings come to the front
|
||||||
|
|
||||||
## Warnings always come to the front
|
Every important notice now surfaces in front with focus, even when RemSound is minimised to the tray. v3.6 adds the last two that didn't: the **"RemSound is already running"** dialog (and its follow-up message), and the **what's-new note shown after an update** that installed while RemSound was tucked away.
|
||||||
|
|
||||||
Even when RemSound is minimised to the system tray, an important warning — the "your files have moved" notice, a microphone warning, an update prompt — now **pops up in front with focus**, so your screen reader reads it straight away. RemSound stays in the tray; only the warning comes forward.
|
|
||||||
|
|
||||||
## One tidy folder for everything
|
|
||||||
|
|
||||||
Everything this machine keeps for you — your settings, profiles, logs, and cue sounds — now lives together in one folder inside RemSound called **user settings and logs**. It moves there automatically the first time you run v3.5, and RemSound tells you once. Nothing is lost. **From now on, updates never touch that folder** — so any custom cue sounds you put there survive updates.
|
|
||||||
|
|
||||||
## Smaller things
|
|
||||||
|
|
||||||
- **Volume and mute** now come back correctly when you load a profile.
|
|
||||||
- A round of reliability fixes from a full code review: two resource leaks closed, profile and settings saves are now crash-safe, and a good clear-out of dead code.
|
|
||||||
|
|
||||||
## Compatibility
|
## Compatibility
|
||||||
|
|
||||||
**v3.5 talks to v3.3 and v3.4 with no trouble** — the over-the-network format hasn't changed, so you don't have to update both ends at once. (You still need everyone on **v3.3 or newer**, because that's where end-to-end encryption came in.)
|
**v3.6 talks to v3.3, v3.4 and v3.5 with no trouble** — the over-the-network format hasn't changed, so you don't have to update both ends at once. (Everyone still needs to be on **v3.3 or newer**, because that's where end-to-end encryption came in.)
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
1. Download `RemSound-v3.5.zip` from this release.
|
1. Download `RemSound-v3.6.zip` from this release.
|
||||||
2. Close RemSound.
|
2. Close RemSound.
|
||||||
3. Extract the zip **over your existing RemSound folder**, overwriting program files when prompted. The zip is program files only — it won't touch your profiles, settings, recordings, or sounds.
|
3. Extract the zip **over your existing RemSound folder**, overwriting program files when prompted. The zip is program files only — it won't touch your settings, profiles, logs, recordings, or sounds.
|
||||||
4. Run `RemSound.exe`. The first launch tidies your files into the new folder and tells you once.
|
4. Run `RemSound.exe`.
|
||||||
|
|
||||||
## Upgrading
|
## Upgrading
|
||||||
|
|
||||||
**From v1.9 onward:** Help → Check for updates works — it will fetch and install v3.5 automatically. If you've ticked "Check for updates on startup" and "Silently install updates", v3.5 installs itself shortly after launch.
|
**From v1.9 onward:** Help → Check for updates works — it will fetch and install v3.6 automatically, and if you've ticked "Check for updates on startup" and "Silently install updates" it installs itself shortly after launch.
|
||||||
|
|
||||||
**v1.8 and earlier:** the auto-updater in those versions has a fault that prevents it installing updates, so Check for updates will download v3.5 but not apply it. Install v3.5 by hand using the steps above — just this once. From the build you install onward, updates are automatic.
|
Note that the hop *to* v3.6 still uses your current version's updater. The new, more reliable in-app installer only takes over **once you're on v3.6**. So on the rare machine where auto-update has been failing — exactly the problem this release fixes — download `RemSound-v3.6.zip` and extract it over your folder by hand, just this once. Every update after that uses the new installer.
|
||||||
|
|
||||||
|
**v1.8 and earlier:** the auto-updater in those versions has a fault that prevents it installing updates at all — install v3.6 by hand using the steps above.
|
||||||
|
|||||||
+8
-9
@@ -1001,14 +1001,13 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
|
|||||||
|
|
||||||
<h3>What happens during an install</h3>
|
<h3>What happens during an install</h3>
|
||||||
|
|
||||||
<p>RemSound can't replace its own program file while it's running, so an install starts a small helper that finishes the job once RemSound has closed:</p>
|
<p>RemSound can't replace its own program file while it's running, so it hands the job to a fresh copy of the new version, which does the swap once RemSound has closed:</p>
|
||||||
|
|
||||||
<ol>
|
<ol>
|
||||||
<li>RemSound downloads the new version into a holding folder next to the running program.</li>
|
<li>RemSound downloads the new version into a temporary folder kept on your own machine, away from the install folder.</li>
|
||||||
<li>It writes a tiny helper alongside it that watches for RemSound to close.</li>
|
<li>It starts the new copy from that temporary folder, and closes itself.</li>
|
||||||
<li>RemSound closes.</li>
|
<li>The new copy waits for RemSound to close fully, then moves your old program files aside and copies the new ones into place. If a file is briefly in use, it waits and retries rather than giving up.</li>
|
||||||
<li>The helper notices, copies the new files over the install folder, deletes the holding folder, and reopens RemSound on the same profile you were running.</li>
|
<li>It reopens RemSound on the same profile you were running, and clears the temporary folder away.</li>
|
||||||
<li>The helper deletes itself.</li>
|
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<p>You'll see the window close, then reopen on the new version within a second or two. Anything that was unsaved in the old session (a profile you were partway through editing, for example) is lost — RemSound will not save it for you before installing. Save first if you've been making changes.</p>
|
<p>You'll see the window close, then reopen on the new version within a second or two. Anything that was unsaved in the old session (a profile you were partway through editing, for example) is lost — RemSound will not save it for you before installing. Save first if you've been making changes.</p>
|
||||||
@@ -1023,11 +1022,11 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
|
|||||||
|
|
||||||
<h3>If an install fails</h3>
|
<h3>If an install fails</h3>
|
||||||
|
|
||||||
<p>The update download is best-effort: a flaky network, a locked install folder, or a temporarily-unavailable version will pop up a message saying it couldn't finish, and leave your running version untouched. The address of the download page is in that message, so you can get the new version in a browser and install it by hand if you need to. If you installed RemSound into <code>Program Files</code> without giving your account permission to write to that folder, the install helper's copy step will fail too — either fix the permission or move RemSound to a folder you can write to (somewhere inside your own user folder, for instance).</p>
|
<p>The update download is best-effort: a flaky network, or a temporarily-unavailable version, will pop up a message saying it couldn't finish, and leave your running version untouched. The address of the download page is in that message, so you can get the new version in a browser and install it by hand if you need to. If you installed RemSound into <code>Program Files</code> without giving your account permission to write to that folder, the install can't replace the files there — either fix the permission or move RemSound to a folder you can write to (somewhere inside your own user folder, for instance).</p>
|
||||||
|
|
||||||
<p><strong>If the helper's copy step itself fails</strong> — most often because Dropbox or another sync app was holding the install folder's files open when the helper tried to replace them — the helper leaves a file called <code>update-failed.txt</code> next to the RemSound program, with details, and leaves the new files sitting in an <code>_update</code> subfolder. It does <em>not</em> reopen the old version in that case — so the next time you start RemSound by hand, you'll either get the still-old version with that note telling you what to do, or you can copy the contents of the <code>_update</code> folder over the install folder yourself. The most reliable fix is: close RemSound, wait 30 seconds for Dropbox to settle, start it again, and try <strong>Help → Check for updates</strong> once more — it almost always works on the second try.</p>
|
<p><strong>If the file swap itself can't finish</strong> — for example because a sync app or another program was holding one of the files open and wouldn't let go — RemSound <em>puts your previous version back exactly as it was</em> and reopens it, rather than leaving you with a half-finished install. It writes a short note called <code>update-failed.txt</code> next to the program explaining what happened. Nothing is broken — just try <strong>Help → Check for updates</strong> again, and it almost always goes through on the next attempt. If a sync app like Dropbox is involved, closing RemSound and giving it half a minute to settle before retrying helps.</p>
|
||||||
|
|
||||||
<p>A step-by-step record of every helper run is kept in a helper log file in the install folder — useful if a failure keeps happening and you want to share it for diagnosis.</p>
|
<p>A step-by-step record of every update is kept in a file called <code>updater.log</code> in the install folder — useful if a failure keeps happening and you want to share it for diagnosis.</p>
|
||||||
|
|
||||||
<h3>The About dialog and release notes</h3>
|
<h3>The About dialog and release notes</h3>
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,20 @@ internal sealed class AboutDialog : Form
|
|||||||
/// updates" path.</summary>
|
/// updates" path.</summary>
|
||||||
private const string ReleaseNotes =
|
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
|
RemSound v3.5
|
||||||
|
|
||||||
Recover a sound card you unplug: if a USB sound card
|
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; });
|
Show<object?>(owner => { show(owner); return null; });
|
||||||
|
|
||||||
/// <summary>Force <paramref name="hWnd"/> to the foreground even when RemSound isn't the active
|
/// <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
|
/// app — including the hardest case: a process the auto-updater RELAUNCHED in the background while
|
||||||
/// our input queue to the current foreground thread for the call lifts that restriction. The
|
/// RemSound was minimised. Windows refuses SetForegroundWindow to a background-spawned process (it
|
||||||
/// owner is top-most regardless, so this is belt-and-braces for focus.</summary>
|
/// 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)
|
private static void ForceForeground(IntPtr hWnd)
|
||||||
{
|
{
|
||||||
|
uint savedTimeout = 0;
|
||||||
|
var loweredTimeout = false;
|
||||||
try
|
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 foreThread = GetWindowThreadProcessId(GetForegroundWindow(), out _);
|
||||||
var thisThread = GetCurrentThreadId();
|
var thisThread = GetCurrentThreadId();
|
||||||
if (foreThread != 0 && foreThread != thisThread)
|
if (foreThread != 0 && foreThread != thisThread)
|
||||||
@@ -65,7 +75,19 @@ internal static class ForegroundDialog
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { /* best-effort; the owner is top-most anyway */ }
|
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 IntPtr GetForegroundWindow();
|
||||||
[DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
|
[DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
|
||||||
@@ -73,4 +95,6 @@ internal static class ForegroundDialog
|
|||||||
[DllImport("user32.dll")] private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach);
|
[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 SetForegroundWindow(IntPtr hWnd);
|
||||||
[DllImport("user32.dll")] private static extern bool BringWindowToTop(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;
|
private static volatile MainForm? activeMainForm;
|
||||||
|
|
||||||
[STAThread]
|
[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.
|
// 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
|
// 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
|
// 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-
|
// 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
|
// 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
|
// sounds\. An update copies the new sounds\ tree but never purges, so it never deletes
|
||||||
// never deletes these orphans — they just linger in the root. Best-effort + idempotent:
|
// these orphans — they just linger in the root. Best-effort + idempotent:
|
||||||
// a no-op once they're gone. 2026-06-08.
|
// a no-op once they're gone. 2026-06-08.
|
||||||
CleanUpLegacyRootSounds();
|
CleanUpLegacyRootSounds();
|
||||||
|
|
||||||
@@ -66,12 +76,13 @@ internal static class Program
|
|||||||
// a killed copy is slow to release the abandoned mutex / its audio devices.
|
// a killed copy is slow to release the abandoned mutex / its audio devices.
|
||||||
if (!instance.TryAcquire(TimeSpan.FromSeconds(5)))
|
if (!instance.TryAcquire(TimeSpan.FromSeconds(5)))
|
||||||
{
|
{
|
||||||
MessageBox.Show(
|
ForegroundDialog.Show(owner => MessageBox.Show(
|
||||||
|
owner,
|
||||||
cleared
|
cleared
|
||||||
? "RemSound closed the other copy but couldn't start cleanly. Please launch RemSound again."
|
? "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 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",
|
"RemSound is already running",
|
||||||
MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
MessageBoxButtons.OK, MessageBoxIcon.Warning));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -83,6 +94,10 @@ internal static class Program
|
|||||||
instance.StartActivationListener();
|
instance.StartActivationListener();
|
||||||
instance.ActivateRequested += () => activeMainForm?.RestoreFromTray();
|
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
|
// 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
|
// 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.
|
// 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
|
/// <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
|
/// pre-2026-05-28 builds (the cues moved into <c>sounds\</c> then; an update copies the new
|
||||||
/// the new tree but never removes the old root copies). Best-effort and idempotent — runs
|
/// 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
|
/// 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>
|
/// touched, never anything else in the folder.</summary>
|
||||||
private static void CleanUpLegacyRootSounds()
|
private static void CleanUpLegacyRootSounds()
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
tag_name on the latest GitHub release; bump it on every public release. The
|
tag_name on the latest GitHub release; bump it on every public release. The
|
||||||
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
|
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
|
||||||
is what the About dialog and the updater both read. -->
|
is what the About dialog and the updater both read. -->
|
||||||
<Version>3.5.0</Version>
|
<Version>3.6.0</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
+108
-201
@@ -12,18 +12,19 @@ namespace RemSound.App;
|
|||||||
/// to the running assembly's version, and (optionally) downloads and installs the new build.
|
/// 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,
|
/// 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">
|
/// <list type="number">
|
||||||
/// <item>Download the release ZIP to <c>%TEMP%\RemSound-update-<tag>.zip</c>.</item>
|
/// <item>Download the release ZIP and extract it to a per-user temp stage OFF the install
|
||||||
/// <item>Extract to <c><exe>\_update\</c>.</item>
|
/// folder (<c><LocalAppData>\RemSound\update\<guid>\app</c>).</item>
|
||||||
/// <item>Write a one-shot batch file at <c><exe>\_apply-update.cmd</c> that waits for
|
/// <item>Launch the staged <c>RemSound.exe --apply-update …</c> from there, then exit.</item>
|
||||||
/// RemSound.exe to exit, robocopies the staged folder over the publish folder, deletes
|
/// <item>That process waits for this one to fully exit, then back-up-and-swaps the new files
|
||||||
/// the staging area, restarts RemSound.exe, and removes itself.</item>
|
/// over the install in plain C# (retry + rename-aside, rolling back on any failure),
|
||||||
/// <item>Start the batch with <c>CreateNoWindow</c> + detached, then call
|
/// restarts RemSound, and the next launch clears the temp stage.</item>
|
||||||
/// <see cref="Application.Exit"/>.</item>
|
|
||||||
/// </list>
|
/// </list>
|
||||||
/// The batch survives RemSound's exit because <c>cmd.exe</c> is its own process. Robocopy's
|
/// Running the installer from temp means nothing in the install folder is locked by the updater
|
||||||
/// retry/wait flags handle the brief moment between RemSound exit and the file unlock.
|
/// 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
|
/// 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
|
/// 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"
|
/// <summary>Filename of the one-shot "after the update restart, silently load this profile"
|
||||||
/// sentinel. Written next to RemSound.exe by <see cref="DownloadAndStageInstallAsync"/>
|
/// sentinel. Written into the install folder by <see cref="UpdateApplier"/> just before it
|
||||||
/// when the caller supplies a non-empty <c>activeProfileTitle</c>; read and deleted by
|
/// relaunches RemSound (the profile title is handed to it via <c>--resume-profile</c>); read
|
||||||
/// <c>Program.Main</c> on the next startup. Lives in the install directory (not %TEMP%
|
/// and deleted by <c>Program.Main</c> on the next startup, so a silent or mid-session update
|
||||||
/// or %APPDATA%) because the helper batch's robocopy step needs to know to skip it —
|
/// drops the user back into the same profile they were running rather than at the picker.</summary>
|
||||||
/// see the <c>/XF</c> list in <see cref="BuildInstallScript"/>.</summary>
|
|
||||||
public const string ResumeProfileSentinelName = "_resume-after-update.txt";
|
public const string ResumeProfileSentinelName = "_resume-after-update.txt";
|
||||||
|
|
||||||
/// <summary>Download the update ZIP, stage it next to RemSound.exe, spawn the detached
|
/// <summary>Download the update ZIP, stage it to a per-user temp folder, and launch the new
|
||||||
/// install helper, and ask the App to exit so the helper can take over. Returns true if
|
/// version's in-app installer (<see cref="UpdateApplier"/>) to take over once this process
|
||||||
/// the helper was launched (caller should Application.Exit immediately afterwards);
|
/// exits. Returns true if the installer was launched (caller should Application.Exit
|
||||||
/// false on any failure earlier in the pipeline. A false return leaves the running
|
/// immediately afterwards); false on any failure earlier in the pipeline. A false return
|
||||||
/// instance untouched.
|
/// leaves the running instance — and the install — untouched.
|
||||||
///
|
///
|
||||||
/// <paramref name="activeProfileTitle"/> — when non-empty, a one-shot sentinel file
|
/// <paramref name="activeProfileTitle"/> — when non-empty, it's passed to the installer via
|
||||||
/// <see cref="ResumeProfileSentinelName"/> is written next to RemSound.exe just before
|
/// <c>--resume-profile</c>; the installer writes the one-shot <see cref="ResumeProfileSentinelName"/>
|
||||||
/// the helper is launched. On the next startup, Program.Main reads it, loads that profile
|
/// sentinel into the install folder just before it relaunches RemSound. On the next startup,
|
||||||
/// silently (skipping the picker), and deletes the sentinel. This makes a silent / manual
|
/// Program.Main reads it, loads that profile silently (skipping the picker), and deletes the
|
||||||
/// update behave like the session never ended — the user is back in the same profile
|
/// sentinel — so a silent / mid-session update behaves like the session never ended. When
|
||||||
/// they were running, without having to remember which one it was. When null/empty, no
|
/// null/empty, no sentinel is written and the post-update launch uses whatever startup
|
||||||
/// sentinel is written and the post-update launch uses whatever startup behaviour
|
/// behaviour AppConfig has configured.</summary>
|
||||||
/// AppConfig has configured.</summary>
|
|
||||||
public async Task<bool> DownloadAndStageInstallAsync(UpdateInfo info, string? activeProfileTitle = null, CancellationToken token = default)
|
public async Task<bool> DownloadAndStageInstallAsync(UpdateInfo info, string? activeProfileTitle = null, CancellationToken token = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var baseDir = AppContext.BaseDirectory;
|
var installDir = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
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
|
// Stage the new version into a LOCAL, per-user temp folder OUTSIDE the install (and off any
|
||||||
// the failure marker — the new attempt starts clean and only re-creates the
|
// Dropbox/OneDrive folder the install might sit in). The new RemSound.exe is launched FROM
|
||||||
// marker if THIS run fails. The resume sentinel from a previous run (if any) is
|
// here to do the swap, so nothing in the install folder is locked by the updater itself, and
|
||||||
// also cleared here; if the caller supplies a profile title, the new sentinel is
|
// a sync engine can't hold the staged files mid-update.
|
||||||
// written below after staging succeeds.
|
var stageRoot = Path.Combine(UpdateStageParentDir, Guid.NewGuid().ToString("N"));
|
||||||
TryDelete(zipPath);
|
var appDir = Path.Combine(stageRoot, "app");
|
||||||
TryDeleteDirectory(stagingDir);
|
var zipPath = Path.Combine(stageRoot, $"RemSound-update-{info.Tag}.zip");
|
||||||
TryDelete(Path.Combine(baseDir, "update-failed.txt"));
|
Directory.CreateDirectory(appDir);
|
||||||
TryDelete(resumeSentinelPath);
|
|
||||||
|
// 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}");
|
Log?.Invoke($"updater: downloading {info.DownloadUrl}");
|
||||||
await using (var src = await http.GetStreamAsync(info.DownloadUrl, token).ConfigureAwait(false))
|
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);
|
await src.CopyToAsync(dst, token).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
Log?.Invoke($"updater: extracting to {stagingDir}");
|
Log?.Invoke($"updater: extracting to {appDir}");
|
||||||
Directory.CreateDirectory(stagingDir);
|
System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, appDir, overwriteFiles: true);
|
||||||
System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, stagingDir, overwriteFiles: true);
|
|
||||||
|
|
||||||
// Some release zips wrap everything in a single top-level folder
|
// Some release zips wrap everything in a single top-level folder (e.g. "RemSound-v1.1/").
|
||||||
// (e.g. "RemSound-v1.1/RemSound.exe"). Flatten if that's the case so the
|
// Flatten to the level that actually holds RemSound.exe.
|
||||||
// robocopy step copies the right tree over the install location.
|
var appRoot = ResolveStagingRoot(appDir);
|
||||||
var stagingRoot = ResolveStagingRoot(stagingDir);
|
|
||||||
|
|
||||||
Log?.Invoke($"updater: writing install helper {batchPath}");
|
var stagedExe = Path.Combine(appRoot, "RemSound.exe");
|
||||||
File.WriteAllText(batchPath, BuildInstallScript(stagingRoot, baseDir));
|
if (!File.Exists(stagedExe))
|
||||||
|
|
||||||
// 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
|
Log?.Invoke("updater: staged RemSound.exe not found — aborting, install left untouched");
|
||||||
{
|
TryDeleteDirectory(stageRoot);
|
||||||
File.WriteAllText(resumeSentinelPath, activeProfileTitle);
|
return false;
|
||||||
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}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 pid = System.Environment.ProcessId;
|
||||||
var psi = new System.Diagnostics.ProcessStartInfo
|
var psi = new System.Diagnostics.ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = "cmd.exe",
|
FileName = stagedExe,
|
||||||
Arguments = $"/c \"\"{batchPath}\" {pid}\"",
|
|
||||||
UseShellExecute = false,
|
UseShellExecute = false,
|
||||||
CreateNoWindow = true,
|
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);
|
System.Diagnostics.Process.Start(psi);
|
||||||
return true;
|
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),
|
/// <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
|
/// return that subfolder so the copy works from the inner level. Otherwise return the
|
||||||
/// staging dir itself.</summary>
|
/// staging dir itself.</summary>
|
||||||
@@ -400,6 +270,43 @@ internal sealed class RemSoundUpdater : IDisposable
|
|||||||
return stagingDir;
|
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"/>.
|
/// <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
|
/// 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>
|
/// 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
|
/// 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
|
/// 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
|
/// 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>
|
/// </summary>
|
||||||
internal static class SingleInstanceDialog
|
internal static class SingleInstanceDialog
|
||||||
{
|
{
|
||||||
@@ -48,7 +50,11 @@ internal static class SingleInstanceDialog
|
|||||||
AllowCancel = true,
|
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 == forceButton) return SingleInstanceDecision.ForceClose;
|
||||||
if (clicked == switchButton) return SingleInstanceDecision.SwitchToRunning;
|
if (clicked == switchButton) return SingleInstanceDecision.SwitchToRunning;
|
||||||
return SingleInstanceDecision.Cancel;
|
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