Skip to main content

max / audiofiles

33.0 KB · 818 lines History Blame Raw
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, closed: 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.
57 //!
58 //! That is what [`Slot::live`](quasi_router::Slot::live) is, and this screen is
59 //! its acceptance case. `screen` sets it on the body; each renderer holds one
60 //! `CADENCE` for every live region it draws, so the two hosts no longer invent
61 //! their own. Nothing here re-reads per frame any more.
62 //!
63 //! # The subscription section, and the two findings it produced
64 //!
65 //! Added in a second pass. It is a purchase flow: a cap to choose, a price that
66 //! depends on it, two cadences, and a Stripe checkout in a browser. Most of it
67 //! describes cleanly and two things do not.
68 //!
69 //! **A price cannot follow a slider.** The shipped picker is a logarithmic
70 //! slider with a live quote beside it, recomputed per drag frame from
71 //! `AppPricing::quote_cents` — a *server* pricing model the app happens to hold
72 //! a copy of. A described field carries a value and `Field::changes` fires a
73 //! route when it changes, so following the drag means a request per step. The
74 //! port therefore quotes what is **committed**, not what is under the thumb, and
75 //! the finding is the general one: **nothing describes a display derived from a
76 //! control's own uncommitted value.** Filed rather than papered over.
77 //!
78 //! Note what is *not* the answer: shipping the pricing model in the description
79 //! so the renderer can compute. That is a formula travelling as data, and the
80 //! next change to it silently prices three renderers differently.
81 //!
82 //! **A form has one submit, and this offers two priced choices over one value.**
83 //! Annual and monthly are two actions over the same cap.
84 //! [`Node::Form`](quasi_router::Node::Form) carries one `action` and one
85 //! `submit`, so this is unsayable as drawn. The port makes the cadence a
86 //! described [`Choice`] inside the form and submits once, which is arguably the
87 //! better screen — the two buttons *are* a radio wearing button clothes — but it
88 //! is a redesign forced by the vocabulary rather than chosen, and that is worth
89 //! recording as such.
90 //!
91 //! **The checkout URL is not `Outcome::Goto`, and the auth URL is.** Same
92 //! affordance, two shapes, and the difference is not aesthetic:
93 //! `SyncManager::start_auth` answers a URL synchronously, so connecting is a
94 //! described `Destination::External`. `subscribe` fetches the checkout URL
95 //! asynchronously and opens it itself, so the route can only ask and answer with
96 //! the screen. Whether "go here" is describable turns on whether the address is
97 //! known when the description is built.
98 //!
99 //! # What it deleted
100 //!
101 //! About thirty-five lines of loading-flag bookkeeping: two `Instant` fields,
102 //! two thirty-second timeouts, and the rule that a checkout error clears one
103 //! flag but a fetch error must not. A described screen says
104 //! [`Readiness::Pending`](quasi_router::layout::Readiness::Pending) and the
105 //! renderer owns what waiting looks like.
106
107 use quasi_router::layout::{FieldKind, Selector, Tone};
108 use quasi_router::{
109 Act, Action, Choice, Field, Node, RegionKind, Request, Response, RouteError, Router, Screen,
110 Slot,
111 };
112
113 use super::{State, Status, Subscription, Sync};
114
115 /// The region the whole screen answers into.
116 const BODY: &str = "sync-body";
117
118 /// One gibibyte, which is what a cap is counted in.
119 use crate::storage_cap::GIB;
120
121 /// The field a cap is chosen with.
122 const CAP: &str = "cap_gib";
123
124 /// The cadences the panel offers, in minutes.
125 ///
126 /// The same four the shipped panel has. A [`Selector::Segmented`] rather than a
127 /// number, because four named choices is a strip and not a range: the shipped
128 /// panel draws pills, and a renderer with no pills draws a small select.
129 const INTERVALS: &[u32] = &[5, 15, 30, 60];
130
131 /// Register this screen's routes.
132 pub fn routes(router: Router<super::Panels<'_>>) -> Router<super::Panels<'_>> {
133 router
134 .get("/sync", index)
135 .post("/sync/connect", connect)
136 .post("/sync/cancel", cancel)
137 .post("/sync/encryption", encryption)
138 .post("/sync/now", sync_now)
139 .post("/sync/auto", auto)
140 .post("/sync/interval", interval)
141 .post("/sync/error/clear", clear_error)
142 .post("/sync/disconnect", disconnect)
143 .post("/sync/subscription/refresh", refresh_subscription)
144 .post("/sync/subscribe", subscribe)
145 .post("/sync/cap", cap)
146 }
147
148 /// `GET /sync`
149 fn index(state: &super::Panels<'_>, _request: Request) -> Result<Response, RouteError> {
150 Ok(screen(state.sync).into())
151 }
152
153 /// `POST /sync/connect`
154 ///
155 /// Answers with somewhere to go rather than with a screen. Starting auth returns
156 /// the address the user has to visit, and visiting it is the host's job: this is
157 /// the case [`Destination::External`](quasi_router::Destination::External) was
158 /// added for, and it is what replaces the three `#[cfg(target_os)]` branches the
159 /// shipped panel carries.
160 fn connect(state: &super::Panels<'_>, _request: Request) -> Result<Response, RouteError> {
161 let address = state
162 .sync
163 .connect()
164 .map_err(|error| RouteError::internal(format!("Sync connect failed: {error}")))?;
165 Ok(Response::goto(Action::external(address)))
166 }
167
168 /// `POST /sync/cancel`
169 fn cancel(state: &super::Panels<'_>, _request: Request) -> Result<Response, RouteError> {
170 state.sync.cancel();
171 Ok(screen(state.sync).into())
172 }
173
174 /// `POST /sync/encryption`
175 ///
176 /// The password arrives in the payload and is never carried back: the field is a
177 /// [`FieldKind::Secret`], which the description refuses to hold a value for, so
178 /// the answer re-describes an empty box rather than the one that was typed in.
179 fn encryption(state: &super::Panels<'_>, request: Request) -> Result<Response, RouteError> {
180 let password = request.payload.get("password").unwrap_or_default();
181 if password.is_empty() {
182 return Ok(Response::from(screen(state.sync))
183 .toast(Tone::Danger, "A password is needed to encrypt this vault."));
184 }
185 let is_new = matches!(
186 state.sync.status().state,
187 State::NeedsEncryption {
188 has_server_key: false
189 }
190 );
191 state.sync.set_password(password, is_new);
192 Ok(screen(state.sync).into())
193 }
194
195 /// `POST /sync/now`
196 fn sync_now(state: &super::Panels<'_>, _request: Request) -> Result<Response, RouteError> {
197 state.sync.sync_now();
198 Ok(screen(state.sync).into())
199 }
200
201 /// `POST /sync/auto`
202 fn auto(state: &super::Panels<'_>, request: Request) -> Result<Response, RouteError> {
203 let on = !request.payload.get("auto").unwrap_or_default().is_empty();
204 state.sync.set_auto(on);
205 Ok(screen(state.sync).into())
206 }
207
208 /// `POST /sync/interval`
209 fn interval(state: &super::Panels<'_>, request: Request) -> Result<Response, RouteError> {
210 let minutes: u32 = request
211 .payload
212 .get(Node::SELECTED)
213 .and_then(|value| value.parse().ok())
214 .ok_or_else(|| RouteError::not_found("no such interval"))?;
215 if !INTERVALS.contains(&minutes) {
216 return Err(RouteError::not_found("no such interval"));
217 }
218 state.sync.set_interval(minutes);
219 Ok(screen(state.sync).into())
220 }
221
222 /// `POST /sync/error/clear`
223 fn clear_error(state: &super::Panels<'_>, _request: Request) -> Result<Response, RouteError> {
224 state.sync.clear_error();
225 Ok(screen(state.sync).into())
226 }
227
228 /// `POST /sync/subscription/refresh`
229 fn refresh_subscription(
230 state: &super::Panels<'_>,
231 _request: Request,
232 ) -> Result<Response, RouteError> {
233 state.sync.refresh_subscription();
234 Ok(screen(state.sync).into())
235 }
236
237 /// `POST /sync/subscribe`
238 ///
239 /// Answers with the screen rather than with somewhere to go, which is the
240 /// contrast the module header draws: the checkout URL is fetched asynchronously
241 /// and the manager opens it, so there is no address to put in an
242 /// [`Outcome::Goto`](quasi_router::Outcome::Goto).
243 fn subscribe(state: &super::Panels<'_>, request: Request) -> Result<Response, RouteError> {
244 let cap = cap_from(state, &request)?;
245 let annual = request.payload.get("cadence") == Some("annual");
246 state.sync.subscribe(cap, annual);
247 Ok(Response::from(screen(state.sync)).toast(Tone::Info, "Opening checkout in your browser."))
248 }
249
250 /// `POST /sync/cap`
251 fn cap(state: &super::Panels<'_>, request: Request) -> Result<Response, RouteError> {
252 let cap = cap_from(state, &request)?;
253 state.sync.queue_cap_change(cap);
254 Ok(Response::from(screen(state.sync))
255 .toast(Tone::Success, "The cap changes at your next renewal."))
256 }
257
258 /// The cap a request asked for, in bytes, refused if it is outside what is sold.
259 ///
260 /// Bounds-checked here and not only in the field, on the rule the interval route
261 /// already follows: `Field::min` and `max` are what a renderer draws, and a
262 /// route is reachable by typing.
263 fn cap_from(state: &super::Panels<'_>, request: &Request) -> Result<i64, RouteError> {
264 let gib: i64 = request
265 .payload
266 .get(CAP)
267 .and_then(|value| value.parse().ok())
268 .ok_or_else(|| RouteError::not_found("no cap named"))?;
269 let bytes = gib.saturating_mul(GIB);
270 let pricing = state
271 .sync
272 .pricing()
273 .ok_or_else(|| RouteError::internal("Pricing is not loaded yet."))?;
274 if bytes < pricing.min_bytes || bytes > pricing.max_bytes {
275 return Err(RouteError::not_found("that cap is not on offer"));
276 }
277 Ok(bytes)
278 }
279
280 /// `POST /sync/disconnect`
281 fn disconnect(state: &super::Panels<'_>, _request: Request) -> Result<Response, RouteError> {
282 state.sync.disconnect();
283 Ok(screen(state.sync).into())
284 }
285
286 /// The screen, which is a different screen per state.
287 ///
288 /// One route answering four shapes rather than four routes: the state is not an
289 /// address, and a user cannot navigate to `Authenticating` — they arrive there
290 /// because something happened. Four addresses would be four places you could
291 /// bookmark into a lie.
292 fn screen(sync: &dyn Sync) -> Screen {
293 let status = sync.status();
294 // Live, which is the whole of what the finding in this module's header
295 // asked for. The state moves without anyone acting: an OAuth callback lands
296 // in another process and `Authenticating` becomes `NeedsEncryption`, and the
297 // pending count falls while a sync runs. The description says the contents
298 // move and says nothing about how often to look; the cadence is
299 // `quasi_immediate::CADENCE`, and the host no longer owns a timer.
300 let mut body = Slot::new(BODY, RegionKind::Pane)
301 .live()
302 .with(Node::page("Cloud Sync"));
303
304 // Nothing to offer when there is nothing to offer it against. See
305 // [`Sync::available`]: an unavailable manager is not a disconnected one, and
306 // the flip found the described screen offering a `Connect` that refuses and
307 // a `Dismiss` for an error `clear_error` cannot clear. The shipped panel
308 // answered this state with a whole second window saying the same two
309 // sentences and drawing no controls.
310 if !sync.available() {
311 return Screen::sidebar_content("Cloud Sync").with(
312 body.with(Node::text("Cloud sync is unavailable."))
313 .with(Node::text("Open a vault to enable sync.")),
314 );
315 }
316
317 body = match status.state {
318 State::Disconnected => disconnected(body),
319 State::Authenticating => authenticating(body),
320 State::NeedsEncryption { has_server_key } => needs_encryption(body, has_server_key),
321 State::Ready | State::Syncing => ready(body, &status, sync),
322 };
323
324 // The error banner, on every state, because a failure can arrive in any of
325 // them. `Node::Notice` carries the tone and the text; what to do about it is
326 // two controls, and Retry is only offered where retrying means anything.
327 if let Some(error) = &status.last_error {
328 body = body.with(Node::Notice {
329 kind: quasi_router::layout::Notice::Banner,
330 tone: Tone::Danger,
331 text: error.clone(),
332 });
333 if matches!(status.state, State::Ready | State::Syncing) {
334 body = body.with(Node::Act(Act::new("Retry", Action::post("/sync/now"))));
335 }
336 body = body.with(Node::Act(Act::new(
337 "Dismiss",
338 Action::post("/sync/error/clear"),
339 )));
340 }
341
342 Screen::sidebar_content("Cloud Sync").with(body)
343 }
344
345 fn disconnected(body: Slot) -> Slot {
346 body.with(Node::text(
347 "Connect your audiofiles vault to Makenot.work for cross-device sync.",
348 ))
349 .with(Node::text(
350 "Metadata (tags, vault structure, analysis) syncs automatically. Audio file sync is per-vault opt-in.",
351 ))
352 .with(Node::Act(Act::new("Connect", Action::post("/sync/connect"))))
353 }
354
355 /// Waiting on a browser, with a way out.
356 ///
357 /// The spinner needs no vocabulary: [`Readiness::Pending`] is what a region
358 /// waiting on something says, and every renderer already answers it. That is one
359 /// place this screen expected a finding and did not get one.
360 fn authenticating(body: Slot) -> Slot {
361 body.with(Node::text("Waiting for authentication in your browser..."))
362 .with(Node::text(
363 "The app will update automatically once you sign in.",
364 ))
365 .with(Node::Act(Act::new("Cancel", Action::post("/sync/cancel"))))
366 }
367
368 /// The password that encrypts this vault.
369 ///
370 /// [`FieldKind::Secret`] and nothing else: the description will not carry the
371 /// typed value, which is `39057019`, so the runtime's buffer is the only place
372 /// it has ever lived. Whether this is a new password or an existing one changes
373 /// only what is said, which is why `has_server_key` reaches the prose and not
374 /// the shape.
375 fn needs_encryption(body: Slot, has_server_key: bool) -> Slot {
376 let says = if has_server_key {
377 "This vault is already encrypted. Enter its password to unlock it here."
378 } else {
379 "Choose a password. It encrypts everything before it leaves this machine, and it cannot be recovered."
380 };
381 body.with(Node::text(says)).with(Node::Form {
382 fields: vec![
383 Field::new(FieldKind::Secret, "password", "Password")
384 .required()
385 .hint(if has_server_key {
386 "The password this vault was encrypted with."
387 } else {
388 "Nobody can reset this for you."
389 }),
390 ],
391 submit: if has_server_key {
392 "Unlock"
393 } else {
394 "Set password"
395 }
396 .to_owned(),
397 action: Action::post("/sync/encryption"),
398 })
399 }
400
401 /// Connected, and what it is doing.
402 fn ready(body: Slot, status: &Status, sync: &dyn Sync) -> Slot {
403 let syncing = matches!(status.state, State::Syncing);
404 let mut body = body.with(Node::text(if syncing { "Syncing..." } else { "Connected" }));
405
406 if let Some(last) = &status.last_sync_at {
407 body = body.with(Node::text(format!("Last sync: {last}")));
408 }
409 if status.pending_changes > 0 {
410 // A count, not a proportion: nothing knows the total, and a `Meter`
411 // handed a made-up denominator would draw a bar that means nothing.
412 body = body.with(Node::Figure(quasi_router::Figure::new(
413 status.pending_changes.to_string(),
414 "pending changes",
415 )));
416 }
417
418 let mut now = Act::new("Sync now", Action::post("/sync/now"));
419 if syncing {
420 // Present, visible and not answering, which is what a control that is
421 // already running should be. The shipped panel says the same thing with
422 // `add_enabled(!syncing, ...)`.
423 now = now.disabled();
424 }
425
426 body.with(Node::Act(now))
427 .with(Node::section("Auto-sync"))
428 .with(Node::Field(Box::new(
429 Field::new(FieldKind::Checkbox, "auto", "Sync on a schedule")
430 .value(if status.auto_sync_enabled { "on" } else { "" })
431 .changes(Action::post("/sync/auto")),
432 )))
433 .with(Node::Select {
434 kind: Selector::Segmented,
435 options: INTERVALS
436 .iter()
437 .map(|minutes| {
438 (
439 Choice::new(minutes.to_string(), format!("{minutes} min")),
440 None,
441 )
442 })
443 .collect(),
444 chosen: Some(status.sync_interval_minutes.to_string()),
445 action: Some(Action::post("/sync/interval")),
446 })
447 .with(Node::section("Audio file sync"))
448 .with(subscription(sync))
449 .with(Node::Act(
450 Act::new("Disconnect", Action::post("/sync/disconnect"))
451 .tone(Tone::Danger)
452 .confirm("Disconnect this vault from cloud sync?"),
453 ))
454 }
455
456 /// What is bought, or what may be.
457 ///
458 /// Three shapes: not fetched yet, subscribed, or on offer. The first is a region
459 /// rather than a sentence, because "not fetched yet" is exactly what
460 /// [`Readiness::Pending`](quasi_router::layout::Readiness::Pending) says and the
461 /// renderer already knows how to draw waiting.
462 fn subscription(sync: &dyn Sync) -> Node {
463 let Some(pricing) = sync.pricing() else {
464 return waiting("Loading pricing...");
465 };
466 match sync.subscription() {
467 None => waiting("Checking subscription..."),
468 Some(sub) if sub.active => subscribed(sync, &sub, &pricing),
469 Some(_) => on_offer(sync, &pricing),
470 }
471 }
472
473 /// A region that is waiting on something.
474 ///
475 /// The whole of what the shipped panel spends two `Instant` fields, two
476 /// thirty-second timeouts and a spinner on. How long to wait and what to draw
477 /// while waiting are the renderer's, which is why neither is here.
478 fn waiting(says: &str) -> Node {
479 Node::Region(
480 Slot::new("subscription", RegionKind::Pane)
481 .pending()
482 .with(Node::text(says))
483 .with(Node::Act(Act::new(
484 "Retry",
485 Action::post("/sync/subscription/refresh"),
486 ))),
487 )
488 }
489
490 /// A running subscription: what it holds, how full it is, and how to change it.
491 fn subscribed(sync: &dyn Sync, sub: &Subscription, pricing: &super::Pricing) -> Node {
492 let mut slot = Slot::new("subscription", RegionKind::Pane).with(Node::text(format!(
493 "Subscribed: {} ({})",
494 gib_of(sub.limit_bytes),
495 sub.interval
496 )));
497
498 if sub.limit_bytes > 0 {
499 // A real proportion, so a real `Meter`: used against bought, both known.
500 // Counted in GiB rather than bytes because the bar is read by a person
501 // and `Meter` takes two `u32`s.
502 slot = slot.with(Node::Meter(
503 quasi_router::Meter::new(gib_count(sub.used_bytes), gib_count(sub.limit_bytes).max(1))
504 .label("GiB used")
505 // At ninety percent, not past it: this is a cap that stops syncing
506 // when it fills, and the point of saying so is to say it before
507 // that happens.
508 .tone(if nearly_full(sub) {
509 Tone::Warning
510 } else {
511 Tone::Neutral
512 }),
513 ));
514 }
515
516 // Say it in words as well as in the bar, and say what happens next. A meter
517 // that has gone amber reports a quantity; the user needs the consequence,
518 // which is that uploads stop and metadata sync carries on. Without this the
519 // first news of a full cap is a failed upload - the 402 from
520 // `routes/synckit/blobs.rs`, which the user meets as a sync that broke.
521 if let Some(warning) = cap_warning(sync, sub, pricing) {
522 slot = slot.with(Node::Notice {
523 kind: quasi_router::layout::Notice::Banner,
524 tone: if sub.used_bytes >= sub.limit_bytes {
525 Tone::Danger
526 } else {
527 Tone::Warning
528 },
529 text: warning,
530 });
531 }
532
533 if let Some(pending) = sub.pending_limit_bytes {
534 slot = slot.with(Node::text(format!(
535 "Pending: cap changes to {} at next renewal.",
536 gib_of(pending)
537 )));
538 }
539
540 // The same control the subscribe screen uses, defaulted to what is already
541 // bought rather than to a proposal: this user has answered the question, and
542 // re-proposing over their answer would be the screen arguing with them. The
543 // exception is a cap that no longer covers the library, where the proposal
544 // is the point.
545 let default = if nearly_full(sub) {
546 proposed_cap(sync.synced_library_bytes(), pricing).max(sub.limit_bytes)
547 } else {
548 sub.limit_bytes
549 };
550
551 Node::Region(
552 slot.with(Node::Form {
553 fields: vec![cap_choice(sync, pricing, default)],
554 submit: "Update cap".to_owned(),
555 action: Action::post("/sync/cap"),
556 })
557 .with(exact_cap_form(pricing, default, "/sync/cap")),
558 )
559 }
560
561 /// Whether the cap is close enough to full to say so.
562 ///
563 /// Ninety percent, the same threshold the meter turns amber at, so the bar and
564 /// the sentence never disagree about whether this is a problem.
565 fn nearly_full(sub: &Subscription) -> bool {
566 crate::storage_cap::nearly_full(sub.used_bytes, sub.limit_bytes)
567 }
568
569 /// What to say about a cap that is filling, if anything.
570 ///
571 /// Three cases, and they are different sentences rather than degrees of one.
572 /// Full means uploads have already stopped. Nearly full means they are about to.
573 /// A library that has outgrown the cap means the number to fix it is known, so
574 /// the message carries it and what it costs.
575 fn cap_warning(sync: &dyn Sync, sub: &Subscription, pricing: &super::Pricing) -> Option<String> {
576 if !nearly_full(sub) {
577 return None;
578 }
579
580 let annual = sub.interval == "annual";
581 let cadence = if annual { "a year" } else { "a month" };
582 let suggestion = |bytes: i64| {
583 format!(
584 " {} would hold it, at {} {cadence}.",
585 gib_of(bytes),
586 money(sync.quote_cents(bytes, annual))
587 )
588 };
589
590 // Only offer a bigger cap when there is one, and when it is actually bigger
591 // than what they have. At the ceiling the honest answer is that raising the
592 // cap is not the remedy.
593 let bigger = sync
594 .synced_library_bytes()
595 .map(|need| proposed_cap(Some(need), pricing))
596 .filter(|proposed| *proposed > sub.limit_bytes)
597 .map_or_else(String::new, suggestion);
598
599 Some(if sub.used_bytes >= sub.limit_bytes {
600 format!(
601 "Your storage cap is full. New sample files are not uploading; \
602 everything else still syncs.{bigger}"
603 )
604 } else {
605 format!(
606 "You are close to your storage cap. When it fills, new sample files \
607 stop uploading and everything else keeps syncing.{bigger}"
608 )
609 })
610 }
611
612 /// No subscription yet: the screen proposes a cap and says what it costs.
613 ///
614 /// The redesign of 2026-08-21, and what it turns on is that **the app already
615 /// knows the answer it used to ask for**. `synced_library_bytes` is the exact
616 /// size of what blob sync would upload, available locally and instantly, so a
617 /// control that opened on an unfilled number was soliciting a guess at a
618 /// question it could compute.
619 ///
620 /// So: state the need, propose a cap with headroom, and show every alternative
621 /// with its price attached. The user confirms or nudges one number instead of
622 /// exploring three orders of magnitude, which is what a slider from 250 GiB to
623 /// 10 TiB asked them to do and what nobody ever did.
624 fn on_offer(sync: &dyn Sync, pricing: &super::Pricing) -> Node {
625 let need = sync.synced_library_bytes();
626 let proposed = proposed_cap(need, pricing);
627
628 let mut slot = Slot::new("subscription", RegionKind::Pane);
629
630 // The need, first, because it is the reason the rest of the screen says what
631 // it says. `None` is "cannot look"; `Some(0)` is a real and different answer.
632 slot = slot.with(Node::text(match need {
633 Some(0) => "No vault is set to sync sample files yet, so nothing would upload today. \
634 Turn on file sync for a vault to change that."
635 .to_owned(),
636 Some(bytes) => format!(
637 "Your synced vaults hold {}. That is what would upload.",
638 gib_of_exact(bytes)
639 ),
640 None => "Pick a storage cap for audio file sync.".to_owned(),
641 }));
642
643 if need.is_some_and(|bytes| bytes > 0) {
644 slot = slot.with(Node::text(format!(
645 "Proposed: {}, which leaves room to grow.",
646 gib_of(proposed)
647 )));
648 }
649
650 slot = slot.with(Node::text(
651 "Annual is two months free: fewer Stripe fees, and we pass the savings on.",
652 ));
653
654 Node::Region(
655 slot.with(Node::Form {
656 fields: vec![
657 cap_choice(sync, pricing, proposed),
658 Field::radio(
659 "cadence",
660 "Billing",
661 vec![
662 Choice::new("annual", "Annual"),
663 Choice::new("monthly", "Monthly"),
664 ],
665 )
666 .value("annual"),
667 ],
668 submit: "Subscribe".to_owned(),
669 action: Action::post("/sync/subscribe"),
670 })
671 .with(exact_cap_form(pricing, proposed, "/sync/subscribe")),
672 )
673 }
674
675 /// The cap as a few named sizes, each carrying what it costs.
676 ///
677 /// A [`Field::radio`] rather than a select, and that is the whole point of the
678 /// control: the prices have to be *visible* without interacting, because the
679 /// decision being made is a spending decision and the enforced quantity is
680 /// bytes. A dropdown hides five of the six prices behind a click.
681 ///
682 /// Both cadences are on every label, so no label goes stale when the cadence
683 /// field changes underneath it. That replaces the old hint - "prices shown are
684 /// for the smallest cap; the exact figure is on the checkout page" - which was
685 /// a form apologising for not being able to say what it charged.
686 fn cap_choice(sync: &dyn Sync, pricing: &super::Pricing, proposed: i64) -> Field {
687 // The selected cap is always among the options, even when it is not one of
688 // the named sizes. A user who typed an exact figure, or who is on a cap from
689 // before this list existed, must see their own cap selected rather than a
690 // group with nothing chosen - which is what a radio says when its value
691 // matches no option, and it reads as "you have not chosen" to someone who
692 // has.
693 let mut sizes: Vec<i64> = offered_caps(pricing).collect();
694 if !sizes.contains(&proposed) {
695 sizes.push(proposed);
696 sizes.sort_unstable();
697 }
698
699 let options = sizes
700 .into_iter()
701 .map(|bytes| {
702 Choice::new(
703 gib_count(bytes).to_string(),
704 format!(
705 "{} - {} a month, or {} a year",
706 gib_of(bytes),
707 money(sync.quote_cents(bytes, false)),
708 money(sync.quote_cents(bytes, true))
709 ),
710 )
711 })
712 .collect();
713
714 Field::radio(CAP, "Storage cap", options).value(gib_count(proposed).to_string())
715 }
716
717 /// The exact-figure entry, as its own form.
718 ///
719 /// Two forms rather than one, because they are two acts. Picking a named size is
720 /// confirming a proposal; typing a number is overriding it, and a form carries
721 /// one submit and one action, so a single form offering both would have two
722 /// controls competing to answer one value.
723 ///
724 /// `min` and `max` are set here, which is what the old `cap_field` claimed in its
725 /// doc comment and did not do: it was a bare number with no bounds, and the only
726 /// thing that rejected an out-of-range cap was `cap_from`, after submit, with
727 /// "that cap is not on offer".
728 fn exact_cap_form(pricing: &super::Pricing, proposed: i64, action: &str) -> Node {
729 let field = Field {
730 min: Some(gib_count(pricing.min_bytes).to_string()),
731 max: Some(gib_count(pricing.max_bytes).to_string()),
732 unit: Some("GiB".to_owned()),
733 ..Field::new(FieldKind::Number, CAP, "Storage cap")
734 }
735 .value(gib_count(proposed).to_string())
736 .required()
737 .hint(format!(
738 "Anything from {} to {}.",
739 gib_of(pricing.min_bytes),
740 gib_of(pricing.max_bytes)
741 ));
742
743 Node::Region(
744 Slot::new("exact-cap", RegionKind::Pane)
745 .with(Node::text("Or set an exact cap."))
746 .with(Node::Form {
747 fields: vec![field],
748 submit: "Use this cap".to_owned(),
749 action: Action::post(action),
750 }),
751 )
752 }
753
754 /// The named caps this pricing actually permits, smallest first.
755 ///
756 /// Both screens read the same list from [`crate::storage_cap`]; a cap the egui
757 /// panel offered and this one did not would be two products.
758 fn offered_caps(pricing: &super::Pricing) -> impl Iterator<Item = i64> {
759 crate::storage_cap::offered(pricing.min_bytes, pricing.max_bytes)
760 }
761
762 /// The cap to propose, sized to what would actually upload.
763 fn proposed_cap(need: Option<i64>, pricing: &super::Pricing) -> i64 {
764 crate::storage_cap::proposed(need, pricing.min_bytes, pricing.max_bytes)
765 }
766
767 /// A byte count as whole GiB, for a person.
768 fn gib_count(bytes: i64) -> u32 {
769 u32::try_from(bytes / GIB).unwrap_or(u32::MAX)
770 }
771
772 /// A byte count as a size a person reads, keeping one decimal below a TiB.
773 ///
774 /// Distinct from [`gib_of`], which spells a *cap* - always a whole number of
775 /// GiB, because that is what a cap is. This spells a measurement, where
776 /// rounding 180.4 GiB to "180 GiB" is fine but rounding 0.4 GiB to "0 GiB"
777 /// would tell a user with a small library that they have nothing.
778 fn gib_of_exact(bytes: i64) -> String {
779 #[expect(
780 clippy::cast_precision_loss,
781 reason = "a library size in GiB is far inside f64's exact integer range"
782 )]
783 let gib = bytes as f64 / GIB as f64;
784 if gib >= 1024.0 {
785 format!("{:.1} TiB", gib / 1024.0)
786 } else if gib >= 10.0 {
787 format!("{gib:.0} GiB")
788 } else {
789 format!("{gib:.1} GiB")
790 }
791 }
792
793 /// A byte count as a cap, spelled the way the shipped panel spells it.
794 fn gib_of(bytes: i64) -> String {
795 let gib = bytes / GIB;
796 if gib >= 1024 {
797 #[expect(
798 clippy::cast_precision_loss,
799 reason = "a cap in TiB is small enough that f64 is exact here"
800 )]
801 let tib = gib as f64 / 1024.0;
802 format!("{tib:.1} TiB")
803 } else {
804 format!("{gib} GiB")
805 }
806 }
807
808 /// Cents as money, the way the shipped panel writes it.
809 fn money(cents: i64) -> String {
810 let dollars = cents / 100;
811 let pennies = cents % 100;
812 if pennies == 0 {
813 format!("${dollars}")
814 } else {
815 format!("${dollars}.{pennies:02}")
816 }
817 }
818