# tinyjs > tinyjs makes ~6 MB desktop apps for macOS, Windows and Linux in JavaScript: a txiki.js backend (files, sockets, processes, FFI — not Node) and a native webview window (WKWebView, WebView2, WebKitGTK). The page reaches the system only through `window.tiny`; the backend has the same calls on its `app` handle. Key facts: - A project is `tinyjs.json` + `src/frontend/` (the page) + `src/backend/main.js` (`export const api = { … }`, called from the page with `tiny.api.call(name, args)`). - The backend runs txiki.js: use `tjs.*` for files and processes; Node built-ins (`fs`, `path`, `child_process`) don't exist. - `tinyjs new ` scaffolds, `tinyjs dev` runs with reload, `tinyjs build` makes the app, `tinyjs doctor` diagnoses. - Where an OS can't do something, the call rejects or answers `'unsupported'`; the docs say which per call. - Every project ships `types/tiny.d.ts` and `types/tjs.d.ts` — the authoritative signatures — and an agent skill in `.claude/skills/tinyjs/` and `.agents/skills/tinyjs/` (source: https://github.com/tarwin/tinyjsapp/tree/main/skill). # Introduction Everything a tinyjs app can do. Frontend calls live under `window.tiny`; the backend gets the same calls on its `app` handle, by the same names — the [API map](https://tinyjs.app/docs/api-map) lists every one side by side. tinyjs makes desktop apps for macOS, Windows and Linux. Where an OS can't do something, the call says so in these docs and rejects or answers `'unsupported'` at runtime, so cross-platform code can feature-detect — see [portability](https://tinyjs.app/docs/portability). Phones aren't on the roadmap. If you need iOS or Android, [Tauri](https://tauri.app) is a good fit. ## quick start {#quick-start} [Install tinyjs](https://tinyjs.app/docs/install), then: ```sh tinyjs new myapp # a working app: a page, a backend, a tinyjs.json cd myapp tinyjs dev # opens it; edits reload as you save tinyjs build # the app to share, in dist/ ``` The page is `src/frontend/`, the backend `src/backend/main.js`. They talk like this: ```js // src/backend/main.js export const api = { greet: ({ name }) => `Hello, ${name}!`, }; ``` ```js // src/frontend/app.js tiny.api.call('greet', { name: 'world' }).then((message) => { document.body.append(message); // "Hello, world!" }); ``` Then build a real one: [your first app](https://tinyjs.app/docs/first-app) is a notebook that saves to disk, step by step. For a Vite + React, Vue or Svelte app, use [`tinyjs new myapp --template`](https://tinyjs.app/docs/cli#new). ## with a coding agent {#agents} Every new project includes a tinyjs skill for coding agents, in `.claude/skills/tinyjs/` and `.agents/skills/tinyjs/`, along with the TypeScript types in `types/`. An agent working in the project picks them up on its own. To point any other AI tool at these docs, give it [`tinyjs.app/llms.txt`](https://tinyjs.app/llms.txt){target="_self"} (an index linking a markdown copy of every page) or [`tinyjs.app/llms-full.txt`](https://tinyjs.app/llms-full.txt){target="_self"} (all of them in one file). # install > one line, no dependencies ::: tabs == macOS ```sh curl -fsSL https://tinyjs.app/install | sh ``` macOS 15+ · Apple silicon + Intel · MIT == Windows ```powershell irm https://tinyjs.app/install.ps1 | iex ``` Windows 10/11 · x86_64 + arm64 · prebuilt, no compiler needed · just the WebView2 runtime (preinstalled on Windows 11) · adds tinyjs to your PATH (open a new terminal) · MIT == Linux ```sh curl -fsSL https://tinyjs.app/install | sh ``` x86_64 + arm64 · same script as macOS, detects Linux · glibc 2.35+ (Ubuntu 22.04 / Debian 12 and newer) · needs libwebkit2gtk-4.1-0 (`sudo apt install libwebkit2gtk-4.1-0` · `sudo zypper install libwebkit2gtk-4_1-0`) · MIT ::: Then `tinyjs new myapp && cd myapp && tinyjs dev` — a window opens with hot reload. The CLI updates itself with `tinyjs update`. Next: [your first app](https://tinyjs.app/docs/first-app). # your first app > a notebook that remembers, in four files This builds a small desktop notebook: write a note, edit it, delete it, and find it still there the next time the app opens. Along the way it uses the three things almost every tinyjs app does — a page that calls its [backend](https://tinyjs.app/docs/backend), the backend doing the work, and data saved with [store](https://tinyjs.app/docs/store). Plain HTML and JavaScript: no framework, no packages to install. Based on a tutorial by [@Zulut30](https://github.com/Zulut30) ([#65](https://github.com/tarwin/tinyjsapp/pull/65)). ## create the project {#create} With tinyjs [installed](https://tinyjs.app/docs/install): ```sh tinyjs new notes cd notes ``` That's a working app already — `tinyjs dev` opens it. Before going on, give the app an id of your own in `tinyjs.json`. It names the folder the notes are saved in, so pick it once and keep it: ```json { "$schema": "https://tinyjs.app/schema/tinyjs.schema.json", "name": "notes", "title": "Notes", "size": "760x640", "id": "com.yourname.notes", "version": "0.1.0" } ``` ## the backend {#backend} Replace `src/backend/main.js`. Each function in `api` is something the page can call; it gets the page's parameters and the [`app` handle](https://tinyjs.app/docs/backend#app), and whatever it returns goes back to the page. ```js // src/backend/main.js — runs on txiki.js, with the app's store and the system const KEY = 'notes'; const MAX_TEXT = 2000; let sequence = 0; let savedNotes = null; // the last list that really reached the disk // Run read-change-write steps one at a time, even when calls overlap, so // two quick saves can't each start from the same list and lose a note. let pending = Promise.resolve(); function inOrder(step) { const result = pending.then(step); pending = result.catch(() => {}); return result; } function textFrom(params) { const text = params?.text; if (typeof text !== 'string' || !text.trim() || text.length > MAX_TEXT) { throw new Error('Write a note between 1 and 2000 characters.'); } return text; } async function readNotes(app) { if (savedNotes !== null) return savedNotes; const notes = await app.store.get(KEY); if (notes === null) return (savedNotes = []); if (!Array.isArray(notes)) throw new Error('The saved notes are in an unexpected format.'); return (savedNotes = notes); } async function saveNotes(app, notes) { // store.set resolves false when the disk write fails — it doesn't throw. if (!(await app.store.set(KEY, notes))) { throw new Error('Could not save the notes. Check disk space and permissions.'); } savedNotes = notes; // only once it's really saved return notes; } export const api = { listNotes: (_params, app) => inOrder(() => readNotes(app)), addNote: (params, app) => inOrder(async () => { const text = textFrom(params); const notes = await readNotes(app); const id = Date.now().toString(36) + '-' + ++sequence; return saveNotes(app, [...notes, { id, text }]); }), updateNote: (params, app) => inOrder(async () => { const text = textFrom(params); const notes = await readNotes(app); if (!notes.some((n) => n.id === params.id)) throw new Error('That note no longer exists.'); return saveNotes(app, notes.map((n) => (n.id === params.id ? { ...n, text } : n))); }), deleteNote: (params, app) => inOrder(async () => { const notes = await readNotes(app); if (!notes.some((n) => n.id === params?.id)) throw new Error('That note no longer exists.'); return saveNotes(app, notes.filter((n) => n.id !== params.id)); }), }; ``` ## the page {#page} Replace the three files in `src/frontend/`: ::: code-group ```html [index.html] Notes

Notes

Loading…

``` ```js [app.js] const $ = (id) => document.getElementById(id); let editing = null; // the id of the note being edited, if any let busy = false; function resetEditor() { editing = null; $('text').value = ''; $('save').textContent = 'Add note'; $('cancel').hidden = true; } function render(notes) { $('notes').replaceChildren(); $('empty').hidden = notes.length > 0; for (const note of notes) { const item = document.createElement('li'); const text = document.createElement('p'); text.textContent = note.text; // a note is text, never HTML const edit = document.createElement('button'); edit.textContent = 'Edit'; edit.onclick = () => { editing = note.id; $('text').value = note.text; $('save').textContent = 'Save changes'; $('cancel').hidden = false; $('text').focus(); }; const remove = document.createElement('button'); remove.textContent = 'Delete'; remove.onclick = () => run(async () => { render(await tiny.api.call('deleteNote', { id: note.id })); if (editing === note.id) resetEditor(); }, 'Deleted.'); item.append(text, edit, remove); $('notes').append(item); } } // One call at a time, with the controls off while it runs. A backend error // arrives as its message, a plain string. async function run(step, done) { if (busy) return; busy = true; $('controls').disabled = true; $('error').textContent = ''; $('status').textContent = 'Working…'; try { await step(); $('status').textContent = done; } catch (err) { $('error').textContent = typeof err === 'string' ? err : err.message; $('status').textContent = 'Not saved — your text is still in the editor.'; } finally { busy = false; $('controls').disabled = false; } } $('editor').onsubmit = (event) => { event.preventDefault(); run(async () => { const method = editing ? 'updateNote' : 'addNote'; render(await tiny.api.call(method, { id: editing, text: $('text').value })); resetEditor(); }, 'Saved.'); }; $('cancel').onclick = resetEditor; run(async () => render(await tiny.api.call('listNotes')), 'Ready.'); ``` ```css [style.css] :root { font: 16px/1.5 system-ui, sans-serif; color-scheme: light dark; } body { margin: 0; } main { max-width: 40rem; margin: auto; padding: 1.5rem; } fieldset { border: 0; margin: 0; padding: 0; } textarea { box-sizing: border-box; width: 100%; padding: .6rem; font: inherit; } button { font: inherit; margin: .5rem .5rem 0 0; } #notes { list-style: none; padding: 0; } #notes li { border-top: 1px solid #8884; padding: .75rem 0; } #notes p { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; } #error { color: #c33; } ``` ::: ## run it {#run} ```sh tinyjs dev ``` Add a note, edit it, delete one. Then quit and run `tinyjs dev` again: the notes are still there. While `tinyjs dev` runs, saving a file in `src/frontend/` reloads the page, and saving the backend restarts it — text you haven't saved yet goes with it. ## how it fits together {#how} **The page asks, the backend does.** `tiny.api.call('addNote', { text })` sends the call to the backend's `api.addNote`, and the list it returns comes back as the result. Anything that crosses — parameters and results — is plain JSON. The page has no file system of its own; everything that touches the computer happens in the backend. See [calling the backend](https://tinyjs.app/docs/api). **Errors come back as text.** When `textFrom` throws, the page's `await` rejects with the message as a plain string, which is why `run()` checks `typeof err === 'string'`. Try saving an empty note. **Notes are text, not HTML.** `render()` sets `textContent`, so a note containing `hello` shows exactly that, and nothing a user types can run as code. **One change at a time.** `inOrder` makes each read-change-write finish before the next starts, so two quick saves can't both start from the same list and drop a note. ## where the notes are saved {#data} `app.store` saves to `store.json` in the app's own data folder, named after its `id`: | | | |---|---| | macOS | `~/Library/Application Support/com.yourname.notes/` | | Windows | `%APPDATA%\com.yourname.notes\` | | Linux | `~/.local/share/com.yourname.notes/` | `(await app.paths()).data` gives the folder from the backend; `tiny.app.paths()` from the page. `tinyjs dev` and a built copy with the same id share it — and changing the id starts with an empty notebook rather than moving the old one. A save that fails (a full disk, no permission) doesn't throw: `store.set` resolves `false`, which is why `saveNotes` checks it and only then updates `savedNotes` — so retrying a failed add can't add the note twice. See [when a save fails](https://tinyjs.app/docs/store#failures). For more than a list of notes, the backend has [SQLite](https://tinyjs.app/docs/sqlite) built in. ## make it feel like a desktop app {#desktop} The notebook works, but nothing in it needs a desktop yet. Five small additions change that — each one is independent, so add any of them. ### 1. export to a file {#export} A browser tab can't write a file wherever you choose; your backend can. The page asks where with the native save dialog, then hands the path to the backend, which writes the notes as Markdown. Add this to the `api` object in `src/backend/main.js`: ```js exportNotes: (params, app) => inOrder(async () => { if (typeof params?.path !== 'string') throw new Error('No file was chosen.'); const notes = await readNotes(app); const markdown = notes.map((n) => n.text).join('\n\n---\n\n') + '\n'; await tjs.writeFile(params.path, new TextEncoder().encode(markdown)); return true; }), ``` …and this to the end of `src/frontend/app.js`: ```js async function exportNotes() { const path = await tiny.dialog.saveFile({ types: ['md'], name: 'notes' }); if (path) run(() => tiny.api.call('exportNotes', { path }), 'Exported to ' + path); } ``` `saveFile` only asks — it resolves the chosen path, or `null` if the user cancels, and writes nothing. See [dialogs](https://tinyjs.app/docs/dialogs#save). ### 2. a menu with shortcuts {#menu} Desktop apps put their commands in the menu bar, with keyboard shortcuts. Add to the end of `app.js`: ```js tiny.menu.set([ { title: 'File', items: [ { id: 'new', label: 'New Note', key: 'n' }, { id: 'export', label: 'Export…', key: 'e' }, ]}, ]); tiny.menu.on((id) => { if (id === 'new') { resetEditor(); $('text').focus(); } if (id === 'export') exportNotes(); }); ``` `key: 'n'` is ⌘N on a Mac and Ctrl+N on Windows and Linux. On macOS the menu goes in the menu bar after the app and Edit menus tinyjs adds for you; on Windows and Linux it's drawn under the window's title bar. See [menus](https://tinyjs.app/docs/menus). ### 3. confirm before deleting {#confirm} Delete removes a note with one click. Ask first, with the system's own dialog — in `render()`, replace the `remove.onclick = …` lines with: ```js remove.onclick = async () => { const sure = await tiny.dialog.confirm('Delete this note?', { detail: note.text.slice(0, 100), ok: 'Delete', }); if (!sure) return; run(async () => { render(await tiny.api.call('deleteNote', { id: note.id })); if (editing === note.id) resetEditor(); }, 'Deleted.'); }; ``` ### 4. remember the window {#remember} One line in `tinyjs.json`, and the window opens where the user left it — same size, same place, same screen: ```json "rememberWindows": true ``` See [`rememberWindows`](https://tinyjs.app/docs/config#rememberWindows). ### 5. the count in the title bar {#title} At the end of `render()`: ```js tiny.win.setTitle(notes.length ? `Notes (${notes.length})` : 'Notes'); ``` The [window](https://tinyjs.app/docs/window) page has the rest of what a window can do. ## build and share it {#build} Quit the dev app, then: ```sh tinyjs build ``` ::: tabs == macOS `dist/Notes.app` is the app. Copy it anywhere and open it. It's signed ad-hoc, which is fine on your own Mac; on someone else's, Gatekeeper blocks it until it's signed with a Developer ID and notarized — see [notarize](https://tinyjs.app/docs/cli#notarize). == Windows Send the whole `dist\` folder: `notes.exe` needs the files beside it. Unsigned, Windows shows a warning the first time it runs. == Linux Send the whole `dist/` folder: `notes` needs the files beside it. The person running it needs WebKitGTK 4.1 — see [install](https://tinyjs.app/docs/install). ::: Build on each OS you want to ship for. Saved notes stay in the data folder on each computer — they aren't part of the build. ## next {#next} - [`tinyjs.json`](https://tinyjs.app/docs/config) — window size, an icon, a menu bar app, file types. - [window](https://tinyjs.app/docs/window), [menus](https://tinyjs.app/docs/menus), [dialogs](https://tinyjs.app/docs/dialogs), [notifications](https://tinyjs.app/docs/notifications) — the rest of what a desktop app does. - [auto-update](https://tinyjs.app/docs/auto-update) — ship new versions to people who already have it. # cli > every command, every flag Run project commands from the app's folder (the one with `tinyjs.json`). Flags go after the command and take their value as the next argument (`--arch x86_64`); `new` and `wrap` also accept `--flag=value`. Unknown flags are ignored, except by `doctor` and `update-project`, which take none. | command | what it does | |---|---| | [`tinyjs new `](#new) | scaffold an app, plain or with a Vite framework | | [`tinyjs wrap `](#wrap) | turn a website into a desktop app | | [`tinyjs dev`](#dev) | run the app with hot reload | | [`tinyjs build`](#build) | build the app for this OS | | [`tinyjs publish`](#publish) | build, zip and write the auto-update manifest | | [`tinyjs notarize`](#notarize) | notarize and staple the macOS build | | [`tinyjs doctor`](#doctor) | find why tinyjs or the project won't run | | [`tinyjs update`](#update) | update tinyjs itself | | [`tinyjs update-project`](#update-project) | refresh the project's types and agent skill | | [`tinyjs uninstall`](#uninstall) | remove tinyjs | | [`tinyjs version`](#version) | print the version | Settings that don't change per run live in [tinyjs.json](https://tinyjs.app/docs/config); the [environment variables](#environment) below override a few of them. ## tinyjs new {#new} ```sh tinyjs new myapp # plain HTML/JS, zero dependencies tinyjs new myapp --template react-ts # Vite + a framework tinyjs new myapp --template # asks for framework and language ``` Creates `` (it must not exist yet) and names the app after its last path segment. The plain template is a [backend](https://tinyjs.app/docs/backend) in `src/backend/main.js`, a page in `src/frontend/`, an icon in `assets/` and a `tinyjs.json` — no packages, no build step. Then `cd myapp && tinyjs dev`. `--template` scaffolds a Vite app instead (through `create-vite`): `tinyjs dev` runs Vite's dev server with HMR inside the native window, and `tinyjs build` ships Vite's build. The backend is TypeScript for `-ts` templates, bundled with esbuild, so npm packages work there too. | flag | | |---|---| | `--template [name]` | `vanilla` `react` `vue` `svelte` `solid` `preact` `lit` `alpine`, each with `-ts` for TypeScript. `alpine` is Vite's vanilla template plus [Alpine.js](https://alpinejs.dev) and a component that calls the backend. Without a name it asks (and fails when there's no terminal to ask on). | | `--pm ` | `npm` `pnpm` `yarn` `bun`, or `vp` for [Vite+](https://viteplus.dev) 1.0+ over one of them (`vp:pnpm`, `vp:bun`…). Without it you pick from the managers that work on this machine, or get the first of npm → pnpm → yarn → bun when there's no terminal. The chosen manager runs the project's dev and build commands from then on. Also `--package-manager`. | | `--no-install` | don't install dependencies. (`vp` installs as part of creating the project, so only Alpine's extra packages are left out.) | | `--install` | install without asking. | Every new project gets TypeScript definitions for `tiny` and the backend (`tiny.d.ts`, `tjs.d.ts`) and a tinyjs skill for coding agents (`.claude/skills/tinyjs/`, `.agents/skills/tinyjs/`). After updating tinyjs, [`update-project`](#update-project) brings them up to date. The `$schema` line in `tinyjs.json` gives editors completion — see [editor schema](https://tinyjs.app/docs/config-schema). Needs Node.js and a package manager for `--template`; the plain template needs nothing. ## tinyjs wrap {#wrap} ```sh tinyjs wrap https://app.example.com # → ./app-example-com tinyjs wrap app.example.com mail --name mail --menubar --panel tinyjs wrap https://app.example.com --force --origins subdomains ``` Makes a [site wrapper](https://tinyjs.app/docs/site-wrappers): an app whose main window is a hosted page rather than your own frontend. It fetches the page for its title and icon, and writes a `tinyjs.json` with the page's URL, popups opening as windows and downloads asking first, and an [API gate](https://tinyjs.app/docs/site-wrappers#api-gate): the site's own origin gets the `"wrapper"` preset (window, menu, tray, dialog, notification, store, clipboard-write and `shell.open` calls), and any other origin the window lands on only window controls. A small backend handles the window options below. The scheme defaults to `https://`; the folder (second argument) and app name default to the host. | flag | | |---|---| | `--name ` | app name, and the folder when none is given. | | `--ua ` | user agent for the page (`userAgent` in tinyjs.json). | | `--menubar` | a menu-bar app: tray icon, no Dock icon, closing hides the window. | | `--panel` | with `--menubar`: a frameless dropdown under the tray icon that hides when it loses focus. | | `--top` | keep the window above others. | | `--external a.com,b.com` | open links to these hosts (and their subdomains) in the default browser. An empty value clears the list. | | `--origins ` | origins the page's own JavaScript may call `tiny` from: `exact` (default — just the page's origin), `subdomains` (the whole site, `https://*.example.com`), or a comma-separated list of URLs. See [the api gate](https://tinyjs.app/docs/site-wrappers). | | `--yes` | don't ask about origins (use `exact`, or `--origins`). | | `--force` | wrap again into an existing tinyjs project. | | `--no-menubar` `--no-panel` `--no-top` | with `--force`: turn that option off. | Re-wrapping with `--force` keeps what you've changed: title, size, id, version, origins, user agent and any keys you added stay, flags you leave out keep their previous setting, and a hand-edited backend is saved as `src/main.js.bak` before it's regenerated. Wrapping into an existing folder that isn't a tinyjs project fails. Uses the network: the page, its icon, and (for `--origins subdomains`) the public suffix list, cached for 30 days. If any fetch fails, the defaults are used. Then `tinyjs dev` to try it, and `tinyjs build` to ship it. ## tinyjs dev {#dev} ```sh tinyjs dev TINYJS_DEBUG=1 tinyjs dev # also trace every message across the bridge ``` Runs the app from source. Edit the page (`src/frontend/`, or `frontend.dir`) and it reloads in place without restarting the app; edit the backend's folder or [shared code](https://tinyjs.app/docs/shared-code) and the backend restarts. Devtools are always on in dev (F12, or ⌥⌘I on macOS). Takes no flags. - **Frameworks**: with `frontend.devUrl` set, `dev` starts `frontend.dev` (your Vite server), waits up to 60 seconds for it to answer, and loads it — the server's own HMR handles the page. Leave `frontend.dev` out to run the server yourself. A watcher of your own that reloads the page sets `"frontend": { "reload": false }`. - **TypeScript** backends are bundled with esbuild on each start (the project's copy, else `npx esbuild`, which needs Node and the network the first time). - `resources/` and `executables/` are used in place — see [resources](https://tinyjs.app/docs/resources) and [executables](https://tinyjs.app/docs/executables). - Prints a one-line notice when a newer tinyjs is out (checked at most once a day). - In a source checkout of tinyjs, the native launcher is rebuilt first when its sources changed. Ends when the app quits, with the app's exit code. Working files go in `.build/`, rebuilt on every start. ## tinyjs build {#build} ```sh tinyjs build tinyjs build --dmg # macOS: also a disk image tinyjs build --arch x86_64 # macOS: for Intel Macs, from any Mac tinyjs build --universal # macOS: one app for both tinyjs build --cli # also a command-line shim ``` Builds the app for the OS you run it on into `dist/`, which is emptied first. A `frontend.build` command (Vite's build in the templates) runs first; its packages must be installed. ::: tabs == macOS - `dist/.app` — the app to distribute: codesigned, with its icon (`AppIcon.icns`, made from [assets/icon.png](https://tinyjs.app/docs/assets)), [resources](https://tinyjs.app/docs/resources), [executables](https://tinyjs.app/docs/executables) (signed too), credits and help book. - `dist/<name>` — the app as one bare executable for local use, loading its page from `dist/frontend/`. - `--dmg` adds `dist/<name>-<version>.dmg`: the app, an Applications shortcut and your [extras](https://tinyjs.app/docs/extras). Signing is ad-hoc unless `signIdentity` (or `TINYJS_SIGN_IDENTITY`) names a Developer ID; then the build adds the hardened runtime and a timestamp, ready for [notarize](#notarize). Ad-hoc apps open on your Mac but Gatekeeper blocks them elsewhere. == Windows - `dist/<name>.exe` — the app, with your icon. - `dist/launcher.exe`, `dist/resources/`, `dist/executables/` — ship the whole folder. The page is packed inside `<name>.exe`. - [Extras](https://tinyjs.app/docs/extras) are copied in beside them. If the build can't clear `dist/` because the app is still running, it says which file is in use — quit the app and build again. == Linux - `dist/<name>` — the app. - `dist/launcher`, `dist/icon.png`, `dist/resources/`, `dist/executables/` — ship the whole folder. The page is packed inside `<name>`. - [Extras](https://tinyjs.app/docs/extras) are copied in beside them. ::: | flag | | |---|---| | `--dmg` | macOS: also build the disk image. | | `--arch arm64\|x86_64` | macOS: build the app for that CPU only, from either kind of Mac. Artifacts are named `<name>-<version>-macos-<arch>.dmg` / `.zip` so both can ship side by side, and building one arch keeps the other's dmg. The other CPU's runtime is downloaded once (checksum-pinned) and cached. Same as `TINYJS_ARCH`. | | `--universal` | macOS: one app that runs on both, about 6 MB bigger. Needs the Xcode Command Line Tools (`xcode-select --install`). Same as `TINYJS_UNIVERSAL=1`. | | `--cli [name]` | also write `dist/bin/<name>` (`<name>.cmd` on Windows), a command that runs the app; files passed to it arrive in [onOpenFiles](https://tinyjs.app/docs/deep-links). Defaults to the app's name. It prints how to put it on your PATH. | Without `--arch` or `--universal`, a macOS build runs only on the CPU type of the Mac that built it, and says so (`runs on: Apple Silicon (arm64)`). Both flags are ignored, with a note, on Windows and Linux. The bare `dist/<name>` is always for the building Mac's CPU. ## tinyjs publish {#publish} ```sh tinyjs publish --notes "Fixes the export bug" tinyjs publish --arch arm64 && tinyjs publish --arch x86_64 # both Macs, one manifest ``` Runs a full [build](#build), then packs it for [auto-update](https://tinyjs.app/docs/auto-update) in `dist/publish/`: the archive plus `manifest.json`, which you upload to the address in `update.url`. Needs `version` in tinyjs.json. - **macOS**: `<name>-<version>.zip` (or `-macos-<arch>.zip` with `--arch`). - **Windows**: `<name>-<version>-win.zip` (`-win-arm64.zip` on ARM). - **Linux**: `<name>-<version>-linux-<arch>.tar.gz`. Publishing the same version again for another CPU (`--arch`, or a Windows arm64 build) adds to the existing manifest rather than replacing it, so one manifest serves every build. | flag | | |---|---| | `--notes "text"` | release notes shown in the update prompt. | | `--notes-file FILE` | the same, read from a file. | | `--arch`, `--universal`, `--dmg`, `--cli` | passed to the build. | | `--sign-manifest FILE` | sign (or re-sign) an existing manifest, for example one you merged by hand, without building. | | `--expires 30d\|2027-01-31` | a signed manifest stops being accepted after this. | | `--signed-only` | leave out the unsigned fields, so apps without a key see no update. | The last three need [signed updates](https://tinyjs.app/docs/signed-updates): `update.publicKey` in tinyjs.json and `TINYJS_UPDATE_PRIVATE_KEY_FILE` pointing at the private key. With both set every publish is signed; with only one of them, publish stops and says which is missing. On macOS, publish rebuilds the app and doesn't [notarize](#notarize) it, so the app in the update zip isn't notarized, even if you notarized `dist/` before publishing. ## tinyjs notarize {#notarize} ```sh tinyjs build --dmg && tinyjs notarize --dmg ``` macOS only. Sends `dist/<title>.app` to Apple's notary service, waits for the result (usually a few minutes) and staples the ticket to the app, so it opens on other Macs without Gatekeeper warnings. | flag | | |---|---| | `--dmg` | rebuild the dmg from the stapled app (a dmg made before notarizing doesn't carry the ticket). An existing dmg is rebuilt anyway. | | `--arch arm64\|x86_64` | after a `build --arch`, pass the same arch so the right dmg is rebuilt. | It needs a **Developer ID Application** certificate and the paid [Apple Developer Program](https://developer.apple.com/programs/). One-time setup: 1. Create the certificate: Xcode → Settings → Accounts → your Apple ID → Manage Certificates → **+** → *Developer ID Application*. 2. Put its exact name in tinyjs.json (`security find-identity -v -p codesigning` lists it): ```json { "signIdentity": "Developer ID Application: Your Name (TEAMID123)" } ``` 3. Make an app-specific password at [account.apple.com](https://account.apple.com) and store it as a keychain profile: ```sh xcrun notarytool store-credentials tinyjs-notary \ --apple-id you@example.com --team-id TEAMID123 --password <app-specific-password> ``` 4. Name the profile in tinyjs.json: `"notarize": { "profile": "tinyjs-notary" }` (or `TINYJS_NOTARY_PROFILE`). Before submitting, notarize checks that the built app really carries a Developer ID signature and says what it found if not (ad-hoc, Apple Development…). Build again after changing `signIdentity`. ## tinyjs doctor {#doctor} ```sh tinyjs doctor ``` Looks for the usual reasons tinyjs or an app won't run — the install, which `tinyjs` your PATH finds, the system webview, Node tooling, and (in a project folder) tinyjs.json and its build tools — and says how to fix each. It only reads files. Exits 1 when it prints a `FAIL`. Everything it checks: [doctor](https://tinyjs.app/docs/doctor). ## tinyjs update {#update} ```sh tinyjs update tinyjs update --check # only say whether there's a newer version tinyjs update --dry-run # print what it would run ``` Updates tinyjs to the latest release by re-running the installer over the current install. A source checkout updates with `git pull` and `./setup.sh` (`setup.ps1` on Windows) instead. Then run [update-project](#update-project) in each project. ## tinyjs update-project {#update-project} ```sh tinyjs update-project ``` Brings the project's type definitions (`tiny.d.ts`, `tjs.d.ts`) and coding-agent skill (`.claude/skills/tinyjs/`, `.agents/skills/tinyjs/`) up to the installed tinyjs. It overwrites edits to those files, adds the skill if the project has none, and leaves type files you deleted deleted. [Doctor](#doctor) notes when they're behind. ## tinyjs uninstall {#uninstall} ```sh tinyjs uninstall # asks first tinyjs uninstall --yes ``` macOS and Linux: removes the tinyjs install and its `tinyjs` command from your PATH. Downloaded caches and the PATH line the installer added to your shell profile stay; it says where. On Windows, delete the install folder and remove it from your user PATH. See [install](https://tinyjs.app/docs/install). ## tinyjs version {#version} ```sh tinyjs version # also --version, -v ``` Prints the tinyjs version and the [txiki.js](https://txikijs.org) runtime it uses. ## environment variables {#environment} | variable | | |---|---| | `TINYJS_DEBUG=1` | `dev`: trace every message across the bridge. | | `TINYJS_ARCH`, `TINYJS_UNIVERSAL=1` | `build`, `publish`, `notarize`: same as `--arch` and `--universal`. | | `TINYJS_SIGN_IDENTITY` | overrides `signIdentity`. | | `TINYJS_NOTARY_PROFILE` | overrides `notarize.profile`. | | `TINYJS_UPDATE_PRIVATE_KEY_FILE` | `publish`: the key that signs the manifest — see [signed updates](https://tinyjs.app/docs/signed-updates). | | `TINYJS_HTML=/abs/page.html` | `dev`: load a different page, such as a self-driving test page. | | `TINYJS_LAUNCHER=/path` | run against a different launcher binary. | | `TINYJS_HOME` | where the installer puts tinyjs. | | `NO_COLOR` | `doctor`: plain output. | The two overrides print a line when they replace a value tinyjs.json really had. The [tinyjs.json](https://tinyjs.app/docs/config#per-os-overrides) per-OS blocks resolve first: root → OS block → environment. <!-- https://tinyjs.app/docs/doctor --> # tinyjs doctor `tinyjs doctor` looks for the usual reasons tinyjs or an app won't run, and says how to fix each one. Run it from your project folder to include the project checks, or from anywhere for the rest. It only reads files. It starts no processes, opens no windows, rebuilds nothing and doesn't use the network. Exit status is 1 when any `FAIL` line is printed, 0 otherwise; `WARN` and `NOTE` lines don't change it. In a terminal the labels are coloured. Piped or redirected output stays plain, and setting `NO_COLOR` turns colour off. ## What it checks - **The install:** the bundled runtime and launcher exist and are executable, and a `TINYJS_LAUNCHER` override if one is set. In a source checkout it also warns when the launcher is older than its sources (`tinyjs dev` and `tinyjs build` rebuild it first), and on an Intel Mac that builds are Intel-only unless you pass `--arch arm64` or `--universal`. - **PATH:** which `tinyjs` a bare `tinyjs` runs. It warns when that is a different install from the one running doctor, or when a second install sits later on PATH, the usual cause of "I updated but nothing changed". - **The webview:** on Windows, the WebView2 Runtime in its usual install folders; on Linux, WebKitGTK 4.1 (`libwebkit2gtk-4.1.so.0`), plus a warning when there is no desktop session (`DISPLAY` / `WAYLAND_DISPLAY`). macOS always has WebKit. - **Node tooling:** which of `node`, `npm`, `pnpm`, `yarn`, `bun` and `vp` are on PATH. The plain template needs none of them; the framework templates (`tinyjs new --template ...`) need one. - **The project**, when the current folder has a `tinyjs.json`: it parses, has a `name` (after this OS's block is merged in), its `minTinyjsVersion` isn't newer than this tinyjs, the package manager its `frontend.build` / `frontend.dev` commands run is on PATH, and a project with a `package.json` has its `node_modules` installed. A `tinyjs.json` with no `"$schema"` line (projects made before 0.57.5) gets a note suggesting it, for editor completion; see [config-schema.md](config-schema.md). Types (`tiny.d.ts`, `tjs.d.ts`) or a coding-agent skill that differ from this tinyjs's copies get a note to run `tinyjs update-project`. ## Limits A clean run can't prove a window will open. Binary architecture, shared libraries other than WebKitGTK, and OS security settings (Gatekeeper, SmartScreen) aren't checked. WebView2 installed somewhere unusual, such as a fixed-version runtime, reads as not found, which is why that is a warning rather than a failure. <!-- https://tinyjs.app/docs/config --> # tinyjs.json > every key is optional except name `tinyjs.json` sits at the root of the project and describes the app: its name and id, how it's laid out and built, its windows, and how it plugs into the OS. `tinyjs dev`, `build`, `publish` and `notarize` read it. A new project starts with: ```json { "$schema": "https://tinyjs.app/schema/tinyjs.schema.json", "name": "myapp", "title": "My App", "size": "960x640", "id": "com.example.myapp", "version": "0.1.0" } ``` The `$schema` line gives editors completion and type checks for every key below — see [editor schema](https://tinyjs.app/docs/config-schema). Keys that need different values per OS go in a [`macos` / `windows` / `linux` block](#per-os-overrides), and two signing keys can come from [environment variables](#environment-variables) instead. ## the app {#the-app} ### `name` {#name} `string` · **required** The executable's name: `dist/<name>`, `<name>.exe`, and the name in dmg and zip files. Also the default for `title` and `id`. ### `title` {#title} `string` · default: `name` What people see: the window title, the menu bar's app name, the `.app` folder (`dist/<title>.app`), the dmg's volume name, and the app's name in Linux and Windows file associations. ### `id` {#id} `string` · default: `com.example.<name>` A reverse-domain identifier, unique to your app (`com.yourcompany.myapp`). The OS uses it to tell apps apart — it's the macOS bundle identifier — and tinyjs keys the app's own storage to it: the data folder, [store](https://tinyjs.app/docs/store), [secrets](https://tinyjs.app/docs/secrets), saved window positions, and single-instance detection. Changing it after release starts users over with empty settings, so pick it before you ship. ### `version` {#version} `string`, like `"1.2.0"` Shown in the About panel and returned by `tiny.app.info()`. [`tinyjs publish`](https://tinyjs.app/docs/cli#publish) requires it, and [auto-update](https://tinyjs.app/docs/auto-update) compares it (the first `major.minor.patch`) to decide whether a release is newer. ### `minTinyjsVersion` {#minTinyjsVersion} `string`, like `"0.62.0"` The oldest tinyjs this project works with. An older tinyjs refuses to `dev` or `build` it and says to run `tinyjs update`, instead of half-working with calls that do nothing. `tinyjs new` and `tinyjs wrap` fill it in with the version that made the project. ### `$schema` {#schema} `string` The [JSON Schema](https://tinyjs.app/docs/config-schema) URL for editors. tinyjs ignores it; [`tinyjs doctor`](https://tinyjs.app/docs/doctor) notes when it's missing. ## code and build {#code-and-build} ### `backend` {#backend} `string` path · default: the first of `src/backend/main.js|ts`, `src/main.js|ts`, `backend/main.js|ts` The [backend](https://tinyjs.app/docs/backend) entry file. Its folder is the backend's root: a `.js` backend ships that whole folder, and a `.ts` entry is bundled from it with esbuild (so npm packages work). Code both sides use goes in a [shared](https://tinyjs.app/docs/shared-code) folder beside it. `tinyjs dev` restarts the backend when anything in that folder changes. ### `frontend` {#frontend} `object` Where the page comes from. A plain project needs nothing here: the page is `src/frontend/`, served as-is. ```json { "frontend": { "dir": "web" } } ``` A bundler project (what `tinyjs new --template` writes) gives build and dev commands instead: ```json { "frontend": { "build": "npm run build", "dist": "dist", "dev": "npm run dev", "devUrl": "http://127.0.0.1:5173" } } ``` | field | | |---|---| | `dir` | the plain page folder, default `src/frontend`. It needs an `index.html` (unless the app has no main window or uses `url`). | | `build` | a shell command [`tinyjs build`](https://tinyjs.app/docs/cli#build) runs first; its output ships instead of `dir`. The project's packages must be installed. | | `dist` | where `build` writes, default `dist`. | | `dev` | a shell command [`tinyjs dev`](https://tinyjs.app/docs/cli#dev) starts — the dev server. Used only with `devUrl`. | | `devUrl` | the dev server's address. `tinyjs dev` waits for it to answer, then loads it, and the server's own hot reload takes over. Leave `dev` out to run the server yourself. Dev only; builds ship `dist`. | | `reload` | `false`: `tinyjs dev` doesn't reload the page when files change, for a watcher of your own that does. | ### `inject` {#inject} `string` path A script run at document start in every window and every page, before the page's own scripts — for polyfills, or for adjusting a [wrapped site](https://tinyjs.app/docs/site-wrappers). A `.ts` file is bundled with esbuild. ### `icon` {#icon} `string` path · default: `assets/icon.png` The app icon, a 1024×1024 PNG. macOS turns it into `AppIcon.icns`; Windows stamps it into `<name>.exe`; Linux ships it as `icon.png` and uses it for the app's desktop entry. See [assets](https://tinyjs.app/docs/assets). ### `resources` {#resources} `string[]` · default: everything in `resources/` Which files and folders inside `resources/` to ship. Omit it to ship the whole folder; `[]` ships none. See [resources](https://tinyjs.app/docs/resources). ### `executables` {#executables} `string[]` · default: the right files from `executables/` for the OS and CPU being built Which programs inside `executables/` to ship. Each must be a native program for the target OS. `[]` ships none. See [executables](https://tinyjs.app/docs/executables). ### `executableEntitlements` {#executableEntitlements} `object` · macOS Entitlements for a sidecar when the app is signed with a Developer ID, by file name: ```json { "executableEntitlements": { "ffmpeg": ["com.apple.security.cs.disable-library-validation"] } } ``` Sidecars not listed keep the entitlements they were built with. See [executables](https://tinyjs.app/docs/executables). ### `extras` {#extras} `string[]` · default: the top level of `extras/`, plus `extras/<os>/` Files for the person downloading the app — a README, a licence — placed beside it: in the Windows and Linux folder and archive, and at the root of the macOS dmg (never inside the app). `[]` ships none. See [extras](https://tinyjs.app/docs/extras). ### `helpBook` {#helpBook} `string` · macOS A help book folder inside `assets/` (for example `"My App Help"`), so the Help menu opens it. Set it in the `macos` block. See [assets](https://tinyjs.app/docs/assets). ## windows {#windows} ### `size` {#size} `string` · default: `"960x640"` The main window's starting size, `"WIDTHxHEIGHT"` — the page area, not counting the title bar. See [tiny.win](https://tinyjs.app/docs/window). ### `mainWindow` {#mainWindow} `boolean` · default: `true` `false` starts the app with no main window and no page at all — a [tray](https://tinyjs.app/docs/tray) or background agent that opens [windows](https://tinyjs.app/docs/multiple-windows) when it needs them. Can't be combined with `url`. ### `chrome` {#chrome} `object` How the main window looks before the page draws anything: frameless, translucent, custom title-bar controls. Same fields as [`tiny.win.setChrome()`](https://tinyjs.app/docs/window), which changes them later. ```json { "chrome": { "frame": false, "vibrancy": "hud", "windowControls": ["close"] } } ``` | field | | macOS | Windows | Linux | |---|---|:-:|:-:|:-:| | `frame` | `false`: no title bar or border — make your own with [`data-tiny-drag`](https://tinyjs.app/docs/window) | ✓ | ✓ | ✓ | | `windowControls` | `true` / `false`, or a list of `"close"` `"minimize"` `"maximize"` | ✓ | close always shows with the others | best effort | | `transparent` | see-through window background | ✓ | main window, set here only | ✓ | | `vibrancy` | blurred background material, such as `"hud"` `"sidebar"` `"popover"`; `false` for none | ✓ | Windows 11: Acrylic for `hud`, `popover`, `menu`; Mica otherwise | — | | `squareCorners` | no rounded corners | ✓ | ✓ | ✓ | | `acceptsFirstMouse` | a click on an inactive window also acts on what it hits | ✓ | — | — | | `windowControlsPos` | `{ "x": 12, "y": 14 }`: where the traffic lights sit | ✓ | — | — | | `menu` | `false`: no menu bar in this window | — (one menu bar per app) | ✓ | ✓ | | `panel` | a non-activating panel: takes the keyboard without activating the app, so it can open over full-screen apps — see [window fx](https://tinyjs.app/docs/window-fx#panel) | ✓ | — | — | ### `rememberWindows` {#rememberWindows} `boolean` · default: `false` Put every window back where the user left it — position, size, maximized or full screen — remembered separately for each set of plugged-in screens. The main window stays hidden until it's been placed, so it doesn't jump. See [tiny.win](https://tinyjs.app/docs/window). ### `offscreenRescue` {#offscreenRescue} `boolean` · default: `true` When the screens have changed since the last run (a display unplugged), windows that would open where nobody can see them are moved onto a screen. `false` turns that off, for apps that park windows off-screen on purpose. [`tiny.win.ensureOnScreen()`](https://tinyjs.app/docs/window) works either way. ### `windowPlacement` {#windowPlacement} `boolean` · Linux `true` if the app places its own windows (`setPosition`, `center`). Wayland doesn't let apps do that, so on Linux this runs the app through X11 (XWayland) when it's available. Does nothing on macOS and Windows. ### `activation` {#activation} `"regular"` (default) or `"accessory"` `"accessory"` makes a menu-bar style app: no Dock icon on macOS, no taskbar button on Windows and Linux, and the window starts hidden until the app shows it — pair it with a [tray](https://tinyjs.app/docs/tray) icon. ## behaviour {#behaviour} ### `contextMenu` {#contextMenu} `boolean` · default: `true` `false` hides the webview's built-in right-click menu (Reload, Inspect…). A [custom context menu](https://tinyjs.app/docs/menus) still shows. ### `debug` {#debug} `true`, `"open"` or `false` (default) Developer tools in a built app: `true` lets F12 open them (⌥⌘I on macOS too), `"open"` opens them with every window. `tinyjs dev` always has F12. ### `browserAccelerators` {#browserAccelerators} `boolean` · default: `false` · Windows `true` keeps WebView2's own keyboard shortcuts (Ctrl+P print, Ctrl+F find, Ctrl+R reload…). They're off by default so they can't fire underneath your app's own shortcuts. ### `about` {#about} `"menu"` · macOS The app menu's About item sends `onMenu('about')` and the page's `menu` event instead of showing the standard About panel, so you can show your own. `about` becomes a reserved menu item id. See [menus](https://tinyjs.app/docs/menus). ### `attribution` {#attribution} `string` · macOS · default: "Made with tinyjs" The credit line in the standard About panel. An `assets/Credits.html` (or `.rtf`) replaces it with formatted credits — see [assets](https://tinyjs.app/docs/assets). ### `downloads` {#downloads} `"auto"` (default), `"ask"` or `"deny"` What happens when the page starts a download: `auto` saves to the Downloads folder, `ask` shows a save dialog, `deny` refuses. The backend hears each download's progress through `onDownload`, but can't change the decision — see [site wrappers](https://tinyjs.app/docs/site-wrappers). ### `popups` {#popups} `"external"` (default), `"window"` or `"deny"` What `window.open` and `target="_blank"` links do: `external` opens them in the default browser, `window` in a new app window (keeping `window.opener`, for sign-in popups), `deny` blocks them. The backend can decide per popup with `onWindowOpen` — see [site wrappers](https://tinyjs.app/docs/site-wrappers). ### `userAgent` {#userAgent} `string` Replaces the webview's user agent, for sites that sniff it. ### `readAccess` {#readAccess} `true` or a folder path · macOS Lets the page load local files from outside its own folder — your home folder with `true`, or one folder — through [`tiny.fileURL(path)`](https://tinyjs.app/docs/api). Windows and Linux already allow it. ## OS integration {#os-integration} These are registered for built apps only — part of the `.app` on macOS, added when the app first runs on Windows and Linux — never by `tinyjs dev`. See [deep links & files](https://tinyjs.app/docs/deep-links). ### `urlScheme` {#urlScheme} `string` or `string[]` Links like `myapp://…` open the app and arrive in `onOpenUrl`. A list registers several schemes. ### `fileExtensions` {#fileExtensions} `string[]`, like `["md", "txt"]` File types the app can open: they're listed under "Open With", and opened files arrive in `onOpenFiles`. Windows adds the app to "Open with" without making it the default. ### `openFolders` {#openFolders} `boolean` · macOS, Linux Folders can be opened with the app too (dropped on its icon, or "Open With"); they arrive in `onOpenFiles`. ## permissions and audio {#permissions-and-audio} ### `permissions` {#permissions} `object` Camera, microphone, speech recognition and location, each with the reason shown to the user: ```json { "permissions": { "microphone": "To record voice notes", "camera": "For video calls" } } ``` - **macOS**: the reasons appear in the system permission prompts, and declaring a key is what lets the app ask at all. `speechRecognition` is macOS only and needs `microphone` too. `location` is macOS only: it lets the app ask with [`permissions.request('location')`](https://tinyjs.app/docs/permissions#names), which [`system.wifi()`](https://tinyjs.app/docs/system#wifi) needs to read the network name. Signed builds get the matching hardened-runtime entitlement. - **Linux**: `microphone` and `camera` decide whether the page may use `getUserMedia` at all. - **Windows**: not needed — WebView2 shows its own prompt. See [permissions](https://tinyjs.app/docs/permissions). ### `audioTap` {#audioTap} `"app"`, `"system"` or off (default) Turns on [`tiny.audioTap`](https://tinyjs.app/docs/audiotap), which reads audio being played: `"app"` the app's own, `"system"` everything. Without it, `audioTap.start()` fails with `not-declared`. ### `audioTapReason` {#audioTapReason} `string` · macOS · default: "<title> reads audio output for metering and visualization." The reason shown in macOS's audio capture prompt. ## signing and notarizing (macOS) {#signing} ### `signIdentity` {#signIdentity} `string` · macOS · default: ad-hoc The codesigning certificate, by name: `"Developer ID Application: Your Name (TEAMID123)"`. Without it the app is signed ad-hoc — it runs on your Mac, but Gatekeeper blocks it on others. `TINYJS_SIGN_IDENTITY` overrides it. See [notarize](https://tinyjs.app/docs/cli#notarize) for setting up a certificate. ### `notarize` {#notarize} `{ "profile": "…" }` · macOS The `notarytool` keychain profile [`tinyjs notarize`](https://tinyjs.app/docs/cli#notarize) submits with. `TINYJS_NOTARY_PROFILE` overrides it. ## updates {#updates} ### `update` {#update} `object` ```json { "update": { "url": "https://example.com/myapp/manifest.json", "auto": "daily" } } ``` | field | | |---|---| | `url` | the `manifest.json` [`tinyjs publish`](https://tinyjs.app/docs/cli#publish) wrote, wherever you host it. Must be `https://` (`http://localhost` works for testing). | | `auto` | `"launch"`: check a few seconds after the app starts; `"daily"`: and then once a day. A newer version fires `onUpdateAvailable` and the page's `update-available` event. Leave it out to check only when you call `tiny.app.update.check()`. Built apps only. | | `publicKey` | require updates to be signed: the base64 public key, or a list of keys (a signing key plus a backup). See [signed updates](https://tinyjs.app/docs/signed-updates). | See [auto-update](https://tinyjs.app/docs/auto-update). ## site wrappers {#site-wrappers} ### `url` {#url} `string` Makes the main window a hosted web page instead of your own frontend — wrapping a web app as a desktop app. [`tinyjs wrap`](https://tinyjs.app/docs/cli#wrap) writes this, plus `api`, `popups` and `downloads`, for you. Can't be combined with `mainWindow: false`. See [site wrappers](https://tinyjs.app/docs/site-wrappers). ### `api` {#api} `string`, `string[]` or `object` Which `tiny` calls pages may make. **Set this whenever the app loads a site you don't control**: `tiny` is available to every page in the app, so without it a wrapped site's JavaScript could call anything your backend can do. Checked in the backend, by the calling frame's real origin. | value | | |---|---| | omitted, `"all"` | no limit — fine for an app that only shows its own pages | | `"none"` | no calls at all | | `"wrapper"` | window, menu, tray, dialog, notification and store calls, plus a few read-only ones — no files, processes or network. Your own `api` methods are off; enable them by name | | `["notify", "store.*"]` | only these | | `{ "preset", "enable", "disable", "origins" }` | start from a preset, then turn calls on and off. `enable` wins over `disable` | Names are the call's name (`notify`, `win.setTitle`), `prefix.*` for a group, or `*` for all. `origins` gives each site its own rules, first matching key wins (`*` in a key matches anything): ```json { "api": { "disable": ["*"], "enable": ["win.*"], "origins": { "https://app.example.com": ["notify", "store.*", "media.microphone"], "file://*": "all" } } } ``` A page whose origin matches no key gets the top-level `enable`/`disable` lists. `media.camera`, `media.microphone` and `media.proxy` in an origin's list let that site use the camera or microphone without a prompt (macOS) or at all (Linux), and use `tiny.proxyURL`. The same rules decide which events such a page receives — your own `app.push` events need `event.<name>` (see [events](https://tinyjs.app/docs/site-wrappers#events)). An unknown preset name blocks everything, and says so. See [site wrappers](https://tinyjs.app/docs/site-wrappers) for the full model. ## per-OS overrides {#per-os-overrides} Root keys apply everywhere. A `macos`, `windows` or `linux` block is merged on top for that OS, so the keys that genuinely differ (an `.ico`, a macOS signing identity, a vibrancy that means nothing elsewhere) don't make you write three of everything. The block names are what `tiny.system.os()` returns. ```jsonc { "name": "myapp", "chrome": { "frame": false }, // merged on top on that OS — for dev, build and publish alike "macos": { "signIdentity": "Developer ID Application: …", "chrome": { "vibrancy": "hud" } }, "windows": { "icon": "assets/icon.ico" }, "linux": { "icon": "assets/icon-512.png" } } ``` Objects merge, so the macOS block above ends up with `{ "frame": false, "vibrancy": "hud" }` rather than losing the root's `frame`. Everything else — strings, numbers, lists — replaces the root value. ## environment variables {#environment-variables} Two keys can come from the environment instead, so a signing identity or notary profile can live in your shell or CI secrets rather than a committed file: | variable | sets | |---|---| | `TINYJS_SIGN_IDENTITY` | [`signIdentity`](#signIdentity) | | `TINYJS_NOTARY_PROFILE` | [`notarize.profile`](#notarize) | They win over the file — root and OS block alike — and the build prints a line when one replaces a value the file set (`==> signIdentity from TINYJS_SIGN_IDENTITY (overriding tinyjs.json)`), so a variable left exported in a shell can't quietly change how a project signs. The order is **root → OS block → environment**. The other `TINYJS_*` variables are for running and debugging — see [cli](https://tinyjs.app/docs/cli#environment). <!-- https://tinyjs.app/docs/config-schema --> # Editor assistance for tinyjs.json The [JSON Schema](schema/tinyjs.schema.json) describes the configuration fields used by the CLI and runtime. It supplies field descriptions, completion, and type checks in editors that support JSON Schema Draft 2020-12. Projects made by `tinyjs new` and `tinyjs wrap` already point at it, in the first line of `tinyjs.json`: ```json { "$schema": "https://tinyjs.app/schema/tinyjs.schema.json", "name": "hello" } ``` VS Code, JetBrains IDEs, Zed and most other editors pick that up with no setup. For an older project, add the same line. tinyjs itself ignores the key and never fetches the schema: the editor does that, and offline it just shows no hints. The URL always serves the schema for the latest tinyjs release. ## Compatibility and scope Root fields and partial `macos`, `windows`, and `linux` blocks share the same definitions. tinyjs merges plain objects recursively; arrays and scalars in the active platform block replace the root values. Environment signing settings then take precedence. Unknown fields remain allowed, including nested extension fields, to support future tinyjs releases and application tooling. The schema checks documented field shapes; it does not reproduce every implicit JavaScript coercion. Optional settings that accept `null` retain that option. It deliberately imposes no version format, bundle ID format, path regex, or required root `name`: a platform block can provide the name. A schema-valid fragment is therefore not necessarily a runnable app. The CLI still checks the resolved name, incompatible options (such as `mainWindow: false` with `url`), file existence, path safety, native executable formats, and signing credentials. Validation does not grant or audit API capabilities. ## Verification `test/config-schema/coverage.py` needs only the standard library. It fails when `cli.js` or the runtime reads a top-level key the schema doesn't describe. Nested keys (`chrome.*`, `frontend.*`, ...) aren't covered and have to be added by hand: ```sh python3 test/config-schema/coverage.py ``` <!-- https://tinyjs.app/docs/shared-code --> # shared code > code both sides import Constants, validation or formatting that the [backend](https://tinyjs.app/docs/backend) and the page both use go in a `shared/` folder beside the backend's folder. Both sides import it by the same relative path, in `tinyjs dev` and in a build. ```text myapp/ src/ backend/main.js frontend/app.js shared/settings.js ``` ```js // src/shared/settings.js export const MAX_TITLE = 80; export const validTitle = (t) => t.length > 0 && t.length <= MAX_TITLE; ``` ```js // src/backend/main.js and src/frontend/app.js alike import { validTitle } from '../shared/settings.js'; ``` The folder goes next to the backend's: `src/shared/` beside `src/backend/`, or `shared/` at the project root in the Vite templates, whose backend is `backend/`. A plain page loads it with `<script type="module">`; a bundled frontend (Vite) pulls it into its own bundle. ## rules - **Plain `.js` modules** (ES modules, `export`/`import`). - **Nothing secret.** The folder ships as plain files next to the page, so the page — and anyone who opens the app's files — can read it. - **No `tjs:*` imports.** The page doesn't have txiki.js; anything that touches files, processes or the network belongs in the backend. In `tinyjs dev`, editing a shared file restarts the app, since the backend uses it too. Projects from before 0.52 (`src/main.js`, with the frontend inside `src/`) can't use `src/shared/` — `tinyjs dev` says so. Move `src/main.js` and the files it imports into `src/backend/` first. <!-- https://tinyjs.app/docs/resources --> # resources > data your code reads Data your own code reads at runtime — a dictionary, fonts, templates, a model — goes in a `resources/` folder. The whole folder ships with the app, and `paths().resources` points at it: the folder itself in `tinyjs dev`, the bundled copy in a build. The same relative paths work in both, wherever the app is installed or launched from. ```text myapp/ resources/ dictionary.json fonts/ ``` In the [backend](https://tinyjs.app/docs/backend): ```js export async function init(app) { const root = (await app.paths()).resources; // string, or null with no resources/ if (root === null) return; const bytes = await tjs.readFile(root + '/dictionary.json'); console.log(new TextDecoder().decode(bytes)); } ``` The page gets the same path from `await tiny.app.paths()`. Reading the files is still the backend's job: resources don't widen what the page may load. Expose what the page needs through your [backend API](https://tinyjs.app/docs/api). ## where it ends up {#where} - **macOS**: inside the app, at `Contents/Resources/app/resources/`. - **Windows, Linux**: a `resources/` folder beside the executable in `dist/`. The files stay as plain files (not packed into the executable), and [`tinyjs publish`](https://tinyjs.app/docs/cli#publish) archives and updates include them. With no `resources/` folder, or `"resources": []`, nothing ships and the path is `null`. ## shipping part of it {#list} To ship only some of the folder, list paths inside it with [`resources`](https://tinyjs.app/docs/config#resources). A list narrows builds only — `tinyjs dev` always sees the whole folder. ```json { "resources": ["dictionary.json", "fonts/"] } ``` For different files per OS, put a list in a [per-OS block](https://tinyjs.app/docs/config#per-os-overrides). An OS's list replaces the root one (it doesn't add to it), and an OS without its own list uses the root's — so to give one OS a file the others don't get, empty the root list: ```jsonc { "resources": [], // Windows, Linux: nothing "macos": { "resources": ["mac-only.png"] } // macOS: just this } ``` ## rules {#rules} The build fails on a listed path that doesn't exist, absolute, drive and UNC paths, `..`, and symlinks (including `resources/` itself). On Windows, path parts can't end in a dot or a space. Programs your backend runs go in [executables](https://tinyjs.app/docs/executables) instead; files for the person downloading the app, in [extras](https://tinyjs.app/docs/extras). <!-- https://tinyjs.app/docs/executables --> # executables > programs your backend runs (sidecars) Programs your backend launches — a CLI built with Go, Rust, Bun, Deno, pkg or PyInstaller, or a tool like ffmpeg — go in `executables/`. A build ships only its own OS's programs, so every OS's can sit there together, and `paths().executables` points at the ones that shipped. ```text myapp/ executables/ my-cli.exe # Windows (PE): only Windows builds ship it my-cli # macOS (Mach-O, universal) linux/my-cli # Linux's own, since the name clashes linux-arm64/my-cli # …and the arm64 one, for arm64 builds ``` ```js export async function init(app) { const dir = (await app.paths()).executables; // string, or null if none shipped if (dir === null) return; const exe = dir + '/my-cli' + (tjs.env.OS === 'Windows_NT' ? '.exe' : ''); const p = tjs.spawn([exe, '--version'], { stdout: 'pipe' }); // …read p.stdout, await p.wait() } ``` ## which files ship {#which} With no list, a build takes, in order: 1. the files at the top of `executables/`, 2. then `executables/<os>/` — `macos`, `windows` or `linux`, 3. then `executables/<os>-arm64/` or `<os>-x86_64/`, for that CPU's build (see [`--arch`](https://tinyjs.app/docs/cli#build)). A later file replaces an earlier one with the same name. Files at the top that aren't this OS's programs are left out with a note; in an OS folder, they fail the build. Other subfolders aren't shipped (the build notes them). Only native programs ship — Mach-O on macOS, PE on Windows, ELF on Linux, plus the libraries they load, and `#!` scripts on Linux. Scripts and data a program reads go in [resources](https://tinyjs.app/docs/resources). Or list paths inside `executables/` with [`executables`](https://tinyjs.app/docs/config#executables) (a folder means the files in it). A [per-OS block](https://tinyjs.app/docs/config#per-os-overrides) replaces the list for that OS: ```json { "executables": ["my-cli.exe"], "macos": { "executables": ["mac/my-cli", "mac/helper"] }, "linux": { "executables": ["linux/"] } } ``` ## where they end up {#where} Everything ships flat, so `paths().executables + '/my-cli'` is the program wherever it came from — two shipped files with one name fail the build. - **macOS**: inside the app, in `Contents/Helpers/`. - **Windows, Linux**: an `executables/` folder beside the executable in `dist/`. Builds mark them executable, and [`tinyjs publish`](https://tinyjs.app/docs/cli#publish) archives and updates carry them. In `tinyjs dev` the path is `.build/executables/`, linking to your files (copies on Windows), so a rebuilt program is picked up on the next spawn — but there the file needs its own executable bit (`chmod +x`), and the build notes when it's missing. ## macOS signing {#signing} The build signs every sidecar with the app's identity, innermost first, as `<id>.<file>`. With a Developer ID ([`signIdentity`](https://tinyjs.app/docs/config#signIdentity)) that includes the hardened runtime, which notarization requires on every executable — and which kills a JIT runtime (Bun, Deno, Node/pkg) unless it has the entitlement for it. A sidecar keeps the entitlements it arrived with; to set them, name them per file with [`executableEntitlements`](https://tinyjs.app/docs/config#executableEntitlements): ```json { "macos": { "executableEntitlements": { "my-cli": ["com.apple.security.cs.allow-jit", "com.apple.security.cs.allow-unsigned-executable-memory"] } } } ``` The build warns when the app runs on Intel Macs but a sidecar has no x86_64 slice (it would fail to start there); an x86_64-only sidecar runs on Apple Silicon under Rosetta 2. A program you downloaded may be quarantined in dev: clear it with `xattr -d com.apple.quarantine <file>`. <!-- https://tinyjs.app/docs/assets --> # assets > files the OS reads `assets/` holds the files the operating system reads, each by its name. Nothing else in it ships — the build notes any file it doesn't use. Files your own code reads go in [resources](https://tinyjs.app/docs/resources). ```text myapp/ assets/ icon.png # the app icon, every OS Credits.html # macOS About panel credits (or Credits.rtf / .rtfd) My App Help/ # macOS help book ``` Projects made before 0.53 keep `icon.png` and `Credits.html` at the project root; both are still found there. ## icon {#icon} A 1024×1024 PNG. Point [`icon`](https://tinyjs.app/docs/config#icon) somewhere else to use another file. - **macOS**: turned into `AppIcon.icns` for the app, and shown in the Dock during `tinyjs dev`. - **Windows**: stamped into `<name>.exe` and `launcher.exe`. - **Linux**: shipped as `icon.png` beside the app, and used for its desktop entry. ## credits (macOS) {#credits} `Credits.html` (or `.rtf`, `.rtfd`) fills the standard About panel, with working links, in place of the one-line [`attribution`](https://tinyjs.app/docs/config#attribution). Start an HTML file with `<meta charset="utf-8">` and set a font, or AppKit reads it as Latin-1 and sets it in Times. ## help book (macOS) {#help-book} A folder of HTML pages with its own `Contents/Info.plist`, shown by Help Viewer — see [Apple's guide](https://developer.apple.com/library/archive/documentation/Carbon/Conceptual/ProvidingUserAssitAppleHelp/authoring_help/authoring_help_book.html). Name the folder with [`helpBook`](https://tinyjs.app/docs/config#helpBook): ```json { "macos": { "helpBook": "My App Help" } } ``` The build copies it into the app, registers it by its own `CFBundleIdentifier`, and rebuilds its search index with `hiutil`. The app's Help menu then has "<App> Help" (⌘?) and a search field that searches the book — no code needed; add your own items with a `{ role: 'help' }` [menu](https://tinyjs.app/docs/menus). It works in built apps only (in `tinyjs dev` there's no app bundle for Help Viewer to find). Help Viewer caches books by version, so bump [`version`](https://tinyjs.app/docs/config#version) to see an edited book. <!-- https://tinyjs.app/docs/extras --> # extras > README, licence — beside the download Files for the person downloading your app — a README, a licence, install notes — go in `extras/`. They ship next to the app, never inside it: - **macOS**: at the root of the dmg, beside the app and the Applications shortcut. - **Windows, Linux**: in the app's folder beside the executable — `dist/` and the [`tinyjs publish`](https://tinyjs.app/docs/cli#publish) archive. ```text myapp/ extras/ LICENSE.txt # every OS README.txt # Windows and Linux… macos/README.txt # …and the Mac's own, which mentions Gatekeeper ``` ## which files ship {#which} With no list, a build ships the top of `extras/`, then `extras/<os>/` (`macos`, `windows`, `linux`), which replaces a file with the same name. Folders ship whole and keep their executable bits, so an `install.sh` in a Linux archive runs. The build prints what it shipped. Or list paths inside `extras/` with [`extras`](https://tinyjs.app/docs/config#extras) — each lands under its own name, a [per-OS block](https://tinyjs.app/docs/config#per-os-overrides) replaces the list, and `[]` ships nothing: ```json { "extras": ["LICENSE.txt", "README.txt"], "macos": { "extras": ["LICENSE.txt", "macos/README.txt"] } } ``` ## rules {#rules} - A name the build already writes there (`launcher.exe`, the app, `Applications`, `resources`, …) fails the build, compared ignoring case; so do two extras with one name. - Listed paths must exist; absolute, drive and UNC paths, `..` and symlinks fail. On Windows, path parts can't end in a dot or a space. ## macOS: only the dmg carries them {#macos} Build with [`--dmg`](https://tinyjs.app/docs/cli#build), or let [`tinyjs notarize --dmg`](https://tinyjs.app/docs/cli#notarize) rebuild it. The app itself and the update zip never carry extras; a build without `--dmg` notes that they're left out. ## updates {#updates} On Linux an [update](https://tinyjs.app/docs/auto-update) replaces the whole app folder, extras included. On Windows it copies the new files over the old by name, so an extra you drop or rename stays behind. For help people need after installing, use the app itself: [credits, a help book](https://tinyjs.app/docs/assets), or a Help menu item that opens a file from [resources](https://tinyjs.app/docs/resources). <!-- https://tinyjs.app/docs/backend --> # backend > src/backend/main.js — full system access via [txiki.js](https://txikijs.org/) Every tinyjs app is two processes: the page, in the system webview, and the backend, a JavaScript module running on [txiki.js](https://txikijs.org/). The backend has what the page doesn't — files, processes, sockets, a [sqlite](https://tinyjs.app/docs/sqlite) — through the `tjs` global and `tjs:*` modules. The page asks it for things with [`tiny.api.call`](https://tinyjs.app/docs/api), and it talks back with events. See [how it works](https://tinyjs.app/docs/how-it-works) for the wiring. The backend is `src/backend/main.js` (or `.ts`), or wherever [`backend`](https://tinyjs.app/docs/config#backend) points. It exports up to three kinds of thing: ```js // functions the page can call export const api = { readNotes: async ({ dir }, app) => { /* … */ }, }; // runs once at startup export async function init(app) { app.push('ready', { at: Date.now() }); } // called when something happens: a menu item, a tray click, a file opened… export function onMenu(id, app) {} ``` ## api functions {#api} Each function in `api` is a call the page can make: ```js // backend export const api = { rename: async ({ from, to }, app, { window, origin }) => { await tjs.rename(from, to); return { ok: true }; }, }; // page const r = await tiny.api.call('rename', { from: 'a.txt', to: 'b.txt' }); ``` - **Arguments**: the params the page passed (`{}` if none), the [`app` handle](#app), and the caller — the id of the `window` it came from and the page's `origin`. - **Returning** resolves the page's promise with the value, which must be JSON (`undefined` arrives as `null`). Functions may be `async`. - **Throwing** rejects the page's promise with the error's message — a plain string, not an `Error`, so `catch (msg)` gets the text itself and `msg.message` is `undefined`. The stack and any custom fields stay behind; for anything richer, return `{ ok: false, … }` instead. - **Names**: `fetch`, `log`, `notify`, `ping` and `quit` are tinyjs's own calls and win over yours, as do dotted names like `win.close`. Pick other names. Expose named operations (`rename`, `saveNote`) rather than general ones (`exec(cmd)`, `query(sql)`): anything in `api` can be called by any page the app loads. An app that loads a site it doesn't control must limit calls with [`api` in tinyjs.json](https://tinyjs.app/docs/config#api). ## init {#init} `init(app)` runs once, after the native window exists — the page may still be loading. Set things up here: menus, a tray icon, hotkeys, a database. A page that needs data on start should ask for it with an `api` call rather than wait for a push from `init`, which can arrive before the page has subscribed. ## events to the page {#push} `app.push(name, data)` sends an event to every window; the page listens with `tiny.api.on(name, fn)`. `app.win.push(name, data)` reaches only the main window, and `app.window(id).push(…)` one window. With an [`api`](https://tinyjs.app/docs/config#api) block, a page from an origin the app didn't ship — a [wrapped site](https://tinyjs.app/docs/site-wrappers#events) — only gets events its rules allow; your own events need `event.<name>`. ```js // backend setInterval(() => app.push('tick', { now: Date.now() }), 1000); // page const off = tiny.api.on('tick', ({ now }) => { … }); ``` ## hooks {#hooks} Export any of these and tinyjs calls it. Each also reaches the page as an event, mostly with its own `tiny` listener. A hook that throws is reported in the console and the app carries on. | export | called when | see | |---|---|---| | `onMenu(id, app)` | a menu bar item is chosen | [menus](https://tinyjs.app/docs/menus) | | `onContextMenu(id, app)` | a custom right-click menu item is chosen | [menus](https://tinyjs.app/docs/menus) | | `onTray(id, app)` | a tray menu item is chosen; `id` is `null` for a click on the icon itself | [tray](https://tinyjs.app/docs/tray) | | `onHotkey(id, app)` | a global hotkey is pressed | [hotkeys](https://tinyjs.app/docs/hotkeys) | | `onSystem(kind, value, app)` | the theme changes, or the computer sleeps or wakes (`'theme'`, `'sleep'`, `'wake'`) | [system](https://tinyjs.app/docs/system) | | `onLocale(info, app)` | the OS language or region changes (macOS) | [system](https://tinyjs.app/docs/system#locale) | | `onOpenUrl(url, app)` | a `myapp://` link opens the app | [deep links](https://tinyjs.app/docs/deep-links) | | `onOpenFiles(paths, app)` | files or folders are opened with the app | [deep links](https://tinyjs.app/docs/deep-links) | | `onNotificationClick(id, app)` | a notification is clicked | [notifications](https://tinyjs.app/docs/notifications#clicks) | | `onNotificationAction(info, app)` | a notification's button is pressed, or it's replied to | [notifications](https://tinyjs.app/docs/notifications#actions) | | `onMediaKey(info, app)` | a media key (play/pause, next…) is pressed | [media](https://tinyjs.app/docs/media) | | `onWindowState(info, app)` | a window goes full screen, is maximized or minimized, or gains or loses focus | [tiny.win](https://tinyjs.app/docs/window) | | `onWindowClosed(id, app)` | a window closes | [multiple windows](https://tinyjs.app/docs/multiple-windows) | | `onClipboardChange(info, app)` | the clipboard changes (exporting it starts watching) | [clipboard](https://tinyjs.app/docs/clipboard) | | `onAudioTap(chunk, app)` | a chunk of tapped audio arrives | [audioTap](https://tinyjs.app/docs/audiotap) | | `onUpdateAvailable(info, app)` | an automatic update check finds a newer version | [auto-update](https://tinyjs.app/docs/auto-update) | | `onNavigate(info, app)` | a page navigates — and the backend may allow or block it | [site wrappers](https://tinyjs.app/docs/site-wrappers) | | `onDownload(info, app)` | a download starts, finishes or fails — it reports; the [`downloads`](https://tinyjs.app/docs/config#downloads) setting decides | [site wrappers](https://tinyjs.app/docs/site-wrappers) | | `onWindowOpen(info, app)` | a page opens a popup — and the backend decides where it goes | [site wrappers](https://tinyjs.app/docs/site-wrappers) | ## the app handle {#app} The `app` passed to `init`, hooks and `api` functions has the same calls the page has on `tiny`, under the same names: a page's `tiny.win.setTitle()` is `app.win.setTitle()` for the main window, `tiny.store.get()` is `app.store.get()`, `tiny.app.paths()` is `app.paths()`, and so on. The [API map](https://tinyjs.app/docs/api-map) lists every call on both sides. Other windows are `app.window(id)`. The backend has one call of its own, `app.spawnHidden(args, opts)`: `tjs.spawn` that never flashes a console window on Windows. A console program spawned from a built Windows app otherwise opens a terminal window each time; on macOS and Linux it's the same as `tjs.spawn`. ## what the backend can use {#runtime} - **txiki.js**: files, processes, sockets, an HTTP server, workers — the `tjs` global and `tjs:*` modules. See [txiki.js](https://tinyjs.app/docs/txiki) for what's there and links to its reference. - **Web APIs**: `fetch`, `TextEncoder`, `crypto.subtle`, `URL`, `WebSocket` and the rest txiki.js provides — [listed here](https://tinyjs.app/docs/txiki#web). - **npm packages**, in a TypeScript backend: it's bundled with esbuild, so anything that doesn't need Node's built-in modules works. - **Your own files**: [resources](https://tinyjs.app/docs/resources) for data, [executables](https://tinyjs.app/docs/executables) for programs, [shared code](https://tinyjs.app/docs/shared-code) for modules the page uses too. - **A database**: SQLite is built in — see [sqlite](https://tinyjs.app/docs/sqlite). - **The webview's cookies**: read and change them with `app.cookies` — see [cookies](https://tinyjs.app/docs/cookies). The page can't use any of this directly: it has no `tjs`, so expose what it needs through `api`. <!-- https://tinyjs.app/docs/txiki --> # txiki.js > the runtime your backend runs on The [backend](https://tinyjs.app/docs/backend) runs on [txiki.js](https://txikijs.org/), a small JavaScript runtime: the QuickJS engine, libuv for I/O, with SQLite and WebAssembly built in. tinyjs ships **txiki.js 26.6.0**. Everything txiki.js offers is available to your backend — this page is a map of it, with links to [txiki.js's own reference](https://txikijs.org/docs/api-reference) for the details. It isn't Node.js: there's no `require`, no `process`, no `Buffer` and no `node:*` modules. Use the `tjs` global, the `tjs:*` modules and standard Web APIs instead. npm packages work in a TypeScript backend (it's bundled with esbuild) as long as they don't need Node's built-ins. The page has none of this — it runs in the system webview, with no `tjs`. Expose what it needs through [`api` functions](https://tinyjs.app/docs/backend#api). ## the tjs global {#tjs} | area | calls | reference | |---|---|---| | files | `readFile`, `writeFile`, `open`, `stat`, `lstat`, `readDir`, `makeDir`, `makeTempDir`, `makeTempFile`, `remove`, `rename`, `copyFile`, `symlink`, `readLink`, `realPath`, `chmod`, `chown`, `utime`, `statFs`, `watch` | [filesystem](https://txikijs.org/docs/api/global.tjs.Function.open) | | processes | `spawn`, `exec`, `kill`, `exit`, `pid`, `ppid`, `addSignalListener` | [process](https://txikijs.org/docs/api/global.tjs.Function.spawn) | | network | `connect`, `listen`, `lookup` (DNS) | [networking](https://txikijs.org/docs/api/global.tjs.Function.connect) | | HTTP server | `serve` | [HTTP server](https://txikijs.org/docs/api/global.tjs.Function.serve) | | system | `args`, `env`, `cwd`, `chdir`, `homeDir`, `tmpDir`, `hostName`, `exePath`, `system`, `version` | [system](https://txikijs.org/docs/api/global.tjs.Variable.args) | | console & I/O | `stdin`, `stdout`, `stderr`, `createConsole` | [utilities](https://txikijs.org/docs/api/global.tjs.Function.createConsole) | | engine | `engine` — versions, GC, features | [engine](https://txikijs.org/docs/api/global.tjs.Namespace.engine) | | modules | `setImportMap` | [modules](https://txikijs.org/docs/api/global.tjs.Function.setImportMap) | ```js const text = new TextDecoder().decode(await tjs.readFile(path)); await tjs.writeFile(out, new TextEncoder().encode(text.toUpperCase())); const p = tjs.spawn(['git', 'status', '--short'], { stdout: 'pipe' }); let output = ''; for await (const chunk of p.stdout) output += new TextDecoder().decode(chunk); await p.wait(); ``` On Windows, use [`app.spawnHidden()`](https://tinyjs.app/docs/backend#app) rather than `tjs.spawn()` for console programs, so each one doesn't flash a terminal window. ## tjs: modules {#modules} | module | for | reference | |---|---|---| | `tjs:sqlite` | a SQLite database — see [sqlite](https://tinyjs.app/docs/sqlite) | [tjs:sqlite](https://txikijs.org/docs/api/tjs-sqlite) | | `tjs:path` | joining and splitting paths | [tjs:path](https://txikijs.org/docs/api/tjs-path) | | `tjs:hashing` | MD5, SHA-1, SHA-256 and other digests | [tjs:hashing](https://txikijs.org/docs/api/tjs-hashing) | | `tjs:uuid` | UUIDs | [tjs:uuid](https://txikijs.org/docs/api/tjs-uuid) | | `tjs:ffi` | calling C libraries directly | [tjs:ffi](https://txikijs.org/docs/api/tjs-ffi) | | `tjs:wasi` | running WASI WebAssembly modules | [tjs:wasi](https://txikijs.org/docs/api/tjs-wasi) | | `tjs:posix-socket` | raw sockets | [tjs:posix-socket](https://txikijs.org/docs/api/tjs-posix-socket) | | `tjs:ipaddr` | parsing IP addresses | [tjs:ipaddr](https://txikijs.org/docs/api/tjs-ipaddr) | | `tjs:readline`, `tjs:getopts` | terminal input and command-line options, for command-line tools | [readline](https://txikijs.org/docs/api/tjs-readline), [getopts](https://txikijs.org/docs/api/tjs-getopts) | | `tjs:assert`, `tjs:utils` | assertions; `format` and `inspect` | [assert](https://txikijs.org/docs/api/tjs-assert), [utils](https://txikijs.org/docs/api/tjs-utils) | ```js import path from 'tjs:path'; import { createHash } from 'tjs:hashing'; const file = path.join((await app.paths()).data, 'notes.json'); const digest = createHash('sha256').update(text).digest(); ``` ## Web APIs {#web} The backend also has the standard Web APIs txiki.js implements, including `fetch`, `WebSocket`, `Worker`, `crypto` (with `crypto.subtle` and `randomUUID`), `TextEncoder`/`TextDecoder`, `URL`, `Blob`, `FormData`, `AbortController`, `CompressionStream`, `structuredClone`, `performance` and `localStorage`. See txiki.js's [Web Platform APIs](https://txikijs.org/docs/features/web-platform-apis) for the full list. For an app's settings, prefer [`app.store`](https://tinyjs.app/docs/store) to `localStorage`: it's the same store the page sees, saved in the app's data folder. ## types {#types} Every project gets txiki.js's own type definitions as `types/tjs.d.ts` (`backend/tjs.d.ts` in the Vite templates), so editors complete and check `tjs.*` calls and `tjs:*` imports — no npm needed. They match the txiki.js version tinyjs ships, and [`tinyjs update-project`](https://tinyjs.app/docs/cli#update-project) brings them up to date after you update tinyjs. They're for the backend only: the Vite templates' `src/` is the page, which has no `tjs`, so it doesn't get them. ## versions {#versions} txiki.js's website documents its latest release, which can be newer than the version tinyjs ships. If something there doesn't exist in your backend, check `tjs.version` — or `tjs.engine.versions` for QuickJS, libuv and SQLite. A tinyjs release names the txiki.js version it moves to in the [changelog](https://tinyjs.app/changelog). Sharp edges worth knowing — async file calls next to sync SQLite, `stdout: 'ignore'` vs `'null'` — are on [gotchas](https://tinyjs.app/docs/gotchas#txiki). <!-- https://tinyjs.app/docs/sqlite --> # sqlite > SQLite, built in — backend only SQLite ships in the runtime as `tjs:sqlite`, so an app gets a real database with no packages and nothing to install. It's for the [backend](https://tinyjs.app/docs/backend): the page has no `tjs` (importing a `tjs:*` module there throws `ReferenceError: tjs is not defined`), so query in the backend and hand results to the page through an `api` function. ```js import { Database } from 'tjs:sqlite'; let db; export async function init(app) { db = new Database((await app.paths()).data + '/notes.db'); db.exec('CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, text TEXT)'); } export const api = { notes: () => db.prepare('SELECT * FROM notes').all(), addNote: ({ text }) => db.prepare('INSERT INTO notes (text) VALUES (?) RETURNING id').all(text)[0].id, }; ``` ```js // page const id = await tiny.api.call('addNote', { text: 'hello' }); const notes = await tiny.api.call('notes'); // [{ id: 1, text: 'hello' }] ``` Expose named operations like these rather than a general `query(sql)`, so the page — or a site the app loads — can't run arbitrary SQL. ## where to keep it {#where} Put the file in `paths().data`, the app's own data folder: - macOS: `~/Library/Application Support/<id>` - Windows: `%APPDATA%\<id>` - Linux: `~/.local/share/<id>` `paths()` creates the folder before it answers. The folder is named after the app's [`id`](https://tinyjs.app/docs/config#id), so it survives updates and is separate for each app. Not a relative path: `'./notes.db'` resolves against the process's working directory. Under `tinyjs dev` that happens to be the project folder, but a built app launched from Finder, the Start menu or a desktop launcher gets another one — often `/`, which isn't writable. And not inside the app: a file in the app bundle is replaced on every update. For a folder of your own, create it first, and wait for it: `tjs.*` file calls are async, and `new Database()` on a folder that doesn't exist yet fails with "database not initialized". ```js await tjs.makeDir(dir, { recursive: true }); db = new Database(dir + '/notes.db'); ``` ## the API {#api} Calls are synchronous — no `await`. | | | |---|---| | `new Database(path)` | open (or create) a database file; `':memory:'` for a temporary one | | `db.exec(sql)` | run SQL with no results (`CREATE`, several statements at once) | | `db.prepare(sql)` | a statement, for running with values | | `stmt.all(...values)` | run it and return every row, as objects | | `stmt.run(...values)` | run it, returning nothing | | `stmt.finalize()` | free a statement you're done with | | `db.transaction(fn)` | wrap `fn` in a transaction: returns a function that commits when `fn` returns and rolls back if it throws | | `db.inTransaction` | `true` inside a transaction | | `db.close()` | close the database | Values bind by position (`?`) or by name: ```js db.prepare('SELECT * FROM notes WHERE id = ?').all(1); db.prepare('SELECT * FROM notes WHERE id = $id').all({ $id: 1 }); ``` Wrap many writes in a transaction — it's much faster, and all-or-nothing: ```js const insert = db.prepare('INSERT INTO notes (text) VALUES (?)'); const importNotes = db.transaction((texts) => { for (const t of texts) insert.run(t); return texts.length; }); importNotes(['one', 'two', 'three']); // 3 ``` ### coming from better-sqlite3 {#better-sqlite3} The shape is similar, with fewer methods. A statement has only `all()`, `run()` and `finalize()`: - no `get()` — use `.all(…)[0]`; - `run()` returns nothing — for the new row's id, add `RETURNING id` and use `.all(…)[0].id`, or `SELECT last_insert_rowid()`. <!-- https://tinyjs.app/docs/cookies --> # cookies > app.cookies — the webview's cookie jar, backend only Every window in your app shares one cookie jar: the webview's own, the one a page's requests and a [wrapped site](https://tinyjs.app/docs/site-wrappers)'s sign-in use. `app.cookies` lets the [backend](https://tinyjs.app/docs/backend) read and change it — HttpOnly cookies included — to keep someone signed in across reinstalls, sign them out, or hand a session to another part of your app. It's backend only, and there's no page-side version: a page that could read the jar could read every site's session. If a page needs a cookie's value, return it from an [`api` function](https://tinyjs.app/docs/backend#api) of your own. ## reading cookies {#get} ```js const cookies = await app.cookies.get({ url: 'https://app.example.com' }); const [sid] = await app.cookies.get({ url: 'https://app.example.com', name: 'sid' }); ``` `get(filter?)` resolves to an array of cookies: | field | | |---|---| | `name`, `value` | | | `domain` | a leading `.` means the domain and its subdomains; without one the cookie is host-only | | `path` | | | `expires` | milliseconds since the epoch, or `null` for a session cookie | | `secure`, `httpOnly` | | | `sameSite` | `'lax'`, `'strict'` or `'none'` — see [SameSite](#samesite) | Filter by `name`, `domain` or `path`. With `url`, you get exactly the cookies a request to that URL would carry (matching domain, path and `secure`). Without `url`, `get()` returns the whole jar — on Linux that needs WebKitGTK 2.42 or newer. ## setting a cookie {#set} ```js await app.cookies.set({ url: 'https://app.example.com', name: 'theme', value: 'dark', expires: Date.now() + 365 * 24 * 3600 * 1000, }); ``` `set(cookie)` replaces any cookie with the same name, domain and path, and resolves `true`. | field | | |---|---| | `name`, `value` | required | | `url` | where it belongs. `url` alone makes the cookie host-only for that host | | `domain` | `'.example.com'` covers subdomains. Needed when there's no `url` | | `path` | default `/` | | `expires` | a `Date` or milliseconds since the epoch. Leave it out for a session cookie | | `secure`, `httpOnly` | | | `sameSite` | default `'lax'` | ## removing cookies {#remove} ```js await app.cookies.remove({ url: 'https://app.example.com' }); // sign out: all of them await app.cookies.remove({ name: 'sid', domain: '.example.com' }); // one, e.g. straight from get() ``` `remove(target)` resolves to the number of cookies removed. Pass either a cookie's `name` and `domain` (and `path`, default `/`) — what `get()` returns — or a `url`, with an optional `name`, to remove everything `get({ url, name })` would match. ## keeping a session {#session} A wrapped site's session lives in its cookies. To keep someone signed in after the app's data is reset, or move a session between machines, save the cookie somewhere safe and put it back later — [secrets](https://tinyjs.app/docs/secrets) is the place for anything that grants access: ```js // backend const SITE = 'https://app.example.com'; export const api = { saveSession: async (_, app) => { const [sid] = await app.cookies.get({ url: SITE, name: 'sid' }); if (sid) await app.secrets.set('sid', JSON.stringify(sid)); }, restoreSession: async (_, app) => { const saved = await app.secrets.get('sid'); if (saved) await app.cookies.set({ url: SITE, ...JSON.parse(saved) }); }, signOut: async (_, app) => { await app.cookies.remove({ url: SITE }); await app.secrets.delete('sid'); }, }; ``` ## SameSite {#samesite} `set()` defaults `sameSite` to `'lax'` on every OS, because the engines disagree about a cookie without one. `get()` reports what the engine actually enforces, so a cookie a site set without SameSite reads as `'none'` on macOS, `'lax'` on Windows, and either on Linux depending on the WebKitGTK version. Passing a `get()` result straight back to `set()` keeps it behaving the same way. `'none'` generally needs `secure: true` to be sent cross-site. ## errors {#errors} The calls reject when a name or value contains a control character or `;`, when a `url` isn't `http://` or `https://`, or when `set()` has no `name`. [`tiny.fetch`](https://tinyjs.app/docs/fetch) is a separate client: it doesn't send the jar's cookies. Set a `Cookie` header yourself if you need one there. <!-- https://tinyjs.app/docs/api --> # calling the backend > tiny.api — request/response and events The page has no file system, processes or database; the [backend](https://tinyjs.app/docs/backend) has all of them. `tiny.api` is how the two talk: the page calls functions the backend exports in `api`, and the backend sends events back. This page also covers the small calls that sit beside it on `tiny`: logging, notifications, app info, quitting, and file URLs. ```js // backend: src/backend/main.js export const api = { greet: ({ name }) => `hello, ${name}`, }; // page const text = await tiny.api.call('greet', { name: 'Ada' }); // 'hello, Ada' ``` `tiny.api` itself is page-only: the backend calls its own functions directly, and talks back with `app.push` (see [events to the page](https://tinyjs.app/docs/backend#push)). For the rest, the backend twin has the same name on `app` — `tiny.notify()` is `app.notify()`, `tiny.app.info()` is `app.info()`, `tiny.quit()` is `app.quit()`. The [API map](https://tinyjs.app/docs/api-map) lists both sides. ## calling a function {#call} `tiny.api.call(method, params?)` runs `api[method]` in the backend and resolves with what it returns. ```js const notes = await tiny.api.call('listNotes', { folder: 'inbox' }); ``` - **`params`** is any JSON value; the backend function gets `{}` when you pass nothing. It goes through `JSON.stringify`, so a `Date` arrives as a string and `undefined` fields are dropped. - **The result** must be JSON too. A function that returns nothing (or `undefined`) resolves `null`. - **Calls run concurrently.** The backend starts each call as it arrives and doesn't wait for one to finish before the next, so two calls that touch the same state can interleave at their `await`s. - **Names**: tinyjs's own calls (`fetch`, `log`, `notify`, `store.get`, `win.close`, and every other name the `tiny` object sends) win over a backend function with the same name. Pick names that don't collide. See [api functions](https://tinyjs.app/docs/backend#api) for the backend side, including the caller's window id and origin. ## errors {#errors} A backend function that throws rejects the page's promise. The rejection is the error's **message as a plain string**, not an `Error`: ```js // backend export const api = { openNote: async ({ path }) => { if (!path.endsWith('.md')) throw new Error('not a note'); return new TextDecoder().decode(await tjs.readFile(path)); }, }; // page try { await tiny.api.call('openNote', { path }); } catch (msg) { showError(msg); // 'not a note' — msg.message is undefined } ``` The same holds for every `tiny` call that rejects. Other reasons a call rejects: | rejection | why | |---|---| | `unknown method: <name>` | no `api` function, and no built-in call, has that name | | `"<name>" is disabled by tinyjs.json "api"` | the [`api`](https://tinyjs.app/docs/config#api) gate in tinyjs.json doesn't allow that call for this page's origin | For errors the page should handle in code, return `{ ok: false, reason }` instead of throwing — the stack and any custom fields on a thrown error stay in the backend. ## events from the backend {#events} The backend sends events with `app.push(name, data)` (every window), `app.win.push(name, data)` (the main window) or `app.window(id).push(name, data)` (one window). The page listens with `tiny.api.on`: ```js // backend setInterval(() => app.push('tick', { now: Date.now() }), 1000); // page const stop = tiny.api.on('tick', ({ now }) => { clock.textContent = new Date(now).toLocaleTimeString(); }); // later stop(); ``` - `tiny.api.on(event, fn)` adds a handler and returns a function that removes it. It's additive, like `addEventListener`: every handler for the event runs, in the order they were added. - `tiny.api.off(event, fn)` removes a handler by reference. - The `tiny.*.on` helpers (`tiny.menu.on`, `tiny.tray.on`, `tiny.theme.on`, `tiny.app.onNotificationClick`, …) wrap your function before subscribing, so `off(event, fn)` can't find it. Keep the function they return — it's the only way to unsubscribe. - An event pushed before the page has subscribed is lost. A page that needs data at start should ask for it with `tiny.api.call` rather than wait for a push from [`init`](https://tinyjs.app/docs/backend#init). - A handler that throws is reported in the console like any uncaught error, and the other handlers still run. tinyjs sends its own events through the same channel, so you can listen to them by name too, and should avoid these names for your own: `menu`, `contextmenu`, `tray`, `trayclick`, `hotkey`, `theme`, `sleep`, `wake`, `locale`, `open-url`, `open-files`, `drop`, `notification-click`, `notification-action`, `media-key`, `window-state`, `window-closed`, `screens-changed`, `clipboard-change`, `audio-tap`, `update-available`, `navigate`, `download`, `popup`. The [backend hooks](https://tinyjs.app/docs/backend#hooks) table says what each one is. ## logging to the terminal {#log} `tiny.log(msg)` prints in the terminal running the backend, tagged `[web]`, so the page's output and the backend's interleave in one place instead of one of them hiding in the webview inspector. ```js tiny.log('loaded'); tiny.log({ notes: notes.length, ms: performance.now() }); // objects print as objects await tiny.log('saved'); // resolves once the backend has printed it ``` `msg` can be anything JSON can carry. Desktop notifications, `tiny.notify()`, have their own page: [notifications](https://tinyjs.app/docs/notifications). ## app info {#info} `tiny.app.info()` resolves to what the app is and what it runs on: ```js const { version, tinyjs, runtime } = await tiny.app.info(); // { version: '1.2.0', tinyjs: '0.62.1', runtime: 'txiki.js 26.6.0' } ``` | field | | |---|---| | `version` | your app's [`version`](https://tinyjs.app/docs/config#version) from tinyjs.json, or `'0.0.0'` without one | | `tinyjs` | the tinyjs version that built the app; `'dev'` when tinyjs runs from a source checkout | | `runtime` | the backend runtime, e.g. `'txiki.js 26.6.0'` | In the backend it's `await app.info()`. Reading `app.info.version` without calling it still works but is deprecated. ## quitting {#quit} `tiny.quit()` quits the app: every window closes and the backend exits. Window positions saved by [`rememberWindows`](https://tinyjs.app/docs/config#rememberWindows) are written first. In the backend it's `app.quit()`. Closing the main window from its page with `tiny.win.close()` also quits. ## local files in the page {#fileURL} `tiny.fileURL(path)` turns a path on disk into a `file://` URL for an `<img>`, `<audio>` or `<video>` `src`, escaped correctly on every OS. It runs in the page and returns a string at once. ```js const path = await tiny.api.call('coverArt', { track }); // '/Users/me/Music/cover.jpg' img.src = tiny.fileURL(path); // Windows: tiny.fileURL('C:\\Music\\cover.jpg') → 'file:///C:/Music/cover.jpg' // a UNC share: '\\\\nas\\music\\a.mp3' → 'file://nas/music/a.mp3' ``` Building the URL by hand with `'file://' + path` breaks on Windows: the drive letter becomes the URL's host. On macOS the page may only load files inside its own folder. For anything outside it — and a path from the backend usually is — set [`readAccess`](https://tinyjs.app/docs/config#readAccess) in tinyjs.json. Without it the element fires `error` and nothing says why. Windows and Linux don't need it. <!-- https://tinyjs.app/docs/fetch --> # fetch > tiny.fetch and tiny.proxyURL — networking without CORS A page's own `fetch()` is a browser fetch: another origin's API answers only if it sends CORS headers, and the page's CSP and mixed-content rules apply. `tiny.fetch` has the same shape as `window.fetch`, but the request runs in the [backend](https://tinyjs.app/docs/backend), so none of those limits apply and the page can reach any http(s) server. `tiny.proxyURL` does the same for `<audio>` and `<video>`, so a stream from another origin can feed Web Audio. Both are page-only. The backend doesn't need them: its own `fetch()` already has no CORS, and it's the same fetch `tiny.fetch` runs on. ## tiny.fetch {#request} `tiny.fetch(url, init?)` resolves to a real `Response`, so `ok`, `status`, `headers`, `json()`, `text()`, `arrayBuffer()` and `body` all work as usual. ```js const r = await tiny.fetch('https://api.example.com/search', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify({ q: 'hello' }), }); if (!r.ok) throw new Error(`search failed: ${r.status}`); const results = await r.json(); ``` | `init` field | | |---|---| | `method` | as in `fetch` | | `headers` | a plain object, an array of pairs, or a `Headers` | | `body` | a string, `ArrayBuffer`, typed array, `Blob` or `URLSearchParams` | | `redirect` | `'follow'` (default), `'manual'` or `'error'` | | `stream` | `true` for a live body — see [streaming](#stream) | Other `fetch` options (`signal`, `credentials`, `mode`, `cache`, …) are ignored. How it differs from `window.fetch`: - **Pass an absolute URL.** The backend doesn't know the page's address, so `'/api/x'` isn't resolved against it. - **It's a separate client.** The webview's cookies aren't sent and no `Origin` header is added. You may set headers a browser forbids, such as `Cookie`. - **Errors reject with a string**, like every `tiny` call: a network failure or an invalid header gives `catch (msg)` the message, not a `TypeError`. An HTTP error status (404, 500) is not an error: it resolves with `r.ok === false`. - **The whole body is read first.** Without `stream`, the backend downloads the full response before the promise resolves. For a large download or a response that never ends, use `stream: true`. - **No abort.** `signal` isn't supported. A streaming body can be stopped with `reader.cancel()`. - **`r.url` and `r.redirected`** report the address after redirects, as in a browser. ### redirects {#redirects} Redirects are followed hop by hop, up to 20, the way a browser does: a 303, or a 301/302 after a `POST`, becomes a `GET` without the body; 307 and 308 keep both. `Authorization`, `Cookie` and `Proxy-Authorization` are dropped when a redirect leads to another origin, and stay dropped for the rest of the chain. A redirect to anything but http or https rejects. With `redirect: 'manual'`, the 3xx response itself resolves, and unlike in a browser you can read its `status` and `Location` header. `redirect: 'error'` rejects on the first redirect. ### hosts the runtime can't reach alone {#curl} The backend's runtime has two gaps: a URL whose path is only `/` (`https://feeds.example.com/`) goes out as `GET //`, which strict CDNs answer with 404, and it can't connect to hosts that only speak TLS 1.2 (art19 and anchor.fm feeds, for example). tinyjs sends those requests through the system `curl` instead: root-path URLs always, and any request whose connection failed. Because redirects are followed one hop at a time, a tracking URL that bounces onto either kind of host still arrives. A real HTTP error (404, 500) is returned as it is, never retried through curl. `curl` comes with macOS, Linux and Windows 10 1803 or later. Without it, those two cases fail as they would without tinyjs. The same applies to `fetch()` in the backend. ## streaming a response {#stream} With `{ stream: true }` the promise resolves as soon as the headers arrive, and `body` is a live stream. The backend holds the connection and sends a chunk each time the page reads one, so a source that never ends, like internet radio, doesn't pile up in memory. ```js const r = await tiny.fetch('https://ice1.somafm.com/groovesalad-128-mp3', { stream: true }); const reader = r.body.getReader(); for (;;) { const { value, done } = await reader.read(); // value: Uint8Array if (done) break; handleChunk(value); } ``` `reader.cancel()` closes the upstream connection. So does closing the window or reloading the page that started it. A connection error while streaming makes `reader.read()` reject. ## under an api gate {#gate} `tiny.fetch` is the call named `fetch` to the [`api`](https://tinyjs.app/docs/config#api) gate in tinyjs.json; the `"wrapper"` preset leaves it off. Streaming also uses `fetch.pull` and `fetch.cancel`, so a gate that should allow streaming needs both entries: ```json { "api": { "preset": "wrapper", "enable": ["fetch", "fetch.*"] } } ``` A page that can call `tiny.fetch` has the app's whole network reach, including servers on the local network. For your own pages that's no new risk: they can already call your backend, which can do anything. Think about it before you enable `fetch` for a site you [wrap](https://tinyjs.app/docs/site-wrappers). ## a remote stream into Web Audio {#proxyURL} A `MediaElementSource` on an `<audio>` element playing from another origin outputs silence, by spec, so a radio stream can't drive an EQ or an analyser. `tiny.proxyURL(url)` returns a `tiny-media://` URL that streams the remote resource through the native layer with permissive CORS headers. The element isn't tainted, and the Web Audio graph gets real samples. ```js const audio = document.querySelector('audio'); audio.crossOrigin = 'anonymous'; audio.src = tiny.proxyURL('https://ice1.somafm.com/groovesalad-128-mp3'); const ctx = new AudioContext(); const analyser = ctx.createAnalyser(); ctx.createMediaElementSource(audio).connect(analyser).connect(ctx.destination); audio.play(); ``` `tiny.proxyURL` only builds the URL; it returns a string at once and never fails. The native layer makes the request when the element loads it, follows redirects, and accepts http and https URLs only. - **Live streams play.** An icecast or shoutcast stream answers `200` with no `Content-Length`. The proxy gives an audio or video response like that a very large fake length, so the media engine plays it progressively. `audio.duration` and `currentTime` are meaningless for such a stream, so don't wire a seek bar to one. - **Seeking**: on macOS the element's range requests pass through, so a finite file stays seekable. Linux always fetches from the start. - **Your pages only.** Because the proxy reads any URL with CORS out of the way, only the app's own pages (and the dev server) may use it. A wrapped site, an iframe from elsewhere, or a page the app navigated to is refused unless its `api` `origins` key allows the name `media.proxy` (see [`api`](https://tinyjs.app/docs/config#api)). A proxied response is never served as a page. **macOS and Linux only.** Windows has no `tiny-media://` handler, so a proxied URL doesn't load there. Check before you use it: ```js const caps = await tiny.system.capabilities(); audio.src = caps.proxyURL === false ? streamUrl : tiny.proxyURL(streamUrl); ``` To read the rendered audio without routing it through Web Audio at all, see [audio tap](https://tinyjs.app/docs/audiotap). <!-- https://tinyjs.app/docs/window --> # window > tiny.win — window control `tiny.win` controls the window the page lives in: its size and place on screen, whether it's shown, full screen or on top, how it looks (title bar, translucency), and what happens inside it (drag regions, file drops, find, print). Every window runs its own copy, so a call from a settings window moves the settings window. Opening more windows is on [multiple windows](https://tinyjs.app/docs/multiple-windows); overlays, HUDs and window levels are on [window fx](https://tinyjs.app/docs/window-fx). The [backend](https://tinyjs.app/docs/backend) has the same calls under the same names: `tiny.win.setSize()` in a page is `app.win.setSize()` for the main window, and `app.window(id).setSize()` for any other. The page versions return promises; most backend window calls return nothing, and `getState()` and `printToPDF()` return promises on both sides. The [API map](https://tinyjs.app/docs/api-map#win) lists both sides. ```js await tiny.win.setTitle('Untitled — Notes'); await tiny.win.setSize(900, 600); await tiny.win.center(); const { width, height, focused } = await tiny.win.getState(); ``` ## size and position {#position} ```js tiny.win.setSize(900, 600); // the page's box, top-left stays put tiny.win.setPosition(40, 40); // the window's top-left, in screen points tiny.win.center(); tiny.win.setMinSize(640, 480); // floor under the user's resizes ``` - **`setSize(width, height, opts?)`** sizes the *page* — the area your HTML gets, without the title bar or (on Windows and Linux) the menu bar. It's the same box as [`size`](https://tinyjs.app/docs/config#size) in tinyjs.json, `win.open`'s `size: '900x600'`, and `getState().width`/`height`, so reading the size and setting it back changes nothing. The top-left corner stays where it is. `{ units: 'px' }` takes pixels of the screen the window is on instead of points. - **`setPosition(x, y, opts?)`** moves the window's top-left corner (the frame, title bar included). Coordinates are points with the origin at the top-left of the primary screen and y growing downward, the same space as `getState().x`/`y`, [`screens()`](#screens) and [`mousePosition()`](#cursor). With `{ screen }`, x and y are relative to that screen instead — see [placing on a screen](#on-a-screen). - **`center()`** centres the window on its screen. - **`setMinSize(width, height)`** stops the user resizing the page smaller than this; `win.open` takes the same as `minSize: '640x480'`. On macOS a window that's already smaller grows to fit. Your own `setSize` can still go below it on macOS and Windows; on Linux GTK holds both. **Wayland** doesn't let an app place its own windows, so `setPosition` and `center` do nothing there. `getState().canPosition` is `false` in that case (it's only present on Linux), and `tiny.system.capabilities().windowPosition` says the same. An app that needs to place windows can set [`windowPlacement`](https://tinyjs.app/docs/config#windowPlacement) to run through XWayland instead. `setFullscreen(true, { screen })` is the one placement call Wayland honours. ### keeping windows on screen {#ensureOnScreen} A saved position from a display that's since been unplugged points into empty space. tinyjs catches that: when the set of screens differs from the app's last run, each window's first show and first `setPosition` is checked, and a window with less than a sliver visible is moved onto the nearest screen. A display removed while the app runs is handled the same way for the windows on it. Ordinary moves are never touched, so a window you park half off-screen stays there. ```js tiny.win.ensureOnScreen(); // pull this window back now if it can't be seen or grabbed ``` `ensureOnScreen()` runs the same check on demand. An app that parks windows off-screen on purpose sets [`offscreenRescue`](https://tinyjs.app/docs/config#offscreenRescue) to `false`, which turns off the automatic parts; `ensureOnScreen()` still works. To have windows come back where the user left them — position, size, screen, maximized or full screen — set [`rememberWindows`](https://tinyjs.app/docs/config#rememberWindows) in tinyjs.json. No code is needed. ## screens {#screens} ```js const screens = await tiny.app.screens(); const external = screens.find((s) => !s.internal) ?? screens[0]; ``` `tiny.app.screens()` lists every display, in the same coordinate space as `setPosition`. Each entry: | field | | |---|---| | `id` | a number for this session — what `captureScreen()` and screen recording take. It changes across reboots and replugs. | | `uid` | the most stable identity the OS has: the display UUID on macOS, the monitor device path on Windows, the connector name (like `DP-1`) on Linux. Save this, not `id`. Identical monitors can still share one. | | `name` | like `"Built-in Retina Display"`, or `null` | | `x`, `y`, `width`, `height` | the display's frame, in points | | `visible` | `{ x, y, width, height }`: the frame minus the menu bar, Dock or taskbar | | `scale` | device pixels per point | | `primary` | the menu-bar screen, whose top-left is the origin | | `internal` | the laptop's built-in panel; `false` when the OS can't tell | | `pixels` | `{ width, height, visible }`: the same screen in its own pixels; `visible` is relative to the screen | Global coordinates are points on every OS, and they form one consistent space on Windows with mixed scaling too (a 100% monitor beside a 150% laptop). On Linux, `scale` is GTK's integer buffer scale: under fractional scaling (125%) it reads 1 or 2 depending on the compositor, not the user's setting, and `pixels` follow it. To find a screen: ```js await tiny.app.screenAt(x, y); // the screen containing a point, or null await tiny.app.screenFor({ x, y, width, height }); // the one holding most of a rect, or null await tiny.app.foregroundScreen(); // the screen the user is working on await tiny.app.matchScreen(saved); // a saved screen → the connected one, or null ``` - **`foregroundScreen()`** is the screen of the focused window of *any* app — where to open a palette — or the cursor's screen when that app shows no window. It needs no permission. It's `null` on Wayland. - **`matchScreen(saved)`** takes a screen you stored earlier (a `screens()` entry) and returns the connected screen it most likely is now, or `null`. It scores `uid`, then name, resolution and scale, whether it's the built-in panel, and its place in the left-to-right order, since ids don't survive a replug or reboot. `tiny.app.onScreensChanged(fn)` calls `fn(screens)` with the new list when a display is plugged in, unplugged, rearranged or rescaled, and returns an unsubscribe function. The backend gets `onSystem('screens', null, app)` — see [hooks](https://tinyjs.app/docs/backend#hooks). ### placing on a screen {#on-a-screen} ```js const [, second] = await tiny.app.screens(); tiny.win.setPosition(40, 40, { screen: second }); // 40 points from its top-left tiny.win.setPosition(81, 81, { screen: second, units: 'px' }); // in its pixels tiny.win.setFullscreen(true, { screen: second }); // works on Wayland too tiny.win.open('hud', { page: 'hud.html', screen: second, x: 40, y: 40 }); ``` `screen` takes a `screens()` entry or its `id`. With `units: 'px'` the coordinates are that screen's pixels; there's no global pixel space (macOS doesn't have one), so `units: 'px'` without `screen` rejects. `getState()` reports the window back the same ways: `screenId` for the screen holding most of it, `local` for its top-left relative to that screen, and `pixels` for both in that screen's pixels. On Windows with mixed scaling, WebView2's own `screen.availLeft` and `window.screenX` don't agree with these coordinates, and `screenX` goes stale after a move. If you drag a window by hand from `mousemove` events, use `clientX` deltas against `getState()`, not `e.screenX`. ## the cursor {#cursor} ```js const { x, y, window, screen } = await tiny.app.mousePosition(); tiny.win.setPosition(x - 200, y + 12); // open a palette at the mouse ``` `tiny.app.mousePosition()` resolves to: - `x`, `y`: the cursor in global points, the same space as `setPosition`. - `window`: `{ x, y, inside }` relative to this window's page, in `clientX`/`clientY` units, valid while the cursor is outside it too. The backend gets it relative to the main window. - `screen`: `{ id, x, y, width, height, scale }` for the display the cursor is on. On Wayland the compositor hides the cursor once it leaves your window, so the global position only tracks while it's over the app (`window.inside` goes `false` when it leaves). `tiny.app.mouseTracking.start()` gets real global coordinates there through the ScreenCast portal: the system shows a consent dialog the first time (the grant is remembered) and its screen-sharing indicator while tracking is on. ```js try { await tiny.app.mouseTracking.start(); // { ok: true } } catch (e) { console.log(e.code); // 'unsupported' | 'denied' | 'failed' } tiny.app.mouseTracking.stop(); ``` On macOS, Windows and X11 the position is already global and `start()` resolves at once. On Linux, `tiny.system.capabilities().mousePosition` is `false` when `start()` is needed. ## showing and hiding {#visibility} ```js tiny.win.hide(); // hides the app; focus goes back to the previous one tiny.win.hide({ app: false }); // just this window tiny.win.show(); // show and focus tiny.win.show({ activate: false }); // show without taking focus ``` - **`hide(opts?)`** on the main window on macOS hides the whole app, and macOS hands focus back to the app that had it before. A palette can `hide()` and then paste into that app with no bookkeeping. `{ app: false }` puts away only this window and the app stays active. On Windows and Linux, and for other windows on macOS, `hide()` hides just the window either way. - **`show(opts?)`** shows the window and brings it to the front with focus. `{ activate: false }` shows it without taking focus from the app the user is in — for HUDs and notifications. - **`setHideOnClose(true)`** makes the main window's close button hide the window instead of quitting. On macOS the app keeps running and the Dock icon brings it back. Windows and Linux have nowhere to bring a hidden window back from, so there it holds only while something can: a [tray](https://tinyjs.app/docs/tray) icon, [`activation`](https://tinyjs.app/docs/config#activation) `"accessory"`, or another window still open. Otherwise closing the last window quits, as other apps on those systems do. ```js tiny.win.minimize(); tiny.win.restore(); // un-minimize tiny.win.zoom(); // maximize or un-maximize (the green button / title-bar double-click) tiny.win.fullscreen(); // toggle tiny.win.setFullscreen(true); // or set it tiny.win.setAlwaysOnTop(true); // float above other apps' windows tiny.win.setResizable(false); // the user can't resize it; setSize still can tiny.win.setTitle('Report.pdf'); ``` - **Full screen** on macOS is the native kind, in its own Space. On Windows, only the main window goes full screen; other windows are maximized instead. `setFullscreen(true, { screen })` moves the window to that screen first. - **`minimize()`** on Windows, for a window with no taskbar button (an `"accessory"` app's main window), hides it instead, since there's nowhere to minimize to. - **`setAlwaysOnTop(true)`** is the same as `setLevel('floating')`; for the other levels see [window fx](https://tinyjs.app/docs/window-fx#level). ## reading the window {#state} ```js const s = await tiny.win.getState(); if (s && !s.visible) tiny.win.show(); ``` `getState()` resolves to `null` if the window has already closed, otherwise: | field | | |---|---| | `x`, `y` | the window's top-left, in global points — what `setPosition` takes | | `width`, `height` | the page's box — what `setSize`, `setMinSize` and `size` take | | `outer` | `{ width, height }`: the footprint on screen, title bar included. The same as `width`/`height` for a frameless window. | | `fullscreen`, `minimized`, `visible`, `focused` | booleans | | `alwaysOnTop`, `resizable`, `clickThrough`, `allSpaces` | what you set | | `level` | `'normal'`, `'floating'`, `'overlay'` or `'desktop'` | | `chrome` | the current [chrome](#chrome): `frame`, `windowControls` (what's shown; `null` on Linux, which won't say), `transparent`, `vibrancy`, `squareCorners`, `acceptsFirstMouse`, `windowControlsPos` | | `screen` | `{ width, height, scale }` of the screen the window is on | | `screenId` | that screen's `id` in `screens()` | | `local` | `{ x, y }`: the top-left relative to that screen | | `pixels` | `{ x, y, width, height }`: `local` and the page's box in that screen's pixels | | `canPosition` | Linux only: `false` on Wayland, where `setPosition` does nothing | `window.outerWidth` and `window.outerHeight` are 0 in macOS's webview, so use `outer` to keep a window inside a screen. ### state changes {#onState} Rather than poll, listen. `tiny.win.onState(fn)` fires when a window goes full screen, is maximized or minimized, gains or loses focus, or moves to another screen — whatever caused it: the green button, a menu item, F11, or your own call. ```js const off = tiny.win.onState(({ win, fullscreen, maximized, minimized, focused, screenId }) => { if (win !== tiny.win.id) return; // every window hears about every window document.body.classList.toggle('fullscreen', fullscreen); }); off(); // unsubscribe ``` The event goes to every window, so check `win` (`'main'` or a `win.open` id). The backend gets the same object in `onWindowState(info, app)` — see [hooks](https://tinyjs.app/docs/backend#hooks). Wayland never reports `minimized`. ## chrome {#chrome} `setChrome(opts)` changes how the window looks: no title bar, see-through, a blurred background, custom title-bar buttons. Set only the fields you want to change. To start that way with no flash of the default title bar, put the same fields in [`chrome`](https://tinyjs.app/docs/config#chrome) in tinyjs.json (main window) or in `win.open`'s `chrome` (other windows). ```js tiny.win.setChrome({ frame: false, windowControls: false, vibrancy: 'hud' }); ``` | field | | macOS | Windows | Linux | |---|---|:-:|:-:|:-:| | `frame` | `false`: no title bar; the page reaches the top edge. Resizing and focus keep working. Add a [drag region](#drag). | ✓ | ✓ | ✓ | | `windowControls` | the close, minimize and maximize buttons: `true`, `false`, or a list such as `['close']` | ✓ | close always shows with the others | X11, best effort | | `transparent` | see-through where the page's own background is transparent | ✓ | at open only | ✓ | | `vibrancy` | a blurred material behind the page: `'sidebar'`, `'hud'`, `'menu'`, `'popover'`, `'window'`, `'content'`, `'header'`, `'sheet'`, `'tooltip'`, `'fullscreen'`, `'underwindow'`, `'underpage'`, `'titlebar'`, `'selection'`; `null` or `false` for none | ✓ | Windows 11: Acrylic for `hud`, `popover`, `menu`; Mica otherwise | — | | `squareCorners` | `true`: a borderless window with square corners — no title bar or buttons, so add a [drag region](#drag). Resize edges and shadow stay. | ✓ | ✓ | ✓ | | `acceptsFirstMouse` | `true`: the click that focuses an inactive window also reaches the page, instead of macOS swallowing it. For palettes, toolbars, and drag regions on inactive windows. | ✓ | — | already the default | | `windowControlsPos` | `{ x, y }`: move the traffic lights, in points from the window's top-left — for a frameless window with a taller custom title bar. `null` puts them back. Set it once; it survives resizes and full screen. | ✓ | — | — | | `menu` | `false`: this window shows no menu bar. Its shortcuts keep working and other windows keep theirs. | — (one bar per app) | ✓ | ✓ | | `panel` | open-time only (tinyjs.json or `win.open`, not `setChrome`): a non-activating panel that takes the keyboard without activating the app, for palettes over full-screen apps — see [window fx](https://tinyjs.app/docs/window-fx#panel) | ✓ | — | — | Fields a platform doesn't support are ignored. `tiny.system.capabilities()` reports `vibrancy`, `transparency` and `windowControls` per platform, and `getState().chrome` reads back what's set. Windows never draws a menu bar on a frameless or transparent window. ## drag regions and resizing {#drag} A window without a title bar needs something to drag. Mark it in HTML: ```html <header data-tiny-drag> <h1>Notes</h1> <button>New</button> <!-- buttons, links and inputs still click --> </header> ``` Pressing and dragging an element with `data-tiny-drag` moves the window, and double-clicking it zooms (maximizes), like a title bar. Buttons, links, inputs, selects, text areas, editable content and anything inside `data-tiny-nodrag` stay interactive. To start a move or resize from your own code, call these from a `mousedown` handler while the button is still held — they take over the gesture in progress, and do nothing when called from a `click`: ```js handle.addEventListener('mousedown', () => tiny.win.startDrag()); grip.addEventListener('mousedown', () => tiny.win.startResize('se')); ``` - **`startDrag()`** moves the window with the mouse. - **`startResize(edge?)`** resizes from an edge: `'n'`, `'ne'`, `'e'`, `'se'` (the default), `'s'`, `'sw'`, `'w'` or `'nw'`. On macOS it does nothing after `setResizable(false)`. - **`zoom()`** maximizes or restores, as a double-click on a title bar does. Frameless windows keep the system's resize edges on macOS and Windows. On Linux, where an undecorated window has none, tinyjs adds invisible 5px grips on all eight edges; put `data-tiny-noresize` on `<html>` or `<body>` to leave them out, or make the window non-resizable. ## files in and out {#files} Files dragged onto a window arrive as real filesystem paths: ```js const off = tiny.win.onDrop((paths) => { for (const p of paths) openDocument(p); }); ``` The drop isn't tied to a window: every window's `onDrop` hears it, whichever window the files were dropped on. To drag files *out* of the app — into Finder, Explorer, a chat app — call `startDrag({ files })` from a `mousedown` handler while the button is held: ```js row.addEventListener('mousedown', () => tiny.win.startDrag({ files: ['/Users/me/report.pdf'], image: '/tmp/drag.png' })); ``` `files` are real paths; `image` is an optional PNG to show under the cursor (file icons otherwise). `tiny.win.dragOut(opts)` is the same call. It works on macOS and Windows; the Linux launcher doesn't support dragging files out yet. This is page-only: the backend's `app.win.startDrag()` only moves the window. ## page zoom {#zoom} ```js tiny.win.setZoom(2); tiny.win.setSize(400 * 2, 300 * 2); // a crisp "double size" mode ``` `setZoom(factor)` is the webview's own zoom, from 0.25 to 5. The page keeps laying out in CSS pixels and has fewer of them, rendered sharp. Pair it with `setSize` so the window grows with it. On macOS and Linux a factor outside 0.25–5 is ignored. This isn't `zoom()`, which maximizes. ## find in page {#find} ```js const r = await tiny.win.find('needle', { forward: true, matchCase: false }); // { found: true, matches: 4, activeMatch: 1 } await tiny.win.stopFind(); ``` `find(term, opts?)` selects the next match and scrolls it into view; call it again to step through, with `forward: false` to go back. It's the search underneath a ⌘F bar — the bar itself is yours to draw. `matches` and `activeMatch` count matches; on macOS and Windows they come from a text walk in the page and are approximate on unusual pages (hidden text counts, shadow DOM doesn't), while Linux's come from the engine. `stopFind()` clears the selection. It works on all three platforms; `tiny.system.capabilities().findInPage` says so. [Site wrappers](https://tinyjs.app/docs/site-wrappers) use it for a wrapped site's ⌘F. ## print and PDF {#print} ```js tiny.win.print(); // the system print dialog const { path } = await tiny.win.printToPDF('/tmp/invoice.pdf'); ``` - **`print()`** opens the print dialog for this window's page. - **`printToPDF(path)`** renders this window's page to a vector PDF and resolves to `{ path }`, the file written; it rejects with the reason if that fails. From your own pages any path works. From a wrapped site, only the Downloads folder (a bare file name lands there), the app's data folder and the temp folder are written directly; any other path opens a save dialog with the name filled in, and cancelling rejects. On macOS the PDF is one tall page today; Windows and Linux split it into pages. ## share {#share} ```js button.addEventListener('click', (e) => tiny.win.share({ url: 'https://example.com', text: 'Look', x: e.clientX, y: e.clientY })); ``` `share(opts)` opens the macOS share sheet with `text`, a `url` and/or `paths` (real files), anchored at `x`, `y` in the page — pass the click's `clientX`/`clientY`. macOS only: Windows and Linux do nothing, and `tiny.system.capabilities().share` is `false` there. ## this window's menu bar {#menu} `tiny.win.menu.set()`, `update()`, `get()` and `reset()` give one window a menu bar of its own instead of the app menu. See [menus](https://tinyjs.app/docs/menus). To show no bar at all, use `setChrome({ menu: false })`. <!-- https://tinyjs.app/docs/multiple-windows --> # multiple windows > tiny.win.open — settings panels, palettes, document windows An app starts with one window, `'main'`. `tiny.win.open()` opens more: each is a separate native window with its own page, named by an id you choose. Every window gets the full `tiny` API, and [`tiny.win`](https://tinyjs.app/docs/window) in a window's page controls that window. The backend opens the same windows with `app.win.open()` and reaches each one through `app.window(id)`. ```js await tiny.win.open('settings', { page: 'settings.html', title: 'Settings', size: '420x300' }); ``` ## opening a window {#open} `tiny.win.open(id, opts?)` opens a window under `id`. If a window with that id is already open, it's brought to the front and focused instead, and `opts` are ignored — so a Settings menu item can call `open('settings', …)` every time. | option | | default | |---|---|---| | `page` | an HTML file in your frontend folder, like `'settings.html'`, or an `http(s)` URL | `'index.html'` | | `title` | the window title | the id | | `size` | `'WxH'`: the page's box, without the title bar or menu bar — the same units as [`setSize`](https://tinyjs.app/docs/window#position) | `'600x400'` | | `minSize` | `'WxH'`: a floor under the user's resizes | none | | `x`, `y` | the top-left, in global points, set before the window first shows | | | `screen` | a [`screens()`](https://tinyjs.app/docs/window#screens) entry or id; `x`, `y` are then relative to that screen | | | `units` | `'pt'` or `'px'` for `x`, `y` with `screen` | `'pt'` | | `chrome` | the same fields as [`setChrome`](https://tinyjs.app/docs/window#chrome), applied before the first paint | | | `parent` | `true` (the main window) or a window id — see [child windows](#parent) | | `chrome` and the position apply before the window is drawn, so a frameless panel never flashes a title bar and a positioned window doesn't jump from the centre: ```js tiny.win.open('hud', { page: 'hud.html', size: '320x120', x: 40, y: 40, chrome: { frame: false, windowControls: false, transparent: true, vibrancy: 'hud' }, }); ``` A page can only open files inside the app's frontend folder, or `http(s)` URLs; an absolute path or a `../` that leads out of the frontend rejects. The backend's `app.win.open()` also takes absolute paths. New windows show the app menu on Windows and Linux; give one its own with [`tiny.win.menu.set()`](https://tinyjs.app/docs/menus) or none with `chrome: { menu: false }`. With [`rememberWindows`](https://tinyjs.app/docs/config#rememberWindows), a window opened without `x` and `y` goes back to where it was last time. ## the window ids {#ids} ```js tiny.win.id; // the window this page is in: 'main' or its open() id await tiny.win.windows(); // ['main', 'settings', 'hud'] ``` `windows()` lists the open windows; `'main'` is in it when there is a main window. `win.open('main')` shows and focuses the main window. ## closing {#close} ```js tiny.win.close(); // this window tiny.win.close('settings'); // another one ``` `close(id?)` closes the window with that id, or the calling page's own window with no id. Closing `'main'` quits the app. The close button on a secondary window closes just that window. When a window closes, every remaining page gets a `'window-closed'` event and the backend's `onWindowClosed(id, app)` hook runs: ```js tiny.api.on('window-closed', ({ id }) => { if (id === 'settings') reloadPreferences(); }); ``` ## child windows {#parent} ```js tiny.win.open('about', { page: 'about.html', size: '360x420', parent: true }); ``` `parent` keeps the window above another window of yours — `true` for the main window, or a window id — without floating over other apps the way [`setLevel('floating')`](https://tinyjs.app/docs/window-fx#level) does. The child minimizes and hides with its parent and closes when the parent closes. On Windows and Linux it gets no taskbar button of its own; on macOS it also moves when the parent moves. It can only be set when opening the window. ## without a main window {#no-main} With [`"mainWindow": false`](https://tinyjs.app/docs/config#mainWindow) the app starts with no window at all — a [tray](https://tinyjs.app/docs/tray) app or a background agent — and the backend opens windows by name when it needs them. There's no `'main'`: `win.open('main')` rejects, so use another id. Closing the last window leaves the backend and tray running. ```js // backend export function onTray(id, app) { if (id === 'prefs') app.win.open('prefs', { page: 'prefs.html', size: '480x360' }); } ``` ## events between windows {#events} Each window's page is separate, with its own JavaScript and DOM, so windows talk through the backend or through events: - `app.push(name, data)` in the backend reaches every window; `app.window(id).push(name, data)` reaches one, and `app.win.push()` only the main window. Pages listen with `tiny.api.on(name, fn)`. See [events to the page](https://tinyjs.app/docs/backend#push). - Your `api` functions get the caller's window id as `meta.window`, so the backend knows which window asked: ```js // backend export const api = { pick: async ({ color }, app, { window }) => { app.window('main').push('color-picked', { color, from: window }); }, }; ``` - [`tiny.win.onState`](https://tinyjs.app/docs/window#onState), file drops ([`onDrop`](https://tinyjs.app/docs/window#files)) and `'window-closed'` reach every window, so check the window id in the event where it matters. ## from the backend {#backend} ```js app.win.open('settings', { page: 'settings.html' }); const settings = app.window('settings'); settings.setTitle('Settings — Sync'); settings.setPosition(100, 100); await settings.getState(); settings.close(); ``` `app.window(id)` returns a handle with the same window calls as `tiny.win` — `setSize`, `setChrome`, `show`, `getState()` and the rest — plus `push(name, data)` and `eval(js)`. Calls on a handle whose window has closed do nothing. Compared with `app.win` (the main window), a handle has no `open`, `windows` or `setHideOnClose`, and its `close()` takes no id. A few calls are page-only: `onState`, `onDrop`, `find`, `stopFind`, and dragging files out. ## platform differences {#platforms} - **Full screen** on Windows applies to the main window only; other windows are maximized instead. - **Click-through** (`setClickThrough`) on Windows applies to the main window only. - **`setHideOnClose`** acts on the main window's close button, whichever page calls it. A secondary window's close button always closes it. - On macOS, `hide()` from the main window hides the whole app; from another window it hides just that window. See [showing and hiding](https://tinyjs.app/docs/window#visibility). <!-- https://tinyjs.app/docs/window-fx --> # window fx > tiny.win — overlays, HUDs, desktop pets Most windows sit in the normal stack and take clicks. These calls change that, for windows that aren't documents: a drawing overlay that lets clicks through, a HUD that floats over full-screen apps, a pet that lives on the wallpaper, a palette that follows the user to every Space. Like the rest of [`tiny.win`](https://tinyjs.app/docs/window), each acts on the calling page's window, and the backend has them as `app.win.*` and `app.window(id).*`. ```js // a see-through overlay that never gets in the way await tiny.win.setChrome({ frame: false, transparent: true }); await tiny.win.setLevel('overlay'); await tiny.win.setClickThrough(true); await tiny.win.show({ activate: false }); ``` ## click-through {#clickThrough} `setClickThrough(true)` lets mouse events pass through the window to whatever is behind it. Pair it with a transparent window for annotations or a heads-up display. The window can't be clicked any more — including on a button meant to turn click-through off. Keep a way back that isn't in the window: a [hotkey](https://tinyjs.app/docs/hotkeys), a [tray](https://tinyjs.app/docs/tray) item or a menu item. On Windows only the main window can be click-through. `getState().clickThrough` reads it back. ## window levels {#level} ```js tiny.win.setLevel('floating'); ``` `setLevel(level)` puts the window in a band of the stack: | level | | |---|---| | `'normal'` | an ordinary window | | `'floating'` | above other apps' ordinary windows — the same as `setAlwaysOnTop(true)` | | `'overlay'` | on macOS, above nearly everything, including full-screen apps and Mission Control. Windows and Linux have only one band above normal, so there it's the same as `'floating'`. | | `'desktop'` | below ordinary windows, on the wallpaper | On a Linux Wayland session levels do nothing: Wayland has no way for an app to stack its own windows, so `setLevel` and `setAlwaysOnTop` are X11 only, and `tiny.capabilities.can('win.setLevel')` says so (`windowLevel` in `system.capabilities()`). [`windowPlacement`](https://tinyjs.app/docs/config#windowPlacement) runs the app through XWayland, where they work. To keep a window above just your own main window, not other apps, open it with [`parent`](https://tinyjs.app/docs/multiple-windows#parent) instead. On macOS, Mission Control follows from these: `'normal'` and `'floating'` windows show up in it like any document, while `'desktop'`, `'overlay'` and click-through windows stay out of it and out of the window cycle. ## every Space {#allSpaces} ```js tiny.win.setAllSpaces(true); ``` `setAllSpaces(true)` keeps the window on every Space (virtual desktop) as the user switches, and on macOS lets it appear over full-screen apps. It works on macOS and X11. Windows has no equivalent and ignores it (`tiny.system.capabilities().setAllSpaces` is `false`), and Wayland has none either (`setAllSpaces` is `false` there too). ## showing without focus {#activate} `show({ activate: false })` puts a window on screen without taking keyboard focus from the app the user is typing in — for a HUD or a toast. Clicking the window activates it normally. See [showing and hiding](https://tinyjs.app/docs/window#visibility). ## over full-screen apps, with the keyboard (macOS) {#panel} **macOS only** — Windows and Linux ignore `panel` and open an ordinary window. A Spotlight-style palette has to appear over whatever the user is doing — a full-screen app included — and take typing straight away. On macOS an ordinary window can't do both: taking the keyboard means activating the app, and activating it makes macOS leave the full-screen Space for a desktop one. Make the window a **panel** instead: ```json { "activation": "accessory", "chrome": { "frame": false, "panel": true } } ``` ```js // backend export async function init(app) { app.win.setAllSpaces(true); app.win.setLevel('floating'); await app.hotkey.register('summon', 'ctrl+alt+space'); } export function onHotkey(id, app) { app.win.show(); } ``` A panel's `show()` puts it in front and gives it the keyboard **without activating the app**, so with `setAllSpaces(true)` it opens over a full-screen app and macOS stays on that Space. Copy, paste, undo and the other edit shortcuts work in the page, and `focus` / `blur` fire as usual. `hide()` just puts the panel away; the app it floated over never lost focus. `show({ activate: false })` still shows it without taking the keyboard. - It's fixed when the window is created: set it in tinyjs.json `chrome` for the main window, or in [`win.open`](https://tinyjs.app/docs/multiple-windows)'s `chrome`. `setChrome({ panel })` can't change it. - Because the app stays inactive, its menu bar doesn't appear while the panel has focus, and panels aren't in the Window menu or ⌘\` cycling. That suits a palette in a menu-bar app; it's an odd fit for an app's main document window. ## building blocks elsewhere {#elsewhere} Overlays, HUDs and menu-bar monitors usually combine these with calls documented on other pages: - **a window under the tray icon** — [`tiny.tray.position()`](https://tinyjs.app/docs/tray#position), in the same coordinates as `setPosition`. - **a live app icon** — [`tiny.app.icon(path)`](https://tinyjs.app/docs/shell#app-surface), for a progress ring or a status tile. - **battery and Wi-Fi** — [`tiny.system.battery()`](https://tinyjs.app/docs/system#battery) and [`tiny.system.wifi()`](https://tinyjs.app/docs/system#wifi). - **other apps' windows and selected text** — [`tiny.macos.otherWindows()`, `moveWindow()` and `selectedText()`](https://tinyjs.app/docs/macos-only#other-apps), for window managers and PopClip-style popovers. - **on-device AI** — [`tiny.macos.ai`](https://tinyjs.app/docs/macos-only#ai). <!-- https://tinyjs.app/docs/dialogs --> # dialogs > tiny.dialog — native file pickers and message boxes `tiny.dialog` opens the operating system's own panels: file pickers for opening and saving, and message boxes for alerts, confirmations and short text input. The launcher runs them — NSOpenPanel and NSAlert on macOS, the common item dialog and MessageBox on Windows, GTK's file chooser and message dialogs on Linux. They are application-modal rather than attached to one window, which is why they live on `tiny.dialog` and not on `tiny.win`. Every call returns a promise that settles when the user closes the panel. Cancelling is never an error: the pickers resolve `null`, `confirm` resolves `false`. ```js const path = await tiny.dialog.openFile({ types: ['md', 'txt'] }); if (path === null) return; // cancelled const text = await tiny.api.call('readText', { path }); ``` There is no backend twin: dialogs are page-only (see the [API map](https://tinyjs.app/docs/api-map#dialog)). The page gets back a real filesystem path, and reading or writing the file is the [backend](https://tinyjs.app/docs/backend)'s job. ## opening files {#open} ```js await tiny.dialog.openFile(opts?) // → string | null await tiny.dialog.openFiles(opts?) // → string[] | null await tiny.dialog.pickFolder() // → string | null ``` `openFile` lets the user choose one file, `openFiles` one or more, and `pickFolder` a folder. Each resolves the absolute path (or an array of paths), or `null` if the user cancels. ```js const paths = await tiny.dialog.openFiles({ types: ['png', 'jpg', 'jpeg'] }); for (const p of paths ?? []) addImage(p); const dir = await tiny.dialog.pickFolder(); if (dir) await tiny.store.set('projectDir', dir); ``` | option | applies to | | |---|---|---| | `types` | `openFile`, `openFiles`, `saveFile` | file extensions that can be chosen — see [file types](#types) | | `name` | `saveFile` | the file name the save panel starts with | `pickFolder` takes no options. ## file types {#types} `types` is a list of extensions without dots: `['md', 'markdown', 'txt']`. A leading dot and upper case are tolerated (`'.MD'` becomes `'md'`); anything else that couldn't be an extension is dropped. Leave `types` out and every file can be chosen. - **macOS**: only files with those extensions can be chosen. An extension macOS doesn't know still filters. - **Windows and Linux**: the panel starts on a filter of those extensions and also offers "All files", so the user can still pick anything. On Linux the filter matches the extension in lower and upper case. Because Windows and Linux let the user step outside the filter, check the extension of what comes back if your code depends on it. ## saving {#save} ```js await tiny.dialog.saveFile(opts?) // → string | null ``` `saveFile` asks where to save and resolves the chosen path, or `null`. It doesn't write anything: send the path and the data to your backend. ```js const path = await tiny.dialog.saveFile({ types: ['docx'], name: 'report' }); if (path) await tiny.api.call('exportReport', { path }); // …/report.docx ``` `name` pre-fills the file name field. It is a file name, not a path: any folder part is dropped. Without it, macOS shows "Untitled" and Windows and Linux an empty field. When the user types a name with no extension, Windows and Linux add the first extension in `types` — as long as the type filter, not "All files", is selected. A name that already has an extension is left alone. On macOS the save panel handles extensions itself. Each OS asks before replacing an existing file. One Linux caveat: the extension is added after GTK's overwrite check, so if the user types `report` and `report.docx` already exists, the panel doesn't warn. ## alert, confirm and prompt {#messages} ```js await tiny.dialog.alert(message, opts?) // → true await tiny.dialog.confirm(message, opts?) // → true | false await tiny.dialog.prompt(message, opts?) // → string | null ``` The message is the headline; `detail` is smaller text under it. `ok` and `cancel` relabel the buttons on every platform. ```js await tiny.dialog.alert('Export finished', { detail: '12 files written to Downloads.' }); const sure = await tiny.dialog.confirm('Delete 3 notes?', { detail: 'This can’t be undone.', ok: 'Delete', cancel: 'Keep', }); const title = await tiny.dialog.prompt('Rename note', { default: 'Untitled', ok: 'Rename' }); if (title !== null) rename(title); ``` | option | `alert` | `confirm` | `prompt` | | |---|:-:|:-:|:-:|---| | `detail` | ✓ | ✓ | — | secondary text; line breaks are kept | | `ok` | ✓ | ✓ | ✓ | label for the OK button | | `cancel` | — | ✓ | ✓ | label for the Cancel button | | `default` | — | — | ✓ | the text the input field starts with | - `alert` resolves `true` once dismissed. - `confirm` resolves `true` for OK and `false` for Cancel. - `prompt` resolves the entered text — possibly `''` — for OK, and `null` for Cancel. For `alert` and `confirm`, a string second argument is the detail: `tiny.dialog.confirm('Quit?', 'Unsaved changes will be lost.')`. `prompt` has no such shorthand and rejects a string with a `TypeError` — pass `{ default }`. Any other second argument that isn't an object (an array, a number) also rejects with a `TypeError`. An empty message falls back to the app name. On Windows the message box shows the app name in its title bar and the message and detail together as its text. ## the page's own alert() {#browser-dialogs} The page's bare `alert()`, `confirm()` and `prompt()` are the browser's built-ins, not these. They block the page, take only a message, use the page's origin (or the app name) as the headline, and can't relabel their buttons. Use `tiny.dialog` in your own code; the built-ins are there so that sites and libraries that call them still work, for example in a [site wrapper](https://tinyjs.app/docs/site-wrappers). ## in a site wrapper {#site-wrappers} Under a tinyjs.json [`api`](https://tinyjs.app/docs/config#api) gate, dialogs are the `dialog.*` methods — `dialog.openFile`, `dialog.confirm` and so on. The `"wrapper"` preset allows them, so a wrapped site can open the user's file pickers. Disable `dialog.*` if it shouldn't. <!-- https://tinyjs.app/docs/notifications --> # notifications > tiny.notify — banners, clicks, buttons and replies `tiny.notify()` shows a desktop notification: a Notification Center banner on macOS, a toast on Windows, and whatever the desktop's notification service shows on Linux. Clicking it, or one of its buttons, comes back to your app as an event. The backend has the same call as `app.notify(title, body, opts)`, and hears clicks and buttons through the [`onNotificationClick` and `onNotificationAction` hooks](https://tinyjs.app/docs/backend#hooks). ## showing a notification {#notify} ```js tiny.notify('Export finished', 'report.pdf is ready', { id: 'export-42', sound: true }); ``` `tiny.notify(title, body?, opts?)`: | option | | |---|---| | `id` | your id for this notification, passed back on clicks and button presses. Set it whenever you want to hear back — on Linux nothing is reported without one | | `subtitle` | a second line under the title (on Linux it's joined to the body) | | `sound` | `true` plays the notification sound | | `actions` | buttons and reply fields — see [buttons and replies](#actions) | It never rejects, so there's no need to `await` it or catch errors. It resolves `true` once the notification has been handed to the OS — whether or not a banner appears, since the user may have turned notifications off for your app. The one exception is `tinyjs dev` on macOS, where it resolves `false` if the fallback banner couldn't be shown. ## clicks {#clicks} ```js const stop = tiny.app.onNotificationClick((id) => { if (id === 'export-42') tiny.app.shell.reveal(exportPath); }); ``` `tiny.app.onNotificationClick(fn)` calls `fn(id)` when a notification is clicked, and returns a function that removes the handler. Clicks are reported for notifications sent with an `id`, except where noted under [each OS](#os). ## buttons and replies {#actions} ```js tiny.notify('New message', 'from Alex: lunch?', { id: 'msg-881', actions: [ { id: 'reply', title: 'Reply', reply: true, placeholder: 'Message…' }, { id: 'mute', title: 'Mute thread', destructive: true }, ], }); tiny.app.onNotificationAction(({ id, action, reply }) => { if (action === 'reply') sendReply(id, reply); // reply: the typed text if (action === 'mute') muteThread(id); }); ``` Each action is `{ id, title }` plus: | field | | |---|---| | `reply` | show a text field instead of a plain button; what's typed arrives as `reply` | | `placeholder` | placeholder text for the field | | `buttonTitle` | the label of the field's send button, if different from `title` (macOS) | | `destructive` | show the button in red (macOS) | `tiny.app.onNotificationAction(fn)` calls `fn({ id, action, reply })`: the notification's `id`, the action's `id`, and the typed text (`''` for a plain button). It returns a function that removes the handler. Clicking the notification itself, rather than a button, calls `onNotificationClick` instead. Check support before relying on them: `tiny.system.capabilities()` reports `notificationActions` and `notificationReply`. ## on each OS {#os} | | shown as | clicks | buttons | reply field | |---|---|:-:|:-:|:-:| | **macOS, signed** — any real signing identity, including Apple Development | a Notification Center banner with your app's icon | ✓ | ✓ | ✓ | | **macOS, `tinyjs dev` or ad-hoc signed** | a plain banner shown through AppleScript, credited to Script Editor | — | — | — | | **Windows** | a toast, or a tray balloon where toasts aren't available | ✓ | ✓ | one per notification | | **Linux** | the desktop's notification service | with an `id` | with an `id` | — (shows as a plain button, `reply` is `''`) | Notification Center refuses ad-hoc-signed apps, which is why `tinyjs dev` and a build without a [`signIdentity`](https://tinyjs.app/docs/config#signIdentity) fall back to AppleScript. Test clicks and buttons in a signed build. ## permission (macOS) {#permission} The first notification from a signed app asks the user for permission. If they've turned notifications off for your app, notifications are dropped without an error. To find out first: ```js const status = await tiny.app.permissions.check('notifications'); // 'granted', 'denied', 'undetermined' — or 'unsupported' outside a packaged .app if (status === 'undetermined') await tiny.app.permissions.request('notifications'); ``` See [permissions](https://tinyjs.app/docs/permissions). Windows and Linux have no per-app permission for notifications, and answer `'granted'`. ## in the backend {#backend} ```js export async function init(app) { app.notify('Sync complete', '12 files updated', { id: 'sync' }); } export function onNotificationClick(id, app) { if (id === 'sync') app.win.show(); } export function onNotificationAction({ id, action, reply }, app) { // same shape as the page's onNotificationAction } ``` The page events are also on `tiny.api.on` as `'notification-click'` and `'notification-action'`. The older object form, `app.notify({ title, body, … })`, still works but logs a deprecation warning. In an [`api`](https://tinyjs.app/docs/config#api) gate the call is `notify`; the `"wrapper"` preset allows it. <!-- https://tinyjs.app/docs/menus --> # menus > tiny.menu — the menu bar and right-click menus `tiny.menu` declares your app's menu bar and replaces the right-click menu with native items. You describe menus as plain arrays of items, and clicks come back as the item's `id`. The same item shape is used by the [tray](https://tinyjs.app/docs/tray) menu. ```js await tiny.menu.set([ { title: 'File', items: [ { id: 'new', label: 'New Note', key: 'n' }, { id: 'open', label: 'Open…', key: 'o' }, { separator: true }, { id: 'export', label: 'Export as PDF…', key: 'E' }, ]}, ]); tiny.menu.on((id) => { if (id === 'new') newNote(); }); ``` The backend has the same calls on `app.menu` — `app.menu.set()`, `app.menu.update()`, `app.menu.get()`, `app.menu.setContext()` — and gets clicks through the [`onMenu(id, app)` and `onContextMenu(id, app)` hooks](https://tinyjs.app/docs/backend#hooks). Setting it from the backend's [`init`](https://tinyjs.app/docs/backend#init) puts it up at startup, whether or not a page has loaded yet. ## the menu bar {#menu-bar} ```js await tiny.menu.set(menus) // menus: [{ title, items }] ``` This is the **app** menu: every window shows it, including windows opened later. Each call replaces the whole menu; `tiny.menu.set([])` removes yours. - **macOS** has one menu bar for the whole app, at the top of the screen. Every app gets an application menu (About, Quit ⌘Q) and an Edit menu (Undo, Cut, Copy, Paste, Select All) for free; yours follow them. The Edit menu is what makes ⌘C and ⌘V work in the page. - **Windows and Linux** draw a copy of the menu bar inside each window, under the title bar. There is no application menu and no automatic Edit menu — the webview handles Ctrl+C and Ctrl+V on its own. When a bar first appears, the window grows to make room for it, so the page keeps its [`size`](https://tinyjs.app/docs/config#size) (a maximized window gives the row up from the page instead). On Windows, frameless and transparent windows can't show a menu bar. Their menu's shortcuts still work. To give one window a different menu, see [one window's menu](#per-window). ## menu items {#items} ```js { title: 'View', items: [ { id: 'sidebar', label: 'Show Sidebar', key: 's', checked: true }, { id: 'zoom', label: 'Zoom', submenu: [ { id: 'zoom-in', label: 'Zoom In', key: 'alt+i' }, { id: 'zoom-out', label: 'Zoom Out', key: 'alt+o' }, ]}, { separator: true }, { id: 'sync', label: 'Sync Now', enabled: false }, ]} ``` | field | | | |---|---|---| | `id` | string | what a click reports; also how [`update`](#update) and [`get`](#update) find the item | | `label` | string | the text shown; defaults to `id` | | `key` | string | keyboard shortcut — see [shortcuts](#keys) | | `checked` | boolean | shows a checkmark | | `enabled` | boolean | `false` greys the item out | | `submenu` | item[] | a nested menu; the item itself only opens it | | `separator` | `true` | a divider line; other fields are ignored | | `role` | string | a stock editing item instead of your own — see [the Edit menu](#edit) | Item ids should be unique across all your menus: [`update`](#update) changes every item with that id. ## keyboard shortcuts {#keys} `key` is the character pressed together with ⌘ on macOS, or Ctrl on Windows and Linux. ⌘/Ctrl is always part of it — a menu shortcut without it isn't possible. | `key` | macOS | Windows, Linux | |---|---|---| | `'s'` | ⌘S | Ctrl+S | | `'S'` | ⌘⇧S | Ctrl+Shift+S | | `'alt+p'` | ⌥⌘P | Ctrl+Alt+P | | `'alt+shift+f'` | ⌥⇧⌘F | Ctrl+Alt+Shift+F | | `'ctrl+k'` | ⌃⌘K | Ctrl+K | An uppercase letter carries its own Shift. Prefixes add the rest: `alt` (or `opt`, `option`), `shift`, and `ctrl` (or `control`), which adds ⌃ on macOS and changes nothing elsewhere. `cmd`, `command`, `meta` and `super` are accepted and change nothing, since ⌘/Ctrl is always there. Use a letter or a digit for a shortcut that works everywhere. macOS takes any single character — `','` for Settings… is ⌘, — but Windows only fires shortcuts on letters and digits, and Linux reads the key as a GTK key name (`'comma'` rather than `','`). Shortcuts keep working in a window whose bar is [hidden](#per-window). ## handling clicks {#clicks} ```js const off = tiny.menu.on((id) => { … }); // returns an unsubscribe function ``` ```js // backend export function onMenu(id, app) { if (id === 'new') app.window('main').show(); } ``` A click reaches the backend's `onMenu` and every page's `tiny.menu.on` listener — in every open window, whichever window's bar was clicked. If several windows each subscribe, each one runs. For commands that act on the whole app, handle them once: in the backend, or in one window. ## changing items {#update} ```js await tiny.menu.update(id, { label?, checked?, enabled? }) await tiny.menu.get(id) // → { exists, label?, checked?, enabled? } ``` `update` changes a live item without declaring the menu again. Fields you leave out stay as they are, and every copy of the id moves — in every window's bar, the tray menu and the right-click menu. ```js let muted = false; tiny.menu.on(async (id) => { if (id !== 'mute') return; muted = !muted; await tiny.menu.update('mute', { checked: muted, label: muted ? 'Unmute' : 'Mute' }); }); await tiny.menu.get('mute'); // { exists: true, label: 'Unmute', checked: true, enabled: true } await tiny.menu.get('nope'); // { exists: false } ``` `get` reads an item back from the menu bar or the tray menu, and on macOS and Windows from the right-click menu too. An `update` for an id that doesn't exist does nothing. An empty `label` leaves the label unchanged. ## standard menus {#roles} A menu with a `role` instead of a `title` takes a slot that the launcher fills. | role | macOS | Windows, Linux | |---|---|---| | `'app'` | your items inside the application menu, between About and Quit | dropped | | `'edit'` | the Edit menu, at this position in the bar | an "Edit" menu of your items, if you give any | | `'help'` | the Help menu, always last | a "Help" menu of your items, in this position | Only the first `'app'` block and the first `'edit'` block count. ### the application menu {#app} On macOS, Settings… belongs in the application menu, between About and Quit. Put it there with `{ role: 'app' }`. Windows and Linux have no application menu and drop these items, so declare the same item in a menu of your own for them. ```js const settings = { id: 'settings', label: 'Settings…', key: ',' }; const isMac = tiny.system.isMacOS(); await tiny.menu.set([ { role: 'app', items: [settings] }, { title: 'File', items: [ { id: 'new', label: 'New', key: 'n' }, ...(isMac ? [] : [{ separator: true }, { ...settings, key: undefined }]), ]}, ]); ``` Items in the application menu work like any other: they report clicks through `tiny.menu.on` and `onMenu`, and `update` and `get` find them. The `key` is dropped on Windows and Linux above because those only fire shortcuts on letters and digits. ### the Edit menu {#edit} On macOS the Edit menu is always there and comes right after the application menu. Declare `{ role: 'edit' }` to move it — that's how you get File before Edit, the usual Mac order. Items you give it go below Select All. ```js await tiny.menu.set([ { title: 'File', items: [{ id: 'save', label: 'Save', key: 's' }] }, { role: 'edit', items: [{ id: 'find', label: 'Find…', key: 'f' }] }, ]); ``` On Windows and Linux, `{ role: 'edit' }` with items draws an "Edit" menu of exactly those items in that slot, and without items draws nothing. #### stock items {#stock} To control the Edit menu's contents, place the stock items yourself with an item-level `role`: | item `role` | | |---|---| | `'undo'` | Undo | | `'redo'` | Redo | | `'cut'` | Cut | | `'copy'` | Copy | | `'paste'` | Paste | | `'selectAll'` | Select All | | `'standard'` | all six, in that order, with a separator after Redo | As soon as you place one, you set the whole order and macOS adds none of its own. Stock items render on all three OSes, so this gives the same Edit menu everywhere: ```js { role: 'edit', items: [{ role: 'standard' }, { separator: true }, { id: 'find', label: 'Find…', key: 'f' }] } ``` Stock items act on whatever has focus in the page and grey out when they don't apply (on Windows they're always enabled). They work in the menu bar and in [right-click menus](#context); the tray skips them. On macOS, `standard: false` on the menu drops the stock items. With no items of your own as well, there's no Edit menu at all. ⌘C, ⌘V, ⌘X, ⌘A and ⌘Z keep working either way. ```js { role: 'edit', standard: false, items: [{ id: 'find', label: 'Find…', key: 'f' }] } ``` ### the help menu {#help} ```js { role: 'help', items: [{ id: 'site', label: 'Visit Website' }] } ``` On macOS the Help menu always goes last, and macOS puts its menu-search field at the top. When a built app ships a [help book](https://tinyjs.app/docs/config#helpBook), the menu starts with "<App> Help" (⌘?), which opens the book, and your items go below it; `standard: false` drops that item. A help book gets a Help menu even without the role. In `tinyjs dev` there's no help book, so the menu holds only your items. On Windows and Linux it's a plain "Help" menu of your items, in the position you declared it. ### a custom About panel {#about} On macOS the application menu's About item shows the standard About panel. With [`"about": "menu"`](https://tinyjs.app/docs/config#about) in tinyjs.json, it sends a menu click with the reserved id `'about'` instead — to `onMenu('about')` and `tiny.menu.on` — so you can show your own. ```json { "about": "menu" } ``` ```js tiny.menu.on((id) => { if (id === 'about') tiny.win.open('about', { page: 'about.html', title: 'About', size: '360x240' }); }); ``` Windows and Linux have no built-in About item, so there's nothing to hook; add your own, such as in the Help menu. ## one window's menu {#per-window} ```js await tiny.win.menu.set(menus) // this window's own bar await tiny.win.menu.update(id, patch) // only this window's copy await tiny.win.menu.get(id) await tiny.win.menu.reset() // back to the app menu ``` `tiny.win.menu` gives the calling window its own menu bar, with the same spec and the same click events as `tiny.menu`. Windows that don't override keep the app menu. On macOS, which has one bar, the window's menu is shown while that window is focused and the app menu returns when it isn't. `tiny.win.menu.update` patches the item in this window only; `tiny.menu.update` patches every window's copy. In the backend, the same calls are `app.win.menu.*` for the main window and `app.window(id).menu.*` for others. To show no menu bar in one window, hide it rather than emptying it: `tiny.win.setChrome({ menu: false })`, or `"chrome": { "menu": false }` in [tinyjs.json](https://tinyjs.app/docs/config#chrome) or [`win.open`](https://tinyjs.app/docs/multiple-windows). The window's shortcuts keep working. macOS ignores it, since its one bar belongs to the whole app. ## right-click menus {#context} ```js await tiny.menu.setContext(items) // items, or null for the default menu tiny.menu.onContext((id) => { … }) // returns an unsubscribe function ``` `setContext` replaces the webview's right-click menu (Reload, Inspect Element…) with your items, in every window. Items have the same fields as menu bar items, including submenus and [stock items](#stock). `null` or `[]` brings the default menu back. Clicks go to `tiny.menu.onContext` and the backend's `onContextMenu(id, app)` hook. ```js await tiny.menu.setContext([ { id: 'copy-link', label: 'Copy Link' }, { separator: true }, { role: 'paste' }, ]); let target = null; document.addEventListener('contextmenu', (e) => { target = e.target; }); tiny.menu.onContext((id) => { if (id === 'copy-link' && target?.closest('a')) copyLink(target.closest('a').href); }); ``` The click reports only the item's id. To act on what was right-clicked, remember the target in a `contextmenu` listener, as above. To turn off the default menu without replacing it, set [`"contextMenu": false`](https://tinyjs.app/docs/config#contextMenu) in tinyjs.json. A custom menu from `setContext` still shows. ## old names {#deprecated} The backend's earlier spellings still work but log a deprecation notice once: `app.setMenu()`, `app.updateMenuItem()`, `app.getMenuItem()` and `app.setContextMenu()` are now `app.menu.set()`, `.update()`, `.get()` and `.setContext()`; a window handle's `setMenu()`, `resetMenu()`, `updateMenuItem()` and `getMenuItem()` are now `menu.set()`, `.reset()`, `.update()` and `.get()`. <!-- https://tinyjs.app/docs/tray --> # tray > tiny.tray — an icon in the menu bar or notification area `tiny.tray` puts one icon in the macOS menu bar, the Windows notification area or the Linux system tray, with an optional menu. Use it for apps that live in the background — a timer, a clipboard history, a status indicator — often paired with a hidden window that the icon brings up. ```js await tiny.tray.set({ icon: 'sf:cup.and.saucer.fill', tooltip: 'Brew', menu: [ { id: 'show', label: 'Show Brew' }, { separator: true }, { id: 'quit', label: 'Quit' }, ], }); tiny.tray.on((id) => { if (id === 'show') tiny.win.show(); if (id === 'quit') tiny.quit(); }); ``` The backend has the same calls on `app.tray` — `app.tray.set()`, `app.tray.remove()`, `app.tray.position()` — and gets clicks through the [`onTray(id, app)` hook](https://tinyjs.app/docs/backend#hooks), where `id` is the menu item's id, or `null` for a click on the icon itself. A tray app often has no page open, so the backend is the usual place for it. ## showing an icon {#set} ```js await tiny.tray.set(spec) await tiny.tray.remove() ``` `set` shows the icon, or replaces everything about it if it's already there. To change anything — the title, the icon, the menu — call `set` again with the whole spec. `remove` takes the icon away. | field | | | |---|---|---| | `icon` | string | a png path, `'sf:<name>'` or `'emoji:<glyph>'` — see [icons](#icons) | | `title` | string | text beside the icon on macOS, and on Linux where the desktop shows it; Windows uses it as the tooltip when there's no `tooltip` | | `tooltip` | string | text shown on hover | | `template` | boolean | macOS: `false` keeps the png's colours instead of adapting it to the menu bar; default `true` | | `menu` | item[] | the menu, in the same [item shape](https://tinyjs.app/docs/menus#items) as the menu bar | | `primaryAction` | boolean | `true`: a left click is a [click event](#clicks) and the menu opens on right-click | Menu items take `id`, `label`, `checked`, `enabled`, `submenu` and `separator`. Stock editing items (`{ role: 'copy' }`) are skipped — there's nothing to edit in a tray menu. ## icons {#icons} | `icon` | macOS | Windows | Linux | |---|---|---|---| | png path | ✓ scaled to the menu-bar height, aspect kept | ✓ | ✓ absolute path | | `'sf:<name>'` | an SF Symbol, such as `'sf:timer'` | generic app icon | the app's icon, or a generic one | | `'emoji:<glyph>'` | title only | the glyph as a monochrome silhouette, white or black to suit the taskbar | the app's icon, or a generic one | | none | title only | generic app icon | the app's icon, or a generic one | On macOS a png is a template image by default: macOS uses only its shape and colours it for a light or dark menu bar, the way system icons look. Draw it black on transparent, or set `template: false` to keep its own colours. An unknown SF Symbol name shows the title alone. There's no single icon string that's asset-free everywhere, so branch on the OS: ```js const os = tiny.system.os(); const icon = os === 'macos' ? 'sf:timer' : os === 'windows' ? 'emoji:⏱' : (await tiny.app.paths()).resources + '/tray.png'; await tiny.tray.set({ icon, tooltip: 'Timer', menu }); ``` Pass an absolute path for a png — build it from [`paths().resources`](https://tinyjs.app/docs/resources) or `paths().data`. A relative path is resolved against the backend's working directory, which is your project folder in `tinyjs dev` but not dependably anything in a built app. Linux uses the path only if the file exists, and falls back to the app's icon otherwise. To show changing content — a countdown, a level meter — render a png yourself, write it to `paths().data`, and call `set` with the new path. `set` redraws the icon each time, so call it only when something changed. ## clicks {#clicks} ```js tiny.tray.on((id) => { … }) // a menu item was chosen tiny.tray.onClick(() => { … }) // the icon itself was clicked ``` Both return an unsubscribe function. In the backend, both arrive at `onTray`: `onTray('show', app)` for an item, `onTray(null, app)` for the icon. ```js // backend export function onTray(id, app) { if (id === null || id === 'show') app.win.show(); if (id === 'quit') app.quit(); } ``` What a click on the icon does depends on whether there's a menu and on `primaryAction`: | | macOS | Windows | Linux | |---|---|---|---| | menu | any click opens the menu | left or right click opens the menu | a click opens the menu | | no menu | left click → `onClick` | left or right click → `onClick` | a click opens a one-item menu; choosing it → `onClick` | | menu + `primaryAction: true` | left click → `onClick`; right-click or Ctrl-click opens the menu | left click → `onClick`; right-click opens the menu | the menu, with an extra first item → `onClick` | Linux tray icons (StatusNotifierItem) can't report a bare click — the desktop always opens a menu. So where you'd get a click on the other OSes, tinyjs adds a first menu item labelled with the `title`, else the `tooltip`, else "Open", and choosing it fires `onClick`. The click events reach every open window's listeners, as well as the backend. If several windows subscribe, each one runs. ## changing menu items {#update} The tray menu's items are reachable with [`tiny.menu.update()` and `tiny.menu.get()`](https://tinyjs.app/docs/menus#update), so you can tick an item or change its label without calling `set` again: ```js await tiny.menu.update('capturing', { checked: false }); ``` `update` changes every item with that id, menu bar included — give tray items ids of their own. ## where the icon is {#position} ```js await tiny.tray.position() // → { x, y, width, height } | null ``` The icon's rectangle on screen, in the same top-left coordinates as [`tiny.win.setPosition`](https://tinyjs.app/docs/window), for opening a dropdown window under it. It resolves `null` when there's no tray icon — and always on Linux, where the tray doesn't expose where its icons are. ```js const r = await tiny.tray.position(); if (r) await tiny.win.setPosition(Math.round(r.x + r.width / 2 - 160), r.y + r.height); ``` ## menu-bar apps {#menubar-app} An app that lives in the tray usually has no Dock icon or taskbar button, and a window that comes and goes. Set [`"activation": "accessory"`](https://tinyjs.app/docs/config#activation) in tinyjs.json: the app launches with no Dock icon (no taskbar button on Windows and Linux), and the main window starts hidden, so it doesn't flash on screen. ```json { "activation": "accessory" } ``` Then make the close button hide the window rather than quit, and show it from the tray: ```js await tiny.win.setHideOnClose(true); tiny.tray.onClick(() => tiny.win.show()); ``` `tiny.app.presence('menubar')` and `presence('normal')` switch the Dock icon or taskbar button off and on while the app runs; on Linux that works in X11 sessions only. On Windows and Linux, hide-on-close holds only while something can bring the window back — the tray icon, accessory mode, or another open window — otherwise closing the last window quits, as it does in other apps there. With [`"mainWindow": false`](https://tinyjs.app/docs/config#mainWindow) the app starts with no window at all, and opens [windows](https://tinyjs.app/docs/multiple-windows) when it needs them. `tinyjs wrap --menubar` sets a [site wrapper](https://tinyjs.app/docs/cli#wrap) up this way. ## Linux {#linux} The tray needs a StatusNotifier (AppIndicator) host. KDE Plasma and most other desktops have one built in; GNOME needs the AppIndicator shell extension. Without one, `set` still succeeds, but the icon may not show. Check for it with [`tiny.system.requirements`](https://tinyjs.app/docs/portability): ```js const [tray] = await tiny.system.requirements(['tray']); if (!tray.ok) showHint(tray.detail, tray.install?.command); ``` Whether the `title` text shows next to the icon is up to the desktop. <!-- https://tinyjs.app/docs/hotkeys --> # hotkeys > tiny.hotkey — system-wide keyboard shortcuts `tiny.hotkey` registers a key combination that fires wherever the user is — while another app is in front, or while your window is hidden. Use it to summon a palette, toggle recording, or show a menu-bar app's window. For shortcuts that only matter while your app is focused, use a [menu item's `key`](https://tinyjs.app/docs/menus#keys) or a `keydown` listener instead, which don't take the combination away from every other app. ```js await tiny.hotkey.register('summon', 'cmd+shift+space'); tiny.hotkey.on((id) => { if (id === 'summon') tiny.win.show(); }); ``` The backend has the same calls on `app.hotkey` — `app.hotkey.register()` and `app.hotkey.unregister()` — and gets presses through the [`onHotkey(id, app)` hook](https://tinyjs.app/docs/backend#hooks). A hotkey that should work with no window open belongs there: ```js // backend export function init(app) { app.hotkey.register('summon', 'cmd+shift+space'); } export function onHotkey(id, app) { if (id === 'summon') app.win.show(); } ``` ## registering {#register} ```js await tiny.hotkey.register(id, combo) await tiny.hotkey.unregister(id) const off = tiny.hotkey.on((id) => { … }) // returns an unsubscribe function ``` `id` is your name for the hotkey; presses report it. `combo` is modifiers and one key joined with `+`, such as `'cmd+shift+k'` — see [combos](#combos). Registering an id that's already registered replaces its combo, so a settings screen can rebind a hotkey by calling `register` again. `unregister` removes it. Hotkeys last until the app quits. A press reaches the backend's `onHotkey` and every open window's `tiny.hotkey.on` listeners. If several windows subscribe, each one runs. `register()` resolves `true` once the system has the hotkey, and `false` when it doesn't — the combo isn't valid, or another app already holds it. The reason is logged to the console (`tinyjs: hotkey "…" not registered: …`), visible in `tinyjs dev`. Pick combos unlikely to clash, let users change them, and tell them when one couldn't be registered: ```js if (!(await tiny.hotkey.register('summon', 'cmd+shift+space'))) showMessage('That shortcut is already used by another app — pick another one.'); ``` On Linux under Wayland, `true` means the hotkey was handed to the desktop, which then asks the user to approve it (see [platform notes](#platforms)). ## combos {#combos} A combo is modifiers plus exactly one key, joined with `+`, in any order. Names are case-insensitive. A combo with an unknown word, two keys or no key is refused — never registered as something else. | modifier | macOS | Windows, Linux | |---|---|---| | `cmd`, `command`, `meta` | ⌘ | Ctrl | | `ctrl`, `control` | ⌃ | Ctrl | | `alt`, `opt`, `option` | ⌥ | Alt | | `shift` | ⇧ | Shift | | `win`, `super` | ⌘ | the Windows / Super key | `cmd` and `ctrl` are the same key on Windows and Linux, so `'cmd+shift+k'` is ⌘⇧K on a Mac and Ctrl+Shift+K elsewhere. macOS has no separate Windows key, so `win` and `super` mean ⌘ there, as they do in [menus](https://tinyjs.app/docs/menus#keys). Keys have the same names on every OS: | key | names | |---|---| | letters and digits | `a`–`z`, `0`–`9` | | function keys | `f1`–`f12` everywhere; up to `f20` on macOS, `f24` on Windows, `f35` on Linux | | whitespace and editing | `space`, `tab`, `return` (or `enter`), `escape` (or `esc`), `backspace` (or `delete`), `forwarddelete` | | arrows and navigation | `up`, `down`, `left`, `right`, `home`, `end`, `pageup`, `pagedown` | | punctuation | `minus`, `equal`, `comma`, `period`, `slash`, `semicolon`, `quote`, `backslash`, `grave`, `bracketleft`, `bracketright` — or the character itself: `'cmd+,'`, `'ctrl+/'` | `delete` is Backspace on every OS — the key labelled Delete on a Mac; use `forwarddelete` for the other one. Punctuation names are the keys' positions on a US layout. The same names are used by [keystrokes](https://tinyjs.app/docs/keystrokes). ## platform notes {#platforms} - **macOS**: no permission is needed — registering a hotkey doesn't involve Accessibility. The combo is taken from every other app while registered. - **Windows**: a combo another app has already registered can't be taken; the console says so. Holding the keys down doesn't repeat the event. - **Linux, X11**: the launcher grabs the key on the root window. An app that runs through XWayland (see [`windowPlacement`](https://tinyjs.app/docs/config#windowPlacement)) takes this route too, not the portal below. - **Linux, Wayland**: apps can't grab keys there, so tinyjs asks the desktop through the GlobalShortcuts portal. The desktop shows the user a dialog to approve the shortcuts — listed as "<App>: <id>", so give hotkeys readable ids — and may let them pick a different combo; your combo is only a suggestion. Desktops without the portal never fire the hotkey. [`tiny.system.capabilities()`](https://tinyjs.app/docs/portability) reports `globalHotkeys: false` on a Linux Wayland desktop without the GlobalShortcuts portal, where hotkeys can't fire; `tiny.system.requirements(['globalHotkeys'])` says what to install. <!-- https://tinyjs.app/docs/keystrokes --> # keystrokes > tiny.app.keystroke — send key presses to other apps `tiny.app.keystroke` presses a key combination as if the user had typed it, and `tiny.app.paste` presses the paste shortcut. The keys go to whichever app is in front, which makes them the last step of a clipboard manager, a snippet expander or an emoji picker: put text on the [clipboard](https://tinyjs.app/docs/clipboard), hide your window, paste. ```js await tiny.clipboard.write({ text: snippet }); await tiny.win.hide(); const { ok, trusted } = await tiny.app.paste(); ``` The backend has the same calls: `app.keystroke(combo)` and `app.paste()`. ## keystroke {#keystroke} ```js await tiny.app.keystroke(combo) // → { ok, trusted } ``` `combo` is modifiers and one key joined with `+`, the same names as [hotkeys](https://tinyjs.app/docs/hotkeys#combos): `'cmd+v'`, `'cmd+shift+z'`, `'escape'`, `'alt+tab'`. The launcher presses the modifiers, presses and releases the key, then releases the modifiers. `cmd` is Ctrl on Windows and Linux, so `'cmd+v'` pastes on all three. It posts one combination, not text — there's no call that types a string. To enter text, put it on the clipboard and paste. The promise resolves `{ ok, trusted }`; it doesn't reject. | | `ok` | `trusted` | |---|---|---| | macOS | the key was posted: the combo parsed and Accessibility is granted | Accessibility is granted | | Windows | the combo parsed and Windows accepted the input | always `true` | | Linux | the combo parsed and was posted through XTest (always `false` in a Wayland session) | same as `ok` | On Windows, `ok: true` means Windows accepted the input, not that the target app acted on it: Windows silently drops input sent to an app running as administrator from one that isn't. ## paste into another app {#paste} ```js await tiny.app.paste() // → { ok, trusted } ``` `paste()` is `keystroke('cmd+v')`. The paste lands in the app in front, so make that the right app first. On macOS, [`tiny.win.hide()`](https://tinyjs.app/docs/window) hides the whole app and focus goes back to the app the user was in before, with no need to track which one it was: ```js async function insert(text) { await tiny.clipboard.write({ text }); await tiny.win.hide(); const r = await tiny.app.paste(); if (!r.trusted) showAccessibilityHelp(); } ``` ## the Accessibility permission (macOS) {#permissions} macOS only lets an app post keystrokes once the user has granted it Accessibility in System Settings → Privacy & Security. The keystroke comes from the launcher itself, so the prompt and the settings entry name your app, and one grant covers it — instead of the two that scripting System Events through osascript needs, which name osascript or the terminal. Without the grant, `keystroke` resolves `{ ok: false, trusted: false }` and nothing is typed. Ask before the first use rather than failing at it, with [`tiny.app.permissions`](https://tinyjs.app/docs/permissions): ```js if (tiny.system.isMacOS() && await tiny.app.permissions.check('accessibility') !== 'granted') { await tiny.app.permissions.request('accessibility'); // opens System Settings at your app } ``` In `tinyjs dev` the grant belongs to the shared dev launcher rather than your app — see [permissions](https://tinyjs.app/docs/permissions). ## platform support {#platforms} - **macOS**: needs Accessibility, as above. - **Windows**: no permission needed. - **Linux**: synthesised through XTest, so it works in X11 sessions. Wayland doesn't let apps fake input, so in a Wayland session the keys can't reach Wayland apps. In a Wayland session `keystroke()` and `paste()` don't send anything and answer `{ ok: false, trusted: false }`. To check before offering the feature, ask [`tiny.system.capabilities()`](https://tinyjs.app/docs/portability): `keystroke` is `false` on Linux when the session isn't X11, and supported everywhere else. ```js const caps = await tiny.system.capabilities(); if (caps.keystroke !== false) enableQuickPaste(); ``` <!-- https://tinyjs.app/docs/clipboard --> # clipboard > tiny.clipboard — read, write and watch the system clipboard `tiny.clipboard` reads and writes the system clipboard from the launcher process: text, HTML, files, images and colours, in any combination. It also tells you when the clipboard changes, which is what a clipboard-history app or a "paste from clipboard" button needs. The launcher talks to the OS clipboard directly, so there are no `pbpaste` or `xclip` processes, and writing several files at once never loses the last few. Use it instead of `navigator.clipboard` when you need files, images or change notifications, or when the page isn't focused. The backend has the same calls on `app.clipboard`, and an `onClipboardChange(info, app)` export in place of `onChange` (see [hooks](https://tinyjs.app/docs/backend#hooks)). ```js const clip = await tiny.clipboard.read(); if (clip.kind === 'text') console.log(clip.text); await tiny.clipboard.write({ text: 'hello' }); ``` ## reading {#read} `tiny.clipboard.read()` resolves to an object describing everything on the clipboard right now: ```js const clip = await tiny.clipboard.read(); switch (clip.kind) { case 'files': addAttachments(clip.paths); break; case 'image': showImage(tiny.fileURL(clip.image), clip.imageSize); break; case 'text': insert(clip.html ?? clip.text); break; } ``` | field | type | | |---|---|---| | `kind` | `'files'`, `'image'`, `'color'`, `'text'`, `'empty'` | what the clipboard mainly holds | | `changeCount` | number | the change counter — see [watching](#watch) | | `text` | string or `null` | plain text | | `html` | string or `null` | HTML, when the source provided it | | `paths` | string[] | real file paths (`kind: 'files'`) | | `image` | string or `null` | path of a PNG copy of the image (`kind: 'image'`) | | `imageSize` | `{ width, height }` or `null` | the image's size in pixels | | `color` | string or `null` | `'#rrggbb'` or `'#rrggbbaa'` (`kind: 'color'`) | | `concealed` | boolean | a password manager marked the content secret | | `sourceApp` | `{ name, bundleId }` or `null` | the app the content came from | | `sourceURL` | string or `null` | the page a Chromium-based browser copied from | Several fields can be set at once — a copy from a browser usually has both `text` and `html`. `kind` picks the most specific one: files, then image, then colour, then text. `image` is a temporary file the launcher writes for you. Copy it somewhere if you want to keep it: a later `read()` may overwrite or delete it. ### per-OS differences {#read-os} | | macOS | Windows | Linux | |---|---|---|---| | `text`, `html`, `paths`, `image` | yes | yes | yes | | `color` | yes | `null` | `null` | | `concealed` | yes | always `false` | always `false` | | `sourceApp` | yes | `null` | `null` | | `sourceURL` | yes | `null` | `null` | `concealed` is `true` when the content carries the `org.nspasteboard.ConcealedType` or `TransientType` marker, which password managers set. A clipboard-history app should not store that content. `sourceApp` is the app that was frontmost when the change was first noticed. It's exact while you're [watching](#watch); without a watch it's worked out at your next `read()`, by which time the user may have switched apps. Your own writes are attributed to your app. ## writing {#write} `tiny.clipboard.write(data)` replaces the whole clipboard with what you pass. Any combination of fields works, and each lands in the format other apps expect: ```js await tiny.clipboard.write({ text: 'Total: 42', html: '<b>Total:</b> 42' }); await tiny.clipboard.write({ paths: ['/Users/me/a.png', '/Users/me/b.png'] }); await tiny.clipboard.write({ image: '/tmp/chart.png' }); await tiny.clipboard.write({ color: '#ff8800' }); ``` | field | | |---|---| | `text` | plain text | | `html` | HTML | | `paths` | absolute file paths; pasting into a file manager copies the files | | `image` | a PNG file path, a `data:` URL, or raw base64 | | `color` | `'#rrggbb'` or `'#rrggbbaa'` | Writing is fire-and-forget: the promise resolves once the request is sent, and a field the launcher can't use (an image path that doesn't exist, say) is left out without an error. Windows and Linux have no clipboard format for colours, so `color` is written as text there, unless you also passed `text`. On Linux, the clipboard content stays with your app until another app copies something. If your app quits and no clipboard manager is running, what it copied goes with it — the usual X11 and Wayland behaviour. The backend's `app.clipboard.write()` returns `true` straight away rather than a promise. ## watching for changes {#watch} `onChange(fn)` calls `fn` each time the clipboard changes, and starts watching if this page isn't already: ```js const off = tiny.clipboard.onChange(async ({ changeCount, self }) => { if (self) return; // our own write() const clip = await tiny.clipboard.read(); if (!clip.concealed) history.add(clip); }); ``` `self` is `true` when your own `write()` caused the change. The handler returns a function that removes it. To choose the polling interval, call `watch(intervalMs)` yourself (default 500 ms); `unwatch()` stops this page's watch. ```js await tiny.clipboard.watch(250); // …later await tiny.clipboard.unwatch(); ``` The launcher runs one watcher for the whole app at the fastest interval anyone asked for. Each window and the backend has its own watch, so one window's `unwatch()` doesn't silence another window or the backend. A change event goes to every window, so a page can still hear changes after its own `unwatch()` while something else is watching — remove the handler if you want silence. How each OS notices changes: - **macOS** polls the clipboard's change counter in the launcher, at least 100 ms apart. - **Windows** polls the clipboard sequence number at the interval you asked for. - **Linux** gets an event from the system when the clipboard changes, so the interval doesn't matter. ### changeCount {#changeCount} `tiny.clipboard.changeCount()` resolves to the current change counter, without reading the contents. Keep the last one you saw, and after a gap — waking from sleep, a window coming back — one call tells you whether reading again is worthwhile: ```js let seen = (await tiny.clipboard.read()).changeCount; window.addEventListener('focus', async () => { const now = await tiny.clipboard.changeCount(); if (now !== seen) { seen = now; refresh(await tiny.clipboard.read()); } }); ``` On macOS and Windows this is the system's own counter, and it keeps counting with no watch running. On Linux it's a counter the launcher keeps from 0 each time the app starts; it also counts with no watch running. Compare values only within one run of the app. ## in the backend {#backend} ```js // src/backend/main.js export function onClipboardChange({ changeCount, self }, app) { if (!self) app.clipboard.read().then(save); } ``` Exporting `onClipboardChange` starts a 500 ms watch for the backend. Call `app.clipboard.watch(ms)` in `init` for a different interval. `app.clipboard.write()`, `watch()` and `unwatch()` return `true` immediately; `read()` and `changeCount()` return promises. In a page, the same event is `tiny.api.on('clipboard-change', fn)`. ## in a site wrapper {#api-gate} The calls go over the wire as `clip.read`, `clip.write`, `clip.changeCount`, `clip.watch` and `clip.unwatch` — the names to use in the [`api`](https://tinyjs.app/docs/config#api) gate. The `"wrapper"` preset allows `clip.write` but not reading, so a wrapped site can copy but can't see what the user copied elsewhere. <!-- https://tinyjs.app/docs/store --> # store > tiny.store — persistent settings `tiny.store` keeps small values between runs: preferences, the last folder opened, a window layout of your own. It's a set of flat keys with JSON values, saved to one `store.json` file for the app, and every window and the backend see the same values. In the backend it's `app.store`, with the same calls. ```js const theme = (await tiny.store.get('theme')) ?? 'system'; await tiny.store.set('theme', 'dark'); ``` For tokens and passwords use [secrets](https://tinyjs.app/docs/secrets) instead: `store.json` is a plain file anyone with access to the account can read. For data you query, or anything that grows, use a [sqlite](https://tinyjs.app/docs/sqlite). ## reading and writing {#calls} | call | resolves to | |---|---| | `tiny.store.get(key)` | the value, or `null` if the key isn't set | | `tiny.store.set(key, value)` | `true` once saved to disk, `false` if the save failed | | `tiny.store.delete(key)` | `true` once saved, `false` if the save failed; deleting a key that isn't set is fine | | `tiny.store.all()` | every key and value, as an object | ```js await tiny.store.set('recent', ['/Users/me/a.md', '/Users/me/b.md']); await tiny.store.set('window', { sidebar: true, width: 280 }); const { sidebar } = (await tiny.store.get('window')) ?? { sidebar: true }; await tiny.store.delete('recent'); ``` Values must survive JSON: a `Date` comes back as a string, a `Map` or `Set` as `{}`. Storing `null` or `undefined` reads back as `null`, the same as a key that was never set. ## when a save fails {#failures} The store works like `localStorage`: the values live in memory, and `set` and `delete` take effect at once, then save the file. `get` and `all` see a change immediately, even before it's on disk. The calls never reject, so an un-awaited `set` can't crash anything. A failed save (a full disk, no permission) resolves `false` — which `localStorage` never tells you. The value stays in memory, `get` returns it, and the next successful save writes it, but it's lost if the app quits first. To retry, set the same value again rather than redoing a read-modify-write, which would apply the change twice: ```js const next = (await tiny.store.get('launches')) + 1; if (!(await tiny.store.set('launches', next))) { // retry with the same value, not with get() + 1 again await tiny.store.set('launches', next); } ``` Each save writes a temporary file and renames it over `store.json`, so a crash mid-save leaves the old file whole. ## where it's kept {#where} `store.json` sits in the app's data folder, [`paths().data`](https://tinyjs.app/docs/shell): - macOS: `~/Library/Application Support/<id>/store.json` - Windows: `%APPDATA%\<id>\store.json` - Linux: `~/.local/share/<id>/store.json` (or under `$XDG_DATA_HOME`) The folder is named after the app's [`id`](https://tinyjs.app/docs/config#id), so the store survives updates and each app has its own. Changing the `id` after release starts users over with an empty store. Things to know about the file: - **It's read once**, the first time any store call runs, and kept in memory after that. Editing `store.json` while the app runs doesn't change what the app sees, and the app's next save overwrites your edit. - **A file that isn't valid JSON** reads as empty, and the next save replaces it. - **tinyjs keeps a few keys of its own** in the same file, all starting with `__` (for example `__screens`, used to put windows back on screen after a display change). They show up in `all()`. Don't use keys starting with `__` for your own values. ## under an api gate {#gate} The four calls are `store.get`, `store.set`, `store.delete` and `store.all` to the [`api`](https://tinyjs.app/docs/config#api) gate in tinyjs.json, so `"store.*"` allows all of them. The `"wrapper"` preset includes them, which means a site you [wrap](https://tinyjs.app/docs/site-wrappers) can read and change every key — keep anything that site shouldn't see out of the store. <!-- https://tinyjs.app/docs/secrets --> # secrets & auth > tiny.app.secrets and tiny.app.authenticate — the OS keychain, not your JSON API tokens, refresh tokens and passwords belong in the operating system's credential store, which encrypts them and ties them to the user's login. `tiny.app.secrets` reads and writes it. Don't put them in [`tiny.store`](https://tinyjs.app/docs/store): that's a plain JSON file anyone with access to the account can read. `tiny.app.authenticate` asks the user to prove it's them — Touch ID, Windows Hello, or their account password — before you show or use something sensitive. In the backend these are `app.secrets` and `app.authenticate`, with the same calls. ## storing a secret {#secrets} ```js await tiny.app.secrets.set('api-token', token); const saved = await tiny.app.secrets.get('api-token'); // string, or null if (saved === null) showSignIn(); await tiny.app.secrets.delete('api-token'); // on sign-out ``` | call | resolves to | |---|---| | `secrets.get(key)` | the stored string, or `null` if nothing is saved under `key` | | `secrets.set(key, value)` | `true`. Replaces an existing value rather than adding a second one | | `secrets.delete(key)` | `true`, including when nothing was saved under `key` | Values are strings; anything else is converted with `String()`, so store an object as `JSON.stringify(obj)` and parse it on the way out. Secrets are filed under the app's [`id`](https://tinyjs.app/docs/config#id), so each app has its own and they survive updates and reinstalls. Where they go: | OS | store | filed as | |---|---|---| | macOS | the login Keychain | a generic password: service = app id, account = key | | Windows | Credential Manager | a generic credential named `<id>/<key>` | | Linux | the Secret Service over D-Bus (GNOME Keyring, for example) | an item in the default collection labelled `<id>/<key>` | ## when it fails {#errors} Unlike most `tiny` calls, the secrets calls **reject** when the credential store doesn't answer, with the reason as a string: - **Linux with no Secret Service running** (a minimal window manager, a container): every call rejects with `no secret service`. - **Windows**: Credential Manager holds at most 2560 bytes per value, counted as UTF-8. A larger `set` rejects and says so. (The macOS Keychain has no such small limit.) - Any other failure rejects with a short reason, such as `keychain error -25308` on macOS or `credential write failed (5)` on Windows. A missing key is not an error: `get` resolves `null`. ```js let token = null; try { token = await tiny.app.secrets.get('api-token'); } catch (msg) { // no keychain available: ask each time, or explain why sign-in won't stick } ``` `tiny.system.capabilities().secrets` is `true` on Linux and absent (meaning supported) elsewhere; it doesn't tell you whether a Secret Service is running, so catch the rejection. ## developing on macOS {#macos-dev} The Keychain grants access to the program that saved a value. A secret saved while running under `tinyjs dev` belongs to the dev launcher, so the built app asks the user for permission the first time it reads it — and a rebuild changes the app's signature, so it may ask again. Real users, who only ever run the built app, don't see this; you will while developing. ## asking the user to authenticate {#authenticate} `tiny.app.authenticate(reason?)` shows the system's own prompt and resolves `true` if the user passed it, `false` otherwise. It never rejects: cancelling, failing, and having no way to authenticate all resolve `false`. ```js async function revealPassword(entry) { if (!(await tiny.app.authenticate('show the password for ' + entry.name))) return; passwordField.textContent = await tiny.api.call('readPassword', { id: entry.id }); } ``` `reason` is shown in the prompt, and defaults to `'authenticate'`. macOS puts it after "*App* is trying to", so phrase it as the end of that sentence. | OS | prompt | |---|---| | macOS | Touch ID, or the account password where there's no Touch ID | | Windows | Windows Hello: fingerprint, face or PIN. Resolves `false` if Hello isn't set up | | Linux | none yet — always resolves `false` | Check before offering the option: ```js const caps = await tiny.system.capabilities(); if (caps.authenticate !== false) lockButton.hidden = false; ``` `authenticate` only answers yes or no; it doesn't unlock anything by itself. If you gate a secret behind it, do the check where the secret is read. A check in the page alone can be skipped by anything that can call the backend directly. ## under an api gate {#gate} The calls are `secrets.get`, `secrets.set`, `secrets.delete` and `app.authenticate` to the [`api`](https://tinyjs.app/docs/config#api) gate in tinyjs.json. The `"wrapper"` preset leaves them all off, so a site you [wrap](https://tinyjs.app/docs/site-wrappers) can't read the app's secrets unless you enable `secrets.*` for it. Keep tokens in the backend and use them there, as in [site wrappers](https://tinyjs.app/docs/site-wrappers), rather than handing them to the page. <!-- https://tinyjs.app/docs/permissions --> # permissions > tiny.app.permissions — check and ask before you need it Some features only work once the user has allowed them in the OS: posting keystrokes needs Accessibility, screenshots need Screen Recording, and so on. `tiny.app.permissions` lets you find out where you stand and ask at a moment you choose, so the app can say "needs Accessibility to paste — Open Settings" instead of failing silently the first time. This page is about the runtime API. The [`permissions`](https://tinyjs.app/docs/config#permissions) key in tinyjs.json is a separate thing: it declares the camera, microphone and location reasons shown in macOS's prompts, and on Linux whether pages may use the camera and microphone at all. See [camera and microphone](#media) below. The backend has the same calls on `app.permissions`. ```js const status = await tiny.app.permissions.check('accessibility'); if (status !== 'granted') showOnboarding(); ``` ## check and request {#check} ```js await tiny.app.permissions.check(name); // -> status, never prompts await tiny.app.permissions.request(name); // -> status, may prompt ``` Both resolve to one of: | status | | |---|---| | `'granted'` | allowed | | `'denied'` | refused, or not granted yet where the OS can't tell the difference | | `'undetermined'` | the user hasn't been asked yet | | `'unsupported'` | this name means nothing here (wrong OS, or a dev build where it needs a packaged app) | Neither rejects, so you can call them on any OS. An unknown name resolves `'unsupported'`. A typical onboarding step: ```js async function ensureAccessibility() { const p = tiny.app.permissions; if (await p.check('accessibility') === 'granted') return true; await p.request('accessibility'); // opens the system prompt / System Settings return false; // ask the user to come back after enabling it } ``` ## permission names {#names} | name | what it covers (macOS) | |---|---| | `'accessibility'` | posting keystrokes ([`keystroke`, `paste`](https://tinyjs.app/docs/keystrokes)), reading other apps' windows and selected text | | `'screen'` | screenshots and screen recording (`captureScreen`, `macos.recorder`) | | `'notifications'` | notification banners — see [notifications](https://tinyjs.app/docs/notifications#permission) | | `'microphone'`, `'camera'` | the system layer under `getUserMedia` | | `'location'` | Location Services; [`system.wifi()`](https://tinyjs.app/docs/system#wifi) needs it for `ssid` and `bssid` on macOS 14+ | | `'automation'` | sending Apple Events to System Events (`macos.applescript`) | | `'automation:<bundle-id>'` | sending Apple Events to that app, e.g. `'automation:com.apple.Music'` | ## per OS {#os} **macOS** asks the real system permission store (TCC), with these details: - `'accessibility'` reads only `'granted'` or `'denied'`. `request()` shows the system prompt, which sends the user to System Settings with your app listed. Once it has been denied it can't prompt again, so System Settings is the only way back. After the user ticks the box, the status may not change until the app is relaunched — re-checking straight away can still say `'denied'`. - `'screen'` never reads `'undetermined'`: macOS only offers a yes/no check for screen recording, so it's `'denied'` until granted in System Settings. - `'notifications'` is `'unsupported'` unless the app is a packaged `.app`. `request()` shows the notification prompt. - `'microphone'` and `'camera'`: `request()` prompts only while the status is `'undetermined'`. After a denial it resolves `'denied'` without asking; the user has to change it in System Settings. - `'location'`: `request()` prompts only while the status is `'undetermined'`, and only in a packaged app that declares `location` in [`permissions`](https://tinyjs.app/docs/config#permissions) — without the declaration macOS ignores the request, so it resolves `'undetermined'` with no prompt (and does so in `tinyjs dev` too). An app shows up under Location Services in System Settings only after it has asked. `'denied'` also covers Location Services being switched off for the whole Mac. - `'automation'` and `'automation:<bundle-id>'`: `request()` shows the consent dialog. If the check itself fails — a target that can't be found, for instance — the status is `'undetermined'`. **Windows** has no equivalent permission store. The five plain names resolve `'granted'`; `'location'` and `'automation'` names resolve `'unsupported'`. **Linux** has no equivalent either, and answers the same way: `'granted'` for the five plain names, `'unsupported'` for `'location'` and `'automation'` — except in a Wayland session, where `'accessibility'` and `'screen'` are `'unsupported'`, because keystrokes and screen capture don't work there. Otherwise `'granted'` only means there is no permission to get, not that the feature exists; check [`tiny.system.capabilities()`](https://tinyjs.app/docs/portability) for that. ## camera and microphone in pages {#media} `getUserMedia()` works in the app's own pages. Who gets asked depends on the OS and on which page is asking. **macOS**: in the app's own pages, the launcher answers WebKit's per-site prompt itself, so the user sees one system dialog naming your app. Any other origin — a wrapped site, a page it navigated to, a third-party iframe — gets WebKit's own prompt naming that origin, unless the [`api`](https://tinyjs.app/docs/config#api) gate's `origins` key allows `media.microphone` or `media.camera` for it: ```json { "api": { "origins": { "https://meet.example.com": ["media.*", "notify"] } } } ``` A packaged app must also declare why it wants each device in [`permissions`](https://tinyjs.app/docs/config#permissions): ```json { "permissions": { "microphone": "To record voice notes", "camera": "For video calls" } } ``` The strings become the text of the system dialog — macOS ends an app that tries to capture without one — and signed builds get the matching hardened-runtime entitlements. **Linux**: there is no system dialog to fall back on, so the launcher decides. A page gets the camera or microphone only if `permissions` declares that device **and** the page is one of the app's own (or its origin is allowed `media.camera` / `media.microphone` in `api` `origins`). Everything else is denied without a prompt. The check uses the main frame's URL, so an iframe inside an allowed page shares its grant. **Windows**: WebView2 shows its own permission prompt; the `permissions` key isn't needed. ## in development {#dev} In `tinyjs dev`, macOS grants attach to the shared tinyjs launcher rather than to your app: every app you run in dev shares them, and updating the launcher asks again. A packaged app has its own identity and its own grants. Features that need a packaged app — notifications from Notification Center, [audio filters](https://tinyjs.app/docs/audio#macos), the [audio tap](https://tinyjs.app/docs/audiotap#dev) — behave differently in dev too. ## in a site wrapper {#api-gate} The calls go over the wire as `perm.check` and `perm.request`, the names to use in the [`api`](https://tinyjs.app/docs/config#api) gate. The `"wrapper"` preset doesn't include them. <!-- https://tinyjs.app/docs/audio --> # audio filters > tiny.audio — an EQ on your app's own output `tiny.audio` runs a chain of filters — a graphic EQ, headphone correction, a crossover — on everything your app plays. On macOS and Linux the chain runs natively, below the webview, so it reaches audio the page never gets samples for (native HLS, a cross-origin `<audio>` element), and it keeps running across page reloads. Where the native chain doesn't exist (Windows), `tiny.audio.pageChain(ctx)` builds the same chain from Web Audio nodes, with the same calls. The backend has the same calls on `app.audio`. ```js await tiny.audio.filters([ { type: 'peaking', freq: 60, q: 1.1, gain: 4 }, { type: 'highshelf', freq: 8000, q: 0.7, gain: -2 }, ]); ``` ## choosing a backend {#choose} Ask `tiny.system.capabilities()` once, then use the same calls either way: ```js const can = await tiny.system.capabilities(); const eq = can.audioFilters ? tiny.audio : tiny.audio.pageChain(ctx); if (eq.input) { // page chain: route your audio through it source.connect(eq.input); eq.output.connect(ctx.destination); } await eq.filters(bands); // identical from here on ``` | | `audioFilters` | where it runs | |---|---|---| | macOS 14.2 and later | `true` | a Core Audio process tap in the launcher | | Linux | `true` | a PipeWire filter-chain | | Windows | `false` | use `pageChain` | Windows has no native chain on purpose. Filtering there means turning down the app's own session volume, and Windows saves that volume under a key every WebView2 app shares — a crash mid-filter would leave all of them nearly silent. Web Audio works well on Windows, so `pageChain` is the route. Calling `tiny.audio.filters()` on Windows resolves but does nothing. Don't use `pageChain` on Linux: any Web Audio graph that reaches `ctx.destination` crackles under WebKitGTK, which is why the native chain exists there. ## filters {#filters} Each filter is an object: | field | | |---|---| | `type` | one of the types below | | `freq` | centre or corner frequency in Hz (default 1000) | | `q` | quality factor (default 1) | | `gain` | dB for the biquad types (default 0, flat); a linear multiplier for `'gain'` (default 1, unchanged) | | `gainR` | the right channel's gain, when it differs from the left — native only | | type | | |---|---| | `peaking` | boost or cut around `freq` | | `lowshelf`, `highshelf` | boost or cut below / above `freq` | | `lowpass`, `highpass` | cut above / below `freq` | | `bandpass` | pass a band around `freq` | | `notch` | cut a narrow band at `freq` | | `allpass` | change phase only | | `gain` | multiply by `gain` — a preamp. `1` leaves the level alone, `0.5` is −6 dB | Filters run in the order given. All the biquads use the same RBJ cookbook formulas on every backend, so the same numbers give the same curve on macOS, Linux and in `pageChain`. ## setting the chain {#set} `filters(list)` sets the whole chain; `[]` removes it and restores unprocessed output. ```js await tiny.audio.filters([ { type: 'gain', gain: 0.8 }, // preamp { type: 'peaking', freq: 60, q: 1.1, gain: 4 }, { type: 'peaking', freq: 1000, q: 1, gain: -1.5 }, { type: 'highshelf', freq: 8000, q: 0.7, gain: -2 }, ]); ``` If the new list has the same types in the same order as the current one, the native chain retunes in place. If the shape changes — a filter added, removed or of a different type — it's rebuilt, which can leave a short gap. `filter(index, patch)` changes one filter in place. The patch merges onto what you set, so pass only what changed. This is what a slider should call: ```js slider.oninput = () => tiny.audio.filter(1, { gain: Number(slider.value) }); ``` A patch that changes `type` or `gainR` rebuilds the chain instead. An index past the end of the chain is ignored. ### limits {#limits} Long lists are cut short, without an error: - **Linux**: 28 filters, or 15 if any filter has `gainR`. PipeWire's filter-chain crashes past about 30 nodes, and a split left/right filter costs two. - **macOS**: 32 filters. ## balance {#balance} `balance(v)` pans the chain's output from `-1` (left) through `0` to `1` (right). It costs no filter slot and never rebuilds the chain. On the native backends it needs an active chain — a `filters()` call with at least one filter — so an app that wants balance with no EQ can keep one flat filter in place: ```js await tiny.audio.filters([{ type: 'gain', gain: 1 }]); await tiny.audio.balance(-0.3); ``` `gainR` is the other way to get a left/right difference, folded into a filter you already have — `{ type: 'gain', gain: 1, gainR: 0.4 }` is quieter on the right. It's native only. `clear()` removes every filter and centres the balance. ## the page chain {#pageChain} `tiny.audio.pageChain(ctx)` builds the chain from Web Audio nodes in an `AudioContext` you own and returns an object with `input` and `output` gain nodes plus the same four calls: ```js const ctx = new AudioContext(); const audio = document.querySelector('audio'); const eq = tiny.audio.pageChain(ctx); ctx.createMediaElementSource(audio).connect(eq.input); eq.output.connect(ctx.destination); eq.filters([{ type: 'lowshelf', freq: 120, gain: 3 }]); eq.filter(0, { gain: 5 }); // ramps over ~15 ms, no zipper noise eq.balance(0.2); eq.clear(); ``` Its calls return `true` synchronously rather than a promise; `await` works on both. How it differs from the native chain: - It filters only what you connect to `input`. Audio the page has no samples for — native HLS, a cross-origin `<audio>` without CORS — never reaches it. [`tiny.proxyURL`](https://tinyjs.app/docs/fetch) can bring a cross-origin stream into Web Audio. - Shelf filters ignore `q` (Web Audio's shelves have a fixed slope). - `gainR` is ignored; use `balance()`. - `filters()` always rebuilds the node graph, even with the same shape. Use `filter()` for live changes; it rebuilds only when you change a filter's `type`. - It lives in the page, so it goes when the page reloads. ## macOS {#macos} The native chain on macOS takes the app's audio off the speakers through a Core Audio process tap, filters it, and plays it back. That needs the "System Audio Recording" permission, which only a packaged app can be granted. Until it's granted — and always under `tinyjs dev` — the chain stays idle and audio plays unfiltered. It never goes silent: the tap mutes your app's audio only once filtered audio is actually flowing. Sounds from [`app.playSound`](https://tinyjs.app/docs/shell) play from the launcher itself and aren't filtered. ## Linux {#linux} The chain is a PipeWire filter-chain node named `tinyjs-eq-<pid>`, private to your app. It also filters the [sound effects mixer](https://tinyjs.app/docs/sampler). ## in the backend {#backend} `app.audio.filters()`, `filter()`, `balance()` and `clear()` work the same as the page's calls and drive the same chain, but return nothing rather than a promise. `pageChain` is page only. The old names `app.setAudioFilters()`, `app.setAudioFilter()` and `app.setAudioBalance()` still work and log a deprecation notice once. In an [`api`](https://tinyjs.app/docs/config#api) gate, the calls are `audio.filters`, `audio.filterSet` and `audio.balance`. <!-- https://tinyjs.app/docs/sampler --> # sound effects > tiny.audio.sampler — low-latency sound effects `tiny.audio.sampler` is one sound-effects mixer for the whole app. You load short sounds into it once, then fire them as often as you like, each with its own volume, pan and pitch. It's for game and interface sounds — clicks, chimes, footsteps. Music and long recordings belong in an `<audio>` element, and it isn't a sequencer: there's no scheduling a sound for an exact future time. It exists so sound effects work on Linux, where Web Audio crackles under WebKitGTK. There the launcher decodes and mixes the sounds natively, on PipeWire's real-time thread. On macOS and Windows it mixes with Web Audio in the main window's page, which is already real-time there. The calls and the sound are the same everywhere; `tiny.system.capabilities().sampler` says `'native'` or `'page'` if you're curious, but you don't need to branch on it. The mixer is app-wide: every window and the backend (`app.audio.sampler`) drive the same one, with the same sounds loaded. ```js const s = tiny.audio.sampler; await s.load('click', (await tiny.app.paths()).resources + '/click.wav'); s.play('click', { vol: 0.6 }); ``` ## loading sounds {#load} `load(name, source)` decodes a sound into the mixer under `name` and resolves `true` once it's ready to play. Loading the same name again replaces it. ```js const dir = (await tiny.app.paths()).resources; await Promise.all([ s.load('coin', dir + '/coin.wav'), s.load('jump', dir + '/jump.mp3'), ]); ``` `source` is either: - **an absolute file path** — the best choice: the file is read from disk where it's decoded, and its bytes never travel between processes. Ship sounds in [resources](https://tinyjs.app/docs/resources). - **an `ArrayBuffer`, typed array or `Blob`** — the bytes are written once to the app's cache folder and loaded from there. The backend accepts `ArrayBuffer` and typed arrays, not `Blob`. WAV, MP3 and FLAC are guaranteed to decode on every OS. Other formats depend on the platform: on Linux the launcher decodes, and handles exactly those three; on macOS and Windows the webview decodes, and usually handles more. `load()` rejects: - for a path that isn't an audio file — it must have an audio extension (`.wav`, `.mp3`, `.flac`, `.ogg`, `.m4a` and similar) and start like one; - when the sound can't be decoded; - on macOS and Windows, after 15 seconds with no answer from the main window's page. Decoded audio is what takes memory: about 375 KB per second of 48 kHz stereo. That's why this is for short sounds. `unload(name)` frees a sound when you're done with it. ## playing {#play} `play(name, opts)` starts a voice and resolves to a handle for it: ```js const v = await s.play('engine', { vol: 0.8, pan: -0.3, rate: 1.06, loop: true }); v.set({ rate: 1.2 }); // retune while it plays v.stop(); // short fade-out, no click ``` | option | default | | |---|---|---| | `vol` | `1` | linear gain; `1` is the sound as recorded (clamped to 0–8) | | `pan` | `0` | `-1` left to `1` right, equal-power | | `rate` | `1` | playback speed, which moves pitch with it: `2` is an octave up and twice as fast (clamped to 0.0625–16) | | `loop` | `false` | repeat until stopped | The pan law is the one Web Audio's `StereoPannerNode` uses, and the Linux mixer matches it, so the same numbers sound the same on every OS. `play()` rejects for a name that isn't loaded. It never rejects for "too many": the mixer plays up to 32 voices, and starting a 33rd fades out the oldest one. You don't need to keep the handle for a one-shot sound. The voice handle: | | | |---|---| | `v.id` | the voice's number | | `v.set({ vol, pan, rate })` | change any of them while it plays, no restart | | `v.stop()` | fade it out quickly | Calls on a voice that has finished do nothing. ## the whole mixer {#mixer} ```js s.master(0.5); // one gain over everything (linear, default 1, clamped to 0–8) s.stopAll(); // fade out every voice s.unload('coin'); // free its decoded audio; voices playing it stop at once, without a fade ``` ## per-OS notes {#os} - **macOS and Windows**: the mixer lives in the main window's page. If that page reloads, tinyjs loads the same sounds again by itself (and restores `master`), so later `play()` calls work without any code of yours — but voices sounding at the moment of the reload stop. - **Linux**: the launcher decodes, so sound effects don't depend on GStreamer plugins the way `<audio>` does. An active [filter chain](https://tinyjs.app/docs/audio) applies to the sampler like everything else the app plays. ## in the backend {#backend} `app.audio.sampler` drives the same mixer. `load()` and `play()` return promises; `set()`, `stop()`, `stopAll()`, `master()` and `unload()` return `true` immediately. The backend has one extra call, `app.audio.sampler.stop(id)`, which stops a voice by its id — useful when a page started it and told you the id. ```js // src/backend/main.js export async function init(app) { const dir = (await app.paths()).resources; await app.audio.sampler.load('ding', dir + '/ding.wav'); } export function onNotificationClick(id, app) { app.audio.sampler.play('ding'); } ``` In an [`api`](https://tinyjs.app/docs/config#api) gate, the calls are `sampler.load`, `sampler.play`, `sampler.set`, `sampler.stop`, `sampler.stopAll`, `sampler.master` and `sampler.unload`. <!-- https://tinyjs.app/docs/audiotap --> # audio tap > tiny.audioTap — read the audio being played, for meters and visualizers `tiny.audioTap` hands you the audio your app (or the whole system) is actually playing, as raw samples, in step with what the user hears. Use it for level meters, spectrum displays and visualizers — including for audio that never passes through Web Audio, such as native HLS, a cross-origin stream, or other apps. It only observes. To change the sound, the audio has to go through a [filter chain](https://tinyjs.app/docs/audio) or a Web Audio graph instead. The tap must be declared in tinyjs.json with [`audioTap`](https://tinyjs.app/docs/config#audioTap) — `"app"` for your own output, `"system"` for everything: ```json { "audioTap": "app" } ``` Only a page can start the tap. Once it runs, chunks reach every window and the backend's `onAudioTap(chunk, app)` export (see [hooks](https://tinyjs.app/docs/backend#hooks)). ```js await tiny.audioTap.start({ scope: 'app' }); tiny.audioTap.on((chunk) => updateMeter(chunk)); // decoding: see below ``` ## starting and stopping {#start} ```js try { await tiny.audioTap.start({ scope: 'app', interval: 80 }); } catch (e) { showMeterUnavailable(e.code); } ``` | option | default | | |---|---|---| | `scope` | `'app'` | `'app'`: this app's output. `'system'`: everything the computer plays | | `excludeSelf` | `false` | with `scope: 'system'`, leave this app's own output out (macOS only) | | `interval` | `80` | milliseconds of audio per chunk, clamped to 20–500 | `start()` resolves `true`, or rejects with an `Error` whose `code` is: | `code` | | |---|---| | `'not-declared'` | tinyjs.json has no `audioTap`, or asks for `"app"` while you started `scope: 'system'` | | `'unsupported'` | the OS can't tap: macOS older than 14.2, or Linux without `pw-cat` or `parec` | | `'failed'` | the tap couldn't be set up — for example a Core Audio error on macOS, or the capture tool failing to start on Linux | There is one tap per app. Calling `start()` again restarts it with the new options (on macOS, the same options again are a no-op). The tap stops when you call `tiny.audioTap.stop()` (from any window) or when the window that started it closes. Declaring `audioTap` asks for nothing by itself — permission is requested by the first `start()`. So you can wait until a meter first appears on screen before starting the tap. ## reading chunks {#chunks} `tiny.audioTap.on(fn)` calls `fn` with each chunk and returns a function that removes the handler. | field | | |---|---| | `pcm` | base64 of interleaved, little-endian, 16-bit signed samples | | `sampleRate` | samples per second, e.g. `48000` | | `channels` | interleaved channels, usually `2` | | `frames` | samples per channel in this chunk | | `t` | a monotonic time, in milliseconds, for the chunk's first sample | Decode a chunk into floats from −1 to 1: ```js function samples({ pcm }) { const bytes = Uint8Array.from(atob(pcm), (c) => c.charCodeAt(0)); const ints = new Int16Array(bytes.buffer); return Float32Array.from(ints, (v) => v / 32768); } tiny.audioTap.on((chunk) => { const s = samples(chunk); let peakL = 0, peakR = 0; for (let i = 0; i < s.length; i += chunk.channels) { peakL = Math.max(peakL, Math.abs(s[i])); peakR = Math.max(peakR, Math.abs(s[i + 1] ?? s[i])); } drawMeter(peakL, peakR); }); ``` Chunks keep arriving at the same pace while nothing plays; they're silent. In a page, the same chunks are the `'audio-tap'` event on `tiny.api.on`. ## per OS {#os} | | `scope: 'app'` | `scope: 'system'` | `excludeSelf` | permission prompt | |---|---|---|---|---| | macOS | the app's own audio | everything | yes | yes | | Windows | everything (the whole mix) | everything | ignored | no | | Linux | the app's own audio with PipeWire's tools; otherwise everything | everything | ignored | no | **macOS** uses Core Audio process taps. The first `start()` asks for "System Audio Recording" — even with `scope: 'app'`, because WebKit plays your page's audio from a separate helper process, so tapping it is capturing another process. The grant is remembered per app. Set the wording of the prompt with [`audioTapReason`](https://tinyjs.app/docs/config#audioTapReason). If the user refuses, `start()` still succeeds and the chunks are all zeros: macOS doesn't report the refusal. A meter that never moves while audio is playing is the sign. **Windows** captures the whole output mix with WASAPI loopback. There's no per-app capture yet, so `scope: 'app'` gets everything — fine for an app metering itself, since its audio is in the mix — and `excludeSelf` can't be honoured. **Linux** captures from PipeWire or PulseAudio. For `scope: 'app'`, it needs `pw-cli`, `pw-link` and `pw-cat`: it creates a private sink named `tinyjs-tap-<pid>`, copies your app's streams into it, and records that. Without those tools it records the whole mix instead. If the capture tool exits, the tap stops and chunks stop arriving. `tiny.system.requirements(['audioTap'])` reports whether a capture tool is installed and how to install one. ## in development {#dev} Under `tinyjs dev` on macOS, the process macOS treats as the owner of your app is the terminal you ran it from. The tap delivers real samples only if that terminal has the System Audio Recording grant; otherwise it delivers silence. `scope: 'app'` can also pick up other audio started from the same terminal. A built `.app` owns its grant and its audio. ## in the backend {#backend} The backend can't start or stop the tap, but it receives every chunk: ```js // src/backend/main.js export function onAudioTap({ pcm, sampleRate, channels, frames, t }, app) { // e.g. compute a level and push it to a tray-only window } ``` In an [`api`](https://tinyjs.app/docs/config#api) gate, the calls are `audioTap.start` and `audioTap.stop`. <!-- https://tinyjs.app/docs/media --> # media > tiny.app.nowPlaying, tiny.app.say — Now Playing and speech This page covers the calls a media app reaches for: showing what's playing in the system's media controls, receiving the media keys, and speaking text aloud. It ends with screen recording on macOS. Each has a backend twin under the same name on `app` — `app.nowPlaying`, `app.say()` — and media keys have a backend hook, `onMediaKey` (see [hooks](https://tinyjs.app/docs/backend#hooks)). Notifications are on their own page: [notifications](https://tinyjs.app/docs/notifications). ## Now Playing and media keys {#now-playing} `tiny.app.nowPlaying.set(info)` puts your track in the system's media controls and routes the media keys to your app; `onMediaKey(fn)` receives them. ```js await tiny.app.nowPlaying.set({ title: 'Blue in Green', artist: 'Miles Davis', album: 'Kind of Blue', duration: 337, elapsed: 12, playing: true, }); tiny.app.onMediaKey(({ command, time }) => { if (command === 'toggle') player.paused ? player.play() : player.pause(); if (command === 'play') player.play(); if (command === 'pause') player.pause(); if (command === 'next') nextTrack(); if (command === 'previous') previousTrack(); if (command === 'seek') player.currentTime = time; }); ``` | `info` field | | |---|---| | `title`, `artist`, `album` | strings | | `duration` | track length in seconds | | `elapsed` | position in seconds | | `playing` | playing or paused | Call `set()` again whenever any of it changes: a new track, play/pause, a seek. Pass `playing` every time — when it's left out, macOS shows paused and Linux shows playing. `tiny.app.nowPlaying.clear()` removes the entry and releases the keys. `onMediaKey(fn)` gets `{ command, time }`, where `command` is `'play'`, `'pause'`, `'toggle'`, `'next'`, `'previous'` or `'seek'`, and `time` is the seek target in seconds (only for `'seek'`). It returns a function that removes the handler. In a page, the same event is `'media-key'` on `tiny.api.on`. | | Now Playing | media keys | |---|---|---| | macOS | Control Center and the lock screen | the keyboard's media keys, headphone buttons, Control Center; `seek` from scrubbing there | | Linux | an MPRIS player named `org.mpris.MediaPlayer2.<app id>`, shown by the desktop's media controls | MPRIS commands; Stop arrives as `'pause'`, and a relative seek as `'seek'` with an absolute `time` | | Windows | not yet | not yet | On Windows, `capabilities().nowPlaying` and `capabilities().mediaKeys` are `false`, and `set()` does nothing. A playing `<audio>` or `<video>` element registers with Windows's media controls by itself, and play/pause works through it. For next and previous, register them with the web's own API, which works on every OS: ```js navigator.mediaSession.setActionHandler('nexttrack', nextTrack); navigator.mediaSession.setActionHandler('previoustrack', previousTrack); navigator.mediaSession.metadata = new MediaMetadata({ title, artist, album }); ``` The backend's `app.nowPlaying.set()` and `clear()` return `true` immediately. ## speech {#speech} `tiny.app.say(text, opts)` speaks with a system voice and resolves when it has finished speaking: ```js const voices = await tiny.app.voices(); const voice = voices.find((v) => v.lang.startsWith('en'))?.id; const done = await tiny.app.say('Export finished', { voice, rate: 0.5 }); // done: true if it finished, false if it was stopped (or speech is unavailable) tiny.app.stopSpeaking(); ``` | option | | |---|---| | `voice` | a voice `id` from `voices()`; on macOS and Linux also a language like `'en-AU'` | | `rate` | `0`–`1`, about `0.5` is normal speed | `voices()` resolves to `[{ id, name, lang, quality }]`, where `quality` is `'default'`, `'enhanced'` or `'premium'`. | | engine | notes | |---|---|---| | macOS | the system speech synthesizer | enhanced and premium voices need a download in System Settings ▸ Accessibility ▸ Spoken Content. A second `say()` queues behind the first. | | Windows | SAPI | `voice` must be an id from `voices()`. `lang` is Windows's own language code (e.g. `'409'` for US English), and every `quality` is `'default'`. A new `say()` cuts off the current one (which resolves `false`, as a stopped one does). | | Linux | speech-dispatcher (`spd-say`) | without it installed, `say()` resolves `false` and `voices()` resolves `[]`. `tiny.system.requirements(['speech'])` tells the user what to install. `quality` is always `'default'`. | The backend's `app.stopSpeaking()` returns `true` immediately; `say()` and `voices()` return promises. ## screen recording (macOS) {#recorder} `tiny.macos.recorder` records a display to an `.mp4` file. Video only — there is no audio track — and one recording at a time. It needs macOS 14 and the `'screen'` [permission](https://tinyjs.app/docs/permissions); on Windows and Linux it rejects (`capabilities().recorder` is `false`). ```js await tiny.macos.recorder.start({ path: '/tmp/demo.mp4' }); // screenId optional // … const { path, duration } = await tiny.macos.recorder.stop(); // the finished file ``` `start({ path, screenId })` resolves once recording is running, and rejects with the reason (no permission, no `path`, an older macOS). `screenId` is an `id` from `tiny.app.screens()`; without it, the first display macOS lists is recorded. `stop()` resolves to `{ path, duration }` (seconds) once the file is complete. See [macOS only](https://tinyjs.app/docs/macos-only) for the rest of `tiny.macos`. ## in a site wrapper {#api-gate} In an [`api`](https://tinyjs.app/docs/config#api) gate these calls are `nowplaying.set`, `nowplaying.clear`, `app.say`, `app.stopSpeaking`, `app.voices`, `macos.recorder.start` and `macos.recorder.stop`. The `"wrapper"` preset allows `nowplaying.*`. <!-- https://tinyjs.app/docs/shell --> # shell & app surface > `tiny.app.*` — open and trash files, launch at login, the Dock and taskbar The calls an app would otherwise shell out for (`open`, `explorer`, `xdg-open`), and the parts of the desktop the OS owns that your app decorates: the Dock on macOS, the taskbar on Windows, the dock or launcher on Linux. Every call here has the same name in the [backend](https://tinyjs.app/docs/backend#app): the page's `tiny.app.shell.open()` is `app.shell.open()`, `tiny.app.badge()` is `app.badge()`, and so on — the [API map](https://tinyjs.app/docs/api-map) lists both sides. Facts about the machine — theme, sleep and wake, idle time, battery, language, keeping the computer awake — are on [system](https://tinyjs.app/docs/system). Before calling anything that isn't on every OS, ask [`tiny.system.capabilities()`](https://tinyjs.app/docs/portability): a key that's `false` there won't work on this machine. ## opening, revealing, trashing {#shell} ```js await tiny.app.shell.open('https://tinyjs.app'); // the default browser await tiny.app.shell.open('/Users/me/report.pdf'); // the default app for the file await tiny.app.shell.reveal(path); // select it in Finder / Explorer / the file manager await tiny.app.shell.trash(path); // move it to the Trash / Recycle Bin ``` - `open(target)` takes a URL with any scheme (`https:`, `mailto:`, `myapp:`…) or a file path. Called from a page the app didn't ship — a [wrapped site](https://tinyjs.app/docs/site-wrappers#api-gate) — it opens only `http(s)` and `mailto` links, and rejects anything else. - `reveal(path)` opens the file's folder with the file selected. On Linux it asks the file manager over D-Bus, and opens the parent folder when the file manager doesn't answer. - `trash(path)` is recoverable. Use it instead of deleting the user's files. Each resolves `true`, or rejects with the reason as a string — `'no such file'`, `'no application registered for URL'`, or the OS's own message. On macOS, `~` in a path is expanded and `file://` URLs work as paths; on Linux, only a path starting with `/` counts as a file, so pass absolute paths. ## becoming the default app {#default-handler} ```js const r = await tiny.app.setAsDefaultHandler('md'); // or 'folder' // 'ok' | 'unsupported' | 'failed' ``` Makes this app the default opener for an extension, or for folders. Call it only when the user asks for it: an app that quietly takes over a file type gets uninstalled. It works on Linux only, in a built app, for an extension listed in [`fileExtensions`](https://tinyjs.app/docs/config#fileExtensions) (or `'folder'`, with [`openFolders`](https://tinyjs.app/docs/config#openFolders)): the app registers those file types on its first run, and this points them at it with `xdg-mime`. macOS and Windows answer `'unsupported'` — Windows deliberately doesn't let apps set this themselves, so send the user to Settings there. ## launch at login {#launch-at-login} ```js const status = await tiny.app.launchAtLogin.get(); // 'enabled' | 'disabled' | 'requires-approval' | 'unsupported' await tiny.app.launchAtLogin.set(true); // resolves the new status ``` | | how | `'unsupported'` when | |---|---|---| | macOS | a login item (SMAppService) | not a built `.app`, or macOS older than 13 | | Windows | an `HKCU\…\Run` registry value | not a built app | | Linux | an autostart `.desktop` file in `~/.config/autostart` | not a built app | Under `tinyjs dev` it always answers `'unsupported'`: there's no built app yet to start at login. `'requires-approval'` is macOS: the item is registered, but the user has to allow it in System Settings → General → Login Items. If registering fails outright, `set()` rejects with the reason. ## the Dock, taskbar and launcher {#app-surface} ```js tiny.app.badge('3'); // a count on the icon; '' clears it tiny.app.progress(0.4); // a progress bar on the icon, 0..1 tiny.app.progress(null); // remove it tiny.app.attention(); // get the user's attention tiny.app.attention({ critical: true }); tiny.app.icon('/abs/path/tile.png'); // replace the icon; '' restores the original tiny.app.presence('menubar'); // no Dock icon or taskbar button; 'normal' brings it back ``` These are fire-and-forget: they resolve once the request is sent, and where the OS has no way to show something, nothing happens and nothing rejects. `presence()` rejects a mode other than `'normal'` or `'menubar'`. `icon()` takes an absolute path to a png; a file that can't be read leaves the icon as it was. Render a canvas to a png to make a live icon — it combines with `badge()` and `progress()`. | | macOS | Windows | Linux | |---|---|---|---| | `badge` | Dock badge, any text | an overlay on the taskbar button: one or two characters; longer text shows as a dot | a count: numbers only, other text hides the badge | | `progress` | bar on the Dock icon | bar on the taskbar button | bar on the dock icon | | `attention()` | a short Dock bounce | flashes the taskbar button until the window comes to the front | the window's urgency hint | | `attention({ critical })` | bounces until the app is activated | flashes for longer | same as without | | `icon` | the Dock icon | the window icon (title bar, Alt-Tab) — not the taskbar button | the window icon | | `presence('menubar')` | no Dock icon, not in ⌘-Tab | no taskbar button | hidden from the taskbar | On Linux, `badge` and `progress` need a dock that implements the Unity launcher protocol — KDE Plasma, Ubuntu Dock and Dash to Dock do, plain GNOME Shell doesn't — and `icon`, `attention` and `presence` work under X11 only: GNOME on Wayland has nowhere to put them. `capabilities()` answers each of these (`badge`, `progress`, `icon`, `attention`, `presence`) for the current desktop. `presence('menubar')` suits an app that lives in the [tray](https://tinyjs.app/docs/tray); give it a tray icon or a [hotkey](https://tinyjs.app/docs/hotkeys) so the user can still reach it. ## the frontmost app {#frontmost} ```js const front = await tiny.app.frontmostApp(); // { name, bundleId, pid } | null ``` The app the user is in right now — the one focus goes back to after `tiny.win.hide()`, which is how a palette or clipboard app pastes into it (see [keystrokes](https://tinyjs.app/docs/keystrokes)). On Windows `name` is the program's file name without `.exe` and `bundleId` is `null`. On Linux it's always `null`. ## sounds {#sounds} ```js await tiny.app.beep(); // the system alert sound await tiny.app.playSound('success'); // 'info' | 'success' | 'alert' | 'error' await tiny.app.playSound('/abs/path/ding.wav'); ``` Both resolve `true`, or `false` if the sound couldn't be played; they never reject. The four names are portable: each maps to that OS's closest sound (Ping, Glass, Funk and Basso on macOS; the system sound aliases on Windows; the freedesktop sound theme on Linux). `playSound()` also takes an OS's own sound name — `'Glass'` on macOS, `'SystemHand'` on Windows, `'complete'` on Linux — but those don't carry across: `'Glass'` on Windows resolves `false`. For your own sound, pass an absolute path to an audio file. On Linux a file plays through `paplay`, `pw-play` or `aplay`, whichever is installed. ## about the app {#info} ```js await tiny.app.info(); // { version: '1.2.0', tinyjs: '0.62.1', runtime: 'txiki.js …' } ``` `version` is your [`version`](https://tinyjs.app/docs/config#version) from tinyjs.json, and `tinyjs` the tinyjs version that built the app (`'dev'` when it's run from a tinyjs checkout). Updating the app is `tiny.app.update` — see [auto-update](https://tinyjs.app/docs/auto-update). ### standard folders {#paths} ```js const p = await tiny.app.paths(); // { home, data, cache, logs, temp, downloads, desktop, documents, // resources, executables } ``` Use these instead of hardcoding `~/Library/…` or `%APPDATA%`: | | | |---|---| | `data`, `cache`, `logs` | this app's own folders, named after its [`id`](https://tinyjs.app/docs/config#id). They exist by the time `paths()` resolves. Keep a [sqlite](https://tinyjs.app/docs/sqlite) or settings files in `data`. | | `home`, `downloads`, `desktop`, `documents` | the user's folders | | `temp` | the system temp folder | | `resources` | your [resources](https://tinyjs.app/docs/resources) folder, or `null` without one | | `executables` | the folder of your [executables](https://tinyjs.app/docs/executables), or `null` if none shipped | ## language and region {#locale} The user's languages, region and time zone come from `tiny.system.locale()`, and changes arrive in the backend's `onLocale` hook — see [system](https://tinyjs.app/docs/system#locale). <!-- https://tinyjs.app/docs/services --> # system services > `tiny.app.pickColor` · `thumbnail` · `spotlight` · `captureScreen` Things the OS already does well — picking a colour off the screen, drawing a preview of a file, searching the user's files, taking a screenshot — handed to your app as one call each. They run on the machine, with nothing to install and no network. They aren't all on every OS yet, so check before showing the UI for one: ```js const caps = await tiny.system.capabilities(); if (caps.pickColor !== false) showEyedropperButton(); ``` A key is `false` where the call can't work, and absent where it can (see [portability](https://tinyjs.app/docs/portability)). The backend has the same calls under the same names — `app.pickColor()`, `app.thumbnail()` and so on. | | macOS | Windows | Linux | |---|---|---|---| | [`pickColor`](#pick-color) | yes | no | through the desktop portal | | [`thumbnail`](#thumbnail) | any file | any file | images only | | [`spotlight`](#spotlight) | Spotlight: names and contents | no | `plocate`/`locate`, or `find`: names only | | [`captureScreen`](#capture-screen) | macOS 14+, with permission | yes | X11 only | Where a call can't work it rejects with the reason (on Windows, `'unsupported on windows'`), rather than resolving an empty answer that looks like "nothing found". ## colour picker {#pick-color} ```js const color = await tiny.app.pickColor(); // '#rrggbb', or null if the user cancels ``` Shows the system eyedropper; the user can pick a pixel from any app on any screen. It needs no screen-recording permission. On macOS it's the native colour sampler; on Linux it's the desktop portal's colour picker, so the look depends on the desktop. Windows rejects for now. ## thumbnails {#thumbnail} ```js const { path, width, height } = await tiny.app.thumbnail('/Users/me/design.psd', 256); img.src = tiny.fileURL(path); ``` Makes a png preview of a file and resolves `{ path, width, height }`. The png is in the temp folder and belongs to you: copy it if you want to keep it. `size` (default 256) is the box the picture fits in, keeping its proportions. Read the real size off `width` and `height`, since it differs by OS: - **macOS** renders at 2× (up to 512 px for a size of 256). Any path works: a real preview for files Quick Look can render — images, video, PDF, source code — and the file, folder or app icon for anything else, so it never fails because of the file type. - **Windows** asks the shell, at exactly `size` pixels, for any file type it has a preview or an icon for. - **Linux** handles images only, at up to 2×; an image smaller than that comes back at its own size. Other files reject. A path that doesn't exist rejects on every OS. ## searching files {#spotlight} ```js const paths = await tiny.app.spotlight('invoice'); // up to 100 absolute paths ``` Finds files whose name contains the text, ignoring case. - **macOS** searches with Spotlight, in the user's home folder, and matches file contents as well as names. - **Linux** matches names and paths with `plocate` or `locate`, which search an index the system rebuilds periodically, so very new files may be missing. Without either, it falls back to `find` in the home folder: six folders deep, skipping hidden folders and `node_modules`, for at most four seconds. Ask `tiny.system.requirements(['spotlight.index'])` whether an index is installed, and how to install one (see [portability](https://tinyjs.app/docs/portability)). - **Windows** rejects for now. ## screenshots {#capture-screen} ```js const primary = (await tiny.app.screens()).find((s) => s.primary); const { path, width, height } = await tiny.app.captureScreen(primary.id); ``` Saves a png of a screen to the temp folder and resolves `{ path, width, height }` in pixels. The file is yours to move or delete. Take the id from [`tiny.app.screens()`](https://tinyjs.app/docs/window). Without one, it captures the first screen the OS lists, which isn't always the primary — pass the primary's id, as above, to be sure. - **macOS** needs macOS 14 and the `'screen'` permission (see [permissions](https://tinyjs.app/docs/permissions)); without them it rejects with the reason. The cursor isn't included. - **Windows** captures the screen with that id; no permission is needed. - **Linux** works under X11 only and captures the whole desktop — every screen together — whatever id you pass. Wayland rejects: there, only the desktop's own screenshot portal can capture, and it asks the user each time. To read the text in a screenshot, pass the path to [`tiny.macos.ocr()`](https://tinyjs.app/docs/macos-only#ocr). For video, see [`tiny.macos.recorder`](https://tinyjs.app/docs/macos-only#recorder). <!-- https://tinyjs.app/docs/system --> # system > `tiny.system.*` · `tiny.theme.*` · `tiny.app.power` — the machine your app runs on Facts about the computer rather than things your app does: which OS and desktop it is, light or dark mode, when it sleeps and wakes, how long the user has been away, the battery, Wi-Fi, and the user's language and time zone — plus keeping the computer awake while your app works. The backend has the same calls under the same names — `app.system.idleTime()`, `app.theme.get()`, `app.power.preventSleep()` — and gets changes through its [`onSystem` and `onLocale` hooks](#hooks) instead of page listeners. Asking what this machine supports (`capabilities()`), the CPU (`architecture()`) and missing Linux packages (`requirements()`, `missing()`, `promptMissing()`) are on [portability](https://tinyjs.app/docs/portability). ## which machine {#info} ```js tiny.system.os(); // 'macos' | 'windows' | 'linux' — synchronous tiny.system.isMacOS(); // also isWindows(), isLinux() await tiny.system.info(); // { os: 'linux', arch: 'x86_64', session: 'wayland', desktop: 'ubuntu:GNOME' } ``` `os()` and the `is…()` checks answer immediately, so they're safe while the page is setting up. `info()` also has the CPU (`'arm64'` or `'x86_64'`) and, on Linux only, the display server (`session`: `'x11'` or `'wayland'`) and the desktop (`desktop`, from `XDG_CURRENT_DESKTOP`). Both are `null` on macOS and Windows. ## light and dark {#theme} ```js const t = await tiny.theme.get(); // { dark: true } | null const off = tiny.theme.on((dark) => document.body.classList.toggle('dark', dark)); ``` `get()` is the system's current appearance — `null` only in the moment before the launcher first reports it at startup. `on(fn)` calls `fn(dark)` each time the user switches, and returns a function that stops listening. All three OSes report it: the appearance setting on macOS, the app theme setting on Windows, and the desktop's colour-scheme preference on Linux. ## sleep and wake {#sleep-wake} ```js tiny.api.on('sleep', () => pauseSync()); tiny.api.on('wake', () => resumeSync()); ``` The page gets `'sleep'` as the computer goes to sleep and `'wake'` when it comes back, on every OS. There's no `tiny.theme`-style helper; listen with [`tiny.api.on`](https://tinyjs.app/docs/api). Use `wake` to reconnect sockets and refresh anything time-based: timers don't run while the computer sleeps. ## keeping the computer awake {#power} ```js await tiny.app.power.preventSleep('Exporting video'); // the system stays awake await tiny.app.power.preventSleep('Presenting', { display: true }); // the screen stays on too await tiny.app.power.allowSleep(); ``` Stops the computer from going to sleep because nobody is using it, while a long export, download or playback runs. Both calls resolve `true` if the OS accepted the request and `false` if it didn't; they don't reject. Each `preventSleep()` replaces the previous one, so there's only ever one request, and `allowSleep()` releases it. The request ends with the app, even if it crashes, so a crashed app can't leave the computer awake — unlike spawning `caffeinate`. The reason string is shown where the OS lists what's keeping it awake: `pmset -g assertions` on macOS, `systemd-inhibit --list` on Linux. | | how | |---|---| | macOS | a power assertion against idle sleep (idle display sleep with `display: true`) | | Windows | `SetThreadExecutionState`, system (and display) required | | Linux | a logind inhibitor, which blocks suspend altogether, not only idle sleep; `display: true` also inhibits the screensaver | ## idle time {#idle} ```js if (await tiny.system.idleTime() > 300) pausePolling(); // seconds since the last input ``` Seconds since the user last touched the keyboard or mouse, anywhere on the computer. On Linux it's read from GNOME's idle monitor, so on other desktops it's always `0`. ## battery {#battery} ```js const b = await tiny.system.battery(); // { percent: 82, charging: false, plugged: false, minutesRemaining: 214 } | null ``` `null` on a computer without a battery. `minutesRemaining` is the time to full while charging and to empty otherwise, and `null` while the OS is still estimating — and always `null` on Linux. `percent` can be `null` on Windows and Linux when the OS doesn't report it. ## Wi-Fi {#wifi} ```js const w = await tiny.system.wifi(); // { ssid, bssid, rssi, noise, txRate } | null ``` The current Wi-Fi connection: signal (`rssi`) and `noise` in dBm, and `txRate` in Mbps. It's `null` when Wi-Fi is off. macOS only for now — Windows and Linux always answer `null`, and `capabilities().wifi` is `false` there. On macOS 14 and later, `ssid` and `bssid` are `null` unless the app has the Location permission. To get it, declare why in tinyjs.json and ask once — the prompt only appears in a packaged app: ```json { "permissions": { "location": "To show which Wi-Fi network you're on" } } ``` ```js if (await tiny.app.permissions.request('location') === 'granted') { const ssid = (await tiny.system.wifi())?.ssid; // wifi() is null with Wi-Fi off } ``` See [permissions](https://tinyjs.app/docs/permissions#names). ## language and region {#locale} ```js const loc = await tiny.system.locale(); // { language: 'en-AU', languages: ['en-AU', 'en'], system: ['en-AU', 'fr-FR'], // region: 'AU', timeZone: 'Australia/Melbourne' } | null tiny.api.on('locale', (loc) => relabel(loc.language)); // the user changed it ``` Read from the OS settings, not from environment variables like `LANG`. - `language` is the best match for what the app should show, and `languages` the user's preferences in order — limited to the languages the app itself declares. - `system` is the user's full list, whether or not the app speaks those languages. The two differ for an English-only app on a French Mac: `languages` is `['en']`, `system` starts with `'fr-FR'`. - `region` is the country code, or `null` if none is set; `timeZone` an IANA name. A page often doesn't need this: `navigator.language`, `Intl` and the window's `languagechange` event work in the webview. It's for the backend, which has no `Intl`, and for when you want the user's own list rather than the app's. macOS only for now: Windows and Linux answer `null`, and `capabilities().locale` is `false` there. ## backend hooks {#hooks} The backend hears the same changes through exports of its module (see [backend hooks](https://tinyjs.app/docs/backend#hooks)): ```js export function onSystem(kind, value, app) { if (kind === 'theme') console.log('dark mode:', value === 'dark'); if (kind === 'wake') reconnect(); } export function onLocale(info, app) { // info has the same shape as tiny.system.locale() } ``` | `kind` | `value` | when | |---|---|---| | `'theme'` | `'dark'` or `'light'` | the appearance changes | | `'sleep'` | `null` | the computer is going to sleep | | `'wake'` | `null` | it woke up | | `'screens'` | `null` | a screen was connected, removed or rearranged — read the new list with `app.screens()` (see [window](https://tinyjs.app/docs/window)) | `onLocale` fires on macOS only. <!-- https://tinyjs.app/docs/macos-only --> # macOS only > `tiny.macos.*` — AppleScript, Quick Look, OCR, screen recording, on-device AI, other apps' windows Some macOS features have no counterpart on Windows or Linux at all — AppleScript, Quick Look, Apple's on-device model. They live in `tiny.macos.*` so that it's plain from the call itself that it's macOS-only. Anything that could exist on another OS but isn't built there yet stays on `tiny.app` and reports itself through [`capabilities()`](https://tinyjs.app/docs/portability) instead — Spotlight search and the colour picker are on [system services](https://tinyjs.app/docs/services), Touch ID on [secrets](https://tinyjs.app/docs/secrets). Off macOS, every `tiny.macos` call rejects — except `ai.availability()`, which answers `'unsupported'`, since it's the check you make first. The reason names the fix: ```text tiny.macos.quickLook is macOS-only (this is windows) — guard with tiny.system.isMacOS() ``` So guard the call, or the UI that leads to it: ```js if (tiny.system.isMacOS()) previewButton.hidden = false; ``` The backend has the same calls as `app.macos.*`; off macOS they throw the same error, naming `app.macos`. `capabilities()` reports `applescript`, `quickLook`, `ocr`, `recorder`, `ai`, `selectedText`, `otherWindows` and `moveOtherWindows` as `false` on Windows and Linux. ## AppleScript {#applescript} ```js await tiny.macos.applescript('tell application "Music" to playpause'); const title = await tiny.macos.applescript( 'tell application "Safari" to get name of front document'); // string | null ``` Runs AppleScript inside the app, without spawning `osascript`. It resolves the script's result as a string, or `null` when the result isn't text, and rejects with AppleScript's error message. Scripting another app needs the user's permission to control it — macOS asks the first time. Check or ask ahead with the `'automation'` or `'automation:<bundle-id>'` [permission](https://tinyjs.app/docs/permissions). Scripts run on the main thread, so a slow one holds up the window until it finishes. ## Quick Look {#quick-look} ```js tiny.macos.quickLook('/Users/me/photo.heic'); tiny.macos.quickLook([a, b, c]); // the arrow keys move between them tiny.macos.quickLook(); // close the panel ``` The preview panel Finder shows on the space bar, for one path or a list. Called with nothing (or `null`), it closes the panel if it's open. ## text recognition {#ocr} ```js const { text, blocks } = await tiny.macos.ocr('/Users/me/scan.png'); // blocks: [{ text, confidence, box: { x, y, width, height } }] ``` Reads the text in an image with Apple's Vision framework, on the device, in its accurate mode with language correction. `text` is every line joined with newlines. Each block's `confidence` runs 0 to 1, and its `box` is in fractions of the image (0 to 1), measured from the top-left. It rejects if the image can't be read. Together with [`tiny.app.captureScreen()`](https://tinyjs.app/docs/services#capture-screen) it turns a screenshot into text. ## screen recording {#recorder} ```js await tiny.macos.recorder.start({ path: '/Users/me/Movies/demo.mp4' }); // screenId optional // … const { path, duration } = await tiny.macos.recorder.stop(); // duration in seconds ``` Records a screen to an H.264 `.mp4`. `start()` resolves once recording has begun; `stop()` resolves when the file is finished. `screenId` comes from `tiny.app.screens()`, and without one the first screen is recorded. It needs macOS 14 and the `'screen'` [permission](https://tinyjs.app/docs/permissions), and rejects otherwise. It records video only, no sound, and one recording at a time: a second `start()` rejects with `'already recording'`, and `stop()` without a recording rejects with `'not recording'`. `start()` without a `path` rejects too. ## on-device AI {#ai} ```js if (await tiny.macos.ai.availability() === 'available') { const summary = await tiny.macos.ai.generate('Summarise: ' + text, { instructions: 'Answer in one sentence.', }); } ``` Apple's on-device language model (Foundation Models): it runs offline, with no API key, and nothing leaves the computer. Always check `availability()` first: | answer | meaning | |---|---| | `'available'` | ready | | `'unavailable'` | Apple Intelligence is off, or the model hasn't finished downloading | | `'unsupported'` | older than macOS 26, a tinyjs build without the model support compiled in, or not macOS | `generate(prompt, { instructions })` resolves the reply as a string; `instructions` is the system prompt. It rejects with the reason, including `'not built in'` from a build without model support. `capabilities().ai` is `true` only when the build has it. ### tool calling {#ai-tools} From the backend, `generate()` also takes `tools`: functions the model may call while it answers. A tool's `run` is your own function, which can't cross from a page to the backend, so this is backend-only — a page that passes `tools` gets a console warning and a plain reply. ```js const { text, calls } = await app.macos.ai.generate('Move the window to 200, 120', { instructions: 'You control a desktop app window. Use the tools.', tools: [{ name: 'moveWindow', description: 'Move the window on screen.', parameters: { x: { type: 'integer', description: 'x in points' }, y: 'integer', }, run: ({ x, y }) => { app.win.setPosition(x, y); return 'moved'; }, }], }); // calls: [{ name: 'moveWindow', args: { x: 200, y: 120 }, result: 'moved' }] ``` With `tools`, it resolves `{ text, calls }` instead of a string. A parameter's type is `'string'`, `'integer'`, `'number'` or `'boolean'`, written alone or with a `description`. Whatever `run` returns goes back to the model (objects as JSON). If `run` throws, the model gets `{ error: message }` and carries on. **Read `calls`, not `text`.** `calls` is what the model actually did. Measured on macOS 26.5: asked for three tool calls in one turn, the model made all three in one run out of four — and its reply claimed all three every time, including the runs where it skipped one. It also passed a wrong argument once. Put anything that can't be undone behind a confirmation. ## other apps {#other-apps} ```js const sel = await tiny.macos.selectedText(); // string | null const wins = await tiny.macos.otherWindows(); // [...] | null await tiny.macos.moveWindow(wins[0].pid, { x: 0, y: 0, width: 1280, height: 800 }); ``` For PopClip-style popovers and window managers like Rectangle. All three need the `'accessibility'` [permission](https://tinyjs.app/docs/permissions). - `selectedText()` is the text selected in the frontmost app, or `null` when nothing is selected or the app doesn't expose it. - `otherWindows()` lists other apps' windows on screen: `{ app, bundleId, pid, title, index, x, y, width, height }`, in the same top-left coordinates as [`tiny.win.setPosition`](https://tinyjs.app/docs/window). It's `null` without the permission. - `moveWindow(pid, rect)` moves and resizes that app's main window. It resolves `true`, or rejects with `'needs Accessibility'` or `'no movable window'`. The pid can also come from [`frontmostApp()`](https://tinyjs.app/docs/shell#frontmost). ## share sheet {#share} The macOS share menu (AirDrop, Messages, Mail…) is `tiny.win.share()`, on `tiny.win` because it belongs to a window — see [window](https://tinyjs.app/docs/window#share). <!-- https://tinyjs.app/docs/site-wrappers --> # site wrappers > a hosted web app as a desktop app A site wrapper is an app whose main window shows a hosted site, such as `https://app.example.com`, instead of a frontend you ship. You get the site's own UI, a Dock or taskbar icon, menus, notifications and a backend, and you don't have to maintain a copy of the site. A hosted page needs things a local page never did: native `alert`/`confirm`/`prompt`, downloads that land on disk, a say in where links and popups go, and find-in-page. tinyjs handles all of these on all three platforms. The one thing you must add yourself is a [gate](#api-gate) on what the site's JavaScript may call. ## quick start {#quick-start} ```sh tinyjs wrap https://app.example.com cd app-example-com && tinyjs dev ``` [`tinyjs wrap`](https://tinyjs.app/docs/cli#wrap) fetches the site's title and icon and writes a project: `url`, an `api` gate, `"popups": "window"`, `"downloads": "ask"`, and a small backend that logs navigations and downloads to the terminal. Read the [gate it writes](#what-wrap-writes) before you ship it. By hand, the minimum is: ```json { "name": "example", "url": "https://app.example.com", "api": "none" } ``` ## loading the site {#url} [`url`](https://tinyjs.app/docs/config#url) makes the main window load that page. No `src/frontend/` is needed, and it can't be combined with `"mainWindow": false`. `app.reload()` reloads the live page. Use `url`, not `frontend.devUrl`. `devUrl` exists only for [`tinyjs dev`](https://tinyjs.app/docs/cli#dev) and its dev server. A build ignores it and ships your built frontend instead. Two more keys adjust the site: - [`userAgent`](https://tinyjs.app/docs/config#userAgent) replaces the webview's user agent, for sites that sniff it. On Windows it's applied to the main window only. Popups and other windows keep WebView2's own. - [`inject`](https://tinyjs.app/docs/config#inject) is a script that runs at document start in the top frame of every page, after `tiny` is set up and before the site's own scripts. Use it to add a find bar, hide a banner or patch a missing API. It runs as part of the site's page and has no extra rights: its `tiny` calls are gated as the site's origin, and the site's code can read or undo anything it does. Cookies, `localStorage` and IndexedDB live in the webview's own store, shared by every window of the app. On Windows each app gets its own WebView2 profile in `%APPDATA%\<id>\WebView2`, and the dev and built copies share it. See [cookies](#cookies) for reading and setting the jar from the backend. ## the api gate {#api-gate} `tiny` is put into every page the app loads, including a wrapped site. Without a gate, the site's JavaScript (and every script it loads: analytics, ads, a dependency someone compromised) can call anything your app can, from `tiny.store.all()` to your own `api` functions. The [`api`](https://tinyjs.app/docs/config#api) key in tinyjs.json limits that. The gate runs in the backend, on every call, before anything else happens. A page can rewrite its own `tiny` object, so checks in the page don't protect anything. The gate goes by the calling frame's origin as the webview engine reports it, never by anything the page sends. The launcher adds that origin to every call itself. ### how a call is decided {#gate-rules} | `api` | a call is allowed when | |---|---| | omitted, `"all"` | always. Fine for an app that only shows its own pages | | `"none"` | never (only `tiny`'s own start-up call gets through) | | `"wrapper"` | it's in the [wrapper preset](#wrapper-preset) | | `["notify", "store.*"]` | it's in the list | | `{ "disable": […], "enable": […] }` | it's in `enable`, or it isn't in `disable`. `enable` wins | | `{ "preset": "wrapper", "enable": […] }` | it's in the preset or in `enable` | | `{ …, "origins": { … } }` | the first key that matches the caller's origin decides. An origin that matches no key falls back to the top-level `enable`/`disable` lists, or gets nothing when there are none | Each value in `origins` takes any of the forms above: `"all"`, `"none"`, `"wrapper"`, a list, or an object. ```json { "api": { "disable": ["*"], "enable": ["win.close", "win.minimize"], "origins": { "https://app.example.com": ["notify", "app.badge", "store.get", "store.set"], "file://*": "all" } } } ``` Here the wrapped site can notify, badge and use two store calls. Your own `file://` pages can call anything. Any other origin the window lands on (a sign-in page, a redirect, a link) can only close or minimize the window. **Method names** are what the call is named on the wire, as the [API map](https://tinyjs.app/docs/api-map) lists them: `notify`, `win.setTitle`, `store.get`, and your own `api` functions by their plain name. `store.*` covers every call starting with `store.`. `*` on its own covers everything. **Origin keys** are `scheme://host[:port]`, with no path and no trailing slash. A default port is left out, and your own pages are `file://`. `*` matches any run of characters, dots and colons included, and the key has to match the whole origin: | key | matches | doesn't match | |---|---|---| | `https://app.example.com` | exactly that | `https://app.example.com:8443`, `http://app.example.com` | | `https://*.example.com` | every subdomain, at any depth | `https://example.com` (add it as its own key) | | `https://app.example.com*` | **also `https://app.example.com.evil.net`** | — | Write the host out in full, and give each port its own key. ### mistakes to avoid {#gate-mistakes} - **You can't take calls out of a preset.** `enable` wins over `disable`, so `{ "preset": "wrapper", "disable": ["shell.open"] }` still allows `shell.open`. To drop something from the preset, list what you want instead. - **A string preset applies to every origin.** `"api": "wrapper"` with no `origins` gives the preset to wherever the window ends up, including any site the wrapped one links or redirects to. Use `origins` to name the site. Values that can't mean what they seem fail closed, and log a message saying so: a misspelt preset name, `false` or `""` (treated as `"none"`), or anything that isn't a string, list or object. An `enable` list on its own means "only these" — `{ "enable": ["notify"] }` is the same as `["notify"]`, at the top level and inside `origins`. ### the wrapper preset {#wrapper-preset} `"wrapper"` is the only preset. It allows exactly these calls: | | | |---|---| | window and app chrome | `win.*`, `menu.*`, `tray.*`, `app.badge`, `app.attention`, `app.progress` | | dialogs and notifications | `dialog.*`, `notify` | | storage | `store.*` | | system | `shell.open`, `clip.write`, `sound.play`, `nowplaying.*`, `power.prevent`, `power.allow` | | read-only | `theme.get`, `system.locale`, `system.capabilities`, `system.requirements`, `system.info`, `app.info` | Everything else is denied, for example `tiny.fetch`, reading the clipboard, secrets, screen capture, global hotkeys, permission requests, `shell.reveal` and `shell.trash`, and **your own `api` functions**. Add those by name with `{ "preset": "wrapper", "enable": ["saveDraft"] }`. `shell.open` is in the preset, but a page the app didn't ship — the wrapped site, or anywhere it navigates — can only open `http(s)` and `mailto` links with it; a local path, a `file://` URL or any other scheme is refused. Your own pages (`file://`, the dev server) can open anything. The preset is still broad. `win.*` includes dragging and sharing local files out of the app, and `store.*` includes reading and overwriting the whole store. For a site you don't control, prefer a [short explicit list](#securing). ### camera, microphone and the media proxy {#media} Camera and microphone access in the page never goes through the bridge, so the gate treats them as three extra names that only count inside `origins`: `media.camera`, `media.microphone` and `media.proxy`. - On **macOS**, an origin whose key allows `media.camera` or `media.microphone` gets the device without WebKit's per-site prompt. Other origins see the prompt. - On **Linux**, where there's no prompt, only those origins (and your own pages) get the device at all, and only if [`permissions`](https://tinyjs.app/docs/config#permissions) declares it. - On **Windows**, these names are ignored. WebView2 always shows its own prompt. - `media.proxy` lets the origin use `tiny.proxyURL()`. Presets and top-level lists never grant these. An origin whose value is `"all"` does. See [permissions](https://tinyjs.app/docs/permissions#media) for the rest. ### events {#events} Events the backend sends to pages — `app.push()` and tinyjs's own — go through the gate too. The app's own pages (`file://`, the dev server) get every event; a page from any other origin gets one only if its rules allow the event's name: | event | allowed by | |---|---| | `menu`, `contextmenu` | `menu.*` | | `tray`, `trayclick` | `tray.*` | | `window-state`, `window-closed`, `screens-changed` | `win.*` | | `theme` | `theme.get` | | `locale` | `system.locale` | | `media-key` | `nowplaying.*` | | `notification-click`, `notification-action` | `notify` | | `clipboard-change` | `clip.read` | | `hotkey` | `hotkey.*` | | everything else — `open-url`, `open-files`, `drop`, `download`, `navigate`, `popup`, `audio-tap`, `update-available`, and every event your app pushes | `event.<name>`, granted by name | So the `"wrapper"` preset hears menu, tray, window, theme, locale, Now Playing and notification events, but not the ones that carry local data — a deep link's OAuth code, dropped or downloaded file paths, audio. To push one of your own events to a site, grant it: `"enable": ["event.refresh"]`. Each event is checked against the page in the window when it's delivered, so a window that has navigated elsewhere since doesn't receive it. Without an `"api"` block every page gets every event, as before. ### iframes {#iframes} `tiny` is meant for the top frame, but a script in an iframe can still try to call the bridge by hand. On macOS such a call is allowed only when an `origins` key matches the iframe's own origin, and then under that key's rules. Top-level lists, presets and a missing `api` count for nothing there. On Linux and Windows, calls from iframes never reach the backend. ### checking from the page {#capabilities} Every `tiny` function exists on a wrapped site whether or not the gate lets it through, so `typeof` tells the site nothing. [`tiny.capabilities`](https://tinyjs.app/docs/portability#capabilities) answers for the site's own origin, and works under any gate, even `"none"`, since it can't tell a page more than calling would: ```js await tiny.capabilities.ready; if (tiny.capabilities.can('notify')) showNotifyToggle(); tiny.capabilities.get('clipboard.read'); // { ok: false, why: 'gate', needs: ['clip.read'] } — what to add to "enable" ``` A denied call rejects with `"<name>" is disabled by tinyjs.json "api"`. Run `TINYJS_DEBUG=1 tinyjs dev` to log each denial with the origin it came from, which is the quickest way to find what a site needs. ### your own api functions {#own-api} Every `api` function gets the caller's origin as its third argument. Once you've enabled a function for a wrapped site, check the origin inside it too, and treat its params as input from that site: ```js export const api = { saveDraft: async ({ text }, app, { origin }) => { if (origin !== 'https://app.example.com') throw new Error('not allowed'); if (typeof text !== 'string' || text.length > 100_000) throw new Error('bad draft'); await app.store.set('draft', text); return true; }, }; ``` ## securing a wrapper {#securing} The site's JavaScript runs with whatever its origin is allowed. So does any script it loads, and so does anyone who finds an XSS bug in it. You decide how much that is. Some other paths into your app have no gate at all. - **Events aren't gated.** `app.push()` and every built-in event (a deep link, the paths of opened or downloaded files, navigations in other windows, notification clicks) reach every window's page, the wrapped site included. Don't push anything you wouldn't hand the site. - **`inject` has no extra rights.** It runs in the site's page, with the site's origin. - **The window can leave the site.** Links and redirects take the main frame to other origins. Each is gated as itself: under `origins`, an unlisted one gets only the top-level lists. - **Opening things is a capability.** An `'external'` answer from [`onNavigate`](#navigation) hands a site-chosen URL to the OS. `shell.open` from a site is limited to `http(s)` and `mailto` links. A good starting config for a site you don't control: ```json { "url": "https://app.example.com", "popups": "window", "downloads": "ask", "api": { "disable": ["*"], "enable": ["win.close", "win.minimize", "win.zoom", "win.startDrag"], "origins": { "https://app.example.com": [ "notify", "app.badge", "win.find", "win.stopFind", "win.setTitle", "system.capabilities" ] } } } ``` - The site gets notifications, a badge, find-in-page and its own title. No store, no `shell.open`, none of your functions. - Every other origin gets window controls only, so a frameless window can still be closed and dragged. - Add calls one at a time, by name, as you find you need them. Prefer a function of your own that does one specific thing over enabling a whole namespace. - Keep tokens in [secrets](https://tinyjs.app/docs/secrets), which the gate never opens to a page, rather than in the store. - If the site loads in an iframe of your own page instead, give your page's `file://` its own key. ### what `tinyjs wrap` writes {#what-wrap-writes} `tinyjs wrap` gives the site's origin (or, with `--origins`, its subdomains or a list) the whole `"wrapper"` preset, and every other origin `win.close`, `win.minimize`, `win.zoom` and `win.startDrag`. That's convenient for trying a site out. For a site you don't run, swap the preset for an explicit list as above. ## navigation {#navigation} Export `onNavigate` from the backend to follow every page load, and to stop the ones you don't want. ```js // src/backend/main.js const HOME = 'https://app.example.com'; export function onNavigate(info, app) { if (info.kind === 'policy') { const url = new URL(info.url); if (url.protocol === 'mailto:') return 'external'; // open the mail app if (/^https?:$/.test(url.protocol) && url.origin !== HOME) return 'external'; return; // allow } if (info.kind === 'fail') app.push('offline', { url: info.url, error: info.error }); } ``` `info` is `{ window, kind, url, isMainFrame?, error? }`: | `kind` | when | your return value | |---|---|---| | `policy` | before a main-frame `http(s)` navigation, in any window | `'deny'` blocks it, `'external'` opens it in the default browser, anything else allows | | `policy` | before a navigation to any other scheme (`mailto:`, `tel:`, `zoommtg:`…), in any frame | `'external'` hands it to the OS; anything else blocks it | | `start`, `commit`, `finish` | the load's progress | ignored | | `fail` | the load failed; `error` says why | ignored | | `crash` | the page's web process died | ignored | - **Answer fast.** An `http(s)` ask with no answer after about 400 ms is allowed, so a slow or missing handler can't stop the site from loading. An ask for another scheme with no answer is blocked. - `file://` pages and iframe navigations to `http(s)` aren't asked. - `isMainFrame` is `true` for `http(s)` asks. For other schemes it's `true` or `false` on macOS and `null` on Linux and Windows, which can't tell. - The page gets the same events (except `policy`) as `tiny.api.on('navigate', fn)`. Only return `'external'` for schemes you know. A site can name any URL, and on Windows some registered schemes start programs. What a block can and can't do depends on the engine: | | | |---|---| | macOS | the navigation is held until you answer. A block means no request is made | | Windows | the navigation is cancelled and, if allowed, started again. A form POST that's asked comes back as a GET. Back and forward aren't asked. A page's `beforeunload` prompt can show twice. Other schemes are only asked about when some app on the PC is registered for them | | Linux | the ask comes once the server has answered. A block stops the page showing, but the request was already made | Treat a block as "don't show this page", not as a way to stop requests reaching a server. ## popups {#popups} [`popups`](https://tinyjs.app/docs/config#popups) decides what `window.open` and `target="_blank"` do: | | | |---|---| | `"external"` (default) | `http(s)` URLs open in the default browser | | `"window"` | a new app window. `window.opener`, `postMessage` and `window.open`'s return value work, which sign-in popups need | | `"deny"` | nothing opens | Export `onWindowOpen` to decide each popup yourself: ```js export function onWindowOpen(info, app) { if (info.kind !== 'policy') return; // 'open' reports the outcome const host = new URL(info.url).hostname; if (host === 'accounts.example.com') return 'window'; return 'external'; } ``` The `policy` call comes before anything appears: `{ kind: 'policy', window, opener, url, mode }`, where `mode` is the configured `popups` value and `opener` is the id of the window that asked. Return `'window'`, `'external'` or `'deny'`. Anything else, or no answer within about 400 ms, keeps the configured mode. - In `"window"` mode the popup already exists, hidden and loading, under `info.window` while you decide. - In the other modes there's no window to show, so only `'external'` and `'deny'` work. To be able to answer `'window'`, set `"popups": "window"` and send the rest elsewhere from the hook. - A `kind: 'open'` call follows with `action` (`'window'`, `'external'` or `'deny'`). The page gets the same as `tiny.api.on('popup', fn)`. - Popup windows are named `popup1`, `popup2`… Close one from the backend with `app.window(id).close()`. They take the page's title and have standard window chrome; on macOS and Linux they also use the size and position passed to `window.open`. - Popup and navigation policy both apply. A popup you allow whose URL `onNavigate` then blocks shows as an empty window. A popup's page gets its own `tiny`, gated on its own origin, not its opener's. On **Windows**, calls from a popup's page currently never return, and the page's own `window.close()` does nothing. Close Windows popups from the backend. ### sign-in {#sign-in} Most sign-in flows work in one of two ways, and both work in a wrapper: - **A popup** that posts back to its opener needs `"popups": "window"`. - **A redirect** of the main window through the identity provider and back needs nothing special. While the window is on the provider's origin, the gate treats it as any other unlisted origin. The cookies the site sets go in the app's own jar (see [cookies](#cookies)). Some providers refuse to sign in inside an embedded webview at all. [`userAgent`](https://tinyjs.app/docs/config#userAgent) can help with a site that only sniffs the user agent. ## downloads {#downloads} `<a download>`, blob URLs and any response the webview can't display become downloads. [`downloads`](https://tinyjs.app/docs/config#downloads) sets what happens: | | | |---|---| | `"auto"` (default) | saved to the user's Downloads folder; a name that's taken gets ` (2)`, ` (3)`… | | `"ask"` | a save dialog, pre-filled with the file's name | | `"deny"` | refused | The backend's `onDownload(info, app)` and the page's `tiny.api.on('download', fn)` report each one. They can't change where it goes: ```js export function onDownload(info, app) { // { id, url, filename, path, state, bytes?, total?, error? } if (info.state === 'done') app.notify('Downloaded', info.filename, { id: 'dl-' + info.id }); } ``` `state` is `started`, `progress` (with `bytes` and `total`, about four times a second; `total` is `-1` when the size isn't known), `done`, `failed`, `denied` or `cancelled`. `path` is where the file landed, `null` until known. `filename` matches the file on disk. The Downloads folder is the system's: the user's Downloads on macOS, the Downloads known folder on Windows (even when it's redirected), and the XDG download directory on Linux. On a Windows PC whose WebView2 runtime is too old for this, downloads use WebView2's own UI. ## JS dialogs {#js-dialogs} `alert()`, `confirm()` and `prompt()` in the site show native dialogs titled with the page's origin (its host, on Linux), and return what the user chose. On Windows a page's `beforeunload` warning shows as a confirm box too. These need no `api` entry. They're the page's own dialogs, not calls to tinyjs. ## find in page {#find} [`tiny.win.find()`](https://tinyjs.app/docs/window#find) searches the page and steps through the matches. It's the search behind a ⌘F bar, and you draw the bar. In a wrapper, that means drawing it with `inject`: ```js // inject.js — runs in the wrapped site's page tiny.menu.on(async (id) => { if (id !== 'find') return; const term = prompt('Find'); if (term) await tiny.win.find(term); }); ``` ```js // backend: put a Find item with a shortcut in the menu bar export async function init(app) { app.menu.set([{ title: 'Edit', items: [{ id: 'find', label: 'Find…', key: 'f' }] }]); } ``` The site's origin needs `win.find` (and `win.stopFind`) in its gate. On Windows, WebView2's own Ctrl+F is off unless you turn on [`browserAccelerators`](https://tinyjs.app/docs/config#browserAccelerators). ## cookies {#cookies} A wrapped site's sign-in lives in the webview's cookie jar, which the backend can read and change — to keep a session, or sign out. See [cookies](https://tinyjs.app/docs/cookies). <!-- https://tinyjs.app/docs/deep-links --> # deep links & files > myapp:// links, file associations, single instance An app can claim a URL scheme, so a `myapp://…` link anywhere on the system opens it, and file types, so it shows up under "Open With". Links and files reach the backend through the `onOpenUrl` and `onOpenFiles` hooks, and the page through `tiny.app.onOpenUrl` and `tiny.app.onOpenFiles`. A built app also runs as a single instance: opening it again brings the running copy forward and hands it whatever was opened. All of this needs a built app. `tinyjs dev` registers nothing. ## claiming a scheme or file type {#register} ```json { "id": "com.example.notes", "urlScheme": "notes", "fileExtensions": ["md", "txt"], "openFolders": true } ``` - [`urlScheme`](https://tinyjs.app/docs/config#urlScheme) is a string or a list of schemes. - [`fileExtensions`](https://tinyjs.app/docs/config#fileExtensions) are extensions without the dot. - [`openFolders`](https://tinyjs.app/docs/config#openFolders) lets folders be opened with the app (macOS and Linux). How each OS registers them: | | when | URL scheme | file types | folders | |---|---|---|---|---| | **macOS** | when the `.app` is built: the keys go into its `Info.plist`, and macOS picks them up when the app is first seen | listed for the scheme. If another app claims the same scheme, macOS decides which one opens it | listed under Open With, and may become the default for a type nothing else claims | listed under Open With, never the default | | **Windows** | each time the built app starts, under `HKCU\Software\Classes` (no admin) | the app becomes the scheme's handler for this user | added to Open With, not made the default | — | | **Linux** | each time the built app starts: a `.desktop` file in `$XDG_DATA_HOME/applications`, usually `~/.local/share/applications` (which also puts the app in the app menu), listing the app under the type the system already has for each extension (`text/markdown` for `.md`); only an extension nothing claims gets a type of the app's own | the app becomes the scheme's default handler (`xdg-mime`) | added to Open With, not made the default | listed under Open With | On Windows and Linux the app has to run once before links and files can reach it. tinyjs doesn't remove the entries when the app is deleted. ## receiving links and files {#receive} ```js // src/backend/main.js export function onOpenUrl(url, app) { const u = new URL(url); // notes://open?id=42 if (u.hostname === 'open') app.push('show-note', { id: u.searchParams.get('id') }); } export function onOpenFiles(paths, app) { // absolute paths: files and folders from Open With, the Dock icon, the command line for (const p of paths) openDocument(p, app); } ``` ```js // page const offUrl = tiny.app.onOpenUrl((url) => route(url)); const offFiles = tiny.app.onOpenFiles((paths) => paths.forEach(openDocument)); ``` The backend hooks get `url` as a string and `paths` as an array of absolute paths. The page functions return a function that removes the listener. They're the `'open-url'` and `'open-files'` events, which you can also hear with `tiny.api.on`. See [hooks](https://tinyjs.app/docs/backend#hooks). Files dragged onto a *window* aren't an open. They arrive in [`tiny.win.onDrop`](https://tinyjs.app/docs/window#files). Files dropped on the Dock icon, or on the app in Finder, are, and arrive in `onOpenFiles`. ### at startup {#cold-start} When a link or file starts the app, the backend hook gets it as soon as the backend is up, so nothing is lost. The page event goes out at the same moment, and the page may not have loaded yet. If the page needs to know what the app was opened with, keep it in the backend and let the page ask: ```js // backend let opened = []; export function onOpenFiles(paths, app) { opened.push(...paths); app.push('files-opened'); } export const api = { takeOpenedFiles: async () => opened.splice(0), }; // page — ask once at startup, and again whenever more arrive const load = async () => (await tiny.api.call('takeOpenedFiles')).forEach(openDocument); tiny.api.on('files-opened', load); load(); ``` ### links are input {#untrusted} Any web page, email or other app can open a `myapp://` link, with whatever path and query it likes. Parse it with `new URL()`, accept only the actions you expect, and never pass its parts to a shell, a file path or a database query unchecked. Ask before doing anything destructive. In a [site wrapper](https://tinyjs.app/docs/site-wrappers#securing) these events reach the wrapped site's JavaScript too, like every event, so a deep link carrying a token or an opened file's path is visible to it. ## single instance {#single-instance} A built app runs once per user. Opening it again, by its icon, a link, Open With or the command line, brings the running copy forward and hands it the link or files instead of starting a second copy. The hooks fire in the running copy, and on Windows and Linux its main window is shown. | | how | |---|---| | **macOS** | LaunchServices does it for the `.app`. Starting the bare `dist/<name>` executable, or the [`--cli` shim](#command-line) that runs it, starts a second copy | | **Windows** | a named pipe, `tinyjs-app-<id>-<user>`, locked to this user. A second launch passes its files to the first and exits | | **Linux** | a socket, `tinyjs-app-<id>.sock` in `$XDG_RUNTIME_DIR` (or a private `tinyjs-<uid>` folder in the temp dir when that's unset). A second launch passes its files to the first and exits | The instance is keyed on [`id`](https://tinyjs.app/docs/config#id), so two apps with the same `id` would hand off to each other. Give every app its own. If the pipe or socket can't be used safely (on Windows, another user already holds that pipe name; on Linux, the socket folder isn't a private folder owned by this user), the app prints a warning and runs without single instance or link hand-off. ## the command line {#command-line} A built app treats its command-line arguments as files to open: ```sh dist/notes ~/todo.md ./ideas.txt --verbose # onOpenFiles(['/Users/me/todo.md', '/Users/me/project/ideas.txt']) ``` Arguments starting with `-` are skipped, relative paths are resolved against the current folder, and anything that isn't an existing file or folder is dropped. A URL on the command line is dropped too. Links only arrive through the OS. If the app is already running (on Windows and Linux), the paths go to the running copy. [`tinyjs build --cli`](https://tinyjs.app/docs/cli#build) writes `dist/bin/<name>` (`<name>.cmd` on Windows), a small script that runs the built app with its arguments, for putting on your PATH. On macOS it runs the bare executable rather than the `.app`, so with the app already open it starts a second copy. ## becoming the default app {#default-handler} Registering a file type puts the app under Open With. It doesn't make it the default. - **Linux**: `tiny.app.setAsDefaultHandler('md')` (or `'folder'`) makes it the default, for a type in `fileExtensions`. See [becoming the default app](https://tinyjs.app/docs/shell#default-handler). - **Windows** doesn't let apps make themselves the default. Tell the user to choose the app in Settings → Default apps, or in the Open With dialog. - **macOS**: the user sets it in Finder's Get Info, under "Open with" → "Change All…". `setAsDefaultHandler` answers `'unsupported'` on macOS and Windows. URL schemes are different: on Windows and Linux the app takes the scheme over when it registers. ## testing {#testing} Build the app, run it once, then open a link the way a user would: ```sh open 'notes://open?id=42' # macOS start notes://open?id=42 # Windows (cmd) xdg-open 'notes://open?id=42' # Linux ``` For files, use the system's Open With on a file of a registered type, or pass paths on the command line. <!-- https://tinyjs.app/docs/auto-update --> # auto-update > ship new versions A built app can update itself. You host a small `manifest.json` and an archive of each release on any static web host. The app fetches the manifest, compares its version with its own, and when you call `install()` it downloads the archive, checks its sha256, swaps the new build in and relaunches. tinyjs shows no update UI of its own. Your code decides when to check, what to show and when to install. ## walkthrough {#walkthrough} **1. Point the app at a manifest.** Set [`update.url`](https://tinyjs.app/docs/config#update) and a `version` in tinyjs.json: ```json { "name": "myapp", "version": "1.1.0", "update": { "url": "https://example.com/myapp/manifest.json", "auto": "launch" } } ``` The url must be `https://`; `http://localhost` and `http://127.0.0.1` work for testing. **2. Publish a release.** Bump `version`, then: ```sh tinyjs publish --notes "Fixes the export bug" ``` [`tinyjs publish`](https://tinyjs.app/docs/cli#publish) builds the app and writes `dist/publish/`: the archive (`myapp-1.1.0.zip` on macOS) and `manifest.json`. The download url in the manifest is the archive's name in the same folder as `update.url`. **3. Upload both files** to the folder `update.url` points at. The manifest can have any filename, as long as it matches `update.url`. **4. The app checks.** With `"auto": "launch"` the app checks 5 seconds after it starts (`"daily"`: then every 24 hours too) and tells you when there's something newer: ```js tiny.api.on('update-available', async ({ current, latest, notes }) => { if (await tiny.dialog.confirm(`Update to ${latest}?`, { detail: notes ?? '' })) await tiny.app.update.install(); }); ``` Or check yourself, from a menu item for example: ```js const { available, latest, notes } = await tiny.app.update.check(); if (available) await tiny.app.update.install(); ``` **5. Install.** `install()` downloads and verifies the new build, puts it in place of the running one, opens it, and quits the old one about 250 ms later. ## the API {#api} | page | backend | | |---|---|---| | `tiny.app.update.check()` | `app.update.check()` | fetch the manifest and compare versions | | `tiny.app.update.install()` | `app.update.install()` | download, verify, swap and relaunch | | `tiny.api.on('update-available', fn)` | [`onUpdateAvailable(info, app)`](https://tinyjs.app/docs/backend#hooks) | an automatic check found a newer version | `check()` resolves `{ available, current, latest, notes }`: | field | | |---|---| | `available` | `true` when the manifest's version for this OS and CPU is newer than the app's | | `current` | the running app's `version` | | `latest` | the manifest's version, or `null` | | `notes` | the release notes from `publish --notes`, or `null` | The backend's `app.update.check()` also returns the parsed `manifest`. `install()` resolves `true` just before the app quits, so code after it rarely gets to run. If anything goes wrong it rejects with a reason and the current version keeps running (see [errors](#errors)). There are no progress events; the app keeps running normally while the archive downloads. `check()` works in `tinyjs dev`, comparing against the `version` in tinyjs.json. `install()` and the automatic checks only work in a built app. Under an [`api`](https://tinyjs.app/docs/config#api) gate the calls are `update.check` and `update.install`. The `"wrapper"` preset doesn't include them. ## automatic checks {#auto} | `update.auto` | | |---|---| | left out | no automatic checks; call `check()` yourself | | `"launch"` | one check 5 seconds after the app starts | | `"daily"` | the same, then every 24 hours while the app runs | A check that finds a newer version sends `{ current, latest, notes }` to the `update-available` page event and the `onUpdateAvailable` backend hook. Failed checks are silent, since being offline is normal. A check that finds nothing new sends nothing. ## which versions count as newer {#versions} Versions are compared as `major.minor.patch`. A leading `v` is allowed and anything after the third number is ignored, so `1.2.0-beta` counts as `1.2.0`, and `1.2.0-beta` → `1.2.0` is not an update. A version that doesn't start with three numbers (`1.2`) never counts as newer. `available` is `false` for the same or an older version. ## the manifest {#manifest} The smallest manifest, which a plain macOS `tinyjs publish` writes: ```json { "version": "1.2.0", "url": "https://example.com/myapp/myapp-1.2.0.zip", "sha256": "9f2c…", "notes": "Fixes the export bug" } ``` Builds for other systems and CPUs go in their own blocks, so one manifest serves every install: ```json { "version": "1.2.0", "notes": "Fixes the export bug", "url": "https://example.com/myapp/myapp-1.2.0-macos-arm64.zip", "sha256": "…", "mac": { "arm64": { "url": "https://example.com/myapp/myapp-1.2.0-macos-arm64.zip", "sha256": "…" }, "x86_64": { "url": "https://example.com/myapp/myapp-1.2.0-macos-x86_64.zip", "sha256": "…" } }, "win": { "url": "https://example.com/myapp/myapp-1.2.0-win.zip", "sha256": "…", "x86_64": { "url": "https://example.com/myapp/myapp-1.2.0-win.zip", "sha256": "…" }, "arm64": { "url": "https://example.com/myapp/myapp-1.2.0-win-arm64.zip", "sha256": "…" } }, "linux": { "x86_64": { "url": "https://example.com/myapp/myapp-1.2.0-linux-x86_64.tar.gz", "sha256": "…" }, "arm64": { "url": "https://example.com/myapp/myapp-1.2.0-linux-arm64.tar.gz", "sha256": "…" } } } ``` Each app picks its entry like this, and when there's no entry for it, it reports no update rather than taking a build for another system: | app | uses | |---|---| | macOS, no `mac` block | the top-level `url` and `sha256`, on every Mac | | macOS, with a `mac` block | `mac.arm64` or `mac.x86_64`, by the Mac's hardware. An Intel build running under Rosetta moves to `mac.arm64` | | Windows, x64 build | `win.arm64` on an ARM64 machine if there is one, else `win.x86_64`, else `win.url` | | Windows, arm64 build | `win.arm64` only | | Linux | `linux.x86_64` or `linux.arm64` only | The top-level `url` is what Mac apps from before the `mac` block read, and `win.url` is what Windows apps from before `win.arm64` read; `publish` fills them with the arm64 Mac build and the x64 Windows build. So when you publish per-CPU Mac builds, always include `--arch arm64`. A plain `tinyjs publish` on a Mac builds only for that Mac's CPU and writes no `mac` block, so every Mac gets that one build. If you ship to both Apple Silicon and Intel Macs, publish with `--arch arm64` and `--arch x86_64`, or build one app for both with `--universal`. ### merging platforms by hand {#merging} `publish` adds to the manifest already in `dist/publish/` for a second Mac `--arch` or a second Windows CPU of the same version ([details](https://tinyjs.app/docs/cli#publish)). Builds made on different machines (Mac, Windows, Linux, and the two Linux CPUs) each write their own manifest; merge them into one before uploading: the top-level `url`/`sha256` and `mac` from the Mac, `win` from Windows, and each Linux arch into one `linux` block. When the platforms aren't on the same release, give the lagging entry its own `version`. Otherwise its apps compare against the top-level `version`, see an update, and install the build they already have, over and over: ```json "win": { "version": "1.1.0", "url": "…/myapp-1.1.0-win.zip", "sha256": "…" } ``` An entry's `version` overrides the top-level one on every OS. `notes` can be overridden per entry on macOS and Windows, on the `win` block itself, and on the `linux` block (not per Linux arch). `publish` only ever writes the top-level `version` and `notes`. With [signed updates](https://tinyjs.app/docs/signed-updates), sign the merged file afterwards: see [several platforms](https://tinyjs.app/docs/signed-updates#platforms). ## hosting {#hosting} - Any static host works: GitHub Releases, S3, a web server. - `update.url` and every download url must be `https://` (`http://localhost` and `http://127.0.0.1` for testing). Redirects are followed, and every hop must pass the same rule — a redirect through plain `http://` is refused before it's requested. - The manifest is fetched with a `cache-control: no-cache` request header. A CDN may still serve an old copy, so purge it after uploading if yours caches. - Upload the archive before the manifest, so no app sees a version whose download isn't there yet. - Keep archives at the url the manifest names; an app downloading it gets an `HTTP 404` error otherwise. ## what install does {#install} 1. Fetches the manifest again and picks this OS and CPU's entry (signed apps also re-verify it, see [signed updates](https://tinyjs.app/docs/signed-updates#format)). 2. Downloads the archive and checks its sha256 against the manifest. 3. Unpacks it into a temporary folder: `ditto` on macOS, the `tar.exe` that ships with Windows 10 and later, `tar` on Linux. The archive holds one top-level entry: the `.app` on macOS, the app folder on Windows and Linux. 4. Checks it and swaps it in, which differs per OS (below). 5. Opens the new version and quits the running one. | | checks | swap | |---|---|---| | **macOS** | `codesign --verify --strict --deep` must pass. If the running app is signed with a real identity (a Team ID), the update must carry the same Team ID | puts the new `.app` beside the running one first (`<name>.app.update-new`, copied if it's on another disk), then renames the running app to `<name>.app.update-backup` and the new one into its place, and deletes the backup. If anything fails before the new app is in place, the current app is left as it was | | **Windows** | sha256 only | file by file over the running install: each existing file is renamed to `<file>.update-old` and the new one dropped in. If any step fails, every change is undone. Files the new version no longer has are removed inside `resources/` and `executables/`, and stay everywhere else in the folder. Leftover `.update-old` files (the running exes) are deleted by the next update | | **Linux** | sha256 only | the same as macOS for the whole app folder — staged as `<folder>.update-new` (copied, since the temp folder is usually another filesystem), then swapped | On macOS and Linux the user needs write access to the folder the app sits in (for example `/Applications`), since the app itself is renamed. On Windows they need write access to the install folder. ### macOS: signing and notarization {#macos} The code-signature check catches a damaged or tampered bundle. The Team ID check stops a build signed by someone else from replacing yours, but only when your app has a Team ID: an ad-hoc signed app (no [`signIdentity`](https://tinyjs.app/docs/config#signIdentity)) accepts any validly signed update, so its only protection is the https manifest and its sha256, or [signed updates](https://tinyjs.app/docs/signed-updates). `tinyjs publish` rebuilds the app and doesn't [notarize](https://tinyjs.app/docs/cli#notarize) it, so the app in the update zip is never notarized, even if you notarized `dist/` before publishing. The installed app downloads the zip itself and nothing marks it as downloaded from the internet, so Gatekeeper doesn't block it from opening. Don't offer that zip as a first download on your website, though: a copy downloaded in a browser is quarantined, and Gatekeeper refuses an app that isn't notarized. Ship first installs from `tinyjs build` + `tinyjs notarize`. An app opened straight from a downloaded zip or disk image runs from a read-only copy (App Translocation). `install()` refuses there and asks the user to move the app to /Applications and open it again. ## errors {#errors} `check()` and `install()` reject with a readable message. The ones you're most likely to see: | message | | |---|---| | `no update url configured …` | `update.url` is missing | | `update url must be https:// …` | also `download url must be https://`, and the `(after redirect)` versions | | `update check failed: HTTP 404` | the manifest isn't at `update.url` | | `no newer update available for this platform` | `install()` with nothing newer, or no build in the manifest for this OS and CPU | | `auto-update only works from the packaged .app build` | `install()` outside a built app, such as in `tinyjs dev` (on Windows and Linux: `auto-update only works in a built app, not in tinyjs dev`) | | `the app is running from a quarantined location …` | macOS App Translocation, see [above](#macos) | | `update manifest has no download url` / `… no sha256 — refusing to install` | a broken manifest entry | | `download failed: HTTP …` | the archive isn't where the manifest says | | `checksum mismatch — refusing to install` | the archive doesn't match its `sha256`, often an archive re-uploaded without its manifest | | `could not extract the update archive` / `update archive does not contain an app …` | a damaged archive or one for another OS | | `code signature verification failed on the update` | macOS: the new bundle's signature doesn't verify | | `update is signed by a different team (X ≠ Y) …` | macOS: see [above](#macos) | | `cannot move the current app (insufficient permissions?)` | no write access to the app's folder | | `could not copy the new version next to the app (disk full?) …` | the staging copy failed; the current version is untouched | | `update swap failed — the current version is intact (…)` | Windows: a file was locked. `… the rollback hit errors — please re-install the app` means it couldn't be undone cleanly | Signed apps can also get signature and expiry errors, listed under [signed updates](https://tinyjs.app/docs/signed-updates#errors). `install()` checks again before downloading anything, and only installs what `check()` would call an update for this OS and CPU — so calling it when nothing is newer, or when the release has no build for this machine, rejects rather than reinstalling or installing the wrong build. ## signed updates {#signing} The sha256 in the manifest comes from the same host as the archive, so whoever controls that host can change both. With [signed updates](https://tinyjs.app/docs/signed-updates) the app pins your public key and only accepts a manifest signed with your private key, which never leaves your machine or CI secrets. It's opt-in, uses the same `update.url`, and installs from before you turn it on keep updating. <!-- https://tinyjs.app/docs/signed-updates --> # signed updates > updates only you can publish A plain [auto-update](https://tinyjs.app/docs/auto-update) manifest is trusted because it comes over https, and the archive because its sha256 matches the manifest. Both live on the same host, so anyone who controls that host (or the bucket or CDN behind it) can swap in their own build and its matching hash. Signed updates close that gap. You pin one or more Ed25519 public keys in the app with [`update.publicKey`](https://tinyjs.app/docs/config#update), and `tinyjs publish` signs the manifest with a private key that never leaves your machine or CI secrets. The app checks the signature over the whole manifest (version, notes, every download url and sha256) before it reads anything from it. The https, sha256 and macOS code-signature checks all still apply. This is separate from Apple and Windows code signing, and works the same on all three systems. It's opt-in: same `update.url`, and existing installs keep updating. ## setting it up {#setup} ### 1. create two keys {#keys} Make two keys: one you sign with day to day, and a backup kept offline (a password manager, an encrypted USB stick), somewhere apart from the first. Apps accept a manifest signed by either, so if the signing key is lost or leaks, the backup can still ship updates, including the one that replaces the bad key. A key only helps once it's in installed apps, so pin the backup from your first signed release. Use the `tjs` runtime bundled with tinyjs (`bin/tjs` in the tinyjs folder). In a private folder **outside your app project**, restrict new files to yourself (`umask 077` on macOS and Linux), save this as `keygen.js` and run `/path/to/tinyjs/bin/tjs run keygen.js`: ```js for (const name of ['update', 'update-backup']) { const pair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']); await tjs.writeFile(name + '-private.der', new Uint8Array(await crypto.subtle.exportKey('pkcs8', pair.privateKey))); const raw = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey)); const publicKey = btoa(Array.from(raw, b => String.fromCharCode(b)).join('')); await tjs.writeFile(name + '-public.txt', new TextEncoder().encode(publicKey)); } ``` You get four files: | file | what it is | where it goes | |---|---|---| | `update-private.der` | the signing key (PKCS8 DER) | private storage or a CI secret | | `update-backup-private.der` | the backup key | offline, now | | `update-public.txt`, `update-backup-public.txt` | base64 of the 32-byte raw public keys | tinyjs.json | Never put a private key in tinyjs.json, your source or resource folders, version control or an uploaded release. Keep it outside the project: building copies backend sources into the app. ### 2. pin the public keys {#config} ```json { "update": { "url": "https://example.com/myapp/manifest.json", "publicKey": ["BASE64_SIGNING_PUBLIC_KEY", "BASE64_BACKUP_PUBLIC_KEY"] } } ``` A single string works for one key. The keys are built into the app. ### 3. publish with the private key {#publish} ```sh TINYJS_UPDATE_PRIVATE_KEY_FILE=/private/keys/update-private.der tinyjs publish --notes "Fixes the export bug" ``` In PowerShell, set `$env:TINYJS_UPDATE_PRIVATE_KEY_FILE` first, then run `tinyjs publish`. The variable holds the file's path, not the key. In CI, point it at a secret file your CI provides. `publish` checks the key before it builds anything: - With `publicKey` set, it needs `TINYJS_UPDATE_PRIVATE_KEY_FILE`, and the key must match one of the listed public keys. - With the variable set and no usable `publicKey`, it stops. - A `publicKey` that's set but isn't a non-empty string or list (`""`, `[]`, a number) gets a note, and the manifest is published unsigned. `tinyjs build` and `tinyjs dev` never need the private key. ### 4. upload {#upload} Upload `dist/publish/` as usual: the archive plus `manifest.json`, under the filename `update.url` names. Download urls must still be https. ## what the app accepts {#rules} Signing is on when `update.publicKey` is a non-empty string or a non-empty list of strings. Anything else (left out, `null`, `""`, `[]`) means unsigned updates. With signing on, the app trusts only the `signed` block and fails closed. Each of these means no update, with an [error](#errors) from `check()` and `install()`: - no `signed` block, or one with an unknown format or extra fields - a signature that doesn't verify against any pinned key - a pinned key that isn't valid base64 of 32 bytes, even if another key in the list would have matched - an [expired](#expires) manifest - a runtime without Ed25519 support (there's no unsigned fallback) `check()` verifies the manifest before it picks this OS's entry. `install()` fetches and verifies it again rather than trusting the earlier check, and refuses anything that isn't newer or has no build for this OS and CPU. So if a newer, correctly signed manifest replaces the old one between the two calls, that's the one installed. ## the signed manifest {#format} `publish` writes the usual manifest with a `signed` block added: ```json { "version": "2.0.0", "url": "https://example.com/myapp/myapp-2.0.0.zip", "sha256": "…", "notes": "…", "signed": { "format": "tinyjs-update-v1", "algorithm": "Ed25519", "payload": "{\"version\":\"2.0.0\",\"url\":\"https://example.com/myapp/myapp-2.0.0.zip\",\"sha256\":\"…\",\"notes\":\"…\"}", "signature": "BASE64_64_BYTE_SIGNATURE" } } ``` - Apps **without** a key read the plain fields, as they always have, and ignore `signed`. - Apps **with** a key read only `payload`, after checking the signature. Editing the plain fields by hand changes what keyless apps see and nothing else. - `payload` is the whole plain manifest as a JSON string: every platform block, url, hash and note is covered. - The signed bytes are the UTF-8 of `"tinyjs-update-v1\n" + payload`. The payload isn't normalized, so changing even its whitespace breaks the signature. - The `signed` block has exactly those four fields. Base64 is standard, with padding. The key always comes from the app, never from the manifest. ## expiry {#expires} By default a signed manifest is valid forever. Someone who controls your host can't forge one, but they can keep serving an old one: holding users on an earlier release, or offering a superseded release to users still older than it. `--expires` puts a date inside the signed payload: ```sh TINYJS_UPDATE_PRIVATE_KEY_FILE=… tinyjs publish --expires 30d # or a date: 2027-01-31 (midnight UTC) TINYJS_UPDATE_PRIVATE_KEY_FILE=… tinyjs publish --sign-manifest dist/publish/manifest.json --expires 30d ``` After that moment `check()` and `install()` reject with `update manifest expired …`, and automatic checks stay silent. The cost: re-sign before it runs out, or every installed app stops seeing updates until you do. The second command above extends an existing manifest without rebuilding (a scheduled CI job works). Re-signing without `--expires` keeps the old date. Keep the window generous: a user whose clock is off by more than what's left is refused too. Apps without a key ignore `expires`, since anyone who could edit their manifest could edit the date. ## several platforms {#platforms} Publishing a second Mac `--arch`, or a second Windows CPU, of the same version verifies the existing signed manifest in `dist/publish/`, merges the new build into it and signs the result. If the existing one doesn't verify it isn't merged; publish that build again. Builds from different machines are merged by hand ([how](https://tinyjs.app/docs/auto-update#merging)). Sign after the final merge: 1. Merge the plain fields, then **delete the `signed` blocks**. 2. Check the version, notes and every url and sha256. 3. Sign it, from the app project with the pinned keys: ```sh TINYJS_UPDATE_PRIVATE_KEY_FILE=/private/keys/update-private.der tinyjs publish --sign-manifest merged.json ``` `--sign-manifest FILE` doesn't build. It writes the `signed` block into the file. Given a file that already has one, it verifies it and re-signs the same data (to extend `--expires`, say). If the plain fields no longer match the signed block, it refuses rather than guess which you meant: delete the block to sign the plain fields as they are. ## existing installs {#existing-installs} Nothing to migrate. Keep the same `update.url`, add `publicKey` and publish as usual. Installs from before the key read the plain fields, so the release that adds the key reaches them the old, unsigned way; signing can't secure that one step after the fact. From then on they only accept signed manifests. When you don't want keyless installs to update any more, add `--signed-only` (to `publish` or `--sign-manifest`). The manifest is then just `{ "signed": { … } }`, and apps without a key see no update. A signed-only manifest stays signed-only when re-signed. ### turning signing off {#turning-off} Installed apps with a key only accept signed manifests, even after you remove `publicKey` from tinyjs.json. The release that removes it has to reach them signed: publish it without the key, then put `publicKey` back in tinyjs.json long enough to run `tinyjs publish --sign-manifest dist/publish/manifest.json`. ## rotating or replacing a key {#rotation} The update url stays the same throughout; only the key list changes. `publish` signs with whichever private key the variable names, as long as tinyjs.json lists its public key. | situation | what to do | |---|---| | planned rotation | ship a release listing old, new and backup keys, still signed with the old one. Once most users have it, sign with the new key and drop the old one in a later release | | signing key lost | sign with the backup. Ship a release listing the backup plus a newly generated key (your new backup), and keep signing with the backup | | signing key leaked | the same, as fast as you can. Until users install the release that drops the leaked key, whoever has it can sign updates their apps accept. There's no revocation | | every private key lost | signed apps can't be updated any more. Users have to reinstall from somewhere else they trust | ## errors {#errors} From the app's `check()` and `install()`: | message | | |---|---| | `signed Ed25519 update manifest required` | the manifest is unsigned, or its `signed` block is malformed | | `update manifest signature verification failed` | signed with a key the app doesn't pin, or changed since | | `invalid update signature …` / `invalid update publicKey …` | not valid base64, or the wrong length | | `invalid signed update manifest` | the payload has no `version` | | `update manifest expired … — the publisher needs to re-sign it` | see [expiry](#expires) | | `update manifest has an invalid expires date` | `expires` isn't a date | | `no newer signed update available for this platform` | `install()` with nothing newer for this OS and CPU | From `tinyjs publish`: | message | | |---|---| | `signed updates require TINYJS_UPDATE_PRIVATE_KEY_FILE …` | `publicKey` is set, the variable isn't | | `set update.publicKey before using TINYJS_UPDATE_PRIVATE_KEY_FILE` | the reverse | | `cannot sign updates: update manifest signature verification failed` | the private key doesn't match any listed public key | | `--expires needs signed updates …`, `--signed-only needs signed updates …` | those flags without a key | | `cannot sign manifest: … has edits outside its signed block …` | see [several platforms](#platforms) | ## what it protects against {#threat-model} **It protects against** anyone who can change files on your update host, its storage or its CDN, or intercept the connection to it. They can't change the version, the notes, any download url or any sha256, so they can't get a different archive installed. That matters most on Windows and Linux, where tinyjs doesn't check the executables' own signatures, and for ad-hoc signed Mac apps, which have no Team ID to pin. **It doesn't protect against:** - **a leaked private key.** Whoever has it can sign updates until your apps stop trusting it (see [rotation](#rotation)). - **your build machine or CI.** Whatever it builds, `publish` signs. - **holding updates back.** The host can serve nothing, or an older signed manifest. Apps never install a version older than their own, but without [`--expires`](#expires) a host can keep offering an old signed release to users older than it. - **the release that turns signing on**, which reaches existing installs unsigned ([above](#existing-installs)). - **first installs.** The copy users download from your website isn't covered by this; that's Apple's and Microsoft's code signing. - **the user's own machine.** The keys are in the installed app, and anyone who can modify it can change them. <!-- https://tinyjs.app/docs/how-it-works --> # how it works > two processes, one socket A tinyjs app is two processes. The **backend** is your JavaScript running on [txiki.js](https://txikijs.org/), with files, processes and the network. The **launcher** is a small native program (about 2 MB) that owns the window and the system webview your page runs in. They talk over a private socket — no HTTP server, no ports, nothing listening for anything else to find. ```text ┌───────────────────────────┐ private socket ┌───────────────────────────┐ │ backend (txiki.js) │◄──────────────────►│ launcher (native) │ │ src/backend/main.js │ one line per │ · window, menus, tray │ │ + tinyjs's bridge │ message │ · native dialogs │ │ · your app logic, api │ │ · the system webview │ │ · files, processes, net │ └─────────────┬─────────────┘ └───────────────────────────┘ │ ▼ ┌───────────────────────────┐ │ your page (file://) │ │ tiny.api.call(…) │ │ tiny.api.on(…) │ └───────────────────────────┘ ``` Each OS has its own launcher, and everything above the launcher is the same code on all three: | | launcher | webview | socket | |---|---|---|---| | macOS | `launcher-macos.cc` | WebKit (WKWebView) | Unix socket | | Windows | `launcher-win.cc` | WebView2 (Edge) | named pipe | | Linux | `launcher-linux.cc` | WebKitGTK 4.1 | Unix socket | ## starting up {#startup} **In `tinyjs dev`, and in Windows and Linux builds**, the backend starts first. It makes the socket in a fresh temp folder only your user can open (mode `0700`), then starts the launcher, pointed at your page. Your `init(app)` runs once the launcher has connected. **A packaged macOS app** starts the other way round, because macOS wants the app's own executable to be the process with the window: the `.app` opens the launcher, which makes the socket and starts the backend (`tjs run …/entry.js`), and the backend connects to it. Your code can't tell the difference. ## the page {#page} Your page loads as a real `file://` document from the frontend folder on every OS, so relative scripts, styles and images load straight from disk with no bundling step. The page gets `tiny` before its own scripts run. It has no `tjs` and no access to the file system: anything it needs from the system goes through the backend, as an [`api` call](https://tinyjs.app/docs/api). ## messages {#protocol} The protocol is plain text, one line per message, with JSON payloads: - **a call from the page** — `tiny.api.call()`, or any `tiny.*` method — travels to the backend as `CALL` and comes back as `RET`, which settles the page's promise; - **events to the page** — `app.push()` and tinyjs's own events — are delivered by running `window.__emit(…)` in each window; - **native operations** — set the title, show a dialog, register a hotkey — are one verb each, sent from the backend to the launcher, and can target one window (`VERB@<window id>`). `TINYJS_DEBUG=1 tinyjs dev` prints every line in both directions, which is the fastest way to see what a call actually did. ## shutting down {#shutdown} Closing the main window quits the app (unless it [hides on close](https://tinyjs.app/docs/window#visibility)): the launcher exits, and the backend notices and exits too. `tiny.quit()` and `app.quit()` go the other way, from the backend. ## what this means for your app {#consequences} - Everything between the page and the backend crosses as JSON: send plain data, not class instances or functions. - The backend isn't a webview, so it isn't slowed down when windows are hidden — continuous work belongs there. See [backend](https://tinyjs.app/docs/backend). - Nothing listens on the network, so there's no port for another program to find or collide with. - A site loaded in the app gets `tiny` too, which is why [site wrappers](https://tinyjs.app/docs/site-wrappers#api-gate) need the `api` gate. <!-- https://tinyjs.app/docs/portability --> # portability > macOS, Windows and Linux All three run the same runtime and the same protocol; only the launcher and the transport differ. ```text launcher source webview transport macOS launcher-macos.cc WebKit unix socket Windows launcher-win.cc WebView2 named pipe Linux launcher-linux.cc GTK3 + WebKitGTK 4.1 unix socket ``` **Anything unported fails cleanly, so cross-platform code can feature-detect.** Capability calls reject with a specific reason, query calls resolve `null`, and fire-and-forget ones are silent no-ops. Ask before you call: ```js const caps = await tiny.system.capabilities(); // -> { os: 'macos' | 'windows' | 'linux', badge: true, progress: true, … } // Anything ABSENT from the answer is supported, so test !== false if (caps.badge !== false) tiny.app.badge('3'); // the backend asks the same questions: app.system.capabilities(), .os(), … // synchronous, safe during page setup (the webview's own UA is decisive) tiny.system.os(); tiny.system.isMacOS(); tiny.system.isWindows(); tiny.system.isLinux(); // the chip is the one thing the page CAN'T see: WKWebView reports MacIntel // on Apple silicon, so ask the backend await tiny.system.architecture(); // 'arm64' | 'x86_64' // Linux assembles media codecs, speech etc. from packages, so a feature can // be missing on one box — ask, and tell the user the fix. All ok elsewhere. await tiny.system.requirements(['media.aac']); // -> [{ id, ok, feature, detail, install: { manager, packages, command } }] // ids: media.aac, media.h264, media.mp3, speech, spotlight.index, audioTap, globalHotkeys, // mouseTracking, tray, windowPosition. { refresh: true } re-probes. const gaps = await tiny.system.missing(); // just the ones that failed // or: a native dialog naming what's missing, offering to copy ONE install // command; shows nothing when all is present, so call it from a failure path audio.addEventListener('error', () => tiny.system.promptMissing(['media.aac'])); ``` ## what this page can do {#capabilities} A page can ask by the call itself instead. `tiny.capabilities` answers per `tiny.*` path, combining what the OS and the Linux session support with what tinyjs.json's [`"api"` gate](https://tinyjs.app/docs/site-wrappers#api-gate) allows this page's origin: ```js await tiny.capabilities.ready; // once; the rest is synchronous if (tiny.capabilities.can('app.badge')) tiny.app.badge('3'); tiny.capabilities.can('tray.*'); // true if any tray call works tiny.capabilities.get('app.badge'); // { ok: true } // { ok: false, why: 'platform', feature: 'badge' } this OS can't // { ok: false, why: 'session', feature: 'keystroke' } this Linux session can't (Wayland) // { ok: false, why: 'gate', needs: ['app.badge'] } tinyjs.json "api" denies it tiny.capabilities.list(); // every path that works here, sorted tiny.capabilities.onChange(({ added, removed }) => render()); ``` Paths are the ones you'd call, `tiny.` optional. `why` can also be `'unknown'` (no such path) or `'pending'` (no answer yet, when `can()` is always false). `ready` resolves within 3 seconds even if the answer hasn't arrived, so awaiting it can't hang a page; a late answer still arrives, and `onChange` fires when it does, as it does for the first answer, so a page can render from `onChange` alone. `feature` is the `system.capabilities()` key that says no. It works on every page, gated or not. A page is only told about the names its own `tiny` uses, and the app's own `api` functions aren't covered. It's informational: the backend still checks every call. `system.capabilities()` stays the raw platform table, and the one the backend uses. Some things are not "not ported yet" but concepts the other OSes don't have — AppleScript, Quick Look. Those live in `tiny.macos.*` and reject elsewhere (except `ai.availability()`, which answers `'unsupported'`, since it's the check you make first), so the split is visible in the call itself. Everything that *could* exist on another OS stays on `tiny.app` and answers `'unsupported'` until it does. <!-- https://tinyjs.app/docs/gotchas --> # gotchas > things that cost real debugging Sharp edges in txiki.js (26.6.0, the runtime tinyjs ships) and the webviews, collected so you don't have to rediscover them. Each feature page lists its own platform differences; this page is for the ones that cut across everything. ## txiki.js {#txiki} **File calls are async; SQLite is sync.** Every `tjs.*` file system call returns a promise, while `tjs:sqlite` doesn't. A missing `await` on `makeDir` before opening a database is a race that only fails sometimes: ```js await tjs.makeDir(dir, { recursive: true }); const db = new Database(dir + '/app.db'); // no await here ``` **`tjs.cwd` is a property**, not a function — and in a built app it's rarely your project folder, so build paths from [`app.paths()`](https://tinyjs.app/docs/resources) instead. **To silence a child process, use `'ignore'`.** `stdout: 'null'` doesn't silence anything — the child's output goes to yours: ```js tjs.spawn(argv, { stdout: 'ignore', stderr: 'ignore' }); ``` **Writing to a child's stdin is unreliable.** Several writes to `stdin: 'pipe'` may not all arrive. Pass input as arguments or through a file instead. **`import.meta.url` throws in a built app**, where the backend is compiled into one executable. Find shipped files through `app.paths()` ([resources](https://tinyjs.app/docs/resources), [executables](https://tinyjs.app/docs/executables)). ## the webview {#webview} **Hidden windows are slowed down.** The webview throttles `requestAnimationFrame` and timers in a window that's covered, minimized or off-screen, so a hidden window can't reliably drive a visible one. Do continuous work in the visible window, or in the [backend](https://tinyjs.app/docs/backend) (which isn't a webview) and [push](https://tinyjs.app/docs/backend#push) the results. **On macOS, local media is limited to the page's folder.** `<img>`, `<audio>` and `<video>` there can only load files under the frontend folder; widen that with [`readAccess`](https://tinyjs.app/docs/config#readAccess), and build the URL with [`tiny.fileURL(path)`](https://tinyjs.app/docs/api#fileURL). Windows and Linux already let a `file://` page load other local files. **Pages are real `file://` documents**, not HTML injected into `about:blank`. That's what makes relative paths work, and it's a secure context, so APIs that need one (WebGPU's `navigator.gpu`, for instance) are there. On macOS 15 and earlier, tinyjs also turns on WebKit's WebGPU switch for you. **`fetch` from the page follows browser rules** — CORS included. For requests a browser would block, use [`tiny.fetch`](https://tinyjs.app/docs/fetch), which goes through the backend. ## Linux {#linux} **Web Audio crackles.** Audio sent to `ctx.destination` under WebKitGTK is rendered on a normal-priority thread and stutters under load. That's measured, not theoretical, and it's why tinyjs has native [audio filters](https://tinyjs.app/docs/audio) and [sound effects](https://tinyjs.app/docs/sampler) instead of relying on Web Audio there. **Wayland limits what an app can do** with windows and input: no placing windows yourself (see [`windowPlacement`](https://tinyjs.app/docs/config#windowPlacement)), global hotkeys through a system prompt ([hotkeys](https://tinyjs.app/docs/hotkeys)), and no global cursor position without permission ([window](https://tinyjs.app/docs/window#cursor)).