feat: add multiple accounts with a sidebar switcher and per-account folders

This commit is contained in:
randogoth 2026-10-14 17:39:35 +03:00
parent 6172df3e00
commit 32e2e2a8f2
6 changed files with 702 additions and 68 deletions

View file

@ -60,6 +60,16 @@ impl ReadMarks {
}
}
/// One account folder, as the switcher lists it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountEntry {
pub id: String,
/// The registered address; None until signup completes.
pub label: Option<String>,
/// Whether the folder holds a master another account can reuse.
pub has_key: bool,
}
/// Onboarding: create a master or restore one from a pasted seed.
pub struct Onboard {
pub mode: OnboardMode,
@ -202,6 +212,16 @@ pub enum Message {
OnboardSeedChanged(String),
OnboardRestoreFromSeed,
OnboardSeedSaved,
/// Copies the master of the named account into this one, so both
/// addresses share a key.
OnboardReuse(String),
// Accounts
AccountSwitch(String),
AccountAdd,
/// Abandons an account that's still being set up and returns to
/// `Carrier::previous`.
AccountAddCancelled,
// Signup
SignupUserChanged(String),
@ -346,7 +366,14 @@ pub enum PendingOp {
}
pub struct Carrier {
/// The config root holding `accounts/` and `active.txt`.
pub root: PathBuf,
/// The active account's id and folder.
pub id: String,
pub dir: PathBuf,
pub accounts: Vec<AccountEntry>,
/// The account to return to when one still being set up is cancelled.
pub previous: Option<String>,
pub store: Arc<Store>,
pub master: Option<[u8; KEY_LEN]>,
pub marks: ReadMarks,
@ -398,7 +425,36 @@ pub struct Carrier {
}
impl Carrier {
pub fn boot(dir: PathBuf) -> (Carrier, Option<PendingOp>) {
/// Opens the account `active.txt` names, else the first one, else a
/// fresh empty one.
pub fn boot(root: PathBuf) -> (Carrier, Option<PendingOp>) {
let id = match ops::load_active(&root)
.or_else(|| ops::list_accounts(&root).into_iter().next())
{
Some(id) => id,
None => match ops::new_account(&root) {
Ok(id) => id,
Err(e) => {
eprintln!("cannot create an account in {}: {e}", root.display());
std::process::exit(1);
}
},
};
Carrier::boot_account(root, id)
}
pub fn boot_account(root: PathBuf, id: String) -> (Carrier, Option<PendingOp>) {
let dir = ops::account_dir(&root, &id);
if let Err(e) = ops::save_active(&root, &id) {
eprintln!("{e}");
}
let accounts = load_accounts(&root);
// An unregistered account next to registered ones is one being
// added, even after a restart, so it always offers a way back.
let previous = accounts
.iter()
.find(|entry| entry.id != id && entry.label.is_some())
.map(|entry| entry.id.clone());
let store = match Store::open(&dir.join("carrier.db")) {
Ok(store) => Arc::new(store),
Err(e) => {
@ -416,9 +472,14 @@ impl Carrier {
};
let drafts = ops::load_drafts(&dir.join("drafts.txt"));
let settings = ops::load_settings(&dir.join("settings.txt"));
let app = Carrier {
let previous = previous.filter(|_| !registered);
let mut app = Carrier {
marks: ReadMarks::load(dir.join("read.txt")),
root,
id,
dir,
accounts,
previous,
store,
master,
page,
@ -459,12 +520,29 @@ impl Carrier {
compose_fresh: Cell::new(false),
form_fresh: Cell::new(true),
};
let op = registered.then(|| PendingOp::Snapshot {
read: app.marks.snapshot(),
});
// Through the busy guard, so a switch can't land while this
// snapshot is still loading the old account's lists.
let op = if registered { app.refresh() } else { None };
(app, op)
}
/// Accounts other than this one whose master a new account can reuse.
pub fn reusable_accounts(&self) -> Vec<&AccountEntry> {
self.accounts
.iter()
.filter(|entry| entry.id != self.id && entry.has_key)
.collect()
}
/// Whether another account already holds this address. Two stores
/// fetching one mailbox would split its mail between them.
fn address_taken(&self, addr: &Address) -> bool {
let short = addr.short();
self.accounts
.iter()
.any(|entry| entry.id != self.id && entry.label.as_deref() == Some(short.as_str()))
}
fn master(&self) -> [u8; KEY_LEN] {
self.master.unwrap_or([0u8; KEY_LEN])
}
@ -506,7 +584,7 @@ impl Carrier {
return;
}
self.drafts.push(Draft {
id: ops::draft_id(),
id: ops::random_id(),
to: compose.to,
subject: compose.subject,
body: compose.body,
@ -536,27 +614,51 @@ 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;
}
/// Deletes the active account's folder, then boots `next`, or
/// whichever account `boot` picks. 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 remove_active(&mut self, next: Option<String>) -> Result<Option<PendingOp>, String> {
self.store = Arc::new(Store::open_in_memory().map_err(|e| e.to_string())?);
ops::remove_account(&self.root, &self.id)?;
let root = self.root.clone();
let (app, op) = match next {
Some(id) => Carrier::boot_account(root, id),
None => Carrier::boot(root),
};
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(i18n::t("logged-out-note"));
Ok(op)
}
/// Signing out removes this account only; the app moves on to another
/// one, or back to onboarding when it was the last.
fn factory_reset(&mut self) -> Option<PendingOp> {
match self.remove_active(None) {
Ok(op) => {
match self.store.account().ok().flatten() {
Some(addr) => self.note(i18n::t1(
"logged-out-switched-note",
"address",
addr.short(),
)),
None => self.note(i18n::t("logged-out-note")),
}
op
}
Err(e) => {
self.fail(e);
None
}
}
}
/// Replaces the model with another account's, keeping nothing of this
/// one but what's already on disk.
fn switch_to(&mut self, id: String) -> Option<PendingOp> {
let (app, op) = Carrier::boot_account(self.root.clone(), id);
*self = app;
op
}
/// Cancelling out of a not-yet-registered identity removes the key
@ -846,6 +948,52 @@ impl Carrier {
}
}
_ if self.busy.is_some() => return Vec::new(),
Message::AccountSwitch(id) => {
if id == self.id || !self.accounts.iter().any(|entry| entry.id == id) {
return Vec::new();
}
self.stash_draft();
return self.switch_to(id).into_iter().collect();
}
Message::AccountAdd => {
self.stash_draft();
match ops::new_account(&self.root) {
Ok(id) => {
let previous = self.id.clone();
self.switch_to(id);
self.previous = Some(previous);
}
Err(e) => self.fail(e),
}
}
Message::AccountAddCancelled => {
let Some(previous) = self.previous.clone() else {
return Vec::new();
};
return match self.remove_active(Some(previous)) {
Ok(op) => op.into_iter().collect(),
Err(e) => {
self.fail(e);
Vec::new()
}
};
}
Message::OnboardReuse(id) => {
if id == self.id {
return Vec::new();
}
let source = ops::account_dir(&self.root, &id).join("carrier.key");
let copied = ops::load_key(&source)
.and_then(|master| ops::save_key(&self.key_path(), &master).map(|()| master));
match copied {
Ok(master) => {
self.master = Some(master);
self.page = Page::Signup;
self.note(i18n::t("seed-reused-note"));
}
Err(e) => self.fail(e),
}
}
Message::OnboardCreate => {
let master = ops::new_master();
if let Err(e) = ops::save_key(&self.key_path(), &master) {
@ -913,6 +1061,14 @@ impl Carrier {
self.fail(i18n::t("signup-enter-user-host-error"));
return Vec::new();
};
if self.address_taken(&addr) {
self.fail(i18n::t1(
"accounts-duplicate-error",
"address",
addr.short(),
));
return Vec::new();
}
let invite = if self.signup.invite.is_empty() {
None
} else {
@ -928,6 +1084,14 @@ impl Carrier {
self.fail(i18n::t("signup-enter-user-host-error"));
return Vec::new();
};
if self.address_taken(&addr) {
self.fail(i18n::t1(
"accounts-duplicate-error",
"address",
addr.short(),
));
return Vec::new();
}
if !self.start("restoring") {
return Vec::new();
}
@ -1351,7 +1515,7 @@ impl Carrier {
}
Message::ResetConfirmed => {
self.dialog = None;
self.factory_reset();
return self.factory_reset().into_iter().collect();
}
Message::RotateConfirmed => {
self.dialog = None;
@ -1394,6 +1558,8 @@ impl Carrier {
// address is bound and it's safe to leave the page.
if self.page == Page::Signup {
self.page = Page::Mail;
self.accounts = load_accounts(&self.root);
self.previous = None;
}
self.refresh()
}
@ -1453,6 +1619,20 @@ impl Carrier {
}
}
fn load_accounts(root: &std::path::Path) -> Vec<AccountEntry> {
ops::list_accounts(root)
.into_iter()
.map(|id| {
let dir = ops::account_dir(root, &id);
AccountEntry {
label: ops::account_label(&dir),
has_key: dir.join("carrier.key").exists(),
id,
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
@ -1463,6 +1643,136 @@ mod tests {
app
}
/// A registered account folder under `root`, with its own master.
fn registered(root: &std::path::Path, address: &str) -> String {
let id = ops::new_account(root).unwrap();
let dir = ops::account_dir(root, &id);
ops::save_key(&dir.join("carrier.key"), &ops::new_master()).unwrap();
let store = Store::open(&dir.join("carrier.db")).unwrap();
store
.set_account(&Address::parse(address).unwrap())
.unwrap();
id
}
/// Two registered accounts, booted into `alice`.
fn two_accounts() -> (Carrier, String, String) {
let root = tempfile::tempdir().unwrap().keep();
let alice = registered(&root, "alice@example.org");
let bob = registered(&root, "bob@example.org");
ops::save_active(&root, &alice).unwrap();
let (app, _) = Carrier::boot(root);
(app, alice, bob)
}
#[test]
fn boot_opens_the_active_account_through_the_busy_guard() {
let (app, alice, _) = two_accounts();
assert_eq!(app.id, alice);
assert_eq!(app.page, Page::Mail);
assert_eq!(app.accounts.len(), 2);
assert!(app.previous.is_none());
// The boot snapshot holds the guard, so no switch can overtake it.
assert_eq!(app.busy.as_deref(), Some("refreshing"));
}
#[test]
fn switching_keeps_each_accounts_drafts_and_settings_apart() {
let (mut app, alice, bob) = two_accounts();
app.busy = None;
app.handle(Message::StrictPinningChanged(true));
app.handle(Message::ComposePressed);
app.handle(Message::ComposeSubjectChanged("half written".into()));
let ops = app.handle(Message::AccountSwitch(bob.clone()));
assert!(matches!(ops.as_slice(), [PendingOp::Snapshot { .. }]));
assert_eq!(app.id, bob);
assert_eq!(ops::load_active(&app.root).as_deref(), Some(bob.as_str()));
assert!(app.compose.is_none());
assert!(app.drafts.is_empty());
assert!(!app.strict_pinning);
app.busy = None;
app.handle(Message::AccountSwitch(alice.clone()));
assert_eq!(app.drafts.len(), 1);
assert_eq!(app.drafts[0].subject, "half written");
assert!(app.strict_pinning);
}
#[test]
fn switching_waits_for_the_running_operation() {
let (mut app, alice, bob) = two_accounts();
app.busy = Some("fetching".into());
assert!(app.handle(Message::AccountSwitch(bob)).is_empty());
assert_eq!(app.id, alice);
}
#[test]
fn adding_an_account_can_reuse_a_secret_or_be_cancelled() {
let (mut app, alice, _) = two_accounts();
app.busy = None;
app.handle(Message::AccountAdd);
assert_eq!(app.page, Page::Onboard);
assert_eq!(app.previous.as_deref(), Some(alice.as_str()));
assert_eq!(app.accounts.len(), 3);
assert_eq!(app.reusable_accounts().len(), 2);
let added = app.dir.clone();
app.handle(Message::OnboardReuse(alice.clone()));
assert_eq!(app.page, Page::Signup);
let alice_master = ops::load_key(&ops::account_dir(&app.root, &alice).join("carrier.key"));
assert_eq!(app.master, alice_master.ok());
assert_eq!(ops::load_key(&added.join("carrier.key")).ok(), app.master);
app.handle(Message::AccountAddCancelled);
assert!(!added.exists());
assert_eq!(app.id, alice);
assert_eq!(app.page, Page::Mail);
assert_eq!(app.accounts.len(), 2);
}
/// Quitting halfway through adding an account must not strand the
/// user in onboarding with no way back.
#[test]
fn an_unfinished_account_offers_a_way_back_after_a_restart() {
let (mut app, alice, _) = two_accounts();
app.busy = None;
app.handle(Message::AccountAdd);
let (rebooted, _) = Carrier::boot(app.root.clone());
assert_eq!(rebooted.page, Page::Onboard);
assert_eq!(rebooted.previous.as_deref(), Some(alice.as_str()));
}
#[test]
fn an_address_another_account_holds_is_refused() {
let (mut app, _, _) = two_accounts();
app.busy = None;
app.handle(Message::AccountAdd);
app.handle(Message::OnboardCreate);
app.handle(Message::OnboardSeedSaved);
app.signup.user = "bob".into();
app.signup.host = "example.org".into();
assert!(app.handle(Message::SignupRegister).is_empty());
assert!(app.status_error);
assert!(app.handle(Message::SignupRecall).is_empty());
assert!(app.busy.is_none());
}
#[test]
fn signing_out_of_one_account_moves_to_the_other() {
let (mut app, alice, bob) = two_accounts();
app.busy = None;
let alice_dir = app.dir.clone();
app.handle(Message::ResetPressed);
let ops = app.handle(Message::ResetConfirmed);
assert!(matches!(ops.as_slice(), [PendingOp::Snapshot { .. }]));
assert!(!alice_dir.exists());
assert_eq!(app.id, bob);
assert_eq!(app.page, Page::Mail);
assert!(app.accounts.iter().all(|entry| entry.id != alice));
assert!(app.dir.join("carrier.key").exists());
}
/// Results clear the busy flag instead of deadlocking: this is the
/// regression test for the guard bug the Iced version shipped with.
#[test]
@ -1753,7 +2063,7 @@ mod tests {
app.handle(Message::StrictPinningChanged(true));
app.handle(Message::KeepMailChanged(true));
app.handle(Message::TimeoutChanged("90".into()));
assert!(dir.join("settings.txt").exists());
assert!(app.dir.join("settings.txt").exists());
let (rebooted, _) = Carrier::boot(dir);
assert!(rebooted.strict_pinning);
@ -1795,12 +2105,13 @@ mod tests {
#[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 (mut app, _) = Carrier::boot(dir);
let account = app.dir.clone();
let master = ops::new_master();
ops::save_key(&dir.join("carrier.key"), &master).unwrap();
ops::save_key(&account.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();
std::fs::write(account.join("read.txt"), "ab").unwrap();
std::fs::write(account.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();
app.handle(Message::StrictPinningChanged(true));
@ -1828,14 +2139,15 @@ mod tests {
assert!(app.signup.user.is_empty());
assert!(app.form_fresh.get());
for name in ["carrier.key", "read.txt", "drafts.txt", "settings.txt"] {
assert!(!dir.join(name).exists(), "{name} survived the reset");
assert!(!account.join(name).exists(), "{name} survived the reset");
}
// The last account's folder goes too; boot starts a fresh one.
assert!(!account.exists());
assert_ne!(app.dir, account);
// Preferences go with everything else, so the rebooted model is
// back to its defaults rather than the logged-out user's.
assert!(!app.strict_pinning);
// 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.dir.join("carrier.db").exists());
assert!(app.store.account().unwrap().is_none());
}
@ -1848,11 +2160,11 @@ mod tests {
let (mut app, _) = Carrier::boot(dir.clone());
app.handle(Message::OnboardCreate);
assert!(app.master.is_some());
assert!(dir.join("carrier.key").exists());
assert!(app.dir.join("carrier.key").exists());
app.handle(Message::OnboardBack);
assert!(app.master.is_none());
assert!(!dir.join("carrier.key").exists());
assert!(!app.dir.join("carrier.key").exists());
assert_eq!(app.page, Page::Onboard);
assert_eq!(app.onboard.mode, OnboardMode::Start);
}
@ -1883,7 +2195,7 @@ mod tests {
app.handle(Message::SignupStartOver);
assert!(app.master.is_none());
assert!(!dir.join("carrier.key").exists());
assert!(!app.dir.join("carrier.key").exists());
assert_eq!(app.page, Page::Onboard);
assert!(app.signup.host.is_empty());
}