diff --git a/deno.json b/deno.json index 49f07b3..8aea41f 100644 --- a/deno.json +++ b/deno.json @@ -16,7 +16,7 @@ "build:icons": "deno run -A tools/make-icons.ts", "dev": "deno task build:ui && deno run -A --watch main.ts", "relay": "deno run -A relay/main.ts", - "test:sync": "deno run -A relay/test.ts && deno run -A src/sync/mls.test.ts && deno run -A src/sync/mls-transport.test.ts && deno run -A src/sync/atproto.test.ts && deno run -A src/sync/e2e.test.ts", + "test:sync": "deno run -A relay/test.ts && deno run -A src/sync/mls.test.ts && deno run -A src/sync/mls-transport.test.ts && deno run -A src/sync/atproto.test.ts && deno run -A src/sync/e2e.test.ts && deno run -A src/sync/newcomer.test.ts", "desktop": "deno task build:ui && deno desktop --hmr -A --include static --include vendor --include tiles --include build main.ts", "build": "deno task build:ui && deno desktop -A --include static --include vendor --include tiles --include build --icon build/icon-1024.png --output dist/Ziran.app main.ts && deno run -A tools/mac-bundle.ts dist/Ziran.app", "build:mac": "deno task build:ui && deno desktop -A --include static --include vendor --include tiles --include build --icon build/icon-1024.png --target aarch64-apple-darwin --output dist/Ziran-arm64.app main.ts && deno run -A tools/mac-bundle.ts dist/Ziran-arm64.app", diff --git a/deploy/relay/Dockerfile b/deploy/relay/Dockerfile index af0751e..cddc05b 100644 --- a/deploy/relay/Dockerfile +++ b/deploy/relay/Dockerfile @@ -7,9 +7,9 @@ COPY relay-linux-x64 /usr/local/bin/ziran-relay RUN chmod +x /usr/local/bin/ziran-relay USER ziran ENV ZIRAN_RELAY_HOST=0.0.0.0 \ - ZIRAN_RELAY_PORT=4190 \ + ZIRAN_RELAY_PORT=4191 \ ZIRAN_RELAY_DATA=/data \ ZIRAN_RELAY_DID=did:web:relay.ziran.space \ ZIRAN_RELAY_DEV_AUTH=off -EXPOSE 4190 +EXPOSE 4191 CMD ["ziran-relay"] diff --git a/deploy/relay/service.json b/deploy/relay/service.json index d93d516..b1f6258 100644 --- a/deploy/relay/service.json +++ b/deploy/relay/service.json @@ -6,6 +6,6 @@ "prod": ["relay.ziran.space"] }, "headers": true, - "upstreamPort": 4190, + "upstreamPort": 4191, "data": true } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f69e782..8b246a6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -107,6 +107,17 @@ session.json; restores pass bounds as constructor options and the page re-applies them via moveTo/resizeTo. The base window centres itself when no stored bounds exist. +**Window navigation (a sharp edge):** `deno desktop` loads the served root +itself, before anything of ours runs, and that load carries no API key; +`BrowserWindowOptions` has no `url` field and there is no ready/load event, +so a `navigate()` issued straight after `new BrowserWindow()` silently +no-ops. `navigateUntilArrived()` (src/server/windows.ts) therefore retries on +a backoff until the window's WebSocket connects (`store.hasArrived`). A +keyless `GET /` serves a splash rather than booting a UI that would 401 on +everything; a *wrong* key still gets the real UI so it can say the window is +out of date. The API key itself persists at `/api-key` (0600) so a +window that outlives a restart keeps working. + ## OS integration App + document icons are generated by `tools/make-icons.ts` (headless-Chrome @@ -185,7 +196,7 @@ Full design in `docs/SYNC-DESIGN.md`; research in `docs/SYNC-OPTIONS.md`. the `/i/` share-link landing page. Enforces only write-permission, membership/caps, and per-role presence visibility from the document's declared roles; payloads are opaque MLS ciphertext. `deno task relay`, or - the app embeds one on :4190 when `ZIRAN_RELAY` is unset (a second local + the app embeds one on :4191 when `ZIRAN_RELAY` is unset (a second local instance joins the first's — same-machine collaboration works out of the box). - **Crypto** (`src/sync/mls.ts`): ts-mls behind a narrow façade; MLS runs in diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 51a7c8f..e3f49ca 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -51,8 +51,7 @@ instructions per platform. ```sh deno task build:relay # cross-compiles → deploy/relay/relay-linux-x64 -cd deploy/relay -sm deploy +(cd deploy/relay; sm deploy) ``` - **No secrets anywhere.** Production auth is atproto **service-auth**: each diff --git a/docs/SYNC-DESIGN.md b/docs/SYNC-DESIGN.md index 0109ea0..afd4913 100644 --- a/docs/SYNC-DESIGN.md +++ b/docs/SYNC-DESIGN.md @@ -104,6 +104,26 @@ interface SyncTransport { MLS (phase 2) sits between: server ACL governs *write*, cryptographic group membership governs *read* (relay can't decrypt). +### Roles are the document's vocabulary + +A tile declares its own roles in the manifest, with optional human `label`s +(TicTacToe: `x`/`o` both labelled "Player", plus `watcher`). The runtime +chrome offers exactly those — never a list of its own — minus the owner's +seat and any role at capacity. Terse role *names* stay the document's +business; labels are what people read. + +### Inviting someone who has never used Ziran + +MLS needs the invitee's published KeyPackage to build a Welcome, so a +stranger cannot be added at invite time. Instead the invitation is stored +**pending**: real, waiting, with no Welcome yet. When that person installs +Ziran and signs in, their runtime publishes a KeyPackage; the relay then +tells the inviter (immediately if online, on their next connect otherwise) +that the invitation is completable. The inviter's runtime performs the +add-commit, hands over the Welcome, and re-uploads a snapshot for the new +epoch; the relay delivers the now-complete invitation. From the newcomer's +side it simply turns from "Preparing your copy…" into an Accept button. + ### Invitations Relay-only delivery, three surfaces: diff --git a/main.ts b/main.ts index bd7bc69..dc4f223 100644 --- a/main.ts +++ b/main.ts @@ -11,7 +11,7 @@ import { ZiranStore } from './src/server/store.ts'; import { SyncManager } from './src/server/sync.ts'; import { startTileServer } from './src/server/tileserver.ts'; import { hasDesktopWindows, openDocWindow, setupMainWindow } from './src/server/windows.ts'; -import { appDataDir } from './src/server/paths.ts'; +import { apiKeyFile, appDataDir } from './src/server/paths.ts'; const store = new ZiranStore(); await store.load(); @@ -25,12 +25,26 @@ store.onDocMoved = (oldId, newId, newPath) => sync.moved(oldId, newId, newPath); const { AtSession } = await import('./src/server/atsession.ts'); const at = new AtSession(); -// Launch-time bearer token: 127.0.0.1 is reachable by every local process, -// so the API only answers requests carrying this key. It reaches the UI -// exclusively through the URLs we navigate our own windows to. -// ZIRAN_KEY overrides for tests. -store.apiKey = Deno.env.get('ZIRAN_KEY') ?? - [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, '0')).join(''); +// Bearer token: 127.0.0.1 is reachable by every local process, so the API +// only answers requests carrying this key. It reaches the UI exclusively +// through the URLs we navigate our own windows to. +// +// Persisted (0600, beside the documents) rather than minted per launch: a +// window that outlives a restart — a reload, an HMR cycle, a relaunch that +// restores windows — keeps a URL with the old key, and a rotating key made +// every one of its API calls 401. ZIRAN_KEY overrides for tests. +store.apiKey = Deno.env.get('ZIRAN_KEY') ?? await (async () => { + const file = apiKeyFile(); + try { + const saved = (await Deno.readTextFile(file)).trim(); + if (/^[0-9a-f]{64}$/.test(saved)) return saved; + } catch { + // first run + } + const key = [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, '0')).join(''); + await Deno.writeTextFile(file, key, { mode: 0o600 }); + return key; +})(); const handler = createHandler(store, sync, at); @@ -64,20 +78,31 @@ console.log(`tile server on http://load.localhost:${store.tileServerPort}/`); const relayUrl = await (async () => { const configured = Deno.env.get('ZIRAN_RELAY'); if (configured) return configured; - const port = 4190; + const port = 4191; + const url = `ws://127.0.0.1:${port}/ws`; try { - const { startRelay } = await import('./relay/main.ts'); - const { DevVerifier } = await import('./relay/auth.ts'); + const { startRelay, standardVerifier } = await import('./relay/main.ts'); await startRelay({ port, dataDir: join(appDataDir(), 'relay'), - verifier: new DevVerifier(Deno.env.get('ZIRAN_RELAY_SECRET') ?? 'dev-secret'), + // The same door the hosted relay uses: atproto service-auth JWTs for + // signed-in people, dev HMAC tokens otherwise. A DevVerifier alone + // would refuse every signed-in client. + verifier: standardVerifier(), }); - console.log(`embedded relay on ws://127.0.0.1:${port}/ws`); + console.log(`embedded relay on ${url}`); } catch { - console.log(`joining existing local relay on ws://127.0.0.1:${port}/ws`); + // Port busy: usually a second Ziran (which is how same-machine + // collaboration works). Confirm it really is a relay before saying so. + const relay = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(1500) }) + .then((r) => r.ok, () => false); + console.log( + relay + ? `joining the relay already running on ${url}` + : `port ${port} is taken by something that is not a Ziran relay — live sessions are paused`, + ); } - return `ws://127.0.0.1:${port}/ws`; + return url; })(); // AT sign-in restores any previous session before sync connects, so the // relay handshake uses the real identity when there is one. @@ -93,6 +118,7 @@ setupMainWindow({ if (winId !== 'main') store.closeWindow(winId); }, onBounds: (winId, b) => store.updateBounds(winId === 'main' ? 'base' : winId, b), + hasArrived: (winId) => store.hasArrived(winId), onOpenTile: async () => { const { pickOpen } = await import('./src/server/dialogs.ts'); const picked = await pickOpen(); diff --git a/relay/main.ts b/relay/main.ts index 89dc8bf..ddd9423 100644 --- a/relay/main.ts +++ b/relay/main.ts @@ -4,7 +4,7 @@ // write-permission per role and membership. Payloads are opaque (plaintext in // phase 1; MLS ciphertext in phase 2 changes nothing here). // -// deno run -A relay/main.ts # binds 127.0.0.1:4190 +// deno run -A relay/main.ts # binds 127.0.0.1:4191 // ZIRAN_RELAY_SECRET / ZIRAN_RELAY_DATA / ZIRAN_RELAY_PORT to configure. import { canPresence, canWrite, capReached, isAdmin, visibleRoster } from './acl.ts'; @@ -140,6 +140,7 @@ export async function startRelay(opts: { role: f.role, createdAt: Date.now(), payload: f.payload, // opaque MLS Welcome under E2EE; relay never reads it + pending: f.pending === true, }; await store.putInvite(inv); send(c.ws, { t: 'invited', id: f.id, invitation: inv }); @@ -149,6 +150,26 @@ export async function startRelay(opts: { } }; + /** Tell an inviter that a pending invitation can now be completed. */ + const nudgeInviter = (inv: Invitation) => { + for (const ic of byDid.get(inv.inviterDid) ?? []) { + send(ic.ws, { t: 'invite-ready', token: inv.token, docId: inv.docId, inviteeDid: inv.inviteeDid }); + } + }; + + const handleComplete = async (c: Conn, f: Extract) => { + const inv = store.invite(f.token); + if (!inv) return errFrame(c.ws, 'no-invite', 'invitation not found', f.id); + // Only the person who issued it may supply the Welcome. + if (inv.inviterDid !== c.did) return errFrame(c.ws, 'forbidden', 'not your invitation', f.id); + const done = await store.completeInvite(f.token, f.payload); + send(c.ws, { t: 'ack', id: f.id }); + if (!done) return; + for (const ic of byDid.get(done.inviteeDid) ?? []) { + if (ic.wantsInbox) send(ic.ws, { t: 'inbox', invitation: done }); + } + }; + const handleAccept = async (c: Conn, f: Extract) => { const inv = store.invite(f.token); if (!inv) return errFrame(c.ws, 'no-invite', 'invitation not found', f.id); @@ -188,11 +209,21 @@ export async function startRelay(opts: { case 'sub-inbox': { c.wantsInbox = true; for (const inv of store.inboxFor(c.did)) send(c.ws, { t: 'inbox', invitation: inv }); + // Invitations this person issued that are now completable — the + // invitee showed up while they were away. + for (const inv of store.completableBy(c.did)) { + send(c.ws, { t: 'invite-ready', token: inv.token, docId: inv.docId, inviteeDid: inv.inviteeDid }); + } return; } case 'pub-kp': await store.putKeyPackage(c.did, f.b64); + // Someone who was invited before they had keys just published some: + // wake their inviters so the invitations can be completed. + for (const inv of store.pendingFor(c.did)) nudgeInviter(inv); return; + case 'complete-invite': + return handleComplete(c, f); case 'get-kp': send(c.ws, { t: 'kp', id: f.id, b64: store.keyPackage(f.did) ?? null }); return; @@ -289,7 +320,7 @@ export async function startRelay(opts: { // Loopback by default (dev); containers set ZIRAN_RELAY_HOST=0.0.0.0 — the // front proxy is the only thing exposed to the outside either way. const hostname = (opts.hostname as string | undefined) ?? Deno.env.get('ZIRAN_RELAY_HOST') ?? '127.0.0.1'; - const server = Deno.serve({ hostname, port: opts.port ?? 4190, onListen: () => {} }, handler); + const server = Deno.serve({ hostname, port: opts.port ?? 4191, onListen: () => {} }, handler); return { port: (server.addr as Deno.NetAddr).port, shutdown: () => server.shutdown(), @@ -309,7 +340,7 @@ export function standardVerifier(): Verifier { if (import.meta.main) { const relay = await startRelay({ - port: Number(Deno.env.get('ZIRAN_RELAY_PORT') ?? 4190), + port: Number(Deno.env.get('ZIRAN_RELAY_PORT') ?? 4191), dataDir: Deno.env.get('ZIRAN_RELAY_DATA') ?? './relay-data', verifier: standardVerifier(), }); diff --git a/relay/store.ts b/relay/store.ts index 899c74e..4d872b4 100644 --- a/relay/store.ts +++ b/relay/store.ts @@ -151,6 +151,31 @@ export class RelayStore { return this.#invites.get(token); } + /** Attach the Welcome to a pending invitation and re-deliver it. */ + async completeInvite(token: string, payload: string): Promise { + const inv = this.#invites.get(token); + if (!inv) return undefined; + inv.payload = payload; + inv.pending = false; + await Deno.writeTextFile(join(this.#root, 'invites', `${token}.json`), JSON.stringify(inv)); + const box = this.#inbox.get(inv.inviteeDid) ?? []; + const at = box.findIndex((i) => i.token === token); + if (at >= 0) box[at] = inv; + return inv; + } + + /** Pending invitations this DID issued whose invitee can now be added. */ + completableBy(inviterDid: string): Invitation[] { + return [...this.#invites.values()].filter((i) => + i.pending && i.inviterDid === inviterDid && this.#keyPackages.has(i.inviteeDid) + ); + } + + /** Pending invitations addressed to this DID (they just showed up). */ + pendingFor(inviteeDid: string): Invitation[] { + return [...this.#invites.values()].filter((i) => i.pending && i.inviteeDid === inviteeDid); + } + /** KeyPackage directory: latest published package per DID. Stands in for the per-user PDS record until AT sign-in lands; reuse is tolerated by the MLS layer, so last-write-wins is correct enough here. */ diff --git a/src/components/zn-base-window.ts b/src/components/zn-base-window.ts index 448d8e8..c72d752 100644 --- a/src/components/zn-base-window.ts +++ b/src/components/zn-base-window.ts @@ -1,7 +1,7 @@ import { css, html, LitElement, nothing } from 'lit'; import { baseStyles } from '../styles/shared.ts'; import { desk } from '../state/desk.ts'; -import { modelIconUrl, recentIconUrl, relativeTime } from '../state/backend.ts'; +import { keyed, modelIconUrl, recentIconUrl, relativeTime } from '../state/backend.ts'; import type { ModelEntry, RecentEntry } from '../types.ts'; import { icons, zmark } from './icons.ts'; @@ -145,6 +145,21 @@ export class ZnBaseWindow extends LitElement { outline: 2px solid var(--signal-strong); outline-offset: 2px; } + .retry { + font: inherit; + font-size: var(--text-sm); + font-weight: var(--w-medium); + border: 1px solid var(--ink); + background: var(--ink); + color: var(--surface); + border-radius: var(--r-full); + padding: 5px 16px; + cursor: pointer; + } + .retry:focus-visible { + outline: 2px solid var(--signal); + outline-offset: 2px; + } .id-signin { margin-left: 0; } @@ -171,11 +186,27 @@ export class ZnBaseWindow extends LitElement { outline: 2px solid var(--signal-strong); outline-offset: 2px; } - .id-dot { - width: 7px; - height: 7px; + .id-avatar { border-radius: 50%; + object-fit: cover; + flex: none; + display: block; + background: oklch(0.4 0.02 352); + } + .id-avatar.fallback { + display: grid; + place-items: center; background: var(--signal); + color: #fff; + font-size: 0.72rem; + font-weight: var(--w-semibold); + line-height: 1; + } + .id-name { + max-width: 12rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .id-pop { top: 60px; @@ -183,10 +214,25 @@ export class ZnBaseWindow extends LitElement { left: auto; max-width: 320px; } - .id-did { - margin: var(--sp-1) var(--sp-2); - font-family: var(--font-mono); - font-size: 0.72rem; + .id-head { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-2); + min-width: 0; + } + .id-head-text { + display: grid; + min-width: 0; + } + .id-head-name { + font-weight: var(--w-semibold); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .id-head-handle { + font-size: var(--text-sm); color: var(--ink-muted); overflow: hidden; text-overflow: ellipsis; @@ -269,6 +315,12 @@ export class ZnBaseWindow extends LitElement { font-size: var(--text-sm); color: var(--ink); } + .invite-waiting { + font-size: var(--text-sm); + color: var(--ink-muted); + white-space: nowrap; + padding: 3px 4px; + } .invite-accept, .invite-dismiss { font: inherit; @@ -571,6 +623,8 @@ export class ZnBaseWindow extends LitElement { const enterDoc = q ? recents[0] : undefined; const enterModel = q && !enterDoc ? models[0] : undefined; const damagedCount = desk.recents.filter((r) => r.damaged).length; + const sharedCount = Object.keys(desk.sync?.docs ?? {}).length; + if (desk.backendError) return this.renderBackendError(); return html`

Your documents live here.

- Ziran documents are tiles: they carry their own editor, live on this - machine, and never need a cloud. Start from a model to make your first - one — it will appear here. You can also drop a .tile file anywhere on - this window. + Ziran documents are tiles: they carry their own editor and live on this + machine, with no cloud and no account. Invite someone and a document + syncs end-to-end encrypted, everyone keeping their own copy. Start from + a model to make your first one, or drop a .tile file anywhere on this + window.

` @@ -622,11 +677,13 @@ export class ZnBaseWindow extends LitElement {

Models

-

Editors without their data — any document can become one.

+

Pick one to start a new document. A model carries the editor, not the content.

${models.length ? models.map((m) => this.renderModel(m, m === enterModel)) - : html`

No model matches “${this.query}”.

`} + : q + ? html`

No model matches “${this.query}”.

` + : html`

No models in your library yet.

`}
@@ -637,8 +694,10 @@ export class ZnBaseWindow extends LitElement { ${desk.recents.length ? damagedCount ? `${damagedCount} of ${desk.recents.length} tiles failed verification` - : 'All documents verified and saved on this machine' - : 'Everything you make here stays on this machine'} + : sharedCount + ? `All documents verified · ${sharedCount} shared live` + : 'All documents verified · nothing leaves this machine until you share it' + : 'Documents you make live here; sharing one is always your choice'} ${desk.recents.length} ${desk.recents.length === 1 ? 'tile' : 'tiles'} @@ -647,22 +706,61 @@ export class ZnBaseWindow extends LitElement { `; } + /** When the backend refuses or disappears, say so. Showing the ordinary + empty desk here would claim the library is empty when we simply never + got an answer — the failure that made a stale key look like five + unrelated bugs. */ + private renderBackendError() { + const stale = desk.backendError === 'unauthorized'; + return html` +
+
+ ${zmark} + ziran +
+
+
+
+

${stale ? 'This window is out of date.' : 'Ziran can’t reach its backend.'}

+

+ ${stale + ? 'It was opened by an earlier run of Ziran and no longer has access. Reopening Ziran will restore your documents — none of them are affected.' + : `Your documents are safe on this machine; this window just can’t read them right now (${desk.backendError}).`} +

+

+
+
+
+
+ `; + } + /** The identity affordance: your AT handle when signed in, a quiet way in when not. Collaboration works either way — signing in makes you reachable by handle across machines. */ private renderIdentity() { const at = desk.sync?.at; if (at?.signedIn) { + const name = at.displayName || at.handle || 'You'; + const handle = at.handle ? `@${at.handle}` : undefined; return html` - `; @@ -690,6 +788,16 @@ export class ZnBaseWindow extends LitElement { `; } + /** The person's avatar, or their initial when they haven't set one. */ + private renderAvatar(at: { hasAvatar?: boolean }, name: string, size = 22) { + const style = `width:${size}px;height:${size}px`; + return at.hasAvatar + ? html`` + : html``; + } + private idAction(fn: () => void) { this.renderRoot.querySelector('.id-pop')?.hidePopover(); fn(); @@ -709,7 +817,9 @@ export class ZnBaseWindow extends LitElement { ${inv.inviterHandle ?? inv.inviterDid} invited you to “${inv.title}” as ${inv.role} - + ${inv.pending + ? html`Preparing your copy…` + : html``} `)} diff --git a/src/components/zn-connections.ts b/src/components/zn-connections.ts index 34a295b..e99d068 100644 --- a/src/components/zn-connections.ts +++ b/src/components/zn-connections.ts @@ -318,34 +318,46 @@ export class ZnConnections extends LitElement {

`; } - const roles = ['editor', 'watcher']; + // The document's own roles — not a list this panel invents. + const label = (name: string) => + s.roles?.find((r) => r.name === name)?.label ?? + name.charAt(0).toUpperCase() + name.slice(1); + const open = (s.roles ?? []).filter((r) => r.invitable && !r.full); return html`
    ${s.members.map((m) => html`
  • ${m.handle ?? m.did} - ${m.did === sync.identity.did ? `${m.role} · you` : m.role} + + ${m.did === sync.identity.did ? `${label(m.role)} · you` : label(m.role)} +
  • `)}
-
- - - -
-

- They get the invitation inside Ziran and as a link you can pass along. Accepting - downloads the document to their machine first, then joins them live. -

+ ${open.length + ? html` +
+ + + +
+

+ They get the invitation inside Ziran and as a link you can pass along. Accepting + downloads the document to their machine first, then joins them live. +

+ ` + : html`

+ Every seat this document offers is taken, so there is no one left to invite. +

`} `; } diff --git a/src/server/api.ts b/src/server/api.ts index 621f565..b28c978 100644 --- a/src/server/api.ts +++ b/src/server/api.ts @@ -30,6 +30,33 @@ async function serveStatic(pathname: string): Promise { return undefined; } +/** Shown for a keyless load of the app root. Deno Desktop performs its own + * initial load of the served address before our navigate() can land, and + * that load carries no key: booting the real UI there would only produce a + * window that 401s on everything. This holds the place until the keyed + * navigate arrives (see navigateUntilArrived in windows.ts). */ +const splash = () => + new Response( + `Ziran + + +

Starting Ziran…

+

If this stays put, open Ziran from the app — or, when running it yourself, + use the address printed in the terminal.

`, + { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } }, + ); + const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } }); const err = (message: string, status = 400) => json({ error: message }, status); @@ -301,6 +328,13 @@ export function createHandler(store: ZiranStore, sync?: SyncManager, at?: AtSess if (typeof identifier !== 'string' || !identifier.trim()) return err('your handle is required'); return json({ url: await at.signIn(identifier) }); } + if (pathname === '/api/at/avatar') { + const avatar = at?.avatar; + if (!avatar) return err('no avatar', 404); + return new Response(avatar.bytes as BodyInit, { + headers: { 'content-type': avatar.type, 'cache-control': 'no-cache' }, + }); + } if (pathname === '/api/at/signout' && req.method === 'POST') { if (!at) return err('sign-in unavailable', 503); await at.signOut(); @@ -341,6 +375,12 @@ export function createHandler(store: ZiranStore, sync?: SyncManager, at?: AtSess return err((e as Error).message, 500); } + // A bare load of the app root (no key at all) is the runtime's own + // pre-navigate load: hold it with the splash rather than booting a UI + // that cannot talk to the backend. A *wrong* key still gets the real UI, + // which says plainly that the window is out of date. + if (pathname === '/' && !url.searchParams.has('key')) return splash(); + return (await serveStatic(pathname)) ?? new Response('not found', { status: 404 }); }; } diff --git a/src/server/atsession.ts b/src/server/atsession.ts index 5bce980..932881b 100644 --- a/src/server/atsession.ts +++ b/src/server/atsession.ts @@ -71,6 +71,9 @@ function openInBrowser(url: string): void { export class AtSession { did: string | null = null; handle: string | null = null; + displayName: string | null = null; + /** Cached avatar bytes, served same-origin from /api/at/avatar. */ + avatar: { bytes: Uint8Array; type: string } | null = null; #client?: NodeOAuthClient; #session?: OAuthSession; @@ -125,11 +128,53 @@ export class AtSession { this.#session = await this.#client!.restore(did); this.#agent = new Agent(this.#session); this.did = did; + let pds: string | null = null; try { - this.handle = (await resolveIdentity(did)).handle; + const identity = await resolveIdentity(did); + this.handle = identity.handle; + pds = identity.pds; } catch { this.handle = null; } + await this.#loadProfile(did, pds).catch(() => {}); + } + + /** Read the profile record straight from the person's own repo, so this + works on any PDS rather than depending on the Bluesky AppView. The + avatar is a blob ref; fetch it once and keep the bytes. */ + async #loadProfile(did: string, pds: string | null): Promise { + this.displayName = null; + this.avatar = null; + if (!pds) return; + const base = pds.replace(/\/+$/, ''); + const res = await fetch( + `${base}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}` + + `&collection=app.bsky.actor.profile&rkey=self`, + ); + if (!res.ok) { + await res.body?.cancel(); + return; + } + const value = (await res.json())?.value ?? {}; + if (typeof value.displayName === 'string' && value.displayName.trim()) { + this.displayName = value.displayName.trim(); + } + const cid = value.avatar?.ref?.$link ?? value.avatar?.ref?.toString?.(); + if (typeof cid !== 'string' || !cid) return; + const blob = await fetch( + `${base}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`, + ); + if (!blob.ok) { + await blob.body?.cancel(); + return; + } + const bytes = new Uint8Array(await blob.arrayBuffer()); + // Cap it: an avatar is small, and this sits in memory. + if (bytes.length > 4 * 1024 * 1024) return; + this.avatar = { + bytes, + type: blob.headers.get('content-type') ?? value.avatar?.mimeType ?? 'image/jpeg', + }; } /** Begin sign-in: returns the authorization URL (also opened in the @@ -154,6 +199,8 @@ export class AtSession { const did = this.did; this.did = null; this.handle = null; + this.displayName = null; + this.avatar = null; this.#agent = undefined; this.#session = undefined; await Deno.remove(this.#currentFile).catch(() => {}); diff --git a/src/server/paths.ts b/src/server/paths.ts index 498d577..67c189a 100644 --- a/src/server/paths.ts +++ b/src/server/paths.ts @@ -25,6 +25,7 @@ export const modelsDir = () => join(appDataDir(), 'models'); export const importsDir = () => join(appDataDir(), 'imports'); export const recentsFile = () => join(appDataDir(), 'recents.json'); export const sessionFile = () => join(appDataDir(), 'session.json'); +export const apiKeyFile = () => join(appDataDir(), 'api-key'); export const syncDir = () => join(appDataDir(), 'sync'); export const groupsDir = () => join(syncDir(), 'groups'); export const identityFile = () => join(syncDir(), 'identity.json'); diff --git a/src/server/store.ts b/src/server/store.ts index d102a25..768089d 100644 --- a/src/server/store.ts +++ b/src/server/store.ts @@ -99,6 +99,7 @@ export class ZiranStore { #sockets = new Set(); #winSockets = new Map>(); + #arrived = new Set(); #winSeq = 0; #paneSeq = 0; #session: DeskSession | null = null; @@ -139,6 +140,13 @@ export class ZiranStore { return (this.#winSockets.get(winId)?.size ?? 0) > 0; } + /** True once a page for this window has connected at least once — the + signal that a navigate() actually landed (Deno Desktop has no ready + event, and navigate is a silent no-op before the window exists). */ + hasArrived(winId: string): boolean { + return this.#arrived.has(winId); + } + /** Create a document-window record and (in desktop mode) its OS window. With no explicit bounds, a single-doc window reuses the geometry its document last had (remembered on the recents entry). */ @@ -591,6 +599,7 @@ export class ZiranStore { window is gone: drop its record and release its docs. */ addSocket(ws: WebSocket, winId?: string): void { this.#sockets.add(ws); + this.#arrived.add(winId || 'base'); if (winId && winId !== 'base') { const set = this.#winSockets.get(winId) ?? new Set(); set.add(ws); diff --git a/src/server/sync.ts b/src/server/sync.ts index b46a9c1..4a231e1 100644 --- a/src/server/sync.ts +++ b/src/server/sync.ts @@ -59,10 +59,17 @@ interface LiveDoc { export interface SyncSummary { identity: { did: string; handle: string }; - at: { signedIn: boolean; handle?: string; did?: string }; + at: { signedIn: boolean; handle?: string; did?: string; displayName?: string; hasAvatar?: boolean }; relayOk: boolean; invitations: Array>; - docs: Record; + docs: Record; + }>; } export class SyncManager { @@ -171,6 +178,7 @@ export class SyncManager { await this.#transport.register(); this.relayOk = true; this.#watchInbox(); + this.#watchPendingInvites(); // Rejoin every registered shared doc so offline edits flow both ways // even before its window opens. Docs shared under a different identity // than the current one fail membership and are skipped with a warning. @@ -230,6 +238,29 @@ export class SyncManager { })().catch(() => {}); } + /** Invitations we issued to people who had never run Ziran complete + themselves here: the relay says the invitee has published keys, so we + add them to the group and hand over the Welcome. Needs the document in + a live session, which rejoin gives us at startup. */ + #watchPendingInvites(): void { + const transport = this.#transport!; + transport.onInviteReady(async (token, syncId, inviteeDid) => { + if (this.#transport !== transport) return; // identity switched + const rec = this.#registry.find((r) => r.syncId === syncId); + if (!rec) return; + try { + await transport.completePending(token, syncId, inviteeDid); + // The newcomer joins at the epoch this add created, so they need a + // snapshot encrypted for it. + await this.uploadSnapshot(rec); + this.#store.broadcast(); + console.log(`sync: completed the invitation for ${inviteeDid}`); + } catch (err) { + console.warn(`sync: could not complete an invitation:`, (err as Error).message); + } + }); + } + /* ——— doc lookup ——— */ byLocal(localId: string): SharedDoc | undefined { @@ -337,7 +368,8 @@ export class SyncManager { const invitation = await this.#transport!.invite(rec.syncId, inviteeDid, role, doc?.info.name); // Snapshot AFTER the add-commit: the invitee joins at the post-add epoch // and can only decrypt a snapshot encrypted at (or after) that epoch. - await this.uploadSnapshot(rec); + // (A pending invitation adds nobody yet; its snapshot comes on completion.) + if (!invitation.pending) await this.uploadSnapshot(rec); this.#store.broadcast(); return { invitation, link: this.inviteLink(invitation.token) }; } @@ -353,6 +385,11 @@ export class SyncManager { this.#requireRelay(); const inv = this.#invitations.get(token) ?? (await this.#base!.getInvite(token)) ?? undefined; if (!inv) throw new Error('invitation not found — it may have been withdrawn'); + if (inv.pending) { + throw new Error( + `${inv.inviterHandle ?? 'The person who invited you'} still has to finish this invitation; it will arrive on its own`, + ); + } // Download-then-sync: the snapshot IS the tile file; verify + open happens // through the normal engine path once we write it into documents/. const result = await this.#transport!.accept(token); @@ -439,17 +476,33 @@ export class SyncManager { summary(): SyncSummary { const docs: SyncSummary['docs'] = {}; for (const [localId, live] of this.#live) { + const manifest = live.rec.manifest; docs[localId] = { syncId: live.rec.syncId, role: live.rec.role, members: live.roster, inviteBase: this.relayUrl.replace(/^ws/, 'http').replace(/\/ws$/, ''), + // The document defines its own roles; the chrome offers exactly + // those, minus the owner's (that seat is taken) and any at capacity. + roles: Object.entries(manifest.roles).map(([name, spec]) => ({ + name, + label: spec.label ?? name.charAt(0).toUpperCase() + name.slice(1), + invitable: name !== manifest.owner, + full: typeof spec.max === 'number' && + live.roster.filter((m) => m.role === name).length >= spec.max, + })), }; } return { identity: { did: this.did, handle: this.handle }, at: this.#at?.signedIn - ? { signedIn: true, handle: this.#at.handle ?? undefined, did: this.#at.did ?? undefined } + ? { + signedIn: true, + handle: this.#at.handle ?? undefined, + did: this.#at.did ?? undefined, + displayName: this.#at.displayName ?? undefined, + hasAvatar: Boolean(this.#at.avatar), + } : { signedIn: false }, relayOk: this.relayOk, invitations: [...this.#invitations.values()].map(({ payload: _p, ...rest }) => rest), diff --git a/src/server/windows.ts b/src/server/windows.ts index 2ab11ba..a64c238 100644 --- a/src/server/windows.ts +++ b/src/server/windows.ts @@ -34,6 +34,36 @@ export interface DesktopHandlers { onBounds: (winId: string, bounds: Bounds) => void; /** File > Open Tile. */ onOpenTile: () => void; + /** Has a page for this window connected yet? Drives navigate retries. */ + hasArrived: (winId: string) => boolean; +} + +/** Point a window at its URL and keep trying until a page actually shows up. + * + * Deno Desktop exposes no ready/load event, and `navigate()` before the + * window exists is a silent no-op — meanwhile the runtime performs its own + * initial load of the served root, which carries no API key. Left alone, + * that keyless page is what the user ends up staring at. Retrying until the + * window's socket connects is self-correcting: the moment one navigate + * lands, the page reports in and the retries stop. */ +function navigateUntilArrived( + win: BrowserWindowLike, + url: string, + winId: string, + arrived: (winId: string) => boolean, +): void { + const attempt = (delays: number[]): void => { + if (arrived(winId)) return; + try { + win.navigate?.(url); + } catch { + // window went away between attempts + } + const [next, ...rest] = delays; + if (next === undefined) return; + setTimeout(() => attempt(rest), next); + }; + attempt([700, 1200, 2000, 3000, 4000]); } const APP_MENU: MenuItem[] = [ @@ -160,7 +190,7 @@ export function setupMainWindow(h: DesktopHandlers, mainUrl: string): void { try { const win = new BW({ title: 'Ziran', width: 1100, height: 780 }); wire(win, 'main'); - win.navigate?.(mainUrl); + navigateUntilArrived(win, mainUrl, 'base', h.hasArrived); try { win.setApplicationMenu?.(APP_MENU); } catch (err) { @@ -192,7 +222,7 @@ export function openDocWindow( } const win = new BW(opts); wire(win, winId); - win.navigate?.(url); + navigateUntilArrived(win, url, winId, handlers?.hasArrived ?? (() => false)); return true; } catch (err) { console.warn('could not open document window:', err); diff --git a/src/state/backend.ts b/src/state/backend.ts index 305b0b9..3650959 100644 --- a/src/state/backend.ts +++ b/src/state/backend.ts @@ -20,6 +20,25 @@ export const keyed = (url: string): string => `${url}${url.includes('?') ? '&' : const authHeaders = (extra: Record = {}) => ({ 'x-ziran-key': apiKey, ...extra }); +export interface PickResult { + path?: string; + cancelled?: boolean; + unsupported?: boolean; + /** The request itself failed (auth, backend gone): not a dialog outcome. */ + error?: string; +} + +async function pickRequest(url: string): Promise { + try { + const res = await fetch(url, { headers: authHeaders() }); + const data = await res.json().catch(() => ({})); + if (!res.ok) return { error: res.status === 401 ? 'unauthorized' : (data.error ?? `HTTP ${res.status}`) }; + return data as PickResult; + } catch (err) { + return { error: (err as Error).message }; + } +} + async function post(url: string, body?: unknown): Promise { const res = await fetch(url, { method: 'POST', @@ -52,8 +71,14 @@ export const backend = { ws.addEventListener('close', () => setTimeout(() => this.connect(winId), 1500)); }, - state: (): Promise => - fetch('/api/state', { headers: authHeaders() }).then((r) => r.json()), + state: async (): Promise => { + const res = await fetch('/api/state', { headers: authHeaders() }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(res.status === 401 ? 'unauthorized' : (data.error ?? `state failed (${res.status})`)); + } + return await res.json() as ServerState; + }, postBounds: (winId: string, b: Bounds): Promise => fetch(`/api/windows/${winId}/bounds`, { method: 'POST', @@ -69,10 +94,9 @@ export const backend = { post(`/api/windows/${winId}/op`, body), rename: (id: string, name: string): Promise => post(`/api/docs/${id}/rename`, { name }), saveAsModel: (docId: string): Promise => post(`/api/models/from/${docId}`), - pick: (): Promise<{ path?: string; cancelled?: boolean; unsupported?: boolean }> => - fetch('/api/pick', { headers: authHeaders() }).then((r) => r.json()), - pickSave: (name: string): Promise<{ path?: string; cancelled?: boolean; unsupported?: boolean }> => - fetch(`/api/pick-save?name=${encodeURIComponent(name)}`, { headers: authHeaders() }).then((r) => r.json()), + pick: (): Promise => pickRequest('/api/pick'), + pickSave: (name: string): Promise => + pickRequest(`/api/pick-save?name=${encodeURIComponent(name)}`), moveDoc: (id: string, dest: string): Promise<{ oldId: string; info: DocInfo }> => post(`/api/docs/${id}/move`, { dest }), removeRecent: (id: string): Promise => @@ -91,7 +115,11 @@ export const backend = { /* ——— collaboration ——— */ syncShare: (docId: string): Promise => post(`/api/docs/${docId}/sync/share`), - syncInvite: (docId: string, did: string, role: string): Promise<{ link: string }> => + syncInvite: ( + docId: string, + did: string, + role: string, + ): Promise<{ link: string; invitation: { pending?: boolean } }> => post(`/api/docs/${docId}/sync/invite`, { did, role }), syncAttach: (docId: string): Promise< { shared: boolean; me?: SyncMember; roster?: SyncMember[]; buffered?: SyncFrame[] } diff --git a/src/state/desk.ts b/src/state/desk.ts index e18b576..bd6cc0e 100644 --- a/src/state/desk.ts +++ b/src/state/desk.ts @@ -15,6 +15,9 @@ class DeskStore extends EventTarget { desktop = false; connected = false; sync: SyncState | undefined; + /** Set when the backend refused or vanished; the UI says so plainly + instead of pretending the library is empty. */ + backendError: string | undefined; /** Which window this page renders. */ winId: string = 'base'; @@ -25,10 +28,22 @@ class DeskStore extends EventTarget { this.winId = new URLSearchParams(location.search).get('win') ?? 'base'; backend.onState = (state) => this.applyServerState(state); backend.connect(this.winId); - backend.state().then((s) => this.applyServerState(s)).catch(() => {}); + backend.state() + .then((s) => this.applyServerState(s)) + .catch((err) => { + // Never render an empty desk when the truth is "we couldn't ask". + this.backendError = (err as Error).message; + this.emit(); + }); } applyServerState(state: ServerState): void { + if (!state || !Array.isArray(state.recents) || !Array.isArray(state.open)) { + this.backendError = 'unexpected reply from the backend'; + this.emit(); + return; + } + this.backendError = undefined; this.recents = state.recents; this.models = state.models; this.windows = state.windows; @@ -133,8 +148,15 @@ class DeskStore extends EventTarget { async openExternal(): Promise { const picked = await backend.pick(); if (picked.cancelled) return; + if (picked.error) { + // A failed request is not a missing file dialog — say what happened + // rather than falling back to asking for a path. + this.toast(`Could not open the file picker: ${picked.error}`); + return; + } let path = picked.path; if (picked.unsupported || !path) { + // Genuinely no native dialog (a bare Linux box): ask for a path. path = prompt('Absolute path to a .tile file:') ?? undefined; if (!path) return; } @@ -226,6 +248,10 @@ class DeskStore extends EventTarget { if (!doc) return; const picked = await backend.pickSave(doc.path.split('/').pop() ?? `${doc.name}.tile`); if (picked.cancelled) return; + if (picked.error) { + this.toast(`Could not open the save dialog: ${picked.error}`); + return; + } let dest = picked.path; if (picked.unsupported || !dest) { dest = prompt('Move tile to (absolute path):', doc.path) ?? undefined; @@ -275,12 +301,17 @@ class DeskStore extends EventTarget { /** Returns the share link on success (also toasts it). */ async invite(docId: string, did: string, role: string): Promise { try { - const { link } = await backend.syncInvite(docId, did, role); + const { link, invitation } = await backend.syncInvite(docId, did, role); + // Someone who has never run Ziran can still be invited: the invitation + // waits for them, and finishes itself once they sign in. + const note = invitation?.pending + ? 'Invitation waiting — send them the link; it completes once they set up Ziran' + : 'Invitation sent'; try { await navigator.clipboard.writeText(link); - this.toast('Invitation sent — share link copied to your clipboard'); + this.toast(`${note} — link copied to your clipboard`); } catch { - this.toast(`Invitation sent — share link: ${link}`); + this.toast(`${note} — link: ${link}`); } return link; } catch (err) { @@ -316,6 +347,17 @@ class DeskStore extends EventTarget { } } + async copyHandle(): Promise { + const handle = this.sync?.at?.handle; + if (!handle) return; + try { + await navigator.clipboard.writeText(handle); + this.toast(`Copied ${handle} — hand it to whoever wants to invite you`); + } catch { + this.toast(`Your handle is ${handle}`); + } + } + async atSignOut(): Promise { try { await backend.atSignOut(); diff --git a/src/sync/mls-transport.ts b/src/sync/mls-transport.ts index eed8145..5ff6836 100644 --- a/src/sync/mls-transport.ts +++ b/src/sync/mls-transport.ts @@ -252,8 +252,15 @@ export class MlsSyncTransport implements SyncTransport { const session = this.#sessions.get(docId); const group = this.#groups.get(docId); if (!session || !group) throw new Error('join the document before inviting'); - // Fetch the invitee's published KeyPackage and add them to the group. - const kp = await this.opts.directory.fetch(inviteeDid); + let kp: Uint8Array; + try { + kp = await this.opts.directory.fetch(inviteeDid); + } catch { + // They have never run Ziran, so there is no key to encrypt a Welcome + // to. Record the invitation as pending: it waits on the relay until + // they sign in, and the relay then nudges us to finish it. + return this.opts.base.invite(docId, inviteeDid, role, { title: title ?? docId, pending: true }); + } const { commit, welcome } = await group.addMember(kp); this.#dirty(docId); // The commit rides the ordered log so current members advance their epoch; @@ -266,6 +273,23 @@ export class MlsSyncTransport implements SyncTransport { }); } + /** Finish an invitation issued before the invitee had keys: add them to + the group now and hand the relay the Welcome. */ + async completePending(token: string, docId: string, inviteeDid: string): Promise { + const session = this.#sessions.get(docId); + const group = this.#groups.get(docId); + if (!session || !group) throw new Error('the document is not in a live session'); + const kp = await this.opts.directory.fetch(inviteeDid); + const { commit, welcome } = await group.addMember(kp); + this.#dirty(docId); + await session.base_append_raw(commit); + await this.opts.base.completeInvite(token, toB64(welcome)); + } + + onInviteReady(handler: (token: string, docId: string, inviteeDid: string) => void): () => void { + return this.opts.base.onInviteReady(handler); + } + async *inbox(): AsyncIterable { for await (const inv of this.opts.base.inbox()) { this.#invitations.set(inv.token, inv); diff --git a/src/sync/newcomer.test.ts b/src/sync/newcomer.test.ts new file mode 100644 index 0000000..c6198d3 --- /dev/null +++ b/src/sync/newcomer.test.ts @@ -0,0 +1,123 @@ +// Inviting someone who has never run Ziran: the invitation waits, and +// completes itself the moment they arrive. Also checks that the invite UI is +// offered the document's OWN roles (tictactoe has no "editor"). +// Run: deno run -A src/sync/newcomer.test.ts + +import { join } from '@std/path'; +import { startRelay, standardVerifier } from '../../relay/main.ts'; +import { toB64 } from './protocol.ts'; + +let failures = 0; +const check = (label: string, ok: boolean, detail = '') => { + console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${detail ? ` — ${detail}` : ''}`); + if (!ok) failures++; +}; +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const dec = (b: Uint8Array) => new TextDecoder().decode(b); + +const tmp = await Deno.makeTempDir({ prefix: 'ziran-newcomer-' }); +const relay = await startRelay({ port: 0, dataDir: join(tmp, 'relay'), verifier: standardVerifier() }); +const relayUrl = `ws://127.0.0.1:${relay.port}/ws`; + +interface App { port: number; key: string; child: Deno.ChildProcess } +async function startApp(name: string, did: string, port: number): Promise { + const child = new Deno.Command(Deno.execPath(), { + args: ['run', '-A', 'main.ts'], + env: { + ZIRAN_DATA_DIR: join(tmp, name), ZIRAN_KEY: `${name}-key`, ZIRAN_PORT: String(port), + ZIRAN_DID: did, ZIRAN_HANDLE: `${name}.test`, + ZIRAN_RELAY: relayUrl, ZIRAN_RELAY_SECRET: 'dev-secret', + }, + stdout: 'null', stderr: 'null', + }).spawn(); + for (let i = 0; i < 80; i++) { + try { + const res = await fetch(`http://127.0.0.1:${port}/api/state`, { headers: { 'x-ziran-key': `${name}-key` } }); + const ok = res.ok; + await res.body?.cancel(); + if (ok) return { port, key: `${name}-key`, child }; + } catch { /* not yet */ } + await sleep(250); + } + throw new Error(`${name} did not start`); +} +const req = async (app: App, path: string, init?: RequestInit) => { + const res = await fetch(`http://127.0.0.1:${app.port}${path}`, { + ...init, + headers: { 'x-ziran-key': app.key, 'content-type': 'application/json', ...(init?.headers ?? {}) }, + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? `${path} → ${res.status}`); + return data; +}; + +let alice: App | undefined; +let newcomer: App | undefined; +try { + alice = await startApp('alice', 'did:ziran:nc-alice', 4831); + await sleep(1200); + + // Alice starts a game and goes live. + const state = await req(alice, '/api/state'); + const model = state.models.find((m: { id: string }) => m.id === 'com.berjon.ziran.tictactoe'); + const opened = await req(alice, '/api/docs/from-model', { method: 'POST', body: JSON.stringify({ modelId: model.id }) }); + const doc = opened.doc.id as string; + await req(alice, `/api/docs/${doc}/sync/share`, { method: 'POST' }); + await sleep(400); + + // The roles offered are the DOCUMENT's, labelled for humans. + const live = (await req(alice, '/api/state')).sync.docs[doc]; + const offered = (live.roles ?? []).filter((r: { invitable: boolean; full: boolean }) => r.invitable && !r.full); + check('invite offers the document’s own roles', offered.length === 2, JSON.stringify(offered.map((r: {name: string}) => r.name))); + check('the player seat is labelled “Player”, not “editor”', + offered.some((r: { name: string; label: string }) => r.name === 'o' && r.label === 'Player'), + JSON.stringify(offered)); + check('no “editor” role is offered for a game', !offered.some((r: { name: string }) => r.name === 'editor')); + + // Invite someone who has never run Ziran at all. + const invited = await req(alice, `/api/docs/${doc}/sync/invite`, { + method: 'POST', body: JSON.stringify({ did: 'did:ziran:nc-new', role: 'o' }), + }); + check('inviting a stranger succeeds instead of erroring', Boolean(invited.link), JSON.stringify(invited).slice(0, 90)); + check('and is marked pending', invited.invitation?.pending === true); + + // They install Ziran and sign in for the first time. + newcomer = await startApp('newcomer', 'did:ziran:nc-new', 4832); + await sleep(2500); // publish keys → relay nudges alice → alice completes → welcome delivered + + const nState = await req(newcomer, '/api/state'); + const inv = nState.sync.invitations[0]; + check('the newcomer finds the invitation waiting', Boolean(inv), JSON.stringify(nState.sync.invitations)); + check('and it completed itself (no longer pending)', inv?.pending !== true, JSON.stringify(inv?.pending)); + + // They accept: download-then-sync, exactly as a regular invitation. + const accepted = await req(newcomer, `/api/invitations/${inv.token}/accept`, { method: 'POST' }); + const nDoc = accepted.doc.id as string; + check('the newcomer can accept and gets a verified document', accepted.doc.damaged === false); + + // And they can actually play: their move reaches alice, attributed. + await sleep(600); + const move = JSON.stringify({ op: 'move', cell: 4 }); + await req(alice, `/api/docs/${doc}/sync/send`, { method: 'POST', body: JSON.stringify({ b64: toB64(new TextEncoder().encode(move)) }) }); + await sleep(800); + const nAttach = await req(newcomer, `/api/docs/${nDoc}/sync/attach`, { method: 'POST' }); + check('the newcomer is in the live session', nAttach.shared === true); + const got = (nAttach.buffered ?? []).some((m: { b64: string }) => + dec(Uint8Array.from(atob(m.b64.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0))).includes('"cell":4')); + check('alice’s move reaches the newcomer, decrypted', got); + const roster = (await req(alice, '/api/state')).sync.docs[doc].members; + check('both players are in the roster', roster.length === 2, JSON.stringify(roster.map((m: {role: string}) => m.role))); +} catch (err) { + check('script completed', false, (err as Error).message); +} finally { + try { alice?.child.kill(); newcomer?.child.kill(); } catch { /* gone */ } + relay.shutdown(); + await sleep(300); + await Deno.remove(tmp, { recursive: true }).catch(() => {}); +} + +if (failures) { + console.error(`\n${failures} failure(s)`); + Deno.exit(1); +} +console.log('\ninviting a newcomer: all good'); diff --git a/src/sync/protocol.ts b/src/sync/protocol.ts index 4c3a3b6..e4032b6 100644 --- a/src/sync/protocol.ts +++ b/src/sync/protocol.ts @@ -15,6 +15,10 @@ export interface Member { under `sync.roles`. Everything else about what a role *means* is the tile's business. */ export interface RoleSpec { + /** What to call this role in the runtime's chrome ("Player", "Reviewer"). + Role *names* are the document's vocabulary and may be terse (`o`); + this is what a person should read. Defaults to the capitalized name. */ + label?: string; /** May append durable messages to the log. */ write?: boolean; /** May send on the ephemeral presence lane. */ @@ -48,8 +52,13 @@ export interface Invitation { role: string; createdAt: number; /** Opaque to the relay: the MLS Welcome (b64) under E2EE transports, so the - invitee can join the group. Absent for plaintext transports. */ + invitee can join the group. Absent for plaintext transports, and absent + while an invitation is still *pending* — see `pending`. */ payload?: string; + /** True when the invitee had published no collaboration keys yet (they had + never run Ziran). The invitation is real and waiting: once they sign in + and publish, the inviter's runtime completes it with a Welcome. */ + pending?: boolean; } /** Durable, ordered, attributed message delivered to a joined session. */ @@ -124,7 +133,19 @@ export type ClientFrame = | { t: 'append'; docId: string; id: string; b64: string } | { t: 'ephemeral'; docId: string; b64: string } | { t: 'snapshot'; docId: string; atSeq: number; b64: string } - | { t: 'invite'; docId: string; inviteeDid: string; role: string; id: string; title?: string; payload?: string } + | { + t: 'invite'; + docId: string; + inviteeDid: string; + role: string; + id: string; + title?: string; + payload?: string; + pending?: boolean; + } + // The inviter supplying the Welcome for a pending invitation, once the + // invitee has finally published keys. + | { t: 'complete-invite'; token: string; payload: string; id: string } | { t: 'accept'; token: string; id: string } | { t: 'sub-inbox' } // KeyPackage directory (stands in for the PDS record until AT sign-in). @@ -142,6 +163,9 @@ export type ServerFrame = | { t: 'invited'; id: string; invitation: Invitation } | { t: 'accepted'; id: string; result: { docId: string; role: string; atSeq: number; snapshotB64: string | null } } | { t: 'inbox'; invitation: Invitation } + /** To the inviter: a pending invitation can now be completed, because the + invitee has published collaboration keys. */ + | { t: 'invite-ready'; token: string; docId: string; inviteeDid: string } | { t: 'kp'; id: string; b64: string | null } | { t: 'invite-info'; id: string; invitation: Invitation | null } | { t: 'err'; id?: string; code: string; message: string }; diff --git a/src/sync/relay-client.ts b/src/sync/relay-client.ts index 822ffe5..cdf0245 100644 --- a/src/sync/relay-client.ts +++ b/src/sync/relay-client.ts @@ -97,6 +97,7 @@ export class RelaySyncTransport implements SyncTransport { #sessions = new Map(); #joinWaiters = new Map(); #inboxHandlers = new Set<(inv: Invitation) => void>(); + #readyHandlers = new Set<(token: string, docId: string, inviteeDid: string) => void>(); #inboxBuffer: Invitation[] = []; #subbedInbox = false; @@ -157,6 +158,9 @@ export class RelaySyncTransport implements SyncTransport { if (this.#inboxHandlers.size) for (const h of this.#inboxHandlers) h(f.invitation); else this.#inboxBuffer.push(f.invitation); break; + case 'invite-ready': + for (const h of this.#readyHandlers) h(f.token, f.docId, f.inviteeDid); + break; case 'err': if (f.id) { this.#pending.get(f.id)?.reject(new Error(`${f.code}: ${f.message}`)); @@ -201,13 +205,30 @@ export class RelaySyncTransport implements SyncTransport { return session; } - async invite(docId: string, inviteeDid: string, role: string, extra?: { title?: string; payload?: string }): Promise { + async invite( + docId: string, + inviteeDid: string, + role: string, + extra?: { title?: string; payload?: string; pending?: boolean }, + ): Promise { await this.#connect(); const f = await this.#request((id) => ({ t: 'invite', docId, inviteeDid, role, id, ...extra })); if (f.t !== 'invited') throw new Error('invite failed'); return f.invitation; } + /** Supply the Welcome for an invitation issued before the invitee had keys. */ + async completeInvite(token: string, payload: string): Promise { + await this.#connect(); + await this.#request((id) => ({ t: 'complete-invite', token, payload, id })); + } + + /** Fires when a pending invitation this user issued becomes completable. */ + onInviteReady(handler: (token: string, docId: string, inviteeDid: string) => void): () => void { + this.#readyHandlers.add(handler); + return () => this.#readyHandlers.delete(handler); + } + async *inbox(): AsyncIterable { await this.#connect(); if (!this.#subbedInbox) { diff --git a/src/types.ts b/src/types.ts index 059bf98..a1c6a68 100644 --- a/src/types.ts +++ b/src/types.ts @@ -52,14 +52,23 @@ export interface PendingInvitation { inviterHandle?: string; role: string; createdAt: number; + /** Issued before this person had collaboration keys; the inviter still + has to finish it (happens by itself once both are online). */ + pending?: boolean; } export interface SyncState { identity: { did: string; handle: string }; - at: { signedIn: boolean; handle?: string; did?: string }; + at: { signedIn: boolean; handle?: string; did?: string; displayName?: string; hasAvatar?: boolean }; relayOk: boolean; invitations: PendingInvitation[]; - docs: Record; + docs: Record; + }>; } export interface ServerState { diff --git a/static/app.js b/static/app.js index a519c85..6cf2818 100644 --- a/static/app.js +++ b/static/app.js @@ -631,6 +631,22 @@ var authHeaders = (extra = {}) => ({ "x-ziran-key": apiKey, ...extra }); +async function pickRequest(url) { + try { + const res = await fetch(url, { + headers: authHeaders() + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) return { + error: res.status === 401 ? "unauthorized" : data.error ?? `HTTP ${res.status}` + }; + return data; + } catch (err) { + return { + error: err.message + }; + } +} async function post(url, body) { const res = await fetch(url, { method: "POST", @@ -663,9 +679,16 @@ var backend = { }); ws.addEventListener("close", () => setTimeout(() => this.connect(winId), 1500)); }, - state: () => fetch("/api/state", { - headers: authHeaders() - }).then((r4) => r4.json()), + state: async () => { + const res = await fetch("/api/state", { + headers: authHeaders() + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(res.status === 401 ? "unauthorized" : data.error ?? `state failed (${res.status})`); + } + return await res.json(); + }, postBounds: (winId, b3) => fetch(`/api/windows/${winId}/bounds`, { method: "POST", headers: authHeaders({ @@ -687,12 +710,8 @@ var backend = { name }), saveAsModel: (docId) => post(`/api/models/from/${docId}`), - pick: () => fetch("/api/pick", { - headers: authHeaders() - }).then((r4) => r4.json()), - pickSave: (name) => fetch(`/api/pick-save?name=${encodeURIComponent(name)}`, { - headers: authHeaders() - }).then((r4) => r4.json()), + pick: () => pickRequest("/api/pick"), + pickSave: (name) => pickRequest(`/api/pick-save?name=${encodeURIComponent(name)}`), moveDoc: (id, dest) => post(`/api/docs/${id}/move`, { dest }), @@ -781,6 +800,9 @@ var DeskStore = class extends EventTarget { desktop = false; connected = false; sync; + /** Set when the backend refused or vanished; the UI says so plainly + instead of pretending the library is empty. */ + backendError; /** Which window this page renders. */ winId = "base"; sawMyWindow = false; @@ -788,10 +810,18 @@ var DeskStore = class extends EventTarget { this.winId = new URLSearchParams(location.search).get("win") ?? "base"; backend.onState = (state) => this.applyServerState(state); backend.connect(this.winId); - backend.state().then((s4) => this.applyServerState(s4)).catch(() => { + backend.state().then((s4) => this.applyServerState(s4)).catch((err) => { + this.backendError = err.message; + this.emit(); }); } applyServerState(state) { + if (!state || !Array.isArray(state.recents) || !Array.isArray(state.open)) { + this.backendError = "unexpected reply from the backend"; + this.emit(); + return; + } + this.backendError = void 0; this.recents = state.recents; this.models = state.models; this.windows = state.windows; @@ -883,6 +913,10 @@ var DeskStore = class extends EventTarget { async openExternal() { const picked = await backend.pick(); if (picked.cancelled) return; + if (picked.error) { + this.toast(`Could not open the file picker: ${picked.error}`); + return; + } let path = picked.path; if (picked.unsupported || !path) { path = prompt("Absolute path to a .tile file:") ?? void 0; @@ -1001,6 +1035,10 @@ var DeskStore = class extends EventTarget { if (!doc) return; const picked = await backend.pickSave(doc.path.split("/").pop() ?? `${doc.name}.tile`); if (picked.cancelled) return; + if (picked.error) { + this.toast(`Could not open the save dialog: ${picked.error}`); + return; + } let dest = picked.path; if (picked.unsupported || !dest) { dest = prompt("Move tile to (absolute path):", doc.path) ?? void 0; @@ -1044,12 +1082,13 @@ var DeskStore = class extends EventTarget { /** Returns the share link on success (also toasts it). */ async invite(docId, did, role) { try { - const { link } = await backend.syncInvite(docId, did, role); + const { link, invitation } = await backend.syncInvite(docId, did, role); + const note = invitation?.pending ? "Invitation waiting \u2014 send them the link; it completes once they set up Ziran" : "Invitation sent"; try { await navigator.clipboard.writeText(link); - this.toast("Invitation sent \u2014 share link copied to your clipboard"); + this.toast(`${note} \u2014 link copied to your clipboard`); } catch { - this.toast(`Invitation sent \u2014 share link: ${link}`); + this.toast(`${note} \u2014 link: ${link}`); } return link; } catch (err) { @@ -1080,6 +1119,16 @@ var DeskStore = class extends EventTarget { this.toast(`Could not start sign-in: ${err.message}`); } } + async copyHandle() { + const handle = this.sync?.at?.handle; + if (!handle) return; + try { + await navigator.clipboard.writeText(handle); + this.toast(`Copied ${handle} \u2014 hand it to whoever wants to invite you`); + } catch { + this.toast(`Your handle is ${handle}`); + } + } async atSignOut() { try { await backend.atSignOut(); @@ -1437,6 +1486,21 @@ var ZnBaseWindow = class extends i4 { outline: 2px solid var(--signal-strong); outline-offset: 2px; } + .retry { + font: inherit; + font-size: var(--text-sm); + font-weight: var(--w-medium); + border: 1px solid var(--ink); + background: var(--ink); + color: var(--surface); + border-radius: var(--r-full); + padding: 5px 16px; + cursor: pointer; + } + .retry:focus-visible { + outline: 2px solid var(--signal); + outline-offset: 2px; + } .id-signin { margin-left: 0; } @@ -1463,11 +1527,27 @@ var ZnBaseWindow = class extends i4 { outline: 2px solid var(--signal-strong); outline-offset: 2px; } - .id-dot { - width: 7px; - height: 7px; + .id-avatar { border-radius: 50%; + object-fit: cover; + flex: none; + display: block; + background: oklch(0.4 0.02 352); + } + .id-avatar.fallback { + display: grid; + place-items: center; background: var(--signal); + color: #fff; + font-size: 0.72rem; + font-weight: var(--w-semibold); + line-height: 1; + } + .id-name { + max-width: 12rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .id-pop { top: 60px; @@ -1475,10 +1555,25 @@ var ZnBaseWindow = class extends i4 { left: auto; max-width: 320px; } - .id-did { - margin: var(--sp-1) var(--sp-2); - font-family: var(--font-mono); - font-size: 0.72rem; + .id-head { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-2); + min-width: 0; + } + .id-head-text { + display: grid; + min-width: 0; + } + .id-head-name { + font-weight: var(--w-semibold); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .id-head-handle { + font-size: var(--text-sm); color: var(--ink-muted); overflow: hidden; text-overflow: ellipsis; @@ -1561,6 +1656,12 @@ var ZnBaseWindow = class extends i4 { font-size: var(--text-sm); color: var(--ink); } + .invite-waiting { + font-size: var(--text-sm); + color: var(--ink-muted); + white-space: nowrap; + padding: 3px 4px; + } .invite-accept, .invite-dismiss { font: inherit; @@ -1860,6 +1961,8 @@ var ZnBaseWindow = class extends i4 { const enterDoc = q ? recents[0] : void 0; const enterModel = q && !enterDoc ? models[0] : void 0; const damagedCount = desk.recents.filter((r4) => r4.damaged).length; + const sharedCount = Object.keys(desk.sync?.docs ?? {}).length; + if (desk.backendError) return this.renderBackendError(); return b2`

Your documents live here.

- Ziran documents are tiles: they carry their own editor, live on this - machine, and never need a cloud. Start from a model to make your first - one — it will appear here. You can also drop a .tile file anywhere on - this window. + Ziran documents are tiles: they carry their own editor and live on this + machine, with no cloud and no account. Invite someone and a document + syncs end-to-end encrypted, everyone keeping their own copy. Start from + a model to make your first one, or drop a .tile file anywhere on this + window.

` : recents.length ? recents.map((d3) => this.renderRow(d3, open.has(d3.id), d3 === enterDoc)) : b2`

Nothing called “${this.query}” — try a model instead?

`} @@ -1907,9 +2011,9 @@ var ZnBaseWindow = class extends i4 {

Models

-

Editors without their data — any document can become one.

+

Pick one to start a new document. A model carries the editor, not the content.

- ${models.length ? models.map((m2) => this.renderModel(m2, m2 === enterModel)) : b2`

No model matches “${this.query}”.

`} + ${models.length ? models.map((m2) => this.renderModel(m2, m2 === enterModel)) : q ? b2`

No model matches “${this.query}”.

` : b2`

No models in your library yet.

`}
@@ -1917,7 +2021,7 @@ var ZnBaseWindow = class extends i4 {
${damagedCount ? icons.warning : icons.check} - ${desk.recents.length ? damagedCount ? `${damagedCount} of ${desk.recents.length} tiles failed verification` : "All documents verified and saved on this machine" : "Everything you make here stays on this machine"} + ${desk.recents.length ? damagedCount ? `${damagedCount} of ${desk.recents.length} tiles failed verification` : sharedCount ? `All documents verified \xB7 ${sharedCount} shared live` : "All documents verified \xB7 nothing leaves this machine until you share it" : "Documents you make live here; sharing one is always your choice"} ${desk.recents.length} ${desk.recents.length === 1 ? "tile" : "tiles"} @@ -1925,22 +2029,56 @@ var ZnBaseWindow = class extends i4 {
`; } + /** When the backend refuses or disappears, say so. Showing the ordinary + empty desk here would claim the library is empty when we simply never + got an answer — the failure that made a stale key look like five + unrelated bugs. */ + renderBackendError() { + const stale = desk.backendError === "unauthorized"; + return b2` +
+
+ ${zmark} + ziran +
+
+
+
+

${stale ? "This window is out of date." : "Ziran can\u2019t reach its backend."}

+

+ ${stale ? "It was opened by an earlier run of Ziran and no longer has access. Reopening Ziran will restore your documents \u2014 none of them are affected." : `Your documents are safe on this machine; this window just can\u2019t read them right now (${desk.backendError}).`} +

+

+
+
+
+
+ `; + } /** The identity affordance: your AT handle when signed in, a quiet way in when not. Collaboration works either way — signing in makes you reachable by handle across machines. */ renderIdentity() { const at = desk.sync?.at; if (at?.signedIn) { + const name = at.displayName || at.handle || "You"; + const handle = at.handle ? `@${at.handle}` : void 0; return b2` - `; @@ -1967,6 +2105,13 @@ var ZnBaseWindow = class extends i4 { `; } + /** The person's avatar, or their initial when they haven't set one. */ + renderAvatar(at, name, size = 22) { + const style = `width:${size}px;height:${size}px`; + return at.hasAvatar ? b2`` : b2``; + } idAction(fn) { this.renderRoot.querySelector(".id-pop")?.hidePopover(); fn(); @@ -1985,7 +2130,7 @@ var ZnBaseWindow = class extends i4 { ${inv.inviterHandle ?? inv.inviterDid} invited you to “${inv.title}” as ${inv.role} - + ${inv.pending ? b2`Preparing your copy…` : b2``} `)} @@ -2428,37 +2573,41 @@ var ZnConnections = class extends i4 {

`; } - const roles = [ - "editor", - "watcher" - ]; + const label = (name) => s4.roles?.find((r4) => r4.name === name)?.label ?? name.charAt(0).toUpperCase() + name.slice(1); + const open = (s4.roles ?? []).filter((r4) => r4.invitable && !r4.full); return b2`
    ${s4.members.map((m2) => b2`
  • ${m2.handle ?? m2.did} - ${m2.did === sync.identity.did ? `${m2.role} \xB7 you` : m2.role} + + ${m2.did === sync.identity.did ? `${label(m2.role)} \xB7 you` : label(m2.role)} +
  • `)}
-
- - - -
-

- They get the invitation inside Ziran and as a link you can pass along. Accepting - downloads the document to their machine first, then joins them live. -

+ ${open.length ? b2` +
+ + + +
+

+ They get the invitation inside Ziran and as a link you can pass along. Accepting + downloads the document to their machine first, then joins them live. +

+ ` : b2`

+ Every seat this document offers is taken, so there is no one left to invite. +

`} `; } async submitInvite(e4) { diff --git a/tiles/built/livetext.tile b/tiles/built/livetext.tile index a4f621c88cb10fcfa33f2abb36e58b61be6b87b8..53a9991f485ab5b05695a22e0d161f7650100282 100644 GIT binary patch delta 96 zcmbQUl6)N-ZzSEJ^*EmYR}Tl3%n4 bD(4F3!Q_fk6H`**azS8TW9-&gM*B$s%2X)t delta 67 zcmX@Slx@yZHpWR48O?MTr6#82X6AiOEicL}N&T9Znvz+PU$hX$ElN#HNriF0OuVnq LxO(eqMu|xP?Q6)N-ZzSEJ^*EmYR}Tl3%n4 zD(4F3!Q_fk6H`**azS7oMD9yQQEFLcYB^jsBM8ce$bU`EOwP|MZd{aHTr}~9XyeS* SnT)M7nObKuZ=J~^#{mF~Dnz3I delta 83 zcmaDmQDFN-0mda08LiA0r6#82X6AiOEicL}N&T9Znvz+PU$hX$ElN#HNriF0WE7>A bWu}&=7A