max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+1039 insertions,
-130 deletions
| @@ -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 | + | } |