The same singer hit a rare crash: their transmitter (COMP3) was reachable at two addresses at once — the VPN address 10.8.0.1 they chose and that machine's wireless 192.168.3.245 — and the discovery-driven endpoint-follow ping-ponged the connection between the two (the log shows four moves in 58 ms) right where the process died with no shutdown line, no managed exception, no dialog: a hard crash from the receiver audio-session teardown/rebuild churn the thrash caused. Fix: the follow loop now never moves off an endpoint that's still answering heartbeats, only follows once the current one has been unreachable for a sustained grace period (6 s), only to an address that is itself answering, and never more than once per cooldown (15 s) — so it can't thrash, and a peer reached on a working address stays put (honours the singer's "just stay on 10.8.0.1"). New endpointUnreachableSinceUtc + lastEndpointMoveUtc state; genuine DHCP/network moves are still followed a few seconds later. Also: a global crash handler (Program.WriteCrashReport on AppDomain.UnhandledException + TaskScheduler.UnobservedTaskException) writes a crash-*.txt into the logs folder, so a future "RemSound just vanished" report leaves a stack behind. Removed a stale doc comment left over from the v4.7 adoption removal. Manual gains a "RemSound closed unexpectedly" troubleshooting entry. Version 4.8; About + RELEASE_NOTES updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
503 lines
28 KiB
C#
503 lines
28 KiB
C#
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 */ }
|
||
}
|
||
|
||
[STAThread]
|
||
private static void Main(string[] args)
|
||
{
|
||
// 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;
|
||
}
|
||
|
||
// 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();
|
||
|
||
// 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();
|
||
}
|
||
|
||
// 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.
|
||
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))
|
||
{
|
||
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);
|
||
}
|
||
}
|
||
|
||
// 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);
|
||
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();
|
||
Application.Run(form);
|
||
activeMainForm = null;
|
||
|
||
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))
|
||
{
|
||
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><exe>\default sounds\</c>), which updates
|
||
/// always refresh; both old locations are dead and only cause confusion / stale reads if left:
|
||
/// * <c><exe>\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.1–v3.4 install-side
|
||
AppConfig.LegacyUserSoundsDirectory, // v3.5–v3.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 */ }
|
||
}
|
||
}
|