Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715// 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<void>;}
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<string, { cid: string; type?: string }>(); /** Icon blob → object URL, fetched once per cid (content-addressed). */ #iconUrls = new Map<string, string>(); #iconFetching = new Set<string>(); #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<string, File>(); #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<void> { 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<void> { 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<void> { 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<string, { src?: { $link?: string; ref?: { $link?: string } }; 'content-type'?: string }>; } }).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 <img> 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<string, { docRkey: string; name: string; creator: string; createdAt?: number; icon?: { author: string; cid: string; type?: string }; }>(); 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<ServerState> { return Promise.resolve(this.#snapshot()); }
postBounds(): Promise<unknown> { return Promise.resolve({}); } beaconBounds(): void {} winOp(): Promise<unknown> { 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<OpenResult> { 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<OpenResult> { 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 <input type=file>. */ pick(): Promise<PickResult> { 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<PickResult> { return Promise.resolve({ error: 'saving to disk arrives with export' }); }
async importTile(name: string, bytes: ArrayBuffer): Promise<OpenResult> { 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<DocInfo> { 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<ModelEntry> { 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<unknown> { 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<unknown> { 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<unknown> { 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<unknown> { await this.#host.provider.share(docId, opts); return { ok: true }; }
syncAdopt(space: string, docRkey: string): Promise<OpenResult> { 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<unknown> { 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<Backend['syncAttach']>; } async syncSend(docId: string, b64: string): Promise<{ seq: number }> { return { seq: await this.#host.provider.send(docId, b64) }; } syncEphemeral(docId: string, b64: string): Promise<unknown> { this.#host.provider.ephemeral(docId, b64); return Promise.resolve({}); } async syncSetRole(docId: string, did: string, role: string): Promise<unknown> { await this.#host.provider.setRole(docId, did, role); return { ok: true }; } async syncRemoveMember(docId: string, did: string): Promise<unknown> { await this.#host.provider.removeMember(docId, did); return { ok: true }; }
async acceptInvitation(token: string): Promise<OpenResult> { // 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<unknown> { 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<StoreTileView[]> { 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<StoreTileView[]> { 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<StoreTileView | null> { 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<unknown> { 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<StoreTileView> { 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<unknown> { await removeInstall(this.#client, rkey); await this.#refreshHome(); return { ok: true }; }
async installReorder(rkeys: string[]): Promise<unknown> { 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<OpenResult> { 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<unknown> { await this.#me.signOut(); return { ok: true }; } atProfiles(dids: string[]): Promise<AtProfile[]> { return getProfiles(dids.slice(0, 50).filter((d) => d.startsWith('did:'))).catch(() => []); } atSearch(q: string): Promise<AtProfile[]> { return searchActors(q).catch(() => []); }
/* ——— tile self-storage ——— */
loadData(docId: string, name: string): Promise<Uint8Array | null> { return this.#host.bridge.loadData(docId, name); } putData(docId: string, name: string, data: Uint8Array): Promise<void> { return this.#host.bridge.putData(docId, name, data); }}