feat: build carrier, a minimalist Iced smolmail client on fumi-core
This commit is contained in:
commit
e9e40bca36
12 changed files with 7698 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/target
|
||||
result
|
||||
result-*
|
||||
*.key
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
4706
Cargo.lock
generated
Normal file
4706
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
15
Cargo.toml
Normal file
15
Cargo.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
[package]
|
||||
name = "carrier"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "A minimalist smolmail client built on fumi-core, with an Iced UI"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[dependencies]
|
||||
fumi-core = { path = "../fumi/core" }
|
||||
iced = { version = "0.13", features = ["tokio", "qr_code"] }
|
||||
tokio = { version = "1", features = ["rt"] }
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
40
README.md
Normal file
40
README.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# carrier
|
||||
|
||||
A minimalist desktop client for [Smol Mail](../smolmail): one compact window — the list above, the article below — built on [fumi-core](../fumi), the reference CLI's own library, with an [Iced](https://iced.rs) UI. One 32-byte seed is the only secret; mail stays sealed at rest and opens on demand.
|
||||
|
||||
## What it does
|
||||
|
||||
- Create or restore an identity from a seed, shown once at creation time.
|
||||
- Pin a server's key — paste the operator's, or connect and pin what it presents — then register a username or recall an existing address.
|
||||
- Fetch (with cancel), read, reply and send sealed mail; the inbox, the requests tier and sent copies each keep their own folder.
|
||||
- Accept or block a requester when their mail carried a self-certifying reply address; accept tokens attach to accepted contacts automatically.
|
||||
- Contacts with verified/tofu marks and smol:// import; your own address as a URI, a QR code and a clipboard line.
|
||||
- Key rotation with a confirm-first step, server pin management, a network timeout, and gsmol backup export/import that never contains the seed.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/main.rs config directory, iced launch
|
||||
src/app.rs state and update; blocking calls run on the worker pool
|
||||
src/view.rs the compact split view: tab bar, list, article, status line
|
||||
src/ops.rs fumi-core calls, key/read-mark files, and the offline tests
|
||||
src/theme.rs the palette: navy surfaces, mint accent, monospace protocol values
|
||||
```
|
||||
|
||||
## State
|
||||
|
||||
Everything lives under `$XDG_CONFIG_HOME/carrier` (default `~/.config/carrier`): `carrier.key` is the master seed, written 0600; `carrier.db` is fumi-core's sealed store; `read.txt` is carrier's own read marks, one hex id per line.
|
||||
|
||||
## Run and verify
|
||||
|
||||
```
|
||||
devbox run run # build release and launch
|
||||
devbox run check # fmt --check, build, tests
|
||||
devbox run test # tests only
|
||||
```
|
||||
|
||||
The tests run offline against an in-memory store: seed and key parsing, a sealed envelope listed and opened end to end, contact import, a full backup round-trip, pin labels and the date formatter. `scripts/devbox-libs.sh` puts the GUI stack's runtime libraries (wayland, xkbcommon, EGL, vulkan) on the library path inside the devbox shell — they are dlopened, so nothing that links the binary can provide them.
|
||||
|
||||
## Status
|
||||
|
||||
Work in progress: the UI is unexercised against a live server — the store, crypto and address paths are covered by tests, the network flows (register, fetch, send, rotate) are wired to fumi-core's operations but only smoke-tested as far as launching the window.
|
||||
36
devbox.json
Normal file
36
devbox.json
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.17.2/.schema/devbox.schema.json",
|
||||
"packages": [
|
||||
"rustc@latest",
|
||||
"cargo@latest",
|
||||
"pkg-config@latest",
|
||||
"rustfmt@latest",
|
||||
"nixpkgs#wayland",
|
||||
"nixpkgs#libxkbcommon",
|
||||
"nixpkgs#mesa",
|
||||
"nixpkgs#libGL",
|
||||
"nixpkgs#vulkan-loader"
|
||||
],
|
||||
"shell": {
|
||||
"init_hook": [
|
||||
". \"$PWD/scripts/devbox-libs.sh\""
|
||||
],
|
||||
"scripts": {
|
||||
"build": [
|
||||
"cargo build --release"
|
||||
],
|
||||
"run": [
|
||||
"cargo build --release && ./target/release/carrier"
|
||||
],
|
||||
"dev": [
|
||||
"cargo build && ./target/debug/carrier"
|
||||
],
|
||||
"test": [
|
||||
"cargo test"
|
||||
],
|
||||
"check": [
|
||||
"cargo fmt --check && cargo build && cargo test"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
255
devbox.lock
Normal file
255
devbox.lock
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
{
|
||||
"lockfile_version": "1",
|
||||
"packages": {
|
||||
"cargo@latest": {
|
||||
"last_modified": "2026-08-27T07:16:00Z",
|
||||
"resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#cargo",
|
||||
"source": "devbox-search",
|
||||
"version": "1.97.1",
|
||||
"systems": {
|
||||
"aarch64-darwin": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/cvd2pzmgq4pr7kkzx2zmya47fqd70sqa-cargo-1.97.1",
|
||||
"default": true
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/cvd2pzmgq4pr7kkzx2zmya47fqd70sqa-cargo-1.97.1"
|
||||
},
|
||||
"aarch64-linux": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/z64fp7fjfy9zr3xn7prdidi4gww213d5-cargo-1.97.1",
|
||||
"default": true
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/z64fp7fjfy9zr3xn7prdidi4gww213d5-cargo-1.97.1"
|
||||
},
|
||||
"x86_64-linux": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/dv55qwamlq68hdiprg1v104ymwh1c27r-cargo-1.97.1",
|
||||
"default": true
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/dv55qwamlq68hdiprg1v104ymwh1c27r-cargo-1.97.1"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github:NixOS/nixpkgs/nixpkgs-unstable": {
|
||||
"last_modified": "2026-09-29T03:29:29Z",
|
||||
"resolved": "github:NixOS/nixpkgs/b6c8664de9b6cc07fe5666a29f91884ba81197c4?lastModified=1790652569&narHash=sha256-641r7xrlSOHYoktL9%2FaArPIYNgZJ9eM5URzSEOtPJDY%3D"
|
||||
},
|
||||
"nixpkgs#libGL": {
|
||||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#libGL"
|
||||
},
|
||||
"nixpkgs#libxkbcommon": {
|
||||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#libxkbcommon"
|
||||
},
|
||||
"nixpkgs#mesa": {
|
||||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#mesa"
|
||||
},
|
||||
"nixpkgs#vulkan-loader": {
|
||||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#vulkan-loader"
|
||||
},
|
||||
"nixpkgs#wayland": {
|
||||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#wayland"
|
||||
},
|
||||
"pkg-config@latest": {
|
||||
"last_modified": "2025-11-23T21:50:36Z",
|
||||
"resolved": "github:NixOS/nixpkgs/ee09932cedcef15aaf476f9343d1dea2cb77e261#pkg-config",
|
||||
"source": "devbox-search",
|
||||
"version": "0.29.2",
|
||||
"systems": {
|
||||
"aarch64-darwin": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/hygaaqwk9ylklp0ybwppqhw75nz8ya41-pkg-config-wrapper-0.29.2",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "man",
|
||||
"path": "/nix/store/9px0sji43x3r2w4zxl3j3idwsql7lwxx-pkg-config-wrapper-0.29.2-man",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "doc",
|
||||
"path": "/nix/store/hqk44ra6qxw7iixardl6c3hdgb9kq6ns-pkg-config-wrapper-0.29.2-doc"
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/hygaaqwk9ylklp0ybwppqhw75nz8ya41-pkg-config-wrapper-0.29.2"
|
||||
},
|
||||
"aarch64-linux": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/lbiigi8qbp7mzf1lpr7p982l1kyf01ql-pkg-config-wrapper-0.29.2",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "man",
|
||||
"path": "/nix/store/10060k24qggqyzlwdsfmni9y32zxcg0j-pkg-config-wrapper-0.29.2-man",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "doc",
|
||||
"path": "/nix/store/0y4v51ndpyvkj09hwlfqkz0c3h17zfmc-pkg-config-wrapper-0.29.2-doc"
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/lbiigi8qbp7mzf1lpr7p982l1kyf01ql-pkg-config-wrapper-0.29.2"
|
||||
},
|
||||
"x86_64-darwin": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/vknadizq0q5kffvx6y4379p9gdry9zq3-pkg-config-wrapper-0.29.2",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "man",
|
||||
"path": "/nix/store/1nyspra675q22gfhf7hn2nmfpi6rgim5-pkg-config-wrapper-0.29.2-man",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "doc",
|
||||
"path": "/nix/store/7lq1axxwrafwljs06n88bzyz9w523rkc-pkg-config-wrapper-0.29.2-doc"
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/vknadizq0q5kffvx6y4379p9gdry9zq3-pkg-config-wrapper-0.29.2"
|
||||
},
|
||||
"x86_64-linux": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/8vdiwpbh0g4avsd6x5v4s0di32vcl3dp-pkg-config-wrapper-0.29.2",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "man",
|
||||
"path": "/nix/store/j9xfpnrygg3v37svc5pfin9q5bm49r94-pkg-config-wrapper-0.29.2-man",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "doc",
|
||||
"path": "/nix/store/x3bypxdxaq20kykybhkf21x4jczsiy8y-pkg-config-wrapper-0.29.2-doc"
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/8vdiwpbh0g4avsd6x5v4s0di32vcl3dp-pkg-config-wrapper-0.29.2"
|
||||
}
|
||||
}
|
||||
},
|
||||
"rustc@latest": {
|
||||
"last_modified": "2026-08-27T07:16:00Z",
|
||||
"plugin_version": "0.0.1",
|
||||
"resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#rustc",
|
||||
"source": "devbox-search",
|
||||
"version": "1.97.1",
|
||||
"systems": {
|
||||
"aarch64-darwin": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/swrk83krgf2iaqd7a1bs0pg1sinlz5ds-rustc-wrapper-1.97.1",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "man",
|
||||
"path": "/nix/store/grw7ifdixkdr4ya8bw5x0psjyfr5azva-rustc-wrapper-1.97.1-man",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "doc",
|
||||
"path": "/nix/store/8qkha38sic70l51g4l7dmjrxnzqjrr6w-rustc-wrapper-1.97.1-doc"
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/swrk83krgf2iaqd7a1bs0pg1sinlz5ds-rustc-wrapper-1.97.1"
|
||||
},
|
||||
"aarch64-linux": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/d6bnbpk6jr95frzmmksda4r7l3v8d3m6-rustc-wrapper-1.97.1",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "man",
|
||||
"path": "/nix/store/hn340n5pi9p30j4bcxgzqgq8lmclg1by-rustc-wrapper-1.97.1-man",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "doc",
|
||||
"path": "/nix/store/avbwjkw896rb3nl28kh8qv6kxyq5b9v2-rustc-wrapper-1.97.1-doc"
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/d6bnbpk6jr95frzmmksda4r7l3v8d3m6-rustc-wrapper-1.97.1"
|
||||
},
|
||||
"x86_64-linux": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/yaa1sq5g4jar7rvlspy7kk2wd98nj6qh-rustc-wrapper-1.97.1",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "man",
|
||||
"path": "/nix/store/an2hvf5i7mq52mqdad9rad8mxkpksmq2-rustc-wrapper-1.97.1-man",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "doc",
|
||||
"path": "/nix/store/8svfp79q0gx10gcrsyxf1pw04q8jf0lp-rustc-wrapper-1.97.1-doc"
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/yaa1sq5g4jar7rvlspy7kk2wd98nj6qh-rustc-wrapper-1.97.1"
|
||||
}
|
||||
}
|
||||
},
|
||||
"rustfmt@latest": {
|
||||
"last_modified": "2026-08-27T07:16:00Z",
|
||||
"resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#rustfmt",
|
||||
"source": "devbox-search",
|
||||
"version": "1.97.1",
|
||||
"systems": {
|
||||
"aarch64-darwin": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/l7xlkqk1bz4w75rwf5dh7pn2br04n59l-rustfmt-1.97.1",
|
||||
"default": true
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/l7xlkqk1bz4w75rwf5dh7pn2br04n59l-rustfmt-1.97.1"
|
||||
},
|
||||
"aarch64-linux": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/rl3j1za9dpdri1vr52vl69y1qczjb5zv-rustfmt-1.97.1",
|
||||
"default": true
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/rl3j1za9dpdri1vr52vl69y1qczjb5zv-rustfmt-1.97.1"
|
||||
},
|
||||
"x86_64-linux": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/47k26k98v32x56rxiqawdk5qk0r0b6w1-rustfmt-1.97.1",
|
||||
"default": true
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/47k26k98v32x56rxiqawdk5qk0r0b6w1-rustfmt-1.97.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
52
scripts/devbox-libs.sh
Executable file
52
scripts/devbox-libs.sh
Executable file
|
|
@ -0,0 +1,52 @@
|
|||
#!/usr/bin/env sh
|
||||
# Devbox installs library packages as their -dev outputs, which carry no
|
||||
# shared objects; the outs they reference do. Put the GUI stack's real lib
|
||||
# dirs on LD_LIBRARY_PATH — wayland, xkbcommon, EGL and vulkan are dlopened
|
||||
# at runtime, so neither rpath nor a DT_NEEDED scan can cover them. Only
|
||||
# matching package names are picked up: a blanket list would drag glibc and
|
||||
# friends onto the path for every process in the shell.
|
||||
set -e
|
||||
|
||||
if [ -z "$DEVBOX_PACKAGES_DIR" ]; then
|
||||
return 0 2>/dev/null || exit 0
|
||||
fi
|
||||
|
||||
MANIFEST="$DEVBOX_PACKAGES_DIR/manifest.json"
|
||||
[ -f "$MANIFEST" ] || return 0 2>/dev/null || exit 0
|
||||
|
||||
libs=$(python3 - "$MANIFEST" <<'EOF'
|
||||
import json, os, subprocess, sys
|
||||
|
||||
wanted = ("wayland", "libxkbcommon", "libglvnd", "vulkan-loader", "mesa")
|
||||
manifest = json.load(open(sys.argv[1]))
|
||||
seen, dirs = set(), []
|
||||
paths = [
|
||||
path
|
||||
for name, el in manifest["elements"].items()
|
||||
if any(w in name for w in wanted)
|
||||
for path in el.get("storePaths", [])
|
||||
]
|
||||
for path in paths:
|
||||
candidates = [path]
|
||||
try:
|
||||
candidates += subprocess.run(
|
||||
["nix-store", "-q", "--references", path],
|
||||
capture_output=True, text=True,
|
||||
).stdout.split()
|
||||
except OSError:
|
||||
pass
|
||||
for cand in candidates:
|
||||
lib = os.path.join(cand, "lib")
|
||||
base = os.path.basename(cand)
|
||||
if lib in seen or not os.path.isdir(lib):
|
||||
continue
|
||||
if not any(w in base for w in wanted):
|
||||
continue
|
||||
if any(name.endswith(".so") or ".so." in name for name in os.listdir(lib)):
|
||||
seen.add(lib)
|
||||
dirs.append(lib)
|
||||
print(":".join(dirs))
|
||||
EOF
|
||||
)
|
||||
|
||||
export LD_LIBRARY_PATH="${libs}:${DEVBOX_PACKAGES_DIR}/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||
934
src/app.rs
Normal file
934
src/app.rs
Normal file
|
|
@ -0,0 +1,934 @@
|
|||
//! Application state and update. Every fumi-core call that can block runs in
|
||||
//! `spawn_blocking` and reports back through a `Message`; purely local reads
|
||||
//! (opening one sealed message, marking it read) happen inline in `update`.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use fumi_core::address::Address;
|
||||
use fumi_core::crypto::{unb32, KEY_LEN};
|
||||
use fumi_core::store::Store;
|
||||
use iced::widget::qr_code;
|
||||
use iced::widget::text_editor::{self, Action};
|
||||
use iced::{clipboard, Task};
|
||||
|
||||
use crate::ops::{self, Folder, OpenedMsg, Row, Snapshot};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Page {
|
||||
Onboard,
|
||||
Signup,
|
||||
Mail,
|
||||
Contacts,
|
||||
Settings,
|
||||
}
|
||||
|
||||
/// Read marks are carrier's own state, so they live beside the store as one
|
||||
/// hex id per line — an append-only file that a torn tail can only cost a
|
||||
/// re-read mark, never a lost one.
|
||||
pub struct ReadMarks {
|
||||
path: PathBuf,
|
||||
ids: HashSet<String>,
|
||||
}
|
||||
|
||||
impl ReadMarks {
|
||||
pub fn load(path: PathBuf) -> ReadMarks {
|
||||
let ids = std::fs::read_to_string(&path)
|
||||
.map(|text| text.lines().map(str::to_string).collect())
|
||||
.unwrap_or_default();
|
||||
ReadMarks { path, ids }
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> HashSet<String> {
|
||||
self.ids.clone()
|
||||
}
|
||||
|
||||
pub fn mark(&mut self, id: String) {
|
||||
if self.ids.insert(id.clone()) {
|
||||
use std::io::Write;
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.path)
|
||||
{
|
||||
let _ = writeln!(file, "{id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Onboarding: create a master or restore one from a pasted seed.
|
||||
pub struct Onboard {
|
||||
pub mode: OnboardMode,
|
||||
pub seed: String,
|
||||
pub seed_shown: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OnboardMode {
|
||||
Start,
|
||||
Created,
|
||||
Restore,
|
||||
}
|
||||
|
||||
/// Signup: bind the identity to a username, or recall an existing address.
|
||||
pub struct Signup {
|
||||
pub user: String,
|
||||
pub host: String,
|
||||
pub invite: String,
|
||||
/// The server's base32 static key, pasted from what the operator
|
||||
/// published; pinned before the registration connects.
|
||||
pub key: String,
|
||||
/// True when onboarding just wrote the key file, so Back is offered.
|
||||
pub fresh: bool,
|
||||
}
|
||||
|
||||
/// The compose view: `to` takes a short address or a smol:// URI.
|
||||
pub struct Compose {
|
||||
pub to: String,
|
||||
pub subject: String,
|
||||
pub body: text_editor::Content,
|
||||
/// The message this replies to, as 64 hex characters.
|
||||
pub reply_to: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Dialog {
|
||||
/// Delete locally, and on the server too when `everywhere` is chosen.
|
||||
Delete {
|
||||
id: String,
|
||||
folder: Folder,
|
||||
kept: bool,
|
||||
},
|
||||
/// Rotation only moves the index forward, so the action asks first.
|
||||
Rotate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
// Onboarding
|
||||
OnboardCreate,
|
||||
OnboardRestore,
|
||||
OnboardBack,
|
||||
OnboardSeedEdited(String),
|
||||
OnboardRestoreFromSeed,
|
||||
OnboardSeedSaved,
|
||||
|
||||
// Signup
|
||||
SignupUserEdited(String),
|
||||
SignupHostEdited(String),
|
||||
SignupInviteEdited(String),
|
||||
SignupKeyEdited(String),
|
||||
SignupBack,
|
||||
SignupLearnKey,
|
||||
SignupRegister,
|
||||
SignupRecall,
|
||||
|
||||
// Pin offer (signup and settings share it)
|
||||
PinOffered(Result<Option<(Address, [u8; KEY_LEN])>, String>),
|
||||
PinAccept,
|
||||
PinDiscard,
|
||||
Pinned(Result<(), String>),
|
||||
|
||||
// Mail
|
||||
MessageSelected(Folder, String),
|
||||
FetchPressed,
|
||||
FetchCancelled,
|
||||
ComposePressed,
|
||||
ComposeToEdited(String),
|
||||
ComposeSubjectEdited(String),
|
||||
ComposeBody(Action),
|
||||
ComposeClosed,
|
||||
SendPressed,
|
||||
ReplyPressed,
|
||||
DeletePressed,
|
||||
DeleteConfirmed(bool),
|
||||
DeleteCancelled,
|
||||
AcceptPressed,
|
||||
BlockPressed,
|
||||
|
||||
// Contacts
|
||||
ContactImportEdited(String),
|
||||
ContactImportPressed,
|
||||
ContactWrite(String),
|
||||
ContactAccept(String, String),
|
||||
ContactBlock(String),
|
||||
ContactUnblock(String),
|
||||
CopyOwnAddress,
|
||||
|
||||
// Settings
|
||||
SettingsLearnKey,
|
||||
TimeoutEdited(String),
|
||||
RotatePressed,
|
||||
RotateConfirmed,
|
||||
RotateCancelled,
|
||||
UnpinPressed(String),
|
||||
ExportPressed,
|
||||
Exported(Result<(String, String), String>),
|
||||
CopyExport,
|
||||
ImportBody(Action),
|
||||
ImportPressed,
|
||||
|
||||
// Navigation and results
|
||||
GotoMail(Folder),
|
||||
Goto(Page),
|
||||
SnapshotLoaded(Result<Snapshot, String>),
|
||||
Generic(Result<String, String>),
|
||||
}
|
||||
|
||||
pub struct Carrier {
|
||||
pub dir: PathBuf,
|
||||
pub store: Arc<Store>,
|
||||
pub master: Option<[u8; KEY_LEN]>,
|
||||
pub marks: ReadMarks,
|
||||
pub page: Page,
|
||||
pub status: Option<String>,
|
||||
pub status_error: bool,
|
||||
pub busy: Option<String>,
|
||||
pub cancel: Arc<AtomicBool>,
|
||||
pub timeout: u64,
|
||||
pub timeout_text: String,
|
||||
pub onboard: Onboard,
|
||||
pub signup: Signup,
|
||||
pub folder: Folder,
|
||||
pub lists: Vec<Vec<Row>>,
|
||||
pub open: Option<(Folder, OpenedMsg)>,
|
||||
pub compose: Option<Compose>,
|
||||
pub snapshot: Option<Snapshot>,
|
||||
pub pin_offer: Option<(Address, [u8; KEY_LEN])>,
|
||||
pub backup_text: Option<String>,
|
||||
pub import_body: text_editor::Content,
|
||||
pub contact_import: String,
|
||||
pub dialog: Option<Dialog>,
|
||||
/// The own-address QR, rebuilt whenever a snapshot lands.
|
||||
pub share_qr: Option<qr_code::Data>,
|
||||
}
|
||||
|
||||
impl Carrier {
|
||||
pub fn boot(dir: PathBuf) -> (Carrier, Task<Message>) {
|
||||
let store = match Store::open(&dir.join("carrier.db")) {
|
||||
Ok(store) => Arc::new(store),
|
||||
Err(e) => {
|
||||
eprintln!("cannot open {}: {e}", dir.join("carrier.db").display());
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let master = ops::load_key(&dir.join("carrier.key")).ok();
|
||||
let registered =
|
||||
master.is_some() && store.account().map(|addr| addr.is_some()).unwrap_or(false);
|
||||
let page = match (master.is_some(), registered) {
|
||||
(false, _) => Page::Onboard,
|
||||
(true, false) => Page::Signup,
|
||||
(true, true) => Page::Mail,
|
||||
};
|
||||
let mut app = Carrier {
|
||||
marks: ReadMarks::load(dir.join("read.txt")),
|
||||
dir,
|
||||
store,
|
||||
master,
|
||||
page,
|
||||
status: None,
|
||||
status_error: false,
|
||||
busy: None,
|
||||
cancel: Arc::new(AtomicBool::new(false)),
|
||||
timeout: 30,
|
||||
timeout_text: "30".to_string(),
|
||||
onboard: Onboard {
|
||||
mode: OnboardMode::Start,
|
||||
seed: String::new(),
|
||||
seed_shown: String::new(),
|
||||
},
|
||||
signup: Signup {
|
||||
user: String::new(),
|
||||
host: String::new(),
|
||||
invite: String::new(),
|
||||
key: String::new(),
|
||||
fresh: false,
|
||||
},
|
||||
folder: Folder::Inbox,
|
||||
lists: vec![Vec::new(), Vec::new(), Vec::new()],
|
||||
open: None,
|
||||
compose: None,
|
||||
snapshot: None,
|
||||
pin_offer: None,
|
||||
backup_text: None,
|
||||
import_body: text_editor::Content::new(),
|
||||
contact_import: String::new(),
|
||||
dialog: None,
|
||||
share_qr: None,
|
||||
};
|
||||
let task = if registered {
|
||||
app.refresh()
|
||||
} else {
|
||||
Task::none()
|
||||
};
|
||||
(app, task)
|
||||
}
|
||||
|
||||
fn master(&self) -> [u8; KEY_LEN] {
|
||||
self.master.unwrap_or([0u8; KEY_LEN])
|
||||
}
|
||||
|
||||
fn key_path(&self) -> PathBuf {
|
||||
self.dir.join("carrier.key")
|
||||
}
|
||||
|
||||
fn note(&mut self, text: String) {
|
||||
self.status = Some(text);
|
||||
self.status_error = false;
|
||||
}
|
||||
|
||||
fn fail(&mut self, text: String) {
|
||||
self.status = Some(text);
|
||||
self.status_error = true;
|
||||
}
|
||||
|
||||
/// One operation at a time: a second action while one runs is ignored,
|
||||
/// except `FetchCancelled`, which raises the token the fetch checks.
|
||||
fn start(&mut self, label: &str) -> bool {
|
||||
if self.busy.is_some() {
|
||||
return false;
|
||||
}
|
||||
self.busy = Some(label.to_string());
|
||||
self.cancel.store(false, Ordering::Relaxed);
|
||||
true
|
||||
}
|
||||
|
||||
fn done(&mut self) {
|
||||
self.busy = None;
|
||||
}
|
||||
|
||||
/// One blocking pass over the store, bringing every page's data home.
|
||||
fn refresh(&mut self) -> Task<Message> {
|
||||
if !self.start("refreshing") {
|
||||
return Task::none();
|
||||
}
|
||||
let store = self.store.clone();
|
||||
let master = self.master();
|
||||
let read = self.marks.snapshot();
|
||||
Task::perform(
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || ops::snapshot(&store, &master, &read))
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(format!("refreshing failed: {e}")))
|
||||
},
|
||||
Message::SnapshotLoaded,
|
||||
)
|
||||
}
|
||||
|
||||
fn refresh_after(&mut self, note: String) -> Task<Message> {
|
||||
self.note(note);
|
||||
self.refresh()
|
||||
}
|
||||
|
||||
/// Runs one blocking operation on the worker pool, mapping its result to
|
||||
/// `map` — the label doubles as the busy indicator and the panic message.
|
||||
fn run_with<T, F, M>(&mut self, label: &str, map: M, work: F) -> Task<Message>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce(Arc<Store>, [u8; KEY_LEN]) -> Result<T, String> + Send + 'static,
|
||||
M: Fn(Result<T, String>) -> Message + Send + 'static,
|
||||
{
|
||||
if !self.start(label) {
|
||||
return Task::none();
|
||||
}
|
||||
let label = label.to_string();
|
||||
let store = self.store.clone();
|
||||
let master = self.master();
|
||||
Task::perform(
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || work(store, master))
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(format!("{label} failed: {e}")))
|
||||
},
|
||||
map,
|
||||
)
|
||||
}
|
||||
|
||||
fn run<F>(&mut self, label: &str, work: F) -> Task<Message>
|
||||
where
|
||||
F: FnOnce(Arc<Store>, [u8; KEY_LEN]) -> Result<String, String> + Send + 'static,
|
||||
{
|
||||
self.run_with(label, Message::Generic, work)
|
||||
}
|
||||
|
||||
/// `user@host` as typed on the signup page, defaulting the port.
|
||||
fn signup_address(&self) -> Option<Address> {
|
||||
let user = self.signup.user.trim();
|
||||
let host = self.signup.host.trim();
|
||||
if user.is_empty() || host.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Address::parse(&format!("{user}@{host}")).ok()
|
||||
}
|
||||
|
||||
/// The account address, for the settings page's learn-key action.
|
||||
fn account_address(&self) -> Option<Address> {
|
||||
self.store.account().ok().flatten()
|
||||
}
|
||||
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
// Text editor actions must survive the busy guard: typing in the
|
||||
// compose body while a fetch runs is ordinary behavior.
|
||||
Message::ComposeBody(action) => {
|
||||
if let Some(compose) = &mut self.compose {
|
||||
compose.body.perform(action);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::ImportBody(action) => {
|
||||
self.import_body.perform(action);
|
||||
Task::none()
|
||||
}
|
||||
Message::FetchCancelled => {
|
||||
if self.busy.is_some() {
|
||||
self.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
_ if self.busy.is_some() => Task::none(),
|
||||
Message::OnboardCreate => {
|
||||
let master = ops::new_master();
|
||||
if let Err(e) = ops::save_key(&self.key_path(), &master) {
|
||||
self.fail(e);
|
||||
return Task::none();
|
||||
}
|
||||
self.master = Some(master);
|
||||
self.onboard.seed_shown = ops::seed_text(&master);
|
||||
self.onboard.mode = OnboardMode::Created;
|
||||
self.note("Identity created. Write the seed down; it is the only secret.".into());
|
||||
Task::none()
|
||||
}
|
||||
Message::OnboardRestore => {
|
||||
self.onboard.mode = OnboardMode::Restore;
|
||||
Task::none()
|
||||
}
|
||||
Message::OnboardBack => {
|
||||
self.onboard.mode = OnboardMode::Start;
|
||||
Task::none()
|
||||
}
|
||||
Message::OnboardSeedEdited(seed) => {
|
||||
self.onboard.seed = seed;
|
||||
Task::none()
|
||||
}
|
||||
Message::OnboardRestoreFromSeed => match ops::parse_seed(&self.onboard.seed) {
|
||||
Ok(master) => {
|
||||
if let Err(e) = ops::save_key(&self.key_path(), &master) {
|
||||
self.fail(e);
|
||||
return Task::none();
|
||||
}
|
||||
self.master = Some(master);
|
||||
self.signup.fresh = true;
|
||||
self.page = Page::Signup;
|
||||
self.note("Seed restored. Bind a username or recall an address.".into());
|
||||
Task::none()
|
||||
}
|
||||
Err(e) => {
|
||||
self.fail(e);
|
||||
Task::none()
|
||||
}
|
||||
},
|
||||
Message::OnboardSeedSaved => {
|
||||
self.signup.fresh = true;
|
||||
self.page = Page::Signup;
|
||||
Task::none()
|
||||
}
|
||||
Message::SignupUserEdited(user) => {
|
||||
self.signup.user = user;
|
||||
Task::none()
|
||||
}
|
||||
Message::SignupHostEdited(host) => {
|
||||
self.signup.host = host;
|
||||
Task::none()
|
||||
}
|
||||
Message::SignupInviteEdited(invite) => {
|
||||
self.signup.invite = invite;
|
||||
Task::none()
|
||||
}
|
||||
Message::SignupKeyEdited(key) => {
|
||||
self.signup.key = key;
|
||||
Task::none()
|
||||
}
|
||||
Message::SignupBack => {
|
||||
self.page = Page::Onboard;
|
||||
Task::none()
|
||||
}
|
||||
Message::SignupLearnKey => {
|
||||
let Some(addr) = self.signup_address() else {
|
||||
self.fail("enter user and host first".into());
|
||||
return Task::none();
|
||||
};
|
||||
self.run_with("connecting", Message::PinOffered, move |store, _| {
|
||||
ops::learn_key(&store, &addr, 30).map(|key| key.map(|key| (addr.clone(), key)))
|
||||
})
|
||||
}
|
||||
Message::SettingsLearnKey => {
|
||||
let Some(addr) = self.account_address() else {
|
||||
return Task::none();
|
||||
};
|
||||
self.run_with("connecting", Message::PinOffered, move |store, _| {
|
||||
ops::learn_key(&store, &addr, 30).map(|key| key.map(|key| (addr.clone(), key)))
|
||||
})
|
||||
}
|
||||
Message::PinDiscard => {
|
||||
self.pin_offer = None;
|
||||
Task::none()
|
||||
}
|
||||
Message::PinOffered(result) => {
|
||||
self.done();
|
||||
match result {
|
||||
Ok(Some((addr, key))) => {
|
||||
self.pin_offer = Some((addr, key));
|
||||
self.note(
|
||||
"The server presented this key. Pin it only if the fingerprint \
|
||||
matches what the operator published."
|
||||
.into(),
|
||||
);
|
||||
Task::none()
|
||||
}
|
||||
Ok(None) => {
|
||||
self.note("This server's key is already pinned.".into());
|
||||
Task::none()
|
||||
}
|
||||
Err(e) => {
|
||||
self.fail(e);
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::PinAccept => {
|
||||
let Some((addr, key)) = self.pin_offer.take() else {
|
||||
return Task::none();
|
||||
};
|
||||
self.run_with("pinning", Message::Pinned, move |store, _| {
|
||||
ops::pin(&store, &addr, &key)
|
||||
})
|
||||
}
|
||||
Message::Pinned(result) => {
|
||||
self.done();
|
||||
match result {
|
||||
Ok(()) => self.note("server key pinned".into()),
|
||||
Err(e) => self.fail(e),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::SignupRegister => {
|
||||
let Some(addr) = self.signup_address() else {
|
||||
self.fail("enter user and host".into());
|
||||
return Task::none();
|
||||
};
|
||||
let invite = if self.signup.invite.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.signup.invite.clone())
|
||||
};
|
||||
// A pasted operator key is pinned first: registration
|
||||
// connects with the pin required.
|
||||
let key = self.signup.key.trim().to_string();
|
||||
let timeout = self.timeout;
|
||||
self.run("registering", move |store, master| {
|
||||
if !key.is_empty() {
|
||||
let key = ops::parse_key(&key)?;
|
||||
ops::pin(&store, &addr, &key)?;
|
||||
}
|
||||
ops::register(&store, &master, &addr, invite.as_deref(), timeout)
|
||||
.map(|_| "registered".to_string())
|
||||
})
|
||||
}
|
||||
Message::SignupRecall => {
|
||||
let Some(addr) = self.signup_address() else {
|
||||
self.fail("enter user and host".into());
|
||||
return Task::none();
|
||||
};
|
||||
let timeout = self.timeout;
|
||||
self.run("restoring", move |store, master| {
|
||||
ops::restore(&store, &master, &addr, timeout)
|
||||
.map(|index| format!("restored at rotation {index}"))
|
||||
})
|
||||
}
|
||||
Message::GotoMail(folder) => {
|
||||
if self.page == Page::Mail && self.folder == folder {
|
||||
return Task::none();
|
||||
}
|
||||
self.page = Page::Mail;
|
||||
self.folder = folder;
|
||||
self.open = None;
|
||||
Task::none()
|
||||
}
|
||||
Message::Goto(page) => {
|
||||
self.page = page;
|
||||
self.open = None;
|
||||
self.compose = None;
|
||||
if page == Page::Mail || page == Page::Contacts || page == Page::Settings {
|
||||
return self.refresh();
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::MessageSelected(folder, id) => {
|
||||
self.folder = folder;
|
||||
self.open = None;
|
||||
self.marks.mark(id.clone());
|
||||
// Opening one envelope and re-listing is local and fast, so
|
||||
// it happens inline; the refresh keeps the unread dots honest.
|
||||
let master = self.master();
|
||||
let read = self.marks.snapshot();
|
||||
match ops::open(&self.store, &master, &id, folder) {
|
||||
Ok(msg) => {
|
||||
self.open = Some((folder, msg));
|
||||
self.status = None;
|
||||
}
|
||||
Err(e) => self.fail(e),
|
||||
}
|
||||
if let Ok(lists) = ops::lists(&self.store, &master, &read) {
|
||||
self.lists = lists;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::FetchPressed => {
|
||||
if !self.start("fetching") {
|
||||
return Task::none();
|
||||
}
|
||||
let store = self.store.clone();
|
||||
let master = self.master();
|
||||
let cancel = self.cancel.clone();
|
||||
let timeout = self.timeout;
|
||||
Task::perform(
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
ops::fetch(&store, &master, cancel, timeout)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(format!("fetching failed: {e}")))
|
||||
},
|
||||
Message::Generic,
|
||||
)
|
||||
}
|
||||
Message::ComposePressed => {
|
||||
self.compose = Some(Compose {
|
||||
to: String::new(),
|
||||
subject: String::new(),
|
||||
body: text_editor::Content::new(),
|
||||
reply_to: None,
|
||||
});
|
||||
Task::none()
|
||||
}
|
||||
Message::ReplyPressed => {
|
||||
let Some((_, msg)) = &self.open else {
|
||||
return Task::none();
|
||||
};
|
||||
// A fingerprint-only sender left no address to reply to.
|
||||
let to = if let Some(uri) = &msg.reply_to {
|
||||
uri.clone()
|
||||
} else if msg.from.starts_with('<') {
|
||||
self.fail("this sender left no address to reply to".into());
|
||||
return Task::none();
|
||||
} else {
|
||||
msg.from.clone()
|
||||
};
|
||||
let subject = if msg.subject.is_empty() || msg.subject.starts_with("Re:") {
|
||||
msg.subject.clone()
|
||||
} else {
|
||||
format!("Re: {}", msg.subject)
|
||||
};
|
||||
self.compose = Some(Compose {
|
||||
to,
|
||||
subject,
|
||||
body: text_editor::Content::new(),
|
||||
reply_to: Some(msg.id.clone()),
|
||||
});
|
||||
Task::none()
|
||||
}
|
||||
Message::ComposeToEdited(to) => {
|
||||
if let Some(compose) = &mut self.compose {
|
||||
compose.to = to;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::ComposeSubjectEdited(subject) => {
|
||||
if let Some(compose) = &mut self.compose {
|
||||
compose.subject = subject;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::ComposeClosed => {
|
||||
self.compose = None;
|
||||
Task::none()
|
||||
}
|
||||
Message::SendPressed => {
|
||||
let Some(compose) = &self.compose else {
|
||||
return Task::none();
|
||||
};
|
||||
if compose.to.trim().is_empty() {
|
||||
self.fail("enter a recipient".into());
|
||||
return Task::none();
|
||||
}
|
||||
let to = compose.to.trim().to_string();
|
||||
let subject = compose.subject.trim().to_string();
|
||||
let body = compose.body.text();
|
||||
let reply_to = compose.reply_to.clone();
|
||||
self.compose = None;
|
||||
let timeout = self.timeout;
|
||||
self.run("sending", move |store, master| {
|
||||
ops::send(
|
||||
&store,
|
||||
&master,
|
||||
&to,
|
||||
if subject.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(subject.as_str())
|
||||
},
|
||||
&body,
|
||||
reply_to.as_deref(),
|
||||
timeout,
|
||||
)
|
||||
})
|
||||
}
|
||||
Message::DeletePressed => {
|
||||
let Some((folder, msg)) = &self.open else {
|
||||
return Task::none();
|
||||
};
|
||||
self.dialog = Some(Dialog::Delete {
|
||||
id: msg.id.clone(),
|
||||
folder: *folder,
|
||||
kept: msg.kept,
|
||||
});
|
||||
Task::none()
|
||||
}
|
||||
Message::DeleteCancelled => {
|
||||
self.dialog = None;
|
||||
Task::none()
|
||||
}
|
||||
Message::DeleteConfirmed(everywhere) => {
|
||||
let Some(Dialog::Delete { id, folder, kept }) = self.dialog.take() else {
|
||||
return Task::none();
|
||||
};
|
||||
let scope = everywhere && kept;
|
||||
let timeout = self.timeout;
|
||||
self.run("deleting", move |store, master| {
|
||||
ops::delete(&store, &master, &id, folder, scope, timeout)
|
||||
.map(|_| "deleted".to_string())
|
||||
})
|
||||
}
|
||||
Message::AcceptPressed => {
|
||||
let Some((folder, msg)) = &self.open else {
|
||||
return Task::none();
|
||||
};
|
||||
if *folder != Folder::Requests {
|
||||
return Task::none();
|
||||
}
|
||||
let Some(uri) = msg.reply_to.clone() else {
|
||||
self.fail("this sender left no address to accept".into());
|
||||
return Task::none();
|
||||
};
|
||||
let Ok(addr) = Address::parse(&uri) else {
|
||||
self.fail("the sender's reply address does not parse".into());
|
||||
return Task::none();
|
||||
};
|
||||
let Ok(identity) =
|
||||
<[u8; KEY_LEN]>::try_from(unb32(&msg.sender).unwrap_or_default())
|
||||
else {
|
||||
self.fail("the sender's key does not parse".into());
|
||||
return Task::none();
|
||||
};
|
||||
let address = addr.short();
|
||||
let timeout = self.timeout;
|
||||
self.run("accepting", move |store, master| {
|
||||
ops::accept_contact(&store, &master, &address, &identity, timeout)
|
||||
.map(|_| format!("accepted {address}"))
|
||||
})
|
||||
}
|
||||
Message::BlockPressed => {
|
||||
let Some((_, msg)) = &self.open else {
|
||||
return Task::none();
|
||||
};
|
||||
let Some(uri) = msg.reply_to.clone() else {
|
||||
self.fail("this sender left no address to block".into());
|
||||
return Task::none();
|
||||
};
|
||||
let Ok(addr) = Address::parse(&uri) else {
|
||||
self.fail("the sender's reply address does not parse".into());
|
||||
return Task::none();
|
||||
};
|
||||
let address = addr.short();
|
||||
let timeout = self.timeout;
|
||||
self.run("blocking", move |store, master| {
|
||||
ops::block_contact(&store, &master, &address, timeout)
|
||||
.map(|_| format!("blocked {address}"))
|
||||
})
|
||||
}
|
||||
Message::ContactImportEdited(text) => {
|
||||
self.contact_import = text;
|
||||
Task::none()
|
||||
}
|
||||
Message::ContactImportPressed => {
|
||||
let uri = self.contact_import.trim().to_string();
|
||||
if uri.is_empty() {
|
||||
return Task::none();
|
||||
}
|
||||
self.run("importing", move |store, _| {
|
||||
ops::import_contact(&store, &uri)
|
||||
.map(|(address, _)| format!("imported {address} (verified)"))
|
||||
})
|
||||
}
|
||||
Message::ContactWrite(address) => {
|
||||
let key = self
|
||||
.snapshot
|
||||
.as_ref()
|
||||
.and_then(|snap| {
|
||||
snap.contacts
|
||||
.iter()
|
||||
.find(|c| c.address == address)
|
||||
.map(|c| c.key.clone())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
self.compose = Some(Compose {
|
||||
// Self-certifying: the sealed copy goes to the key we hold.
|
||||
to: format!("smol://{address}/{key}"),
|
||||
subject: String::new(),
|
||||
body: text_editor::Content::new(),
|
||||
reply_to: None,
|
||||
});
|
||||
Task::none()
|
||||
}
|
||||
Message::ContactAccept(address, key) => {
|
||||
let Ok(identity) = <[u8; KEY_LEN]>::try_from(unb32(&key).unwrap_or_default())
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
let timeout = self.timeout;
|
||||
self.run("accepting", move |store, master| {
|
||||
ops::accept_contact(&store, &master, &address, &identity, timeout)
|
||||
.map(|_| format!("accepted {address}"))
|
||||
})
|
||||
}
|
||||
Message::ContactBlock(address) => {
|
||||
let timeout = self.timeout;
|
||||
self.run("blocking", move |store, master| {
|
||||
ops::block_contact(&store, &master, &address, timeout)
|
||||
.map(|_| format!("blocked {address}"))
|
||||
})
|
||||
}
|
||||
Message::ContactUnblock(address) => {
|
||||
// A block keeps the row, so unblocking re-accepts it.
|
||||
let key = self
|
||||
.snapshot
|
||||
.as_ref()
|
||||
.and_then(|snap| {
|
||||
snap.contacts
|
||||
.iter()
|
||||
.find(|c| c.address == address)
|
||||
.map(|c| c.key.clone())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let timeout = self.timeout;
|
||||
self.run("unblocking", move |store, master| {
|
||||
let Ok(identity) = <[u8; KEY_LEN]>::try_from(unb32(&key).unwrap_or_default())
|
||||
else {
|
||||
return Err("contact key does not parse".to_string());
|
||||
};
|
||||
ops::accept_contact(&store, &master, &address, &identity, timeout)
|
||||
.map(|_| format!("unblocked {address}"))
|
||||
})
|
||||
}
|
||||
Message::CopyOwnAddress => {
|
||||
let Some(snap) = &self.snapshot else {
|
||||
return Task::none();
|
||||
};
|
||||
clipboard::write(format!("smol://{}/{}", snap.who.address, snap.who.key))
|
||||
}
|
||||
Message::CopyExport => match &self.backup_text {
|
||||
Some(text) => clipboard::write(text.clone()),
|
||||
None => Task::none(),
|
||||
},
|
||||
Message::TimeoutEdited(text) => {
|
||||
if let Ok(timeout) = text.trim().parse::<u64>() {
|
||||
self.timeout = timeout;
|
||||
}
|
||||
self.timeout_text = text;
|
||||
Task::none()
|
||||
}
|
||||
Message::RotatePressed => {
|
||||
self.dialog = Some(Dialog::Rotate);
|
||||
Task::none()
|
||||
}
|
||||
Message::RotateCancelled => {
|
||||
self.dialog = None;
|
||||
Task::none()
|
||||
}
|
||||
Message::RotateConfirmed => {
|
||||
self.dialog = None;
|
||||
let timeout = self.timeout;
|
||||
self.run("rotating", move |store, master| {
|
||||
ops::rotate(&store, &master, timeout).map(|(index, key)| {
|
||||
format!("rotated to index {index}; new key {}…", &key[..12])
|
||||
})
|
||||
})
|
||||
}
|
||||
Message::UnpinPressed(label) => self.run("unpinning", move |store, _| {
|
||||
ops::unpin(&store, &label).map(|_| format!("unpinned {label}"))
|
||||
}),
|
||||
Message::ExportPressed => {
|
||||
let dir = self.dir.clone();
|
||||
self.run_with("exporting", Message::Exported, move |store, master| {
|
||||
ops::export_backup(&store, &master, &dir)
|
||||
.map(|(path, text)| (format!("backup written to {}", path.display()), text))
|
||||
})
|
||||
}
|
||||
Message::Exported(result) => {
|
||||
self.done();
|
||||
match result {
|
||||
Ok((note, text)) => {
|
||||
self.backup_text = Some(text);
|
||||
self.note(note);
|
||||
Task::none()
|
||||
}
|
||||
Err(e) => {
|
||||
self.fail(e);
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::ImportPressed => {
|
||||
let text = self.import_body.text();
|
||||
if text.trim().is_empty() {
|
||||
return Task::none();
|
||||
}
|
||||
self.run("importing", move |store, master| {
|
||||
ops::import_backup(&store, &master, &text)
|
||||
})
|
||||
}
|
||||
Message::SnapshotLoaded(result) => {
|
||||
self.done();
|
||||
match result {
|
||||
Ok(snap) => {
|
||||
self.share_qr = qr_code::Data::new(
|
||||
format!("smol://{}/{}", snap.who.address, snap.who.key).as_bytes(),
|
||||
)
|
||||
.ok();
|
||||
self.lists = snap.lists.clone();
|
||||
self.snapshot = Some(snap);
|
||||
Task::none()
|
||||
}
|
||||
Err(e) => {
|
||||
self.fail(e);
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Generic(result) => {
|
||||
self.done();
|
||||
match result {
|
||||
Ok(note) => self.refresh_after(note),
|
||||
Err(e) => {
|
||||
self.fail(e);
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/main.rs
Normal file
37
src/main.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
//! carrier — a minimalist smolmail client built on fumi-core, with an Iced
|
||||
//! UI. State lives in the config directory: `carrier.key` (the master,
|
||||
//! 0600), `carrier.db` (fumi-core's sealed store) and `read.txt` (read
|
||||
//! marks, carrier's own sidecar).
|
||||
|
||||
mod app;
|
||||
mod ops;
|
||||
mod theme;
|
||||
mod view;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use app::Carrier;
|
||||
|
||||
fn main() -> iced::Result {
|
||||
let dir = config_dir();
|
||||
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||||
eprintln!("cannot create {}: {e}", dir.display());
|
||||
std::process::exit(1);
|
||||
}
|
||||
iced::application("carrier", Carrier::update, view::view)
|
||||
.theme(|_| theme::theme())
|
||||
.window_size(iced::Size::new(1080.0, 640.0))
|
||||
.run_with(move || {
|
||||
let (state, task) = Carrier::boot(dir);
|
||||
(state, task)
|
||||
})
|
||||
}
|
||||
|
||||
fn config_dir() -> PathBuf {
|
||||
match std::env::var("XDG_CONFIG_HOME") {
|
||||
Ok(xdg) if !xdg.is_empty() => PathBuf::from(xdg).join("carrier"),
|
||||
_ => PathBuf::from(std::env::var("HOME").unwrap_or_default())
|
||||
.join(".config")
|
||||
.join("carrier"),
|
||||
}
|
||||
}
|
||||
672
src/ops.rs
Normal file
672
src/ops.rs
Normal file
|
|
@ -0,0 +1,672 @@
|
|||
//! Blocking fumi-core operations and the local files around them. Everything
|
||||
//! here runs on a worker thread; the UI state only ever sees the plain
|
||||
//! values these functions return.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fumi_core::address::{Address, DEFAULT_PORT};
|
||||
use fumi_core::client::{FetchOptions, TrustChange};
|
||||
use fumi_core::crypto::{b32, fingerprint, unb32, KEY_LEN};
|
||||
use fumi_core::error::Error;
|
||||
use fumi_core::store::Store;
|
||||
use fumi_core::{account, client, export, store};
|
||||
|
||||
/// One message in a folder list, already opened for its metadata.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Row {
|
||||
pub id: String,
|
||||
pub from: String,
|
||||
pub subject: String,
|
||||
pub at: i64,
|
||||
pub unread: bool,
|
||||
}
|
||||
|
||||
/// A fully opened message.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OpenedMsg {
|
||||
pub id: String,
|
||||
pub from: String,
|
||||
pub subject: String,
|
||||
pub time: i64,
|
||||
pub body: String,
|
||||
/// The sender's public key, base32 — the identity `accept` freezes.
|
||||
pub sender: String,
|
||||
/// The sender's self-certifying address, when the payload carried one.
|
||||
pub reply_to: Option<String>,
|
||||
pub kept: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Contact {
|
||||
pub address: String,
|
||||
pub key: String,
|
||||
pub verified: bool,
|
||||
/// Some(true) accepted, Some(false) blocked, None never decided.
|
||||
pub accepted: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Who {
|
||||
pub address: String,
|
||||
pub key: String,
|
||||
pub fingerprint: String,
|
||||
pub rotations: u32,
|
||||
}
|
||||
|
||||
/// Everything the main pages display, refreshed in one blocking pass.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Snapshot {
|
||||
pub lists: Vec<Vec<Row>>,
|
||||
pub contacts: Vec<Contact>,
|
||||
pub pins: Vec<(String, [u8; KEY_LEN])>,
|
||||
pub who: Who,
|
||||
}
|
||||
|
||||
pub fn snapshot(
|
||||
store: &Store,
|
||||
master: &[u8; KEY_LEN],
|
||||
read: &HashSet<String>,
|
||||
) -> Result<Snapshot, String> {
|
||||
Ok(Snapshot {
|
||||
lists: lists(store, master, read)?,
|
||||
contacts: contacts(store)?,
|
||||
pins: pins(store)?,
|
||||
who: whoami(store, master)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// The three folders, in sidebar order.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Folder {
|
||||
Inbox,
|
||||
Requests,
|
||||
Sent,
|
||||
}
|
||||
|
||||
impl Folder {
|
||||
pub const ALL: [Folder; 3] = [Folder::Inbox, Folder::Requests, Folder::Sent];
|
||||
|
||||
fn table(self) -> &'static str {
|
||||
match self {
|
||||
Folder::Inbox => "inbox",
|
||||
Folder::Requests => "requests",
|
||||
Folder::Sent => "sent",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn explain(e: Error) -> String {
|
||||
e.to_string()
|
||||
}
|
||||
|
||||
pub fn new_master() -> [u8; KEY_LEN] {
|
||||
use rand_core::RngCore;
|
||||
let mut master = [0u8; KEY_LEN];
|
||||
rand_core::OsRng.fill_bytes(&mut master);
|
||||
master
|
||||
}
|
||||
|
||||
pub fn seed_text(master: &[u8; KEY_LEN]) -> String {
|
||||
b32(master)
|
||||
}
|
||||
|
||||
/// A seed as base32 or 64 hex characters, the two forms a paste can carry.
|
||||
pub fn parse_seed(text: &str) -> Result<[u8; KEY_LEN], String> {
|
||||
let text = text.trim();
|
||||
let bytes = if text.len() == 64 && text.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
let mut raw = Vec::with_capacity(32);
|
||||
for pair in text.as_bytes().chunks(2) {
|
||||
let hi = (pair[0] as char).to_digit(16).ok_or("bad hex")?;
|
||||
let lo = (pair[1] as char).to_digit(16).ok_or("bad hex")?;
|
||||
raw.push((hi * 16 + lo) as u8);
|
||||
}
|
||||
raw
|
||||
} else {
|
||||
unb32(text).map_err(|e| explain(e))?
|
||||
};
|
||||
bytes
|
||||
.try_into()
|
||||
.map_err(|_| "a seed is 32 bytes".to_string())
|
||||
}
|
||||
|
||||
pub fn load_key(path: &Path) -> Result<[u8; KEY_LEN], String> {
|
||||
let raw = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
raw.try_into()
|
||||
.map_err(|_| format!("{}: not a 32-byte master", path.display()))
|
||||
}
|
||||
|
||||
pub fn save_key(path: &Path, master: &[u8; KEY_LEN]) -> Result<(), String> {
|
||||
#[cfg(unix)]
|
||||
let err = {
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.map_err(|e| e.to_string())?;
|
||||
file.write_all(master).map_err(|e| e.to_string())
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let err = std::fs::write(path, master).map_err(|e| e.to_string());
|
||||
err
|
||||
}
|
||||
|
||||
pub fn account(store: &Store, master: &[u8; KEY_LEN]) -> Result<account::Account, String> {
|
||||
let index = store.rotations().map_err(explain)?;
|
||||
account::Account::new(*master, index).map_err(explain)
|
||||
}
|
||||
|
||||
pub fn parse_address(text: &str) -> Result<Address, String> {
|
||||
Address::parse(text).map_err(explain)
|
||||
}
|
||||
|
||||
/// A base32 public key, the form the operator publishes and pins are shown in.
|
||||
pub fn parse_key(text: &str) -> Result<[u8; KEY_LEN], String> {
|
||||
unb32(text.trim())
|
||||
.map_err(explain)?
|
||||
.try_into()
|
||||
.map_err(|_| "keys are 32 bytes of base32".to_string())
|
||||
}
|
||||
|
||||
pub fn register(
|
||||
store: &Store,
|
||||
master: &[u8; KEY_LEN],
|
||||
addr: &Address,
|
||||
invite: Option<&str>,
|
||||
timeout: u64,
|
||||
) -> Result<(), String> {
|
||||
let me = account(store, master)?;
|
||||
client::register(store, addr, &me, invite, timeout).map_err(explain)
|
||||
}
|
||||
|
||||
pub fn restore(
|
||||
store: &Store,
|
||||
master: &[u8; KEY_LEN],
|
||||
addr: &Address,
|
||||
timeout: u64,
|
||||
) -> Result<u32, String> {
|
||||
client::restore(store, master, addr, timeout).map_err(explain)
|
||||
}
|
||||
|
||||
/// Connects without requiring a pin and reports the static key the server
|
||||
/// presented, so the user can decide to pin it (sec 4 trust on first use).
|
||||
/// None when a pin already covers this host.
|
||||
pub fn learn_key(
|
||||
store: &Store,
|
||||
addr: &Address,
|
||||
timeout: u64,
|
||||
) -> Result<Option<[u8; KEY_LEN]>, String> {
|
||||
if store
|
||||
.server_pin(&addr.host, addr.port)
|
||||
.map_err(explain)?
|
||||
.is_some()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let mut session = client::connect(store, addr, false, timeout).map_err(explain)?;
|
||||
let offered = session.unpinned_static;
|
||||
session.close();
|
||||
Ok(offered)
|
||||
}
|
||||
|
||||
pub fn pin(store: &Store, addr: &Address, key: &[u8; KEY_LEN]) -> Result<(), String> {
|
||||
store
|
||||
.pin_server(&addr.host, addr.port, key)
|
||||
.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> {
|
||||
store.pins().map_err(explain)
|
||||
}
|
||||
|
||||
/// Fetches with the default cursor policy, returning a one-line summary.
|
||||
pub fn fetch(
|
||||
store: &Store,
|
||||
master: &[u8; KEY_LEN],
|
||||
cancel: Arc<AtomicBool>,
|
||||
timeout: u64,
|
||||
) -> Result<String, String> {
|
||||
let me = account(store, master)?;
|
||||
let opts = FetchOptions {
|
||||
keep: false,
|
||||
reset: false,
|
||||
timeout,
|
||||
dial: None,
|
||||
cancel: Some(&cancel),
|
||||
};
|
||||
let fetched = client::fetch_with(store, &me, opts).map_err(explain)?;
|
||||
let mut note = format!("{} fetched, {} stored", fetched.total, fetched.stored);
|
||||
if !fetched.rejected.is_empty() {
|
||||
note.push_str(&format!(", {} rejected", fetched.rejected.len()));
|
||||
}
|
||||
if fetched.cancelled {
|
||||
note.push_str(" (cancelled)");
|
||||
}
|
||||
Ok(note)
|
||||
}
|
||||
|
||||
pub fn send(
|
||||
store: &Store,
|
||||
master: &[u8; KEY_LEN],
|
||||
to: &str,
|
||||
subject: Option<&str>,
|
||||
body: &str,
|
||||
reply_to: Option<&str>,
|
||||
timeout: u64,
|
||||
) -> Result<String, String> {
|
||||
let addr = parse_address(to)?;
|
||||
let me = account(store, master)?;
|
||||
let draft = client::SendDraft {
|
||||
address: &addr,
|
||||
text: body.to_string(),
|
||||
subject,
|
||||
reply_to,
|
||||
headers: &[],
|
||||
anonymous: false,
|
||||
no_pad: false,
|
||||
};
|
||||
let sent = client::send(store, &me, &draft, timeout).map_err(explain)?;
|
||||
let change = match sent.change {
|
||||
TrustChange::New { unverified: true } => {
|
||||
"; new contact learned from an unpinned server (unverified)"
|
||||
}
|
||||
TrustChange::New { unverified: false } => "; new contact learned (trust on first use)",
|
||||
TrustChange::Rotated => "; contact rotated to a new key (chain verified)",
|
||||
TrustChange::None => "",
|
||||
};
|
||||
let mut note = format!("sent to {} ({} bytes)", addr.short(), sent.bytes);
|
||||
if sent.token_used {
|
||||
note.push_str(", accept token attached");
|
||||
}
|
||||
note.push_str(change);
|
||||
if let Some(warning) = sent.warning {
|
||||
note.push_str(&format!("; {warning}"));
|
||||
}
|
||||
Ok(note)
|
||||
}
|
||||
|
||||
/// Removes one message locally, and on the server when `everywhere` is set.
|
||||
pub fn delete(
|
||||
store: &Store,
|
||||
master: &[u8; KEY_LEN],
|
||||
id: &str,
|
||||
folder: Folder,
|
||||
everywhere: bool,
|
||||
timeout: u64,
|
||||
) -> Result<(), String> {
|
||||
let raw = hex32(id)?;
|
||||
store.delete_local(folder.table(), &raw).map_err(explain)?;
|
||||
if everywhere {
|
||||
let me = account(store, master)?;
|
||||
client::delete(store, &me, &[raw], None, timeout).map_err(explain)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Admits a correspondent to the main tier and pushes the changed token set.
|
||||
pub fn accept_contact(
|
||||
store: &Store,
|
||||
master: &[u8; KEY_LEN],
|
||||
address: &str,
|
||||
identity: &[u8; KEY_LEN],
|
||||
timeout: u64,
|
||||
) -> Result<(), String> {
|
||||
store.accept(address, identity).map_err(explain)?;
|
||||
let me = account(store, master)?;
|
||||
client::push_tokens(store, &me, None, timeout).map_err(explain)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Blocks a correspondent and pushes the changed token set.
|
||||
pub fn block_contact(
|
||||
store: &Store,
|
||||
master: &[u8; KEY_LEN],
|
||||
address: &str,
|
||||
timeout: u64,
|
||||
) -> Result<bool, String> {
|
||||
let blocked = store.block(address).map_err(explain)?;
|
||||
let me = account(store, master)?;
|
||||
client::push_tokens(store, &me, None, timeout).map_err(explain)?;
|
||||
Ok(blocked)
|
||||
}
|
||||
|
||||
/// Adds a contact from a self-certifying `smol://` address.
|
||||
pub fn import_contact(store: &Store, uri: &str) -> Result<(String, String), String> {
|
||||
let addr = parse_address(uri)?;
|
||||
let key = addr
|
||||
.identity
|
||||
.ok_or("import needs a self-certifying smol:// address carrying a key")?;
|
||||
store
|
||||
.save_contact(&addr.short(), &key, true)
|
||||
.map_err(explain)?;
|
||||
Ok((addr.short(), b32(&key)))
|
||||
}
|
||||
|
||||
pub fn contacts(store: &Store) -> Result<Vec<Contact>, String> {
|
||||
Ok(store
|
||||
.contact_rows()
|
||||
.map_err(explain)?
|
||||
.into_iter()
|
||||
.map(|(address, identity, verified, accepted)| Contact {
|
||||
address,
|
||||
key: b32(&identity),
|
||||
verified,
|
||||
accepted: accepted.map(|state| state == 1),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn rotate(
|
||||
store: &Store,
|
||||
master: &[u8; KEY_LEN],
|
||||
timeout: u64,
|
||||
) -> Result<(u32, String), String> {
|
||||
let me = account(store, master)?;
|
||||
let rotated = client::rotate(store, &me, None, timeout).map_err(explain)?;
|
||||
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> {
|
||||
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))
|
||||
}
|
||||
|
||||
pub fn import_backup(store: &Store, master: &[u8; KEY_LEN], text: &str) -> Result<String, String> {
|
||||
let summary = export::import(store, master, text).map_err(explain)?;
|
||||
Ok(summary.to_string())
|
||||
}
|
||||
|
||||
fn hex32(text: &str) -> Result<[u8; KEY_LEN], String> {
|
||||
let raw = if text.len() == 64 && text.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
(0..32)
|
||||
.map(|i| u8::from_str_radix(&text[i * 2..i * 2 + 2], 16))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())?
|
||||
} else {
|
||||
return Err("message ids are 64 hex characters".to_string());
|
||||
};
|
||||
raw.try_into()
|
||||
.map_err(|_| "message ids are 32 bytes".to_string())
|
||||
}
|
||||
|
||||
/// Lists one folder as rows, marking unread against the local read set.
|
||||
fn rows(store: &Store, me: &account::Account, folder: Folder, read: &HashSet<String>) -> Vec<Row> {
|
||||
store
|
||||
.mail(folder.table())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|stored| {
|
||||
let id = b32_hex(&stored.id);
|
||||
let described = client::describe(store, me, &stored).ok()?;
|
||||
let from = match folder {
|
||||
Folder::Sent => stored.recipient.clone().unwrap_or_default(),
|
||||
_ => described.from,
|
||||
};
|
||||
let unread = !read.contains(&id);
|
||||
Some(Row {
|
||||
id,
|
||||
from,
|
||||
subject: described.subject,
|
||||
at: described.time,
|
||||
unread,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// All three folders at once, so the sidebar counts never go stale.
|
||||
pub fn lists(
|
||||
store: &Store,
|
||||
master: &[u8; KEY_LEN],
|
||||
read: &HashSet<String>,
|
||||
) -> Result<Vec<Vec<Row>>, String> {
|
||||
let me = account(store, master)?;
|
||||
Ok(Folder::ALL
|
||||
.iter()
|
||||
.map(|folder| rows(store, &me, *folder, read))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn open(
|
||||
store: &Store,
|
||||
master: &[u8; KEY_LEN],
|
||||
id: &str,
|
||||
folder: Folder,
|
||||
) -> Result<OpenedMsg, String> {
|
||||
let raw = hex32(id)?;
|
||||
let me = account(store, master)?;
|
||||
let stored = store
|
||||
.mail(folder.table())
|
||||
.map_err(explain)?
|
||||
.into_iter()
|
||||
.find(|m| m.id == raw)
|
||||
.ok_or_else(|| "message not found".to_string())?;
|
||||
let described = client::describe(store, &me, &stored).map_err(explain)?;
|
||||
Ok(OpenedMsg {
|
||||
id: id.to_string(),
|
||||
from: match folder {
|
||||
Folder::Sent => stored.recipient.clone().unwrap_or_default(),
|
||||
_ => described.from,
|
||||
},
|
||||
subject: described.subject,
|
||||
time: described.time,
|
||||
body: described.text,
|
||||
sender: b32(&described.sender),
|
||||
reply_to: described.fields.get("reply-to").cloned(),
|
||||
kept: stored.kept,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn whoami(store: &Store, master: &[u8; KEY_LEN]) -> Result<Who, String> {
|
||||
let me = account(store, master)?;
|
||||
let address = store
|
||||
.account()
|
||||
.map_err(explain)?
|
||||
.map(|addr| addr.short())
|
||||
.unwrap_or_default();
|
||||
Ok(Who {
|
||||
address,
|
||||
key: b32(&me.me().pk()),
|
||||
fingerprint: fingerprint(&me.me().pk()),
|
||||
rotations: me.index(),
|
||||
})
|
||||
}
|
||||
|
||||
fn b32_hex(raw: &[u8; KEY_LEN]) -> String {
|
||||
raw.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// UTC stamp for an epoch-second timestamp; mail is not minute-critical and
|
||||
/// the writer's zone is unknown, so UTC is the honest display.
|
||||
pub fn when(at: i64) -> String {
|
||||
let days = at.div_euclid(86400);
|
||||
let secs = at.rem_euclid(86400);
|
||||
let (year, month, day) = civil_from_days(days);
|
||||
format!(
|
||||
"{year:04}-{month:02}-{day:02} {:02}:{:02}",
|
||||
secs / 3600,
|
||||
(secs % 3600) / 60
|
||||
)
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a civil date (Howard Hinnant's algorithm).
|
||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719468;
|
||||
let era = if z >= 0 { z } else { z - 146096 } / 146097;
|
||||
let doe = z - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let year = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
(if month <= 2 { year + 1 } else { year }, month, day)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fumi_core::message::{build_frontmatter, message_id, seal};
|
||||
use fumi_core::store::now;
|
||||
|
||||
fn master() -> [u8; KEY_LEN] {
|
||||
let mut seed = [0u8; KEY_LEN];
|
||||
for (i, b) in seed.iter_mut().enumerate() {
|
||||
*b = i as u8;
|
||||
}
|
||||
seed
|
||||
}
|
||||
|
||||
fn setup() -> Store {
|
||||
Store::open_in_memory().expect("in-memory store")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_round_trips_through_base32_and_hex() {
|
||||
let m = master();
|
||||
assert_eq!(parse_seed(&seed_text(&m)).unwrap(), m);
|
||||
let hex: String = m.iter().map(|b| format!("{b:02x}")).collect();
|
||||
assert_eq!(parse_seed(&hex).unwrap(), m);
|
||||
assert!(parse_seed("not a seed").is_err());
|
||||
assert!(parse_seed(&hex[..63]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_parsing_round_trips() {
|
||||
let m = master();
|
||||
assert_eq!(parse_key(&b32(&m)).unwrap(), m);
|
||||
assert!(parse_key("short").is_err());
|
||||
}
|
||||
|
||||
/// A sealed self-copy in the store surfaces as a row, opens with its
|
||||
/// frontmatter split, and flips to read against the marks set.
|
||||
#[test]
|
||||
fn list_and_open_round_trip() {
|
||||
let store = setup();
|
||||
let m = master();
|
||||
let me = account(&store, &m).unwrap();
|
||||
let addr = Address::parse("me@localhost").unwrap();
|
||||
store.set_account(&addr).unwrap();
|
||||
|
||||
let body = build_frontmatter(
|
||||
&[("Subject", "hello"), ("Reply-To", &addr.uri(&me.me().pk()))],
|
||||
"the body text",
|
||||
);
|
||||
let envelope = seal(me.me(), &me.me().pk(), body.as_bytes(), now(), true).unwrap();
|
||||
let id = message_id(&envelope);
|
||||
store
|
||||
.store_inbox(&id, &envelope, now(), false, false)
|
||||
.unwrap();
|
||||
let id_hex: String = id.iter().map(|b| format!("{b:02x}")).collect();
|
||||
|
||||
let empty = HashSet::new();
|
||||
let folders = lists(&store, &m, &empty).unwrap();
|
||||
let inbox = &folders[0];
|
||||
assert_eq!(inbox.len(), 1);
|
||||
assert_eq!(inbox[0].subject, "hello");
|
||||
assert_eq!(inbox[0].from, "me@localhost");
|
||||
assert!(inbox[0].unread);
|
||||
|
||||
let opened = open(&store, &m, &id_hex, Folder::Inbox).unwrap();
|
||||
assert_eq!(opened.subject, "hello");
|
||||
assert_eq!(opened.body, "the body text");
|
||||
assert!(opened.reply_to.is_some());
|
||||
|
||||
let mut read = HashSet::new();
|
||||
read.insert(id_hex);
|
||||
let folders = lists(&store, &m, &read).unwrap();
|
||||
assert!(!folders[0][0].unread);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_contact_saves_verified() {
|
||||
let store = setup();
|
||||
let me = account(&store, &master()).unwrap();
|
||||
let addr = Address::parse("me@localhost").unwrap();
|
||||
let uri = addr.uri(&me.me().pk());
|
||||
import_contact(&store, &uri).unwrap();
|
||||
let rows = contacts(&store).unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].address, "me@localhost");
|
||||
assert!(rows[0].verified);
|
||||
// A short form carries no key, so importing it is an error.
|
||||
assert!(import_contact(&store, "me@localhost").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_round_trips() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = setup();
|
||||
let m = master();
|
||||
let me = account(&store, &m).unwrap();
|
||||
let addr = Address::parse("me@localhost").unwrap();
|
||||
store.set_account(&addr).unwrap();
|
||||
|
||||
let body = build_frontmatter(&[("Subject", "kept")], "sealed body");
|
||||
let envelope = seal(me.me(), &me.me().pk(), body.as_bytes(), now(), true).unwrap();
|
||||
let id = message_id(&envelope);
|
||||
store
|
||||
.store_inbox(&id, &envelope, now(), false, false)
|
||||
.unwrap();
|
||||
|
||||
let (path, text) = export_backup(&store, &m, dir.path()).unwrap();
|
||||
assert!(path.exists());
|
||||
assert!(!text.is_empty());
|
||||
|
||||
let fresh = setup();
|
||||
let summary = import_backup(&fresh, &m, &text).unwrap();
|
||||
assert!(summary.contains("1 messages"));
|
||||
let empty = HashSet::new();
|
||||
let folders = lists(&fresh, &m, &empty).unwrap();
|
||||
assert_eq!(folders[0].len(), 1);
|
||||
assert_eq!(folders[0][0].subject, "kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpin_splits_the_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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stamps_are_utc_civil_dates() {
|
||||
assert_eq!(when(0), "1970-01-01 00:00");
|
||||
assert_eq!(when(1_759_279_361), "2025-10-01 00:42");
|
||||
// Negative stays well-defined (pre-1970).
|
||||
assert_eq!(when(-1), "1969-12-31 23:59");
|
||||
}
|
||||
}
|
||||
126
src/theme.rs
Normal file
126
src/theme.rs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
//! The one palette the whole UI is built from: dark navy surfaces, light
|
||||
//! text, mint accents — the same scheme kirakira ships as its dark theme.
|
||||
|
||||
use iced::border;
|
||||
use iced::color;
|
||||
use iced::theme::Palette;
|
||||
use iced::widget::{button, container};
|
||||
use iced::{Border, Color, Theme};
|
||||
|
||||
pub const BACKGROUND: Color = color!(0x01111d);
|
||||
pub const PANEL: Color = color!(0x0a1c2e);
|
||||
pub const CARD: Color = color!(0x10263b);
|
||||
pub const ACCENT: Color = color!(0x3ddc97);
|
||||
pub const TEXT: Color = color!(0xdce7f2);
|
||||
pub const MUTED: Color = color!(0x7a90a5);
|
||||
pub const DANGER: Color = color!(0xf0564a);
|
||||
|
||||
pub fn theme() -> Theme {
|
||||
Theme::custom(
|
||||
"carrier".to_string(),
|
||||
Palette {
|
||||
background: BACKGROUND,
|
||||
text: TEXT,
|
||||
primary: ACCENT,
|
||||
success: ACCENT,
|
||||
danger: DANGER,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The surface of the navigation sidebar.
|
||||
pub fn panel(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(PANEL.into()),
|
||||
text_color: Some(TEXT),
|
||||
..container::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A card slightly lighter than its panel.
|
||||
pub fn card(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(CARD.into()),
|
||||
text_color: Some(TEXT),
|
||||
border: border::rounded(6),
|
||||
..container::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The status line at the bottom of the window.
|
||||
pub fn status(error: bool) -> impl Fn(&Theme) -> container::Style {
|
||||
move |_theme| container::Style {
|
||||
background: Some(PANEL.into()),
|
||||
text_color: Some(if error { DANGER } else { MUTED }),
|
||||
..container::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The primary action button: mint on navy.
|
||||
pub fn accent() -> impl Fn(&Theme, button::Status) -> button::Style {
|
||||
move |_theme, status| {
|
||||
let hovered = matches!(status, button::Status::Hovered);
|
||||
button::Style {
|
||||
background: Some(if hovered { color!(0x54e8a8) } else { ACCENT }.into()),
|
||||
text_color: BACKGROUND,
|
||||
border: border::rounded(4),
|
||||
..button::Style::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A quiet secondary button: filled with the panel color, no border.
|
||||
pub fn quiet() -> impl Fn(&Theme, button::Status) -> button::Style {
|
||||
move |_theme, status| {
|
||||
let hovered = matches!(status, button::Status::Hovered);
|
||||
button::Style {
|
||||
background: Some(if hovered { CARD } else { PANEL }.into()),
|
||||
text_color: TEXT,
|
||||
border: border::rounded(4),
|
||||
..button::Style::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A destructive action, red text on a quiet surface.
|
||||
pub fn danger() -> impl Fn(&Theme, button::Status) -> button::Style {
|
||||
move |_theme, status| {
|
||||
let hovered = matches!(status, button::Status::Hovered);
|
||||
button::Style {
|
||||
background: Some(if hovered { color!(0x2a1420) } else { PANEL }.into()),
|
||||
text_color: DANGER,
|
||||
border: border::rounded(4),
|
||||
..button::Style::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single list row; the selected row carries the accent border.
|
||||
pub fn row_style(selected: bool) -> impl Fn(&Theme, button::Status) -> button::Style {
|
||||
move |_theme, status| {
|
||||
let hovered = matches!(status, button::Status::Hovered);
|
||||
button::Style {
|
||||
background: Some(
|
||||
if selected {
|
||||
CARD
|
||||
} else if hovered {
|
||||
color!(0x0d2136)
|
||||
} else {
|
||||
PANEL
|
||||
}
|
||||
.into(),
|
||||
),
|
||||
text_color: TEXT,
|
||||
border: if selected {
|
||||
Border {
|
||||
color: ACCENT,
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
}
|
||||
} else {
|
||||
border::rounded(4)
|
||||
},
|
||||
..button::Style::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
818
src/view.rs
Normal file
818
src/view.rs
Normal file
|
|
@ -0,0 +1,818 @@
|
|||
//! The view: a compact horizontal split — a tab bar, the full-width list
|
||||
//! above, the article below, and a status line at the bottom edge. Protocol
|
||||
//! values (addresses, keys, ids, dates) are monospace; prose stays sans.
|
||||
|
||||
use iced::widget::text::Wrapping;
|
||||
use iced::widget::{
|
||||
button, column, container, qr_code, row, rule, scrollable, text, text_editor, text_input, Space,
|
||||
};
|
||||
use iced::{Alignment, Element, Font, Length, Padding};
|
||||
|
||||
use fumi_core::crypto::{b32, fingerprint};
|
||||
|
||||
use crate::app::{Carrier, Dialog, Message, OnboardMode, Page};
|
||||
use crate::ops::{self, Contact, Folder, Row};
|
||||
use crate::theme::{self, ACCENT, MUTED, TEXT};
|
||||
|
||||
/// Column widths of the list, in pixels; monospace keeps them aligned.
|
||||
const DATE_W: f32 = 104.0;
|
||||
const FLAG_W: f32 = 24.0;
|
||||
const FROM_W: f32 = 248.0;
|
||||
const ADDR_W: f32 = 260.0;
|
||||
const KEY_W: f32 = 248.0;
|
||||
const STATE_W: f32 = 88.0;
|
||||
|
||||
pub fn view(app: &Carrier) -> Element<'_, Message> {
|
||||
let content: Element<'_, Message> = if app.compose.is_some() {
|
||||
compose(app)
|
||||
} else {
|
||||
match app.page {
|
||||
Page::Onboard => onboard(app),
|
||||
Page::Signup => signup(app),
|
||||
Page::Mail => mail(app),
|
||||
Page::Contacts => contacts(app),
|
||||
Page::Settings => settings(app),
|
||||
}
|
||||
};
|
||||
column![content, dialog_banner(app), status(app)]
|
||||
.spacing(0)
|
||||
.height(Length::Fill)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
// --- shared pieces ---------------------------------------------------------
|
||||
|
||||
/// One line of monospace UI text.
|
||||
fn mono<'a>(content: impl std::fmt::Display) -> iced::widget::Text<'a> {
|
||||
text(content.to_string())
|
||||
.font(Font::MONOSPACE)
|
||||
.size(12)
|
||||
.color(TEXT)
|
||||
}
|
||||
|
||||
fn muted<'a>(content: impl std::fmt::Display) -> iced::widget::Text<'a> {
|
||||
text(content.to_string())
|
||||
.font(Font::MONOSPACE)
|
||||
.size(12)
|
||||
.color(MUTED)
|
||||
}
|
||||
|
||||
fn accent<'a>(content: impl std::fmt::Display) -> iced::widget::Text<'a> {
|
||||
text(content.to_string())
|
||||
.font(Font::MONOSPACE)
|
||||
.size(12)
|
||||
.color(ACCENT)
|
||||
}
|
||||
|
||||
/// A fixed-width cell so every row lines up like a table column.
|
||||
fn cell<'a>(content: iced::widget::Text<'a>, width: f32) -> Element<'a, Message> {
|
||||
container(content)
|
||||
.width(width)
|
||||
.align_x(Alignment::Start)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn action<'a>(label: &str, msg: Message) -> Element<'a, Message> {
|
||||
button(text(label.to_string()).size(12))
|
||||
.on_press(msg)
|
||||
.style(theme::accent())
|
||||
.padding([3, 10])
|
||||
.into()
|
||||
}
|
||||
|
||||
fn action_disabled<'a>(label: &str) -> Element<'a, Message> {
|
||||
button(text(label.to_string()).size(12))
|
||||
.padding([3, 10])
|
||||
.style(theme::quiet())
|
||||
.into()
|
||||
}
|
||||
|
||||
fn quiet_action<'a>(label: &str, msg: Message) -> Element<'a, Message> {
|
||||
button(text(label.to_string()).size(12))
|
||||
.on_press(msg)
|
||||
.style(theme::quiet())
|
||||
.padding([3, 10])
|
||||
.into()
|
||||
}
|
||||
|
||||
fn danger_action<'a>(label: &str, msg: Message) -> Element<'a, Message> {
|
||||
button(text(label.to_string()).size(12))
|
||||
.on_press(msg)
|
||||
.style(theme::danger())
|
||||
.padding([3, 10])
|
||||
.into()
|
||||
}
|
||||
|
||||
/// A tab entry: accent text when active, muted otherwise.
|
||||
fn tab<'a>(label: impl std::fmt::Display, active: bool, msg: Message) -> Element<'a, Message> {
|
||||
let label = label.to_string();
|
||||
button(
|
||||
text(label)
|
||||
.font(Font::MONOSPACE)
|
||||
.size(12)
|
||||
.color(if active { ACCENT } else { MUTED }),
|
||||
)
|
||||
.on_press(msg)
|
||||
.padding([3, 6])
|
||||
.into()
|
||||
}
|
||||
|
||||
/// The bar every main page shares: identity, folder tabs, page tabs, actions.
|
||||
fn top_bar<'a>(app: &'a Carrier) -> Element<'a, Message> {
|
||||
let address = app
|
||||
.snapshot
|
||||
.as_ref()
|
||||
.map(|snap| snap.who.address.clone())
|
||||
.unwrap_or_default();
|
||||
let unread = |folder: usize| {
|
||||
app.lists
|
||||
.get(folder)
|
||||
.map(|rows| rows.iter().filter(|r| r.unread).count())
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let fetching = app.busy.as_deref() == Some("fetching");
|
||||
let mut tabs = row![].spacing(2);
|
||||
for (folder, label) in [
|
||||
(Folder::Inbox, "Inbox"),
|
||||
(Folder::Requests, "Requests"),
|
||||
(Folder::Sent, "Sent"),
|
||||
] {
|
||||
let count = unread(folder as usize);
|
||||
let label = if count > 0 {
|
||||
format!("{label} {count}")
|
||||
} else {
|
||||
label.to_string()
|
||||
};
|
||||
tabs = tabs.push(tab(
|
||||
label,
|
||||
app.folder == folder && app.page == Page::Mail,
|
||||
Message::GotoMail(folder),
|
||||
));
|
||||
}
|
||||
tabs = tabs
|
||||
.push(tab(
|
||||
"Contacts",
|
||||
app.page == Page::Contacts,
|
||||
Message::Goto(Page::Contacts),
|
||||
))
|
||||
.push(tab(
|
||||
"Settings",
|
||||
app.page == Page::Settings,
|
||||
Message::Goto(Page::Settings),
|
||||
));
|
||||
let fetch = if fetching {
|
||||
action_disabled("Fetching…")
|
||||
} else if app.busy.is_some() {
|
||||
action_disabled("Fetch")
|
||||
} else {
|
||||
action("Fetch", Message::FetchPressed)
|
||||
};
|
||||
let write = if app.busy.is_some() {
|
||||
action_disabled("Write")
|
||||
} else {
|
||||
action("Write", Message::ComposePressed)
|
||||
};
|
||||
container(
|
||||
row![
|
||||
accent("carrier"),
|
||||
mono(address),
|
||||
Space::with_width(Length::Fill),
|
||||
tabs,
|
||||
Space::with_width(Length::Fill),
|
||||
fetch,
|
||||
write,
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.style(theme::panel)
|
||||
.padding([6, 10])
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// The one status line: the busy label with its cancel button while an
|
||||
/// operation runs, otherwise the last result or error.
|
||||
fn status<'a>(app: &'a Carrier) -> Element<'a, Message> {
|
||||
let mut line = row![].spacing(10).align_y(Alignment::Center);
|
||||
match (&app.busy, &app.status) {
|
||||
(Some(busy), _) => {
|
||||
line = line.push(muted(format!("{busy}…")));
|
||||
if busy == "fetching" {
|
||||
line = line.push(quiet_action("Cancel", Message::FetchCancelled));
|
||||
}
|
||||
}
|
||||
(None, Some(status)) => {
|
||||
let piece = if app.status_error {
|
||||
text(status.clone()).font(Font::MONOSPACE).size(12)
|
||||
} else {
|
||||
muted(status.clone())
|
||||
};
|
||||
line = line.push(piece);
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
container(line)
|
||||
.style(theme::status(app.status_error && app.busy.is_none()))
|
||||
.padding([4, 10])
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Inline confirmations: a slim banner between the content and the status.
|
||||
fn dialog_banner<'a>(app: &'a Carrier) -> Element<'a, Message> {
|
||||
let Some(dialog) = &app.dialog else {
|
||||
return row![].into();
|
||||
};
|
||||
let mut line = row![].spacing(8).align_y(Alignment::Center);
|
||||
match dialog {
|
||||
Dialog::Delete { kept, .. } => {
|
||||
line = line.push(muted("delete this message:"));
|
||||
line = line.push(danger_action("here", Message::DeleteConfirmed(false)));
|
||||
if *kept {
|
||||
line = line.push(danger_action("everywhere", Message::DeleteConfirmed(true)));
|
||||
}
|
||||
line = line.push(quiet_action("cancel", Message::DeleteCancelled));
|
||||
}
|
||||
Dialog::Rotate => {
|
||||
line = line.push(muted(
|
||||
"rotate the identity? the old key stays derivable; the index never moves back",
|
||||
));
|
||||
line = line.push(action("rotate", Message::RotateConfirmed));
|
||||
line = line.push(quiet_action("cancel", Message::RotateCancelled));
|
||||
}
|
||||
}
|
||||
container(line)
|
||||
.style(theme::status(false))
|
||||
.padding([4, 10])
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn pin_offer_banner<'a>(app: &'a Carrier) -> Element<'a, Message> {
|
||||
let Some((_, key)) = &app.pin_offer else {
|
||||
return row![].into();
|
||||
};
|
||||
row![
|
||||
muted("server key offered:"),
|
||||
mono(fingerprint(key)),
|
||||
mono(short(&b32(key), 24)),
|
||||
action("pin", Message::PinAccept),
|
||||
quiet_action("discard", Message::PinDiscard),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
// --- mail ------------------------------------------------------------------
|
||||
|
||||
fn mail<'a>(app: &'a Carrier) -> Element<'a, Message> {
|
||||
column![
|
||||
top_bar(app),
|
||||
container(list_pane(app)).height(Length::FillPortion(38)),
|
||||
rule::Rule::horizontal(1.0),
|
||||
container(detail_pane(app)).height(Length::FillPortion(62)),
|
||||
]
|
||||
.spacing(0)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn list_pane<'a>(app: &'a Carrier) -> Element<'a, Message> {
|
||||
let rows = app
|
||||
.lists
|
||||
.get(app.folder as usize)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let header = row![
|
||||
cell(muted("date"), DATE_W),
|
||||
cell(muted("f"), FLAG_W),
|
||||
cell(muted("from/to"), FROM_W),
|
||||
muted("subject"),
|
||||
]
|
||||
.spacing(10)
|
||||
.padding(padding());
|
||||
let mut list = column![header].spacing(1);
|
||||
if rows.is_empty() {
|
||||
let hint = match app.folder {
|
||||
Folder::Inbox => "inbox is empty — fetch to check the mailbox",
|
||||
Folder::Requests => "no requests",
|
||||
Folder::Sent => "nothing sent yet",
|
||||
};
|
||||
list = list.push(container(muted(hint)).padding(Padding::new(10.0)));
|
||||
}
|
||||
for row in &rows {
|
||||
let selected = app
|
||||
.open
|
||||
.as_ref()
|
||||
.is_some_and(|(folder, msg)| *folder == app.folder && msg.id == row.id);
|
||||
list = list.push(list_row(app.folder, row, selected));
|
||||
}
|
||||
scrollable(list).height(Length::Fill).into()
|
||||
}
|
||||
|
||||
/// One compact row: date, flags, from/to, subject — a flat button so the
|
||||
/// whole line is clickable like a mu4e header.
|
||||
fn list_row<'a>(folder: Folder, row: &Row, selected: bool) -> Element<'a, Message> {
|
||||
let flag = if row.unread {
|
||||
accent("●")
|
||||
} else {
|
||||
muted("·")
|
||||
};
|
||||
let date = ops::when(row.at);
|
||||
button(
|
||||
row![
|
||||
cell(mono(date), DATE_W),
|
||||
cell(flag, FLAG_W),
|
||||
cell(mono(truncate(&row.from, 32)), FROM_W),
|
||||
text(truncate(&row.subject, 64))
|
||||
.font(Font::MONOSPACE)
|
||||
.size(12)
|
||||
.color(if row.unread { TEXT } else { MUTED })
|
||||
.wrapping(Wrapping::None),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.on_press(Message::MessageSelected(folder, row.id.clone()))
|
||||
.style(theme::row_style(selected))
|
||||
.padding(padding())
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn padding() -> Padding {
|
||||
Padding::new(3.0).right(10.0).left(10.0)
|
||||
}
|
||||
|
||||
/// A `label: value` header line for the article view.
|
||||
fn header_line<'a>(label: &str, value: impl std::fmt::Display) -> Element<'a, Message> {
|
||||
row![muted(format!("{label:<9}")), mono(value),]
|
||||
.spacing(4)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn detail_pane<'a>(app: &'a Carrier) -> Element<'a, Message> {
|
||||
let Some((folder, msg)) = &app.open else {
|
||||
return container(muted("select a message to read it"))
|
||||
.center(Length::Fill)
|
||||
.into();
|
||||
};
|
||||
let mut headers = column![].spacing(1);
|
||||
let to_label = if *folder == Folder::Sent {
|
||||
"to"
|
||||
} else {
|
||||
"from"
|
||||
};
|
||||
headers = headers.push(header_line(to_label, &msg.from));
|
||||
headers = headers.push(header_line(
|
||||
"subject",
|
||||
if msg.subject.is_empty() {
|
||||
"(no subject)".to_string()
|
||||
} else {
|
||||
msg.subject.clone()
|
||||
},
|
||||
));
|
||||
headers = headers.push(header_line("date", format!("{} UTC", ops::when(msg.time))));
|
||||
headers = headers.push(header_line("id", short(&msg.id, 16)));
|
||||
if msg.kept {
|
||||
headers = headers.push(header_line("server", "a copy still exists there"));
|
||||
}
|
||||
let mut actions = row![].spacing(8);
|
||||
actions = actions.push(quiet_action("reply", Message::ReplyPressed));
|
||||
if *folder == Folder::Requests {
|
||||
if msg.reply_to.is_some() {
|
||||
actions = actions.push(action("accept", Message::AcceptPressed));
|
||||
actions = actions.push(danger_action("block", Message::BlockPressed));
|
||||
} else {
|
||||
actions = actions.push(muted("no reply address: cannot accept or block"));
|
||||
}
|
||||
}
|
||||
actions = actions.push(danger_action("delete", Message::DeletePressed));
|
||||
column![
|
||||
container(headers).padding([8, 10]),
|
||||
rule::Rule::horizontal(1.0),
|
||||
scrollable(column![container(
|
||||
text(msg.body.clone())
|
||||
.font(Font::MONOSPACE)
|
||||
.size(12)
|
||||
.color(TEXT)
|
||||
)
|
||||
.padding([8, 10])])
|
||||
.height(Length::Fill),
|
||||
rule::Rule::horizontal(1.0),
|
||||
container(actions).padding([6, 10]),
|
||||
]
|
||||
.spacing(0)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
// --- compose ---------------------------------------------------------------
|
||||
|
||||
/// Compose takes the lower pane, keeping the list visible above — writing is
|
||||
/// the article pane's other job, as in the layout it comes from.
|
||||
fn compose<'a>(app: &'a Carrier) -> Element<'a, Message> {
|
||||
let Some(compose) = &app.compose else {
|
||||
unreachable!("compose view without a draft")
|
||||
};
|
||||
let label = if compose.reply_to.is_some() {
|
||||
"reply"
|
||||
} else {
|
||||
"write"
|
||||
};
|
||||
column![
|
||||
top_bar(app),
|
||||
container(
|
||||
column![
|
||||
row![
|
||||
muted("to"),
|
||||
text_input("alice@example.org or smol://…", &compose.to)
|
||||
.on_input(Message::ComposeToEdited)
|
||||
.size(12)
|
||||
.width(Length::Fill),
|
||||
]
|
||||
.spacing(8),
|
||||
row![
|
||||
muted("subject"),
|
||||
text_input("subject", &compose.subject)
|
||||
.on_input(Message::ComposeSubjectEdited)
|
||||
.size(12)
|
||||
.width(Length::Fill),
|
||||
]
|
||||
.spacing(8),
|
||||
text_editor(&compose.body)
|
||||
.on_action(Message::ComposeBody)
|
||||
.font(Font::MONOSPACE)
|
||||
.size(12)
|
||||
.height(Length::Fill),
|
||||
row![
|
||||
action(label, Message::SendPressed),
|
||||
quiet_action("cancel", Message::ComposeClosed),
|
||||
]
|
||||
.spacing(8),
|
||||
]
|
||||
.spacing(6)
|
||||
)
|
||||
.padding(10)
|
||||
.height(Length::Fill),
|
||||
]
|
||||
.spacing(0)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
// --- contacts ---------------------------------------------------------------
|
||||
|
||||
fn contacts<'a>(app: &'a Carrier) -> Element<'a, Message> {
|
||||
let Some(snap) = &app.snapshot else {
|
||||
return column![top_bar(app)].into();
|
||||
};
|
||||
let own = format!("smol://{}/{}", snap.who.address, snap.who.key);
|
||||
let mut share = row![
|
||||
muted("you:"),
|
||||
mono(own),
|
||||
quiet_action("copy", Message::CopyOwnAddress),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center);
|
||||
if let Some(data) = &app.share_qr {
|
||||
share = share.push(container(qr_code(data).cell_size(3)).padding(0));
|
||||
}
|
||||
let header = row![
|
||||
cell(muted("address"), ADDR_W),
|
||||
cell(muted("key"), KEY_W),
|
||||
cell(muted("state"), STATE_W),
|
||||
muted(""),
|
||||
]
|
||||
.spacing(10)
|
||||
.padding(padding());
|
||||
let mut table = column![header].spacing(1);
|
||||
for contact in &snap.contacts {
|
||||
table = table.push(contact_row(contact));
|
||||
}
|
||||
if snap.contacts.is_empty() {
|
||||
table = table.push(
|
||||
container(muted("no contacts yet — import a smol:// address"))
|
||||
.padding(Padding::new(10.0)),
|
||||
);
|
||||
}
|
||||
column![
|
||||
top_bar(app),
|
||||
container(share).style(theme::panel).padding([6, 10]),
|
||||
container(
|
||||
row![
|
||||
text_input("paste a smol:// address to import", &app.contact_import)
|
||||
.on_input(Message::ContactImportEdited)
|
||||
.on_submit(Message::ContactImportPressed)
|
||||
.size(12)
|
||||
.width(Length::Fill),
|
||||
action("import", Message::ContactImportPressed),
|
||||
]
|
||||
.spacing(8)
|
||||
)
|
||||
.padding([6, 10]),
|
||||
scrollable(table).height(Length::Fill),
|
||||
]
|
||||
.spacing(0)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn contact_row<'a>(contact: &Contact) -> Element<'a, Message> {
|
||||
let state = match contact.accepted {
|
||||
Some(true) => "accepted",
|
||||
Some(false) => "blocked",
|
||||
None => {
|
||||
if contact.verified {
|
||||
"verified"
|
||||
} else {
|
||||
"tofu"
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut actions = row![quiet_action(
|
||||
"write",
|
||||
Message::ContactWrite(contact.address.clone()),
|
||||
)]
|
||||
.spacing(6);
|
||||
match contact.accepted {
|
||||
Some(true) => {
|
||||
actions = actions.push(danger_action(
|
||||
"block",
|
||||
Message::ContactBlock(contact.address.clone()),
|
||||
));
|
||||
}
|
||||
Some(false) => {
|
||||
actions = actions.push(action(
|
||||
"unblock",
|
||||
Message::ContactUnblock(contact.address.clone()),
|
||||
));
|
||||
}
|
||||
None => {
|
||||
actions = actions.push(action(
|
||||
"accept",
|
||||
Message::ContactAccept(contact.address.clone(), contact.key.clone()),
|
||||
));
|
||||
}
|
||||
}
|
||||
container(
|
||||
row![
|
||||
cell(accent(&contact.address), ADDR_W),
|
||||
cell(mono(short(&contact.key, 32)), KEY_W),
|
||||
cell(muted(state), STATE_W),
|
||||
actions,
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.style(theme::card)
|
||||
.padding(padding())
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
// --- settings ---------------------------------------------------------------
|
||||
|
||||
fn settings<'a>(app: &'a Carrier) -> Element<'a, Message> {
|
||||
let Some(snap) = &app.snapshot else {
|
||||
return column![top_bar(app)].into();
|
||||
};
|
||||
let who = &snap.who;
|
||||
let identity = column![
|
||||
header_line("address", &who.address),
|
||||
header_line("key", &who.key),
|
||||
header_line("fingerprint", &who.fingerprint),
|
||||
header_line("rotations", who.rotations),
|
||||
row![
|
||||
action("rotate", Message::RotatePressed),
|
||||
quiet_action("learn key", Message::SettingsLearnKey),
|
||||
]
|
||||
.spacing(8),
|
||||
]
|
||||
.spacing(1);
|
||||
|
||||
let mut pins = column![].spacing(1);
|
||||
for (label, key) in &snap.pins {
|
||||
pins = pins.push(
|
||||
row![
|
||||
cell(mono(label), ADDR_W),
|
||||
cell(mono(short(&b32(key), 32)), KEY_W),
|
||||
danger_action("unpin", Message::UnpinPressed(label.clone())),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center),
|
||||
);
|
||||
}
|
||||
if snap.pins.is_empty() {
|
||||
pins = pins.push(muted("no server keys pinned"));
|
||||
}
|
||||
|
||||
// Pinned to the app borrow: the column mixes owned rows with the
|
||||
// `text_editor` that borrows `app.import_body`.
|
||||
let mut export: iced::widget::Column<'_, Message> = column![].spacing(4);
|
||||
export = export.push(
|
||||
row![
|
||||
action("export", Message::ExportPressed),
|
||||
quiet_action("copy", Message::CopyExport),
|
||||
]
|
||||
.spacing(8),
|
||||
);
|
||||
if let Some(text) = &app.backup_text {
|
||||
export = export.push(scrollable(mono(short(text, 4000))).height(Length::Fixed(80.0)));
|
||||
}
|
||||
export = export.push(muted("import a backup (gsmol): paste it below"));
|
||||
export = export.push(
|
||||
text_editor(&app.import_body)
|
||||
.on_action(Message::ImportBody)
|
||||
.font(Font::MONOSPACE)
|
||||
.size(12)
|
||||
.height(Length::Fixed(100.0)),
|
||||
);
|
||||
export = export.push(action("import", Message::ImportPressed));
|
||||
|
||||
let sections = column![
|
||||
section("identity", identity.into()),
|
||||
section("server keys", pins.into()),
|
||||
section("backup", export.into()),
|
||||
section(
|
||||
"network",
|
||||
row![
|
||||
muted("timeout"),
|
||||
text_input("30", &app.timeout_text)
|
||||
.on_input(Message::TimeoutEdited)
|
||||
.size(12)
|
||||
.width(80),
|
||||
muted("seconds"),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.into(),
|
||||
),
|
||||
]
|
||||
.spacing(10);
|
||||
|
||||
column![
|
||||
top_bar(app),
|
||||
pin_offer_banner(app),
|
||||
scrollable(container(sections).padding(10)).height(Length::Fill),
|
||||
]
|
||||
.spacing(0)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn section<'a>(title: &str, body: Element<'a, Message>) -> Element<'a, Message> {
|
||||
container(
|
||||
column![container(accent(title)).padding([2, 0]), body]
|
||||
.spacing(6)
|
||||
.max_width(720),
|
||||
)
|
||||
.style(theme::card)
|
||||
.padding(10)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
// --- onboarding and signup ---------------------------------------------------
|
||||
|
||||
fn centered<'a>(body: Element<'a, Message>) -> Element<'a, Message> {
|
||||
container(body)
|
||||
.center(Length::Fill)
|
||||
.max_width(520)
|
||||
.padding(20)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn onboard<'a>(app: &'a Carrier) -> Element<'a, Message> {
|
||||
let body = match app.onboard.mode {
|
||||
OnboardMode::Start => column![
|
||||
text("carrier").font(Font::MONOSPACE).size(24).color(ACCENT),
|
||||
muted("smolmail: one seed, sealed mail, five operations"),
|
||||
row![
|
||||
action("create an identity", Message::OnboardCreate),
|
||||
quiet_action("restore from a seed", Message::OnboardRestore),
|
||||
]
|
||||
.spacing(8)
|
||||
.padding([12, 0]),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_x(Alignment::Start),
|
||||
OnboardMode::Created => column![
|
||||
muted("this seed is the only secret — write it down now; it is shown once"),
|
||||
container(
|
||||
text_input("", &app.onboard.seed_shown)
|
||||
.font(Font::MONOSPACE)
|
||||
.size(12)
|
||||
.width(Length::Fill)
|
||||
)
|
||||
.style(theme::card)
|
||||
.padding(6),
|
||||
row![
|
||||
action("i saved it", Message::OnboardSeedSaved),
|
||||
quiet_action("back", Message::OnboardBack),
|
||||
]
|
||||
.spacing(8),
|
||||
]
|
||||
.spacing(10),
|
||||
OnboardMode::Restore => column![
|
||||
muted("paste the 32-byte seed, base32 or hex"),
|
||||
text_input("seed", &app.onboard.seed)
|
||||
.on_input(Message::OnboardSeedEdited)
|
||||
.on_submit(Message::OnboardRestoreFromSeed)
|
||||
.size(12)
|
||||
.width(Length::Fill),
|
||||
row![
|
||||
action("restore", Message::OnboardRestoreFromSeed),
|
||||
quiet_action("back", Message::OnboardBack),
|
||||
]
|
||||
.spacing(8),
|
||||
]
|
||||
.spacing(10),
|
||||
};
|
||||
centered(body.into())
|
||||
}
|
||||
|
||||
fn signup<'a>(app: &'a Carrier) -> Element<'a, Message> {
|
||||
let mut body = column![
|
||||
accent("set up the address"),
|
||||
row![
|
||||
muted("user"),
|
||||
text_input("alice", &app.signup.user)
|
||||
.on_input(Message::SignupUserEdited)
|
||||
.size(12)
|
||||
.width(Length::Fill),
|
||||
]
|
||||
.spacing(8),
|
||||
row![
|
||||
muted("host"),
|
||||
text_input("example.org", &app.signup.host)
|
||||
.on_input(Message::SignupHostEdited)
|
||||
.size(12)
|
||||
.width(Length::Fill),
|
||||
]
|
||||
.spacing(8),
|
||||
row![
|
||||
muted("invite"),
|
||||
text_input("optional token", &app.signup.invite)
|
||||
.on_input(Message::SignupInviteEdited)
|
||||
.size(12)
|
||||
.width(Length::Fill),
|
||||
]
|
||||
.spacing(8),
|
||||
row![
|
||||
muted("key"),
|
||||
text_input("server key, if the operator published one", &app.signup.key)
|
||||
.on_input(Message::SignupKeyEdited)
|
||||
.size(12)
|
||||
.width(Length::Fill),
|
||||
]
|
||||
.spacing(8),
|
||||
]
|
||||
.spacing(8);
|
||||
if app.signup.key.is_empty() {
|
||||
body = body.push(
|
||||
row![
|
||||
muted("or"),
|
||||
quiet_action("learn the server key", Message::SignupLearnKey),
|
||||
muted("and pin what it presents"),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center),
|
||||
);
|
||||
}
|
||||
body = body.push(pin_offer_banner(app));
|
||||
body = body.push(
|
||||
row![
|
||||
action("register", Message::SignupRegister),
|
||||
quiet_action("i already have this address", Message::SignupRecall),
|
||||
]
|
||||
.spacing(8)
|
||||
.padding([8, 0]),
|
||||
);
|
||||
if app.signup.fresh {
|
||||
body = body.push(quiet_action("back", Message::SignupBack));
|
||||
}
|
||||
centered(body.into())
|
||||
}
|
||||
|
||||
// --- small helpers -----------------------------------------------------------
|
||||
|
||||
fn truncate(text: &str, max: usize) -> String {
|
||||
if text.chars().count() <= max {
|
||||
text.to_string()
|
||||
} else {
|
||||
let cut: String = text.chars().take(max - 1).collect();
|
||||
format!("{cut}…")
|
||||
}
|
||||
}
|
||||
|
||||
fn short(text: &str, max: usize) -> String {
|
||||
if text.len() <= max {
|
||||
text.to_string()
|
||||
} else {
|
||||
format!("{}…{}", &text[..max / 2], &text[text.len() - 3..])
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue