diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ac2d37c..7be01a08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,65 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] -Every change merged after 5.0.0 lands here until the release cut, which renames this -section to the released version and opens a fresh empty one (see `docs/RELEASING.md` §2). +Every change merged since 4.7.0 lands here. The `## [5.0.0] - 2026-10-06` section cut on 6 Oct was premature — no `v5.0.0` tag was ever pushed, so the entries are folded back into Unreleased until the real release cut (see `docs/RELEASING.md` §2). ### Added - **Five more UI locales with follow-system language (#984).** The webview ships Spanish, Italian, Polish, Brazilian Portuguese (`pt`, with the regional tag `pt-BR` resolving to it longest-tag-first) and Dutch alongside en/de/fr — the shared `Dict` type keeps all eight at key parity, and the Rust tray/menu tables carry the same eight so native surfaces render the same language. First-run detection prefix-matches every supported code (so `es-ES` no longer falls back to English), and a Settings "Follow the system language" toggle re-resolves from the OS language on every boot and on change. `en` stays the documented fallback for unknown tags. Provenance: the five new locales are model-written with human review pending (see CONTRIBUTING) — usable, but no native-speaker read yet. - -### Changed -- **Tray module split into one file per concern (#756).** `src-tauri/src/tray.rs` becomes `src-tauri/src/tray/` with `mod.rs` (menu ids, `handle_menu_event`, `setup_tray`, `rebuild_tray_menu`/`update_tray_menu`, badge, icon), `cache.rs` (device/queue caches, throttle, fetch mode), `dedup.rs` (state snapshot, playback-mode atoms, window visibility), `snooze.rs` (snooze/profile/manual-status builders and writers), `devices.rs` (device/volume/seek selection and builders), `actions.rs` (player actions, repaint/refresh, write lock), plus `testkit.rs` (shared source-scan helpers). All statics stay in place; the 35 tray tests move next to the arms they guard with identical assertions. No behaviour change. -- **Settings split into per-card components (#750).** New `src/lib/components/settings/` owns the repeated card shell (`SettingsCard`: `.card pane-card` + section header + optional Reset/actions slot) plus all thirteen extracted cards — slice 1: `RulesCard` (quiet hours, track rules, work-hours import, dry-run tester, manual-status pair), `LoggingCard` (`logging.*`), `BackupCard` (export/import), `ShortcutsCard` (capture, validation, registration status); slice 2: `SpotifyCard` (`spotify.*` + reconnect/manual-URL escape hatch), `TeamsCard` (`teams` connection + device-code + provider-routed persist banner), `PresenceCard` (availability sync + presence gates), `StatusFormatCard` (template + live preview + profanity filter/lexicon), `PollingCard` (`polling.*` + clamp hints), `NotificationsCard` (per-class toggles), `AppearanceCard` (theme radiogroup + density + locale + autostart), `ProfilesCard` (`presence_profiles` + active id), `UpdatesCard` (`updates.channel`). Cards take `$bindable()` slices and/or callbacks per their needs (`RulesCard` alone takes both `onreset` and `onchange`; `LoggingCard`/`UpdatesCard`/`ShortcutsCard` bind with no `onreset`; `NotificationsCard`/`BackupCard` are store- or callback-driven with no slice); `Settings.svelte` keeps `localConfig`/`isDirty`/save-discard/`pendingNav` and the `.actions` footer, with only the page-level styles (`.settings`/`.sections`/dirty banner/footer) remaining. `SettingsCard` renders header actions even without Reset (Rules Undo). Shared `normalizeShortcutReason`/`shortcutReasonLabel`/`validateShortcutBinding` live in `src/lib/utils/shortcuts.ts`. -- **Rust emit payloads are ts-rs-typed structs (#762).** New `src-tauri/src/events.rs` owns every structured Tauri event payload (`ErrorEvent` + `ErrorSeverity`/`ErrorRecovery`, `PresencePaused`, `PlaybackStateChanged`, `PresenceGated`, `PresenceUpdated`, `PresenceCleared`, `PresenceAvailabilityUpdated`, `PreferredPresenceUpdated`, `SyncStopped`, `ManualStatusUpdated`, `SpotifySecretConflict`, `ConfigChanged`, `TeamsReconnectRequired`, `AuthPersistWarning`) with `#[ts(export)]` into `src/lib/types-generated/`; all emit sites build the structs instead of inline `json!`, the tray parses `playback-state-changed` through the shared type, and `src/lib/types.ts` re-exports the generated types (the hand-written `ErrorEventPayload` and Dashboard-local `ManualStatusUpdatedPayload` are deleted). `test_event_payload_shapes_are_pinned` now pins all three canonical payloads including the previously unasserted `error` envelope with exact JSON. No wire-shape change. -- **Polling sync flag hardcodes its Acquire/Release ordering (#759).** `Polling::is_syncing()` and `Polling::set_syncing(value)` no longer take an `Ordering` parameter — the Acquire load / Release store pair lives inside the type, so no call site can silently drop the poller-exit → tray/UI happens-before chain with a weaker ordering. `try_claim()` keeps its AcqRel CAS. No behaviour change. -- **Redaction formatting unified behind one shared helper (#910).** `pkce::redact_len` / `pkce::redact_prefix` (both dead, `#[allow(dead_code)]`) are replaced by a single `redact::redact_len` module that owns the `[REDACTED len N]` construction; every deep-link, startup-URL, Spotify-auth, and diagnostics-snapshot site routes through it. The 4-character secret prefix the deep-link logs used to print (24 bits of a base64url secret) is deleted — those lines now log length only. No public function prints a secret prefix anymore. -- **Poll config reads share one immutable snapshot instead of deep-cloning per iteration (#893).** `Config` now holds `RwLock>>` with a `Config::snapshot()` getter that clones the pointer, not the document; `run_inner` and the exit-path cleanup take their config through it and never hold the read guard across the iteration body, so a save racing an in-flight poll completes without blocking and the iteration never observes a partially updated config. Writers publish a new `Arc` under the write guard (`Arc::make_mut` for the in-place keychain-stamp path). The Spotify bundle, write-clock snapshot, and tray dedup clones are untouched. No on-disk format change. -- **Config `u64` fields typed as `number`, not `bigint`, in generated types (#765).** `PollingConfig`, `LoggingConfig`, patch types, `ConfigSummary` counters, snooze minutes and stage-progress bytes carry `#[ts(type = "number")]`, matching the `serde_json` wire shape (JS numbers) and the existing `TrackInfo` override idiom. The `BIGINT_SECTIONS` normaliser, `BigInt(...)` defaults and bigint-aware stringify paths are gone; the store round-trips plain numbers. No value change. -- **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. -- **Tray/config caches move off module statics into `AppState::caches` (#758, PARTIAL — slice 2).** `AppCaches` owns the throttled devices/queue caches, post-action fetch instants, dedup snapshot, window/playing/mode mirrors, delayed-refresh guard, and the config quarantine/conflict flags; `load_config`/`quarantine_corrupt_config`/`emit_spotify_secret_conflict_once`, the tray rebuild/paint helpers, and every window/config/updater/snooze caller thread `&state.caches` instead of touching statics. `CONFIG_QUARANTINED`, `CONFLICT_EVENT_SENT`, and the tray cache statics are deleted and each test constructs its own `AppCaches::new()` (new `test_two_app_states_do_not_share_caches` proves isolation). `QUARANTINE_TEST_LOCK` is deleted; `LOCALE_TEST_LOCK` survives because the locale table (`i18n::CURRENT`) stays process-wide per the issue's keeps, so 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. -- **Polling loop split into one file per concern with shared `PollState` (#754).** `src-tauri/src/polling/poll_once.rs` (11,988 ln) becomes `polling/{clocks,iteration,refresh,gate,rules,presence,status_text,write,timing,exit}.rs` plus a 10-module registry in `mod.rs`; the 17 `&mut` out-params travel as one `&mut PollState` (no `#[allow(clippy::too_many_arguments)]` on `run`/`run_inner`). All 121 polling tests move with their modules with identical assertions (incl. the restored `polling_dead_refresh_clear_uses_replacement_safe_authority` in `refresh.rs`); `Box::leak` in the preferred-presence arm is preserved verbatim. No behaviour change. -### Fixed -- **Lock-ordering and click-ordering guards go behavioural, prose scans annotated (#778-PARTIAL — 8 files).** The #398 snapshot guard (`test_get_sync_status_reads_under_single_critical_section`) no longer greps source text: a held-section probe fires while the snapshot holds its guards and each of the four write guards is `try_write`-refused individually, so rewording the "Single critical section" comment cannot fail the suite while a dropped or early-released guard does. Tray click ordering moves into the pure `tray_click_target` decision fn the dispatcher matches on, pinned by `tray_click_target_orders_every_arm`. Every surviving source scan in the 8 touched files carries a why-not-behavioural comment. Follow-up: the same annotation for the remaining ~25 s… -- **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. -- **The sync-status snapshot serves the previous instant under token contention (#1126).** `commands/sync.rs::sync_status_from_state` probes both token slots with `Tokens::try_spotify()` / `try_teams()` (new `parking_lot` `try_read` seams in `lib.rs`, mirroring `Config::try_get_mut()`) ahead of its critical section, in the same #398 order. A writer holding either slot — the poller's commit, a reconnect/disconnect clear — makes the probe miss instead of parking the snapshot, and the fn returns the last cached `SyncStatus` (`AppState::last_sync_snapshot`, published by the fresh path only). The fallback is never a conservative default: a synthesized `teams_connected: false` would flash the Dashboard's disconnected banner during every refresh. Uncontended output is unchanged. A first call that races a writer before any fresh assembly ever ran has no previous instant to serve, so it takes the blocking fresh path once and fills the cache. The contention log names only the slot (`spotify` / `teams`); no token contents reach it. Closes the #879 residual (suggested fix 2). - -- **The skip link lands on the view body, not the view header (#742).** `id="main-content"` moves off the `.app-container` wrapper in `src/routes/+page.svelte` onto the region below each view's header — the Dashboard's `
`, Settings' `.sections`, the Logs `.log-wrap`, Diagnostics' and Reconnect's `.content`, Onboarding's `.step`, About's card — each with `tabindex="-1"` so the target takes focus without entering the tab order. Only one view mounts at a time, so the id stays unique per document, and the detached route's own pane-level target (#743) is untouched. `tests/skip-link-target.test.ts` pins one-below-the-header placement per view plus the Dashboard → Settings switch; `tests/browser/skip-link.spec.ts` proves the real-browser focus move and the first Tab into the body. -- **Panic and reconnect events survive a view switch (#704):** the last two Dashboard-scoped listeners — `polling-thread-panicked` and the generic `reconnect-required` — move to the always-mounted `+layout.svelte`, mirroring #670/#700. A poller panic or reconnect demand that lands while Settings/Logs/Diagnostics owns the screen now flips the shared sync mirror off instead of being dropped with the destroyed Dashboard; the panic's fatal message is recorded in the shared store so a Dashboard that mounts afterwards still renders it, and the reconnect navigation parks through the #817 dirty-draft guard instead of unmounting the form. - -## [5.0.0] - 2026-10-06 - -### Added - **OS presentation-state gate (#872).** `SHQueryUserNotificationState` is now folded into the existing presence gate through a new `PresentationState` enum behind a swappable trait in `src-tauri/src/platform/focus.rs`. A new opt-in `teams.gate_when_presenting` toggle pauses the Teams status write while a full-screen app, slide deck or Windows Focus Assist Quiet Time is on; the toggle defaults to OFF and is a no-op on Linux/macOS (where the probe answers `Unknown`). The new reason sits at the lowest precedence of the presence-class reasons so it can never outrank `busy` or `in a call`. - **Desktop-idle gate (#873).** A second probe in `src-tauri/src/platform/idle.rs::seconds_since_last_input()` reports seconds since the last keyboard/mouse event — `GetLastInputInfo` on Windows, `None` on Linux/macOS — and feeds a new `teams.idle_away_after_seconds` threshold (0 = off, clamped to 60..=3600 by `clamp_teams`). The first iteration after the gate clears forces exactly one status write via a `force_resume_write` clock that overrides the #384 byte-identical dedup; a Dashboard chip and a Settings card expose both new opt-ins in en/de/fr. - - **A user-opt-in `setUserPreferredPresence` integration (#866).** The Teams config now carries `teams.preferred_presence { enabled, availability, activity, expiry_minutes }`. When the user opts in and `respect_manual_status` is OFF, a matching rule that does not name its own presence pair (or a freshly-armed snooze) drives Graph `setUserPreferredPresence` with the configured Busy/DND/BeRightBack/Away pair; the resulting session is cleared at the configured expiry, at the next snooze-end, and on `RunEvent::Exit`. National-cloud note: `setUserPreferredPresence` is a commercial-Graph surface that sovereign clouds have historically rejected; the call goes through the same `/users/{oid}` fallback the other Graph POSTs use. - **A user-composed manual Teams status with an expiry (#870).** A Dashboard composer + tray "Recent statuses" submenu + `--set-status ` / `--clear-status` CLI flags let the user post a one-off Teams status (bounded to `MAX_RULE_STATUS_CHARS`, profanity-filtered, with an `expiry_minutes` clamped to 5..=720). The status expires locally and on Teams at the same instant, the Dashboard chip shows the active window, and the tray "Clear manual status" entry only appears while one is armed. The recent-statuses ring is capped at 5 entries and dedupes on identical messages. - **Volume, seek, and the documented playback capability flags (#871).** `TrackInfo` now carries the active device's `volume_percent`, `supports_volume`, and `actions` fields (the latter as a typed `DeviceActions` struct mirroring Spotify's documented `device.actions` object). New `player_set_volume` and `player_seek` helpers join the existing `player_*` family; the Dashboard renders a volume slider and a click-to-seek progress bar; the tray gains a Volume submenu (0/25/50/75/100) and a Seek submenu (+/- 30 s) — both disabled when the active device refuses the capability, and the existing shuffle/repeat/previous/next items get the same disable treatment. The capability gate is the same on the Rust side (`commands/playback::set_volume` / `seek`) and on the tray rebuild, so a stale-capabilities click never reaches Graph. - **Bounded status-decision history with a Dashboard "Activity" card (#877).** A new `history` module records every presence-updated, presence-gated, snooze-start, snooze-end, preferred-presence-armed, and preferred-presence-cleared decision into a `VecDeque` ring capped at 200. The Dashboard's "Activity" card renders the newest 20 entries; the Diagnostics snapshot surfaces the same ring as `redact_sensitive`-passed lines (so a credential-shaped `note` cannot reach a public paste); an opt-in JSONL mirror lands in the same `app_log_dir()` folder the `tauri-plugin-log` already targets, gated by `logging.presence_history` (OFF so a noisy rule set cannot grow the log without bound). - **Outlook calendar pre-gate with a same-poll un-gate (#867).** A new `calendar` module owns a cached, 5-minute-throttled `GET /me/calendarView` read with `Prefer: outlook.timezone` and the documented `$select` projection. A busy event (and an optional `pre_meeting_suppress_minutes` window ahead of it, capped at 60 minutes by the same clamp the other Teams fields share) suppresses the status write the same way the existing presence-gate does, with a fresh `GATE_REASON_CALENDAR` reason so the Dashboard chip and snapshot label it precisely. `gate_recheck_due` now also fires when the cached calendar's next boundary passes, so the un-gate lands within one poll of the meeting end rather than waiting up to `AVAILABILITY_REARM_SECONDS` for the cadence to elapse. The tray snooze submenu adds an "Until this meeting ends" entry, shown only while a busy meeting is in progress; clicking it writes the meeting's end as the deadline, with a graceful fall-back to "until tomorrow" when no meeting is active. `Calendars.ReadBasic` is added to the device-code scope list — it forces one Teams re-consent, every failure path is fail-open (a tenant that refuses the scope reproduces today's behaviour exactly), and the Settings copy says so. - **Outlook working-hours import for quiet-hours rules (#876).** The Settings quiet-hours card gains an "Import Outlook working hours" button that calls the new `import_working_hours` IPC command. Rust reads Graph `mailboxSettings/workingHours` (with `MailboxSettings.Read` — one Teams re-consent, gated on the JWT `scp` claim), parses the `startTime` / `endTime` / `daysOfWeek` / `timeZone.offset` payload, and inverts each working day into a `QuietHoursEntry` so a Mon–Fri 09:00–17:00 schedule yields exactly five wrap-around entries (17:00–09:00 on each weekday) plus two full-day entries (Sat/Sun). The result is **previewed**, not persisted — the Settings card shows the proposed rules with a "Replace existing" toggle, and only `update_config` (the same read-merge-write lock every other edit uses) writes them to disk. A tenant that refuses the scope, a session that has not yet re-consented, or an Outlook user who has cleared the Work hours tab each produce a non-preview variant with the user-visible reconnect/try-again message and no rule overwrite. - - **Token-guarded localhost control and event API (`--serve[=PORT]`, #865).** A new `--serve` flag binds `127.0.0.1:PORT` (default `8649`) and serves a small HTTP API: `GET /status` returns the same JSON shape as `--status`; `GET /events` streams the three presence-related Tauri events (`presence-updated`, `spotify-track-changed`, `presence-gated`) as Server-Sent Events; `POST /pause`, `POST /resume`, `POST /snooze?minutes=N` and `POST /profile?id=` are mutating and require `Authorization: Bearer `. The token is 32 random bytes (base64url, 43 chars) generated and stored in the OS keychain under `serve_token:com.presencejam.app` — never on disk in plaintext, never in argv, never in env. Constant-time token comparison is the helper's only equality check; `redact_sensitive` scrubs the credential from any future log line. No route writes `config.json` or `tokens.json`; the keychain slot is the only place the token lives. Docs (`docs/HEADLESS.md`, `docs/API.md`) covering the new flag and the operator's first-boot token-retrieval steps are deferred to the docs slice. - **Supervised headless daemon mode (`--daemon`, #896).** A new `--daemon` flag runs the same poller the GUI runs, but windowless: no tray icon, no app menu, no deep-link registration, no single-instance lock. A new `polling::daemon::run` supervisor installs SIGTERM/SIGINT handlers (Unix), starts the poller, and blocks on either a stop signal or a self-exiting poller; SIGTERM produces exit 0 (the systemd `ExecStop=` / launchd `Stop` contract). Three packaging units ship in `packaging/`: `systemd/presencejam.service` (`Restart=on-failure`, `Type=simple`, `WantedBy=default.target`), `launchd/com.presencejam.daemon.plist` (`KeepAlive SuccessfulExit=false`, `RunAtLoad`), and `windows/presencejam-task.xml` for Task Scheduler (`RunAtLogon`, `Delay=PT30S`, `HighestAvailable`). A locked or missing keychain at boot retries with exponential backoff (`token_io::read_or_create_serve_token_with_backoff`, 6 attempts, 1–30 s) rather than exiting 1. +- **Track rules now extend across device, album, show, playlist, and duration, with an explainable dry-run tester (#868).** `TrackRuleEntry` adds `match_kind` (`Substring | Exact | Glob`), `album_substring`, `show_substring`, `device_substring`, `playlist_uri`, `min_duration_seconds` (capped at 24 h by `clamp_track_rule_action`), `negate`, and a discriminated `action` (`Suppress | Replace { status } | SnoozeMinutes { value } | Profile { id } | Presence { availability, activity }`). The live `process_track` walker now feeds `album` / `show` / `device` / `playlist_uri` / `duration_ms` from the `NowPlaying` poll body; the new Settings "Test these rules" card runs the SAME walker through a new `explain_rules` Tauri command and surfaces a per-rule reason chain (matched / not matched / disabled / outside-window / negate-flipped) plus a one-line headline. A new Dashboard "why" `
` row expands under the existing `presence-gated` chip and re-runs the tester with the current track. The legacy `replacement_status` / `presence_availability` / `presence_activity` flat fields keep their documented "post this text instead of the track template" semantics: `decision_from_rule` projects through `action` first and falls back to the legacy fields when `action: Suppress` carries a populated replacement — so the pre-#868 Settings UI does not silently lose its rule text. +- **Named presence profiles, switchable from the tray, a hotkey and the CLI (#869).** `AppConfig` carries `presence_profiles: Vec` and `active_profile: Option`; a `PresenceProfile` is a named overlay of `status_format`, `clear_on_pause`, `availability_sync`, the gate flags (`gate_when_out_of_office`, `gate_when_presenting`, `idle_away_after_seconds`), `preferred_presence`, a `track_rules` subset, and `notifications`. Names are unique and at most 32 characters (`clamp_presence_profiles`); an unknown `active_profile` id clears to `None`. Switching resolves at READ time through `effective_config(&cfg)` (a non-mutating overlay merge — no on-disk write) so the tray "Active profile" submenu beside the snooze items (with a "Base configuration" sentinel that clears the active id), the `presencejam --profile ` CLI flag, and a Settings "Presence profiles" card all share the same switch path. The profile list travels through the existing config export / import (`presence_profiles` and `active_profile` are added to `IMPORT_SECTION_KEYS` and the schema-version floor rises from 2 to 3 — both additive with `#[serde(default)]`, so a pre-5.0 config loads unchanged). A third shortcut slot binds the same switch path; the dispatcher wiring lives in `commands/shortcuts.rs` and is intentionally left to that slice, which owns the slot enum and its registration. +- **OS-aware playback source (`Auto` / `System` / `Spotify`, #862).** A new `src-tauri/src/sources/` module defines `trait PlaybackSource { fn poll(&mut self) -> Result, SourceError>; fn capabilities(&self) -> SourceCaps; }` with a flat `NowPlaying` shape that maps 1:1 onto the existing `TrackInfo` (artist, title, album, album art URL, progress, duration, is_playing) — so `process_track`, the rules walker, the status formatter and the presence gate need no change. Three implementations ship: `sources::spotify::SpotifySource` (wraps `get_currently_playing` and owns the existing `If-None-Match` cache so the conditional-GET round-trip is preserved), `sources::smc::SmcSource` (Windows `windows::Media::Control::GlobalSystemMediaTransportControlsSessionManager`, picks the most-recently-updated session whose `PlaybackStatus == Playing`), and `sources::mpris::MprisSource` (Linux `zbus`, walks `org.mpris.MediaPlayer2.*`, prefers the previous winner and falls back to the first `PlaybackStatus == "Playing"`). An `AutoSource` wrapper picks the OS source first and falls back to Spotify when the OS source returns no session or fails. `AppConfig` carries `playback.source` (`auto | spotify | system`, default `auto`); a kind change from Settings rebuilds the source on the next poll, the Spotify ETag cache and the system-source singletons drop with the old source. ### Changed +- **Tray module split into one file per concern (#756).** `src-tauri/src/tray.rs` becomes `src-tauri/src/tray/` with `mod.rs` (menu ids, `handle_menu_event`, `setup_tray`, `rebuild_tray_menu`/`update_tray_menu`, badge, icon), `cache.rs` (device/queue caches, throttle, fetch mode), `dedup.rs` (state snapshot, playback-mode atoms, window visibility), `snooze.rs` (snooze/profile/manual-status builders and writers), `devices.rs` (device/volume/seek selection and builders), `actions.rs` (player actions, repaint/refresh, write lock), plus `testkit.rs` (shared source-scan helpers). All statics stay in place; the 35 tray tests move next to the arms they guard with identical assertions. No behaviour change. +- **Settings split into per-card components (#750).** New `src/lib/components/settings/` owns the repeated card shell (`SettingsCard`: `.card pane-card` + section header + optional Reset/actions slot) plus all thirteen extracted cards — slice 1: `RulesCard` (quiet hours, track rules, work-hours import, dry-run tester, manual-status pair), `LoggingCard` (`logging.*`), `BackupCard` (export/import), `ShortcutsCard` (capture, validation, registration status); slice 2: `SpotifyCard` (`spotify.*` + reconnect/manual-URL escape hatch), `TeamsCard` (`teams` connection + device-code + provider-routed persist banner), `PresenceCard` (availability sync + presence gates), `StatusFormatCard` (template + live preview + profanity filter/lexicon), `PollingCard` (`polling.*` + clamp hints), `NotificationsCard` (per-class toggles), `AppearanceCard` (theme radiogroup + density + locale + autostart), `ProfilesCard` (`presence_profiles` + active id), `UpdatesCard` (`updates.channel`). Cards take `$bindable()` slices and/or callbacks per their needs (`RulesCard` alone takes both `onreset` and `onchange`; `LoggingCard`/`UpdatesCard`/`ShortcutsCard` bind with no `onreset`; `NotificationsCard`/`BackupCard` are store- or callback-driven with no slice); `Settings.svelte` keeps `localConfig`/`isDirty`/save-discard/`pendingNav` and the `.actions` footer, with only the page-level styles (`.settings`/`.sections`/dirty banner/footer) remaining. `SettingsCard` renders header actions even without Reset (Rules Undo). Shared `normalizeShortcutReason`/`shortcutReasonLabel`/`validateShortcutBinding` live in `src/lib/utils/shortcuts.ts`. +- **Rust emit payloads are ts-rs-typed structs (#762).** New `src-tauri/src/events.rs` owns every structured Tauri event payload (`ErrorEvent` + `ErrorSeverity`/`ErrorRecovery`, `PresencePaused`, `PlaybackStateChanged`, `PresenceGated`, `PresenceUpdated`, `PresenceCleared`, `PresenceAvailabilityUpdated`, `PreferredPresenceUpdated`, `SyncStopped`, `ManualStatusUpdated`, `SpotifySecretConflict`, `ConfigChanged`, `TeamsReconnectRequired`, `AuthPersistWarning`) with `#[ts(export)]` into `src/lib/types-generated/`; all emit sites build the structs instead of inline `json!`, the tray parses `playback-state-changed` through the shared type, and `src/lib/types.ts` re-exports the generated types (the hand-written `ErrorEventPayload` and Dashboard-local `ManualStatusUpdatedPayload` are deleted). `test_event_payload_shapes_are_pinned` now pins all three canonical payloads including the previously unasserted `error` envelope with exact JSON. No wire-shape change. +- **Polling sync flag hardcodes its Acquire/Release ordering (#759).** `Polling::is_syncing()` and `Polling::set_syncing(value)` no longer take an `Ordering` parameter — the Acquire load / Release store pair lives inside the type, so no call site can silently drop the poller-exit → tray/UI happens-before chain with a weaker ordering. `try_claim()` keeps its AcqRel CAS. No behaviour change. +- **Redaction formatting unified behind one shared helper (#910).** `pkce::redact_len` / `pkce::redact_prefix` (both dead, `#[allow(dead_code)]`) are replaced by a single `redact::redact_len` module that owns the `[REDACTED len N]` construction; every deep-link, startup-URL, Spotify-auth, and diagnostics-snapshot site routes through it. The 4-character secret prefix the deep-link logs used to print (24 bits of a base64url secret) is deleted — those lines now log length only. No public function prints a secret prefix anymore. +- **Poll config reads share one immutable snapshot instead of deep-cloning per iteration (#893).** `Config` now holds `RwLock>>` with a `Config::snapshot()` getter that clones the pointer, not the document; `run_inner` and the exit-path cleanup take their config through it and never hold the read guard across the iteration body, so a save racing an in-flight poll completes without blocking and the iteration never observes a partially updated config. Writers publish a new `Arc` under the write guard (`Arc::make_mut` for the in-place keychain-stamp path). The Spotify bundle, write-clock snapshot, and tray dedup clones are untouched. No on-disk format change. +- **Config `u64` fields typed as `number`, not `bigint`, in generated types (#765).** `PollingConfig`, `LoggingConfig`, patch types, `ConfigSummary` counters, snooze minutes and stage-progress bytes carry `#[ts(type = "number")]`, matching the `serde_json` wire shape (JS numbers) and the existing `TrackInfo` override idiom. The `BIGINT_SECTIONS` normaliser, `BigInt(...)` defaults and bigint-aware stringify paths are gone; the store round-trips plain numbers. No value change. +- **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. +- **Tray/config caches move off module statics into `AppState::caches` (#758, PARTIAL — slice 2).** `AppCaches` owns the throttled devices/queue caches, post-action fetch instants, dedup snapshot, window/playing/mode mirrors, delayed-refresh guard, and the config quarantine/conflict flags; `load_config`/`quarantine_corrupt_config`/`emit_spotify_secret_conflict_once`, the tray rebuild/paint helpers, and every window/config/updater/snooze caller thread `&state.caches` instead of touching statics. `CONFIG_QUARANTINED`, `CONFLICT_EVENT_SENT`, and the tray cache statics are deleted and each test constructs its own `AppCaches::new()` (new `test_two_app_states_do_not_share_caches` proves isolation). `QUARANTINE_TEST_LOCK` is deleted; `LOCALE_TEST_LOCK` survives because the locale table (`i18n::CURRENT`) stays process-wide per the issue's keeps, so the issue stays open. No behaviour change in the single-session path. - **Warm keychain presence avoids repeated OS probes during config loads (#881).** `config::load_config` reuses a fresh `Present` observation from the warm cache first; a cold or expired `Present` entry, or an `Unavailable` result, falls back to the direct tri-state probe. `Present`, `Absent`, and `Unavailable` semantics remain distinct, and `config::with_keychain_flags` stamps both derived fields from the result. - **Cached shared HTTP client, validated playback device ids, unified provider log tags (#884, #822, #777 — #1132).** The Teams client is now built once in a new `src-tauri/src/http.rs` alongside the shared retry/expiry helpers instead of per call site; `playback_transfer` percent-encodes and validates the device id rather than passing it through; and `teams.rs` / `spotify.rs` carry a `[TEAMS]` / `[SPOTIFY]` tag constant with a guard test so a new failure path cannot log untagged. - **Dashboard track-change events are typed at the IPC boundary (#780).** The `spotify-track-changed` listener uses `listen`, assigns the typed payload to the track card, and forwards the same `TrackInfo` to notifications. - **`#932` rework — the per-path `commit_manual_spotify_session` / `commit_callback_spotify_session` wrappers are removed.** Both Spotify commit paths now route through the single `commit_spotify_session` seam directly, and two source guards in `commands/spotify_auth.rs` (`manual_paste_handler_uses_commit_spotify_session_seam`, `deep_link_handler_uses_commit_spotify_session_seam`) pin the call sites: any future regression that re-introduces the pre-#932 `token_io::persist_tokens(...)?` short-circuit, or that re-adds a per-path wrapper between the production call site and the seam, fails the suite. The behavioural tests for the manual-paste and deep-link paths now drive `commit_spotify_session` directly with the path's log prefix instead of going through a wrapper. The `routeReconnect` persistence-banner routing is extracted to `src/lib/utils/routeReconnect.ts` (`pickReconnectProvider`) and a new `tests/routeReconnect.test.ts` asserts the routing — the commit message on `e833931` claimed such a test existed but `grep -rn "routeReconnect" tests/` returned nothing before this rework. - - **Fixed control, artwork and log-column sizes are density tokens (#960).** `:root` declares `--ctl-h: 36px`, `--ctl-h-sm: 32px`, `--art-lg: 88px`, `--badge-fs: 11px`, `--spinner-size: 24px` and `--log-col-ts: 88px` (reusing the existing `--swatch-h`); compact density shrinks only artwork, badge, spinner and swatch, so every button keeps a ≥32px hit target in both densities. The icon buttons, album art, log grid, level badge, dismiss button and info icon paint through tokens. `tests/hygiene.test.ts` asserts the tokens, the compact floor and the literal-free selectors. - **Empty-state and spinner are shared primitives (#961).** `.empty-state` (+ `.small` / `p` / `.hint`) is one `src/app.css` rule beside the #751 pane primitives; LogViewer's full-height fill and Diagnostics' left-pinned load-error hint are the only locals left. Diagnostics' loading branch renders the shared spinner above the collecting label (`aria-hidden="true"`, label in the status region). `tests/hygiene.test.ts` pins the single definitions and `tests/diagnostics-loading.test.ts` pins the loading-branch node. - -### Added -- **Track rules now extend across device, album, show, playlist, and duration, with an explainable dry-run tester (#868).** `TrackRuleEntry` adds `match_kind` (`Substring | Exact | Glob`), `album_substring`, `show_substring`, `device_substring`, `playlist_uri`, `min_duration_seconds` (capped at 24 h by `clamp_track_rule_action`), `negate`, and a discriminated `action` (`Suppress | Replace { status } | SnoozeMinutes { value } | Profile { id } | Presence { availability, activity }`). The live `process_track` walker now feeds `album` / `show` / `device` / `playlist_uri` / `duration_ms` from the `NowPlaying` poll body; the new Settings "Test these rules" card runs the SAME walker through a new `explain_rules` Tauri command and surfaces a per-rule reason chain (matched / not matched / disabled / outside-window / negate-flipped) plus a one-line headline. A new Dashboard "why" `
` row expands under the existing `presence-gated` chip and re-runs the tester with the current track. The legacy `replacement_status` / `presence_availability` / `presence_activity` flat fields keep their documented "post this text instead of the track template" semantics: `decision_from_rule` projects through `action` first and falls back to the legacy fields when `action: Suppress` carries a populated replacement — so the pre-#868 Settings UI does not silently lose its rule text. -- **Named presence profiles, switchable from the tray, a hotkey and the CLI (#869).** `AppConfig` carries `presence_profiles: Vec` and `active_profile: Option`; a `PresenceProfile` is a named overlay of `status_format`, `clear_on_pause`, `availability_sync`, the gate flags (`gate_when_out_of_office`, `gate_when_presenting`, `idle_away_after_seconds`), `preferred_presence`, a `track_rules` subset, and `notifications`. Names are unique and at most 32 characters (`clamp_presence_profiles`); an unknown `active_profile` id clears to `None`. Switching resolves at READ time through `effective_config(&cfg)` (a non-mutating overlay merge — no on-disk write) so the tray "Active profile" submenu beside the snooze items (with a "Base configuration" sentinel that clears the active id), the `presencejam --profile ` CLI flag, and a Settings "Presence profiles" card all share the same switch path. The profile list travels through the existing config export / import (`presence_profiles` and `active_profile` are added to `IMPORT_SECTION_KEYS` and the schema-version floor rises from 2 to 3 — both additive with `#[serde(default)]`, so a pre-5.0 config loads unchanged). A third shortcut slot binds the same switch path; the dispatcher wiring lives in `commands/shortcuts.rs` and is intentionally left to that slice, which owns the slot enum and its registration. +- **The polling loop drives playback through `&mut dyn PlaybackSource`.** The pre-5.0 direct `get_currently_playing(&access_token, last_etag.as_deref())` call in `poll_once::run_inner` now goes through a trait object — the Spotify ETag cache and the system source singletons persist across iterations, the `last_source_kind` comparison key rebuilds the source on a kind change, and the existing `SourceError` taxonomy maps every `SpotifyApiError` arm (ExpiredToken → Auth, NotPremium → Auth, RateLimited → Transient, etc.) so the retry, 401-retry and 5-strikes paths keep their documented semantics. On Windows and Linux the system source runs even when the user has not connected Spotify, so a non-Premium user gets a working status from whatever else is playing on the desktop; on macOS the system source has no implementation (Apple has no public API for another app's now-playing) and the `Onboarding.svelte` wizard surfaces a platform-aware note explaining the Spotify-only fallback. Slack and Discord sinks are out of scope. The `NowPlaying` field set stays identical to the existing `TrackInfo` field set, so `matching_track_rule_at` and the formatter pipeline need no change. +- **The shell renders its page through the `children` snippet instead of `` (#779).** `src/routes/+layout.svelte` declares `let { children } = $props()` and renders `{@render children?.()}` inside the existing `{:else}` branch, so the last Svelte 4 API in the codebase is gone. `grep -rn '`, Settings' `.sections`, the Logs `.log-wrap`, Diagnostics' and Reconnect's `.content`, Onboarding's `.step`, About's card — each with `tabindex="-1"` so the target takes focus without entering the tab order. Only one view mounts at a time, so the id stays unique per document, and the detached route's own pane-level target (#743) is untouched. `tests/skip-link-target.test.ts` pins one-below-the-header placement per view plus the Dashboard → Settings switch; `tests/browser/skip-link.spec.ts` proves the real-browser focus move and the first Tab into the body. +- **Panic and reconnect events survive a view switch (#704):** the last two Dashboard-scoped listeners — `polling-thread-panicked` and the generic `reconnect-required` — move to the always-mounted `+layout.svelte`, mirroring #670/#700. A poller panic or reconnect demand that lands while Settings/Logs/Diagnostics owns the screen now flips the shared sync mirror off instead of being dropped with the destroyed Dashboard; the panic's fatal message is recorded in the shared store so a Dashboard that mounts afterwards still renders it, and the reconnect navigation parks through the #817 dirty-draft guard instead of unmounting the form. - **Spotify sign-in keeps the live session when the encrypted token write fails (#932).** The manual-paste command (`complete_spotify_auth_manual`) and the deep-link callback (`handle_spotify_callback` in `lib.rs`) now share a `commit_spotify_session` helper that mirrors the Teams policy from #562: the `?` on `token_io::persist_tokens` is replaced with a `match`, so a locked keychain, full disk or failed AES-key write leaves the in-memory session in `AppState`, invalidates the onboarding cache, emits `spotify-auth-complete` and returns `Ok` instead of propagating an IPC error the UI would render as a sign-in failure. The persistence gap surfaces on a new `spotify-auth-persist-warning` event that reuses the existing Settings banner — the Teams warning's payload shape is generalized to `{ provider, message }` so the same banner renders both providers and the retry routes to `reconnect_spotify_session` for Spotify / `reconnectTeams` for Teams. No tokens, refresh tokens, client secrets or PKCE verifiers are ever logged. **Wire-format break:** `teams-auth-persist-warning` now emits `{ provider: "teams", message: }` instead of a bare `string`; external listeners that subscribed to the old shape will receive the stringified object. - **The update check and the deferred download are now bounded, and `.deb`/`.rpm` installs can actually update (#940, #894, #782 — #1133).** A hanging endpoint can no longer pin the updater, because both paths have timeouts. `install_method_for(bundle_type())` selects the install path from the running bundle, so the Debian/Ubuntu install `SETUP.md` recommends previously could never update at all — `latest.json` points `linux-x86_64` at the AppImage while the `.deb` installer rejects AppImage bytes as `invalid updater binary format`. The updater payloads are now exported through ts-rs rather than hand-mirrored in `src/lib/types.ts`. - **Beta updater checks use the rolling prerelease manifest (#895).** `BETA_ENDPOINT` now points at `releases/download/beta/latest-beta.json` rather than the stable-release `releases/latest/` path, and a regression test rejects the structurally incorrect stable URL. @@ -125,6 +110,22 @@ section to the released version and opens a fresh empty one (see `docs/RELEASING - **The Tauri pin row cites the current manifest (#855).** `docs/STATE-OF-FEATURES.md` quotes `tauri ~2.11` at `src-tauri/Cargo.toml:45` with the deliberate-edit + Dependabot re-check clause; zero `v4.2.0` references remain. - **The Dashboard snooze chip stops announcing its countdown every second (#736).** The chip's `role="status"` is moved off the per-second countdown onto a sibling `.snooze-status` node whose text is set once on entry ("Sync paused for X minutes") and once on exit ("Sync resumed"); the visible countdown now lives in a plain `.snooze-countdown` span with no live semantics and no `aria-hidden`. The 1 s tick is preserved (the chip already carried seconds resolution, and slowing it would be a UX regression), and the new timer that drives the exit announcement is cleared on destroy. - **The sync-status snapshot stops holding the token slots across its tail work (#879).** `commands/sync.rs::sync_status_from_state` took its four read guards for the whole fn, so the Dashboard's `get_sync_status` held `current_track`, `tokens.spotify`, `config` and `tokens.teams` while it loaded the write clocks, cloned the manual-status record (a `std::sync::Mutex`) and formatted its log line — queueing the poller's track store, token commit and config save behind work that needed no lock. The guards now cover only the values that must share one instant (the `current_track` clone, the connected/paused booleans derived from the guarded values, `is_syncing`, `secret_conflict` and the write clocks — the poller publishes a track and then that track's clocks, so the clocks read must stay inside) and are dropped before the manual-status read, the struct assembly and the log line. A regression test stands a writer on all four slots at the release instant and asserts it gets in; removing any one of the four releases fails it. The command's `spawn_blocking` offload, which keeps the UI thread off these locks entirely, landed in #1002 and is unchanged. +- **The paused-track clear retries once after a Teams token refresh and classifies its failures (#929).** The three Teams write paths (the playing write in `process_track`, the no-track clear in `handle_no_track`, and the paused-track clear in `process_track`) now share one helper, `teams_write_with_optional_refresh` in `src-tauri/src/polling/poll_once.rs`, that owns the reactive 401 refresh, `cas_refresh_teams` commit, `token_io::persist_tokens`, single retry, and typed classification. A 401 the local expiry check did not predict (server-side revocation, clock skew) used to fail silently on the paused branch and leave Teams showing the music status with no error event and no `teams-reconnect-required` prompt to the UI; the paused arm now retries once and surfaces `teams-reconnect-required` on `ExpiredToken` / `InvalidGrant` / `ReauthRequired` exactly like the playing arm. The paused arm additionally emits `emit_error(Warning)` for transient / rate-limited / other failures so the Dashboard sees a Warning instead of a stuck silent state. The playing write and no-track clear share the same helper, so the three sites cannot drift in their 401-handling shape again. +- **Color-scheme follows the painted theme, not the OS preference (#959).** `[data-theme='dark']` and `[data-theme='light']` in `src/app.css` now declare `color-scheme: dark / light`, and the `` in `+layout.svelte` is driven by the live `appliedTheme` store. Native form controls and scrollbars match what the app actually paints (a dark-OS machine running the light theme now gets light native surfaces, and vice versa). +- **German tray wording aligned with the webview terminology (#901).** Seven Rust tray/menu German strings (open-logs-folder, status-syncing and its no-track sibling, pause-sync, resume-sync, manual-status-clear, profile-empty) now use the same nouns as the webview Settings card and Dashboard chip — German was the only locale naming the same control or state two different ways in adjacent UI. +- **The update banner honours the theme token system (#902).** Six dead tokens (`--r-xl`, `--ease-in-out`, `--z-banner`, `--partner`, `--partner-soft`, `--bg-overlay`) and two dead classes (`button.ghost` / `.btn-ghost`, `.text-muted`) are removed from `src/app.css`. The banner's hardcoded `z-index: 1000;` and `box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);` are replaced by `--z-update-banner` and `--shadow-update-banner`. +- **The tray and deep-link entry points can no longer panic on an unmanaged `AppState` (#937) — defence-in-depth, not a fix for a reachable crash.** `force_tray_refresh` and `handle_deep_link` each looked the managed state up with `app.state::>()`, which panics with "state() called before manage()" if the lookup runs before the setup closure's `app.manage(state.clone())`. Both now go through one `try_state` lookup that returns early with a `[TRAY]` / `[DEEP_LINK]` `warn!` (raised from `debug!`, which the default tauri-plugin-log level filters) when the state is absent, and each drops a redundant second `app.state::<>()` on the same handle it already holds. Traced honestly: **no current caller can produce that window.** `force_tray_refresh` is reached only from the tray menu arms and the `save_config` locale repaint, both after `manage()`; the forwarded-second-launch path goes through `refresh_tray_from_state` → `repaint_tray_from_state`, which has used `try_state` since before this change; and both `handle_deep_link` call sites (`get_current()`'s drain and the `on_open_url` listener) are registered in setup after `manage()` — the deep-link plugin's `handle_cli_arguments` does run during `Builder::build`, but its `deep-link://new-url` emit has no listener yet, so the URL survives only in the plugin's `current` slot and is drained later. A deep link that *did* arrive unmanaged is logged and dropped, not replayed after setup (#1122). The managed-state lookup and the tray rebuild / token-exchange spawn are injected seams, so three behavioural unit tests drive the unmanaged and managed arms of both functions with no GUI runtime and no `tauri::test` mock — which also keeps Tauri's empty `test` feature out of the test binary, the flag that breaks the Windows lib test binary's load with `STATUS_ENTRYPOINT_NOT_FOUND`. +- **The logger flushes before the build-failure exit (#947).** `lib.rs::run`'s `Builder::build` failure arm now calls `log::logger().flush()` before `std::process::exit(1)`, mirroring the CLI failure path. The single diagnostic explaining "the app will not start" can no longer be lost to tauri-plugin-log's file-target buffer. +- **LogViewer toolbar wrap and overflow contract (#948, #1134).** The toolbar now wraps (`flex-wrap: wrap` plus `row-gap`) and the level strip shrinks and scrolls its own overflow instead of forcing the row wider. At the shipped 600x750 default the toolbar previously overflowed the pane; at the 400x500 declared minimum the six-button strip was 425px inside a 360px pane. +- **Diagnostics Save/Copy/dismiss coverage (#781, #1134).** Those three actions now have executing tests rather than none. +- **Settings save/revert in the unsaved-changes banner (#966, #1135).** Commit and discard no longer require scrolling to the footer. +- **Undo for a removed quiet-hours row or track rule (#981, #1135).** Restores the row with its exact prior field values and a sensible focus position. +- **Theme preview swatches driven by tokens (#904, #1135).** `--preview-dark-*`, `--preview-light-*` and `--swatch-h` live in `src/app.css`; no hex literal remains in the component. +- **Status snapshot no longer holds the token locks through its tail (#879, #1125).** `sync_status_from_state` releases the guards once it has cloned what it needs, and the write-clock read stays inside the guarded section so `current_track` and its clocks still come from one instant. +- **A second launch arriving before `AppState` is managed no longer panics (#937, #1123).** Both the tray-refresh and deep-link paths tolerate a missing managed state instead of calling `state()` before `manage()`. +- **Blocking Tauri commands run off the main thread (#928, #1130).** The remaining blocking commands moved behind `spawn_blocking` or became `async`, with per-command thread-id tests. +- **Config: unknown keys survive a round trip, `schema_version` never lowers, imports stage first (#767, #938, #939, #1131).** Every section struct carries `#[serde(flatten)] pub extra`; `stamp_schema_version` uses `.max()`; `replace_with_backup` stages an incoming file before moving the live one aside. +- **Detached Logs and Settings panes have a working skip link (#743).** The shared layout renders "Skip to main content" in every webview, but `src/routes/detached/[pane]/+page.svelte` mounted `LogViewer` / `Settings` bare, so in a popped-out pane the link was the first tab stop with no fragment target anywhere in the document. The route now wraps every pane branch (including the unknown-pane notice) in a container carrying `id="main-content"` and `tabindex="-1"`, mirroring the main window's `.app-container`, so activating the link moves focus into the pane body. The main window's copy of the id is untouched (#742 owns moving it). Covered by `tests/detached-skip-link.test.ts` and `tests/browser/detached-skip-link.spec.ts`. ### Security - **Unknown menu events no longer log payload-bearing or raw device ids (#918).** The shared dispatcher preserves known fixed ids but logs payload/unknown ids only as a safe prefix plus length; tests cover both the formatting helper and the production unknown-event record. @@ -132,50 +133,24 @@ section to the released version and opens a fresh empty one (see `docs/RELEASING - **The packaged webview CSP blocks form submissions (#924).** The Tauri CSP explicitly retains `base-uri 'self'` and adds `form-action 'none'`; the configuration-pinning test prevents either directive from disappearing. - **Diagnostics saves are Rust-owned and bounded (#921).** The webview supplies no JSON or destination; Rust rebuilds the typed snapshot, enforces the 256 KiB limit and typed-shape validation, then atomically publishes the generated Downloads file. - **Headless CLI token reads are strictly read-only (#840).** `--status` and headless reads use `TokenReadMode::ReadOnly`: legacy plaintext is parsed without keychain-key creation, migration, chmod, rename, or rewrite; GUI reads retain `MigrateLegacy`. - - **Detached windows are now opened from Rust.** The main window no longer carries the unscoped `core:webview:allow-create-webview-window` grant — any script in that window could previously raise a chromed window on an arbitrary origin. The grant is removed from `src-tauri/capabilities/default.json` and the windows are built by the new `detach_pane` command from a fixed label/URL/size table (#922). - **The IPC guard matrix is enforced by a test and covers all 54 registered commands (#771).** `commands/mod.rs::test_guard_matrix_covers_every_registered_command` brace-counts the `generate_handler![...]` list out of `lib.rs` and asserts every registered name appears backticked in the caller-location matrix and vice versa, so a command added without its guarded / main-only-by-caller-location / detached-legit justification fails `cargo test`. The seven previously unlisted entries are documented: `load_config` and `set_locale` as detached-legit (the detached Settings pane invokes both), `is_onboarding_complete` and `save_diagnostics_snapshot` as main-only by caller location, `reconnect_spotify_session` as guarded, and `check_for_update` + `cancel_deferred_update` as main-only by caller location (UpdatePrompt mounts only under `{#if isMainWindow}`; neither takes a `window` param, so adding one plus a guard is the recorded defence-in-depth follow-up). The submodule map at the top of the same file now lists every `#[tauri::command]` each module owns, including the crate-root `updater_bg` / `diagnostics` / `history` commands and `detach_pane`. +### 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. +- **Polling loop split into one file per concern with shared `PollState` (#754).** `src-tauri/src/polling/poll_once.rs` (11,988 ln) becomes `polling/{clocks,iteration,refresh,gate,rules,presence,status_text,write,timing,exit}.rs` plus a 10-module registry in `mod.rs`; the 17 `&mut` out-params travel as one `&mut PollState` (no `#[allow(clippy::too_many_arguments)]` on `run`/`run_inner`). All 121 polling tests move with their modules with identical assertions (incl. the restored `polling_dead_refresh_clear_uses_replacement_safe_authority` in `refresh.rs`); `Box::leak` in the preferred-presence arm is preserved verbatim. No behaviour change. + ### Test - **Theme/density coverage is named for its actual subject (#775).** The former `tests/hygiene.test.ts` is now `tests/theme-density.test.ts`; no current config, script, workflow, or non-historical documentation reference names the old filename. - **Static i18n coverage sees all weekday keys (#773).** `WEEKDAY_KEYS` is a typed 1–7 registry, Settings consumes it, and `tests/i18n.test.ts` pins all seven keys and includes them in the literal call-site coverage scan. - **Detached Logs/Settings/unknown route branches are rendered in tests (#857).** The route test exercises both valid pane values and the unknown fallback, while the coverage exclusion remains unchanged for the intentionally uninstrumented route. - **The native-literal guard catches new untranslated copy (#843).** The Rust i18n test scans production literals in `tray.rs` and `menu.rs`, including URL-containing ordinary and raw strings, while ignoring comments and correctly handling char/lifetime boundaries; it reports copy absent from the explicit allowlist and includes synthetic unknown-label coverage. - **The shared Teams-write retry tests run without Tauri's `test` feature (#929 rework).** `src-tauri/Cargo.toml` no longer carries the `[dev-dependencies] tauri = { features = ["test"] }` line. The `test` feature is an empty upstream flag that only flips `#[cfg(any(test, feature = "test"))]` in tauri's lib, compiling the `tauri::test` module into the rlib the Windows test binary links — and the linked binary fails to load with `STATUS_ENTRYPOINT_NOT_FOUND` (exit 0xc0000139) on the Windows CI leg. The `teams_write_with_refresh_fn` and `handle_teams_refresh_failure` helpers now take the emit and persist seams as injectable closures (the same shape #932 used for `commit_spotify_session`), and the production wrapper `teams_write_with_optional_refresh` supplies `&AppHandle` + `token_io::persist_tokens`; the test module drives a module-scope `CapturingEmitter` (with a separate `markers` lane for the `null`-payload `teams-reconnect-required` event) and a recording persist closure. Production behaviour, event names, and event payloads are unchanged. - - **The shared Teams-write retry tests run WITH Tauri's `test` feature, TARGET-GATED to non-Windows (#929 rework, B1/B2 closure).** The dev-dependency from the prior attempt is restored under `[target.'cfg(not(windows))'.dev-dependencies]` so Linux/macOS tests can drive the production wrapper `teams_write_with_optional_refresh` through `tauri::test::mock_app()` and a real listener on `teams-reconnect-required`; the Windows dev-dep is still excluded because enabling the feature on Windows links `tauri::test` into the test binary, and the linked binary fails to load with `0xc0000139 STATUS_ENTRYPOINT_NOT_FOUND` on the Windows CI leg (binary-fingerprint evidence: branches WITH the dev-dep build `presence_jam_lib-352d250090363d71.exe` and FAIL; branches WITHOUT it build `presence_jam_lib-d797c262adba749f.exe` and PASS — 3 pass, 2 fail, no overlap; Linux/macOS unaffected). Windows keeps the `CapturingEmitter` coverage of `teams_write_with_refresh_fn` and gets the wrapper-level coverage on Linux/macOS only. The companion assertion that the wrapper injects `|s| token_io::persist_tokens(s, app)` as a closure is rewritten to drop `//`/`///` line comments AND the contents of every `"…"` string literal before matching, so a reviewer can no longer defeat it with `let _decoy: &str = "";`. Production behaviour, wire event names (`teams-reconnect-required`), and payload (`json!(null)`) are unchanged. -### Added -- **OS-aware playback source (`Auto` / `System` / `Spotify`, #862).** A new `src-tauri/src/sources/` module defines `trait PlaybackSource { fn poll(&mut self) -> Result, SourceError>; fn capabilities(&self) -> SourceCaps; }` with a flat `NowPlaying` shape that maps 1:1 onto the existing `TrackInfo` (artist, title, album, album art URL, progress, duration, is_playing) — so `process_track`, the rules walker, the status formatter and the presence gate need no change. Three implementations ship: `sources::spotify::SpotifySource` (wraps `get_currently_playing` and owns the existing `If-None-Match` cache so the conditional-GET round-trip is preserved), `sources::smc::SmcSource` (Windows `windows::Media::Control::GlobalSystemMediaTransportControlsSessionManager`, picks the most-recently-updated session whose `PlaybackStatus == Playing`), and `sources::mpris::MprisSource` (Linux `zbus`, walks `org.mpris.MediaPlayer2.*`, prefers the previous winner and falls back to the first `PlaybackStatus == "Playing"`). An `AutoSource` wrapper picks the OS source first and falls back to Spotify when the OS source returns no session or fails. `AppConfig` carries `playback.source` (`auto | spotify | system`, default `auto`); a kind change from Settings rebuilds the source on the next poll, the Spotify ETag cache and the system-source singletons drop with the old source. - -### Changed -- **The polling loop drives playback through `&mut dyn PlaybackSource`.** The pre-5.0 direct `get_currently_playing(&access_token, last_etag.as_deref())` call in `poll_once::run_inner` now goes through a trait object — the Spotify ETag cache and the system source singletons persist across iterations, the `last_source_kind` comparison key rebuilds the source on a kind change, and the existing `SourceError` taxonomy maps every `SpotifyApiError` arm (ExpiredToken → Auth, NotPremium → Auth, RateLimited → Transient, etc.) so the retry, 401-retry and 5-strikes paths keep their documented semantics. On Windows and Linux the system source runs even when the user has not connected Spotify, so a non-Premium user gets a working status from whatever else is playing on the desktop; on macOS the system source has no implementation (Apple has no public API for another app's now-playing) and the `Onboarding.svelte` wizard surfaces a platform-aware note explaining the Spotify-only fallback. Slack and Discord sinks are out of scope. The `NowPlaying` field set stays identical to the existing `TrackInfo` field set, so `matching_track_rule_at` and the formatter pipeline need no change. -- **The shell renders its page through the `children` snippet instead of `` (#779).** `src/routes/+layout.svelte` declares `let { children } = $props()` and renders `{@render children?.()}` inside the existing `{:else}` branch, so the last Svelte 4 API in the codebase is gone. `grep -rn '` in `+layout.svelte` is driven by the live `appliedTheme` store. Native form controls and scrollbars match what the app actually paints (a dark-OS machine running the light theme now gets light native surfaces, and vice versa). -- **German tray wording aligned with the webview terminology (#901).** Seven Rust tray/menu German strings (open-logs-folder, status-syncing and its no-track sibling, pause-sync, resume-sync, manual-status-clear, profile-empty) now use the same nouns as the webview Settings card and Dashboard chip — German was the only locale naming the same control or state two different ways in adjacent UI. -- **The update banner honours the theme token system (#902).** Six dead tokens (`--r-xl`, `--ease-in-out`, `--z-banner`, `--partner`, `--partner-soft`, `--bg-overlay`) and two dead classes (`button.ghost` / `.btn-ghost`, `.text-muted`) are removed from `src/app.css`. The banner's hardcoded `z-index: 1000;` and `box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);` are replaced by `--z-update-banner` and `--shadow-update-banner`. -- **The tray and deep-link entry points can no longer panic on an unmanaged `AppState` (#937) — defence-in-depth, not a fix for a reachable crash.** `force_tray_refresh` and `handle_deep_link` each looked the managed state up with `app.state::>()`, which panics with "state() called before manage()" if the lookup runs before the setup closure's `app.manage(state.clone())`. Both now go through one `try_state` lookup that returns early with a `[TRAY]` / `[DEEP_LINK]` `warn!` (raised from `debug!`, which the default tauri-plugin-log level filters) when the state is absent, and each drops a redundant second `app.state::<>()` on the same handle it already holds. Traced honestly: **no current caller can produce that window.** `force_tray_refresh` is reached only from the tray menu arms and the `save_config` locale repaint, both after `manage()`; the forwarded-second-launch path goes through `refresh_tray_from_state` → `repaint_tray_from_state`, which has used `try_state` since before this change; and both `handle_deep_link` call sites (`get_current()`'s drain and the `on_open_url` listener) are registered in setup after `manage()` — the deep-link plugin's `handle_cli_arguments` does run during `Builder::build`, but its `deep-link://new-url` emit has no listener yet, so the URL survives only in the plugin's `current` slot and is drained later. A deep link that *did* arrive unmanaged is logged and dropped, not replayed after setup (#1122). The managed-state lookup and the tray rebuild / token-exchange spawn are injected seams, so three behavioural unit tests drive the unmanaged and managed arms of both functions with no GUI runtime and no `tauri::test` mock — which also keeps Tauri's empty `test` feature out of the test binary, the flag that breaks the Windows lib test binary's load with `STATUS_ENTRYPOINT_NOT_FOUND`. -- **The logger flushes before the build-failure exit (#947).** `lib.rs::run`'s `Builder::build` failure arm now calls `log::logger().flush()` before `std::process::exit(1)`, mirroring the CLI failure path. The single diagnostic explaining "the app will not start" can no longer be lost to tauri-plugin-log's file-target buffer. - - -- **LogViewer toolbar wrap and overflow contract (#948, #1134).** The toolbar now wraps (`flex-wrap: wrap` plus `row-gap`) and the level strip shrinks and scrolls its own overflow instead of forcing the row wider. At the shipped 600x750 default the toolbar previously overflowed the pane; at the 400x500 declared minimum the six-button strip was 425px inside a 360px pane. -- **Diagnostics Save/Copy/dismiss coverage (#781, #1134).** Those three actions now have executing tests rather than none. -- **Settings save/revert in the unsaved-changes banner (#966, #1135).** Commit and discard no longer require scrolling to the footer. -- **Undo for a removed quiet-hours row or track rule (#981, #1135).** Restores the row with its exact prior field values and a sensible focus position. -- **Theme preview swatches driven by tokens (#904, #1135).** `--preview-dark-*`, `--preview-light-*` and `--swatch-h` live in `src/app.css`; no hex literal remains in the component. -- **Status snapshot no longer holds the token locks through its tail (#879, #1125).** `sync_status_from_state` releases the guards once it has cloned what it needs, and the write-clock read stays inside the guarded section so `current_track` and its clocks still come from one instant. -- **A second launch arriving before `AppState` is managed no longer panics (#937, #1123).** Both the tray-refresh and deep-link paths tolerate a missing managed state instead of calling `state()` before `manage()`. -- **Blocking Tauri commands run off the main thread (#928, #1130).** The remaining blocking commands moved behind `spawn_blocking` or became `async`, with per-command thread-id tests. -- **Config: unknown keys survive a round trip, `schema_version` never lowers, imports stage first (#767, #938, #939, #1131).** Every section struct carries `#[serde(flatten)] pub extra`; `stamp_schema_version` uses `.max()`; `replace_with_backup` stages an incoming file before moving the live one aside. -- **Detached Logs and Settings panes have a working skip link (#743).** The shared layout renders "Skip to main content" in every webview, but `src/routes/detached/[pane]/+page.svelte` mounted `LogViewer` / `Settings` bare, so in a popped-out pane the link was the first tab stop with no fragment target anywhere in the document. The route now wraps every pane branch (including the unknown-pane notice) in a container carrying `id="main-content"` and `tabindex="-1"`, mirroring the main window's `.app-container`, so activating the link moves focus into the pane body. The main window's copy of the id is untouched (#742 owns moving it). Covered by `tests/detached-skip-link.test.ts` and `tests/browser/detached-skip-link.spec.ts`. - -### Docs (chore) - **Stale code citations in source comments corrected (#856).** Four long-standing comments pointed at `polling.rs` (now `polling/{mod,loop,poll_once,state}.rs`), `commands.rs` (now `commands/*.rs`) and a non-existent `ts_rs_export` module — every cited line or module now points at the live home. (`grep -rn 'polling\.rs\|commands\.rs' src-tauri/src` returns no matches.) - **Stale comment citations swept across the codebase (#908).** Nine stale `file:line` comments — in `Dashboard.svelte`, `Settings.svelte`, `commands/spotify_auth.rs`, `teams.rs`, `stores/notifications.ts`, `commands/mod.rs`, `updater_bg.rs` and `sources/spotify.rs` — were replaced with symbol-name references or live line ranges. Comments only; no code, behaviour, or test changes. @@ -1590,9 +1565,8 @@ Closes #60 #61 #62 #63 ### Removed - PowerShell script version — this is a full rewrite -[5.0.0]: https://github.com/Carme99/PresenceJam-Desktop/compare/v4.7.0...v5.0.0 [4.7.0]: https://github.com/Carme99/PresenceJam-Desktop/compare/v4.6.0...v4.7.0 -[Unreleased]: https://github.com/Carme99/PresenceJam-Desktop/compare/v5.0.0...HEAD +[Unreleased]: https://github.com/Carme99/PresenceJam-Desktop/compare/v4.7.0...HEAD [4.6.0]: https://github.com/Carme99/PresenceJam-Desktop/compare/v4.5.2...v4.6.0 [4.5.2]: https://github.com/Carme99/PresenceJam-Desktop/compare/v4.5.1...v4.5.2 [4.5.1]: https://github.com/Carme99/PresenceJam-Desktop/compare/v4.5.0...v4.5.1 diff --git a/src-tauri/linux/com.presencejam.app.metainfo.xml b/src-tauri/linux/com.presencejam.app.metainfo.xml index 9cbf802b..3b73c75d 100644 --- a/src-tauri/linux/com.presencejam.app.metainfo.xml +++ b/src-tauri/linux/com.presencejam.app.metainfo.xml @@ -67,20 +67,6 @@ - - -

- The v5 audit-wave backlog: every config section covered by - ConfigPatch with unknown keys preserved and a monotonic schema - version; a cached shared HTTP client and validated playback device - ids; bounded update checks with a working .deb/.rpm update path; - macOS app menu installed app-wide; blocking Tauri commands moved - off the main thread; Dashboard hydration so a view switch no longer - re-reads config and status; and the accessibility, localisation - and documentation corrections that went with them. -

-
-

diff --git a/src-tauri/src/tray/mod.rs b/src-tauri/src/tray/mod.rs index 83c02999..3c2a88c5 100644 --- a/src-tauri/src/tray/mod.rs +++ b/src-tauri/src/tray/mod.rs @@ -838,7 +838,8 @@ pub fn setup_tray(app: &tauri::App) -> Result<(), String> { // before `process_track` returns and before the rebuild that paints it. // A spawn would race the rebuild and could paint a stale Play/Pause // mark on a same-track pause (#758 slice-2 review). `try_state` is a - // lock-free map lookup, so there is no blocking cost to staying inline. + // short map lookup under one uncontended mutex, so there is no + // blocking cost worth a thread hop. let listen_handle = app.handle().clone(); app.listen("playback-state-changed", move |event| { let Some(state) = listen_handle.try_state::>() else {