2626//! APPROXIMATES the yellow-with-`!` badge - no glyph is drawn into the tile.
2727//! The state machine, tooltip text, and notification routing are all real.
2828
29+ use std:: collections:: HashMap ;
2930use std:: sync:: Mutex ;
3031
31- use driven_core:: types:: { ErrorCode , OrchestratorState , PauseReason } ;
32+ use driven_core:: types:: { AccountId , ErrorCode , OrchestratorState , PauseReason } ;
3233use tauri:: image:: Image ;
3334use tauri:: menu:: { Menu , MenuBuilder , MenuItem } ;
3435use tauri:: tray:: { MouseButton , MouseButtonState , TrayIconBuilder , TrayIconEvent } ;
@@ -274,10 +275,15 @@ fn tooltip_for_error(code: ErrorCode) -> String {
274275// Notification dedup state
275276// -----------------------------------------------------------------------------
276277
277- /// Module-level notification dedup state (DESIGN s117/s247): fire the
278- /// first-sync-complete toast exactly once, and fire one error toast per
279- /// ENTRY into an error code (not once per `StateChanged` event - the
280- /// orchestrator broadcast can replay the current state after a `Lagged`).
278+ /// Per-account notification dedup state (DESIGN s117/s247): fire the
279+ /// first-sync-complete toast exactly once PER ACCOUNT, and fire one error toast
280+ /// per ENTRY into an error code PER ACCOUNT (not once per `StateChanged` event -
281+ /// the orchestrator broadcast can replay the current state after a `Lagged`).
282+ ///
283+ /// V5-P2-2 / C5-P2-4: keyed per account so one account's first-sync toast does
284+ /// not silence another's, and an error on account B is not suppressed by the
285+ /// same code already toasted for account A.
286+ #[ derive( Default ) ]
281287struct NotifyState {
282288 /// True once a sync cycle has been observed running this process (so the
283289 /// next `Idle` transition is a genuine completion, not the boot `Idle`).
@@ -289,22 +295,17 @@ struct NotifyState {
289295 last_error_code : Option < ErrorCode > ,
290296}
291297
292- impl NotifyState {
293- const fn new ( ) -> Self {
294- Self {
295- saw_active_cycle : false ,
296- first_sync_notified : false ,
297- last_error_code : None ,
298- }
299- }
300- }
301-
302- static NOTIFY : Mutex < NotifyState > = Mutex :: new ( NotifyState :: new ( ) ) ;
303-
304- /// Lock the dedup state, recovering a poisoned lock (HARD RULE: no panic on
305- /// a poisoned mutex).
306- fn notify_state ( ) -> std:: sync:: MutexGuard < ' static , NotifyState > {
307- NOTIFY . lock ( ) . unwrap_or_else ( |e| e. into_inner ( ) )
298+ /// Process-global map of per-account dedup state. Keyed by [`AccountId`] so the
299+ /// dedup latches are independent across accounts (V5-P2-2).
300+ static NOTIFY : Mutex < Option < HashMap < AccountId , NotifyState > > > = Mutex :: new ( None ) ;
301+
302+ /// Run `f` against the dedup state for `account`, creating it on first use.
303+ /// Recovers a poisoned lock instead of panicking (HARD RULE).
304+ fn with_notify_state < R > ( account : AccountId , f : impl FnOnce ( & mut NotifyState ) -> R ) -> R {
305+ let mut guard = NOTIFY . lock ( ) . unwrap_or_else ( |e| e. into_inner ( ) ) ;
306+ let map = guard. get_or_insert_with ( HashMap :: new) ;
307+ let entry = map. entry ( account) . or_default ( ) ;
308+ f ( entry)
308309}
309310
310311// -----------------------------------------------------------------------------
@@ -396,7 +397,7 @@ fn on_menu_event(app: &AppHandle, id: &str) {
396397 let Some ( state) = app. try_state :: < crate :: app_state:: AppState > ( ) else {
397398 return missing_state_err ( ) ;
398399 } ;
399- crate :: commands:: sync:: pause_sync ( state, Some ( 30 * 60 ) ) . await
400+ crate :: commands:: sync:: pause_sync ( app . clone ( ) , state, Some ( 30 * 60 ) ) . await
400401 } ) ,
401402 menu_id:: RESUME => spawn_command ( app, |app| async move {
402403 let Some ( state) = app. try_state :: < crate :: app_state:: AppState > ( ) else {
@@ -479,7 +480,7 @@ fn navigate_hint(app: &AppHandle, route: &str) {
479480/// Best-effort: a missing tray or a failed icon/tooltip set is logged, never
480481/// panicked. `apply_state` returns `()` (the committed signature) so all
481482/// errors are swallowed with a `tracing` line.
482- pub fn apply_state ( app : & AppHandle , state : OrchestratorState ) {
483+ pub fn apply_state ( app : & AppHandle , account_id : AccountId , state : OrchestratorState ) {
483484 let icon = TrayIcon :: for_state ( & state) ;
484485
485486 if let Some ( tray) = app. tray_by_id ( TRAY_ID ) {
@@ -497,7 +498,7 @@ pub fn apply_state(app: &AppHandle, state: OrchestratorState) {
497498 tracing:: warn!( target: TARGET , "tray {TRAY_ID} not found; cannot apply state" ) ;
498499 }
499500
500- notify_for_state ( app, & state) ;
501+ notify_for_state ( app, account_id , & state) ;
501502}
502503
503504/// Raise the DESIGN s117/s247 OS notifications for a state transition.
@@ -509,7 +510,7 @@ pub fn apply_state(app: &AppHandle, state: OrchestratorState) {
509510/// case (`auth.invalid_grant` / `auth.consent_required`) is deliberately
510511/// skipped here - it is covered by [`notify_needs_reauth`], which the shell
511512/// calls with the account + email the state cannot carry.
512- fn notify_for_state ( app : & AppHandle , state : & OrchestratorState ) {
513+ fn notify_for_state ( app : & AppHandle , account_id : AccountId , state : & OrchestratorState ) {
513514 match state {
514515 OrchestratorState :: PowerCheck
515516 | OrchestratorState :: Scanning { .. }
@@ -518,19 +519,23 @@ fn notify_for_state(app: &AppHandle, state: &OrchestratorState) {
518519 | OrchestratorState :: Verifying { .. }
519520 | OrchestratorState :: Backoff { .. } => {
520521 // A cycle is underway; the next Idle is a genuine completion.
521- let mut s = notify_state ( ) ;
522- s. saw_active_cycle = true ;
523- // Leaving any error state clears the dedup latch so a recurrence
524- // notifies again.
525- s. last_error_code = None ;
522+ with_notify_state ( account_id, |s| {
523+ s. saw_active_cycle = true ;
524+ // Leaving any error state clears the dedup latch so a recurrence
525+ // notifies again.
526+ s. last_error_code = None ;
527+ } ) ;
526528 }
527529 OrchestratorState :: Idle { .. } => {
528- let mut s = notify_state ( ) ;
529- s. last_error_code = None ;
530- let should_fire = s. saw_active_cycle && !s. first_sync_notified ;
530+ let should_fire = with_notify_state ( account_id, |s| {
531+ s. last_error_code = None ;
532+ let fire = s. saw_active_cycle && !s. first_sync_notified ;
533+ if fire {
534+ s. first_sync_notified = true ;
535+ }
536+ fire
537+ } ) ;
531538 if should_fire {
532- s. first_sync_notified = true ;
533- drop ( s) ;
534539 show_notification (
535540 app,
536541 rust_i18n:: t!( "notifications.first_sync_complete.title" ) . into_owned ( ) ,
@@ -541,8 +546,9 @@ fn notify_for_state(app: &AppHandle, state: &OrchestratorState) {
541546 OrchestratorState :: Paused { .. } => {
542547 // Pauses (battery / metered / network) are icon+tooltip only, no
543548 // toast on every blip (DESIGN s117/s247).
544- let mut s = notify_state ( ) ;
545- s. last_error_code = None ;
549+ with_notify_state ( account_id, |s| {
550+ s. last_error_code = None ;
551+ } ) ;
546552 }
547553 OrchestratorState :: Error { detail } => {
548554 // Reauth is handled by notify_needs_reauth (needs account/email).
@@ -557,18 +563,22 @@ fn notify_for_state(app: &AppHandle, state: &OrchestratorState) {
557563 if TrayIcon :: for_state ( state) != TrayIcon :: Error {
558564 return ;
559565 }
560- let mut s = notify_state ( ) ;
561- if s. last_error_code == Some ( detail. code ) {
562- return ; // already toasted this error; suppress the replay
566+ let should_fire = with_notify_state ( account_id, |s| {
567+ if s. last_error_code == Some ( detail. code ) {
568+ false // already toasted this error; suppress the replay
569+ } else {
570+ s. last_error_code = Some ( detail. code ) ;
571+ true
572+ }
573+ } ) ;
574+ if should_fire {
575+ let body = error_notification_body ( detail. code ) ;
576+ show_notification (
577+ app,
578+ rust_i18n:: t!( "notifications.error.title" ) . into_owned ( ) ,
579+ body,
580+ ) ;
563581 }
564- s. last_error_code = Some ( detail. code ) ;
565- drop ( s) ;
566- let body = error_notification_body ( detail. code ) ;
567- show_notification (
568- app,
569- rust_i18n:: t!( "notifications.error.title" ) . into_owned ( ) ,
570- body,
571- ) ;
572582 }
573583 }
574584}
0 commit comments