1use 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
27pub const WINDOW_LABEL: &str = "moonbase-activation";
29pub(crate) const SCHEME: &str = "moonbase";
30
31const 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
42const 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
48pub struct TrialFeature {
49 pub label: String,
50 pub included: bool,
51}
52
53#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
56#[serde(rename_all = "camelCase")]
57pub struct UiStrings {
58 pub welcome_title: Option<String>,
60 pub welcome_body: Option<String>,
62 pub activate_online: Option<String>,
64 pub activate_offline: Option<String>,
66 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 pub expired_notice: Option<String>,
78 pub not_covered_title: Option<String>,
81 pub not_covered_body: Option<String>,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
91#[serde(rename_all = "camelCase")]
92#[non_exhaustive]
93pub enum Screen {
94 License,
96 Update,
98 Offline,
100 #[serde(rename = "addons")]
102 AddOns,
103}
104
105#[derive(Debug, Clone, Default, PartialEq, Eq)]
107#[non_exhaustive]
108pub enum Presentation {
109 #[default]
111 Window,
112 Overlay {
116 window: String,
118 },
119}
120
121impl Presentation {
122 pub fn overlay() -> Self {
124 Self::overlay_on("main")
125 }
126
127 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#[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 pub fn disabled() -> Self {
199 Self { enabled: false, ..Self::default() }
200 }
201
202 pub fn product_name(mut self, name: impl Into<String>) -> Self {
204 self.product_name = Some(name.into());
205 self
206 }
207
208 pub fn manufacturer_name(mut self, name: impl Into<String>) -> Self {
210 self.manufacturer_name = Some(name.into());
211 self
212 }
213
214 pub fn accent(mut self, color: impl Into<String>) -> Self {
217 self.accent = color.into();
218 self
219 }
220
221 pub fn logo_svg(mut self, svg: impl Into<String>) -> Self {
224 self.logo = Some(Logo::Svg(svg.into()));
225 self
226 }
227
228 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 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 pub fn trial_length_days(mut self, days: u32) -> Self {
243 self.trial_length_days = days.max(1);
244 self
245 }
246
247 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 pub fn show_addons(mut self, show: bool) -> Self {
265 self.show_addons = show;
266 self
267 }
268
269 pub fn auto_present_on_lock(mut self, present: bool) -> Self {
272 self.auto_present_on_lock = present;
273 self
274 }
275
276 pub fn auto_present_update(mut self, present: bool) -> Self {
279 self.auto_present_update = present;
280 self
281 }
282
283 pub fn reduce_motion(mut self, reduce: bool) -> Self {
286 self.reduce_motion = reduce;
287 self
288 }
289
290 pub fn window_title(mut self, title: impl Into<String>) -> Self {
292 self.window_title = Some(title.into());
293 self
294 }
295
296 pub fn presentation(mut self, presentation: Presentation) -> Self {
304 self.presentation = presentation;
305 self
306 }
307
308 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
317pub(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#[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 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 NotCovered,
347}
348
349#[derive(Debug, Clone, Default, Serialize)]
351pub(crate) struct UiOpened {
352 screen: Option<Screen>,
353}
354
355pub(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 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 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
467fn 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
476fn 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
503pub(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
530fn 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 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 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 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 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 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 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 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 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 pub fn skip_update(&self, version: &str) -> Result<()> {
768 self.require_ui()?.skip(version)
769 }
770
771 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 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 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 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 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 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 .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 .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 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 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}