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
.craftpackage format, produced bycraftsmith 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:
- The craft list is fixed at build time.
crafts.config.json→vite.config.ts→__CRAFT_TOOLS__/__DEFAULT_CRAFT__. The panel selector,win.installedCrafts, and theWebCraftStoreid list all come from that constant (src/components/craft-panel/index.tsx). - A craft is served at
/<craftId>/frompublic/(itscopy-to-publicscript). The iframe loads/<craftId>/index.html?lang=…. - 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. onCanvasInputhandlers return a boolean synchronously to say whether they consumed the event. This cannot cross apostMessageboundary — the main reason a sandbox is not phase 1.- Identity is the craft id string.
callerRegistry.getCraftUuid(craftId)decides block ownership; craft state is keyed bycraftId; Watson namespaces tools as<craftId>__<tool>. - Watson already loads tool modules without a server path.
importSelfContainedinsrc/components/watson/lib/craft-store-web.tsfetchestools.jsand imports it from a blob URL.InstalledCraftStoreis already an interface. - 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.htmlcraftsmith 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:
| Field | Type | Meaning |
|---|---|---|
hostApi | number | Lowest host API version the craft needs. The host refuses newer ones. |
files | string[] | Every file in the package, package-relative. The loader serves only these. |
publisher | string? | Free text, shown in the install dialog. Not verified in phase 1. |
description | string? | 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.jsonparses andschemaVersion === 1.craftIdis kebab-case, at most 32 characters (Watson's tool name limit is 64 including the<craftId>__prefix). Kebab-case already rules out__.craftIdis not a built-in craft id. Built-ins always win.hostApi <= HOST_API_VERSION.- Every path in
filesis relative, has no.., no leading/, no\, and exists in the zip. Files in the zip that are not listed are dropped. url/tools.jsentries 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.
// 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:
| Command | Args | Notes |
|---|---|---|
craft_pkg_list | — | Reads every record.json. |
craft_pkg_install | record, files: [{path, data}] | data is base64. Writes crafts/.staging-<id>/, renames over crafts/<id>/. |
craft_pkg_read | craftId, path | Returns base64, or null. |
craft_pkg_remove | craftId | Deletes 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):
- Read
index.htmland every other file throughCraftPackageStore. - Make a blob URL for each non-HTML file, with a MIME type from its extension.
- Rewrite relative
src/hrefattributes inindex.html(script,link,img,source) to those blob URLs. UseDOMParser, not regexes. - Prepend to
<head>, in order:<base href="https://craft.invalid/">, an import map fromhttps://craft.invalid/<path>to each blob URL (so an inline module'simport './tools.js'still resolves), and<script>window.locale = "<tag>"</script>— this replaces?lang=, which a blob URL cannot carry. - Make a blob URL for the rewritten HTML and give it to the iframe.
- 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.localefirst, then?lang=. Built-in crafts read?lang=for the first paint; installed ones getwindow.localebefore any of their scripts run. Existing crafts callpickLocale(tag || urlLocale()); that becomespickLocale(tag || window.locale || urlLocale()). - Reference assets only from
index.html, by relative path. A runtimefetch('./data.json')will not resolve. Bundle data into the JS instead. - No
location-based routing.location.hrefis 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-storageentries 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.
- 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 tosrcdocwith every script inlined. - Manifest fields +
craftsmith pack; packlights-outas the test fixture. CraftPackageStorecontract, validation, IndexedDB backend, unit tests (path traversal, missing entry, built-in id clash, size caps, badhostApi).- Loader +
CraftRegistry; switch the panel fromiframeSrctocraftId. WebCraftStorereads installed crafts.- Install / manage UI + i18n.
- Tauri commands + backend.
- e2e: install the
lights-outfixture under a different id, reload, open it, click a cell, uninstall. - Update
writing-a-craft.mdwith the constraints above.
