v5.1: install RemSound as a proper Windows app, plus smaller sounds

New Options -> Install / Uninstall RemSound on this PC: a per-user self-installer
(%LOCALAPPDATA%\Programs\RemSound, no admin) with optional desktop + Start-menu
shortcuts, login auto-start (reuses StartupAutoStart), Windows Installed-apps
registration, and copy-across of profiles+config, recordings and logs. Install
state is decided by a marker file, not a folder-path guess; the post-install
relaunch hands over foreground via AllowSetForegroundWindow so the installed copy
comes to the front; uninstall uses a batch remover (no PowerShell) and confirms
with two independent tick-boxes. All new dialogs use the house accessible controls
(AccessibleCheckBox, Theme.Heading).

Also: iOS (TestFlight) companion link alongside Android in README + manual;
slimmed-down default cue WAVs; About/RELEASE_NOTES/manual updated; version -> 5.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-09 15:45:21 +01:00
co-authored by Claude Opus 4.8
parent a92928d357
commit 7038fef67c
46 changed files with 996 additions and 37 deletions
+113 -1
View File
@@ -1409,7 +1409,10 @@ public sealed class MainForm : Form
// wired up before we hide the window.
var coldStart = isFirstLaunch;
isFirstLaunch = false;
var minimizeThisInstance = startNextInstanceMinimized || (coldStart && AppConfig.Load().StartMinimised);
// A post-install relaunch (--foreground) overrides any start-minimised preference — the
// user just ran an interactive install and expects to see the installed copy come up.
var minimizeThisInstance = !forceForegroundOnStart
&& (startNextInstanceMinimized || (coldStart && AppConfig.Load().StartMinimised));
startNextInstanceMinimized = false;
if (minimizeThisInstance)
{
@@ -1417,6 +1420,36 @@ public sealed class MainForm : Form
// user choosing to minimise, so it must not sound the "minimise" cue.
BeginInvoke(() => trayController.Minimize(playCue: false));
}
else if (forceForegroundOnStart)
{
forceForegroundOnStart = false;
logFile.Event("installer: post-install relaunch — bringing the window to the foreground");
// Deferred so it runs after Shown settles, then yanks the window to the front so the
// just-installed copy isn't left hiding behind other windows. Try again a moment later:
// a freshly-launched process routinely loses the very first foreground race (the OS is
// still settling which window owns the foreground just after the old copy exited).
BeginInvoke(() =>
{
ForceWindowToForeground();
var attempts = 0;
var retry = new System.Windows.Forms.Timer { Interval = 250 };
retry.Tick += (_, _) =>
{
attempts++;
// Stop once we genuinely own the foreground, or after a few tries.
var won = !IsDisposed && GetForegroundWindow() == Handle;
if (IsDisposed || attempts >= 5 || won)
{
retry.Stop();
retry.Dispose();
logFile.Event($"installer: foreground grab done after {attempts} retries, gotForeground={won}");
return;
}
ForceWindowToForeground();
};
retry.Start();
});
}
// Kick off UPnP discovery if the user has the box ticked. Off by default; the
// mapper itself coalesces redundant Start() calls so a re-enter via Shown after
@@ -1987,6 +2020,27 @@ public sealed class MainForm : Form
UpdateRealtekAsioMenuItemText();
}
// Install / uninstall RemSound as a proper per-user Windows app. The single item flips to
// "Uninstall…" when this copy IS the installed one. Copies files to %LOCALAPPDATA%\Programs
// (no admin), with optional shortcuts and login auto-start. Modelled on Andre's Sensor Readout.
ToolStripMenuItem installItem;
if (AppInstaller.IsInstalledCopy)
{
installItem = new ToolStripMenuItem("&Uninstall RemSound from this PC...")
{
AccessibleName = "Uninstall RemSound from this PC",
};
installItem.Click += (_, _) => AppInstaller.RunUninstallInProcess(this, msg => logFile.Event($"installer: {msg}"));
}
else
{
installItem = new ToolStripMenuItem("&Install RemSound on this PC...")
{
AccessibleName = "Install RemSound on this PC",
};
installItem.Click += (_, _) => AppInstaller.RunInstall(this, msg => logFile.Event($"installer: {msg}"));
}
var optionItems = new List<ToolStripItem>
{
recordingSettingsItem,
@@ -1997,6 +2051,7 @@ public sealed class MainForm : Form
};
if (realtekToggle is not null) optionItems.Add(realtekToggle);
optionItems.Add(new ToolStripSeparator());
optionItems.Add(installItem);
optionItems.Add(prefsItem);
optionsMenu.DropDownItems.AddRange(optionItems.ToArray());
@@ -2104,6 +2159,12 @@ public sealed class MainForm : Form
// "loading audio driver" splash in that case.
internal static bool startNextInstanceMinimized;
// Set from the --foreground switch, which the post-install relaunch passes. Makes the next
// MainForm pull itself to the front and take focus even past Windows' foreground lock, so the
// freshly-installed copy doesn't open behind other windows and leave the user hunting for it.
// One-shot: consumed (cleared) the first time a window honours it.
internal static bool forceForegroundOnStart;
// True until the first MainForm of the process has shown its window. Distinguishes a genuine
// cold launch (where the "Start minimised" preference applies) from an in-session new-profile or
// profile-switch relaunch (where it must NOT — those would otherwise hide the window and look
@@ -7304,6 +7365,57 @@ public sealed class MainForm : Form
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
/// <summary>Bring this window to the very front and give it focus, robust against Windows'
/// foreground lock. A freshly-launched process (the post-install relaunch) has no recent user
/// input to its name, so a bare <c>SetForegroundWindow</c> is refused and the window opens behind
/// whatever's on top. Briefly attaching our input queue to the current foreground thread lets the
/// call through; the TopMost blip nudges the Z-order too. Best-effort — never throws.</summary>
private void ForceWindowToForeground()
{
try
{
if (IsDisposed) return;
// Capture who's in front FIRST. If we called Show()/Activate() before reading this,
// GetForegroundWindow could already return our own handle — and then we'd skip the
// thread-attach below, which is the part that actually lets SetForegroundWindow win.
var fgWnd = GetForegroundWindow();
var myThread = GetCurrentThreadId();
var fgThread = fgWnd == IntPtr.Zero ? myThread : GetWindowThreadProcessId(fgWnd, out _);
// Share input state with whatever currently owns the foreground, so Windows treats our
// SetForegroundWindow as coming from the active thread and allows it past the lock.
var attached = fgThread != myThread && AttachThreadInput(fgThread, myThread, true);
try
{
if (WindowState == FormWindowState.Minimized) WindowState = FormWindowState.Normal;
Show();
BringToFront();
SetForegroundWindow(Handle);
Activate();
Focus();
}
finally
{
if (attached) AttachThreadInput(fgThread, myThread, false);
}
}
catch { /* best-effort — worst case the window is visible but not focused */ }
}
[System.Runtime.InteropServices.DllImport("user32.dll")]
[return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
private static extern bool SetForegroundWindow(IntPtr hWnd);
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
[System.Runtime.InteropServices.DllImport("kernel32.dll")]
private static extern uint GetCurrentThreadId();
[System.Runtime.InteropServices.DllImport("user32.dll")]
[return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach);
// ===================== Profile system =====================