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.
@@ -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`
-
`;
}
- 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`
- 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.
@@ -1917,7 +2021,7 @@ 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}).`}
+
+
location.reload()}>Reload this window
+
+
+
+
+ `;
+ }
/** 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`
-
- @${at.handle ?? at.did}
+
+ ${this.renderAvatar(at, name)}
+ ${name}
- 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.
+