Files
margin/simplify/.research/rust-core.md
T
2026-10-03 22:48:49 +05:30

32 KiB

Rust core plumbing (research, 2026-09-06)

What the four apps have in common below the features: library.rs, lib.rs, dto.rs, SQLite, logging, settings, filesystem helpers, error shapes, updates, async, and dependency versions. Everything here was read off disk. Google/OAuth, typesetting and spellcheck are other notes.

Sizes for reference: 2,190 Rust lines in Margin, 8,490 in Calendar, 5,365 in Docs, 45,884 in Mail. Mail's number includes 8,045 lines of tests.rs files; Calendar's includes 802.

The verdict first

Area Apps that have it How close, really One crate?
lib.rs builder and menu 4 ~120 lines per app verbatim identical Yes, the biggest single win
Logging 1 (Mail) three apps have nothing Yes, and it fixes a real gap
Updates 1.5 (Margin, plus packaged_by in two) Margin's updates.rs is already app-agnostic Yes, cheap
SQLite 3 ~100 duplicated lines out of ~8,500 Yes, small, for the two bugs it fixes
library.rs 4 5 identical lines, then four different files No
dto.rs conventions 3 one convention, rigidly held, nothing to extract No
Settings 1 (Mail) the other three keep prefs in localStorage No
Filesystem helpers 2.5 three genuinely different algorithms No
Error types 4 already uniform, nothing to fix No
Async 3 one shared idea (Sink), 12 lines No

Three defects found on the way, listed at the end of the lib.rs section.

library.rs

Line counts: Margin 146, Calendar 9, Docs 9, Mail 54.

Calendar's and Docs' are byte-identical files (diff returns nothing): nine lines containing only app_data_dir. Mail's is that same function plus atomic_write and a test. Margin's is a different file that happens to share the name: BookSummary (margin library.rs:8-14), the four book commands, and app_data_dir at library.rs:49-53.

The genuinely shared part is five lines, identical in all four (margin library.rs:49-53, calendar library.rs:5-9, docs library.rs:5-9, mail library.rs:8-12):

pub fn app_data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
    let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
    fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
    Ok(dir)
}

It should move into whatever shared crate exists for other reasons. It is not a reason to create one. Everything else in Margin's library.rs is the book library and belongs to Margin.

lib.rs: the Tauri builder

Line counts: Margin 256, Calendar 354, Docs 365, Mail 475. 1,450 lines total.

Measured overlap: 73 distinct non-comment lines appear verbatim in all four files. Counting occurrences, that is 114 lines of Margin's lib.rs, 123 of Calendar's, 120 of Docs' and 124 of Mail's, which is 48%, 44%, 41% and 32% of each file's code lines. Excluding trivial brace lines it is still 73 to 81 lines each. Roughly 480 lines of duplicated boilerplate across the suite.

The identical blocks, in order:

main.rs. Six lines, identical in all four but for the crate name. Nothing to do here; Tauri requires it.

The builder prologue. Margin lib.rs:149-162, Calendar 250-263, Docs 250-266, Mail 253-268. generate_context! first, #[cfg_attr(mobile, allow(unused_mut))], then:

#[cfg(desktop)]
{
    builder = builder.plugin(tauri_plugin_process::init());
    if context.config().plugins.0.contains_key("updater") {
        builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
    }
}

Verbatim four times. Two copies say so in a comment: Calendar lib.rs:248-249 and Mail lib.rs:251-252 both read "Ported from margin's lib.rs".

The menu scaffold. Menu::default(handle), the submenus collect, the find_submenu closure, the match find_submenu("File") with its prepend_items / SubmenuBuilder arms, the Edit and Help appends, the macOS app-submenu insert block and the non-macOS fallback. Margin lib.rs:46-60 and 62-93, Calendar 50-64 and 66-95, Docs 100-114 and 115-146, Mail 84-98 and 100-135. Pairwise diffs of the whole build_menu: Calendar against Mail is 49 differing lines out of 118 and 123. Margin against Calendar is 89. Docs is the outlier at 149 to 159, because it rebuilds the macOS app submenu from scratch rather than patching Tauri's default (the reasoning is at Docs lib.rs:164-176 and is good).

The menu event. Identical line in all four: app.emit("menu-action", event.id().0.as_str()).ok(); (Margin lib.rs:192, Calendar 308, Docs 318, Mail 341), inside an identical matches! guard over a list of ids.

show_main_window. Calendar lib.rs:227-234 and Mail lib.rs:184-191 have identical bodies.

packaged_by. Calendar lib.rs:239-244 and Mail lib.rs:196-201, identical including the doc comment, differing only in the env var name (MARGIN_CALENDAR_PACKAGED_BY vs MARGIN_MAIL_PACKAGED_BY).

build.rs. Margin's and Docs' are the three-line default. Calendar's and Mail's are byte-identical 29-line files with the same embed_credentials and the same comment.

Where they genuinely must differ

The menu contents (ids, labels, accelerators, which submenus get extra rows), the invoke_handler list, the manage calls and the body of setup, deep link registration (Calendar and Mail only), and the iOS viewport fix and Android consent-tab watcher (Calendar only, see below).

Three defects found while reading

Margin Mail cannot compile for mobile. #[cfg_attr(mobile, tauri::mobile_entry_point)] sits at lib.rs:203, directly above attach_account, not above pub fn run() at lib.rs:250. Separately, setup calls listen_for_redirects (lib.rs:307), stop_uikit_shrinking_the_viewport (lib.rs:310) and watch_for_the_consent_tab_closing (lib.rs:314) under cfg(mobile), cfg(target_os = "ios") and cfg(target_os = "android"). None of the three is defined anywhere in the crate; grep returns only the call sites. All three exist in Calendar (lib.rs:149-172, 185-199, 213-220) and were evidently meant to be ported with the rest. The iOS and Android dependency blocks are in Cargo.toml waiting for them.

Four apps, three close behaviours. Calendar and Mail prevent the close and hide the window (Calendar lib.rs:313-321, Mail 346-354), then restore it on RunEvent::Reopen. Margin lets the window be destroyed but calls api.prevent_exit() and rebuilds the window from config on Reopen (lib.rs:231-238, 241-256). Docs calls .run(context) directly at lib.rs:363, so it has no RunEvent closure and no CloseRequested handler anywhere in the crate: closing the window quits the app. For a suite that shares a design language this is the kind of thing that should have one answer, and a shared shell crate would force one.

Capability drift. Margin puts core:window:allow-destroy and allow-start-dragging in default.json, which applies on every platform; the other three put them in desktop.json. Docs additionally carries core:window:allow-toggle-maximize. Nothing is broken, but four hand-edited copies of the same two files will keep drifting.

What the crate would be

A margin-shell crate holding: the plugin prologue as fn desktop_plugins(builder, context), the menu scaffold as fn standard_menu(handle, spec: &MenuSpec) -> tauri::Result<Menu<R>> where MenuSpec names the File rows, the extra Edit and View rows and the Help rows, the menu-action forwarding, show_main_window, hide_on_close, packaged_by(env_var) and app_data_dir. Around 250 lines, deleting roughly 400 across the four apps, and it makes the close behaviour and the capability set one decision instead of four.

dto.rs and the IPC boundary

Lines Structs Enums rename_all = "camelCase"
Margin no dto.rs types inline in their modules 0 8 across the crate
Calendar 181 10 1 11
Docs 243 15 0 15
Mail 927 40 11 43

The three dto.rs files open with the same two-line header ("The IPC contract. Every type here has a matching declaration in src/ipc.ts. Both sides are frozen once written: implementation modules add bodies, not fields."). Margin has no dto.rs, but follows the same convention where it matters: BookSummary at library.rs:8-14 is #[derive(serde::Serialize)] with rename_all = "camelCase".

The convention is one convention and it is held rigidly: #[derive(Debug, Clone, Serialize, Deserialize)] plus #[serde(rename_all = "camelCase")] on every type; #[serde(default)] on patch and optional fields (9 in Margin, 55 in Calendar, 7 in Docs, 148 in Mail); Option<T> for absent rather than a sentinel; i64 epoch milliseconds for time; a string field with the legal values in a doc comment (/// idle | syncing | error) instead of an enum. #[serde(rename = ...)] appears exactly once in the whole suite (Calendar dto.rs:36, for self), and skip_serializing_if twice, both in Mail. Mail is the only app with real enums, all rename_all = "kebab-case".

Is there a macro or crate here? No. #[serde(rename_all = "camelCase")] is already the shortest spelling of the thing; a derive macro would save one line per struct and put a proc-macro crate in four build graphs. The duplication that costs something is on the other side: 65 structs across the three dto.rs files each have a hand-written TypeScript interface in src/ipc.ts with nothing checking that they agree, and the first 41 lines of Calendar's and Docs' ipc.ts are byte identical. That is a codegen question (ts-rs, tauri-specta) for the frontend note.

The one type that genuinely repeats is the progress status: Calendar SyncStatus (dto.rs:147-167), Docs IndexStatus (dto.rs:87-109), Mail SyncStatus (dto.rs:861-893). All three are phase: String with the states in a doc comment, error: Option<String>, message: Option<String>, progress counters, and a hand-written Default or idle() constructor. The common core is six lines. Similarly AuthEvent: Calendar dto.rs:172-181 and Mail dto.rs:898-910, where Mail's is Calendar's plus granted_scopes and missing_required. Worth putting in a shared crate that exists anyway. Not worth one on its own.

SQLite

Present in three. Calendar store/ is 1,351 lines (about 960 non-test). Docs index.rs is 1,771 lines of which only about 420 touch SQLite at all, the rest being fzy scoring, snippet windowing and markdown parsing. Mail is 6,222 non-test lines across db.rs, mirror/ and state/, plus two .sql schema files. Call it 8,500 lines of database code.

Genuinely duplicated: 80 to 120 lines. These are not three copies of one layer, they are three different databases in one house style. The specific overlaps:

  • app_data_dir, as above.
  • version(), byte-identical between Calendar store/schema.rs:130-136 and Mail mirror/schema.rs:127-135, and again modulo the state. prefix at Mail state/schema.rs:56-64.
  • The meta upsert. Calendar store/write.rs:214-228, Mail mirror/write.rs:57-73, Docs' remember at index.rs:891-899. Four copies of one INSERT ... ON CONFLICT DO UPDATE.
  • now_ms. Docs index.rs:905-910 and Mail mirror/write.rs:42-46 are identical; Calendar's store/write.rs:87-89 is the chrono equivalent.
  • The placeholder helper: Docs placeholders (index.rs:901-903) and Mail holes (mirror/read.rs:898-900), same one-liner, different name and separator.
  • The FTS5 tokenizer string unicode61 remove_diacritics 2, in Docs index.rs:119-122 and Mail mirror/mirror.sql:169-178.
  • .map_err(|e| e.to_string()), 309 occurrences (Calendar 59, Docs 36, Mail 214). Not a function waiting to be extracted; a From impl waiting to be written.
  • The migrate skeleton: read version, refuse if newer, return if equal, run the ladder, stamp.

Where they legitimately differ. Three connection ownership models, each correct for its app: Mutex<Connection> (Calendar store/mod.rs:17), Mutex<Option<Connection>> behind a writer thread and a OnceLock<Sender> (Docs index.rs:143-151), and Mutex<HashMap<String, Connection>> with one pair of ATTACHed files per account (Mail db.rs:31-38). Pragmas are the same three ideas delivered three ways: pragma_update calls (Calendar store/mod.rs:17-34), an execute_batch literal (Docs index.rs:208-221), an execute_batch with a formatted ATTACH (Mail db.rs:129-151). Version storage differs on a real decision: PRAGMA user_version in Docs (index.rs:229-248) against a meta row in Calendar and Mail. FTS5 is in two apps and everything above the tokenizer line differs: Docs ranks with bm25 and highlight (index.rs:1184-1188), Mail uses the index purely as a membership subquery (mirror/read.rs:234) under a query language with from: and has: operators (mirror/fts.rs:100-127).

Two defects. Mail has no transactions outside its two migrations: grepping the whole crate for unchecked_transaction, BEGIN IMMEDIATE, .transaction() and SAVEPOINT returns exactly three hits, two execute_batch("BEGIN;") in mirror/schema.rs:37 and state/schema.rs:33, and one SAVEPOINT in state/journal.rs:278. So apply_and_queue (mirror/mod.rs:167-205), which does N flag updates plus N outbox inserts, runs unwrapped. Separately, Calendar's migration ladder is if found < 1 { V1 } else if found < 2 { V2 } (store/schema.rs:115-120), an else if, which will not compose when V3 lands. Mail's sequential ifs (mirror/schema.rs:39-44) will.

Neither app uses prepare_cached anywhere. Mail's still_bodiless (mirror/read.rs:949-969) prepares inside a loop.

The crate. margin-sqlite, roughly 250 lines: open(path, pragmas), Tx and Savepoint RAII guards (Calendar's store/write.rs:62-85 is the right shape already and takes &Connection rather than &mut, which is exactly what Mail needs from inside Db::with), migrate(conn, &[&str], version_store, noun), meta_get/meta_set generic over the table name so Mail's state.meta works, holes(n), now_ms, and a From<rusqlite::Error> error type. Net deletion is maybe 150 lines. Do it for the two defects it fixes and for the busy timeout, which only Mail sets today (db.rs:129-151, 5s), not for the volume. Every schema, every read and every write stays per app.

Logging

Only Margin Mail logs. src-tauri/src/log.rs is 137 lines, 94 of them not tests.

The surface: CAP_BYTES = 256 * 1024 (log.rs:23), init(&Path) (:34) which sets a OnceLock<PathBuf> to dir.join("margin-mail.log"), path() (:38), note(who, line) (:45) which always eprintln!s and then, only if PATH is set, timestamps and appends under a process-wide Mutex<()>, trim (:55) which flattens newlines and cuts at 2,000 characters, and append (:65) which is not rotation but a keep-the-newest-half rewrite when the cap is exceeded. #[tauri::command] log_note (:90) is registered at lib.rs:361, and the webview is the heavier producer: src/ipc.ts:752-758 logs every rejected invoke, and src/main.tsx:21,24 catch window.onerror and unhandledrejection.

The design decision worth keeping: log.rs is told its directory rather than reaching for an AppHandle. db.rs:46 calls log::init(&data) from inside Db::open, so the engine still works under cargo test. Before that call, lines go to stderr, which under a Finder launch is nowhere.

The other three:

eprintln! println! log crate tracing plugin-log log file
Margin 0 0 no no no none
Calendar 0 0 no no no none
Docs 6 0 no no no none
Mail 1 (inside log.rs) 3 (dead) own module no no margin-mail.log

Docs' six are lib.rs:270, lib.rs:278, index.rs:437, index.rs:596, watch.rs:210 and writingtools.rs:136, all going to /dev/null under a Finder launch. Margin and Calendar have nothing at all: when a Drive backup or a calendar sync fails, the string reaches the frontend and then the process forgets it.

This is the clearest shared-crate win in the whole audit. margin-log is init, note, path and the log_note command, about 100 lines, dependent only on chrono and std, with one thing to parameterise (the filename, derivable from the bundle identifier). Three apps gain the ability to answer "why did it fail" after the process has exited, which is the standing rule for this suite. Two things to fix while lifting: note discards the result of append, so a failed write is invisible, and it does blocking file I/O under a std::sync::Mutex from async contexts (sync/engine.rs:327, :363, :448, sync/hydrate.rs:276, google/gmail.rs:106). Bounded and infrequent, so not urgent, but do not copy it into three more apps unexamined. The frontend half (the .catch in ipc.ts plus the two handlers in main.tsx) is 15 more lines per app and catches most real failures.

Settings and persisted state

Only Mail has a settings layer in Rust. settings.rs is 479 lines (343 non-test): settings.json in the app data dir, a 25-field Settings struct at dto.rs:768, a hand-written defaults() (settings.rs:32), a genuine recursive JSON merge for settings_set(patch) (merge_into, settings.rs:234), an atomic write through library::atomic_write (settings.rs:221), and a deliberate refusal to reset on a malformed file (settings.rs:210, tested at :430). Mail also owns accounts.json, keymap.json and imap-trust.json in the same directory.

Where everyone else keeps configuration:

  • Margin: localStorage, 16 keys. Per-project settings live inside the .margin book file, merged against TypeScript defaults at src/model/book.ts:212. Rust holds no settings; its one JSON file is backup.json (gdrive.rs:139), Drive bookkeeping.
  • Calendar: localStorage, five keys. Its entire Settings screen edits one preference, week start day. No config file on disk in any format, and no atomic_write in the crate.
  • Docs: localStorage, seventeen keys behind zustand stores. Rust owns roots.json (fs.rs:60,778), which is workspace state rather than settings, and the index database.

Not a crate. Lifting settings.rs means inventing a settings backend for three apps that do not have one and whose preferences currently live in the webview. That is a feature, not a refactor, and the 25-field struct cannot move regardless. If it is ever wanted, the reusable core is: read JSON, deep-merge a patch, atomic write, error rather than reset on a parse failure. About 60 lines.

One latent bug to fix in place: Settings has exactly one #[serde(default)] field (dto.rs:805, notifications). Every other field is required, so the next field added without one will fail to parse every existing install's settings.json and settings_get will error out. There is a regression test for the one field that has a default (settings.rs:389), but the pattern was not generalised.

Filesystem helpers

Four atomic_writes, three genuinely different algorithms, all of them write, fsync, rename and none of them fsyncing the parent directory, so on all four a crash can still lose the rename.

  • Docs fs.rs:311-347 with helpers at :222-288. A per-path Arc<Mutex<()>> lock map so a debounced autosave cannot race Cmd+S; a copy of the original into the temp before truncating so macOS ACLs, Finder tags and the exec bit survive; a hidden collision-retried temp name (.{name}.{pid}-{seq}-{nanos:x}.tmp, 64 tries); four watch::note_self_write calls; and remove_file on both error paths.
  • Margin project.rs:13-30. Adds .bak rotation. When backup is false it remove_files the target before the rename (project.rs:26), opening a window where the file does not exist. Leaves the temp behind on failure.
  • Mail library.rs:19-33. Adds create_dir_all. Uses with_extension, which for a path with no extension produces a doubled dot. Leaves the temp behind on failure. No lock.
  • write_private in Calendar google/secrets.rs:243-261 and Mail google/secrets.rs:245-263 is a fourth variant and the only byte-identical pair, 0o600. That one belongs to the Google note.

Do not share the general one. Sharing it either drops Docs' watcher integration and lock map or drags the file watcher into the shared crate. Each divergence is justified in a comment in its own file. Do fix the two real bugs listed above, in place.

Not everything even goes through it: Mail writes the mbox export straight to fs::File::create (exports.rs:99-104) and the attachment cache with plain fs::write (attachments.rs:279, :482).

Trash: Docs only. trash = "5", used at fs.rs:712-730 with DeleteMethod::NsFileManager on macOS chosen deliberately over the crate default to avoid an Apple event entitlement, and used again as the safe half of a cross-volume move (fs.rs:676-677). Margin's delete_book (library.rs:143) is a bare remove_file.

Path validation: Docs is the only app with a real gate. resolve rejects non-absolute paths and any Component::ParentDir, canonicalises the deepest existing ancestor and re-appends the tail; resolve_in_roots requires starts_with an open root; checked (fs.rs:212-214) is what every path-taking command calls, reads included. check_name (fs.rs:149-158) rejects empty, ., .., separators and NUL. Mail sidesteps the problem by never letting the frontend name a write target; its only sanitiser is free_path (attachments.rs:363-389), which maps separators to - and trims dots. Margin has none: project.rs:32-46 exposes read_file, write_file and write_bytes as commands taking an arbitrary absolute path from the webview with no checking at all. Its one validated path is the book id whitelist at library.rs:61-66. That is a finding for Margin, not an argument for a crate.

File watching: Docs only. notify 8, notify-debouncer-full 0.7 and ignore 0.4 appear in no other app. 300ms debounce (watch.rs:33), NoCache chosen over the file-id cache because on macOS the inode cache folds the two halves of a rename together (watch.rs:222-229), self-write suppression on a 2s window (watch.rs:55,70-71), one emit per event (watch.rs:147), and kind derived from a fresh symlink_metadata rather than trusted from FSEvents flags (watch.rs:402-429). One app watches files. There is nothing to share.

Error types

Already uniform, and there is nothing to fix. Every one of the 165 #[tauri::command]s across the four apps returns either a bare value or Result<T, String>, with zero exceptions (Margin 24, Calendar 13, Docs 38, Mail 90). Counts of -> Result<T, String> anywhere: 52, 94, 91, 485.

thiserror and anyhow are dependencies of none of the four. Every Display is hand-written. The custom enums are internal and never cross to the frontend: Calendar ApiError (google/api.rs:132, four variants), Mail ApiError (google/api.rs:184, seven), Mail ProviderError (provider/mod.rs:26), Mail Refused (imap/tls.rs:95). There are five impl From in the whole suite. The two ApiErrors look like the same type and are not: Calendar needs SyncTokenExpired and PreconditionFailed, Mail needs Unauthorized, InsufficientScope and Dropped, and even the shared four-line From<reqwest::Error> differs deliberately, with a comment in Mail explaining why Calendar's simpler classification would be wrong for a mail client waking from sleep.

A shared Result/Error shape would be churn. The one useful piece is the From<rusqlite::Error> that kills 309 .map_err(|e| e.to_string()), and that lives in the SQLite crate.

Updates

Margin's updates.rs is 90 lines and does three things: derive a channel from the merged plugin config plus a Mac App Store receipt probe, query Apple's lookup endpoint for a newer App Store version, and open macappstore://. It is almost entirely app-agnostic already. channel() reads handle.config().plugins.0 for "updater" and "appstore"; mas_receipt() walks current_exe() up two levels to _MASReceipt/receipt; appstore_latest() reads config().identifier and package_info().version. No product name, no bundle id, no endpoint is hardcoded. It would drop into any of the other three unchanged.

Margin Calendar Docs Mail
tauri-plugin-updater yes yes yes yes
Conditional registration lib.rs:159 lib.rs:260 lib.rs:263 lib.rs:265
Channel concept yes no no no
App Store vs direct split yes no no no
packaged_by no lib.rs:239 no lib.rs:196
Release pubkey real real placeholder placeholder

Docs and Mail both ship a literal REPLACE_WITH_... string as the updater pubkey in tauri.release.conf.json, and neither release workflow substitutes it (the workflows only set TAURI_SIGNING_PRIVATE_KEY). Neither app can ship a verifiable direct-download update today. All four release configs are 14 lines with an identical structure differing only in pubkey and repo slug.

Share it. updates.rs plus packaged_by is one 110-line module with one thing to parameterise, and even the env var name could be derived from the bundle identifier. Second cheapest win after logging.

Async

Margin has no tokio dependency at all. Docs declares tokio = { features = ["sync", "time"] } (Cargo.toml:34) and never uses it: grep for tokio:: in its src-tauri/src returns nothing. That line is a copy from a sibling and should go.

tauri::async_runtime::spawn is the house style in the three apps that spawn (Margin gdrive.rs:765, Calendar sync.rs:205 and five more, Mail badge.rs:88 and five more). Raw tokio::spawn appears only in Mail's IMAP autodiscovery fan-out (imap/discover.rs:69-72, :382-383, :419), which is safe because Tauri's runtime is tokio but leaves those tasks untracked by Tauri's shutdown. Docs uses no async runtime for background work at all: three std::thread::spawns (watch.rs:260, watch.rs:287, index.rs:203) and #[tauri::command(async)] on sync functions.

The two poll loops are the closest pair of non-trivial code in the suite and are still not the same. Calendar sync.rs:204-215 and Mail sync/mod.rs:374-386 share the skeleton, share FIRST_PASS_SECS = 2, and share a focused() helper that is character-for-character identical (Calendar sync.rs:218-223, Mail sync/mod.rs:388-392). They differ on the wake: Calendar awaits a tokio::sync::Notify with a timeout, so kick (sync.rs:238-242) can pull the next tick forward, and it listens on store-changed to catch a freshly connected account (sync.rs:197-202). Mail's is a bare sleep, and kick (sync/mod.rs:456-464) spawns a separate sync_now instead, relying on the running: AtomicBool re-entrancy guard (sync/engine.rs:74-84) to keep the two from overlapping. Both work. They are two answers, not one shared answer.

Nobody uses tokio::time::interval, CancellationToken, watch::channel, parking_lot or RwLock. Cancellation, where it exists, is a re-entrancy guard (Mail's AtomicBool, Calendar's tokio::sync::Mutex<()> with try_lock), a dropped stream (Mail attachments.rs:140-160), or a Weak (Docs watch.rs:200,262). The three debounce helpers (Docs watch.rs:376-387 and index.rs:451-464, Mail badge.rs:79-92) are three different things; there is nothing to lift.

Events: all four use the Emitter trait and app.emit(name, payload) broadcast. Nothing anywhere uses emit_to or emit_filter, which is fine while every app is single-window. Names are kebab-case and overlap heavily: menu-action in all four, store-changed, sync-progress and auth in Calendar and Mail, pdf-warnings in Margin and Docs. Docs is the only app defining them as constants on both sides (index.rs:42, watch.rs:26, src/ipc.ts:43-52).

The lock rule is applied consistently and is worth writing down as a guideline rather than a crate: std::sync::Mutex for SQLite behind a with(|conn| ...) closure, tokio::sync::Mutex for anything held across an .await. Stated explicitly at Calendar sync.rs:112-115 and Mail db.rs:12-15.

The Sink trait (Calendar sync.rs:68-78, Mail sync/mod.rs:230-266) is the one abstraction arrived at twice independently: status and changed methods, an AppSink { app: AppHandle } implementation, existing so a sync pass can be tested against a recorder. It is 12 lines and it belongs with the sync engine, which is not shared. No async crate.

Dependency drift

Agreed in all four and not worth a table row: tauri and tauri-build at 2, serde and serde_json at 1, base64 0.22, tauri-plugin-opener 2, objc2 0.6 and objc2-foundation 0.3. Also agreed where shared: sha2 0.10, rand 0.8, url 2, chrono 0.4, tempfile 3 (dev), harper-core =2.5.0, typst and typst-pdf 0.14.2, typst-as-lib 0.15.5, objc2-app-kit 0.3, objc2-ui-kit 0.3, block2 0.6, tauri-plugin-dialog and tauri-plugin-deep-link at 2.

Where they disagree, blank meaning the app does not have it:

Crate Margin Calendar Docs Mail
rusqlite 0.37 0.40 0.40
reqwest 0.12 0.12 0.13
chacha20poly1305 0.10 0.11
fontdb 0.23 0.23 0.24
tokio none 1 (sync, time) 1 (unused) 1 (sync, time, net, io-util, rt)
tauri-plugin-process 2 (ungated) 2 (gated) 2 (gated) 2 (gated)
tauri-plugin-updater 2 (ungated) 2 (gated) 2 (gated) 2 (gated)

Resolved in the lockfiles: tauri is 2.11.3 in Margin and 2.11.5 in the other three; serde 1.0.228 vs 1.0.229; tokio 1.52.3 vs 1.53.1; libsqlite3-sys 0.35.0 (Calendar) vs 0.38.2 (Docs, Mail); wry 0.55.1 and objc2 0.6.4 everywhere. reqwest 0.12 and 0.13 are both in Margin's and Calendar's graphs already.

Four real drifts to close: rusqlite 0.37 in Calendar against 0.40 elsewhere, reqwest 0.12 against 0.13 in Mail, chacha20poly1305 0.10 against 0.11, fontdb 0.23 against 0.24. The last three matter because Calendar and Mail share a sealed-token format and Margin and Docs share a Typst pipeline; a version split inside a pair that is meant to be the same code is how the two copies quietly stop being the same code.

What to build, and the one obstacle

Build three crates:

  1. margin-log, about 100 lines. init(dir, filename), note(who, line), path(), the log_note command. Highest value: three apps currently cannot answer why anything failed.
  2. margin-shell, about 250 lines. The builder prologue, the menu scaffold behind a MenuSpec, the menu-action forwarding, show_main_window, hide_on_close, app_data_dir, packaged_by, and Margin's updates.rs unchanged. Deletes roughly 400 lines and forces one answer to the close-button question that currently has three.
  3. margin-sqlite, about 250 lines. open with pragmas including a busy timeout, Tx and Savepoint, migrate, meta_get/meta_set, holes, now_ms, an error type. Deletes maybe 150 lines and fixes Mail's missing transactions and Calendar's non-composable ladder.

Do not build a settings crate, an error crate, a filesystem crate, an async crate or a DTO macro. For each of those, either only one app has the thing, or all four already do the same trivial thing in the same trivial way, or the apparent duplication dissolves on reading the divergences, every one of which is justified in a comment where it sits.

The obstacle is mechanical and is the same one repo-facts.md describes for margin-shared. These are four separate git repositories with no cargo workspace and no path dependencies between them, and Margin Mail has no remote at all and one scaffold commit under 123 uncommitted files. A Cargo path dependency walking out of one checkout into a sibling would fail on a fresh clone and in CI exactly as the npm one already does. Decide where the shared crates live, a fifth repository consumed by git tag or a monorepo, before writing a line of them.