Skip to content

Installable crafts (design, phase 1) ​

Status: phase 1 implemented — src/core/craft-packages/, the craft panel's manager dialog, craftsmith pack, and packages/desktop/src-tauri/src/crafts.rs. Verified in Chromium (install → reload → open → canvas input → Watson tool import → uninstall). Not yet verified in WKWebView or WebView2 (see step 1 below).

Goal ​

Today every craft ships inside the app build. We want crafts to work like VS Code extensions: a user gets a craft package, installs it, and after a reload it appears in the craft panel (and Watson can use its tools). A reload after install or uninstall is fine.

  • Desktop: installed crafts live on disk under the app data directory.
  • Web: installed crafts live in browser storage (IndexedDB, see below).

Phase 1 scope ​

In:

  • A .craft package format, produced by craftsmith pack.
  • Install from a local file the user picks (file picker / drag and drop).
  • List and uninstall installed crafts. Updating = installing a newer version over the old one.
  • Installed crafts load in the craft panel and expose their tools to Watson, the same as built-in crafts.
  • Full trust, with a warning at install time (see Trust).

Out (later phases):

  • A marketplace / remote index, install from URL, auto-update.
  • Signing and publisher verification.
  • A sandbox (cross-origin iframe + message bridge).
  • The headless runtime (rtJs) for installed crafts on Node.

How crafts load today ​

These facts constrain the design:

  1. The craft list is fixed at build time. crafts.config.json → vite.config.ts → __CRAFT_TOOLS__ / __DEFAULT_CRAFT__. The panel selector, win.installedCrafts, and the WebCraftStore id list all come from that constant (src/components/craft-panel/index.tsx).
  2. A craft is served at /<craftId>/ from public/ (its copy-to-public script). The iframe loads /<craftId>/index.html?lang=….
  3. The host injects its API straight into the iframe's window (iframe.contentWindow.workbook = …, onCanvasInput, setCraftState, …). That only works if the craft page is same-origin with the app.
  4. onCanvasInput handlers return a boolean synchronously to say whether they consumed the event. This cannot cross a postMessage boundary — the main reason a sandbox is not phase 1.
  5. Identity is the craft id string. callerRegistry.getCraftUuid(craftId) decides block ownership; craft state is keyed by craftId; Watson namespaces tools as <craftId>__<tool>.
  6. Watson already loads tool modules without a server path.importSelfContained in src/components/watson/lib/craft-store-web.ts fetches tools.js and imports it from a blob URL. InstalledCraftStore is already an interface.
  7. A built craft is small and self-contained: index.html, one bundled <name>.js, manifest.json, tools.js — around 50 KB.

Package format ​

A .craft file is a zip of the craft's built output:

manifest.json      required — generated by craftsmith build
index.html         required if the craft has a UI (manifest.url)
<name>.js          the craft bundle
tools.js           if manifest.tools is non-empty
<other assets>     images, fonts, … referenced from index.html

craftsmith pack <dir> runs the same checks as craftsmith check, then zips dist/ plus index.html. It refuses to pack if a check fails.

Manifest additions ​

CraftManifest (packages/craftsmith/src/manifest.ts, mirrored in packages/logician/src/crafts/manifest.ts) gains:

FieldTypeMeaning
hostApinumberLowest host API version the craft needs. The host refuses newer ones.
filesstring[]Every file in the package, package-relative. The loader serves only these.
publisherstring?Free text, shown in the install dialog. Not verified in phase 1.
descriptionstring?Shown in the install dialog and the manage list.

The host exposes one constant, HOST_API_VERSION (starts at 1), bumped when the injected API changes incompatibly.

Validation (on install) ​

Done in TypeScript, once, on both platforms:

  • manifest.json parses and schemaVersion === 1.
  • craftId is kebab-case, at most 32 characters (Watson's tool name limit is 64 including the <craftId>__ prefix). Kebab-case already rules out __.
  • craftId is not a built-in craft id. Built-ins always win.
  • hostApi <= HOST_API_VERSION.
  • Every path in files is relative, has no .., no leading /, no \, and exists in the zip. Files in the zip that are not listed are dropped.
  • url / tools.js entries exist.
  • Size caps: 5 MB per file, 20 MB per package.

Installing an id that is already installed replaces it (that is how updates work). The dialog shows old and new versions; a downgrade asks again.

Storage ​

One contract, two backends — the same split as craft-storage.

ts
// src/core/craft-packages/index.ts
export interface InstalledCraftRecord {
    craftId: string
    version: string
    manifest: CraftManifest
    installedAt: number
}

export interface CraftPackageStore {
    list(): Promise<InstalledCraftRecord[]>
    install(manifest: CraftManifest, files: Map<string, Uint8Array>): Promise<void>
    remove(craftId: string): Promise<void>
    readFile(craftId: string, path: string): Promise<Uint8Array | undefined>
}

The zip is unpacked in TypeScript (fflate, ~8 KB) on both platforms, so validation lives in one place. The backend only stores files.

Web: IndexedDB, not localStorage ​

localStorage is the wrong place for this:

  • About 5 MB per origin, and that quota is already shared with craft-storage (src/core/craft-storage/local-storage.ts), Watson's settings, and the locale.
  • Strings only — binary assets would need base64 (+33%).
  • Synchronous; reading a craft blocks the main thread.

IndexedDB has none of these limits. Database logisheets-crafts, two object stores: crafts (key craftId → InstalledCraftRecord) and files (key [craftId, path] → Uint8Array). Install is one transaction across both, so a failed install leaves nothing behind.

Call navigator.storage.persist() on first install so the browser is less likely to evict installed crafts under storage pressure.

Desktop: app data directory ​

Layout: <app_data_dir>/crafts/<craftId>/ holding the package files plus record.json. Tauri commands in packages/desktop/src-tauri/src/crafts.rs:

CommandArgsNotes
craft_pkg_list—Reads every record.json.
craft_pkg_installrecord, files: [{path, data}]data is base64. Writes crafts/.staging-<id>/, renames over crafts/<id>/.
craft_pkg_readcraftId, pathReturns base64, or null.
craft_pkg_removecraftIdDeletes the directory.

Rust re-checks every path (relative, no .., stays under crafts/<id>/ after canonicalisation) even though TypeScript already did. The webview is not the only thing that can call invoke.

Loading an installed craft ​

Same-origin via blob URLs ​

The craft page must be same-origin (fact 3). Options considered:

  • Service worker serving /__crafts/<id>/* from IndexedDB. Works on web, but service workers are not supported on custom schemes, which is what Tauri uses on macOS (tauri://localhost).
  • Tauri custom protocol / asset://. A different origin from the app, so injection breaks.
  • Blob URLs. A blob URL's document has the origin of the page that made it, so the iframe stays same-origin. Works the same on both platforms. ✅

Loader steps (src/core/craft-packages/loader.ts):

  1. Read index.html and every other file through CraftPackageStore.
  2. Make a blob URL for each non-HTML file, with a MIME type from its extension.
  3. Rewrite relative src / href attributes in index.html (script, link, img, source) to those blob URLs. Use DOMParser, not regexes.
  4. Prepend to <head>, in order: <base href="https://craft.invalid/">, an import map from https://craft.invalid/<path> to each blob URL (so an inline module's import './tools.js' still resolves), and <script>window.locale = "<tag>"</script> — this replaces ?lang=, which a blob URL cannot carry.
  5. Make a blob URL for the rewritten HTML and give it to the iframe.
  6. Revoke all of them when the panel switches craft or closes.

The host still runs inject() on the iframe's load event, unchanged.

Constraints this puts on crafts ​

To document in writing-a-craft.md:

  • Read the language from window.locale first, then ?lang=. Built-in crafts read ?lang= for the first paint; installed ones get window.locale before any of their scripts run. Existing crafts call pickLocale(tag || urlLocale()); that becomes pickLocale(tag || window.locale || urlLocale()).
  • Reference assets only from index.html, by relative path. A runtime fetch('./data.json') will not resolve. Bundle data into the JS instead.
  • No location-based routing. location.href is a blob URL.

craftId is no longer the iframe src ​

The host keys a craft by /<craftId>/index.html everywhere — craft state in saved workbooks, block ownership, the active craft for canvas input. An installed craft keeps that same key, so all of it works unchanged; only the iframe URL differs (/<id>/index.html?lang=… for built-ins, the loader's blob URL for installed crafts). Deep links resolve against the registry, so ?craft=<id> opens an installed craft too.

Watson ​

WebCraftStore takes a second file source for installed ids: get() reads manifest.json and load() reads tools.js through CraftPackageStore, then imports from a blob URL exactly like importSelfContained does. Built-in ids keep using fetch. createAppCraftStore() builds it from the registry for both Watson and the panel.

Registry at startup ​

A new CraftRegistry (src/core/craft-packages/registry.ts) replaces direct use of __CRAFT_TOOLS__:

entries = builtins (from __CRAFT_TOOLS__)
        + installed (from CraftPackageStore.list(), skipping any built-in id)

It is loaded once, before the craft panel mounts, and never changes while the app runs — that is what "takes effect after a reload" means. The panel selector, win.installedCrafts and WebCraftStore all read from it. Installed crafts appear in every distribution; crafts.config.json governs built-ins only.

UI ​

A puzzle-piece button in the craft panel header opens Installed crafts: the installed list with Uninstall, and Install from file… (a .craft file, or drop one on the dialog). Installing first shows id, version (old → new on an update, with a warning on a downgrade), publisher, description, the AI tools and the trust warning. Any change offers Reload now. Strings live under ui.craft.manage.* in both locale files.

Trust ​

Phase 1 installed crafts run with the same power as built-in crafts, and that power is total. A same-origin iframe can reach window.parent, so an installed craft can:

  • read Watson's API keys from localStorage;
  • call any Tauri command through the parent window — including llm_fetch, which is an HTTP client without CORS — and read other crafts' craft-storage;
  • write to the workbook without going through wrapWorkbookForCraft;
  • claim another craft's id and so its block ownership, if that craft is not installed.

This is the same deal VS Code extensions get. Phase 1 accepts it because:

  • install is only from a file the user picked, never from a link or page;
  • the install dialog says plainly that the craft gets full access to the app, the open workbook, and saved API keys, and asks for confirmation;
  • built-in ids cannot be taken over.

The real fix is a sandbox: a cross-origin iframe with a postMessage RPC bridge. It needs onCanvasInput to stop being synchronous (fact 4) and the injected workbook object to become a proxy, so it is its own phase.

Uninstall and workbook data ​

Uninstalling removes the craft's files only.

  • Craft state saved in a workbook stays there; reinstalling the craft picks it up again.
  • craft-storage entries for the craft are left alone in phase 1.
  • Open question: blocks the craft owns stay owned, and with the owner gone the user may be unable to edit them. Either let the user take ownership of blocks whose owner is not installed, or warn at uninstall.

Implementation order ​

Done: 2–7 and 9. Step 1 is done for Chromium only. Step 8 was checked by hand in the dev server; the Playwright test is not written yet.

  1. Spike — confirm a blob-URL iframe is same-origin and inject() works in Chromium, WKWebView (Tauri macOS) and WebView2 (Tauri Windows). If any fails, fall back to srcdoc with every script inlined.
  2. Manifest fields + craftsmith pack; pack lights-out as the test fixture.
  3. CraftPackageStore contract, validation, IndexedDB backend, unit tests (path traversal, missing entry, built-in id clash, size caps, bad hostApi).
  4. Loader + CraftRegistry; switch the panel from iframeSrc to craftId.
  5. WebCraftStore reads installed crafts.
  6. Install / manage UI + i18n.
  7. Tauri commands + backend.
  8. e2e: install the lights-out fixture under a different id, reload, open it, click a cell, uninstall.
  9. Update writing-a-craft.md with the constraints above.