From 2892ea3fa7e13fbc25a4b684bde729df1e13f5b9 Mon Sep 17 00:00:00 2001 From: randogoth Date: Thu, 8 Oct 2026 19:58:50 +0300 Subject: [PATCH] feat: rework signup into a safer multi-step flow, add keep-mail setting --- src/app.rs | 382 ++++++++++++++++++++++++++++++++++++++++++++++++---- src/ops.rs | 45 ++++--- src/view.rs | 354 +++++++++++++++++++++++++++++++++++------------- 3 files changed, 649 insertions(+), 132 deletions(-) diff --git a/src/app.rs b/src/app.rs index 4e83f83..3b9c479 100644 --- a/src/app.rs +++ b/src/app.rs @@ -75,14 +75,28 @@ pub enum OnboardMode { /// Signup: bind the identity to a username, or recall an existing address. pub struct Signup { + pub step: SignupStep, + pub mode: SignupMode, pub user: String, pub host: String, pub invite: String, - /// The server's base32 static key, pasted from what the operator - /// published; pinned before the registration connects. - pub key: String, - /// True when onboarding just wrote the key file, so Back is offered. - pub fresh: bool, + /// True once this host's key is known to be pinned — either a fetch + /// was just confirmed (see `Carrier::pin_offer`) or it already was + /// from an earlier session — so the server step's button reads + /// "Continue" instead of "Fetch the server's key". + pub key_trusted: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SignupStep { + Server, + Account, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SignupMode { + New, + Existing, } /// The compose view: `to` takes a short address or a smol:// URI. @@ -107,6 +121,8 @@ pub enum Dialog { }, /// Rotation only moves the index forward, so the action asks first. Rotate, + /// Factory reset erases every local file, so it asks first too. + Reset, /// The message being answered carries the sender's own frontmatter /// fields; the reply asks whether to carry copies of them. Reply { @@ -133,9 +149,13 @@ pub enum Message { SignupUserChanged(String), SignupHostChanged(String), SignupInviteChanged(String), - SignupKeyChanged(String), - SignupBack, - SignupLearnKey, + /// The server step's one button: fetches the host's key, or — once + /// one is on screen (fetched this session or already pinned) — pins + /// it and advances. + SignupKeyAction, + SignupStepBack, + SignupStartOver, + SignupModeChanged(SignupMode), SignupRegister, SignupRecall, @@ -186,6 +206,7 @@ pub enum Message { // Settings SettingsLearnKey, + KeepMailChanged(bool), TimeoutChanged(String), RotatePressed, RotateConfirmed, @@ -194,6 +215,9 @@ pub enum Message { ExportPressed, ImportBodyChanged(String), ImportPressed, + ResetPressed, + ResetConfirmed, + ResetCancelled, } /// What a background operation reports back. @@ -219,8 +243,6 @@ pub enum PendingOp { Register { addr: Address, invite: Option, - /// A pasted operator key, pinned before the registration connects. - key: String, }, Restore { addr: Address, @@ -279,6 +301,9 @@ pub struct Carrier { pub status_error: bool, pub busy: Option, pub cancel: Arc, + /// Off by default: a fetch acknowledges what it takes, so mail is + /// removed from the server once it's stored here. + pub keep_mail: bool, pub timeout: u64, pub timeout_text: String, pub onboard: Onboard, @@ -304,6 +329,11 @@ pub struct Carrier { /// The view syncs entry text from the model once after a prefill /// (reply, write-to-contact), then leaves typing to the widgets. pub compose_fresh: Cell, + /// Same pattern for the onboarding/signup entries: a programmatic + /// reset (factory reset, discarding a cancelled identity) clears the + /// model, and this flag makes the view push that blank state into + /// the entries once, instead of leaving stale typed text on screen. + pub form_fresh: Cell, } impl Carrier { @@ -334,6 +364,7 @@ impl Carrier { status_error: false, busy: None, cancel: Arc::new(AtomicBool::new(false)), + keep_mail: false, timeout: 30, timeout_text: "30".to_string(), onboard: Onboard { @@ -342,11 +373,12 @@ impl Carrier { seed_shown: String::new(), }, signup: Signup { + step: SignupStep::Server, + mode: SignupMode::New, user: String::new(), host: String::new(), invite: String::new(), - key: String::new(), - fresh: false, + key_trusted: false, }, folder: Folder::Inbox, lists: vec![Vec::new(), Vec::new(), Vec::new()], @@ -362,6 +394,7 @@ impl Carrier { contact_import: String::new(), dialog: None, compose_fresh: Cell::new(false), + form_fresh: Cell::new(true), }; let op = registered.then(|| PendingOp::Snapshot { read: app.marks.snapshot(), @@ -427,6 +460,61 @@ impl Carrier { self.compose_fresh.set(true); } + /// Factory reset: close the store, erase the config directory, then + /// boot a fresh model in place. The connection is swapped out before + /// the files go because Windows refuses to remove a file a live + /// connection holds, and the busy guard has already seen to it that + /// no operation holds a clone of the store. + fn factory_reset(&mut self) { + let store = match Store::open_in_memory() { + Ok(store) => Arc::new(store), + Err(e) => { + self.fail(e.to_string()); + return; + } + }; + self.store = store; + if let Err(e) = ops::factory_reset(&self.dir) { + self.fail(e); + return; + } + let (app, _) = Carrier::boot(self.dir.clone()); + *self = app; + self.note("factory reset done. Create a new identity or restore a seed.".into()); + } + + /// Cancelling out of a not-yet-registered identity removes the key + /// file too, so Back always leaves onboarding resumable from a clean + /// start — never behind a stale key a later Create could silently + /// overwrite and orphan the server registration against. The seed + /// already shown (or pasted) still regenerates the same master, so + /// nothing is actually lost. + fn discard_identity(&mut self) { + if self.store.account().ok().flatten().is_none() { + if let Err(e) = ops::delete_key(&self.key_path()) { + self.fail(e); + return; + } + self.master = None; + } + self.onboard = Onboard { + mode: OnboardMode::Start, + seed: String::new(), + seed_shown: String::new(), + }; + self.signup = Signup { + step: SignupStep::Server, + mode: SignupMode::New, + user: String::new(), + host: String::new(), + invite: String::new(), + key_trusted: false, + }; + self.pin_offer = None; + self.page = Page::Onboard; + self.form_fresh.set(true); + } + /// The current folder's rows, drafts included: drafts carry the /// recipient where mail carries the sender, newest first. pub fn rows(&self) -> Vec { @@ -504,6 +592,17 @@ impl Carrier { Address::parse(&format!("{user}@{host}")).ok() } + /// The host as typed on the signup page's server step, before a + /// username exists — `learn_key`/`connect` only read host/port/scheme, + /// so a throwaway username satisfies `Address::parse`'s grammar. + fn signup_host_address(&self) -> Option
{ + let host = self.signup.host.trim(); + if host.is_empty() { + return None; + } + Address::parse(&format!("x@{host}")).ok() + } + /// Opens one message and marks it read inline: opening an envelope and /// re-listing is local and fast, so it needs no background pass. fn select_message(&mut self, folder: Folder, id: String) { @@ -596,16 +695,20 @@ impl Carrier { } Message::SignupHostChanged(host) => { self.signup.host = host; + // A different host invalidates whatever key was on + // screen: there is only ever one key pending trust. + self.signup.key_trusted = false; + self.pin_offer = None; } Message::SignupInviteChanged(invite) => { self.signup.invite = invite; } - Message::SignupKeyChanged(key) => { - self.signup.key = key; - } Message::ContactImportChanged(text) => { self.contact_import = text; } + Message::KeepMailChanged(keep) => { + self.keep_mail = keep; + } Message::TimeoutChanged(text) => { if let Ok(timeout) = text.trim().parse::() { self.timeout = timeout; @@ -640,7 +743,13 @@ impl Carrier { self.onboard.mode = OnboardMode::Restore; } Message::OnboardBack => { - self.onboard.mode = OnboardMode::Start; + if self.onboard.mode == OnboardMode::Created { + self.discard_identity(); + } else { + self.onboard.mode = OnboardMode::Start; + self.onboard.seed.clear(); + self.form_fresh.set(true); + } } Message::OnboardRestoreFromSeed => match ops::parse_seed(&self.onboard.seed) { Ok(master) => { @@ -648,7 +757,6 @@ impl Carrier { self.fail(e); } else { self.master = Some(master); - self.signup.fresh = true; self.page = Page::Signup; self.note("Seed restored. Bind a username or recall an address.".into()); } @@ -656,15 +764,28 @@ impl Carrier { Err(e) => self.fail(e), }, Message::OnboardSeedSaved => { - self.signup.fresh = true; self.page = Page::Signup; } - Message::SignupBack => { - self.page = Page::Onboard; + Message::SignupStepBack => { + self.signup.step = SignupStep::Server; } - Message::SignupLearnKey => { - let Some(addr) = self.signup_address() else { - self.fail("enter user and host first".into()); + Message::SignupStartOver => { + self.discard_identity(); + } + Message::SignupModeChanged(mode) => { + self.signup.mode = mode; + } + Message::SignupKeyAction => { + if let Some((addr, key)) = self.pin_offer.take() { + self.signup.step = SignupStep::Account; + return vec![PendingOp::Pin { addr, key }]; + } + if self.signup.key_trusted { + self.signup.step = SignupStep::Account; + return Vec::new(); + } + let Some(addr) = self.signup_host_address() else { + self.fail("enter the server host first".into()); return Vec::new(); }; return vec![PendingOp::LearnKey { addr }]; @@ -694,14 +815,19 @@ impl Carrier { } else { Some(self.signup.invite.clone()) }; - let key = self.signup.key.trim().to_string(); - return vec![PendingOp::Register { addr, invite, key }]; + if !self.start("registering") { + return Vec::new(); + } + return vec![PendingOp::Register { addr, invite }]; } Message::SignupRecall => { let Some(addr) = self.signup_address() else { self.fail("enter user and host".into()); return Vec::new(); }; + if !self.start("restoring") { + return Vec::new(); + } return vec![PendingOp::Restore { addr }]; } Message::GotoMail(folder) => { @@ -989,6 +1115,16 @@ impl Carrier { Message::RotateCancelled => { self.dialog = None; } + Message::ResetPressed => { + self.dialog = Some(Dialog::Reset); + } + Message::ResetCancelled => { + self.dialog = None; + } + Message::ResetConfirmed => { + self.dialog = None; + self.factory_reset(); + } Message::RotateConfirmed => { self.dialog = None; return vec![PendingOp::Rotate]; @@ -1030,6 +1166,13 @@ impl Carrier { OpResult::Generic(Ok(note)) => { self.done(); self.note(note); + // The only `Generic` ops reachable from the signup page are + // Register and Restore — LearnKey/Pin report through their + // own variants — so a success here always means the + // address is bound and it's safe to leave the page. + if self.page == Page::Signup { + self.page = Page::Mail; + } self.refresh() } OpResult::Generic(Err(e)) => { @@ -1049,6 +1192,7 @@ impl Carrier { } OpResult::PinOffered(Ok(None)) => { self.done(); + self.signup.key_trusted = true; self.note("This server's key is already pinned.".into()); None } @@ -1124,6 +1268,194 @@ mod tests { assert_eq!(app.busy.as_deref(), Some("fetching")); } + /// Off by default — a fetch deletes what it takes — and the Settings + /// switch is a plain toggle, not busy-guarded like a network action. + #[test] + fn keep_mail_defaults_off_and_toggles() { + let mut app = app(); + assert!(!app.keep_mail); + app.handle(Message::KeepMailChanged(true)); + assert!(app.keep_mail); + app.handle(Message::KeepMailChanged(false)); + assert!(!app.keep_mail); + } + + /// A factory reset asks first, then erases every file in the config + /// directory and returns the model to onboarding, as if the app had + /// never run. + #[test] + fn factory_reset_erases_the_files_and_returns_to_onboarding() { + let dir = tempfile::tempdir().unwrap().keep(); + let (mut app, _) = Carrier::boot(dir.clone()); + let master = ops::new_master(); + ops::save_key(&dir.join("carrier.key"), &master).unwrap(); + app.master = Some(master); + std::fs::write(dir.join("read.txt"), "ab").unwrap(); + std::fs::write(dir.join("drafts.txt"), "draft2 id 1 -\n\n\n\n").unwrap(); + app.signup.host = "typed-before-the-reset.example".into(); + app.signup.user = "alice".into(); + + // The button is busy-guarded like every other action. + app.busy = Some("fetching".into()); + app.handle(Message::ResetPressed); + assert!(app.dialog.is_none()); + + app.busy = None; + app.handle(Message::ResetPressed); + assert!(matches!(app.dialog, Some(Dialog::Reset))); + + app.handle(Message::ResetConfirmed); + assert!(app.dialog.is_none()); + assert_eq!(app.page, Page::Onboard); + assert_eq!(app.onboard.mode, OnboardMode::Start); + assert!(app.master.is_none()); + assert!(app.snapshot.is_none()); + assert!(app.drafts.is_empty()); + // The fields themselves, not just the model, must come back + // clean — form_fresh tells the view to push the blank state + // into the entries instead of leaving stale typed text on them. + assert!(app.signup.host.is_empty()); + assert!(app.signup.user.is_empty()); + assert!(app.form_fresh.get()); + for name in ["carrier.key", "read.txt", "drafts.txt"] { + assert!(!dir.join(name).exists(), "{name} survived the reset"); + } + // The store is reopened by boot, so a fresh empty db is the one + // file a reset legitimately leaves behind. + assert!(dir.join("carrier.db").exists()); + assert!(app.store.account().unwrap().is_none()); + } + + /// Backing out of a freshly created, not-yet-registered identity must + /// discard it — otherwise a later Create can silently overwrite it out + /// from under an in-flight or already-committed registration. + #[test] + fn onboard_back_from_created_discards_the_key() { + let dir = tempfile::tempdir().unwrap().keep(); + let (mut app, _) = Carrier::boot(dir.clone()); + app.handle(Message::OnboardCreate); + assert!(app.master.is_some()); + assert!(dir.join("carrier.key").exists()); + + app.handle(Message::OnboardBack); + assert!(app.master.is_none()); + assert!(!dir.join("carrier.key").exists()); + assert_eq!(app.page, Page::Onboard); + assert_eq!(app.onboard.mode, OnboardMode::Start); + } + + /// Restore mode writes no key until the seed is actually submitted, so + /// backing out of it has nothing to discard — just the typed seed. + #[test] + fn onboard_back_from_restore_only_clears_the_typed_seed() { + let mut app = app(); + app.handle(Message::OnboardRestore); + app.handle(Message::OnboardSeedChanged("not yet submitted".into())); + app.handle(Message::OnboardBack); + assert!(app.master.is_none()); + assert!(app.onboard.seed.is_empty()); + assert_eq!(app.onboard.mode, OnboardMode::Start); + } + + /// Start Over on the signup page's server step is the same discard, + /// reachable after onboarding has already handed off to Page::Signup. + #[test] + fn signup_start_over_discards_the_key() { + let dir = tempfile::tempdir().unwrap().keep(); + let (mut app, _) = Carrier::boot(dir.clone()); + app.handle(Message::OnboardCreate); + app.handle(Message::OnboardSeedSaved); + assert_eq!(app.page, Page::Signup); + app.signup.host = "example.org".into(); + + app.handle(Message::SignupStartOver); + assert!(app.master.is_none()); + assert!(!dir.join("carrier.key").exists()); + assert_eq!(app.page, Page::Onboard); + assert!(app.signup.host.is_empty()); + } + + /// Register and Restore are long-running, so Back (and a second click) + /// must be locked out for their whole in-flight window, the same way + /// FetchPressed already locks out everything else. + #[test] + fn signup_register_and_recall_engage_the_busy_guard() { + let mut app = app(); + app.master = Some([0u8; KEY_LEN]); + app.page = Page::Signup; + app.signup.user = "alice".into(); + app.signup.host = "example.org".into(); + + let ops = app.handle(Message::SignupRegister); + assert_eq!(app.busy.as_deref(), Some("registering")); + assert!(matches!(ops.as_slice(), [PendingOp::Register { .. }])); + assert!(app.handle(Message::SignupStartOver).is_empty()); + assert_eq!(app.page, Page::Signup); + + app.busy = None; + let ops = app.handle(Message::SignupRecall); + assert_eq!(app.busy.as_deref(), Some("restoring")); + assert!(matches!(ops.as_slice(), [PendingOp::Restore { .. }])); + } + + /// A successful Register/Restore must leave the signup page, or a + /// lingering Back there could still race a fresh identity into + /// existence against an address the store already considers bound. + #[test] + fn generic_success_on_the_signup_page_moves_on_to_mail() { + let mut app = app(); + app.page = Page::Signup; + app.busy = Some("registering".into()); + app.handle_result(OpResult::Generic(Ok("registered".into()))); + assert_eq!(app.page, Page::Mail); + } + + /// The server step's one button fetches a key when none is on + /// screen yet, then pins it and advances once one is. + #[test] + fn signup_key_action_fetches_then_pins_and_advances() { + let mut app = app(); + app.signup.host = "example.org".into(); + + let ops = app.handle(Message::SignupKeyAction); + assert!(matches!(ops.as_slice(), [PendingOp::LearnKey { .. }])); + assert_eq!(app.signup.step, SignupStep::Server); + + let addr = Address::parse("x@example.org").unwrap(); + app.pin_offer = Some((addr, [0u8; KEY_LEN])); + let ops = app.handle(Message::SignupKeyAction); + assert!(matches!(ops.as_slice(), [PendingOp::Pin { .. }])); + assert!(app.pin_offer.is_none()); + assert_eq!(app.signup.step, SignupStep::Account); + } + + /// An already-trusted host (pinned in an earlier session) needs no + /// key to show — Continue just advances with no background op. + #[test] + fn signup_key_action_advances_without_a_pin_when_already_trusted() { + let mut app = app(); + app.signup.host = "example.org".into(); + app.signup.key_trusted = true; + + let ops = app.handle(Message::SignupKeyAction); + assert!(ops.is_empty()); + assert_eq!(app.signup.step, SignupStep::Account); + } + + /// Editing the host after a key was fetched (or already trusted) + /// invalidates it: there is only ever one key pending trust. + #[test] + fn editing_the_host_resets_a_fetched_or_trusted_key() { + let mut app = app(); + let addr = Address::parse("x@example.org").unwrap(); + app.pin_offer = Some((addr, [0u8; KEY_LEN])); + app.signup.key_trusted = true; + + app.handle(Message::SignupHostChanged("example.net".into())); + assert!(app.pin_offer.is_none()); + assert!(!app.signup.key_trusted); + } + fn opened() -> OpenedMsg { OpenedMsg { id: "ab".repeat(32), diff --git a/src/ops.rs b/src/ops.rs index 5cf1d66..151f73e 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -292,6 +292,33 @@ pub fn save_key(path: &Path, master: &[u8; KEY_LEN]) -> Result<(), String> { err } +/// Removes a not-yet-registered master key when onboarding is cancelled. +/// Idempotent, like `factory_reset`: a missing file is fine. +pub fn delete_key(path: &Path) -> Result<(), String> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("{}: {e}", path.display())), + } +} + +/// Removes every file carrier owns: the store, the key and both sidecars. +/// A missing file is fine — reset is idempotent — but a file that cannot +/// be removed is reported, so a reset that did not take is never silent. +/// The caller must close the store first: Windows refuses to remove a +/// file a live connection holds. +pub fn factory_reset(dir: &Path) -> Result<(), String> { + for name in ["carrier.db", "carrier.key", "read.txt", "drafts.txt"] { + let path = dir.join(name); + if let Err(e) = std::fs::remove_file(&path) { + if e.kind() != std::io::ErrorKind::NotFound { + return Err(format!("{}: {e}", path.display())); + } + } + } + Ok(()) +} + pub fn account(store: &Store, master: &[u8; KEY_LEN]) -> Result { let index = store.rotations().map_err(explain)?; account::Account::new(*master, index).map_err(explain) @@ -301,14 +328,6 @@ pub fn parse_address(text: &str) -> Result { Address::parse(text).map_err(explain) } -/// A base32 public key, the form the operator publishes and pins are shown in. -pub fn parse_key(text: &str) -> Result<[u8; KEY_LEN], String> { - unb32(text.trim()) - .map_err(explain)? - .try_into() - .map_err(|_| "keys are 32 bytes of base32".to_string()) -} - pub fn register( store: &Store, master: &[u8; KEY_LEN], @@ -378,11 +397,12 @@ pub fn fetch( store: &Store, master: &[u8; KEY_LEN], cancel: Arc, + keep: bool, timeout: u64, ) -> Result { let me = account(store, master)?; let opts = FetchOptions { - keep: false, + keep, reset: false, timeout, dial: None, @@ -913,13 +933,6 @@ mod tests { assert!(parse_seed(&hex[..63]).is_err()); } - #[test] - fn key_parsing_round_trips() { - let m = master(); - assert_eq!(parse_key(&b32(&m)).unwrap(), m); - assert!(parse_key("short").is_err()); - } - /// A sealed self-copy in the store surfaces as a row, opens with its /// frontmatter split, and flips to read against the marks set. #[test] diff --git a/src/view.rs b/src/view.rs index 90f059a..0725c25 100644 --- a/src/view.rs +++ b/src/view.rs @@ -19,7 +19,9 @@ use relm4::adw::prelude::*; use relm4::ComponentSender; use relm4::{Component, ComponentParts}; -use crate::app::{Carrier, Dialog, Message, OnboardMode, OpResult, Page, PendingOp}; +use crate::app::{ + Carrier, Dialog, Message, OnboardMode, OpResult, Page, PendingOp, SignupMode, SignupStep, +}; use crate::ops::{self, Contact, Folder, Row}; use relm4::RelmApp; @@ -193,6 +195,7 @@ pub struct CarrierWidgets { mail_list: gtk::ListBox, list_empty: adw::StatusPage, list_empty_fetch: gtk::Button, + list_empty_hint: gtk::Label, article_stack: gtk::Stack, article_subject: gtk::Label, article_meta: gtk::Label, @@ -223,16 +226,29 @@ pub struct CarrierWidgets { offer_row: adw::ActionRow, pins_group: adw::PreferencesGroup, pin_rows: Vec, + keep_mail_row: adw::SwitchRow, timeout_row: adw::SpinRow, backup_label: gtk::Label, // onboarding onboard_seed_label: gtk::Label, + onboard_seed_entry: adw::EntryRow, // signup - signup_back: gtk::Button, - signup_offer_list: gtk::ListBox, - signup_offer_row: adw::ActionRow, + signup_key_button: gtk::Button, + signup_key_card: gtk::Box, + signup_key_value: gtk::Label, + signup_key_fingerprint: gtk::Label, + signup_key_hint: gtk::Label, + signup_start_over: gtk::Button, + signup_step_back: gtk::Button, + signup_mode_new: gtk::ToggleButton, + signup_mode_existing: gtk::ToggleButton, + signup_host: adw::EntryRow, + signup_user: adw::EntryRow, + signup_invite: adw::EntryRow, + register_button: gtk::Button, + recall_button: gtk::Button, // status bar and dialogs spinner: gtk::Spinner, @@ -414,7 +430,18 @@ impl CarrierWidgets { let list_empty = adw::StatusPage::new(); let list_empty_fetch = icon_label_button("mail-send-receive-symbolic", "Fetch", "pill"); list_empty_fetch.set_halign(Align::Center); - list_empty.set_child(Some(&list_empty_fetch)); + let list_empty_hint = label( + "Fetching removes mail from the server once it's stored here.\n\ + Turn on \"Keep mail on the server\" in Settings to keep it there instead.", + "dim caption", + ); + list_empty_hint.set_wrap(true); + list_empty_hint.set_justify(gtk::Justification::Center); + list_empty_hint.set_halign(Align::Center); + let list_empty_box = gtk::Box::new(Orientation::Vertical, 8); + list_empty_box.append(&list_empty_fetch); + list_empty_box.append(&list_empty_hint); + list_empty.set_child(Some(&list_empty_box)); let list_stack = gtk::Stack::new(); list_stack.set_hhomogeneous(false); list_stack.set_vhomogeneous(false); @@ -625,6 +652,14 @@ impl CarrierWidgets { .build(); settings.add(&pins_group); + let fetch_group = adw::PreferencesGroup::builder().title("Fetching").build(); + let keep_mail_row = adw::SwitchRow::builder() + .title("Keep mail on the server") + .subtitle("Off: a fetch removes mail from the server once it's stored here.") + .build(); + fetch_group.add(&keep_mail_row); + settings.add(&fetch_group); + let network_group = adw::PreferencesGroup::builder().title("Network").build(); let timeout_row = adw::SpinRow::with_range(1.0, 600.0, 1.0); timeout_row.set_title("Timeout"); @@ -678,6 +713,19 @@ impl CarrierWidgets { import_group.add(&import_backup); settings.add(&import_group); + // The reset closes the page list: nothing after it survives a + // wipe, so it sits last, marked destructive like the delete. + let reset_group = adw::PreferencesGroup::builder() + .title("Factory reset") + .description( + "Erase the seed key, the sealed store and every sidecar on this device, and return to onboarding. Mail already on the server is not touched.", + ) + .build(); + let reset_button = icon_label_button("user-trash-symbolic", "Factory reset", "danger"); + reset_button.set_valign(Align::Center); + reset_group.set_header_suffix(Some(&reset_button)); + settings.add(&reset_group); + stack.add_named(&settings, Some("settings")); // onboarding: three static modes, each a centred column @@ -700,8 +748,8 @@ impl CarrierWidgets { tagline.set_halign(Align::Center); tagline.set_margin_bottom(12); onboard_start.append(&tagline); - let create_button = button("Create an identity", "pill"); - let restore_button = button("Restore from a seed", "pill-quiet"); + let create_button = button("Create a new identity", "pill"); + let restore_button = button("Restore existing identity from seed", "pill-quiet"); onboard_start.append(&create_button); onboard_start.append(&restore_button); stack.add_named(¢er(&onboard_start), Some("onboard")); @@ -746,46 +794,84 @@ impl CarrierWidgets { onboard_restore.append(&restore_back); stack.add_named(¢er(&onboard_restore), Some("onboard-restore")); - // signup - let signup = gtk::Box::new(Orientation::Vertical, 12); - signup.append(&label("Set up the address", "subtitle")); - let signup_group = adw::PreferencesGroup::builder() - .description( - "The invite token and server key are only needed when the operator published them.", - ) - .build(); - let signup_user = adw::EntryRow::builder().title("User").build(); + // signup: step 1 — the server this address lives on + let signup_server = gtk::Box::new(Orientation::Vertical, 12); + signup_server.append(&label("Set up the server", "subtitle")); + let signup_server_group = adw::PreferencesGroup::new(); let signup_host = adw::EntryRow::builder().title("Host").build(); - let signup_invite = adw::EntryRow::builder().title("Invite token").build(); - let signup_key = adw::EntryRow::builder().title("Server key").build(); - for row in [&signup_user, &signup_host, &signup_invite, &signup_key] { - signup_group.add(row); - } - signup.append(&signup_group); - let signup_learn = button("Learn the server key and pin what it presents", ""); - signup.append(&signup_learn); - let signup_offer_list = boxed_list(); - signup_offer_list.set_visible(false); - let signup_offer_row = adw::ActionRow::builder() - .title("Server key offered") + signup_server_group.add(&signup_host); + signup_server.append(&signup_server_group); + + // Shown once a key has been fetched: the key and its fingerprint, + // prominent, plus the reminder to check it against what the + // operator published. Hidden otherwise. + let signup_key_value = label("", "mono"); + signup_key_value.set_selectable(true); + signup_key_value.set_wrap(true); + signup_key_value.set_margin_top(12); + signup_key_value.set_margin_start(12); + signup_key_value.set_margin_end(12); + let signup_key_fingerprint = label("", "mono dim"); + signup_key_fingerprint.set_selectable(true); + signup_key_fingerprint.set_wrap(true); + signup_key_fingerprint.set_margin_bottom(12); + signup_key_fingerprint.set_margin_start(12); + signup_key_fingerprint.set_margin_end(12); + let signup_key_card = gtk::Box::new(Orientation::Vertical, 4); + signup_key_card.add_css_class("card"); + signup_key_card.set_visible(false); + signup_key_card.append(&signup_key_value); + signup_key_card.append(&signup_key_fingerprint); + signup_server.append(&signup_key_card); + let signup_key_hint = label( + "If the server operator published a key or fingerprint, make \ + sure it matches before trusting it.", + "dim", + ); + signup_key_hint.set_wrap(true); + signup_key_hint.set_visible(false); + signup_server.append(&signup_key_hint); + + let signup_key_button = button("Fetch the server's key", "pill"); + let signup_start_over = button("Start over", "pill-quiet"); + signup_server.append(&signup_key_button); + signup_server.append(&signup_start_over); + stack.add_named(¢er(&signup_server), Some("signup-server")); + + // signup: step 2 — bind a username, or recall an existing address + let signup_account = gtk::Box::new(Orientation::Vertical, 12); + signup_account.append(&label("Choose your address", "subtitle")); + let signup_mode_new = gtk::ToggleButton::builder() + .label("Create a new address") + .active(true) + .hexpand(true) .build(); - signup_offer_row.set_subtitle_lines(2); - let signup_offer_pin = button("Pin", "accent"); - signup_offer_pin.set_valign(Align::Center); - let signup_offer_discard = button("Discard", ""); - signup_offer_discard.set_valign(Align::Center); - signup_offer_row.add_suffix(&signup_offer_discard); - signup_offer_row.add_suffix(&signup_offer_pin); - signup_offer_list.append(&signup_offer_row); - signup.append(&signup_offer_list); + let signup_mode_existing = gtk::ToggleButton::builder() + .label("I already have this address") + .hexpand(true) + .build(); + signup_mode_existing.set_group(Some(&signup_mode_new)); + let signup_mode_box = gtk::Box::new(Orientation::Horizontal, 0); + signup_mode_box.add_css_class("linked"); + signup_mode_box.set_homogeneous(true); + signup_mode_box.append(&signup_mode_new); + signup_mode_box.append(&signup_mode_existing); + signup_account.append(&signup_mode_box); + let signup_account_group = adw::PreferencesGroup::new(); + let signup_user = adw::EntryRow::builder().title("User").build(); + let signup_invite = adw::EntryRow::builder() + .title("Invite token (if required by the server)") + .build(); + signup_account_group.add(&signup_user); + signup_account_group.add(&signup_invite); + signup_account.append(&signup_account_group); let register_button = button("Register", "pill"); - let recall_button = button("I already have this address", "pill-quiet"); - signup.append(®ister_button); - signup.append(&recall_button); - let signup_back = button("Back", "quiet"); - signup_back.set_visible(false); - signup.append(&signup_back); - stack.add_named(¢er(&signup), Some("signup")); + let recall_button = button("Restore access", "pill"); + signup_account.append(®ister_button); + signup_account.append(&recall_button); + let signup_step_back = button("Back", "quiet"); + signup_account.append(&signup_step_back); + stack.add_named(¢er(&signup_account), Some("signup-account")); // --- status bar ------------------------------------------------- // One fixed line at the bottom: the spinner and the running @@ -939,17 +1025,20 @@ impl CarrierWidgets { }); rotate_button.connect_clicked(input(Message::RotatePressed)); + reset_button.connect_clicked(input(Message::ResetPressed)); learn_button.connect_clicked(input(Message::SettingsLearnKey)); offer_pin.connect_clicked(input(Message::PinAccept)); - signup_offer_pin.connect_clicked(input(Message::PinAccept)); offer_discard.connect_clicked(input(Message::PinDiscard)); - signup_offer_discard.connect_clicked(input(Message::PinDiscard)); export_button.connect_clicked(input(Message::ExportPressed)); import_backup.connect_clicked(input(Message::ImportPressed)); let timeout_sender = sender.clone(); timeout_row.connect_value_notify(move |row| { timeout_sender.input(Message::TimeoutChanged((row.value() as u64).to_string())) }); + let keep_mail_sender = sender.clone(); + keep_mail_row.connect_active_notify(move |row| { + keep_mail_sender.input(Message::KeepMailChanged(row.is_active())) + }); let import_body_sender = sender.clone(); import_body.connect_changed(move |buffer| { import_body_sender.input(Message::ImportBodyChanged(buffer_text(buffer))) @@ -982,14 +1071,23 @@ impl CarrierWidgets { signup_invite.connect_changed(move |entry| { invite_sender.input(Message::SignupInviteChanged(entry.text().to_string())) }); - let key_sender = sender.clone(); - signup_key.connect_changed(move |entry| { - key_sender.input(Message::SignupKeyChanged(entry.text().to_string())) - }); - signup_learn.connect_clicked(input(Message::SignupLearnKey)); + signup_key_button.connect_clicked(input(Message::SignupKeyAction)); + signup_start_over.connect_clicked(input(Message::SignupStartOver)); + signup_step_back.connect_clicked(input(Message::SignupStepBack)); register_button.connect_clicked(input(Message::SignupRegister)); recall_button.connect_clicked(input(Message::SignupRecall)); - signup_back.connect_clicked(input(Message::SignupBack)); + let mode_new_sender = sender.clone(); + signup_mode_new.connect_toggled(move |btn| { + if btn.is_active() { + mode_new_sender.input(Message::SignupModeChanged(SignupMode::New)); + } + }); + let mode_existing_sender = sender.clone(); + signup_mode_existing.connect_toggled(move |btn| { + if btn.is_active() { + mode_existing_sender.input(Message::SignupModeChanged(SignupMode::Existing)); + } + }); CarrierWidgets { window: root.clone(), @@ -1005,6 +1103,7 @@ impl CarrierWidgets { mail_list, list_empty, list_empty_fetch, + list_empty_hint, article_stack, article_subject, article_meta, @@ -1027,12 +1126,25 @@ impl CarrierWidgets { offer_row, pins_group, pin_rows: Vec::new(), + keep_mail_row, timeout_row, backup_label, onboard_seed_label, - signup_back, - signup_offer_list, - signup_offer_row, + onboard_seed_entry, + signup_key_button, + signup_key_card, + signup_key_value, + signup_key_fingerprint, + signup_key_hint, + signup_start_over, + signup_step_back, + signup_mode_new, + signup_mode_existing, + signup_host, + signup_user, + signup_invite, + register_button, + recall_button, spinner, status_label, cancel_button, @@ -1062,10 +1174,15 @@ impl Component for Carrier { sender: ComponentSender, ) -> ComponentParts { let (model, op) = Carrier::boot(init); - let widgets = CarrierWidgets::new(&root, &sender); + let mut widgets = CarrierWidgets::new(&root, &sender); if let Some(op) = op { spawn_op(&model, op, &sender); } + // `update_view` runs only after a message, and a clean install + // boots with none queued, so the first paint happens here — + // otherwise the stack sits on its default page until the first + // click flips it to the page the model opened with. + render(&model, &mut widgets, &sender); ComponentParts { model, widgets } } @@ -1098,21 +1215,15 @@ fn spawn_op(model: &Carrier, op: PendingOp, sender: &ComponentSender) { let store = model.store.clone(); let master = model.master.unwrap_or([0u8; KEY_LEN]); let timeout = model.timeout; + let keep_mail = model.keep_mail; match op { PendingOp::Snapshot { read } => sender.spawn_command(move |out| { let _ = out.send(OpResult::SnapshotLoaded(ops::snapshot( &store, &master, &read, ))); }), - PendingOp::Register { addr, invite, key } => sender.spawn_command(move |out| { - let res = if key.is_empty() { - ops::register(&store, &master, &addr, invite.as_deref(), timeout) - } else { - ops::parse_key(&key).and_then(|key| { - ops::pin(&store, &addr, &key)?; - ops::register(&store, &master, &addr, invite.as_deref(), timeout) - }) - }; + PendingOp::Register { addr, invite } => sender.spawn_command(move |out| { + let res = ops::register(&store, &master, &addr, invite.as_deref(), timeout); let _ = out.send(OpResult::Generic(res.map(|_| "registered".to_string()))); }), PendingOp::Restore { addr } => sender.spawn_command(move |out| { @@ -1142,7 +1253,7 @@ fn spawn_op(model: &Carrier, op: PendingOp, sender: &ComponentSender) { }); sender.spawn_command(move |out| { let _ = out.send(OpResult::Generic(ops::fetch( - &store, &master, cancel, timeout, + &store, &master, cancel, keep_mail, timeout, ))); }); } @@ -1321,7 +1432,10 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender "onboard-created", OnboardMode::Restore => "onboard-restore", }, - Page::Signup => "signup", + Page::Signup => match model.signup.step { + SignupStep::Server => "signup-server", + SignupStep::Account => "signup-account", + }, Page::Mail => "mail", Page::Settings => "settings", } @@ -1332,32 +1446,30 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender ( - "mail-read-symbolic", - "Inbox is empty", - "Fetch to check the mailbox.", - ), + Folder::Inbox => (None, "Inbox is empty", "Fetch to check the mailbox."), Folder::Requests => ( - "contact-new-symbolic", + Some("contact-new-symbolic"), "No requests", "Mail from senders you have not accepted yet arrives here.", ), Folder::Sent => ( - "mail-send-symbolic", + Some("mail-send-symbolic"), "Nothing sent yet", "Messages you send keep a sealed copy here.", ), Folder::Drafts => ( - "document-edit-symbolic", + Some("document-edit-symbolic"), "No drafts", "A compose closed from the sidebar is kept here.", ), }; - w.list_empty.set_icon_name(Some(icon)); + w.list_empty.set_icon_name(icon); w.list_empty.set_title(title); w.list_empty.set_description(Some(description)); - w.list_empty_fetch - .set_visible(model.folder == Folder::Inbox); + let inbox_empty = model.folder == Folder::Inbox; + w.list_empty_fetch.set_visible(inbox_empty); + w.list_empty_hint + .set_visible(inbox_empty && !model.keep_mail); w.list_stack .set_visible_child_name(if rows.is_empty() { "empty" } else { "list" }); let mut selected_row = None; @@ -1594,11 +1706,11 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender Some edge: `render` // runs after every message and an alert dialog is modal, so presenting @@ -1650,6 +1809,7 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender { let rotating = matches!(dialog, Dialog::Rotate); let replying = matches!(dialog, Dialog::Reply { .. }); + let resetting = matches!(dialog, Dialog::Reset); let alert = match dialog { Dialog::Delete { kept, .. } => { let alert = adw::AlertDialog::new( @@ -1682,6 +1842,16 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender { + let alert = adw::AlertDialog::new( + Some("Factory reset this device?"), + Some("The seed key, the sealed store, contacts, server pins, drafts and read marks are erased here. Mail already on the server stays there — without the seed it cannot be read back."), + ); + alert.add_response("cancel", "Cancel"); + alert.add_response("reset", "Factory reset"); + alert.set_response_appearance("reset", adw::ResponseAppearance::Destructive); + alert + } Dialog::Reply { rows, .. } => { let list = rows .iter() @@ -1708,15 +1878,17 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender Message::RotateConfirmed, - (true, _, _) => Message::RotateCancelled, - (_, true, "copy") => Message::ReplyConfirmed(true), - (_, true, "without") => Message::ReplyConfirmed(false), - (_, true, _) => Message::ReplyCancelled, - (false, _, "here") => Message::DeleteConfirmed(false), - (false, _, "everywhere") => Message::DeleteConfirmed(true), - (false, _, _) => Message::DeleteCancelled, + response_sender.input(match (rotating, replying, resetting, response) { + (true, _, _, "rotate") => Message::RotateConfirmed, + (true, _, _, _) => Message::RotateCancelled, + (_, true, _, "copy") => Message::ReplyConfirmed(true), + (_, true, _, "without") => Message::ReplyConfirmed(false), + (_, true, _, _) => Message::ReplyCancelled, + (_, _, true, "reset") => Message::ResetConfirmed, + (_, _, true, _) => Message::ResetCancelled, + (false, _, _, "here") => Message::DeleteConfirmed(false), + (false, _, _, "everywhere") => Message::DeleteConfirmed(true), + (false, _, _, _) => Message::DeleteCancelled, }); }); alert.present(Some(&w.window));