Skip to main content

max / audiofiles

Describe Settings > License, and copy the whole machine id The fourth of the five sections the flip left behind and the smallest: two reads and two acts, no new vocabulary. The task said to take it as the one that proves what the other four cost, and it does. The refusal was "a key exchanged with a server" and no exchange happens here. Activation is the app's own screen; this reports two strings the app has already resolved and offers Deactivate, which records `VaultAction::DeactivateLicense` and is picked up in `main.rs:748` like every other pending action. THE BUG IT FOUND, and it is the reason this is not a pure port. The machine id is on screen because support asks for it, and the shipped section put a Copy button beside it for exactly that. `sync_license_to_browser` shortened the id on the way in, so `SettingsUiState::machine_id` never held the real value and `copy_text` put twelve characters and an ellipsis on the clipboard -- for a value whose only purpose is to be pasted into a support mail. The app pushes the whole id now and the shortening is the described screen's, which is the same split `storage` made for a library path: the host resolves the fact, the description decides how much a reader sees, the act carries all of it. `shorten` counts characters rather than bytes, which the version it replaces did not; hex ids meant the two agreed and a display helper should not be the thing depending on that. Deactivate asks first, where `widgets::danger_button` was red and immediate. A key that is given up comes back only if the reader still has it written down. One line, a behaviour change rather than a port, and said in the header. Both addresses refuse when there is nothing to act on, since either is reachable by typing: no key means no deactivation, no machine id means nothing to copy. Import Theme is the last of the five, and it is the one waiting on the bytes-in gap `settings` already records.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-25 22:13 UTC
Signed with PGP, not checked
Commit: c7f49f567636823c8f9000126113cd625a7778a6
Parent: 353f70b
6 files changed, +492 insertions, -18 deletions
@@ -248,12 +248,13 @@
248 248 .license_cache
249 249 .as_ref()
250 250 .map(|cache| mask_key(&cache.key_code));
251 - let mid = &self.machine_id;
252 - browser.settings.machine_id = Some(if mid.len() > 12 {
253 - format!("{}...{}", &mid[..8], &mid[mid.len() - 4..])
254 - } else {
255 - mid.clone()
256 - });
251 + // The whole id, not an abbreviation of it. It was shortened here until
252 + // 2026-08-25, which meant `SettingsUiState::machine_id` never held the
253 + // real value and the section's Copy button put twelve characters and an
254 + // ellipsis on the clipboard -- for a value whose only purpose is to be
255 + // pasted into a support mail. The shortening is the described screen's
256 + // now (`quasi::licence::shorten`), where a display choice belongs.
257 + browser.settings.machine_id = Some(self.machine_id.clone());
257 258 }
258 259
259 260 /// Deactivate the license: notify the server (best-effort), delete the
@@ -29,6 +29,7 @@
29 29 //! | [`Storage`] | [`storage`] | an [`Intent`], applied after the frame |
30 30 //! | [`Trash`] | [`trash`] | an [`Intent`], applied after the frame |
31 31 //! | [`Classifier`] | [`classifier`] | an [`Intent`], applied after the frame |
32 + //! | [`Licence`] | [`licence`] | an [`Intent`], applied after the frame |
32 33 //! | [`ThemeChoice`] | [`settings`] | nothing: resolved once by the host |
33 34 //!
34 35 //! The themes are the settled rule from goingson's settings port applied first
@@ -135,6 +136,7 @@
135 136 pub mod importing;
136 137 pub mod integrity;
137 138 pub mod library;
139 + pub mod licence;
138 140 pub mod naming;
139 141 pub mod panel;
140 142 pub mod queue;
@@ -925,6 +927,10 @@
925 927 LocateLooseFiles,
926 928 /// Bring a tombstoned sample back.
927 929 RestoreSample(String),
930 + /// Put this machine's id on the clipboard.
931 + CopyMachineId,
932 + /// Give the licence key up on this machine.
933 + Deactivate,
928 934 /// Begin authoring a rule.
929 935 NewRule,
930 936 /// Re-evaluate every rule across the library.
@@ -5916,6 +5922,59 @@
5916 5922 }
5917 5923 }
5918 5924
5925 + /// The licence, as much of it as the Settings section needs.
5926 + ///
5927 + /// The fifteenth narrow trait and the smallest: two strings the app already
5928 + /// holds and two acts. The refusal it replaces was "a key exchanged with a
5929 + /// server", and no exchange happens here — activation is the app's own screen,
5930 + /// and deactivation goes out through `VaultAction::DeactivateLicense` the way
5931 + /// every other pending action does.
5932 + pub trait Licence {
5933 + /// The key, masked, when one is held.
5934 + ///
5935 + /// `None` is the ordinary state: audiofiles works without a key.
5936 + fn masked(&self) -> Option<String>;
5937 +
5938 + /// This machine's id, in full.
5939 + ///
5940 + /// In full because [`copy`](Self::copy) carries it and a support address is
5941 + /// the reason it is on screen at all. What the reader *sees* is shortened,
5942 + /// and the description decides that: see [`licence`](super::licence).
5943 + fn machine(&self) -> Option<String>;
5944 +
5945 + /// Put the machine id on the clipboard.
5946 + fn copy(&self);
5947 +
5948 + /// Give the key up on this machine.
5949 + fn deactivate(&self);
5950 + }
5951 +
5952 + /// The app's licence state, as the narrow thing the section borrows.
5953 + pub struct FromLicence<'a> {
5954 + /// What the app has loaded.
5955 + pub state: &'a crate::state::BrowserState,
5956 + /// What the described screen asked for, applied after the frame.
5957 + pub intents: &'a std::cell::RefCell<Vec<Intent>>,
5958 + }
5959 +
5960 + impl Licence for FromLicence<'_> {
5961 + fn masked(&self) -> Option<String> {
5962 + self.state.settings.license_key_masked.clone()
5963 + }
5964 +
5965 + fn machine(&self) -> Option<String> {
5966 + self.state.settings.machine_id.clone()
5967 + }
5968 +
5969 + fn copy(&self) {
5970 + self.intents.borrow_mut().push(Intent::CopyMachineId);
5971 + }
5972 +
5973 + fn deactivate(&self) {
5974 + self.intents.borrow_mut().push(Intent::Deactivate);
5975 + }
5976 + }
5977 +
5919 5978 /// The library-wide tag queue, as much of it as a described screen needs.
5920 5979 ///
5921 5980 /// One struct where the flow is an enum, and [`Forging`]'s reason again: the
@@ -6939,6 +6998,8 @@
6939 6998 pub trash: &'a dyn Trash,
6940 6999 /// The tag classifier, for its own window.
6941 7000 pub classifier: &'a dyn Classifier,
7001 + /// The licence, for the settings window's License section.
7002 + pub licence: &'a dyn Licence,
6942 7003 /// The themes on offer, resolved by the host at startup.
6943 7004 pub themes: &'a [ThemeChoice],
6944 7005 }
@@ -6953,7 +7014,9 @@
6953 7014 integrity::routes(importing::routes(naming::routes(toolbar::routes(
6954 7015 library::routes(shell::routes(help::routes(bulk::routes(detail::routes(
6955 7016 export::routes(files::routes(sync::routes(classifier::routes(
6956 - trash::routes(storage::routes(settings::routes(Router::new()))),
7017 + licence::routes(trash::routes(storage::routes(settings::routes(
7018 + Router::new(),
7019 + )))),
6957 7020 )))),
6958 7021 ))))),
6959 7022 )))),
@@ -34,9 +34,9 @@
34 34
35 35 use super::{
36 36 FromBackend, FromBar, FromBulk, FromClassifier, FromContents, FromEditor, FromExport,
37 - FromFilters, FromForge, FromImport, FromIntegrity, FromLibrary, FromNaming, FromQueue,
38 - FromSelection, FromStorage, FromSyncManager, FromTrash, FromWindow, Intent, Panels, Setting,
39 - Sync, ThemeChoice, Unconfigured,
37 + FromFilters, FromForge, FromImport, FromIntegrity, FromLibrary, FromLicence, FromNaming,
38 + FromQueue, FromSelection, FromStorage, FromSyncManager, FromTrash, FromWindow, Intent, Panels,
39 + Setting, Sync, ThemeChoice, Unconfigured,
40 40 };
41 41 use crate::state::BrowserState;
42 42 use crate::ui::theme;
@@ -1699,6 +1699,18 @@
1699 1699 Intent::EnableLayer(id, on) => state.classifier_set_layer_enabled(&id, on),
1700 1700 Intent::WeighLayer(id, weight) => state.classifier_set_layer_weight(&id, weight),
1701 1701 Intent::RemoveLayer(id) => state.classifier_remove_layer(&id),
1702 + // The whole id, which is the point: it is on screen so it can be
1703 + // pasted into a support mail, and what the shipped button copied
1704 + // was the abbreviation the screen was showing.
1705 + Intent::CopyMachineId => {
1706 + if let Some(machine) = state.settings.machine_id.clone() {
1707 + "Copied machine id.".clone_into(&mut state.status);
1708 + ctx.copy_text(machine);
1709 + }
1710 + }
1711 + Intent::Deactivate => {
1712 + state.settings.pending_action = Some(crate::state::VaultAction::DeactivateLicense);
1713 + }
1702 1714 }
1703 1715 }
1704 1716 }
@@ -2596,6 +2608,7 @@
2596 2608 let storage = FromStorage { state, intents };
2597 2609 let trash = FromTrash { state, intents };
2598 2610 let classifier = FromClassifier { state, intents };
2611 + let licence = FromLicence { state, intents };
2599 2612 let panels = Panels {
2600 2613 config: &config,
2601 2614 sync,
@@ -2616,6 +2629,7 @@
2616 2629 storage: &storage,
2617 2630 trash: &trash,
2618 2631 classifier: &classifier,
2632 + licence: &licence,
2619 2633 themes,
2620 2634 };
2621 2635 super::router()
@@ -40,7 +40,7 @@
40 40 //! | Display | yes | five booleans, a number and a control |
41 41 //! | Storage | yes | as of the section's own port; see [`storage`](super::storage) |
42 42 //! | Advanced | **half** | export yes as of quasi 0.50.0; import still a host dialog |
43 - //! | License | **no** | a key exchanged with a server |
43 + //! | License | yes | no key is exchanged here; see [`licence`](super::licence) |
44 44 //! | Trash | yes | as of the section's own port; see [`trash`](super::trash) |
45 45 //! | Classifier | yes | not a section at all; see [`classifier`](super::classifier) |
46 46 //!
@@ -203,6 +203,7 @@
203 203
204 204 body = super::storage::section(body, state);
205 205 body = super::trash::section(body, state);
206 + body = super::licence::section(body, state);
206 207
207 208 // The classifier's door. It was five sections drawn inline here until
208 209 // 2026-08-25, when Max ruled it a window of its own; what stays behind is
@@ -16,12 +16,12 @@
16 16 Collection, ColumnsShown, Config, Coverage, Crumb, Decision, Deleted, Detail, Detailed,
17 17 DeviceChoice, Doing, Draft, Editing, Export, Failure, Files, Filter, Filters, Focus, Folder,
18 18 FolderTags, Forge, Forging, Format, Group, Halted, Harvested, Head, Holding, Importing,
19 - Integrity, Keys, Knob, Layer, Library, LibraryEntry, Measure, Measures, Migrating, Naming,
20 - Narrowing, Order, Panel, Panels, Part, Phase, Pile, Playing, Policy, Preflight, Pricing,
21 - ProfileChoice, Queue, Queued, Readiness, Reviewed, Ruling, Sample, Saying, Scan, Scope,
22 - Searching, Setting, Settings, Shareable, Shared, Sharing, Shell, Source, Spread, Stage, State,
23 - Status, Storage, Strategy, Subject, Subscription, Suggested, Suggestion, Sweep, Sync, Tagged,
24 - Testable, Testing, ThemeChoice, Trash, Vault, VaultChoice, Walked, Where, router,
19 + Integrity, Keys, Knob, Layer, Library, LibraryEntry, Licence, Measure, Measures, Migrating,
20 + Naming, Narrowing, Order, Panel, Panels, Part, Phase, Pile, Playing, Policy, Preflight,
21 + Pricing, ProfileChoice, Queue, Queued, Readiness, Reviewed, Ruling, Sample, Saying, Scan,
22 + Scope, Searching, Setting, Settings, Shareable, Shared, Sharing, Shell, Source, Spread, Stage,
23 + State, Status, Storage, Strategy, Subject, Subscription, Suggested, Suggestion, Sweep, Sync,
24 + Tagged, Testable, Testing, ThemeChoice, Trash, Vault, VaultChoice, Walked, Where, router,
25 25 };
26 26
27 27 /// A config store in memory.
@@ -269,6 +269,7 @@
269 269 storage: &OneLibrary,
270 270 trash: &Emptied,
271 271 classifier: &Untrained,
272 + licence: &Unlicensed,
272 273 themes: &themes,
273 274 };
274 275 router().handle(&state, request)
@@ -437,6 +438,7 @@
437 438 storage: &OneLibrary,
438 439 trash: &Emptied,
439 440 classifier: &Untrained,
441 + licence: &Unlicensed,
440 442 themes: &themes,
441 443 };
442 444 router().handle(&state, request)
@@ -501,6 +503,7 @@
501 503 storage: &OneLibrary,
502 504 trash: &Emptied,
503 505 classifier: &Untrained,
506 + licence: &Unlicensed,
504 507 themes: &themes,
505 508 };
506 509 router().handle(&state, request)
@@ -668,6 +671,7 @@
668 671 storage: &OneLibrary,
669 672 trash: &Emptied,
670 673 classifier: &Untrained,
674 + licence: &Unlicensed,
671 675 themes: &themes,
672 676 };
673 677 let response = router()
@@ -707,7 +711,7 @@
707 711 // open a library, restore a deleted sample, count what is on disk -- and
708 712 // none is a key with a value. Only Storage's three-question form writes the
709 713 // way this screen does, and it does so through one route.
710 - const SECTIONS: [&str; 2] = ["/settings/storage", "/settings/trash"];
714 + const SECTIONS: [&str; 3] = ["/settings/storage", "/settings/trash", "/settings/licence"];
711 715 let counted = |prefix: &str| {
712 716 table
713 717 .iter()
@@ -724,6 +728,10 @@
724 728 // Two acts per row and nothing else. The shipped section's arm-then-confirm
725 729 // pair is `Act::confirm`, so there is no third address for the arming.
726 730 assert_eq!(counted("/settings/trash"), 2, "{table:?}");
731 + // Two reads and two acts. The smallest section of the five the flip left
732 + // behind, and the one that shows what the rule above is worth: neither of
733 + // these is a key with a value either.
734 + assert_eq!(counted("/settings/licence"), 2, "{table:?}");
727 735 }
728 736
729 737 /// A router call against the settings screen, over a given config store.
@@ -751,6 +759,7 @@
751 759 storage: &OneLibrary,
752 760 trash: &Emptied,
753 761 classifier: &Untrained,
762 + licence: &Unlicensed,
754 763 themes: &themes,
755 764 };
756 765 router().handle(&state, request)
@@ -825,6 +834,7 @@
825 834 storage: &OneLibrary,
826 835 trash: &Emptied,
827 836 classifier: &Untrained,
837 + licence: &Unlicensed,
828 838 themes: &themes,
829 839 };
830 840
@@ -879,6 +889,7 @@
879 889 storage: &OneLibrary,
880 890 trash: &Emptied,
881 891 classifier: &Untrained,
892 + licence: &Unlicensed,
882 893 themes: &themes,
883 894 };
884 895 let refused = router().handle(
@@ -919,6 +930,7 @@
919 930 storage: &OneLibrary,
920 931 trash: &Emptied,
921 932 classifier: &Untrained,
933 + licence: &Unlicensed,
922 934 themes: &themes,
923 935 };
924 936
@@ -982,6 +994,7 @@
982 994 storage: &OneLibrary,
983 995 trash: &Emptied,
984 996 classifier: &Untrained,
997 + licence: &Unlicensed,
985 998 themes: &themes,
986 999 };
987 1000 let response = router()
@@ -1041,6 +1054,7 @@
1041 1054 storage: &OneLibrary,
1042 1055 trash: &Emptied,
1043 1056 classifier: &Untrained,
1057 + licence: &Unlicensed,
1044 1058 themes: &themes,
1045 1059 };
1046 1060 let response = router()
@@ -1213,6 +1227,7 @@
1213 1227 storage: &OneLibrary,
1214 1228 trash: &Emptied,
1215 1229 classifier: &Untrained,
1230 + licence: &Unlicensed,
1216 1231 themes: &themes,
1217 1232 };
1218 1233 router().handle(&state, request)
@@ -2841,6 +2856,7 @@
2841 2856 storage: &OneLibrary,
2842 2857 trash: &Emptied,
2843 2858 classifier: &Untrained,
2859 + licence: &Unlicensed,
2844 2860 themes: &themes,
2845 2861 };
2846 2862 router().handle(&state, request)
@@ -3398,6 +3414,7 @@
3398 3414 storage: &OneLibrary,
3399 3415 trash: &Emptied,
3400 3416 classifier: &Untrained,
3417 + licence: &Unlicensed,
3401 3418 themes: &themes,
3402 3419 };
3403 3420 router().handle(&state, request)
@@ -3790,6 +3807,7 @@
3790 3807 storage: &OneLibrary,
3791 3808 trash: &Emptied,
3792 3809 classifier: &Untrained,
3810 + licence: &Unlicensed,
3793 3811 themes: &themes,
3794 3812 };
3795 3813 router().handle(&state, request)
@@ -4169,6 +4187,7 @@
4169 4187 storage: &OneLibrary,
4170 4188 trash: &Emptied,
4171 4189 classifier: &Untrained,
4190 + licence: &Unlicensed,
4172 4191 themes: &themes,
4173 4192 };
4174 4193 router().handle(&state, request)
@@ -4593,6 +4612,7 @@
4593 4612 storage: &OneLibrary,
4594 4613 trash: &Emptied,
4595 4614 classifier: &Untrained,
4615 + licence: &Unlicensed,
4596 4616 themes: &themes,
4597 4617 };
4598 4618 router().handle(&state, request)
@@ -5083,6 +5103,7 @@
5083 5103 storage: &OneLibrary,
5084 5104 trash: &Emptied,
5085 5105 classifier: &Untrained,
5106 + licence: &Unlicensed,
5086 5107 themes: &themes,
5087 5108 };
5088 5109 router().handle(&state, request)
@@ -5944,6 +5965,25 @@
5944 5965 fn remove_layer(&self, _id: &str) {}
5945 5966 }
5946 5967
5968 + /// No key held, and a machine id long enough to be shortened.
5969 + ///
5970 + /// The quiet fixture, matching [`OneLibrary`], [`Emptied`] and [`Untrained`].
5971 + /// The section's own tests use [`FakeLicence`], which records.
5972 + struct Unlicensed;
5973 +
5974 + impl Licence for Unlicensed {
5975 + fn masked(&self) -> Option<String> {
5976 + None
5977 + }
5978 +
5979 + fn machine(&self) -> Option<String> {
5980 + Some("0123456789abcdef0123456789abcdef".to_owned())
5981 + }
5982 +
5983 + fn copy(&self) {}
5984 + fn deactivate(&self) {}
5985 + }
5986 +
5947 5987 /// A namer in memory, recording what was asked of it and refusing on demand.
5948 5988 #[derive(Default)]
5949 5989 struct FakeNaming {
@@ -6068,6 +6108,7 @@
6068 6108 storage: &OneLibrary,
6069 6109 trash: &Emptied,
6070 6110 classifier: &Untrained,
6111 + licence: &Unlicensed,
6071 6112 themes: &themes,
6072 6113 };
6073 6114 router().handle(&state, request)
@@ -6233,6 +6274,7 @@
6233 6274 storage: &OneLibrary,
6234 6275 trash: &Emptied,
6235 6276 classifier: &Untrained,
6277 + licence: &Unlicensed,
6236 6278 themes: &themes,
6237 6279 };
6238 6280 router().handle(&state, request)
@@ -6542,6 +6584,7 @@
6542 6584 storage: &OneLibrary,
6543 6585 trash: &Emptied,
6544 6586 classifier: &Untrained,
6587 + licence: &Unlicensed,
6545 6588 themes: &themes,
6546 6589 };
6547 6590 router().handle(&state, request)
@@ -6628,6 +6671,7 @@
6628 6671 storage: &OneLibrary,
6629 6672 trash: &Emptied,
6630 6673 classifier: &Untrained,
6674 + licence: &Unlicensed,
6631 6675 themes: &themes,
6632 6676 };
6633 6677 let response = router().handle(&state, Request::get("/")).unwrap();
@@ -6812,6 +6856,7 @@
6812 6856 storage: &OneLibrary,
6813 6857 trash: &Emptied,
6814 6858 classifier: &Untrained,
6859 + licence: &Unlicensed,
6815 6860 themes: &themes,
6816 6861 };
6817 6862 router().handle(&state, request)
@@ -8392,6 +8437,7 @@
8392 8437 storage: &OneLibrary,
8393 8438 trash: &Emptied,
8394 8439 classifier: &Untrained,
8440 + licence: &Unlicensed,
8395 8441 themes: &themes,
8396 8442 };
8397 8443 router().handle(&state, request)
@@ -8975,6 +9021,7 @@
8975 9021 storage: &OneLibrary,
8976 9022 trash: &Emptied,
8977 9023 classifier: &Untrained,
9024 + licence: &Unlicensed,
8978 9025 themes: &themes,
8979 9026 };
8980 9027 router().handle(&state, request)
@@ -9116,6 +9163,7 @@
9116 9163 storage: &OneLibrary,
9117 9164 trash: &Emptied,
9118 9165 classifier: &Untrained,
9166 + licence: &Unlicensed,
9119 9167 themes: &themes,
9120 9168 };
9121 9169 router().handle(&state, request)
@@ -9810,6 +9858,7 @@
9810 9858 storage,
9811 9859 trash: &Emptied,
9812 9860 classifier: &Untrained,
9861 + licence: &Unlicensed,
9813 9862 themes: &themes,
9814 9863 };
9815 9864 router().handle(&state, request)
@@ -10234,6 +10283,7 @@
10234 10283 storage: &OneLibrary,
10235 10284 trash,
10236 10285 classifier: &Untrained,
10286 + licence: &Unlicensed,
10237 10287 themes: &themes,
10238 10288 };
10239 10289 router().handle(&state, request)
@@ -10711,6 +10761,7 @@
10711 10761 storage: &OneLibrary,
10712 10762 trash: &Emptied,
10713 10763 classifier,
10764 + licence: &Unlicensed,
10714 10765 themes: &themes,
10715 10766 };
10716 10767 router().handle(&state, request)
@@ -11293,3 +11344,169 @@
11293 11344 assert_eq!(classifier.asked(), ["weigh_layer l1 0.25"]);
11294 11345 assert!(classifying(&classifier, Request::post("/classifier/layers/l1/weight")).is_err());
11295 11346 }
11347 +
11348 + /// A licence in memory, recording what was asked of it.
11349 + #[derive(Default)]
11350 + struct FakeLicence {
11351 + masked: Option<String>,
11352 + machine: Option<String>,
11353 + asked: RefCell<Vec<String>>,
11354 + }
11355 +
11356 + impl FakeLicence {
11357 + /// A key held, on a machine with a long id.
11358 + fn activated() -> Self {
11359 + Self {
11360 + masked: Some("river-...-stone".to_owned()),
11361 + machine: Some("0123456789abcdef0123456789abcdef".to_owned()),
11362 + asked: RefCell::default(),
11363 + }
11364 + }
11365 +
11366 + /// No key, same machine.
11367 + fn unactivated() -> Self {
11368 + Self {
11369 + masked: None,
11370 + ..Self::activated()
11371 + }
11372 + }
11373 +
11374 + fn asked(&self) -> Vec<String> {
11375 + self.asked.borrow().clone()
11376 + }
11377 + }
11378 +
11379 + impl Licence for FakeLicence {
11380 + fn masked(&self) -> Option<String> {
11381 + self.masked.clone()
11382 + }
11383 +
11384 + fn machine(&self) -> Option<String> {
11385 + self.machine.clone()
11386 + }
11387 +
11388 + fn copy(&self) {
11389 + self.asked.borrow_mut().push("copy".to_owned());
11390 + }
11391 +
11392 + fn deactivate(&self) {
11393 + self.asked.borrow_mut().push("deactivate".to_owned());
11394 + }
11395 + }
11396 +
11397 + /// A router call against this licence.
11398 + fn licensing(
11399 + licence: &FakeLicence,
11400 + request: Request,
11401 + ) -> Result<Response, quasi_router::RouteError> {
11402 + let store = Store::default();
11403 + let sync = Offline;
11404 + let files = FakeFiles::default();
11405 + let themes = themes();
11406 + let state = Panels {
11407 + detail: &Unfocused,
11408 + bulk: &Unchosen,
11409 + shell: &Quiet,
11410 + library: &Empty,
11411 + bar: &Still,
11412 + config: &store,
11413 + sync: &sync,
11414 + files: &files,
11415 + export: &Idle,
11416 + naming: &Unnamed,
11417 + importing: &NoImport,
11418 + integrity: &Sound,
11419 + editor: &Unedited,
11420 + forge: &Unforged,
11421 + queue: &Unqueued,
11422 + filters: &Unfiltered,
11423 + storage: &OneLibrary,
11424 + trash: &Emptied,
11425 + classifier: &Untrained,
11426 + licence,
11427 + themes: &themes,
11428 + };
11429 + router().handle(&state, request)
11430 + }
11431 +
11432 + /// The settings screen this licence produces.
11433 + fn licensed(licence: &FakeLicence) -> Screen {
11434 + screen_of(&licensing(licence, Request::get("/settings")).expect("answered")).clone()
11435 + }
11436 +
11437 + #[test]
11438 + fn the_heading_is_the_state_the_licence_is_in() {
11439 + // The one place the app says out loud that a key changes what it is.
11440 + assert!(said_deep(&licensed(&FakeLicence::activated())).contains("audiofiles Pro"));
11441 + let without = said_deep(&licensed(&FakeLicence::unactivated()));
11442 + assert!(!without.contains("audiofiles Pro"));
11443 + assert!(without.contains("No license key. audiofiles is fully functional without one."));
11444 + }
11445 +
11446 + #[test]
11447 + fn a_held_key_is_shown_masked_and_never_in_full() {
11448 + let said = said_deep(&licensed(&FakeLicence::activated()));
11449 + assert!(said.contains("Key: river-...-stone"), "{said}");
11450 + }
11451 +
11452 + #[test]
11453 + fn the_machine_id_is_shortened_on_screen_and_copied_in_full() {
11454 + // The bug this port found. The id is on screen so it can be pasted into a
11455 + // support mail, and the shipped Copy button put the abbreviation on the
11456 + // clipboard because `sync_license_to_browser` shortened it on the way in.
11457 + let licence = FakeLicence::activated();
11458 + let whole = licence.machine.clone().expect("a machine id");
11459 + let said = said_deep(&licensed(&licence));
11460 + assert!(said.contains("Machine: 01234567...cdef"), "{said}");
11461 + assert!(
11462 + !said.contains(&whole),
11463 + "the whole id is on screen, which is not what it is for"
11464 + );
11465 +
11466 + licensing(&licence, Request::post("/settings/licence/machine/copy")).expect("answered");
11467 + assert_eq!(licence.asked(), ["copy"]);
11468 + }
11469 +
11470 + #[test]
11471 + fn deactivate_is_offered_only_where_there_is_something_to_give_up() {
11472 + let without = licensed(&FakeLicence::unactivated());
11473 + assert!(!acts(&without).iter().any(|label| label == "Deactivate"));
11474 + // And the address refuses too, since it is reachable by typing.
11475 + assert!(
11476 + licensing(
11477 + &FakeLicence::unactivated(),
11478 + Request::post("/settings/licence/deactivate")
11479 + )
11480 + .is_err()
11481 + );
11482 +
11483 + let licence = FakeLicence::activated();
11484 + let with = licensed(&licence);
11485 + let deactivate = deep_acts(&with)
11486 + .into_iter()
11487 + .find(|act| act.label == "Deactivate")
11488 + .expect("a deactivate");
11489 + assert_eq!(deactivate.tone, quasi_router::layout::Tone::Danger);
11490 + // The shipped button was red and immediate. A key that is given up comes
11491 + // back only if the reader still has it written down.
11492 + let asked = deactivate.confirm.as_deref().expect("it asks first");
11493 + assert!(asked.contains("re-activate"), "{asked}");
11494 +
11495 + licensing(&licence, Request::post("/settings/licence/deactivate")).expect("answered");
11496 + assert_eq!(licence.asked(), ["deactivate"]);
11497 + }
11498 +
11499 + #[test]
11500 + fn a_machine_with_no_id_offers_no_copy_and_refuses_the_address() {
11501 + let licence = FakeLicence {
11502 + masked: Some("river-...-stone".to_owned()),
11503 + machine: None,
11504 + asked: RefCell::default(),
11505 + };
11506 + let screen = licensed(&licence);
11507 + assert!(!acts(&screen).iter().any(|label| label == "Copy machine id"));
11508 + assert!(!said_deep(&screen).contains("Machine:"));
11509 + assert!(licensing(&licence, Request::post("/settings/licence/machine/copy")).is_err());
11510 + // The key half of the section is unaffected: the two are independent reads.
11511 + assert!(said_deep(&screen).contains("audiofiles Pro"));
11512 + }
@@ -1,0 +1,178 @@
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_router::layout::Tone;
51 + use quasi_router::{Act, Action, Node, Request, Response, RouteError, Router, Slot};
52 +
53 + use super::Panels;
54 +
55 + /// How much of a machine id a reader sees, at each end.
56 + ///
57 + /// Eight and four, which is what `sync_license_to_browser` used to do before
58 + /// the whole value reached this side. The numbers moved; the shape did not.
59 + const HEAD: usize = 8;
60 + const TAIL: usize = 4;
61 +
62 + /// What giving the key up costs.
63 + const GIVES_UP: &str =
64 + "Deactivate this licence on this machine? You will need the key again to re-activate.";
65 +
66 + /// Register the License section's routes.
67 + pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
68 + router
69 + .post("/settings/licence/machine/copy", copy)
70 + .post("/settings/licence/deactivate", deactivate)
71 + }
72 +
73 + /// `POST /settings/licence/machine/copy`
74 + fn copy(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
75 + if state.licence.machine().is_none() {
76 + return Err(RouteError::not_found("this machine has no id yet"));
77 + }
78 + state.licence.copy();
79 + settled(state)
80 + }
81 +
82 + /// `POST /settings/licence/deactivate`
83 + fn deactivate(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
84 + if state.licence.masked().is_none() {
85 + return Err(RouteError::not_found("no licence is held here"));
86 + }
87 + state.licence.deactivate();
88 + settled(state)
89 + }
90 +
91 + /// The settings window again, which is what both acts answer with.
92 + fn settled(state: &Panels<'_>) -> Result<Response, RouteError> {
93 + Ok(super::settings::screen(state)?.into())
94 + }
95 +
96 + /// The whole section, added to the settings body.
97 + pub(super) fn section(body: Slot, state: &Panels<'_>) -> Slot {
98 + let held = state.licence.masked();
99 +
100 + // The heading is the state: "audiofiles Pro" when a key is held, "License"
101 + // when none is. The shipped section did this and it is the one place the
102 + // app says out loud that a key changes what it is.
103 + let mut body = body.with(Node::section(if held.is_some() {
104 + "audiofiles Pro"
105 + } else {
106 + "License"
107 + }));
108 +
109 + body = match &held {
110 + Some(masked) => body.with(Node::text(format!("Key: {masked}"))),
111 + None => body.with(Node::text(
112 + "No license key. audiofiles is fully functional without one.",
113 + )),
114 + };
115 +
116 + if let Some(machine) = state.licence.machine() {
117 + body = body
118 + .with(Node::text(format!("Machine: {}", shorten(&machine))))
119 + .with(Node::Act(Act::new(
120 + "Copy machine id",
121 + Action::post("/settings/licence/machine/copy"),
122 + )));
123 + }
124 +
125 + if held.is_some() {
126 + body = body.with(Node::Act(
127 + Act::new("Deactivate", Action::post("/settings/licence/deactivate"))
128 + .tone(Tone::Danger)
129 + .confirm(GIVES_UP),
130 + ));
131 + }
132 + body
133 + }
134 +
135 + /// A machine id as much of it as is worth reading.
136 + ///
137 + /// The whole value is what Copy carries; this is what sits on a line beside it.
138 + /// A short id is shown whole rather than padded, which is what the `> 12` guard
139 + /// was doing where this used to live.
140 + ///
141 + /// Counted in characters rather than bytes, which the version this replaces did
142 + /// not do. `get_or_create_machine_id` writes hex, so the two agree today and
143 + /// the byte version never panicked; it would have on the first id that was not
144 + /// ASCII, and a display helper is not the place to be relying on the shape of
145 + /// somebody else's value.
146 + fn shorten(machine: &str) -> String {
147 + let count = machine.chars().count();
148 + if count <= HEAD + TAIL {
149 + return machine.to_owned();
150 + }
151 + let head: String = machine.chars().take(HEAD).collect();
152 + let tail: String = machine.chars().skip(count - TAIL).collect();
153 + format!("{head}...{tail}")
154 + }
155 +
156 + #[cfg(test)]
157 + mod tests {
158 + use super::shorten;
159 +
160 + #[test]
161 + fn a_long_id_keeps_both_ends_and_a_short_one_is_shown_whole() {
162 + assert_eq!(shorten("0123456789abcdef"), "01234567...cdef");
163 + assert_eq!(shorten("short"), "short");
164 + // Exactly the boundary is shown whole: twelve characters is already
165 + // shorter than "eight, an ellipsis and four".
166 + assert_eq!(shorten("0123456789ab"), "0123456789ab");
167 + }
168 +
169 + #[test]
170 + fn a_multibyte_id_is_cut_by_character_rather_than_by_byte() {
171 + // Cannot arise from `get_or_create_machine_id`, which writes hex. It is
172 + // here because the version this replaces sliced by byte and would have
173 + // panicked rather than shortened, and a display helper should not be
174 + // the thing that depends on someone else's value staying ASCII.
175 + assert_eq!(shorten("ααααααααββββγγγγ"), "αααααααα...γγγγ");
176 + assert_eq!(shorten("αβγδ"), "αβγδ");
177 + }
178 + }