feat: add multiple accounts with a sidebar switcher and per-account folders
This commit is contained in:
parent
6172df3e00
commit
32e2e2a8f2
6 changed files with 702 additions and 68 deletions
|
|
@ -99,11 +99,13 @@ draft-saved-note = Draft saved
|
||||||
## Log out
|
## Log out
|
||||||
|
|
||||||
logged-out-note = Signed out. Create a new identity or restore a seed.
|
logged-out-note = Signed out. Create a new identity or restore a seed.
|
||||||
|
logged-out-switched-note = Signed out. You're now using { $address }.
|
||||||
|
|
||||||
## Onboarding
|
## Onboarding
|
||||||
|
|
||||||
identity-created-note = Identity created. Write the seed down. It's the only secret.
|
identity-created-note = Identity created. Write the seed down. It's the only secret.
|
||||||
seed-restored-note = Seed restored. Bind a username or recall an address.
|
seed-restored-note = Seed restored. Bind a username or recall an address.
|
||||||
|
seed-reused-note = Secret copied. Bind a username or recall an address.
|
||||||
|
|
||||||
## Signup
|
## Signup
|
||||||
|
|
||||||
|
|
@ -350,8 +352,8 @@ advanced-first-contact-description = A recipient who isn't a contact yet, and wh
|
||||||
advanced-strict-pinning-title = Require a pinned server
|
advanced-strict-pinning-title = Require a pinned server
|
||||||
advanced-strict-pinning-subtitle = Stop that send and offer to pin the server's key first, so you can check it against what the operator published. With this off, the key's taken on trust, and the send is only reported as unverified afterwards.
|
advanced-strict-pinning-subtitle = Stop that send and offer to pin the server's key first, so you can check it against what the operator published. With this off, the key's taken on trust, and the send is only reported as unverified afterwards.
|
||||||
advanced-logout-title = Sign out
|
advanced-logout-title = Sign out
|
||||||
advanced-logout-description = Removes this identity from this app entirely: the secret, pins, contacts, and every cached message. Without a backup of the secret, you can't get back in. Mail already fetched here may have no other copy anywhere, unless "Leave mail on server" was on. Whatever's still on the server comes back on the first fetch after you sign back in.
|
advanced-logout-description = Removes this account from this app: the secret, pins, contacts, and every cached message. Other accounts stay as they are. Without a backup of the secret, you can't get back in. Mail already fetched here may have no other copy anywhere, unless "Leave mail on server" was on. Whatever's still on the server comes back on the first fetch after you sign back in.
|
||||||
advanced-logout-button = Sign out and delete all data
|
advanced-logout-button = Sign out and delete this account's data
|
||||||
|
|
||||||
## Onboarding
|
## Onboarding
|
||||||
##
|
##
|
||||||
|
|
@ -359,7 +361,7 @@ advanced-logout-button = Sign out and delete all data
|
||||||
## is left as a literal in code — see the extraction report.
|
## is left as a literal in code — see the extraction report.
|
||||||
|
|
||||||
onboard-welcome-title = welcome
|
onboard-welcome-title = welcome
|
||||||
onboard-welcome-description = Encrypted mail with no account and no password. Your identity is one secret, kept in this browser and nowhere else.
|
onboard-welcome-description = Encrypted mail with no password. Each identity is one secret, stored only on this device.
|
||||||
onboard-tagline = smolmail: one seed, sealed mail, five operations
|
onboard-tagline = smolmail: one seed, sealed mail, five operations
|
||||||
onboard-create-button = Create a new identity
|
onboard-create-button = Create a new identity
|
||||||
onboard-restore-button = Restore an existing identity from a seed
|
onboard-restore-button = Restore an existing identity from a seed
|
||||||
|
|
@ -371,6 +373,16 @@ onboard-restore-description = Paste the secret you saved. Your keys come back fr
|
||||||
onboard-seed-entry-title = Seed: 32 bytes, base32 or hex
|
onboard-seed-entry-title = Seed: 32 bytes, base32 or hex
|
||||||
onboard-restore-go-button = Restore
|
onboard-restore-go-button = Restore
|
||||||
|
|
||||||
|
## Accounts — the switcher on the sidebar identity row, and adding one
|
||||||
|
|
||||||
|
accounts-menu-tooltip = Switch account
|
||||||
|
accounts-add-button = Add account
|
||||||
|
# Shown when signup is about to bind an address another account in this app already uses.
|
||||||
|
accounts-duplicate-error = { $address } is already in carrier. Switch to it from the account menu.
|
||||||
|
accounts-add-cancel-button = Cancel adding account
|
||||||
|
onboard-reuse-title = Use the secret from another account
|
||||||
|
onboard-reuse-description = Both addresses will share one public key, so anyone who sees both can tell they belong together.
|
||||||
|
|
||||||
## Signup
|
## Signup
|
||||||
|
|
||||||
signup-server-title = Set up the server
|
signup-server-title = Set up the server
|
||||||
|
|
@ -401,7 +413,7 @@ delete-dialog-body-gone = This removes the only copy, which is sealed and stored
|
||||||
delete-dialog-everywhere-button = Delete everywhere
|
delete-dialog-everywhere-button = Delete everywhere
|
||||||
rotate-dialog-title = Rotate the identity?
|
rotate-dialog-title = Rotate the identity?
|
||||||
rotate-dialog-confirm-button = Rotate
|
rotate-dialog-confirm-button = Rotate
|
||||||
reset-dialog-title = Sign out and delete all data?
|
reset-dialog-title = Sign out and delete this account's data?
|
||||||
reset-dialog-confirm-button = Sign Out
|
reset-dialog-confirm-button = Sign Out
|
||||||
# `$list` is the sender's own frontmatter rows, already typed by a human
|
# `$list` is the sender's own frontmatter rows, already typed by a human
|
||||||
# and reproduced verbatim — not translatable prose itself, just data
|
# and reproduced verbatim — not translatable prose itself, just data
|
||||||
|
|
|
||||||
388
src/app.rs
388
src/app.rs
|
|
@ -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.
|
/// Onboarding: create a master or restore one from a pasted seed.
|
||||||
pub struct Onboard {
|
pub struct Onboard {
|
||||||
pub mode: OnboardMode,
|
pub mode: OnboardMode,
|
||||||
|
|
@ -202,6 +212,16 @@ pub enum Message {
|
||||||
OnboardSeedChanged(String),
|
OnboardSeedChanged(String),
|
||||||
OnboardRestoreFromSeed,
|
OnboardRestoreFromSeed,
|
||||||
OnboardSeedSaved,
|
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
|
// Signup
|
||||||
SignupUserChanged(String),
|
SignupUserChanged(String),
|
||||||
|
|
@ -346,7 +366,14 @@ pub enum PendingOp {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Carrier {
|
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 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 store: Arc<Store>,
|
||||||
pub master: Option<[u8; KEY_LEN]>,
|
pub master: Option<[u8; KEY_LEN]>,
|
||||||
pub marks: ReadMarks,
|
pub marks: ReadMarks,
|
||||||
|
|
@ -398,7 +425,36 @@ pub struct Carrier {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl 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")) {
|
let store = match Store::open(&dir.join("carrier.db")) {
|
||||||
Ok(store) => Arc::new(store),
|
Ok(store) => Arc::new(store),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -416,9 +472,14 @@ impl Carrier {
|
||||||
};
|
};
|
||||||
let drafts = ops::load_drafts(&dir.join("drafts.txt"));
|
let drafts = ops::load_drafts(&dir.join("drafts.txt"));
|
||||||
let settings = ops::load_settings(&dir.join("settings.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")),
|
marks: ReadMarks::load(dir.join("read.txt")),
|
||||||
|
root,
|
||||||
|
id,
|
||||||
dir,
|
dir,
|
||||||
|
accounts,
|
||||||
|
previous,
|
||||||
store,
|
store,
|
||||||
master,
|
master,
|
||||||
page,
|
page,
|
||||||
|
|
@ -459,12 +520,29 @@ impl Carrier {
|
||||||
compose_fresh: Cell::new(false),
|
compose_fresh: Cell::new(false),
|
||||||
form_fresh: Cell::new(true),
|
form_fresh: Cell::new(true),
|
||||||
};
|
};
|
||||||
let op = registered.then(|| PendingOp::Snapshot {
|
// Through the busy guard, so a switch can't land while this
|
||||||
read: app.marks.snapshot(),
|
// snapshot is still loading the old account's lists.
|
||||||
});
|
let op = if registered { app.refresh() } else { None };
|
||||||
(app, op)
|
(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] {
|
fn master(&self) -> [u8; KEY_LEN] {
|
||||||
self.master.unwrap_or([0u8; KEY_LEN])
|
self.master.unwrap_or([0u8; KEY_LEN])
|
||||||
}
|
}
|
||||||
|
|
@ -506,7 +584,7 @@ impl Carrier {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.drafts.push(Draft {
|
self.drafts.push(Draft {
|
||||||
id: ops::draft_id(),
|
id: ops::random_id(),
|
||||||
to: compose.to,
|
to: compose.to,
|
||||||
subject: compose.subject,
|
subject: compose.subject,
|
||||||
body: compose.body,
|
body: compose.body,
|
||||||
|
|
@ -536,27 +614,51 @@ impl Carrier {
|
||||||
self.compose_fresh.set(true);
|
self.compose_fresh.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Factory reset: close the store, erase the config directory, then
|
/// Deletes the active account's folder, then boots `next`, or
|
||||||
/// boot a fresh model in place. The connection is swapped out before
|
/// whichever account `boot` picks. The connection is swapped out
|
||||||
/// the files go because Windows refuses to remove a file a live
|
/// before the files go because Windows refuses to remove a file a
|
||||||
/// connection holds, and the busy guard has already seen to it that
|
/// live connection holds, and the busy guard has already seen to it
|
||||||
/// no operation holds a clone of the store.
|
/// that no operation holds a clone of the store.
|
||||||
fn factory_reset(&mut self) {
|
fn remove_active(&mut self, next: Option<String>) -> Result<Option<PendingOp>, String> {
|
||||||
let store = match Store::open_in_memory() {
|
self.store = Arc::new(Store::open_in_memory().map_err(|e| e.to_string())?);
|
||||||
Ok(store) => Arc::new(store),
|
ops::remove_account(&self.root, &self.id)?;
|
||||||
Err(e) => {
|
let root = self.root.clone();
|
||||||
self.fail(e.to_string());
|
let (app, op) = match next {
|
||||||
return;
|
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 = 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
|
/// 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(),
|
_ 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 => {
|
Message::OnboardCreate => {
|
||||||
let master = ops::new_master();
|
let master = ops::new_master();
|
||||||
if let Err(e) = ops::save_key(&self.key_path(), &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"));
|
self.fail(i18n::t("signup-enter-user-host-error"));
|
||||||
return Vec::new();
|
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() {
|
let invite = if self.signup.invite.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -928,6 +1084,14 @@ impl Carrier {
|
||||||
self.fail(i18n::t("signup-enter-user-host-error"));
|
self.fail(i18n::t("signup-enter-user-host-error"));
|
||||||
return Vec::new();
|
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") {
|
if !self.start("restoring") {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
@ -1351,7 +1515,7 @@ impl Carrier {
|
||||||
}
|
}
|
||||||
Message::ResetConfirmed => {
|
Message::ResetConfirmed => {
|
||||||
self.dialog = None;
|
self.dialog = None;
|
||||||
self.factory_reset();
|
return self.factory_reset().into_iter().collect();
|
||||||
}
|
}
|
||||||
Message::RotateConfirmed => {
|
Message::RotateConfirmed => {
|
||||||
self.dialog = None;
|
self.dialog = None;
|
||||||
|
|
@ -1394,6 +1558,8 @@ impl Carrier {
|
||||||
// address is bound and it's safe to leave the page.
|
// address is bound and it's safe to leave the page.
|
||||||
if self.page == Page::Signup {
|
if self.page == Page::Signup {
|
||||||
self.page = Page::Mail;
|
self.page = Page::Mail;
|
||||||
|
self.accounts = load_accounts(&self.root);
|
||||||
|
self.previous = None;
|
||||||
}
|
}
|
||||||
self.refresh()
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -1463,6 +1643,136 @@ mod tests {
|
||||||
app
|
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
|
/// Results clear the busy flag instead of deadlocking: this is the
|
||||||
/// regression test for the guard bug the Iced version shipped with.
|
/// regression test for the guard bug the Iced version shipped with.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1753,7 +2063,7 @@ mod tests {
|
||||||
app.handle(Message::StrictPinningChanged(true));
|
app.handle(Message::StrictPinningChanged(true));
|
||||||
app.handle(Message::KeepMailChanged(true));
|
app.handle(Message::KeepMailChanged(true));
|
||||||
app.handle(Message::TimeoutChanged("90".into()));
|
app.handle(Message::TimeoutChanged("90".into()));
|
||||||
assert!(dir.join("settings.txt").exists());
|
assert!(app.dir.join("settings.txt").exists());
|
||||||
|
|
||||||
let (rebooted, _) = Carrier::boot(dir);
|
let (rebooted, _) = Carrier::boot(dir);
|
||||||
assert!(rebooted.strict_pinning);
|
assert!(rebooted.strict_pinning);
|
||||||
|
|
@ -1795,12 +2105,13 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn factory_reset_erases_the_files_and_returns_to_onboarding() {
|
fn factory_reset_erases_the_files_and_returns_to_onboarding() {
|
||||||
let dir = tempfile::tempdir().unwrap().keep();
|
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();
|
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);
|
app.master = Some(master);
|
||||||
std::fs::write(dir.join("read.txt"), "ab").unwrap();
|
std::fs::write(account.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("drafts.txt"), "draft2 id 1 -\n\n\n\n").unwrap();
|
||||||
app.signup.host = "typed-before-the-reset.example".into();
|
app.signup.host = "typed-before-the-reset.example".into();
|
||||||
app.signup.user = "alice".into();
|
app.signup.user = "alice".into();
|
||||||
app.handle(Message::StrictPinningChanged(true));
|
app.handle(Message::StrictPinningChanged(true));
|
||||||
|
|
@ -1828,14 +2139,15 @@ mod tests {
|
||||||
assert!(app.signup.user.is_empty());
|
assert!(app.signup.user.is_empty());
|
||||||
assert!(app.form_fresh.get());
|
assert!(app.form_fresh.get());
|
||||||
for name in ["carrier.key", "read.txt", "drafts.txt", "settings.txt"] {
|
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
|
// Preferences go with everything else, so the rebooted model is
|
||||||
// back to its defaults rather than the logged-out user's.
|
// back to its defaults rather than the logged-out user's.
|
||||||
assert!(!app.strict_pinning);
|
assert!(!app.strict_pinning);
|
||||||
// The store is reopened by boot, so a fresh empty db is the one
|
assert!(app.dir.join("carrier.db").exists());
|
||||||
// file a reset legitimately leaves behind.
|
|
||||||
assert!(dir.join("carrier.db").exists());
|
|
||||||
assert!(app.store.account().unwrap().is_none());
|
assert!(app.store.account().unwrap().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1848,11 +2160,11 @@ mod tests {
|
||||||
let (mut app, _) = Carrier::boot(dir.clone());
|
let (mut app, _) = Carrier::boot(dir.clone());
|
||||||
app.handle(Message::OnboardCreate);
|
app.handle(Message::OnboardCreate);
|
||||||
assert!(app.master.is_some());
|
assert!(app.master.is_some());
|
||||||
assert!(dir.join("carrier.key").exists());
|
assert!(app.dir.join("carrier.key").exists());
|
||||||
|
|
||||||
app.handle(Message::OnboardBack);
|
app.handle(Message::OnboardBack);
|
||||||
assert!(app.master.is_none());
|
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.page, Page::Onboard);
|
||||||
assert_eq!(app.onboard.mode, OnboardMode::Start);
|
assert_eq!(app.onboard.mode, OnboardMode::Start);
|
||||||
}
|
}
|
||||||
|
|
@ -1883,7 +2195,7 @@ mod tests {
|
||||||
|
|
||||||
app.handle(Message::SignupStartOver);
|
app.handle(Message::SignupStartOver);
|
||||||
assert!(app.master.is_none());
|
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.page, Page::Onboard);
|
||||||
assert!(app.signup.host.is_empty());
|
assert!(app.signup.host.is_empty());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
52
src/demo.rs
52
src/demo.rs
|
|
@ -1,7 +1,8 @@
|
||||||
//! Sample data for layout work, compiled into dev builds only. The dev
|
//! Sample data for layout work, compiled into dev builds only. The dev
|
||||||
//! build uses its own `carrier-demo` profile directory; seeding runs once
|
//! build uses its own `carrier-demo` profile directory; seeding runs once
|
||||||
//! when it has no key yet, so read marks survive across refinement
|
//! when it has no accounts yet, so read marks survive across refinement
|
||||||
//! sessions and the release profile's real account is never touched.
|
//! sessions and the release profile's real account is never touched.
|
||||||
|
//! Two accounts are planted so the account switcher has something to show.
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
|
@ -22,15 +23,58 @@ fn identity(tag: u8) -> Identity {
|
||||||
Identity::from_seed([tag; KEY_LEN])
|
Identity::from_seed([tag; KEY_LEN])
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn seed(dir: &Path) {
|
pub fn seed(root: &Path) {
|
||||||
if dir.join("carrier.key").exists() {
|
if !crate::ops::list_accounts(root).is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Err(e) = plant(dir) {
|
let planted = (|| {
|
||||||
|
for id in ["demo", "demo2"] {
|
||||||
|
let dir = crate::ops::account_dir(root, id);
|
||||||
|
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
plant(&crate::ops::account_dir(root, "demo"))?;
|
||||||
|
plant_second(&crate::ops::account_dir(root, "demo2"))?;
|
||||||
|
crate::ops::save_active(root, "demo")
|
||||||
|
})();
|
||||||
|
if let Err(e) = planted {
|
||||||
eprintln!("demo seed failed: {e}");
|
eprintln!("demo seed failed: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A sparse second account with its own master, so switching shows a
|
||||||
|
/// visibly different inbox.
|
||||||
|
fn plant_second(dir: &Path) -> Result<(), String> {
|
||||||
|
let master: [u8; KEY_LEN] = core::array::from_fn(|i| i as u8 + 101);
|
||||||
|
crate::ops::save_key(&dir.join("carrier.key"), &master)?;
|
||||||
|
let store = Store::open(&dir.join("carrier.db")).map_err(|e| e.to_string())?;
|
||||||
|
let me = Account::new(master, 0).map_err(|e| e.to_string())?;
|
||||||
|
let addr = Address::parse("demo2@carrier.local").map_err(|e| e.to_string())?;
|
||||||
|
store.set_account(&addr).map_err(|e| e.to_string())?;
|
||||||
|
store
|
||||||
|
.pin_server(&addr.host, addr.port, &identity(42).pk())
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let sender = identity(11);
|
||||||
|
let uri = Address::parse("alice@example.org")
|
||||||
|
.unwrap()
|
||||||
|
.uri(&sender.pk());
|
||||||
|
let text = build_frontmatter(
|
||||||
|
&[("Subject", "your second address works"), ("Reply-To", &uri)],
|
||||||
|
"This inbox belongs to the second demo account.",
|
||||||
|
);
|
||||||
|
let envelope = seal(&sender, &me.me().pk(), text.as_bytes(), now() - 3600, true)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
store
|
||||||
|
.store_inbox(
|
||||||
|
&message_id(&envelope),
|
||||||
|
&envelope,
|
||||||
|
now() - 3600,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn plant(dir: &Path) -> Result<(), String> {
|
fn plant(dir: &Path) -> Result<(), String> {
|
||||||
let master = demo_master();
|
let master = demo_master();
|
||||||
crate::ops::save_key(&dir.join("carrier.key"), &master)?;
|
crate::ops::save_key(&dir.join("carrier.key"), &master)?;
|
||||||
|
|
|
||||||
11
src/main.rs
11
src/main.rs
|
|
@ -1,7 +1,8 @@
|
||||||
//! carrier — a minimalist smolmail client built on fumi-core, with a
|
//! carrier — a minimalist smolmail client built on fumi-core, with a
|
||||||
//! Relm4 UI. State lives in the config directory: `carrier.key` (the
|
//! Relm4 UI. Each account lives in its own `accounts/<id>/` folder under
|
||||||
//! master, 0600), `carrier.db` (fumi-core's sealed store) and `read.txt`
|
//! the config directory: `carrier.key` (the master, 0600), `carrier.db`
|
||||||
//! (read marks, carrier's own sidecar).
|
//! (fumi-core's sealed store) and carrier's own sidecars (read marks,
|
||||||
|
//! drafts, settings). `active.txt` names the account the app opens.
|
||||||
|
|
||||||
// No console window when the Windows release build is double-clicked.
|
// No console window when the Windows release build is double-clicked.
|
||||||
// Debug builds keep it for eprintln output.
|
// Debug builds keep it for eprintln output.
|
||||||
|
|
@ -22,6 +23,10 @@ fn main() {
|
||||||
eprintln!("cannot create {}: {e}", dir.display());
|
eprintln!("cannot create {}: {e}", dir.display());
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
if let Err(e) = ops::migrate_legacy(&dir) {
|
||||||
|
eprintln!("cannot move the account into {}: {e}", dir.display());
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
demo::seed(&dir);
|
demo::seed(&dir);
|
||||||
view::run(dir);
|
view::run(dir);
|
||||||
|
|
|
||||||
172
src/ops.rs
172
src/ops.rs
|
|
@ -3,7 +3,7 @@
|
||||||
//! values these functions return.
|
//! values these functions return.
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
|
@ -138,8 +138,8 @@ pub struct Draft {
|
||||||
pub at: i64,
|
pub at: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A fresh draft id: 8 random bytes as hex, unique within the sidecar.
|
/// A fresh id for a draft or an account folder: 8 random bytes as hex.
|
||||||
pub fn draft_id() -> String {
|
pub fn random_id() -> String {
|
||||||
use rand_core::RngCore;
|
use rand_core::RngCore;
|
||||||
let mut bytes = [0u8; 8];
|
let mut bytes = [0u8; 8];
|
||||||
rand_core::OsRng.fill_bytes(&mut bytes);
|
rand_core::OsRng.fill_bytes(&mut bytes);
|
||||||
|
|
@ -399,19 +399,13 @@ pub fn delete_key(path: &Path) -> Result<(), String> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes every file carrier owns: the store, the key and both sidecars.
|
/// Removes every file an account owns: the store, the key and the sidecars.
|
||||||
/// A missing file is fine — reset is idempotent — but a file that cannot
|
/// 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.
|
/// 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
|
/// The caller must close the store first: Windows refuses to remove a
|
||||||
/// file a live connection holds.
|
/// file a live connection holds.
|
||||||
pub fn factory_reset(dir: &Path) -> Result<(), String> {
|
pub fn factory_reset(dir: &Path) -> Result<(), String> {
|
||||||
for name in [
|
for name in ACCOUNT_FILES {
|
||||||
"carrier.db",
|
|
||||||
"carrier.key",
|
|
||||||
"read.txt",
|
|
||||||
"drafts.txt",
|
|
||||||
"settings.txt",
|
|
||||||
] {
|
|
||||||
let path = dir.join(name);
|
let path = dir.join(name);
|
||||||
if let Err(e) = std::fs::remove_file(&path) {
|
if let Err(e) = std::fs::remove_file(&path) {
|
||||||
if e.kind() != std::io::ErrorKind::NotFound {
|
if e.kind() != std::io::ErrorKind::NotFound {
|
||||||
|
|
@ -425,6 +419,113 @@ pub fn factory_reset(dir: &Path) -> Result<(), String> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The files one account owns. The WAL pair is listed so a migration never
|
||||||
|
/// strands commits a crash left uncheckpointed.
|
||||||
|
const ACCOUNT_FILES: [&str; 7] = [
|
||||||
|
"carrier.db",
|
||||||
|
"carrier.db-wal",
|
||||||
|
"carrier.db-shm",
|
||||||
|
"carrier.key",
|
||||||
|
"read.txt",
|
||||||
|
"drafts.txt",
|
||||||
|
"settings.txt",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn accounts_dir(root: &Path) -> PathBuf {
|
||||||
|
root.join("accounts")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn account_dir(root: &Path, id: &str) -> PathBuf {
|
||||||
|
accounts_dir(root).join(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves a single-account config root into `accounts/<id>/` and makes it
|
||||||
|
/// the active account. Runs before anything opens the files; a root that
|
||||||
|
/// already has `accounts/`, or holds none of the files, is left alone.
|
||||||
|
pub fn migrate_legacy(root: &Path) -> Result<(), String> {
|
||||||
|
if accounts_dir(root).exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let present: Vec<&str> = ACCOUNT_FILES
|
||||||
|
.into_iter()
|
||||||
|
.filter(|name| root.join(name).exists())
|
||||||
|
.collect();
|
||||||
|
if present.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let id = random_id();
|
||||||
|
let dir = account_dir(root, &id);
|
||||||
|
std::fs::create_dir_all(&dir)
|
||||||
|
.map_err(|e| i18n::t_args("file-error", Some(&file_error_args(&dir, &e))))?;
|
||||||
|
for name in present {
|
||||||
|
let from = root.join(name);
|
||||||
|
std::fs::rename(&from, dir.join(name))
|
||||||
|
.map_err(|e| i18n::t_args("file-error", Some(&file_error_args(&from, &e))))?;
|
||||||
|
}
|
||||||
|
save_active(root, &id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Account folder names, sorted so the switcher's order is stable.
|
||||||
|
pub fn list_accounts(root: &Path) -> Vec<String> {
|
||||||
|
let mut ids: Vec<String> = std::fs::read_dir(accounts_dir(root))
|
||||||
|
.map(|entries| {
|
||||||
|
entries
|
||||||
|
.flatten()
|
||||||
|
.filter(|entry| entry.path().is_dir())
|
||||||
|
.filter_map(|entry| entry.file_name().into_string().ok())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
ids.sort();
|
||||||
|
ids
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The active account's id, if `active.txt` names a folder that exists.
|
||||||
|
pub fn load_active(root: &Path) -> Option<String> {
|
||||||
|
let id = std::fs::read_to_string(root.join("active.txt")).ok()?;
|
||||||
|
let id = id.trim().to_string();
|
||||||
|
(!id.is_empty() && account_dir(root, &id).is_dir()).then_some(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_active(root: &Path, id: &str) -> Result<(), String> {
|
||||||
|
let path = root.join("active.txt");
|
||||||
|
std::fs::write(&path, format!("{id}\n"))
|
||||||
|
.map_err(|e| i18n::t_args("file-error", Some(&file_error_args(&path, &e))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates an empty account folder and returns its id.
|
||||||
|
pub fn new_account(root: &Path) -> Result<String, String> {
|
||||||
|
let id = random_id();
|
||||||
|
let dir = account_dir(root, &id);
|
||||||
|
std::fs::create_dir_all(&dir)
|
||||||
|
.map_err(|e| i18n::t_args("file-error", Some(&file_error_args(&dir, &e))))?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes an account's files, then its folder. Same contract as
|
||||||
|
/// `factory_reset`: the caller must close the account's store first.
|
||||||
|
pub fn remove_account(root: &Path, id: &str) -> Result<(), String> {
|
||||||
|
let dir = account_dir(root, id);
|
||||||
|
factory_reset(&dir)?;
|
||||||
|
match std::fs::remove_dir(&dir) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(i18n::t_args("file-error", Some(&file_error_args(&dir, &e)))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The address an account folder is registered under, read from its own
|
||||||
|
/// store; None for a folder that isn't registered or can't be opened.
|
||||||
|
pub fn account_label(dir: &Path) -> Option<String> {
|
||||||
|
// Opening a missing database would create one.
|
||||||
|
let path = dir.join("carrier.db");
|
||||||
|
if !path.exists() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let store = Store::open(&path).ok()?;
|
||||||
|
store.account().ok().flatten().map(|addr| addr.short())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn account(store: &Store, master: &[u8; KEY_LEN]) -> Result<account::Account, String> {
|
pub fn account(store: &Store, master: &[u8; KEY_LEN]) -> Result<account::Account, String> {
|
||||||
let index = store.rotations().map_err(explain)?;
|
let index = store.rotations().map_err(explain)?;
|
||||||
account::Account::new(*master, index).map_err(explain)
|
account::Account::new(*master, index).map_err(explain)
|
||||||
|
|
@ -966,13 +1067,55 @@ mod tests {
|
||||||
assert_eq!(load_settings(&path).timeout, 30);
|
assert_eq!(load_settings(&path).timeout, 30);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_legacy_root_migrates_into_an_account_folder_once() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let root = root.path();
|
||||||
|
// A fresh root has nothing to move.
|
||||||
|
migrate_legacy(root).unwrap();
|
||||||
|
assert!(list_accounts(root).is_empty());
|
||||||
|
|
||||||
|
for name in ["carrier.key", "carrier.db", "carrier.db-wal", "read.txt"] {
|
||||||
|
std::fs::write(root.join(name), name).unwrap();
|
||||||
|
}
|
||||||
|
migrate_legacy(root).unwrap();
|
||||||
|
let ids = list_accounts(root);
|
||||||
|
assert_eq!(ids.len(), 1);
|
||||||
|
assert_eq!(load_active(root).as_ref(), Some(&ids[0]));
|
||||||
|
let dir = account_dir(root, &ids[0]);
|
||||||
|
for name in ["carrier.key", "carrier.db", "carrier.db-wal", "read.txt"] {
|
||||||
|
assert!(!root.join(name).exists(), "{name} stayed in the root");
|
||||||
|
assert_eq!(std::fs::read_to_string(dir.join(name)).unwrap(), name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once `accounts/` exists, stray root files are left alone.
|
||||||
|
std::fs::write(root.join("carrier.key"), "stray").unwrap();
|
||||||
|
migrate_legacy(root).unwrap();
|
||||||
|
assert_eq!(list_accounts(root).len(), 1);
|
||||||
|
assert!(root.join("carrier.key").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_active_account_must_name_an_existing_folder() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let root = root.path();
|
||||||
|
save_active(root, "gone").unwrap();
|
||||||
|
assert!(load_active(root).is_none());
|
||||||
|
let id = new_account(root).unwrap();
|
||||||
|
save_active(root, &id).unwrap();
|
||||||
|
assert_eq!(load_active(root), Some(id.clone()));
|
||||||
|
remove_account(root, &id).unwrap();
|
||||||
|
assert!(load_active(root).is_none());
|
||||||
|
assert!(list_accounts(root).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn drafts_round_trip_through_the_sidecar() {
|
fn drafts_round_trip_through_the_sidecar() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let path = dir.path().join("drafts.txt");
|
let path = dir.path().join("drafts.txt");
|
||||||
let drafts = vec![
|
let drafts = vec![
|
||||||
Draft {
|
Draft {
|
||||||
id: draft_id(),
|
id: random_id(),
|
||||||
to: "smol://alice@example.org/KEY".into(),
|
to: "smol://alice@example.org/KEY".into(),
|
||||||
subject: "line breaks\nand \\backslashes\r".into(),
|
subject: "line breaks\nand \\backslashes\r".into(),
|
||||||
body: "a draft\n\nwith paragraphs\nand a \\ literal".into(),
|
body: "a draft\n\nwith paragraphs\nand a \\ literal".into(),
|
||||||
|
|
@ -987,7 +1130,7 @@ mod tests {
|
||||||
at: now(),
|
at: now(),
|
||||||
},
|
},
|
||||||
Draft {
|
Draft {
|
||||||
id: draft_id(),
|
id: random_id(),
|
||||||
to: "bob@example.org".into(),
|
to: "bob@example.org".into(),
|
||||||
subject: "no reply".into(),
|
subject: "no reply".into(),
|
||||||
body: String::new(),
|
body: String::new(),
|
||||||
|
|
@ -1287,7 +1430,8 @@ mod tests {
|
||||||
fn demo_seed_carries_a_mail_with_custom_fields() {
|
fn demo_seed_carries_a_mail_with_custom_fields() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
crate::demo::seed(dir.path());
|
crate::demo::seed(dir.path());
|
||||||
let store = Store::open(&dir.path().join("carrier.db")).unwrap();
|
let account = account_dir(dir.path(), "demo");
|
||||||
|
let store = Store::open(&account.join("carrier.db")).unwrap();
|
||||||
// The demo master is fixed bytes in demo.rs.
|
// The demo master is fixed bytes in demo.rs.
|
||||||
let master: [u8; KEY_LEN] = core::array::from_fn(|i| i as u8 + 1);
|
let master: [u8; KEY_LEN] = core::array::from_fn(|i| i as u8 + 1);
|
||||||
let empty = HashSet::new();
|
let empty = HashSet::new();
|
||||||
|
|
|
||||||
127
src/view.rs
127
src/view.rs
|
|
@ -21,8 +21,8 @@ use relm4::ComponentSender;
|
||||||
use relm4::{Component, ComponentParts};
|
use relm4::{Component, ComponentParts};
|
||||||
|
|
||||||
use crate::app::{
|
use crate::app::{
|
||||||
Carrier, Dialog, DialogKind, Message, OnboardMode, OpResult, Page, PendingOp, SignupMode,
|
AccountEntry, Carrier, Dialog, DialogKind, Message, OnboardMode, OpResult, Page, PendingOp,
|
||||||
SignupStep,
|
SignupMode, SignupStep,
|
||||||
};
|
};
|
||||||
use crate::i18n;
|
use crate::i18n;
|
||||||
use crate::ops::{self, Contact, Folder, Row};
|
use crate::ops::{self, Contact, Folder, Row};
|
||||||
|
|
@ -178,6 +178,32 @@ fn contact_state(contact: &Contact) -> String {
|
||||||
i18n::t(id)
|
i18n::t(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An account's address, or the not-registered label while it has none.
|
||||||
|
fn account_title(entry: &AccountEntry) -> String {
|
||||||
|
entry
|
||||||
|
.label
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| i18n::t("account-not-registered-value"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One switcher row, named by account id; the active one carries a check.
|
||||||
|
fn account_row(entry: &AccountEntry, active: bool) -> gtk::ListBoxRow {
|
||||||
|
let line = gtk::Box::new(Orientation::Horizontal, 6);
|
||||||
|
line.set_margin_top(6);
|
||||||
|
line.set_margin_bottom(6);
|
||||||
|
let title = label(&account_title(entry), "mono");
|
||||||
|
title.set_hexpand(true);
|
||||||
|
title.set_ellipsize(gtk::pango::EllipsizeMode::End);
|
||||||
|
line.append(&title);
|
||||||
|
let check = gtk::Image::from_icon_name("object-select-symbolic");
|
||||||
|
check.set_opacity(if active { 1.0 } else { 0.0 });
|
||||||
|
line.append(&check);
|
||||||
|
let row = gtk::ListBoxRow::new();
|
||||||
|
row.set_widget_name(&entry.id);
|
||||||
|
row.set_child(Some(&line));
|
||||||
|
row
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CarrierWidgets {
|
pub struct CarrierWidgets {
|
||||||
/// `AlertDialog::present` needs a parent, and only the window is one.
|
/// `AlertDialog::present` needs a parent, and only the window is one.
|
||||||
|
|
@ -225,6 +251,9 @@ pub struct CarrierWidgets {
|
||||||
// sidebar bottom: own identity and the contact list
|
// sidebar bottom: own identity and the contact list
|
||||||
sidebar_own: gtk::Label,
|
sidebar_own: gtk::Label,
|
||||||
sidebar_contacts: gtk::ListBox,
|
sidebar_contacts: gtk::ListBox,
|
||||||
|
/// The account switcher beside the identity card, and its list.
|
||||||
|
accounts_menu: gtk::MenuButton,
|
||||||
|
accounts_list: gtk::ListBox,
|
||||||
|
|
||||||
// the QR-only share popover on the detail view's header
|
// the QR-only share popover on the detail view's header
|
||||||
detail_share: gtk::MenuButton,
|
detail_share: gtk::MenuButton,
|
||||||
|
|
@ -245,6 +274,9 @@ pub struct CarrierWidgets {
|
||||||
// onboarding
|
// onboarding
|
||||||
onboard_seed_label: gtk::Label,
|
onboard_seed_label: gtk::Label,
|
||||||
onboard_seed_entry: adw::EntryRow,
|
onboard_seed_entry: adw::EntryRow,
|
||||||
|
onboard_reuse: gtk::Box,
|
||||||
|
onboard_reuse_list: gtk::ListBox,
|
||||||
|
onboard_cancel: gtk::Button,
|
||||||
|
|
||||||
// signup
|
// signup
|
||||||
signup_key_button: gtk::Button,
|
signup_key_button: gtk::Button,
|
||||||
|
|
@ -392,10 +424,37 @@ impl CarrierWidgets {
|
||||||
own_text.append(&sidebar_own);
|
own_text.append(&sidebar_own);
|
||||||
let own_row = gtk::Button::new();
|
let own_row = gtk::Button::new();
|
||||||
own_row.add_css_class("card");
|
own_row.add_css_class("card");
|
||||||
own_row.set_halign(Align::Fill);
|
own_row.set_hexpand(true);
|
||||||
own_row.set_margin_top(6);
|
|
||||||
own_row.set_child(Some(&own_text));
|
own_row.set_child(Some(&own_text));
|
||||||
sidebar_bottom.append(&own_row);
|
|
||||||
|
// The account switcher sits beside the card: one list of accounts,
|
||||||
|
// the active one checked, and a way to add another.
|
||||||
|
let accounts_list = gtk::ListBox::new();
|
||||||
|
accounts_list.add_css_class("navigation-sidebar");
|
||||||
|
accounts_list.set_selection_mode(gtk::SelectionMode::None);
|
||||||
|
let add_account = icon_label_button(
|
||||||
|
"list-add-symbolic",
|
||||||
|
&i18n::t("accounts-add-button"),
|
||||||
|
"quiet",
|
||||||
|
);
|
||||||
|
let accounts_box = gtk::Box::new(Orientation::Vertical, 6);
|
||||||
|
accounts_box.set_width_request(260);
|
||||||
|
accounts_box.append(&accounts_list);
|
||||||
|
accounts_box.append(&add_account);
|
||||||
|
let accounts_popover = gtk::Popover::new();
|
||||||
|
accounts_popover.set_child(Some(&accounts_box));
|
||||||
|
let accounts_menu = gtk::MenuButton::new();
|
||||||
|
accounts_menu.set_icon_name("system-switch-user-symbolic");
|
||||||
|
accounts_menu.add_css_class("flat");
|
||||||
|
accounts_menu.add_css_class("circular");
|
||||||
|
accounts_menu.set_valign(Align::Center);
|
||||||
|
accounts_menu.set_tooltip_text(Some(&i18n::t("accounts-menu-tooltip")));
|
||||||
|
accounts_menu.set_popover(Some(&accounts_popover));
|
||||||
|
let own_line = gtk::Box::new(Orientation::Horizontal, 6);
|
||||||
|
own_line.set_margin_top(6);
|
||||||
|
own_line.append(&own_row);
|
||||||
|
own_line.append(&accounts_menu);
|
||||||
|
sidebar_bottom.append(&own_line);
|
||||||
|
|
||||||
// The list's header carries its one action: importing a
|
// The list's header carries its one action: importing a
|
||||||
// smol:// address, in a popover off a plain add button.
|
// smol:// address, in a popover off a plain add button.
|
||||||
|
|
@ -937,6 +996,19 @@ impl CarrierWidgets {
|
||||||
let restore_button = button(&i18n::t("onboard-restore-button"), "pill-quiet");
|
let restore_button = button(&i18n::t("onboard-restore-button"), "pill-quiet");
|
||||||
onboard_start.append(&create_button);
|
onboard_start.append(&create_button);
|
||||||
onboard_start.append(&restore_button);
|
onboard_start.append(&restore_button);
|
||||||
|
// Shown only while other accounts hold a secret: one row each.
|
||||||
|
let onboard_reuse = gtk::Box::new(Orientation::Vertical, 6);
|
||||||
|
onboard_reuse.set_margin_top(12);
|
||||||
|
onboard_reuse.append(&label(&i18n::t("onboard-reuse-title"), "heading"));
|
||||||
|
let reuse_description = label(&i18n::t("onboard-reuse-description"), "dim");
|
||||||
|
reuse_description.set_wrap(true);
|
||||||
|
onboard_reuse.append(&reuse_description);
|
||||||
|
let onboard_reuse_list = boxed_list();
|
||||||
|
onboard_reuse.append(&onboard_reuse_list);
|
||||||
|
onboard_start.append(&onboard_reuse);
|
||||||
|
let onboard_cancel = button(&i18n::t("accounts-add-cancel-button"), "pill-quiet");
|
||||||
|
onboard_cancel.set_margin_top(12);
|
||||||
|
onboard_start.append(&onboard_cancel);
|
||||||
stack.add_named(¢er(&onboard_start), Some("onboard"));
|
stack.add_named(¢er(&onboard_start), Some("onboard"));
|
||||||
|
|
||||||
let onboard_created = gtk::Box::new(Orientation::Vertical, 12);
|
let onboard_created = gtk::Box::new(Orientation::Vertical, 12);
|
||||||
|
|
@ -1196,6 +1268,17 @@ impl CarrierWidgets {
|
||||||
});
|
});
|
||||||
|
|
||||||
own_row.connect_clicked(input(Message::OwnSelected));
|
own_row.connect_clicked(input(Message::OwnSelected));
|
||||||
|
let switch_sender = sender.clone();
|
||||||
|
let switch_popover = accounts_popover.clone();
|
||||||
|
accounts_list.connect_row_activated(move |_, row| {
|
||||||
|
switch_popover.popdown();
|
||||||
|
switch_sender.input(Message::AccountSwitch(row.widget_name().to_string()));
|
||||||
|
});
|
||||||
|
let add_sender = sender.clone();
|
||||||
|
add_account.connect_clicked(move |_| {
|
||||||
|
accounts_popover.popdown();
|
||||||
|
add_sender.input(Message::AccountAdd);
|
||||||
|
});
|
||||||
import_button.connect_clicked(input(Message::ContactImportPressed));
|
import_button.connect_clicked(input(Message::ContactImportPressed));
|
||||||
let import_sender = sender.clone();
|
let import_sender = sender.clone();
|
||||||
contact_import.connect_changed(move |entry| {
|
contact_import.connect_changed(move |entry| {
|
||||||
|
|
@ -1269,6 +1352,11 @@ impl CarrierWidgets {
|
||||||
|
|
||||||
create_button.connect_clicked(input(Message::OnboardCreate));
|
create_button.connect_clicked(input(Message::OnboardCreate));
|
||||||
restore_button.connect_clicked(input(Message::OnboardRestore));
|
restore_button.connect_clicked(input(Message::OnboardRestore));
|
||||||
|
onboard_cancel.connect_clicked(input(Message::AccountAddCancelled));
|
||||||
|
let reuse_sender = sender.clone();
|
||||||
|
onboard_reuse_list.connect_row_activated(move |_, row| {
|
||||||
|
reuse_sender.input(Message::OnboardReuse(row.widget_name().to_string()));
|
||||||
|
});
|
||||||
restore_back.connect_clicked(input(Message::OnboardBack));
|
restore_back.connect_clicked(input(Message::OnboardBack));
|
||||||
created_back.connect_clicked(input(Message::OnboardBack));
|
created_back.connect_clicked(input(Message::OnboardBack));
|
||||||
saved_button.connect_clicked(input(Message::OnboardSeedSaved));
|
saved_button.connect_clicked(input(Message::OnboardSeedSaved));
|
||||||
|
|
@ -1348,6 +1436,8 @@ impl CarrierWidgets {
|
||||||
compose_pin_button,
|
compose_pin_button,
|
||||||
sidebar_own,
|
sidebar_own,
|
||||||
sidebar_contacts: contacts_list,
|
sidebar_contacts: contacts_list,
|
||||||
|
accounts_menu,
|
||||||
|
accounts_list,
|
||||||
detail_share,
|
detail_share,
|
||||||
detail_copy,
|
detail_copy,
|
||||||
detail_qr,
|
detail_qr,
|
||||||
|
|
@ -1362,6 +1452,9 @@ impl CarrierWidgets {
|
||||||
timeout_row,
|
timeout_row,
|
||||||
onboard_seed_label,
|
onboard_seed_label,
|
||||||
onboard_seed_entry,
|
onboard_seed_entry,
|
||||||
|
onboard_reuse,
|
||||||
|
onboard_reuse_list,
|
||||||
|
onboard_cancel,
|
||||||
signup_key_button,
|
signup_key_button,
|
||||||
signup_key_card,
|
signup_key_card,
|
||||||
signup_key_value,
|
signup_key_value,
|
||||||
|
|
@ -2055,6 +2148,30 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accounts: the switcher hides while one is being set up, so an
|
||||||
|
// unfinished account is only ever left through Cancel.
|
||||||
|
w.accounts_menu
|
||||||
|
.set_visible(matches!(model.page, Page::Mail | Page::Settings));
|
||||||
|
clear_list(&w.accounts_list);
|
||||||
|
for entry in &model.accounts {
|
||||||
|
w.accounts_list
|
||||||
|
.append(&account_row(entry, entry.id == model.id));
|
||||||
|
}
|
||||||
|
clear_list(&w.onboard_reuse_list);
|
||||||
|
let reusable = model.reusable_accounts();
|
||||||
|
w.onboard_reuse.set_visible(!reusable.is_empty());
|
||||||
|
for entry in reusable {
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(account_title(entry))
|
||||||
|
.use_markup(false)
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
row.set_widget_name(&entry.id);
|
||||||
|
row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||||
|
w.onboard_reuse_list.append(&row);
|
||||||
|
}
|
||||||
|
w.onboard_cancel.set_visible(model.previous.is_some());
|
||||||
|
|
||||||
// Settings: the identity/server rows are fixed, so only their values
|
// Settings: the identity/server rows are fixed, so only their values
|
||||||
// change.
|
// change.
|
||||||
if let Some(snap) = &model.snapshot {
|
if let Some(snap) = &model.snapshot {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue