the docs

Everything a tinyjs app can do. Frontend calls live under window.tiny; the backend exports plain functions.

Written mac-first — most calls below work identically on Windows and Linux (both in beta). Where an OS can't do something it rejects or answers 'unsupported' so cross-platform code can feature-detect; the full per-OS breakdown lives in the README's Portability section and TODO-linux.md.

install one line, no dependencies

curl -fsSL https://tinyjs.app/install | sh
macOS · apple silicon + intel · MIT
irm https://tinyjs.app/install.ps1 | iex
windows 10/11 · prebuilt, no compiler needed · just the WebView2 runtime (preinstalled on win 11) · adds tinyjs to your PATH (open a new terminal) · MIT
curl -fsSL https://tinyjs.app/install | sh
linux (beta) · x86_64 + arm64 · same script as macOS, detects Linux · needs libwebkit2gtk-4.1-0 (sudo apt install libwebkit2gtk-4.1-0) · MIT

Then tinyjs new myapp && cd myapp && tinyjs dev — a window opens with hot reload. The CLI updates itself with tinyjs update.

cli

tinyjs new <dir>
  # scaffold an app (zero dependencies)

tinyjs new <dir> --template react-ts|vue-ts|svelte-ts|…
  # Vite + tinyjs: HMR dev server in the native window, with the
  # TypeScript backend bundled by esbuild (npm packages ok)

tinyjs dev
  # run with hot reload — the frontend swaps in place, the backend
  # restarts (TINYJS_DEBUG=1 traces the bridge)

tinyjs build
  # dist/<name> binary + codesigned dist/<Name>.app
  # --dmg: also a disk image installer

tinyjs publish
  # build + zip + auto-update manifest in dist/publish/

tinyjs notarize
  # notarytool submit + staple (needs a Developer ID)
  # --dmg: rebuild the dmg from the stapled .app

tinyjs update
  # update the tinyjs CLI itself (--check: report only)

tinyjs.json every key is optional except name

{
  // binary + bundle name (required)
  "name": "myapp",
  // window title & menu name (default: name)
  "title": "My App",
  // initial window size
  "size": "960x640",
  // bundle identifier
  "id": "com.example.myapp",
  // shown in About; drives auto-update
  "version": "1.0.0",
  // oldest tinyjs this app works with — optional, but it turns "some calls
  // mysteriously do nothing" into a message naming the real problem
  "minTinyjsVersion": "0.30.0",
  // 1024×1024 → AppIcon.icns
  "icon": "icon.png",
  // codesigning identity (default: ad-hoc)
  "signIdentity": "Developer ID Application: …",
  // deep links: myapp://…
  "urlScheme": "myapp",
  // "Open With" file associations
  "fileExtensions": ["md"],
  // getUserMedia prompts — the string is the reason shown to the user
  "permissions": { "microphone": "why", "camera": "why",
                   "speechRecognition": "why" },
  // enable tiny.audioTap ("app" | "system")
  "audioTap": "app",
  // suppress WebKit's default right-click menu (default: true)
  "contextMenu": false,
  // macOS About menu item fires onMenu('about') / the 'menu' event instead of
  // showing the standard panel ('about' becomes a reserved menu-item id)
  "about": "menu",
  // stale saved positions (an unplugged display) auto-rescue windows onto a
  // live screen; false if your app parks windows off-screen on purpose —
  // win.ensureOnScreen() stays available either way (default: true)
  "offscreenRescue": false,
  // override the webview UA (UA-sniffing sites; wrapping a hosted app)
  "userAgent": "Mozilla/5.0 …",
  // WRAPPING A HOSTED SITE — the main window IS the remote page, so no local
  // frontend is needed. See "wrapping a hosted site" below.
  "url": "https://app.example.com",
  // what that origin may call over the bridge. tiny is injected into EVERY
  // origin, so without this the site's own JS holds an RPC channel to a
  // backend with full filesystem access. enable wins over disable.
  "api": { "origins": { "https://app.example.com": ["notify", "store.*"] } },
  // document-start script for every page (.ts is bundled with esbuild)
  "inject": "src/shim.js",
  // where downloads go: auto (OS Downloads dir) | ask (save panel) | deny
  "downloads": "auto",
  // window.open / target=_blank: external (real browser) | window | deny
  "popups": "external",
  // frameless / translucent window
  "chrome": { "frame": false, "vibrancy": "hud" },
  // backend entry; a .ts path is bundled with esbuild
  "backend": "backend/main.ts",
  // bundler hooks (Vite etc.) — dev/devUrl are used by `tinyjs dev`
  "frontend": {
    "build": "npm run build", "dist": "dist",
    "dev": "npm run dev", "devUrl": "http://127.0.0.1:5173"
  },
  // menu-bar agent: no Dock icon, starts hidden (no launch flash)
  "activation": "accessory",
  // auto-update manifest published by `tinyjs publish`
  "update": { "url": "https://…/manifest.json" },
  // notarytool keychain profile used by `tinyjs notarize`
  "notarize": { "profile": "my-notary-profile" }
}

Secrets stay out of the file. Two keys fall back to the environment when they're absent, so a signing identity or a notary profile can live in your shell or in CI secrets instead of in a committed tinyjs.json:

TINYJS_SIGN_IDENTITY="Developer ID Application: …"
  # used by `tinyjs build` and `tinyjs notarize` when "signIdentity" is absent
  # (without either, the build signs ad-hoc — fine locally, not distributable)

TINYJS_NOTARY_PROFILE="my-notary-profile"
  # used by `tinyjs notarize` when "notarize": { "profile" } is absent

These override the file rather than merely filling a gap — but never silently: if an env var displaces a value that was actually present in tinyjs.json, the build says so (==> signIdentity from TINYJS_SIGN_IDENTITY (overriding tinyjs.json)), because a variable left exported in a shell quietly changing how a project signs is worth shouting about. The rest of the TINYJS_* vars are for running and debugging rather than configuration:

TINYJS_DEBUG=1        # trace every bridge line in `tinyjs dev`
TINYJS_HTML=/abs/page.html
                    # load a different page — self-driving test pages
TINYJS_LAUNCHER=/path/to/launcher
                    # run against a different launcher binary
TINYJS_HOME=/opt/tinyjs   # where the CLI installs and looks for itself

per-OS overrides

Root keys apply everywhere. An optional macos / windows / linux block is merged on top for that platform — so the keys that genuinely differ (an .ico, a macOS-only signing identity, a vibrancy that means nothing elsewhere) don't force you to write three of everything. The block names are the strings tiny.system.os() returns.

{
  "name": "myapp",
  "icon": "icon.png",
  "chrome": { "frame": false },

  // merged on top when building on that OS
  "macos":   { "signIdentity": "Developer ID Application: …",
               "chrome": { "vibrancy": "hud" } },
  "windows": { "icon": "icon.ico" },
  "linux":   { "icon": "icon-512.png" }
}

Plain objects merge, so the macOS block above ends up with { frame: false, vibrancy: 'hud' } rather than losing the root's frame. Scalars and arrays replace outright. The full resolution order is root → OS block → env var.

backend src/main.js — full system access via txiki.js

export const api = {
  // callable from the page: await tiny.api.call('readNotes', { dir })
  // return value resolves the page's promise; throwing rejects it
  readNotes: async ({ dir }, app) => { … },
};

export function init(app) {          // runs once the window is up
  app.push('tick', data);             // push an event to the page
}

// optional event exports (each also arrives as a page event):
export function onMenu(id, app) {}
export function onTray(id, app) {}         // id null = bare icon click
export function onHotkey(id, app) {}
export function onContextMenu(id, app) {}
export function onSystem(kind, value, app) {}  // 'theme'|'sleep'|'wake'
export function onOpenUrl(url, app) {}
export function onOpenFiles(paths, app) {}

The app handle mirrors the frontend surface: setTitle, setSize, setMenu, setContextMenu, eval(js), reload(), quit(), notify({title, body}), hide/show/center/minimize/fullscreen, setPosition, setAlwaysOnTop, setResizable, setHideOnClose, presence, print(), tray.set/remove, store.*, hotkey.*, update.check/install — plus spawnHidden(args, opts): tjs.spawn that never flashes a console window on Windows (console tools spawned from a built app otherwise each pop a terminal; identical to tjs.spawn on macOS and Linux, where there's no console to flash).

SQLite ships in the runtime:

import { Database } from 'tjs:sqlite';
const db = new Database(dir + '/notes.db');
db.exec('CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, text)');
const st = db.prepare('INSERT INTO notes (text) VALUES (?)');
st.run('hello'); st.finalize();
db.prepare('SELECT * FROM notes').all();  // [{ id: 1, text: 'hello' }]

tiny.api request/response + events

const r = await tiny.api.call('method', { params });  // → backend api.method
tiny.api.on('event-name', (data) => …);              // ← app.push(...)
// on() is additive (N handlers per event, all fire) and returns an
// unsubscribe; tiny.api.off(event, fn) removes by reference. The tiny.*.on
// sugar (menu.on, tray.on, theme.on, …) returns the unsubscribe too — and
// since the sugar wraps your callback, that return is the only way to unhook:
const stop = tiny.api.on('tick', fn);  stop();
tiny.api.off('tick', fn);

tiny.log(msg);                     // print in the backend terminal, tagged [web],
// so the page's output and the backend's interleave in ONE place instead of
// hiding in the webview inspector. Anything JSON can carry (an object stays
// an object); resolves true once the backend has the line, so it's awaitable.
tiny.quit();
tiny.notify(title, body, { id, subtitle, sound });
// packaged apps signed with a real identity (even "Apple Development")
// get native Notification Center banners — your icon, permission prompt,
// and clicks back:
tiny.app.onNotificationClick((id) => …);
// ad-hoc/dev builds fall back to osascript banners automatically
// notify() never rejects — it resolves false if delivery failed, so
// fire-and-forget is safe
await tiny.app.info();             // { version, tinyjs, runtime }

tiny.fetch networking without CORS

Like window.fetch, but the request runs in the backend (a native process) — no CORS, CSP, or mixed-content limits, so the page can reach any origin. Resolves to a real Response.

const r = await tiny.fetch('https://api.example.com/x', {
  method: 'POST', headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ q: 'hi' }),
});
await r.json();   // r.ok, r.status, r.headers, r.text(), r.body … all work

// { stream: true } → a LIVE streaming body. The backend holds the
// connection and the page pulls chunks on demand (backpressured) — the
// whole point for endless sources like internet radio, where a buffered
// fetch would never resolve.
const radio = await tiny.fetch(streamUrl, { stream: true });
const reader = radio.body.getReader();
for (;;) { const { value, done } = await reader.read(); if (done) break; /* … */ }
reader.cancel();  // (or closing the window) tears the upstream connection down

A page with CORS-free network reach has the app's full network reach — for tinyjs that's not a new trust boundary (every page already holds an RPC channel to a backend with full system access), but worth knowing.

Reachability: tiny.fetch (and plain fetch() in the backend) transparently hands two cases the bundled runtime can't do on its own to the system curl: root-path URLs like https://feeds.example.com/ (the runtime emits GET //, which strict CDNs answer with 404) and TLS 1.2-only hosts (art19, anchor.fm — the runtime's TLS stack requires 1.3). Redirects are followed hop by hop, so a tracker URL that bounces into either case still lands. curl is only consulted when the built-in path failed at the wire — a real HTTP error (404, 500) is returned as-is, never retried. curl ships with macOS, Linux, and Windows 10 1803+; without it those two cases fail as before.

tiny.proxyURL(url) — a cross-origin stream into Web Audio

A MediaElementSource on a cross-origin <audio> (internet radio) outputs silence by spec, so it can't drive an EQ or analyser. tiny.proxyURL returns a same-app tiny-media:// URL that streams the remote through the native layer with permissive CORS, so the element is untainted and the full Web Audio graph gets real samples.

const audio = document.querySelector('audio');
audio.crossOrigin = 'anonymous';
audio.src = tiny.proxyURL('https://ice1.somafm.com/groovesalad-128-mp3');
const ctx = new AudioContext();
ctx.createMediaElementSource(audio)
   .connect(ctx.createAnalyser());   // …→ your EQ → ctx.destination
audio.play();

The native layer does the HTTP (following redirects), http/https only. Live internet radio works too: a non-seekable stream that answers 200 with no Content-Length (icecast/shoutcast) is served to the element with a synthetic large length, so CoreMedia plays it progressively and the analyser gets real samples. Tradeoff: audio.duration / currentTime are meaningless for such a live stream (a huge fake timeline) — don't wire a seekbar to one.

tiny.win window control

// setSize is the PAGE's box (decorations excluded), top-left anchored —
// the same units tinyjs.json's "size" and win.open's size: 'WxH' take.
tiny.win.setTitle(t);  tiny.win.setSize(w, h);
tiny.win.center();     tiny.win.setPosition(x, y);   // top-left origin
// clamp onto the nearest screen IF nobody could see or grab the window
// (less than a sliver visible). Stale saved positions from an unplugged
// display are rescued automatically: when the screen layout differs from
// the app's previous run, each window's first show/setPosition gets this
// check, and a display departing mid-session sweeps visible windows. Apps
// that park windows off-screen on purpose set "offscreenRescue": false in
// tinyjs.json (kills the automatic parts; this manual call always works).
tiny.win.ensureOnScreen();
tiny.win.minimize();   tiny.win.restore();
tiny.win.fullscreen();  tiny.win.setFullscreen(bool);  // toggle / absolute
tiny.win.setAlwaysOnTop(bool);  tiny.win.setResizable(bool);
// floor under USER resizes (win.open takes the same as minSize: '900x640').
// Your own setSize still goes under it on macOS and Windows; GTK clamps both.
tiny.win.setMinSize(900, 640);
// native page zoom (0.25–5): the page keeps laying out in CSS px and simply
// has fewer of them — pair with setSize(w*f, h*f) for a crisp "double size"
tiny.win.setZoom(2);
tiny.win.hide();  tiny.win.show();
// hide() hides the APP — focus returns to the previous app on its own, so a
// palette can hide() then app.paste() with no frontmost-pid bookkeeping
tiny.win.show({ activate: false });  // surface WITHOUT stealing focus (HUDs)
tiny.win.setHideOnClose(bool);   // close button hides instead of quits

// global cursor position — same top-left coords as setPosition, so a
// palette can open at the mouse; window = relative to this window's content
// area ({ x, y, inside } — clientX/clientY units, valid even while the
// cursor is outside it); screen = the display the cursor is on
const { x, y, window, screen } = await tiny.app.mousePosition();

// every display, same coords — position windows on any monitor
const screens = await tiny.app.screens();
// [{ id, name, x, y, width, height, visible: { x, y, width, height },
//    scale, primary }] — visible excludes the menu bar and Dock; primary
//    is the menu-bar screen (the coordinate origin)
tiny.win.print();                // native print panel

// files dragged onto the window — real filesystem paths
tiny.win.onDrop((paths) => …);

// frameless / transparent / vibrancy (native resize + focus kept)
tiny.win.setChrome({ frame: false, windowControls: false, vibrancy: 'hud' });
// squareCorners: true → BORDERLESS (square, no titlebar/traffic lights;
// no native titlebar drag — use data-tiny-drag; resize/shadow/focus kept).
// Put it in tinyjs.json "chrome" to apply before first paint.
tiny.win.setChrome({ squareCorners: true });
// acceptsFirstMouse: true → the click that focuses an unfocused window ALSO
// reaches the page (macOS swallows it by default). Good for palettes/toolbars
// and DOM drag regions on unfocused windows.
tiny.win.setChrome({ acceptsFirstMouse: true });
// move the traffic lights: { x, y } from the window's top-left — for a
// frameless window whose custom titlebar is taller than the default corner.
// One call; the launcher re-applies it across resizes and fullscreen
// round-trips. null = back to the OS layout. macOS only, ignored elsewhere;
// getState().chrome reports it back.
tiny.win.setChrome({ windowControlsPos: { x: 12, y: 24 } });
// drag regions: <header data-tiny-drag> — drag moves, double-click zooms
tiny.win.startDrag();  tiny.win.zoom();
// begin a resize from an edge, for a handle you drew yourself. Frameless
// windows already get invisible grips on all eight (data-tiny-noresize opts
// out on Linux). Like startDrag, it hands over a gesture already in progress:
// call it from mousedown while the button is down — from a click it no-ops.
grip.addEventListener('mousedown', () => tiny.win.startResize('se'));
// 'n','ne','e','se','s','sw','w','nw'

// drag real files OUT of the app (Finder, Slack, …) — call from a
// mousedown handler while the button is still held; image: optional png
// drag-image. macOS + Windows; the Linux launcher has no drag source yet.
row.addEventListener('mousedown', () =>
  tiny.win.startDrag({ files: ['/tmp/report.pdf'] }));   // = win.dragOut(…)

// read the window back
const s = await tiny.win.getState();
// { x, y, width, height, outer: { width, height }, fullscreen, minimized,
//   visible, focused, alwaysOnTop, resizable, screen: { width, height, scale } }
// width/height are the PAGE's box — the same units setSize, setMinSize and
// win.open's size take, so reading the size and handing it back is a no-op.
// outer is the footprint on screen, decorations in; for a frameless window
// the two are equal. (window.outerWidth/outerHeight are 0 in a WKWebView.)
// x/y are the window's top-left — the units setPosition takes.

// …or don't poll: transitions arrive as events, whatever the cause
// (green button, menu item, F11, your own setFullscreen). Same vocabulary
// as getState(); `win` because events are broadcast — every page hears
// about every window and filters by id. Backend twin: export
// onWindowState(info, app). Wayland never reports `minimized`.
const off = tiny.win.onState(({ win, fullscreen, maximized, minimized, focused }) => …);
off();                        // every tiny on… returns its own unsubscribe

multiple windows

tiny.win.open('settings', { page: 'settings.html', title: 'Settings', size: '420x300' });
tiny.win.id;                  // which window this page lives in
tiny.win.close();             // the calling window ('main' quits the app)
await tiny.win.windows();     // ['main', 'settings', …]

// chrome + x/y apply BEFORE first paint — no titlebar flash, no center-jump
tiny.win.open('hud', { page: 'hud.html', x: 40, y: 40,
                     chrome: { frame: false, windowControls: false, vibrancy: 'hud' } });

// parent: stays above that window of YOURS (not above other apps), hides/
// minimizes/closes with it; no own taskbar entry on win/linux. Open-time
// only; on macOS it also moves with its parent. true = 'main', or a win id.
tiny.win.open('about', { page: 'about.html', size: '360x420', parent: true });

// every window runs the full tiny.* bridge; win.* targets its own window.
// backend: app.openWindow(id, opts), app.window(id).setTitle/push/close/…,
// app.push broadcasts to all windows, export onWindowClosed(id, app);
// api handlers: (params, app, meta) — meta.window = caller's window id

dialogs native panels, run by the launcher

await tiny.dialog.openFile();      // path | null
await tiny.dialog.openFiles();     // paths[] | null
await tiny.dialog.pickFolder();    // path | null
await tiny.dialog.saveFile();      // path | null
await tiny.dialog.alert(message, detail);
await tiny.dialog.confirm(message, { detail, ok, cancel });   // true | false
await tiny.dialog.prompt(message, { default, ok, cancel });   // string | null

tiny.tray menu bar apps

tiny.tray.set({
  title: 'MyApp',          // text and/or icon (template by default)
  icon: 'sf:cup.and.saucer.fill',  // SF Symbol by name — or a png path ('tray.png')
  tooltip: '…',
  menu: [{ id: 'show', label: 'Show' }, { separator: true }, { id: 'q', label: 'Quit' }],
  primaryAction: true,     // left click fires onClick; menu opens on right-click
});
tiny.tray.on((id) => …);       // menu clicks
tiny.tray.onClick(fn);         // icon click (no menu set, or primaryAction left click)
tiny.tray.remove();

// the menu-bar-only app recipe: "activation": "accessory" in tinyjs.json
// (launches with no Dock icon, window hidden — no flash), then:
tiny.win.setHideOnClose(true);
tiny.win.show();               // whenever the window is wanted

tiny.store persistent settings

Flat keys, JSON values, atomic writes — ~/Library/Application Support/<bundle id>/store.json. Reach for SQLite when it gets query-shaped.

await tiny.store.set('key', value);
await tiny.store.get('key');      // value | null
await tiny.store.delete('key');
await tiny.store.all();           // { … }

tiny.clipboard native NSPasteboard

Lives in the long-lived launcher process — no pbpaste/osascript spawns, no scratch files, and multi-file writes never lose their tail to a flush race.

const clip = await tiny.clipboard.read();
// { kind: 'files'|'image'|'color'|'text'|'empty', changeCount,
//   text, html, paths, image, imageSize, color,
//   concealed, sourceApp, sourceURL }
// image: png temp path, valid until the clipboard changes again
//   (copy the file to keep it); imageSize: { width, height } px
// concealed: password-manager marker (org.nspasteboard Concealed/
//   Transient) — clipboard-history apps must skip these
// sourceApp: { name, bundleId } — frontmost app when the change was
//   noticed (exact while watch() runs, best-effort otherwise)
// sourceURL: page a Chromium-browser copy came from

tiny.clipboard.write({ text: 'hi' });               // any combination:
tiny.clipboard.write({ paths: ['/tmp/a.png', '/tmp/b.png'] });
tiny.clipboard.write({ image: pngPathOrBase64, color: '#ff8800' });

await tiny.clipboard.changeCount();   // cheap "did it change?" probe
tiny.clipboard.watch(500);            // launcher-side polling (0 spawns)
tiny.clipboard.onChange(({ changeCount, self }) => …);  // self = own write
tiny.clipboard.unwatch();             // stops delivery, not the counter —
// the OS keeps counting, so one changeCount() after a gap (a wake, a window
// coming back) says whether re-reading is worth it, without touching contents
// backend: app.clipboard.* is the same api; a createApp onClipboardChange
// handler auto-starts the watcher

tiny.hotkey system-wide

tiny.hotkey.register('boss', 'cmd+shift+k');   // fires even unfocused
tiny.hotkey.on((id) => tiny.win.show());
tiny.hotkey.unregister('boss');

tiny.audio an EQ on your own output

A DSP chain — graphic EQ, headphone correction, a crossover — on this app's own output. Native (applied below the browser, so it reaches audio the page never gets samples for and survives a reload) on Linux (PipeWire) and macOS 14.2+ (a muted Core Audio process tap); capabilities().audioFilters is false on Windows — the only way to silence the direct signal there is session volume, which Windows persists on a key every WebView2 app shares, so a crash while filtering would near-silence all of them. pageChain is the fallback: the same chain from Web Audio nodes in the page, same RBJ curves, same verbs.

const can = await tiny.system.capabilities();
// one backend decision, identical code after it
const eq = can.audioFilters ? tiny.audio : tiny.audio.pageChain(ctx);
if (eq.input) { src.connect(eq.input); eq.output.connect(ctx.destination); }
await eq.filters([
  { type: 'gain', gain: 1.0 },                     // preamp (linear)
  { type: 'peaking',   freq: 60,   q: 1.1, gain: 4 },   // dB
  { type: 'highshelf', freq: 8000, q: 0.7, gain: -2 },
]);
eq.filter(1, { freq: 60, q: 1.1, gain: -3 });  // retune ONE, live — slider drags
await eq.balance(-0.2);                        // -1 left .. 1 right, no filter slot
await eq.clear();                              // unprocessed output restored

Types: peaking, lowshelf, highshelf, lowpass, highpass, bandpass, notch, allpass (freq/q/gain, gain in dB) and gain (a linear multiplier). At most 28 filters (15 if any uses gainR). Replacing the chain rebuilds it; keep the shape stable and let sliders call filter(i, …).

pageChain is page-scoped — it filters what you route through inputoutput, nothing else — and Web Audio's shelves ignore q / per-filter gainR (use balance()). Don't use it on Linux: Web Audio reaching ctx.destination crackles there, which is exactly why the native chain exists.

tiny.audio.sampler sound effects

One sampled-SFX mixer per app: a bank of short decoded sounds (wav/mp3/flac guaranteed) fired with per-voice volume, pan and pitch, mixed into one output. Game and UI sound effects — not streaming, not music (that's <audio>), not a sequencer. It exists so SFX work on Linux, where Web Audio reaching ctx.destination crackles (see audio filters): there the launcher decodes and mixes natively in PipeWire's real-time data path. On macOS/Windows it mixes in the main window's page via Web Audio, which is already real-time there. capabilities().sampler reports 'native' | 'page', but the API is identical — don't branch.

const s = tiny.audio.sampler;
await s.load('coo', '/abs/path/coo.mp3');   // by path; ArrayBuffer also accepted
const v = await s.play('coo', { vol: 0.8, pan: -0.3, rate: 1.06, loop: false });
v.set({ pan: 0.1 });                        // live, no restart
v.stop();                                   // short fade-out, no click
s.master(0.5);                              // one master gain
s.stopAll();
s.unload('coo');                            // frees the decoded PCM; cuts its voices

vol is linear 0..1, pan −1..1 equal-power (StereoPanner's law — the native mixer matches it, so the same numbers sound the same everywhere), rate a playbackRate-style ratio (pitch and speed together). Up to 32 voices; past that play() steals the oldest rather than failing. App-scoped: every window and the backend (app.audio.sampler) drive the SAME mixer with the same state.

Load by path when you can — bytes are written to the app cache once and loaded from disk, never streamed around. Decoded audio is the real memory (48kHz stereo ≈ 375KB per second), which is why this API is samples-only. On macOS/Windows a main-window reload re-arms the bank automatically; voices playing at that moment die. On Linux the launcher decodes, so effects can't be broken by missing GStreamer plugins, and an active filter chain applies to the sampler like everything else the app plays. Out of scope: sample-accurate scheduling (start(when)), per-voice filters, MediaStreams.

tiny.audioTap read the audio output

Read the app's (or the system's) rendered audio output as PCM — for VU meters and visualizers, including audio that never touches Web Audio (native HLS, CORS-tainted streams, other apps). Read-only: it observes the mix in sync with what's audible, it can't process it (EQ still needs the signal in the graph — see proxyURL). Add "audioTap": "app" (or "system") to tinyjs.json. macOS 14.4+.

await tiny.audioTap.start({ scope: 'app', interval: 80 }); // resolves true, or throws { code }
tiny.audioTap.on(({ pcm, sampleRate, channels, frames, t }) => {
  const bin = atob(pcm), n = bin.length >> 1;   // base64 -> interleaved LE Int16
  let peak = 0;
  for (let i = 0; i < n; i++) {
    const v = ((bin.charCodeAt(2*i) | (bin.charCodeAt(2*i+1) << 8)) << 16 >> 16) / 32768;
    if (Math.abs(v) > peak) peak = Math.abs(v);
  }
  drawMeter(peak);                              // samples interleave by `channels`
});
tiny.audioTap.stop();                            // (or the owning window closing)

Authorization is deferred to the first start() — declaring the manifest key does nothing until you call it, so you can lazy-arm the tap the first time a meter appears. The first start() prompts for "System Audio Recording" — even scope:'app', because WKWebView renders audio in a separate com.apple.WebKit.GPU helper, making the tap a cross-process capture; the grant persists per app. scope:'system' also hears other apps (excludeSelf drops your own). start() throws an Error with a .code: unsupported (pre-14.4), not-declared (manifest missing the scope), denied (TCC refused) or failed; a denied tap can't be reported synchronously — it arrives as silent (all-zero) chunks. Under tinyjs dev the audio "owner" is your terminal, not your app, so the tap delivers real PCM only if that terminal holds the grant — otherwise silence; a built .app owns its own grant.

keystrokes paste into other apps

A CGEvent posted by the launcher — one permission (Accessibility) whose prompt names your app, instead of osascript→System Events needing two grants that name osascript or the terminal.

await tiny.app.keystroke('cmd+v');   // -> { ok, trusted }
await tiny.app.paste();              // = keystroke('cmd+v')
// hide the window first so the paste lands in the frontmost app:
tiny.win.hide();  await tiny.app.paste();
// trusted: false → Accessibility isn't granted (see permissions)

tiny.app.permissions onboarding, not surprises

Check before use — "needs Accessibility to paste — Open Settings" beats failing silently at first use.

await tiny.app.permissions.check('accessibility');
// 'granted' | 'denied' | 'undetermined' | 'unsupported'
await tiny.app.permissions.request('accessibility');
// prompts; accessibility opens System Settings pointed at your app
// names: 'accessibility', 'screen', 'notifications' (packaged apps),
//        'microphone', 'camera' (the TCC layer under getUserMedia),
//        'automation' (System Events) or 'automation:<bundle-id>'

Mic + camera: getUserMedia() just works in a tinyjs page — the launcher answers WebKit's per-origin prompt itself, so users see the one system dialog naming your app. A packaged app must declare "permissions": { "microphone": "why", "camera": "why" } in tinyjs.json: the strings become the dialog text (macOS kills a bundle that captures without them), and signed builds get the matching hardened-runtime device entitlements.

Two quirks: 'screen' never reads 'undetermined' — macOS only exposes a yes/no preflight for screen recording, so it's 'denied' until granted in System Settings. And in dev mode, TCC grants attach to the shared launcher binary (~/.tinyjs) rather than your app — every dev app shares them, and a launcher update re-prompts; packaged apps carry their own identity and grants.

shell, login & app surface stop shelling out

The NSWorkspace verbs apps otherwise spawn open for, plus SMAppService login items and the app surface the OS owns — the Dock on macOS, the taskbar on Windows, the launcher on Linux. Shell calls resolve true or reject with the reason.

await tiny.app.shell.open('https://tinyjs.app');  // default browser
await tiny.app.shell.open('/path/report.pdf');    // default app for the file
await tiny.app.shell.reveal(path);   // show in Finder
await tiny.app.shell.trash(path);    // recoverable — prefer over deleting

// standard per-app directories — no hardcoded ~/Library paths
await tiny.app.paths();  // { home, data, cache, logs, temp, downloads,
                         //   desktop, documents } — data/cache/logs are per
                         //   app id; create on first write. Backend twin
                         //   app.paths is a plain object (no await).

// launch at login (packaged .app on macOS 13+; dev mode -> 'unsupported')
await tiny.app.launchAtLogin.get();      // 'enabled' | 'disabled' |
await tiny.app.launchAtLogin.set(true);  //  'requires-approval' | 'unsupported'
// 'requires-approval': the user must allow it in System Settings > Login Items

// decorate the OS's app surface: Dock / taskbar / launcher
tiny.app.badge('3');   tiny.app.badge('');    // '' clears
tiny.app.attention();                    // until the app is activated
tiny.app.attention({ critical: true });  // until the user acts
tiny.app.presence('menubar');            // 'normal' | 'menubar'

// keep the system awake — replaces spawning `caffeinate` (the assertion
// dies with the app, so a crash never wedges sleep)
await tiny.app.power.preventSleep('Exporting video');
await tiny.app.power.preventSleep('Playing', { display: true }); // screen too
await tiny.app.power.allowSleep();

// the active app right now — who focus returns to after win.hide()
await tiny.app.frontmostApp();   // { name, bundleId, pid } | null

// sounds: system beep, a system sound by name, or an audio file path
await tiny.app.beep();
await tiny.app.playSound('Ping');   // -> false if it didn't load

// native share sheet — anchor it at the click
btn.addEventListener('click', (e) =>
  tiny.win.share({ url, text, paths, x: e.clientX, y: e.clientY }));

// seconds since the user's last input — pause polling when they're away
await tiny.system.idleTime();

// the user's languages + time zone, read from the OS (not from LANG)
const { language, languages, system, region, timeZone } = await tiny.system.locale();
// languages = filtered to the app's declared localizations; system = the
// raw user preference. They differ for an English-only app on a French Mac.

// Quick Look — the Finder-spacebar preview panel (no qlmanage spawn)
tiny.macos.quickLook('/path/photo.heic'); // array pages with arrow keys
tiny.macos.quickLook();                   // close

// screenshot a display (id from screens(); default primary) — png in the
// temp dir, you own the file. Needs the 'screen' permission + macOS 14.
const { path, width, height } = await tiny.app.captureScreen();

system services the OS does the work

On-device, no cloud, no extra permission infrastructure — and, despite where these used to be documented, not macOS-only. Ask tiny.system.capabilities() if in doubt.

// the system eyedropper — any pixel, any app, NO screen-recording perm
// macOS · Windows · Linux (xdg portal)
const color = await tiny.app.pickColor();   // '#rrggbb' | null on cancel

// a thumbnail png for ANY path — a real preview where Quick Look has a
// renderer, the document/app/folder ICON where it doesn't, so it never
// fails on file type alone. A missing path DOES reject.
// macOS (any path) · Windows · Linux (images)
const thumb = await tiny.app.thumbnail('/path/file.psd', 256);
// -> { path, width, height } — @2x, aspect preserved

// find files by name or content — macOS Spotlight, Windows Search,
// plocate/locate on Linux (falling back to a bounded find)
const hits = await tiny.app.spotlight('invoice');   // up to 100 paths

secrets & auth the OS keychain, not your JSON

Tokens belong in the platform's credential store, never in tiny.store — which is a plain JSON file anyone can read.

// macOS Keychain · Windows Credential Manager · Linux Secret Service
await tiny.app.secrets.set('api-token', 'abc123');
await tiny.app.secrets.get('api-token');     // string | null
await tiny.app.secrets.delete('api-token');

// set replaces (never duplicates), delete of a key that was never
// there still resolves true, and an unsaved key reads back as null

// Touch ID / Windows Hello / the account-password sheet
// (no Linux equivalent yet — capabilities().authenticate says so)
if (await tiny.app.authenticate('unlock the vault')) { … }

On macOS the keychain's ACL names the binary that wrote the value, so a secret saved while running under tinyjs dev makes the built app prompt the first time it reads it — and rebuilding the app changes its signature, so it prompts again. Real users never see this; you will, while developing.

macOS only no equivalent elsewhere

Everything macOS-only now lives in tiny.macos.*, and calling any of it off macOS rejects with the reason rather than resolving something empty — a call that can never work there is a bug in the app, not a missing feature. Guard with tiny.system.isMacOS(), or gate on tiny.system.capabilities().

If one of these later grows a Windows or Linux implementation it moves back to tiny.app, keeping the tiny.macos name working alongside it for a release or two — so a rename in that direction won't break anything overnight.

// AppleScript in-process — control Music, Spotify, Finder, anything
// scriptable; no osascript spawn, same 'automation' permission
await tiny.macos.applescript('tell application "Music" to playpause');

// the Finder-spacebar preview panel; no args closes it
tiny.macos.quickLook('/path/photo.heic');


// on-device OCR (Vision) — captureScreen + this = screenshot-to-text
const { text, blocks } = await tiny.macos.ocr('/path/scan.png');
// blocks: [{ text, confidence, box }] — box normalized 0..1, top-left

// share stays on tiny.win — it anchors to a specific window, and
// window-scoped calls belong on the window namespace even when macOS-only
await tiny.win.share({ url });        // native share sheet, anchored in the window
await tiny.macos.recorder.start({ path }); // screen recording to .mp4
await tiny.macos.selectedText();        // text selected in the front app

media Now Playing, speech, rich notifications

// Now Playing — show in Control Center / lock screen and receive the
// hardware media keys (F7/F8/F9, AirPods taps, Control Center transport)
tiny.app.nowPlaying.set({ title: 'Song', artist: 'Band', album: 'LP',
                        duration: 240, elapsed: 12, playing: true });
tiny.app.onMediaKey(({ command, time }) => { // play|pause|toggle|next|
  if (command === 'toggle') togglePlayback();     // previous|seek (time=secs)
});
tiny.app.nowPlaying.clear();

// text-to-speech; say() resolves when playback ends (false if interrupted)
const voices = await tiny.app.voices();   // [{ id, name, lang, quality }]
await tiny.app.say('Export finished', { voice: voices[0].id, rate: 0.5 });
tiny.app.stopSpeaking();

// notifications with action buttons + a reply field (packaged apps)
tiny.notify('New message', 'from Alex', { actions: [
  { id: 'reply', title: 'Reply', reply: true, placeholder: 'Message…' },
  { id: 'del', title: 'Delete', destructive: true },
]});
tiny.app.onNotificationAction(({ id, action, reply }) => {
  if (action === 'reply') sendReply(reply);   // reply = the typed text
});
// backend twins: export onMediaKey(info, app), onNotificationAction(info, app)

// record a display to an .mp4 (SCStream → H.264; video only for now).
// Needs the 'screen' permission + macOS 14; one recording at a time.
await tiny.macos.recorder.start({ path: '/tmp/demo.mp4' });  // screenId optional
const { path, duration } = await tiny.macos.recorder.stop();  // finalized file

window fx overlays, HUDs, pets, window managers

// stack + behaviour: draw-on-screen overlays, HUDs over fullscreen apps,
// desktop pets, palettes that follow you onto every Space
tiny.win.setClickThrough(true);   // mouse events pass through the window
// …which means the window can't be clicked to switch it back off. Keep a
// hotkey or a tray item for that; an in-window button can never be pressed.
tiny.win.setLevel('overlay');     // 'normal'|'floating'|'overlay'|'desktop'
// floating = what setAlwaysOnTop(true) sets. overlay clears fullscreen apps
// and Mission Control on macOS; Windows and Linux have two bands, so overlay
// lands on the same topmost as floating. desktop drops it onto the wallpaper.
// Mission Control (macOS) follows from these: normal + floating windows
// show in the four-finger swipe like any document; desktop, overlay and
// click-through windows stay out of it — a pet is not a document.
tiny.win.setAllSpaces(true);      // follow across Spaces + over fullscreen
// macOS and X11 only — a no-op on Windows, and Wayland has no equivalent.

// grab the text selected in ANY app — PopClip-style popovers (Accessibility)
const sel = await tiny.macos.selectedText();   // string | null

// arrange other apps' windows — Rectangle/Magnet territory (Accessibility)
const wins = await tiny.macos.otherWindows();  // [{ app, pid, title, x,y,w,h }]
await tiny.macos.moveWindow(wins[0].pid, { x: 0, y: 0, width: 1280, height: 800 });

// anchor a dropdown window under the tray icon
const spot = await tiny.tray.position();     // { x, y, width, height } | null

// render the page to a vector PDF (WKWebView) — invoices, reports
const { path } = await tiny.win.printToPDF('/tmp/report.pdf');

// a live app icon (render a canvas → progress rings)
tiny.app.icon(canvasPngPath);      // '' resets to the bundle icon

// battery + Wi-Fi for menu-bar monitors
const bat = await tiny.system.battery();  // { percent, charging, plugged, minutesRemaining } | null
const net = await tiny.system.wifi();     // { ssid, bssid, rssi, txRate } | null (ssid needs Location)

// find files by name/content (Spotlight, no mdfind spawn) — 100 paths max
const docs = await tiny.app.spotlight('quarterly report');

// on-device LLM — Apple's FoundationModels (offline, no API key, private).
// Needs macOS 26 + Apple Intelligence on; guard on availability().
if (await tiny.macos.ai.availability() === 'available')
  await tiny.macos.ai.generate('Summarise: ' + text, { instructions: 'Be terse.' });

// tool calling — BACKEND only (run() is a real function, it can't cross
// the bridge from a page). The schema is built at runtime from parameters.
const { text, calls } = await app.macos.ai.generate('Move the window to 200, 120', {
  instructions: 'You control a desktop app window. Use the tools.',
  tools: [{ name: 'moveWindow', description: 'Move the window on screen.',
            parameters: { x: { type: 'integer', description: 'x in points' },
                          y: { type: 'integer', description: 'y in points' } },
            run: ({ x, y }) => { app.window('main').setPosition(x, y); return 'moved'; } }],
});

Read calls, not text. Measured on macOS 26.5: asked for three tool calls in one turn, the model made all three in one run out of four — and its prose claimed all three every time, including the runs where it silently skipped one. It also passed a wrong argument once. calls is what happened; the sentence is a summary that may be fiction. Put anything irreversible behind a confirmation.

system theme + power

await tiny.theme.get();            // { dark } | null
tiny.theme.on((dark) => …);        // live changes
tiny.api.on('sleep', fn);  tiny.api.on('wake', fn);

wrapping a hosted site all three platforms

"url" in tinyjs.json makes the main window a remote page — no local frontend needed. Everything a browser does, which a local-page app never missed, then applies: JS dialogs get native panels headlined by the page's origin, downloads land on disk, popups and navigation are yours to route, and tiny.win.find() powers ⌘F. frontend.devUrl is dev-only — a packaged app built that way has no page at all.

Gate the origin. tiny is injected into every origin, so without "api" the site's own JavaScript holds an RPC channel to a backend with full filesystem and process access. The gate is enforced in the backend — the page-side object is editable by a hostile page, so page-side gating is decoration — and keys off the calling frame's origin as the engine reports it, not as the page claims it. With origins present, an origin matching no key gets nothing, so a redirect to an unlisted domain inherits no access.

{ // preset, lists, or per-origin keyholes — enable wins over disable
  "api": "wrapper",
  "api": { "disable": ["*"], "enable": ["notify", "win.*", "store.*"] },
  "api": { "origins": {
    "https://app.example.com": ["notify", "store.*"],
    "file://*": "all" } } }
// page — what the gate denies THIS origin, so you can hide the UI
const { api } = await tiny.system.capabilities();  // { gated, denied: [...] }
await tiny.win.find('needle', { forward: true, matchCase: false });
// -> { found, matches, activeMatch } — call again to step
await tiny.win.stopFind();
// backend (src/main.js) — route links, watch loads, veto navigations
export function onNavigate(info, app) {
  // kind: 'policy' | 'start' | 'commit' | 'finish' | 'fail' | 'crash'
  if (info.kind === 'policy' && !info.url.startsWith('https://app.example.com'))
    return 'external';   // 'deny' | 'external' (hand to the real browser)
}
export function onDownload(info, app) { }  // started|progress|done|failed|denied|cancelled
export function onWindowOpen(info, app) { }  // 'window' | 'external' | 'deny'

A policy answer slower than ~400ms allows, so a wrapper can never deadlock its own first load. Engine caveats worth knowing before you promise a behaviour: on Linux a denied navigation has already made its request (the only place WebKitGTK can tell a main frame from a subframe is the response decision), and on Windows an asked main-frame POST re-issues as a GET.

deep links & files packaged .app

Claim "urlScheme" / "fileExtensions" in tinyjs.json. Cold-start events are buffered; a second open activates the running instance (single-instance is automatic).

tiny.app.onOpenUrl((url) => …);      // open myapp://compose?to=x
tiny.app.onOpenFiles((paths) => …);  // "Open With", Dock drops

auto-update ship new versions

Each release: tinyjs publish, upload dist/publish/* next to your manifest url. Installs verify sha256 + code signature (Team-ID pinned for Developer ID builds), swap atomically with rollback, and relaunch.

const { available, latest, notes } = await tiny.api.call('update.check');
if (available) await tiny.api.call('update.install');

// or let tinyjs check for you — "update": { "url": …, "auto": "launch" }
// (or "daily"); packaged apps get an 'update-available' event with
// { current, latest, notes } (notes from `tinyjs publish --notes "…"`)
tiny.api.on('update-available', async ({ latest, notes }) => {
  if (await tiny.dialog.confirm(`Update to ${latest}?`, { detail: notes ?? '' }))
    await tiny.api.call('update.install');
});
// backend twin: export function onUpdateAvailable(info, app) { … }

how it works two processes, one socket

Your app is a backend process (txiki.js, full system access) and a native window, talking over a private socket. No HTTP server, no ports, nothing listening for anything else to find.

┌──────────────────────┐  unix socket   ┌──────────────────────────┐
│ backend (txiki.js)   │◄──────────────►│ launcher (C++, ~380 KB)  │
│ your src/main.js     │  line protocol │ native/launcher-macos.cc │
│ + runtime/bridge.js  │                │ · WKWebView window       │
│ · owns app logic     │                │ · webview_bind bridge    │
│ · fs/net/process API │                │ · native dialogs         │
│ · spawns launcher    │                │ · else: dumb forwarder   │
└──────────────────────┘                └──────────┬───────────────┘
                                                   │ window.__invoke / eval
                                        ┌──────────▼────────────┐
                                        │ your page (file://)   │
                                        │ api.call('m', params) │
                                        │ api.on('event', fn)   │
                                        └───────────────────────┘

The backend creates the socket in a fresh 0700 temp dir — invisible to other users, with no port to collide with or scan — then spawns the launcher pointed at your frontend's index.html. Closing the window ends the launcher; the backend notices, cleans up and exits. tiny.quit() works the other way. On Windows the socket is a named pipe; everything above it is identical.

The protocol is newline-delimited text, one line per message, payloads JSON — CALL / RET for request–response, EVT for backend→page pushes, and a verb per native operation. TINYJS_DEBUG=1 traces every line.

portability mac-first, Windows + Linux in beta

All three run the same runtime and the same protocol; only the launcher and the transport differ.

              launcher source            webview               transport
macOS         launcher-macos.cc         WebKit                unix socket
Windows       launcher-win.cc           WebView2              named pipe
Linux         launcher-linux.cc         GTK3 + WebKitGTK 4.1  unix socket

Anything unported fails cleanly, so cross-platform code can feature-detect. Capability calls reject with a specific reason, query calls resolve null, and fire-and-forget ones are silent no-ops. Ask before you call:

const caps = await tiny.system.capabilities();
// -> { os: 'macos' | 'windows' | 'linux', badge: true, progress: true, … }
// Anything ABSENT from the answer is supported on that platform.
if (caps.badge) tiny.app.badge('3');

// synchronous, safe during page setup (the webview's own UA is decisive)
tiny.system.os();  tiny.system.isMacOS();  tiny.system.isWindows();  tiny.system.isLinux();

// the chip is the one thing the page CAN'T see: WKWebView reports MacIntel
// on Apple silicon, so ask the backend
await tiny.system.architecture();   // 'arm64' | 'x86_64'

// requirements() filtered to the ones that failed — [] on macOS/Windows
const gaps = await tiny.system.missing();  // [{ id, feature, detail, install }]

Some things are not "not ported yet" but concepts the other OSes don't have — AppleScript, Quick Look. Those live in tiny.macos.* and reject elsewhere, so the split is visible in the call itself. Everything that could exist on another OS stays on tiny.app and answers 'unsupported' until it does. The full per-OS breakdown is in the README's Portability section.

gotchas things that cost real debugging

Mostly txiki.js (v26.6.0) and WebKit sharp edges, kept here so you don't rediscover them:

// txiki streams have no `for await` — use a reader
const reader = res.body.getReader();

// tjs.cwd is a PROPERTY, not a function
const here = tjs.cwd;

// silencing a child's output is 'ignore' — 'null' silently INHERITS
tjs.spawn(argv, { stdout: 'ignore' });

// import.meta.url throws inside a compiled binary — resolve against
// tjs.exePath instead

Pages load as real file:// documents rather than through webview_set_html: the latter's about:blank origin isn't a secure context, and WebKit hides SecureContext-only APIs there — navigator.gpu among them. The file:// origin is also what makes multi-file frontends work, with relative scripts and images loading straight from the page's directory.

On Linux, Web Audio reaching ctx.destination crackles under WebKitGTK — the graph renders on a normal-priority thread and nothing will promote it. That's measured, not theoretical; the native audio filters exist precisely because of it.

rakali, the tinyjs mascot