diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c64d6c8..3c36908b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ section to the released version and opens a fresh empty one (see `docs/RELEASING - **Polling session state moves off module statics into `AppState::session` (#758, PARTIAL — polling half).** `SessionState` owns the write-decision clocks (with the D11 generation guard), the quiet/snooze latches, the last-now-playing cache, the preferred-presence session, the exit snapshot, and the #863 failure/gate mirrors; `poll_once`/`loop`/`state`/`sync`/`diagnostics` thread `&session` instead of touching statics. `global_state_lock` is deleted and each test constructs its own `SessionState::new()` (new `test_two_sessions_do_not_share_clocks_latches_or_caches` proves isolation). Tray/config `AppCaches` (slice 2) stay static for now, so `QUARANTINE_TEST_LOCK` and `LOCALE_TEST_LOCK` survive and the issue stays open. No behaviour change in the single-session path. ### Refactor - **Monolithic `lib.rs` split into `app`/`cli`/`deep_link`/`state` modules (#757).** The ~4.9k-line `lib.rs` is now a 34-line module registry + state re-export shim; the Tauri `run()`/setup wiring lives in `app.rs` (with `setup_*` helpers), CLI parsing/dispatch in `cli.rs`, the Spotify-callback exchange in `deep_link.rs`, and `AppState` + token-commit seams in `state.rs`. Six source-scanner guards retargeted to the new homes (`generate_handler!` → `app.rs`, `handle_spotify_callback` → `deep_link.rs`, `forward_launch_to_running_instance` → `app.rs`, `polling::run_oneshot` call site → `cli.rs`, log-permission setup → `setup_log_permissions` helper, `macos_deeplink` module decl → `lib.rs`), and the redaction-literal sweep now covers the four new modules. No behaviour change. +- **Config split into 7-slice mod with re-exported surface (#755).** `src-tauri/src/config.rs` (8313 ln) becomes `src-tauri/src/config/{schema,clamp,snooze,patch,migrate,io,transfer}.rs` plus a 52-line re-export header in `mod.rs` that keeps every `crate::config::X` path stable. All 121 config tests remain centralized in `config/mod.rs` (identical set, 565 asserts) — the slices carry no `#[test]`; source-scan guards read the slices through one `concat!(include_str!(…))`, `redact.rs` aggregates the 8 slice sources, `LoggingConfig` lives only in `schema.rs`. `cargo check --all-targets` plus `cargo test --lib` (config 121/121, full 910/910) plus `clippy -D warnings` plus `fmt --check` are all clean. ### Fixed - **Polish CLDR few/many plurals render the real forms (#1154).** `tCount` already resolved any `Intl.PluralRules` category with an `_other` fallback — the gap was data, not logic — so this adds `{key}_few` entries for both plural keys (`logs.count`, `dashboard.snoozeStatusStart`) in all eight dictionaries: real Polish nominative plurals ("2 wpisy", "2 minuty") plus `_other`-mirroring `_few` entries in the seven locales whose CLDR never selects `few`. No distinct `_many`: for the covered nouns CLDR `many` ("5 wpisów", "5 minut") IS the genitive plural `_other` carries, and the fr/es/it/pt `many`-at-10⁶+ magnitudes are unreachable for small UI counts. `tests/i18n.test.ts` asserts the real few/many forms (failing pre-fix with "2 wpisów"), and the key-coverage + placeholder-union scans now cover the `_one`/`_other`/`_few` trio. Provenance: the two Polish `_few` forms are model-written per #984 — human review pending. - **A deep link arriving before `AppState` is managed is replayed after setup instead of dropped (#1122).** `handle_deep_link`'s unmanaged-state guard now buffers the callback URL in a process-wide single slot (a second early callback overwrites; the slot drains once) and the setup closure re-dispatches it through `handle_deep_link_from_app` immediately after `app.manage(state.clone())`. The early arm returns before the #799 `deep_link_seen` claim, so the replay is the first delivery the dedup gate sees and true duplicates still drop. Both new log lines carry presence only — never the URL, code, verifier, or state contents. diff --git a/docs/STATE-OF-FEATURES.md b/docs/STATE-OF-FEATURES.md index aa9519ac..5b36e4f7 100644 --- a/docs/STATE-OF-FEATURES.md +++ b/docs/STATE-OF-FEATURES.md @@ -37,6 +37,7 @@ end-to-end; the few rows that can't be sourced inline are explicitly flagged | Tray tooltip + native CheckMenuItems + dock badge (C4) | ✅ | `tray/mod.rs` + `tray/dedup.rs` — live tooltip `Artist — Track (▶|⏸)` on each rebuild; Play/Pause, Shuffle and Repeat as native CheckMenuItems driven by `LAST_PLAYING_STATE` / `LAST_SHUFFLE_STATE` / `LAST_REPEAT_STATE`; macOS-only (`#[cfg]`) presence-gated dock badge wired into the polling loop (`b82f515`). The menu also carries a **Pause / Resume Sync** item (IDs `ID_PAUSE_SYNC` / `ID_RESUME_SYNC`) and, since 4.7.0 (#677), a **Pause sync for…** snooze submenu plus **Resume sync now** while a snooze is active. The tray's own status line is `sync_status_line` ("Syncing — Artist — Track", "Paused — …", "Not syncing", "Syncing — nothing playing"), and the tooltip is `{status_line} · {track_tooltip}`; the words come from the `i18n::Strings` table and a snooze replaces the line with `snooze_status_line` (remaining time + local deadline). | | Settings dirty-state + clamp feedback + reset (C9) | ✅ | `Settings.svelte` — unsaved-changes banner via BigInt-safe deep compare; inline clamp feedback mirroring Rust `clamp_polling`; per-section Reset-to-default buttons using `defaultConfig`. | | Settings per-card split, slice 1 (#750-PARTIAL) | ⚠ Partial | `src/lib/components/settings/` holds `SettingsCard` shell + `RulesCard`/`LoggingCard`/`BackupCard`/`ShortcutsCard` (each: `$bindable()` slice + `onreset`/`onchange`); `Settings.svelte` keeps draft state, save/discard, `pendingNav`, footer. Remaining: Spotify, Teams, Presence, StatusFormat, Polling, Notifications, Appearance, Profiles, Updates cards. | +| Config module split, one concern per file (#755) | ✅ | `src-tauri/src/config/mod.rs` re-exports `schema`/`clamp`/`snooze`/`patch`/`migrate`/`io`/`transfer` (one concern per file, every `crate::config::X` path stable); all 121 config tests remain centralized in `mod.rs` (identical set) — the slices carry no `#[test]`; `redact.rs` aggregates the 8 slice sources through one `concat!`; `LoggingConfig` lives only in `schema.rs`; `cargo check --all-targets` plus `cargo test --lib` (config 121/121, full 910/910) plus `clippy -D warnings` plus `fmt --check` all clean. | | Notification throttle + grouping (C8) | ✅ | `Dashboard.svelte` — max 1 track-change notification per 5s (throttled tracks don't claim `lastNotifiedId`); replace-in-place via stable id + group tag where the platform supports it. 4.6 moved the opt-in into `src/lib/stores/notifications.ts` (#549); 4.7.0 (#675) replaces it with four config-backed classes (see the notification-classes row below), migrating the legacy `localStorage.notificationsEnabled` value into `track_change` exactly once — and only after the write lands, so a rejected save cannot lose an opt-out. Track changes keep the 5 s throttle (`TRACK_NOTIFICATION_THROTTLE_MS`) and the replace-in-place id. | | WCAG 2.2 AA accessibility pass (C12) | ✅ | Skip link, focus-ring alpha fixes, `prefers-reduced-motion` guards, and darkened status/accent tokens in both themes. The build-version label uses `var(--fg-muted)` at full opacity; `tests/version-contrast.test.ts` mounts the page with production styles, composites effective opacity against the painted background, and requires at least 4.5:1 in both themes. | | Form-control boundary contrast (#740) | ✅ | A dedicated `--border-input` token paints the shared `input` / `textarea` / `select` rule in `src/app.css`: **3.95:1** against `--bg-surface` and **3.29:1** against the `--bg-elevated` field fill in the dark theme, **3.48:1** / **3.08:1** in the light theme, all at or above the WCAG 1.4.11 3:1 non-text minimum. The decorative `--border` divider colour is unchanged. `tests/form-control-contrast.test.ts` resolves the token each theme actually paints and asserts the computed WCAG ratio, not a hex literal, so a palette revision is judged by the ratio it keeps. | diff --git a/src-tauri/src/config/clamp.rs b/src-tauri/src/config/clamp.rs new file mode 100644 index 00000000..154c1a28 --- /dev/null +++ b/src-tauri/src/config/clamp.rs @@ -0,0 +1,558 @@ +use super::schema::{ + clamp_quiet_hours_window, AppConfig, LoggingConfig, PollingConfig, PreferredPresenceConfig, + PresenceProfile, ShortcutsConfig, StatusRulesConfig, TeamsConfig, TrackRuleAction, + TrackRuleEntry, +}; +use serde::Deserialize; +pub(crate) fn clamp_polling(cfg: &mut PollingConfig) { + cfg.default_interval_seconds = cfg.default_interval_seconds.clamp(5, 300); + cfg.minimum_interval_seconds = cfg.minimum_interval_seconds.clamp(5, 30); + cfg.max_interval_seconds = cfg + .max_interval_seconds + .clamp(cfg.minimum_interval_seconds, 300); + // CfgDiag#5 (#540): `default` is pinned to the pair AFTER both ends are + // clamped, so `minimum <= default <= maximum` always holds. Without this + // a persisted `{default: 300, minimum: 10, maximum: 30}` was accepted and + // drove the no-track sleep (poll_once's `pause_backoff`) five minutes + // past the ceiling the UI was showing as one minute. + cfg.default_interval_seconds = cfg + .default_interval_seconds + .clamp(cfg.minimum_interval_seconds, cfg.max_interval_seconds); + cfg.expiry_buffer_seconds = cfg.expiry_buffer_seconds.clamp(0, 60); + // CfgDiag#3(c) (#538): the pause-backoff ceiling is user-configurable, + // so clamp it into a sane band whatever the file (or the UI) said. + cfg.pause_backoff_max_seconds = cfg.pause_backoff_max_seconds.clamp(60, 3600); +} + +/// Bound the user-supplied teams text fields: the profanity lexicon +/// (CfgDiag#3(b), issue #538) to at most 64 entries of 32 characters, and the +/// two manual-status texts (S4, issue #672) to +/// [`MAX_RULE_STATUS_CHARS`] like a rule's replacement text. +/// `clamped_config` is the only normalizer, so this runs on load and on every +/// save. +pub(crate) fn clamp_teams(cfg: &mut TeamsConfig) { + cfg.profanity_extra_words.truncate(64); + for word in &mut cfg.profanity_extra_words { + if word.chars().count() > 32 { + *word = word.chars().take(32).collect(); + } + } + // S4 (issue #672): the two manual-status texts are status lines too, so they + // are bounded exactly like a rule's replacement text. An empty text is left + // alone — `poll_once` reads it as "use the default". + clamp_rule_text(&mut cfg.paused_status_format); + clamp_rule_text(&mut cfg.stopped_status_format); + // Issue #866: the preferred-presence pair rides the same + // normalizer — `normalize_presence_pair` already clears both + // fields when they fail to match `PRESENCE_COMBINATIONS`, so + // disabling an unsupported config is automatic. + clamp_preferred_presence(&mut cfg.preferred_presence); + // Issue #873: the idle-away threshold. `0` disables (no clamp), any + // other value is clamped into 60..=3600 so a hand-edited config + // cannot put the gate in a state that surprises the user (a 1 s + // threshold would have every normal typing pause fire the gate). + if cfg.idle_away_after_seconds != 0 { + cfg.idle_away_after_seconds = cfg.idle_away_after_seconds.clamp(60, 3600); + } + + // Issue #867: cap the pre-meeting suppression window at 60 minutes — + // anything larger is almost certainly a hand-edited mistake, and a + // longer window only widens the blast radius of a flaky calendar. + cfg.pre_meeting_suppress_minutes = cfg.pre_meeting_suppress_minutes.min(60); +} + +/// Issue #866: bound the preferred-presence config the same way `clamp_rules` +/// bounds rule pairs. The expiry is clamped to `5..=720` minutes (Graph's +/// `expirationDuration` accepts anything but the app's clear-at-expiry logic +/// needs a sane cadence); an unsupported pair clears BOTH fields and disables +/// the feature — a Graph POST with a pair outside `PRESENCE_COMBINATIONS` +/// would 4xx every call, and the user would never see a presence move. +pub(crate) fn clamp_preferred_presence(cfg: &mut PreferredPresenceConfig) { + cfg.expiry_minutes = cfg.expiry_minutes.clamp(5, 720); + match normalize_presence_pair(&cfg.availability, &cfg.activity) { + Some(pair) => { + cfg.availability = pair.availability; + cfg.activity = pair.activity; + } + None => { + cfg.availability.clear(); + cfg.activity.clear(); + cfg.enabled = false; + } + } +} + +/// Issue #866: resolve the preferred-presence config into the validated +/// `PresencePair` the Graph POST needs — `None` when the feature is off, the +/// pair is empty, or the user's `respect_manual_status` setting wins the +/// decision. Pure so the gating tests do not need a Tauri runtime. +pub fn preferred_presence_pair( + teams: &TeamsConfig, + respect_manual_status: bool, +) -> Option { + if !teams.preferred_presence.enabled || respect_manual_status { + return None; + } + normalize_presence_pair( + &teams.preferred_presence.availability, + &teams.preferred_presence.activity, + ) +} + +/// Issue #866: the `expirationDuration` the Graph +/// `setUserPreferredPresence` POST carries. Pure so the same shape that +/// goes to `setPresence` can be tested in isolation. +pub fn preferred_presence_expiry_duration(teams: &TeamsConfig) -> String { + let minutes = teams.preferred_presence.expiry_minutes.max(5); + format!("PT{}M", minutes) +} + +/// Issue #870: borrow the user's lexicon (`teams.profanity_extra_words`, +/// issue #538) into the slice shape `profanity::filter_status` expects. The +/// function is `None`-aware — a hand-edited config that lacks the section +/// reads as the empty slice, reproducing the pre-#538 behaviour exactly. +pub fn profanity_extra_words_for_filter(config: Option<&std::sync::Arc>) -> &[String] { + config + .map(|c| c.teams.profanity_extra_words.as_slice()) + .unwrap_or(&[]) +} + +/// The closed set of `availability`/`activity` pairs the Graph +/// `presence: setPresence` action accepts (finding #634, issue #634). +/// +/// Quoted from https://learn.microsoft.com/graph/api/presence-setpresence: +/// "Supported combinations of availability and activity are: +/// Available/Available, Busy/InACall, Busy/InAConferenceCall, Away/Away, +/// DoNotDisturb/Presenting". `DoNotDisturb/DoNotDisturb` appears in the +/// manage-presence-state permutation table but is NOT settable through +/// setPresence, and OutOfOffice/InAMeeting "has no effect" — neither is +/// offered here, so a rule can never contain a pair Graph silently drops. +pub const PRESENCE_COMBINATIONS: [(&str, &str); 5] = [ + ("Available", "Available"), + ("Busy", "InACall"), + ("Busy", "InAConferenceCall"), + ("Away", "Away"), + ("DoNotDisturb", "Presenting"), +]; + +/// A validated `setPresence` pair — constructible only through +/// [`normalize_presence_pair`], so an invalid combination cannot exist. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PresencePair { + pub availability: String, + pub activity: String, +} + +/// Canonicalize a rule's presence pair (finding #634, issue #634). +/// +/// Case-insensitive and whitespace-trimmed (a hand-edited `config.json` may +/// say `"doNotDisturb"`), and the ONLY constructor of [`PresencePair`]. Any +/// pair outside [`PRESENCE_COMBINATIONS`] — including a half-filled pair — +/// yields `None`, and [`clamp_rules`] then clears both fields, so an +/// unsupported value is normalized away at the IPC boundary exactly like +/// `clamp_polling` normalizes an out-of-range interval. +pub fn normalize_presence_pair(availability: &str, activity: &str) -> Option { + let availability = availability.trim(); + let activity = activity.trim(); + if availability.is_empty() || activity.is_empty() { + return None; + } + PRESENCE_COMBINATIONS + .iter() + .find(|(avail, act)| { + avail.eq_ignore_ascii_case(availability) && act.eq_ignore_ascii_case(activity) + }) + .map(|(avail, act)| PresencePair { + availability: (*avail).to_string(), + activity: (*act).to_string(), + }) +} + +/// Upper bound on a rule's status replacement text (finding #634). The text +/// is POSTed verbatim as the Teams status message AND embedded in the #343 +/// change key, so it stays a status line rather than an essay; the Settings +/// editor mirrors this with a `maxlength` + counter so the truncation is +/// never silent. +pub const MAX_RULE_STATUS_CHARS: usize = 128; + +/// S4 (issue #672): minutes in a rule's day. `end_minutes` may be +/// `TRACK_RULE_DAY_MINUTES` (= the end of the day), which is why the track-rule +/// window uses `u32` while [`QuietHoursEntry`] carries the same value in the +/// `u16` [`QUIET_HOURS_DAY_MINUTES`]. +pub const TRACK_RULE_DAY_MINUTES: u32 = 1440; + +/// The same 24-hour day as [`TRACK_RULE_DAY_MINUTES`], in the `u16` width +/// [`QuietHoursEntry`]'s minute fields carry (issue #821). One value written +/// twice, once per field width; both schedule windows draw their bounds from it, +/// so the two halves of the rules model cannot disagree about where the day +/// ends. +pub(crate) const QUIET_HOURS_DAY_MINUTES: u16 = 1440; + +/// Normalize the rule model (finding #634, issue #634): canonicalize every +/// presence pair, bound every replacement text, and normalize both schedule +/// windows ([`clamp_quiet_hours_window`] for quiet hours, issue #821; +/// [`clamp_track_rule_window`] for track rules, issue #672). Mirrors +/// `clamp_polling` / `clamp_teams`, so it runs on load and on every save through +/// [`clamped_config`]. Issue #868 adds the action's nested text / value / id +/// normalization through [`clamp_track_rule_action`] — the SINGLE +/// normalizer the spec mandates, so a hand-edited config cannot smuggle a +/// 200-character status or a 100 000-minute snooze past the IPC boundary. +pub(crate) fn clamp_rules(cfg: &mut StatusRulesConfig) { + for entry in &mut cfg.quiet_hours { + clamp_presence_pair( + &mut entry.presence_availability, + &mut entry.presence_activity, + ); + clamp_rule_text(&mut entry.replacement_status); + clamp_quiet_hours_window(entry); + } + for rule in &mut cfg.track_rules { + clamp_presence_pair(&mut rule.presence_availability, &mut rule.presence_activity); + clamp_rule_text(&mut rule.replacement_status); + clamp_track_rule_window(rule); + clamp_track_rule_action(rule); + } +} + +/// Normalize a track rule's `action` field (issue #868). The legacy +/// flat fields (`replacement_status`, `presence_availability` / +/// `presence_activity`) are also mirrored INTO the action so the rule +/// walker and the dry-run tester share one projection — a rule that +/// sets `replacement_status` but keeps `action: Suppress` continues to +/// behave like the legacy "post this fixed text" replacement, but a +/// rule that sets `action: Replace { status: "…" }` now uses the new +/// field verbatim. The `min_duration_seconds` cap mirrors the same +/// paranoia as the `clamp_teams` caps — a hand-edited config cannot +/// put the duration gate in a permanently-firing state. +pub(crate) fn clamp_track_rule_action(rule: &mut TrackRuleEntry) { + rule.min_duration_seconds = rule.min_duration_seconds.min(MAX_TRACK_RULE_DURATION_SECS); + match &mut rule.action { + TrackRuleAction::Suppress => { + // No fields to clamp. + } + TrackRuleAction::Replace { status } => { + clamp_rule_text(status); + } + TrackRuleAction::SnoozeMinutes { value } => { + // 1..=1440 minutes (24 hours); an empty / zero value falls + // back to the legacy SnoozePreset::ForMinutes(15) default + // when the rule fires, so the gate can still act. + if *value == 0 { + *value = 15; + } + *value = (*value).clamp(1, MAX_TRACK_RULE_SNOOZE_MINUTES); + } + TrackRuleAction::Profile { id } => { + clamp_profile_id(id); + } + TrackRuleAction::Presence { + availability, + activity, + } => { + clamp_presence_pair(availability, activity); + } + } +} + +/// Upper bound on `min_duration_seconds` (issue #868): 24 h. Mirrors +/// the 60 minute pre-meeting cap and the `clamp_teams` upper bounds so +/// a hand-edited config cannot wedge the duration gate in a +/// permanently-matching state. +pub const MAX_TRACK_RULE_DURATION_SECS: u32 = 24 * 60 * 60; + +/// Upper bound on `SnoozeMinutes.value` (issue #868): 24 h. +pub const MAX_TRACK_RULE_SNOOZE_MINUTES: u32 = 24 * 60; + +/// Issue #869: shared cap on a presence profile's `id`. Also reused by +/// `clamp_track_rule_action` for `TrackRuleAction::Profile { id }`. +pub const MAX_PROFILE_ID_CHARS: usize = 32; + +/// Issue #869: trim / cap a presence profile id (and the matching +/// `TrackRuleAction::Profile { id }`). Whitespace is stripped from the +/// edges; the result is truncated to [`MAX_PROFILE_ID_CHARS`]; an empty +/// id stays empty so the rule walker treats it as `Suppress`. +pub fn clamp_profile_id(id: &mut String) { + let trimmed = id.trim().to_string(); + if trimmed.chars().count() > MAX_PROFILE_ID_CHARS { + *id = trimmed.chars().take(MAX_PROFILE_ID_CHARS).collect(); + } else { + *id = trimmed; + } +} + +/// Issue #869: normalize the presence-profile list (issue #869). +/// +/// - Names are trimmed + truncated to [`MAX_PROFILE_ID_CHARS`] and +/// must be unique (case-sensitive); a duplicate is dropped so a +/// hand-edited config cannot smuggle two profiles under the same id +/// and confuse the tray / hotkey / CLI. +/// - A profile whose name normalises to empty is dropped for the same +/// reason `clamp_profile_id` clears empty ids. +/// - The active-profile pointer is cleared if its name no longer +/// matches any surviving profile (Settings just deleted it; a hand +/// edit typo'd it; an upgrade dropped the whole list). The pointer +/// is `Option<&mut Option>` so the caller can pass either +/// `&mut config.active_profile` or a local — both paths share one +/// definition of "the pointer is invalid, so it must be cleared". +pub fn clamp_presence_profiles(profiles: &mut Vec, active: &mut Option) { + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + profiles.retain_mut(|profile| { + clamp_profile_id(&mut profile.name); + if profile.name.is_empty() { + log::warn!( + "[CFG] presence_profiles: dropped a profile with an empty name (issue #869)" + ); + return false; + } + if !seen.insert(profile.name.clone()) { + log::warn!( + "[CFG] presence_profiles: dropped a duplicate profile named {:?} (issue #869)", + profile.name + ); + return false; + } + // Cap the idle threshold so a hand-edited config cannot put + // the idle gate in a permanently-firing state. + if let Some(value) = profile.idle_away_after_seconds.as_mut() { + *value = (*value).min(86_400); + } + // A profile's `track_rules` overlay, when present, is itself + // a Vec — re-run `clamp_rules` semantics on + // it so a profile stored before the rule extensions existed + // gets the same normalization every other rule path gets. + if let Some(rules) = profile.track_rules.as_mut() { + for rule in rules.iter_mut() { + clamp_track_rule_window(rule); + clamp_track_rule_action(rule); + } + } + true + }); + if let Some(name) = active.as_ref() { + let still_present = profiles.iter().any(|p| &p.name == name); + if !still_present { + log::warn!( + "[CFG] active_profile: the stored profile {:?} no longer exists — cleared (issue #869)", + name + ); + *active = None; + } + } +} + +/// Issue #869: resolve the active profile overlay onto the base +/// configuration at READ time. `effective_config` is the single +/// non-mutating overlay path the tray / hotkey / CLI / Settings all +/// share; it MUST NOT mutate the input (the spec calls this out +/// explicitly — a "switch to profile X" call is a runtime state +/// change, not a config rewrite). +/// +/// Resolution rules: +/// - `active_profile == None` → the input is returned unchanged. +/// - `active_profile == Some(name)` but `name` does not match any +/// profile → the input is returned unchanged (defensive parity +/// with `clamp_presence_profiles`, which would have cleared the +/// pointer; the runtime side keeps the read-only contract even if a +/// caller forgot to clamp first). +/// - Otherwise, every `Some(_)` field on the matched profile wins +/// over the base field. `None` overlay fields fall through to the +/// base unchanged. The `track_rules` overlay, when present, REPLACES +/// the base rules list — the spec's documented "rules subset" +/// semantics — so `Some(vec![])` is a legitimate "no rules while +/// this profile is active" shape. +pub fn effective_config(config: &AppConfig) -> AppConfig { + let Some(active_name) = config.active_profile.as_ref() else { + return config.clone(); + }; + let Some(profile) = config + .presence_profiles + .iter() + .find(|p| &p.name == active_name) + else { + // Defensive: the clamp normally clears this case, but the + // runtime side keeps the read-only contract. Return the base + // unchanged rather than panic / silently pick a wrong profile. + return config.clone(); + }; + let mut out = config.clone(); + if let Some(v) = &profile.status_format { + out.teams.status_format = v.clone(); + } + if let Some(v) = profile.clear_on_pause { + out.teams.clear_on_pause = v; + } + if let Some(v) = profile.availability_sync { + out.teams.availability_sync = v; + } + if let Some(v) = profile.gate_when_out_of_office { + out.teams.gate_when_out_of_office = v; + } + if let Some(v) = profile.gate_when_presenting { + out.teams.gate_when_presenting = v; + } + if let Some(v) = profile.idle_away_after_seconds { + out.teams.idle_away_after_seconds = v; + } + if let Some(pp) = &profile.preferred_presence { + out.teams.preferred_presence = pp.clone(); + } + if let Some(rules) = &profile.track_rules { + out.status_rules.track_rules = rules.clone(); + } + if let Some(notifications) = &profile.notifications { + out.notifications = notifications.clone(); + } + out +} + +/// Issue #893: the hot-path twin of [`effective_config`]. When no profile is +/// active (the common case) the base pointer is shared — no deep copy — and +/// only an active overlay allocates. Poll-iteration readers take their +/// snapshot through this so one iteration performs no `AppConfig` clone. +pub fn effective_snapshot(config: &std::sync::Arc) -> std::sync::Arc { + if config.active_profile.is_none() { + return std::sync::Arc::clone(config); + } + std::sync::Arc::new(effective_config(config)) +} + +/// Rewrite a rule's pair in place to its canonical form, or clear BOTH fields +/// when the pair is not one of [`PRESENCE_COMBINATIONS`] (an empty pair is the +/// documented "don't touch presence" value). +fn clamp_presence_pair(availability: &mut String, activity: &mut String) { + match normalize_presence_pair(availability, activity) { + Some(pair) => { + *availability = pair.availability; + *activity = pair.activity; + } + None => { + availability.clear(); + activity.clear(); + } + } +} + +/// Truncate a rule's replacement text to [`MAX_RULE_STATUS_CHARS`]. +/// Issue #870: the text-length bound shared by [`clamp_rule_text`] and the +/// Dashboard composer / `--set-status` CLI flag. Public so the manual +/// status path and the rule-replacement-text path share one anchor. +pub fn clamp_rule_text(text: &mut String) { + if text.chars().count() > MAX_RULE_STATUS_CHARS { + *text = text.chars().take(MAX_RULE_STATUS_CHARS).collect(); + } +} + +/// Normalize a rule's weekday list in place (issue #821): keep only the +/// documented ISO range `1..=7`, then sort and deduplicate. +/// +/// The ONE normalization of `days` for both halves of the rules model — the +/// quiet-hours window and the track rule — so the two cannot drift on what +/// load-time normalization means. A list that ends up empty means "every day", +/// so dropping an out-of-range value can only widen a rule, never leave it +/// matching nothing. +pub(crate) fn normalize_rule_days(days: &mut Vec) { + days.retain(|day| (1..=7).contains(day)); + days.sort_unstable(); + days.dedup(); +} + +/// S4 (issue #672): normalize a track rule's schedule in place. +/// +/// Minutes are clamped into `0..=TRACK_RULE_DAY_MINUTES`, so a hand-edited +/// config cannot wedge the comparison, and `days` goes through +/// [`normalize_rule_days`] — the documented ISO range `1..=7`, sorted and +/// deduplicated — so it matches the invariant [`QuietHoursEntry`] relies on. +/// The window itself keeps +/// [`QuietHoursEntry`]'s semantics: `[start, end)` with a wrap-around pair +/// (`start > end`, e.g. 22:00→07:00) honoured, and `start == end` matching +/// nothing. +pub(crate) fn clamp_track_rule_window(rule: &mut TrackRuleEntry) { + // A START of 1440 is unreachable: `now` never exceeds 1439, so such a rule + // could never match while the picker happily renders it as 00:00. Clamp the + // start to the last minute of the day instead, and the end to the end of + // the day (1440), which IS reachable as "until midnight". + rule.start_minutes = rule.start_minutes.min(TRACK_RULE_DAY_MINUTES - 1); + rule.end_minutes = rule.end_minutes.min(TRACK_RULE_DAY_MINUTES); + normalize_rule_days(&mut rule.days); +} + +pub(crate) fn clamp_logging(cfg: &mut LoggingConfig) { + cfg.max_file_size_mb = cfg.max_file_size_mb.clamp(1, 500); + cfg.keep_files = cfg.keep_files.clamp(1, 20); +} + +/// Canonicalise the stored UI locale to a tag the app can actually render +/// (issue #767 — the clamp for the new `ConfigPatch::locale` field). +/// +/// `None` stays `None`: that is the documented pre-4.7 state, and it means +/// "follow the OS", not "English". A present tag is resolved onto one of the +/// shipped dictionaries through +/// [`crate::i18n::resolve_tag`], which is the same function the `set_locale` +/// command canonicalises with — so a patch, a `set_locale` call and a +/// hand-edited file all converge on the identical stored value. Without it a +/// patch could persist `"de-AT-x-priv"` and the picker would render a tag +/// that resolves to English while the file claims German. +pub(crate) fn clamp_locale(cfg: &mut AppConfig) { + let Some(tag) = cfg.locale.as_deref() else { + return; + }; + cfg.locale = Some(crate::i18n::resolve_tag(Some(tag)).to_string()); +} + +/// Read a three-state `Option>` patch field: absent leaves the +/// stored value untouched, `null` clears it, a string sets it (issue #767). +/// +/// Serde cannot do this unaided. For `Option>` both a MISSING key and +/// an explicit `null` deserialize to `None`, so the two states that the field +/// exists to distinguish collapse into one — `{"locale": null}` would read as +/// "this patch says nothing about the locale" and the caller's intent to clear +/// it would be silently dropped. This maps the two JSON spellings onto the two +/// distinct `Option` layers, with `#[serde(default)]` still supplying the +/// missing-key case. +pub(crate) fn deserialize_optional_tag<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + Option::::deserialize(deserializer).map(Some) +} + +/// Bound the length of the two shortcut bindings (issue #767 — the clamp for +/// the new `ConfigPatch::shortcuts` field). +/// +/// A binding is user-supplied text that goes straight into `config.json`, and +/// the Settings number/text inputs do not constrain a typed value, so an +/// unbounded string is the same class of hazard `clamp_rule_text` bounds for a +/// rule's replacement text. It is capped here rather than validated because +/// whether an accelerator PARSES is `commands::shortcuts::validate_accelerator`'s +/// job, and its failure is surfaced to the user as a `ShortcutReason` rather +/// than silently unbound (issue #810). A second, quieter rule here would make +/// two places answer "is this binding usable?". +/// +/// A blank binding is deliberately LEFT ALONE — `Some(" ")` is not rewritten +/// to `None`, and nothing is trimmed away. `configured_binding` already treats +/// blank as unbound when it plans a registration, and the Settings field +/// renders exactly what is stored; a writer that normalised it away would make +/// that field lie about the document on disk (pinned by +/// `shortcut_bindings_round_trip_through_json`). +pub(crate) fn clamp_shortcuts(cfg: &mut ShortcutsConfig) { + fn bound(slot: &mut Option) { + let Some(value) = slot.as_deref() else { + return; + }; + if value.chars().count() > MAX_SHORTCUT_BINDING_CHARS { + *slot = Some(value.chars().take(MAX_SHORTCUT_BINDING_CHARS).collect()); + } + } + bound(&mut cfg.toggle_playback); + bound(&mut cfg.toggle_sync); +} + +/// Longest accelerator spelling `clamp_shortcuts` will store (issue #767). +/// +/// Generous next to any real binding — `CmdOrCtrl+Alt+Shift+F12` is 23 +/// characters — and small enough that a pasted paragraph cannot become the +/// stored document. The registrar still rejects anything that does not parse +/// (`validate_accelerator`); this only bounds the text on the way to disk. +pub(crate) const MAX_SHORTCUT_BINDING_CHARS: usize = 128; diff --git a/src-tauri/src/config/io.rs b/src-tauri/src/config/io.rs new file mode 100644 index 00000000..d2427dd3 --- /dev/null +++ b/src-tauri/src/config/io.rs @@ -0,0 +1,877 @@ +use super::clamp::{ + clamp_locale, clamp_logging, clamp_polling, clamp_presence_profiles, clamp_rules, + clamp_shortcuts, clamp_teams, +}; +use super::migrate::{ + default_schema_version, migrate_config, stamp_schema_version, SCHEMA_VERSION, +}; +use super::schema::{AppConfig, ClientSecretState, LoggingConfig}; +use super::snooze::{clamp_snooze, snooze_expired_deadline}; +use super::transfer::strip_client_secret_from_extras; +use std::collections::BTreeMap; +use std::fs; +use std::io::{Read, Write}; +#[cfg(unix)] +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use tauri::Emitter; +pub fn config_dir() -> Result { + // Maintained replacement for the unmaintained `dirs` crate (issue #418): + // `directories::BaseDirs::new()` resolves the same platform config + // roots (XDG_CONFIG_HOME/~/.config on Linux, ~/Library/Application + // Support on macOS, %APPDATA% on Windows) and preserves the + // `/PresenceJam` layout and 0o700 creation below. + let base_dir = directories::BaseDirs::new() + .map(|b| b.config_dir().to_path_buf()) + .ok_or_else(|| { + "Failed to get config directory: BaseDirs::new() returned None".to_string() + })?; + + let app_dir = base_dir.join("PresenceJam"); + + if !app_dir.exists() { + fs::create_dir_all(&app_dir).map_err(|e| { + format!( + "Failed to create config directory '{}': {}", + app_dir.display(), + e + ) + })?; + #[cfg(unix)] + { + let _ = fs::set_permissions(&app_dir, std::fs::Permissions::from_mode(0o700)); + } + log::info!("[CFG] Created config directory at '{}'", app_dir.display()); + } + + Ok(app_dir) +} + +pub fn get_config_path() -> Result { + let dir = config_dir()?; + Ok(dir.join("config.json")) +} + +/// Set when `load_config` finds a corrupt config.json and quarantines it to +/// `.bak` (issue #379). Diagnostics-visible via +/// [`config_was_quarantined`]; warn-log-only otherwise — no other channel is +/// touched by this slice. +pub(crate) static CONFIG_QUARANTINED: AtomicBool = AtomicBool::new(false); + +/// Diagnostics-visible flag: true once this process has quarantined a corrupt +/// config.json to `.bak` and fallen back to defaults (issue #379). +pub fn config_was_quarantined() -> bool { + CONFIG_QUARANTINED.load(Ordering::SeqCst) +} + +/// Backup path alongside the original: `config.json` → `config.json.bak`. +/// +/// Shared with the config-import command (4.7.0, S5), which moves the +/// outgoing file here before an imported document replaces it. +pub(crate) fn quarantine_backup_path(path: &std::path::Path) -> PathBuf { + let mut backup = path.as_os_str().to_owned(); + backup.push(".bak"); + PathBuf::from(backup) +} + +/// The sidecar an imported document is staged in before it replaces the live +/// config: `.import.tmp`. +/// +/// Deliberately distinct from `atomic_write_json`'s `.tmp`, so an +/// import in flight and an ordinary save can never consume or clear each +/// other's staged bytes. Beside the live file, so the final rename stays on +/// one volume and is atomic (issue #939). +pub(crate) fn staged_import_path(path: &std::path::Path) -> PathBuf { + let mut staged = path.as_os_str().to_owned(); + staged.push(".import.tmp"); + PathBuf::from(staged) +} + +/// Replace the config at `path` with `json`, keeping the outgoing document as +/// `.bak` (issue #939). +/// +/// Order is the whole point. The incoming bytes are written and fsynced to a +/// same-directory sidecar FIRST, so a replacement that cannot be written — disk +/// full, quota, permission denied, an antivirus lock — fails with the live +/// `config.json` still in place. Only then is the live file moved aside and +/// the staged copy renamed over it; both of those are renames, so the window in +/// which no live config exists is a single syscall wide rather than spanning a +/// write. +/// +/// If that final rename fails, the backup is moved back before the error +/// returns, so the user is left with the document they had rather than with +/// only a `.bak` that nothing in the app restores. Every failure path removes +/// the staged sidecar, and a stale one from a crashed import is pre-cleared +/// exactly as `atomic_write_json` does for `config.json.tmp` (#135 path A). +/// +/// The command layer's `import_config` runs this inside the config write +/// guard (issue #946), so no competing writer can slip between the file +/// replacement and the reload that publishes it. +pub(crate) fn replace_with_backup(path: &std::path::Path, json: &str) -> Result<(), String> { + let staged = staged_import_path(path); + + if let Err(e) = fs::remove_file(&staged) { + if e.kind() != std::io::ErrorKind::NotFound { + return Err(format!( + "Failed to remove stale import temp file '{}': {}", + staged.display(), + e + )); + } + } + + // 0600 at creation, never chmod-after-create, for the same reason + // `atomic_write_json` does it: no window in which the config is + // world-readable. + #[cfg(unix)] + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&staged) + .map_err(|e| { + format!( + "Failed to create import temp file '{}': {}", + staged.display(), + e + ) + })?; + + #[cfg(not(unix))] + let mut file = fs::File::create(&staged).map_err(|e| { + format!( + "Failed to create import temp file '{}': {}", + staged.display(), + e + ) + })?; + + if let Err(e) = file.write_all(json.as_bytes()) { + let _ = fs::remove_file(&staged); + return Err(format!( + "Failed to write import temp file '{}': {}", + staged.display(), + e + )); + } + if let Err(e) = file.sync_all() { + let _ = fs::remove_file(&staged); + return Err(format!( + "Failed to sync import temp file '{}': {}", + staged.display(), + e + )); + } + drop(file); + + // The incoming document is now fully durable on disk, so moving the live + // file aside can no longer lose the user's settings. + let backup = quarantine_backup_path(path); + let had_live = path.exists(); + if had_live { + if let Err(e) = fs::rename(path, &backup) { + let _ = fs::remove_file(&staged); + return Err(format!( + "Failed to move the current config to '{}': {}", + backup.display(), + e + )); + } + log::info!( + "[CFG] import: previous config moved to '{}'", + backup.display() + ); + } + + if let Err(e) = fs::rename(&staged, path) { + log::error!( + "[CFG] import: FAILED to install the imported config at '{}': {}", + path.display(), + e + ); + let _ = fs::remove_file(&staged); + if had_live { + match fs::rename(&backup, path) { + Ok(()) => log::warn!("[CFG] import: the previous config was moved back into place"), + Err(rollback) => log::error!( + "[CFG] import: rollback FAILED - the previous config is at '{}': {}", + backup.display(), + rollback + ), + } + } + return Err(format!( + "Failed to install the imported config at '{}': {}", + path.display(), + e + )); + } + + // Same parent-directory fsync `atomic_write_json` performs: the renames + // above are only durable once the directory entry is flushed too. + #[cfg(unix)] + if let Some(parent) = path.parent() { + if let Ok(dir) = fs::File::open(parent) { + if let Err(e) = dir.sync_all() { + log::warn!( + "[CFG] Failed to fsync config dir '{}': {}", + parent.display(), + e + ); + } + } + } + Ok(()) +} + +/// Bare file name of the quarantine backup for `path` when one exists, +/// else `None`. Deliberately a bare name and never an absolute path, so the +/// diagnostics snapshot can surface it without breaching the #409 +/// no-absolute-path rule (CfgDiag#2, issue #537). +pub(crate) fn quarantine_backup_name_for(path: &std::path::Path) -> Option { + let backup = quarantine_backup_path(path); + if backup.is_file() { + backup + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + } else { + None + } +} + +/// [`quarantine_backup_name_for`] against this process's real config path. +/// `None` when nothing was quarantined or the `.bak` has since been removed. +pub fn config_quarantine_backup_name() -> Option { + quarantine_backup_name_for(&get_config_path().ok()?) +} + +/// Rename a corrupt config file alongside itself (`.bak`), raise the +/// diagnostics-visible quarantine flag, and warn. Never fails the load: +/// rename errors are logged and swallowed so the caller falls back to +/// defaults either way (issue #379). +pub(crate) fn quarantine_corrupt_config( + path: &std::path::Path, + parse_err: impl std::fmt::Display, +) -> PathBuf { + let backup = quarantine_backup_path(path); + match fs::rename(path, &backup) { + Ok(()) => log::warn!( + "[CFG] corrupt config '{}' quarantined to '{}': {} — loading defaults", + path.display(), + backup.display(), + parse_err + ), + Err(rename_err) => log::warn!( + "[CFG] corrupt config '{}' failed to parse ({}) and quarantine rename to '{}' failed ({}); loading defaults", + path.display(), + parse_err, + backup.display(), + rename_err + ), + } + CONFIG_QUARANTINED.store(true, Ordering::SeqCst); + backup +} + +/// Every top-level key [`AppConfig`] has a typed field for (issue #926). +/// +/// [`config_from_sections`] reads these one at a time and everything else lands +/// in [`AppConfig::extra`] — the same partition `#[serde(flatten)]` performs +/// when serde parses the document in one call, written out so that one bad +/// section can be replaced by its default without rejecting the rest. +/// `typed_config_keys_match_the_serialized_schema` fails if this list and the +/// struct ever disagree. +pub(crate) const TYPED_CONFIG_KEYS: [&str; 16] = [ + "spotify", + "teams", + "polling", + "logging", + "updates", + "playback", + "autostart", + "notifications", + "locale", + "snooze_until", + "status_rules", + "presence_profiles", + "active_profile", + "shortcuts", + "schema_version", + "revision", +]; + +/// The JSON type of `value`, for a log line that names the shape of a bad root +/// without echoing a whole (possibly multi-megabyte) document. +fn json_kind(value: &serde_json::Value) -> &'static str { + match value { + serde_json::Value::Null => "null", + serde_json::Value::Bool(_) => "a boolean", + serde_json::Value::Number(_) => "a number", + serde_json::Value::String(_) => "a string", + serde_json::Value::Array(_) => "an array", + serde_json::Value::Object(_) => "an object", + } +} + +/// Deserialize ONE typed field out of a config document, falling back to +/// `fallback` when that field alone does not match the schema (issue #926). +/// +/// Per-field, not per-document. A key the file omits takes `fallback` +/// silently — what `#[serde(default)]` has always done — while a key that is +/// PRESENT but invalid takes it with a `[CFG]` warning naming the key, which is +/// the observable replacement for the old all-or-nothing parse. A `null` value +/// is "present but invalid" for every non-`Option` field and a value for an +/// `Option` one, so the field's own type decides, not a special case here. +fn field_or_fallback( + root: &serde_json::Map, + key: &str, + fallback: T, +) -> T { + let Some(raw) = root.get(key) else { + return fallback; + }; + match serde_json::from_value::(raw.clone()) { + Ok(value) => value, + Err(e) => { + log::warn!( + "[CFG] config field '{}' is invalid ({}) — using its default, the rest of the config is kept", + key, + e + ); + fallback + } + } +} + +/// Build an [`AppConfig`] from a config document's root object, one typed field +/// at a time (issue #926). +/// +/// A section that no longer matches the schema costs exactly that section — its +/// default, warned about by [`field_or_fallback`] — instead of the whole +/// document, which is what one wrong-typed, out-of-range or misspelled value +/// used to cost (the file was quarantined and the app booted on defaults). The +/// document's scalar fields take the same route, so `"autostart": "yes"` cannot +/// take quiet hours down with it either. +/// +/// Unknown top-level keys are still retained in [`AppConfig::extra`] (issue +/// #379), exactly as the `#[serde(flatten)]` field collected them under the +/// single-pass parse. +pub(crate) fn config_from_sections(root: serde_json::Map) -> AppConfig { + let mut config = AppConfig { + spotify: field_or_fallback(&root, "spotify", Default::default()), + teams: field_or_fallback(&root, "teams", Default::default()), + polling: field_or_fallback(&root, "polling", Default::default()), + logging: field_or_fallback(&root, "logging", Default::default()), + updates: field_or_fallback(&root, "updates", Default::default()), + playback: field_or_fallback(&root, "playback", Default::default()), + autostart: field_or_fallback(&root, "autostart", Default::default()), + notifications: field_or_fallback(&root, "notifications", Default::default()), + locale: field_or_fallback(&root, "locale", Default::default()), + snooze_until: field_or_fallback(&root, "snooze_until", Default::default()), + status_rules: field_or_fallback(&root, "status_rules", Default::default()), + presence_profiles: field_or_fallback(&root, "presence_profiles", Default::default()), + active_profile: field_or_fallback(&root, "active_profile", Default::default()), + shortcuts: field_or_fallback(&root, "shortcuts", Default::default()), + schema_version: field_or_fallback(&root, "schema_version", default_schema_version()), + revision: field_or_fallback(&root, "revision", 0), + extra: BTreeMap::new(), + }; + for (key, value) in root { + if !TYPED_CONFIG_KEYS.contains(&key.as_str()) { + config.extra.insert(key, value); + } + } + config +} + +/// Tighten a loose `config.json` to 0600 (issue #135 path A), best-effort +/// since issue #802. +/// +/// Idempotent on a file that is already 0600. Unix-only: Windows' default ACL +/// is already user-only, so there is nothing to tighten there. +/// +/// Best-effort, NOT a precondition: a mode that cannot be READ (EROFS on a +/// read-only or ostree mount, EPERM on a file owned by another user, an +/// ACL-managed path) or cannot be CHANGED is logged and ignored. Both used to +/// be `?`-propagated, so `load_config` failed outright on a perfectly readable +/// file — startup logged "no config found", `AppState.config` stayed `None`, +/// the Settings and Dashboard stores fell back to built-in defaults, and +/// because a Settings save posts the whole document, the next save persisted +/// those defaults over the user's real file. Hardening a file we can already +/// read is a courtesy; refusing to read it is a data-loss path. +#[cfg(unix)] +pub(crate) fn tighten_config_permissions(path: &std::path::Path) { + let current = match fs::metadata(path) { + Ok(metadata) => metadata.permissions(), + Err(e) => { + log::warn!( + "[CFG] Could not read the mode of config file '{}': {} — loading it anyway", + path.display(), + e + ); + return; + } + }; + let current_mode = current.mode() & 0o777; + if current_mode == 0o600 { + return; + } + log::warn!( + "[CFG] Tightening config.json mode from {:o} to 0600 (issue #135)", + current_mode + ); + let mut tightened = current; + tightened.set_mode(0o600); + if let Err(e) = fs::set_permissions(path, tightened) { + log::warn!( + "[CFG] Could not chmod config file '{}' to 0600: {} — loading it anyway", + path.display(), + e + ); + } +} + +pub fn load_config() -> Result { + load_config_from(&get_config_path()?).map(|config| { + with_keychain_flags(config, || { + crate::keychain::cached_spotify_client_secret_presence() + }) + }) +} + +/// Path-taking core of [`load_config`]: the file I/O, the section-by-section +/// parse and the normalization, with the keychain stamping left to the public +/// entry point — so this half is testable against real files with no keychain +/// probe, the same shape [`import_config_document`] uses. +pub(crate) fn load_config_from(path: &std::path::Path) -> Result { + if !path.exists() { + log::info!( + "[CFG] Config file not found at '{}', using defaults", + path.display() + ); + return Ok(AppConfig::default()); + } + + // Issue #135 path A: tighten the mode of any pre-existing config.json that + // was created loose by an older PresenceJam version (default umask 022 → + // 0644). Best-effort since issue #802 — see `tighten_config_permissions`. + #[cfg(unix)] + tighten_config_permissions(path); + + let mut file = fs::File::open(path) + .map_err(|e| format!("Failed to open config file '{}': {}", path.display(), e))?; + + let mut contents = String::new(); + file.read_to_string(&mut contents) + .map_err(|e| format!("Failed to read config file '{}': {}", path.display(), e))?; + + let mut config = match serde_json::from_str::(&contents) { + // Issue #926: the document IS an object — load it field by field, so a + // section that no longer matches the schema costs exactly that + // section's default and nothing else. + Ok(serde_json::Value::Object(root)) => config_from_sections(root), + // Anything else is not a config: a bare array/string/number/null + // root, or text that is not JSON at all. Quarantine, exactly as the + // single-pass parse answered those two shapes before. + Ok(other) => { + quarantine_corrupt_config( + path, + format!("expected a JSON object, found {}", json_kind(&other)), + ); + return Ok(AppConfig::default()); + } + Err(e) => { + // Issue #379: never lose the evidence — quarantine the corrupt + // file to `.bak` alongside the original and boot on + // defaults. Observable via `config_was_quarantined()`. + quarantine_corrupt_config(path, &e); + return Ok(AppConfig::default()); + } + }; + // Issue #916: an unknown-key bucket is `#[serde(flatten)]` with no + // entry-level filter, so a `client_secret` a hand-edit or another tool left + // at any level was deserialized, handed to the webview by the `load_config` + // command and re-serialized on the next save — a credential crossing the + // IPC boundary in plaintext, in a file SECURITY.md promises is + // keychain-only. The legacy migration owns the DISK copy (it moves the + // value into the keychain, or deliberately leaves it on a conflict); this + // keeps the value out of the document the webview receives. Every load path + // funnels through here — the startup load and the `load_config` command + // alike — so there is no second place to remember. + let stripped_secrets = strip_client_secret_from_extras(&mut config); + if stripped_secrets > 0 { + log::warn!( + "[CFG] config: stripped {} client_secret key(s) from unknown keys — the Spotify client secret is keychain-only (issue #9)", + stripped_secrets + ); + } + // CfgDiag#1 (#536): the version dispatcher runs BEFORE the clamps, so a + // migration can never have its rewritten values re-clamped away, and + // `schema_version` is raised even for a file that was never saved by + // this binary. + let from_version = config.schema_version; + migrate_config(&mut config, from_version); + clamp_polling(&mut config.polling); + clamp_teams(&mut config.teams); + clamp_rules(&mut config.status_rules); + clamp_logging(&mut config.logging); + // Issue #869: enforce name uniqueness + ≤ 32 chars + active-profile + // validity on every load, mirroring the other `clamp_*` calls. The + // active-profile pointer is cleared if the named profile has been + // removed (a hand-edited config or an upgrade that dropped profiles + // cannot silently land on a phantom id). + clamp_presence_profiles(&mut config.presence_profiles, &mut config.active_profile); + // Issue #767: same two clamps as `clamped_config`, so the document a load + // hands the webview already carries the canonical tag and the normalised + // bindings. The reader does not persist anything here — the values reach + // disk on the next guarded write, exactly like every other clamp. + clamp_locale(&mut config); + clamp_shortcuts(&mut config.shortcuts); + // 4.7.0 (S9, issue #677): an expired snooze is reported here and REMOVED by + // the guarded writers below, never by this reader. + // + // The distinction is load-bearing twice over. (a) `load_config` runs on + // paths that hold no config-write guard — the startup load, the + // `load_config` command, `update_config`'s cold read — so writing the file + // from here could clobber a concurrent save and break the #297 invariant + // that the file and the in-memory copy agree. (b) An expired value must stay + // VISIBLE to the consumer that can persist its removal: `poll_once`'s + // `SnoozeGate::Expired` arm and the tray's startup cleaner both read the + // stored field. Clearing it here made the disk drift permanent — the + // in-memory copy looked clean, so nothing ever rewrote `config.json`, and + // the line below repeated on every launch. + if snooze_expired_deadline(&config, chrono::Utc::now()) { + log::info!("[CFG] snooze: the stored deadline had already passed — it is ignored and cleared on the next write"); + } + + log::info!("[CFG] Loaded configuration from '{}'", path.display()); + Ok(config) +} + +/// The persisted `logging` section, read without a full config load +/// (4.7.0, S5). +/// +/// The log plugin is registered on the Tauri builder *before* the `setup` +/// hook runs, so the rotating file target needs its size and retention +/// settings before [`load_config`] is reached. Deliberately narrow: +/// no migration, no quarantine side effects, and **no keychain probe** — +/// `load_config` runs moments later and a second probe at startup is a real +/// macOS prompt risk (see [`with_keychain_flags`]). +/// +/// A missing, unreadable or unparsable file yields the defaults; the real +/// load still handles the corrupt-file case. +pub fn logging_config_for_startup() -> LoggingConfig { + let mut logging = match get_config_path() + .ok() + .and_then(|path| fs::read_to_string(path).ok()) + { + Some(contents) => match serde_json::from_str::(&contents) { + Ok(cfg) => cfg.logging, + Err(e) => { + log::warn!( + "[CFG] startup log-rotation read: config unparsable ({}); using log defaults", + e + ); + LoggingConfig::default() + } + }, + None => LoggingConfig::default(), + }; + clamp_logging(&mut logging); + logging +} + +/// Populate derived/display fields that are not persisted to disk. +/// +/// Covers the two Spotify keychain views: `client_secret_set` (the pre-#560 +/// `Present`-only flag) and `client_secret_state` (the tri-state that can say +/// "the keychain could not answer"). See issues #9 and #560. +/// +/// One presence result feeds both. A fresh warm keychain observation avoids +/// an OS round trip; a cold or expired cache falls back to the direct +/// tri-state probe, which still notices credentials changed through the OS UI. +pub(crate) fn with_keychain_flags( + config: AppConfig, + presence: impl FnOnce() -> crate::keychain::KeychainPresence, +) -> AppConfig { + stamp_keychain_flags(config, presence()) +} + +pub(crate) fn stamp_keychain_flags( + mut config: AppConfig, + presence: crate::keychain::KeychainPresence, +) -> AppConfig { + config.spotify.client_secret_set = + matches!(presence, crate::keychain::KeychainPresence::Present); + config.spotify.client_secret_state = ClientSecretState::from(&presence); + config +} +pub(crate) fn atomic_write_json(path: &std::path::Path, json: &str) -> Result<(), String> { + let temp_path = path.with_extension("tmp"); + + // Issue #135 path A: create the temp file with mode 0600 atomically. + // Pre-clear any stale sidecar from a previous crash (between temp-write + // and rename). Without this pre-clear, create_new(true) would error with + // AlreadyExists on a leftover `.tmp`, turning a one-off crash into a + // permanent save failure until the user manually deletes the sidecar. + // Deletion of a non-existent file is fine — we ignore NotFound. + if let Err(e) = fs::remove_file(&temp_path) { + if e.kind() != std::io::ErrorKind::NotFound { + return Err(format!( + "Failed to remove stale temp file '{}': {}", + temp_path.display(), + e + )); + } + } + // OpenOptions::create_new(true) prevents racing with a leftover sidecar; + // .mode(0o600) sets the mode at file-creation time (no chmod-after-create + // window where config.json could briefly sit world-readable). The + // subsequent rename() preserves the source mode on POSIX. On Windows, + // the new file inherits the user-only default ACL of the parent. + #[cfg(unix)] + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&temp_path) + .map_err(|e| { + format!( + "Failed to create temp file '{}': {}", + temp_path.display(), + e + ) + })?; + + #[cfg(not(unix))] + let mut file = fs::File::create(&temp_path).map_err(|e| { + format!( + "Failed to create temp file '{}': {}", + temp_path.display(), + e + ) + })?; + + file.write_all(json.as_bytes()) + .map_err(|e| format!("Failed to write temp file '{}': {}", temp_path.display(), e))?; + + file.sync_all() + .map_err(|e| format!("Failed to sync temp file '{}': {}", temp_path.display(), e))?; + + std::fs::rename(&temp_path, path) + .map_err(|e| format!("Failed to rename temp file to '{}': {}", path.display(), e))?; + #[cfg(unix)] + { + if let Some(parent) = path.parent() { + if let Ok(dir) = std::fs::File::open(parent) { + if let Err(e) = dir.sync_all() { + log::warn!( + "[CFG] Failed to fsync config dir '{}': {}", + parent.display(), + e + ); + } + } + } + } + + Ok(()) +} +/// The config as it will actually be persisted: `clamp_polling` applied. +/// +/// `save_config` writes a clamped copy, so the caller must store THIS value +/// in `AppState` rather than its own unclamped input — otherwise a value the +/// UI can type (the number inputs' `min`/`max` attributes do not constrain a +/// typed value) lives in memory while a different one sits on disk, and the +/// two silently reconcile only on the next launch. See issue #297. +pub fn clamped_config(config: &AppConfig) -> AppConfig { + let mut cfg = config.clone(); + clamp_polling(&mut cfg.polling); + clamp_teams(&mut cfg.teams); + clamp_rules(&mut cfg.status_rules); + clamp_logging(&mut cfg.logging); + // Issue #869: clamp the profile list + active id on every save + // (mirrors `clamp_rules` and `clamp_teams`). The active-profile + // pointer is cleared if its name no longer matches — a Settings + // delete that removes the active profile must not leave a phantom + // pointer behind. + clamp_presence_profiles(&mut cfg.presence_profiles, &mut cfg.active_profile); + // 4.7.0 (S9, issue #677): a write that carries an already-expired deadline + // (a whole-document save from a stale draft, or a resume click that raced + // its own deadline) normalizes it away, so the in-memory copy, the file on + // disk and the tray can never disagree about a snooze being active. + clamp_snooze(&mut cfg, chrono::Utc::now()); + // Issue #767: the two clamps for the patch fields that did not exist when + // this function was written. Without them a `update_config` patch could + // persist a locale tag the app cannot render, or a blank shortcut binding + // that renders as set-up while registering nothing. + clamp_locale(&mut cfg); + clamp_shortcuts(&mut cfg.shortcuts); + // Issue #916: the write path strips too, so a payload that carries a + // `client_secret` in an unknown-key bucket cannot put a credential back + // into `config.json` (or into an export) on the way out. + strip_client_secret_from_extras(&mut cfg); + cfg +} + +/// The persisted document's markers, read once (issues #938 and #943): the +/// stored `schema_version` and the stored `revision`. +/// +/// A missing, unreadable, unparsable or non-object file yields `(None, 0)`: +/// there is no version to protect and no revision to be behind, and a corrupt +/// file is about to be replaced by the save this is guarding anyway. A stored +/// `schema_version` that is not a `u32` is `None` for the same reason. +fn stored_document_markers(path: &std::path::Path) -> (Option, u64) { + let Ok(contents) = fs::read_to_string(path) else { + return (None, 0); + }; + let Ok(root) = serde_json::from_str::(&contents) else { + return (None, 0); + }; + ( + root.get("schema_version") + .and_then(serde_json::Value::as_u64) + .and_then(|version| u32::try_from(version).ok()), + root.get("revision") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + ) +} + +/// Marker the stale-revision error starts with (issue #943), so the webview's +/// config store can tell "the settings changed in another window" apart from +/// any other save failure and re-load the document instead of retrying the same +/// payload. +pub const STALE_REVISION_MARKER: &str = "stale-config-revision"; + +/// Emitted after every accepted save (issue #943): `{"revision": u64, +/// "config": }`. +/// +/// The config-writing commands call [`emit_config_changed`] once a persist has +/// succeeded, so a second Settings webview adopts the stored state instead of +/// writing its own stale copy over it — the same shape the presence and tray +/// mirrors use. A tray snooze released this way reaches the Dashboard with no +/// remount. +pub const CONFIG_CHANGED_EVENT: &str = "config-changed"; + +/// Emit [`CONFIG_CHANGED_EVENT`] for the document that was just persisted, +/// returning its revision (issue #943). +/// +/// `persisted` is what [`save_config_persisted`] returned — the clamped, +/// revision-stamped document that is on disk — so what the other window renders +/// matches the file. Emission is best-effort, like every other app-level emit in +/// this codebase: a window that is not listening loses nothing, it reads the +/// same document on its next load. +pub fn emit_config_changed(app: &tauri::AppHandle, persisted: &AppConfig) -> u64 { + match serde_json::to_value(persisted) { + Ok(document) => { + let _ = app.emit( + CONFIG_CHANGED_EVENT, + crate::events::ConfigChanged { + revision: persisted.revision, + config: document, + }, + ); + } + Err(e) => log::warn!( + "[CFG] config-changed: the persisted document could not be serialized ({}); not emitting", + e + ), + } + persisted.revision +} + +pub fn save_config(config: &AppConfig) -> Result<(), String> { + save_config_persisted(config).map(|_| ()) +} + +/// Persist `config` and return the document that was written (issue #943). +/// +/// [`save_config`] is this function with the returned document dropped, for +/// callers that do not keep the config in memory. A caller that DOES — the +/// commands layer stores the persisted value in `AppState`, see #297 — should +/// use this one: the written document carries the next `revision`, and a caller +/// still holding the pre-save copy would be rejected as stale by its own next +/// save once it starts sending that revision. +pub fn save_config_persisted(config: &AppConfig) -> Result { + save_config_to(&get_config_path()?, config) +} + +/// Path-taking core of [`save_config_persisted`]: the normalization, the +/// stale-revision rejection, the newer-document refusal and the atomic write — +/// so the write path is testable against real files, the same shape +/// [`import_config_document`] uses. +pub(crate) fn save_config_to( + path: &std::path::Path, + config: &AppConfig, +) -> Result { + let (stored_version, stored_revision_of_file) = stored_document_markers(path); + + // Issue #943: a payload behind the document on disk is a second webview + // writing its own stale copy — the write that silently reverted the other + // window's change. Reject it instead: the caller re-loads, the user is told + // which window moved, and the newer document survives. + // + // `revision == 0` means the payload carries no revision at all (a frontend + // that does not send the field yet, or a fresh install), so it is stamped + // upward rather than rejected: rejecting it would make every save from such + // a client fail as soon as the first one succeeded, which is a worse failure + // than the one being fixed. The guard applies the moment a client sends the + // revision it loaded. + if config.revision != 0 && config.revision < stored_revision_of_file { + log::warn!( + "[CFG] refusing a stale config write to '{}': the stored document is at revision {} and this copy is at {}", + path.display(), + stored_revision_of_file, + config.revision + ); + return Err(format!( + "{STALE_REVISION_MARKER}: the settings were changed in another window (stored revision {stored_revision_of_file}, this copy is at revision {})", + config.revision + )); + } + + // Issue #938: never rewrite a document a NEWER binary wrote. The marker is + // the only record of which migrations have run, and this build sees none of + // that document's unknown keys: writing back would relabel it at this + // build's version — so the newer build's dispatcher skips its own + // migrations on the next launch — and drop the keys those migrations read. + // Leaving the file alone loses nothing, and the caller surfaces the error. + if let Some(stored) = stored_version { + if stored > SCHEMA_VERSION { + log::warn!( + "[CFG] refusing to overwrite config '{}': schema_version {} was written by a newer PresenceJam (this build writes {})", + path.display(), + stored, + SCHEMA_VERSION + ); + return Err(format!( + "The stored configuration was written by a newer version of PresenceJam (schema {stored}); leaving it untouched" + )); + } + } + + let mut cfg = clamped_config(config); + // CfgDiag#1 (#536): the client's `schema_version` is a suggestion, not an + // instruction — a stale payload can never lower the version, and since + // issue #938 it cannot raise one above a newer document's either. + stamp_schema_version(&mut cfg); + // Issue #943: strictly increasing, and never below either side's value, so + // two windows saving in sequence hand each other a rising token. + cfg.revision = stored_revision_of_file + .max(config.revision) + .saturating_add(1); + + let json = serde_json::to_string_pretty(&cfg) + .map_err(|e| format!("Failed to serialize config to JSON: {}", e))?; + + atomic_write_json(path, &json)?; + + log::info!( + "[CFG] Saved configuration to '{}' (revision {})", + path.display(), + cfg.revision + ); + Ok(cfg) +} diff --git a/src-tauri/src/config/migrate.rs b/src-tauri/src/config/migrate.rs new file mode 100644 index 00000000..d7241250 --- /dev/null +++ b/src-tauri/src/config/migrate.rs @@ -0,0 +1,328 @@ +use super::io::{atomic_write_json, get_config_path}; +use super::schema::AppConfig; +use super::transfer::{legacy_client_secret, strip_client_secret_keys}; +use std::fs; +use std::sync::atomic::{AtomicBool, Ordering}; +use tauri::Emitter; +/// The config schema version THIS binary writes (CfgDiag#1, issue #536). +/// Bump whenever the persisted shape gains or changes a field that needs a +/// migration. +/// +/// Deliberately separate from [`default_schema_version`]: a file with no +/// `schema_version` key predates 4.3.0 and is therefore a *v1* file, so the +/// dispatcher must still run for it. Issue #869: bumped from 2 to 3 for the +/// `presence_profiles` / `active_profile` additions — both are +/// `#[serde(default)]`, so pre-5.0 documents still load as `presence_profiles +/// = vec![]` / `active_profile = None` without a migration step, but the +/// version marker is bumped so a future dispatcher can tell which binary +/// authored a given file. +pub const SCHEMA_VERSION: u32 = 3; + +pub(crate) fn default_schema_version() -> u32 { + 1 +} + +/// Make the binary — never the client — authoritative for `schema_version` +/// (CfgDiag#1, issue #536; issue #938 for the newer-document half). +/// +/// A stale frontend payload (or a wizard literal that still sends `1`) can no +/// longer erase the record that a migration already ran: the marker never goes +/// below [`SCHEMA_VERSION`]. It never goes DOWN at all — a document written by a +/// NEWER binary keeps its own version, because this build cannot know which of +/// that version's migrations have already run, and relabelling it would make the +/// newer build's dispatcher skip them on its next launch. [`save_config`] +/// refuses such a document outright rather than writing over it. +pub fn stamp_schema_version(cfg: &mut AppConfig) { + cfg.schema_version = cfg.schema_version.max(SCHEMA_VERSION); +} + +/// Version-directed fixups run by `load_config` BEFORE the clamps (issue +/// #536). Fail-safe by construction: a file written by a newer binary keeps +/// its own (higher) version and is passed through untouched, so unknown +/// fields are never relabelled as if this binary had produced them. +pub(crate) fn migrate_config(cfg: &mut AppConfig, from: u32) { + match from { + // v1 → v2 (4.6): the three additions of CfgDiag#3 (#538) are all + // additive with serde defaults, so there is nothing to backfill — + // the step exists so a future breaking change has a home. + 1 => {} + n if n > SCHEMA_VERSION => log::warn!( + "[CFG] config written by a newer binary (schema {} > {}); passing through unknown fields", + n, + SCHEMA_VERSION + ), + _ => {} + } + cfg.schema_version = cfg.schema_version.max(SCHEMA_VERSION); +} +/// Frontend event emitted (once per process) when the legacy-plaintext +/// migration finds a *different* secret already in the OS keychain. +/// +/// The Settings view should listen for this event and prompt the user to +/// run Settings → Reconnect Spotify. See issue #376. +pub const SPOTIFY_SECRET_CONFLICT_EVENT: &str = "spotify-secret-conflict"; +/// One-shot startup migration for the legacy `spotify.client_secret` +/// field (≤ v2.5.0): write it to +/// the OS keychain and strip the plaintext from the file. Idempotent +/// and safe to call on every startup. +/// +/// Conflict policy: if the keychain already holds a *different* +/// secret, the migration is a no-op (we don't clobber a working +/// keychain entry with another install's plaintext, and we do NOT delete +/// the plaintext unilaterally — the user may need it). The user resolves +/// the conflict via Settings → Reconnect Spotify. See audit Q3 and +/// issues #9 and #376. +/// +/// Bounded notification: the conflict is surfaced exactly once per process +/// (see `migrate_legacy_client_secret_with_app`; a process-wide flag guards +/// the emit) — there is no retry loop or timeout that auto-deletes the +/// plaintext. Manual step: after Reconnect Spotify stores the current +/// secret in the keychain, the next launch either completes the migration +/// (keychain empty / identical value → plaintext stripped) or re-emits +/// this event while the stale plaintext is still present. +/// Log-only variant kept for backward compatibility (no `AppHandle` +/// available at some call sites). Prefer +/// `migrate_legacy_client_secret_with_app`, which additionally surfaces a +/// keychain conflict to the UI via [`SPOTIFY_SECRET_CONFLICT_EVENT`]. +pub fn migrate_legacy_client_secret() { + run_legacy_secret_migration(); +} + +/// Startup migration with user-visible conflict surfacing (issue #376). +/// +/// Runs the same migration as [`migrate_legacy_client_secret`] and returns +/// the observable outcome so the caller can persist it (issue #813); when +/// the outcome is [`LegacySecretOutcome::ConflictKeychainDiffers`], emits a +/// one-time [`SPOTIFY_SECRET_CONFLICT_EVENT`] so Settings can prompt +/// Settings → Reconnect Spotify (payload carries the manual step). +/// All other outcomes are silent apart from the usual `[CFG]` logs. +pub fn migrate_legacy_client_secret_with_app(app: &tauri::AppHandle) -> LegacySecretOutcome { + let outcome = run_legacy_secret_migration(); + if outcome == LegacySecretOutcome::ConflictKeychainDiffers { + emit_spotify_secret_conflict_once(app); + } + outcome +} +/// Observable outcome of one [`run_legacy_secret_migration`] pass. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LegacySecretOutcome { + /// No `spotify.client_secret` plaintext field (or it was empty): + /// nothing to do. Also returned when the config file is missing, + /// unreadable, or unparsable, or a keychain write / file rewrite + /// failed part-way (plaintext left on disk in those cases). + NoLegacyField, + /// Plaintext migrated into an empty keychain (or the keychain + /// already held the identical value) and the strip pass ran. + Migrated, + /// Keychain already holds a *different* secret: plaintext deliberately + /// left on disk. The caller must surface this via + /// [`SPOTIFY_SECRET_CONFLICT_EVENT`]. + ConflictKeychainDiffers, +} +/// Pure decision step of the migration: given the legacy plaintext (if any) +/// and the current keychain read, decide the outcome without touching disk +/// or the keychain. Unit-tested directly (issue #376). +pub(crate) fn decide_legacy_secret_outcome( + plaintext: Option<&str>, + keychain: &Result, +) -> LegacySecretOutcome { + let plaintext = match plaintext { + Some(s) if !s.is_empty() => s, + _ => return LegacySecretOutcome::NoLegacyField, + }; + match keychain { + Ok(existing) if existing == plaintext => LegacySecretOutcome::Migrated, + Ok(_) => LegacySecretOutcome::ConflictKeychainDiffers, + Err(_) => LegacySecretOutcome::Migrated, + } +} +/// Process-wide guard so the conflict event fires at most once per launch, +/// no matter how often the migration entry points are called. +static CONFLICT_EVENT_SENT: AtomicBool = AtomicBool::new(false); +/// Emit [`SPOTIFY_SECRET_CONFLICT_EVENT`] unless already sent this process. +/// Follows the `let _ = app.emit(...)` pattern used in `poll_once.rs`; +/// the payload tells Settings to prompt Reconnect Spotify. Returns true +/// when this call performed the (single) emit. +fn emit_spotify_secret_conflict_once(app: &tauri::AppHandle) -> bool { + if CONFLICT_EVENT_SENT.swap(true, Ordering::AcqRel) { + return false; + } + log::warn!( + "[CFG] migrate_legacy_client_secret: EMIT {} event (prompt Settings → Reconnect Spotify)", + SPOTIFY_SECRET_CONFLICT_EVENT + ); + let _ = app.emit( + SPOTIFY_SECRET_CONFLICT_EVENT, + crate::events::SpotifySecretConflict { + action: "reconnect-spotify".to_string(), + message: "The Spotify client secret in config.json differs from the one in the OS keychain. Open Settings → Reconnect Spotify to resolve. The legacy plaintext is left untouched until then.".to_string(), + }, + ); + true +} + +/// Bare file name of the sidecar that keeps a conflicting legacy plaintext +/// (issue #803): `config.json.legacy-secret`, beside `config.json`. +pub(crate) const LEGACY_SECRET_SIDECAR_NAME: &str = "config.json.legacy-secret"; + +/// Write a copy of a conflicting legacy `client_secret` beside `config.json` +/// and return the sidecar's BARE file name (issue #803). +/// +/// The migration deliberately leaves the plaintext in `config.json` when the +/// keychain already holds a different value — but `save_config` serialises +/// `AppConfig`, which has no `client_secret` field, so the next save from +/// anywhere (a Settings toggle, a tray snooze, the poller's snooze cleanup) +/// removed the only remaining copy while the app kept authenticating with the +/// stale keychain value. The user could not recover it afterwards: it was shown +/// nowhere in the UI and the file no longer held it. The sidecar is a copy the +/// app never rewrites, so the "the plaintext is not deleted" promise holds past +/// the next write. +/// +/// The document holds exactly the one key plus a note, and is never read back +/// by the app — it is the user's copy, for Settings → Reconnect Spotify — which +/// is why this logs the FILE NAME and never the value. +/// +/// The write goes through the same atomic-replace, fsync-the-directory helper +/// the config writer uses, so the sidecar is created 0600 on Unix (create_new + +/// mode) and user-only by default ACL on Windows, with no window in which it is +/// world-readable. +pub(crate) fn write_legacy_secret_sidecar( + config_path: &std::path::Path, + secret: &str, +) -> Result { + let sidecar = config_path.with_file_name(LEGACY_SECRET_SIDECAR_NAME); + let document = serde_json::to_string_pretty(&serde_json::json!({ + "client_secret": secret, + "note": "Legacy Spotify client secret kept from config.json: the OS keychain already held a different value. Resolve via Settings → Reconnect Spotify, then delete this file.", + })) + .map_err(|e| format!("Failed to serialize the legacy-secret sidecar: {}", e))?; + atomic_write_json(&sidecar, &document)?; + let name = sidecar + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .ok_or_else(|| "Legacy-secret sidecar has no file name".to_string())?; + log::warn!( + "[CFG] migrate_legacy_client_secret: the conflicting plaintext is kept in '{}' as well — resolve it via Settings → Reconnect Spotify, then delete that file", + name + ); + Ok(name) +} + +/// Executes the migration IO and returns its observable outcome. +fn run_legacy_secret_migration() -> LegacySecretOutcome { + let path = match get_config_path() { + Ok(p) => p, + Err(e) => { + log::warn!( + "[CFG] migrate_legacy_client_secret: config path unavailable: {}", + e + ); + return LegacySecretOutcome::NoLegacyField; + } + }; + if !path.exists() { + return LegacySecretOutcome::NoLegacyField; // Fresh install — nothing to migrate. + } + let contents = match fs::read_to_string(&path) { + Ok(s) => s, + Err(e) => { + log::warn!("[CFG] migrate_legacy_client_secret: read failed: {}", e); + return LegacySecretOutcome::NoLegacyField; + } + }; + // Parse as raw Value so the pre-v2.6.0 nested `spotify.client_secret` field + // can be inspected and removed BEFORE the typed parse. (`SpotifyConfig` + // declares no such field, so `serde_json::from_str::` would drop + // the value into the section's unknown-key bucket — see issue #938 — and + // leave it in the file the migration is supposed to clean.) + let mut root: serde_json::Value = match serde_json::from_str(&contents) { + Ok(v) => v, + Err(e) => { + log::warn!("[CFG] migrate_legacy_client_secret: parse failed: {}", e); + return LegacySecretOutcome::NoLegacyField; + } + }; + let plaintext = legacy_client_secret(&root); + let keychain_read = crate::keychain::get_spotify_client_secret(); + let outcome = decide_legacy_secret_outcome(plaintext.as_deref(), &keychain_read); + match (&outcome, &keychain_read) { + (LegacySecretOutcome::NoLegacyField, _) => { + log::debug!("[CFG] migrate_legacy_client_secret: no legacy plaintext field"); + return outcome; + } + (LegacySecretOutcome::ConflictKeychainDiffers, Ok(existing)) => { + // Conflict check: if keychain already holds a *different* secret, + // don't clobber it. Leave the plaintext in place; the user can + // resolve via Settings → Reconnect Spotify (surfaced via + // `spotify-secret-conflict`; see `migrate_legacy_client_secret_with_app`). + let plaintext = plaintext.unwrap_or_default(); + log::warn!( + "[CFG] migrate_legacy_client_secret: keychain holds a different secret; leaving config.json untouched (user should Reconnect)" + ); + log::warn!( + "[CFG] migrate_legacy_client_secret: plaintext.len={}, keychain.len={}", + plaintext.len(), + existing.len() + ); + // Issue #803: the plaintext stays in `config.json` (that is the + // documented promise), but a copy also goes into a sidecar the app + // never rewrites, because the next unrelated save would otherwise be + // its last appearance anywhere. + if let Err(e) = write_legacy_secret_sidecar(&path, &plaintext) { + log::warn!( + "[CFG] migrate_legacy_client_secret: could not write the legacy-secret sidecar: {}", + e + ); + } + return outcome; + } + _ => { + // Keychain empty (the typical pre-v2.6.0-upgrader case), or it + // already holds the identical value (strip-only). Write the + // plaintext into the keychain only when the keychain is empty. + if keychain_read.is_err() { + log::info!("[CFG] migrate_legacy_client_secret: keychain empty, writing plaintext into keychain"); + // `plaintext` is `Some(non-empty)` here: `decide_*` only + // returns `Migrated` for `Some(non-empty)` input. + let plaintext = plaintext.unwrap_or_default(); + if let Err(e) = crate::keychain::store_spotify_client_secret(&plaintext) { + log::warn!( + "[CFG] migrate_legacy_client_secret: keychain write failed: {} (plaintext left in config.json)", + e + ); + return LegacySecretOutcome::NoLegacyField; + } + } else { + log::info!( + "[CFG] migrate_legacy_client_secret: keychain already holds this value, stripping plaintext only" + ); + } + } + } + // Strip EVERY `client_secret` key, not only the documented pre-v2.6.0 + // `spotify.client_secret`: a hand-edited or third-party file can nest the + // same credential under any path, and the migration has just taken + // responsibility for the value it read (issue #916). + strip_client_secret_keys(&mut root); + let new_contents = match serde_json::to_string_pretty(&root) { + Ok(s) => s, + Err(e) => { + log::warn!( + "[CFG] migrate_legacy_client_secret: re-serialise failed: {}", + e + ); + return LegacySecretOutcome::NoLegacyField; + } + }; + if let Err(e) = atomic_write_json(&path, &new_contents) { + log::warn!( + "[CFG] migrate_legacy_client_secret: atomic rewrite failed: {} (keychain has the value, plaintext remains on disk)", + e + ); + } else { + log::info!( + "[CFG] migrate_legacy_client_secret: SUCCESS — plaintext stripped from config.json" + ); + } + LegacySecretOutcome::Migrated +} diff --git a/src-tauri/src/config.rs b/src-tauri/src/config/mod.rs similarity index 50% rename from src-tauri/src/config.rs rename to src-tauri/src/config/mod.rs index d9a2623d..c78926bb 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config/mod.rs @@ -1,4021 +1,77 @@ -use crate::profanity; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; -use std::fs; -use std::io::{Read, Write}; -#[cfg(unix)] -use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; -use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, Ordering}; -use tauri::Emitter; - -/// Three-way view of the OS-keychain `client_secret` slot (issue #560). -/// -/// [`SpotifyConfig::client_secret_set`] cannot express this: it is the -/// `Present`-only projection, so a locked or missing Secret Service collapsed -/// into `false` — the same answer as "the user never configured a secret". -/// Every UI gate that read it then pushed a fully credentialed Linux user -/// through re-onboarding while their secret was still in the keychain, -/// merely unreadable at that moment. This is the type those gates read -/// instead. The keychain-side classification lives in -/// [`crate::keychain::KeychainPresence`]; the conversion below is the single -/// place the two vocabularies meet. -/// -/// Serialized lowercase — `present`/`absent`/`unavailable` is the on-the-wire -/// and on-disk spelling the frontend switches on, so `rename_all` is part of -/// the contract, not cosmetics. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] -#[serde(rename_all = "lowercase")] -#[ts(export, export_to = "../../src/lib/types-generated/")] -pub enum ClientSecretState { - /// Stored in the OS keychain and readable right now. The only state a - /// sign-in flow can complete from. - Present, - /// No entry for the slot: the genuine "onboarding needed" answer. - #[default] - Absent, - /// The keychain could not answer (no Secret Service daemon, a locked - /// keyring, denied storage access). The secret is still there — the UI - /// must never render this as "not configured". - Unavailable, -} - -impl From<&crate::keychain::KeychainPresence> for ClientSecretState { - fn from(presence: &crate::keychain::KeychainPresence) -> Self { - use crate::keychain::KeychainPresence; - match presence { - KeychainPresence::Present => ClientSecretState::Present, - KeychainPresence::Absent => ClientSecretState::Absent, - KeychainPresence::Unavailable(_) => ClientSecretState::Unavailable, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)] -#[ts(export, export_to = "../../src/lib/types-generated/")] -pub struct SpotifyConfig { - /// Spotify app client id. `#[serde(default)]` since issue #926: this was - /// the only persisted field without one, so a spotify section that omitted - /// it — or spelled it `null` — failed the whole document, which - /// `load_config` answered by quarantining the file and booting on - /// defaults, costing the user every other setting too. - #[serde(default)] - pub client_id: String, - /// True iff the Spotify `client_secret` is currently stored in the OS - /// keychain. This is a derived/display field — it is populated by - /// `load_config` (and not persisted to disk). The actual secret lives - /// in the keychain, not in `config.json`. See issue #9. - #[serde(default)] - pub client_secret_set: bool, - /// Tri-state companion of [`Self::client_secret_set`] (issue #560), same - /// derived/display contract: stamped by [`with_keychain_flags`] on load, - /// never a durable statement about the keychain. `unavailable` is the - /// case the bool cannot carry. - #[serde(default)] - pub client_secret_state: ClientSecretState, - #[serde(default = "default_redirect_uri")] - pub redirect_uri: String, - /// Unknown / future keys NESTED inside this section, retained across - /// load→save so a section written by a newer binary is not silently - /// stripped by an older one (issue #938 — the section-level companion of - /// [`AppConfig::extra`]). Omitted from JSON while empty and skipped in the - /// TypeScript export, so an untouched config gains no bytes and the - /// generated TypeScript is unchanged. - #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] - #[ts(skip)] - pub extra: BTreeMap, -} - -fn default_redirect_uri() -> String { - "presencejam://callback".to_string() -} - -#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)] -#[ts(export, export_to = "../../src/lib/types-generated/")] -pub struct TeamsConfig { - #[serde(default = "default_status_format")] - pub status_format: String, - #[serde(default = "default_clear_on_pause")] - pub clear_on_pause: bool, - #[serde(default = "default_profanity_filter")] - pub profanity_filter: bool, - #[serde(default = "default_profanity_placeholder")] - pub profanity_placeholder: String, - #[serde(default)] - pub start_minimized: bool, - /// P1 (issue #3.0-P1): drive the Teams presence bubble - /// (Available/Available while a track plays) via Graph - /// setPresence/clearPresence. OFF by default — it overrides the - /// user's manual presence bubble. - #[serde(default = "default_availability_sync")] - pub availability_sync: bool, - /// P2 (issue #3.0-P2): before writing a status message, read the - /// user's presence and skip the write when busy/DND/in a - /// meeting/in a call/presenting. ON by default. - #[serde(default = "default_presence_gate")] - pub presence_gate: bool, - /// User-supplied extra words for the status profanity filter - /// (CfgDiag#3(b), issue #538). Normalized once and matched under the - /// same boundary gates as the built-in lexicon. Empty by default; - /// bounded to 64 entries of 32 chars by `clamp_teams`. - #[serde(default)] - pub profanity_extra_words: Vec, - /// Finding #635 (issue #635): never overwrite a Teams status message the - /// user set by hand. ON by default — clobbering a message the user typed - /// ("In a workshop until 3") is the app taking over something the user - /// owns, and the read-before-write check reuses the presence sample the - /// gate already fetches (see `poll_once::manual_status_blocks_write`). - #[serde(default = "default_respect_manual_status")] - pub respect_manual_status: bool, - /// Finding #637 (issue #637): also gate the status write while the user - /// is marked out of office. OFF by default, matching how - /// `availability_sync` shipped — 4.5 behaviour is unchanged until the - /// user opts in. - #[serde(default = "default_gate_when_out_of_office")] - pub gate_when_out_of_office: bool, - /// Issue #872: also gate the status write while the OS reports a - /// full-screen app, presentation mode, or Quiet Time. OFF by default - /// — a hand-edited config flips it on; the GUI does too. Linux/macOS - /// always report `Unknown` (`platform::focus`), so the toggle is a - /// no-op on those targets. Fails open on a Windows probe error so a - /// transient shell-API failure cannot lock the gate. - #[serde(default = "default_gate_when_presenting")] - pub gate_when_presenting: bool, - /// Issue #873: stop advertising listening once the OS reports no - /// keyboard/mouse input for this many seconds. `0` (the default) - /// disables the feature — 4.7 behaviour is unchanged until the user - /// opts in. Clamped to 60..=3600 by `clamp_teams` so a hand-edited - /// config cannot put the gate in a state that surprises the user - /// (a 1 s threshold would fire on every typing pause). Linux/macOS - /// always report `None` (`platform::idle`), so the toggle is a no-op - /// on those targets. - #[serde(default)] - #[ts(type = "number")] - pub idle_away_after_seconds: u64, - /// Issue #867: minutes before a busy Outlook calendar event starts that - /// the status write is suppressed. `0` means suppress only during the - /// meeting itself (the same behaviour as the presence-gate today); - /// `>0` lets a user pre-gate so a track that started ten minutes before - /// the meeting is also caught. Capped at 60 minutes by `clamp_teams`. - #[serde(default)] - pub pre_meeting_suppress_minutes: u16, - /// S4 (issue #672): the text posted as the Teams status message while - /// playback is paused — the user-templatable form of the literal the - /// paused clear used to hardcode (`"🎵 Paused"`, emoji included by - /// `poll_once`). Defaults to that literal's text, so an existing config - /// renders byte-identically. - #[serde(default = "default_paused_status_format")] - pub paused_status_format: String, - /// S4 (issue #672): the text posted when nothing is playing — the - /// user-templatable form of the no-track clear's hardcoded - /// `"🎵 Nothing playing on Spotify"`. Defaults to that literal's text. - #[serde(default = "default_stopped_status_format")] - pub stopped_status_format: String, - /// Issue #866: a long-lived "preferred presence" the app sets on the user's - /// behalf via Graph `setUserPreferredPresence`, applying the documented - /// Busy / DND / BeRightBack / Away pairs while a rule or snooze wants - /// presence moved. The user's own Teams bubble wins — `respect_manual_status` - /// suppresses the call — and the user can clear it from the Settings pane - /// or by quitting the app (the `RunEvent::Exit` arm invokes the Graph - /// `clearUserPreferredPresence` counterpart). - /// - /// National-cloud note: `setUserPreferredPresence` is a commercial-Graph - /// surface. The free `graph.microsoft.com` endpoint used by `setPresence` - /// is the same on every cloud, but sovereign clouds (US Gov / DoD, China, - /// Germany) have historically rejected preferred-presence POSTs. The app - /// always prefers `setUserPreferredPresence` when enabled, and logs a - /// one-shot warning the first time the endpoint answers with the - /// documented 4xx shape. - #[serde(default)] - pub preferred_presence: PreferredPresenceConfig, - /// Unknown / future keys NESTED inside this section, retained across - /// load→save so a section written by a newer binary is not silently - /// stripped by an older one (issue #938 — the section-level companion of - /// [`AppConfig::extra`]). Omitted from JSON while empty and skipped in the - /// TypeScript export, so an untouched config gains no bytes and the - /// generated TypeScript is unchanged. - #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] - #[ts(skip)] - pub extra: BTreeMap, -} - -fn default_status_format() -> String { - "🎵 {artist} - {track} 🎧".to_string() -} - -fn default_start_minimized() -> bool { - false -} - -fn default_clear_on_pause() -> bool { - true -} - -fn default_profanity_filter() -> bool { - true -} - -fn default_profanity_placeholder() -> String { - profanity::safe_placeholder_default().to_string() -} - -fn default_availability_sync() -> bool { - false -} - -fn default_presence_gate() -> bool { - true -} - -fn default_respect_manual_status() -> bool { - true -} - -fn default_gate_when_out_of_office() -> bool { - false -} - -fn default_gate_when_presenting() -> bool { - false -} - -fn default_paused_status_format() -> String { - "Paused".to_string() -} - -fn default_stopped_status_format() -> String { - "Nothing playing on Spotify".to_string() -} - -/// Issue #866: the user-configurable "preferred presence" the app drives on -/// the user's behalf via Graph `setUserPreferredPresence`. Distinct from the -/// ephemeral [`Self::availability_sync`] `setPresence` session — preferred -/// presence is the documented Busy / DND / BeRightBack / Away vehicle and -/// survives across processes the user did not start themselves. -/// -/// `expiry_minutes` is bound by [`clamp_preferred_presence`] into -/// `5..=720`. The pair is bound by the same [`normalize_presence_pair`] the -/// rules use — a hand-edited file that names a pair Graph silently drops is -/// normalized away at the IPC boundary exactly like the rule pairs. -#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)] -#[ts(export, export_to = "../../src/lib/types-generated/")] -pub struct PreferredPresenceConfig { - /// OFF by default — preferred presence is opt-in, mirroring how - /// `availability_sync` shipped (it overrides the user's manual bubble). - #[serde(default)] - pub enabled: bool, - /// Graph availability token (`Busy`, `DoNotDisturb`, `BeRightBack`, - /// `Away`). Normalized through [`normalize_presence_pair`] on load and on - /// every save; an unsupported value clears the pair and disables the - /// feature (the call would never land anyway). - #[serde(default)] - pub availability: String, - /// Graph activity token (`Busy`, `DoNotDisturb`, `Away`, `BeRightBack`, - /// or — for `Busy` — `InACall`/`InAConferenceCall`/`Presenting`). Same - /// normalizer as `availability`. - #[serde(default)] - pub activity: String, - /// How long the preferred presence survives a successful - /// `setUserPreferredPresence` before the app clears it at expiry (the - /// same expiry the rule+snooze path observed, and the same `RunEvent::Exit` - /// arm clears on quit). Default: 60 minutes. - #[serde(default = "default_preferred_presence_expiry")] - pub expiry_minutes: u32, - /// Unknown / future keys NESTED inside this section, retained across - /// load→save so a section written by a newer binary is not silently - /// stripped by an older one (issue #938 — this object is a SECTION in its - /// own right, reachable at `teams.preferred_presence`, and it was the one - /// nested object still missing the retention map its siblings all carry). - #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] - #[ts(skip)] - pub extra: BTreeMap, -} - -fn default_preferred_presence_expiry() -> u32 { - 60 -} - -impl Default for PreferredPresenceConfig { - fn default() -> Self { - Self { - enabled: false, - availability: String::new(), - activity: String::new(), - expiry_minutes: default_preferred_presence_expiry(), - extra: BTreeMap::new(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)] -#[ts(export, export_to = "../../src/lib/types-generated/")] -pub struct PollingConfig { - // Issue #765: Tauri IPC crosses the boundary via serde_json, which decodes - // `u64` values as JS `number` (f64). Override ts-rs's `bigint` default so - // the generated `.ts` matches what `invoke()` actually returns at - // runtime — `bigint` would type-lie about the wire shape. All five values - // are small (seconds, clamped to <= 3600), well under 2^53. - #[serde(default = "default_interval_seconds")] - #[ts(type = "number")] - pub default_interval_seconds: u64, - #[serde(default = "default_min_interval_seconds")] - #[ts(type = "number")] - pub minimum_interval_seconds: u64, - #[serde(default = "default_max_interval_seconds")] - #[ts(type = "number")] - pub max_interval_seconds: u64, - #[serde(default = "default_expiry_buffer_seconds")] - #[ts(type = "number")] - pub expiry_buffer_seconds: u64, - /// Ceiling for the "paused playback" exponential backoff (CfgDiag#3(c), - /// issue #538). `pause_backoff` used to hardcode a 300 s cap; it is now - /// the ladder's ceiling (default 300, so an untouched config is unchanged) - /// and the value is clamped into 60..=3600 by `clamp_polling`. - #[serde(default = "default_pause_backoff_max")] - #[ts(type = "number")] - pub pause_backoff_max_seconds: u64, - /// Unknown / future keys NESTED inside this section, retained across - /// load→save so a section written by a newer binary is not silently - /// stripped by an older one (issue #938 — the section-level companion of - /// [`AppConfig::extra`]). Omitted from JSON while empty and skipped in the - /// TypeScript export, so an untouched config gains no bytes and the - /// generated TypeScript is unchanged. - #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] - #[ts(skip)] - pub extra: BTreeMap, -} - -fn default_interval_seconds() -> u64 { - 30 -} - -fn default_min_interval_seconds() -> u64 { - 10 -} - -fn default_max_interval_seconds() -> u64 { - 60 -} - -fn default_expiry_buffer_seconds() -> u64 { - 10 -} - -fn default_pause_backoff_max() -> u64 { - 300 -} -fn clamp_polling(cfg: &mut PollingConfig) { - cfg.default_interval_seconds = cfg.default_interval_seconds.clamp(5, 300); - cfg.minimum_interval_seconds = cfg.minimum_interval_seconds.clamp(5, 30); - cfg.max_interval_seconds = cfg - .max_interval_seconds - .clamp(cfg.minimum_interval_seconds, 300); - // CfgDiag#5 (#540): `default` is pinned to the pair AFTER both ends are - // clamped, so `minimum <= default <= maximum` always holds. Without this - // a persisted `{default: 300, minimum: 10, maximum: 30}` was accepted and - // drove the no-track sleep (poll_once's `pause_backoff`) five minutes - // past the ceiling the UI was showing as one minute. - cfg.default_interval_seconds = cfg - .default_interval_seconds - .clamp(cfg.minimum_interval_seconds, cfg.max_interval_seconds); - cfg.expiry_buffer_seconds = cfg.expiry_buffer_seconds.clamp(0, 60); - // CfgDiag#3(c) (#538): the pause-backoff ceiling is user-configurable, - // so clamp it into a sane band whatever the file (or the UI) said. - cfg.pause_backoff_max_seconds = cfg.pause_backoff_max_seconds.clamp(60, 3600); -} - -/// Bound the user-supplied teams text fields: the profanity lexicon -/// (CfgDiag#3(b), issue #538) to at most 64 entries of 32 characters, and the -/// two manual-status texts (S4, issue #672) to -/// [`MAX_RULE_STATUS_CHARS`] like a rule's replacement text. -/// `clamped_config` is the only normalizer, so this runs on load and on every -/// save. -fn clamp_teams(cfg: &mut TeamsConfig) { - cfg.profanity_extra_words.truncate(64); - for word in &mut cfg.profanity_extra_words { - if word.chars().count() > 32 { - *word = word.chars().take(32).collect(); - } - } - // S4 (issue #672): the two manual-status texts are status lines too, so they - // are bounded exactly like a rule's replacement text. An empty text is left - // alone — `poll_once` reads it as "use the default". - clamp_rule_text(&mut cfg.paused_status_format); - clamp_rule_text(&mut cfg.stopped_status_format); - // Issue #866: the preferred-presence pair rides the same - // normalizer — `normalize_presence_pair` already clears both - // fields when they fail to match `PRESENCE_COMBINATIONS`, so - // disabling an unsupported config is automatic. - clamp_preferred_presence(&mut cfg.preferred_presence); - // Issue #873: the idle-away threshold. `0` disables (no clamp), any - // other value is clamped into 60..=3600 so a hand-edited config - // cannot put the gate in a state that surprises the user (a 1 s - // threshold would have every normal typing pause fire the gate). - if cfg.idle_away_after_seconds != 0 { - cfg.idle_away_after_seconds = cfg.idle_away_after_seconds.clamp(60, 3600); - } - - // Issue #867: cap the pre-meeting suppression window at 60 minutes — - // anything larger is almost certainly a hand-edited mistake, and a - // longer window only widens the blast radius of a flaky calendar. - cfg.pre_meeting_suppress_minutes = cfg.pre_meeting_suppress_minutes.min(60); -} - -/// Issue #866: bound the preferred-presence config the same way `clamp_rules` -/// bounds rule pairs. The expiry is clamped to `5..=720` minutes (Graph's -/// `expirationDuration` accepts anything but the app's clear-at-expiry logic -/// needs a sane cadence); an unsupported pair clears BOTH fields and disables -/// the feature — a Graph POST with a pair outside `PRESENCE_COMBINATIONS` -/// would 4xx every call, and the user would never see a presence move. -fn clamp_preferred_presence(cfg: &mut PreferredPresenceConfig) { - cfg.expiry_minutes = cfg.expiry_minutes.clamp(5, 720); - match normalize_presence_pair(&cfg.availability, &cfg.activity) { - Some(pair) => { - cfg.availability = pair.availability; - cfg.activity = pair.activity; - } - None => { - cfg.availability.clear(); - cfg.activity.clear(); - cfg.enabled = false; - } - } -} - -/// Issue #866: resolve the preferred-presence config into the validated -/// `PresencePair` the Graph POST needs — `None` when the feature is off, the -/// pair is empty, or the user's `respect_manual_status` setting wins the -/// decision. Pure so the gating tests do not need a Tauri runtime. -pub fn preferred_presence_pair( - teams: &TeamsConfig, - respect_manual_status: bool, -) -> Option { - if !teams.preferred_presence.enabled || respect_manual_status { - return None; - } - normalize_presence_pair( - &teams.preferred_presence.availability, - &teams.preferred_presence.activity, - ) -} - -/// Issue #866: the `expirationDuration` the Graph -/// `setUserPreferredPresence` POST carries. Pure so the same shape that -/// goes to `setPresence` can be tested in isolation. -pub fn preferred_presence_expiry_duration(teams: &TeamsConfig) -> String { - let minutes = teams.preferred_presence.expiry_minutes.max(5); - format!("PT{}M", minutes) -} - -/// Issue #870: borrow the user's lexicon (`teams.profanity_extra_words`, -/// issue #538) into the slice shape `profanity::filter_status` expects. The -/// function is `None`-aware — a hand-edited config that lacks the section -/// reads as the empty slice, reproducing the pre-#538 behaviour exactly. -pub fn profanity_extra_words_for_filter(config: Option<&std::sync::Arc>) -> &[String] { - config - .map(|c| c.teams.profanity_extra_words.as_slice()) - .unwrap_or(&[]) -} - -/// The closed set of `availability`/`activity` pairs the Graph -/// `presence: setPresence` action accepts (finding #634, issue #634). -/// -/// Quoted from https://learn.microsoft.com/graph/api/presence-setpresence: -/// "Supported combinations of availability and activity are: -/// Available/Available, Busy/InACall, Busy/InAConferenceCall, Away/Away, -/// DoNotDisturb/Presenting". `DoNotDisturb/DoNotDisturb` appears in the -/// manage-presence-state permutation table but is NOT settable through -/// setPresence, and OutOfOffice/InAMeeting "has no effect" — neither is -/// offered here, so a rule can never contain a pair Graph silently drops. -pub const PRESENCE_COMBINATIONS: [(&str, &str); 5] = [ - ("Available", "Available"), - ("Busy", "InACall"), - ("Busy", "InAConferenceCall"), - ("Away", "Away"), - ("DoNotDisturb", "Presenting"), -]; - -/// A validated `setPresence` pair — constructible only through -/// [`normalize_presence_pair`], so an invalid combination cannot exist. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PresencePair { - pub availability: String, - pub activity: String, -} - -/// Canonicalize a rule's presence pair (finding #634, issue #634). -/// -/// Case-insensitive and whitespace-trimmed (a hand-edited `config.json` may -/// say `"doNotDisturb"`), and the ONLY constructor of [`PresencePair`]. Any -/// pair outside [`PRESENCE_COMBINATIONS`] — including a half-filled pair — -/// yields `None`, and [`clamp_rules`] then clears both fields, so an -/// unsupported value is normalized away at the IPC boundary exactly like -/// `clamp_polling` normalizes an out-of-range interval. -pub fn normalize_presence_pair(availability: &str, activity: &str) -> Option { - let availability = availability.trim(); - let activity = activity.trim(); - if availability.is_empty() || activity.is_empty() { - return None; - } - PRESENCE_COMBINATIONS - .iter() - .find(|(avail, act)| { - avail.eq_ignore_ascii_case(availability) && act.eq_ignore_ascii_case(activity) - }) - .map(|(avail, act)| PresencePair { - availability: (*avail).to_string(), - activity: (*act).to_string(), - }) -} - -/// Upper bound on a rule's status replacement text (finding #634). The text -/// is POSTed verbatim as the Teams status message AND embedded in the #343 -/// change key, so it stays a status line rather than an essay; the Settings -/// editor mirrors this with a `maxlength` + counter so the truncation is -/// never silent. -pub const MAX_RULE_STATUS_CHARS: usize = 128; - -/// S4 (issue #672): minutes in a rule's day. `end_minutes` may be -/// `TRACK_RULE_DAY_MINUTES` (= the end of the day), which is why the track-rule -/// window uses `u32` while [`QuietHoursEntry`] carries the same value in the -/// `u16` [`QUIET_HOURS_DAY_MINUTES`]. -pub const TRACK_RULE_DAY_MINUTES: u32 = 1440; - -/// The same 24-hour day as [`TRACK_RULE_DAY_MINUTES`], in the `u16` width -/// [`QuietHoursEntry`]'s minute fields carry (issue #821). One value written -/// twice, once per field width; both schedule windows draw their bounds from it, -/// so the two halves of the rules model cannot disagree about where the day -/// ends. -const QUIET_HOURS_DAY_MINUTES: u16 = 1440; - -/// Normalize the rule model (finding #634, issue #634): canonicalize every -/// presence pair, bound every replacement text, and normalize both schedule -/// windows ([`clamp_quiet_hours_window`] for quiet hours, issue #821; -/// [`clamp_track_rule_window`] for track rules, issue #672). Mirrors -/// `clamp_polling` / `clamp_teams`, so it runs on load and on every save through -/// [`clamped_config`]. Issue #868 adds the action's nested text / value / id -/// normalization through [`clamp_track_rule_action`] — the SINGLE -/// normalizer the spec mandates, so a hand-edited config cannot smuggle a -/// 200-character status or a 100 000-minute snooze past the IPC boundary. -fn clamp_rules(cfg: &mut StatusRulesConfig) { - for entry in &mut cfg.quiet_hours { - clamp_presence_pair( - &mut entry.presence_availability, - &mut entry.presence_activity, - ); - clamp_rule_text(&mut entry.replacement_status); - clamp_quiet_hours_window(entry); - } - for rule in &mut cfg.track_rules { - clamp_presence_pair(&mut rule.presence_availability, &mut rule.presence_activity); - clamp_rule_text(&mut rule.replacement_status); - clamp_track_rule_window(rule); - clamp_track_rule_action(rule); - } -} - -/// Normalize a track rule's `action` field (issue #868). The legacy -/// flat fields (`replacement_status`, `presence_availability` / -/// `presence_activity`) are also mirrored INTO the action so the rule -/// walker and the dry-run tester share one projection — a rule that -/// sets `replacement_status` but keeps `action: Suppress` continues to -/// behave like the legacy "post this fixed text" replacement, but a -/// rule that sets `action: Replace { status: "…" }` now uses the new -/// field verbatim. The `min_duration_seconds` cap mirrors the same -/// paranoia as the `clamp_teams` caps — a hand-edited config cannot -/// put the duration gate in a permanently-firing state. -fn clamp_track_rule_action(rule: &mut TrackRuleEntry) { - rule.min_duration_seconds = rule.min_duration_seconds.min(MAX_TRACK_RULE_DURATION_SECS); - match &mut rule.action { - TrackRuleAction::Suppress => { - // No fields to clamp. - } - TrackRuleAction::Replace { status } => { - clamp_rule_text(status); - } - TrackRuleAction::SnoozeMinutes { value } => { - // 1..=1440 minutes (24 hours); an empty / zero value falls - // back to the legacy SnoozePreset::ForMinutes(15) default - // when the rule fires, so the gate can still act. - if *value == 0 { - *value = 15; - } - *value = (*value).clamp(1, MAX_TRACK_RULE_SNOOZE_MINUTES); - } - TrackRuleAction::Profile { id } => { - clamp_profile_id(id); - } - TrackRuleAction::Presence { - availability, - activity, - } => { - clamp_presence_pair(availability, activity); - } - } -} - -/// Upper bound on `min_duration_seconds` (issue #868): 24 h. Mirrors -/// the 60 minute pre-meeting cap and the `clamp_teams` upper bounds so -/// a hand-edited config cannot wedge the duration gate in a -/// permanently-matching state. -pub const MAX_TRACK_RULE_DURATION_SECS: u32 = 24 * 60 * 60; - -/// Upper bound on `SnoozeMinutes.value` (issue #868): 24 h. -pub const MAX_TRACK_RULE_SNOOZE_MINUTES: u32 = 24 * 60; - -/// Issue #869: shared cap on a presence profile's `id`. Also reused by -/// `clamp_track_rule_action` for `TrackRuleAction::Profile { id }`. -pub const MAX_PROFILE_ID_CHARS: usize = 32; - -/// Issue #869: trim / cap a presence profile id (and the matching -/// `TrackRuleAction::Profile { id }`). Whitespace is stripped from the -/// edges; the result is truncated to [`MAX_PROFILE_ID_CHARS`]; an empty -/// id stays empty so the rule walker treats it as `Suppress`. -pub fn clamp_profile_id(id: &mut String) { - let trimmed = id.trim().to_string(); - if trimmed.chars().count() > MAX_PROFILE_ID_CHARS { - *id = trimmed.chars().take(MAX_PROFILE_ID_CHARS).collect(); - } else { - *id = trimmed; - } -} - -/// Issue #869: normalize the presence-profile list (issue #869). -/// -/// - Names are trimmed + truncated to [`MAX_PROFILE_ID_CHARS`] and -/// must be unique (case-sensitive); a duplicate is dropped so a -/// hand-edited config cannot smuggle two profiles under the same id -/// and confuse the tray / hotkey / CLI. -/// - A profile whose name normalises to empty is dropped for the same -/// reason `clamp_profile_id` clears empty ids. -/// - The active-profile pointer is cleared if its name no longer -/// matches any surviving profile (Settings just deleted it; a hand -/// edit typo'd it; an upgrade dropped the whole list). The pointer -/// is `Option<&mut Option>` so the caller can pass either -/// `&mut config.active_profile` or a local — both paths share one -/// definition of "the pointer is invalid, so it must be cleared". -pub fn clamp_presence_profiles(profiles: &mut Vec, active: &mut Option) { - let mut seen: std::collections::HashSet = std::collections::HashSet::new(); - profiles.retain_mut(|profile| { - clamp_profile_id(&mut profile.name); - if profile.name.is_empty() { - log::warn!( - "[CFG] presence_profiles: dropped a profile with an empty name (issue #869)" - ); - return false; - } - if !seen.insert(profile.name.clone()) { - log::warn!( - "[CFG] presence_profiles: dropped a duplicate profile named {:?} (issue #869)", - profile.name - ); - return false; - } - // Cap the idle threshold so a hand-edited config cannot put - // the idle gate in a permanently-firing state. - if let Some(value) = profile.idle_away_after_seconds.as_mut() { - *value = (*value).min(86_400); - } - // A profile's `track_rules` overlay, when present, is itself - // a Vec — re-run `clamp_rules` semantics on - // it so a profile stored before the rule extensions existed - // gets the same normalization every other rule path gets. - if let Some(rules) = profile.track_rules.as_mut() { - for rule in rules.iter_mut() { - clamp_track_rule_window(rule); - clamp_track_rule_action(rule); - } - } - true - }); - if let Some(name) = active.as_ref() { - let still_present = profiles.iter().any(|p| &p.name == name); - if !still_present { - log::warn!( - "[CFG] active_profile: the stored profile {:?} no longer exists — cleared (issue #869)", - name - ); - *active = None; - } - } -} - -/// Issue #869: resolve the active profile overlay onto the base -/// configuration at READ time. `effective_config` is the single -/// non-mutating overlay path the tray / hotkey / CLI / Settings all -/// share; it MUST NOT mutate the input (the spec calls this out -/// explicitly — a "switch to profile X" call is a runtime state -/// change, not a config rewrite). -/// -/// Resolution rules: -/// - `active_profile == None` → the input is returned unchanged. -/// - `active_profile == Some(name)` but `name` does not match any -/// profile → the input is returned unchanged (defensive parity -/// with `clamp_presence_profiles`, which would have cleared the -/// pointer; the runtime side keeps the read-only contract even if a -/// caller forgot to clamp first). -/// - Otherwise, every `Some(_)` field on the matched profile wins -/// over the base field. `None` overlay fields fall through to the -/// base unchanged. The `track_rules` overlay, when present, REPLACES -/// the base rules list — the spec's documented "rules subset" -/// semantics — so `Some(vec![])` is a legitimate "no rules while -/// this profile is active" shape. -pub fn effective_config(config: &AppConfig) -> AppConfig { - let Some(active_name) = config.active_profile.as_ref() else { - return config.clone(); - }; - let Some(profile) = config - .presence_profiles - .iter() - .find(|p| &p.name == active_name) - else { - // Defensive: the clamp normally clears this case, but the - // runtime side keeps the read-only contract. Return the base - // unchanged rather than panic / silently pick a wrong profile. - return config.clone(); - }; - let mut out = config.clone(); - if let Some(v) = &profile.status_format { - out.teams.status_format = v.clone(); - } - if let Some(v) = profile.clear_on_pause { - out.teams.clear_on_pause = v; - } - if let Some(v) = profile.availability_sync { - out.teams.availability_sync = v; - } - if let Some(v) = profile.gate_when_out_of_office { - out.teams.gate_when_out_of_office = v; - } - if let Some(v) = profile.gate_when_presenting { - out.teams.gate_when_presenting = v; - } - if let Some(v) = profile.idle_away_after_seconds { - out.teams.idle_away_after_seconds = v; - } - if let Some(pp) = &profile.preferred_presence { - out.teams.preferred_presence = pp.clone(); - } - if let Some(rules) = &profile.track_rules { - out.status_rules.track_rules = rules.clone(); - } - if let Some(notifications) = &profile.notifications { - out.notifications = notifications.clone(); - } - out -} - -/// Issue #893: the hot-path twin of [`effective_config`]. When no profile is -/// active (the common case) the base pointer is shared — no deep copy — and -/// only an active overlay allocates. Poll-iteration readers take their -/// snapshot through this so one iteration performs no `AppConfig` clone. -pub fn effective_snapshot(config: &std::sync::Arc) -> std::sync::Arc { - if config.active_profile.is_none() { - return std::sync::Arc::clone(config); - } - std::sync::Arc::new(effective_config(config)) -} - -/// Rewrite a rule's pair in place to its canonical form, or clear BOTH fields -/// when the pair is not one of [`PRESENCE_COMBINATIONS`] (an empty pair is the -/// documented "don't touch presence" value). -fn clamp_presence_pair(availability: &mut String, activity: &mut String) { - match normalize_presence_pair(availability, activity) { - Some(pair) => { - *availability = pair.availability; - *activity = pair.activity; - } - None => { - availability.clear(); - activity.clear(); - } - } -} - -/// Truncate a rule's replacement text to [`MAX_RULE_STATUS_CHARS`]. -/// Issue #870: the text-length bound shared by [`clamp_rule_text`] and the -/// Dashboard composer / `--set-status` CLI flag. Public so the manual -/// status path and the rule-replacement-text path share one anchor. -pub fn clamp_rule_text(text: &mut String) { - if text.chars().count() > MAX_RULE_STATUS_CHARS { - *text = text.chars().take(MAX_RULE_STATUS_CHARS).collect(); - } -} - -/// Normalize a rule's weekday list in place (issue #821): keep only the -/// documented ISO range `1..=7`, then sort and deduplicate. -/// -/// The ONE normalization of `days` for both halves of the rules model — the -/// quiet-hours window and the track rule — so the two cannot drift on what -/// load-time normalization means. A list that ends up empty means "every day", -/// so dropping an out-of-range value can only widen a rule, never leave it -/// matching nothing. -fn normalize_rule_days(days: &mut Vec) { - days.retain(|day| (1..=7).contains(day)); - days.sort_unstable(); - days.dedup(); -} - -/// S4 (issue #672): normalize a track rule's schedule in place. -/// -/// Minutes are clamped into `0..=TRACK_RULE_DAY_MINUTES`, so a hand-edited -/// config cannot wedge the comparison, and `days` goes through -/// [`normalize_rule_days`] — the documented ISO range `1..=7`, sorted and -/// deduplicated — so it matches the invariant [`QuietHoursEntry`] relies on. -/// The window itself keeps -/// [`QuietHoursEntry`]'s semantics: `[start, end)` with a wrap-around pair -/// (`start > end`, e.g. 22:00→07:00) honoured, and `start == end` matching -/// nothing. -fn clamp_track_rule_window(rule: &mut TrackRuleEntry) { - // A START of 1440 is unreachable: `now` never exceeds 1439, so such a rule - // could never match while the picker happily renders it as 00:00. Clamp the - // start to the last minute of the day instead, and the end to the end of - // the day (1440), which IS reachable as "until midnight". - rule.start_minutes = rule.start_minutes.min(TRACK_RULE_DAY_MINUTES - 1); - rule.end_minutes = rule.end_minutes.min(TRACK_RULE_DAY_MINUTES); - normalize_rule_days(&mut rule.days); -} - -/// Normalize a quiet-hours window in place (issue #821), mirroring -/// [`clamp_track_rule_window`]: the minutes into the range [`QuietHoursEntry`] -/// documents, and `days` through [`normalize_rule_days`]. -/// -/// Without this, an out-of-range weekday loaded unchanged and matched NO weekday -/// at all, so a hand-edited or other-build `days: [0]` window — its -/// `pause_polling` arm included — silently never fired, with no error anywhere. -/// The Settings day picker only ever writes `1..=7`, so the trigger is exactly -/// the hand-edited/foreign document this load-time normalizer exists for. -/// -/// The window itself keeps [`QuietHoursEntry`]'s semantics: `[start, end)` with -/// a wrap-around pair (`start > end`, e.g. 22:00→07:00) honoured, and -/// `start == end` matching nothing. -fn clamp_quiet_hours_window(entry: &mut QuietHoursEntry) { - // Same reasoning as the track rule above: a START of 1440 is unreachable - // (`now` never exceeds 1439), while an END of 1440 is the end of the day. - entry.start_minutes = entry.start_minutes.min(QUIET_HOURS_DAY_MINUTES - 1); - entry.end_minutes = entry.end_minutes.min(QUIET_HOURS_DAY_MINUTES); - normalize_rule_days(&mut entry.days); -} - -#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)] -#[ts(export, export_to = "../../src/lib/types-generated/")] -pub struct LoggingConfig { - #[serde(default = "default_logging_enabled")] - pub enabled: bool, - #[serde(default = "default_log_level")] - pub log_level: String, - /// Rotation ceiling for the log file, in mebibytes (4.7.0). The - /// rotating file target renames the active log and starts a fresh one - /// once it would exceed this size. Clamped to 1..=500 by - /// [`clamp_logging`]. - #[serde(default = "default_max_file_size_mb")] - #[ts(type = "number")] - pub max_file_size_mb: u64, - /// How many *archived* log files to retain (4.7.0). The active - /// `PresenceJam.log` is not counted, so the directory holds at most - /// `keep_files + 1` log files. Clamped to 1..=20 by [`clamp_logging`]. - #[serde(default = "default_keep_files")] - pub keep_files: u32, - /// Issue #877: opt-in JSONL mirror of the bounded status-decision - /// history. OFF by default — a noisy rule set could otherwise grow - /// the log without bound — and writes only when the user opts in. - /// The mirror lives in the same `app_log_dir()` folder - /// `tauri-plugin-log` already targets; the file is `presence-history.jsonl` - /// and one line per decision appends. - #[serde(default)] - pub presence_history: bool, - /// Unknown / future keys NESTED inside this section, retained across - /// load→save so a section written by a newer binary is not silently - /// stripped by an older one (issue #938 — the section-level companion of - /// [`AppConfig::extra`]). Omitted from JSON while empty and skipped in the - /// TypeScript export, so an untouched config gains no bytes and the - /// generated TypeScript is unchanged. - #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] - #[ts(skip)] - pub extra: BTreeMap, -} - -fn default_logging_enabled() -> bool { - true -} - -fn default_log_level() -> String { - "Info".to_string() -} - -fn default_max_file_size_mb() -> u64 { - 10 -} - -fn default_keep_files() -> u32 { - 3 -} - -/// Bound the log-rotation settings (4.7.0, S5). Mirrors [`clamp_polling`]: -/// the Settings number inputs' `min`/`max` attributes do not constrain a -/// typed value and a hand-edited `config.json` is not policed by anyone -/// else, so this is the only normalizer — it runs on load and on every save. -/// -/// `keep_files >= 1` matters beyond taste: the rotating target is built as -/// `KeepSome(keep_files)` (see `lib.rs::log_rotation_strategy`) and the -/// plugin's archive pass computes `keep_count - 1`. -fn clamp_logging(cfg: &mut LoggingConfig) { - cfg.max_file_size_mb = cfg.max_file_size_mb.clamp(1, 500); - cfg.keep_files = cfg.keep_files.clamp(1, 20); -} - -/// Canonicalise the stored UI locale to a tag the app can actually render -/// (issue #767 — the clamp for the new `ConfigPatch::locale` field). -/// -/// `None` stays `None`: that is the documented pre-4.7 state, and it means -/// "follow the OS", not "English". A present tag is resolved onto one of the -/// shipped dictionaries through -/// [`crate::i18n::resolve_tag`], which is the same function the `set_locale` -/// command canonicalises with — so a patch, a `set_locale` call and a -/// hand-edited file all converge on the identical stored value. Without it a -/// patch could persist `"de-AT-x-priv"` and the picker would render a tag -/// that resolves to English while the file claims German. -fn clamp_locale(cfg: &mut AppConfig) { - let Some(tag) = cfg.locale.as_deref() else { - return; - }; - cfg.locale = Some(crate::i18n::resolve_tag(Some(tag)).to_string()); -} - -/// Read a three-state `Option>` patch field: absent leaves the -/// stored value untouched, `null` clears it, a string sets it (issue #767). -/// -/// Serde cannot do this unaided. For `Option>` both a MISSING key and -/// an explicit `null` deserialize to `None`, so the two states that the field -/// exists to distinguish collapse into one — `{"locale": null}` would read as -/// "this patch says nothing about the locale" and the caller's intent to clear -/// it would be silently dropped. This maps the two JSON spellings onto the two -/// distinct `Option` layers, with `#[serde(default)]` still supplying the -/// missing-key case. -fn deserialize_optional_tag<'de, D>(deserializer: D) -> Result>, D::Error> -where - D: serde::Deserializer<'de>, -{ - Option::::deserialize(deserializer).map(Some) -} - -/// Bound the length of the two shortcut bindings (issue #767 — the clamp for -/// the new `ConfigPatch::shortcuts` field). -/// -/// A binding is user-supplied text that goes straight into `config.json`, and -/// the Settings number/text inputs do not constrain a typed value, so an -/// unbounded string is the same class of hazard `clamp_rule_text` bounds for a -/// rule's replacement text. It is capped here rather than validated because -/// whether an accelerator PARSES is `commands::shortcuts::validate_accelerator`'s -/// job, and its failure is surfaced to the user as a `ShortcutReason` rather -/// than silently unbound (issue #810). A second, quieter rule here would make -/// two places answer "is this binding usable?". -/// -/// A blank binding is deliberately LEFT ALONE — `Some(" ")` is not rewritten -/// to `None`, and nothing is trimmed away. `configured_binding` already treats -/// blank as unbound when it plans a registration, and the Settings field -/// renders exactly what is stored; a writer that normalised it away would make -/// that field lie about the document on disk (pinned by -/// `shortcut_bindings_round_trip_through_json`). -fn clamp_shortcuts(cfg: &mut ShortcutsConfig) { - fn bound(slot: &mut Option) { - let Some(value) = slot.as_deref() else { - return; - }; - if value.chars().count() > MAX_SHORTCUT_BINDING_CHARS { - *slot = Some(value.chars().take(MAX_SHORTCUT_BINDING_CHARS).collect()); - } - } - bound(&mut cfg.toggle_playback); - bound(&mut cfg.toggle_sync); -} - -/// Longest accelerator spelling `clamp_shortcuts` will store (issue #767). -/// -/// Generous next to any real binding — `CmdOrCtrl+Alt+Shift+F12` is 23 -/// characters — and small enough that a pasted paragraph cannot become the -/// stored document. The registrar still rejects anything that does not parse -/// (`validate_accelerator`); this only bounds the text on the way to disk. -const MAX_SHORTCUT_BINDING_CHARS: usize = 128; - -/// Whether a stored snooze deadline is still in the future (4.7.0, S9 / -/// issue #677), as a UTC instant. -/// -/// `None` for an absent, unparsable or already-passed value, so a hand-edited -/// `config.json` can never resurrect a snooze. The parse itself is -/// **tolerant of the offset**: RFC3339 accepts `+02:00` as well as `Z` and both -/// denote an instant, which is what a value re-serialized by another tool (or -/// by a future version that writes local time) may carry — only a *past* -/// instant, a malformed string or a bare date is rejected here. -pub fn snooze_deadline( - stored: &str, - now: chrono::DateTime, -) -> Option> { - let deadline = chrono::DateTime::parse_from_rfc3339(stored.trim()) - .ok()? - .with_timezone(&chrono::Utc); - (deadline > now).then_some(deadline) -} - -/// Whether a config carries a `snooze_until` that is no longer a live deadline -/// (4.7.0, S9 / issue #677) — the non-mutating twin of [`clamp_snooze`], for -/// readers (`load_config`) that must report the state without changing it. -/// -/// True for an absent field? No: an absent field is simply "not snoozed", which -/// is not something to report or clean. True for an unparsable value and for an -/// instant that has passed — both are dead weight that a writer should remove. -pub fn snooze_expired_deadline(cfg: &AppConfig, now: chrono::DateTime) -> bool { - cfg.snooze_until.is_some() && snooze_status(cfg, now).is_none() -} - -/// Drops an expired (or unparsable) `snooze_until` (4.7.0, S9 / issue #677). -/// -/// Returns `true` when the field was cleared. Pure apart from its `now` -/// argument, so the boundary is unit-testable. Only the WRITE paths call it: -/// [`clamped_config`] (so every save normalizes the value) and the two guarded -/// cleaners that own a config-write guard — `poll_once::clear_snooze_if_expired` -/// on the iteration that observes the expiry, and the tray's startup cleaner. -/// `load_config` deliberately uses [`snooze_expired_deadline`] instead: it is a -/// reader on paths that hold no write guard, and clearing in memory there would -/// hide the expiry from the very cleaners that can fix the file. -pub fn clamp_snooze(cfg: &mut AppConfig, now: chrono::DateTime) -> bool { - if !snooze_expired_deadline(cfg, now) { - return false; - } - cfg.snooze_until = None; - true -} - -/// A snooze preset offered by the tray submenu (4.7.0, S9 / issue #677; -/// issue #867 adds the calendar-bound `UntilNextMeetingEnds`). -/// -/// The first two are instant offsets (`now + delta`), which no timezone can -/// move. The third is a LOCAL calendar boundary, which is why it is a variant -/// of its own rather than a `Duration` — see [`next_local_midnight_utc`]. -/// The fourth (issue #867) reads from the Outlook calendar cache and falls -/// back to "until tomorrow" when no meeting is active. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SnoozePreset { - ThirtyMinutes, - OneHour, - UntilTomorrow, - UntilNextMeetingEnds, -} - -/// The deadline a preset denotes (4.7.0, S9 / issue #677; issue #867). -/// -/// Both clocks are arguments rather than read inside, so the "until tomorrow" -/// boundary can be pinned at an exact wall-clock time and timezone in a unit -/// test — the boundary is where a timezone bug would hide. -/// -/// `next_meeting_end` (issue #867) is the end of the meeting currently in -/// progress, computed by the tray handler from the [`crate::calendar`] cache. -/// When `preset` is `UntilNextMeetingEnds` and `next_meeting_end` is `None` -/// (no active meeting, or the cache is empty), the deadline falls back to -/// "until tomorrow" — clicking the entry while no meeting is in progress -/// still produces a valid deadline. -pub fn snooze_preset_deadline( - preset: SnoozePreset, - now_utc: chrono::DateTime, - now_local: chrono::DateTime, - next_meeting_end: Option>, -) -> chrono::DateTime { - match preset { - SnoozePreset::ThirtyMinutes => now_utc + chrono::TimeDelta::minutes(30), - SnoozePreset::OneHour => now_utc + chrono::TimeDelta::minutes(60), - SnoozePreset::UntilTomorrow => next_local_midnight_utc(now_local), - SnoozePreset::UntilNextMeetingEnds => match next_meeting_end { - Some(end) => end, - None => next_local_midnight_utc(now_local), - }, - } -} - -/// The start of the next LOCAL calendar day, as a UTC instant — the "until -/// tomorrow" deadline (4.7.0, S9 / issue #677). -/// -/// ## Semantics -/// -/// "Until tomorrow" means *the next local midnight*, never `now + 24 h`: -/// -/// - `snooze_until` is persisted as a UTC instant, but "tomorrow" is read from -/// the machine's local calendar. Computing the deadline as a fixed offset -/// from `now` would silently stretch or shrink the snooze by the UTC offset: -/// at 23:00 in UTC+13 the user means one hour of quiet, not twenty-five, and -/// at 00:05 in UTC−11 they mean almost a full day. -/// - The boundary is the START of the next day. At exactly local midnight the -/// deadline is a full day away; one second later it is one second short of a -/// day. Either way it is strictly in the future, so an "until tomorrow" -/// snooze is always at least one second long. -/// - A DST transition inside the window changes the real duration, never the -/// wall-clock boundary: a fall-back night lasts 25 hours, a spring-forward -/// night 23. Keeping the calendar boundary is what makes the label true. -/// -/// ## DST edge cases at local midnight -/// -/// - **Ambiguous** — a fall-back repeats local midnight (e.g. -/// `America/Santiago`): the EARLIER of the two instants wins. It is the -/// conservative choice for a deadline (the user asked to stop) and, being -/// derived from the wall clock alone, gives the same answer on every -/// evaluation. -/// - **Gap** — a spring-forward swallows local midnight in a zone whose -/// transition is at 00:00: the first instant that exists on the new day is -/// used, so the deadline lands inside tomorrow instead of on a wall-clock -/// time that never happens. -fn next_local_midnight_utc( - now_local: chrono::DateTime, -) -> chrono::DateTime { - let midnight = (now_local.date_naive() + chrono::Days::new(1)) - .and_hms_opt(0, 0, 0) - .expect("00:00:00 is always a valid wall-clock time"); - resolve_local_forward(&now_local.timezone(), midnight) -} - -/// The earliest UTC instant at or after the local wall-clock time `naive` -/// (4.7.0, S9 / issue #677). -/// -/// `Single` is the normal case, `Ambiguous` takes the earlier instant and -/// `None` (a gap: the wall clock does not exist) walks forward a minute at a -/// time to the first instant that does. A real offset change is minutes, never -/// hours, and the walk is bounded by [`LOCAL_GAP_PROBE_MINUTES`], so it can -/// neither spin nor run long. -fn resolve_local_forward( - tz: &Tz, - naive: chrono::NaiveDateTime, -) -> chrono::DateTime { - let mut probe = naive; - for _ in 0..LOCAL_GAP_PROBE_MINUTES { - match tz.from_local_datetime(&probe) { - chrono::LocalResult::Single(dt) => return dt.with_timezone(&chrono::Utc), - chrono::LocalResult::Ambiguous(earlier, _) => { - return earlier.with_timezone(&chrono::Utc) - } - chrono::LocalResult::None => probe += chrono::TimeDelta::minutes(1), - } - } - // Unreachable for any real timezone — no DST gap is twelve hours deep. - // Reading the wall clock as UTC keeps a menu click from panicking over a - // deadline; the resulting snooze is simply long. - chrono::DateTime::from_naive_utc_and_offset(naive, chrono::Utc) -} - -/// Upper bound on the spring-forward walk in [`resolve_local_forward`]: twelve -/// hours in minutes, far past any real gap (the deepest known is two hours). -const LOCAL_GAP_PROBE_MINUTES: u32 = 720; - -/// The persisted spelling of a deadline: RFC3339, UTC, second precision -/// (`2026-09-17T13:45:00Z`) (4.7.0, S9 / issue #677). -/// -/// One constructor, so the spelling the tray writes and the spelling -/// [`snooze_deadline`] parses cannot drift. Second precision because the -/// presets are whole minutes and a sub-second deadline would only make two -/// equal states look different. -pub fn snooze_store_form(deadline: chrono::DateTime) -> String { - deadline.to_rfc3339_opts(chrono::SecondsFormat::Secs, true) -} - -/// A snooze that is currently active, as the tray renders it (4.7.0, S9 / -/// issue #677). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SnoozeStatus { - /// The stored deadline, as the instant it denotes. - pub deadline: chrono::DateTime, - /// Whole seconds left; always `>= 1`. - pub remaining_seconds: i64, -} - -/// The active snooze for a config, or `None` when there is none / it has -/// passed / the stored value cannot be parsed (4.7.0, S9 / issue #677). -pub fn snooze_status(cfg: &AppConfig, now: chrono::DateTime) -> Option { - let stored = cfg.snooze_until.as_deref()?; - let deadline = snooze_deadline(stored, now)?; - Some(SnoozeStatus { - deadline, - remaining_seconds: (deadline - now).num_seconds(), - }) -} - -/// The countdown the tray and the Dashboard both render: whole minutes left, -/// rounded UP, and never below 1 (4.7.0, S9 / issue #677). -/// -/// Rounding up means a freshly-set 30-minute snooze reads "30 min" rather than -/// "29", and the last minute reads "1 min" rather than "0" for a snooze that is -/// still active. `min(1440)` bounds a hand-edited config's absurd deadline -/// without hiding it. -pub fn snooze_minutes_left(remaining_seconds: i64) -> i64 { - // `i64::div_ceil` is still unstable (`int_roundings`), so round up by hand; - // the `max(0)` makes the pair exact for every input. - let seconds = remaining_seconds.max(0); - // `saturating_add`: a hand-edited absurd deadline must clamp, not panic. - ((seconds.saturating_add(59)) / 60).clamp(1, 24 * 60) -} - -/// Single place the logger's max level is wired from `logging.enabled` / -/// `logging.log_level` (CfgDiag#4, issue #539). -/// -/// Called once from the `lib.rs` setup block after the startup config load -/// and again by every config write that can change logging, so a -/// `logging.enabled: false` (or a Debug-to-reproduce-a-bug switch) takes -/// effect immediately instead of at the next launch. An unrecognised level -/// string falls back to Info rather than silently disabling logging. -pub fn apply_log_level(cfg: &LoggingConfig) { - let level_str = cfg.log_level.to_lowercase(); - let max_level = if !cfg.enabled { - log::LevelFilter::Off - } else { - match level_str.as_str() { - "off" => log::LevelFilter::Off, - "error" => log::LevelFilter::Error, - "warn" => log::LevelFilter::Warn, - "info" => log::LevelFilter::Info, - "debug" => log::LevelFilter::Debug, - "trace" => log::LevelFilter::Trace, - _ => log::LevelFilter::Info, - } - }; - log::set_max_level(max_level); - log::info!( - "[CFG] log level applied: {:?} (enabled={})", - max_level, - cfg.enabled - ); -} - -/// The config schema version THIS binary writes (CfgDiag#1, issue #536). -/// Bump whenever the persisted shape gains or changes a field that needs a -/// migration. -/// -/// Deliberately separate from [`default_schema_version`]: a file with no -/// `schema_version` key predates 4.3.0 and is therefore a *v1* file, so the -/// dispatcher must still run for it. Issue #869: bumped from 2 to 3 for the -/// `presence_profiles` / `active_profile` additions — both are -/// `#[serde(default)]`, so pre-5.0 documents still load as `presence_profiles -/// = vec![]` / `active_profile = None` without a migration step, but the -/// version marker is bumped so a future dispatcher can tell which binary -/// authored a given file. -pub const SCHEMA_VERSION: u32 = 3; - -fn default_schema_version() -> u32 { - 1 -} - -/// Make the binary — never the client — authoritative for `schema_version` -/// (CfgDiag#1, issue #536; issue #938 for the newer-document half). -/// -/// A stale frontend payload (or a wizard literal that still sends `1`) can no -/// longer erase the record that a migration already ran: the marker never goes -/// below [`SCHEMA_VERSION`]. It never goes DOWN at all — a document written by a -/// NEWER binary keeps its own version, because this build cannot know which of -/// that version's migrations have already run, and relabelling it would make the -/// newer build's dispatcher skip them on its next launch. [`save_config`] -/// refuses such a document outright rather than writing over it. -pub fn stamp_schema_version(cfg: &mut AppConfig) { - cfg.schema_version = cfg.schema_version.max(SCHEMA_VERSION); -} - -/// Version-directed fixups run by `load_config` BEFORE the clamps (issue -/// #536). Fail-safe by construction: a file written by a newer binary keeps -/// its own (higher) version and is passed through untouched, so unknown -/// fields are never relabelled as if this binary had produced them. -fn migrate_config(cfg: &mut AppConfig, from: u32) { - match from { - // v1 → v2 (4.6): the three additions of CfgDiag#3 (#538) are all - // additive with serde defaults, so there is nothing to backfill — - // the step exists so a future breaking change has a home. - 1 => {} - n if n > SCHEMA_VERSION => log::warn!( - "[CFG] config written by a newer binary (schema {} > {}); passing through unknown fields", - n, - SCHEMA_VERSION - ), - _ => {} - } - cfg.schema_version = cfg.schema_version.max(SCHEMA_VERSION); -} - -/// One quiet-hours entry for issue #432: status writes are suppressed while -/// the local time falls inside `[start_minutes, end_minutes)` (minutes -/// since midnight; wrap-around ranges like 22:00→07:00 are supported). -/// `days` holds ISO weekday numbers 1 (Mon)..=7 (Sun); empty means every -/// day. A midnight-crossing window is NIGHT-OWNING (issue #794): each half -/// is tested against the day it falls on — the evening half (`now >= start`) -/// against `weekday`, the morning half (`now < end`) against the previous -/// ISO day (wrapping 1→7) — so a Monday-only 22:00→07:00 window covers -/// Monday night into Tuesday morning, not Sunday night. All fields -/// `#[serde(default)]` individually so a hand-edited config missing one -/// still loads, and load-time normalization of `days` and the two minutes -/// lives in one place: [`clamp_quiet_hours_window`]. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] -#[ts(export, export_to = "../../src/lib/types-generated/")] -pub struct QuietHoursEntry { - #[serde(default)] - pub enabled: bool, - /// Minutes since midnight, normalized into `0..=1439` by - /// [`clamp_quiet_hours_window`] (a start of 1440 is unreachable — the - /// clock never reads it). - #[serde(default)] - pub start_minutes: u16, - /// Minutes since midnight, normalized into `0..=1440` by - /// [`clamp_quiet_hours_window`]; `1440` is the end of the day. - #[serde(default = "default_quiet_end")] - pub end_minutes: u16, - /// ISO weekday numbers 1..=7; empty = every day. Normalized by - /// [`clamp_quiet_hours_window`] (out-of-range days dropped, then sorted and - /// deduplicated) exactly like [`TrackRuleEntry::days`]. - #[serde(default)] - pub days: Vec, - /// Optional fixed status posted while this window is active instead of - /// suppressing the write (CfgDiag#3(a), issue #538) — e.g. "Busy" during - /// focus hours. Empty = suppress, mirroring - /// [`TrackRuleEntry::replacement_status`]. - #[serde(default)] - pub replacement_status: String, - /// setPresence pair applied while this window is active (finding #634, - /// issue #634) — e.g. Away/Away outside working hours, so the user is - /// visibly away instead of merely unheard. Both fields empty (the - /// default) = don't touch presence; `clamp_rules` normalizes them against - /// [`PRESENCE_COMBINATIONS`]. - #[serde(default)] - pub presence_availability: String, - #[serde(default)] - pub presence_activity: String, - /// S4 (issue #672): also stop POLLING while this window is active, not - /// just the status write — no Spotify GET, no Graph work, and no clock - /// movement for the duration (the polling driver re-evaluates the window - /// every iteration, so it resumes by itself). OFF by default: 4.6 - /// behaviour is unchanged until the user opts in. - #[serde(default = "default_pause_polling")] - pub pause_polling: bool, -} - -/// Mirrors the serde defaults field-by-field (note `end_minutes` defaults to -/// [`default_quiet_end`], not `u16::default()`), so a test fixture built with -/// `..Default::default()` and a config file missing the same field agree. -impl Default for QuietHoursEntry { - fn default() -> Self { - Self { - enabled: false, - start_minutes: 0, - end_minutes: default_quiet_end(), - days: Vec::new(), - replacement_status: String::new(), - presence_availability: String::new(), - presence_activity: String::new(), - pause_polling: default_pause_polling(), - } - } -} - -fn default_quiet_end() -> u16 { - 420 -} - -fn default_pause_polling() -> bool { - false -} - -fn default_track_rule_days() -> Vec { - Vec::new() -} - -fn default_track_rule_start() -> u32 { - 0 -} - -/// The contract's default end: the end of the day, so the default window -/// covers every minute (0 → 1440). -fn default_track_rule_end() -> u32 { - TRACK_RULE_DAY_MINUTES -} - -/// Issue #868: how `artist_substring` / `track_substring` are compared -/// against the playing track. Substring is the legacy behaviour (case- -/// insensitive `contains`); Exact requires a full case-insensitive -/// equality; Glob treats the two substrings as case-insensitive glob -/// patterns (`*` matches any run, `?` matches one character) evaluated -/// independently. Album / show / device / playlist-uri remain substring -/// matches regardless of `match_kind` — they are extension surfaces, not -/// primary identifiers, and the Settings UI exposes only the substring -/// field for them. -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, ts_rs::TS, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -#[ts(export, export_to = "../../src/lib/types-generated/")] -pub enum TrackRuleMatchKind { - #[default] - Substring, - Exact, - Glob, -} - -/// Issue #868: what happens when a track rule matches. `Suppress` is the -/// documented "write nothing" default; `Replace` posts a fixed status text -/// instead of the track template; `SnoozeMinutes { value }` arms the snooze -/// for `value` minutes; `Profile { id }` switches the active presence -/// profile for the duration of the track; `Presence { availability, -/// activity }` applies a Teams presence pair while the track plays. The -/// legacy `replacement_status` + `presence_availability` / -/// `presence_activity` fields continue to feed the `Replace` / `Presence` -/// variants during the transition — see `explain_rules` for the canonical -/// "what would fire" projection. -#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS, PartialEq, Eq)] -#[serde(tag = "kind", rename_all = "lowercase")] -#[ts(export, export_to = "../../src/lib/types-generated/")] -pub enum TrackRuleAction { - /// Default: write nothing for this track (suppress the status update). - #[default] - Suppress, - /// Post a fixed status text instead of the track template. - Replace { status: String }, - /// Snooze sync for `value` minutes (clamped into 1..=1440 by - /// `clamp_track_rule_action`). - SnoozeMinutes { value: u32 }, - /// Switch the active presence profile for this track. `id` is the - /// profile name from `AppConfig::presence_profiles`; a missing id is - /// treated as `Suppress` by the rule walker. - Profile { id: String }, - /// Apply a Teams presence pair for the duration of the track. The pair - /// is normalized against [`PRESENCE_COMBINATIONS`] by `clamp_rules` - /// exactly like the legacy `presence_availability` / - /// `presence_activity` fields. - Presence { - availability: String, - activity: String, - }, -} - -/// One track-matching rule for issue #432 / issue #868: when the -/// substring conditions AND the album / show / device / playlist-uri -/// extensions AND the duration gate all match, the rule's `action` runs. -/// Issue #868 also adds `negate` so an empty match list still wins when -/// the negation's conditions match (a "suppress on the absence of a -/// device substring" pattern), plus `match_kind` and the new -/// `action` enum. Empty substrings match everything (so a rule with -/// only one field set still works); `min_duration_seconds == 0` skips the -/// duration gate. -#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)] -#[ts(export, export_to = "../../src/lib/types-generated/")] -pub struct TrackRuleEntry { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub artist_substring: String, - #[serde(default)] - pub track_substring: String, - /// Issue #868: how `artist_substring` / `track_substring` are compared. - /// Defaults to `Substring` (the legacy case-insensitive `contains`). - #[serde(default)] - pub match_kind: TrackRuleMatchKind, - /// Issue #868: substring matched against the track's album title - /// (empty = match any album). - #[serde(default)] - pub album_substring: String, - /// Issue #868: substring matched against the episode's show name when - /// the playing item is a podcast episode (empty = match any show or any - /// track). - #[serde(default)] - pub show_substring: String, - /// Issue #868: substring matched against the active Spotify device's - /// name (empty = match any device). - #[serde(default)] - pub device_substring: String, - /// Issue #868: substring matched against the playing context URI - /// (e.g. `spotify:playlist:abc…`). Empty = match any context. - #[serde(default)] - pub playlist_uri: String, - /// Issue #868: minimum track / episode duration, in seconds, for this - /// rule to match. `0` (the default) disables the gate. Capped at 86 400 - /// (24 h) by `clamp_track_rule_action` so a hand-edited config cannot - /// put the gate in a permanently-firing state. - #[serde(default)] - pub min_duration_seconds: u32, - /// Issue #868: when `true`, the rule matches the NEGATION of the - /// combined conditions (the "suppress unless something matches" - /// pattern). `false` (the default) keeps the legacy "match if the - /// conditions hold" semantics. - #[serde(default)] - pub negate: bool, - /// Optional fixed status posted instead of suppressing (issue #432 - /// "busy/focus" alternative, retained for the legacy - /// `TrackRuleAction::Replace { status: … }` projection). - /// Empty = suppress silently. - #[serde(default)] - pub replacement_status: String, - /// setPresence pair applied while this rule matches (finding #634, issue - /// #634) — e.g. DoNotDisturb/Presenting for a focus playlist. Both fields - /// empty (the default) = don't touch presence; `clamp_rules` normalizes - /// them against [`PRESENCE_COMBINATIONS`]. - #[serde(default)] - pub presence_availability: String, - #[serde(default)] - pub presence_activity: String, - /// Issue #868: the rule's effect. Defaults to `Suppress`, the legacy - /// "write nothing for this track" outcome. `clamp_rules` normalizes - /// the inner text / value / id fields into their canonical forms. - #[serde(default)] - pub action: TrackRuleAction, - /// S4 (issue #672): ISO weekday numbers 1 (Mon)..=7 (Sun) this rule - /// applies on; empty = every day — the same shape and semantics - /// [`QuietHoursEntry::days`] uses, normalized by `clamp_rules` (see - /// [`clamp_track_rule_window`]). - #[serde(default = "default_track_rule_days")] - pub days: Vec, - /// S4 (issue #672): start of the rule's local-time window, in minutes since - /// midnight. The window is `[start_minutes, end_minutes)` with the same - /// wrap-around rule as [`QuietHoursEntry`] (22:00→07:00 works); the - /// default pair (`0`, `1440`) covers every minute of the day. A - /// midnight-crossing window is NIGHT-OWNING (issue #794): the evening - /// half (`now >= start`) is tested against the selected day, the morning - /// half (`now < end`) against the previous ISO day (wrapping 1→7) — a - /// Monday-only 22:00→07:00 rule covers Monday night into Tuesday - /// morning. - #[serde(default = "default_track_rule_start")] - pub start_minutes: u32, - /// S4 (issue #672): end of the rule's local-time window, in minutes since - /// midnight; `1440` is the end of the day. - #[serde(default = "default_track_rule_end")] - pub end_minutes: u32, -} - -/// Mirrors the serde defaults field-by-field — see [`QuietHoursEntry`]. -impl Default for TrackRuleEntry { - fn default() -> Self { - Self { - enabled: false, - artist_substring: String::new(), - track_substring: String::new(), - match_kind: TrackRuleMatchKind::default(), - album_substring: String::new(), - show_substring: String::new(), - device_substring: String::new(), - playlist_uri: String::new(), - min_duration_seconds: 0, - negate: false, - replacement_status: String::new(), - presence_availability: String::new(), - presence_activity: String::new(), - action: TrackRuleAction::default(), - days: default_track_rule_days(), - start_minutes: default_track_rule_start(), - end_minutes: default_track_rule_end(), - } - } -} - -/// User-defined status rules for issue #432 (quiet hours + track -/// matching). Additive on `AppConfig` with `#[serde(default)]` so -/// pre-4.5 config files load unchanged (issue #379 versioning untouched: -/// `schema_version` stays 1, `extra` retention untouched). -#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS)] -#[ts(export, export_to = "../../src/lib/types-generated/")] -pub struct StatusRulesConfig { - #[serde(default)] - pub quiet_hours: Vec, - #[serde(default)] - pub track_rules: Vec, - /// Unknown / future keys NESTED inside this section, retained across - /// load→save so a section written by a newer binary is not silently - /// stripped by an older one (issue #938 — the section-level companion of - /// [`AppConfig::extra`]). Omitted from JSON while empty and skipped in the - /// TypeScript export, so an untouched config gains no bytes and the - /// generated TypeScript is unchanged. - #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] - #[ts(skip)] - pub extra: BTreeMap, -} - -/// Which desktop-notification classes the app may show (4.7.0 / issue #675). -/// -/// Replaces the single `notificationsEnabled` localStorage opt-in, which only -/// ever governed track changes, with one toggle per class. All four are ON by -/// default (matching the 4.7.0 schema table); a class is only ever dispatched -/// when its flag is true *and* the OS granted notification permission. -/// Additive with serde defaults, so a pre-4.7 config file loads unchanged — -/// including one that only ever carried the legacy key, which the frontend -/// migrates into `track_change` on first launch. -#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)] -#[ts(export, export_to = "../../src/lib/types-generated/")] -pub struct NotificationsConfig { - /// System notification when the playing track changes (the pre-4.7 class). - #[serde(default = "default_notification_class")] - pub track_change: bool, - /// The poller stopped on its own — an auth failure or a self-terminating - /// loop — so the Dashboard mirror can no longer report "Syncing". - #[serde(default = "default_notification_class")] - pub sync_stopped: bool, - /// A stored Teams session is no longer usable and a sign-in is required. - #[serde(default = "default_notification_class")] - pub auth_required: bool, - /// An update finished staging and will install on quit. - #[serde(default = "default_notification_class")] - pub update_staged: bool, - /// Unknown / future keys NESTED inside this section, retained across - /// load→save so a section written by a newer binary is not silently - /// stripped by an older one (issue #938 — the section-level companion of - /// [`AppConfig::extra`]). Omitted from JSON while empty and skipped in the - /// TypeScript export, so an untouched config gains no bytes and the - /// generated TypeScript is unchanged. - #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] - #[ts(skip)] - pub extra: BTreeMap, -} - -/// Mirrors the serde defaults field-by-field ([`QuietHoursEntry`]'s pattern): -/// every class defaults to ON, so a config file missing the section and one -/// built with `Default::default()` agree. -impl Default for NotificationsConfig { - fn default() -> Self { - Self { - track_change: default_notification_class(), - sync_stopped: default_notification_class(), - auth_required: default_notification_class(), - update_staged: default_notification_class(), - extra: BTreeMap::new(), - } - } -} - -/// Issue #869: one named presence profile — a typed overlay of the -/// base `AppConfig` the user can switch from the tray, a hotkey, or -/// `presencejam --profile `. Every overlay field is `Option<_>` -/// so a profile can carry JUST a status format (a one-line tweak) or a -/// full rules replacement (a "Focus" mode that suppresses every track -/// except the user's whitelist). Names are unique and at most 32 -/// characters — [`clamp_presence_profiles`] enforces both at load and -/// on every save. Profile overlays are resolved at READ time through -/// [`effective_config`], so the on-disk base values stay untouched -/// even while a non-default profile is active. -#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS)] -#[ts(export, export_to = "../../src/lib/types-generated/")] -pub struct PresenceProfile { - /// Profile name, the id the tray / hotkey / CLI use to switch. - /// Case-sensitive, unique within `presence_profiles`, ≤ 32 - /// characters after trim (`MAX_PROFILE_ID_CHARS`). A profile whose - /// name normalises to empty is dropped by `clamp_presence_profiles`. - pub name: String, - /// `None` keeps the base `teams.status_format`; `Some` overrides it - /// while the profile is active. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub status_format: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub clear_on_pause: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub availability_sync: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub gate_when_out_of_office: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub gate_when_presenting: Option, - /// Capped at 86 400 (24 h) by `clamp_presence_profiles` so a hand- - /// edited config cannot put the idle gate in a permanently-firing - /// state. `None` keeps the base value. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(type = "number | null")] - pub idle_away_after_seconds: Option, - /// `None` keeps the base `teams.preferred_presence`; `Some` overlays - /// the whole `PreferredPresenceConfig` (enabled, availability, - /// activity, expiry) while the profile is active. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub preferred_presence: Option, - /// `None` keeps the base `status_rules.track_rules`; `Some` - /// replaces the whole list while the profile is active. The - /// `Some(vec![])` shape is a legitimate "no rules" overlay (a - /// silent profile that only flips the status format). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub track_rules: Option>, - /// `None` keeps the base `notifications`; `Some` overlays each - /// notification class while the profile is active. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub notifications: Option, - /// Unknown / future keys NESTED inside this section, retained across - /// load→save so a section written by a newer binary is not silently - /// stripped by an older one (issue #938 — the section-level companion of - /// [`AppConfig::extra`]). - #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] - #[ts(skip)] - pub extra: BTreeMap, -} - -/// Default accelerator for the playback toggle (issue #676). -/// -/// `CmdOrCtrl` is the plugin parser's platform-portable "primary modifier" -/// spelling (`global_hotkey::hotkey::CMD_OR_CTRL` — SUPER on macOS, CONTROL -/// elsewhere), so a `config.json` carried between platforms keeps the user's -/// intent instead of pinning Command on one machine and Ctrl on another. -pub const DEFAULT_TOGGLE_PLAYBACK_SHORTCUT: &str = "CmdOrCtrl+Alt+P"; - -/// Default accelerator for the sync pause/resume toggle (issue #676). -pub const DEFAULT_TOGGLE_SYNC_SHORTCUT: &str = "CmdOrCtrl+Alt+S"; - -/// Global-shortcut bindings (issue #676). -/// -/// `None` — and the blank string a hand-edited file can carry — means -/// "unbound": the slot registers nothing and never disturbs the other slot. -/// A key that is *absent* from the file takes the documented default, while an -/// explicit `null` is a deliberate unbinding, because serde applies -/// `default = "…"` only when the key is missing. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] -#[ts(export, export_to = "../../src/lib/types-generated/")] -pub struct ShortcutsConfig { - #[serde(default = "default_toggle_playback_shortcut")] - pub toggle_playback: Option, - #[serde(default = "default_toggle_sync_shortcut")] - pub toggle_sync: Option, - /// Unknown / future keys NESTED inside this section, retained across - /// load→save so a section written by a newer binary is not silently - /// stripped by an older one (issue #938 — the section-level companion of - /// [`AppConfig::extra`]). Omitted from JSON while empty and skipped in the - /// TypeScript export, so an untouched config gains no bytes and the - /// generated TypeScript is unchanged. - #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] - #[ts(skip)] - pub extra: BTreeMap, -} - -fn default_toggle_playback_shortcut() -> Option { - Some(DEFAULT_TOGGLE_PLAYBACK_SHORTCUT.to_string()) -} - -fn default_toggle_sync_shortcut() -> Option { - Some(DEFAULT_TOGGLE_SYNC_SHORTCUT.to_string()) -} - -impl Default for ShortcutsConfig { - fn default() -> Self { - Self { - toggle_playback: default_toggle_playback_shortcut(), - toggle_sync: default_toggle_sync_shortcut(), - extra: BTreeMap::new(), - } - } -} - -/// Shared serde default for every [`NotificationsConfig`] flag. -fn default_notification_class() -> bool { - true -} - -/// Which release manifest the updater consults (4.7.0, issue #678). -/// -/// Serialized lowercase — `stable`/`beta` is the on-disk and on-the-wire -/// spelling the Settings picker round-trips, so `rename_all` is part of the -/// contract, not cosmetics (same convention as [`ClientSecretState`]). -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] -#[serde(rename_all = "lowercase")] -#[ts(export, export_to = "../../src/lib/types-generated/")] -pub enum UpdateChannel { - /// Published stable releases (`releases/latest`): the default channel. - #[default] - Stable, - /// Rolling prerelease builds (`releases/download/beta`). A missing or - /// non-newer beta manifest falls back to the stable manifest through - /// `updater_bg::update_endpoints`. - Beta, -} - -/// Updater settings (4.7.0, issue #678). Additive on `AppConfig` with -/// `#[serde(default)]`, so a pre-4.7 config loads as the stable channel. -#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS)] -#[ts(export, export_to = "../../src/lib/types-generated/")] -pub struct UpdatesConfig { - /// Missing key keeps the serde default (`stable`); an unrecognised value - /// is read leniently — see [`deserialize_update_channel`]. - #[serde(default, deserialize_with = "deserialize_update_channel")] - pub channel: UpdateChannel, - /// Unknown / future keys NESTED inside this section, retained across - /// load→save so a section written by a newer binary is not silently - /// stripped by an older one (issue #938 — the section-level companion of - /// [`AppConfig::extra`]). Omitted from JSON while empty and skipped in the - /// TypeScript export, so an untouched config gains no bytes and the - /// generated TypeScript is unchanged. - #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] - #[ts(skip)] - pub extra: BTreeMap, -} - -/// Playback source selection (5.0.0, issue #862). -/// -/// `Auto` is the documented default — the poll loop tries the OS media- -/// session source first (SMTC on Windows, MPRIS on Linux) and falls back -/// to the Spotify Web API source when the session is empty. `Spotify` -/// reproduces the pre-5.0 behaviour exactly. `System` forces the OS -/// source; on macOS there is no OS source so the poll loop reports -/// "no track" and the user is told to switch back to Spotify in -/// `Onboarding.svelte`. -#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS)] -#[ts(export, export_to = "../../src/lib/types-generated/")] -pub struct PlaybackConfig { - #[serde(default)] - pub source: crate::sources::PlaybackSourceKind, - /// Unknown / future keys NESTED inside this section, retained across - /// load→save so a section written by a newer binary is not silently - /// stripped by an older one (issue #938 — the section-level companion - /// of [`AppConfig::extra`]). Omitted from JSON while empty and - /// skipped in the TypeScript export. - #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] - #[ts(skip)] - pub extra: BTreeMap, -} - -/// Lenient read of one `updates.channel` value (4.7.0, issue #678): the -/// channel, plus the warning to log when the document carried a spelling this -/// binary does not know. -/// -/// Why lenient: `UpdateChannel` is new in 4.7.0, and a plain enum field makes -/// serde reject the WHOLE document — which `load_config` answers by -/// quarantining `config.json` to `config.json.bak` and booting on defaults, so -/// one unrecognised spelling would cost the user every setting they have (a -/// config written by a newer binary, or a hand-edit, would do it). The rest of -/// the document survives instead. -/// -/// Deliberately scoped to this field: the pre-existing enums -/// ([`SpotifyConfig::client_secret_state`] and friends) still reject the whole -/// document, so that inconsistency stays visible rather than half-fixed here. -fn lenient_update_channel(raw: &serde_json::Value) -> (UpdateChannel, Option) { - // The happy path goes through the enum's own `Deserialize`, so the - // lowercase wire spelling keeps living in `#[serde(rename_all)]` alone. - match serde_json::from_value::(raw.clone()) { - Ok(channel) => (channel, None), - Err(_) => ( - UpdateChannel::Stable, - Some(format!( - "updates.channel: unrecognised value {raw}; using \"stable\" (the rest of the \ - config is kept)" - )), - ), - } -} - -/// `deserialize_with` for [`UpdatesConfig::channel`]: [`lenient_update_channel`] -/// plus its warning. A MISSING key never reaches here — `#[serde(default)]` on -/// the field still supplies the default channel. -fn deserialize_update_channel<'de, D>(deserializer: D) -> Result -where - D: serde::Deserializer<'de>, -{ - let raw = serde_json::Value::deserialize(deserializer)?; - let (channel, warning) = lenient_update_channel(&raw); - if let Some(warning) = warning { - // The `[CFG]` prefix belongs at the log site (`test_config_log_tags_…` - // scans for it there, and the message is reused verbatim below). - log::warn!("[CFG] {warning}"); - } - Ok(channel) -} - -#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)] -#[ts(export, export_to = "../../src/lib/types-generated/")] -pub struct AppConfig { - #[serde(default)] - pub spotify: SpotifyConfig, - #[serde(default)] - pub teams: TeamsConfig, - #[serde(default)] - pub polling: PollingConfig, - #[serde(default)] - pub logging: LoggingConfig, - /// Release channel the updater reads (issue #678). - #[serde(default)] - pub updates: UpdatesConfig, - /// Playback source selection (5.0.0, issue #862). `Auto` is the - /// documented default — try the OS media-session source first, - /// fall back to Spotify. Additive on `AppConfig` with - /// `#[serde(default)]`, so a pre-5.0 config file loads as `Auto` - /// and the on-disk byte shape does not change. - #[serde(default)] - pub playback: PlaybackConfig, - #[serde(default)] - pub autostart: bool, - /// Desktop-notification classes (4.7.0 / issue #675). Additive on - /// `AppConfig` with a serde default so pre-4.7 config files load - /// unchanged (`schema_version` untouched, `extra` retention untouched). - #[serde(default)] - pub notifications: NotificationsConfig, - /// UI locale for the native surfaces (tray + application menu) and the - /// webview dictionaries (4.7.0, issue #674). `None` — the documented - /// pre-4.7 state and every config file written before this release — - /// reads as `"en"`. Only `en`/`de`/`fr` (or a `de-AT`-style variant of - /// one) are meaningful; an unknown tag falls back to English and is - /// logged (see `crate::i18n::resolve_tag`). The frontend mirrors this - /// value as the single source of truth for the language picker. - #[serde(default)] - pub locale: Option, - /// Persisted "pause sync" deadline set from the tray's snooze submenu - /// (4.7.0, S9 / issue #677). RFC3339 **in UTC** (`2026-09-17T13:45:00Z`): - /// the value has to survive a relaunch, a timezone change and a DST - /// transition without moving, so the three presets are computed from the - /// local clock and stored as that instant - /// (`crate::polling::poll_once::snooze`). `None` — the documented default - /// and every config file written before this release — means polling runs - /// normally. - /// - /// An expired (or unparsable) value is INERT everywhere — [`snooze_status`] - /// and therefore the tray, the chip and the poller's gate all treat "no - /// longer a live deadline" as "not snoozed" — and it is removed by the first - /// WRITE that touches the document: [`clamp_snooze`] through - /// [`clamped_config`] on any save, `poll_once::clear_snooze_if_expired` on - /// the iteration that observes the expiry, or the tray's startup cleaner. - /// `load_config` only reports it (see [`snooze_expired_deadline`]), because - /// a reader that fixes the field in memory would hide the expiry from the - /// writers that can actually correct `config.json`. - #[serde(default)] - pub snooze_until: Option, - #[serde(default)] - pub status_rules: StatusRulesConfig, - /// Issue #869: the named presence profiles the user can switch from the - /// tray, a hotkey or `presencejam --profile `. Each profile is a - /// typed overlay of a SUBSET of the base config — `status_format`, - /// `clear_on_pause`, `availability_sync`, the gate flags, the preferred - /// presence, a rules subset and notifications. Switching resolves at - /// READ time through [`effective_config`], so a profile change NEVER - /// rewrites the on-disk base values. Empty by default, additive with - /// `#[serde(default)]` so a pre-5.0 config file loads with no profiles - /// and the `effective_config` overlay is a no-op. - #[serde(default)] - pub presence_profiles: Vec, - /// Issue #869: the name of the active profile. `None` — the documented - /// pre-5.0 default — means "use the base configuration". A value that - /// does not match any profile name is treated as `None` by - /// [`clamp_presence_profiles`] (the active id is cleared at the IPC - /// boundary) so a hand-edited config cannot silently land on a phantom - /// profile and never resolve. - #[serde(default)] - pub active_profile: Option, - /// Global-shortcut bindings (issue #676). Additive with - /// `#[serde(default)]`, so a pre-4.7 config file loads with the documented - /// default accelerators rather than with no shortcuts at all. - #[serde(default)] - pub shortcuts: ShortcutsConfig, - /// Config schema version (issue #379). Files written before 4.3.0 carry - /// no such key and load as version 1. - #[serde(default = "default_schema_version")] - pub schema_version: u32, - /// The document's revision, raised once per accepted save (issue #943). - /// - /// Settings renders in both the main window and the detached pane, each - /// webview holding its own copy loaded once, so two writers can be a save - /// apart. A save whose payload is OLDER than the revision on disk is - /// rejected instead of silently reverting the other window's change (see - /// [`STALE_REVISION_MARKER`]), and [`emit_config_changed`] carries the new - /// revision so every window can adopt the document that was actually - /// persisted. - /// - /// `0` for a fresh install, for every file written before this field - /// existed, and for a client that does not send the field yet — which is why - /// `0` is stamped upward rather than treated as stale. The first save from - /// such a payload stores `1`. - /// - /// Skipped in the TS export: the field is the Rust-side guard until the - /// store half of #943 ships, and exporting it would make the generated - /// `AppConfig` require a member the frontend does not construct yet. - #[serde(default)] - #[ts(skip)] - pub revision: u64, - /// Unknown / future top-level keys, retained across load→save so a newer - /// config file is never silently stripped by an older binary (issue #379). - /// Skipped in the TS export (and omitted from JSON while empty) so - /// `extra` stays byte-identical when empty. (Note: `schema_version` - /// serializes on every save, so full-file byte-identity is not claimed - /// across versions — only `extra` introduces no new bytes.) - #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] - #[ts(skip)] - pub extra: BTreeMap, -} - -impl Default for SpotifyConfig { - fn default() -> Self { - Self { - client_id: String::new(), - client_secret_set: false, - client_secret_state: ClientSecretState::Absent, - redirect_uri: default_redirect_uri(), - extra: BTreeMap::new(), - } - } -} - -impl Default for TeamsConfig { - fn default() -> Self { - Self { - status_format: default_status_format(), - clear_on_pause: default_clear_on_pause(), - profanity_filter: default_profanity_filter(), - profanity_placeholder: default_profanity_placeholder(), - start_minimized: default_start_minimized(), - availability_sync: default_availability_sync(), - presence_gate: default_presence_gate(), - profanity_extra_words: Vec::new(), - respect_manual_status: default_respect_manual_status(), - gate_when_out_of_office: default_gate_when_out_of_office(), - gate_when_presenting: default_gate_when_presenting(), - // Issue #873: `0` = off (the default; an untouched config - // behaves exactly as today). The clamp runs through - // `clamp_teams` so any non-zero value lands in 60..=3600. - idle_away_after_seconds: 0, - // Issue #867: 0 disables the pre-meeting suppression window - // (the previous behaviour, which also matches the documented - // default); any non-zero value is capped at 60 by `clamp_teams`. - pre_meeting_suppress_minutes: 0, - paused_status_format: default_paused_status_format(), - stopped_status_format: default_stopped_status_format(), - preferred_presence: PreferredPresenceConfig::default(), - extra: BTreeMap::new(), - } - } -} - -impl Default for PollingConfig { - fn default() -> Self { - Self { - default_interval_seconds: default_interval_seconds(), - minimum_interval_seconds: default_min_interval_seconds(), - max_interval_seconds: default_max_interval_seconds(), - expiry_buffer_seconds: default_expiry_buffer_seconds(), - pause_backoff_max_seconds: default_pause_backoff_max(), - extra: BTreeMap::new(), - } - } -} - -impl Default for LoggingConfig { - fn default() -> Self { - Self { - enabled: default_logging_enabled(), - log_level: default_log_level(), - max_file_size_mb: default_max_file_size_mb(), - keep_files: default_keep_files(), - // Issue #877: opt-in. A user with a busy rule set could - // otherwise grow the log without bound; the Dashboard's - // "Activity" card is the always-on reading surface. - presence_history: false, - extra: BTreeMap::new(), - } - } -} - -// `Default::default()` can't be derived because `LoggingConfig` uses -// `default_*()` helper functions to seed its fields with non-`Default` -// values (a default log level, a default "enabled" flag). The helper -// calls are intentional, not a candidate for `#[derive(Default)]`. -#[allow(clippy::derivable_impls)] -impl Default for AppConfig { - fn default() -> Self { - Self { - spotify: SpotifyConfig::default(), - teams: TeamsConfig::default(), - polling: PollingConfig::default(), - logging: LoggingConfig::default(), - updates: UpdatesConfig::default(), - playback: PlaybackConfig::default(), - autostart: false, - notifications: NotificationsConfig::default(), - locale: None, - snooze_until: None, - status_rules: StatusRulesConfig::default(), - presence_profiles: Vec::new(), - active_profile: None, - shortcuts: ShortcutsConfig::default(), - extra: BTreeMap::new(), - schema_version: default_schema_version(), - revision: 0, - } - } -} - -/// Field-level patch for the `spotify` section (CfgDiag#0, issue #535). -/// -/// `client_secret_set` and `client_secret_state` are deliberately absent: -/// both are derived display values filled in by [`with_keychain_flags`] from -/// the OS keychain, so a client must not be able to assert them. -#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS)] -#[ts(export, export_to = "../../src/lib/types-generated/")] -pub struct SpotifyPatch { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub redirect_uri: Option, -} - -/// Field-level patch for the `teams` section (CfgDiag#0, issue #535). -/// -/// Every user-facing field of [`TeamsConfig`] is carried here, so no -/// `update_config` caller is pushed onto the whole-document `save_config` -/// path for want of a field (issue #767). Every value is re-clamped by -/// [`clamp_teams`] through [`clamped_config`] after the merge. -#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS)] -#[ts(export, export_to = "../../src/lib/types-generated/")] -pub struct TeamsPatch { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub status_format: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub clear_on_pause: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub profanity_filter: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub profanity_placeholder: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub start_minimized: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub availability_sync: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub presence_gate: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub profanity_extra_words: Option>, - /// Finding #635 (issue #635): never overwrite a Teams status message the - /// user set by hand. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub respect_manual_status: Option, - /// Finding #637 (issue #637): also gate the status write while the user - /// is marked out of office. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub gate_when_out_of_office: Option, - /// Issue #872: also gate while the OS reports a full-screen app, - /// presentation mode or Quiet Time. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub gate_when_presenting: Option, - /// Issue #873: idle threshold in seconds; `0` disables the gate. - /// Clamped into `60..=3600` (or left at `0`) by `clamp_teams`. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(type = "number | null")] - pub idle_away_after_seconds: Option, - /// Issue #867: minutes before a meeting starts that the write is - /// suppressed; `0` means during the meeting only. Capped at 60 by - /// `clamp_teams`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pre_meeting_suppress_minutes: Option, - /// S4 (issue #672): the user-templatable paused/stopped status texts, part - /// of the same field-level patch as the rest of the section — a Settings - /// save that omitted them would leave the stored text untouched. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub paused_status_format: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub stopped_status_format: Option, - /// Issue #866: the preferred-presence config. Replaced wholesale when - /// present, exactly like the rule lists above — there is no per-field - /// addressing, and the Settings pane edits the section as one form. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub preferred_presence: Option, -} - -/// Field-level patch for the `updates` section (issue #767). -/// -/// The channel is the section's only user-facing field. Unlike -/// [`UpdatesConfig::channel`] on the config itself, an unrecognised -/// spelling here REJECTS the patch rather than being read leniently: a -/// partial write is a deliberate IPC action by a caller that already holds -/// the rendered channel list, so a value outside the enum means the two -/// sides disagree — and the stored document is then left untouched, which -/// is the safe answer for a partial write. -#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS)] -#[ts(export, export_to = "../../src/lib/types-generated/")] -pub struct UpdatesPatch { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub channel: Option, -} - -/// Field-level patch for the `shortcuts` section (issue #767). -/// -/// `None` in the stored [`ShortcutsConfig`] is the documented "unbound" -/// state, so a patch names a slot with a string and leaves it out to keep -/// the stored binding. A blank string is normalised to unbound by -/// `clamp_shortcuts`, exactly as -/// [`crate::commands::shortcuts::configured_binding`] already reads it. -#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS)] -#[ts(export, export_to = "../../src/lib/types-generated/")] -pub struct ShortcutsPatch { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub toggle_playback: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub toggle_sync: Option, -} - -/// Field-level patch for the `polling` section (CfgDiag#0, issue #535). Every -/// value is re-clamped by [`clamped_config`] after the merge. -#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS)] -#[ts(export, export_to = "../../src/lib/types-generated/")] -pub struct PollingPatch { - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(type = "number | null")] - pub default_interval_seconds: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(type = "number | null")] - pub minimum_interval_seconds: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(type = "number | null")] - pub max_interval_seconds: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(type = "number | null")] - pub expiry_buffer_seconds: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(type = "number | null")] - pub pause_backoff_max_seconds: Option, -} - -/// Field-level patch for the `logging` section (CfgDiag#0, issue #535). -/// -/// Issue #767: the rotation settings (`max_file_size_mb`, `keep_files`) and -/// the issue #877 `presence_history` mirror are user-facing too, so they -/// ride the same partial-write path instead of forcing a whole-document -/// save. Re-clamped by [`clamp_logging`]. -#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS)] -#[ts(export, export_to = "../../src/lib/types-generated/")] -pub struct LoggingPatch { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub enabled: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub log_level: Option, - /// Rotation ceiling in mebibytes; clamped to `1..=500` by `clamp_logging`. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(type = "number | null")] - pub max_file_size_mb: Option, - /// Archived log files to retain; clamped to `1..=20` by `clamp_logging`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub keep_files: Option, - /// Issue #877: mirror the bounded status-decision history to disk. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub presence_history: Option, -} - -/// Field-level patch for the `status_rules` section (CfgDiag#0, issue #535). -/// -/// A named list is replaced wholesale — there is no per-entry addressing, so -/// naming `quiet_hours` means "this is the new list". Omitting it leaves the -/// stored list untouched. -#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS)] -#[ts(export, export_to = "../../src/lib/types-generated/")] -pub struct StatusRulesPatch { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub quiet_hours: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub track_rules: Option>, -} - -/// Field-level patch for the `notifications` section (issue #789). Each -/// class is an `Option` so a toggle names only its own class; an -/// absent class leaves the stored flag untouched, mirroring the per-field -/// shape of every other section patch above. -#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS)] -#[ts(export, export_to = "../../src/lib/types-generated/")] -pub struct NotificationsPatch { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub track_change: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sync_stopped: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub auth_required: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub update_staged: Option, -} - -/// Field-level update to [`AppConfig`] for callers that only know part of -/// the document (CfgDiag#0, issue #535). -/// -/// `save_config` is a whole-document replace, so every caller had to already -/// hold a complete, current `AppConfig`. A caller that did not — the setup -/// wizard being the first — silently reset everything it omitted, which is -/// the backend half of the #531 config-clobber family. -/// -/// **Every field of every nested patch is `Option` and skipped when absent.** -/// That shape is load-bearing, not stylistic: typing a section as the whole -/// `TeamsConfig` would deserialize a patch of `{"teams": -/// {"status_format": "x"}}` into a fully populated `TeamsConfig` whose -/// omitted fields took their *defaults*, and assigning that section would -/// reset the user's `start_minimized`, profanity and presence settings — the -/// very clobber this command exists to prevent, reproduced one level down. -/// An absent key MUST leave the stored value untouched. -/// -/// **Every section the app can write is represented here** (issue #767). -/// A section missing from this struct is a section whose only write path is -/// the whole-document `save_config` — the clobber class this type exists to -/// end. `presence_profiles` / `active_profile` are deliberately absent: the -/// tray's profile picker owns them and reaches `save_config` under the write -/// guard itself (issue #869), so a second addressing scheme would only add a -/// way for the two to disagree. -#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS)] -#[ts(export, export_to = "../../src/lib/types-generated/")] -pub struct ConfigPatch { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub spotify: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub teams: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub polling: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub logging: Option, - /// Release channel for the updater (issue #767). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub updates: Option, - /// Playback source selection (issue #767). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub playback: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub autostart: Option, - // Issue #789: one-class toggle + pause-sync deadline for the same - // merge-instead-of-replace path. `snooze_until` is `Option>` - // so the three states stay distinct: absent leaves the stored deadline - // untouched, `Some(None)` clears it, `Some(Some(..))` sets it. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub notifications: Option, - // Issue #789: the pause-sync deadline for the same partial-write path. - // `Option>` so the three states stay distinct: absent leaves the - // stored deadline untouched, `Some(None)` clears it, `Some(Some(..))` sets - // a new one. `deserialize_with` is what makes the middle state reachable — - // see `deserialize_optional_tag`. - #[serde( - default, - deserialize_with = "deserialize_optional_tag", - skip_serializing_if = "Option::is_none" - )] - pub snooze_until: Option>, - /// UI locale (issue #767). Same three-state shape as `snooze_until`: - /// absent leaves the stored tag untouched, `Some(None)` clears it back to - /// the documented "follow the OS" default, `Some(Some(..))` sets it. The - /// value is canonicalised by `clamp_locale` on the way out, so a patch - /// cannot persist a tag the app cannot render. - #[serde( - default, - deserialize_with = "deserialize_optional_tag", - skip_serializing_if = "Option::is_none" - )] - pub locale: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub status_rules: Option, - /// Global-shortcut bindings (issue #767). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub shortcuts: Option, -} - -/// Field-level patch for the `playback` section (issue #767). -/// -/// `source` is the section's only field; `Auto` is the documented default and -/// is what an untouched config resolves to. -#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS)] -#[ts(export, export_to = "../../src/lib/types-generated/")] -pub struct PlaybackPatch { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source: Option, -} - -/// Merge a patch into `base`, field by field. Only the fields the patch -/// explicitly names are overwritten; everything else — including `extra` and -/// the binary-owned `schema_version` — is left exactly as it was. -/// -/// Pure, so the merge guarantee is unit-testable without touching disk. -pub fn apply_patch(base: &mut AppConfig, patch: &ConfigPatch) { - if let Some(p) = &patch.spotify { - if let Some(v) = &p.client_id { - base.spotify.client_id = v.clone(); - } - if let Some(v) = &p.redirect_uri { - base.spotify.redirect_uri = v.clone(); - } - } - if let Some(p) = &patch.teams { - if let Some(v) = &p.status_format { - base.teams.status_format = v.clone(); - } - if let Some(v) = p.clear_on_pause { - base.teams.clear_on_pause = v; - } - if let Some(v) = p.profanity_filter { - base.teams.profanity_filter = v; - } - if let Some(v) = &p.profanity_placeholder { - base.teams.profanity_placeholder = v.clone(); - } - if let Some(v) = p.start_minimized { - base.teams.start_minimized = v; - } - if let Some(v) = p.availability_sync { - base.teams.availability_sync = v; - } - if let Some(v) = p.presence_gate { - base.teams.presence_gate = v; - } - if let Some(v) = &p.profanity_extra_words { - base.teams.profanity_extra_words = v.clone(); - } - // Issue #767: the presence gates and the two bounded windows join the - // patch, so a caller that owns one toggle no longer has to hold the - // whole document to change it. `clamp_teams` re-bounds them after the - // merge, exactly as it does for a full save. - if let Some(v) = p.respect_manual_status { - base.teams.respect_manual_status = v; - } - if let Some(v) = p.gate_when_out_of_office { - base.teams.gate_when_out_of_office = v; - } - if let Some(v) = p.gate_when_presenting { - base.teams.gate_when_presenting = v; - } - if let Some(v) = p.idle_away_after_seconds { - base.teams.idle_away_after_seconds = v; - } - if let Some(v) = p.pre_meeting_suppress_minutes { - base.teams.pre_meeting_suppress_minutes = v; - } - if let Some(v) = &p.paused_status_format { - base.teams.paused_status_format = v.clone(); - } - if let Some(v) = &p.stopped_status_format { - base.teams.stopped_status_format = v.clone(); - } - if let Some(v) = &p.preferred_presence { - base.teams.preferred_presence = v.clone(); - } - } - if let Some(p) = &patch.polling { - if let Some(v) = p.default_interval_seconds { - base.polling.default_interval_seconds = v; - } - if let Some(v) = p.minimum_interval_seconds { - base.polling.minimum_interval_seconds = v; - } - if let Some(v) = p.max_interval_seconds { - base.polling.max_interval_seconds = v; - } - if let Some(v) = p.expiry_buffer_seconds { - base.polling.expiry_buffer_seconds = v; - } - if let Some(v) = p.pause_backoff_max_seconds { - base.polling.pause_backoff_max_seconds = v; - } - } - if let Some(p) = &patch.logging { - if let Some(v) = p.enabled { - base.logging.enabled = v; - } - if let Some(v) = &p.log_level { - base.logging.log_level = v.clone(); - } - // Issue #767: the rotation ceiling, the retained-archive count and the - // #877 history mirror. `clamp_logging` re-bounds the two numbers. - if let Some(v) = p.max_file_size_mb { - base.logging.max_file_size_mb = v; - } - if let Some(v) = p.keep_files { - base.logging.keep_files = v; - } - if let Some(v) = p.presence_history { - base.logging.presence_history = v; - } - } - // Issue #767: the release channel. Replaced wholesale (it is a single - // field), and an unrecognised spelling never reaches this function — - // serde rejects the patch instead, leaving the stored document alone. - if let Some(p) = &patch.updates { - if let Some(v) = p.channel { - base.updates.channel = v; - } - } - // Issue #767: the playback source selection. - if let Some(p) = &patch.playback { - if let Some(v) = p.source { - base.playback.source = v; - } - } - if let Some(v) = patch.autostart { - base.autostart = v; - } - if let Some(p) = &patch.notifications { - if let Some(v) = p.track_change { - base.notifications.track_change = v; - } - if let Some(v) = p.sync_stopped { - base.notifications.sync_stopped = v; - } - if let Some(v) = p.auth_required { - base.notifications.auth_required = v; - } - if let Some(v) = p.update_staged { - base.notifications.update_staged = v; - } - } - // Absent leaves the stored deadline untouched; `Some(None)` clears an - // active pause; `Some(Some(..))` sets a new one. The write path's - // `clamp_snooze` still drops an expired value afterwards. - if let Some(v) = &patch.snooze_until { - base.snooze_until = v.clone(); - } - if let Some(p) = &patch.status_rules { - if let Some(v) = &p.quiet_hours { - base.status_rules.quiet_hours = v.clone(); - } - if let Some(v) = &p.track_rules { - base.status_rules.track_rules = v.clone(); - } - } - // Issue #767: `locale` mirrors `snooze_until`'s three states, so a patch - // that names no locale leaves the user's tag exactly as it was. - if let Some(v) = &patch.locale { - base.locale = v.clone(); - } - // Issue #767: shortcut bindings, one slot at a time. `clamp_shortcuts` - // normalises a blank binding to "unbound" on the write path. - if let Some(p) = &patch.shortcuts { - if let Some(v) = &p.toggle_playback { - base.shortcuts.toggle_playback = Some(v.clone()); - } - if let Some(v) = &p.toggle_sync { - base.shortcuts.toggle_sync = Some(v.clone()); - } - } -} - -pub fn config_dir() -> Result { - // Maintained replacement for the unmaintained `dirs` crate (issue #418): - // `directories::BaseDirs::new()` resolves the same platform config - // roots (XDG_CONFIG_HOME/~/.config on Linux, ~/Library/Application - // Support on macOS, %APPDATA% on Windows) and preserves the - // `/PresenceJam` layout and 0o700 creation below. - let base_dir = directories::BaseDirs::new() - .map(|b| b.config_dir().to_path_buf()) - .ok_or_else(|| { - "Failed to get config directory: BaseDirs::new() returned None".to_string() - })?; - - let app_dir = base_dir.join("PresenceJam"); - - if !app_dir.exists() { - fs::create_dir_all(&app_dir).map_err(|e| { - format!( - "Failed to create config directory '{}': {}", - app_dir.display(), - e - ) - })?; - #[cfg(unix)] - { - let _ = fs::set_permissions(&app_dir, std::fs::Permissions::from_mode(0o700)); - } - log::info!("[CFG] Created config directory at '{}'", app_dir.display()); - } - - Ok(app_dir) -} - -pub fn get_config_path() -> Result { - let dir = config_dir()?; - Ok(dir.join("config.json")) -} - -/// Set when `load_config` finds a corrupt config.json and quarantines it to -/// `.bak` (issue #379). Diagnostics-visible via -/// [`config_was_quarantined`]; warn-log-only otherwise — no other channel is -/// touched by this slice. -static CONFIG_QUARANTINED: AtomicBool = AtomicBool::new(false); - -/// Diagnostics-visible flag: true once this process has quarantined a corrupt -/// config.json to `.bak` and fallen back to defaults (issue #379). -pub fn config_was_quarantined() -> bool { - CONFIG_QUARANTINED.load(Ordering::SeqCst) -} - -/// Backup path alongside the original: `config.json` → `config.json.bak`. -/// -/// Shared with the config-import command (4.7.0, S5), which moves the -/// outgoing file here before an imported document replaces it. -pub(crate) fn quarantine_backup_path(path: &std::path::Path) -> PathBuf { - let mut backup = path.as_os_str().to_owned(); - backup.push(".bak"); - PathBuf::from(backup) -} - -/// The sidecar an imported document is staged in before it replaces the live -/// config: `.import.tmp`. -/// -/// Deliberately distinct from `atomic_write_json`'s `.tmp`, so an -/// import in flight and an ordinary save can never consume or clear each -/// other's staged bytes. Beside the live file, so the final rename stays on -/// one volume and is atomic (issue #939). -fn staged_import_path(path: &std::path::Path) -> PathBuf { - let mut staged = path.as_os_str().to_owned(); - staged.push(".import.tmp"); - PathBuf::from(staged) -} - -/// Replace the config at `path` with `json`, keeping the outgoing document as -/// `.bak` (issue #939). -/// -/// Order is the whole point. The incoming bytes are written and fsynced to a -/// same-directory sidecar FIRST, so a replacement that cannot be written — disk -/// full, quota, permission denied, an antivirus lock — fails with the live -/// `config.json` still in place. Only then is the live file moved aside and -/// the staged copy renamed over it; both of those are renames, so the window in -/// which no live config exists is a single syscall wide rather than spanning a -/// write. -/// -/// If that final rename fails, the backup is moved back before the error -/// returns, so the user is left with the document they had rather than with -/// only a `.bak` that nothing in the app restores. Every failure path removes -/// the staged sidecar, and a stale one from a crashed import is pre-cleared -/// exactly as `atomic_write_json` does for `config.json.tmp` (#135 path A). -/// -/// The command layer's `import_config` runs this inside the config write -/// guard (issue #946), so no competing writer can slip between the file -/// replacement and the reload that publishes it. -fn replace_with_backup(path: &std::path::Path, json: &str) -> Result<(), String> { - let staged = staged_import_path(path); - - if let Err(e) = fs::remove_file(&staged) { - if e.kind() != std::io::ErrorKind::NotFound { - return Err(format!( - "Failed to remove stale import temp file '{}': {}", - staged.display(), - e - )); - } - } - - // 0600 at creation, never chmod-after-create, for the same reason - // `atomic_write_json` does it: no window in which the config is - // world-readable. - #[cfg(unix)] - let mut file = fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&staged) - .map_err(|e| { - format!( - "Failed to create import temp file '{}': {}", - staged.display(), - e - ) - })?; - - #[cfg(not(unix))] - let mut file = fs::File::create(&staged).map_err(|e| { - format!( - "Failed to create import temp file '{}': {}", - staged.display(), - e - ) - })?; - - if let Err(e) = file.write_all(json.as_bytes()) { - let _ = fs::remove_file(&staged); - return Err(format!( - "Failed to write import temp file '{}': {}", - staged.display(), - e - )); - } - if let Err(e) = file.sync_all() { - let _ = fs::remove_file(&staged); - return Err(format!( - "Failed to sync import temp file '{}': {}", - staged.display(), - e - )); - } - drop(file); - - // The incoming document is now fully durable on disk, so moving the live - // file aside can no longer lose the user's settings. - let backup = quarantine_backup_path(path); - let had_live = path.exists(); - if had_live { - if let Err(e) = fs::rename(path, &backup) { - let _ = fs::remove_file(&staged); - return Err(format!( - "Failed to move the current config to '{}': {}", - backup.display(), - e - )); - } - log::info!( - "[CFG] import: previous config moved to '{}'", - backup.display() - ); - } - - if let Err(e) = fs::rename(&staged, path) { - log::error!( - "[CFG] import: FAILED to install the imported config at '{}': {}", - path.display(), - e - ); - let _ = fs::remove_file(&staged); - if had_live { - match fs::rename(&backup, path) { - Ok(()) => log::warn!("[CFG] import: the previous config was moved back into place"), - Err(rollback) => log::error!( - "[CFG] import: rollback FAILED - the previous config is at '{}': {}", - backup.display(), - rollback - ), - } - } - return Err(format!( - "Failed to install the imported config at '{}': {}", - path.display(), - e - )); - } - - // Same parent-directory fsync `atomic_write_json` performs: the renames - // above are only durable once the directory entry is flushed too. - #[cfg(unix)] - if let Some(parent) = path.parent() { - if let Ok(dir) = fs::File::open(parent) { - if let Err(e) = dir.sync_all() { - log::warn!( - "[CFG] Failed to fsync config dir '{}': {}", - parent.display(), - e - ); - } - } - } - Ok(()) -} - -/// Bare file name of the quarantine backup for `path` when one exists, -/// else `None`. Deliberately a bare name and never an absolute path, so the -/// diagnostics snapshot can surface it without breaching the #409 -/// no-absolute-path rule (CfgDiag#2, issue #537). -fn quarantine_backup_name_for(path: &std::path::Path) -> Option { - let backup = quarantine_backup_path(path); - if backup.is_file() { - backup - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - } else { - None - } -} - -/// [`quarantine_backup_name_for`] against this process's real config path. -/// `None` when nothing was quarantined or the `.bak` has since been removed. -pub fn config_quarantine_backup_name() -> Option { - quarantine_backup_name_for(&get_config_path().ok()?) -} - -/// Rename a corrupt config file alongside itself (`.bak`), raise the -/// diagnostics-visible quarantine flag, and warn. Never fails the load: -/// rename errors are logged and swallowed so the caller falls back to -/// defaults either way (issue #379). -fn quarantine_corrupt_config(path: &std::path::Path, parse_err: impl std::fmt::Display) -> PathBuf { - let backup = quarantine_backup_path(path); - match fs::rename(path, &backup) { - Ok(()) => log::warn!( - "[CFG] corrupt config '{}' quarantined to '{}': {} — loading defaults", - path.display(), - backup.display(), - parse_err - ), - Err(rename_err) => log::warn!( - "[CFG] corrupt config '{}' failed to parse ({}) and quarantine rename to '{}' failed ({}); loading defaults", - path.display(), - parse_err, - backup.display(), - rename_err - ), - } - CONFIG_QUARANTINED.store(true, Ordering::SeqCst); - backup -} - -/// Every top-level key [`AppConfig`] has a typed field for (issue #926). -/// -/// [`config_from_sections`] reads these one at a time and everything else lands -/// in [`AppConfig::extra`] — the same partition `#[serde(flatten)]` performs -/// when serde parses the document in one call, written out so that one bad -/// section can be replaced by its default without rejecting the rest. -/// `typed_config_keys_match_the_serialized_schema` fails if this list and the -/// struct ever disagree. -const TYPED_CONFIG_KEYS: [&str; 16] = [ - "spotify", - "teams", - "polling", - "logging", - "updates", - "playback", - "autostart", - "notifications", - "locale", - "snooze_until", - "status_rules", - "presence_profiles", - "active_profile", - "shortcuts", - "schema_version", - "revision", -]; - -/// The JSON type of `value`, for a log line that names the shape of a bad root -/// without echoing a whole (possibly multi-megabyte) document. -fn json_kind(value: &serde_json::Value) -> &'static str { - match value { - serde_json::Value::Null => "null", - serde_json::Value::Bool(_) => "a boolean", - serde_json::Value::Number(_) => "a number", - serde_json::Value::String(_) => "a string", - serde_json::Value::Array(_) => "an array", - serde_json::Value::Object(_) => "an object", - } -} - -/// Deserialize ONE typed field out of a config document, falling back to -/// `fallback` when that field alone does not match the schema (issue #926). -/// -/// Per-field, not per-document. A key the file omits takes `fallback` -/// silently — what `#[serde(default)]` has always done — while a key that is -/// PRESENT but invalid takes it with a `[CFG]` warning naming the key, which is -/// the observable replacement for the old all-or-nothing parse. A `null` value -/// is "present but invalid" for every non-`Option` field and a value for an -/// `Option` one, so the field's own type decides, not a special case here. -fn field_or_fallback( - root: &serde_json::Map, - key: &str, - fallback: T, -) -> T { - let Some(raw) = root.get(key) else { - return fallback; - }; - match serde_json::from_value::(raw.clone()) { - Ok(value) => value, - Err(e) => { - log::warn!( - "[CFG] config field '{}' is invalid ({}) — using its default, the rest of the config is kept", - key, - e - ); - fallback - } - } -} - -/// Build an [`AppConfig`] from a config document's root object, one typed field -/// at a time (issue #926). -/// -/// A section that no longer matches the schema costs exactly that section — its -/// default, warned about by [`field_or_fallback`] — instead of the whole -/// document, which is what one wrong-typed, out-of-range or misspelled value -/// used to cost (the file was quarantined and the app booted on defaults). The -/// document's scalar fields take the same route, so `"autostart": "yes"` cannot -/// take quiet hours down with it either. -/// -/// Unknown top-level keys are still retained in [`AppConfig::extra`] (issue -/// #379), exactly as the `#[serde(flatten)]` field collected them under the -/// single-pass parse. -fn config_from_sections(root: serde_json::Map) -> AppConfig { - let mut config = AppConfig { - spotify: field_or_fallback(&root, "spotify", Default::default()), - teams: field_or_fallback(&root, "teams", Default::default()), - polling: field_or_fallback(&root, "polling", Default::default()), - logging: field_or_fallback(&root, "logging", Default::default()), - updates: field_or_fallback(&root, "updates", Default::default()), - playback: field_or_fallback(&root, "playback", Default::default()), - autostart: field_or_fallback(&root, "autostart", Default::default()), - notifications: field_or_fallback(&root, "notifications", Default::default()), - locale: field_or_fallback(&root, "locale", Default::default()), - snooze_until: field_or_fallback(&root, "snooze_until", Default::default()), - status_rules: field_or_fallback(&root, "status_rules", Default::default()), - presence_profiles: field_or_fallback(&root, "presence_profiles", Default::default()), - active_profile: field_or_fallback(&root, "active_profile", Default::default()), - shortcuts: field_or_fallback(&root, "shortcuts", Default::default()), - schema_version: field_or_fallback(&root, "schema_version", default_schema_version()), - revision: field_or_fallback(&root, "revision", 0), - extra: BTreeMap::new(), - }; - for (key, value) in root { - if !TYPED_CONFIG_KEYS.contains(&key.as_str()) { - config.extra.insert(key, value); - } - } - config -} - -/// Tighten a loose `config.json` to 0600 (issue #135 path A), best-effort -/// since issue #802. -/// -/// Idempotent on a file that is already 0600. Unix-only: Windows' default ACL -/// is already user-only, so there is nothing to tighten there. -/// -/// Best-effort, NOT a precondition: a mode that cannot be READ (EROFS on a -/// read-only or ostree mount, EPERM on a file owned by another user, an -/// ACL-managed path) or cannot be CHANGED is logged and ignored. Both used to -/// be `?`-propagated, so `load_config` failed outright on a perfectly readable -/// file — startup logged "no config found", `AppState.config` stayed `None`, -/// the Settings and Dashboard stores fell back to built-in defaults, and -/// because a Settings save posts the whole document, the next save persisted -/// those defaults over the user's real file. Hardening a file we can already -/// read is a courtesy; refusing to read it is a data-loss path. -#[cfg(unix)] -fn tighten_config_permissions(path: &std::path::Path) { - let current = match fs::metadata(path) { - Ok(metadata) => metadata.permissions(), - Err(e) => { - log::warn!( - "[CFG] Could not read the mode of config file '{}': {} — loading it anyway", - path.display(), - e - ); - return; - } - }; - let current_mode = current.mode() & 0o777; - if current_mode == 0o600 { - return; - } - log::warn!( - "[CFG] Tightening config.json mode from {:o} to 0600 (issue #135)", - current_mode - ); - let mut tightened = current; - tightened.set_mode(0o600); - if let Err(e) = fs::set_permissions(path, tightened) { - log::warn!( - "[CFG] Could not chmod config file '{}' to 0600: {} — loading it anyway", - path.display(), - e - ); - } -} - -pub fn load_config() -> Result { - load_config_from(&get_config_path()?).map(|config| { - with_keychain_flags(config, || { - crate::keychain::cached_spotify_client_secret_presence() - }) - }) -} - -/// Path-taking core of [`load_config`]: the file I/O, the section-by-section -/// parse and the normalization, with the keychain stamping left to the public -/// entry point — so this half is testable against real files with no keychain -/// probe, the same shape [`import_config_document`] uses. -fn load_config_from(path: &std::path::Path) -> Result { - if !path.exists() { - log::info!( - "[CFG] Config file not found at '{}', using defaults", - path.display() - ); - return Ok(AppConfig::default()); - } - - // Issue #135 path A: tighten the mode of any pre-existing config.json that - // was created loose by an older PresenceJam version (default umask 022 → - // 0644). Best-effort since issue #802 — see `tighten_config_permissions`. - #[cfg(unix)] - tighten_config_permissions(path); - - let mut file = fs::File::open(path) - .map_err(|e| format!("Failed to open config file '{}': {}", path.display(), e))?; - - let mut contents = String::new(); - file.read_to_string(&mut contents) - .map_err(|e| format!("Failed to read config file '{}': {}", path.display(), e))?; - - let mut config = match serde_json::from_str::(&contents) { - // Issue #926: the document IS an object — load it field by field, so a - // section that no longer matches the schema costs exactly that - // section's default and nothing else. - Ok(serde_json::Value::Object(root)) => config_from_sections(root), - // Anything else is not a config: a bare array/string/number/null - // root, or text that is not JSON at all. Quarantine, exactly as the - // single-pass parse answered those two shapes before. - Ok(other) => { - quarantine_corrupt_config( - path, - format!("expected a JSON object, found {}", json_kind(&other)), - ); - return Ok(AppConfig::default()); - } - Err(e) => { - // Issue #379: never lose the evidence — quarantine the corrupt - // file to `.bak` alongside the original and boot on - // defaults. Observable via `config_was_quarantined()`. - quarantine_corrupt_config(path, &e); - return Ok(AppConfig::default()); - } - }; - // Issue #916: an unknown-key bucket is `#[serde(flatten)]` with no - // entry-level filter, so a `client_secret` a hand-edit or another tool left - // at any level was deserialized, handed to the webview by the `load_config` - // command and re-serialized on the next save — a credential crossing the - // IPC boundary in plaintext, in a file SECURITY.md promises is - // keychain-only. The legacy migration owns the DISK copy (it moves the - // value into the keychain, or deliberately leaves it on a conflict); this - // keeps the value out of the document the webview receives. Every load path - // funnels through here — the startup load and the `load_config` command - // alike — so there is no second place to remember. - let stripped_secrets = strip_client_secret_from_extras(&mut config); - if stripped_secrets > 0 { - log::warn!( - "[CFG] config: stripped {} client_secret key(s) from unknown keys — the Spotify client secret is keychain-only (issue #9)", - stripped_secrets - ); - } - // CfgDiag#1 (#536): the version dispatcher runs BEFORE the clamps, so a - // migration can never have its rewritten values re-clamped away, and - // `schema_version` is raised even for a file that was never saved by - // this binary. - let from_version = config.schema_version; - migrate_config(&mut config, from_version); - clamp_polling(&mut config.polling); - clamp_teams(&mut config.teams); - clamp_rules(&mut config.status_rules); - clamp_logging(&mut config.logging); - // Issue #869: enforce name uniqueness + ≤ 32 chars + active-profile - // validity on every load, mirroring the other `clamp_*` calls. The - // active-profile pointer is cleared if the named profile has been - // removed (a hand-edited config or an upgrade that dropped profiles - // cannot silently land on a phantom id). - clamp_presence_profiles(&mut config.presence_profiles, &mut config.active_profile); - // Issue #767: same two clamps as `clamped_config`, so the document a load - // hands the webview already carries the canonical tag and the normalised - // bindings. The reader does not persist anything here — the values reach - // disk on the next guarded write, exactly like every other clamp. - clamp_locale(&mut config); - clamp_shortcuts(&mut config.shortcuts); - // 4.7.0 (S9, issue #677): an expired snooze is reported here and REMOVED by - // the guarded writers below, never by this reader. - // - // The distinction is load-bearing twice over. (a) `load_config` runs on - // paths that hold no config-write guard — the startup load, the - // `load_config` command, `update_config`'s cold read — so writing the file - // from here could clobber a concurrent save and break the #297 invariant - // that the file and the in-memory copy agree. (b) An expired value must stay - // VISIBLE to the consumer that can persist its removal: `poll_once`'s - // `SnoozeGate::Expired` arm and the tray's startup cleaner both read the - // stored field. Clearing it here made the disk drift permanent — the - // in-memory copy looked clean, so nothing ever rewrote `config.json`, and - // the line below repeated on every launch. - if snooze_expired_deadline(&config, chrono::Utc::now()) { - log::info!("[CFG] snooze: the stored deadline had already passed — it is ignored and cleared on the next write"); - } - - log::info!("[CFG] Loaded configuration from '{}'", path.display()); - Ok(config) -} - -/// The persisted `logging` section, read without a full config load -/// (4.7.0, S5). -/// -/// The log plugin is registered on the Tauri builder *before* the `setup` -/// hook runs, so the rotating file target needs its size and retention -/// settings before [`load_config`] is reached. Deliberately narrow: -/// no migration, no quarantine side effects, and **no keychain probe** — -/// `load_config` runs moments later and a second probe at startup is a real -/// macOS prompt risk (see [`with_keychain_flags`]). -/// -/// A missing, unreadable or unparsable file yields the defaults; the real -/// load still handles the corrupt-file case. -pub fn logging_config_for_startup() -> LoggingConfig { - let mut logging = match get_config_path() - .ok() - .and_then(|path| fs::read_to_string(path).ok()) - { - Some(contents) => match serde_json::from_str::(&contents) { - Ok(cfg) => cfg.logging, - Err(e) => { - log::warn!( - "[CFG] startup log-rotation read: config unparsable ({}); using log defaults", - e - ); - LoggingConfig::default() - } - }, - None => LoggingConfig::default(), - }; - clamp_logging(&mut logging); - logging -} - -/// Populate derived/display fields that are not persisted to disk. -/// -/// Covers the two Spotify keychain views: `client_secret_set` (the pre-#560 -/// `Present`-only flag) and `client_secret_state` (the tri-state that can say -/// "the keychain could not answer"). See issues #9 and #560. -/// -/// One presence result feeds both. A fresh warm keychain observation avoids -/// an OS round trip; a cold or expired cache falls back to the direct -/// tri-state probe, which still notices credentials changed through the OS UI. -fn with_keychain_flags( - config: AppConfig, - presence: impl FnOnce() -> crate::keychain::KeychainPresence, -) -> AppConfig { - stamp_keychain_flags(config, presence()) -} - -fn stamp_keychain_flags( - mut config: AppConfig, - presence: crate::keychain::KeychainPresence, -) -> AppConfig { - config.spotify.client_secret_set = - matches!(presence, crate::keychain::KeychainPresence::Present); - config.spotify.client_secret_state = ClientSecretState::from(&presence); - config -} - -/// Frontend event emitted (once per process) when the legacy-plaintext -/// migration finds a *different* secret already in the OS keychain. -/// -/// The Settings view should listen for this event and prompt the user to -/// run Settings → Reconnect Spotify. See issue #376. -pub const SPOTIFY_SECRET_CONFLICT_EVENT: &str = "spotify-secret-conflict"; -/// One-shot startup migration for the legacy `spotify.client_secret` -/// field (≤ v2.5.0): write it to -/// the OS keychain and strip the plaintext from the file. Idempotent -/// and safe to call on every startup. -/// -/// Conflict policy: if the keychain already holds a *different* -/// secret, the migration is a no-op (we don't clobber a working -/// keychain entry with another install's plaintext, and we do NOT delete -/// the plaintext unilaterally — the user may need it). The user resolves -/// the conflict via Settings → Reconnect Spotify. See audit Q3 and -/// issues #9 and #376. -/// -/// Bounded notification: the conflict is surfaced exactly once per process -/// (see `migrate_legacy_client_secret_with_app`; a process-wide flag guards -/// the emit) — there is no retry loop or timeout that auto-deletes the -/// plaintext. Manual step: after Reconnect Spotify stores the current -/// secret in the keychain, the next launch either completes the migration -/// (keychain empty / identical value → plaintext stripped) or re-emits -/// this event while the stale plaintext is still present. -/// Log-only variant kept for backward compatibility (no `AppHandle` -/// available at some call sites). Prefer -/// `migrate_legacy_client_secret_with_app`, which additionally surfaces a -/// keychain conflict to the UI via [`SPOTIFY_SECRET_CONFLICT_EVENT`]. -pub fn migrate_legacy_client_secret() { - run_legacy_secret_migration(); -} - -/// Startup migration with user-visible conflict surfacing (issue #376). -/// -/// Runs the same migration as [`migrate_legacy_client_secret`] and returns -/// the observable outcome so the caller can persist it (issue #813); when -/// the outcome is [`LegacySecretOutcome::ConflictKeychainDiffers`], emits a -/// one-time [`SPOTIFY_SECRET_CONFLICT_EVENT`] so Settings can prompt -/// Settings → Reconnect Spotify (payload carries the manual step). -/// All other outcomes are silent apart from the usual `[CFG]` logs. -pub fn migrate_legacy_client_secret_with_app(app: &tauri::AppHandle) -> LegacySecretOutcome { - let outcome = run_legacy_secret_migration(); - if outcome == LegacySecretOutcome::ConflictKeychainDiffers { - emit_spotify_secret_conflict_once(app); - } - outcome -} -/// Observable outcome of one [`run_legacy_secret_migration`] pass. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum LegacySecretOutcome { - /// No `spotify.client_secret` plaintext field (or it was empty): - /// nothing to do. Also returned when the config file is missing, - /// unreadable, or unparsable, or a keychain write / file rewrite - /// failed part-way (plaintext left on disk in those cases). - NoLegacyField, - /// Plaintext migrated into an empty keychain (or the keychain - /// already held the identical value) and the strip pass ran. - Migrated, - /// Keychain already holds a *different* secret: plaintext deliberately - /// left on disk. The caller must surface this via - /// [`SPOTIFY_SECRET_CONFLICT_EVENT`]. - ConflictKeychainDiffers, -} -/// Pure decision step of the migration: given the legacy plaintext (if any) -/// and the current keychain read, decide the outcome without touching disk -/// or the keychain. Unit-tested directly (issue #376). -fn decide_legacy_secret_outcome( - plaintext: Option<&str>, - keychain: &Result, -) -> LegacySecretOutcome { - let plaintext = match plaintext { - Some(s) if !s.is_empty() => s, - _ => return LegacySecretOutcome::NoLegacyField, - }; - match keychain { - Ok(existing) if existing == plaintext => LegacySecretOutcome::Migrated, - Ok(_) => LegacySecretOutcome::ConflictKeychainDiffers, - Err(_) => LegacySecretOutcome::Migrated, - } -} -/// Process-wide guard so the conflict event fires at most once per launch, -/// no matter how often the migration entry points are called. -static CONFLICT_EVENT_SENT: AtomicBool = AtomicBool::new(false); -/// Emit [`SPOTIFY_SECRET_CONFLICT_EVENT`] unless already sent this process. -/// Follows the `let _ = app.emit(...)` pattern used in `poll_once.rs`; -/// the payload tells Settings to prompt Reconnect Spotify. Returns true -/// when this call performed the (single) emit. -fn emit_spotify_secret_conflict_once(app: &tauri::AppHandle) -> bool { - if CONFLICT_EVENT_SENT.swap(true, Ordering::AcqRel) { - return false; - } - log::warn!( - "[CFG] migrate_legacy_client_secret: EMIT {} event (prompt Settings → Reconnect Spotify)", - SPOTIFY_SECRET_CONFLICT_EVENT - ); - let _ = app.emit( - SPOTIFY_SECRET_CONFLICT_EVENT, - crate::events::SpotifySecretConflict { - action: "reconnect-spotify".to_string(), - message: "The Spotify client secret in config.json differs from the one in the OS keychain. Open Settings → Reconnect Spotify to resolve. The legacy plaintext is left untouched until then.".to_string(), - }, - ); - true -} - -/// Bare file name of the sidecar that keeps a conflicting legacy plaintext -/// (issue #803): `config.json.legacy-secret`, beside `config.json`. -const LEGACY_SECRET_SIDECAR_NAME: &str = "config.json.legacy-secret"; - -/// Write a copy of a conflicting legacy `client_secret` beside `config.json` -/// and return the sidecar's BARE file name (issue #803). -/// -/// The migration deliberately leaves the plaintext in `config.json` when the -/// keychain already holds a different value — but `save_config` serialises -/// `AppConfig`, which has no `client_secret` field, so the next save from -/// anywhere (a Settings toggle, a tray snooze, the poller's snooze cleanup) -/// removed the only remaining copy while the app kept authenticating with the -/// stale keychain value. The user could not recover it afterwards: it was shown -/// nowhere in the UI and the file no longer held it. The sidecar is a copy the -/// app never rewrites, so the "the plaintext is not deleted" promise holds past -/// the next write. -/// -/// The document holds exactly the one key plus a note, and is never read back -/// by the app — it is the user's copy, for Settings → Reconnect Spotify — which -/// is why this logs the FILE NAME and never the value. -/// -/// The write goes through the same atomic-replace, fsync-the-directory helper -/// the config writer uses, so the sidecar is created 0600 on Unix (create_new + -/// mode) and user-only by default ACL on Windows, with no window in which it is -/// world-readable. -fn write_legacy_secret_sidecar( - config_path: &std::path::Path, - secret: &str, -) -> Result { - let sidecar = config_path.with_file_name(LEGACY_SECRET_SIDECAR_NAME); - let document = serde_json::to_string_pretty(&serde_json::json!({ - "client_secret": secret, - "note": "Legacy Spotify client secret kept from config.json: the OS keychain already held a different value. Resolve via Settings → Reconnect Spotify, then delete this file.", - })) - .map_err(|e| format!("Failed to serialize the legacy-secret sidecar: {}", e))?; - atomic_write_json(&sidecar, &document)?; - let name = sidecar - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - .ok_or_else(|| "Legacy-secret sidecar has no file name".to_string())?; - log::warn!( - "[CFG] migrate_legacy_client_secret: the conflicting plaintext is kept in '{}' as well — resolve it via Settings → Reconnect Spotify, then delete that file", - name - ); - Ok(name) -} - -/// Executes the migration IO and returns its observable outcome. -fn run_legacy_secret_migration() -> LegacySecretOutcome { - let path = match get_config_path() { - Ok(p) => p, - Err(e) => { - log::warn!( - "[CFG] migrate_legacy_client_secret: config path unavailable: {}", - e - ); - return LegacySecretOutcome::NoLegacyField; - } - }; - if !path.exists() { - return LegacySecretOutcome::NoLegacyField; // Fresh install — nothing to migrate. - } - let contents = match fs::read_to_string(&path) { - Ok(s) => s, - Err(e) => { - log::warn!("[CFG] migrate_legacy_client_secret: read failed: {}", e); - return LegacySecretOutcome::NoLegacyField; - } - }; - // Parse as raw Value so the pre-v2.6.0 nested `spotify.client_secret` field - // can be inspected and removed BEFORE the typed parse. (`SpotifyConfig` - // declares no such field, so `serde_json::from_str::` would drop - // the value into the section's unknown-key bucket — see issue #938 — and - // leave it in the file the migration is supposed to clean.) - let mut root: serde_json::Value = match serde_json::from_str(&contents) { - Ok(v) => v, - Err(e) => { - log::warn!("[CFG] migrate_legacy_client_secret: parse failed: {}", e); - return LegacySecretOutcome::NoLegacyField; - } - }; - let plaintext = legacy_client_secret(&root); - let keychain_read = crate::keychain::get_spotify_client_secret(); - let outcome = decide_legacy_secret_outcome(plaintext.as_deref(), &keychain_read); - match (&outcome, &keychain_read) { - (LegacySecretOutcome::NoLegacyField, _) => { - log::debug!("[CFG] migrate_legacy_client_secret: no legacy plaintext field"); - return outcome; - } - (LegacySecretOutcome::ConflictKeychainDiffers, Ok(existing)) => { - // Conflict check: if keychain already holds a *different* secret, - // don't clobber it. Leave the plaintext in place; the user can - // resolve via Settings → Reconnect Spotify (surfaced via - // `spotify-secret-conflict`; see `migrate_legacy_client_secret_with_app`). - let plaintext = plaintext.unwrap_or_default(); - log::warn!( - "[CFG] migrate_legacy_client_secret: keychain holds a different secret; leaving config.json untouched (user should Reconnect)" - ); - log::warn!( - "[CFG] migrate_legacy_client_secret: plaintext.len={}, keychain.len={}", - plaintext.len(), - existing.len() - ); - // Issue #803: the plaintext stays in `config.json` (that is the - // documented promise), but a copy also goes into a sidecar the app - // never rewrites, because the next unrelated save would otherwise be - // its last appearance anywhere. - if let Err(e) = write_legacy_secret_sidecar(&path, &plaintext) { - log::warn!( - "[CFG] migrate_legacy_client_secret: could not write the legacy-secret sidecar: {}", - e - ); - } - return outcome; - } - _ => { - // Keychain empty (the typical pre-v2.6.0-upgrader case), or it - // already holds the identical value (strip-only). Write the - // plaintext into the keychain only when the keychain is empty. - if keychain_read.is_err() { - log::info!("[CFG] migrate_legacy_client_secret: keychain empty, writing plaintext into keychain"); - // `plaintext` is `Some(non-empty)` here: `decide_*` only - // returns `Migrated` for `Some(non-empty)` input. - let plaintext = plaintext.unwrap_or_default(); - if let Err(e) = crate::keychain::store_spotify_client_secret(&plaintext) { - log::warn!( - "[CFG] migrate_legacy_client_secret: keychain write failed: {} (plaintext left in config.json)", - e - ); - return LegacySecretOutcome::NoLegacyField; - } - } else { - log::info!( - "[CFG] migrate_legacy_client_secret: keychain already holds this value, stripping plaintext only" - ); - } - } - } - // Strip EVERY `client_secret` key, not only the documented pre-v2.6.0 - // `spotify.client_secret`: a hand-edited or third-party file can nest the - // same credential under any path, and the migration has just taken - // responsibility for the value it read (issue #916). - strip_client_secret_keys(&mut root); - let new_contents = match serde_json::to_string_pretty(&root) { - Ok(s) => s, - Err(e) => { - log::warn!( - "[CFG] migrate_legacy_client_secret: re-serialise failed: {}", - e - ); - return LegacySecretOutcome::NoLegacyField; - } - }; - if let Err(e) = atomic_write_json(&path, &new_contents) { - log::warn!( - "[CFG] migrate_legacy_client_secret: atomic rewrite failed: {} (keychain has the value, plaintext remains on disk)", - e - ); - } else { - log::info!( - "[CFG] migrate_legacy_client_secret: SUCCESS — plaintext stripped from config.json" - ); - } - LegacySecretOutcome::Migrated -} - -pub(crate) fn atomic_write_json(path: &std::path::Path, json: &str) -> Result<(), String> { - let temp_path = path.with_extension("tmp"); - - // Issue #135 path A: create the temp file with mode 0600 atomically. - // Pre-clear any stale sidecar from a previous crash (between temp-write - // and rename). Without this pre-clear, create_new(true) would error with - // AlreadyExists on a leftover `.tmp`, turning a one-off crash into a - // permanent save failure until the user manually deletes the sidecar. - // Deletion of a non-existent file is fine — we ignore NotFound. - if let Err(e) = fs::remove_file(&temp_path) { - if e.kind() != std::io::ErrorKind::NotFound { - return Err(format!( - "Failed to remove stale temp file '{}': {}", - temp_path.display(), - e - )); - } - } - // OpenOptions::create_new(true) prevents racing with a leftover sidecar; - // .mode(0o600) sets the mode at file-creation time (no chmod-after-create - // window where config.json could briefly sit world-readable). The - // subsequent rename() preserves the source mode on POSIX. On Windows, - // the new file inherits the user-only default ACL of the parent. - #[cfg(unix)] - let mut file = fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&temp_path) - .map_err(|e| { - format!( - "Failed to create temp file '{}': {}", - temp_path.display(), - e - ) - })?; - - #[cfg(not(unix))] - let mut file = fs::File::create(&temp_path).map_err(|e| { - format!( - "Failed to create temp file '{}': {}", - temp_path.display(), - e - ) - })?; - - file.write_all(json.as_bytes()) - .map_err(|e| format!("Failed to write temp file '{}': {}", temp_path.display(), e))?; - - file.sync_all() - .map_err(|e| format!("Failed to sync temp file '{}': {}", temp_path.display(), e))?; - - std::fs::rename(&temp_path, path) - .map_err(|e| format!("Failed to rename temp file to '{}': {}", path.display(), e))?; - #[cfg(unix)] - { - if let Some(parent) = path.parent() { - if let Ok(dir) = std::fs::File::open(parent) { - if let Err(e) = dir.sync_all() { - log::warn!( - "[CFG] Failed to fsync config dir '{}': {}", - parent.display(), - e - ); - } - } - } - } - - Ok(()) -} - -/// The config as it will actually be persisted: `clamp_polling` applied. -/// -/// `save_config` writes a clamped copy, so the caller must store THIS value -/// in `AppState` rather than its own unclamped input — otherwise a value the -/// UI can type (the number inputs' `min`/`max` attributes do not constrain a -/// typed value) lives in memory while a different one sits on disk, and the -/// two silently reconcile only on the next launch. See issue #297. -pub fn clamped_config(config: &AppConfig) -> AppConfig { - let mut cfg = config.clone(); - clamp_polling(&mut cfg.polling); - clamp_teams(&mut cfg.teams); - clamp_rules(&mut cfg.status_rules); - clamp_logging(&mut cfg.logging); - // Issue #869: clamp the profile list + active id on every save - // (mirrors `clamp_rules` and `clamp_teams`). The active-profile - // pointer is cleared if its name no longer matches — a Settings - // delete that removes the active profile must not leave a phantom - // pointer behind. - clamp_presence_profiles(&mut cfg.presence_profiles, &mut cfg.active_profile); - // 4.7.0 (S9, issue #677): a write that carries an already-expired deadline - // (a whole-document save from a stale draft, or a resume click that raced - // its own deadline) normalizes it away, so the in-memory copy, the file on - // disk and the tray can never disagree about a snooze being active. - clamp_snooze(&mut cfg, chrono::Utc::now()); - // Issue #767: the two clamps for the patch fields that did not exist when - // this function was written. Without them a `update_config` patch could - // persist a locale tag the app cannot render, or a blank shortcut binding - // that renders as set-up while registering nothing. - clamp_locale(&mut cfg); - clamp_shortcuts(&mut cfg.shortcuts); - // Issue #916: the write path strips too, so a payload that carries a - // `client_secret` in an unknown-key bucket cannot put a credential back - // into `config.json` (or into an export) on the way out. - strip_client_secret_from_extras(&mut cfg); - cfg -} +//! Configuration store split (issue #755). +//! +//! One concern per file; `crate::config::X` paths stay stable through the +//! re-exports below. + +pub mod clamp; +pub mod io; +pub mod migrate; +pub mod patch; +pub mod schema; +pub mod snooze; +pub mod transfer; + +pub use clamp::{ + clamp_presence_profiles, clamp_profile_id, clamp_rule_text, effective_config, + effective_snapshot, normalize_presence_pair, preferred_presence_expiry_duration, + preferred_presence_pair, profanity_extra_words_for_filter, PresencePair, MAX_PROFILE_ID_CHARS, + MAX_RULE_STATUS_CHARS, MAX_TRACK_RULE_DURATION_SECS, MAX_TRACK_RULE_SNOOZE_MINUTES, + PRESENCE_COMBINATIONS, TRACK_RULE_DAY_MINUTES, +}; +#[cfg(test)] +pub(crate) use io::atomic_write_json; +pub(crate) use io::quarantine_backup_path; +pub use io::{ + clamped_config, config_dir, config_quarantine_backup_name, config_was_quarantined, + emit_config_changed, get_config_path, load_config, logging_config_for_startup, save_config, + save_config_persisted, CONFIG_CHANGED_EVENT, STALE_REVISION_MARKER, +}; +pub use migrate::{ + migrate_legacy_client_secret, migrate_legacy_client_secret_with_app, stamp_schema_version, + LegacySecretOutcome, SCHEMA_VERSION, SPOTIFY_SECRET_CONFLICT_EVENT, +}; +pub use patch::{ + apply_patch, ConfigPatch, LoggingPatch, NotificationsPatch, PlaybackPatch, PollingPatch, + ShortcutsPatch, SpotifyPatch, StatusRulesPatch, TeamsPatch, UpdatesPatch, +}; +pub use schema::{ + apply_log_level, AppConfig, ClientSecretState, LoggingConfig, NotificationsConfig, + PlaybackConfig, PollingConfig, PreferredPresenceConfig, PresenceProfile, QuietHoursEntry, + ShortcutsConfig, SpotifyConfig, StatusRulesConfig, TeamsConfig, TrackRuleAction, + TrackRuleEntry, TrackRuleMatchKind, UpdateChannel, UpdatesConfig, + DEFAULT_TOGGLE_PLAYBACK_SHORTCUT, DEFAULT_TOGGLE_SYNC_SHORTCUT, +}; +pub use snooze::{ + clamp_snooze, snooze_deadline, snooze_expired_deadline, snooze_minutes_left, + snooze_preset_deadline, snooze_status, snooze_store_form, SnoozePreset, SnoozeStatus, +}; +pub use transfer::{ + export_document, export_file_name, import_config_document, prepare_import, PreparedImport, +}; -/// The persisted document's markers, read once (issues #938 and #943): the -/// stored `schema_version` and the stored `revision`. -/// -/// A missing, unreadable, unparsable or non-object file yields `(None, 0)`: -/// there is no version to protect and no revision to be behind, and a corrupt -/// file is about to be replaced by the save this is guarding anyway. A stored -/// `schema_version` that is not a `u32` is `None` for the same reason. -fn stored_document_markers(path: &std::path::Path) -> (Option, u64) { - let Ok(contents) = fs::read_to_string(path) else { - return (None, 0); +#[cfg(test)] +mod tests { + use super::clamp::{ + clamp_logging, clamp_polling, clamp_preferred_presence, clamp_rules, clamp_teams, + MAX_SHORTCUT_BINDING_CHARS, }; - let Ok(root) = serde_json::from_str::(&contents) else { - return (None, 0); + use super::io::{ + load_config_from, quarantine_backup_name_for, quarantine_corrupt_config, save_config_to, + staged_import_path, stamp_keychain_flags, with_keychain_flags, CONFIG_QUARANTINED, + TYPED_CONFIG_KEYS, }; - ( - root.get("schema_version") - .and_then(serde_json::Value::as_u64) - .and_then(|version| u32::try_from(version).ok()), - root.get("revision") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0), - ) -} - -/// Marker the stale-revision error starts with (issue #943), so the webview's -/// config store can tell "the settings changed in another window" apart from -/// any other save failure and re-load the document instead of retrying the same -/// payload. -pub const STALE_REVISION_MARKER: &str = "stale-config-revision"; - -/// Emitted after every accepted save (issue #943): `{"revision": u64, -/// "config": }`. -/// -/// The config-writing commands call [`emit_config_changed`] once a persist has -/// succeeded, so a second Settings webview adopts the stored state instead of -/// writing its own stale copy over it — the same shape the presence and tray -/// mirrors use. A tray snooze released this way reaches the Dashboard with no -/// remount. -pub const CONFIG_CHANGED_EVENT: &str = "config-changed"; - -/// Emit [`CONFIG_CHANGED_EVENT`] for the document that was just persisted, -/// returning its revision (issue #943). -/// -/// `persisted` is what [`save_config_persisted`] returned — the clamped, -/// revision-stamped document that is on disk — so what the other window renders -/// matches the file. Emission is best-effort, like every other app-level emit in -/// this codebase: a window that is not listening loses nothing, it reads the -/// same document on its next load. -pub fn emit_config_changed(app: &tauri::AppHandle, persisted: &AppConfig) -> u64 { - match serde_json::to_value(persisted) { - Ok(document) => { - let _ = app.emit( - CONFIG_CHANGED_EVENT, - crate::events::ConfigChanged { - revision: persisted.revision, - config: document, - }, - ); - } - Err(e) => log::warn!( - "[CFG] config-changed: the persisted document could not be serialized ({}); not emitting", - e - ), - } - persisted.revision -} - -pub fn save_config(config: &AppConfig) -> Result<(), String> { - save_config_persisted(config).map(|_| ()) -} - -/// Persist `config` and return the document that was written (issue #943). -/// -/// [`save_config`] is this function with the returned document dropped, for -/// callers that do not keep the config in memory. A caller that DOES — the -/// commands layer stores the persisted value in `AppState`, see #297 — should -/// use this one: the written document carries the next `revision`, and a caller -/// still holding the pre-save copy would be rejected as stale by its own next -/// save once it starts sending that revision. -pub fn save_config_persisted(config: &AppConfig) -> Result { - save_config_to(&get_config_path()?, config) -} - -/// Path-taking core of [`save_config_persisted`]: the normalization, the -/// stale-revision rejection, the newer-document refusal and the atomic write — -/// so the write path is testable against real files, the same shape -/// [`import_config_document`] uses. -fn save_config_to(path: &std::path::Path, config: &AppConfig) -> Result { - let (stored_version, stored_revision_of_file) = stored_document_markers(path); - - // Issue #943: a payload behind the document on disk is a second webview - // writing its own stale copy — the write that silently reverted the other - // window's change. Reject it instead: the caller re-loads, the user is told - // which window moved, and the newer document survives. - // - // `revision == 0` means the payload carries no revision at all (a frontend - // that does not send the field yet, or a fresh install), so it is stamped - // upward rather than rejected: rejecting it would make every save from such - // a client fail as soon as the first one succeeded, which is a worse failure - // than the one being fixed. The guard applies the moment a client sends the - // revision it loaded. - if config.revision != 0 && config.revision < stored_revision_of_file { - log::warn!( - "[CFG] refusing a stale config write to '{}': the stored document is at revision {} and this copy is at {}", - path.display(), - stored_revision_of_file, - config.revision - ); - return Err(format!( - "{STALE_REVISION_MARKER}: the settings were changed in another window (stored revision {stored_revision_of_file}, this copy is at revision {})", - config.revision - )); - } - - // Issue #938: never rewrite a document a NEWER binary wrote. The marker is - // the only record of which migrations have run, and this build sees none of - // that document's unknown keys: writing back would relabel it at this - // build's version — so the newer build's dispatcher skips its own - // migrations on the next launch — and drop the keys those migrations read. - // Leaving the file alone loses nothing, and the caller surfaces the error. - if let Some(stored) = stored_version { - if stored > SCHEMA_VERSION { - log::warn!( - "[CFG] refusing to overwrite config '{}': schema_version {} was written by a newer PresenceJam (this build writes {})", - path.display(), - stored, - SCHEMA_VERSION - ); - return Err(format!( - "The stored configuration was written by a newer version of PresenceJam (schema {stored}); leaving it untouched" - )); - } - } - - let mut cfg = clamped_config(config); - // CfgDiag#1 (#536): the client's `schema_version` is a suggestion, not an - // instruction — a stale payload can never lower the version, and since - // issue #938 it cannot raise one above a newer document's either. - stamp_schema_version(&mut cfg); - // Issue #943: strictly increasing, and never below either side's value, so - // two windows saving in sequence hand each other a rising token. - cfg.revision = stored_revision_of_file - .max(config.revision) - .saturating_add(1); - - let json = serde_json::to_string_pretty(&cfg) - .map_err(|e| format!("Failed to serialize config to JSON: {}", e))?; - - atomic_write_json(path, &json)?; - - log::info!( - "[CFG] Saved configuration to '{}' (revision {})", - path.display(), - cfg.revision - ); - Ok(cfg) -} - -// --------------------------------------------------------------------------- -// 4.7.0 (S5): config export / import. -// -// The Spotify client secret is keychain-only (issue #9), so an exported -// document is a *shareable* file: every `client_secret` key is stripped on -// the way out and an incoming document that carries one is refused outright, -// rather than silently dropping the plaintext the user asked us to import. -// --------------------------------------------------------------------------- - -/// Timestamp suffix of an export file name — `YYYYMMDD-HHMMSS`, UTC. -fn export_timestamp(at: chrono::DateTime) -> String { - at.format("%Y%m%d-%H%M%S").to_string() -} - -/// Name an export is offered under: -/// `presencejam-config--.json`. -pub fn export_file_name(version: &str, at: chrono::DateTime) -> String { - format!( - "presencejam-config-{}-{}.json", - version, - export_timestamp(at) - ) -} - -/// Walk every `client_secret` key in `value`, calling `visit(dotted_path, value)` -/// for each (issue #916). -/// -/// The ONE traversal behind [`client_secret_paths`] (export/import refusal) and -/// [`legacy_client_secret`] (the legacy-plaintext migration), so the two cannot -/// disagree about where a credential may hide. Nested objects AND arrays are -/// walked: a secret cannot escape by sitting inside the unknown-key retention -/// map, or inside a hand-written nested object, merely because the typed schema -/// has no such field. -fn walk_client_secret_keys( - value: &serde_json::Value, - visit: &mut impl FnMut(&str, &serde_json::Value), -) { - fn walk( - value: &serde_json::Value, - prefix: &str, - visit: &mut impl FnMut(&str, &serde_json::Value), - ) { - match value { - serde_json::Value::Object(map) => { - for (key, child) in map { - let path = if prefix.is_empty() { - key.clone() - } else { - format!("{}.{}", prefix, key) - }; - if key == "client_secret" { - visit(&path, child); - } - walk(child, &path, visit); - } - } - serde_json::Value::Array(items) => { - for (index, child) in items.iter().enumerate() { - walk(child, &format!("{}[{}]", prefix, index), visit); - } - } - _ => {} - } - } - walk(value, "", visit); -} - -/// Collect the dotted paths of every `client_secret` key anywhere in `value`. -/// -/// Only keys are matched — values are irrelevant to the decision, which is why -/// an explicit `null` or a nested object counts here (the import refusal wants -/// every shape) while [`legacy_client_secret`] wants a string. -fn client_secret_paths(value: &serde_json::Value) -> Vec { - let mut out = Vec::new(); - walk_client_secret_keys(value, &mut |path, _| out.push(path.to_string())); - out -} - -/// The plaintext credential the legacy migration acts on (issue #916): the -/// documented pre-v2.6.0 `spotify.client_secret` when the document has one, -/// otherwise the first `client_secret` key anywhere that holds a non-empty -/// string. -/// -/// Before this, only `spotify.client_secret` was read, so a credential at any -/// other path was neither migrated to the keychain nor removed from disk — it -/// was silently dropped. `None` means there is genuinely nothing to migrate. -fn legacy_client_secret(root: &serde_json::Value) -> Option { - let mut found: Vec<(String, String)> = Vec::new(); - walk_client_secret_keys(root, &mut |path, value| { - if let Some(text) = value.as_str().filter(|text| !text.is_empty()) { - found.push((path.to_string(), text.to_string())); - } - }); - let documented = found - .iter() - .find(|(path, _)| path == "spotify.client_secret"); - documented.or(found.first()).map(|(_, text)| text.clone()) -} - -/// Remove every `client_secret` key anywhere in the tree; returns how many -/// were removed. -fn strip_client_secret_keys(value: &mut serde_json::Value) -> usize { - let mut removed = 0; - match value { - serde_json::Value::Object(map) => { - if map.remove("client_secret").is_some() { - removed += 1; - } - for child in map.values_mut() { - removed += strip_client_secret_keys(child); - } - } - serde_json::Value::Array(items) => { - for child in items.iter_mut() { - removed += strip_client_secret_keys(child); - } - } - _ => {} - } - removed -} - -/// Remove `client_secret` keys from ONE unknown-key map (issue #916): the -/// top-level entry, plus any nested inside a retained unknown value. Returns how -/// many keys were removed, so the caller logs a single line. -/// -/// An unknown-key bucket is `#[serde(flatten)]` with no entry-level filter, so a -/// key the codebase treats as a credential everywhere else would otherwise be -/// deserialized, handed to the webview by the `load_config` command and -/// re-serialized on the next save. -fn strip_client_secret_from_extra(extra: &mut BTreeMap) -> usize { - let mut removed = if extra.remove("client_secret").is_some() { - 1 - } else { - 0 + use super::migrate::{ + decide_legacy_secret_outcome, default_schema_version, migrate_config, + write_legacy_secret_sidecar, LEGACY_SECRET_SIDECAR_NAME, }; - for value in extra.values_mut() { - removed += strip_client_secret_keys(value); - } - removed -} - -/// [`strip_client_secret_from_extra`] over every unknown-key bucket a config -/// carries: the document's own top-level map and each section's (issue #916; -/// the section maps are themselves issue #938). -fn strip_client_secret_from_extras(config: &mut AppConfig) -> usize { - let mut removed = strip_client_secret_from_extra(&mut config.extra); - // The list MUST stay exhaustive over every `extra` bucket the config - // carries. Three of them were missed (#938): `playback`, each entry of - // `presence_profiles`, and `teams.preferred_presence`. A `client_secret` - // left in one of those buckets is deserialized, handed to the webview by - // the `load_config` command and re-serialized on the next save — exactly - // the IPC crossing issue #916 forbids, reachable through a section that - // only gained its retention map later. - for extra in [ - &mut config.spotify.extra, - &mut config.teams.extra, - &mut config.teams.preferred_presence.extra, - &mut config.polling.extra, - &mut config.logging.extra, - &mut config.updates.extra, - &mut config.playback.extra, - &mut config.notifications.extra, - &mut config.status_rules.extra, - &mut config.shortcuts.extra, - ] { - removed += strip_client_secret_from_extra(extra); - } - for profile in &mut config.presence_profiles { - removed += strip_client_secret_from_extra(&mut profile.extra); - } - removed -} - -/// The document an export writes (4.7.0, S5): the persisted shape of `cfg`, -/// clamped exactly as [`save_config`] would write it, with every -/// `client_secret` key and both derived keychain views stripped. -/// -/// The secret strip is a guard, not the normal path — the secret lives in the -/// OS keychain and there is no typed field to carry it — but a secret reaching -/// a file the user is explicitly told to keep or share would undo the keychain -/// migration. Token material never enters `AppConfig` at all. -/// -/// `client_secret_set` / `client_secret_state` go too: they are display -/// projections of *this* machine's keychain (issue #560), so an exported file -/// that carried them would describe the exporting machine to whoever imports -/// it. `load_config` re-stamps both from the real keychain on the next read. -pub fn export_document(cfg: &AppConfig) -> Result { - let mut value = serde_json::to_value(clamped_config(cfg)) - .map_err(|e| format!("Failed to serialize config to JSON: {}", e))?; - let stripped = strip_client_secret_keys(&mut value); - if stripped > 0 { - log::warn!( - "[CFG] export: stripped {} client_secret key(s) from the exported document", - stripped - ); - } - if let Some(spotify) = value - .get_mut("spotify") - .and_then(serde_json::Value::as_object_mut) - { - spotify.remove("client_secret_set"); - spotify.remove("client_secret_state"); - } - // S9 (issue #975): the snooze deadline is RUNTIME state — "pause sync until - // then" on THIS machine — not a setting. An export is advertised as a - // shareable settings copy, so a file exported while sync was paused would - // otherwise hand the recipient the exporter's still-future deadline: that - // install performs no Spotify or Graph work until it passes, and nothing in - // the import flow says a pause came with the file. - if let Some(root) = value.as_object_mut() { - root.remove("snooze_until"); - // An export is a document this app wrote, so it carries the schema - // floor the binary is authoritative for (`stamp_schema_version`): the - // floor only ever raises the value, so a newer source is left alone. - let floor = root - .get("schema_version") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0) - .max(u64::from(SCHEMA_VERSION)); - root.insert("schema_version".into(), serde_json::json!(floor)); - } - serde_json::to_string_pretty(&value) - .map_err(|e| format!("Failed to serialize config to JSON: {}", e)) -} - -/// An imported document that passed validation and normalization. -#[derive(Debug)] -pub struct PreparedImport { - /// Migrated and clamped config, with the keychain views neutralized. - pub config: AppConfig, - /// The exact JSON [`import_config_document`] writes for it. - pub document: String, -} - -/// Validate and normalize an imported config document (4.7.0, S5). -/// -/// Refuses a document carrying a `client_secret` key anywhere (the pre-#560 -/// plaintext shape, or a hand-edited file) instead of quietly dropping it: -/// silently discarding a credential the user meant to import is worse than -/// telling them why it will not be imported. Every other field takes the same -/// route a file read off disk takes — the version migration and all the -/// clamps — so an import cannot introduce a value the UI could not have saved. -pub fn prepare_import(raw: &str) -> Result { - let value: serde_json::Value = - serde_json::from_str(raw).map_err(|e| format!("Imported file is not valid JSON: {}", e))?; - if !value.is_object() { - return Err( - "Imported file is not a PresenceJam configuration (expected a JSON object)".to_string(), - ); - } - - let secrets = client_secret_paths(&value); - if !secrets.is_empty() { - return Err(format!( - "Imported file carries a plaintext client_secret ({}); PresenceJam keeps the Spotify client secret in the OS keychain, never in config.json", - secrets.join(", ") - )); - } - // No strip pass here: `client_secret_paths` matches the *key* regardless of - // value, so every shape of it — a string, an explicit null, a nested object - // — was already refused above. Nothing can reach the document below. - - let mut config: AppConfig = serde_json::from_value(value).map_err(|e| { - format!( - "Imported file does not match the PresenceJam configuration schema: {}", - e - ) - })?; - - // Same ordering as `load_config`: the version dispatcher runs before the - // clamps, so a migration's rewritten values are never re-clamped away. - let from_version = config.schema_version; - migrate_config(&mut config, from_version); - clamp_polling(&mut config.polling); - clamp_teams(&mut config.teams); - clamp_rules(&mut config.status_rules); - clamp_logging(&mut config.logging); - // Issue #869: clamp the profile list + active id on every load - // (mirrors the other `clamp_*` calls). The active-profile pointer - // is cleared if its name no longer matches — a hand-edited config - // or an upgrade that dropped profiles cannot silently land on a - // phantom id. - clamp_presence_profiles(&mut config.presence_profiles, &mut config.active_profile); - // Issue #767: an imported document's locale tag and shortcut bindings take - // the same clamps as a load, so an import cannot introduce a value the UI - // could not have saved. - clamp_locale(&mut config); - clamp_shortcuts(&mut config.shortcuts); - // Deliberately NOT `stamp_schema_version`: `migrate_config` raises the - // version to the floor and passes a *newer* file through at its own - // version, exactly as `load_config` does. Stamping would relabel a - // newer document as if this binary had produced it. - - // The two keychain views describe the machine the file came from, never - // the importing one — `load_config` re-stamps them from the real keychain - // as soon as the import lands. - config.spotify.client_secret_set = false; - config.spotify.client_secret_state = ClientSecretState::Absent; - - // Issue #975: an imported document must never start life paused. The - // deadline belongs to the exporting machine's runtime state — the export - // above no longer writes it — and an older or hand-edited file that still - // carries a future one would silence this machine's polling until it - // passed, with nothing in the UI explaining why. - config.snooze_until = None; - // Same floor as the export path: raising an older document to this binary's - // schema is the migration the loader would apply anyway; a newer document is - // never relabelled (`stamp_schema_version` only ever raises). - stamp_schema_version(&mut config); - - // The rewritten document must not carry the key at all (the issue asserts on - // its absence, not on a null value), exactly as the export path does. - let mut value = serde_json::to_value(&config) - .map_err(|e| format!("Failed to serialize imported config to JSON: {}", e))?; - if let Some(root) = value.as_object_mut() { - root.remove("snooze_until"); - } - let document = serde_json::to_string_pretty(&value) - .map_err(|e| format!("Failed to serialize imported config to JSON: {}", e))?; - Ok(PreparedImport { config, document }) -} - -/// Replace the config at `path` with an imported document (4.7.0, S5). -/// -/// `confirm_overwrite` is the user's decision, asked **after** the document has -/// been validated and only when there is a current file to replace: a decline is -/// a clean no-op that leaves the live file byte-identical and writes no `.bak`. -/// The dialog itself lives in the command (it needs an `AppHandle`); this shape -/// keeps the decision itself testable against real files. -/// -/// The outgoing file is moved to `.bak` first (the same backup path the -/// corrupt-file quarantine uses), so an import is never a one-way door; a -/// missing current file is not an error (a fresh install has nothing to back up) -/// and does not prompt. Refuses before touching disk, so a rejected import -/// leaves both the live config and the previous `.bak` untouched. `Ok(None)` -/// means the user declined. -pub fn import_config_document( - raw: &str, - path: &std::path::Path, - confirm_overwrite: impl FnOnce() -> bool, -) -> Result, String> { - let prepared = prepare_import(raw)?; - - if path.exists() && !confirm_overwrite() { - log::info!( - "[CFG] import: DECLINED by the user; '{}' left untouched", - path.display() - ); - return Ok(None); - } - // Issue #939: stage the incoming document to a same-directory sidecar and - // only then move the live file aside (see `replace_with_backup`). Doing it - // the other way round — rename first, write second — meant a write that - // failed, or a process death between the two, left the user with NO live - // config at all: the next launch booted on defaults and their settings - // existed only in a `.bak` that nothing in the app restores. - replace_with_backup(path, &prepared.document)?; - log::info!( - "[CFG] import: configuration imported into '{}'", - path.display() - ); - Ok(Some(prepared.config)) -} - -#[cfg(test)] -mod tests { + use super::schema::{default_redirect_uri, default_status_format, lenient_update_channel}; + use super::snooze::{next_local_midnight_utc, resolve_local_forward}; + use super::transfer::{client_secret_paths, legacy_client_secret}; use super::*; + use crate::profanity; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + use std::sync::atomic::Ordering; static QUARANTINE_TEST_LOCK: parking_lot::Mutex<()> = parking_lot::Mutex::new(()); #[test] @@ -4205,7 +261,15 @@ mod tests { /// test survives reordering / splitting / renaming of adjacent code. #[test] fn test_atomic_write_json_does_not_remove_destination_first() { - let src = include_str!("config.rs"); + let src = concat!( + include_str!("schema.rs"), + include_str!("clamp.rs"), + include_str!("snooze.rs"), + include_str!("patch.rs"), + include_str!("migrate.rs"), + include_str!("io.rs"), + include_str!("transfer.rs") + ); // Find the function signature (the line that starts the body). let sig_idx = src .find("fn atomic_write_json(") @@ -6258,7 +2322,15 @@ mod tests { /// `include_str!` scan would match the test itself and pass vacuously. #[test] fn test_config_log_tags_use_cfg_prefix() { - let src = include_str!("config.rs"); + let src = concat!( + include_str!("schema.rs"), + include_str!("clamp.rs"), + include_str!("snooze.rs"), + include_str!("patch.rs"), + include_str!("migrate.rs"), + include_str!("io.rs"), + include_str!("transfer.rs") + ); let needles = [ concat!("log::", "info!("), concat!("log::", "warn!("), @@ -7544,7 +3616,18 @@ mod tests { /// in the issue does not deny it, for an unprivileged user or for root. #[test] fn test_config_mode_tightening_cannot_abort_the_load() { - let body = fn_body(include_str!("config.rs"), "fn tighten_config_permissions("); + let body = fn_body( + concat!( + include_str!("schema.rs"), + include_str!("clamp.rs"), + include_str!("snooze.rs"), + include_str!("patch.rs"), + include_str!("migrate.rs"), + include_str!("io.rs"), + include_str!("transfer.rs") + ), + "fn tighten_config_permissions(", + ); assert!( !body.contains('?'), "tighten_config_permissions must not propagate an error: a mode that \ diff --git a/src-tauri/src/config/patch.rs b/src-tauri/src/config/patch.rs new file mode 100644 index 00000000..3db8fc8c --- /dev/null +++ b/src-tauri/src/config/patch.rs @@ -0,0 +1,444 @@ +use super::clamp::deserialize_optional_tag; +use super::schema::{ + AppConfig, PreferredPresenceConfig, QuietHoursEntry, TrackRuleEntry, UpdateChannel, +}; +use serde::{Deserialize, Serialize}; +/// Field-level patch for the `spotify` section (CfgDiag#0, issue #535). +/// +/// `client_secret_set` and `client_secret_state` are deliberately absent: +/// both are derived display values filled in by [`with_keychain_flags`] from +/// the OS keychain, so a client must not be able to assert them. +#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS)] +#[ts(export, export_to = "../../src/lib/types-generated/")] +pub struct SpotifyPatch { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub redirect_uri: Option, +} + +/// Field-level patch for the `teams` section (CfgDiag#0, issue #535). +/// +/// Every user-facing field of [`TeamsConfig`] is carried here, so no +/// `update_config` caller is pushed onto the whole-document `save_config` +/// path for want of a field (issue #767). Every value is re-clamped by +/// [`clamp_teams`] through [`clamped_config`] after the merge. +#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS)] +#[ts(export, export_to = "../../src/lib/types-generated/")] +pub struct TeamsPatch { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status_format: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub clear_on_pause: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profanity_filter: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profanity_placeholder: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_minimized: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub availability_sync: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub presence_gate: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profanity_extra_words: Option>, + /// Finding #635 (issue #635): never overwrite a Teams status message the + /// user set by hand. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub respect_manual_status: Option, + /// Finding #637 (issue #637): also gate the status write while the user + /// is marked out of office. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gate_when_out_of_office: Option, + /// Issue #872: also gate while the OS reports a full-screen app, + /// presentation mode or Quiet Time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gate_when_presenting: Option, + /// Issue #873: idle threshold in seconds; `0` disables the gate. + /// Clamped into `60..=3600` (or left at `0`) by `clamp_teams`. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null")] + pub idle_away_after_seconds: Option, + /// Issue #867: minutes before a meeting starts that the write is + /// suppressed; `0` means during the meeting only. Capped at 60 by + /// `clamp_teams`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pre_meeting_suppress_minutes: Option, + /// S4 (issue #672): the user-templatable paused/stopped status texts, part + /// of the same field-level patch as the rest of the section — a Settings + /// save that omitted them would leave the stored text untouched. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub paused_status_format: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stopped_status_format: Option, + /// Issue #866: the preferred-presence config. Replaced wholesale when + /// present, exactly like the rule lists above — there is no per-field + /// addressing, and the Settings pane edits the section as one form. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preferred_presence: Option, +} + +/// Field-level patch for the `updates` section (issue #767). +/// +/// The channel is the section's only user-facing field. Unlike +/// [`UpdatesConfig::channel`] on the config itself, an unrecognised +/// spelling here REJECTS the patch rather than being read leniently: a +/// partial write is a deliberate IPC action by a caller that already holds +/// the rendered channel list, so a value outside the enum means the two +/// sides disagree — and the stored document is then left untouched, which +/// is the safe answer for a partial write. +#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS)] +#[ts(export, export_to = "../../src/lib/types-generated/")] +pub struct UpdatesPatch { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub channel: Option, +} + +/// Field-level patch for the `shortcuts` section (issue #767). +/// +/// `None` in the stored [`ShortcutsConfig`] is the documented "unbound" +/// state, so a patch names a slot with a string and leaves it out to keep +/// the stored binding. A blank string is normalised to unbound by +/// `clamp_shortcuts`, exactly as +/// [`crate::commands::shortcuts::configured_binding`] already reads it. +#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS)] +#[ts(export, export_to = "../../src/lib/types-generated/")] +pub struct ShortcutsPatch { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub toggle_playback: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub toggle_sync: Option, +} + +/// Field-level patch for the `polling` section (CfgDiag#0, issue #535). Every +/// value is re-clamped by [`clamped_config`] after the merge. +#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS)] +#[ts(export, export_to = "../../src/lib/types-generated/")] +pub struct PollingPatch { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null")] + pub default_interval_seconds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null")] + pub minimum_interval_seconds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null")] + pub max_interval_seconds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null")] + pub expiry_buffer_seconds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null")] + pub pause_backoff_max_seconds: Option, +} + +/// Field-level patch for the `logging` section (CfgDiag#0, issue #535). +/// +/// Issue #767: the rotation settings (`max_file_size_mb`, `keep_files`) and +/// the issue #877 `presence_history` mirror are user-facing too, so they +/// ride the same partial-write path instead of forcing a whole-document +/// save. Re-clamped by [`clamp_logging`]. +#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS)] +#[ts(export, export_to = "../../src/lib/types-generated/")] +pub struct LoggingPatch { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub log_level: Option, + /// Rotation ceiling in mebibytes; clamped to `1..=500` by `clamp_logging`. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null")] + pub max_file_size_mb: Option, + /// Archived log files to retain; clamped to `1..=20` by `clamp_logging`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub keep_files: Option, + /// Issue #877: mirror the bounded status-decision history to disk. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub presence_history: Option, +} + +/// Field-level patch for the `status_rules` section (CfgDiag#0, issue #535). +/// +/// A named list is replaced wholesale — there is no per-entry addressing, so +/// naming `quiet_hours` means "this is the new list". Omitting it leaves the +/// stored list untouched. +#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS)] +#[ts(export, export_to = "../../src/lib/types-generated/")] +pub struct StatusRulesPatch { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub quiet_hours: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub track_rules: Option>, +} + +/// Field-level patch for the `notifications` section (issue #789). Each +/// class is an `Option` so a toggle names only its own class; an +/// absent class leaves the stored flag untouched, mirroring the per-field +/// shape of every other section patch above. +#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS)] +#[ts(export, export_to = "../../src/lib/types-generated/")] +pub struct NotificationsPatch { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub track_change: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sync_stopped: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth_required: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub update_staged: Option, +} + +/// Field-level update to [`AppConfig`] for callers that only know part of +/// the document (CfgDiag#0, issue #535). +/// +/// `save_config` is a whole-document replace, so every caller had to already +/// hold a complete, current `AppConfig`. A caller that did not — the setup +/// wizard being the first — silently reset everything it omitted, which is +/// the backend half of the #531 config-clobber family. +/// +/// **Every field of every nested patch is `Option` and skipped when absent.** +/// That shape is load-bearing, not stylistic: typing a section as the whole +/// `TeamsConfig` would deserialize a patch of `{"teams": +/// {"status_format": "x"}}` into a fully populated `TeamsConfig` whose +/// omitted fields took their *defaults*, and assigning that section would +/// reset the user's `start_minimized`, profanity and presence settings — the +/// very clobber this command exists to prevent, reproduced one level down. +/// An absent key MUST leave the stored value untouched. +/// +/// **Every section the app can write is represented here** (issue #767). +/// A section missing from this struct is a section whose only write path is +/// the whole-document `save_config` — the clobber class this type exists to +/// end. `presence_profiles` / `active_profile` are deliberately absent: the +/// tray's profile picker owns them and reaches `save_config` under the write +/// guard itself (issue #869), so a second addressing scheme would only add a +/// way for the two to disagree. +#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS)] +#[ts(export, export_to = "../../src/lib/types-generated/")] +pub struct ConfigPatch { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spotify: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub teams: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub polling: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub logging: Option, + /// Release channel for the updater (issue #767). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub updates: Option, + /// Playback source selection (issue #767). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub playback: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub autostart: Option, + // Issue #789: one-class toggle + pause-sync deadline for the same + // merge-instead-of-replace path. `snooze_until` is `Option>` + // so the three states stay distinct: absent leaves the stored deadline + // untouched, `Some(None)` clears it, `Some(Some(..))` sets it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub notifications: Option, + // Issue #789: the pause-sync deadline for the same partial-write path. + // `Option>` so the three states stay distinct: absent leaves the + // stored deadline untouched, `Some(None)` clears it, `Some(Some(..))` sets + // a new one. `deserialize_with` is what makes the middle state reachable — + // see `deserialize_optional_tag`. + #[serde( + default, + deserialize_with = "deserialize_optional_tag", + skip_serializing_if = "Option::is_none" + )] + pub snooze_until: Option>, + /// UI locale (issue #767). Same three-state shape as `snooze_until`: + /// absent leaves the stored tag untouched, `Some(None)` clears it back to + /// the documented "follow the OS" default, `Some(Some(..))` sets it. The + /// value is canonicalised by `clamp_locale` on the way out, so a patch + /// cannot persist a tag the app cannot render. + #[serde( + default, + deserialize_with = "deserialize_optional_tag", + skip_serializing_if = "Option::is_none" + )] + pub locale: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status_rules: Option, + /// Global-shortcut bindings (issue #767). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shortcuts: Option, +} + +/// Field-level patch for the `playback` section (issue #767). +/// +/// `source` is the section's only field; `Auto` is the documented default and +/// is what an untouched config resolves to. +#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS)] +#[ts(export, export_to = "../../src/lib/types-generated/")] +pub struct PlaybackPatch { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, +} + +/// Merge a patch into `base`, field by field. Only the fields the patch +/// explicitly names are overwritten; everything else — including `extra` and +/// the binary-owned `schema_version` — is left exactly as it was. +/// +/// Pure, so the merge guarantee is unit-testable without touching disk. +pub fn apply_patch(base: &mut AppConfig, patch: &ConfigPatch) { + if let Some(p) = &patch.spotify { + if let Some(v) = &p.client_id { + base.spotify.client_id = v.clone(); + } + if let Some(v) = &p.redirect_uri { + base.spotify.redirect_uri = v.clone(); + } + } + if let Some(p) = &patch.teams { + if let Some(v) = &p.status_format { + base.teams.status_format = v.clone(); + } + if let Some(v) = p.clear_on_pause { + base.teams.clear_on_pause = v; + } + if let Some(v) = p.profanity_filter { + base.teams.profanity_filter = v; + } + if let Some(v) = &p.profanity_placeholder { + base.teams.profanity_placeholder = v.clone(); + } + if let Some(v) = p.start_minimized { + base.teams.start_minimized = v; + } + if let Some(v) = p.availability_sync { + base.teams.availability_sync = v; + } + if let Some(v) = p.presence_gate { + base.teams.presence_gate = v; + } + if let Some(v) = &p.profanity_extra_words { + base.teams.profanity_extra_words = v.clone(); + } + // Issue #767: the presence gates and the two bounded windows join the + // patch, so a caller that owns one toggle no longer has to hold the + // whole document to change it. `clamp_teams` re-bounds them after the + // merge, exactly as it does for a full save. + if let Some(v) = p.respect_manual_status { + base.teams.respect_manual_status = v; + } + if let Some(v) = p.gate_when_out_of_office { + base.teams.gate_when_out_of_office = v; + } + if let Some(v) = p.gate_when_presenting { + base.teams.gate_when_presenting = v; + } + if let Some(v) = p.idle_away_after_seconds { + base.teams.idle_away_after_seconds = v; + } + if let Some(v) = p.pre_meeting_suppress_minutes { + base.teams.pre_meeting_suppress_minutes = v; + } + if let Some(v) = &p.paused_status_format { + base.teams.paused_status_format = v.clone(); + } + if let Some(v) = &p.stopped_status_format { + base.teams.stopped_status_format = v.clone(); + } + if let Some(v) = &p.preferred_presence { + base.teams.preferred_presence = v.clone(); + } + } + if let Some(p) = &patch.polling { + if let Some(v) = p.default_interval_seconds { + base.polling.default_interval_seconds = v; + } + if let Some(v) = p.minimum_interval_seconds { + base.polling.minimum_interval_seconds = v; + } + if let Some(v) = p.max_interval_seconds { + base.polling.max_interval_seconds = v; + } + if let Some(v) = p.expiry_buffer_seconds { + base.polling.expiry_buffer_seconds = v; + } + if let Some(v) = p.pause_backoff_max_seconds { + base.polling.pause_backoff_max_seconds = v; + } + } + if let Some(p) = &patch.logging { + if let Some(v) = p.enabled { + base.logging.enabled = v; + } + if let Some(v) = &p.log_level { + base.logging.log_level = v.clone(); + } + // Issue #767: the rotation ceiling, the retained-archive count and the + // #877 history mirror. `clamp_logging` re-bounds the two numbers. + if let Some(v) = p.max_file_size_mb { + base.logging.max_file_size_mb = v; + } + if let Some(v) = p.keep_files { + base.logging.keep_files = v; + } + if let Some(v) = p.presence_history { + base.logging.presence_history = v; + } + } + // Issue #767: the release channel. Replaced wholesale (it is a single + // field), and an unrecognised spelling never reaches this function — + // serde rejects the patch instead, leaving the stored document alone. + if let Some(p) = &patch.updates { + if let Some(v) = p.channel { + base.updates.channel = v; + } + } + // Issue #767: the playback source selection. + if let Some(p) = &patch.playback { + if let Some(v) = p.source { + base.playback.source = v; + } + } + if let Some(v) = patch.autostart { + base.autostart = v; + } + if let Some(p) = &patch.notifications { + if let Some(v) = p.track_change { + base.notifications.track_change = v; + } + if let Some(v) = p.sync_stopped { + base.notifications.sync_stopped = v; + } + if let Some(v) = p.auth_required { + base.notifications.auth_required = v; + } + if let Some(v) = p.update_staged { + base.notifications.update_staged = v; + } + } + // Absent leaves the stored deadline untouched; `Some(None)` clears an + // active pause; `Some(Some(..))` sets a new one. The write path's + // `clamp_snooze` still drops an expired value afterwards. + if let Some(v) = &patch.snooze_until { + base.snooze_until = v.clone(); + } + if let Some(p) = &patch.status_rules { + if let Some(v) = &p.quiet_hours { + base.status_rules.quiet_hours = v.clone(); + } + if let Some(v) = &p.track_rules { + base.status_rules.track_rules = v.clone(); + } + } + // Issue #767: `locale` mirrors `snooze_until`'s three states, so a patch + // that names no locale leaves the user's tag exactly as it was. + if let Some(v) = &patch.locale { + base.locale = v.clone(); + } + // Issue #767: shortcut bindings, one slot at a time. `clamp_shortcuts` + // normalises a blank binding to "unbound" on the write path. + if let Some(p) = &patch.shortcuts { + if let Some(v) = &p.toggle_playback { + base.shortcuts.toggle_playback = Some(v.clone()); + } + if let Some(v) = &p.toggle_sync { + base.shortcuts.toggle_sync = Some(v.clone()); + } + } +} diff --git a/src-tauri/src/config/schema.rs b/src-tauri/src/config/schema.rs new file mode 100644 index 00000000..543e7bff --- /dev/null +++ b/src-tauri/src/config/schema.rs @@ -0,0 +1,1259 @@ +use super::clamp::{normalize_rule_days, QUIET_HOURS_DAY_MINUTES, TRACK_RULE_DAY_MINUTES}; +use super::migrate::default_schema_version; +use crate::profanity; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +/// Three-way view of the OS-keychain `client_secret` slot (issue #560). +/// +/// [`SpotifyConfig::client_secret_set`] cannot express this: it is the +/// `Present`-only projection, so a locked or missing Secret Service collapsed +/// into `false` — the same answer as "the user never configured a secret". +/// Every UI gate that read it then pushed a fully credentialed Linux user +/// through re-onboarding while their secret was still in the keychain, +/// merely unreadable at that moment. This is the type those gates read +/// instead. The keychain-side classification lives in +/// [`crate::keychain::KeychainPresence`]; the conversion below is the single +/// place the two vocabularies meet. +/// +/// Serialized lowercase — `present`/`absent`/`unavailable` is the on-the-wire +/// and on-disk spelling the frontend switches on, so `rename_all` is part of +/// the contract, not cosmetics. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] +#[serde(rename_all = "lowercase")] +#[ts(export, export_to = "../../src/lib/types-generated/")] +pub enum ClientSecretState { + /// Stored in the OS keychain and readable right now. The only state a + /// sign-in flow can complete from. + Present, + /// No entry for the slot: the genuine "onboarding needed" answer. + #[default] + Absent, + /// The keychain could not answer (no Secret Service daemon, a locked + /// keyring, denied storage access). The secret is still there — the UI + /// must never render this as "not configured". + Unavailable, +} + +impl From<&crate::keychain::KeychainPresence> for ClientSecretState { + fn from(presence: &crate::keychain::KeychainPresence) -> Self { + use crate::keychain::KeychainPresence; + match presence { + KeychainPresence::Present => ClientSecretState::Present, + KeychainPresence::Absent => ClientSecretState::Absent, + KeychainPresence::Unavailable(_) => ClientSecretState::Unavailable, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)] +#[ts(export, export_to = "../../src/lib/types-generated/")] +pub struct SpotifyConfig { + /// Spotify app client id. `#[serde(default)]` since issue #926: this was + /// the only persisted field without one, so a spotify section that omitted + /// it — or spelled it `null` — failed the whole document, which + /// `load_config` answered by quarantining the file and booting on + /// defaults, costing the user every other setting too. + #[serde(default)] + pub client_id: String, + /// True iff the Spotify `client_secret` is currently stored in the OS + /// keychain. This is a derived/display field — it is populated by + /// `load_config` (and not persisted to disk). The actual secret lives + /// in the keychain, not in `config.json`. See issue #9. + #[serde(default)] + pub client_secret_set: bool, + /// Tri-state companion of [`Self::client_secret_set`] (issue #560), same + /// derived/display contract: stamped by [`with_keychain_flags`] on load, + /// never a durable statement about the keychain. `unavailable` is the + /// case the bool cannot carry. + #[serde(default)] + pub client_secret_state: ClientSecretState, + #[serde(default = "default_redirect_uri")] + pub redirect_uri: String, + /// Unknown / future keys NESTED inside this section, retained across + /// load→save so a section written by a newer binary is not silently + /// stripped by an older one (issue #938 — the section-level companion of + /// [`AppConfig::extra`]). Omitted from JSON while empty and skipped in the + /// TypeScript export, so an untouched config gains no bytes and the + /// generated TypeScript is unchanged. + #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] + #[ts(skip)] + pub extra: BTreeMap, +} + +pub(crate) fn default_redirect_uri() -> String { + "presencejam://callback".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)] +#[ts(export, export_to = "../../src/lib/types-generated/")] +pub struct TeamsConfig { + #[serde(default = "default_status_format")] + pub status_format: String, + #[serde(default = "default_clear_on_pause")] + pub clear_on_pause: bool, + #[serde(default = "default_profanity_filter")] + pub profanity_filter: bool, + #[serde(default = "default_profanity_placeholder")] + pub profanity_placeholder: String, + #[serde(default)] + pub start_minimized: bool, + /// P1 (issue #3.0-P1): drive the Teams presence bubble + /// (Available/Available while a track plays) via Graph + /// setPresence/clearPresence. OFF by default — it overrides the + /// user's manual presence bubble. + #[serde(default = "default_availability_sync")] + pub availability_sync: bool, + /// P2 (issue #3.0-P2): before writing a status message, read the + /// user's presence and skip the write when busy/DND/in a + /// meeting/in a call/presenting. ON by default. + #[serde(default = "default_presence_gate")] + pub presence_gate: bool, + /// User-supplied extra words for the status profanity filter + /// (CfgDiag#3(b), issue #538). Normalized once and matched under the + /// same boundary gates as the built-in lexicon. Empty by default; + /// bounded to 64 entries of 32 chars by `clamp_teams`. + #[serde(default)] + pub profanity_extra_words: Vec, + /// Finding #635 (issue #635): never overwrite a Teams status message the + /// user set by hand. ON by default — clobbering a message the user typed + /// ("In a workshop until 3") is the app taking over something the user + /// owns, and the read-before-write check reuses the presence sample the + /// gate already fetches (see `poll_once::manual_status_blocks_write`). + #[serde(default = "default_respect_manual_status")] + pub respect_manual_status: bool, + /// Finding #637 (issue #637): also gate the status write while the user + /// is marked out of office. OFF by default, matching how + /// `availability_sync` shipped — 4.5 behaviour is unchanged until the + /// user opts in. + #[serde(default = "default_gate_when_out_of_office")] + pub gate_when_out_of_office: bool, + /// Issue #872: also gate the status write while the OS reports a + /// full-screen app, presentation mode, or Quiet Time. OFF by default + /// — a hand-edited config flips it on; the GUI does too. Linux/macOS + /// always report `Unknown` (`platform::focus`), so the toggle is a + /// no-op on those targets. Fails open on a Windows probe error so a + /// transient shell-API failure cannot lock the gate. + #[serde(default = "default_gate_when_presenting")] + pub gate_when_presenting: bool, + /// Issue #873: stop advertising listening once the OS reports no + /// keyboard/mouse input for this many seconds. `0` (the default) + /// disables the feature — 4.7 behaviour is unchanged until the user + /// opts in. Clamped to 60..=3600 by `clamp_teams` so a hand-edited + /// config cannot put the gate in a state that surprises the user + /// (a 1 s threshold would fire on every typing pause). Linux/macOS + /// always report `None` (`platform::idle`), so the toggle is a no-op + /// on those targets. + #[serde(default)] + #[ts(type = "number")] + pub idle_away_after_seconds: u64, + /// Issue #867: minutes before a busy Outlook calendar event starts that + /// the status write is suppressed. `0` means suppress only during the + /// meeting itself (the same behaviour as the presence-gate today); + /// `>0` lets a user pre-gate so a track that started ten minutes before + /// the meeting is also caught. Capped at 60 minutes by `clamp_teams`. + #[serde(default)] + pub pre_meeting_suppress_minutes: u16, + /// S4 (issue #672): the text posted as the Teams status message while + /// playback is paused — the user-templatable form of the literal the + /// paused clear used to hardcode (`"🎵 Paused"`, emoji included by + /// `poll_once`). Defaults to that literal's text, so an existing config + /// renders byte-identically. + #[serde(default = "default_paused_status_format")] + pub paused_status_format: String, + /// S4 (issue #672): the text posted when nothing is playing — the + /// user-templatable form of the no-track clear's hardcoded + /// `"🎵 Nothing playing on Spotify"`. Defaults to that literal's text. + #[serde(default = "default_stopped_status_format")] + pub stopped_status_format: String, + /// Issue #866: a long-lived "preferred presence" the app sets on the user's + /// behalf via Graph `setUserPreferredPresence`, applying the documented + /// Busy / DND / BeRightBack / Away pairs while a rule or snooze wants + /// presence moved. The user's own Teams bubble wins — `respect_manual_status` + /// suppresses the call — and the user can clear it from the Settings pane + /// or by quitting the app (the `RunEvent::Exit` arm invokes the Graph + /// `clearUserPreferredPresence` counterpart). + /// + /// National-cloud note: `setUserPreferredPresence` is a commercial-Graph + /// surface. The free `graph.microsoft.com` endpoint used by `setPresence` + /// is the same on every cloud, but sovereign clouds (US Gov / DoD, China, + /// Germany) have historically rejected preferred-presence POSTs. The app + /// always prefers `setUserPreferredPresence` when enabled, and logs a + /// one-shot warning the first time the endpoint answers with the + /// documented 4xx shape. + #[serde(default)] + pub preferred_presence: PreferredPresenceConfig, + /// Unknown / future keys NESTED inside this section, retained across + /// load→save so a section written by a newer binary is not silently + /// stripped by an older one (issue #938 — the section-level companion of + /// [`AppConfig::extra`]). Omitted from JSON while empty and skipped in the + /// TypeScript export, so an untouched config gains no bytes and the + /// generated TypeScript is unchanged. + #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] + #[ts(skip)] + pub extra: BTreeMap, +} + +pub(crate) fn default_status_format() -> String { + "🎵 {artist} - {track} 🎧".to_string() +} + +pub(crate) fn default_start_minimized() -> bool { + false +} + +pub(crate) fn default_clear_on_pause() -> bool { + true +} + +pub(crate) fn default_profanity_filter() -> bool { + true +} + +pub(crate) fn default_profanity_placeholder() -> String { + profanity::safe_placeholder_default().to_string() +} + +pub(crate) fn default_availability_sync() -> bool { + false +} + +pub(crate) fn default_presence_gate() -> bool { + true +} + +pub(crate) fn default_respect_manual_status() -> bool { + true +} + +pub(crate) fn default_gate_when_out_of_office() -> bool { + false +} + +pub(crate) fn default_gate_when_presenting() -> bool { + false +} + +pub(crate) fn default_paused_status_format() -> String { + "Paused".to_string() +} + +pub(crate) fn default_stopped_status_format() -> String { + "Nothing playing on Spotify".to_string() +} + +/// Issue #866: the user-configurable "preferred presence" the app drives on +/// the user's behalf via Graph `setUserPreferredPresence`. Distinct from the +/// ephemeral [`Self::availability_sync`] `setPresence` session — preferred +/// presence is the documented Busy / DND / BeRightBack / Away vehicle and +/// survives across processes the user did not start themselves. +/// +/// `expiry_minutes` is bound by [`clamp_preferred_presence`] into +/// `5..=720`. The pair is bound by the same [`normalize_presence_pair`] the +/// rules use — a hand-edited file that names a pair Graph silently drops is +/// normalized away at the IPC boundary exactly like the rule pairs. +#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)] +#[ts(export, export_to = "../../src/lib/types-generated/")] +pub struct PreferredPresenceConfig { + /// OFF by default — preferred presence is opt-in, mirroring how + /// `availability_sync` shipped (it overrides the user's manual bubble). + #[serde(default)] + pub enabled: bool, + /// Graph availability token (`Busy`, `DoNotDisturb`, `BeRightBack`, + /// `Away`). Normalized through [`normalize_presence_pair`] on load and on + /// every save; an unsupported value clears the pair and disables the + /// feature (the call would never land anyway). + #[serde(default)] + pub availability: String, + /// Graph activity token (`Busy`, `DoNotDisturb`, `Away`, `BeRightBack`, + /// or — for `Busy` — `InACall`/`InAConferenceCall`/`Presenting`). Same + /// normalizer as `availability`. + #[serde(default)] + pub activity: String, + /// How long the preferred presence survives a successful + /// `setUserPreferredPresence` before the app clears it at expiry (the + /// same expiry the rule+snooze path observed, and the same `RunEvent::Exit` + /// arm clears on quit). Default: 60 minutes. + #[serde(default = "default_preferred_presence_expiry")] + pub expiry_minutes: u32, + /// Unknown / future keys NESTED inside this section, retained across + /// load→save so a section written by a newer binary is not silently + /// stripped by an older one (issue #938 — this object is a SECTION in its + /// own right, reachable at `teams.preferred_presence`, and it was the one + /// nested object still missing the retention map its siblings all carry). + #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] + #[ts(skip)] + pub extra: BTreeMap, +} + +pub(crate) fn default_preferred_presence_expiry() -> u32 { + 60 +} + +impl Default for PreferredPresenceConfig { + fn default() -> Self { + Self { + enabled: false, + availability: String::new(), + activity: String::new(), + expiry_minutes: default_preferred_presence_expiry(), + extra: BTreeMap::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)] +#[ts(export, export_to = "../../src/lib/types-generated/")] +pub struct PollingConfig { + // Issue #765: Tauri IPC crosses the boundary via serde_json, which decodes + // `u64` values as JS `number` (f64). Override ts-rs's `bigint` default so + // the generated `.ts` matches what `invoke()` actually returns at + // runtime — `bigint` would type-lie about the wire shape. All five values + // are small (seconds, clamped to <= 3600), well under 2^53. + #[serde(default = "default_interval_seconds")] + #[ts(type = "number")] + pub default_interval_seconds: u64, + #[serde(default = "default_min_interval_seconds")] + #[ts(type = "number")] + pub minimum_interval_seconds: u64, + #[serde(default = "default_max_interval_seconds")] + #[ts(type = "number")] + pub max_interval_seconds: u64, + #[serde(default = "default_expiry_buffer_seconds")] + #[ts(type = "number")] + pub expiry_buffer_seconds: u64, + /// Ceiling for the "paused playback" exponential backoff (CfgDiag#3(c), + /// issue #538). `pause_backoff` used to hardcode a 300 s cap; it is now + /// the ladder's ceiling (default 300, so an untouched config is unchanged) + /// and the value is clamped into 60..=3600 by `clamp_polling`. + #[serde(default = "default_pause_backoff_max")] + #[ts(type = "number")] + pub pause_backoff_max_seconds: u64, + /// Unknown / future keys NESTED inside this section, retained across + /// load→save so a section written by a newer binary is not silently + /// stripped by an older one (issue #938 — the section-level companion of + /// [`AppConfig::extra`]). Omitted from JSON while empty and skipped in the + /// TypeScript export, so an untouched config gains no bytes and the + /// generated TypeScript is unchanged. + #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] + #[ts(skip)] + pub extra: BTreeMap, +} + +pub(crate) fn default_interval_seconds() -> u64 { + 30 +} + +pub(crate) fn default_min_interval_seconds() -> u64 { + 10 +} + +pub(crate) fn default_max_interval_seconds() -> u64 { + 60 +} + +pub(crate) fn default_expiry_buffer_seconds() -> u64 { + 10 +} + +pub(crate) fn default_pause_backoff_max() -> u64 { + 300 +} +/// Normalize a quiet-hours window in place (issue #821), mirroring +/// [`clamp_track_rule_window`]: the minutes into the range [`QuietHoursEntry`] +/// documents, and `days` through [`normalize_rule_days`]. +/// +/// Without this, an out-of-range weekday loaded unchanged and matched NO weekday +/// at all, so a hand-edited or other-build `days: [0]` window — its +/// `pause_polling` arm included — silently never fired, with no error anywhere. +/// The Settings day picker only ever writes `1..=7`, so the trigger is exactly +/// the hand-edited/foreign document this load-time normalizer exists for. +/// +/// The window itself keeps [`QuietHoursEntry`]'s semantics: `[start, end)` with +/// a wrap-around pair (`start > end`, e.g. 22:00→07:00) honoured, and +/// `start == end` matching nothing. +pub(crate) fn clamp_quiet_hours_window(entry: &mut QuietHoursEntry) { + // Same reasoning as the track rule above: a START of 1440 is unreachable + // (`now` never exceeds 1439), while an END of 1440 is the end of the day. + entry.start_minutes = entry.start_minutes.min(QUIET_HOURS_DAY_MINUTES - 1); + entry.end_minutes = entry.end_minutes.min(QUIET_HOURS_DAY_MINUTES); + normalize_rule_days(&mut entry.days); +} + +#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)] +#[ts(export, export_to = "../../src/lib/types-generated/")] +pub struct LoggingConfig { + #[serde(default = "default_logging_enabled")] + pub enabled: bool, + #[serde(default = "default_log_level")] + pub log_level: String, + /// Rotation ceiling for the log file, in mebibytes (4.7.0). The + /// rotating file target renames the active log and starts a fresh one + /// once it would exceed this size. Clamped to 1..=500 by + /// [`clamp_logging`]. + #[serde(default = "default_max_file_size_mb")] + #[ts(type = "number")] + pub max_file_size_mb: u64, + /// How many *archived* log files to retain (4.7.0). The active + /// `PresenceJam.log` is not counted, so the directory holds at most + /// `keep_files + 1` log files. Clamped to 1..=20 by [`clamp_logging`]. + #[serde(default = "default_keep_files")] + pub keep_files: u32, + /// Issue #877: opt-in JSONL mirror of the bounded status-decision + /// history. OFF by default — a noisy rule set could otherwise grow + /// the log without bound — and writes only when the user opts in. + /// The mirror lives in the same `app_log_dir()` folder + /// `tauri-plugin-log` already targets; the file is `presence-history.jsonl` + /// and one line per decision appends. + #[serde(default)] + pub presence_history: bool, + /// Unknown / future keys NESTED inside this section, retained across + /// load→save so a section written by a newer binary is not silently + /// stripped by an older one (issue #938 — the section-level companion of + /// [`AppConfig::extra`]). Omitted from JSON while empty and skipped in the + /// TypeScript export, so an untouched config gains no bytes and the + /// generated TypeScript is unchanged. + #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] + #[ts(skip)] + pub extra: BTreeMap, +} + +pub(crate) fn default_logging_enabled() -> bool { + true +} + +pub(crate) fn default_log_level() -> String { + "Info".to_string() +} + +pub(crate) fn default_max_file_size_mb() -> u64 { + 10 +} + +pub(crate) fn default_keep_files() -> u32 { + 3 +} + +/// Bound the log-rotation settings (4.7.0, S5). Mirrors [`clamp_polling`]: +/// the Settings number inputs' `min`/`max` attributes do not constrain a +/// typed value and a hand-edited `config.json` is not policed by anyone +/// else, so this is the only normalizer — it runs on load and on every save. +/// +/// `keep_files >= 1` matters beyond taste: the rotating target is built as +/// `KeepSome(keep_files)` (see `lib.rs::log_rotation_strategy`) and the +/// plugin's archive pass computes `keep_count - 1`. +/// Single place the logger's max level is wired from `logging.enabled` / +/// `logging.log_level` (CfgDiag#4, issue #539). +/// +/// Called once from the `lib.rs` setup block after the startup config load +/// and again by every config write that can change logging, so a +/// `logging.enabled: false` (or a Debug-to-reproduce-a-bug switch) takes +/// effect immediately instead of at the next launch. An unrecognised level +/// string falls back to Info rather than silently disabling logging. +pub fn apply_log_level(cfg: &LoggingConfig) { + let level_str = cfg.log_level.to_lowercase(); + let max_level = if !cfg.enabled { + log::LevelFilter::Off + } else { + match level_str.as_str() { + "off" => log::LevelFilter::Off, + "error" => log::LevelFilter::Error, + "warn" => log::LevelFilter::Warn, + "info" => log::LevelFilter::Info, + "debug" => log::LevelFilter::Debug, + "trace" => log::LevelFilter::Trace, + _ => log::LevelFilter::Info, + } + }; + log::set_max_level(max_level); + log::info!( + "[CFG] log level applied: {:?} (enabled={})", + max_level, + cfg.enabled + ); +} +/// One quiet-hours entry for issue #432: status writes are suppressed while +/// the local time falls inside `[start_minutes, end_minutes)` (minutes +/// since midnight; wrap-around ranges like 22:00→07:00 are supported). +/// `days` holds ISO weekday numbers 1 (Mon)..=7 (Sun); empty means every +/// day. A midnight-crossing window is NIGHT-OWNING (issue #794): each half +/// is tested against the day it falls on — the evening half (`now >= start`) +/// against `weekday`, the morning half (`now < end`) against the previous +/// ISO day (wrapping 1→7) — so a Monday-only 22:00→07:00 window covers +/// Monday night into Tuesday morning, not Sunday night. All fields +/// `#[serde(default)]` individually so a hand-edited config missing one +/// still loads, and load-time normalization of `days` and the two minutes +/// lives in one place: [`clamp_quiet_hours_window`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] +#[ts(export, export_to = "../../src/lib/types-generated/")] +pub struct QuietHoursEntry { + #[serde(default)] + pub enabled: bool, + /// Minutes since midnight, normalized into `0..=1439` by + /// [`clamp_quiet_hours_window`] (a start of 1440 is unreachable — the + /// clock never reads it). + #[serde(default)] + pub start_minutes: u16, + /// Minutes since midnight, normalized into `0..=1440` by + /// [`clamp_quiet_hours_window`]; `1440` is the end of the day. + #[serde(default = "default_quiet_end")] + pub end_minutes: u16, + /// ISO weekday numbers 1..=7; empty = every day. Normalized by + /// [`clamp_quiet_hours_window`] (out-of-range days dropped, then sorted and + /// deduplicated) exactly like [`TrackRuleEntry::days`]. + #[serde(default)] + pub days: Vec, + /// Optional fixed status posted while this window is active instead of + /// suppressing the write (CfgDiag#3(a), issue #538) — e.g. "Busy" during + /// focus hours. Empty = suppress, mirroring + /// [`TrackRuleEntry::replacement_status`]. + #[serde(default)] + pub replacement_status: String, + /// setPresence pair applied while this window is active (finding #634, + /// issue #634) — e.g. Away/Away outside working hours, so the user is + /// visibly away instead of merely unheard. Both fields empty (the + /// default) = don't touch presence; `clamp_rules` normalizes them against + /// [`PRESENCE_COMBINATIONS`]. + #[serde(default)] + pub presence_availability: String, + #[serde(default)] + pub presence_activity: String, + /// S4 (issue #672): also stop POLLING while this window is active, not + /// just the status write — no Spotify GET, no Graph work, and no clock + /// movement for the duration (the polling driver re-evaluates the window + /// every iteration, so it resumes by itself). OFF by default: 4.6 + /// behaviour is unchanged until the user opts in. + #[serde(default = "default_pause_polling")] + pub pause_polling: bool, +} + +/// Mirrors the serde defaults field-by-field (note `end_minutes` defaults to +/// [`default_quiet_end`], not `u16::default()`), so a test fixture built with +/// `..Default::default()` and a config file missing the same field agree. +impl Default for QuietHoursEntry { + fn default() -> Self { + Self { + enabled: false, + start_minutes: 0, + end_minutes: default_quiet_end(), + days: Vec::new(), + replacement_status: String::new(), + presence_availability: String::new(), + presence_activity: String::new(), + pause_polling: default_pause_polling(), + } + } +} + +pub(crate) fn default_quiet_end() -> u16 { + 420 +} + +pub(crate) fn default_pause_polling() -> bool { + false +} + +pub(crate) fn default_track_rule_days() -> Vec { + Vec::new() +} + +pub(crate) fn default_track_rule_start() -> u32 { + 0 +} + +/// The contract's default end: the end of the day, so the default window +/// covers every minute (0 → 1440). +pub(crate) fn default_track_rule_end() -> u32 { + TRACK_RULE_DAY_MINUTES +} + +/// Issue #868: how `artist_substring` / `track_substring` are compared +/// against the playing track. Substring is the legacy behaviour (case- +/// insensitive `contains`); Exact requires a full case-insensitive +/// equality; Glob treats the two substrings as case-insensitive glob +/// patterns (`*` matches any run, `?` matches one character) evaluated +/// independently. Album / show / device / playlist-uri remain substring +/// matches regardless of `match_kind` — they are extension surfaces, not +/// primary identifiers, and the Settings UI exposes only the substring +/// field for them. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, ts_rs::TS, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +#[ts(export, export_to = "../../src/lib/types-generated/")] +pub enum TrackRuleMatchKind { + #[default] + Substring, + Exact, + Glob, +} + +/// Issue #868: what happens when a track rule matches. `Suppress` is the +/// documented "write nothing" default; `Replace` posts a fixed status text +/// instead of the track template; `SnoozeMinutes { value }` arms the snooze +/// for `value` minutes; `Profile { id }` switches the active presence +/// profile for the duration of the track; `Presence { availability, +/// activity }` applies a Teams presence pair while the track plays. The +/// legacy `replacement_status` + `presence_availability` / +/// `presence_activity` fields continue to feed the `Replace` / `Presence` +/// variants during the transition — see `explain_rules` for the canonical +/// "what would fire" projection. +#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "lowercase")] +#[ts(export, export_to = "../../src/lib/types-generated/")] +pub enum TrackRuleAction { + /// Default: write nothing for this track (suppress the status update). + #[default] + Suppress, + /// Post a fixed status text instead of the track template. + Replace { status: String }, + /// Snooze sync for `value` minutes (clamped into 1..=1440 by + /// `clamp_track_rule_action`). + SnoozeMinutes { value: u32 }, + /// Switch the active presence profile for this track. `id` is the + /// profile name from `AppConfig::presence_profiles`; a missing id is + /// treated as `Suppress` by the rule walker. + Profile { id: String }, + /// Apply a Teams presence pair for the duration of the track. The pair + /// is normalized against [`PRESENCE_COMBINATIONS`] by `clamp_rules` + /// exactly like the legacy `presence_availability` / + /// `presence_activity` fields. + Presence { + availability: String, + activity: String, + }, +} + +/// One track-matching rule for issue #432 / issue #868: when the +/// substring conditions AND the album / show / device / playlist-uri +/// extensions AND the duration gate all match, the rule's `action` runs. +/// Issue #868 also adds `negate` so an empty match list still wins when +/// the negation's conditions match (a "suppress on the absence of a +/// device substring" pattern), plus `match_kind` and the new +/// `action` enum. Empty substrings match everything (so a rule with +/// only one field set still works); `min_duration_seconds == 0` skips the +/// duration gate. +#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)] +#[ts(export, export_to = "../../src/lib/types-generated/")] +pub struct TrackRuleEntry { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub artist_substring: String, + #[serde(default)] + pub track_substring: String, + /// Issue #868: how `artist_substring` / `track_substring` are compared. + /// Defaults to `Substring` (the legacy case-insensitive `contains`). + #[serde(default)] + pub match_kind: TrackRuleMatchKind, + /// Issue #868: substring matched against the track's album title + /// (empty = match any album). + #[serde(default)] + pub album_substring: String, + /// Issue #868: substring matched against the episode's show name when + /// the playing item is a podcast episode (empty = match any show or any + /// track). + #[serde(default)] + pub show_substring: String, + /// Issue #868: substring matched against the active Spotify device's + /// name (empty = match any device). + #[serde(default)] + pub device_substring: String, + /// Issue #868: substring matched against the playing context URI + /// (e.g. `spotify:playlist:abc…`). Empty = match any context. + #[serde(default)] + pub playlist_uri: String, + /// Issue #868: minimum track / episode duration, in seconds, for this + /// rule to match. `0` (the default) disables the gate. Capped at 86 400 + /// (24 h) by `clamp_track_rule_action` so a hand-edited config cannot + /// put the gate in a permanently-firing state. + #[serde(default)] + pub min_duration_seconds: u32, + /// Issue #868: when `true`, the rule matches the NEGATION of the + /// combined conditions (the "suppress unless something matches" + /// pattern). `false` (the default) keeps the legacy "match if the + /// conditions hold" semantics. + #[serde(default)] + pub negate: bool, + /// Optional fixed status posted instead of suppressing (issue #432 + /// "busy/focus" alternative, retained for the legacy + /// `TrackRuleAction::Replace { status: … }` projection). + /// Empty = suppress silently. + #[serde(default)] + pub replacement_status: String, + /// setPresence pair applied while this rule matches (finding #634, issue + /// #634) — e.g. DoNotDisturb/Presenting for a focus playlist. Both fields + /// empty (the default) = don't touch presence; `clamp_rules` normalizes + /// them against [`PRESENCE_COMBINATIONS`]. + #[serde(default)] + pub presence_availability: String, + #[serde(default)] + pub presence_activity: String, + /// Issue #868: the rule's effect. Defaults to `Suppress`, the legacy + /// "write nothing for this track" outcome. `clamp_rules` normalizes + /// the inner text / value / id fields into their canonical forms. + #[serde(default)] + pub action: TrackRuleAction, + /// S4 (issue #672): ISO weekday numbers 1 (Mon)..=7 (Sun) this rule + /// applies on; empty = every day — the same shape and semantics + /// [`QuietHoursEntry::days`] uses, normalized by `clamp_rules` (see + /// [`clamp_track_rule_window`]). + #[serde(default = "default_track_rule_days")] + pub days: Vec, + /// S4 (issue #672): start of the rule's local-time window, in minutes since + /// midnight. The window is `[start_minutes, end_minutes)` with the same + /// wrap-around rule as [`QuietHoursEntry`] (22:00→07:00 works); the + /// default pair (`0`, `1440`) covers every minute of the day. A + /// midnight-crossing window is NIGHT-OWNING (issue #794): the evening + /// half (`now >= start`) is tested against the selected day, the morning + /// half (`now < end`) against the previous ISO day (wrapping 1→7) — a + /// Monday-only 22:00→07:00 rule covers Monday night into Tuesday + /// morning. + #[serde(default = "default_track_rule_start")] + pub start_minutes: u32, + /// S4 (issue #672): end of the rule's local-time window, in minutes since + /// midnight; `1440` is the end of the day. + #[serde(default = "default_track_rule_end")] + pub end_minutes: u32, +} + +/// Mirrors the serde defaults field-by-field — see [`QuietHoursEntry`]. +impl Default for TrackRuleEntry { + fn default() -> Self { + Self { + enabled: false, + artist_substring: String::new(), + track_substring: String::new(), + match_kind: TrackRuleMatchKind::default(), + album_substring: String::new(), + show_substring: String::new(), + device_substring: String::new(), + playlist_uri: String::new(), + min_duration_seconds: 0, + negate: false, + replacement_status: String::new(), + presence_availability: String::new(), + presence_activity: String::new(), + action: TrackRuleAction::default(), + days: default_track_rule_days(), + start_minutes: default_track_rule_start(), + end_minutes: default_track_rule_end(), + } + } +} + +/// User-defined status rules for issue #432 (quiet hours + track +/// matching). Additive on `AppConfig` with `#[serde(default)]` so +/// pre-4.5 config files load unchanged (issue #379 versioning untouched: +/// `schema_version` stays 1, `extra` retention untouched). +#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS)] +#[ts(export, export_to = "../../src/lib/types-generated/")] +pub struct StatusRulesConfig { + #[serde(default)] + pub quiet_hours: Vec, + #[serde(default)] + pub track_rules: Vec, + /// Unknown / future keys NESTED inside this section, retained across + /// load→save so a section written by a newer binary is not silently + /// stripped by an older one (issue #938 — the section-level companion of + /// [`AppConfig::extra`]). Omitted from JSON while empty and skipped in the + /// TypeScript export, so an untouched config gains no bytes and the + /// generated TypeScript is unchanged. + #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] + #[ts(skip)] + pub extra: BTreeMap, +} + +/// Which desktop-notification classes the app may show (4.7.0 / issue #675). +/// +/// Replaces the single `notificationsEnabled` localStorage opt-in, which only +/// ever governed track changes, with one toggle per class. All four are ON by +/// default (matching the 4.7.0 schema table); a class is only ever dispatched +/// when its flag is true *and* the OS granted notification permission. +/// Additive with serde defaults, so a pre-4.7 config file loads unchanged — +/// including one that only ever carried the legacy key, which the frontend +/// migrates into `track_change` on first launch. +#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)] +#[ts(export, export_to = "../../src/lib/types-generated/")] +pub struct NotificationsConfig { + /// System notification when the playing track changes (the pre-4.7 class). + #[serde(default = "default_notification_class")] + pub track_change: bool, + /// The poller stopped on its own — an auth failure or a self-terminating + /// loop — so the Dashboard mirror can no longer report "Syncing". + #[serde(default = "default_notification_class")] + pub sync_stopped: bool, + /// A stored Teams session is no longer usable and a sign-in is required. + #[serde(default = "default_notification_class")] + pub auth_required: bool, + /// An update finished staging and will install on quit. + #[serde(default = "default_notification_class")] + pub update_staged: bool, + /// Unknown / future keys NESTED inside this section, retained across + /// load→save so a section written by a newer binary is not silently + /// stripped by an older one (issue #938 — the section-level companion of + /// [`AppConfig::extra`]). Omitted from JSON while empty and skipped in the + /// TypeScript export, so an untouched config gains no bytes and the + /// generated TypeScript is unchanged. + #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] + #[ts(skip)] + pub extra: BTreeMap, +} + +/// Mirrors the serde defaults field-by-field ([`QuietHoursEntry`]'s pattern): +/// every class defaults to ON, so a config file missing the section and one +/// built with `Default::default()` agree. +impl Default for NotificationsConfig { + fn default() -> Self { + Self { + track_change: default_notification_class(), + sync_stopped: default_notification_class(), + auth_required: default_notification_class(), + update_staged: default_notification_class(), + extra: BTreeMap::new(), + } + } +} + +/// Issue #869: one named presence profile — a typed overlay of the +/// base `AppConfig` the user can switch from the tray, a hotkey, or +/// `presencejam --profile `. Every overlay field is `Option<_>` +/// so a profile can carry JUST a status format (a one-line tweak) or a +/// full rules replacement (a "Focus" mode that suppresses every track +/// except the user's whitelist). Names are unique and at most 32 +/// characters — [`clamp_presence_profiles`] enforces both at load and +/// on every save. Profile overlays are resolved at READ time through +/// [`effective_config`], so the on-disk base values stay untouched +/// even while a non-default profile is active. +#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS)] +#[ts(export, export_to = "../../src/lib/types-generated/")] +pub struct PresenceProfile { + /// Profile name, the id the tray / hotkey / CLI use to switch. + /// Case-sensitive, unique within `presence_profiles`, ≤ 32 + /// characters after trim (`MAX_PROFILE_ID_CHARS`). A profile whose + /// name normalises to empty is dropped by `clamp_presence_profiles`. + pub name: String, + /// `None` keeps the base `teams.status_format`; `Some` overrides it + /// while the profile is active. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status_format: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub clear_on_pause: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub availability_sync: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gate_when_out_of_office: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gate_when_presenting: Option, + /// Capped at 86 400 (24 h) by `clamp_presence_profiles` so a hand- + /// edited config cannot put the idle gate in a permanently-firing + /// state. `None` keeps the base value. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null")] + pub idle_away_after_seconds: Option, + /// `None` keeps the base `teams.preferred_presence`; `Some` overlays + /// the whole `PreferredPresenceConfig` (enabled, availability, + /// activity, expiry) while the profile is active. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preferred_presence: Option, + /// `None` keeps the base `status_rules.track_rules`; `Some` + /// replaces the whole list while the profile is active. The + /// `Some(vec![])` shape is a legitimate "no rules" overlay (a + /// silent profile that only flips the status format). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub track_rules: Option>, + /// `None` keeps the base `notifications`; `Some` overlays each + /// notification class while the profile is active. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub notifications: Option, + /// Unknown / future keys NESTED inside this section, retained across + /// load→save so a section written by a newer binary is not silently + /// stripped by an older one (issue #938 — the section-level companion of + /// [`AppConfig::extra`]). + #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] + #[ts(skip)] + pub extra: BTreeMap, +} + +/// Default accelerator for the playback toggle (issue #676). +/// +/// `CmdOrCtrl` is the plugin parser's platform-portable "primary modifier" +/// spelling (`global_hotkey::hotkey::CMD_OR_CTRL` — SUPER on macOS, CONTROL +/// elsewhere), so a `config.json` carried between platforms keeps the user's +/// intent instead of pinning Command on one machine and Ctrl on another. +pub const DEFAULT_TOGGLE_PLAYBACK_SHORTCUT: &str = "CmdOrCtrl+Alt+P"; + +/// Default accelerator for the sync pause/resume toggle (issue #676). +pub const DEFAULT_TOGGLE_SYNC_SHORTCUT: &str = "CmdOrCtrl+Alt+S"; + +/// Global-shortcut bindings (issue #676). +/// +/// `None` — and the blank string a hand-edited file can carry — means +/// "unbound": the slot registers nothing and never disturbs the other slot. +/// A key that is *absent* from the file takes the documented default, while an +/// explicit `null` is a deliberate unbinding, because serde applies +/// `default = "…"` only when the key is missing. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] +#[ts(export, export_to = "../../src/lib/types-generated/")] +pub struct ShortcutsConfig { + #[serde(default = "default_toggle_playback_shortcut")] + pub toggle_playback: Option, + #[serde(default = "default_toggle_sync_shortcut")] + pub toggle_sync: Option, + /// Unknown / future keys NESTED inside this section, retained across + /// load→save so a section written by a newer binary is not silently + /// stripped by an older one (issue #938 — the section-level companion of + /// [`AppConfig::extra`]). Omitted from JSON while empty and skipped in the + /// TypeScript export, so an untouched config gains no bytes and the + /// generated TypeScript is unchanged. + #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] + #[ts(skip)] + pub extra: BTreeMap, +} + +pub(crate) fn default_toggle_playback_shortcut() -> Option { + Some(DEFAULT_TOGGLE_PLAYBACK_SHORTCUT.to_string()) +} + +pub(crate) fn default_toggle_sync_shortcut() -> Option { + Some(DEFAULT_TOGGLE_SYNC_SHORTCUT.to_string()) +} + +impl Default for ShortcutsConfig { + fn default() -> Self { + Self { + toggle_playback: default_toggle_playback_shortcut(), + toggle_sync: default_toggle_sync_shortcut(), + extra: BTreeMap::new(), + } + } +} + +/// Shared serde default for every [`NotificationsConfig`] flag. +pub(crate) fn default_notification_class() -> bool { + true +} + +/// Which release manifest the updater consults (4.7.0, issue #678). +/// +/// Serialized lowercase — `stable`/`beta` is the on-disk and on-the-wire +/// spelling the Settings picker round-trips, so `rename_all` is part of the +/// contract, not cosmetics (same convention as [`ClientSecretState`]). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] +#[serde(rename_all = "lowercase")] +#[ts(export, export_to = "../../src/lib/types-generated/")] +pub enum UpdateChannel { + /// Published stable releases (`releases/latest`): the default channel. + #[default] + Stable, + /// Rolling prerelease builds (`releases/download/beta`). A missing or + /// non-newer beta manifest falls back to the stable manifest through + /// `updater_bg::update_endpoints`. + Beta, +} + +/// Updater settings (4.7.0, issue #678). Additive on `AppConfig` with +/// `#[serde(default)]`, so a pre-4.7 config loads as the stable channel. +#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS)] +#[ts(export, export_to = "../../src/lib/types-generated/")] +pub struct UpdatesConfig { + /// Missing key keeps the serde default (`stable`); an unrecognised value + /// is read leniently — see [`deserialize_update_channel`]. + #[serde(default, deserialize_with = "deserialize_update_channel")] + pub channel: UpdateChannel, + /// Unknown / future keys NESTED inside this section, retained across + /// load→save so a section written by a newer binary is not silently + /// stripped by an older one (issue #938 — the section-level companion of + /// [`AppConfig::extra`]). Omitted from JSON while empty and skipped in the + /// TypeScript export, so an untouched config gains no bytes and the + /// generated TypeScript is unchanged. + #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] + #[ts(skip)] + pub extra: BTreeMap, +} + +/// Playback source selection (5.0.0, issue #862). +/// +/// `Auto` is the documented default — the poll loop tries the OS media- +/// session source first (SMTC on Windows, MPRIS on Linux) and falls back +/// to the Spotify Web API source when the session is empty. `Spotify` +/// reproduces the pre-5.0 behaviour exactly. `System` forces the OS +/// source; on macOS there is no OS source so the poll loop reports +/// "no track" and the user is told to switch back to Spotify in +/// `Onboarding.svelte`. +#[derive(Debug, Clone, Default, Serialize, Deserialize, ts_rs::TS)] +#[ts(export, export_to = "../../src/lib/types-generated/")] +pub struct PlaybackConfig { + #[serde(default)] + pub source: crate::sources::PlaybackSourceKind, + /// Unknown / future keys NESTED inside this section, retained across + /// load→save so a section written by a newer binary is not silently + /// stripped by an older one (issue #938 — the section-level companion + /// of [`AppConfig::extra`]). Omitted from JSON while empty and + /// skipped in the TypeScript export. + #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] + #[ts(skip)] + pub extra: BTreeMap, +} + +/// Lenient read of one `updates.channel` value (4.7.0, issue #678): the +/// channel, plus the warning to log when the document carried a spelling this +/// binary does not know. +/// +/// Why lenient: `UpdateChannel` is new in 4.7.0, and a plain enum field makes +/// serde reject the WHOLE document — which `load_config` answers by +/// quarantining `config.json` to `config.json.bak` and booting on defaults, so +/// one unrecognised spelling would cost the user every setting they have (a +/// config written by a newer binary, or a hand-edit, would do it). The rest of +/// the document survives instead. +/// +/// Deliberately scoped to this field: the pre-existing enums +/// ([`SpotifyConfig::client_secret_state`] and friends) still reject the whole +/// document, so that inconsistency stays visible rather than half-fixed here. +pub(crate) fn lenient_update_channel(raw: &serde_json::Value) -> (UpdateChannel, Option) { + // The happy path goes through the enum's own `Deserialize`, so the + // lowercase wire spelling keeps living in `#[serde(rename_all)]` alone. + match serde_json::from_value::(raw.clone()) { + Ok(channel) => (channel, None), + Err(_) => ( + UpdateChannel::Stable, + Some(format!( + "updates.channel: unrecognised value {raw}; using \"stable\" (the rest of the \ + config is kept)" + )), + ), + } +} + +/// `deserialize_with` for [`UpdatesConfig::channel`]: [`lenient_update_channel`] +/// plus its warning. A MISSING key never reaches here — `#[serde(default)]` on +/// the field still supplies the default channel. +pub(crate) fn deserialize_update_channel<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let raw = serde_json::Value::deserialize(deserializer)?; + let (channel, warning) = lenient_update_channel(&raw); + if let Some(warning) = warning { + // The `[CFG]` prefix belongs at the log site (`test_config_log_tags_…` + // scans for it there, and the message is reused verbatim below). + log::warn!("[CFG] {warning}"); + } + Ok(channel) +} + +#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)] +#[ts(export, export_to = "../../src/lib/types-generated/")] +pub struct AppConfig { + #[serde(default)] + pub spotify: SpotifyConfig, + #[serde(default)] + pub teams: TeamsConfig, + #[serde(default)] + pub polling: PollingConfig, + #[serde(default)] + pub logging: LoggingConfig, + /// Release channel the updater reads (issue #678). + #[serde(default)] + pub updates: UpdatesConfig, + /// Playback source selection (5.0.0, issue #862). `Auto` is the + /// documented default — try the OS media-session source first, + /// fall back to Spotify. Additive on `AppConfig` with + /// `#[serde(default)]`, so a pre-5.0 config file loads as `Auto` + /// and the on-disk byte shape does not change. + #[serde(default)] + pub playback: PlaybackConfig, + #[serde(default)] + pub autostart: bool, + /// Desktop-notification classes (4.7.0 / issue #675). Additive on + /// `AppConfig` with a serde default so pre-4.7 config files load + /// unchanged (`schema_version` untouched, `extra` retention untouched). + #[serde(default)] + pub notifications: NotificationsConfig, + /// UI locale for the native surfaces (tray + application menu) and the + /// webview dictionaries (4.7.0, issue #674). `None` — the documented + /// pre-4.7 state and every config file written before this release — + /// reads as `"en"`. Only `en`/`de`/`fr` (or a `de-AT`-style variant of + /// one) are meaningful; an unknown tag falls back to English and is + /// logged (see `crate::i18n::resolve_tag`). The frontend mirrors this + /// value as the single source of truth for the language picker. + #[serde(default)] + pub locale: Option, + /// Persisted "pause sync" deadline set from the tray's snooze submenu + /// (4.7.0, S9 / issue #677). RFC3339 **in UTC** (`2026-09-17T13:45:00Z`): + /// the value has to survive a relaunch, a timezone change and a DST + /// transition without moving, so the three presets are computed from the + /// local clock and stored as that instant + /// (`crate::polling::poll_once::snooze`). `None` — the documented default + /// and every config file written before this release — means polling runs + /// normally. + /// + /// An expired (or unparsable) value is INERT everywhere — [`snooze_status`] + /// and therefore the tray, the chip and the poller's gate all treat "no + /// longer a live deadline" as "not snoozed" — and it is removed by the first + /// WRITE that touches the document: [`clamp_snooze`] through + /// [`clamped_config`] on any save, `poll_once::clear_snooze_if_expired` on + /// the iteration that observes the expiry, or the tray's startup cleaner. + /// `load_config` only reports it (see [`snooze_expired_deadline`]), because + /// a reader that fixes the field in memory would hide the expiry from the + /// writers that can actually correct `config.json`. + #[serde(default)] + pub snooze_until: Option, + #[serde(default)] + pub status_rules: StatusRulesConfig, + /// Issue #869: the named presence profiles the user can switch from the + /// tray, a hotkey or `presencejam --profile `. Each profile is a + /// typed overlay of a SUBSET of the base config — `status_format`, + /// `clear_on_pause`, `availability_sync`, the gate flags, the preferred + /// presence, a rules subset and notifications. Switching resolves at + /// READ time through [`effective_config`], so a profile change NEVER + /// rewrites the on-disk base values. Empty by default, additive with + /// `#[serde(default)]` so a pre-5.0 config file loads with no profiles + /// and the `effective_config` overlay is a no-op. + #[serde(default)] + pub presence_profiles: Vec, + /// Issue #869: the name of the active profile. `None` — the documented + /// pre-5.0 default — means "use the base configuration". A value that + /// does not match any profile name is treated as `None` by + /// [`clamp_presence_profiles`] (the active id is cleared at the IPC + /// boundary) so a hand-edited config cannot silently land on a phantom + /// profile and never resolve. + #[serde(default)] + pub active_profile: Option, + /// Global-shortcut bindings (issue #676). Additive with + /// `#[serde(default)]`, so a pre-4.7 config file loads with the documented + /// default accelerators rather than with no shortcuts at all. + #[serde(default)] + pub shortcuts: ShortcutsConfig, + /// Config schema version (issue #379). Files written before 4.3.0 carry + /// no such key and load as version 1. + #[serde(default = "default_schema_version")] + pub schema_version: u32, + /// The document's revision, raised once per accepted save (issue #943). + /// + /// Settings renders in both the main window and the detached pane, each + /// webview holding its own copy loaded once, so two writers can be a save + /// apart. A save whose payload is OLDER than the revision on disk is + /// rejected instead of silently reverting the other window's change (see + /// [`STALE_REVISION_MARKER`]), and [`emit_config_changed`] carries the new + /// revision so every window can adopt the document that was actually + /// persisted. + /// + /// `0` for a fresh install, for every file written before this field + /// existed, and for a client that does not send the field yet — which is why + /// `0` is stamped upward rather than treated as stale. The first save from + /// such a payload stores `1`. + /// + /// Skipped in the TS export: the field is the Rust-side guard until the + /// store half of #943 ships, and exporting it would make the generated + /// `AppConfig` require a member the frontend does not construct yet. + #[serde(default)] + #[ts(skip)] + pub revision: u64, + /// Unknown / future top-level keys, retained across load→save so a newer + /// config file is never silently stripped by an older binary (issue #379). + /// Skipped in the TS export (and omitted from JSON while empty) so + /// `extra` stays byte-identical when empty. (Note: `schema_version` + /// serializes on every save, so full-file byte-identity is not claimed + /// across versions — only `extra` introduces no new bytes.) + #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] + #[ts(skip)] + pub extra: BTreeMap, +} + +impl Default for SpotifyConfig { + fn default() -> Self { + Self { + client_id: String::new(), + client_secret_set: false, + client_secret_state: ClientSecretState::Absent, + redirect_uri: default_redirect_uri(), + extra: BTreeMap::new(), + } + } +} + +impl Default for TeamsConfig { + fn default() -> Self { + Self { + status_format: default_status_format(), + clear_on_pause: default_clear_on_pause(), + profanity_filter: default_profanity_filter(), + profanity_placeholder: default_profanity_placeholder(), + start_minimized: default_start_minimized(), + availability_sync: default_availability_sync(), + presence_gate: default_presence_gate(), + profanity_extra_words: Vec::new(), + respect_manual_status: default_respect_manual_status(), + gate_when_out_of_office: default_gate_when_out_of_office(), + gate_when_presenting: default_gate_when_presenting(), + // Issue #873: `0` = off (the default; an untouched config + // behaves exactly as today). The clamp runs through + // `clamp_teams` so any non-zero value lands in 60..=3600. + idle_away_after_seconds: 0, + // Issue #867: 0 disables the pre-meeting suppression window + // (the previous behaviour, which also matches the documented + // default); any non-zero value is capped at 60 by `clamp_teams`. + pre_meeting_suppress_minutes: 0, + paused_status_format: default_paused_status_format(), + stopped_status_format: default_stopped_status_format(), + preferred_presence: PreferredPresenceConfig::default(), + extra: BTreeMap::new(), + } + } +} + +impl Default for PollingConfig { + fn default() -> Self { + Self { + default_interval_seconds: default_interval_seconds(), + minimum_interval_seconds: default_min_interval_seconds(), + max_interval_seconds: default_max_interval_seconds(), + expiry_buffer_seconds: default_expiry_buffer_seconds(), + pause_backoff_max_seconds: default_pause_backoff_max(), + extra: BTreeMap::new(), + } + } +} + +impl Default for LoggingConfig { + fn default() -> Self { + Self { + enabled: default_logging_enabled(), + log_level: default_log_level(), + max_file_size_mb: default_max_file_size_mb(), + keep_files: default_keep_files(), + // Issue #877: opt-in. A user with a busy rule set could + // otherwise grow the log without bound; the Dashboard's + // "Activity" card is the always-on reading surface. + presence_history: false, + extra: BTreeMap::new(), + } + } +} + +// `Default::default()` can't be derived because `LoggingConfig` uses +// `default_*()` helper functions to seed its fields with non-`Default` +// values (a default log level, a default "enabled" flag). The helper +// calls are intentional, not a candidate for `#[derive(Default)]`. +#[allow(clippy::derivable_impls)] +impl Default for AppConfig { + fn default() -> Self { + Self { + spotify: SpotifyConfig::default(), + teams: TeamsConfig::default(), + polling: PollingConfig::default(), + logging: LoggingConfig::default(), + updates: UpdatesConfig::default(), + playback: PlaybackConfig::default(), + autostart: false, + notifications: NotificationsConfig::default(), + locale: None, + snooze_until: None, + status_rules: StatusRulesConfig::default(), + presence_profiles: Vec::new(), + active_profile: None, + shortcuts: ShortcutsConfig::default(), + extra: BTreeMap::new(), + schema_version: default_schema_version(), + revision: 0, + } + } +} diff --git a/src-tauri/src/config/snooze.rs b/src-tauri/src/config/snooze.rs new file mode 100644 index 00000000..20620c25 --- /dev/null +++ b/src-tauri/src/config/snooze.rs @@ -0,0 +1,212 @@ +use super::schema::AppConfig; +/// Whether a stored snooze deadline is still in the future (4.7.0, S9 / +/// issue #677), as a UTC instant. +/// +/// `None` for an absent, unparsable or already-passed value, so a hand-edited +/// `config.json` can never resurrect a snooze. The parse itself is +/// **tolerant of the offset**: RFC3339 accepts `+02:00` as well as `Z` and both +/// denote an instant, which is what a value re-serialized by another tool (or +/// by a future version that writes local time) may carry — only a *past* +/// instant, a malformed string or a bare date is rejected here. +pub fn snooze_deadline( + stored: &str, + now: chrono::DateTime, +) -> Option> { + let deadline = chrono::DateTime::parse_from_rfc3339(stored.trim()) + .ok()? + .with_timezone(&chrono::Utc); + (deadline > now).then_some(deadline) +} + +/// Whether a config carries a `snooze_until` that is no longer a live deadline +/// (4.7.0, S9 / issue #677) — the non-mutating twin of [`clamp_snooze`], for +/// readers (`load_config`) that must report the state without changing it. +/// +/// True for an absent field? No: an absent field is simply "not snoozed", which +/// is not something to report or clean. True for an unparsable value and for an +/// instant that has passed — both are dead weight that a writer should remove. +pub fn snooze_expired_deadline(cfg: &AppConfig, now: chrono::DateTime) -> bool { + cfg.snooze_until.is_some() && snooze_status(cfg, now).is_none() +} + +/// Drops an expired (or unparsable) `snooze_until` (4.7.0, S9 / issue #677). +/// +/// Returns `true` when the field was cleared. Pure apart from its `now` +/// argument, so the boundary is unit-testable. Only the WRITE paths call it: +/// [`clamped_config`] (so every save normalizes the value) and the two guarded +/// cleaners that own a config-write guard — `poll_once::clear_snooze_if_expired` +/// on the iteration that observes the expiry, and the tray's startup cleaner. +/// `load_config` deliberately uses [`snooze_expired_deadline`] instead: it is a +/// reader on paths that hold no write guard, and clearing in memory there would +/// hide the expiry from the very cleaners that can fix the file. +pub fn clamp_snooze(cfg: &mut AppConfig, now: chrono::DateTime) -> bool { + if !snooze_expired_deadline(cfg, now) { + return false; + } + cfg.snooze_until = None; + true +} + +/// A snooze preset offered by the tray submenu (4.7.0, S9 / issue #677; +/// issue #867 adds the calendar-bound `UntilNextMeetingEnds`). +/// +/// The first two are instant offsets (`now + delta`), which no timezone can +/// move. The third is a LOCAL calendar boundary, which is why it is a variant +/// of its own rather than a `Duration` — see [`next_local_midnight_utc`]. +/// The fourth (issue #867) reads from the Outlook calendar cache and falls +/// back to "until tomorrow" when no meeting is active. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SnoozePreset { + ThirtyMinutes, + OneHour, + UntilTomorrow, + UntilNextMeetingEnds, +} + +/// The deadline a preset denotes (4.7.0, S9 / issue #677; issue #867). +/// +/// Both clocks are arguments rather than read inside, so the "until tomorrow" +/// boundary can be pinned at an exact wall-clock time and timezone in a unit +/// test — the boundary is where a timezone bug would hide. +/// +/// `next_meeting_end` (issue #867) is the end of the meeting currently in +/// progress, computed by the tray handler from the [`crate::calendar`] cache. +/// When `preset` is `UntilNextMeetingEnds` and `next_meeting_end` is `None` +/// (no active meeting, or the cache is empty), the deadline falls back to +/// "until tomorrow" — clicking the entry while no meeting is in progress +/// still produces a valid deadline. +pub fn snooze_preset_deadline( + preset: SnoozePreset, + now_utc: chrono::DateTime, + now_local: chrono::DateTime, + next_meeting_end: Option>, +) -> chrono::DateTime { + match preset { + SnoozePreset::ThirtyMinutes => now_utc + chrono::TimeDelta::minutes(30), + SnoozePreset::OneHour => now_utc + chrono::TimeDelta::minutes(60), + SnoozePreset::UntilTomorrow => next_local_midnight_utc(now_local), + SnoozePreset::UntilNextMeetingEnds => match next_meeting_end { + Some(end) => end, + None => next_local_midnight_utc(now_local), + }, + } +} + +/// The start of the next LOCAL calendar day, as a UTC instant — the "until +/// tomorrow" deadline (4.7.0, S9 / issue #677). +/// +/// ## Semantics +/// +/// "Until tomorrow" means *the next local midnight*, never `now + 24 h`: +/// +/// - `snooze_until` is persisted as a UTC instant, but "tomorrow" is read from +/// the machine's local calendar. Computing the deadline as a fixed offset +/// from `now` would silently stretch or shrink the snooze by the UTC offset: +/// at 23:00 in UTC+13 the user means one hour of quiet, not twenty-five, and +/// at 00:05 in UTC−11 they mean almost a full day. +/// - The boundary is the START of the next day. At exactly local midnight the +/// deadline is a full day away; one second later it is one second short of a +/// day. Either way it is strictly in the future, so an "until tomorrow" +/// snooze is always at least one second long. +/// - A DST transition inside the window changes the real duration, never the +/// wall-clock boundary: a fall-back night lasts 25 hours, a spring-forward +/// night 23. Keeping the calendar boundary is what makes the label true. +/// +/// ## DST edge cases at local midnight +/// +/// - **Ambiguous** — a fall-back repeats local midnight (e.g. +/// `America/Santiago`): the EARLIER of the two instants wins. It is the +/// conservative choice for a deadline (the user asked to stop) and, being +/// derived from the wall clock alone, gives the same answer on every +/// evaluation. +/// - **Gap** — a spring-forward swallows local midnight in a zone whose +/// transition is at 00:00: the first instant that exists on the new day is +/// used, so the deadline lands inside tomorrow instead of on a wall-clock +/// time that never happens. +pub(crate) fn next_local_midnight_utc( + now_local: chrono::DateTime, +) -> chrono::DateTime { + let midnight = (now_local.date_naive() + chrono::Days::new(1)) + .and_hms_opt(0, 0, 0) + .expect("00:00:00 is always a valid wall-clock time"); + resolve_local_forward(&now_local.timezone(), midnight) +} + +/// The earliest UTC instant at or after the local wall-clock time `naive` +/// (4.7.0, S9 / issue #677). +/// +/// `Single` is the normal case, `Ambiguous` takes the earlier instant and +/// `None` (a gap: the wall clock does not exist) walks forward a minute at a +/// time to the first instant that does. A real offset change is minutes, never +/// hours, and the walk is bounded by [`LOCAL_GAP_PROBE_MINUTES`], so it can +/// neither spin nor run long. +pub(crate) fn resolve_local_forward( + tz: &Tz, + naive: chrono::NaiveDateTime, +) -> chrono::DateTime { + let mut probe = naive; + for _ in 0..LOCAL_GAP_PROBE_MINUTES { + match tz.from_local_datetime(&probe) { + chrono::LocalResult::Single(dt) => return dt.with_timezone(&chrono::Utc), + chrono::LocalResult::Ambiguous(earlier, _) => { + return earlier.with_timezone(&chrono::Utc) + } + chrono::LocalResult::None => probe += chrono::TimeDelta::minutes(1), + } + } + // Unreachable for any real timezone — no DST gap is twelve hours deep. + // Reading the wall clock as UTC keeps a menu click from panicking over a + // deadline; the resulting snooze is simply long. + chrono::DateTime::from_naive_utc_and_offset(naive, chrono::Utc) +} + +/// Upper bound on the spring-forward walk in [`resolve_local_forward`]: twelve +/// hours in minutes, far past any real gap (the deepest known is two hours). +const LOCAL_GAP_PROBE_MINUTES: u32 = 720; + +/// The persisted spelling of a deadline: RFC3339, UTC, second precision +/// (`2026-09-17T13:45:00Z`) (4.7.0, S9 / issue #677). +/// +/// One constructor, so the spelling the tray writes and the spelling +/// [`snooze_deadline`] parses cannot drift. Second precision because the +/// presets are whole minutes and a sub-second deadline would only make two +/// equal states look different. +pub fn snooze_store_form(deadline: chrono::DateTime) -> String { + deadline.to_rfc3339_opts(chrono::SecondsFormat::Secs, true) +} + +/// A snooze that is currently active, as the tray renders it (4.7.0, S9 / +/// issue #677). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SnoozeStatus { + /// The stored deadline, as the instant it denotes. + pub deadline: chrono::DateTime, + /// Whole seconds left; always `>= 1`. + pub remaining_seconds: i64, +} + +/// The active snooze for a config, or `None` when there is none / it has +/// passed / the stored value cannot be parsed (4.7.0, S9 / issue #677). +pub fn snooze_status(cfg: &AppConfig, now: chrono::DateTime) -> Option { + let stored = cfg.snooze_until.as_deref()?; + let deadline = snooze_deadline(stored, now)?; + Some(SnoozeStatus { + deadline, + remaining_seconds: (deadline - now).num_seconds(), + }) +} + +/// The countdown the tray and the Dashboard both render: whole minutes left, +/// rounded UP, and never below 1 (4.7.0, S9 / issue #677). +/// +/// Rounding up means a freshly-set 30-minute snooze reads "30 min" rather than +/// "29", and the last minute reads "1 min" rather than "0" for a snooze that is +/// still active. `min(1440)` bounds a hand-edited config's absurd deadline +/// without hiding it. +pub fn snooze_minutes_left(remaining_seconds: i64) -> i64 { + // `i64::div_ceil` is still unstable (`int_roundings`), so round up by hand; + // the `max(0)` makes the pair exact for every input. + let seconds = remaining_seconds.max(0); + // `saturating_add`: a hand-edited absurd deadline must clamp, not panic. + ((seconds.saturating_add(59)) / 60).clamp(1, 24 * 60) +} diff --git a/src-tauri/src/config/transfer.rs b/src-tauri/src/config/transfer.rs new file mode 100644 index 00000000..775b11ba --- /dev/null +++ b/src-tauri/src/config/transfer.rs @@ -0,0 +1,365 @@ +use super::clamp::{ + clamp_locale, clamp_logging, clamp_polling, clamp_presence_profiles, clamp_rules, + clamp_shortcuts, clamp_teams, +}; +use super::io::{clamped_config, replace_with_backup}; +use super::migrate::{migrate_config, stamp_schema_version, SCHEMA_VERSION}; +use super::schema::{AppConfig, ClientSecretState}; +use std::collections::BTreeMap; +/// Timestamp suffix of an export file name — `YYYYMMDD-HHMMSS`, UTC. +pub(crate) fn export_timestamp(at: chrono::DateTime) -> String { + at.format("%Y%m%d-%H%M%S").to_string() +} + +/// Name an export is offered under: +/// `presencejam-config--.json`. +pub fn export_file_name(version: &str, at: chrono::DateTime) -> String { + format!( + "presencejam-config-{}-{}.json", + version, + export_timestamp(at) + ) +} + +/// Walk every `client_secret` key in `value`, calling `visit(dotted_path, value)` +/// for each (issue #916). +/// +/// The ONE traversal behind [`client_secret_paths`] (export/import refusal) and +/// [`legacy_client_secret`] (the legacy-plaintext migration), so the two cannot +/// disagree about where a credential may hide. Nested objects AND arrays are +/// walked: a secret cannot escape by sitting inside the unknown-key retention +/// map, or inside a hand-written nested object, merely because the typed schema +/// has no such field. +pub(crate) fn walk_client_secret_keys( + value: &serde_json::Value, + visit: &mut impl FnMut(&str, &serde_json::Value), +) { + fn walk( + value: &serde_json::Value, + prefix: &str, + visit: &mut impl FnMut(&str, &serde_json::Value), + ) { + match value { + serde_json::Value::Object(map) => { + for (key, child) in map { + let path = if prefix.is_empty() { + key.clone() + } else { + format!("{}.{}", prefix, key) + }; + if key == "client_secret" { + visit(&path, child); + } + walk(child, &path, visit); + } + } + serde_json::Value::Array(items) => { + for (index, child) in items.iter().enumerate() { + walk(child, &format!("{}[{}]", prefix, index), visit); + } + } + _ => {} + } + } + walk(value, "", visit); +} + +/// Collect the dotted paths of every `client_secret` key anywhere in `value`. +/// +/// Only keys are matched — values are irrelevant to the decision, which is why +/// an explicit `null` or a nested object counts here (the import refusal wants +/// every shape) while [`legacy_client_secret`] wants a string. +pub(crate) fn client_secret_paths(value: &serde_json::Value) -> Vec { + let mut out = Vec::new(); + walk_client_secret_keys(value, &mut |path, _| out.push(path.to_string())); + out +} + +/// The plaintext credential the legacy migration acts on (issue #916): the +/// documented pre-v2.6.0 `spotify.client_secret` when the document has one, +/// otherwise the first `client_secret` key anywhere that holds a non-empty +/// string. +/// +/// Before this, only `spotify.client_secret` was read, so a credential at any +/// other path was neither migrated to the keychain nor removed from disk — it +/// was silently dropped. `None` means there is genuinely nothing to migrate. +pub(crate) fn legacy_client_secret(root: &serde_json::Value) -> Option { + let mut found: Vec<(String, String)> = Vec::new(); + walk_client_secret_keys(root, &mut |path, value| { + if let Some(text) = value.as_str().filter(|text| !text.is_empty()) { + found.push((path.to_string(), text.to_string())); + } + }); + let documented = found + .iter() + .find(|(path, _)| path == "spotify.client_secret"); + documented.or(found.first()).map(|(_, text)| text.clone()) +} + +/// Remove every `client_secret` key anywhere in the tree; returns how many +/// were removed. +pub(crate) fn strip_client_secret_keys(value: &mut serde_json::Value) -> usize { + let mut removed = 0; + match value { + serde_json::Value::Object(map) => { + if map.remove("client_secret").is_some() { + removed += 1; + } + for child in map.values_mut() { + removed += strip_client_secret_keys(child); + } + } + serde_json::Value::Array(items) => { + for child in items.iter_mut() { + removed += strip_client_secret_keys(child); + } + } + _ => {} + } + removed +} + +/// Remove `client_secret` keys from ONE unknown-key map (issue #916): the +/// top-level entry, plus any nested inside a retained unknown value. Returns how +/// many keys were removed, so the caller logs a single line. +/// +/// An unknown-key bucket is `#[serde(flatten)]` with no entry-level filter, so a +/// key the codebase treats as a credential everywhere else would otherwise be +/// deserialized, handed to the webview by the `load_config` command and +/// re-serialized on the next save. +pub(crate) fn strip_client_secret_from_extra( + extra: &mut BTreeMap, +) -> usize { + let mut removed = if extra.remove("client_secret").is_some() { + 1 + } else { + 0 + }; + for value in extra.values_mut() { + removed += strip_client_secret_keys(value); + } + removed +} + +/// [`strip_client_secret_from_extra`] over every unknown-key bucket a config +/// carries: the document's own top-level map and each section's (issue #916; +/// the section maps are themselves issue #938). +pub(crate) fn strip_client_secret_from_extras(config: &mut AppConfig) -> usize { + let mut removed = strip_client_secret_from_extra(&mut config.extra); + // The list MUST stay exhaustive over every `extra` bucket the config + // carries. Three of them were missed (#938): `playback`, each entry of + // `presence_profiles`, and `teams.preferred_presence`. A `client_secret` + // left in one of those buckets is deserialized, handed to the webview by + // the `load_config` command and re-serialized on the next save — exactly + // the IPC crossing issue #916 forbids, reachable through a section that + // only gained its retention map later. + for extra in [ + &mut config.spotify.extra, + &mut config.teams.extra, + &mut config.teams.preferred_presence.extra, + &mut config.polling.extra, + &mut config.logging.extra, + &mut config.updates.extra, + &mut config.playback.extra, + &mut config.notifications.extra, + &mut config.status_rules.extra, + &mut config.shortcuts.extra, + ] { + removed += strip_client_secret_from_extra(extra); + } + for profile in &mut config.presence_profiles { + removed += strip_client_secret_from_extra(&mut profile.extra); + } + removed +} + +/// The document an export writes (4.7.0, S5): the persisted shape of `cfg`, +/// clamped exactly as [`save_config`] would write it, with every +/// `client_secret` key and both derived keychain views stripped. +/// +/// The secret strip is a guard, not the normal path — the secret lives in the +/// OS keychain and there is no typed field to carry it — but a secret reaching +/// a file the user is explicitly told to keep or share would undo the keychain +/// migration. Token material never enters `AppConfig` at all. +/// +/// `client_secret_set` / `client_secret_state` go too: they are display +/// projections of *this* machine's keychain (issue #560), so an exported file +/// that carried them would describe the exporting machine to whoever imports +/// it. `load_config` re-stamps both from the real keychain on the next read. +pub fn export_document(cfg: &AppConfig) -> Result { + let mut value = serde_json::to_value(clamped_config(cfg)) + .map_err(|e| format!("Failed to serialize config to JSON: {}", e))?; + let stripped = strip_client_secret_keys(&mut value); + if stripped > 0 { + log::warn!( + "[CFG] export: stripped {} client_secret key(s) from the exported document", + stripped + ); + } + if let Some(spotify) = value + .get_mut("spotify") + .and_then(serde_json::Value::as_object_mut) + { + spotify.remove("client_secret_set"); + spotify.remove("client_secret_state"); + } + // S9 (issue #975): the snooze deadline is RUNTIME state — "pause sync until + // then" on THIS machine — not a setting. An export is advertised as a + // shareable settings copy, so a file exported while sync was paused would + // otherwise hand the recipient the exporter's still-future deadline: that + // install performs no Spotify or Graph work until it passes, and nothing in + // the import flow says a pause came with the file. + if let Some(root) = value.as_object_mut() { + root.remove("snooze_until"); + // An export is a document this app wrote, so it carries the schema + // floor the binary is authoritative for (`stamp_schema_version`): the + // floor only ever raises the value, so a newer source is left alone. + let floor = root + .get("schema_version") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0) + .max(u64::from(SCHEMA_VERSION)); + root.insert("schema_version".into(), serde_json::json!(floor)); + } + serde_json::to_string_pretty(&value) + .map_err(|e| format!("Failed to serialize config to JSON: {}", e)) +} + +/// An imported document that passed validation and normalization. +#[derive(Debug)] +pub struct PreparedImport { + /// Migrated and clamped config, with the keychain views neutralized. + pub config: AppConfig, + /// The exact JSON [`import_config_document`] writes for it. + pub document: String, +} + +/// Validate and normalize an imported config document (4.7.0, S5). +/// +/// Refuses a document carrying a `client_secret` key anywhere (the pre-#560 +/// plaintext shape, or a hand-edited file) instead of quietly dropping it: +/// silently discarding a credential the user meant to import is worse than +/// telling them why it will not be imported. Every other field takes the same +/// route a file read off disk takes — the version migration and all the +/// clamps — so an import cannot introduce a value the UI could not have saved. +pub fn prepare_import(raw: &str) -> Result { + let value: serde_json::Value = + serde_json::from_str(raw).map_err(|e| format!("Imported file is not valid JSON: {}", e))?; + if !value.is_object() { + return Err( + "Imported file is not a PresenceJam configuration (expected a JSON object)".to_string(), + ); + } + + let secrets = client_secret_paths(&value); + if !secrets.is_empty() { + return Err(format!( + "Imported file carries a plaintext client_secret ({}); PresenceJam keeps the Spotify client secret in the OS keychain, never in config.json", + secrets.join(", ") + )); + } + // No strip pass here: `client_secret_paths` matches the *key* regardless of + // value, so every shape of it — a string, an explicit null, a nested object + // — was already refused above. Nothing can reach the document below. + + let mut config: AppConfig = serde_json::from_value(value).map_err(|e| { + format!( + "Imported file does not match the PresenceJam configuration schema: {}", + e + ) + })?; + + // Same ordering as `load_config`: the version dispatcher runs before the + // clamps, so a migration's rewritten values are never re-clamped away. + let from_version = config.schema_version; + migrate_config(&mut config, from_version); + clamp_polling(&mut config.polling); + clamp_teams(&mut config.teams); + clamp_rules(&mut config.status_rules); + clamp_logging(&mut config.logging); + // Issue #869: clamp the profile list + active id on every load + // (mirrors the other `clamp_*` calls). The active-profile pointer + // is cleared if its name no longer matches — a hand-edited config + // or an upgrade that dropped profiles cannot silently land on a + // phantom id. + clamp_presence_profiles(&mut config.presence_profiles, &mut config.active_profile); + // Issue #767: an imported document's locale tag and shortcut bindings take + // the same clamps as a load, so an import cannot introduce a value the UI + // could not have saved. + clamp_locale(&mut config); + clamp_shortcuts(&mut config.shortcuts); + // Deliberately NOT `stamp_schema_version`: `migrate_config` raises the + // version to the floor and passes a *newer* file through at its own + // version, exactly as `load_config` does. Stamping would relabel a + // newer document as if this binary had produced it. + + // The two keychain views describe the machine the file came from, never + // the importing one — `load_config` re-stamps them from the real keychain + // as soon as the import lands. + config.spotify.client_secret_set = false; + config.spotify.client_secret_state = ClientSecretState::Absent; + + // Issue #975: an imported document must never start life paused. The + // deadline belongs to the exporting machine's runtime state — the export + // above no longer writes it — and an older or hand-edited file that still + // carries a future one would silence this machine's polling until it + // passed, with nothing in the UI explaining why. + config.snooze_until = None; + // Same floor as the export path: raising an older document to this binary's + // schema is the migration the loader would apply anyway; a newer document is + // never relabelled (`stamp_schema_version` only ever raises). + stamp_schema_version(&mut config); + + // The rewritten document must not carry the key at all (the issue asserts on + // its absence, not on a null value), exactly as the export path does. + let mut value = serde_json::to_value(&config) + .map_err(|e| format!("Failed to serialize imported config to JSON: {}", e))?; + if let Some(root) = value.as_object_mut() { + root.remove("snooze_until"); + } + let document = serde_json::to_string_pretty(&value) + .map_err(|e| format!("Failed to serialize imported config to JSON: {}", e))?; + Ok(PreparedImport { config, document }) +} + +/// Replace the config at `path` with an imported document (4.7.0, S5). +/// +/// `confirm_overwrite` is the user's decision, asked **after** the document has +/// been validated and only when there is a current file to replace: a decline is +/// a clean no-op that leaves the live file byte-identical and writes no `.bak`. +/// The dialog itself lives in the command (it needs an `AppHandle`); this shape +/// keeps the decision itself testable against real files. +/// +/// The outgoing file is moved to `.bak` first (the same backup path the +/// corrupt-file quarantine uses), so an import is never a one-way door; a +/// missing current file is not an error (a fresh install has nothing to back up) +/// and does not prompt. Refuses before touching disk, so a rejected import +/// leaves both the live config and the previous `.bak` untouched. `Ok(None)` +/// means the user declined. +pub fn import_config_document( + raw: &str, + path: &std::path::Path, + confirm_overwrite: impl FnOnce() -> bool, +) -> Result, String> { + let prepared = prepare_import(raw)?; + + if path.exists() && !confirm_overwrite() { + log::info!( + "[CFG] import: DECLINED by the user; '{}' left untouched", + path.display() + ); + return Ok(None); + } + // Issue #939: stage the incoming document to a same-directory sidecar and + // only then move the live file aside (see `replace_with_backup`). Doing it + // the other way round — rename first, write second — meant a write that + // failed, or a process death between the two, left the user with NO live + // config at all: the next launch booted on defaults and their settings + // existed only in a `.bak` that nothing in the app restores. + replace_with_backup(path, &prepared.document)?; + log::info!( + "[CFG] import: configuration imported into '{}'", + path.display() + ); + Ok(Some(prepared.config)) +} diff --git a/src-tauri/src/redact.rs b/src-tauri/src/redact.rs index c080e236..b9138409 100644 --- a/src-tauri/src/redact.rs +++ b/src-tauri/src/redact.rs @@ -102,7 +102,19 @@ mod tests { ("tray/snooze.rs", include_str!("tray/snooze.rs")), ("tray/devices.rs", include_str!("tray/devices.rs")), ("tray/actions.rs", include_str!("tray/actions.rs")), - ("config.rs", include_str!("config.rs")), + ( + "config.rs", + concat!( + include_str!("config/schema.rs"), + include_str!("config/clamp.rs"), + include_str!("config/snooze.rs"), + include_str!("config/patch.rs"), + include_str!("config/migrate.rs"), + include_str!("config/io.rs"), + include_str!("config/transfer.rs"), + include_str!("config/mod.rs"), + ), + ), ("teams.rs", include_str!("teams.rs")), ("spotify.rs", include_str!("spotify.rs")), ("commands/sync.rs", include_str!("commands/sync.rs")),