// The web implementation of the app's backend (docs/WEB-APP.md, Phase D // pulled forward): the SAME desk store and zn-* components the desktop // renders, over the home space instead of a local server. Installed with // setBackend() before anything mounts, so no component knows the difference. // // Mapping: recents = one row per doc-space (+ legacy home docs); the // sidebar = store installs from the home space (seeded once with // @ziran.space/note — no built-in models on the web); documents open as // ordinary browser TABS (`#doc=…`); filesystem-only affordances answer // honestly instead of pretending. import type { AtProfile, DocInfo, InstallEntry, ModelEntry, RecentEntry, ServerState, StoreTileView, SyncState } from '../types.ts'; import type { Backend, OpenResult, PickResult } from '../state/backend.ts'; import { authorityOf, SpaceClient } from '../sync/spaces/client.ts'; import { homeRef } from '../sync/spaces/scope.ts'; import { getProfiles, searchActors } from '../server/atprofiles.ts'; import { resolveIdentity } from '../sync/atproto.ts'; import { getPublished, instantiatePublished, listPublished, publishTile, type PublishedTile, storeBlobUrl, tileIconCid, tileScreenshotCids, } from '../store/store.ts'; import { type HomeDoc, importTileToDocSpace, type InstallRec, listHomeDocs, listInstalls, recordInstall, removeInstall, reorderInstalls, } from './home.ts'; import { zfs } from '../lib/zfs.ts'; import type { WebHost } from './host.ts'; /** Who the Store features by default: the Ziran publisher identity — a plain AT account on any PDS (publishing is ordinary repo records). Both handles are tried; whichever resolves and answers, answers. The shelf itself comes from the index when it is up; this is the fallback. */ export const FEATURED_PUBLISHERS = ['ziran.app', 'ziran.space']; /** The store index (deploy/index): a crawl of the PDS's published tiles. */ export const INDEX_URL = 'https://index.ziran.space'; export interface WebIdentity { did: string; handle?: string; displayName?: string; avatarUrl?: string; signOut(): Promise; } const HOME_LIST_MS = 60_000; export class WebBackend { onState: ((state: ServerState) => void) | undefined; readonly events: EventTarget; #host: WebHost; #client: SpaceClient; #me: WebIdentity; #fetch: typeof fetch; #homeRef: string; #homeDocs: HomeDoc[] = []; /** Legacy home docs' icon blobs, from their checkpoints. */ #homeIcons = new Map(); /** Icon blob → object URL, fetched once per cid (content-addressed). */ #iconUrls = new Map(); #iconFetching = new Set(); #installs: InstallRec[] = []; /** Files picked but not yet imported, keyed by the fake path handed to openPath (the pick→open contract is path-shaped on the desktop). */ #pendingFiles = new Map(); #timer: number | undefined; constructor(host: WebHost, client: SpaceClient, me: WebIdentity, fetchImpl: typeof fetch = fetch) { this.#host = host; this.#client = client; this.#me = me; this.#fetch = fetchImpl; this.#homeRef = homeRef(me.did); this.events = host.events; host.onChange = () => this.#push(); } /* ——— state assembly ——— */ async start(): Promise { await this.#seedDefaultInstall().catch(() => { // Best-effort: an unreachable store just means an empty sidebar with // its link to the Store; seeding retries next session. }); await this.#refreshHome(); this.#timer = setInterval(() => { if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return; this.#refreshHome().catch(() => {}); }, HOME_LIST_MS) as unknown as number; if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') this.#refreshHome().catch(() => {}); }); } } stop(): void { if (this.#timer !== undefined) clearInterval(this.#timer); } /** A fresh sidebar starts with exactly one install: @ziran.space/note. Seeding happens ONCE — a sentinel record (no publisher/name, invisible to listInstalls) marks it done, so removing the default never resurrects it on the next load. */ async #seedDefaultInstall(): Promise { const records = await this.#client .listRecords(this.#homeRef, this.#me.did, 'space.ziran.install') .catch(() => []); if (records.length > 0) return; // installed something, or already seeded await this.storeInstall('ziran.space', 'note'); await this.#client.putRecord(this.#homeRef, 'space.ziran.install', 'seeded', { $type: 'space.ziran.install', seeded: true, createdAt: new Date().toISOString(), }); } async #refreshHome(): Promise { this.#homeDocs = await listHomeDocs(this.#client); this.#installs = await listInstalls(this.#client).catch(() => this.#installs); // Legacy home docs: their checkpoints carry the manifest — icon included. const checkpoints = await this.#client .listRecords(this.#homeRef, this.#me.did, 'space.ziran.checkpoint') .catch(() => []); for (const r of checkpoints) { const tile = (r.value as { tile?: { icons?: Array<{ src: string }>; model?: { icons?: Array<{ src: string }> }; resources?: Record; } }).tile; const src = (tile?.model?.icons ?? tile?.icons ?? [])[0]?.src; const entry = src ? tile?.resources?.[src] : undefined; const cid = entry?.src?.ref?.$link ?? entry?.src?.$link; if (cid) this.#homeIcons.set(r.rkey, { cid, type: entry?.['content-type'] }); } this.#push(); } /** The doc's icon as a URL an can use: object URL from the space blob, fetched once per cid (content addressing makes it immutable). */ #iconUrl(ref: string, author: string, icon: { cid: string; type?: string } | undefined): string | undefined { if (!icon) return undefined; const hit = this.#iconUrls.get(icon.cid); if (hit) return hit; if (!this.#iconFetching.has(icon.cid)) { this.#iconFetching.add(icon.cid); this.#client.getBlob(ref, author, icon.cid).then((bytes) => { this.#iconUrls.set( icon.cid, URL.createObjectURL(new Blob([bytes as BlobPart], { type: icon.type ?? 'image/svg+xml' })), ); this.#push(); }).catch(() => { // Unfetchable icon: the row keeps its fallback glyph. }); } return undefined; } /** All documents: one row per doc-space (doc = space), plus any legacy docs still living in the home space. */ #recents(): RecentEntry[] { const summary = this.#host.provider.summary(); const rows: RecentEntry[] = this.#homeDocs.map((d) => ({ id: `home:${d.rkey}`, path: `home:${d.rkey}`, name: d.name, damaged: false, hasIcon: false, icon: this.#iconUrl(this.#homeRef, this.#me.did, this.#homeIcons.get(d.rkey)), modelId: d.model, size: 0, mtime: Date.parse(d.createdAt ?? '') || 0, fingerprint: '', lastOpened: Date.parse(d.createdAt ?? '') || 0, creator: this.#me.did, contributors: [], createdAt: Date.parse(d.createdAt ?? '') || undefined, live: true, })); for (const s of summary.spaces ?? []) { if (s.home) continue; const members = (s.members ?? []).filter((m) => !m.pending).map((m) => m.did); const docs = new Map(); for (const a of s.available) docs.set(a.docRkey, a); for (const [, d] of Object.entries(summary.docs)) { if (d.space !== s.ref) continue; const rkey = d.syncId.split('/').pop()!; // Catalog is the truth (name + creator); this fallback only bridges // the beat before the first poll fills it. if (!docs.has(rkey)) docs.set(rkey, { docRkey: rkey, name: s.name, creator: '' }); } for (const d of docs.values()) { rows.push({ id: `space:${s.ref}#${d.docRkey}`, path: `space:${s.ref}#${d.docRkey}`, name: d.name, damaged: false, hasIcon: false, icon: d.icon ? this.#iconUrl(s.ref, d.icon.author, d.icon) : undefined, size: 0, mtime: s.updatedAt ?? 0, fingerprint: '', lastOpened: s.updatedAt ?? 0, space: s.ref, creator: d.creator, contributors: members.filter((m) => m !== d.creator), createdAt: d.createdAt, updatedAt: s.updatedAt, live: summary.relayOk, }); } } return rows; } #snapshot(): ServerState { const summary = this.#host.provider.summary(); const sync: SyncState = { ...summary, at: { signedIn: true, did: this.#me.did, handle: this.#me.handle, displayName: this.#me.displayName, avatarUrl: this.#me.avatarUrl, }, spaces: (summary.spaces ?? []).map((s) => s.ref === this.#homeRef ? { ...s, name: 'Home' } : s ), }; return { recents: this.#recents(), models: [], // the web has no built-ins: the sidebar is installs installs: this.#installs.map((i): InstallEntry => ({ rkey: i.rkey, publisher: i.publisher, name: i.name, kind: i.kind, title: i.title, summary: i.summary, iconUrl: i.icon && i.pds ? storeBlobUrl(i.pds, i.publisher, i.icon) : undefined, })), open: [], windows: [], tileServerPort: 0, desktop: false, sync, }; } #push(): void { this.onState?.(this.#snapshot()); } /* ——— Backend surface ——— */ connect(_winId: string): void { queueMicrotask(() => this.#push()); } state(): Promise { return Promise.resolve(this.#snapshot()); } postBounds(): Promise { return Promise.resolve({}); } beaconBounds(): void {} winOp(): Promise { return Promise.reject(new Error('window layout lives on the desktop')); } /** A tab per document: the doc's identity rides the fragment. */ #tabResult(docRkey: string, name: string, ref = this.#homeRef): OpenResult { const home = ref === this.#homeRef; const at = home ? '' : `&ref=${encodeURIComponent(ref)}`; return { doc: { id: home ? `home:${docRkey}` : `space:${ref}#${docRkey}`, path: home ? `home:${docRkey}` : `space:${ref}#${docRkey}`, name, damaged: false, hasIcon: false, size: 0, mtime: 0, fingerprint: '', } as DocInfo, win: { id: `tab-${docRkey}`, panes: [], ratio: 0.5, createdAt: 0 }, url: `/#doc=${encodeURIComponent(docRkey)}${at}`, existing: false, tab: true, }; } async openPath(path: string): Promise { if (path.startsWith('home:')) { const rkey = path.slice(5); const doc = this.#homeDocs.find((d) => d.rkey === rkey); return this.#tabResult(rkey, doc?.name ?? 'document'); } if (path.startsWith('space:')) { const [ref, rkey] = path.slice(6).split('#'); if (!ref || !rkey) throw new Error('malformed document address'); const row = this.#recents().find((r) => r.path === path); return this.#tabResult(rkey, row?.name ?? 'document', ref); } const file = this.#pendingFiles.get(path); if (file) { this.#pendingFiles.delete(path); const imported = await importTileToDocSpace(this.#client, new Uint8Array(await file.arrayBuffer()), file.name); await this.#host.provider.pollNow(true).catch(() => {}); await this.#refreshHome(); return this.#tabResult(imported.rkey, imported.name, imported.space); } throw new Error('that document is not in your space'); } /** Creating from a model means fetching + importing + checkpointing — too slow to sit behind a silent click. The click gets its tab IMMEDIATELY (`#new=` target); the tab itself runs createFromModel with a visible "creating" state, then renames itself to the real doc address. */ fromModel(modelId: string): Promise { const install = this.#installs.find( (i) => i.rkey === modelId || `${i.publisher}/${i.name}` === modelId, ); if (!install) return Promise.reject(new Error('that model is not installed — find it in the Store')); return Promise.resolve({ doc: { id: '', path: '', name: install.title, damaged: false, hasIcon: false, size: 0, mtime: 0, fingerprint: '' } as DocInfo, win: { id: `new-${crypto.randomUUID().slice(0, 8)}`, panes: [], ratio: 0.5, createdAt: 0 }, url: `/#new=${encodeURIComponent(modelId)}`, existing: false, tab: true, }); } /** The slow half of fromModel, run inside the new tab. Every new doc is born as its own space (doc = space). */ async createFromModel(modelId: string): Promise<{ rkey: string; name: string; space?: string }> { // Installed store models carry their install rkey as the id. const install = this.#installs.find((i) => i.rkey === modelId || `${i.publisher}/${i.name}` === modelId); if (!install) throw new Error('that model is not installed — find it in the Store'); return await this.#createFromInstall(install); } async #createFromInstall(install: InstallRec): Promise<{ rkey: string; name: string; space?: string }> { const pub = await getPublished(fetch, install.pds, install.publisher, install.name); if (!pub) throw new Error('this tile is no longer published — remove it from the sidebar?'); const path = `/store/${crypto.randomUUID().slice(0, 8)}.tile`; await instantiatePublished(fetch, pub, path); const bytes = await zfs.readFile(path); await zfs.remove(path).catch(() => {}); const imported = await importTileToDocSpace(this.#client, bytes, `${install.name}.tile`); await this.#host.provider.pollNow(true).catch(() => {}); await this.#refreshHome(); return { rkey: imported.rkey, name: imported.name, space: imported.space }; } /** The desktop's native dialog, browser-shaped: an . */ pick(): Promise { return new Promise((resolve) => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.tile'; input.addEventListener('change', () => { const file = input.files?.[0]; if (!file) return resolve({ cancelled: true }); const token = `upload:${crypto.randomUUID().slice(0, 8)}`; this.#pendingFiles.set(token, file); resolve({ path: token }); }); input.addEventListener('cancel', () => resolve({ cancelled: true })); input.click(); }); } pickSave(): Promise { return Promise.resolve({ error: 'saving to disk arrives with export' }); } async importTile(name: string, bytes: ArrayBuffer): Promise { const imported = await importTileToDocSpace(this.#client, new Uint8Array(bytes), name); await this.#host.provider.pollNow(true).catch(() => {}); await this.#refreshHome(); return this.#tabResult(imported.rkey, imported.name, imported.space); } async rename(docId: string, name: string): Promise { await this.#host.rename(docId, name); await this.#refreshHome(); const doc = this.#host.doc(docId)!; return { id: docId, path: doc.path, name: doc.name, damaged: false, hasIcon: false, size: doc.tile.size, mtime: doc.tile.mtime, fingerprint: doc.tile.fingerprint, } as DocInfo; } saveAsModel(): Promise { return Promise.reject(new Error('saving models on the web arrives with the next phase')); } moveDoc(): Promise<{ oldId: string; info: DocInfo }> { return Promise.reject(new Error('documents on the web live in your space, not at paths')); } reveal(): Promise<{ ok: boolean }> { return Promise.resolve({ ok: false }); } removeRecent(): Promise { return Promise.reject(new Error('this list is your space itself — use Delete to remove a document')); } /** Delete. Doc = space: deleting a document you own deletes its space. Legacy home docs: their records go. Someone else's doc is theirs to delete, not yours. */ async deleteDoc(docId: string): Promise { if (docId.startsWith('space:')) { const [ref, rkey] = docId.slice(6).split('#'); if (authorityOf(ref!) === this.#me.did) { // Doc = space: my space goes entirely. await this.#client.deleteSpace(ref!); this.#host.provider.forgetSpace(ref!); this.#push(); return { ok: true }; } // Legacy multi-doc space owned by someone else: a doc I CREATED there // is my records to retract, even though the space is not mine. const row = this.#recents().find((r) => r.id === docId); if (row?.creator === this.#me.did && rkey) { await this.#client.deleteRecord(ref!, 'space.ziran.doc', rkey); await this.#client.deleteRecord(ref!, 'space.ziran.checkpoint', rkey).catch(() => {}); await this.#client.deleteRecord(ref!, 'space.ziran.presence', rkey).catch(() => {}); await this.#host.provider.pollNow(true).catch(() => {}); this.#push(); return { ok: true }; } throw new Error('this document belongs to someone else — only they can delete it'); } const rkey = docId.startsWith('home:') ? docId.slice(5) : docId; await this.#client.deleteRecord(this.#homeRef, 'space.ziran.doc', rkey); await this.#client.deleteRecord(this.#homeRef, 'space.ziran.checkpoint', rkey).catch(() => {}); await this.#client.deleteRecord(this.#homeRef, 'space.ziran.presence', rkey).catch(() => {}); await this.#refreshHome(); return { ok: true }; } removeModel(): Promise { return Promise.reject(new Error('that model is not installed — the sidebar is managed with its Edit button')); } /* ——— collaboration: straight through to the provider ——— */ async syncShare(docId: string, opts?: { space?: string; newSpaceName?: string }): Promise { await this.#host.provider.share(docId, opts); return { ok: true }; } syncAdopt(space: string, docRkey: string): Promise { const name = this.#host.provider.summary().spaces?.find((s) => s.ref === space) ?.available.find((d) => d.docRkey === docRkey)?.name ?? 'document'; return Promise.resolve(this.#tabResult(docRkey, name, space)); } syncRetry(): Promise { this.#host.provider.retryNow(); return Promise.resolve({ ok: true }); } async syncInvite(docId: string, did: string, role: string): Promise<{ link: string; invitation: { pending?: boolean } }> { return await this.#host.provider.invite(docId, did, role); } syncAttach(docId: string) { return this.#host.bridge.syncAttach(docId) as ReturnType; } async syncSend(docId: string, b64: string): Promise<{ seq: number }> { return { seq: await this.#host.provider.send(docId, b64) }; } syncEphemeral(docId: string, b64: string): Promise { this.#host.provider.ephemeral(docId, b64); return Promise.resolve({}); } async syncSetRole(docId: string, did: string, role: string): Promise { await this.#host.provider.setRole(docId, did, role); return { ok: true }; } async syncRemoveMember(docId: string, did: string): Promise { await this.#host.provider.removeMember(docId, did); return { ok: true }; } async acceptInvitation(token: string): Promise { // Adopt in this tab (materializes + registers), then hand the person a // proper document tab for it. const { path } = await this.#host.provider.accept(token); const summary = this.#host.provider.summary(); for (const [localId, d] of Object.entries(summary.docs)) { const doc = this.#host.doc(localId); if (doc?.path === path && d.space) { const rkey = d.syncId.split('/').pop()!; return this.#tabResult(rkey, doc.name, d.space); } } throw new Error('the invitation was accepted but the document did not surface — reload and look under By location'); } dismissInvitation(token: string): Promise { this.#host.provider.dismiss(token); return Promise.resolve({ ok: true }); } /* ——— the store ——— */ #storeView(pub: PublishedTile): StoreTileView { const icon = tileIconCid(pub.value); return { publisher: pub.publisher, name: pub.rkey, title: pub.value.title, summary: pub.value.summary, kind: pub.value.kind, iconUrl: icon ? storeBlobUrl(pub.pds, pub.publisher, icon) : undefined, screenshotUrls: tileScreenshotCids(pub.value).map((c) => storeBlobUrl(pub.pds, pub.publisher, c)), createdAt: pub.value.createdAt, installed: this.#installs.some((i) => i.publisher === pub.publisher && i.name === pub.rkey), }; } async #resolvePublisher(publisher: string): Promise<{ did: string; pds: string }> { const identity = await resolveIdentity(publisher.replace(/^@/, '')); if (!identity.pds) throw new Error(`could not find a server for ${publisher}`); return { did: identity.did, pds: identity.pds.replace(/\/+$/, '') }; } /** The whole shelf (no argument) via the index, or one publisher's tiles read straight from their PDS (canonical). The featured publishers are the fallback when the index does not answer. */ async storeList(publisher?: string): Promise { const all: StoreTileView[] = []; if (!publisher) { const indexed = await this.#storeListIndexed().catch(() => null); if (indexed) all.push(...indexed); else { for (const t of FEATURED_PUBLISHERS) { try { const { did, pds } = await this.#resolvePublisher(t); const pubs = await listPublished(fetch, pds, did).catch(() => []); all.push(...pubs.map((p) => this.#storeView(p))); } catch { // A featured handle that does not resolve (yet) is not an outage. } } } // YOUR publishes never wait on a crawl: read them straight off your // own PDS and merge — a tile published seconds ago is on the shelf. const own = await listPublished(fetch, this.#client.auth.pds, this.#me.did).catch(() => []); for (const p of own) { if (!all.some((t) => t.publisher === p.publisher && t.name === p.rkey)) { all.push(this.#storeView(p)); } } return all.sort((a, b) => a.title.localeCompare(b.title)); } const { did, pds } = await this.#resolvePublisher(publisher); const pubs = await listPublished(fetch, pds, did).catch(() => []); return pubs.map((p) => this.#storeView(p)).sort((a, b) => a.title.localeCompare(b.title)); } async #storeListIndexed(): Promise { const res = await fetch(`${INDEX_URL}/tiles`); if (!res.ok) throw new Error(`index answered ${res.status}`); const { tiles } = await res.json() as { tiles: Array<{ publisher: string; pds: string; name: string; title: string; summary?: string; kind: 'model' | 'app'; icon?: string; createdAt?: string; }>; }; return tiles.map((t) => ({ publisher: t.publisher, name: t.name, title: t.title, summary: t.summary, kind: t.kind, iconUrl: t.icon ? storeBlobUrl(t.pds, t.publisher, t.icon) : undefined, createdAt: t.createdAt, installed: this.#installs.some((i) => i.publisher === t.publisher && i.name === t.name), })); } async storeGet(publisher: string, name: string): Promise { const { did, pds } = await this.#resolvePublisher(publisher); const pub = await getPublished(fetch, pds, did, name); return pub ? this.#storeView(pub) : null; } /** Install: a record in the home space — the sidebar reads it anywhere. */ async storeInstall(publisher: string, name: string): Promise { const { did, pds } = await this.#resolvePublisher(publisher); const pub = await getPublished(fetch, pds, did, name); if (!pub) throw new Error('that tile is not published (anymore)'); await recordInstall(this.#client, { publisher: did, name, kind: pub.value.kind, title: pub.value.title, summary: pub.value.summary, icon: tileIconCid(pub.value), pds, }); await this.#refreshHome(); return { ok: true }; } async storePublish(path: string, name: string): Promise { const file = this.#pendingFiles.get(path); if (!file) throw new Error('pick a tile first'); this.#pendingFiles.delete(path); const tmp = `/publish/${crypto.randomUUID().slice(0, 8)}.tile`; await zfs.writeFile(tmp, new Uint8Array(await file.arrayBuffer())); try { const pub = await publishTile( { did: this.#me.did, pds: this.#client.auth.pds, headers: () => Promise.resolve({}) }, this.#fetch, tmp, { name, uploadBlob: (bytes, mime) => this.#client.uploadBlob(bytes, mime) }, ); // Nudge the index so everyone ELSE sees it soon too (the shelf shows // your own publishes immediately regardless). fetch(`${INDEX_URL}/refresh`, { method: 'POST' }).catch(() => {}); return this.#storeView(pub); } finally { await zfs.remove(tmp).catch(() => {}); } } async installRemove(rkey: string): Promise { await removeInstall(this.#client, rkey); await this.#refreshHome(); return { ok: true }; } async installReorder(rkeys: string[]): Promise { await reorderInstalls(this.#client, rkeys); await this.#refreshHome(); return { ok: true }; } /** Open an installed tile: instantiate a fresh document from it — the same instant-tab shape fromModel uses. */ openInstall(rkey: string): Promise { const install = this.#installs.find((i) => i.rkey === rkey); if (!install) return Promise.reject(new Error('not in your sidebar')); return Promise.resolve({ doc: { id: '', path: '', name: install.title, damaged: false, hasIcon: false, size: 0, mtime: 0, fingerprint: '' } as DocInfo, win: { id: `new-${crypto.randomUUID().slice(0, 8)}`, panes: [], ratio: 0.5, createdAt: 0 }, url: `/#new=${encodeURIComponent(rkey)}`, existing: false, tab: true, }); } /* ——— identity ——— */ atSignIn(): Promise<{ url: string }> { return Promise.reject(new Error('already signed in')); } async atSignOut(): Promise { await this.#me.signOut(); return { ok: true }; } atProfiles(dids: string[]): Promise { return getProfiles(dids.slice(0, 50).filter((d) => d.startsWith('did:'))).catch(() => []); } atSearch(q: string): Promise { return searchActors(q).catch(() => []); } /* ——— tile self-storage ——— */ loadData(docId: string, name: string): Promise { return this.#host.bridge.loadData(docId, name); } putData(docId: string, name: string, data: Uint8Array): Promise { return this.#host.bridge.putData(docId, name, data); } }