Bump to v3.1.3: single-instance lock + update/read-only reliability fixes
Fixes the chained-fault runaway Andre hit after an update (multiple copies
stacking, audio climbing to deafening, terminal kill to recover).
- Single-instance lock (SingleInstanceCoordinator + SingleInstanceDialog,
wired in Program.Main). A named mutex makes two copies impossible. A second
launch offers: switch to the running copy (default; surfaces it from the
tray via a named activation event), or force the running copy closed and
start fresh (Process.Kill, retried elevated if the target is elevated).
This is the structural fix that makes the stacking runaway impossible.
- Prompt-free update exit. InstallUpdateAsync sets updatingInProgress before
Application.Exit(); the close path's skipPrompt now honours it, so no
unsaved-changes dialog (whose default button is Cancel) can abort the
update's restart.
- Read-only persistence fix. BuildCurrentProfile now carries
currentProfileReadOnly into the saved snapshot. Previously a deliberate
save of a locked profile wrote ReadOnly=false, silently unlocking it on
disk — which re-armed the save prompt that then blocked the update.
- In-process double-install guard. updateInstallStarted stops the ~4 s
startup check and the background poll both staging an install + helper.
- Docs: About box, RELEASE_NOTES.md, readme.html + MANUAL.md ("Only one copy
of RemSound runs at a time").
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
baf50baf73
commit
9ab0a1a20a
@@ -208,6 +208,16 @@ Item| Shortcut| What it does
|
|||||||
|
|
||||||
**Keyboard access:** the tray icon is reachable through standard Windows shortcuts — **Windows + B** moves focus to the notification area, arrow keys navigate, Enter activates, and the application context-menu key (or Shift+F10) opens the right-click menu without a mouse.
|
**Keyboard access:** the tray icon is reachable through standard Windows shortcuts — **Windows + B** moves focus to the notification area, arrow keys navigate, Enter activates, and the application context-menu key (or Shift+F10) opens the right-click menu without a mouse.
|
||||||
|
|
||||||
|
### Only one copy of RemSound runs at a time
|
||||||
|
|
||||||
|
RemSound only ever runs as a single copy. If you try to open it while it's already running — for example by double-clicking it when it's already sitting in the system tray — it won't start a second one. Instead it asks what you'd like to do:
|
||||||
|
|
||||||
|
* **Switch to the running copy** — brings the copy that's already running back to the front. This is the default and usually what you want. Remember it may be minimised to the system tray, down by the clock.
|
||||||
|
* **Force the running copy to close and start fresh** — only needed if the running copy is stuck or not responding. It closes that copy and starts a new one. If the stuck copy was in the middle of a recording, that recording is lost — so this is the “get me out of trouble” option, not an everyday one.
|
||||||
|
* **Cancel** — do nothing.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 5. Menus (File, Record, Options, Help)
|
## 5. Menus (File, Record, Options, Help)
|
||||||
|
|
||||||
There are four menus on the main window: **File (Alt+F)** , **Record (Alt+K)** , **Options (Alt+O)** and **Help (Alt+H)**. The Record menu opens with Alt+K rather than Alt+R because Alt+R is already used by the **Receive audio** checkbox on the main window. The menu's title is shown as “Record (Alt+K)” so you can find the shortcut even though there is no K in the word.
|
There are four menus on the main window: **File (Alt+F)** , **Record (Alt+K)** , **Options (Alt+O)** and **Help (Alt+H)**. The Record menu opens with Alt+K rather than Alt+R because Alt+R is already used by the **Receive audio** checkbox on the main window. The menu's title is shown as “Record (Alt+K)” so you can find the shortcut even though there is no K in the word.
|
||||||
|
|||||||
+17
-22
@@ -1,40 +1,35 @@
|
|||||||
# RemSound v3.1.2
|
# RemSound v3.1.3
|
||||||
|
|
||||||
A small accessibility fix for the system tray icon, plus a clearer "check for updates" message for Windows 7 users. Minor update — safe to take, nothing else changes.
|
An important reliability fix. After an update, a chain of small faults could line up and leave RemSound misbehaving — in the worst case, more than one copy running at once with the sound getting louder and louder. This release breaks that chain in several places, the most important being that RemSound now flatly refuses to run as two copies at the same time.
|
||||||
|
|
||||||
## The tray icon's double announcement (screen reader users)
|
## What could go wrong (and now can't)
|
||||||
|
|
||||||
If you run RemSound minimised to the tray with a screen reader, you may have noticed the tray icon reading out **two states one after the other** every time you landed on it — for example "no peers connected" and then immediately "1 peer connected". It always happened in that same order, and showing the window then minimising it again was the only thing that cleared it.
|
A few separate problems were feeding into each other:
|
||||||
|
|
||||||
### Why it happened
|
- **Locked profiles could quietly unlock themselves.** If you'd marked a profile as read-only (locked) and then deliberately saved it, the save was silently dropping the lock. Next time you opened that profile it was editable again — so it started asking "save your changes?" when you didn't expect it.
|
||||||
|
- **That "save your changes?" question could block an update.** When RemSound updates itself it has to close and restart. If the save question popped up at that moment, a stray Enter or Escape landed on "Cancel" and quietly stopped the restart — so the update never finished cleanly.
|
||||||
|
- **Nothing stopped extra copies running.** With the restart half-done, RemSound could end up with two copies going at once — and then more — each one playing incoming audio, which is why the sound climbed to a deafening level. The only way out was force-closing them all.
|
||||||
|
|
||||||
Windows stamps a tray icon with its name at the exact moment the icon appears. On a fresh start — say, just after you log in — the icon appears a second or so before your other machine has reconnected, so the honest state at that instant really is "no peers". Windows remembers that as the icon's name and only ever refreshes the little hover bubble afterwards, never the stamped name. So your screen reader read the frozen name ("no peers") followed by the live bubble ("1 peer") — two readings of what should be one thing.
|
## What's fixed
|
||||||
|
|
||||||
### The fix
|
- **Only one copy at a time.** RemSound now refuses to run as two copies. If you open it while it's already running, it asks what you'd like to do: **switch to the copy that's already running** (it may be tucked in the system tray, by the clock), or — if that copy is stuck — **force it closed and start fresh**. This makes the stacking-copies runaway impossible.
|
||||||
|
- **Updates can't be interrupted.** When RemSound updates and restarts, nothing is allowed to get in the way — no question can cancel the restart.
|
||||||
RemSound now re-stamps the icon itself whenever something real changes — a peer connecting or dropping, sending or receiving switching on or off, or a recording starting or stopping. That's the automatic version of the old "show the window then minimise again" trick. Your screen reader now hears a single, current state.
|
- **Locked means locked.** Marking a profile read-only now survives saving it, deliberately or otherwise.
|
||||||
|
- **No double-installs.** A single copy can no longer kick off two update installs at once.
|
||||||
## Recording shows as "recording" in the tray
|
|
||||||
|
|
||||||
While a recording is running, the tray summary now simply says **"recording"** rather than counting the seconds. A live timer would have forced the icon to flicker every second or left a screen reader announcing a stale time — neither was good. If you want the exact length, it's on the main window.
|
|
||||||
|
|
||||||
## Clearer update message on Windows 7
|
|
||||||
|
|
||||||
On a Windows 7 machine that can't make a secure (HTTPS) connection to the update server, **Check for updates** used to report "you're already on the latest version" — which was wrong and confusing. It now tells you plainly that the secure connection failed, names the Windows updates that fix it, and gives you a direct link to download the new version by hand if you'd rather. Windows 10 and 11 were never affected.
|
|
||||||
|
|
||||||
## Nothing else has changed
|
## Nothing else has changed
|
||||||
|
|
||||||
Same wire format, same codec list, same audio cues, same everything else from v3.1. v3.1.2 talks to v3.0.x and v3.1.x peers exactly as before.
|
Same wire format, same codec list, same audio cues, same everything else from v3.1. v3.1.3 talks to v3.0.x and v3.1.x peers exactly as before.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
1. Download `RemSound-v3.1.2.zip` from this release.
|
1. Download `RemSound-v3.1.3.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 will not touch your profiles, settings or recordings.
|
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 or recordings.
|
||||||
4. Run `RemSound.exe`.
|
4. Run `RemSound.exe`.
|
||||||
|
|
||||||
## Upgrading
|
## Upgrading
|
||||||
|
|
||||||
**v1.9 through v3.1.1:** Help → Check for updates works — it will fetch and install v3.1.2 automatically. If you've ticked "Check for updates on startup" and "Silently install updates", v3.1.2 installs itself shortly after launch.
|
**v1.9 through v3.1.2:** Help → Check for updates works — it will fetch and install v3.1.3 automatically. If you've ticked "Check for updates on startup" and "Silently install updates", v3.1.3 installs itself shortly after launch.
|
||||||
|
|
||||||
**v1.8 and earlier:** the auto-updater in those versions has a fault that prevents it from installing updates, so Check for updates will download v3.1.2 but not apply it. Install v3.1.2 by hand using the steps above — just this once. From the build you install onward, updates are automatic.
|
**v1.8 and earlier:** the auto-updater in those versions has a fault that prevents it from installing updates, so Check for updates will download v3.1.3 but not apply it. Install v3.1.3 by hand using the steps above — just this once. From the build you install onward, updates are automatic.
|
||||||
|
|||||||
+10
@@ -238,6 +238,16 @@ ul, ol { padding-left: 1.4em; }
|
|||||||
|
|
||||||
<p><strong>Keyboard access:</strong> the tray icon is reachable through standard Windows shortcuts — <strong>Windows + B</strong> moves focus to the notification area, arrow keys navigate, Enter activates, and the application context-menu key (or Shift+F10) opens the right-click menu without a mouse.</p>
|
<p><strong>Keyboard access:</strong> the tray icon is reachable through standard Windows shortcuts — <strong>Windows + B</strong> moves focus to the notification area, arrow keys navigate, Enter activates, and the application context-menu key (or Shift+F10) opens the right-click menu without a mouse.</p>
|
||||||
|
|
||||||
|
<h3>Only one copy of RemSound runs at a time</h3>
|
||||||
|
|
||||||
|
<p>RemSound only ever runs as a single copy. If you try to open it while it's already running — for example by double-clicking it when it's already sitting in the system tray — it won't start a second one. Instead it asks what you'd like to do:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>Switch to the running copy</strong> — brings the copy that's already running back to the front. This is the default and usually what you want. Remember it may be minimised to the system tray, down by the clock.</li>
|
||||||
|
<li><strong>Force the running copy to close and start fresh</strong> — only needed if the running copy is stuck or not responding. It closes that copy and starts a new one. If the stuck copy was in the middle of a recording, that recording is lost — so this is the “get me out of trouble” option, not an everyday one.</li>
|
||||||
|
<li><strong>Cancel</strong> — do nothing.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<h2 id="menu-bar">5. Menus (File, Record, Options, Help)</h2>
|
<h2 id="menu-bar">5. Menus (File, Record, Options, Help)</h2>
|
||||||
|
|
||||||
<p>There are four menus on the main window: <strong>File (Alt+F)</strong>, <strong>Record (Alt+K)</strong>, <strong>Options (Alt+O)</strong> and <strong>Help (Alt+H)</strong>. The Record menu opens with Alt+K rather than Alt+R because Alt+R is already used by the <strong>Receive audio</strong> checkbox on the main window. The menu's title is shown as “Record (Alt+K)” so you can find the shortcut even though there is no K in the word.</p>
|
<p>There are four menus on the main window: <strong>File (Alt+F)</strong>, <strong>Record (Alt+K)</strong>, <strong>Options (Alt+O)</strong> and <strong>Help (Alt+H)</strong>. The Record menu opens with Alt+K rather than Alt+R because Alt+R is already used by the <strong>Receive audio</strong> checkbox on the main window. The menu's title is shown as “Record (Alt+K)” so you can find the shortcut even though there is no K in the word.</p>
|
||||||
|
|||||||
@@ -20,6 +20,37 @@ internal sealed class AboutDialog : Form
|
|||||||
/// updates" path.</summary>
|
/// updates" path.</summary>
|
||||||
private const string ReleaseNotes =
|
private const string ReleaseNotes =
|
||||||
"""
|
"""
|
||||||
|
RemSound v3.1.3
|
||||||
|
|
||||||
|
An important reliability fix. After an update, a
|
||||||
|
chain of small faults could line up and leave
|
||||||
|
RemSound misbehaving — in the worst case, more than
|
||||||
|
one copy running at once with the sound getting
|
||||||
|
louder and louder. This release breaks that chain.
|
||||||
|
|
||||||
|
Only one copy at a time: RemSound now refuses to run
|
||||||
|
as two copies at once. If you open it while it's
|
||||||
|
already running, it offers to switch you to the copy
|
||||||
|
that's already going (it may be down in the system
|
||||||
|
tray), or — if that copy is stuck — to force it
|
||||||
|
closed and start fresh. This makes the "stacking
|
||||||
|
copies" runaway impossible.
|
||||||
|
|
||||||
|
Updates can't be blocked any more: when RemSound
|
||||||
|
updates itself and restarts, nothing is allowed to
|
||||||
|
get in the way of that restart. Previously an
|
||||||
|
"unsaved changes?" question could pop up at that
|
||||||
|
moment and quietly cancel the update.
|
||||||
|
|
||||||
|
Locked profiles stay locked: marking a profile as
|
||||||
|
read-only now survives saving it. Before, if you
|
||||||
|
deliberately saved a locked profile the lock was
|
||||||
|
silently lost — which brought back the "save your
|
||||||
|
changes?" question and, in turn, could trip up an
|
||||||
|
update. Locked means locked.
|
||||||
|
|
||||||
|
Everything else from v3.1 is unchanged.
|
||||||
|
|
||||||
RemSound v3.1.2
|
RemSound v3.1.2
|
||||||
|
|
||||||
A small accessibility fix for the system tray
|
A small accessibility fix for the system tray
|
||||||
|
|||||||
@@ -493,6 +493,19 @@ public sealed class MainForm : Form
|
|||||||
private bool unsavedChanges;
|
private bool unsavedChanges;
|
||||||
// Skip MarkProfileDirty calls while we're programmatically applying a loaded profile.
|
// Skip MarkProfileDirty calls while we're programmatically applying a loaded profile.
|
||||||
private bool applyingProfile;
|
private bool applyingProfile;
|
||||||
|
// Set true the instant an auto-update hands off and we're about to exit to let the
|
||||||
|
// installer restart us. While this is set, the close path skips EVERY prompt — the
|
||||||
|
// update is a deliberate, unattended action and no dialog (least of all the unsaved-
|
||||||
|
// changes prompt, whose default button is Cancel) may be allowed to abort the restart.
|
||||||
|
// This was the second link in Andre's runaway: a stray Enter/Escape on the save prompt
|
||||||
|
// cancelled the update's exit, so the new version never came up cleanly.
|
||||||
|
private bool updatingInProgress;
|
||||||
|
// Set once an install has actually begun (download + stage + helper launch). Guards against
|
||||||
|
// firing the installer twice from a single process: the ~4 s startup check and the periodic
|
||||||
|
// background poll can both surface the same release in quick succession, and without this
|
||||||
|
// each would stage and spawn its own helper. Cross-PROCESS duplication is prevented by the
|
||||||
|
// single-instance lock in Program.Main; this is the within-process half of that protection.
|
||||||
|
private bool updateInstallStarted;
|
||||||
/// <summary>Set when the user changed the profiles FOLDER (not just switched profile)
|
/// <summary>Set when the user changed the profiles FOLDER (not just switched profile)
|
||||||
/// via the Manage Profiles dialog. Program.cs reads this after the form closes; if true,
|
/// via the Manage Profiles dialog. Program.cs reads this after the form closes; if true,
|
||||||
/// it re-runs the entire profile selection flow under the new folder rather than the
|
/// it re-runs the entire profile selection flow under the new folder rather than the
|
||||||
@@ -500,6 +513,24 @@ public sealed class MainForm : Form
|
|||||||
/// <see cref="NextProfileTitleToLoad"/> in practice.</summary>
|
/// <see cref="NextProfileTitleToLoad"/> in practice.</summary>
|
||||||
public bool ReloadFromScratch { get; private set; }
|
public bool ReloadFromScratch { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Bring this window to the front, restoring it from the system tray if it's
|
||||||
|
/// parked there. Called when the user launches a SECOND copy of RemSound and the single-
|
||||||
|
/// instance guard chooses "switch to the running copy" — the second copy signals this one
|
||||||
|
/// to surface instead of starting another process. Safe to call from any thread: it
|
||||||
|
/// marshals to the UI thread itself. Reuses the tray controller's Restore (Show +
|
||||||
|
/// SetForegroundWindow), which works whether the window is in the tray or just behind
|
||||||
|
/// other windows.</summary>
|
||||||
|
public void RestoreFromTray()
|
||||||
|
{
|
||||||
|
if (IsDisposed) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (InvokeRequired) { BeginInvoke(new Action(RestoreFromTray)); return; }
|
||||||
|
trayController.Restore();
|
||||||
|
}
|
||||||
|
catch { /* best-effort — surfacing the window is a convenience, not load-critical */ }
|
||||||
|
}
|
||||||
|
|
||||||
public MainForm() : this(null, null, null, null) { }
|
public MainForm() : this(null, null, null, null) { }
|
||||||
|
|
||||||
public MainForm(ProfileStore? profileStore, Profile? profile, string? loadedTitle, string? loadedPath = null)
|
public MainForm(ProfileStore? profileStore, Profile? profile, string? loadedTitle, string? loadedPath = null)
|
||||||
@@ -2042,14 +2073,29 @@ public sealed class MainForm : Form
|
|||||||
// mid-session update would drop the session AND leave the user back at the picker —
|
// mid-session update would drop the session AND leave the user back at the picker —
|
||||||
// the session never resumes by itself. Null/empty title (blank template, no profile
|
// the session never resumes by itself. Null/empty title (blank template, no profile
|
||||||
// saved yet) skips the sentinel and the relaunch falls through to normal startup.
|
// saved yet) skips the sentinel and the relaunch falls through to normal startup.
|
||||||
|
// Don't let two near-simultaneous checks both stage an install and spawn two helpers.
|
||||||
|
if (updateInstallStarted)
|
||||||
|
{
|
||||||
|
logFile.Event($"updater: install already in progress; ignoring repeat request for {info.Tag}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateInstallStarted = true;
|
||||||
|
|
||||||
var ok = await updater.DownloadAndStageInstallAsync(info, currentProfileTitle).ConfigureAwait(true);
|
var ok = await updater.DownloadAndStageInstallAsync(info, currentProfileTitle).ConfigureAwait(true);
|
||||||
if (!ok)
|
if (!ok)
|
||||||
{
|
{
|
||||||
|
// Nothing was staged — allow a later attempt rather than wedging the updater off
|
||||||
|
// for the rest of the session.
|
||||||
|
updateInstallStarted = false;
|
||||||
MessageBox.Show(this,
|
MessageBox.Show(this,
|
||||||
$"Could not download or stage the update. Try again later, or visit the release page in your browser:\n\n{info.ReleaseUrl}",
|
$"Could not download or stage the update. Try again later, or visit the release page in your browser:\n\n{info.ReleaseUrl}",
|
||||||
"Update failed", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
"Update failed", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// The helper is staged and launched. We MUST now exit cleanly so it can replace our
|
||||||
|
// files and restart us — set updatingInProgress so the close path skips every prompt;
|
||||||
|
// no dialog (least of all the unsaved-changes prompt) may be allowed to cancel this.
|
||||||
|
updatingInProgress = true;
|
||||||
logFile.Event($"updater: install helper launched for {info.Tag}, exiting");
|
logFile.Event($"updater: install helper launched for {info.Tag}, exiting");
|
||||||
Application.Exit();
|
Application.Exit();
|
||||||
}
|
}
|
||||||
@@ -4977,6 +5023,13 @@ public sealed class MainForm : Form
|
|||||||
{
|
{
|
||||||
var profile = new Profile { Title = title };
|
var profile = new Profile { Title = title };
|
||||||
settings.CopyTo(profile);
|
settings.CopyTo(profile);
|
||||||
|
// Carry the active read-only (lock) flag into the saved snapshot. Without this line a
|
||||||
|
// deliberate save of a locked profile — e.g. the "save through the lock" flow Andre
|
||||||
|
// uses — would write the profile back with ReadOnly defaulting to false, silently
|
||||||
|
// UNLOCKING it on disk. Next launch the profile would no longer be read-only, the
|
||||||
|
// unsaved-changes prompt would start firing again, and (worse) that prompt could block
|
||||||
|
// an unattended auto-update from restarting. The lock flag must survive every save.
|
||||||
|
profile.ReadOnly = currentProfileReadOnly;
|
||||||
profile.Volume = volumeBar.Value;
|
profile.Volume = volumeBar.Value;
|
||||||
profile.Muted = receiver.IsMuted;
|
profile.Muted = receiver.IsMuted;
|
||||||
profile.ReceiveAudioOn = receiveAudioCheckbox.Checked;
|
profile.ReceiveAudioOn = receiveAudioCheckbox.Checked;
|
||||||
@@ -6181,7 +6234,7 @@ public sealed class MainForm : Form
|
|||||||
// is what unblocks NVDA-less or remote-session-dropped shutdowns from deadlocking
|
// is what unblocks NVDA-less or remote-session-dropped shutdowns from deadlocking
|
||||||
// on a dialog the user can't reach.
|
// on a dialog the user can't reach.
|
||||||
var skipPrompt = !string.IsNullOrEmpty(NextProfileTitleToLoad) || ReloadFromScratch
|
var skipPrompt = !string.IsNullOrEmpty(NextProfileTitleToLoad) || ReloadFromScratch
|
||||||
|| currentProfileReadOnly;
|
|| currentProfileReadOnly || updatingInProgress;
|
||||||
|
|
||||||
if (!skipPrompt && profileStore is not null && unsavedChanges)
|
if (!skipPrompt && profileStore is not null && unsavedChanges)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ namespace RemSound.App;
|
|||||||
|
|
||||||
internal static class Program
|
internal static class Program
|
||||||
{
|
{
|
||||||
|
// The MainForm currently open in the loop below, or null between profile-switch
|
||||||
|
// iterations. Tracked so the single-instance coordinator's activation callback (which
|
||||||
|
// fires on a background thread when a second copy asks us to surface) can reach the live
|
||||||
|
// window. volatile for cross-thread visibility; RestoreFromTray marshals to the UI thread.
|
||||||
|
private static volatile MainForm? activeMainForm;
|
||||||
|
|
||||||
[STAThread]
|
[STAThread]
|
||||||
private static void Main()
|
private static void Main()
|
||||||
{
|
{
|
||||||
@@ -18,6 +24,51 @@ internal static class Program
|
|||||||
|
|
||||||
ApplicationConfiguration.Initialize();
|
ApplicationConfiguration.Initialize();
|
||||||
|
|
||||||
|
// Single-instance guard. RemSound must never run as two copies at once: with the
|
||||||
|
// auto-updater relaunching the app, a copy that didn't exit cleanly used to leave two
|
||||||
|
// (then more) copies running, each playing received audio — Andre's "stacked and
|
||||||
|
// stacked", deafening-audio runaway (2026-05-30). The lock makes that structurally
|
||||||
|
// impossible. Acquired BEFORE anything user-visible so a second copy bows out (or
|
||||||
|
// takes over a stuck one) before it ever touches audio devices or the network.
|
||||||
|
using var instance = new SingleInstanceCoordinator();
|
||||||
|
if (!instance.TryAcquire(TimeSpan.Zero))
|
||||||
|
{
|
||||||
|
// If we can't even ask the user (dialog failed to show), the safe answer is "don't
|
||||||
|
// start a second copy" — bowing out is always safer than risking a duplicate.
|
||||||
|
SingleInstanceDecision decision;
|
||||||
|
try { decision = SingleInstanceDialog.Ask(); }
|
||||||
|
catch { return; }
|
||||||
|
|
||||||
|
switch (decision)
|
||||||
|
{
|
||||||
|
case SingleInstanceDecision.SwitchToRunning:
|
||||||
|
SingleInstanceCoordinator.SignalExistingToActivate();
|
||||||
|
return;
|
||||||
|
case SingleInstanceDecision.Cancel:
|
||||||
|
return;
|
||||||
|
case SingleInstanceDecision.ForceClose:
|
||||||
|
var cleared = SingleInstanceCoordinator.ForceCloseOtherInstances();
|
||||||
|
// Take the lock now the others should be gone. Allow a few seconds in case
|
||||||
|
// a killed copy is slow to release the abandoned mutex / its audio devices.
|
||||||
|
if (!instance.TryAcquire(TimeSpan.FromSeconds(5)))
|
||||||
|
{
|
||||||
|
MessageBox.Show(
|
||||||
|
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);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We hold the single-instance lock. Listen for a later copy asking us to surface, and
|
||||||
|
// route that request to whichever main window is open at the time.
|
||||||
|
instance.StartActivationListener();
|
||||||
|
instance.ActivateRequested += () => activeMainForm?.RestoreFromTray();
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -124,8 +175,12 @@ internal static class Program
|
|||||||
// look hung. No-op for WASAPI-only profiles (construction is near-instant).
|
// look hung. No-op for WASAPI-only profiles (construction is near-instant).
|
||||||
var splash = AsioLoadingSplash.StartIfNeeded(profile);
|
var splash = AsioLoadingSplash.StartIfNeeded(profile);
|
||||||
using var form = new MainForm(store, profile, title, nextPath);
|
using var form = new MainForm(store, profile, title, nextPath);
|
||||||
|
// Expose the live window to the single-instance activation callback (a second
|
||||||
|
// copy choosing "switch to the running copy" signals us to surface this form).
|
||||||
|
activeMainForm = form;
|
||||||
splash?.Dismiss();
|
splash?.Dismiss();
|
||||||
Application.Run(form);
|
Application.Run(form);
|
||||||
|
activeMainForm = null;
|
||||||
|
|
||||||
if (form.ReloadFromScratch)
|
if (form.ReloadFromScratch)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.1.2</Version>
|
<Version>3.1.3</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace RemSound.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enforces "only one RemSound at a time" and provides the plumbing to either surface the
|
||||||
|
/// already-running copy or force a stuck one to close. Created once in Program.Main and held
|
||||||
|
/// for the whole app lifetime.
|
||||||
|
///
|
||||||
|
/// Why this exists: RemSound had no single-instance guard at all — it only ever NOTICED a
|
||||||
|
/// second copy when a global hotkey failed to register. With the auto-updater relaunching the
|
||||||
|
/// app, a copy that didn't exit cleanly could leave two (then more) copies running at once,
|
||||||
|
/// each playing received audio. Andre hit exactly that on 2026-05-30: copies "stacked and
|
||||||
|
/// stacked", the audio got deafening, and the only way out was force-killing them all from a
|
||||||
|
/// terminal. A real lock makes that structurally impossible.
|
||||||
|
///
|
||||||
|
/// Mechanism:
|
||||||
|
/// * A named system <see cref="Mutex"/> is the lock. The first copy to start owns it; a
|
||||||
|
/// later copy fails to acquire it and so KNOWS another copy is live.
|
||||||
|
/// * A named auto-reset <see cref="EventWaitHandle"/> is the "come to the front" signal.
|
||||||
|
/// The owning copy runs a background thread waiting on it; when a second copy sets it
|
||||||
|
/// (the user chose "switch to the running copy"), the thread raises
|
||||||
|
/// <see cref="ActivateRequested"/>, which Program.Main routes to the live window.
|
||||||
|
/// * <see cref="ForceCloseOtherInstances"/> terminates any other RemSound process with
|
||||||
|
/// Process.Kill (TerminateProcess), so a hung copy dies regardless of its message-loop
|
||||||
|
/// state. If a copy is running elevated and we are not, the kill is retried via an
|
||||||
|
/// elevated taskkill (one UAC prompt).
|
||||||
|
///
|
||||||
|
/// Names are in the per-session (Local) namespace and are fixed strings — not version- or
|
||||||
|
/// path-derived — so ANY RemSound.exe blocks ANY other, which is the "refuses to load unless
|
||||||
|
/// previous copies are gone" guarantee Andre asked for. 2026-05-31.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class SingleInstanceCoordinator : IDisposable
|
||||||
|
{
|
||||||
|
private const string MutexName = "RemSound.SingleInstance.Mutex.v1";
|
||||||
|
private const string ActivateEventName = "RemSound.SingleInstance.Activate.v1";
|
||||||
|
|
||||||
|
private readonly Mutex mutex;
|
||||||
|
private bool ownsMutex;
|
||||||
|
private EventWaitHandle? activateEvent;
|
||||||
|
private Thread? listenerThread;
|
||||||
|
private volatile bool stopListener;
|
||||||
|
|
||||||
|
/// <summary>Raised on a background thread when another copy asks this one to surface.
|
||||||
|
/// Program.Main marshals it onto the running window.</summary>
|
||||||
|
public event Action? ActivateRequested;
|
||||||
|
|
||||||
|
public SingleInstanceCoordinator()
|
||||||
|
{
|
||||||
|
mutex = new Mutex(initiallyOwned: false, MutexName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True once we hold the single-instance lock.</summary>
|
||||||
|
public bool IsPrimaryInstance => ownsMutex;
|
||||||
|
|
||||||
|
/// <summary>Try to take the single-instance lock, waiting up to <paramref name="timeout"/>.
|
||||||
|
/// An abandoned mutex (previous owner crashed or was force-killed) counts as acquired — we
|
||||||
|
/// become the new owner.</summary>
|
||||||
|
public bool TryAcquire(TimeSpan timeout)
|
||||||
|
{
|
||||||
|
if (ownsMutex) return true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ownsMutex = mutex.WaitOne(timeout);
|
||||||
|
}
|
||||||
|
catch (AbandonedMutexException)
|
||||||
|
{
|
||||||
|
// Previous owner died without releasing. Ownership passes to us.
|
||||||
|
ownsMutex = true;
|
||||||
|
}
|
||||||
|
return ownsMutex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Start the background listener that surfaces this copy when a later copy
|
||||||
|
/// signals it. Only meaningful on the primary instance.</summary>
|
||||||
|
public void StartActivationListener()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
activateEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ActivateEventName);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Can't create the signal — "switch to the running copy" just won't surface this
|
||||||
|
// window automatically. Not fatal; the user can still reach it via the tray.
|
||||||
|
activateEvent = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listenerThread = new Thread(ListenLoop) { IsBackground = true, Name = "RemSound-Activation" };
|
||||||
|
listenerThread.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ListenLoop()
|
||||||
|
{
|
||||||
|
var ev = activateEvent;
|
||||||
|
if (ev is null) return;
|
||||||
|
while (!stopListener)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Short timeout so Dispose can stop us promptly even if no signal arrives.
|
||||||
|
if (ev.WaitOne(500))
|
||||||
|
{
|
||||||
|
if (stopListener) return;
|
||||||
|
ActivateRequested?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Signal whichever copy currently owns the lock to bring itself to the front.
|
||||||
|
/// Called by a SECOND copy that chose "switch to the running copy".</summary>
|
||||||
|
public static void SignalExistingToActivate()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (EventWaitHandle.TryOpenExisting(ActivateEventName, out var ev))
|
||||||
|
{
|
||||||
|
using (ev) ev.Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort — if the signal can't be delivered the user can click the tray icon.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Force every OTHER RemSound process to terminate. Returns true if no other
|
||||||
|
/// RemSound process remains after the attempt. Uses Process.Kill (TerminateProcess) so a
|
||||||
|
/// hung copy dies regardless of its state; PIDs we can't reach (an elevated copy while we
|
||||||
|
/// run normally) are retried via an elevated taskkill.</summary>
|
||||||
|
public static bool ForceCloseOtherInstances()
|
||||||
|
{
|
||||||
|
var me = Environment.ProcessId;
|
||||||
|
var deniedPids = new List<int>();
|
||||||
|
|
||||||
|
foreach (var p in OtherInstances(me))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
p.Kill();
|
||||||
|
p.WaitForExit(4000);
|
||||||
|
}
|
||||||
|
catch (Win32Exception)
|
||||||
|
{
|
||||||
|
// Access denied — almost always an elevated target we can't reach unelevated.
|
||||||
|
deniedPids.Add(p.Id);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
// Already exited between enumeration and Kill — fine.
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore — the post-check below is the source of truth.
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
p.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deniedPids.Count > 0)
|
||||||
|
{
|
||||||
|
TryElevatedKill(deniedPids);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source of truth: is the field actually clear now?
|
||||||
|
var remaining = OtherInstances(me).ToList();
|
||||||
|
var clear = remaining.Count == 0;
|
||||||
|
foreach (var p in remaining) p.Dispose();
|
||||||
|
return clear;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Process> OtherInstances(int selfPid)
|
||||||
|
{
|
||||||
|
Process[] all;
|
||||||
|
try { all = Process.GetProcessesByName("RemSound"); }
|
||||||
|
catch { return []; }
|
||||||
|
|
||||||
|
var others = new List<Process>(all.Length);
|
||||||
|
foreach (var p in all)
|
||||||
|
{
|
||||||
|
if (p.Id == selfPid) { p.Dispose(); continue; }
|
||||||
|
others.Add(p);
|
||||||
|
}
|
||||||
|
return others;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryElevatedKill(List<int> pids)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Target exact PIDs, never /IM RemSound.exe — an image-name kill would also take
|
||||||
|
// out this very process. Verb=runas raises the one UAC prompt that lets a normal
|
||||||
|
// process terminate an elevated one.
|
||||||
|
var args = "/F " + string.Join(" ", pids.ConvertAll(id => $"/PID {id}"));
|
||||||
|
var psi = new ProcessStartInfo("taskkill.exe", args)
|
||||||
|
{
|
||||||
|
UseShellExecute = true,
|
||||||
|
Verb = "runas",
|
||||||
|
WindowStyle = ProcessWindowStyle.Hidden,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
};
|
||||||
|
var proc = Process.Start(psi);
|
||||||
|
proc?.WaitForExit(5000);
|
||||||
|
proc?.Dispose();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// User declined UAC, or taskkill wasn't available. The caller's post-check reports
|
||||||
|
// the field still isn't clear and the UI surfaces a message.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
stopListener = true;
|
||||||
|
try { activateEvent?.Set(); } catch { /* wake the listener so it can exit */ }
|
||||||
|
try { listenerThread?.Join(1000); } catch { /* ignore */ }
|
||||||
|
try { activateEvent?.Dispose(); } catch { /* ignore */ }
|
||||||
|
if (ownsMutex)
|
||||||
|
{
|
||||||
|
try { mutex.ReleaseMutex(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
try { mutex.Dispose(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using System.Windows.Forms;
|
||||||
|
|
||||||
|
namespace RemSound.App;
|
||||||
|
|
||||||
|
internal enum SingleInstanceDecision
|
||||||
|
{
|
||||||
|
/// <summary>Bring the already-running copy to the front; this copy exits.</summary>
|
||||||
|
SwitchToRunning,
|
||||||
|
|
||||||
|
/// <summary>Force the running copy to close, then start fresh.</summary>
|
||||||
|
ForceClose,
|
||||||
|
|
||||||
|
/// <summary>Do nothing; this copy exits.</summary>
|
||||||
|
Cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shown when a SECOND copy of RemSound is launched while one is already running. Offers three
|
||||||
|
/// choices, with the safe one (switch to the running copy) as the default so a habitual Enter
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal static class SingleInstanceDialog
|
||||||
|
{
|
||||||
|
public static SingleInstanceDecision Ask()
|
||||||
|
{
|
||||||
|
var switchButton = new TaskDialogButton("Switch to the running copy");
|
||||||
|
var forceButton = new TaskDialogButton("Force the running copy to close and start fresh");
|
||||||
|
var cancelButton = new TaskDialogButton("Cancel") { AllowCloseDialog = true };
|
||||||
|
|
||||||
|
var page = new TaskDialogPage
|
||||||
|
{
|
||||||
|
Caption = "RemSound is already running",
|
||||||
|
Heading = "RemSound is already running",
|
||||||
|
Text =
|
||||||
|
"Only one copy of RemSound can run at a time. What would you like to do?\n\n"
|
||||||
|
+ "• Switch to the running copy — bring the copy that's already running back to the "
|
||||||
|
+ "front. It may be tucked away in the system tray, down by the clock.\n\n"
|
||||||
|
+ "• Force the running copy to close and start fresh — use this only if the running "
|
||||||
|
+ "copy is stuck or not responding. If it's in the middle of a recording, that "
|
||||||
|
+ "recording will be lost.\n\n"
|
||||||
|
+ "• Cancel — do nothing.",
|
||||||
|
Icon = TaskDialogIcon.Warning,
|
||||||
|
Buttons = { switchButton, forceButton, cancelButton },
|
||||||
|
DefaultButton = switchButton,
|
||||||
|
AllowCancel = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
var clicked = TaskDialog.ShowDialog(page);
|
||||||
|
if (clicked == forceButton) return SingleInstanceDecision.ForceClose;
|
||||||
|
if (clicked == switchButton) return SingleInstanceDecision.SwitchToRunning;
|
||||||
|
return SingleInstanceDecision.Cancel;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user