Tiny desktop apps for macOS — and, in beta, Windows and Linux: a txiki.js backend + a native webview window.
- ~6 MB shipped, two real files — no Electron, no Node, no bundled Chromium
- No HTTP server, no ports — the page and the backend talk over a Unix domain socket in a private temp directory
- Backend is plain JavaScript with full system access (files, sockets, processes, FFI) via txiki.js
- Frontend is plain HTML/CSS/JS (multi-file, WebGPU included) rendered by the system WebKit
- Native menus, dialogs, tray/menu-bar apps, notifications, drag & drop with real file paths, window control
- Hot reload in dev, signed
.appbundles out ofbuild, auto-update out ofpublish
curl -fsSL https://tinyjs.app/install | shThe same script now handles Linux too (it detects the OS). Installs to
~/.tinyjs and symlinks tinyjs onto your PATH. Pin a version with
TINYJS_VERSION=vX.Y.Z. Later, tinyjs update re-runs the installer if a
newer release exists (tinyjs update --check only reports); tinyjs dev
also mentions new releases, checking at most once a day. Linux needs the
system WebKitGTK runtime: sudo apt install libwebkit2gtk-4.1-0 on
Debian/Ubuntu. Prebuilt binaries ship for Linux x86_64 and arm64 with the
first tagged release after Linux support merged — the installer says so
plainly against older releases.
To install from source instead:
git clone https://github.com/tarwin/tinyjsapp && cd tinyjsapp
./setup.sh # downloads the txiki.js runtime, compiles the launcher
ln -s "$(pwd)/tinyjs" /usr/local/bin/tinyjsOn Linux, setup.sh needs the system dev packages first —
sudo apt install build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev (Debian/Ubuntu). It
downloads a prebuilt tjs from the tinyjsapp releases, or builds txiki.js
from source (TJS_BUILD=1 ./setup.sh, needs cmake + ninja).
Windows (beta):
irm https://tinyjs.app/install.ps1 | iexInstalls prebuilt binaries to %LOCALAPPDATA%\tinyjs (override with
TINYJS_HOME; pin with TINYJS_VERSION) and adds it to your user PATH —
open a new terminal afterwards. Needs only the WebView2 runtime
(preinstalled on Windows 11). tinyjs update re-runs the installer.
(Requires a release that ships Windows assets — the first one after Windows
support merged; the installer says so plainly against older releases.)
To develop tinyjs itself from source (needs MinGW-w64:
winget install BrechtSanders.WinLibs.POSIX.UCRT):
git clone https://github.com/tarwin/tinyjsapp; cd tinyjsapp
powershell -ExecutionPolicy Bypass -File setup.ps1
# adds the checkout to your user PATH (-SkipPath to opt out); after that,
# `tinyjs dev` auto-rebuilds the launcher whenever the native sources changeLinux (beta):
Runs on X11 and Wayland sessions — Ubuntu 24.04+ and current distros with
webkit2gtk-4.1. Use the same install command above (it detects Linux) or
build from source with ./setup.sh (see the apt deps above). Needs the
libwebkit2gtk-4.1-0 runtime package.
Playing media? WebKitGTK decodes through GStreamer, and a stock desktop
install carries only some of it. Without the rest, <audio>/<video> play
MP3, Ogg/Vorbis, Opus, WAV and FLAC, but AAC/M4A won't play at all
(canPlayType('audio/mp4; codecs="mp4a.40.2"') returns "") and Media
Source Extensions report every type unsupported — which covers most podcasts,
most internet radio, and most adaptive streaming. Add:
sudo apt install gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-libavWebKit hints at this itself, logging "WebKit wasn't able to find a WebVTT encoder … unless gst-plugins-bad is installed" on startup.
Don't play audio through Web Audio on Linux. WebKitGTK renders the Web
Audio graph on a normal-priority (SCHED_OTHER) thread while its media threads
get real-time priority, so anything reaching ctx.destination misses its
deadline and crunches — on an idle machine, at any latencyHint, whether the
source is an element or a decoded buffer. A plain <audio> element goes
through GStreamer instead and is flawless. There is no graph-side fix; buffering
only softens it, and rtkit won't promote the thread.
So on Linux, play the element directly and get analysis from tiny.audioTap:
if (tiny.system.isLinux()) {
audio.volume = volume; // straight to the speakers, no graph
await tiny.audioTap.start({ scope: 'app' }); // PCM for your visualiser
} else {
const src = ctx.createMediaElementSource(audio);
src.connect(gain); gain.connect(ctx.destination);
}scope: 'app' captures only your own output (a private PipeWire null sink fed
by your app's ports), so playback is unaffected and you don't hear the rest of
the desktop. What you lose is anything that was a graph node — an equalizer,
a StereoPanner — so tell the user rather than leaving dead controls on screen.
For sound effects none of this applies: tiny.audio.sampler mixes decoded
samples with per-voice volume/pan/pitch natively in the launcher on Linux
(PipeWire's real-time data path — clean under any page load) and via Web Audio
on macOS/Windows, same API, same equal-power pan numbers. Use it instead of
createBufferSource graphs.
See examples/amp for the whole pattern, visualisers included.
Analysis-only graphs are fine: a MediaElementSource feeding nothing but an
AnalyserNode never reaches destination, so a missed deadline costs a
dropped frame rather than audible crackle. Keep such elements at volume = 0,
since WebKitGTK also plays a graph-routed element's own output straight to the
speakers (macOS/Windows mute it).
See Portability below for what's supported on Windows and Linux.
Full docs: tinyjs.app/docs · release history: tinyjs.app/changelog.
tinyjs new myapp
cd myapp
tinyjs devA window opens. dev hot-reloads: edit anything in src/frontend/ and the
page re-renders in place, caches bypassed (no restart, backend state
survives); edit backend sources and the process restarts automatically.
TINYJS_DEBUG=1 tinyjs dev traces every message crossing the bridge.
Frameworks welcome: tinyjs new myapp --template react-ts (or vue-ts,
svelte-ts, solid-ts, vanilla-ts, …) scaffolds a Vite app wired to tinyjs —
tinyjs dev runs Vite's dev server with HMR inside the native window, and
tinyjs build ships the built assets as usual. TypeScript backends are
bundled with esbuild automatically (which also makes npm packages usable in
the backend). The zero-dependency default scaffold is unchanged.
The tiny global is injected into every page by the launcher — no script
tag needed — and ships with full TypeScript definitions (types/tiny.d.ts).
A project is just:
myapp/
tinyjs.json # { name, title, size, id, version, icon?, signIdentity?,
# urlScheme?, fileExtensions?, chrome?, update?, notarize?,
# permissions? ({ microphone: "why", camera: "why" } for getUserMedia),
# contextMenu? (false suppresses WebKit's default right-click menu),
# debug? (true = F12 opens devtools, "open" = every window auto-opens
# them; default off — `tinyjs dev` always has F12),
# browserAccelerators? (true re-enables the engine's own keys —
# Ctrl+F find, Ctrl+R reload…; default suppressed, Windows only),
# activation? ("accessory" = menu-bar agent: no Dock, starts hidden),
# macos?/windows?/linux? (merged on top for that OS),
# minTinyjsVersion? (refuse to run on an older tinyjs) }
icon.png # 1024×1024 app icon (template ships a default)
src/main.js # backend: export const api = {...}; export function init(app) {}
src/frontend/ # index.html + any local js/css/images
Keys that genuinely differ per platform go in a macos / windows / linux
block, merged on top of the root ones for that OS — the block names are the
strings tiny.system.os() returns:
{
"name": "myapp",
"icon": "icon.png",
"chrome": { "frame": false },
"macos": { "signIdentity": "Developer ID Application: …",
"chrome": { "vibrancy": "hud" } },
"windows": { "icon": "icon.ico" },
"linux": { "icon": "icon-512.png" }
}Plain objects merge (the macOS build above gets { frame: false, vibrancy: 'hud' }), scalars and arrays replace. TINYJS_SIGN_IDENTITY and
TINYJS_NOTARY_PROFILE override the file — and say so when they displace a
value that was really there. Resolution order: root → OS block → env.
Backend (src/main.js) — every api function is callable from the page;
handlers receive (params, app):
export const api = {
hello: async ({ name }) => `hi ${name}`,
};
export function init(app) { // window is up
setInterval(() => app.push('tick', Date.now()), 1000);
// app also has: setTitle(t), setSize(w, h), setMenu(menus), eval(js),
// reload(), quit(), notify({title, body}), hide()/show()/center()/
// minimize()/fullscreen(), setPosition(x, y), setAlwaysOnTop(v),
// setResizable(v), setHideOnClose(v), presence(mode), print(),
// restore(), setFullscreen(v), getWinState(), setChrome(opts),
// startDrag(), zoom(), tray.set/remove,
// updateMenuItem(id, patch), getMenuItem(id), info, store.get/set/delete/all,
// hotkey.register(id, combo)/unregister(id), setContextMenu(items),
// update.check()/update.install(),
// clipboard.read()/write(data)/changeCount()/watch(ms)/unwatch(),
// keystroke(combo), paste(), permissions.check(name)/request(name),
// mousePosition(), screens(), paths, show({ activate: false }),
// shell.open(target)/reveal(path)/trash(path),
// launchAtLogin.get()/set(v), badge(text), attention(opts),
// power.preventSleep(reason, opts)/allowSleep(), frontmostApp(),
// beep(), playSound(target), window(id).share(opts),
// idleTime(), captureScreen(screenId),
// pickColor(), ocr(path), thumbnail(path, size),
// secrets.get/set/delete, authenticate(reason),
// nowPlaying.set/clear, say(text, opts), voices(), stopSpeaking(),
// recorder.start({ path, screenId })/stop(),
// selectedText(), otherWindows(), moveWindow(pid, rect),
// window(id).setClickThrough/setLevel/setAllSpaces, tray.position(),
// printToPDF(path), icon(png), presence(mode),
// macos.applescript(source)/quickLook(paths),
// battery(), wifi(),
// spotlight(query)
}
export function onMenu(id, app) {} // optional: handle menu clicks backend-side
export function onTray(id, app) {} // optional: tray clicks (id null = bare icon)
export function onHotkey(id, app) {} // optional: global hotkey presses
export function onContextMenu(id, app) {} // optional: context menu clicks
export function onSystem(kind, value, app) {} // optional: 'theme'|'sleep'|'wake'The backend runtime ships SQLite built in, handy for anything tiny.store
is too small for:
import { Database } from 'tjs:sqlite';
const db = new Database(dataDir + '/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' }]Frontend — the tiny global is injected into every page automatically:
const greeting = await tiny.api.call('hello', { name: 'world' }); // request/response
tiny.api.on('tick', (t) => ...); // backend push
// on() is additive (addEventListener-style: N handlers, all fire) and returns
// an unsubscribe; there's also tiny.api.off('tick', fn). The tiny.*.on sugar
// (menu.on, tray.on, theme.on, …) returns the unsubscribe too — and since the
// sugar wraps your callback, that return value is the only way to unhook it:
const stop = tiny.api.on('tick', onTick); stop();
tiny.api.off('tick', onTick); // same thing, by reference
tiny.log('debug msg'); tiny.quit();
// tiny.log prints from the BACKEND, tagged [web] — so the page's lines and
// the backend's interleave in one terminal, in the order they happened.
// Objects arrive as objects; it resolves true once the backend has the line.
tiny.notify('Done', 'Your export finished'); // desktop notification
// packaged apps get REAL Notification Center banners (your app's icon,
// permission prompt on first use) when built with a signing identity —
// even "Apple Development" works. Ad-hoc/dev builds fall back to osascript.
tiny.notify('Ping', 'body', { id: 'x', subtitle: '…', sound: true });
tiny.app.onNotificationClick((id) => ...); // backend: export onNotificationClick
await tiny.app.info(); // { version: <app>, tinyjs: <built with>, runtime: <txiki> }
// window control
// setSize/size are the PAGE's box (decorations excluded) — 1200x800 of
// document on every OS, whatever the title bar adds around it.
tiny.win.setTitle('My App'); tiny.win.setSize(1200, 800);
tiny.win.center(); tiny.win.setPosition(100, 80); // top-left origin
tiny.win.minimize(); tiny.win.restore();
tiny.win.fullscreen(); tiny.win.setFullscreen(true); // toggle / absolute
tiny.win.setAlwaysOnTop(true); tiny.win.setResizable(false);
tiny.win.hide(); tiny.win.show();
// hide() hides the APP (NSApp hide) — macOS returns focus to the previously
// active app on its own, so a palette can hide() then app.paste() with no
// frontmost-pid bookkeeping. show() re-activates;
tiny.win.show({ activate: false }); // …or surface WITHOUT stealing focus
// (overlay/HUD panels)
tiny.win.hide({ app: false }); // …or put away THIS WINDOW only, app
// stays frontmost (a welcome/launcher
// window stepping aside for documents)
tiny.win.setHideOnClose(true); // close button hides instead of quitting
// global cursor position (same top-left coords as setPosition — handy for
// popping a palette at the mouse)
const { x, y, window, screen } = await tiny.app.mousePosition();
// window = relative to this window's content area, clientX/clientY units,
// valid even while the cursor is OUTSIDE it: { x, y, inside }
// screen = the display the cursor is on: { x, y, width, height, scale }
// frameless / transparent / vibrancy windows (native resize + focus kept)
tiny.win.setChrome({ frame: false, windowControls: false, vibrancy: 'hud' });
// mark your own titlebar: <header data-tiny-drag>…</header> — drag moves the
// window, double-click zooms; interactive children are excluded automatically
// (or opt out with data-tiny-nodrag). Also in tinyjs.json as "chrome": {…}
// (packaged apps apply it before first paint — no titlebar flash).
tiny.win.startDrag(); tiny.win.zoom(); // manual equivalents
// Resizing a frameless window: only macOS keeps native edges throughout. An
// undecorated GTK window has none, and neither does a frameless SECONDARY
// window on Windows (WebView2's child HWND covers the whole rect, so the
// resize border is never hit-tested) — so on Linux, and for Windows
// satellites, the client adds invisible 5px grips around the edge
// automatically. Nothing to do per app. The Windows MAIN window is the
// exception: it keeps real left/right/bottom borders, trading the top edge.
tiny.win.startResize('se'); // 'n','ne','e','se','s','sw','w','nw' — for your own handle
// A fixed-size window (a Winamp-style deck) opts out of the grips:
// <html data-tiny-noresize> …or make it non-resizable:
tiny.win.setResizable(false);
// setResizable(false) means the USER can't drag the edges — your own
// setSize() still works, including shrinking to a titlebar for a shade view.
tiny.win.setMinSize(900, 640); // a FLOOR rather than a lock (win.open takes
// the same as minSize: '900x640'). Same rule as above on macOS and Windows —
// it binds the user, not your setSize; GTK clamps both.
tiny.win.setZoom(2); // native page zoom (0.25–5), rendered by the webview so
// it stays crisp. The page keeps laying out in CSS px and just has fewer of
// them — pair with setSize(w*2, h*2) for a real "double size" mode.
// square corners (drop macOS's rounded window corners). This makes the
// window BORDERLESS — square, no titlebar, no traffic lights — and is
// deliberately un-native: you lose the native titlebar drag (use
// data-tiny-drag) but keep resize edges, the shadow, and keyboard focus.
tiny.win.setChrome({ squareCorners: true });
// declare it in tinyjs.json "chrome": { "squareCorners": true } so it
// applies before first paint (no rounded→square flash on launch).
// acceptsFirstMouse: true delivers the click that focuses an unfocused window
// through to the page (macOS swallows it by default — "click once to focus,
// again to act"). Handy for palettes/toolbars and DOM drag regions.
tiny.win.setChrome({ acceptsFirstMouse: true });
// move the traffic lights (macOS): { x, y } from the window's top-left, for
// frameless windows whose custom titlebar is taller than the default corner
// assumes. One call — the launcher re-applies it across resizes and
// fullscreen round-trips. null restores the OS layout. Ignored on Windows
// and Linux. Also in tinyjs.json "chrome" so it applies before first paint.
tiny.win.setChrome({ windowControlsPos: { x: 12, y: 24 } });
// read the window back
const s = await tiny.win.getState();
// { x, y, width, height, outer: { width, height },
// fullscreen, minimized, visible, focused,
// alwaysOnTop, resizable, chrome: { frame, windowControls,
// windowControlsPos, transparent, vibrancy, squareCorners,
// acceptsFirstMouse },
// screen: { width, height, scale } }
// width/height are the page's box — hand them straight back to setSize and
// nothing moves. outer is the footprint on screen, decorations in (the page
// can't work that out itself: window.outerWidth is 0 in a WKWebView).
// x/y are the window's top-left, the units setPosition takes.
// …or don't poll it: window-state transitions arrive as events, whatever
// the cause (green button, menu item, F11, your own setFullscreen call).
const off = tiny.win.onState(({ win, fullscreen, maximized, minimized, focused }) => {
// same vocabulary as getState(); `win` because events are broadcast —
// every page hears about every window and filters by id ('main' or a
// win.open id). Wayland never reports `minimized` (compositor-private).
});
off(); // like every tiny on…, it returns its own unsubscribe
// files dragged onto the window arrive with REAL filesystem paths
tiny.win.onDrop((paths) => tiny.log(paths.join(', ')));
tiny.win.print(); // native print panel for the page
// persistent settings (JSON in ~/Library/Application Support/<app id>/)
await tiny.store.set('recent', ['/tmp/a.txt']);
const recent = await tiny.store.get('recent'); // value | null
await tiny.store.delete('recent'); await tiny.store.all();
// fetch that runs in the BACKEND — no CORS, CSP, or mixed-content limits,
// so the page can hit any origin. Same shape as window.fetch, returns a
// real Response (res.json()/res.text()/res.headers/res.ok all work).
const r = await tiny.fetch('https://api.example.com/data', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ q: 'hi' }),
});
const data = await r.json();
// { stream: true } gives a LIVE streaming body — the whole point for endless
// sources like internet radio (a buffered fetch would never resolve). The
// backend keeps the connection and the page pulls chunks on demand, with
// natural backpressure; res.body.getReader() is the endless tap.
const radio = await tiny.fetch('https://ice1.somafm.com/groovesalad-128-mp3', { stream: true });
const reader = radio.body.getReader();
for (;;) { const { value, done } = await reader.read(); if (done) break; /* feed decodeAudioData, MediaSource, … */ }
// reader.cancel() (or closing the window) tears the upstream connection down.
//
// 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 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 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. Details in
// TODO-txiki.md (the plan is to fix the runtime itself and drop the shim).
// tiny.proxyURL — get a cross-origin stream (internet radio) INTO Web Audio.
// A MediaElementSource on a cross-origin <audio> outputs silence by spec;
// proxyURL streams the remote through the native layer with permissive CORS,
// so the element is untainted and the full EQ/analyser 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()); // …→ EQ → 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 sees real
// samples. Tradeoff: audio.duration/currentTime are meaningless for such a live
// stream (huge fake timeline) — don't wire a seekbar to one.
// system-wide hotkeys (work while other apps are focused)
tiny.hotkey.register('boss', 'cmd+shift+k');
tiny.hotkey.on((id) => tiny.win.show());
tiny.hotkey.unregister('boss');
// tiny.audio.sampler — sampled sound effects (game/UI SFX): a bank of short
// decoded sounds (wav/mp3/flac) fired with per-voice vol/pan/rate, one mixed
// output, 32 voices with oldest-stealing. Works on Linux (native mix in the
// launcher — Web Audio crackles there) and macOS/Windows (Web Audio in the
// main window's page) with identical behavior; backend twin app.audio.sampler
// drives the same mixer. Not for music — that's <audio>.
const s = tiny.audio.sampler;
await s.load('coo', '/abs/path/coo.mp3'); // or an ArrayBuffer
const v = await s.play('coo', { vol: 0.8, pan: -0.3, rate: 1.06 });
v.set({ pan: 0.1 }); // live, no restart
v.stop(); // short fade, no click
s.master(0.5); s.stopAll(); s.unload('coo');
// tiny.audioTap — read the app's rendered audio OUTPUT as PCM (VU meters,
// visualizers) — including audio that bypasses Web Audio (native HLS,
// CORS-tainted streams). Read-only; needs "audioTap": "app" in tinyjs.json.
await tiny.audioTap.start({ scope: 'app', interval: 80 }); // 'system' also possible
tiny.audioTap.on(({ pcm, sampleRate, channels, frames }) => {
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);
}
meter.style.height = (peak * 100) + '%'; // channels/frames describe the layout
});
// tiny.audioTap.stop(); // (or the owning window closing) tears the tap down.
// macOS 14.4+. 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 is shown. The FIRST start() prompts for "System Audio
// Recording" (even scope:'app' — WKWebView renders audio in a separate
// com.apple.WebKit.GPU helper, so the tap is a cross-process capture); the
// grant persists per app. scope:'system' also hears other apps (excludeSelf
// drops your own). NOTE: under `tinyjs dev` the audio "owner" is your terminal,
// not your app — the tap delivers real PCM only if that terminal holds the
// System Audio Recording grant, otherwise silent chunks; a built .app owns its
// own grant. Denial can't be reported synchronously — it surfaces as silence.
// custom right-click menu (native NSMenu; null restores WebKit's default)
tiny.menu.setContext([{ id: 'copy-path', label: 'Copy Path' }, { separator: true }, { id: 'del', label: 'Delete' }]);
tiny.menu.onContext((id) => ...);
// to hide WebKit's default menu entirely (Reload/Back/Inspect Element…) for an
// app-like feel, set "contextMenu": false in tinyjs.json. setContext still wins.
// system theme + power events
const { dark } = await tiny.theme.get();
tiny.theme.on((dark) => document.body.classList.toggle('dark', dark));
tiny.api.on('sleep', () => ...); tiny.api.on('wake', () => ...);
// clipboard — native NSPasteboard in the launcher process (no pbpaste/
// osascript spawns, no scratch files, multi-file writes never lose the tail)
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) — history apps skip
// sourceApp: { name, bundleId } frontmost when the change was noticed
// (exact while watch() runs); sourceURL: Chromium copy's page url
tiny.clipboard.write({ text: 'hi' }); // any combination of
tiny.clipboard.write({ paths: ['/tmp/a.png', '/tmp/b.png'] }); // text, html,
tiny.clipboard.write({ image: pngPathOrBase64, color: '#ff8800' }); // paths…
await tiny.clipboard.changeCount(); // cheap "did it change?" probe
tiny.clipboard.watch(500); // launcher-side change polling
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 reading contents
// backend: same api as app.clipboard.*; a createApp onClipboardChange
// handler auto-starts the watcher
// drag files OUT of the app — into Finder, Slack, anywhere (real files).
// Must be called from a mousedown handler while the button is held:
row.addEventListener('mousedown', () =>
tiny.win.startDrag({ files: ['/tmp/report.pdf'], image: undefined }));
// native keystrokes — a CGEvent posted by the launcher. ONE permission
// (Accessibility) whose prompt names your app; no osascript, no spawn.
await tiny.app.keystroke('cmd+v'); // -> { ok, trusted }
await tiny.app.paste(); // = keystroke('cmd+v'); call
// win.hide() first so the paste
// lands in the frontmost app
// permissions — check before use, build onboarding instead of failing
await tiny.app.permissions.check('accessibility');
// 'granted' | 'denied' | 'undetermined' | 'unsupported'
await tiny.app.permissions.request('accessibility'); // prompt/open Settings
// names: 'accessibility', 'screen', 'notifications' (packaged apps),
// 'microphone', 'camera' (TCC layer under getUserMedia),
// 'automation' (System Events) or 'automation:<bundle-id>'
// 'screen' never reads 'undetermined' — macOS only exposes a yes/no
// preflight for screen recording ('denied' until granted in Settings).
// mic/camera: getUserMedia() just works — the launcher grants WebKit's
// per-origin prompt so users only see the system dialog naming your app.
// Speech-to-text isn't a tiny.* call at all — the page's own
// webkitSpeechRecognition works (WebKit and WebView2 both have it), but a
// packaged app must declare BOTH "microphone" and "speechRecognition" usage
// strings or the OS refuses the service with `service-not-allowed` and no
// prompt. Pair it with app.say() and macos.ai for a hands-free loop.
// Packaged apps declare "permissions": {"microphone": "why", ...} in
// tinyjs.json → Info.plist usage strings + hardened-runtime entitlements.
// Dev-mode note: TCC grants attach to the SHARED launcher binary
// (~/.tinyjs), not your app — all dev apps share them, and a launcher
// update re-prompts. Packaged apps carry their own identity and grants.
// shell — the NSWorkspace verbs apps otherwise spawn `open` for
await tiny.app.shell.open('https://tinyjs.app'); // default browser
await tiny.app.shell.open('/path/to/report.pdf'); // default app for the file
await tiny.app.shell.reveal('/path/to/file'); // show in Finder
await tiny.app.shell.trash('/path/to/file'); // recoverable (prefer over
// deleting user files)
// each resolves true, or rejects with the reason ('no such file',
// 'no application registered for URL', …)
// every display, same top-left coords as win.setPosition
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)
// standard per-app directories — prefer over hardcoding ~/Library paths
const paths = await tiny.app.paths();
// { home, data, cache, logs, temp, downloads, desktop, documents }
// data/cache/logs are per app id; create them on first write
// (backend: 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': macOS wants the user to allow it in
// System Settings > General > Login Items
// decorate the OS's app surface: Dock (macOS), taskbar (Windows),
// launcher (Linux) — verbs named for intent, not for furniture
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
// keep the system awake — replaces spawning `caffeinate` (the assertion
// dies with the app, so a crash never wedges sleep); the reason shows in
// `pmset -g assertions`
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()
const front = await tiny.app.frontmostApp(); // { name, bundleId, pid } | null
// sounds — system beep, a system sound by name, or an audio file
await tiny.app.beep();
await tiny.app.playSound('Ping'); // -> false if it didn't load
await tiny.app.playSound('/path/to/done.aiff');
// native share sheet — anchor it at the click
btn.addEventListener('click', (e) =>
tiny.win.share({ url: 'https://tinyjs.app', text: 'Look at this',
paths: ['/tmp/report.pdf'], x: e.clientX, y: e.clientY }));
// seconds since the user's last input (pause polling when they're away)
const idle = await tiny.system.idleTime();
// files named on the command line reach onOpenFiles — cold start on all
// three platforms, and forwarded to the running copy over the instance pipe
// on win/linux. `tinyjs build --cli [name]` writes dist/bin/<name>, a shim
// that's just `exec <backend> "$@"`.
export function onOpenFiles(paths, app) { /* argv, LaunchServices, drag-drop */ }
// become the default opener for an extension — ask because the USER asked
await tiny.app.setAsDefaultHandler('md'); // 'ok' | 'unsupported' | 'failed'
// the user's languages + time zone, read from the OS — NOT from LANG, which
// Windows doesn't have and which elsewhere describes the parent process
const { language, languages, system, region, timeZone } = await tiny.system.locale();
// `languages` is filtered to the localizations the app bundle declares;
// `system` is the raw preference. They differ for an English-only app on a
// French Mac — render with the first, offer a translation based on the second.
//
// A PAGE rarely needs this: navigator.language / navigator.languages / all of
// Intl / the 'languagechange' event work in the webview. The BACKEND is the
// gap — txiki has no Intl at all, so format in the page and branch on
// language in the backend.
// Quick Look — the Finder-spacebar preview panel (no qlmanage spawn);
// an array pages with the arrow keys, no args closes it
tiny.macos.quickLook('/path/to/photo.heic');
tiny.macos.quickLook([a, b, c]); tiny.macos.quickLook();
// screenshot a display (id from screens(); default primary) — png in the
// temp dir, you own the file. Needs the 'screen' permission + macOS 14;
// rejects with the reason otherwise.
const { path, width, height } = await tiny.app.captureScreen();
// the system eyedropper — pick any pixel on screen, in any app; needs NO
// screen-recording permission. '#rrggbb', or null if the user cancels.
const color = await tiny.app.pickColor();
// on-device OCR (Vision) — screenshot-to-text is captureScreen + this
const { text, blocks } = await tiny.macos.ocr('/path/scan.png');
// blocks: [{ text, confidence, box }] — box normalized 0..1, top-left
// a thumbnail png for ANY path — file browsers stop caring about formats.
// A content preview where Quick Look has a renderer (PSD, video, 3D models,
// source files); the document / app / folder ICON where it doesn't, so on
// macOS this never fails on file type alone. WINDOWS behaves the same way
// via IShellItemImageFactory — images preview, and folders, .exe and plain
// text all come back as their shell icon (measured 2026-07-28). LINUX is the
// narrow one: images only, everything else rejects 'no thumbnail' (measured
// 2026-07-28) — so feature-detect per file, not per platform.
// A path that doesn't exist rejects everywhere.
// SIZE: macOS and Linux treat `size` as points and render @2x (ask 64, get
// 128); Windows treats it as pixels and returns exactly what you asked for.
// Read width/height off the result rather than assuming either.
const thumb = await tiny.app.thumbnail('/path/file.psd', 256);
// Keychain secrets (the keytar/safeStorage role) — tokens go here,
// never in tiny.store; values survive reinstalls
await tiny.app.secrets.set('api-token', 'abc123');
const tok = await tiny.app.secrets.get('api-token'); // string | null
await tiny.app.secrets.delete('api-token');
// set replaces rather than duplicating; delete of a key that was never
// there still resolves true. On macOS the keychain ACL names the binary
// that wrote the value, so a secret saved under `tinyjs dev` makes the
// BUILT app prompt once when it first reads it (a dev-only annoyance).
// Touch ID (or the account-password sheet) — "the user proved it's them".
// false covers cancel AND unavailable, and Linux has no owner check at
// all, so it answers false there — a gate on this fails closed.
if (await tiny.app.authenticate('unlock the vault')) { /* … */ }
// AppleScript in-process — control Music, Spotify, Finder, any scriptable
// app; no osascript spawn, same 'automation' permission as keystrokes
await tiny.macos.applescript('tell application "Music" to playpause');
const sum = await tiny.macos.applescript('return 2 + 3'); // '5'
// Now Playing — your media app shows in Control Center / the lock screen
// and the hardware media keys (F7/F8/F9, AirPods) route to you
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 with the system voices; say() resolves when playback ends
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: 'mute', title: 'Mute' },
{ id: 'del', title: 'Delete', destructive: true },
]});
tiny.app.onNotificationAction(({ id, action, reply }) => {
if (action === 'reply') sendReply(reply); // reply = the typed text
});
// 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
// … later …
const { path, duration } = await tiny.macos.recorder.stop(); // finalized file
// window superpowers — overlays, HUDs, desktop pets, palettes that follow
// you across Spaces. clickThrough lets mouse events pass through the window.
tiny.win.setClickThrough(true); // draw-on-screen / HUD overlays
tiny.win.setLevel('overlay'); // 'normal'|'floating'|'overlay'
// |'desktop' (behind windows)
tiny.win.setAllSpaces(true); // follow across every Space
// (backend: app.setClickThrough/setLevel/setAllSpaces + app.window(id).*)
// 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; 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 under the tray icon
const spot = await tiny.tray.position(); // { x, y, width, height } | null
// render the page to a PDF (vector, 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 }
// (ssid needs Location) | null
// find files by name or 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 AND Apple Intelligence switched on, so ALWAYS check
// availability() first — three states, and only one of them can generate.
// TOOL CALLING (backend only — a tool's run() is a real function, so it
// can't cross the bridge from a page). The model decides what to call:
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 application window to a position 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'; },
}],
});
// `calls` is the RECORD; `text` is a summary that may be fiction. Measured on
// macOS 26.5: asked for three tool calls in one turn, this model made all
// three in one run out of four — and claimed all three in its prose every
// time, including the runs where it silently skipped one. Put anything
// irreversible behind a confirmation, not behind a hopeful read of `text`.
if (await tiny.macos.ai.availability() === 'available') {
const reply = await tiny.macos.ai.generate('Summarise this in one line: ' + text,
{ instructions: 'You are terse.' }); // instructions = a system prompt
}
// native file dialogs (NSOpenPanel/NSSavePanel, run by the launcher)
const file = await tiny.dialog.openFile(); // path | null
const files = await tiny.dialog.openFiles(); // paths[] | null
const dir = await tiny.dialog.pickFolder(); // path | null
const dest = await tiny.dialog.saveFile(); // path | null
// openFile/openFiles/saveFile take { types: ['md', 'txt'] } — extensions,
// no dots — to limit what's choosable (allowedContentTypes on macOS,
// COMDLG_FILTERSPEC on Windows, GtkFileFilter on Linux; the latter two add
// an "All files" escape hatch). Omit types and everything is selectable.
const md = await tiny.dialog.openFile({ types: ['md', 'markdown', 'txt'] });
// native system dialogs (NSAlert)
await tiny.dialog.alert('Heads up', 'optional detail');
const ok = await tiny.dialog.confirm('Delete everything?', { detail: '…', ok: 'Delete', cancel: 'Keep' });
const name = await tiny.dialog.prompt('Your name?', { default: 'world' }); // string | nullEvery app gets a default menu bar: an app menu (About — standard panel with the app name, version from tinyjs.json, and a tinyjs credit — and Quit, ⌘Q) plus an Edit menu so copy/paste shortcuts work. Add your own menus after those:
tiny.menu.set([
{ title: 'Actions', items: [
{ id: 'open', label: 'Open File…', key: 'o' }, // key = ⌘+<key>
{ id: 'find', label: 'Replace…', key: 'alt+f' }, // ⌥⌘F ('F' alone = ⌘⇧F)
{ id: 'mute', label: 'Mute', checked: true }, // ✓ checkmark
{ id: 'later', label: 'Not Yet', enabled: false },// grayed out
{ separator: true },
{ id: 'more', label: 'More', submenu: [ // nests
{ id: 'a', label: 'Sub Item' },
]},
]},
]);
tiny.menu.on((id) => { ... }); // clicks; backend can also export onMenu(id, app)
// patch an item in place — no redeclaring:
tiny.menu.update('mute', { checked: false, label: 'Unmuted' });
const { exists, label, checked, enabled } = await tiny.menu.get('mute');The same item shape (checked / enabled / submenu) works in tray and context
menus, and menu.update / menu.get reach those too.
On macOS that Edit menu isn't optional — the webview needs its key equivalents
— but where it sits is. { role: 'edit' } in the array gives it that slot
instead of the first one, which is how a File menu gets to come before it,
the way every Mac app has it:
tiny.menu.set([
{ title: 'File', items: [...] },
{ role: 'edit' }, // Undo / Cut / Copy / Paste / Select All
{ title: 'View', items: [...] },
]);Windows and Linux have no launcher-owned menu, so they skip the entry and keep the order you declared.
The macOS About item normally shows the standard panel (app name, version,
a tinyjs credit) for free. To draw your own instead, set "about": "menu" in
tinyjs.json — the click then arrives like any other menu item, with the
reserved id 'about', in tiny.menu.on / the backend's onMenu. Apps that
don't opt in keep the free panel; Windows and Linux have no default About
item, so there the flag changes nothing.
The dialogs and menus are native: the backend hands the work to the launcher,
which runs panels/menus on the UI thread and answers the page's promise
directly via webview_return.
Any html file in your frontend dir can be its own window:
tiny.win.open('settings', { page: 'settings.html', title: 'Settings', size: '420x300' });
tiny.win.id; // which window this page lives in
tiny.win.close(); // close the calling window ('main' quits)
await tiny.win.windows(); // ['main', 'settings', ...]
// chrome + position are applied BEFORE the window paints — a frameless
// panel never flashes its titlebar, and it opens where you asked (not
// center-then-jump):
tiny.win.open('hud', { page: 'hud.html', size: '300x120', x: 40, y: 40,
chrome: { frame: false, windowControls: false, vibrancy: 'hud' } });
// parent keeps a window above another of YOUR windows — an about box or tool
// panel that can't get lost behind the window it belongs to, without floating
// over other apps the way setLevel('floating') would. true = 'main', or name
// a win.open id. It also minimizes/hides with its parent, closes when the
// parent closes, and on Windows/Linux gets no taskbar entry of its own.
// Open-time only (Windows ownership can't be set later). One cross-OS
// difference: on macOS the window also MOVES with its parent.
tiny.win.open('about', { page: 'about.html', size: '360x420', parent: true });Every window runs the full tiny.* bridge, and tiny.win.* calls from a
page target its own window. Backend side:
app.openWindow('settings', { page: 'settings.html' });
app.window('settings').setTitle('…'); // eval, push, close, hide/show,
// setSize/Position, chrome, getState…
app.push('event', data); // broadcasts to every window
app.windows();
export function onWindowClosed(id, app) {} // also a 'window-closed' event
export function onWindowState(info, app) {} // { win, fullscreen, maximized,
// minimized, focused } on every
// transition — the backend twin
// of tiny.win.onStateAPI handlers can tell who's calling: myMethod: async (params, app, meta) => … — meta.window is the caller's window id.
Packaged apps (tinyjs build) can claim a URL scheme and file extensions in
tinyjs.json:
{ "urlScheme": "myapp", "fileExtensions": ["md", "txt"] }Then open myapp://compose?to=x, "Open With → MyApp", and dropping files on
the Dock icon all deliver events — including on cold start (launch events are
buffered until the app is ready):
tiny.app.onOpenUrl((url) => ...); // backend: export onOpenUrl(url, app)
tiny.app.onOpenFiles((paths) => ...); // backend: export onOpenFiles(paths, app)Single-instancing is automatic: a second open (URL, file, or Dock click)
activates the running app and delivers the event to it instead of launching
another copy. Dev mode has no bundle, so schemes/associations only work in
built apps.
tiny.tray.set({
title: 'MyApp', // text in the menu bar (and/or an icon)
icon: 'sf:cup.and.saucer.fill', // SF Symbol by name — or a png path ('tray.png')
tooltip: 'My tiny app', // pngs are template images by default
menu: [ // ({ template: false } keeps their colors)
{ id: 'show', label: 'Show Window' },
{ separator: true },
{ id: 'quit', label: 'Quit' },
],
});
tiny.tray.on((id) => { ... }); // menu clicks; backend: export onTray(id, app)
tiny.tray.remove();
// the full tray-app recipe:
tiny.win.setHideOnClose(true); // closing the window hides it
tiny.app.presence('menubar'); // no Dock icon — menu-bar-only appWith no menu, clicking the icon fires tiny.tray.onClick(fn) instead —
classic toggle-window behavior. Want both? primaryAction: true keeps the
menu on right-click (and ctrl-click) while a plain left click fires
onClick — the Caffeine-style toggle:
tiny.tray.set({ icon: 'sf:cup.and.saucer.fill', primaryAction: true,
menu: [{ id: 'quit', label: 'Quit' }] });
tiny.tray.onClick(() => toggle()); // left click; backend: onTray(null, app)And to launch as a menu-bar agent from the start — no Dock icon, no window,
not even a flash of either — declare it in tinyjs.json instead of hiding
things in init():
{ "activation": "accessory" }Packaged apps get LSUIElement (the system never shows them in the Dock);
dev mode behaves the same. The window exists but stays hidden until you call
tiny.win.show() / app.show(), and app.presence('normal') turns the app
back into a regular one.
If your app moves its own windows — win.setPosition, win.center, snapping,
docking, anything that decides where a window goes — say so:
{ "windowPlacement": true }It's a no-op on macOS and Windows, which always let a window place itself.
It matters on Linux: Wayland forbids a client from placing its own
toplevels, so setPosition and center do nothing on a Wayland session,
and mousePosition reads 0,0 because the global pointer is hidden too. With
this key set, the launcher asks GTK for the X11 backend (XWayland on a
Wayland desktop), where placement, the global cursor, captureScreen and
keystroke all work — so a window-choreographing app behaves the same as it
does on macOS.
Don't set it if you don't need it: the Wayland backend is the better one when you don't (native fractional scaling, no XWayland translation layer).
To branch at runtime instead, ask what the machine can do:
tiny.system.os() // 'macos' | 'windows' | 'linux' (sync)
tiny.system.isMacOS() // sync — as are isWindows() / isLinux(),
// so a page can branch before first paint
await tiny.system.architecture() // 'arm64' | 'x86_64' — must be awaited:
// WKWebView says MacIntel on Apple silicon
await tiny.system.info() // { os, arch, session, desktop }
const can = await tiny.system.capabilities();
if (!can.windowPosition) useDragInstead(); // e.g. tiny.win.startDrag()capabilities() reports what this machine can actually do, so an app can
degrade on purpose instead of calling something that quietly does nothing.
tiny.audio.filters runs a DSP chain on this app's own output — a graphic
EQ, headphone correction, a crossover — below the browser:
const can = await tiny.system.capabilities();
// One backend decision, then identical code: pageChain speaks the same verbs.
const eq = can.audioFilters ? tiny.audio : tiny.audio.pageChain(ctx);
if (eq.input) { // the page chain needs routing once
source.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, { gain: -3 }); // retune one, live, no gap
await eq.clear(); // removeTypes: peaking, lowshelf, highshelf, lowpass, highpass, bandpass,
notch, allpass (freq/q/gain, gain in dB) and gain (a linear
multiplier). Filters apply in order.
Why this exists rather than "just use Web Audio": on Linux you can't. WebKitGTK
renders the Web Audio graph on a normal-priority thread while its media threads
get real-time priority, so anything reaching ctx.destination crackles —
on an idle machine, at any latencyHint, from an element or a decoded buffer.
Filtering below the browser sidesteps that, and picks up two things Web Audio
never had: it applies to audio the page doesn't own (raw radio streams, native
HLS, a CORS-tainted <audio>), and it survives a page reload.
How it's built, per platform:
- Linux — a
libpipewire-module-filter-chainsink the app's stream is routed through. - macOS 14.2+ — a Core Audio process tap over the app's own WebKit audio processes, muted, fed back through an aggregate device wrapping the real output, with the biquads applied in between. No driver, no system install. The maths is the same RBJ cookbook PipeWire's builtins use, so the same numbers give the same curve on both.
- Windows — reports
false, permanently, and the reason is measured rather than pending: taking the direct signal off the speakers there means attenuating the audio session, and Windows persists session volume on a key every WebView2 app on the machine shares — a crash while filtering would near-silence all of them (seeTODO-audio-filters.mdfor the numbers). Instead,tiny.audio.pageChain(ctx)builds the same chain from Web Audio nodes in the page — same band vocabulary, same RBJ curves (verified: ask +12 dB at the centre, measure 12.00), same four verbs, so the snippet above needs no second code path. Two honest differences: it filters only what you route through it (not native HLS or CORS-tainted media the page never gets samples for), and Web Audio's shelves ignoreq/ per-filtergainR(usebalance()). Don't reach for it on Linux — see the crackle above; that's what the native chain is for.
Three limits worth knowing:
- Filter count. 15 on Linux (PipeWire's filter-chain crashes above that, so tinyjs truncates rather than letting a page take the audio server down), 32 on macOS.
- macOS needs the audio-capture permission, which is granted per bundle id
on first use — so this only engages in a packaged app, not
tinyjs dev. Until it does, the chain sits armed but idle. - A chain that can't engage leaves audio alone. It never half-applies and never mutes: on macOS the tap starts unmuted and silent, and only takes over once it has proven it can actually hear the app. An unauthorized tap returns success and silence rather than an error, so trusting it would mute the app outright — the one failure mode genuinely worth designing against.
Replacing the chain rebuilds it (a brief gap); changing only values retunes in place. Keep the shape stable if you're driving it from a slider — pass the same list of types every time and just vary the numbers.
Linux ships its media stack in pieces — AAC and H.264 live in optional
GStreamer plugin sets — so a feature can be absent on one machine and present
on the next. requirements() answers what's missing and how to fix it:
const [aac] = await tiny.system.requirements(['media.aac']);
// -> { id, ok, feature, detail, install: { manager, packages, command } }
// the same thing with the satisfied ones dropped — the common case, and
// empty on macOS/Windows, where all of it ships with the OS
const gaps = await tiny.system.missing();promptMissing() is the presentable version: it shows a native dialog naming
the feature, explaining why it's absent, and offering a Copy install
command button — nobody retypes gstreamer1.0-plugins-bad correctly from a
toast. It shows nothing at all when the requirements are satisfied, so
call it straight from a failure path:
audio.addEventListener('error', () => tiny.system.promptMissing(['media.aac']));Ask for several at once and their packages merge into a single command, so the
user runs one line. Where nothing installable would fix it (windowPosition
on Wayland is the session, not a package) it explains instead of offering a
command that doesn't exist.
Probes are cached for the life of the app, so a package the user just
installed would still read as missing — pass { refresh: true } to re-probe
when they retry:
const [aac] = await tiny.system.requirements(['media.aac'], { refresh: true });Ids: media.aac, media.h264, media.mp3, speech, spotlight.index,
audioTap, tray, windowPosition. Everything reports ok on macOS and
Windows, so this costs nothing to call cross-platform.
tinyjs buildproduces:
dist/<name>— a standalone executable (tjs app compile) that loads its page fromdist/frontend/next to it. Great for local/CLI use.dist/<Name>.app— a fully codesigned macOS bundle, the artifact you distribute.
The frontend ships as real files (the launcher renders file:// documents,
so relative scripts/styles/images just work — no bundling step). The build
generates AppIcon.icns from icon.png via sips + iconutil and
codesigns everything (ad-hoc by default; set signIdentity in tinyjs.json
or TINYJS_SIGN_IDENTITY for a Developer ID).
tinyjs build --dmg additionally produces dist/<name>-<version>.dmg — the
.app plus an /Applications shortcut, the classic installer image. With a real
Developer ID, tinyjs notarize submits the built .app via notarytool
(keychain profile from tinyjs.json "notarize": { "profile": … } or
TINYJS_NOTARY_PROFILE) and staples the ticket. Pass tinyjs notarize --dmg
to also rebuild the dmg from the stapled .app — a dmg made at build time
holds the pre-staple bundle, so its ticket is missing and offline Gatekeeper
rejects it. (If a dmg already exists on disk, notarize refreshes it
automatically, since that copy is guaranteed stale.)
The ad-hoc-signed default runs fine on your Mac, but on anyone else's,
Gatekeeper blocks it: recipients must approve it in System Settings →
Privacy & Security → "Open Anyway" (on macOS 15+ the old right-click-Open
trick no longer works) or strip quarantine with
xattr -d com.apple.quarantine. Tolerable for friends and internal tools;
hostile for the public.
For frictionless installs you need a Developer ID Application certificate plus notarization, and both require the paid Apple Developer Program ($99/year). A free Apple ID only issues "Apple Development" certificates, which are valid on your own registered devices — not for distribution. One-time setup:
-
Enroll at developer.apple.com.
-
Create the certificate: Xcode → Settings → Accounts → your Apple ID → Manage Certificates → + → Developer ID Application (or create and download it from the developer portal). It lands in your login keychain.
-
Copy its exact name into
tinyjs.json:security find-identity -v -p codesigning # 1) ABC123… "Developer ID Application: Your Name (TEAMID123)"{ "signIdentity": "Developer ID Application: Your Name (TEAMID123)" }(or export
TINYJS_SIGN_IDENTITYinstead).tinyjs buildnow signs with the hardened runtime and a secure timestamp — notarization-ready. -
Store notarization credentials (once): generate an app-specific password at account.apple.com (Sign-In & Security → App-Specific Passwords), then
xcrun notarytool store-credentials tinyjs-notary \ --apple-id you@example.com --team-id TEAMID123 \ --password <app-specific-password>
and point
tinyjs.jsonat the profile:"notarize": { "profile": "tinyjs-notary" }. -
Ship:
tinyjs build && tinyjs notarize(for a dmg installer, usetinyjs build --dmg && tinyjs notarize --dmgso the image is rebuilt from the stapled .app). The stapled .app opens anywhere, no warnings.
Point tinyjs.json at a manifest you host anywhere (any static host —
GitHub Releases, S3, nginx):
{ "name": "myapp", "version": "1.1.0",
"update": { "url": "https://example.com/myapp/manifest.json" } }Each release: tinyjs publish --notes "What changed" → dist/publish/
contains myapp-1.1.0.zip + manifest.json (version, download url, sha256,
notes) — upload both to the directory update.url points at
(--notes-file CHANGES.md for longer notes). In the app:
const { available, latest, notes } = await tiny.api.call('update.check');
if (available) await tiny.api.call('update.install'); // downloads, verifies
// sha256 + code signature, swaps the .app in place, relaunchesOr let tinyjs check for you: "update": { "url": …, "auto": "launch" }
(or "daily") checks in the background (packaged apps only) and fires the
update-available page event / onUpdateAvailable(info, app) backend
export with { current, latest, notes } — show your own prompt, then
update.install():
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 equivalents: app.update.check() / app.update.install().)
Installs are refused on checksum or signature mismatch, roll back on
failure, and require the packaged .app (a quarantined/translocated app is
asked to move to /Applications first).
tjs-compiled binaries can't be re-signed — txiki appends the bundled app
after the Mach-O, which codesign rejects ("main executable failed strict
validation"; fine locally, fatal for distribution). So the bundle ships the
stock runtime plus the app as plain data, with the launcher as the bundle
executable (it listens on the socket and spawns the backend — and, as the
LaunchServices-registered process, receives deep links, file opens, and
single-instance activation):
MyApp.app/Contents/
MacOS/myapp the launcher (CFBundleExecutable) — window process,
spawns tjs
MacOS/tjs stock runtime binary — signs cleanly
Resources/app/ entry.js, bridge.js, update.js, frontend/, src/…
(plain data, sealed by the bundle signature)
Resources/AppIcon.icns
Clean Mach-Os plus data files means the whole bundle passes
codesign --verify --strict --deep — and with a real Developer ID the build
signs with the hardened runtime automatically, so tinyjs notarize is the
only remaining step.
┌──────────────────────┐ 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 a socket in a fresh
0700temp dir (invisible to other users; no ports to collide or scan), listens, then spawns the launcher pointed at your frontend'sindex.html. - Closing the window ends the launcher; the backend notices, cleans up, exits.
api.call('quit')works the other way (backend sendsQUIT).
| direction | line | meaning |
|---|---|---|
| launcher → backend | CALL <id> <json-args> |
page invoked api.call(...) |
| backend → launcher | RET <id> <status> <json> |
resolve (0) / reject (≠0) a call |
| backend → launcher | EVAL <js> |
run JS in the page |
| backend → launcher | TITLE <text> / SIZE <w> <h> |
window control |
| backend → launcher | DLG <id> <op>[\t<arg>…] |
native dialog (file panels, alert/confirm/prompt); launcher answers the call itself via webview_return |
| backend → launcher | MENUBEGIN … MENU <t> / ITEM id\tlabel\tkey / SEP … MENUEND |
declare custom menu bar menus |
| launcher → backend | MENU <id> |
a custom menu item was clicked |
| backend → launcher | TRAYBEGIN <t>\t<icon>\t<tmpl>\t<tip>\t<primary> … ITEM/SEP … TRAYEND / TRAYREMOVE |
declare/remove the menu bar status item (icon: png path or sf:<symbol>; primary=1: menu on right-click only) |
| launcher → backend | TRAY <id> / TRAYCLICK |
tray menu item / tray icon clicked (no menu, or left click with primary=1) |
| backend → launcher | WINOP <op> [args] |
hide (main = NSApp hide: focus returns to the previous app), show [0 = don't steal focus], center, minimize, fullscreen, ontop, resizable, pos, dock, hideonclose |
| backend → launcher | GET <qid> mouse |
global cursor position + its screen (answered as GOT) |
| launcher → backend | DROP <json-paths> |
files dragged onto the window (real paths) |
| backend → launcher | CTXBEGIN … ITEM/SEP … CTXEND / CTXCLEAR |
replace/restore the right-click menu |
| launcher → backend | CTX <id> |
a context menu item was clicked |
| backend → launcher | HKREG <id>\t<combo> / HKUNREG <id> |
global hotkeys |
| launcher → backend | HOTKEY <id> |
a global hotkey fired |
| backend → launcher | AUDIOTAP <qid> <scope>\t<excludeSelf>\t<interval> / AUDIOTAP STOP |
start/stop an output PCM tap (start answered as GOT) |
| launcher → backend | AUDIOTAP <b64>\t<sr>\t<ch>\t<frames>\t<t> |
a tap PCM chunk (base64 interleaved Int16) |
| launcher → backend | SYS theme dark|light / SYS sleep / SYS wake |
system events (theme also once at startup) |
| backend → launcher | CLIPWRITE <text>\t<html>\t<image>\t<color>\t<path>… |
write the clipboard (fields escape \n/\t; image: png path or base64) |
| backend → launcher | GET <qid> clipboard[:count] |
read the clipboard (answered as GOT) |
| backend → launcher | CLIPWATCH <ms> |
poll changeCount in the launcher (0 = stop) |
| launcher → backend | CLIPCHANGE <count> <self01> |
the clipboard changed (self=1: our own write) |
| backend → launcher | DRAGOUT[@win] <image>\t<path>… |
drag real files out of the window (from a page mousedown) |
| backend → launcher | KEYSTROKE <qid> <combo> |
post a CGEvent keystroke; answers GOT <qid> {ok, trusted} |
| backend → launcher | PERMCHK <qid> <name> / PERMREQ <qid> <name> |
check/request a TCC permission; answers GOT <qid> {status} |
| backend → launcher | PRINT |
native print panel |
| backend → launcher | RELOAD |
re-render the page from disk, caches bypassed (hot-reload) |
| backend → launcher | QUIT |
close the window |
In the page, window.__invoke (injected by webview_bind) already returns a
promise — the shim in tiny.js is ~10 lines.
bin/tjs txiki.js runtime (fetched by setup.sh, not committed;
bin/tjs.exe on Windows via setup.ps1)
native/launcher-macos.cc the macOS window process (Objective-C++; webview
headers vendored)
native/launcher-win.cc the Windows window process (WebView2 via the same
vendored webview; named pipe instead of a Unix socket)
native/launcher-linux.cc the Linux window process (GTK3 + WebKitGTK 4.1
directly, no vendored webview; Unix socket, same as macOS)
native/make-icon.jxa default template icon generator (osascript -l JavaScript)
runtime/bridge.js backend bridge library (socket, protocol, win.* methods)
runtime/update.js app auto-update (manifest check, verify, bundle swap)
template/ what `tinyjs new` copies
skill/SKILL.md tinyjs reference for coding agents (copied into new
projects at .claude/skills/tinyjs/ and, for other
agents, .agents/skills/tinyjs/)
cli.js + tinyjs the CLI (tinyjs.cmd is the Windows wrapper)
test/smoke.html self-driving test page
docs/ tinyjs.app site (GitHub Pages): landing page + installer
(docs/install is what `curl tinyjs.app/install` fetches)
+ changelog.html (tinyjs.app/changelog)
CHANGELOG.md release history (canonical; the site page mirrors it)
setup.sh from-source bootstrap (fetch tjs, compile natives) —
handles both macOS and Linux, branching on `uname`
setup.ps1 the same for Windows (also fetches the WebView2 header)
TODO-windows.md remaining Windows-port work, ordered (tick items off
as they land)
TODO-linux.md remaining Linux-port work, ordered (tick items off
as they land)
.github/workflows/ release automation: tag vX.Y.Z → universal binaries →
per-arch tarballs + checksums → GitHub release
After editing the natives, re-run ./setup.sh (or the c++/cc lines
inside it); on Windows, re-run setup.ps1 (it regenerates tiny_client.h
and recompiles launcher-win.exe). To cut a release: update CHANGELOG.md and docs/changelog.html
(served at tinyjs.app/changelog — retitle the "upcoming" section with the
date), then git tag vX.Y.Z && git push --tags.
test/smoke.html exercises calls, error rejection, window control, and push,
then quits (native dialogs need a human, so they're not covered):
cd someproject
TINYJS_HTML=/path/to/tinyjs/test/smoke.html tinyjs dev
# expect: [web] SMOKE RESULTS {...} with no FAIL entries, clean exitThe same page also runs against a built dist/<name> or the .app's
Contents/MacOS/<name> via the same env var.
tjs.spawn(..., {stdin:'pipe'})delivered only the first write to the child's stdin (fixed upstream in PR #1028; until it's in a tagged release the bridge's Unix socket design sidesteps it — and stays for robustness reasons regardless).tjs compiledoes not bundle imports — they resolve at runtime relative to cwd. Usetjs app compile(app dir +app.jsonmanifest).import.meta.urlthrows inside a compiled binary; resolve shipped files relative totjs.exePath.- txiki streams don't support
for await; usegetReader(). tjs.cwdis a property, not a function; spawn's stdio silencer is'ignore'('null'silently inherits).- The launcher loads local pages as real
file://documents (loadFileURL:allowingReadAccessToURL:), notwebview_set_html: the latter'sabout:blankorigin is not a secure context, and WebKit hides SecureContext-only APIs there — notablynavigator.gpu(WebGPU). The file:// origin also makes multi-file frontends work: relative scripts/styles/images load straight from the page's directory. - WebGPU is additionally gated behind a WebKit feature flag on macOS 15 and
earlier; the launcher flips it at startup via the private
WKPreferences _setEnabled:forFeature:API (no-op where it's already on, as on macOS 26). - The webview headers don't compile under ARC; build without
-fobjc-arc. - Occluded windows are throttled. WebKit starves
requestAnimationFrameand timers in a window that's covered or off-screen, so a hidden window can't reliably drive a visible one (e.g. a background renderer feeding a foreground viz). Do time-critical/continuous work in the visible window, or in the backend (which isn't a webview and isn't throttled) and push results to the page. file://media is sandboxed to the page's own directory. By default<audio>/<video>/<img>can only load assets under the frontend dir; a file elsewhere fails withMEDIA_ERR_SRC_NOT_SUPPORTED. Widen the read root with thereadAccessoption —createApp({ readAccess: true })(the user's home dir) orreadAccess: '/abs/media/root', or"readAccess"in tinyjs.json (true/ a path). Thenfile://assets anywhere under that root load directly — no base64 → Blob round-trip. (It's opt-in because it widens what the page can read from disk.)- Custom User-Agent. WKWebView's default UA omits the trailing
Version/x Safari/x, so UA-sniffing sites see an unrecognized browser. Override it withcreateApp({ userAgent: '…' })or"userAgent"in tinyjs.json (packaged apps read it from theTinyjsUserAgentplist key the build writes; in dev it rides in onTINYJS_UA). Useful when pointing adevUrlat a real hosted site. Note that a UA alone often isn't enough: many SaaS apps (Slack, etc.) also feature-detect at boot and deliberately refuse embedded/non-blessed browsers — an embedded WKWebView genuinely lacks some of what they require (e.g. Web Push), so spoofing the UA gets you recognized as a browser but not necessarily in. "inject"(document-start JS)."inject": "src/shim.js"in tinyjs.json injects that file (bundled at build;.tsgoes through esbuild) into every page at document-start, on any origin — the tool for shimming a wrapped third-party site before it boots. Dev carries it in theTINYJS_INJECTenv var; packaged .apps ship it asResources/app/inject.js. It runs your code inside someone else's origin with full page privileges — pair it with an"api"capability gate when the origin isn't yours.- Site wrappers.
"url": "https://…"makes the main window a remote page (no local frontend needed). JS dialogs (alert/confirm/prompt), downloads ("downloads":auto|ask|deny, with progress events),window.open("popups":external|window|deny, refinable per-popup fromonWindowOpen'skind: 'policy'call), navigation events + policy (onNavigate— return'deny'/'external'from akind: 'policy'call), andtiny.win.findmake a wrapped site behave like an app. The"api"capability gate makes it shippable:"api": "wrapper"preset,{ disable, enable }lists of wire method names (enable wins), or per-origin keyholes —"api": { "origins": { "file://*": "all", "https://app.example.com": ["notify", "store.*"] } }— keyed off the calling frame's origin as WebKit reports it, not as the page claims it.capabilities().api.deniedtells a page what it can't have. All three platforms; the per-engine caveats (a denied navigation still made its request on Linux, an asked POST re-issues as a GET on Windows, popups keep default chrome everywhere) are in TODO-site-wrapper.md.
tiny.macos.ai.generate() runs Apple's FoundationModels LLM locally — offline,
no API key, fully private.
Released macOS tarballs include it. The release workflow builds on a
macos-26 runner with the Swift shim linked in, so tiny.macos.ai is a
feature you can ship, not one users have to build for themselves.
./setup.sh does the same for a source build: it links the shim in when the
SDK it's compiling against carries FoundationModels, and quietly builds
without when it doesn't, so an older toolchain still works. It says which it
did.
Either way the binary keeps the macOS 14 floor and weak-links
FoundationModels, so it still launches on macOS 14+ — ai.availability()
just returns 'unsupported' below macOS 26, as does a launcher compiled
without the shim. So app code that guards on availability() is always safe, and
guarding is not optional: the honest states are available, unavailable
(Apple Intelligence off or still downloading) and unsupported, and only
the first one can generate anything.
if (await tiny.macos.ai.availability() === 'available')
await tiny.macos.ai.generate(prompt, { instructions });The release build asserts what makes that safe, per architecture, because
each of these failures is silent: the deployment floor is still 14.0, the
FoundationModels link is still weak (a strong one would stop the
launcher loading at all below macOS 26 — the AI feature taking the whole app
down with it), and the shim is actually present rather than a broken Swift
step having shipped a launcher that just answers 'unsupported'.
macOS is the primary platform. Windows and Linux support are both in beta. All three run the same runtime and speak the identical wire protocol — only the launcher and the transport differ:
| launcher source | webview | transport | bootstrap | CLI | |
|---|---|---|---|---|---|
| macOS | native/launcher-macos.cc |
WebKit | Unix socket | ./setup.sh |
tinyjs |
| Windows | native/launcher-win.cc |
WebView2 | named pipe (\\.\pipe\…) |
setup.ps1 |
tinyjs.cmd |
| Linux | native/launcher-linux.cc |
GTK3 + WebKitGTK 4.1 | Unix socket | ./setup.sh |
tinyjs |
Windows and Linux use the same vendored webview library as macOS;
tjs.listen('pipe', …)/libuv abstracts the socket-vs-named-pipe split.
Anything unported fails cleanly, so cross-platform code can
feature-detect. Capability calls reject with a specific reason (e.g.
"screen recording isn't supported on Linux yet"), query calls (wifi,
frontmostApp, selectedText, otherWindows, tray.position) resolve
null, and fire-and-forget ones are silent no-ops.
Works:
- Bridge & dev loop — the full page↔backend bridge (api calls, push
events,
tiny.fetch), dev mode with hot reload and backend restart, VitedevUrlfrontends. - Windows & chrome — multi-window (
win.open— each window gets its own WebView2 + bridge), the window ops (fullscreen/ontop/click-through/level/…),transparentchrome, and vibrancy→mica/acrylic backdrops (Win11). - Native UI — menu bar with working
key:accelerators (Ctrl+), tray (+ balloon notifications), custom context menus, file/folder/save dialogs, alert/confirm/prompt. - Data in & out — drag & drop with real paths both ways
(
win.onDropin,startDrag({ files })out), clipboard (text/html/files/image, both directions). - System — global hotkeys,
keystroke(cmdmaps to Ctrl),shell.open/reveal/trash,secrets(Credential Manager),power.preventSleep, theme + sleep/wake events,printToPDF,captureScreen,thumbnail,say/voices(SAPI),launchAtLogin(built apps; HKCU Run). - Shipping —
tinyjs build(a portabledist/folder:<name>.exe+launcher.exe+frontend/),tinyjs publish+ app auto-update (file-by-file swap — Windows can't replace a whole running folder — then relaunch).
Not yet ported:
- notification action buttons — balloons only; real toasts need an AppUserModelID story
nowPlaying/ media keys — wants the WinRTSystemMediaTransportControlsotherWindows/moveWindow,pickColor,spotlight,system.locale- the genuinely macOS-only APIs: Quick Look, OCR, AppleScript,
proxyURLmedia proxy, Now Playing on the lock screen, Spaces,tiny.macos.*
Burn-down list with implementation notes: TODO-windows.md.
Works:
- Windows & chrome — window ops (hide/show/center/minimize/fullscreen/
ontop/resizable/position/zoom/level/click-through/sticky), frameless +
transparent chrome (
vibrancyis a no-op), multi-window, hot reload. - Native UI — menus + accelerators (Ctrl+key), native dialogs
(open/save/folder/alert/confirm/prompt), custom context menus +
suppression, notifications with action buttons
(
org.freedesktop.Notifications; no reply fields), the print dialog +printToPDF, and tray via AppIndicator/StatusNotifier (menu-based — a bare icon-click is emulated through a menu entry, andtray.position()returnsnull). - Data & storage — clipboard (text/html/image/files + watch),
store/paths(XDG dirs),tiny.fetch/proxyURLstreaming,secrets(Secret Service/GNOME Keyring),shell.open/reveal/trash,spotlight(name search — indexedplocate/locatewhen present, else a boundedfind). - System — theme dark/light (+ live changes), sleep/wake events,
power.preventSleep(a logind inhibitor),launchAtLogin(autostart.desktop, built apps only),screens/mousePosition/getWinState,battery,idleTime(GNOME),pickColor(portal),thumbnail(images only),captureScreen(X11 sessions only),app.attention(a taskbar urgency hint),app.icon(the window icon),app.presence(the skip-taskbar hint) — those last three are X11 sessions only, since GTK's Wayland backend has no protocol to carry any of them and drops all three;capabilities()reports them per session.app.badge/app.progressgo out over the Unity LauncherEntry DBus protocol instead, so they work on either session but only where the dock implements it (KDE Plasma, Ubuntu Dock, Dash-to-Dock — not vanilla GNOME Shell), and only for a built app, whose.desktopentry is what the signal is addressed to. - Input — global hotkeys (X11 via XGrabKey; pure Wayland via the
GlobalShortcuts portal — the compositor prompts to approve them once),
keystrokesynthesis (X11/XWayland via XTest — not pure Wayland). - Audio & media —
playSound/beep,say/voices(via speech-dispatcher'sspd-saywhen installed),nowPlaying+ media keys (a real MPRIS player — shows in the GNOME/KDE media widget and lock screen, and Play/Pause/Next/Previous/Seek route back viaonMediaKey),audioTap(system scope — the default sink's monitor viaparec/pw-cat; like Windows,scope:'app'is approximated by the system mix). - Shipping — a built app registers its own
.desktopentry on first run (app-menu listing, icon, deep links viaurlScheme, file associations viafileExtensions, and single-instance) with no separate install step.tinyjs publishemits<name>-<version>-linux-<arch>.tar.gzplus a manifest carrying a per-arch"linux": { "<arch>": { url, sha256 } }block alongside the mac and win ones; auto-update works (swap + relaunch).
Not (yet) supported:
recorder,ocr,quickLook,applescriptapp.badge(no freedesktop standard) andattention({critical: true}),sharewifi,selectedText,otherWindows,moveWindow,frontmostAppauthenticate,tiny.macos.aisetAllSpaces— maps onto sticky windows rather than true per-Space follow
Burn-down list with implementation notes: TODO-linux.md.
Nothing here is planned work — these are directions worth measuring, mostly
aimed at the same thing: the ~6 MB a shipped app costs today, of which
bin/tjs is 5.6 MB and the launcher ~380 KB.
txiki.js already exposes build flags for the parts we don't all need:
BUILD_WITH_WASM=OFF (~0.4 MB) and BUILD_WITH_SQLITE=OFF (~1.5 MB),
plus BUILD_WITH_STRIP/BUILD_WITH_LTO/BUILD_WITH_GC_SECTIONS and
BUILDTYPE=MinSizeRel, which cut size without dropping features
(docs). The
tjs-lite discussion
reports 6.4 MB → 3.31 MB with WASM/SQLite/TLS out, and ~2.15 MB with the
optimization flags stacked on top, for a 7–8% faster start.
setup.sh can already build from source (TJS_BUILD=1), so this is
mostly a matter of picking flags and re-measuring. The catches: we
document tjs:sqlite as the backend database story, so dropping it is an
API break rather than a size win; dropping TLS would take tiny.fetch to
https, proxyURL, and auto-update with it; and MinSizeRel trades
compute-heavy JS throughput for bytes. A likely first cut is
WASM-off + strip/LTO/gc-sections, keeping SQLite and TLS — worth building
and timing against the numbers above rather than assuming they transfer.
scriptc (Vercel Labs) compiles
TypeScript straight to a native binary with no JS engine bundled: ~170–200 KB
static, ~2.4 ms startup, 1–4 MB RSS, with an embedded QuickJS (~620 KB)
available under --dynamic for the parts that need real dynamic
evaluation. It covers a useful slice of Node's surface (fs, path, process,
crypto, child_process, net/http/https/tls, dgram, dns, fetch).
For tinyjs that would mean a bridge.js rewritten in TypeScript against
scriptc's APIs, shipping a launcher plus a sub-megabyte backend. The
unknowns are whether tjs-specific pieces (tjs.listen('pipe', …), tjs.spawn
stdio, FFI, tjs app compile as the packaging step) have equivalents that
hold up, and what a user's own src/main.js becomes — today it's plain JS
run by a real engine, and static compilation is a different contract. It's a
research spike, not a port.
Built on txiki.js (MIT) by Saúl Ibarra Corretgé and webview (MIT). MIT licensed.
