Skip to main content

tauri_plugin_moonbase/
ui.rs

1//! The built-in activation UI, from the "Solstice Activation" design, as its own
2//! window or as an overlay that locks one of the app's windows.
3//!
4//! As a window, the plugin serves the page itself over its own `moonbase://` scheme.
5//! As an overlay, the same UI is injected into the app's webviews and mounted in a
6//! closed shadow root over the app's content. Either way the plugin grants the UI
7//! its commands at runtime, so an app gets a finished, themed activation flow
8//! without writing any frontend code. It presents itself when the app is locked,
9//! and whenever the app asks.
10
11use std::path::{Path, PathBuf};
12use std::sync::{Mutex, PoisonError};
13#[cfg(desktop)]
14use std::time::{Duration, Instant};
15
16use base64::Engine as _;
17use moonbase::ReleaseAccess;
18use serde::{Deserialize, Serialize};
19use serde_json::{Value, json};
20use tauri::http::{Request, Response, StatusCode, header};
21use tauri::{AppHandle, Manager, Runtime};
22
23use crate::controller::Moonbase;
24use crate::error::{Error, Result};
25use crate::models::{LicenseState, Status};
26
27/// The window's label. Capabilities, events and `get_webview_window` use it.
28pub const WINDOW_LABEL: &str = "moonbase-activation";
29pub(crate) const SCHEME: &str = "moonbase";
30
31/// Where the footer's moonbase mark links to.
32const MOONBASE_SITE: &str = "https://moonbase.sh";
33#[cfg(desktop)]
34const WIDTH: f64 = 660.0;
35
36const INDEX_HTML: &str = include_str!("../ui/index.html");
37const UI_CSS: &str = include_str!("../ui/ui.css");
38const UI_JS: &str = include_str!("../ui/ui.js");
39const WINDOW_JS: &str = include_str!("../ui/window.js");
40const OVERLAY_JS: &str = include_str!("../ui/overlay.js");
41
42// Everything the page loads is its own; the IPC origins are Tauri's.
43const CSP: &str = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; \
44                   connect-src ipc: http://ipc.localhost";
45
46/// A line on the trial screen: something the trial includes, or does not.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
48pub struct TrialFeature {
49    pub label: String,
50    pub included: bool,
51}
52
53/// Copy overrides. Leave a field `None` for the built-in text, several of which
54/// name your product and manufacturer.
55#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
56#[serde(rename_all = "camelCase")]
57pub struct UiStrings {
58    /// Default: "Activate {product}".
59    pub welcome_title: Option<String>,
60    /// Default: "Unlock {product} through your {manufacturer} account, or take it for a spin with a free trial."
61    pub welcome_body: Option<String>,
62    /// Default: "Activate online".
63    pub activate_online: Option<String>,
64    /// Default: "No internet? Activate offline".
65    pub activate_offline: Option<String>,
66    /// Shown on the welcome screen instead of the welcome copy once the app has
67    /// lost its license, one pair per reason, so the customer learns why.
68    pub deactivated_title: Option<String>,
69    pub deactivated_body: Option<String>,
70    pub license_expired_title: Option<String>,
71    pub license_expired_body: Option<String>,
72    pub license_invalid_title: Option<String>,
73    pub license_invalid_body: Option<String>,
74    pub license_unverified_title: Option<String>,
75    pub license_unverified_body: Option<String>,
76    /// The note on the trial-expired screen about what stops working.
77    pub expired_notice: Option<String>,
78    /// The upgrade screen, for a license that doesn't cover this version of the
79    /// app. Default: "Version {version} needs an upgrade".
80    pub not_covered_title: Option<String>,
81    /// Default: what the license covers, e.g. "Your updates ended on 1 June 2026.
82    /// Your license covers releases up to 2.3.0."
83    pub not_covered_body: Option<String>,
84}
85
86/// A screen of the built-in UI to open on, with
87/// [`Moonbase::present`](crate::Moonbase::present). Without one it shows whatever the
88/// license state calls for. In the frontend, the same names as strings:
89/// `"license"`, `"update"`, `"offline"`, `"addons"`.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
91#[serde(rename_all = "camelCase")]
92#[non_exhaustive]
93pub enum Screen {
94    /// The license details, or what the state calls for when there is no license.
95    License,
96    /// The update screen, for a newer release than the app.
97    Update,
98    /// Offline activation: the machine file and the license file.
99    Offline,
100    /// The product's add-ons, from a full license.
101    #[serde(rename = "addons")]
102    AddOns,
103}
104
105/// Where the activation UI appears.
106#[derive(Debug, Clone, Default, PartialEq, Eq)]
107#[non_exhaustive]
108pub enum Presentation {
109    /// Its own window, beside the app, which stays usable meanwhile. The default.
110    #[default]
111    Window,
112    /// Over the content of one of the app's windows, which is blurred and inert
113    /// behind it: the app is locked for as long as it has no license. Once licensed
114    /// the overlay can be dismissed, and opened again on demand.
115    Overlay {
116        /// The label of the window to lock.
117        window: String,
118    },
119}
120
121impl Presentation {
122    /// An overlay over the app's `main` window.
123    pub fn overlay() -> Self {
124        Self::overlay_on("main")
125    }
126
127    /// An overlay over the window with this label.
128    pub fn overlay_on(window: impl Into<String>) -> Self {
129        Self::Overlay { window: window.into() }
130    }
131}
132
133#[derive(Debug, Clone)]
134enum Logo {
135    Svg(String),
136    Png(Vec<u8>),
137}
138
139/// Branding and behaviour for the built-in activation window.
140///
141/// ```no_run
142/// use tauri_plugin_moonbase::UiConfig;
143///
144/// let ui = UiConfig::new()
145///     .product_name("Solstice")
146///     .manufacturer_name("Helio Audio")
147///     .accent("#186cdc")
148///     .trial_feature("Full DSP engine, unrestricted", true)
149///     .trial_feature("Use in commercial releases", false);
150/// ```
151#[derive(Debug, Clone)]
152pub struct UiConfig {
153    pub(crate) enabled: bool,
154    product_name: Option<String>,
155    manufacturer_name: Option<String>,
156    accent: String,
157    logo: Option<Logo>,
158    trial_features: Vec<TrialFeature>,
159    trial_length_days: u32,
160    show_moonbase_badge: bool,
161    strings: UiStrings,
162    show_addons: bool,
163    pub(crate) auto_present_on_lock: bool,
164    pub(crate) auto_present_update: bool,
165    reduce_motion: bool,
166    window_title: Option<String>,
167    pub(crate) presentation: Presentation,
168}
169
170impl Default for UiConfig {
171    fn default() -> Self {
172        Self {
173            enabled: true,
174            product_name: None,
175            manufacturer_name: None,
176            accent: "#186cdc".into(),
177            logo: None,
178            trial_features: Vec::new(),
179            trial_length_days: 14,
180            show_moonbase_badge: true,
181            strings: UiStrings::default(),
182            show_addons: false,
183            auto_present_on_lock: true,
184            auto_present_update: true,
185            reduce_motion: false,
186            window_title: None,
187            presentation: Presentation::Window,
188        }
189    }
190}
191
192impl UiConfig {
193    pub fn new() -> Self {
194        Self::default()
195    }
196
197    /// No built-in window: drive your own UI from the state event and commands.
198    pub fn disabled() -> Self {
199        Self { enabled: false, ..Self::default() }
200    }
201
202    /// Shown throughout the UI. Defaults to the app's product name.
203    pub fn product_name(mut self, name: impl Into<String>) -> Self {
204        self.product_name = Some(name.into());
205        self
206    }
207
208    /// Shown under the product name, and as the account the customer signs in to.
209    pub fn manufacturer_name(mut self, name: impl Into<String>) -> Self {
210        self.manufacturer_name = Some(name.into());
211        self
212    }
213
214    /// The one brand colour: buttons, progress, step numbers. Any CSS colour.
215    /// Defaults to Moonbase blue, `#186cdc`.
216    pub fn accent(mut self, color: impl Into<String>) -> Self {
217        self.accent = color.into();
218        self
219    }
220
221    /// The product mark in the header, as SVG markup. Without one, a sun mark is
222    /// drawn in the accent colour.
223    pub fn logo_svg(mut self, svg: impl Into<String>) -> Self {
224        self.logo = Some(Logo::Svg(svg.into()));
225        self
226    }
227
228    /// The product mark in the header, as PNG bytes. Supply at least 76 px square.
229    pub fn logo_png(mut self, png: impl Into<Vec<u8>>) -> Self {
230        self.logo = Some(Logo::Png(png.into()));
231        self
232    }
233
234    /// Add a line to the trial screen's feature list.
235    pub fn trial_feature(mut self, label: impl Into<String>, included: bool) -> Self {
236        self.trial_features.push(TrialFeature { label: label.into(), included });
237        self
238    }
239
240    /// The trial length shown when the license does not say (an endless trial
241    /// shows no countdown). Default 14.
242    pub fn trial_length_days(mut self, days: u32) -> Self {
243        self.trial_length_days = days.max(1);
244        self
245    }
246
247    /// The "Licensing secured by moonbase" footer, whose Moonbase mark links to
248    /// moonbase.sh. On by default.
249    pub fn show_moonbase_badge(mut self, show: bool) -> Self {
250        self.show_moonbase_badge = show;
251        self
252    }
253
254    pub fn strings(mut self, strings: UiStrings) -> Self {
255        self.strings = strings;
256        self
257    }
258
259    /// Add an "Add-ons" row to the license details, opening a screen that lists
260    /// the product's add-ons from Moonbase with what the customer can do with each:
261    /// try it, buy it, or activate it on this computer. Off by default; turn it on
262    /// when the product has add-ons. The add-ons a license includes unlock either
263    /// way: check them with [`Moonbase::has_sub_product`].
264    pub fn show_addons(mut self, show: bool) -> Self {
265        self.show_addons = show;
266        self
267    }
268
269    /// Present the window whenever the app is locked: when it starts without a
270    /// license, and whenever the license is lost while it runs. On by default.
271    pub fn auto_present_on_lock(mut self, present: bool) -> Self {
272        self.auto_present_on_lock = present;
273        self
274    }
275
276    /// Present the update screen when the app starts with a newer, unskipped
277    /// release available. On by default.
278    pub fn auto_present_update(mut self, present: bool) -> Self {
279        self.auto_present_update = present;
280        self
281    }
282
283    /// Skip the screen transitions, the success pop and the glow. The system's
284    /// reduced-motion setting is honoured either way.
285    pub fn reduce_motion(mut self, reduce: bool) -> Self {
286        self.reduce_motion = reduce;
287        self
288    }
289
290    /// The window title. Defaults to the product name.
291    pub fn window_title(mut self, title: impl Into<String>) -> Self {
292        self.window_title = Some(title.into());
293        self
294    }
295
296    /// Show the UI in its own window (the default), or as an overlay that locks one
297    /// of the app's windows until it is licensed:
298    ///
299    /// ```no_run
300    /// # use tauri_plugin_moonbase::{Presentation, UiConfig};
301    /// let ui = UiConfig::new().presentation(Presentation::overlay());
302    /// ```
303    pub fn presentation(mut self, presentation: Presentation) -> Self {
304        self.presentation = presentation;
305        self
306    }
307
308    /// The label of the window an overlay locks, if the UI is an overlay.
309    pub(crate) fn overlay_window(&self) -> Option<&str> {
310        match &self.presentation {
311            Presentation::Overlay { window } => Some(window),
312            Presentation::Window => None,
313        }
314    }
315}
316
317/// The script that lets the UI mount as an overlay in the app's webviews: the UI,
318/// its stylesheet, and the mount logic, dormant until the plugin mounts it.
319pub(crate) fn overlay_script() -> String {
320    let css = serde_json::to_string(UI_CSS).expect("a string serializes");
321    format!("(function () {{\nconst CSS = {css};\n{UI_JS}\n{OVERLAY_JS}\n}})();")
322}
323
324/// How the update download is going, pushed to the window as it changes.
325#[derive(Debug, Clone, Default, PartialEq, Serialize)]
326#[serde(rename_all = "camelCase")]
327pub(crate) struct DownloadState {
328    phase: DownloadPhase,
329    progress: Option<f64>,
330    path: Option<String>,
331    error: Option<String>,
332    /// For `NotCovered`: what the customer's licenses cover, and where to upgrade.
333    release_access: Option<ReleaseAccess>,
334    upgrade_url: Option<String>,
335}
336
337#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
338#[serde(rename_all = "camelCase")]
339enum DownloadPhase {
340    #[default]
341    Idle,
342    Downloading,
343    Done,
344    Failed,
345    /// Moonbase refused the download: the customer's licenses don't cover it.
346    NotCovered,
347}
348
349/// What `ui_ready` tells the page: the screen it was presented for, if any.
350#[derive(Debug, Clone, Default, Serialize)]
351pub(crate) struct UiOpened {
352    screen: Option<Screen>,
353}
354
355/// The window's runtime state, held by the controller.
356pub(crate) struct Ui {
357    pub(crate) config: UiConfig,
358    endpoint: String,
359    state_file: PathBuf,
360    pending_screen: Mutex<Option<Screen>>,
361    creating: Mutex<bool>,
362    granted: Mutex<bool>,
363    overlay_open: Mutex<bool>,
364    last_licensed: Mutex<Option<bool>>,
365    download: Mutex<DownloadState>,
366    downloaded: Mutex<Option<PathBuf>>,
367}
368
369fn guard<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
370    mutex.lock().unwrap_or_else(PoisonError::into_inner)
371}
372
373impl Ui {
374    pub(crate) fn new(config: UiConfig, endpoint: String, license_file: &Path) -> Self {
375        let mut state_file = license_file.as_os_str().to_owned();
376        state_file.push(".state.json");
377        Self {
378            config,
379            endpoint,
380            state_file: state_file.into(),
381            pending_screen: Mutex::default(),
382            creating: Mutex::new(false),
383            granted: Mutex::new(false),
384            overlay_open: Mutex::new(false),
385            last_licensed: Mutex::default(),
386            download: Mutex::default(),
387            downloaded: Mutex::default(),
388        }
389    }
390
391    pub(crate) fn product_name<R: Runtime>(&self, app: &AppHandle<R>) -> String {
392        self.config.product_name.clone().unwrap_or_else(|| app.package_info().name.clone())
393    }
394
395    pub(crate) fn manufacturer_name(&self) -> Option<&str> {
396        self.config.manufacturer_name.as_deref()
397    }
398
399    fn activation_host(&self) -> String {
400        tauri::Url::parse(&self.endpoint)
401            .ok()
402            .and_then(|url| url.host_str().map(str::to_owned))
403            .unwrap_or_else(|| self.endpoint.clone())
404    }
405
406    /// The client state file beside the license (skipped update versions), with
407    /// unknown keys preserved so an older build never drops a newer one's.
408    fn read_state(&self) -> Value {
409        std::fs::read(&self.state_file)
410            .ok()
411            .and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
412            .filter(Value::is_object)
413            .unwrap_or_else(|| json!({}))
414    }
415
416    fn is_skipped(&self, version: &str) -> bool {
417        self.read_state()["ignoredUpdates"]
418            .as_array()
419            .is_some_and(|versions| versions.iter().any(|skipped| skipped.as_str() == Some(version)))
420    }
421
422    fn skip(&self, version: &str) -> Result<()> {
423        let mut state = self.read_state();
424        if !state["ignoredUpdates"].is_array() {
425            state["ignoredUpdates"] = json!([]);
426        }
427        let versions = state["ignoredUpdates"].as_array_mut().expect("just made an array");
428        if !versions.iter().any(|skipped| skipped.as_str() == Some(version)) {
429            versions.push(json!(version));
430        }
431        let text = serde_json::to_string_pretty(&state).map_err(|error| Error::Internal(error.to_string()))?;
432        std::fs::write(&self.state_file, text)
433            .map_err(|error| Error::Internal(format!("Couldn't save the skipped update: {error}")))
434    }
435
436    fn page_config<R: Runtime>(&self, app: &AppHandle<R>) -> Value {
437        let product = self.product_name(app);
438        // The window loads a PNG logo from the plugin's scheme; an overlay cannot, so
439        // it gets the bytes. An SVG logo is inlined either way.
440        let (logo, logo_svg) = match &self.config.logo {
441            Some(Logo::Svg(svg)) => (None, Some(svg.clone())),
442            Some(Logo::Png(png)) if self.config.overlay_window().is_some() => {
443                (Some(format!("data:image/png;base64,{}", base64::engine::general_purpose::STANDARD.encode(png))), None)
444            }
445            Some(Logo::Png(_)) => (Some("logo".to_owned()), None),
446            None => (None, None),
447        };
448        json!({
449            "productName": product,
450            "manufacturerName": self.config.manufacturer_name,
451            "accent": self.config.accent,
452            "logo": logo,
453            "logoSvg": logo_svg,
454            "trialFeatures": self.config.trial_features,
455            "trialLengthDays": self.config.trial_length_days,
456            "showBadge": self.config.show_moonbase_badge,
457            "strings": self.config.strings,
458            "showAddOns": self.config.show_addons,
459            "reduceMotion": self.config.reduce_motion,
460            "activationHost": self.activation_host(),
461            "requestFileName": format!("{}.dt", file_safe(&product)),
462            "os": std::env::consts::OS,
463        })
464    }
465}
466
467/// A name usable as a file name everywhere: path separators and the characters
468/// Windows refuses become dashes.
469fn file_safe(name: &str) -> String {
470    let cleaned: String =
471        name.chars().map(|c| if c.is_control() || r#"\/:*?"<>|"#.contains(c) { '-' } else { c }).collect();
472    let cleaned = cleaned.trim().trim_matches('.').to_owned();
473    if cleaned.is_empty() { "license".into() } else { cleaned }
474}
475
476/// `dir/name`, or `dir/name (2)` and so on when that is taken.
477fn unique_path(dir: &Path, name: &str) -> PathBuf {
478    let candidate = dir.join(name);
479    if !candidate.exists() {
480        return candidate;
481    }
482    let (stem, extension) = match name.rsplit_once('.') {
483        Some((stem, extension)) if !stem.is_empty() => (stem.to_owned(), format!(".{extension}")),
484        _ => (name.to_owned(), String::new()),
485    };
486    (2..)
487        .map(|n| dir.join(format!("{stem} ({n}){extension}")))
488        .find(|path| !path.exists())
489        .expect("some suffix is free")
490}
491
492fn respond(status: StatusCode, content_type: &str, body: Vec<u8>) -> Response<Vec<u8>> {
493    let mut builder = Response::builder()
494        .status(status)
495        .header(header::CONTENT_TYPE, content_type)
496        .header(header::CACHE_CONTROL, "no-store");
497    if content_type.starts_with("text/html") {
498        builder = builder.header("Content-Security-Policy", CSP);
499    }
500    builder.body(body).unwrap_or_else(|_| Response::new(Vec::new()))
501}
502
503/// Serve the window's page over the plugin's scheme.
504pub(crate) fn serve<R: Runtime>(app: &AppHandle<R>, request: &Request<Vec<u8>>) -> Response<Vec<u8>> {
505    let not_found = || respond(StatusCode::NOT_FOUND, "text/plain", b"Not found".to_vec());
506    let Some(moonbase) = app.try_state::<Moonbase<R>>() else {
507        return not_found();
508    };
509    let Some(ui) = moonbase.ui() else {
510        return not_found();
511    };
512    match request.uri().path() {
513        "/" | "/index.html" => respond(StatusCode::OK, "text/html; charset=utf-8", INDEX_HTML.as_bytes().to_vec()),
514        "/ui.css" => respond(StatusCode::OK, "text/css; charset=utf-8", UI_CSS.as_bytes().to_vec()),
515        "/ui.js" => respond(StatusCode::OK, "text/javascript; charset=utf-8", UI_JS.as_bytes().to_vec()),
516        "/window.js" => respond(StatusCode::OK, "text/javascript; charset=utf-8", WINDOW_JS.as_bytes().to_vec()),
517        "/config.js" => {
518            let script = format!("window.__MOONBASE_UI__ = {};", ui.page_config(app));
519            respond(StatusCode::OK, "text/javascript; charset=utf-8", script.into_bytes())
520        }
521        "/logo" => match &ui.config.logo {
522            Some(Logo::Svg(svg)) => respond(StatusCode::OK, "image/svg+xml", svg.as_bytes().to_vec()),
523            Some(Logo::Png(png)) => respond(StatusCode::OK, "image/png", png.clone()),
524            None => not_found(),
525        },
526        _ => not_found(),
527    }
528}
529
530/// Let the UI call the plugin. Plugins cannot ship a capability file of their own,
531/// so this one is added at runtime, scoped to the window the UI is in, just before
532/// it first appears.
533fn grant_ui_permissions<R: Runtime>(app: &AppHandle<R>, window: &str) -> Result<()> {
534    let mut capability = tauri::ipc::CapabilityBuilder::new(format!("moonbase-ui-{window}"))
535        .window(window)
536        .local(true)
537        .permission("moonbase:default");
538    if window == WINDOW_LABEL {
539        capability = capability.permission("core:window:allow-start-dragging");
540    }
541    app.add_capability(capability).map_err(Error::from)
542}
543
544fn eval_in<R: Runtime>(app: &AppHandle<R>, window: &str, script: String) {
545    let Some(window) = app.get_webview_window(window) else {
546        return;
547    };
548    if let Err(error) = window.eval(script) {
549        log::debug!("moonbase: couldn't update the activation UI: {error}");
550    }
551}
552
553impl<R: Runtime> Moonbase<R> {
554    fn require_ui(&self) -> Result<&Ui> {
555        self.ui().ok_or_else(|| Error::Internal("The built-in activation window is turned off".into()))
556    }
557
558    /// Hand the UI something new, wherever it is showing.
559    fn push(&self, function: &str, payload: &impl Serialize) {
560        let Some(ui) = self.ui() else {
561            return;
562        };
563        let Ok(json) = serde_json::to_string(payload) else {
564            return;
565        };
566        match ui.config.overlay_window() {
567            Some(window) => {
568                if *guard(&ui.overlay_open) {
569                    let script = format!("window.__moonbaseOverlay && window.__moonbaseOverlay.{function}({json})");
570                    eval_in(self.app(), window, script);
571                }
572            }
573            None => {
574                let script = format!("window.__moonbase && window.__moonbase.{function}({json})");
575                eval_in(self.app(), WINDOW_LABEL, script);
576            }
577        }
578    }
579
580    fn grant_once(&self, ui: &Ui, window: &str) -> Result<()> {
581        let mut granted = guard(&ui.granted);
582        if !*granted {
583            grant_ui_permissions(self.app(), window)?;
584            *granted = true;
585        }
586        Ok(())
587    }
588
589    /// Mount the overlay in `window`, or bring it to `screen` if it is up. A window
590    /// that does not exist yet (the app is still starting) gets it when its page
591    /// loads.
592    fn mount_overlay(&self, ui: &Ui, window: &str, screen: Option<Screen>) -> Result<()> {
593        let Some(target) = self.app().get_webview_window(window) else {
594            *guard(&ui.pending_screen) = screen;
595            log::debug!("moonbase: no window labelled \"{window}\" yet; the overlay mounts when it loads");
596            return Ok(());
597        };
598        let screen = screen.or_else(|| guard(&ui.pending_screen).take());
599        let config = ui.page_config(self.app());
600        let state = serde_json::to_string(&self.state()).map_err(|error| Error::Internal(error.to_string()))?;
601        let screen = serde_json::to_string(&screen).map_err(|error| Error::Internal(error.to_string()))?;
602        target
603            .eval(format!("window.__moonbaseOverlay && window.__moonbaseOverlay.mount({config}, {state}, {screen})"))?;
604        #[cfg(desktop)]
605        {
606            let _ = target.unminimize();
607            let _ = target.show();
608            let _ = target.set_focus();
609        }
610        Ok(())
611    }
612
613    /// A page finished loading in one of the app's webviews: put the overlay back if
614    /// that page replaced one that showed it (a reload, or a navigation).
615    pub(crate) fn on_page_load(&self, label: &str) {
616        let Some(ui) = self.ui() else {
617            return;
618        };
619        if ui.config.overlay_window() != Some(label) || !*guard(&ui.overlay_open) {
620            return;
621        }
622        if let Err(error) = self.mount_overlay(ui, label, None) {
623            log::warn!("moonbase: couldn't restore the activation overlay: {error}");
624        }
625    }
626
627    /// Called after every state change: keep the window current, and present it
628    /// when the app locks or opens with an update.
629    pub(crate) fn after_state_change(&self, state: &LicenseState) {
630        let Some(ui) = self.ui() else {
631            return;
632        };
633        self.push("update", state);
634        if state.status == Status::Loading {
635            return;
636        }
637        let previous = guard(&ui.last_licensed).replace(state.licensed);
638        if !state.licensed && previous != Some(false) && ui.config.auto_present_on_lock {
639            self.present_quietly(None);
640        } else if previous.is_none()
641            && state.licensed
642            && ui.config.auto_present_update
643            && let Some(update) = &state.update
644            && !ui.is_skipped(&update.latest_version)
645        {
646            self.present_quietly(Some(Screen::Update));
647        }
648    }
649
650    fn present_quietly(&self, screen: Option<Screen>) {
651        if let Err(error) = self.present(screen) {
652            log::warn!("moonbase: couldn't present the activation UI: {error}");
653        }
654    }
655
656    /// Show the activation window, creating it if needed, and bring it to the
657    /// front; or, with [`Presentation::Overlay`], mount the overlay over its
658    /// window. `screen` opens it on a particular [`Screen`]; without one it shows
659    /// whatever the license state calls for.
660    pub fn present(&self, screen: Option<Screen>) -> Result<()> {
661        let ui = self.require_ui()?;
662        if let Some(window) = ui.config.overlay_window() {
663            self.grant_once(ui, window)?;
664            *guard(&ui.overlay_open) = true;
665            return self.mount_overlay(ui, window, screen);
666        }
667        #[cfg(not(desktop))]
668        {
669            let _ = (ui, screen);
670            Err(Error::Internal("The built-in activation window is desktop-only".into()))
671        }
672        #[cfg(desktop)]
673        {
674            if let Some(window) = self.app().get_webview_window(WINDOW_LABEL) {
675                if let Some(screen) = screen {
676                    self.push("show", &screen);
677                }
678                let _ = window.unminimize();
679                window.show()?;
680                window.set_focus()?;
681                return Ok(());
682            }
683            {
684                let mut creating = guard(&ui.creating);
685                if *creating {
686                    *guard(&ui.pending_screen) = screen;
687                    return Ok(());
688                }
689                *creating = true;
690            }
691            *guard(&ui.pending_screen) = screen;
692            if let Err(error) = self.grant_once(ui, WINDOW_LABEL) {
693                *guard(&ui.creating) = false;
694                return Err(error);
695            }
696            let title = ui.config.window_title.clone().unwrap_or_else(|| ui.product_name(self.app()));
697            let this = self.clone();
698            // Off the calling thread: on Windows, creating a webview from the main
699            // thread inside a synchronous handler deadlocks.
700            std::thread::spawn(move || {
701                let built = build_window(this.app(), &title);
702                if let Some(ui) = this.ui() {
703                    *guard(&ui.creating) = false;
704                }
705                match built {
706                    Ok(()) => {
707                        // Shown when the page reports its size; this covers a page
708                        // that never does.
709                        std::thread::sleep(Duration::from_millis(1500));
710                        if let Some(window) = this.app().get_webview_window(WINDOW_LABEL)
711                            && !window.is_visible().unwrap_or(true)
712                        {
713                            let _ = window.show();
714                            let _ = window.set_focus();
715                        }
716                    }
717                    Err(error) => log::warn!("moonbase: couldn't open the activation window: {error}"),
718                }
719            });
720            Ok(())
721        }
722    }
723
724    /// Close the activation window, or take the overlay down, if it is showing.
725    pub fn close_ui(&self) -> Result<()> {
726        let Some(ui) = self.ui() else {
727            return Ok(());
728        };
729        match ui.config.overlay_window() {
730            Some(window) => {
731                *guard(&ui.overlay_open) = false;
732                eval_in(self.app(), window, "window.__moonbaseOverlay && window.__moonbaseOverlay.unmount()".into());
733            }
734            None => {
735                if let Some(window) = self.app().get_webview_window(WINDOW_LABEL) {
736                    window.close()?;
737                }
738            }
739        }
740        Ok(())
741    }
742
743    pub(crate) fn ui_ready(&self, height: f64) -> Result<UiOpened> {
744        let ui = self.require_ui()?;
745        self.ui_resize(height)?;
746        #[cfg(desktop)]
747        if let Some(window) = self.app().get_webview_window(WINDOW_LABEL) {
748            window.center()?;
749            window.show()?;
750            window.set_focus()?;
751        }
752        Ok(UiOpened { screen: guard(&ui.pending_screen).take() })
753    }
754
755    pub(crate) fn ui_resize(&self, height: f64) -> Result<()> {
756        #[cfg(desktop)]
757        if let Some(window) = self.app().get_webview_window(WINDOW_LABEL) {
758            let height = height.clamp(240.0, 900.0);
759            window.set_size(tauri::LogicalSize::new(WIDTH, height))?;
760        }
761        #[cfg(not(desktop))]
762        let _ = height;
763        Ok(())
764    }
765
766    /// Never prompt for this release again; a newer one still prompts.
767    pub fn skip_update(&self, version: &str) -> Result<()> {
768        self.require_ui()?.skip(version)
769    }
770
771    /// Open a link from the window in the browser: the pending activation, the
772    /// store, or Moonbase's site from the footer. Nothing else, so the command
773    /// cannot be used to open arbitrary URLs.
774    pub(crate) fn open_link(&self, url: &str) -> Result<()> {
775        let ui = self.require_ui()?;
776        let parsed = tauri::Url::parse(url).map_err(|_| Error::Internal("Not a link".into()))?;
777        let store = tauri::Url::parse(&ui.endpoint).ok();
778        let same_origin = store.is_some_and(|store| store.origin() == parsed.origin());
779        let pending =
780            self.state().activation.and_then(|activation| activation.browser_url).is_some_and(|pending| pending == url);
781        let moonbase = url == MOONBASE_SITE && ui.config.show_moonbase_badge;
782        if !(same_origin || pending || moonbase) {
783            return Err(Error::Internal("That link can't be opened from here".into()));
784        }
785        self.open_url(url).map_err(|error| Error::Internal(format!("Couldn't open the browser: {error}")))
786    }
787
788    fn reveal(&self, path: &Path) {
789        let revealed = match self.app().try_state::<tauri_plugin_opener::Opener<R>>() {
790            Some(opener) => opener.reveal_item_in_dir(path),
791            None => tauri_plugin_opener::reveal_item_in_dir(path),
792        };
793        if let Err(error) = revealed {
794            log::debug!("moonbase: couldn't reveal {}: {error}", path.display());
795        }
796    }
797
798    fn downloads_dir(&self) -> Result<PathBuf> {
799        let path = self.app().path();
800        path.download_dir().or_else(|_| path.temp_dir()).map_err(Error::from)
801    }
802
803    /// Save the machine file for offline activation: through a save dialog when
804    /// the app has registered `tauri-plugin-dialog`, otherwise into Downloads,
805    /// revealed in the file manager. `None` when the user cancels.
806    pub fn save_machine_file(&self) -> Result<Option<PathBuf>> {
807        let ui = self.require_ui()?;
808        let token = self.device_token()?;
809        let name = format!("{}.dt", file_safe(&ui.product_name(self.app())));
810
811        #[cfg(desktop)]
812        if let Some(dialog) = self.app().try_state::<tauri_plugin_dialog::Dialog<R>>() {
813            let mut picker = dialog
814                .file()
815                .set_title("Save machine file")
816                .set_file_name(&name)
817                .add_filter("Moonbase machine file", &["dt"]);
818            let parent = ui.config.overlay_window().unwrap_or(WINDOW_LABEL);
819            if let Some(window) = self.app().get_webview_window(parent) {
820                picker = picker.set_parent(&window);
821            }
822            let Some(picked) = picker.blocking_save_file() else {
823                return Ok(None);
824            };
825            let path = picked.into_path().map_err(|error| Error::Internal(error.to_string()))?;
826            std::fs::write(&path, token)
827                .map_err(|error| Error::Internal(format!("Couldn't save the machine file: {error}")))?;
828            return Ok(Some(path));
829        }
830
831        let dir = self.downloads_dir()?;
832        let path = unique_path(&dir, &name);
833        std::fs::write(&path, token)
834            .map_err(|error| Error::Internal(format!("Couldn't save the machine file: {error}")))?;
835        self.reveal(&path);
836        Ok(Some(path))
837    }
838
839    fn set_download(&self, ui: &Ui, next: DownloadState) {
840        *guard(&ui.download) = next.clone();
841        self.push("download", &next);
842    }
843
844    /// Download the latest installer for this platform into Downloads, reporting
845    /// progress to the window, then reveal it.
846    pub fn download_update(&self) -> Result<()> {
847        let ui = self.require_ui()?;
848        if guard(&ui.download).phase == DownloadPhase::Downloading {
849            return Ok(());
850        }
851        self.set_download(ui, DownloadState { phase: DownloadPhase::Downloading, ..DownloadState::default() });
852        match self.fetch_installer(ui) {
853            Ok(path) => {
854                self.reveal(&path);
855                let shown = path.display().to_string();
856                *guard(&ui.downloaded) = Some(path);
857                self.set_download(
858                    ui,
859                    DownloadState {
860                        phase: DownloadPhase::Done,
861                        progress: Some(1.0),
862                        path: Some(shown),
863                        ..DownloadState::default()
864                    },
865                );
866                Ok(())
867            }
868            Err(Error::Moonbase(moonbase::Error::ReleaseNotCovered(refusal))) => {
869                // The screen swaps the download for the upgrade.
870                log::info!("moonbase: the update download was refused: {refusal}");
871                self.set_download(
872                    ui,
873                    DownloadState {
874                        phase: DownloadPhase::NotCovered,
875                        release_access: refusal.release_access().cloned(),
876                        upgrade_url: refusal.upgrade_url().map(str::to_owned),
877                        ..DownloadState::default()
878                    },
879                );
880                Err(Error::Moonbase(moonbase::Error::ReleaseNotCovered(refusal)))
881            }
882            Err(error) => {
883                log::warn!("moonbase: the update download failed: {error}");
884                self.set_download(
885                    ui,
886                    DownloadState {
887                        phase: DownloadPhase::Failed,
888                        error: Some(format!("The download failed: {error}")),
889                        ..DownloadState::default()
890                    },
891                );
892                Err(error)
893            }
894        }
895    }
896
897    fn fetch_installer(&self, ui: &Ui) -> Result<PathBuf> {
898        use std::io::{Read, Write};
899
900        let target = self.update_download_url()?;
901        let product = ui.product_name(self.app());
902        let name = Some(file_safe(&target.filename))
903            .filter(|name| name != "license")
904            .unwrap_or_else(|| format!("{} installer", file_safe(&product)));
905        let path = unique_path(&self.downloads_dir()?, &name);
906        let mut partial = path.as_os_str().to_owned();
907        partial.push(".part");
908        let partial = PathBuf::from(partial);
909
910        let agent =
911            ureq::Agent::config_builder().timeout_connect(Some(std::time::Duration::from_secs(15))).build().new_agent();
912        let response = agent.get(&target.url).call().map_err(|error| Error::Internal(error.to_string()))?;
913        let total = response
914            .headers()
915            .get(header::CONTENT_LENGTH)
916            .and_then(|value| value.to_str().ok())
917            .and_then(|value| value.parse::<u64>().ok())
918            .filter(|total| *total > 0);
919        let mut reader = response.into_body().into_reader();
920        let result = (|| -> std::io::Result<()> {
921            let mut file = std::fs::File::create(&partial)?;
922            let mut buffer = vec![0u8; 64 * 1024];
923            let mut received = 0u64;
924            #[cfg(desktop)]
925            let mut last_report = Instant::now();
926            loop {
927                let read = reader.read(&mut buffer)?;
928                if read == 0 {
929                    break;
930                }
931                file.write_all(&buffer[..read])?;
932                received += read as u64;
933                #[cfg(desktop)]
934                if last_report.elapsed() > Duration::from_millis(120) {
935                    last_report = Instant::now();
936                    self.set_download(
937                        ui,
938                        DownloadState {
939                            phase: DownloadPhase::Downloading,
940                            progress: total.map(|total| (received as f64 / total as f64).min(1.0)),
941                            ..DownloadState::default()
942                        },
943                    );
944                }
945            }
946            file.flush()?;
947            drop(file);
948            std::fs::rename(&partial, &path)
949        })();
950        if let Err(error) = result {
951            let _ = std::fs::remove_file(&partial);
952            return Err(Error::Internal(error.to_string()));
953        }
954        Ok(path)
955    }
956
957    /// Show the downloaded installer in the file manager again.
958    pub fn reveal_update(&self) -> Result<()> {
959        let ui = self.require_ui()?;
960        let path =
961            guard(&ui.downloaded).clone().ok_or_else(|| Error::Internal("Nothing has been downloaded".into()))?;
962        self.reveal(&path);
963        Ok(())
964    }
965}
966
967#[cfg(desktop)]
968fn build_window<R: Runtime>(app: &AppHandle<R>, title: &str) -> Result<()> {
969    use tauri::{WebviewUrl, WebviewWindowBuilder};
970
971    // Custom schemes are `scheme://localhost` where the webview supports them, and
972    // `http://scheme.localhost` on Windows.
973    let url = if cfg!(windows) { "http://moonbase.localhost/index.html" } else { "moonbase://localhost/index.html" };
974    let url = url.parse().map_err(|_| Error::Internal("Invalid window URL".into()))?;
975
976    let mut builder = WebviewWindowBuilder::new(app, WINDOW_LABEL, WebviewUrl::CustomProtocol(url))
977        .title(title)
978        .inner_size(WIDTH, 560.0)
979        .resizable(false)
980        .maximizable(false)
981        .visible(false)
982        .focused(true)
983        .center()
984        .theme(Some(tauri::Theme::Dark))
985        .background_color(tauri::window::Color(8, 11, 19, 255))
986        // The page takes dropped license files itself.
987        .disable_drag_drop_handler();
988    #[cfg(target_os = "macos")]
989    {
990        builder = builder.title_bar_style(tauri::TitleBarStyle::Overlay).hidden_title(true);
991    }
992    if let Some(main) = app.get_webview_window("main") {
993        builder = builder.parent(&main)?;
994    }
995    builder.build()?;
996    Ok(())
997}
998
999#[cfg(test)]
1000mod tests {
1001    use super::*;
1002
1003    #[test]
1004    fn file_names_are_made_safe() {
1005        assert_eq!(file_safe("Solstice"), "Solstice");
1006        assert_eq!(file_safe("A/B: C?"), "A-B- C-");
1007        assert_eq!(file_safe(" .. "), "license");
1008    }
1009
1010    #[test]
1011    fn a_taken_path_gets_a_numbered_sibling() {
1012        let dir = std::env::temp_dir().join(format!("moonbase-ui-{}", std::process::id()));
1013        std::fs::create_dir_all(&dir).unwrap();
1014        std::fs::write(dir.join("Solstice.dt"), "").unwrap();
1015        assert_eq!(unique_path(&dir, "Solstice.dt"), dir.join("Solstice (2).dt"));
1016        assert_eq!(unique_path(&dir, "Other.dt"), dir.join("Other.dt"));
1017        let _ = std::fs::remove_dir_all(&dir);
1018    }
1019
1020    #[test]
1021    fn skipped_versions_survive_and_keep_unknown_keys() {
1022        let dir = std::env::temp_dir().join(format!("moonbase-ui-state-{}", std::process::id()));
1023        std::fs::create_dir_all(&dir).unwrap();
1024        let ui = Ui::new(UiConfig::new(), "https://demo.moonbase.sh".into(), &dir.join("license.mb"));
1025        std::fs::write(&ui.state_file, r#"{"fromANewerBuild":true}"#).unwrap();
1026        assert!(!ui.is_skipped("2.0.0"));
1027        ui.skip("2.0.0").unwrap();
1028        ui.skip("2.0.0").unwrap();
1029        assert!(ui.is_skipped("2.0.0"));
1030        assert!(!ui.is_skipped("2.1.0"));
1031        let saved: Value = serde_json::from_slice(&std::fs::read(&ui.state_file).unwrap()).unwrap();
1032        assert_eq!(saved["fromANewerBuild"], true);
1033        assert_eq!(saved["ignoredUpdates"], json!(["2.0.0"]));
1034        let _ = std::fs::remove_dir_all(&dir);
1035    }
1036}
1037
1038#[cfg(test)]
1039mod serving {
1040    use moonbase::{ApiError, HttpRequest, HttpResponse, HttpTransport, StaticDeviceIdResolver};
1041    use tauri::test::{MockRuntime, mock_builder, mock_context, noop_assets};
1042
1043    use super::*;
1044
1045    struct Offline;
1046
1047    impl HttpTransport for Offline {
1048        fn send(&self, _request: &HttpRequest) -> moonbase::Result<HttpResponse> {
1049            Err(ApiError::unreachable("offline").into())
1050        }
1051    }
1052
1053    fn app(dir: &Path, ui: UiConfig) -> tauri::App<MockRuntime> {
1054        mock_builder()
1055            .plugin(
1056                crate::Builder::new(
1057                    "https://helio.moonbase.sh",
1058                    "solstice",
1059                    include_str!("../tests/fixtures/signing-public.pem"),
1060                )
1061                .license_file(dir.join("license.mb"))
1062                .device_id_resolver(StaticDeviceIdResolver::new("Studio", "device-id"))
1063                .transport(Offline)
1064                // The mock runtime has no webviews to present the window in.
1065                .ui(ui.auto_present_on_lock(false))
1066                .build(),
1067            )
1068            .build(mock_context(noop_assets()))
1069            .unwrap()
1070    }
1071
1072    fn get(app: &tauri::App<MockRuntime>, path: &str) -> Response<Vec<u8>> {
1073        let request = Request::builder().uri(format!("moonbase://localhost{path}")).body(Vec::new()).unwrap();
1074        serve(app.handle(), &request)
1075    }
1076
1077    fn text(response: &Response<Vec<u8>>) -> String {
1078        String::from_utf8(response.body().clone()).unwrap()
1079    }
1080
1081    #[test]
1082    fn the_page_is_served_with_a_strict_content_security_policy() {
1083        let dir = tempfile::tempdir().unwrap();
1084        let app = app(dir.path(), UiConfig::new());
1085        let page = get(&app, "/index.html");
1086        assert_eq!(page.status(), StatusCode::OK);
1087        assert!(page.headers()[header::CONTENT_TYPE].to_str().unwrap().starts_with("text/html"));
1088        let csp = page.headers()["Content-Security-Policy"].to_str().unwrap();
1089        assert!(csp.contains("script-src 'self'") && csp.contains("connect-src ipc: http://ipc.localhost"));
1090        assert!(text(&page).contains("config.js"));
1091        for path in ["/", "/ui.css", "/ui.js", "/window.js"] {
1092            assert_eq!(get(&app, path).status(), StatusCode::OK, "{path}");
1093        }
1094        assert_eq!(get(&app, "/../Cargo.toml").status(), StatusCode::NOT_FOUND);
1095        assert_eq!(get(&app, "/logo").status(), StatusCode::NOT_FOUND);
1096    }
1097
1098    #[test]
1099    fn the_config_carries_the_branding() {
1100        let dir = tempfile::tempdir().unwrap();
1101        let ui = UiConfig::new()
1102            .product_name("Solstice")
1103            .manufacturer_name("Helio Audio")
1104            .accent("#4f46e5")
1105            .logo_svg("<svg xmlns=\"http://www.w3.org/2000/svg\"/>")
1106            .trial_feature("Full DSP engine", true)
1107            .show_addons(true);
1108        let app = app(dir.path(), ui);
1109
1110        let script = text(&get(&app, "/config.js"));
1111        let json: Value =
1112            serde_json::from_str(script.trim_start_matches("window.__MOONBASE_UI__ = ").trim_end_matches(';')).unwrap();
1113        assert_eq!(json["productName"], "Solstice");
1114        assert_eq!(json["manufacturerName"], "Helio Audio");
1115        assert_eq!(json["accent"], "#4f46e5");
1116        // An SVG logo is inlined, so it never needs loading.
1117        assert_eq!(json["logo"], Value::Null);
1118        assert!(json["logoSvg"].as_str().unwrap().starts_with("<svg"));
1119        assert_eq!(json["activationHost"], "helio.moonbase.sh");
1120        assert_eq!(json["requestFileName"], "Solstice.dt");
1121        assert_eq!(json["trialFeatures"][0]["label"], "Full DSP engine");
1122        assert_eq!(json["showAddOns"], true);
1123
1124        let logo = get(&app, "/logo");
1125        assert_eq!(logo.headers()[header::CONTENT_TYPE], "image/svg+xml");
1126    }
1127
1128    #[test]
1129    fn an_overlay_gets_its_logo_inline_and_its_code_injected() {
1130        let dir = tempfile::tempdir().unwrap();
1131        let png = vec![0x89, b'P', b'N', b'G'];
1132        let ui = UiConfig::new().presentation(Presentation::overlay()).logo_png(png);
1133        assert_eq!(ui.overlay_window(), Some("main"));
1134        let app = app(dir.path(), ui);
1135        let moonbase = app.state::<Moonbase<MockRuntime>>();
1136        let config = moonbase.ui().unwrap().page_config(app.handle());
1137        // The app's page cannot load from the plugin's scheme, so the bytes travel along.
1138        assert_eq!(config["logo"], "data:image/png;base64,iVBORw==");
1139
1140        let script = overlay_script();
1141        assert!(script.starts_with("(function () {"));
1142        assert!(script.contains("const CSS = \"/*"));
1143        assert!(script.contains("function createActivationUI"));
1144        assert!(script.contains("__moonbaseOverlay"));
1145        assert_eq!(Presentation::overlay_on("editor"), Presentation::Overlay { window: "editor".into() });
1146        assert_eq!(UiConfig::new().overlay_window(), None);
1147    }
1148
1149    #[test]
1150    fn a_disabled_window_serves_nothing() {
1151        let dir = tempfile::tempdir().unwrap();
1152        let app = app(dir.path(), UiConfig::disabled());
1153        assert_eq!(get(&app, "/index.html").status(), StatusCode::NOT_FOUND);
1154    }
1155}