Skip to content

Commit 37464b2

Browse files
authored
Autosync: tray icon and Close to tray setting (#693)
1 parent 5409fe3 commit 37464b2

14 files changed

Lines changed: 1004 additions & 12 deletions

File tree

Cargo.lock

Lines changed: 40 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

quilt-sync/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99
<!-- markdownlint-disable MD013 -->
1010
# Changelog
1111

12+
## [v0.18.2-alpha1] - 2026-05-22
13+
14+
### Added
15+
16+
- Tray icon with status indicator and `Close to tray` setting in Settings → Autosync — keeps autosync running on the closed-window cadence when enabled (default off) (<https://github.com/quiltdata/quilt-rs/pull/693>)
17+
1218
## [v0.18.1] - 2026-05-21
1319

1420
### Changed

quilt-sync/src-tauri/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "quilt-sync"
3-
version = "0.18.1"
3+
version = "0.18.2-alpha1"
44
authors = ["Quilt Data, Inc."]
55
description = "Cross-platform desktop application for editing Quilt data packages"
66
documentation = "https://docs.quiltdata.com"
@@ -33,7 +33,7 @@ notify-debouncer-full = "0.7"
3333
serde.workspace = true
3434
serde_json.workspace = true
3535
serde_qs = "1.1.1"
36-
tauri = { version = "2.11.1", features = ["test", "devtools"] }
36+
tauri = { version = "2.11.1", features = ["test", "devtools", "tray-icon", "image-png"] }
3737
tauri-plugin-deep-link = "2.4.9"
3838
tauri-plugin-process = "2.3.1"
3939
tauri-plugin-shell = "2.3.5"
118 KB
Loading

quilt-sync/src-tauri/src/autopull.rs

Lines changed: 114 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
use std::collections::BTreeMap;
2+
use std::collections::BTreeSet;
23
use std::sync::Arc;
34
use std::time::Duration;
45

6+
use quilt_uri::Host;
57
use quilt_uri::Namespace;
68
use tauri::Manager;
79
use tokio::sync::RwLock;
10+
use tokio::sync::watch;
811

12+
use crate::autopull::status::SyncTrayAggregator;
913
use crate::model::Model;
1014
use crate::publish_settings::SharedPublishSettings;
1115
use crate::telemetry::prelude::*;
1216

1317
pub mod reporter;
1418
pub mod settings;
19+
pub mod status;
1520
pub mod tick;
1621

1722
pub use reporter::PackageStatusEvent;
@@ -21,6 +26,8 @@ pub use settings::PullSettings;
2126
pub use settings::PushSettings;
2227
pub use settings::SharedAutosyncSettings;
2328
pub use settings::init as init_settings;
29+
pub use status::SyncTrayStatus;
30+
pub use status::TrayMode;
2431

2532
use tick::BackoffState;
2633
use 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

7784
pub 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 {
191244
mod 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

Comments
 (0)