Files
RemSound/src/RemSound.App/Program.cs
T

503 lines
28 KiB
C#
Raw Normal View History

2026-05-13 15:08:31 +01:00
using System.Runtime;
using System.Windows.Forms;
using RemSound.Core;
namespace RemSound.App;
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;
// Writes an otherwise-fatal exception to a timestamped crash file in the logs folder, so a
// "RemSound just disappeared, no dialog" report (#16) leaves a stack behind to diagnose instead
// of nothing. Best-effort and self-contained — a crash handler must never throw.
private static void WriteCrashReport(string source, Exception? ex)
{
try
{
var dir = AppConfig.LogsDirectory;
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, $"crash-{DateTime.Now:yyyyMMdd-HHmmss-fff}.txt");
var report =
$"RemSound crash report{Environment.NewLine}" +
$"Version: {typeof(Program).Assembly.GetName().Version}{Environment.NewLine}" +
$"Time: {DateTime.Now:O}{Environment.NewLine}" +
$"Source: {source}{Environment.NewLine}{Environment.NewLine}" +
(ex?.ToString() ?? "(no exception object)");
File.WriteAllText(path, report);
}
catch { /* a crash handler must never throw */ }
}
2026-05-13 15:08:31 +01:00
[STAThread]
private static void Main(string[] args)
2026-05-13 15:08:31 +01:00
{
// The auto-updater relaunches a temp copy of the NEW RemSound.exe in this mode to swap the
// new files over the install while the old copy exits (see UpdateApplier / RemSoundUpdater).
// Handle it first and return: this process is the installer, not a normal launch, so it must
// not touch the single-instance lock, audio devices, or the migration steps below.
if (args.Length > 0 && Array.Exists(args, a => string.Equals(a, "--apply-update", StringComparison.OrdinalIgnoreCase)))
{
UpdateApplier.Run(args);
return;
}
// Capture otherwise-fatal background-thread exceptions to a crash file, so a "RemSound just
// vanished with no dialog" report (#16) leaves a stack behind instead of nothing. Best-effort.
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
WriteCrashReport("AppDomain.UnhandledException", e.ExceptionObject as Exception);
TaskScheduler.UnobservedTaskException += (_, e) =>
{
WriteCrashReport("TaskScheduler.UnobservedTaskException", e.Exception);
e.SetObserved();
};
// --config-dir <folder> (test / portable isolation): redirect ALL user state - config,
// profiles, logs, cue sounds - to an explicit folder for THIS process only. Applied first,
// before the layout migration and sound consolidation below read or write the default
// location, so a smoke test can run a real build without touching the user's settings
// (smoke-test brief, safety rule 1).
if (CommandLine.TryGetConfigDir(args, out var configDir))
{
AppConfig.SetUserDataDirectoryOverride(configDir);
}
// --silent: make this launch play no cue sounds at all (startup, connect, checkbox,
// tab-switch, ...) and skip the front-most "missing sound file" warning. The automated test
// harness passes it so its throwaway launches stay completely quiet instead of chiming a
// startup cue (or popping a dialog) onto whoever happens to be at the screen. Set up front,
// before consolidation or the startup cue can fire.
if (Array.Exists(args, a => string.Equals(a, "--silent", StringComparison.OrdinalIgnoreCase)))
{
CuePlayer.GloballyMuted = true;
}
2026-05-13 15:08:31 +01:00
// SustainedLowLatency tells the GC to avoid full (gen 2) collections while audio is streaming.
// Gen 0/1 collections still happen but are sub-millisecond; the long pauses that were causing
// the receiver to fall behind in clusters of 4-5 underruns at a time were almost certainly
// gen 2 sweeps. This trades a bit of memory headroom (the GC will hold on to garbage longer)
// for dramatically more predictable timing — exactly the trade real-time audio wants.
GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency;
ApplicationConfiguration.Initialize();
// Consolidate every older layout (loose files, or the interim config\ folder) into the single
// "user settings and logs" folder before anything reads config/profiles/logs. Idempotent +
// best-effort; upgrades users from any older build. Shown to the user once if files moved.
var layoutMigration = RemSound.Core.AppConfig.MigrateLegacyLayoutIfNeeded();
// Cue sounds no longer live in a sounds\ folder at all (the shipped defaults are install-side
// in AppConfig.SoundsDirectory = "default sounds\" now, so an update always refreshes them).
// Delete BOTH defunct old sounds folders an upgrader might still have, whatever version they
// came from. Best-effort + idempotent.
RemoveLegacySoundFolders();
// Remove cue WAVs (and their .sfk peak files) left loose in the install ROOT by pre-
// 2026-05-28 builds, where the cues lived next to RemSound.exe before they moved into
// sounds\. An update copies the new sounds\ tree but never purges, so it never deletes
// these orphans — they just linger in the root. Best-effort + idempotent:
// a no-op once they're gone. 2026-06-08.
CleanUpLegacyRootSounds();
// Command-line interface (Sensor-Readout-style). "Do-and-exit" commands (--devices,
// --selftest, --diagnostics, --version, --log, --close, --help) run here — before the
// single-instance lock and before any window — and terminate the process. Otherwise we
// collect launch overrides (--profile / --connect / --minimized) and continue the normal
// GUI start below, applying them as we resolve the profile.
var cliExit = CommandLine.Process(args, out var cli);
if (cliExit is { } cliCode) Environment.Exit(cliCode);
if (cli.StartMinimized) MainForm.startNextInstanceMinimized = true;
// 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)))
{
ForegroundDialog.Show(owner => MessageBox.Show(
owner,
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 — THIS copy has taken over (any stuck older copy was
// force-closed just above). Play the one-shot startup cue here: after the take-over
// decision is settled and before the profile picker/load, so if a copy was already
// running you only hear it once the NEW process is in charge. The earlier "switch to the
// running copy" / "cancel" paths returned before this point, so a copy that bowed out
// never plays it. Fire-and-forget WaveOut, so it sounds even when we launch into the tray.
PlayStartupCueIfEnabled();
// 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();
// Best-effort: clear leftover update temp stages (and any relics of the old batch updater).
// We hold the single-instance lock here, so only the live copy does this — no sibling race.
RemSoundUpdater.CleanUpUpdateStages();
2026-05-13 15:08:31 +01:00
// 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
// is per-thread and modifier-aware: bare F1 only, so Shift/Ctrl/Alt+F1 stay free.
HelpLauncher.Install();
// Audible typing feedback: a soft click on each keystroke in any edit field, plus a distinct
// passkey sound on password fields. Machine-wide toggle (on by default). Installed app-wide
// here - after the single-instance guard, before the profile picker - so it works on the
// picker and every dialog. Best-effort: inert if the click sounds can't load.
KeyClickService.Initialize(AppConfig.Load().EnableKeyboardClicks);
Application.ApplicationExit += (_, _) => KeyClickService.Shutdown();
// Tick/untick sounds for checkbox toggles app-wide (CheckSoundService) and the tab-switch
// cue (TabSwitchSoundService). Loaded here; reloaded by MainForm.ReloadAllCueSounds whenever
// cue settings change in Preferences.
CheckSoundService.Reload();
TabSwitchSoundService.Reload();
// One-time "your settings moved" notice — only the launch that actually relocated files
// shows it (idempotent migration ⇒ MovedAnything is false on every later launch). Shown
// here, after the guard and before the profile picker, so the user reads it once up front.
// Skipped on a --silent (automated/throwaway) launch: its TaskDialog dings at and pops over
// whoever's at the screen during a test, and a throwaway instance needn't announce a move.
if (layoutMigration.MovedAnything && !CuePlayer.GloballyMuted)
{
ShowLayoutMigrationNotice();
}
2026-05-13 15:08:31 +01:00
// Outer loop: lets ProfileManagementDialog change the profiles folder mid-session.
// When that happens, MainForm sets ReloadFromScratch=true, we re-read AppConfig, build
// a fresh ProfileStore, and re-show ProfileSelectionDialog so the user picks a profile
// (or blank template) from the *new* folder. Inner loop handles the cheaper "switch to
// a profile in the same folder" case.
while (true)
{
var appConfig = AppConfig.Load();
var store = appConfig.CreateStore();
Profile? profile;
string? title;
// Auto-load shortcut: two paths.
//
// (a) Resume-after-update sentinel — a one-shot file written by the updater
// just before it relaunches RemSound.exe (see RemSoundUpdater
// ResumeProfileSentinelName). Holds the title of whichever profile was
// loaded at the moment the update fired. If present, we load that profile
// silently and delete the sentinel — so a silent or mid-session update
// restores the same session the user was running, without dropping them
// at the picker. This takes precedence over StartWithProfileTitle because
// a mid-session update may have moved the user away from their configured
// startup profile.
//
// (b) AppConfig.StartWithProfileTitle — the persistent "Start with a specific
// profile" preference set via the Startup behaviour dialog. Loaded if (a)
// didn't fire. Combined with the Windows auto-start registry entry and the
// StartMinimised flag, it lets the user boot a machine and have RemSound
// up and streaming with no clicks.
//
// Either path falls through to the normal picker if the named profile no longer
// exists (deleted since it was selected, or the profiles folder changed) so the
// user isn't stuck.
2026-05-13 15:08:31 +01:00
Profile? autoLoaded = null;
string? autoLoadedTitle = null;
// CLI overrides take precedence over the resume sentinel and the "start with profile"
// setting. --profile loads a named profile; --connect with no --profile starts blank
// (the requested peer is added to whatever profile loads, further below).
if (cli.ProfileName is not null)
{
try { autoLoaded = store.Load(cli.ProfileName); if (autoLoaded is not null) autoLoadedTitle = cli.ProfileName; }
catch { /* fall through to the normal resolution */ }
}
else if (cli.ForceBlankProfile)
{
autoLoaded = Profile.NewBlank();
autoLoadedTitle = null;
}
var resumeSentinelPath = Path.Combine(AppContext.BaseDirectory, RemSoundUpdater.ResumeProfileSentinelName);
string? resumeTitle = null;
if (File.Exists(resumeSentinelPath))
{
try { resumeTitle = File.ReadAllText(resumeSentinelPath).Trim(); }
catch { resumeTitle = null; }
// Delete the sentinel unconditionally — it's a one-shot. If the load fails
// below, the user gets the picker on this launch and a normal start next
// time, rather than the sentinel re-firing on every relaunch forever.
try { File.Delete(resumeSentinelPath); } catch { /* ignore */ }
}
if (autoLoaded is null && !string.IsNullOrWhiteSpace(resumeTitle))
{
try
{
autoLoaded = store.Load(resumeTitle!);
if (autoLoaded is not null) autoLoadedTitle = resumeTitle;
}
catch { /* fall through to StartWithProfileTitle / picker */ }
}
if (autoLoaded is null && !string.IsNullOrWhiteSpace(appConfig.StartWithProfileTitle))
2026-05-13 15:08:31 +01:00
{
try
{
autoLoaded = store.Load(appConfig.StartWithProfileTitle!);
if (autoLoaded is not null) autoLoadedTitle = appConfig.StartWithProfileTitle;
}
catch { /* fall back to picker */ }
}
if (autoLoaded is not null)
{
profile = autoLoaded;
title = autoLoadedTitle;
}
else
{
using var dialog = new ProfileSelectionDialog(store);
if (dialog.ShowDialog() != DialogResult.OK) return;
// ProfileSelectionDialog can have changed the folder via its Browse button;
// if so, it's already saved AppConfig and rebuilt its internal store. Pick up
// its post-Browse store reference for the rest of the session.
store = dialog.Store;
profile = dialog.SelectedProfile;
title = dialog.SelectedTitle;
}
// Apply --connect: add the requested peer address(es) to the loaded profile so MainForm
// selects and connects to them on startup. Stored as plain address strings, the same
// shape "Add peer by IP" produces (the audio port is the default unless one is given).
if (cli.ConnectPeers.Count > 0 && profile is not null)
{
foreach (var ep in cli.ConnectPeers)
{
var addr = ep.Port == RemPacket.DefaultPort ? ep.Address.ToString() : $"{ep.Address}:{ep.Port}";
if (!profile.RememberedPeers.Contains(addr)) profile.RememberedPeers.Add(addr);
if (!profile.SelectedConnectedPeers.Contains(addr)) profile.SelectedConnectedPeers.Add(addr);
}
}
2026-05-13 15:08:31 +01:00
// Switch-profile loop: when the user clicks "Switch to profile" in the Manage
// Profiles dialog, the form sets NextProfileTitleToLoad and closes; we re-open
// MainForm under the newly chosen profile. Null = user closed the form normally
// → exit. ReloadFromScratch = the user changed the profiles FOLDER mid-session,
// so we break out of this inner loop and let the outer loop redo the selection
// dialog under the new folder.
var reloadFromScratch = false;
string? nextPath = null;
while (true)
{
// Opening an ASIO driver is slow (1-3 s) and happens synchronously inside
// MainForm construction. Show a "Loading audio driver" splash — on its own
// thread, so it stays painted while this thread is busy — so startup doesn't
// look hung. No-op for WASAPI-only profiles (construction is near-instant).
// Skip the loading splash when this rebuild is a quick-profile-switch that's
// staying in the tray — popping a splash up in front of the user's current app
// defeats the point of keeping RemSound minimised, and the switch cue already
// gave them feedback. Normal launches and visible switches still show it.
var splash = MainForm.startNextInstanceMinimized ? null : AsioLoadingSplash.StartIfNeeded(profile);
2026-05-13 15:08:31 +01:00
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();
2026-05-13 15:08:31 +01:00
Application.Run(form);
activeMainForm = null;
2026-05-13 15:08:31 +01:00
if (form.ReloadFromScratch)
{
reloadFromScratch = true;
break;
}
// Path-based reload (File → Open profile from a path that may be outside
// the active store's BaseDirectory) takes precedence — read JSON directly
// from that path. Falls back to title-based store.Load when no path is set
// (e.g. legacy switch-by-title flows that pre-date the path tracking).
nextPath = form.NextProfilePathToLoad;
var nextTitle = form.NextProfileTitleToLoad;
if (form.LoadBlankTemplateNext)
{
// File → New profile: rebuild on a fresh blank template, no saved profile.
profile = Profile.NewBlank();
title = null;
nextPath = null;
}
else if (!string.IsNullOrEmpty(nextPath))
2026-05-13 15:08:31 +01:00
{
try
{
var json = File.ReadAllText(nextPath);
profile = System.Text.Json.JsonSerializer.Deserialize<Profile>(json) ?? Profile.NewBlank();
title = !string.IsNullOrEmpty(nextTitle)
? nextTitle
: Path.GetFileNameWithoutExtension(nextPath);
}
catch
{
// Malformed / unreadable JSON. Fall back to blank template under
// whatever title we have, rather than crashing the loop.
profile = Profile.NewBlank();
title = !string.IsNullOrEmpty(nextTitle)
? nextTitle
: Path.GetFileNameWithoutExtension(nextPath);
nextPath = null;
}
}
else if (!string.IsNullOrEmpty(nextTitle))
{
title = nextTitle;
profile = store.Load(nextTitle) ?? Profile.NewBlank();
}
else
{
return; // form closed normally — exit app
}
}
if (!reloadFromScratch) return;
}
}
/// <summary>One-time, Windows-native notice telling the user their config/profiles were moved
/// into the new "user settings and logs" folder. Only called when a real migration happened. TaskDialog
/// (not a hand-rolled Form) so a screen reader reads the whole message automatically.</summary>
private static void ShowLayoutMigrationNotice()
{
var page = new TaskDialogPage
{
Caption = "RemSound files location",
Heading = "Your RemSound files have moved into one folder",
Text = "To keep the RemSound folder tidy and stop updates from ever touching your own files, "
+ "this update moved everything this machine owns into a single folder inside RemSound "
+ "called \"user settings and logs\":\n\n"
+ "- Your settings (global config)\n"
+ "- Your saved profiles\n"
+ "- Your logs\n"
+ "- Your cue sounds\n\n"
+ "Nothing was lost and RemSound works exactly as before. From now on, RemSound updates "
+ "leave that folder completely untouched. You will only see this message once.",
Icon = TaskDialogIcon.Information,
Buttons = { TaskDialogButton.OK },
DefaultButton = TaskDialogButton.OK,
AllowCancel = true,
};
// Give the notice a momentary top-most, foreground owner so it opens FRONT and CENTRE —
// RemSound may have launched straight into the tray (auto-start / start-minimised), and a
// parent-less TaskDialog can otherwise open behind everything where a screen-reader user
// can't read it.
try { ForegroundDialog.Show(owner => TaskDialog.ShowDialog(owner, page)); }
catch { /* a notice must never stop RemSound from starting */ }
}
/// <summary>Delete cue WAVs and their .sfk peak files left loose in the install ROOT by
/// pre-2026-05-28 builds (the cues moved into <c>sounds\</c> then; an update copies the new
/// tree but never removes the old root copies). Best-effort and idempotent — runs
/// every launch and no-ops once the orphans are gone. Only the known default cue names are
/// touched, never anything else in the folder.</summary>
private static void CleanUpLegacyRootSounds()
{
try
{
var root = AppContext.BaseDirectory;
string[] cueBaseNames =
{
"connect", "disconnect", "record start", "record stop",
"save", "profile", "profile menu open", "update",
};
foreach (var baseName in cueBaseNames)
{
foreach (var fileName in new[] { baseName + ".wav", baseName + ".sfk", baseName + ".wav.sfk" })
{
try
{
var path = Path.Combine(root, fileName);
if (File.Exists(path)) File.Delete(path);
}
catch { /* a locked / unremovable file must never stop startup */ }
}
}
}
catch { /* never let cleanup disturb startup */ }
}
/// <summary>Delete the two defunct old cue-sounds folders an upgrader might still have on disk,
/// whichever version they came from. Sounds now live install-side in
/// <see cref="AppConfig.SoundsDirectory"/> (<c>&lt;exe&gt;\default sounds\</c>), which updates
/// always refresh; both old locations are dead and only cause confusion / stale reads if left:
/// * <c>&lt;exe&gt;\sounds\</c> — the install-side folder cue WAVs lived in from ~v3.1 to v3.4.
/// A user jumping STRAIGHT from that era to this version never ran the v3.5 consolidation that
/// used to move-and-delete it, so it can still be sitting there.
/// * <c>...\user settings and logs\sounds\</c> — the per-user folder cues lived in from v3.5 to
/// v3.9.1, with a never-overwrite seed that meant a changed default could never reach an
/// existing user (the whole reason for the 2026-06-13 move).
/// Best-effort + idempotent — a no-op once they're gone. The user's REAL custom sounds were never
/// in either folder (they're explicit Browse-picked file paths elsewhere), so nothing is lost.</summary>
private static void RemoveLegacySoundFolders()
{
foreach (var legacy in new[]
{
Path.Combine(AppContext.BaseDirectory, "sounds"), // ~v3.1v3.4 install-side
AppConfig.LegacyUserSoundsDirectory, // v3.5v3.9.1 per-user
})
{
try { if (Directory.Exists(legacy)) Directory.Delete(legacy, recursive: true); }
catch { /* leave it if locked / unreadable — it's just unused clutter now */ }
}
}
/// <summary>Play the startup cue once if the machine-wide setting is on. Resolves the WAV the
/// same way the in-app cues do — a user-set custom path (machine-wide, in <see cref="AppConfig"/>)
/// if it exists on disk, otherwise the bundled <c>default sounds\start up.wav</c>. Read straight from
/// AppConfig because no profile (and therefore no settings store) is loaded yet at this point
/// in startup. Best-effort: a cue must never stop RemSound from starting.</summary>
private static void PlayStartupCueIfEnabled()
{
try
{
var cfg = AppConfig.Load();
if (!cfg.EnableStartupCue) return;
var custom = cfg.StartupCueCustomPath;
// Custom override wins; otherwise the chosen default variant ("start up 1.wav" etc).
var path = !string.IsNullOrWhiteSpace(custom) && File.Exists(custom)
? custom
: CueSounds.ResolveDefaultPath(MainForm.CueId.Startup, "start up.wav", cfg);
if (path is null || !File.Exists(path)) return;
new CuePlayer(path).Play();
}
catch { /* a startup cue must never disturb startup */ }
}
2026-05-13 15:08:31 +01:00
}