- Rust 100%
- Just <1%
- Shell <1%
dragon-glass #
Local-first sync for an Obsidian vault, in Rust, over iroh.
Dragonglass is what they call obsidian in Westeros.
The problem #
Notes live in an Obsidian vault that gets edited across a Mac, a Linux desktop, a Linux laptop, and iPhone and iPad.
iCloud handles the Apple devices fine, but nothing outside that ecosystem can reach it. So the Linux machines sync through a git repo on a NAS — which works, but means remembering to push before closing the laptop and pull before opening the desktop. Forget once and you are resolving a merge conflict in a note you wrote three days ago.
The vault should just be the same everywhere, without anyone thinking about it.
How it works #
Every machine runs a daemon that watches the vault, hashes what changed, and reconciles with the other machines over a peer-to-peer mesh. No server, no cloud account, no manual step.
Linux desktop ─┐
Linux laptop ─┼─ iroh mesh ── NAS (always-on peer)
Mac ───────────┘
└─ watches the iCloud vault directory ──> iPhone / iPad (unchanged)
Three things make this less obvious than it looks:
iOS cannot run a daemon. Obsidian's mobile plugins are JavaScript in a WebView with no native module loading, and iOS suspends background processes anyway. So the Mac bridges instead: it already holds the iCloud vault on local disk, and it brings that directory into the mesh. Apple devices keep working exactly as they do now, except conflicts get resolved properly rather than accumulating as workspace 2.json copies.
Every peer dials every peer. The NAS is not a hub and nothing routes through it. Its only special quality is that it never sleeps, which is what lets two laptops that are never awake at the same time still converge — it holds a full replica each of them can reach independently.
Concurrency is detected, not guessed. Edits are ordered by version vectors, never by wall-clock time. Four machines have four clocks, and ordering by timestamp is a guess that silently discards a note when it is wrong. When two edits are genuinely concurrent, both survive and one becomes a conflict copy.
What it does today #
A daemon per machine watches a vault, hashes what changed, and reconciles with every other machine over a real iroh mesh — gossip-triggered propagation for live edits, a periodic full reconcile as a fallback if a peer missed one. Genuinely concurrent edits become conflict copies instead of one side silently winning. Content is stored locally, content-addressed, with full history — any prior version can be restored or snapshotted. Offline mode, large-file admission control (an oversized incoming file is fetched and held rather than auto-applied, pending a manual approve), a signed device roster, and a Unix control socket a real CLI (dragon-glass-cli) talks to are all real and running. The daemon installs as a persistent systemd/launchd service.
Not yet built: the interactive short-code pairing flow (pairing today is manual ticket copy-paste, and seeding a vault's identity onto a second machine is a manual one-time file copy — see Identity below), the log/conflicts CLI commands (no queryable data model behind them yet), and any UI beyond a terminal.
Currently being validated: real day-to-day use across a Mac, two Linux boxes, and a NAS, running in parallel with the git-based backup it's meant to replace — the point of that overlap, and how long it runs before git is retired, is recorded in docs/decisions.md.
99 of 103 catalogued behaviours pass — docs/behaviors.md is the spec, just coverage reports gaps. What's left uncovered: two permanently-open notes where real informal evidence was collected but never formalized (NET-1, BOOT-6), one pre-existing gap (SAFE-7), and one behaviour verified by hand against a real service manager rather than a unit test (SVC-4) — see docs/roadmap.md for what shipped and when.
Why not just use something that exists #
Syncthing already solves Mac↔Linux↔NAS, today, for free. If the only goal were "stop typing git pull", that would be the answer — it has no iOS story, but neither does this project directly.
Obsidian Sync works everywhere including iOS, and is the pragmatic paid option.
This project exists because building it is part of the point, and because a local-first engine you own end to end can do things a general-purpose file syncer will not: understanding that .obsidian/workspace.json is session state rather than settings, keeping full history for notes but bounded history for drawings, and refusing to act at all when it cannot read a file rather than guessing that it was deleted.
Development #
Requires a recent stable Rust toolchain and just.
just setup # enable the repo's git hooks
just # check — the fast inner loop
just test # all tests
just coverage # which behaviour IDs still have no test
just ci # everything CI runs
just release # build daemon + CLI in release mode
just install <vault> # register that vault's daemon as a persistent service
just uninstall <vault> # remove it
Running it across your devices #
Every peer dials every other peer directly — there's no hub (see How it works) — so with N machines you're exchanging N·(N-1)/2 tickets, not N. For two Linux boxes, a Mac, and the NAS, that's six.
-
Build on every machine.
just release -
First boot, alone, on every machine. Point each one at that machine's own local vault directory — these are separate copies that get reconciled, not one shared mount:
./target/release/dragon-glass-daemon /path/to/this/machines/vaultThis is what actually creates
~/.config/dragon-glass/config.toml— nothing at build time does. It mints a device identity, adopts the vault, self-admits to a fresh signed roster, and prints this machine's ticket. Leave it running just long enough to copy that ticket, thenCtrl-C— peers are only read at startup, so there's no point pairing before this step.If this vault already exists on more than one machine, read Identity below before doing this on the second machine — the naive version of this step is the single easiest way to corrupt a vault that spans more than one device.
-
Opt in to writing, on every machine. The daemon starts read-only (SAFE-4) until told otherwise. Edit
~/.config/dragon-glass/config.toml's[[vault]]entry:[[vault]] write_enabled = true -
Mark the NAS as an archive — same file, same vault entry, on the NAS only:
role = "archive"This stops it watching its own copy for local edits (it makes none) and turns on periodic snapshots instead.
-
Exchange tickets, every pair. Paste every other machine's printed ticket into this machine's
config.toml, one[[peers]]entry each — do this on all four machines:peers = [ "endpointab...", # Linux desktop "endpointcd...", # Linux laptop "endpointef...", # NAS ] -
Restart every daemon, pointed at the same vault path as step 2 — either by hand for a quick test:
./target/release/dragon-glass-daemon /path/to/this/machines/vaultor as a persistent service that survives logout and reboot:
just install /path/to/this/machines/vaultwhich registers a systemd user unit on Linux or a launchd agent on macOS (see step 6a below). Either way, each should end up watching with
3 known peersand start dialling.6a. On the NAS specifically, a user-level systemd service like this one doesn't run unless something is logged in — which a headless box usually isn't. Enable lingering once so it starts at boot regardless:
loginctl enable-linger $(whoami)installalso prints this reminder itself when run on Linux. To remove a service later:just uninstall /path/to/vault. -
Test it. Edit a note on one machine — it should reach the others within a few seconds, gossip-triggered rather than waiting on the periodic sync interval. Confirm from any machine without eyeballing files:
./target/release/dragon-glass-cli status --vault /path/to/vault ./target/release/dragon-glass-cli verify --vault /path/to/vault
If the vault directories didn't start out identical, expect the first reconciliation to look like a real merge of two independently-grown trees: genuinely concurrent edits become conflict copies rather than one side silently winning. That's the intended behaviour, not a bug.
Running it by hand in a terminal is fine for a quick check, but doesn't survive that terminal closing — use install (step 6) for anything meant to keep running.
Identity, the one rule that actually matters #
A vault's identity — its vault_id — lives entirely in the .dragon-glass.toml marker file at the vault's root, and nowhere else. There is no field to type an id into. add_vault_at decides what a directory's id is by checking whether a marker file is already there when it first looks: if one exists, its id is reused; if not, a brand new random id is minted on the spot.
That has a sharp edge: if you point the daemon at an empty, never-before-managed directory on more than one machine — instead of seeding it from an already-adopted copy first — each machine mints its own, different id for what you intended to be one vault. They will never converge: iroh-gossip topics are derived from vault_id, so differently-id'd machines broadcast into different topics and simply never hear each other, and the periodic full-sync fallback has no identity check of its own (NET-7 closes the worst version of this — two devices scoped to different vaults now refuse each other rather than merging — but two machines that agree on the same wrong id will still merge happily, since agreeing is all the check verifies). Get the id wrong in the first place and the two vaults will cheerfully merge into a tangle of .dragon-glass (conflict ...).toml files.
So: whenever a vault is going to live on more than one machine, adopt it in exactly one place first, then copy the resulting .dragon-glass.toml to every other machine's copy of that vault before running the daemon there for the first time. That single file carries the id; everything else (device_secret, peers) is per-machine and unaffected.
One more sharp edge, found the hard way: the running daemon reads vault_id from the marker file once, at startup, and keeps it in memory for the life of the process. If you ever hand-edit a marker's vault_id field to fix a mistake, the fix does nothing until the daemon for that vault is restarted — the file being correct on disk is not the same as the running process knowing it.
Adding another vault to the same machines #
Once a fleet is already paired for one vault, a second one is much cheaper — pairing lives in device_secret/peers, which are per-machine, not per-vault, so there's no ticket exchange to redo.
- On one machine, run the daemon once against the new (genuinely unmanaged) vault path,
Ctrl-C, and confirm the id it minted (grep -A1 '\[\[vault\]\]' ~/.config/dragon-glass/config.toml). - Copy that vault's
.dragon-glass.tomlto the other machines' copies of the same directory — before running the daemon there, so they adopt the same id instead of minting their own (see Identity above). - Run the daemon once on each of those; confirm the id matches.
- Set
write_enabled = trueon every machine (still off by default, per-vault);role = "archive"on the NAS if it should archive this one too. just install /path/to/new-vaulton every machine — a separate service alongside any others, with its own control socket and unit name (both derived per-vault, so they never collide).- Leave
peersalone entirely — already shared across every vault on that machine.
Things that will bite you #
Found for real, running this across an actual fleet — not hypothetical:
- Never run two daemons against the same vault at once. A manual
dragon-glass-daemonleft running in a forgotten terminal, plus the same vault alsoinstalled as a service, will race on the same on-disk index. Checkpgrep -af dragon-glass-daemonandsystemctl --user status/launchctl listbefore starting another one. - To stop an installed service, use
uninstall, notkill. Bothsystemd(Restart=always) andlaunchd(KeepAlive=true) are built to resurrect the process the instant it exits — killing it directly just gets it restarted immediately, which looks exactly like it refusing to die. Ctrl-Con a manually-run daemon isn't fully reliable yet — a real, open gap, not expected behaviour. If it doesn't respond,kill -INT <pid>(found viapgrep -af dragon-glass-daemon) is the reliable fallback; escalate tokill -9if even that doesn't land within a couple of seconds.- Obsidian's Flatpak build, if the vault lives outside
home//media//mnt//run/media, can read and write it fine (via a one-time folder-picker portal grant) but won't reliably notice externally written changes — dragon-glass's own writes included. Grant it explicitly and restart the app fully (not just a reload):flatpak override --user <app-id> --filesystem=/path/to/parent/dir - Attachments over the size threshold (10MB by default) don't just appear. They're fetched and held pending a manual
dragon-glass approve <path>— this isADMITworking as designed, not a stall. - A laptop's sleep/wake cycle looks like a stall for up to the sync interval (30s) while the network re-establishes on wake — expected, and it self-heals on the next periodic dial; see the
NET-3note in docs/roadmap.md.
Documentation #
| docs/architecture.md | The design and why it is shaped this way |
| docs/behaviors.md | 103 numbered, testable behaviours — the spec |
| docs/roadmap.md | What shipped, when, and what's not built yet |
| docs/decisions.md | What was rejected, and why, so it is not re-proposed |
| CONTRIBUTING.md | Conventions for everyone, human or agent |
Start with the architecture. The behaviour catalogue reads better jumping to VV → MERGE → CONV → BOOT → SAFE than top to bottom — that is where the judgement lives; the rest is mechanical.
Contributing #
See CONTRIBUTING.md. The one convention worth knowing up front: behaviours have stable IDs, and tests carry the ID in their name.
#[test]
fn vv_3_disjoint_increments_are_concurrent() { … }
So grep is the traceability matrix, and a behaviour is done when its test passes — not when the code looks finished.
License #
MIT.