Skip to main content

tauri_plugin_moonbase/
lib.rs

1//! # Moonbase licensing for Tauri
2//!
3//! License activation for a Tauri 2 app, built on the [`moonbase`] SDK. Register
4//! the plugin with your store's details and it validates the stored license at
5//! startup, runs browser and offline activation, trials and deactivation, watches
6//! for expiry, and tells your frontend about every change:
7//!
8//! ```no_run
9//! # const PUBLIC_KEY: &str = "";
10//! tauri::Builder::default()
11//!     .plugin(tauri_plugin_opener::init())
12//!     .plugin(
13//!         tauri_plugin_moonbase::Builder::new("https://your-tenant.moonbase.sh", "your-product", PUBLIC_KEY)
14//!             .build(),
15//!     )
16//! #   ;
17//! ```
18//!
19//! From the frontend, with `"withGlobalTauri": true`:
20//!
21//! ```js
22//! const { moonbase } = window.__TAURI__;
23//! moonbase.onStateChanged((state) => render(state));
24//! render(await moonbase.state());
25//! await moonbase.activate(); // opens the browser, then polls
26//! ```
27//!
28//! Or invoke the commands directly: `invoke('plugin:moonbase|state')`. Grant them
29//! with the `moonbase:default` permission in your capability file.
30//!
31//! Gate features, and add-ons, in Rust with [`MoonbaseExt`]:
32//!
33//! ```no_run
34//! use tauri_plugin_moonbase::MoonbaseExt;
35//! # fn export(app: &tauri::AppHandle) {
36//! if !app.moonbase().is_licensed() {
37//!     return;
38//! }
39//! if app.moonbase().has_sub_product("gradients") {
40//!     // the add-on is unlocked on this device
41//! }
42//! # }
43//! ```
44
45use std::path::PathBuf;
46use std::sync::Arc;
47use std::time::Duration;
48
49use moonbase::{
50    DeviceIdFallback, DeviceIdResolver, FileLicenseStore, HttpTransport, Licensing, LicensingOptions,
51    MoonbaseDeviceIdResolver, MoonbaseDeviceIdResolverOptions,
52};
53use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
54use tauri::webview::PageLoadEvent;
55use tauri::{Manager, RunEvent, Runtime, WindowEvent};
56
57mod commands;
58mod controller;
59mod error;
60mod models;
61mod ui;
62
63pub use crate::controller::{Moonbase, STATE_CHANGED_EVENT};
64pub use crate::error::{Error, Result};
65pub use crate::models::{
66    AddOnsPhase, AddOnsState, DeviceInfo, Failure, LicenseState, LicenseView, LockReason, NotCovered,
67    PendingActivation, Status, UpdateHint, UpdateInfo,
68};
69pub use crate::ui::{Presentation, Screen, TrialFeature, UiConfig, UiStrings, WINDOW_LABEL};
70pub use moonbase;
71
72/// The plugin's version, as reported in the `User-Agent`.
73pub const VERSION: &str = env!("CARGO_PKG_VERSION");
74
75/// Access the plugin's [`Moonbase`] controller from an `App`, `AppHandle`,
76/// `Window` or anything else that implements [`Manager`].
77pub trait MoonbaseExt<R: Runtime> {
78    fn moonbase(&self) -> &Moonbase<R>;
79}
80
81impl<R: Runtime, T: Manager<R>> MoonbaseExt<R> for T {
82    fn moonbase(&self) -> &Moonbase<R> {
83        self.state::<Moonbase<R>>().inner()
84    }
85}
86
87/// Configures and builds the plugin.
88#[must_use]
89pub struct Builder {
90    options: LicensingOptions,
91    license_file: Option<PathBuf>,
92    device_id_resolver: Option<Arc<dyn DeviceIdResolver>>,
93    transport: Option<Arc<dyn HttpTransport>>,
94    allow_device_name_fallback: bool,
95    open_browser: bool,
96    ui: UiConfig,
97}
98
99impl Builder {
100    /// Your store (`https://<tenant>.moonbase.sh`), the product id, and the
101    /// product's public key (PEM or base64 DER), all from the Moonbase dashboard.
102    pub fn new(endpoint: impl Into<String>, product_id: impl Into<String>, public_key: impl Into<String>) -> Self {
103        Self {
104            options: LicensingOptions::new(endpoint, product_id, public_key),
105            license_file: None,
106            device_id_resolver: None,
107            transport: None,
108            allow_device_name_fallback: false,
109            open_browser: true,
110            ui: UiConfig::default(),
111        }
112    }
113
114    /// Pin the token issuer to your Moonbase account id.
115    pub fn account_id(mut self, account_id: impl Into<String>) -> Self {
116        self.options.account_id = Some(account_id.into());
117        self
118    }
119
120    /// The version reported to Moonbase and compared with the latest release, and
121    /// the version a license that covers only some versions has to cover.
122    /// Defaults to the app's own version from `tauri.conf.json`.
123    pub fn application_version(mut self, version: impl Into<String>) -> Self {
124        self.options.application_version = Some(version.into());
125        self
126    }
127
128    /// Whether a license that doesn't cover the app's version is refused on this
129    /// device too, when Moonbase isn't asked (an offline license, or within the
130    /// check interval). Moonbase refuses one when validating online either way.
131    /// On by default.
132    pub fn enforce_release_access(mut self, enforce: bool) -> Self {
133        self.options.enforce_release_access = enforce;
134        self
135    }
136
137    /// Whether activating and validating also takes a seat of each add-on (a
138    /// sub-product the merchant includes in this product's license) that the
139    /// customer owns, so the license reports it. On by default. Turn it off to take
140    /// seats yourself with [`Moonbase::activate_sub_product`]; the add-ons screen
141    /// then offers "Activate" for each one owned.
142    pub fn auto_activate_sub_products(mut self, auto_activate: bool) -> Self {
143        self.options.auto_activate_sub_products = auto_activate;
144        self
145    }
146
147    /// Where the license is kept. Defaults to `license.mb` in the app's data
148    /// directory.
149    pub fn license_file(mut self, path: impl Into<PathBuf>) -> Self {
150        self.license_file = Some(path.into());
151        self
152    }
153
154    /// How long an online-activated license keeps working without a successful
155    /// online check. Default 7 days.
156    pub fn grace_period(mut self, grace: Duration) -> Self {
157        self.options.online_validation_grace_period = grace;
158        self
159    }
160
161    /// The least time between online checks of the license. Default 5 minutes.
162    pub fn check_interval(mut self, interval: Duration) -> Self {
163        self.options.online_validation_min_interval = interval;
164        self
165    }
166
167    pub fn http_timeouts(mut self, connect: Duration, request: Duration) -> Self {
168        self.options = self.options.http_timeouts(connect, request);
169        self
170    }
171
172    /// An extra `User-Agent` segment for a layer built on top of this plugin, such
173    /// as `"MyFramework/2.0"`. Appended after the plugin's own.
174    pub fn client_info(mut self, client_info: impl Into<String>) -> Self {
175        self.options.client_info = Some(client_info.into());
176        self
177    }
178
179    /// Sent as `meta[<key>]` with license requests.
180    pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
181        self.options = self.options.metadata(key, value);
182        self
183    }
184
185    /// Override how this machine is identified. Defaults to the cross-SDK
186    /// [`MoonbaseDeviceIdResolver`], so a license activated through any Moonbase
187    /// SDK on this machine validates here too. Pass the resolver itself, or an
188    /// `Arc` of one.
189    pub fn device_id_resolver(mut self, resolver: impl DeviceIdResolver + 'static) -> Self {
190        self.device_id_resolver = Some(Arc::new(resolver));
191        self
192    }
193
194    /// Send requests through your own [`HttpTransport`], e.g. one that shares the
195    /// app's proxy settings. Defaults to the SDK's bundled transport. Pass the
196    /// transport itself, or an `Arc` of one.
197    pub fn transport(mut self, transport: impl HttpTransport + 'static) -> Self {
198        self.transport = Some(Arc::new(transport));
199        self
200    }
201
202    /// Accept a deliberately weaker device id (a hash of the host name, stamped
203    /// `mbd2n_`) on machines with no readable hardware identity, instead of
204    /// refusing to activate. Ignored with a custom resolver.
205    pub fn allow_device_name_fallback(mut self, allow: bool) -> Self {
206        self.allow_device_name_fallback = allow;
207        self
208    }
209
210    /// Whether `activate` opens the browser itself. On by default; turn it off to
211    /// open [`PendingActivation::browser_url`] yourself.
212    pub fn open_browser(mut self, open: bool) -> Self {
213        self.open_browser = open;
214        self
215    }
216
217    /// Brand and tune the built-in activation window, or turn it off with
218    /// [`UiConfig::disabled`] to drive your own UI from the state event.
219    pub fn ui(mut self, ui: UiConfig) -> Self {
220        self.ui = ui;
221        self
222    }
223
224    pub fn build<R: Runtime>(self) -> TauriPlugin<R> {
225        // An overlay mounts inside the app's own pages, so its code rides along in
226        // every webview, dormant until the plugin mounts it.
227        let overlay_script = (self.ui.enabled && self.ui.overlay_window().is_some()).then(ui::overlay_script);
228        let mut plugin = PluginBuilder::new("moonbase");
229        if let Some(script) = overlay_script {
230            plugin = plugin.js_init_script(script);
231        }
232        plugin
233            .invoke_handler(tauri::generate_handler![
234                commands::state,
235                commands::activate,
236                commands::cancel_activation,
237                commands::refresh,
238                commands::deactivate,
239                commands::forget,
240                commands::device_token,
241                commands::activate_offline,
242                commands::dismiss_error,
243                commands::device,
244                commands::check_update,
245                commands::update_download_url,
246                commands::present,
247                commands::close_ui,
248                commands::ui_ready,
249                commands::ui_resize,
250                commands::save_machine_file,
251                commands::download_update,
252                commands::reveal_update,
253                commands::skip_update,
254                commands::open_link,
255                commands::load_add_ons,
256                commands::refresh_add_ons,
257                commands::activate_sub_product,
258                commands::request_sub_product_trial,
259                commands::buy_sub_product,
260                commands::upgrade_license,
261            ])
262            .register_uri_scheme_protocol(ui::SCHEME, |context, request| ui::serve(context.app_handle(), &request))
263            .setup(move |app, _api| {
264                let mut options = self.options;
265                if options.application_version.is_none() {
266                    options.application_version = Some(app.package_info().version.to_string());
267                }
268                let segment =
269                    format!("tauri-plugin-moonbase/{VERSION} (Tauri {}; {})", tauri::VERSION, std::env::consts::OS);
270                options.client_info = Some(match options.client_info.take() {
271                    Some(outer) => format!("{segment} {outer}"),
272                    None => segment,
273                });
274
275                let license_file = match self.license_file {
276                    Some(path) => path,
277                    None => app.path().app_data_dir()?.join("license.mb"),
278                };
279                let store = FileLicenseStore::new(license_file);
280                let resolver: Arc<dyn DeviceIdResolver> = self.device_id_resolver.unwrap_or_else(|| {
281                    Arc::new(MoonbaseDeviceIdResolver::with_options(MoonbaseDeviceIdResolverOptions {
282                        fallback: if self.allow_device_name_fallback {
283                            DeviceIdFallback::DeviceName
284                        } else {
285                            DeviceIdFallback::None
286                        },
287                        ..Default::default()
288                    }))
289                });
290
291                let settings = controller::Settings {
292                    grace_period: options.online_validation_grace_period,
293                    check_interval: options.online_validation_min_interval,
294                    application_version: options.application_version.clone(),
295                    open_browser: self.open_browser,
296                    auto_activate_sub_products: options.auto_activate_sub_products,
297                };
298                let mut licensing = Licensing::builder(options).store(store.clone()).device_id_resolver(resolver);
299                if let Some(transport) = self.transport {
300                    licensing = licensing.transport(transport);
301                }
302                let licensing = licensing.build()?;
303
304                let ui =
305                    self.ui.enabled.then(|| ui::Ui::new(self.ui, licensing.options().endpoint.clone(), store.path()));
306                let moonbase = Moonbase::new(app.clone(), licensing, store, settings, ui);
307                moonbase.start();
308                app.manage(moonbase);
309                Ok(())
310            })
311            .on_page_load(|webview, payload| {
312                if payload.event() == PageLoadEvent::Finished
313                    && let Some(moonbase) = webview.try_state::<Moonbase<R>>()
314                {
315                    moonbase.on_page_load(webview.label());
316                }
317            })
318            .on_event(|app, event| {
319                let Some(moonbase) = app.try_state::<Moonbase<R>>() else {
320                    return;
321                };
322                match event {
323                    RunEvent::Exit => moonbase.shut_down(),
324                    // Coming back from the browser after buying an add-on.
325                    RunEvent::WindowEvent { event: WindowEvent::Focused(focused), .. } => {
326                        moonbase.on_focus_changed(*focused);
327                    }
328                    _ => {}
329                }
330            })
331            .build()
332    }
333}
334
335impl std::fmt::Debug for Builder {
336    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337        f.debug_struct("Builder")
338            .field("endpoint", &self.options.endpoint)
339            .field("product_id", &self.options.product_id)
340            .field("license_file", &self.license_file)
341            .finish_non_exhaustive()
342    }
343}