From 743c388cfbc88ef66100be04c10b80793a3b2923 Mon Sep 17 00:00:00 2001 From: randogoth Date: Thu, 8 Oct 2026 20:46:17 +0300 Subject: [PATCH] feat: split settings into Identity, Server, Backup and Advanced tabs --- Cargo.toml | 3 +- src/app.rs | 119 ++++++-------- src/ops.rs | 65 ++++---- src/view.rs | 466 ++++++++++++++++++++++++++++++++-------------------- 4 files changed, 365 insertions(+), 288 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c81e242..19f960e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,8 @@ license = "Apache-2.0" [dependencies] fumi-core = { path = "../fumi/core" } -gtk = { version = "0.9", package = "gtk4" } +# v4_10 unlocks gtk::FileDialog, used for the backup export/import pickers. +gtk = { version = "0.9", package = "gtk4", features = ["v4_10"] } # Relm4 pulls libadwaita in; the direct entry only raises the API level, # since Cargo unions features and the rows and dialogs used here are 1.5. libadwaita = { version = "0.7", features = ["v1_5"] } diff --git a/src/app.rs b/src/app.rs index 3b9c479..5824a81 100644 --- a/src/app.rs +++ b/src/app.rs @@ -159,10 +159,6 @@ pub enum Message { SignupRegister, SignupRecall, - // Pin offer (signup and settings share it) - PinAccept, - PinDiscard, - // Mail GotoMail(Folder), Goto(Page), @@ -202,19 +198,17 @@ pub enum Message { ContactAccept(String, String), ContactBlock(String), ContactUnblock(String), - CopyExport, // Settings - SettingsLearnKey, + SecretRevealToggled, + CopySecret, KeepMailChanged(bool), TimeoutChanged(String), RotatePressed, RotateConfirmed, RotateCancelled, - UnpinPressed(String), - ExportPressed, - ImportBodyChanged(String), - ImportPressed, + ExportBackupTo(PathBuf), + ImportBackupFrom(String), ResetPressed, ResetConfirmed, ResetCancelled, @@ -229,8 +223,6 @@ pub enum OpResult { /// The key a server presented with no pin in place (sec 4 tofu). PinOffered(Result, String>), Pinned(Result<(), String>), - /// (note, backup text) — the text is for the clipboard and the view. - Exported(Result<(String, String), String>), } /// One background operation to run on the blocking pool, carrying every @@ -280,11 +272,8 @@ pub enum PendingOp { uri: String, }, Rotate, - Unpin { - label: String, - }, Export { - dir: PathBuf, + path: PathBuf, }, ImportBackup { text: String, @@ -322,8 +311,8 @@ pub struct Carrier { pub drafts: Vec, pub snapshot: Option, pub pin_offer: Option<(Address, [u8; KEY_LEN])>, - pub backup_text: Option, - pub import_body: String, + /// The Identity tab's recovery secret is masked until this is toggled. + pub secret_revealed: bool, pub contact_import: String, pub dialog: Option, /// The view syncs entry text from the model once after a prefill @@ -389,8 +378,7 @@ impl Carrier { drafts, snapshot: None, pin_offer: None, - backup_text: None, - import_body: String::new(), + secret_revealed: false, contact_import: String::new(), dialog: None, compose_fresh: Cell::new(false), @@ -480,7 +468,7 @@ impl Carrier { } let (app, _) = Carrier::boot(self.dir.clone()); *self = app; - self.note("factory reset done. Create a new identity or restore a seed.".into()); + self.note("logged out. Create a new identity or restore a seed.".into()); } /// Cancelling out of a not-yet-registered identity removes the key @@ -577,11 +565,6 @@ impl Carrier { }) } - /// The account address, for the settings page's learn-key action. - fn account_address(&self) -> Option
{ - self.store.account().ok().flatten() - } - /// `user@host` as typed on the signup page, defaulting the port. fn signup_address(&self) -> Option
{ let user = self.signup.user.trim(); @@ -656,9 +639,6 @@ impl Carrier { } } } - Message::ImportBodyChanged(text) => { - self.import_body = text; - } Message::ContactSelected(address) => { self.stash_draft(); self.selected_own = false; @@ -709,6 +689,9 @@ impl Carrier { Message::KeepMailChanged(keep) => { self.keep_mail = keep; } + Message::SecretRevealToggled => { + self.secret_revealed = !self.secret_revealed; + } Message::TimeoutChanged(text) => { if let Ok(timeout) = text.trim().parse::() { self.timeout = timeout; @@ -790,21 +773,6 @@ impl Carrier { }; return vec![PendingOp::LearnKey { addr }]; } - Message::SettingsLearnKey => { - let Some(addr) = self.account_address() else { - return Vec::new(); - }; - return vec![PendingOp::LearnKey { addr }]; - } - Message::PinDiscard => { - self.pin_offer = None; - } - Message::PinAccept => { - let Some((addr, key)) = self.pin_offer.take() else { - return Vec::new(); - }; - return vec![PendingOp::Pin { addr, key }]; - } Message::SignupRegister => { let Some(addr) = self.signup_address() else { self.fail("enter user and host".into()); @@ -1102,11 +1070,10 @@ impl Carrier { }; return vec![PendingOp::Accept { address, identity }]; } - Message::CopyExport => { - if let Some(text) = &self.backup_text { - if let Some(display) = gtk::gdk::Display::default() { - display.clipboard().set_text(text); - } + Message::CopySecret => { + let text = ops::seed_text(&self.master()); + if let Some(display) = gtk::gdk::Display::default() { + display.clipboard().set_text(&text); } } Message::RotatePressed => { @@ -1129,20 +1096,14 @@ impl Carrier { self.dialog = None; return vec![PendingOp::Rotate]; } - Message::UnpinPressed(label) => { - return vec![PendingOp::Unpin { label }]; + Message::ExportBackupTo(path) => { + return vec![PendingOp::Export { path }]; } - Message::ExportPressed => { - let dir = self.dir.clone(); - return vec![PendingOp::Export { dir }]; - } - Message::ImportPressed => { - if self.import_body.trim().is_empty() { + Message::ImportBackupFrom(text) => { + if text.trim().is_empty() { return Vec::new(); } - return vec![PendingOp::ImportBackup { - text: self.import_body.clone(), - }]; + return vec![PendingOp::ImportBackup { text }]; } } Vec::new() @@ -1211,17 +1172,6 @@ impl Carrier { self.fail(e); None } - OpResult::Exported(Ok((note, text))) => { - self.done(); - self.backup_text = Some(text); - self.note(note); - None - } - OpResult::Exported(Err(e)) => { - self.done(); - self.fail(e); - None - } } } } @@ -1280,6 +1230,33 @@ mod tests { assert!(!app.keep_mail); } + #[test] + fn secret_reveal_toggles() { + let mut app = app(); + assert!(!app.secret_revealed); + app.handle(Message::SecretRevealToggled); + assert!(app.secret_revealed); + app.handle(Message::SecretRevealToggled); + assert!(!app.secret_revealed); + } + + /// Export/Import now go through a native file dialog rather than a + /// paste box, so the model only ever sees the chosen path/text — + /// an empty import (e.g. an empty file) is a no-op, like before. + #[test] + fn export_and_import_dispatch_on_the_chosen_path() { + let mut app = app(); + let path = std::path::PathBuf::from("/tmp/wherever.gsmol"); + let ops = app.handle(Message::ExportBackupTo(path.clone())); + assert!(matches!(ops.as_slice(), [PendingOp::Export { path: p }] if *p == path)); + + assert!(app + .handle(Message::ImportBackupFrom(" ".into())) + .is_empty()); + let ops = app.handle(Message::ImportBackupFrom("some backup text".into())); + assert!(matches!(ops.as_slice(), [PendingOp::ImportBackup { .. }])); + } + /// A factory reset asks first, then erases every file in the config /// directory and returns the model to onboarding, as if the app had /// never run. diff --git a/src/ops.rs b/src/ops.rs index 151f73e..916e826 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -3,17 +3,17 @@ //! values these functions return. use std::collections::HashSet; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::atomic::AtomicBool; use std::sync::Arc; -use fumi_core::address::{Address, DEFAULT_PORT}; +use fumi_core::address::Address; use fumi_core::client::{FetchOptions, TrustChange}; use fumi_core::crypto::{b32, fingerprint, unb32, KEY_LEN}; use fumi_core::error::Error; use fumi_core::message::{FRONTMATTER_KEYS, FRONTMATTER_MAX}; use fumi_core::store::Store; -use fumi_core::{account, client, export, store}; +use fumi_core::{account, client, export}; /// One message in a folder list, already opened for its metadata. #[derive(Debug, Clone)] @@ -57,8 +57,10 @@ pub struct Contact { #[derive(Debug, Clone)] pub struct Who { pub address: String, + pub host: String, pub key: String, pub fingerprint: String, + pub uri: String, pub rotations: u32, } @@ -375,19 +377,6 @@ pub fn pin(store: &Store, addr: &Address, key: &[u8; KEY_LEN]) -> Result<(), Str .map_err(explain) } -/// Splits a stored pin label (`host` or `host:port`) back into its parts. -fn label_parts(label: &str) -> (String, u16) { - match label.rsplit_once(':') { - Some((host, port)) => (host.to_string(), port.parse().unwrap_or(DEFAULT_PORT)), - None => (label.to_string(), DEFAULT_PORT), - } -} - -pub fn unpin(store: &Store, label: &str) -> Result<(), String> { - let (host, port) = label_parts(label); - store.unpin_server(&host, port).map_err(explain) -} - pub fn pins(store: &Store) -> Result, String> { store.pins().map_err(explain) } @@ -621,16 +610,11 @@ pub fn rotate( Ok((store.rotations().map_err(explain)?, b32(&rotated.new_pk))) } -/// Writes the gsmol backup beside the store and returns it for the clipboard. -pub fn export_backup( - store: &Store, - master: &[u8; KEY_LEN], - dir: &Path, -) -> Result<(PathBuf, String), String> { +/// Writes the gsmol backup to the chosen path. +pub fn export_backup(store: &Store, master: &[u8; KEY_LEN], path: &Path) -> Result { let text = export::export(store, master).map_err(explain)?; - let path = dir.join(format!("carrier-backup-{}.gsmol", store::now())); - std::fs::write(&path, &text).map_err(|e| format!("{}: {e}", path.display()))?; - Ok((path, text)) + std::fs::write(path, &text).map_err(|e| format!("{}: {e}", path.display()))?; + Ok(format!("backup written to {}", path.display())) } pub fn import_backup(store: &Store, master: &[u8; KEY_LEN], text: &str) -> Result { @@ -733,15 +717,26 @@ pub fn open( pub fn whoami(store: &Store, master: &[u8; KEY_LEN]) -> Result { let me = account(store, master)?; - let address = store - .account() - .map_err(explain)? + let account = store.account().map_err(explain)?; + let pk = me.me().pk(); + let address = account + .as_ref() .map(|addr| addr.short()) .unwrap_or_default(); + let host = account + .as_ref() + .map(|addr| addr.host.clone()) + .unwrap_or_default(); + let uri = account + .as_ref() + .map(|addr| addr.uri(&pk)) + .unwrap_or_default(); Ok(Who { address, - key: b32(&me.me().pk()), - fingerprint: fingerprint(&me.me().pk()), + host, + key: b32(&pk), + fingerprint: fingerprint(&pk), + uri, rotations: me.index(), }) } @@ -1018,8 +1013,11 @@ mod tests { .store_inbox(&id, &envelope, now(), false, false) .unwrap(); - let (path, text) = export_backup(&store, &m, dir.path()).unwrap(); + let path = dir.path().join("backup.gsmol"); + let note = export_backup(&store, &m, &path).unwrap(); assert!(path.exists()); + assert!(note.contains(&path.display().to_string())); + let text = std::fs::read_to_string(&path).unwrap(); assert!(!text.is_empty()); let fresh = setup(); @@ -1032,17 +1030,14 @@ mod tests { } #[test] - fn unpin_splits_the_label() { + fn pin_lists_under_the_host_port_label() { let store = setup(); let addr = Address::parse("me@localhost:1962").unwrap(); let key = master(); - store.pin_server(&addr.host, addr.port, &key).unwrap(); assert_eq!(pin(&store, &addr, &key), Ok(())); let all = pins(&store).unwrap(); assert_eq!(all.len(), 1); assert_eq!(all[0].0, "localhost:1962"); - unpin(&store, "localhost:1962").unwrap(); - assert!(pins(&store).unwrap().is_empty()); } /// The dev-build demo seed grows a mail that carries the sender's own diff --git a/src/view.rs b/src/view.rs index 0725c25..64b0d63 100644 --- a/src/view.rs +++ b/src/view.rs @@ -221,14 +221,14 @@ pub struct CarrierWidgets { detail_qr: gtk::Image, // settings page - identity_values: Vec, - offer_group: adw::PreferencesGroup, - offer_row: adw::ActionRow, - pins_group: adw::PreferencesGroup, - pin_rows: Vec, - keep_mail_row: adw::SwitchRow, + identity_rows: Vec, + secret_row: adw::ActionRow, + secret_reveal: gtk::Button, + host_row: adw::ActionRow, + public_key_row: adw::ActionRow, + rotations_value: gtk::Label, + keep_mail_check: gtk::CheckButton, timeout_row: adw::SpinRow, - backup_label: gtk::Label, // onboarding onboard_seed_label: gtk::Label, @@ -603,128 +603,216 @@ impl CarrierWidgets { compose.append(&compose_actions); stack.add_named(&compose, Some("compose")); - // settings: the preferences page every GNOME app shows, one group - // per concern, so scrolling, width and margins come for free. - let settings = adw::PreferencesPage::new(); + // settings: a tabbed preferences page — Identity, Server, Backup, + // Advanced — switched by an adw::ViewSwitcher over an + // adw::ViewStack, each tab its own PreferencesPage. + let settings_stack = adw::ViewStack::new(); - let identity_group = adw::PreferencesGroup::builder().title("Identity").build(); - let identity_actions = gtk::Box::new(Orientation::Horizontal, 6); - let rotate_button = icon_label_button("view-refresh-symbolic", "Rotate", ""); - let learn_button = button("Learn key", ""); - identity_actions.append(&learn_button); - identity_actions.append(&rotate_button); - identity_group.set_header_suffix(Some(&identity_actions)); - let mut identity_values = Vec::new(); - for title in ["Address", "Key", "Fingerprint", "Rotations"] { - let row = adw::ActionRow::builder().title(title).build(); - let value = label("", "mono dim"); - value.set_selectable(true); - value.set_ellipsize(gtk::pango::EllipsizeMode::End); - value.set_max_width_chars(48); - value.set_valign(Align::Center); - row.add_suffix(&value); + // Read-only values use Adwaita's "property" row: the field name + // sits small above the value it labels, which leaves the value + // itself room to wrap instead of being ellipsized into a suffix. + // Markup is off so the copy button can hand back exactly what is + // on screen. + let property_row = |title: &str| -> adw::ActionRow { + let row = adw::ActionRow::builder() + .title(title) + .use_markup(false) + .subtitle_lines(0) + .subtitle_selectable(true) + .build(); + row.add_css_class("property"); + row.add_css_class("monospace"); + let copy = icon_button( + "edit-copy-symbolic", + &format!("Copy the {}", title.to_lowercase()), + ); + let copy_source = row.clone(); + copy.connect_clicked(move |_| { + if let Some(display) = gtk::gdk::Display::default() { + display + .clipboard() + .set_text(©_source.subtitle().unwrap_or_default()); + } + }); + row.add_suffix(©); + row + }; + + // -- Identity tab -------------------------------------------------- + // The switcher already names each page, so the first group on a + // page carries no title of its own. + let identity_page = adw::PreferencesPage::new(); + let identity_group = adw::PreferencesGroup::new(); + let mut identity_rows = Vec::new(); + for title in ["Address", "Key", "Fingerprint", "URI"] { + let row = property_row(title); identity_group.add(&row); - identity_values.push(value); + identity_rows.push(row); } - settings.add(&identity_group); + identity_page.add(&identity_group); - // The pin offer is a group that appears in place rather than a - // dialog: pinning is a decision about this host, taken next to the - // identity it belongs to. - let offer_group = adw::PreferencesGroup::builder() - .title("Server key offered") - .visible(false) + // The group header names this one, so the row carries only the + // secret — masked until the reveal button is pressed. + let secret_group = adw::PreferencesGroup::builder() + .title("Recovery Secret") .build(); - let offer_row = adw::ActionRow::builder().title("").build(); - offer_row.add_css_class("monospace"); - offer_row.set_subtitle_lines(2); - let offer_pin = button("Pin", "accent"); - offer_pin.set_valign(Align::Center); - let offer_discard = button("Discard", ""); - offer_discard.set_valign(Align::Center); - offer_row.add_suffix(&offer_discard); - offer_row.add_suffix(&offer_pin); - offer_group.add(&offer_row); - settings.add(&offer_group); - - let pins_group = adw::PreferencesGroup::builder() - .title("Server keys") + let secret_row = adw::ActionRow::builder() + .use_markup(false) + .title_lines(0) + .title_selectable(true) .build(); - settings.add(&pins_group); + secret_row.add_css_class("monospace"); + let secret_reveal = icon_button("view-reveal-symbolic", "Reveal the recovery secret"); + let secret_copy = icon_button("edit-copy-symbolic", "Copy the recovery secret"); + secret_row.add_suffix(&secret_reveal); + secret_row.add_suffix(&secret_copy); + secret_group.add(&secret_row); + identity_page.add(&secret_group); + settings_stack.add_titled_with_icon( + &identity_page, + Some("identity"), + "Identity", + "avatar-default-symbolic", + ); - let fetch_group = adw::PreferencesGroup::builder().title("Fetching").build(); - let keep_mail_row = adw::SwitchRow::builder() - .title("Keep mail on the server") - .subtitle("Off: a fetch removes mail from the server once it's stored here.") - .build(); - fetch_group.add(&keep_mail_row); - settings.add(&fetch_group); - - let network_group = adw::PreferencesGroup::builder().title("Network").build(); + // -- Server tab ---------------------------------------------------- + let server_page = adw::PreferencesPage::new(); + let server_group = adw::PreferencesGroup::builder().title("Connection").build(); + let host_row = property_row("Host"); + let public_key_row = property_row("Public Key"); + server_group.add(&host_row); + server_group.add(&public_key_row); let timeout_row = adw::SpinRow::with_range(1.0, 600.0, 1.0); timeout_row.set_title("Timeout"); timeout_row.set_subtitle("Seconds before a network call gives up"); - network_group.add(&timeout_row); - settings.add(&network_group); + server_group.add(&timeout_row); + server_page.add(&server_group); + let network_group = adw::PreferencesGroup::builder().title("Fetching").build(); + let keep_mail_row = adw::ActionRow::builder() + .title("Leave mail on server") + .subtitle( + "Keep a copy on the server after fetching, so another client can \ + fetch it too. When this is enabled, deleting a message removes \ + it from this client and the server.", + ) + .subtitle_lines(0) + .activatable(true) + .build(); + let keep_mail_check = gtk::CheckButton::builder().valign(Align::Center).build(); + keep_mail_row.add_suffix(&keep_mail_check); + keep_mail_row.set_activatable_widget(Some(&keep_mail_check)); + network_group.add(&keep_mail_row); + server_page.add(&network_group); + settings_stack.add_titled_with_icon( + &server_page, + Some("server"), + "Server", + "network-server-symbolic", + ); + + // -- Backup tab ---------------------------------------------------- + let backup_page = adw::PreferencesPage::new(); let backup_group = adw::PreferencesGroup::builder() - .title("Backup") .description( - "Sealed to a key derived from the seed, so it restores only where this\n identity already lives. The seed itself is never in the file.", + "One file holding your mail, contacts and server info for moving \ + to another client, or for keeping what the server no longer \ + has. It is sealed to this identity and deliberately leaves the \ + secret itself out.", ) .build(); - let backup_actions = gtk::Box::new(Orientation::Horizontal, 6); - let export_button = icon_label_button("document-save-symbolic", "Export", ""); - let copy_export = icon_button("edit-copy-symbolic", "Copy the backup"); - backup_actions.append(©_export); - backup_actions.append(&export_button); - backup_group.set_header_suffix(Some(&backup_actions)); - let backup_label = label("", "mono caption"); - backup_label.set_wrap(true); - backup_label.set_wrap_mode(gtk::pango::WrapMode::Char); - backup_label.set_selectable(true); - let backup_scroll = gtk::ScrolledWindow::new(); - backup_scroll.add_css_class("card"); - backup_scroll.set_height_request(72); - backup_scroll.set_child(Some(&backup_label)); - backup_group.add(&backup_scroll); - settings.add(&backup_group); - - let import_group = adw::PreferencesGroup::builder() - .title("Restore") - .description("Paste a gsmol backup to restore the contacts, pins and mail it carries.") + let export_row = adw::ActionRow::builder() + .title("Export") + .subtitle("Write a backup file") + .activatable(true) .build(); - let import_view = gtk::TextView::new(); - import_view.set_wrap_mode(WrapMode::WordChar); - import_view.set_top_margin(8); - import_view.set_bottom_margin(8); - import_view.set_left_margin(8); - import_view.set_right_margin(8); - import_view.add_css_class("monospace"); - let import_body = import_view.buffer(); - let import_scroll = gtk::ScrolledWindow::new(); - import_scroll.add_css_class("card"); - import_scroll.set_height_request(96); - import_scroll.set_child(Some(&import_view)); - import_group.add(&import_scroll); - let import_backup = icon_label_button("document-open-symbolic", "Restore", ""); - import_backup.set_halign(Align::End); - import_backup.set_margin_top(6); - import_group.add(&import_backup); - settings.add(&import_group); + export_row.add_prefix(>k::Image::from_icon_name("document-save-symbolic")); + export_row.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); + let import_row = adw::ActionRow::builder() + .title("Import") + .subtitle("Restore from a backup file") + .activatable(true) + .build(); + import_row.add_prefix(>k::Image::from_icon_name("document-open-symbolic")); + import_row.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); + backup_group.add(&export_row); + backup_group.add(&import_row); + backup_page.add(&backup_group); + settings_stack.add_titled_with_icon( + &backup_page, + Some("backup"), + "Backup", + "document-save-symbolic", + ); - // The reset closes the page list: nothing after it survives a - // wipe, so it sits last, marked destructive like the delete. - let reset_group = adw::PreferencesGroup::builder() - .title("Factory reset") + // -- Advanced tab -------------------------------------------------- + let advanced_page = adw::PreferencesPage::new(); + let rotations_group = adw::PreferencesGroup::builder() + .title("Rotations") .description( - "Erase the seed key, the sealed store and every sidecar on this device, and return to onboarding. Mail already on the server is not touched.", + "Issues a fresh key from your secret with a signed certificate \ + proving it replaces this one. Contacts accept the change from \ + that chain, and the old key stays readable. Rotation is not \ + revocation.", ) .build(); - let reset_button = icon_label_button("user-trash-symbolic", "Factory reset", "danger"); - reset_button.set_valign(Align::Center); - reset_group.set_header_suffix(Some(&reset_button)); - settings.add(&reset_group); + // Both actions sit in their group's body, centred under the + // explanation: a long description wraps under the group title and + // would run straight into a header-suffix button. + let rotations_row = adw::ActionRow::builder().title("Completed").build(); + let rotations_value = label("", "dim numeric"); + rotations_value.set_valign(Align::Center); + rotations_row.add_suffix(&rotations_value); + rotations_group.add(&rotations_row); + let rotate_button = icon_label_button("view-refresh-symbolic", "Rotate identity", ""); + rotate_button.set_halign(Align::Center); + rotate_button.set_margin_top(12); + rotations_group.add(&rotate_button); + advanced_page.add(&rotations_group); + + // The log-out button is wide and destructive, so it sits in the + // group's body under the warning rather than squeezed beside the + // header where its label would crowd the title. + let logout_group = adw::PreferencesGroup::builder() + .title("Log Out") + .description( + "Removes this identity from this client entirely: the secret, \ + pins, contacts and every cached message. Without a backup of \ + the secret there is no way back in. Mail already fetched here \ + may have no other copy anywhere, unless \"leave mail on \ + server\" was on. Whatever is still on the server comes back \ + on the first fetch after logging back in.", + ) + .build(); + let reset_button = icon_label_button( + "system-log-out-symbolic", + "Log out and delete all data", + "danger", + ); + reset_button.set_halign(Align::Center); + reset_button.set_margin_top(12); + logout_group.add(&reset_button); + advanced_page.add(&logout_group); + settings_stack.add_titled_with_icon( + &advanced_page, + Some("advanced"), + "Advanced", + "emblem-system-symbolic", + ); + + // The switcher is a centred bar of its own above the pages, since + // the window's headerbar already carries the app name and address. + let settings_switcher = adw::ViewSwitcher::builder() + .stack(&settings_stack) + .policy(adw::ViewSwitcherPolicy::Wide) + .halign(Align::Center) + .build(); + let settings_switcher_bar = gtk::Box::new(Orientation::Vertical, 0); + settings_switcher_bar.add_css_class("toolbar"); + settings_switcher_bar.append(&settings_switcher); + let settings = gtk::Box::new(Orientation::Vertical, 0); + settings.append(&settings_switcher_bar); + settings.append(&settings_stack); stack.add_named(&settings, Some("settings")); @@ -1013,7 +1101,6 @@ impl CarrierWidgets { }); own_row.connect_clicked(input(Message::OwnSelected)); - copy_export.connect_clicked(input(Message::CopyExport)); import_button.connect_clicked(input(Message::ContactImportPressed)); let import_sender = sender.clone(); contact_import.connect_changed(move |entry| { @@ -1026,22 +1113,57 @@ impl CarrierWidgets { rotate_button.connect_clicked(input(Message::RotatePressed)); reset_button.connect_clicked(input(Message::ResetPressed)); - learn_button.connect_clicked(input(Message::SettingsLearnKey)); - offer_pin.connect_clicked(input(Message::PinAccept)); - offer_discard.connect_clicked(input(Message::PinDiscard)); - export_button.connect_clicked(input(Message::ExportPressed)); - import_backup.connect_clicked(input(Message::ImportPressed)); + secret_reveal.connect_clicked(input(Message::SecretRevealToggled)); + secret_copy.connect_clicked(input(Message::CopySecret)); let timeout_sender = sender.clone(); timeout_row.connect_value_notify(move |row| { timeout_sender.input(Message::TimeoutChanged((row.value() as u64).to_string())) }); let keep_mail_sender = sender.clone(); - keep_mail_row.connect_active_notify(move |row| { - keep_mail_sender.input(Message::KeepMailChanged(row.is_active())) + keep_mail_check.connect_toggled(move |check| { + keep_mail_sender.input(Message::KeepMailChanged(check.is_active())) }); - let import_body_sender = sender.clone(); - import_body.connect_changed(move |buffer| { - import_body_sender.input(Message::ImportBodyChanged(buffer_text(buffer))) + // Export/Import go straight to a native file dialog rather than a + // Message — the chosen path only matters for this one call, and + // round-tripping it through the model would gain nothing. + let export_sender = sender.clone(); + let export_window = root.clone(); + export_row.connect_activated(move |_| { + let dialog = gtk::FileDialog::builder() + .title("Export Backup") + .initial_name("carrier-backup.gsmol") + .build(); + let sender = export_sender.clone(); + dialog.save( + Some(&export_window), + gtk::gio::Cancellable::NONE, + move |result| { + if let Ok(file) = result { + if let Some(path) = file.path() { + sender.input(Message::ExportBackupTo(path)); + } + } + }, + ); + }); + let backup_import_sender = sender.clone(); + let backup_import_window = root.clone(); + import_row.connect_activated(move |_| { + let dialog = gtk::FileDialog::builder().title("Import Backup").build(); + let sender = backup_import_sender.clone(); + dialog.open( + Some(&backup_import_window), + gtk::gio::Cancellable::NONE, + move |result| { + if let Ok(file) = result { + if let Some(path) = file.path() { + if let Ok(text) = std::fs::read_to_string(&path) { + sender.input(Message::ImportBackupFrom(text)); + } + } + } + }, + ); }); create_button.connect_clicked(input(Message::OnboardCreate)); @@ -1121,14 +1243,14 @@ impl CarrierWidgets { detail_share, detail_copy, detail_qr, - identity_values, - offer_group, - offer_row, - pins_group, - pin_rows: Vec::new(), - keep_mail_row, + identity_rows, + secret_row, + secret_reveal, + host_row, + public_key_row, + rotations_value, + keep_mail_check, timeout_row, - backup_label, onboard_seed_label, onboard_seed_entry, signup_key_button, @@ -1314,14 +1436,10 @@ fn spawn_op(model: &Carrier, op: PendingOp, sender: &ComponentSender) { .map(|(index, key)| format!("rotated to index {index}; new key {}…", &key[..12])); let _ = out.send(OpResult::Generic(res)); }), - PendingOp::Unpin { label } => sender.spawn_command(move |out| { - let res = ops::unpin(&store, &label).map(|_| format!("unpinned {label}")); - let _ = out.send(OpResult::Generic(res)); - }), - PendingOp::Export { dir } => sender.spawn_command(move |out| { - let exported = ops::export_backup(&store, &master, &dir) - .map(|(path, text)| (format!("backup written to {}", path.display()), text)); - let _ = out.send(OpResult::Exported(exported)); + PendingOp::Export { path } => sender.spawn_command(move |out| { + let _ = out.send(OpResult::Generic(ops::export_backup( + &store, &master, &path, + ))); }), PendingOp::ImportBackup { text } => sender.spawn_command(move |out| { let _ = out.send(OpResult::Generic(ops::import_backup( @@ -1690,62 +1808,48 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender()) - .unwrap_or_default(); - w.backup_label.set_text(&backup_text); // A programmatic reset (factory reset, discarding a cancelled // identity) clears these in the model; push that once into the @@ -1835,7 +1939,7 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender { let alert = adw::AlertDialog::new( Some("Rotate the identity?"), - Some("The old key stays derivable from the seed, but the rotation index never moves back."), + Some("Issues a fresh key from your secret with a signed certificate proving it replaces this one. Contacts accept the change from that chain, and the old key stays readable. Rotation is not revocation."), ); alert.add_response("cancel", "Cancel"); alert.add_response("rotate", "Rotate"); @@ -1844,11 +1948,11 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender { let alert = adw::AlertDialog::new( - Some("Factory reset this device?"), - Some("The seed key, the sealed store, contacts, server pins, drafts and read marks are erased here. Mail already on the server stays there — without the seed it cannot be read back."), + Some("Log out and delete all data?"), + Some("Removes this identity from this client entirely: the secret, pins, contacts and every cached message. Without a backup of the secret there is no way back in. Mail already fetched here may have no other copy anywhere, unless \"leave mail on server\" was on. Whatever is still on the server comes back on the first fetch after logging back in."), ); alert.add_response("cancel", "Cancel"); - alert.add_response("reset", "Factory reset"); + alert.add_response("reset", "Log Out"); alert.set_response_appearance("reset", adw::ResponseAppearance::Destructive); alert }