11use std:: collections:: BTreeMap ;
2+ use std:: collections:: BTreeSet ;
23use std:: sync:: Arc ;
34use std:: time:: Duration ;
45
6+ use quilt_uri:: Host ;
57use quilt_uri:: Namespace ;
68use tauri:: Manager ;
79use tokio:: sync:: RwLock ;
10+ use tokio:: sync:: watch;
811
12+ use crate :: autopull:: status:: SyncTrayAggregator ;
913use crate :: model:: Model ;
1014use crate :: publish_settings:: SharedPublishSettings ;
1115use crate :: telemetry:: prelude:: * ;
1216
1317pub mod reporter;
1418pub mod settings;
19+ pub mod status;
1520pub mod tick;
1621
1722pub use reporter:: PackageStatusEvent ;
@@ -21,6 +26,8 @@ pub use settings::PullSettings;
2126pub use settings:: PushSettings ;
2227pub use settings:: SharedAutosyncSettings ;
2328pub use settings:: init as init_settings;
29+ pub use status:: SyncTrayStatus ;
30+ pub use status:: TrayMode ;
2431
2532use tick:: BackoffState ;
2633use tick:: run_once;
@@ -34,9 +41,7 @@ pub enum WindowMode {
3441 Focused ,
3542 Unfocused ,
3643 /// Window has been closed but the app stays alive via the tray icon.
37- /// Wired in milestone 4 (`05-trayicon.md`); kept here so the cadence
38- /// table and the on-disk settings file are forward-compatible.
39- #[ allow( dead_code) ]
44+ /// Set when the user closes the main window with `close_to_tray` on.
4045 Closed ,
4146}
4247
@@ -71,7 +76,9 @@ pub(crate) struct WatcherInner {
7176 pub publish_settings : SharedPublishSettings ,
7277 pub paused : RwLock < BTreeMap < Namespace , PausedReason > > ,
7378 pub backoff : RwLock < BTreeMap < Namespace , BackoffState > > ,
79+ pub login_blocked : RwLock < BTreeMap < Namespace , Option < Host > > > ,
7480 pub reporter : Arc < dyn StatusReporter > ,
81+ pub aggregator : Arc < SyncTrayAggregator > ,
7582}
7683
7784pub fn create_window_mode ( ) -> SharedWindowMode {
@@ -91,14 +98,18 @@ impl Watcher {
9198 window_mode : SharedWindowMode ,
9299 publish_settings : SharedPublishSettings ,
93100 reporter : Arc < dyn StatusReporter > ,
94- ) -> Self {
101+ ) -> ( Self , watch:: Receiver < SyncTrayStatus > ) {
102+ let ( tx, rx) = watch:: channel ( SyncTrayStatus :: default ( ) ) ;
103+ let aggregator = Arc :: new ( SyncTrayAggregator :: new ( tx) ) ;
95104 let inner = Arc :: new ( WatcherInner {
96105 settings,
97106 window_mode,
98107 publish_settings,
99108 paused : RwLock :: new ( BTreeMap :: new ( ) ) ,
100109 backoff : RwLock :: new ( BTreeMap :: new ( ) ) ,
110+ login_blocked : RwLock :: new ( BTreeMap :: new ( ) ) ,
101111 reporter,
112+ aggregator,
102113 } ) ;
103114 let task_inner = Arc :: clone ( & inner) ;
104115 tauri:: async_runtime:: spawn ( async move {
@@ -109,13 +120,18 @@ impl Watcher {
109120 cadence_for_mode ( & settings. pull , mode)
110121 } ;
111122 tokio:: time:: sleep ( cadence) . await ;
123+ task_inner. aggregator . note_tick_started ( ) ;
112124 let model_state = app_handle. state :: < Model > ( ) ;
113- if let Err ( err) = run_once ( & * model_state, & task_inner) . await {
114- warn ! ( "autosync: tick error: {err}" ) ;
125+ match run_once ( & * model_state, & task_inner) . await {
126+ Ok ( ( ) ) => task_inner. aggregator . note_tick_ended_ok ( ) ,
127+ Err ( err) => {
128+ warn ! ( "autosync: tick error: {err}" ) ;
129+ task_inner. aggregator . note_tick_ended_err ( ) ;
130+ }
115131 }
116132 }
117133 } ) ;
118- Self { inner }
134+ ( Self { inner } , rx )
119135 }
120136
121137 pub async fn set_window_mode ( & self , mode : WindowMode ) {
@@ -127,12 +143,24 @@ impl Watcher {
127143 /// remote) that resolves the underlying conflict.
128144 pub async fn clear_paused ( & self , namespace : & Namespace ) {
129145 self . inner . paused . write ( ) . await . remove ( namespace) ;
146+ self . inner . login_blocked . write ( ) . await . remove ( namespace) ;
147+ self . inner . aggregator . note_cleared ( namespace) ;
130148 }
131149
132150 /// Drop the entire paused set. Called when `update_autosync_settings`
133151 /// flips `enabled` from false to true (M3).
134152 pub async fn clear_all_paused ( & self ) {
135- self . inner . paused . write ( ) . await . clear ( ) ;
153+ let mut paused = self . inner . paused . write ( ) . await ;
154+ let mut login_blocked = self . inner . login_blocked . write ( ) . await ;
155+ let namespaces: BTreeSet < Namespace > =
156+ paused. keys ( ) . chain ( login_blocked. keys ( ) ) . cloned ( ) . collect ( ) ;
157+ paused. clear ( ) ;
158+ login_blocked. clear ( ) ;
159+ drop ( paused) ;
160+ drop ( login_blocked) ;
161+ for ns in & namespaces {
162+ self . inner . aggregator . note_cleared ( ns) ;
163+ }
136164 }
137165
138166 /// Point-in-time view of the paused set, used by the
@@ -153,6 +181,15 @@ impl Watcher {
153181
154182 #[ cfg( test) ]
155183 fn new_for_test ( reporter : Arc < dyn StatusReporter > ) -> Self {
184+ let ( tx, _) = watch:: channel ( SyncTrayStatus :: default ( ) ) ;
185+ Self :: new_for_test_with_aggregator ( reporter, Arc :: new ( SyncTrayAggregator :: new ( tx) ) )
186+ }
187+
188+ #[ cfg( test) ]
189+ fn new_for_test_with_aggregator (
190+ reporter : Arc < dyn StatusReporter > ,
191+ aggregator : Arc < SyncTrayAggregator > ,
192+ ) -> Self {
156193 Self {
157194 inner : Arc :: new ( WatcherInner {
158195 settings : Arc :: new ( RwLock :: new ( AutosyncSettings :: default ( ) ) ) ,
@@ -162,16 +199,32 @@ impl Watcher {
162199 ) ) ,
163200 paused : RwLock :: new ( BTreeMap :: new ( ) ) ,
164201 backoff : RwLock :: new ( BTreeMap :: new ( ) ) ,
202+ login_blocked : RwLock :: new ( BTreeMap :: new ( ) ) ,
165203 reporter,
204+ aggregator,
166205 } ) ,
167206 }
168207 }
169208
209+ #[ cfg( test) ]
210+ async fn login_blocked_for_test ( & self ) -> BTreeMap < Namespace , Option < Host > > {
211+ self . inner . login_blocked . read ( ) . await . clone ( )
212+ }
213+
170214 #[ cfg( test) ]
171215 async fn pause_for_test ( & self , namespace : Namespace , reason : PausedReason ) {
172216 self . inner . paused . write ( ) . await . insert ( namespace, reason) ;
173217 }
174218
219+ #[ cfg( test) ]
220+ async fn login_block_for_test ( & self , namespace : Namespace , host : Option < Host > ) {
221+ self . inner
222+ . login_blocked
223+ . write ( )
224+ . await
225+ . insert ( namespace, host) ;
226+ }
227+
175228 #[ cfg( test) ]
176229 async fn paused_count ( & self ) -> usize {
177230 self . inner . paused . read ( ) . await . len ( )
@@ -191,6 +244,7 @@ pub fn cadence_for_mode(pull: &PullSettings, mode: WindowMode) -> Duration {
191244mod tests {
192245 use super :: * ;
193246 use reporter:: LogReporter ;
247+ use status:: TrayMode ;
194248
195249 #[ test]
196250 fn cadence_picks_per_mode_secs ( ) {
@@ -284,4 +338,56 @@ mod tests {
284338 watcher. clear_all_paused ( ) . await ;
285339 assert_eq ! ( watcher. paused_count( ) . await , 0 ) ;
286340 }
341+
342+ #[ tokio:: test]
343+ async fn new_for_test_starts_with_idle_status ( ) {
344+ let ( tx, rx) = watch:: channel ( SyncTrayStatus :: default ( ) ) ;
345+ let watcher = Watcher :: new_for_test_with_aggregator (
346+ Arc :: new ( LogReporter ) ,
347+ Arc :: new ( SyncTrayAggregator :: new ( tx) ) ,
348+ ) ;
349+ let status = rx. borrow ( ) . clone ( ) ;
350+ assert_eq ! ( status. mode, TrayMode :: Idle ) ;
351+ assert ! ( watcher. login_blocked_for_test( ) . await . is_empty( ) ) ;
352+ }
353+
354+ #[ tokio:: test]
355+ async fn clear_all_paused_clears_login_blocked_only_aggregator_error ( ) {
356+ // Regression: a namespace that hit `LoginRequired` lives in
357+ // `login_blocked` and in the aggregator's error map but never
358+ // enters `paused`. Re-enabling autosync must still drop the
359+ // aggregator error so the tray doesn't stay stuck in Error.
360+ let ( tx, rx) = watch:: channel ( SyncTrayStatus :: default ( ) ) ;
361+ let aggregator = Arc :: new ( SyncTrayAggregator :: new ( tx) ) ;
362+ let watcher =
363+ Watcher :: new_for_test_with_aggregator ( Arc :: new ( LogReporter ) , aggregator. clone ( ) ) ;
364+ let ns: Namespace = ( "acme" , "demo" ) . into ( ) ;
365+
366+ watcher. login_block_for_test ( ns. clone ( ) , None ) . await ;
367+ aggregator. note_login_required ( & ns, None ) ;
368+ assert_eq ! ( rx. borrow( ) . mode, TrayMode :: Error ) ;
369+
370+ watcher. clear_all_paused ( ) . await ;
371+ assert ! ( watcher. login_blocked_for_test( ) . await . is_empty( ) ) ;
372+ assert ! ( rx. borrow( ) . error. is_none( ) ) ;
373+ assert_eq ! ( rx. borrow( ) . mode, TrayMode :: Idle ) ;
374+ }
375+
376+ #[ tokio:: test]
377+ async fn clear_paused_also_clears_aggregator_error ( ) {
378+ let ( tx, rx) = watch:: channel ( SyncTrayStatus :: default ( ) ) ;
379+ let aggregator = Arc :: new ( SyncTrayAggregator :: new ( tx) ) ;
380+ let watcher =
381+ Watcher :: new_for_test_with_aggregator ( Arc :: new ( LogReporter ) , aggregator. clone ( ) ) ;
382+ let ns: Namespace = ( "acme" , "demo" ) . into ( ) ;
383+ watcher
384+ . pause_for_test ( ns. clone ( ) , PausedReason :: Diverged )
385+ . await ;
386+ aggregator. note_paused ( & ns, "diverged" ) ;
387+ assert_eq ! ( rx. borrow( ) . mode, TrayMode :: Paused ) ;
388+
389+ watcher. clear_paused ( & ns) . await ;
390+ assert ! ( rx. borrow( ) . error. is_none( ) ) ;
391+ assert_eq ! ( rx. borrow( ) . mode, TrayMode :: Idle ) ;
392+ }
287393}
0 commit comments