feat: split settings into Identity, Server, Backup and Advanced tabs

This commit is contained in:
randogoth 2026-10-08 20:46:17 +03:00
parent 2892ea3fa7
commit 743c388cfb
4 changed files with 365 additions and 288 deletions

View file

@ -7,7 +7,8 @@ license = "Apache-2.0"
[dependencies] [dependencies]
fumi-core = { path = "../fumi/core" } 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, # 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. # since Cargo unions features and the rows and dialogs used here are 1.5.
libadwaita = { version = "0.7", features = ["v1_5"] } libadwaita = { version = "0.7", features = ["v1_5"] }

View file

@ -159,10 +159,6 @@ pub enum Message {
SignupRegister, SignupRegister,
SignupRecall, SignupRecall,
// Pin offer (signup and settings share it)
PinAccept,
PinDiscard,
// Mail // Mail
GotoMail(Folder), GotoMail(Folder),
Goto(Page), Goto(Page),
@ -202,19 +198,17 @@ pub enum Message {
ContactAccept(String, String), ContactAccept(String, String),
ContactBlock(String), ContactBlock(String),
ContactUnblock(String), ContactUnblock(String),
CopyExport,
// Settings // Settings
SettingsLearnKey, SecretRevealToggled,
CopySecret,
KeepMailChanged(bool), KeepMailChanged(bool),
TimeoutChanged(String), TimeoutChanged(String),
RotatePressed, RotatePressed,
RotateConfirmed, RotateConfirmed,
RotateCancelled, RotateCancelled,
UnpinPressed(String), ExportBackupTo(PathBuf),
ExportPressed, ImportBackupFrom(String),
ImportBodyChanged(String),
ImportPressed,
ResetPressed, ResetPressed,
ResetConfirmed, ResetConfirmed,
ResetCancelled, ResetCancelled,
@ -229,8 +223,6 @@ pub enum OpResult {
/// The key a server presented with no pin in place (sec 4 tofu). /// The key a server presented with no pin in place (sec 4 tofu).
PinOffered(Result<Option<(Address, [u8; KEY_LEN])>, String>), PinOffered(Result<Option<(Address, [u8; KEY_LEN])>, String>),
Pinned(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 /// One background operation to run on the blocking pool, carrying every
@ -280,11 +272,8 @@ pub enum PendingOp {
uri: String, uri: String,
}, },
Rotate, Rotate,
Unpin {
label: String,
},
Export { Export {
dir: PathBuf, path: PathBuf,
}, },
ImportBackup { ImportBackup {
text: String, text: String,
@ -322,8 +311,8 @@ pub struct Carrier {
pub drafts: Vec<Draft>, pub drafts: Vec<Draft>,
pub snapshot: Option<Snapshot>, pub snapshot: Option<Snapshot>,
pub pin_offer: Option<(Address, [u8; KEY_LEN])>, pub pin_offer: Option<(Address, [u8; KEY_LEN])>,
pub backup_text: Option<String>, /// The Identity tab's recovery secret is masked until this is toggled.
pub import_body: String, pub secret_revealed: bool,
pub contact_import: String, pub contact_import: String,
pub dialog: Option<Dialog>, pub dialog: Option<Dialog>,
/// The view syncs entry text from the model once after a prefill /// The view syncs entry text from the model once after a prefill
@ -389,8 +378,7 @@ impl Carrier {
drafts, drafts,
snapshot: None, snapshot: None,
pin_offer: None, pin_offer: None,
backup_text: None, secret_revealed: false,
import_body: String::new(),
contact_import: String::new(), contact_import: String::new(),
dialog: None, dialog: None,
compose_fresh: Cell::new(false), compose_fresh: Cell::new(false),
@ -480,7 +468,7 @@ impl Carrier {
} }
let (app, _) = Carrier::boot(self.dir.clone()); let (app, _) = Carrier::boot(self.dir.clone());
*self = app; *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 /// 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<Address> {
self.store.account().ok().flatten()
}
/// `user@host` as typed on the signup page, defaulting the port. /// `user@host` as typed on the signup page, defaulting the port.
fn signup_address(&self) -> Option<Address> { fn signup_address(&self) -> Option<Address> {
let user = self.signup.user.trim(); let user = self.signup.user.trim();
@ -656,9 +639,6 @@ impl Carrier {
} }
} }
} }
Message::ImportBodyChanged(text) => {
self.import_body = text;
}
Message::ContactSelected(address) => { Message::ContactSelected(address) => {
self.stash_draft(); self.stash_draft();
self.selected_own = false; self.selected_own = false;
@ -709,6 +689,9 @@ impl Carrier {
Message::KeepMailChanged(keep) => { Message::KeepMailChanged(keep) => {
self.keep_mail = keep; self.keep_mail = keep;
} }
Message::SecretRevealToggled => {
self.secret_revealed = !self.secret_revealed;
}
Message::TimeoutChanged(text) => { Message::TimeoutChanged(text) => {
if let Ok(timeout) = text.trim().parse::<u64>() { if let Ok(timeout) = text.trim().parse::<u64>() {
self.timeout = timeout; self.timeout = timeout;
@ -790,21 +773,6 @@ impl Carrier {
}; };
return vec![PendingOp::LearnKey { addr }]; 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 => { Message::SignupRegister => {
let Some(addr) = self.signup_address() else { let Some(addr) = self.signup_address() else {
self.fail("enter user and host".into()); self.fail("enter user and host".into());
@ -1102,11 +1070,10 @@ impl Carrier {
}; };
return vec![PendingOp::Accept { address, identity }]; return vec![PendingOp::Accept { address, identity }];
} }
Message::CopyExport => { Message::CopySecret => {
if let Some(text) = &self.backup_text { let text = ops::seed_text(&self.master());
if let Some(display) = gtk::gdk::Display::default() { if let Some(display) = gtk::gdk::Display::default() {
display.clipboard().set_text(text); display.clipboard().set_text(&text);
}
} }
} }
Message::RotatePressed => { Message::RotatePressed => {
@ -1129,20 +1096,14 @@ impl Carrier {
self.dialog = None; self.dialog = None;
return vec![PendingOp::Rotate]; return vec![PendingOp::Rotate];
} }
Message::UnpinPressed(label) => { Message::ExportBackupTo(path) => {
return vec![PendingOp::Unpin { label }]; return vec![PendingOp::Export { path }];
} }
Message::ExportPressed => { Message::ImportBackupFrom(text) => {
let dir = self.dir.clone(); if text.trim().is_empty() {
return vec![PendingOp::Export { dir }];
}
Message::ImportPressed => {
if self.import_body.trim().is_empty() {
return Vec::new(); return Vec::new();
} }
return vec![PendingOp::ImportBackup { return vec![PendingOp::ImportBackup { text }];
text: self.import_body.clone(),
}];
} }
} }
Vec::new() Vec::new()
@ -1211,17 +1172,6 @@ impl Carrier {
self.fail(e); self.fail(e);
None 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); 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 /// A factory reset asks first, then erases every file in the config
/// directory and returns the model to onboarding, as if the app had /// directory and returns the model to onboarding, as if the app had
/// never run. /// never run.

View file

@ -3,17 +3,17 @@
//! values these functions return. //! values these functions return.
use std::collections::HashSet; use std::collections::HashSet;
use std::path::{Path, PathBuf}; use std::path::Path;
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use std::sync::Arc; 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::client::{FetchOptions, TrustChange};
use fumi_core::crypto::{b32, fingerprint, unb32, KEY_LEN}; use fumi_core::crypto::{b32, fingerprint, unb32, KEY_LEN};
use fumi_core::error::Error; use fumi_core::error::Error;
use fumi_core::message::{FRONTMATTER_KEYS, FRONTMATTER_MAX}; use fumi_core::message::{FRONTMATTER_KEYS, FRONTMATTER_MAX};
use fumi_core::store::Store; 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. /// One message in a folder list, already opened for its metadata.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -57,8 +57,10 @@ pub struct Contact {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Who { pub struct Who {
pub address: String, pub address: String,
pub host: String,
pub key: String, pub key: String,
pub fingerprint: String, pub fingerprint: String,
pub uri: String,
pub rotations: u32, pub rotations: u32,
} }
@ -375,19 +377,6 @@ pub fn pin(store: &Store, addr: &Address, key: &[u8; KEY_LEN]) -> Result<(), Str
.map_err(explain) .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<Vec<(String, [u8; KEY_LEN])>, String> { pub fn pins(store: &Store) -> Result<Vec<(String, [u8; KEY_LEN])>, String> {
store.pins().map_err(explain) store.pins().map_err(explain)
} }
@ -621,16 +610,11 @@ pub fn rotate(
Ok((store.rotations().map_err(explain)?, b32(&rotated.new_pk))) Ok((store.rotations().map_err(explain)?, b32(&rotated.new_pk)))
} }
/// Writes the gsmol backup beside the store and returns it for the clipboard. /// Writes the gsmol backup to the chosen path.
pub fn export_backup( pub fn export_backup(store: &Store, master: &[u8; KEY_LEN], path: &Path) -> Result<String, String> {
store: &Store,
master: &[u8; KEY_LEN],
dir: &Path,
) -> Result<(PathBuf, String), String> {
let text = export::export(store, master).map_err(explain)?; 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()))?;
std::fs::write(&path, &text).map_err(|e| format!("{}: {e}", path.display()))?; Ok(format!("backup written to {}", path.display()))
Ok((path, text))
} }
pub fn import_backup(store: &Store, master: &[u8; KEY_LEN], text: &str) -> Result<String, String> { pub fn import_backup(store: &Store, master: &[u8; KEY_LEN], text: &str) -> Result<String, String> {
@ -733,15 +717,26 @@ pub fn open(
pub fn whoami(store: &Store, master: &[u8; KEY_LEN]) -> Result<Who, String> { pub fn whoami(store: &Store, master: &[u8; KEY_LEN]) -> Result<Who, String> {
let me = account(store, master)?; let me = account(store, master)?;
let address = store let account = store.account().map_err(explain)?;
.account() let pk = me.me().pk();
.map_err(explain)? let address = account
.as_ref()
.map(|addr| addr.short()) .map(|addr| addr.short())
.unwrap_or_default(); .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 { Ok(Who {
address, address,
key: b32(&me.me().pk()), host,
fingerprint: fingerprint(&me.me().pk()), key: b32(&pk),
fingerprint: fingerprint(&pk),
uri,
rotations: me.index(), rotations: me.index(),
}) })
} }
@ -1018,8 +1013,11 @@ mod tests {
.store_inbox(&id, &envelope, now(), false, false) .store_inbox(&id, &envelope, now(), false, false)
.unwrap(); .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!(path.exists());
assert!(note.contains(&path.display().to_string()));
let text = std::fs::read_to_string(&path).unwrap();
assert!(!text.is_empty()); assert!(!text.is_empty());
let fresh = setup(); let fresh = setup();
@ -1032,17 +1030,14 @@ mod tests {
} }
#[test] #[test]
fn unpin_splits_the_label() { fn pin_lists_under_the_host_port_label() {
let store = setup(); let store = setup();
let addr = Address::parse("me@localhost:1962").unwrap(); let addr = Address::parse("me@localhost:1962").unwrap();
let key = master(); let key = master();
store.pin_server(&addr.host, addr.port, &key).unwrap();
assert_eq!(pin(&store, &addr, &key), Ok(())); assert_eq!(pin(&store, &addr, &key), Ok(()));
let all = pins(&store).unwrap(); let all = pins(&store).unwrap();
assert_eq!(all.len(), 1); assert_eq!(all.len(), 1);
assert_eq!(all[0].0, "localhost:1962"); 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 /// The dev-build demo seed grows a mail that carries the sender's own

View file

@ -221,14 +221,14 @@ pub struct CarrierWidgets {
detail_qr: gtk::Image, detail_qr: gtk::Image,
// settings page // settings page
identity_values: Vec<gtk::Label>, identity_rows: Vec<adw::ActionRow>,
offer_group: adw::PreferencesGroup, secret_row: adw::ActionRow,
offer_row: adw::ActionRow, secret_reveal: gtk::Button,
pins_group: adw::PreferencesGroup, host_row: adw::ActionRow,
pin_rows: Vec<adw::ActionRow>, public_key_row: adw::ActionRow,
keep_mail_row: adw::SwitchRow, rotations_value: gtk::Label,
keep_mail_check: gtk::CheckButton,
timeout_row: adw::SpinRow, timeout_row: adw::SpinRow,
backup_label: gtk::Label,
// onboarding // onboarding
onboard_seed_label: gtk::Label, onboard_seed_label: gtk::Label,
@ -603,128 +603,216 @@ impl CarrierWidgets {
compose.append(&compose_actions); compose.append(&compose_actions);
stack.add_named(&compose, Some("compose")); stack.add_named(&compose, Some("compose"));
// settings: the preferences page every GNOME app shows, one group // settings: a tabbed preferences page — Identity, Server, Backup,
// per concern, so scrolling, width and margins come for free. // Advanced — switched by an adw::ViewSwitcher over an
let settings = adw::PreferencesPage::new(); // adw::ViewStack, each tab its own PreferencesPage.
let settings_stack = adw::ViewStack::new();
let identity_group = adw::PreferencesGroup::builder().title("Identity").build(); // Read-only values use Adwaita's "property" row: the field name
let identity_actions = gtk::Box::new(Orientation::Horizontal, 6); // sits small above the value it labels, which leaves the value
let rotate_button = icon_label_button("view-refresh-symbolic", "Rotate", ""); // itself room to wrap instead of being ellipsized into a suffix.
let learn_button = button("Learn key", ""); // Markup is off so the copy button can hand back exactly what is
identity_actions.append(&learn_button); // on screen.
identity_actions.append(&rotate_button); let property_row = |title: &str| -> adw::ActionRow {
identity_group.set_header_suffix(Some(&identity_actions)); let row = adw::ActionRow::builder()
let mut identity_values = Vec::new(); .title(title)
for title in ["Address", "Key", "Fingerprint", "Rotations"] { .use_markup(false)
let row = adw::ActionRow::builder().title(title).build(); .subtitle_lines(0)
let value = label("", "mono dim"); .subtitle_selectable(true)
value.set_selectable(true); .build();
value.set_ellipsize(gtk::pango::EllipsizeMode::End); row.add_css_class("property");
value.set_max_width_chars(48); row.add_css_class("monospace");
value.set_valign(Align::Center); let copy = icon_button(
row.add_suffix(&value); "edit-copy-symbolic",
identity_group.add(&row); &format!("Copy the {}", title.to_lowercase()),
identity_values.push(value); );
let copy_source = row.clone();
copy.connect_clicked(move |_| {
if let Some(display) = gtk::gdk::Display::default() {
display
.clipboard()
.set_text(&copy_source.subtitle().unwrap_or_default());
} }
settings.add(&identity_group); });
row.add_suffix(&copy);
row
};
// The pin offer is a group that appears in place rather than a // -- Identity tab --------------------------------------------------
// dialog: pinning is a decision about this host, taken next to the // The switcher already names each page, so the first group on a
// identity it belongs to. // page carries no title of its own.
let offer_group = adw::PreferencesGroup::builder() let identity_page = adw::PreferencesPage::new();
.title("Server key offered") let identity_group = adw::PreferencesGroup::new();
.visible(false) let mut identity_rows = Vec::new();
for title in ["Address", "Key", "Fingerprint", "URI"] {
let row = property_row(title);
identity_group.add(&row);
identity_rows.push(row);
}
identity_page.add(&identity_group);
// 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(); .build();
let offer_row = adw::ActionRow::builder().title("").build(); let secret_row = adw::ActionRow::builder()
offer_row.add_css_class("monospace"); .use_markup(false)
offer_row.set_subtitle_lines(2); .title_lines(0)
let offer_pin = button("Pin", "accent"); .title_selectable(true)
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")
.build(); .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(); // -- Server tab ----------------------------------------------------
let keep_mail_row = adw::SwitchRow::builder() let server_page = adw::PreferencesPage::new();
.title("Keep mail on the server") let server_group = adw::PreferencesGroup::builder().title("Connection").build();
.subtitle("Off: a fetch removes mail from the server once it's stored here.") let host_row = property_row("Host");
.build(); let public_key_row = property_row("Public Key");
fetch_group.add(&keep_mail_row); server_group.add(&host_row);
settings.add(&fetch_group); server_group.add(&public_key_row);
let network_group = adw::PreferencesGroup::builder().title("Network").build();
let timeout_row = adw::SpinRow::with_range(1.0, 600.0, 1.0); let timeout_row = adw::SpinRow::with_range(1.0, 600.0, 1.0);
timeout_row.set_title("Timeout"); timeout_row.set_title("Timeout");
timeout_row.set_subtitle("Seconds before a network call gives up"); timeout_row.set_subtitle("Seconds before a network call gives up");
network_group.add(&timeout_row); server_group.add(&timeout_row);
settings.add(&network_group); 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() let backup_group = adw::PreferencesGroup::builder()
.title("Backup")
.description( .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(); .build();
let backup_actions = gtk::Box::new(Orientation::Horizontal, 6); let export_row = adw::ActionRow::builder()
let export_button = icon_label_button("document-save-symbolic", "Export", ""); .title("Export")
let copy_export = icon_button("edit-copy-symbolic", "Copy the backup"); .subtitle("Write a backup file")
backup_actions.append(&copy_export); .activatable(true)
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.")
.build(); .build();
let import_view = gtk::TextView::new(); export_row.add_prefix(&gtk::Image::from_icon_name("document-save-symbolic"));
import_view.set_wrap_mode(WrapMode::WordChar); export_row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic"));
import_view.set_top_margin(8); let import_row = adw::ActionRow::builder()
import_view.set_bottom_margin(8); .title("Import")
import_view.set_left_margin(8); .subtitle("Restore from a backup file")
import_view.set_right_margin(8); .activatable(true)
import_view.add_css_class("monospace"); .build();
let import_body = import_view.buffer(); import_row.add_prefix(&gtk::Image::from_icon_name("document-open-symbolic"));
let import_scroll = gtk::ScrolledWindow::new(); import_row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic"));
import_scroll.add_css_class("card"); backup_group.add(&export_row);
import_scroll.set_height_request(96); backup_group.add(&import_row);
import_scroll.set_child(Some(&import_view)); backup_page.add(&backup_group);
import_group.add(&import_scroll); settings_stack.add_titled_with_icon(
let import_backup = icon_label_button("document-open-symbolic", "Restore", ""); &backup_page,
import_backup.set_halign(Align::End); Some("backup"),
import_backup.set_margin_top(6); "Backup",
import_group.add(&import_backup); "document-save-symbolic",
settings.add(&import_group); );
// The reset closes the page list: nothing after it survives a // -- Advanced tab --------------------------------------------------
// wipe, so it sits last, marked destructive like the delete. let advanced_page = adw::PreferencesPage::new();
let reset_group = adw::PreferencesGroup::builder() let rotations_group = adw::PreferencesGroup::builder()
.title("Factory reset") .title("Rotations")
.description( .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(); .build();
let reset_button = icon_label_button("user-trash-symbolic", "Factory reset", "danger"); // Both actions sit in their group's body, centred under the
reset_button.set_valign(Align::Center); // explanation: a long description wraps under the group title and
reset_group.set_header_suffix(Some(&reset_button)); // would run straight into a header-suffix button.
settings.add(&reset_group); 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")); stack.add_named(&settings, Some("settings"));
@ -1013,7 +1101,6 @@ impl CarrierWidgets {
}); });
own_row.connect_clicked(input(Message::OwnSelected)); own_row.connect_clicked(input(Message::OwnSelected));
copy_export.connect_clicked(input(Message::CopyExport));
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| {
@ -1026,22 +1113,57 @@ impl CarrierWidgets {
rotate_button.connect_clicked(input(Message::RotatePressed)); rotate_button.connect_clicked(input(Message::RotatePressed));
reset_button.connect_clicked(input(Message::ResetPressed)); reset_button.connect_clicked(input(Message::ResetPressed));
learn_button.connect_clicked(input(Message::SettingsLearnKey)); secret_reveal.connect_clicked(input(Message::SecretRevealToggled));
offer_pin.connect_clicked(input(Message::PinAccept)); secret_copy.connect_clicked(input(Message::CopySecret));
offer_discard.connect_clicked(input(Message::PinDiscard));
export_button.connect_clicked(input(Message::ExportPressed));
import_backup.connect_clicked(input(Message::ImportPressed));
let timeout_sender = sender.clone(); let timeout_sender = sender.clone();
timeout_row.connect_value_notify(move |row| { timeout_row.connect_value_notify(move |row| {
timeout_sender.input(Message::TimeoutChanged((row.value() as u64).to_string())) timeout_sender.input(Message::TimeoutChanged((row.value() as u64).to_string()))
}); });
let keep_mail_sender = sender.clone(); let keep_mail_sender = sender.clone();
keep_mail_row.connect_active_notify(move |row| { keep_mail_check.connect_toggled(move |check| {
keep_mail_sender.input(Message::KeepMailChanged(row.is_active())) keep_mail_sender.input(Message::KeepMailChanged(check.is_active()))
}); });
let import_body_sender = sender.clone(); // Export/Import go straight to a native file dialog rather than a
import_body.connect_changed(move |buffer| { // Message — the chosen path only matters for this one call, and
import_body_sender.input(Message::ImportBodyChanged(buffer_text(buffer))) // 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)); create_button.connect_clicked(input(Message::OnboardCreate));
@ -1121,14 +1243,14 @@ impl CarrierWidgets {
detail_share, detail_share,
detail_copy, detail_copy,
detail_qr, detail_qr,
identity_values, identity_rows,
offer_group, secret_row,
offer_row, secret_reveal,
pins_group, host_row,
pin_rows: Vec::new(), public_key_row,
keep_mail_row, rotations_value,
keep_mail_check,
timeout_row, timeout_row,
backup_label,
onboard_seed_label, onboard_seed_label,
onboard_seed_entry, onboard_seed_entry,
signup_key_button, signup_key_button,
@ -1314,14 +1436,10 @@ fn spawn_op(model: &Carrier, op: PendingOp, sender: &ComponentSender<Carrier>) {
.map(|(index, key)| format!("rotated to index {index}; new key {}…", &key[..12])); .map(|(index, key)| format!("rotated to index {index}; new key {}…", &key[..12]));
let _ = out.send(OpResult::Generic(res)); let _ = out.send(OpResult::Generic(res));
}), }),
PendingOp::Unpin { label } => sender.spawn_command(move |out| { PendingOp::Export { path } => sender.spawn_command(move |out| {
let res = ops::unpin(&store, &label).map(|_| format!("unpinned {label}")); let _ = out.send(OpResult::Generic(ops::export_backup(
let _ = out.send(OpResult::Generic(res)); &store, &master, &path,
}), )));
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::ImportBackup { text } => sender.spawn_command(move |out| { PendingOp::ImportBackup { text } => sender.spawn_command(move |out| {
let _ = out.send(OpResult::Generic(ops::import_backup( let _ = out.send(OpResult::Generic(ops::import_backup(
@ -1690,62 +1808,48 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
); );
} }
// Settings: the identity rows are fixed, so only their values change. // Settings: the identity/server rows are fixed, so only their values
// change.
if let Some(snap) = &model.snapshot { if let Some(snap) = &model.snapshot {
let values = [ let values = [
snap.who.address.clone(), snap.who.address.clone(),
snap.who.key.clone(), snap.who.key.clone(),
snap.who.fingerprint.clone(), snap.who.fingerprint.clone(),
snap.who.rotations.to_string(), snap.who.uri.clone(),
]; ];
for (value, text) in w.identity_values.iter().zip(values) { for (row, text) in w.identity_rows.iter().zip(values) {
value.set_text(&text); row.set_subtitle(&text);
} }
w.host_row.set_subtitle(&snap.who.host);
let public_key = snap
.pins
.first()
.map(|(_, key)| b32(key))
.unwrap_or_else(|| "not pinned yet".to_string());
w.public_key_row.set_subtitle(&public_key);
w.rotations_value.set_text(&snap.who.rotations.to_string());
} }
if let Some((_, key)) = &model.pin_offer { // The recovery secret is masked until revealed; the copy button
w.offer_row.set_title(&escape(&fingerprint(key))); // always copies the real value (see Message::CopySecret), not
w.offer_row.set_subtitle(&escape(&b32(key))); // whatever's currently on screen.
w.offer_group.set_visible(true); let secret = ops::seed_text(&model.master.unwrap_or([0u8; KEY_LEN]));
if model.secret_revealed {
w.secret_row.set_title(&secret);
w.secret_reveal.set_icon_name("view-conceal-symbolic");
w.secret_reveal
.set_tooltip_text(Some("Hide the recovery secret"));
} else { } else {
w.offer_group.set_visible(false); w.secret_row.set_title(&"•".repeat(secret.chars().count()));
w.secret_reveal.set_icon_name("view-reveal-symbolic");
w.secret_reveal
.set_tooltip_text(Some("Reveal the recovery secret"));
} }
if w.keep_mail_row.is_active() != model.keep_mail { if w.keep_mail_check.is_active() != model.keep_mail {
w.keep_mail_row.set_active(model.keep_mail); w.keep_mail_check.set_active(model.keep_mail);
} }
if w.timeout_row.value() as u64 != model.timeout { if w.timeout_row.value() as u64 != model.timeout {
w.timeout_row.set_value(model.timeout as f64); w.timeout_row.set_value(model.timeout as f64);
} }
for row in std::mem::take(&mut w.pin_rows) {
w.pins_group.remove(&row);
}
if let Some(snap) = &model.snapshot {
if snap.pins.is_empty() {
let empty = adw::ActionRow::builder()
.title("No server keys pinned")
.build();
w.pins_group.add(&empty);
w.pin_rows.push(empty);
}
for (pin_label, key) in &snap.pins {
let row = adw::ActionRow::builder()
.title(escape(pin_label))
.subtitle(escape(&b32(key)))
.build();
row.set_subtitle_lines(1);
let unpin = icon_button("user-trash-symbolic", "Unpin this server key");
unpin.add_css_class("destructive-action");
unpin.connect_clicked(input(Message::UnpinPressed(pin_label.clone())));
row.add_suffix(&unpin);
w.pins_group.add(&row);
w.pin_rows.push(row);
}
}
let backup_text = model
.backup_text
.as_ref()
.map(|text| text.chars().take(2000).collect::<String>())
.unwrap_or_default();
w.backup_label.set_text(&backup_text);
// A programmatic reset (factory reset, discarding a cancelled // A programmatic reset (factory reset, discarding a cancelled
// identity) clears these in the model; push that once into the // identity) clears these in the model; push that once into the
@ -1835,7 +1939,7 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
Dialog::Rotate => { Dialog::Rotate => {
let alert = adw::AlertDialog::new( let alert = adw::AlertDialog::new(
Some("Rotate the identity?"), 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("cancel", "Cancel");
alert.add_response("rotate", "Rotate"); alert.add_response("rotate", "Rotate");
@ -1844,11 +1948,11 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
} }
Dialog::Reset => { Dialog::Reset => {
let alert = adw::AlertDialog::new( let alert = adw::AlertDialog::new(
Some("Factory reset this device?"), Some("Log out and delete all data?"),
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("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("cancel", "Cancel");
alert.add_response("reset", "Factory reset"); alert.add_response("reset", "Log Out");
alert.set_response_appearance("reset", adw::ResponseAppearance::Destructive); alert.set_response_appearance("reset", adw::ResponseAppearance::Destructive);
alert alert
} }