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
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.
|
||||
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());
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue