using System.Text.Json; namespace RemSound.Core; /// How often the self-updater polls GitHub Releases for a newer build. Values are /// stable: don't reorder; deserialisation reads the underlying int from remsound.config.json. public enum UpdateCheckFrequency { Never = 0, EveryHour = 1, Every6Hours = 2, Every24Hours = 3, } /// /// App-level configuration that lives next to the exe as remsound.config.json. /// Distinct from : profiles are user-chosen sets of audio / /// connectivity / device settings; the app config is the *meta* layer that holds /// preferences that should be sticky regardless of which profile is loaded. Profiles are /// per-setup; this file is per-installation. /// /// What lives here: /// * — where the profile JSONs are read from. /// /// (Pre-2026-05-11 also held BothModeWarningSuppressed — the "do not show me again" /// tick on the WASAPI+ASIO latency popup. The popup was retired along with the audio-mode /// listbox; old config JSONs that still contain the key just have it ignored.) /// /// Persisted location: <exe>\remsound.config.json. If the file is missing or /// malformed, defaults are used and the app behaves exactly as it did pre-2026-05-05 /// (per-machine subfolder under the exe). The file is only written when the user /// explicitly changes a setting. /// public sealed class AppConfig { /// Filesystem path to the directory the app should read profiles from. When /// null, RemSound uses the legacy default: <exe>\profiles\<machine>\. /// When set to an explicit folder, that folder IS the profiles folder — no per-machine /// subfolder is appended (the user picked it, they meant it; that also lets a user point /// at a Dropbox folder shared between machines). public string? ProfilesDirectory { get; set; } /// True if the user has ticked "do not show me this message again" on the /// confirmation popup that fires when Save (Ctrl+S / File → Save) successfully /// overwrites the currently-loaded profile. Lives here (not in Profile) so the /// preference sticks across profile switches — once you've decided you don't need /// the "Profile saved" nag, you don't expect it to come back when you load a /// different profile. The Save-As path doesn't use this flag: the Save-As dialog /// itself is the user-visible confirmation, so a follow-up popup is redundant. public bool SaveProfileConfirmationSuppressed { get; set; } /// True if the user has ticked "do not show me this message again" on the /// "you are saving onto a read-only profile" warning. Once ticked, Ctrl+S / File → Save /// on a read-only profile saves silently through the lock instead of warning first. /// Machine-local (not per-profile) so the preference sticks across profile switches; the /// prompt itself is the same wording on any read-only profile so a single dismissal /// applies everywhere. 2026-05-23 — semantic shift from v2.x: in v2.x the read-only lock /// hard-blocked explicit saves and this flag suppressed the explanatory "save was skipped" /// popup. In v3.0 the lock only suppresses the automatic "you have unsaved changes" prompt /// on close / profile switch; explicit Ctrl+S / File → Save now goes through with a /// one-time warning gated by this flag. The JSON key was renamed alongside the semantic /// change so users upgrading from v2.x see the new warning at least once — a v2.x /// suppression flag is no longer applicable and is silently discarded on load. public bool SaveOnReadOnlyWarningSuppressed { get; set; } /// If true, RemSound minimises to the system tray immediately after the main /// window finishes loading. Lets the user "boot up the machine and have RemSound /// already running quietly". Default false. public bool StartMinimised { get; set; } /// If true (the default), the main window shows a "Volume, pan and EQ for peers" tab /// (positioned before "Audio profile") for setting per-peer volume, panning and EQ. Toggled by /// "Show the volume, pan and EQ for peers tab" on the Preferences General tab. Machine-wide (a /// UI-visibility preference, like which tabs exist) — the shaping VALUES are saved per profile, in /// . public bool ShowPanEqTab { get; set; } = true; /// Custom friendly names the user has given to peers, keyed by the peer's stable identity — /// its machine name when known, otherwise its address. Machine-wide (a name applies in every profile) /// and shown wherever that peer appears. Empty by default. See the Connectivity tab's Rename peer. public Dictionary PeerFriendlyNames { get; set; } = new(); /// If true (the default), RemSound plays the startup cue once, right after this /// copy wins the single-instance takeover and before the profile loads. Machine-wide (not /// per-) because it fires before any profile — and its per-profile /// custom-cue dictionary — has been chosen. Like the connect/disconnect cues it's audible /// feedback, on by default; the user unticks "Startup sound" in Preferences to silence it. /// (The usual "auto-options default off" rule is about data-persistence toggles, not cues.) public bool EnableStartupCue { get; set; } = true; /// Optional custom WAV path for the startup cue. Null = use the bundled /// sounds\start up.wav. Machine-wide for the same reason as /// : the cue plays before any profile (and the per-profile /// custom-cue paths) is loaded, so it can't live on . public string? StartupCueCustomPath { get; set; } /// The chosen default-sound FILENAME for each cue (e.g. "connect 2.wav"), keyed by /// the cue id (MainForm.CueId). The cue WAVs ship as numbered variants ("connect 1.wav", /// "connect 2.wav", ...); this records which one the user picked in Preferences. Machine-wide, /// so a user's preferred sound palette follows them across every profile. A cue absent from the /// dictionary uses the first available variant (the "1"s) by default. A per-profile custom WAV /// () still overrides this choice. public Dictionary DefaultCueSounds { get; set; } = new(); /// If true (the default), typing into any edit field anywhere in RemSound plays a soft /// keyboard-click sound (one of several, picked at random), so a screen-reader user gets audible /// typing feedback. Password fields additionally play a distinct key sound at the same time. /// Machine-wide; the user unticks "Play keyboard clicks" in Preferences to silence it. public bool EnableKeyboardClicks { get; set; } = true; /// Per-cue enable flags for the machine-wide cues added 2026-06-13: the send/receive /// on/off toggle cues and the minimise(hide)/restore(show) cues. Machine-wide (like the startup /// cue) rather than per-profile - they're app-level feedback for an action, not a per-profile /// audio setting. All default on; the user unticks them in Preferences like any other cue. public bool EnableSendOnCue { get; set; } = true; public bool EnableSendOffCue { get; set; } = true; public bool EnableReceiveOnCue { get; set; } = true; public bool EnableReceiveOffCue { get; set; } = true; public bool EnableHideCue { get; set; } = true; public bool EnableShowCue { get; set; } = true; /// Tick / untick sounds played on every checkbox toggle anywhere in the app. public bool EnableCheckboxOnCue { get; set; } = true; public bool EnableCheckboxOffCue { get; set; } = true; /// Sound played whenever the user switches between tabs anywhere in the app (the main /// window's tab strip and every tabbed dialog). Machine-wide, default on. public bool EnableTabSwitchCue { get; set; } = true; /// Custom WAV overrides for the machine-wide cues above, keyed by cue id. The /// equivalent of but machine-wide, since these cues don't /// live on a profile. Empty = use the chosen default variant. The startup cue keeps its own /// field for backward compatibility. public Dictionary MachineCueCustomPaths { get; set; } = new(); /// If true, RemSound writes a tab-separated diagnostic log to /// <exe>\logs\. Lives here (not in ) because logging /// is a debugging affordance for the installation, not a user-facing audio preference — /// switching profiles shouldn't accidentally re-enable a flood of writes the user had /// turned off, and a one-machine "yes log everything" decision shouldn't have to ride /// along on every saved profile. Default false: no log file is created until the user /// ticks Enable logs in the Preferences dialog. public bool LoggingEnabled { get; set; } /// If true, RemSound checks the total size of the logs\ folder at startup and, /// when it exceeds megabytes, shows a one-time warning so /// the user can prune or clear it. Off by default (opt-in, like the other behaviour toggles). /// Machine-local — a "watch my disk on this box" decision, not a per-profile audio setting. public bool WarnIfLogsFolderExceeds { get; set; } /// The size threshold in megabytes for . Default /// 100 MB. Only consulted when that flag is on. public int LogsFolderWarnThresholdMb { get; set; } = 100; /// If true, RemSound deletes log files older than days /// from the logs\ folder at startup. Off by default (opt-in). The currently-open log file /// is never a candidate (it's today's). Machine-local. public bool PruneOldLogs { get; set; } /// Age in days for : log files last written more than this many /// days ago are deleted at startup. Range 1–30, default 14. Only consulted when that flag is on. public int PruneOldLogsDays { get; set; } = 14; /// The keyboard shortcuts, machine-wide as of v4.4. Before v4.4 these lived on each /// , so a shortcut set on one profile didn't apply on another (issue #14). /// They now live here — one set shared by every profile, loaded/saved via /// 's Load*/Save* hotkey methods. Null = use the built-in default /// for that action. The old per-profile fields on /// are kept only so old profile JSONs still deserialise; they are no longer read or written. public HotkeyRecord? ReceiveMuteHotkey { get; set; } public HotkeyRecord? SendMuteHotkey { get; set; } public HotkeyRecord? TrayHotkey { get; set; } public HotkeyRecord? VolumeUpHotkey { get; set; } public HotkeyRecord? VolumeDownHotkey { get; set; } public HotkeyRecord? ToggleRecordingHotkey { get; set; } public HotkeyRecord? RemoteVolumeUpHotkey { get; set; } public HotkeyRecord? RemoteVolumeDownHotkey { get; set; } public HotkeyRecord? RemoteMuteToggleHotkey { get; set; } public HotkeyRecord? SystemVolumeUpHotkey { get; set; } public HotkeyRecord? SystemVolumeDownHotkey { get; set; } public HotkeyRecord? SystemMuteToggleHotkey { get; set; } public HotkeyRecord? QuickProfileSwitchHotkey { get; set; } public HotkeyRecord? SpeakStatusLineHotkey { get; set; } /// Global shortcut that toggles the "Enable volume, pan and EQ for all peers" master /// switch. Machine-wide and unset by default; NOT stored in any profile (unlike the shaping /// values). The user binds it in Keyboard shortcuts. public HotkeyRecord? ToggleAllPeerShapingHotkey { get; set; } /// True once the one-time "your keyboard shortcuts are now shared across profiles" notice /// has been shown (to an upgrader), or silently marked done on a fresh install that had nothing to /// reset. Stops the notice re-appearing. v4.4. (Superseded by the import offer below; kept so the /// flag on an existing v4.4 config still deserialises.) public bool KeyboardShortcutsGlobalNoticeShown { get; set; } /// True once the one-time "bring your keyboard shortcuts across?" import offer has been /// resolved (the user chose to import from a profile, or to start fresh). Deliberately SEPARATE from /// so that users who already updated to v4.4 (and /// were reset) are STILL offered the chance to import their old per-profile shortcuts — which remain /// readable in their profile files. public bool KeyboardShortcutsImportOffered { get; set; } /// If true, the "Use Windows default output" follower at the top of the received-sound /// output list is ticked at startup, so received sound plays to whatever Windows currently calls /// the default output and re-routes when that changes (plug in headphones, it follows). Unlike a /// specific device tick — which can go stale and play to the wrong card — a follower can never be /// wrong, so it's safe to persist. Default false (opt-in). is /// the same idea for the send-input list. public bool UseDefaultOutputDevice { get; set; } public bool UseDefaultInputDevice { get; set; } /// Remembered answer to the "untick the other outputs/inputs when you turn on Use Windows /// default?" prompt. Null = ask each time; true = always untick; false = never untick. Set when the /// user ticks "Don't ask again" on that prompt. Machine-wide. public bool? UntickOthersWhenUsingDefaultOutput { get; set; } public bool? UntickOthersWhenUsingDefaultInput { get; set; } /// If non-null and a profile with this title exists, RemSound skips the /// startup profile picker and loads this profile directly. Combine with /// + the Windows auto-start registry entry /// (see StartupAutoStart) to get a fully unattended boot-into-streaming flow. /// To re-show the picker temporarily, untick "Start with a specific profile" in the /// Startup behaviour dialog. Null = always show the picker (legacy behaviour). public string? StartWithProfileTitle { get; set; } /// How often RemSound polls the GitHub Releases API for a newer build. Default /// . Set to /// to disable background checks entirely (the user can still trigger a manual check via /// the Preferences button or the Help menu). public UpdateCheckFrequency UpdateCheckFrequency { get; set; } = UpdateCheckFrequency.Every24Hours; /// If true, RemSound downloads and applies a new release without prompting: /// the running instance writes the new files to a staging folder, spawns a small /// detached helper that waits for the exe to exit, swaps in the new files, and restarts /// RemSound. Default false — the user gets a confirmation dialog before each install. public bool SilentlyInstallUpdates { get; set; } /// If true (the default), RemSound runs an update check shortly after launch in /// addition to whatever drives in the background. The /// startup check is what catches users who quit and re-open the app within the polling /// interval — without it they could miss an update for hours. Set to false to disable the /// startup check; the periodic timer (if set) still runs. public bool CheckForUpdatesOnStartup { get; set; } = true; /// If true, RemSound opens the About box (which leads with the latest release /// notes) once on the first launch AFTER an update has been installed, so the user sees /// "what's new" without going looking. Default false — opt-in. Detected by comparing the /// running version against at launch, so it only fires /// when the version actually changed, never on an ordinary relaunch. On by default — it's a /// discoverability aid (see what changed), not a data-persistence toggle, so the usual /// "auto-options default off" rule doesn't really apply; users can untick it. public bool ShowWhatsNewAfterUpdate { get; set; } = true; /// The app version recorded at the last launch. Used only to detect "the version /// changed since last run" for . Null until first /// recorded, so a fresh install never counts as an update. public string? LastWhatsNewVersion { get; set; } /// If true, RemSound tries to open the audio port (UDP 47830) on the local router /// using UPnP / NAT-PMP / PCP, so peers on the public internet can reach this machine /// without manual port forwarding. Default false — the toggle opt-in only, because some /// networks (corporate, hostile shared) shouldn't have apps poking the router. When /// successful, RemSound surfaces the external address in the Preferences dialog so the /// user knows what to give peers. Falls back gracefully when the router doesn't support /// UPnP — RemSound just doesn't open anything. public bool UpnpEnabled { get; set; } /// UTC timestamp of the last successful update check. Used by the background /// update timer to space out polls across launches — if you set the frequency to /// "every 24 hours" and re-launch the app three times that day, it still hits the API /// only once. Null on a fresh install. public DateTime? LastUpdateCheckUtc { get; set; } /// Most-recently-opened profile paths, newest first, capped at /// . Populated by every /// time a profile is loaded, surfaced in the File → Recent profiles submenu. Stored as /// full paths so profiles saved outside the canonical profiles folder are also /// reachable (Save-As to an arbitrary path stays in the recents list). public List RecentProfiles { get; set; } = new(); /// Cap on how many entries we keep in . Five is the /// most that fits comfortably as 1–5 single-digit mnemonics inside a submenu without /// the user needing to read the names to remember which row they want. public const int MaxRecentProfiles = 5; /// Push a profile path to the front of the recents list. Removes any existing /// entry that matches (case-insensitive) so a recently re-opened profile rises to the /// top instead of being duplicated. Caps the list at . /// Caller must after mutating. public void NoteRecentProfile(string? path) { if (string.IsNullOrWhiteSpace(path)) return; RecentProfiles.RemoveAll(p => string.Equals(p, path, StringComparison.OrdinalIgnoreCase)); RecentProfiles.Insert(0, path); while (RecentProfiles.Count > MaxRecentProfiles) { RecentProfiles.RemoveAt(RecentProfiles.Count - 1); } } /// Friendly names of ASIO drivers RemSound must never touch — it won't probe them, /// won't list them in the driver picker, and won't open them for streaming. Global (not /// per-profile) because "this driver is broken on this machine" is about the hardware/driver /// install, not any one profile. Populated when the user answers "yes" to the Realtek-ASIO /// compatibility warning, or toggles the Options-menu entry. Matched case-insensitively. public List DisabledAsioDrivers { get; set; } = new(); /// Friendly names of ASIO drivers RemSound has already shown its compatibility warning /// for, so a user who answered "no, keep using it" isn't nagged on every launch. Independent of /// : a driver can be warned-about-but-still-enabled. public List AsioDriversWarnedAbout { get; set; } = new(); /// True if RemSound should refuse to interact with the named ASIO driver in any way. public bool IsAsioDriverDisabled(string? driverName) => !string.IsNullOrWhiteSpace(driverName) && DisabledAsioDrivers.Exists(d => string.Equals(d, driverName, StringComparison.OrdinalIgnoreCase)); /// Disable or re-enable the named ASIO driver. Caller must after. public void SetAsioDriverDisabled(string driverName, bool disabled) { if (string.IsNullOrWhiteSpace(driverName)) return; DisabledAsioDrivers.RemoveAll(d => string.Equals(d, driverName, StringComparison.OrdinalIgnoreCase)); if (disabled) DisabledAsioDrivers.Add(driverName); } /// True once the compatibility warning has been shown for this driver. Case-insensitive. public bool HasWarnedAboutAsioDriver(string driverName) => AsioDriversWarnedAbout.Exists(d => string.Equals(d, driverName, StringComparison.OrdinalIgnoreCase)); /// Record that the compatibility warning has been shown for this driver (so we don't /// re-nag a user who chose to keep it). Caller must after. public void MarkAsioDriverWarned(string driverName) { if (string.IsNullOrWhiteSpace(driverName)) return; if (!HasWarnedAboutAsioDriver(driverName)) AsioDriversWarnedAbout.Add(driverName); } /// True if the named ASIO driver looks like a Realtek HD Audio ASIO driver (its name /// or description contains "Realtek"). Realtek's bundled ASIO driver (rthdasio64.dll) leaks OS /// handles on every open and is broadly known to misbehave with ASIO hosts; ASUS and other OEMs /// ship the same Realtek driver under their own branding, so we match "Realtek" anywhere in the /// name. RemSound uses this to proactively offer to disable the driver. public static bool IsRealtekAsioDriver(string? driverName) => !string.IsNullOrWhiteSpace(driverName) && driverName.Contains("Realtek", StringComparison.OrdinalIgnoreCase); /// The single per-user folder next to the exe — <exe>\user settings and logs\ — /// that holds EVERYTHING this machine's user owns: the global config file, the profiles\ /// subfolder, logs\, and sounds\. 2026-06-10: consolidated here from the loose files /// / the earlier config\ folder so the install root stays tidy and the auto-updater can /// exclude one folder to leave ALL user state (including custom cue WAVs) untouched. public const string UserDataFolderName = "user settings and logs"; /// Process-wide override for . Null = the default /// folder next to the exe. Set once at startup from the --config-dir switch so the test /// suite (and a portable layout) can point ALL user state - config, profiles, logs, cue sounds - /// at an explicit throwaway folder without touching the user's real settings. Must be set before /// anything reads config/profiles/logs/sounds. private static string? _userDataDirectoryOverride; /// Redirect every user-state folder to for this process only. /// Call before / any config read. Idempotent. public static void SetUserDataDirectoryOverride(string path) { if (!string.IsNullOrWhiteSpace(path)) _userDataDirectoryOverride = Path.GetFullPath(path); } public static string UserDataDirectory => _userDataDirectoryOverride ?? Path.Combine(AppContext.BaseDirectory, UserDataFolderName); /// Where the per-machine log files are written. public static string LogsDirectory => Path.Combine(UserDataDirectory, "logs"); /// Where the shipped DEFAULT cue WAVs live: a default sounds\ folder next to the /// exe. This is part of the INSTALL, not user state — the auto-updater (and a dev republish) /// always overwrites it, so a changed default sound reaches every user, including existing ones. /// Deliberately NOT under and NOT redirected by --config-dir: /// these are shipped defaults, not per-user data. The user's OWN custom sounds are never stored /// here — they're explicit file paths (the Preferences "Browse" picker) that live in the user's /// own location, which the updater never touches. 2026-06-13: moved here out of the per-user /// sounds\ folder, whose never-overwrite seeding meant a tweaked default could never land /// for anyone who already had the old one. public static string SoundsDirectory => Path.Combine(AppContext.BaseDirectory, "default sounds"); /// The OLD per-user sounds folder (...\user settings and logs\sounds\), now /// defunct after sounds moved to the install-side . Kept only so the /// startup migration can delete the orphan. Do NOT read cues from here. public static string LegacyUserSoundsDirectory => Path.Combine(UserDataDirectory, "sounds"); /// The base profiles folder (ProfileStore appends the per-machine subfolder). public static string ProfilesBaseDirectory => Path.Combine(UserDataDirectory, "profiles"); private static string ConfigPath => Path.Combine(UserDataDirectory, "global config.json"); /// What relocated this launch. True only on the /// one launch where an older layout was found and moved — the caller uses it to show a one-time /// "everything moved" notice. public readonly record struct LayoutMigrationResult(bool MovedAnything); /// /// One-time, idempotent consolidation of EVERY older layout into /// <exe>\user settings and logs\. Handles all the field permutations, each move guarded /// by "source exists AND destination doesn't" so it's safe to run every launch and never clobbers /// already-migrated data: /// * global config: <exe>\remsound.config.json (oldest) OR /// <exe>\config\global config.json (the 2026-06-07 interim layout) /// * profiles: <exe>\config\profiles\ (interim) OR <exe>\profiles\ (oldest) /// * logs: <exe>\logs\ /// → all under ...\user settings and logs\. (Sounds are NOT part of this folder any more — /// the shipped defaults live install-side in ; Program deletes the /// two orphaned old sounds folders on startup.) Runs BEFORE anything reads config/profiles/ /// logs. A custom is untouched. Directory moves fall back to /// copy-then-delete across a volume boundary. /// public static LayoutMigrationResult MigrateLegacyLayoutIfNeeded() { var moved = false; try { Directory.CreateDirectory(UserDataDirectory); var root = AppContext.BaseDirectory; var interimConfigDir = Path.Combine(root, "config"); // Global config — interim location wins over the oldest loose file. if (!File.Exists(ConfigPath)) { var interimGlobal = Path.Combine(interimConfigDir, "global config.json"); var oldestGlobal = Path.Combine(root, "remsound.config.json"); if (File.Exists(interimGlobal)) { File.Move(interimGlobal, ConfigPath); moved = true; } else if (File.Exists(oldestGlobal)) { File.Move(oldestGlobal, ConfigPath); moved = true; } } // Profiles — interim location wins over the oldest. if (!Directory.Exists(ProfilesBaseDirectory)) { var interimProfiles = Path.Combine(interimConfigDir, "profiles"); var oldestProfiles = Path.Combine(root, "profiles"); if (Directory.Exists(interimProfiles)) { MoveDirectoryResilient(interimProfiles, ProfilesBaseDirectory); moved = true; } else if (Directory.Exists(oldestProfiles)) { MoveDirectoryResilient(oldestProfiles, ProfilesBaseDirectory); moved = true; } } // Logs (only ever lived loose in the root). var oldLogs = Path.Combine(root, "logs"); if (Directory.Exists(oldLogs) && !Directory.Exists(LogsDirectory)) { MoveDirectoryResilient(oldLogs, LogsDirectory); moved = true; } // Remove the now-empty 2026-06-07 interim config\ folder. try { if (Directory.Exists(interimConfigDir) && Directory.GetFileSystemEntries(interimConfigDir).Length == 0) Directory.Delete(interimConfigDir); } catch { /* leave it if it isn't empty / can't be removed */ } } catch { // Best-effort: a failed move (permissions, file in use) just means the app falls // back to defaults / an empty profiles list rather than crashing on launch. } return new LayoutMigrationResult(moved); } /// Move a directory, falling back to recursive copy-then-delete when a plain /// can't cross a volume boundary (e.g. the user-data folder is a /// junction onto another drive). Copy uses overwrite:false so an already-present destination /// file is never clobbered. private static void MoveDirectoryResilient(string source, string dest) { try { Directory.Move(source, dest); } catch (IOException) { CopyDirectoryRecursive(source, dest); try { Directory.Delete(source, recursive: true); } catch { /* copy succeeded; leaving the source is harmless */ } } } private static void CopyDirectoryRecursive(string source, string dest) { Directory.CreateDirectory(dest); foreach (var file in Directory.GetFiles(source)) File.Copy(file, Path.Combine(dest, Path.GetFileName(file)), overwrite: false); foreach (var dir in Directory.GetDirectories(source)) CopyDirectoryRecursive(dir, Path.Combine(dest, Path.GetFileName(dir))); } /// Reads the app config from disk. Always returns a non-null instance — a missing /// or malformed file becomes a defaults-only AppConfig rather than throwing. public static AppConfig Load() { try { if (!File.Exists(ConfigPath)) return new AppConfig(); var json = File.ReadAllText(ConfigPath); return JsonSerializer.Deserialize(json) ?? new AppConfig(); } catch { // Corrupt config file shouldn't keep RemSound from launching. Fall back to // defaults; the user can re-pick a folder via the dialog and we'll overwrite // the bad file on the next save. return new AppConfig(); } } /// Writes this config to disk. Throws on filesystem failures (caller should /// surface a MessageBox — failure to persist a directory choice is user-visible). public void Save() { Directory.CreateDirectory(UserDataDirectory); var json = JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }); // Atomic replace — write a temp then move it over, so a torn write (crash / power-loss / // the updater force-closing us mid-save) can't truncate the file and silently revert config // to defaults. var tmp = ConfigPath + ".tmp"; try { File.WriteAllText(tmp, json); File.Move(tmp, ConfigPath, overwrite: true); } catch { try { if (File.Exists(tmp)) File.Delete(tmp); } catch { /* ignore */ } throw; } } /// Convenience: build the appropriate for the /// current config. Falls back to the default store (per-machine subfolder) if the /// configured folder is missing, blank, or doesn't exist on disk. public ProfileStore CreateStore() { if (!string.IsNullOrWhiteSpace(ProfilesDirectory) && Directory.Exists(ProfilesDirectory)) { return new ProfileStore(ProfilesDirectory); } return new ProfileStore(); } }