Skip to main content

tauri_plugin_moonbase/
controller.rs

1//! The headless activation state machine behind the plugin's commands.
2//!
3//! Network and file work runs on worker threads. Every state-changing entry point
4//! bumps a generation counter, and a worker's result only lands if the generation
5//! is still the one it started under, so a slow request can never clobber a newer
6//! state (a cancel, a fresh activation, a deactivation). Every change is emitted
7//! as [`STATE_CHANGED_EVENT`] with the new [`LicenseState`].
8//!
9//! Once started it also watches the license with no network traffic: a license
10//! whose `exp` passes or whose offline grace period runs out is re-checked (and
11//! locked if it has ended) without a restart, and a license another instance or
12//! process activates, refreshes or removes is picked up within a few seconds. A
13//! change is emitted, too, when an add-on runs out while the license carries on,
14//! and when a day goes by on a countdown to when the license or an add-on ends.
15
16use std::collections::HashSet;
17use std::path::PathBuf;
18use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
19use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
20use std::thread;
21use std::time::{Duration, Instant, SystemTime};
22
23use moonbase::version::update_available;
24use moonbase::{
25    ActivationMethod, ActivationRequest, DownloadTarget, Error as SdkError, FileLicenseStore, License, LicenseStore,
26    Licensing, Platform, ReleaseAccess, ReleaseNotCovered, can_download,
27};
28use tauri::{AppHandle, Emitter, Manager, Runtime};
29
30use crate::error::{Error, Result};
31use crate::models::{
32    AddOnsPhase, AddOnsState, DeviceInfo, Failure, LicenseState, LicenseView, LockReason, NotCovered,
33    PendingActivation, Status, UpdateHint, UpdateInfo,
34};
35use crate::ui::Ui;
36
37/// Emitted with a [`LicenseState`] payload whenever anything in it changes.
38pub const STATE_CHANGED_EVENT: &str = "moonbase://state-changed";
39
40/// Poll every 2 seconds: the user is switching back from the browser, so faster
41/// gains nothing (and the SDK holds each request to one poll per second anyway).
42const POLL_INTERVAL: Duration = Duration::from_secs(2);
43const WATCH_INTERVAL: Duration = Duration::from_secs(2);
44
45/// How long after opening an add-on's buy link a return to the app still counts
46/// as coming back from the purchase.
47const PURCHASE_WINDOW: Duration = Duration::from_secs(60 * 60);
48
49pub(crate) struct Settings {
50    pub grace_period: Duration,
51    pub check_interval: Duration,
52    pub application_version: Option<String>,
53    pub open_browser: bool,
54    pub auto_activate_sub_products: bool,
55}
56
57/// What the watch last saw of the license file.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59struct FileStamp {
60    exists: bool,
61    len: u64,
62    modified: Option<SystemTime>,
63}
64
65/// A license that is valid, but doesn't cover this version of the app.
66#[derive(Debug, Clone)]
67struct NotCoveredLicense {
68    license: License,
69    app_version: String,
70    release_access: Option<ReleaseAccess>,
71    upgrade_url: Option<String>,
72}
73
74impl NotCoveredLicense {
75    fn from_refusal(refusal: &ReleaseNotCovered, fallback: &License) -> Self {
76        let license = refusal.license().cloned().unwrap_or_else(|| fallback.clone());
77        Self {
78            app_version: refusal.app_version().to_owned(),
79            release_access: refusal.release_access().cloned().or_else(|| license.release_access.clone()),
80            upgrade_url: refusal.upgrade_url().map(str::to_owned),
81            license,
82        }
83    }
84
85    fn view(&self) -> NotCovered {
86        NotCovered {
87            license: LicenseView::from(&self.license),
88            app_version: self.app_version.clone(),
89            release_access: self.release_access.clone(),
90            upgrade_url: self.upgrade_url.clone(),
91        }
92    }
93}
94
95/// Where this device stands with its license: one of these at a time, so no two
96/// can contradict each other.
97#[derive(Debug, Clone)]
98enum Standing {
99    /// No license, and why, when there was one.
100    Unlicensed(Option<LockReason>),
101    /// A license is in force.
102    Licensed(License),
103    /// A trial that ended, kept for display.
104    TrialExpired(License),
105    /// A license that doesn't cover this version of the app: kept, but locked.
106    NotCovered(NotCoveredLicense),
107}
108
109impl Default for Standing {
110    fn default() -> Self {
111        Self::Unlicensed(None)
112    }
113}
114
115impl Standing {
116    /// The license in force.
117    fn license(&self) -> Option<&License> {
118        match self {
119            Self::Licensed(license) => Some(license),
120            _ => None,
121        }
122    }
123
124    /// The license this device holds: the one in force, or one that doesn't cover
125    /// this version of the app.
126    fn held(&self) -> Option<&License> {
127        match self {
128            Self::Licensed(license) => Some(license),
129            Self::NotCovered(not_covered) => Some(&not_covered.license),
130            Self::Unlicensed(_) | Self::TrialExpired(_) => None,
131        }
132    }
133
134    fn not_covered(&self) -> Option<&NotCoveredLicense> {
135        match self {
136            Self::NotCovered(not_covered) => Some(not_covered),
137            _ => None,
138        }
139    }
140
141    fn lock_reason(&self) -> Option<LockReason> {
142        match self {
143            Self::Unlicensed(reason) => *reason,
144            Self::Licensed(_) => None,
145            Self::TrialExpired(_) => Some(LockReason::Expired),
146            Self::NotCovered(_) => Some(LockReason::NotCovered),
147        }
148    }
149}
150
151#[derive(Default)]
152struct Inner {
153    loading: bool,
154    standing: Standing,
155    /// From the click until the activation completes or is cancelled.
156    activation: Option<PendingActivation>,
157    pending: Option<ActivationRequest>,
158    /// The generation of the deactivation in flight, so only that one clears it.
159    deactivating: Option<u64>,
160    error: Option<Failure>,
161    refreshes_in_flight: usize,
162    last_grace_check: Option<Instant>,
163    file_stamp: Option<FileStamp>,
164    /// Where to buy the upgrade the update screen last offered.
165    update_upgrade_url: Option<String>,
166
167    add_ons: AddOnsState,
168    /// Bumped by every list, and when the license they were listed for goes, so a
169    /// list that lands late is dropped.
170    add_ons_generation: u64,
171    /// Numbers each add-on activation or trial, and the one `busy_id` belongs to.
172    add_on_calls: u64,
173    add_on_call: u64,
174    /// Add-ons that ran out and were re-checked for, once each.
175    lapses_checked: HashSet<String>,
176    /// Set once an add-on's buy link opened, until the app comes back to the front.
177    purchase_opened_at: Option<Instant>,
178    /// When the app last went to the background since.
179    left_for_purchase: Option<Instant>,
180    /// The ends the screens count down to, and the days to each as of the last
181    /// watch tick.
182    countdown: (Vec<SystemTime>, Vec<u64>),
183}
184
185struct Shared<R: Runtime> {
186    app: AppHandle<R>,
187    licensing: Arc<Licensing>,
188    store: FileLicenseStore,
189    settings: Settings,
190    inner: Mutex<Inner>,
191    generation: AtomicU64,
192    licensed: AtomicBool,
193    shutdown: AtomicBool,
194    /// Taken around snapshot-and-send, so events leave in the order their
195    /// snapshots were taken.
196    emit_lock: Mutex<()>,
197    /// Held by a re-check and an add-on call for the whole of their work, from the
198    /// request until the answer is applied, so answers land here in the order they
199    /// reached the store. The store's own lock, which they also hold from the
200    /// request until the write, orders them against other instances.
201    online_calls: Mutex<()>,
202    /// The active add-ons as last emitted, so the watch can tell when one ran out.
203    emitted_sub_products: Mutex<Vec<String>>,
204    /// The built-in activation window, unless the app turned it off.
205    ui: Option<Ui>,
206}
207
208/// The plugin's license controller, managed as Tauri state. Reach it from Rust
209/// with [`MoonbaseExt::moonbase`](crate::MoonbaseExt::moonbase) to gate features or
210/// drive activation without the webview.
211///
212/// Every method that talks to Moonbase or the disk blocks; call those from a
213/// worker thread or `spawn_blocking`. [`state`](Self::state),
214/// [`is_licensed`](Self::is_licensed), [`license`](Self::license) and
215/// [`has_sub_product`](Self::has_sub_product) never block on I/O.
216pub struct Moonbase<R: Runtime>(Arc<Shared<R>>);
217
218impl<R: Runtime> Clone for Moonbase<R> {
219    fn clone(&self) -> Self {
220        Self(self.0.clone())
221    }
222}
223
224enum Activity {
225    StartActivation,
226    Activation,
227    Deactivation,
228    OfflineActivation,
229}
230
231fn busy_status(status: u16) -> bool {
232    status == 408 || status == 429 || status >= 500
233}
234
235fn failure(activity: Activity, error: &SdkError) -> Failure {
236    let message = match (activity, error) {
237        (_, SdkError::InsufficientDeviceIdentity { .. }) => {
238            "This computer can't be identified, so it can't be activated. Contact the developer for help.".into()
239        }
240        (Activity::Deactivation, SdkError::StoreClosed(_)) => {
241            "This store has closed, so this computer can't be deactivated.".into()
242        }
243        (_, SdkError::StoreClosed(_)) => "This store has closed, so activation isn't available.".into(),
244
245        (Activity::StartActivation, SdkError::Api(api)) if busy_status(api.status_code()) => {
246            "Moonbase couldn't start activation right now. Try again in a minute.".into()
247        }
248        (Activity::StartActivation, SdkError::Api(_)) => {
249            "Couldn't reach Moonbase to start activation. Check your internet connection and try again.".into()
250        }
251        (Activity::StartActivation, error) if error.is_license_rejection() => {
252            "Activation isn't available for this product right now. If this keeps happening, contact the developer."
253                .into()
254        }
255
256        (Activity::Activation, SdkError::ActivationRequestEnded(_)) => {
257            "This activation expired or was cancelled. Activate again to continue.".into()
258        }
259        (Activity::Activation, SdkError::LicenseExpired(_)) => {
260            "This license has expired, so it can't be activated.".into()
261        }
262        (Activity::Activation, error) if error.is_license_invalid() => {
263            "Activation was rejected. If this keeps happening, contact the developer.".into()
264        }
265
266        (Activity::Deactivation, SdkError::Api(api)) if busy_status(api.status_code()) => {
267            "Moonbase couldn't deactivate right now. Try again in a minute.".into()
268        }
269        (Activity::Deactivation, SdkError::Api(_)) => {
270            "Couldn't reach Moonbase to deactivate. Try again when you're online.".into()
271        }
272
273        (Activity::OfflineActivation, SdkError::ReleaseNotCovered(_)) => {
274            "That license doesn't cover this version.".into()
275        }
276        (Activity::OfflineActivation, _) => "That license file isn't valid for this device.".into(),
277
278        // Nobody anticipated it, so the most useful thing is its own words.
279        (Activity::StartActivation, error) => format!("Activation couldn't start: {error}"),
280        (Activity::Activation, error) => format!("Activation failed: {error}"),
281        (Activity::Deactivation, error) => format!("Couldn't deactivate: {error}"),
282    };
283    Failure { message, detail: error.to_string(), kind: Some(error.error_type()) }
284}
285
286/// Copy for an add-on request that got no answer about the add-on: the
287/// connection, Moonbase, or this machine. `lead` says what failed ("Couldn't
288/// activate Drum Kit"), and `not_found` what a 404 means for this request.
289fn add_on_failure(error: &SdkError, lead: &str, not_found: &str) -> String {
290    match error {
291        SdkError::Api(api) if api.status_code() == 404 => not_found.into(),
292        SdkError::Api(api) if busy_status(api.status_code()) => {
293            "Moonbase couldn't do that right now. Try again in a minute.".into()
294        }
295        SdkError::Api(_) => "Couldn't reach Moonbase. Check your internet connection and try again.".into(),
296        SdkError::StoreClosed(_) => "This store has closed, so add-ons aren't available.".into(),
297        SdkError::InsufficientDeviceIdentity { .. } => {
298            "This computer can't be identified. Contact the developer for help.".into()
299        }
300        error if error.is_license_rejection() || matches!(error, SdkError::Configuration(_)) => {
301            format!("{lead}. If this keeps happening, contact the developer.")
302        }
303        error => format!("{lead}: {error}"),
304    }
305}
306
307/// Activation can never complete after these; anything else is retried.
308fn ends_activation(error: &SdkError) -> bool {
309    error.is_license_rejection()
310        || matches!(
311            error,
312            SdkError::ActivationRequestEnded(_)
313                | SdkError::StoreClosed(_)
314                | SdkError::InsufficientDeviceIdentity { .. }
315                | SdkError::Configuration(_)
316        )
317}
318
319fn age(time: SystemTime) -> Duration {
320    SystemTime::now().duration_since(time).unwrap_or(Duration::ZERO)
321}
322
323/// Whole days from now until `when`, rounded up, and 0 once it has passed.
324fn days_until(when: SystemTime) -> u64 {
325    when.duration_since(SystemTime::now()).map_or(0, |left| left.as_secs().div_ceil(86_400))
326}
327
328/// The add-ons to unlock on this device as of `now`, by their own part of their
329/// product ID.
330fn active_sub_products(license: &License, now: SystemTime) -> Vec<String> {
331    let mut active: Vec<String> = Vec::new();
332    for entry in &license.sub_products {
333        if license.has_sub_product_at(&entry.id, now) && !active.contains(&entry.id) {
334            active.push(entry.id.clone());
335        }
336    }
337    active
338}
339
340/// What validating the stored license at startup concluded.
341#[derive(Default)]
342struct StartupOutcome {
343    license: Option<License>,
344    expired_trial: Option<License>,
345    not_covered: Option<NotCoveredLicense>,
346    delete_expired_offline: bool,
347    reason: Option<LockReason>,
348}
349
350/// Take a stored license judged on this device alone, as the SDK's own fallbacks
351/// take one: only when it covers this version of the app. One that doesn't stays
352/// on disk, and locks.
353fn accept_locally(licensing: &Licensing, license: License, outcome: &mut StartupOutcome) {
354    match licensing.validator().ensure_covers_application_version(&license) {
355        Ok(()) => outcome.license = Some(license),
356        Err(SdkError::ReleaseNotCovered(refusal)) => {
357            // Kept on disk: it still covers the versions it did.
358            log::info!("moonbase: the stored license doesn't cover this version: {refusal}");
359            outcome.not_covered = Some(NotCoveredLicense::from_refusal(&refusal, &license));
360        }
361        Err(error) => {
362            log::warn!("moonbase: stored license rejected: {error}");
363            outcome.reason = Some(LockReason::Invalid);
364        }
365    }
366}
367
368fn check_stored_license(
369    licensing: &Licensing,
370    grace_period: Duration,
371    still_current: impl Fn() -> bool,
372) -> StartupOutcome {
373    let mut outcome = StartupOutcome::default();
374    let stored = match licensing.store().load_local_license() {
375        Ok(Some(stored)) => stored,
376        Ok(None) => return outcome,
377        Err(error) => {
378            log::warn!("moonbase: loading the stored license failed: {error}");
379            return outcome;
380        }
381    };
382
383    // Peek without failing on a past `exp`, to tell an expired offline license
384    // (dead for good) from an online one (refreshable) and from one that cannot be
385    // trusted at all.
386    let peek = match licensing.validate_token_local_allow_expired(&stored.token) {
387        Ok(peek) => peek,
388        Err(SdkError::InsufficientDeviceIdentity { .. }) => {
389            // Nothing is wrong with the token: this machine cannot identify itself.
390            log::warn!("moonbase: could not identify this device, so the stored license could not be checked");
391            return outcome;
392        }
393        Err(error) => {
394            // Tampered, or bound to another device: locked, but left on disk.
395            log::warn!("moonbase: stored license rejected: {error}");
396            outcome.reason = Some(LockReason::Invalid);
397            return outcome;
398        }
399    };
400
401    if peek.method == ActivationMethod::Offline {
402        if peek.is_expired() {
403            log::info!("moonbase: the stored offline license has expired; removing it");
404            outcome.delete_expired_offline = true;
405            outcome.reason = Some(LockReason::Expired);
406        } else {
407            accept_locally(licensing, peek, &mut outcome);
408        }
409        return outcome;
410    }
411
412    // Persist the refresh only while nothing has superseded the check, so a license
413    // the user cleared meanwhile is not written back.
414    match licensing.validate_token_online_with(&stored.token, still_current) {
415        Ok(license) => outcome.license = Some(license),
416        Err(SdkError::ReleaseNotCovered(refusal)) => {
417            // Good, but not for this version: locked, and kept on disk (the SDK
418            // already stored the copy Moonbase sent).
419            log::info!("moonbase: the stored license doesn't cover this version: {refusal}");
420            outcome.not_covered = Some(NotCoveredLicense::from_refusal(&refusal, &peek));
421        }
422        Err(SdkError::LicenseExpired(_)) => {
423            outcome.reason = Some(LockReason::Expired);
424            if peek.trial {
425                outcome.expired_trial = Some(peek);
426            }
427        }
428        Err(error) if error.is_license_invalid() => {
429            log::warn!("moonbase: re-validating the stored license failed: {error}");
430            outcome.reason = Some(LockReason::Invalid);
431        }
432        Err(SdkError::Storage(error)) => {
433            // Our own file lock failed, which says nothing about the license: treat
434            // it like being offline, as the SDK's fallback within the grace period
435            // does, version check included. (The token passed local validation, its
436            // expiry and device binding, to get here.)
437            log::warn!("moonbase: re-validating the stored license failed: {error}");
438            if age(peek.validated_at) <= grace_period {
439                accept_locally(licensing, peek, &mut outcome);
440            } else {
441                outcome.reason = Some(LockReason::Unverified);
442            }
443        }
444        Err(error) => {
445            // Unreachable past the grace period.
446            log::warn!("moonbase: re-validating the stored license failed: {error}");
447            outcome.reason = Some(LockReason::Unverified);
448        }
449    }
450    outcome
451}
452
453/// What an add-on activation or trial came to, once applied.
454enum AddOnOutcome {
455    Applied {
456        relist: bool,
457    },
458    /// The license it was asked from was deactivated, cleared or replaced since.
459    Changed,
460    Failed {
461        error: SdkError,
462        recheck: bool,
463    },
464}
465
466const LICENSE_CHANGED: &str = "Your license changed while this was in progress.";
467
468impl<R: Runtime> Moonbase<R> {
469    pub(crate) fn new(
470        app: AppHandle<R>,
471        licensing: Licensing,
472        store: FileLicenseStore,
473        settings: Settings,
474        ui: Option<Ui>,
475    ) -> Self {
476        Self(Arc::new(Shared {
477            app,
478            licensing: Arc::new(licensing),
479            store,
480            settings,
481            inner: Mutex::new(Inner::default()),
482            generation: AtomicU64::new(0),
483            licensed: AtomicBool::new(false),
484            shutdown: AtomicBool::new(false),
485            emit_lock: Mutex::new(()),
486            online_calls: Mutex::new(()),
487            emitted_sub_products: Mutex::default(),
488            ui,
489        }))
490    }
491
492    fn inner(&self) -> MutexGuard<'_, Inner> {
493        self.0.inner.lock().unwrap_or_else(PoisonError::into_inner)
494    }
495
496    fn online_turn(&self) -> MutexGuard<'_, ()> {
497        self.0.online_calls.lock().unwrap_or_else(PoisonError::into_inner)
498    }
499
500    fn bump(&self) -> u64 {
501        self.0.generation.fetch_add(1, Ordering::SeqCst) + 1
502    }
503
504    fn is_current(&self, generation: u64) -> bool {
505        self.0.generation.load(Ordering::SeqCst) == generation
506    }
507
508    fn emit(&self) {
509        let _ordered = self.0.emit_lock.lock().unwrap_or_else(PoisonError::into_inner);
510        let state = self.state();
511        state
512            .active_sub_products
513            .clone_into(&mut self.0.emitted_sub_products.lock().unwrap_or_else(PoisonError::into_inner));
514        if let Err(error) = self.0.app.emit(STATE_CHANGED_EVENT, &state) {
515            log::warn!("moonbase: could not emit the license state: {error}");
516        }
517        self.after_state_change(&state);
518    }
519
520    pub(crate) fn app(&self) -> &AppHandle<R> {
521        &self.0.app
522    }
523
524    pub(crate) fn ui(&self) -> Option<&Ui> {
525        self.0.ui.as_ref()
526    }
527
528    /// The SDK instance behind the plugin, for anything the plugin does not wrap.
529    pub fn licensing(&self) -> &Licensing {
530        &self.0.licensing
531    }
532
533    /// Is there a valid license right now? Lock-free, so cheap enough to check
534    /// anywhere you gate a feature.
535    pub fn is_licensed(&self) -> bool {
536        self.0.licensed.load(Ordering::Acquire)
537    }
538
539    /// The license in force, if any.
540    pub fn license(&self) -> Option<License> {
541        self.inner().standing.license().cloned()
542    }
543
544    /// Whether to unlock an add-on on this device: the license in force lists it
545    /// as active and it hasn't expired. Takes the add-on's own part of its product
546    /// ID (`"drum-kit"`) or its whole ID (`"my-app.drum-kit"`). False while no
547    /// license is in force.
548    pub fn has_sub_product(&self, sub_product_id: &str) -> bool {
549        self.inner().standing.license().is_some_and(|license| license.has_sub_product(sub_product_id))
550    }
551
552    /// The add-ons [`has_sub_product`](Self::has_sub_product) is true for right
553    /// now, by their own part of their product ID.
554    pub fn active_sub_products(&self) -> Vec<String> {
555        self.inner()
556            .standing
557            .license()
558            .map(|license| active_sub_products(license, SystemTime::now()))
559            .unwrap_or_default()
560    }
561
562    /// Where the license is kept.
563    pub fn license_file(&self) -> PathBuf {
564        self.0.store.path().to_owned()
565    }
566
567    /// A snapshot of the license state, as the UI sees it.
568    pub fn state(&self) -> LicenseState {
569        let inner = self.inner();
570        let status = if inner.loading {
571            Status::Loading
572        } else if inner.activation.is_some() {
573            Status::Activating
574        } else {
575            match &inner.standing {
576                Standing::Licensed(license) if license.trial => Status::Trial,
577                Standing::Licensed(_) => Status::Licensed,
578                Standing::TrialExpired(_) => Status::TrialExpired,
579                Standing::NotCovered(_) => Status::NotCovered,
580                Standing::Unlicensed(_) => Status::Unlicensed,
581            }
582        };
583        let license = inner.standing.license();
584        let update = match (license, &self.0.settings.application_version) {
585            (Some(license), Some(current)) => license
586                .licensed_product
587                .current_release_version
588                .as_ref()
589                .filter(|latest| update_available(current, latest))
590                .map(|latest| UpdateHint { current_version: current.clone(), latest_version: latest.clone() }),
591            _ => None,
592        };
593        LicenseState {
594            status,
595            licensed: license.is_some(),
596            license: license.map(LicenseView::from),
597            expired_trial: match &inner.standing {
598                Standing::TrialExpired(trial) => Some(LicenseView::from(trial)),
599                _ => None,
600            },
601            not_covered: inner.standing.not_covered().map(NotCoveredLicense::view),
602            lock_reason: inner.standing.lock_reason(),
603            activation: inner.activation.clone(),
604            busy: inner.deactivating.is_some(),
605            refreshing: inner.refreshes_in_flight > 0,
606            error: inner.error.clone(),
607            update,
608            active_sub_products: license
609                .map(|license| active_sub_products(license, SystemTime::now()))
610                .unwrap_or_default(),
611            add_ons: inner.add_ons.clone(),
612        }
613    }
614
615    /// Whether `copy` is an older copy of the activation held, licensed or not
616    /// covered.
617    fn is_older_than_held(inner: &Inner, copy: &License) -> bool {
618        inner
619            .standing
620            .held()
621            .is_some_and(|held| held.activation_id == copy.activation_id && copy.validated_at < held.validated_at)
622    }
623
624    fn reset_add_ons(inner: &mut Inner) {
625        inner.add_ons_generation += 1;
626        inner.add_ons = AddOnsState::default();
627        inner.add_on_call = 0;
628        inner.lapses_checked.clear();
629        inner.purchase_opened_at = None;
630        inner.left_for_purchase = None;
631    }
632
633    fn set_standing(&self, inner: &mut Inner, standing: Standing) {
634        let same_activation = matches!(
635            (inner.standing.license(), standing.license()),
636            (Some(current), Some(next)) if current.activation_id == next.activation_id
637        );
638        self.0.licensed.store(standing.license().is_some(), Ordering::Release);
639        // The add-ons were listed for the activation that just went or was
640        // replaced, along with anything still on its way for it.
641        if !same_activation {
642            Self::reset_add_ons(inner);
643        }
644        inner.standing = standing;
645    }
646
647    fn set_license(&self, inner: &mut Inner, license: License) {
648        self.set_standing(inner, Standing::Licensed(license));
649    }
650
651    /// Drop the license and say why. The file stays, so the next launch checks it
652    /// again.
653    fn lock(&self, inner: &mut Inner, reason: Option<LockReason>) {
654        self.set_standing(inner, Standing::Unlicensed(reason));
655    }
656
657    fn show_trial_expired(&self, inner: &mut Inner, trial: License) {
658        self.set_standing(inner, Standing::TrialExpired(trial));
659    }
660
661    /// Lock like an ended trial, but keep the license stored: it still covers the
662    /// versions it did, and an upgrade makes it cover this one.
663    fn show_not_covered(&self, inner: &mut Inner, not_covered: NotCoveredLicense) {
664        self.set_standing(inner, Standing::NotCovered(not_covered));
665        inner.error = None;
666    }
667
668    /// Stop polling and drop everything in flight: their results land on nothing.
669    fn end_activation_flows(&self, inner: &mut Inner) {
670        inner.activation = None;
671        inner.pending = None;
672        inner.deactivating = None;
673        Self::reset_add_ons(inner);
674        self.bump();
675    }
676
677    fn stamp_file(&self) -> FileStamp {
678        match std::fs::metadata(self.0.store.path()) {
679            Ok(metadata) if metadata.is_file() => {
680                FileStamp { exists: true, len: metadata.len(), modified: metadata.modified().ok() }
681            }
682            _ => FileStamp { exists: false, len: 0, modified: None },
683        }
684    }
685
686    /// Whether the store still holds this activation. If it no longer does, another
687    /// instance deactivated or replaced it after a request went out, and writing
688    /// that request's answer would bring a removed license back.
689    fn stores_activation(store: &dyn LicenseStore, activation_id: &str) -> bool {
690        store.load_local_license().ok().flatten().is_some_and(|stored| stored.activation_id == activation_id)
691    }
692
693    fn delete_stored(&self, inner: &mut Inner, only_activation: Option<&str>) {
694        let store = self.0.licensing.store();
695        let result = store.lock_for_update().and_then(|_guard| match only_activation {
696            Some(activation_id) => match store.load_local_license()? {
697                Some(stored) if stored.activation_id == activation_id => store.delete_local_license(),
698                _ => Ok(()),
699            },
700            None => store.delete_local_license(),
701        });
702        if let Err(error) = result {
703            log::warn!("moonbase: couldn't delete the stored license: {error}");
704        }
705        inner.file_stamp = Some(self.stamp_file());
706    }
707
708    pub(crate) fn start(&self) {
709        {
710            let mut inner = self.inner();
711            inner.loading = true;
712            // The first look is taken before the load, so a change that lands
713            // while it runs is still seen.
714            inner.file_stamp = Some(self.stamp_file());
715        }
716        let generation = self.bump();
717
718        let this = self.clone();
719        thread::spawn(move || {
720            let outcome =
721                check_stored_license(&this.0.licensing, this.0.settings.grace_period, || this.is_current(generation));
722            let mut inner = this.inner();
723            inner.loading = false;
724            // Superseded: the user did something while the check ran.
725            if this.is_current(generation) {
726                if outcome.delete_expired_offline {
727                    this.delete_stored(&mut inner, None);
728                }
729                if let Some(trial) = outcome.expired_trial {
730                    this.show_trial_expired(&mut inner, trial);
731                } else if let Some(not_covered) = outcome.not_covered {
732                    this.show_not_covered(&mut inner, not_covered);
733                } else if let Some(license) = outcome.license {
734                    this.set_license(&mut inner, license);
735                } else {
736                    this.lock(&mut inner, outcome.reason);
737                }
738            }
739            drop(inner);
740            this.emit();
741        });
742
743        let this = self.clone();
744        thread::spawn(move || {
745            while !this.0.shutdown.load(Ordering::Acquire) {
746                thread::sleep(WATCH_INTERVAL);
747                this.watch();
748            }
749        });
750    }
751
752    pub(crate) fn shut_down(&self) {
753        self.0.shutdown.store(true, Ordering::Release);
754        self.bump();
755        // A worker waiting its turn on the license lock while another instance
756        // re-validates stops waiting, rather than hold up the exit.
757        self.0.store.cancel_lock_waits();
758    }
759
760    /// Start a browser activation: create the request, open the browser, and poll
761    /// in the background until it completes. Returns once the request exists, with
762    /// its link in [`LicenseState::activation`].
763    ///
764    /// [`ActivationMethod::Offline`] mints an offline license through the same
765    /// browser flow, for a machine that has network now but will not later.
766    pub fn activate(&self, method: ActivationMethod) -> Result<LicenseState> {
767        let generation = {
768            let mut inner = self.inner();
769            if inner.deactivating.is_some() {
770                return Err(Error::Busy("A deactivation is in progress".into()));
771            }
772            inner.activation = Some(PendingActivation { browser_url: None, method, browser_open_failed: false });
773            inner.pending = None;
774            inner.error = None;
775            self.bump()
776        };
777        self.emit();
778
779        let result = self.0.licensing.request_activation(method);
780
781        let mut inner = self.inner();
782        if !self.is_current(generation) {
783            drop(inner);
784            return Ok(self.state());
785        }
786        let request = match result {
787            Ok(request) => request,
788            Err(error) => {
789                log::warn!("moonbase: request_activation failed: {error}");
790                inner.activation = None;
791                inner.error = Some(failure(Activity::StartActivation, &error));
792                drop(inner);
793                self.emit();
794                return Err(error.into());
795            }
796        };
797        inner.pending = Some(request.clone());
798        inner.activation = Some(PendingActivation {
799            browser_url: Some(request.browser_url.clone()),
800            method,
801            browser_open_failed: false,
802        });
803        drop(inner);
804
805        if self.0.settings.open_browser
806            && let Err(error) = self.open_url(&request.browser_url)
807        {
808            // Windows with no default browser set, say. The screen then offers the
809            // link to copy, for any browser on this computer or another.
810            log::warn!("moonbase: couldn't open the browser for activation ({error}); the state carries the link");
811            let mut inner = self.inner();
812            if self.is_current(generation)
813                && let Some(activation) = &mut inner.activation
814            {
815                activation.browser_open_failed = true;
816            }
817        }
818        self.spawn_poll(generation, request);
819        self.emit();
820        Ok(self.state())
821    }
822
823    /// Open a link in the browser: through the opener plugin's own handle when the
824    /// app registered it (which also covers mobile), and the desktop default
825    /// browser otherwise.
826    pub(crate) fn open_url(&self, url: &str) -> std::result::Result<(), String> {
827        let opened = match self.0.app.try_state::<tauri_plugin_opener::Opener<R>>() {
828            Some(opener) => opener.open_url(url, None::<String>),
829            None => tauri_plugin_opener::open_url(url, None::<&str>),
830        };
831        opened.map_err(|error| error.to_string())
832    }
833
834    fn spawn_poll(&self, generation: u64, request: ActivationRequest) {
835        let this = self.clone();
836        thread::spawn(move || {
837            loop {
838                thread::sleep(POLL_INTERVAL);
839                if this.0.shutdown.load(Ordering::Acquire) || !this.is_current(generation) {
840                    return;
841                }
842                // Stored by the SDK under the store's lock, unless superseded first.
843                match this.0.licensing.complete_activation_with(&request, || this.is_current(generation)) {
844                    Ok(None) => {}
845                    Ok(Some(license)) => return this.on_activated(generation, license),
846                    Err(error) if ends_activation(&error) => {
847                        log::warn!("moonbase: activation rejected during polling: {error}");
848                        let mut inner = this.inner();
849                        if this.is_current(generation) {
850                            inner.activation = None;
851                            inner.pending = None;
852                            inner.error = Some(failure(Activity::Activation, &error));
853                        }
854                        drop(inner);
855                        return this.emit();
856                    }
857                    // Unreachable, rate limited, failing: keep waiting.
858                    Err(error) => log::debug!("moonbase: activation poll failed, still waiting: {error}"),
859                }
860            }
861        });
862    }
863
864    fn on_activated(&self, generation: u64, license: License) {
865        let mut inner = self.inner();
866        if !self.is_current(generation) {
867            return;
868        }
869        self.end_activation_flows(&mut inner);
870        inner.file_stamp = Some(self.stamp_file());
871        self.set_license(&mut inner, license);
872        inner.error = None;
873        drop(inner);
874        self.emit();
875    }
876
877    /// Stop waiting for a browser activation. A license held from before stays.
878    pub fn cancel_activation(&self) -> LicenseState {
879        let mut inner = self.inner();
880        // Nothing to cancel: bumping the generation would supersede unrelated work.
881        if inner.activation.is_none() {
882            return self.state_with(inner);
883        }
884        inner.activation = None;
885        inner.pending = None;
886        inner.error = None;
887        self.bump();
888        drop(inner);
889        self.emit();
890        self.state()
891    }
892
893    /// Re-check the license against Moonbase and refresh its entitlements in
894    /// place: call it after a purchase so new features and add-ons load without a
895    /// restart. `force` skips the online-validation throttle.
896    ///
897    /// A revoked or lapsed license locks, and so does one that cannot be checked
898    /// once the offline grace period has run out; within it a network failure
899    /// keeps the license and returns the error. No-op for offline licenses, and
900    /// while an activation is in progress (it brings a fresh license of its own).
901    ///
902    /// With [`Status::NotCovered`] it re-checks the license that doesn't cover this
903    /// version, so once the customer has bought the upgrade the app unlocks.
904    pub fn refresh(&self, force: bool) -> Result<LicenseState> {
905        let (generation, current, not_covered_before) = {
906            let mut inner = self.inner();
907            if inner.activation.is_some() {
908                return Ok(self.state_with(inner));
909            }
910            if inner.deactivating.is_some() {
911                return Err(Error::Busy("A deactivation is in progress".into()));
912            }
913            let Some(current) = inner.standing.held().cloned() else {
914                return Err(Error::NotLicensed);
915            };
916            if current.method == ActivationMethod::Offline {
917                return Ok(self.state_with(inner));
918            }
919            inner.refreshes_in_flight += 1;
920            (self.bump(), current, inner.standing.license().is_none())
921        };
922        if not_covered_before {
923            self.emit(); // `refreshing` shows on the upgrade screen
924        }
925
926        let _turn = self.online_turn();
927        let store = self.0.licensing.store();
928        // Asked under the store's lock, just before an answer is stored: not when
929        // this check was superseded (deactivate and forget move the generation on
930        // first, and delete under the same lock after), and not over another
931        // activation stored meanwhile.
932        let still_current = || self.is_current(generation) && Self::stores_activation(store, &current.activation_id);
933
934        let result = if force {
935            // Past the throttle, straight to the API, but holding the store's lock
936            // from the request until the answer is stored, as the SDK does for its
937            // own checks: answers then reach the store in the order the server gave
938            // them, whichever instance asked.
939            store.lock_for_update().and_then(|_guard| {
940                let result = self.0.licensing.client().validate_token_online(&current.token);
941                let answer = match &result {
942                    Ok(refreshed) => Some(refreshed),
943                    // The copy Moonbase sent with the refusal still covers the
944                    // versions it did, so it is stored the same way.
945                    Err(SdkError::ReleaseNotCovered(refusal)) => refusal.license(),
946                    Err(_) => None,
947                };
948                if let Some(answer) = answer
949                    && still_current()
950                    && let Err(error) = store.store_local_license(answer)
951                {
952                    log::warn!("moonbase: couldn't persist the refreshed license: {error}");
953                }
954                result
955            })
956        } else {
957            // Throttled and grace-period aware, and stores what it got the same way.
958            self.0.licensing.validate_token_online_with(&current.token, still_current)
959        };
960
961        let mut inner = self.inner();
962        inner.refreshes_in_flight -= 1;
963        if !self.is_current(generation) {
964            drop(inner);
965            if not_covered_before {
966                self.emit(); // `refreshing` may have cleared
967            }
968            return Ok(self.state());
969        }
970        // A throttled check can answer with the very license it was asked about.
971        // When the license held here has moved on since (an add-on call landed,
972        // say), that answer is the older of the two: keep what is held. A forced
973        // check always reached the server, after anything held here, so its answer
974        // applies even when it matches the license it was asked about.
975        if let Ok(refreshed) = &result
976            && !force
977            && refreshed.token == current.token
978            && inner
979                .standing
980                .license()
981                .is_some_and(|held| held.activation_id == refreshed.activation_id && held.token != current.token)
982        {
983            return Ok(self.state_with(inner));
984        }
985        let outcome = match result {
986            Ok(refreshed) => {
987                inner.file_stamp = Some(self.stamp_file());
988                if not_covered_before {
989                    inner.error = None;
990                }
991                self.set_license(&mut inner, refreshed);
992                Ok(())
993            }
994            Err(SdkError::ReleaseNotCovered(refusal)) => {
995                let not_covered = NotCoveredLicense::from_refusal(&refusal, &current);
996                if Self::is_older_than_held(&inner, &not_covered.license) {
997                    // A newer copy arrived while this answer was on its way, such
998                    // as a sibling's after the upgrade, and outranks it.
999                    log::debug!("moonbase: dropped a re-validation answer older than the license held: {refusal}");
1000                } else {
1001                    log::info!("moonbase: the license doesn't cover this version on re-validation: {refusal}");
1002                    self.show_not_covered(&mut inner, not_covered);
1003                }
1004                Ok(())
1005            }
1006            Err(SdkError::LicenseExpired(message)) if current.trial => {
1007                log::info!("moonbase: the trial ended on re-validation: {message}");
1008                self.show_trial_expired(&mut inner, current);
1009                Ok(())
1010            }
1011            Err(error) if !error.is_license_rejection() && inner.standing.not_covered().is_some() => {
1012                // "Check again" on the upgrade screen got no answer. The app is
1013                // locked already, so it just says so and stays put.
1014                log::warn!("moonbase: couldn't re-check the license that doesn't cover this version: {error}");
1015                inner.error = Some(Failure {
1016                    message: "Couldn't reach Moonbase to check your license. Try again when you're online.".into(),
1017                    detail: error.to_string(),
1018                    kind: Some(error.error_type()),
1019                });
1020                Err(error)
1021            }
1022            Err(error) if error.is_license_rejection() => {
1023                log::warn!("moonbase: license rejected on re-validation: {error}");
1024                let reason = if matches!(error, SdkError::LicenseExpired(_)) {
1025                    LockReason::Expired
1026                } else {
1027                    LockReason::Invalid
1028                };
1029                self.lock(&mut inner, Some(reason));
1030                Ok(())
1031            }
1032            Err(error) if age(current.validated_at) > self.0.settings.grace_period => {
1033                log::warn!("moonbase: couldn't re-validate within the offline grace period: {error}");
1034                self.lock(&mut inner, Some(LockReason::Unverified));
1035                Ok(())
1036            }
1037            // A network blip must not lock anyone out.
1038            Err(error) => Err(error),
1039        };
1040        drop(inner);
1041        self.emit();
1042        outcome.map(|()| self.state()).map_err(Error::from)
1043    }
1044
1045    fn state_with(&self, inner: MutexGuard<'_, Inner>) -> LicenseState {
1046        drop(inner);
1047        self.state()
1048    }
1049
1050    /// Free this device's seat and lock. Offline and trial licenses cannot be
1051    /// revoked, so those are forgotten locally instead. When Moonbase cannot be
1052    /// reached the license is kept, and the error says so. Also hands back the seat
1053    /// of a license that doesn't cover this version of the app.
1054    pub fn deactivate(&self) -> Result<LicenseState> {
1055        let (generation, license) = {
1056            let mut inner = self.inner();
1057            let Some(license) = inner.standing.held().cloned() else {
1058                return Err(Error::NotLicensed);
1059            };
1060            if license.method == ActivationMethod::Offline || license.trial {
1061                drop(inner);
1062                return Ok(self.forget());
1063            }
1064            if inner.deactivating.is_some() {
1065                return Err(Error::Busy("A deactivation is already in progress".into()));
1066            }
1067            // The activation's poll would be superseded and strand it.
1068            if inner.activation.is_some() {
1069                return Err(Error::Busy("Finish or cancel the activation first".into()));
1070            }
1071            inner.error = None;
1072            let generation = self.bump();
1073            inner.deactivating = Some(generation);
1074            (generation, license)
1075        };
1076        self.emit();
1077
1078        let result = self.0.licensing.revoke_activation(&license.token);
1079
1080        let mut inner = self.inner();
1081        if inner.deactivating == Some(generation) {
1082            inner.deactivating = None;
1083        }
1084        if !self.is_current(generation) {
1085            drop(inner);
1086            self.emit();
1087            return Ok(self.state());
1088        }
1089        let outcome = match result {
1090            // A verdict means the server will never accept the token again, so
1091            // forgetting it here is all that is left to do.
1092            Ok(()) | Err(SdkError::OperationNotSupported(_)) => Ok(()),
1093            Err(error) if error.is_license_rejection() => Ok(()),
1094            Err(error) => {
1095                log::warn!("moonbase: revoke_activation failed, license kept: {error}");
1096                inner.error = Some(failure(Activity::Deactivation, &error));
1097                Err(error)
1098            }
1099        };
1100        if outcome.is_ok() {
1101            self.delete_stored(&mut inner, Some(&license.activation_id));
1102            self.lock(&mut inner, Some(LockReason::Deactivated));
1103        }
1104        drop(inner);
1105        self.emit();
1106        outcome.map(|()| self.state()).map_err(Error::from)
1107    }
1108
1109    /// Forget the license on this device only, without telling Moonbase.
1110    pub fn forget(&self) -> LicenseState {
1111        let mut inner = self.inner();
1112        self.end_activation_flows(&mut inner);
1113        self.delete_stored(&mut inner, None);
1114        self.lock(&mut inner, Some(LockReason::Deactivated));
1115        inner.error = None;
1116        drop(inner);
1117        self.emit();
1118        self.state()
1119    }
1120
1121    /// The device token ("machine file") for activating a machine with no network:
1122    /// save it as a `.dt` file the customer exchanges at
1123    /// `https://<tenant>.moonbase.sh/activate` for a license file, then pass that
1124    /// file's contents to [`activate_offline`](Self::activate_offline).
1125    pub fn device_token(&self) -> Result<String> {
1126        self.0.licensing.generate_device_token().map_err(Error::from)
1127    }
1128
1129    /// Activate from the contents of an offline license file.
1130    ///
1131    /// A license file for this device that doesn't cover this version of the app
1132    /// is stored anyway, as it still covers the versions it did, and the app locks
1133    /// with [`Status::NotCovered`]; the error says so.
1134    pub fn activate_offline(&self, license_file_contents: &str) -> Result<LicenseState> {
1135        // Stored by the SDK, a file that doesn't cover this version included.
1136        match self.0.licensing.import_offline_license(license_file_contents) {
1137            Ok(license) => {
1138                let mut inner = self.inner();
1139                self.end_activation_flows(&mut inner);
1140                inner.file_stamp = Some(self.stamp_file());
1141                self.set_license(&mut inner, license);
1142                inner.error = None;
1143                drop(inner);
1144                self.emit();
1145                Ok(self.state())
1146            }
1147            Err(SdkError::ReleaseNotCovered(refusal)) if refusal.license().is_some() => {
1148                // This device's license, for the versions it covers: kept, but locked.
1149                log::info!("moonbase: the offline license doesn't cover this version: {refusal}");
1150                let license = refusal.license().cloned().expect("checked above");
1151                let mut inner = self.inner();
1152                self.end_activation_flows(&mut inner);
1153                inner.file_stamp = Some(self.stamp_file());
1154                self.show_not_covered(&mut inner, NotCoveredLicense::from_refusal(&refusal, &license));
1155                drop(inner);
1156                self.emit();
1157                Err(SdkError::ReleaseNotCovered(refusal).into())
1158            }
1159            Err(error) => {
1160                log::warn!("moonbase: offline license file rejected: {error}");
1161                self.inner().error = Some(failure(Activity::OfflineActivation, &error));
1162                self.emit();
1163                Err(error.into())
1164            }
1165        }
1166    }
1167
1168    /// Offer the upgrade the app needs. With [`Status::NotCovered`], open the
1169    /// upgrade page Moonbase named for the license or, without one, start an online
1170    /// activation, whose browser page offers the upgrade where the store sells one
1171    /// and activates this device once a license covers this version. Otherwise open
1172    /// the upgrade page [`check_update`](Self::check_update) found for a new
1173    /// version the license doesn't cover. After the purchase,
1174    /// [`refresh`](Self::refresh) picks up the upgraded license.
1175    pub fn upgrade_license(&self) -> Result<LicenseState> {
1176        let (url, not_covered) = {
1177            let inner = self.inner();
1178            match inner.standing.not_covered() {
1179                Some(not_covered) => (not_covered.upgrade_url.clone(), true),
1180                None => (inner.update_upgrade_url.clone(), false),
1181            }
1182        };
1183        let Some(url) = url else {
1184            if not_covered {
1185                // Only Moonbase knows where to buy the upgrade. Its activation page
1186                // offers it to a signed-in customer, then activates this device.
1187                return self.activate(ActivationMethod::Online);
1188            }
1189            return Err(Error::Internal("There is no upgrade to offer".into()));
1190        };
1191        if let Err(error) = self.open_url(&url) {
1192            log::warn!("moonbase: couldn't open the browser for the upgrade: {error}");
1193            if not_covered {
1194                self.inner().error = Some(Failure {
1195                    message: format!("Couldn't open your browser. Upgrade at {url}"),
1196                    detail: error.clone(),
1197                    kind: None,
1198                });
1199                self.emit();
1200            }
1201            return Err(Error::Internal(format!("Couldn't open the browser: {error}")));
1202        }
1203        Ok(self.state())
1204    }
1205
1206    /// Clear the last failure, e.g. when the user dismisses its message.
1207    pub fn dismiss_error(&self) -> LicenseState {
1208        self.inner().error = None;
1209        self.emit();
1210        self.state()
1211    }
1212
1213    /// This device, for diagnostics.
1214    pub fn device(&self) -> DeviceInfo {
1215        let resolver = self.0.licensing.device_resolver();
1216        let (description, error) = match resolver.describe_device() {
1217            Ok(description) => (description, None),
1218            Err(error) => (None, Some(error.to_string())),
1219        };
1220        DeviceInfo { name: resolver.device_name(), platform: Platform::current().as_str().into(), description, error }
1221    }
1222
1223    /// Whether a newer release than this app is out, with its notes when it is.
1224    /// Only the notes need the network. For a customer whose licenses don't cover
1225    /// the new version, says so and where to upgrade.
1226    pub fn check_update(&self) -> Result<UpdateInfo> {
1227        let license = self.license().ok_or(Error::NotLicensed)?;
1228        let current = self.0.settings.application_version.clone().unwrap_or_default();
1229        let latest = license.licensed_product.current_release_version.clone();
1230        let mut info = UpdateInfo {
1231            available: latest.as_deref().is_some_and(|latest| update_available(&current, latest)),
1232            current_version: current,
1233            latest_version: latest.clone(),
1234            release_notes: String::new(),
1235            has_downloads: false,
1236            can_download: false,
1237            covered: true,
1238            release_access: None,
1239            upgrade_url: None,
1240        };
1241        if let (true, Some(latest)) = (info.available, latest) {
1242            let release = self.0.licensing.inventory().get_release(&latest, &license.token)?;
1243            // Moonbase judges this by all of the customer's licenses, as validating
1244            // the new version does: the seat moves to one that covers it.
1245            info.can_download = release.has_downloads && can_download(&license, &release);
1246            info.has_downloads = release.has_downloads;
1247            info.release_notes = release.description;
1248            info.covered = release.release_covered;
1249            info.release_access = release.release_access;
1250            info.upgrade_url.clone_from(&release.upgrade_url);
1251            self.inner().update_upgrade_url = release.upgrade_url;
1252        }
1253        Ok(info)
1254    }
1255
1256    /// A short-lived download link for the latest installer for this platform.
1257    ///
1258    /// Fails with [`moonbase::Error::ReleaseNotCovered`], with the upgrade link,
1259    /// when the customer's licenses don't cover the release.
1260    pub fn update_download_url(&self) -> Result<DownloadTarget> {
1261        let license = self.license().ok_or(Error::NotLicensed)?;
1262        match self.0.licensing.inventory().get_download_url(Platform::current().as_str(), &license.token) {
1263            Ok(target) => Ok(target),
1264            Err(SdkError::ReleaseNotCovered(refusal)) => {
1265                if let Some(url) = refusal.upgrade_url() {
1266                    self.inner().update_upgrade_url = Some(url.to_owned());
1267                }
1268                Err(SdkError::ReleaseNotCovered(refusal).into())
1269            }
1270            Err(error) => Err(error.into()),
1271        }
1272    }
1273
1274    // -- Add-ons: sub-products the merchant includes in the product's license. The
1275    // license in force says which ones this device may unlock; the list says what
1276    // else the customer can do: try one, buy one, or take a seat of one they own.
1277
1278    /// List the product's add-ons for this device into [`LicenseState::add_ons`],
1279    /// with their prices, ownership and trials, for a store or upgrade screen.
1280    ///
1281    /// Can take a seat: with
1282    /// [`auto_activate_sub_products`](crate::Builder::auto_activate_sub_products) on
1283    /// (the default), when the list shows an add-on the customer owns, with a seat
1284    /// free, that the license doesn't hold yet (one bought since the license was
1285    /// last checked), the license is re-checked once, which takes that seat as
1286    /// every check does, and the add-ons are listed again. With the option off it
1287    /// only reads.
1288    pub fn load_add_ons(&self) -> Result<LicenseState> {
1289        self.list_add_ons(true)
1290    }
1291
1292    fn list_add_ons(&self, pick_up_seats: bool) -> Result<LicenseState> {
1293        let (generation, token) = {
1294            let mut inner = self.inner();
1295            let Some(token) = inner.standing.license().map(|license| license.token.clone()) else {
1296                return Err(Error::NotLicensed);
1297            };
1298            inner.add_ons_generation += 1;
1299            inner.add_ons.phase = AddOnsPhase::Loading;
1300            inner.add_ons.error = None;
1301            (inner.add_ons_generation, token)
1302        };
1303        self.emit();
1304
1305        let result = self.0.licensing.list_sub_products(&token);
1306
1307        let (recheck, take_seat) = {
1308            let mut inner = self.inner();
1309            if inner.add_ons_generation != generation {
1310                // Superseded, or the license went.
1311                return Ok(self.state_with(inner));
1312            }
1313            inner.add_ons.phase = AddOnsPhase::Ready;
1314            let refused = match &result {
1315                Ok(catalog) => {
1316                    inner.add_ons.currency = Some(catalog.currency.clone());
1317                    inner.add_ons.add_ons.clone_from(&catalog.sub_products);
1318                    inner.add_ons.error = None;
1319                    false
1320                }
1321                Err(error) => {
1322                    log::warn!("moonbase: listing the add-ons failed: {error}");
1323                    inner.add_ons.error = Some(match error {
1324                        SdkError::LicenseExpired(_) => "Your license has expired.".into(),
1325                        error if error.is_license_invalid() => {
1326                            "Your license is no longer valid on this computer.".into()
1327                        }
1328                        error => add_on_failure(
1329                            error,
1330                            "Couldn't load the add-ons",
1331                            "Moonbase couldn't find the add-ons for this license.",
1332                        ),
1333                    });
1334                    error.is_license_rejection()
1335                }
1336            };
1337            // Bought since the license was last checked: checking it takes the
1338            // seat, as it would at the next launch. Once, so an add-on the server
1339            // still won't seat can't start a loop.
1340            let take_seat = result.is_ok() && pick_up_seats && self.lists_seat_to_take(&inner);
1341            (refused && inner.deactivating.is_none(), take_seat)
1342        };
1343        self.emit();
1344
1345        // The license itself was refused: re-check it, which locks as a refresh
1346        // does when the server stands by that.
1347        if recheck && let Err(error) = self.refresh(true) {
1348            log::debug!("moonbase: the re-check after a refused add-on list failed: {error}");
1349        }
1350        if take_seat {
1351            log::info!("moonbase: an add-on the customer owns has a seat free; re-checking the license to take it");
1352            return self.refresh_and_list();
1353        }
1354        result.map(|_| self.state()).map_err(Error::from)
1355    }
1356
1357    fn lists_seat_to_take(&self, inner: &Inner) -> bool {
1358        let Some(license) = inner.standing.license() else {
1359            return false;
1360        };
1361        if !self.0.settings.auto_activate_sub_products || license.trial || license.method != ActivationMethod::Online {
1362            return false;
1363        }
1364        let now = SystemTime::now();
1365        inner.add_ons.add_ons.iter().any(|entry| {
1366            let owned = entry.product.owned || entry.product.subscribed;
1367            if !entry.included_in_license || !owned || entry.revoked {
1368                return false;
1369            }
1370            // On a seat here already: nothing to take. A trial still unlocking one
1371            // the customer has since bought gives way to a seat at the next check.
1372            let held = license.find_sub_product(&entry.id);
1373            if held.is_some_and(|held| !held.trial) && license.has_sub_product_at(&entry.id, now) {
1374                return false;
1375            }
1376            // A seat is free, or the list says this device has one that the license
1377            // doesn't show (an answer that was never stored, say).
1378            entry.active || entry.seats.is_some_and(|seats| seats.remaining > 0)
1379        })
1380    }
1381
1382    /// Re-check the license, so an add-on bought since takes its seat, then list
1383    /// the add-ons again: what "Bought an add-on? Refresh" does after a purchase in
1384    /// the browser.
1385    pub fn refresh_add_ons(&self) -> Result<LicenseState> {
1386        self.inner().add_ons.action_error = None;
1387        self.refresh_and_list()
1388    }
1389
1390    fn refresh_and_list(&self) -> Result<LicenseState> {
1391        let offline = {
1392            let mut inner = self.inner();
1393            // Not over a deactivation on its way, which settles the license either way.
1394            let Some(license) = inner.standing.license() else {
1395                return Ok(self.state_with(inner));
1396            };
1397            if inner.deactivating.is_some() {
1398                return Ok(self.state_with(inner));
1399            }
1400            let offline = license.method == ActivationMethod::Offline;
1401            if !offline {
1402                inner.add_ons.phase = AddOnsPhase::Loading;
1403                inner.add_ons.error = None;
1404            }
1405            offline
1406        };
1407        // An offline license is never re-validated; its add-ons are just listed.
1408        if !offline {
1409            self.emit();
1410            // The license was just checked, so the list that follows has no seat
1411            // left to take that checking again would.
1412            if let Err(error) = self.refresh(true) {
1413                log::debug!("moonbase: the re-check before listing the add-ons failed: {error}");
1414            }
1415            if self.inner().standing.license().is_none() {
1416                return Ok(self.state());
1417            }
1418        }
1419        self.list_add_ons(false)
1420    }
1421
1422    /// Take a seat of an add-on the customer owns for this device, from the full
1423    /// license in force: one whose seat was revoked here, or any, with
1424    /// [`auto_activate_sub_products`](crate::Builder::auto_activate_sub_products)
1425    /// off. Stores the license that comes back, and lists the add-ons again if they
1426    /// were loaded.
1427    ///
1428    /// One add-on at a time; why one failed is also in
1429    /// [`AddOnsState::action_error`](crate::AddOnsState::action_error), worded for
1430    /// the customer. A license the server no longer accepts is re-checked, and
1431    /// locks as [`refresh`](Self::refresh) would.
1432    pub fn activate_sub_product(&self, sub_product_id: &str) -> Result<LicenseState> {
1433        self.run_sub_product_action(sub_product_id, false)
1434    }
1435
1436    /// Start the trial of an add-on the customer doesn't own on this device, from
1437    /// the full license in force (owning it, they get a seat instead). Otherwise as
1438    /// [`activate_sub_product`](Self::activate_sub_product).
1439    pub fn request_sub_product_trial(&self, sub_product_id: &str) -> Result<LicenseState> {
1440        self.run_sub_product_action(sub_product_id, true)
1441    }
1442
1443    fn add_on_name(inner: &Inner, sub_product_id: &str) -> Option<String> {
1444        inner
1445            .add_ons
1446            .add_ons
1447            .iter()
1448            .find(|entry| entry.id == sub_product_id || entry.product.id == sub_product_id)
1449            .map(|entry| entry.product.name.clone())
1450    }
1451
1452    fn run_sub_product_action(&self, sub_product_id: &str, trial: bool) -> Result<LicenseState> {
1453        let (call, token, activation_id, name) = {
1454            let mut inner = self.inner();
1455            let refusal = match inner.standing.license() {
1456                None => Some((format!("Activate {} first.", self.product_name()), Error::NotLicensed)),
1457                Some(license) if license.trial => {
1458                    let message = "Add-ons are activated from a full license, not a trial.";
1459                    Some((message.to_owned(), SdkError::OperationNotSupported(message.into()).into()))
1460                }
1461                Some(_) => None,
1462            };
1463            if let Some((message, error)) = refusal {
1464                inner.add_ons.action_error = Some(message);
1465                drop(inner);
1466                self.emit();
1467                return Err(error);
1468            }
1469            if inner.add_ons.busy_id.is_some() {
1470                return Err(Error::Busy("Another add-on is still being set up. Try again in a moment.".into()));
1471            }
1472            let license = inner.standing.license().expect("checked above");
1473            let (token, activation_id) = (license.token.clone(), license.activation_id.clone());
1474            let name = Self::add_on_name(&inner, sub_product_id);
1475            inner.add_on_calls += 1;
1476            inner.add_on_call = inner.add_on_calls;
1477            inner.add_ons.busy_id = Some(sub_product_id.to_owned());
1478            inner.add_ons.action_error = None;
1479            (inner.add_on_call, token, activation_id, name)
1480        };
1481        self.emit();
1482
1483        let outcome = {
1484            let _turn = self.online_turn();
1485            let store = self.0.licensing.store();
1486            // The SDK holds the store's lock from the request until it stores the
1487            // answer, so answers reach the store in the order the server gave them,
1488            // whichever instance asked. Only over a stored copy of this activation:
1489            // one cleared, deactivated or replaced meanwhile is left as it is.
1490            let still_this_license = || Self::stores_activation(store, &activation_id);
1491            let result = if trial {
1492                self.0.licensing.request_sub_product_trial_with(&token, sub_product_id, still_this_license)
1493            } else {
1494                self.0.licensing.activate_sub_product_with(&token, sub_product_id, still_this_license)
1495            };
1496
1497            let mut inner = self.inner();
1498            // Unless the license went in the meantime, which cleared it already.
1499            if inner.add_on_call == call {
1500                inner.add_on_call = 0;
1501                inner.add_ons.busy_id = None;
1502            }
1503            // Only onto the license it was asked from: one deactivated, cleared or
1504            // replaced since keeps what it has now.
1505            let same_license = inner.standing.license().is_some_and(|license| license.activation_id == activation_id);
1506            match result {
1507                Ok(license) if same_license && license.activation_id == activation_id => {
1508                    inner.file_stamp = Some(self.stamp_file());
1509                    self.set_license(&mut inner, license);
1510                    AddOnOutcome::Applied { relist: inner.add_ons.phase != AddOnsPhase::Idle }
1511                }
1512                Ok(_) => AddOnOutcome::Changed,
1513                Err(error) => {
1514                    log::warn!(
1515                        "moonbase: {} failed: {error}",
1516                        if trial { "request_sub_product_trial" } else { "activate_sub_product" }
1517                    );
1518                    if same_license {
1519                        let subject = name.clone().unwrap_or_else(|| "This add-on".into());
1520                        let object = name.clone().unwrap_or_else(|| "this add-on".into());
1521                        inner.add_ons.action_error = Some(match &error {
1522                            SdkError::NoEligibleLicense(_) => format!(
1523                                "No seat of {object} is free for this computer. Free one in your {} account, or buy one.",
1524                                self.account_name()
1525                            ),
1526                            SdkError::OperationNotSupported(_) if trial => {
1527                                format!("{subject} can't be tried on this computer.")
1528                            }
1529                            SdkError::OperationNotSupported(_) => {
1530                                format!("{subject} can't be activated on this computer.")
1531                            }
1532                            SdkError::LicenseExpired(_) => "Your license has expired.".into(),
1533                            error if error.is_license_invalid() => {
1534                                "Your license is no longer valid on this computer.".into()
1535                            }
1536                            error => add_on_failure(
1537                                error,
1538                                &if trial {
1539                                    format!("Couldn't start the trial of {object}")
1540                                } else {
1541                                    format!("Couldn't activate {object}")
1542                                },
1543                                &format!("Moonbase couldn't find {object}."),
1544                            ),
1545                        });
1546                        let recheck = error.is_license_rejection() && inner.deactivating.is_none();
1547                        AddOnOutcome::Failed { error, recheck }
1548                    } else {
1549                        AddOnOutcome::Changed
1550                    }
1551                }
1552            }
1553        };
1554        self.emit();
1555
1556        match outcome {
1557            AddOnOutcome::Applied { relist } => {
1558                // Show what changed.
1559                if relist && let Err(error) = self.list_add_ons(false) {
1560                    log::debug!("moonbase: listing the add-ons again failed: {error}");
1561                }
1562                Ok(self.state())
1563            }
1564            AddOnOutcome::Changed => Err(Error::Busy(LICENSE_CHANGED.into())),
1565            AddOnOutcome::Failed { error, recheck } => {
1566                // The license itself was refused: re-check it, which locks as a
1567                // refresh does when the server stands by that.
1568                if recheck && let Err(error) = self.refresh(true) {
1569                    log::debug!("moonbase: the re-check after a refused add-on failed: {error}");
1570                }
1571                Err(error.into())
1572            }
1573        }
1574    }
1575
1576    /// Open the add-on's buy link from [`LicenseState::add_ons`] in the browser.
1577    /// When the app then goes to the background (the customer is in the browser)
1578    /// and comes back to the front, the add-ons are refreshed as
1579    /// [`refresh_add_ons`](Self::refresh_add_ons) does, so one just bought unlocks
1580    /// without a click.
1581    pub fn buy_sub_product(&self, sub_product_id: &str) -> Result<()> {
1582        let (url, name) = {
1583            let inner = self.inner();
1584            inner
1585                .add_ons
1586                .add_ons
1587                .iter()
1588                .find(|entry| entry.id == sub_product_id || entry.product.id == sub_product_id)
1589                .and_then(|entry| entry.buy_url.clone().map(|url| (url, entry.product.name.clone())))
1590                .ok_or_else(|| Error::Internal("That add-on isn't for sale here".into()))?
1591        };
1592        if let Err(error) = self.open_url(&url) {
1593            log::warn!("moonbase: couldn't open the browser to buy an add-on: {error}");
1594            self.inner().add_ons.action_error = Some(format!("Couldn't open your browser to buy {name}."));
1595            self.emit();
1596            return Err(Error::Internal(format!("Couldn't open the browser: {error}")));
1597        }
1598        let mut inner = self.inner();
1599        inner.purchase_opened_at = Some(Instant::now());
1600        inner.left_for_purchase = None;
1601        Ok(())
1602    }
1603
1604    /// One of the app's windows gained or lost focus: notice the customer coming
1605    /// back from a purchase in the browser.
1606    pub(crate) fn on_focus_changed(&self, focused: bool) {
1607        let mut inner = self.inner();
1608        let Some(opened) = inner.purchase_opened_at else {
1609            return;
1610        };
1611        if opened.elapsed() > PURCHASE_WINDOW {
1612            // Whatever happened, it wasn't a purchase coming back.
1613            inner.purchase_opened_at = None;
1614            inner.left_for_purchase = None;
1615            return;
1616        }
1617        if !focused {
1618            inner.left_for_purchase = Some(Instant::now());
1619            return;
1620        }
1621        // Back. Moving between the app's own windows passes through here too, in a
1622        // blink, and a deactivation on its way settles the license first.
1623        let away = inner.left_for_purchase.is_some_and(|left| left.elapsed() >= Duration::from_secs(1));
1624        if !away || inner.standing.license().is_none() || inner.deactivating.is_some() {
1625            return;
1626        }
1627        inner.purchase_opened_at = None;
1628        inner.left_for_purchase = None;
1629        drop(inner);
1630        log::info!("moonbase: back from the browser after opening an add-on's buy link; refreshing the add-ons");
1631        let this = self.clone();
1632        thread::spawn(move || {
1633            if let Err(error) = this.refresh_add_ons() {
1634                log::debug!("moonbase: refreshing the add-ons failed: {error}");
1635            }
1636        });
1637    }
1638
1639    fn product_name(&self) -> String {
1640        self.ui().map_or_else(|| self.0.app.package_info().name.clone(), |ui| ui.product_name(&self.0.app))
1641    }
1642
1643    fn account_name(&self) -> String {
1644        self.ui().and_then(Ui::manufacturer_name).unwrap_or("Moonbase").to_owned()
1645    }
1646
1647    // -- The license watch: local checks only (a stat of the license file, a read
1648    // when it changed, and a look at the clock), so no request goes out unless a
1649    // license or an add-on has actually run out.
1650
1651    fn watch(&self) {
1652        if self.inner().loading {
1653            return;
1654        }
1655        self.check_license_file();
1656        if !self.check_sub_products() {
1657            self.check_deadlines();
1658        }
1659        self.check_countdowns();
1660    }
1661
1662    fn check_license_file(&self) {
1663        let stamp = self.stamp_file();
1664        // Taken with the stamp, so a forget or deactivate that lands while the file
1665        // is read and validated below keeps the copy from being adopted.
1666        let generation = self.0.generation.load(Ordering::SeqCst);
1667        {
1668            let mut inner = self.inner();
1669            if inner.file_stamp == Some(stamp) {
1670                return;
1671            }
1672            // Only a file that was there and went away means another instance
1673            // removed it. One that never got written must not lock this one.
1674            let existed = inner.file_stamp.is_some_and(|previous| previous.exists);
1675            inner.file_stamp = Some(stamp);
1676            if !stamp.exists {
1677                if existed {
1678                    self.on_license_file_removed(inner);
1679                }
1680                return;
1681            }
1682        }
1683
1684        // Read the file itself rather than through the store: the store lock can be
1685        // held by another process for a whole network check, and a load deletes a
1686        // file it cannot parse, which is what a file caught mid-write looks like. A
1687        // torn read just fails here; the write that completes it changes the stamp.
1688        let Ok(on_disk) = std::fs::read(self.0.store.path())
1689            .map_err(|_| ())
1690            .and_then(|bytes| serde_json::from_slice::<License>(&bytes).map_err(|_| ()))
1691        else {
1692            return;
1693        };
1694        // Our own write, or a sibling's that changed nothing.
1695        if self.inner().standing.held().is_some_and(|held| held.token == on_disk.token) {
1696            return;
1697        }
1698        let (stored, not_covered) = match self.0.licensing.validate_token_local(&on_disk.token) {
1699            Ok(stored) => (stored, None),
1700            // Good, but not for this version: typically a sibling's answer from
1701            // Moonbase, stored along with the refusal. Taken like any stored
1702            // license, except that it locks.
1703            Err(SdkError::ReleaseNotCovered(refusal)) => match refusal.license() {
1704                Some(license) => (license.clone(), Some(NotCoveredLicense::from_refusal(&refusal, license))),
1705                None => return,
1706            },
1707            // Expired, bound elsewhere or tampered with: nothing this instance can use.
1708            Err(_) => return,
1709        };
1710        // Unverified for longer than the grace period: startup would only take it
1711        // after an online check, so neither does this. Whoever re-validates it
1712        // writes a fresh copy.
1713        if stored.method != ActivationMethod::Offline && age(stored.validated_at) > self.0.settings.grace_period {
1714            return;
1715        }
1716        match not_covered {
1717            Some(not_covered) => self.adopt_not_covered(not_covered, generation),
1718            None => self.adopt_stored_license(stored, generation),
1719        }
1720    }
1721
1722    fn on_license_file_removed(&self, mut inner: MutexGuard<'_, Inner>) {
1723        if inner.standing.license().is_none() {
1724            // The license that didn't cover this version is gone as well.
1725            if inner.standing.not_covered().is_none() {
1726                return;
1727            }
1728            log::info!("moonbase: the license file was removed by another instance or process");
1729            if inner.activation.is_some() {
1730                // The activation in progress carries on and brings a license of its own.
1731                self.lock(&mut inner, Some(LockReason::Deactivated));
1732            } else {
1733                // A "Check again" in flight must not write it back.
1734                self.end_activation_flows(&mut inner);
1735                self.lock(&mut inner, Some(LockReason::Deactivated));
1736            }
1737            drop(inner);
1738            return self.emit();
1739        }
1740        log::info!("moonbase: the license file was removed by another instance or process; locking");
1741        if inner.activation.is_some() {
1742            // The user is activating here (typically unlocking a trial): lock, but
1743            // leave the flow running. When it lands it brings its own license.
1744            self.lock(&mut inner, Some(LockReason::Deactivated));
1745        } else {
1746            self.end_activation_flows(&mut inner);
1747            self.lock(&mut inner, Some(LockReason::Deactivated));
1748        }
1749        drop(inner);
1750        self.emit();
1751    }
1752
1753    fn adopt_stored_license(&self, stored: License, generation: u64) {
1754        let mut inner = self.inner();
1755        if !self.is_current(generation) {
1756            // Superseded while the file was read. Look again on the next tick.
1757            inner.file_stamp = None;
1758            return;
1759        }
1760        // Two processes' refreshes can land out of order; never trade the copy held
1761        // for an older one. That includes one Moonbase said doesn't cover this
1762        // version, which only a newer copy undoes.
1763        if Self::is_older_than_held(&inner, &stored) {
1764            return;
1765        }
1766        if let Some(current) = inner.standing.license()
1767            && current.activation_id == stored.activation_id
1768        {
1769            // A newer copy of the same activation is taken quietly.
1770            self.set_license(&mut inner, stored);
1771            drop(inner);
1772            return self.emit();
1773        }
1774        // A different activation, made or replaced elsewhere, or the upgrade of one
1775        // that didn't cover this version. Anything in flight here belongs to the
1776        // old one and must not land on top of it.
1777        log::info!("moonbase: picked up a license stored by another instance or process");
1778        self.end_activation_flows(&mut inner);
1779        self.set_license(&mut inner, stored);
1780        drop(inner);
1781        self.emit();
1782    }
1783
1784    fn adopt_not_covered(&self, mut not_covered: NotCoveredLicense, generation: u64) {
1785        let mut inner = self.inner();
1786        if !self.is_current(generation) {
1787            inner.file_stamp = None;
1788            return;
1789        }
1790        // As adopt_stored_license: never an older copy of the activation held here.
1791        // An activation in progress brings a license of its own.
1792        if Self::is_older_than_held(&inner, &not_covered.license) || inner.activation.is_some() {
1793            return;
1794        }
1795        let same_activation =
1796            inner.standing.held().is_some_and(|held| held.activation_id == not_covered.license.activation_id);
1797        log::info!("moonbase: another instance or process found that the license doesn't cover this version; locking");
1798        // The file holds no upgrade link, so keep the one Moonbase gave here.
1799        if same_activation
1800            && not_covered.upgrade_url.is_none()
1801            && let Some(held) = inner.standing.not_covered()
1802        {
1803            not_covered.upgrade_url.clone_from(&held.upgrade_url);
1804        }
1805        // A different activation's in-flight work must not land on top of it.
1806        if !same_activation {
1807            self.end_activation_flows(&mut inner);
1808        }
1809        self.show_not_covered(&mut inner, not_covered);
1810        drop(inner);
1811        self.emit();
1812    }
1813
1814    /// An add-on's trial (or subscription) can run out while the license carries
1815    /// on, which changes what the app may unlock without a new token. True when it
1816    /// started a re-check.
1817    fn check_sub_products(&self) -> bool {
1818        let now = SystemTime::now();
1819        let (changed, ran_out) = {
1820            let mut inner = self.inner();
1821            let Some(license) = inner.standing.license().cloned() else {
1822                return false;
1823            };
1824            let active = active_sub_products(&license, now);
1825            let changed = *self.0.emitted_sub_products.lock().unwrap_or_else(PoisonError::into_inner) != active;
1826            for id in &active {
1827                // Unlocked again: may be checked again later.
1828                inner.lapses_checked.remove(id);
1829            }
1830            // One whose time ran out may have been bought or renewed meanwhile,
1831            // which the server turns into a seat at the next check: check now, once
1832            // for each, as an expiring license is checked.
1833            let mut ran_out = Vec::new();
1834            if license.method == ActivationMethod::Online
1835                && inner.deactivating.is_none()
1836                && inner.refreshes_in_flight == 0
1837                && inner.activation.is_none()
1838            {
1839                for entry in &license.sub_products {
1840                    if entry.active
1841                        && entry.expires_at.is_some_and(|expires| expires <= now)
1842                        && !inner.lapses_checked.contains(&entry.id)
1843                    {
1844                        ran_out.push(entry.id.clone());
1845                    }
1846                }
1847                inner.lapses_checked.extend(ran_out.iter().cloned());
1848            }
1849            (changed, !ran_out.is_empty())
1850        };
1851        if changed {
1852            self.emit();
1853        }
1854        if ran_out {
1855            log::info!("moonbase: an add-on ran out; re-checking the license in case it was bought or renewed");
1856            if let Err(error) = self.refresh(true) {
1857                log::debug!("moonbase: the add-on re-check failed: {error}");
1858            }
1859        }
1860        ran_out
1861    }
1862
1863    fn check_deadlines(&self) {
1864        let mut inner = self.inner();
1865        // Not while a refresh is on its way: another would supersede it.
1866        let Some(license) = inner.standing.license().cloned() else {
1867            return;
1868        };
1869        if inner.deactivating.is_some() || inner.refreshes_in_flight > 0 {
1870            return;
1871        }
1872        let expired = license.expires_at.is_some_and(|expires| expires <= SystemTime::now());
1873        let offline = license.method == ActivationMethod::Offline;
1874        let past_grace = !offline && age(license.validated_at) > self.0.settings.grace_period;
1875        if !expired && !past_grace {
1876            return;
1877        }
1878
1879        if inner.activation.is_some() {
1880            // Typically a trial being unlocked. A re-check would supersede the
1881            // activation's own requests, so stop honouring the license and leave
1882            // the flow alone: it replaces the license when it lands.
1883            log::info!("moonbase: the license ran out while an activation was in progress; locking");
1884            if expired && license.trial {
1885                self.show_trial_expired(&mut inner, license);
1886            } else {
1887                self.lock(&mut inner, Some(if expired { LockReason::Expired } else { LockReason::Unverified }));
1888            }
1889            drop(inner);
1890            return self.emit();
1891        }
1892
1893        if offline {
1894            // Never re-validated, and dead for good once past its `exp`.
1895            log::info!("moonbase: the offline license has expired; removing it");
1896            self.bump();
1897            self.delete_stored(&mut inner, None);
1898            self.lock(&mut inner, Some(LockReason::Expired));
1899            drop(inner);
1900            return self.emit();
1901        }
1902
1903        if !expired {
1904            // Spaced out, so a skewed clock cannot turn the watch into a request
1905            // every tick.
1906            let now = Instant::now();
1907            if inner.last_grace_check.is_some_and(|last| now - last < self.0.settings.check_interval) {
1908                return;
1909            }
1910            inner.last_grace_check = Some(now);
1911        }
1912        drop(inner);
1913
1914        // A passed `exp` locks without a request (a trial shows as expired); past
1915        // the grace period the server gets one more chance.
1916        if let Err(error) = self.refresh(false) {
1917            log::debug!("moonbase: the deadline re-check failed: {error}");
1918        }
1919    }
1920
1921    /// The UI counts down to when the license and its add-ons end ("5 days left"),
1922    /// and nothing reports a day going by: emit when one of those counts moves, so
1923    /// a screen left open keeps up. Not when what ends has changed: whatever
1924    /// changed it emitted already.
1925    fn check_countdowns(&self) {
1926        let mut inner = self.inner();
1927        let mut ends: Vec<SystemTime> = Vec::new();
1928        if let Some(license) = inner.standing.license() {
1929            ends.extend(license.expires_at);
1930            ends.extend(license.sub_products.iter().filter_map(|entry| entry.expires_at));
1931        }
1932        for entry in &inner.add_ons.add_ons {
1933            ends.extend(entry.expires_at);
1934            ends.extend(entry.trial.as_ref().and_then(|trial| trial.expires_at));
1935        }
1936        let days: Vec<u64> = ends.iter().copied().map(days_until).collect();
1937        let day_went_by = ends == inner.countdown.0 && days != inner.countdown.1;
1938        inner.countdown = (ends, days);
1939        drop(inner);
1940        if day_went_by {
1941            self.emit();
1942        }
1943    }
1944}
1945
1946impl<R: Runtime> std::fmt::Debug for Moonbase<R> {
1947    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1948        f.debug_struct("Moonbase")
1949            .field("license_file", &self.0.store.path())
1950            .field("licensed", &self.is_licensed())
1951            .finish_non_exhaustive()
1952    }
1953}