From 4d20179c182e2e1c8d723d78d5c634084e2f59d1 Mon Sep 17 00:00:00 2001 From: Carme99 Date: Wed, 7 Oct 2026 00:41:11 +0100 Subject: [PATCH] refactor(tray): split tray.rs into mod, cache, dedup, snooze, devices, actions (#756) --- CHANGELOG.md | 1 + docs/STATE-OF-FEATURES.md | 12 +- docs/architecture/frontend.md | 2 +- docs/architecture/overview.md | 2 +- docs/architecture/tray-and-shell.md | 8 +- src-tauri/src/i18n.rs | 9 +- src-tauri/src/menu.rs | 2 +- src-tauri/src/polling/poll_once.rs | 2 +- src-tauri/src/redact.rs | 7 +- src-tauri/src/tray.rs | 4709 --------------------------- src-tauri/src/tray/actions.rs | 568 ++++ src-tauri/src/tray/cache.rs | 381 +++ src-tauri/src/tray/dedup.rs | 415 +++ src-tauri/src/tray/devices.rs | 373 +++ src-tauri/src/tray/mod.rs | 2111 ++++++++++++ src-tauri/src/tray/snooze.rs | 892 +++++ src-tauri/src/tray/testkit.rs | 69 + 17 files changed, 4837 insertions(+), 4726 deletions(-) delete mode 100644 src-tauri/src/tray.rs create mode 100644 src-tauri/src/tray/actions.rs create mode 100644 src-tauri/src/tray/cache.rs create mode 100644 src-tauri/src/tray/dedup.rs create mode 100644 src-tauri/src/tray/devices.rs create mode 100644 src-tauri/src/tray/mod.rs create mode 100644 src-tauri/src/tray/snooze.rs create mode 100644 src-tauri/src/tray/testkit.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 66a16b28..7c64d6c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ section to the released version and opens a fresh empty one (see `docs/RELEASING - **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, slice 1 (#750-PARTIAL).** New `src/lib/components/settings/` owns the repeated card shell (`SettingsCard`: `.card pane-card` + section header + optional Reset) plus the first four extracted cards — `RulesCard` (quiet hours, track rules, work-hours import, dry-run tester, manual-status pair), `LoggingCard` (`logging.*`), `BackupCard` (export/import), `ShortcutsCard` (capture, validation, registration status). Each takes a `$bindable()` config slice plus `onreset`/`onchange` callbacks; `Settings.svelte` keeps `localConfig`/`isDirty`/save-discard/`pendingNav` and the `.actions` footer. Shared `normalizeShortcutReason`/`shortcutReasonLabel`/`validateShortcutBinding` move to `src/lib/utils/shortcuts.ts`. Remaining: Spotify, Teams, Presence, StatusFormat, Polling, Notifications, Appearance, Profiles, Updates cards. No markup or computed-value change. - **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. diff --git a/docs/STATE-OF-FEATURES.md b/docs/STATE-OF-FEATURES.md index a377f72c..aa9519ac 100644 --- a/docs/STATE-OF-FEATURES.md +++ b/docs/STATE-OF-FEATURES.md @@ -25,7 +25,7 @@ end-to-end; the few rows that can't be sourced inline are explicitly flagged | Tokens.json encryption (AES-256-GCM, keychain key) | ✅ | `src-tauri/src/token_io.rs` — on-disk format `b"PJENC" \| version byte (0x01) \| 12-byte random nonce \| AES-256-GCM ciphertext`. The 256-bit key is generated on first use and stored in the OS keychain under `tokens_aes_key:com.presencejam.app` (`keychain.rs::get_or_create_tokens_aes_key`). Plaintext files from ≤ v2.10.0 are migrated to ciphertext on first read. (issue #140) | | Availability sync (opt-in) | ⚠ Partial | `config.teams.availability_sync`, default **off**. While a track plays, `set_teams_presence(...)` against `graph.microsoft.com/v1.0/me/presence/setPresence` (`/users/{oid}` fallback on 404), re-armed at most every 4 min because an `Available` session **times out after 5 min** — a non-configurable timeout distinct from `expirationDuration`, which only bounds the session's absolute life (`PT5M`–`PT4H`, then `Offline`). 4.6 (#636) derives `expirationDuration` from the remaining listening time (`presence_expiration_duration`, `PT4H` for a live/unknown-position stream) and clears the session on quit (`clear_presence_on_exit` from the `RunEvent::Exit` arm). Unit-tested (`test_presence_expiration_duration_is_bounded_by_listening_time`); the arm/clear round trip against a live tenant and the exit-time clear are not observed yet — `clear_presence_on_exit` has no test at all — and on Windows an update-driven quit never reaches it (the installer exits the process without returning). | | Meeting/call-aware status gating | ⚠ Partial | `config.teams.presence_gate`, default **on**. `get_teams_presence` runs before each status write; busy / Do-Not-Disturb / focusing availability or in-a-meeting / in-a-call / presenting activity suppresses the write (`teams.rs::presence_gate_reason`; `focusing` added in #254). 4.6 (#635/#637) adds two more reasons — `respect_manual_status` (default **on**, protects a message the user typed; content-identity authorship check, fails open with no sample) and `gate_when_out_of_office` (default **off**, lowest precedence) — and `presence_gate_decision` is now the single decision function. The Dashboard has specific labels for `quiet-hours`, `track-rule`, `manual-status`, `out of office`, `presenting`, `quiet-time`, and `idle`; `calendar`, presence verdicts, unknown reasons, and the empty reason use the generic label. Truth tables are unit-tested (`test_manual_status_blocks_write_policy`, `test_presence_gate_decision_precedence_and_opt_ins`, `teams.rs::out_of_office_gates_only_when_opted_in`); no test covers the live write-skip against a real tenant, so the row stays ⚠. | -| Tray playback remote | ✅ | `src-tauri/src/tray.rs` + `commands/playback.rs` — Play/Pause toggle, Previous, Next, Shuffle and Repeat toggles (see their own row), a Devices submenu (pick = transfer + start playback there) and an Up Next queue peek (≤3 tracks). Requires **Spotify Premium** (403 otherwise) and the `user-modify-playback-state` scope, which needs a one-time re-auth for pre-3.0 tokens (Settings banner, issue #3.0-P3). Every player action routes through `commands/playback.rs::player_with_refresh_typed` (issue #586) rather than a raw token snapshot. | +| Tray playback remote | ✅ | `src-tauri/src/tray/` (`actions.rs` + `devices.rs`) + `commands/playback.rs` — Play/Pause toggle, Previous, Next, Shuffle and Repeat toggles (see their own row), a Devices submenu (pick = transfer + start playback there) and an Up Next queue peek (≤3 tracks). Requires **Spotify Premium** (403 otherwise) and the `user-modify-playback-state` scope, which needs a one-time re-auth for pre-3.0 tokens (Settings banner, issue #3.0-P3). Every player action routes through `commands/playback.rs::player_with_refresh_typed` (issue #586) rather than a raw token snapshot. | | Auto-update (unsigned, GitHub Releases) | ✅ | `tauri-plugin-updater` is registered in `lib.rs`; the main capability grants `updater:allow-check` and `updater:allow-download-and-install` plus notification permissions. `updater_bg.rs::update_endpoints` selects Stable or the rolling Beta asset before the stable fallback; `check_for_update` resolves the candidate in Rust, the release job hand-assembles `latest.json`, and the separate sign job signs updater payloads independently of OS code signing. `UpdatePrompt.svelte` shows the banner and the channel-appropriate install path. | | ts-rs generated TS types from Rust wire structs | ✅ | `src-tauri/src/spotify.rs`, `teams.rs`, `commands/sync.rs`, `config.rs`, and `events.rs` (#762: every structured Tauri event payload — `ErrorEvent`, `PresencePaused`, `PlaybackStateChanged`, `PresenceGated`, `SyncStopped`, `ManualStatusUpdated`, `ConfigChanged`, and the remaining emit shapes — is a `#[ts(export)]` struct built at each emit site instead of inline `json!`; `spotify-track-changed` reuses `TrackInfo`) derive `#[ts_rs::TS]` with `#[ts(export, export_to = "../../src/lib/types-generated/")]`. Cargo test regenerates. See [`docs/architecture/frontend.md`](./architecture/frontend.md#directory-structure). | | Windows / macOS / Linux release matrix builds | ✅ | `.github/workflows/release.yml` defines a three-way `macos-latest` / `windows-latest` / `ubuntu-latest` matrix: macOS publishes the `.dmg`, Windows publishes `.exe` and `.msi`, and Linux publishes `.deb`, `.rpm`, and `.AppImage`; cite a current release run when claiming a particular published artifact. | @@ -34,7 +34,7 @@ end-to-end; the few rows that can't be sourced inline are explicitly flagged | Multi-window detach for Logs/Settings (C7) | ✅ | `src/routes/detached/[pane]/+page.svelte` renders the Logs, Settings, and unknown-pane branches; `src/lib/stores/detach.ts` calls Rust's fixed-table `detach_pane` command, and the main capability no longer grants webview-window creation. Detached labels receive minimal mirrored permissions in `src-tauri/capabilities/detached.json`, including `core:window:allow-close` for Pop back in; a refused close leaves the badge alone. `+layout.svelte` listeners remain window-label-guarded, and the app still boots single-window. | | IPC guard matrix enforced by test (#771) | ✅ | `src-tauri/src/commands/mod.rs` caller-location matrix covers all 54 `generate_handler!` commands with a guarded / main-only-by-caller-location / detached-legit justification each; `test_guard_matrix_covers_every_registered_command` brace-counts the handler list out of `lib.rs` and fails on any registered-without-entry or listed-without-registration drift. `check_for_update` + `cancel_deferred_update` stay main-only by caller location (UpdatePrompt mounts only under `{#if isMainWindow}`); adding a `window` param + guard is the recorded follow-up. | | Deep-link navigate UX (C2) | ✅ | `navigate` event emitted from `handle_deep_link` / Teams auth success (`dashboard` / `settings`); listener in `+page.svelte` defers while Onboarding owns the view. | -| Tray tooltip + native CheckMenuItems + dock badge (C4) | ✅ | `tray.rs` — live tooltip `Artist — Track (▶|⏸)` on each rebuild; Play/Pause, Shuffle and Repeat as native CheckMenuItems driven by `LAST_PLAYING_STATE` / `LAST_SHUFFLE_STATE` / `LAST_REPEAT_STATE`; macOS-only (`#[cfg]`) presence-gated dock badge wired into the polling loop (`b82f515`). The menu also carries a **Pause / Resume Sync** item (IDs `ID_PAUSE_SYNC` / `ID_RESUME_SYNC`) and, since 4.7.0 (#677), a **Pause sync for…** snooze submenu plus **Resume sync now** while a snooze is active. The tray's own status line is `sync_status_line` ("Syncing — Artist — Track", "Paused — …", "Not syncing", "Syncing — nothing playing"), and the tooltip is `{status_line} · {track_tooltip}`; the words come from the `i18n::Strings` table and a snooze replaces the line with `snooze_status_line` (remaining time + local deadline). | +| Tray tooltip + native CheckMenuItems + dock badge (C4) | ✅ | `tray/mod.rs` + `tray/dedup.rs` — live tooltip `Artist — Track (▶|⏸)` on each rebuild; Play/Pause, Shuffle and Repeat as native CheckMenuItems driven by `LAST_PLAYING_STATE` / `LAST_SHUFFLE_STATE` / `LAST_REPEAT_STATE`; macOS-only (`#[cfg]`) presence-gated dock badge wired into the polling loop (`b82f515`). The menu also carries a **Pause / Resume Sync** item (IDs `ID_PAUSE_SYNC` / `ID_RESUME_SYNC`) and, since 4.7.0 (#677), a **Pause sync for…** snooze submenu plus **Resume sync now** while a snooze is active. The tray's own status line is `sync_status_line` ("Syncing — Artist — Track", "Paused — …", "Not syncing", "Syncing — nothing playing"), and the tooltip is `{status_line} · {track_tooltip}`; the words come from the `i18n::Strings` table and a snooze replaces the line with `snooze_status_line` (remaining time + local deadline). | | Settings dirty-state + clamp feedback + reset (C9) | ✅ | `Settings.svelte` — unsaved-changes banner via BigInt-safe deep compare; inline clamp feedback mirroring Rust `clamp_polling`; per-section Reset-to-default buttons using `defaultConfig`. | | Settings per-card split, slice 1 (#750-PARTIAL) | ⚠ Partial | `src/lib/components/settings/` holds `SettingsCard` shell + `RulesCard`/`LoggingCard`/`BackupCard`/`ShortcutsCard` (each: `$bindable()` slice + `onreset`/`onchange`); `Settings.svelte` keeps draft state, save/discard, `pendingNav`, footer. Remaining: Spotify, Teams, Presence, StatusFormat, Polling, Notifications, Appearance, Profiles, Updates cards. | | Notification throttle + grouping (C8) | ✅ | `Dashboard.svelte` — max 1 track-change notification per 5s (throttled tracks don't claim `lastNotifiedId`); replace-in-place via stable id + group tag where the platform supports it. 4.6 moved the opt-in into `src/lib/stores/notifications.ts` (#549); 4.7.0 (#675) replaces it with four config-backed classes (see the notification-classes row below), migrating the legacy `localStorage.notificationsEnabled` value into `track_change` exactly once — and only after the write lands, so a rejected save cannot lose an opt-out. Track changes keep the 5 s throttle (`TRACK_NOTIFICATION_THROTTLE_MS`) and the replace-in-place id. | @@ -81,7 +81,7 @@ end-to-end; the few rows that can't be sourced inline are explicitly flagged | Maintained path/rand crates | ✅ | `dirs` → `directories` 6, `rand` 0.9 with `try_fill_bytes` propagation; single keyring feature set (#418). | | Status template placeholders ({device}/{playlist}/{progress}/{shuffle}/{repeat}) | ✅ | `src-tauri/src/spotify.rs::format_status_with_context` — one 13-token table (`placeholder_values`) filled per render and substituted in a single left-to-right pass (`substitute_placeholders`), so a value that arrived from Spotify is never re-scanned and re-expanded (#341; `format_status_does_not_expand_data_inserted_tokens`, #580). `{context}` is a literal alias of `{playlist}`; `{shuffle}`/`{repeat}` are icon-only (🔀/🔁 or `""`); `{progress}` is `M:SS` with no hour rollover (`90:00` for a 90-minute episode) and **empty** when `progress_ms` is `None` (#165). UI hint keys `settings.placeholdersHint` / `onboarding.placeholdersHint` list the music tokens. | | Podcast & audiobook episodes | ✅ | `src-tauri/src/spotify.rs::map_media_item` reads the item's own `type` against the documented `oneOf(track, episode)` union: an episode's `show.name` takes the `artist` slot and `show.publisher` the `album` slot, and `EpisodeInfo` carries `show_name`/`publisher`. `poll_once::process_track` selects `DEFAULT_EPISODE_STATUS_FORMAT` (`🎙️ {show} - {episode}`) whenever `now.episode.is_some()`, so a music template is never applied to an episode (#581). Adverts are unchanged: the envelope's `currently_playing_type` is gated and `Ad | Unknown` returns `None` from the mapper — "nothing playing" (#161). The queue mapper feeds episodes into tray *Up Next* and drops ads (#583). Note: the episode template is a built-in constant — there is **no** `teams.episode_status_format` config key. | -| Tray Shuffle / Repeat toggles | ✅ | `src-tauri/src/tray.rs` — both are native CheckMenuItems whose marks read `LAST_SHUFFLE_STATE` / `LAST_REPEAT_STATE`, written by `note_playback_modes` from the poll body (no extra request) and optimistically on a successful tray toggle before `force_tray_refresh`. Shuffle targets the inverse of the last known state; Repeat targets `RepeatState::next()`, i.e. `off → context → track → off`, and its label spells the mode out because a check mark cannot distinguish the last two (#582). A rejected command records nothing, so the tray never claims a state Spotify refused; the error surfaces on `playback-error` as an in-app toast. | +| Tray Shuffle / Repeat toggles | ✅ | `src-tauri/src/tray/dedup.rs` — both are native CheckMenuItems whose marks read `LAST_SHUFFLE_STATE` / `LAST_REPEAT_STATE`, written by `note_playback_modes` from the poll body (no extra request) and optimistically on a successful tray toggle before `force_tray_refresh`. Shuffle targets the inverse of the last known state; Repeat targets `RepeatState::next()`, i.e. `off → context → track → off`, and its label spells the mode out because a check mark cannot distinguish the last two (#582). A rejected command records nothing, so the tray never claims a state Spotify refused; the error surfaces on `playback-error` as an in-app toast. | | Live stage progress + cancel for install-on-quit | ✅ | `updater_bg.rs` binds progress, completion, and cancellation to the exact stage request id and generation. Cancel invalidates an in-flight completion, removes an already-committed payload, or tombstones cancellation that arrives before staging begins; a losing download cannot stage or install, and queued terminal events are suppressed. | | Keychain-unavailable surface (locked vs absent) | ✅ | `keychain.rs::KeychainPresence` preserves `Present` / `Absent` / `Unavailable(help)`. Config loads stamp both derived fields through `with_keychain_flags` and the warm presence cache; a fresh `Present` observation is reused for 30 seconds, while a cold or expired cache falls back to the direct namespaced/legacy probe. Explicit user-action checks remain uncached. | | Log viewer on-disk backfill | ✅ | `src-tauri/src/commands/logs.rs::get_recent_logs` reads the tail on `spawn_blocking`, clamped twice — `MAX_LOG_LINES` 500 and `LOG_TAIL_MAX_BYTES` 256 KiB — dropping the partial first line; a missing file is `Ok(empty)`, not an error. `LogViewer.svelte` registers `log://log` first and then awaits the seed, prepending it and re-clamping to `MAX_BUFFER`. The seed is **raw, not redacted** by explicit decision (#595): redaction belongs to the paste-able snapshot path. **Clear** sets `seedCancelled` so history cannot reappear. | @@ -96,7 +96,7 @@ end-to-end; the few rows that can't be sourced inline are explicitly flagged | Out-of-office gating (opt-in) | ✅ | `config.teams.gate_when_out_of_office`, default **off** (`default_gate_when_out_of_office`). Fires on either documented signal — `outOfOfficeSettings.isOutOfOffice` or an `outOfOffice` activity (case-insensitive; a body omitting the object parses as not-out-of-office) — and is the **lowest-precedence** reason, so busy/in-a-call wins the explanation. A rule carrying its own presence pair overrides it for that iteration (`ooo_gate_enabled(config, rule.presence.is_some())`). Reason string is the literal `out of office` (spaces). Unit-tested (`teams.rs::out_of_office_gates_only_when_opted_in`, `parse_presence_body_carries_status_message_and_ooo`). | | Bounded Available session + quit clear | ✅ | `poll_once::presence_expiration_duration` derives the requested `expirationDuration` from the remaining listening time plus one re-arm period, clamped to Microsoft's documented `PT5M`–`PT4H`; a live/unknown-position stream (issue #165) keeps `PT4H`. `lib.rs`'s `RunEvent::Exit` arm runs `updater_bg::install_pending_on_exit` **first**, then `polling::clear_presence_on_exit`, which is best-effort and log-only, bounded by `teams::EXIT_CLEANUP_TIMEOUT` (3 s), no-ops when nothing of ours was armed or posted, skips an expired stored token, and does not emit `presence-availability-updated`. Unit-tested for the pure duration function; the exit-time clear now decides from the `ExitSnapshot` written on every posting/arming iteration (`polling/state.rs`), so the loop's exit-tail `reset_write_clocks()` can no longer cancel it (#684), and both the behaviour and the ordering are pinned by `test_exit_cleanup_survives_the_loop_exit_tail` and `test_clear_presence_on_exit_reads_the_snapshot_not_the_clocks` (a Windows update-driven quit never reaches the clear). | | Three config fields exposed in 4.6 (#538) | ✅ | All three are editable in Settings with clamp feedback and all three are consumed. `status_rules.quiet_hours[].replacement_status` — posted from the playing path and the no-track clear, capped at `MAX_RULE_STATUS_CHARS` (160); it is part of the change fingerprint, so an edit mid-track forces one rewrite. `teams.profanity_extra_words` — `profanity::filter_status(text, placeholder, is_playing, extra_words)` runs the entries through `contains_extra_word` against the same normalized text with the built-in evasion machinery (separator skip/wildcard, leet, stretch gates, the #578 span check) but without the built-in stem carve-outs, so a user's `not` does not flag `noting`; consumed by the polling status write and by `preview_status`. `polling.pause_backoff_max_seconds` — the pause-ladder ceiling (see the backoff row above). Settings hints (`settings.extraWordsHint`, `settings.pauseBackoffClampHint`) match the implemented behaviour. | -| Tray snooze — "Pause sync for…" (v4.7.0) | ✅ | `src-tauri/src/tray.rs` — the snooze submenu (`ID_SNOOZE_30` / `ID_SNOOZE_1H` / `ID_SNOOZE_TOMORROW`, plus `ID_SNOOZE_RESUME` while one is active) writes `config.rs::AppConfig.snooze_until` as an RFC3339 UTC instant, and a deadline that passed while the app was closed is cleared by `tray.rs::clear_expired_snooze_at_startup` (run from the tray setup), and one that lapses while the app is running by `poll_once.rs::clear_snooze_if_expired` on the iteration that observes it — both through `config.rs::clamp_snooze`, which every save also runs via `config.rs::clamped_config`; `config.rs::load_config` deliberately only reports the state (`config.rs::snooze_expired_deadline`) because it is a reader holding no write guard. `poll_once.rs::snooze_gate` runs **before** the write clocks are loaded, so a snoozed iteration performs no Spotify or Graph request, touches no clock and leaves the existing Teams status alone; the driver sleeps at `polling.max_interval_seconds` and re-evaluates the deadline every iteration (the thread is never stopped or parked), and `run_oneshot` honours the same gate so a CLI one-shot cannot undo a snooze. "Until tomorrow" is the next **local** midnight (`config.rs::snooze_preset_deadline`, never `now + 24 h`). `Dashboard.svelte` shows the countdown chip with **Resume now**. | +| Tray snooze — "Pause sync for…" (v4.7.0) | ✅ | `src-tauri/src/tray/snooze.rs` — the snooze submenu (`ID_SNOOZE_30` / `ID_SNOOZE_1H` / `ID_SNOOZE_TOMORROW`, plus `ID_SNOOZE_RESUME` while one is active) writes `config.rs::AppConfig.snooze_until` as an RFC3339 UTC instant, and a deadline that passed while the app was closed is cleared by `tray/snooze.rs::clear_expired_snooze_at_startup` (run from the tray setup), and one that lapses while the app is running by `poll_once.rs::clear_snooze_if_expired` on the iteration that observes it — both through `config.rs::clamp_snooze`, which every save also runs via `config.rs::clamped_config`; `config.rs::load_config` deliberately only reports the state (`config.rs::snooze_expired_deadline`) because it is a reader holding no write guard. `poll_once.rs::snooze_gate` runs **before** the write clocks are loaded, so a snoozed iteration performs no Spotify or Graph request, touches no clock and leaves the existing Teams status alone; the driver sleeps at `polling.max_interval_seconds` and re-evaluates the deadline every iteration (the thread is never stopped or parked), and `run_oneshot` honours the same gate so a CLI one-shot cannot undo a snooze. "Until tomorrow" is the next **local** midnight (`config.rs::snooze_preset_deadline`, never `now + 24 h`). `Dashboard.svelte` shows the countdown chip with **Resume now**. | | Global shortcuts (v4.7.0) | ✅ | `src-tauri/src/commands/shortcuts.rs` — two slots (`ShortcutSlot::TogglePlayback` / `ToggleSync`) with defaults `CmdOrCtrl+Alt+P` / `CmdOrCtrl+Alt+S` stored in `config.rs::ShortcutsConfig`; `plan_shortcuts` decides each slot independently, `validate_accelerator` refuses an unparsable, colliding or modifier-less combination (bare keys need F1–F24 or media, #810), and `apply_plan` registers through `tauri-plugin-global-shortcut` (`lib.rs`). Registration is per slot and never fatal: a refused grab (a Wayland compositor, or a combination another app owns) is recorded as that slot's status and shown in Settings as `settings.shortcutRegistrationFailed` with the reason, while the other slot keeps working. `dispatch_event` routes playback thr… | | CLI flags (v4.7.0) | ✅ | `src-tauri/src/lib.rs` — `--status` (`CliCommand::Status`, prints the `commands/sync.rs::SyncStatus` JSON and exits 0), `--sync-once` (exactly one `polling::run_oneshot` iteration; exit 0 on completion, 1 with the poller's reason on stderr), `--help` (usage text, exit 0). `--status` and `--help` are handled before any Tauri app is built, so they need no window, no tray icon and no single-instance lock; `--sync-once` drives the app's own poller and therefore needs a display server on Linux (documented `xvfb-run` workaround). Unknown arguments are ignored and launch the GUI exactly as before, like `--minimized` and a `presencejam://` URL. On Windows release builds the runner attaches to the parent console before CLI output (#818); a `windows-cli-smoke` CI job asserts `--help` via `cmd /c`. | | Update channel (v4.7.0) | ✅ | `config.rs::UpdatesConfig.channel` (`UpdateChannel::{Stable, Beta}`, serialized lowercase, lenient on an unknown value → `Stable` with a log) selects the endpoint list in `updater_bg.rs::update_endpoints`: Stable is `…/releases/latest/download/latest.json`; Beta is the rolling `…/releases/download/beta/latest-beta.json` asset **followed by** the stable URL, so a missing or non-newer beta manifest falls through to stable. `updater_bg.rs::check_for_update` resolves the banner's candidate in Rust, and on Beta the immediate download-and-relaunch path is replaced by install-on-quit. | @@ -104,7 +104,7 @@ end-to-end; the few rows that can't be sourced inline are explicitly flagged | Rule scheduling, rule priority, quiet-hours pause-polling and editable pause/stop texts (v4.7.0) | ✅ | `config.rs::TrackRuleEntry` gains `days` (ISO 1=Mon…7=Sun, empty = every day), `start_minutes` and `end_minutes` (defaults `[]`, `0`, `1440`), normalized by `config.rs::clamp_track_rule_window`; `poll_once.rs::rule_gate_at` walks `status_rules.track_rules` **in array order and returns the first match** (the Settings card's move-up / move-down buttons edit that order), while `quiet_hours` keep strict precedence. Midnight-crossing windows own the night they start (#794): each half is tested against its own day, so Monday 22:00–07:00 covers Monday night into Tuesday morning. `config.rs::QuietHoursEntry.pause_polling` (default `false`) makes an active window skip the iteration entirely — no Spotify/Teams request, no clock touched, thread never parked — logged once per transition (`[POLLING] quiet hours: polling paused u… | | Settings export / import (v4.7.0) | ✅ | Import validates the selected document and rejects plaintext `client_secret`, then stages it beside `config.json` in an app-owned `.import.tmp` sidecar with exclusive creation and fsync. The live file moves to `config.json.bak`, the staged file is atomically installed, and a failed install rolls back. Replacement, reload/validation, and `AppState` adoption run under one config write guard. | | Follow-system theme + compact density (v4.7.0) | ✅ | `src/lib/stores/theme.ts` — `Theme = 'dark' \| 'light' \| 'system'` with `resolveTheme` and a `matchMedia('(prefers-color-scheme: light)')` `change` listener that repaints only while the preference is `system` (an explicit dark/light is never overridden by the OS); `src/app.html`'s pre-paint bootstrap resolves the stored preference too, so a System user sees no flash of the wrong theme. A sibling `density` store sets `html[data-density="compact"]`, a token-scale override block in `src/app.css` applied before first paint, with no component variants; Settings → Appearance exposes the third theme card, the hint, and the density toggle. | -| Tray / native-menu localization (v4.7.0) | ✅ | `i18n.rs` keeps the en/de/fr native strings in one table and the production literal scanner lexes double-quoted and raw URL-containing literals in `tray.rs` and `menu.rs`, ignoring comments and char syntax while failing on copy absent from the explicit allowlist. Scanner self-tests cover translated copy, field references, comments, URL punctuation, and char/lifetime boundaries. | +| Tray / native-menu localization (v4.7.0) | ✅ | `i18n.rs` keeps the en/de/fr native strings in one table and the production literal scanner lexes double-quoted and raw URL-containing literals in `tray/` and `menu.rs`, ignoring comments and char syntax while failing on copy absent from the explicit allowlist. Scanner self-tests cover translated copy, field references, comments, URL punctuation, and char/lifetime boundaries. | | Frontend coverage ratchet (v4.7.0) + Rust coverage gate (wave-6, #838) | ✅ | `vitest.config.js` sets `coverage.provider: 'v8'` with thresholds equal to the measured baseline (statements 68.91, branches 62.57, functions 73.33, lines 68.02) and an explicit `exclude` for the generated `src/lib/types-generated/**` and the two files the provider cannot instrument — so the local and CI denominators match. `ci.yml`'s `frontend` job runs `npm run test:coverage` in place of `npm test`, and a dropped percentage fails the job. Wave-6 adds a `rust-coverage` job (`cargo llvm-cov --lib`, lcov artifact, per-file floors for `poll_once.rs`/`config.rs`/`token_io.rs` starting at 0 with calibration TODO). Rust coverage tooling no longer out of scope. | | Dependency licence + source gate (wave-6, #776) + npm prod-audit gate (#844) | ✅ | `src-tauri/deny.toml` allow-lists the tree's licences with `[sources] unknown-registry/unknown-git = "deny"`; `ci.yml`'s SHA-pinned `cargo-deny` job enforces `licenses` + `sources`. The `dep-audit` job gates the npm production tree (`npm audit --omit=dev --audit-level=high`) with step-scoped `continue-on-error` on the advisory legs; the `ignore:` list carries owner + expiry tied to #642. | | Docs link audit in CI (wave-6, #831) + vendored-binary guard (#753) | ✅ | `ci.yml`'s `docs-links` job runs stdlib-only `docs/link-audit.py` (relative targets + `#anchor` resolution); `no-vendored-binaries` fails on untracked, unignored root paths (chrome-headless-shell stays gitignored). Both documented in `docs/RELEASING.md` §3 + §5. | @@ -115,7 +115,7 @@ end-to-end; the few rows that can't be sourced inline are explicitly flagged | Update banner wired to the token system (wave-7, #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; the banner's `z-index` + `box-shadow` are now `var(--z-update-banner)` + `var(--shadow-update-banner)`. | | Logger flushes before the build-failure exit (wave-7, #947) | ✅ | `lib.rs::run` `Builder::build` failure arm calls `log::logger().flush()` before `std::process::exit(1)`, mirroring the CLI failure path. tauri-plugin-log's file target buffers, so the "the app will not start" diagnostic can no longer be lost. | | Stale comment citations swept (wave-7, #856, #908) | ✅ | `polling.rs` / `commands.rs` / `ts_rs_export` references and nine other stale `file:line` citations across the codebase point at live homes. `grep -rn 'polling\.rs\|commands\.rs' src-tauri/src` returns no matches. | -| Tray + deep-link state lookups cannot panic on an unmanaged `AppState` (#937) | ✅ | `tray.rs::force_tray_refresh` and `lib.rs::handle_deep_link` resolve the managed state through one `try_state` lookup that logs `[TRAY]`/`[DEEP_LINK]` at `warn!` and returns when it is absent, instead of the panicking `app.state::<>()`; each also dropped a redundant second `state()` on a handle it already held. Defence-in-depth, not a reachable-crash fix: every current caller (tray menu arms, the `save_config` locale repaint, `get_current()`'s drain, the `on_open_url` listener) runs after setup's `app.manage(state.clone())`, and the forwarded-second-launch tray path (`refresh_tray_from_state` → `repaint_tray_from_state`) already used `try_state`. Three behavioural unit tests drive the unmanaged and managed arms through injected seams — no GUI runtime, no `tauri::test` mock, and no `[dev-dependencies] tauri` `test` feature, which is what breaks the Windows test binary's load (#836). A deep link arriving in that window is still logged and dropped rather than replayed (#1122). | +| Tray + deep-link state lookups cannot panic on an unmanaged `AppState` (#937) | ✅ | `tray/actions.rs::force_tray_refresh` and `lib.rs::handle_deep_link` resolve the managed state through one `try_state` lookup that logs `[TRAY]`/`[DEEP_LINK]` at `warn!` and returns when it is absent, instead of the panicking `app.state::<>()`; each also dropped a redundant second `state()` on a handle it already held. Defence-in-depth, not a reachable-crash fix: every current caller (tray menu arms, the `save_config` locale repaint, `get_current()`'s drain, the `on_open_url` listener) runs after setup's `app.manage(state.clone())`, and the forwarded-second-launch tray path (`refresh_tray_from_state` → `repaint_tray_from_state`) already used `try_state`. Three behavioural unit tests drive the unmanaged and managed arms through injected seams — no GUI runtime, no `tauri::test` mock, and no `[dev-dependencies] tauri` `test` feature, which is what breaks the Windows test binary's load (#836). A deep link arriving in that window is still logged and dropped rather than replayed (#1122). | | Pre-manage deep link is buffered and replayed after setup (#1122) | ✅ | `lib.rs::handle_deep_link`'s unmanaged-state guard buffers the callback URL in a process-wide single slot (`PENDING_DEEP_LINK`, at most one URL, overwrite-not-queue, drained 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 gate sees and true duplicates still drop; both new log lines carry presence only, never the URL/code/verifier/state. Two behavioural unit tests (buffer-then-replay completes auth exactly once; second early callback overwrites) alongside the unchanged #937 pair. | | App shell renders route content via the `children` snippet (#779) | ✅ | `src/routes/+layout.svelte` declares `let { children }: { children?: Snippet } = $props()` and renders `{@render children?.()}` inside the existing `{:else}` branch; no legacy slot element is left anywhere under `src/`, so `npm run check` reports no `slot_element_deprecated` warning. `tests/shell-render.test.ts` mounts the shell with a page snippet and asserts the body renders inside the chrome and is swapped (never duplicated, never stale) on the next route. | | Polling sync flag hardcodes its Acquire/Release ordering (#759) | ✅ | `lib.rs::Polling::is_syncing()` loads with Acquire and `set_syncing(value)` stores with Release — no `Ordering` parameter, 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. `test_polling_sync_flag_publish_then_observe` pins the publish-then-observe default; `test_polling_sub_struct_lock_and_invalidate` passes unchanged. | diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index 7d69c95b..95514538 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -247,7 +247,7 @@ PresenceJam-Desktop/ │ │ ├── profanity.rs # Curated profanity word list │ │ ├── spotify.rs # PKCE OAuth client + Web API (ts-rs TS) │ │ ├── teams.rs # Device-code + MS Graph (ts-rs TS) -│ │ ├── tray.rs # System tray + dedup snapshot (native CheckMenuItem Play/Pause + live tooltip, v4.0) +│ │ ├── tray/ # System tray menu (mod + cache/dedup/snooze/devices/actions, #756) (native CheckMenuItem Play/Pause + live tooltip, v4.0) │ │ ├── updater_bg.rs # Background update checks + stage_deferred_update / PendingUpdate (v4.0) │ │ ├── diagnostics.rs # Telemetry-free get_diagnostics_snapshot (v4.0) │ │ ├── menu.rs # macOS / Windows app menu bar diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 20c28bfb..655d970e 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -63,7 +63,7 @@ graph TD SpotifyAPI["spotify.rs
Spotify Web API (Authorization Code + PKCE)"] TeamsAPI["teams.rs
Microsoft Graph (device code)"] Keychain["keychain.rs
OS keychain wrapper
(Secret Service on Linux)"] - Tray["tray.rs / menu.rs
system tray + app menu"] + Tray["tray/ / menu.rs
system tray + app menu"] end subgraph Storage ["Storage"] diff --git a/docs/architecture/tray-and-shell.md b/docs/architecture/tray-and-shell.md index 31c0aebb..5c4a494e 100644 --- a/docs/architecture/tray-and-shell.md +++ b/docs/architecture/tray-and-shell.md @@ -88,12 +88,12 @@ The UI is localized to **English, German, and French** via the i18n barrel Detached Logs/Settings windows own independent locale instances, so a `storage` listener converges them on the main window's write (the same pattern as the `#423` theme listener, with a same-value guard that stops a write loop) (#620). -- **Native surfaces (4.7.0, #674):** the tray menu (`tray.rs`) and the native +- **Native surfaces (4.7.0, #674):** the tray menu (`tray/`) and the native application menu (`menu.rs`) render from a Rust string table (`src-tauri/src/i18n.rs`: one `Strings` field per literal, with `EN`/`DE`/`FR` tables). An unknown `locale` falls back to English and is logged, and a Rust parity test fails when the three tables drift apart or a label is hard-coded - back into `tray.rs`/`menu.rs`. + back into `tray/`/`menu.rs`. - Known limitation: Rust-side error strings surfaced through `invoke()` rejections and event payloads remain English, as does the app name. @@ -195,7 +195,7 @@ matrix builds **aarch64 macOS only** — Intel Macs never receive updates ## System Tray (v4.6) -`tray.rs` builds the menu natively, from in-process state: +`tray/mod.rs` builds the menu natively, from in-process state: - **Shuffle / Repeat are real toggles (#582).** Both are `CheckMenuItemBuilder` items; Shuffle's mark reads `LAST_SHUFFLE_STATE` and @@ -222,7 +222,7 @@ matrix builds **aarch64 macOS only** — Intel Macs never receive updates Devices/Queue listings; those are display fetches, not playback commands. - **Refresh cadence:** the polling loop calls `update_tray_menu` after every iteration, behind a dedup key built by `tray_snapshot_for` - (`src-tauri/src/tray.rs::tray_snapshot_for`) from `(is_syncing, is_window_visible, + (`src-tauri/src/tray/dedup.rs::tray_snapshot_for`) from `(is_syncing, is_window_visible, "artist|title|is_playing", shuffle, repeat, snooze deadline + minute bucket)`. The mode atoms and the snooze key are in the key on purpose: a Shuffle/Repeat change made from another Spotify client has to force a rebuild, otherwise the diff --git a/src-tauri/src/i18n.rs b/src-tauri/src/i18n.rs index e50c698d..b772fc25 100644 --- a/src-tauri/src/i18n.rs +++ b/src-tauri/src/i18n.rs @@ -9,7 +9,7 @@ //! `tables_carry_an_identical_field_set` parses the struct declaration out of //! this file and fails when a table misses a field or falls out of order, and //! `no_user_visible_literal_stays_hard_coded` scans production literals in -//! `tray.rs`/`menu.rs` and fails when user-visible copy is not in these tables. +//! `tray/`/`menu.rs` and fails when user-visible copy is not in these tables. //! Deliberate exceptions, mirroring the frontend's documented limitation: //! error strings surfaced through `invoke()` rejections or event payloads //! (e.g. `playback-error`) stay English, and the app name ("PresenceJam", @@ -1311,7 +1311,12 @@ mod tests { fn no_user_visible_literal_stays_hard_coded() { let mut offenders = Vec::new(); for (module, src) in [ - ("tray.rs", include_str!("tray.rs")), + ("tray/mod.rs", include_str!("tray/mod.rs")), + ("tray/cache.rs", include_str!("tray/cache.rs")), + ("tray/dedup.rs", include_str!("tray/dedup.rs")), + ("tray/snooze.rs", include_str!("tray/snooze.rs")), + ("tray/devices.rs", include_str!("tray/devices.rs")), + ("tray/actions.rs", include_str!("tray/actions.rs")), ("menu.rs", include_str!("menu.rs")), ] { offenders.extend( diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index aecd3771..167500b1 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -481,7 +481,7 @@ mod tests { // (`tray::handle_menu_event`) — this handler keeps only the // window-menu-only arms, so the graceful-shutdown routing is pinned // from the dispatcher side instead. - let src = include_str!("tray.rs"); + let src = include_str!("tray/mod.rs"); let sig_idx = src .find("pub fn handle_menu_event(") .expect("handle_menu_event must exist"); diff --git a/src-tauri/src/polling/poll_once.rs b/src-tauri/src/polling/poll_once.rs index 2e1b026d..4efa321c 100644 --- a/src-tauri/src/polling/poll_once.rs +++ b/src-tauri/src/polling/poll_once.rs @@ -11871,7 +11871,7 @@ mod tests { sync_source.contains("crate::polling::run_oneshot("), "refresh_status must route its one-shot through run_oneshot so the pause applies (#793)" ); - let tray_source = include_str!("../tray.rs"); + let tray_source = include_str!("../tray/actions.rs"); assert!( tray_source.contains("crate::polling::run_oneshot("), "the tray catch-up must route its one-shot through run_oneshot so the pause applies (#793)" diff --git a/src-tauri/src/redact.rs b/src-tauri/src/redact.rs index c6317b23..c080e236 100644 --- a/src-tauri/src/redact.rs +++ b/src-tauri/src/redact.rs @@ -96,7 +96,12 @@ mod tests { "commands/spotify_auth.rs", include_str!("commands/spotify_auth.rs"), ), - ("tray.rs", include_str!("tray.rs")), + ("tray/mod.rs", include_str!("tray/mod.rs")), + ("tray/cache.rs", include_str!("tray/cache.rs")), + ("tray/dedup.rs", include_str!("tray/dedup.rs")), + ("tray/snooze.rs", include_str!("tray/snooze.rs")), + ("tray/devices.rs", include_str!("tray/devices.rs")), + ("tray/actions.rs", include_str!("tray/actions.rs")), ("config.rs", include_str!("config.rs")), ("teams.rs", include_str!("teams.rs")), ("spotify.rs", include_str!("spotify.rs")), diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs deleted file mode 100644 index d6c52e7c..00000000 --- a/src-tauri/src/tray.rs +++ /dev/null @@ -1,4709 +0,0 @@ -use std::borrow::Cow; -use std::sync::atomic::Ordering; -use std::sync::OnceLock; -use std::time::{Duration, Instant}; -use tauri::{ - menu::{ - CheckMenuItemBuilder, MenuBuilder, MenuItemBuilder, PredefinedMenuItem, Submenu, - SubmenuBuilder, - }, - tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent}, - AppHandle, Emitter, Listener, Manager, -}; - -use crate::events::PlaybackStateChanged; -use crate::spotify::RepeatState; - -use crate::i18n::{self, Strings}; -use crate::menu::{ID_ABOUT, ID_OPEN_LOGS, ID_QUIT, ID_SETTINGS, ID_SHOW_DASHBOARD, ID_SHOW_LOGS}; - -// Menu item IDs -const ID_SHOW_HIDE: &str = "show_hide_window"; -const ID_PAUSE_SYNC: &str = "pause_sync"; -const ID_RESUME_SYNC: &str = "resume_sync"; -const ID_CURRENT_TRACK: &str = "current_track"; -/// Disabled head-of-menu item that states what the app is actually doing -/// (issue #591) — the Pause/Resume verb alone left sync state unstated, and -/// the presence-gate badge is macOS-only. -const ID_SYNC_STATUS: &str = "sync_status"; -// Spotify playback control (issue #3.0-P3). Device submenu items carry -// ids of the form `{ID_DEVICES}|{spotify device id}` so the click handler -// resolves the stable id instead of racing a list index (issue #388). -const ID_PLAY_PAUSE: &str = "play_pause"; -const ID_PREVIOUS: &str = "previous"; -const ID_NEXT: &str = "next"; -/// Shuffle toggle (issue #582). A check item whose mark comes from the -/// `shuffle_state` the poll body already carries — no extra request. The -/// label is the `shuffle` entry of the i18n table (issue #674): the state -/// itself stays the native check mark. -const ID_SHUFFLE: &str = "shuffle"; -/// Repeat toggle (issue #582). Repeat has THREE states (`off`/`context`/ -/// `track`), so the item's label spells the mode out — a check mark alone -/// cannot tell `context` from `track`. -const ID_REPEAT: &str = "repeat"; -const ID_DEVICES: &str = "devices"; -const ID_QUEUE: &str = "queue"; -/// Menu-item id prefix for device submenu entries (`{ID_DEVICES}|{device id}`). -/// `concat!` cannot take a const, so this mirrors `ID_DEVICES` literally; -/// keep the two in sync when either changes. -const DEVICE_ITEM_PREFIX: &str = "devices|"; - -// Snooze submenu (4.7.0, S9 / issue #677). The three unqualified ids are the -// presets; the fourth only exists while a snooze is active, so the way out of a -// snooze always sits beside the way in. Issue #867 adds a fifth id — the -// "until this meeting ends" entry — that is appended only while a busy -// Outlook meeting is currently in progress. -const ID_SNOOZE_30: &str = "snooze|30m"; -const ID_SNOOZE_1H: &str = "snooze|1h"; -const ID_SNOOZE_TOMORROW: &str = "snooze|tomorrow"; -const ID_SNOOZE_NEXT_MEETING: &str = "snooze|next_meeting"; -const ID_SNOOZE_RESUME: &str = "snooze|resume"; -/// Menu-item id prefix for the snooze submenu. Mirrors the ids above literally -/// (`concat!` cannot take a const); keep both in sync when either changes. -const SNOOZE_ITEM_PREFIX: &str = "snooze|"; -const SNOOZE_30_SUFFIX: &str = "30m"; -const SNOOZE_1H_SUFFIX: &str = "1h"; -const SNOOZE_TOMORROW_SUFFIX: &str = "tomorrow"; -const SNOOZE_NEXT_MEETING_SUFFIX: &str = "next_meeting"; -const SNOOZE_RESUME_SUFFIX: &str = "resume"; - -// Issue #870: the "Recent statuses" submenu entries carry the -// `{MANUAL_STATUS_ITEM_PREFIX}|` shape — the index resolves to a -// recent-status entry, and a stale index falls off the back of the ring -// cleanly (the click handler ignores unknown indices). The literal suffixes -// declared below are stable across the GUI and the tray, so a menu rebuilt -// against a stale snapshot cannot dispatch an unknown action. -const MANUAL_STATUS_ITEM_PREFIX: &str = "manualstatus|"; -const ID_MANUAL_STATUS_CLEAR: &str = "manualstatus|clear"; - -// Issue #871: the Volume submenu entries carry `{VOLUME_ITEM_PREFIX}|{n}` -// where `n` is the literal Spotify volume percentage. The Seek submenu -// entries carry `{SEEK_ITEM_PREFIX}|{+|-}{ms}` (the sign is in the id so -// the click handler cannot mistake a seek-back for a seek-forward). -const VOLUME_ITEM_PREFIX: &str = "volume|"; -const SEEK_ITEM_PREFIX: &str = "seek|"; -const SEEK_BACK_30_MS: u64 = 30_000; -const SEEK_FORWARD_30_MS: u64 = 30_000; - -// Issue #869: the presence-profile submenu. The "no profile" entry clears -// the active profile (back to the base configuration); every other entry -// carries `{PROFILE_ITEM_PREFIX}|{profile name}` and the click handler -// resolves the name against the clamped list. Names are unique and ≤ 32 -// chars after `clamp_presence_profiles`, so the id dispatch never has to -// disambiguate. -const ID_PROFILE_BASE: &str = "profile|base"; -const PROFILE_ITEM_PREFIX: &str = "profile|"; - -/// A safe diagnostic label for a native menu event id. -/// -/// Fixed action ids remain readable so support diagnostics keep their useful -/// dispatch context. Ids carrying a menu payload expose only the stable prefix -/// and suffix length; unknown fixed ids expose only their length. This follows -/// the device-selection redaction discipline and keeps bearer-adjacent Spotify -/// device ids out of every dispatcher log line. -pub(crate) fn menu_event_id_for_log(id: &str) -> Cow<'_, str> { - if matches!( - id, - ID_SHOW_HIDE - | ID_PAUSE_SYNC - | ID_RESUME_SYNC - | ID_CURRENT_TRACK - | ID_SYNC_STATUS - | ID_PLAY_PAUSE - | ID_PREVIOUS - | ID_NEXT - | ID_SHUFFLE - | ID_REPEAT - | ID_QUIT - | ID_DEVICES - | ID_QUEUE - | ID_SETTINGS - | ID_OPEN_LOGS - | ID_SHOW_DASHBOARD - | ID_SHOW_LOGS - | ID_ABOUT - ) { - return Cow::Borrowed(id); - } - - match id.split_once('|') { - Some((prefix, suffix)) => Cow::Owned(format!("<{prefix} len={}>", suffix.len())), - None => Cow::Owned(format!("", id.len())), - } -} - -fn log_dispatched_menu_event_with(id: &str, emit: impl FnOnce(&log::Record<'_>)) { - let redacted = menu_event_id_for_log(id); - let message = format!("[TRAY] menu event: id={redacted}"); - let args = format_args!("{message}"); - let record = log::Record::builder() - .args(args) - .level(log::Level::Info) - .target(module_path!()) - .build(); - emit(&record); -} - -fn log_dispatched_menu_event(id: &str) { - let level = log::Level::Info; - if level > log::STATIC_MAX_LEVEL || level > log::max_level() { - return; - } - let metadata = log::Metadata::builder() - .level(level) - .target(module_path!()) - .build(); - if !log::logger().enabled(&metadata) { - return; - } - log_dispatched_menu_event_with(id, |record| log::Log::log(log::logger(), record)); -} - -static TRAY: OnceLock = OnceLock::new(); - -/// Get the global TrayIcon instance. -pub fn get_tray() -> Option<&'static TrayIcon> { - TRAY.get() -} - -/// Single dispatcher for every native menu click (issue #804). -/// -/// The tray builder's `on_menu_event` is a *global* listener — Tauri calls it -/// for window-menu events too — so this one function owns every tray-built id -/// and delegates anything else (the app-menu-only ids) to -/// [`crate::menu::handle_app_menu_event`]. It is registered exactly once (on -/// the tray builder in [`setup_tray`]): one click then logs and emits exactly -/// once, and tray-only ids never reach the app-menu handler's unknown-event log. -/// The second `window.on_menu_event` registration lib.rs used to carry -/// double-fired every shared id and is gone. -pub fn handle_menu_event(app: &AppHandle, id: &str) { - log_dispatched_menu_event(id); - match id { - ID_SHOW_HIDE => { - if let Some(window) = app.get_webview_window("main") { - if window.is_visible().unwrap_or(false) { - let _ = window.hide(); - // Issue #886: the dedup key reads the visibility mirror, - // so the tray's own window changes must report it. - note_window_visibility(false); - } else { - let _ = window.show(); - note_window_visibility(true); - // Issue #483: a minimized window stays minimized - // after show() -- unminimize first (mirrors the - // single-instance raise in lib.rs and show_window). - let _ = window.unminimize(); - let _ = window.set_focus(); - } - } else { - // Residual of #826: this arm is the tray's second window-raise - // path, and it must not stay silent about the same condition - // `commands::window::show_window` warns about. - log::warn!("[TRAY] show/hide: main window not found"); - } - // Issue #587: the repaint performs blocking Spotify HTTP - // (devices/queue fetches, 10 s timeout each) whenever the - // 60 s throttle has lapsed, so it must never run on the - // menu-event thread — offload it like the #386 player arms. - refresh_tray_from_state(app); - } - ID_PAUSE_SYNC | ID_RESUME_SYNC => { - // Issue #588: this arm only *asks* the frontend to toggle - // (the frontend owns the start/stop call), so the running - // flag settles asynchronously. Watch it from a worker and - // repaint from backend truth, instead of relying on the - // Dashboard route being mounted to mirror the change back. - let before = app - .state::>() - .polling - .is_syncing(); - let _ = app.emit("toggle-pause", ()); - let app_handle = app.clone(); - std::thread::spawn(move || { - if !await_sync_toggle(&app_handle, before) { - log::debug!( - "[TRAY] pause/resume: sync flag unchanged after {:?}; repainting anyway", - TOGGLE_SETTLE_TIMEOUT - ); - } - repaint_tray_from_state(&app_handle, "pause/resume"); - }); - } - ID_QUIT => { - // Issue #383: Quit must terminate the process even with no - // frontend listener — the old hide-only arm wedged the app - // in the tray with no way out. Route through the shared - // graceful shutdown (emits app-shutdown, then exits - // unconditionally after SHUTDOWN_GRACE). - crate::menu::request_graceful_shutdown(app); - } - // Shared with the app menu (issue #804): the tray builder's listener is - // global, so this arm owns every `settings` click from either surface. - ID_SETTINGS => { - let _ = app.emit("navigate", "settings"); - if let Some(window) = app.get_webview_window("main") { - let _ = window.show(); - note_window_visibility(true); - // Issue #483: mirror the unminimize in the Show arm. - let _ = window.unminimize(); - let _ = window.set_focus(); - } - // Issue #592: showing the window here changes the Show/Hide - // label, so the tray must be repainted from backend state. - refresh_tray_from_state(app); - } - ID_OPEN_LOGS => { - let _ = app.emit("open-logs-folder", ()); - } - // Spotify playback control (issue #3.0-P3). These dispatch - // directly against the Spotify API with the stored access - ID_PLAY_PAUSE => { - // Issue #386: the click-path blocking Spotify HTTP must not - // run on the menu-event thread — a slow network would wedge - // the tray menu. Offload everything (the currently-playing - // GET plus the play/pause action) to a worker thread. - // - // Issue #586: the worker resolves its token through the - // shared refresh-aware policy rather than snapshotting - // `state.tokens.spotify()`, so an expired access token is - // refreshed (and retried once) exactly like the command layer. - let app_handle = app.clone(); - std::thread::spawn(move || { - // Resolve the ACTUAL playing state from the API rather - // than the stored track: the polling loop's `is_playing` - // goes stale on a same-track pause (it's only re-stored - // on title/artist change), and an external device may - // have changed state since. One extra GET per click is - // fine — this is user-initiated. Unknown → resume. - // Unconditional GET (`None`): user-initiated one-off - // click with no stored validator. C11 signature. - let state = app_handle.state::>(); - let should_pause = match crate::commands::playback::player_with_refresh_typed( - state.inner(), - &app_handle, - "play/pause state", - |token| crate::spotify::get_currently_playing(token, None), - ) { - Ok(crate::spotify::CurrentlyPlaying::Modified { now: Some(now), .. }) => { - now.media.is_playing - } - Ok(_) => false, - Err(e) => { - log::warn!("[TRAY] play/pause: playback state read failed: {}", e); - let _ = app_handle.emit("playback-error", e.to_string()); - return; - } - }; - if should_pause { - run_player_action(&app_handle, "pause", Some(false), None, |t| { - crate::spotify::player_pause(t, None) - }); - } else { - run_player_action(&app_handle, "play", Some(true), None, |t| { - crate::spotify::player_play(t, None) - }); - } - }); - } - ID_PREVIOUS => { - // Issue #386: offload the blocking Spotify HTTP off the - // menu-event thread. Skipping doesn't change playing state. - let app_handle = app.clone(); - std::thread::spawn(move || { - run_player_action(&app_handle, "previous", None, None, |token| { - crate::spotify::player_previous(token, None) - }); - }); - } - ID_NEXT => { - // Issue #386: offload the blocking Spotify HTTP off the - // menu-event thread. Skipping doesn't change playing state. - let app_handle = app.clone(); - std::thread::spawn(move || { - run_player_action(&app_handle, "next", None, None, |token| { - crate::spotify::player_next(token, None) - }); - }); - } - ID_SHUFFLE => { - // Issue #582: the target state is the inverse of the last - // state we know about (the poll body's `shuffle_state`, or - // this item's own last successful toggle). Issue #386: the - // blocking Spotify HTTP must not run on the menu-event - // thread. - // - // The new state is handed to `run_player_action` instead of - // being stored here: that function records it in its success - // arm *before* the menu rebuild it triggers, so the rebuilt - // item shows the state the API just accepted (storing after - // the call would repaint the old state and no later rebuild - // would correct it). On a 403 from a non-Premium account - // nothing is recorded and the item keeps showing the truth. - let app_handle = app.clone(); - std::thread::spawn(move || { - let target = shuffle_toggle_target(LAST_SHUFFLE_STATE.load(Ordering::Acquire)); - let repeat = last_repeat_state(); - run_player_action( - &app_handle, - "shuffle", - None, - Some((target, repeat)), - |token| crate::spotify::player_set_shuffle(token, target, None), - ); - }); - } - ID_REPEAT => { - // Issue #582: repeat cycles off → context → track → off, - // matching Spotify's own player button, so "repeat one" is - // reachable from the tray. Same off-thread and - // record-on-success discipline as Shuffle above. - let app_handle = app.clone(); - std::thread::spawn(move || { - let target = last_repeat_state().next(); - let shuffle = LAST_SHUFFLE_STATE.load(Ordering::Acquire); - run_player_action( - &app_handle, - "repeat", - None, - Some((shuffle, target)), - |token| crate::spotify::player_set_repeat(token, target, None), - ); - }); - } - id if id.starts_with(SNOOZE_ITEM_PREFIX) => { - // S9 (issue #677): a snooze click writes `config.json` (atomic - // write + fsync), so it must run off the menu-event thread like - // every other arm here — a slow disk would otherwise wedge the - // native menu. The repaint follows the write, so it renders the - // new countdown, the "Resume sync now" entry and the - // snooze-forced cache-only fetch mode in one pass. - // The id is copied out of the borrowed `event` before the - // worker takes ownership (it is used in the unknown-id warn). - let raw = id.to_string(); - let selection = parse_snooze_menu_id(&raw); - let app_handle = app.clone(); - std::thread::spawn(move || { - match selection { - Some(SnoozeMenuSelection::Preset(preset)) => { - let now_utc = chrono::Utc::now(); - // Issue #867: the meeting-bound preset needs the - // calendar cache at click time. We capture it here - // (no fetch) — the entry is only built while a - // meeting is active, so the cache is fresh - // enough. - let next_meeting_end = - if preset == crate::config::SnoozePreset::UntilNextMeetingEnds { - let state = app_handle.state::>(); - state.calendar.current_meeting_end(now_utc) - } else { - None - }; - let deadline = crate::config::snooze_preset_deadline( - preset, - now_utc, - chrono::Local::now(), - next_meeting_end, - ); - if let Err(e) = write_snooze(&app_handle, Some(deadline)) { - log::error!("[TRAY] snooze: could not store the deadline: {}", e); - } - } - Some(SnoozeMenuSelection::Resume) => { - if let Err(e) = write_snooze(&app_handle, None) { - log::error!("[TRAY] snooze: could not clear the deadline: {}", e); - } - } - None => { - log::warn!( - "[TRAY] snooze: unrecognized menu id '{}'", - menu_event_id_for_log(&raw) - ); - } - } - repaint_tray_from_state(&app_handle, "snooze"); - }); - } - id if id == ID_PROFILE_BASE || id.starts_with(PROFILE_ITEM_PREFIX) => { - // Issue #869: a profile click writes `config.json` (atomic - // write + fsync), so it must run off the menu-event thread - // like the snooze arm above — a slow disk would otherwise - // wedge the native menu. The id dispatch never has to - // disambiguate names because `clamp_presence_profiles` - // dedupes and rejects pipes at load/save, so the - // `{PROFILE_ITEM_PREFIX}|{name}` format is unambiguous. - // - // The id is copied out of the borrowed `event` before the - // worker takes ownership (it is used in the unknown-name - // warn and the profile-not-found path). - let raw = id.to_string(); - let app_handle = app.clone(); - std::thread::spawn(move || { - // Resolve the picked name from the stored config. - // Reading state.config here (off the menu-event thread) - // keeps the click handler off the config lock. - let state = app_handle.state::>(); - let target: Option = if raw == ID_PROFILE_BASE { - None - } else if let Some(stripped) = raw.strip_prefix(PROFILE_ITEM_PREFIX) { - // Skip the disabled empty placeholder. - if stripped == "empty" { - repaint_tray_from_state(&app_handle, "profile (empty placeholder)"); - return; - } - // Validate against the clamped list — a name that - // was deleted between the rebuild and the click - // (or a stale menu from a previous build) must - // fall back to base, not silently land on a - // phantom id. - let exists = - state.config.get().as_ref().is_some_and(|c| { - c.presence_profiles.iter().any(|p| p.name == stripped) - }); - if !exists { - log::warn!( - "[TRAY] profile: {:?} not found — falling back to base", - menu_event_id_for_log(&raw) - ); - None - } else { - Some(stripped.to_string()) - } - } else { - log::warn!( - "[TRAY] profile: unrecognized menu id '{}'", - menu_event_id_for_log(&raw) - ); - return; - }; - if let Err(e) = write_active_profile(&app_handle, target) { - log::error!("[TRAY] profile: could not persist the switch: {}", e); - } - repaint_tray_from_state(&app_handle, "profile"); - }); - } - id if id == ID_MANUAL_STATUS_CLEAR => { - // Issue #870: the tray's "Clear manual status" entry. Routes - // through the same `clear_manual_status_inner` helper the - // Dashboard composer uses, on a worker thread — the Graph - // POST + the persisted record reset must not run on the - // menu-event thread. - let app_handle = app.clone(); - std::thread::spawn(move || { - let state = app_handle.state::>(); - if let Err(e) = - crate::commands::status::clear_manual_status_inner(state.inner(), &app_handle) - { - log::error!("[TRAY] manual status clear: {}", e); - } - repaint_tray_from_state(&app_handle, "manual status clear"); - }); - } - id if id.starts_with(MANUAL_STATUS_ITEM_PREFIX) => { - // Issue #870: a "Recent statuses" pick. The trailing index - // resolves to one of the ring's slots; a stale index (the - // ring rotated since the menu was built) is logged and - // ignored. The pick carries the user's text verbatim, so the - // Dashboard composer and the tray share the same store. - let raw = id.to_string(); - let index: usize = raw - .strip_prefix(MANUAL_STATUS_ITEM_PREFIX) - .and_then(|s| s.parse().ok()) - .unwrap_or(99); - let recent = crate::commands::status::load_recent_statuses(); - let picked = recent.get(index).cloned(); - let app_handle = app.clone(); - std::thread::spawn(move || { - let Some(entry) = picked else { - log::warn!( - "[TRAY] manual status pick: stale index {index}; the recent ring rotated" - ); - repaint_tray_from_state(&app_handle, "manual status pick (stale)"); - return; - }; - let state = app_handle.state::>(); - let expiry = crate::commands::status::clamp_expiry_public(60); - if let Err(e) = crate::commands::status::set_manual_status_inner( - state.inner(), - &app_handle, - &entry.message, - expiry, - ) { - log::error!("[TRAY] manual status pick: {}", e); - } - repaint_tray_from_state(&app_handle, "manual status pick"); - }); - } - id if id.starts_with(VOLUME_ITEM_PREFIX) => { - // Issue #871: the tray's Volume submenu picked a percentage. - // The click handler offloads the Spotify HTTP and records the - // new volume so the Dashboard slider mirrors the new value - // on its next SyncStatus fetch. A stale id (an old build's - // menu) is logged and ignored. - let raw = id.to_string(); - let selection = parse_volume_menu_id(&raw); - let app_handle = app.clone(); - std::thread::spawn(move || { - let Some(VolumeMenuSelection::Percent(percent)) = selection else { - log::warn!( - "[TRAY] volume: unrecognized menu id '{}'", - menu_event_id_for_log(&raw) - ); - repaint_tray_from_state(&app_handle, "volume (stale)"); - return; - }; - run_player_action(&app_handle, "volume", None, None, |token| { - crate::spotify::player_set_volume(token, percent, None) - }); - }); - } - id if id.starts_with(SEEK_ITEM_PREFIX) => { - // Issue #871: the tray's Seek submenu picked a delta. The - // click handler reads the stored progress + duration from - // the AppState, computes the new position, and dispatches - // through `commands/playback::seek` so the same token - // refresh + retry-once policy applies. A stale id (an old - // build's menu) is logged and ignored. - let raw = id.to_string(); - let selection = parse_seek_menu_id(&raw); - let app_handle = app.clone(); - std::thread::spawn(move || { - let Some(SeekMenuSelection::Delta(delta)) = selection else { - log::warn!( - "[TRAY] seek: unrecognized menu id '{}'", - menu_event_id_for_log(&raw) - ); - repaint_tray_from_state(&app_handle, "seek (stale)"); - return; - }; - let state = app_handle.state::>(); - let current = state - .polling - .current_track() - .as_ref() - .map(|t| (t.progress_ms, t.duration_ms)); - let Some((progress, duration)) = current else { - log::warn!("[TRAY] seek: no current track, cannot seek"); - repaint_tray_from_state(&app_handle, "seek (no track)"); - return; - }; - // Spotify reports `progress_ms` as `Option` (issue #3.0-P3 - // — the documented "Can be `null`"); the duration is - // always present. A missing `progress` defaults to 0 so - // the user still gets a forward-30s jump from the - // beginning of the track. - let progress = progress.unwrap_or(0); - let new_position = if delta >= 0 { - progress.saturating_add(delta as u64).min(duration) - } else { - progress.saturating_sub((-delta) as u64) - }; - run_player_action(&app_handle, "seek", None, None, |token| { - crate::spotify::player_seek(token, new_position as i64, None) - }); - }); - } - id if id.starts_with(DEVICE_ITEM_PREFIX) => { - // Device submenu item: `{ID_DEVICES}|{stable device id}` - // resolved by id (issue #388), with a live re-fetch fallback - // when the cached list went stale. Issue #386: the whole - // resolution + transfer runs on a worker thread so no HTTP - // touches the menu-event thread. - let raw = id - .strip_prefix(DEVICE_ITEM_PREFIX) - .unwrap_or("") - .to_string(); - let selected = parse_device_menu_id(&raw); - let app_handle = app.clone(); - std::thread::spawn(move || { - let device_id = resolve_device_id(&app_handle, &selected); - match device_id { - Some(device_id) => { - // Transfer starts playback on the target device. - run_player_action(&app_handle, "transfer", Some(true), None, |token| { - crate::spotify::player_transfer(token, &device_id, true) - }); - } - None => { - log::warn!( - "[TRAY] transfer: unknown or id-less device selected (id={})", - selected_for_log(&selected) - ); - } - } - }); - } - _ => crate::menu::handle_app_menu_event(app, id), - } -} - -pub fn setup_tray(app: &tauri::App) -> Result<(), String> { - // 4.7.0 (issue #674): the tray renders in the locale stored in the config. - // Installed before the first menu build so the initial menu (and the - // immediately following `update_tray_menu`) already carry the right - // labels; an unknown/absent value installs English. - let state = app.state::>(); - crate::i18n::install_from_app_state(state.inner()); - // 4.7.0 (S9 / issue #677): the startup cleanup for a snooze deadline that - // expired while the app was closed. Runs before the first menu build so the - // tray never renders a state the config no longer holds, and only writes - // when there is actually something to clear. - clear_expired_snooze_at_startup(app.handle()); - // Issue #768: the tray is built WITHOUT a menu and `update_tray_menu` below - // sets the real one. The transient initial menu this used to build was a - // second, already-diverged layout (no status row, no now-playing row, - // hardcoded Pause/Show labels) that the immediate rebuild replaced - // microseconds later — and that a FAILED rebuild left on screen. An empty - // tray can only be empty; it is reachable for the first moments of - // startup, while the window is not yet interactive. - - let builder = TrayIconBuilder::new() - .tooltip("PresenceJam") - // Issue #911: macOS wants a monochrome TEMPLATE image (marked as one - // right after the build below); Windows and Linux keep the - // application icon. - .icon(tray_icon(app)?) - // Issue #971: Tauri documents this flag as unsupported on Linux, where - // a left click opens the AppIndicator menu unconditionally. It only - // ever changes behaviour on Windows and macOS. - .show_menu_on_left_click(false) - .on_menu_event(|app, event| handle_menu_event(app, event.id().as_ref())) - // Issue #971: Linux delivers no tray click events at all (Tauri lists - // `TrayIconEvent::Click` as unsupported there), so the `tray-click` - // emit below — and the frontend listener for it — are inert on that - // platform. Nothing may depend on that event: the window is raised from - // the menu's Show/Hide item, which every platform has. - .on_tray_icon_event(|tray, event| { - if let TrayIconEvent::Click { - button: MouseButton::Left, - button_state: MouseButtonState::Up, - .. - } = event - { - let app = tray.app_handle(); - let _ = app.emit("tray-click", ()); - } - }); - // Issue #927: the tray build is the one call here that panics instead of - // returning `Err`. On Linux it dlopens libayatana-appindicator3.so.1 / - // libappindicator3.so.1 through `libappindicator-sys`, whose `Lazy` - // panics when neither soname resolves — and this runs inline on the setup - // thread, so the panic would unwind across the event-loop callback instead of - // becoming the error `lib.rs` already handles by running without a tray. - // The panic is raised on this thread, above the FFI boundary, so it is - // catchable. - let tray = guard_tray_panic( - "tray icon", - std::panic::AssertUnwindSafe(|| builder.build(app).map_err(|e| e.to_string())), - )?; - - // Issue #911: mark the menu-bar icon as a template, so macOS draws it from - // its alpha channel and tints it — black stays black on a light menu bar and - // inverts on a dark one, and it dims with the bar for a modal. The call is a - // no-op off macOS (Tauri only implements it there), so it stays gated. - #[cfg(target_os = "macos")] - if let Err(e) = tray.set_icon_as_template(true) { - log::warn!( - "[TRAY] setup_tray: failed to mark the menu-bar icon as a template: {}", - e - ); - } - - // Store the TrayIcon globally (idempotent) - if TRAY.get().is_some() { - log::warn!("[TRAY] setup_tray: already initialized, skipping"); - return Ok(()); - } - TRAY.set(tray) - .map_err(|_| "Tray already initialized".to_string())?; - - // Issue #689 (D6): the polling loop emits `playback-state-changed` when - // a track's playing state changes without the track itself changing. - // The Play/Pause mark and the status line read `LAST_PLAYING_STATE`, - // which a rebuild only re-seeds on a new track key — so a same-track - // pause would leave the mark claiming "playing" until the next track. - // Consuming the event here keeps the tray's belief truthful; the - // poller's own re-store then drives the rebuild that paints it. - app.listen("playback-state-changed", |event| { - consume_playback_state_changed(event.payload()); - }); - - // Immediately set the real menu to reflect actual state (Bug 11 fix). - // Without this the tray would stay menu-less until the first poll, and a - // track cached from the previous session would not be shown. - // Issue #768: there is no throwaway menu underneath this any more, so a - // failure here cannot leave a "Pause Sync"-labelled stale layout behind. - // The dedup snapshot is only committed by a successful rebuild, so the - // next poll retries this paint. - // Issue #882: this paint is CACHE-ONLY. It runs on the main thread inside - // Tauri's `setup()`, before the event loop starts and while the dedup - // snapshot is still empty — so a normal rebuild would issue the devices - // and queue GETs right here (10 s timeout each) with no window on screen - // to explain the wait, and could never take the "nothing changed" early - // return. - let state = app.state::>(); - let is_syncing = state.polling.is_syncing(); - let current_track = state.polling.current_track().clone(); - if let Err(e) = update_tray_menu_startup(app.handle(), is_syncing, current_track) { - log::warn!( - "[TRAY] setup_tray: failed to update initial tray menu: {}", - e - ); - } - // Issue #882: the real devices/queue fetch belongs to the worker refresh, - // off the setup thread, so the startup paint costs no network at all and - // the submenus still converge to the full content a moment later. - refresh_tray_from_state(app.handle()); - - log::info!("[TRAY] setup_tray: system tray initialized successfully"); - Ok(()) -} - -/// Snapshot of the last tray-menu state, used for the dedup guard in -/// `update_tray_menu` (issue #71). The polling thread calls -/// `update_tray_menu` on every successful poll; the menu only needs to -/// change when `is_syncing`, window visibility (the Show/Hide label), the -/// current track's title/is_playing, or one of the two playback modes -/// changes. -/// -/// The two playback modes are part of the key (issue #691): their atoms -/// drive the Shuffle/Repeat check marks, so an external mode change has to -/// force a rebuild — otherwise the marks stayed on the previous mode. The -/// click target is unaffected: `shuffle_toggle_target` reads the live atom. -/// -/// The snooze is part of the key too (4.7.0, S9 / issue #677): it drives the -/// status line, the presence of the "Resume sync now" entry AND the fetch mode -/// of the Devices/Up Next submenus, so a snooze that starts, ends or ticks over -/// to the next minute must repaint rather than early-return. The tick is why -/// the key carries [`snooze_dedup_key`]'s minute bucket and not just the -/// deadline — a key of the deadline alone would freeze the countdown at the -/// minute it was set. -#[derive(Clone, PartialEq, Eq)] -struct TrayStateSnapshot { - is_syncing: bool, - is_window_visible: bool, - /// `artist|title|is_playing` — is_playing is in the key so a same-track - /// pause repaints the Play/Pause mark (issue #229). - track_key: Option, - shuffle: bool, - repeat: RepeatState, - /// Throttle bucket of the devices cache when this key was built (issue - /// #805): the number of full [`TRAY_SPOTIFY_FETCH_THROTTLE`] windows since - /// it was filled, `0` while it is empty. - /// - /// The key carries the bucket and not the timestamp because the bucket is - /// what the fetchers act on: a session where nothing else moves now - /// repaints — and therefore re-fetches — once per window, while a rebuild - /// inside the window still dedupes to a no-op. Without it the early return - /// below skipped the fetches themselves, and the Devices/Up Next submenus - /// kept whatever the last rebuild happened to render, indefinitely. - devices_bucket: u64, - /// Same, for the Up Next queue cache (issue #805). - queue_bucket: u64, - snooze_key: Option, - /// Issue #869: the active presence profile id, fed into the dedup key - /// so a profile switch (tray, CLI, Settings card) repaints the - /// "Active profile" submenu's check mark. The id is `None` when the - /// base configuration is in use — the documented pre-5.0 default and - /// the post-switch state of `--profile base`. - active_profile_key: Option, -} - -/// True when `next` differs from the last committed snapshot, i.e. when the -/// tray menu must be rebuilt. Pure, so the dedup contract is unit-testable -/// without a Tauri runtime. -fn tray_state_changed(prev: Option<&TrayStateSnapshot>, next: &TrayStateSnapshot) -> bool { - prev != Some(next) -} - -/// Builds the dedup key from the same inputs the rebuild renders from: the -/// caller's sync flag and precomputed window visibility, the track's -/// artist/title/is_playing, the two playback-mode atoms the polling loop feeds -/// (`note_playback_modes`), the snooze the rebuild will render (4.7.0, S9) and -/// the throttle bucket of both caches (issue #805). -/// Single construction site so the key can never be built from a subset of what -/// the menu shows (issue #691). -fn tray_snapshot_for( - is_syncing: bool, - is_window_visible: bool, - current_track: Option<&crate::spotify::TrackInfo>, - snooze_key: Option, - // The two cache throttle buckets (issue #805). Read by the caller rather - // than here so the key stays a pure function of its inputs. - devices_bucket: u64, - queue_bucket: u64, - // Issue #869: the active profile id, fed in by the caller for the same - // dedup reason as the snooze — a switch changes the check mark, so the - // key has to include the id or the rebuild early-returns. - active_profile_key: Option, -) -> TrayStateSnapshot { - TrayStateSnapshot { - is_syncing, - is_window_visible, - track_key: current_track.map(|t| format!("{}|{}|{}", t.artist, t.title, t.is_playing)), - shuffle: LAST_SHUFFLE_STATE.load(Ordering::Acquire), - repeat: last_repeat_state(), - snooze_key, - devices_bucket, - queue_bucket, - active_profile_key, - } -} - -/// The throttle bucket a cache slot is in (issue #805): how many full throttle -/// windows have elapsed since it was filled, `0` while it is empty. -/// -/// Pure in its timestamp, so "an unchanged rebuild inside the window is still -/// a no-op, one window later it repaints" is asserted without sleeping. -fn throttle_bucket(fetched_at: Option, throttle: Duration) -> u64 { - match fetched_at { - None => 0, - Some(at) => at.elapsed().as_secs() / throttle.as_secs().max(1), - } -} - -/// The throttle buckets of both caches (issue #805), read under short locks — -/// no HTTP, and no lock held past the read. -fn cache_buckets() -> (u64, u64) { - let devices_at = DEVICES_CACHE.lock().as_ref().map(|(at, _)| *at); - let queue_at = QUEUE_CACHE.lock().as_ref().map(|(at, _)| *at); - ( - throttle_bucket(devices_at, TRAY_SPOTIFY_FETCH_THROTTLE), - throttle_bucket(queue_at, TRAY_SPOTIFY_FETCH_THROTTLE), - ) -} - -/// The active snooze as one rebuild renders it (4.7.0, S9 / issue #677). -#[derive(Debug, Clone, Copy)] -struct TraySnooze { - status: crate::config::SnoozeStatus, - /// Whole minutes the countdown shows (rounded up, never below 1). - minutes_left: i64, -} - -/// The snooze half of the dedup key: the deadline plus the minute bucket the -/// countdown is on, so the rebuilt status line cannot be deduped away while the -/// sleep runs (4.7.0, S9 / issue #677). -fn snooze_dedup_key(status: &crate::config::SnoozeStatus) -> String { - format!( - "{}|{}", - crate::config::snooze_store_form(status.deadline), - crate::config::snooze_minutes_left(status.remaining_seconds) - ) -} - -/// Resolves the snooze a rebuild must render from the mounted config -/// (4.7.0, S9 / issue #677). Absent config, absent field and an already-passed -/// deadline all mean "not snoozed", which is what makes the tray agree with the -/// poller without a second source of truth. -fn resolve_snooze(config: Option<&std::sync::Arc>) -> Option { - let status = crate::config::snooze_status(config?, chrono::Utc::now())?; - Some(TraySnooze { - minutes_left: crate::config::snooze_minutes_left(status.remaining_seconds), - status, - }) -} - -static LAST_TRAY_STATE: std::sync::OnceLock>> = - std::sync::OnceLock::new(); - -fn last_tray_state() -> &'static parking_lot::Mutex> { - LAST_TRAY_STATE.get_or_init(|| parking_lot::Mutex::new(None)) -} - -/// Module-level mutex that serialises the two writers to the tray -/// (polling thread and frontend command). Issue #71. -static TRAY_WRITE_LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); - -fn tray_write_lock() -> &'static parking_lot::Mutex<()> { - TRAY_WRITE_LOCK.get_or_init(|| parking_lot::Mutex::new(())) -} - -/// Last known main-window visibility, which is what the dedup key carries -/// (issue #886). -/// -/// The rebuild used to ask the window directly, and `is_visible()` blocks the -/// calling thread on an event-loop reply — a hop paid by every poll, including -/// the ones the dedup key discards a few lines later. [`note_window_visibility`] -/// keeps the mirror honest, and the rebuild re-reads the real window whenever it -/// paints anyway (see the self-heal in `rebuild_tray_menu`). -static WINDOW_VISIBLE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); - -/// Records the main window's visibility (issue #886). Every path that shows or -/// hides the window should report it — the tray's own Show/Hide and Open -/// Settings arms do, and the window commands / close-to-tray paths are expected -/// to; the poll loop then never has to ask the event loop for it. -pub fn note_window_visibility(visible: bool) { - WINDOW_VISIBLE.store(visible, Ordering::Release); -} - -/// The visibility the dedup key is built from (issue #886) — the mirror, so the -/// discarded path performs no event-loop hop. -fn window_visible() -> bool { - WINDOW_VISIBLE.load(Ordering::Acquire) -} - -/// The real main-window visibility, queried once per paint (issue #886). Only -/// the paths that are already off the hot path may call this: the startup paint -/// (on the main thread) and the rebuild that passed the dedup guard. -fn live_window_visible(app: &AppHandle) -> bool { - app.get_webview_window("main") - .and_then(|w| w.is_visible().ok()) - .unwrap_or(false) -} - -/// The snooze a rebuild must render, read out of the mounted config under a -/// scoped guard (issue #886). -/// -/// Only `snooze_until` (plus the clock) is needed, so that one field is copied -/// out instead of cloning the whole `AppConfig` — which allocates its status -/// rules on every poll, including the ones the dedup key discards. The guard -/// lives only for this call, so none survives into the blocking Spotify HTTP -/// below. -fn snooze_from_app_state(state: &crate::AppState) -> Option { - let config = state.config.get(); - resolve_snooze(config.as_ref()) -} - -/// Which entry point a rebuild came through (issue #882). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum TrayPaint { - /// An ordinary rebuild: the dedup key decides whether the menu is rebuilt, - /// and a successful rebuild commits the snapshot. - Deduped, - /// The startup paint. Built on the main thread before the event loop runs: - /// it renders the throttled caches without touching the network, and it - /// does not commit the snapshot (see `update_tray_menu_startup`). - Startup, -} - -/// The fetch mode a rebuild runs under (issues #677, #882). -/// -/// Pure, so "the startup paint performs no request" and "a snooze performs no -/// request" are asserted directly rather than through the rebuild's source. -fn paint_fetch_mode(paint: TrayPaint, snoozed: bool, action_refresh_due: bool) -> TrayFetch { - match paint { - // Issue #882: no network before the event loop exists. - TrayPaint::Startup => TrayFetch::CacheOnly, - TrayPaint::Deduped => { - let base = tray_fetch_mode(snoozed); - // A snooze outranks an action: "no Spotify request while snoozed" is - // that feature's acceptance criterion (issue #677). - if base == TrayFetch::CacheOnly { - TrayFetch::CacheOnly - } else if action_refresh_due { - // Issue #883: the action asks for fresh Devices/Up Next lists. - TrayFetch::RefreshNow - } else { - base - } - } - } -} - -/// Throttle window for the tray's Spotify devices/queue fetches -/// (issue #3.0-P3). The polling loop calls `update_tray_menu` on every -/// successful poll; without a cap the Devices/Up Next submenus would -/// hammer the Spotify API on every iteration. Fetched data is cached -/// here and re-used until the window lapses. -const TRAY_SPOTIFY_FETCH_THROTTLE: Duration = Duration::from_secs(60); - -/// The shuffle state a click switches to: the inverse of the last observed -/// one. Pure so the toggle contract is unit-testable. -fn shuffle_toggle_target(current: bool) -> bool { - !current -} - -/// Cache slot for the throttled devices fetch: `(fetched_at, devices)`. -/// Fast path for the device submenu's click dispatch, which resolves the -/// stable `{ID_DEVICES}|{device id}` against this list and falls back to a -/// live re-fetch when stale (issue #388). -type DeviceCacheSlot = Option<(Instant, Vec)>; - -static DEVICES_CACHE: std::sync::LazyLock> = - std::sync::LazyLock::new(|| parking_lot::Mutex::new(None)); - -static QUEUE_CACHE: std::sync::LazyLock< - parking_lot::Mutex>, -> = std::sync::LazyLock::new(|| parking_lot::Mutex::new(None)); - -/// Last known Spotify playing state, driving the Play/Pause check mark and -/// the tray status line (and the toggle's fallback dispatch). Re-seeded -/// from the polling loop's stored track on a genuine track change, from the -/// poller's `playback-state-changed` event when the playing state changes -/// for the same track (issue #689), and by the tray's own successful -/// play/pause/transfer actions. Issue #3.0-P3. -static LAST_PLAYING_STATE: std::sync::LazyLock = - std::sync::LazyLock::new(|| std::sync::atomic::AtomicBool::new(false)); - -/// Records the playing state the Play/Pause mark and the status line render. -/// Every path that learns the truth writes it here, so the mark never infers -/// playback from a candidate that may already be stale. Issue #3.0-P3. -fn note_playing_state(is_playing: bool) { - LAST_PLAYING_STATE.store(is_playing, Ordering::Release); -} - -/// Consumes a `playback-state-changed` payload (issue #689, tray half) via -/// the shared [`crate::events::PlaybackStateChanged`] contract, so a Rust-side -/// field rename fails here too — not just at the emit site. Only `is_playing` -/// is consumed: the event's `track_key` is part of the shared contract, but -/// the tray's dedup key already carries the track identity. The poll body's -/// playing state is authoritative for a same-track change, and the poller has -/// already re-stored it, so recording it here keeps the Play/Pause mark and -/// the status line truthful without waiting for the next track. An unparsable -/// payload keeps the last known state rather than inventing one. -fn consume_playback_state_changed(payload: &str) { - match serde_json::from_str::(payload) { - Ok(state) => note_playing_state(state.is_playing), - Err(e) => log::warn!( - "[TRAY] playback-state-changed: unparsable payload, keeping the last playing state: {}", - e - ), - } -} - -/// Last known shuffle state, driving the Shuffle item's check mark -/// (issue #582). Written by the polling loop from the poll body -/// (`note_playback_modes`) and optimistically by the tray's own successful -/// toggle, so a same-track toggle does not wait for the next poll. It is a -/// module-level atomic rather than a field on the app's frozen `TrackInfo` -/// because that type is the ts-rs-exported IPC shape shared with the -/// Dashboard and built by exhaustive literals outside this module. -static LAST_SHUFFLE_STATE: std::sync::LazyLock = - std::sync::LazyLock::new(|| std::sync::atomic::AtomicBool::new(false)); - -/// Last known repeat mode, encoded as [`RepeatState`]'s `u8` discriminant. -/// Same lifecycle as `LAST_SHUFFLE_STATE`. -static LAST_REPEAT_STATE: std::sync::LazyLock = - std::sync::LazyLock::new(|| std::sync::atomic::AtomicU8::new(RepeatState::Off as u8)); - -/// Records the playback modes a poll body reported. Called by the polling -/// loop for every observed item (playing or paused) — the poll response is -/// the source of truth for both toggles, so no extra Spotify request is -/// needed to render them. See issue #582. -pub(crate) fn note_playback_modes(shuffle: bool, repeat: RepeatState) { - LAST_SHUFFLE_STATE.store(shuffle, Ordering::Release); - LAST_REPEAT_STATE.store(repeat as u8, Ordering::Release); -} - -/// The tray's view of the current repeat mode. An out-of-range byte (only -/// possible if the encoder above is changed without this decoder) degrades -/// to `Off` rather than panicking in a menu build. -fn last_repeat_state() -> RepeatState { - match LAST_REPEAT_STATE.load(Ordering::Acquire) { - 1 => RepeatState::Context, - 2 => RepeatState::Track, - _ => RepeatState::Off, - } -} - -/// Menu label for the Repeat item: the mode is spelled out because the -/// documented state space has three values and a check mark only carries -/// on/off. Pure, and localized from the table it is handed (issue #674), so -/// the label contract is unit-testable in every locale. -fn repeat_menu_label(strings: &Strings, state: RepeatState) -> &'static str { - match state { - RepeatState::Off => strings.repeat_off, - RepeatState::Context => strings.repeat_context, - RepeatState::Track => strings.repeat_track, - } -} - -/// Coalescing guard for the delayed one-shot refresh kicked after a -/// successful tray player action: rapid next/previous clicks must not pile -/// up unbounded 2 s-sleep threads each firing blocking Spotify+Teams HTTP. -/// First claimant spawns; losers skip (their track change is covered by the -/// in-flight refresh's unconditional GET plus the polling loop). -static DELAYED_REFRESH_IN_FLIGHT: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); - -/// Shortest gap between the post-action re-fetches of the Devices/Up Next lists -/// (issue #883). A player action wants those submenus to mirror what just -/// happened, but a burst of clicks must share one devices+queue pair: five -/// seconds is long enough to coalesce a burst, short enough that the menu still -/// matches the click the user just made. -const TRAY_POST_ACTION_FETCH_MIN: Duration = Duration::from_secs(5); - -/// The most recent tray player action that wants the Devices/Up Next lists -/// refreshed (issue #883). `force_tray_refresh` records the action here instead -/// of emptying both caches, which bypassed the fetch throttle for every click. -static LAST_TRAY_ACTION: std::sync::LazyLock>> = - std::sync::LazyLock::new(|| parking_lot::Mutex::new(None)); - -/// When the most recent post-action re-fetch was issued (issue #883). Recorded -/// before the requests run, so a click landing while they are in flight reuses -/// them instead of paying for a pair of its own. -static LAST_ACTION_FETCH: std::sync::LazyLock>> = - std::sync::LazyLock::new(|| parking_lot::Mutex::new(None)); - -/// Whether a rebuild must re-fetch the Devices/Up Next lists on behalf of a -/// player action (issue #883). Pure in its instants, so the coalescing rule — -/// ten rapid clicks, one pair of requests — is unit-testable without waiting. -fn action_fetch_due( - last_action: Option, - last_action_fetch: Option, - now: Instant, - min_interval: Duration, -) -> bool { - match (last_action, last_action_fetch) { - // No action has asked for anything. - (None, _) => false, - // An action nothing has fetched for yet is due immediately. - (Some(_), None) => true, - // Only an action newer than the last post-action fetch — and far enough - // after it — is worth two more requests; a burst inside `min_interval` - // shares the pair already issued. - (Some(action), Some(fetched)) => { - action > fetched && now.duration_since(fetched) >= min_interval - } - } -} - -/// Returns cached devices when the throttle window hasn't elapsed, else -/// fetches fresh ones. On a fetch failure the stale cache is returned so -/// the submenu doesn't flicker to "(no devices)" on a transient error. -/// -/// The cache mutex is held only to clone the snapshot and to store the -/// fresh result — the HTTP fetch runs outside any lock so a cold/stale -/// cache never blocks tray interactions. If two threads race on a stale -/// cache both will fetch; the last writer wins. This benign double-fetch -/// wastes one request but cannot corrupt state. See issue #217. -/// `min_interval` is this fetch's throttle (issue #883): the full window for an -/// ordinary rebuild, `Duration::ZERO` for the one a player action asks for. -fn cached_devices(access_token: &str, min_interval: Duration) -> Vec { - // Snapshot under short lock, then drop before deciding staleness. - let snapshot = { - let cache = DEVICES_CACHE.lock(); - cache.clone() - }; - let needs_fetch = match &snapshot { - Some((fetched_at, _)) => fetched_at.elapsed() >= min_interval, - None => true, - }; - if !needs_fetch { - return snapshot.unwrap().1; - } - // Throttled fetch OUTSIDE any lock — never hold DEVICES_CACHE across HTTP. - match crate::spotify::get_devices(access_token) { - Ok(devices) => { - // Re-acquire only to store the fresh result. - *DEVICES_CACHE.lock() = Some((Instant::now(), devices.clone())); - devices - } - Err(e) => { - log::warn!("[TRAY] cached_devices: failed to fetch devices: {}", e); - snapshot.map(|(_, devices)| devices).unwrap_or_default() - } - } -} - -/// Returns cached queue when the throttle window hasn't elapsed, else -/// fetches fresh. Falls back to the stale cache on failure. -/// -/// Same lock discipline as `cached_devices`: snapshot, drop, fetch outside -/// lock, re-acquire to store. Benign double-fetch on a race. See issue #217. -fn cached_queue(access_token: &str, min_interval: Duration) -> Option { - // Snapshot under short lock, then drop before deciding staleness. - let snapshot = { - let cache = QUEUE_CACHE.lock(); - cache.clone() - }; - let needs_fetch = match &snapshot { - Some((fetched_at, _)) => fetched_at.elapsed() >= min_interval, - None => true, - }; - if !needs_fetch { - return snapshot.map(|(_, queue)| queue); - } - // Throttled fetch OUTSIDE any lock — never hold QUEUE_CACHE across HTTP. - match crate::spotify::get_queue(access_token) { - Ok(queue) => { - // Re-acquire only to store. - *QUEUE_CACHE.lock() = Some((Instant::now(), queue.clone())); - Some(queue) - } - Err(e) => { - log::warn!("[TRAY] cached_queue: failed to fetch queue: {}", e); - snapshot.map(|(_, queue)| queue) - } - } -} - -/// Where a rebuild may take its Devices / Up Next lists from (4.7.0, S9 / -/// issue #677). -/// -/// A snooze means "the user asked for no activity", and that covers the app's -/// own background requests: the acceptance for the feature is that a snoozed -/// session produces no Spotify GET at all. `update_tray_menu` is called from -/// the polling driver and from the snooze click arm, so the fetch mode has to -/// be a property of the rebuild rather than of its caller — otherwise a -/// countdown repaint would silently re-fetch devices every 60 s (the throttle -/// window) and break that guarantee. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum TrayFetch { - /// Normal rebuild: reuse the throttled cache, fetching when it lapsed. - Refresh, - /// Issue #883: a player action just changed the playback/device state these - /// lists mirror, so this one rebuild bypasses the throttle. A burst of - /// clicks still shares one pair of requests — see [`action_fetch_due`]. - RefreshNow, - /// A snooze is active: render the last cached lists (or nothing) and issue - /// no request. The submenus go stale by design until the snooze ends. - CacheOnly, -} - -/// The fetch mode a rebuild must use (4.7.0, S9 / issue #677). -/// -/// Pure, so the "a snooze means no Spotify request" decision is asserted -/// directly instead of only through the rebuild's source text. -fn tray_fetch_mode(snoozed: bool) -> TrayFetch { - if snoozed { - TrayFetch::CacheOnly - } else { - TrayFetch::Refresh - } -} - -/// The Devices list for a rebuild, honouring [`TrayFetch`]. A missing access -/// token is an empty submenu either way — there is nothing to fetch with. -fn devices_for_menu( - access_token: Option<&str>, - fetch: TrayFetch, -) -> Vec { - match (access_token, fetch) { - (Some(token), TrayFetch::Refresh) => cached_devices(token, TRAY_SPOTIFY_FETCH_THROTTLE), - (Some(token), TrayFetch::RefreshNow) => cached_devices(token, Duration::ZERO), - (_, TrayFetch::CacheOnly) => DEVICES_CACHE - .lock() - .clone() - .map(|(_, devices)| devices) - .unwrap_or_default(), - (None, _) => Vec::new(), - } -} - -/// The Up Next snapshot for a rebuild, honouring [`TrayFetch`]. Same contract -/// as [`devices_for_menu`]. -fn queue_for_menu( - access_token: Option<&str>, - fetch: TrayFetch, -) -> Option { - match (access_token, fetch) { - (Some(token), TrayFetch::Refresh) => cached_queue(token, TRAY_SPOTIFY_FETCH_THROTTLE), - (Some(token), TrayFetch::RefreshNow) => cached_queue(token, Duration::ZERO), - (_, TrayFetch::CacheOnly) => QUEUE_CACHE.lock().clone().map(|(_, queue)| queue), - (None, _) => None, - } -} - -/// What the `{ID_DEVICES}|{…}` suffix resolved to. Device menu ids carry the -/// stable Spotify device id (issue #388); the `LegacyIndex` variant accepts -/// ids minted by an older menu build still on screen when the app updated. -#[derive(Debug, PartialEq, Eq)] -enum DeviceMenuSelection { - DeviceId(String), - LegacyIndex(usize), - Invalid, -} - -/// Parses the suffix of a device menu-item id. A numeric suffix from an old -/// menu build is kept as `LegacyIndex` for back-compat; anything else is a -/// stable Spotify device id (`DeviceId`), including the empty string and the -/// `none` placeholder, which both resolve to `Invalid` downstream. -fn parse_device_menu_id(suffix: &str) -> DeviceMenuSelection { - if suffix == "none" { - return DeviceMenuSelection::Invalid; - } - // Spotify device ids are opaque base62 strings; a pure-ASCII-digit - // suffix can only have come from the old `{index}` scheme. - if !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit()) { - if let Ok(i) = suffix.parse::() { - return DeviceMenuSelection::LegacyIndex(i); - } - } - if suffix.is_empty() { - return DeviceMenuSelection::Invalid; - } - DeviceMenuSelection::DeviceId(suffix.to_string()) -} - -/// Redacted label for the unknown-device warn log: ids are bearer-adjacent, -/// so log only the length, never the id itself. -fn selected_for_log(selected: &DeviceMenuSelection) -> String { - match selected { - DeviceMenuSelection::DeviceId(id) => format!("", id.len()), - DeviceMenuSelection::LegacyIndex(i) => format!("", i), - DeviceMenuSelection::Invalid => "".to_string(), - } -} - -/// Resolves a parsed device-menu selection to a live Spotify device id. -/// Cache first (no IO), then a live `get_devices` re-fetch when the cached -/// list went stale (issue #388: the old `devices.get(i)` raced the -/// 60 s-throttled cache against the live device list). MUST run off the -/// menu-event thread — the fallback performs blocking HTTP (issue #386) — -/// and resolves its token through the shared refresh-aware policy, so an -/// expired access token does not strand a transfer on "unknown device" -/// (issue #586). -fn resolve_device_id(app: &AppHandle, selected: &DeviceMenuSelection) -> Option { - match selected { - DeviceMenuSelection::DeviceId(id) => { - // Fast path: still in the cached list and transferable. - let cached = DEVICES_CACHE.lock().as_ref().and_then(|(_, devices)| { - devices - .iter() - .find(|d| d.id.as_deref() == Some(id.as_str())) - .and_then(|d| d.id.clone()) - }); - if cached.is_some() { - return cached; - } - // Slow path: live re-fetch; the device may have appeared after - // the submenu was built, or the cache may be stale. - let state = app.state::>(); - match crate::commands::playback::player_with_refresh_typed( - state.inner(), - app, - "transfer device list", - crate::spotify::get_devices, - ) { - Ok(devices) => { - *DEVICES_CACHE.lock() = Some((Instant::now(), devices.clone())); - devices - .into_iter() - .find(|d| d.id.as_deref() == Some(id.as_str())) - .and_then(|d| d.id) - } - Err(e) => { - log::warn!("[TRAY] transfer: live device re-fetch failed: {}", e); - None - } - } - } - DeviceMenuSelection::LegacyIndex(i) => DEVICES_CACHE - .lock() - .as_ref() - .and_then(|(_, devices)| devices.get(*i).cloned()) - .and_then(|device| device.id), - DeviceMenuSelection::Invalid => None, - } -} - -/// Builds the Devices submenu from an already-fetched slice. No HTTP is -/// performed here — the caller must have fetched outside any tray lock. -/// See issue #217. -fn build_devices_submenu_from_devices( - app: &AppHandle, - devices: &[crate::spotify::DeviceInfo], -) -> Result, String> { - let s = i18n::current(); - let submenu = Submenu::with_id(app, ID_DEVICES, s.devices, true).map_err(|e| e.to_string())?; - if devices.is_empty() { - let empty = MenuItemBuilder::with_id(format!("{}|none", ID_DEVICES), s.no_devices) - .enabled(false) - .build(app) - .map_err(|e| e.to_string())?; - submenu.append(&empty).map_err(|e| e.to_string())?; - } else { - for device in devices.iter() { - let label = if device.is_active { - format!("✓ {}", device.name) - } else { - device.name.clone() - }; - // The active device (and id-less devices) can't be transferred to. - let enabled = !device.is_active && device.id.is_some(); - // Issue #388: carry the stable Spotify device id (not the list - // index) so a click resolves even when the cached list raced a - // live device change. Id-less devices reuse the `none` id: they - // are disabled and parse back to `Invalid`, which the click - // handler rejects with a warn. - let suffix = device.id.as_deref().unwrap_or("none"); - let item = MenuItemBuilder::with_id(format!("{}|{}", ID_DEVICES, suffix), label) - .enabled(enabled) - .build(app) - .map_err(|e| e.to_string())?; - submenu.append(&item).map_err(|e| e.to_string())?; - } - } - Ok(submenu) -} - -/// Builds the Up Next submenu from an already-fetched queue snapshot. -/// No HTTP here — fetch must have happened outside the tray lock. See issue #217. -fn build_queue_submenu_from_queue( - app: &AppHandle, - queue: Option<&crate::spotify::QueueInfo>, -) -> Result, String> { - let s = i18n::current(); - let submenu = Submenu::with_id(app, ID_QUEUE, s.up_next, true).map_err(|e| e.to_string())?; - let up_next: Vec = queue - .map(|q| q.up_next.iter().take(3).cloned().collect()) - .unwrap_or_default(); - if up_next.is_empty() { - let empty = MenuItemBuilder::with_id(format!("{}|none", ID_QUEUE), s.queue_empty) - .enabled(false) - .build(app) - .map_err(|e| e.to_string())?; - submenu.append(&empty).map_err(|e| e.to_string())?; - } else { - for (index, track) in up_next.iter().enumerate() { - let item = MenuItemBuilder::with_id( - format!("{}|none|{}", ID_QUEUE, index), - format!("{} - {}", track.artist, track.title), - ) - .enabled(false) - .build(app) - .map_err(|e| e.to_string())?; - submenu.append(&item).map_err(|e| e.to_string())?; - } - } - Ok(submenu) -} - -/// Builds the snooze submenu (4.7.0, S9 / issue #677): the three presets, plus -/// a "Resume sync now" entry while a snooze is active. -/// -/// The three presets stay enabled during a snooze on purpose — picking another -/// one re-snoozes from now, which is how a user extends a 30-minute snooze to -/// "until tomorrow" without first resuming. Every label comes from the -/// installed i18n table, so the submenu localizes with the rest of the tray. -/// -/// No IO: the caller has already resolved the snooze (and, while one is active, -/// is required to be in [`TrayFetch::CacheOnly`] mode — this builder performs -/// no fetch of its own). -fn build_snooze_submenu( - app: &AppHandle, - s: &Strings, - snooze: Option<&TraySnooze>, -) -> Result, String> { - let thirty = MenuItemBuilder::with_id(ID_SNOOZE_30, s.snooze_30_minutes) - .build(app) - .map_err(|e| e.to_string())?; - let hour = MenuItemBuilder::with_id(ID_SNOOZE_1H, s.snooze_1_hour) - .build(app) - .map_err(|e| e.to_string())?; - let tomorrow = MenuItemBuilder::with_id(ID_SNOOZE_TOMORROW, s.snooze_until_tomorrow) - .build(app) - .map_err(|e| e.to_string())?; - let mut leading: Vec<&dyn tauri::menu::IsMenuItem> = - vec![&thirty, &hour, &tomorrow]; - // Issue #867: surface "Until this meeting ends" only while a busy meeting - // is in progress (calendar cache is non-empty AND has a busy event - // covering `now`). Outside of a meeting the entry is hidden so the - // user can't pick a deadline that's effectively the same as "30 - // minutes" with an opaque end time. - let meeting_active = { - let state = app.state::>(); - state.calendar.meeting_active(chrono::Utc::now()) - }; - // Issue #867: a single allocated `MenuItem` for the meeting entry, kept - // alive for the duration of `leading`'s borrow. Boxed so the conditional - // push does not need a separate `Option` shim and the references remain - // `Send`/`Sync`-safe to copy into the `items` slice below. - let meeting_entry: Option> = if meeting_active { - Some( - MenuItemBuilder::with_id(ID_SNOOZE_NEXT_MEETING, s.snooze_until_next_meeting_ends) - .build(app) - .map_err(|e| e.to_string())?, - ) - } else { - None - }; - if let Some(ref entry) = meeting_entry { - leading.push(entry); - } - let submenu = SubmenuBuilder::new(app, s.snooze_pause_menu) - .items(&leading) - .build() - .map_err(|e| e.to_string())?; - - if snooze.is_some() { - let separator = PredefinedMenuItem::separator(app).map_err(|e| e.to_string())?; - let resume = MenuItemBuilder::with_id(ID_SNOOZE_RESUME, s.snooze_resume_now) - .build(app) - .map_err(|e| e.to_string())?; - submenu.append(&separator).map_err(|e| e.to_string())?; - submenu.append(&resume).map_err(|e| e.to_string())?; - } - Ok(submenu) -} - -/// Issue #869: the "Active profile" submenu. The spec says the tray must -/// expose a profile picker beside the snooze items so the runtime overlay -/// path (`effective_config`) has a UI surface to flip from. The picker -/// has three states, matching the three resolve paths `effective_config` -/// implements: -/// -/// 1. `active_profile == None` → the "Base configuration" entry is the -/// only enabled one. Picking it is a no-op (already on base), and the -/// rest are unchecked picks that activate the matching profile. -/// 2. `active_profile == Some(name)` → the matching entry gets a check -/// mark. Picking it is also a no-op. -/// 3. The list is empty (the documented pre-5.0 default and a hand- -/// deleted config) → the submenu shows a single disabled placeholder. -/// -/// The id format `{PROFILE_ITEM_PREFIX}|{name}` lets the click handler -/// resolve the picked name without a parallel name→id map. `name` has -/// already been deduped and truncated by `clamp_presence_profiles`, so -/// the only remaining edge case is the single-pipe `name`, which the -/// clamp forbids via the `snooze_pause_menu` style regex — see -/// `clamp_profile_id`. Picking a profile that has been deleted between -/// the rebuild and the click is logged and ignored by the handler. -fn build_profile_submenu( - app: &AppHandle, - s: &Strings, - profiles: &[crate::config::PresenceProfile], - active: Option<&str>, -) -> Result, String> { - let submenu = SubmenuBuilder::new(app, s.profile_menu) - .build() - .map_err(|e| e.to_string())?; - - // The "Base configuration" entry is the only path that clears the - // active profile. Checked when `active == None`, disabled when there - // is no profile list to switch off of. - let base_checked = active.is_none(); - let base_enabled = !profiles.is_empty() || active.is_some(); - let base_item = CheckMenuItemBuilder::with_id(ID_PROFILE_BASE, s.profile_base) - .checked(base_checked) - .enabled(base_enabled) - .build(app) - .map_err(|e| e.to_string())?; - submenu.append(&base_item).map_err(|e| e.to_string())?; - - if profiles.is_empty() { - let empty = - MenuItemBuilder::with_id(format!("{PROFILE_ITEM_PREFIX}empty"), s.profile_empty) - .enabled(false) - .build(app) - .map_err(|e| e.to_string())?; - submenu.append(&empty).map_err(|e| e.to_string())?; - return Ok(submenu); - } - - let separator = PredefinedMenuItem::separator(app).map_err(|e| e.to_string())?; - submenu.append(&separator).map_err(|e| e.to_string())?; - - for profile in profiles { - let checked = active == Some(profile.name.as_str()); - let picker = CheckMenuItemBuilder::with_id( - format!("{PROFILE_ITEM_PREFIX}{}", profile.name), - profile.name.clone(), - ) - .checked(checked) - .build(app) - .map_err(|e| e.to_string())?; - submenu.append(&picker).map_err(|e| e.to_string())?; - } - Ok(submenu) -} - -/// Issue #870: the "Recent statuses" submenu. The spec caps the visible -/// entries at three; the Dashboard composer reads the broader -/// [`crate::commands::status::RECENT_STATUSES_CAPACITY`] ring. The labels -/// use the recent-status entry's exact text (filtered through the same -/// `MAX_RULE_STATUS_CHARS` bound as the composer), so a user picking -/// "Right back at 2" from the tray gets the same text the Dashboard would -/// have posted. -/// -/// The "Clear manual status" entry sits at the bottom, only while a -/// manual status is armed — picking it routes through the same helper the -/// Dashboard composer's Clear button uses. -fn build_manual_status_submenu( - app: &AppHandle, - s: &Strings, - recent: &[crate::commands::status::RecentManualStatus], - armed: Option<&crate::commands::status::ManualStatus>, -) -> Result, String> { - let mut items: Vec>> = Vec::new(); - let shown = recent.iter().take(3); - for (idx, entry) in shown.enumerate() { - let item = MenuItemBuilder::with_id( - format!("{MANUAL_STATUS_ITEM_PREFIX}{idx}"), - entry.message.clone(), - ) - .build(app) - .map_err(|e| e.to_string())?; - items.push(Box::new(item)); - } - let has_items = !items.is_empty(); - let submenu = SubmenuBuilder::new(app, s.manual_status_recent_menu) - .build() - .map_err(|e| e.to_string())?; - if !has_items { - let empty = MenuItemBuilder::with_id( - format!("{MANUAL_STATUS_ITEM_PREFIX}none"), - s.manual_status_recent_empty, - ) - .enabled(false) - .build(app) - .map_err(|e| e.to_string())?; - submenu.append(&empty).map_err(|e| e.to_string())?; - } else { - for item in items - .iter() - .map(|b| b.as_ref() as &dyn tauri::menu::IsMenuItem) - { - submenu.append(item).map_err(|e| e.to_string())?; - } - } - if armed.is_some() { - let separator = PredefinedMenuItem::separator(app).map_err(|e| e.to_string())?; - let clear = MenuItemBuilder::with_id(ID_MANUAL_STATUS_CLEAR, s.manual_status_clear) - .build(app) - .map_err(|e| e.to_string())?; - submenu.append(&separator).map_err(|e| e.to_string())?; - submenu.append(&clear).map_err(|e| e.to_string())?; - } - Ok(submenu) -} - -/// What a snooze menu id asked for (4.7.0, S9 / issue #677). Parsed rather -/// than switched on the raw id so the click arm stays a dispatch table and the -/// mapping is unit-testable without a menu. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SnoozeMenuSelection { - Preset(crate::config::SnoozePreset), - Resume, -} - -/// Issue #871: the tray's Volume submenu. Five discrete picks (0/25/50/75/100) -/// ride through `commands/playback::set_volume`, the same path the -/// Dashboard's slider uses. The whole submenu is disabled when the active -/// device refuses the volume capability, so a stale-capabilities click -/// never reaches the click handler. -fn build_volume_submenu( - app: &AppHandle, - s: &Strings, - enabled: bool, -) -> Result, String> { - let submenu = SubmenuBuilder::new(app, s.volume_menu) - .enabled(enabled) - .build() - .map_err(|e| e.to_string())?; - for percent in [0u32, 25, 50, 75, 100] { - let label = s - .volume_percent_label - .replace("{percent}", &percent.to_string()); - let item = MenuItemBuilder::with_id(format!("{VOLUME_ITEM_PREFIX}{percent}"), label) - .build(app) - .map_err(|e| e.to_string())?; - submenu.append(&item).map_err(|e| e.to_string())?; - } - Ok(submenu) -} - -/// Issue #871: the tray's Seek submenu (back / forward 30 s). Both -/// directions ride through `commands/playback::seek` — Spotify clamps the -/// position to the current track's duration on its side, so a "forward -/// 30 s" pick near the end of the track is a no-op rather than an error. -fn build_seek_submenu( - app: &AppHandle, - s: &Strings, - enabled: bool, -) -> Result, String> { - let submenu = SubmenuBuilder::new(app, s.seek_menu) - .enabled(enabled) - .build() - .map_err(|e| e.to_string())?; - let back_label = s - .seek_back_30s_label - .replace("{seconds}", &(SEEK_BACK_30_MS / 1000).to_string()); - let back = - MenuItemBuilder::with_id(format!("{SEEK_ITEM_PREFIX}-{SEEK_BACK_30_MS}"), back_label) - .build(app) - .map_err(|e| e.to_string())?; - submenu.append(&back).map_err(|e| e.to_string())?; - let forward_label = s - .seek_forward_30s_label - .replace("{seconds}", &(SEEK_FORWARD_30_MS / 1000).to_string()); - let forward = MenuItemBuilder::with_id( - format!("{SEEK_ITEM_PREFIX}+{SEEK_FORWARD_30_MS}"), - forward_label, - ) - .build(app) - .map_err(|e| e.to_string())?; - submenu.append(&forward).map_err(|e| e.to_string())?; - Ok(submenu) -} - -/// Issue #871: the volume menu id parsed into the documented Spotify -/// `volume_percent`. `None` for any id that is not one of the five known -/// percentages so a stale menu from an older build cannot drive a -/// volume slider to a number Spotify would reject. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum VolumeMenuSelection { - Percent(u32), -} - -/// Parses a volume menu-item id. The strict bounds (`0..=100`) match the -/// documented Spotify range, so a malicious or stale id cannot push the -/// device's volume past Spotify's own ceiling. -fn parse_volume_menu_id(id: &str) -> Option { - let raw = id.strip_prefix(VOLUME_ITEM_PREFIX)?; - let percent: u32 = raw.parse().ok()?; - if percent <= 100 { - Some(VolumeMenuSelection::Percent(percent)) - } else { - None - } -} - -/// Issue #871: the seek menu id parsed into a signed millisecond delta. -/// The click handler applies the delta to the current `progress_ms`, -/// clamped at zero and the track's `duration_ms` so a forward jump past -/// the end of the track is a no-op rather than an error. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SeekMenuSelection { - Delta(i64), -} - -fn parse_seek_menu_id(id: &str) -> Option { - let raw = id.strip_prefix(SEEK_ITEM_PREFIX)?; - // The sign lives in the first character; `+30` is forward, `-30` is - // back. Anything else is refused. - if let Some(rest) = raw.strip_prefix('+') { - let ms: u64 = rest.parse().ok()?; - // Cap at a sensible upper bound so a typo cannot jump 1000 minutes - // forward. The tray only ever mints `+/- 30000` (30 s) but the - // parser must defend against a menu snapshot from a future build. - if ms <= 10 * 60 * 1000 { - return Some(SeekMenuSelection::Delta(ms as i64)); - } - } else if let Some(rest) = raw.strip_prefix('-') { - let ms: u64 = rest.parse().ok()?; - if ms <= 10 * 60 * 1000 { - return Some(SeekMenuSelection::Delta(-(ms as i64))); - } - } - None -} - -/// Parses a snooze menu-item id. `None` for anything that is not one of the -/// five known ids — a stale menu from an older build must not snooze by -/// accident. -fn parse_snooze_menu_id(id: &str) -> Option { - match id.strip_prefix(SNOOZE_ITEM_PREFIX)? { - SNOOZE_30_SUFFIX => Some(SnoozeMenuSelection::Preset( - crate::config::SnoozePreset::ThirtyMinutes, - )), - SNOOZE_1H_SUFFIX => Some(SnoozeMenuSelection::Preset( - crate::config::SnoozePreset::OneHour, - )), - SNOOZE_TOMORROW_SUFFIX => Some(SnoozeMenuSelection::Preset( - crate::config::SnoozePreset::UntilTomorrow, - )), - // Issue #867: the meeting-bound preset is parsed here so a stale - // menu from a build that never had it cannot pick the wrong - // deadline. The click handler still queries the calendar cache to - // compute the actual end-of-meeting instant. - SNOOZE_NEXT_MEETING_SUFFIX => Some(SnoozeMenuSelection::Preset( - crate::config::SnoozePreset::UntilNextMeetingEnds, - )), - SNOOZE_RESUME_SUFFIX => Some(SnoozeMenuSelection::Resume), - _ => None, - } -} - -/// Persists a snooze deadline (or clears it) into `AppConfig::snooze_until` -/// (4.7.0, S9 / issue #677). -/// -/// The write half only — callers log the action, so the copy matches what -/// happened (a preset, an explicit resume, or the startup cleanup of a deadline -/// that expired while the app was closed). Follows -/// `commands::config::update_config` exactly: hold the config write guard across -/// the atomic write, persist the CLAMPED copy with the binary-owned -/// `schema_version` stamped, and store THAT value — otherwise the in-memory -/// config (what the poller reads) and config.json disagree until the next launch -/// (issues #297 / #536). -/// -/// `None` clears the field, which is both "Resume sync now" and the state the -/// frontend writes when its chip's Resume button is used. -fn store_snooze( - app: &AppHandle, - until: Option>, -) -> Result<(), String> { - let state = app.state::>(); - let mut guard = state.config.get_mut(); - let base = match guard.as_ref() { - Some(current) => (**current).clone(), - None => crate::config::load_config()?, - }; - let mut next = base; - next.snooze_until = until.map(crate::config::snooze_store_form); - - let mut persisted = crate::config::clamped_config(&next); - crate::config::stamp_schema_version(&mut persisted); - crate::config::save_config(&persisted)?; - *guard = Some(std::sync::Arc::new(persisted)); - - Ok(()) -} - -/// Persists a user-chosen snooze (or its removal) and logs the action -/// (4.7.0, S9 / issue #677). The tray's three click paths all land here. -fn write_snooze( - app: &AppHandle, - until: Option>, -) -> Result<(), String> { - store_snooze(app, until)?; - match until { - Some(deadline) => log::info!( - "[TRAY] snooze: polling paused until {} (local {}, {} min)", - crate::config::snooze_store_form(deadline), - deadline.with_timezone(&chrono::Local).format("%H:%M"), - crate::config::snooze_minutes_left((deadline - chrono::Utc::now()).num_seconds()), - ), - None => log::info!("[TRAY] snooze: resumed by the user"), - } - Ok(()) -} - -/// Issue #869: switch the active presence profile from the tray. Mirrors -/// [`write_snooze`] exactly — same read-merge-write lock, same `clamped_config` -/// path, same store-what-was-persisted discipline. `Some(name)` activates the -/// named profile (the click handler has already validated the name against the -/// clamped list); `None` clears the active profile back to the base -/// configuration. -/// -/// The write goes through `clamped_config` so the active-profile pointer is -/// re-validated against the stored list — a profile deleted between the menu -/// rebuild and the click is dropped here too, not just by the click handler. -fn write_active_profile(app: &AppHandle, name: Option) -> Result<(), String> { - store_active_profile(app, name.clone())?; - match name { - Some(profile) => log::info!("[TRAY] profile: active profile is now {:?}", profile), - None => log::info!("[TRAY] profile: cleared — using base configuration"), - } - Ok(()) -} - -/// Issue #869: the write half of the tray's profile picker. Holds the config -/// write guard across the atomic save and stores the clamped copy so the -/// in-memory config (what the poller reads) and `config.json` agree on the -/// next launch — exactly the discipline `store_snooze` and -/// `commands::config::update_config` enforce. -fn store_active_profile(app: &AppHandle, name: Option) -> Result<(), String> { - let state = app.state::>(); - let mut guard = state.config.get_mut(); - let base = match guard.as_ref() { - Some(current) => (**current).clone(), - None => crate::config::load_config()?, - }; - let mut next = base; - next.active_profile = name; - let mut persisted = crate::config::clamped_config(&next); - crate::config::stamp_schema_version(&mut persisted); - crate::config::save_config(&persisted)?; - *guard = Some(std::sync::Arc::new(persisted)); - Ok(()) -} - -/// Clears a stored snooze deadline that has already passed, once, as the app -/// comes up (4.7.0, S9 / issue #677). -/// -/// `config::load_config` is a READER on paths that hold no config-write guard, -/// so it reports an expired deadline without touching the document (a reader -/// that fixed the field in memory would hide the expiry from every writer that -/// can correct `config.json` — see that fn). This is the writer: a guarded -/// store through [`store_snooze`], run at startup, so a snooze that expired -/// while the app was closed does not sit in `config.json` logging a clamp line -/// on every launch. The poller's own expiry arm covers a deadline that lapses -/// while the app is running; either writer alone is sufficient. -fn clear_expired_snooze_at_startup(app: &AppHandle) { - let state = app.state::>(); - let expired = state - .config - .get() - .as_ref() - .is_some_and(|cfg| crate::config::snooze_expired_deadline(cfg, chrono::Utc::now())); - if !expired { - return; - } - match store_snooze(app, None) { - Ok(()) => { - log::info!("[TRAY] snooze: cleared the expired deadline left by the previous session") - } - Err(e) => log::warn!( - "[TRAY] snooze: could not clear the expired deadline ({}); the poller will retry", - e - ), - } -} - -/// Runs a Spotify player action from a tray click using the stored access -/// token. On success the tray menu is force-refreshed and the action's -/// deterministic outcome is recorded for the items that mirror it: -/// `resulting_playing` for the Play/Pause item (`None` for actions that do -/// not change the playing state), `resulting_modes` for the shuffle/repeat -/// toggles (issue #582). Both are applied BEFORE `force_tray_refresh`, so the -/// rebuild renders the state the API just accepted; recording them in the -/// caller after this function returned would repaint the replaced state and -/// the tray's dedup key would not change again until the next track ends. On -/// failure the error is logged and emitted on the `playback-error` event so -/// the frontend can surface it; a `NoActiveDevice` error (404) is logged -/// distinctly — the Devices submenu offers transfer in that case. Returns -/// `true` when the action succeeded. -/// See issues #3.0-P3 and #582. -fn run_player_action( - app: &AppHandle, - label: &str, - resulting_playing: Option, - resulting_modes: Option<(bool, RepeatState)>, - action: impl Fn(&str) -> Result<(), crate::spotify::SpotifyApiError>, -) -> bool { - // Issue #586: route the tray's player actions through the shared - // refresh-aware policy instead of a raw `state.tokens.spotify()` - // snapshot — a stale access token is refreshed proactively, and an - // `ExpiredToken` response gets one refresh + retry, exactly as the - // command layer does. - let state = app.state::>(); - match crate::commands::playback::player_with_refresh_typed(state.inner(), app, label, action) { - Ok(()) => { - log::info!("[TRAY] {}: success", label); - if let Some(playing) = resulting_playing { - note_playing_state(playing); - } - if let Some((shuffle, repeat)) = resulting_modes { - note_playback_modes(shuffle, repeat); - } - force_tray_refresh_from_app(app); - // Immediate Teams catch-up after a successful player action: - // wait 2 s for Spotify's currently-playing to catch up after - // a skip, then run a one-shot poll (no-op when sync is off). - // Coalesced: rapid clicks skip while a delayed refresh is - // already pending; its unconditional GET covers their tracks. - if DELAYED_REFRESH_IN_FLIGHT - .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) - .is_ok() - { - let app_clone = app.clone(); - let label_owned = label.to_string(); - std::thread::spawn(move || { - // RAII: a panic in run_oneshot must not wedge future - // refreshes (a manual clear on each return path would). - struct ResetOnDrop; - impl Drop for ResetOnDrop { - fn drop(&mut self) { - DELAYED_REFRESH_IN_FLIGHT.store(false, Ordering::Release); - } - } - let _reset = ResetOnDrop; - std::thread::sleep(std::time::Duration::from_secs(2)); - let state = app_clone.state::>(); - if !state.polling.is_syncing() { - log::debug!("[TRAY] {}: delayed refresh skipped (sync off)", label_owned); - return; - } - crate::polling::run_oneshot(state.inner(), &app_clone); - let is_syncing = state.polling.is_syncing(); - let current_track = state.polling.current_track().clone(); - if let Err(e) = update_tray_menu(&app_clone, is_syncing, current_track) { - log::warn!( - "[TRAY] {}: delayed refresh tray update failed: {}", - label_owned, - e - ); - } - }); - } else { - log::debug!( - "[TRAY] {}: delayed refresh already pending, coalesced", - label - ); - } - true - } - Err(crate::spotify::SpotifyApiError::NoActiveDevice) => { - log::warn!( - "[TRAY] {}: no active device — pick one from the Devices menu", - label - ); - let _ = app.emit( - "playback-error", - "No active playback device - pick one from the tray Devices menu", - ); - false - } - Err(e) => { - log::error!("[TRAY] {}: failed: {}", label, e); - let _ = app.emit("playback-error", e.to_string()); - false - } - } -} - -/// Upper bound on the wait for the frontend's Pause/Resume toggle to land -/// (issue #588). The frontend owns the actual `start_syncing` / -/// `stop_syncing` call, so the flag settles only after that round-trip. -const TOGGLE_SETTLE_TIMEOUT: Duration = Duration::from_secs(3); - -/// Poll cadence while waiting for the toggle to land. -const TOGGLE_SETTLE_POLL: Duration = Duration::from_millis(150); - -/// Repaints the tray from authoritative backend state on the CURRENT -/// thread. Callers must already be off the menu/app-event thread — the -/// rebuild performs blocking Spotify HTTP whenever the fetch throttle has -/// lapsed. `context` only labels the failure log. -fn repaint_tray_from_state(app: &AppHandle, context: &str) { - let Some(state) = app.try_state::>() else { - log::debug!( - "[TRAY] {}: AppState not registered yet, skipping repaint", - context - ); - return; - }; - let is_syncing = state.polling.is_syncing(); - let current_track = state.polling.current_track().clone(); - if let Err(e) = update_tray_menu(app, is_syncing, current_track) { - log::warn!("[TRAY] {}: tray repaint failed: {}", context, e); - } -} - -/// Repaints the tray from backend state on a worker thread. -/// -/// Issues #587/#592: every caller runs on a menu/app-event thread (tray -/// click arms, app-menu items, the single-instance raise), and -/// `update_tray_menu` fetches Spotify devices/queue with a 10 s timeout -/// whenever the 60 s throttle has lapsed — doing that inline wedges the -/// native menu (the freeze issue #386 removed from the player arms). -pub(crate) fn refresh_tray_from_state(app: &AppHandle) { - let app_handle = app.clone(); - std::thread::spawn(move || repaint_tray_from_state(&app_handle, "refresh_tray_from_state")); -} - -/// Repaints the tray after a locale change (issue #674). -/// -/// A language switch changes only labels, so the dedup snapshot would -/// otherwise hide it — `force_tray_refresh` nudges that snapshot and rebuilds -/// from the freshly installed table. It runs on a worker thread for the same -/// reason as [`refresh_tray_from_state`]: the rebuild may perform blocking -/// Spotify HTTP, which must never run on a menu/app-event thread. -pub(crate) fn refresh_tray_for_locale(app: &AppHandle) { - let app_handle = app.clone(); - std::thread::spawn(move || force_tray_refresh_from_app(&app_handle)); -} - -/// Waits — bounded by [`TOGGLE_SETTLE_TIMEOUT`] — for the frontend's -/// Pause/Resume toggle to move the running flag away from `before`. -/// Returns `true` when it moved, `false` when the window elapsed or state -/// was unavailable; the caller repaints either way. -fn await_sync_toggle(app: &AppHandle, before: bool) -> bool { - let deadline = Instant::now() + TOGGLE_SETTLE_TIMEOUT; - while Instant::now() < deadline { - std::thread::sleep(TOGGLE_SETTLE_POLL); - let Some(state) = app.try_state::>() else { - return false; - }; - if state.polling.is_syncing() != before { - return true; - } - } - false -} - -/// Production entry point for [`force_tray_refresh`]: binds the managed-state -/// lookup and the tray rebuild to a live `AppHandle`. -/// -/// `try_state` — not `state` — is the lookup: `state()` panics with "state() -/// called before manage()", so a lookup that can run before `app.manage()` -/// must not use it. See the seam's doc comment for whether this function's -/// current callers can (they cannot — this is defence-in-depth). -fn force_tray_refresh_from_app(app: &AppHandle) { - force_tray_refresh( - &|| { - app.try_state::>() - .map(|state| state.inner().clone()) - }, - &mut |is_syncing, current_track| { - let _ = update_tray_menu(app, is_syncing, current_track); - }, - ); -} - -/// Forces the next `update_tray_menu` call to rebuild and asks it for fresh -/// Devices/Up Next lists: records the action (issue #883 — the caches stay, and -/// the rebuild re-fetches under `TRAY_POST_ACTION_FETCH_MIN`), nudges the dedup -/// snapshot so the rebuild can't early-return, then rebuilds immediately. -/// User-initiated tray actions call this so the menu reflects the new -/// playback/device state right away — the dedup key alone wouldn't change on -/// e.g. a pause or a transfer. -/// -/// The snapshot is nudged (not cleared) with the *current* track key so the -/// re-seed logic in `update_tray_menu` (which only fires on a genuine track -/// change) doesn't clobber the toggle state set by the action. See -/// issue #3.0-P3. -/// -/// Issue #937 asked for this path to tolerate a second launch arriving before -/// `app.manage(state.clone())` has run in the setup closure. As it turns out -/// this function has no such caller, and it did not crash before this change: -/// its only two production callers — [`run_player_action`] (tray menu events) -/// and [`refresh_tray_for_locale`] (the `save_config` IPC) — both run after -/// setup has managed the state. The forwarded-second-launch path reaches -/// [`refresh_tray_from_state`] → `repaint_tray_from_state`, which has used -/// `try_state` on `main` already. So this is defence-in-depth, not a fix for -/// an observed crash: the lookup cannot panic, and no future caller has to -/// remember that it cannot. -/// -/// The managed-state lookup is a parameter rather than an inline -/// `app.try_state()` so the unit test can drive both arms — a lookup that -/// reports "not managed yet" (`None`) and one that returns a managed state — -/// without a GUI runtime. That is also why `managed_state` returning `None` -/// is the arm under test: it is exactly what a pre-`manage()` caller would -/// see. The skip is logged at `warn!`, not `debug!`: the default -/// tauri-plugin-log level filters `debug!`, so a `debug!` line would be -/// invisible to anyone actually debugging a real skip. -/// -/// The binder in [`force_tray_refresh_from_app`] is deliberately UNPINNED: -/// nothing in the test suite constrains it to `try_state` rather than -/// `state()`, because Tauri offers no hermetic `AppHandle` constructor -/// outside the `test` feature that #937's rework removed (that feature is -/// what breaks the Windows test binary's loader). A revert of the binder -/// would restore the panic at the call, with no test failing — acceptable -/// only because no caller reaches this function before `manage()`. -/// -/// The seams are `&dyn Fn` rather than generic parameters so the #883/#886 -/// source guards in this module keep resolving to this body, and so the -/// regression test drives this function itself instead of a stand-in. -fn force_tray_refresh( - managed_state: &dyn Fn() -> Option>, - rebuild: &mut dyn FnMut(bool, Option), -) { - let Some(state) = managed_state() else { - log::warn!( - "[TRAY] force_tray_refresh: AppState not managed yet — skipping the forced \ - tray rebuild (issue #937)" - ); - return; - }; - let is_syncing = state.polling.is_syncing(); - let current_track = state.polling.current_track().clone(); - // S9 (issue #677): the snooze is read here only for the dedup key below — - // while one is active the rebuild renders the CACHED Devices/Up Next lists - // instead of fetching (`TrayFetch::CacheOnly`) and an action cannot override - // that. The nudge below still forces the repaint, which is all a snooze-time - // repaint needs. - let snooze = snooze_from_app_state(&state); - // Issue #883: the caches stay. Emptying them was how this helper asked for - // fresh Devices/Up Next lists, and it bypassed `TRAY_SPOTIFY_FETCH_THROTTLE` - // on every player action; the action is recorded instead, and the rebuild - // re-fetches under `TRAY_POST_ACTION_FETCH_MIN` — which coalesces a burst - // into one pair of requests. The nudge below still forces the repaint. - *LAST_TRAY_ACTION.lock() = Some(Instant::now()); - let snooze_key = snooze.as_ref().map(|sn| snooze_dedup_key(&sn.status)); - // Nudge the dedup snapshot (not clear it) with the *current* track key so - // the rebuild below can't early-return while the re-seed logic stays - // inert: a cleared snapshot would look like a genuine track change and - // clobber the toggle state the action just recorded. Flipping the sync - // bit is enough — the real snapshot is committed by that rebuild. - let (devices_bucket, queue_bucket) = cache_buckets(); - // Issue #869: the nudge snapshot's profile key reads the post-action - // value so the rebuild the function triggers sees a fresh dedup key — - // the real write happens inside `store_active_profile`. - // Issue #937: this used to be a second `app.state::<>()` call on the same - // guard-clause risk. The single lookup above is already in hand, so the - // key is read from it — one lookup, and this site can no longer panic. - let nudge_profile_key = state - .config - .get() - .as_ref() - .and_then(|c| c.active_profile.clone()); - let mut nudge = tray_snapshot_for( - is_syncing, - false, - current_track.as_ref(), - snooze_key, - devices_bucket, - queue_bucket, - nudge_profile_key, - ); - nudge.is_syncing = !is_syncing; - *last_tray_state().lock() = Some(nudge); - rebuild(is_syncing, current_track); -} - -/// One-line sync/status summary for the tray's status item and tooltip -/// (issue #591). The Pause/Resume verb on its own left the sync state -/// unstated, and the presence-gated dock badge is macOS-only. `is_playing` -/// is `LAST_PLAYING_STATE` — the same source as the Play/Pause checkmark — -/// not the polling loop's copy, which goes stale on a same-track pause. -/// -/// Localized from the table it is handed (issue #674); the artist/title and -/// the em-dash separators are locale-neutral, so only the status word and the -/// track-less line come from the table. -fn sync_status_line( - strings: &Strings, - is_syncing: bool, - is_playing: bool, - track: Option<&crate::spotify::TrackInfo>, -) -> String { - if !is_syncing { - return strings.status_not_syncing.to_string(); - } - match track { - None => strings.status_syncing_no_track.to_string(), - Some(t) if is_playing => format!("{} — {} — {}", strings.status_syncing, t.artist, t.title), - Some(t) => format!("{} — {} — {}", strings.status_paused, t.artist, t.title), - } -} - -/// The tray's status line while a snooze is active (4.7.0, S9 / issue #677): -/// the word, the remaining time and the local deadline. -/// -/// This replaces [`sync_status_line`] rather than prefixing it: a snoozed -/// session is deliberately not syncing anything, so any words about the current -/// track would be a claim about work that is not happening. The countdown needs -/// no locale-specific plural handling — the unit is a table entry, and the -/// minute count is rounded so it never reads "0 min left" while the snooze is -/// still live (see `config::snooze_minutes_left`). -/// -/// Localized from the table it is handed, like every other label here; the -/// `→` and `—` separators are locale-neutral, matching `sync_status_line`. -fn snooze_status_line(strings: &Strings, snooze: &TraySnooze) -> String { - format!( - "{} — {} {} (→ {})", - strings.snooze_paused, - snooze.minutes_left, - strings.snooze_minutes_left, - snooze - .status - .deadline - .with_timezone(&chrono::Local) - .format("%H:%M") - ) -} - -/// Rebuilds the tray menu with current state. -/// Called by menu.rs when sync state or track changes. -pub fn update_tray_menu( - app: &AppHandle, - is_syncing: bool, - current_track: Option, -) -> Result<(), String> { - rebuild_tray_menu(app, is_syncing, current_track.as_ref(), TrayPaint::Deduped) -} - -/// The menu the tray shows before the event loop is running (issue #882). -/// -/// Two differences from a normal rebuild, both consequences of running on the -/// main thread inside `setup()`: it never touches the network (the throttled -/// caches are rendered as they stand — empty on a cold start), and it does NOT -/// commit the dedup snapshot. This paint shows the caches only, so recording it -/// would make the first honest rebuild look like a no-op and the Devices/Up -/// Next submenus would stay empty for the rest of the session. -fn update_tray_menu_startup( - app: &AppHandle, - is_syncing: bool, - current_track: Option, -) -> Result<(), String> { - rebuild_tray_menu(app, is_syncing, current_track.as_ref(), TrayPaint::Startup) -} - -/// The rebuild both entry points above share, so the tray has one layout. -fn rebuild_tray_menu( - app: &AppHandle, - is_syncing: bool, - current_track: Option<&crate::spotify::TrackInfo>, - paint: TrayPaint, -) -> Result<(), String> { - let tray = match get_tray() { - Some(t) => t, - None => { - log::warn!("[TRAY] update_tray_menu: Tray not initialized"); - return Err("Tray not initialized".to_string()); - } - }; - - // Issue #71 + #229 + #691: dedup guard. The polling thread calls this on - // every successful poll; the menu only needs rebuilding when is_syncing, - // window visibility (drives the Show/Hide label), the track's - // title/artist/is_playing, or one of the playback modes actually - // changes. is_playing is included so a same-track pause flips the - // Play/Pause mark without waiting for a poll; the modes are included so - // a change made in another Spotify client repaints the Shuffle/Repeat - // check marks (and the next click toggles from the fresh belief). - // - // Window visibility is computed up front so the dedup key includes - // it — otherwise a hide/show click would early-return and the label - // would go stale. - // Issue #886: the key reads the visibility mirror, so this path performs no - // event-loop hop — `live_window_visible` below re-reads the real window on - // the way to a paint, which is where the label is built. - let key_visible = window_visible(); - // 4.7.0 (S9 / issue #677): the snooze is resolved once, up front, because it - // feeds three separate decisions below — the dedup key, the fetch mode and - // the rendered status line. Issue #886: it comes from a scoped read of the - // mounted config, so no whole-`AppConfig` clone is allocated per poll and no - // read guard survives into the blocking Spotify HTTP below. `state` is the - // same handle the rest of the rebuild uses. - let state = app.state::>(); - let snooze = snooze_from_app_state(state.inner()); - let snooze_key = snooze.as_ref().map(|sn| snooze_dedup_key(&sn.status)); - // Issue #883: a player action asks for fresh Devices/Up Next lists, but only - // when it is not already covered by a fetch in flight (`action_fetch_due`), - // and never while snoozed (`paint_fetch_mode`). - let action_refresh_due = action_fetch_due( - *LAST_TRAY_ACTION.lock(), - *LAST_ACTION_FETCH.lock(), - Instant::now(), - TRAY_POST_ACTION_FETCH_MIN, - ); - let fetch = paint_fetch_mode(paint, snooze.is_some(), action_refresh_due); - // Issue #805: the two cache buckets are part of the key, so a session where - // nothing else moves still repaints — and therefore re-fetches — once per - // throttle window instead of keeping whatever the last rebuild rendered. - let (devices_bucket, queue_bucket) = cache_buckets(); - // Issue #869: the active profile id rides the dedup key so a switch — - // tray click, CLI, or Settings card — repaints the submenu's check - // mark. Cloned out of the short-lived read guard because the snapshot - // outlives this scope (the dedup is the next call's read source). - let active_profile_key = state - .config - .get() - .as_ref() - .and_then(|c| c.active_profile.clone()); - let snapshot = tray_snapshot_for( - is_syncing, - key_visible, - current_track, - snooze_key, - devices_bucket, - queue_bucket, - active_profile_key, - ); - { - let last = last_tray_state().lock(); - if !tray_state_changed(last.as_ref(), &snapshot) { - // No-op: menu state hasn't changed. - return Ok(()); - } - // Re-seed the Play/Pause toggle's playing state when the polling - // loop observed a genuinely new track (or a stop): its stored - // `is_playing` is only fresh at track-change time. Tray-initiated - // actions and the poller's `playback-state-changed` event update - // LAST_PLAYING_STATE themselves, and a forced rebuild keeps the - // same track_key, so neither path re-seeds here. - let track_changed = match last.as_ref() { - Some(prev) => prev.track_key != snapshot.track_key, - None => true, - }; - if track_changed { - let fresh_playing = current_track - .as_ref() - .map(|t| t.is_playing) - .unwrap_or(false); - note_playing_state(fresh_playing); - } - // Do NOT update the snapshot yet. If the rebuild below fails - // (e.g., set_menu returns Err), we want the next call with the - // same state to retry rather than no-op on a stale snapshot. - } - - // Issue #886: the key was built from the mirror; the label below is built - // from the real window — one query, paid only on this path, which is about - // to perform Spotify HTTP anyway. A show/hide that did not report itself - // therefore cannot leave the Show/Hide label wrong, and the mirror self-heals - // for the polls that follow. - let is_window_visible = live_window_visible(app); - note_window_visibility(is_window_visible); - - // Fetch Spotify data OUTSIDE the tray write lock, and only when the fetch - // mode allows it. The throttled caches are snapshotted and fetched without - // holding either cache mutex across HTTP (see cached_devices/cached_queue), - // and the tray lock is not yet held so a concurrent tray click or polling - // update never blocks on the network. Benign double-fetch race documented on - // those helpers. See issue #217. - let access_token = state - .tokens - .spotify() - .as_ref() - .map(|t| t.access_token.clone()); - if fetch == TrayFetch::RefreshNow { - // Recorded BEFORE the requests run: a click that lands while they are in - // flight must reuse them, not start a pair of its own (issue #883). - *LAST_ACTION_FETCH.lock() = Some(Instant::now()); - } - let devices: Vec = devices_for_menu(access_token.as_deref(), fetch); - let queue: Option = queue_for_menu(access_token.as_deref(), fetch); - // Issue #871: the active device's capability flags gate the playback - // submenu — Volume disabled when `actions.setting_volume` is false, - // Seek disabled when `actions.seeking` is false, Shuffle disabled when - // the device is restricted or `actions.toggling_shuffle` is false, - // Repeat the same. A device list that lacks the active device (e.g. - // a freshly-restarted Spotify session that has not yet published - // devices) returns `None`; the playback submenu then disables the - // capability-gated items by default so a stale-capabilities click - // cannot reach Graph with a 403. - let active_capabilities = devices - .iter() - .find(|d| d.is_active) - .map(|d| (d.supports_volume, d.is_restricted, d.actions.clone())); - let (active_supports_volume, active_is_restricted, active_actions) = - active_capabilities.unwrap_or((false, true, crate::spotify::DeviceActions::default())); - - // Build menu items without holding the tray write lock. Only the final - // tray.set_menu call needs serialising — everything above is pure data - // preparation and menu-item construction. - // Issue #674: every label below comes from the installed i18n table, so a - // language switch only has to rebuild the menu. - let s = i18n::current(); - // Determine Show/Hide label based on the precomputed visibility. - let show_hide_label = if is_window_visible { - s.hide_window - } else { - s.show_window - }; - - let show_hide = MenuItemBuilder::with_id(ID_SHOW_HIDE, show_hide_label) - .build(app) - .map_err(|e| { - log::warn!( - "[TRAY] update_tray_menu: failed to build show_hide menu item: {}", - e - ); - e.to_string() - })?; - - // Pause/Resume label based on sync state - let pause_resume_id = if is_syncing { - ID_PAUSE_SYNC - } else { - ID_RESUME_SYNC - }; - let pause_resume_label = if is_syncing { - s.pause_sync - } else { - s.resume_sync - }; - let pause_resume = MenuItemBuilder::with_id(pause_resume_id, pause_resume_label) - .build(app) - .map_err(|e| { - log::warn!( - "[TRAY] update_tray_menu: failed to build pause_resume menu item: {}", - e - ); - e.to_string() - })?; - - let separator1 = PredefinedMenuItem::separator(app).map_err(|e| { - log::warn!("[TRAY] update_tray_menu: failed to build separator1: {}", e); - e.to_string() - })?; - // separator2 inserted only when track is added (see below) - let separator3 = PredefinedMenuItem::separator(app).map_err(|e| { - log::warn!("[TRAY] update_tray_menu: failed to build separator3: {}", e); - e.to_string() - })?; - - let open_settings = MenuItemBuilder::with_id(ID_SETTINGS, s.open_settings) - .build(app) - .map_err(|e| { - log::warn!( - "[TRAY] update_tray_menu: failed to build open_settings menu item: {}", - e - ); - e.to_string() - })?; - - let open_logs = MenuItemBuilder::with_id(ID_OPEN_LOGS, s.open_logs_folder) - .build(app) - .map_err(|e| { - log::warn!( - "[TRAY] update_tray_menu: failed to build open_logs menu item: {}", - e - ); - e.to_string() - })?; - - let quit = MenuItemBuilder::with_id(ID_QUIT, s.quit) - .build(app) - .map_err(|e| { - log::warn!( - "[TRAY] update_tray_menu: failed to build quit menu item: {}", - e - ); - e.to_string() - })?; - - // Spotify playback controls (issue #3.0-P3). Play/Pause is a single - // check-item whose native checked state comes from LAST_PLAYING_STATE - // (see the static's docs — the polling loop's stored track goes stale - // on a same-track pause); the Devices/Up Next submenus are built from - // the pre-fetched throttled caches so the polling loop's per-iteration - // rebuilds don't hammer the Spotify API. The checkmark is derived from - // (track_id, is_playing) via the track_key dedup and LAST_PLAYING_STATE, - // so a same-track pause flips without waiting for the next poll. See - // issues #229 and #217. - let is_playing = LAST_PLAYING_STATE.load(Ordering::Acquire); - // Issue #591: a disabled head-of-menu status line stating what the app - // is actually doing (syncing / paused / not syncing). Everything below - // is derived from state already in scope for this rebuild, so the line - // cannot drift from the Show/Hide and Pause/Resume items beside it. - // S9 (issue #677): a snooze replaces the line outright — the remaining time - // and the local deadline are the whole answer while polling is suspended, - // and naming a track would describe work the app is deliberately not doing. - let status_line = match &snooze { - Some(sn) => snooze_status_line(s, sn), - None => sync_status_line(s, is_syncing, is_playing, current_track), - }; - let sync_status = MenuItemBuilder::with_id(ID_SYNC_STATUS, status_line.clone()) - .enabled(false) - .build(app) - .map_err(|e| { - log::warn!( - "[TRAY] update_tray_menu: failed to build sync_status item: {}", - e - ); - e.to_string() - })?; - // Issue #871: the playback items gate on the active device's documented - // capabilities. The Dashboard composer and the slider share the same - // gate (`commands/playback::set_volume` / `seek` refuse to issue the - // Graph call when the device refuses it; here the tray items are - // disabled for the same reason so a stale-capabilities click never - // even reaches the click handler). - let play_pause = CheckMenuItemBuilder::with_id(ID_PLAY_PAUSE, s.play_pause) - .checked(is_playing) - .enabled(active_actions.resuming || active_actions.pausing) - .build(app) - .map_err(|e| { - log::warn!( - "[TRAY] update_tray_menu: failed to build play_pause item: {}", - e - ); - e.to_string() - })?; - let previous = MenuItemBuilder::with_id(ID_PREVIOUS, s.previous) - .enabled(!active_is_restricted && active_actions.skipping_prev) - .build(app) - .map_err(|e| { - log::warn!( - "[TRAY] update_tray_menu: failed to build previous item: {}", - e - ); - e.to_string() - })?; - let next = MenuItemBuilder::with_id(ID_NEXT, s.next) - .enabled(!active_is_restricted && active_actions.skipping_next) - .build(app) - .map_err(|e| { - log::warn!("[TRAY] update_tray_menu: failed to build next item: {}", e); - e.to_string() - })?; - // Issue #582: the playback-mode toggles. Their marks come from the - // module-level atoms the polling loop feeds from the poll body (the - // shuffle/repeat state is free — the same response the app already - // parses), so no extra request and no cache machinery is involved. - // Both atoms are part of the dedup snapshot (issue #691), so a mode - // changed from another client forces the rebuild that repaints the - // mark; a click on these items rebuilds through `force_tray_refresh` - // and is reflected immediately. - // - // Issue #871: the existing `is_restricted` gate stays; the Spotify - // capability flags are the documented twin (issue #871 — Spotify - // documents both as sources of truth for "can I do this?"; the - // capability flag wins when the device reports it). - let shuffle = CheckMenuItemBuilder::with_id(ID_SHUFFLE, s.shuffle) - .checked(LAST_SHUFFLE_STATE.load(Ordering::Acquire)) - .enabled(!active_is_restricted && active_actions.toggling_shuffle) - .build(app) - .map_err(|e| { - log::warn!( - "[TRAY] update_tray_menu: failed to build shuffle item: {}", - e - ); - e.to_string() - })?; - let repeat_state = last_repeat_state(); - let repeat = CheckMenuItemBuilder::with_id(ID_REPEAT, repeat_menu_label(s, repeat_state)) - .checked(repeat_state.is_on()) - .enabled( - !active_is_restricted - && (active_actions.toggling_repeat_context || active_actions.toggling_repeat_track), - ) - .build(app) - .map_err(|e| { - log::warn!( - "[TRAY] update_tray_menu: failed to build repeat item: {}", - e - ); - e.to_string() - })?; - // Issue #871: the Volume submenu. The 0/25/50/75/100 picks are the - // documented Spotify boundaries (`PUT /me/player/volume` clamps to - // 0..=100). Disabled when the active device refuses the volume - // capability — `supports_volume` is the older twin, the new - // `actions.setting_volume` flag is the documented source of truth. - let volume_submenu = build_volume_submenu( - app, - s, - active_supports_volume && active_actions.setting_volume, - ) - .map_err(|e| { - log::warn!( - "[TRAY] update_tray_menu: failed to build volume submenu: {}", - e - ); - e.to_string() - })?; - // Issue #871: the Seek submenu. The +/- 30 s picks are the documented - // surface for `PUT /me/player/seek`; the Dashboard's click-to-seek - // progress bar reaches the same endpoint through - // `commands/playback::seek`. Disabled when the device refuses the - // seek capability. - let seek_submenu = build_seek_submenu(app, s, !active_is_restricted && active_actions.seeking) - .map_err(|e| { - log::warn!( - "[TRAY] update_tray_menu: failed to build seek submenu: {}", - e - ); - e.to_string() - })?; - let playback_separator = PredefinedMenuItem::separator(app).map_err(|e| { - log::warn!( - "[TRAY] update_tray_menu: failed to build playback_separator: {}", - e - ); - e.to_string() - })?; - - let devices_submenu = build_devices_submenu_from_devices(app, &devices).map_err(|e| { - log::warn!( - "[TRAY] update_tray_menu: failed to build devices submenu: {}", - e - ); - e - })?; - let queue_submenu = build_queue_submenu_from_queue(app, queue.as_ref()).map_err(|e| { - log::warn!( - "[TRAY] update_tray_menu: failed to build queue submenu: {}", - e - ); - e - })?; - // S9 (issue #677): the snooze submenu, rebuilt with the resolved snooze so - // the "Resume sync now" entry appears exactly while one is active. Labels - // come from the same installed table as everything above. - let snooze_submenu = build_snooze_submenu(app, s, snooze.as_ref()).map_err(|e| { - log::warn!( - "[TRAY] update_tray_menu: failed to build snooze submenu: {}", - e - ); - e - })?; - // Issue #870: the "Recent statuses" submenu. The recent ring is a - // process-global; this build call is the only thing that turns it into a - // tray surface, and the `manualstatus|clear` entry only appears while a - // manual status is armed. The Dashboard composer reads the same ring, - // so a click on the tray's pick hits the same record the Dashboard would. - let manual_status_submenu = build_manual_status_submenu( - app, - s, - &crate::commands::status::load_recent_statuses(), - crate::commands::status::load_manual_status().as_ref(), - ) - .map_err(|e| { - log::warn!( - "[TRAY] update_tray_menu: failed to build manual-status submenu: {}", - e - ); - e - })?; - // Issue #869: the "Active profile" submenu. Sits next to the snooze - // items because both are runtime state changes — the spec asks for - // the picker beside the snooze items, not under Settings, so the - // user can flip overlays without leaving the tray. - let profile_submenu = { - let profiles: Vec = state - .config - .get() - .as_ref() - .map(|c| c.presence_profiles.clone()) - .unwrap_or_default(); - let active = state - .config - .get() - .as_ref() - .and_then(|c| c.active_profile.clone()); - build_profile_submenu(app, s, &profiles, active.as_deref()) - } - .map_err(|e| { - log::warn!( - "[TRAY] update_tray_menu: failed to build profile submenu: {}", - e - ); - e - })?; - - // Build menu with optional track info - let mut menu_builder = MenuBuilder::new(app) - .items(&[ - &sync_status, - &show_hide, - &pause_resume, - &snooze_submenu, - &profile_submenu, - &manual_status_submenu, - &separator1, - ]) - .items(&[ - &play_pause, - &previous, - &next, - &shuffle, - &repeat, - &volume_submenu, - &seek_submenu, - ]) - .item(&playback_separator) - .items(&[&devices_submenu, &queue_submenu]); - - // Add current track item if playing — insert separator2 here too. - // Issue #956: the gate is the rebuild's OWN `is_playing` binding — the same - // one the Play/Pause mark and the status line read — not the stored track's - // flag. `run_player_action` records the new playing state and forces this - // rebuild without re-storing the track, so a tray-initiated pause used to - // leave this row naming a track the tray had just paused. - if is_playing { - if let Some(track) = ¤t_track { - let separator2 = PredefinedMenuItem::separator(app).map_err(|e| { - log::warn!("[TRAY] update_tray_menu: failed to build separator2: {}", e); - e.to_string() - })?; - let track_item = MenuItemBuilder::with_id( - ID_CURRENT_TRACK, - format!("🎵 {} - {}", track.artist, track.title), - ) - .enabled(false) - .build(app) - .map_err(|e| { - log::warn!("[TRAY] update_tray_menu: failed to build track_item: {}", e); - e.to_string() - })?; - menu_builder = menu_builder.item(&track_item).item(&separator2); - } - } - - let menu = menu_builder - .items(&[&open_settings, &open_logs, &separator3, &quit]) - .build() - .map_err(|e| { - log::warn!("[TRAY] update_tray_menu: failed to build menu: {}", e); - e.to_string() - })?; - - // Acquire the tray write lock ONLY around the final set_menu. The long - // HTTP fetches and the entire menu build above ran without it, so neither - // a polling-thread rebuild nor a main-thread tray click blocks on the - // network. See issue #217. - { - let _write_guard = tray_write_lock().lock(); - tray.set_menu(Some(menu)).map_err(|e| { - log::warn!("[TRAY] update_tray_menu: failed to set tray menu: {}", e); - format!("Failed to set tray menu: {}", e) - })?; - } - - // C4 polish: keep the tray tooltip live — "Artist — Track (▶|⏸)" while - // a track is known, the plain app name otherwise. This runs on every - // rebuild, i.e. exactly whenever track info changes (the dedup key - // already covers artist/title/is_playing), and performs no IO and no - // extra locking beyond the tray handle itself. - // Issue #956: the glyph comes from the rebuild's own `is_playing` binding, - // like the row above and the check mark, so one hover cannot contradict the - // menu it belongs to. - let track_tooltip = match ¤t_track { - Some(t) => format!( - "{} — {} ({})", - t.artist, - t.title, - if is_playing { "▶" } else { "⏸" } - ), - None => "PresenceJam".to_string(), - }; - // Issue #591: the status line leads the tooltip, so a hover states - // whether PresenceJam is syncing even when no track row is present - // (and off macOS, where the presence-gated dock badge is a no-op). - let tooltip = format!("{} · {}", status_line, track_tooltip); - if let Err(e) = tray.set_tooltip(Some(tooltip)) { - log::warn!("[TRAY] update_tray_menu: failed to set tooltip: {}", e); - } - - // Issue #971: `set_tooltip` is a documented no-op on Linux, which has no - // hover surface for an AppIndicator — so the same summary rides as the - // indicator's title, which Linux renders beside the icon. That closes the - // gap where the status line, the current track and the snooze countdown - // were only visible after opening the menu. Windows and macOS keep the - // tooltip and set no title (the call is a no-op there anyway). - #[cfg(target_os = "linux")] - if let Err(e) = tray.set_title(Some(status_line.clone())) { - log::warn!("[TRAY] update_tray_menu: failed to set tray title: {}", e); - } - - // Commit the snapshot only after a successful set_menu. A failed - // set_menu above left the snapshot at the previous value, so the - // next call with the same state will retry rather than no-op. - // Issue #882: the startup paint never commits — it renders the caches - // only, so recording it would dedup away the first real rebuild. - if paint == TrayPaint::Deduped { - *last_tray_state().lock() = Some(snapshot); - } - - log::info!( - "[TRAY] update_tray_menu: tray menu updated - is_syncing={}, visible={}, track={:?}", - is_syncing, - is_window_visible, - current_track - .as_ref() - .map(|t| format!("{} - {}", t.artist, t.title)) - ); - Ok(()) -} - -/// C4 polish: reflect presence-gated sync on the macOS dock icon. While a -/// track's Teams status write is suppressed by the presence gate -/// (`polling/poll_once.rs` records it in `gated_track_key`), the dock icon -/// shows a badge so the user can see presence updates are being held back; -/// cleared when the gate lifts or syncing stops. No-op off macOS so call -/// sites stay cfg-free and compile everywhere. -/// -/// SINGLE CALL-SITE (owned by the polling driver, feat/v4-c11): in -/// `polling/loop.rs::polling_loop`, immediately AFTER the post-iteration -/// `tray::update_tray_menu(...)` block, add -/// `tray::set_presence_gated_badge(&app, gated_track_key.is_some());`, -/// and after the loop exits, add -/// `tray::set_presence_gated_badge(&app, false);`. -#[cfg(target_os = "macos")] -pub fn set_presence_gated_badge(app: &AppHandle, gated: bool) { - let Some(window) = app.get_webview_window("main") else { - return; - }; - if let Err(e) = window.set_badge_count(if gated { Some(1) } else { None }) { - log::warn!( - "[TRAY] set_presence_gated_badge: failed to set dock badge: {}", - e - ); - } -} - -#[cfg(not(target_os = "macos"))] -pub fn set_presence_gated_badge(_app: &AppHandle, _gated: bool) {} - -/// Runs a tray-library call, turning the panic a missing native tray library -/// raises into the `Err` this module's contract promises (issue #927). -/// -/// The reason is logged once, at error level, together with what failed: an -/// AppImage whose bundler could not see a dlopen-only dependency is the likely -/// host, and the log line is the only thing that says so. -fn guard_tray_panic( - what: &str, - f: impl FnOnce() -> Result + std::panic::UnwindSafe, -) -> Result { - match std::panic::catch_unwind(f) { - Ok(result) => result, - Err(payload) => { - let reason = payload - .downcast_ref::<&str>() - .map(|s| (*s).to_string()) - .or_else(|| payload.downcast_ref::().cloned()) - .unwrap_or_else(|| "unknown panic".to_string()); - log::error!( - "[TRAY] {}: the tray library is unavailable ({}); running without a tray", - what, - reason - ); - Err(format!("{} unavailable: {}", what, reason)) - } - } -} - -/// Whether a tray icon actually exists (issue #927). -/// -/// `setup_tray` can now fail without panicking, and a session with no tray has -/// no reachable way back to a window that close-to-tray has hidden — so the -/// caller that hides it (lib.rs) has to gate on this. -pub fn tray_available() -> bool { - get_tray().is_some() -} - -/// Side of [`TEMPLATE_GLYPH`] in cells (issue #911). macOS sizes menu-bar items -/// at 22 pt, so the glyph is drawn as a 22-cell square and doubled for `@2x`. -#[cfg(any(target_os = "macos", test))] -const TEMPLATE_GLYPH_SIZE: u32 = 22; - -/// The menu-bar glyph: a quarter note, `#` inked and `.` transparent. -/// -/// A bitmap in the source rather than a shipped PNG: the tray then needs no -/// macOS-only asset and no PNG-decoding feature to read one, and the shape is -/// pure data that every platform's test run can check. The stem starts at the -/// top-right and runs down into the note head, with the flag off its top. -#[cfg(any(target_os = "macos", test))] -const TEMPLATE_GLYPH: [&str; TEMPLATE_GLYPH_SIZE as usize] = [ - "......................", - "......................", - "......................", - "......................", - "...........#####......", - "...........######.....", - "...........######.....", - "...........######.....", - "...........##.........", - "...........##.........", - "...........##.........", - "...........##.........", - ".......#...##.........", - ".....#####.##.........", - "....#########.........", - "....#########.........", - "...##########.........", - "....#########.........", - "....#########.........", - ".....#####.##.........", - ".......#...##.........", - "......................", -]; - -/// The RGBA bytes for [`TEMPLATE_GLYPH`] at `scale` (issue #911): opaque black -/// for ink, fully transparent elsewhere. -/// -/// A template image carries no colour — macOS reads the alpha channel and lets -/// the menu bar tint the result — so the colour channels are zero everywhere and -/// only the alpha distinguishes ink from background. Each cell becomes a -/// `scale`×`scale` block, which is how the `@2x` twin is produced. -#[cfg(any(target_os = "macos", test))] -fn template_icon_rgba(scale: u32) -> Vec { - let scale = scale.max(1); - let side = TEMPLATE_GLYPH_SIZE * scale; - let mut rgba = Vec::with_capacity((side * side * 4) as usize); - for row in TEMPLATE_GLYPH { - for _ in 0..scale { - for cell in row.bytes() { - let alpha = if cell == b'#' { 255u8 } else { 0u8 }; - for _ in 0..scale { - rgba.extend_from_slice(&[0, 0, 0, alpha]); - } - } - } - } - rgba -} - -/// The macOS menu-bar icon (issue #911): [`TEMPLATE_GLYPH`] at its `@2x` size, so -/// the same image is sharp on a Retina bar without a second asset. -#[cfg(target_os = "macos")] -fn macos_template_icon() -> tauri::image::Image<'static> { - const SCALE: u32 = 2; - let side = TEMPLATE_GLYPH_SIZE * SCALE; - let rgba = template_icon_rgba(SCALE); - debug_assert_eq!(rgba.len(), (side * side * 4) as usize); - tauri::image::Image::new_owned(rgba, side, side) -} - -/// The tray icon this platform wants (issue #911). -/// -/// macOS gets the monochrome template glyph — the full-colour application icon -/// (`default_window_icon()`, 32/128 px) is what the menu bar rendered oversized -/// and untinted. Windows' notification area and Linux's indicators are unaffected -/// by the missing template flag, so they keep that icon. -fn tray_icon(app: &tauri::App) -> Result, String> { - #[cfg(target_os = "macos")] - { - Ok(macos_template_icon()) - } - #[cfg(not(target_os = "macos"))] - { - app.default_window_icon() - .cloned() - .ok_or_else(|| "No default icon".to_string()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - // Issue #674: the label helpers are table-driven, so the tests name the - // tables directly instead of relying on the process-wide locale. - use crate::i18n::{DE, EN, FR}; - - /// Issue #388: device menu ids must parse to stable Spotify device ids, - /// never to a list index. Pure helper — no Tauri runtime needed. - #[test] - fn parse_device_menu_id_carries_stable_id() { - assert_eq!( - parse_device_menu_id("abc123XYZ"), - DeviceMenuSelection::DeviceId("abc123XYZ".to_string()) - ); - assert_eq!( - parse_device_menu_id("0"), - DeviceMenuSelection::LegacyIndex(0) - ); - assert_eq!( - parse_device_menu_id("12"), - DeviceMenuSelection::LegacyIndex(12) - ); - assert_eq!(parse_device_menu_id("none"), DeviceMenuSelection::Invalid); - assert_eq!(parse_device_menu_id(""), DeviceMenuSelection::Invalid); - } - /// Issue #482: id-snapshot edge cases -- a reorder between menu build - /// and click must still hit the labelled device (id-keyed resolution), - /// and unknown/empty selections must resolve to Invalid, never to a - /// neighboring device by index. - #[test] - fn parse_device_menu_id_rejects_unknown_and_empty() { - // Spotify ids are opaque base62; digit-only suffixes are the legacy - // index scheme and must keep parsing as LegacyIndex, never as an id. - assert_eq!( - parse_device_menu_id("007"), - DeviceMenuSelection::LegacyIndex(7) - ); - // Mixed alphanumeric ids (even with a leading digit) are stable ids. - assert_eq!( - parse_device_menu_id("0abc"), - DeviceMenuSelection::DeviceId("0abc".to_string()) - ); - // The placeholder and the empty string are never a device. - assert_eq!(parse_device_menu_id("none"), DeviceMenuSelection::Invalid); - assert_eq!(parse_device_menu_id(""), DeviceMenuSelection::Invalid); - // Redaction helper never leaks the id itself. - let logged = selected_for_log(&DeviceMenuSelection::DeviceId("secret-id-123".to_string())); - assert!( - !logged.contains("secret-id-123"), - "device id must not appear in logs" - ); - assert!( - logged.contains("len="), - "redacted label must carry the length" - ); - } - - /// Issue #918: the dispatcher's normal log must preserve known action ids - /// while reducing payload-bearing and unknown ids to their shape. - #[test] - fn menu_event_log_redacts_payload_and_unknown_ids() { - assert_eq!(menu_event_id_for_log("play_pause"), "play_pause"); - assert_eq!(menu_event_id_for_log("about"), "about"); - - let device_id = "aB3deviceCredentialValueWithThirtyTwoChars"; - let formatted_device_id = format!("devices|{device_id}"); - let logged_device = menu_event_id_for_log(&formatted_device_id); - assert_eq!(logged_device, ""); - assert!(!logged_device.contains(device_id)); - assert!(!logged_device.contains(&format!("devices|{device_id}"))); - - let logged_unknown = menu_event_id_for_log("mystery"); - assert_eq!(logged_unknown, ""); - assert!(!logged_unknown.contains("mystery")); - } - - /// Issue #918: capture the production global-dispatcher record, then bind - /// the real handler to that seam. A raw-id call at the handler is therefore - /// a regression even when the formatter itself still behaves correctly. - #[test] - fn dispatched_menu_event_record_redacts_device_id() { - let device_id = "aB3deviceCredentialValueWithThirtyTwoChars"; - let event_id = format!("devices|{device_id}"); - let mut captured = None; - - log_dispatched_menu_event_with(&event_id, |record| { - assert_eq!(record.level(), log::Level::Info); - captured = Some(record.args().to_string()); - }); - - let record = captured.expect("the dispatcher seam must emit one record"); - assert!( - record.contains("[TRAY] menu event: id="), - "the production record shape changed: {record}" - ); - assert!(!record.contains(device_id), "device id leaked: {record}"); - assert!( - !record.contains(&event_id), - "device menu id leaked: {record}" - ); - - let mut known_record = None; - log_dispatched_menu_event_with("play_pause", |record| { - known_record = Some(record.args().to_string()); - }); - assert_eq!( - known_record.as_deref(), - Some("[TRAY] menu event: id=play_pause") - ); - - let prod = prod_source(include_str!("tray.rs")); - let dispatcher = body_of(prod, "pub fn handle_menu_event("); - assert!( - dispatcher.contains("log_dispatched_menu_event(id);"), - "handle_menu_event must emit through the captured production seam" - ); - let wrapper = body_of(prod, "fn log_dispatched_menu_event("); - assert!( - wrapper.contains("log_dispatched_menu_event_with(id,"), - "the production wrapper must delegate to the captured record seam" - ); - } - - /// Issue #388: the click handler must resolve by id with a live - /// re-fetch fallback instead of `devices.get(i)`. Issue #586: that - /// re-fetch must resolve its token through the shared refresh-aware - /// policy, so an expired token does not strand a transfer. - #[test] - fn device_click_resolves_by_id_with_live_fallback() { - let src = include_str!("tray.rs"); - let body = body_of(prod_source(src), "fn resolve_device_id("); - assert!( - body.contains("crate::spotify::get_devices"), - "resolve_device_id must live re-fetch when the cache misses" - ); - assert!( - body.contains("player_with_refresh_typed("), - "the live re-fetch must use the refresh-aware token policy (issue #586)" - ); - assert!( - !body.contains("tokens.spotify()"), - "resolve_device_id must not snapshot the raw access token (issue #586)" - ); - assert!( - !body.contains("devices.get(*i).cloned()") || body.contains("LegacyIndex"), - "index lookup must survive only on the LegacyIndex back-compat path" - ); - } - - /// Issues #383/#386: the tray Quit arm must terminate via the shared - /// graceful shutdown, and the playback/device click arms must offload - /// blocking Spotify HTTP onto worker threads. Issue #587: the Show/Hide - /// repaint rebuilds a menu that can fetch Spotify over the network, so - /// it must be offloaded too. - #[test] - fn tray_click_arms_quit_and_offload() { - let src = include_str!("tray.rs"); - let body = body_of(prod_source(src), "pub fn handle_menu_event("); - // #383: Quit terminates even with no frontend listener. - let quit_pos = body - .find("ID_QUIT =>") - .expect("handle_menu_event must handle ID_QUIT"); - let quit_tail = &body[quit_pos..quit_pos + 600.min(body.len() - quit_pos)]; - assert!( - quit_tail.contains("request_graceful_shutdown"), - "tray ID_QUIT arm must route through request_graceful_shutdown" - ); - assert!( - !quit_tail.contains("window.hide()"), - "the hide-only quit arm (issue #383) must stay gone" - ); - // #386: no blocking Spotify HTTP directly on the menu-event thread. - for marker in [ - "get_currently_playing(token, None)", - "player_pause(t, None)", - "player_play(t, None)", - "player_previous(token, None)", - "player_next(token, None)", - "player_transfer(token,", - ] { - let pos = body - .find(marker) - .unwrap_or_else(|| panic!("expected click-path marker `{}` in setup_tray", marker)); - let before = &body[..pos]; - assert!( - before.rfind("std::thread::spawn").is_some(), - "blocking call `{}` must run inside a spawned worker thread", - marker - ); - } - // #587: the Show/Hide repaint must be offloaded. `update_tray_menu` - // fetches Spotify devices/queue with a 10 s timeout once the fetch - // throttle lapses, so rebuilding inline would wedge the menu the - // same way the #386 player arms used to. - let show_pos = body - .find("ID_SHOW_HIDE =>") - .expect("handle_menu_event must handle ID_SHOW_HIDE"); - let show_end = body[show_pos..] - .find("ID_PAUSE_SYNC") - .map(|i| show_pos + i) - .unwrap_or(body.len()); - let show_arm = &body[show_pos..show_end]; - assert!( - !show_arm.contains("update_tray_menu("), - "the Show/Hide arm must not rebuild the tray menu inline (issue #587)" - ); - assert!( - show_arm.contains("refresh_tray_from_state("), - "the Show/Hide arm must repaint through the offloading refresh helper" - ); - // #588: the Pause/Resume label is repainted from backend truth after - // the frontend's asynchronous toggle, not left to the Dashboard. - let pause_pos = body - .find("ID_PAUSE_SYNC | ID_RESUME_SYNC =>") - .expect("handle_menu_event must handle ID_PAUSE_SYNC | ID_RESUME_SYNC"); - let pause_end = body[pause_pos..] - .find("ID_QUIT =>") - .map(|i| pause_pos + i) - .unwrap_or(body.len()); - let pause_arm = &body[pause_pos..pause_end]; - assert!( - pause_arm.contains("std::thread::spawn"), - "the Pause/Resume arm must repaint from a worker (issue #588)" - ); - assert!( - pause_arm.contains("await_sync_toggle("), - "the Pause/Resume arm must wait for the toggle to settle before repainting" - ); - // #586: no raw token snapshot on the click path — the refresh-aware - // policy resolves the token (and retries once on ExpiredToken). - assert!( - !show_arm.contains("tokens.spotify()"), - "the click path must not read the raw Spotify token snapshot" - ); - } - - /// Issue #804: every menu click must fire exactly once. The tray - /// builder's `on_menu_event` is a global listener (it sees window-menu - /// events too), so the `window.on_menu_event` registration lib.rs carried - /// double-fired every shared id — and tray-only ids fell into menu.rs's - /// unknown-event warn. The single dispatcher `handle_menu_event` owns - /// every tray id and delegates the app-menu-only ids to - /// `menu::handle_app_menu_event`, which keeps only those three arms. - /// Fails pre-fix with twins (and with the second lib.rs registration). - #[test] - fn menu_events_route_through_one_dispatcher() { - let tray_prod = prod_source(include_str!("tray.rs")); - let menu_prod = prod_source(include_str!("menu.rs")); - let app_prod = prod_source(include_str!("app.rs")); - // One registration: the tray builder's global listener delegating to - // the named dispatcher; no per-window handler in app.rs. - assert!( - tray_prod.contains( - ".on_menu_event(|app, event| handle_menu_event(app, event.id().as_ref()))" - ), - "setup_tray must register the single dispatcher, not an inline match" - ); - assert!( - !app_prod.contains("window.on_menu_event"), - "app.rs must not register a second menu handler (issue #804: double-fire)" - ); - let dispatcher = body_of(tray_prod, "pub fn handle_menu_event("); - // The dispatcher owns every tray-built id ... - for marker in [ - "ID_SHOW_HIDE =>", - "ID_PAUSE_SYNC | ID_RESUME_SYNC =>", - "ID_QUIT =>", - "ID_SETTINGS =>", - "ID_OPEN_LOGS =>", - "ID_PLAY_PAUSE =>", - "ID_PREVIOUS =>", - "ID_NEXT =>", - "ID_SHUFFLE =>", - "ID_REPEAT =>", - "SNOOZE_ITEM_PREFIX", - "ID_PROFILE_BASE", - "PROFILE_ITEM_PREFIX", - "ID_MANUAL_STATUS_CLEAR", - "MANUAL_STATUS_ITEM_PREFIX", - "VOLUME_ITEM_PREFIX", - "SEEK_ITEM_PREFIX", - "DEVICE_ITEM_PREFIX", - ] { - assert!( - dispatcher.contains(marker), - "handle_menu_event must own `{}`", - marker - ); - } - // ... and hands anything else to the app-menu handler. - assert!( - dispatcher.contains("crate::menu::handle_app_menu_event(app, id)"), - "handle_menu_event must delegate app-menu-only ids to menu::handle_app_menu_event" - ); - // setup_tray itself carries no match arms any more. - let setup = body_of(tray_prod, "pub fn setup_tray("); - assert!( - !setup.contains("ID_QUIT =>"), - "setup_tray must not keep a second copy of the dispatch arms" - ); - // The app-menu handler keeps only its three window-menu-only arms, so - // no id is owned twice and tray-only ids never hit its unknown warn. - let app_menu = body_of(menu_prod, "pub(crate) fn handle_app_menu_event("); - for marker in ["ID_SHOW_DASHBOARD =>", "ID_SHOW_LOGS =>", "ID_ABOUT =>"] { - assert!( - app_menu.contains(marker), - "handle_app_menu_event must keep `{}`", - marker - ); - } - // NOTE: `app_menu` is the handler body only (not the whole file), - // so these assertions cannot match the test module's own prose below. - for marker in ["ID_SETTINGS =>", "ID_OPEN_LOGS =>", "ID_QUIT =>"] { - assert!( - !app_menu.contains(marker), - "handle_app_menu_event must not twin `{}` (issue #804)", - marker - ); - } - } - - /// Production half of `src` — everything before the inline test module, - /// so a scan can never match the assertions themselves. - fn prod_source(src: &str) -> &str { - src.split("#[cfg(test)]\nmod tests") - .next() - .expect("tray.rs has no #[cfg(test)] mod tests block") - } - - /// Drops `//` line comments so a source-scan assertion is not fooled by - /// prose that quotes the very construct it forbids: a comment saying - /// "never snapshot `state.tokens.spotify()`" must not read as a - /// snapshot. String literals are not parsed, so a `//` inside a literal - /// truncates the rest of that line — that can only lose trailing text, - /// never invent it. Stripping also removes any brace a comment mentions, - /// which keeps the brace counting below from being unbalanced by prose. - fn strip_line_comments(src: &str) -> String { - src.lines() - .map(|line| match line.find("//") { - Some(i) => &line[..i], - None => line, - }) - .collect::>() - .join("\n") - } - - /// Comment-stripped, brace-counted body isolation for `sig`'s fn. - /// Order-independent: never anchor on the next fn. - fn body_of(prod: &str, sig: &str) -> String { - let stripped = strip_line_comments(prod); - let after_sig = stripped - .split(sig) - .nth(1) - .unwrap_or_else(|| panic!("tray.rs has no `{}`", sig)); - let open = after_sig - .find('{') - .unwrap_or_else(|| panic!("{} has no opening brace", sig)); - let mut depth = 0usize; - let mut end = None; - for (i, ch) in after_sig[open..].char_indices() { - match ch { - '{' => depth += 1, - '}' => { - depth -= 1; - if depth == 0 { - end = Some(open + i + 1); - break; - } - } - _ => {} - } - } - after_sig[..end.unwrap_or_else(|| panic!("{} body never closed", sig))].to_string() - } - - /// Issue #586: tray player actions used to snapshot - /// `state.tokens.spotify()` — an expired access token meant a failed - /// click with no refresh and no retry, while the refresh-aware policy in - /// `commands/playback.rs` had no callers. Both tray paths must now route - /// through that shared policy. - #[test] - fn tray_player_actions_use_refresh_aware_token() { - let src = include_str!("tray.rs"); - let prod = src - .split("#[cfg(test)]\nmod tests") - .next() - .expect("tray.rs has no #[cfg(test)] mod tests block"); - let action_body = body_of(prod, "fn run_player_action("); - assert!( - action_body.contains("player_with_refresh_typed("), - "run_player_action must route through the shared refresh-aware policy (issue #586)" - ); - assert!( - !action_body.contains("tokens.spotify()"), - "run_player_action must not snapshot the raw access token (issue #586)" - ); - let setup_body = body_of(prod, "pub fn handle_menu_event("); - let play_pos = setup_body - .find("ID_PLAY_PAUSE =>") - .expect("handle_menu_event must handle ID_PLAY_PAUSE"); - let play_end = setup_body[play_pos..] - .find("ID_PREVIOUS =>") - .map(|i| play_pos + i) - .unwrap_or(setup_body.len()); - let play_arm = &setup_body[play_pos..play_end]; - assert!( - play_arm.contains("player_with_refresh_typed("), - "the Play/Pause arm must read playback state through the refresh-aware policy (issue #586)" - ); - assert!( - !play_arm.contains("tokens.spotify()"), - "the Play/Pause arm must not snapshot the raw access token (issue #586)" - ); - } - - /// Issue #591: the tray must state whether the app is syncing instead of - /// leaving it to be inferred from the Pause/Resume verb. Issue #674: the - /// status word comes from the table the caller hands in, so the same - /// helper renders German/French without a second code path. - #[test] - fn sync_status_line_reports_backend_state_in_every_locale() { - let track = crate::spotify::TrackInfo { - title: "Track".to_string(), - artist: "Artist".to_string(), - album: "Album".to_string(), - album_art_url: String::new(), - is_playing: true, - progress_ms: None, - duration_ms: 0, - volume_percent: None, - supports_volume: None, - actions: None, - }; - assert_eq!( - sync_status_line(&EN, false, true, Some(&track)), - "Not syncing", - "a stopped poller outranks any remembered track" - ); - assert_eq!( - sync_status_line(&EN, false, false, None), - "Not syncing", - "no track and no sync is still Not syncing" - ); - assert_eq!( - sync_status_line(&EN, true, false, None), - "Syncing — no track", - "syncing with nothing playing must say so rather than claim a track" - ); - assert_eq!( - sync_status_line(&EN, true, true, Some(&track)), - "Syncing — Artist — Track" - ); - assert_eq!( - sync_status_line(&EN, true, false, Some(&track)), - "Paused — Artist — Track", - "a same-track pause is reported from the live playing state" - ); - - // Issue #674: the selected table is what the user reads — Deutsch and - // Français must not fall back to the English status words. - assert_eq!( - sync_status_line(&DE, true, true, Some(&track)), - format!("{} — Artist — Track", DE.status_syncing) - ); - assert_eq!( - sync_status_line(&DE, true, false, Some(&track)), - format!("{} — Artist — Track", DE.status_paused) - ); - assert_eq!( - sync_status_line(&DE, false, false, None), - DE.status_not_syncing - ); - assert_eq!( - sync_status_line(&FR, true, false, None), - FR.status_syncing_no_track - ); - } - - /// Issue #582: a check mark can only carry on/off, while `repeat_state` - /// has three documented values — so the label must name the mode, or a - /// user cannot tell "repeat one" from "repeat the playlist". Issue #674: - /// the label is read from the installed table. - #[test] - fn repeat_menu_label_spells_out_the_mode() { - assert_eq!(repeat_menu_label(&EN, RepeatState::Off), "Repeat: Off"); - assert_eq!( - repeat_menu_label(&EN, RepeatState::Context), - "Repeat: Context" - ); - assert_eq!(repeat_menu_label(&EN, RepeatState::Track), "Repeat: Track"); - - assert_eq!(repeat_menu_label(&DE, RepeatState::Off), "Wiederholen: Aus"); - assert_eq!( - repeat_menu_label(&DE, RepeatState::Context), - "Wiederholen: Kontext" - ); - assert_eq!( - repeat_menu_label(&DE, RepeatState::Track), - "Wiederholen: Titel" - ); - assert_eq!( - repeat_menu_label(&FR, RepeatState::Track), - "Répéter : titre" - ); - } - - /// Issue #582: the two toggles render the state the poll body reported - /// (`note_playback_modes` → the atoms the menu build reads), and the - /// click target is the documented cycle, so a successful toggle leaves - /// the item showing what the API was just told to adopt. - #[test] - fn playback_modes_feed_both_toggle_items() { - // The mode atoms are process-global and read by the rebuild path, so - // the tests that drive them must not interleave. - let _guard = MODE_ATOM_LOCK.lock(); - note_playback_modes(true, RepeatState::Track); - assert!(LAST_SHUFFLE_STATE.load(Ordering::Acquire)); - assert_eq!(last_repeat_state(), RepeatState::Track); - assert_eq!(repeat_menu_label(&EN, last_repeat_state()), "Repeat: Track"); - // The click targets: Repeat advances along the documented cycle, - // Shuffle flips whatever was last observed. - assert_eq!(last_repeat_state().next(), RepeatState::Off); - assert!(!shuffle_toggle_target( - LAST_SHUFFLE_STATE.load(Ordering::Acquire) - )); - - // A poll that reports everything off must clear both items. - note_playback_modes(false, RepeatState::Off); - assert!(!LAST_SHUFFLE_STATE.load(Ordering::Acquire)); - assert_eq!(last_repeat_state(), RepeatState::Off); - assert!(!last_repeat_state().is_on()); - } - - /// Serialises the tests that drive the process-global playback-mode - /// atoms, which the rebuild path also reads. - static MODE_ATOM_LOCK: parking_lot::Mutex<()> = parking_lot::Mutex::new(()); - - /// Issue #691: the dedup key is built by the same function the rebuild - /// path uses, and it carries both playback modes — so a shuffle/repeat - /// change made in another Spotify client forces the repaint that updates - /// the check marks (and the next click toggles from the fresh belief), - /// while an unchanged poll still dedupes to a no-op. - #[test] - fn mode_change_forces_a_tray_rebuild() { - let _guard = MODE_ATOM_LOCK.lock(); - let track = |is_playing: bool| crate::spotify::TrackInfo { - title: "Title".to_string(), - artist: "Artist".to_string(), - album: "Album".to_string(), - album_art_url: String::new(), - is_playing, - progress_ms: None, - duration_ms: 0, - volume_percent: None, - supports_volume: None, - actions: None, - }; - let key = |sync: bool, visible: bool| { - tray_snapshot_for(sync, visible, Some(&track(true)), None, 0, 0, None) - }; - - note_playback_modes(false, RepeatState::Off); - let base = key(true, true); - - // The poll body reports a shuffle the user toggled in the Spotify app. - note_playback_modes(true, RepeatState::Off); - let shuffled = key(true, true); - assert!( - tray_state_changed(Some(&base), &shuffled), - "an external shuffle change must repaint the tray (issue #691)" - ); - - // …and a repeat-mode change. - note_playback_modes(true, RepeatState::Context); - let repeated = key(true, true); - assert!( - tray_state_changed(Some(&shuffled), &repeated), - "an external repeat change must repaint the tray (issue #691)" - ); - - // An unchanged poll is still a no-op. - assert!( - !tray_state_changed(Some(&repeated), &key(true, true)), - "an unchanged poll must not rebuild the menu" - ); - assert!( - tray_state_changed(None, &repeated), - "the first call has no snapshot and must always rebuild" - ); - - // The pre-existing parts of the key still repaint. - assert!( - tray_state_changed(Some(&repeated), &key(false, true)), - "a sync toggle must still repaint (issue #71)" - ); - assert!( - tray_state_changed(Some(&repeated), &key(true, false)), - "a Show/Hide click must still repaint (issue #71)" - ); - - // A same-track pause lives in the track half of the key (issue #229). - let paused = tray_snapshot_for(true, true, Some(&track(false)), None, 0, 0, None); - assert!( - tray_state_changed(Some(&repeated), &paused), - "a same-track pause must still repaint the Play/Pause mark (#229)" - ); - - // Leave the shared atoms as a fresh poll would find them. - note_playback_modes(false, RepeatState::Off); - } - - /// Issue #689 (D6, tray half): the poller emits `playback-state-changed` - /// when a track's playing state changes without the track itself - /// changing. Consuming it moves the Play/Pause mark and the status line - /// off "playing" — the tray used to keep claiming the stale state until - /// the next track emerged. - #[test] - fn playback_state_event_moves_the_play_pause_mark() { - let track = crate::spotify::TrackInfo { - title: "Track".to_string(), - artist: "Artist".to_string(), - album: "Album".to_string(), - album_art_url: String::new(), - is_playing: true, - progress_ms: None, - duration_ms: 0, - volume_percent: None, - supports_volume: None, - actions: None, - }; - let mark = |playing: bool| sync_status_line(&EN, true, playing, Some(&track)); - - note_playing_state(true); - assert_eq!( - mark(LAST_PLAYING_STATE.load(Ordering::Acquire)), - "Syncing — Artist — Track" - ); - - // The exact payload shape the poller emits for a same-track pause. - consume_playback_state_changed(r#"{"is_playing":false,"track_key":"Artist|Title"}"#); - assert!( - !LAST_PLAYING_STATE.load(Ordering::Acquire), - "the tray must believe a same-track pause (issue #689)" - ); - assert_eq!( - mark(LAST_PLAYING_STATE.load(Ordering::Acquire)), - "Paused — Artist — Track" - ); - - consume_playback_state_changed(r#"{"is_playing":true,"track_key":"Artist|Title"}"#); - assert!(LAST_PLAYING_STATE.load(Ordering::Acquire)); - assert_eq!( - mark(LAST_PLAYING_STATE.load(Ordering::Acquire)), - "Syncing — Artist — Track" - ); - - // A payload the tray cannot parse must keep the last known state - // rather than invent one. - consume_playback_state_changed("not json"); - assert!( - LAST_PLAYING_STATE.load(Ordering::Acquire), - "an unparsable payload must not clobber the last playing state" - ); - } - - /// Issue #689 (D6): the tray's belief only moves if the poller's - /// `playback-state-changed` event is actually subscribed. A mistyped or - /// deleted `app.listen` would leave the functional test above green — it - /// calls the consumer directly — while the running app ignored the event. - /// Guards the registration inside `setup_tray`, like the click-arm scans. - #[test] - fn setup_tray_subscribes_to_playback_state_changes() { - let src = include_str!("tray.rs"); - let body = body_of(prod_source(src), "pub fn setup_tray("); - assert!( - body.contains("app.listen(\"playback-state-changed\""), - "setup_tray must subscribe to the poller's playback-state-changed event (issue #689)" - ); - assert!( - body.contains("consume_playback_state_changed(event.payload())"), - "the subscription must hand the payload to the tray's consumer" - ); - } - - // ----------------------------------------------------------------------- - // S9 (issue #677): the snooze submenu, the countdown status line and the - // snooze-aware dedup key / fetch mode. - // ----------------------------------------------------------------------- - - /// A config carrying `snooze_until` as stored. - fn snoozed_config(stored: &str) -> std::sync::Arc { - std::sync::Arc::new(crate::config::AppConfig { - snooze_until: Some(stored.to_string()), - ..crate::config::AppConfig::default() - }) - } - - /// A deadline `seconds` from now, in the stored spelling. - fn deadline_in(seconds: i64) -> String { - crate::config::snooze_store_form(chrono::Utc::now() + chrono::TimeDelta::seconds(seconds)) - } - - /// Every id the submenu can mint maps to exactly one action, and anything - /// else is refused — a stale menu from an older build must not snooze. - #[test] - fn snooze_menu_ids_parse_to_their_action() { - use crate::config::SnoozePreset; - assert_eq!( - parse_snooze_menu_id(ID_SNOOZE_30), - Some(SnoozeMenuSelection::Preset(SnoozePreset::ThirtyMinutes)) - ); - assert_eq!( - parse_snooze_menu_id(ID_SNOOZE_1H), - Some(SnoozeMenuSelection::Preset(SnoozePreset::OneHour)) - ); - assert_eq!( - parse_snooze_menu_id(ID_SNOOZE_TOMORROW), - Some(SnoozeMenuSelection::Preset(SnoozePreset::UntilTomorrow)) - ); - // Issue #867: the meeting-bound preset id parses to the new variant. - assert_eq!( - parse_snooze_menu_id(ID_SNOOZE_NEXT_MEETING), - Some(SnoozeMenuSelection::Preset( - SnoozePreset::UntilNextMeetingEnds - )) - ); - assert_eq!( - parse_snooze_menu_id(ID_SNOOZE_RESUME), - Some(SnoozeMenuSelection::Resume) - ); - // The prefix alone, an unknown suffix and a foreign id all resolve to - // nothing rather than to a default preset. - for unknown in [ - SNOOZE_ITEM_PREFIX, - "snooze|2h", - "play_pause", - "devices|abc", - "", - ] { - assert_eq!(parse_snooze_menu_id(unknown), None, "id {:?}", unknown); - } - } - - /// The status line states the remaining time and the local deadline, in - /// every locale (issue #677 + the #674 table contract). - #[test] - fn snooze_status_line_counts_down_in_every_locale() { - let snooze = TraySnooze { - status: crate::config::SnoozeStatus { - // A fixed instant so the rendered `HH:MM` is stable; the local - // hour depends on the runner's zone, so it is rebuilt here from - // the same instant rather than hard-coded. - deadline: chrono::Utc::now() + chrono::TimeDelta::minutes(30), - remaining_seconds: 30 * 60, - }, - minutes_left: 30, - }; - let local = snooze - .status - .deadline - .with_timezone(&chrono::Local) - .format("%H:%M") - .to_string(); - - assert_eq!( - snooze_status_line(&EN, &snooze), - format!("Snoozed — 30 min left (→ {})", local) - ); - assert_eq!( - snooze_status_line(&DE, &snooze), - format!("Sync pausiert — 30 Min. verbleibend (→ {})", local) - ); - assert_eq!( - snooze_status_line(&FR, &snooze), - format!("Synchro en pause — 30 min restant (→ {})", local) - ); - // The countdown is the ONLY thing the line says — a snoozed session is - // not syncing, so it must not name a track. - assert!(!snooze_status_line(&EN, &snooze).contains("Syncing")); - } - - /// A snooze is resolved from the stored value alone, and everything that is - /// not a live deadline resolves to "not snoozed" — which is what keeps the - /// tray and the poller agreeing without a second source of truth. - #[test] - fn resolve_snooze_accepts_only_a_live_deadline() { - assert!(resolve_snooze(None).is_none(), "no config loaded"); - assert!(resolve_snooze(Some(&std::sync::Arc::new( - crate::config::AppConfig::default() - ))) - .is_none()); - assert!(resolve_snooze(Some(&snoozed_config(&deadline_in(-60)))).is_none()); - assert!(resolve_snooze(Some(&snoozed_config("not a timestamp"))).is_none()); - - let live = resolve_snooze(Some(&snoozed_config(&deadline_in(30 * 60)))) - .expect("a future deadline is an active snooze"); - assert_eq!(live.minutes_left, 30, "a fresh 30-minute snooze reads 30"); - assert!(live.status.remaining_seconds > 0); - } - - /// The dedup key carries the snooze, so a snooze that starts, ends or ticks - /// over to the next minute repaints instead of early-returning as a no-op - /// (the countdown would otherwise freeze at the minute the menu was built). - #[test] - fn snooze_change_forces_a_tray_rebuild() { - let _guard = MODE_ATOM_LOCK.lock(); - note_playback_modes(false, RepeatState::Off); - let track = crate::spotify::TrackInfo { - title: "Title".to_string(), - artist: "Artist".to_string(), - album: "Album".to_string(), - album_art_url: String::new(), - is_playing: true, - progress_ms: None, - duration_ms: 0, - volume_percent: None, - supports_volume: None, - actions: None, - }; - let at = |snooze: Option| { - tray_snapshot_for(true, true, Some(&track), snooze, 0, 0, None) - }; - - let none = at(None); - let snoozed = at(Some(snooze_dedup_key(&crate::config::SnoozeStatus { - deadline: chrono::Utc::now() + chrono::TimeDelta::minutes(30), - remaining_seconds: 30 * 60, - }))); - assert!( - tray_state_changed(Some(&none), &snoozed), - "starting a snooze must repaint (issue #677)" - ); - assert!( - tray_state_changed(Some(&snoozed), &none), - "ending a snooze must repaint (issue #677)" - ); - - // The minute bucket is part of the key: same deadline, next minute. - let deadline = chrono::Utc::now() + chrono::TimeDelta::minutes(30); - let minute_29 = Some(snooze_dedup_key(&crate::config::SnoozeStatus { - deadline, - remaining_seconds: 29 * 60, - })); - let minute_30 = Some(snooze_dedup_key(&crate::config::SnoozeStatus { - deadline, - remaining_seconds: 30 * 60, - })); - assert!( - tray_state_changed(Some(&at(minute_30)), &at(minute_29.clone())), - "the countdown must move once a minute, not once per snooze" - ); - assert!( - !tray_state_changed(Some(&at(minute_29.clone())), &at(minute_29)), - "a rebuild with nothing changed still dedupes" - ); - } - - /// A snooze means "no Spotify request", and the tray's own rebuild is one - /// of the two things that still runs while snoozed — so its fetch mode must - /// follow the snooze rather than the caller. - #[test] - fn snooze_makes_the_tray_rebuild_cache_only() { - assert_eq!(tray_fetch_mode(true), TrayFetch::CacheOnly); - assert_eq!(tray_fetch_mode(false), TrayFetch::Refresh); - - // Structural: the rebuild derives its fetch choice from the snooze and - // hands it to BOTH submenu sources — a literal `Refresh` in either call - // would re-fetch devices/queue every throttle window while snoozed. - let prod = prod_source(include_str!("tray.rs")); - let body = body_of(prod, "fn rebuild_tray_menu("); - assert!( - body.contains("paint_fetch_mode(paint, snooze.is_some(), action_refresh_due)"), - "the rebuild's fetch mode must come from the paint and the snooze" - ); - // …and the snooze rule itself is unchanged: an ordinary rebuild while - // snoozed is cache-only (issue #677), while the startup paint is - // cache-only whatever the snooze says (issue #882). - let decider = body_of(prod, "fn paint_fetch_mode("); - assert!( - decider.contains("tray_fetch_mode(snoozed)"), - "an ordinary rebuild must take its fetch mode from the snooze (issue #677)" - ); - for call in [ - "devices_for_menu(access_token.as_deref(), fetch)", - "queue_for_menu(access_token.as_deref(), fetch)", - ] { - assert!( - body.contains(call), - "`{}` must honour the snooze-aware fetch mode", - call - ); - } - assert!( - !body.contains("cached_devices(") && !body.contains("cached_queue("), - "the rebuild must go through the fetch-mode helpers, not the raw fetchers" - ); - } - - /// The snooze must be resolved BEFORE the dedup comparison — a key computed - /// after the early return would never see it. - #[test] - fn snooze_is_resolved_before_the_dedup_guard() { - let prod = prod_source(include_str!("tray.rs")); - let body = body_of(prod, "fn rebuild_tray_menu("); - // Issue #886: the snooze comes from a scoped read of the mounted config - // (`snooze_from_app_state`) instead of a whole-`AppConfig` clone. - let resolve = body - .find("snooze_from_app_state(") - .expect("update_tray_menu must resolve the snooze"); - let snapshot = body - .find("tray_snapshot_for(") - .expect("update_tray_menu must build the dedup key"); - let guard = body - .find("tray_state_changed(") - .expect("update_tray_menu must keep the dedup guard"); - assert!( - resolve < snapshot && snapshot < guard, - "the snooze must be part of the dedup key, evaluated before the guard" - ); - assert!( - body.contains("snooze_key"), - "the dedup key must carry the snooze (deadline + minute bucket)" - ); - } - - /// A snooze click writes `config.json`, so it must happen off the - /// menu-event thread, and the repaint must follow the write. - #[test] - fn snooze_click_arms_write_off_thread_and_repaint() { - let prod = prod_source(include_str!("tray.rs")); - let body = body_of(prod, "pub fn handle_menu_event("); - let arm_pos = body - .find("id if id.starts_with(SNOOZE_ITEM_PREFIX)") - .expect("handle_menu_event must handle the snooze submenu ids"); - let arm_end = body[arm_pos..] - .find("id if id.starts_with(DEVICE_ITEM_PREFIX)") - .map(|i| arm_pos + i) - .unwrap_or(body.len()); - let arm = &body[arm_pos..arm_end]; - assert!( - arm.contains("std::thread::spawn"), - "the snooze arm must not write config.json on the menu-event thread" - ); - assert!( - arm.contains("write_snooze("), - "the snooze arm must go through the config-writing helper" - ); - assert!( - arm.contains("repaint_tray_from_state("), - "the snooze arm must repaint after the write" - ); - // The worker must own the id: the menu-event borrow cannot outlive it - // (a `&str` borrowed from `event` fails to compile in the spawn). - assert!( - arm.contains("id.to_string()"), - "the snooze arm must copy the id before spawning the worker" - ); - assert!( - !arm.contains("tokens.spotify()"), - "the snooze arm must not touch the Spotify token path" - ); - } - - /// The tray's snooze writer follows the same store-what-was-persisted - /// discipline as `commands::config::update_config` (issues #297 / #536) — an - /// in-memory value that disagrees with disk would show a countdown for a - /// snooze the next launch does not honour. - #[test] - fn store_snooze_persists_the_clamped_stamped_value() { - let prod = prod_source(include_str!("tray.rs")); - let body = body_of(prod, "fn store_snooze("); - for marker in [ - "state.config.get_mut()", - "crate::config::clamped_config(&next)", - "crate::config::stamp_schema_version(&mut persisted)", - "crate::config::save_config(&persisted)", - "*guard = Some(std::sync::Arc::new(persisted))", - ] { - assert!( - body.contains(marker), - "store_snooze must contain `{}`", - marker - ); - } - // A failed write must leave the in-memory config alone, so the tray - // never renders a snooze that is not on disk. - assert!( - body.find("save_config(&persisted)?").unwrap_or(usize::MAX) - < body - .find("*guard = Some(std::sync::Arc::new(persisted))") - .unwrap_or(0), - "the store must happen only after a successful save" - ); - // The write half holds no log lines: the copy belongs to the caller, so - // the startup cleanup cannot masquerade as a user resume. - assert!( - !body.contains("log::info!"), - "store_snooze must not log — the caller owns the message" - ); - } - - /// The tray must log what it did: the deadline on the way in and the - /// explicit resume on the way out (issue #677's logging contract). - #[test] - fn write_snooze_logs_both_edges() { - let prod = prod_source(include_str!("tray.rs")); - let body = body_of(prod, "fn write_snooze("); - assert!( - body.contains("store_snooze(app, until)?"), - "write_snooze must be the logging wrapper around the writer" - ); - assert!( - body.contains("[TRAY] snooze: polling paused until"), - "the snooze start must be logged with its deadline" - ); - assert!( - body.contains("[TRAY] snooze: resumed by the user"), - "the manual resume must be logged" - ); - } - - /// Issue #869: the tray's profile picker mirrors `write_snooze`'s - /// discipline — a write helper that takes the logger's copy and a - /// store helper that holds the config write guard across the atomic - /// save. Splitting the two means the same guarded path can be reused - /// by the CLI without re-doing the lock dance. - #[test] - fn write_active_profile_uses_the_guarded_writer_and_logs_both_edges() { - let prod = prod_source(include_str!("tray.rs")); - let body = body_of(prod, "fn write_active_profile("); - assert!( - body.contains("store_active_profile(app, name.clone())?"), - "write_active_profile must be the logging wrapper around the writer" - ); - assert!( - body.contains("[TRAY] profile: active profile is now"), - "an active profile switch must be logged" - ); - assert!( - body.contains("[TRAY] profile: cleared — using base configuration"), - "clearing the active profile must be logged with its own copy" - ); - - let store = body_of(prod, "fn store_active_profile("); - for marker in [ - "state.config.get_mut()", - "crate::config::clamped_config(&next)", - "crate::config::stamp_schema_version(&mut persisted)", - "crate::config::save_config(&persisted)?", - "*guard = Some(std::sync::Arc::new(persisted))", - ] { - assert!( - store.contains(marker), - "store_active_profile must contain `{}`", - marker - ); - } - } - - /// Issue #869: the menu build wires the profile submenu next to the - /// snooze items and the click handler dispatches both the base - /// sentinel and the per-profile prefixed ids through the same off- - /// thread writer. - #[test] - fn profile_submenu_is_in_the_main_menu_and_its_click_arm_is_off_thread() { - let prod = prod_source(include_str!("tray.rs")); - let build_profile = body_of(prod, "fn build_profile_submenu("); - assert!( - build_profile.contains("ID_PROFILE_BASE"), - "the picker must carry the base sentinel id" - ); - assert!( - build_profile.contains("PROFILE_ITEM_PREFIX"), - "the picker must prefix per-profile ids" - ); - // The dedup key carries the active id, so a profile switch repaints. - let snapshot = body_of(prod, "fn tray_snapshot_for("); - assert!( - snapshot.contains("active_profile_key"), - "the dedup key must carry the active profile id so a switch repaints" - ); - // The click handler validates the picked name and offloads the write. - let setup = body_of(prod, "pub fn handle_menu_event("); - assert!( - setup.contains("ID_PROFILE_BASE") && setup.contains("PROFILE_ITEM_PREFIX"), - "the click dispatcher must handle both the base sentinel and per-profile ids" - ); - assert!( - setup.contains("write_active_profile(&app_handle, target)"), - "the click handler must call the write helper, never inline a config write" - ); - assert!( - setup.contains("repaint_tray_from_state(&app_handle, \"profile\")"), - "the click handler must repaint after the write so the checkmark moves" - ); - // Wired into the menu build. - let rebuild = body_of(prod, "fn rebuild_tray_menu("); - assert!( - rebuild.contains("build_profile_submenu(app, s, &profiles, active.as_deref())"), - "rebuild_tray_menu must build the profile submenu" - ); - assert!( - rebuild.contains("&profile_submenu,"), - "rebuild_tray_menu must add the profile submenu to the main menu" - ); - } - - /// A deadline that expired while the app was closed is cleared from - /// `config.json` at startup, so the clamp log line cannot repeat on every - /// launch and the persisted document matches what the app honours. - #[test] - fn startup_cleanup_clears_an_expired_deadline_through_the_guarded_writer() { - let prod = prod_source(include_str!("tray.rs")); - let body = body_of(prod, "fn clear_expired_snooze_at_startup("); - assert!( - body.contains("crate::config::snooze_expired_deadline(cfg"), - "the cleaner must ask the shared predicate whether the deadline is dead" - ); - assert!( - body.contains("store_snooze(app, None)"), - "the cleaner must go through the guarded writer, not write the file itself" - ); - assert!( - body.contains("cleared the expired deadline left by the previous session"), - "the cleanup must be logged, and with its own copy" - ); - // Wired into startup: a cleaner nothing calls would leave the stale - // deadline on disk forever. - let setup = body_of(prod, "pub fn setup_tray("); - assert!( - setup.contains("clear_expired_snooze_at_startup(app.handle())"), - "setup_tray must run the cleanup once, before the first menu build" - ); - let cleanup = setup - .find("clear_expired_snooze_at_startup(") - .expect("call site"); - let menu = setup.find("update_tray_menu_startup(").expect("tray paint"); - assert!( - cleanup < menu, - "the cleanup must precede the first menu build, so the tray never \ - renders a state the config no longer holds" - ); - } - - /// Issue #882: `setup_tray` runs on the main thread inside Tauri's - /// `setup()`, before the event loop exists, so its paint must not perform - /// the devices/queue GETs (10 s timeout each, with no window on screen to - /// explain the wait). It renders the throttled caches and hands the real - /// fetch to the worker refresh. - #[test] - fn startup_paint_is_cache_only_and_fetches_nothing() { - // The decision itself: the startup paint renders the caches whether or - // not a snooze is active, while an ordinary rebuild still follows the - // snooze rule (issue #677). - assert_eq!( - paint_fetch_mode(TrayPaint::Startup, false, false), - TrayFetch::CacheOnly - ); - assert_eq!( - paint_fetch_mode(TrayPaint::Startup, true, false), - TrayFetch::CacheOnly - ); - assert_eq!( - paint_fetch_mode(TrayPaint::Deduped, false, false), - TrayFetch::Refresh - ); - assert_eq!( - paint_fetch_mode(TrayPaint::Deduped, true, false), - TrayFetch::CacheOnly - ); - // Issue #883: an action makes one rebuild bypass the throttle — unless - // a snooze is active, which outranks it (issue #677). - assert_eq!( - paint_fetch_mode(TrayPaint::Deduped, false, true), - TrayFetch::RefreshNow - ); - assert_eq!( - paint_fetch_mode(TrayPaint::Deduped, true, true), - TrayFetch::CacheOnly - ); - - // The wiring: setup_tray must paint through the cache-only entry point - // and must not run a fetching rebuild inline, and the devices/queue - // fetch must be handed to the off-thread refresh instead. - let prod = prod_source(include_str!("tray.rs")); - let setup = body_of(prod, "pub fn setup_tray("); - assert!( - setup.contains("update_tray_menu_startup("), - "setup_tray must paint through the cache-only entry point (issue #882)" - ); - assert!( - !setup.contains("update_tray_menu(app.handle()"), - "setup_tray must not run a fetching rebuild on the setup thread" - ); - assert!( - setup.contains("refresh_tray_from_state(app.handle())"), - "the startup paint must hand the real fetch to the worker refresh" - ); - // The startup paint must not record the dedup snapshot: it renders the - // caches only, so recording it would make the first honest rebuild — - // the worker refresh just spawned — look like a no-op and leave the - // Devices/Up Next submenus empty. - let rebuild = body_of(prod, "fn rebuild_tray_menu("); - assert!( - rebuild.contains("if paint == TrayPaint::Deduped"), - "only an ordinary rebuild may commit the dedup snapshot (issue #882)" - ); - } - - /// Issue #805: the dedup key carries the throttle bucket of both caches. - /// Without it the early return also skipped the devices/queue fetches, so - /// a session where nothing else moved showed whatever the last rebuild had - /// rendered — possibly hours old, listing devices that had long since - /// disconnected. The bucket makes a quiet session repaint exactly once per - /// throttle window while every rebuild inside the window still dedupes. - #[test] - fn stale_caches_force_a_tray_rebuild_once_per_throttle_window() { - // The bucket is a pure function of the cache's age, so the window - // boundary is asserted without sleeping. - assert_eq!( - throttle_bucket(None, TRAY_SPOTIFY_FETCH_THROTTLE), - 0, - "an empty cache must not force a rebuild on its own" - ); - assert_eq!( - throttle_bucket(Some(Instant::now()), TRAY_SPOTIFY_FETCH_THROTTLE), - 0 - ); - assert_eq!( - throttle_bucket( - Some(Instant::now() - TRAY_SPOTIFY_FETCH_THROTTLE), - TRAY_SPOTIFY_FETCH_THROTTLE - ), - 1, - "a cache exactly one window old is due for a re-fetch" - ); - assert_eq!( - throttle_bucket( - Some(Instant::now() - 10 * TRAY_SPOTIFY_FETCH_THROTTLE), - TRAY_SPOTIFY_FETCH_THROTTLE - ), - 10 - ); - - let _guard = MODE_ATOM_LOCK.lock(); - note_playback_modes(false, RepeatState::Off); - let track = crate::spotify::TrackInfo { - title: "Title".to_string(), - artist: "Artist".to_string(), - album: "Album".to_string(), - album_art_url: String::new(), - is_playing: true, - progress_ms: None, - duration_ms: 0, - volume_percent: None, - supports_volume: None, - actions: None, - }; - let at = |devices: u64, queue: u64| { - tray_snapshot_for(true, true, Some(&track), None, devices, queue, None) - }; - - // Identical track, window, modes and caches: still a no-op. - assert!( - !tray_state_changed(Some(&at(0, 0)), &at(0, 0)), - "a rebuild inside the throttle window must still dedupe" - ); - // One window later either cache is due, and that must repaint — the - // repaint is what re-runs the fetchers. - assert!( - tray_state_changed(Some(&at(0, 0)), &at(1, 0)), - "a devices cache one throttle window old must repaint (issue #805)" - ); - assert!( - tray_state_changed(Some(&at(0, 0)), &at(0, 1)), - "a queue cache one throttle window old must repaint (issue #805)" - ); - assert!( - !tray_state_changed(Some(&at(1, 1)), &at(1, 1)), - "the bucket alone must not make every rebuild a repaint" - ); - } - - /// Issue #956: one rebuild described two different playing states. The - /// status line and the Play/Pause check mark read the rebuild's own - /// `is_playing`, while the now-playing row and the tooltip glyph read the - /// poller's stored `TrackInfo` — which a tray pause does not re-store. So a - /// pause left the row naming a track that was no longer playing and the - /// tooltip claiming ▶ over a menu that said paused, until the next poll. - #[test] - fn now_playing_row_and_tooltip_follow_the_rebuilds_playing_state() { - let prod = prod_source(include_str!("tray.rs")); - let body = body_of(prod, "fn rebuild_tray_menu("); - - // The now-playing row: the gate is the shared binding, placed before - // the row is built, and nothing in the row re-reads a stored flag. - let gate = body - .find("if is_playing {") - .expect("the now-playing row must be gated on the playing state"); - let row_pos = body - .find("ID_CURRENT_TRACK") - .expect("the rebuild must build the now-playing row"); - let row_end = body[row_pos..] - .find("open_settings") - .map(|i| row_pos + i) - .unwrap_or(body.len()); - let row = &body[row_pos..row_end]; - assert!( - gate < row_pos, - "the now-playing row must be gated on the rebuild's playing state (issue #956)" - ); - assert!( - !row.contains(".is_playing"), - "the now-playing row must not re-read the stored track's flag (issue #956)" - ); - - // The tooltip glyph: same binding, so a hover agrees with the menu. - let tip_pos = body - .find("let track_tooltip") - .expect("the rebuild must build the tooltip"); - let tip = &body[tip_pos..]; - assert!( - tip.contains("if is_playing { \"▶\" } else { \"⏸\" }"), - "the tooltip glyph must come from the rebuild's playing state (issue #956)" - ); - assert!( - !tip.contains(".is_playing"), - "the tooltip must not read the stored track's playing flag (issue #956)" - ); - } - - /// Issue #971: on Linux Tauri supports neither tray click events nor - /// `set_tooltip`, so the status summary — which the tooltip carries on - /// Windows and macOS — must ride as the AppIndicator's title there, or the - /// sync state, the current track and the snooze countdown are only visible - /// after opening the menu. - #[test] - fn linux_tray_title_carries_the_status_line() { - let prod = prod_source(include_str!("tray.rs")); - let body = body_of(prod, "fn rebuild_tray_menu("); - let tooltip_pos = body - .find("tray.set_tooltip(") - .expect("the rebuild must still write the tooltip"); - let after = &body[tooltip_pos..]; - assert!( - after.contains("#[cfg(target_os = \"linux\")]"), - "the title mirror must be Linux-only (issue #971)" - ); - assert!( - after.contains("tray.set_title(Some(status_line.clone()))"), - "the Linux title must carry the status line the tooltip carries (issue #971)" - ); - - // The build site names the behaviours that are inert on Linux: the - // left-click flag, the click handler it belongs to, and the tooltip - // above. The flag itself must stay (it is what Windows/macOS need). - let setup = body_of(prod, "pub fn setup_tray("); - assert!( - setup.contains(".show_menu_on_left_click(false)"), - "the tray must keep the documented left-click behaviour (issue #971)" - ); - assert!( - setup.contains(".on_tray_icon_event(|tray, event|"), - "the click handler must stay for Windows and macOS (issue #971)" - ); - } - - /// Issue #883: every tray player action used to empty both throttled caches, - /// so each click paid two fresh Spotify fetches and the 60 s throttle that - /// protects the API was bypassed entirely. The action is now recorded and the - /// rebuild re-fetches under a short minimum interval, which a burst of clicks - /// shares instead of multiplying. - #[test] - fn post_action_refresh_coalesces_a_burst_of_clicks() { - let min = TRAY_POST_ACTION_FETCH_MIN; - let t0 = Instant::now(); - let click = t0 + Duration::from_millis(10); - - // No action: the ordinary throttle decides. - assert!(!action_fetch_due(None, None, t0, min)); - // An action nothing has fetched for yet is due immediately, and stays - // due until a fetch is issued on its behalf. - assert!(action_fetch_due(Some(t0), None, t0, min)); - assert!(action_fetch_due(Some(click), None, click, min)); - - // The pair of requests that action asked for, recorded as it is issued. - let fetched = click + Duration::from_millis(1); - assert!( - !action_fetch_due(Some(click), Some(fetched), fetched, min), - "the fetch already in flight covers the action that asked for it" - ); - - // Ten rapid clicks inside the interval share that single pair. - let mut pairs = 1; - for n in 1..10u64 { - let at = t0 + Duration::from_millis(n * 300); - if action_fetch_due(Some(at), Some(fetched), at + Duration::from_millis(1), min) { - pairs += 1; - } - } - assert_eq!( - pairs, 1, - "ten clicks inside the interval must share one devices+queue pair (issue #883)" - ); - - // A click that lands after the interval is worth a fresh pair… - assert!(action_fetch_due( - Some(t0 + Duration::from_secs(30)), - Some(fetched), - fetched + min, - min - )); - // …while a fetch that already followed the action is not repeated. - assert!(!action_fetch_due( - Some(click), - Some(fetched), - fetched + min, - min - )); - - // The wiring: the caches must no longer be emptied on a player action, - // and the rebuild must pass the action's fetch mode to both submenu - // sources (it marks the fetch in flight before issuing it). - let prod = prod_source(include_str!("tray.rs")); - let force = body_of(prod, "fn force_tray_refresh("); - assert!( - force.contains("LAST_TRAY_ACTION.lock() = Some(Instant::now())"), - "a player action must be recorded, not enforced by emptying the caches" - ); - assert!( - !force.contains("DEVICES_CACHE.lock() = None") - && !force.contains("QUEUE_CACHE.lock() = None"), - "the caches must stay: emptying them bypassed the fetch throttle (issue #883)" - ); - let body = body_of(prod, "fn rebuild_tray_menu("); - let mark = body - .find("LAST_ACTION_FETCH.lock() = Some(Instant::now())") - .expect("a post-action rebuild must mark its fetch in flight"); - let devices = body - .find("devices_for_menu(access_token.as_deref(), fetch)") - .expect("the rebuild must build the devices submenu from the fetch mode"); - assert!( - mark < devices, - "the in-flight mark must be recorded before the requests run (issue #883)" - ); - } - - /// Issue #886: the poll loop calls the rebuild on every iteration and the - /// dedup key discards most of those calls — but only after a window query - /// that blocks the calling thread on an event-loop reply and a clone of the - /// whole `AppConfig`. The key now reads a visibility mirror and a scoped - /// snooze read, and the real query sits below the early return. - #[test] - fn discarded_rebuilds_query_neither_the_window_nor_a_config_clone() { - // The mirror is the key's source and round-trips. - note_window_visibility(true); - assert!(window_visible(), "the mirror must report what was recorded"); - note_window_visibility(false); - assert!(!window_visible()); - - let prod = prod_source(include_str!("tray.rs")); - let body = body_of(prod, "fn rebuild_tray_menu("); - let guard = body - .find("tray_state_changed(") - .expect("the rebuild must keep the dedup guard"); - let mirror = body - .find("window_visible()") - .expect("the dedup key must read the visibility mirror (issue #886)"); - assert!( - mirror < guard, - "the key must be built from the mirror, so a discarded rebuild costs no hop" - ); - // The real query, and anything else that can block, sits below it. - let live = body - .find("live_window_visible(") - .expect("a repaint must still render the real window state (issue #886)"); - assert!( - live > guard, - "the visibility query belongs below the dedup early-return (issue #886)" - ); - assert!( - !body.contains("is_visible()"), - "the rebuild itself must not query the event loop (issue #886)" - ); - assert!( - !body.contains("config.get().clone()"), - "the snooze must not cost a whole-AppConfig clone per poll (issue #886)" - ); - assert!( - body.contains("snooze_from_app_state("), - "the snooze must come from the scoped read (issue #886)" - ); - // The same scoped read replaces the clone on the forced path. - let force = body_of(prod, "fn force_tray_refresh("); - assert!( - !force.contains("config.get().clone()"), - "force_tray_refresh must not clone the config either (issue #886)" - ); - assert!( - force.contains("snooze_from_app_state("), - "force_tray_refresh must use the scoped snooze read (issue #886)" - ); - } - - /// Issue #927: on a host where neither appindicator soname resolves, the tray - /// build panics inside `libappindicator-sys` (its `Lazy` dlopens - /// both and panics when neither is there). `setup_tray` is written to fail as - /// `Result` — lib.rs logs the error and carries on without a tray — so the - /// panic has to arrive as that error, and `tray_available()` is what the - /// close-to-tray guard reads. - /// - /// The panic message this test provokes is expected output. - #[test] - fn a_missing_tray_library_is_an_error_not_a_panic() { - let err = guard_tray_panic("tray icon", || -> Result<(), String> { - panic!("libayatana-appindicator3.so.1: cannot open shared object file") - }) - .expect_err("a panicking tray build must be reported as an error"); - assert!( - err.contains("libayatana-appindicator3.so.1"), - "the error must name the reason, got: {}", - err - ); - assert!(err.contains("tray icon"), "the error must name what failed"); - - // A normal failure passes through untouched, and a success returns its - // value — the guard must not swallow either. - assert_eq!( - guard_tray_panic("tray icon", || Err::<(), String>("nope".to_string())), - Err("nope".to_string()) - ); - assert_eq!(guard_tray_panic("tray icon", || Ok(7)), Ok(7)); - - // The wiring: the build is the guarded call, and the close-to-tray guard - // has an accessor to gate on (lib.rs owns that call site). - let prod = prod_source(include_str!("tray.rs")); - let setup = body_of(prod, "pub fn setup_tray("); - assert!( - setup.contains("guard_tray_panic(") && setup.contains("\"tray icon\""), - "the tray build must be guarded (issue #927)" - ); - assert!( - setup.contains("builder.build(app)"), - "the guarded call must be the tray build itself" - ); - assert!( - prod.contains("pub fn tray_available()"), - "close-to-tray needs an accessor for whether a tray exists (issue #927)" - ); - } - - /// Issue #911: macOS wants a monochrome TEMPLATE image for the menu bar, - /// because the bar draws the icon from its alpha channel and tints it — that - /// is what makes the item invert in a dark bar and dim with the bar for a - /// modal. The tray used `default_window_icon()`, the full-colour 32/128 px - /// application icon, which is why the item was oversized and ignored the tint. - #[test] - fn the_menu_bar_icon_is_a_monochrome_template_at_menu_bar_size() { - // The glyph is a square bitmap of ink and transparency, and it is a mark - // rather than a stray pixel. - assert_eq!(TEMPLATE_GLYPH.len(), TEMPLATE_GLYPH_SIZE as usize); - for row in TEMPLATE_GLYPH { - assert_eq!(row.len(), TEMPLATE_GLYPH_SIZE as usize, "row {:?}", row); - assert!( - row.bytes().all(|b| b == b'#' || b == b'.'), - "row {:?} must be ink or transparency only", - row - ); - } - let ink = TEMPLATE_GLYPH - .iter() - .flat_map(|row| row.bytes()) - .filter(|b| *b == b'#') - .count(); - assert!( - ink > 60, - "the glyph must be a legible mark, got {} cells", - ink - ); - - // The RGBA handed to macOS is black plus alpha at both menu-bar sizes, - // and the ink covers exactly the glyph's cells scaled up — a template - // image gets its colour from the menu bar, so a coloured pixel would be - // ignored there anyway. - for scale in [1u32, 2] { - let side = TEMPLATE_GLYPH_SIZE * scale; - let rgba = template_icon_rgba(scale); - assert_eq!(rgba.len(), (side * side * 4) as usize, "scale {}", scale); - for px in rgba.chunks_exact(4) { - assert_eq!(&px[..3], &[0, 0, 0], "a template image is black"); - assert!(px[3] == 0 || px[3] == 255, "alpha is on or off"); - } - assert_eq!( - rgba.chunks_exact(4).filter(|px| px[3] == 255).count(), - ink * (scale * scale) as usize, - "scale {} must cover the glyph and nothing else", - scale - ); - } - - // The wiring: macOS builds the tray from the template glyph and marks it - // as a template; Windows and Linux keep the application icon. - let prod = prod_source(include_str!("tray.rs")); - let setup = body_of(prod, "pub fn setup_tray("); - assert!( - setup.contains(".icon(tray_icon(app)?)"), - "the tray icon must come from the platform-aware source (issue #911)" - ); - assert!( - setup.contains("set_icon_as_template(true)"), - "the macOS icon must be marked as a template (issue #911)" - ); - let source = body_of(prod, "fn tray_icon("); - assert!( - source.contains("default_window_icon()"), - "Windows and Linux must keep the application icon (issue #911)" - ); - assert!( - source.contains("macos_template_icon()"), - "macOS must build the monochrome template glyph (issue #911)" - ); - } - - /// Issue #937: `force_tray_refresh` used to look the managed state up with - /// `app.state::>()`, which panics with "state() called before - /// manage()" when the lookup runs before the setup closure has run - /// `app.manage(state.clone())`. - /// - /// To be accurate about what this pins: no current caller can produce - /// that window. `force_tray_refresh` is reached from `run_player_action` - /// (tray menu events) and `refresh_tray_for_locale` (the `save_config` - /// IPC), both downstream of `manage()`; the forwarded-second-launch path - /// goes through `refresh_tray_from_state` → `repaint_tray_from_state`, - /// which already used `try_state` on `main`. The change is - /// defence-in-depth, and this test is its regression cover. - /// - /// The function takes the managed-state lookup and the tray rebuild as - /// seams, so both arms are driven here directly: a lookup reporting "not - /// managed yet" (`None` — what a pre-`manage()` caller would see) must - /// return without touching the tray, and a managed state must rebuild - /// exactly once, from the values it read. - #[test] - fn force_tray_refresh_tolerates_missing_state_issue_937() { - use std::panic::{catch_unwind, AssertUnwindSafe}; - use std::sync::Arc; - - // Arm 1: an unmanaged state. A lookup that returns `None` is exactly - // what `try_state` reports before `app.manage()` has run. - let mut rebuilds: Vec<(bool, Option)> = Vec::new(); - let unwound = catch_unwind(AssertUnwindSafe(|| { - force_tray_refresh(&|| None, &mut |is_syncing, track| { - rebuilds.push((is_syncing, track)); - }); - })); - assert!( - unwound.is_ok(), - "force_tray_refresh must survive an unmanaged AppState (issue #937): \ - pre-fix this path called app.state::>(), which panics \ - with 'state() called before manage()' and would take the tray worker \ - thread down with it" - ); - assert!( - rebuilds.is_empty(), - "with no managed AppState there is nothing to repaint from, so the tray \ - must not be rebuilt (issue #937) — got {} rebuild(s)", - rebuilds.len() - ); - - // Arm 2: the normal launch. A managed state proceeds, and the rebuild - // sees the sync flag the function read out of it. - let state = Arc::new(crate::AppState::new()); - state.polling.set_syncing(true); - let mut rebuilds: Vec<(bool, Option)> = Vec::new(); - force_tray_refresh(&|| Some(state.clone()), &mut |is_syncing, track| { - rebuilds.push((is_syncing, track)); - }); - assert_eq!( - rebuilds.len(), - 1, - "a managed AppState must produce exactly one forced rebuild (issue #937)" - ); - assert!( - rebuilds[0].0, - "the rebuild must be driven by the managed state's sync flag, not a \ - default (issue #937)" - ); - } -} diff --git a/src-tauri/src/tray/actions.rs b/src-tauri/src/tray/actions.rs new file mode 100644 index 00000000..011bf308 --- /dev/null +++ b/src-tauri/src/tray/actions.rs @@ -0,0 +1,568 @@ +//! tray/actions.rs — split from tray/mod.rs (#756). + +#![allow(unused_imports)] + +use super::*; +use crate::events::PlaybackStateChanged; +use crate::i18n::Strings; +use crate::spotify::RepeatState; +use std::sync::atomic::Ordering; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Emitter, Listener, Manager}; + +/// Module-level mutex that serialises the two writers to the tray +/// (polling thread and frontend command). Issue #71. +pub static TRAY_WRITE_LOCK: std::sync::OnceLock> = + std::sync::OnceLock::new(); + +pub fn tray_write_lock() -> &'static parking_lot::Mutex<()> { + TRAY_WRITE_LOCK.get_or_init(|| parking_lot::Mutex::new(())) +} + +/// Coalescing guard for the delayed one-shot refresh kicked after a +/// successful tray player action: rapid next/previous clicks must not pile +/// up unbounded 2 s-sleep threads each firing blocking Spotify+Teams HTTP. +/// First claimant spawns; losers skip (their track change is covered by the +/// in-flight refresh's unconditional GET plus the polling loop). +pub static DELAYED_REFRESH_IN_FLIGHT: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +/// Runs a Spotify player action from a tray click using the stored access +/// token. On success the tray menu is force-refreshed and the action's +/// deterministic outcome is recorded for the items that mirror it: +/// `resulting_playing` for the Play/Pause item (`None` for actions that do +/// not change the playing state), `resulting_modes` for the shuffle/repeat +/// toggles (issue #582). Both are applied BEFORE `force_tray_refresh`, so the +/// rebuild renders the state the API just accepted; recording them in the +/// caller after this function returned would repaint the replaced state and +/// the tray's dedup key would not change again until the next track ends. On +/// failure the error is logged and emitted on the `playback-error` event so +/// the frontend can surface it; a `NoActiveDevice` error (404) is logged +/// distinctly — the Devices submenu offers transfer in that case. Returns +/// `true` when the action succeeded. +/// See issues #3.0-P3 and #582. +pub fn run_player_action( + app: &AppHandle, + label: &str, + resulting_playing: Option, + resulting_modes: Option<(bool, RepeatState)>, + action: impl Fn(&str) -> Result<(), crate::spotify::SpotifyApiError>, +) -> bool { + // Issue #586: route the tray's player actions through the shared + // refresh-aware policy instead of a raw `state.tokens.spotify()` + // snapshot — a stale access token is refreshed proactively, and an + // `ExpiredToken` response gets one refresh + retry, exactly as the + // command layer does. + let state = app.state::>(); + match crate::commands::playback::player_with_refresh_typed(state.inner(), app, label, action) { + Ok(()) => { + log::info!("[TRAY] {}: success", label); + if let Some(playing) = resulting_playing { + note_playing_state(playing); + } + if let Some((shuffle, repeat)) = resulting_modes { + note_playback_modes(shuffle, repeat); + } + force_tray_refresh_from_app(app); + // Immediate Teams catch-up after a successful player action: + // wait 2 s for Spotify's currently-playing to catch up after + // a skip, then run a one-shot poll (no-op when sync is off). + // Coalesced: rapid clicks skip while a delayed refresh is + // already pending; its unconditional GET covers their tracks. + if DELAYED_REFRESH_IN_FLIGHT + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + let app_clone = app.clone(); + let label_owned = label.to_string(); + std::thread::spawn(move || { + // RAII: a panic in run_oneshot must not wedge future + // refreshes (a manual clear on each return path would). + struct ResetOnDrop; + impl Drop for ResetOnDrop { + fn drop(&mut self) { + DELAYED_REFRESH_IN_FLIGHT.store(false, Ordering::Release); + } + } + let _reset = ResetOnDrop; + std::thread::sleep(std::time::Duration::from_secs(2)); + let state = app_clone.state::>(); + if !state.polling.is_syncing() { + log::debug!("[TRAY] {}: delayed refresh skipped (sync off)", label_owned); + return; + } + crate::polling::run_oneshot(state.inner(), &app_clone); + let is_syncing = state.polling.is_syncing(); + let current_track = state.polling.current_track().clone(); + if let Err(e) = update_tray_menu(&app_clone, is_syncing, current_track) { + log::warn!( + "[TRAY] {}: delayed refresh tray update failed: {}", + label_owned, + e + ); + } + }); + } else { + log::debug!( + "[TRAY] {}: delayed refresh already pending, coalesced", + label + ); + } + true + } + Err(crate::spotify::SpotifyApiError::NoActiveDevice) => { + log::warn!( + "[TRAY] {}: no active device — pick one from the Devices menu", + label + ); + let _ = app.emit( + "playback-error", + "No active playback device - pick one from the tray Devices menu", + ); + false + } + Err(e) => { + log::error!("[TRAY] {}: failed: {}", label, e); + let _ = app.emit("playback-error", e.to_string()); + false + } + } +} + +/// Upper bound on the wait for the frontend's Pause/Resume toggle to land +/// (issue #588). The frontend owns the actual `start_syncing` / +/// `stop_syncing` call, so the flag settles only after that round-trip. +pub const TOGGLE_SETTLE_TIMEOUT: Duration = Duration::from_secs(3); + +/// Poll cadence while waiting for the toggle to land. +pub const TOGGLE_SETTLE_POLL: Duration = Duration::from_millis(150); + +/// Repaints the tray from authoritative backend state on the CURRENT +/// thread. Callers must already be off the menu/app-event thread — the +/// rebuild performs blocking Spotify HTTP whenever the fetch throttle has +/// lapsed. `context` only labels the failure log. +pub fn repaint_tray_from_state(app: &AppHandle, context: &str) { + let Some(state) = app.try_state::>() else { + log::debug!( + "[TRAY] {}: AppState not registered yet, skipping repaint", + context + ); + return; + }; + let is_syncing = state.polling.is_syncing(); + let current_track = state.polling.current_track().clone(); + if let Err(e) = update_tray_menu(app, is_syncing, current_track) { + log::warn!("[TRAY] {}: tray repaint failed: {}", context, e); + } +} + +/// Repaints the tray from backend state on a worker thread. +/// +/// Issues #587/#592: every caller runs on a menu/app-event thread (tray +/// click arms, app-menu items, the single-instance raise), and +/// `update_tray_menu` fetches Spotify devices/queue with a 10 s timeout +/// whenever the 60 s throttle has lapsed — doing that inline wedges the +/// native menu (the freeze issue #386 removed from the player arms). +pub fn refresh_tray_from_state(app: &AppHandle) { + let app_handle = app.clone(); + std::thread::spawn(move || repaint_tray_from_state(&app_handle, "refresh_tray_from_state")); +} + +/// Repaints the tray after a locale change (issue #674). +/// +/// A language switch changes only labels, so the dedup snapshot would +/// otherwise hide it — `force_tray_refresh` nudges that snapshot and rebuilds +/// from the freshly installed table. It runs on a worker thread for the same +/// reason as [`refresh_tray_from_state`]: the rebuild may perform blocking +/// Spotify HTTP, which must never run on a menu/app-event thread. +pub fn refresh_tray_for_locale(app: &AppHandle) { + let app_handle = app.clone(); + std::thread::spawn(move || force_tray_refresh_from_app(&app_handle)); +} + +/// Waits — bounded by [`TOGGLE_SETTLE_TIMEOUT`] — for the frontend's +/// Pause/Resume toggle to move the running flag away from `before`. +/// Returns `true` when it moved, `false` when the window elapsed or state +/// was unavailable; the caller repaints either way. +pub fn await_sync_toggle(app: &AppHandle, before: bool) -> bool { + let deadline = Instant::now() + TOGGLE_SETTLE_TIMEOUT; + while Instant::now() < deadline { + std::thread::sleep(TOGGLE_SETTLE_POLL); + let Some(state) = app.try_state::>() else { + return false; + }; + if state.polling.is_syncing() != before { + return true; + } + } + false +} + +/// Production entry point for [`force_tray_refresh`]: binds the managed-state +/// lookup and the tray rebuild to a live `AppHandle`. +/// +/// `try_state` — not `state` — is the lookup: `state()` panics with "state() +/// called before manage()", so a lookup that can run before `app.manage()` +/// must not use it. See the seam's doc comment for whether this function's +/// current callers can (they cannot — this is defence-in-depth). +pub fn force_tray_refresh_from_app(app: &AppHandle) { + force_tray_refresh( + &|| { + app.try_state::>() + .map(|state| state.inner().clone()) + }, + &mut |is_syncing, current_track| { + let _ = update_tray_menu(app, is_syncing, current_track); + }, + ); +} + +/// Forces the next `update_tray_menu` call to rebuild and asks it for fresh +/// Devices/Up Next lists: records the action (issue #883 — the caches stay, and +/// the rebuild re-fetches under `TRAY_POST_ACTION_FETCH_MIN`), nudges the dedup +/// snapshot so the rebuild can't early-return, then rebuilds immediately. +/// User-initiated tray actions call this so the menu reflects the new +/// playback/device state right away — the dedup key alone wouldn't change on +/// e.g. a pause or a transfer. +/// +/// The snapshot is nudged (not cleared) with the *current* track key so the +/// re-seed logic in `update_tray_menu` (which only fires on a genuine track +/// change) doesn't clobber the toggle state set by the action. See +/// issue #3.0-P3. +/// +/// Issue #937 asked for this path to tolerate a second launch arriving before +/// `app.manage(state.clone())` has run in the setup closure. As it turns out +/// this function has no such caller, and it did not crash before this change: +/// its only two production callers — [`run_player_action`] (tray menu events) +/// and [`refresh_tray_for_locale`] (the `save_config` IPC) — both run after +/// setup has managed the state. The forwarded-second-launch path reaches +/// [`refresh_tray_from_state`] → `repaint_tray_from_state`, which has used +/// `try_state` on `main` already. So this is defence-in-depth, not a fix for +/// an observed crash: the lookup cannot panic, and no future caller has to +/// remember that it cannot. +/// +/// The managed-state lookup is a parameter rather than an inline +/// `app.try_state()` so the unit test can drive both arms — a lookup that +/// reports "not managed yet" (`None`) and one that returns a managed state — +/// without a GUI runtime. That is also why `managed_state` returning `None` +/// is the arm under test: it is exactly what a pre-`manage()` caller would +/// see. The skip is logged at `warn!`, not `debug!`: the default +/// tauri-plugin-log level filters `debug!`, so a `debug!` line would be +/// invisible to anyone actually debugging a real skip. +/// +/// The binder in [`force_tray_refresh_from_app`] is deliberately UNPINNED: +/// nothing in the test suite constrains it to `try_state` rather than +/// `state()`, because Tauri offers no hermetic `AppHandle` constructor +/// outside the `test` feature that #937's rework removed (that feature is +/// what breaks the Windows test binary's loader). A revert of the binder +/// would restore the panic at the call, with no test failing — acceptable +/// only because no caller reaches this function before `manage()`. +/// +/// The seams are `&dyn Fn` rather than generic parameters so the #883/#886 +/// source guards in this module keep resolving to this body, and so the +/// regression test drives this function itself instead of a stand-in. +pub fn force_tray_refresh( + managed_state: &dyn Fn() -> Option>, + rebuild: &mut dyn FnMut(bool, Option), +) { + let Some(state) = managed_state() else { + log::warn!( + "[TRAY] force_tray_refresh: AppState not managed yet — skipping the forced \ + tray rebuild (issue #937)" + ); + return; + }; + let is_syncing = state.polling.is_syncing(); + let current_track = state.polling.current_track().clone(); + // S9 (issue #677): the snooze is read here only for the dedup key below — + // while one is active the rebuild renders the CACHED Devices/Up Next lists + // instead of fetching (`TrayFetch::CacheOnly`) and an action cannot override + // that. The nudge below still forces the repaint, which is all a snooze-time + // repaint needs. + let snooze = snooze_from_app_state(&state); + // Issue #883: the caches stay. Emptying them was how this helper asked for + // fresh Devices/Up Next lists, and it bypassed `TRAY_SPOTIFY_FETCH_THROTTLE` + // on every player action; the action is recorded instead, and the rebuild + // re-fetches under `TRAY_POST_ACTION_FETCH_MIN` — which coalesces a burst + // into one pair of requests. The nudge below still forces the repaint. + *LAST_TRAY_ACTION.lock() = Some(Instant::now()); + let snooze_key = snooze.as_ref().map(|sn| snooze_dedup_key(&sn.status)); + // Nudge the dedup snapshot (not clear it) with the *current* track key so + // the rebuild below can't early-return while the re-seed logic stays + // inert: a cleared snapshot would look like a genuine track change and + // clobber the toggle state the action just recorded. Flipping the sync + // bit is enough — the real snapshot is committed by that rebuild. + let (devices_bucket, queue_bucket) = cache_buckets(); + // Issue #869: the nudge snapshot's profile key reads the post-action + // value so the rebuild the function triggers sees a fresh dedup key — + // the real write happens inside `store_active_profile`. + // Issue #937: this used to be a second `app.state::<>()` call on the same + // guard-clause risk. The single lookup above is already in hand, so the + // key is read from it — one lookup, and this site can no longer panic. + let nudge_profile_key = state + .config + .get() + .as_ref() + .and_then(|c| c.active_profile.clone()); + let mut nudge = tray_snapshot_for( + is_syncing, + false, + current_track.as_ref(), + snooze_key, + devices_bucket, + queue_bucket, + nudge_profile_key, + ); + nudge.is_syncing = !is_syncing; + *last_tray_state().lock() = Some(nudge); + rebuild(is_syncing, current_track); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::i18n::{DE, EN, FR}; + use crate::tray::testkit::{body_of, prod_source, tray_prod_source}; + /// Issue #586: tray player actions used to snapshot + /// `state.tokens.spotify()` — an expired access token meant a failed + /// click with no refresh and no retry, while the refresh-aware policy in + /// `commands/playback.rs` had no callers. Both tray paths must now route + /// through that shared policy. + #[test] + fn tray_player_actions_use_refresh_aware_token() { + let src = tray_prod_source(); + let prod = prod_source(src); + let action_body = body_of(prod, "fn run_player_action("); + assert!( + action_body.contains("player_with_refresh_typed("), + "run_player_action must route through the shared refresh-aware policy (issue #586)" + ); + assert!( + !action_body.contains("tokens.spotify()"), + "run_player_action must not snapshot the raw access token (issue #586)" + ); + let setup_body = body_of(prod, "pub fn handle_menu_event("); + let play_pos = setup_body + .find("ID_PLAY_PAUSE =>") + .expect("handle_menu_event must handle ID_PLAY_PAUSE"); + let play_end = setup_body[play_pos..] + .find("ID_PREVIOUS =>") + .map(|i| play_pos + i) + .unwrap_or(setup_body.len()); + let play_arm = &setup_body[play_pos..play_end]; + assert!( + play_arm.contains("player_with_refresh_typed("), + "the Play/Pause arm must read playback state through the refresh-aware policy (issue #586)" + ); + assert!( + !play_arm.contains("tokens.spotify()"), + "the Play/Pause arm must not snapshot the raw access token (issue #586)" + ); + } + /// Issue #591: the tray must state whether the app is syncing instead of + /// leaving it to be inferred from the Pause/Resume verb. Issue #674: the + /// status word comes from the table the caller hands in, so the same + /// helper renders German/French without a second code path. + #[test] + fn sync_status_line_reports_backend_state_in_every_locale() { + let track = crate::spotify::TrackInfo { + title: "Track".to_string(), + artist: "Artist".to_string(), + album: "Album".to_string(), + album_art_url: String::new(), + is_playing: true, + progress_ms: None, + duration_ms: 0, + volume_percent: None, + supports_volume: None, + actions: None, + }; + assert_eq!( + sync_status_line(&EN, false, true, Some(&track)), + "Not syncing", + "a stopped poller outranks any remembered track" + ); + assert_eq!( + sync_status_line(&EN, false, false, None), + "Not syncing", + "no track and no sync is still Not syncing" + ); + assert_eq!( + sync_status_line(&EN, true, false, None), + "Syncing — no track", + "syncing with nothing playing must say so rather than claim a track" + ); + assert_eq!( + sync_status_line(&EN, true, true, Some(&track)), + "Syncing — Artist — Track" + ); + assert_eq!( + sync_status_line(&EN, true, false, Some(&track)), + "Paused — Artist — Track", + "a same-track pause is reported from the live playing state" + ); + + // Issue #674: the selected table is what the user reads — Deutsch and + // Français must not fall back to the English status words. + assert_eq!( + sync_status_line(&DE, true, true, Some(&track)), + format!("{} — Artist — Track", DE.status_syncing) + ); + assert_eq!( + sync_status_line(&DE, true, false, Some(&track)), + format!("{} — Artist — Track", DE.status_paused) + ); + assert_eq!( + sync_status_line(&DE, false, false, None), + DE.status_not_syncing + ); + assert_eq!( + sync_status_line(&FR, true, false, None), + FR.status_syncing_no_track + ); + } + /// Issue #883: every tray player action used to empty both throttled caches, + /// so each click paid two fresh Spotify fetches and the 60 s throttle that + /// protects the API was bypassed entirely. The action is now recorded and the + /// rebuild re-fetches under a short minimum interval, which a burst of clicks + /// shares instead of multiplying. + #[test] + fn post_action_refresh_coalesces_a_burst_of_clicks() { + let min = TRAY_POST_ACTION_FETCH_MIN; + let t0 = Instant::now(); + let click = t0 + Duration::from_millis(10); + + // No action: the ordinary throttle decides. + assert!(!action_fetch_due(None, None, t0, min)); + // An action nothing has fetched for yet is due immediately, and stays + // due until a fetch is issued on its behalf. + assert!(action_fetch_due(Some(t0), None, t0, min)); + assert!(action_fetch_due(Some(click), None, click, min)); + + // The pair of requests that action asked for, recorded as it is issued. + let fetched = click + Duration::from_millis(1); + assert!( + !action_fetch_due(Some(click), Some(fetched), fetched, min), + "the fetch already in flight covers the action that asked for it" + ); + + // Ten rapid clicks inside the interval share that single pair. + let mut pairs = 1; + for n in 1..10u64 { + let at = t0 + Duration::from_millis(n * 300); + if action_fetch_due(Some(at), Some(fetched), at + Duration::from_millis(1), min) { + pairs += 1; + } + } + assert_eq!( + pairs, 1, + "ten clicks inside the interval must share one devices+queue pair (issue #883)" + ); + + // A click that lands after the interval is worth a fresh pair… + assert!(action_fetch_due( + Some(t0 + Duration::from_secs(30)), + Some(fetched), + fetched + min, + min + )); + // …while a fetch that already followed the action is not repeated. + assert!(!action_fetch_due( + Some(click), + Some(fetched), + fetched + min, + min + )); + + // The wiring: the caches must no longer be emptied on a player action, + // and the rebuild must pass the action's fetch mode to both submenu + // sources (it marks the fetch in flight before issuing it). + let prod = tray_prod_source(); + let force = body_of(prod, "fn force_tray_refresh("); + assert!( + force.contains("LAST_TRAY_ACTION.lock() = Some(Instant::now())"), + "a player action must be recorded, not enforced by emptying the caches" + ); + assert!( + !force.contains("DEVICES_CACHE.lock() = None") + && !force.contains("QUEUE_CACHE.lock() = None"), + "the caches must stay: emptying them bypassed the fetch throttle (issue #883)" + ); + let body = body_of(prod, "fn rebuild_tray_menu("); + let mark = body + .find("LAST_ACTION_FETCH.lock() = Some(Instant::now())") + .expect("a post-action rebuild must mark its fetch in flight"); + let devices = body + .find("devices_for_menu(access_token.as_deref(), fetch)") + .expect("the rebuild must build the devices submenu from the fetch mode"); + assert!( + mark < devices, + "the in-flight mark must be recorded before the requests run (issue #883)" + ); + } + /// Issue #937: `force_tray_refresh` used to look the managed state up with + /// `app.state::>()`, which panics with "state() called before + /// manage()" when the lookup runs before the setup closure has run + /// `app.manage(state.clone())`. + /// + /// To be accurate about what this pins: no current caller can produce + /// that window. `force_tray_refresh` is reached from `run_player_action` + /// (tray menu events) and `refresh_tray_for_locale` (the `save_config` + /// IPC), both downstream of `manage()`; the forwarded-second-launch path + /// goes through `refresh_tray_from_state` → `repaint_tray_from_state`, + /// which already used `try_state` on `main`. The change is + /// defence-in-depth, and this test is its regression cover. + /// + /// The function takes the managed-state lookup and the tray rebuild as + /// seams, so both arms are driven here directly: a lookup reporting "not + /// managed yet" (`None` — what a pre-`manage()` caller would see) must + /// return without touching the tray, and a managed state must rebuild + /// exactly once, from the values it read. + #[test] + fn force_tray_refresh_tolerates_missing_state_issue_937() { + use std::panic::{catch_unwind, AssertUnwindSafe}; + use std::sync::Arc; + + // Arm 1: an unmanaged state. A lookup that returns `None` is exactly + // what `try_state` reports before `app.manage()` has run. + let mut rebuilds: Vec<(bool, Option)> = Vec::new(); + let unwound = catch_unwind(AssertUnwindSafe(|| { + force_tray_refresh(&|| None, &mut |is_syncing, track| { + rebuilds.push((is_syncing, track)); + }); + })); + assert!( + unwound.is_ok(), + "force_tray_refresh must survive an unmanaged AppState (issue #937): \ + pre-fix this path called app.state::>(), which panics \ + with 'state() called before manage()' and would take the tray worker \ + thread down with it" + ); + assert!( + rebuilds.is_empty(), + "with no managed AppState there is nothing to repaint from, so the tray \ + must not be rebuilt (issue #937) — got {} rebuild(s)", + rebuilds.len() + ); + + // Arm 2: the normal launch. A managed state proceeds, and the rebuild + // sees the sync flag the function read out of it. + let state = Arc::new(crate::AppState::new()); + state.polling.set_syncing(true); + let mut rebuilds: Vec<(bool, Option)> = Vec::new(); + force_tray_refresh(&|| Some(state.clone()), &mut |is_syncing, track| { + rebuilds.push((is_syncing, track)); + }); + assert_eq!( + rebuilds.len(), + 1, + "a managed AppState must produce exactly one forced rebuild (issue #937)" + ); + assert!( + rebuilds[0].0, + "the rebuild must be driven by the managed state's sync flag, not a \ + default (issue #937)" + ); + } +} diff --git a/src-tauri/src/tray/cache.rs b/src-tauri/src/tray/cache.rs new file mode 100644 index 00000000..1ce50e61 --- /dev/null +++ b/src-tauri/src/tray/cache.rs @@ -0,0 +1,381 @@ +//! tray/cache.rs — split from tray/mod.rs (#756). + +#![allow(unused_imports)] + +use super::*; +use crate::events::PlaybackStateChanged; +use crate::i18n::Strings; +use crate::spotify::RepeatState; +use std::time::{Duration, Instant}; +use tauri::AppHandle; + +/// The throttle bucket a cache slot is in (issue #805): how many full throttle +/// windows have elapsed since it was filled, `0` while it is empty. +/// +/// Pure in its timestamp, so "an unchanged rebuild inside the window is still +/// a no-op, one window later it repaints" is asserted without sleeping. +pub fn throttle_bucket(fetched_at: Option, throttle: Duration) -> u64 { + match fetched_at { + None => 0, + Some(at) => at.elapsed().as_secs() / throttle.as_secs().max(1), + } +} + +/// The throttle buckets of both caches (issue #805), read under short locks — +/// no HTTP, and no lock held past the read. +pub fn cache_buckets() -> (u64, u64) { + let devices_at = DEVICES_CACHE.lock().as_ref().map(|(at, _)| *at); + let queue_at = QUEUE_CACHE.lock().as_ref().map(|(at, _)| *at); + ( + throttle_bucket(devices_at, TRAY_SPOTIFY_FETCH_THROTTLE), + throttle_bucket(queue_at, TRAY_SPOTIFY_FETCH_THROTTLE), + ) +} + +/// Which entry point a rebuild came through (issue #882). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrayPaint { + /// An ordinary rebuild: the dedup key decides whether the menu is rebuilt, + /// and a successful rebuild commits the snapshot. + Deduped, + /// The startup paint. Built on the main thread before the event loop runs: + /// it renders the throttled caches without touching the network, and it + /// does not commit the snapshot (see `update_tray_menu_startup`). + Startup, +} + +/// The fetch mode a rebuild runs under (issues #677, #882). +/// +/// Pure, so "the startup paint performs no request" and "a snooze performs no +/// request" are asserted directly rather than through the rebuild's source. +pub fn paint_fetch_mode(paint: TrayPaint, snoozed: bool, action_refresh_due: bool) -> TrayFetch { + match paint { + // Issue #882: no network before the event loop exists. + TrayPaint::Startup => TrayFetch::CacheOnly, + TrayPaint::Deduped => { + let base = tray_fetch_mode(snoozed); + // A snooze outranks an action: "no Spotify request while snoozed" is + // that feature's acceptance criterion (issue #677). + if base == TrayFetch::CacheOnly { + TrayFetch::CacheOnly + } else if action_refresh_due { + // Issue #883: the action asks for fresh Devices/Up Next lists. + TrayFetch::RefreshNow + } else { + base + } + } + } +} + +/// Throttle window for the tray's Spotify devices/queue fetches +/// (issue #3.0-P3). The polling loop calls `update_tray_menu` on every +/// successful poll; without a cap the Devices/Up Next submenus would +/// hammer the Spotify API on every iteration. Fetched data is cached +/// here and re-used until the window lapses. +pub const TRAY_SPOTIFY_FETCH_THROTTLE: Duration = Duration::from_secs(60); + +/// Cache slot for the throttled devices fetch: `(fetched_at, devices)`. +/// Fast path for the device submenu's click dispatch, which resolves the +/// stable `{ID_DEVICES}|{device id}` against this list and falls back to a +/// live re-fetch when stale (issue #388). +pub type DeviceCacheSlot = Option<(Instant, Vec)>; + +pub static DEVICES_CACHE: std::sync::LazyLock> = + std::sync::LazyLock::new(|| parking_lot::Mutex::new(None)); + +pub static QUEUE_CACHE: std::sync::LazyLock< + parking_lot::Mutex>, +> = std::sync::LazyLock::new(|| parking_lot::Mutex::new(None)); + +/// Shortest gap between the post-action re-fetches of the Devices/Up Next lists +/// (issue #883). A player action wants those submenus to mirror what just +/// happened, but a burst of clicks must share one devices+queue pair: five +/// seconds is long enough to coalesce a burst, short enough that the menu still +/// matches the click the user just made. +pub const TRAY_POST_ACTION_FETCH_MIN: Duration = Duration::from_secs(5); + +/// The most recent tray player action that wants the Devices/Up Next lists +/// refreshed (issue #883). `force_tray_refresh` records the action here instead +/// of emptying both caches, which bypassed the fetch throttle for every click. +pub static LAST_TRAY_ACTION: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| parking_lot::Mutex::new(None)); + +/// When the most recent post-action re-fetch was issued (issue #883). Recorded +/// before the requests run, so a click landing while they are in flight reuses +/// them instead of paying for a pair of its own. +pub static LAST_ACTION_FETCH: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| parking_lot::Mutex::new(None)); + +/// Whether a rebuild must re-fetch the Devices/Up Next lists on behalf of a +/// player action (issue #883). Pure in its instants, so the coalescing rule — +/// ten rapid clicks, one pair of requests — is unit-testable without waiting. +pub fn action_fetch_due( + last_action: Option, + last_action_fetch: Option, + now: Instant, + min_interval: Duration, +) -> bool { + match (last_action, last_action_fetch) { + // No action has asked for anything. + (None, _) => false, + // An action nothing has fetched for yet is due immediately. + (Some(_), None) => true, + // Only an action newer than the last post-action fetch — and far enough + // after it — is worth two more requests; a burst inside `min_interval` + // shares the pair already issued. + (Some(action), Some(fetched)) => { + action > fetched && now.duration_since(fetched) >= min_interval + } + } +} + +/// Returns cached devices when the throttle window hasn't elapsed, else +/// fetches fresh ones. On a fetch failure the stale cache is returned so +/// the submenu doesn't flicker to "(no devices)" on a transient error. +/// +/// The cache mutex is held only to clone the snapshot and to store the +/// fresh result — the HTTP fetch runs outside any lock so a cold/stale +/// cache never blocks tray interactions. If two threads race on a stale +/// cache both will fetch; the last writer wins. This benign double-fetch +/// wastes one request but cannot corrupt state. See issue #217. +/// `min_interval` is this fetch's throttle (issue #883): the full window for an +/// ordinary rebuild, `Duration::ZERO` for the one a player action asks for. +pub fn cached_devices( + access_token: &str, + min_interval: Duration, +) -> Vec { + // Snapshot under short lock, then drop before deciding staleness. + let snapshot = { + let cache = DEVICES_CACHE.lock(); + cache.clone() + }; + let needs_fetch = match &snapshot { + Some((fetched_at, _)) => fetched_at.elapsed() >= min_interval, + None => true, + }; + if !needs_fetch { + return snapshot.unwrap().1; + } + // Throttled fetch OUTSIDE any lock — never hold DEVICES_CACHE across HTTP. + match crate::spotify::get_devices(access_token) { + Ok(devices) => { + // Re-acquire only to store the fresh result. + *DEVICES_CACHE.lock() = Some((Instant::now(), devices.clone())); + devices + } + Err(e) => { + log::warn!("[TRAY] cached_devices: failed to fetch devices: {}", e); + snapshot.map(|(_, devices)| devices).unwrap_or_default() + } + } +} + +/// Returns cached queue when the throttle window hasn't elapsed, else +/// fetches fresh. Falls back to the stale cache on failure. +/// +/// Same lock discipline as `cached_devices`: snapshot, drop, fetch outside +/// lock, re-acquire to store. Benign double-fetch on a race. See issue #217. +pub fn cached_queue( + access_token: &str, + min_interval: Duration, +) -> Option { + // Snapshot under short lock, then drop before deciding staleness. + let snapshot = { + let cache = QUEUE_CACHE.lock(); + cache.clone() + }; + let needs_fetch = match &snapshot { + Some((fetched_at, _)) => fetched_at.elapsed() >= min_interval, + None => true, + }; + if !needs_fetch { + return snapshot.map(|(_, queue)| queue); + } + // Throttled fetch OUTSIDE any lock — never hold QUEUE_CACHE across HTTP. + match crate::spotify::get_queue(access_token) { + Ok(queue) => { + // Re-acquire only to store. + *QUEUE_CACHE.lock() = Some((Instant::now(), queue.clone())); + Some(queue) + } + Err(e) => { + log::warn!("[TRAY] cached_queue: failed to fetch queue: {}", e); + snapshot.map(|(_, queue)| queue) + } + } +} + +/// Where a rebuild may take its Devices / Up Next lists from (4.7.0, S9 / +/// issue #677). +/// +/// A snooze means "the user asked for no activity", and that covers the app's +/// own background requests: the acceptance for the feature is that a snoozed +/// session produces no Spotify GET at all. `update_tray_menu` is called from +/// the polling driver and from the snooze click arm, so the fetch mode has to +/// be a property of the rebuild rather than of its caller — otherwise a +/// countdown repaint would silently re-fetch devices every 60 s (the throttle +/// window) and break that guarantee. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrayFetch { + /// Normal rebuild: reuse the throttled cache, fetching when it lapsed. + Refresh, + /// Issue #883: a player action just changed the playback/device state these + /// lists mirror, so this one rebuild bypasses the throttle. A burst of + /// clicks still shares one pair of requests — see [`action_fetch_due`]. + RefreshNow, + /// A snooze is active: render the last cached lists (or nothing) and issue + /// no request. The submenus go stale by design until the snooze ends. + CacheOnly, +} + +/// The fetch mode a rebuild must use (4.7.0, S9 / issue #677). +/// +/// Pure, so the "a snooze means no Spotify request" decision is asserted +/// directly instead of only through the rebuild's source text. +pub fn tray_fetch_mode(snoozed: bool) -> TrayFetch { + if snoozed { + TrayFetch::CacheOnly + } else { + TrayFetch::Refresh + } +} + +/// The Devices list for a rebuild, honouring [`TrayFetch`]. A missing access +/// token is an empty submenu either way — there is nothing to fetch with. +pub fn devices_for_menu( + access_token: Option<&str>, + fetch: TrayFetch, +) -> Vec { + match (access_token, fetch) { + (Some(token), TrayFetch::Refresh) => cached_devices(token, TRAY_SPOTIFY_FETCH_THROTTLE), + (Some(token), TrayFetch::RefreshNow) => cached_devices(token, Duration::ZERO), + (_, TrayFetch::CacheOnly) => DEVICES_CACHE + .lock() + .clone() + .map(|(_, devices)| devices) + .unwrap_or_default(), + (None, _) => Vec::new(), + } +} + +/// The Up Next snapshot for a rebuild, honouring [`TrayFetch`]. Same contract +/// as [`devices_for_menu`]. +pub fn queue_for_menu( + access_token: Option<&str>, + fetch: TrayFetch, +) -> Option { + match (access_token, fetch) { + (Some(token), TrayFetch::Refresh) => cached_queue(token, TRAY_SPOTIFY_FETCH_THROTTLE), + (Some(token), TrayFetch::RefreshNow) => cached_queue(token, Duration::ZERO), + (_, TrayFetch::CacheOnly) => QUEUE_CACHE.lock().clone().map(|(_, queue)| queue), + (None, _) => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::i18n::{DE, EN, FR}; + use crate::tray::testkit::MODE_ATOM_LOCK; + /// Issue #582: a check mark can only carry on/off, while `repeat_state` + /// has three documented values — so the label must name the mode, or a + /// user cannot tell "repeat one" from "repeat the playlist". Issue #674: + /// the label is read from the installed table. + #[test] + fn repeat_menu_label_spells_out_the_mode() { + assert_eq!(repeat_menu_label(&EN, RepeatState::Off), "Repeat: Off"); + assert_eq!( + repeat_menu_label(&EN, RepeatState::Context), + "Repeat: Context" + ); + assert_eq!(repeat_menu_label(&EN, RepeatState::Track), "Repeat: Track"); + + assert_eq!(repeat_menu_label(&DE, RepeatState::Off), "Wiederholen: Aus"); + assert_eq!( + repeat_menu_label(&DE, RepeatState::Context), + "Wiederholen: Kontext" + ); + assert_eq!( + repeat_menu_label(&DE, RepeatState::Track), + "Wiederholen: Titel" + ); + assert_eq!( + repeat_menu_label(&FR, RepeatState::Track), + "Répéter : titre" + ); + } + /// Issue #805: the dedup key carries the throttle bucket of both caches. + /// Without it the early return also skipped the devices/queue fetches, so + /// a session where nothing else moved showed whatever the last rebuild had + /// rendered — possibly hours old, listing devices that had long since + /// disconnected. The bucket makes a quiet session repaint exactly once per + /// throttle window while every rebuild inside the window still dedupes. + #[test] + fn stale_caches_force_a_tray_rebuild_once_per_throttle_window() { + // The bucket is a pure function of the cache's age, so the window + // boundary is asserted without sleeping. + assert_eq!( + throttle_bucket(None, TRAY_SPOTIFY_FETCH_THROTTLE), + 0, + "an empty cache must not force a rebuild on its own" + ); + assert_eq!( + throttle_bucket(Some(Instant::now()), TRAY_SPOTIFY_FETCH_THROTTLE), + 0 + ); + assert_eq!( + throttle_bucket( + Some(Instant::now() - TRAY_SPOTIFY_FETCH_THROTTLE), + TRAY_SPOTIFY_FETCH_THROTTLE + ), + 1, + "a cache exactly one window old is due for a re-fetch" + ); + assert_eq!( + throttle_bucket( + Some(Instant::now() - 10 * TRAY_SPOTIFY_FETCH_THROTTLE), + TRAY_SPOTIFY_FETCH_THROTTLE + ), + 10 + ); + + let _guard = MODE_ATOM_LOCK.lock(); + note_playback_modes(false, RepeatState::Off); + let track = crate::spotify::TrackInfo { + title: "Title".to_string(), + artist: "Artist".to_string(), + album: "Album".to_string(), + album_art_url: String::new(), + is_playing: true, + progress_ms: None, + duration_ms: 0, + volume_percent: None, + supports_volume: None, + actions: None, + }; + let at = |devices: u64, queue: u64| { + tray_snapshot_for(true, true, Some(&track), None, devices, queue, None) + }; + + // Identical track, window, modes and caches: still a no-op. + assert!( + !tray_state_changed(Some(&at(0, 0)), &at(0, 0)), + "a rebuild inside the throttle window must still dedupe" + ); + // One window later either cache is due, and that must repaint — the + // repaint is what re-runs the fetchers. + assert!( + tray_state_changed(Some(&at(0, 0)), &at(1, 0)), + "a devices cache one throttle window old must repaint (issue #805)" + ); + assert!( + tray_state_changed(Some(&at(0, 0)), &at(0, 1)), + "a queue cache one throttle window old must repaint (issue #805)" + ); + assert!( + !tray_state_changed(Some(&at(1, 1)), &at(1, 1)), + "the bucket alone must not make every rebuild a repaint" + ); + } +} diff --git a/src-tauri/src/tray/dedup.rs b/src-tauri/src/tray/dedup.rs new file mode 100644 index 00000000..cbd71f11 --- /dev/null +++ b/src-tauri/src/tray/dedup.rs @@ -0,0 +1,415 @@ +//! tray/dedup.rs — split from tray/mod.rs (#756). + +#![allow(unused_imports)] + +use super::*; +use crate::i18n::Strings; +use crate::spotify::RepeatState; +use std::sync::atomic::Ordering; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Manager}; + +/// Snapshot of the last tray-menu state, used for the dedup guard in +/// `update_tray_menu` (issue #71). The polling thread calls +/// `update_tray_menu` on every successful poll; the menu only needs to +/// change when `is_syncing`, window visibility (the Show/Hide label), the +/// current track's title/is_playing, or one of the two playback modes +/// changes. +/// +/// The two playback modes are part of the key (issue #691): their atoms +/// drive the Shuffle/Repeat check marks, so an external mode change has to +/// force a rebuild — otherwise the marks stayed on the previous mode. The +/// click target is unaffected: `shuffle_toggle_target` reads the live atom. +/// +/// The snooze is part of the key too (4.7.0, S9 / issue #677): it drives the +/// status line, the presence of the "Resume sync now" entry AND the fetch mode +/// of the Devices/Up Next submenus, so a snooze that starts, ends or ticks over +/// to the next minute must repaint rather than early-return. The tick is why +/// the key carries [`snooze_dedup_key`]'s minute bucket and not just the +/// deadline — a key of the deadline alone would freeze the countdown at the +/// minute it was set. +#[derive(Clone, PartialEq, Eq)] +pub struct TrayStateSnapshot { + pub(crate) is_syncing: bool, + is_window_visible: bool, + /// `artist|title|is_playing` — is_playing is in the key so a same-track + /// pause repaints the Play/Pause mark (issue #229). + pub(crate) track_key: Option, + shuffle: bool, + repeat: RepeatState, + /// Throttle bucket of the devices cache when this key was built (issue + /// #805): the number of full [`TRAY_SPOTIFY_FETCH_THROTTLE`] windows since + /// it was filled, `0` while it is empty. + /// + /// The key carries the bucket and not the timestamp because the bucket is + /// what the fetchers act on: a session where nothing else moves now + /// repaints — and therefore re-fetches — once per window, while a rebuild + /// inside the window still dedupes to a no-op. Without it the early return + /// below skipped the fetches themselves, and the Devices/Up Next submenus + /// kept whatever the last rebuild happened to render, indefinitely. + devices_bucket: u64, + /// Same, for the Up Next queue cache (issue #805). + queue_bucket: u64, + snooze_key: Option, + /// Issue #869: the active presence profile id, fed into the dedup key + /// so a profile switch (tray, CLI, Settings card) repaints the + /// "Active profile" submenu's check mark. The id is `None` when the + /// base configuration is in use — the documented pre-5.0 default and + /// the post-switch state of `--profile base`. + active_profile_key: Option, +} + +/// True when `next` differs from the last committed snapshot, i.e. when the +/// tray menu must be rebuilt. Pure, so the dedup contract is unit-testable +/// without a Tauri runtime. +pub fn tray_state_changed(prev: Option<&TrayStateSnapshot>, next: &TrayStateSnapshot) -> bool { + prev != Some(next) +} + +/// Builds the dedup key from the same inputs the rebuild renders from: the +/// caller's sync flag and precomputed window visibility, the track's +/// artist/title/is_playing, the two playback-mode atoms the polling loop feeds +/// (`note_playback_modes`), the snooze the rebuild will render (4.7.0, S9) and +/// the throttle bucket of both caches (issue #805). +/// Single construction site so the key can never be built from a subset of what +/// the menu shows (issue #691). +pub fn tray_snapshot_for( + is_syncing: bool, + is_window_visible: bool, + current_track: Option<&crate::spotify::TrackInfo>, + snooze_key: Option, + // The two cache throttle buckets (issue #805). Read by the caller rather + // than here so the key stays a pure function of its inputs. + devices_bucket: u64, + queue_bucket: u64, + // Issue #869: the active profile id, fed in by the caller for the same + // dedup reason as the snooze — a switch changes the check mark, so the + // key has to include the id or the rebuild early-returns. + active_profile_key: Option, +) -> TrayStateSnapshot { + TrayStateSnapshot { + is_syncing, + is_window_visible, + track_key: current_track.map(|t| format!("{}|{}|{}", t.artist, t.title, t.is_playing)), + shuffle: LAST_SHUFFLE_STATE.load(Ordering::Acquire), + repeat: last_repeat_state(), + snooze_key, + devices_bucket, + queue_bucket, + active_profile_key, + } +} + +pub static LAST_TRAY_STATE: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + +pub fn last_tray_state() -> &'static parking_lot::Mutex> { + LAST_TRAY_STATE.get_or_init(|| parking_lot::Mutex::new(None)) +} + +/// Last known main-window visibility, which is what the dedup key carries +/// (issue #886). +/// +/// The rebuild used to ask the window directly, and `is_visible()` blocks the +/// calling thread on an event-loop reply — a hop paid by every poll, including +/// the ones the dedup key discards a few lines later. [`note_window_visibility`] +/// keeps the mirror honest, and the rebuild re-reads the real window whenever it +/// paints anyway (see the self-heal in `rebuild_tray_menu`). +pub static WINDOW_VISIBLE: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +/// Records the main window's visibility (issue #886). Every path that shows or +/// hides the window should report it — the tray's own Show/Hide and Open +/// Settings arms do, and the window commands / close-to-tray paths are expected +/// to; the poll loop then never has to ask the event loop for it. +pub fn note_window_visibility(visible: bool) { + WINDOW_VISIBLE.store(visible, Ordering::Release); +} + +/// The visibility the dedup key is built from (issue #886) — the mirror, so the +/// discarded path performs no event-loop hop. +pub fn window_visible() -> bool { + WINDOW_VISIBLE.load(Ordering::Acquire) +} + +/// The real main-window visibility, queried once per paint (issue #886). Only +/// the paths that are already off the hot path may call this: the startup paint +/// (on the main thread) and the rebuild that passed the dedup guard. +pub fn live_window_visible(app: &AppHandle) -> bool { + app.get_webview_window("main") + .and_then(|w| w.is_visible().ok()) + .unwrap_or(false) +} + +/// The shuffle state a click switches to: the inverse of the last observed +/// one. Pure so the toggle contract is unit-testable. +pub fn shuffle_toggle_target(current: bool) -> bool { + !current +} + +/// Last known Spotify playing state, driving the Play/Pause check mark and +/// the tray status line (and the toggle's fallback dispatch). Re-seeded +/// from the polling loop's stored track on a genuine track change, from the +/// poller's `playback-state-changed` event when the playing state changes +/// for the same track (issue #689), and by the tray's own successful +/// play/pause/transfer actions. Issue #3.0-P3. +pub static LAST_PLAYING_STATE: std::sync::LazyLock = + std::sync::LazyLock::new(|| std::sync::atomic::AtomicBool::new(false)); + +/// Records the playing state the Play/Pause mark and the status line render. +/// Every path that learns the truth writes it here, so the mark never infers +/// playback from a candidate that may already be stale. Issue #3.0-P3. +pub fn note_playing_state(is_playing: bool) { + LAST_PLAYING_STATE.store(is_playing, Ordering::Release); +} + +/// Consumes a `playback-state-changed` payload (issue #689, tray half) via +/// the shared [`crate::events::PlaybackStateChanged`] contract, so a Rust-side +/// field rename fails here too — not just at the emit site. Only `is_playing` +/// is consumed: the event's `track_key` is part of the shared contract, but +/// the tray's dedup key already carries the track identity. The poll body's +/// playing state is authoritative for a same-track change, and the poller has +/// already re-stored it, so recording it here keeps the Play/Pause mark and +/// the status line truthful without waiting for the next track. An unparsable +/// payload keeps the last known state rather than inventing one. +pub fn consume_playback_state_changed(payload: &str) { + match serde_json::from_str::(payload) { + Ok(state) => note_playing_state(state.is_playing), + Err(e) => log::warn!( + "[TRAY] playback-state-changed: unparsable payload, keeping the last playing state: {}", + e + ), + } +} + +/// Last known shuffle state, driving the Shuffle item's check mark +/// (issue #582). Written by the polling loop from the poll body +/// (`note_playback_modes`) and optimistically by the tray's own successful +/// toggle, so a same-track toggle does not wait for the next poll. It is a +/// module-level atomic rather than a field on the app's frozen `TrackInfo` +/// because that type is the ts-rs-exported IPC shape shared with the +/// Dashboard and built by exhaustive literals outside this module. +pub static LAST_SHUFFLE_STATE: std::sync::LazyLock = + std::sync::LazyLock::new(|| std::sync::atomic::AtomicBool::new(false)); + +/// Last known repeat mode, encoded as [`RepeatState`]'s `u8` discriminant. +/// Same lifecycle as `LAST_SHUFFLE_STATE`. +pub static LAST_REPEAT_STATE: std::sync::LazyLock = + std::sync::LazyLock::new(|| std::sync::atomic::AtomicU8::new(RepeatState::Off as u8)); + +/// Records the playback modes a poll body reported. Called by the polling +/// loop for every observed item (playing or paused) — the poll response is +/// the source of truth for both toggles, so no extra Spotify request is +/// needed to render them. See issue #582. +pub fn note_playback_modes(shuffle: bool, repeat: RepeatState) { + LAST_SHUFFLE_STATE.store(shuffle, Ordering::Release); + LAST_REPEAT_STATE.store(repeat as u8, Ordering::Release); +} + +/// The tray's view of the current repeat mode. An out-of-range byte (only +/// possible if the encoder above is changed without this decoder) degrades +/// to `Off` rather than panicking in a menu build. +pub fn last_repeat_state() -> RepeatState { + match LAST_REPEAT_STATE.load(Ordering::Acquire) { + 1 => RepeatState::Context, + 2 => RepeatState::Track, + _ => RepeatState::Off, + } +} + +/// Menu label for the Repeat item: the mode is spelled out because the +/// documented state space has three values and a check mark only carries +/// on/off. Pure, and localized from the table it is handed (issue #674), so +/// the label contract is unit-testable in every locale. +pub fn repeat_menu_label(strings: &Strings, state: RepeatState) -> &'static str { + match state { + RepeatState::Off => strings.repeat_off, + RepeatState::Context => strings.repeat_context, + RepeatState::Track => strings.repeat_track, + } +} + +/// One-line sync/status summary for the tray's status item and tooltip +/// (issue #591). The Pause/Resume verb on its own left the sync state +/// unstated, and the presence-gated dock badge is macOS-only. `is_playing` +/// is `LAST_PLAYING_STATE` — the same source as the Play/Pause checkmark — +/// not the polling loop's copy, which goes stale on a same-track pause. +/// +/// Localized from the table it is handed (issue #674); the artist/title and +/// the em-dash separators are locale-neutral, so only the status word and the +/// track-less line come from the table. +pub fn sync_status_line( + strings: &Strings, + is_syncing: bool, + is_playing: bool, + track: Option<&crate::spotify::TrackInfo>, +) -> String { + if !is_syncing { + return strings.status_not_syncing.to_string(); + } + match track { + None => strings.status_syncing_no_track.to_string(), + Some(t) if is_playing => format!("{} — {} — {}", strings.status_syncing, t.artist, t.title), + Some(t) => format!("{} — {} — {}", strings.status_paused, t.artist, t.title), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::i18n::{DE, EN, FR}; + use crate::tray::testkit::MODE_ATOM_LOCK; + /// Issue #582: the two toggles render the state the poll body reported + /// (`note_playback_modes` → the atoms the menu build reads), and the + /// click target is the documented cycle, so a successful toggle leaves + /// the item showing what the API was just told to adopt. + #[test] + fn playback_modes_feed_both_toggle_items() { + // The mode atoms are process-global and read by the rebuild path, so + // the tests that drive them must not interleave. + let _guard = MODE_ATOM_LOCK.lock(); + note_playback_modes(true, RepeatState::Track); + assert!(LAST_SHUFFLE_STATE.load(Ordering::Acquire)); + assert_eq!(last_repeat_state(), RepeatState::Track); + assert_eq!(repeat_menu_label(&EN, last_repeat_state()), "Repeat: Track"); + // The click targets: Repeat advances along the documented cycle, + // Shuffle flips whatever was last observed. + assert_eq!(last_repeat_state().next(), RepeatState::Off); + assert!(!shuffle_toggle_target( + LAST_SHUFFLE_STATE.load(Ordering::Acquire) + )); + + // A poll that reports everything off must clear both items. + note_playback_modes(false, RepeatState::Off); + assert!(!LAST_SHUFFLE_STATE.load(Ordering::Acquire)); + assert_eq!(last_repeat_state(), RepeatState::Off); + assert!(!last_repeat_state().is_on()); + } + /// Issue #691: the dedup key is built by the same function the rebuild + /// path uses, and it carries both playback modes — so a shuffle/repeat + /// change made in another Spotify client forces the repaint that updates + /// the check marks (and the next click toggles from the fresh belief), + /// while an unchanged poll still dedupes to a no-op. + #[test] + fn mode_change_forces_a_tray_rebuild() { + let _guard = MODE_ATOM_LOCK.lock(); + let track = |is_playing: bool| crate::spotify::TrackInfo { + title: "Title".to_string(), + artist: "Artist".to_string(), + album: "Album".to_string(), + album_art_url: String::new(), + is_playing, + progress_ms: None, + duration_ms: 0, + volume_percent: None, + supports_volume: None, + actions: None, + }; + let key = |sync: bool, visible: bool| { + tray_snapshot_for(sync, visible, Some(&track(true)), None, 0, 0, None) + }; + + note_playback_modes(false, RepeatState::Off); + let base = key(true, true); + + // The poll body reports a shuffle the user toggled in the Spotify app. + note_playback_modes(true, RepeatState::Off); + let shuffled = key(true, true); + assert!( + tray_state_changed(Some(&base), &shuffled), + "an external shuffle change must repaint the tray (issue #691)" + ); + + // …and a repeat-mode change. + note_playback_modes(true, RepeatState::Context); + let repeated = key(true, true); + assert!( + tray_state_changed(Some(&shuffled), &repeated), + "an external repeat change must repaint the tray (issue #691)" + ); + + // An unchanged poll is still a no-op. + assert!( + !tray_state_changed(Some(&repeated), &key(true, true)), + "an unchanged poll must not rebuild the menu" + ); + assert!( + tray_state_changed(None, &repeated), + "the first call has no snapshot and must always rebuild" + ); + + // The pre-existing parts of the key still repaint. + assert!( + tray_state_changed(Some(&repeated), &key(false, true)), + "a sync toggle must still repaint (issue #71)" + ); + assert!( + tray_state_changed(Some(&repeated), &key(true, false)), + "a Show/Hide click must still repaint (issue #71)" + ); + + // A same-track pause lives in the track half of the key (issue #229). + let paused = tray_snapshot_for(true, true, Some(&track(false)), None, 0, 0, None); + assert!( + tray_state_changed(Some(&repeated), &paused), + "a same-track pause must still repaint the Play/Pause mark (#229)" + ); + + // Leave the shared atoms as a fresh poll would find them. + note_playback_modes(false, RepeatState::Off); + } + /// Issue #689 (D6, tray half): the poller emits `playback-state-changed` + /// when a track's playing state changes without the track itself + /// changing. Consuming it moves the Play/Pause mark and the status line + /// off "playing" — the tray used to keep claiming the stale state until + /// the next track emerged. + #[test] + fn playback_state_event_moves_the_play_pause_mark() { + let track = crate::spotify::TrackInfo { + title: "Track".to_string(), + artist: "Artist".to_string(), + album: "Album".to_string(), + album_art_url: String::new(), + is_playing: true, + progress_ms: None, + duration_ms: 0, + volume_percent: None, + supports_volume: None, + actions: None, + }; + let mark = |playing: bool| sync_status_line(&EN, true, playing, Some(&track)); + + note_playing_state(true); + assert_eq!( + mark(LAST_PLAYING_STATE.load(Ordering::Acquire)), + "Syncing — Artist — Track" + ); + + // The exact payload shape the poller emits for a same-track pause. + consume_playback_state_changed(r#"{"is_playing":false,"track_key":"Artist|Title"}"#); + assert!( + !LAST_PLAYING_STATE.load(Ordering::Acquire), + "the tray must believe a same-track pause (issue #689)" + ); + assert_eq!( + mark(LAST_PLAYING_STATE.load(Ordering::Acquire)), + "Paused — Artist — Track" + ); + + consume_playback_state_changed(r#"{"is_playing":true,"track_key":"Artist|Title"}"#); + assert!(LAST_PLAYING_STATE.load(Ordering::Acquire)); + assert_eq!( + mark(LAST_PLAYING_STATE.load(Ordering::Acquire)), + "Syncing — Artist — Track" + ); + + // A payload the tray cannot parse must keep the last known state + // rather than invent one. + consume_playback_state_changed("not json"); + assert!( + LAST_PLAYING_STATE.load(Ordering::Acquire), + "an unparsable payload must not clobber the last playing state" + ); + } +} diff --git a/src-tauri/src/tray/devices.rs b/src-tauri/src/tray/devices.rs new file mode 100644 index 00000000..3b33350b --- /dev/null +++ b/src-tauri/src/tray/devices.rs @@ -0,0 +1,373 @@ +//! tray/devices.rs — split from tray/mod.rs (#756). + +#![allow(unused_imports)] + +use super::*; +use crate::i18n::{self, Strings}; +use crate::menu::{ID_ABOUT, ID_OPEN_LOGS, ID_QUIT, ID_SETTINGS, ID_SHOW_DASHBOARD, ID_SHOW_LOGS}; +use std::time::{Duration, Instant}; +use tauri::{ + menu::{ + CheckMenuItemBuilder, MenuBuilder, MenuItemBuilder, PredefinedMenuItem, Submenu, + SubmenuBuilder, + }, + AppHandle, Manager, +}; + +/// What the `{ID_DEVICES}|{…}` suffix resolved to. Device menu ids carry the +/// stable Spotify device id (issue #388); the `LegacyIndex` variant accepts +/// ids minted by an older menu build still on screen when the app updated. +#[derive(Debug, PartialEq, Eq)] +pub enum DeviceMenuSelection { + DeviceId(String), + LegacyIndex(usize), + Invalid, +} + +/// Parses the suffix of a device menu-item id. A numeric suffix from an old +/// menu build is kept as `LegacyIndex` for back-compat; anything else is a +/// stable Spotify device id (`DeviceId`), including the empty string and the +/// `none` placeholder, which both resolve to `Invalid` downstream. +pub fn parse_device_menu_id(suffix: &str) -> DeviceMenuSelection { + if suffix == "none" { + return DeviceMenuSelection::Invalid; + } + // Spotify device ids are opaque base62 strings; a pure-ASCII-digit + // suffix can only have come from the old `{index}` scheme. + if !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit()) { + if let Ok(i) = suffix.parse::() { + return DeviceMenuSelection::LegacyIndex(i); + } + } + if suffix.is_empty() { + return DeviceMenuSelection::Invalid; + } + DeviceMenuSelection::DeviceId(suffix.to_string()) +} + +/// Redacted label for the unknown-device warn log: ids are bearer-adjacent, +/// so log only the length, never the id itself. +pub fn selected_for_log(selected: &DeviceMenuSelection) -> String { + match selected { + DeviceMenuSelection::DeviceId(id) => format!("", id.len()), + DeviceMenuSelection::LegacyIndex(i) => format!("", i), + DeviceMenuSelection::Invalid => "".to_string(), + } +} + +/// Resolves a parsed device-menu selection to a live Spotify device id. +/// Cache first (no IO), then a live `get_devices` re-fetch when the cached +/// list went stale (issue #388: the old `devices.get(i)` raced the +/// 60 s-throttled cache against the live device list). MUST run off the +/// menu-event thread — the fallback performs blocking HTTP (issue #386) — +/// and resolves its token through the shared refresh-aware policy, so an +/// expired access token does not strand a transfer on "unknown device" +/// (issue #586). +pub fn resolve_device_id(app: &AppHandle, selected: &DeviceMenuSelection) -> Option { + match selected { + DeviceMenuSelection::DeviceId(id) => { + // Fast path: still in the cached list and transferable. + let cached = DEVICES_CACHE.lock().as_ref().and_then(|(_, devices)| { + devices + .iter() + .find(|d| d.id.as_deref() == Some(id.as_str())) + .and_then(|d| d.id.clone()) + }); + if cached.is_some() { + return cached; + } + // Slow path: live re-fetch; the device may have appeared after + // the submenu was built, or the cache may be stale. + let state = app.state::>(); + match crate::commands::playback::player_with_refresh_typed( + state.inner(), + app, + "transfer device list", + crate::spotify::get_devices, + ) { + Ok(devices) => { + *DEVICES_CACHE.lock() = Some((Instant::now(), devices.clone())); + devices + .into_iter() + .find(|d| d.id.as_deref() == Some(id.as_str())) + .and_then(|d| d.id) + } + Err(e) => { + log::warn!("[TRAY] transfer: live device re-fetch failed: {}", e); + None + } + } + } + DeviceMenuSelection::LegacyIndex(i) => DEVICES_CACHE + .lock() + .as_ref() + .and_then(|(_, devices)| devices.get(*i).cloned()) + .and_then(|device| device.id), + DeviceMenuSelection::Invalid => None, + } +} + +/// Builds the Devices submenu from an already-fetched slice. No HTTP is +/// performed here — the caller must have fetched outside any tray lock. +/// See issue #217. +pub fn build_devices_submenu_from_devices( + app: &AppHandle, + devices: &[crate::spotify::DeviceInfo], +) -> Result, String> { + let s = i18n::current(); + let submenu = Submenu::with_id(app, ID_DEVICES, s.devices, true).map_err(|e| e.to_string())?; + if devices.is_empty() { + let empty = MenuItemBuilder::with_id(format!("{}|none", ID_DEVICES), s.no_devices) + .enabled(false) + .build(app) + .map_err(|e| e.to_string())?; + submenu.append(&empty).map_err(|e| e.to_string())?; + } else { + for device in devices.iter() { + let label = if device.is_active { + format!("✓ {}", device.name) + } else { + device.name.clone() + }; + // The active device (and id-less devices) can't be transferred to. + let enabled = !device.is_active && device.id.is_some(); + // Issue #388: carry the stable Spotify device id (not the list + // index) so a click resolves even when the cached list raced a + // live device change. Id-less devices reuse the `none` id: they + // are disabled and parse back to `Invalid`, which the click + // handler rejects with a warn. + let suffix = device.id.as_deref().unwrap_or("none"); + let item = MenuItemBuilder::with_id(format!("{}|{}", ID_DEVICES, suffix), label) + .enabled(enabled) + .build(app) + .map_err(|e| e.to_string())?; + submenu.append(&item).map_err(|e| e.to_string())?; + } + } + Ok(submenu) +} + +/// Builds the Up Next submenu from an already-fetched queue snapshot. +/// No HTTP here — fetch must have happened outside the tray lock. See issue #217. +pub fn build_queue_submenu_from_queue( + app: &AppHandle, + queue: Option<&crate::spotify::QueueInfo>, +) -> Result, String> { + let s = i18n::current(); + let submenu = Submenu::with_id(app, ID_QUEUE, s.up_next, true).map_err(|e| e.to_string())?; + let up_next: Vec = queue + .map(|q| q.up_next.iter().take(3).cloned().collect()) + .unwrap_or_default(); + if up_next.is_empty() { + let empty = MenuItemBuilder::with_id(format!("{}|none", ID_QUEUE), s.queue_empty) + .enabled(false) + .build(app) + .map_err(|e| e.to_string())?; + submenu.append(&empty).map_err(|e| e.to_string())?; + } else { + for (index, track) in up_next.iter().enumerate() { + let item = MenuItemBuilder::with_id( + format!("{}|none|{}", ID_QUEUE, index), + format!("{} - {}", track.artist, track.title), + ) + .enabled(false) + .build(app) + .map_err(|e| e.to_string())?; + submenu.append(&item).map_err(|e| e.to_string())?; + } + } + Ok(submenu) +} + +/// Issue #871: the tray's Volume submenu. Five discrete picks (0/25/50/75/100) +/// ride through `commands/playback::set_volume`, the same path the +/// Dashboard's slider uses. The whole submenu is disabled when the active +/// device refuses the volume capability, so a stale-capabilities click +/// never reaches the click handler. +pub fn build_volume_submenu( + app: &AppHandle, + s: &Strings, + enabled: bool, +) -> Result, String> { + let submenu = SubmenuBuilder::new(app, s.volume_menu) + .enabled(enabled) + .build() + .map_err(|e| e.to_string())?; + for percent in [0u32, 25, 50, 75, 100] { + let label = s + .volume_percent_label + .replace("{percent}", &percent.to_string()); + let item = MenuItemBuilder::with_id(format!("{VOLUME_ITEM_PREFIX}{percent}"), label) + .build(app) + .map_err(|e| e.to_string())?; + submenu.append(&item).map_err(|e| e.to_string())?; + } + Ok(submenu) +} + +/// Issue #871: the tray's Seek submenu (back / forward 30 s). Both +/// directions ride through `commands/playback::seek` — Spotify clamps the +/// position to the current track's duration on its side, so a "forward +/// 30 s" pick near the end of the track is a no-op rather than an error. +pub fn build_seek_submenu( + app: &AppHandle, + s: &Strings, + enabled: bool, +) -> Result, String> { + let submenu = SubmenuBuilder::new(app, s.seek_menu) + .enabled(enabled) + .build() + .map_err(|e| e.to_string())?; + let back_label = s + .seek_back_30s_label + .replace("{seconds}", &(SEEK_BACK_30_MS / 1000).to_string()); + let back = + MenuItemBuilder::with_id(format!("{SEEK_ITEM_PREFIX}-{SEEK_BACK_30_MS}"), back_label) + .build(app) + .map_err(|e| e.to_string())?; + submenu.append(&back).map_err(|e| e.to_string())?; + let forward_label = s + .seek_forward_30s_label + .replace("{seconds}", &(SEEK_FORWARD_30_MS / 1000).to_string()); + let forward = MenuItemBuilder::with_id( + format!("{SEEK_ITEM_PREFIX}+{SEEK_FORWARD_30_MS}"), + forward_label, + ) + .build(app) + .map_err(|e| e.to_string())?; + submenu.append(&forward).map_err(|e| e.to_string())?; + Ok(submenu) +} + +/// Issue #871: the volume menu id parsed into the documented Spotify +/// `volume_percent`. `None` for any id that is not one of the five known +/// percentages so a stale menu from an older build cannot drive a +/// volume slider to a number Spotify would reject. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VolumeMenuSelection { + Percent(u32), +} + +/// Parses a volume menu-item id. The strict bounds (`0..=100`) match the +/// documented Spotify range, so a malicious or stale id cannot push the +/// device's volume past Spotify's own ceiling. +pub fn parse_volume_menu_id(id: &str) -> Option { + let raw = id.strip_prefix(VOLUME_ITEM_PREFIX)?; + let percent: u32 = raw.parse().ok()?; + if percent <= 100 { + Some(VolumeMenuSelection::Percent(percent)) + } else { + None + } +} + +/// Issue #871: the seek menu id parsed into a signed millisecond delta. +/// The click handler applies the delta to the current `progress_ms`, +/// clamped at zero and the track's `duration_ms` so a forward jump past +/// the end of the track is a no-op rather than an error. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SeekMenuSelection { + Delta(i64), +} + +pub fn parse_seek_menu_id(id: &str) -> Option { + let raw = id.strip_prefix(SEEK_ITEM_PREFIX)?; + // The sign lives in the first character; `+30` is forward, `-30` is + // back. Anything else is refused. + if let Some(rest) = raw.strip_prefix('+') { + let ms: u64 = rest.parse().ok()?; + // Cap at a sensible upper bound so a typo cannot jump 1000 minutes + // forward. The tray only ever mints `+/- 30000` (30 s) but the + // parser must defend against a menu snapshot from a future build. + if ms <= 10 * 60 * 1000 { + return Some(SeekMenuSelection::Delta(ms as i64)); + } + } else if let Some(rest) = raw.strip_prefix('-') { + let ms: u64 = rest.parse().ok()?; + if ms <= 10 * 60 * 1000 { + return Some(SeekMenuSelection::Delta(-(ms as i64))); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tray::testkit::{body_of, prod_source, tray_prod_source}; + /// Issue #388: device menu ids must parse to stable Spotify device ids, + /// never to a list index. Pure helper — no Tauri runtime needed. + #[test] + fn parse_device_menu_id_carries_stable_id() { + assert_eq!( + parse_device_menu_id("abc123XYZ"), + DeviceMenuSelection::DeviceId("abc123XYZ".to_string()) + ); + assert_eq!( + parse_device_menu_id("0"), + DeviceMenuSelection::LegacyIndex(0) + ); + assert_eq!( + parse_device_menu_id("12"), + DeviceMenuSelection::LegacyIndex(12) + ); + assert_eq!(parse_device_menu_id("none"), DeviceMenuSelection::Invalid); + assert_eq!(parse_device_menu_id(""), DeviceMenuSelection::Invalid); + } + /// Issue #482: id-snapshot edge cases -- a reorder between menu build + /// and click must still hit the labelled device (id-keyed resolution), + /// and unknown/empty selections must resolve to Invalid, never to a + /// neighboring device by index. + #[test] + fn parse_device_menu_id_rejects_unknown_and_empty() { + // Spotify ids are opaque base62; digit-only suffixes are the legacy + // index scheme and must keep parsing as LegacyIndex, never as an id. + assert_eq!( + parse_device_menu_id("007"), + DeviceMenuSelection::LegacyIndex(7) + ); + // Mixed alphanumeric ids (even with a leading digit) are stable ids. + assert_eq!( + parse_device_menu_id("0abc"), + DeviceMenuSelection::DeviceId("0abc".to_string()) + ); + // The placeholder and the empty string are never a device. + assert_eq!(parse_device_menu_id("none"), DeviceMenuSelection::Invalid); + assert_eq!(parse_device_menu_id(""), DeviceMenuSelection::Invalid); + // Redaction helper never leaks the id itself. + let logged = selected_for_log(&DeviceMenuSelection::DeviceId("secret-id-123".to_string())); + assert!( + !logged.contains("secret-id-123"), + "device id must not appear in logs" + ); + assert!( + logged.contains("len="), + "redacted label must carry the length" + ); + } + /// Issue #388: the click handler must resolve by id with a live + /// re-fetch fallback instead of `devices.get(i)`. Issue #586: that + /// re-fetch must resolve its token through the shared refresh-aware + /// policy, so an expired token does not strand a transfer. + #[test] + fn device_click_resolves_by_id_with_live_fallback() { + let src = tray_prod_source(); + let body = body_of(prod_source(src), "fn resolve_device_id("); + assert!( + body.contains("crate::spotify::get_devices"), + "resolve_device_id must live re-fetch when the cache misses" + ); + assert!( + body.contains("player_with_refresh_typed("), + "the live re-fetch must use the refresh-aware token policy (issue #586)" + ); + assert!( + !body.contains("tokens.spotify()"), + "resolve_device_id must not snapshot the raw access token (issue #586)" + ); + assert!( + !body.contains("devices.get(*i).cloned()") || body.contains("LegacyIndex"), + "index lookup must survive only on the LegacyIndex back-compat path" + ); + } +} diff --git a/src-tauri/src/tray/mod.rs b/src-tauri/src/tray/mod.rs new file mode 100644 index 00000000..965174d6 --- /dev/null +++ b/src-tauri/src/tray/mod.rs @@ -0,0 +1,2111 @@ +//! System tray menu (mod.rs keeps the shell; #756 split the concerns out). +use crate::events::PlaybackStateChanged; +use crate::i18n; +use crate::menu::{ID_ABOUT, ID_OPEN_LOGS, ID_QUIT, ID_SETTINGS, ID_SHOW_DASHBOARD, ID_SHOW_LOGS}; +use std::borrow::Cow; +use std::sync::atomic::Ordering; +use std::sync::OnceLock; +use std::time::Instant; +use tauri::{ + menu::{CheckMenuItemBuilder, MenuBuilder, MenuItemBuilder, PredefinedMenuItem}, + tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent}, + AppHandle, Emitter, Listener, Manager, +}; +pub mod actions; +pub mod cache; +pub mod dedup; +pub mod devices; +pub mod snooze; +pub(crate) mod testkit; +pub use actions::{ + await_sync_toggle, force_tray_refresh, force_tray_refresh_from_app, refresh_tray_for_locale, + refresh_tray_from_state, repaint_tray_from_state, run_player_action, tray_write_lock, + DELAYED_REFRESH_IN_FLIGHT, TOGGLE_SETTLE_POLL, TOGGLE_SETTLE_TIMEOUT, +}; +pub use cache::{ + action_fetch_due, cache_buckets, cached_devices, cached_queue, devices_for_menu, + paint_fetch_mode, queue_for_menu, tray_fetch_mode, DeviceCacheSlot, TrayFetch, TrayPaint, + DEVICES_CACHE, LAST_ACTION_FETCH, LAST_TRAY_ACTION, QUEUE_CACHE, TRAY_POST_ACTION_FETCH_MIN, + TRAY_SPOTIFY_FETCH_THROTTLE, +}; +pub use dedup::{ + consume_playback_state_changed, last_repeat_state, last_tray_state, live_window_visible, + note_playback_modes, note_playing_state, note_window_visibility, repeat_menu_label, + shuffle_toggle_target, sync_status_line, tray_snapshot_for, tray_state_changed, window_visible, + TrayStateSnapshot, LAST_PLAYING_STATE, LAST_REPEAT_STATE, LAST_SHUFFLE_STATE, LAST_TRAY_STATE, + WINDOW_VISIBLE, +}; +pub use devices::{ + build_devices_submenu_from_devices, build_queue_submenu_from_queue, build_seek_submenu, + build_volume_submenu, parse_device_menu_id, parse_seek_menu_id, parse_volume_menu_id, + resolve_device_id, selected_for_log, DeviceMenuSelection, SeekMenuSelection, + VolumeMenuSelection, +}; +pub use snooze::{ + build_manual_status_submenu, build_profile_submenu, build_snooze_submenu, + clear_expired_snooze_at_startup, parse_snooze_menu_id, resolve_snooze, snooze_dedup_key, + snooze_from_app_state, snooze_status_line, store_active_profile, store_snooze, + write_active_profile, write_snooze, SnoozeMenuSelection, TraySnooze, +}; +// Menu item IDs +const ID_SHOW_HIDE: &str = "show_hide_window"; +const ID_PAUSE_SYNC: &str = "pause_sync"; +const ID_RESUME_SYNC: &str = "resume_sync"; +const ID_CURRENT_TRACK: &str = "current_track"; +/// Disabled head-of-menu item that states what the app is actually doing +/// (issue #591) — the Pause/Resume verb alone left sync state unstated, and +/// the presence-gate badge is macOS-only. +const ID_SYNC_STATUS: &str = "sync_status"; +// Spotify playback control (issue #3.0-P3). Device submenu items carry +// ids of the form `{ID_DEVICES}|{spotify device id}` so the click handler +// resolves the stable id instead of racing a list index (issue #388). +const ID_PLAY_PAUSE: &str = "play_pause"; +const ID_PREVIOUS: &str = "previous"; +const ID_NEXT: &str = "next"; +/// Shuffle toggle (issue #582). A check item whose mark comes from the +/// `shuffle_state` the poll body already carries — no extra request. The +/// label is the `shuffle` entry of the i18n table (issue #674): the state +/// itself stays the native check mark. +const ID_SHUFFLE: &str = "shuffle"; +/// Repeat toggle (issue #582). Repeat has THREE states (`off`/`context`/ +/// `track`), so the item's label spells the mode out — a check mark alone +/// cannot tell `context` from `track`. +const ID_REPEAT: &str = "repeat"; +const ID_DEVICES: &str = "devices"; +const ID_QUEUE: &str = "queue"; +/// Menu-item id prefix for device submenu entries (`{ID_DEVICES}|{device id}`). +/// `concat!` cannot take a const, so this mirrors `ID_DEVICES` literally; +/// keep the two in sync when either changes. +const DEVICE_ITEM_PREFIX: &str = "devices|"; + +// Snooze submenu (4.7.0, S9 / issue #677). The three unqualified ids are the +// presets; the fourth only exists while a snooze is active, so the way out of a +// snooze always sits beside the way in. Issue #867 adds a fifth id — the +// "until this meeting ends" entry — that is appended only while a busy +// Outlook meeting is currently in progress. +const ID_SNOOZE_30: &str = "snooze|30m"; +const ID_SNOOZE_1H: &str = "snooze|1h"; +const ID_SNOOZE_TOMORROW: &str = "snooze|tomorrow"; +const ID_SNOOZE_NEXT_MEETING: &str = "snooze|next_meeting"; +const ID_SNOOZE_RESUME: &str = "snooze|resume"; +/// Menu-item id prefix for the snooze submenu. Mirrors the ids above literally +/// (`concat!` cannot take a const); keep both in sync when either changes. +const SNOOZE_ITEM_PREFIX: &str = "snooze|"; +const SNOOZE_30_SUFFIX: &str = "30m"; +const SNOOZE_1H_SUFFIX: &str = "1h"; +const SNOOZE_TOMORROW_SUFFIX: &str = "tomorrow"; +const SNOOZE_NEXT_MEETING_SUFFIX: &str = "next_meeting"; +const SNOOZE_RESUME_SUFFIX: &str = "resume"; + +// Issue #870: the "Recent statuses" submenu entries carry the +// `{MANUAL_STATUS_ITEM_PREFIX}|` shape — the index resolves to a +// recent-status entry, and a stale index falls off the back of the ring +// cleanly (the click handler ignores unknown indices). The literal suffixes +// declared below are stable across the GUI and the tray, so a menu rebuilt +// against a stale snapshot cannot dispatch an unknown action. +const MANUAL_STATUS_ITEM_PREFIX: &str = "manualstatus|"; +const ID_MANUAL_STATUS_CLEAR: &str = "manualstatus|clear"; + +// Issue #871: the Volume submenu entries carry `{VOLUME_ITEM_PREFIX}|{n}` +// where `n` is the literal Spotify volume percentage. The Seek submenu +// entries carry `{SEEK_ITEM_PREFIX}|{+|-}{ms}` (the sign is in the id so +// the click handler cannot mistake a seek-back for a seek-forward). +const VOLUME_ITEM_PREFIX: &str = "volume|"; +const SEEK_ITEM_PREFIX: &str = "seek|"; +const SEEK_BACK_30_MS: u64 = 30_000; +const SEEK_FORWARD_30_MS: u64 = 30_000; + +// Issue #869: the presence-profile submenu. The "no profile" entry clears +// the active profile (back to the base configuration); every other entry +// carries `{PROFILE_ITEM_PREFIX}|{profile name}` and the click handler +// resolves the name against the clamped list. Names are unique and ≤ 32 +// chars after `clamp_presence_profiles`, so the id dispatch never has to +// disambiguate. +const ID_PROFILE_BASE: &str = "profile|base"; +const PROFILE_ITEM_PREFIX: &str = "profile|"; + +/// A safe diagnostic label for a native menu event id. +/// +/// Fixed action ids remain readable so support diagnostics keep their useful +/// dispatch context. Ids carrying a menu payload expose only the stable prefix +/// and suffix length; unknown fixed ids expose only their length. This follows +/// the device-selection redaction discipline and keeps bearer-adjacent Spotify +/// device ids out of every dispatcher log line. +pub(crate) fn menu_event_id_for_log(id: &str) -> Cow<'_, str> { + if matches!( + id, + ID_SHOW_HIDE + | ID_PAUSE_SYNC + | ID_RESUME_SYNC + | ID_CURRENT_TRACK + | ID_SYNC_STATUS + | ID_PLAY_PAUSE + | ID_PREVIOUS + | ID_NEXT + | ID_SHUFFLE + | ID_REPEAT + | ID_QUIT + | ID_DEVICES + | ID_QUEUE + | ID_SETTINGS + | ID_OPEN_LOGS + | ID_SHOW_DASHBOARD + | ID_SHOW_LOGS + | ID_ABOUT + ) { + return Cow::Borrowed(id); + } + + match id.split_once('|') { + Some((prefix, suffix)) => Cow::Owned(format!("<{prefix} len={}>", suffix.len())), + None => Cow::Owned(format!("", id.len())), + } +} + +pub(crate) fn log_dispatched_menu_event_with(id: &str, emit: impl FnOnce(&log::Record<'_>)) { + let redacted = menu_event_id_for_log(id); + let message = format!("[TRAY] menu event: id={redacted}"); + let args = format_args!("{message}"); + let record = log::Record::builder() + .args(args) + .level(log::Level::Info) + .target(module_path!()) + .build(); + emit(&record); +} + +pub(crate) fn log_dispatched_menu_event(id: &str) { + let level = log::Level::Info; + if level > log::STATIC_MAX_LEVEL || level > log::max_level() { + return; + } + let metadata = log::Metadata::builder() + .level(level) + .target(module_path!()) + .build(); + if !log::logger().enabled(&metadata) { + return; + } + log_dispatched_menu_event_with(id, |record| log::Log::log(log::logger(), record)); +} + +pub(crate) static TRAY: OnceLock = OnceLock::new(); + +/// Get the global TrayIcon instance. +pub fn get_tray() -> Option<&'static TrayIcon> { + TRAY.get() +} + +/// Single dispatcher for every native menu click (issue #804). +/// +/// The tray builder's `on_menu_event` is a *global* listener — Tauri calls it +/// for window-menu events too — so this one function owns every tray-built id +/// and delegates anything else (the app-menu-only ids) to +/// [`crate::menu::handle_app_menu_event`]. It is registered exactly once (on +/// the tray builder in [`setup_tray`]): one click then logs and emits exactly +/// once, and tray-only ids never reach the app-menu handler's unknown-event log. +/// The second `window.on_menu_event` registration lib.rs used to carry +/// double-fired every shared id and is gone. +pub fn handle_menu_event(app: &AppHandle, id: &str) { + log_dispatched_menu_event(id); + match id { + ID_SHOW_HIDE => { + if let Some(window) = app.get_webview_window("main") { + if window.is_visible().unwrap_or(false) { + let _ = window.hide(); + // Issue #886: the dedup key reads the visibility mirror, + // so the tray's own window changes must report it. + note_window_visibility(false); + } else { + let _ = window.show(); + note_window_visibility(true); + // Issue #483: a minimized window stays minimized + // after show() -- unminimize first (mirrors the + // single-instance raise in lib.rs and show_window). + let _ = window.unminimize(); + let _ = window.set_focus(); + } + } else { + // Residual of #826: this arm is the tray's second window-raise + // path, and it must not stay silent about the same condition + // `commands::window::show_window` warns about. + log::warn!("[TRAY] show/hide: main window not found"); + } + // Issue #587: the repaint performs blocking Spotify HTTP + // (devices/queue fetches, 10 s timeout each) whenever the + // 60 s throttle has lapsed, so it must never run on the + // menu-event thread — offload it like the #386 player arms. + refresh_tray_from_state(app); + } + ID_PAUSE_SYNC | ID_RESUME_SYNC => { + // Issue #588: this arm only *asks* the frontend to toggle + // (the frontend owns the start/stop call), so the running + // flag settles asynchronously. Watch it from a worker and + // repaint from backend truth, instead of relying on the + // Dashboard route being mounted to mirror the change back. + let before = app + .state::>() + .polling + .is_syncing(); + let _ = app.emit("toggle-pause", ()); + let app_handle = app.clone(); + std::thread::spawn(move || { + if !await_sync_toggle(&app_handle, before) { + log::debug!( + "[TRAY] pause/resume: sync flag unchanged after {:?}; repainting anyway", + TOGGLE_SETTLE_TIMEOUT + ); + } + repaint_tray_from_state(&app_handle, "pause/resume"); + }); + } + ID_QUIT => { + // Issue #383: Quit must terminate the process even with no + // frontend listener — the old hide-only arm wedged the app + // in the tray with no way out. Route through the shared + // graceful shutdown (emits app-shutdown, then exits + // unconditionally after SHUTDOWN_GRACE). + crate::menu::request_graceful_shutdown(app); + } + // Shared with the app menu (issue #804): the tray builder's listener is + // global, so this arm owns every `settings` click from either surface. + ID_SETTINGS => { + let _ = app.emit("navigate", "settings"); + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + note_window_visibility(true); + // Issue #483: mirror the unminimize in the Show arm. + let _ = window.unminimize(); + let _ = window.set_focus(); + } + // Issue #592: showing the window here changes the Show/Hide + // label, so the tray must be repainted from backend state. + refresh_tray_from_state(app); + } + ID_OPEN_LOGS => { + let _ = app.emit("open-logs-folder", ()); + } + // Spotify playback control (issue #3.0-P3). These dispatch + // directly against the Spotify API with the stored access + ID_PLAY_PAUSE => { + // Issue #386: the click-path blocking Spotify HTTP must not + // run on the menu-event thread — a slow network would wedge + // the tray menu. Offload everything (the currently-playing + // GET plus the play/pause action) to a worker thread. + // + // Issue #586: the worker resolves its token through the + // shared refresh-aware policy rather than snapshotting + // `state.tokens.spotify()`, so an expired access token is + // refreshed (and retried once) exactly like the command layer. + let app_handle = app.clone(); + std::thread::spawn(move || { + // Resolve the ACTUAL playing state from the API rather + // than the stored track: the polling loop's `is_playing` + // goes stale on a same-track pause (it's only re-stored + // on title/artist change), and an external device may + // have changed state since. One extra GET per click is + // fine — this is user-initiated. Unknown → resume. + // Unconditional GET (`None`): user-initiated one-off + // click with no stored validator. C11 signature. + let state = app_handle.state::>(); + let should_pause = match crate::commands::playback::player_with_refresh_typed( + state.inner(), + &app_handle, + "play/pause state", + |token| crate::spotify::get_currently_playing(token, None), + ) { + Ok(crate::spotify::CurrentlyPlaying::Modified { now: Some(now), .. }) => { + now.media.is_playing + } + Ok(_) => false, + Err(e) => { + log::warn!("[TRAY] play/pause: playback state read failed: {}", e); + let _ = app_handle.emit("playback-error", e.to_string()); + return; + } + }; + if should_pause { + run_player_action(&app_handle, "pause", Some(false), None, |t| { + crate::spotify::player_pause(t, None) + }); + } else { + run_player_action(&app_handle, "play", Some(true), None, |t| { + crate::spotify::player_play(t, None) + }); + } + }); + } + ID_PREVIOUS => { + // Issue #386: offload the blocking Spotify HTTP off the + // menu-event thread. Skipping doesn't change playing state. + let app_handle = app.clone(); + std::thread::spawn(move || { + run_player_action(&app_handle, "previous", None, None, |token| { + crate::spotify::player_previous(token, None) + }); + }); + } + ID_NEXT => { + // Issue #386: offload the blocking Spotify HTTP off the + // menu-event thread. Skipping doesn't change playing state. + let app_handle = app.clone(); + std::thread::spawn(move || { + run_player_action(&app_handle, "next", None, None, |token| { + crate::spotify::player_next(token, None) + }); + }); + } + ID_SHUFFLE => { + // Issue #582: the target state is the inverse of the last + // state we know about (the poll body's `shuffle_state`, or + // this item's own last successful toggle). Issue #386: the + // blocking Spotify HTTP must not run on the menu-event + // thread. + // + // The new state is handed to `run_player_action` instead of + // being stored here: that function records it in its success + // arm *before* the menu rebuild it triggers, so the rebuilt + // item shows the state the API just accepted (storing after + // the call would repaint the old state and no later rebuild + // would correct it). On a 403 from a non-Premium account + // nothing is recorded and the item keeps showing the truth. + let app_handle = app.clone(); + std::thread::spawn(move || { + let target = shuffle_toggle_target(LAST_SHUFFLE_STATE.load(Ordering::Acquire)); + let repeat = last_repeat_state(); + run_player_action( + &app_handle, + "shuffle", + None, + Some((target, repeat)), + |token| crate::spotify::player_set_shuffle(token, target, None), + ); + }); + } + ID_REPEAT => { + // Issue #582: repeat cycles off → context → track → off, + // matching Spotify's own player button, so "repeat one" is + // reachable from the tray. Same off-thread and + // record-on-success discipline as Shuffle above. + let app_handle = app.clone(); + std::thread::spawn(move || { + let target = last_repeat_state().next(); + let shuffle = LAST_SHUFFLE_STATE.load(Ordering::Acquire); + run_player_action( + &app_handle, + "repeat", + None, + Some((shuffle, target)), + |token| crate::spotify::player_set_repeat(token, target, None), + ); + }); + } + id if id.starts_with(SNOOZE_ITEM_PREFIX) => { + // S9 (issue #677): a snooze click writes `config.json` (atomic + // write + fsync), so it must run off the menu-event thread like + // every other arm here — a slow disk would otherwise wedge the + // native menu. The repaint follows the write, so it renders the + // new countdown, the "Resume sync now" entry and the + // snooze-forced cache-only fetch mode in one pass. + // The id is copied out of the borrowed `event` before the + // worker takes ownership (it is used in the unknown-id warn). + let raw = id.to_string(); + let selection = parse_snooze_menu_id(&raw); + let app_handle = app.clone(); + std::thread::spawn(move || { + match selection { + Some(SnoozeMenuSelection::Preset(preset)) => { + let now_utc = chrono::Utc::now(); + // Issue #867: the meeting-bound preset needs the + // calendar cache at click time. We capture it here + // (no fetch) — the entry is only built while a + // meeting is active, so the cache is fresh + // enough. + let next_meeting_end = + if preset == crate::config::SnoozePreset::UntilNextMeetingEnds { + let state = app_handle.state::>(); + state.calendar.current_meeting_end(now_utc) + } else { + None + }; + let deadline = crate::config::snooze_preset_deadline( + preset, + now_utc, + chrono::Local::now(), + next_meeting_end, + ); + if let Err(e) = write_snooze(&app_handle, Some(deadline)) { + log::error!("[TRAY] snooze: could not store the deadline: {}", e); + } + } + Some(SnoozeMenuSelection::Resume) => { + if let Err(e) = write_snooze(&app_handle, None) { + log::error!("[TRAY] snooze: could not clear the deadline: {}", e); + } + } + None => { + log::warn!( + "[TRAY] snooze: unrecognized menu id '{}'", + menu_event_id_for_log(&raw) + ); + } + } + repaint_tray_from_state(&app_handle, "snooze"); + }); + } + id if id == ID_PROFILE_BASE || id.starts_with(PROFILE_ITEM_PREFIX) => { + // Issue #869: a profile click writes `config.json` (atomic + // write + fsync), so it must run off the menu-event thread + // like the snooze arm above — a slow disk would otherwise + // wedge the native menu. The id dispatch never has to + // disambiguate names because `clamp_presence_profiles` + // dedupes and rejects pipes at load/save, so the + // `{PROFILE_ITEM_PREFIX}|{name}` format is unambiguous. + // + // The id is copied out of the borrowed `event` before the + // worker takes ownership (it is used in the unknown-name + // warn and the profile-not-found path). + let raw = id.to_string(); + let app_handle = app.clone(); + std::thread::spawn(move || { + // Resolve the picked name from the stored config. + // Reading state.config here (off the menu-event thread) + // keeps the click handler off the config lock. + let state = app_handle.state::>(); + let target: Option = if raw == ID_PROFILE_BASE { + None + } else if let Some(stripped) = raw.strip_prefix(PROFILE_ITEM_PREFIX) { + // Skip the disabled empty placeholder. + if stripped == "empty" { + repaint_tray_from_state(&app_handle, "profile (empty placeholder)"); + return; + } + // Validate against the clamped list — a name that + // was deleted between the rebuild and the click + // (or a stale menu from a previous build) must + // fall back to base, not silently land on a + // phantom id. + let exists = + state.config.get().as_ref().is_some_and(|c| { + c.presence_profiles.iter().any(|p| p.name == stripped) + }); + if !exists { + log::warn!( + "[TRAY] profile: {:?} not found — falling back to base", + menu_event_id_for_log(&raw) + ); + None + } else { + Some(stripped.to_string()) + } + } else { + log::warn!( + "[TRAY] profile: unrecognized menu id '{}'", + menu_event_id_for_log(&raw) + ); + return; + }; + if let Err(e) = write_active_profile(&app_handle, target) { + log::error!("[TRAY] profile: could not persist the switch: {}", e); + } + repaint_tray_from_state(&app_handle, "profile"); + }); + } + id if id == ID_MANUAL_STATUS_CLEAR => { + // Issue #870: the tray's "Clear manual status" entry. Routes + // through the same `clear_manual_status_inner` helper the + // Dashboard composer uses, on a worker thread — the Graph + // POST + the persisted record reset must not run on the + // menu-event thread. + let app_handle = app.clone(); + std::thread::spawn(move || { + let state = app_handle.state::>(); + if let Err(e) = + crate::commands::status::clear_manual_status_inner(state.inner(), &app_handle) + { + log::error!("[TRAY] manual status clear: {}", e); + } + repaint_tray_from_state(&app_handle, "manual status clear"); + }); + } + id if id.starts_with(MANUAL_STATUS_ITEM_PREFIX) => { + // Issue #870: a "Recent statuses" pick. The trailing index + // resolves to one of the ring's slots; a stale index (the + // ring rotated since the menu was built) is logged and + // ignored. The pick carries the user's text verbatim, so the + // Dashboard composer and the tray share the same store. + let raw = id.to_string(); + let index: usize = raw + .strip_prefix(MANUAL_STATUS_ITEM_PREFIX) + .and_then(|s| s.parse().ok()) + .unwrap_or(99); + let recent = crate::commands::status::load_recent_statuses(); + let picked = recent.get(index).cloned(); + let app_handle = app.clone(); + std::thread::spawn(move || { + let Some(entry) = picked else { + log::warn!( + "[TRAY] manual status pick: stale index {index}; the recent ring rotated" + ); + repaint_tray_from_state(&app_handle, "manual status pick (stale)"); + return; + }; + let state = app_handle.state::>(); + let expiry = crate::commands::status::clamp_expiry_public(60); + if let Err(e) = crate::commands::status::set_manual_status_inner( + state.inner(), + &app_handle, + &entry.message, + expiry, + ) { + log::error!("[TRAY] manual status pick: {}", e); + } + repaint_tray_from_state(&app_handle, "manual status pick"); + }); + } + id if id.starts_with(VOLUME_ITEM_PREFIX) => { + // Issue #871: the tray's Volume submenu picked a percentage. + // The click handler offloads the Spotify HTTP and records the + // new volume so the Dashboard slider mirrors the new value + // on its next SyncStatus fetch. A stale id (an old build's + // menu) is logged and ignored. + let raw = id.to_string(); + let selection = parse_volume_menu_id(&raw); + let app_handle = app.clone(); + std::thread::spawn(move || { + let Some(VolumeMenuSelection::Percent(percent)) = selection else { + log::warn!( + "[TRAY] volume: unrecognized menu id '{}'", + menu_event_id_for_log(&raw) + ); + repaint_tray_from_state(&app_handle, "volume (stale)"); + return; + }; + run_player_action(&app_handle, "volume", None, None, |token| { + crate::spotify::player_set_volume(token, percent, None) + }); + }); + } + id if id.starts_with(SEEK_ITEM_PREFIX) => { + // Issue #871: the tray's Seek submenu picked a delta. The + // click handler reads the stored progress + duration from + // the AppState, computes the new position, and dispatches + // through `commands/playback::seek` so the same token + // refresh + retry-once policy applies. A stale id (an old + // build's menu) is logged and ignored. + let raw = id.to_string(); + let selection = parse_seek_menu_id(&raw); + let app_handle = app.clone(); + std::thread::spawn(move || { + let Some(SeekMenuSelection::Delta(delta)) = selection else { + log::warn!( + "[TRAY] seek: unrecognized menu id '{}'", + menu_event_id_for_log(&raw) + ); + repaint_tray_from_state(&app_handle, "seek (stale)"); + return; + }; + let state = app_handle.state::>(); + let current = state + .polling + .current_track() + .as_ref() + .map(|t| (t.progress_ms, t.duration_ms)); + let Some((progress, duration)) = current else { + log::warn!("[TRAY] seek: no current track, cannot seek"); + repaint_tray_from_state(&app_handle, "seek (no track)"); + return; + }; + // Spotify reports `progress_ms` as `Option` (issue #3.0-P3 + // — the documented "Can be `null`"); the duration is + // always present. A missing `progress` defaults to 0 so + // the user still gets a forward-30s jump from the + // beginning of the track. + let progress = progress.unwrap_or(0); + let new_position = if delta >= 0 { + progress.saturating_add(delta as u64).min(duration) + } else { + progress.saturating_sub((-delta) as u64) + }; + run_player_action(&app_handle, "seek", None, None, |token| { + crate::spotify::player_seek(token, new_position as i64, None) + }); + }); + } + id if id.starts_with(DEVICE_ITEM_PREFIX) => { + // Device submenu item: `{ID_DEVICES}|{stable device id}` + // resolved by id (issue #388), with a live re-fetch fallback + // when the cached list went stale. Issue #386: the whole + // resolution + transfer runs on a worker thread so no HTTP + // touches the menu-event thread. + let raw = id + .strip_prefix(DEVICE_ITEM_PREFIX) + .unwrap_or("") + .to_string(); + let selected = parse_device_menu_id(&raw); + let app_handle = app.clone(); + std::thread::spawn(move || { + let device_id = resolve_device_id(&app_handle, &selected); + match device_id { + Some(device_id) => { + // Transfer starts playback on the target device. + run_player_action(&app_handle, "transfer", Some(true), None, |token| { + crate::spotify::player_transfer(token, &device_id, true) + }); + } + None => { + log::warn!( + "[TRAY] transfer: unknown or id-less device selected (id={})", + selected_for_log(&selected) + ); + } + } + }); + } + _ => crate::menu::handle_app_menu_event(app, id), + } +} + +pub fn setup_tray(app: &tauri::App) -> Result<(), String> { + // 4.7.0 (issue #674): the tray renders in the locale stored in the config. + // Installed before the first menu build so the initial menu (and the + // immediately following `update_tray_menu`) already carry the right + // labels; an unknown/absent value installs English. + let state = app.state::>(); + crate::i18n::install_from_app_state(state.inner()); + // 4.7.0 (S9 / issue #677): the startup cleanup for a snooze deadline that + // expired while the app was closed. Runs before the first menu build so the + // tray never renders a state the config no longer holds, and only writes + // when there is actually something to clear. + clear_expired_snooze_at_startup(app.handle()); + // Issue #768: the tray is built WITHOUT a menu and `update_tray_menu` below + // sets the real one. The transient initial menu this used to build was a + // second, already-diverged layout (no status row, no now-playing row, + // hardcoded Pause/Show labels) that the immediate rebuild replaced + // microseconds later — and that a FAILED rebuild left on screen. An empty + // tray can only be empty; it is reachable for the first moments of + // startup, while the window is not yet interactive. + + let builder = TrayIconBuilder::new() + .tooltip("PresenceJam") + // Issue #911: macOS wants a monochrome TEMPLATE image (marked as one + // right after the build below); Windows and Linux keep the + // application icon. + .icon(tray_icon(app)?) + // Issue #971: Tauri documents this flag as unsupported on Linux, where + // a left click opens the AppIndicator menu unconditionally. It only + // ever changes behaviour on Windows and macOS. + .show_menu_on_left_click(false) + .on_menu_event(|app, event| handle_menu_event(app, event.id().as_ref())) + // Issue #971: Linux delivers no tray click events at all (Tauri lists + // `TrayIconEvent::Click` as unsupported there), so the `tray-click` + // emit below — and the frontend listener for it — are inert on that + // platform. Nothing may depend on that event: the window is raised from + // the menu's Show/Hide item, which every platform has. + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + let app = tray.app_handle(); + let _ = app.emit("tray-click", ()); + } + }); + // Issue #927: the tray build is the one call here that panics instead of + // returning `Err`. On Linux it dlopens libayatana-appindicator3.so.1 / + // libappindicator3.so.1 through `libappindicator-sys`, whose `Lazy` + // panics when neither soname resolves — and this runs inline on the setup + // thread, so the panic would unwind across the event-loop callback instead of + // becoming the error `lib.rs` already handles by running without a tray. + // The panic is raised on this thread, above the FFI boundary, so it is + // catchable. + let tray = guard_tray_panic( + "tray icon", + std::panic::AssertUnwindSafe(|| builder.build(app).map_err(|e| e.to_string())), + )?; + + // Issue #911: mark the menu-bar icon as a template, so macOS draws it from + // its alpha channel and tints it — black stays black on a light menu bar and + // inverts on a dark one, and it dims with the bar for a modal. The call is a + // no-op off macOS (Tauri only implements it there), so it stays gated. + #[cfg(target_os = "macos")] + if let Err(e) = tray.set_icon_as_template(true) { + log::warn!( + "[TRAY] setup_tray: failed to mark the menu-bar icon as a template: {}", + e + ); + } + + // Store the TrayIcon globally (idempotent) + if TRAY.get().is_some() { + log::warn!("[TRAY] setup_tray: already initialized, skipping"); + return Ok(()); + } + TRAY.set(tray) + .map_err(|_| "Tray already initialized".to_string())?; + + // Issue #689 (D6): the polling loop emits `playback-state-changed` when + // a track's playing state changes without the track itself changing. + // The Play/Pause mark and the status line read `LAST_PLAYING_STATE`, + // which a rebuild only re-seeds on a new track key — so a same-track + // pause would leave the mark claiming "playing" until the next track. + // Consuming the event here keeps the tray's belief truthful; the + // poller's own re-store then drives the rebuild that paints it. + app.listen("playback-state-changed", |event| { + consume_playback_state_changed(event.payload()); + }); + + // Immediately set the real menu to reflect actual state (Bug 11 fix). + // Without this the tray would stay menu-less until the first poll, and a + // track cached from the previous session would not be shown. + // Issue #768: there is no throwaway menu underneath this any more, so a + // failure here cannot leave a "Pause Sync"-labelled stale layout behind. + // The dedup snapshot is only committed by a successful rebuild, so the + // next poll retries this paint. + // Issue #882: this paint is CACHE-ONLY. It runs on the main thread inside + // Tauri's `setup()`, before the event loop starts and while the dedup + // snapshot is still empty — so a normal rebuild would issue the devices + // and queue GETs right here (10 s timeout each) with no window on screen + // to explain the wait, and could never take the "nothing changed" early + // return. + let state = app.state::>(); + let is_syncing = state.polling.is_syncing(); + let current_track = state.polling.current_track().clone(); + if let Err(e) = update_tray_menu_startup(app.handle(), is_syncing, current_track) { + log::warn!( + "[TRAY] setup_tray: failed to update initial tray menu: {}", + e + ); + } + // Issue #882: the real devices/queue fetch belongs to the worker refresh, + // off the setup thread, so the startup paint costs no network at all and + // the submenus still converge to the full content a moment later. + refresh_tray_from_state(app.handle()); + + log::info!("[TRAY] setup_tray: system tray initialized successfully"); + Ok(()) +} + +/// Rebuilds the tray menu with current state. +/// Called by menu.rs when sync state or track changes. +pub fn update_tray_menu( + app: &AppHandle, + is_syncing: bool, + current_track: Option, +) -> Result<(), String> { + rebuild_tray_menu(app, is_syncing, current_track.as_ref(), TrayPaint::Deduped) +} + +/// The menu the tray shows before the event loop is running (issue #882). +/// +/// Two differences from a normal rebuild, both consequences of running on the +/// main thread inside `setup()`: it never touches the network (the throttled +/// caches are rendered as they stand — empty on a cold start), and it does NOT +/// commit the dedup snapshot. This paint shows the caches only, so recording it +/// would make the first honest rebuild look like a no-op and the Devices/Up +/// Next submenus would stay empty for the rest of the session. +pub(crate) fn update_tray_menu_startup( + app: &AppHandle, + is_syncing: bool, + current_track: Option, +) -> Result<(), String> { + rebuild_tray_menu(app, is_syncing, current_track.as_ref(), TrayPaint::Startup) +} + +/// The rebuild both entry points above share, so the tray has one layout. +pub(crate) fn rebuild_tray_menu( + app: &AppHandle, + is_syncing: bool, + current_track: Option<&crate::spotify::TrackInfo>, + paint: TrayPaint, +) -> Result<(), String> { + let tray = match get_tray() { + Some(t) => t, + None => { + log::warn!("[TRAY] update_tray_menu: Tray not initialized"); + return Err("Tray not initialized".to_string()); + } + }; + + // Issue #71 + #229 + #691: dedup guard. The polling thread calls this on + // every successful poll; the menu only needs rebuilding when is_syncing, + // window visibility (drives the Show/Hide label), the track's + // title/artist/is_playing, or one of the playback modes actually + // changes. is_playing is included so a same-track pause flips the + // Play/Pause mark without waiting for a poll; the modes are included so + // a change made in another Spotify client repaints the Shuffle/Repeat + // check marks (and the next click toggles from the fresh belief). + // + // Window visibility is computed up front so the dedup key includes + // it — otherwise a hide/show click would early-return and the label + // would go stale. + // Issue #886: the key reads the visibility mirror, so this path performs no + // event-loop hop — `live_window_visible` below re-reads the real window on + // the way to a paint, which is where the label is built. + let key_visible = window_visible(); + // 4.7.0 (S9 / issue #677): the snooze is resolved once, up front, because it + // feeds three separate decisions below — the dedup key, the fetch mode and + // the rendered status line. Issue #886: it comes from a scoped read of the + // mounted config, so no whole-`AppConfig` clone is allocated per poll and no + // read guard survives into the blocking Spotify HTTP below. `state` is the + // same handle the rest of the rebuild uses. + let state = app.state::>(); + let snooze = snooze_from_app_state(state.inner()); + let snooze_key = snooze.as_ref().map(|sn| snooze_dedup_key(&sn.status)); + // Issue #883: a player action asks for fresh Devices/Up Next lists, but only + // when it is not already covered by a fetch in flight (`action_fetch_due`), + // and never while snoozed (`paint_fetch_mode`). + let action_refresh_due = action_fetch_due( + *LAST_TRAY_ACTION.lock(), + *LAST_ACTION_FETCH.lock(), + Instant::now(), + TRAY_POST_ACTION_FETCH_MIN, + ); + let fetch = paint_fetch_mode(paint, snooze.is_some(), action_refresh_due); + // Issue #805: the two cache buckets are part of the key, so a session where + // nothing else moves still repaints — and therefore re-fetches — once per + // throttle window instead of keeping whatever the last rebuild rendered. + let (devices_bucket, queue_bucket) = cache_buckets(); + // Issue #869: the active profile id rides the dedup key so a switch — + // tray click, CLI, or Settings card — repaints the submenu's check + // mark. Cloned out of the short-lived read guard because the snapshot + // outlives this scope (the dedup is the next call's read source). + let active_profile_key = state + .config + .get() + .as_ref() + .and_then(|c| c.active_profile.clone()); + let snapshot = tray_snapshot_for( + is_syncing, + key_visible, + current_track, + snooze_key, + devices_bucket, + queue_bucket, + active_profile_key, + ); + { + let last = last_tray_state().lock(); + if !tray_state_changed(last.as_ref(), &snapshot) { + // No-op: menu state hasn't changed. + return Ok(()); + } + // Re-seed the Play/Pause toggle's playing state when the polling + // loop observed a genuinely new track (or a stop): its stored + // `is_playing` is only fresh at track-change time. Tray-initiated + // actions and the poller's `playback-state-changed` event update + // LAST_PLAYING_STATE themselves, and a forced rebuild keeps the + // same track_key, so neither path re-seeds here. + let track_changed = match last.as_ref() { + Some(prev) => prev.track_key != snapshot.track_key, + None => true, + }; + if track_changed { + let fresh_playing = current_track + .as_ref() + .map(|t| t.is_playing) + .unwrap_or(false); + note_playing_state(fresh_playing); + } + // Do NOT update the snapshot yet. If the rebuild below fails + // (e.g., set_menu returns Err), we want the next call with the + // same state to retry rather than no-op on a stale snapshot. + } + + // Issue #886: the key was built from the mirror; the label below is built + // from the real window — one query, paid only on this path, which is about + // to perform Spotify HTTP anyway. A show/hide that did not report itself + // therefore cannot leave the Show/Hide label wrong, and the mirror self-heals + // for the polls that follow. + let is_window_visible = live_window_visible(app); + note_window_visibility(is_window_visible); + + // Fetch Spotify data OUTSIDE the tray write lock, and only when the fetch + // mode allows it. The throttled caches are snapshotted and fetched without + // holding either cache mutex across HTTP (see cached_devices/cached_queue), + // and the tray lock is not yet held so a concurrent tray click or polling + // update never blocks on the network. Benign double-fetch race documented on + // those helpers. See issue #217. + let access_token = state + .tokens + .spotify() + .as_ref() + .map(|t| t.access_token.clone()); + if fetch == TrayFetch::RefreshNow { + // Recorded BEFORE the requests run: a click that lands while they are in + // flight must reuse them, not start a pair of its own (issue #883). + *LAST_ACTION_FETCH.lock() = Some(Instant::now()); + } + let devices: Vec = devices_for_menu(access_token.as_deref(), fetch); + let queue: Option = queue_for_menu(access_token.as_deref(), fetch); + // Issue #871: the active device's capability flags gate the playback + // submenu — Volume disabled when `actions.setting_volume` is false, + // Seek disabled when `actions.seeking` is false, Shuffle disabled when + // the device is restricted or `actions.toggling_shuffle` is false, + // Repeat the same. A device list that lacks the active device (e.g. + // a freshly-restarted Spotify session that has not yet published + // devices) returns `None`; the playback submenu then disables the + // capability-gated items by default so a stale-capabilities click + // cannot reach Graph with a 403. + let active_capabilities = devices + .iter() + .find(|d| d.is_active) + .map(|d| (d.supports_volume, d.is_restricted, d.actions.clone())); + let (active_supports_volume, active_is_restricted, active_actions) = + active_capabilities.unwrap_or((false, true, crate::spotify::DeviceActions::default())); + + // Build menu items without holding the tray write lock. Only the final + // tray.set_menu call needs serialising — everything above is pure data + // preparation and menu-item construction. + // Issue #674: every label below comes from the installed i18n table, so a + // language switch only has to rebuild the menu. + let s = i18n::current(); + // Determine Show/Hide label based on the precomputed visibility. + let show_hide_label = if is_window_visible { + s.hide_window + } else { + s.show_window + }; + + let show_hide = MenuItemBuilder::with_id(ID_SHOW_HIDE, show_hide_label) + .build(app) + .map_err(|e| { + log::warn!( + "[TRAY] update_tray_menu: failed to build show_hide menu item: {}", + e + ); + e.to_string() + })?; + + // Pause/Resume label based on sync state + let pause_resume_id = if is_syncing { + ID_PAUSE_SYNC + } else { + ID_RESUME_SYNC + }; + let pause_resume_label = if is_syncing { + s.pause_sync + } else { + s.resume_sync + }; + let pause_resume = MenuItemBuilder::with_id(pause_resume_id, pause_resume_label) + .build(app) + .map_err(|e| { + log::warn!( + "[TRAY] update_tray_menu: failed to build pause_resume menu item: {}", + e + ); + e.to_string() + })?; + + let separator1 = PredefinedMenuItem::separator(app).map_err(|e| { + log::warn!("[TRAY] update_tray_menu: failed to build separator1: {}", e); + e.to_string() + })?; + // separator2 inserted only when track is added (see below) + let separator3 = PredefinedMenuItem::separator(app).map_err(|e| { + log::warn!("[TRAY] update_tray_menu: failed to build separator3: {}", e); + e.to_string() + })?; + + let open_settings = MenuItemBuilder::with_id(ID_SETTINGS, s.open_settings) + .build(app) + .map_err(|e| { + log::warn!( + "[TRAY] update_tray_menu: failed to build open_settings menu item: {}", + e + ); + e.to_string() + })?; + + let open_logs = MenuItemBuilder::with_id(ID_OPEN_LOGS, s.open_logs_folder) + .build(app) + .map_err(|e| { + log::warn!( + "[TRAY] update_tray_menu: failed to build open_logs menu item: {}", + e + ); + e.to_string() + })?; + + let quit = MenuItemBuilder::with_id(ID_QUIT, s.quit) + .build(app) + .map_err(|e| { + log::warn!( + "[TRAY] update_tray_menu: failed to build quit menu item: {}", + e + ); + e.to_string() + })?; + + // Spotify playback controls (issue #3.0-P3). Play/Pause is a single + // check-item whose native checked state comes from LAST_PLAYING_STATE + // (see the static's docs — the polling loop's stored track goes stale + // on a same-track pause); the Devices/Up Next submenus are built from + // the pre-fetched throttled caches so the polling loop's per-iteration + // rebuilds don't hammer the Spotify API. The checkmark is derived from + // (track_id, is_playing) via the track_key dedup and LAST_PLAYING_STATE, + // so a same-track pause flips without waiting for the next poll. See + // issues #229 and #217. + let is_playing = LAST_PLAYING_STATE.load(Ordering::Acquire); + // Issue #591: a disabled head-of-menu status line stating what the app + // is actually doing (syncing / paused / not syncing). Everything below + // is derived from state already in scope for this rebuild, so the line + // cannot drift from the Show/Hide and Pause/Resume items beside it. + // S9 (issue #677): a snooze replaces the line outright — the remaining time + // and the local deadline are the whole answer while polling is suspended, + // and naming a track would describe work the app is deliberately not doing. + let status_line = match &snooze { + Some(sn) => snooze_status_line(s, sn), + None => sync_status_line(s, is_syncing, is_playing, current_track), + }; + let sync_status = MenuItemBuilder::with_id(ID_SYNC_STATUS, status_line.clone()) + .enabled(false) + .build(app) + .map_err(|e| { + log::warn!( + "[TRAY] update_tray_menu: failed to build sync_status item: {}", + e + ); + e.to_string() + })?; + // Issue #871: the playback items gate on the active device's documented + // capabilities. The Dashboard composer and the slider share the same + // gate (`commands/playback::set_volume` / `seek` refuse to issue the + // Graph call when the device refuses it; here the tray items are + // disabled for the same reason so a stale-capabilities click never + // even reaches the click handler). + let play_pause = CheckMenuItemBuilder::with_id(ID_PLAY_PAUSE, s.play_pause) + .checked(is_playing) + .enabled(active_actions.resuming || active_actions.pausing) + .build(app) + .map_err(|e| { + log::warn!( + "[TRAY] update_tray_menu: failed to build play_pause item: {}", + e + ); + e.to_string() + })?; + let previous = MenuItemBuilder::with_id(ID_PREVIOUS, s.previous) + .enabled(!active_is_restricted && active_actions.skipping_prev) + .build(app) + .map_err(|e| { + log::warn!( + "[TRAY] update_tray_menu: failed to build previous item: {}", + e + ); + e.to_string() + })?; + let next = MenuItemBuilder::with_id(ID_NEXT, s.next) + .enabled(!active_is_restricted && active_actions.skipping_next) + .build(app) + .map_err(|e| { + log::warn!("[TRAY] update_tray_menu: failed to build next item: {}", e); + e.to_string() + })?; + // Issue #582: the playback-mode toggles. Their marks come from the + // module-level atoms the polling loop feeds from the poll body (the + // shuffle/repeat state is free — the same response the app already + // parses), so no extra request and no cache machinery is involved. + // Both atoms are part of the dedup snapshot (issue #691), so a mode + // changed from another client forces the rebuild that repaints the + // mark; a click on these items rebuilds through `force_tray_refresh` + // and is reflected immediately. + // + // Issue #871: the existing `is_restricted` gate stays; the Spotify + // capability flags are the documented twin (issue #871 — Spotify + // documents both as sources of truth for "can I do this?"; the + // capability flag wins when the device reports it). + let shuffle = CheckMenuItemBuilder::with_id(ID_SHUFFLE, s.shuffle) + .checked(LAST_SHUFFLE_STATE.load(Ordering::Acquire)) + .enabled(!active_is_restricted && active_actions.toggling_shuffle) + .build(app) + .map_err(|e| { + log::warn!( + "[TRAY] update_tray_menu: failed to build shuffle item: {}", + e + ); + e.to_string() + })?; + let repeat_state = last_repeat_state(); + let repeat = CheckMenuItemBuilder::with_id(ID_REPEAT, repeat_menu_label(s, repeat_state)) + .checked(repeat_state.is_on()) + .enabled( + !active_is_restricted + && (active_actions.toggling_repeat_context || active_actions.toggling_repeat_track), + ) + .build(app) + .map_err(|e| { + log::warn!( + "[TRAY] update_tray_menu: failed to build repeat item: {}", + e + ); + e.to_string() + })?; + // Issue #871: the Volume submenu. The 0/25/50/75/100 picks are the + // documented Spotify boundaries (`PUT /me/player/volume` clamps to + // 0..=100). Disabled when the active device refuses the volume + // capability — `supports_volume` is the older twin, the new + // `actions.setting_volume` flag is the documented source of truth. + let volume_submenu = build_volume_submenu( + app, + s, + active_supports_volume && active_actions.setting_volume, + ) + .map_err(|e| { + log::warn!( + "[TRAY] update_tray_menu: failed to build volume submenu: {}", + e + ); + e.to_string() + })?; + // Issue #871: the Seek submenu. The +/- 30 s picks are the documented + // surface for `PUT /me/player/seek`; the Dashboard's click-to-seek + // progress bar reaches the same endpoint through + // `commands/playback::seek`. Disabled when the device refuses the + // seek capability. + let seek_submenu = build_seek_submenu(app, s, !active_is_restricted && active_actions.seeking) + .map_err(|e| { + log::warn!( + "[TRAY] update_tray_menu: failed to build seek submenu: {}", + e + ); + e.to_string() + })?; + let playback_separator = PredefinedMenuItem::separator(app).map_err(|e| { + log::warn!( + "[TRAY] update_tray_menu: failed to build playback_separator: {}", + e + ); + e.to_string() + })?; + + let devices_submenu = build_devices_submenu_from_devices(app, &devices).map_err(|e| { + log::warn!( + "[TRAY] update_tray_menu: failed to build devices submenu: {}", + e + ); + e + })?; + let queue_submenu = build_queue_submenu_from_queue(app, queue.as_ref()).map_err(|e| { + log::warn!( + "[TRAY] update_tray_menu: failed to build queue submenu: {}", + e + ); + e + })?; + // S9 (issue #677): the snooze submenu, rebuilt with the resolved snooze so + // the "Resume sync now" entry appears exactly while one is active. Labels + // come from the same installed table as everything above. + let snooze_submenu = build_snooze_submenu(app, s, snooze.as_ref()).map_err(|e| { + log::warn!( + "[TRAY] update_tray_menu: failed to build snooze submenu: {}", + e + ); + e + })?; + // Issue #870: the "Recent statuses" submenu. The recent ring is a + // process-global; this build call is the only thing that turns it into a + // tray surface, and the `manualstatus|clear` entry only appears while a + // manual status is armed. The Dashboard composer reads the same ring, + // so a click on the tray's pick hits the same record the Dashboard would. + let manual_status_submenu = build_manual_status_submenu( + app, + s, + &crate::commands::status::load_recent_statuses(), + crate::commands::status::load_manual_status().as_ref(), + ) + .map_err(|e| { + log::warn!( + "[TRAY] update_tray_menu: failed to build manual-status submenu: {}", + e + ); + e + })?; + // Issue #869: the "Active profile" submenu. Sits next to the snooze + // items because both are runtime state changes — the spec asks for + // the picker beside the snooze items, not under Settings, so the + // user can flip overlays without leaving the tray. + let profile_submenu = { + let profiles: Vec = state + .config + .get() + .as_ref() + .map(|c| c.presence_profiles.clone()) + .unwrap_or_default(); + let active = state + .config + .get() + .as_ref() + .and_then(|c| c.active_profile.clone()); + build_profile_submenu(app, s, &profiles, active.as_deref()) + } + .map_err(|e| { + log::warn!( + "[TRAY] update_tray_menu: failed to build profile submenu: {}", + e + ); + e + })?; + + // Build menu with optional track info + let mut menu_builder = MenuBuilder::new(app) + .items(&[ + &sync_status, + &show_hide, + &pause_resume, + &snooze_submenu, + &profile_submenu, + &manual_status_submenu, + &separator1, + ]) + .items(&[ + &play_pause, + &previous, + &next, + &shuffle, + &repeat, + &volume_submenu, + &seek_submenu, + ]) + .item(&playback_separator) + .items(&[&devices_submenu, &queue_submenu]); + + // Add current track item if playing — insert separator2 here too. + // Issue #956: the gate is the rebuild's OWN `is_playing` binding — the same + // one the Play/Pause mark and the status line read — not the stored track's + // flag. `run_player_action` records the new playing state and forces this + // rebuild without re-storing the track, so a tray-initiated pause used to + // leave this row naming a track the tray had just paused. + if is_playing { + if let Some(track) = ¤t_track { + let separator2 = PredefinedMenuItem::separator(app).map_err(|e| { + log::warn!("[TRAY] update_tray_menu: failed to build separator2: {}", e); + e.to_string() + })?; + let track_item = MenuItemBuilder::with_id( + ID_CURRENT_TRACK, + format!("🎵 {} - {}", track.artist, track.title), + ) + .enabled(false) + .build(app) + .map_err(|e| { + log::warn!("[TRAY] update_tray_menu: failed to build track_item: {}", e); + e.to_string() + })?; + menu_builder = menu_builder.item(&track_item).item(&separator2); + } + } + + let menu = menu_builder + .items(&[&open_settings, &open_logs, &separator3, &quit]) + .build() + .map_err(|e| { + log::warn!("[TRAY] update_tray_menu: failed to build menu: {}", e); + e.to_string() + })?; + + // Acquire the tray write lock ONLY around the final set_menu. The long + // HTTP fetches and the entire menu build above ran without it, so neither + // a polling-thread rebuild nor a main-thread tray click blocks on the + // network. See issue #217. + { + let _write_guard = tray_write_lock().lock(); + tray.set_menu(Some(menu)).map_err(|e| { + log::warn!("[TRAY] update_tray_menu: failed to set tray menu: {}", e); + format!("Failed to set tray menu: {}", e) + })?; + } + + // C4 polish: keep the tray tooltip live — "Artist — Track (▶|⏸)" while + // a track is known, the plain app name otherwise. This runs on every + // rebuild, i.e. exactly whenever track info changes (the dedup key + // already covers artist/title/is_playing), and performs no IO and no + // extra locking beyond the tray handle itself. + // Issue #956: the glyph comes from the rebuild's own `is_playing` binding, + // like the row above and the check mark, so one hover cannot contradict the + // menu it belongs to. + let track_tooltip = match ¤t_track { + Some(t) => format!( + "{} — {} ({})", + t.artist, + t.title, + if is_playing { "▶" } else { "⏸" } + ), + None => "PresenceJam".to_string(), + }; + // Issue #591: the status line leads the tooltip, so a hover states + // whether PresenceJam is syncing even when no track row is present + // (and off macOS, where the presence-gated dock badge is a no-op). + let tooltip = format!("{} · {}", status_line, track_tooltip); + if let Err(e) = tray.set_tooltip(Some(tooltip)) { + log::warn!("[TRAY] update_tray_menu: failed to set tooltip: {}", e); + } + + // Issue #971: `set_tooltip` is a documented no-op on Linux, which has no + // hover surface for an AppIndicator — so the same summary rides as the + // indicator's title, which Linux renders beside the icon. That closes the + // gap where the status line, the current track and the snooze countdown + // were only visible after opening the menu. Windows and macOS keep the + // tooltip and set no title (the call is a no-op there anyway). + #[cfg(target_os = "linux")] + if let Err(e) = tray.set_title(Some(status_line.clone())) { + log::warn!("[TRAY] update_tray_menu: failed to set tray title: {}", e); + } + + // Commit the snapshot only after a successful set_menu. A failed + // set_menu above left the snapshot at the previous value, so the + // next call with the same state will retry rather than no-op. + // Issue #882: the startup paint never commits — it renders the caches + // only, so recording it would dedup away the first real rebuild. + if paint == TrayPaint::Deduped { + *last_tray_state().lock() = Some(snapshot); + } + + log::info!( + "[TRAY] update_tray_menu: tray menu updated - is_syncing={}, visible={}, track={:?}", + is_syncing, + is_window_visible, + current_track + .as_ref() + .map(|t| format!("{} - {}", t.artist, t.title)) + ); + Ok(()) +} + +/// C4 polish: reflect presence-gated sync on the macOS dock icon. While a +/// track's Teams status write is suppressed by the presence gate +/// (`polling/poll_once.rs` records it in `gated_track_key`), the dock icon +/// shows a badge so the user can see presence updates are being held back; +/// cleared when the gate lifts or syncing stops. No-op off macOS so call +/// sites stay cfg-free and compile everywhere. +/// +/// SINGLE CALL-SITE (owned by the polling driver, feat/v4-c11): in +/// `polling/loop.rs::polling_loop`, immediately AFTER the post-iteration +/// `tray::update_tray_menu(...)` block, add +/// `tray::set_presence_gated_badge(&app, gated_track_key.is_some());`, +/// and after the loop exits, add +/// `tray::set_presence_gated_badge(&app, false);`. +#[cfg(target_os = "macos")] +pub fn set_presence_gated_badge(app: &AppHandle, gated: bool) { + let Some(window) = app.get_webview_window("main") else { + return; + }; + if let Err(e) = window.set_badge_count(if gated { Some(1) } else { None }) { + log::warn!( + "[TRAY] set_presence_gated_badge: failed to set dock badge: {}", + e + ); + } +} + +#[cfg(not(target_os = "macos"))] +pub fn set_presence_gated_badge(_app: &AppHandle, _gated: bool) {} + +/// Runs a tray-library call, turning the panic a missing native tray library +/// raises into the `Err` this module's contract promises (issue #927). +/// +/// The reason is logged once, at error level, together with what failed: an +/// AppImage whose bundler could not see a dlopen-only dependency is the likely +/// host, and the log line is the only thing that says so. +pub(crate) fn guard_tray_panic( + what: &str, + f: impl FnOnce() -> Result + std::panic::UnwindSafe, +) -> Result { + match std::panic::catch_unwind(f) { + Ok(result) => result, + Err(payload) => { + let reason = payload + .downcast_ref::<&str>() + .map(|s| (*s).to_string()) + .or_else(|| payload.downcast_ref::().cloned()) + .unwrap_or_else(|| "unknown panic".to_string()); + log::error!( + "[TRAY] {}: the tray library is unavailable ({}); running without a tray", + what, + reason + ); + Err(format!("{} unavailable: {}", what, reason)) + } + } +} + +/// Whether a tray icon actually exists (issue #927). +/// +/// `setup_tray` can now fail without panicking, and a session with no tray has +/// no reachable way back to a window that close-to-tray has hidden — so the +/// caller that hides it (lib.rs) has to gate on this. +pub fn tray_available() -> bool { + get_tray().is_some() +} + +/// Side of [`TEMPLATE_GLYPH`] in cells (issue #911). macOS sizes menu-bar items +/// at 22 pt, so the glyph is drawn as a 22-cell square and doubled for `@2x`. +#[cfg(any(target_os = "macos", test))] +pub(crate) const TEMPLATE_GLYPH_SIZE: u32 = 22; + +/// The menu-bar glyph: a quarter note, `#` inked and `.` transparent. +/// +/// A bitmap in the source rather than a shipped PNG: the tray then needs no +/// macOS-only asset and no PNG-decoding feature to read one, and the shape is +/// pure data that every platform's test run can check. The stem starts at the +/// top-right and runs down into the note head, with the flag off its top. +#[cfg(any(target_os = "macos", test))] +pub(crate) const TEMPLATE_GLYPH: [&str; TEMPLATE_GLYPH_SIZE as usize] = [ + "......................", + "......................", + "......................", + "......................", + "...........#####......", + "...........######.....", + "...........######.....", + "...........######.....", + "...........##.........", + "...........##.........", + "...........##.........", + "...........##.........", + ".......#...##.........", + ".....#####.##.........", + "....#########.........", + "....#########.........", + "...##########.........", + "....#########.........", + "....#########.........", + ".....#####.##.........", + ".......#...##.........", + "......................", +]; + +/// The RGBA bytes for [`TEMPLATE_GLYPH`] at `scale` (issue #911): opaque black +/// for ink, fully transparent elsewhere. +/// +/// A template image carries no colour — macOS reads the alpha channel and lets +/// the menu bar tint the result — so the colour channels are zero everywhere and +/// only the alpha distinguishes ink from background. Each cell becomes a +/// `scale`×`scale` block, which is how the `@2x` twin is produced. +#[cfg(any(target_os = "macos", test))] +pub(crate) fn template_icon_rgba(scale: u32) -> Vec { + let scale = scale.max(1); + let side = TEMPLATE_GLYPH_SIZE * scale; + let mut rgba = Vec::with_capacity((side * side * 4) as usize); + for row in TEMPLATE_GLYPH { + for _ in 0..scale { + for cell in row.bytes() { + let alpha = if cell == b'#' { 255u8 } else { 0u8 }; + for _ in 0..scale { + rgba.extend_from_slice(&[0, 0, 0, alpha]); + } + } + } + } + rgba +} + +/// The macOS menu-bar icon (issue #911): [`TEMPLATE_GLYPH`] at its `@2x` size, so +/// the same image is sharp on a Retina bar without a second asset. +#[cfg(target_os = "macos")] +pub(crate) fn macos_template_icon() -> tauri::image::Image<'static> { + const SCALE: u32 = 2; + let side = TEMPLATE_GLYPH_SIZE * SCALE; + let rgba = template_icon_rgba(SCALE); + debug_assert_eq!(rgba.len(), (side * side * 4) as usize); + tauri::image::Image::new_owned(rgba, side, side) +} + +/// The tray icon this platform wants (issue #911). +/// +/// macOS gets the monochrome template glyph — the full-colour application icon +/// (`default_window_icon()`, 32/128 px) is what the menu bar rendered oversized +/// and untinted. Windows' notification area and Linux's indicators are unaffected +/// by the missing template flag, so they keep that icon. +pub(crate) fn tray_icon(app: &tauri::App) -> Result, String> { + #[cfg(target_os = "macos")] + { + Ok(macos_template_icon()) + } + #[cfg(not(target_os = "macos"))] + { + app.default_window_icon() + .cloned() + .ok_or_else(|| "No default icon".to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tray::testkit::{body_of, prod_source, tray_prod_source}; + /// Issue #918: the dispatcher's normal log must preserve known action ids + /// while reducing payload-bearing and unknown ids to their shape. + #[test] + fn menu_event_log_redacts_payload_and_unknown_ids() { + assert_eq!(menu_event_id_for_log("play_pause"), "play_pause"); + assert_eq!(menu_event_id_for_log("about"), "about"); + + let device_id = "aB3deviceCredentialValueWithThirtyTwoChars"; + let formatted_device_id = format!("devices|{device_id}"); + let logged_device = menu_event_id_for_log(&formatted_device_id); + assert_eq!(logged_device, ""); + assert!(!logged_device.contains(device_id)); + assert!(!logged_device.contains(&format!("devices|{device_id}"))); + + let logged_unknown = menu_event_id_for_log("mystery"); + assert_eq!(logged_unknown, ""); + assert!(!logged_unknown.contains("mystery")); + } + /// Issue #918: capture the production global-dispatcher record, then bind + /// the real handler to that seam. A raw-id call at the handler is therefore + /// a regression even when the formatter itself still behaves correctly. + #[test] + fn dispatched_menu_event_record_redacts_device_id() { + let device_id = "aB3deviceCredentialValueWithThirtyTwoChars"; + let event_id = format!("devices|{device_id}"); + let mut captured = None; + + log_dispatched_menu_event_with(&event_id, |record| { + assert_eq!(record.level(), log::Level::Info); + captured = Some(record.args().to_string()); + }); + + let record = captured.expect("the dispatcher seam must emit one record"); + assert!( + record.contains("[TRAY] menu event: id="), + "the production record shape changed: {record}" + ); + assert!(!record.contains(device_id), "device id leaked: {record}"); + assert!( + !record.contains(&event_id), + "device menu id leaked: {record}" + ); + + let mut known_record = None; + log_dispatched_menu_event_with("play_pause", |record| { + known_record = Some(record.args().to_string()); + }); + assert_eq!( + known_record.as_deref(), + Some("[TRAY] menu event: id=play_pause") + ); + + let prod = tray_prod_source(); + let dispatcher = body_of(prod, "pub fn handle_menu_event("); + assert!( + dispatcher.contains("log_dispatched_menu_event(id);"), + "handle_menu_event must emit through the captured production seam" + ); + let wrapper = body_of(prod, "fn log_dispatched_menu_event("); + assert!( + wrapper.contains("log_dispatched_menu_event_with(id,"), + "the production wrapper must delegate to the captured record seam" + ); + } + /// Issues #383/#386: the tray Quit arm must terminate via the shared + /// graceful shutdown, and the playback/device click arms must offload + /// blocking Spotify HTTP onto worker threads. Issue #587: the Show/Hide + /// repaint rebuilds a menu that can fetch Spotify over the network, so + /// it must be offloaded too. + #[test] + fn tray_click_arms_quit_and_offload() { + let src = tray_prod_source(); + let body = body_of(prod_source(src), "pub fn handle_menu_event("); + // #383: Quit terminates even with no frontend listener. + let quit_pos = body + .find("ID_QUIT =>") + .expect("handle_menu_event must handle ID_QUIT"); + let quit_tail = &body[quit_pos..quit_pos + 600.min(body.len() - quit_pos)]; + assert!( + quit_tail.contains("request_graceful_shutdown"), + "tray ID_QUIT arm must route through request_graceful_shutdown" + ); + assert!( + !quit_tail.contains("window.hide()"), + "the hide-only quit arm (issue #383) must stay gone" + ); + // #386: no blocking Spotify HTTP directly on the menu-event thread. + for marker in [ + "get_currently_playing(token, None)", + "player_pause(t, None)", + "player_play(t, None)", + "player_previous(token, None)", + "player_next(token, None)", + "player_transfer(token,", + ] { + let pos = body + .find(marker) + .unwrap_or_else(|| panic!("expected click-path marker `{}` in setup_tray", marker)); + let before = &body[..pos]; + assert!( + before.rfind("std::thread::spawn").is_some(), + "blocking call `{}` must run inside a spawned worker thread", + marker + ); + } + // #587: the Show/Hide repaint must be offloaded. `update_tray_menu` + // fetches Spotify devices/queue with a 10 s timeout once the fetch + // throttle lapses, so rebuilding inline would wedge the menu the + // same way the #386 player arms used to. + let show_pos = body + .find("ID_SHOW_HIDE =>") + .expect("handle_menu_event must handle ID_SHOW_HIDE"); + let show_end = body[show_pos..] + .find("ID_PAUSE_SYNC") + .map(|i| show_pos + i) + .unwrap_or(body.len()); + let show_arm = &body[show_pos..show_end]; + assert!( + !show_arm.contains("update_tray_menu("), + "the Show/Hide arm must not rebuild the tray menu inline (issue #587)" + ); + assert!( + show_arm.contains("refresh_tray_from_state("), + "the Show/Hide arm must repaint through the offloading refresh helper" + ); + // #588: the Pause/Resume label is repainted from backend truth after + // the frontend's asynchronous toggle, not left to the Dashboard. + let pause_pos = body + .find("ID_PAUSE_SYNC | ID_RESUME_SYNC =>") + .expect("handle_menu_event must handle ID_PAUSE_SYNC | ID_RESUME_SYNC"); + let pause_end = body[pause_pos..] + .find("ID_QUIT =>") + .map(|i| pause_pos + i) + .unwrap_or(body.len()); + let pause_arm = &body[pause_pos..pause_end]; + assert!( + pause_arm.contains("std::thread::spawn"), + "the Pause/Resume arm must repaint from a worker (issue #588)" + ); + assert!( + pause_arm.contains("await_sync_toggle("), + "the Pause/Resume arm must wait for the toggle to settle before repainting" + ); + // #586: no raw token snapshot on the click path — the refresh-aware + // policy resolves the token (and retries once on ExpiredToken). + assert!( + !show_arm.contains("tokens.spotify()"), + "the click path must not read the raw Spotify token snapshot" + ); + } + /// Issue #804: every menu click must fire exactly once. The tray + /// builder's `on_menu_event` is a global listener (it sees window-menu + /// events too), so the `window.on_menu_event` registration lib.rs carried + /// double-fired every shared id — and tray-only ids fell into menu.rs's + /// unknown-event warn. The single dispatcher `handle_menu_event` owns + /// every tray id and delegates the app-menu-only ids to + /// `menu::handle_app_menu_event`, which keeps only those three arms. + /// Fails pre-fix with twins (and with the second lib.rs registration). + #[test] + fn menu_events_route_through_one_dispatcher() { + let tray_prod = tray_prod_source(); + let menu_prod = prod_source(include_str!("../menu.rs")); + let app_prod = prod_source(include_str!("../app.rs")); + // One registration: the tray builder's global listener delegating to + // the named dispatcher; no per-window handler in app.rs. + assert!( + tray_prod.contains( + ".on_menu_event(|app, event| handle_menu_event(app, event.id().as_ref()))" + ), + "setup_tray must register the single dispatcher, not an inline match" + ); + assert!( + !app_prod.contains("window.on_menu_event"), + "app.rs must not register a second menu handler (issue #804: double-fire)" + ); + let dispatcher = body_of(tray_prod, "pub fn handle_menu_event("); + // The dispatcher owns every tray-built id ... + for marker in [ + "ID_SHOW_HIDE =>", + "ID_PAUSE_SYNC | ID_RESUME_SYNC =>", + "ID_QUIT =>", + "ID_SETTINGS =>", + "ID_OPEN_LOGS =>", + "ID_PLAY_PAUSE =>", + "ID_PREVIOUS =>", + "ID_NEXT =>", + "ID_SHUFFLE =>", + "ID_REPEAT =>", + "SNOOZE_ITEM_PREFIX", + "ID_PROFILE_BASE", + "PROFILE_ITEM_PREFIX", + "ID_MANUAL_STATUS_CLEAR", + "MANUAL_STATUS_ITEM_PREFIX", + "VOLUME_ITEM_PREFIX", + "SEEK_ITEM_PREFIX", + "DEVICE_ITEM_PREFIX", + ] { + assert!( + dispatcher.contains(marker), + "handle_menu_event must own `{}`", + marker + ); + } + // ... and hands anything else to the app-menu handler. + assert!( + dispatcher.contains("crate::menu::handle_app_menu_event(app, id)"), + "handle_menu_event must delegate app-menu-only ids to menu::handle_app_menu_event" + ); + // setup_tray itself carries no match arms any more. + let setup = body_of(tray_prod, "pub fn setup_tray("); + assert!( + !setup.contains("ID_QUIT =>"), + "setup_tray must not keep a second copy of the dispatch arms" + ); + // The app-menu handler keeps only its three window-menu-only arms, so + // no id is owned twice and tray-only ids never hit its unknown warn. + let app_menu = body_of(menu_prod, "pub(crate) fn handle_app_menu_event("); + for marker in ["ID_SHOW_DASHBOARD =>", "ID_SHOW_LOGS =>", "ID_ABOUT =>"] { + assert!( + app_menu.contains(marker), + "handle_app_menu_event must keep `{}`", + marker + ); + } + // NOTE: `app_menu` is the handler body only (not the whole file), + // so these assertions cannot match the test module's own prose below. + for marker in ["ID_SETTINGS =>", "ID_OPEN_LOGS =>", "ID_QUIT =>"] { + assert!( + !app_menu.contains(marker), + "handle_app_menu_event must not twin `{}` (issue #804)", + marker + ); + } + } + /// Issue #689 (D6): the tray's belief only moves if the poller's + /// `playback-state-changed` event is actually subscribed. A mistyped or + /// deleted `app.listen` would leave the functional test above green — it + /// calls the consumer directly — while the running app ignored the event. + /// Guards the registration inside `setup_tray`, like the click-arm scans. + #[test] + fn setup_tray_subscribes_to_playback_state_changes() { + let src = tray_prod_source(); + let body = body_of(prod_source(src), "pub fn setup_tray("); + assert!( + body.contains("app.listen(\"playback-state-changed\""), + "setup_tray must subscribe to the poller's playback-state-changed event (issue #689)" + ); + assert!( + body.contains("consume_playback_state_changed(event.payload())"), + "the subscription must hand the payload to the tray's consumer" + ); + } + /// Issue #882: `setup_tray` runs on the main thread inside Tauri's + /// `setup()`, before the event loop exists, so its paint must not perform + /// the devices/queue GETs (10 s timeout each, with no window on screen to + /// explain the wait). It renders the throttled caches and hands the real + /// fetch to the worker refresh. + #[test] + fn startup_paint_is_cache_only_and_fetches_nothing() { + // The decision itself: the startup paint renders the caches whether or + // not a snooze is active, while an ordinary rebuild still follows the + // snooze rule (issue #677). + assert_eq!( + paint_fetch_mode(TrayPaint::Startup, false, false), + TrayFetch::CacheOnly + ); + assert_eq!( + paint_fetch_mode(TrayPaint::Startup, true, false), + TrayFetch::CacheOnly + ); + assert_eq!( + paint_fetch_mode(TrayPaint::Deduped, false, false), + TrayFetch::Refresh + ); + assert_eq!( + paint_fetch_mode(TrayPaint::Deduped, true, false), + TrayFetch::CacheOnly + ); + // Issue #883: an action makes one rebuild bypass the throttle — unless + // a snooze is active, which outranks it (issue #677). + assert_eq!( + paint_fetch_mode(TrayPaint::Deduped, false, true), + TrayFetch::RefreshNow + ); + assert_eq!( + paint_fetch_mode(TrayPaint::Deduped, true, true), + TrayFetch::CacheOnly + ); + + // The wiring: setup_tray must paint through the cache-only entry point + // and must not run a fetching rebuild inline, and the devices/queue + // fetch must be handed to the off-thread refresh instead. + let prod = tray_prod_source(); + let setup = body_of(prod, "pub fn setup_tray("); + assert!( + setup.contains("update_tray_menu_startup("), + "setup_tray must paint through the cache-only entry point (issue #882)" + ); + assert!( + !setup.contains("update_tray_menu(app.handle()"), + "setup_tray must not run a fetching rebuild on the setup thread" + ); + assert!( + setup.contains("refresh_tray_from_state(app.handle())"), + "the startup paint must hand the real fetch to the worker refresh" + ); + // The startup paint must not record the dedup snapshot: it renders the + // caches only, so recording it would make the first honest rebuild — + // the worker refresh just spawned — look like a no-op and leave the + // Devices/Up Next submenus empty. + let rebuild = body_of(prod, "fn rebuild_tray_menu("); + assert!( + rebuild.contains("if paint == TrayPaint::Deduped"), + "only an ordinary rebuild may commit the dedup snapshot (issue #882)" + ); + } + /// Issue #956: one rebuild described two different playing states. The + /// status line and the Play/Pause check mark read the rebuild's own + /// `is_playing`, while the now-playing row and the tooltip glyph read the + /// poller's stored `TrackInfo` — which a tray pause does not re-store. So a + /// pause left the row naming a track that was no longer playing and the + /// tooltip claiming ▶ over a menu that said paused, until the next poll. + #[test] + fn now_playing_row_and_tooltip_follow_the_rebuilds_playing_state() { + let prod = tray_prod_source(); + let body = body_of(prod, "fn rebuild_tray_menu("); + + // The now-playing row: the gate is the shared binding, placed before + // the row is built, and nothing in the row re-reads a stored flag. + let gate = body + .find("if is_playing {") + .expect("the now-playing row must be gated on the playing state"); + let row_pos = body + .find("ID_CURRENT_TRACK") + .expect("the rebuild must build the now-playing row"); + let row_end = body[row_pos..] + .find("open_settings") + .map(|i| row_pos + i) + .unwrap_or(body.len()); + let row = &body[row_pos..row_end]; + assert!( + gate < row_pos, + "the now-playing row must be gated on the rebuild's playing state (issue #956)" + ); + assert!( + !row.contains(".is_playing"), + "the now-playing row must not re-read the stored track's flag (issue #956)" + ); + + // The tooltip glyph: same binding, so a hover agrees with the menu. + let tip_pos = body + .find("let track_tooltip") + .expect("the rebuild must build the tooltip"); + let tip = &body[tip_pos..]; + assert!( + tip.contains("if is_playing { \"▶\" } else { \"⏸\" }"), + "the tooltip glyph must come from the rebuild's playing state (issue #956)" + ); + assert!( + !tip.contains(".is_playing"), + "the tooltip must not read the stored track's playing flag (issue #956)" + ); + } + /// Issue #971: on Linux Tauri supports neither tray click events nor + /// `set_tooltip`, so the status summary — which the tooltip carries on + /// Windows and macOS — must ride as the AppIndicator's title there, or the + /// sync state, the current track and the snooze countdown are only visible + /// after opening the menu. + #[test] + fn linux_tray_title_carries_the_status_line() { + let prod = tray_prod_source(); + let body = body_of(prod, "fn rebuild_tray_menu("); + let tooltip_pos = body + .find("tray.set_tooltip(") + .expect("the rebuild must still write the tooltip"); + let after = &body[tooltip_pos..]; + assert!( + after.contains("#[cfg(target_os = \"linux\")]"), + "the title mirror must be Linux-only (issue #971)" + ); + assert!( + after.contains("tray.set_title(Some(status_line.clone()))"), + "the Linux title must carry the status line the tooltip carries (issue #971)" + ); + + // The build site names the behaviours that are inert on Linux: the + // left-click flag, the click handler it belongs to, and the tooltip + // above. The flag itself must stay (it is what Windows/macOS need). + let setup = body_of(prod, "pub fn setup_tray("); + assert!( + setup.contains(".show_menu_on_left_click(false)"), + "the tray must keep the documented left-click behaviour (issue #971)" + ); + assert!( + setup.contains(".on_tray_icon_event(|tray, event|"), + "the click handler must stay for Windows and macOS (issue #971)" + ); + } + /// Issue #886: the poll loop calls the rebuild on every iteration and the + /// dedup key discards most of those calls — but only after a window query + /// that blocks the calling thread on an event-loop reply and a clone of the + /// whole `AppConfig`. The key now reads a visibility mirror and a scoped + /// snooze read, and the real query sits below the early return. + #[test] + fn discarded_rebuilds_query_neither_the_window_nor_a_config_clone() { + // The mirror is the key's source and round-trips. + note_window_visibility(true); + assert!(window_visible(), "the mirror must report what was recorded"); + note_window_visibility(false); + assert!(!window_visible()); + + let prod = tray_prod_source(); + let body = body_of(prod, "fn rebuild_tray_menu("); + let guard = body + .find("tray_state_changed(") + .expect("the rebuild must keep the dedup guard"); + let mirror = body + .find("window_visible()") + .expect("the dedup key must read the visibility mirror (issue #886)"); + assert!( + mirror < guard, + "the key must be built from the mirror, so a discarded rebuild costs no hop" + ); + // The real query, and anything else that can block, sits below it. + let live = body + .find("live_window_visible(") + .expect("a repaint must still render the real window state (issue #886)"); + assert!( + live > guard, + "the visibility query belongs below the dedup early-return (issue #886)" + ); + assert!( + !body.contains("is_visible()"), + "the rebuild itself must not query the event loop (issue #886)" + ); + assert!( + !body.contains("config.get().clone()"), + "the snooze must not cost a whole-AppConfig clone per poll (issue #886)" + ); + assert!( + body.contains("snooze_from_app_state("), + "the snooze must come from the scoped read (issue #886)" + ); + // The same scoped read replaces the clone on the forced path. + let force = body_of(prod, "fn force_tray_refresh("); + assert!( + !force.contains("config.get().clone()"), + "force_tray_refresh must not clone the config either (issue #886)" + ); + assert!( + force.contains("snooze_from_app_state("), + "force_tray_refresh must use the scoped snooze read (issue #886)" + ); + } + /// Issue #927: on a host where neither appindicator soname resolves, the tray + /// build panics inside `libappindicator-sys` (its `Lazy` dlopens + /// both and panics when neither is there). `setup_tray` is written to fail as + /// `Result` — lib.rs logs the error and carries on without a tray — so the + /// panic has to arrive as that error, and `tray_available()` is what the + /// close-to-tray guard reads. + /// + /// The panic message this test provokes is expected output. + #[test] + fn a_missing_tray_library_is_an_error_not_a_panic() { + let err = guard_tray_panic("tray icon", || -> Result<(), String> { + panic!("libayatana-appindicator3.so.1: cannot open shared object file") + }) + .expect_err("a panicking tray build must be reported as an error"); + assert!( + err.contains("libayatana-appindicator3.so.1"), + "the error must name the reason, got: {}", + err + ); + assert!(err.contains("tray icon"), "the error must name what failed"); + + // A normal failure passes through untouched, and a success returns its + // value — the guard must not swallow either. + assert_eq!( + guard_tray_panic("tray icon", || Err::<(), String>("nope".to_string())), + Err("nope".to_string()) + ); + assert_eq!(guard_tray_panic("tray icon", || Ok(7)), Ok(7)); + + // The wiring: the build is the guarded call, and the close-to-tray guard + // has an accessor to gate on (lib.rs owns that call site). + let prod = tray_prod_source(); + let setup = body_of(prod, "pub fn setup_tray("); + assert!( + setup.contains("guard_tray_panic(") && setup.contains("\"tray icon\""), + "the tray build must be guarded (issue #927)" + ); + assert!( + setup.contains("builder.build(app)"), + "the guarded call must be the tray build itself" + ); + assert!( + prod.contains("pub fn tray_available()"), + "close-to-tray needs an accessor for whether a tray exists (issue #927)" + ); + } + /// Issue #911: macOS wants a monochrome TEMPLATE image for the menu bar, + /// because the bar draws the icon from its alpha channel and tints it — that + /// is what makes the item invert in a dark bar and dim with the bar for a + /// modal. The tray used `default_window_icon()`, the full-colour 32/128 px + /// application icon, which is why the item was oversized and ignored the tint. + #[test] + fn the_menu_bar_icon_is_a_monochrome_template_at_menu_bar_size() { + // The glyph is a square bitmap of ink and transparency, and it is a mark + // rather than a stray pixel. + assert_eq!(TEMPLATE_GLYPH.len(), TEMPLATE_GLYPH_SIZE as usize); + for row in TEMPLATE_GLYPH { + assert_eq!(row.len(), TEMPLATE_GLYPH_SIZE as usize, "row {:?}", row); + assert!( + row.bytes().all(|b| b == b'#' || b == b'.'), + "row {:?} must be ink or transparency only", + row + ); + } + let ink = TEMPLATE_GLYPH + .iter() + .flat_map(|row| row.bytes()) + .filter(|b| *b == b'#') + .count(); + assert!( + ink > 60, + "the glyph must be a legible mark, got {} cells", + ink + ); + + // The RGBA handed to macOS is black plus alpha at both menu-bar sizes, + // and the ink covers exactly the glyph's cells scaled up — a template + // image gets its colour from the menu bar, so a coloured pixel would be + // ignored there anyway. + for scale in [1u32, 2] { + let side = TEMPLATE_GLYPH_SIZE * scale; + let rgba = template_icon_rgba(scale); + assert_eq!(rgba.len(), (side * side * 4) as usize, "scale {}", scale); + for px in rgba.chunks_exact(4) { + assert_eq!(&px[..3], &[0, 0, 0], "a template image is black"); + assert!(px[3] == 0 || px[3] == 255, "alpha is on or off"); + } + assert_eq!( + rgba.chunks_exact(4).filter(|px| px[3] == 255).count(), + ink * (scale * scale) as usize, + "scale {} must cover the glyph and nothing else", + scale + ); + } + + // The wiring: macOS builds the tray from the template glyph and marks it + // as a template; Windows and Linux keep the application icon. + let prod = tray_prod_source(); + let setup = body_of(prod, "pub fn setup_tray("); + assert!( + setup.contains(".icon(tray_icon(app)?)"), + "the tray icon must come from the platform-aware source (issue #911)" + ); + assert!( + setup.contains("set_icon_as_template(true)"), + "the macOS icon must be marked as a template (issue #911)" + ); + let source = body_of(prod, "fn tray_icon("); + assert!( + source.contains("default_window_icon()"), + "Windows and Linux must keep the application icon (issue #911)" + ); + assert!( + source.contains("macos_template_icon()"), + "macOS must build the monochrome template glyph (issue #911)" + ); + } +} diff --git a/src-tauri/src/tray/snooze.rs b/src-tauri/src/tray/snooze.rs new file mode 100644 index 00000000..76574e5b --- /dev/null +++ b/src-tauri/src/tray/snooze.rs @@ -0,0 +1,892 @@ +//! tray/snooze.rs — split from tray/mod.rs (#756). + +#![allow(unused_imports)] + +use super::*; +use crate::i18n::Strings; +use crate::spotify::RepeatState; +use std::time::Instant; +use tauri::{ + menu::{MenuItemBuilder, PredefinedMenuItem, Submenu, SubmenuBuilder}, + AppHandle, Manager, +}; + +/// The active snooze as one rebuild renders it (4.7.0, S9 / issue #677). +#[derive(Debug, Clone, Copy)] +pub struct TraySnooze { + pub(crate) status: crate::config::SnoozeStatus, + /// Whole minutes the countdown shows (rounded up, never below 1). + minutes_left: i64, +} + +/// The snooze half of the dedup key: the deadline plus the minute bucket the +/// countdown is on, so the rebuilt status line cannot be deduped away while the +/// sleep runs (4.7.0, S9 / issue #677). +pub fn snooze_dedup_key(status: &crate::config::SnoozeStatus) -> String { + format!( + "{}|{}", + crate::config::snooze_store_form(status.deadline), + crate::config::snooze_minutes_left(status.remaining_seconds) + ) +} + +/// Resolves the snooze a rebuild must render from the mounted config +/// (4.7.0, S9 / issue #677). Absent config, absent field and an already-passed +/// deadline all mean "not snoozed", which is what makes the tray agree with the +/// poller without a second source of truth. +pub fn resolve_snooze( + config: Option<&std::sync::Arc>, +) -> Option { + let status = crate::config::snooze_status(config?, chrono::Utc::now())?; + Some(TraySnooze { + minutes_left: crate::config::snooze_minutes_left(status.remaining_seconds), + status, + }) +} + +/// The snooze a rebuild must render, read out of the mounted config under a +/// scoped guard (issue #886). +/// +/// Only `snooze_until` (plus the clock) is needed, so that one field is copied +/// out instead of cloning the whole `AppConfig` — which allocates its status +/// rules on every poll, including the ones the dedup key discards. The guard +/// lives only for this call, so none survives into the blocking Spotify HTTP +/// below. +pub fn snooze_from_app_state(state: &crate::AppState) -> Option { + let config = state.config.get(); + resolve_snooze(config.as_ref()) +} + +/// Builds the snooze submenu (4.7.0, S9 / issue #677): the three presets, plus +/// a "Resume sync now" entry while a snooze is active. +/// +/// The three presets stay enabled during a snooze on purpose — picking another +/// one re-snoozes from now, which is how a user extends a 30-minute snooze to +/// "until tomorrow" without first resuming. Every label comes from the +/// installed i18n table, so the submenu localizes with the rest of the tray. +/// +/// No IO: the caller has already resolved the snooze (and, while one is active, +/// is required to be in [`TrayFetch::CacheOnly`] mode — this builder performs +/// no fetch of its own). +pub fn build_snooze_submenu( + app: &AppHandle, + s: &Strings, + snooze: Option<&TraySnooze>, +) -> Result, String> { + let thirty = MenuItemBuilder::with_id(ID_SNOOZE_30, s.snooze_30_minutes) + .build(app) + .map_err(|e| e.to_string())?; + let hour = MenuItemBuilder::with_id(ID_SNOOZE_1H, s.snooze_1_hour) + .build(app) + .map_err(|e| e.to_string())?; + let tomorrow = MenuItemBuilder::with_id(ID_SNOOZE_TOMORROW, s.snooze_until_tomorrow) + .build(app) + .map_err(|e| e.to_string())?; + let mut leading: Vec<&dyn tauri::menu::IsMenuItem> = + vec![&thirty, &hour, &tomorrow]; + // Issue #867: surface "Until this meeting ends" only while a busy meeting + // is in progress (calendar cache is non-empty AND has a busy event + // covering `now`). Outside of a meeting the entry is hidden so the + // user can't pick a deadline that's effectively the same as "30 + // minutes" with an opaque end time. + let meeting_active = { + let state = app.state::>(); + state.calendar.meeting_active(chrono::Utc::now()) + }; + // Issue #867: a single allocated `MenuItem` for the meeting entry, kept + // alive for the duration of `leading`'s borrow. Boxed so the conditional + // push does not need a separate `Option` shim and the references remain + // `Send`/`Sync`-safe to copy into the `items` slice below. + let meeting_entry: Option> = if meeting_active { + Some( + MenuItemBuilder::with_id(ID_SNOOZE_NEXT_MEETING, s.snooze_until_next_meeting_ends) + .build(app) + .map_err(|e| e.to_string())?, + ) + } else { + None + }; + if let Some(ref entry) = meeting_entry { + leading.push(entry); + } + let submenu = SubmenuBuilder::new(app, s.snooze_pause_menu) + .items(&leading) + .build() + .map_err(|e| e.to_string())?; + + if snooze.is_some() { + let separator = PredefinedMenuItem::separator(app).map_err(|e| e.to_string())?; + let resume = MenuItemBuilder::with_id(ID_SNOOZE_RESUME, s.snooze_resume_now) + .build(app) + .map_err(|e| e.to_string())?; + submenu.append(&separator).map_err(|e| e.to_string())?; + submenu.append(&resume).map_err(|e| e.to_string())?; + } + Ok(submenu) +} + +/// Issue #869: the "Active profile" submenu. The spec says the tray must +/// expose a profile picker beside the snooze items so the runtime overlay +/// path (`effective_config`) has a UI surface to flip from. The picker +/// has three states, matching the three resolve paths `effective_config` +/// implements: +/// +/// 1. `active_profile == None` → the "Base configuration" entry is the +/// only enabled one. Picking it is a no-op (already on base), and the +/// rest are unchecked picks that activate the matching profile. +/// 2. `active_profile == Some(name)` → the matching entry gets a check +/// mark. Picking it is also a no-op. +/// 3. The list is empty (the documented pre-5.0 default and a hand- +/// deleted config) → the submenu shows a single disabled placeholder. +/// +/// The id format `{PROFILE_ITEM_PREFIX}|{name}` lets the click handler +/// resolve the picked name without a parallel name→id map. `name` has +/// already been deduped and truncated by `clamp_presence_profiles`, so +/// the only remaining edge case is the single-pipe `name`, which the +/// clamp forbids via the `snooze_pause_menu` style regex — see +/// `clamp_profile_id`. Picking a profile that has been deleted between +/// the rebuild and the click is logged and ignored by the handler. +pub fn build_profile_submenu( + app: &AppHandle, + s: &Strings, + profiles: &[crate::config::PresenceProfile], + active: Option<&str>, +) -> Result, String> { + let submenu = SubmenuBuilder::new(app, s.profile_menu) + .build() + .map_err(|e| e.to_string())?; + + // The "Base configuration" entry is the only path that clears the + // active profile. Checked when `active == None`, disabled when there + // is no profile list to switch off of. + let base_checked = active.is_none(); + let base_enabled = !profiles.is_empty() || active.is_some(); + let base_item = CheckMenuItemBuilder::with_id(ID_PROFILE_BASE, s.profile_base) + .checked(base_checked) + .enabled(base_enabled) + .build(app) + .map_err(|e| e.to_string())?; + submenu.append(&base_item).map_err(|e| e.to_string())?; + + if profiles.is_empty() { + let empty = + MenuItemBuilder::with_id(format!("{PROFILE_ITEM_PREFIX}empty"), s.profile_empty) + .enabled(false) + .build(app) + .map_err(|e| e.to_string())?; + submenu.append(&empty).map_err(|e| e.to_string())?; + return Ok(submenu); + } + + let separator = PredefinedMenuItem::separator(app).map_err(|e| e.to_string())?; + submenu.append(&separator).map_err(|e| e.to_string())?; + + for profile in profiles { + let checked = active == Some(profile.name.as_str()); + let picker = CheckMenuItemBuilder::with_id( + format!("{PROFILE_ITEM_PREFIX}{}", profile.name), + profile.name.clone(), + ) + .checked(checked) + .build(app) + .map_err(|e| e.to_string())?; + submenu.append(&picker).map_err(|e| e.to_string())?; + } + Ok(submenu) +} + +/// Issue #870: the "Recent statuses" submenu. The spec caps the visible +/// entries at three; the Dashboard composer reads the broader +/// [`crate::commands::status::RECENT_STATUSES_CAPACITY`] ring. The labels +/// use the recent-status entry's exact text (filtered through the same +/// `MAX_RULE_STATUS_CHARS` bound as the composer), so a user picking +/// "Right back at 2" from the tray gets the same text the Dashboard would +/// have posted. +/// +/// The "Clear manual status" entry sits at the bottom, only while a +/// manual status is armed — picking it routes through the same helper the +/// Dashboard composer's Clear button uses. +pub fn build_manual_status_submenu( + app: &AppHandle, + s: &Strings, + recent: &[crate::commands::status::RecentManualStatus], + armed: Option<&crate::commands::status::ManualStatus>, +) -> Result, String> { + let mut items: Vec>> = Vec::new(); + let shown = recent.iter().take(3); + for (idx, entry) in shown.enumerate() { + let item = MenuItemBuilder::with_id( + format!("{MANUAL_STATUS_ITEM_PREFIX}{idx}"), + entry.message.clone(), + ) + .build(app) + .map_err(|e| e.to_string())?; + items.push(Box::new(item)); + } + let has_items = !items.is_empty(); + let submenu = SubmenuBuilder::new(app, s.manual_status_recent_menu) + .build() + .map_err(|e| e.to_string())?; + if !has_items { + let empty = MenuItemBuilder::with_id( + format!("{MANUAL_STATUS_ITEM_PREFIX}none"), + s.manual_status_recent_empty, + ) + .enabled(false) + .build(app) + .map_err(|e| e.to_string())?; + submenu.append(&empty).map_err(|e| e.to_string())?; + } else { + for item in items + .iter() + .map(|b| b.as_ref() as &dyn tauri::menu::IsMenuItem) + { + submenu.append(item).map_err(|e| e.to_string())?; + } + } + if armed.is_some() { + let separator = PredefinedMenuItem::separator(app).map_err(|e| e.to_string())?; + let clear = MenuItemBuilder::with_id(ID_MANUAL_STATUS_CLEAR, s.manual_status_clear) + .build(app) + .map_err(|e| e.to_string())?; + submenu.append(&separator).map_err(|e| e.to_string())?; + submenu.append(&clear).map_err(|e| e.to_string())?; + } + Ok(submenu) +} + +/// What a snooze menu id asked for (4.7.0, S9 / issue #677). Parsed rather +/// than switched on the raw id so the click arm stays a dispatch table and the +/// mapping is unit-testable without a menu. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SnoozeMenuSelection { + Preset(crate::config::SnoozePreset), + Resume, +} + +/// Parses a snooze menu-item id. `None` for anything that is not one of the +/// five known ids — a stale menu from an older build must not snooze by +/// accident. +pub fn parse_snooze_menu_id(id: &str) -> Option { + match id.strip_prefix(SNOOZE_ITEM_PREFIX)? { + SNOOZE_30_SUFFIX => Some(SnoozeMenuSelection::Preset( + crate::config::SnoozePreset::ThirtyMinutes, + )), + SNOOZE_1H_SUFFIX => Some(SnoozeMenuSelection::Preset( + crate::config::SnoozePreset::OneHour, + )), + SNOOZE_TOMORROW_SUFFIX => Some(SnoozeMenuSelection::Preset( + crate::config::SnoozePreset::UntilTomorrow, + )), + // Issue #867: the meeting-bound preset is parsed here so a stale + // menu from a build that never had it cannot pick the wrong + // deadline. The click handler still queries the calendar cache to + // compute the actual end-of-meeting instant. + SNOOZE_NEXT_MEETING_SUFFIX => Some(SnoozeMenuSelection::Preset( + crate::config::SnoozePreset::UntilNextMeetingEnds, + )), + SNOOZE_RESUME_SUFFIX => Some(SnoozeMenuSelection::Resume), + _ => None, + } +} + +/// Persists a snooze deadline (or clears it) into `AppConfig::snooze_until` +/// (4.7.0, S9 / issue #677). +/// +/// The write half only — callers log the action, so the copy matches what +/// happened (a preset, an explicit resume, or the startup cleanup of a deadline +/// that expired while the app was closed). Follows +/// `commands::config::update_config` exactly: hold the config write guard across +/// the atomic write, persist the CLAMPED copy with the binary-owned +/// `schema_version` stamped, and store THAT value — otherwise the in-memory +/// config (what the poller reads) and config.json disagree until the next launch +/// (issues #297 / #536). +/// +/// `None` clears the field, which is both "Resume sync now" and the state the +/// frontend writes when its chip's Resume button is used. +pub fn store_snooze( + app: &AppHandle, + until: Option>, +) -> Result<(), String> { + let state = app.state::>(); + let mut guard = state.config.get_mut(); + let base = match guard.as_ref() { + Some(current) => (**current).clone(), + None => crate::config::load_config()?, + }; + let mut next = base; + next.snooze_until = until.map(crate::config::snooze_store_form); + + let mut persisted = crate::config::clamped_config(&next); + crate::config::stamp_schema_version(&mut persisted); + crate::config::save_config(&persisted)?; + *guard = Some(std::sync::Arc::new(persisted)); + + Ok(()) +} + +/// Persists a user-chosen snooze (or its removal) and logs the action +/// (4.7.0, S9 / issue #677). The tray's three click paths all land here. +pub fn write_snooze( + app: &AppHandle, + until: Option>, +) -> Result<(), String> { + store_snooze(app, until)?; + match until { + Some(deadline) => log::info!( + "[TRAY] snooze: polling paused until {} (local {}, {} min)", + crate::config::snooze_store_form(deadline), + deadline.with_timezone(&chrono::Local).format("%H:%M"), + crate::config::snooze_minutes_left((deadline - chrono::Utc::now()).num_seconds()), + ), + None => log::info!("[TRAY] snooze: resumed by the user"), + } + Ok(()) +} + +/// Issue #869: switch the active presence profile from the tray. Mirrors +/// [`write_snooze`] exactly — same read-merge-write lock, same `clamped_config` +/// path, same store-what-was-persisted discipline. `Some(name)` activates the +/// named profile (the click handler has already validated the name against the +/// clamped list); `None` clears the active profile back to the base +/// configuration. +/// +/// The write goes through `clamped_config` so the active-profile pointer is +/// re-validated against the stored list — a profile deleted between the menu +/// rebuild and the click is dropped here too, not just by the click handler. +pub fn write_active_profile(app: &AppHandle, name: Option) -> Result<(), String> { + store_active_profile(app, name.clone())?; + match name { + Some(profile) => log::info!("[TRAY] profile: active profile is now {:?}", profile), + None => log::info!("[TRAY] profile: cleared — using base configuration"), + } + Ok(()) +} + +/// Issue #869: the write half of the tray's profile picker. Holds the config +/// write guard across the atomic save and stores the clamped copy so the +/// in-memory config (what the poller reads) and `config.json` agree on the +/// next launch — exactly the discipline `store_snooze` and +/// `commands::config::update_config` enforce. +pub fn store_active_profile(app: &AppHandle, name: Option) -> Result<(), String> { + let state = app.state::>(); + let mut guard = state.config.get_mut(); + let base = match guard.as_ref() { + Some(current) => (**current).clone(), + None => crate::config::load_config()?, + }; + let mut next = base; + next.active_profile = name; + let mut persisted = crate::config::clamped_config(&next); + crate::config::stamp_schema_version(&mut persisted); + crate::config::save_config(&persisted)?; + *guard = Some(std::sync::Arc::new(persisted)); + Ok(()) +} + +/// Clears a stored snooze deadline that has already passed, once, as the app +/// comes up (4.7.0, S9 / issue #677). +/// +/// `config::load_config` is a READER on paths that hold no config-write guard, +/// so it reports an expired deadline without touching the document (a reader +/// that fixed the field in memory would hide the expiry from every writer that +/// can correct `config.json` — see that fn). This is the writer: a guarded +/// store through [`store_snooze`], run at startup, so a snooze that expired +/// while the app was closed does not sit in `config.json` logging a clamp line +/// on every launch. The poller's own expiry arm covers a deadline that lapses +/// while the app is running; either writer alone is sufficient. +pub fn clear_expired_snooze_at_startup(app: &AppHandle) { + let state = app.state::>(); + let expired = state + .config + .get() + .as_ref() + .is_some_and(|cfg| crate::config::snooze_expired_deadline(cfg, chrono::Utc::now())); + if !expired { + return; + } + match store_snooze(app, None) { + Ok(()) => { + log::info!("[TRAY] snooze: cleared the expired deadline left by the previous session") + } + Err(e) => log::warn!( + "[TRAY] snooze: could not clear the expired deadline ({}); the poller will retry", + e + ), + } +} + +/// The tray's status line while a snooze is active (4.7.0, S9 / issue #677): +/// the word, the remaining time and the local deadline. +/// +/// This replaces [`sync_status_line`] rather than prefixing it: a snoozed +/// session is deliberately not syncing anything, so any words about the current +/// track would be a claim about work that is not happening. The countdown needs +/// no locale-specific plural handling — the unit is a table entry, and the +/// minute count is rounded so it never reads "0 min left" while the snooze is +/// still live (see `config::snooze_minutes_left`). +/// +/// Localized from the table it is handed, like every other label here; the +/// `→` and `—` separators are locale-neutral, matching `sync_status_line`. +pub fn snooze_status_line(strings: &Strings, snooze: &TraySnooze) -> String { + format!( + "{} — {} {} (→ {})", + strings.snooze_paused, + snooze.minutes_left, + strings.snooze_minutes_left, + snooze + .status + .deadline + .with_timezone(&chrono::Local) + .format("%H:%M") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::i18n::{DE, EN, FR}; + use crate::tray::testkit::MODE_ATOM_LOCK; + use crate::tray::testkit::{body_of, tray_prod_source}; + /// A config carrying `snooze_until` as stored. + fn snoozed_config(stored: &str) -> std::sync::Arc { + std::sync::Arc::new(crate::config::AppConfig { + snooze_until: Some(stored.to_string()), + ..crate::config::AppConfig::default() + }) + } + /// A deadline `seconds` from now, in the stored spelling. + fn deadline_in(seconds: i64) -> String { + crate::config::snooze_store_form(chrono::Utc::now() + chrono::TimeDelta::seconds(seconds)) + } + /// Every id the submenu can mint maps to exactly one action, and anything + /// else is refused — a stale menu from an older build must not snooze. + #[test] + fn snooze_menu_ids_parse_to_their_action() { + use crate::config::SnoozePreset; + assert_eq!( + parse_snooze_menu_id(ID_SNOOZE_30), + Some(SnoozeMenuSelection::Preset(SnoozePreset::ThirtyMinutes)) + ); + assert_eq!( + parse_snooze_menu_id(ID_SNOOZE_1H), + Some(SnoozeMenuSelection::Preset(SnoozePreset::OneHour)) + ); + assert_eq!( + parse_snooze_menu_id(ID_SNOOZE_TOMORROW), + Some(SnoozeMenuSelection::Preset(SnoozePreset::UntilTomorrow)) + ); + // Issue #867: the meeting-bound preset id parses to the new variant. + assert_eq!( + parse_snooze_menu_id(ID_SNOOZE_NEXT_MEETING), + Some(SnoozeMenuSelection::Preset( + SnoozePreset::UntilNextMeetingEnds + )) + ); + assert_eq!( + parse_snooze_menu_id(ID_SNOOZE_RESUME), + Some(SnoozeMenuSelection::Resume) + ); + // The prefix alone, an unknown suffix and a foreign id all resolve to + // nothing rather than to a default preset. + for unknown in [ + SNOOZE_ITEM_PREFIX, + "snooze|2h", + "play_pause", + "devices|abc", + "", + ] { + assert_eq!(parse_snooze_menu_id(unknown), None, "id {:?}", unknown); + } + } + /// The status line states the remaining time and the local deadline, in + /// every locale (issue #677 + the #674 table contract). + #[test] + fn snooze_status_line_counts_down_in_every_locale() { + let snooze = TraySnooze { + status: crate::config::SnoozeStatus { + // A fixed instant so the rendered `HH:MM` is stable; the local + // hour depends on the runner's zone, so it is rebuilt here from + // the same instant rather than hard-coded. + deadline: chrono::Utc::now() + chrono::TimeDelta::minutes(30), + remaining_seconds: 30 * 60, + }, + minutes_left: 30, + }; + let local = snooze + .status + .deadline + .with_timezone(&chrono::Local) + .format("%H:%M") + .to_string(); + + assert_eq!( + snooze_status_line(&EN, &snooze), + format!("Snoozed — 30 min left (→ {})", local) + ); + assert_eq!( + snooze_status_line(&DE, &snooze), + format!("Sync pausiert — 30 Min. verbleibend (→ {})", local) + ); + assert_eq!( + snooze_status_line(&FR, &snooze), + format!("Synchro en pause — 30 min restant (→ {})", local) + ); + // The countdown is the ONLY thing the line says — a snoozed session is + // not syncing, so it must not name a track. + assert!(!snooze_status_line(&EN, &snooze).contains("Syncing")); + } + /// A snooze is resolved from the stored value alone, and everything that is + /// not a live deadline resolves to "not snoozed" — which is what keeps the + /// tray and the poller agreeing without a second source of truth. + #[test] + fn resolve_snooze_accepts_only_a_live_deadline() { + assert!(resolve_snooze(None).is_none(), "no config loaded"); + assert!(resolve_snooze(Some(&std::sync::Arc::new( + crate::config::AppConfig::default() + ))) + .is_none()); + assert!(resolve_snooze(Some(&snoozed_config(&deadline_in(-60)))).is_none()); + assert!(resolve_snooze(Some(&snoozed_config("not a timestamp"))).is_none()); + + let live = resolve_snooze(Some(&snoozed_config(&deadline_in(30 * 60)))) + .expect("a future deadline is an active snooze"); + assert_eq!(live.minutes_left, 30, "a fresh 30-minute snooze reads 30"); + assert!(live.status.remaining_seconds > 0); + } + /// The dedup key carries the snooze, so a snooze that starts, ends or ticks + /// over to the next minute repaints instead of early-returning as a no-op + /// (the countdown would otherwise freeze at the minute the menu was built). + #[test] + fn snooze_change_forces_a_tray_rebuild() { + let _guard = MODE_ATOM_LOCK.lock(); + note_playback_modes(false, RepeatState::Off); + let track = crate::spotify::TrackInfo { + title: "Title".to_string(), + artist: "Artist".to_string(), + album: "Album".to_string(), + album_art_url: String::new(), + is_playing: true, + progress_ms: None, + duration_ms: 0, + volume_percent: None, + supports_volume: None, + actions: None, + }; + let at = |snooze: Option| { + tray_snapshot_for(true, true, Some(&track), snooze, 0, 0, None) + }; + + let none = at(None); + let snoozed = at(Some(snooze_dedup_key(&crate::config::SnoozeStatus { + deadline: chrono::Utc::now() + chrono::TimeDelta::minutes(30), + remaining_seconds: 30 * 60, + }))); + assert!( + tray_state_changed(Some(&none), &snoozed), + "starting a snooze must repaint (issue #677)" + ); + assert!( + tray_state_changed(Some(&snoozed), &none), + "ending a snooze must repaint (issue #677)" + ); + + // The minute bucket is part of the key: same deadline, next minute. + let deadline = chrono::Utc::now() + chrono::TimeDelta::minutes(30); + let minute_29 = Some(snooze_dedup_key(&crate::config::SnoozeStatus { + deadline, + remaining_seconds: 29 * 60, + })); + let minute_30 = Some(snooze_dedup_key(&crate::config::SnoozeStatus { + deadline, + remaining_seconds: 30 * 60, + })); + assert!( + tray_state_changed(Some(&at(minute_30)), &at(minute_29.clone())), + "the countdown must move once a minute, not once per snooze" + ); + assert!( + !tray_state_changed(Some(&at(minute_29.clone())), &at(minute_29)), + "a rebuild with nothing changed still dedupes" + ); + } + /// A snooze means "no Spotify request", and the tray's own rebuild is one + /// of the two things that still runs while snoozed — so its fetch mode must + /// follow the snooze rather than the caller. + #[test] + fn snooze_makes_the_tray_rebuild_cache_only() { + assert_eq!(tray_fetch_mode(true), TrayFetch::CacheOnly); + assert_eq!(tray_fetch_mode(false), TrayFetch::Refresh); + + // Structural: the rebuild derives its fetch choice from the snooze and + // hands it to BOTH submenu sources — a literal `Refresh` in either call + // would re-fetch devices/queue every throttle window while snoozed. + let prod = tray_prod_source(); + let body = body_of(prod, "fn rebuild_tray_menu("); + assert!( + body.contains("paint_fetch_mode(paint, snooze.is_some(), action_refresh_due)"), + "the rebuild's fetch mode must come from the paint and the snooze" + ); + // …and the snooze rule itself is unchanged: an ordinary rebuild while + // snoozed is cache-only (issue #677), while the startup paint is + // cache-only whatever the snooze says (issue #882). + let decider = body_of(prod, "fn paint_fetch_mode("); + assert!( + decider.contains("tray_fetch_mode(snoozed)"), + "an ordinary rebuild must take its fetch mode from the snooze (issue #677)" + ); + for call in [ + "devices_for_menu(access_token.as_deref(), fetch)", + "queue_for_menu(access_token.as_deref(), fetch)", + ] { + assert!( + body.contains(call), + "`{}` must honour the snooze-aware fetch mode", + call + ); + } + assert!( + !body.contains("cached_devices(") && !body.contains("cached_queue("), + "the rebuild must go through the fetch-mode helpers, not the raw fetchers" + ); + } + /// The snooze must be resolved BEFORE the dedup comparison — a key computed + /// after the early return would never see it. + #[test] + fn snooze_is_resolved_before_the_dedup_guard() { + let prod = tray_prod_source(); + let body = body_of(prod, "fn rebuild_tray_menu("); + // Issue #886: the snooze comes from a scoped read of the mounted config + // (`snooze_from_app_state`) instead of a whole-`AppConfig` clone. + let resolve = body + .find("snooze_from_app_state(") + .expect("update_tray_menu must resolve the snooze"); + let snapshot = body + .find("tray_snapshot_for(") + .expect("update_tray_menu must build the dedup key"); + let guard = body + .find("tray_state_changed(") + .expect("update_tray_menu must keep the dedup guard"); + assert!( + resolve < snapshot && snapshot < guard, + "the snooze must be part of the dedup key, evaluated before the guard" + ); + assert!( + body.contains("snooze_key"), + "the dedup key must carry the snooze (deadline + minute bucket)" + ); + } + /// A snooze click writes `config.json`, so it must happen off the + /// menu-event thread, and the repaint must follow the write. + #[test] + fn snooze_click_arms_write_off_thread_and_repaint() { + let prod = tray_prod_source(); + let body = body_of(prod, "pub fn handle_menu_event("); + let arm_pos = body + .find("id if id.starts_with(SNOOZE_ITEM_PREFIX)") + .expect("handle_menu_event must handle the snooze submenu ids"); + let arm_end = body[arm_pos..] + .find("id if id.starts_with(DEVICE_ITEM_PREFIX)") + .map(|i| arm_pos + i) + .unwrap_or(body.len()); + let arm = &body[arm_pos..arm_end]; + assert!( + arm.contains("std::thread::spawn"), + "the snooze arm must not write config.json on the menu-event thread" + ); + assert!( + arm.contains("write_snooze("), + "the snooze arm must go through the config-writing helper" + ); + assert!( + arm.contains("repaint_tray_from_state("), + "the snooze arm must repaint after the write" + ); + // The worker must own the id: the menu-event borrow cannot outlive it + // (a `&str` borrowed from `event` fails to compile in the spawn). + assert!( + arm.contains("id.to_string()"), + "the snooze arm must copy the id before spawning the worker" + ); + assert!( + !arm.contains("tokens.spotify()"), + "the snooze arm must not touch the Spotify token path" + ); + } + /// The tray's snooze writer follows the same store-what-was-persisted + /// discipline as `commands::config::update_config` (issues #297 / #536) — an + /// in-memory value that disagrees with disk would show a countdown for a + /// snooze the next launch does not honour. + #[test] + fn store_snooze_persists_the_clamped_stamped_value() { + let prod = tray_prod_source(); + let body = body_of(prod, "fn store_snooze("); + for marker in [ + "state.config.get_mut()", + "crate::config::clamped_config(&next)", + "crate::config::stamp_schema_version(&mut persisted)", + "crate::config::save_config(&persisted)", + "*guard = Some(std::sync::Arc::new(persisted))", + ] { + assert!( + body.contains(marker), + "store_snooze must contain `{}`", + marker + ); + } + // A failed write must leave the in-memory config alone, so the tray + // never renders a snooze that is not on disk. + assert!( + body.find("save_config(&persisted)?").unwrap_or(usize::MAX) + < body + .find("*guard = Some(std::sync::Arc::new(persisted))") + .unwrap_or(0), + "the store must happen only after a successful save" + ); + // The write half holds no log lines: the copy belongs to the caller, so + // the startup cleanup cannot masquerade as a user resume. + assert!( + !body.contains("log::info!"), + "store_snooze must not log — the caller owns the message" + ); + } + /// The tray must log what it did: the deadline on the way in and the + /// explicit resume on the way out (issue #677's logging contract). + #[test] + fn write_snooze_logs_both_edges() { + let prod = tray_prod_source(); + let body = body_of(prod, "fn write_snooze("); + assert!( + body.contains("store_snooze(app, until)?"), + "write_snooze must be the logging wrapper around the writer" + ); + assert!( + body.contains("[TRAY] snooze: polling paused until"), + "the snooze start must be logged with its deadline" + ); + assert!( + body.contains("[TRAY] snooze: resumed by the user"), + "the manual resume must be logged" + ); + } + /// Issue #869: the tray's profile picker mirrors `write_snooze`'s + /// discipline — a write helper that takes the logger's copy and a + /// store helper that holds the config write guard across the atomic + /// save. Splitting the two means the same guarded path can be reused + /// by the CLI without re-doing the lock dance. + #[test] + fn write_active_profile_uses_the_guarded_writer_and_logs_both_edges() { + let prod = tray_prod_source(); + let body = body_of(prod, "fn write_active_profile("); + assert!( + body.contains("store_active_profile(app, name.clone())?"), + "write_active_profile must be the logging wrapper around the writer" + ); + assert!( + body.contains("[TRAY] profile: active profile is now"), + "an active profile switch must be logged" + ); + assert!( + body.contains("[TRAY] profile: cleared — using base configuration"), + "clearing the active profile must be logged with its own copy" + ); + + let store = body_of(prod, "fn store_active_profile("); + for marker in [ + "state.config.get_mut()", + "crate::config::clamped_config(&next)", + "crate::config::stamp_schema_version(&mut persisted)", + "crate::config::save_config(&persisted)?", + "*guard = Some(std::sync::Arc::new(persisted))", + ] { + assert!( + store.contains(marker), + "store_active_profile must contain `{}`", + marker + ); + } + } + /// Issue #869: the menu build wires the profile submenu next to the + /// snooze items and the click handler dispatches both the base + /// sentinel and the per-profile prefixed ids through the same off- + /// thread writer. + #[test] + fn profile_submenu_is_in_the_main_menu_and_its_click_arm_is_off_thread() { + let prod = tray_prod_source(); + let build_profile = body_of(prod, "fn build_profile_submenu("); + assert!( + build_profile.contains("ID_PROFILE_BASE"), + "the picker must carry the base sentinel id" + ); + assert!( + build_profile.contains("PROFILE_ITEM_PREFIX"), + "the picker must prefix per-profile ids" + ); + // The dedup key carries the active id, so a profile switch repaints. + let snapshot = body_of(prod, "fn tray_snapshot_for("); + assert!( + snapshot.contains("active_profile_key"), + "the dedup key must carry the active profile id so a switch repaints" + ); + // The click handler validates the picked name and offloads the write. + let setup = body_of(prod, "pub fn handle_menu_event("); + assert!( + setup.contains("ID_PROFILE_BASE") && setup.contains("PROFILE_ITEM_PREFIX"), + "the click dispatcher must handle both the base sentinel and per-profile ids" + ); + assert!( + setup.contains("write_active_profile(&app_handle, target)"), + "the click handler must call the write helper, never inline a config write" + ); + assert!( + setup.contains("repaint_tray_from_state(&app_handle, \"profile\")"), + "the click handler must repaint after the write so the checkmark moves" + ); + // Wired into the menu build. + let rebuild = body_of(prod, "fn rebuild_tray_menu("); + assert!( + rebuild.contains("build_profile_submenu(app, s, &profiles, active.as_deref())"), + "rebuild_tray_menu must build the profile submenu" + ); + assert!( + rebuild.contains("&profile_submenu,"), + "rebuild_tray_menu must add the profile submenu to the main menu" + ); + } + /// A deadline that expired while the app was closed is cleared from + /// `config.json` at startup, so the clamp log line cannot repeat on every + /// launch and the persisted document matches what the app honours. + #[test] + fn startup_cleanup_clears_an_expired_deadline_through_the_guarded_writer() { + let prod = tray_prod_source(); + let body = body_of(prod, "fn clear_expired_snooze_at_startup("); + assert!( + body.contains("crate::config::snooze_expired_deadline(cfg"), + "the cleaner must ask the shared predicate whether the deadline is dead" + ); + assert!( + body.contains("store_snooze(app, None)"), + "the cleaner must go through the guarded writer, not write the file itself" + ); + assert!( + body.contains("cleared the expired deadline left by the previous session"), + "the cleanup must be logged, and with its own copy" + ); + // Wired into startup: a cleaner nothing calls would leave the stale + // deadline on disk forever. + let setup = body_of(prod, "pub fn setup_tray("); + assert!( + setup.contains("clear_expired_snooze_at_startup(app.handle())"), + "setup_tray must run the cleanup once, before the first menu build" + ); + let cleanup = setup + .find("clear_expired_snooze_at_startup(") + .expect("call site"); + let menu = setup.find("update_tray_menu_startup(").expect("tray paint"); + assert!( + cleanup < menu, + "the cleanup must precede the first menu build, so the tray never \ + renders a state the config no longer holds" + ); + } +} diff --git a/src-tauri/src/tray/testkit.rs b/src-tauri/src/tray/testkit.rs new file mode 100644 index 00000000..cc07c15b --- /dev/null +++ b/src-tauri/src/tray/testkit.rs @@ -0,0 +1,69 @@ +//! tray/testkit.rs — shared helpers for source-scan tests (#756). +#![allow(dead_code)] +use std::sync::OnceLock; +#[cfg(test)] +pub static MODE_ATOM_LOCK: parking_lot::Mutex<()> = parking_lot::Mutex::new(()); +static TRAY_PROD: OnceLock = OnceLock::new(); +/// Production half of `src` — everything before the inline test module, +/// so a scan can never match the assertions themselves. +pub fn prod_source(src: &str) -> &str { + src.split("#[cfg(test)]\nmod tests").next().unwrap_or(src) +} +/// Drops `//` line comments so a source-scan assertion is not fooled by +/// prose that quotes the very construct it forbids. +pub fn strip_line_comments(src: &str) -> String { + src.lines() + .map(|line| match line.find("//") { + Some(i) => &line[..i], + None => line, + }) + .collect::>() + .join("\n") +} +/// Comment-stripped, brace-counted body isolation for `sig`'s fn. +/// Order-independent: never anchor on the next fn. +pub fn body_of(prod: &str, sig: &str) -> String { + let stripped = strip_line_comments(prod); + let after_sig = stripped + .split(sig) + .nth(1) + .unwrap_or_else(|| panic!("tray source has no `{}`", sig)); + let open = after_sig + .find('{') + .unwrap_or_else(|| panic!("{} has no opening brace", sig)); + let mut depth = 0usize; + let mut end = None; + for (i, ch) in after_sig[open..].char_indices() { + match ch { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + end = Some(open + i + 1); + break; + } + } + _ => {} + } + } + after_sig[..end.unwrap_or_else(|| panic!("{} body never closed", sig))].to_string() +} +/// Concatenated production halves of all six tray modules, so source-scan +/// guards keep resolving after the #756 split no matter which file owns +/// the scanned fn. +pub fn tray_prod_source() -> &'static str { + TRAY_PROD.get_or_init(|| { + [ + include_str!("mod.rs"), + include_str!("cache.rs"), + include_str!("dedup.rs"), + include_str!("snooze.rs"), + include_str!("devices.rs"), + include_str!("actions.rs"), + ] + .iter() + .map(|s| prod_source(s)) + .collect::>() + .join("\n") + }) +}