| 6 |
6 |
|
//! entirely, optionally ticking "don't show again" to skip it on every future
|
| 7 |
7 |
|
//! launch too.
|
| 8 |
8 |
|
|
| 9 |
|
- |
use audiofiles_browser::ui::theme;
|
|
9 |
+ |
use audiofiles_browser::ui::{theme, widgets};
|
| 10 |
10 |
|
use eframe::egui;
|
| 11 |
11 |
|
|
| 12 |
12 |
|
use super::{AppScreen, AudioFilesApp, SYNC_SERVER_URL};
|
| 73 |
73 |
|
ui.add_space(theme::space::section());
|
| 74 |
74 |
|
|
| 75 |
75 |
|
// ── License key entry (optional) ──
|
| 76 |
|
- |
ui.label(
|
| 77 |
|
- |
egui::RichText::new("Have a key? Paste it here.")
|
| 78 |
|
- |
.color(theme::content_secondary()),
|
| 79 |
|
- |
);
|
| 80 |
|
- |
ui.add_space(theme::space::bound());
|
|
76 |
+ |
//
|
|
77 |
+ |
// A described field: a labelled question with standing help,
|
|
78 |
+ |
// ghost text and — when the key itself is what is wrong — an
|
|
79 |
+ |
// error. Not `required`, deliberately: the marker would say
|
|
80 |
+ |
// the screen refuses to move on without a key, and the whole
|
|
81 |
+ |
// screen is built on it not doing that. The disabled Activate
|
|
82 |
+ |
// button is what says an empty key cannot be submitted.
|
|
83 |
+ |
let field_error = self
|
|
84 |
+ |
.activation_error
|
|
85 |
+ |
.as_ref()
|
|
86 |
+ |
.filter(|err| error_scope(err) == ErrorScope::Field)
|
|
87 |
+ |
.map(ToString::to_string);
|
|
88 |
+ |
let key_field = makeover_layout::Field {
|
|
89 |
+ |
hint: Some("Have a key? Paste it here."),
|
|
90 |
+ |
error: field_error.as_deref(),
|
|
91 |
+ |
placeholder: Some("five-word-license-key-example"),
|
|
92 |
+ |
..makeover_layout::Field::new(
|
|
93 |
+ |
makeover_layout::FieldKind::Text,
|
|
94 |
+ |
"license_key",
|
|
95 |
+ |
"License key",
|
|
96 |
+ |
)
|
|
97 |
+ |
};
|
| 81 |
98 |
|
|
| 82 |
99 |
|
let input_width = 360.0_f32.min(available.x - 40.0);
|
| 83 |
|
- |
ui.allocate_ui(egui::vec2(input_width, 28.0), |ui| {
|
| 84 |
|
- |
let response = ui.add_sized(
|
| 85 |
|
- |
ui.available_size(),
|
| 86 |
|
- |
egui::TextEdit::singleline(&mut self.license_key_input)
|
| 87 |
|
- |
.hint_text("five-word-license-key-example"),
|
| 88 |
|
- |
);
|
|
100 |
+ |
ui.allocate_ui(egui::vec2(input_width, 0.0), |ui| {
|
|
101 |
+ |
let Some(response) = widgets::field(
|
|
102 |
+ |
ui,
|
|
103 |
+ |
&key_field,
|
|
104 |
+ |
makeover_immediate::Filling::Text(&mut self.license_key_input),
|
|
105 |
+ |
None,
|
|
106 |
+ |
) else {
|
|
107 |
+ |
return;
|
|
108 |
+ |
};
|
| 89 |
109 |
|
// Clear stale activation error as soon as the user edits the field.
|
| 90 |
110 |
|
if response.changed() {
|
| 91 |
111 |
|
self.activation_error = None;
|
| 100 |
120 |
|
}
|
| 101 |
121 |
|
});
|
| 102 |
122 |
|
|
|
123 |
+ |
// The way out follows the message it belongs to. A key the
|
|
124 |
+ |
// server did not recognise is reported on the field, so the
|
|
125 |
+ |
// link that replaces the key goes with it rather than staying
|
|
126 |
+ |
// behind under a button whose error is now blank.
|
|
127 |
+ |
if let Some(err) = self
|
|
128 |
+ |
.activation_error
|
|
129 |
+ |
.clone()
|
|
130 |
+ |
.filter(|err| error_scope(err) == ErrorScope::Field)
|
|
131 |
+ |
{
|
|
132 |
+ |
ui.add_space(theme::space::bound());
|
|
133 |
+ |
if draw_recovery_affordance(ui, &err) && !self.activating {
|
|
134 |
+ |
self.start_activation();
|
|
135 |
+ |
}
|
|
136 |
+ |
}
|
|
137 |
+ |
|
| 103 |
138 |
|
ui.add_space(theme::space::peer());
|
| 104 |
139 |
|
|
| 105 |
140 |
|
let can_activate =
|
| 123 |
158 |
|
ui.spinner();
|
| 124 |
159 |
|
}
|
| 125 |
160 |
|
|
| 126 |
|
- |
if let Some(err) = self.activation_error.clone() {
|
|
161 |
+ |
// What went wrong with the attempt rather than with the key:
|
|
162 |
+ |
// the network, the server, this machine's slot. It is the
|
|
163 |
+ |
// outcome of pressing Activate, so it is reported where
|
|
164 |
+ |
// Activate is.
|
|
165 |
+ |
if let Some(err) = self
|
|
166 |
+ |
.activation_error
|
|
167 |
+ |
.clone()
|
|
168 |
+ |
.filter(|err| error_scope(err) == ErrorScope::Submission)
|
|
169 |
+ |
{
|
| 127 |
170 |
|
ui.add_space(theme::space::peer());
|
| 128 |
171 |
|
ui.colored_label(theme::danger(), err.to_string());
|
| 129 |
172 |
|
ui.add_space(theme::space::bound());
|
| 130 |
|
- |
match recovery_affordance(&err) {
|
| 131 |
|
- |
RecoveryAffordance::Retry => {
|
| 132 |
|
- |
if ui.button("Try again").clicked() && !self.activating {
|
| 133 |
|
- |
self.start_activation();
|
| 134 |
|
- |
}
|
| 135 |
|
- |
}
|
| 136 |
|
- |
RecoveryAffordance::GetNewKey => {
|
| 137 |
|
- |
ui.hyperlink_to(
|
| 138 |
|
- |
"Get a new license key",
|
| 139 |
|
- |
"https://makenot.work/store/audiofiles",
|
| 140 |
|
- |
);
|
| 141 |
|
- |
}
|
| 142 |
|
- |
RecoveryAffordance::ContactSupport => {
|
| 143 |
|
- |
ui.hyperlink_to(
|
| 144 |
|
- |
"Contact support",
|
| 145 |
|
- |
"mailto:info@makenot.work?subject=License%20activation%20issue",
|
| 146 |
|
- |
);
|
| 147 |
|
- |
}
|
|
173 |
+ |
if draw_recovery_affordance(ui, &err) && !self.activating {
|
|
174 |
+ |
self.start_activation();
|
| 148 |
175 |
|
}
|
| 149 |
176 |
|
}
|
| 150 |
177 |
|
|
| 285 |
312 |
|
}
|
| 286 |
313 |
|
}
|
| 287 |
314 |
|
|
|
315 |
+ |
/// Where an activation failure is reported.
|
|
316 |
+ |
///
|
|
317 |
+ |
/// The split the described field made necessary, and the same one
|
|
318 |
+ |
/// `encryption_submit_state` draws in the sync panel: a message belongs to the
|
|
319 |
+ |
/// thing it is about. A key the server rejected is a fact about the value in
|
|
320 |
+ |
/// the field; a server that did not answer is a fact about the attempt, and
|
|
321 |
+ |
/// putting it on the field would say the user typed it wrong.
|
|
322 |
+ |
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
|
323 |
+ |
pub(crate) enum ErrorScope {
|
|
324 |
+ |
/// Wrong with the key that was typed.
|
|
325 |
+ |
Field,
|
|
326 |
+ |
/// Wrong with the attempt: the network, the server, or this machine's slot.
|
|
327 |
+ |
Submission,
|
|
328 |
+ |
}
|
|
329 |
+ |
|
|
330 |
+ |
/// Route a classified activation error to where it is shown.
|
|
331 |
+ |
///
|
|
332 |
+ |
/// Pure variant routing, like [`recovery_affordance`], and deliberately a
|
|
333 |
+ |
/// separate function from it: where a message goes and what it offers as a way
|
|
334 |
+ |
/// out are different questions, and only one of them is about the key.
|
|
335 |
+ |
///
|
|
336 |
+ |
/// `MachineLimit` is a submission error, not a field one. The key is real and
|
|
337 |
+ |
/// correctly typed; what is exhausted is its activation slots, which is a fact
|
|
338 |
+ |
/// about the account rather than about the value in the field.
|
|
339 |
+ |
pub(crate) fn error_scope(err: &super::license::ActivationError) -> ErrorScope {
|
|
340 |
+ |
use super::license::ActivationError as E;
|
|
341 |
+ |
match err {
|
|
342 |
+ |
E::InvalidKey => ErrorScope::Field,
|
|
343 |
+ |
E::Network | E::Server(_) | E::Other(_) | E::MachineLimit => ErrorScope::Submission,
|
|
344 |
+ |
}
|
|
345 |
+ |
}
|
|
346 |
+ |
|
|
347 |
+ |
/// Draw the recovery action for an activation error. Returns true when the user
|
|
348 |
+ |
/// asked to retry, which is the one affordance the caller has to act on; the
|
|
349 |
+ |
/// other two are links and handle themselves.
|
|
350 |
+ |
///
|
|
351 |
+ |
/// Shared by both scopes so the way out looks the same wherever the message it
|
|
352 |
+ |
/// belongs to ended up.
|
|
353 |
+ |
fn draw_recovery_affordance(ui: &mut egui::Ui, err: &super::license::ActivationError) -> bool {
|
|
354 |
+ |
match recovery_affordance(err) {
|
|
355 |
+ |
RecoveryAffordance::Retry => ui.button("Try again").clicked(),
|
|
356 |
+ |
RecoveryAffordance::GetNewKey => {
|
|
357 |
+ |
ui.hyperlink_to(
|
|
358 |
+ |
"Get a new license key",
|
|
359 |
+ |
"https://makenot.work/store/audiofiles",
|
|
360 |
+ |
);
|
|
361 |
+ |
false
|
|
362 |
+ |
}
|
|
363 |
+ |
RecoveryAffordance::ContactSupport => {
|
|
364 |
+ |
ui.hyperlink_to(
|
|
365 |
+ |
"Contact support",
|
|
366 |
+ |
"mailto:info@makenot.work?subject=License%20activation%20issue",
|
|
367 |
+ |
);
|
|
368 |
+ |
false
|
|
369 |
+ |
}
|
|
370 |
+ |
}
|
|
371 |
+ |
}
|
|
372 |
+ |
|
| 288 |
373 |
|
/// The audiofiles Pro benefit list, in display order. Kept as a plain constant
|
| 289 |
374 |
|
/// so the tongue-in-cheek copy is easy to spot and edit in one place.
|
| 290 |
375 |
|
const PRO_BENEFITS: &[(&str, &str)] = &[
|
| 372 |
457 |
|
);
|
| 373 |
458 |
|
}
|
| 374 |
459 |
|
|
|
460 |
+ |
// ── error_scope: which error is about the key, and which is not ──
|
|
461 |
+ |
|
|
462 |
+ |
#[test]
|
|
463 |
+ |
fn invalid_key_is_reported_on_the_field() {
|
|
464 |
+ |
assert_eq!(error_scope(&ActivationError::InvalidKey), ErrorScope::Field);
|
|
465 |
+ |
}
|
|
466 |
+ |
|
|
467 |
+ |
#[test]
|
|
468 |
+ |
fn machine_limit_is_reported_with_the_submission() {
|
|
469 |
+ |
// The key is real and correctly typed; its slots are used up. Putting
|
|
470 |
+ |
// that on the field would tell the user they mistyped it.
|
|
471 |
+ |
assert_eq!(
|
|
472 |
+ |
error_scope(&ActivationError::MachineLimit),
|
|
473 |
+ |
ErrorScope::Submission
|
|
474 |
+ |
);
|
|
475 |
+ |
}
|
|
476 |
+ |
|
|
477 |
+ |
#[test]
|
|
478 |
+ |
fn transport_errors_are_reported_with_the_submission() {
|
|
479 |
+ |
for err in [
|
|
480 |
+ |
ActivationError::Network,
|
|
481 |
+ |
ActivationError::Server(503),
|
|
482 |
+ |
ActivationError::Other("parse blew up".to_string()),
|
|
483 |
+ |
] {
|
|
484 |
+ |
assert_eq!(error_scope(&err), ErrorScope::Submission);
|
|
485 |
+ |
}
|
|
486 |
+ |
}
|
|
487 |
+ |
|
|
488 |
+ |
#[test]
|
|
489 |
+ |
fn only_the_field_scoped_error_carries_a_key_replacing_affordance() {
|
|
490 |
+ |
// The pairing the two-scope split rests on: the one error that lands on
|
|
491 |
+ |
// the field is the one whose way out is a new key. If a submission-scoped
|
|
492 |
+ |
// error ever routes to GetNewKey, the link would sit under a message
|
|
493 |
+ |
// that is not about the key.
|
|
494 |
+ |
for err in [
|
|
495 |
+ |
ActivationError::Network,
|
|
496 |
+ |
ActivationError::Server(503),
|
|
497 |
+ |
ActivationError::Other("parse blew up".to_string()),
|
|
498 |
+ |
ActivationError::MachineLimit,
|
|
499 |
+ |
] {
|
|
500 |
+ |
assert_ne!(recovery_affordance(&err), RecoveryAffordance::GetNewKey);
|
|
501 |
+ |
}
|
|
502 |
+ |
assert_eq!(
|
|
503 |
+ |
recovery_affordance(&ActivationError::InvalidKey),
|
|
504 |
+ |
RecoveryAffordance::GetNewKey
|
|
505 |
+ |
);
|
|
506 |
+ |
}
|
|
507 |
+ |
|
| 375 |
508 |
|
// ── Pro benefits copy ──
|
| 376 |
509 |
|
|
| 377 |
510 |
|
#[test]
|