---
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.