Skip to main content

max / audiofiles

Describe the cloud sync panel The second audiofiles screen through quasi, and the one that answers what a description does with a state machine: nothing new. Four states, four screens, one route. A user cannot navigate to Authenticating -- they arrive there because something happened -- so four addresses would be four places you could bookmark into a lie. goingson ruled its own sync section undescribable on 2026-08-09 because it reached a network client through commands taking an AppHandle. The same feature comes through cleanly here, and the reason is worth keeping: every SyncManager method this screen needs already takes &self, with the async behind a scheduler the manager owns. So the boundary is not "network is undescribable" but the one the settings port found from the other side -- a described screen needs a synchronous handle to the app's own capability, and whether it has one is a property of how the app is built rather than of what the capability does. The description deletes three platform branches. draw_disconnected opens the auth URL with open / xdg-open / cmd /c start, chosen by cfg(target_os), inside a drawing function. POST /sync/connect answers Outcome::Goto with an external destination and the host opens it once, for every control that ever goes outside. THE FINDING, and it is the largest this port programme has produced in a while: a description is built once per answer and this screen is alive. The panel reads status() every frame -- the spinner, the pending count falling, the state changing when an OAuth callback lands in another process. None of that is a user acting, and nothing in the vocabulary says "this is live". The host re-asking on a timer works and is invisible in the description, so two hosts will each invent a cadence. Not a poll interval in the description: Message::undo settled that timing is renderer policy. The missing word is nearer "this reports something that changes without the user". Panels replaces Settings as the router's state, because a router is one table: Router<S> is generic over a single S, so both screens share it and each borrows only the capability it uses. The subscription and cap section is deliberately out: a purchase flow with money in it deserves its own pass rather than riding along.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-14 01:53 UTC
Signed with PGP, not checked
Commit: ec1d6ef9fa03a70c22ebe5752c952ba9229e6320
Parent: e2bb987
7 files changed, +1039 insertions, -130 deletions
M Cargo.lock +12 -12
@@ -7541,18 +7541,6 @@
7541 7541 "winnow 1.0.4",
7542 7542 ]
7543 7543
7544 - [[patch.unused]]
7545 - name = "kberg"
7546 - version = "0.1.0"
7547 -
7548 - [[patch.unused]]
7549 - name = "ops-status"
7550 - version = "0.1.0"
7551 -
7552 - [[patch.unused]]
7553 - name = "painhours"
7554 - version = "0.1.0"
7555 -
7556 7544 [[patch.unused]]
7557 7545 name = "quasi-axum"
7558 7546 version = "0.2.0"
@@ -7572,3 +7560,15 @@
7572 7560 [[patch.unused]]
7573 7561 name = "quasi-webview"
7574 7562 version = "0.2.0"
7563 +
7564 + [[patch.unused]]
7565 + name = "kberg"
7566 + version = "0.1.0"
7567 +
7568 + [[patch.unused]]
7569 + name = "ops-status"
7570 + version = "0.1.0"
7571 +
7572 + [[patch.unused]]
7573 + name = "painhours"
7574 + version = "0.1.0"
@@ -167,11 +167,15 @@
167 167 // compared by looking at them. Behind the feature, so a shipped build
168 168 // has neither this call nor the module it reaches.
169 169 #[cfg(feature = "quasi")]
170 - crate::quasi::panel::draw(ctx, state);
170 + crate::quasi::panel::draw_settings(ctx, state);
171 171 }
172 172
173 173 // Sync panel overlay
174 174 if state.sync.show_panel {
175 + // The described one beside it, on the same toggle. `None` is the case
176 + // the shipped side answers with a whole second window.
177 + #[cfg(feature = "quasi")]
178 + crate::quasi::panel::draw_sync(ctx, state, sync_manager);
175 179 if let Some(sync) = sync_manager {
176 180 crate::ui::sync_panel::draw_sync_panel(ctx, state, sync);
177 181 } else {
@@ -9,7 +9,7 @@
9 9 //! # What the router's state is, and why it is this small
10 10 //!
11 11 //! A handler is `fn(&S, Request)`: sync, holding only what the app put in `S`.
12 - //! [`Settings`] is therefore the *narrowest* thing the described screens need
12 + //! [`Panels`] is therefore the *narrowest* thing the described screens need
13 13 //! rather than the whole of [`BrowserState`](crate::state::BrowserState), and
14 14 //! that turns out to be two things:
15 15 //!
@@ -33,6 +33,7 @@
33 33
34 34 pub mod panel;
35 35 pub mod settings;
36 + pub mod sync;
36 37
37 38 use audiofiles_core::config_key::ConfigKey;
38 39 use quasi_router::Router;
@@ -44,7 +45,7 @@
44 45 /// Two methods against `Backend`'s several dozen, and the narrowing is the
45 46 /// point rather than tidiness. Three things fall out of it:
46 47 ///
47 - /// - **`Settings` is honestly the narrowest thing the screens need.** Borrowing
48 + /// - **`Panels` is honestly the narrowest thing the screens need.** Borrowing
48 49 /// `&dyn Backend` would have said "this screen may do anything the app can do"
49 50 /// in its own type, which is exactly what a description layer is for not
50 51 /// saying.
@@ -90,6 +91,188 @@
90 91 }
91 92 }
92 93
94 + /// Where cloud sync has got to, as the description needs to name it.
95 + ///
96 + /// A plain snapshot rather than `audiofiles_sync::SyncStatus`, for the reason
97 + /// [`ThemeChoice`] is not `ThemeMeta`: a described screen should not depend on
98 + /// the shape of the thing it reports on, and the two fields this screen never
99 + /// names (`device_id`, `needs_refresh`) would otherwise be in its blast radius.
100 + #[derive(Debug, Clone, PartialEq, Eq)]
101 + pub struct Status {
102 + /// Which of the four the flow is in.
103 + pub state: State,
104 + /// When the last sync finished, as the app formats it.
105 + pub last_sync_at: Option<String>,
106 + /// How many local changes have not gone up.
107 + pub pending_changes: i64,
108 + /// What went wrong, if anything did.
109 + pub last_error: Option<String>,
110 + /// Whether the scheduler is running.
111 + pub auto_sync_enabled: bool,
112 + /// How often it runs, in minutes.
113 + pub sync_interval_minutes: u32,
114 + }
115 +
116 + /// The four states the sync flow has.
117 + ///
118 + /// Mirrored rather than re-exported, so the described screen names its own
119 + /// vocabulary. `NeedsEncryption` keeps its flag because it changes what the
120 + /// screen says, though not what shape it is.
121 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
122 + pub enum State {
123 + /// Not connected to anything.
124 + Disconnected,
125 + /// Waiting on a browser.
126 + Authenticating,
127 + /// Connected, and the vault is not unlocked yet.
128 + NeedsEncryption {
129 + /// Whether the server already holds a key, so this is an unlock rather
130 + /// than a first password.
131 + has_server_key: bool,
132 + },
133 + /// Connected and idle.
134 + Ready,
135 + /// Connected and working.
136 + Syncing,
137 + }
138 +
139 + /// Cloud sync, as much of it as a described screen needs.
140 + ///
141 + /// The same narrowing [`Config`] makes, for the same three reasons, and it lands
142 + /// harder here: `SyncManager` owns a scheduler, a client and a keyring, and a
143 + /// described screen borrowing all of that would say in its own type that it may
144 + /// start a network conversation. What it may actually do is these eight things.
145 + ///
146 + /// Every one of them is `&self` on the manager already, which is why this screen
147 + /// is describable at all — see [`sync`]'s header, where that is compared against
148 + /// goingson ruling its own sync section out.
149 + pub trait Sync {
150 + /// Where the flow has got to.
151 + fn status(&self) -> Status;
152 +
153 + /// Begin authentication, and answer where the user has to go.
154 + ///
155 + /// # Errors
156 + /// Whatever the manager said, as text.
157 + fn connect(&self) -> Result<String, String>;
158 +
159 + /// Give up waiting on the browser.
160 + fn cancel(&self);
161 +
162 + /// Set or supply the password that encrypts this vault.
163 + ///
164 + /// `is_new` is the difference between choosing a password and unlocking with
165 + /// one, which the manager needs and the screen already knows from
166 + /// [`State::NeedsEncryption`].
167 + fn set_password(&self, password: &str, is_new: bool);
168 +
169 + /// Sync now rather than on the schedule.
170 + fn sync_now(&self);
171 +
172 + /// Turn the schedule on or off.
173 + fn set_auto(&self, enabled: bool);
174 +
175 + /// How often the schedule runs, in minutes.
176 + fn set_interval(&self, minutes: u32);
177 +
178 + /// Clear whatever went wrong.
179 + fn clear_error(&self);
180 +
181 + /// Stop syncing this vault.
182 + fn disconnect(&self);
183 + }
184 +
185 + /// The app's sync manager, as the narrow thing a described screen borrows.
186 + pub struct FromSyncManager<'a>(pub &'a audiofiles_sync::SyncManager);
187 +
188 + impl Sync for FromSyncManager<'_> {
189 + fn status(&self) -> Status {
190 + let status = self.0.status();
191 + Status {
192 + state: match status.state {
193 + audiofiles_sync::SyncState::Disconnected => State::Disconnected,
194 + audiofiles_sync::SyncState::Authenticating => State::Authenticating,
195 + audiofiles_sync::SyncState::NeedsEncryption { has_server_key } => {
196 + State::NeedsEncryption { has_server_key }
197 + }
198 + audiofiles_sync::SyncState::Ready => State::Ready,
199 + audiofiles_sync::SyncState::Syncing => State::Syncing,
200 + },
201 + last_sync_at: status.last_sync_at,
202 + pending_changes: status.pending_changes,
203 + last_error: status.last_error,
204 + auto_sync_enabled: status.auto_sync_enabled,
205 + sync_interval_minutes: status.sync_interval_minutes,
206 + }
207 + }
208 +
209 + fn connect(&self) -> Result<String, String> {
210 + self.0.start_auth().map_err(|error| error.to_string())
211 + }
212 +
213 + fn cancel(&self) {
214 + self.0.cancel_auth();
215 + }
216 +
217 + fn set_password(&self, password: &str, is_new: bool) {
218 + self.0.setup_encryption(password.to_owned(), is_new);
219 + }
220 +
221 + fn sync_now(&self) {
222 + self.0.sync_now();
223 + }
224 +
225 + fn set_auto(&self, enabled: bool) {
226 + self.0.update_settings(Some(enabled), None);
227 + }
228 +
229 + fn set_interval(&self, minutes: u32) {
230 + self.0.update_settings(None, Some(minutes));
231 + }
232 +
233 + fn clear_error(&self) {
234 + self.0.clear_last_error();
235 + }
236 +
237 + fn disconnect(&self) {
238 + self.0.disconnect();
239 + }
240 + }
241 +
242 + /// Sync that is not configured on this machine.
243 + ///
244 + /// The app has a whole second window for this case
245 + /// (`ui::sync_panel::draw_sync_not_configured`), so the described side needs an
246 + /// answer too. It reports [`State::Disconnected`] and refuses to connect, which
247 + /// is the truth rather than a stub: there is nothing to connect *to*, and a
248 + /// silent no-op would look like a control that does nothing.
249 + pub struct Unconfigured;
250 +
251 + impl Sync for Unconfigured {
252 + fn status(&self) -> Status {
253 + Status {
254 + state: State::Disconnected,
255 + last_sync_at: None,
256 + pending_changes: 0,
257 + last_error: Some("Cloud sync is not configured for this build.".to_owned()),
258 + auto_sync_enabled: false,
259 + sync_interval_minutes: 0,
260 + }
261 + }
262 +
263 + fn connect(&self) -> Result<String, String> {
264 + Err("Cloud sync is not configured for this build.".to_owned())
265 + }
266 +
267 + fn cancel(&self) {}
268 + fn set_password(&self, _password: &str, _is_new: bool) {}
269 + fn sync_now(&self) {}
270 + fn set_auto(&self, _enabled: bool) {}
271 + fn set_interval(&self, _minutes: u32) {}
272 + fn clear_error(&self) {}
273 + fn disconnect(&self) {}
274 + }
275 +
93 276 /// A theme the host resolved, as the description needs to name it.
94 277 ///
95 278 /// Three strings rather than the app's own `ThemeMeta`, so the described screen
@@ -106,14 +289,17 @@
106 289 pub variant: String,
107 290 }
108 291
109 - /// What the described screens read and write.
292 + /// Everything the described screens read and write.
110 293 ///
111 - /// Borrowed rather than owned, which is what lets the app hand over the backend
112 - /// it already has without an `Arc` or a second handle. `Router<S>` puts no
113 - /// bounds on `S`, so a state that borrows is as valid as one that owns.
114 - pub struct Settings<'a> {
115 - /// The config store, and nothing else about the app.
294 + /// One state for every screen rather than one per screen, because a router is
295 + /// one table: `Router<S>` is generic over a single `S`, so the settings screen
296 + /// and the sync screen share it. Each borrows only the capability it uses, and
297 + /// the type says which.
298 + pub struct Panels<'a> {
299 + /// The config store, for the settings screen.
116 300 pub config: &'a dyn Config,
301 + /// Cloud sync, for the sync screen.
302 + pub sync: &'a dyn Sync,
117 303 /// The themes on offer, resolved by the host at startup.
118 304 pub themes: &'a [ThemeChoice],
119 305 }
@@ -123,8 +309,8 @@
123 309 /// Built per call rather than once: it is a `Vec` of function pointers, so the
124 310 /// cost is nothing, and building it fresh is what lets the state borrow.
125 311 #[must_use]
126 - pub fn router<'a>() -> Router<Settings<'a>> {
127 - settings::routes(Router::new())
312 + pub fn router<'a>() -> Router<Panels<'a>> {
313 + sync::routes(settings::routes(Router::new()))
128 314 }
129 315
130 316 #[cfg(test)]
@@ -1,18 +1,24 @@
1 - //! The described settings screen, in a window beside the shipped one.
1 + //! The described screens, in windows beside the shipped ones.
2 2 //!
3 3 //! Beside rather than instead of, which is the whole arrangement: with the
4 - //! feature on, opening Settings opens both windows, and the two can be compared
5 - //! by looking at them. A port that replaced the working panel on the way in
6 - //! would have to be right first time.
4 + //! feature on, opening Settings opens two windows and opening Cloud Sync opens
5 + //! two, so each port can be compared by looking at it. A port that replaced a
6 + //! working panel on the way in would have to be right first time.
7 + //!
8 + //! One described window per shipped window, each holding its own [`Runtime`],
9 + //! which mirrors the app rather than inventing navigation the shipped app does
10 + //! not have. A described screen *can* navigate — that is what [`Step::Call`] is
11 + //! — but a link from Settings to Sync would be a screen this port made up.
7 12 //!
8 13 //! # What this module is, and what it deliberately is not
9 14 //!
10 - //! It is the *host* half of the port, and it is small on purpose: fifty lines of
11 - //! plumbing against a described screen that knows nothing about egui. Everything
12 - //! it does is one of four things, and none of them is drawing:
15 + //! It is the *host* half, and it is small on purpose: plumbing against described
16 + //! screens that know nothing about egui. Everything it does is one of four
17 + //! things, and none of them is drawing:
13 18 //!
14 - //! 1. resolve the host facts the screen needs ([`ThemeChoice`], the palette),
15 - //! 2. hold the [`Runtime`] across frames, because a frame does not outlive itself,
19 + //! 1. resolve the host facts the screens need (themes, the palette) and adapt
20 + //! the app's own handles to the narrow traits the screens borrow,
21 + //! 2. hold each [`Runtime`] across frames, because a frame does not outlive itself,
16 22 //! 3. hand a [`Step`] to the router and the answer back to the runtime,
17 23 //! 4. put a route failure somewhere the user can see it.
18 24 //!
@@ -20,44 +26,78 @@
20 26 //! The moment this file starts deciding what a node looks like, the drawing has
21 27 //! left `quasi-immediate` and the port has become a second renderer.
22 28
29 + use audiofiles_sync::SyncManager;
23 30 use quasi_immediate::{Immediate, Runtime, Step};
24 - use quasi_router::Request;
31 + use quasi_router::{Request, Response, Screen};
25 32
26 - use super::{Settings, ThemeChoice};
33 + use super::{FromBackend, FromSyncManager, Panels, Sync, ThemeChoice, Unconfigured};
27 34 use crate::state::BrowserState;
28 35 use crate::ui::theme;
29 36
30 - /// The described screen's own state, held across frames.
37 + /// The described screens' own state, held across frames.
31 38 ///
32 - /// A `Runtime` and nothing else. What the user typed and ticked lives inside it,
33 - /// which is the half egui does not hold for a described screen: the fields are
34 - /// rebuilt from the description every frame, so their buffers have to outlive
35 - /// one.
39 + /// One runtime per window. What the user typed and ticked lives inside it, which
40 + /// is the half egui does not hold for a described screen: the fields are rebuilt
41 + /// from the description every frame, so their buffers have to outlive one.
36 42 #[derive(Debug, Default)]
37 43 pub struct Described {
38 - runtime: Option<Runtime>,
39 - }
40 -
41 - impl Described {
42 - /// Forget the screen, so the next open reads the store again.
43 - ///
44 - /// Called when the window closes rather than on every frame: the runtime is
45 - /// what holds unsent edits, and rebuilding it per frame would throw away
46 - /// what the user was typing.
47 - pub fn close(&mut self) {
48 - self.runtime = None;
49 - }
44 + settings: Option<Runtime>,
45 + sync: Option<Runtime>,
50 46 }
51 47
52 48 /// Draw the described settings window, and act on whatever was pressed.
53 - pub fn draw(ctx: &egui::Context, state: &mut BrowserState) {
54 - // Taken out of the state for the frame, because the router borrows the
55 - // backend off the same struct. Put back below whatever happens.
56 - let mut runtime = state.described.runtime.take();
57 - let themes = themes();
49 + pub fn draw_settings(ctx: &egui::Context, state: &mut BrowserState) {
50 + let mut runtime = state.described.settings.take();
51 + let closed = window(
52 + ctx,
53 + "Settings (described)",
54 + &mut runtime,
55 + state,
56 + None,
57 + "/settings",
58 + );
59 + state.described.settings = runtime;
60 + if closed {
61 + state.settings.show_manager = false;
62 + state.described.settings = None;
63 + }
64 + }
58 65
66 + /// Draw the described sync window, and act on whatever was pressed.
67 + ///
68 + /// `sync` is `None` when the app has no manager, which is the case the shipped
69 + /// panel answers with a whole second window. Here it is [`Unconfigured`], which
70 + /// reports disconnected and refuses to connect.
71 + pub fn draw_sync(ctx: &egui::Context, state: &mut BrowserState, sync: Option<&SyncManager>) {
72 + let mut runtime = state.described.sync.take();
73 + let closed = window(
74 + ctx,
75 + "Cloud Sync (described)",
76 + &mut runtime,
77 + state,
78 + sync,
79 + "/sync",
80 + );
81 + state.described.sync = runtime;
82 + if closed {
83 + state.sync.show_panel = false;
84 + state.described.sync = None;
85 + }
86 + }
87 +
88 + /// One described window: draw it, act on it, and say whether it was closed.
89 + fn window(
90 + ctx: &egui::Context,
91 + title: &str,
92 + runtime: &mut Option<Runtime>,
93 + state: &BrowserState,
94 + sync: Option<&SyncManager>,
95 + home: &str,
96 + ) -> bool {
97 + let themes = themes();
59 98 let mut open = true;
60 - egui::Window::new("Settings (described)")
99 +
100 + egui::Window::new(title)
61 101 .open(&mut open)
62 102 .default_width(420.0)
63 103 .show(ctx, |ui| {
@@ -65,9 +105,9 @@
65 105
66 106 // The first frame has no screen yet, so it asks for one. Everything
67 107 // after it is the loop below.
68 - let runtime = match &mut runtime {
108 + let runtime = match runtime {
69 109 Some(runtime) => runtime,
70 - none => match answer(state, &themes, Request::get("/settings")) {
110 + none => match answer(state, sync, &themes, Request::get(home)) {
71 111 Ok(screen) => none.insert(Runtime::new(screen)),
72 112 Err(message) => {
73 113 ui.label(message);
@@ -76,74 +116,107 @@
76 116 },
77 117 };
78 118
79 - match runtime.show(ui, &immediate) {
80 - Step::Idle => {}
81 - Step::Call(request) => match answer(state, &themes, request.clone()) {
82 - Ok(screen) => {
83 - runtime.apply(&request, quasi_router::Response::screen(screen));
84 - }
85 - Err(message) => runtime.say(message),
86 - },
87 - // A described control asked before acting. The shipped panel has
88 - // no equivalent, so this is drawn where it is asked rather than
89 - // in a second window.
90 - Step::Ask(question) => {
91 - ui.label(&question);
92 - ui.horizontal(|ui| {
93 - if ui.button("Yes").clicked() {
94 - let step = runtime.answer(true);
95 - if let Step::Call(request) = step {
96 - match answer(state, &themes, request.clone()) {
97 - Ok(screen) => {
98 - runtime.apply(
99 - &request,
100 - quasi_router::Response::screen(screen),
101 - );
102 - }
103 - Err(message) => runtime.say(message),
104 - }
105 - }
106 - }
107 - if ui.button("No").clicked() {
108 - runtime.answer(false);
109 - }
110 - });
111 - }
112 - // Nothing here addresses anything outside the app yet. Said
113 - // rather than ignored, so the first control that does is a
114 - // message and not a silence.
115 - Step::Open(address) => runtime.say(format!("Nothing here opens {address}.")),
116 - }
119 + let step = runtime.show(ui, &immediate);
120 + perform(runtime, ui, state, sync, &themes, step);
117 121 });
118 122
119 - state.described.runtime = runtime;
120 - if !open {
121 - state.settings.show_manager = false;
122 - state.described.close();
123 + !open
124 + }
125 +
126 + /// Do what the runtime asked for.
127 + fn perform(
128 + runtime: &mut Runtime,
129 + ui: &mut egui::Ui,
130 + state: &BrowserState,
131 + sync: Option<&SyncManager>,
132 + themes: &[ThemeChoice],
133 + step: Step,
134 + ) {
135 + match step {
136 + Step::Idle => {}
137 + Step::Call(request) => match answer(state, sync, themes, request.clone()) {
138 + Ok(screen) => {
139 + runtime.apply(&request, Response::screen(screen));
140 + }
141 + Err(message) => runtime.say(message),
142 + },
143 + // A described control asked before acting. Drawn where it is asked
144 + // rather than in a second window, since it is about the control.
145 + Step::Ask(question) => {
146 + ui.label(&question);
147 + ui.horizontal(|ui| {
148 + if ui.button("Yes").clicked() {
149 + let next = runtime.answer(true);
150 + perform(runtime, ui, state, sync, themes, next);
151 + }
152 + if ui.button("No").clicked() {
153 + runtime.answer(false);
154 + }
155 + });
156 + }
157 + // Somewhere outside the app, which is a one-way handoff.
158 + Step::Open(address) => open_externally(&address),
123 159 }
124 160 }
125 161
126 - /// Ask the router, and flatten a refusal into something a user can read.
162 + /// Hand an address to the desktop.
127 163 ///
128 - /// The one place the app's own error vocabulary and the router's meet. A
129 - /// `RouteError` carries a class and a message; a window has room for the
130 - /// message.
164 + /// The one piece of platform knowledge in the port, and it is the host's by
165 + /// definition. **This is what the description deletes from the shipped panel**:
166 + /// `ui::sync_panel::draw_disconnected` carries the same three branches inside a
167 + /// drawing function, chosen by `#[cfg(target_os)]`, because that is where the
168 + /// auth URL happened to be. Here the description says "external" and the host
169 + /// answers once, for every control that ever goes outside.
170 + fn open_externally(address: &str) {
171 + #[cfg(target_os = "macos")]
172 + let (program, leading) = ("open", Vec::<&str>::new());
173 + #[cfg(target_os = "linux")]
174 + let (program, leading) = ("xdg-open", Vec::<&str>::new());
175 + #[cfg(target_os = "windows")]
176 + let (program, leading) = ("cmd", vec!["/c", "start"]);
177 +
178 + let _ = std::process::Command::new(program)
179 + .args(leading)
180 + .arg(address)
181 + .spawn();
182 + }
183 +
184 + /// Ask the router, and flatten a refusal into something a user can read.
131 185 fn answer(
132 186 state: &BrowserState,
187 + sync: Option<&SyncManager>,
133 188 themes: &[ThemeChoice],
134 189 request: Request,
135 - ) -> Result<quasi_router::Screen, String> {
136 - let config = super::FromBackend(&*state.backend);
137 - let settings = Settings {
190 + ) -> Result<Screen, String> {
191 + let config = FromBackend(&*state.backend);
192 + let manager = sync.map(FromSyncManager);
193 + let unconfigured = Unconfigured;
194 + let sync: &dyn Sync = match &manager {
195 + Some(manager) => manager,
196 + None => &unconfigured,
197 + };
198 +
199 + let panels = Panels {
138 200 config: &config,
201 + sync,
139 202 themes,
140 203 };
141 204 let response = super::router()
142 - .handle(&settings, request)
205 + .handle(&panels, request)
143 206 .map_err(|error| error.message.clone())?;
144 207 match response.outcome {
145 208 quasi_router::Outcome::Screen(screen) => Ok(screen),
146 - other => Err(format!("the settings routes answered {other:?}")),
209 + // A described control that goes somewhere the host performs. Done here
210 + // rather than propagated, because the caller asked for a screen and this
211 + // one keeps the screen it had.
212 + quasi_router::Outcome::Goto(action) => {
213 + open_externally(action.destination.as_str());
214 + Err(format!(
215 + "Opened {} in your browser.",
216 + action.destination.as_str()
217 + ))
218 + }
219 + other => Err(format!("the routes answered {other:?}")),
147 220 }
148 221 }
149 222
@@ -61,7 +61,7 @@
61 61 Slot,
62 62 };
63 63
64 - use super::Settings;
64 + use super::Panels;
65 65
66 66 /// The region the whole screen answers into.
67 67 const BODY: &str = "settings-body";
@@ -84,7 +84,7 @@
84 84 ];
85 85
86 86 /// Register this screen's routes.
87 - pub fn routes(router: Router<Settings<'_>>) -> Router<Settings<'_>> {
87 + pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
88 88 router
89 89 .get("/settings", index)
90 90 .post("/settings/config/{key}", write)
@@ -92,7 +92,7 @@
92 92 }
93 93
94 94 /// `GET /settings`
95 - fn index(state: &Settings<'_>, _request: Request) -> Result<Response, RouteError> {
95 + fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
96 96 Ok(screen(state)?.into())
97 97 }
98 98
@@ -102,7 +102,7 @@
102 102 /// `NotFound` rather than an internal error: the address is reachable by
103 103 /// typing, and `ConfigKey::from_key` is the same refusal the rest of the app
104 104 /// makes.
105 - fn write(state: &Settings<'_>, request: Request) -> Result<Response, RouteError> {
105 + fn write(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
106 106 let name = request.captures.require("key")?;
107 107 let value = request.payload.get(name).unwrap_or_default();
108 108
@@ -117,7 +117,7 @@
117 117 }
118 118
119 119 /// `POST /settings/columns/reset`
120 - fn reset_columns(state: &Settings<'_>, _request: Request) -> Result<Response, RouteError> {
120 + fn reset_columns(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
121 121 set(state, ConfigKey::ColumnConfig, "")?;
122 122 Ok(Response::from(screen(state)?).toast(
123 123 quasi_router::layout::Tone::Success,
@@ -126,7 +126,7 @@
126 126 }
127 127
128 128 /// The whole screen.
129 - fn screen(state: &Settings<'_>) -> Result<Screen, RouteError> {
129 + fn screen(state: &Panels<'_>) -> Result<Screen, RouteError> {
130 130 let mut body = Slot::new(BODY, RegionKind::Pane)
131 131 .with(Node::page("Settings"))
132 132 .with(Node::section("Appearance"))
@@ -182,7 +182,7 @@
182 182 /// by variant and sorts by contrast tier within each group, and `Choice` carries
183 183 /// a value and a label. The variant goes in the label so the fact survives; the
184 184 /// structure does not.
185 - fn appearance(state: &Settings<'_>) -> Node {
185 + fn appearance(state: &Panels<'_>) -> Node {
186 186 let chosen = state.config.get(ConfigKey::Theme).ok().flatten();
187 187 let options = state
188 188 .themes
@@ -206,7 +206,7 @@
206 206 /// should, which is the shape `14612ed8` was recounted for. Thirteen of
207 207 /// goingson's nineteen change-sites were standalone controls, and every control
208 208 /// here is one.
209 - fn toggle(state: &Settings<'_>, key: ConfigKey, label: &str) -> Result<Node, RouteError> {
209 + fn toggle(state: &Panels<'_>, key: ConfigKey, label: &str) -> Result<Node, RouteError> {
210 210 let on = get(state, key)?.is_some_and(|value| value == "1" || value == "true");
211 211 Ok(Node::Field(Box::new(
212 212 Field::new(FieldKind::Checkbox, key.as_str(), label)
@@ -221,7 +221,7 @@
221 221 /// `Field::min` and `max` are the bounds the shipped slider draws as a track,
222 222 /// and a renderer with no slider draws a number that still cannot go out of
223 223 /// range. Naming the widget would have been the description choosing a control.
224 - fn row_height(state: &Settings<'_>) -> Result<Field, RouteError> {
224 + fn row_height(state: &Panels<'_>) -> Result<Field, RouteError> {
225 225 let current = get(state, ConfigKey::RowHeight)?.unwrap_or_else(|| "24".to_owned());
226 226 Ok(Field::new(
227 227 FieldKind::Number,
@@ -258,7 +258,7 @@
258 258 ///
259 259 /// `None` when the name is not a column, which is what sends the caller down the
260 260 /// ordinary `ConfigKey` path.
261 - fn column_write(state: &Settings<'_>, name: &str, value: &str) -> Option<(ConfigKey, String)> {
261 + fn column_write(state: &Panels<'_>, name: &str, value: &str) -> Option<(ConfigKey, String)> {
262 262 let flag = name.strip_prefix("column.")?;
263 263 let stored = state
264 264 .config
@@ -293,11 +293,11 @@
293 293 }
294 294
295 295 /// Read a key, reporting a store failure as this app's own.
296 - fn get(state: &Settings<'_>, key: ConfigKey) -> Result<Option<String>, RouteError> {
296 + fn get(state: &Panels<'_>, key: ConfigKey) -> Result<Option<String>, RouteError> {
297 297 state.config.get(key).map_err(RouteError::internal)
298 298 }
299 299
300 300 /// Write a key.
301 - fn set(state: &Settings<'_>, key: ConfigKey, value: &str) -> Result<(), RouteError> {
301 + fn set(state: &Panels<'_>, key: ConfigKey, value: &str) -> Result<(), RouteError> {
302 302 state.config.set(key, value).map_err(RouteError::internal)
303 303 }
@@ -11,7 +11,7 @@
11 11 use audiofiles_core::config_key::ConfigKey;
12 12 use quasi_router::{Method, Node, Outcome, Params, Request, Response, Screen};
13 13
14 - use super::{Config, Settings, ThemeChoice, router};
14 + use super::{Config, Panels, State, Status, Sync, ThemeChoice, router};
15 15
16 16 /// A config store in memory.
17 17 ///
@@ -53,6 +53,39 @@
53 53 }
54 54 }
55 55
56 + /// Sync that reports nothing and does nothing.
57 + ///
58 + /// The settings tests do not touch it, and it is here because `Panels` is one
59 + /// state for every screen: a router is one table, so a settings test still has
60 + /// to name a sync. That is the cost of sharing, and it is a fixture rather than
61 + /// a design problem.
62 + struct Offline;
63 +
64 + impl Sync for Offline {
65 + fn status(&self) -> Status {
66 + Status {
67 + state: State::Disconnected,
68 + last_sync_at: None,
69 + pending_changes: 0,
70 + last_error: None,
71 + auto_sync_enabled: false,
72 + sync_interval_minutes: 15,
73 + }
74 + }
75 +
76 + fn connect(&self) -> Result<String, String> {
77 + Err("offline".to_owned())
78 + }
79 +
80 + fn cancel(&self) {}
81 + fn set_password(&self, _password: &str, _is_new: bool) {}
82 + fn sync_now(&self) {}
83 + fn set_auto(&self, _enabled: bool) {}
84 + fn set_interval(&self, _minutes: u32) {}
85 + fn clear_error(&self) {}
86 + fn disconnect(&self) {}
87 + }
88 +
56 89 fn themes() -> Vec<ThemeChoice> {
57 90 vec![
58 91 ThemeChoice {
@@ -93,8 +126,10 @@
93 126 fn the_screen_answers_with_every_control_it_describes() {
94 127 let store = Store::default();
95 128 let themes = themes();
96 - let state = Settings {
129 + let sync = Offline;
130 + let state = Panels {
97 131 config: &store,
132 + sync: &sync,
98 133 themes: &themes,
99 134 };
100 135 let response = router()
@@ -120,15 +155,23 @@
120 155 let table: Vec<(Method, String)> = router().routes().map(|(m, p)| (m, p.to_owned())).collect();
121 156 assert!(table.contains(&(Method::Get, "/settings".to_owned())));
122 157 assert!(table.contains(&(Method::Post, "/settings/config/{key}".to_owned())));
123 - assert_eq!(table.len(), 3, "{table:?}");
158 + // Counted per screen rather than in total, so a second screen landing in the
159 + // same table does not read as this one growing routes.
160 + let settings = table
161 + .iter()
162 + .filter(|(_, path)| path.starts_with("/settings"))
163 + .count();
164 + assert_eq!(settings, 3, "{table:?}");
124 165 }
125 166
126 167 #[test]
127 168 fn a_toggle_reads_what_is_stored_and_writes_what_was_sent() {
128 169 let store = Store::with(&[(ConfigKey::PreviewLoop, "1")]);
129 170 let themes = themes();
130 - let state = Settings {
171 + let sync = Offline;
172 + let state = Panels {
131 173 config: &store,
174 + sync: &sync,
132 175 themes: &themes,
133 176 };
134 177
@@ -161,8 +204,10 @@
161 204 // the address is reachable by typing, so it is a 404 rather than a 500.
162 205 let store = Store::default();
163 206 let themes = themes();
164 - let state = Settings {
207 + let sync = Offline;
208 + let state = Panels {
165 209 config: &store,
210 + sync: &sync,
166 211 themes: &themes,
167 212 };
168 213 let refused = router().handle(
@@ -181,8 +226,10 @@
181 226 // description that named the blob would be describing a storage format.
182 227 let store = Store::default();
183 228 let themes = themes();
184 - let state = Settings {
229 + let sync = Offline;
230 + let state = Panels {
185 231 config: &store,
232 + sync: &sync,
186 233 themes: &themes,
187 234 };
188 235
@@ -224,8 +271,10 @@
224 271 // readable at startup goes in `S` rather than through a capability surface.
225 272 let store = Store::with(&[(ConfigKey::Theme, "nord")]);
226 273 let themes = themes();
227 - let state = Settings {
274 + let sync = Offline;
275 + let state = Panels {
228 276 config: &store,
277 + sync: &sync,
229 278 themes: &themes,
230 279 };
231 280 let response = router()
@@ -263,8 +312,10 @@
263 312 fn resetting_the_columns_says_it_did() {
264 313 let store = Store::with(&[(ConfigKey::ColumnConfig, "{\"show_bpm\":false}")]);
265 314 let themes = themes();
266 - let state = Settings {
315 + let sync = Offline;
316 + let state = Panels {
267 317 config: &store,
318 + sync: &sync,
268 319 themes: &themes,
269 320 };
270 321 let response = router()
@@ -276,3 +327,269 @@
276 327 "a destructive-looking control that says nothing is one the user cannot tell worked"
277 328 );
278 329 }
330 +
331 + /// Sync in whatever state a test wants, recording what was asked of it.
332 + struct FakeSync {
333 + status: Status,
334 + calls: RefCell<Vec<String>>,
335 + }
336 +
337 + impl FakeSync {
338 + fn in_state(state: State) -> Self {
339 + Self {
340 + status: Status {
341 + state,
342 + last_sync_at: None,
343 + pending_changes: 0,
344 + last_error: None,
345 + auto_sync_enabled: false,
346 + sync_interval_minutes: 15,
347 + },
348 + calls: RefCell::new(Vec::new()),
349 + }
350 + }
351 +
352 + fn called(&self) -> Vec<String> {
353 + self.calls.borrow().clone()
354 + }
355 +
356 + fn note(&self, what: &str) {
357 + self.calls.borrow_mut().push(what.to_owned());
358 + }
359 + }
360 +
361 + impl Sync for FakeSync {
362 + fn status(&self) -> Status {
363 + self.status.clone()
364 + }
365 +
366 + fn connect(&self) -> Result<String, String> {
367 + self.note("connect");
368 + Ok("https://makenot.work/auth?code=abc".to_owned())
369 + }
370 +
371 + fn cancel(&self) {
372 + self.note("cancel");
373 + }
374 +
375 + fn set_password(&self, _password: &str, is_new: bool) {
376 + self.note(if is_new {
377 + "set_password:new"
378 + } else {
379 + "set_password:unlock"
380 + });
381 + }
382 +
383 + fn sync_now(&self) {
384 + self.note("sync_now");
385 + }
386 +
387 + fn set_auto(&self, enabled: bool) {
388 + self.note(if enabled { "auto:on" } else { "auto:off" });
389 + }
390 +
391 + fn set_interval(&self, minutes: u32) {
392 + self.note(&format!("interval:{minutes}"));
393 + }
394 +
395 + fn clear_error(&self) {
396 + self.note("clear_error");
397 + }
398 +
399 + fn disconnect(&self) {
400 + self.note("disconnect");
401 + }
402 + }
403 +
404 + /// A router call against a sync in this state.
405 + fn syncing(sync: &FakeSync, request: Request) -> Result<Response, quasi_router::RouteError> {
406 + let store = Store::default();
407 + let themes = themes();
408 + let state = Panels {
409 + config: &store,
410 + sync,
411 + themes: &themes,
412 + };
413 + router().handle(&state, request)
414 + }
415 +
416 + /// Every act on a screen, by label.
417 + fn acts(screen: &Screen) -> Vec<String> {
418 + screen
419 + .slots
420 + .iter()
421 + .flat_map(|slot| &slot.body)
422 + .filter_map(|node| match node {
423 + Node::Act(act) => Some(act.label.clone()),
424 + _ => None,
425 + })
426 + .collect()
427 + }
428 +
429 + #[test]
430 + fn one_route_answers_four_shapes_because_a_state_is_not_an_address() {
431 + // A user cannot navigate to Authenticating; they arrive there because
432 + // something happened. Four addresses would be four places you could bookmark
433 + // into a lie.
434 + for (state, expected) in [
435 + (State::Disconnected, "Connect"),
436 + (State::Authenticating, "Cancel"),
437 + (State::Ready, "Sync now"),
438 + ] {
439 + let sync = FakeSync::in_state(state);
440 + let response = syncing(&sync, Request::get("/sync")).expect("answered");
441 + let labels = acts(screen_of(&response));
442 + assert!(
443 + labels.iter().any(|label| label == expected),
444 + "{state:?} offered {labels:?}, wanted {expected}"
445 + );
446 + }
447 + }
448 +
449 + #[test]
450 + fn connecting_answers_with_somewhere_to_go_rather_than_a_screen() {
451 + // The case `Destination::External` exists for, and what replaces the three
452 + // `#[cfg(target_os)]` branches the shipped panel keeps inside a drawing
453 + // function.
454 + let sync = FakeSync::in_state(State::Disconnected);
455 + let response = syncing(&sync, Request::post("/sync/connect")).expect("answered");
456 + match &response.outcome {
457 + Outcome::Goto(action) => {
458 + assert!(action.destination.is_external(), "{action:?}");
459 + assert!(action.destination.as_str().starts_with("https://"));
460 + }
461 + other => panic!("expected somewhere to go, got {other:?}"),
462 + }
463 + assert_eq!(sync.called(), ["connect"]);
464 + }
465 +
466 + #[test]
467 + fn the_password_screen_knows_whether_it_is_setting_or_unlocking() {
468 + // `has_server_key` changes what is said and not what shape it is, and the
469 + // manager needs it: choosing a password is not the same call as supplying
470 + // one.
471 + for (has_server_key, expected) in [(false, "set_password:new"), (true, "set_password:unlock")] {
472 + let sync = FakeSync::in_state(State::NeedsEncryption { has_server_key });
473 + syncing(
474 + &sync,
475 + Request::post("/sync/encryption")
476 + .sending(Params::new().with("password".to_owned(), "hunter2".to_owned())),
477 + )
478 + .expect("answered");
479 + assert_eq!(sync.called(), [expected]);
480 + }
481 + }
482 +
483 + #[test]
484 + fn an_empty_password_is_refused_without_reaching_the_manager() {
485 + let sync = FakeSync::in_state(State::NeedsEncryption {
486 + has_server_key: false,
487 + });
488 + let response = syncing(
489 + &sync,
490 + Request::post("/sync/encryption")
491 + .sending(Params::new().with("password".to_owned(), String::new())),
492 + )
493 + .expect("answered");
494 + assert!(response.notice.is_some(), "the refusal said nothing");
495 + assert!(
496 + sync.called().is_empty(),
497 + "an empty password reached the manager"
498 + );
499 + }
500 +
501 + #[test]
502 + fn the_password_is_never_carried_back_into_the_description() {
503 + // `39057019`: a Secret field refuses to hold a value, so the runtime's
504 + // buffer is the only place the typed password has ever lived.
505 + let sync = FakeSync::in_state(State::NeedsEncryption {
506 + has_server_key: false,
507 + });
508 + let response = syncing(&sync, Request::get("/sync")).expect("answered");
509 + let carried: Vec<_> = screen_of(&response)
510 + .slots
511 + .iter()
512 + .flat_map(|slot| &slot.body)
513 + .filter_map(|node| match node {
514 + Node::Form { fields, .. } => Some(fields.clone()),
515 + _ => None,
516 + })
517 + .flatten()
518 + .collect();
519 + let password = carried
520 + .iter()
521 + .find(|field| field.name == "password")
522 + .expect("the screen asks for a password");
523 + assert_eq!(password.kind, quasi_router::layout::FieldKind::Secret);
524 + assert_eq!(password.value, None, "the description carried a secret");
525 + }
526 +
527 + #[test]
528 + fn a_running_sync_is_drawn_and_does_not_answer() {
529 + // Present, visible and not answering, which is what a control that is
530 + // already running should be.
531 + let sync = FakeSync::in_state(State::Syncing);
532 + let response = syncing(&sync, Request::get("/sync")).expect("answered");
533 + let now = screen_of(&response)
534 + .slots
535 + .iter()
536 + .flat_map(|slot| &slot.body)
537 + .find_map(|node| match node {
538 + Node::Act(act) if act.label == "Sync now" => Some(act),
539 + _ => None,
540 + })
541 + .expect("the screen offers Sync now");
542 + // Read off the member rather than through a predicate: `Act::disabled` is
543 + // the builder on this type, where `makeover_layout::Act::disabled` is the
544 + // question. One name, two crates, opposite parts of speech.
545 + assert_eq!(
546 + now.state,
547 + Some(quasi_router::layout::State::Disabled),
548 + "a sync already running still answered"
549 + );
550 + }
551 +
552 + #[test]
553 + fn an_interval_the_panel_does_not_offer_is_refused() {
554 + let sync = FakeSync::in_state(State::Ready);
555 + let refused = syncing(
556 + &sync,
557 + Request::post("/sync/interval")
558 + .sending(Params::new().with(Node::SELECTED.to_owned(), "7".to_owned())),
559 + );
560 + assert!(refused.is_err(), "an unoffered cadence was accepted");
561 + assert!(sync.called().is_empty());
562 +
563 + let accepted = syncing(
564 + &sync,
565 + Request::post("/sync/interval")
566 + .sending(Params::new().with(Node::SELECTED.to_owned(), "30".to_owned())),
567 + );
568 + assert!(accepted.is_ok());
569 + assert_eq!(sync.called(), ["interval:30"]);
570 + }
571 +
572 + #[test]
573 + fn a_failure_is_reported_in_every_state_and_retry_only_where_it_means_something() {
574 + for (state, retryable) in [(State::Disconnected, false), (State::Ready, true)] {
575 + let mut sync = FakeSync::in_state(state);
576 + sync.status.last_error = Some("the server said no".to_owned());
577 + let response = syncing(&sync, Request::get("/sync")).expect("answered");
578 + let screen = screen_of(&response);
579 +
580 + let said = screen
581 + .slots
582 + .iter()
583 + .flat_map(|slot| &slot.body)
584 + .any(|node| matches!(node, Node::Notice { text, .. } if text.contains("said no")));
585 + assert!(said, "{state:?} swallowed the error");
586 +
587 + let labels = acts(screen);
588 + assert!(labels.iter().any(|label| label == "Dismiss"));
589 + assert_eq!(
590 + labels.iter().any(|label| label == "Retry"),
591 + retryable,
592 + "{state:?} offered the wrong escape: {labels:?}"
593 + );
594 + }
595 + }
@@ -1,0 +1,329 @@
1 + //! The cloud sync panel, described rather than built.
2 + //!
3 + //! The second audiofiles screen through `quasi`, and the one that answers a
4 + //! question the settings port could not: **what does a description do with a
5 + //! screen that is a state machine?** Nothing new. Four states, four screens, one
6 + //! route, which is routing doing its ordinary job.
7 + //!
8 + //! # It is describable here and was not in goingson, for a reason worth keeping
9 + //!
10 + //! goingson's settings port ruled Sync and Sharing out on 2026-08-09: they
11 + //! "reach a network client through commands taking an `AppHandle`", and a
12 + //! handler is `fn(&S, Request)` with no handle and no runtime.
13 + //!
14 + //! audiofiles' sync is the same *feature* and comes through cleanly, because
15 + //! every method on `SyncManager` this screen needs already takes `&self`:
16 + //! `start_auth`, `cancel_auth`, `setup_encryption`, `sync_now`,
17 + //! `update_settings`, `disconnect`, `clear_last_error`. The async lives inside
18 + //! the manager, behind a scheduler it owns, rather than in a command wrapper the
19 + //! UI has to call through.
20 + //!
21 + //! So the boundary is not "network is undescribable". It is the one the settings
22 + //! port already found and this confirms from the other side: **what a described
23 + //! screen needs is a synchronous handle to the app's own capability**, and
24 + //! whether it has one is a property of how the app is built rather than of what
25 + //! the capability does.
26 + //!
27 + //! # What the description deletes
28 + //!
29 + //! Three platform branches. `draw_disconnected` opens the auth URL with
30 + //! `open` / `xdg-open` / `cmd /c start`, chosen by `#[cfg(target_os)]`, inside a
31 + //! drawing function. Here `POST /sync/connect` answers
32 + //! [`Outcome::Goto`](quasi_router::Outcome::Goto) with an external destination,
33 + //! which is a one-way handoff the *host* performs — `Step::Open` in
34 + //! `quasi-immediate`, an anchor with `target="_blank"` in a webview. The
35 + //! description says "somewhere outside the app" and each host already knows what
36 + //! that means for it.
37 + //!
38 + //! # THE FINDING: a description is built once, and this screen is alive
39 + //!
40 + //! The panel reads `sync.status()` **every frame** and redraws from it: the
41 + //! spinner while syncing, the pending-changes count as it falls, the state
42 + //! changing under the user when the OAuth callback lands in another process.
43 + //! None of that is a user acting.
44 + //!
45 + //! A description is built once per answer, so a described sync screen is a
46 + //! photograph. Nothing in the vocabulary says "this is live". The workaround
47 + //! here is that the host re-asks on a timer, which works and is invisible in the
48 + //! description — meaning two hosts will each invent their own cadence, which is
49 + //! the divergence the layer exists to end.
50 + //!
51 + //! Note what this is *not*: it is not asking for a poll interval in the
52 + //! description. `Message::undo` settled that timing is renderer policy ("No
53 + //! timeout here. How long an undo stays offered is renderer policy"). The
54 + //! missing word is nearer "this region reports something that changes without
55 + //! the user" — a fact about the content, which the renderer then answers with a
56 + //! cadence of its own choosing. Filed rather than invented here.
57 + //!
58 + //! # Out of scope, deliberately
59 + //!
60 + //! The subscription and storage-cap section (`draw_subscription_section`,
61 + //! `draw_cap_slider`, `draw_cap_picker`, ~250 lines). It is a purchase flow with
62 + //! money in it, an external checkout and a pricing fetch, and it deserves its own
63 + //! pass rather than riding along with the state machine. Nothing about it looks
64 + //! undescribable; it is simply a second screen wearing this one's window.
65 +
66 + use quasi_router::layout::{FieldKind, Selector, Tone};
67 + use quasi_router::{
68 + Act, Action, Choice, Field, Node, RegionKind, Request, Response, RouteError, Router, Screen,
69 + Slot,
70 + };
71 +
72 + use super::{State, Status, Sync};
73 +
74 + /// The region the whole screen answers into.
75 + const BODY: &str = "sync-body";
76 +
77 + /// The cadences the panel offers, in minutes.
78 + ///
79 + /// The same four the shipped panel has. A [`Selector::Segmented`] rather than a
80 + /// number, because four named choices is a strip and not a range: the shipped
81 + /// panel draws pills, and a renderer with no pills draws a small select.
82 + const INTERVALS: &[u32] = &[5, 15, 30, 60];
83 +
84 + /// Register this screen's routes.
85 + pub fn routes(router: Router<super::Panels<'_>>) -> Router<super::Panels<'_>> {
86 + router
87 + .get("/sync", index)
88 + .post("/sync/connect", connect)
89 + .post("/sync/cancel", cancel)
90 + .post("/sync/encryption", encryption)
91 + .post("/sync/now", sync_now)
92 + .post("/sync/auto", auto)
93 + .post("/sync/interval", interval)
94 + .post("/sync/error/clear", clear_error)
95 + .post("/sync/disconnect", disconnect)
96 + }
97 +
98 + /// `GET /sync`
99 + fn index(state: &super::Panels<'_>, _request: Request) -> Result<Response, RouteError> {
100 + Ok(screen(state.sync).into())
101 + }
102 +
103 + /// `POST /sync/connect`
104 + ///
105 + /// Answers with somewhere to go rather than with a screen. Starting auth returns
106 + /// the address the user has to visit, and visiting it is the host's job: this is
107 + /// the case [`Destination::External`](quasi_router::Destination::External) was
108 + /// added for, and it is what replaces the three `#[cfg(target_os)]` branches the
109 + /// shipped panel carries.
110 + fn connect(state: &super::Panels<'_>, _request: Request) -> Result<Response, RouteError> {
111 + let address = state
112 + .sync
113 + .connect()
114 + .map_err(|error| RouteError::internal(format!("Sync connect failed: {error}")))?;
115 + Ok(Response::goto(Action::external(address)))
116 + }
117 +
118 + /// `POST /sync/cancel`
119 + fn cancel(state: &super::Panels<'_>, _request: Request) -> Result<Response, RouteError> {
120 + state.sync.cancel();
121 + Ok(screen(state.sync).into())
122 + }
123 +
124 + /// `POST /sync/encryption`
125 + ///
126 + /// The password arrives in the payload and is never carried back: the field is a
127 + /// [`FieldKind::Secret`], which the description refuses to hold a value for, so
128 + /// the answer re-describes an empty box rather than the one that was typed in.
129 + fn encryption(state: &super::Panels<'_>, request: Request) -> Result<Response, RouteError> {
130 + let password = request.payload.get("password").unwrap_or_default();
131 + if password.is_empty() {
132 + return Ok(Response::from(screen(state.sync))
133 + .toast(Tone::Danger, "A password is needed to encrypt this vault."));
134 + }
135 + let is_new = matches!(
136 + state.sync.status().state,
137 + State::NeedsEncryption {
138 + has_server_key: false
139 + }
140 + );
141 + state.sync.set_password(password, is_new);
142 + Ok(screen(state.sync).into())
143 + }
144 +
145 + /// `POST /sync/now`
146 + fn sync_now(state: &super::Panels<'_>, _request: Request) -> Result<Response, RouteError> {
147 + state.sync.sync_now();
148 + Ok(screen(state.sync).into())
149 + }
150 +
151 + /// `POST /sync/auto`
152 + fn auto(state: &super::Panels<'_>, request: Request) -> Result<Response, RouteError> {
153 + let on = !request.payload.get("auto").unwrap_or_default().is_empty();
154 + state.sync.set_auto(on);
155 + Ok(screen(state.sync).into())
156 + }
157 +
158 + /// `POST /sync/interval`
159 + fn interval(state: &super::Panels<'_>, request: Request) -> Result<Response, RouteError> {
160 + let minutes: u32 = request
161 + .payload
162 + .get(Node::SELECTED)
163 + .and_then(|value| value.parse().ok())
164 + .ok_or_else(|| RouteError::not_found("no such interval"))?;
165 + if !INTERVALS.contains(&minutes) {
166 + return Err(RouteError::not_found("no such interval"));
167 + }
168 + state.sync.set_interval(minutes);
169 + Ok(screen(state.sync).into())
170 + }
171 +
172 + /// `POST /sync/error/clear`
173 + fn clear_error(state: &super::Panels<'_>, _request: Request) -> Result<Response, RouteError> {
174 + state.sync.clear_error();
175 + Ok(screen(state.sync).into())
176 + }
177 +
178 + /// `POST /sync/disconnect`
179 + fn disconnect(state: &super::Panels<'_>, _request: Request) -> Result<Response, RouteError> {
180 + state.sync.disconnect();
181 + Ok(screen(state.sync).into())
182 + }
183 +
184 + /// The screen, which is a different screen per state.
185 + ///
186 + /// One route answering four shapes rather than four routes: the state is not an
187 + /// address, and a user cannot navigate to `Authenticating` — they arrive there
188 + /// because something happened. Four addresses would be four places you could
189 + /// bookmark into a lie.
190 + fn screen(sync: &dyn Sync) -> Screen {
191 + let status = sync.status();
192 + let mut body = Slot::new(BODY, RegionKind::Pane).with(Node::page("Cloud Sync"));
193 +
194 + body = match status.state {
195 + State::Disconnected => disconnected(body),
196 + State::Authenticating => authenticating(body),
197 + State::NeedsEncryption { has_server_key } => needs_encryption(body, has_server_key),
198 + State::Ready | State::Syncing => ready(body, &status),
199 + };
200 +
201 + // The error banner, on every state, because a failure can arrive in any of
202 + // them. `Node::Notice` carries the tone and the text; what to do about it is
203 + // two controls, and Retry is only offered where retrying means anything.
204 + if let Some(error) = &status.last_error {
205 + body = body.with(Node::Notice {
206 + kind: quasi_router::layout::Notice::Banner,
207 + tone: Tone::Danger,
208 + text: error.clone(),
209 + });
210 + if matches!(status.state, State::Ready | State::Syncing) {
211 + body = body.with(Node::Act(Act::new("Retry", Action::post("/sync/now"))));
212 + }
213 + body = body.with(Node::Act(Act::new(
214 + "Dismiss",
215 + Action::post("/sync/error/clear"),
216 + )));
217 + }
218 +
219 + Screen::sidebar_content("Cloud Sync").with(body)
220 + }
221 +
222 + fn disconnected(body: Slot) -> Slot {
223 + body.with(Node::text(
224 + "Connect your audiofiles vault to Makenot.work for cross-device sync.",
225 + ))
226 + .with(Node::text(
227 + "Metadata (tags, vault structure, analysis) syncs automatically. Audio file sync is per-vault opt-in.",
228 + ))
229 + .with(Node::Act(Act::new("Connect", Action::post("/sync/connect"))))
230 + }
231 +
232 + /// Waiting on a browser, with a way out.
233 + ///
234 + /// The spinner needs no vocabulary: [`Readiness::Pending`] is what a region
235 + /// waiting on something says, and every renderer already answers it. That is one
236 + /// place this screen expected a finding and did not get one.
237 + fn authenticating(body: Slot) -> Slot {
238 + body.with(Node::text("Waiting for authentication in your browser..."))
239 + .with(Node::text(
240 + "The app will update automatically once you sign in.",
241 + ))
242 + .with(Node::Act(Act::new("Cancel", Action::post("/sync/cancel"))))
243 + }
244 +
245 + /// The password that encrypts this vault.
246 + ///
247 + /// [`FieldKind::Secret`] and nothing else: the description will not carry the
248 + /// typed value, which is `39057019`, so the runtime's buffer is the only place
249 + /// it has ever lived. Whether this is a new password or an existing one changes
250 + /// only what is said, which is why `has_server_key` reaches the prose and not
251 + /// the shape.
252 + fn needs_encryption(body: Slot, has_server_key: bool) -> Slot {
253 + let says = if has_server_key {
254 + "This vault is already encrypted. Enter its password to unlock it here."
255 + } else {
256 + "Choose a password. It encrypts everything before it leaves this machine, and it cannot be recovered."
257 + };
258 + body.with(Node::text(says)).with(Node::Form {
259 + fields: vec![
260 + Field::new(FieldKind::Secret, "password", "Password")
261 + .required()
262 + .hint(if has_server_key {
263 + "The password this vault was encrypted with."
264 + } else {
265 + "Nobody can reset this for you."
266 + }),
267 + ],
268 + submit: if has_server_key {
269 + "Unlock"
270 + } else {
271 + "Set password"
272 + }
273 + .to_owned(),
274 + action: Action::post("/sync/encryption"),
275 + })
276 + }
277 +
278 + /// Connected, and what it is doing.
279 + fn ready(body: Slot, status: &Status) -> Slot {
280 + let syncing = matches!(status.state, State::Syncing);
281 + let mut body = body.with(Node::text(if syncing { "Syncing..." } else { "Connected" }));
282 +
283 + if let Some(last) = &status.last_sync_at {
284 + body = body.with(Node::text(format!("Last sync: {last}")));
285 + }
286 + if status.pending_changes > 0 {
287 + // A count, not a proportion: nothing knows the total, and a `Meter`
288 + // handed a made-up denominator would draw a bar that means nothing.
289 + body = body.with(Node::Figure(quasi_router::Figure::new(
290 + status.pending_changes.to_string(),
291 + "pending changes",
292 + )));
293 + }
294 +
295 + let mut now = Act::new("Sync now", Action::post("/sync/now"));
296 + if syncing {
297 + // Present, visible and not answering, which is what a control that is
298 + // already running should be. The shipped panel says the same thing with
299 + // `add_enabled(!syncing, ...)`.
300 + now = now.disabled();
301 + }
302 +
303 + body.with(Node::Act(now))
304 + .with(Node::section("Auto-sync"))
305 + .with(Node::Field(Box::new(
306 + Field::new(FieldKind::Checkbox, "auto", "Sync on a schedule")
307 + .value(if status.auto_sync_enabled { "on" } else { "" })
308 + .changes(Action::post("/sync/auto")),
309 + )))
310 + .with(Node::Select {
311 + kind: Selector::Segmented,
312 + options: INTERVALS
313 + .iter()
314 + .map(|minutes| {
315 + (
316 + Choice::new(minutes.to_string(), format!("{minutes} min")),
317 + None,
318 + )
319 + })
320 + .collect(),
321 + chosen: Some(status.sync_interval_minutes.to_string()),
322 + action: Some(Action::post("/sync/interval")),
323 + })
324 + .with(Node::Act(
325 + Act::new("Disconnect", Action::post("/sync/disconnect"))
326 + .tone(Tone::Danger)
327 + .confirm("Disconnect this vault from cloud sync?"),
328 + ))
329 + }