# Changelog All notable changes to PresenceJam are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] ## [2.9.1] - 2026-07-23 ### Dependencies - **deps(ci): bump `actions/setup-node` 6.4.0 → 6.5.0 (PR #144).** Minor via Dependabot weekly batch. Bundles security bumps underneath: `@actions/cache` 5.1.0, undici + fast-xml-parser security overrides. (CI github-actions group.) - **deps(backend): bump the cargo-minor-and-patch group with 6 updates (PR #146).** `tauri-plugin-store` 2.4.3 → 2.4.4 (patch, iOS scanner fix), `tauri-plugin-log` 2.8.0 → 2.9.0 (minor, adds opt-in `FileOpenStrategy::Rotate` — default behaviour unchanged), `serde` 1.0.228 → 1.0.229, `serde_json` 1.0.150 → 1.0.151, `rand` 0.8.6 → 0.8.7, `tauri-plugin-single-instance` 2.4.2 → 2.4.3. (Backend cargo group.) - **deps(frontend): bump the npm-minor-and-patch group with 5 updates (PR #145).** `@tauri-apps/plugin-log` 2.8.0 → 2.9.0, `@tauri-apps/plugin-store` 2.4.3 → 2.4.4 (mirror PR #146's Rust-side bumps — Tauri requires Rust ↔ JS plugin version sync), `@sveltejs/kit` 2.69.1 → 2.70.1 (minor; `defineEnvVars` moved to new `@sveltejs/kit/env` package — no impact since PresenceJam doesn't use `defineEnvVars`, runs in SPA mode via `@sveltejs/adapter-static`), `svelte` 5.56.4 → 5.56.6, `svelte-check` 4.7.1 → 4.7.3. (Frontend npm group.) Patch-level bump because all three PRs are Dependabot minor/patch bumps with no user-visible behaviour changes. ## [2.9.0] - 2026-07-12 ### Changed - **feat(ui): visual refresh — design system, light & dark themes, new brand mark, refreshed app icons.** Replaces the ad-hoc 11-token CSS with a full token system (color, spacing, radius, type, motion). Adds a `` Svelte component (`src/lib/components/Logo.svelte`) used in Dashboard header, About, and Onboarding chrome. Adds light & dark themes with a header toggle (round icon button) and a Settings → Appearance picker; `+layout.svelte` side-effect-imports the theme store so persisted themes apply on cold start regardless of which route mounts first. Status badges (Spotify / Teams / Syncing) gain a leading dot, an animated pulse on the active sync state, and a dedicated accent variant. Focus rings moved to `:focus-visible`. Custom scrollbars (WebKit + Firefox). All six components re-skimmed — card padding & border-radius are now sourced from tokens instead of being copy-pasted, log-level filter replaced with a segmented control, Onboarding gets an inline device-code display and the Spotify step supports a manual URL-paste backchannel. - **feat(ui): regenerate app icons from a single source-of-truth SVG.** New `static/icon.svg` (Spotify-green EQ inside a Teams-purple "presence" pill on midnight background) is fed to `npx tauri icon` which regenerates the full matrix — PNG/ICO/ICNS/iOS/Android — under `src-tauri/icons/`. The previously-shipped amber/teal yin-yang icon is gone. `static/icon.svg` + `static/logo.svg` (full lockup with wordmark) used in README and as the in-app SVG. ### Dependencies - **deps(backend): bump tauri (PR #138).** Patch via Dependabot weekly batch. (Backend cargo group.) - **deps(frontend): bump @sveltejs/kit (PR #137).** Patch via Dependabot weekly batch. (Frontend npm dev-dependency group.) ### Removed - **chore: drop stale brand artefacts.** `logos/_Design a modern desktop app logo for PresenceJam_.png` and `logos/_Design a modern desktop app logo for PresenceJam_ 2.png` (unused AI drafts) deleted. `static/svelte.svg`, `static/tauri.svg`, `static/vite.svg` sample assets removed. README no longer references non-existent `docs/screenshots/*.png` paths. ### Security - **fix(security): tighten tokens.json + config.json file mode to 0600 (Unix), user-only ACL (Windows) — issue #135 path A.** `src-tauri/src/token_io.rs::write_tokens_atomic` and `src-tauri/src/config.rs::atomic_write_json` now create the temp sidecar with mode 0600 atomically via `OpenOptions::new().write(true).create_new(true).mode(0o600)` (Unix); the subsequent `rename()` preserves the source mode, so the live file ends up 0600 too. Pre-existing loose files are tightened on first read by `read_tokens_at` / `load_config`. Stale `.tmp` sidecars from a prior crash are pre-cleared so `create_new(true)` does not block the next write. This is **file-mode tightening, not encryption**; the file contents remain plaintext JSON. See `SECURITY.md` "Data Storage → File permissions (v2.8.x)" for source-of-truth citations and the A-vs-B decision. Adds two regression tests: `token_io::tests::recovers_from_stale_tmp_sidecar` and `config::tests::test_atomic_write_json_recovers_from_stale_tmp_sidecar`. Existing `test_atomic_write_json_does_not_remove_destination_first` (PR #133) tightened to forbid `remove_file(path)` on the destination while allowing `remove_file(&temp_path)` on the sidecar. ## [2.8.0] - 2026-07-04 ### Security - **fix(security): re-register presencejam:// scheme at every launch (further mitigates #66).** `src-tauri/src/lib.rs` previously called `tauri-plugin-deep-link`'s `register_all()` only on Windows (`#[cfg(windows)]` gate around the existing call site). The plugin's `register` is a no-op on macOS/Android/iOS (returns `Err(UnsupportedPlatform)`) and an effective re-registration on Windows (writes `HKCU\Software\Classes\`) and Linux (writes `~/.local/share/applications/.desktop` and runs `xdg-mime default`). This change removes the Windows-only gate so Linux also re-registers on every launch, defending against a foreign app pre-registering `presencejam://` to hijack the Spotify OAuth callback. **Windows + Linux coverage only**; macOS remains partially mitigated by #65 (PKCE verifier in AppState only, never on disk, never exposed via IPC — an interceptor can read the `code` but cannot exchange it for tokens). Native `LSSetDefaultHandlerForURLScheme` work for macOS is tracked separately. Does not modify the OAuth `redirect_uri` or `state` parameter — no Spotify re-registration required. ### CI/Build - **ci: refresh pinned GitHub Action SHAs across ci.yml + release.yml (PR #130).** Bumps `actions/checkout` v4.3.1 → v7.0.0, `actions/setup-node` v4.4.0 → v6.4.0, `actions/upload-artifact` v4.6.2 → v7.0.1, `actions/download-artifact` v4.3.0 → v8.0.1. SHA pinning (PR #68 policy) preserved. **`actions/download-artifact` v8 introduces `digest-mismatch` (defaults to error) — set explicitly to `warn` in both release.yml occurrences as a safer first try; switch to `error` after one clean release cycle.** `ncipollo/release-action`, `vedantmgoyal2009/winget-releaser`, `dtolnay/rust-toolchain`, `Swatinem/rust-cache` deliberately not bumped (stable). - **deps(frontend): bump `@tauri-apps/cli` 2.11.3 → 2.11.4 (PR #127).** Patch via Dependabot weekly batch. (Frontend npm group — confirms the minor-and-patch auto-update policy.) ### Documentation - **docs: README and SETUP install instructions no longer pin a specific release version.** Install commands and download links now reference the [latest release](https://github.com/Carme99/PresenceJam-Desktop/releases/latest) instead of stale `PresenceJam-.msi` filenames. (Landed via #131.) ### Tests - Regression guard `test_register_all_not_gated_to_windows_only` added; uses `include_str!("lib.rs")` to assert `app.deep_link().register_all()` is not gated to Windows alone. ## [2.7.5] - 2026-06-25 ### Refactored - **refactor(frontend): type the 5 invoke / listen call sites (PR #116, #78 part 1).** Replaces 6 untyped invoke/listen sites with typed equivalents matching the Rust-side return shapes. Closes the silent-drift risk: a Rust-side field rename now produces a TypeScript compile error, not a runtime undefined. New interfaces in `src/lib/types.ts`: `SyncStatus`, `DeviceCodeResponse`, `LogPayload`. - **refactor: error handling consistency (PR #117, #79 items 1+2).** Three sub-changes: (1) drop the silent-failure `eprintln!` in the panic hook (`lib.rs:407`) — stderr is not connected to the user's log file on macOS release builds. (2) Add a `severity` field to the `error` event payload via a centralised `emit_error(app, source, message, severity)` helper; the polling loop's 3 error emit sites now route through it. (3) Gate the Dashboard.svelte red banner on `severity === 'error'` — warnings (transient 401-retry, backoff) no longer alarm-fatigue the user. Includes a `test_error_event_emits_severity_field` regression guard in `polling.rs`. - **refactor(commands): split commands.rs into 7 per-workflow modules (PR #122, #76, #79).** Extracts the 24 `#[tauri::command]` handlers + 3 helpers from the single 1113-line `src-tauri/src/commands.rs` into a `src-tauri/src/commands/` directory: `config.rs`, `spotify_auth.rs`, `teams_auth.rs`, `sync.rs`, `window.rs`, `onboarding.rs`, `misc.rs`, plus a thin `mod.rs`. The `tauri::generate_handler!` macro in `lib.rs` now references the 7 submodules via globs. Closes #76. Also includes the [CMD.] log tag namespace rename from #79 item 3: each per-workflow file uses a `const CMD: &str = "[CMD.]"` so the log tag differs by command category, making the existing log_tag sweep work easier to grep. - **refactor: extract OnboardingCache sub-struct with lock encapsulation (PR #118, #80 step 1).** Pulls the 30s onboarding result cache out of the monolithic `AppState` into its own `OnboardingCache` sub-struct. The `lock()` and `invalidate()` methods encapsulate the inner mutex; the field is private. This is the load-bearing pattern for #80 step 2 (Tokens, Polling, PendingAuths, Config). - **refactor: extract Tokens/Polling/PendingAuths/Config sub-structs with lock encapsulation (PR #120, #80 step 2).** Continues the #80 split: 4 new sub-structs with private inner fields, lock-acquisition methods (`spotify()`, `teams()`, `handle()`, `handle_mut()`, `try_claim()`, `set_syncing()`, `current_track()`, `current_track_mut()`, `stop_tx()`, `stop_tx_mut()`, `get()`, `get_mut()`), and `Default` impls for clippy. `try_claim()` encapsulates the `compare_exchange(false, true, AcqRel, Acquire)` pattern that was raw atomic on `is_syncing` in step 1. The 38/39 pre-existing tests still pass; 3 new regression tests added. - **refactor: extract poll_once/state/loop modules (PR #123, #72).** Extracts the 1089-line `src-tauri/src/polling.rs` into 4 files: `polling/loop.rs` (thin driver, ~50 lines), `polling/poll_once.rs` (single source of truth for one iteration, with the unified CAS-discard helper `cas_refresh_or_discard` and the unified 401-retry path), `polling/state.rs` (`start_polling`/`stop_polling` thread lifecycle), `polling/mod.rs` (re-exports + `ErrorSeverity` + `emit_error`). Closes all 3 documented drift points: (1) `consecutive_pauses` is now incremented in exactly one place (the `record_no_track_outcome` helper called by both the main `Ok(None)` and 401-retry `Ok(None)` paths); (2) the `error` event is emitted in exactly one place per failed poll (the unified `Err` arm at the bottom of `poll_once.rs`); (3) the CAS-discard re-read dance is shared between Spotify proactive, Spotify 401-retry, and Teams refresh via the `cas_refresh_or_discard` helper, with one canonical log message. 9 regression tests added covering all 3 drift points. - **refactor: ts-rs build-time codegen for AppConfig + token/track/sync types (PR #121, #78 part 2).** Adds `ts-rs` v12 (`chrono-impl` feature) as a regular dependency. Derives `ts_rs::TS` and `#[ts(export, export_to = "../../src/lib/types-generated/")]` on the wire-shape structs across `spotify.rs`, `teams.rs`, `commands/sync.rs`, and `config.rs`. The generated `.ts` files land in `src/lib/types-generated/` (gitignored, regenerated by `cargo test`). `src/lib/types.ts` re-exports the generated types so existing component imports (`import type { SpotifyTokens } from '$lib/types'`) keep working unchanged. `u64` fields (`TrackInfo.progress_ms`, `TrackInfo.duration_ms`, `DeviceCodeResponse.interval`, `DeviceCodeResponse.expires_in`) override the ts-rs default `bigint` with `#[ts(type = "number")]` because Tauri's serde_json IPC bridge decodes u64 as JS number (f64) at runtime. 5 round-trip regression tests added. ### Fixed (CI) - **ci(frontend): run cargo test --lib to materialise ts-rs codegen before svelte-check (PR #124, #125).** The `frontend` CI job now runs `cargo test --lib` (with the generated `src/lib/types-generated/` directory cleared first to avoid stale-cache issues) before `npm run check`, so the ts-rs-generated TypeScript files exist when svelte-check runs the type-re-exports from `src/lib/types.ts`. Without this, the Frontend check fails on PRs that introduce new ts-rs types because the `.ts` files are only produced when the test binary runs. ## [2.7.4] - 2026-06-25 ### Security - **deps(npm): pin cookie >= 0.7.0 via package.json overrides (GHSA-pxg6-pf52-xh8x, PR #113).** The vulnerable `cookie@0.6.0` was pulled transitively via `@sveltejs/kit@2.68.0`. A top-level `overrides` block in `package.json` forces resolution to `^0.7.0` across the entire transitive graph. Dev-only (vite/svelte-kit dev server); no production binary impact. - **security(ci): switch homebrew job to credential helper, document 90-day PAT rotation (PR #114, #68 finish).** The `homebrew` job in `release.yml` previously cloned the tap with `x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/...` — the token leaked into `git remote -v` output, the process listing, and any error log captured by the job. Replaced with a non-persistent `git config credential.helper` that hands the token to git on demand. Added a new "Release Pipeline Token Rotation" section to `SECURITY.md` documenting the rotation procedure, why fine-grained PATs (not classic), and why 90 days. ### Fixed - **refactor(frontend): type the 5 invoke / listen call sites (PR #116, #78 part 1).** Replaces 6 untyped invoke/listen sites with typed equivalents matching the Rust-side return shapes. Closes the silent-drift risk: a Rust-side field rename now produces a TypeScript compile error, not a runtime undefined. New interfaces in `src/lib/types.ts`: `SyncStatus`, `DeviceCodeResponse`, `LogPayload`. ### Changed - **refactor: error handling consistency (PR #117, #79 items 1+2).** Three sub-changes: (1) drop the silent-failure `eprintln!` in the panic hook (`lib.rs:407`) — stderr is not connected to the user's log file on macOS release builds, so the panic was invisible. (2) Add a `severity` field to the `error` event payload via a centralised `emit_error(app, source, message, severity)` helper; the polling loop's 3 error emit sites now route through it. (3) Gate the Dashboard.svelte red banner on `severity === 'error'` — warnings (transient 401-retry, backoff) no longer alarm-fatigue the user. Includes a `test_error_event_emits_severity_field` regression guard in `polling.rs`. ### Refactored - **refactor: extract OnboardingCache sub-struct with lock encapsulation (PR #118, #80 step 1).** Pulls the 30s onboarding result cache out of the monolithic `AppState` into its own `OnboardingCache` sub-struct. The `lock()` and `invalidate()` methods encapsulate the inner mutex; the field is private. This is the load-bearing pattern for #80 step 2 (Tokens, Polling, PendingAuths, Config). Includes 2 regression tests: `test_onboarding_cache_lock_and_invalidate` (exercises the public API) and `test_onboarding_cache_encapsulation_no_direct_state_access` (grep guard against re-exposing the field). ## [2.7.3] - 2026-06-25 ### Security - **fix: per-install keychain namespacing (audit M2).** `keychain.rs:19-34` — `SPOTIFY_CLIENT_SECRET_USER` is now namespaced by the Tauri bundle identifier (`spotify_client_secret:com.presencejam.app`). Side-by-side installs on the same OS user (prod, dev, beta) now get isolated slots. `get_spotify_client_secret` falls back to the legacy unnamespaced slot used through v2.7.2, migrates the value forward to the namespaced slot, and deletes the legacy entry — so existing v2.7.2 users do not have to re-onboard. `has_spotify_client_secret` and `delete_spotify_client_secret` consult both slots (legacy delete is best-effort). - **fix: strip plaintext Spotify client_secret from config.json on startup (audit Q3).** `config.rs` adds `migrate_legacy_client_secret()`, called from `lib.rs:441` after `load_config`. If `config.json` contains a plaintext `spotify.client_secret` field (legacy ≤ v2.5.0), the value is written into the OS keychain and the plaintext is atomically stripped from the file. Conflict policy: if the keychain already holds a *different* secret, the migration is a no-op (the user is told to Reconnect via Settings) so a multi-install upgrade cannot clobber a working keychain entry. ### Fixed - **fix(polling): count `SpotifyApiError::Other` toward `transient_failure_count` (audit M1).** `polling.rs:871-886` — the 5-strikes exit-to-reconnect-required previously only counted `RateLimited` and `ExpiredToken`. A reqwest send failure (DNS, TLS handshake, connection refused) or a non-200/204/401/429 HTTP response is wrapped into `Other`; that variant is now treated as transient so a permanent network outage eventually triggers `reconnect-required` instead of looping forever emitting `error` events. - **feat: actionable keychain error on Linux (audit Q7).** `keychain.rs` adds `keychain_error_help()` / `map_keychain_err()` helpers that wrap every `Entry::new` / `set_password` / `delete_credential` call site. When the keychain is unavailable (no Secret Service daemon, locked `gnome-keyring`, missing `kwallet`), the returned error message points the user at `SETUP.md#linux-keyring` with install commands for the major distros and a `secret-tool` self-check recipe. `SETUP.md` adds a new "Linux: System Keyring Required" section documenting the dependency and the `secret-tool` smoke test. No encrypted-config fallback is added — a working keyring is a hard requirement, by design. - **fix(frontend): `TeamsTokens.refresh_token` is `string | null` (audit S2).** `src/lib/types.ts:32` was previously typed as `string`, but the Rust side (`teams.rs:57`) declares `pub refresh_token: Option` with `#[serde(default)]` and no `skip_serializing_if` — so the field is always emitted, defaulting to JSON `null` when the Microsoft token endpoint doesn't return one. TS was lying about the wire shape. Spotify's `refresh_token` is a plain `String` on both sides and remains `string`. No frontend code currently reads `.refresh_token` directly, so this is a latent-bug fix with zero call-site impact. - **fix: forward deep-link argv via single-instance plugin (audit S5).** `lib.rs:340-362` — the `tauri_plugin_single_instance` callback was a no-op log line. Now it (1) raises, un-minimizes, and focuses the existing main window when a second instance launches, and (2) scans `argv` (skipping argv[0] = exe path) for any `presencejam://` URL and forwards it through `handle_deep_link`. macOS deep-link delivery via the plugin's `on_open_url` callback was already wired and is unaffected. ### Changed - **chore(deps): drop unused `tauri-plugin-process` (audit Q6).** The plugin was registered in `lib.rs:372` and declared in `Cargo.toml:28` and `package.json:23`, but no frontend code imports `@tauri-apps/plugin-process` and no Rust code calls into the plugin's IPC. All three registration points and the `ACKNOWLEDGEMENTS.md` table entries have been removed. No behaviour change; pure attack-surface reduction. - **feat(macOS): hide dock icon when Start minimized is on (audit Q4).** `lib.rs` setup now calls `app.set_activation_policy(ActivationPolicy::Accessory)` when `start_minimized` is true, and `commands::save_config` does the same on every save (symmetrically setting `Regular` when the user disables it) so the dock icon and menu-bar app menu disappear for tray-only use. No restart required to re-enable the dock icon. The change is `#[cfg(target_os = "macos")]`-gated; Windows and Linux are unaffected. - **chore: User-Agent version from `CARGO_PKG_VERSION` (audit Q8).** `teams.rs:52` now uses `format!("PresenceJam/{}", env!("CARGO_PKG_VERSION"))` instead of the hardcoded `"PresenceJam/2.0"`, which had drifted since v2.0.0. `CONTRIBUTING.md` adds the rule "User-Agent and any version-stamped payload must use `env!("CARGO_PKG_VERSION")` — never hardcode the version." Spotify's `reqwest::blocking::Client` is unaffected — it doesn't set a User-Agent at all (pre-existing gap, out of scope). ### Chore - **chore(nits): three mechanical cleanups.** `tauri.conf.json:30` drops the unused `https://api-secure.spotify.com` from the CSP `connect-src` (no Rust code calls it). `teams.rs:78` drops the redundant `serde(rename = "verification_uri")` on `DeviceCodeResponseRaw.verification_uri` (the field is already named that; only the `alias = "verification_url"` is doing real work). New `teams::is_token_expired(&TeamsTokens)` mirrors `spotify::is_token_expired`; the two inline `Utc::now() [<>]= expires_at - 60s` checks at `polling.rs:131` and `teams.rs:548` now call the helpers (the polling.rs import is aliased `is_token_expired as is_teams_token_expired` to avoid shadowing the Spotify one). ### Verified (no code change) - **Verified: keychain cache priming at startup is race-free (audit Q1).** `lib.rs:406-415` reads the keychain on startup to populate the `OnceLock>>` cache before the polling thread's first iteration. Because `keychain::store_spotify_client_secret` (called from `start_spotify_auth` during onboarding) also writes the cache on success, there is no race window where the polling thread could see a stale empty cache after onboarding completes. Left as-is. - **Verified: tray Show/Hide label regression is fixed (audit Q5).** `tray.rs:158-211` already includes `is_window_visible` in the dedup key (`TrayStateSnapshot = (bool, bool, Option)` for `(is_syncing, is_window_visible, track_key)`); `update_tray_menu` reads `window.is_visible()` at line 200 before computing the label. Per v2.6.4 #71 fix. No code change needed. ## [2.7.2] - 2026-06-20 ### Fixed - **fix(v2.7.2): verifier-flagged nits + release-hygiene catch (#92).** `Settings.svelte:73` log tag rename (`[SETTINGS] start_spotify_auth failed:` → `[SETTINGS] start_spotify_reconnect failed:`) — the catch block is for the reconnect-required listener (added in v2.7.1), not the original auth. `commands.rs:298` idiomatic `let _ = client_secret;` → `_client_secret` at declaration. `Cargo.lock` presence-jam version 2.7.0 → 2.7.1 (missed by the v2.7.1 release commit 856b613). ### Changed - **chore(v2.7.2): a11y fix + project-wide rustfmt pass (#93).** `Settings.svelte:195` orphan `