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