--- name: tinyjs description: Build and modify tinyjs desktop apps — tiny macOS (and beta Windows/Linux) apps with a txiki.js JavaScript backend and a native webview window. Use when working in a project with a tinyjs.json, when the user mentions tinyjs, tiny.api, or tinyjs dev/build, or when porting an Electron app to tinyjs. --- # Building tinyjs apps tinyjs (https://tinyjs.app, repo tarwin/tinyjsapp) makes ~6 MB desktop apps: a **txiki.js backend** (full system access: files, sockets, processes, FFI) + a **native webview window** — WKWebView on macOS, WebView2 on Windows, WebKitGTK 4.1 on Linux (both beta). They talk over a private socket — no HTTP server, no ports. The page has no direct system access: everything privileged crosses `tiny.api` (which the tinyjs.json `"api"` gate can narrow), which is why anything interpolated into `innerHTML` must be escaped. Two launcher-side paths skip that crossing, both limited to the app's own pages and the origins `"api"` trusts: `tiny.proxyURL`'s `tiny-media://` proxy (cross-origin http(s) reads, macOS + Linux; `media.proxy`) and mic/camera (`media.microphone` / `media.camera`). Current release: 0.50.0. App floors: macOS 15+ — a default build opens only on the build Mac's CPU (`build --arch arm64|x86_64` or `--universal` for the other); Windows 10/11 (WebView2); Linux glibc 2.35+ (Ubuntu 22.04 / Debian 12 / Mint 21 and up). ## Read the reference for the job at hand | doing | read | |---|---| | any API beyond the basics below (windows, menus, tray, clipboard, audio, permissions, system…) | references/api.md | | per-OS support, Windows/Linux quirks, capability gating | references/platforms.md | | starting a tray app, desktop pet/overlay, media app, document app, site wrapper, agent app | references/recipes.md | | shipping: build/sign/notarize, per-OS packaging, auto-update manifests | references/release.md | | porting from Electron (API map, what won't work) | references/electron-migration.md | | deciding where code runs, wire/binary payloads, throttling, memory | references/performance.md | ## Commands ```sh tinyjs new # scaffold (zero dependencies) tinyjs new --template react-ts|vue-ts|svelte-ts|solid-ts|preact-ts|lit-ts|alpine-ts|vanilla-ts|… # create-vite + tinyjs overlay: HMR dev server in the # native window, esbuild-bundled TS backend (npm pkgs ok). # No name = asks framework + language. --pm npm|pnpm| # yarn|bun|vp[:pnpm] (else asks; npm off a terminal), # --no-install. Agents: pass both flags — no prompts. # vp needs Vite+ ≥ 1.0. alpine = vanilla + Alpine.js. tinyjs wrap # site wrapper: the site IS the app, origin-gated API # (--origins subdomains, --menubar [--panel], --top, # --external a.com, --ua, --force re-wraps in place) tinyjs dev # run with hot reload (frontend edits swap in place; # backend edits restart the process) tinyjs build # dist/ binary + dist/.app (codesigned) # --dmg installer image; --cli [name] terminal shim # --arch arm64|x86_64: macOS .app for that CPU (any Mac; # 0.42+); --universal: one .app for both (needs the # Command Line Tools' lipo). Bare dist/ = host CPU tinyjs publish # build + dist/publish/-.zip|tarball + manifest tinyjs notarize # macOS: notarytool submit + staple (--dmg re-makes dmg) # publish/notarize take --arch too — repeat the build's tinyjs update # update tinyjs itself (--check); also: uninstall, version TINYJS_DEBUG=1 tinyjs dev # trace every bridge message ``` ## Project layout ``` tinyjs.json { name, title, size, id, version, icon?, minTinyjsVersion?, // refuse older tinyjs with a // real message; `tinyjs new` // stamps its own version — raise // it for newer APIs you adopt update?: { url: "https://…/manifest.json", auto? }, urlScheme?: "myapp", fileExtensions?: ["md"], openFolders?: true, readAccess?: true | "/path", userAgent?: "…", activation?: "accessory", offscreenRescue?: false, windowPlacement?: true, contextMenu?: false, audioTap?: "app" | "system", audioTapReason?: "why", about?: "menu", attribution?: "…", // About panel credit line (macOS); // a project-root Credits.html wins debug?: true | "open", browserAccelerators?: true, permissions?: { microphone?: "why", camera?: "why", speechRecognition?: "why" }, chrome?: { frame, windowControls, windowControlsPos, transparent, vibrancy, squareCorners, acceptsFirstMouse, menu }, signIdentity?, notarize?: { profile }, backend?: "backend/main.ts", // .ts → esbuild bundle; // default src/main.{js,ts} or backend/main.{js,ts} frontend?: { dir?, build, dist, dev, devUrl }, // dir: plain page folder (default src/frontend); // build/dev need the packages installed first // wrapping a hosted site (recipes.md) — "url" replaces // the local frontend, "api" gates what that origin may // call. NEVER wrap a site you don't control without it. url?: "https://app.example.com", inject?: "src/shim.js", // document-start, every page downloads?: "auto" | "ask" | "deny", popups?: "external" | "window" | "deny", api?: "wrapper" | { disable?, enable?, origins? }, macos?/windows?/linux?: { …merged on top per OS } } icon.png 1024×1024 app icon src/main.js backend src/frontend/ index.html + assets — served as real files (file://), so relative paths just work ``` ## Backend (src/main.js) ```js export const api = { // page calls tiny.api.call('readNotes', { dir }) — return resolves the // page's promise, throwing rejects it; meta.window = calling window id readNotes: async ({ dir }, app, meta) => { ... }, }; export function init(app) { app.push('event-name', data); // page: tiny.api.on('event-name') // app mostly mirrors the page: app.window(id).*, app.openWindow, // app.tray.*, app.audio.sampler.*, app.clipboard.*, app.store.*, // app.paths (plain object), notify, quit, … — but some names differ // (app.setMenu, app.setChrome, …): name map in references/api.md } // other exports the scaffold wires, each (info, app): onMenu, onTray (id // null = bare icon click), onContextMenu, onHotkey, onWindowState, // onWindowClosed, onOpenUrl, onOpenFiles, onMediaKey, onNotificationClick, // onNotificationAction, onUpdateAvailable, onClipboardChange, onLocale, // onAudioTap, onNavigate, onDownload, onWindowOpen — except // onSystem(kind, value, app) ``` Runtime is txiki.js (`tjs` global): `tjs.readFile/writeFile/readDir/stat`, `tjs.spawn`, `tjs.watch`, `fetch`, `WebSocket`, `tjs:sqlite`, FFI. It is NOT Node (no require, no Node builtins, no native npm modules) and it has no JIT — compute-heavy work belongs in the page (references/performance.md). Streams need `getReader()` (no `for await`); `tjs.cwd` is a property; no Intl (format in the page). Every `tjs.*` fs call is async — await it — while `tjs:sqlite` is fully sync (never await; statements have run/all/finalize only, no get(), run() returns void). ## Frontend essentials ```js await tiny.api.call('method', { params }); // -> backend api. const off = tiny.api.on('event', fn); // <- app.push; returns unsubscribe audio.src = tiny.fileURL(path); // ALWAYS this, never 'file://' + path // (breaks on Windows drive letters) const r = await tiny.fetch(url, opts); // backend-proxied, no CORS/CSP tiny.win.open('settings', { page: 'settings.html', size: '420x300', chrome: { frame: false } }); // chrome/x/y/minSize in open() (or tinyjs.json "chrome" for main) apply // BEFORE first paint — a late setChrome flashes the default window. ``` Everything else — windows/chrome/state events, menus (in every window), dialogs, tray, notifications, clipboard, hotkeys, audio (sampler / filters / audioTap / proxyURL), store/secrets, permissions, deep links, auto-update, `tiny.macos.*` — is in references/api.md with signatures and gotchas. ## Cross-platform rules - Gate features, don't fork code. `capabilities()` lists only the EXCEPTIONS: test `caps.x !== false`, never `if (caps.x)` (the truthy form reports "unsupported" on the OS that has it). Query calls resolve `null` where unsupported, capability calls reject with the reason, fire-and-forget ones no-op — but `tiny.macos.*` off macOS REJECTS. `caps.sampler` is a string ('native'|'page') — informational, don't branch. - Use `app.paths` / `tiny.app.paths()` — never hardcode `~/Library` or `%APPDATA%`; join with '/' (works everywhere). - `tiny.system.os()/isMacOS()/isWindows()/isLinux()` are synchronous; `architecture()` must be awaited (navigator lies on Apple Silicon). - Missing system pieces (codecs, speech, tray…) → `tiny.system.promptMissing([ids])` puts the fix in front of the user. - Edit menu: never declare `{ title: 'Edit' }` — macOS already has one, so the bar shows two. Use `{ role: 'edit', items: [...] }`; for the SAME menu on all three OSes, place the stock items yourself: `items: [{ role: 'standard' }, { separator: true }, ...yours]` (details in references/api.md → Menus). ## Rules of thumb - Backend capabilities go in `api` methods; keep the frontend thin — but keep COMPUTE in the page and bytes off the wire (pass paths, not ArrayBuffers; references/performance.md). - Escape anything interpolated into `innerHTML` — the page holds an RPC channel to full system access. - Never declare a top-level `chrome` identifier in frontend code: `window.chrome` is a non-configurable global on WebView2, so a top-level `const chrome` is a PARSE-time SyntaxError that kills the whole script. - Linux audio: never route Web Audio to `ctx.destination` (it crackles — measured, unfixable page-side). SFX → `tiny.audio.sampler`; EQ → `tiny.audio.filters`; details in references/platforms.md. - Occluded/hidden windows are throttled (rAF stops) — continuous work lives in a visible window or the backend. - Verify changes with a self-driving test page (references/recipes.md): `TINYJS_HTML=/abs/page.html tinyjs dev`. The bundled `test/smoke.html` only fits an UNMODIFIED zero-dependency scaffold (it calls the template's `sysinfo`/`listDir` and waits for its `tick` push). - No display (SSH, CI, sandbox, headless Linux): the launcher prints a `tinyjs:` explanation and exits 3 ("launcher exited before connecting") — the environment, not your code; there is no headless mode. - `dist/.app` is the distributable; bare `dist/` is local-only.