mirror of
https://github.com/flectar/mail.git
synced 2026-11-12 18:23:36 +00:00
1357 lines
50 KiB
Rust
1357 lines
50 KiB
Rust
//! Flectar Mail Sync's settings page and dialogs (issue #39).
|
|
//!
|
|
//! The core runs sync. This controller starts what the user asks for, shows
|
|
//! where sync stands from the core's events, and draws pairing QR codes.
|
|
//! Paying for sync happens in the browser, on the payment provider's pages
|
|
//! the sync server opens; builds sold through a store that requires its own
|
|
//! in-app purchases only show what the group pays.
|
|
//! What another device changed reaches the window the way a change made here
|
|
//! does: preferences through the settings they belong to, and accounts
|
|
//! through the window's own account updates.
|
|
|
|
use super::*;
|
|
use crate::documents::{DocumentDialog, DocumentFuture, DocumentProvider};
|
|
use crate::retained_model::RetainedModel;
|
|
use chrono::{Datelike, Timelike};
|
|
use flectar_mail_core::Core;
|
|
use flectar_mail_core::device_sync::{
|
|
DeviceSyncState, DeviceSyncStatus, PairingCode, PairingEvent, SyncBilling, SyncBillingStatus,
|
|
SyncCodeOutcome, SyncPlan,
|
|
};
|
|
use flectar_mail_core::error::CoreError;
|
|
use slint::SharedString;
|
|
use startup::DeviceSyncUpdates;
|
|
use std::{io::Write, path::Path};
|
|
|
|
/// The name a saved recovery code is offered under. Saving a new code over
|
|
/// an old one replaces it, as it should: the old one no longer works.
|
|
const RECOVERY_FILE_NAME: &str = "flectar-mail-sync-recovery-code.txt";
|
|
|
|
/// Staged copies of the recovery code start with this, so a copy a crash
|
|
/// left behind during the save dialog goes at the next start.
|
|
const RECOVERY_STAGING_PREFIX: &str = "recovery-code-";
|
|
|
|
/// The most steps of other pairings kept while the dialog waits to learn
|
|
/// which pairing its request started.
|
|
const MAX_EARLY_STEPS: usize = 16;
|
|
|
|
/// State the page's callbacks and the core's events share.
|
|
#[derive(Default)]
|
|
pub(super) struct SyncController {
|
|
/// The devices the page lists. Updated in place, so a press on a row's
|
|
/// button survives the status updates every sync brings.
|
|
devices: Rc<RetainedModel<SyncDeviceRow>>,
|
|
/// Bumped whenever the dialog starts a pairing or closes, so the answer
|
|
/// to an earlier request is dropped.
|
|
attempt: Cell<u64>,
|
|
/// The core's number for the pairing the dialog follows.
|
|
pairing: Cell<Option<u64>>,
|
|
/// Steps that arrived before the request starting the pairing returned
|
|
/// its number, oldest first.
|
|
early: RefCell<Vec<(u64, PairingEvent)>>,
|
|
/// Flectar's server was asked what it charges, while sync was off.
|
|
offer_loaded: Cell<bool>,
|
|
/// Sync was on at the last status.
|
|
was_on: Cell<bool>,
|
|
/// The save dialog for the recovery code is open.
|
|
saving_recovery: Cell<bool>,
|
|
}
|
|
|
|
impl SyncController {
|
|
/// Connect the page's callbacks. The page shows sync as off until the
|
|
/// core reports otherwise.
|
|
pub(super) fn register(
|
|
self: &Rc<Self>,
|
|
app: &AppWindow,
|
|
state: &Rc<RefCell<InboxState>>,
|
|
runtime: &tokio::runtime::Handle,
|
|
urls: ExternalUrlOpener,
|
|
documents: Arc<dyn DocumentProvider>,
|
|
temp_dir: PathBuf,
|
|
) {
|
|
let ui = app.global::<SyncUi>();
|
|
ui.set_devices(Rc::clone(&self.devices).into());
|
|
ui.set_can_buy(platform::SELLS_SYNC);
|
|
|
|
let staging = temp_dir.clone();
|
|
runtime.spawn_blocking(move || remove_staged_recovery_codes(&staging));
|
|
|
|
let actions = Actions {
|
|
app: app.as_weak(),
|
|
state: Rc::clone(state),
|
|
runtime: runtime.clone(),
|
|
controller: Rc::clone(self),
|
|
urls,
|
|
documents,
|
|
temp_dir,
|
|
};
|
|
|
|
let turn_on = actions.clone();
|
|
ui.on_turn_on(move || turn_on.turn_on());
|
|
let join = actions.clone();
|
|
ui.on_join_with_code(move |code| join.join_with_code(code.to_string()));
|
|
let show = actions.clone();
|
|
ui.on_join_show_qr(move || show.join_show_qr());
|
|
let recover = actions.clone();
|
|
ui.on_recover(move |words| recover.recover(words.to_string()));
|
|
let invite = actions.clone();
|
|
ui.on_invite(move |with_code| invite.invite(with_code));
|
|
let accept = actions.clone();
|
|
ui.on_accept(move || accept.decide(true));
|
|
let decline = actions.clone();
|
|
ui.on_decline(move || decline.decide(false));
|
|
let cancel = actions.clone();
|
|
ui.on_cancel_pairing(move || cancel.cancel_pairing());
|
|
let remove = actions.clone();
|
|
ui.on_remove_device(move |id| remove.remove_device(id.to_string()));
|
|
let reset = actions.clone();
|
|
ui.on_reset_recovery(move || reset.reset_recovery());
|
|
let save = actions.clone();
|
|
ui.on_save_recovery_code(move |text| save.save_recovery_code(text.to_string()));
|
|
let turn_off = actions.clone();
|
|
ui.on_turn_off(move || turn_off.turn_off());
|
|
let delete = actions.clone();
|
|
ui.on_delete_everywhere(move || delete.delete_everywhere());
|
|
let sign_ins = actions.clone();
|
|
ui.on_set_sync_sign_ins(move |on| sign_ins.set_sync_sign_ins(on));
|
|
let opened = actions.clone();
|
|
ui.on_opened(move || opened.opened());
|
|
let subscribe = actions.clone();
|
|
ui.on_subscribe(move |plan| subscribe.subscribe(plan.to_string()));
|
|
let manage = actions.clone();
|
|
ui.on_manage_subscription(move || manage.manage_subscription());
|
|
let redeem = actions.clone();
|
|
ui.on_redeem_code(move |code| redeem.redeem_code(code.to_string()));
|
|
ui.on_sync_now(move || {
|
|
if let Some(core) = actions.core() {
|
|
core.device_sync_now();
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Name this device to its sync group and show where sync stands, once
|
|
/// the window has the core.
|
|
pub(super) fn attach(&self, app: &AppWindow, core: &CoreMailSource) {
|
|
let core = core.device_sync_core();
|
|
core.describe_device(platform::device_description());
|
|
self.show_status(app, &core.device_sync_status());
|
|
}
|
|
|
|
/// Take on what the core reported since the window last looked.
|
|
pub(super) fn apply(
|
|
&self,
|
|
app: &AppWindow,
|
|
state: &Rc<RefCell<InboxState>>,
|
|
runtime: &tokio::runtime::Handle,
|
|
ui_tasks: &UiSender<UiTaskUpdate>,
|
|
calendar_month: NaiveDate,
|
|
updates: DeviceSyncUpdates,
|
|
) {
|
|
if let Some(status) = updates.status {
|
|
self.show_status(app, &status);
|
|
}
|
|
for (pairing, event) in updates.pairing {
|
|
self.show_pairing(app, pairing, event);
|
|
}
|
|
if updates.settings_changed {
|
|
reload_preferences(app, state, runtime);
|
|
}
|
|
if updates.accounts_changed {
|
|
reload_accounts(
|
|
state,
|
|
runtime,
|
|
ui_tasks,
|
|
calendar_month,
|
|
updates.removed_accounts,
|
|
);
|
|
}
|
|
}
|
|
|
|
fn show_status(&self, app: &AppWindow, status: &DeviceSyncStatus) {
|
|
let ui = app.global::<SyncUi>();
|
|
let (state, error) = match &status.state {
|
|
DeviceSyncState::Off => ("off", ""),
|
|
DeviceSyncState::Idle => ("idle", ""),
|
|
DeviceSyncState::Syncing => ("syncing", ""),
|
|
DeviceSyncState::Error(error) => ("error", error.as_str()),
|
|
DeviceSyncState::Removed => ("removed", ""),
|
|
DeviceSyncState::Deleted => ("deleted", ""),
|
|
DeviceSyncState::PaymentRequired => ("payment", ""),
|
|
};
|
|
ui.set_state(state.into());
|
|
ui.set_error(sentence(error).into());
|
|
let (last_synced, today) = last_synced(status.last_synced_at, &Local::now());
|
|
ui.set_last_synced(last_synced.into());
|
|
ui.set_last_synced_today(today);
|
|
ui.set_server_url(status.server_url.as_deref().unwrap_or_default().into());
|
|
self.devices.reconcile_by(
|
|
status
|
|
.devices
|
|
.iter()
|
|
.map(|device| SyncDeviceRow {
|
|
id: device.id.as_str().into(),
|
|
name: device.name.as_str().into(),
|
|
platform: device.platform.as_str().into(),
|
|
this_device: device.this_device,
|
|
})
|
|
.collect(),
|
|
|row| row.id.clone(),
|
|
|a, b| a == b,
|
|
);
|
|
show_billing(&ui, status.billing.as_ref());
|
|
// Adding a device needs sync on; one that stopped can't finish.
|
|
let off = !matches!(
|
|
status.state,
|
|
DeviceSyncState::Idle
|
|
| DeviceSyncState::Syncing
|
|
| DeviceSyncState::Error(_)
|
|
| DeviceSyncState::PaymentRequired
|
|
);
|
|
// Turned off: ask again what turning it on costs, since this device
|
|
// may have had its free trial meanwhile.
|
|
if self.was_on.replace(!off) && off {
|
|
self.offer_loaded.set(false);
|
|
ui.invoke_opened();
|
|
}
|
|
if off && ui.get_dialog() == "invite" {
|
|
self.end_attempt(&ui);
|
|
ui.set_dialog("".into());
|
|
}
|
|
}
|
|
|
|
/// Show a step of the pairing the dialog follows; steps of others are
|
|
/// dropped.
|
|
fn show_pairing(&self, app: &AppWindow, pairing: u64, event: PairingEvent) {
|
|
let ui = app.global::<SyncUi>();
|
|
if ui.get_dialog().is_empty() {
|
|
return;
|
|
}
|
|
match self.pairing.get() {
|
|
Some(current) if current == pairing => show_step(&ui, event),
|
|
Some(_) => {}
|
|
// The request that started it hasn't returned its number yet.
|
|
None => {
|
|
let mut early = self.early.borrow_mut();
|
|
if early.len() == MAX_EARLY_STEPS {
|
|
early.remove(0);
|
|
}
|
|
early.push((pairing, event));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Start a new attempt in the dialog. Returns its number.
|
|
fn start_attempt(&self, ui: &SyncUi, stage: &str) -> u64 {
|
|
self.end_attempt(ui);
|
|
ui.set_pairing_stage(stage.into());
|
|
self.attempt.get()
|
|
}
|
|
|
|
/// Forget the dialog's pairing and what it showed. Returns the pairing,
|
|
/// if the core had numbered it.
|
|
fn end_attempt(&self, ui: &SyncUi) -> Option<u64> {
|
|
self.attempt.set(self.attempt.get().wrapping_add(1));
|
|
self.early.borrow_mut().clear();
|
|
ui.set_pairing_stage("".into());
|
|
ui.set_pairing_has_qr(false);
|
|
ui.set_pairing_qr(Image::default());
|
|
ui.set_pairing_code("".into());
|
|
ui.set_pairing_words(ModelRc::default());
|
|
ui.set_pairing_error("".into());
|
|
self.pairing.take()
|
|
}
|
|
|
|
/// The core numbered the attempt's pairing: follow it, including any
|
|
/// step that came first.
|
|
fn follow(&self, ui: &SyncUi, pairing: u64) {
|
|
self.pairing.set(Some(pairing));
|
|
let early = std::mem::take(&mut *self.early.borrow_mut());
|
|
for (_, event) in early.into_iter().filter(|(from, _)| *from == pairing) {
|
|
show_step(ui, event);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What the page's callbacks need.
|
|
#[derive(Clone)]
|
|
struct Actions {
|
|
app: slint::Weak<AppWindow>,
|
|
state: Rc<RefCell<InboxState>>,
|
|
runtime: tokio::runtime::Handle,
|
|
controller: Rc<SyncController>,
|
|
urls: ExternalUrlOpener,
|
|
documents: Arc<dyn DocumentProvider>,
|
|
/// Where a file to save is written first, below the cache directory,
|
|
/// which every document provider accepts as an export source.
|
|
temp_dir: PathBuf,
|
|
}
|
|
|
|
impl Actions {
|
|
fn core(&self) -> Option<Arc<Core>> {
|
|
self.state
|
|
.borrow()
|
|
.core
|
|
.as_ref()
|
|
.map(CoreMailSource::device_sync_core)
|
|
}
|
|
|
|
/// Run `request` on the runtime with the page's buttons waiting, then
|
|
/// `done` with its result on the UI thread.
|
|
fn run<T: Send + 'static>(
|
|
&self,
|
|
request: impl Future<Output = flectar_mail_core::error::Result<T>> + Send + 'static,
|
|
done: impl FnOnce(&SyncUi, &SyncController, Result<T, CoreError>) + 'static,
|
|
) {
|
|
let Some(app) = self.app.upgrade() else {
|
|
return;
|
|
};
|
|
app.global::<SyncUi>().set_busy(true);
|
|
let app = self.app.clone();
|
|
let controller = Rc::clone(&self.controller);
|
|
documents::run_request(
|
|
&self.runtime,
|
|
Box::pin(async move { Ok::<_, String>(request.await) }),
|
|
move |result| {
|
|
let Some(app) = app.upgrade() else {
|
|
return;
|
|
};
|
|
let ui = app.global::<SyncUi>();
|
|
ui.set_busy(false);
|
|
let result = result.unwrap_or_else(|error| Err(CoreError::Other(error)));
|
|
done(&ui, &controller, result);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// The server address typed on the page, or `Err` after showing that it
|
|
/// isn't one. `pairing` shows it in the dialog instead.
|
|
fn server(&self, ui: &SyncUi, pairing: bool) -> Result<Option<String>, ()> {
|
|
server_address(&ui.get_server()).inspect_err(|()| {
|
|
if pairing {
|
|
self.controller.end_attempt(ui);
|
|
ui.set_pairing_stage("server".into());
|
|
} else {
|
|
show_failure(ui, "server", "");
|
|
}
|
|
})
|
|
}
|
|
|
|
fn turn_on(&self) {
|
|
let (Some(app), Some(core)) = (self.app.upgrade(), self.core()) else {
|
|
return;
|
|
};
|
|
let ui = app.global::<SyncUi>();
|
|
let Ok(server) = self.server(&ui, false) else {
|
|
return;
|
|
};
|
|
show_failure(&ui, "", "");
|
|
self.run(
|
|
async move { core.device_sync_turn_on(server).await },
|
|
|ui, _, result| match result {
|
|
Ok(words) => show_recovery_words(ui, &words),
|
|
Err(error) => show_failure(ui, "turn-on", &error_text(&error)),
|
|
},
|
|
);
|
|
}
|
|
|
|
fn join_with_code(&self, code: String) {
|
|
let (Some(app), Some(core)) = (self.app.upgrade(), self.core()) else {
|
|
return;
|
|
};
|
|
let ui = app.global::<SyncUi>();
|
|
let Ok(server) = self.server(&ui, true) else {
|
|
return;
|
|
};
|
|
let attempt = self.controller.start_attempt(&ui, "starting");
|
|
let cancel = Arc::clone(&core);
|
|
self.run(
|
|
async move { core.device_sync_join_with_code(server, code).await },
|
|
move |ui, controller, result| {
|
|
if controller.attempt.get() != attempt {
|
|
if let Ok(pairing) = result {
|
|
cancel.device_sync_cancel_pairing(pairing);
|
|
}
|
|
return;
|
|
}
|
|
match result {
|
|
Ok(pairing) => controller.follow(ui, pairing),
|
|
Err(error) => show_pairing_error(ui, &error),
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
fn join_show_qr(&self) {
|
|
let (Some(app), Some(core)) = (self.app.upgrade(), self.core()) else {
|
|
return;
|
|
};
|
|
let ui = app.global::<SyncUi>();
|
|
let Ok(server) = self.server(&ui, true) else {
|
|
return;
|
|
};
|
|
let attempt = self.controller.start_attempt(&ui, "starting");
|
|
let cancel = Arc::clone(&core);
|
|
self.run(
|
|
async move { core.device_sync_join_show_code(server).await },
|
|
move |ui, controller, result| {
|
|
show_code(ui, controller, attempt, result, &cancel);
|
|
},
|
|
);
|
|
}
|
|
|
|
fn recover(&self, words: String) {
|
|
let (Some(app), Some(core)) = (self.app.upgrade(), self.core()) else {
|
|
return;
|
|
};
|
|
let ui = app.global::<SyncUi>();
|
|
let Ok(server) = self.server(&ui, true) else {
|
|
return;
|
|
};
|
|
let attempt = self.controller.start_attempt(&ui, "starting");
|
|
self.run(
|
|
async move { core.device_sync_recover(server, words).await },
|
|
move |ui, controller, result| {
|
|
// Joining happened even if the dialog closed meanwhile; the
|
|
// page shows sync on from the core's status.
|
|
if controller.attempt.get() != attempt {
|
|
return;
|
|
}
|
|
match result {
|
|
Ok(()) => ui.set_pairing_stage("joined".into()),
|
|
Err(error) => show_pairing_error(ui, &error),
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
fn invite(&self, with_code: bool) {
|
|
let (Some(app), Some(core)) = (self.app.upgrade(), self.core()) else {
|
|
return;
|
|
};
|
|
let ui = app.global::<SyncUi>();
|
|
if let Some(pairing) = self.controller.pairing.get() {
|
|
core.device_sync_cancel_pairing(pairing);
|
|
}
|
|
let attempt = self.controller.start_attempt(&ui, "starting");
|
|
let cancel = Arc::clone(&core);
|
|
self.run(
|
|
async move { core.device_sync_invite(with_code).await },
|
|
move |ui, controller, result| {
|
|
show_code(ui, controller, attempt, result, &cancel);
|
|
},
|
|
);
|
|
}
|
|
|
|
fn decide(&self, accept: bool) {
|
|
let (Some(core), Some(pairing)) = (self.core(), self.controller.pairing.get()) else {
|
|
return;
|
|
};
|
|
if accept {
|
|
core.device_sync_accept(pairing);
|
|
} else {
|
|
core.device_sync_decline(pairing);
|
|
}
|
|
}
|
|
|
|
fn cancel_pairing(&self) {
|
|
let Some(app) = self.app.upgrade() else {
|
|
return;
|
|
};
|
|
let pairing = self.controller.end_attempt(&app.global::<SyncUi>());
|
|
if let (Some(core), Some(pairing)) = (self.core(), pairing) {
|
|
core.device_sync_cancel_pairing(pairing);
|
|
}
|
|
}
|
|
|
|
fn remove_device(&self, device: String) {
|
|
let Some(core) = self.core() else {
|
|
return;
|
|
};
|
|
self.run(
|
|
async move { core.device_sync_remove_device(device).await },
|
|
|ui, _, result| match result {
|
|
Ok(()) => show_failure(ui, "", ""),
|
|
Err(error) => show_failure(ui, "remove", &error_text(&error)),
|
|
},
|
|
);
|
|
}
|
|
|
|
fn reset_recovery(&self) {
|
|
let Some(core) = self.core() else {
|
|
return;
|
|
};
|
|
self.run(
|
|
async move { core.device_sync_reset_recovery().await },
|
|
|ui, _, result| match result {
|
|
Ok(words) => {
|
|
show_failure(ui, "", "");
|
|
show_recovery_words(ui, &words);
|
|
}
|
|
Err(error) => show_failure(ui, "reset", &error_text(&error)),
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Save the recovery code as a text file where the user picks, through
|
|
/// the same pickers as attachments. The staged copy goes as soon as the
|
|
/// picker is done with it.
|
|
fn save_recovery_code(&self, text: String) {
|
|
if self.controller.saving_recovery.replace(true) {
|
|
return;
|
|
}
|
|
let documents = Arc::clone(&self.documents);
|
|
let temp_dir = self.temp_dir.clone();
|
|
let core = self.core();
|
|
let request: DocumentFuture<bool> = Box::pin(async move {
|
|
let file = tokio::task::spawn_blocking(move || stage_recovery_code(&temp_dir, &text))
|
|
.await
|
|
.map_err(|error| error.to_string())?
|
|
.map_err(|error| error.to_string())?;
|
|
// Start in the download folder from Settings, as attachments do.
|
|
let dialog = match core {
|
|
Some(core) => crate::documents::download_dialog_for(&core).await,
|
|
None => DocumentDialog::default(),
|
|
};
|
|
let dialog = DocumentDialog {
|
|
title: "Save recovery code".into(),
|
|
filter: Some(("Text".into(), &["txt"])),
|
|
..dialog
|
|
};
|
|
documents
|
|
.export_with(file.to_path_buf(), RECOVERY_FILE_NAME.into(), dialog)
|
|
.await
|
|
});
|
|
let app = self.app.clone();
|
|
let controller = Rc::clone(&self.controller);
|
|
crate::documents::run_request(&self.runtime, request, move |result| {
|
|
controller.saving_recovery.set(false);
|
|
let Some(app) = app.upgrade() else {
|
|
return;
|
|
};
|
|
let ui = app.global::<SyncUi>();
|
|
match result {
|
|
Ok(true) => {
|
|
ui.set_recovery_saved(true);
|
|
ui.set_recovery_save_error(SharedString::new());
|
|
}
|
|
// Cancelled.
|
|
Ok(false) => {}
|
|
Err(error) => ui.set_recovery_save_error(error.into()),
|
|
}
|
|
});
|
|
}
|
|
|
|
fn turn_off(&self) {
|
|
let Some(core) = self.core() else {
|
|
return;
|
|
};
|
|
self.run(
|
|
async move { core.device_sync_turn_off().await },
|
|
|ui, _, result| match result {
|
|
Ok(()) => show_failure(ui, "", ""),
|
|
Err(error) => show_failure(ui, "turn-off", &error_text(&error)),
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Passwords and sign-ins sync, or stop, on every device of the group.
|
|
fn set_sync_sign_ins(&self, on: bool) {
|
|
let Some(core) = self.core() else {
|
|
return;
|
|
};
|
|
self.run(
|
|
async move { core.device_sync_set_sign_ins(on).await },
|
|
move |ui, _, result| match result {
|
|
Ok(()) => show_failure(ui, "", ""),
|
|
Err(error) => {
|
|
ui.set_sync_sign_ins(!on);
|
|
show_failure(ui, "sign-ins", &error_text(&error));
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
/// The page opened. With sync off, show what Flectar's server charges,
|
|
/// once; with sync on, ask what the group pays, which a payment in the
|
|
/// browser may have changed.
|
|
fn opened(&self) {
|
|
let (Some(app), Some(core)) = (self.app.upgrade(), self.core()) else {
|
|
return;
|
|
};
|
|
if core.device_sync_status().state != DeviceSyncState::Off {
|
|
self.runtime.spawn(async move {
|
|
// The status reports it; failing to reach the server keeps
|
|
// what was known.
|
|
let _ = core.device_sync_refresh_billing().await;
|
|
});
|
|
return;
|
|
}
|
|
if self.controller.offer_loaded.replace(true) {
|
|
return;
|
|
}
|
|
let app = app.as_weak();
|
|
let controller = Rc::clone(&self.controller);
|
|
documents::run_request(
|
|
&self.runtime,
|
|
Box::pin(async move { Ok::<_, String>(core.device_sync_offer(None).await) }),
|
|
move |result| {
|
|
let Some(app) = app.upgrade() else {
|
|
return;
|
|
};
|
|
match result {
|
|
Ok(Ok(offer)) => {
|
|
let ui = app.global::<SyncUi>();
|
|
let offer = offer.unwrap_or(flectar_mail_core::device_sync::SyncOffer {
|
|
trial_days: 0,
|
|
plans: Vec::new(),
|
|
});
|
|
let prices = plan_prices(&offer.plans);
|
|
ui.set_offer_trial_days(i32::try_from(offer.trial_days).unwrap_or(0));
|
|
ui.set_offer_monthly(prices.monthly.into());
|
|
ui.set_offer_yearly(prices.yearly.into());
|
|
}
|
|
// Offline: try again the next time the page opens.
|
|
_ => controller.offer_loaded.set(false),
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Open the payment provider's checkout for `plan` in the browser.
|
|
fn subscribe(&self, plan: String) {
|
|
let Some(core) = self.core() else {
|
|
return;
|
|
};
|
|
let urls = self.urls.clone();
|
|
self.run(
|
|
async move { core.device_sync_checkout(plan).await },
|
|
move |ui, _, result| match result {
|
|
Ok(Some(url)) => open_page(ui, &urls, &url, "subscribe"),
|
|
// The group pays already; the status now says so.
|
|
Ok(None) => show_failure(ui, "", ""),
|
|
Err(error) => show_failure(ui, "subscribe", &error_text(&error)),
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Open the page that changes or cancels the subscription.
|
|
fn manage_subscription(&self) {
|
|
let Some(core) = self.core() else {
|
|
return;
|
|
};
|
|
let urls = self.urls.clone();
|
|
self.run(
|
|
async move { core.device_sync_manage_billing().await },
|
|
move |ui, _, result| match result {
|
|
Ok(url) => open_page(ui, &urls, &url, "manage"),
|
|
Err(error) => show_failure(ui, "manage", &error_text(&error)),
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Redeem a code that makes syncing free for a while. The dialog says
|
|
/// how it went; the subscription card follows the status.
|
|
fn redeem_code(&self, code: String) {
|
|
let Some(core) = self.core() else {
|
|
return;
|
|
};
|
|
self.run(
|
|
async move { core.device_sync_redeem_code(code).await },
|
|
|ui, _, result| {
|
|
let stage = match result {
|
|
Ok(SyncCodeOutcome::Redeemed) => "redeemed",
|
|
Ok(SyncCodeOutcome::Unknown) => "unknown",
|
|
Ok(SyncCodeOutcome::Used) => "used",
|
|
Ok(SyncCodeOutcome::Expired) => "expired",
|
|
Err(error) => {
|
|
ui.set_redeem_failure(error_text(&error).into());
|
|
"failed"
|
|
}
|
|
};
|
|
ui.set_redeem_stage(stage.into());
|
|
},
|
|
);
|
|
}
|
|
|
|
fn delete_everywhere(&self) {
|
|
let Some(core) = self.core() else {
|
|
return;
|
|
};
|
|
self.run(
|
|
async move { core.device_sync_delete_everywhere().await },
|
|
|ui, _, result| match result {
|
|
Ok(()) => show_failure(ui, "", ""),
|
|
Err(error) => show_failure(ui, "delete", &error_text(&error)),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Open a page of the payment provider; `action` names it if that fails.
|
|
fn open_page(ui: &SyncUi, urls: &ExternalUrlOpener, url: &str, action: &str) {
|
|
match urls.open_external(url) {
|
|
Ok(()) => show_failure(ui, "", ""),
|
|
Err(error) => show_failure(ui, action, &error),
|
|
}
|
|
}
|
|
|
|
/// What the group pays, for the subscription card.
|
|
fn show_billing(ui: &SyncUi, billing: Option<&SyncBilling>) {
|
|
let Some(billing) = billing else {
|
|
ui.set_billing("".into());
|
|
return;
|
|
};
|
|
let (state, date) = match billing.status {
|
|
SyncBillingStatus::Trial => ("trial", billing.trial_ends_at),
|
|
SyncBillingStatus::Active => ("active", billing.paid_until),
|
|
SyncBillingStatus::PastDue => ("past-due", billing.paid_until),
|
|
SyncBillingStatus::Granted => ("free", billing.free_until),
|
|
SyncBillingStatus::Expired => ended(billing),
|
|
SyncBillingStatus::Unknown => ("unknown", None),
|
|
};
|
|
ui.set_billing(state.into());
|
|
ui.set_billing_date(billing_date(date).into());
|
|
ui.set_billing_renews(billing.renews);
|
|
// A free period still to come, which a group that pays is told about.
|
|
let now = chrono::Utc::now().timestamp_millis();
|
|
ui.set_free_until(billing_date(billing.free_until.filter(|until| *until > now)).into());
|
|
let prices = plan_prices(&billing.plans);
|
|
ui.set_monthly_price(prices.monthly.into());
|
|
ui.set_yearly_price(prices.yearly.into());
|
|
ui.set_yearly_monthly_price(prices.yearly_monthly.into());
|
|
ui.set_yearly_saving(prices.yearly_saving);
|
|
ui.set_tax_included(prices.tax_included);
|
|
}
|
|
|
|
/// The state of a group whose trial, paid and free periods have all ended:
|
|
/// the one that ended last names it.
|
|
fn ended(billing: &SyncBilling) -> (&'static str, Option<i64>) {
|
|
match (billing.paid_until, billing.free_until) {
|
|
(paid, Some(free)) if paid.is_none_or(|paid| free > paid) => ("free-ended", Some(free)),
|
|
(Some(paid), _) => ("ended", Some(paid)),
|
|
_ => ("trial-ended", billing.trial_ends_at),
|
|
}
|
|
}
|
|
|
|
/// A date the subscription card names, such as "Oct 22, 2026".
|
|
fn billing_date(at: Option<i64>) -> String {
|
|
at.and_then(chrono::DateTime::from_timestamp_millis)
|
|
.map(|at| {
|
|
date_format::current().date(DateStyle::Medium, at.with_timezone(&Local).date_naive())
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// The plans as the pricing card shows them.
|
|
#[derive(Debug, Default, PartialEq, Eq)]
|
|
struct PlanPrices {
|
|
/// Each empty when there is no such plan.
|
|
monthly: String,
|
|
yearly: String,
|
|
/// The yearly price per month.
|
|
yearly_monthly: String,
|
|
/// What the yearly plan saves against twelve monthly payments, in
|
|
/// percent.
|
|
yearly_saving: i32,
|
|
tax_included: bool,
|
|
}
|
|
|
|
fn plan_prices(plans: &[SyncPlan]) -> PlanPrices {
|
|
let monthly = plans.iter().find(|plan| plan.interval == "month");
|
|
let yearly = plans.iter().find(|plan| plan.interval == "year");
|
|
let label = |plan: Option<&SyncPlan>| {
|
|
plan.map(|plan| price(plan.amount, &plan.currency))
|
|
.unwrap_or_default()
|
|
};
|
|
let mut prices = PlanPrices {
|
|
monthly: label(monthly),
|
|
yearly: label(yearly),
|
|
tax_included: !plans.is_empty() && plans.iter().all(|plan| plan.tax_included),
|
|
..PlanPrices::default()
|
|
};
|
|
if let Some(yearly) = yearly {
|
|
// Rounded to the nearest smallest unit.
|
|
prices.yearly_monthly = price((yearly.amount + 6) / 12, &yearly.currency);
|
|
if let Some(monthly) = monthly.filter(|monthly| monthly.currency == yearly.currency) {
|
|
let twelve = monthly.amount.saturating_mul(12);
|
|
if yearly.amount < twelve {
|
|
let saving = ((twelve - yearly.amount) * 100 + twelve / 2) / twelve;
|
|
prices.yearly_saving = i32::try_from(saving).unwrap_or(0);
|
|
}
|
|
}
|
|
}
|
|
prices
|
|
}
|
|
|
|
/// A price such as "€4" or "€4.50", from the amount in the currency's
|
|
/// smallest unit and its ISO 4217 code. Like the app's other numbers, it
|
|
/// uses a decimal point.
|
|
fn price(amount: u64, currency: &str) -> String {
|
|
// Currencies without a smaller unit, as Stripe counts them.
|
|
const ZERO_DECIMAL: [&str; 16] = [
|
|
"bif", "clp", "djf", "gnf", "jpy", "kmf", "krw", "mga", "pyg", "rwf", "ugx", "vnd", "vuv",
|
|
"xaf", "xof", "xpf",
|
|
];
|
|
let currency = currency.to_ascii_lowercase();
|
|
let number = if ZERO_DECIMAL.contains(¤cy.as_str()) {
|
|
amount.to_string()
|
|
} else if amount.is_multiple_of(100) {
|
|
(amount / 100).to_string()
|
|
} else {
|
|
format!("{}.{:02}", amount / 100, amount % 100)
|
|
};
|
|
match currency.as_str() {
|
|
"eur" => format!("€{number}"),
|
|
"usd" => format!("${number}"),
|
|
"gbp" => format!("£{number}"),
|
|
"jpy" => format!("¥{number}"),
|
|
_ => format!("{} {number}", currency.to_ascii_uppercase()),
|
|
}
|
|
}
|
|
|
|
/// Show the code a new pairing returned, if the dialog still waits for it.
|
|
/// A pairing the user no longer waits for is cancelled.
|
|
fn show_code(
|
|
ui: &SyncUi,
|
|
controller: &SyncController,
|
|
attempt: u64,
|
|
result: Result<PairingCode, CoreError>,
|
|
core: &Core,
|
|
) {
|
|
if controller.attempt.get() != attempt {
|
|
if let Ok(code) = result {
|
|
core.device_sync_cancel_pairing(code.pairing);
|
|
}
|
|
return;
|
|
}
|
|
match result {
|
|
Ok(code) => {
|
|
let image = code.qr.as_deref().and_then(qr_image);
|
|
ui.set_pairing_has_qr(image.is_some());
|
|
ui.set_pairing_qr(image.unwrap_or_default());
|
|
ui.set_pairing_code(
|
|
code.code
|
|
.as_deref()
|
|
.map(spaced_code)
|
|
.unwrap_or_default()
|
|
.into(),
|
|
);
|
|
ui.set_pairing_stage("code".into());
|
|
controller.follow(ui, code.pairing);
|
|
}
|
|
Err(error) => show_pairing_error(ui, &error),
|
|
}
|
|
}
|
|
|
|
fn show_step(ui: &SyncUi, event: PairingEvent) {
|
|
let stage = match event {
|
|
PairingEvent::Request { words } => {
|
|
ui.set_pairing_words(words_model(&words));
|
|
"request"
|
|
}
|
|
PairingEvent::Waiting { words } => {
|
|
ui.set_pairing_words(words_model(&words));
|
|
"waiting"
|
|
}
|
|
PairingEvent::Joined => "joined",
|
|
PairingEvent::Added => "added",
|
|
PairingEvent::Declined => "declined",
|
|
PairingEvent::Expired => "expired",
|
|
PairingEvent::Failed(error) => {
|
|
ui.set_pairing_error(error.into());
|
|
"failed"
|
|
}
|
|
};
|
|
ui.set_pairing_stage(stage.into());
|
|
}
|
|
|
|
/// A pairing that could not start: a code or recovery code that matches
|
|
/// nothing, or another failure the dialog shows as it is.
|
|
fn show_pairing_error(ui: &SyncUi, error: &CoreError) {
|
|
if matches!(error, CoreError::NotFound(_)) {
|
|
ui.set_pairing_stage("not-found".into());
|
|
} else {
|
|
ui.set_pairing_error(error_text(error).into());
|
|
ui.set_pairing_stage("failed".into());
|
|
}
|
|
}
|
|
|
|
/// What went wrong, in the client's own words when it was the sync server,
|
|
/// without the core's category prefix.
|
|
fn error_text(error: &CoreError) -> String {
|
|
match error {
|
|
CoreError::Network(text) | CoreError::NotFound(text) | CoreError::Other(text) => {
|
|
text.clone()
|
|
}
|
|
error => error.to_string(),
|
|
}
|
|
}
|
|
|
|
/// An error shown on a line of its own, which starts like a sentence.
|
|
fn sentence(text: &str) -> String {
|
|
let mut chars = text.chars();
|
|
chars
|
|
.next()
|
|
.map(|first| first.to_uppercase().chain(chars).collect())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// The action that failed last and why, or none.
|
|
fn show_failure(ui: &SyncUi, action: &str, failure: &str) {
|
|
ui.set_failed_action(action.into());
|
|
ui.set_failure(failure.into());
|
|
}
|
|
|
|
fn show_recovery_words(ui: &SyncUi, words: &str) {
|
|
let words: Vec<&str> = words.split_whitespace().collect();
|
|
ui.set_recovery_text(words.join(" ").into());
|
|
ui.set_recovery_words(words_model(&words));
|
|
ui.set_recovery_saved(false);
|
|
ui.set_recovery_save_error(SharedString::new());
|
|
}
|
|
|
|
/// Writes the recovery code's file text below `temp_dir` for a picker to
|
|
/// copy, readable by this user only. The file goes when the path drops.
|
|
fn stage_recovery_code(temp_dir: &Path, text: &str) -> std::io::Result<tempfile::TempPath> {
|
|
std::fs::create_dir_all(temp_dir)?;
|
|
let mut file = tempfile::Builder::new()
|
|
.prefix(RECOVERY_STAGING_PREFIX)
|
|
.suffix(".txt")
|
|
.tempfile_in(temp_dir)?;
|
|
file.write_all(text.as_bytes())?;
|
|
Ok(file.into_temp_path())
|
|
}
|
|
|
|
/// Removes staged recovery codes that a crash left behind.
|
|
fn remove_staged_recovery_codes(temp_dir: &Path) {
|
|
let Ok(entries) = std::fs::read_dir(temp_dir) else {
|
|
return;
|
|
};
|
|
for entry in entries.flatten() {
|
|
if entry
|
|
.file_name()
|
|
.to_string_lossy()
|
|
.starts_with(RECOVERY_STAGING_PREFIX)
|
|
{
|
|
let _ = std::fs::remove_file(entry.path());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn words_model<S: AsRef<str>>(words: &[S]) -> ModelRc<SharedString> {
|
|
ModelRc::new(VecModel::from(
|
|
words
|
|
.iter()
|
|
.map(|word| SharedString::from(word.as_ref()))
|
|
.collect::<Vec<_>>(),
|
|
))
|
|
}
|
|
|
|
/// Eight digits as two groups of four, which are easier to read out and
|
|
/// type. Joining takes the digits with or without the space.
|
|
fn spaced_code(code: &str) -> String {
|
|
let digits: Vec<char> = code.chars().filter(char::is_ascii_digit).collect();
|
|
if digits.len() != 8 {
|
|
return code.to_owned();
|
|
}
|
|
let (first, second) = digits.split_at(4);
|
|
format!(
|
|
"{} {}",
|
|
first.iter().collect::<String>(),
|
|
second.iter().collect::<String>()
|
|
)
|
|
}
|
|
|
|
/// The sync server address typed on the page as the client takes it, or
|
|
/// `None` for Flectar's. Without a scheme it is HTTPS; plain HTTP is only
|
|
/// for a server on this device, as a session token must not cross a network
|
|
/// in the clear.
|
|
fn server_address(typed: &str) -> Result<Option<String>, ()> {
|
|
let typed = typed.trim();
|
|
if typed.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
let url = if typed.contains("://") {
|
|
url::Url::parse(typed)
|
|
} else {
|
|
url::Url::parse(&format!("https://{typed}"))
|
|
}
|
|
.map_err(|_| ())?;
|
|
let loopback = match url.host() {
|
|
Some(url::Host::Domain(name)) => name.eq_ignore_ascii_case("localhost"),
|
|
Some(url::Host::Ipv4(address)) => address.is_loopback(),
|
|
Some(url::Host::Ipv6(address)) => address.is_loopback(),
|
|
None => return Err(()),
|
|
};
|
|
let secure = url.scheme() == "https" || (url.scheme() == "http" && loopback);
|
|
if !secure
|
|
|| !url.username().is_empty()
|
|
|| url.password().is_some()
|
|
|| url.query().is_some()
|
|
|| url.fragment().is_some()
|
|
{
|
|
return Err(());
|
|
}
|
|
Ok(Some(url.as_str().trim_end_matches('/').to_owned()))
|
|
}
|
|
|
|
/// When sync last finished, as the page words it: a time when it was today,
|
|
/// else a date. Empty before the first sync.
|
|
fn last_synced(at: Option<i64>, now: &chrono::DateTime<Local>) -> (String, bool) {
|
|
let Some(at) = at
|
|
.and_then(chrono::DateTime::from_timestamp_millis)
|
|
.map(|at| at.with_timezone(&Local))
|
|
else {
|
|
return (String::new(), false);
|
|
};
|
|
let formats = date_format::current();
|
|
if at.date_naive() == now.date_naive() {
|
|
(formats.time(at.hour(), at.minute()), true)
|
|
} else if at.year() == now.year() {
|
|
(
|
|
formats.date(DateStyle::ShortDayMonth, at.date_naive()),
|
|
false,
|
|
)
|
|
} else {
|
|
(formats.date(DateStyle::Short, at.date_naive()), false)
|
|
}
|
|
}
|
|
|
|
/// A QR code for the screen: one pixel per module plus the four-module quiet
|
|
/// zone scanners need. The dialog scales it up without smoothing.
|
|
fn qr_image(text: &str) -> Option<Image> {
|
|
const QUIET_ZONE: usize = 4;
|
|
let code =
|
|
qrcode::QrCode::with_error_correction_level(text.as_bytes(), qrcode::EcLevel::M).ok()?;
|
|
let modules = code.width();
|
|
let side = modules + 2 * QUIET_ZONE;
|
|
let mut buffer = SharedPixelBuffer::<Rgba8Pixel>::new(side as u32, side as u32);
|
|
let pixels = buffer.make_mut_slice();
|
|
pixels.fill(Rgba8Pixel::new(255, 255, 255, 255));
|
|
for (index, color) in code.into_colors().into_iter().enumerate() {
|
|
if color == qrcode::Color::Dark {
|
|
let (x, y) = (index % modules + QUIET_ZONE, index / modules + QUIET_ZONE);
|
|
pixels[y * side + x] = Rgba8Pixel::new(0, 0, 0, 255);
|
|
}
|
|
}
|
|
Some(Image::from_rgba8(buffer))
|
|
}
|
|
|
|
/// Another device changed synced preferences: show them as if they had
|
|
/// changed here.
|
|
fn reload_preferences(
|
|
app: &AppWindow,
|
|
state: &Rc<RefCell<InboxState>>,
|
|
runtime: &tokio::runtime::Handle,
|
|
) {
|
|
let Some(core) = state.borrow().core.clone() else {
|
|
return;
|
|
};
|
|
let app = app.as_weak();
|
|
let state = Rc::clone(state);
|
|
let apply_runtime = runtime.clone();
|
|
documents::run_request(
|
|
runtime,
|
|
Box::pin(async move { core.load_settings().await }),
|
|
move |settings| match (app.upgrade(), settings) {
|
|
(Some(app), Ok(settings)) => show_preferences(&app, &state, &apply_runtime, &settings),
|
|
(_, Err(error)) => tracing::warn!(%error, "reading synced preferences failed"),
|
|
_ => {}
|
|
},
|
|
);
|
|
}
|
|
|
|
/// The synced preferences (`device_sync::SYNCED_PREFERENCES` in the core),
|
|
/// applied to the window the way each one's own setting applies it.
|
|
fn show_preferences(
|
|
app: &AppWindow,
|
|
state: &Rc<RefCell<InboxState>>,
|
|
runtime: &tokio::runtime::Handle,
|
|
settings: &Settings,
|
|
) {
|
|
startup::apply_theme(app, settings);
|
|
app.global::<MessageColors>()
|
|
.set_preference(settings.message_appearance.as_str().into());
|
|
app.set_monochrome_sidebar_icons(settings.monochrome_sidebar_icons);
|
|
app.set_show_avatars(settings.show_avatars);
|
|
app.set_show_tabs(settings.show_tabs);
|
|
app.set_workspace_layout(
|
|
match settings.workspace_layout.as_str() {
|
|
"minimal" => "minimal",
|
|
_ => "default",
|
|
}
|
|
.into(),
|
|
);
|
|
if app.get_time_format().as_str() != settings.time_format.as_str() {
|
|
app.set_time_format(settings.time_format.as_str().into());
|
|
date_format::reload(app);
|
|
}
|
|
if app.get_language_mode().as_str() != normalized_language_preference(&settings.language) {
|
|
apply_language(app, &settings.language);
|
|
}
|
|
app.set_mark_read_on_open(settings.mark_read_on_open);
|
|
state.borrow_mut().mark_read_on_open = settings.mark_read_on_open;
|
|
app.set_collect_outgoing_contacts(settings.collect_outgoing_contacts);
|
|
app.set_collect_incoming_contacts(settings.collect_incoming_contacts);
|
|
app.set_contact_suggest_all_accounts(settings.contact_suggest_all_accounts);
|
|
app.set_suggest_learned_contacts(settings.suggest_learned_contacts);
|
|
|
|
if app.get_group_mail_by_date() != settings.group_mail_by_date {
|
|
app.set_group_mail_by_date(settings.group_mail_by_date);
|
|
state.borrow_mut().mail_groups.clear();
|
|
refresh_rows_only(app, state, runtime);
|
|
}
|
|
apply_mail_grouping(app, state, runtime, settings.mail_grouping());
|
|
app.set_show_account_markers(settings.show_account_badges);
|
|
app.global::<SyncUi>()
|
|
.set_sync_sign_ins(settings.sync_sign_ins);
|
|
settings_controller::apply_account_presentation_settings(app, state, runtime, settings);
|
|
let remote_images = settings.load_remote_images && cfg!(feature = "remote-content");
|
|
if app.get_remote_images_enabled() != remote_images {
|
|
// Through the toggle's own handler: it also starts or stops loading.
|
|
app.invoke_set_remote_images(remote_images);
|
|
}
|
|
}
|
|
|
|
/// Another device added, changed or removed accounts. Removed ones leave
|
|
/// the window as a removal here does; the others are listed again.
|
|
fn reload_accounts(
|
|
state: &Rc<RefCell<InboxState>>,
|
|
runtime: &tokio::runtime::Handle,
|
|
ui_tasks: &UiSender<UiTaskUpdate>,
|
|
calendar_month: NaiveDate,
|
|
removed: Vec<i64>,
|
|
) {
|
|
let (core, removals) = {
|
|
let state = state.borrow();
|
|
let Some(core) = state.core.clone() else {
|
|
return;
|
|
};
|
|
let removals: Vec<_> = removed
|
|
.into_iter()
|
|
.filter(|account_id| {
|
|
state
|
|
.connected_accounts
|
|
.iter()
|
|
.any(|account| account.id == *account_id)
|
|
})
|
|
.map(|account_id| {
|
|
(
|
|
account_id,
|
|
RemovalView::of(&state, account_id, calendar_month),
|
|
)
|
|
})
|
|
.collect();
|
|
(core, removals)
|
|
};
|
|
let updates = ui_tasks.clone();
|
|
runtime.spawn(async move {
|
|
for (account_id, view) in removals {
|
|
let accounts = core.load_account_snapshot().await.ok();
|
|
// A removal that failed leaves the account listed.
|
|
let removed = accounts.as_ref().is_some_and(|snapshot| {
|
|
!snapshot
|
|
.accounts
|
|
.iter()
|
|
.any(|account| account.id == account_id)
|
|
});
|
|
if !removed {
|
|
continue;
|
|
}
|
|
let removal =
|
|
read_account_removal(&core, account_id, true, accounts.as_ref(), view).await;
|
|
let _ = updates
|
|
.send(UiTaskUpdate {
|
|
message: UiMessage::plain("Account removed on another device."),
|
|
accounts,
|
|
account_removal: Some(removal),
|
|
calendar_connections: core.load_calendar_connections().await.ok(),
|
|
carddav_connections: core.load_carddav_connections().await.ok(),
|
|
calendar_error: None,
|
|
clear_account_form: false,
|
|
finishes_account_setup: false,
|
|
finishes_oauth: false,
|
|
close_to_tray: None,
|
|
})
|
|
.await;
|
|
}
|
|
let _ = updates
|
|
.send(UiTaskUpdate {
|
|
message: UiMessage::plain("Accounts updated from your other devices."),
|
|
accounts: core.load_account_snapshot().await.ok(),
|
|
account_removal: None,
|
|
calendar_connections: core.load_calendar_connections().await.ok(),
|
|
carddav_connections: core.load_carddav_connections().await.ok(),
|
|
calendar_error: None,
|
|
clear_account_form: false,
|
|
finishes_account_setup: false,
|
|
finishes_oauth: false,
|
|
close_to_tray: None,
|
|
})
|
|
.await;
|
|
});
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn server_addresses_default_to_https_and_keep_plain_http_local() {
|
|
assert_eq!(server_address(" "), Ok(None));
|
|
assert_eq!(
|
|
server_address("sync.example.com"),
|
|
Ok(Some("https://sync.example.com".into()))
|
|
);
|
|
assert_eq!(
|
|
server_address("https://Sync.Example.com/flectar/"),
|
|
Ok(Some("https://sync.example.com/flectar".into()))
|
|
);
|
|
assert_eq!(
|
|
server_address("http://localhost:8080"),
|
|
Ok(Some("http://localhost:8080".into()))
|
|
);
|
|
assert_eq!(
|
|
server_address("http://[::1]:8080/"),
|
|
Ok(Some("http://[::1]:8080".into()))
|
|
);
|
|
assert_eq!(server_address("http://sync.example.com"), Err(()));
|
|
assert_eq!(server_address("http://192.168.1.10"), Err(()));
|
|
assert_eq!(server_address("ftp://sync.example.com"), Err(()));
|
|
assert_eq!(
|
|
server_address("https://user:pass@sync.example.com"),
|
|
Err(())
|
|
);
|
|
assert_eq!(server_address("https://sync.example.com/?a=b"), Err(()));
|
|
assert_eq!(server_address("not a url"), Err(()));
|
|
}
|
|
|
|
#[test]
|
|
fn errors_on_their_own_line_start_like_sentences() {
|
|
assert_eq!(
|
|
sentence("could not reach the sync server"),
|
|
"Could not reach the sync server"
|
|
);
|
|
assert_eq!(sentence(""), "");
|
|
}
|
|
|
|
#[test]
|
|
fn prices_read_like_price_tags() {
|
|
assert_eq!(price(400, "eur"), "€4");
|
|
assert_eq!(price(450, "EUR"), "€4.50");
|
|
assert_eq!(price(4805, "usd"), "$48.05");
|
|
assert_eq!(price(500, "jpy"), "¥500");
|
|
assert_eq!(price(400, "chf"), "CHF 4");
|
|
let plan = |interval: &str, amount| SyncPlan {
|
|
id: interval.into(),
|
|
amount,
|
|
currency: "eur".into(),
|
|
interval: interval.into(),
|
|
tax_included: true,
|
|
};
|
|
assert_eq!(
|
|
plan_prices(&[plan("year", 4000), plan("month", 400)]),
|
|
PlanPrices {
|
|
monthly: "€4".into(),
|
|
yearly: "€40".into(),
|
|
yearly_monthly: "€3.33".into(),
|
|
yearly_saving: 17,
|
|
tax_included: true,
|
|
}
|
|
);
|
|
let yearly_only = plan_prices(&[plan("year", 4800)]);
|
|
assert_eq!(
|
|
(
|
|
yearly_only.monthly.as_str(),
|
|
yearly_only.yearly_monthly.as_str()
|
|
),
|
|
("", "€4")
|
|
);
|
|
assert_eq!(yearly_only.yearly_saving, 0);
|
|
assert_eq!(plan_prices(&[]), PlanPrices::default());
|
|
}
|
|
|
|
#[test]
|
|
fn short_codes_show_as_two_groups() {
|
|
assert_eq!(spaced_code("12345678"), "1234 5678");
|
|
assert_eq!(spaced_code("1234 5678"), "1234 5678");
|
|
assert_eq!(spaced_code("123"), "123");
|
|
}
|
|
|
|
#[test]
|
|
fn qr_codes_keep_a_quiet_zone() {
|
|
let image = qr_image(
|
|
"flectar-mail-sync:pair?v=1&server=https%3A%2F%2Fmail-sync.flectar.com&slot=AAAAAAAAAAAAAAAAAAAAAA&secret=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
|
)
|
|
.unwrap();
|
|
let size = image.size();
|
|
assert_eq!(size.width, size.height);
|
|
// Version 1 is 21 modules a side; a pairing link needs more.
|
|
assert!(size.width > 21 + 8);
|
|
let pixels = image.to_rgba8().unwrap();
|
|
let corner = pixels.as_slice()[0];
|
|
assert_eq!(corner, Rgba8Pixel::new(255, 255, 255, 255));
|
|
// The finder pattern starts right after the quiet zone.
|
|
let finder = pixels.as_slice()[4 * size.width as usize + 4];
|
|
assert_eq!(finder, Rgba8Pixel::new(0, 0, 0, 255));
|
|
}
|
|
|
|
#[test]
|
|
fn last_synced_shows_a_time_today_and_a_date_before() {
|
|
let now = Local.with_ymd_and_hms(2026, 11, 7, 15, 0, 0).unwrap();
|
|
let today = Local.with_ymd_and_hms(2026, 11, 7, 9, 5, 0).unwrap();
|
|
let (text, is_today) = last_synced(Some(today.timestamp_millis()), &now);
|
|
assert!(is_today);
|
|
assert!(text.contains("05"), "{text}");
|
|
let earlier = Local.with_ymd_and_hms(2026, 10, 22, 9, 5, 0).unwrap();
|
|
let (text, is_today) = last_synced(Some(earlier.timestamp_millis()), &now);
|
|
assert!(!is_today);
|
|
assert!(text.contains("22"), "{text}");
|
|
assert_eq!(last_synced(None, &now), (String::new(), false));
|
|
}
|
|
|
|
#[test]
|
|
fn the_period_that_ended_last_names_an_ended_group() {
|
|
let billing = |paid: Option<i64>, free: Option<i64>| SyncBilling {
|
|
status: SyncBillingStatus::Expired,
|
|
trial_ends_at: Some(1),
|
|
paid_until: paid,
|
|
free_until: free,
|
|
renews: false,
|
|
plans: Vec::new(),
|
|
};
|
|
assert_eq!(ended(&billing(None, None)), ("trial-ended", Some(1)));
|
|
assert_eq!(ended(&billing(Some(5), None)), ("ended", Some(5)));
|
|
assert_eq!(ended(&billing(None, Some(7))), ("free-ended", Some(7)));
|
|
assert_eq!(ended(&billing(Some(9), Some(7))), ("ended", Some(9)));
|
|
assert_eq!(ended(&billing(Some(5), Some(7))), ("free-ended", Some(7)));
|
|
}
|
|
|
|
#[test]
|
|
fn a_staged_recovery_code_stays_private_and_never_outlives_its_save() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let staged = stage_recovery_code(dir.path(), "abandon ability").unwrap();
|
|
assert_eq!(std::fs::read_to_string(&staged).unwrap(), "abandon ability");
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mode = std::fs::metadata(&staged).unwrap().permissions().mode();
|
|
assert_eq!(mode & 0o077, 0, "{mode:o}");
|
|
}
|
|
let path = staged.to_path_buf();
|
|
drop(staged);
|
|
assert!(!path.exists());
|
|
|
|
// A copy a crash left behind goes at the next start; other files stay.
|
|
let left = stage_recovery_code(dir.path(), "abandon")
|
|
.unwrap()
|
|
.keep()
|
|
.unwrap();
|
|
let other = dir.path().join("email-1.html");
|
|
std::fs::write(&other, "<p></p>").unwrap();
|
|
remove_staged_recovery_codes(dir.path());
|
|
assert!(!left.exists());
|
|
assert!(other.exists());
|
|
}
|
|
}
|