// Ziran — a local-first document environment built on self-editing DASL tiles. // // Under `deno desktop`, Deno.serve() auto-binds to the runtime-assigned // 127.0.0.1 port (DENO_SERVE_ADDRESS) and the CEF window opens on it. Run // with plain `deno run -A main.ts` and it binds 127.0.0.1:4180 for a regular // browser instead. import { join } from '@std/path'; import { createHandler } from './src/server/api.ts'; import { ZiranStore } from './src/server/store.ts'; import { RelayProvider } from './src/server/sync.ts'; import type { CollabProvider } from './src/sync/provider.ts'; import { SpacesProvider } from './src/sync/spaces/provider.ts'; import { passwordSession, type SessionAuth, SpaceClient } from './src/sync/spaces/client.ts'; import { startTileServer } from './src/server/tileserver.ts'; import { focusWindow, hasDesktopWindows, openDocWindow, setupMainWindow } from './src/server/windows.ts'; import { apiKeyFile, appDataDir, appPortFile, documentsDir, spacesRegistryFile } from './src/server/paths.ts'; // Deep links (ziran://…) and .tile files invoke the app as a NEW process // (that is how OS file/URL associations work). When an instance is already // running, hand the arguments to it and exit before anything boots — the // person clicked a link, not "launch a second copy of Ziran". const handOffArgs = Deno.args.filter((a) => a.startsWith('ziran://') || a.endsWith('.tile')); if (handOffArgs.length && (await handOffToRunningInstance(handOffArgs))) { Deno.exit(0); } // A plain double-launch (no arguments) must not boot a second copy over the // same data dir either: both copies would share one OAuth session store, and // refresh tokens are single-use — the two instances take turns consuming // each other's, until the library gives up and deletes the session ("the // session was deleted by another process", in the person's face). Raise the // running instance and bow out. Dev harnesses that genuinely want several // instances point ZIRAN_DATA_DIR elsewhere, or set // ZIRAN_ALLOW_SECOND_INSTANCE=1. if ( !handOffArgs.length && Deno.env.get('ZIRAN_ALLOW_SECOND_INSTANCE') !== '1' && (await raiseRunningInstance()) ) { console.log('Ziran is already running — raised its window instead of starting a second copy.'); Deno.exit(0); } async function instanceAddress(): Promise<{ key: string; port: number } | null> { try { const key = Deno.env.get('ZIRAN_KEY') ?? (await Deno.readTextFile(apiKeyFile())).trim(); const port = Number((await Deno.readTextFile(appPortFile())).trim()); if (!Number.isInteger(port) || port <= 0) return null; return { key, port }; } catch { return null; // first run: nothing is running } } async function raiseRunningInstance(): Promise { const addr = await instanceAddress(); if (!addr) return false; const res = await fetch(`http://127.0.0.1:${addr.port}/api/raise`, { method: 'POST', headers: { 'x-ziran-key': addr.key }, signal: AbortSignal.timeout(3_000), }).catch(() => null); if (!res) return false; await res.body?.cancel(); return res.ok; // 401 = not our instance (stale port file): boot normally } async function handOffToRunningInstance(args: string[]): Promise { const addr = await instanceAddress(); if (!addr) return false; const { key, port } = addr; let reached = false; for (const arg of args) { const res = await fetch(`http://127.0.0.1:${port}/api/deeplink`, { method: 'POST', headers: { 'x-ziran-key': key, 'content-type': 'application/json' }, body: JSON.stringify({ arg }), signal: AbortSignal.timeout(5_000), }).catch(() => null); if (!res) return reached; // nobody there (or it just died): boot normally await res.body?.cancel(); if (res.status === 401) return false; // not our instance: boot normally // 2xx or an app-level error: the running instance took it and will say // whatever needs saying in its own window. reached = true; } return reached; } const store = new ZiranStore(); await store.load(); store.desktop = hasDesktopWindows(); if (store.desktop) { store.openOsWindow = openDocWindow; store.focusOsWindow = focusWindow; } const { AtSession } = await import('./src/server/atsession.ts'); const at = new AtSession(); // The collaboration provider (docs/SYNC-SPACES.md): AT Proto Spaces by // default — every member's contribution lives in their own PDS under a // standard protocol, no Ziran infrastructure in the critical path. The // relay+MLS stack stays intact behind ZIRAN_SYNC=relay (the E2EE return // path; see SYNC-DESIGN.md). const syncMode = Deno.env.get('ZIRAN_SYNC') === 'relay' ? 'relay' : 'spaces'; const sync: CollabProvider = syncMode === 'spaces' ? makeSpacesProvider() : new RelayProvider(store); store.syncSummary = () => sync.summary(); store.onDocMoved = (oldId, newId, newPath) => sync.moved(oldId, newId, newPath); function makeSpacesProvider(): SpacesProvider { // Env credentials (dev, tests, alpha machines): a legacy password session — // the space endpoints accept ACCESS_FULL tokens. Otherwise the OAuth // sign-in's session signs own-PDS calls (its scope may not cover the space // endpoints yet; a refusal shows up in the panel rather than crashing). const envHandle = Deno.env.get('ZIRAN_SPACES_HANDLE'); const envPassword = Deno.env.get('ZIRAN_SPACES_PASSWORD'); const envPds = Deno.env.get('ZIRAN_SPACES_PDS'); const envAuth = Boolean(envHandle && envPassword && envPds); let cached: { auth: SessionAuth; at: number } | null = null; let oauthFetch: typeof fetch | undefined; return new SpacesProvider({ host: { doc: (id) => store.doc(id), openPath: (p) => store.openPath(p), isOpen: (id) => store.open.has(id), broadcast: () => store.broadcast(), broadcastEvent: (e) => store.broadcastEvent(e), }, auth: async () => { if (envAuth) { // Access JWTs age out (~2h): re-mint well inside that. if (!cached || Date.now() - cached.at > 45 * 60 * 1000) { cached = { auth: await passwordSession(envPds!, envHandle!, envPassword!), at: Date.now() }; } oauthFetch = undefined; return cached.auth; } if (at.signedIn && at.did && at.pds) { const fetchImpl = at.spacesFetch(); if (fetchImpl) { oauthFetch = fetchImpl; return { did: at.did, pds: at.pds.replace(/\/+$/, ''), headers: () => Promise.resolve({}) }; } } return null; }, clientFor: (auth) => new SpaceClient({ auth, fetch: oauthFetch }), registryFile: spacesRegistryFile(), documentsDir: documentsDir(), atInfo: () => at.signedIn ? { signedIn: true, handle: at.handle ?? undefined, did: at.did ?? undefined, displayName: at.displayName ?? undefined, hasAvatar: Boolean(at.avatar), } : { signedIn: false, staleScope: at.staleScope, sessionLost: at.sessionLost }, devIdentity: () => envAuth, handle: () => envHandle ?? at.handle ?? undefined, }); } // 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); // Bind the main server before ANY other listener: under deno desktop the // runtime probes a free port for DENO_SERVE_ADDRESS and releases it, so a // listener started earlier can be handed that exact port by the OS and // Deno.serve() then dies with AddrInUse. const server = Deno.env.get('DENO_SERVE_ADDRESS') ? Deno.serve(handler) : Deno.serve( { hostname: '127.0.0.1', port: Number(Deno.env.get('ZIRAN_PORT') ?? 4180) }, handler, ); const port = (server.addr as Deno.NetAddr).port; store.appPort = port; // Recorded so a later invocation (deep link, file open) finds this instance. await Deno.writeTextFile(appPortFile(), String(port)).catch(() => {}); const mainUrl = `http://127.0.0.1:${port}/?key=${store.apiKey}`; console.log(`ziran on ${mainUrl}`); // Deno's node-compat listeners honor DENO_SERVE_ADDRESS too; without this the // express tile server would try to bind the exact same address. Deno.env.delete('DENO_SERVE_ADDRESS'); // Claim and navigate the main window the moment the server can answer it — // before the tile server, relay, and AT session restore (which can hit the // network). Everything later announces itself over the window's socket, so // nothing below needs to finish first; making the window wait on it all is // what made launches feel endless. setupMainWindow({ onClosed: (winId) => { if (winId !== 'main') store.closeWindow(winId); }, onBounds: (winId, b) => store.updateBounds(winId === 'main' ? 'base' : winId, b), // A window's page is alive while its socket is connected — the one signal // the keeper loop navigates on. Strictly per-window: a restored document // window's page says nothing about the base window's. pageAlive: (winId) => store.hasLiveSockets(winId), onOpenTile: async () => { const { pickOpen } = await import('./src/server/dialogs.ts'); const picked = await pickOpen(); if (!('path' in picked) || !picked.path) return; try { const info = await store.openPath(picked.path); const shown = store.windows.some((w) => w.panes.some((p) => p.tabs.includes(info.id))); if (!shown) store.createWindow([info.id]); } catch (err) { console.warn('could not open picked tile:', (err as Error).message); } }, }, mainUrl); store.tileServerPort = await startTileServer(); store.broadcast(); console.log(`tile server on http://load.localhost:${store.tileServerPort}/`); // AT sign-in restores any previous session before sync connects, so the // backend handshake uses the real identity when there is one. try { await at.init(port); } catch (err) { console.warn('AT sign-in unavailable:', (err as Error).message); } if (sync instanceof RelayProvider) { // Collaboration relay. Signed in, you talk to the hosted relay — an invite // link is only shareable when it points somewhere the invitee can also // reach. Signed out (dev identities), a local embedded relay keeps solo and // same-machine work going. ZIRAN_RELAY overrides both. const HOSTED_RELAY = 'wss://relay.ziran.space/ws'; const localRelayUrl = await (async () => { const relayPort = 4191; const url = `ws://127.0.0.1:${relayPort}/ws`; if (Deno.env.get('ZIRAN_RELAY')) return url; // overridden; don't bind ports try { const { startRelay, standardVerifier } = await import('./relay/main.ts'); await startRelay({ port: relayPort, dataDir: join(appDataDir(), 'relay'), // The same door the hosted relay uses: atproto service-auth JWTs for // signed-in people, dev HMAC tokens otherwise. verifier: standardVerifier(), }); console.log(`embedded relay on ${url}`); } catch { // 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:${relayPort}/health`, { signal: AbortSignal.timeout(1500) }) .then((r) => r.ok, () => false); console.log( relay ? `joining the relay already running on ${url}` : `port ${relayPort} is taken by something that is not a Ziran relay — live sessions are paused`, ); } return url; })(); sync.init( () => Deno.env.get('ZIRAN_RELAY') ?? (at.signedIn ? HOSTED_RELAY : localRelayUrl), at, ).catch((err) => console.warn('sync init failed:', (err as Error).message)); } else if (sync instanceof SpacesProvider) { at.onChange = () => sync.retryNow(); sync.init().catch((err) => console.warn('spaces sync init failed:', (err as Error).message)); } // Reopen last session's document windows (desktop mode only; in a browser, // popup rules make silent restores hostile). await store.restoreSession(); // First-run OS integration (Windows/Linux; macOS is baked into the bundle). if (store.desktop) { const { registerFileTypes } = await import('./src/server/register.ts'); registerFileTypes(); } // Files and ziran:// links passed on the command line (file/URL associations // invoke us this way on Windows/Linux; macOS open-events are a known gap — // see ARCHITECTURE.md). for (const arg of Deno.args) { const invite = arg.match(/^ziran:\/\/invite\/([0-9a-f]+)$/); if (invite) { // Accept = download first, verify, then join the live session. sync.accept(invite[1]!) .then(async ({ path }) => { const info = await store.openPath(path); store.createWindow([info.id]); }) .catch((err) => console.warn('could not accept invitation:', (err as Error).message)); continue; } if (arg.startsWith('ziran://space?') && sync instanceof SpacesProvider) { const params = new URL(arg).searchParams; const ref = params.get('ref'); const docRkey = params.get('doc'); if (ref && docRkey) { sync.adopt(ref, docRkey) .then(async ({ path }) => { const info = await store.openPath(path); store.createWindow([info.id]); }) .catch((err) => console.warn('could not fetch from the space link:', (err as Error).message)); } continue; } if (!arg.endsWith('.tile')) continue; try { const info = await store.openPath(await Deno.realPath(arg)); if (!store.windows.some((w) => w.panes.some((p) => p.tabs.includes(info.id)))) { store.createWindow([info.id]); } } catch (err) { console.warn(`could not open ${arg}:`, (err as Error).message); } }