use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::thread;
use std::time::{Duration, Instant, SystemTime};
use moonbase::version::update_available;
use moonbase::{
ActivationMethod, ActivationRequest, DownloadTarget, Error as SdkError, FileLicenseStore, License, LicenseStore,
Licensing, Platform, ReleaseAccess, ReleaseNotCovered, can_download,
};
use tauri::{AppHandle, Emitter, Manager, Runtime};
use crate::error::{Error, Result};
use crate::models::{
AddOnsPhase, AddOnsState, DeviceInfo, Failure, LicenseState, LicenseView, LockReason, NotCovered,
PendingActivation, Status, UpdateHint, UpdateInfo,
};
use crate::ui::Ui;
pub const STATE_CHANGED_EVENT: &str = "moonbase://state-changed";
const POLL_INTERVAL: Duration = Duration::from_secs(2);
const WATCH_INTERVAL: Duration = Duration::from_secs(2);
const PURCHASE_WINDOW: Duration = Duration::from_secs(60 * 60);
pub(crate) struct Settings {
pub grace_period: Duration,
pub check_interval: Duration,
pub application_version: Option<String>,
pub open_browser: bool,
pub auto_activate_sub_products: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct FileStamp {
exists: bool,
len: u64,
modified: Option<SystemTime>,
}
#[derive(Debug, Clone)]
struct NotCoveredLicense {
license: License,
app_version: String,
release_access: Option<ReleaseAccess>,
upgrade_url: Option<String>,
}
impl NotCoveredLicense {
fn from_refusal(refusal: &ReleaseNotCovered, fallback: &License) -> Self {
let license = refusal.license().cloned().unwrap_or_else(|| fallback.clone());
Self {
app_version: refusal.app_version().to_owned(),
release_access: refusal.release_access().cloned().or_else(|| license.release_access.clone()),
upgrade_url: refusal.upgrade_url().map(str::to_owned),
license,
}
}
fn view(&self) -> NotCovered {
NotCovered {
license: LicenseView::from(&self.license),
app_version: self.app_version.clone(),
release_access: self.release_access.clone(),
upgrade_url: self.upgrade_url.clone(),
}
}
}
#[derive(Debug, Clone)]
enum Standing {
Unlicensed(Option<LockReason>),
Licensed(License),
TrialExpired(License),
NotCovered(NotCoveredLicense),
}
impl Default for Standing {
fn default() -> Self {
Self::Unlicensed(None)
}
}
impl Standing {
fn license(&self) -> Option<&License> {
match self {
Self::Licensed(license) => Some(license),
_ => None,
}
}
fn held(&self) -> Option<&License> {
match self {
Self::Licensed(license) => Some(license),
Self::NotCovered(not_covered) => Some(¬_covered.license),
Self::Unlicensed(_) | Self::TrialExpired(_) => None,
}
}
fn not_covered(&self) -> Option<&NotCoveredLicense> {
match self {
Self::NotCovered(not_covered) => Some(not_covered),
_ => None,
}
}
fn lock_reason(&self) -> Option<LockReason> {
match self {
Self::Unlicensed(reason) => *reason,
Self::Licensed(_) => None,
Self::TrialExpired(_) => Some(LockReason::Expired),
Self::NotCovered(_) => Some(LockReason::NotCovered),
}
}
}
#[derive(Default)]
struct Inner {
loading: bool,
standing: Standing,
activation: Option<PendingActivation>,
pending: Option<ActivationRequest>,
deactivating: Option<u64>,
error: Option<Failure>,
refreshes_in_flight: usize,
last_grace_check: Option<Instant>,
file_stamp: Option<FileStamp>,
update_upgrade_url: Option<String>,
add_ons: AddOnsState,
add_ons_generation: u64,
add_on_calls: u64,
add_on_call: u64,
lapses_checked: HashSet<String>,
purchase_opened_at: Option<Instant>,
left_for_purchase: Option<Instant>,
countdown: (Vec<SystemTime>, Vec<u64>),
}
struct Shared<R: Runtime> {
app: AppHandle<R>,
licensing: Arc<Licensing>,
store: FileLicenseStore,
settings: Settings,
inner: Mutex<Inner>,
generation: AtomicU64,
licensed: AtomicBool,
shutdown: AtomicBool,
emit_lock: Mutex<()>,
online_calls: Mutex<()>,
emitted_sub_products: Mutex<Vec<String>>,
ui: Option<Ui>,
}
pub struct Moonbase<R: Runtime>(Arc<Shared<R>>);
impl<R: Runtime> Clone for Moonbase<R> {
fn clone(&self) -> Self {
Self(self.0.clone())
}
}
enum Activity {
StartActivation,
Activation,
Deactivation,
OfflineActivation,
}
fn busy_status(status: u16) -> bool {
status == 408 || status == 429 || status >= 500
}
fn failure(activity: Activity, error: &SdkError) -> Failure {
let message = match (activity, error) {
(_, SdkError::InsufficientDeviceIdentity { .. }) => {
"This computer can't be identified, so it can't be activated. Contact the developer for help.".into()
}
(Activity::Deactivation, SdkError::StoreClosed(_)) => {
"This store has closed, so this computer can't be deactivated.".into()
}
(_, SdkError::StoreClosed(_)) => "This store has closed, so activation isn't available.".into(),
(Activity::StartActivation, SdkError::Api(api)) if busy_status(api.status_code()) => {
"Moonbase couldn't start activation right now. Try again in a minute.".into()
}
(Activity::StartActivation, SdkError::Api(_)) => {
"Couldn't reach Moonbase to start activation. Check your internet connection and try again.".into()
}
(Activity::StartActivation, error) if error.is_license_rejection() => {
"Activation isn't available for this product right now. If this keeps happening, contact the developer."
.into()
}
(Activity::Activation, SdkError::ActivationRequestEnded(_)) => {
"This activation expired or was cancelled. Activate again to continue.".into()
}
(Activity::Activation, SdkError::LicenseExpired(_)) => {
"This license has expired, so it can't be activated.".into()
}
(Activity::Activation, error) if error.is_license_invalid() => {
"Activation was rejected. If this keeps happening, contact the developer.".into()
}
(Activity::Deactivation, SdkError::Api(api)) if busy_status(api.status_code()) => {
"Moonbase couldn't deactivate right now. Try again in a minute.".into()
}
(Activity::Deactivation, SdkError::Api(_)) => {
"Couldn't reach Moonbase to deactivate. Try again when you're online.".into()
}
(Activity::OfflineActivation, SdkError::ReleaseNotCovered(_)) => {
"That license doesn't cover this version.".into()
}
(Activity::OfflineActivation, _) => "That license file isn't valid for this device.".into(),
(Activity::StartActivation, error) => format!("Activation couldn't start: {error}"),
(Activity::Activation, error) => format!("Activation failed: {error}"),
(Activity::Deactivation, error) => format!("Couldn't deactivate: {error}"),
};
Failure { message, detail: error.to_string(), kind: Some(error.error_type()) }
}
fn add_on_failure(error: &SdkError, lead: &str, not_found: &str) -> String {
match error {
SdkError::Api(api) if api.status_code() == 404 => not_found.into(),
SdkError::Api(api) if busy_status(api.status_code()) => {
"Moonbase couldn't do that right now. Try again in a minute.".into()
}
SdkError::Api(_) => "Couldn't reach Moonbase. Check your internet connection and try again.".into(),
SdkError::StoreClosed(_) => "This store has closed, so add-ons aren't available.".into(),
SdkError::InsufficientDeviceIdentity { .. } => {
"This computer can't be identified. Contact the developer for help.".into()
}
error if error.is_license_rejection() || matches!(error, SdkError::Configuration(_)) => {
format!("{lead}. If this keeps happening, contact the developer.")
}
error => format!("{lead}: {error}"),
}
}
fn ends_activation(error: &SdkError) -> bool {
error.is_license_rejection()
|| matches!(
error,
SdkError::ActivationRequestEnded(_)
| SdkError::StoreClosed(_)
| SdkError::InsufficientDeviceIdentity { .. }
| SdkError::Configuration(_)
)
}
fn age(time: SystemTime) -> Duration {
SystemTime::now().duration_since(time).unwrap_or(Duration::ZERO)
}
fn days_until(when: SystemTime) -> u64 {
when.duration_since(SystemTime::now()).map_or(0, |left| left.as_secs().div_ceil(86_400))
}
fn active_sub_products(license: &License, now: SystemTime) -> Vec<String> {
let mut active: Vec<String> = Vec::new();
for entry in &license.sub_products {
if license.has_sub_product_at(&entry.id, now) && !active.contains(&entry.id) {
active.push(entry.id.clone());
}
}
active
}
#[derive(Default)]
struct StartupOutcome {
license: Option<License>,
expired_trial: Option<License>,
not_covered: Option<NotCoveredLicense>,
delete_expired_offline: bool,
reason: Option<LockReason>,
}
fn accept_locally(licensing: &Licensing, license: License, outcome: &mut StartupOutcome) {
match licensing.validator().ensure_covers_application_version(&license) {
Ok(()) => outcome.license = Some(license),
Err(SdkError::ReleaseNotCovered(refusal)) => {
log::info!("moonbase: the stored license doesn't cover this version: {refusal}");
outcome.not_covered = Some(NotCoveredLicense::from_refusal(&refusal, &license));
}
Err(error) => {
log::warn!("moonbase: stored license rejected: {error}");
outcome.reason = Some(LockReason::Invalid);
}
}
}
fn check_stored_license(
licensing: &Licensing,
grace_period: Duration,
still_current: impl Fn() -> bool,
) -> StartupOutcome {
let mut outcome = StartupOutcome::default();
let stored = match licensing.store().load_local_license() {
Ok(Some(stored)) => stored,
Ok(None) => return outcome,
Err(error) => {
log::warn!("moonbase: loading the stored license failed: {error}");
return outcome;
}
};
let peek = match licensing.validate_token_local_allow_expired(&stored.token) {
Ok(peek) => peek,
Err(SdkError::InsufficientDeviceIdentity { .. }) => {
log::warn!("moonbase: could not identify this device, so the stored license could not be checked");
return outcome;
}
Err(error) => {
log::warn!("moonbase: stored license rejected: {error}");
outcome.reason = Some(LockReason::Invalid);
return outcome;
}
};
if peek.method == ActivationMethod::Offline {
if peek.is_expired() {
log::info!("moonbase: the stored offline license has expired; removing it");
outcome.delete_expired_offline = true;
outcome.reason = Some(LockReason::Expired);
} else {
accept_locally(licensing, peek, &mut outcome);
}
return outcome;
}
match licensing.validate_token_online_with(&stored.token, still_current) {
Ok(license) => outcome.license = Some(license),
Err(SdkError::ReleaseNotCovered(refusal)) => {
log::info!("moonbase: the stored license doesn't cover this version: {refusal}");
outcome.not_covered = Some(NotCoveredLicense::from_refusal(&refusal, &peek));
}
Err(SdkError::LicenseExpired(_)) => {
outcome.reason = Some(LockReason::Expired);
if peek.trial {
outcome.expired_trial = Some(peek);
}
}
Err(error) if error.is_license_invalid() => {
log::warn!("moonbase: re-validating the stored license failed: {error}");
outcome.reason = Some(LockReason::Invalid);
}
Err(SdkError::Storage(error)) => {
log::warn!("moonbase: re-validating the stored license failed: {error}");
if age(peek.validated_at) <= grace_period {
accept_locally(licensing, peek, &mut outcome);
} else {
outcome.reason = Some(LockReason::Unverified);
}
}
Err(error) => {
log::warn!("moonbase: re-validating the stored license failed: {error}");
outcome.reason = Some(LockReason::Unverified);
}
}
outcome
}
enum AddOnOutcome {
Applied {
relist: bool,
},
Changed,
Failed {
error: SdkError,
recheck: bool,
},
}
const LICENSE_CHANGED: &str = "Your license changed while this was in progress.";
impl<R: Runtime> Moonbase<R> {
pub(crate) fn new(
app: AppHandle<R>,
licensing: Licensing,
store: FileLicenseStore,
settings: Settings,
ui: Option<Ui>,
) -> Self {
Self(Arc::new(Shared {
app,
licensing: Arc::new(licensing),
store,
settings,
inner: Mutex::new(Inner::default()),
generation: AtomicU64::new(0),
licensed: AtomicBool::new(false),
shutdown: AtomicBool::new(false),
emit_lock: Mutex::new(()),
online_calls: Mutex::new(()),
emitted_sub_products: Mutex::default(),
ui,
}))
}
fn inner(&self) -> MutexGuard<'_, Inner> {
self.0.inner.lock().unwrap_or_else(PoisonError::into_inner)
}
fn online_turn(&self) -> MutexGuard<'_, ()> {
self.0.online_calls.lock().unwrap_or_else(PoisonError::into_inner)
}
fn bump(&self) -> u64 {
self.0.generation.fetch_add(1, Ordering::SeqCst) + 1
}
fn is_current(&self, generation: u64) -> bool {
self.0.generation.load(Ordering::SeqCst) == generation
}
fn emit(&self) {
let _ordered = self.0.emit_lock.lock().unwrap_or_else(PoisonError::into_inner);
let state = self.state();
state
.active_sub_products
.clone_into(&mut self.0.emitted_sub_products.lock().unwrap_or_else(PoisonError::into_inner));
if let Err(error) = self.0.app.emit(STATE_CHANGED_EVENT, &state) {
log::warn!("moonbase: could not emit the license state: {error}");
}
self.after_state_change(&state);
}
pub(crate) fn app(&self) -> &AppHandle<R> {
&self.0.app
}
pub(crate) fn ui(&self) -> Option<&Ui> {
self.0.ui.as_ref()
}
pub fn licensing(&self) -> &Licensing {
&self.0.licensing
}
pub fn is_licensed(&self) -> bool {
self.0.licensed.load(Ordering::Acquire)
}
pub fn license(&self) -> Option<License> {
self.inner().standing.license().cloned()
}
pub fn has_sub_product(&self, sub_product_id: &str) -> bool {
self.inner().standing.license().is_some_and(|license| license.has_sub_product(sub_product_id))
}
pub fn active_sub_products(&self) -> Vec<String> {
self.inner()
.standing
.license()
.map(|license| active_sub_products(license, SystemTime::now()))
.unwrap_or_default()
}
pub fn license_file(&self) -> PathBuf {
self.0.store.path().to_owned()
}
pub fn state(&self) -> LicenseState {
let inner = self.inner();
let status = if inner.loading {
Status::Loading
} else if inner.activation.is_some() {
Status::Activating
} else {
match &inner.standing {
Standing::Licensed(license) if license.trial => Status::Trial,
Standing::Licensed(_) => Status::Licensed,
Standing::TrialExpired(_) => Status::TrialExpired,
Standing::NotCovered(_) => Status::NotCovered,
Standing::Unlicensed(_) => Status::Unlicensed,
}
};
let license = inner.standing.license();
let update = match (license, &self.0.settings.application_version) {
(Some(license), Some(current)) => license
.licensed_product
.current_release_version
.as_ref()
.filter(|latest| update_available(current, latest))
.map(|latest| UpdateHint { current_version: current.clone(), latest_version: latest.clone() }),
_ => None,
};
LicenseState {
status,
licensed: license.is_some(),
license: license.map(LicenseView::from),
expired_trial: match &inner.standing {
Standing::TrialExpired(trial) => Some(LicenseView::from(trial)),
_ => None,
},
not_covered: inner.standing.not_covered().map(NotCoveredLicense::view),
lock_reason: inner.standing.lock_reason(),
activation: inner.activation.clone(),
busy: inner.deactivating.is_some(),
refreshing: inner.refreshes_in_flight > 0,
error: inner.error.clone(),
update,
active_sub_products: license
.map(|license| active_sub_products(license, SystemTime::now()))
.unwrap_or_default(),
add_ons: inner.add_ons.clone(),
}
}
fn is_older_than_held(inner: &Inner, copy: &License) -> bool {
inner
.standing
.held()
.is_some_and(|held| held.activation_id == copy.activation_id && copy.validated_at < held.validated_at)
}
fn reset_add_ons(inner: &mut Inner) {
inner.add_ons_generation += 1;
inner.add_ons = AddOnsState::default();
inner.add_on_call = 0;
inner.lapses_checked.clear();
inner.purchase_opened_at = None;
inner.left_for_purchase = None;
}
fn set_standing(&self, inner: &mut Inner, standing: Standing) {
let same_activation = matches!(
(inner.standing.license(), standing.license()),
(Some(current), Some(next)) if current.activation_id == next.activation_id
);
self.0.licensed.store(standing.license().is_some(), Ordering::Release);
if !same_activation {
Self::reset_add_ons(inner);
}
inner.standing = standing;
}
fn set_license(&self, inner: &mut Inner, license: License) {
self.set_standing(inner, Standing::Licensed(license));
}
fn lock(&self, inner: &mut Inner, reason: Option<LockReason>) {
self.set_standing(inner, Standing::Unlicensed(reason));
}
fn show_trial_expired(&self, inner: &mut Inner, trial: License) {
self.set_standing(inner, Standing::TrialExpired(trial));
}
fn show_not_covered(&self, inner: &mut Inner, not_covered: NotCoveredLicense) {
self.set_standing(inner, Standing::NotCovered(not_covered));
inner.error = None;
}
fn end_activation_flows(&self, inner: &mut Inner) {
inner.activation = None;
inner.pending = None;
inner.deactivating = None;
Self::reset_add_ons(inner);
self.bump();
}
fn stamp_file(&self) -> FileStamp {
match std::fs::metadata(self.0.store.path()) {
Ok(metadata) if metadata.is_file() => {
FileStamp { exists: true, len: metadata.len(), modified: metadata.modified().ok() }
}
_ => FileStamp { exists: false, len: 0, modified: None },
}
}
fn stores_activation(store: &dyn LicenseStore, activation_id: &str) -> bool {
store.load_local_license().ok().flatten().is_some_and(|stored| stored.activation_id == activation_id)
}
fn delete_stored(&self, inner: &mut Inner, only_activation: Option<&str>) {
let store = self.0.licensing.store();
let result = store.lock_for_update().and_then(|_guard| match only_activation {
Some(activation_id) => match store.load_local_license()? {
Some(stored) if stored.activation_id == activation_id => store.delete_local_license(),
_ => Ok(()),
},
None => store.delete_local_license(),
});
if let Err(error) = result {
log::warn!("moonbase: couldn't delete the stored license: {error}");
}
inner.file_stamp = Some(self.stamp_file());
}
pub(crate) fn start(&self) {
{
let mut inner = self.inner();
inner.loading = true;
inner.file_stamp = Some(self.stamp_file());
}
let generation = self.bump();
let this = self.clone();
thread::spawn(move || {
let outcome =
check_stored_license(&this.0.licensing, this.0.settings.grace_period, || this.is_current(generation));
let mut inner = this.inner();
inner.loading = false;
if this.is_current(generation) {
if outcome.delete_expired_offline {
this.delete_stored(&mut inner, None);
}
if let Some(trial) = outcome.expired_trial {
this.show_trial_expired(&mut inner, trial);
} else if let Some(not_covered) = outcome.not_covered {
this.show_not_covered(&mut inner, not_covered);
} else if let Some(license) = outcome.license {
this.set_license(&mut inner, license);
} else {
this.lock(&mut inner, outcome.reason);
}
}
drop(inner);
this.emit();
});
let this = self.clone();
thread::spawn(move || {
while !this.0.shutdown.load(Ordering::Acquire) {
thread::sleep(WATCH_INTERVAL);
this.watch();
}
});
}
pub(crate) fn shut_down(&self) {
self.0.shutdown.store(true, Ordering::Release);
self.bump();
self.0.store.cancel_lock_waits();
}
pub fn activate(&self, method: ActivationMethod) -> Result<LicenseState> {
let generation = {
let mut inner = self.inner();
if inner.deactivating.is_some() {
return Err(Error::Busy("A deactivation is in progress".into()));
}
inner.activation = Some(PendingActivation { browser_url: None, method, browser_open_failed: false });
inner.pending = None;
inner.error = None;
self.bump()
};
self.emit();
let result = self.0.licensing.request_activation(method);
let mut inner = self.inner();
if !self.is_current(generation) {
drop(inner);
return Ok(self.state());
}
let request = match result {
Ok(request) => request,
Err(error) => {
log::warn!("moonbase: request_activation failed: {error}");
inner.activation = None;
inner.error = Some(failure(Activity::StartActivation, &error));
drop(inner);
self.emit();
return Err(error.into());
}
};
inner.pending = Some(request.clone());
inner.activation = Some(PendingActivation {
browser_url: Some(request.browser_url.clone()),
method,
browser_open_failed: false,
});
drop(inner);
if self.0.settings.open_browser
&& let Err(error) = self.open_url(&request.browser_url)
{
log::warn!("moonbase: couldn't open the browser for activation ({error}); the state carries the link");
let mut inner = self.inner();
if self.is_current(generation)
&& let Some(activation) = &mut inner.activation
{
activation.browser_open_failed = true;
}
}
self.spawn_poll(generation, request);
self.emit();
Ok(self.state())
}
pub(crate) fn open_url(&self, url: &str) -> std::result::Result<(), String> {
let opened = match self.0.app.try_state::<tauri_plugin_opener::Opener<R>>() {
Some(opener) => opener.open_url(url, None::<String>),
None => tauri_plugin_opener::open_url(url, None::<&str>),
};
opened.map_err(|error| error.to_string())
}
fn spawn_poll(&self, generation: u64, request: ActivationRequest) {
let this = self.clone();
thread::spawn(move || {
loop {
thread::sleep(POLL_INTERVAL);
if this.0.shutdown.load(Ordering::Acquire) || !this.is_current(generation) {
return;
}
match this.0.licensing.complete_activation_with(&request, || this.is_current(generation)) {
Ok(None) => {}
Ok(Some(license)) => return this.on_activated(generation, license),
Err(error) if ends_activation(&error) => {
log::warn!("moonbase: activation rejected during polling: {error}");
let mut inner = this.inner();
if this.is_current(generation) {
inner.activation = None;
inner.pending = None;
inner.error = Some(failure(Activity::Activation, &error));
}
drop(inner);
return this.emit();
}
Err(error) => log::debug!("moonbase: activation poll failed, still waiting: {error}"),
}
}
});
}
fn on_activated(&self, generation: u64, license: License) {
let mut inner = self.inner();
if !self.is_current(generation) {
return;
}
self.end_activation_flows(&mut inner);
inner.file_stamp = Some(self.stamp_file());
self.set_license(&mut inner, license);
inner.error = None;
drop(inner);
self.emit();
}
pub fn cancel_activation(&self) -> LicenseState {
let mut inner = self.inner();
if inner.activation.is_none() {
return self.state_with(inner);
}
inner.activation = None;
inner.pending = None;
inner.error = None;
self.bump();
drop(inner);
self.emit();
self.state()
}
pub fn refresh(&self, force: bool) -> Result<LicenseState> {
let (generation, current, not_covered_before) = {
let mut inner = self.inner();
if inner.activation.is_some() {
return Ok(self.state_with(inner));
}
if inner.deactivating.is_some() {
return Err(Error::Busy("A deactivation is in progress".into()));
}
let Some(current) = inner.standing.held().cloned() else {
return Err(Error::NotLicensed);
};
if current.method == ActivationMethod::Offline {
return Ok(self.state_with(inner));
}
inner.refreshes_in_flight += 1;
(self.bump(), current, inner.standing.license().is_none())
};
if not_covered_before {
self.emit(); }
let _turn = self.online_turn();
let store = self.0.licensing.store();
let still_current = || self.is_current(generation) && Self::stores_activation(store, ¤t.activation_id);
let result = if force {
store.lock_for_update().and_then(|_guard| {
let result = self.0.licensing.client().validate_token_online(¤t.token);
let answer = match &result {
Ok(refreshed) => Some(refreshed),
Err(SdkError::ReleaseNotCovered(refusal)) => refusal.license(),
Err(_) => None,
};
if let Some(answer) = answer
&& still_current()
&& let Err(error) = store.store_local_license(answer)
{
log::warn!("moonbase: couldn't persist the refreshed license: {error}");
}
result
})
} else {
self.0.licensing.validate_token_online_with(¤t.token, still_current)
};
let mut inner = self.inner();
inner.refreshes_in_flight -= 1;
if !self.is_current(generation) {
drop(inner);
if not_covered_before {
self.emit(); }
return Ok(self.state());
}
if let Ok(refreshed) = &result
&& !force
&& refreshed.token == current.token
&& inner
.standing
.license()
.is_some_and(|held| held.activation_id == refreshed.activation_id && held.token != current.token)
{
return Ok(self.state_with(inner));
}
let outcome = match result {
Ok(refreshed) => {
inner.file_stamp = Some(self.stamp_file());
if not_covered_before {
inner.error = None;
}
self.set_license(&mut inner, refreshed);
Ok(())
}
Err(SdkError::ReleaseNotCovered(refusal)) => {
let not_covered = NotCoveredLicense::from_refusal(&refusal, ¤t);
if Self::is_older_than_held(&inner, ¬_covered.license) {
log::debug!("moonbase: dropped a re-validation answer older than the license held: {refusal}");
} else {
log::info!("moonbase: the license doesn't cover this version on re-validation: {refusal}");
self.show_not_covered(&mut inner, not_covered);
}
Ok(())
}
Err(SdkError::LicenseExpired(message)) if current.trial => {
log::info!("moonbase: the trial ended on re-validation: {message}");
self.show_trial_expired(&mut inner, current);
Ok(())
}
Err(error) if !error.is_license_rejection() && inner.standing.not_covered().is_some() => {
log::warn!("moonbase: couldn't re-check the license that doesn't cover this version: {error}");
inner.error = Some(Failure {
message: "Couldn't reach Moonbase to check your license. Try again when you're online.".into(),
detail: error.to_string(),
kind: Some(error.error_type()),
});
Err(error)
}
Err(error) if error.is_license_rejection() => {
log::warn!("moonbase: license rejected on re-validation: {error}");
let reason = if matches!(error, SdkError::LicenseExpired(_)) {
LockReason::Expired
} else {
LockReason::Invalid
};
self.lock(&mut inner, Some(reason));
Ok(())
}
Err(error) if age(current.validated_at) > self.0.settings.grace_period => {
log::warn!("moonbase: couldn't re-validate within the offline grace period: {error}");
self.lock(&mut inner, Some(LockReason::Unverified));
Ok(())
}
Err(error) => Err(error),
};
drop(inner);
self.emit();
outcome.map(|()| self.state()).map_err(Error::from)
}
fn state_with(&self, inner: MutexGuard<'_, Inner>) -> LicenseState {
drop(inner);
self.state()
}
pub fn deactivate(&self) -> Result<LicenseState> {
let (generation, license) = {
let mut inner = self.inner();
let Some(license) = inner.standing.held().cloned() else {
return Err(Error::NotLicensed);
};
if license.method == ActivationMethod::Offline || license.trial {
drop(inner);
return Ok(self.forget());
}
if inner.deactivating.is_some() {
return Err(Error::Busy("A deactivation is already in progress".into()));
}
if inner.activation.is_some() {
return Err(Error::Busy("Finish or cancel the activation first".into()));
}
inner.error = None;
let generation = self.bump();
inner.deactivating = Some(generation);
(generation, license)
};
self.emit();
let result = self.0.licensing.revoke_activation(&license.token);
let mut inner = self.inner();
if inner.deactivating == Some(generation) {
inner.deactivating = None;
}
if !self.is_current(generation) {
drop(inner);
self.emit();
return Ok(self.state());
}
let outcome = match result {
Ok(()) | Err(SdkError::OperationNotSupported(_)) => Ok(()),
Err(error) if error.is_license_rejection() => Ok(()),
Err(error) => {
log::warn!("moonbase: revoke_activation failed, license kept: {error}");
inner.error = Some(failure(Activity::Deactivation, &error));
Err(error)
}
};
if outcome.is_ok() {
self.delete_stored(&mut inner, Some(&license.activation_id));
self.lock(&mut inner, Some(LockReason::Deactivated));
}
drop(inner);
self.emit();
outcome.map(|()| self.state()).map_err(Error::from)
}
pub fn forget(&self) -> LicenseState {
let mut inner = self.inner();
self.end_activation_flows(&mut inner);
self.delete_stored(&mut inner, None);
self.lock(&mut inner, Some(LockReason::Deactivated));
inner.error = None;
drop(inner);
self.emit();
self.state()
}
pub fn device_token(&self) -> Result<String> {
self.0.licensing.generate_device_token().map_err(Error::from)
}
pub fn activate_offline(&self, license_file_contents: &str) -> Result<LicenseState> {
match self.0.licensing.import_offline_license(license_file_contents) {
Ok(license) => {
let mut inner = self.inner();
self.end_activation_flows(&mut inner);
inner.file_stamp = Some(self.stamp_file());
self.set_license(&mut inner, license);
inner.error = None;
drop(inner);
self.emit();
Ok(self.state())
}
Err(SdkError::ReleaseNotCovered(refusal)) if refusal.license().is_some() => {
log::info!("moonbase: the offline license doesn't cover this version: {refusal}");
let license = refusal.license().cloned().expect("checked above");
let mut inner = self.inner();
self.end_activation_flows(&mut inner);
inner.file_stamp = Some(self.stamp_file());
self.show_not_covered(&mut inner, NotCoveredLicense::from_refusal(&refusal, &license));
drop(inner);
self.emit();
Err(SdkError::ReleaseNotCovered(refusal).into())
}
Err(error) => {
log::warn!("moonbase: offline license file rejected: {error}");
self.inner().error = Some(failure(Activity::OfflineActivation, &error));
self.emit();
Err(error.into())
}
}
}
pub fn upgrade_license(&self) -> Result<LicenseState> {
let (url, not_covered) = {
let inner = self.inner();
match inner.standing.not_covered() {
Some(not_covered) => (not_covered.upgrade_url.clone(), true),
None => (inner.update_upgrade_url.clone(), false),
}
};
let Some(url) = url else {
if not_covered {
return self.activate(ActivationMethod::Online);
}
return Err(Error::Internal("There is no upgrade to offer".into()));
};
if let Err(error) = self.open_url(&url) {
log::warn!("moonbase: couldn't open the browser for the upgrade: {error}");
if not_covered {
self.inner().error = Some(Failure {
message: format!("Couldn't open your browser. Upgrade at {url}"),
detail: error.clone(),
kind: None,
});
self.emit();
}
return Err(Error::Internal(format!("Couldn't open the browser: {error}")));
}
Ok(self.state())
}
pub fn dismiss_error(&self) -> LicenseState {
self.inner().error = None;
self.emit();
self.state()
}
pub fn device(&self) -> DeviceInfo {
let resolver = self.0.licensing.device_resolver();
let (description, error) = match resolver.describe_device() {
Ok(description) => (description, None),
Err(error) => (None, Some(error.to_string())),
};
DeviceInfo { name: resolver.device_name(), platform: Platform::current().as_str().into(), description, error }
}
pub fn check_update(&self) -> Result<UpdateInfo> {
let license = self.license().ok_or(Error::NotLicensed)?;
let current = self.0.settings.application_version.clone().unwrap_or_default();
let latest = license.licensed_product.current_release_version.clone();
let mut info = UpdateInfo {
available: latest.as_deref().is_some_and(|latest| update_available(¤t, latest)),
current_version: current,
latest_version: latest.clone(),
release_notes: String::new(),
has_downloads: false,
can_download: false,
covered: true,
release_access: None,
upgrade_url: None,
};
if let (true, Some(latest)) = (info.available, latest) {
let release = self.0.licensing.inventory().get_release(&latest, &license.token)?;
info.can_download = release.has_downloads && can_download(&license, &release);
info.has_downloads = release.has_downloads;
info.release_notes = release.description;
info.covered = release.release_covered;
info.release_access = release.release_access;
info.upgrade_url.clone_from(&release.upgrade_url);
self.inner().update_upgrade_url = release.upgrade_url;
}
Ok(info)
}
pub fn update_download_url(&self) -> Result<DownloadTarget> {
let license = self.license().ok_or(Error::NotLicensed)?;
match self.0.licensing.inventory().get_download_url(Platform::current().as_str(), &license.token) {
Ok(target) => Ok(target),
Err(SdkError::ReleaseNotCovered(refusal)) => {
if let Some(url) = refusal.upgrade_url() {
self.inner().update_upgrade_url = Some(url.to_owned());
}
Err(SdkError::ReleaseNotCovered(refusal).into())
}
Err(error) => Err(error.into()),
}
}
pub fn load_add_ons(&self) -> Result<LicenseState> {
self.list_add_ons(true)
}
fn list_add_ons(&self, pick_up_seats: bool) -> Result<LicenseState> {
let (generation, token) = {
let mut inner = self.inner();
let Some(token) = inner.standing.license().map(|license| license.token.clone()) else {
return Err(Error::NotLicensed);
};
inner.add_ons_generation += 1;
inner.add_ons.phase = AddOnsPhase::Loading;
inner.add_ons.error = None;
(inner.add_ons_generation, token)
};
self.emit();
let result = self.0.licensing.list_sub_products(&token);
let (recheck, take_seat) = {
let mut inner = self.inner();
if inner.add_ons_generation != generation {
return Ok(self.state_with(inner));
}
inner.add_ons.phase = AddOnsPhase::Ready;
let refused = match &result {
Ok(catalog) => {
inner.add_ons.currency = Some(catalog.currency.clone());
inner.add_ons.add_ons.clone_from(&catalog.sub_products);
inner.add_ons.error = None;
false
}
Err(error) => {
log::warn!("moonbase: listing the add-ons failed: {error}");
inner.add_ons.error = Some(match error {
SdkError::LicenseExpired(_) => "Your license has expired.".into(),
error if error.is_license_invalid() => {
"Your license is no longer valid on this computer.".into()
}
error => add_on_failure(
error,
"Couldn't load the add-ons",
"Moonbase couldn't find the add-ons for this license.",
),
});
error.is_license_rejection()
}
};
let take_seat = result.is_ok() && pick_up_seats && self.lists_seat_to_take(&inner);
(refused && inner.deactivating.is_none(), take_seat)
};
self.emit();
if recheck && let Err(error) = self.refresh(true) {
log::debug!("moonbase: the re-check after a refused add-on list failed: {error}");
}
if take_seat {
log::info!("moonbase: an add-on the customer owns has a seat free; re-checking the license to take it");
return self.refresh_and_list();
}
result.map(|_| self.state()).map_err(Error::from)
}
fn lists_seat_to_take(&self, inner: &Inner) -> bool {
let Some(license) = inner.standing.license() else {
return false;
};
if !self.0.settings.auto_activate_sub_products || license.trial || license.method != ActivationMethod::Online {
return false;
}
let now = SystemTime::now();
inner.add_ons.add_ons.iter().any(|entry| {
let owned = entry.product.owned || entry.product.subscribed;
if !entry.included_in_license || !owned || entry.revoked {
return false;
}
let held = license.find_sub_product(&entry.id);
if held.is_some_and(|held| !held.trial) && license.has_sub_product_at(&entry.id, now) {
return false;
}
entry.active || entry.seats.is_some_and(|seats| seats.remaining > 0)
})
}
pub fn refresh_add_ons(&self) -> Result<LicenseState> {
self.inner().add_ons.action_error = None;
self.refresh_and_list()
}
fn refresh_and_list(&self) -> Result<LicenseState> {
let offline = {
let mut inner = self.inner();
let Some(license) = inner.standing.license() else {
return Ok(self.state_with(inner));
};
if inner.deactivating.is_some() {
return Ok(self.state_with(inner));
}
let offline = license.method == ActivationMethod::Offline;
if !offline {
inner.add_ons.phase = AddOnsPhase::Loading;
inner.add_ons.error = None;
}
offline
};
if !offline {
self.emit();
if let Err(error) = self.refresh(true) {
log::debug!("moonbase: the re-check before listing the add-ons failed: {error}");
}
if self.inner().standing.license().is_none() {
return Ok(self.state());
}
}
self.list_add_ons(false)
}
pub fn activate_sub_product(&self, sub_product_id: &str) -> Result<LicenseState> {
self.run_sub_product_action(sub_product_id, false)
}
pub fn request_sub_product_trial(&self, sub_product_id: &str) -> Result<LicenseState> {
self.run_sub_product_action(sub_product_id, true)
}
fn add_on_name(inner: &Inner, sub_product_id: &str) -> Option<String> {
inner
.add_ons
.add_ons
.iter()
.find(|entry| entry.id == sub_product_id || entry.product.id == sub_product_id)
.map(|entry| entry.product.name.clone())
}
fn run_sub_product_action(&self, sub_product_id: &str, trial: bool) -> Result<LicenseState> {
let (call, token, activation_id, name) = {
let mut inner = self.inner();
let refusal = match inner.standing.license() {
None => Some((format!("Activate {} first.", self.product_name()), Error::NotLicensed)),
Some(license) if license.trial => {
let message = "Add-ons are activated from a full license, not a trial.";
Some((message.to_owned(), SdkError::OperationNotSupported(message.into()).into()))
}
Some(_) => None,
};
if let Some((message, error)) = refusal {
inner.add_ons.action_error = Some(message);
drop(inner);
self.emit();
return Err(error);
}
if inner.add_ons.busy_id.is_some() {
return Err(Error::Busy("Another add-on is still being set up. Try again in a moment.".into()));
}
let license = inner.standing.license().expect("checked above");
let (token, activation_id) = (license.token.clone(), license.activation_id.clone());
let name = Self::add_on_name(&inner, sub_product_id);
inner.add_on_calls += 1;
inner.add_on_call = inner.add_on_calls;
inner.add_ons.busy_id = Some(sub_product_id.to_owned());
inner.add_ons.action_error = None;
(inner.add_on_call, token, activation_id, name)
};
self.emit();
let outcome = {
let _turn = self.online_turn();
let store = self.0.licensing.store();
let still_this_license = || Self::stores_activation(store, &activation_id);
let result = if trial {
self.0.licensing.request_sub_product_trial_with(&token, sub_product_id, still_this_license)
} else {
self.0.licensing.activate_sub_product_with(&token, sub_product_id, still_this_license)
};
let mut inner = self.inner();
if inner.add_on_call == call {
inner.add_on_call = 0;
inner.add_ons.busy_id = None;
}
let same_license = inner.standing.license().is_some_and(|license| license.activation_id == activation_id);
match result {
Ok(license) if same_license && license.activation_id == activation_id => {
inner.file_stamp = Some(self.stamp_file());
self.set_license(&mut inner, license);
AddOnOutcome::Applied { relist: inner.add_ons.phase != AddOnsPhase::Idle }
}
Ok(_) => AddOnOutcome::Changed,
Err(error) => {
log::warn!(
"moonbase: {} failed: {error}",
if trial { "request_sub_product_trial" } else { "activate_sub_product" }
);
if same_license {
let subject = name.clone().unwrap_or_else(|| "This add-on".into());
let object = name.clone().unwrap_or_else(|| "this add-on".into());
inner.add_ons.action_error = Some(match &error {
SdkError::NoEligibleLicense(_) => format!(
"No seat of {object} is free for this computer. Free one in your {} account, or buy one.",
self.account_name()
),
SdkError::OperationNotSupported(_) if trial => {
format!("{subject} can't be tried on this computer.")
}
SdkError::OperationNotSupported(_) => {
format!("{subject} can't be activated on this computer.")
}
SdkError::LicenseExpired(_) => "Your license has expired.".into(),
error if error.is_license_invalid() => {
"Your license is no longer valid on this computer.".into()
}
error => add_on_failure(
error,
&if trial {
format!("Couldn't start the trial of {object}")
} else {
format!("Couldn't activate {object}")
},
&format!("Moonbase couldn't find {object}."),
),
});
let recheck = error.is_license_rejection() && inner.deactivating.is_none();
AddOnOutcome::Failed { error, recheck }
} else {
AddOnOutcome::Changed
}
}
}
};
self.emit();
match outcome {
AddOnOutcome::Applied { relist } => {
if relist && let Err(error) = self.list_add_ons(false) {
log::debug!("moonbase: listing the add-ons again failed: {error}");
}
Ok(self.state())
}
AddOnOutcome::Changed => Err(Error::Busy(LICENSE_CHANGED.into())),
AddOnOutcome::Failed { error, recheck } => {
if recheck && let Err(error) = self.refresh(true) {
log::debug!("moonbase: the re-check after a refused add-on failed: {error}");
}
Err(error.into())
}
}
}
pub fn buy_sub_product(&self, sub_product_id: &str) -> Result<()> {
let (url, name) = {
let inner = self.inner();
inner
.add_ons
.add_ons
.iter()
.find(|entry| entry.id == sub_product_id || entry.product.id == sub_product_id)
.and_then(|entry| entry.buy_url.clone().map(|url| (url, entry.product.name.clone())))
.ok_or_else(|| Error::Internal("That add-on isn't for sale here".into()))?
};
if let Err(error) = self.open_url(&url) {
log::warn!("moonbase: couldn't open the browser to buy an add-on: {error}");
self.inner().add_ons.action_error = Some(format!("Couldn't open your browser to buy {name}."));
self.emit();
return Err(Error::Internal(format!("Couldn't open the browser: {error}")));
}
let mut inner = self.inner();
inner.purchase_opened_at = Some(Instant::now());
inner.left_for_purchase = None;
Ok(())
}
pub(crate) fn on_focus_changed(&self, focused: bool) {
let mut inner = self.inner();
let Some(opened) = inner.purchase_opened_at else {
return;
};
if opened.elapsed() > PURCHASE_WINDOW {
inner.purchase_opened_at = None;
inner.left_for_purchase = None;
return;
}
if !focused {
inner.left_for_purchase = Some(Instant::now());
return;
}
let away = inner.left_for_purchase.is_some_and(|left| left.elapsed() >= Duration::from_secs(1));
if !away || inner.standing.license().is_none() || inner.deactivating.is_some() {
return;
}
inner.purchase_opened_at = None;
inner.left_for_purchase = None;
drop(inner);
log::info!("moonbase: back from the browser after opening an add-on's buy link; refreshing the add-ons");
let this = self.clone();
thread::spawn(move || {
if let Err(error) = this.refresh_add_ons() {
log::debug!("moonbase: refreshing the add-ons failed: {error}");
}
});
}
fn product_name(&self) -> String {
self.ui().map_or_else(|| self.0.app.package_info().name.clone(), |ui| ui.product_name(&self.0.app))
}
fn account_name(&self) -> String {
self.ui().and_then(Ui::manufacturer_name).unwrap_or("Moonbase").to_owned()
}
fn watch(&self) {
if self.inner().loading {
return;
}
self.check_license_file();
if !self.check_sub_products() {
self.check_deadlines();
}
self.check_countdowns();
}
fn check_license_file(&self) {
let stamp = self.stamp_file();
let generation = self.0.generation.load(Ordering::SeqCst);
{
let mut inner = self.inner();
if inner.file_stamp == Some(stamp) {
return;
}
let existed = inner.file_stamp.is_some_and(|previous| previous.exists);
inner.file_stamp = Some(stamp);
if !stamp.exists {
if existed {
self.on_license_file_removed(inner);
}
return;
}
}
let Ok(on_disk) = std::fs::read(self.0.store.path())
.map_err(|_| ())
.and_then(|bytes| serde_json::from_slice::<License>(&bytes).map_err(|_| ()))
else {
return;
};
if self.inner().standing.held().is_some_and(|held| held.token == on_disk.token) {
return;
}
let (stored, not_covered) = match self.0.licensing.validate_token_local(&on_disk.token) {
Ok(stored) => (stored, None),
Err(SdkError::ReleaseNotCovered(refusal)) => match refusal.license() {
Some(license) => (license.clone(), Some(NotCoveredLicense::from_refusal(&refusal, license))),
None => return,
},
Err(_) => return,
};
if stored.method != ActivationMethod::Offline && age(stored.validated_at) > self.0.settings.grace_period {
return;
}
match not_covered {
Some(not_covered) => self.adopt_not_covered(not_covered, generation),
None => self.adopt_stored_license(stored, generation),
}
}
fn on_license_file_removed(&self, mut inner: MutexGuard<'_, Inner>) {
if inner.standing.license().is_none() {
if inner.standing.not_covered().is_none() {
return;
}
log::info!("moonbase: the license file was removed by another instance or process");
if inner.activation.is_some() {
self.lock(&mut inner, Some(LockReason::Deactivated));
} else {
self.end_activation_flows(&mut inner);
self.lock(&mut inner, Some(LockReason::Deactivated));
}
drop(inner);
return self.emit();
}
log::info!("moonbase: the license file was removed by another instance or process; locking");
if inner.activation.is_some() {
self.lock(&mut inner, Some(LockReason::Deactivated));
} else {
self.end_activation_flows(&mut inner);
self.lock(&mut inner, Some(LockReason::Deactivated));
}
drop(inner);
self.emit();
}
fn adopt_stored_license(&self, stored: License, generation: u64) {
let mut inner = self.inner();
if !self.is_current(generation) {
inner.file_stamp = None;
return;
}
if Self::is_older_than_held(&inner, &stored) {
return;
}
if let Some(current) = inner.standing.license()
&& current.activation_id == stored.activation_id
{
self.set_license(&mut inner, stored);
drop(inner);
return self.emit();
}
log::info!("moonbase: picked up a license stored by another instance or process");
self.end_activation_flows(&mut inner);
self.set_license(&mut inner, stored);
drop(inner);
self.emit();
}
fn adopt_not_covered(&self, mut not_covered: NotCoveredLicense, generation: u64) {
let mut inner = self.inner();
if !self.is_current(generation) {
inner.file_stamp = None;
return;
}
if Self::is_older_than_held(&inner, ¬_covered.license) || inner.activation.is_some() {
return;
}
let same_activation =
inner.standing.held().is_some_and(|held| held.activation_id == not_covered.license.activation_id);
log::info!("moonbase: another instance or process found that the license doesn't cover this version; locking");
if same_activation
&& not_covered.upgrade_url.is_none()
&& let Some(held) = inner.standing.not_covered()
{
not_covered.upgrade_url.clone_from(&held.upgrade_url);
}
if !same_activation {
self.end_activation_flows(&mut inner);
}
self.show_not_covered(&mut inner, not_covered);
drop(inner);
self.emit();
}
fn check_sub_products(&self) -> bool {
let now = SystemTime::now();
let (changed, ran_out) = {
let mut inner = self.inner();
let Some(license) = inner.standing.license().cloned() else {
return false;
};
let active = active_sub_products(&license, now);
let changed = *self.0.emitted_sub_products.lock().unwrap_or_else(PoisonError::into_inner) != active;
for id in &active {
inner.lapses_checked.remove(id);
}
let mut ran_out = Vec::new();
if license.method == ActivationMethod::Online
&& inner.deactivating.is_none()
&& inner.refreshes_in_flight == 0
&& inner.activation.is_none()
{
for entry in &license.sub_products {
if entry.active
&& entry.expires_at.is_some_and(|expires| expires <= now)
&& !inner.lapses_checked.contains(&entry.id)
{
ran_out.push(entry.id.clone());
}
}
inner.lapses_checked.extend(ran_out.iter().cloned());
}
(changed, !ran_out.is_empty())
};
if changed {
self.emit();
}
if ran_out {
log::info!("moonbase: an add-on ran out; re-checking the license in case it was bought or renewed");
if let Err(error) = self.refresh(true) {
log::debug!("moonbase: the add-on re-check failed: {error}");
}
}
ran_out
}
fn check_deadlines(&self) {
let mut inner = self.inner();
let Some(license) = inner.standing.license().cloned() else {
return;
};
if inner.deactivating.is_some() || inner.refreshes_in_flight > 0 {
return;
}
let expired = license.expires_at.is_some_and(|expires| expires <= SystemTime::now());
let offline = license.method == ActivationMethod::Offline;
let past_grace = !offline && age(license.validated_at) > self.0.settings.grace_period;
if !expired && !past_grace {
return;
}
if inner.activation.is_some() {
log::info!("moonbase: the license ran out while an activation was in progress; locking");
if expired && license.trial {
self.show_trial_expired(&mut inner, license);
} else {
self.lock(&mut inner, Some(if expired { LockReason::Expired } else { LockReason::Unverified }));
}
drop(inner);
return self.emit();
}
if offline {
log::info!("moonbase: the offline license has expired; removing it");
self.bump();
self.delete_stored(&mut inner, None);
self.lock(&mut inner, Some(LockReason::Expired));
drop(inner);
return self.emit();
}
if !expired {
let now = Instant::now();
if inner.last_grace_check.is_some_and(|last| now - last < self.0.settings.check_interval) {
return;
}
inner.last_grace_check = Some(now);
}
drop(inner);
if let Err(error) = self.refresh(false) {
log::debug!("moonbase: the deadline re-check failed: {error}");
}
}
fn check_countdowns(&self) {
let mut inner = self.inner();
let mut ends: Vec<SystemTime> = Vec::new();
if let Some(license) = inner.standing.license() {
ends.extend(license.expires_at);
ends.extend(license.sub_products.iter().filter_map(|entry| entry.expires_at));
}
for entry in &inner.add_ons.add_ons {
ends.extend(entry.expires_at);
ends.extend(entry.trial.as_ref().and_then(|trial| trial.expires_at));
}
let days: Vec<u64> = ends.iter().copied().map(days_until).collect();
let day_went_by = ends == inner.countdown.0 && days != inner.countdown.1;
inner.countdown = (ends, days);
drop(inner);
if day_went_by {
self.emit();
}
}
}
impl<R: Runtime> std::fmt::Debug for Moonbase<R> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Moonbase")
.field("license_file", &self.0.store.path())
.field("licensed", &self.is_licensed())
.finish_non_exhaustive()
}
}