jkunzandClaude Opus 5.5 de3a4f3592 fix: the App Store checker loads only when a wrapper is checked
validateCraftWrapper is async and imports the catalog's checker there, so
a wrapper's server import (getCraftWrapper) loads no catalog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:51:08 +00:00
2026-10-20 12:34:28 +00:00
2026-10-20 12:34:28 +00:00

@workspace.global/craftkit

The shared parts of the workspace.global Craft app wrappers: the shim that runs a Craft app's released WebAssembly build as a frame app in the webdesktop, and the build step that downloads, verifies and unpacks that build.

Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.

How a wrapper uses it

A wrapper package (@workspace.global/<app>) holds a manifest (workspace.app.json), a pin (upstream.json), a two-line shim page and these three pieces of code:

// ts_web/index.ts: bundled into dist_app/shim.js
import { startCraftShim } from '@workspace.global/craftkit/web';
startCraftShim({ appId: 'gridcraft', appName: 'GridCraft', bridge: 'gridcraft' });
// ts/index.ts: what the webdesktop server imports
import { getCraftWrapper } from '@workspace.global/craftkit';
export const { manifest, appDir, upstreamPin } = getCraftWrapper(packageDir);
// package.json scripts
"build": "tsbuild tsfolders --allowimplicitany && tsbundle && craftkit upstream",
"test": "tsbuild tsfolders --allowimplicitany && tstest test/ --verbose --timeout 60"

craftkit upstream downloads the pinned asset once into .nogit/upstream/, checks its size and sha256, and unpacks it unmodified into dist_app/app/. It then writes dist_app/app.files.json and prints it: the build's version (craftAppVersion: the pin's tag and the first 12 hex digits of the asset's sha256) and the files the app fetches as it starts, with their decoded sizes. A build of another version is swapped with the version taken off app.files.json first (swapAppBuild), so app@<old>/ answers 404 rather than the new bytes meanwhile. craftkit check lists what is wrong with the manifest and pin (also validateCraftWrapper() for the wrapper's test).

app.files.json is read from the release's index.html (readStartupUrls, readAppFiles). It holds each URL exactly as the page requests it:

  • Trunk's loader (ten apps): the <link rel="modulepreload"> glue and the <link rel="preload" as="fetch"> wasm.
  • The hand-written module loaders (EffectCraft, FilmCraft, LightCraft): the literals in import, fetch() and new URL(…, location.href). This includes LightCraft's 16 MB font and FilmCraft's ?v=<build> suffix, whose hash cargo xtask web writes into the page's const build.
  • The modules the glue imports (wasm-bindgen snippets).

A URL that names no file of the build, a page without a .wasm, or a literal joined to anything else stops the build: a new loader needs a case in readStartupUrls.

Each wrapper commits dist_app/app.files.json (git add -f, since dist_* is ignored), so a new pin shows the list in its diff.

The shim

startCraftShim() loads ./app@<version>/index.html in an iframe on the shim page's origin (./app/index.html for an app.files.json without a version) and speaks the webdesktop's frame protocol 2 (the catalog's TWgFrameMessage, mirrored in ts_web/protocol.ts) on the app's behalf.

Loading. Before the app starts, the shim downloads the files of app.files.json (prefetchAppFiles), counting the bytes and throwing them away, and sends the share done as progress (0 to 1, at most every 100 ms), which the window shows as a bar. The browser keeps the files in its HTTP cache, and the app's own requests find them there, so each file crosses the network once. The shim cannot wrap the app's requests instead: Trunk's pages start the wasm download from the HTML.

  • Already cached: a file an earlier start left in the cache as immutable counts as loaded without being read. An only-if-cached probe checks that it is still there; the shim remembers these files in the origin's localStorage, so a first start probes nothing (a probe that misses shows as a failed request in DevTools). A no-cache file (EffectCraft, LightCraft, FilmCraft) is fetched and revalidated.
  • Errors: a file that fails is left to the app.
  • Versioned URLs: the webdesktop serves app@<version>/ only while that build is on disk (404 otherwise) and lets browsers keep its files for good, so every file of the build is immutable there, also those without a hash in their name. Its service worker keeps them per app version, and the probe finds them there.

At 1 the window says "Starting…" until ready. When the app cannot start, the shim sends failed with the reason:

  • the loader's error (<id>Load.error, LightCraft's "failed to load" notice, a bridge's <prefix>:failed);
  • an uncaught WebAssembly error before the app started (readStartError, e.g. a .wasm that does not compile).

DevTools' "Disable cache" bypasses the HTTP cache, so with it every file downloads twice; test with a cleared cache or a fresh profile instead.

Files in. open-file from the host goes, in this order:

  1. to the Open dialog the app is waiting on
  2. else through the app's parent bridge (<prefix>:open)
  3. else in as a file dropped on the app's canvas

The shim says ready once the app runs: on its bridge's ready, or as soon as its loader is done (wasmBindings, <id>Load.readyMs, LightCraft's lightcraft.command), or 30 s after the page loaded. A file pushed into an app without a bridge goes in as a drop only 1.5 s after its loader is done, since eframe installs its drop handler late; until then the shim holds it (holdDrops, releaseDrops).

Opening. The app's own Open dialogs become request-open with the extensions of the file input's accept (else the manifest's fileTypes). ts_web/intercept.ts wraps these in the app's window:

  • HTMLInputElement.prototype.click and showPicker
  • a press on a file input (rfd's fallback card)

The chosen file goes back into the input through a DataTransfer, as if picked. open-cancelled from the host (proposed) dispatches cancel on the input; without it, a new request cancels the old one.

Saving. Saves become save-file:

  • Apps with a bridge: <prefix>:save.
  • Every other app: its downloads. The shim wraps HTMLAnchorElement.prototype.click and presses on <a download>, for blob: and data: URLs. URL.createObjectURL is wrapped so the Blob can still be read after the app revokes its URL.

saveAs is set when the file's name differs from the open document's (an export, a converted or renamed file), so those go through the Save dialog. saved updates the window title and the save target; save-failed shows a notice.

Saving on request. When a window with unsaved changes closes, the webdesktop asks whether to save, and Save sends save-request. Apps with a bridge (PdfCraft, CADCraft, GridCraft) say saveOnRequest: true in their ready. On save-request the shim presses the app's Save shortcut, ⌃S, on its focused canvas (pressKeyInAppWindow(window, 's', { code: 'KeyS', ctrlKey: true })); eframe reads Ctrl as Command on every platform. The save then comes back through the bridge as save-file. The bridges take only <prefix>:open from their parent, so a key press is the one way to ask for a save. In headless Chromium this saved sample.pdf, line.dxf and, as a Save As, budget.csv converted to budget.xlsx. Apps without a bridge report no unsaved changes, so the webdesktop never asks them to save.

Pickers. showOpenFilePicker and showSaveFilePicker are taken away inside the webdesktop. FilmCraft, WordCraft and GridCraft then use the file inputs and downloads the shim catches.

Window.

  • The title is the open document's name, plus "— Edited" while a bridge reports unsaved changes.
  • dirty reaches the host.
  • A press inside the app raises its window (focus).
  • The workspace's theme reaches the app as its frame's color-scheme, which the app's page reads as prefers-color-scheme; the shim's own page follows it too. See Theme below for apps with a theme setting of their own.

Full screen and window commands. eframe's web backend leaves egui's viewport commands undone and logs each one as a console warning, e.g. Unhandled egui viewport command: Fullscreen(true) - not implemented in web backend. The shim wraps the app window's console.warn (interceptViewportCommands, parseViewportWarning) and carries them out:

Command What the shim does
Fullscreen(true) requestFullscreen() on the app's iframe. The key or click that started the show activates the shim's page too (activation reaches ancestor frames), and both frames allow fullscreen. Without an activation, or when the browser refuses, it asks the host with request-fullscreen
Fullscreen(false) leaves the shim's full screen, and sends exit-fullscreen if it asked the host
Close close
Maximized(true), Maximized(false) maximize with maximized
Minimized(true) minimize

A warning the shim carried out is not logged. Every other warning (Minimized(false), Title(…), SetTheme(…), …), and the window commands without a host, reach the console unchanged. When full screen ends before the app asked (Escape, which the browser keeps for itself, or the host's fullscreen: false), the shim presses Escape on the app's canvas, so the app ends its show. DeckCraft's slide show and presenter view then fill the screen and leave it cleanly.

Origins. The shim posts to the webdesktop's exact origin, never '*', and takes host messages only from the parent window at that origin. The origin is location.ancestorOrigins[0], else that of document.referrer (readParentOrigin). Which pages may frame the shim is the server's to say: the webdesktop sends X-Frame-Options: SAMEORIGIN, and a wrapper served from another origin needs a frame-ancestors policy that names the webdesktop. Bridge messages count only from the app's window on the shim's own origin.

On its own (no parent, or one whose origin it cannot tell), the shim leaves the browser's pickers and downloads alone.

Options: appQuery sets the app's own start flags (for example DeckCraft's blank), passQuery hands shim query parameters on (for example file), background sets the colours shown while loading.

Theme

Craft apps start in the workspace's theme, and a theme picked inside an app lasts only while that window is open. startCraftShim({ theme }) says how, per app (ts_web/theme.ts):

  • settings: where the app keeps its theme. That is localStorage or a JSON file in the origin's private file system (opfs), plus the JSON path, and the value that follows the system (follow) or, for an app without one, its bright and dark values. Before the app starts, at every start, the shim puts that value back: only at that path, keeping every other preference and the file's layout. That also covers a window that ended without a close (a crash, a closed tab). A seed creates the setting on a first start, for apps that read a partial file.
  • command: a call on the API the app's page puts on window (FilmCraft's filmcraft.execute('prefs.set', …)), made on every page the app frame loads, for apps that keep their theme only in memory; {theme} in its arguments stands for the value.
  • startsAs: for an app that starts in its own theme whatever prefers-color-scheme says, but follows a change of it (DeckCraft). Its frame starts in the app's own color-scheme and takes the workspace's once the app has painted. egui keeps a bright and a dark style, and the app's first frame writes its theme into the one current then.

The app starts once the workspace's first theme message is in (up to themeWaitMs, 1.5 s), so its frame has the workspace's color-scheme before the app reads it. Without a workspace host the shim leaves the app's theme alone.

Build and test

pnpm install
pnpm run build
pnpm test

License

MIT. The Craft apps the wrappers ship are dual-licensed MIT OR Apache-2.0 by their authors; their ArtCraft name and logos are trademarks that may be used only unmodified.

S
Description
Shared parts of the workspace.global Craft app wrappers: the frame-app shim, and the build step that downloads, verifies and unpacks a Craft app's released web build.
Readme
163 KiB
0 Stars 3 Watchers 0 Forks
Languages
TypeScript 99.9%