Release v3.2: update sound cue + single-instance bring-to-front fix
- New "Update sound" cue (CueId.Update / update.wav). Plays just before an
update starts installing, on every path (manual, background-silent,
startup-silent), so a silent background update gives an audible heads-up
before RemSound closes to restart. Per-profile mute + custom-sound override
in Preferences, same infrastructure as the other cues:
* Profile.EnableUpdateCue + RemSoundSettingsStore Load/Save + round-trip
* MainForm updateSound field, TryLoadCueSound, play in InstallUpdateAsync
gated by LoadEnableUpdateCue
* PreferencesDialog "Update sound" CueRow + ResolveCueFilePath mapping
* update.wav shipped in sounds\ via csproj Content
- Single-instance "switch to the running copy" now actually brings the window
to the front. The second copy grants the running copy foreground rights via
AllowSetForegroundWindow before signalling, and lingers briefly so it can
raise itself before we exit — without this, Windows' foreground lock left
the running window only flashing in the taskbar (Ed's report).
- Docs: About box v3.2 block, RELEASE_NOTES.md, manual cue section (now seven
cues, with the update cue described), MANUAL.md regenerated.
- Version bumped 3.1.3 -> 3.2.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9ab0a1a20a
commit
cdcac859c4
@@ -20,6 +20,34 @@ internal sealed class AboutDialog : Form
|
||||
/// updates" path.</summary>
|
||||
private const string ReleaseNotes =
|
||||
"""
|
||||
RemSound v3.2
|
||||
|
||||
A new audio cue, plus the reliability work from
|
||||
the recent updates, rolled into one release.
|
||||
|
||||
New "Update sound" cue: RemSound now plays a short
|
||||
sound just before it closes to install an update —
|
||||
for both manual updates and silent background ones.
|
||||
So a silent update no longer takes you by surprise;
|
||||
you hear it coming. Like every cue it's per-profile,
|
||||
can be muted, and you can swap in your own sound,
|
||||
all from File → Preferences → Audio cue sounds.
|
||||
|
||||
Only one copy at a time: RemSound refuses to run as
|
||||
two copies at once. Open it while it's already
|
||||
running and it offers to switch you to the copy
|
||||
that's already going (now reliably bringing that
|
||||
window to the front, even from the system tray), or
|
||||
— if that copy is stuck — to force it closed and
|
||||
start fresh.
|
||||
|
||||
Updates can't be blocked or doubled up: nothing can
|
||||
get in the way of an update's restart any more, and
|
||||
a single copy can't kick off two installs at once.
|
||||
|
||||
Locked profiles stay locked: marking a profile as
|
||||
read-only now survives saving it.
|
||||
|
||||
RemSound v3.1.3
|
||||
|
||||
An important reliability fix. After an update, a
|
||||
|
||||
@@ -326,6 +326,7 @@ public sealed class MainForm : Form
|
||||
// Save As; Profile fires immediately after a profile finishes loading in MainForm.
|
||||
private System.Media.SoundPlayer? saveSound;
|
||||
private System.Media.SoundPlayer? profileSwitchSound;
|
||||
private System.Media.SoundPlayer? updateSound;
|
||||
// Labels for the three send/receive device lists, captured at layout time so they can be
|
||||
// re-titled when the user toggles between WASAPI mode (Windows devices) and ASIO mode
|
||||
// (driver channel pairs). null until BuildLayout has run.
|
||||
@@ -892,6 +893,7 @@ public sealed class MainForm : Form
|
||||
TryLoadCueSound(CueId.RecordStop, "record stop.wav", out recordStopSound);
|
||||
TryLoadCueSound(CueId.Save, "save.wav", out saveSound);
|
||||
TryLoadCueSound(CueId.ProfileSwitch, "profile.wav", out profileSwitchSound);
|
||||
TryLoadCueSound(CueId.Update, "update.wav", out updateSound);
|
||||
|
||||
LoadAudioDevices();
|
||||
// Apply persisted ASIO mode from settings — switches sender/receiver backends so the
|
||||
@@ -2081,6 +2083,14 @@ public sealed class MainForm : Form
|
||||
}
|
||||
updateInstallStarted = true;
|
||||
|
||||
// Update cue — an audible heads-up that an update is going in, played just before it
|
||||
// starts. Fires for every install path (manual, background-silent, startup-silent), so
|
||||
// a silent background update isn't completely silent: the user hears that RemSound is
|
||||
// about to close and update. Played before the download so it sounds well before the
|
||||
// app vanishes; the download comfortably outlasts the short cue. Honours the per-profile
|
||||
// EnableUpdateCue flag set in Preferences.
|
||||
if (settings.LoadEnableUpdateCue()) updateSound?.Play();
|
||||
|
||||
var ok = await updater.DownloadAndStageInstallAsync(info, currentProfileTitle).ConfigureAwait(true);
|
||||
if (!ok)
|
||||
{
|
||||
@@ -5426,6 +5436,7 @@ public sealed class MainForm : Form
|
||||
public const string RecordStop = "record-stop";
|
||||
public const string Save = "save";
|
||||
public const string ProfileSwitch = "profile-switch";
|
||||
public const string Update = "update";
|
||||
}
|
||||
|
||||
/// <summary>Load one cue sound. Resolution order:
|
||||
@@ -5487,6 +5498,7 @@ public sealed class MainForm : Form
|
||||
TryLoadCueSound(CueId.RecordStop, "record stop.wav", out recordStopSound);
|
||||
TryLoadCueSound(CueId.Save, "save.wav", out saveSound);
|
||||
TryLoadCueSound(CueId.ProfileSwitch, "profile.wav", out profileSwitchSound);
|
||||
TryLoadCueSound(CueId.Update, "update.wav", out updateSound);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -102,6 +102,8 @@ internal sealed class PreferencesDialog : Form
|
||||
s => s.LoadEnableSaveCue(), (s, v) => s.SaveEnableSaveCue(v)),
|
||||
new("Profile switched sound", MainForm.CueId.ProfileSwitch,
|
||||
s => s.LoadEnableProfileSwitchCue(), (s, v) => s.SaveEnableProfileSwitchCue(v)),
|
||||
new("Update sound", MainForm.CueId.Update,
|
||||
s => s.LoadEnableUpdateCue(), (s, v) => s.SaveEnableUpdateCue(v)),
|
||||
];
|
||||
|
||||
private readonly AccessibleCheckBox acceptRemoteVolumeBox = new()
|
||||
@@ -595,6 +597,7 @@ internal sealed class PreferencesDialog : Form
|
||||
MainForm.CueId.RecordStop => "record stop.wav",
|
||||
MainForm.CueId.Save => "save.wav",
|
||||
MainForm.CueId.ProfileSwitch => "profile.wav",
|
||||
MainForm.CueId.Update => "update.wav",
|
||||
_ => null,
|
||||
};
|
||||
if (defaultFileName is null) return null;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
tag_name on the latest GitHub release; bump it on every public release. The
|
||||
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
|
||||
is what the About dialog and the updater both read. -->
|
||||
<Version>3.1.3</Version>
|
||||
<Version>3.2.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -90,6 +90,13 @@
|
||||
<Link>sounds\profile.wav</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<!-- update.wav (2026-05-31): plays just before an update starts installing, so a silent
|
||||
background update still gives an audible heads-up. Per-profile mute + custom-sound
|
||||
override available in Preferences, same as the other cues. -->
|
||||
<Content Include="..\..\sounds\update.wav" Condition="Exists('..\..\sounds\update.wav')">
|
||||
<Link>sounds\update.wav</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<!-- User manual. F1 anywhere in the app opens this via the user's default browser
|
||||
(HelpLauncher.OpenManual). The PreserveNewest mode means a fresh publish overwrites
|
||||
the published copy whenever the source is newer; manually-edited copies inside
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
@@ -113,10 +114,31 @@ internal sealed class SingleInstanceCoordinator : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool AllowSetForegroundWindow(int dwProcessId);
|
||||
|
||||
/// <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>
|
||||
/// Called by a SECOND copy that chose "switch to the running copy".
|
||||
///
|
||||
/// Windows blocks <c>SetForegroundWindow</c> from a process that didn't receive the most
|
||||
/// recent user input. When the user launches this second copy from (say) an Explorer
|
||||
/// window and clicks "switch", the input belongs to US, not the running copy — so the
|
||||
/// running copy's own SetForegroundWindow is denied and its window only flashes in the
|
||||
/// taskbar instead of coming forward (the bug Ed reported). The documented fix is for the
|
||||
/// process that currently holds the foreground right — us, right now — to hand it to the
|
||||
/// running copy via <see cref="AllowSetForegroundWindow"/> BEFORE signalling. We then
|
||||
/// linger briefly so the running copy can raise itself while the grant is fresh and the
|
||||
/// foreground hasn't churned from us exiting.</summary>
|
||||
public static void SignalExistingToActivate()
|
||||
{
|
||||
// Grant every other RemSound process the one-shot right to pull itself to the front.
|
||||
foreach (var p in OtherInstances(Environment.ProcessId))
|
||||
{
|
||||
try { AllowSetForegroundWindow(p.Id); } catch { /* best-effort */ }
|
||||
p.Dispose();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (EventWaitHandle.TryOpenExisting(ActivateEventName, out var ev))
|
||||
@@ -128,6 +150,12 @@ internal sealed class SingleInstanceCoordinator : IDisposable
|
||||
{
|
||||
// Best-effort — if the signal can't be delivered the user can click the tray icon.
|
||||
}
|
||||
|
||||
// Stay alive a beat so the running copy can raise itself while we're still the
|
||||
// foreground process and the grant is fresh. If we vanished instantly the foreground
|
||||
// would churn and the grant could be consumed before it's used. Invisible to the user —
|
||||
// our dialog has already closed and we have no window.
|
||||
try { Thread.Sleep(600); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/// <summary>Force every OTHER RemSound process to terminate. Returns true if no other
|
||||
|
||||
@@ -107,10 +107,14 @@ public sealed class Profile
|
||||
public bool? EnableRecordStopCue { get; set; }
|
||||
public bool? EnableSaveCue { get; set; }
|
||||
public bool? EnableProfileSwitchCue { get; set; }
|
||||
/// <summary>Plays the update cue just before an update starts installing (manual or
|
||||
/// silent). Null = unset → defaults to on, so a silent background update still gives an
|
||||
/// audible heads-up. Added 2026-05-31.</summary>
|
||||
public bool? EnableUpdateCue { get; set; }
|
||||
|
||||
/// <summary>Per-cue custom WAV file overrides, keyed by the well-known cue id (<c>connect</c>,
|
||||
/// <c>disconnect</c>, <c>record-start</c>, <c>record-stop</c>, <c>save</c>,
|
||||
/// <c>profile-switch</c>) and valued with the absolute filesystem path to the user's chosen
|
||||
/// <c>profile-switch</c>, <c>update</c>) and valued with the absolute filesystem path to the user's chosen
|
||||
/// WAV. Per-profile (moved here from AppConfig 2026-05-28) so a "live monitoring" profile
|
||||
/// can have one set of custom sounds and a "recording" profile a different set. Missing
|
||||
/// keys mean "use the default sound shipped in the sounds\ folder next to RemSound.exe".
|
||||
|
||||
@@ -428,6 +428,16 @@ public sealed class RemSoundSettingsStore
|
||||
Save(s);
|
||||
}
|
||||
|
||||
public bool LoadEnableUpdateCue() =>
|
||||
Try(() => Load()?.EnableUpdateCue) ?? true;
|
||||
|
||||
public void SaveEnableUpdateCue(bool value)
|
||||
{
|
||||
var s = Load() ?? new Settings();
|
||||
s.EnableUpdateCue = value;
|
||||
Save(s);
|
||||
}
|
||||
|
||||
/// <summary>The user's custom WAV path for a given cue, or null when they're using the
|
||||
/// default. Per-profile (lives on <see cref="Profile.CustomCuePaths"/>) so different
|
||||
/// profiles can carry different cue palettes.</summary>
|
||||
@@ -587,6 +597,7 @@ public sealed class RemSoundSettingsStore
|
||||
EnableRecordStopCue = profile.EnableRecordStopCue,
|
||||
EnableSaveCue = profile.EnableSaveCue,
|
||||
EnableProfileSwitchCue = profile.EnableProfileSwitchCue,
|
||||
EnableUpdateCue = profile.EnableUpdateCue,
|
||||
// Defensive copy so cache mutations don't leak into the in-memory Profile graph
|
||||
// (and vice-versa). Profile is loaded once at startup; the cache evolves through
|
||||
// the session and is written back via CopyTo on save.
|
||||
@@ -643,6 +654,7 @@ public sealed class RemSoundSettingsStore
|
||||
profile.EnableRecordStopCue = s.EnableRecordStopCue;
|
||||
profile.EnableSaveCue = s.EnableSaveCue;
|
||||
profile.EnableProfileSwitchCue = s.EnableProfileSwitchCue;
|
||||
profile.EnableUpdateCue = s.EnableUpdateCue;
|
||||
profile.CustomCuePaths = s.CustomCuePaths is null
|
||||
? new Dictionary<string, string>()
|
||||
: new Dictionary<string, string>(s.CustomCuePaths);
|
||||
@@ -713,6 +725,7 @@ public sealed class RemSoundSettingsStore
|
||||
public bool? EnableRecordStopCue { get; set; }
|
||||
public bool? EnableSaveCue { get; set; }
|
||||
public bool? EnableProfileSwitchCue { get; set; }
|
||||
public bool? EnableUpdateCue { get; set; }
|
||||
public Dictionary<string, string>? CustomCuePaths { get; set; }
|
||||
public RecordingSettings? RecordingSettings { get; set; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user