▸_ tinyjs ← home

the api

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.

cli

tinyjs new <dir>    # scaffold an app (zero dependencies)
tinyjs new <dir> --template react-ts|vue-ts|svelte-ts|…
                    #   Vite + tinyjs: HMR dev server in the native window,
                    #   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)

tinyjs.json

{
  "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" }
}

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

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

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

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

The app handle mirrors the frontend surface: setTitle, setSize, setMenu, setContextMenu, eval(js), reload(), quit(), notify({title, body}), hide/show/center/minimize/fullscreen, setPosition, setAlwaysOnTop, setResizable, setHideOnClose, 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' }]

tiny.api request/response + events

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 }

tiny.fetch networking without CORS

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

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

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

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

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

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

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

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

tiny.win window control

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

multiple windows

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

// chrome + x/y apply BEFORE first paint — no titlebar flash, no center-jump
tiny.win.open('hud', { page: 'hud.html', x: 40, y: 40,
                     chrome: { frame: false, 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

dialogs native panels, run by the launcher

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 menu bar apps

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

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

tiny.store persistent settings

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

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

tiny.clipboard native NSPasteboard

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

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

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

await tiny.clipboard.changeCount();   // cheap "did it change?" probe
tiny.clipboard.watch(500);            // launcher-side polling (0 spawns)
tiny.clipboard.onChange(({ changeCount, self }) => …);  // self = own write
tiny.clipboard.unwatch();
// backend: app.clipboard.* is the same api; a createApp onClipboardChange
// handler auto-starts the watcher

tiny.hotkey system-wide

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

tiny.audioTap read the audio output

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

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

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

keystrokes paste into other apps

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

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

tiny.app.permissions onboarding, not surprises

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

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

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

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

shell, login & dock stop shelling out

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();

Mac superpowers you can do that with JS?

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');

media Now Playing, speech, rich notifications

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

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

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

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

window fx overlays, HUDs, pets, window managers

// stack + behaviour: draw-on-screen overlays, HUDs over fullscreen apps,
// desktop pets, palettes that follow you onto every Space
tiny.win.setClickThrough(true);   // mouse events pass through the window
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.' });

system theme + power

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

deep links & files packaged .app

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

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

auto-update ship new versions

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

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

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