mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-04 12:07:03 +00:00
500 lines
32 KiB
Markdown
500 lines
32 KiB
Markdown
# 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`):
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
#[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 `if`s (`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_write`s, 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_file`s 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 `ApiError`s 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::spawn`s (`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.
|