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.
curl -fsSL https://tinyjs.app/install | sh
irm https://tinyjs.app/install.ps1 | iex
curl -fsSL https://tinyjs.app/install | sh
sudo apt install libwebkit2gtk-4.1-0) · MITThen tinyjs new myapp && cd myapp && tinyjs dev
— a window opens with hot reload. The CLI updates itself with
tinyjs update.
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)
{
// 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" }
}
{
"name": "myapp",
"title": "My App",
"size": "960x640",
"id": "com.example.myapp",
"version": "1.0.0",
"minTinyjsVersion": "0.30.0",
"icon": "icon.png",
"signIdentity": "Developer ID Application: …",
"urlScheme": "myapp",
"fileExtensions": ["md"],
"permissions": { "microphone": "why", "camera": "why",
"speechRecognition": "why" },
"audioTap": "app",
"contextMenu": false,
"about": "menu",
"userAgent": "Mozilla/5.0 …",
"url": "https://app.example.com",
"api": { "origins": { "https://app.example.com": ["notify", "store.*"] } },
"inject": "src/shim.js",
"downloads": "auto",
"popups": "external",
"chrome": { "frame": false, "vibrancy": "hud" },
"backend": "backend/main.ts",
"frontend": {
"build": "npm run build", "dist": "dist",
"dev": "npm run dev", "devUrl": "http://127.0.0.1:5173"
},
"activation": "accessory",
"update": { "url": "https://…/manifest.json" },
"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
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.
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' }]
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 }
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 AudioA 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.
// 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
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
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.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
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(); // { … }
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.register('boss', 'cmd+shift+k'); // fires even unfocused
tiny.hotkey.on((id) => tiny.win.show());
tiny.hotkey.unregister('boss');
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 input→output, 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.
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.
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.
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)
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.
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();
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
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.
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
// 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
// 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.
await tiny.theme.get(); // { dark } | null
tiny.theme.on((dark) => …); // live changes
tiny.api.on('sleep', fn); tiny.api.on('wake', fn);
"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.
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
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) { … }
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.
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.
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.
