Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .claude/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,17 @@
- `plugin/` - composition root: host.ts, bootstrap, one register*() per concern
- `core/` - long-lived services: LogService, PassphraseManager, StatePersister, DeviceName
- `sync/` - the engine: manifest, diff, operations, history, projection. No UI.
- `crypto/` - the vault key and blobs (`index.ts`), live keys, and `seal.ts`: everything the relay carries (live frames, cursors, presence), sealed with its space's key
- `crypto/` - the vault key and blobs (`index.ts`), live keys, `seal.ts`: everything the relay carries (live frames, cursors, presence), sealed with its space's key, and `device-key.ts`: the key in Obsidian's SecretStorage that seals what stays on the device (the passphrase cache, the settings envelope)
- `hub/` - the relay hub: socket link, one socket per relay (`channels.ts`: the vault and owned shares on the own relay, joined shares on their owner's), a per-space facade (`space(id)`) routing frames by slot; `HubConnection.statusOf` is the relay status every display reads (`status.ts`)
- `presence/` - who is where: each device announces its open file on every space channel it holds, sealed with that space's key (`people.ts`, `channel.ts`, `announcement.ts`); `here.ts` tracks the active file and idleness; `unseen.ts` the files others changed that this device has not opened. Keys and identity per space come from `plugin/space-access.ts`, shared with live editing
- `spaces/` - space records (`settings.spaces`, one object each under `spaces/` in the vault storage), the partition (`spacesOf`, `mountError`), the owner's share (`owner.ts`), invites (`invite.ts`), how a device reaches a share (`access.ts`). No network and no settings: the relay broker client is `storage/adapters/share-broker.ts`, the flows are `ui/shares/share-action.ts` and `ui/shares/invite-action.ts`. `sync/` never imports it; `core/session-factory.ts` opens a share's session from its record
- `live/` - live documents: `session/` (one file's Y.Doc in its space's room: the outbox, the room log, who else is in it, the rotation it asked for), `workspace/` (which open notes are live: `sessions.ts` and each note's `noteState`, `room.ts`, `space.ts`, the join patience, renames, and `editors.ts` pairing each model with the view that edits it), `cold/` (the file sync's `LiveNotes` port, declared in `sync/live-notes.ts` so `sync/` never imports `live/`, and the agreed texts), `doc-id.ts` (a room's id), `doc-types.ts` (what goes live, in which view), and a model per kind (`text/`, `drawing/`, each with the binding that attaches its view)
- `links/` - share links, see [share links](share-links.md): the record in `settings.links`, written only through `SharedLinks` (`plugin.sharedLinks`, subscribable), `note-links.ts` (a note's live links and whether the note changed since), the snapshot (Obsidian's renderer into a hidden attached element, then `sanitize.ts`, `math.ts` and `images.ts`), `publish.ts` (seal with `@mdsync/protocol`'s link format, store through `storage/adapters/link-broker.ts`). No UI: the flows are `ui/links/`. The page a link opens is `packages/viewer`, served by the relay
- `links/` - share links, see [share links](share-links.md): the record in `settings.links`, written only through `SharedLinks` (`plugin.sharedLinks`, subscribable), traded with the vault storage under `links/` (`remote.ts`, at each `host.spaces()` refresh), `note-links.ts` (a note's live links and whether the note changed since), the snapshot (Obsidian's renderer into a hidden attached element, then `sanitize.ts`, `math.ts` and `images.ts`), `publish.ts` (seal with `@mdsync/protocol`'s link format, store through `storage/adapters/link-broker.ts`). No UI: the flows are `ui/links/`. The page a link opens is `packages/viewer`, served by the relay
- `drawing/` - Excalidraw files without Obsidian: reading the scene, its fingerprint, the merge by element. Pure; used by `sync/`, `vault/` and `live/`
- `storage/` - remote backends behind StorageAdapter, plus the registry
- `storage/` - remote backends behind StorageAdapter, plus the registry; `records.ts` keeps records (spaces, links) one sealed object each, with an etag read cache per kind
- `vault/` - Obsidian filesystem access, scanning, ignore rules
- `settings/` - settings model, transfer, and the settings tab sections
- `settings/` - settings model, `envelope.ts` (the secrets of `data.json`, sealed by the device key), transfer, and declarative settings definitions; `page.ts` adapts imperative pages, and `fields.ts` provides field rows and `renderDefinitions` for the wizard. `setup/` is the first-run wizard (one `create*Step` view per step, `steps.ts` computes which are done from the settings, `run-action.ts` is the busy/error wrapper every async button uses)
- `cloudflare/` - the Cloudflare API without UI: `api.ts` envelope client over an injected `Http`, relay deploy (`relay-deploy.ts`, `assets.ts`, `migrations.ts`), `relay-account.ts` (which worker on the account is this device's relay, relay names, the bundled relay version), R2 bucket and S3 keys (`r2.ts`), the token template (`token.ts`). `obsidian-http.ts` is the only file that imports Obsidian
- `ui/` - views and modals at the top; `live/` (note header in `header/`, relay and live status in `status/`, the states both show in `live-state.ts`), `shares/` (share window, share and invite flows), `links/` (share-link window, its list, the menu entries, the note header button), `explorer/` (file tree indicators and presence), `actions/` (menu actions), `common/` (notices, icon buttons, avatars, relay text and fixes, menus, helpers: whatever two of these use)
- `editor/` - CodeMirror gutter signs, the read-only share lock
- `shared/`, `utils/` - app-aware helpers vs. generic algorithms
Expand Down
7 changes: 4 additions & 3 deletions .claude/commands-and-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@

## Commands

- **Set up sync** (`open-setup-wizard`) opens the setup wizard; a fresh install opens it once. It also opens from a sync without storage (notice action), from sharing a link without a relay (at the relay step), and from Settings → Connection. Which steps are done is computed from the settings, never stored. The Cloudflare token it keeps (`cloudflareToken`, `cloudflareAccountId`) is device-local and never transferred.
- Add user-facing commands via `this.addCommand(...)` with stable IDs; never rename once released.
- Sync is manual by default (`compare`, `push`, `pull`); autosync and push-after-changes are opt-in settings. Push/pull run a compare preflight and must surface conflicts instead of choosing a side silently.
- The `reset-remote-storage` command is destructive and must remain confirmation-gated. It deletes `manifest.json.enc`, `objects/`, `history.json.enc`, and `pins/` in the configured remote prefix (history must not outlive the objects it references), preserves local vault files, clears local `baseline`/`vaultId`, and keeps `salt.bin` and `keys.json` so the current passphrase-derived key remains valid.
- **Share folder** (folder menu; hidden where the folder cannot be shared, disabled with the reason unless the storage is S3; without a relay it still shares and says invites need one) makes the folder a space of its own under `<prefix>/shares/<id>/` with its own key, opens its window and pushes it there meanwhile. The owner's other devices mount it on their next refresh. The vault's next push drops the folder's files from its manifest.
- **The share's window** (**Manage sharing** in the folder menu of a share root or anything inside it, its badge in the file explorer, **Manage** in the settings, command `manage-shared-folder` for the open file's share) holds everything about one share: one list of the people (owner with the relay: everyone with access, else whoever is here): role, where they are now, open their note, **Revoke** (owner, with the relay), the relay status, the invite form, **Pause** (Off / On this device / On all my devices), **Stop sharing** / **Leave**.
- **Invite** (the share's window, owner, needs the relay) registers the share's storage with the relay's broker, issues a token for one person and shows an `obsidian://mdsync-share` link with a generated password to send separately. Opening the link, or pasting it in **Accept an invite** (settings) or the command `accept-shared-folder-invite` (Obsidian opens a link in the vault last used, not always the right one), asks for the password (no vault storage needed: such a guest device syncs only its shares) and a new or empty folder (default `Shared/<name>`), then pulls the share there. The participant never holds storage credentials. A **Read-only** invite is pulled but never auto-pushed; the broker refuses a manual push. Its notes open in source mode follow the others' typing live, locked. Inviting the same name again (any case) replaces that person's link.
- **Shared folders** (settings, Sync tab) starts with **Use shared folders on this device** (device-local, on by default): off pauses every share here, arriving ones too, cancels running operations, and hides the rest of the section, the folder menu entries, **Manage sharing** and the Changes tab's paused line; the relay carries only the vault, and opening an invite first asks to turn shares on. The paused badge stays on a share folder that is on disk. On, it has **Your name** once the person owns an open share (how participants see them; empty shows "Owner"; stored on their shares, so it reaches all their devices), and lists this person's open shares, each with **Manage**, so a share whose folder is gone here can still be closed. **Stop sharing** (owner; ends every token at the broker) or **Leave** (participant) closes the record on all of the person's devices; the folder's files stay and sync with the vault again. **Pause** On this device stops syncing the share here, history included; On all my devices puts it on the record, so every device of the person stops at its next refresh, and Off from any of them clears it. The folder stays out of the vault meanwhile, and the Changes tab names it. **Pause shared folders added on other devices** (device-local) pauses a share new to this device in the same save that brings its record, before any pull. **Push shared folders right away** (on by default) pushes a change in a share 2 s after the last file event there (`quietQueue` in `sync/scheduler.ts`), comparing and pushing only that share (`autoPushShares`). Events find their share by the records from launch; before the first refresh, the push refreshes first. Under it **Push only added, moved and deleted files** (on by default) leaves edits for a push, so their gutter marks and the Changes tab show them; open notes still update live. Read-only and paused shares never push, and with it off share paths go to the vault's **Auto-push after change** queue. A push that published nothing signals no one. After a refresh in which an owned share compared fine, the device sends its current S3 credentials to the broker again when they changed, so rotated keys reach participants without a new invite. Renaming a shared folder (or a folder above it) in Obsidian moves it on the person's other devices too, each at its next refresh; where the new path is taken the share keeps syncing at the old one, with a notice. A move into another shared folder or a hidden one is renamed back.
- **Share link** (file menu of a Markdown note, not Excalidraw; disabled with "needs a relay" without one) and `share-note-link` open the window that publishes the open note as a link: expiry, view limit, optional passphrase, images, the note's name. The window shows what the note's copy leaves out; the passphrase is shown once, beside the link, and never stored. **Share links of this note (n)** (file menu, when it has links), `manage-note-links` (named "Manage share links") and **Settings → Sync → Share links** open the list: views left, **Copy link**, **Update** (same link, the note as it is now; views spent stay spent) and **Stop sharing**. A shared note shows a globe badge in the file tree and a header button that open its links, with a dot when the note changed since it was shared; `update-note-links` ("Update share links of this note") updates them. `settings.links` is device-local and outside device transfer. See [share links](share-links.md).
- **Shared folders** (settings, Sync page) starts with **Use shared folders on this device** (device-local, on by default): off pauses every share here, arriving ones too, cancels running operations, and hides the rest of the section, the folder menu entries, **Manage sharing** and the Changes tab's paused line; the relay carries only the vault, and opening an invite first asks to turn shares on. The paused badge stays on a share folder that is on disk. On, it has **Your name** once the person owns an open share (how participants see them; empty shows "Owner"; stored on their shares, so it reaches all their devices), and lists this person's open shares under **Your shared folders**, each with **Manage**, so a share whose folder is gone here can still be closed. **Stop sharing** (owner; ends every token at the broker) or **Leave** (participant) closes the record on all of the person's devices; the folder's files stay and sync with the vault again. **Pause** On this device stops syncing the share here, history included; On all my devices puts it on the record, so every device of the person stops at its next refresh, and Off from any of them clears it. The folder stays out of the vault meanwhile, and the Changes tab names it. **Pause shared folders added on other devices** (device-local) pauses a share new to this device in the same save that brings its record, before any pull. **Push shared folders right away** (on by default) pushes a change in a share 2 s after the last file event there (`quietQueue` in `sync/scheduler.ts`), comparing and pushing only that share (`autoPushShares`). Events find their share by the records from launch; before the first refresh, the push refreshes first. Under it **Push only added, moved and deleted files** (on by default) leaves edits for a push, so their gutter marks and the Changes tab show them; open notes still update live. Read-only and paused shares never push, and with it off share paths go to the vault's **Auto-push after change** queue. A push that published nothing signals no one. After a refresh in which an owned share compared fine, the device sends its current S3 credentials to the broker again when they changed, so rotated keys reach participants without a new invite. Renaming a shared folder (or a folder above it) in Obsidian moves it on the person's other devices too, each at its next refresh; where the new path is taken the share keeps syncing at the old one, with a notice. A move into another shared folder or a hidden one is renamed back.
- **Share link** (file menu of a Markdown note, not Excalidraw; disabled with "needs a relay" without one) and `share-note-link` open the window that publishes the open note as a link: expiry, view limit, optional passphrase, images, the note's name. The window shows what the note's copy leaves out; the passphrase is shown once, beside the link, and never stored. **Share links of this note (n)** (file menu, when it has links), `manage-note-links` (named "Manage share links") and **Settings → Sync → Share links** open the list: views left, **Copy link**, **Update** (same link, the note as it is now; views spent stay spent) and **Stop sharing**. A shared note shows a globe badge in the file tree and a header button that open its links, with a dot when the note changed since it was shared; `update-note-links` ("Update share links of this note") updates them. `settings.links` reaches the person's other devices through the vault storage at each refresh, so any of them can copy, update or stop a link; it stays outside device transfer. See [share links](share-links.md).
- `refresh` and the Changes tab's refresh button also list the disk, as the first refresh after launch does: Obsidian's watcher misses files copied in bulk from outside the app. Files its index lacks sync anyway, with a notice to restart Obsidian to see them.
- `rebuild-live-note` is available only for the active note bound to a live room. It moves the room into its next generation from the current text, shedding the edit history; attribution survives.
- Presence: each open note's header shows whether it is live (and why not) and who else has it open, as coloured rings with their initial; tapping it lists them with **follow**: the tab opens each note they open and keeps their cursor in its middle; input there asks **Stop following** in a notice, and it ends on that, on a note opened there by hand, on their leaving the space, or on tapping them again. A note outside shares shows the mark only with another of your devices in it or relay trouble. The file explorer marks share roots by kind with a headcount of people in its notes, and people on the notes they have open (a collapsed folder gathers them); the desktop status bar shows the relay state and who is in shared notes. The header menu also has **Show who typed what** (shared notes), **Reconnect** (relay down; restarts only that space's socket) or **Open relay settings** (the relay refused this device), **Manage sharing** and **Rebuild live note** (not for a read-only person, who never writes the room). **Real-time sync** switches off presence and live editing together; **Show my open note to others** off keeps the device online but hides its note; **File and folder indicators** off keeps the share badge, the tree's way into the share window. Relay state everywhere comes from `realtime.hub.statusOf`. Commands `show-live-status` (named "Show relay status": the status bar item's menu: a relay row per space, who is in shared notes; the only live status on a phone) and `show-note-live-menu` (the note header's menu, **follow** included) reach both without a pointer. On a phone, tapping a people badge or the new dot in the tree shows its tooltip as a notice.
Expand All @@ -19,7 +20,7 @@

- Provide a settings tab with sensible defaults and validation; persist via `this.loadData()` / `this.saveData()`.
- Device transfer exports only the main sync settings as a compact `obsidian://mdsync?d=...` URL/QR. The payload uses short field names, omits default-valued fields, and may compress before encryption when that makes the token smaller. Never include the cached passphrase, passphrase cache settings, or local-only display preferences. Import requires the same passphrase and explicit confirmation.
- Local diagnostics are stored only on the current device in `<configDir>/plugins/mdsync/logs.json` and surfaced in the second tab of the plugin settings. They must stay excluded from sync.
- Local diagnostics are stored only on the current device in `<configDir>/plugins/mdsync/logs.json` and surfaced on the Logs page of the plugin settings. They must stay excluded from sync.
- Configuration categories (core settings, hotkeys, plugin list, plugins, snippets, themes) are per device and default to off. A disabled category is invisible to that device: nothing is scanned, diffed, pulled or published for it, and the remote keeps whatever other devices synced. Turning a category off never deletes anything anywhere. MDSync's own plugin folder is never synced.
- **Clear on remote** beside a category is the only way to remove a category from the remote. It bumps that category's reset generation so every device forgets its baseline for it instead of reading the removal as deletions; local files stay everywhere, and enabled devices re-upload on their next push. Confirmation-gated.

Expand Down
Loading
Loading