# 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…
No notes yet.
```
```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/` — the app as one bare executable for local use, loading its page from `dist/frontend/`.
- `--dmg` adds `dist/-.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/.exe` — the app, with your icon.
- `dist/launcher.exe`, `dist/resources/`, `dist/executables/` — ship the whole folder. The page is packed inside `.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/` — the app.
- `dist/launcher`, `dist/icon.png`, `dist/resources/`, `dist/executables/` — ship the whole folder. The page is packed inside ``.
- [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 `--macos-.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/` (`.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/` 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**: `-.zip` (or `-macos-.zip` with `--arch`).
- **Windows**: `--win.zip` (`-win-arm64.zip` on ARM).
- **Linux**: `--linux-.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/.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
```
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.
# 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.
# 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/`, `.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/.app`), the dmg's volume name, and the app's name in Linux and Windows file associations.
### `id` {#id}
`string` · default: `com.example.`
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 `.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//`
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.` (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).
# 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
```
# 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 `