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.
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,
# TypeScript backend bundled with esbuild (npm pkgs ok)
tinyjs dev # run with hot reload (frontend swaps in place,
# 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 Developer ID)
# --dmg: rebuild the dmg from the stapled .app
tinyjs update # update the tinyjs CLI itself (--check: report only)
{
"name": "myapp", // binary + bundle name (required)
"title": "My App", // window title & menu name (default: name)
"size": "960x640", // initial window size
"id": "com.example.myapp", // bundle identifier
"version": "1.0.0", // shown in About; drives auto-update
"icon": "icon.png", // 1024×1024 → AppIcon.icns
"signIdentity": "Developer ID Application: …", // default: ad-hoc
"urlScheme": "myapp", // deep links: myapp://…
"fileExtensions": ["md"], // "Open With" file associations
"permissions": { "microphone": "why", "camera": "why" }, // getUserMedia
"audioTap": "app", // enable tiny.audioTap ("app" | "system")
"contextMenu": false, // suppress WebKit's default right-click menu (default: true)
"userAgent": "Mozilla/5.0 …", // override the webview UA (UA-sniffing sites; wrapping a devUrl)
"chrome": { "frame": false, "vibrancy": "hud" }, // frameless window
"backend": "backend/main.ts", // entry; .ts → esbuild bundle
"frontend": { // bundler hooks (Vite etc.)
"build": "npm run build", "dist": "dist",
"dev": "npm run dev", "devUrl": "http://127.0.0.1:5173"
},
"activation": "accessory", // menu-bar agent: no Dock icon, starts hidden (no flash)
"update": { "url": "https://…/manifest.json" },
"notarize": { "profile": "my-notary-profile" }
}
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,
setDockVisible, 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(...)
tiny.log(msg); // print in the backend terminal
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.
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.
tiny.win.setTitle(t); tiny.win.setSize(w, h);
tiny.win.center(); tiny.win.setPosition(x, y); // top-left origin
tiny.win.minimize(); tiny.win.restore();
tiny.win.fullscreen(); tiny.win.setFullscreen(bool); // toggle / absolute
tiny.win.setAlwaysOnTop(bool); tiny.win.setResizable(bool);
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, trafficLights: 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 });
// drag regions: <header data-tiny-drag> — drag moves, double-click zooms
tiny.win.startDrag(); tiny.win.zoom();
// drag real files OUT of the app (Finder, Slack, …) — call from a
// mousedown handler while the button is still held; image: optional png
row.addEventListener('mousedown', () =>
tiny.win.startDrag({ files: ['/tmp/report.pdf'] }));
// read the window back
const s = await tiny.win.getState();
// { x, y, width, height, fullscreen, minimized, visible, focused,
// alwaysOnTop, resizable, screen: { width, height, scale } }
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, trafficLights: false, vibrancy: 'hud' } });
// 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.win.openFile(); // path | null
await tiny.win.openFiles(); // paths[] | null
await tiny.win.pickFolder(); // path | null
await tiny.win.saveFile(); // path | null
await tiny.win.alert(message, detail);
await tiny.win.confirm(message, { detail, ok, cancel }); // true | false
await tiny.win.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();
// 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');
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 Dock tile.
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
tiny.app.dock.setBadge('3'); tiny.app.dock.setBadge(''); // '' clears
tiny.app.dock.bounce(); // until the app is activated
tiny.app.dock.bounce({ critical: true }); // until the user acts
// 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.app.idleTime();
// Quick Look — the Finder-spacebar preview panel (no qlmanage spawn)
tiny.app.quickLook('/path/photo.heic'); // array pages with arrow keys
tiny.app.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();
The frameworks macOS ships that nobody in JS-land can usually touch — all on-device, no cloud, no extra permissions infrastructure.
// the system eyedropper — any pixel, any app, NO screen-recording perm
const color = await tiny.app.pickColor(); // '#rrggbb' | null on cancel
// on-device OCR (Vision) — captureScreen + this = screenshot-to-text
const { text, blocks } = await tiny.app.ocr('/path/scan.png');
// blocks: [{ text, confidence, box }] — box normalized 0..1, top-left
// a thumbnail png for ANY file type Quick Look understands
const thumb = await tiny.app.thumbnail('/path/file.psd', 256);
// -> { path, width, height }
// Keychain secrets — tokens live here, never in tiny.store
await tiny.app.secrets.set('api-token', 'abc123');
await tiny.app.secrets.get('api-token'); // string | null
await tiny.app.secrets.delete('api-token');
// Touch ID (or the account-password sheet on Macs without it)
if (await tiny.app.authenticate('unlock the vault')) { … }
// AppleScript in-process — control Music, Spotify, Finder, anything
// scriptable; no osascript spawn, same 'automation' permission
await tiny.app.applescript('tell application "Music" to playpause');
// 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.app.recorder.start({ path: '/tmp/demo.mp4' }); // screenId optional
const { path, duration } = await tiny.app.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
tiny.win.setLevel('overlay'); // 'normal'|'floating'|'overlay'|'desktop'
tiny.win.setAllSpaces(true); // follow across Spaces + over fullscreen
// grab the text selected in ANY app — PopClip-style popovers (Accessibility)
const sel = await tiny.app.selectedText(); // string | null
// arrange other apps' windows — Rectangle/Magnet territory (Accessibility)
const wins = await tiny.app.otherWindows(); // [{ app, pid, title, x,y,w,h }]
await tiny.app.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');
// trackpad haptics + a dynamic Dock icon (render a canvas → progress rings)
tiny.app.haptic('generic'); // 'generic'|'alignment'|'level'
tiny.app.dockIcon(canvasPngPath); // '' resets to the bundle icon
// battery + Wi-Fi for menu-bar monitors
const bat = await tiny.app.battery(); // { percent, charging, plugged, minutesRemaining } | null
const net = await tiny.app.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).
// Ships only in a TINYJS_AI build on macOS 26; guard on availability().
if (await tiny.app.ai.availability() === 'available')
await tiny.app.ai.generate('Summarise: ' + text, { instructions: 'Be terse.' });
await tiny.theme.get(); // { dark } | null
tiny.theme.on((dark) => …); // live changes
tiny.api.on('sleep', fn); tiny.api.on('wake', fn);
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.win.confirm(`Update to ${latest}?`, { detail: notes ?? '' }))
await tiny.api.call('update.install');
});
// backend twin: export function onUpdateAvailable(info, app) { … }