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
+759
View File
@@ -0,0 +1,759 @@
using System.Diagnostics;
using System.Drawing;
using System.Text;
using Microsoft.Win32;
using RemSound.Core;
namespace RemSound.App;
/// <summary>
/// "Install RemSound on this PC" — promotes a portable copy (a folder the user extracted from the
/// zip and runs from Downloads / a USB stick / wherever) into a proper per-user install under
/// <c>%LOCALAPPDATA%\Programs\RemSound</c>, with optional desktop and Start-menu shortcuts, a
/// Windows "Installed apps" entry, and login auto-start. Modelled on Andre's Sensor Readout
/// self-installer.
///
/// Design choices, all deliberate:
/// * <b>Per-user location</b> (<c>%LOCALAPPDATA%\Programs</c>), never Program Files. No admin /
/// UAC prompt, and the silent auto-updater keeps working because it can write its own folder.
/// * <b>No separate installer .exe / script.</b> A screen-reader user never has to hunt down and
/// run a loose file — it's a menu item inside the app they already have open.
/// * <b>The whole folder is the install.</b> RemSound reads its settings/profiles/logs relative to
/// its own exe (see <see cref="AppConfig.UserDataDirectory"/>), so once it's running from the
/// install folder it finds everything there automatically — no AppData lookup, no code changes.
/// The installer just copies the program files (always) plus, per the user's tick-boxes, their
/// "user settings and logs" data and their recordings into the same layout inside the target.
/// * <b>Batch uninstaller, not PowerShell.</b> A running exe can't delete its own folder, so a
/// tiny <c>.cmd</c> waits for RemSound to close then removes the folder. Batch (not a .ps1) so a
/// locked-down PowerShell execution policy can never block an uninstall.
///
/// Entry points: <see cref="RunInstall"/> (Options → Install…), <see cref="RunUninstallInProcess"/>
/// (Options → Uninstall… while running the installed copy) and <see cref="RunUninstallStandalone"/>
/// (the <c>--uninstall</c> switch behind the Start-menu / Installed-apps "Uninstall" entry).
/// </summary>
internal static class AppInstaller
{
private const string AppName = "RemSound";
private const string ExeName = "RemSound.exe";
private const string ManualName = "readme.html";
/// <summary>Windows "Installed apps" registration lives under the per-user hive (no admin).</summary>
private const string UninstallRegistryPath =
@"Software\Microsoft\Windows\CurrentVersion\Uninstall\RemSound";
/// <summary>Subfolders/entries beside the exe that are USER DATA, not program files — copied only
/// when the matching tick-box is set, never as part of the always-copied program files.</summary>
private static readonly string[] UserDataEntries =
{
AppConfig.UserDataFolderName, // "user settings and logs" (config + profiles + logs)
"recordings",
};
/// <summary>Filename of the marker RemSound drops beside its exe when it installs itself. Its
/// PRESENCE is the authority for "am I an installed copy" — an explicit token the installer writes,
/// NOT a guess based on which folder the exe happens to sit in. Inferring install-state from the
/// path (does my folder equal the computed install folder?) is fragile: it breaks if the install
/// location ever moves, if the path resolves differently, or if the folder is relocated. A fresh
/// copy unzipped from the release has no marker, so it correctly knows it's portable.</summary>
private const string InstalledMarkerName = "installed.marker";
/// <summary>The fixed per-user install location: <c>%LOCALAPPDATA%\Programs\RemSound</c>.</summary>
public static string InstallFolder
{
get
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (string.IsNullOrWhiteSpace(localAppData))
{
localAppData = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "AppData", "Local");
}
return Path.Combine(localAppData, "Programs", AppName);
}
}
private static string MarkerPath(string folder) => Path.Combine(folder, InstalledMarkerName);
/// <summary>True when THIS running copy is an installed one — decided purely by whether the
/// installer's marker file sits beside this exe, never by comparing folder paths. The Options menu
/// uses it to show "Uninstall…" instead of "Install…".</summary>
public static bool IsInstalledCopy => File.Exists(MarkerPath(AppContext.BaseDirectory));
/// <summary>True when an install already exists at the target location (a re-run becomes an
/// update) — read from the destination's marker.</summary>
private static bool InstallExistsAtTarget => File.Exists(MarkerPath(InstallFolder));
// ---------------- install ----------------
/// <summary>Options → "Install RemSound on this PC". Shows the tick-box dialog, copies the files,
/// wires up shortcuts/registration/auto-start, then relaunches the installed copy and asks the
/// portable one to exit. <paramref name="log"/> (may be null) records milestones to the diagnostic
/// log when logging is on.</summary>
public static void RunInstall(IWin32Window owner, Action<string>? log = null)
{
var source = NormalizeFolder(AppContext.BaseDirectory);
var target = NormalizeFolder(InstallFolder);
if (IsInstalledCopy)
{
MessageBox.Show(owner,
"This copy of RemSound is already installed, so there's nothing to do. " +
"To remove it, use Uninstall RemSound from this PC.",
"Already installed", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
var updating = InstallExistsAtTarget;
var options = ShowInstallOptionsDialog(owner, target, updating);
if (options is null) return; // cancelled
var installedExe = Path.Combine(target, ExeName);
try
{
log?.Invoke($"install: starting {(updating ? "update" : "fresh install")} to \"{target}\" " +
$"(desktop={options.CreateDesktopShortcut}, startMenu={options.CreateStartMenuFolder}, " +
$"startup={options.RunAtStartup}, profiles={options.CopyProfilesAndConfig}, " +
$"recordings={options.CopyRecordings}, logs={options.CopyLogs})");
// The file copy is the part that must succeed — a failure here (disk full, permissions)
// aborts with the copy untouched. Directory.CreateDirectory + copy.
Directory.CreateDirectory(target);
CopyProgramFiles(source, target);
CopyUserData(source, target, options);
// Drop the marker that tells the installed copy it IS installed (so the Options menu shows
// Uninstall). Part of the must-succeed path: without it the install wouldn't recognise
// itself. Written last, after the files are safely in place.
File.WriteAllText(MarkerPath(target),
$"RemSound {CommandLine.AppVersion} was installed here by the in-app installer." + Environment.NewLine +
"RemSound checks for this file to know it is installed rather than portable." + Environment.NewLine +
"Removing this file makes RemSound treat this copy as portable again.", new UTF8Encoding(false));
// Everything below is best-effort desktop integration: a hiccup in one (e.g. a policy
// blocking a shortcut) is logged but must NOT undo a good install — the app is already
// sitting installed, and the user can add a shortcut by hand.
TryStep(log, "desktop shortcut", () => SetDesktopShortcut(options.CreateDesktopShortcut, installedExe, target));
TryStep(log, "Start menu folder", () => SetStartMenuFolder(options.CreateStartMenuFolder, installedExe, target));
// Re-point (or clear) login auto-start so it launches the INSTALLED exe, not the portable
// one the user may delete. Either way the entry now reflects the tick-box and the new path.
TryStep(log, "run at startup", () =>
{
if (options.RunAtStartup) StartupAutoStart.TryEnable(installedExe);
else StartupAutoStart.TryDisable();
});
TryStep(log, "register in Installed apps", () => RegisterInstalledApp(target, installedExe));
log?.Invoke("install: files copied and shortcuts written; relaunching installed copy");
}
catch (Exception ex)
{
log?.Invoke($"install: FAILED — {ex.GetType().Name}: {ex.Message}");
MessageBox.Show(owner,
"RemSound could not finish installing:" + Environment.NewLine + Environment.NewLine + ex.Message +
Environment.NewLine + Environment.NewLine +
"Nothing was changed to the copy you're running now — you can keep using it.",
"Install failed", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
MessageBox.Show(owner,
$"RemSound is now installed on this PC, in:{Environment.NewLine}{target}{Environment.NewLine}{Environment.NewLine}" +
"Press OK to finish. RemSound will now close and reopen from the new install location.",
"RemSound installed", MessageBoxButtons.OK, MessageBoxIcon.Information);
// Hand over cleanly. We can't just launch the installed exe and exit: the single-instance
// lock would still be held for the instant it takes us to shut down, and the new copy would
// see "already running". So a tiny batch waits for THIS process to exit (lock released), then
// starts the installed copy. Then we exit decisively.
try { StartRelaunchAfterExit(installedExe, target); } catch { /* fall through — worst case the user starts it from the shortcut */ }
Environment.Exit(0);
}
// ---------------- uninstall ----------------
/// <summary>Options → "Uninstall RemSound from this PC", chosen from inside the running installed
/// copy. THIS process holds the files open, so we confirm, tear down shortcuts/registration/
/// auto-start, launch the wait-then-delete batch, and exit so it can remove the folder.</summary>
public static void RunUninstallInProcess(IWin32Window owner, Action<string>? log = null)
{
// The folder we're running from IS the install (the marker confirms it) — delete that, rather
// than a separately computed path, so uninstall never targets the wrong place.
var target = NormalizeFolder(AppContext.BaseDirectory);
if (!IsInstalledCopy)
{
MessageBox.Show(owner,
"This copy of RemSound isn't an installed one, so there's nothing to uninstall from here. " +
"You can just delete this folder.",
"Not an installed copy", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
var options = ShowUninstallConfirmDialog(owner, target);
if (options is null) return; // cancelled
log?.Invoke($"uninstall: starting (removeProfilesConfigLogs={options.RemoveProfilesConfigLogs}, " +
$"removeRecordings={options.RemoveRecordings})");
TearDownIntegration();
try { StartDeleteAfterExit(target, options.RemoveProfilesConfigLogs, options.RemoveRecordings); }
catch (Exception ex)
{
log?.Invoke($"uninstall: could not start remover — {ex.Message}");
MessageBox.Show(owner,
"RemSound couldn't start the uninstaller:" + Environment.NewLine + Environment.NewLine + ex.Message,
"Uninstall failed", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
// Uninstall doesn't relaunch — just tell the user it's done, then close (the remover finishes
// deleting the folder the moment this process exits).
MessageBox.Show(owner,
"RemSound has been removed from this PC. Press OK to finish.",
"RemSound uninstalled", MessageBoxButtons.OK, MessageBoxIcon.Information);
Environment.Exit(0);
}
/// <summary>The <c>--uninstall</c> switch: run from the Start-menu "Uninstall" shortcut or Windows
/// "Installed apps". A separate installed copy may still be running, so we confirm, force-close any
/// running RemSound, tear down integration, and start the wait-then-delete batch. Called very early
/// in <see cref="Program"/>, before the single-instance guard, and always ends the process.</summary>
public static void RunUninstallStandalone()
{
// Reached only from the installed exe (the Start-menu / Installed-apps Uninstall entries run
// "<installed exe> --uninstall"), so the folder we're in is the real install — gated by the
// marker so a stray "--uninstall" on a portable copy does nothing.
var target = NormalizeFolder(AppContext.BaseDirectory);
if (!IsInstalledCopy)
{
MessageBox.Show(
"This copy of RemSound isn't an installed one, so there's nothing to remove.",
"Nothing to uninstall", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
var options = ShowUninstallConfirmDialog(null, target);
if (options is null) return; // cancelled
// Close any running RemSound (the installed copy) so its files unlock; the remover also retries,
// so exact timing doesn't matter.
try { SingleInstanceCoordinator.ForceCloseOtherInstances(); } catch { /* remover retries regardless */ }
TearDownIntegration();
try { StartDeleteAfterExit(target, options.RemoveProfilesConfigLogs, options.RemoveRecordings); }
catch (Exception ex)
{
MessageBox.Show(
"RemSound couldn't start the uninstaller:" + Environment.NewLine + Environment.NewLine + ex.Message,
"Uninstall failed", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
MessageBox.Show(
"RemSound has been removed from this PC. Press OK to finish.",
"RemSound uninstalled", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
/// <summary>Remove everything the install put OUTSIDE its own folder — desktop + Start-menu
/// shortcuts, the login auto-start entry, and the Windows "Installed apps" registration. The
/// folder itself is deleted by the batch remover after RemSound closes.</summary>
private static void TearDownIntegration()
{
try { SetDesktopShortcut(false, "", ""); } catch { }
try { SetStartMenuFolder(false, "", ""); } catch { }
try { StartupAutoStart.TryDisable(); } catch { }
try { UnregisterInstalledApp(); } catch { }
}
// ---------------- file copy ----------------
/// <summary>Copy the PROGRAM files (exe, DLLs, native runtimes, default sounds, manual, Tolk, …) —
/// everything beside the exe EXCEPT the user-data entries, which are handled separately so the
/// tick-boxes can include or exclude them. Overwrites, so a re-run cleanly updates/repairs.</summary>
private static void CopyProgramFiles(string source, string target)
{
foreach (var file in Directory.GetFiles(source))
{
// Never carry a stray install marker across — the installer writes a fresh one into the
// target itself, so a source copy's marker must not leak in and mislabel things.
if (string.Equals(Path.GetFileName(file), InstalledMarkerName, StringComparison.OrdinalIgnoreCase))
continue;
File.Copy(file, Path.Combine(target, Path.GetFileName(file)), overwrite: true);
}
foreach (var dir in Directory.GetDirectories(source))
{
var name = Path.GetFileName(dir);
if (UserDataEntries.Any(e => string.Equals(e, name, StringComparison.OrdinalIgnoreCase)))
continue; // user data — copied conditionally below
CopyDirectory(dir, Path.Combine(target, name));
}
}
/// <summary>Copy the user's own data into the same layout inside the install folder, per the
/// tick-boxes: profiles + the global config file, recordings, and logs — each independently.</summary>
private static void CopyUserData(string source, string target, InstallOptions options)
{
var srcData = Path.Combine(source, AppConfig.UserDataFolderName);
var dstData = Path.Combine(target, AppConfig.UserDataFolderName);
if (options.CopyProfilesAndConfig)
{
var srcProfiles = Path.Combine(srcData, "profiles");
if (Directory.Exists(srcProfiles)) CopyDirectory(srcProfiles, Path.Combine(dstData, "profiles"));
var srcConfig = Path.Combine(srcData, "global config.json");
if (File.Exists(srcConfig))
{
Directory.CreateDirectory(dstData);
File.Copy(srcConfig, Path.Combine(dstData, "global config.json"), overwrite: true);
}
}
if (options.CopyLogs)
{
var srcLogs = Path.Combine(srcData, "logs");
if (Directory.Exists(srcLogs)) CopyDirectory(srcLogs, Path.Combine(dstData, "logs"));
}
if (options.CopyRecordings)
{
var srcRec = Path.Combine(source, "recordings");
if (Directory.Exists(srcRec)) CopyDirectory(srcRec, Path.Combine(target, "recordings"));
}
}
private static void CopyDirectory(string source, string dest)
{
Directory.CreateDirectory(dest);
foreach (var file in Directory.GetFiles(source))
File.Copy(file, Path.Combine(dest, Path.GetFileName(file)), overwrite: true);
foreach (var dir in Directory.GetDirectories(source))
CopyDirectory(dir, Path.Combine(dest, Path.GetFileName(dir)));
}
// ---------------- shortcuts ----------------
private static string DesktopShortcutPath =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), AppName + ".lnk");
/// <summary>The per-user Start-menu Programs folder that holds RemSound's shortcuts group.</summary>
private static string StartMenuFolderPath =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Programs), AppName);
private static void SetDesktopShortcut(bool enabled, string targetExe, string workingDir)
{
if (!enabled)
{
if (File.Exists(DesktopShortcutPath)) File.Delete(DesktopShortcutPath);
return;
}
CreateShortcut(DesktopShortcutPath, targetExe, "", workingDir, "RemSound low-latency audio", targetExe);
}
/// <summary>Create (or remove) the Start-menu "RemSound" folder with three shortcuts: the program,
/// the manual, and Uninstall — the classic "installed app" grouping the user asked for.</summary>
private static void SetStartMenuFolder(bool enabled, string targetExe, string workingDir)
{
var folder = StartMenuFolderPath;
if (!enabled)
{
try { if (Directory.Exists(folder)) Directory.Delete(folder, recursive: true); } catch { }
return;
}
Directory.CreateDirectory(folder);
CreateShortcut(Path.Combine(folder, AppName + ".lnk"),
targetExe, "", workingDir, "RemSound low-latency audio", targetExe);
var manual = Path.Combine(workingDir, ManualName);
if (File.Exists(manual))
CreateShortcut(Path.Combine(folder, "RemSound Manual.lnk"),
manual, "", workingDir, "Open the RemSound manual", targetExe);
CreateShortcut(Path.Combine(folder, "Uninstall RemSound.lnk"),
targetExe, "--uninstall", workingDir, "Uninstall RemSound from this PC", targetExe);
}
/// <summary>Write a .lnk using the Windows Script Host shell object via late-bound COM, so there's
/// no extra library to ship and no IShellLink P/Invoke to maintain. Best-effort per shortcut.</summary>
private static void CreateShortcut(string linkPath, string targetPath, string arguments,
string workingDir, string description, string iconSourceExe)
{
var dir = Path.GetDirectoryName(linkPath);
if (!string.IsNullOrWhiteSpace(dir)) Directory.CreateDirectory(dir);
if (File.Exists(linkPath)) File.Delete(linkPath);
var shellType = Type.GetTypeFromProgID("WScript.Shell");
if (shellType is null) return;
dynamic? shell = null;
try
{
shell = Activator.CreateInstance(shellType);
dynamic link = shell!.CreateShortcut(linkPath);
link.TargetPath = targetPath;
link.Arguments = arguments;
link.WorkingDirectory = workingDir;
link.Description = description;
if (!string.IsNullOrWhiteSpace(iconSourceExe)) link.IconLocation = iconSourceExe + ",0";
link.Save();
}
finally
{
if (shell is not null)
{
try { System.Runtime.InteropServices.Marshal.FinalReleaseComObject(shell); } catch { }
}
}
}
// ---------------- "Installed apps" registration ----------------
private static void RegisterInstalledApp(string installFolder, string installedExe)
{
using var key = Registry.CurrentUser.CreateSubKey(UninstallRegistryPath, writable: true);
if (key is null) return;
key.SetValue("DisplayName", "RemSound");
key.SetValue("DisplayVersion", CommandLine.AppVersion);
key.SetValue("Publisher", "RemSound");
key.SetValue("InstallLocation", installFolder);
key.SetValue("DisplayIcon", installedExe);
key.SetValue("UninstallString", $"\"{installedExe}\" --uninstall");
key.SetValue("QuietUninstallString", $"\"{installedExe}\" --uninstall");
key.SetValue("NoModify", 1, RegistryValueKind.DWord);
key.SetValue("NoRepair", 1, RegistryValueKind.DWord);
try { key.SetValue("EstimatedSize", (int)(FolderSizeBytes(installFolder) / 1024), RegistryValueKind.DWord); }
catch { /* size is cosmetic in the Apps list — skip if it can't be measured */ }
}
private static void UnregisterInstalledApp()
{
try { Registry.CurrentUser.DeleteSubKeyTree(UninstallRegistryPath, throwOnMissingSubKey: false); }
catch { }
}
private static long FolderSizeBytes(string folder)
{
long total = 0;
foreach (var f in Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories))
{
try { total += new FileInfo(f).Length; } catch { }
}
return total;
}
// ---------------- batch handoff / removal ----------------
/// <summary>Launch the installed copy and hand it our foreground right. We start it DIRECTLY (not
/// via a detached helper) while THIS process is still alive and frontmost, so we can call
/// <c>AllowSetForegroundWindow</c> to grant the child permission to pull itself to the front once
/// it's up — the Windows-sanctioned way. A process launched from a background helper never gets
/// that right, which is why the window kept opening behind everything. The child is told our PID
/// (<c>--await-pid</c>) so it waits for us to exit — releasing the single-instance lock — before it
/// starts for real, avoiding the "already running" race.</summary>
private static void StartRelaunchAfterExit(string installedExe, string installFolder)
{
var psi = new ProcessStartInfo
{
FileName = installedExe,
WorkingDirectory = installFolder,
UseShellExecute = false,
};
psi.ArgumentList.Add("--foreground");
psi.ArgumentList.Add("--await-pid");
psi.ArgumentList.Add(Environment.ProcessId.ToString());
var child = Process.Start(psi);
// Grant the just-launched child the right to take the foreground. Given now, while we still
// hold it, it survives our imminent exit and lets the child's SetForegroundWindow succeed.
if (child is not null)
{
try { AllowSetForegroundWindow(child.Id); } catch { /* the child also self-nudges as a fallback */ }
}
}
[System.Runtime.InteropServices.DllImport("user32.dll")]
[return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
private static extern bool AllowSetForegroundWindow(int dwProcessId);
/// <summary>Spawn a hidden batch that waits for RemSound to close, then removes the install folder,
/// retrying a few times in case a copy is slow to release its files, then deletes itself. The two
/// flags decide independently whether the "user settings and logs" folder (profiles + config +
/// logs) and the "recordings" folder go too; whichever aren't removed are kept in place.</summary>
private static void StartDeleteAfterExit(string installFolder, bool removeProfilesConfigLogs, bool removeRecordings)
{
var pid = Environment.ProcessId;
// Folders to KEEP: everything else (all program files, and any data folder not being removed)
// is deleted. When nothing is kept, the whole folder goes in one shot.
var keep = new List<string>();
if (!removeProfilesConfigLogs) keep.Add(AppConfig.UserDataFolderName);
if (!removeRecordings) keep.Add("recordings");
string deleteBody;
if (keep.Count == 0)
{
deleteBody = $"rd /s /q \"{installFolder}\"\r\n";
}
else
{
// Delete every subfolder except the kept one(s), plus all files in the root. The kept
// folders remain, so the install folder itself is left holding just the user's data.
var guards = string.Concat(keep.Select(k => $"if /I not \"%%~nxD\"==\"{k}\" "));
deleteBody =
$"for /d %%D in (\"{installFolder}\\*\") do {guards}rd /s /q \"%%D\"\r\n" +
$"del /f /q \"{installFolder}\\*\" >nul 2>&1\r\n";
}
// Retry sentinel: the exe file is the last thing to unlock, so loop until it (or the whole
// folder) is gone. Written without parenthesised blocks so %tries% expands fresh each pass.
var script =
"@echo off\r\n" +
":wait\r\n" +
$"tasklist /FI \"PID eq {pid}\" 2>nul | find \"{pid}\" >nul\r\n" +
"if %errorlevel%==0 ( ping -n 2 127.0.0.1 >nul & goto wait )\r\n" +
"set tries=0\r\n" +
":del\r\n" +
deleteBody +
$"if not exist \"{installFolder}\\{ExeName}\" goto done\r\n" +
"ping -n 2 127.0.0.1 >nul\r\n" +
"set /a tries+=1\r\n" +
"if %tries% lss 30 goto del\r\n" +
":done\r\n" +
"del \"%~f0\"\r\n";
RunHiddenBatch(script, "RemSound-uninstall-");
}
private static void RunHiddenBatch(string script, string namePrefix)
{
var path = Path.Combine(Path.GetTempPath(), namePrefix + Guid.NewGuid().ToString("N") + ".cmd");
File.WriteAllText(path, script, new UTF8Encoding(false));
Process.Start(new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/c \"{path}\"",
WindowStyle = ProcessWindowStyle.Hidden,
CreateNoWindow = true,
UseShellExecute = false,
});
}
// ---------------- dialogs ----------------
private sealed class InstallOptions
{
public bool CreateDesktopShortcut;
public bool CreateStartMenuFolder;
public bool RunAtStartup;
public bool CopyProfilesAndConfig;
public bool CopyRecordings;
public bool CopyLogs;
}
private sealed class UninstallOptions
{
public bool RemoveProfilesConfigLogs;
public bool RemoveRecordings;
}
/// <summary>The install tick-box dialog. Tab order is: every check-box first, THEN Install, THEN
/// Cancel — and there is deliberately no default button, so Enter never fires Install before the
/// user has tabbed past the options. Escape cancels.</summary>
private static InstallOptions? ShowInstallOptionsDialog(IWin32Window owner, string target, bool updating)
{
using var dialog = new Form
{
Text = updating ? "Update the RemSound install" : "Install RemSound on this PC",
StartPosition = FormStartPosition.CenterParent,
FormBorderStyle = FormBorderStyle.FixedDialog,
MinimizeBox = false,
MaximizeBox = false,
AutoSize = true,
AutoSizeMode = AutoSizeMode.GrowAndShrink,
ShowInTaskbar = false,
};
var layout = new TableLayoutPanel
{
Dock = DockStyle.Fill,
AutoSize = true,
AutoSizeMode = AutoSizeMode.GrowAndShrink,
ColumnCount = 1,
Padding = new Padding(14),
};
var heading = Theme.Heading(dialog.Text);
var intro = new Label
{
AutoSize = true,
MaximumSize = new Size(520, 0),
Margin = new Padding(3, 3, 3, 10),
Text = (updating
? "RemSound is already installed. This will update the installed copy in:"
: "RemSound will be installed for you in:")
+ Environment.NewLine + target + Environment.NewLine + Environment.NewLine
+ "No administrator rights are needed. Choose what to set up, then select Install.",
};
var desktopBox = MakeCheck("Create a &desktop shortcut", "Create a desktop shortcut", true);
var startMenuBox = MakeCheck("Add to the &Start menu (program, manual and uninstall)",
"Add a Start menu folder containing the program, the manual and an uninstall shortcut", true);
var startupBox = MakeCheck("&Run RemSound when I sign in to Windows",
"Run RemSound automatically when you sign in to Windows", StartupAutoStart.IsEnabled);
var profilesBox = MakeCheck("Copy my &profiles and settings across",
"Copy your profiles and all your settings from this copy into the install folder", true);
var recordingsBox = MakeCheck("Copy my re&cordings across",
"Copy your recordings from this copy into the install folder", true);
var logsBox = MakeCheck("Copy my &logs across",
"Copy your diagnostic logs from this copy into the install folder", false);
var installButton = new Button
{
Text = updating ? "&Update" : "&Install",
AccessibleName = updating ? "Update" : "Install",
DialogResult = DialogResult.OK,
AutoSize = true,
Margin = new Padding(3, 12, 3, 3),
};
var cancelButton = new Button
{
Text = "&Cancel",
AccessibleName = "Cancel",
DialogResult = DialogResult.Cancel,
AutoSize = true,
Margin = new Padding(3, 12, 3, 3),
};
var buttons = new FlowLayoutPanel
{
FlowDirection = FlowDirection.LeftToRight,
AutoSize = true,
Dock = DockStyle.Fill,
Margin = Padding.Empty,
};
buttons.Controls.Add(installButton);
buttons.Controls.Add(cancelButton);
foreach (var c in new Control[]
{ heading, intro, desktopBox, startMenuBox, startupBox, profilesBox, recordingsBox, logsBox, buttons })
{
layout.Controls.Add(c);
}
dialog.Controls.Add(layout);
// Tab order: options top-to-bottom, then Install, then Cancel. No AcceptButton, so pressing
// Enter on a check-box does nothing — the user must tab to Install and activate it.
var order = new Control[]
{ desktopBox, startMenuBox, startupBox, profilesBox, recordingsBox, logsBox, installButton, cancelButton };
for (var i = 0; i < order.Length; i++) order[i].TabIndex = i;
dialog.CancelButton = cancelButton; // Escape cancels
dialog.AcceptButton = null;
if (dialog.ShowDialog(owner) != DialogResult.OK) return null;
return new InstallOptions
{
CreateDesktopShortcut = desktopBox.Checked,
CreateStartMenuFolder = startMenuBox.Checked,
RunAtStartup = startupBox.Checked,
CopyProfilesAndConfig = profilesBox.Checked,
CopyRecordings = recordingsBox.Checked,
CopyLogs = logsBox.Checked,
};
}
// AccessibleCheckBox (not a plain CheckBox) — it re-fires the MSAA focus event on every toggle so
// NVDA reliably announces "checked" / "not checked", including for a spacebar toggle while the box
// already has focus, which is the state plain WinForms checkboxes leave silent on .NET 10.
private static AccessibleCheckBox MakeCheck(string text, string accessibleName, bool @checked) => new()
{
Text = text,
AccessibleName = accessibleName,
Checked = @checked,
AutoSize = true,
Margin = new Padding(3, 2, 3, 2),
};
/// <summary>The uninstall confirmation. Two independent opt-ins — remove profiles/config/logs, and
/// remove recordings — both unticked by default, so the safe outcome (keep my data) is the default.
/// OK to go ahead, Cancel to back out.</summary>
private static UninstallOptions? ShowUninstallConfirmDialog(IWin32Window? owner, string target)
{
using var dialog = new Form
{
Text = "Uninstall RemSound from this PC",
StartPosition = owner is null ? FormStartPosition.CenterScreen : FormStartPosition.CenterParent,
FormBorderStyle = FormBorderStyle.FixedDialog,
MinimizeBox = false,
MaximizeBox = false,
AutoSize = true,
AutoSizeMode = AutoSizeMode.GrowAndShrink,
ShowInTaskbar = false,
TopMost = owner is null, // launched with no parent (Start menu) — make sure it's seen
};
var layout = new TableLayoutPanel
{
Dock = DockStyle.Fill,
AutoSize = true,
AutoSizeMode = AutoSizeMode.GrowAndShrink,
ColumnCount = 1,
Padding = new Padding(14),
};
var heading = Theme.Heading("Uninstall RemSound from this PC");
var intro = new Label
{
AutoSize = true,
MaximumSize = new Size(520, 0),
Margin = new Padding(3, 3, 3, 10),
Text = "This will remove the installed RemSound from this PC:" + Environment.NewLine + target +
Environment.NewLine + Environment.NewLine +
"RemSound will close when the uninstall starts.",
};
var removeDataBox = MakeCheck("Remove &profiles, config and logs", "Remove profiles, config and logs", false);
var removeRecBox = MakeCheck("Remove re&cordings", "Remove recordings", false);
var okButton = new Button
{
Text = "&OK", AccessibleName = "OK",
DialogResult = DialogResult.OK, AutoSize = true, Margin = new Padding(3, 12, 3, 3),
};
var cancelButton = new Button
{
Text = "&Cancel", AccessibleName = "Cancel",
DialogResult = DialogResult.Cancel, AutoSize = true, Margin = new Padding(3, 12, 3, 3),
};
var buttons = new FlowLayoutPanel
{
FlowDirection = FlowDirection.LeftToRight, AutoSize = true, Dock = DockStyle.Fill, Margin = Padding.Empty,
};
buttons.Controls.Add(okButton);
buttons.Controls.Add(cancelButton);
foreach (var c in new Control[] { heading, intro, removeDataBox, removeRecBox, buttons }) layout.Controls.Add(c);
dialog.Controls.Add(layout);
// Tab order: the two check-boxes, then OK, then Cancel. No default button, so Enter on a box
// doesn't fire OK — the user tabs to OK deliberately. Escape cancels.
removeDataBox.TabIndex = 0;
removeRecBox.TabIndex = 1;
okButton.TabIndex = 2;
cancelButton.TabIndex = 3;
dialog.CancelButton = cancelButton;
dialog.AcceptButton = null;
var result = owner is null ? dialog.ShowDialog() : dialog.ShowDialog(owner);
return result == DialogResult.OK
? new UninstallOptions { RemoveProfilesConfigLogs = removeDataBox.Checked, RemoveRecordings = removeRecBox.Checked }
: null;
}
/// <summary>Run a best-effort integration step, logging (never throwing) on failure — so a blocked
/// shortcut or registry write doesn't undo an otherwise-good install.</summary>
private static void TryStep(Action<string>? log, string what, Action step)
{
try { step(); }
catch (Exception ex) { log?.Invoke($"install: could not set up {what} — {ex.GetType().Name}: {ex.Message}"); }
}
private static string NormalizeFolder(string path) =>
Path.GetFullPath(path ?? "").TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}