Skip to main content

max / audiofiles

7.5 KB · 194 lines History Blame Raw
1 //! The settings window's License section, described: what key is held, which
2 //! machine this is, and the one act that gives the key up.
3 //!
4 //! The fourth of the five sections the flip left behind and much the smallest.
5 //! The task filed against it said to take it as the one that proves what the
6 //! other four cost, and it does: two reads, two acts, no new vocabulary.
7 //!
8 //! # The refusal was about a server the screen never talks to
9 //!
10 //! [`settings`](super::settings) ruled it out as "a key exchanged with a
11 //! server". Activation is a whole screen of the app's own
12 //! (`audiofiles-app/src/activation.rs`) and is not this; what this section does
13 //! is report two strings the app has already resolved and offer Deactivate,
14 //! which records `VaultAction::DeactivateLicense` and is picked up in
15 //! `main.rs:748` like any other pending action. Nothing here reaches the
16 //! network.
17 //!
18 //! # THE BUG THIS PORT FOUND: Copy was copying an abbreviation
19 //!
20 //! The machine id is on screen because support asks for it, and the shipped
21 //! section put a Copy button beside it for exactly that. But
22 //! `sync_license_to_browser` shortened the id **on the way in** —
23 //! `format!("{}...{}", &mid[..8], &mid[mid.len() - 4..])` — so
24 //! `SettingsUiState::machine_id` never held the whole thing, and
25 //! `ui.ctx().copy_text(mid.clone())` put `abcdefgh...wxyz` on the clipboard.
26 //! Twelve characters and an ellipsis, pasted into a support mail, for a value
27 //! whose only purpose is to be pasted into a support mail.
28 //!
29 //! Fixed on the way past rather than described faithfully: the app pushes the
30 //! whole id now, [`Licence::machine`] carries it, and the *shortening is the
31 //! description's* — [`shorten`] is here, beside the screen that shows it, which
32 //! is where a display choice belongs. `Intent::CopyMachineId` carries the full
33 //! value.
34 //!
35 //! That is the same split [`storage`](super::storage) made for a library path:
36 //! the host resolves the fact, the description decides how much of it a reader
37 //! sees, and the act carries the whole of it.
38 //!
39 //! # Deactivate asks first, where the shipped button did not
40 //!
41 //! `widgets::danger_button` was red and immediate. A key that is given up comes
42 //! back only if the reader still has it written down somewhere, which is the
43 //! definition of the thing [`Act::confirm`] exists for, and every other
44 //! destructive act this port has described carries one. One line, and it is a
45 //! behaviour change rather than a port, so it is said here.
46 //!
47 //! [`Act::confirm`]: quasi_router::Act::confirm
48 //! [`Licence::machine`]: super::Licence::machine
49
50 use quasi_declare::declare;
51 use quasi_router::{Request, Response, RouteError, Router};
52
53 use super::Panels;
54
55 /// How much of a machine id a reader sees, at each end.
56 ///
57 /// Eight and four.
58 const HEAD: usize = 8;
59 const TAIL: usize = 4;
60
61 /// What giving the key up costs.
62 const GIVES_UP: &str =
63 "Deactivate this licence on this machine? You will need the key again to re-activate.";
64
65 /// Register the License section's routes.
66 pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
67 router
68 .post("/settings/licence/machine/copy", copy)
69 .post("/settings/licence/deactivate", deactivate)
70 }
71
72 /// `POST /settings/licence/machine/copy`
73 fn copy(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
74 if state.licence.machine().is_none() {
75 return Err(RouteError::not_found("this machine has no id yet"));
76 }
77 state.licence.copy();
78 settled(state)
79 }
80
81 /// `POST /settings/licence/deactivate`
82 fn deactivate(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
83 if state.licence.masked().is_none() {
84 return Err(RouteError::not_found("no licence is held here"));
85 }
86 state.licence.deactivate();
87 settled(state)
88 }
89
90 /// The settings window again, which is what both acts answer with.
91 fn settled(state: &Panels<'_>) -> Result<Response, RouteError> {
92 super::settings::showing(state)
93 }
94
95 /// The licence, as the section draws it.
96 ///
97 /// `key` and `machine` are total: a `given` evaluates both its arms' holes
98 /// whether or not either is placed (R9), so the reader answers the absent case
99 /// with nothing rather than with a panic.
100 pub(super) struct Standing {
101 /// Whether a key is held here at all.
102 held: bool,
103 /// The masked key, or nothing when none is held.
104 key: String,
105 /// This machine's id as much of it as is worth reading, when the host has
106 /// one. The whole value is what Copy carries; see [`shorten`].
107 machine: Option<String>,
108 }
109
110 /// What the section draws, read off the app.
111 pub(super) fn read(state: &Panels<'_>) -> Standing {
112 let key = state.licence.masked();
113 Standing {
114 held: key.is_some(),
115 key: key.unwrap_or_default(),
116 machine: state.licence.machine().map(|machine| shorten(&machine)),
117 }
118 }
119
120 declare! {
121 /// The whole section, spliced into the settings body.
122 ///
123 /// The heading is the state: "audiofiles Pro" when a key is held, "License"
124 /// when none is. The shipped section did this and it is the one place the
125 /// app says out loud that a key changes what it is.
126 pub(super) shape section(licence: &Standing) -> Vec<Node>;
127
128 let heading = given licence.held {
129 true -> "audiofiles Pro",
130 otherwise -> "License",
131 };
132 let says = given licence.held {
133 true -> "Key: {licence.key}",
134 otherwise -> "No license key. audiofiles is fully functional without one.",
135 };
136
137 section heading;
138 text says;
139
140 for machine in licence.machine.iter() {
141 text "Machine: {machine}";
142 act "Copy machine id" to post "/settings/licence/machine/copy";
143 }
144
145 act "Deactivate" to post "/settings/licence/deactivate" when licence.held {
146 tone Danger;
147 confirm GIVES_UP;
148 }
149 }
150
151 /// A machine id as much of it as is worth reading.
152 ///
153 /// The whole value is what Copy carries; this is what sits on a line beside it.
154 /// A short id is shown whole rather than padded.
155 ///
156 /// Counted in characters rather than bytes. `get_or_create_machine_id` writes
157 /// hex, so the two agree today and
158 /// the byte version never panicked; it would have on the first id that was not
159 /// ASCII, and a display helper is not the place to be relying on the shape of
160 /// somebody else's value.
161 fn shorten(machine: &str) -> String {
162 let count = machine.chars().count();
163 if count <= HEAD + TAIL {
164 return machine.to_owned();
165 }
166 let head: String = machine.chars().take(HEAD).collect();
167 let tail: String = machine.chars().skip(count - TAIL).collect();
168 format!("{head}...{tail}")
169 }
170
171 #[cfg(test)]
172 mod tests {
173 use super::shorten;
174
175 #[test]
176 fn a_long_id_keeps_both_ends_and_a_short_one_is_shown_whole() {
177 assert_eq!(shorten("0123456789abcdef"), "01234567...cdef");
178 assert_eq!(shorten("short"), "short");
179 // Exactly the boundary is shown whole: twelve characters is already
180 // shorter than "eight, an ellipsis and four".
181 assert_eq!(shorten("0123456789ab"), "0123456789ab");
182 }
183
184 #[test]
185 fn a_multibyte_id_is_cut_by_character_rather_than_by_byte() {
186 // Cannot arise from `get_or_create_machine_id`, which writes hex. It is
187 // here because the version this replaces sliced by byte and would have
188 // panicked rather than shortened, and a display helper should not be
189 // the thing that depends on someone else's value staying ASCII.
190 assert_eq!(shorten("ααααααααββββγγγγ"), "αααααααα...γγγγ");
191 assert_eq!(shorten("αβγδ"), "αβγδ");
192 }
193 }
194