// Open the viewer. Preferred path = glimpse's native WebView host (on Windows // its .NET 8 + WebView2 binary, built into node_modules at install/first use). // We keep native window chrome (resizable/maximizable) and deliver the page via // NavigateToString (setHTML) when it fits, falling back to a file:// temp file // (loadFile) only for oversized pages — see present() for the why. If glimpse's // backend is unavailable, fall back to serving the SAME HTML over a local // server + the default browser, so `scratch ui` always works. import { spawn, spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { type Pad, resolveEntryPath } from "../discovery.ts"; import type { IO } from "../commands.ts"; import { bold, cyan, dim, note, ok } from "../colors.ts"; import { loadConfig, saveConfig } from "../config.ts"; import { type FileEntry, type Manifest, readManifest, sanitizeComments, writeManifest } from "../manifest.ts"; import { isExcalidrawPath, parseScene } from "../excalidraw.ts"; import { createReloader, type Reloader } from "./reload.ts"; import { buildLinkedView, type FileView, hrefToAbs } from "./render.ts"; import type { Watcher } from "./watch.ts"; // Persist a settings payload posted by the viewer page (WebView2 postMessage or // POST /settings). saveConfig sanitizes field-by-field, so untrusted/extra keys // in the payload are simply dropped. async function persistViewerSettings(payload: unknown, io: IO): Promise { if (!payload || typeof payload !== "object") return; try { await saveConfig(payload as Parameters[0]); } catch (e) { note(io, `saving settings failed (${(e as Error).message.split("\n")[0]}).`); } } // Persist a file's inline comments posted by the viewer page (WebView2 // postMessage or POST /comments) — the manifest-writeback mirror of // persistViewerSettings. The pad is identified by its dir (a pad's identity), // the file by its manifest path; the comment array replaces the entry's // wholesale. Comments are sanitized with the same rules the parser applies, so // a hostile/buggy page can't write malformed entries. The manifest is re-read // from disk first so we never clobber metadata edited while the viewer is open // (last write wins only on the comments themselves). export async function persistFileComments(pads: Pad[], payload: unknown, io: IO): Promise { if (!payload || typeof payload !== "object") return; const p = payload as { padDir?: unknown; filePath?: unknown; comments?: unknown }; if (typeof p.padDir !== "string" || typeof p.filePath !== "string") return; const pad = pads.find((x) => x.dir === p.padDir); if (!pad) return; try { const m = await readManifest(pad.dir); const entry = m.files.find((f) => f.path === p.filePath); if (!entry) return; const comments = sanitizeComments(p.comments); if (comments.length > 0) entry.comments = comments; else delete entry.comments; await writeManifest(pad.dir, m); } catch (e) { note(io, `saving comments failed (${(e as Error).message.split("\n")[0]}).`); } } // Set or clear a file's hidden flag in its pad manifest, posted by the viewer // page (WebView2 __scratch_hide / POST /hide) — metadata-only, the mirror of // persistFileComments. A hidden entry stays registered but never reaches the // viewer (render.ts filters it). Ctrl+Alt+H toggles: on a visible file it hides // (the default, hidden omitted); on a session-revealed hidden file it sends // hidden:false to unhide for good. The manifest is re-read from disk first so // concurrent metadata edits aren't clobbered. Returns the validated target when // the write went through (so callers can sync session state — the reveal // conceal in launchViewer), null otherwise. export async function persistFileHidden( pads: Pad[], payload: unknown, io: IO, ): Promise<{ padDir: string; filePath: string } | null> { try { const t = await resolveEntryTarget(pads, payload); if (!t) return null; if (t.p.hidden === false) delete t.entry.hidden; else t.entry.hidden = true; await writeManifest(t.pad.dir, t.manifest); return { padDir: t.pad.dir, filePath: t.entry.path }; } catch (e) { note(io, `hiding file failed (${(e as Error).message.split("\n")[0]}).`); return null; } } // The single trust boundary for every viewer-posted write ({padDir, filePath} // payloads): the pad must be one this session scanned, the file must be a // manifest-registered entry, and the absolute path only ever comes from // resolveEntryPath (linked `src` honored). All persist* siblings start here, so // a hardening change (path normalization, case rules) lands once, not thrice. async function resolveEntryTarget( pads: Pad[], payload: unknown, ): Promise<{ p: Record; pad: Pad; manifest: Manifest; entry: FileEntry; abs: string } | null> { if (!payload || typeof payload !== "object") return null; const p = payload as Record; if (typeof p.padDir !== "string" || typeof p.filePath !== "string") return null; const pad = pads.find((x) => x.dir === p.padDir); if (!pad) return null; const manifest = await readManifest(pad.dir); const entry = manifest.files.find((f) => f.path === p.filePath); if (!entry) return null; return { p, pad, manifest, entry, abs: resolveEntryPath(pad.dir, entry) }; } // Resolve a link the viewer followed to a file NO manifest lists (WebView2 // __scratch_peek / POST /peek): {fromAbs, href}, where fromAbs is the on-disk // location of the doc holding the link (FileView.abs — a registered file or an // earlier peek alike), so relative links land where the author meant, inside // the pad or outside it. Live viewer only; nothing is registered, so the result // is a one-off preview (never in the sidebar, gone on reload). Null when the // target is not a regular file. export async function resolvePeek(payload: unknown): Promise { if (!payload || typeof payload !== "object") return null; const p = payload as { fromAbs?: unknown; href?: unknown }; if (typeof p.fromAbs !== "string" || typeof p.href !== "string") return null; const abs = hrefToAbs(p.fromAbs, p.href); return abs ? buildLinkedView(abs) : null; } // Both transports answer a peek the same way: {file} or {file: null}, a failure // noted on the console rather than surfaced as a transport error. async function peekReply(persist: HostHandlers, payload: unknown, io: IO): Promise<{ file: FileView | null }> { try { return { file: await persist.peek(payload) }; } catch (e) { note(io, `peek failed (${(e as Error).message.split("\n")[0]}).`); return { file: null }; } } // Apply a reveal request posted by the viewer page (WebView2 __scratch_reveal / // POST /reveal): one hidden file ({padDir, filePath} — a link to it was // followed; + conceal:true to undo when the user navigates away) or all of them // ({all} — the 'h' toggle). Unlike its persist* siblings this touches NO // manifest: reveals are session state on the Reloader, gone on relaunch. // Returns whether the page needs a data patch. A conceal returns false: it // delivers nothing new to the client (the page already splices the file out // itself), so it only has to land in the Reloader for the NEXT rebuild. export function applyReveal(reloader: Reloader, payload: unknown): boolean { if (!payload || typeof payload !== "object") return false; const p = payload as { padDir?: unknown; filePath?: unknown; all?: unknown; conceal?: unknown }; if (typeof p.all === "boolean") { reloader.setRevealAll(p.all); return true; } if (typeof p.padDir === "string" && typeof p.filePath === "string") { if (p.conceal === true) { reloader.conceal(p.padDir, p.filePath); return false; } reloader.reveal(p.padDir, p.filePath); return true; } return false; } // Toggle a GFM task checkbox in a file's CONTENT, posted by the viewer page // (WebView2 __scratch_checkbox / POST /checkbox). This is the ONE place the CLI // writes file content rather than just metadata — a deliberate exception to the // read-only/never-author invariant, scoped to flipping a single "[ ]"/"[x]" // marker. The edit is line-addressed: the page sends the source line index it // rendered; we re-read the file from disk, verify that line still IS a task // marker (the same regex the renderer used), and flip just that char — so a // drifted line is skipped rather than corrupted, and unrelated content (incl. // line endings elsewhere) is untouched. The file is resolved via the manifest // (linked `src` honored), so writes stay scoped to pad-registered files. const TASK_MARKER = /^(\s*(?:[-*+]\s+|#{1,6}\s+`?)\[)([ xX])(\].*)$/; export async function persistFileCheckbox(pads: Pad[], payload: unknown, io: IO): Promise { try { const t = await resolveEntryTarget(pads, payload); if (!t) return; const { line, checked } = t.p as { line?: unknown; checked?: unknown }; if (typeof line !== "number" || !Number.isInteger(line) || line < 0 || typeof checked !== "boolean") return; const raw = await readFile(t.abs, "utf8"); const eol = raw.includes("\r\n") ? "\r\n" : "\n"; const lines = raw.split(/\r?\n/); const target = lines[line]; const mt = typeof target === "string" ? target.match(TASK_MARKER) : null; if (!mt) return; // line drifted since render — skip rather than corrupt lines[line] = mt[1] + (checked ? "x" : " ") + mt[3]; await writeFile(t.abs, lines.join(eol), "utf8"); } catch (e) { note(io, `saving checkbox failed (${(e as Error).message.split("\n")[0]}).`); } } // Write an edited Excalidraw scene back to its .excalidraw file, posted by the // viewer's in-place editor (WebView2 __scratch_excalidraw / POST /excalidraw). // The SECOND content-writing exception (with persistFileCheckbox) to the // never-author invariant: the user drew the scene in the embedded editor; we // only persist the JSON it serialized. Scoped like the checkbox path — the file // resolves through the pad manifest (linked `src` honored), must still BE a // .excalidraw target, and the payload must parse as a scene (parseScene) — so // a hostile page can't write arbitrary bytes to arbitrary paths. Returns // whether the write landed, so callers can push the re-rendered SVG back. export async function persistExcalidrawScene(pads: Pad[], payload: unknown, io: IO): Promise { try { const t = await resolveEntryTarget(pads, payload); if (!t || typeof t.p.scene !== "string") return false; if (!isExcalidrawPath(t.abs)) return false; parseScene(t.p.scene); // throws unless the payload is a real scene await writeFile(t.abs, t.p.scene, "utf8"); return true; } catch (e) { note(io, `saving drawing failed (${(e as Error).message.split("\n")[0]}).`); return false; } } // Save-a-copy from the native viewer (its setHTML origin isn't a secure context, // so the page can't use showSaveFilePicker). The page hands us the export HTML; // we pop a real OS Save dialog (PowerShell on Windows, where the native host // lives) and write the file. Returns the chosen path, or null if cancelled. async function saveExportToFile(payload: unknown, io: IO): Promise { const p = (payload ?? {}) as { html?: unknown; name?: unknown }; if (typeof p.html !== "string") return null; const suggested = typeof p.name === "string" && p.name ? p.name : "scratchpad.html"; let target: string | null = null; if (process.platform === "win32") { // FileName via env var dodges all PowerShell quoting; -STA is required for the // WinForms dialog. Empty stdout = the user cancelled. const ps = [ "Add-Type -AssemblyName System.Windows.Forms | Out-Null", "$d = New-Object System.Windows.Forms.SaveFileDialog", "$d.Filter = 'HTML page (*.html)|*.html|All files (*.*)|*.*'", "$d.FileName = $env:SCRATCH_SAVE_NAME", "$d.InitialDirectory = (Get-Location).Path", "$d.Title = 'Save scratchpad export'", "if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($d.FileName) }", ].join("; "); const r = spawnSync("powershell.exe", ["-NoProfile", "-STA", "-Command", ps], { encoding: "utf8", env: { ...process.env, SCRATCH_SAVE_NAME: suggested }, }); const out = (r.stdout ?? "").trim(); if (out) target = out; } else { target = resolve(suggested); } if (!target) return null; // cancelled await writeFile(target, p.html, "utf8"); ok(io, `exported → ${cyan(target)}`); return target; } // Quit from the terminal with 'q', pager-style. Raw mode swallows Ctrl+C as // \x03, which we route through the same graceful quit — that also stops the // console from delivering CTRL_C_EVENT to the console-attached WebView2 host // (abrupt kill mid-teardown is what spews Chromium errors onto our stderr). // No-op when stdin isn't a TTY (piped/CI). Returns a cleanup function. function watchQuitKey(quit: () => void): () => void { const stdin = process.stdin; if (!stdin.isTTY || typeof stdin.setRawMode !== "function") return () => {}; const onData = (b: Buffer) => { const s = b.toString(); if (s === "q" || s === "\x03") quit(); }; stdin.setRawMode(true); stdin.resume(); stdin.on("data", onData); // Idempotent: quit paths can overlap (q + window close), and re-pausing or // un-raw-ing stdin twice must not throw mid-shutdown. let cleaned = false; return () => { if (cleaned) return; cleaned = true; stdin.off("data", onData); try { stdin.setRawMode(false); } catch {} stdin.pause(); }; } export interface LaunchOpts { title: string; /** Force the browser viewer instead of the default glimpse native window. */ forceBrowser?: boolean; /** Build the native host on demand if it's missing (never automatic). */ installNative?: boolean; /** Native window without OS chrome (title bar/border). Default true. */ frameless?: boolean; /** Auto hot-reload the viewer when watched pad files change. Default true. */ autoReload?: boolean; } /** Page → host handlers, one per message key / POST route: disk write-backs plus * the one read (peek) the page cannot do itself. */ interface HostHandlers { comments: (payload: unknown) => Promise; checkbox: (payload: unknown) => Promise; hidden: (payload: unknown) => Promise; /** Returns true when the scene was written — the caller then pushes a reload * so the page gets the freshly re-rendered SVG through the normal read path. */ excalidraw: (payload: unknown) => Promise; /** Read-only: the FileView of a linked, unregistered file, or null. */ peek: (payload: unknown) => Promise; } export async function launchViewer( pads: Pad[], rootLabel: string, io: IO, opts: LaunchOpts, ): Promise { // The reloader builds the initial page too, so its vendor-needs baseline is // primed from exactly what the launched page loaded. const reloader = createReloader(pads, rootLabel); const snap = await reloader.rebuild(); // Writeback handlers shared by both transports. Passed as one object: they're // structurally identical, so positional params would swap silently. const persist: HostHandlers = { comments: (payload) => persistFileComments(pads, payload, io), checkbox: (payload) => persistFileCheckbox(pads, payload, io), // Hiding also drops any session reveal of that file, so a revealed file that // gets re-hidden (Ctrl+Alt+H) actually disappears on the next rebuild. hidden: async (payload) => { const target = await persistFileHidden(pads, payload, io); if (target) reloader.conceal(target.padDir, target.filePath); }, excalidraw: (payload) => persistExcalidrawScene(pads, payload, io), peek: resolvePeek, }; // Native glimpse is the default; --browser forces the browser viewer. When the // native host isn't built, tryGlimpse prints how to install it and we fall back. if (!opts.forceBrowser) { const ok = await tryGlimpse( snap.html, opts.title, io, reloader, opts.frameless !== false, !!opts.installNative, persist, opts.autoReload !== false, ); if (ok) return 0; io.err("falling back to the browser viewer."); } return serveBrowser(snap.html, opts.title, io, reloader, persist, opts.autoReload !== false); } // glimpse's WebView2 host is a compiled .NET binary. On a global `bun add -g` // install Bun blocks glimpseui's postinstall (lifecycle scripts of untrusted — // and transitive — deps don't run), so the host is never built. We do NOT build // it automatically: on a plain `scratch ui` we point the user at how to install // it. `--install-native` builds it on demand (needs the .NET 8 SDK), landing it // in glimpseui's own native/windows/bin/ where it resolves the host from. // Returns true when the host is ready to use. function prepareWindowsHost(io: IO, install: boolean): boolean { let root: string; try { // glimpseui's main is src/glimpse.mjs → its package root is two dirs up. root = dirname(dirname(fileURLToPath((import.meta as any).resolve("glimpseui")))); } catch { return false; // unresolvable (e.g. compiled-binary VFS) } const hostBin = join(root, "native", "windows", "bin", "glimpse.exe"); if (existsSync(hostBin)) return true; // already built const buildScript = join(root, "scripts", "build.mjs"); if (!existsSync(buildScript)) return false; // not a real on-disk glimpseui if (!install) { note( io, "the native window isn't installed (its WebView2 host isn't built).\n" + " Build it once with `scratch ui --install-native` (needs the .NET 8 SDK),\n" + " or use `scratch ui --browser` for the browser viewer.", ); return false; } const sdk = spawnSync("dotnet", ["--list-sdks"], { encoding: "utf8" }); if (sdk.error || sdk.status !== 0 || !sdk.stdout?.trim()) { note( io, "--install-native needs the .NET 8 SDK + WebView2 runtime.\n" + " Install the SDK (https://dotnet.microsoft.com/download/dotnet/8.0), then rerun.", ); return false; } io.out(`${dim("note:")} building the native viewer host (one-time; takes a moment)…`); // Reuse glimpse's own build script so its publish flags stay authoritative. const build = spawnSync(process.execPath, [buildScript, "win32"], { cwd: root, stdio: "inherit" }); if (build.status !== 0 || !existsSync(hostBin)) { note(io, "native host build failed — check the .NET output above."); return false; } return true; } async function tryGlimpse( html: string, title: string, io: IO, reloader: Reloader, frameless: boolean, install: boolean, persist: HostHandlers, autoReload: boolean, ): Promise { // glimpseui resolves its native host relative to its own module file. Inside a // `bun build --compile` standalone that module lives in the virtual `B:\~BUN\` // FS, so the host path points nowhere and native silently falls back to the // browser. Fix: if a host is staged next to the executable (dist/glimpse/, put // there by `bun scripts/build-host.ts`), point glimpse at it via the env // override it honors (GLIMPSE_BINARY_PATH). In dev (execPath = bun.exe) there's // no sibling, so this is a no-op and glimpse resolves from node_modules. if (process.platform === "win32" && !process.env.GLIMPSE_BINARY_PATH) { const sibling = join(dirname(process.execPath), "glimpse", "glimpse.exe"); if (existsSync(sibling)) process.env.GLIMPSE_BINARY_PATH = sibling; // No staged host (npm/bun install): the host must be built. Never automatic — // recommend `--install-native`, or build now if that flag was passed. else if (!prepareWindowsHost(io, install)) return false; } let open: (html: string, options?: Record) => any; try { ({ open } = (await import("glimpseui")) as any); } catch { return false; } // The host inherits our stderr, and on an abrupt shutdown (Ctrl+C in the // terminal kills the console-attached host mid-teardown) Chromium logs e.g. // "Failed to unregister class Chrome_WidgetWin_0" onto it. WebView2 honors // this env var, so silence Chromium's logging in the host we spawn. const flag = "--disable-logging"; const extra = process.env.WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS; if (!extra?.includes(flag)) process.env.WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = extra ? `${extra} ${flag}` : flag; let win: any; try { // Open with NO initial HTML so the host emits 'ready' (instead of doing its // own NavigateToString); we then deliver the page ourselves via present(). // frameless (config-driven): drop the native title bar/border — the page // draws its own close affordance (#closeBtn) + drag strip. Override via the // user config file (ui.frameless=false) to keep native chrome. win = open("", { width: 1280, height: 800, title, frameless, }); } catch (e) { note(io, `native window unavailable (${(e as Error).message.split("\n")[0]}); using browser.`); return false; } // The native window has no address bar / terminal output of its own, so echo // what was opened — otherwise `scratch ui` looks like it did nothing. io.out(bold(title)); io.out(dim( " opened in a native window — " + (autoReload ? "edits reload automatically; " : "") + "press 'r' or the ⟳ button to reload; 'q' here (or close the window) to exit.", )); // A temp file is only needed for the loadFile fallback below, so stage it // lazily — most pages (CDN-vendored) go the setHTML path and never touch disk. let dir: string | null = null; let htmlPath = ""; const cleanupTmp = () => { if (dir) void rm(dir, { recursive: true, force: true }).catch(() => {}); }; // Prefer setHTML (NavigateToString): renders at the correct monitor DPI and // needs no temp file. But it throws past ~2MB and crashes the host (unhandled // at Program.cs:233), so cap conservatively and fall back to a file:// load // (no size limit) for oversized pages. CDN vendoring keeps pages small. const NAV_LIMIT = 1_800_000; const present = async (h: string) => { if (Buffer.byteLength(h, "utf8") < NAV_LIMIT) { win.setHTML(h); return; } if (!dir) { dir = await mkdtemp(join(tmpdir(), "scratch-ui-")); htmlPath = join(dir, "viewer.html"); } await writeFile(htmlPath, h, "utf8"); win.loadFile(htmlPath); }; // The host re-emits 'ready' on EVERY navigation (glimpse.mjs case 'ready'), so // present exactly once — otherwise each setHTML/loadFile retriggers ready → // present → an infinite reload loop. let presented = false; win.on("ready", () => { if (presented) return; presented = true; void present(html); }); return new Promise((resolve) => { let settled = false; const stopKeys = watchQuitKey(() => { try { win.close(); // graceful: host disposes WebView2, then 'closed' fires below } catch {} }); // Manual reload: the page posts {__scratch_reload:true} (its reload button / // 'r' key). We rebuild from disk and push a fresh payload — an in-place data // patch via __scratchReload (which only re-renders the open file if it // actually changed), or, when new vendor bundles are needed, a full re-render // delivered through present() (setHTML, or loadFile if oversized). // Rebuild from disk and push to the page — an in-place data patch, or a full // re-render via present() when new vendor bundles are needed (so highlighting/ // diagrams added since launch load their script). Shared by manual reload ('r') // and the hard-refresh data sync; quiet suppresses the page's reload toast. const pushReload = async (quiet: boolean) => { const s = await reloader.rebuild(); if (s.full) await present(s.html); else win.send(`window.__scratchReload(${s.payloadJson}${quiet ? ", true" : ""})`); }; win.on("message", async (d: any) => { if (d && d.__scratch_settings) { await persistViewerSettings(d.__scratch_settings, io); return; } // Comment mutations from the page (add/edit/delete) — write the file's // comment array back into its pad manifest. if (d && d.__scratch_comments) { await persist.comments(d.__scratch_comments); return; } // Task-checkbox toggle from the page — flip the "[ ]"/"[x]" in the file. if (d && d.__scratch_checkbox) { await persist.checkbox(d.__scratch_checkbox); return; } // File hidden from the page (Ctrl+Alt+H) — set the entry's hidden flag. if (d && d.__scratch_hide) { await persist.hidden(d.__scratch_hide); return; } // Edited Excalidraw scene from the in-place editor — write it back, then // push a quiet reload so the page gets the server-re-rendered SVG (the // same read path every other change flows through). Deterministic even // with autoReload off; with it on, the watcher's debounced patch merges. if (d && d.__scratch_excalidraw) { if (await persist.excalidraw(d.__scratch_excalidraw)) { try { await pushReload(true); } catch (e) { note(io, `drawing reload failed (${(e as Error).message.split("\n")[0]}).`); } } return; } // Session reveal of hidden files (a link to a hidden doc, or the 'h' // toggle). Reloader-only state — nothing is written to the manifest — // answered with a quiet data patch that now carries the hidden content. if (d && d.__scratch_reveal) { if (applyReveal(reloader, d.__scratch_reveal)) { try { await pushReload(true); } catch (e) { note(io, `reveal failed (${(e as Error).message.split("\n")[0]}).`); } } return; } // Peek at a linked, unregistered file — reply with its FileView (or null). if (d && d.__scratch_peek) { win.send(`window.__scratchPeek(${JSON.stringify(await peekReply(persist, d.__scratch_peek, io))})`); return; } // Save-a-copy: the page can't open its own save dialog (non-secure origin), // so it asks us to. Echo the result back so it can clear its dirty flag. if (d && d.__scratch_save) { try { const saved = await saveExportToFile(d.__scratch_save, io); win.send(`window.__scratchSaved(${JSON.stringify({ saved: saved != null, path: saved })})`); } catch (e) { note(io, `save failed (${(e as Error).message.split("\n")[0]}).`); win.send(`window.__scratchSaved(${JSON.stringify({ saved: false })})`); } return; } // A native WebView2 reload (Ctrl+R/F5) re-renders the HTML string we // presented at launch, whose embedded #settings island is frozen at // launch-time config. The reloaded page asks us for the authoritative // config so it can re-apply settings saved since — keeping the config file // the single source of truth (no client-side shadow store). if (d && d.__scratch_get_settings) { try { const cfg = await loadConfig(); win.send(`window.__scratchSettings(${JSON.stringify(cfg.ui)})`); } catch (e) { note(io, `settings sync failed (${(e as Error).message.split("\n")[0]}).`); } return; } // Hard-refresh DATA sync — sibling of __scratch_get_settings. A native // Ctrl+R/F5 re-renders the launch-time HTML (its embedded data island frozen // at launch), so the reloaded page asks us for the current pad data; we // rebuild from disk and patch it in (quiet → silent unless drifted). Keeps // disk the single source of truth on hard refresh as it is for 'r'. if (d && d.__scratch_get_data) { try { await pushReload(true); } catch (e) { note(io, `data sync failed (${(e as Error).message.split("\n")[0]}).`); } return; } if (!d || !d.__scratch_reload) return; try { await pushReload(false); } catch (e) { note(io, `reload failed (${(e as Error).message.split("\n")[0]}).`); } }); // Auto hot-reload: watch the open pads and push a quiet in-place patch when a // file changes on disk (debounced in the watcher). pushReload already routes // vendor growth through a full re-present, so this reuses the manual path. const watcher: Watcher | null = autoReload ? reloader.watch(() => { void pushReload(true).catch((e) => note(io, `auto-reload failed (${(e as Error).message.split("\n")[0]}).`), ); }) : null; win.on("error", (e: Error) => { if (!settled) { settled = true; stopKeys(); watcher?.close(); cleanupTmp(); note(io, `native window failed (${e.message.split("\n")[0]}); using browser.`); resolve(false); } }); win.on("closed", () => { if (!settled) { settled = true; stopKeys(); watcher?.close(); cleanupTmp(); resolve(true); } }); }); } async function serveBrowser( html: string, title: string, io: IO, reloader: Reloader, persist: HostHandlers, autoReload: boolean, ): Promise { // The browser transport is request/response: a manual reload rebuilds from disk // on the next GET. Auto hot-reload adds the missing push channel — an SSE stream // (/events) the page listens on, plus /data for an in-place patch — since the // server otherwise has no way to tell an open page to refresh. const encoder = new TextEncoder(); const clients = new Set(); const broadcast = (full: boolean) => { const msg = encoder.encode(`data: ${JSON.stringify({ full })}\n\n`); for (const c of clients) { try { c.enqueue(msg); } catch {} } }; // The watcher already rebuilds to learn `full`; cache that snapshot's payload so // the follow-up GET /data serves it instead of rebuilding the view a second time. let lastPayload: string | null = null; // Server-initiated push (the browser mirror of tryGlimpse's pushReload): // rebuild once, stash the payload for GET /data, and tell every open page to // come get it (or hard-reload on vendor growth). Shared by the watcher and // any handler that changes what a rebuild returns (e.g. /reveal). const pushPatch = async () => { const s = await reloader.rebuild(); lastPayload = s.payloadJson; broadcast(s.full); }; const server = Bun.serve({ port: 0, async fetch(req) { const url = new URL(req.url); // Settings write-back from the page's settings panel (no webview here). if (req.method === "POST" && url.pathname === "/settings") { await persistViewerSettings(await req.json().catch(() => null), io); return new Response(null, { status: 204 }); } // Comment write-back — browser mirror of the WebView2 __scratch_comments path. if (req.method === "POST" && url.pathname === "/comments") { await persist.comments(await req.json().catch(() => null)); return new Response(null, { status: 204 }); } // Checkbox toggle write-back — browser mirror of __scratch_checkbox. if (req.method === "POST" && url.pathname === "/checkbox") { await persist.checkbox(await req.json().catch(() => null)); return new Response(null, { status: 204 }); } // Hide-file write-back — browser mirror of __scratch_hide. if (req.method === "POST" && url.pathname === "/hide") { await persist.hidden(await req.json().catch(() => null)); return new Response(null, { status: 204 }); } // Excalidraw scene write-back — browser mirror of __scratch_excalidraw. // A landed write pushes the re-rendered SVG through the SSE patch channel. if (req.method === "POST" && url.pathname === "/excalidraw") { if (await persist.excalidraw(await req.json().catch(() => null))) { await pushPatch().catch((e) => note(io, `drawing reload failed (${(e as Error).message.split("\n")[0]}).`), ); } return new Response(null, { status: 204 }); } // Session reveal — browser mirror of __scratch_reveal. When the reveal // changes what the page should show, push it through the auto-reload // channel (SSE + /data), which every open page already listens on. if (req.method === "POST" && url.pathname === "/reveal") { if (applyReveal(reloader, await req.json().catch(() => null))) { await pushPatch().catch((e) => note(io, `reveal failed (${(e as Error).message.split("\n")[0]}).`), ); } return new Response(null, { status: 204 }); } // Peek — browser mirror of __scratch_peek; the FileView comes back in the body. if (req.method === "POST" && url.pathname === "/peek") { return Response.json(await peekReply(persist, await req.json().catch(() => null), io)); } // Auto-reload event stream. The page opens EventSource('/events'); on a // watched change we push {full}: full=true (a new vendor bundle became // necessary) → the page hard-reloads; else it fetches /data and patches in // place. The controller is tracked so broadcast() can reach every client. if (req.method === "GET" && url.pathname === "/events") { let self: ReadableStreamDefaultController | null = null; const stream = new ReadableStream({ start(controller) { self = controller; clients.add(controller); controller.enqueue(encoder.encode(": ok\n\n")); }, cancel() { if (self) clients.delete(self); }, }); return new Response(stream, { headers: { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive", }, }); } // Fresh data island for an in-place patch (no whole-document re-fetch). The // watcher stashes the payload it just rebuilt; fall back to a rebuild only if // asked before any change (e.g. a client reconnecting). if (req.method === "GET" && url.pathname === "/data") { let payload = lastPayload; if (payload == null) { try { payload = (await reloader.rebuild()).payloadJson; } catch (e) { note(io, `data sync failed (${(e as Error).message.split("\n")[0]}).`); } } return new Response(payload ?? "null", { headers: { "content-type": "application/json", "cache-control": "no-store" }, }); } let body = html; // first paint uses the prebuilt page; reloads rebuild try { body = (await reloader.rebuild()).html; } catch (e) { note(io, `rebuild failed (${(e as Error).message.split("\n")[0]}); serving last good page.`); } // no-store: the server rebuilds per request (picking up settings just // POSTed to /settings), but a cached document would let the browser serve // the launch-time page on reload — losing those changes. Force a re-fetch. return new Response(body, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store, must-revalidate", }, }); }, }); // Auto hot-reload: on a watched change, rebuild once and broadcast {full} to // every open page (debounced in the watcher). The client fetches /data (or // hard-reloads on vendor growth) — the browser mirror of the native push. const watcher: Watcher | null = autoReload ? reloader.watch(() => { void pushPatch().catch((e) => note(io, `auto-reload failed (${(e as Error).message.split("\n")[0]}).`), ); }) : null; const url = `http://localhost:${server.port}/`; io.out(bold(title)); io.out(` serving viewer at ${cyan(url)}`); io.out(dim( ` (${autoReload ? "edits reload automatically; " : ""}reload the page to refresh from disk; 'q' or Ctrl+C to stop)`, )); openBrowser(url); // Keep alive until quit from the terminal ('q'/Ctrl+C via watchQuitKey when // stdin is a TTY, plain SIGINT otherwise). await new Promise((resolve) => { // stop only ever runs after stopKeys is assigned (event-driven), so the // forward reference is safe; the cleanup itself is idempotent. const stop = () => { stopKeys(); watcher?.close(); for (const c of clients) { try { c.close(); } catch {} } io.out("\nstopped."); server.stop(); resolve(); }; const stopKeys = watchQuitKey(stop); process.on("SIGINT", stop); }); return 0; } function openBrowser(url: string): void { const p = process.platform; const [cmd, args] = p === "win32" ? ["cmd", ["/c", "start", "", url]] : p === "darwin" ? ["open", [url]] : ["xdg-open", [url]]; try { spawn(cmd, args, { stdio: "ignore", detached: true }).unref(); } catch { // Best-effort; URL was already printed. } }