//! The settings window's License section, described: what key is held, which //! machine this is, and the one act that gives the key up. //! //! The fourth of the five sections the flip left behind and much the smallest. //! The task filed against it said to take it as the one that proves what the //! other four cost, and it does: two reads, two acts, no new vocabulary. //! //! # The refusal was about a server the screen never talks to //! //! [`settings`](super::settings) ruled it out as "a key exchanged with a //! server". Activation is a whole screen of the app's own //! (`audiofiles-app/src/activation.rs`) and is not this; what this section does //! is report two strings the app has already resolved and offer Deactivate, //! which records `VaultAction::DeactivateLicense` and is picked up in //! `main.rs:748` like any other pending action. Nothing here reaches the //! network. //! //! # THE BUG THIS PORT FOUND: Copy was copying an abbreviation //! //! The machine id is on screen because support asks for it, and the shipped //! section put a Copy button beside it for exactly that. But //! `sync_license_to_browser` shortened the id **on the way in** — //! `format!("{}...{}", &mid[..8], &mid[mid.len() - 4..])` — so //! `SettingsUiState::machine_id` never held the whole thing, and //! `ui.ctx().copy_text(mid.clone())` put `abcdefgh...wxyz` on the clipboard. //! Twelve characters and an ellipsis, pasted into a support mail, for a value //! whose only purpose is to be pasted into a support mail. //! //! Fixed on the way past rather than described faithfully: the app pushes the //! whole id now, [`Licence::machine`] carries it, and the *shortening is the //! description's* — [`shorten`] is here, beside the screen that shows it, which //! is where a display choice belongs. `Intent::CopyMachineId` carries the full //! value. //! //! That is the same split [`storage`](super::storage) made for a library path: //! the host resolves the fact, the description decides how much of it a reader //! sees, and the act carries the whole of it. //! //! # Deactivate asks first, where the shipped button did not //! //! `widgets::danger_button` was red and immediate. A key that is given up comes //! back only if the reader still has it written down somewhere, which is the //! definition of the thing [`Act::confirm`] exists for, and every other //! destructive act this port has described carries one. One line, and it is a //! behaviour change rather than a port, so it is said here. //! //! [`Act::confirm`]: quasi_router::Act::confirm //! [`Licence::machine`]: super::Licence::machine use quasi_declare::declare; use quasi_router::{Request, Response, RouteError, Router}; use super::Panels; /// How much of a machine id a reader sees, at each end. /// /// Eight and four. const HEAD: usize = 8; const TAIL: usize = 4; /// What giving the key up costs. const GIVES_UP: &str = "Deactivate this licence on this machine? You will need the key again to re-activate."; /// Register the License section's routes. pub fn routes(router: Router>) -> Router> { router .post("/settings/licence/machine/copy", copy) .post("/settings/licence/deactivate", deactivate) } /// `POST /settings/licence/machine/copy` fn copy(state: &Panels<'_>, _request: Request) -> Result { if state.licence.machine().is_none() { return Err(RouteError::not_found("this machine has no id yet")); } state.licence.copy(); settled(state) } /// `POST /settings/licence/deactivate` fn deactivate(state: &Panels<'_>, _request: Request) -> Result { if state.licence.masked().is_none() { return Err(RouteError::not_found("no licence is held here")); } state.licence.deactivate(); settled(state) } /// The settings window again, which is what both acts answer with. fn settled(state: &Panels<'_>) -> Result { super::settings::showing(state) } /// The licence, as the section draws it. /// /// `key` and `machine` are total: a `given` evaluates both its arms' holes /// whether or not either is placed (R9), so the reader answers the absent case /// with nothing rather than with a panic. pub(super) struct Standing { /// Whether a key is held here at all. held: bool, /// The masked key, or nothing when none is held. key: String, /// This machine's id as much of it as is worth reading, when the host has /// one. The whole value is what Copy carries; see [`shorten`]. machine: Option, } /// What the section draws, read off the app. pub(super) fn read(state: &Panels<'_>) -> Standing { let key = state.licence.masked(); Standing { held: key.is_some(), key: key.unwrap_or_default(), machine: state.licence.machine().map(|machine| shorten(&machine)), } } declare! { /// The whole section, spliced into the settings body. /// /// The heading is the state: "audiofiles Pro" when a key is held, "License" /// when none is. The shipped section did this and it is the one place the /// app says out loud that a key changes what it is. pub(super) shape section(licence: &Standing) -> Vec; let heading = given licence.held { true -> "audiofiles Pro", otherwise -> "License", }; let says = given licence.held { true -> "Key: {licence.key}", otherwise -> "No license key. audiofiles is fully functional without one.", }; section heading; text says; for machine in licence.machine.iter() { text "Machine: {machine}"; act "Copy machine id" to post "/settings/licence/machine/copy"; } act "Deactivate" to post "/settings/licence/deactivate" when licence.held { tone Danger; confirm GIVES_UP; } } /// A machine id as much of it as is worth reading. /// /// The whole value is what Copy carries; this is what sits on a line beside it. /// A short id is shown whole rather than padded. /// /// Counted in characters rather than bytes. `get_or_create_machine_id` writes /// hex, so the two agree today and /// the byte version never panicked; it would have on the first id that was not /// ASCII, and a display helper is not the place to be relying on the shape of /// somebody else's value. fn shorten(machine: &str) -> String { let count = machine.chars().count(); if count <= HEAD + TAIL { return machine.to_owned(); } let head: String = machine.chars().take(HEAD).collect(); let tail: String = machine.chars().skip(count - TAIL).collect(); format!("{head}...{tail}") } #[cfg(test)] mod tests { use super::shorten; #[test] fn a_long_id_keeps_both_ends_and_a_short_one_is_shown_whole() { assert_eq!(shorten("0123456789abcdef"), "01234567...cdef"); assert_eq!(shorten("short"), "short"); // Exactly the boundary is shown whole: twelve characters is already // shorter than "eight, an ellipsis and four". assert_eq!(shorten("0123456789ab"), "0123456789ab"); } #[test] fn a_multibyte_id_is_cut_by_character_rather_than_by_byte() { // Cannot arise from `get_or_create_machine_id`, which writes hex. It is // here because the version this replaces sliced by byte and would have // panicked rather than shortened, and a display helper should not be // the thing that depends on someone else's value staying ASCII. assert_eq!(shorten("ααααααααββββγγγγ"), "αααααααα...γγγγ"); assert_eq!(shorten("αβγδ"), "αβγδ"); } }