From fa27e55b6ab428481a7fc4a0ca4b7886a0ee9205 Mon Sep 17 00:00:00 2001 From: kitbyte Date: Sun, 11 Oct 2026 01:19:16 +0300 Subject: [PATCH 01/11] feat: setup wizard with in-plugin Cloudflare relay and R2 deploy --- .claude/architecture.md | 3 +- .claude/commands-and-settings.md | 1 + .gitignore | 3 + README.md | 8 +- docs/demos/cloudflare-token.webp | Bin 0 -> 204028 bytes package.json | 1 + packages/plugin/esbuild.config.mjs | 3 + packages/plugin/esbuild.relay-bundle.mjs | 34 ++ packages/plugin/src/cloudflare/api.ts | 103 ++++ packages/plugin/src/cloudflare/assets.ts | 55 ++ packages/plugin/src/cloudflare/bundle.ts | 28 + packages/plugin/src/cloudflare/index.ts | 39 ++ packages/plugin/src/cloudflare/migrations.ts | 32 ++ packages/plugin/src/cloudflare/multipart.ts | 27 + .../plugin/src/cloudflare/obsidian-http.ts | 12 + packages/plugin/src/cloudflare/r2.ts | 56 ++ .../plugin/src/cloudflare/relay-account.ts | 83 +++ .../plugin/src/cloudflare/relay-bundle.d.ts | 7 + .../plugin/src/cloudflare/relay-deploy.ts | 239 +++++++++ packages/plugin/src/cloudflare/token.ts | 60 +++ packages/plugin/src/commands/index.ts | 6 + packages/plugin/src/constants.ts | 2 + .../plugin/src/core/passphrase-manager.ts | 53 +- packages/plugin/src/core/session-factory.ts | 4 +- packages/plugin/src/crypto/index.ts | 7 +- packages/plugin/src/crypto/sealed-link.ts | 11 +- packages/plugin/src/links/record.ts | 3 +- packages/plugin/src/main.ts | 26 +- packages/plugin/src/plugin/bootstrap.ts | 2 + packages/plugin/src/plugin/host.ts | 5 + .../plugin/src/settings/cloudflare-login.ts | 64 +++ .../plugin/src/settings/connection-test.ts | 41 +- packages/plugin/src/settings/model.ts | 13 +- .../src/settings/sections/automation.ts | 68 ++- .../plugin/src/settings/sections/backend.ts | 5 +- .../plugin/src/settings/sections/index.ts | 2 +- .../plugin/src/settings/sections/relay.ts | 42 +- .../src/settings/sections/storage-fields.ts | 6 +- .../plugin/src/settings/setup/choice-card.ts | 34 ++ .../src/settings/setup/cloudflare-token.ts | 146 ++++++ packages/plugin/src/settings/setup/form.ts | 34 ++ .../plugin/src/settings/setup/import-step.ts | 78 +++ packages/plugin/src/settings/setup/index.ts | 2 + .../src/settings/setup/passphrase-meter.ts | 62 +++ .../src/settings/setup/passphrase-step.ts | 150 ++++++ packages/plugin/src/settings/setup/r2.ts | 36 ++ .../plugin/src/settings/setup/ready-step.ts | 181 +++++++ .../src/settings/setup/relay-deploy-view.ts | 340 ++++++++++++ .../plugin/src/settings/setup/relay-step.ts | 169 ++++++ packages/plugin/src/settings/setup/relay.ts | 154 ++++++ .../plugin/src/settings/setup/run-action.ts | 30 ++ .../plugin/src/settings/setup/start-step.ts | 41 ++ packages/plugin/src/settings/setup/stepper.ts | 88 ++++ packages/plugin/src/settings/setup/steps.ts | 59 +++ .../plugin/src/settings/setup/storage-step.ts | 149 ++++++ packages/plugin/src/settings/setup/wizard.ts | 250 +++++++++ packages/plugin/src/settings/tab.ts | 13 +- .../src/settings/transfer-controller.ts | 16 +- packages/plugin/src/shared/path.ts | 12 + packages/plugin/src/styles/index.css | 5 + .../src/styles/settings/setup-cards.css | 185 +++++++ .../src/styles/settings/setup-forms.css | 198 +++++++ .../src/styles/settings/setup-progress.css | 168 ++++++ .../src/styles/settings/setup-stepper.css | 177 +++++++ packages/plugin/src/styles/settings/setup.css | 54 ++ packages/plugin/src/ui/common/index.ts | 1 + packages/plugin/src/ui/common/notices.ts | 18 +- packages/plugin/src/ui/index.ts | 1 + packages/plugin/src/ui/links/link-menu.ts | 17 +- .../plugin/src/ui/shares/invite-action.ts | 19 +- packages/plugin/tests/cloudflare/api.test.ts | 47 ++ .../tests/cloudflare/migrations.test.ts | 37 ++ packages/plugin/tests/cloudflare/r2.test.ts | 117 +++++ .../tests/cloudflare/relay-account.test.ts | 136 +++++ .../tests/cloudflare/relay-deploy.test.ts | 490 ++++++++++++++++++ .../tests/core/passphrase-manager.test.ts | 183 ++++++- .../plugin/tests/helpers/fake-cloudflare.ts | 66 +++ packages/plugin/tests/links/record.test.ts | 8 + .../tests/settings/cloudflare-login.test.ts | 31 ++ .../tests/settings/connection-test.test.ts | 143 +++++ packages/plugin/tests/settings/model.test.ts | 37 +- .../tests/settings/relay-section.test.ts | 3 + .../tests/settings/setup/element-methods.ts | 47 ++ .../settings/setup/passphrase-meter.test.ts | 60 +++ .../plugin/tests/settings/setup/r2.test.ts | 234 +++++++++ .../tests/settings/setup/relay-step.test.ts | 298 +++++++++++ .../plugin/tests/settings/setup/relay.test.ts | 423 +++++++++++++++ .../tests/settings/setup/run-action.test.ts | 69 +++ .../tests/settings/setup/stepper.test.ts | 84 +++ .../plugin/tests/settings/setup/steps.test.ts | 183 +++++++ .../settings/transfer-controller.test.ts | 88 +++- packages/plugin/tests/spaces/invite.test.ts | 2 +- packages/plugin/tests/stubs/relay-bundle.ts | 16 + packages/plugin/vitest.config.ts | 3 + packages/relay/bundle.mjs | 82 +++ packages/relay/package.json | 1 + packages/relay/src/index.ts | 7 +- packages/relay/tests/hub/access.test.ts | 10 +- tests/e2e/device.ts | 13 +- tests/e2e/obsidian.ts | 8 +- tests/e2e/setup.ts | 197 +++++++ 101 files changed, 7065 insertions(+), 99 deletions(-) create mode 100644 docs/demos/cloudflare-token.webp create mode 100644 packages/plugin/esbuild.relay-bundle.mjs create mode 100644 packages/plugin/src/cloudflare/api.ts create mode 100644 packages/plugin/src/cloudflare/assets.ts create mode 100644 packages/plugin/src/cloudflare/bundle.ts create mode 100644 packages/plugin/src/cloudflare/index.ts create mode 100644 packages/plugin/src/cloudflare/migrations.ts create mode 100644 packages/plugin/src/cloudflare/multipart.ts create mode 100644 packages/plugin/src/cloudflare/obsidian-http.ts create mode 100644 packages/plugin/src/cloudflare/r2.ts create mode 100644 packages/plugin/src/cloudflare/relay-account.ts create mode 100644 packages/plugin/src/cloudflare/relay-bundle.d.ts create mode 100644 packages/plugin/src/cloudflare/relay-deploy.ts create mode 100644 packages/plugin/src/cloudflare/token.ts create mode 100644 packages/plugin/src/settings/cloudflare-login.ts create mode 100644 packages/plugin/src/settings/setup/choice-card.ts create mode 100644 packages/plugin/src/settings/setup/cloudflare-token.ts create mode 100644 packages/plugin/src/settings/setup/form.ts create mode 100644 packages/plugin/src/settings/setup/import-step.ts create mode 100644 packages/plugin/src/settings/setup/index.ts create mode 100644 packages/plugin/src/settings/setup/passphrase-meter.ts create mode 100644 packages/plugin/src/settings/setup/passphrase-step.ts create mode 100644 packages/plugin/src/settings/setup/r2.ts create mode 100644 packages/plugin/src/settings/setup/ready-step.ts create mode 100644 packages/plugin/src/settings/setup/relay-deploy-view.ts create mode 100644 packages/plugin/src/settings/setup/relay-step.ts create mode 100644 packages/plugin/src/settings/setup/relay.ts create mode 100644 packages/plugin/src/settings/setup/run-action.ts create mode 100644 packages/plugin/src/settings/setup/start-step.ts create mode 100644 packages/plugin/src/settings/setup/stepper.ts create mode 100644 packages/plugin/src/settings/setup/steps.ts create mode 100644 packages/plugin/src/settings/setup/storage-step.ts create mode 100644 packages/plugin/src/settings/setup/wizard.ts create mode 100644 packages/plugin/src/styles/settings/setup-cards.css create mode 100644 packages/plugin/src/styles/settings/setup-forms.css create mode 100644 packages/plugin/src/styles/settings/setup-progress.css create mode 100644 packages/plugin/src/styles/settings/setup-stepper.css create mode 100644 packages/plugin/src/styles/settings/setup.css create mode 100644 packages/plugin/tests/cloudflare/api.test.ts create mode 100644 packages/plugin/tests/cloudflare/migrations.test.ts create mode 100644 packages/plugin/tests/cloudflare/r2.test.ts create mode 100644 packages/plugin/tests/cloudflare/relay-account.test.ts create mode 100644 packages/plugin/tests/cloudflare/relay-deploy.test.ts create mode 100644 packages/plugin/tests/helpers/fake-cloudflare.ts create mode 100644 packages/plugin/tests/settings/cloudflare-login.test.ts create mode 100644 packages/plugin/tests/settings/connection-test.test.ts create mode 100644 packages/plugin/tests/settings/setup/element-methods.ts create mode 100644 packages/plugin/tests/settings/setup/passphrase-meter.test.ts create mode 100644 packages/plugin/tests/settings/setup/r2.test.ts create mode 100644 packages/plugin/tests/settings/setup/relay-step.test.ts create mode 100644 packages/plugin/tests/settings/setup/relay.test.ts create mode 100644 packages/plugin/tests/settings/setup/run-action.test.ts create mode 100644 packages/plugin/tests/settings/setup/stepper.test.ts create mode 100644 packages/plugin/tests/settings/setup/steps.test.ts create mode 100644 packages/plugin/tests/stubs/relay-bundle.ts create mode 100644 packages/relay/bundle.mjs create mode 100644 tests/e2e/setup.ts diff --git a/.claude/architecture.md b/.claude/architecture.md index c3ffbd07..048ea98a 100644 --- a/.claude/architecture.md +++ b/.claude/architecture.md @@ -15,7 +15,8 @@ - `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 - `vault/` - Obsidian filesystem access, scanning, ignore rules -- `settings/` - settings model, transfer, and the settings tab sections +- `settings/` - settings model, transfer, and the settings tab sections; `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 diff --git a/.claude/commands-and-settings.md b/.claude/commands-and-settings.md index 860719f6..d5895797 100644 --- a/.claude/commands-and-settings.md +++ b/.claude/commands-and-settings.md @@ -2,6 +2,7 @@ ## 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. diff --git a/.gitignore b/.gitignore index 7bb36bce..7910d72b 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ packages/plugin/styles.css # The link viewer's build, served by the relay. packages/viewer/dist +# The relay as one uploadable JSON (packages/relay/bundle.mjs). +packages/relay/dist + # Exclude sourcemaps *.map diff --git a/README.md b/README.md index 29d10a96..a394fc55 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,10 @@ MDSync syncs your vault through an S3 bucket, a WebDAV server or Google Drive. T > **Tip: Cloudflare R2 is an easy S3-compatible backend.** Its free tier (10 GB, 1 million write and list requests and 10 million read requests a month) covers most vaults, downloads cost nothing, and it supports the conditional writes MDSync relies on. Enable R2 on your Cloudflare account, create a bucket, and create an API token with **Object Read & Write** limited to that bucket. R2 shows the access key ID and secret only once. In MDSync pick S3-compatible and set **Endpoint** to `https://.r2.cloudflarestorage.com` (`https://.eu.r2.cloudflarestorage.com` for a bucket created in the EU jurisdiction), leave **Region** as `auto`, and fill in the bucket and the token's keys. The same Cloudflare account can host the [relay](#self-hosting-the-relay). +**MDSync: Set up sync** can create the R2 bucket and the relay for you. It asks for a Cloudflare API token: copy **Your API token**, not the S3 keys. + +![Creating the Cloudflare API token for the setup wizard and copying the right value](docs/demos/cloudflare-token.webp) + Give each vault its own bucket prefix. MDSync refuses a prefix that already holds another vault. Set up the first device alone and let its first sync finish. That sync creates the vault's key. On storage without conditional writes (Google Drive, some WebDAV and S3-compatible servers), two devices starting at the same moment can each create one, and files encrypted by one become unreadable to the other. @@ -309,10 +313,12 @@ MDSync has no telemetry. It connects only to: - **Your storage** (S3 endpoint, WebDAV server or the Google Drive API), which gets encrypted files and the encrypted file list. - **Your relay**, if you set one up. It is a Cloudflare Worker you deploy to your own account. +- **The Cloudflare API** (`api.cloudflare.com`), only when the setup wizard deploys the relay or creates R2 storage for you. The token you paste can edit Workers, KV and R2 on that account. If the account has no workers.dev address yet, the wizard registers a random one. +- **GitHub** (`raw.githubusercontent.com`), only when you open **Show me how** in the setup wizard, to play a short clip of creating the Cloudflare token. You need an account with your storage provider. The relay needs a Cloudflare account, and Google Drive needs your own Google Cloud OAuth client. -Storage credentials, share keys and the relay secret are stored in the plugin's `data.json` on each device, like other plugins' settings. Diagnostics logs stay on the device and never sync. +Storage credentials, share keys, the relay secret and the Cloudflare token are stored in the plugin's `data.json` on each device, like other plugins' settings. Diagnostics logs stay on the device and never sync. **What the relay sees.** It never sees file contents, file names or keys: live edits and presence travel encrypted under each space's key. It does see a hash of your storage identity, a device id per connection, and when each sync and note switch happens. For a shared folder with participants, it keeps your S3 access key in its storage to sign requests for them, plus the names you invited people by. With Google Drive, it exchanges your sign-in for tokens and sees your Drive refresh token. diff --git a/docs/demos/cloudflare-token.webp b/docs/demos/cloudflare-token.webp new file mode 100644 index 0000000000000000000000000000000000000000..04040521009d505fc2dff431537840487b725ca3 GIT binary patch literal 204028 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 ({ + path: args.path, + namespace: "relay-bundle", + })); + build.onLoad({ filter: /.*/, namespace: "relay-bundle" }, () => { + if (!contents) { + // Through the shell: pnpm is a .cmd shim on Windows. + execSync("pnpm --filter mdsync-relay run bundle", { + stdio: ["ignore", "ignore", "inherit"], + }); + contents = readFileSync(BUNDLE, "utf8"); + } + return { contents, loader: "json" }; + }); + }, + }; +} diff --git a/packages/plugin/src/cloudflare/api.ts b/packages/plugin/src/cloudflare/api.ts new file mode 100644 index 00000000..19c1dfca --- /dev/null +++ b/packages/plugin/src/cloudflare/api.ts @@ -0,0 +1,103 @@ +import type { FormBody } from "./multipart"; + +/** Injected so one flow runs over Obsidian's `requestUrl` and Node's `fetch`. */ +export interface HttpRequest { + url: string; + method: "GET" | "POST" | "PUT" | "DELETE"; + headers: Record; + body?: string | ArrayBuffer; +} + +export interface HttpResponse { + status: number; + text: string; +} + +export type Http = (request: HttpRequest) => Promise; + +const API_BASE = "https://api.cloudflare.com/client/v4"; + +interface Envelope { + success?: boolean; + result?: T; + errors?: ReadonlyArray<{ code: number; message: string }>; +} + +export class CloudflareError extends Error { + constructor( + readonly status: number, + readonly codes: readonly number[], + message: string, + ) { + super(message); + this.name = "CloudflareError"; + } +} + +export interface CallOptions { + json?: unknown; + form?: FormBody; + /** Overrides the API token, e.g. with an asset upload session JWT. */ + bearer?: string; +} + +export interface CloudflareApi { + readonly token: string; + call( + method: HttpRequest["method"], + path: string, + options?: CallOptions, + ): Promise; +} + +export function cloudflareApi(http: Http, token: string): CloudflareApi { + return { + token, + async call( + method: HttpRequest["method"], + path: string, + options: CallOptions = {}, + ): Promise { + const headers: Record = { + Authorization: `Bearer ${options.bearer ?? token}`, + }; + let body: string | ArrayBuffer | undefined; + if (options.form) { + headers["Content-Type"] = options.form.contentType; + body = options.form.body; + } else if (options.json !== undefined) { + headers["Content-Type"] = "application/json"; + body = JSON.stringify(options.json); + } + const res = await http({ + url: `${API_BASE}${path}`, + method, + headers, + body, + }); + const envelope = parseEnvelope(res.text); + if (res.status >= 400 || !envelope || envelope.success === false) { + const errors = envelope?.errors ?? []; + const detail = errors.map((e) => `${e.message} (${e.code})`).join("; "); + throw new CloudflareError( + res.status, + errors.map((e) => e.code), + `Cloudflare ${method} ${path}: ${detail || `HTTP ${res.status}`}`, + ); + } + return envelope.result as T; + }, + }; +} + +function parseEnvelope(text: string): Envelope | null { + try { + return JSON.parse(text) as Envelope; + } catch { + return null; + } +} + +export function hasCode(err: unknown, code: number): boolean { + return err instanceof CloudflareError && err.codes.includes(code); +} diff --git a/packages/plugin/src/cloudflare/assets.ts b/packages/plugin/src/cloudflare/assets.ts new file mode 100644 index 00000000..26ae20a8 --- /dev/null +++ b/packages/plugin/src/cloudflare/assets.ts @@ -0,0 +1,55 @@ +import { bytesToBase64 } from "@/utils"; + +import type { CloudflareApi } from "./api"; +import type { RelayAsset } from "./bundle"; +import { encodeForm } from "./multipart"; + +const encoder = new TextEncoder(); + +interface UploadSession { + jwt: string; + buckets?: string[][]; +} + +/** Returns the completion JWT the script upload attaches the files with. */ +export async function uploadAssets( + api: CloudflareApi, + accountPath: string, + scriptName: string, + files: readonly RelayAsset[], +): Promise { + const manifest = Object.fromEntries( + files.map((f) => [f.path, { hash: f.hash, size: f.size }]), + ); + const session = await api.call( + "POST", + `${accountPath}/workers/scripts/${scriptName}/assets-upload-session`, + { json: { manifest } }, + ); + // Nothing new to upload: the session token is already the completion token. + if (!session.buckets?.length) return session.jwt; + + const byHash = new Map(files.map((f) => [f.hash, f])); + let completion: string | undefined; + for (const bucket of session.buckets) { + const parts = bucket.map((hash) => { + const file = byHash.get(hash); + if (!file) throw new Error(`Cloudflare asked for unknown file ${hash}.`); + return { + name: hash, + filename: hash, + type: file.type, + data: bytesToBase64(encoder.encode(file.text)), + }; + }); + const result = await api.call<{ jwt?: string } | null>( + "POST", + `${accountPath}/workers/assets/upload?base64=true`, + { form: encodeForm(parts), bearer: session.jwt }, + ); + completion = result?.jwt ?? completion; + } + if (!completion) + throw new Error("Cloudflare did not confirm the relay's files."); + return completion; +} diff --git a/packages/plugin/src/cloudflare/bundle.ts b/packages/plugin/src/cloudflare/bundle.ts new file mode 100644 index 00000000..d0bd6799 --- /dev/null +++ b/packages/plugin/src/cloudflare/bundle.ts @@ -0,0 +1,28 @@ +/** What `packages/relay/bundle.mjs` emits: the relay ready to upload, configured by its wrangler.toml. */ +export interface RelayBundle { + name: string; + /** Hash of everything else here; a relay deployed from this bundle reports it on `/status`. */ + version: string; + mainModule: string; + worker: string; + compatibilityDate: string; + compatibilityFlags: string[]; + kvBindings: string[]; + durableObjects: Array<{ name: string; className: string }>; + /** Wrangler's `[[migrations]]` as written: a `tag` plus the class changes, passed through as they are. */ + migrations: Array<{ tag: string } & Record>; + assets: { + binding: string; + runWorkerFirst?: boolean | string[]; + files: RelayAsset[]; + }; +} + +export interface RelayAsset { + /** Served path with a leading slash, e.g. `/viewer/index.html`. */ + path: string; + hash: string; + size: number; + type: string; + text: string; +} diff --git a/packages/plugin/src/cloudflare/index.ts b/packages/plugin/src/cloudflare/index.ts new file mode 100644 index 00000000..6e8495a8 --- /dev/null +++ b/packages/plugin/src/cloudflare/index.ts @@ -0,0 +1,39 @@ +export { + type CloudflareApi, + cloudflareApi, + type Http, + type HttpRequest, + hasCode, +} from "./api"; +export type { RelayBundle } from "./bundle"; +export { + ensureBucket, + R2NotEnabledError, + type R2Storage, + r2DashboardUrl, + r2Storage, +} from "./r2"; +export { + type AccountRelays, + accountRelays, + freeRelayName, + ownRelayName, + RELAY_NAME, + RELAY_VERSION, + type RelayTarget, + relayNameError, + relayUrlOf, +} from "./relay-account"; +export { + deployRelay, + ERelayDeployStep, + workerNames, + workersSubdomain, + workerUrl, +} from "./relay-deploy"; +export { + type CloudflareAccount, + listAccounts, + tokenTemplateUrl, + verifyToken, +} from "./token"; diff --git a/packages/plugin/src/cloudflare/migrations.ts b/packages/plugin/src/cloudflare/migrations.ts new file mode 100644 index 00000000..fc0b02ca --- /dev/null +++ b/packages/plugin/src/cloudflare/migrations.ts @@ -0,0 +1,32 @@ +import type { RelayBundle } from "./bundle"; + +export interface MigrationUpload { + old_tag?: string; + new_tag: string; + steps: Array>; +} + +/** + * Wrangler's rule: everything after the deployed tag. An unknown deployed tag is refused rather than + * replayed from the start, which could recreate or delete a Durable Object class holding live data. + */ +export function pendingMigrations( + migrations: RelayBundle["migrations"], + deployedTag: string | undefined, +): MigrationUpload | undefined { + const last = migrations.at(-1); + if (!last || deployedTag === last.tag) return undefined; + const at = deployedTag + ? migrations.findIndex((m) => m.tag === deployedTag) + : -1; + if (deployedTag && at === -1) { + throw new Error( + `The deployed relay is at migration "${deployedTag}", which this version does not know.`, + ); + } + return { + ...(deployedTag ? { old_tag: deployedTag } : {}), + new_tag: last.tag, + steps: migrations.slice(at + 1).map(({ tag: _tag, ...step }) => step), + }; +} diff --git a/packages/plugin/src/cloudflare/multipart.ts b/packages/plugin/src/cloudflare/multipart.ts new file mode 100644 index 00000000..184deec0 --- /dev/null +++ b/packages/plugin/src/cloudflare/multipart.ts @@ -0,0 +1,27 @@ +import { toHex } from "@mdsync/protocol"; + +export interface FormPart { + name: string; + filename?: string; + type: string; + data: string; +} + +export interface FormBody { + contentType: string; + body: ArrayBuffer; +} + +/** `requestUrl` takes no FormData, so the body is assembled by hand. */ +export function encodeForm(parts: readonly FormPart[]): FormBody { + const boundary = `mdsync-${toHex(crypto.getRandomValues(new Uint8Array(16)))}`; + const body = parts.map((part) => { + const filename = part.filename ? `; filename="${part.filename}"` : ""; + return `--${boundary}\r\nContent-Disposition: form-data; name="${part.name}"${filename}\r\nContent-Type: ${part.type}\r\n\r\n${part.data}\r\n`; + }); + body.push(`--${boundary}--\r\n`); + return { + contentType: `multipart/form-data; boundary=${boundary}`, + body: new TextEncoder().encode(body.join("")).buffer, + }; +} diff --git a/packages/plugin/src/cloudflare/obsidian-http.ts b/packages/plugin/src/cloudflare/obsidian-http.ts new file mode 100644 index 00000000..73574cf3 --- /dev/null +++ b/packages/plugin/src/cloudflare/obsidian-http.ts @@ -0,0 +1,12 @@ +import { requestUrl } from "obsidian"; + +import type { Http } from "./api"; + +/** + * Kept out of index.ts so the module still loads in Node. `throw: false`: Cloudflare's error envelope + * says more than requestUrl's exception. + */ +export const obsidianHttp: Http = async (request) => { + const res = await requestUrl({ ...request, throw: false }); + return { status: res.status, text: res.text }; +}; diff --git a/packages/plugin/src/cloudflare/r2.ts b/packages/plugin/src/cloudflare/r2.ts new file mode 100644 index 00000000..eefdf6e4 --- /dev/null +++ b/packages/plugin/src/cloudflare/r2.ts @@ -0,0 +1,56 @@ +import { sha256Hex } from "@/crypto"; +import type { S3StorageConfig } from "@/storage/config"; + +import { type CloudflareApi, hasCode } from "./api"; + +const BUCKET_OWNED = 10004; +const R2_NOT_ENABLED = 10042; + +/** R2 needs a one-time opt-in with a payment method in the dashboard, even on the free tier. */ +export class R2NotEnabledError extends Error { + override name = "R2NotEnabledError"; + constructor(readonly accountId: string) { + super("R2 is not enabled on this Cloudflare account yet."); + } +} + +export function r2DashboardUrl(accountId: string): string { + return `https://dash.cloudflare.com/${accountId}/r2/overview`; +} + +export async function ensureBucket( + api: CloudflareApi, + accountId: string, + name: string, +): Promise { + try { + await api.call("POST", `/accounts/${accountId}/r2/buckets`, { + json: { name }, + }); + } catch (err) { + if (hasCode(err, BUCKET_OWNED)) return; + if (hasCode(err, R2_NOT_ENABLED)) throw new R2NotEnabledError(accountId); + throw err; + } +} + +export type R2Storage = Pick< + S3StorageConfig, + "endpoint" | "region" | "bucket" | "accessKeyId" | "secretAccessKey" +>; + +/** Cloudflare's S3 derivation: the token's id and the SHA-256 of its value; nothing is minted. */ +export async function r2Storage( + accountId: string, + bucket: string, + tokenId: string, + token: string, +): Promise { + return { + endpoint: `https://${accountId}.r2.cloudflarestorage.com`, + region: "auto", + bucket, + accessKeyId: tokenId, + secretAccessKey: await sha256Hex(new TextEncoder().encode(token)), + }; +} diff --git a/packages/plugin/src/cloudflare/relay-account.ts b/packages/plugin/src/cloudflare/relay-account.ts new file mode 100644 index 00000000..d4f899bb --- /dev/null +++ b/packages/plugin/src/cloudflare/relay-account.ts @@ -0,0 +1,83 @@ +import bundle from "mdsync:relay-bundle"; + +import type { CloudflareApi } from "./api"; +import { workerNames, workersSubdomain, workerUrl } from "./relay-deploy"; + +/** The relay build this plugin carries; `/status` of a relay running it says the same. */ +export const RELAY_VERSION = bundle.version; +export const RELAY_NAME = bundle.name; + +const NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; + +export interface AccountRelays { + /** Every worker on the account, relay or not: a new relay must not take one's name. */ + taken: readonly string[]; + subdomain: string | null; +} + +export async function accountRelays( + api: CloudflareApi, + accountId: string, +): Promise { + const [taken, subdomain] = await Promise.all([ + workerNames(api, accountId), + workersSubdomain(api, accountId), + ]); + return { taken, subdomain }; +} + +/** Where a relay of this name answers, once the account has a workers.dev name. */ +export function relayUrlOf( + relays: AccountRelays, + name = RELAY_NAME, +): string | null { + return relays.subdomain ? workerUrl(name, relays.subdomain) : null; +} + +/** The worker behind `relayUrl` when it is one of this account's: the relay to update in place. */ +export function ownRelayName( + relays: AccountRelays, + relayUrl: string, +): string | null { + if (!relays.subdomain) return null; + const suffix = `.${relays.subdomain}.workers.dev`; + const host = hostOf(relayUrl); + if (!host?.endsWith(suffix)) return null; + const name = host.slice(0, -suffix.length); + return relays.taken.includes(name) ? name : null; +} + +function hostOf(url: string): string | null { + try { + return new URL(url).hostname; + } catch { + return null; + } +} + +export function freeRelayName(taken: readonly string[]): string { + if (!taken.includes(RELAY_NAME)) return RELAY_NAME; + for (let n = 2; ; n++) { + const name = `${RELAY_NAME}-${n}`; + if (!taken.includes(name)) return name; + } +} + +/** Why `name` cannot be a new worker, or null. */ +export function relayNameError( + name: string, + taken: readonly string[], +): string | null { + if (!NAME.test(name)) { + return "Use lowercase letters, digits and dashes, up to 63."; + } + return taken.includes(name) + ? "A worker with this name already exists." + : null; +} + +export interface RelayTarget { + name: string; + /** The relay's current secret when updating it: shares, links and devices keep working. */ + secret: string; +} diff --git a/packages/plugin/src/cloudflare/relay-bundle.d.ts b/packages/plugin/src/cloudflare/relay-bundle.d.ts new file mode 100644 index 00000000..5e0dc236 --- /dev/null +++ b/packages/plugin/src/cloudflare/relay-bundle.d.ts @@ -0,0 +1,7 @@ +/** The relay this build deploys, built from packages/relay by esbuild.relay-bundle.mjs. */ +declare module "mdsync:relay-bundle" { + import type { RelayBundle } from "@/cloudflare/bundle"; + + const bundle: RelayBundle; + export default bundle; +} diff --git a/packages/plugin/src/cloudflare/relay-deploy.ts b/packages/plugin/src/cloudflare/relay-deploy.ts new file mode 100644 index 00000000..68f0b7c5 --- /dev/null +++ b/packages/plugin/src/cloudflare/relay-deploy.ts @@ -0,0 +1,239 @@ +import { type CloudflareApi, hasCode } from "./api"; +import { uploadAssets } from "./assets"; +import type { RelayBundle } from "./bundle"; +import { type MigrationUpload, pendingMigrations } from "./migrations"; +import { encodeForm } from "./multipart"; + +export const ERelayDeployStep = { + Subdomain: "subdomain", + Storage: "storage", + Files: "files", + Worker: "worker", + Route: "route", +} as const; +export type ERelayDeployStep = + (typeof ERelayDeployStep)[keyof typeof ERelayDeployStep]; + +export interface RelayDeployOptions { + api: CloudflareApi; + accountId: string; + bundle: RelayBundle; + secret: string; + /** A workers.dev name to register when the account has none yet; called again if one is taken. */ + proposeSubdomain: () => string; + onStep?: (step: ERelayDeployStep) => void; + /** Awaited once the worker holds the secret, before its URL is switched on. */ + onUploaded?: (url: string) => Promise; +} + +const SECRET_BINDING = "RELAY_SECRET"; +const VERSION_BINDING = "RELAY_VERSION"; +const NO_SUBDOMAIN = 10007; +const SUBDOMAIN_TAKEN = 10031; +const SUBDOMAIN_ATTEMPTS = 3; + +interface DeployedScript { + migrationTag?: string; + kv: Map; +} + +/** Creates or updates the relay in place; returns its URL, which may need minutes to answer the first time. */ +export async function deployRelay(o: RelayDeployOptions): Promise { + const { api, bundle } = o; + const account = `/accounts/${o.accountId}`; + const script = `${account}/workers/scripts/${bundle.name}`; + + o.onStep?.(ERelayDeployStep.Subdomain); + const subdomain = await ensureSubdomain(api, o.accountId, o.proposeSubdomain); + + o.onStep?.(ERelayDeployStep.Storage); + const deployed = await deployedScript(api, account, bundle.name); + const kv = new Map(); + for (const binding of bundle.kvBindings) { + // An existing relay keeps its namespace: a new one would orphan every share token. + kv.set( + binding, + deployed?.kv.get(binding) ?? + (await ensureNamespace( + api, + account, + namespaceTitle(bundle.name, binding), + )), + ); + } + const migrations = pendingMigrations( + bundle.migrations, + deployed?.migrationTag, + ); + + o.onStep?.(ERelayDeployStep.Files); + const assetsJwt = await uploadAssets( + api, + account, + bundle.name, + bundle.assets.files, + ); + + o.onStep?.(ERelayDeployStep.Worker); + await api.call("PUT", script, { + form: uploadForm(bundle, o.secret, kv, migrations, assetsJwt), + }); + const url = workerUrl(bundle.name, subdomain); + await o.onUploaded?.(url); + + o.onStep?.(ERelayDeployStep.Route); + await api.call("POST", `${script}/subdomain`, { + json: { enabled: true, previews_enabled: false }, + }); + return url; +} + +export function workerUrl(name: string, subdomain: string): string { + return `https://${name}.${subdomain}.workers.dev`; +} + +/** The account's workers.dev name, or null before one is registered. */ +export async function workersSubdomain( + api: CloudflareApi, + accountId: string, +): Promise { + try { + const current = await api.call<{ subdomain?: string } | null>( + "GET", + `/accounts/${accountId}/workers/subdomain`, + ); + return current?.subdomain || null; + } catch (err) { + if (hasCode(err, NO_SUBDOMAIN)) return null; + throw err; + } +} + +export async function workerNames( + api: CloudflareApi, + accountId: string, +): Promise { + return (await listScripts(api, `/accounts/${accountId}`)).map((s) => s.id); +} + +function listScripts( + api: CloudflareApi, + account: string, +): Promise> { + return api.call("GET", `${account}/workers/scripts`); +} + +async function ensureSubdomain( + api: CloudflareApi, + accountId: string, + propose: () => string, +): Promise { + const current = await workersSubdomain(api, accountId); + if (current) return current; + for (let attempt = 1; ; attempt++) { + try { + const created = await api.call<{ subdomain: string }>( + "PUT", + `/accounts/${accountId}/workers/subdomain`, + { json: { subdomain: propose() } }, + ); + return created.subdomain; + } catch (err) { + if (!hasCode(err, SUBDOMAIN_TAKEN) || attempt >= SUBDOMAIN_ATTEMPTS) { + throw err; + } + } + } +} + +async function deployedScript( + api: CloudflareApi, + account: string, + name: string, +): Promise { + const found = (await listScripts(api, account)).find((s) => s.id === name); + if (!found) return null; + const settings = await api.call<{ + bindings?: Array<{ type: string; name: string; namespace_id?: string }>; + }>("GET", `${account}/workers/scripts/${name}/settings`); + const kv = new Map(); + for (const b of settings.bindings ?? []) { + if (b.type === "kv_namespace" && b.namespace_id) + kv.set(b.name, b.namespace_id); + } + return { migrationTag: found.migration_tag, kv }; +} + +/** Wrangler's title for a namespace it provisions, so a relay first deployed by the GitHub action is found. */ +function namespaceTitle(script: string, binding: string): string { + return `${script}-${binding.toLowerCase().replace(/_/g, "-")}`; +} + +/** Found by title first, so a relay deleted and deployed again finds its tokens. */ +async function ensureNamespace( + api: CloudflareApi, + account: string, + title: string, +): Promise { + const namespaces = await api.call>( + "GET", + `${account}/storage/kv/namespaces?per_page=1000`, + ); + const existing = namespaces.find((n) => n.title === title); + if (existing) return existing.id; + const created = await api.call<{ id: string }>( + "POST", + `${account}/storage/kv/namespaces`, + { json: { title } }, + ); + return created.id; +} + +function uploadForm( + bundle: RelayBundle, + secret: string, + kv: ReadonlyMap, + migrations: MigrationUpload | undefined, + assetsJwt: string, +) { + const metadata = { + main_module: bundle.mainModule, + compatibility_date: bundle.compatibilityDate, + compatibility_flags: bundle.compatibilityFlags, + bindings: [ + ...[...kv].map(([name, id]) => ({ + type: "kv_namespace", + name, + namespace_id: id, + })), + ...bundle.durableObjects.map((d) => ({ + type: "durable_object_namespace", + name: d.name, + class_name: d.className, + })), + { type: "assets", name: bundle.assets.binding }, + { type: "plain_text", name: VERSION_BINDING, text: bundle.version }, + { type: "secret_text", name: SECRET_BINDING, text: secret }, + ], + // Keeps secrets set outside MDSync (the Google Drive client); the relay secret above still wins. + keep_bindings: ["secret_text"], + ...(migrations ? { migrations } : {}), + assets: { + jwt: assetsJwt, + config: { run_worker_first: bundle.assets.runWorkerFirst }, + }, + }; + return encodeForm([ + { + name: "metadata", + type: "application/json", + data: JSON.stringify(metadata), + }, + { + name: bundle.mainModule, + filename: bundle.mainModule, + type: "application/javascript+module", + data: bundle.worker, + }, + ]); +} diff --git a/packages/plugin/src/cloudflare/token.ts b/packages/plugin/src/cloudflare/token.ts new file mode 100644 index 00000000..944d26f0 --- /dev/null +++ b/packages/plugin/src/cloudflare/token.ts @@ -0,0 +1,60 @@ +import { type CloudflareApi, hasCode } from "./api"; + +const TOKEN_NAME = "MDSync"; +const INVALID_TOKEN = 1000; + +/** Everything the relay deploy and the R2 storage setup call. */ +const PERMISSIONS = [ + { key: "workers_scripts", type: "edit" }, + { key: "workers_kv_storage", type: "edit" }, + { key: "workers_r2", type: "edit" }, + { key: "account_settings", type: "read" }, +] as const; + +/** + * Opens the dashboard's account token form with these permissions picked; the user picks the account + * and confirms. An account token reaches that one account, unlike a user token's `accountId=*`. + */ +export function tokenTemplateUrl(): string { + const params = new URLSearchParams({ + to: "/:account/api-tokens", + permissionGroupKeys: JSON.stringify(PERMISSIONS), + name: TOKEN_NAME, + }); + return `https://dash.cloudflare.com/?${params}`; +} + +export interface CloudflareAccount { + id: string; + name: string; +} + +/** + * The token's id doubles as the R2 access key id. An account token (`cfat_`) verifies only under its + * account and a user token only under `/user`; each calls the other invalid. + */ +export async function verifyToken( + api: CloudflareApi, + accountId: string, +): Promise { + type Verified = { id: string; status: string }; + let verified: Verified; + try { + verified = await api.call( + "GET", + `/accounts/${accountId}/tokens/verify`, + ); + } catch (err) { + if (!hasCode(err, INVALID_TOKEN)) throw err; + verified = await api.call("GET", "/user/tokens/verify"); + } + if (verified.status !== "active") { + throw new Error(`The Cloudflare token is ${verified.status}.`); + } + return verified.id; +} + +export function listAccounts(api: CloudflareApi): Promise { + // The API's largest page; an account token reaches one account anyway. + return api.call("GET", "/accounts?per_page=50"); +} diff --git a/packages/plugin/src/commands/index.ts b/packages/plugin/src/commands/index.ts index 83c8b5ae..917daba0 100644 --- a/packages/plugin/src/commands/index.ts +++ b/packages/plugin/src/commands/index.ts @@ -32,6 +32,12 @@ export function registerCommands( plugin: Plugin & PluginHost, openNoteMenu: (checking: boolean) => boolean, ): void { + plugin.addCommand({ + id: "open-setup-wizard", + name: "Set up sync", + callback: () => plugin.openSetup(), + }); + plugin.addCommand({ id: "compare", name: "Compare with remote", diff --git a/packages/plugin/src/constants.ts b/packages/plugin/src/constants.ts index 6a6cfffd..501d075a 100644 --- a/packages/plugin/src/constants.ts +++ b/packages/plugin/src/constants.ts @@ -19,3 +19,5 @@ export const FILE_HISTORY_MAX_SNAPSHOTS = 1000; export const SOURCE_CONTROL_VIEW_TYPE = "mdsync-source-control"; export const DIFF_VIEW_TYPE = "mdsync-diff"; + +export const STORAGE_CHANGED_REASON = "Storage backend changed."; diff --git a/packages/plugin/src/core/passphrase-manager.ts b/packages/plugin/src/core/passphrase-manager.ts index bfcfdd0c..fa1f046d 100644 --- a/packages/plugin/src/core/passphrase-manager.ts +++ b/packages/plugin/src/core/passphrase-manager.ts @@ -18,7 +18,11 @@ import { type ObjectStorage, storageIdentity, } from "@/storage"; -import { resolveContentKey, rotatePassphrase } from "@/sync/keyfile"; +import { + readKeyfile, + resolveContentKey, + rotatePassphrase, +} from "@/sync/keyfile"; interface CachedKey { key: EncryptionKey; @@ -112,12 +116,51 @@ export class PassphraseManager { } if (!(await this.prompt(false))) return null; if (!this.passphrase) return null; - const storage = createStorageAdapter(activeStorage(this.settings)); - const epoch = await rotatePassphrase(storage, this.passphrase, next); + const epoch = await rotatePassphrase(this.storage(), this.passphrase, next); await this.replacePassphrase(next); return epoch; } + /** The current storage's key is open, so the passphrase is known to be this vault's. */ + isUnlocked(): boolean { + return this.cachedKey?.signature === this.bindingSignature(); + } + + /** A new vault has none until the first {@link unlock} creates it. */ + async vaultHasKey(): Promise { + return (await readKeyfile(this.storage())) !== null; + } + + /** + * Opens the vault key with `value` (creating it on a new vault), or without one with the entered or saved + * passphrase; false when there is none. A value that fails is dropped again. + */ + async unlock(value?: string): Promise { + const before = { + passphrase: this.passphrase, + unverified: this.unverified, + cachedKey: this.cachedKey, + }; + if (value !== undefined) { + this.passphrase = value; + this.unverified = true; + this.cachedKey = null; + } else if (!this.passphrase && !(await this.tryLoadCached())) { + return false; + } + try { + await this.resolveKey(this.storage()); + return true; + } catch (err) { + if (value !== undefined && this.passphrase === value) { + this.passphrase = before.passphrase; + this.unverified = before.unverified; + this.cachedKey = before.cachedKey; + } + throw err; + } + } + /** Adopts a passphrase known to open the vault: a rotation's, or one that opened a transfer. */ async replacePassphrase(value: string): Promise { this.passphrase = value; @@ -198,6 +241,10 @@ export class PassphraseManager { return storageIdentity(activeStorage(this.settings)); } + private storage(): ObjectStorage { + return createStorageAdapter(activeStorage(this.settings)); + } + private async tryLoadCached(): Promise { if (!this.settings.cachePassphrase) return false; const cached = await loadCachedPassphrase( diff --git a/packages/plugin/src/core/session-factory.ts b/packages/plugin/src/core/session-factory.ts index c45bb840..97efe498 100644 --- a/packages/plugin/src/core/session-factory.ts +++ b/packages/plugin/src/core/session-factory.ts @@ -41,6 +41,8 @@ export interface SessionFactoryDeps { state: StatePersister; logs: LogService; notify: (message: string) => void; + /** A sync asked for before this device has storage. */ + onNoStorage: () => void; /** Persists settings an adapter rewrote itself, such as a refreshed token. */ persistSettings?: () => Promise; /** Late-bound: live editing starts after the sync it hooks into. */ @@ -142,7 +144,7 @@ async function openVault( ESyncLogOperation.Session, "Session blocked because storage is not configured.", ); - deps.notify("Configure a storage backend first."); + deps.onNoStorage(); return null; } if (!(await deps.passphrase.prompt(false))) { diff --git a/packages/plugin/src/crypto/index.ts b/packages/plugin/src/crypto/index.ts index 429d2510..4d28f245 100644 --- a/packages/plugin/src/crypto/index.ts +++ b/packages/plugin/src/crypto/index.ts @@ -10,7 +10,7 @@ import { KDF_SALT_LABEL, MIN_PASSPHRASE_LENGTH, } from "@/crypto/constants"; -import { deflateBytes, GZIP, inflateBytes } from "@/utils"; +import { bytesToBase64Url, deflateBytes, GZIP, inflateBytes } from "@/utils"; const subtle = window.crypto.subtle; const encoder = new TextEncoder(); @@ -213,6 +213,11 @@ export function randomBytes(length: number): Uint8Array { return bytes; } +/** 32 random bytes as base64url: a shared secret such as the relay's. */ +export function randomSecret(): string { + return bytesToBase64Url(randomBytes(32)); +} + export function randomId(): string { if (typeof window.crypto.randomUUID === "function") { return window.crypto.randomUUID(); diff --git a/packages/plugin/src/crypto/sealed-link.ts b/packages/plugin/src/crypto/sealed-link.ts index 97ab1a62..b52fc3b8 100644 --- a/packages/plugin/src/crypto/sealed-link.ts +++ b/packages/plugin/src/crypto/sealed-link.ts @@ -13,6 +13,8 @@ const LINK_VERSION = 5; const LINK_SALT_BYTES = 16; const LINK_PARTS = 4; export const LINK_PARAM = "d"; +const NOT_OPENED = + "Could not open the link: wrong passphrase, or the link was cut short."; const ELinkEncoding = { Plain: "p", @@ -47,7 +49,14 @@ export async function openLink( ): Promise { const parsed = parseLinkToken(extractLinkToken(input)); const key = await deriveKey(passphrase, parsed.salt); - const encoded = await decryptBytes(key, parsed.ciphertext); + const encoded = await decryptBytes(key, parsed.ciphertext).catch( + (err: unknown) => { + // AES-GCM reports a wrong key only as an OperationError with no message. + throw err instanceof DOMException && err.name === "OperationError" + ? new Error(NOT_OPENED) + : err; + }, + ); return decodeLinkBytes(parsed.encoding, encoded); } diff --git a/packages/plugin/src/links/record.ts b/packages/plugin/src/links/record.ts index 6a178841..47b8187a 100644 --- a/packages/plugin/src/links/record.ts +++ b/packages/plugin/src/links/record.ts @@ -1,4 +1,4 @@ -import { relayBase } from "@/shared"; +import { isCleartextRemote, relayBase } from "@/shared"; const MS_PER_S = 1000; @@ -81,6 +81,7 @@ export function onRelay( relay: { relayUrl: string; relaySecret: string }, ): boolean { if (!relay.relayUrl || !relay.relaySecret) return false; + if (isCleartextRemote(relay.relayUrl)) return false; try { return ( new URL(record.url).origin === new URL(relayBase(relay.relayUrl)).origin diff --git a/packages/plugin/src/main.ts b/packages/plugin/src/main.ts index 559c2d3f..0ec8d46c 100644 --- a/packages/plugin/src/main.ts +++ b/packages/plugin/src/main.ts @@ -17,6 +17,11 @@ import { type MdsyncSettings, mergeSettings, } from "@/settings/model"; +import { + closeSetupWizard, + type ESetupStep, + openSetupWizard, +} from "@/settings/setup"; import type { MdsyncSettingTab } from "@/settings/tab"; import { SettingsTransferController } from "@/settings/transfer-controller"; import { reportWarning } from "@/shared"; @@ -28,6 +33,7 @@ import { createDeletedElsewhere, createSpaceGone, type IndicatorHandle, + notifyAction, notifyInfo, } from "@/ui"; import { loadNodeFs } from "@/vault/symlinks"; @@ -90,7 +96,8 @@ export default class MdsyncPlugin extends Plugin implements PluginHost { private scopeRefreshTimer: number | null = null; private editorSigns: SignsHandle | null = null; private fileIndicators: IndicatorHandle | null = null; - private unloaded = false; + unloaded = false; + private freshInstall = false; /** One settings write at a time: two in flight may land the older one last. */ private savingSettings: Promise = Promise.resolve(); @@ -114,6 +121,10 @@ export default class MdsyncPlugin extends Plugin implements PluginHost { ), onTheirsPulled: (space, paths) => markTheirs(this, this.unseen, space, paths), + onNoStorage: () => + notifyAction("Set up storage to sync.", "Set up", () => + this.openSetup(), + ), persistSettings: () => this.saveSettings(), liveNotes: (space) => this.realtime?.liveNotes(space), }); @@ -169,6 +180,12 @@ export default class MdsyncPlugin extends Plugin implements PluginHost { // Re-render the open Settings tab so auth status updates live. registerProtocolHandlers(this, () => this.settingsTab?.display()); + + if (this.freshInstall) { + // Saved first: closing the wizard must not bring it back on every start. + await this.saveSettings(); + this.app.workspace.onLayoutReady(() => this.openSetup()); + } } onunload(): void { @@ -178,6 +195,7 @@ export default class MdsyncPlugin extends Plugin implements PluginHost { this.scopeRefreshTimer = null; } // Isolated: one throwing teardown must not leave the rest running after unload. + safely(() => closeSetupWizard()); safely(() => this.editorSigns?.dispose()); this.editorSigns = null; this.fileIndicators = null; @@ -190,6 +208,7 @@ export default class MdsyncPlugin extends Plugin implements PluginHost { async loadSettings(): Promise { const data = (await this.loadData()) as Partial | null; + this.freshInstall = data === null; this.settings = mergeSettings(data); } @@ -219,6 +238,11 @@ export default class MdsyncPlugin extends Plugin implements PluginHost { refreshOpenSourceControlViews(this); } + openSetup(step?: ESetupStep): void { + // The settings tab under the wizard would keep showing what the wizard replaced. + openSetupWizard(this, step, () => this.settingsTab?.display()); + } + scheduleScopeRefresh(reason = "Sync scope changed."): void { const snapshot = this.controller.getSnapshot(); // A first refresh still running read the old scope: drop it too. diff --git a/packages/plugin/src/plugin/bootstrap.ts b/packages/plugin/src/plugin/bootstrap.ts index 5ec1ad53..62107767 100644 --- a/packages/plugin/src/plugin/bootstrap.ts +++ b/packages/plugin/src/plugin/bootstrap.ts @@ -35,6 +35,7 @@ interface BootstrapPluginRuntimeOptions { onSpaceGone?: (space: Space) => void; onUnindexed?: (count: number) => void; onTheirsPulled?: (space: Space, paths: readonly string[]) => void; + onNoStorage: () => void; persistSettings: () => Promise; liveNotes?: (space: Space) => LiveNotes | undefined; } @@ -64,6 +65,7 @@ export async function bootstrapPluginRuntime( state: statePersister, logs, notify: notifyInfo, + onNoStorage: options.onNoStorage, persistSettings, liveNotes, }); diff --git a/packages/plugin/src/plugin/host.ts b/packages/plugin/src/plugin/host.ts index c323af86..94630716 100644 --- a/packages/plugin/src/plugin/host.ts +++ b/packages/plugin/src/plugin/host.ts @@ -4,6 +4,7 @@ import type { DeviceName, LogService, PassphraseManager } from "@/core"; import type { SharedLinks } from "@/links"; import type { Unseen } from "@/presence"; import type { MdsyncSettings } from "@/settings/model"; +import type { ESetupStep } from "@/settings/setup/steps"; import type { SettingsTransferController } from "@/settings/transfer-controller"; import type { SpaceRecords } from "@/spaces"; import type { SyncController } from "@/sync/controller"; @@ -28,6 +29,8 @@ export interface PluginHost { readonly spaces: SpaceRecords; readonly unseen: Unseen; readonly sharedLinks: SharedLinks; + /** Work that outlives its view, like a relay deploy, stops here. */ + readonly unloaded: boolean; saveSettings(): Promise; scheduleScopeRefresh(reason?: string): void; @@ -35,4 +38,6 @@ export interface PluginHost { refreshEditorSigns(enabled: boolean): void; refreshFileIndicators(enabled: boolean): void; refreshSourceControlView(): void; + /** The setup wizard, at `step` or where this device's setup stopped. */ + openSetup(step?: ESetupStep): void; } diff --git a/packages/plugin/src/settings/cloudflare-login.ts b/packages/plugin/src/settings/cloudflare-login.ts new file mode 100644 index 00000000..4600ec24 --- /dev/null +++ b/packages/plugin/src/settings/cloudflare-login.ts @@ -0,0 +1,64 @@ +import { + type CloudflareAccount, + type CloudflareApi, + cloudflareApi, + hasCode, + listAccounts, + verifyToken, +} from "@/cloudflare"; +import { obsidianHttp } from "@/cloudflare/obsidian-http"; +import type { PluginHost } from "@/plugin/host"; +import type { MdsyncSettings } from "@/settings/model"; +import { errorMessage } from "@/shared"; + +/** A per-Worker token or a role without the legacy Workers Scripts permission. */ +const AUTH_ERROR = 10000; + +export interface CloudflareLogin { + api: CloudflareApi; + accountId: string; +} + +export function cloudflareErrorMessage(err: unknown): string { + return hasCode(err, AUTH_ERROR) + ? "Cloudflare refused the token. Create it again with Open Cloudflare, keeping every permission it selects, and paste the new one." + : errorMessage(err); +} + +/** The connected account, or null before a token is saved here. */ +export function cloudflareOf(settings: MdsyncSettings): CloudflareLogin | null { + const { cloudflareToken: token, cloudflareAccountId: accountId } = settings; + return token && accountId + ? { api: cloudflareApi(obsidianHttp, token), accountId } + : null; +} + +export async function accountsOf(token: string): Promise { + const accounts = await listAccounts(cloudflareApi(obsidianHttp, token)); + if (accounts.length === 0) { + throw new Error("This token reaches no Cloudflare account."); + } + return accounts; +} + +/** Verified before it is kept: a revoked or expired token would fail every later step. */ +export async function connectCloudflare( + plugin: PluginHost, + token: string, + accountId: string, +): Promise { + await verifyToken(cloudflareApi(obsidianHttp, token), accountId); + Object.assign(plugin.settings, { + cloudflareToken: token, + cloudflareAccountId: accountId, + }); + await plugin.saveSettings(); +} + +export async function forgetCloudflare(plugin: PluginHost): Promise { + Object.assign(plugin.settings, { + cloudflareToken: "", + cloudflareAccountId: "", + }); + await plugin.saveSettings(); +} diff --git a/packages/plugin/src/settings/connection-test.ts b/packages/plugin/src/settings/connection-test.ts index 94ed4e91..3384f7ff 100644 --- a/packages/plugin/src/settings/connection-test.ts +++ b/packages/plugin/src/settings/connection-test.ts @@ -6,7 +6,7 @@ import { isRelayConfigured, type RelayConfig, } from "@/settings/model"; -import { errorMessage, relayBase } from "@/shared"; +import { errorMessage, isCleartextRemote, relayBase } from "@/shared"; import { createStorageAdapter, describeStorageTarget, @@ -19,6 +19,11 @@ export interface ConnectionTestResult { message: string; } +export interface RelayTestResult extends ConnectionTestResult { + /** The build the relay runs; null for one deployed outside MDSync. */ + version?: string | null; +} + /** * Reaches the remote without a passphrase, so credentials can be checked before anything is encrypted. * Reads only the manifest key: absent is a healthy empty remote, anything else a real connection problem. @@ -49,9 +54,14 @@ export async function testConnection( } /** Tells a wrong URL (unreachable, HTTP 404) apart from a wrong secret. */ -export async function testRelay( - relay: RelayConfig, -): Promise { +export async function testRelay(relay: RelayConfig): Promise { + if (isCleartextRemote(relay.relayUrl)) { + return { + ok: false, + message: + "Use an https:// relay URL: the secret is sent with every request.", + }; + } if (!isRelayConfigured(relay)) { return { ok: false, message: "Enter the relay URL and secret first." }; } @@ -62,9 +72,20 @@ export async function testRelay( headers: { "X-Mdsync-Admin": relay.relaySecret }, throw: false, }); + const body = jsonOf(res) as { + message?: string; + version?: string | null; + } | null; return res.status === 200 - ? { ok: true, message: "Connected. The relay accepts this secret." } - : { ok: false, message: `Relay error: ${relayMessage(res)}` }; + ? { + ok: true, + message: "Connected. The relay accepts this secret.", + version: body?.version ?? null, + } + : { + ok: false, + message: `Relay error: ${body?.message ?? `HTTP ${res.status}`}`, + }; } catch (err) { return { ok: false, message: errorMessage(err) }; } @@ -74,12 +95,10 @@ export async function testRelay( * An edge error page is HTML and Obsidian parses `.json` lazily: reading it would throw a SyntaxError over * the status the caller needs. */ -function relayMessage(res: { status: number; json?: unknown }): string { +function jsonOf(res: { json?: unknown }): unknown { try { - const detail = res.json as { message?: string } | undefined; - if (detail?.message) return detail.message; + return res.json ?? null; } catch { - // Not JSON; the status is the whole story. + return null; } - return `HTTP ${res.status}`; } diff --git a/packages/plugin/src/settings/model.ts b/packages/plugin/src/settings/model.ts index 0a11bb75..89e57b3d 100644 --- a/packages/plugin/src/settings/model.ts +++ b/packages/plugin/src/settings/model.ts @@ -7,6 +7,7 @@ import { FILE_HISTORY_MIN_SNAPSHOTS, } from "@/constants"; import { type LinkRecord, parseLinkRecord } from "@/links/record"; +import { isCleartextRemote } from "@/shared"; import { isSpaceRecord, type SpaceRecord } from "@/spaces/record"; import { type BrokerAdmin, @@ -89,6 +90,9 @@ export interface MdsyncSettings { relayUrl: string; /** The worker's RELAY_SECRET; relay room tokens derive from it. */ relaySecret: string; + /** Deploys and updates the relay, and made the R2 keys. Device-local, never transferred. */ + cloudflareToken: string; + cloudflareAccountId: string; cachePassphrase: boolean; showStatusBar: boolean; showRibbonIcon: boolean; @@ -131,6 +135,8 @@ export const DEFAULT_SETTINGS: MdsyncSettings = { links: [], relayUrl: "", relaySecret: "", + cloudflareToken: "", + cloudflareAccountId: "", cachePassphrase: true, showStatusBar: true, showRibbonIcon: true, @@ -173,9 +179,12 @@ export function ownerStorage(settings: MdsyncSettings): S3StorageConfig | null { export type RelayConfig = Pick; -/** Both or nothing: the URL alone opens no room and signs nothing. */ +/** Both or nothing: the URL alone opens no room and signs nothing. A relay that takes the secret in the clear is none. */ export function isRelayConfigured(relay: RelayConfig): boolean { - return Boolean(relay.relayUrl && relay.relaySecret); + return ( + Boolean(relay.relayUrl && relay.relaySecret) && + !isCleartextRemote(relay.relayUrl) + ); } /** The owner's side of the relay: shares and links both speak to it with the secret. */ diff --git a/packages/plugin/src/settings/sections/automation.ts b/packages/plugin/src/settings/sections/automation.ts index ae2e3518..f99c34bb 100644 --- a/packages/plugin/src/settings/sections/automation.ts +++ b/packages/plugin/src/settings/sections/automation.ts @@ -17,20 +17,51 @@ import { type SettingsField, SUB_SETTING_CLASS, } from "@/settings/fields"; +import { isRelayConfigured } from "@/settings/model"; import { EFieldKind } from "@/storage"; import { clampMaxSnapshots } from "@/sync/history"; import { VAULT_SPACE } from "@/sync/space"; import { RELAY_TEXT } from "@/ui/common"; +const AUTOSYNC_FIELD: SettingsField = { + kind: EFieldKind.Toggle, + name: "Autosync", + desc: "Sync automatically: once after startup and on a schedule. Pulls remote changes; conflicts that cannot be merged safely stop the cycle.", + get: (s) => s.autoSyncEnabled, + set: (v) => ({ autoSyncEnabled: v }), + rerender: true, +}; + +const REALTIME_FIELD: SettingsField = { + kind: EFieldKind.Toggle, + name: "Real-time sync", + desc: "Other devices pull the moment you push, and you see who is in which note. Turning it off also stops live editing. Your devices use the relay server (Connection tab), shared folders their owner's.", + get: (s) => s.realtimeSync, + set: (v) => ({ realtimeSync: v }), + after: restartRelay, + rerender: true, +}; + +const LIVE_EDITING_FIELD: SettingsField = { + kind: EFieldKind.Toggle, + name: "Live editing", + desc: "Notes open in the editor edit together, keystroke by keystroke, across your devices and with the people in shared folders, through the relay.", + when: (s) => s.realtimeSync, + sub: true, + get: (s) => s.liveEditing, + set: (v) => ({ liveEditing: v }), + after: (plugin) => void plugin.realtime.live.refresh(), +}; + +/** What the setup wizard offers once the vault syncs; real-time sync only with a relay to carry it. */ +export const SETUP_FIELDS: ReadonlyArray = [ + AUTOSYNC_FIELD, + { ...REALTIME_FIELD, when: isRelayConfigured }, + LIVE_EDITING_FIELD, +]; + const AUTOMATION_FIELDS: ReadonlyArray = [ - { - kind: EFieldKind.Toggle, - name: "Autosync", - desc: "Sync automatically: once after startup and on a schedule. Pulls remote changes; conflicts that cannot be merged safely stop the cycle.", - get: (s) => s.autoSyncEnabled, - set: (v) => ({ autoSyncEnabled: v }), - rerender: true, - }, + AUTOSYNC_FIELD, { kind: EFieldKind.Number, name: "Interval (minutes)", @@ -106,25 +137,8 @@ const AUTOMATION_FIELDS: ReadonlyArray = [ get: (s) => s.historyAutoRefresh, set: (v) => ({ historyAutoRefresh: v }), }, - { - kind: EFieldKind.Toggle, - name: "Real-time sync", - desc: "Other devices pull the moment you push, and you see who is in which note. Turning it off also stops live editing. Your devices use the relay server (Connection tab), shared folders their owner's.", - get: (s) => s.realtimeSync, - set: (v) => ({ realtimeSync: v }), - after: restartRelay, - rerender: true, - }, - { - kind: EFieldKind.Toggle, - name: "Live editing", - desc: "Notes open in the editor edit together, keystroke by keystroke, across your devices and with the people in shared folders, through the relay.", - when: (s) => s.realtimeSync, - sub: true, - get: (s) => s.liveEditing, - set: (v) => ({ liveEditing: v }), - after: (plugin) => void plugin.realtime.live.refresh(), - }, + REALTIME_FIELD, + LIVE_EDITING_FIELD, { kind: EFieldKind.Toggle, name: "Show my open note to others", diff --git a/packages/plugin/src/settings/sections/backend.ts b/packages/plugin/src/settings/sections/backend.ts index ff4b42b3..640ce4e2 100644 --- a/packages/plugin/src/settings/sections/backend.ts +++ b/packages/plugin/src/settings/sections/backend.ts @@ -1,5 +1,6 @@ import { Setting } from "obsidian"; +import { STORAGE_CHANGED_REASON } from "@/constants"; import type { PluginHost } from "@/plugin/host"; import { testConnection } from "@/settings/connection-test"; import { renderCheckRow } from "@/settings/fields"; @@ -7,8 +8,6 @@ import type { EStorageBackend } from "@/storage"; import { getDescriptor, listBackends } from "@/storage"; import { renderStorageFields } from "./storage-fields"; -const BACKEND_SETTINGS_CHANGED = "Storage backend changed."; - export function renderBackendSection( parent: HTMLElement, plugin: PluginHost, @@ -40,7 +39,7 @@ export function renderBackendSection( settings.activeStorageKind = nextKind; void plugin.saveSettings().then(() => { - plugin.scheduleScopeRefresh(BACKEND_SETTINGS_CHANGED); + plugin.scheduleScopeRefresh(STORAGE_CHANGED_REASON); onDisplay(); }); }); diff --git a/packages/plugin/src/settings/sections/index.ts b/packages/plugin/src/settings/sections/index.ts index ba3f4111..c99f204b 100644 --- a/packages/plugin/src/settings/sections/index.ts +++ b/packages/plugin/src/settings/sections/index.ts @@ -1,4 +1,4 @@ -export { renderAutomationSection } from "./automation"; +export { renderAutomationSection, SETUP_FIELDS } from "./automation"; export { renderBackendSection } from "./backend"; export { renderExclusionsSection } from "./exclusions"; export { renderLinksSection } from "./links"; diff --git a/packages/plugin/src/settings/sections/relay.ts b/packages/plugin/src/settings/sections/relay.ts index d05314d9..1cfab6c1 100644 --- a/packages/plugin/src/settings/sections/relay.ts +++ b/packages/plugin/src/settings/sections/relay.ts @@ -1,16 +1,18 @@ import { Setting } from "obsidian"; + +import { randomSecret } from "@/crypto"; +import { forgetCloudflare } from "@/settings/cloudflare-login"; import { testRelay } from "@/settings/connection-test"; import { type FieldContext, renderCheckRow, renderField, } from "@/settings/fields"; +import { isRelayConfigured } from "@/settings/model"; +import { ESetupStep } from "@/settings/setup/steps"; import { relayBase } from "@/shared"; import { EFieldKind } from "@/storage"; import { runWithNotice } from "@/ui/common"; -import { bytesToBase64Url } from "@/utils"; - -const SECRET_BYTES = 32; function secretButtonLabel(secret: string): string { return secret ? "Copy" : "Generate"; @@ -22,9 +24,17 @@ export function renderRelaySection( ): void { const { plugin } = ctx; new Setting(parent).setName("Relay server").setHeading(); - new Setting(parent).setDesc( - "Your own Cloudflare worker: instant sync between devices. Generate a secret, save it as the RELAY_SECRET repository secret, run the Deploy Relay GitHub action and paste the URL it prints.", - ); + new Setting(parent) + .setDesc( + "Your own Cloudflare worker: instant sync between devices, live notes and share links. MDSync deploys it to your Cloudflare account.", + ) + .addButton((button) => + button + .setButtonText( + isRelayConfigured(plugin.settings) ? "Update relay" : "Deploy relay", + ) + .onClick(() => plugin.openSetup(ESetupStep.Relay)), + ); renderField(parent, ctx, { kind: EFieldKind.Text, @@ -51,16 +61,14 @@ export function renderRelaySection( ?.addEventListener("input", showLabel); button.onClick(async () => { if (!plugin.settings.relaySecret) { - plugin.settings.relaySecret = bytesToBase64Url( - crypto.getRandomValues(new Uint8Array(SECRET_BYTES)), - ); + plugin.settings.relaySecret = randomSecret(); await plugin.saveSettings(); ctx.rerender(); } const { relaySecret } = plugin.settings; await runWithNotice( () => navigator.clipboard.writeText(relaySecret), - "Relay secret copied. Save it as the RELAY_SECRET repository secret.", + "Relay secret copied.", "Could not copy the relay secret", ); }); @@ -72,4 +80,18 @@ export function renderRelaySection( "Checks that the URL reaches your relay and the secret matches.", () => testRelay(plugin.settings), ); + + if (plugin.settings.cloudflareToken) { + new Setting(parent) + .setName("Cloudflare token") + .setDesc( + "Kept on this device to deploy and update the relay. R2 storage keys made from it stop working if it is deleted in Cloudflare.", + ) + .addButton((button) => + button.setButtonText("Forget").onClick(async () => { + await forgetCloudflare(plugin); + ctx.rerender(); + }), + ); + } } diff --git a/packages/plugin/src/settings/sections/storage-fields.ts b/packages/plugin/src/settings/sections/storage-fields.ts index 0c9c8403..0ffb2d60 100644 --- a/packages/plugin/src/settings/sections/storage-fields.ts +++ b/packages/plugin/src/settings/sections/storage-fields.ts @@ -103,7 +103,11 @@ function renderGoogleDriveAuth( .setDesc( isAuth ? "Authenticated. Tokens are stored on this device." - : "Not authenticated. Click to authorize.", + : createFragment((f) => { + f.appendText("Not authenticated. Select "); + f.createEl("strong", { text: "Log in" }); + f.appendText(" to authorize."); + }), ) .addButton((b) => b diff --git a/packages/plugin/src/settings/setup/choice-card.ts b/packages/plugin/src/settings/setup/choice-card.ts new file mode 100644 index 00000000..6420b5b7 --- /dev/null +++ b/packages/plugin/src/settings/setup/choice-card.ts @@ -0,0 +1,34 @@ +import { setIcon } from "obsidian"; + +export interface ChoiceCardOptions { + icon: string; + title: string; + desc: string; + badge?: string; + /** The way most people take: its icon is filled with the accent. */ + primary?: boolean; + onClick: () => void; +} + +/** A whole-row button for picking one way forward. */ +export function choiceCard( + parent: HTMLElement, + options: ChoiceCardOptions, +): HTMLButtonElement { + const card = parent.createEl("button", { cls: "mdsync-choice" }); + card.type = "button"; + if (options.primary) card.addClass("is-primary"); + setIcon(card.createSpan({ cls: "mdsync-choice-icon" }), options.icon); + const text = card.createDiv({ cls: "mdsync-choice-text" }); + const title = text.createDiv({ + cls: "mdsync-choice-title", + text: options.title, + }); + if (options.badge) { + title.createSpan({ cls: "mdsync-choice-badge", text: options.badge }); + } + text.createDiv({ cls: "mdsync-choice-desc", text: options.desc }); + setIcon(card.createSpan({ cls: "mdsync-choice-arrow" }), "chevron-right"); + card.addEventListener("click", options.onClick); + return card; +} diff --git a/packages/plugin/src/settings/setup/cloudflare-token.ts b/packages/plugin/src/settings/setup/cloudflare-token.ts new file mode 100644 index 00000000..d9476c3a --- /dev/null +++ b/packages/plugin/src/settings/setup/cloudflare-token.ts @@ -0,0 +1,146 @@ +import { Setting } from "obsidian"; + +import { type CloudflareAccount, tokenTemplateUrl } from "@/cloudflare"; +import { accountsOf, connectCloudflare } from "@/settings/cloudflare-login"; +import { alertLine, focusKey, onEnter } from "@/ui/common"; + +import { actionRow } from "./form"; +import { type ActionState, runAction } from "./run-action"; +import type { Wizard } from "./wizard"; + +/** Fetched only when the person opens "Show me how"; disclosed in the README. */ +const TOKEN_DEMO_URL = + "https://raw.githubusercontent.com/k1tbyte/mdsync/main/docs/demos/cloudflare-token.webp"; + +export interface TokenForm extends ActionState { + token: string; + accounts: CloudflareAccount[]; + accountId: string; + demoOpen: boolean; +} + +export function tokenForm(): TokenForm { + return { + token: "", + accounts: [], + accountId: "", + demoOpen: false, + error: "", + busy: false, + }; +} + +function renderDemo(parent: HTMLElement, form: TokenForm): void { + const demo = parent.createEl("details", { cls: "mdsync-howto-demo" }); + demo.open = form.demoOpen; + demo.createEl("summary", { text: "Show me how" }); + const load = (): void => { + if (demo.querySelector("img, .mdsync-howto-demo-failed")) return; + const img = demo.createEl("img", { + attr: { + src: TOKEN_DEMO_URL, + alt: "Creating the token in Cloudflare and copying Your API Token, not the S3 keys", + }, + }); + img.addEventListener("error", () => { + img.replaceWith( + createDiv({ + cls: "mdsync-howto-demo-failed", + text: "The clip could not be loaded.", + }), + ); + }); + }; + if (form.demoOpen) load(); + demo.addEventListener("toggle", () => { + form.demoOpen = demo.open; + if (demo.open) load(); + }); +} + +/** `connected` runs once the token is verified and saved on this device. */ +export function renderTokenForm( + el: HTMLElement, + wizard: Wizard, + form: TokenForm, + connected: () => void, +): void { + const connect = async (): Promise => { + if (!form.token) return; + let saved = false; + await runAction(wizard, form, async () => { + const { token } = form; + const listed = form.accounts.length === 0; + if (listed) { + const accounts = await accountsOf(token); + // Edited meanwhile: these accounts belong to the old token. + if (form.token !== token) return; + form.accounts = accounts; + form.accountId = accounts[0]?.id ?? ""; + } + // Several accounts: the person picks one below and connects again. + if (listed && form.accounts.length > 1) return; + await connectCloudflare(wizard.plugin, token, form.accountId); + saved = true; + }); + if (saved) connected(); + }; + + const steps = el.createEl("ol", { cls: "mdsync-howto" }); + const create = steps.createEl("li"); + new Setting(create) + .setName("Create a token") + .setDesc( + "Opens Cloudflare with the permissions MDSync needs already selected. Sign up or log in, select your account, then select Continue to summary and Create token.", + ) + .addButton((b) => + b + .setButtonText("Open Cloudflare") + .onClick(() => window.open(tokenTemplateUrl())), + ); + renderDemo(create, form); + new Setting(steps.createEl("li")) + .setName("Paste the token") + .setDesc( + "Kept on this device only, to update the relay later. Never synced or exported.", + ) + .addText((t) => { + t.inputEl.type = "password"; + t.inputEl.setAttr("aria-label", "Cloudflare token"); + focusKey(t.inputEl, "cloudflare-token"); + t.setValue(form.token).onChange((v) => { + form.token = v.trim(); + form.accounts = []; + form.accountId = ""; + }); + onEnter(t.inputEl, () => void connect()); + }); + if (form.accounts.length > 1) { + new Setting(steps.createEl("li")) + .setName("Choose the account") + .setDesc( + createFragment((f) => { + f.appendText( + "This token opens several accounts. Select one, then select ", + ); + f.createEl("strong", { text: "Connect" }); + f.appendText(" again."); + }), + ) + .addDropdown((d) => { + for (const account of form.accounts) + d.addOption(account.id, account.name); + d.setValue(form.accountId).onChange((v) => { + form.accountId = v; + }); + }); + } + if (form.error) alertLine(el).setText(form.error); + actionRow(el).addButton((b) => + b + .setButtonText(form.busy ? "Connecting…" : "Connect") + .setCta() + .setDisabled(form.busy) + .onClick(() => void connect()), + ); +} diff --git a/packages/plugin/src/settings/setup/form.ts b/packages/plugin/src/settings/setup/form.ts new file mode 100644 index 00000000..e1c3c247 --- /dev/null +++ b/packages/plugin/src/settings/setup/form.ts @@ -0,0 +1,34 @@ +import { Setting, setIcon } from "obsidian"; + +export type CalloutTone = "info" | "warning" | "success"; + +const CALLOUT_ICONS: Record = { + info: "info", + warning: "triangle-alert", + success: "circle-check", +}; + +/** Buttons that belong to the fields above them: no separator, so they read as part of the form. */ +export function actionRow(parent: HTMLElement): Setting { + const row = new Setting(parent); + row.settingEl.addClass("mdsync-button-row"); + return row; +} + +export function callout( + parent: HTMLElement, + tone: CalloutTone, + text: string, +): HTMLElement { + const box = parent.createDiv({ cls: `mdsync-callout is-${tone}` }); + setIcon(box.createSpan({ cls: "mdsync-callout-icon" }), CALLOUT_ICONS[tone]); + box.createDiv({ cls: "mdsync-callout-text", text }); + return box; +} + +/** A line for work that is still going. */ +export function busyLine(parent: HTMLElement, text: string): void { + const line = parent.createDiv({ cls: "mdsync-setup-busy" }); + line.createSpan({ cls: "mdsync-status-spinner" }); + line.createSpan({ text }); +} diff --git a/packages/plugin/src/settings/setup/import-step.ts b/packages/plugin/src/settings/setup/import-step.ts new file mode 100644 index 00000000..551d54c8 --- /dev/null +++ b/packages/plugin/src/settings/setup/import-step.ts @@ -0,0 +1,78 @@ +import { Setting } from "obsidian"; + +import { canSync } from "@/settings/model"; +import { alertLine, focusKey, onEnter } from "@/ui/common"; + +import { actionRow, callout } from "./form"; +import { runAction } from "./run-action"; +import type { StepView, Wizard } from "./wizard"; + +export function createImportStep(wizard: Wizard): StepView { + const { plugin } = wizard; + let link = ""; + let passphrase = ""; + const state = { busy: false, error: "" }; + + const submit = async (): Promise => { + if (!link || !passphrase) return; + const imported = await runAction(wizard, state, () => + plugin.transfer.importWith(link, passphrase), + ); + if (imported && wizard.isShowing(view)) wizard.next(); + }; + + const view: StepView = { + render(el) { + el.createEl("h3", { text: "Setup link from your other device" }); + const intro = el.createEl("p", { + text: "On the device that already syncs, open ", + }); + intro.createEl("strong", { + text: "Settings → MDSync → Connection → Export setup", + }); + intro.appendText( + ". Scan its QR code with this device's camera, or copy its link here.", + ); + if (canSync(plugin.settings)) { + callout( + el, + "warning", + "Importing replaces this device's storage and relay settings.", + ); + } + const linkRow = new Setting(el).setName("Setup link").addTextArea((t) => { + t.inputEl.rows = 3; + t.inputEl.setAttr("aria-label", "Setup link"); + focusKey(t.inputEl, "link"); + t.setPlaceholder("obsidian://mdsync?d=…") + .setValue(link) + .onChange((v) => { + link = v.trim(); + }); + onEnter(t.inputEl, () => void submit()); + }); + linkRow.settingEl.addClass("mdsync-setup-stacked"); + new Setting(el) + .setName("Passphrase") + .setDesc("The vault's passphrase, the same as on the other device.") + .addText((t) => { + t.inputEl.type = "password"; + t.inputEl.setAttr("aria-label", "Passphrase"); + focusKey(t.inputEl, "passphrase"); + t.setValue(passphrase).onChange((v) => { + passphrase = v; + }); + onEnter(t.inputEl, () => void submit()); + }); + if (state.error) alertLine(el).setText(state.error); + actionRow(el).addButton((b) => + b + .setButtonText(state.busy ? "Importing…" : "Import") + .setCta() + .setDisabled(state.busy) + .onClick(() => void submit()), + ); + }, + }; + return view; +} diff --git a/packages/plugin/src/settings/setup/index.ts b/packages/plugin/src/settings/setup/index.ts new file mode 100644 index 00000000..202eaa63 --- /dev/null +++ b/packages/plugin/src/settings/setup/index.ts @@ -0,0 +1,2 @@ +export { ESetupStep } from "./steps"; +export { closeSetupWizard, openSetupWizard } from "./wizard"; diff --git a/packages/plugin/src/settings/setup/passphrase-meter.ts b/packages/plugin/src/settings/setup/passphrase-meter.ts new file mode 100644 index 00000000..1fb61f24 --- /dev/null +++ b/packages/plugin/src/settings/setup/passphrase-meter.ts @@ -0,0 +1,62 @@ +import { MIN_PASSPHRASE_LENGTH } from "@/crypto/constants"; + +export const METER_BARS = 4; + +export interface PassphraseStrength { + /** Bars to light, 0 to METER_BARS. */ + filled: number; + label: string; +} + +/** Length only: it is what the minimum rests on, and a score from character classes would flatter short ones. */ +export function passphraseStrength(passphrase: string): PassphraseStrength { + const length = [...passphrase].length; + if (length === 0) { + return { filled: 0, label: `At least ${MIN_PASSPHRASE_LENGTH} characters` }; + } + if (length < MIN_PASSPHRASE_LENGTH) { + return { + filled: 1, + label: `${length} of ${MIN_PASSPHRASE_LENGTH} characters`, + }; + } + if (length < MIN_PASSPHRASE_LENGTH + 4) return { filled: 2, label: "Okay" }; + if (length < MIN_PASSPHRASE_LENGTH + 10) return { filled: 3, label: "Good" }; + return { filled: METER_BARS, label: "Strong" }; +} + +/** Returns what to call with each new value: the meter changes in place, so typing is never interrupted. */ +export function passphraseMeter( + parent: HTMLElement, + initial: string, +): (value: string) => void { + const meter = parent.createDiv({ + cls: "mdsync-meter", + attr: { + role: "meter", + "aria-label": "Passphrase strength", + "aria-valuemin": "0", + "aria-valuemax": String(METER_BARS), + }, + }); + const bars = meter.createDiv({ cls: "mdsync-meter-bars" }); + const cells = Array.from({ length: METER_BARS }, () => + bars.createSpan({ cls: "mdsync-meter-bar" }), + ); + const label = meter.createSpan({ + cls: "mdsync-meter-label", + attr: { "aria-live": "polite" }, + }); + const update = (value: string): void => { + const { filled, label: text } = passphraseStrength(value); + meter.dataset.filled = String(filled); + meter.setAttr("aria-valuenow", String(filled)); + meter.setAttr("aria-valuetext", text); + cells.forEach((cell, at) => { + cell.toggleClass("is-on", at < filled); + }); + label.setText(text); + }; + update(initial); + return update; +} diff --git a/packages/plugin/src/settings/setup/passphrase-step.ts b/packages/plugin/src/settings/setup/passphrase-step.ts new file mode 100644 index 00000000..ad386516 --- /dev/null +++ b/packages/plugin/src/settings/setup/passphrase-step.ts @@ -0,0 +1,150 @@ +import { Setting } from "obsidian"; + +import { weakPassphrase } from "@/crypto"; +import { errorMessage } from "@/shared"; +import { PassphraseRotatedError } from "@/sync/keyfile"; +import { alertLine, focusKey, onEnter } from "@/ui/common"; + +import { actionRow, busyLine, callout } from "./form"; +import { passphraseMeter } from "./passphrase-meter"; +import { runAction } from "./run-action"; +import type { StepView, Wizard } from "./wizard"; + +type Phase = "checking" | "new" | "existing" | "unlocked" | "unreachable"; + +const WRONG = "This passphrase does not open the vault."; + +export function createPassphraseStep(wizard: Wizard): StepView { + const { passphrase } = wizard.plugin; + let phase: Phase = "checking"; + let value = ""; + let confirm = ""; + const state = { busy: false, error: "" }; + + /** A passphrase entered or saved earlier is tried first, so a set-up device just moves on. */ + const check = async (): Promise => { + phase = "checking"; + state.error = ""; + wizard.redraw(); + try { + if (await passphrase.unlock()) phase = "unlocked"; + else phase = (await passphrase.vaultHasKey()) ? "existing" : "new"; + } catch (err) { + if (err instanceof PassphraseRotatedError) { + phase = "existing"; + state.error = "The passphrase saved here no longer opens the vault."; + } else { + phase = "unreachable"; + state.error = errorMessage(err); + } + } + wizard.redraw(); + }; + + const submit = async (): Promise => { + if (!value) return; + // Only a new one is held to today's rules: an older vault may have a shorter passphrase. + if (phase === "new") { + state.error = + weakPassphrase(value) ?? + (value !== confirm ? "Passphrases do not match." : ""); + if (state.error) { + wizard.redraw(); + return; + } + } + const unlocked = await runAction( + wizard, + state, + () => passphrase.unlock(value), + (err) => + err instanceof PassphraseRotatedError ? WRONG : errorMessage(err), + ); + if (unlocked && wizard.isShowing(view)) wizard.next(); + }; + + const field = ( + el: HTMLElement, + name: string, + current: string, + set: (v: string) => void, + ) => + new Setting(el).setName(name).addText((t) => { + t.inputEl.type = "password"; + t.inputEl.setAttr("aria-label", name); + focusKey(t.inputEl, name); + t.setValue(current).onChange(set); + onEnter(t.inputEl, () => void submit()); + }); + + void check(); + const view: StepView = { + render(el) { + el.createEl("h3", { text: "Encryption passphrase" }); + if (phase === "checking") { + busyLine(el, "Checking the storage…"); + return; + } + if (phase === "unlocked") { + callout( + el, + "success", + "This device has the vault's passphrase. Change it under Settings → MDSync → Connection → Encryption.", + ); + return; + } + if (phase === "unreachable") { + alertLine(el).setText(`Could not reach the storage: ${state.error}`); + actionRow(el).addButton((b) => + b.setButtonText("Try again").onClick(() => void check()), + ); + return; + } + if (phase === "new") { + el.createEl("p", { + text: "It encrypts every note before it leaves this device. Your notes here stay as they are.", + }); + callout( + el, + "warning", + "Nobody can reset it, MDSync included: lose it and the synced copy cannot be read.", + ); + let meter: ((v: string) => void) | undefined; + const first = field(el, "Passphrase", value, (v) => { + value = v; + meter?.(v); + }); + meter = passphraseMeter(first.descEl, value); + field(el, "Confirm passphrase", confirm, (v) => { + confirm = v; + }); + } else { + el.createEl("p", { + text: "This storage already holds a vault. Enter the passphrase you set on your other device.", + }); + el.createEl("p", { + cls: "setting-item-description", + text: "It is the one chosen on the first device. Encryption is end to end, so nobody can recover it.", + }); + field(el, "Passphrase", value, (v) => { + value = v; + }); + } + if (state.error) alertLine(el).setText(state.error); + actionRow(el).addButton((b) => + b + .setButtonText( + state.busy + ? "Checking…" + : phase === "new" + ? "Set passphrase" + : "Unlock", + ) + .setCta() + .setDisabled(state.busy) + .onClick(() => void submit()), + ); + }, + }; + return view; +} diff --git a/packages/plugin/src/settings/setup/r2.ts b/packages/plugin/src/settings/setup/r2.ts new file mode 100644 index 00000000..794b7135 --- /dev/null +++ b/packages/plugin/src/settings/setup/r2.ts @@ -0,0 +1,36 @@ +import { ensureBucket, r2Storage, verifyToken } from "@/cloudflare"; +import { STORAGE_CHANGED_REASON } from "@/constants"; +import { sha256Hex } from "@/crypto"; +import type { PluginHost } from "@/plugin/host"; +import { cloudflareOf } from "@/settings/cloudflare-login"; +import { defaultS3Config, EStorageBackend } from "@/storage"; + +const BUCKET = "mdsync-vaults"; + +/** Makes the bucket (or finds it) and points the vault's S3 storage at it, under this vault's prefix. */ +export async function useR2(plugin: PluginHost): Promise { + const login = cloudflareOf(plugin.settings); + if (!login) throw new Error("Connect your Cloudflare account first."); + const { api, accountId } = login; + await ensureBucket(api, accountId, BUCKET); + const tokenId = await verifyToken(api, accountId); + const r2 = await r2Storage(accountId, BUCKET, tokenId, api.token); + plugin.settings.storageConfigs[EStorageBackend.S3] = { + ...defaultS3Config(), + ...r2, + prefix: await vaultPrefix(plugin.app.vault.getName()), + }; + plugin.settings.activeStorageKind = EStorageBackend.S3; + await plugin.saveSettings(); + plugin.scheduleScopeRefresh(STORAGE_CHANGED_REASON); +} + +/** Two vaults of one account must not share a remote: names the slug folds together keep their own hash. */ +export async function vaultPrefix(name: string): Promise { + const slug = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + const hash = await sha256Hex(new TextEncoder().encode(name)); + return `${slug || "vault"}-${hash.slice(0, 8)}`; +} diff --git a/packages/plugin/src/settings/setup/ready-step.ts b/packages/plugin/src/settings/setup/ready-step.ts new file mode 100644 index 00000000..44bd6542 --- /dev/null +++ b/packages/plugin/src/settings/setup/ready-step.ts @@ -0,0 +1,181 @@ +import { Setting, setIcon } from "obsidian"; + +import { SOURCE_CONTROL_VIEW_TYPE } from "@/constants"; +import { renderFields } from "@/settings/fields"; +import { + activeStorage, + canSync, + isRelayConfigured, + isStorageConfigured, +} from "@/settings/model"; +import { SETUP_FIELDS } from "@/settings/sections/automation"; +import { describeStorageTarget } from "@/storage"; +import type { SyncStatusSnapshot } from "@/sync/controller"; +import { openSourceControlView, showSettingsTransferExport } from "@/ui"; +import { alertLine } from "@/ui/common"; + +import type { StepView, Wizard } from "./wizard"; + +function summaryRow( + list: HTMLElement, + icon: string, + label: string, + value: string, + on: boolean, +): void { + const row = list.createEl("li", { cls: "mdsync-summary-row" }); + row.toggleClass("is-off", !on); + setIcon(row.createSpan({ cls: "mdsync-summary-icon" }), icon); + const text = row.createDiv({ cls: "mdsync-summary-text" }); + text.createDiv({ cls: "mdsync-summary-label", text: label }); + text.createDiv({ cls: "mdsync-summary-value", text: value }); + setIcon( + row.createSpan({ cls: "mdsync-summary-state" }), + on ? "check" : "minus", + ); +} + +export function createReadyStep(wizard: Wizard): StepView { + const { plugin } = wizard; + const { controller } = plugin; + let syncing = false; + let synced: SyncStatusSnapshot | null = null; + + /** The scheduled sync's own cycle: pulls, pushes, and leaves files changed on both sides for Changes. */ + const syncNow = async (): Promise => { + syncing = true; + wizard.redraw(); + const unsubscribe = controller.subscribe(() => wizard.redraw()); + try { + await controller.refreshAndAutoSync(true); + } finally { + unsubscribe(); + syncing = false; + synced = controller.getSnapshot(); + wizard.redraw(); + } + }; + + const renderSync = (el: HTMLElement): void => { + const ready = canSync(plugin.settings); + const row = new Setting(el) + .setName("First sync") + .setDesc( + "Compares this vault with the storage, then pulls and pushes what changed.", + ) + .addButton((b) => + b + .setButtonText(syncing ? "Syncing…" : "Sync now") + .setCta() + .setDisabled(syncing || !ready) + .onClick(() => void syncNow()), + ); + if (!ready) { + row.setDesc("Set up a storage first."); + return; + } + if (syncing) { + row.setDesc(controller.getSnapshot().progressText ?? "Syncing…"); + return; + } + if (!synced) return; + const { error, spaceErrors, conflicts, pendingLocal, pendingRemote } = + synced; + const left = conflicts + pendingLocal + pendingRemote; + if (error) { + alertLine(el).setText(error); + return; + } + if (spaceErrors.length > 0) { + for (const { root, message } of spaceErrors) { + alertLine(el).setText(`Error in "${root}": ${message}`); + } + return; + } + if (left === 0) { + row.setDesc("In sync."); + return; + } + row.setDesc( + conflicts > 0 + ? `${conflicts} file(s) changed both here and in the storage. Choose a side for each in Changes.` + : `${left} change(s) were not synced. Review them in Changes.`, + ); + row.addButton((b) => + b.setButtonText("Open changes").onClick(() => { + wizard.close(); + void openSourceControlView(plugin.app, SOURCE_CONTROL_VIEW_TYPE); + }), + ); + }; + + const renderSummary = (el: HTMLElement): void => { + const { settings } = plugin; + const list = el.createEl("ul", { cls: "mdsync-summary" }); + const storage = isStorageConfigured(settings); + summaryRow( + list, + "hard-drive", + "Storage", + storage ? describeStorageTarget(activeStorage(settings)) : "Not set up", + storage, + ); + summaryRow( + list, + "key-round", + "Encryption", + !plugin.passphrase.has() + ? "No passphrase yet" + : settings.cachePassphrase + ? "Passphrase saved on this device" + : "Passphrase unlocked for this session", + plugin.passphrase.has(), + ); + const relay = isRelayConfigured(settings); + summaryRow( + list, + "radio-tower", + "Relay", + relay ? settings.relayUrl : "Not set up (optional)", + relay, + ); + }; + + const view: StepView = { + render(el) { + const ready = canSync(plugin.settings); + const hero = el.createDiv({ cls: "mdsync-hero" }); + const icon = hero.createDiv({ cls: "mdsync-hero-icon" }); + icon.toggleClass("is-success", ready); + setIcon(icon, ready ? "party-popper" : "sparkles"); + const text = hero.createDiv({ cls: "mdsync-hero-text" }); + text.createEl("h3", { text: ready ? "You're all set" : "Almost there" }); + text.createEl("p", { + text: ready + ? "Your vault syncs through storage you own, encrypted on this device." + : "Set up a storage to start syncing.", + }); + renderSummary(el); + renderSync(el); + renderFields( + el, + { plugin, rerender: () => wizard.redraw() }, + SETUP_FIELDS, + ); + new Setting(el) + .setName("Set up another device") + .setDesc( + "Shows a link and QR code that bring this setup to your phone or another computer.", + ) + .addButton((b) => + b.setButtonText("Show setup link").onClick(() => + showSettingsTransferExport(plugin.app, { + createPackage: (options) => + plugin.transfer.createPackage(options), + }), + ), + ); + }, + }; + return view; +} diff --git a/packages/plugin/src/settings/setup/relay-deploy-view.ts b/packages/plugin/src/settings/setup/relay-deploy-view.ts new file mode 100644 index 00000000..9deac0d9 --- /dev/null +++ b/packages/plugin/src/settings/setup/relay-deploy-view.ts @@ -0,0 +1,340 @@ +import { Setting, setIcon } from "obsidian"; + +import { + type AccountRelays, + accountRelays, + ERelayDeployStep, + freeRelayName, + RELAY_NAME, + type RelayTarget, + relayNameError, + relayUrlOf, +} from "@/cloudflare"; +import { randomSecret } from "@/crypto"; +import { + cloudflareErrorMessage, + cloudflareOf, + forgetCloudflare, +} from "@/settings/cloudflare-login"; +import { testRelay } from "@/settings/connection-test"; +import { isRelayConfigured } from "@/settings/model"; +import { alertLine, focusKey, serial } from "@/ui/common"; + +import { renderTokenForm, tokenForm } from "./cloudflare-token"; +import { actionRow, busyLine, callout } from "./form"; +import { + activateRelay, + deployOwnRelay, + failedRelay, + planRelay, + runningRelayDeploy, +} from "./relay"; +import type { Wizard } from "./wizard"; + +type Phase = + | "token" + | "attaching" + | "checking" + | "taken" + | "deploying" + | "waiting" + | "silent" + | "failed" + | "done"; + +const STEP_LABELS: Record = { + [ERelayDeployStep.Subdomain]: "Workers address", + [ERelayDeployStep.Storage]: "Relay storage", + [ERelayDeployStep.Files]: "Share page files", + [ERelayDeployStep.Worker]: "Relay code", + [ERelayDeployStep.Route]: "Public address", +}; +const DEPLOY_STEPS = Object.values(ERelayDeployStep); + +export interface RelayDeployView { + start(): void; + render(el: HTMLElement): void; + /** A deploy is running or its relay has not answered yet. */ + busy(): boolean; +} + +/** `online` runs once the relay answers with this build. */ +export function relayDeployView( + wizard: Wizard, + online: () => void, +): RelayDeployView { + const { plugin } = wizard; + const token = tokenForm(); + let phase: Phase = "checking"; + let error = ""; + let relays: AccountRelays | null = null; + /** The worker to update with a secret typed here; null when this device's relay is not on the account. */ + let existing: string | null = RELAY_NAME; + let secret = ""; + let newName = ""; + let reached = 0; + /** The deploy that went last: "Try again" repeats it with the secret it chose. */ + let attempted: RelayTarget | null = null; + const fail = (err: unknown): void => { + phase = "failed"; + error = cloudflareErrorMessage(err); + wizard.redraw(); + }; + + /** The deploy goes on after Back or a closed wizard; a second one must not start beside it. */ + const run = serial((task: () => Promise) => task().catch(fail)); + + const plan = async (retry = false): Promise => { + const login = cloudflareOf(plugin.settings); + if (!login) { + phase = "token"; + wizard.redraw(); + return; + } + const running = runningRelayDeploy(); + if (running) return attach(running, login.accountId); + phase = "checking"; + reached = 0; + error = ""; + wizard.redraw(); + relays = await accountRelays(login.api, login.accountId); + newName = freeRelayName(relays.taken); + const next = await planRelay( + relays, + plugin.settings, + (retry ? attempted : null) ?? + // Only while no relay is set: its secret exists nowhere else. Otherwise a stale one could replace a working relay. + (isRelayConfigured(plugin.settings) + ? null + : failedRelay(login.accountId)), + ); + if ("deploy" in next) return deploy(next.deploy); + existing = next.ask; + phase = "taken"; + wizard.redraw(); + }; + + const deploy = async (target: RelayTarget): Promise => { + const login = cloudflareOf(plugin.settings); + if (!login) return plan(); + const running = runningRelayDeploy(); + if (running) return attach(running, login.accountId); + attempted = target; + phase = "deploying"; + reached = 0; + error = ""; + wizard.redraw(); + await deployOwnRelay(plugin, login, target, (step) => { + reached = DEPLOY_STEPS.indexOf(step); + wizard.redraw(); + }); + reached = DEPLOY_STEPS.length; + await wait(); + }; + + /** A wizard closed mid-deploy left it running: this one follows it instead of starting another. */ + const attach = async ( + running: Promise, + accountId: string, + ): Promise => { + phase = "attaching"; + wizard.redraw(); + try { + await running; + } catch (err) { + attempted = failedRelay(accountId); + throw err; + } + reached = DEPLOY_STEPS.length; + await wait(); + }; + + const wait = async (): Promise => { + phase = "waiting"; + wizard.redraw(); + if (!(await activateRelay(plugin))) { + phase = "silent"; + wizard.redraw(); + return; + } + phase = "done"; + reached = DEPLOY_STEPS.length + 1; + if (!wizard.isLive()) return; + wizard.redraw(); + online(); + }; + + const updateExisting = async (): Promise => { + // What was checked is what goes out, even if the field changes meanwhile. + const target = { name: existing, secret }; + const relayUrl = + target.name && relays ? relayUrlOf(relays, target.name) : null; + if (!target.name || !relayUrl || !target.secret) return; + const checked = await testRelay({ relayUrl, relaySecret: target.secret }); + if (!checked.ok) { + error = checked.message; + wizard.redraw(); + return; + } + await deploy({ name: target.name, secret: target.secret }); + }; + + const anotherToken = (): void => + void run(async () => { + await forgetCloudflare(plugin); + attempted = null; + await plan(); + }); + + const deployNew = async (): Promise => { + error = relayNameError(newName, relays?.taken ?? []) ?? ""; + if (error) { + wizard.redraw(); + return; + } + await deploy({ name: newName, secret: randomSecret() }); + }; + + const renderExisting = (el: HTMLElement): void => { + if (!existing) { + callout( + el, + "warning", + `This device's relay, ${plugin.settings.relayUrl}, is not on this Cloudflare account.`, + ); + new Setting(el) + .setName("Update it") + .setDesc("Connect the Cloudflare account that runs it.") + .addButton((b) => + b.setButtonText("Use another token").onClick(anotherToken), + ); + } else { + renderUpdate(el, existing); + } + new Setting(el) + .setName("Deploy a new relay") + .setDesc( + "Starts empty. Your other devices need its address and secret: send them a new setup link afterwards.", + ) + .addText((t) => { + t.inputEl.setAttr("aria-label", "Relay name"); + focusKey(t.inputEl, "name"); + t.setValue(newName).onChange((v) => { + newName = v.trim(); + }); + }) + .addButton((b) => + b.setButtonText("Deploy new").onClick(() => void run(deployNew)), + ); + if (error) alertLine(el).setText(error); + }; + + const renderUpdate = (el: HTMLElement, name: string): void => { + callout( + el, + "info", + `A relay named ${name} is already on this Cloudflare account, perhaps from another device or the GitHub action.`, + ); + new Setting(el) + .setName("Update it") + .setDesc( + "Enter its secret, from Settings → MDSync → Connection on a device that uses it. Its shared folders and links keep working.", + ) + .addText((t) => { + t.inputEl.type = "password"; + t.inputEl.setAttr("aria-label", "Relay secret"); + focusKey(t.inputEl, "secret"); + t.setValue(secret).onChange((v) => { + secret = v.trim(); + }); + }) + .addButton((b) => + b.setButtonText("Update").onClick(() => void run(updateExisting)), + ); + }; + + const renderProgress = (el: HTMLElement): void => { + const list = el.createEl("ol", { cls: "mdsync-progress" }); + const labels = [...DEPLOY_STEPS.map((s) => STEP_LABELS[s]), "Answering"]; + labels.forEach((label, at) => { + const item = list.createEl("li", { cls: "mdsync-progress-step" }); + const dot = item.createSpan({ cls: "mdsync-progress-dot" }); + item.createSpan({ text: label }); + if (at < reached) { + item.addClass("is-done"); + setIcon(dot, "check"); + } else if (at === reached) { + const failed = phase === "failed"; + item.addClass(failed ? "is-failed" : "is-running"); + if (failed) setIcon(dot, "x"); + } + }); + if (phase === "waiting") { + callout( + el, + "info", + "Waiting for the relay to answer. A new workers.dev address can take a few minutes.", + ); + } + if (phase === "silent") { + callout( + el, + "warning", + "The relay is deployed but does not answer yet. Check again in a minute.", + ); + actionRow(el).addButton((b) => + b + .setButtonText("Check again") + .setCta() + .onClick(() => void run(wait)), + ); + } + }; + + return { + start: () => void run(plan), + busy: () => + phase === "attaching" || phase === "deploying" || phase === "waiting", + render(el) { + if (phase === "token") { + callout( + el, + "info", + "MDSync deploys the relay with a Cloudflare token. It runs on the free Workers plan; no payment method needed.", + ); + renderTokenForm(el, wizard, token, () => void run(plan)); + return; + } + if (phase === "attaching") { + busyLine(el, "The relay is still deploying from earlier…"); + return; + } + if (phase === "checking") { + busyLine(el, "Looking at your Cloudflare account…"); + return; + } + if (phase === "taken") { + renderExisting(el); + return; + } + if (phase === "failed" && reached === 0) { + alertLine(el).setText(error); + } else { + renderProgress(el); + if (phase === "failed") alertLine(el).setText(error); + } + if (phase === "failed") { + actionRow(el) + .addButton((b) => + b.setButtonText("Use another token").onClick(anotherToken), + ) + .addButton((b) => + b + .setButtonText("Try again") + .setCta() + .onClick(() => void run(() => plan(true))), + ); + } + }, + }; +} diff --git a/packages/plugin/src/settings/setup/relay-step.ts b/packages/plugin/src/settings/setup/relay-step.ts new file mode 100644 index 00000000..68b8c6be --- /dev/null +++ b/packages/plugin/src/settings/setup/relay-step.ts @@ -0,0 +1,169 @@ +import { Setting } from "obsidian"; + +import { RELAY_VERSION } from "@/cloudflare"; +import { type RelayTestResult, testRelay } from "@/settings/connection-test"; +import { isRelayConfigured } from "@/settings/model"; +import { relayBase } from "@/shared"; +import { alertLine, focusKey, onEnter } from "@/ui/common"; + +import { choiceCard } from "./choice-card"; +import { actionRow, busyLine, callout } from "./form"; +import { useRelay } from "./relay"; +import { relayDeployView } from "./relay-deploy-view"; +import { runAction } from "./run-action"; +import type { StepView, Wizard } from "./wizard"; + +type Mode = "choose" | "deploy" | "manual"; + +const WITHOUT_RELAY = + "Optional. Without a relay your devices sync on the schedule or when you push and pull, and there is no live editing, no invites to shared folders and no share links. You can add it later under Settings → MDSync → Connection."; + +export function createRelayStep(wizard: Wizard): StepView { + const { plugin } = wizard; + let mode: Mode = "choose"; + let status: RelayTestResult | null = null; + const manual = { + url: plugin.settings.relayUrl, + secret: "", + error: "", + busy: false, + }; + const moveOn = (): void => { + if (wizard.isShowing(view) && mode !== "choose") wizard.next(); + }; + const deploy = relayDeployView(wizard, moveOn); + + const checkStatus = async (): Promise => { + status = await testRelay(plugin.settings); + wizard.redraw(); + }; + + const connect = async (): Promise => { + const relay = { + relayUrl: relayBase(manual.url), + relaySecret: manual.secret, + }; + const connected = await runAction(wizard, manual, async () => { + const result = await testRelay(relay); + if (!result.ok) throw new Error(result.message); + // A wizard closed meanwhile may be reopened with another relay typed in. + if (mode !== "manual" || !wizard.isShowing(view)) return; + Object.assign(plugin.settings, relay); + await useRelay(plugin); + }); + if (connected && mode === "manual") moveOn(); + }; + + const startDeploy = (): void => { + mode = "deploy"; + deploy.start(); + }; + + const renderStatus = (el: HTMLElement): void => { + if (!status) { + busyLine(el, `Checking ${plugin.settings.relayUrl}…`); + return; + } + if (!status.ok) { + callout( + el, + "warning", + `Relay: ${plugin.settings.relayUrl}. ${status.message}`, + ); + } else if (status.version === RELAY_VERSION) { + callout(el, "success", "Relay connected and up to date."); + return; + } else { + callout( + el, + "info", + status.version + ? "Relay connected. This version of MDSync has a newer relay." + : "Relay connected. It was deployed outside MDSync, so its version is unknown.", + ); + } + new Setting(el) + .setName("Update relay") + .setDesc( + "Deploys this version's relay to your Cloudflare account. Its secret, shared folders and links stay.", + ) + .addButton((b) => + b.setButtonText("Update").setCta().onClick(startDeploy), + ); + }; + + const renderChoice = (el: HTMLElement): void => { + if (isRelayConfigured(plugin.settings)) { + renderStatus(el); + return; + } + el.createEl("p", { + text: "A relay is a small server in your own Cloudflare account. It tells your devices the moment something changes, and carries live editing, shared folders and share links.", + }); + callout(el, "info", WITHOUT_RELAY); + const choices = el.createDiv({ cls: "mdsync-choices" }); + choiceCard(choices, { + icon: "rocket", + title: "Deploy to my Cloudflare", + badge: "Recommended", + desc: "MDSync uploads the relay and can update it later. Takes about a minute. If your account has no workers.dev address yet, MDSync registers a random one for it.", + primary: true, + onClick: startDeploy, + }); + choiceCard(choices, { + icon: "plug", + title: "I already have a relay", + desc: "Enter the address and secret of a relay you deployed yourself.", + onClick: () => { + mode = "manual"; + wizard.redraw(); + }, + }); + }; + + const renderManual = (el: HTMLElement): void => { + new Setting(el).setName("Relay URL").addText((t) => { + focusKey(t.inputEl, "url"); + onEnter(t.inputEl, connect); + t.setPlaceholder("https://mdsync-relay..workers.dev") + .setValue(manual.url) + .onChange((v) => { + manual.url = v.trim(); + }); + }); + new Setting(el).setName("Relay secret").addText((t) => { + t.inputEl.type = "password"; + focusKey(t.inputEl, "secret"); + onEnter(t.inputEl, connect); + t.setValue(manual.secret).onChange((v) => { + manual.secret = v.trim(); + }); + }); + if (manual.error) alertLine(el).setText(manual.error); + actionRow(el).addButton((b) => + b + .setButtonText(manual.busy ? "Connecting…" : "Connect") + .setCta() + .setDisabled(manual.busy) + .onClick(() => void connect()), + ); + }; + + if (isRelayConfigured(plugin.settings)) void checkStatus(); + const view: StepView = { + render(el) { + el.createEl("h3", { text: "Relay" }); + if (mode === "deploy") deploy.render(el); + else if (mode === "manual") renderManual(el); + else renderChoice(el); + }, + back() { + if (mode === "choose") return false; + mode = "choose"; + manual.error = ""; + return true; + }, + blocksNext: () => deploy.busy(), + }; + return view; +} diff --git a/packages/plugin/src/settings/setup/relay.ts b/packages/plugin/src/settings/setup/relay.ts new file mode 100644 index 00000000..2da749bf --- /dev/null +++ b/packages/plugin/src/settings/setup/relay.ts @@ -0,0 +1,154 @@ +import bundle from "mdsync:relay-bundle"; +import { toHex } from "@mdsync/protocol"; + +import { + type AccountRelays, + deployRelay, + type ERelayDeployStep, + ownRelayName, + RELAY_NAME, + RELAY_VERSION, + type RelayTarget, +} from "@/cloudflare"; +import { randomBytes, randomSecret } from "@/crypto"; +import type { PluginHost } from "@/plugin/host"; +import type { CloudflareLogin } from "@/settings/cloudflare-login"; +import { testRelay } from "@/settings/connection-test"; +import { isRelayConfigured, type RelayConfig } from "@/settings/model"; + +const POLL_MS = 3000; +/** A workers.dev name registered just now can take minutes to answer. */ +const WAIT_MS = 3 * 60_000; + +/** Deploy now, or ask the user: `ask` is the worker to update with a typed secret, null when this device's relay is not on the account. */ +export type RelayPlan = { deploy: RelayTarget } | { ask: string | null }; + +/** + * Updates this device's relay when it can; replacing a relay, or one whose secret is unknown, is asked. + * `failed` is the deploy that just broke off: its secret is this device's own, so it goes again unasked. + * A silent relay alone proves nothing: another device may have changed its secret. + */ +export async function planRelay( + relays: AccountRelays, + settings: RelayConfig, + failed: RelayTarget | null = null, +): Promise { + if (failed) return { deploy: failed }; + const own = ownRelayName(relays, settings.relayUrl); + const configured = isRelayConfigured(settings); + if (own && configured && (await testRelay(settings)).ok) { + return { deploy: { name: own, secret: settings.relaySecret } }; + } + if (!own && !configured && !relays.taken.includes(RELAY_NAME)) { + return { deploy: { name: RELAY_NAME, secret: randomSecret() } }; + } + return { ask: own ?? (configured ? null : RELAY_NAME) }; +} + +let deploying: Promise | null = null; +/** Cloudflare may hold the secret of a deploy whose answer was lost: a reopened wizard must retry with it. */ +let failed: { accountId: string; target: RelayTarget } | null = null; + +/** The deploy still running from a wizard that may be closed by now. */ +export function runningRelayDeploy(): Promise | null { + return deploying; +} + +/** The last deploy on this account that broke off, for planRelay's `failed`. */ +export function failedRelay(accountId: string): RelayTarget | null { + return failed?.accountId === accountId ? failed.target : null; +} + +/** + * The URL and secret are saved the moment the worker holds the secret, so closing the wizard mid-deploy + * never loses the secret of a live relay. + */ +export function deployOwnRelay( + plugin: PluginHost, + login: CloudflareLogin, + target: RelayTarget, + onStep: (step: ERelayDeployStep) => void, +): Promise { + // Outlives the wizard that started it, so a reopened one could start a second beside it. + if (deploying) + return Promise.reject( + new Error("The relay is still deploying. Wait a moment."), + ); + const run = (async () => { + try { + await deployTo(plugin, login, target, onStep); + failed = null; + } catch (err) { + failed = { accountId: login.accountId, target }; + throw err; + } finally { + deploying = null; + } + })(); + deploying = run; + return run; +} + +async function deployTo( + plugin: PluginHost, + login: CloudflareLogin, + target: RelayTarget, + onStep: (step: ERelayDeployStep) => void, +): Promise { + await deployRelay({ + api: login.api, + accountId: login.accountId, + bundle: { ...bundle, name: target.name }, + secret: target.secret, + proposeSubdomain: () => `mdsync-${toHex(randomBytes(3))}`, + onStep, + onUploaded: async (url) => { + Object.assign(plugin.settings, { + relayUrl: url, + relaySecret: target.secret, + }); + await plugin.saveSettings(); + }, + }); +} + +/** True once the relay answers with this plugin's build; false when it is still silent after a few minutes, or `keepWaiting` says stop. */ +export async function waitForRelay( + relay: RelayConfig, + keepWaiting: () => boolean, +): Promise { + const deadline = Date.now() + WAIT_MS; + while (keepWaiting()) { + const status = await testRelay(relay); + if (status.ok && status.version === RELAY_VERSION) return true; + if (Date.now() >= deadline) return false; + await new Promise((resolve) => window.setTimeout(resolve, POLL_MS)); + } + return false; +} + +let activating: Promise | null = null; + +/** One wait however many wizards ask: the relay is switched on once, when it answers. Survives a closed wizard, stops on unload. */ +export function activateRelay(plugin: PluginHost): Promise { + activating ??= (async () => { + try { + const answered = await waitForRelay( + plugin.settings, + () => !plugin.unloaded, + ); + if (answered) await useRelay(plugin); + return answered; + } finally { + activating = null; + } + })(); + return activating; +} + +/** Real-time sync is what a relay is for: on once it answers. */ +export async function useRelay(plugin: PluginHost): Promise { + plugin.settings.realtimeSync = true; + await plugin.saveSettings(); + plugin.realtime.hub.restart(); +} diff --git a/packages/plugin/src/settings/setup/run-action.ts b/packages/plugin/src/settings/setup/run-action.ts new file mode 100644 index 00000000..23cd93f1 --- /dev/null +++ b/packages/plugin/src/settings/setup/run-action.ts @@ -0,0 +1,30 @@ +import { errorMessage } from "@/shared"; + +import type { Wizard } from "./wizard"; + +export interface ActionState { + busy: boolean; + error: string; +} + +export async function runAction( + wizard: Pick, + state: ActionState, + task: () => Promise, + describe: (err: unknown) => string = errorMessage, +): Promise { + if (state.busy) return false; + state.busy = true; + state.error = ""; + wizard.redraw(); + try { + await task(); + return true; + } catch (err) { + state.error = describe(err); + return false; + } finally { + state.busy = false; + wizard.redraw(); + } +} diff --git a/packages/plugin/src/settings/setup/start-step.ts b/packages/plugin/src/settings/setup/start-step.ts new file mode 100644 index 00000000..c7f8fb31 --- /dev/null +++ b/packages/plugin/src/settings/setup/start-step.ts @@ -0,0 +1,41 @@ +import { setIcon } from "obsidian"; + +import { openInvite } from "@/ui"; + +import { choiceCard } from "./choice-card"; +import { ESetupPath } from "./steps"; +import type { StepView, Wizard } from "./wizard"; + +export function createStartStep(wizard: Wizard): StepView { + return { + render(el) { + const hero = el.createDiv({ cls: "mdsync-hero" }); + setIcon(hero.createDiv({ cls: "mdsync-hero-icon" }), "refresh-cw"); + const text = hero.createDiv({ cls: "mdsync-hero-text" }); + text.createEl("h3", { text: "Sync your vault, privately" }); + text.createEl("p", { + text: "MDSync keeps this vault in sync through storage you own. Everything is encrypted on this device with a passphrase only you know.", + }); + const choices = el.createDiv({ cls: "mdsync-choices" }); + choiceCard(choices, { + icon: "sparkles", + title: "Start syncing this vault", + desc: "The first device: choose a storage and set a passphrase.", + primary: true, + onClick: () => wizard.choose(ESetupPath.New), + }); + choiceCard(choices, { + icon: "smartphone", + title: "Connect to a vault set up on another device", + desc: "Use the setup link or QR code from that device.", + onClick: () => wizard.choose(ESetupPath.Join), + }); + choiceCard(choices, { + icon: "folder-input", + title: "Accept an invite to a shared folder", + desc: "Someone shared a folder with you. No storage of your own is needed.", + onClick: () => void openInvite(wizard.plugin, "", () => wizard.close()), + }); + }, + }; +} diff --git a/packages/plugin/src/settings/setup/stepper.ts b/packages/plugin/src/settings/setup/stepper.ts new file mode 100644 index 00000000..3246af16 --- /dev/null +++ b/packages/plugin/src/settings/setup/stepper.ts @@ -0,0 +1,88 @@ +import { setIcon } from "obsidian"; + +export const EStepState = { + Done: "done", + Current: "current", + /** Passed without finishing: an optional step left out. */ + Skipped: "skipped", + Ahead: "ahead", +} as const; +export type EStepState = (typeof EStepState)[keyof typeof EStepState]; + +/** Only a step before the current one can be done or skipped. */ +export function stepStates( + steps: readonly T[], + current: T, + isDone: (step: T) => boolean, +): EStepState[] { + const at = steps.indexOf(current); + return steps.map((step, index) => { + if (index === at) return EStepState.Current; + if (index > at) return EStepState.Ahead; + return isDone(step) ? EStepState.Done : EStepState.Skipped; + }); +} + +export interface StepperItem { + id: string; + label: string; + icon: string; + state: EStepState; +} + +export interface Stepper { + update(items: readonly StepperItem[]): void; +} + +/** Keeps its elements between updates, so a step reached fills in by CSS transition instead of reappearing. */ +export function createStepper(parent: HTMLElement): Stepper { + const list = parent.createEl("ol", { + cls: "mdsync-stepper", + attr: { "aria-label": "Setup steps" }, + }); + let shown = ""; + return { + update(items) { + list.toggleClass("is-hidden", items.length === 0); + const ids = items.map((item) => item.id).join(); + if (ids !== shown) { + shown = ids; + list.empty(); + items.forEach((item, index) => { + buildStep(list, item, index); + }); + } + items.forEach((item, index) => { + const step = list.children[index]; + if (step instanceof HTMLElement) paintStep(step, item); + }); + }, + }; +} + +function buildStep(list: HTMLElement, item: StepperItem, index: number): void { + const step = list.createEl("li", { cls: "mdsync-step" }); + step.setCssProps({ "--mdsync-step-index": String(index) }); + const dot = step.createSpan({ cls: "mdsync-step-dot" }); + dot.setAttr("aria-hidden", "true"); + setIcon(dot.createSpan({ cls: "mdsync-step-icon" }), item.icon); + setIcon(dot.createSpan({ cls: "mdsync-step-check" }), "check"); + step.createSpan({ cls: "mdsync-step-label", text: item.label }); +} + +const STATE_SPOKEN: Partial> = { + [EStepState.Done]: "done", + [EStepState.Skipped]: "skipped", + [EStepState.Ahead]: "upcoming", +}; + +function paintStep(step: HTMLElement, item: StepperItem): void { + step.className = `mdsync-step is-${item.state}`; + if (item.state !== EStepState.Ahead) step.addClass("is-reached"); + if (item.state === EStepState.Current) step.setAttr("aria-current", "step"); + else step.removeAttribute("aria-current"); + // The check mark is hidden from screen readers, so the state is spoken here. + const spoken = STATE_SPOKEN[item.state]; + if (spoken) step.setAttr("aria-label", `${item.label}, ${spoken}`); + else step.removeAttribute("aria-label"); +} diff --git a/packages/plugin/src/settings/setup/steps.ts b/packages/plugin/src/settings/setup/steps.ts new file mode 100644 index 00000000..420cda37 --- /dev/null +++ b/packages/plugin/src/settings/setup/steps.ts @@ -0,0 +1,59 @@ +import type { PluginHost } from "@/plugin/host"; +import { + canSync, + isRelayConfigured, + isStorageConfigured, +} from "@/settings/model"; + +export const ESetupStep = { + Start: "start", + Storage: "storage", + Passphrase: "passphrase", + Import: "import", + Relay: "relay", + Ready: "ready", +} as const; +export type ESetupStep = (typeof ESetupStep)[keyof typeof ESetupStep]; + +export const ESetupPath = { + /** The first device: storage and passphrase made here. */ + New: "new", + /** Another device already syncs: its setup link brings both. */ + Join: "join", +} as const; +export type ESetupPath = (typeof ESetupPath)[keyof typeof ESetupPath]; + +export const PATH_STEPS: Record = { + [ESetupPath.New]: [ + ESetupStep.Storage, + ESetupStep.Passphrase, + ESetupStep.Relay, + ESetupStep.Ready, + ], + [ESetupPath.Join]: [ESetupStep.Import, ESetupStep.Relay, ESetupStep.Ready], +}; + +/** Computed, never stored, so the wizard always agrees with the settings. */ +export function isStepDone(plugin: PluginHost, step: ESetupStep): boolean { + switch (step) { + case ESetupStep.Storage: + return isStorageConfigured(plugin.settings); + case ESetupStep.Passphrase: + return plugin.passphrase.isUnlocked(); + case ESetupStep.Import: + return canSync(plugin.settings) && plugin.passphrase.has(); + case ESetupStep.Relay: + return isRelayConfigured(plugin.settings); + default: + return true; + } +} + +/** Where the wizard opens: the first step this device still lacks. */ +export function firstStep(plugin: PluginHost): ESetupStep { + if (!isStorageConfigured(plugin.settings)) return ESetupStep.Start; + return ( + PATH_STEPS[ESetupPath.New].find((step) => !isStepDone(plugin, step)) ?? + ESetupStep.Ready + ); +} diff --git a/packages/plugin/src/settings/setup/storage-step.ts b/packages/plugin/src/settings/setup/storage-step.ts new file mode 100644 index 00000000..9c51dc23 --- /dev/null +++ b/packages/plugin/src/settings/setup/storage-step.ts @@ -0,0 +1,149 @@ +import { R2NotEnabledError, r2DashboardUrl } from "@/cloudflare"; +import { + cloudflareErrorMessage, + cloudflareOf, +} from "@/settings/cloudflare-login"; +import { testConnection } from "@/settings/connection-test"; +import { activeStorage, isStorageConfigured } from "@/settings/model"; +import { renderBackendSection } from "@/settings/sections/backend"; +import { describeStorageTarget } from "@/storage"; +import { alertLine } from "@/ui/common"; +import { choiceCard } from "./choice-card"; +import { renderTokenForm, tokenForm } from "./cloudflare-token"; +import { actionRow, callout } from "./form"; +import { useR2 } from "./r2"; +import { runAction } from "./run-action"; +import type { StepView, Wizard } from "./wizard"; + +type Mode = "choose" | "r2" | "other"; + +export function createStorageStep(wizard: Wizard): StepView { + const { plugin } = wizard; + const token = tokenForm(); + const state = { busy: false, error: "" }; + let mode: Mode = "choose"; + let r2Off: string | null = null; + + /** Every way in ends here: a storage that answers, or the reason it does not. */ + const finish = async (setUp: () => Promise): Promise => { + r2Off = null; + const answered = await runAction( + wizard, + state, + async () => { + await setUp(); + const tested = await testConnection(plugin); + if (!tested.ok) throw new Error(tested.message); + }, + (err) => { + if (!(err instanceof R2NotEnabledError)) { + return cloudflareErrorMessage(err); + } + r2Off = err.accountId; + return ""; + }, + ); + if (answered && mode !== "choose" && wizard.isShowing(view)) wizard.next(); + }; + const createR2 = () => void finish(() => useR2(plugin)); + + const renderChoice = (el: HTMLElement): void => { + const configured = isStorageConfigured(plugin.settings); + if (configured) { + callout( + el, + "success", + `Using ${describeStorageTarget(activeStorage(plugin.settings))}. Select Next to keep it.`, + ); + } + const choices = el.createDiv({ cls: "mdsync-choices" }); + choiceCard(choices, { + icon: "cloud", + title: "Cloudflare R2", + badge: "Recommended", + desc: `Free up to 10 GB. MDSync makes the bucket and its keys from one Cloudflare token. R2 asks for a payment method once, even on the free plan.${configured ? " Replaces the storage above." : ""}`, + primary: true, + onClick: () => { + mode = "r2"; + wizard.redraw(); + }, + }); + choiceCard(choices, { + icon: "server", + title: "Other storage", + desc: "Any S3-compatible service, WebDAV, or Google Drive (needs an auth server of your own).", + onClick: () => { + mode = "other"; + wizard.redraw(); + }, + }); + }; + + const renderR2 = (el: HTMLElement): void => { + if (!cloudflareOf(plugin.settings)) { + renderTokenForm(el, wizard, token, createR2); + return; + } + callout( + el, + "info", + "Keep this token in Cloudflare: the storage keys come from it, so deleting it stops sync.", + ); + if (r2Off) { + alertLine(el).setText( + "R2 is not turned on for this Cloudflare account yet. Open R2, add a payment method (the free plan costs nothing), then try again.", + ); + } + if (state.error) alertLine(el).setText(state.error); + const row = actionRow(el); + if (r2Off) { + const account = r2Off; + row.addButton((b) => + b + .setButtonText("Open R2") + .onClick(() => window.open(r2DashboardUrl(account))), + ); + } + row.addButton((b) => + b + .setButtonText( + state.busy ? "Creating…" : r2Off ? "Try again" : "Create storage", + ) + .setCta() + .setDisabled(state.busy) + .onClick(createR2), + ); + }; + + const renderOther = (el: HTMLElement): void => { + renderBackendSection(el, plugin, () => wizard.redraw()); + if (state.error) alertLine(el).setText(state.error); + actionRow(el).addButton((b) => + b + .setButtonText(state.busy ? "Checking…" : "Continue") + .setCta() + .setDisabled(state.busy) + .onClick(() => void finish(async () => undefined)), + ); + }; + + const view: StepView = { + render(el) { + el.createEl("h3", { text: "Where your vault is kept" }); + if (mode === "choose") renderChoice(el); + else if (mode === "r2") renderR2(el); + else renderOther(el); + }, + back() { + if (mode === "choose") return false; + // A running connect or setup would apply to the choice left behind. + if (state.busy || token.busy) return true; + mode = "choose"; + state.error = ""; + r2Off = null; + return true; + }, + blocksNext: () => mode !== "choose", + }; + return view; +} diff --git a/packages/plugin/src/settings/setup/wizard.ts b/packages/plugin/src/settings/setup/wizard.ts new file mode 100644 index 00000000..083a99b3 --- /dev/null +++ b/packages/plugin/src/settings/setup/wizard.ts @@ -0,0 +1,250 @@ +import { Modal, Setting } from "obsidian"; + +import type { PluginHost } from "@/plugin/host"; +import { renderKeepingFocus } from "@/ui/common"; + +import { createImportStep } from "./import-step"; +import { createPassphraseStep } from "./passphrase-step"; +import { createReadyStep } from "./ready-step"; +import { createRelayStep } from "./relay-step"; +import { createStartStep } from "./start-step"; +import { + createStepper, + EStepState, + type Stepper, + type StepperItem, + stepStates, +} from "./stepper"; +import { + ESetupPath, + ESetupStep, + firstStep, + isStepDone, + PATH_STEPS, +} from "./steps"; +import { createStorageStep } from "./storage-step"; + +export interface Wizard { + readonly plugin: PluginHost; + /** A late result moves on only from the step that is still on screen. */ + isShowing(view: StepView): boolean; + /** Not `isOpen`: Obsidian's Modal already has that as a boolean field. */ + isLive(): boolean; + redraw(): void; + next(): void; + choose(path: ESetupPath): void; + close(): void; +} + +export interface StepView { + render(el: HTMLElement): void; + /** Goes back inside the step; false when it has nothing to undo. */ + back?(): boolean; + blocksNext?(): boolean; +} + +interface StepSpec { + label: string; + icon: string; + optional?: boolean; + create(wizard: Wizard): StepView; +} + +const STEPS: Record = { + [ESetupStep.Start]: { + label: "Start", + icon: "sparkles", + create: createStartStep, + }, + [ESetupStep.Storage]: { + label: "Storage", + icon: "hard-drive", + create: createStorageStep, + }, + [ESetupStep.Passphrase]: { + label: "Passphrase", + icon: "key-round", + create: createPassphraseStep, + }, + [ESetupStep.Import]: { + label: "Setup link", + icon: "link", + create: createImportStep, + }, + [ESetupStep.Relay]: { + label: "Relay", + icon: "radio-tower", + optional: true, + create: createRelayStep, + }, + [ESetupStep.Ready]: { + label: "Sync", + icon: "refresh-cw", + create: createReadyStep, + }, +}; + +class SetupWizardModal extends Modal implements Wizard { + private path: ESetupPath | null; + private step: ESetupStep; + private view: StepView; + private opened = false; + private stepper: Stepper | null = null; + private bodyEl: HTMLElement | null = null; + + constructor( + readonly plugin: PluginHost, + step: ESetupStep, + private readonly onClosed: () => void, + ) { + super(plugin.app); + this.step = step; + this.path = pathOf(step, null); + this.view = STEPS[step].create(this); + } + + onOpen(): void { + this.opened = true; + this.titleEl.setText("Set up MDSync"); + this.modalEl.addClass("mdsync-setup"); + this.stepper = createStepper(this.contentEl); + this.bodyEl = this.contentEl.createDiv(); + this.redraw(); + } + + onClose(): void { + this.opened = false; + this.stepper = null; + this.bodyEl = null; + this.contentEl.empty(); + if (current === this) current = null; + this.onClosed(); + } + + isShowing(view: StepView): boolean { + return this.opened && this.view === view; + } + + isLive(): boolean { + return this.opened; + } + + redraw(): void { + if (!this.opened || !this.stepper || !this.bodyEl) return; + const body = this.bodyEl; + this.stepper.update(this.stepperItems()); + renderKeepingFocus(body, () => this.draw(body)); + } + + next(): void { + const steps = this.steps(); + const following = steps[steps.indexOf(this.step) + 1]; + if (following) this.go(following); + else this.close(); + } + + choose(path: ESetupPath): void { + this.path = path; + this.go(PATH_STEPS[path][0] ?? ESetupStep.Ready); + } + + go(step: ESetupStep): void { + this.path = pathOf(step, this.path); + this.step = step; + this.view = STEPS[step].create(this); + this.contentEl.scrollTop = 0; + this.redraw(); + } + + private steps(): readonly ESetupStep[] { + return this.path ? PATH_STEPS[this.path] : []; + } + + private back(): void { + if (this.view.back?.()) { + this.redraw(); + return; + } + const steps = this.steps(); + this.go(steps[steps.indexOf(this.step) - 1] ?? ESetupStep.Start); + } + + private draw(parent: HTMLElement): void { + parent.empty(); + this.view.render(parent.createDiv({ cls: "mdsync-setup-body" })); + this.drawFooter(parent); + } + + private stepperItems(): StepperItem[] { + const steps = this.steps(); + const states = stepStates(steps, this.step, (step) => + isStepDone(this.plugin, step), + ); + return steps.map((step, index) => ({ + id: step, + label: STEPS[step].label, + icon: STEPS[step].icon, + state: states[index] ?? EStepState.Ahead, + })); + } + + private drawFooter(parent: HTMLElement): void { + const footer = new Setting(parent); + footer.settingEl.addClass("mdsync-setup-footer"); + if (!this.path) { + footer.addButton((b) => + b.setButtonText("Not now").onClick(() => this.close()), + ); + return; + } + const steps = this.steps(); + const last = steps.indexOf(this.step) === steps.length - 1; + const done = isStepDone(this.plugin, this.step); + footer.addButton((b) => { + b.setButtonText("Back").onClick(() => this.back()); + b.buttonEl.addClass("mdsync-setup-back"); + }); + if (STEPS[this.step].optional && !done) { + footer.addButton((b) => + b.setButtonText("Skip").onClick(() => this.next()), + ); + } + footer.addButton((b) => + b + .setButtonText(last ? "Done" : "Next") + .setCta() + .setDisabled(!done || this.view.blocksNext?.() === true) + .onClick(() => this.next()), + ); + } +} + +/** The path a step belongs to, keeping the current one when it has the step. */ +function pathOf(step: ESetupStep, path: ESetupPath | null): ESetupPath | null { + if (step === ESetupStep.Start) return null; + if (path && PATH_STEPS[path].includes(step)) return path; + return step === ESetupStep.Import ? ESetupPath.Join : ESetupPath.New; +} + +let current: SetupWizardModal | null = null; + +export function closeSetupWizard(): void { + current?.close(); +} + +/** One wizard at a time: asking again moves the open one to `step`. */ +export function openSetupWizard( + plugin: PluginHost, + step: ESetupStep | undefined, + onClosed: () => void, +): void { + // Settings is a document of its own: a wizard left in another one is hidden, or went away with it. + const shown = current?.containerEl; + if (shown?.isConnected && shown.ownerDocument === activeDocument) { + if (step) current?.go(step); + return; + } + current?.close(); + current = new SetupWizardModal(plugin, step ?? firstStep(plugin), onClosed); + current.open(); +} diff --git a/packages/plugin/src/settings/tab.ts b/packages/plugin/src/settings/tab.ts index db6f0704..1d4b8a7a 100644 --- a/packages/plugin/src/settings/tab.ts +++ b/packages/plugin/src/settings/tab.ts @@ -27,7 +27,11 @@ import { TYPING_SETTLE_MS, } from "./fields"; import { renderLogsView } from "./logs-view"; -import type { MdsyncSettings, SettingsSyncCategories } from "./model"; +import { + canSync, + type MdsyncSettings, + type SettingsSyncCategories, +} from "./model"; import { renderAutomationSection, renderBackendSection, @@ -235,6 +239,13 @@ export class MdsyncSettingTab extends PluginSettingTab { } private renderConnectionTab(parent: HTMLElement): void { + new Setting(parent) + .setName("Setup wizard") + .setDesc("Storage, passphrase and relay, step by step.") + .addButton((button) => { + button.setButtonText("Open").onClick(() => this.plugin.openSetup()); + if (!canSync(this.plugin.settings)) button.setCta(); + }); renderBackendSection(parent, this.plugin, () => this.display()); renderSecuritySection(parent, this.plugin, () => this.display()); renderRelaySection(parent, this.fieldContext()); diff --git a/packages/plugin/src/settings/transfer-controller.ts b/packages/plugin/src/settings/transfer-controller.ts index 8f0727db..08c7d025 100644 --- a/packages/plugin/src/settings/transfer-controller.ts +++ b/packages/plugin/src/settings/transfer-controller.ts @@ -56,6 +56,16 @@ export class SettingsTransferController { return true; } + /** The setup wizard's import: the passphrase is typed beside the link, and its button is the consent. */ + async importWith(input: string, passphrase: string): Promise { + const imported = await readSettingsTransfer(input, passphrase); + // Without storage the link opens nothing here: this device's passphrase still belongs to its own. + await this.apply( + mergeTransferredSettings(this.deps.settings, imported), + imported.storageConfigs ? passphrase : null, + ); + } + async handleProtocol(params: ObsidianProtocolData): Promise { const data = params.d; if (typeof data !== "string") { @@ -77,11 +87,13 @@ export class SettingsTransferController { /** `passphrase` opened the token, so it is the vault's. */ private async apply( merged: MdsyncSettings, - passphrase: string, + passphrase: string | null, ): Promise { Object.assign(this.deps.settings, merged); await this.deps.saveSettings(); - await this.deps.passphrase.replacePassphrase(passphrase); + if (passphrase !== null) { + await this.deps.passphrase.replacePassphrase(passphrase); + } this.deps.onSettingsReplaced(); } } diff --git a/packages/plugin/src/shared/path.ts b/packages/plugin/src/shared/path.ts index 7bc81f6f..adb627c4 100644 --- a/packages/plugin/src/shared/path.ts +++ b/packages/plugin/src/shared/path.ts @@ -30,6 +30,18 @@ export function relayBase(url: string): string { return url.trim().replace(/\/+$/, ""); } +const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); + +/** The secret rides every request: only a local relay may be reached without TLS. */ +export function isCleartextRemote(url: string): boolean { + try { + const { protocol, hostname } = new URL(url.trim()); + return protocol === "http:" && !LOCAL_HOSTS.has(hostname); + } catch { + return false; + } +} + /** Trims surrounding slashes and re-adds a single trailing one: `"/a/b/" → "a/b/"`. */ export function normalizeKeyPrefix(prefix: string): string { const trimmed = prefix.replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); diff --git a/packages/plugin/src/styles/index.css b/packages/plugin/src/styles/index.css index 474cc602..1c61e9a5 100644 --- a/packages/plugin/src/styles/index.css +++ b/packages/plugin/src/styles/index.css @@ -28,6 +28,11 @@ @import "./diff/hunk-preview.css"; @import "./settings/transfer.css"; +@import "./settings/setup.css"; +@import "./settings/setup-stepper.css"; +@import "./settings/setup-cards.css"; +@import "./settings/setup-forms.css"; +@import "./settings/setup-progress.css"; @import "./explorer.css"; @import "./status-bar.css"; @import "./ribbon.css"; diff --git a/packages/plugin/src/styles/settings/setup-cards.css b/packages/plugin/src/styles/settings/setup-cards.css new file mode 100644 index 00000000..b9bfdb09 --- /dev/null +++ b/packages/plugin/src/styles/settings/setup-cards.css @@ -0,0 +1,185 @@ +.mdsync-hero { + display: flex; + align-items: center; + gap: var(--size-4-4); + margin-bottom: var(--size-4-5); +} + +.mdsync-hero-icon { + display: grid; + flex: none; + place-items: center; + width: 56px; + height: 56px; + border-radius: var(--radius-l); + background: linear-gradient( + 135deg, + var(--interactive-accent), + var(--interactive-accent-hover) + ); + box-shadow: 0 6px 18px + color-mix(in srgb, var(--interactive-accent) 35%, transparent); + color: var(--text-on-accent); +} + +/* A tint with the tone as the glyph: legible on any theme, unlike text-on-accent over a palette color. */ +.mdsync-hero-icon.is-success { + background: color-mix( + in srgb, + var(--text-success) 16%, + var(--background-primary) + ); + box-shadow: 0 6px 18px + color-mix(in srgb, var(--text-success) 25%, transparent); + color: var(--text-success); +} + +.mdsync-hero-icon .svg-icon { + width: 28px; + height: 28px; +} + +.mdsync-hero-text h3 { + margin: 0 0 var(--size-4-1); + font-size: var(--font-ui-large); +} + +.mdsync-hero-text p { + margin: 0; + color: var(--text-muted); +} + +.mdsync-choices { + display: flex; + flex-direction: column; + gap: var(--size-4-2); +} + +.mdsync-choices .mdsync-choice { + display: flex; + align-items: center; + gap: var(--size-4-3); + box-sizing: border-box; + width: 100%; + height: auto; + padding: var(--size-4-3) var(--size-4-4); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + background: var(--background-primary); + box-shadow: none; + text-align: left; + cursor: pointer; + transition: + border-color 0.2s ease, + background-color 0.2s ease, + transform 0.2s ease; +} + +.mdsync-choices .mdsync-choice:hover { + border-color: var(--interactive-accent); + background: color-mix( + in srgb, + var(--interactive-accent) 6%, + var(--background-primary) + ); + box-shadow: none; +} + +.mdsync-choices .mdsync-choice:active { + transform: scale(0.99); +} + +.mdsync-choices .mdsync-choice.is-primary { + border-color: color-mix( + in srgb, + var(--interactive-accent) 45%, + var(--background-modifier-border) + ); +} + +.mdsync-choice-icon { + display: grid; + flex: none; + place-items: center; + width: 40px; + height: 40px; + border-radius: var(--radius-m); + background: var(--background-modifier-hover); + color: var(--text-muted); + transition: + background-color 0.2s ease, + color 0.2s ease; +} + +.mdsync-choice-icon .svg-icon { + width: 20px; + height: 20px; +} + +.mdsync-choice.is-primary .mdsync-choice-icon, +.mdsync-choice:hover .mdsync-choice-icon { + background: var(--interactive-accent); + color: var(--text-on-accent); +} + +.mdsync-choice-text { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.mdsync-choice-title { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--size-4-2); + color: var(--text-normal); + font-weight: var(--font-medium); +} + +.mdsync-choice-badge { + padding: 1px var(--size-4-2); + border-radius: var(--mdsync-radius-pill); + background: color-mix(in srgb, var(--interactive-accent) 16%, transparent); + color: var(--interactive-accent); + font-size: var(--font-ui-smaller); + font-weight: var(--font-medium); +} + +.mdsync-choice-desc { + color: var(--text-muted); + font-size: var(--font-ui-small); + line-height: var(--line-height-tight); + white-space: normal; +} + +.mdsync-choice-arrow { + flex: none; + color: var(--text-faint); + transition: + color 0.2s ease, + transform 0.2s ease; +} + +.mdsync-choice:hover .mdsync-choice-arrow { + color: var(--interactive-accent); + transform: translateX(var(--size-4-1)); +} + +@media (prefers-reduced-motion: reduce) { + .mdsync-choices .mdsync-choice, + .mdsync-choice-icon, + .mdsync-choice-arrow { + transition: none; + } +} + +.is-phone .mdsync-hero { + align-items: flex-start; +} + +.is-phone .mdsync-choice { + min-height: var(--mdsync-touch); +} diff --git a/packages/plugin/src/styles/settings/setup-forms.css b/packages/plugin/src/styles/settings/setup-forms.css new file mode 100644 index 00000000..f4eddc20 --- /dev/null +++ b/packages/plugin/src/styles/settings/setup-forms.css @@ -0,0 +1,198 @@ +.mdsync-setup-body .mod-warning { + padding: var(--size-4-2) var(--size-4-3); + border-radius: var(--radius-m); + background: color-mix(in srgb, var(--text-warning) 10%, transparent); + font-size: var(--font-ui-small); +} + +.mdsync-callout { + --mdsync-callout-tone: var(--interactive-accent); + display: flex; + align-items: flex-start; + gap: var(--size-4-3); + margin: var(--size-4-3) 0; + padding: var(--size-4-3); + border-radius: var(--radius-m); + background: color-mix(in srgb, var(--mdsync-callout-tone) 10%, transparent); + color: var(--text-muted); + font-size: var(--font-ui-small); + line-height: var(--line-height-tight); +} + +.mdsync-callout.is-warning { + --mdsync-callout-tone: var(--text-warning); +} + +.mdsync-callout.is-success { + --mdsync-callout-tone: var(--text-success); +} + +.mdsync-callout-icon { + display: grid; + flex: none; + color: var(--mdsync-callout-tone); +} + +.mdsync-callout-icon .svg-icon { + width: var(--mdsync-icon-lg); + height: var(--mdsync-icon-lg); +} + +.mdsync-setup-busy { + display: flex; + align-items: center; + gap: var(--size-4-3); + padding: var(--size-4-3) 0; + color: var(--text-muted); +} + +/* Numbered how-to: a circle per step, joined by a line. */ +.mdsync-howto { + counter-reset: mdsync-howto; + margin: 0; + padding: 0; + list-style: none; +} + +.mdsync-howto > li { + --mdsync-howto-dot: 26px; + position: relative; + counter-increment: mdsync-howto; + /* Obsidian zeroes a lone row's own padding, so the step carries it. */ + padding: var(--size-4-3) 0 var(--size-4-3) + calc(var(--mdsync-howto-dot) + var(--size-4-3)); +} + +.mdsync-howto > li::before { + content: counter(mdsync-howto); + position: absolute; + top: calc(var(--size-4-3) - 3px); + left: 0; + display: grid; + place-items: center; + width: var(--mdsync-howto-dot); + height: var(--mdsync-howto-dot); + border-radius: var(--mdsync-radius-pill); + background: color-mix(in srgb, var(--interactive-accent) 16%, transparent); + color: var(--interactive-accent); + font-size: var(--font-ui-small); + font-weight: var(--font-semibold); +} + +.mdsync-howto > li:not(:last-child)::after { + content: ""; + position: absolute; + top: calc(var(--size-4-3) - 3px + var(--mdsync-howto-dot) + var(--size-4-1)); + bottom: calc(var(--size-4-1) * -1); + left: calc(var(--mdsync-howto-dot) / 2 - 1px); + width: 2px; + background: var(--background-modifier-border); +} + +.mdsync-howto-demo { + margin-top: var(--size-4-1); +} + +.mdsync-howto-demo > summary { + width: fit-content; + color: var(--text-accent); + font-size: var(--font-ui-small); + cursor: pointer; +} + +.mdsync-howto-demo > img { + display: block; + width: 100%; + aspect-ratio: 25 / 16; + margin-top: var(--size-4-2); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + background: var(--background-secondary); +} + +.mdsync-howto-demo-failed { + margin-top: var(--size-4-2); + color: var(--text-muted); + font-size: var(--font-ui-small); +} + +/* Obsidian's separator rule for modal settings has four classes of specificity; match it and win on order. */ +.modal.mdsync-setup .mdsync-setup-body .setting-item.mdsync-button-row, +.modal.mdsync-setup .mdsync-setup-body .mdsync-howto .setting-item { + border-top: 0; +} + +/* The wizard's rows are flat: the indent rule of a sub-setting would draw a stray corner. */ +.modal.mdsync-setup .mdsync-setup-body .setting-item.mdsync-sub-setting { + margin-left: 0; + padding-left: 0; + border-left: 0; +} + +/* The info column is the only room next to the field: the meter lives under its name. */ +.mdsync-meter { + display: flex; + flex-direction: column; + gap: var(--size-4-1); + margin-top: var(--size-4-2); + max-width: 220px; +} + +.mdsync-meter-bars { + display: flex; + gap: var(--size-4-1); +} + +.mdsync-meter-bar { + flex: 1 1 0; + height: 4px; + border-radius: var(--mdsync-radius-pill); + background: var(--background-modifier-border); + transition: background-color 0.25s ease; +} + +.mdsync-meter-bar.is-on { + background: var(--text-success); +} + +.mdsync-meter[data-filled="1"] .mdsync-meter-bar.is-on { + background: var(--text-error); +} + +.mdsync-meter[data-filled="2"] .mdsync-meter-bar.is-on { + background: var(--text-warning); +} + +/* On a phone the field stacks under its name: flatten the info column so the meter can follow the input. */ +.is-phone .setting-item:has(.mdsync-meter) { + flex-wrap: wrap; +} + +.is-phone .setting-item-info:has(.mdsync-meter) { + display: contents; +} + +.is-phone .setting-item:has(.mdsync-meter) .setting-item-name { + flex-basis: 100%; +} + +.is-phone .setting-item:has(.mdsync-meter) .setting-item-control { + flex-basis: 100%; + order: 1; +} + +.is-phone .setting-item-description:has(.mdsync-meter) { + flex-basis: 100%; + order: 2; +} + +.mdsync-meter-label { + color: var(--text-muted); + font-size: var(--font-ui-smaller); +} + +@media (prefers-reduced-motion: reduce) { + .mdsync-meter-bar { + transition: none; + } +} diff --git a/packages/plugin/src/styles/settings/setup-progress.css b/packages/plugin/src/styles/settings/setup-progress.css new file mode 100644 index 00000000..39f814fd --- /dev/null +++ b/packages/plugin/src/styles/settings/setup-progress.css @@ -0,0 +1,168 @@ +/* Relay deploy: one dot per stage, a line joins each to the one before. */ +.mdsync-progress { + --mdsync-progress-dot: 24px; + margin: var(--size-4-2) 0 var(--size-4-4); + padding: 0; + list-style: none; +} + +.mdsync-progress-step { + position: relative; + display: flex; + align-items: center; + gap: var(--size-4-3); + min-height: calc(var(--mdsync-progress-dot) + var(--size-4-3)); + color: var(--text-faint); + transition: color 0.3s ease; +} + +.mdsync-progress-step + .mdsync-progress-step::before { + content: ""; + position: absolute; + top: calc(var(--size-4-3) / -2); + left: calc(var(--mdsync-progress-dot) / 2 - 1px); + width: 2px; + height: var(--size-4-3); + background: var(--background-modifier-border); + transition: background-color 0.3s ease; +} + +.mdsync-progress-step.is-done + .mdsync-progress-step::before { + background: var(--interactive-accent); +} + +.mdsync-progress-dot { + display: grid; + flex: none; + place-items: center; + box-sizing: border-box; + width: var(--mdsync-progress-dot); + height: var(--mdsync-progress-dot); + border: 2px solid var(--background-modifier-border); + border-radius: var(--mdsync-radius-pill); + color: var(--text-on-accent); +} + +.mdsync-progress-dot .svg-icon { + width: 14px; + height: 14px; +} + +.mdsync-progress-step.is-done { + color: var(--text-normal); +} + +.mdsync-progress-step.is-done .mdsync-progress-dot { + border-color: var(--interactive-accent); + background: var(--interactive-accent); +} + +.mdsync-progress-step.is-running { + color: var(--text-normal); + font-weight: var(--font-semibold); +} + +.mdsync-progress-step.is-running .mdsync-progress-dot { + border-color: var(--interactive-accent); + box-shadow: 0 0 0 4px + color-mix(in srgb, var(--interactive-accent) 20%, transparent); +} + +.mdsync-progress-step.is-running .mdsync-progress-dot::after { + content: ""; + width: 10px; + height: 10px; + border: 2px solid transparent; + border-top-color: var(--interactive-accent); + border-radius: 50%; + animation: mdsync-spin 0.65s linear infinite; +} + +.mdsync-progress-step.is-failed { + color: var(--text-error); +} + +.mdsync-progress-step.is-failed .mdsync-progress-dot { + border-color: var(--text-error); + background: color-mix(in srgb, var(--text-error) 14%, transparent); + color: var(--text-error); +} + +@media (prefers-reduced-motion: reduce) { + .mdsync-progress-step, + .mdsync-progress-step + .mdsync-progress-step::before { + transition: none; + } + + .mdsync-progress-step.is-running .mdsync-progress-dot::after { + animation: none; + } +} + +/* Ready: what this device is set up with. */ +.mdsync-summary { + margin: 0 0 var(--size-4-3); + padding: 0; + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + list-style: none; +} + +.mdsync-summary-row { + display: flex; + align-items: center; + gap: var(--size-4-3); + padding: var(--size-4-3) var(--size-4-4); +} + +.mdsync-summary-row + .mdsync-summary-row { + border-top: 1px solid var(--background-modifier-border); +} + +.mdsync-summary-icon { + display: grid; + flex: none; + place-items: center; + width: 32px; + height: 32px; + border-radius: var(--radius-m); + background: color-mix(in srgb, var(--text-success) 14%, transparent); + color: var(--text-success); +} + +.mdsync-summary-icon .svg-icon { + width: var(--mdsync-icon-lg); + height: var(--mdsync-icon-lg); +} + +.mdsync-summary-text { + flex: 1 1 auto; + min-width: 0; +} + +.mdsync-summary-label { + color: var(--text-muted); + font-size: var(--font-ui-smaller); +} + +.mdsync-summary-value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mdsync-summary-state { + display: grid; + flex: none; + color: var(--text-success); +} + +.mdsync-summary-row.is-off .mdsync-summary-icon { + background: var(--background-modifier-hover); + color: var(--text-faint); +} + +.mdsync-summary-row.is-off .mdsync-summary-value, +.mdsync-summary-row.is-off .mdsync-summary-state { + color: var(--text-faint); +} diff --git a/packages/plugin/src/styles/settings/setup-stepper.css b/packages/plugin/src/styles/settings/setup-stepper.css new file mode 100644 index 00000000..78a2841f --- /dev/null +++ b/packages/plugin/src/styles/settings/setup-stepper.css @@ -0,0 +1,177 @@ +.mdsync-stepper { + --mdsync-step-dot: 34px; + --mdsync-step-gap: var(--size-4-2); + display: flex; + min-width: 0; + margin: var(--size-4-2) 0 var(--size-4-6); + padding: 0; + list-style: none; +} + +.mdsync-stepper.is-hidden { + display: none; +} + +.mdsync-step { + position: relative; + display: flex; + flex: 1 1 0; + flex-direction: column; + min-width: 0; + align-items: center; + gap: var(--size-4-2); + color: var(--text-faint); + font-size: var(--font-ui-smaller); + text-align: center; + animation: mdsync-step-in 0.4s ease-out both; + animation-delay: calc(var(--mdsync-step-index, 0) * 70ms); +} + +/* The line into a step: empty until the step is reached, then filled left to right. */ +.mdsync-step + .mdsync-step::before { + content: ""; + position: absolute; + top: calc(var(--mdsync-step-dot) / 2 - 1px); + right: calc(50% + var(--mdsync-step-dot) / 2 + var(--mdsync-step-gap)); + left: calc(-50% + var(--mdsync-step-dot) / 2 + var(--mdsync-step-gap)); + height: 2px; + border-radius: 1px; + background-color: var(--background-modifier-border); + background-image: linear-gradient( + var(--interactive-accent), + var(--interactive-accent) + ); + background-repeat: no-repeat; + background-size: 0% 100%; + transition: background-size 0.5s ease-in-out; +} + +.mdsync-step + .mdsync-step.is-reached::before { + background-size: 100% 100%; +} + +.mdsync-step-dot { + position: relative; + z-index: 1; + display: grid; + place-items: center; + box-sizing: border-box; + width: var(--mdsync-step-dot); + height: var(--mdsync-step-dot); + border: 2px solid var(--background-modifier-border); + border-radius: var(--mdsync-radius-pill); + background-color: var(--background-primary); + color: var(--text-faint); + transition: + background-color 0.3s ease, + border-color 0.3s ease, + color 0.3s ease, + box-shadow 0.3s ease; +} + +.mdsync-step-icon, +.mdsync-step-check { + display: grid; + grid-area: 1 / 1; + place-items: center; +} + +.mdsync-step-dot .svg-icon { + width: var(--mdsync-icon-lg); + height: var(--mdsync-icon-lg); + transition: + opacity 0.25s ease, + transform 0.3s ease; +} + +.mdsync-step-check .svg-icon { + opacity: 0; + transform: scale(0.4); +} + +.mdsync-step-label { + max-width: 100%; + overflow-wrap: anywhere; + transition: color 0.3s ease; +} + +.mdsync-step.is-reached .mdsync-step-dot { + border-color: var(--interactive-accent); + color: var(--interactive-accent); +} + +.mdsync-step.is-reached .mdsync-step-label { + color: var(--text-muted); +} + +.mdsync-step.is-current .mdsync-step-dot { + box-shadow: 0 0 0 4px + color-mix(in srgb, var(--interactive-accent) 22%, transparent); +} + +.mdsync-step.is-current .mdsync-step-label { + color: var(--text-normal); + font-weight: var(--font-semibold); +} + +.mdsync-step.is-done .mdsync-step-dot { + background-color: var(--interactive-accent); + color: var(--text-on-accent); +} + +.mdsync-step.is-done .mdsync-step-icon .svg-icon { + opacity: 0; + transform: scale(0.4); +} + +.mdsync-step.is-done .mdsync-step-check .svg-icon { + opacity: 1; + transform: none; +} + +.mdsync-step.is-skipped .mdsync-step-dot { + border-style: dashed; + border-color: var(--text-faint); + color: var(--text-faint); +} + +@keyframes mdsync-step-in { + from { + opacity: 0; + transform: translateY(var(--size-4-2)); + } +} + +@media (prefers-reduced-motion: reduce) { + .mdsync-step, + .mdsync-step + .mdsync-step::before, + .mdsync-step-dot, + .mdsync-step-dot .svg-icon, + .mdsync-step-label { + animation: none; + transition: none; + } +} + +.is-phone .mdsync-stepper { + --mdsync-step-dot: 30px; + margin-bottom: var(--size-4-4); +} + +/* Only the current label shows: hidden ones take no room. */ +.is-phone .mdsync-step:not(.is-current) .mdsync-step-label { + display: none; +} + +.mdsync-sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + border: 0; + white-space: nowrap; +} diff --git a/packages/plugin/src/styles/settings/setup.css b/packages/plugin/src/styles/settings/setup.css new file mode 100644 index 00000000..ffcceaee --- /dev/null +++ b/packages/plugin/src/styles/settings/setup.css @@ -0,0 +1,54 @@ +.mdsync-setup { + --dialog-width: 620px; +} + +.mdsync-setup-body h3 { + margin-top: 0; +} + +/* + * Pinned, so Back and Next stay in reach on a long step. The modal scrolls inside its own padding and + * the footer sticks above it: the shadow paints that strip so no text shows under the buttons, and the + * buttons sit where they do at the end of the scroll. + * Obsidian's modal rule makes every setting transparent at four classes of specificity; match it and win on order. + */ +.modal.mdsync-setup .setting-item.mdsync-setup-footer { + --mdsync-footer-bg: var(--modal-background, var(--background-primary)); + position: sticky; + bottom: 0; + z-index: 2; + margin-top: var(--size-4-4); + border-top: 1px solid var(--background-modifier-border); + background-color: var(--mdsync-footer-bg); + box-shadow: 0 var(--size-4-4) 0 var(--mdsync-footer-bg); +} + +.mdsync-setup-footer .setting-item-info { + display: none; +} + +.mdsync-setup-footer .setting-item-control { + flex: 1 1 auto; +} + +.mdsync-setup-footer .mdsync-setup-back { + margin-right: auto; + background: transparent; + box-shadow: none; + color: var(--text-muted); +} + +.mdsync-setup-footer .mdsync-setup-back:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.mdsync-setup-stacked { + flex-wrap: wrap; +} + +.mdsync-setup-stacked .setting-item-control, +.mdsync-setup-stacked textarea { + flex-basis: 100%; + width: 100%; +} diff --git a/packages/plugin/src/ui/common/index.ts b/packages/plugin/src/ui/common/index.ts index c669b893..e86710a8 100644 --- a/packages/plugin/src/ui/common/index.ts +++ b/packages/plugin/src/ui/common/index.ts @@ -11,6 +11,7 @@ export { onLongPress } from "./long-press"; export { infoTitle, personTitle, showMenuAt } from "./menu"; export { attempt, + notifyAction, notifyError, notifyInfo, reportError, diff --git a/packages/plugin/src/ui/common/notices.ts b/packages/plugin/src/ui/common/notices.ts index 60abbecd..47ba4822 100644 --- a/packages/plugin/src/ui/common/notices.ts +++ b/packages/plugin/src/ui/common/notices.ts @@ -1,4 +1,4 @@ -import { Notice } from "obsidian"; +import { ButtonComponent, Notice } from "obsidian"; import { errorMessage } from "@/shared"; import type { SyncOperationResult } from "@/sync/controller"; @@ -15,6 +15,22 @@ export function notifyInfo(message: string): void { new Notice(`MDSync: ${message}`); } +/** A notice with one button, for what a background task cannot open a modal for. */ +export function notifyAction( + message: string, + label: string, + action: () => void, +): void { + const notice = new Notice(`MDSync: ${message}`, NOTICE_DURATION_MS); + new ButtonComponent(notice.messageEl.createDiv()) + .setButtonText(label) + .setCta() + .onClick(() => { + notice.hide(); + action(); + }); +} + /** Shows a caught error as a notice and logs the original to the console. */ export function reportError(err: unknown): void { notifyError(errorMessage(err)); diff --git a/packages/plugin/src/ui/index.ts b/packages/plugin/src/ui/index.ts index 647c0ca6..a2e5f8be 100644 --- a/packages/plugin/src/ui/index.ts +++ b/packages/plugin/src/ui/index.ts @@ -7,6 +7,7 @@ export { } from "./actions/maintenance-actions"; export { addPushMenuItem } from "./actions/push-action"; export { + notifyAction, notifyError, notifyInfo, reportError, diff --git a/packages/plugin/src/ui/links/link-menu.ts b/packages/plugin/src/ui/links/link-menu.ts index fe27d257..7297bc05 100644 --- a/packages/plugin/src/ui/links/link-menu.ts +++ b/packages/plugin/src/ui/links/link-menu.ts @@ -1,9 +1,8 @@ import { type App, type Menu, type TAbstractFile, TFile } from "obsidian"; -import { NO_RELAY } from "@/links"; import { docKindOf } from "@/live"; import type { PluginHost } from "@/plugin/host"; import { isRelayConfigured } from "@/settings/model"; -import { notifyInfo } from "@/ui/common"; +import { ESetupStep } from "@/settings/setup/steps"; import { ManageLinksModal } from "./manage-links-modal"; import { ShareLinkModal } from "./share-link-modal"; @@ -22,11 +21,11 @@ export function isLinkable( } export function openShareLink(plugin: PluginHost, file: TFile): void { - if (!isRelayConfigured(plugin.settings)) { - notifyInfo(NO_RELAY); - return; + if (isRelayConfigured(plugin.settings)) { + new ShareLinkModal(plugin, file).open(); + } else { + plugin.openSetup(ESetupStep.Relay); } - new ShareLinkModal(plugin, file).open(); } export function openManageLinks(plugin: PluginHost, path?: string): void { @@ -39,14 +38,14 @@ export function addLinkMenuItems( file: TAbstractFile, ): void { if (!isLinkable(plugin.app, file)) return; - const ready = isRelayConfigured(plugin.settings); menu.addItem((item) => item .setTitle( - ready ? "MDSync: Share link" : "MDSync: Share link (needs a relay)", + isRelayConfigured(plugin.settings) + ? "MDSync: Share link" + : "MDSync: Share link (set up a relay)", ) .setIcon("link") - .setDisabled(!ready) .onClick(() => openShareLink(plugin, file)), ); const count = plugin.sharedLinks.of(file.path).length; diff --git a/packages/plugin/src/ui/shares/invite-action.ts b/packages/plugin/src/ui/shares/invite-action.ts index a2de4ec1..1b7dcd48 100644 --- a/packages/plugin/src/ui/shares/invite-action.ts +++ b/packages/plugin/src/ui/shares/invite-action.ts @@ -30,8 +30,12 @@ export interface CreatedInvite { password: string; } -/** Opens an `obsidian://mdsync-share` link, or asks for one when `link` is empty. */ -export async function openInvite(plugin: PluginHost, link = ""): Promise { +/** Opens an `obsidian://mdsync-share` link, or asks for one when `link` is empty. `accepted` runs once the share is in. */ +export async function openInvite( + plugin: PluginHost, + link = "", + accepted?: () => void, +): Promise { if (!plugin.settings.useSharedFolders && !(await turnOnShares(plugin))) return; new AcceptInviteModal( @@ -42,8 +46,15 @@ export async function openInvite(plugin: PluginHost, link = ""): Promise { const known = recordOf(plugin, invite); return known && !known.closed ? known.root : null; }, - (invite, root) => - mountShare(plugin, invite, stripTrailingSlash(normalizePath(root))), + async (invite, root) => { + const problem = await mountShare( + plugin, + invite, + stripTrailingSlash(normalizePath(root)), + ); + if (!problem) accepted?.(); + return problem; + }, ).open(); } diff --git a/packages/plugin/tests/cloudflare/api.test.ts b/packages/plugin/tests/cloudflare/api.test.ts new file mode 100644 index 00000000..3be6e044 --- /dev/null +++ b/packages/plugin/tests/cloudflare/api.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { cloudflareApi, hasCode } from "@/cloudflare"; + +function apiReplying(status: number, text: string) { + return cloudflareApi(async () => ({ status, text }), "tok"); +} + +describe("cloudflareApi", () => { + it("returns the result of a successful reply", async () => { + const api = apiReplying(200, JSON.stringify({ success: true, result: 7 })); + + expect(await api.call("GET", "/x")).toBe(7); + }); + + it("throws the reply's errors with their codes", async () => { + const api = apiReplying( + 400, + JSON.stringify({ + success: false, + errors: [{ code: 10004, message: "taken" }], + }), + ); + + const err = await api.call("POST", "/x").catch((e: unknown) => e); + + expect(hasCode(err, 10004)).toBe(true); + expect((err as Error).message).toContain("taken (10004)"); + }); + + it("refuses a successful status whose body is not Cloudflare's JSON", async () => { + const api = apiReplying(200, "captive portal"); + + await expect(api.call("GET", "/x")).rejects.toThrow("HTTP 200"); + }); + + it("sends the override bearer instead of the token", async () => { + const sent: string[] = []; + const api = cloudflareApi(async (request) => { + sent.push(request.headers.Authorization ?? ""); + return { status: 200, text: JSON.stringify({ success: true }) }; + }, "tok"); + + await api.call("GET", "/x", { bearer: "jwt" }); + + expect(sent).toEqual(["Bearer jwt"]); + }); +}); diff --git a/packages/plugin/tests/cloudflare/migrations.test.ts b/packages/plugin/tests/cloudflare/migrations.test.ts new file mode 100644 index 00000000..99c52e89 --- /dev/null +++ b/packages/plugin/tests/cloudflare/migrations.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { pendingMigrations } from "@/cloudflare/migrations"; + +const MIGRATIONS = [ + { tag: "v1", new_sqlite_classes: ["SyncRelay"] }, + { tag: "v2", new_sqlite_classes: ["Hub"], deleted_classes: ["SyncRelay"] }, + { tag: "v3", new_sqlite_classes: ["Link"] }, +]; + +describe("pendingMigrations", () => { + it("sends every step to a fresh relay, without an old tag", () => { + expect(pendingMigrations(MIGRATIONS, undefined)).toEqual({ + new_tag: "v3", + steps: [ + { new_sqlite_classes: ["SyncRelay"] }, + { new_sqlite_classes: ["Hub"], deleted_classes: ["SyncRelay"] }, + { new_sqlite_classes: ["Link"] }, + ], + }); + }); + + it("sends only the steps after the deployed tag", () => { + expect(pendingMigrations(MIGRATIONS, "v2")).toEqual({ + old_tag: "v2", + new_tag: "v3", + steps: [{ new_sqlite_classes: ["Link"] }], + }); + }); + + it("sends nothing to a relay already at the last tag", () => { + expect(pendingMigrations(MIGRATIONS, "v3")).toBeUndefined(); + }); + + it("refuses a deployed tag it does not know instead of replaying history", () => { + expect(() => pendingMigrations(MIGRATIONS, "v9")).toThrow(/v9/); + }); +}); diff --git a/packages/plugin/tests/cloudflare/r2.test.ts b/packages/plugin/tests/cloudflare/r2.test.ts new file mode 100644 index 00000000..84bf8c34 --- /dev/null +++ b/packages/plugin/tests/cloudflare/r2.test.ts @@ -0,0 +1,117 @@ +import { createHash } from "node:crypto"; +import { failure, fakeCloudflare } from "@tests/helpers/fake-cloudflare"; +import { describe, expect, it } from "vitest"; +import { + ensureBucket, + R2NotEnabledError, + r2Storage, + tokenTemplateUrl, + verifyToken, +} from "@/cloudflare"; + +const BUCKETS = "/accounts/acc/r2/buckets"; + +describe("R2 storage", () => { + it("creates the bucket, and takes one this account already owns", async () => { + const created = fakeCloudflare([ + { + method: "POST", + path: BUCKETS, + reply: () => ({ result: { name: "vault" } }), + }, + ]); + await ensureBucket(created.api, "acc", "vault"); + expect(JSON.parse(created.calls[0]?.body as string)).toEqual({ + name: "vault", + }); + + const owned = fakeCloudflare([ + { method: "POST", path: BUCKETS, reply: () => failure(10004, 409) }, + ]); + await expect( + ensureBucket(owned.api, "acc", "vault"), + ).resolves.toBeUndefined(); + }); + + it("tells an account without R2 apart from other failures", async () => { + const off = fakeCloudflare([ + { method: "POST", path: BUCKETS, reply: () => failure(10042, 403) }, + ]); + await expect(ensureBucket(off.api, "acc", "vault")).rejects.toBeInstanceOf( + R2NotEnabledError, + ); + + const other = fakeCloudflare([ + { method: "POST", path: BUCKETS, reply: () => failure(10005) }, + ]); + await expect(ensureBucket(other.api, "acc", "vault")).rejects.toThrow( + /10005/, + ); + }); + + it("derives S3 credentials from the token: its id and the SHA-256 of its value", async () => { + expect(await r2Storage("acc", "vault", "token-id", "token-value")).toEqual({ + endpoint: "https://acc.r2.cloudflarestorage.com", + region: "auto", + bucket: "vault", + accessKeyId: "token-id", + secretAccessKey: createHash("sha256").update("token-value").digest("hex"), + }); + }); +}); + +describe("verifyToken", () => { + const ACCOUNT_VERIFY = "/accounts/acc/tokens/verify"; + const USER_VERIFY = "/user/tokens/verify"; + + it("verifies an account token under its account", async () => { + const cf = fakeCloudflare([ + { + method: "GET", + path: ACCOUNT_VERIFY, + reply: () => ({ result: { id: "acct-token", status: "active" } }), + }, + ]); + expect(await verifyToken(cf.api, "acc")).toBe("acct-token"); + }); + + it("falls back to the user endpoint for a user token", async () => { + const cf = fakeCloudflare([ + { method: "GET", path: ACCOUNT_VERIFY, reply: () => failure(1000, 401) }, + { + method: "GET", + path: USER_VERIFY, + reply: () => ({ result: { id: "user-token", status: "active" } }), + }, + ]); + expect(await verifyToken(cf.api, "acc")).toBe("user-token"); + }); + + it("refuses a token that is not active", async () => { + const cf = fakeCloudflare([ + { + method: "GET", + path: ACCOUNT_VERIFY, + reply: () => ({ result: { id: "t", status: "expired" } }), + }, + ]); + await expect(verifyToken(cf.api, "acc")).rejects.toThrow(/expired/); + }); +}); + +describe("tokenTemplateUrl", () => { + it("pre-fills an account token with the permissions the relay deploy and R2 setup need", () => { + const url = new URL(tokenTemplateUrl()); + expect(url.origin + url.pathname).toBe("https://dash.cloudflare.com/"); + expect(url.searchParams.get("to")).toBe("/:account/api-tokens"); + expect(url.searchParams.get("name")).toBe("MDSync"); + expect( + JSON.parse(url.searchParams.get("permissionGroupKeys") ?? ""), + ).toEqual([ + { key: "workers_scripts", type: "edit" }, + { key: "workers_kv_storage", type: "edit" }, + { key: "workers_r2", type: "edit" }, + { key: "account_settings", type: "read" }, + ]); + }); +}); diff --git a/packages/plugin/tests/cloudflare/relay-account.test.ts b/packages/plugin/tests/cloudflare/relay-account.test.ts new file mode 100644 index 00000000..70d01621 --- /dev/null +++ b/packages/plugin/tests/cloudflare/relay-account.test.ts @@ -0,0 +1,136 @@ +import { failure, fakeCloudflare } from "@tests/helpers/fake-cloudflare"; +import { describe, expect, it } from "vitest"; +import { + accountRelays, + freeRelayName, + ownRelayName, + relayNameError, + relayUrlOf, +} from "@/cloudflare"; + +const A = "/accounts/acc"; +const RELAYS = { taken: ["mdsync-relay", "other"], subdomain: "me" }; +const RELAY_URL = "https://mdsync-relay.me.workers.dev"; + +describe("accountRelays", () => { + it("lists every worker name and the account's workers.dev subdomain", async () => { + const cf = fakeCloudflare([ + { + method: "GET", + path: `${A}/workers/scripts`, + reply: () => ({ + result: [{ id: "mdsync-relay" }, { id: "unrelated" }], + }), + }, + { + method: "GET", + path: `${A}/workers/subdomain`, + reply: () => ({ result: { subdomain: "me" } }), + }, + ]); + expect(await accountRelays(cf.api, "acc")).toEqual({ + taken: ["mdsync-relay", "unrelated"], + subdomain: "me", + }); + }); + + it("keeps worker names when the account has no workers.dev subdomain", async () => { + const cf = fakeCloudflare([ + { + method: "GET", + path: `${A}/workers/scripts`, + reply: () => ({ result: [{ id: "other" }] }), + }, + { + method: "GET", + path: `${A}/workers/subdomain`, + reply: () => failure(10007, 404), + }, + ]); + expect(await accountRelays(cf.api, "acc")).toEqual({ + taken: ["other"], + subdomain: null, + }); + }); + + it("propagates account lookup failures", async () => { + const cf = fakeCloudflare([ + { + method: "GET", + path: `${A}/workers/scripts`, + reply: () => failure(10005), + }, + { + method: "GET", + path: `${A}/workers/subdomain`, + reply: () => ({ result: { subdomain: "me" } }), + }, + ]); + await expect(accountRelays(cf.api, "acc")).rejects.toThrow(/10005/); + }); +}); + +describe("relay names and URLs", () => { + it("builds the default or chosen worker URL only with a subdomain", () => { + expect(relayUrlOf(RELAYS)).toBe(RELAY_URL); + expect(relayUrlOf(RELAYS, "other")).toBe("https://other.me.workers.dev"); + expect(relayUrlOf({ taken: [], subdomain: null })).toBeNull(); + }); + + it.each([ + [RELAY_URL, "mdsync-relay"], + [`${RELAY_URL}/status?x=1`, "mdsync-relay"], + ["https://other.me.workers.dev", "other"], + ["https://missing.me.workers.dev", null], + ["https://mdsync-relay.someone-else.workers.dev", null], + ["https://mdsync-relay.me.workers.dev.evil.test", null], + ["https://mdsync-relay.notme.workers.dev", null], + ["https://nested.mdsync-relay.me.workers.dev", null], + ["https://relay.example", null], + ["not a URL", null], + ] as const)("recognizes an owned worker for %s as %s", (url, name) => { + expect(ownRelayName(RELAYS, url)).toBe(name); + }); + + it("cannot claim an owned worker without an account subdomain", () => { + expect(ownRelayName({ ...RELAYS, subdomain: null }, RELAY_URL)).toBeNull(); + }); + + it.each([ + [[], "mdsync-relay"], + [["unrelated", "mdsync-relay-2"], "mdsync-relay"], + [["mdsync-relay"], "mdsync-relay-2"], + [["mdsync-relay", "mdsync-relay-2", "mdsync-relay-4"], "mdsync-relay-3"], + ] as const)("chooses the first free relay name from %j", (taken, name) => { + expect(freeRelayName(taken)).toBe(name); + }); + + it.each(["a", "0", "my-relay-2", "a".repeat(63)])( + "accepts the available worker name %s", + (name) => { + expect(relayNameError(name, [])).toBeNull(); + }, + ); + + it.each([ + "", + "Relay", + "my_relay", + "my relay", + "-relay", + "relay-", + "a.b", + "é", + "a".repeat(64), + ])("rejects the invalid worker name %s", (name) => { + expect(relayNameError(name, [])).toBe( + "Use lowercase letters, digits and dashes, up to 63.", + ); + }); + + it("rejects an existing worker name", () => { + expect(relayNameError("other", RELAYS.taken)).toBe( + "A worker with this name already exists.", + ); + }); +}); diff --git a/packages/plugin/tests/cloudflare/relay-deploy.test.ts b/packages/plugin/tests/cloudflare/relay-deploy.test.ts new file mode 100644 index 00000000..13ffedd4 --- /dev/null +++ b/packages/plugin/tests/cloudflare/relay-deploy.test.ts @@ -0,0 +1,490 @@ +import { + failure, + fakeCloudflare, + type Route, + readForm, +} from "@tests/helpers/fake-cloudflare"; +import { describe, expect, it, vi } from "vitest"; +import { + deployRelay, + type HttpRequest, + type RelayBundle, + workerNames, + workersSubdomain, + workerUrl, +} from "@/cloudflare"; + +const A = "/accounts/acc"; +const SCRIPT = `${A}/workers/scripts/mdsync-relay`; + +const BUNDLE: RelayBundle = { + name: "mdsync-relay", + version: "v-hash", + mainModule: "index.js", + worker: "export default {}", + compatibilityDate: "2026-09-14", + compatibilityFlags: [], + kvBindings: ["SHARE_TOKENS"], + durableObjects: [{ name: "HUB", className: "Hub" }], + migrations: [ + { tag: "v1", new_sqlite_classes: ["Hub"] }, + { tag: "v2", new_sqlite_classes: ["Link"] }, + ], + assets: { + binding: "ASSETS", + runWorkerFirst: ["/s/*"], + files: [ + { + path: "/viewer/index.html", + hash: "a".repeat(32), + size: 3, + type: "text/html", + text: "", + }, + { + path: "/viewer/viewer.js", + hash: "b".repeat(32), + size: 1, + type: "text/javascript", + text: ";", + }, + ], + }, +}; + +const DEPLOY: Route[] = [ + { + method: "PUT", + path: SCRIPT, + reply: () => ({ result: { id: "mdsync-relay" } }), + }, + { + method: "POST", + path: `${SCRIPT}/subdomain`, + reply: () => ({ result: { enabled: true } }), + }, +]; + +async function metadataOf(call: HttpRequest | undefined) { + if (!call) throw new Error("no script upload"); + const form = await readForm(call); + expect(await (form.get("index.js") as File).text()).toBe(BUNDLE.worker); + return JSON.parse(form.get("metadata") as string); +} + +describe("deployRelay", () => { + it("deploys a fresh relay: subdomain, namespace, files, worker and route", async () => { + let uploads = 0; + const cf = fakeCloudflare([ + { + method: "GET", + path: `${A}/workers/subdomain`, + reply: () => failure(10007, 404), + }, + { + method: "PUT", + path: `${A}/workers/subdomain`, + reply: (r) => ({ result: JSON.parse(r.body as string) }), + }, + { + method: "GET", + path: `${A}/workers/scripts`, + reply: () => ({ result: [] }), + }, + { + method: "GET", + path: /storage\/kv\/namespaces\?/, + reply: () => ({ result: [] }), + }, + { + method: "POST", + path: `${A}/storage/kv/namespaces`, + reply: () => ({ result: { id: "kv1" } }), + }, + { + method: "POST", + path: `${SCRIPT}/assets-upload-session`, + reply: () => ({ + result: { + jwt: "upload", + buckets: [["a".repeat(32)], ["b".repeat(32)]], + }, + }), + }, + { + method: "POST", + path: `${A}/workers/assets/upload?base64=true`, + reply: () => ({ + status: ++uploads === 2 ? 201 : 200, + result: uploads === 2 ? { jwt: "done" } : {}, + }), + }, + ...DEPLOY, + ]); + + const url = await deployRelay({ + api: cf.api, + accountId: "acc", + bundle: BUNDLE, + secret: "s3cret", + proposeSubdomain: () => "mdsync-x", + }); + + expect(url).toBe("https://mdsync-relay.mdsync-x.workers.dev"); + const kvCreate = cf.calls.find( + (c) => c.method === "POST" && c.url.endsWith("/kv/namespaces"), + ); + expect(JSON.parse(kvCreate?.body as string)).toEqual({ + title: "mdsync-relay-share-tokens", + }); + + const assetCalls = cf.calls.filter((c) => c.url.includes("/assets/upload")); + expect(assetCalls.map((c) => c.headers.Authorization)).toEqual([ + "Bearer upload", + "Bearer upload", + ]); + const first = await readForm(assetCalls[0] as HttpRequest); + expect(await (first.get("a".repeat(32)) as File).text()).toBe("PGI+"); + + const meta = await metadataOf( + cf.calls.find( + (c) => c.method === "PUT" && c.url.endsWith("mdsync-relay"), + ), + ); + expect(meta.bindings).toEqual([ + { type: "kv_namespace", name: "SHARE_TOKENS", namespace_id: "kv1" }, + { type: "durable_object_namespace", name: "HUB", class_name: "Hub" }, + { type: "assets", name: "ASSETS" }, + { type: "plain_text", name: "RELAY_VERSION", text: "v-hash" }, + { type: "secret_text", name: "RELAY_SECRET", text: "s3cret" }, + ]); + expect(meta.keep_bindings).toEqual(["secret_text"]); + expect(meta.migrations).toEqual({ + new_tag: "v2", + steps: [ + { new_sqlite_classes: ["Hub"] }, + { new_sqlite_classes: ["Link"] }, + ], + }); + expect(meta.assets).toEqual({ + jwt: "done", + config: { run_worker_first: ["/s/*"] }, + }); + expect(JSON.parse(cf.calls.at(-1)?.body as string)).toEqual({ + enabled: true, + previews_enabled: false, + }); + }); + + it("updates a deployed relay in place: its namespace, only new migrations, unchanged files", async () => { + const cf = fakeCloudflare([ + { + method: "GET", + path: `${A}/workers/subdomain`, + reply: () => ({ result: { subdomain: "me" } }), + }, + { + method: "GET", + path: `${A}/workers/scripts`, + reply: () => ({ + result: [{ id: "mdsync-relay", migration_tag: "v1" }], + }), + }, + { + method: "GET", + path: `${SCRIPT}/settings`, + reply: () => ({ + result: { + bindings: [ + { + type: "kv_namespace", + name: "SHARE_TOKENS", + namespace_id: "kv-old", + }, + ], + }, + }), + }, + { + method: "POST", + path: `${SCRIPT}/assets-upload-session`, + reply: () => ({ result: { jwt: "same", buckets: [] } }), + }, + ...DEPLOY, + ]); + + const url = await deployRelay({ + api: cf.api, + accountId: "acc", + bundle: BUNDLE, + secret: "s3cret", + proposeSubdomain: () => { + throw new Error("must not register a subdomain"); + }, + }); + + expect(url).toBe("https://mdsync-relay.me.workers.dev"); + expect( + cf + .paths() + .some( + (p) => p.includes("kv/namespaces") || p.includes("assets/upload"), + ), + ).toBe(false); + const meta = await metadataOf(cf.calls.find((c) => c.method === "PUT")); + expect(meta.bindings[0]).toEqual({ + type: "kv_namespace", + name: "SHARE_TOKENS", + namespace_id: "kv-old", + }); + expect(meta.migrations).toEqual({ + old_tag: "v1", + new_tag: "v2", + steps: [{ new_sqlite_classes: ["Link"] }], + }); + expect(meta.assets.jwt).toBe("same"); + }); + + it("proposes another workers.dev name when one is taken", async () => { + const names = ["taken", "free"]; + const cf = fakeCloudflare([ + { + method: "GET", + path: `${A}/workers/subdomain`, + reply: () => failure(10007, 404), + }, + { + method: "PUT", + path: `${A}/workers/subdomain`, + reply: (r) => + JSON.parse(r.body as string).subdomain === "taken" + ? failure(10031, 409) + : { result: { subdomain: "free" } }, + }, + { + method: "GET", + path: `${A}/workers/scripts`, + reply: () => ({ result: [] }), + }, + { + method: "GET", + path: /storage\/kv\/namespaces\?/, + reply: () => ({ + result: [{ id: "kv9", title: "mdsync-relay-share-tokens" }], + }), + }, + { + method: "POST", + path: `${SCRIPT}/assets-upload-session`, + reply: () => ({ result: { jwt: "j", buckets: [] } }), + }, + ...DEPLOY, + ]); + + const url = await deployRelay({ + api: cf.api, + accountId: "acc", + bundle: BUNDLE, + secret: "s", + proposeSubdomain: () => names.shift() ?? "none", + }); + + expect(url).toBe("https://mdsync-relay.free.workers.dev"); + expect(cf.paths()).not.toContain(`POST ${A}/storage/kv/namespaces`); + }); +}); + +const DEPLOYED: Route[] = [ + { + method: "GET", + path: `${A}/workers/subdomain`, + reply: () => ({ result: { subdomain: "me" } }), + }, + { + method: "GET", + path: `${A}/workers/scripts`, + reply: () => ({ result: [{ id: "mdsync-relay", migration_tag: "v2" }] }), + }, + { + method: "GET", + path: `${SCRIPT}/settings`, + reply: () => ({ + result: { + bindings: [ + { + type: "kv_namespace", + name: "SHARE_TOKENS", + namespace_id: "kv-old", + }, + ], + }, + }), + }, + { + method: "POST", + path: `${SCRIPT}/assets-upload-session`, + reply: () => ({ result: { jwt: "same", buckets: [] } }), + }, +]; + +describe("deployRelay onUploaded", () => { + it("awaits the hook with the URL right after the worker upload, before the route is switched on", async () => { + const cf = fakeCloudflare([...DEPLOYED, ...DEPLOY]); + const seen: string[][] = []; + let release: () => void = () => {}; + const onUploaded = vi.fn( + (_url: string) => + new Promise((resolve) => { + seen.push(cf.paths()); + release = resolve; + }), + ); + + const deploy = deployRelay({ + api: cf.api, + accountId: "acc", + bundle: BUNDLE, + secret: "s", + proposeSubdomain: () => "unused", + onUploaded, + }); + await vi.waitFor(() => expect(onUploaded).toHaveBeenCalled()); + const whileWaiting = cf.paths(); + release(); + await deploy; + + expect(onUploaded).toHaveBeenCalledWith( + "https://mdsync-relay.me.workers.dev", + ); + expect(seen[0]?.at(-1)).toBe(`PUT ${SCRIPT}`); + expect(whileWaiting).toEqual(seen[0]); + expect(cf.paths().at(-1)).toBe(`POST ${SCRIPT}/subdomain`); + }); + + it("has called the hook when a step after the upload fails", async () => { + const cf = fakeCloudflare([ + ...DEPLOYED, + { + method: "PUT", + path: SCRIPT, + reply: () => ({ result: { id: "mdsync-relay" } }), + }, + { + method: "POST", + path: `${SCRIPT}/subdomain`, + reply: () => failure(10000, 403), + }, + ]); + const onUploaded = vi.fn(async (_url: string) => {}); + + await expect( + deployRelay({ + api: cf.api, + accountId: "acc", + bundle: BUNDLE, + secret: "s", + proposeSubdomain: () => "unused", + onUploaded, + }), + ).rejects.toThrow(); + + expect(onUploaded).toHaveBeenCalledWith( + "https://mdsync-relay.me.workers.dev", + ); + }); + + it("does not call the hook when the worker upload fails", async () => { + const cf = fakeCloudflare([ + ...DEPLOYED, + { method: "PUT", path: SCRIPT, reply: () => failure(10021, 400) }, + ]); + const onUploaded = vi.fn(async (_url: string) => {}); + + await expect( + deployRelay({ + api: cf.api, + accountId: "acc", + bundle: BUNDLE, + secret: "s", + proposeSubdomain: () => "unused", + onUploaded, + }), + ).rejects.toThrow(); + + expect(onUploaded).not.toHaveBeenCalled(); + }); +}); + +describe("workerNames", () => { + it("lists the id of every script on the account", async () => { + const cf = fakeCloudflare([ + { + method: "GET", + path: `${A}/workers/scripts`, + reply: () => ({ + result: [{ id: "mdsync-relay", migration_tag: "v2" }, { id: "blog" }], + }), + }, + ]); + + expect(await workerNames(cf.api, "acc")).toEqual(["mdsync-relay", "blog"]); + }); + + it("is empty for an account without scripts", async () => { + const cf = fakeCloudflare([ + { + method: "GET", + path: `${A}/workers/scripts`, + reply: () => ({ result: [] }), + }, + ]); + + expect(await workerNames(cf.api, "acc")).toEqual([]); + }); +}); + +describe("workersSubdomain", () => { + it("returns the account's workers.dev name", async () => { + const cf = fakeCloudflare([ + { + method: "GET", + path: `${A}/workers/subdomain`, + reply: () => ({ result: { subdomain: "me" } }), + }, + ]); + + expect(await workersSubdomain(cf.api, "acc")).toBe("me"); + }); + + it("returns null before one is registered", async () => { + const cf = fakeCloudflare([ + { + method: "GET", + path: `${A}/workers/subdomain`, + reply: () => failure(10007, 404), + }, + ]); + + expect(await workersSubdomain(cf.api, "acc")).toBeNull(); + }); + + it("rethrows any other Cloudflare error", async () => { + const cf = fakeCloudflare([ + { + method: "GET", + path: `${A}/workers/subdomain`, + reply: () => failure(10000, 403), + }, + ]); + + await expect(workersSubdomain(cf.api, "acc")).rejects.toThrow(); + }); +}); + +describe("workerUrl", () => { + it("joins the script and account names under workers.dev", () => { + expect(workerUrl("mdsync-relay", "me")).toBe( + "https://mdsync-relay.me.workers.dev", + ); + }); +}); diff --git a/packages/plugin/tests/core/passphrase-manager.test.ts b/packages/plugin/tests/core/passphrase-manager.test.ts index 7bfbdd69..bafee0bc 100644 --- a/packages/plugin/tests/core/passphrase-manager.test.ts +++ b/packages/plugin/tests/core/passphrase-manager.test.ts @@ -2,12 +2,22 @@ import type { DataAdapter } from "obsidian"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { PassphraseManager } from "@/core/passphrase-manager"; -import { saveCachedPassphrase } from "@/crypto/passphrase-cache"; -import { DEFAULT_SETTINGS } from "@/settings/model"; -import type { ObjectStorage } from "@/storage"; -import { resolveContentKey } from "@/sync/keyfile"; +import { + loadCachedPassphrase, + saveCachedPassphrase, +} from "@/crypto/passphrase-cache"; +import { DEFAULT_SETTINGS, type MdsyncSettings } from "@/settings/model"; +import { defaultS3Config, type ObjectStorage } from "@/storage"; +import { + type Keyfile, + PassphraseRotatedError, + readKeyfile, + resolveContentKey, +} from "@/sync/keyfile"; -vi.mock("@/sync/keyfile", () => ({ +vi.mock("@/sync/keyfile", async (importOriginal) => ({ + ...(await importOriginal>()), + readKeyfile: vi.fn(), resolveContentKey: vi.fn(), rotatePassphrase: vi.fn(), })); @@ -35,6 +45,34 @@ function manager(answers: string[]): PassphraseManager { }); } +function configured(bucket: string): MdsyncSettings { + return { + ...DEFAULT_SETTINGS, + cachePassphrase: true, + storageConfigs: { + s3: { + ...defaultS3Config(), + endpoint: "https://s3.example.test", + bucket, + accessKeyId: "AK", + secretAccessKey: "SK", + }, + }, + }; +} + +function vaultManager( + settings: MdsyncSettings, + answers: string[] = [], +): PassphraseManager { + return new PassphraseManager( + async () => answers.shift() ?? null, + {} as DataAdapter, + ".obsidian", + settings, + ); +} + beforeEach(() => vi.clearAllMocks()); describe("PassphraseManager", () => { @@ -82,3 +120,138 @@ describe("PassphraseManager", () => { expect(saveCachedPassphrase).toHaveBeenCalledOnce(); }); }); + +describe("PassphraseManager.isUnlocked", () => { + it("is locked before any key is resolved", async () => { + const passphrase = vaultManager(configured("notes"), ["secret"]); + await passphrase.prompt(false); + + expect(passphrase.isUnlocked()).toBe(false); + }); + + it("is unlocked once the current storage's key is open", async () => { + vi.mocked(resolveContentKey).mockResolvedValue(resolved("key")); + const passphrase = vaultManager(configured("notes")); + + await passphrase.unlock("secret"); + + expect(passphrase.isUnlocked()).toBe(true); + }); + + it("is not unlocked by a key opened for another storage", async () => { + vi.mocked(resolveContentKey).mockResolvedValue(resolved("key")); + const settings = configured("notes"); + const passphrase = vaultManager(settings); + await passphrase.unlock("secret"); + + settings.storageConfigs = configured("other").storageConfigs; + + expect(passphrase.isUnlocked()).toBe(false); + }); +}); + +describe("PassphraseManager.vaultHasKey", () => { + it("is true when the remote holds a keyfile", async () => { + vi.mocked(readKeyfile).mockResolvedValue({ epoch: 1 } as Keyfile); + + expect(await vaultManager(configured("notes")).vaultHasKey()).toBe(true); + }); + + it("is false for a new vault without a keyfile", async () => { + vi.mocked(readKeyfile).mockResolvedValue(null); + + expect(await vaultManager(configured("notes")).vaultHasKey()).toBe(false); + }); +}); + +describe("PassphraseManager.unlock", () => { + it("opens the key with a given passphrase and caches it", async () => { + vi.mocked(resolveContentKey).mockResolvedValue(resolved("key")); + const passphrase = vaultManager(configured("notes")); + + expect(await passphrase.unlock("secret")).toBe(true); + + expect(passphrase.current()).toBe("secret"); + expect(vi.mocked(resolveContentKey).mock.calls[0]?.[1]).toBe("secret"); + expect(saveCachedPassphrase).toHaveBeenCalledOnce(); + }); + + it("restores the previous passphrase when the given one fails and rethrows", async () => { + const passphrase = vaultManager(configured("notes"), ["old"]); + await passphrase.prompt(false); + vi.mocked(resolveContentKey).mockRejectedValueOnce( + new PassphraseRotatedError(), + ); + + await expect(passphrase.unlock("new")).rejects.toBeInstanceOf( + PassphraseRotatedError, + ); + + expect(passphrase.current()).toBe("old"); + expect(passphrase.isUnlocked()).toBe(false); + expect(saveCachedPassphrase).not.toHaveBeenCalled(); + }); + + it("keeps the restored passphrase unverified, so it is not cached until it opens the key", async () => { + const passphrase = vaultManager(configured("notes"), ["old"]); + await passphrase.prompt(false); + vi.mocked(resolveContentKey).mockRejectedValueOnce(new Error("wrong")); + await expect(passphrase.unlock("new")).rejects.toThrow("wrong"); + + await passphrase.persistIfEnabled(); + + expect(saveCachedPassphrase).not.toHaveBeenCalled(); + }); + + it("keeps the open key of the previous passphrase when the given one fails", async () => { + vi.mocked(resolveContentKey).mockResolvedValueOnce(resolved("key")); + const passphrase = vaultManager(configured("notes")); + await passphrase.unlock("old"); + vi.mocked(resolveContentKey).mockRejectedValueOnce(new Error("wrong")); + + await expect(passphrase.unlock("new")).rejects.toThrow("wrong"); + + expect(passphrase.current()).toBe("old"); + expect(passphrase.isUnlocked()).toBe(true); + }); + + it("leaves no passphrase behind when the first one fails", async () => { + vi.mocked(resolveContentKey).mockRejectedValueOnce(new Error("wrong")); + const passphrase = vaultManager(configured("notes")); + + await expect(passphrase.unlock("typo")).rejects.toThrow("wrong"); + + expect(passphrase.has()).toBe(false); + }); + + it("uses the saved passphrase when none is given", async () => { + vi.mocked(loadCachedPassphrase).mockResolvedValueOnce("saved"); + vi.mocked(resolveContentKey).mockResolvedValue(resolved("key")); + const passphrase = vaultManager(configured("notes")); + + expect(await passphrase.unlock()).toBe(true); + + expect(passphrase.current()).toBe("saved"); + expect(passphrase.isUnlocked()).toBe(true); + }); + + it("uses the entered passphrase when none is given", async () => { + vi.mocked(resolveContentKey).mockResolvedValue(resolved("key")); + const passphrase = vaultManager(configured("notes"), ["typed"]); + await passphrase.prompt(false); + vi.mocked(loadCachedPassphrase).mockClear(); + + expect(await passphrase.unlock()).toBe(true); + + expect(vi.mocked(resolveContentKey).mock.calls[0]?.[1]).toBe("typed"); + expect(loadCachedPassphrase).not.toHaveBeenCalled(); + }); + + it("returns false without a passphrase to try", async () => { + const passphrase = vaultManager(configured("notes")); + + expect(await passphrase.unlock()).toBe(false); + + expect(resolveContentKey).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin/tests/helpers/fake-cloudflare.ts b/packages/plugin/tests/helpers/fake-cloudflare.ts new file mode 100644 index 00000000..fe7f1800 --- /dev/null +++ b/packages/plugin/tests/helpers/fake-cloudflare.ts @@ -0,0 +1,66 @@ +import { + type CloudflareApi, + cloudflareApi, + type HttpRequest, +} from "@/cloudflare"; + +const BASE = "https://api.cloudflare.com/client/v4"; + +export interface Reply { + status?: number; + result?: unknown; + errors?: Array<{ code: number; message: string }>; +} + +export interface Route { + method: HttpRequest["method"]; + path: string | RegExp; + reply: (request: HttpRequest) => Reply; +} + +export interface FakeCloudflare { + api: CloudflareApi; + calls: HttpRequest[]; + /** Path without the API base, query included. */ + paths: () => string[]; +} + +export function fakeCloudflare(routes: Route[], token = "tok"): FakeCloudflare { + const calls: HttpRequest[] = []; + const api = cloudflareApi(async (request) => { + calls.push(request); + const path = request.url.slice(BASE.length); + const route = routes.find( + (r) => + r.method === request.method && + (typeof r.path === "string" ? r.path === path : r.path.test(path)), + ); + const reply: Reply = route?.reply(request) ?? { + status: 404, + errors: [{ code: 0, message: `no route ${request.method} ${path}` }], + }; + return { + status: reply.status ?? 200, + text: JSON.stringify({ + success: !reply.errors, + result: reply.result ?? null, + errors: reply.errors ?? [], + }), + }; + }, token); + return { + api, + calls, + paths: () => calls.map((c) => `${c.method} ${c.url.slice(BASE.length)}`), + }; +} + +export function failure(code: number, status = 400): Reply { + return { status, errors: [{ code, message: `error ${code}` }] }; +} + +export async function readForm(request: HttpRequest): Promise { + return new Response(request.body, { + headers: { "content-type": request.headers["Content-Type"] ?? "" }, + }).formData(); +} diff --git a/packages/plugin/tests/links/record.test.ts b/packages/plugin/tests/links/record.test.ts index 4ad3501c..e914ea0c 100644 --- a/packages/plugin/tests/links/record.test.ts +++ b/packages/plugin/tests/links/record.test.ts @@ -200,4 +200,12 @@ describe("onRelay", () => { it("rejects an invalid record URL", () => { expect(onRelay({ ...record, url: "not a URL" }, relay)).toBe(false); }); + + it("rejects a remote relay reached over plain http", () => { + const plain = { ...relay, relayUrl: "http://relay.example" }; + + expect( + onRelay({ ...record, url: "http://relay.example/s/one" }, plain), + ).toBe(false); + }); }); diff --git a/packages/plugin/tests/settings/cloudflare-login.test.ts b/packages/plugin/tests/settings/cloudflare-login.test.ts new file mode 100644 index 00000000..3c28cac1 --- /dev/null +++ b/packages/plugin/tests/settings/cloudflare-login.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { cloudflareApi } from "@/cloudflare"; +import { cloudflareErrorMessage } from "@/settings/cloudflare-login"; + +async function failureOf(code: number): Promise { + const api = cloudflareApi( + async () => ({ + status: 403, + text: JSON.stringify({ + success: false, + errors: [{ code, message: `error ${code}` }], + }), + }), + "tok", + ); + return api.call("GET", "/x").catch((err: unknown) => err); +} + +describe("cloudflareErrorMessage", () => { + it("tells the person to recreate the token on an authentication error", async () => { + expect(cloudflareErrorMessage(await failureOf(10000))).toContain( + "Create it again", + ); + }); + + it("passes any other error through", async () => { + expect(cloudflareErrorMessage(await failureOf(10004))).toContain( + "error 10004", + ); + }); +}); diff --git a/packages/plugin/tests/settings/connection-test.test.ts b/packages/plugin/tests/settings/connection-test.test.ts new file mode 100644 index 00000000..544c5373 --- /dev/null +++ b/packages/plugin/tests/settings/connection-test.test.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { testRelay } from "@/settings/connection-test"; + +interface Reply { + status: number; + json?: () => unknown; + error?: Error; +} + +const requests: Array<{ url: string; headers?: Record }> = []; +let reply: Reply = { status: 200 }; + +vi.mock("obsidian", async (importOriginal) => ({ + ...(await importOriginal>()), + requestUrl: (params: { url: string; headers?: Record }) => { + requests.push(params); + if (reply.error) return Promise.reject(reply.error); + const { status, json } = reply; + return Promise.resolve({ + status, + get json() { + return json?.(); + }, + }); + }, +})); + +const RELAY = { relayUrl: "https://relay.example/", relaySecret: "s3cret" }; + +beforeEach(() => { + requests.length = 0; + reply = { status: 200 }; +}); + +describe("testRelay", () => { + it("asks the relay's status with the admin secret", async () => { + await testRelay(RELAY); + + expect(requests).toEqual([ + expect.objectContaining({ + url: "https://relay.example/status", + headers: { "X-Mdsync-Admin": "s3cret" }, + }), + ]); + }); + + it("returns the version the relay reports", async () => { + reply = { status: 200, json: () => ({ version: "v-hash" }) }; + + expect(await testRelay(RELAY)).toEqual({ + ok: true, + message: "Connected. The relay accepts this secret.", + version: "v-hash", + }); + }); + + it("returns a null version for a relay that reports none", async () => { + reply = { status: 200, json: () => ({}) }; + + const result = await testRelay(RELAY); + + expect(result.ok).toBe(true); + expect(result.version).toBeNull(); + }); + + it("fails with the relay's message on a non-200 answer", async () => { + reply = { status: 401, json: () => ({ message: "Wrong secret" }) }; + + expect(await testRelay(RELAY)).toEqual({ + ok: false, + message: "Relay error: Wrong secret", + }); + }); + + it("fails with the HTTP status when an HTML error page has no JSON", async () => { + reply = { + status: 404, + json: () => { + throw new SyntaxError("Unexpected token '<'"); + }, + }; + + expect(await testRelay(RELAY)).toEqual({ + ok: false, + message: "Relay error: HTTP 404", + }); + }); + + it("refuses to send the secret over plain http to a remote host", async () => { + const result = await testRelay({ + relayUrl: "http://relay.example", + relaySecret: "s3cret", + }); + + expect(result.ok).toBe(false); + expect(result.message).toContain("https://"); + expect(requests).toEqual([]); + }); + + it.each([ + "http://localhost:8787", + "http://127.0.0.1:8787/", + "http://[::1]:8787", + ])("reaches a local relay over http: %s", async (relayUrl) => { + await testRelay({ relayUrl, relaySecret: "s3cret" }); + + expect(requests).toHaveLength(1); + }); + + it("still connects when a 200 body is not JSON", async () => { + reply = { + status: 200, + json: () => { + throw new SyntaxError("Unexpected token '<'"); + }, + }; + + const result = await testRelay(RELAY); + + expect(result.ok).toBe(true); + expect(result.version).toBeNull(); + }); + + it("fails with the network error when the relay is unreachable", async () => { + reply = { status: 0, error: new Error("net::ERR_NAME_NOT_RESOLVED") }; + + const result = await testRelay(RELAY); + + expect(result.ok).toBe(false); + expect(result.message).toContain("ERR_NAME_NOT_RESOLVED"); + }); + + it("asks for both URL and secret before reaching out", async () => { + const result = await testRelay({ + relayUrl: RELAY.relayUrl, + relaySecret: "", + }); + + expect(result.ok).toBe(false); + expect(requests).toEqual([]); + }); +}); diff --git a/packages/plugin/tests/settings/model.test.ts b/packages/plugin/tests/settings/model.test.ts index 335b77ed..7c00aa7c 100644 --- a/packages/plugin/tests/settings/model.test.ts +++ b/packages/plugin/tests/settings/model.test.ts @@ -1,9 +1,44 @@ import { describe, expect, it } from "vitest"; import { AUTO_PUSH_SETTLE_MAX_SECONDS } from "@/constants"; -import { DEFAULT_SETTINGS, mergeSettings } from "@/settings/model"; +import { + DEFAULT_SETTINGS, + isRelayConfigured, + mergeSettings, +} from "@/settings/model"; import { defaultS3Config } from "@/storage"; import { EStorageBackend, type StorageAdapterConfig } from "@/storage/config"; +describe("isRelayConfigured", () => { + const secret = "s3cret"; + + it.each([ + "https://relay.example", + "http://localhost:8787", + "http://127.0.0.1:8787/", + "http://[::1]:8787", + ])("accepts %s", (relayUrl) => { + expect(isRelayConfigured({ relayUrl, relaySecret: secret })).toBe(true); + }); + + it("needs both the URL and the secret", () => { + expect(isRelayConfigured({ relayUrl: "", relaySecret: secret })).toBe( + false, + ); + expect( + isRelayConfigured({ relayUrl: "https://relay.example", relaySecret: "" }), + ).toBe(false); + }); + + it("refuses a remote relay that would take the secret over plain http", () => { + expect( + isRelayConfigured({ + relayUrl: "http://relay.example", + relaySecret: secret, + }), + ).toBe(false); + }); +}); + describe("mergeSettings", () => { it("requires opt-in for every configuration category on a fresh device", () => { expect(Object.values(mergeSettings(null).settingsSync)).toEqual([ diff --git a/packages/plugin/tests/settings/relay-section.test.ts b/packages/plugin/tests/settings/relay-section.test.ts index 5314c466..21ba3970 100644 --- a/packages/plugin/tests/settings/relay-section.test.ts +++ b/packages/plugin/tests/settings/relay-section.test.ts @@ -14,6 +14,9 @@ vi.mock("obsidian", () => ({ setDesc() { return this; } + addButton() { + return this; + } }, })); vi.mock("@/settings/fields", () => ({ diff --git a/packages/plugin/tests/settings/setup/element-methods.ts b/packages/plugin/tests/settings/setup/element-methods.ts new file mode 100644 index 00000000..ee232505 --- /dev/null +++ b/packages/plugin/tests/settings/setup/element-methods.ts @@ -0,0 +1,47 @@ +interface ElementInfo { + text?: string; + cls?: string; + attr?: Record; +} + +/** The HTMLElement helpers Obsidian adds, which jsdom lacks; only what the setup views use. */ +export function installElementMethods(): void { + const proto = HTMLElement.prototype as unknown as Record; + function createEl(this: HTMLElement, tag: string, info: ElementInfo = {}) { + const node = document.createElement(tag); + if (info.text !== undefined) node.textContent = info.text; + if (info.cls) node.className = info.cls; + for (const [key, value] of Object.entries(info.attr ?? {})) + node.setAttribute(key, value); + this.appendChild(node); + return node; + } + Object.assign(proto, { + createEl, + createDiv(this: HTMLElement, info?: ElementInfo) { + return createEl.call(this, "div", info); + }, + createSpan(this: HTMLElement, info?: ElementInfo) { + return createEl.call(this, "span", info); + }, + setAttr(this: HTMLElement, key: string, value: string) { + this.setAttribute(key, value); + }, + addClass(this: HTMLElement, cls: string) { + this.classList.add(cls); + }, + toggleClass(this: HTMLElement, cls: string, on: boolean) { + this.classList.toggle(cls, on); + }, + setText(this: HTMLElement, text: string) { + this.textContent = text; + }, + setCssProps(this: HTMLElement, props: Record) { + for (const [key, value] of Object.entries(props)) + this.style.setProperty(key, value); + }, + empty(this: HTMLElement) { + this.replaceChildren(); + }, + }); +} diff --git a/packages/plugin/tests/settings/setup/passphrase-meter.test.ts b/packages/plugin/tests/settings/setup/passphrase-meter.test.ts new file mode 100644 index 00000000..71280382 --- /dev/null +++ b/packages/plugin/tests/settings/setup/passphrase-meter.test.ts @@ -0,0 +1,60 @@ +// @vitest-environment jsdom + +import { installElementMethods } from "@tests/settings/setup/element-methods"; +import { describe, expect, it } from "vitest"; + +import { MIN_PASSPHRASE_LENGTH } from "@/crypto/constants"; +import { + METER_BARS, + passphraseMeter, + passphraseStrength, +} from "@/settings/setup/passphrase-meter"; + +installElementMethods(); + +const chars = (count: number): string => "a".repeat(count); + +describe("passphraseStrength", () => { + it("lights nothing for an empty passphrase", () => { + expect(passphraseStrength("").filled).toBe(0); + }); + + it("counts characters below the minimum", () => { + expect(passphraseStrength(chars(5))).toEqual({ + filled: 1, + label: `5 of ${MIN_PASSPHRASE_LENGTH} characters`, + }); + }); + + it("counts code points, as the minimum does", () => { + expect(passphraseStrength("😀".repeat(MIN_PASSPHRASE_LENGTH)).filled).toBe( + 2, + ); + }); + + it("grows with length once the minimum is met", () => { + const filled = [ + MIN_PASSPHRASE_LENGTH, + MIN_PASSPHRASE_LENGTH + 4, + MIN_PASSPHRASE_LENGTH + 10, + ].map((length) => passphraseStrength(chars(length)).filled); + expect(filled).toEqual([2, 3, METER_BARS]); + }); +}); + +describe("passphraseMeter", () => { + it("exposes the strength as a meter with a live label", () => { + const parent = document.createElement("div"); + const update = passphraseMeter(parent, ""); + const meter = parent.querySelector('[role="meter"]'); + expect(meter?.getAttribute("aria-valuemin")).toBe("0"); + expect(meter?.getAttribute("aria-valuemax")).toBe(String(METER_BARS)); + expect(meter?.getAttribute("aria-valuenow")).toBe("0"); + update(chars(MIN_PASSPHRASE_LENGTH + 10)); + expect(meter?.getAttribute("aria-valuenow")).toBe(String(METER_BARS)); + expect(meter?.getAttribute("aria-valuetext")).toBe("Strong"); + expect( + parent.querySelector(".mdsync-meter-label")?.getAttribute("aria-live"), + ).toBe("polite"); + }); +}); diff --git a/packages/plugin/tests/settings/setup/r2.test.ts b/packages/plugin/tests/settings/setup/r2.test.ts new file mode 100644 index 00000000..88560289 --- /dev/null +++ b/packages/plugin/tests/settings/setup/r2.test.ts @@ -0,0 +1,234 @@ +import { createHash } from "node:crypto"; +import { + failure, + fakeCloudflare, + type Reply, +} from "@tests/helpers/fake-cloudflare"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { R2NotEnabledError } from "@/cloudflare"; +import type { PluginHost } from "@/plugin/host"; +import * as cloudflare from "@/settings/cloudflare-login"; +import { mergeSettings } from "@/settings/model"; +import { useR2, vaultPrefix } from "@/settings/setup/r2"; +import { defaultS3Config, EStorageBackend } from "@/storage"; + +const BUCKETS = "/accounts/acc/r2/buckets"; +const VERIFY = "/accounts/acc/tokens/verify"; + +function host(name = "My Notes"): PluginHost { + return { + settings: mergeSettings({ + cloudflareToken: "token-value", + cloudflareAccountId: "acc", + activeStorageKind: EStorageBackend.WebDAV, + storageConfigs: { + [EStorageBackend.WebDAV]: { + kind: EStorageBackend.WebDAV, + baseUrl: "https://webdav.example", + basePath: "notes", + username: "user", + password: "password", + concurrency: 4, + }, + [EStorageBackend.S3]: { + ...defaultS3Config(), + bucket: "old-bucket", + prefix: "old-prefix", + accessKeyId: "old-key", + secretAccessKey: "old-secret", + }, + }, + }), + app: { vault: { getName: vi.fn(() => name) } }, + saveSettings: vi.fn(async () => {}), + scheduleScopeRefresh: vi.fn(), + } as unknown as PluginHost; +} + +function connected( + bucket: () => Reply = () => ({ result: { name: "mdsync-vaults" } }), + verify: () => Reply = () => ({ + result: { id: "token-id", status: "active" }, + }), +) { + const cf = fakeCloudflare( + [ + { method: "POST", path: BUCKETS, reply: bucket }, + { method: "GET", path: VERIFY, reply: verify }, + ], + "token-value", + ); + vi.spyOn(cloudflare, "cloudflareOf").mockReturnValue({ + api: cf.api, + accountId: "acc", + }); + return cf; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function hash8(name: string): string { + return createHash("sha256").update(name).digest("hex").slice(0, 8); +} + +describe("vaultPrefix", () => { + it.each([ + ["My Notes", "my-notes"], + [" Work___Notes!! ", "work-notes"], + ["A...B / C", "a-b-c"], + ["2026", "2026"], + ["笔记 42", "42"], + ["Café", "caf"], + ])("uses the Latin letters and digits in %s as %s", async (name, slug) => { + expect(await vaultPrefix(name)).toBe(`${slug}-${hash8(name)}`); + }); + + it.each(["笔记", "日本語", "📚", "---", "", " "])( + "falls back to vault when %j has no Latin letters or digits", + async (name) => { + expect(await vaultPrefix(name)).toBe(`vault-${hash8(name)}`); + }, + ); + + it("keeps names the slug folds together apart, and each one stable", async () => { + const first = await vaultPrefix("Work Notes"); + expect(await vaultPrefix("Work Notes")).toBe(first); + expect(await vaultPrefix("Work-Notes")).not.toBe(first); + expect(await vaultPrefix("日本語")).not.toBe(await vaultPrefix("笔记")); + }); +}); + +describe("useR2", () => { + it("creates the shared bucket and saves vault-specific S3 credentials before refreshing scope", async () => { + const plugin = host(); + const previousWebDAV = + plugin.settings.storageConfigs[EStorageBackend.WebDAV]; + const cf = connected(); + const expected = { + ...defaultS3Config(), + endpoint: "https://acc.r2.cloudflarestorage.com", + region: "auto", + bucket: "mdsync-vaults", + accessKeyId: "token-id", + secretAccessKey: createHash("sha256").update("token-value").digest("hex"), + prefix: `my-notes-${hash8("My Notes")}`, + }; + vi.mocked(plugin.saveSettings).mockImplementation(async () => { + expect(plugin.settings.storageConfigs[EStorageBackend.S3]).toEqual( + expected, + ); + expect(plugin.settings.activeStorageKind).toBe(EStorageBackend.S3); + expect(plugin.scheduleScopeRefresh).not.toHaveBeenCalled(); + }); + + await useR2(plugin); + + expect(cloudflare.cloudflareOf).toHaveBeenCalledWith(plugin.settings); + expect(cf.paths()).toEqual([`POST ${BUCKETS}`, `GET ${VERIFY}`]); + expect(JSON.parse(cf.calls[0]?.body as string)).toEqual({ + name: "mdsync-vaults", + }); + expect(cf.calls.map((call) => call.headers.Authorization)).toEqual([ + "Bearer token-value", + "Bearer token-value", + ]); + expect(plugin.settings.storageConfigs[EStorageBackend.S3]).toEqual( + expected, + ); + expect(plugin.settings.storageConfigs[EStorageBackend.WebDAV]).toBe( + previousWebDAV, + ); + expect(plugin.saveSettings).toHaveBeenCalledTimes(1); + expect(plugin.scheduleScopeRefresh).toHaveBeenCalledTimes(1); + expect(plugin.scheduleScopeRefresh).toHaveBeenCalledWith( + "Storage backend changed.", + ); + }); + + it("uses an already-owned bucket and a hashed prefix for a non-Latin vault", async () => { + const plugin = host("笔记"); + connected(() => failure(10004, 409)); + await useR2(plugin); + expect(plugin.settings.storageConfigs[EStorageBackend.S3]).toMatchObject({ + bucket: "mdsync-vaults", + prefix: `vault-${hash8("笔记")}`, + }); + expect(plugin.settings.activeStorageKind).toBe(EStorageBackend.S3); + expect(plugin.saveSettings).toHaveBeenCalledTimes(1); + }); + + it.each([ + { cloudflareToken: "", cloudflareAccountId: "acc" }, + { cloudflareToken: "token-value", cloudflareAccountId: "" }, + { cloudflareToken: "", cloudflareAccountId: "" }, + ])("requires both Cloudflare login fields: %j", async (login) => { + const plugin = host(); + Object.assign(plugin.settings, login); + const before = structuredClone(plugin.settings); + await expect(useR2(plugin)).rejects.toThrow( + "Connect your Cloudflare account first.", + ); + expect(plugin.settings).toEqual(before); + expect(plugin.saveSettings).not.toHaveBeenCalled(); + expect(plugin.scheduleScopeRefresh).not.toHaveBeenCalled(); + }); + + it("leaves storage unchanged when R2 is not enabled", async () => { + const plugin = host(); + const before = structuredClone(plugin.settings); + const cf = connected(() => failure(10042, 403)); + await expect(useR2(plugin)).rejects.toBeInstanceOf(R2NotEnabledError); + expect(cf.paths()).toEqual([`POST ${BUCKETS}`]); + expect(plugin.settings).toEqual(before); + expect(plugin.saveSettings).not.toHaveBeenCalled(); + expect(plugin.scheduleScopeRefresh).not.toHaveBeenCalled(); + }); + + it("leaves storage unchanged when bucket creation fails", async () => { + const plugin = host(); + const before = structuredClone(plugin.settings); + const cf = connected(() => failure(10005)); + await expect(useR2(plugin)).rejects.toThrow(/10005/); + expect(cf.paths()).toEqual([`POST ${BUCKETS}`]); + expect(plugin.settings).toEqual(before); + expect(plugin.saveSettings).not.toHaveBeenCalled(); + expect(plugin.scheduleScopeRefresh).not.toHaveBeenCalled(); + }); + + it("leaves storage unchanged when the token is expired", async () => { + const plugin = host(); + const before = structuredClone(plugin.settings); + const cf = connected(undefined, () => ({ + result: { id: "token-id", status: "expired" }, + })); + await expect(useR2(plugin)).rejects.toThrow( + "The Cloudflare token is expired.", + ); + expect(cf.paths()).toEqual([`POST ${BUCKETS}`, `GET ${VERIFY}`]); + expect(plugin.settings).toEqual(before); + expect(plugin.saveSettings).not.toHaveBeenCalled(); + expect(plugin.scheduleScopeRefresh).not.toHaveBeenCalled(); + }); + + it("leaves storage unchanged when token verification fails", async () => { + const plugin = host(); + const before = structuredClone(plugin.settings); + connected(undefined, () => failure(10005)); + await expect(useR2(plugin)).rejects.toThrow(/10005/); + expect(plugin.settings).toEqual(before); + expect(plugin.saveSettings).not.toHaveBeenCalled(); + expect(plugin.scheduleScopeRefresh).not.toHaveBeenCalled(); + }); + + it("does not refresh scope when saving the new storage fails", async () => { + const plugin = host(); + connected(); + vi.mocked(plugin.saveSettings).mockRejectedValueOnce( + new Error("save failed"), + ); + await expect(useR2(plugin)).rejects.toThrow("save failed"); + expect(plugin.scheduleScopeRefresh).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin/tests/settings/setup/relay-step.test.ts b/packages/plugin/tests/settings/setup/relay-step.test.ts new file mode 100644 index 00000000..95fc1e0d --- /dev/null +++ b/packages/plugin/tests/settings/setup/relay-step.test.ts @@ -0,0 +1,298 @@ +// @vitest-environment jsdom +import { installElementMethods } from "@tests/settings/setup/element-methods"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PluginHost } from "@/plugin/host"; +import { testRelay } from "@/settings/connection-test"; +import { mergeSettings } from "@/settings/model"; +import { + activateRelay, + deployOwnRelay, + failedRelay, + planRelay, + runningRelayDeploy, + useRelay, +} from "@/settings/setup/relay"; +import { createRelayStep } from "@/settings/setup/relay-step"; +import type { StepView, Wizard } from "@/settings/setup/wizard"; + +installElementMethods(); + +const buttons: Array<{ label: string; click: () => void }> = []; + +vi.mock("obsidian", async (original) => { + class Setting { + readonly settingEl: HTMLElement; + constructor(parent: HTMLElement) { + this.settingEl = parent.createDiv(); + } + setName() { + return this; + } + setDesc() { + return this; + } + addText(build: (text: unknown) => void) { + const inputEl = this.settingEl.createEl("input"); + const text = { + inputEl, + setPlaceholder: () => text, + setValue: (v: string) => { + inputEl.value = v; + return text; + }, + onChange: (cb: (v: string) => void) => { + inputEl.addEventListener("input", () => cb(inputEl.value)); + return text; + }, + }; + build(text); + return this; + } + addButton(build: (button: unknown) => void) { + const entry = { label: "", click: () => {} }; + const button = { + buttonEl: this.settingEl.createEl("button"), + setButtonText: (label: string) => { + entry.label = label; + return button; + }, + setCta: () => button, + setDisabled: () => button, + onClick: (cb: () => void) => { + entry.click = cb; + return button; + }, + }; + build(button); + buttons.push(entry); + return this; + } + } + return { + ...(await original()), + Setting, + }; +}); +vi.mock("@/settings/connection-test", () => ({ testRelay: vi.fn() })); +vi.mock("@/settings/cloudflare-login", () => ({ + cloudflareOf: () => ({ api: {}, accountId: "acc" }), + cloudflareErrorMessage: (err: Error) => err.message, + forgetCloudflare: vi.fn(), +})); +vi.mock("@/cloudflare", async (original) => ({ + ...(await original()), + accountRelays: vi.fn(async () => ({ taken: [], subdomain: "me" })), +})); +vi.mock("@/settings/setup/relay", async (original) => ({ + ...(await original()), + planRelay: vi.fn(async () => ({ deploy: { name: "r", secret: "s" } })), + deployOwnRelay: vi.fn(), + failedRelay: vi.fn(() => null), + runningRelayDeploy: vi.fn(() => null), + activateRelay: vi.fn(), + useRelay: vi.fn(async () => {}), +})); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (err: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function setup() { + const plugin = { + settings: mergeSettings(null), + saveSettings: vi.fn(async () => {}), + unloaded: false, + } as unknown as PluginHost & { unloaded: boolean }; + const el = document.createElement("div"); + document.body.replaceChildren(el); + let open = true; + let view: StepView | null = null; + const wizard = { + plugin, + isShowing: (v: StepView) => open && v === view, + isLive: () => open, + redraw: () => { + el.replaceChildren(); + buttons.length = 0; + if (open) view?.render(el); + }, + next: vi.fn(), + choose: vi.fn(), + close: () => { + open = false; + }, + } satisfies Wizard; + view = createRelayStep(wizard); + wizard.redraw(); + const card = (title: string) => + [...el.querySelectorAll(".mdsync-choice")] + .find((c) => c.textContent?.includes(title)) + ?.click(); + const press = (label: string) => + buttons.find((b) => b.label === label)?.click(); + return { plugin, wizard, view, el, card, press }; +} + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function enter(input: HTMLInputElement): void { + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(runningRelayDeploy).mockReturnValue(null); + vi.mocked(failedRelay).mockReturnValue(null); +}); + +describe("the manual relay form", () => { + function fill(el: HTMLElement): HTMLInputElement[] { + const inputs = [...el.querySelectorAll("input")]; + const [url, secret] = inputs; + if (!url || !secret) throw new Error("no form"); + url.value = "https://relay.example"; + url.dispatchEvent(new Event("input")); + secret.value = "typed"; + secret.dispatchEvent(new Event("input")); + return inputs; + } + + it.each([0, 1])("connects on Enter in input %i", async (at) => { + vi.mocked(testRelay).mockResolvedValue({ ok: true, message: "" }); + const { plugin, el, card, wizard } = setup(); + card("I already have a relay"); + const input = fill(el)[at]; + if (!input) throw new Error("no input"); + + enter(input); + await flush(); + + expect(plugin.settings).toMatchObject({ + relayUrl: "https://relay.example", + relaySecret: "typed", + }); + expect(useRelay).toHaveBeenCalledOnce(); + expect(wizard.next).toHaveBeenCalledOnce(); + }); + + it("leaves the settings alone when the wizard closed while the relay was tested", async () => { + const answer = deferred<{ ok: boolean; message: string }>(); + vi.mocked(testRelay).mockReturnValue(answer.promise); + const { plugin, el, card, wizard } = setup(); + card("I already have a relay"); + const [url] = fill(el); + if (!url) throw new Error("no input"); + + enter(url); + await flush(); + wizard.close(); + answer.resolve({ ok: true, message: "" }); + await flush(); + + expect(plugin.settings).toMatchObject({ relayUrl: "", relaySecret: "" }); + expect(useRelay).not.toHaveBeenCalled(); + }); +}); + +describe("deploying from the wizard", () => { + it("blocks Next while deploying and waiting, and frees it once the deploy failed", async () => { + const upload = deferred(); + vi.mocked(deployOwnRelay).mockReturnValue(upload.promise); + const { view, card } = setup(); + expect(view.blocksNext?.()).toBe(false); + + card("Deploy to my Cloudflare"); + await flush(); + expect(view.blocksNext?.()).toBe(true); + + upload.reject(new Error("lost")); + await flush(); + expect(view.blocksNext?.()).toBe(false); + }); + + it("frees Next when the relay stays silent", async () => { + vi.mocked(deployOwnRelay).mockResolvedValue(); + const silent = deferred(); + vi.mocked(activateRelay).mockReturnValue(silent.promise); + const { view, card } = setup(); + + card("Deploy to my Cloudflare"); + await flush(); + expect(view.blocksNext?.()).toBe(true); + + silent.resolve(false); + await flush(); + expect(view.blocksNext?.()).toBe(false); + }); + + it("retries the deploy that broke off in an earlier wizard", async () => { + const lost = { name: "mdsync-relay", secret: "kept" }; + vi.mocked(failedRelay).mockReturnValue(lost); + vi.mocked(deployOwnRelay).mockReturnValue(new Promise(() => {})); + const { card } = setup(); + + card("Deploy to my Cloudflare"); + await flush(); + + expect(failedRelay).toHaveBeenCalledWith("acc"); + expect(vi.mocked(planRelay).mock.calls[0]?.[2]).toEqual(lost); + }); + + it("hands the wait to activateRelay and stays put once the wizard closed", async () => { + vi.mocked(deployOwnRelay).mockResolvedValue(); + const answer = deferred(); + vi.mocked(activateRelay).mockReturnValue(answer.promise); + const { plugin, wizard, card } = setup(); + + card("Deploy to my Cloudflare"); + await flush(); + wizard.close(); + answer.resolve(true); + await flush(); + + expect(activateRelay).toHaveBeenCalledExactlyOnceWith(plugin); + expect(wizard.next).not.toHaveBeenCalled(); + }); + + it("ignores a remembered failed deploy once a relay is set", async () => { + vi.mocked(failedRelay).mockReturnValue({ name: "stale", secret: "old" }); + vi.mocked(deployOwnRelay).mockReturnValue(new Promise(() => {})); + const { plugin, card } = setup(); + Object.assign(plugin.settings, { + relayUrl: "https://mine.example", + relaySecret: "mine", + }); + + card("Deploy to my Cloudflare"); + await flush(); + + expect(vi.mocked(planRelay).mock.calls[0]?.[2]).toBeNull(); + }); + + it("follows a deploy still running from a closed wizard instead of failing", async () => { + const running = deferred(); + vi.mocked(runningRelayDeploy).mockReturnValue(running.promise); + vi.mocked(activateRelay).mockReturnValue(new Promise(() => {})); + const { el, view, card } = setup(); + + card("Deploy to my Cloudflare"); + await flush(); + expect(el.querySelector(".mod-warning")).toBeNull(); + expect(el.textContent).toContain("still deploying"); + expect(view.blocksNext?.()).toBe(true); + expect(deployOwnRelay).not.toHaveBeenCalled(); + + vi.mocked(runningRelayDeploy).mockReturnValue(null); + running.resolve(); + await flush(); + expect(activateRelay).toHaveBeenCalledOnce(); + expect(deployOwnRelay).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin/tests/settings/setup/relay.test.ts b/packages/plugin/tests/settings/setup/relay.test.ts new file mode 100644 index 00000000..c4826f5a --- /dev/null +++ b/packages/plugin/tests/settings/setup/relay.test.ts @@ -0,0 +1,423 @@ +import { + failure, + fakeCloudflare, + type Reply, + type Route, + readForm, +} from "@tests/helpers/fake-cloudflare"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ERelayDeployStep, RELAY_NAME, RELAY_VERSION } from "@/cloudflare"; +import type { PluginHost } from "@/plugin/host"; +import { testRelay } from "@/settings/connection-test"; +import { mergeSettings } from "@/settings/model"; +import { + activateRelay, + deployOwnRelay, + failedRelay, + planRelay, + runningRelayDeploy, + useRelay, + waitForRelay, +} from "@/settings/setup/relay"; + +vi.mock("@/settings/connection-test", () => ({ testRelay: vi.fn() })); + +const A = "/accounts/acc"; +const RELAYS = { taken: ["mdsync-relay", "other"], subdomain: "me" }; +const RELAY = { + relayUrl: "https://mdsync-relay.me.workers.dev", + relaySecret: "secret", +}; + +function host(): PluginHost { + return { + settings: mergeSettings(null), + saveSettings: vi.fn(async () => {}), + realtime: { hub: { restart: vi.fn() } }, + } as unknown as PluginHost; +} + +function deployRoutes( + name = RELAY_NAME, + upload: () => Reply = () => ({ result: { id: name } }), + route: () => Reply = () => ({ result: { enabled: true } }), +): Route[] { + const script = `${A}/workers/scripts/${name}`; + return [ + { + method: "GET", + path: `${A}/workers/subdomain`, + reply: () => ({ result: { subdomain: "me" } }), + }, + { + method: "GET", + path: `${A}/workers/scripts`, + reply: () => ({ result: [] }), + }, + { + method: "POST", + path: `${script}/assets-upload-session`, + reply: () => ({ result: { jwt: "done", buckets: [] } }), + }, + { method: "PUT", path: script, reply: upload }, + { method: "POST", path: `${script}/subdomain`, reply: route }, + ]; +} + +afterEach(() => { + vi.useRealTimers(); + vi.mocked(testRelay).mockReset(); +}); + +describe("planRelay", () => { + const NONE = { relayUrl: "", relaySecret: "" }; + + it("updates this device's relay with its saved secret when it answers", async () => { + vi.mocked(testRelay).mockResolvedValue({ ok: true, message: "" }); + + expect(await planRelay(RELAYS, RELAY)).toEqual({ + deploy: { name: RELAY_NAME, secret: "secret" }, + }); + }); + + it("asks for the secret when the saved one does not get an answer", async () => { + vi.mocked(testRelay).mockResolvedValue({ ok: false, message: "timeout" }); + + expect(await planRelay(RELAYS, RELAY)).toEqual({ ask: RELAY_NAME }); + }); + + it("repeats the deploy that just failed with its own secret, without asking", async () => { + const failed = { name: RELAY_NAME, secret: "chosen-here" }; + + expect(await planRelay(RELAYS, RELAY, failed)).toEqual({ deploy: failed }); + expect(testRelay).not.toHaveBeenCalled(); + }); + + it("deploys the default relay with a fresh secret on an account without one", async () => { + const plan = await planRelay({ taken: ["other"], subdomain: "me" }, NONE); + + if (!("deploy" in plan)) throw new Error("expected a deploy"); + expect(plan.deploy.name).toBe(RELAY_NAME); + expect(plan.deploy.secret).not.toBe(""); + expect(testRelay).not.toHaveBeenCalled(); + }); + + it("asks before touching a default-named worker this device does not know", async () => { + expect(await planRelay(RELAYS, NONE)).toEqual({ ask: RELAY_NAME }); + }); + + it("offers no update for a relay that is not on the account", async () => { + const elsewhere = { relayUrl: "https://relay.example", relaySecret: "s" }; + + expect(await planRelay(RELAYS, elsewhere)).toEqual({ ask: null }); + expect(testRelay).not.toHaveBeenCalled(); + }); +}); + +describe("deployOwnRelay", () => { + it("saves the chosen worker URL and secret after upload and before enabling its route", async () => { + const plugin = host(); + const name = "mdsync-relay-2"; + const cf = fakeCloudflare( + deployRoutes(name, undefined, () => { + expect(plugin.settings).toMatchObject({ + relayUrl: `https://${name}.me.workers.dev`, + relaySecret: "kept-secret", + }); + expect(plugin.saveSettings).toHaveBeenCalledTimes(1); + return { result: { enabled: true } }; + }), + ); + vi.mocked(plugin.saveSettings).mockImplementation(async () => { + expect(cf.paths().at(-1)).toBe(`PUT ${A}/workers/scripts/${name}`); + }); + const onStep = vi.fn(); + + await deployOwnRelay( + plugin, + { api: cf.api, accountId: "acc" }, + { name, secret: "kept-secret" }, + onStep, + ); + + expect(onStep.mock.calls.map(([step]) => step)).toEqual( + Object.values(ERelayDeployStep), + ); + expect(plugin.settings.realtimeSync).toBe(false); + expect(plugin.realtime.hub.restart).not.toHaveBeenCalled(); + const upload = cf.calls.find((call) => call.method === "PUT"); + if (!upload) throw new Error("no script upload"); + const form = await readForm(upload); + const metadata = JSON.parse(form.get("metadata") as string); + expect(metadata.bindings).toContainEqual({ + type: "secret_text", + name: "RELAY_SECRET", + text: "kept-secret", + }); + expect(metadata.bindings).toContainEqual({ + type: "plain_text", + name: "RELAY_VERSION", + text: "test-version", + }); + }); + + it("keeps the uploaded credentials when enabling the route fails and allows a retry", async () => { + const plugin = host(); + Object.assign(plugin.settings, { + relayUrl: "https://old.example", + relaySecret: "old-secret", + }); + let fail = true; + const cf = fakeCloudflare( + deployRoutes(RELAY_NAME, undefined, () => { + expect(plugin.settings).toMatchObject(RELAY); + expect(plugin.saveSettings).toHaveBeenCalled(); + return fail ? failure(10005) : { result: {} }; + }), + ); + const login = { api: cf.api, accountId: "acc" }; + const target = { name: RELAY_NAME, secret: RELAY.relaySecret }; + + await expect( + deployOwnRelay(plugin, login, target, vi.fn()), + ).rejects.toThrow(/10005/); + expect(plugin.settings).toMatchObject(RELAY); + expect(plugin.saveSettings).toHaveBeenCalledTimes(1); + fail = false; + await expect( + deployOwnRelay(plugin, login, target, vi.fn()), + ).resolves.toBeUndefined(); + }); + + it("leaves credentials unchanged when the worker upload fails and allows a retry", async () => { + const plugin = host(); + let fail = true; + const cf = fakeCloudflare( + deployRoutes(RELAY_NAME, () => (fail ? failure(10005) : { result: {} })), + ); + const login = { api: cf.api, accountId: "acc" }; + const target = { name: RELAY_NAME, secret: RELAY.relaySecret }; + + await expect( + deployOwnRelay(plugin, login, target, vi.fn()), + ).rejects.toThrow(/10005/); + expect(plugin.settings).toMatchObject({ relayUrl: "", relaySecret: "" }); + expect(plugin.saveSettings).not.toHaveBeenCalled(); + expect(cf.paths()).not.toContain( + `POST ${A}/workers/scripts/${RELAY_NAME}/subdomain`, + ); + fail = false; + await expect( + deployOwnRelay(plugin, login, target, vi.fn()), + ).resolves.toBeUndefined(); + }); + + it("keeps a broken-off deploy for its own account until one succeeds", async () => { + const plugin = host(); + let fail = true; + const cf = fakeCloudflare( + deployRoutes(RELAY_NAME, () => (fail ? failure(10005) : { result: {} })), + ); + const login = { api: cf.api, accountId: "acc" }; + const target = { name: RELAY_NAME, secret: "generated" }; + + await expect( + deployOwnRelay(plugin, login, target, vi.fn()), + ).rejects.toThrow(/10005/); + expect(failedRelay("acc")).toEqual(target); + expect(failedRelay("other")).toBeNull(); + expect(plugin.settings).toMatchObject({ relayUrl: "", relaySecret: "" }); + + fail = false; + await deployOwnRelay(plugin, login, target, vi.fn()); + expect(failedRelay("acc")).toBeNull(); + }); + + it("exposes the running deploy until it settles", async () => { + const cf = fakeCloudflare(deployRoutes()); + const login = { api: cf.api, accountId: "acc" }; + const running = deployOwnRelay( + host(), + login, + { name: RELAY_NAME, secret: "s" }, + vi.fn(), + ); + expect(runningRelayDeploy()).toBe(running); + await running; + expect(runningRelayDeploy()).toBeNull(); + }); + + it("refuses a concurrent deployment until the first deployment finishes saving", async () => { + const plugin = host(); + const cf = fakeCloudflare(deployRoutes()); + const login = { api: cf.api, accountId: "acc" }; + const target = { name: RELAY_NAME, secret: RELAY.relaySecret }; + let release!: () => void; + let uploaded!: () => void; + const saving = new Promise((resolve) => { + release = resolve; + }); + const reachedSave = new Promise((resolve) => { + uploaded = resolve; + }); + vi.mocked(plugin.saveSettings).mockImplementationOnce(async () => { + uploaded(); + await saving; + }); + const first = deployOwnRelay(plugin, login, target, vi.fn()); + try { + await reachedSave; + const calls = cf.calls.length; + const onStep = vi.fn(); + await expect( + deployOwnRelay(host(), login, target, onStep), + ).rejects.toThrow("The relay is still deploying. Wait a moment."); + expect(onStep).not.toHaveBeenCalled(); + expect(cf.calls).toHaveLength(calls); + expect(cf.paths()).not.toContain( + `POST ${A}/workers/scripts/${RELAY_NAME}/subdomain`, + ); + } finally { + release(); + await first; + } + await expect( + deployOwnRelay(plugin, login, target, vi.fn()), + ).resolves.toBeUndefined(); + }); +}); + +const KEEP_WAITING = () => true; + +describe("waitForRelay", () => { + it("returns immediately when the relay already runs the bundled version", async () => { + vi.useFakeTimers(); + vi.mocked(testRelay).mockResolvedValue({ + ok: true, + message: "ready", + version: RELAY_VERSION, + }); + expect(await waitForRelay(RELAY, KEEP_WAITING)).toBe(true); + expect(testRelay).toHaveBeenCalledTimes(1); + expect(testRelay).toHaveBeenCalledWith(RELAY); + expect(vi.getTimerCount()).toBe(0); + }); + + it("polls every three seconds through connection failures and stale versions until ready", async () => { + vi.useFakeTimers(); + vi.mocked(testRelay) + .mockResolvedValueOnce({ + ok: false, + message: "offline", + version: RELAY_VERSION, + }) + .mockResolvedValueOnce({ + ok: true, + message: "old", + version: "old-version", + }) + .mockResolvedValueOnce({ + ok: true, + message: "unversioned", + version: null, + }) + .mockResolvedValueOnce({ + ok: true, + message: "ready", + version: RELAY_VERSION, + }); + const waiting = waitForRelay(RELAY, KEEP_WAITING); + await vi.advanceTimersByTimeAsync(2999); + expect(testRelay).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(testRelay).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(6000); + expect(await waiting).toBe(true); + expect(testRelay).toHaveBeenCalledTimes(4); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each([ + { ok: false, message: "offline" }, + { ok: true, message: "stale", version: "old-version" }, + { ok: true, message: "unversioned" }, + ])( + "stops after three minutes when the relay stays $message", + async (status) => { + vi.useFakeTimers(); + vi.mocked(testRelay).mockResolvedValue(status); + const waiting = waitForRelay(RELAY, KEEP_WAITING); + await vi.advanceTimersByTimeAsync(180_000); + expect(await waiting).toBe(false); + expect(testRelay).toHaveBeenCalledTimes(61); + expect(vi.getTimerCount()).toBe(0); + }, + ); +}); + +describe("waitForRelay stopping", () => { + it("stops polling once told to, without another request", async () => { + vi.useFakeTimers(); + vi.mocked(testRelay).mockResolvedValue({ ok: false, message: "offline" }); + let open = true; + const waiting = waitForRelay(RELAY, () => open); + await vi.advanceTimersByTimeAsync(0); + expect(testRelay).toHaveBeenCalledTimes(1); + open = false; + await vi.advanceTimersByTimeAsync(3000); + expect(await waiting).toBe(false); + expect(testRelay).toHaveBeenCalledTimes(1); + }); +}); + +describe("activateRelay", () => { + it("waits once and switches the relay on once however many wizards ask", async () => { + vi.useFakeTimers(); + vi.mocked(testRelay).mockResolvedValue({ + ok: true, + message: "ready", + version: RELAY_VERSION, + }); + const plugin = host(); + + const answers = await Promise.all([ + activateRelay(plugin), + activateRelay(plugin), + ]); + + expect(answers).toEqual([true, true]); + expect(testRelay).toHaveBeenCalledTimes(1); + expect(plugin.saveSettings).toHaveBeenCalledTimes(1); + expect(plugin.realtime.hub.restart).toHaveBeenCalledTimes(1); + }); + + it("gives up without switching anything on once the plugin is unloaded", async () => { + const plugin = Object.assign(host(), { unloaded: true }); + + expect(await activateRelay(plugin)).toBe(false); + expect(testRelay).not.toHaveBeenCalled(); + expect(plugin.saveSettings).not.toHaveBeenCalled(); + }); +}); + +describe("useRelay", () => { + it("enables real-time sync and saves it before restarting the hub", async () => { + const plugin = host(); + vi.mocked(plugin.saveSettings).mockImplementation(async () => { + expect(plugin.settings.realtimeSync).toBe(true); + expect(plugin.realtime.hub.restart).not.toHaveBeenCalled(); + }); + await useRelay(plugin); + expect(plugin.saveSettings).toHaveBeenCalledTimes(1); + expect(plugin.realtime.hub.restart).toHaveBeenCalledTimes(1); + }); + + it("does not restart the hub when saving fails", async () => { + const plugin = host(); + vi.mocked(plugin.saveSettings).mockRejectedValueOnce( + new Error("save failed"), + ); + await expect(useRelay(plugin)).rejects.toThrow("save failed"); + expect(plugin.realtime.hub.restart).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin/tests/settings/setup/run-action.test.ts b/packages/plugin/tests/settings/setup/run-action.test.ts new file mode 100644 index 00000000..82a009b1 --- /dev/null +++ b/packages/plugin/tests/settings/setup/run-action.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; +import { runAction } from "@/settings/setup/run-action"; + +function setup() { + const wizard = { redraw: vi.fn() }; + const state = { busy: false, error: "stale" }; + return { wizard, state }; +} + +describe("runAction", () => { + it("is busy while the task runs and idle with a clean error after it succeeds", async () => { + const { wizard, state } = setup(); + let busyDuring = false; + + const done = await runAction(wizard, state, async () => { + busyDuring = state.busy; + }); + + expect(done).toBe(true); + expect(busyDuring).toBe(true); + expect(state).toEqual({ busy: false, error: "" }); + expect(wizard.redraw).toHaveBeenCalledTimes(2); + }); + + it("keeps the failure in state and redraws", async () => { + const { wizard, state } = setup(); + + const done = await runAction(wizard, state, async () => { + throw new Error("nope"); + }); + + expect(done).toBe(false); + expect(state).toEqual({ busy: false, error: "nope" }); + expect(wizard.redraw).toHaveBeenCalledTimes(2); + }); + + it("words a failure through describe", async () => { + const { wizard, state } = setup(); + + await runAction( + wizard, + state, + async () => { + throw new Error("raw"); + }, + (err) => `wrapped ${(err as Error).message}`, + ); + + expect(state.error).toBe("wrapped raw"); + }); + + it("ignores a second call while one runs", async () => { + const { wizard, state } = setup(); + let release = () => {}; + const task = vi.fn( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + + const first = runAction(wizard, state, task); + expect(await runAction(wizard, state, task)).toBe(false); + release(); + await first; + + expect(task).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/plugin/tests/settings/setup/stepper.test.ts b/packages/plugin/tests/settings/setup/stepper.test.ts new file mode 100644 index 00000000..1a8677d9 --- /dev/null +++ b/packages/plugin/tests/settings/setup/stepper.test.ts @@ -0,0 +1,84 @@ +// @vitest-environment jsdom + +import { installElementMethods } from "@tests/settings/setup/element-methods"; +import { describe, expect, it } from "vitest"; + +import { + createStepper, + EStepState, + stepStates, +} from "@/settings/setup/stepper"; + +installElementMethods(); + +const STEPS = ["storage", "passphrase", "relay", "ready"] as const; +const { Done, Current, Skipped, Ahead } = EStepState; + +describe("stepStates", () => { + it("marks the first step current and the rest ahead", () => { + expect(stepStates(STEPS, "storage", () => false)).toEqual([ + Current, + Ahead, + Ahead, + Ahead, + ]); + }); + + it("marks finished steps before the current one done", () => { + expect(stepStates(STEPS, "relay", () => true)).toEqual([ + Done, + Done, + Current, + Ahead, + ]); + }); + + it("marks a passed step that is not finished as skipped", () => { + expect(stepStates(STEPS, "ready", (step) => step !== "relay")).toEqual([ + Done, + Done, + Skipped, + Current, + ]); + }); + + it("does not mark a step ahead done, even when its work is already there", () => { + expect(stepStates(STEPS, "storage", () => true)).toEqual([ + Current, + Ahead, + Ahead, + Ahead, + ]); + }); + + it("puts every step ahead when the current one is not on the path", () => { + expect(stepStates(STEPS, "start" as never, () => true)).toEqual([ + Ahead, + Ahead, + Ahead, + Ahead, + ]); + }); +}); + +describe("createStepper", () => { + it("names the list and speaks each step's state", () => { + const parent = document.createElement("div"); + const stepper = createStepper(parent); + stepper.update( + [Done, Current, Ahead].map((state, at) => ({ + id: String(at), + label: `Step ${at}`, + icon: "dot", + state, + })), + ); + const list = parent.querySelector("ol"); + expect(list?.getAttribute("aria-label")).toBe("Setup steps"); + const [done, current, ahead] = Array.from(list?.children ?? []); + expect(done?.getAttribute("aria-label")).toBe("Step 0, done"); + expect(current?.getAttribute("aria-current")).toBe("step"); + expect(current?.hasAttribute("aria-label")).toBe(false); + expect(ahead?.getAttribute("aria-label")).toBe("Step 2, upcoming"); + }); +}); diff --git a/packages/plugin/tests/settings/setup/steps.test.ts b/packages/plugin/tests/settings/setup/steps.test.ts new file mode 100644 index 00000000..0c63fc9d --- /dev/null +++ b/packages/plugin/tests/settings/setup/steps.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PluginHost } from "@/plugin/host"; +import { type MdsyncSettings, mergeSettings } from "@/settings/model"; +import { + ESetupPath, + ESetupStep, + firstStep, + isStepDone, + PATH_STEPS, +} from "@/settings/setup/steps"; +import type { SpaceRecord } from "@/spaces/record"; +import { defaultS3Config, EStorageBackend } from "@/storage"; + +const STORAGE = { + storageConfigs: { + [EStorageBackend.S3]: { + ...defaultS3Config(), + bucket: "vault", + accessKeyId: "key", + secretAccessKey: "secret", + }, + }, +}; +const RELAY = { relayUrl: "https://relay.example", relaySecret: "secret" }; + +function host( + settings: Partial = {}, + unlocked = false, + has = false, +): PluginHost { + return { + settings: mergeSettings(settings), + passphrase: { + isUnlocked: vi.fn(() => unlocked), + has: vi.fn(() => has), + }, + } as unknown as PluginHost; +} + +describe("setup paths", () => { + it("sets up storage and passphrase on a new device but imports them on a joining device", () => { + expect(PATH_STEPS[ESetupPath.New]).toEqual([ + ESetupStep.Storage, + ESetupStep.Passphrase, + ESetupStep.Relay, + ESetupStep.Ready, + ]); + expect(PATH_STEPS[ESetupPath.Join]).toEqual([ + ESetupStep.Import, + ESetupStep.Relay, + ESetupStep.Ready, + ]); + }); +}); + +describe("isStepDone", () => { + it("requires the active storage backend to be configured", () => { + expect(isStepDone(host(), ESetupStep.Storage)).toBe(false); + expect(isStepDone(host(STORAGE), ESetupStep.Storage)).toBe(true); + const plugin = host(STORAGE); + plugin.settings.storageConfigs[EStorageBackend.WebDAV] = { + kind: EStorageBackend.WebDAV, + baseUrl: "", + basePath: "", + username: "", + password: "", + concurrency: 4, + }; + plugin.settings.activeStorageKind = EStorageBackend.WebDAV; + expect(isStepDone(plugin, ESetupStep.Storage)).toBe(false); + }); + + it.each([ + [false, false, false], + [false, true, false], + [true, false, true], + [true, true, true], + ] as const)( + "requires an unlocked passphrase, not just a saved one: unlocked=%s, has=%s", + (unlocked, has, done) => { + expect(isStepDone(host({}, unlocked, has), ESetupStep.Passphrase)).toBe( + done, + ); + }, + ); + + it.each([ + [false, false, false], + [false, true, false], + [true, false, false], + [true, true, true], + ] as const)( + "requires both sync access and a passphrase for import: storage=%s, has=%s", + (storage, has, done) => { + const plugin = host(storage ? STORAGE : {}, false, has); + expect(isStepDone(plugin, ESetupStep.Import)).toBe(done); + }, + ); + + it("accepts an imported participant share without vault storage", () => { + const plugin = host({}, false, true); + const share: SpaceRecord = { + id: "share", + name: "Team", + root: "Team", + rev: 1, + author: "owner", + key: "key", + access: { + kind: "participant", + relayUrl: "https://relay.example", + token: "token", + participantId: "participant", + personName: "Guest", + }, + }; + plugin.settings.spaces = [share]; + expect(isStepDone(plugin, ESetupStep.Import)).toBe(true); + expect(isStepDone(plugin, ESetupStep.Storage)).toBe(false); + share.closed = true; + expect(isStepDone(plugin, ESetupStep.Import)).toBe(false); + }); + + it.each([ + ["", "", false], + [RELAY.relayUrl, "", false], + ["", RELAY.relaySecret, false], + [RELAY.relayUrl, RELAY.relaySecret, true], + ] as const)( + "requires both relay URL and secret: URL=%s, secret=%s", + (relayUrl, relaySecret, done) => { + const plugin = host({ relayUrl, relaySecret, realtimeSync: false }); + expect(isStepDone(plugin, ESetupStep.Relay)).toBe(done); + }, + ); + + it.each([ESetupStep.Start, ESetupStep.Ready])( + "needs no configuration for %s", + (step) => { + expect(isStepDone(host(), step)).toBe(true); + }, + ); +}); + +describe("firstStep", () => { + it("opens Start without storage even when the passphrase and relay are ready", () => { + expect(firstStep(host())).toBe(ESetupStep.Start); + expect(firstStep(host(RELAY, true, true))).toBe(ESetupStep.Start); + }); + + it("opens Passphrase after storage until the passphrase is unlocked", () => { + expect(firstStep(host(STORAGE))).toBe(ESetupStep.Passphrase); + expect(firstStep(host({ ...STORAGE, ...RELAY }, false, true))).toBe( + ESetupStep.Passphrase, + ); + }); + + it("opens Relay after storage and an unlocked passphrase", () => { + expect(firstStep(host(STORAGE, true, true))).toBe(ESetupStep.Relay); + expect( + firstStep(host({ ...STORAGE, relayUrl: RELAY.relayUrl }, true, true)), + ).toBe(ESetupStep.Relay); + }); + + it("opens Ready with storage, an unlocked passphrase and relay credentials", () => { + expect(firstStep(host({ ...STORAGE, ...RELAY }, true, true))).toBe( + ESetupStep.Ready, + ); + }); + + it("recomputes the opening step as settings and passphrase state change", () => { + const plugin = host(); + expect(firstStep(plugin)).toBe(ESetupStep.Start); + plugin.settings.storageConfigs = STORAGE.storageConfigs; + expect(firstStep(plugin)).toBe(ESetupStep.Passphrase); + vi.mocked(plugin.passphrase.isUnlocked).mockReturnValue(true); + expect(firstStep(plugin)).toBe(ESetupStep.Relay); + Object.assign(plugin.settings, RELAY); + expect(firstStep(plugin)).toBe(ESetupStep.Ready); + vi.mocked(plugin.passphrase.isUnlocked).mockReturnValue(false); + expect(firstStep(plugin)).toBe(ESetupStep.Passphrase); + }); +}); diff --git a/packages/plugin/tests/settings/transfer-controller.test.ts b/packages/plugin/tests/settings/transfer-controller.test.ts index 9d00c713..99c5592a 100644 --- a/packages/plugin/tests/settings/transfer-controller.test.ts +++ b/packages/plugin/tests/settings/transfer-controller.test.ts @@ -4,7 +4,10 @@ import { describe, expect, it, vi } from "vitest"; import { PassphraseManager } from "@/core/passphrase-manager"; import type { LinkRecord } from "@/links"; import { type MdsyncSettings, mergeSettings } from "@/settings/model"; -import { createSettingsTransferUrl } from "@/settings/transfer"; +import { + createSettingsTransferUrl, + ESettingsTransferStorageMode, +} from "@/settings/transfer"; import { SettingsTransferController } from "@/settings/transfer-controller"; import { defaultS3Config } from "@/storage"; import { confirmSettingsTransferImport } from "@/ui"; @@ -119,3 +122,86 @@ describe("settings import", () => { manager.dispose(); }); }); + +describe("settings import with a typed passphrase", () => { + const SOURCE_S3 = { + ...defaultS3Config(), + endpoint: "https://s3.example.test", + bucket: "notes", + prefix: "vault/", + accessKeyId: "AK", + secretAccessKey: "S".repeat(44), + }; + + function setup() { + vi.mocked(confirmSettingsTransferImport).mockClear(); + const settings = mergeSettings({}); + const manager = new PassphraseManager( + async () => null, + new InMemoryAdapter().asDataAdapter(), + ".obsidian", + settings, + ); + const saveSettings = vi.fn(async () => {}); + const onSettingsReplaced = vi.fn(); + const transfer = new SettingsTransferController({ + app: {} as App, + settings, + passphrase: manager, + saveSettings, + onSettingsReplaced, + }); + return { settings, manager, saveSettings, onSettingsReplaced, transfer }; + } + + it("applies the link without asking, saves it and adopts the passphrase", async () => { + const token = await createSettingsTransferUrl( + mergeSettings({ storageConfigs: { s3: SOURCE_S3 } }), + "pass", + ); + const { settings, manager, saveSettings, onSettingsReplaced, transfer } = + setup(); + + await transfer.importWith(token, "pass"); + + expect(settings.storageConfigs.s3).toEqual(SOURCE_S3); + expect(saveSettings).toHaveBeenCalledOnce(); + expect(onSettingsReplaced).toHaveBeenCalledOnce(); + expect(manager.current()).toBe("pass"); + expect(confirmSettingsTransferImport).not.toHaveBeenCalled(); + manager.dispose(); + }); + + it("keeps this device's passphrase when the link carries no storage", async () => { + const token = await createSettingsTransferUrl( + mergeSettings({ autoSyncEnabled: true }), + "other", + { storageMode: ESettingsTransferStorageMode.None }, + ); + const { manager, transfer } = setup(); + await manager.replacePassphrase("mine"); + + await transfer.importWith(token, "other"); + + expect(manager.current()).toBe("mine"); + manager.dispose(); + }); + + it("rejects a wrong passphrase and leaves the settings untouched", async () => { + const token = await createSettingsTransferUrl( + mergeSettings({ storageConfigs: { s3: SOURCE_S3 } }), + "pass", + ); + const { settings, manager, saveSettings, onSettingsReplaced, transfer } = + setup(); + const before = JSON.parse(JSON.stringify(settings)); + + await expect(transfer.importWith(token, "wrong")).rejects.toThrow(); + + expect(JSON.parse(JSON.stringify(settings))).toEqual(before); + expect(saveSettings).not.toHaveBeenCalled(); + expect(onSettingsReplaced).not.toHaveBeenCalled(); + expect(manager.has()).toBe(false); + manager.dispose(); + }); +}); diff --git a/packages/plugin/tests/spaces/invite.test.ts b/packages/plugin/tests/spaces/invite.test.ts index 34baa7e9..77366270 100644 --- a/packages/plugin/tests/spaces/invite.test.ts +++ b/packages/plugin/tests/spaces/invite.test.ts @@ -83,7 +83,7 @@ describe("share invites", () => { it("stay shut to another password and to links that are not invites", async () => { const link = await inviteLink(INVITE, "right"); - await expect(readInvite(link, "wrong")).rejects.toThrow(); + await expect(readInvite(link, "wrong")).rejects.toThrow("wrong passphrase"); const settings = await sealLink(new TextEncoder().encode("{}"), "right"); await expect(readInvite(settings, "right")).rejects.toThrow( diff --git a/packages/plugin/tests/stubs/relay-bundle.ts b/packages/plugin/tests/stubs/relay-bundle.ts new file mode 100644 index 00000000..0dcf72b6 --- /dev/null +++ b/packages/plugin/tests/stubs/relay-bundle.ts @@ -0,0 +1,16 @@ +import type { RelayBundle } from "@/cloudflare/bundle"; + +const bundle: RelayBundle = { + name: "mdsync-relay", + version: "test-version", + mainModule: "index.js", + worker: "export default {};", + compatibilityDate: "2025-01-01", + compatibilityFlags: [], + kvBindings: [], + durableObjects: [], + migrations: [], + assets: { binding: "ASSETS", files: [] }, +}; + +export default bundle; diff --git a/packages/plugin/vitest.config.ts b/packages/plugin/vitest.config.ts index fd7d5cf3..4881ed8a 100644 --- a/packages/plugin/vitest.config.ts +++ b/packages/plugin/vitest.config.ts @@ -9,6 +9,9 @@ export default defineConfig({ obsidian: fileURLToPath( new URL("./tests/stubs/obsidian.ts", import.meta.url), ), + "mdsync:relay-bundle": fileURLToPath( + new URL("./tests/stubs/relay-bundle.ts", import.meta.url), + ), }, }, test: { diff --git a/packages/relay/bundle.mjs b/packages/relay/bundle.mjs new file mode 100644 index 00000000..dd7432c4 --- /dev/null +++ b/packages/relay/bundle.mjs @@ -0,0 +1,82 @@ +// Builds the relay as one JSON the plugin can upload through the Cloudflare API (see RelayBundle). +import { execSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { extname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { unstable_readConfig } from "wrangler"; + +const root = fileURLToPath(new URL(".", import.meta.url)); +const out = join(root, "dist"); +const workerDir = join(out, "worker"); + +// What wrangler serves these as. Text only: the plugin carries the files readable, not as opaque blobs. +const TYPES = { + ".html": "text/html", + ".js": "text/javascript", + ".css": "text/css", + ".svg": "image/svg+xml", +}; + +const config = unstable_readConfig({ config: join(root, "wrangler.toml") }); +// Through the shell: pnpm is a .cmd shim on Windows. +execSync("pnpm exec wrangler deploy --dry-run --outdir dist/worker", { + cwd: root, + stdio: "inherit", +}); + +const assetsDir = resolve(root, config.assets.directory); +const files = walk(assetsDir).map((path) => { + const ext = extname(path); + const type = TYPES[ext]; + if (!type) throw new Error(`No text type for relay asset ${path}`); + const bytes = readFileSync(path); + return { + path: `/${relative(assetsDir, path).split("\\").join("/")}`, + // Cloudflare skips a hash it holds and keeps that file's type, so the type is hashed too. + hash: sha256(`${type}\n${bytes.toString("base64")}`).slice(0, 32), + size: bytes.length, + type, + text: bytes.toString("utf8"), + }; +}); + +const content = { + name: config.name, + mainModule: "index.js", + worker: readFileSync(join(workerDir, "index.js"), "utf8"), + compatibilityDate: config.compatibility_date, + compatibilityFlags: config.compatibility_flags, + kvBindings: config.kv_namespaces.map((kv) => kv.binding), + durableObjects: config.durable_objects.bindings.map((d) => ({ + name: d.name, + className: d.class_name, + })), + migrations: config.migrations, + assets: { + binding: config.assets.binding, + runWorkerFirst: config.assets.run_worker_first, + files, + }, +}; +const bundle = { + ...content, + version: sha256(JSON.stringify(content)).slice(0, 12), +}; + +mkdirSync(out, { recursive: true }); +writeFileSync(join(out, "relay-bundle.json"), JSON.stringify(bundle)); +console.log( + `relay-bundle.json: ${bundle.version}, worker ${bundle.worker.length} B, ${files.length} assets`, +); + +function sha256(data) { + return createHash("sha256").update(data).digest("hex"); +} + +function walk(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + return entry.isDirectory() ? walk(path) : [path]; + }); +} diff --git a/packages/relay/package.json b/packages/relay/package.json index 14336f55..f4274b9e 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "pnpm --filter mdsync-viewer run build && wrangler dev", "deploy": "pnpm --filter mdsync-viewer run build && wrangler deploy", + "bundle": "pnpm --filter mdsync-viewer run build && node bundle.mjs", "typecheck": "tsc -noEmit", "test": "vitest run --passWithNoTests" }, diff --git a/packages/relay/src/index.ts b/packages/relay/src/index.ts index fb945b6a..469ae0f6 100644 --- a/packages/relay/src/index.ts +++ b/packages/relay/src/index.ts @@ -13,7 +13,10 @@ import { ADMIN_HEADER, isAdmin } from "./secret"; import { handleShareRequest } from "./share/broker"; import type { ShareEnv } from "./share/kv"; -export interface Env extends ShareEnv, GoogleOAuthEnv, LinkEnv, ShellEnv {} +export interface Env extends ShareEnv, GoogleOAuthEnv, LinkEnv, ShellEnv { + /** Set by the plugin's deploy only; a relay from the workflow has none. */ + RELAY_VERSION?: string; +} export { Hub, Link }; @@ -67,5 +70,5 @@ async function status(request: Request, env: Env): Promise { { status: 401 }, ); } - return Response.json({ ok: true }); + return Response.json({ ok: true, version: env.RELAY_VERSION ?? null }); } diff --git a/packages/relay/tests/hub/access.test.ts b/packages/relay/tests/hub/access.test.ts index d382a02b..c1719ac9 100644 --- a/packages/relay/tests/hub/access.test.ts +++ b/packages/relay/tests/hub/access.test.ts @@ -303,7 +303,15 @@ describe("hub routing", () => { const status = (secret: string, env = makeEnv().env) => call("/status", env, { headers: { "X-Mdsync-Admin": secret } }); - expect((await status(SECRET)).status).toBe(200); + expect(await (await status(SECRET)).json()).toEqual({ + ok: true, + version: null, + }); + const deployed = { ...makeEnv().env, RELAY_VERSION: "abc123" }; + expect(await (await status(SECRET, deployed)).json()).toEqual({ + ok: true, + version: "abc123", + }); expect((await status("wrong")).status).toBe(401); expect((await status("", makeEnv(new FakeKV(), null).env)).status).toBe( 401, diff --git a/tests/e2e/device.ts b/tests/e2e/device.ts index b4a0617f..2297f96a 100644 --- a/tests/e2e/device.ts +++ b/tests/e2e/device.ts @@ -124,7 +124,9 @@ export function fill( (row) => row.querySelector(".setting-item-name")?.textContent === label, ) - ?.querySelector("input"); + ?.querySelector( + "input, textarea", + ); if (input) { input.value = text; input.dispatchEvent(new Event("input")); @@ -145,7 +147,7 @@ export function fill( ); } -/** Clicks the open modal's button with `text`, once it shows up. */ +/** Clicks the open modal's button, or choice card, with `text`, once it shows up. */ export function press(device: Obsidian, text: string): Promise { return device.evaluate( ({ label, wait }) => @@ -154,7 +156,12 @@ export function press(device: Obsidian, text: string): Promise { const look = () => { const button = [...activeDocument.querySelectorAll(".modal button")] .reverse() - .find((each) => each.textContent === label); + .find( + (each) => + each.textContent === label || + each.querySelector(".mdsync-choice-title")?.firstChild + ?.textContent === label, + ); if (button instanceof HTMLButtonElement) { button.click(); return resolve(); diff --git a/tests/e2e/obsidian.ts b/tests/e2e/obsidian.ts index 79b6ba77..06f37bdc 100644 --- a/tests/e2e/obsidian.ts +++ b/tests/e2e/obsidian.ts @@ -50,8 +50,8 @@ export interface Obsidian { export async function launchObsidian(options: { port: number; - /** The plugin's data.json. */ - settings: object; + /** The plugin's data.json; null for a fresh install without one. */ + settings: object | null; files?: Record; /** Other community plugins' folders, each named by its plugin id. */ plugins?: string[]; @@ -65,7 +65,9 @@ export async function launchObsidian(options: { for (const file of PLUGIN_FILES) { copyFileSync(join(REPO, file), join(pluginDir, basename(file))); } - writeJson(join(pluginDir, "data.json"), options.settings); + if (options.settings) { + writeJson(join(pluginDir, "data.json"), options.settings); + } const others = (options.plugins ?? []).map((dir) => { cpSync(dir, join(vault, ".obsidian", "plugins", basename(dir)), { recursive: true, diff --git a/tests/e2e/setup.ts b/tests/e2e/setup.ts new file mode 100644 index 00000000..4e81c184 --- /dev/null +++ b/tests/e2e/setup.ts @@ -0,0 +1,197 @@ +/** A fresh install opens the setup wizard; one device starts a vault through it, a second joins by its link. */ + +import { fill, loaded, press, read } from "./device"; +import { check, runScenario, sleep } from "./harness"; +import { launchObsidian, type Obsidian } from "./obsidian"; +import { startS3 } from "./s3"; + +// biome-ignore lint/suspicious/noExplicitAny: the renderer's app is untyped here. +declare const app: any; +declare const activeDocument: Document; + +const CDP_PORTS = [9241, 9242]; +const S3_PORT = 8841; +const PASSPHRASE = "correct horse battery staple"; +const NOTE = "note.md"; + +await runScenario("setup wizard e2e", async () => { + const s3 = await startS3(S3_PORT); + const devices: Obsidian[] = []; + try { + const first = await launchObsidian({ + port: CDP_PORTS[0] as number, + settings: null, + files: { [NOTE]: "from the first device\n" }, + }); + devices.push(first); + await loaded(first); + await wizardShown(first); + await settled(first, "setup-start"); + + await press(first, "Start syncing this vault"); + await settled(first, "setup-storage"); + await press(first, "Other storage"); + await settled(first, "setup-storage-other"); + await fill(first, "Endpoint", s3.url); + await fill(first, "Region", "auto"); + await fill(first, "Bucket", s3.bucket); + await fill(first, "Prefix", "vault"); + await fill(first, "Access key ID", "e2e"); + await fill(first, "Secret access key", "e2e"); + await press(first, "Continue"); + + await settled(first, "setup-passphrase"); + await fill(first, "Passphrase", "short"); + await fill(first, "Confirm passphrase", "short"); + await press(first, "Set passphrase"); + check("a weak new passphrase is refused", await warned(first), true); + await fill(first, "Passphrase", PASSPHRASE); + await fill(first, "Confirm passphrase", PASSPHRASE); + await press(first, "Set passphrase"); + + await settled(first, "setup-relay"); + await press(first, "Skip"); + await settled(first, "setup-ready"); + await press(first, "Sync now"); + await first.waitFor( + "the first sync", + () => activeDocument.querySelector(".mdsync-setup-body")?.textContent, + (text) => text?.includes("In sync.") ?? false, + ); + check( + "the first device keeps its passphrase unlocked", + await first.evaluate(() => + app.plugins.plugins.mdsync.passphrase.isUnlocked(), + ), + true, + ); + check( + "the note reached the storage", + s3.keys().some((key) => key.startsWith("vault/")), + true, + ); + await press(first, "Done"); + check("Done closes the wizard", await wizardOpen(first), false); + + const link = await first.evaluate(async () => { + const pack = await app.plugins.plugins.mdsync.transfer.createPackage({ + storageMode: "active", + includeSyncScope: true, + includeAutomation: true, + includeRealtime: true, + }); + return pack.url as string; + }); + + await first.evaluate(async () => { + await app.plugins.disablePlugin("mdsync"); + await app.plugins.enablePlugin("mdsync"); + }); + await loaded(first); + await sleep(2000); + check( + "a configured device does not open the wizard", + await wizardOpen(first), + false, + ); + + const second = await launchObsidian({ + port: CDP_PORTS[1] as number, + settings: null, + }); + devices.push(second); + await loaded(second); + await wizardShown(second); + await press(second, "Accept an invite to a shared folder"); + check( + "the invite window opens over the wizard", + await modalTitles(second, 2), + ["Set up MDSync", "Shared folder invite"], + ); + await second.evaluate(() => + activeDocument.activeElement?.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ), + ); + check( + "closing the invite returns to the wizard", + await modalTitles(second, 1), + ["Set up MDSync"], + ); + await press(second, "Connect to a vault set up on another device"); + await fill(second, "Setup link", link); + await fill(second, "Passphrase", "wrong passphrase here"); + await press(second, "Import"); + check( + "a wrong passphrase leaves the link step", + await warned(second), + true, + ); + await fill(second, "Passphrase", PASSPHRASE); + await press(second, "Import"); + await press(second, "Skip"); + await press(second, "Sync now"); + await second.waitFor( + "the joining sync", + () => activeDocument.querySelector(".mdsync-setup-body")?.textContent, + (text) => text?.includes("In sync.") ?? false, + ); + check( + "the joining device pulled the note", + await read(second, NOTE), + "from the first device\n", + ); + } finally { + for (const device of devices) await device.stop(); + s3.stop(); + } +}); + +/** With `E2E_SHOTS`, waits out the stepper's transitions before the shot. */ +async function settled(device: Obsidian, name: string): Promise { + if (!process.env.E2E_SHOTS) return; + await sleep(1200); + await device.shot(name, ".mdsync-setup"); +} + +/** A fresh vault's trust prompt opens Community plugins, a document of its own over the wizard. */ +async function wizardShown(device: Obsidian): Promise { + await device.waitFor( + "Community plugins", + () => activeDocument.querySelector(".mod-settings") !== null, + Boolean, + ); + await device.evaluate(() => app.setting.close()); + return device.waitFor( + "the setup wizard", + () => activeDocument.querySelector(".mdsync-setup") !== null, + Boolean, + ); +} + +function modalTitles(device: Obsidian, count: number): Promise { + return device.waitFor( + "the open windows", + () => + [...activeDocument.querySelectorAll(".modal-title")].map( + (el) => el.textContent ?? "", + ), + (titles) => titles.length === count, + ); +} + +function wizardOpen(device: Obsidian): Promise { + return device.evaluate( + () => activeDocument.querySelector(".mdsync-setup") !== null, + ); +} + +/** The current step shows a problem. */ +function warned(device: Obsidian): Promise { + return device.waitFor( + "a warning in the step", + () => + activeDocument.querySelector(".mdsync-setup-body .mod-warning") !== null, + Boolean, + ); +} From 8763d50ada0ddc7c867604d18520b4f33ef6be84 Mon Sep 17 00:00:00 2001 From: kitbyte Date: Sat, 10 Oct 2026 23:08:28 +0300 Subject: [PATCH 02/11] feat: share links synced across devices through vault storage --- .claude/architecture.md | 4 +- .claude/commands-and-settings.md | 2 +- .claude/security.md | 2 +- .claude/share-links.md | 30 ++- .claude/shares.md | 8 +- .claude/sync-invariants.md | 2 +- README.md | 3 +- packages/plugin/src/links/index.ts | 2 +- packages/plugin/src/links/publish.ts | 6 +- packages/plugin/src/links/record.ts | 66 ++++- packages/plugin/src/links/remote.ts | 64 +++++ packages/plugin/src/links/shared-links.ts | 131 ++++++++-- packages/plugin/src/main.ts | 15 +- packages/plugin/src/plugin/bootstrap.ts | 12 +- packages/plugin/src/settings/model.ts | 8 +- packages/plugin/src/spaces/owner.ts | 4 +- packages/plugin/src/spaces/record.ts | 7 +- packages/plugin/src/spaces/remote.ts | 135 ++--------- packages/plugin/src/storage/records.ts | 166 +++++++++++++ packages/plugin/src/utils/index.ts | 1 + packages/plugin/src/utils/revision.ts | 10 + .../plugin/tests/helpers/explorer-host.ts | 12 +- packages/plugin/tests/helpers/shared-links.ts | 49 ++++ packages/plugin/tests/links/describe.test.ts | 2 + packages/plugin/tests/links/publish.test.ts | 22 +- packages/plugin/tests/links/record.test.ts | 47 +++- packages/plugin/tests/links/remote.test.ts | 207 ++++++++++++++++ .../plugin/tests/links/shared-links.test.ts | 228 ++++++++++++++---- .../settings/transfer-controller.test.ts | 2 + packages/plugin/tests/spaces/records.test.ts | 9 - packages/plugin/tests/ui/link-actions.test.ts | 12 +- .../tests/ui/manage-links-modal.test.ts | 14 +- .../plugin/tests/ui/note-link-action.test.ts | 12 +- packages/plugin/tests/utils/revision.test.ts | 17 ++ 34 files changed, 1037 insertions(+), 274 deletions(-) create mode 100644 packages/plugin/src/links/remote.ts create mode 100644 packages/plugin/src/storage/records.ts create mode 100644 packages/plugin/src/utils/revision.ts create mode 100644 packages/plugin/tests/helpers/shared-links.ts create mode 100644 packages/plugin/tests/links/remote.test.ts create mode 100644 packages/plugin/tests/utils/revision.test.ts diff --git a/.claude/architecture.md b/.claude/architecture.md index 048ea98a..13a53992 100644 --- a/.claude/architecture.md +++ b/.claude/architecture.md @@ -11,9 +11,9 @@ - `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; `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 diff --git a/.claude/commands-and-settings.md b/.claude/commands-and-settings.md index d5895797..4776626c 100644 --- a/.claude/commands-and-settings.md +++ b/.claude/commands-and-settings.md @@ -10,7 +10,7 @@ - **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/`), 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). +- **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. diff --git a/.claude/security.md b/.claude/security.md index 0480dd2e..28aa0ff1 100644 --- a/.claude/security.md +++ b/.claude/security.md @@ -10,5 +10,5 @@ Follow Obsidian's Developer Policies and Plugin Guidelines: - Clearly disclose any external services used, data sent, and risks. Features requiring cloud services need explicit opt-in. - No deceptive patterns, ads, or spammy notifications. - Shared folders: `data.json` holds each share's key and a participant's broker token in plain text, like the storage credentials; the vault key also opens them through the space records. To presign for participants the relay keeps the owner's S3 credentials (its KV), so its operator is trusted with them. A participant with write access can delete the share's objects; history, not revocation, repairs that. -- Share links: creating one sends a rendered copy of one note, sealed on the device, to the user's own relay (disclosed in README). `data.json` keeps each link whole, its key included, so the owner can copy it again; the passphrase is never stored. A reader who ticks **Remember on this browser** keeps the derived non-extractable key and gate in that browser's IndexedDB until the link expires or is gone, never the passphrase. The relay holds ciphertext, a SHA-256 of the link's gate (derived from the key in the fragment, and the passphrase if any: the id alone serves and updates nothing), the view limit and expiry. Everything published passes `links/sanitize.ts` (no properties, comments, embedded notes or paths) and the viewer's DOMPurify; the viewer page runs under a CSP with no inline script, no `unsafe-eval` and no framing, and the key stays in the URL fragment, which in-page jumps must not replace. The relay's operator serves the viewer script, so they are trusted as the owner. +- Share links: creating one sends a rendered copy of one note, sealed on the device, to the user's own relay (disclosed in README). `data.json` keeps each link whole, its key included, so the owner can copy it again, and the person's other devices get it through the vault storage, sealed under the vault key (`links/`); the passphrase is never stored. A reader who ticks **Remember on this browser** keeps the derived non-extractable key and gate in that browser's IndexedDB until the link expires or is gone, never the passphrase. The relay holds ciphertext, a SHA-256 of the link's gate (derived from the key in the fragment, and the passphrase if any: the id alone serves and updates nothing), the view limit and expiry. Everything published passes `links/sanitize.ts` (no properties, comments, embedded notes or paths) and the viewer's DOMPurify; the viewer page runs under a CSP with no inline script, no `unsafe-eval` and no framing, and the key stays in the URL fragment, which in-page jumps must not replace. The relay's operator serves the viewer script, so they are trusted as the owner. - Register and clean up all DOM, app, and interval listeners with the provided `register*` helpers so the plugin unloads safely; teardown is idempotent so reload/unload leaks nothing. diff --git a/.claude/share-links.md b/.claude/share-links.md index bfe75df2..d30056d2 100644 --- a/.claude/share-links.md +++ b/.claude/share-links.md @@ -17,7 +17,7 @@ shared folder ([shares](shares.md)): no accounts, no sync, a read-only snapshot. | A view is counted when the ciphertext is served, not when the page loads | link-preview bots fetch only the shell | | Every link has a gate derived from the fragment key (plus the passphrase when there is one), checked by the relay before it serves or updates | the id alone burns no view and yields no ciphertext; a wrong guess burns no view; operator alone cannot decrypt | | Seal format lives in `packages/protocol` | plugin and viewer share one source of truth | -| Link list is device-local (`settings.links`) | YAGNI; the link itself (with its key) must be re-copyable | +| Link list travels through the vault storage (`links/.json.enc`, vault key), LWW, no tombstones | any of the person's devices can copy, update or stop a link; the link itself (with its key) must be re-copyable | ## Link format @@ -161,16 +161,32 @@ Properties are never published (no option): the renderer does not draw them. `settings/model.ts` beside `RelayConfig`, so links never import `ui/shares`. Then record `LinkRecord` (`links/record.ts`: id, url with key, path, showTitle, createdAt, publishedAt, expiry as the relay set it, view limit, - salt, images, detached) in `settings.links` (device-local, never in device + salt, images, detached, `rev`, `author`) in `settings.links` (never in device transfer). If the record cannot be saved the link is revoked again, or it would be live with no key here to stop it. Loading keeps any record with an - id and a link (`parseLinkRecord` repairs the rest): a record is never dropped - with its key. An older relay answers `not_found`: the error says to redeploy it. + id and a link (`parseStoredLink` repairs the rest, a record of the first + builds starts at `rev` 0): a record is never dropped with its key. An older + relay answers `not_found`: the error says to redeploy it. - `SharedLinks` (`links/shared-links.ts`, `plugin.sharedLinks`) is the only writer of `settings.links`: add, remove, `published` (Update), `move` (rename), `detach` (the note was deleted: its links stay live and listed under Manage, with no Update, and a new note at that path starts clean); - each change saves and notifies subscribers. + each change saves, notifies subscribers and signs the records it replaced + (`rev + 1`, this device). Add and remove also refresh the vault, so the + other devices hear soon. +- Trade (`links/remote.ts`, `sync` inside `host.spaces()`, after the space + records): one object per link under `/links/`, read through + `storage/records.ts`. Local-only fields `vault` (storage identity last traded + with) and `removed` never leave the device. A removed link is deleted by name + (a lagging listing must not keep it), then forgotten; a failed delete keeps + it for the next trade. A copy traded with this storage that it no longer + lists was removed elsewhere and is dropped, even if edited here: removal + beats edit. One that will not decode is never written over or dropped, + unless removed here. Edits made while a trade runs are replayed onto its + outcome, above what it took in, also those that matched nothing yet. A + removed link is always flagged first, even one never traded: any trade, a + failed one too, may have written it. Known risk: two devices that each still hold a link removed on a + third can bring it back as a list entry; its relay payload stays gone. - Shared-note marks: `noteLinks` (`links/note-links.ts`) gives a note's unexpired links and `stale` (`file.stat.mtime > publishedAt`; `publishedAt` is taken just before the note is read, and a pulled write gets a local @@ -189,8 +205,8 @@ Properties are never published (no option): the renderer does not draw them. link, or one made through another relay than the vault's current one, can only be removed from the list: nothing here can stop it. A rename (of the note or a folder above it) updates the stored path (`plugin/links.ts`). -- `data.json` keeps the link with its key in plain text, like share keys; add - it to the security notes. +- `data.json` keeps the link with its key in plain text, like share keys; the + vault storage keeps it sealed under the vault key. ## Steps diff --git a/.claude/shares.md b/.claude/shares.md index 6e469292..b46b9ddf 100644 --- a/.claude/shares.md +++ b/.claude/shares.md @@ -43,8 +43,8 @@ announcement (`ChannelPresence.nameOf`). never race; one record edited on two devices is LWW (`rev`, then author). Never in `manifest.json.enc`: `state.json` keeps the manifest in plain text. A record is re-read only when its listing etag differs from the etag of the - bytes last read (`listWithEtags`, cached per storage and key in - `spaces/remote.ts`; one it just published, or one that will not decode, is + bytes last read (`listDetailed`, cached per storage, key and prefix in + `storage/records.ts`; one it just published, or one that will not decode, is read again next cycle): a quiet cycle is one list and no GETs. A backend that lists no etags reads every record. - A share's location (endpoint, bucket, prefix) is pinned once: re-deriving it @@ -382,8 +382,8 @@ places to change later: the hub on every signature costs a Durable Object round trip per object. - Re-inviting recognises a person by name, case-insensitively: one name is one participant, a renamed person is a new one, an unnamed invite is always new. -- `reset-remote-storage` leaves `shares/` and `spaces/`: resetting the vault does - not end shares others depend on. +- `reset-remote-storage` leaves `shares/`, `spaces/` and `links/`: resetting the + vault does not end shares others depend on, nor forget live share links. - Mounting over one's own copy: identical files converge; an edit made before the record arrived is a conflict with no base in the share. - The vault drops a share folder's frozen entries at its next push, so they diff --git a/.claude/sync-invariants.md b/.claude/sync-invariants.md index 0f58b2ad..b3734d31 100644 --- a/.claude/sync-invariants.md +++ b/.claude/sync-invariants.md @@ -25,7 +25,7 @@ data or publishes remote deletions. Every change in these areas needs tests. - **Closing a share is a record edit, never a deletion of files.** Records are tombstoned (`closed`, `rev + 1`) so no device republishes them; a closed record leaves the partition from the next refresh, and its root returns to the vault scope with its files. Stop sharing then empties the share's own storage through the operation queue (`deleteShareObjects` refuses the vault). Every close, here or seen from elsewhere, and leaving shares with the vault storage forget the share's state by its id (`StorageState.space`), needing no session: a participant's slot keeps one identity across re-accepts, so a later reopen at any root would otherwise read the old baseline as deleted files. Stop sharing refreshes before it deletes, so an operation queued for the share is refused instead of pushing its objects back. - **A paused share stays in the partition.** Pause is device-local (`settings.pausedSpaces`, never published); `spacesOf` marks the space `paused`, the refresh skips it and every session refuses it, reads such as history too (`OperationRunner.openSession`), but its root stays out of the vault scope. Dropping it from the partition would hand the folder to the vault and publish it there. - **A moved root moves on every device before any scan.** A rename of a share root (or a folder above it) edits the record (`moveRoot`, `rev + 1`) and invalidates in the same tick; results begun before the invalidation are dropped (refresh epoch), so auto-push never acts on the old partition. A device that learns of the move keeps the folder where it is (`settings.localRoots`, saved with the records) and `plugin/share-moves.ts` moves it inside `host.spaces()`, before the partition is fixed; a blocked move keeps the share syncing at the old root. A closed share's folder is the vault's and is never moved. An old root must never turn into vault content that holds the share's files. -- **Vault-wide listings stay inside `objects/` and `pins/`.** Shares live under `/shares//` and records under `/spaces/`, inside the vault's prefix; GC, verify and reset must never list or delete beyond their own folders. +- **Vault-wide listings stay inside `objects/` and `pins/`.** Shares live under `/shares//` and records under `/spaces/` and `/links/`, inside the vault's prefix; GC, verify and reset must never list or delete beyond their own folders. - **A space's remote paths are relative to its root; local ones never are.** Manifest, history log and pin manifests are translated only in their codec functions (`sync/space-paths.ts`, called from `sync/manifest.ts` and `sync/history/store.ts`); every read and write of those blobs goes through them, hash-only readers included. Each scan carries forward the hash-cache entries its space does not own, so one space's persist never drops another's. A share's `state.json` slot records the root its baseline sits under (`StorageState.root`); `projectSession` re-roots it once after a move (`manifestMoved`), and an unmoved baseline keeps its reference so the persister still debounces. - **A note is live only in its own space's room.** The room is named under that space's keys by the path inside its root (`docIdIn`), and its frames go only on that space's slot (`HubConnection.space`). A note whose space changes (shared, paused, new key) leaves its room; the file sync of one space answers `later` for a note whose room belongs to another. Paused shares stay cold. A read-only person's notes follow their room and never write it (`FollowerSession`): a change made there, or an incoming version the room lacks, leaves the note to the file sync; the hub refuses every write from a read-only grant (`Refused`). - **A live room is seeded once, and only while it has no log.** The session sends `Seed` from disk only on a `State` with `head == 0`; the hub appends it only into an empty log and answers a losing seed with the whole room, and the seed is applied locally only on its `Echo`. An empty room *with* a log is a deleted note: seeding it would resurrect it, two seeds would double it. An empty room behind what the device knows of it (its agreed seq in that generation, or a successor it was pointed at) lost its log and is never seeded. diff --git a/README.md b/README.md index a394fc55..ceaac0fe 100644 --- a/README.md +++ b/README.md @@ -269,8 +269,9 @@ Publish one note as a link anyone can open in a browser with nothing installed, - The page has an outline, folding headings, copyable code, wide text and **Copy as Markdown**. - Left out: properties, comments, links to other notes (their text stays) and embedded notes. Vault images are embedded and shrunk; **Include images** turns that off. The window lists what was left out before you create the link. - **Manage share links** shows the views left, copies the link, **Update**s it to the note as it is now (same link) or stops it at once. A globe in the file tree and in the note's header marks a shared note; a dot means it changed since. +- Your other devices list the same links, so any of them can copy, update or stop one. -A link is a copy: later edits do not reach it until you update it. Anyone who has the link (and the passphrase) can read the note and pass it on. The links, with their keys, are kept in the plugin's `data.json`. +A link is a copy: later edits do not reach it until you update it. Anyone who has the link (and the passphrase) can read the note and pass it on. The links, with their keys, are kept in the plugin's `data.json` and reach your other devices through your storage, encrypted with the vault key. ## Device transfer diff --git a/packages/plugin/src/links/index.ts b/packages/plugin/src/links/index.ts index bb595aea..055f30bc 100644 --- a/packages/plugin/src/links/index.ts +++ b/packages/plugin/src/links/index.ts @@ -28,7 +28,7 @@ export { type LinkRecord, noteName, onRelay, - parseLinkRecord, + type StoredLink, } from "./record"; export { SharedLinks } from "./shared-links"; export type { Snapshot } from "./snapshot"; diff --git a/packages/plugin/src/links/publish.ts b/packages/plugin/src/links/publish.ts index 9454958c..6f540102 100644 --- a/packages/plugin/src/links/publish.ts +++ b/packages/plugin/src/links/publish.ts @@ -24,6 +24,7 @@ import { } from "@/storage"; import { noteText } from "./note-text"; import { type LinkRecord, onRelay } from "./record"; +import type { NewLink } from "./shared-links"; import { type Snapshot, takeSnapshot } from "./snapshot"; export interface ShareOptions { @@ -81,7 +82,7 @@ export async function publishLink( gate: sealed.gate, salt, }); - const record: LinkRecord = { + const link: NewLink = { id, url: linkUrl(relayBase(admin.relayUrl), id, key), // A rename during the upload moved only the links already stored. @@ -97,7 +98,7 @@ export async function publishLink( detached: plugin.app.vault.getFileByPath(file.path) !== file, }; try { - await plugin.sharedLinks.add(record); + return await plugin.sharedLinks.add(link); } catch (err) { // Without the record the key is lost and nothing here could ever stop the link. If it cannot be // stopped now either, the record stays in memory for the next save and a retry. @@ -106,7 +107,6 @@ export async function publishLink( .catch(() => undefined); throw err; } - return record; } /** Snapshots taken for one batch of updates, by whether they carry images: a note is drawn at most twice. */ diff --git a/packages/plugin/src/links/record.ts b/packages/plugin/src/links/record.ts index 47b8187a..da74e44f 100644 --- a/packages/plugin/src/links/record.ts +++ b/packages/plugin/src/links/record.ts @@ -1,9 +1,12 @@ +import { isLinkId } from "@mdsync/protocol"; + import { isCleartextRemote, relayBase } from "@/shared"; +import type { Revised } from "@/utils"; const MS_PER_S = 1000; -/** One share link this device published. */ -export interface LinkRecord { +/** One share link of this person, as every device of theirs trades it through the vault storage. */ +export interface LinkRecord extends Revised { id: string; /** The whole link, key included: the only way to copy it again. */ url: string; @@ -26,11 +29,46 @@ export interface LinkRecord { detached: boolean; } +/** This device's copy of a record; the extra fields never leave it. */ +export interface StoredLink extends LinkRecord { + /** Identity of the vault storage it was last traded with; unset until first published. */ + vault?: string; + /** Removed here: hidden, until the next trade deletes it from the vault storage too. */ + removed?: true; +} + +/** A record of the first builds, made before links were traded, starts at revision 0. */ +export function parseStoredLink(value: unknown): StoredLink | null { + const record = parseLinkRecord(value); + if (!record) return null; + const { vault, removed } = value as Record; + return { + ...record, + ...(typeof vault === "string" ? { vault } : {}), + ...(removed === true ? { removed } : {}), + }; +} + +/** Another device's record: its id names the object it came in. */ +export function parseTradedLink(value: unknown): LinkRecord | null { + const record = parseLinkRecord(value); + return record && isLinkId(record.id) ? record : null; +} + +/** What goes to the vault storage: nothing only this device knows. */ +export function tradedLink({ + vault: _, + removed: __, + ...record +}: StoredLink): LinkRecord { + return record; +} + /** * Keeps whatever a stored record still tells: the id and the link are all that stopping it takes, so a record * missing other fields is repaired rather than dropped with its key. */ -export function parseLinkRecord(value: unknown): LinkRecord | null { +function parseLinkRecord(value: unknown): LinkRecord | null { if (typeof value !== "object" || value === null) return null; const record = value as Record; const { id, url, path } = record; @@ -38,6 +76,8 @@ export function parseLinkRecord(value: unknown): LinkRecord | null { return { id, url, + rev: numberOr(record.rev, 0), + author: typeof record.author === "string" ? record.author : "", path: typeof path === "string" ? path : "", // Records of the first builds kept the choice as `title`, empty when the name was hidden. showTitle: @@ -92,11 +132,11 @@ export function onRelay( } /** The links after a note, or the folder holding it, moved; null when none was affected. */ -export function renamedLinks( - links: readonly LinkRecord[], +export function renamedLinks( + links: readonly T[], from: string, to: string, -): LinkRecord[] | null { +): T[] | null { return changedLinks(links, from, (record) => ({ ...record, path: to + record.path.slice(from.length), @@ -104,19 +144,19 @@ export function renamedLinks( } /** The links after a note, or the folder holding it, was deleted; null when none was affected. */ -export function detachedLinks( - links: readonly LinkRecord[], +export function detachedLinks( + links: readonly T[], path: string, -): LinkRecord[] | null { +): T[] | null { return changedLinks(links, path, (record) => ({ ...record, detached: true })); } /** Applies `change` to the attached links at or under `path`; null when there were none. */ -function changedLinks( - links: readonly LinkRecord[], +function changedLinks( + links: readonly T[], path: string, - change: (record: LinkRecord) => LinkRecord, -): LinkRecord[] | null { + change: (record: T) => T, +): T[] | null { let touched = false; const next = links.map((record) => { const within = record.path === path || record.path.startsWith(`${path}/`); diff --git a/packages/plugin/src/links/remote.ts b/packages/plugin/src/links/remote.ts new file mode 100644 index 00000000..a62defb1 --- /dev/null +++ b/packages/plugin/src/links/remote.ts @@ -0,0 +1,64 @@ +import type { EncryptionKey } from "@/crypto"; +import { + type RecordKind, + readRecords, + removeRecord, + writeRecord, +} from "@/storage/records"; +import type { StorageAdapter } from "@/storage/types"; +import { isNewer } from "@/utils"; + +import { + type LinkRecord, + parseTradedLink, + type StoredLink, + tradedLink, +} from "./record"; + +const LINKS: RecordKind = { + prefix: "links/", + parse: parseTradedLink, + label: "Share link record", +}; + +/** + * Trades this device's links with the vault storage. No tombstones: a link removed on any device is deleted + * from the storage, and a copy already traded there that the storage no longer lists was removed elsewhere. + * A listing that lags only drops a copy the storage still holds; the next trade brings it back. + */ +export async function tradeLinks( + storage: StorageAdapter, + key: EncryptionKey, + local: readonly StoredLink[], +): Promise<{ links: StoredLink[]; changed: boolean }> { + const vault = storage.identity(); + const { records, unreadable } = await readRecords(storage, key, LINKS); + const theirs = new Map(records.map((record) => [record.id, record])); + const links: StoredLink[] = []; + let changed = false; + for (const link of local) { + const known = theirs.get(link.id); + theirs.delete(link.id); + if (link.removed) { + // Beats any revision there, readable or not. Kept until the deletion went through, so a failed one is tried again. + await removeRecord(storage, LINKS, link.id); + changed = true; + } else if (unreadable.has(link.id)) { + // May be newer, or of a newer format: never written over. + links.push(link); + } else if (!known && link.vault === vault) { + // Removed on another device: the removal beats any edit made here meanwhile. + } else if (!known || isNewer(link, known)) { + await writeRecord(storage, key, LINKS, tradedLink(link)); + changed = true; + links.push({ ...link, vault }); + } else if (link.vault === vault && !isNewer(known, link)) { + // The same revision: this copy stays, so a trade that brought nothing saves nothing. + links.push(link); + } else { + links.push({ ...known, vault }); + } + } + for (const record of theirs.values()) links.push({ ...record, vault }); + return { links, changed }; +} diff --git a/packages/plugin/src/links/shared-links.ts b/packages/plugin/src/links/shared-links.ts index 7b8b6a81..b2d43a5a 100644 --- a/packages/plugin/src/links/shared-links.ts +++ b/packages/plugin/src/links/shared-links.ts @@ -1,22 +1,45 @@ -import { detachedLinks, type LinkRecord, renamedLinks } from "./record"; +import type { EncryptionKey } from "@/crypto"; +import type { StorageAdapter } from "@/storage/types"; + +import { + detachedLinks, + type LinkRecord, + renamedLinks, + type StoredLink, +} from "./record"; +import { tradeLinks } from "./remote"; const MS_PER_S = 1000; /** setTimeout's ceiling; a later expiry just takes another lap. */ const MAX_DELAY_MS = 2 ** 31 - 1; -/** The one owner of this device's share links: each change is saved and told to the subscribers. */ +export interface SharedLinksDeps { + /** Asked each time: a settings import replaces the object. */ + settings(): { links: StoredLink[] }; + save(): Promise; + /** This device, which signs each edit. */ + author(): string; + /** A link was added or removed here: the person's other devices should learn of it soon. */ + announce(): void; +} + +/** A change of the list; null when it changed nothing. */ +type Edit = (links: readonly StoredLink[]) => StoredLink[] | null; + +/** A new link, before this device signs it. */ +export type NewLink = Omit; + +/** The one owner of this person's share links: each change is saved and told to the subscribers. */ export class SharedLinks { private readonly listeners = new Set<() => void>(); private expiryTimer: number | null = null; + /** Edits made while a trade runs, replayed onto what it settled. */ + private pending: Edit[] | null = null; - constructor( - /** Asked each time: a settings import replaces the object. */ - private readonly settings: () => { links: LinkRecord[] }, - private readonly save: () => Promise, - ) {} + constructor(private readonly deps: SharedLinksDeps) {} all(): readonly LinkRecord[] { - return this.settings().links; + return this.deps.settings().links.filter((record) => !record.removed); } /** The links the note at `path` feeds; those of a deleted note stay under {@link all}. */ @@ -26,32 +49,67 @@ export class SharedLinks { ); } - add(record: LinkRecord): Promise { - return this.write([...this.all(), record]); + async add(link: NewLink): Promise { + const record: LinkRecord = { ...link, rev: 0, author: this.deps.author() }; + await this.edit((links) => [...links, record]); + this.deps.announce(); + return record; } - remove(id: string): Promise { - return this.write(this.all().filter((record) => record.id !== id)); + /** + * Hidden at once, forgotten once the next trade deleted it from the vault storage: any trade, a failed one + * too, may have written it there already. + */ + async remove(id: string): Promise { + const removed = await this.edit((links) => { + const at = links.find((record) => record.id === id); + if (!at || at.removed) return null; + return links.map((record) => + record === at ? { ...record, removed: true as const } : record, + ); + }); + if (removed) this.deps.announce(); } - published(id: string, at: number): Promise { - return this.write( - this.all().map((record) => - record.id === id ? { ...record, publishedAt: at } : record, - ), + async published(id: string, at: number): Promise { + await this.edit((links) => + links.some((record) => record.id === id) + ? links.map((record) => + record.id === id ? { ...record, publishedAt: at } : record, + ) + : null, ); } /** A renamed note, or the folder holding it, keeps its links. */ async move(from: string, to: string): Promise { - const links = renamedLinks(this.all(), from, to); - if (links) await this.write(links); + await this.edit((links) => renamedLinks(links, from, to)); } /** A deleted note no longer feeds its links: a new note at that path must not inherit them. */ async detach(path: string): Promise { - const links = detachedLinks(this.all(), path); - if (links) await this.write(links); + await this.edit((links) => detachedLinks(links, path)); + } + + /** Trades the links with the vault storage; true when it wrote or deleted something there. */ + async sync(storage: StorageAdapter, key: EncryptionKey): Promise { + if (this.pending) return false; + this.pending = []; + try { + const traded = await tradeLinks(storage, key, this.deps.settings().links); + const links = this.pending.reduce( + (settled, change) => change(settled) ?? settled, + traded.links, + ); + if ( + JSON.stringify(links) !== JSON.stringify(this.deps.settings().links) + ) { + await this.write(links); + } + return traded.changed; + } finally { + this.pending = null; + } } /** Also told when a link expires: what is shared changes then without a write. */ @@ -64,10 +122,35 @@ export class SharedLinks { }; } - private async write(links: LinkRecord[]): Promise { - this.settings().links = links; + /** + * Every record `change` replaced becomes a revision by this device, also when replayed onto a trade's + * outcome, so it lands above whatever the trade took in. Replayed even when it changed nothing here: it may + * touch a link the trade brings in. False when nothing changed. + */ + private async edit(change: Edit): Promise { + const revised: Edit = (links) => { + const next = change(links); + if (!next) return null; + const kept = new Set(links); + const ids = new Set(links.map(({ id }) => id)); + const author = this.deps.author(); + return next.map((record) => + kept.has(record) || !ids.has(record.id) + ? record + : { ...record, rev: record.rev + 1, author }, + ); + }; + this.pending?.push(revised); + const links = revised(this.deps.settings().links); + if (!links) return false; + await this.write(links); + return true; + } + + private async write(links: StoredLink[]): Promise { + this.deps.settings().links = links; this.notify(); - await this.save(); + await this.deps.save(); } private notify(): void { diff --git a/packages/plugin/src/main.ts b/packages/plugin/src/main.ts index 0ec8d46c..b5233a72 100644 --- a/packages/plugin/src/main.ts +++ b/packages/plugin/src/main.ts @@ -14,6 +14,7 @@ import type { Unseen } from "@/presence"; import { canSync, DEFAULT_SETTINGS, + isStorageConfigured, type MdsyncSettings, mergeSettings, } from "@/settings/model"; @@ -87,10 +88,15 @@ export default class MdsyncPlugin extends Plugin implements PluginHost { ignoreState!: IgnoreStateHandle; spaces!: SpaceRecords; unseen!: Unseen; - readonly sharedLinks = new SharedLinks( - () => this.settings, - () => this.saveSettings(), - ); + readonly sharedLinks = new SharedLinks({ + settings: () => this.settings, + save: () => this.saveSettings(), + author: () => this.controller.currentDevice().id, + // The vault refresh trades the links. + announce: () => { + if (isStorageConfigured(this.settings)) void this.controller.refresh(); + }, + }); private settingsTab?: MdsyncSettingTab; private statePersister!: StatePersister; private scopeRefreshTimer: number | null = null; @@ -108,6 +114,7 @@ export default class MdsyncPlugin extends Plugin implements PluginHost { const runtime = await bootstrapPluginRuntime({ app: this.app, settings: this.settings, + links: this.sharedLinks, onPushComplete: (space) => { this.realtime.hub.signal(space.id); refreshOpenHistoryViewsAfterPush(this); diff --git a/packages/plugin/src/plugin/bootstrap.ts b/packages/plugin/src/plugin/bootstrap.ts index 62107767..79d9ce86 100644 --- a/packages/plugin/src/plugin/bootstrap.ts +++ b/packages/plugin/src/plugin/bootstrap.ts @@ -6,6 +6,7 @@ import { PassphraseManager, StatePersister, } from "@/core"; +import type { SharedLinks } from "@/links"; import { isStorageConfigured, type MdsyncSettings } from "@/settings/model"; import { reportWarning } from "@/shared"; import { SpaceRecords } from "@/spaces"; @@ -28,6 +29,8 @@ export interface PluginRuntime { interface BootstrapPluginRuntimeOptions { app: App; settings: MdsyncSettings; + /** Traded with the vault storage on every full refresh, after the records. */ + links: Pick; /** Also for a published space record: other devices learn of shares from the vault. */ onPushComplete?: (space: Space) => void; onSpaceRefreshed?: (space: Space) => void; @@ -86,7 +89,14 @@ export async function bootstrapPluginRuntime( ), ); const { published, closed, left } = await spaces.sync(storage, key); - if (published) options.onPushComplete?.(VAULT_SPACE); + // A link that fails to trade must not hold up the files. + const traded = await options.links + .sync(storage, key) + .catch((err: unknown) => { + reportWarning("Share links did not sync.", err); + return false; + }); + if (published || traded) options.onPushComplete?.(VAULT_SPACE); if (left.length > 0) { notifyError( `The vault storage changed: ${left.length} shared folder(s) stay with the previous one and sync here as plain folders until you switch back.`, diff --git a/packages/plugin/src/settings/model.ts b/packages/plugin/src/settings/model.ts index 89e57b3d..a85ac645 100644 --- a/packages/plugin/src/settings/model.ts +++ b/packages/plugin/src/settings/model.ts @@ -6,7 +6,7 @@ import { FILE_HISTORY_MAX_SNAPSHOTS, FILE_HISTORY_MIN_SNAPSHOTS, } from "@/constants"; -import { type LinkRecord, parseLinkRecord } from "@/links/record"; +import { parseStoredLink, type StoredLink } from "@/links/record"; import { isCleartextRemote } from "@/shared"; import { isSpaceRecord, type SpaceRecord } from "@/spaces/record"; import { @@ -84,8 +84,8 @@ export interface MdsyncSettings { localRoots: Record; /** The vault storage (identity) the records were traded with: another vault's never reach this one. */ spacesVault: string | null; - /** Notes this device shared as links. Holds each link's key, so never published or transferred. */ - links: LinkRecord[]; + /** This person's share links, keys included: traded sealed through the vault storage, never transferred. */ + links: StoredLink[]; /** Self-hosted worker (packages/relay): realtime signals. */ relayUrl: string; /** The worker's RELAY_SECRET; relay room tokens derive from it. */ @@ -298,7 +298,7 @@ export function mergeSettings( : [], localRoots: stringValues(stored?.localRoots), links: Array.isArray(stored?.links) - ? stored.links.flatMap((link) => parseLinkRecord(link) ?? []) + ? stored.links.flatMap((link) => parseStoredLink(link) ?? []) : [], spacesVault: typeof stored?.spacesVault === "string" ? stored.spacesVault : null, diff --git a/packages/plugin/src/spaces/owner.ts b/packages/plugin/src/spaces/owner.ts index 375d36f7..24ae94ed 100644 --- a/packages/plugin/src/spaces/owner.ts +++ b/packages/plugin/src/spaces/owner.ts @@ -6,9 +6,9 @@ import { type S3StorageConfig, type StorageAdapterConfig, } from "@/storage"; -import { bytesToBase64 } from "@/utils"; +import { bytesToBase64, isNewer } from "@/utils"; -import { isNewer, type ShareLocation, type SpaceRecord } from "./record"; +import type { ShareLocation, SpaceRecord } from "./record"; const SHARE_KEY_BYTES = 32; diff --git a/packages/plugin/src/spaces/record.ts b/packages/plugin/src/spaces/record.ts index 3c691d07..d355d2e5 100644 --- a/packages/plugin/src/spaces/record.ts +++ b/packages/plugin/src/spaces/record.ts @@ -1,5 +1,7 @@ import { isShareId, OWNER } from "@mdsync/protocol"; +import { isNewer } from "@/utils"; + /** * Where a share's objects live: its owner's S3 location when it was created. Pinned for good; re-deriving * it from later settings would orphan them. @@ -53,11 +55,6 @@ export interface SpaceRecord { paused?: true; } -/** Last writer wins, deterministically on every device. */ -export function isNewer(a: SpaceRecord, b: SpaceRecord): boolean { - return a.rev !== b.rev ? a.rev > b.rev : a.author > b.author; -} - /** Outranks every edit made offline before the close; a join built on it still outranks it. */ const CLOSE_REV_STEP = 1_000_000; diff --git a/packages/plugin/src/spaces/remote.ts b/packages/plugin/src/spaces/remote.ts index 80ba9769..9a51898b 100644 --- a/packages/plugin/src/spaces/remote.ts +++ b/packages/plugin/src/spaces/remote.ts @@ -1,30 +1,15 @@ -/** - * Records travel through the vault's own storage, one object each, so two devices adding spaces never race - * and no compare-and-set is needed. - */ - -import { decryptJson, type EncryptionKey, encryptJson } from "@/crypto"; -import { reportWarning } from "@/shared"; -import type { ListedObject, ObjectStorage } from "@/storage/types"; - -import { - isNewer, - isSpaceRecord, - mergeRecords, - type SpaceRecord, -} from "./record"; +import type { EncryptionKey } from "@/crypto"; +import { type RecordKind, readRecords, writeRecord } from "@/storage/records"; +import type { ObjectStorage } from "@/storage/types"; +import { isNewer } from "@/utils"; -const RECORDS_PREFIX = "spaces/"; -const RECORD_SUFFIX = ".json.enc"; +import { isSpaceRecord, mergeRecords, type SpaceRecord } from "./record"; -/** Keyed by the etag of the bytes that were read, which a listing may run ahead of or behind. */ -type RecordCache = Map; - -// The storage lives as long as its settings; a new key or storage starts cold. -const caches = new WeakMap< - ObjectStorage, - { key: EncryptionKey; records: RecordCache } ->(); +const SPACES: RecordKind = { + prefix: "spaces/", + parse: (value) => (isSpaceRecord(value) ? value : null), + label: "Space record", +}; /** * Merges the remote records into `local` and publishes what the remote lacks; `unbound`: `local` was made @@ -36,11 +21,10 @@ export async function syncRecords( local: readonly SpaceRecord[], unbound = false, ): Promise<{ records: SpaceRecord[]; published: boolean }> { - const cache = cacheFor(storage, key); const { records: remote, unreadable } = await readRecords( storage, key, - cache, + SPACES, ); const theirs = new Map(remote.map((record) => [record.id, record])); // Unbound: the vault's own share stays its owner's; a join outranks its older close, as `acceptInvite` does. @@ -57,16 +41,10 @@ export async function syncRecords( let published = false; for (const record of mine) { // One this device cannot read may be newer, or of a newer format: never written over. - if (unreadable.has(recordKey(record.id))) continue; + if (unreadable.has(record.id)) continue; const known = theirs.get(record.id); if (known && !isNewer(record, known)) continue; - await storage.put( - recordKey(record.id), - await encryptJson(key, record), - "application/octet-stream", - ); - // Its listing etag is not known until the next list, which reads it once. - cache.delete(recordKey(record.id)); + await writeRecord(storage, key, SPACES, record); published = true; } return { records: mergeRecords(remote, mine), published }; @@ -76,90 +54,5 @@ export async function fetchRecords( storage: ObjectStorage, key: EncryptionKey, ): Promise { - return (await readRecords(storage, key, cacheFor(storage, key))).records; -} - -function cacheFor(storage: ObjectStorage, key: EncryptionKey): RecordCache { - const held = caches.get(storage); - if (held?.key === key) return held.records; - const records: RecordCache = new Map(); - caches.set(storage, { key, records }); - return records; -} - -async function listRecords(storage: ObjectStorage): Promise { - if (storage.listDetailed) return storage.listDetailed(RECORDS_PREFIX); - return (await storage.list(RECORDS_PREFIX)).map((name) => ({ - key: name, - etag: null, - modified: null, - })); -} - -/** `unreadable`: names of records listed but not decoded. */ -async function readRecords( - storage: ObjectStorage, - key: EncryptionKey, - cache: RecordCache, -): Promise<{ records: SpaceRecord[]; unreadable: Set }> { - const listed = (await listRecords(storage)).filter(({ key: name }) => - name.endsWith(RECORD_SUFFIX), - ); - const names = new Set(listed.map(({ key: name }) => name)); - for (const name of cache.keys()) { - if (!names.has(name)) cache.delete(name); - } - const unreadable = new Set(); - const records = await Promise.all( - listed.map(async ({ key: name, etag }) => { - const held = cache.get(name); - if (etag !== null && held?.etag === etag) return held.record; - const read = await readObject(storage, name, etag); - if (!read) return null; - const record = await decodeRecord(key, name, read.body); - if (!record) unreadable.add(name); - else if (read.etag !== null) { - cache.set(name, { etag: read.etag, record }); - } - return record; - }), - ); - return { - records: records.filter((record) => record !== null), - unreadable, - }; -} - -/** A backend that cannot name the version it served leaves the listing's word as the only one. */ -async function readObject( - storage: ObjectStorage, - name: string, - listedEtag: string | null, -): Promise<{ body: Uint8Array; etag: string | null } | null> { - if (!storage.getIfChanged) { - const body = await storage.get(name); - return body && { body, etag: listedEtag }; - } - const read = await storage.getIfChanged(name, null); - return read.status === "found" ? read : null; -} - -async function decodeRecord( - key: EncryptionKey, - name: string, - blob: Uint8Array, -): Promise { - try { - const record = await decryptJson(key, blob); - if (isSpaceRecord(record) && recordKey(record.id) === name) return record; - } catch (err) { - reportWarning(`Space record "${name}" is unreadable.`, err); - return null; - } - reportWarning(`Space record "${name}" is malformed.`); - return null; -} - -function recordKey(id: string): string { - return `${RECORDS_PREFIX}${id}${RECORD_SUFFIX}`; + return (await readRecords(storage, key, SPACES)).records; } diff --git a/packages/plugin/src/storage/records.ts b/packages/plugin/src/storage/records.ts new file mode 100644 index 00000000..e4bd7fc5 --- /dev/null +++ b/packages/plugin/src/storage/records.ts @@ -0,0 +1,166 @@ +/** + * Records travel through the vault's own storage, one object each, so two devices adding records never race + * and no compare-and-set is needed. + */ + +import { decryptJson, type EncryptionKey, encryptJson } from "@/crypto"; +import { reportWarning } from "@/shared"; + +import type { ListedObject, ObjectStorage } from "./types"; + +/** One kind of record: `.json.enc`, sealed with the vault key. */ +export interface RecordKind { + prefix: string; + parse(value: unknown): T | null; + /** Names the kind in warnings. */ + label: string; +} + +const RECORD_SUFFIX = ".json.enc"; + +/** Keyed by the etag of the bytes that were read, which a listing may run ahead of or behind. */ +type RecordCache = Map; + +// The storage lives as long as its settings; a new key or storage starts cold. One cache per kind, so one +// listing never prunes another's. +const caches = new WeakMap< + ObjectStorage, + Map +>(); + +/** `unreadable`: ids of records listed but not decoded. */ +export async function readRecords( + storage: ObjectStorage, + key: EncryptionKey, + kind: RecordKind, +): Promise<{ records: T[]; unreadable: Set }> { + const cache = cacheFor(storage, key, kind); + const listed = (await listRecords(storage, kind.prefix)).filter( + ({ key: name }) => name.endsWith(RECORD_SUFFIX), + ); + const names = new Set(listed.map(({ key: name }) => name)); + for (const name of cache.keys()) { + if (!names.has(name)) cache.delete(name); + } + const unreadable = new Set(); + const records = await Promise.all( + listed.map(async ({ key: name, etag }) => { + const held = cache.get(name); + if (etag !== null && held?.etag === etag) return held.record as T; + const read = await readObject(storage, name, etag); + if (!read) return null; + const record = await decodeRecord(key, kind, name, read.body); + if (!record) unreadable.add(idOf(kind, name)); + else if (read.etag !== null) { + cache.set(name, { etag: read.etag, record }); + } + return record; + }), + ); + return { + records: records.filter((record) => record !== null), + unreadable, + }; +} + +export async function writeRecord( + storage: ObjectStorage, + key: EncryptionKey, + kind: RecordKind, + record: T, +): Promise { + const name = recordName(kind, record.id); + await storage.put( + name, + await encryptJson(key, record), + "application/octet-stream", + ); + // Its listing etag is not known until the next list, which reads it once. + caches.get(storage)?.get(kind.prefix)?.records.delete(name); +} + +/** By its name, listed or not: a listing that lags must not keep a record alive. */ +export async function removeRecord( + storage: ObjectStorage, + kind: RecordKind, + id: string, +): Promise { + const name = recordName(kind, id); + await storage.delete(name); + caches.get(storage)?.get(kind.prefix)?.records.delete(name); +} + +function cacheFor( + storage: ObjectStorage, + key: EncryptionKey, + kind: RecordKind, +): RecordCache { + let kinds = caches.get(storage); + if (!kinds) { + kinds = new Map(); + caches.set(storage, kinds); + } + const held = kinds.get(kind.prefix); + if (held?.key === key) return held.records; + const records: RecordCache = new Map(); + kinds.set(kind.prefix, { key, records }); + return records; +} + +async function listRecords( + storage: ObjectStorage, + prefix: string, +): Promise { + if (storage.listDetailed) return storage.listDetailed(prefix); + return (await storage.list(prefix)).map((name) => ({ + key: name, + etag: null, + modified: null, + })); +} + +/** A backend that cannot name the version it served leaves the listing's word as the only one. */ +async function readObject( + storage: ObjectStorage, + name: string, + listedEtag: string | null, +): Promise<{ body: Uint8Array; etag: string | null } | null> { + if (!storage.getIfChanged) { + const body = await storage.get(name); + return body && { body, etag: listedEtag }; + } + const read = await storage.getIfChanged(name, null); + return read.status === "found" ? read : null; +} + +async function decodeRecord( + key: EncryptionKey, + kind: RecordKind, + name: string, + blob: Uint8Array, +): Promise { + let record: T | null; + try { + record = kind.parse(await decryptJson(key, blob)); + } catch (err) { + reportWarning(`${kind.label} "${name}" is unreadable.`, err); + return null; + } + if (record && recordName(kind, record.id) === name) return record; + reportWarning(`${kind.label} "${name}" is malformed.`); + return null; +} + +function recordName( + kind: RecordKind, + id: string, +): string { + return `${kind.prefix}${id}${RECORD_SUFFIX}`; +} + +function idOf( + kind: RecordKind, + name: string, +): string { + return name.slice(kind.prefix.length, -RECORD_SUFFIX.length); +} diff --git a/packages/plugin/src/utils/index.ts b/packages/plugin/src/utils/index.ts index d9056a9a..948519d4 100644 --- a/packages/plugin/src/utils/index.ts +++ b/packages/plugin/src/utils/index.ts @@ -4,4 +4,5 @@ export * from "./compress"; export * from "./concurrency"; export * from "./eol"; export * from "./file-concurrency"; +export * from "./revision"; export * from "./search"; diff --git a/packages/plugin/src/utils/revision.ts b/packages/plugin/src/utils/revision.ts new file mode 100644 index 00000000..2810acdd --- /dev/null +++ b/packages/plugin/src/utils/revision.ts @@ -0,0 +1,10 @@ +/** A record several devices may edit: every edit raises `rev` and names its device. */ +export interface Revised { + rev: number; + author: string; +} + +/** Last writer wins, deterministically on every device. */ +export function isNewer(a: Revised, b: Revised): boolean { + return a.rev !== b.rev ? a.rev > b.rev : a.author > b.author; +} diff --git a/packages/plugin/tests/helpers/explorer-host.ts b/packages/plugin/tests/helpers/explorer-host.ts index d9d4377a..26c9dfd5 100644 --- a/packages/plugin/tests/helpers/explorer-host.ts +++ b/packages/plugin/tests/helpers/explorer-host.ts @@ -30,6 +30,8 @@ export const LAPTOP: Person = { export const LINK: LinkRecord = { id: "link", url: "https://example.com/link", + rev: 0, + author: "", path: "notes/published.md", showTitle: true, detached: false, @@ -55,10 +57,12 @@ export function host( const settings = { ...DEFAULT_SETTINGS, links }; return { settings, - sharedLinks: new SharedLinks( - () => settings, - async () => {}, - ), + sharedLinks: new SharedLinks({ + settings: () => settings, + save: async () => {}, + author: () => "laptop", + announce: () => {}, + }), app: { vault: { getFileByPath: (path: string) => ({ path, stat: { mtime: 0 } }), diff --git a/packages/plugin/tests/helpers/shared-links.ts b/packages/plugin/tests/helpers/shared-links.ts new file mode 100644 index 00000000..a3b0d39a --- /dev/null +++ b/packages/plugin/tests/helpers/shared-links.ts @@ -0,0 +1,49 @@ +import { vi } from "vitest"; + +import type { StoredLink } from "@/links/record"; +import { SharedLinks } from "@/links/shared-links"; + +const PUBLISHED = 1_760_000_000_000; + +/** A link id from one character: `a` gives 22 of them. */ +export function linkId(seed: string): string { + return seed.repeat(22).slice(0, 22); +} + +export function link( + seed: string, + path = `${seed}.md`, + extra: Partial = {}, +): StoredLink { + const id = linkId(seed); + return { + id, + url: `https://relay.example/s/${id}#key`, + rev: 0, + author: "laptop", + path, + showTitle: true, + detached: false, + createdAt: PUBLISHED, + publishedAt: PUBLISHED, + expires: null, + maxViews: null, + salt: null, + images: true, + ...extra, + }; +} + +/** One device's links, persisted into its own settings object. */ +export function linkDevice(links: StoredLink[] = [], author = "laptop") { + const settings = { links }; + const save = vi.fn(async () => {}); + const announce = vi.fn(); + const shared = new SharedLinks({ + settings: () => settings, + save, + author: () => author, + announce, + }); + return { settings, save, announce, shared }; +} diff --git a/packages/plugin/tests/links/describe.test.ts b/packages/plugin/tests/links/describe.test.ts index 2491ab4d..baf5961a 100644 --- a/packages/plugin/tests/links/describe.test.ts +++ b/packages/plugin/tests/links/describe.test.ts @@ -25,6 +25,8 @@ function pickedAt(ms: number): string { const record: LinkRecord = { id: "abcdefghijklmnopqrstuv", url: "https://relay.example/s/abcdefghijklmnopqrstuv#key", + rev: 0, + author: "", path: "note.md", showTitle: true, detached: false, diff --git a/packages/plugin/tests/links/publish.test.ts b/packages/plugin/tests/links/publish.test.ts index c69047b0..1a27b2f9 100644 --- a/packages/plugin/tests/links/publish.test.ts +++ b/packages/plugin/tests/links/publish.test.ts @@ -70,7 +70,12 @@ function host() { saveSettings, } as unknown as PluginHost; Object.assign(plugin, { - sharedLinks: new SharedLinks(() => plugin.settings, saveSettings), + sharedLinks: new SharedLinks({ + settings: () => plugin.settings, + save: saveSettings, + author: () => "laptop", + announce: () => {}, + }), }); return { plugin, file, getFileByPath, saveSettings }; } @@ -80,6 +85,8 @@ function record(file: TFile): LinkRecord { return { id, url: linkUrl(relay, id, newLinkKey()), + rev: 0, + author: "", path: file.path, showTitle: true, detached: false, @@ -130,7 +137,7 @@ describe("publishLink", () => { const id = uploadCall()[1]; expect(revokeLink).toHaveBeenCalledExactlyOnceWith(admin, id); expect(remove).toHaveBeenCalledExactlyOnceWith(id); - expect(plugin.settings.links).toEqual([existing]); + expect(plugin.sharedLinks.all()).toEqual([existing]); }); it("retains the record when saving and revoking both fail, rethrowing the save error", async () => { @@ -183,6 +190,8 @@ describe("publishLink", () => { ); expect(location.id).toBe(published.id); expect(published).toMatchObject({ + rev: 0, + author: "laptop", path: file.path, showTitle: true, detached: false, @@ -378,7 +387,12 @@ describe("updateLink", () => { expect(gate).toBe((await deriveLinkKeys(locationOf(published).key)).gate); expect(published.url).toBe(url); expect(plugin.settings.links).toEqual([ - { ...published, publishedAt: expect.any(Number) }, + { + ...published, + rev: 1, + author: "laptop", + publishedAt: expect.any(Number), + }, ]); expect(plugin.settings.links[0]?.publishedAt).toBeGreaterThan( published.publishedAt, @@ -473,7 +487,7 @@ describe("revokeLinkRecord", () => { await revokeLinkRecord(plugin, published); expect(revokeLink).toHaveBeenCalledExactlyOnceWith(admin, published.id); - expect(plugin.settings.links).toEqual([other]); + expect(plugin.sharedLinks.all()).toEqual([other]); expect(saveSettings).toHaveBeenCalledTimes(1); }); diff --git a/packages/plugin/tests/links/record.test.ts b/packages/plugin/tests/links/record.test.ts index e914ea0c..9558671c 100644 --- a/packages/plugin/tests/links/record.test.ts +++ b/packages/plugin/tests/links/record.test.ts @@ -4,13 +4,17 @@ import { type LinkRecord, noteName, onRelay, - parseLinkRecord, + parseStoredLink, + parseTradedLink, renamedLinks, + tradedLink, } from "@/links/record"; const record: LinkRecord = { id: "abcdefghijklmnopqrstuv", url: "https://relay.example/s/abcdefghijklmnopqrstuv#key", + rev: 3, + author: "laptop", path: "a/b/note.md", showTitle: true, detached: false, @@ -24,13 +28,13 @@ const record: LinkRecord = { const relay = { relayUrl: "https://relay.example", relaySecret: "secret" }; -describe("parseLinkRecord", () => { +describe("parseStoredLink", () => { it.each([ record, { ...record, expires: 2_000_000_000, maxViews: 5, salt: "salt" }, { ...record, showTitle: false, images: false, detached: true }, ])("keeps a complete record %#", (value) => { - expect(parseLinkRecord(value)).toEqual(value); + expect(parseStoredLink(value)).toEqual(value); }); it.each([ @@ -46,7 +50,7 @@ describe("parseLinkRecord", () => { { id: 1, url: "url" }, { id: "id", url: null }, ])("drops a value without a string id and url %#", (value) => { - expect(parseLinkRecord(value)).toBeNull(); + expect(parseStoredLink(value)).toBeNull(); }); it.each([ @@ -58,15 +62,17 @@ describe("parseLinkRecord", () => { "migrates the original title choice, with an explicit showTitle winning %#", (choice, showTitle) => { const { showTitle: _showTitle, ...original } = record; - expect(parseLinkRecord({ ...original, ...choice })?.showTitle).toBe( + expect(parseStoredLink({ ...original, ...choice })?.showTitle).toBe( showTitle, ); }, ); it("repairs an incomplete record without losing its address", () => { - expect(parseLinkRecord({ id: record.id, url: record.url })).toEqual({ + expect(parseStoredLink({ id: record.id, url: record.url })).toEqual({ ...record, + rev: 0, + author: "", path: "", detached: true, createdAt: 0, @@ -75,6 +81,8 @@ describe("parseLinkRecord", () => { }); it.each([ + ["rev", "3", { rev: 0 }], + ["author", 1, { author: "" }], ["path", false, { path: "", detached: true }], ["showTitle", 1, { showTitle: true }], ["createdAt", "today", { createdAt: 0 }], @@ -85,7 +93,7 @@ describe("parseLinkRecord", () => { ["images", "yes", { images: true }], ["detached", "yes", { detached: false }], ] as const)("repairs an invalid %s", (field, value, repaired) => { - expect(parseLinkRecord({ ...record, [field]: value })).toEqual({ + expect(parseStoredLink({ ...record, [field]: value })).toEqual({ ...record, ...repaired, }); @@ -96,9 +104,9 @@ describe("parseLinkRecord", () => { const incomplete = { ...record }; Reflect.deleteProperty(incomplete, field); if (field === "id" || field === "url") { - expect(parseLinkRecord(incomplete), field).toBeNull(); + expect(parseStoredLink(incomplete), field).toBeNull(); } else { - expect(parseLinkRecord(incomplete), field).toMatchObject({ + expect(parseStoredLink(incomplete), field).toMatchObject({ id: record.id, url: record.url, }); @@ -107,6 +115,27 @@ describe("parseLinkRecord", () => { }); }); +describe("local and traded fields", () => { + it("keeps what only this device knows across a reload", () => { + const stored = { ...record, vault: "s3|bucket", removed: true as const }; + expect(parseStoredLink(stored)).toEqual(stored); + expect(parseStoredLink({ ...record, vault: 1, removed: "yes" })).toEqual( + record, + ); + }); + + it("never takes or sends this device's fields through the vault storage", () => { + const stored = { ...record, vault: "s3|bucket", removed: true as const }; + expect(tradedLink(stored)).toEqual(record); + expect(parseTradedLink(stored)).toEqual(record); + }); + + it("refuses a traded record whose id could not name a link", () => { + expect(parseTradedLink({ ...record, id: "../spaces/x" })).toBeNull(); + expect(parseTradedLink({ ...record, id: "short" })).toBeNull(); + }); +}); + describe("detachedLinks", () => { it("detaches an exact note and its folder without touching shared prefixes", () => { const nested = { ...record, id: "nested", path: "a/b/sub/note.md" }; diff --git a/packages/plugin/tests/links/remote.test.ts b/packages/plugin/tests/links/remote.test.ts new file mode 100644 index 00000000..a8fc3775 --- /dev/null +++ b/packages/plugin/tests/links/remote.test.ts @@ -0,0 +1,207 @@ +import { FakeStorage } from "@tests/helpers/fake-storage"; +import { RevalidatingStorage } from "@tests/helpers/revalidating-storage"; +import { link } from "@tests/helpers/shared-links"; +import { record } from "@tests/helpers/space-records"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { + decryptJson, + deriveKey, + type EncryptionKey, + encryptJson, +} from "@/crypto"; +import type { LinkRecord } from "@/links/record"; +import { tradeLinks } from "@/links/remote"; +import { syncRecords } from "@/spaces/remote"; + +vi.mock("@/shared/diagnostics", async (importOriginal) => ({ + ...(await importOriginal>()), + reportWarning: vi.fn(), +})); + +let key: EncryptionKey; +let otherKey: EncryptionKey; + +beforeAll(async () => { + key = await deriveKey("vault", new Uint8Array(16)); + otherKey = await deriveKey("other", new Uint8Array(16)); +}); + +const VAULT = "vault"; +const objectName = (each: LinkRecord) => `links/${each.id}.json.enc`; + +async function publish( + storage: FakeStorage, + each: LinkRecord, + withKey = key, +): Promise { + await storage.put(objectName(each), await encryptJson(withKey, each)); +} + +async function stored( + storage: FakeStorage, + each: LinkRecord, +): Promise { + const body = storage.map.get(objectName(each)); + return body ? decryptJson(key, body) : null; +} + +/** Leaves its objects out of the listing, as a lagging one may. */ +class Unlisted extends FakeStorage { + override async list(): Promise { + return []; + } +} + +describe("tradeLinks", () => { + it("publishes a link never traded, without what only this device knows, and binds it to the storage", async () => { + const storage = new FakeStorage(VAULT); + const mine = link("a"); + + const traded = await tradeLinks(storage, key, [mine]); + + expect(traded).toEqual({ + links: [{ ...mine, vault: VAULT }], + changed: true, + }); + expect(await stored(storage, mine)).toEqual(mine); + }); + + it("writes nothing and keeps its own copies while nothing changed", async () => { + const storage = new FakeStorage(VAULT); + const first = await tradeLinks(storage, key, [link("a")]); + const body = storage.map.get(objectName(link("a"))); + + const second = await tradeLinks(storage, key, first.links); + + expect(second.changed).toBe(false); + expect(second.links[0]).toBe(first.links[0]); + expect(storage.map.get(objectName(link("a")))).toBe(body); + }); + + it("takes in the links another device traded", async () => { + const storage = new FakeStorage(VAULT); + const theirs = link("b", "b.md", { author: "phone" }); + await publish(storage, theirs); + + const traded = await tradeLinks(storage, key, [link("a")]); + + expect(traded.links).toEqual([ + { ...link("a"), vault: VAULT }, + { ...theirs, vault: VAULT }, + ]); + }); + + it("lets the newer revision win either way", async () => { + const storage = new FakeStorage(VAULT); + await publish(storage, link("a", "remote.md", { rev: 3 })); + await publish(storage, link("b", "remote.md", { rev: 3 })); + const local = [ + link("a", "local.md", { rev: 2, vault: VAULT }), + link("b", "local.md", { rev: 4, vault: VAULT }), + ]; + + const traded = await tradeLinks(storage, key, local); + + expect(traded.links.map(({ path, rev }) => [path, rev])).toEqual([ + ["remote.md", 3], + ["local.md", 4], + ]); + expect(await stored(storage, link("b"))).toMatchObject({ + path: "local.md", + }); + }); + + it("drops a traded copy the storage no longer lists: removed on another device, even if edited here", async () => { + const storage = new FakeStorage(VAULT); + const edited = link("a", "moved.md", { rev: 5, vault: VAULT }); + + const traded = await tradeLinks(storage, key, [edited]); + + expect(traded).toEqual({ links: [], changed: false }); + expect(storage.map.size).toBe(0); + }); + + it("publishes a copy last traded with another storage instead of dropping it", async () => { + const storage = new FakeStorage(VAULT); + const moved = link("a", "a.md", { vault: "old" }); + + const traded = await tradeLinks(storage, key, [moved]); + + expect(traded.links).toEqual([{ ...moved, vault: VAULT }]); + expect(await stored(storage, moved)).toEqual(link("a")); + }); + + it("deletes a removed link from the storage and forgets it", async () => { + const storage = new FakeStorage(VAULT); + await publish(storage, link("a")); + + const traded = await tradeLinks(storage, key, [ + link("a", "a.md", { vault: VAULT, removed: true }), + ]); + + expect(traded).toEqual({ links: [], changed: true }); + expect(storage.map.size).toBe(0); + }); + + it("deletes a removed link that a lagging listing leaves out", async () => { + const storage = new Unlisted(VAULT); + await publish(storage, link("a")); + + await tradeLinks(storage, key, [ + link("a", "a.md", { vault: VAULT, removed: true }), + ]); + + expect(storage.map.size).toBe(0); + }); + + it("fails whole when a removal does not go through, so it is tried again", async () => { + const storage = new FakeStorage(VAULT); + const failure = new Error("offline"); + vi.spyOn(storage, "delete").mockRejectedValueOnce(failure); + + await expect( + tradeLinks(storage, key, [ + link("a", "a.md", { vault: VAULT, removed: true }), + ]), + ).rejects.toBe(failure); + }); + + it("never writes over or drops a link whose object it cannot read", async () => { + const storage = new FakeStorage(VAULT); + await publish(storage, link("a", "a.md", { rev: 9 }), otherKey); + const body = storage.map.get(objectName(link("a"))); + const mine = link("a", "a.md", { rev: 1, vault: VAULT }); + + const traded = await tradeLinks(storage, key, [mine]); + + expect(traded).toEqual({ links: [mine], changed: false }); + expect(storage.map.get(objectName(link("a")))).toBe(body); + }); + + it("deletes an unreadable object of a link removed here: removal beats any revision", async () => { + const storage = new FakeStorage(VAULT); + await publish(storage, link("a", "a.md", { rev: 9 }), otherKey); + + const traded = await tradeLinks(storage, key, [ + link("a", "a.md", { vault: VAULT, removed: true }), + ]); + + expect(traded).toEqual({ links: [], changed: true }); + expect(storage.map.size).toBe(0); + }); + + it("keeps its read cache apart from the space records' on one storage", async () => { + const storage = new RevalidatingStorage(VAULT); + await publish(storage, link("a")); + await syncRecords(storage, key, [record("s", "S")]); + await tradeLinks(storage, key, []); + await syncRecords(storage, key, []); + const reads = storage.getCalls; + + await tradeLinks(storage, key, []); + await syncRecords(storage, key, []); + + expect(storage.getCalls).toBe(reads); + }); +}); diff --git a/packages/plugin/tests/links/shared-links.test.ts b/packages/plugin/tests/links/shared-links.test.ts index 63e079d3..407354e8 100644 --- a/packages/plugin/tests/links/shared-links.test.ts +++ b/packages/plugin/tests/links/shared-links.test.ts @@ -1,38 +1,49 @@ -import { describe, expect, it, vi } from "vitest"; +import { FakeStorage } from "@tests/helpers/fake-storage"; +import { link, linkDevice, linkId } from "@tests/helpers/shared-links"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { + decryptJson, + deriveKey, + type EncryptionKey, + encryptJson, +} from "@/crypto"; import { noteLinks, noteLinksText } from "@/links/note-links"; -import type { LinkRecord } from "@/links/record"; +import type { StoredLink } from "@/links/record"; import { SharedLinks } from "@/links/shared-links"; import type { PluginHost } from "@/plugin/host"; const PUBLISHED = 1_760_000_000_000; +const VAULT = "vault"; -function link(id: string, path: string, extra?: Partial) { - return { - id, - url: `https://relay.example/s/${id}#key`, - path, - showTitle: true, - detached: false, - createdAt: PUBLISHED, - publishedAt: PUBLISHED, - expires: null, - maxViews: null, - salt: null, - images: true, - ...extra, - } satisfies LinkRecord; -} +let key: EncryptionKey; + +beforeAll(async () => { + key = await deriveKey("vault", new Uint8Array(16)); +}); -function store(links: LinkRecord[]) { - const settings = { links }; - const save = vi.fn(async () => {}); +function store(links: StoredLink[], author?: string) { + const device = linkDevice(links, author); const listener = vi.fn(); - const shared = new SharedLinks(() => settings, save); - shared.subscribe(listener); - return { settings, save, listener, shared }; + device.shared.subscribe(listener); + return { ...device, listener }; } +/** Holds every listing until opened, so a trade stays in flight. */ +class Held extends FakeStorage { + open: () => void = () => {}; + private gate = new Promise((resolve) => { + this.open = resolve; + }); + + override async list(prefix: string): Promise { + await this.gate; + return super.list(prefix); + } +} + +const objectName = (seed: string) => `links/${linkId(seed)}.json.enc`; + describe("SharedLinks", () => { it("detaches a note or folder and excludes its links from of", async () => { const a = link("a", "d/x.md"); @@ -41,30 +52,53 @@ describe("SharedLinks", () => { const { shared, save, listener } = store([a, b, other]); await shared.detach(a.path); expect(shared.of(a.path)).toEqual([]); - expect(shared.all()).toEqual([{ ...a, detached: true }, b, other]); + expect(shared.all()).toEqual([{ ...a, detached: true, rev: 1 }, b, other]); await shared.detach("d"); expect(shared.of(b.path)).toEqual([]); expect(shared.all()).toEqual([ - { ...a, detached: true }, - { ...b, detached: true }, + { ...a, detached: true, rev: 1 }, + { ...b, detached: true, rev: 1 }, other, ]); await shared.detach("d"); expect(save).toHaveBeenCalledTimes(2); expect(listener).toHaveBeenCalledTimes(2); }); - it("saves and tells subscribers on every change", async () => { - const { settings, save, listener, shared } = store([link("a", "x.md")]); + + it("saves and tells subscribers on every change, announcing adds and removals", async () => { + const { settings, save, listener, announce, shared } = store([ + link("a", "x.md"), + ]); await shared.add(link("b", "x.md")); - await shared.published("a", PUBLISHED + 5); - await shared.remove("b"); + await shared.published(linkId("a"), PUBLISHED + 5); + await shared.remove(linkId("b")); expect(settings.links).toEqual([ - link("a", "x.md", { publishedAt: PUBLISHED + 5 }), + link("a", "x.md", { publishedAt: PUBLISHED + 5, rev: 1 }), + link("b", "x.md", { rev: 1, removed: true }), ]); + expect(shared.all()).toEqual([settings.links[0]]); expect(save).toHaveBeenCalledTimes(3); expect(listener).toHaveBeenCalledTimes(3); + expect(announce).toHaveBeenCalledTimes(2); + }); + + it("signs every edit as a new revision by this device", async () => { + const { settings, shared } = store( + [link("a", "d/x.md", { rev: 4 }), link("b", "y.md")], + "phone", + ); + + const added = await shared.add(link("c", "z.md", { rev: 7 })); + await shared.move("d", "e"); + + expect(added).toMatchObject({ rev: 0, author: "phone" }); + expect(settings.links.map(({ rev, author }) => [rev, author])).toEqual([ + [5, "phone"], + [0, "laptop"], + [0, "phone"], + ]); }); it("moves links with their note and ignores unrelated renames", async () => { @@ -79,6 +113,119 @@ describe("SharedLinks", () => { expect(listener).toHaveBeenCalledTimes(1); }); + it("hides a traded link at once and keeps it until a trade deletes it", async () => { + const traded = link("a", "x.md", { vault: VAULT }); + const { settings, shared } = store([traded]); + + await shared.remove(traded.id); + + expect(shared.all()).toEqual([]); + expect(shared.of("x.md")).toEqual([]); + expect(settings.links).toEqual([{ ...traded, rev: 1, removed: true }]); + }); + + it("lets any device of the vault list and revoke a link another one made", async () => { + const storage = new FakeStorage(VAULT); + const laptop = linkDevice(); + const phone = linkDevice([], "phone"); + + const made = await laptop.shared.add(link("a")); + expect(await laptop.shared.sync(storage, key)).toBe(true); + expect(await phone.shared.sync(storage, key)).toBe(false); + expect(phone.shared.all()).toEqual([{ ...made, vault: VAULT }]); + + await phone.shared.remove(made.id); + expect(phone.announce).toHaveBeenCalledOnce(); + expect(await phone.shared.sync(storage, key)).toBe(true); + expect(storage.map.size).toBe(0); + expect(phone.settings.links).toEqual([]); + + expect(await laptop.shared.sync(storage, key)).toBe(false); + expect(laptop.settings.links).toEqual([]); + }); + + it("saves only when a trade changed the list", async () => { + const storage = new FakeStorage(VAULT); + const { save, shared } = linkDevice([link("a")]); + await shared.sync(storage, key); + save.mockClear(); + + expect(await shared.sync(storage, key)).toBe(false); + expect(save).not.toHaveBeenCalled(); + }); + + it("replays an edit made during a trade above the revision the trade took in", async () => { + const storage = new Held(VAULT); + const theirs = link("a", "a.md", { rev: 3, author: "phone" }); + await storage.put(objectName("a"), await encryptJson(key, theirs)); + const { settings, shared } = linkDevice([ + link("a", "a.md", { vault: VAULT }), + ]); + + const trading = shared.sync(storage, key); + await shared.published(linkId("a"), PUBLISHED + 5); + expect(await shared.sync(storage, key)).toBe(false); + expect(settings.links[0]).toMatchObject({ + rev: 1, + publishedAt: PUBLISHED + 5, + }); + storage.open(); + await trading; + + const replayed = { + ...theirs, + rev: 4, + author: "laptop", + publishedAt: PUBLISHED + 5, + }; + expect(settings.links).toEqual([{ ...replayed, vault: VAULT }]); + expect(await shared.sync(storage, key)).toBe(true); + const body = storage.map.get(objectName("a")); + expect(body && (await decryptJson(key, body))).toEqual(replayed); + }); + + it("replays a rename during a trade onto a link the trade brings in", async () => { + const storage = new Held(VAULT); + const theirs = link("a", "x.md", { author: "phone" }); + await storage.put(objectName("a"), await encryptJson(key, theirs)); + const { settings, save, shared } = linkDevice(); + + const trading = shared.sync(storage, key); + await shared.move("x.md", "y.md"); + expect(save).not.toHaveBeenCalled(); + storage.open(); + await trading; + + expect(settings.links).toEqual([ + { ...theirs, path: "y.md", rev: 1, author: "laptop", vault: VAULT }, + ]); + }); + + it("still deletes links a failed trade wrote, removed during or after it", async () => { + const storage = new Held(VAULT); + const failure = new Error("offline"); + const put = storage.put.bind(storage); + vi.spyOn(storage, "put").mockImplementation((name, body) => + name === objectName("b") ? Promise.reject(failure) : put(name, body), + ); + const { settings, shared } = linkDevice([link("a"), link("c"), link("b")]); + + const trading = shared.sync(storage, key); + await shared.remove(linkId("a")); + storage.open(); + await expect(trading).rejects.toBe(failure); + await shared.remove(linkId("c")); + expect(storage.map.has(objectName("a"))).toBe(true); + expect(storage.map.has(objectName("c"))).toBe(true); + expect(shared.all()).toEqual([link("b")]); + + vi.mocked(storage.put).mockImplementation(put); + await shared.sync(storage, key); + + expect([...storage.map.keys()]).toEqual([objectName("b")]); + expect(settings.links).toEqual([{ ...link("b"), vault: VAULT }]); + }); + it("tells subscribers when a link expires, while anyone listens", () => { vi.useFakeTimers({ now: PUBLISHED }); try { @@ -89,10 +236,7 @@ describe("SharedLinks", () => { expect(vi.getTimerCount()).toBe(0); const later = link("b", "x.md", { expires: PUBLISHED / 1000 + 600 }); - const idle = new SharedLinks( - () => ({ links: [later] }), - async () => {}, - ); + const { shared: idle } = linkDevice([later]); const unsubscribe = idle.subscribe(() => {}); expect(vi.getTimerCount()).toBe(1); unsubscribe(); @@ -104,10 +248,12 @@ describe("SharedLinks", () => { it("reads the settings object current at each call", async () => { let settings = { links: [link("a", "x.md")] }; - const shared = new SharedLinks( - () => settings, - async () => {}, - ); + const shared = new SharedLinks({ + settings: () => settings, + save: async () => {}, + author: () => "laptop", + announce: () => {}, + }); settings = { links: [] }; expect(shared.all()).toEqual([]); await shared.add(link("b", "y.md")); @@ -116,7 +262,7 @@ describe("SharedLinks", () => { }); describe("noteLinks", () => { - function host(links: LinkRecord[], mtime: number) { + function host(links: StoredLink[], mtime: number) { const { shared } = store(links); return { sharedLinks: shared, diff --git a/packages/plugin/tests/settings/transfer-controller.test.ts b/packages/plugin/tests/settings/transfer-controller.test.ts index 99c5592a..160109b3 100644 --- a/packages/plugin/tests/settings/transfer-controller.test.ts +++ b/packages/plugin/tests/settings/transfer-controller.test.ts @@ -93,6 +93,8 @@ describe("settings import", () => { const link: LinkRecord = { id: "one", url: "https://relay.example/s/one", + rev: 0, + author: "", path: "note.md", showTitle: true, detached: false, diff --git a/packages/plugin/tests/spaces/records.test.ts b/packages/plugin/tests/spaces/records.test.ts index 1b7ce3fb..9b297954 100644 --- a/packages/plugin/tests/spaces/records.test.ts +++ b/packages/plugin/tests/spaces/records.test.ts @@ -5,7 +5,6 @@ import { deriveKey, type EncryptionKey, encryptJson } from "@/crypto"; import { mountError, spacesOf } from "@/spaces/partition"; import { closeRecord, - isNewer, isSpaceRecord, mergeRecords, type SpaceRecord, @@ -20,14 +19,6 @@ beforeAll(async () => { }); describe("space records", () => { - it("lets the higher revision win, then the larger author", () => { - expect(isNewer(record("a", "x", 2), record("a", "x", 1))).toBe(true); - expect(isNewer(record("a", "x", 1, "b"), record("a", "x", 1, "a"))).toBe( - true, - ); - expect(isNewer(record("a", "x", 1), record("a", "x", 1))).toBe(false); - }); - it("keep a close over edits made offline before it, however many", () => { const open = record("a", "x", 1); const closed = closeRecord(open, "laptop"); diff --git a/packages/plugin/tests/ui/link-actions.test.ts b/packages/plugin/tests/ui/link-actions.test.ts index d5ba9506..7ae0a28b 100644 --- a/packages/plugin/tests/ui/link-actions.test.ts +++ b/packages/plugin/tests/ui/link-actions.test.ts @@ -39,6 +39,8 @@ vi.mock("@/ui", async () => ({ const RECORD: LinkRecord = { id: "one", url: "https://relay.example/one", + rev: 0, + author: "", path: "note.md", showTitle: true, detached: false, @@ -59,10 +61,12 @@ function host(records: LinkRecord[] = [RECORD]) { }; const plugin = { settings, - sharedLinks: new SharedLinks( - () => settings, - async () => {}, - ), + sharedLinks: new SharedLinks({ + settings: () => settings, + save: async () => {}, + author: () => "laptop", + announce: () => {}, + }), app: { workspace: { getActiveFile: () => file }, vault: { getFileByPath: () => ({ stat: { mtime: 1 } }) }, diff --git a/packages/plugin/tests/ui/manage-links-modal.test.ts b/packages/plugin/tests/ui/manage-links-modal.test.ts index 862ac74d..38d702a7 100644 --- a/packages/plugin/tests/ui/manage-links-modal.test.ts +++ b/packages/plugin/tests/ui/manage-links-modal.test.ts @@ -86,6 +86,8 @@ const rows: { name: string; desc: string; buttons: Button[] }[] = []; const RECORD: LinkRecord = { id: "one", url: "https://relay.example/one", + rev: 0, + author: "", path: "note.md", showTitle: true, detached: false, @@ -114,10 +116,12 @@ function host( relayUrl: "https://relay.example", relaySecret: "secret", }; - const sharedLinks = new SharedLinks( - () => settings, - async () => {}, - ); + const sharedLinks = new SharedLinks({ + settings: () => settings, + save: async () => {}, + author: () => "laptop", + announce: () => {}, + }); const note = mtime === null ? null : { path: RECORD.path, stat: { mtime } }; const plugin = { settings, @@ -220,6 +224,8 @@ describe("ManageLinksModal", () => { expect(updateSharedLinks).toHaveBeenCalledWith(plugin, [ { ...RECORD, + rev: 1, + author: "laptop", path: "moved.md", }, ]); diff --git a/packages/plugin/tests/ui/note-link-action.test.ts b/packages/plugin/tests/ui/note-link-action.test.ts index ae753a3a..9170e56e 100644 --- a/packages/plugin/tests/ui/note-link-action.test.ts +++ b/packages/plugin/tests/ui/note-link-action.test.ts @@ -11,6 +11,8 @@ vi.mock("@/ui/links/link-menu", () => ({ openManageLinks: vi.fn() })); const RECORD: LinkRecord = { id: "one", url: "https://relay.example/one", + rev: 0, + author: "", path: "note.md", showTitle: true, detached: false, @@ -63,10 +65,12 @@ function host() { }; const cleanup: (() => void)[] = []; const settings = { links: [{ ...RECORD }] }; - const sharedLinks = new SharedLinks( - () => settings, - async () => {}, - ); + const sharedLinks = new SharedLinks({ + settings: () => settings, + save: async () => {}, + author: () => "laptop", + announce: () => {}, + }); const plugin = { sharedLinks, app: { diff --git a/packages/plugin/tests/utils/revision.test.ts b/packages/plugin/tests/utils/revision.test.ts new file mode 100644 index 00000000..0d8a990a --- /dev/null +++ b/packages/plugin/tests/utils/revision.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { isNewer } from "@/utils/revision"; + +describe("isNewer", () => { + it("lets the higher revision win, then the larger author", () => { + expect(isNewer({ rev: 2, author: "a" }, { rev: 1, author: "z" })).toBe( + true, + ); + expect(isNewer({ rev: 1, author: "b" }, { rev: 1, author: "a" })).toBe( + true, + ); + expect(isNewer({ rev: 1, author: "a" }, { rev: 1, author: "a" })).toBe( + false, + ); + }); +}); From 9fda9406d758aa86e554187ba7ba8d47ce27a708 Mon Sep 17 00:00:00 2001 From: kitbyte Date: Sun, 11 Oct 2026 01:19:04 +0300 Subject: [PATCH 03/11] feat: data.json secrets sealed with a device key in Obsidian's keychain --- .claude/architecture.md | 4 +- .claude/security.md | 5 +- .claude/share-links.md | 5 +- README.md | 2 +- .../plugin/src/core/passphrase-manager.ts | 4 + packages/plugin/src/crypto/device-key.ts | 80 ++++++++ .../plugin/src/crypto/passphrase-cache.ts | 55 +----- packages/plugin/src/main.ts | 39 +++- packages/plugin/src/plugin/bootstrap.ts | 5 +- packages/plugin/src/settings/envelope.ts | 87 +++++++++ .../plugin/src/settings/sections/backend.ts | 2 +- .../plugin/src/settings/sections/security.ts | 2 +- .../tests/core/passphrase-manager.test.ts | 13 +- .../plugin/tests/crypto/device-key.test.ts | 95 ++++++++++ .../tests/crypto/passphrase-cache.test.ts | 102 +++++------ packages/plugin/tests/helpers/device-key.ts | 34 ++++ .../plugin/tests/settings/envelope.test.ts | 173 ++++++++++++++++++ .../settings/transfer-controller.test.ts | 11 +- 18 files changed, 600 insertions(+), 118 deletions(-) create mode 100644 packages/plugin/src/crypto/device-key.ts create mode 100644 packages/plugin/src/settings/envelope.ts create mode 100644 packages/plugin/tests/crypto/device-key.test.ts create mode 100644 packages/plugin/tests/helpers/device-key.ts create mode 100644 packages/plugin/tests/settings/envelope.test.ts diff --git a/.claude/architecture.md b/.claude/architecture.md index 13a53992..df4c48a1 100644 --- a/.claude/architecture.md +++ b/.claude/architecture.md @@ -6,7 +6,7 @@ - `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 @@ -15,7 +15,7 @@ - `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; `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; `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) +- `settings/` - settings model, `envelope.ts` (the secrets of `data.json`, sealed by the device key), transfer, and the settings tab sections; `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 diff --git a/.claude/security.md b/.claude/security.md index 28aa0ff1..27278afb 100644 --- a/.claude/security.md +++ b/.claude/security.md @@ -9,6 +9,7 @@ Follow Obsidian's Developer Policies and Plugin Guidelines: - Do not collect vault contents, filenames, or personal information unless strictly necessary and explicitly consented; store or transmit vault contents only when essential and consented. - Clearly disclose any external services used, data sent, and risks. Features requiring cloud services need explicit opt-in. - No deceptive patterns, ads, or spammy notifications. -- Shared folders: `data.json` holds each share's key and a participant's broker token in plain text, like the storage credentials; the vault key also opens them through the space records. To presign for participants the relay keeps the owner's S3 credentials (its KV), so its operator is trusted with them. A participant with write access can delete the share's objects; history, not revocation, repairs that. -- Share links: creating one sends a rendered copy of one note, sealed on the device, to the user's own relay (disclosed in README). `data.json` keeps each link whole, its key included, so the owner can copy it again, and the person's other devices get it through the vault storage, sealed under the vault key (`links/`); the passphrase is never stored. A reader who ticks **Remember on this browser** keeps the derived non-extractable key and gate in that browser's IndexedDB until the link expires or is gone, never the passphrase. The relay holds ciphertext, a SHA-256 of the link's gate (derived from the key in the fragment, and the passphrase if any: the id alone serves and updates nothing), the view limit and expiry. Everything published passes `links/sanitize.ts` (no properties, comments, embedded notes or paths) and the viewer's DOMPurify; the viewer page runs under a CSP with no inline script, no `unsafe-eval` and no framing, and the key stays in the URL fragment, which in-page jumps must not replace. The relay's operator serves the viewer script, so they are trusted as the owner. +- Secrets on the device: `data.json` keeps the storage credentials, relay secret, Cloudflare token, share records (keys, broker tokens, roots) and share links in one AES-GCM envelope (`secrets`, `settings/envelope.ts`); the other settings stay readable. Its key, and the passphrase cache's, is the device key (`crypto/device-key.ts`) in Obsidian's SecretStorage (`mdsync-device-key`): per vault id, outside the vault, sealed by the OS keychain where Electron's safeStorage works (1.13.7 desktop does), never synced. A copy of the vault folder (backup, git, a cloud folder) opens nothing; earlier builds' plain files and `device.key` are migrated at launch. An envelope this device cannot open is written back untouched until storage is set up again, and a notice says to import a setup link. Other plugins can read SecretStorage, as they can read `data.json`. +- Shared folders: each share's key and a participant's broker token sit in that envelope, like the storage credentials; the vault key also opens them through the space records. To presign for participants the relay keeps the owner's S3 credentials (its KV), so its operator is trusted with them. A participant with write access can delete the share's objects; history, not revocation, repairs that. +- Share links: creating one sends a rendered copy of one note, sealed on the device, to the user's own relay (disclosed in README). The envelope keeps each link whole, its key included, so the owner can copy it again, and the person's other devices get it through the vault storage, sealed under the vault key (`links/`); the passphrase is never stored. A reader who ticks **Remember on this browser** keeps the derived non-extractable key and gate in that browser's IndexedDB until the link expires or is gone, never the passphrase. The relay holds ciphertext, a SHA-256 of the link's gate (derived from the key in the fragment, and the passphrase if any: the id alone serves and updates nothing), the view limit and expiry. Everything published passes `links/sanitize.ts` (no properties, comments, embedded notes or paths) and the viewer's DOMPurify; the viewer page runs under a CSP with no inline script, no `unsafe-eval` and no framing, and the key stays in the URL fragment, which in-page jumps must not replace. The relay's operator serves the viewer script, so they are trusted as the owner. - Register and clean up all DOM, app, and interval listeners with the provided `register*` helpers so the plugin unloads safely; teardown is idempotent so reload/unload leaks nothing. diff --git a/.claude/share-links.md b/.claude/share-links.md index d30056d2..d9ed18e8 100644 --- a/.claude/share-links.md +++ b/.claude/share-links.md @@ -205,8 +205,9 @@ Properties are never published (no option): the renderer does not draw them. link, or one made through another relay than the vault's current one, can only be removed from the list: nothing here can stop it. A rename (of the note or a folder above it) updates the stored path (`plugin/links.ts`). -- `data.json` keeps the link with its key in plain text, like share keys; the - vault storage keeps it sealed under the vault key. +- `data.json` keeps the link with its key in the device key's envelope, like + share keys (see [security](security.md)); the vault storage keeps it sealed + under the vault key. ## Steps diff --git a/README.md b/README.md index ceaac0fe..f6c3067b 100644 --- a/README.md +++ b/README.md @@ -319,7 +319,7 @@ MDSync has no telemetry. It connects only to: You need an account with your storage provider. The relay needs a Cloudflare account, and Google Drive needs your own Google Cloud OAuth client. -Storage credentials, share keys, the relay secret and the Cloudflare token are stored in the plugin's `data.json` on each device, like other plugins' settings. Diagnostics logs stay on the device and never sync. +Storage credentials, share keys, share links, the relay secret and the Cloudflare token are stored in the plugin's `data.json` on each device, encrypted with a key kept in Obsidian's keychain for that vault, outside the vault folder. A copy of the vault (a backup, git, a cloud folder) carries none of them; on a copied or moved vault, import a [setup link](#device-transfer) from another device. Diagnostics logs stay on the device and never sync. **What the relay sees.** It never sees file contents, file names or keys: live edits and presence travel encrypted under each space's key. It does see a hash of your storage identity, a device id per connection, and when each sync and note switch happens. For a shared folder with participants, it keeps your S3 access key in its storage to sign requests for them, plus the names you invited people by. With Google Drive, it exchanges your sign-in for tokens and sees your Drive refresh token. diff --git a/packages/plugin/src/core/passphrase-manager.ts b/packages/plugin/src/core/passphrase-manager.ts index fa1f046d..367af083 100644 --- a/packages/plugin/src/core/passphrase-manager.ts +++ b/packages/plugin/src/core/passphrase-manager.ts @@ -1,6 +1,7 @@ import type { DataAdapter } from "obsidian"; import type { EncryptionKey } from "@/crypto"; +import type { DeviceKey } from "@/crypto/device-key"; import type { LiveKeys } from "@/crypto/live-keys"; import { clearCachedPassphrase, @@ -52,6 +53,7 @@ export class PassphraseManager { private readonly adapter: DataAdapter, private readonly configDir: string, private readonly settings: MdsyncSettings, + private readonly deviceKey: DeviceKey, ) {} has(): boolean { @@ -176,6 +178,7 @@ export class PassphraseManager { await saveCachedPassphrase( this.adapter, this.configDir, + this.deviceKey, this.passphrase, this.bindingSignature(), ); @@ -250,6 +253,7 @@ export class PassphraseManager { const cached = await loadCachedPassphrase( this.adapter, this.configDir, + this.deviceKey, this.bindingSignature(), ); if (!cached) return false; diff --git a/packages/plugin/src/crypto/device-key.ts b/packages/plugin/src/crypto/device-key.ts new file mode 100644 index 00000000..6aea40ca --- /dev/null +++ b/packages/plugin/src/crypto/device-key.ts @@ -0,0 +1,80 @@ +import type { DataAdapter, SecretStorage } from "obsidian"; + +import { PLUGIN_ID } from "@/constants"; +import { reportWarning } from "@/shared"; +import { base64ToBytes, bytesToBase64 } from "@/utils"; +import { deletePath, readBinary } from "@/vault/io"; + +import { DEVICE_KEY_BYTES, DEVICE_KEY_FILE_NAME } from "./constants"; +import { type EncryptionKey, importAesKey, randomBytes } from "./index"; + +const SECRET_ID = `${PLUGIN_ID}-device-key`; + +/** + * Seals what this device keeps in the plugin folder. It lives in Obsidian's keychain, outside the vault, so a + * copy of the vault folder opens none of it. + */ +export class DeviceKey { + private key: Promise | null = null; + private created: Promise | null = null; + + constructor( + private readonly secrets: SecretStorage, + private readonly adapter: DataAdapter, + private readonly configDir: string, + ) {} + + /** Null until something sealed needed one, or when the keychain lost it. */ + read(): Promise { + this.key ??= this.load(); + return this.key; + } + + /** One key however many callers ask at once: a second would orphan what the first sealed. */ + readOrCreate(): Promise { + this.created ??= this.read().then((key) => key ?? this.create()); + return this.created; + } + + private async load(): Promise { + await this.moveLegacyFile(); + const value = this.secrets.getSecret(SECRET_ID); + if (!value) return null; + try { + const bytes = base64ToBytes(value); + return bytes.length === DEVICE_KEY_BYTES + ? await importAesKey(bytes) + : null; + } catch { + return null; + } + } + + private async create(): Promise { + const bytes = randomBytes(DEVICE_KEY_BYTES); + this.secrets.setSecret(SECRET_ID, bytesToBase64(bytes)); + const key = await importAesKey(bytes); + this.key = Promise.resolve(key); + return key; + } + + /** Earlier builds kept the key beside what it sealed; retried at every launch until the file is gone. */ + private async moveLegacyFile(): Promise { + const path = `${this.configDir.replace(/\/$/, "")}/plugins/${PLUGIN_ID}/${DEVICE_KEY_FILE_NAME}`; + try { + if (!(await this.adapter.exists(path))) return; + if (!this.secrets.getSecret(SECRET_ID)) { + const bytes = await readBinary(this.adapter, path); + if (bytes.length === DEVICE_KEY_BYTES) { + this.secrets.setSecret(SECRET_ID, bytesToBase64(bytes)); + } + } + await deletePath(this.adapter, path); + } catch (err) { + reportWarning( + "Could not move the device key into Obsidian's keychain.", + err, + ); + } + } +} diff --git a/packages/plugin/src/crypto/passphrase-cache.ts b/packages/plugin/src/crypto/passphrase-cache.ts index 7588565b..3e6bab64 100644 --- a/packages/plugin/src/crypto/passphrase-cache.ts +++ b/packages/plugin/src/crypto/passphrase-cache.ts @@ -1,18 +1,10 @@ import type { DataAdapter } from "obsidian"; import { PLUGIN_ID } from "@/constants"; -import { - DEVICE_KEY_BYTES, - DEVICE_KEY_FILE_NAME, - PASSPHRASE_CACHE_FILE_NAME, -} from "@/crypto/constants"; +import { PASSPHRASE_CACHE_FILE_NAME } from "@/crypto/constants"; import { deletePath, readBinary, writeBinary } from "@/vault/io"; -import { - decryptJson, - type EncryptionKey, - encryptJson, - importAesKey, - randomBytes, -} from "./index"; + +import type { DeviceKey } from "./device-key"; +import { decryptJson, encryptJson } from "./index"; interface CachedPayload { version: 1; @@ -23,11 +15,12 @@ interface CachedPayload { export async function loadCachedPassphrase( adapter: DataAdapter, configDir: string, + deviceKey: DeviceKey, binding: string, ): Promise { const path = cachePath(configDir); if (!(await adapter.exists(path))) return null; - const key = await loadDeviceKey(adapter, configDir); + const key = await deviceKey.read(); if (!key) return null; try { const blob = await readBinary(adapter, path); @@ -43,16 +36,11 @@ export async function loadCachedPassphrase( export async function saveCachedPassphrase( adapter: DataAdapter, configDir: string, + deviceKey: DeviceKey, passphrase: string, binding: string, ): Promise { - let key = await loadDeviceKey(adapter, configDir); - if (!key) { - // An available passphrase lets us rebuild the cache without the old key. - const fresh = randomBytes(DEVICE_KEY_BYTES); - key = await importAesKey(fresh); - await writeBinary(adapter, deviceKeyPath(configDir), fresh); - } + const key = await deviceKey.readOrCreate(); const payload: CachedPayload = { version: 1, passphrase, binding }; const blob = await encryptJson(key, payload); await writeBinary(adapter, cachePath(configDir), blob); @@ -65,30 +53,7 @@ export async function clearCachedPassphrase( await deletePath(adapter, cachePath(configDir)); } -async function loadDeviceKey( - adapter: DataAdapter, - configDir: string, -): Promise { - const path = deviceKeyPath(configDir); - if (!(await adapter.exists(path))) return null; - try { - const bytes = await readBinary(adapter, path); - if (bytes.length !== DEVICE_KEY_BYTES) return null; - return await importAesKey(bytes); - } catch { - return null; - } -} - -function pluginFolder(configDir: string): string { - const trimmed = configDir.endsWith("/") ? configDir.slice(0, -1) : configDir; - return `${trimmed}/plugins/${PLUGIN_ID}`; -} - function cachePath(configDir: string): string { - return `${pluginFolder(configDir)}/${PASSPHRASE_CACHE_FILE_NAME}`; -} - -function deviceKeyPath(configDir: string): string { - return `${pluginFolder(configDir)}/${DEVICE_KEY_FILE_NAME}`; + const trimmed = configDir.endsWith("/") ? configDir.slice(0, -1) : configDir; + return `${trimmed}/plugins/${PLUGIN_ID}/${PASSPHRASE_CACHE_FILE_NAME}`; } diff --git a/packages/plugin/src/main.ts b/packages/plugin/src/main.ts index b5233a72..7f2e87c1 100644 --- a/packages/plugin/src/main.ts +++ b/packages/plugin/src/main.ts @@ -7,10 +7,12 @@ import { type PassphraseManager, type StatePersister, } from "@/core"; +import { DeviceKey } from "@/crypto/device-key"; import { registerReadOnlyLock } from "@/editor/read-only"; import { registerEditorSigns, type SignsHandle } from "@/editor/signs"; import { SharedLinks } from "@/links"; import type { Unseen } from "@/presence"; +import { SettingsEnvelope } from "@/settings/envelope"; import { canSync, DEFAULT_SETTINGS, @@ -98,6 +100,8 @@ export default class MdsyncPlugin extends Plugin implements PluginHost { }, }); private settingsTab?: MdsyncSettingTab; + private deviceKey!: DeviceKey; + private envelope!: SettingsEnvelope; private statePersister!: StatePersister; private scopeRefreshTimer: number | null = null; private editorSigns: SignsHandle | null = null; @@ -109,11 +113,19 @@ export default class MdsyncPlugin extends Plugin implements PluginHost { async onload(): Promise { await loadNodeFs(); + const { secretStorage, vault } = this.app; + this.deviceKey = new DeviceKey( + secretStorage, + vault.adapter, + vault.configDir, + ); + this.envelope = new SettingsEnvelope(this.deviceKey); await this.loadSettings(); const registerShare = createShareRegistration(this); const runtime = await bootstrapPluginRuntime({ app: this.app, settings: this.settings, + deviceKey: this.deviceKey, links: this.sharedLinks, onPushComplete: (space) => { this.realtime.hub.signal(space.id); @@ -192,6 +204,14 @@ export default class MdsyncPlugin extends Plugin implements PluginHost { // Saved first: closing the wizard must not bring it back on every start. await this.saveSettings(); this.app.workspace.onLayoutReady(() => this.openSetup()); + } else if (this.envelope.unreadable()) { + this.app.workspace.onLayoutReady(() => + notifyAction( + "MDSync cannot read its saved connection on this device: this vault was copied or moved, or Obsidian's keychain was reset. Import a setup link from another device, or set up again.", + "Set up", + () => this.openSetup(), + ), + ); } } @@ -214,19 +234,28 @@ export default class MdsyncPlugin extends Plugin implements PluginHost { } async loadSettings(): Promise { - const data = (await this.loadData()) as Partial | null; + const data = (await this.loadData()) as Record | null; this.freshInstall = data === null; - this.settings = mergeSettings(data); + const { stored, inTheClear } = await this.envelope.open(data); + this.settings = mergeSettings(stored); + // Sealed now, not at the next change: the secrets of an earlier build must not linger in the clear. + if (inTheClear) await this.writeSettings(); } async saveSettings(): Promise { - const save = this.savingSettings.then(() => this.saveData(this.settings)); - this.savingSettings = save.catch(() => undefined); - await save; + await this.writeSettings(); // Any settings write can change the room, the relay credentials or a note's space. this.realtime.refresh(); } + private writeSettings(): Promise { + const save = this.savingSettings.then(async () => + this.saveData(await this.envelope.seal(this.settings)), + ); + this.savingSettings = save.catch(() => undefined); + return save; + } + async resetLocalState(): Promise { this.controller.cancel(); await this.controller.between(() => this.statePersister.reset()); diff --git a/packages/plugin/src/plugin/bootstrap.ts b/packages/plugin/src/plugin/bootstrap.ts index 79d9ce86..b62f4ffd 100644 --- a/packages/plugin/src/plugin/bootstrap.ts +++ b/packages/plugin/src/plugin/bootstrap.ts @@ -6,6 +6,7 @@ import { PassphraseManager, StatePersister, } from "@/core"; +import type { DeviceKey } from "@/crypto/device-key"; import type { SharedLinks } from "@/links"; import { isStorageConfigured, type MdsyncSettings } from "@/settings/model"; import { reportWarning } from "@/shared"; @@ -29,6 +30,7 @@ export interface PluginRuntime { interface BootstrapPluginRuntimeOptions { app: App; settings: MdsyncSettings; + deviceKey: DeviceKey; /** Traded with the vault storage on every full refresh, after the records. */ links: Pick; /** Also for a published space record: other devices learn of shares from the vault. */ @@ -46,7 +48,7 @@ interface BootstrapPluginRuntimeOptions { export async function bootstrapPluginRuntime( options: BootstrapPluginRuntimeOptions, ): Promise { - const { app, settings, persistSettings, liveNotes } = options; + const { app, settings, deviceKey, persistSettings, liveNotes } = options; const { adapter, configDir } = app.vault; const logs = new LogService(adapter, configDir); await logs.load(); @@ -57,6 +59,7 @@ export async function bootstrapPluginRuntime( adapter, configDir, settings, + deviceKey, ); const spaces = new SpaceRecords(settings, persistSettings); diff --git a/packages/plugin/src/settings/envelope.ts b/packages/plugin/src/settings/envelope.ts new file mode 100644 index 00000000..11cf263b --- /dev/null +++ b/packages/plugin/src/settings/envelope.ts @@ -0,0 +1,87 @@ +import { decryptJson, encryptJson } from "@/crypto"; +import type { DeviceKey } from "@/crypto/device-key"; +import { base64ToBytes, bytesToBase64 } from "@/utils"; + +import { type MdsyncSettings, mergeSettings } from "./model"; + +/** The secrets, and the share state that means nothing without them: kept or lost as one. */ +const SEALED = [ + "storageConfigs", + "relaySecret", + "cloudflareToken", + "spaces", + "localRoots", + "spacesVault", + "links", +] as const satisfies readonly (keyof MdsyncSettings)[]; + +type Stored = Record; + +/** `data.json` with the secrets sealed by the device key under `secrets`; every other field stays readable. */ +export class SettingsEnvelope { + /** One this device cannot open, written back until the sealed settings change: the keychain may recover. */ + private held: string | null = null; + private heldView = ""; + + constructor(private readonly deviceKey: DeviceKey) {} + + unreadable(): boolean { + return this.held !== null; + } + + /** + * `inTheClear`: the file holds secrets anyone who copies it can read, written by an earlier build. Beside an + * envelope they are a downgrade's leftovers, and the envelope wins. + */ + async open( + data: Stored | null, + ): Promise<{ stored: Stored | null; inTheClear: boolean }> { + if (!data) return { stored: null, inTheClear: false }; + const { secrets, ...rest } = data; + const inTheClear = SEALED.some((field) => field in rest); + if (typeof secrets !== "string") return { stored: data, inTheClear }; + const plain = withoutSealed(rest); + try { + const key = await this.deviceKey.read(); + const opened = + key && (await decryptJson(key, base64ToBytes(secrets))); + if (typeof opened === "object" && opened !== null) { + // Only the sealed fields: a blob sealed for another purpose must not leak into the settings. + return { stored: { ...plain, ...sealedOf(opened) }, inTheClear }; + } + } catch { + // Another key sealed it, or none is left: held below. + } + this.held = secrets; + this.heldView = JSON.stringify(sealedOf(mergeSettings(plain))); + return { stored: plain, inTheClear }; + } + + async seal(settings: MdsyncSettings): Promise { + const plain = withoutSealed(settings); + const sealed = sealedOf(settings); + if (this.held !== null && JSON.stringify(sealed) === this.heldView) { + return { ...plain, secrets: this.held }; + } + const key = await this.deviceKey.readOrCreate(); + const secrets = bytesToBase64(await encryptJson(key, sealed)); + this.held = null; + return { ...plain, secrets }; + } +} + +function sealedOf(fields: object): Stored { + const source = fields as Stored; + return Object.fromEntries( + SEALED.filter((field) => field in source).map((field) => [ + field, + source[field], + ]), + ); +} + +function withoutSealed(fields: object): Stored { + const plain: Stored = { ...fields }; + for (const field of SEALED) delete plain[field]; + return plain; +} diff --git a/packages/plugin/src/settings/sections/backend.ts b/packages/plugin/src/settings/sections/backend.ts index 640ce4e2..b3d87e57 100644 --- a/packages/plugin/src/settings/sections/backend.ts +++ b/packages/plugin/src/settings/sections/backend.ts @@ -15,7 +15,7 @@ export function renderBackendSection( ): void { new Setting(parent).setName("Backend").setHeading(); new Setting(parent).setDesc( - "Credentials are stored on this device in plain text. Inviting someone to a shared folder hands them to the relay, which signs that folder's requests.", + "Credentials are stored on this device, encrypted with a key in Obsidian's keychain; a copy of the vault does not carry them. Inviting someone to a shared folder hands them to the relay, which signs that folder's requests.", ); const settings = plugin.settings; diff --git a/packages/plugin/src/settings/sections/security.ts b/packages/plugin/src/settings/sections/security.ts index 384265eb..6b64afc5 100644 --- a/packages/plugin/src/settings/sections/security.ts +++ b/packages/plugin/src/settings/sections/security.ts @@ -20,7 +20,7 @@ export function renderSecuritySection( new Setting(parent) .setName("Cache passphrase between launches") .setDesc( - "Saves a verified passphrase on this device. Its encryption key is stored in the same plugin folder; disable on shared devices.", + "Saves a verified passphrase on this device, encrypted with a key kept in Obsidian's keychain, outside the vault. Disable on shared devices.", ) .addToggle((t) => t.setValue(plugin.settings.cachePassphrase).onChange(async (v) => { diff --git a/packages/plugin/tests/core/passphrase-manager.test.ts b/packages/plugin/tests/core/passphrase-manager.test.ts index bafee0bc..4e521a55 100644 --- a/packages/plugin/tests/core/passphrase-manager.test.ts +++ b/packages/plugin/tests/core/passphrase-manager.test.ts @@ -2,6 +2,7 @@ import type { DataAdapter } from "obsidian"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { PassphraseManager } from "@/core/passphrase-manager"; +import type { DeviceKey } from "@/crypto/device-key"; import { loadCachedPassphrase, saveCachedPassphrase, @@ -39,10 +40,13 @@ function resolved(name: string) { function manager(answers: string[]): PassphraseManager { const ask = vi.fn(async () => answers.shift() ?? null); - return new PassphraseManager(ask, {} as DataAdapter, ".obsidian", { - ...DEFAULT_SETTINGS, - cachePassphrase: true, - }); + return new PassphraseManager( + ask, + {} as DataAdapter, + ".obsidian", + { ...DEFAULT_SETTINGS, cachePassphrase: true }, + {} as DeviceKey, + ); } function configured(bucket: string): MdsyncSettings { @@ -70,6 +74,7 @@ function vaultManager( {} as DataAdapter, ".obsidian", settings, + {} as DeviceKey, ); } diff --git a/packages/plugin/tests/crypto/device-key.test.ts b/packages/plugin/tests/crypto/device-key.test.ts new file mode 100644 index 00000000..77ae95d3 --- /dev/null +++ b/packages/plugin/tests/crypto/device-key.test.ts @@ -0,0 +1,95 @@ +import { deviceKey, FakeSecretStorage } from "@tests/helpers/device-key"; +import { InMemoryAdapter } from "@tests/helpers/in-memory-adapter"; +import { describe, expect, it, vi } from "vitest"; + +import { decryptJson, encryptJson, importAesKey } from "@/crypto"; + +vi.mock("@/shared/diagnostics", async (importOriginal) => ({ + ...(await importOriginal>()), + reportWarning: vi.fn(), +})); + +const LEGACY = ".obsidian/plugins/mdsync/device.key"; +const SECRET = "mdsync-device-key"; + +describe("DeviceKey", () => { + it("has none until one is needed, then one for every caller and every launch", async () => { + const secrets = new FakeSecretStorage(); + const key = deviceKey(secrets); + expect(await key.read()).toBeNull(); + expect(secrets.map.size).toBe(0); + + const [first, second] = await Promise.all([ + key.readOrCreate(), + key.readOrCreate(), + ]); + expect(first).toBe(second); + expect(await key.read()).toBe(first); + expect(secrets.listSecrets()).toEqual([SECRET]); + + const sealed = await encryptJson(first, { a: 1 }); + const restarted = await deviceKey(secrets).read(); + expect(restarted && (await decryptJson(restarted, sealed))).toEqual({ + a: 1, + }); + }); + + it("replaces a key the keychain holds damaged", async () => { + const secrets = new FakeSecretStorage(); + secrets.setSecret(SECRET, "not a key"); + const key = deviceKey(secrets); + + expect(await key.read()).toBeNull(); + await key.readOrCreate(); + expect(secrets.getSecret(SECRET)).not.toBe("not a key"); + }); + + it("moves an earlier build's key file into the keychain, so what it sealed still opens", async () => { + const vault = new InMemoryAdapter(); + const bytes = new Uint8Array(32).fill(7); + const sealed = await encryptJson(await importAesKey(bytes), { a: 1 }); + vault.putText(LEGACY, "\x07".repeat(32)); + const secrets = new FakeSecretStorage(); + + const key = await deviceKey(secrets, vault.asDataAdapter()).read(); + + expect(key && (await decryptJson(key, sealed))).toEqual({ a: 1 }); + expect(vault.hasFile(LEGACY)).toBe(false); + }); + + it("deletes a leftover key file without letting it replace the keychain's", async () => { + const vault = new InMemoryAdapter(); + vault.putText(LEGACY, "x".repeat(32)); + const secrets = new FakeSecretStorage(); + secrets.setSecret(SECRET, "kept"); + + await deviceKey(secrets, vault.asDataAdapter()).read(); + + expect(secrets.getSecret(SECRET)).toBe("kept"); + expect(vault.hasFile(LEGACY)).toBe(false); + }); + + it("drops a damaged key file and starts without a key", async () => { + const vault = new InMemoryAdapter(); + vault.putText(LEGACY, "short"); + const secrets = new FakeSecretStorage(); + + expect(await deviceKey(secrets, vault.asDataAdapter()).read()).toBeNull(); + expect(secrets.map.size).toBe(0); + expect(vault.hasFile(LEGACY)).toBe(false); + }); + + it("tries the move again at the next launch when deleting the file failed", async () => { + const vault = new InMemoryAdapter(); + vault.putText(LEGACY, "x".repeat(32)); + const adapter = vault.asDataAdapter(); + vi.spyOn(adapter, "remove").mockRejectedValueOnce(new Error("busy")); + const secrets = new FakeSecretStorage(); + + expect(await deviceKey(secrets, adapter).read()).not.toBeNull(); + expect(vault.hasFile(LEGACY)).toBe(true); + + await deviceKey(secrets, adapter).read(); + expect(vault.hasFile(LEGACY)).toBe(false); + }); +}); diff --git a/packages/plugin/tests/crypto/passphrase-cache.test.ts b/packages/plugin/tests/crypto/passphrase-cache.test.ts index dcdd6888..36852f64 100644 --- a/packages/plugin/tests/crypto/passphrase-cache.test.ts +++ b/packages/plugin/tests/crypto/passphrase-cache.test.ts @@ -1,86 +1,86 @@ +import { deviceKey, FakeSecretStorage } from "@tests/helpers/device-key"; import { InMemoryAdapter } from "@tests/helpers/in-memory-adapter"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { DEVICE_KEY_BYTES } from "@/crypto/constants"; + import { loadCachedPassphrase, saveCachedPassphrase, } from "@/crypto/passphrase-cache"; -const keyPath = ".obsidian/plugins/mdsync/device.key"; const cachePath = ".obsidian/plugins/mdsync/passphrase.enc"; const binding = "s3|example|region|bucket|prefix"; afterEach(() => vi.restoreAllMocks()); -describe("passphrase cache recovery", () => { - it.each([0, 16, 31, 33])( - "rebuilds a %i-byte key only when saving an available passphrase", - async (length) => { - const vault = new InMemoryAdapter(); - vault.putText(keyPath, "x".repeat(length)); - vault.putText(cachePath, "unreadable cache"); - const adapter = vault.asDataAdapter(); - const writes = vi.spyOn(adapter, "writeBinary"); +describe("passphrase cache", () => { + it("opens on the next launch and stays bound to its storage", async () => { + const adapter = new InMemoryAdapter().asDataAdapter(); + const secrets = new FakeSecretStorage(); + await saveCachedPassphrase( + adapter, + ".obsidian", + deviceKey(secrets, adapter), + "passphrase", + binding, + ); - expect( - await loadCachedPassphrase(adapter, ".obsidian", binding), - ).toBeNull(); - expect(writes).not.toHaveBeenCalled(); - expect(vault.readText(keyPath)).toBe("x".repeat(length)); - await saveCachedPassphrase( + const restarted = deviceKey(secrets, adapter); + expect( + await loadCachedPassphrase( adapter, ".obsidian", - "entered-passphrase", - binding, - ); - expect((await adapter.readBinary(keyPath)).byteLength).toBe( - DEVICE_KEY_BYTES, - ); - expect(await loadCachedPassphrase(adapter, ".obsidian", binding)).toBe( - "entered-passphrase", - ); - }, - ); + restarted, + "other-storage", + ), + ).toBeNull(); + expect( + await loadCachedPassphrase(adapter, ".obsidian", restarted, binding), + ).toBe("passphrase"); + }); - it("rebuilds an unreadable key when the new cache can be written", async () => { - const vault = new InMemoryAdapter(); - const adapter = vault.asDataAdapter(); - await saveCachedPassphrase(adapter, ".obsidian", "old-passphrase", binding); - vi.spyOn(adapter, "readBinary").mockRejectedValueOnce( - new Error("Could not read device key"), - ); + it("is unreadable in a copy of the vault on another device", async () => { + const adapter = new InMemoryAdapter().asDataAdapter(); await saveCachedPassphrase( adapter, ".obsidian", - "entered-passphrase", + deviceKey(new FakeSecretStorage(), adapter), + "passphrase", binding, ); - expect(await loadCachedPassphrase(adapter, ".obsidian", binding)).toBe( - "entered-passphrase", - ); + const elsewhere = deviceKey(new FakeSecretStorage(), adapter); + + expect( + await loadCachedPassphrase(adapter, ".obsidian", elsewhere, binding), + ).toBeNull(); + expect(await elsewhere.read()).toBeNull(); }); - it("reuses a valid key and keeps the cache bound to its storage", async () => { + it("rebuilds its key only when saving an available passphrase", async () => { const vault = new InMemoryAdapter(); + vault.putText(cachePath, "unreadable cache"); const adapter = vault.asDataAdapter(); - await saveCachedPassphrase(adapter, ".obsidian", "old-passphrase", binding); - const key = await adapter.readBinary(keyPath); - await saveCachedPassphrase(adapter, ".obsidian", "new-passphrase", binding); - expect(await adapter.readBinary(keyPath)).toEqual(key); + const key = deviceKey(new FakeSecretStorage(), adapter); + expect( - await loadCachedPassphrase(adapter, ".obsidian", "other-storage"), + await loadCachedPassphrase(adapter, ".obsidian", key, binding), ).toBeNull(); - expect(await loadCachedPassphrase(adapter, ".obsidian", binding)).toBe( - "new-passphrase", + expect(await key.read()).toBeNull(); + await saveCachedPassphrase(adapter, ".obsidian", key, "entered", binding); + expect(await loadCachedPassphrase(adapter, ".obsidian", key, binding)).toBe( + "entered", ); }); it("reports a real write failure without claiming that the cache was saved", async () => { - const vault = new InMemoryAdapter(); - vault.putText(keyPath, ""); - const adapter = vault.asDataAdapter(); + const adapter = new InMemoryAdapter().asDataAdapter(); vi.spyOn(adapter, "writeBinary").mockRejectedValue(new Error("disk full")); await expect( - saveCachedPassphrase(adapter, ".obsidian", "entered-passphrase", binding), + saveCachedPassphrase( + adapter, + ".obsidian", + deviceKey(new FakeSecretStorage(), adapter), + "entered", + binding, + ), ).rejects.toThrow("disk full"); }); }); diff --git a/packages/plugin/tests/helpers/device-key.ts b/packages/plugin/tests/helpers/device-key.ts new file mode 100644 index 00000000..71e14940 --- /dev/null +++ b/packages/plugin/tests/helpers/device-key.ts @@ -0,0 +1,34 @@ +import type { DataAdapter, SecretStorage } from "obsidian"; + +import { DeviceKey } from "@/crypto/device-key"; + +import { InMemoryAdapter } from "./in-memory-adapter"; + +/** Obsidian's keychain of one vault. */ +export class FakeSecretStorage { + readonly map = new Map(); + + getSecret(id: string): string | null { + return this.map.get(id) ?? null; + } + + setSecret(id: string, secret: string): void { + this.map.set(id, secret); + } + + listSecrets(): string[] { + return [...this.map.keys()]; + } +} + +/** The device key as one launch sees it: a new one over the same keychain is a restart. */ +export function deviceKey( + secrets = new FakeSecretStorage(), + adapter: DataAdapter = new InMemoryAdapter().asDataAdapter(), +): DeviceKey { + return new DeviceKey( + secrets as unknown as SecretStorage, + adapter, + ".obsidian", + ); +} diff --git a/packages/plugin/tests/settings/envelope.test.ts b/packages/plugin/tests/settings/envelope.test.ts new file mode 100644 index 00000000..51ab8089 --- /dev/null +++ b/packages/plugin/tests/settings/envelope.test.ts @@ -0,0 +1,173 @@ +import { deviceKey, FakeSecretStorage } from "@tests/helpers/device-key"; +import { link } from "@tests/helpers/shared-links"; +import { describe, expect, it } from "vitest"; + +import { encryptJson } from "@/crypto"; +import { SettingsEnvelope } from "@/settings/envelope"; +import { type MdsyncSettings, mergeSettings } from "@/settings/model"; +import { defaultS3Config } from "@/storage"; +import { bytesToBase64 } from "@/utils"; + +const SEALED = [ + "storageConfigs", + "relaySecret", + "cloudflareToken", + "spaces", + "localRoots", + "spacesVault", + "links", +]; + +function configured(bucket = "notes"): MdsyncSettings { + return mergeSettings({ + storageConfigs: { + s3: { + ...defaultS3Config(), + bucket, + accessKeyId: "AKIA", + secretAccessKey: "S".repeat(40), + }, + }, + relaySecret: "relay-secret", + cloudflareToken: "cf-token", + localRoots: { share: "Moved" }, + spacesVault: "s3|vault", + links: [link("a")], + uiLayout: "flat", + }); +} + +/** What Obsidian writes to data.json and reads back. */ +function onDisk(data: object): Record { + return JSON.parse(JSON.stringify(data)); +} + +async function launch( + data: Record | null, + secrets: FakeSecretStorage, +) { + const envelope = new SettingsEnvelope(deviceKey(secrets)); + const opened = await envelope.open(data); + return { envelope, ...opened, settings: mergeSettings(opened.stored) }; +} + +describe("SettingsEnvelope", () => { + it("keeps the secrets out of the file in the clear and opens them on the next launch", async () => { + const secrets = new FakeSecretStorage(); + const settings = configured(); + const file = onDisk( + await new SettingsEnvelope(deviceKey(secrets)).seal(settings), + ); + + for (const field of SEALED) expect(file).not.toHaveProperty(field); + expect(JSON.stringify(file)).not.toContain("relay-secret"); + expect(file.uiLayout).toBe("flat"); + + const next = await launch(file, secrets); + expect(next.settings).toEqual(settings); + expect(next.settings).not.toHaveProperty("secrets"); + expect(next.inTheClear).toBe(false); + expect(next.envelope.unreadable()).toBe(false); + }); + + it("takes an earlier build's file as it is and says it holds secrets in the clear", async () => { + const file = onDisk(configured()); + + const next = await launch(file, new FakeSecretStorage()); + + expect(next.inTheClear).toBe(true); + expect(next.settings).toEqual(configured()); + }); + + it("opens no secrets in a copy on another device, but keeps the rest", async () => { + const file = onDisk( + await new SettingsEnvelope(deviceKey(new FakeSecretStorage())).seal( + configured(), + ), + ); + + const copy = await launch(file, new FakeSecretStorage()); + + expect(copy.envelope.unreadable()).toBe(true); + expect(copy.settings).toEqual(mergeSettings({ uiLayout: "flat" })); + }); + + it("writes an unreadable envelope back untouched until the sealed settings change", async () => { + const file = onDisk( + await new SettingsEnvelope(deviceKey(new FakeSecretStorage())).seal( + configured(), + ), + ); + const secrets = new FakeSecretStorage(); + const copy = await launch(file, secrets); + + copy.settings.uiLayout = "tree"; + expect((await copy.envelope.seal(copy.settings)).secrets).toBe( + file.secrets, + ); + + const again = configured("other"); + const resealed = onDisk(await copy.envelope.seal(again)); + expect(resealed.secrets).not.toBe(file.secrets); + expect(copy.envelope.unreadable()).toBe(false); + expect((await launch(resealed, secrets)).settings).toEqual(again); + }); + + it("keeps what is sealed after an unreadable envelope, even before storage is set up", async () => { + const file = onDisk( + await new SettingsEnvelope(deviceKey(new FakeSecretStorage())).seal( + configured(), + ), + ); + const secrets = new FakeSecretStorage(); + const copy = await launch(file, secrets); + + copy.settings.cloudflareToken = "new-token"; + copy.settings.links = [link("b")]; + const saved = onDisk(await copy.envelope.seal(copy.settings)); + + expect(saved.secrets).not.toBe(file.secrets); + const next = await launch(saved, secrets); + expect(next.settings.cloudflareToken).toBe("new-token"); + expect(next.settings.links).toEqual([link("b")]); + }); + + it("takes only the sealed fields from what it opens", async () => { + const secrets = new FakeSecretStorage(); + const key = deviceKey(secrets); + const foreign = bytesToBase64( + await encryptJson(await key.readOrCreate(), { + version: 1, + passphrase: "hunter2", + binding: "b", + }), + ); + + const next = await launch({ uiLayout: "flat", secrets: foreign }, secrets); + + expect(next.settings).not.toHaveProperty("passphrase"); + expect( + JSON.stringify(await next.envelope.seal(next.settings)), + ).not.toContain("hunter2"); + }); + + it("lets the envelope win over secrets an older build left beside it, and asks to scrub them", async () => { + const secrets = new FakeSecretStorage(); + const file = onDisk( + await new SettingsEnvelope(deviceKey(secrets)).seal(configured()), + ); + + const next = await launch({ ...file, relaySecret: "stale" }, secrets); + const copy = await launch( + { ...file, relaySecret: "stale" }, + new FakeSecretStorage(), + ); + + expect(next.settings.relaySecret).toBe("relay-secret"); + expect(next.inTheClear).toBe(true); + expect(copy.inTheClear).toBe(true); + expect(await copy.envelope.seal(copy.settings)).not.toHaveProperty( + "relaySecret", + ); + }); +}); diff --git a/packages/plugin/tests/settings/transfer-controller.test.ts b/packages/plugin/tests/settings/transfer-controller.test.ts index 160109b3..4e12705f 100644 --- a/packages/plugin/tests/settings/transfer-controller.test.ts +++ b/packages/plugin/tests/settings/transfer-controller.test.ts @@ -1,3 +1,4 @@ +import { deviceKey, FakeSecretStorage } from "@tests/helpers/device-key"; import { InMemoryAdapter } from "@tests/helpers/in-memory-adapter"; import type { App } from "obsidian"; import { describe, expect, it, vi } from "vitest"; @@ -40,14 +41,15 @@ describe("settings transfer passphrase cache", () => { }); const token = await createSettingsTransferUrl(source, "test-passphrase"); const settings = mergeSettings({ cachePassphrase }); - const vault = new InMemoryAdapter(); - if (brokenKey) vault.putText(".obsidian/plugins/mdsync/device.key", ""); - const adapter = vault.asDataAdapter(); + const adapter = new InMemoryAdapter().asDataAdapter(); + const secrets = new FakeSecretStorage(); + if (brokenKey) secrets.setSecret("mdsync-device-key", ""); const manager = new PassphraseManager( async () => "test-passphrase", adapter, ".obsidian", settings, + deviceKey(secrets, adapter), ); let saved: MdsyncSettings = settings; const transfer = new SettingsTransferController({ @@ -68,6 +70,7 @@ describe("settings transfer passphrase cache", () => { adapter, ".obsidian", mergeSettings(saved), + deviceKey(secrets, adapter), ); expect(await restarted.prompt(false)).toBe(true); expect(restarted.current()).toBe("test-passphrase"); @@ -89,6 +92,7 @@ describe("settings import", () => { adapter, ".obsidian", settings, + deviceKey(new FakeSecretStorage(), adapter), ); const link: LinkRecord = { id: "one", @@ -143,6 +147,7 @@ describe("settings import with a typed passphrase", () => { new InMemoryAdapter().asDataAdapter(), ".obsidian", settings, + deviceKey(), ); const saveSettings = vi.fn(async () => {}); const onSettingsReplaced = vi.fn(); From c926b6009b3c522a4c721d6e822cbe68432f8143 Mon Sep 17 00:00:00 2001 From: kitbyte Date: Sun, 11 Oct 2026 01:19:30 +0300 Subject: [PATCH 04/11] chore: untrack .claude/settings.local.json --- .claude/settings.local.json | 8 -------- .gitignore | 3 +++ 2 files changed, 3 insertions(+), 8 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 67fbb55a..00000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(Get-ChildItem -Path \"E:\\\\Repositories\\\\pet\\\\obsync\" -Recurse -Filter \"*.ts\" -ErrorAction SilentlyContinue)", - "Bash(Select-Object -First 30 FullName)" - ] - } -} diff --git a/.gitignore b/.gitignore index 7910d72b..443fd0dd 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ artifacts # Local code index. .codegraph/ + +# Claude Code local settings +.claude/settings.local.json From c32a6aea0abf8bbb9485b958f959de2adbbe57f0 Mon Sep 17 00:00:00 2001 From: kitbyte Date: Sun, 11 Oct 2026 12:12:33 +0300 Subject: [PATCH 05/11] docs: README restructure, wizard-first setup, marketplace link --- README.md | 63 +++++++++++++++++++++++---------------- packages/plugin/README.md | 18 ----------- 2 files changed, 37 insertions(+), 44 deletions(-) delete mode 100644 packages/plugin/README.md diff --git a/README.md b/README.md index f6c3067b..6cb45e9b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@

MDSync: serverless, end-to-end encrypted vault sync for Obsidian, with a merge editor, file history, live editing and shared folders

@@ -12,7 +13,7 @@ Relay

-MDSync syncs your vault through an S3 bucket, a WebDAV server or Google Drive. There is no MDSync server: your devices talk straight to your storage, and everything they upload is encrypted on the device with a key derived from your passphrase. The provider stores only encrypted data. Sync is manual until you turn on automation, so you can review each change before it leaves the device. An optional relay, a Cloudflare Worker you deploy to your own account, adds instant sync, live editing and shared folders. +MDSync syncs your vault through an S3 bucket, a WebDAV server or Google Drive. There is no MDSync server: your devices talk straight to your storage, and everything they upload is encrypted on the device with a key derived from your passphrase. Sync is manual until you turn on automatic sync, so you can review each change before it leaves the device. An optional relay, a Cloudflare Worker you deploy to your own account, adds instant sync, live editing, shared folders and share links. ## Features @@ -47,25 +48,29 @@ MDSync syncs your vault through an S3 bucket, a WebDAV server or Google Drive. T - [Device transfer](#device-transfer): move your setup to a new device with an encrypted link or QR code. - Integrity check of the remote, and cleanup of objects nothing references. - Remote reset that rebuilds the remote from this vault (you type `RESET` to confirm). -- A local diagnostics log in the settings, never synced. +- A local diagnostics log in the settings. ## Quick start -1. Install MDSync from **Settings → Community plugins** and enable it. -2. Open **Settings → MDSync**, pick a storage backend and fill in its fields. -3. Run **MDSync: Compare with remote** and choose a passphrase (12 characters or more). -4. Run **MDSync: Push all local changes** for the first upload. -5. On the next device, import the setup with a [transfer link](#device-transfer) or enter the same storage and passphrase, then pull. +1. Install [MDSync](https://community.obsidian.md/plugins/mdsync) from **Settings → Community plugins** and enable it. The setup wizard opens on its own; later, run **MDSync: Set up sync**. +2. On the first device, choose **Start syncing this vault**: pick a storage, choose a passphrase (12 characters or more) and, if you want real time, deploy the relay. **Sync now** on the last step does the first upload. +3. On every other device, choose **Connect to a vault set up on another device** and paste a [setup link](#device-transfer) from the first one. -> **Tip: Cloudflare R2 is an easy S3-compatible backend.** Its free tier (10 GB, 1 million write and list requests and 10 million read requests a month) covers most vaults, downloads cost nothing, and it supports the conditional writes MDSync relies on. Enable R2 on your Cloudflare account, create a bucket, and create an API token with **Object Read & Write** limited to that bucket. R2 shows the access key ID and secret only once. In MDSync pick S3-compatible and set **Endpoint** to `https://.r2.cloudflarestorage.com` (`https://.eu.r2.cloudflarestorage.com` for a bucket created in the EU jurisdiction), leave **Region** as `auto`, and fill in the bucket and the token's keys. The same Cloudflare account can host the [relay](#self-hosting-the-relay). +Give each vault its own bucket prefix. MDSync refuses a prefix that already holds another vault. + +Set up the first device alone and let its first sync finish. That sync creates the vault's key. On storage without conditional writes (Google Drive, some WebDAV and S3-compatible servers), two devices starting at the same moment can each create one, and files encrypted by one become unreadable to the other. -**MDSync: Set up sync** can create the R2 bucket and the relay for you. It asks for a Cloudflare API token: copy **Your API token**, not the S3 keys. +
+Manual setup -![Creating the Cloudflare API token for the setup wizard and copying the right value](docs/demos/cloudflare-token.webp) +1. Open **Settings → MDSync**, pick a storage backend and fill in its fields. +2. Run **MDSync: Compare with remote** and choose a passphrase. +3. Run **MDSync: Push all local changes** for the first upload. +4. On the next device, import a [setup link](#device-transfer) or enter the same storage and passphrase, then pull. -Give each vault its own bucket prefix. MDSync refuses a prefix that already holds another vault. +**Cloudflare R2 by hand.** Its free tier (10 GB, 1 million write and list requests and 10 million read requests a month) covers most vaults, downloads cost nothing, and it supports the conditional writes MDSync relies on. Enable R2 on your Cloudflare account, create a bucket, and create an API token with **Object Read & Write** limited to that bucket. R2 shows the access key ID and secret only once. In MDSync pick S3-compatible and set **Endpoint** to `https://.r2.cloudflarestorage.com` (`https://.eu.r2.cloudflarestorage.com` for a bucket created in the EU jurisdiction), leave **Region** as `auto`, and fill in the bucket and the token's keys. The same Cloudflare account can host the [relay](#self-hosting-the-relay). -Set up the first device alone and let its first sync finish. That sync creates the vault's key. On storage without conditional writes (Google Drive, some WebDAV and S3-compatible servers), two devices starting at the same moment can each create one, and files encrypted by one become unreadable to the other. +
## Sync @@ -79,8 +84,6 @@ Set up the first device alone and let its first sync finish. That sync creates t ### Encryption -The file list and every file are encrypted with a key derived from your passphrase. The passphrase itself is never uploaded. - - **Cache passphrase between launches** keeps it on the device, so you are not asked at every start. **MDSync: Forget cached passphrase** clears it. - **Rotate passphrase** re-wraps the vault's data key under a new passphrase. Notes are not re-encrypted, so it is instant. Every device has to switch to the new passphrase afterwards. @@ -99,7 +102,7 @@ The panel has four tabs: **Changes**, **History**, **Timeline** and **Deleted**. ### Automatic sync -Sync is manual by default. In **Settings → MDSync → Sync** you can turn on: +In **Settings → MDSync → Sync** you can turn on: - **Autosync**: compare, pull and push every few minutes. - **Push after changes settle**: push once the vault has been quiet for a set delay. It never pulls, so conflicts and incoming changes wait for you. @@ -215,7 +218,7 @@ A note open on two devices edits together, keystroke by keystroke, with the othe The note header shows who else has the note open, as coloured initials. Click one to follow that person: your tab opens the notes they open and keeps their cursor in view. The file explorer shows headcounts on shared folders and a dot on files others changed that you have not opened yet. On desktop, the status bar shows the relay state. -**Show my open note to others** off keeps you online without showing where you are. +Turn off **Show my open note to others** to stay online without showing where you are.
Demos @@ -232,13 +235,12 @@ The note header shows who else has the note open, as coloured initials. Click on ### Shared folders -Share a folder from its menu in the file explorer. Owning a share needs S3-compatible storage, and sending invites needs the relay. +Share a folder from its menu in the file explorer. Owning a share needs S3-compatible storage. 1. **Invite** gives you an `obsidian://mdsync-share` link and a password. Send them separately. 2. The other person opens the link (or pastes it in **Accept an invite**), enters the password and picks an empty folder. They need no storage of their own and never see your credentials. -3. A **read-only** invite pulls changes but can never push. -From the share's window you can see who is in it, revoke a person, pause the share on this device or on all your devices, and stop sharing. When a share ends, its files stay and sync with your vault again. Renaming a shared folder renames it on your other devices too. +A **read-only** invite pulls changes but can never push. From the share's window you can see who is in it, revoke a person, pause the share on this device or on all your devices, and stop sharing. When a share ends, its files stay and sync with your vault again. Renaming a shared folder renames it on your other devices too.
Demos @@ -255,7 +257,7 @@ From the share's window you can see who is in it, revoke a person, pause the sha ### Share links -Publish one note as a link anyone can open in a browser with nothing installed, from its menu in the file explorer (**MDSync: Share link**) or with **Share this note as a link**. It needs the relay, which serves the page. +Publish one note as a link anyone can open in a browser with nothing installed, from its menu in the file explorer (**MDSync: Share link**) or with **Share this note as a link**.
Demo @@ -265,13 +267,12 @@ Publish one note as a link anyone can open in a browser with nothing installed,
- **Expires** (5 minutes to 30 days, an exact date, or never), **Views** and an optional **passphrase** limit who can read it and for how long. Once the time or the views run out, the relay erases the note. -- The note is drawn as Obsidian's reading view draws it, then encrypted on your device. The key is in the part of the link after `#`, which browsers never send, so the relay holds only ciphertext. +- The note is drawn as Obsidian's reading view draws it, then encrypted on your device. - The page has an outline, folding headings, copyable code, wide text and **Copy as Markdown**. - Left out: properties, comments, links to other notes (their text stays) and embedded notes. Vault images are embedded and shrunk; **Include images** turns that off. The window lists what was left out before you create the link. -- **Manage share links** shows the views left, copies the link, **Update**s it to the note as it is now (same link) or stops it at once. A globe in the file tree and in the note's header marks a shared note; a dot means it changed since. -- Your other devices list the same links, so any of them can copy, update or stop one. +- **Manage share links**, on any of your devices, shows the views left. From there you can copy a link, **Update** it to the note as it is now (the link stays the same) or stop it at once. A globe in the file tree and in the note's header marks a shared note; a dot means it changed since. -A link is a copy: later edits do not reach it until you update it. Anyone who has the link (and the passphrase) can read the note and pass it on. The links, with their keys, are kept in the plugin's `data.json` and reach your other devices through your storage, encrypted with the vault key. +A link is a copy: later edits do not reach it until you update it. Anyone who has the link (and the passphrase) can read the note and pass it on. ## Device transfer @@ -284,9 +285,12 @@ A link is a copy: later edits do not reach it until you update it. Anyone who ha
+## Commands +
All commands +- **Set up sync**: open the setup wizard. - **Compare with remote**: refresh sync status and open the source control view. - **Refresh sync status**: compare only. - **Push all local changes** / **Pull all remote changes**. @@ -321,9 +325,11 @@ You need an account with your storage provider. The relay needs a Cloudflare acc Storage credentials, share keys, share links, the relay secret and the Cloudflare token are stored in the plugin's `data.json` on each device, encrypted with a key kept in Obsidian's keychain for that vault, outside the vault folder. A copy of the vault (a backup, git, a cloud folder) carries none of them; on a copied or moved vault, import a [setup link](#device-transfer) from another device. Diagnostics logs stay on the device and never sync. +**The Cloudflare token.** The wizard keeps it on the device where you paste it, to update the relay later, and never syncs or exports it. The R2 storage the wizard creates uses S3 keys derived from this token, so they can read and write every R2 bucket on the account, including buckets MDSync did not create. Deleting the token in Cloudflare stops sync. For keys limited to one bucket, set up R2 by hand as the manual setup under [Quick start](#quick-start) describes. + **What the relay sees.** It never sees file contents, file names or keys: live edits and presence travel encrypted under each space's key. It does see a hash of your storage identity, a device id per connection, and when each sync and note switch happens. For a shared folder with participants, it keeps your S3 access key in its storage to sign requests for them, plus the names you invited people by. With Google Drive, it exchanges your sign-in for tokens and sees your Drive refresh token. -**Share links.** Creating one sends a rendered copy of that note to your relay, encrypted on your device. The relay stores the ciphertext, a fingerprint of the passphrase check, the view limit and the expiry, and counts views. It never sees the key (it stays in the part of the link after `#`), the passphrase, or the text. The page a recipient opens is served by your relay and decrypts in their browser. +**Share links.** Creating one sends a rendered copy of that note to your relay, encrypted on your device. The relay stores the ciphertext, a fingerprint of the passphrase check, the view limit and the expiry, and counts views. It never sees the key (it stays in the part of the link after `#`, which browsers never send), the passphrase, or the text. The page a recipient opens is served by your relay and decrypts in their browser. Your other devices get the links, with their keys, through your storage, encrypted with the vault key.
What MDSync writes to your storage @@ -336,6 +342,7 @@ Storage credentials, share keys, share links, the relay secret and the Cloudflar /objects/.enc encrypted file contents /pins/.json.enc full file list of a pinned version /spaces/.json.enc shared folder records +/links/.json.enc share links, with their keys /shares//... each shared folder's own storage ``` @@ -345,6 +352,10 @@ Storage credentials, share keys, share links, the relay secret and the Cloudflar The relay is `packages/relay`: one Cloudflare Worker that handles instant sync, live editing, invites to shared folders, share links and the Google Drive sign-in. It fits in the Workers free tier. +**From MDSync (recommended).** Select **Deploy relay** in **Settings → MDSync → Connection → Relay server**, or **Deploy to my Cloudflare** in the setup wizard, and paste a Cloudflare API token. MDSync uploads the relay and connects to it. When a new MDSync version brings a newer relay, the same button reads **Update relay**. + +**From GitHub Actions.** + 1. Fork this repository. 2. In **Settings → MDSync → Connection → Relay server**, select **Generate**. The secret is copied to your clipboard. 3. In your fork, under **Settings → Secrets and variables → Actions**, add `CLOUDFLARE_API_TOKEN` (the *Edit Cloudflare Workers* template is enough), `CLOUDFLARE_ACCOUNT_ID` and `RELAY_SECRET`. @@ -355,7 +366,7 @@ The relay is `packages/relay`: one Cloudflare Worker that handles instant sync, Google Drive sign-in 1. In Google Cloud, create an OAuth client of type *Web application* with the redirect URI `https:///auth`, and enable the Google Drive API. -2. Add `GDRIVE_CLIENT_ID` and `GDRIVE_CLIENT_SECRET` to the fork's secrets and run **Deploy Relay** again. +2. Add `GDRIVE_CLIENT_ID` and `GDRIVE_CLIENT_SECRET` as secrets: on a relay deployed from MDSync, in the Cloudflare dashboard under the worker's **Settings → Variables and Secrets** (updates from MDSync keep them); on a fork, to its repository secrets, then run **Deploy Relay** again. 3. In MDSync, set **Auth server URL** to your relay URL and select **Log in**. While the OAuth consent screen is in testing mode, Google expires refresh tokens after 7 days. Publish it to stay signed in. diff --git a/packages/plugin/README.md b/packages/plugin/README.md deleted file mode 100644 index 8518c844..00000000 --- a/packages/plugin/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# Name -### mdsync - -# Synopsis -Simple plugin to synchronize files to S3. - -# Description - -# Example - -# Install: -`npm install mdsync` - -# Test: -`npm test` - -#License: - From d5a93541a93e26a7e97137c19359a0823f268466 Mon Sep 17 00:00:00 2001 From: kitbyte Date: Sun, 11 Oct 2026 15:12:12 +0300 Subject: [PATCH 06/11] fix: :has selectors replaced by classes set in code, timeline actions on focus-within --- packages/plugin/src/settings/setup/passphrase-step.ts | 1 + packages/plugin/src/styles/base/icon-button.css | 2 +- packages/plugin/src/styles/diff/phone.css | 4 +--- packages/plugin/src/styles/mobile.css | 2 +- packages/plugin/src/styles/settings/setup-forms.css | 10 +++++----- packages/plugin/src/styles/source-control/timeline.css | 3 +-- packages/plugin/src/ui/common/enter-key.ts | 1 + packages/plugin/src/ui/common/icon-button.ts | 4 +++- packages/plugin/src/ui/diff/compare-panel.ts | 5 +++++ packages/plugin/src/ui/diff/header.ts | 1 + packages/plugin/src/ui/diff/layout-mode.ts | 2 +- packages/plugin/src/ui/diff/line-wrapping-control.ts | 1 + 12 files changed, 22 insertions(+), 14 deletions(-) diff --git a/packages/plugin/src/settings/setup/passphrase-step.ts b/packages/plugin/src/settings/setup/passphrase-step.ts index ad386516..6651d3e3 100644 --- a/packages/plugin/src/settings/setup/passphrase-step.ts +++ b/packages/plugin/src/settings/setup/passphrase-step.ts @@ -114,6 +114,7 @@ export function createPassphraseStep(wizard: Wizard): StepView { value = v; meter?.(v); }); + first.settingEl.addClass("mdsync-meter-row"); meter = passphraseMeter(first.descEl, value); field(el, "Confirm passphrase", confirm, (v) => { confirm = v; diff --git a/packages/plugin/src/styles/base/icon-button.css b/packages/plugin/src/styles/base/icon-button.css index c5e5dad8..c3ca1c14 100644 --- a/packages/plugin/src/styles/base/icon-button.css +++ b/packages/plugin/src/styles/base/icon-button.css @@ -4,7 +4,7 @@ padding: 0 var(--size-4-2); } -.mdsync-icon-btn:where(:has(> svg:only-child)) { +.mdsync-icon-btn:where(.mdsync-icon-only) { min-width: var(--input-height); padding-inline: 0; } diff --git a/packages/plugin/src/styles/diff/phone.css b/packages/plugin/src/styles/diff/phone.css index 97b4112b..c14c5bdf 100644 --- a/packages/plugin/src/styles/diff/phone.css +++ b/packages/plugin/src/styles/diff/phone.css @@ -24,9 +24,7 @@ body.is-phone .mdsync-diff-view .mdsync-diff-file-actions > button { body.is-phone .mdsync-diff-view - .mdsync-diff-file-actions:not(:has(> button)):not( - :has(.mdsync-pending:not(:empty)) - ) { + .mdsync-diff-file-actions:not(.has-buttons):not(.has-pending) { display: none; } diff --git a/packages/plugin/src/styles/mobile.css b/packages/plugin/src/styles/mobile.css index d45a8f6e..3d6d82bc 100644 --- a/packages/plugin/src/styles/mobile.css +++ b/packages/plugin/src/styles/mobile.css @@ -15,7 +15,7 @@ body.is-tablet .mdsync-toolbar-icon, .mdsync-section-icon-action, .mdsync-rail-btn, - .mdsync-icon-btn:has(> svg:only-child) + .mdsync-icon-btn.mdsync-icon-only ) { padding-inline: 0; } diff --git a/packages/plugin/src/styles/settings/setup-forms.css b/packages/plugin/src/styles/settings/setup-forms.css index f4eddc20..ed28d2da 100644 --- a/packages/plugin/src/styles/settings/setup-forms.css +++ b/packages/plugin/src/styles/settings/setup-forms.css @@ -164,24 +164,24 @@ } /* On a phone the field stacks under its name: flatten the info column so the meter can follow the input. */ -.is-phone .setting-item:has(.mdsync-meter) { +.is-phone .setting-item.mdsync-meter-row { flex-wrap: wrap; } -.is-phone .setting-item-info:has(.mdsync-meter) { +.is-phone .mdsync-meter-row .setting-item-info { display: contents; } -.is-phone .setting-item:has(.mdsync-meter) .setting-item-name { +.is-phone .setting-item.mdsync-meter-row .setting-item-name { flex-basis: 100%; } -.is-phone .setting-item:has(.mdsync-meter) .setting-item-control { +.is-phone .setting-item.mdsync-meter-row .setting-item-control { flex-basis: 100%; order: 1; } -.is-phone .setting-item-description:has(.mdsync-meter) { +.is-phone .mdsync-meter-row .setting-item-description { flex-basis: 100%; order: 2; } diff --git a/packages/plugin/src/styles/source-control/timeline.css b/packages/plugin/src/styles/source-control/timeline.css index 35aa5bbe..609dff87 100644 --- a/packages/plugin/src/styles/source-control/timeline.css +++ b/packages/plugin/src/styles/source-control/timeline.css @@ -136,8 +136,7 @@ pointer-events: none; } -.mdsync-timeline-file:focus-visible .mdsync-timeline-actions, -.mdsync-timeline-file:has(:focus-visible) .mdsync-timeline-actions { +.mdsync-timeline-file:focus-within .mdsync-timeline-actions { opacity: 1; pointer-events: auto; } diff --git a/packages/plugin/src/ui/common/enter-key.ts b/packages/plugin/src/ui/common/enter-key.ts index 63982508..a759158f 100644 --- a/packages/plugin/src/ui/common/enter-key.ts +++ b/packages/plugin/src/ui/common/enter-key.ts @@ -5,6 +5,7 @@ function isPlainEnter(event: KeyboardEvent): boolean { return ( event.key === "Enter" && !event.isComposing && + // WebKit (Obsidian on iOS) confirms IME with Enter, isComposing false and keyCode 229. event.keyCode !== 229 && !event.repeat && !event.ctrlKey && diff --git a/packages/plugin/src/ui/common/icon-button.ts b/packages/plugin/src/ui/common/icon-button.ts index a7ff0aa8..37ccc355 100644 --- a/packages/plugin/src/ui/common/icon-button.ts +++ b/packages/plugin/src/ui/common/icon-button.ts @@ -6,7 +6,9 @@ export function appendIconButton( label: string, onClick: (event: MouseEvent) => void, ): HTMLButtonElement { - const button = parent.createEl("button", { cls: "mdsync-icon-btn" }); + const button = parent.createEl("button", { + cls: "mdsync-icon-btn mdsync-icon-only", + }); button.type = "button"; button.setAttr("aria-label", label); setIcon(button, icon); diff --git a/packages/plugin/src/ui/diff/compare-panel.ts b/packages/plugin/src/ui/diff/compare-panel.ts index ee6d0db2..d68d9cca 100644 --- a/packages/plugin/src/ui/diff/compare-panel.ts +++ b/packages/plugin/src/ui/diff/compare-panel.ts @@ -173,6 +173,7 @@ export class ComparePanel { mountFileActions(host: HTMLElement): void { if (!this.fileActionsEl) return; host.appendChild(this.fileActionsEl); + host.toggleClass("has-pending", this.choices.size > 0); } /** The pane the rail buttons belong to: the side a decision takes from. */ @@ -423,6 +424,10 @@ export class ComparePanel { /** What Apply will do: a coloured count per kind, none for a kind not chosen. */ private renderPending(el: HTMLElement): void { + el.closest(".mdsync-diff-file-actions")?.toggleClass( + "has-pending", + this.choices.size > 0, + ); el.empty(); for (const kind of Object.values(EChoiceKind)) { const count = this.choices.count(kind); diff --git a/packages/plugin/src/ui/diff/header.ts b/packages/plugin/src/ui/diff/header.ts index 8000567f..efa70e19 100644 --- a/packages/plugin/src/ui/diff/header.ts +++ b/packages/plugin/src/ui/diff/header.ts @@ -116,6 +116,7 @@ function appendButton( text: string, onClick: () => void, ): HTMLButtonElement { + parent.addClass("has-buttons"); const button = parent.createEl("button", { text }); button.type = "button"; button.addEventListener("click", onClick); diff --git a/packages/plugin/src/ui/diff/layout-mode.ts b/packages/plugin/src/ui/diff/layout-mode.ts index 9a41893c..6031f1c7 100644 --- a/packages/plugin/src/ui/diff/layout-mode.ts +++ b/packages/plugin/src/ui/diff/layout-mode.ts @@ -15,7 +15,7 @@ export class LayoutMode { private readonly onLayout: (combined: boolean, changed: boolean) => void, ) { this.button = toolbar.createEl("button", { - cls: "mdsync-icon-btn mdsync-layout-btn", + cls: "mdsync-icon-btn mdsync-icon-only mdsync-layout-btn", }); this.button.addEventListener("click", () => { this.forceCombined = !this.current; diff --git a/packages/plugin/src/ui/diff/line-wrapping-control.ts b/packages/plugin/src/ui/diff/line-wrapping-control.ts index b70f0d0d..b9862258 100644 --- a/packages/plugin/src/ui/diff/line-wrapping-control.ts +++ b/packages/plugin/src/ui/diff/line-wrapping-control.ts @@ -33,6 +33,7 @@ export class LineWrappingControl { "Toggle line wrapping", () => this.toggle(), ); + this.button.removeClass("mdsync-icon-only"); this.button.createSpan({ cls: "mdsync-phone-button-label", text: "Wrap" }); this.render(); } From a9d6fc0ed02d790389b0562e19f79a0654f18a15 Mon Sep 17 00:00:00 2001 From: kitbyte Date: Sun, 11 Oct 2026 15:12:49 +0300 Subject: [PATCH 07/11] feat: declarative settings with pages and Obsidian settings search, shared folders on their own page --- .claude/architecture.md | 2 +- .claude/commands-and-settings.md | 4 +- packages/plugin/src/main.ts | 6 +- packages/plugin/src/settings/fields.ts | 110 +++-- packages/plugin/src/settings/logs-view.ts | 18 +- packages/plugin/src/settings/page.ts | 21 + .../plugin/src/settings/sections/advanced.ts | 29 ++ .../src/settings/sections/automation.ts | 30 +- .../plugin/src/settings/sections/backend.ts | 97 ++-- .../settings/sections/configuration-scope.ts | 150 ++++++ .../src/settings/sections/device-transfer.ts | 57 +++ .../src/settings/sections/exclusions.ts | 87 ++-- .../plugin/src/settings/sections/index.ts | 20 +- .../plugin/src/settings/sections/interface.ts | 59 +++ .../plugin/src/settings/sections/links.ts | 36 +- .../src/settings/sections/maintenance.ts | 48 +- .../plugin/src/settings/sections/relay.ts | 147 +++--- .../plugin/src/settings/sections/security.ts | 170 +++---- .../plugin/src/settings/sections/shares.ts | 158 +++--- .../src/settings/sections/storage-fields.ts | 35 +- .../plugin/src/settings/setup/ready-step.ts | 9 +- .../plugin/src/settings/setup/storage-step.ts | 7 +- packages/plugin/src/settings/tab.ts | 466 +++--------------- .../src/styles/settings/tabs-and-logs.css | 19 - packages/plugin/tests/settings/fields.test.ts | 80 +++ .../tests/settings/number-field.test.ts | 5 +- .../tests/settings/relay-section.test.ts | 38 +- .../tests/settings/security-section.test.ts | 16 +- .../tests/settings/shares-section.test.ts | 58 +++ tests/e2e/device.ts | 48 +- tests/e2e/people.ts | 5 +- tests/e2e/settings.ts | 55 ++- tests/e2e/shares.ts | 5 +- tests/e2e/sharing.ts | 5 +- 34 files changed, 1198 insertions(+), 902 deletions(-) create mode 100644 packages/plugin/src/settings/page.ts create mode 100644 packages/plugin/src/settings/sections/advanced.ts create mode 100644 packages/plugin/src/settings/sections/configuration-scope.ts create mode 100644 packages/plugin/src/settings/sections/device-transfer.ts create mode 100644 packages/plugin/src/settings/sections/interface.ts create mode 100644 packages/plugin/tests/settings/fields.test.ts create mode 100644 packages/plugin/tests/settings/shares-section.test.ts diff --git a/.claude/architecture.md b/.claude/architecture.md index df4c48a1..09974d8a 100644 --- a/.claude/architecture.md +++ b/.claude/architecture.md @@ -15,7 +15,7 @@ - `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; `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, `envelope.ts` (the secrets of `data.json`, sealed by the device key), transfer, and the settings tab sections; `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) +- `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 diff --git a/.claude/commands-and-settings.md b/.claude/commands-and-settings.md index 4776626c..6651552c 100644 --- a/.claude/commands-and-settings.md +++ b/.claude/commands-and-settings.md @@ -9,7 +9,7 @@ - **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 `/shares//` 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/`), 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. +- **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. @@ -20,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 `/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 `/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. diff --git a/packages/plugin/src/main.ts b/packages/plugin/src/main.ts index 7f2e87c1..53d4850f 100644 --- a/packages/plugin/src/main.ts +++ b/packages/plugin/src/main.ts @@ -198,7 +198,7 @@ export default class MdsyncPlugin extends Plugin implements PluginHost { registerStatePersistenceFlush(this, this.statePersister); // Re-render the open Settings tab so auth status updates live. - registerProtocolHandlers(this, () => this.settingsTab?.display()); + registerProtocolHandlers(this, () => this.settingsTab?.update()); if (this.freshInstall) { // Saved first: closing the wizard must not bring it back on every start. @@ -276,7 +276,7 @@ export default class MdsyncPlugin extends Plugin implements PluginHost { openSetup(step?: ESetupStep): void { // The settings tab under the wizard would keep showing what the wizard replaced. - openSetupWizard(this, step, () => this.settingsTab?.display()); + openSetupWizard(this, step, () => this.settingsTab?.update()); } scheduleScopeRefresh(reason = "Sync scope changed."): void { @@ -306,7 +306,7 @@ export default class MdsyncPlugin extends Plugin implements PluginHost { this.refreshEditorSigns(this.settings.showEditorChangeSigns); this.refreshFileIndicators(this.settings.showFileExplorerIndicators); this.refreshSourceControlView(); - this.settingsTab?.display(); + this.settingsTab?.update(); this.scheduleScopeRefresh("Settings imported."); } } diff --git a/packages/plugin/src/settings/fields.ts b/packages/plugin/src/settings/fields.ts index 9bc00ff5..079c2755 100644 --- a/packages/plugin/src/settings/fields.ts +++ b/packages/plugin/src/settings/fields.ts @@ -1,4 +1,10 @@ -import { debounce, Setting } from "obsidian"; +import { + debounce, + Setting, + type SettingDefinitionGroup, + type SettingDefinitionRender, + type SettingGroupItem, +} from "obsidian"; import type { PluginHost } from "@/plugin/host"; import { EFieldKind } from "@/storage"; @@ -59,25 +65,27 @@ export interface SliderField extends FieldBase { export type SettingsField = ToggleField | TextField | NumberField | SliderField; -export function renderFields( - parent: HTMLElement, - ctx: FieldContext, - fields: ReadonlyArray, -): void { - for (const field of fields) { - if (field.when && !field.when(ctx.plugin.settings)) continue; - renderField(parent, ctx, field); - } +export type SettingsRow = Omit & { + render: (setting: Setting) => ReturnType; +}; + +export function fieldRow(ctx: FieldContext, field: SettingsField): SettingsRow { + const { when } = field; + return { + name: field.name, + desc: field.desc, + visible: when ? () => when(ctx.plugin.settings) : undefined, + render: (setting) => { + applyControl(setting, ctx, field); + }, + }; } -/** Returns the row so a caller can attach extra controls beside the field. */ -export function renderField( - parent: HTMLElement, +function applyControl( + setting: Setting, ctx: FieldContext, field: SettingsField, ): Setting { - const setting = new Setting(parent).setName(field.name); - if (field.desc) setting.setDesc(field.desc); if (field.sub) setting.settingEl.addClass(SUB_SETTING_CLASS); const apply = (patch: Partial): void => { @@ -125,24 +133,68 @@ export function renderField( } /** A Test button whose outcome replaces the row description. */ -export function renderCheckRow( - parent: HTMLElement, +export function checkRow( name: string, desc: string, run: () => Promise, +): SettingsRow { + return { + name, + desc, + render: (setting) => { + setting.addButton((button) => { + button.setButtonText("Test").onClick(async () => { + button.setDisabled(true); + button.setButtonText("Testing…"); + const result = await run(); + setting.setDesc(result.message); + setting.descEl.toggleClass(ERROR_DESC_CLASS, !result.ok); + button.setDisabled(false); + button.setButtonText("Test"); + }); + }); + }, + }; +} + +export function group( + heading: string | undefined, + items: SettingGroupItem[], +): SettingDefinitionGroup { + return { type: "group", heading, cls: "mdsync-settings", items }; +} + +export function introRow(text: string): SettingsRow { + return { + name: text, + desc: text, + searchable: false, + render: (setting) => { + setting.setName(""); + }, + }; +} + +export function renderDefinitions( + parent: HTMLElement, + items: ReadonlyArray, ): void { - const setting = new Setting(parent).setName(name).setDesc(desc); - setting.addButton((button) => { - button.setButtonText("Test").onClick(async () => { - button.setDisabled(true); - button.setButtonText("Testing…"); - const result = await run(); - setting.setDesc(result.message); - setting.descEl.toggleClass(ERROR_DESC_CLASS, !result.ok); - button.setDisabled(false); - button.setButtonText("Test"); - }); - }); + for (const item of items) { + if ( + typeof item.visible === "function" + ? !item.visible() + : item.visible === false + ) + continue; + if ("type" in item) { + if (item.heading) new Setting(parent).setName(item.heading).setHeading(); + renderDefinitions(parent, (item.items ?? []) as SettingsRow[]); + } else { + const setting = new Setting(parent).setName(item.name); + if (item.desc !== undefined) setting.setDesc(item.desc); + item.render(setting); + } + } } function applyPatch( diff --git a/packages/plugin/src/settings/logs-view.ts b/packages/plugin/src/settings/logs-view.ts index 02ec4452..c14f02b4 100644 --- a/packages/plugin/src/settings/logs-view.ts +++ b/packages/plugin/src/settings/logs-view.ts @@ -1,4 +1,4 @@ -import { Setting } from "obsidian"; +import { Setting, type SettingDefinitionPage } from "obsidian"; import { ESyncLogLevel, ESyncLogOperation, @@ -6,6 +6,7 @@ import { } from "@/logs/store"; import type { PluginHost } from "@/plugin/host"; import { formatTimestamp } from "@/shared"; +import { imperativePage } from "./page"; const LOG_LEVEL_LABELS: Record = { [ESyncLogLevel.Info]: "Info", @@ -27,12 +28,23 @@ const OPERATION_LABELS: Record = { [ESyncLogOperation.Session]: "Session", }; -export function renderLogsView( +export function logsPage(plugin: PluginHost): SettingDefinitionPage { + return { + type: "page", + name: "Logs", + desc: "Local sync diagnostics and errors.", + page: () => + imperativePage("Logs", (parent, refresh) => + renderLogsView(parent, plugin, refresh), + ), + }; +} + +function renderLogsView( parent: HTMLElement, plugin: PluginHost, onRefresh: () => void, ): void { - new Setting(parent).setName("Logs").setHeading(); parent.createEl("p", { text: "Compare, push, pull, reset and sync errors are recorded locally on this device. " + diff --git a/packages/plugin/src/settings/page.ts b/packages/plugin/src/settings/page.ts new file mode 100644 index 00000000..408d06d4 --- /dev/null +++ b/packages/plugin/src/settings/page.ts @@ -0,0 +1,21 @@ +import { SettingPage } from "obsidian"; + +export function imperativePage( + title: string, + render: (el: HTMLElement, refresh: () => void) => void, +): SettingPage { + return new (class extends SettingPage { + constructor() { + super(); + this.title = title; + this.containerEl.addClass("mdsync-settings"); + } + + display(): void { + const scrollTop = this.rootEl.scrollTop; + this.containerEl.empty(); + render(this.containerEl, () => this.display()); + this.rootEl.scrollTop = scrollTop; + } + })(); +} diff --git a/packages/plugin/src/settings/sections/advanced.ts b/packages/plugin/src/settings/sections/advanced.ts new file mode 100644 index 00000000..88f58bbb --- /dev/null +++ b/packages/plugin/src/settings/sections/advanced.ts @@ -0,0 +1,29 @@ +import { debounce, type SettingDefinitionGroup } from "obsidian"; + +import { type FieldContext, group, TYPING_SETTLE_MS } from "@/settings/fields"; +import { defaultDeviceName } from "@/sync/device"; + +export function advancedSection({ + plugin, +}: FieldContext): SettingDefinitionGroup { + return group("Advanced", [ + { + name: "Device name", + desc: "Shown in file history so you can tell devices apart. Stored locally on this device; never synced.", + render: (setting) => { + setting.addText((text) => + text + .setPlaceholder(defaultDeviceName()) + .setValue(plugin.device.current()) + .onChange( + debounce( + (value) => void plugin.device.rename(value), + TYPING_SETTLE_MS, + true, + ), + ), + ); + }, + }, + ]); +} diff --git a/packages/plugin/src/settings/sections/automation.ts b/packages/plugin/src/settings/sections/automation.ts index f99c34bb..6996acf9 100644 --- a/packages/plugin/src/settings/sections/automation.ts +++ b/packages/plugin/src/settings/sections/automation.ts @@ -1,4 +1,4 @@ -import { Setting } from "obsidian"; +import type { Setting, SettingDefinitionGroup } from "obsidian"; import { AUTO_PUSH_SETTLE_MAX_SECONDS, @@ -13,7 +13,8 @@ import type { PluginHost } from "@/plugin/host"; import type { People } from "@/presence"; import { type FieldContext, - renderFields, + fieldRow, + group, type SettingsField, SUB_SETTING_CLASS, } from "@/settings/fields"; @@ -150,26 +151,21 @@ const AUTOMATION_FIELDS: ReadonlyArray = [ }, ]; -/** Returns unsubscribe for the connected-devices row, if shown. */ -export function renderAutomationSection( - parent: HTMLElement, - plugin: PluginHost, - onDisplay: () => void, -): (() => void) | null { - new Setting(parent).setName("Automation").setHeading(); - - const ctx: FieldContext = { plugin, rerender: onDisplay }; - renderFields(parent, ctx, AUTOMATION_FIELDS); - - if (!plugin.settings.realtimeSync) return null; - return renderConnectedDevices(parent, plugin); +export function automationSection(ctx: FieldContext): SettingDefinitionGroup { + return group("Automation", [ + ...AUTOMATION_FIELDS.map((field) => fieldRow(ctx, field)), + { + name: "Connected devices", + visible: () => ctx.plugin.settings.realtimeSync, + render: (setting) => renderConnectedDevices(setting, ctx.plugin), + }, + ]); } function renderConnectedDevices( - parent: HTMLElement, + devicesSetting: Setting, plugin: PluginHost, ): () => void { - const devicesSetting = new Setting(parent).setName("Connected devices"); devicesSetting.settingEl.addClass(SUB_SETTING_CLASS); const { hub, people } = plugin.realtime; const render = (): void => { diff --git a/packages/plugin/src/settings/sections/backend.ts b/packages/plugin/src/settings/sections/backend.ts index b3d87e57..ab268e04 100644 --- a/packages/plugin/src/settings/sections/backend.ts +++ b/packages/plugin/src/settings/sections/backend.ts @@ -1,55 +1,62 @@ -import { Setting } from "obsidian"; +import type { SettingDefinitionGroup } from "obsidian"; import { STORAGE_CHANGED_REASON } from "@/constants"; -import type { PluginHost } from "@/plugin/host"; import { testConnection } from "@/settings/connection-test"; -import { renderCheckRow } from "@/settings/fields"; +import { + checkRow, + type FieldContext, + group, + introRow, +} from "@/settings/fields"; import type { EStorageBackend } from "@/storage"; import { getDescriptor, listBackends } from "@/storage"; -import { renderStorageFields } from "./storage-fields"; +import { storageRows } from "./storage-fields"; -export function renderBackendSection( - parent: HTMLElement, - plugin: PluginHost, - onDisplay: () => void, -): void { - new Setting(parent).setName("Backend").setHeading(); - new Setting(parent).setDesc( - "Credentials are stored on this device, encrypted with a key in Obsidian's keychain; a copy of the vault does not carry them. Inviting someone to a shared folder hands them to the relay, which signs that folder's requests.", - ); +export function backendSection(ctx: FieldContext): SettingDefinitionGroup { + const { plugin } = ctx; + return group("Backend", [ + introRow( + "Credentials are stored on this device, encrypted with a key in Obsidian's keychain; a copy of the vault does not carry them. Inviting someone to a shared folder hands them to the relay, which signs that folder's requests.", + ), + { + name: "Storage backend", + desc: "Select the remote that holds the encrypted manifest and objects.", + render: (setting) => { + setting.addDropdown((dropdown) => { + const settings = plugin.settings; + for (const entry of listBackends()) { + dropdown.addOption(entry.kind, entry.label); + } + dropdown.setValue(settings.activeStorageKind); + dropdown.onChange((value) => { + const nextKind = value as EStorageBackend; + if (nextKind === settings.activeStorageKind) return; - const settings = plugin.settings; - new Setting(parent) - .setName("Storage backend") - .setDesc("Select the remote that holds the encrypted manifest and objects.") - .addDropdown((dropdown) => { - for (const entry of listBackends()) { - dropdown.addOption(entry.kind, entry.label); - } - dropdown.setValue(settings.activeStorageKind); - dropdown.onChange((value) => { - const nextKind = value as EStorageBackend; - if (nextKind === settings.activeStorageKind) return; + // Keep saved config per backend; switch active pointer and seed defaults on first use. + if (!settings.storageConfigs[nextKind]) { + settings.storageConfigs[nextKind] = + getDescriptor(nextKind).defaults(); + } + settings.activeStorageKind = nextKind; - // Keep saved config per backend; switch active pointer and seed defaults on first use. - if (!settings.storageConfigs[nextKind]) { - settings.storageConfigs[nextKind] = - getDescriptor(nextKind).defaults(); - } - settings.activeStorageKind = nextKind; - - void plugin.saveSettings().then(() => { - plugin.scheduleScopeRefresh(STORAGE_CHANGED_REASON); - onDisplay(); + void plugin.saveSettings().then(() => { + plugin.scheduleScopeRefresh(STORAGE_CHANGED_REASON); + ctx.rerender(); + }); + }); }); - }); - }); - - renderStorageFields(parent, plugin, settings.activeStorageKind); - renderCheckRow( - parent, - "Test connection", - "Checks the credentials above by reading from the remote.", - () => testConnection(plugin), - ); + }, + }, + ...listBackends().flatMap(({ kind }) => + storageRows(plugin, kind).map((row) => ({ + ...row, + visible: () => plugin.settings.activeStorageKind === kind, + })), + ), + checkRow( + "Test connection", + "Checks the credentials above by reading from the remote.", + () => testConnection(plugin), + ), + ]); } diff --git a/packages/plugin/src/settings/sections/configuration-scope.ts b/packages/plugin/src/settings/sections/configuration-scope.ts new file mode 100644 index 00000000..2c0b2ed0 --- /dev/null +++ b/packages/plugin/src/settings/sections/configuration-scope.ts @@ -0,0 +1,150 @@ +import type { SettingDefinitionGroup } from "obsidian"; + +import { + type FieldContext, + fieldRow, + group, + introRow, + type SettingsRow, +} from "@/settings/fields"; +import type { SettingsSyncCategories } from "@/settings/model"; +import { EFieldKind } from "@/storage"; +import type { Manifest } from "@/sync/types"; +import { notifyError, notifyInfo, openConfirmModal } from "@/ui"; +import { createScopePolicy, type ScopePolicy } from "@/vault/scope"; + +interface SettingsSyncRow { + key: keyof SettingsSyncCategories; + name: string; + desc: string; +} + +const SETTINGS_SYNC_ROWS: ReadonlyArray = [ + { + key: "coreSettings", + name: "Core settings", + desc: "App, appearance, core plugins, graph, bookmarks and templates.", + }, + { key: "hotkeys", name: "Hotkeys", desc: "hotkeys.json" }, + { + key: "pluginList", + name: "Enabled community plugins", + desc: "community-plugins.json only", + }, + { + key: "pluginConfigs", + name: "Plugins and their settings", + desc: "Plugin files and settings (MDSync and device-local plugins excluded).", + }, + { + key: "snippets", + name: "CSS snippets", + desc: "snippets folder inside the config folder", + }, + { + key: "themes", + name: "Themes", + desc: "themes folder inside the config folder", + }, +]; + +function countRemoteCategories( + remote: Manifest | null | undefined, + scope: ScopePolicy, +): Map { + const counts = new Map(); + const paths = [ + ...Object.keys(remote?.files ?? {}), + ...(remote?.folders ?? []).map((dir) => `${dir}/`), + ]; + for (const path of paths) { + const category = scope.getCategory(path); + if (category) counts.set(category, (counts.get(category) ?? 0) + 1); + } + return counts; +} + +export function configurationScopeSection( + ctx: FieldContext, +): SettingDefinitionGroup { + const { plugin } = ctx; + return group("Obsidian configuration scope", [ + introRow( + "Workspace, cache, trash and device-local plugin data are never synced. Turning a category off only stops this device from syncing it; the remote keeps its files.", + ), + { + ...introRow("Compare with the remote to see which categories it holds."), + visible: () => !plugin.controller.getSnapshot().result?.remote, + render: (setting) => { + setting.setName("").addButton((button) => + button.setButtonText("Check remote").onClick(async () => { + button.setDisabled(true); + await plugin.controller.refresh(); + ctx.rerender(); + }), + ); + }, + }, + ...SETTINGS_SYNC_ROWS.map((row): SettingsRow => { + const field = fieldRow(ctx, { + kind: EFieldKind.Toggle, + name: row.name, + desc: row.desc, + get: (s) => s.settingsSync[row.key], + set: (v) => ({ + settingsSync: { ...plugin.settings.settingsSync, [row.key]: v }, + }), + refreshScope: true, + }); + return { + ...field, + render: (setting) => { + field.render(setting); + const counts = countRemoteCategories( + plugin.controller.getSnapshot().result?.remote, + createScopePolicy({ + settingsSync: plugin.settings.settingsSync, + configDir: plugin.app.vault.configDir, + }), + ); + const count = counts.get(row.key); + if (!count) return; + setting.addButton((button) => + button + .setButtonText("Clear on remote") + .setTooltip(`${count} remote item(s)`) + .setDestructive() + .onClick(() => void clearCategory(ctx, row)), + ); + }, + }; + }), + ]); +} + +async function clearCategory( + ctx: FieldContext, + row: SettingsSyncRow, +): Promise { + const { plugin } = ctx; + const confirmed = await openConfirmModal({ + app: plugin.app, + title: `Clear ${row.name} on remote?`, + body: [ + `Removes every ${row.name} file from the remote storage. Local files stay on every device, and devices with this category enabled re-upload theirs on their next push.`, + "Disable the category on every device first if it should stay gone. Update MDSync on your other devices before clearing: older versions refuse the cleared remote.", + ], + confirmLabel: "Clear on remote", + confirmClass: "mod-warning", + }); + if (!confirmed) return; + if (await plugin.controller.resetCategory(row.key)) { + notifyInfo(`Cleared ${row.name} on remote.`); + } else { + notifyError( + plugin.controller.getSnapshot().error ?? + "Could not clear the category on the remote.", + ); + } + ctx.rerender(); +} diff --git a/packages/plugin/src/settings/sections/device-transfer.ts b/packages/plugin/src/settings/sections/device-transfer.ts new file mode 100644 index 00000000..9ca811b3 --- /dev/null +++ b/packages/plugin/src/settings/sections/device-transfer.ts @@ -0,0 +1,57 @@ +import type { SettingDefinitionGroup } from "obsidian"; + +import { type FieldContext, group, introRow } from "@/settings/fields"; +import { + askSettingsTransferInput, + notifyInfo, + reportError, + showSettingsTransferExport, +} from "@/ui"; + +export function deviceTransferSection( + ctx: FieldContext, +): SettingDefinitionGroup { + const { plugin } = ctx; + return group("Device transfer", [ + introRow( + "Export or import compact encrypted sync settings. Local-only display preferences stay on each device.", + ), + { + name: "Export setup", + desc: "Create a compact encrypted link and QR code for another device.", + render: (setting) => { + setting.addButton((button) => + button.setButtonText("Export").onClick(() => + showSettingsTransferExport(plugin.app, { + createPackage: (options) => + plugin.transfer.createPackage(options), + }), + ), + ); + }, + }, + { + name: "Import setup", + desc: "Paste an encrypted setup link and replace storage, sync scope, ignore, and automation settings.", + render: (setting) => { + setting.addButton((button) => + button + .setButtonText("Import") + .setDestructive() + .onClick(async () => { + const input = await askSettingsTransferInput(plugin.app); + if (!input) return; + try { + const imported = await plugin.transfer.importFrom(input); + if (!imported) return; + notifyInfo("Settings imported."); + ctx.rerender(); + } catch (err) { + reportError(err); + } + }), + ); + }, + }, + ]); +} diff --git a/packages/plugin/src/settings/sections/exclusions.ts b/packages/plugin/src/settings/sections/exclusions.ts index cb510029..6d1af5fa 100644 --- a/packages/plugin/src/settings/sections/exclusions.ts +++ b/packages/plugin/src/settings/sections/exclusions.ts @@ -1,9 +1,15 @@ -import { type App, debounce, Setting, TFile } from "obsidian"; +import { + type App, + debounce, + type SettingDefinitionGroup, + TFile, +} from "obsidian"; import { IGNORE_FILE_NAME } from "@/constants"; import { type FieldContext, - renderFields, + fieldRow, + group, type SettingsField, TYPING_SETTLE_MS, } from "@/settings/fields"; @@ -39,23 +45,8 @@ const EXCLUSION_FIELDS: ReadonlyArray = [ }, ]; -export function renderExclusionsSection( - parent: HTMLElement, - ctx: FieldContext, -): void { +export function exclusionsSection(ctx: FieldContext): SettingDefinitionGroup { const { plugin } = ctx; - new Setting(parent).setName("Exclusions").setHeading(); - new Setting(parent) - .setName("Shared vault rules") - .setDesc( - `Stored in ${IGNORE_FILE_NAME} at the vault root and synced to your other devices. Shared folders use their own ${IGNORE_FILE_NAME}.`, - ) - .addButton((button) => - button - .setButtonText(`Open ${IGNORE_FILE_NAME}`) - .onClick(() => void openSharedIgnore(plugin.app).catch(reportError)), - ); - const savePatterns = debounce( () => { void plugin @@ -67,28 +58,44 @@ export function renderExclusionsSection( TYPING_SETTLE_MS, true, ); - const patterns = new Setting(parent) - .setName("Rules for this device only") - .setDesc( - `Added to the shared rules; does not edit ${IGNORE_FILE_NAME}. One gitignore-style pattern per line.`, - ) - .setClass("mdsync-ignore-patterns"); - patterns.nameEl.id = "mdsync-local-ignore-label"; - patterns.descEl.id = "mdsync-local-ignore-description"; - patterns.addTextArea((text) => { - text.inputEl.rows = 6; - text.inputEl.spellcheck = false; - text.inputEl.setAttribute("aria-labelledby", patterns.nameEl.id); - text.inputEl.setAttribute("aria-describedby", patterns.descEl.id); - text - .setPlaceholder("Attachments/\n*.tmp") - .setValue(plugin.settings.ignorePatterns) - .onChange((value) => { - plugin.settings.ignorePatterns = value; - savePatterns(); - }); - }); - renderFields(parent, ctx, EXCLUSION_FIELDS); + return group("Exclusions", [ + { + name: "Shared vault rules", + desc: `Stored in ${IGNORE_FILE_NAME} at the vault root and synced to your other devices. Shared folders use their own ${IGNORE_FILE_NAME}.`, + render: (setting) => { + setting.addButton((button) => + button + .setButtonText(`Open ${IGNORE_FILE_NAME}`) + .onClick( + () => void openSharedIgnore(plugin.app).catch(reportError), + ), + ); + }, + }, + { + name: "Rules for this device only", + desc: `Added to the shared rules; does not edit ${IGNORE_FILE_NAME}. One gitignore-style pattern per line.`, + render: (patterns) => { + patterns.setClass("mdsync-ignore-patterns"); + patterns.nameEl.id = "mdsync-local-ignore-label"; + patterns.descEl.id = "mdsync-local-ignore-description"; + patterns.addTextArea((text) => { + text.inputEl.rows = 6; + text.inputEl.spellcheck = false; + text.inputEl.setAttribute("aria-labelledby", patterns.nameEl.id); + text.inputEl.setAttribute("aria-describedby", patterns.descEl.id); + text + .setPlaceholder("Attachments/\n*.tmp") + .setValue(plugin.settings.ignorePatterns) + .onChange((value) => { + plugin.settings.ignorePatterns = value; + savePatterns(); + }); + }); + }, + }, + ...EXCLUSION_FIELDS.map((field) => fieldRow(ctx, field)), + ]); } async function openSharedIgnore(app: App): Promise { diff --git a/packages/plugin/src/settings/sections/index.ts b/packages/plugin/src/settings/sections/index.ts index c99f204b..3ce831de 100644 --- a/packages/plugin/src/settings/sections/index.ts +++ b/packages/plugin/src/settings/sections/index.ts @@ -1,8 +1,12 @@ -export { renderAutomationSection, SETUP_FIELDS } from "./automation"; -export { renderBackendSection } from "./backend"; -export { renderExclusionsSection } from "./exclusions"; -export { renderLinksSection } from "./links"; -export { renderMaintenanceSection } from "./maintenance"; -export { renderRelaySection } from "./relay"; -export { renderSecuritySection } from "./security"; -export { renderSharesSection } from "./shares"; +export { advancedSection } from "./advanced"; +export { automationSection, SETUP_FIELDS } from "./automation"; +export { backendSection } from "./backend"; +export { configurationScopeSection } from "./configuration-scope"; +export { deviceTransferSection } from "./device-transfer"; +export { exclusionsSection } from "./exclusions"; +export { interfaceSection } from "./interface"; +export { linksSection } from "./links"; +export { maintenanceSection } from "./maintenance"; +export { relaySection } from "./relay"; +export { securitySection } from "./security"; +export { sharesSection } from "./shares"; diff --git a/packages/plugin/src/settings/sections/interface.ts b/packages/plugin/src/settings/sections/interface.ts new file mode 100644 index 00000000..5615fd56 --- /dev/null +++ b/packages/plugin/src/settings/sections/interface.ts @@ -0,0 +1,59 @@ +import type { SettingDefinitionGroup } from "obsidian"; + +import { + type FieldContext, + fieldRow, + group, + type SettingsField, +} from "@/settings/fields"; +import { EFieldKind } from "@/storage"; + +const INTERFACE_FIELDS: ReadonlyArray = [ + { + kind: EFieldKind.Toggle, + name: "Status bar indicator", + desc: "Applies after Obsidian restarts.", + get: (s) => s.showStatusBar, + set: (v) => ({ showStatusBar: v }), + }, + { + kind: EFieldKind.Toggle, + name: "Ribbon icon", + desc: "Applies after Obsidian restarts.", + get: (s) => s.showRibbonIcon, + set: (v) => ({ showRibbonIcon: v }), + }, + { + kind: EFieldKind.Toggle, + name: "File and folder indicators", + desc: "Show change marks, linked and ignored paths, who is in a note and new-note dots in the file tree, and a linked or ignored label for the open note. Share badges always stay: they open the share window.", + get: (s) => s.showFileExplorerIndicators, + set: (v) => ({ showFileExplorerIndicators: v }), + after: (plugin) => + plugin.refreshFileIndicators(plugin.settings.showFileExplorerIndicators), + }, + { + kind: EFieldKind.Toggle, + name: "Editor change signs", + desc: "Show per-line gutter marks for changes since the last sync.", + get: (s) => s.showEditorChangeSigns, + set: (v) => ({ showEditorChangeSigns: v }), + after: (plugin) => + plugin.refreshEditorSigns(plugin.settings.showEditorChangeSigns), + }, + { + kind: EFieldKind.Toggle, + name: "File sizes in changes", + desc: "Show current sizes and size changes in the Changes list.", + get: (s) => s.showFileSizes, + set: (v) => ({ showFileSizes: v }), + after: (plugin) => plugin.refreshSourceControlView(), + }, +]; + +export function interfaceSection(ctx: FieldContext): SettingDefinitionGroup { + return group( + undefined, + INTERFACE_FIELDS.map((field) => fieldRow(ctx, field)), + ); +} diff --git a/packages/plugin/src/settings/sections/links.ts b/packages/plugin/src/settings/sections/links.ts index 124f43a2..deb5463f 100644 --- a/packages/plugin/src/settings/sections/links.ts +++ b/packages/plugin/src/settings/sections/links.ts @@ -1,20 +1,24 @@ -import { Setting } from "obsidian"; +import type { SettingDefinitionGroup } from "obsidian"; -import type { PluginHost } from "@/plugin/host"; +import { type FieldContext, group } from "@/settings/fields"; import { openManageLinks } from "@/ui"; -export function renderLinksSection( - parent: HTMLElement, - plugin: PluginHost, -): void { - new Setting(parent).setName("Share links").setHeading(); - const count = plugin.sharedLinks.all().length; - new Setting(parent) - .setName("Notes shared by link") - .setDesc( - `${count === 0 ? "None yet. " : `${count} on this device. `}A link publishes one note, encrypted on this device, to your relay. Create one from the note's menu: MDSync: Share link.`, - ) - .addButton((button) => - button.setButtonText("Manage").onClick(() => openManageLinks(plugin)), - ); +export function linksSection({ plugin }: FieldContext): SettingDefinitionGroup { + return group("Share links", [ + { + name: "Notes shared by link", + render: (setting) => { + const count = plugin.sharedLinks.all().length; + setting + .setDesc( + `${count === 0 ? "None yet. " : `${count} on this device. `}A link publishes one note, encrypted on this device, to your relay. Create one from the note's menu: MDSync: Share link.`, + ) + .addButton((button) => + button + .setButtonText("Manage") + .onClick(() => openManageLinks(plugin)), + ); + }, + }, + ]); } diff --git a/packages/plugin/src/settings/sections/maintenance.ts b/packages/plugin/src/settings/sections/maintenance.ts index 0324bd37..925282ec 100644 --- a/packages/plugin/src/settings/sections/maintenance.ts +++ b/packages/plugin/src/settings/sections/maintenance.ts @@ -1,6 +1,7 @@ -import { Setting } from "obsidian"; +import type { SettingDefinitionGroup } from "obsidian"; import type { PluginHost } from "@/plugin/host"; +import { type FieldContext, group } from "@/settings/fields"; import { deepCleanOrphanedObjects, resetLocalState, @@ -46,28 +47,29 @@ const MAINTENANCE_ACTIONS: ReadonlyArray = [ }, ]; -export function renderMaintenanceSection( - parent: HTMLElement, - plugin: PluginHost, -): void { - new Setting(parent).setName("Maintenance").setHeading(); - - for (const action of MAINTENANCE_ACTIONS) { - new Setting(parent) - .setName(action.name) - .setDesc(action.desc) - .addButton((button) => { - let running = false; - button.setButtonText(action.buttonText).onClick(() => { - if (running) return; - running = true; - button.setDisabled(true); - void action.run(plugin).finally(() => { - running = false; - button.setDisabled(false); +export function maintenanceSection({ + plugin, +}: FieldContext): SettingDefinitionGroup { + return group( + undefined, + MAINTENANCE_ACTIONS.map((action) => ({ + name: action.name, + desc: action.desc, + render: (setting) => { + setting.addButton((button) => { + let running = false; + button.setButtonText(action.buttonText).onClick(() => { + if (running) return; + running = true; + button.setDisabled(true); + void action.run(plugin).finally(() => { + running = false; + button.setDisabled(false); + }); }); + if (action.warning) button.setDestructive(); }); - if (action.warning) button.setDestructive(); - }); - } + }, + })), + ); } diff --git a/packages/plugin/src/settings/sections/relay.ts b/packages/plugin/src/settings/sections/relay.ts index 1cfab6c1..0e0c440b 100644 --- a/packages/plugin/src/settings/sections/relay.ts +++ b/packages/plugin/src/settings/sections/relay.ts @@ -1,12 +1,13 @@ -import { Setting } from "obsidian"; +import type { SettingDefinitionGroup } from "obsidian"; import { randomSecret } from "@/crypto"; import { forgetCloudflare } from "@/settings/cloudflare-login"; import { testRelay } from "@/settings/connection-test"; import { + checkRow, type FieldContext, - renderCheckRow, - renderField, + fieldRow, + group, } from "@/settings/fields"; import { isRelayConfigured } from "@/settings/model"; import { ESetupStep } from "@/settings/setup/steps"; @@ -18,80 +19,84 @@ function secretButtonLabel(secret: string): string { return secret ? "Copy" : "Generate"; } -export function renderRelaySection( - parent: HTMLElement, - ctx: FieldContext, -): void { +export function relaySection(ctx: FieldContext): SettingDefinitionGroup { const { plugin } = ctx; - new Setting(parent).setName("Relay server").setHeading(); - new Setting(parent) - .setDesc( - "Your own Cloudflare worker: instant sync between devices, live notes and share links. MDSync deploys it to your Cloudflare account.", - ) - .addButton((button) => - button - .setButtonText( - isRelayConfigured(plugin.settings) ? "Update relay" : "Deploy relay", - ) - .onClick(() => plugin.openSetup(ESetupStep.Relay)), - ); - - renderField(parent, ctx, { - kind: EFieldKind.Text, - name: "Relay URL", - placeholder: "https://mdsync-relay..workers.dev", - get: (s) => s.relayUrl, - set: (v) => ({ relayUrl: relayBase(v) }), - }); - - const secretRow = renderField(parent, ctx, { + const secretRow = fieldRow(ctx, { kind: EFieldKind.Password, name: "Relay secret", desc: "Matches RELAY_SECRET on the worker. Sent only to your relay.", get: (s) => s.relaySecret, set: (v) => ({ relaySecret: v.trim() }), }); - secretRow.addButton((button) => { - const showLabel = (): void => { - button.setButtonText(secretButtonLabel(plugin.settings.relaySecret)); - }; - showLabel(); - secretRow.controlEl - .querySelector("input") - ?.addEventListener("input", showLabel); - button.onClick(async () => { - if (!plugin.settings.relaySecret) { - plugin.settings.relaySecret = randomSecret(); - await plugin.saveSettings(); - ctx.rerender(); - } - const { relaySecret } = plugin.settings; - await runWithNotice( - () => navigator.clipboard.writeText(relaySecret), - "Relay secret copied.", - "Could not copy the relay secret", - ); - }); - }); - - renderCheckRow( - parent, - "Test relay", - "Checks that the URL reaches your relay and the secret matches.", - () => testRelay(plugin.settings), - ); + return group("Relay server", [ + { + name: "Deploy relay", + desc: "Your own Cloudflare worker: instant sync between devices, live notes and share links. MDSync deploys it to your Cloudflare account.", + render: (setting) => { + const configured = isRelayConfigured(plugin.settings); + setting.setName(configured ? "Update relay" : "Deploy relay"); + setting.addButton((button) => + button + .setButtonText(configured ? "Update" : "Deploy") + .onClick(() => plugin.openSetup(ESetupStep.Relay)), + ); + }, + }, + fieldRow(ctx, { + kind: EFieldKind.Text, + name: "Relay URL", + placeholder: "https://mdsync-relay..workers.dev", + get: (s) => s.relayUrl, + set: (v) => ({ relayUrl: relayBase(v) }), + }), + { + ...secretRow, + render: (setting) => { + secretRow.render(setting); + setting.addButton((button) => { + const showLabel = (): void => { + button.setButtonText( + secretButtonLabel(plugin.settings.relaySecret), + ); + }; + showLabel(); + setting.controlEl + .querySelector("input") + ?.addEventListener("input", showLabel); + button.onClick(async () => { + if (!plugin.settings.relaySecret) { + plugin.settings.relaySecret = randomSecret(); + await plugin.saveSettings(); + ctx.rerender(); + } + const { relaySecret } = plugin.settings; + await runWithNotice( + () => navigator.clipboard.writeText(relaySecret), + "Relay secret copied.", + "Could not copy the relay secret", + ); + }); + }); + }, + }, - if (plugin.settings.cloudflareToken) { - new Setting(parent) - .setName("Cloudflare token") - .setDesc( - "Kept on this device to deploy and update the relay. R2 storage keys made from it stop working if it is deleted in Cloudflare.", - ) - .addButton((button) => - button.setButtonText("Forget").onClick(async () => { - await forgetCloudflare(plugin); - ctx.rerender(); - }), - ); - } + checkRow( + "Test relay", + "Checks that the URL reaches your relay and the secret matches.", + () => testRelay(plugin.settings), + ), + { + name: "Cloudflare token", + desc: "Kept on this device to deploy and update the relay. R2 storage keys made from it stop working if it is deleted in Cloudflare.", + visible: () => Boolean(plugin.settings.cloudflareToken), + render: (setting) => { + setting.addButton((button) => + button.setButtonText("Forget").onClick(async () => { + await forgetCloudflare(plugin); + ctx.rerender(); + }), + ); + }, + }, + ]); } diff --git a/packages/plugin/src/settings/sections/security.ts b/packages/plugin/src/settings/sections/security.ts index 6b64afc5..30062384 100644 --- a/packages/plugin/src/settings/sections/security.ts +++ b/packages/plugin/src/settings/sections/security.ts @@ -1,94 +1,94 @@ -import { Setting } from "obsidian"; +import type { SettingDefinitionGroup } from "obsidian"; import { clearCachedPassphrase } from "@/crypto/passphrase-cache"; -import type { PluginHost } from "@/plugin/host"; +import { type FieldContext, group } from "@/settings/fields"; import { isStorageConfigured } from "@/settings/model"; import { PassphraseRotatedError } from "@/sync/keyfile"; import { askNewPassphrase, notifyError, notifyInfo, reportError } from "@/ui"; -export function renderSecuritySection( - parent: HTMLElement, - plugin: PluginHost, - onDisplay: () => void, -): void { - new Setting(parent).setName("Encryption").setHeading(); - - const status = plugin.passphrase.has() - ? "Loaded for this session." - : "Not loaded for this session. Sync will use a saved passphrase or ask you for one."; - - new Setting(parent) - .setName("Cache passphrase between launches") - .setDesc( - "Saves a verified passphrase on this device, encrypted with a key kept in Obsidian's keychain, outside the vault. Disable on shared devices.", - ) - .addToggle((t) => - t.setValue(plugin.settings.cachePassphrase).onChange(async (v) => { - Object.assign(plugin.settings, { cachePassphrase: v }); - await plugin.saveSettings(); - if (v) { - await plugin.passphrase.persistIfEnabled(); - return; - } - await clearCachedPassphrase( - plugin.app.vault.adapter, - plugin.app.vault.configDir, +export function securitySection(ctx: FieldContext): SettingDefinitionGroup { + const { plugin, rerender } = ctx; + return group("Encryption", [ + { + name: "Cache passphrase between launches", + desc: "Saves a verified passphrase on this device, encrypted with a key kept in Obsidian's keychain, outside the vault. Disable on shared devices.", + render: (setting) => { + setting.addToggle((t) => + t.setValue(plugin.settings.cachePassphrase).onChange(async (v) => { + Object.assign(plugin.settings, { cachePassphrase: v }); + await plugin.saveSettings(); + if (v) { + await plugin.passphrase.persistIfEnabled(); + return; + } + await clearCachedPassphrase( + plugin.app.vault.adapter, + plugin.app.vault.configDir, + ); + }), ); - }), - ); - - new Setting(parent) - .setName("Passphrase on this device") - .setDesc( - `${status} Enter does not change the vault passphrase. Forget clears it from this device, including the saved copy.`, - ) - .addButton((b) => - b.setButtonText("Enter…").onClick(async () => { - if (await plugin.passphrase.prompt(true)) onDisplay(); - }), - ) - .addButton((b) => - b - .setButtonText("Forget") - .setDestructive() - .onClick(async () => { - await plugin.passphrase.forget(); - notifyInfo("Passphrase forgotten on this device."); - onDisplay(); - }), - ); - - new Setting(parent) - .setName("Change vault passphrase") - .setDesc( - "Set a new passphrase for this vault. Notes do not need to be uploaded again. Your other devices must enter the new passphrase.", - ) - .addButton((button) => - button.setButtonText("Change…").onClick(async () => { - if (!isStorageConfigured(plugin.settings)) { - notifyInfo("Configure a storage backend first."); - return; - } - button.setDisabled(true); - try { - if (!(await plugin.passphrase.prompt(false))) return; - const next = await askNewPassphrase(plugin.app); - if (!next) return; - const epoch = await plugin.passphrase.rotate(next); - if (epoch === null) return; - notifyInfo( - "Vault passphrase changed. Enter it on your other devices.", + }, + }, + { + name: "Passphrase on this device", + render: (setting) => { + const status = plugin.passphrase.has() + ? "Loaded for this session." + : "Not loaded for this session. Sync will use a saved passphrase or ask you for one."; + setting + .setDesc( + `${status} Enter does not change the vault passphrase. Forget clears it from this device, including the saved copy.`, + ) + .addButton((b) => + b.setButtonText("Enter…").onClick(async () => { + if (await plugin.passphrase.prompt(true)) rerender(); + }), + ) + .addButton((b) => + b + .setButtonText("Forget") + .setDestructive() + .onClick(async () => { + await plugin.passphrase.forget(); + notifyInfo("Passphrase forgotten on this device."); + rerender(); + }), ); - } catch (err) { - if (err instanceof PassphraseRotatedError) { - notifyError("Current passphrase is incorrect."); - return; - } - reportError(err); - } finally { - button.setDisabled(false); - onDisplay(); - } - }), - ); + }, + }, + { + name: "Change vault passphrase", + desc: "Set a new passphrase for this vault. Notes do not need to be uploaded again. Your other devices must enter the new passphrase.", + render: (setting) => { + setting.addButton((button) => + button.setButtonText("Change…").onClick(async () => { + if (!isStorageConfigured(plugin.settings)) { + notifyInfo("Configure a storage backend first."); + return; + } + button.setDisabled(true); + try { + if (!(await plugin.passphrase.prompt(false))) return; + const next = await askNewPassphrase(plugin.app); + if (!next) return; + const epoch = await plugin.passphrase.rotate(next); + if (epoch === null) return; + notifyInfo( + "Vault passphrase changed. Enter it on your other devices.", + ); + } catch (err) { + if (err instanceof PassphraseRotatedError) { + notifyError("Current passphrase is incorrect."); + return; + } + reportError(err); + } finally { + button.setDisabled(false); + rerender(); + } + }), + ); + }, + }, + ]); } diff --git a/packages/plugin/src/settings/sections/shares.ts b/packages/plugin/src/settings/sections/shares.ts index ab9cf57e..6ee3e937 100644 --- a/packages/plugin/src/settings/sections/shares.ts +++ b/packages/plugin/src/settings/sections/shares.ts @@ -1,35 +1,38 @@ -import { Setting } from "obsidian"; +import { Setting, type SettingDefinitionGroup } from "obsidian"; -import { type FieldContext, renderField } from "@/settings/fields"; +import { + type FieldContext, + fieldRow, + group, + introRow, +} from "@/settings/fields"; +import { imperativePage } from "@/settings/page"; import { ownerNameOf, renameOwner } from "@/spaces"; import { EFieldKind } from "@/storage"; import { openInvite, openShareWindow, shareSummary } from "@/ui"; /** This person's open shares, each managed in its window, even on a device where the folder is gone. */ -export function renderSharesSection( - parent: HTMLElement, - ctx: FieldContext, -): void { - const { plugin, rerender } = ctx; - new Setting(parent).setName("Shared folders").setHeading(); - renderField(parent, ctx, { - kind: EFieldKind.Toggle, - name: "Use shared folders on this device", - desc: "Off, none of them syncs or shows up here, and the relay carries only the vault. Their folders already here stay as they are. This device only.", - get: (s) => s.useSharedFolders, - set: (v) => ({ useSharedFolders: v }), - after: (host) => { - // A pull or push running now would go on writing into a share. - if (!host.settings.useSharedFolders) host.controller.cancel(); - void host.controller.refresh(); - }, - rerender: true, - }); - if (!plugin.settings.useSharedFolders) return; - const open = plugin.spaces.list().filter((record) => !record.closed); - // Kept on the shares themselves, so it only exists once there is one to show it in. - if (open.some(({ access }) => access.kind === "owner")) { - renderField(parent, ctx, { +export function sharesSection(ctx: FieldContext): SettingDefinitionGroup { + const { plugin } = ctx; + const open = () => plugin.spaces.list().filter((record) => !record.closed); + const enabled = () => plugin.settings.useSharedFolders; + return group("Shared folders", [ + fieldRow(ctx, { + kind: EFieldKind.Toggle, + name: "Use shared folders on this device", + desc: "Off, none of them syncs or shows up here, and the relay carries only the vault. Their folders already here stay as they are. This device only.", + get: (s) => s.useSharedFolders, + set: (v) => ({ useSharedFolders: v }), + after: (host) => { + // A pull or push running now would go on writing into a share. + if (!host.settings.useSharedFolders) host.controller.cancel(); + void host.controller.refresh(); + }, + rerender: true, + }), + fieldRow(ctx, { + when: () => + enabled() && open().some(({ access }) => access.kind === "owner"), kind: EFieldKind.Text, name: "Your name", desc: "How the people you share folders with see you. Empty shows Owner.", @@ -42,54 +45,69 @@ export function renderSharesSection( host.controller.currentDevice().id, ), }), - }); - } - renderField(parent, ctx, { - kind: EFieldKind.Toggle, - name: "Push shared folders right away", - desc: "A change in a shared folder reaches the others a couple of seconds after you stop, whatever the vault's auto-push says. Read-only folders never push.", - get: (s) => s.pushSharesRightAway, - set: (v) => ({ pushSharesRightAway: v }), - rerender: true, - }); - if (plugin.settings.pushSharesRightAway) { - renderField(parent, ctx, { + }), + fieldRow(ctx, { + when: enabled, + kind: EFieldKind.Toggle, + name: "Push shared folders right away", + desc: "A change in a shared folder reaches the others a couple of seconds after you stop, whatever the vault's auto-push says. Read-only folders never push.", + get: (s) => s.pushSharesRightAway, + set: (v) => ({ pushSharesRightAway: v }), + rerender: true, + }), + fieldRow(ctx, { + when: (s) => enabled() && s.pushSharesRightAway, kind: EFieldKind.Toggle, name: "Push only added, moved and deleted files", desc: "Edits wait for your push, so their gutter marks show what changed. Open notes still update live.", sub: true, get: (s) => s.shareEditsWait, set: (v) => ({ shareEditsWait: v }), - }); - } - renderField(parent, ctx, { - kind: EFieldKind.Toggle, - name: "Pause shared folders added on other devices", - desc: "They arrive here paused, before anything is pulled, until you resume them. This device only.", - get: (s) => s.pauseArrivingShares, - set: (v) => ({ pauseArrivingShares: v }), - }); - new Setting(parent) - .setName("Accept an invite") - .setDesc( - "Paste a link someone sent you, when opening it lands in another vault.", - ) - .addButton((button) => - button.setButtonText("Paste link").onClick(() => void openInvite(plugin)), - ); - if (open.length === 0) { - new Setting(parent).setDesc( - "Nothing shared yet. Open a folder's menu in the file explorer and select MDSync: Share folder.", - ); - } - for (const record of open) { - new Setting(parent) - .setName(record.name) - .setDesc(shareSummary(plugin.spaces, record)) - .addButton((button) => - button - .setButtonText("Manage") - .onClick(() => openShareWindow(plugin, record, rerender)), - ); - } + }), + fieldRow(ctx, { + when: enabled, + kind: EFieldKind.Toggle, + name: "Pause shared folders added on other devices", + desc: "They arrive here paused, before anything is pulled, until you resume them. This device only.", + get: (s) => s.pauseArrivingShares, + set: (v) => ({ pauseArrivingShares: v }), + }), + { + name: "Accept an invite", + desc: "Paste a link someone sent you, when opening it lands in another vault.", + visible: enabled, + render: (setting) => { + setting.addButton((button) => + button + .setButtonText("Paste link") + .onClick(() => void openInvite(plugin)), + ); + }, + }, + { + ...introRow( + "Nothing shared yet. Open a folder's menu in the file explorer and select MDSync: Share folder.", + ), + visible: () => enabled() && open().length === 0, + }, + { + type: "page", + name: "Your shared folders", + visible: () => enabled() && open().length > 0, + displayValue: () => String(open().length), + page: () => + imperativePage("Your shared folders", (parent, refresh) => { + for (const record of open()) { + new Setting(parent) + .setName(record.name) + .setDesc(shareSummary(plugin.spaces, record)) + .addButton((button) => + button + .setButtonText("Manage") + .onClick(() => openShareWindow(plugin, record, refresh)), + ); + } + }), + }, + ]); } diff --git a/packages/plugin/src/settings/sections/storage-fields.ts b/packages/plugin/src/settings/sections/storage-fields.ts index 0ffb2d60..6d88ecb4 100644 --- a/packages/plugin/src/settings/sections/storage-fields.ts +++ b/packages/plugin/src/settings/sections/storage-fields.ts @@ -1,6 +1,7 @@ -import { Setting } from "obsidian"; +import type { Setting } from "obsidian"; import type { PluginHost } from "@/plugin/host"; +import type { SettingsRow } from "@/settings/fields"; import { EFieldKind, EStorageBackend, @@ -11,21 +12,22 @@ import { } from "@/storage"; import { notifyError, notifyInfo } from "@/ui"; -/** - * Renders one backend's credential fields. Takes the kind explicitly so the shares section can edit S3 - * without the vault switching to it. - */ -export function renderStorageFields( - parent: HTMLElement, +export function storageRows( plugin: PluginHost, kind: EStorageBackend, -): void { - for (const field of getDescriptor(kind).fields) { - renderStorageField(parent, plugin, kind, field); - } +): SettingsRow[] { + const rows: SettingsRow[] = getDescriptor(kind).fields.map((field) => ({ + name: field.name, + desc: field.desc, + render: (setting) => renderStorageField(setting, plugin, kind, field), + })); if (kind === EStorageBackend.GoogleDrive) { - renderGoogleDriveAuth(parent, plugin, kind); + rows.push({ + name: "Google account", + render: (setting) => renderGoogleDriveAuth(setting, plugin, kind), + }); } + return rows; } function storageOf( @@ -38,13 +40,11 @@ function storageOf( } function renderStorageField( - parent: HTMLElement, + setting: Setting, plugin: PluginHost, kind: EStorageBackend, field: SettingsFieldSpec, ): void { - const setting = new Setting(parent).setName(field.name); - if (field.desc) setting.setDesc(field.desc); const storage = storageOf(plugin, kind); if (field.kind === EFieldKind.Toggle) { @@ -89,7 +89,7 @@ function renderStorageField( } function renderGoogleDriveAuth( - parent: HTMLElement, + setting: Setting, plugin: PluginHost, kind: EStorageBackend, ): void { @@ -98,8 +98,7 @@ function renderGoogleDriveAuth( storageOf(plugin, kind) as unknown as GoogleDriveStorageConfig; const isAuth = Boolean(config().refreshToken); - new Setting(parent) - .setName("Google account") + setting .setDesc( isAuth ? "Authenticated. Tokens are stored on this device." diff --git a/packages/plugin/src/settings/setup/ready-step.ts b/packages/plugin/src/settings/setup/ready-step.ts index 44bd6542..7ba5ef4c 100644 --- a/packages/plugin/src/settings/setup/ready-step.ts +++ b/packages/plugin/src/settings/setup/ready-step.ts @@ -1,7 +1,7 @@ import { Setting, setIcon } from "obsidian"; import { SOURCE_CONTROL_VIEW_TYPE } from "@/constants"; -import { renderFields } from "@/settings/fields"; +import { fieldRow, renderDefinitions } from "@/settings/fields"; import { activeStorage, canSync, @@ -157,10 +157,11 @@ export function createReadyStep(wizard: Wizard): StepView { }); renderSummary(el); renderSync(el); - renderFields( + renderDefinitions( el, - { plugin, rerender: () => wizard.redraw() }, - SETUP_FIELDS, + SETUP_FIELDS.map((field) => + fieldRow({ plugin, rerender: () => wizard.redraw() }, field), + ), ); new Setting(el) .setName("Set up another device") diff --git a/packages/plugin/src/settings/setup/storage-step.ts b/packages/plugin/src/settings/setup/storage-step.ts index 9c51dc23..9261c525 100644 --- a/packages/plugin/src/settings/setup/storage-step.ts +++ b/packages/plugin/src/settings/setup/storage-step.ts @@ -4,8 +4,9 @@ import { cloudflareOf, } from "@/settings/cloudflare-login"; import { testConnection } from "@/settings/connection-test"; +import { renderDefinitions } from "@/settings/fields"; import { activeStorage, isStorageConfigured } from "@/settings/model"; -import { renderBackendSection } from "@/settings/sections/backend"; +import { backendSection } from "@/settings/sections/backend"; import { describeStorageTarget } from "@/storage"; import { alertLine } from "@/ui/common"; import { choiceCard } from "./choice-card"; @@ -116,7 +117,9 @@ export function createStorageStep(wizard: Wizard): StepView { }; const renderOther = (el: HTMLElement): void => { - renderBackendSection(el, plugin, () => wizard.redraw()); + renderDefinitions(el, [ + backendSection({ plugin, rerender: () => wizard.redraw() }), + ]); if (state.error) alertLine(el).setText(state.error); actionRow(el).addButton((b) => b diff --git a/packages/plugin/src/settings/tab.ts b/packages/plugin/src/settings/tab.ts index 1d4b8a7a..430ced06 100644 --- a/packages/plugin/src/settings/tab.ts +++ b/packages/plugin/src/settings/tab.ts @@ -1,412 +1,94 @@ import { type App, - debounce, type Plugin, PluginSettingTab, - Setting, + type SettingDefinitionItem, } from "obsidian"; import type { PluginHost } from "@/plugin/host"; -import { EFieldKind } from "@/storage"; -import { defaultDeviceName } from "@/sync/device"; -import type { Manifest } from "@/sync/types"; +import { type FieldContext, group } from "./fields"; +import { logsPage } from "./logs-view"; +import { canSync } from "./model"; import { - askSettingsTransferInput, - notifyError, - notifyInfo, - openConfirmModal, - reportError, - showSettingsTransferExport, -} from "@/ui"; -import { createScopePolicy, type ScopePolicy } from "@/vault/scope"; -import { - type FieldContext, - renderField, - renderFields, - type SettingsField, - TYPING_SETTLE_MS, -} from "./fields"; -import { renderLogsView } from "./logs-view"; -import { - canSync, - type MdsyncSettings, - type SettingsSyncCategories, -} from "./model"; -import { - renderAutomationSection, - renderBackendSection, - renderExclusionsSection, - renderLinksSection, - renderMaintenanceSection, - renderRelaySection, - renderSecuritySection, - renderSharesSection, + advancedSection, + automationSection, + backendSection, + configurationScopeSection, + deviceTransferSection, + exclusionsSection, + interfaceSection, + linksSection, + maintenanceSection, + relaySection, + securitySection, + sharesSection, } from "./sections"; -const ESettingsViewTab = { - Connection: "connection", - Sync: "sync", - Interface: "interface", - Maintenance: "maintenance", - Logs: "logs", -} as const; -type ESettingsViewTab = - (typeof ESettingsViewTab)[keyof typeof ESettingsViewTab]; - -const SETTINGS_TAB_LABELS: Record = { - [ESettingsViewTab.Connection]: "Connection", - [ESettingsViewTab.Sync]: "Sync", - [ESettingsViewTab.Interface]: "Interface", - [ESettingsViewTab.Maintenance]: "Maintenance", - [ESettingsViewTab.Logs]: "Logs", -}; - -const SETTINGS_TABS = Object.values(ESettingsViewTab); - -interface SettingsSyncRow { - key: keyof SettingsSyncCategories; - name: string; - desc: string; -} - -const SETTINGS_SYNC_ROWS: ReadonlyArray = [ - { - key: "coreSettings", - name: "Core settings", - desc: "App, appearance, core plugins, graph, bookmarks and templates.", - }, - { key: "hotkeys", name: "Hotkeys", desc: "hotkeys.json" }, - { - key: "pluginList", - name: "Enabled community plugins", - desc: "community-plugins.json only", - }, - { - key: "pluginConfigs", - name: "Plugins and their settings", - desc: "Plugin files and settings (MDSync and device-local plugins excluded).", - }, - { - key: "snippets", - name: "CSS snippets", - desc: "snippets folder inside the config folder", - }, - { - key: "themes", - name: "Themes", - desc: "themes folder inside the config folder", - }, -]; - -/** Files and empty folders the remote holds per configuration category. */ -function countRemoteCategories( - remote: Manifest | null | undefined, - scope: ScopePolicy, -): Map { - const counts = new Map(); - const paths = [ - ...Object.keys(remote?.files ?? {}), - ...(remote?.folders ?? []).map((dir) => `${dir}/`), - ]; - for (const path of paths) { - const category = scope.getCategory(path); - if (category) counts.set(category, (counts.get(category) ?? 0) + 1); - } - return counts; -} - -const INTERFACE_FIELDS: ReadonlyArray = [ - { - kind: EFieldKind.Toggle, - name: "Status bar indicator", - desc: "Applies after Obsidian restarts.", - get: (s) => s.showStatusBar, - set: (v) => ({ showStatusBar: v }), - }, - { - kind: EFieldKind.Toggle, - name: "Ribbon icon", - desc: "Applies after Obsidian restarts.", - get: (s) => s.showRibbonIcon, - set: (v) => ({ showRibbonIcon: v }), - }, - { - kind: EFieldKind.Toggle, - name: "File and folder indicators", - desc: "Show change marks, linked and ignored paths, who is in a note and new-note dots in the file tree, and a linked or ignored label for the open note. Share badges always stay: they open the share window.", - get: (s) => s.showFileExplorerIndicators, - set: (v) => ({ showFileExplorerIndicators: v }), - after: (plugin) => - plugin.refreshFileIndicators(plugin.settings.showFileExplorerIndicators), - }, - { - kind: EFieldKind.Toggle, - name: "Editor change signs", - desc: "Show per-line gutter marks for changes since the last sync.", - get: (s) => s.showEditorChangeSigns, - set: (v) => ({ showEditorChangeSigns: v }), - after: (plugin) => - plugin.refreshEditorSigns(plugin.settings.showEditorChangeSigns), - }, - { - kind: EFieldKind.Toggle, - name: "File sizes in changes", - desc: "Show current sizes and size changes in the Changes list.", - get: (s) => s.showFileSizes, - set: (v) => ({ showFileSizes: v }), - after: (plugin) => plugin.refreshSourceControlView(), - }, -]; - export class MdsyncSettingTab extends PluginSettingTab { private readonly plugin: Plugin & PluginHost; - private activeTab: ESettingsViewTab = ESettingsViewTab.Connection; - private sectionUnsubs: Array<() => void> = []; constructor(app: App, plugin: Plugin & PluginHost) { super(app, plugin); this.plugin = plugin; } - hide(): void { - this.unsubscribeSections(); - this.activeTab = ESettingsViewTab.Connection; - } - - display(): void { - this.unsubscribeSections(); - const { containerEl } = this; - const scrollTop = containerEl.scrollTop; - containerEl.addClass("mdsync-settings"); - containerEl.empty(); - this.renderTabBar(containerEl); - - switch (this.activeTab) { - case ESettingsViewTab.Connection: - this.renderConnectionTab(containerEl); - break; - case ESettingsViewTab.Sync: - this.renderSyncTab(containerEl); - break; - case ESettingsViewTab.Interface: - this.renderUiSection(containerEl); - break; - case ESettingsViewTab.Maintenance: - renderMaintenanceSection(containerEl, this.plugin); - break; - case ESettingsViewTab.Logs: - renderLogsView(containerEl, this.plugin, () => this.display()); - break; - } - containerEl.scrollTop = scrollTop; - } - - private fieldContext(): FieldContext { - return { plugin: this.plugin, rerender: () => this.display() }; - } - - private unsubscribeSections(): void { - for (const unsub of this.sectionUnsubs) unsub(); - this.sectionUnsubs = []; - } - - private renderTabBar(parent: HTMLElement): void { - const bar = parent.createDiv({ - cls: "mdsync-settings-tabs mdsync-settings-nav", - }); - bar.setAttr("role", "tablist"); - bar.setAttr("aria-label", "MDSync settings sections"); - for (const tab of SETTINGS_TABS) this.renderTabButton(bar, tab); - } - - private renderTabButton(parent: HTMLElement, tab: ESettingsViewTab): void { - const button = parent.createEl("button", { - cls: "mdsync-settings-tab-button", - text: SETTINGS_TAB_LABELS[tab], - }); - button.type = "button"; - button.setAttr("role", "tab"); - button.setAttr("aria-selected", String(tab === this.activeTab)); - if (tab === this.activeTab) { - button.addClass("is-active"); - } - button.addEventListener("click", () => { - if (tab === this.activeTab) return; - this.activeTab = tab; - this.containerEl.scrollTop = 0; - this.display(); - }); - } - - private renderConnectionTab(parent: HTMLElement): void { - new Setting(parent) - .setName("Setup wizard") - .setDesc("Storage, passphrase and relay, step by step.") - .addButton((button) => { - button.setButtonText("Open").onClick(() => this.plugin.openSetup()); - if (!canSync(this.plugin.settings)) button.setCta(); - }); - renderBackendSection(parent, this.plugin, () => this.display()); - renderSecuritySection(parent, this.plugin, () => this.display()); - renderRelaySection(parent, this.fieldContext()); - this.renderTransferSection(parent); - this.renderAdvancedSection(parent); - } - - private renderSyncTab(parent: HTMLElement): void { - const automationUnsub = renderAutomationSection(parent, this.plugin, () => - this.display(), - ); - if (automationUnsub) this.sectionUnsubs.push(automationUnsub); - renderSharesSection(parent, this.fieldContext()); - renderLinksSection(parent, this.plugin); - this.renderSettingsSyncSection(parent); - renderExclusionsSection(parent, this.fieldContext()); - } - - private renderTransferSection(parent: HTMLElement): void { - new Setting(parent).setName("Device transfer").setHeading(); - new Setting(parent).setDesc( - "Export or import compact encrypted sync settings. Local-only display preferences stay on each device.", - ); - - new Setting(parent) - .setName("Export setup") - .setDesc( - "Create a compact encrypted link and QR code for another device.", - ) - .addButton((button) => - button - .setButtonText("Export") - .onClick(() => void this.handleExportSettings()), - ); - - new Setting(parent) - .setName("Import setup") - .setDesc( - "Paste an encrypted setup link and replace storage, sync scope, ignore, and automation settings.", - ) - .addButton((button) => - button - .setButtonText("Import") - .setDestructive() - .onClick(() => void this.handleImportSettings()), - ); - } - - private renderSettingsSyncSection(parent: HTMLElement): void { - new Setting(parent).setName("Obsidian configuration scope").setHeading(); - new Setting(parent).setDesc( - "Workspace, cache, trash and device-local plugin data are never synced. Turning a category off only stops this device from syncing it; the remote keeps its files.", - ); - const remote = this.plugin.controller.getSnapshot().result?.remote; - if (!remote) { - new Setting(parent) - .setDesc("Compare with the remote to see which categories it holds.") - .addButton((button) => - button.setButtonText("Check remote").onClick(async () => { - button.setDisabled(true); - await this.plugin.controller.refresh(); - this.display(); - }), - ); - } - const remoteCounts = countRemoteCategories( - remote, - createScopePolicy({ - settingsSync: this.plugin.settings.settingsSync, - configDir: this.app.vault.configDir, - }), - ); - for (const row of SETTINGS_SYNC_ROWS) { - const setting = renderField(parent, this.fieldContext(), { - kind: EFieldKind.Toggle, - name: row.name, - desc: row.desc, - get: (s: MdsyncSettings) => s.settingsSync[row.key], - set: (v: boolean) => ({ - settingsSync: { ...this.plugin.settings.settingsSync, [row.key]: v }, - }), - refreshScope: true, - }); - const count = remoteCounts.get(row.key); - if (!count) continue; - setting.addButton((button) => - button - .setButtonText("Clear on remote") - .setTooltip(`${count} remote item(s)`) - .setDestructive() - .onClick(() => void this.handleClearCategoryRemote(row)), - ); - } - } - - private async handleClearCategoryRemote(row: SettingsSyncRow): Promise { - const confirmed = await openConfirmModal({ - app: this.app, - title: `Clear ${row.name} on remote?`, - body: [ - `Removes every ${row.name} file from the remote storage. Local files stay on every device, and devices with this category enabled re-upload theirs on their next push.`, - "Disable the category on every device first if it should stay gone. Update MDSync on your other devices before clearing: older versions refuse the cleared remote.", - ], - confirmLabel: "Clear on remote", - confirmClass: "mod-warning", - }); - if (!confirmed) return; - if (await this.plugin.controller.resetCategory(row.key)) { - notifyInfo(`Cleared ${row.name} on remote.`); - } else { - notifyError( - this.plugin.controller.getSnapshot().error ?? - "Could not clear the category on the remote.", - ); - } - this.display(); - } - - private renderUiSection(parent: HTMLElement): void { - new Setting(parent).setName("Interface").setHeading(); - renderFields(parent, this.fieldContext(), INTERFACE_FIELDS); - } - - private renderAdvancedSection(parent: HTMLElement): void { - new Setting(parent).setName("Advanced").setHeading(); - - new Setting(parent) - .setName("Device name") - .setDesc( - "Shown in file history so you can tell devices apart. Stored locally on this device; never synced.", - ) - .addText((t) => - t - .setPlaceholder(defaultDeviceName()) - .setValue(this.plugin.device.current()) - .onChange( - debounce( - (v) => void this.plugin.device.rename(v), - TYPING_SETTLE_MS, - true, - ), - ), - ); - } - - private async handleExportSettings(): Promise { - showSettingsTransferExport(this.app, { - createPackage: (options) => this.plugin.transfer.createPackage(options), - }); - } - - private async handleImportSettings(): Promise { - const input = await askSettingsTransferInput(this.app); - if (!input) return; - try { - const imported = await this.plugin.transfer.importFrom(input); - if (!imported) return; - notifyInfo("Settings imported."); - this.display(); - } catch (err) { - reportError(err); - } + getSettingDefinitions(): SettingDefinitionItem[] { + const ctx: FieldContext = { + plugin: this.plugin, + rerender: () => this.update(), + }; + return [ + group(undefined, [ + { + name: "Setup wizard", + desc: "Storage, passphrase and relay, step by step.", + render: (setting) => { + setting.addButton((button) => { + button + .setButtonText("Open") + .onClick(() => this.plugin.openSetup()); + if (!canSync(this.plugin.settings)) button.setCta(); + }); + }, + }, + ]), + { + type: "page", + name: "Connection", + desc: "Storage, encryption, relay and device transfer.", + items: [ + backendSection(ctx), + securitySection(ctx), + relaySection(ctx), + deviceTransferSection(ctx), + advancedSection(ctx), + ], + }, + { + type: "page", + name: "Sync", + desc: "Automation, shared folders, share links, configuration scope and exclusions.", + items: [ + automationSection(ctx), + sharesSection(ctx), + linksSection(ctx), + configurationScopeSection(ctx), + exclusionsSection(ctx), + ], + }, + { + type: "page", + name: "Interface", + desc: "Status bar, ribbon, file indicators and editor signs.", + items: [interfaceSection(ctx)], + }, + { + type: "page", + name: "Maintenance", + desc: "Local state, remote integrity and storage cleanup.", + items: [maintenanceSection(ctx)], + }, + logsPage(this.plugin), + ]; } } diff --git a/packages/plugin/src/styles/settings/tabs-and-logs.css b/packages/plugin/src/styles/settings/tabs-and-logs.css index 09ccf42a..871b0f4a 100644 --- a/packages/plugin/src/styles/settings/tabs-and-logs.css +++ b/packages/plugin/src/styles/settings/tabs-and-logs.css @@ -1,9 +1,3 @@ -.mdsync-settings-tabs { - display: flex; - gap: var(--size-4-2); - margin-bottom: var(--size-4-4); -} - .mdsync-settings-tab-button { background-color: var(--interactive-normal); } @@ -14,19 +8,6 @@ color: var(--text-on-accent); } -.mdsync-settings-nav { - flex-wrap: wrap; -} - -.mdsync-settings-nav .mdsync-settings-tab-button { - flex: 0 1 auto; -} - -body.is-phone .mdsync-settings-nav .mdsync-settings-tab-button { - flex-grow: 1; - border: 1px solid var(--background-modifier-border); -} - .mdsync-log-list { display: flex; flex-direction: column; diff --git a/packages/plugin/tests/settings/fields.test.ts b/packages/plugin/tests/settings/fields.test.ts new file mode 100644 index 00000000..7a020c03 --- /dev/null +++ b/packages/plugin/tests/settings/fields.test.ts @@ -0,0 +1,80 @@ +import type { Setting, SettingDefinitionRender } from "obsidian"; +import { describe, expect, it, vi } from "vitest"; + +import { + type FieldContext, + fieldRow, + group, + introRow, + renderDefinitions, + type SettingsRow, +} from "@/settings/fields"; +import { EFieldKind } from "@/storage/field-spec"; + +vi.mock("obsidian", () => ({ + debounce: (fn: unknown) => fn, + Setting: class { + name = ""; + desc = ""; + heading = false; + constructor() { + rows.push(this); + } + setName(name: string) { + this.name = name; + return this; + } + setDesc(desc: string) { + this.desc = desc; + return this; + } + setHeading() { + this.heading = true; + return this; + } + }, +})); + +const rows: { name: string; desc: string; heading: boolean }[] = []; + +describe("setting definitions", () => { + it("evaluates field visibility against current settings", () => { + const settings = { realtimeSync: false }; + const ctx = { + plugin: { settings }, + rerender: vi.fn(), + } as unknown as FieldContext; + const row: SettingDefinitionRender = fieldRow(ctx, { + kind: EFieldKind.Toggle, + name: "Live editing", + when: (s) => s.realtimeSync, + get: (s) => s.liveEditing, + set: (value) => ({ liveEditing: value }), + }); + if (typeof row.visible !== "function") + throw new Error("Missing visibility predicate"); + expect(row.visible()).toBe(false); + settings.realtimeSync = true; + expect(row.visible()).toBe(true); + }); + + it("renders group headings, names and descriptions, skipping invisible rows", () => { + rows.length = 0; + const render = vi.fn(); + const hidden = vi.fn(); + const items: SettingsRow[] = [ + { name: "Shown", desc: "Description", render }, + { name: "Hidden", visible: false, render: hidden }, + { name: "Conditional", visible: () => false, render: hidden }, + introRow("Introduction"), + ]; + renderDefinitions({} as HTMLElement, [group("Heading", items)]); + expect(rows).toEqual([ + { name: "Heading", desc: "", heading: true }, + { name: "Shown", desc: "Description", heading: false }, + { name: "", desc: "Introduction", heading: false }, + ]); + expect(render).toHaveBeenCalledWith(rows[1] as unknown as Setting); + expect(hidden).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin/tests/settings/number-field.test.ts b/packages/plugin/tests/settings/number-field.test.ts index 7666ba0f..aa67fe9d 100644 --- a/packages/plugin/tests/settings/number-field.test.ts +++ b/packages/plugin/tests/settings/number-field.test.ts @@ -1,9 +1,10 @@ +import { Setting } from "obsidian"; import { describe, expect, it, vi } from "vitest"; import { type FieldContext, + fieldRow, type NumberField, - renderField, } from "@/settings/fields"; import { EFieldKind } from "@/storage/field-spec"; @@ -48,7 +49,7 @@ function typeInto(raw: string) { plugin: { settings, saveSettings: async () => undefined }, rerender: () => undefined, } as unknown as FieldContext; - renderField({} as HTMLElement, ctx, minutes); + fieldRow(ctx, minutes).render(new Setting({} as HTMLElement)); typed(raw); return settings.autoSyncIntervalMinutes; } diff --git a/packages/plugin/tests/settings/relay-section.test.ts b/packages/plugin/tests/settings/relay-section.test.ts index 21ba3970..e4154269 100644 --- a/packages/plugin/tests/settings/relay-section.test.ts +++ b/packages/plugin/tests/settings/relay-section.test.ts @@ -1,7 +1,7 @@ +import type { Setting } from "obsidian"; import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { type FieldContext, renderField } from "@/settings/fields"; -import { renderRelaySection } from "@/settings/sections/relay"; +import { type FieldContext, fieldRow } from "@/settings/fields"; +import { relaySection } from "@/settings/sections/relay"; vi.mock("obsidian", () => ({ Setting: class { @@ -20,8 +20,9 @@ vi.mock("obsidian", () => ({ }, })); vi.mock("@/settings/fields", () => ({ - renderField: vi.fn(), - renderCheckRow: vi.fn(), + fieldRow: vi.fn(), + checkRow: vi.fn(), + group: (_heading: string, items: unknown[]) => ({ items }), })); vi.mock("@/ui/common/notices", () => ({ runWithNotice: vi.fn(async (action: () => Promise) => { @@ -52,20 +53,25 @@ function relayForm(relaySecret: string) { typed = listener; }, }; - vi.mocked(renderField) - .mockReturnValueOnce({} as ReturnType) - .mockReturnValueOnce({ - addButton: (build: (button: unknown) => void) => build(button), - controlEl: { querySelector: () => input }, - } as unknown as ReturnType); + vi.mocked(fieldRow).mockImplementation((_ctx, field) => ({ + name: field.name, + render: () => {}, + })); const saveSettings = vi.fn(async () => {}); const rerender = vi.fn(); - renderRelaySection( - {} as HTMLElement, + const definitions = relaySection({ + plugin: { settings, saveSettings }, + rerender, + } as unknown as FieldContext); + const row = definitions.items?.find((item) => item.name === "Relay secret"); + if (!row || !("render" in row) || !row.render) + throw new Error("No relay secret row"); + row.render( { - plugin: { settings, saveSettings }, - rerender, - } as unknown as FieldContext, + addButton: (build: (button: unknown) => void) => build(button), + controlEl: { querySelector: () => input }, + } as unknown as Setting, + {} as never, ); return { settings, diff --git a/packages/plugin/tests/settings/security-section.test.ts b/packages/plugin/tests/settings/security-section.test.ts index 2910beb0..30898c02 100644 --- a/packages/plugin/tests/settings/security-section.test.ts +++ b/packages/plugin/tests/settings/security-section.test.ts @@ -1,8 +1,9 @@ +import { Setting } from "obsidian"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { PluginHost } from "@/plugin/host"; import { isStorageConfigured } from "@/settings/model"; -import { renderSecuritySection } from "@/settings/sections/security"; +import { securitySection } from "@/settings/sections/security"; import { askNewPassphrase, notifyInfo, reportError } from "@/ui"; vi.mock("@/settings/model", () => ({ isStorageConfigured: vi.fn(() => true) })); @@ -77,11 +78,16 @@ function form(loaded = false) { rotate: vi.fn(async () => 2), }; const rerender = vi.fn(); - renderSecuritySection( - {} as HTMLElement, - { settings: {}, passphrase } as unknown as PluginHost, + const definitions = securitySection({ + plugin: { settings: {}, passphrase } as unknown as PluginHost, rerender, - ); + }); + for (const definition of definitions.items ?? []) { + if (!("render" in definition) || !definition.render) continue; + const setting = new Setting({} as HTMLElement).setName(definition.name); + if (definition.desc) setting.setDesc(definition.desc); + definition.render(setting, {} as never); + } return { passphrase, rerender }; } diff --git a/packages/plugin/tests/settings/shares-section.test.ts b/packages/plugin/tests/settings/shares-section.test.ts new file mode 100644 index 00000000..06ad09b8 --- /dev/null +++ b/packages/plugin/tests/settings/shares-section.test.ts @@ -0,0 +1,58 @@ +import type { SettingDefinitionPage } from "obsidian"; +import { describe, expect, it, vi } from "vitest"; + +import type { FieldContext } from "@/settings/fields"; +import { sharesSection } from "@/settings/sections/shares"; + +vi.mock("@/settings/page", () => ({ imperativePage: vi.fn() })); +vi.mock("@/ui", () => ({ + openInvite: vi.fn(), + openShareWindow: vi.fn(), + shareSummary: vi.fn(), +})); + +function form() { + const settings = { useSharedFolders: true }; + const records: { closed?: true; access: { kind: string } }[] = []; + const ctx = { + plugin: { settings, spaces: { list: () => records } }, + rerender: vi.fn(), + } as unknown as FieldContext; + const entry = sharesSection(ctx).items?.find( + (item) => item.name === "Your shared folders", + ) as SettingDefinitionPage; + return { settings, records, entry }; +} + +describe("the shared folders page entry", () => { + it("is visible only with enabled, open shares", () => { + const { settings, records, entry } = form(); + if (typeof entry.visible !== "function") + throw new Error("Missing visibility predicate"); + expect(entry.visible()).toBe(false); + const owner: (typeof records)[number] = { access: { kind: "owner" } }; + records.push(owner); + expect(entry.visible()).toBe(true); + settings.useSharedFolders = false; + expect(entry.visible()).toBe(false); + settings.useSharedFolders = true; + owner.closed = true; + expect(entry.visible()).toBe(false); + }); + + it("counts current open shares without rebuilding definitions", () => { + const { records, entry } = form(); + if (typeof entry.displayValue !== "function") + throw new Error("Missing count callback"); + expect(entry.displayValue()).toBe("0"); + const owner: (typeof records)[number] = { access: { kind: "owner" } }; + records.push( + owner, + { access: { kind: "participant" } }, + { closed: true, access: { kind: "owner" } }, + ); + expect(entry.displayValue()).toBe("2"); + owner.closed = true; + expect(entry.displayValue()).toBe("1"); + }); +}); diff --git a/tests/e2e/device.ts b/tests/e2e/device.ts index 2297f96a..0ffb0004 100644 --- a/tests/e2e/device.ts +++ b/tests/e2e/device.ts @@ -1,5 +1,6 @@ /** What a scenario does to one Obsidian through the plugin, its menus and its modals. */ +import { poll } from "./harness"; import type { Obsidian } from "./obsidian"; // biome-ignore lint/suspicious/noExplicitAny: the renderer's app is untyped here. @@ -215,13 +216,56 @@ export function field(device: Obsidian, name: string): Promise { export async function openSettings( device: Obsidian, - tab: string, + ...pages: string[] ): Promise { await device.evaluate(() => { app.setting.open(); app.setting.openTabById("mdsync"); }); - await press(device, tab); + for (const page of pages) { + await poll(`${page} settings row`, async () => { + const clicked = await device.evaluate((name) => { + const row = [ + ...activeDocument.querySelectorAll( + ".mod-settings .setting-item.mod-navigable", + ), + ].find( + (item) => + item.checkVisibility() && + item.querySelector(".setting-item-name")?.textContent === name, + ); + row?.click(); + return Boolean(row); + }, page); + return clicked || undefined; + }); + // The page slides in over the last one: helpers read the one left shown. + await poll(`${page} settings page shown`, async () => { + const shown = await device.evaluate((name) => { + const visible = (selector: string) => + [ + ...activeDocument.querySelectorAll( + `.mod-settings ${selector}`, + ), + ].filter((el) => el.checkVisibility()); + return ( + visible(".vertical-tab-content").length === 1 && + // A phone shows the page title as the modal's. + visible(".setting-page-title, .modal-title").some( + (el) => el.textContent === name, + ) + ); + }, page); + return shown || undefined; + }); + } +} + +export async function openSharedFolderSettings( + device: Obsidian, +): Promise { + await openSettings(device, "Sync", "Your shared folders"); + await press(device, "Manage"); } export function closeSettings(device: Obsidian): Promise { diff --git a/tests/e2e/people.ts b/tests/e2e/people.ts index ddf97417..4cf4b3df 100644 --- a/tests/e2e/people.ts +++ b/tests/e2e/people.ts @@ -7,7 +7,7 @@ import { CLEAN, closeModals, confirm, - openSettings, + openSharedFolderSettings, press, read, sync, @@ -191,8 +191,7 @@ async function people(owner: Obsidian): Promise { () => activeDocument.querySelector(".mdsync-share-access") !== null, ); if (!open) { - await openSettings(owner, "Sync"); - await press(owner, "Manage"); + await openSharedFolderSettings(owner); } return poll("the people", () => owner.evaluate(() => { diff --git a/tests/e2e/settings.ts b/tests/e2e/settings.ts index 8b051498..2a9d4d87 100644 --- a/tests/e2e/settings.ts +++ b/tests/e2e/settings.ts @@ -1,6 +1,6 @@ import { chromium, type Page } from "playwright-core"; -import { fill, loaded, press } from "./device"; +import { fill, loaded, openSettings, press } from "./device"; import { check, poll, runScenario } from "./harness"; import { launchObsidian, type Obsidian } from "./obsidian"; @@ -45,11 +45,12 @@ await runScenario("settings UX e2e", async () => { await page .locator(".vertical-tab-nav-item-title", { hasText: /^MDSync$/ }) .click(); + await openSettings(device, "Connection"); await checkPassphraseScroll(device, page); await checkSyncSettings(device, page); await checkExclusions(device, page); await checkPhoneLayout(device, page); - await page.getByRole("tab", { name: "Sync", exact: true }).click(); + await openSettings(device, "Sync"); await page .getByRole("button", { name: "Open syncignore.md", exact: true }) .click(); @@ -115,8 +116,8 @@ async function checkPassphraseScroll( } async function checkSyncSettings(device: Obsidian, page: Page): Promise { - await page.getByRole("tab", { name: "Sync", exact: true }).click(); - check("switching sections starts at the top", await scrollTop(device), 0); + await openSettings(device, "Sync"); + check("opening a page starts at the top", await scrollTop(device), 0); const toggle = settingRow(page, "Push shared folders right away").locator( ".checkbox-container", ); @@ -125,7 +126,7 @@ async function checkSyncSettings(device: Obsidian, page: Page): Promise { check("the shared-folder toggle starts below the top", before > 0, true); await toggle.click(); await settingRow(page, "Push only added, moved and deleted files").waitFor({ - state: "detached", + state: "hidden", }); check( "a conditional toggle preserves scroll", @@ -160,9 +161,9 @@ async function checkExclusions(device: Obsidian, page: Page): Promise { 0, ); await textarea.fill(LOCAL_RULES); - await page.getByRole("tab", { name: "Interface", exact: true }).click(); + await openSettings(device, "Interface"); await poll( - "local rules saved after leaving the tab", + "local rules saved after leaving the page", async () => (await device.evaluate( async () => @@ -179,7 +180,7 @@ async function checkExclusions(device: Obsidian, page: Page): Promise { SHARED_RULES, ); - await page.getByRole("tab", { name: "Sync", exact: true }).click(); + await openSettings(device, "Sync"); check( "local rules survive a rerender", await textarea.inputValue(), @@ -199,8 +200,12 @@ async function checkPhoneLayout(device: Obsidian, page: Page): Promise { const textarea = page.locator(".mdsync-ignore-patterns textarea"); await textarea.scrollIntoViewIfNeeded(); const layout = await device.evaluate(() => { - const root = - activeDocument.querySelector(".mdsync-settings"); + const shown = [ + ...activeDocument.querySelectorAll( + ".mod-settings .vertical-tab-content", + ), + ].find((el) => el.checkVisibility()); + const root = shown?.querySelector(".mdsync-settings")?.parentElement; const row = root?.querySelector(".mdsync-ignore-patterns"); const text = row?.querySelector("textarea"); const label = row?.querySelector(".setting-item-info"); @@ -228,13 +233,17 @@ async function checkPhoneLayout(device: Obsidian, page: Page): Promise { }); await shot(page, `settings-exclusions-${width}px`); } - await page.getByRole("tab", { name: "Connection", exact: true }).click(); + await openSettings(device, "Connection"); await page.setViewportSize({ width: 320, height: 844 }); check( "connection controls fit a narrow phone", await device.evaluate(() => { - const root = - activeDocument.querySelector(".mdsync-settings"); + const shown = [ + ...activeDocument.querySelectorAll( + ".mod-settings .vertical-tab-content", + ), + ].find((el) => el.checkVisibility()); + const root = shown?.querySelector(".mdsync-settings")?.parentElement; if (!root) throw new Error("No settings container"); return root.scrollWidth <= root.clientWidth; }), @@ -245,23 +254,29 @@ async function checkPhoneLayout(device: Obsidian, page: Page): Promise { async function shot(page: Page, name: string): Promise { if (!process.env.E2E_SHOTS) return; - await page.locator(".mdsync-settings").screenshot({ + await page.locator(".mod-settings .vertical-tab-content:visible").screenshot({ path: `artifacts/e2e-shots/${name}.png`, style: ".notice-container { display: none; }", }); } function settingRow(page: Page, name: string) { - return page.locator(".mdsync-settings .setting-item").filter({ - has: page.locator(".setting-item-name", { - hasText: new RegExp(`^${name}$`), - }), - }); + return page + .locator(".mod-settings .vertical-tab-content:visible .setting-item") + .filter({ + has: page.locator(".setting-item-name", { + hasText: new RegExp(`^${name}$`), + }), + }); } function scrollTop(device: Obsidian): Promise { return device.evaluate(() => { - const root = activeDocument.querySelector(".mdsync-settings"); + const root = [ + ...activeDocument.querySelectorAll( + ".mod-settings .vertical-tab-content", + ), + ].find((el) => el.checkVisibility()); if (!root) throw new Error("No settings container"); return root.scrollTop; }); diff --git a/tests/e2e/shares.ts b/tests/e2e/shares.ts index 9e4d029b..0736e29f 100644 --- a/tests/e2e/shares.ts +++ b/tests/e2e/shares.ts @@ -7,7 +7,7 @@ import { CLEAN, clickMenuItem, closeModals, - openSettings, + openSharedFolderSettings, press, read, sync, @@ -242,8 +242,7 @@ async function pauseHere( action: string, paused: number, ): Promise { - await openSettings(device, "Sync"); - await press(device, "Manage"); + await openSharedFolderSettings(device); await press(device, action); await device.waitFor( `${action} saved`, diff --git a/tests/e2e/sharing.ts b/tests/e2e/sharing.ts index b4cef6ca..38e86034 100644 --- a/tests/e2e/sharing.ts +++ b/tests/e2e/sharing.ts @@ -12,7 +12,7 @@ import { field, fill, loaded, - openSettings, + openSharedFolderSettings, press, read, sync, @@ -190,8 +190,7 @@ export async function closeFromSettings( device: Obsidian, action: string, ): Promise { - await openSettings(device, "Sync"); - await press(device, "Manage"); + await openSharedFolderSettings(device); await press(device, action); await confirm(device, action); await device.waitFor( From 762765edf565b387ba4b933596782c104f79549f Mon Sep 17 00:00:00 2001 From: kitbyte Date: Sun, 11 Oct 2026 15:13:02 +0300 Subject: [PATCH 08/11] feat: selectable logs, copy one entry or all --- packages/plugin/src/settings/logs-view.ts | 44 ++++++++++++++++--- .../src/styles/settings/tabs-and-logs.css | 7 +++ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/packages/plugin/src/settings/logs-view.ts b/packages/plugin/src/settings/logs-view.ts index c14f02b4..8e458f1f 100644 --- a/packages/plugin/src/settings/logs-view.ts +++ b/packages/plugin/src/settings/logs-view.ts @@ -1,4 +1,8 @@ -import { Setting, type SettingDefinitionPage } from "obsidian"; +import { + ExtraButtonComponent, + Setting, + type SettingDefinitionPage, +} from "obsidian"; import { ESyncLogLevel, ESyncLogOperation, @@ -6,6 +10,7 @@ import { } from "@/logs/store"; import type { PluginHost } from "@/plugin/host"; import { formatTimestamp } from "@/shared"; +import { copyText } from "@/ui/common"; import { imperativePage } from "./page"; const LOG_LEVEL_LABELS: Record = { @@ -51,11 +56,22 @@ function renderLogsView( "Use Compare with remote from the command palette to inspect the current diff.", }); - new Setting(parent) + const entries = plugin.logs.getEntries(); + const diagnostics = new Setting(parent) .setName("Diagnostics") .setDesc( "Stored locally inside the MDSync plugin folder and excluded from sync.", - ) + ); + if (entries.length > 0) { + diagnostics.addButton((button) => + button + .setButtonText("Copy logs") + .onClick( + () => void copyText("Logs", entries.map(logText).join("\n\n")), + ), + ); + } + diagnostics .addButton((button) => button.setButtonText("Refresh").onClick(() => { onRefresh(); @@ -71,7 +87,6 @@ function renderLogsView( }), ); - const entries = plugin.logs.getEntries(); if (entries.length === 0) { parent.createEl("p", { text: "No logs yet." }); return; @@ -88,9 +103,11 @@ function renderLogEntry(parent: HTMLElement, entry: SyncLogEntry): void { cls: `mdsync-log-entry ${LOG_LEVEL_CLASSES[entry.level]}`, }); const meta = item.createDiv({ cls: "mdsync-log-meta" }); - meta.setText( - `${formatTimestamp(entry.timestamp)} • ${OPERATION_LABELS[entry.operation]} • ${LOG_LEVEL_LABELS[entry.level]}`, - ); + meta.createSpan({ text: logMeta(entry) }); + new ExtraButtonComponent(meta) + .setIcon("copy") + .setTooltip("Copy entry") + .onClick(() => void copyText("Log entry", logText(entry))); item.createDiv({ cls: "mdsync-log-message", text: entry.message }); if (entry.details.length === 0) { return; @@ -102,3 +119,16 @@ function renderLogEntry(parent: HTMLElement, entry: SyncLogEntry): void { list.createEl("li", { text: detail }); } } + +function logMeta(entry: SyncLogEntry): string { + return `${formatTimestamp(entry.timestamp)} • ${OPERATION_LABELS[entry.operation]} • ${LOG_LEVEL_LABELS[entry.level]}`; +} + +/** What the entry shows, details expanded, as plain text. */ +function logText(entry: SyncLogEntry): string { + return [ + logMeta(entry), + entry.message, + ...entry.details.map((detail) => `- ${detail}`), + ].join("\n"); +} diff --git a/packages/plugin/src/styles/settings/tabs-and-logs.css b/packages/plugin/src/styles/settings/tabs-and-logs.css index 871b0f4a..d7254632 100644 --- a/packages/plugin/src/styles/settings/tabs-and-logs.css +++ b/packages/plugin/src/styles/settings/tabs-and-logs.css @@ -8,10 +8,13 @@ color: var(--text-on-accent); } +/* Obsidian makes the app unselectable; WebKit on iOS reads only the prefixed property. */ .mdsync-log-list { display: flex; flex-direction: column; gap: var(--size-4-2); + -webkit-user-select: text; + user-select: text; } .mdsync-log-entry { @@ -34,6 +37,10 @@ } .mdsync-log-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--size-4-2); color: var(--text-muted); font-size: var(--font-ui-smaller); margin-bottom: var(--size-4-1); From 374997980850c4456b388eec08f51706d11ee3b5 Mon Sep 17 00:00:00 2001 From: kitbyte Date: Sun, 11 Oct 2026 15:47:40 +0300 Subject: [PATCH 09/11] test: e2e pause through the dropdown, stopped links counted from the visible list --- tests/e2e/device.ts | 28 ++++++++++++++++++++++++++++ tests/e2e/links-obsidian.ts | 32 ++++++++++---------------------- tests/e2e/shares.ts | 18 +++++++----------- 3 files changed, 45 insertions(+), 33 deletions(-) diff --git a/tests/e2e/device.ts b/tests/e2e/device.ts index 0ffb0004..be334bfb 100644 --- a/tests/e2e/device.ts +++ b/tests/e2e/device.ts @@ -214,6 +214,34 @@ export function field(device: Obsidian, name: string): Promise { ); } +/** Picks `value` in the open modal's dropdown named `name`, once it shows up. */ +export async function choose( + device: Obsidian, + name: string, + value: string, +): Promise { + await poll(`"${name}" dropdown`, () => + device.evaluate( + ({ label, wanted }) => { + const select = [ + ...activeDocument.querySelectorAll(".modal .setting-item"), + ] + .reverse() + .find( + (row) => + row.querySelector(".setting-item-name")?.textContent === label, + ) + ?.querySelector("select"); + if (!select) throw new Error(`no "${label}" dropdown`); + select.value = wanted; + select.dispatchEvent(new Event("change")); + return true; + }, + { label: name, wanted: value }, + ), + ); +} + export async function openSettings( device: Obsidian, ...pages: string[] diff --git a/tests/e2e/links-obsidian.ts b/tests/e2e/links-obsidian.ts index 6edc02ae..d0c9176e 100644 --- a/tests/e2e/links-obsidian.ts +++ b/tests/e2e/links-obsidian.ts @@ -5,7 +5,15 @@ import { type Browser, chromium, type Page } from "playwright-core"; -import { clickMenuItem, confirm, field, fill, loaded, press } from "./device"; +import { + choose, + clickMenuItem, + confirm, + field, + fill, + loaded, + press, +} from "./device"; import { check, poll, runScenario } from "./harness"; import { admin, SECRET } from "./link-client"; import { launchObsidian, type Obsidian } from "./obsidian"; @@ -368,7 +376,7 @@ async function manage( check( "and leaves the list", await device.evaluate( - () => app.plugins.plugins.mdsync.settings.links.length, + () => app.plugins.plugins.mdsync.sharedLinks.all().length, ), 0, ); @@ -409,26 +417,6 @@ async function views(base: string, link: string): Promise { return ((await status.json()) as { views: number }).views; } -/** Picks `value` in the open modal's dropdown named `name`. */ -function choose(device: Obsidian, name: string, value: string): Promise { - return device.evaluate( - ({ label, wanted }) => { - const select = [ - ...activeDocument.querySelectorAll(".modal .setting-item"), - ] - .find( - (row) => - row.querySelector(".setting-item-name")?.textContent === label, - ) - ?.querySelector("select"); - if (!select) throw new Error(`no "${label}" dropdown`); - select.value = wanted; - select.dispatchEvent(new Event("change")); - }, - { label: name, wanted: value }, - ); -} - /** Answers the prompt modal, whose field is not a setting. */ async function promptFor(device: Obsidian, text: string): Promise { await poll("the prompt", () => diff --git a/tests/e2e/shares.ts b/tests/e2e/shares.ts index 0736e29f..0bac6284 100644 --- a/tests/e2e/shares.ts +++ b/tests/e2e/shares.ts @@ -5,10 +5,10 @@ import { CLEAN, + choose, clickMenuItem, closeModals, openSharedFolderSettings, - press, read, sync, unlock, @@ -135,7 +135,7 @@ async function scenario( [true, null], ); - await pauseHere(desktop, "Pause on this device", 1); + await pauseHere(desktop, true); await write(laptop, PLAN, "plan v4\n"); check("the laptop pushes to the paused desktop", await sync(laptop), CLEAN); const todo = await vaultEntry(desktop, TODO); @@ -150,7 +150,7 @@ async function scenario( await vaultEntry(desktop, TODO), todo, ); - await pauseHere(desktop, "Resume on this device", 0); + await pauseHere(desktop, false); check("the desktop resumes", await sync(desktop), CLEAN); check("the laptop pulls the paused edit", await sync(laptop), CLEAN); check( @@ -237,17 +237,13 @@ function vaultEntry(device: Obsidian, path: string): Promise { } /** Pause or resume the only share on this device, from its window in the settings. */ -async function pauseHere( - device: Obsidian, - action: string, - paused: number, -): Promise { +async function pauseHere(device: Obsidian, paused: boolean): Promise { await openSharedFolderSettings(device); - await press(device, action); + await choose(device, "Pause", paused ? "here" : ""); await device.waitFor( - `${action} saved`, + `pause ${paused} saved`, () => app.plugins.plugins.mdsync.settings.pausedSpaces.length, - (count: number) => count === paused, + (count: number) => count === Number(paused), ); await closeModals(device); } From f088545ce6751367181a5e4ec93e6ef23fbfe98c Mon Sep 17 00:00:00 2001 From: kitbyte Date: Sun, 11 Oct 2026 15:47:47 +0300 Subject: [PATCH 10/11] fix: notice action button spans the notice, with a top margin --- packages/plugin/src/styles/base/notice.css | 7 +++++++ packages/plugin/src/styles/index.css | 1 + packages/plugin/src/ui/common/notices.ts | 4 +++- 3 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 packages/plugin/src/styles/base/notice.css diff --git a/packages/plugin/src/styles/base/notice.css b/packages/plugin/src/styles/base/notice.css new file mode 100644 index 00000000..b44c92a1 --- /dev/null +++ b/packages/plugin/src/styles/base/notice.css @@ -0,0 +1,7 @@ +.mdsync-notice-action { + margin-top: var(--size-4-2); +} + +.mdsync-notice-action button { + width: 100%; +} diff --git a/packages/plugin/src/styles/index.css b/packages/plugin/src/styles/index.css index 1c61e9a5..9dfd3ea3 100644 --- a/packages/plugin/src/styles/index.css +++ b/packages/plugin/src/styles/index.css @@ -16,6 +16,7 @@ @import "./diff/view.css"; @import "./base/icon-button.css"; +@import "./base/notice.css"; @import "./diff/tones.css"; @import "./diff/toolbars.css"; @import "./diff/pane-grid.css"; diff --git a/packages/plugin/src/ui/common/notices.ts b/packages/plugin/src/ui/common/notices.ts index 47ba4822..23ca4529 100644 --- a/packages/plugin/src/ui/common/notices.ts +++ b/packages/plugin/src/ui/common/notices.ts @@ -22,7 +22,9 @@ export function notifyAction( action: () => void, ): void { const notice = new Notice(`MDSync: ${message}`, NOTICE_DURATION_MS); - new ButtonComponent(notice.messageEl.createDiv()) + new ButtonComponent( + notice.messageEl.createDiv({ cls: "mdsync-notice-action" }), + ) .setButtonText(label) .setCta() .onClick(() => { From 5e42d435b24f5180cf13c480695c1a70d9bb8e06 Mon Sep 17 00:00:00 2001 From: kitbyte Date: Sun, 11 Oct 2026 12:14:58 +0300 Subject: [PATCH 11/11] chore: release 1.2.0, bump script writes formatted JSON and the plugin version --- .claude/releasing.md | 2 +- CHANGELOG.md | 17 +++++++++++++++++ manifest.json | 2 +- package.json | 2 +- packages/plugin/package.json | 2 +- version-bump.mjs | 17 +++++++++++++---- versions.json | 3 ++- 7 files changed, 36 insertions(+), 9 deletions(-) diff --git a/.claude/releasing.md b/.claude/releasing.md index bf04eeb0..0d64396e 100644 --- a/.claude/releasing.md +++ b/.claude/releasing.md @@ -10,7 +10,7 @@ ## Process - Add a `## [x.y.z] - YYYY-MM-DD` section at the top of `CHANGELOG.md`: plain sentences for users under `### Important`, `### Features`, `### Improvements` or `### Fixes`, never commit titles. It is the source of truth for release notes. -- Bump `version` (`pnpm version x.y.z`): `version-bump.mjs` refuses a version without a changelog section, then updates `manifest.json` and `versions.json` (plugin version → minimum app version). +- Bump `version` (`pnpm version x.y.z`): `version-bump.mjs` refuses a version without a changelog section, then updates `manifest.json`, `packages/plugin/package.json` and `versions.json` (plugin version → minimum app version). - Push a tag equal to `manifest.json`'s `version` - no leading `v`. The Release workflow checks that tag, manifest and the newest changelog section agree, builds, attests the assets, and creates the GitHub release with that section as its notes. CI runs the same check (`node tools/changelog.mjs check`) on every push. - The release carries `manifest.json`, `main.js`, and `styles.css` as individual assets. `main.js` and `styles.css` are both build outputs of `pnpm build`. Release artifacts live at the top level of the plugin folder in the vault (`/.obsidian/plugins//`). - Plugins are submitted at community.obsidian.md (sign in, connect GitHub, Plugins → New plugin); its scan checks the manifest, the release assets, the source and that the build matches it. Fix findings with a new, higher version. diff --git a/CHANGELOG.md b/CHANGELOG.md index ce7a58c9..c9815170 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,23 @@ This file is the source of truth for release notes: the section of a version becomes the notes of its GitHub release. The newest section must match `version` in `manifest.json`. Headings are `## [x.y.z] - YYYY-MM-DD`, then `### Important`, `### Features`, `### Improvements` and `### Fixes` as needed. Write for users, in plain sentences, not commit titles. +## [1.2.0] - 2026-10-11 + +### Important + +- Storage credentials, the relay secret, the Cloudflare token, shared folder keys, share links and the cached passphrase are encrypted with a key kept in Obsidian's keychain, outside the vault. Existing settings move over at the first launch. A copy of the vault folder (a backup, git, a cloud folder) no longer carries them: on a copied or moved vault, import a setup link from another device. + +### Features + +- **Setup wizard.** It opens on first install, or with **MDSync: Set up sync**, and walks through storage, passphrase, the relay and the first sync. With a Cloudflare API token it creates an R2 bucket and deploys the relay to your account from inside Obsidian. Another device joins with a setup link, and a shared folder invite can be accepted from the same window. +- **Share links on all your devices.** Links you create appear on your other devices, and any of them can copy, update or stop one. They travel through your storage, encrypted with the vault key. + +### Improvements + +- MDSync's settings show up in Obsidian's settings search. They are split into Connection, Sync, Interface, Maintenance and Logs pages, and your shared folders are listed on their own page. +- Log entries can be selected, and copied one by one or all at once. +- The relay test refuses a plain `http://` URL, since the relay secret is sent with every request. + ## [1.1.0] - 2026-10-08 ### Features diff --git a/manifest.json b/manifest.json index ef1f2a62..23e19528 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "mdsync", "name": "MDSync", - "version": "1.1.0", + "version": "1.2.0", "minAppVersion": "1.13.0", "description": "Sync your vault end-to-end encrypted through your own S3, WebDAV or Google Drive storage, with conflict resolution, file history, shared folders and live editing.", "author": "kitbyte", diff --git a/package.json b/package.json index 335b1df0..f873ab10 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mdsync-monorepo", - "version": "1.1.0", + "version": "1.2.0", "description": "Monorepo for the MDSync Obsidian plugin and its companion services.", "private": true, "type": "module", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 931dfd96..d78f48bb 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "mdsync", - "version": "1.1.0", + "version": "1.2.0", "description": "Encrypted vault sync for Obsidian over your own storage.", "main": "main.js", "type": "module", diff --git a/version-bump.mjs b/version-bump.mjs index 570b5578..a1a66bb6 100644 --- a/version-bump.mjs +++ b/version-bump.mjs @@ -12,13 +12,22 @@ if (!notesFor(targetVersion)) { ); } -const manifest = JSON.parse(readFileSync("manifest.json", "utf8")); +const readJson = (path) => JSON.parse(readFileSync(path, "utf8")); +// Biome's formatter wants the trailing newline. +const writeJson = (path, data) => + writeFileSync(path, `${JSON.stringify(data, null, "\t")}\n`); + +const manifest = readJson("manifest.json"); const { minAppVersion } = manifest; manifest.version = targetVersion; -writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); +writeJson("manifest.json", manifest); + +const plugin = readJson("packages/plugin/package.json"); +plugin.version = targetVersion; +writeJson("packages/plugin/package.json", plugin); -const versions = JSON.parse(readFileSync("versions.json", "utf8")); +const versions = readJson("versions.json"); if (versions[targetVersion] !== minAppVersion) { versions[targetVersion] = minAppVersion; - writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); + writeJson("versions.json", versions); } diff --git a/versions.json b/versions.json index 9602ead7..83d1454c 100644 --- a/versions.json +++ b/versions.json @@ -1,4 +1,5 @@ { "1.0.0": "1.13.0", - "1.1.0": "1.13.0" + "1.1.0": "1.13.0", + "1.2.0": "1.13.0" }

+ Obsidian community plugin Release License