# tinyjs — working notes for agent sessions

Runtime + CLI for tiny native apps: txiki.js backend (`runtime/bridge.js`),
webview client (`runtime/tiny.js`, compiled into each launcher via
`native/gen-client.sh`), three native launchers. Wire protocol: newline-
delimited lines over a Unix socket; `CMD@<winid>` targets a window, bare
`CMD` means main — window-scoped ops must dispatch via `forWin(m)`, not the
global app handle.

Releases are git tags (`vX.Y.Z` on main); a tag push builds macOS + Windows
+ linux-x86_64 + linux-arm64 in CI. Update CHANGELOG.md before tagging
(the site's /changelog includes it). If the API changed (tiny.d.ts, bridge.js's
`app`, tiny.js), update the matching page in docs/docs/ and regenerate
docs/docs/api-map.md with
`scripts/api-map/run.sh` — never hand-edit it. The site's /llms.txt,
/llms-full.txt and /docs/<page>.md copies are written at build time by
docs/.vitepress/llms.ts from the sidebar in config.mts — a new docs page
reaches them by being in the sidebar. When TJS_VERSION changes
(setup.sh, setup.ps1, release.yml), regenerate template/types/tjs.d.ts
with `python3 scripts/txiki-types/update.py` — never hand-edit it either.
Docs pages can use
`::: tabs` (`== Title` per tab), `::: pagelink`, `::: download`,
`::: carousel` and `::: embed <YouTube/Vimeo url>` — syntax at the top of
docs/.vitepress/markdown.ts. If a tinyjs.json key was
added, add it to docs/public/schema/tinyjs.schema.json; `python3
test/config-schema/coverage.py` catches a missed top-level key (not nested
ones). Linux legs build on ubuntu-22.04
runners ON PURPOSE — the linker bakes the build image's glibc floor into
tjs and the launcher, and 24.04-built binaries refuse to load on Ubuntu
22.04 / Debian 12 (shipped broken through v0.32.0). A verify step asserts
the floor (GLIBC ≤ 2.35, GLIBCXX ≤ 3.4.30), the arch, and the
optionally-linked features before packaging; tjs needs gcc-12 + the
#pragma region strip in setup.sh (details in release.yml's comments).
Never "modernize" the runners or the cache key without re-reading those.
App tarballs (amp etc.) are packaged separately in 22.04 containers —
runbook in ../tinyjsapp-examples/CLAUDE.md. Linux burn-down + platform notes:
TODO-linux.md. Native-filters-on-other-OSes plan: TODO-audio-filters.md.
Sampled-SFX mixer (`tiny.audio.sampler`, native on Linux / Web Audio
elsewhere): TODO-audio-sampler.md — shipped 2026-07-31, verified on all
three platforms same day (Linux instrumented, mac/win hand-tested;
TODO-verify.md). PDF pagination + header/footer plan:
TODO-pdf.md (macOS `printToPDF` emits ONE tall page today; Windows and
Linux paginate).
Multi-screen (#70 — Windows mixed-DPI coordinates are inconsistent today,
screen-relative placement, stable screen ids, remembered placement):
TODO-screens.md, planned 2026-10-19.
Site wrappers (a hosted third-party page as the app's main frame — JS
dialogs, downloads, navigation policy, popups, find, and the tinyjs.json
`"api"` capability gate): TODO-site-wrapper.md, shipped on all three
platforms 2026-08-04/06. The gate's per-origin keyholes trust the origin
the LAUNCHER stamps onto each CALL, so anything touching that path is
security code — the origin is always the LAST element of the CALL array
(every launcher appends it; reading `[1]` trusted the page on Windows).
Verify it the way a hostile page would, never only through our own client:
adversarial probes in that file, per-OS ticks in TODO-verify.md.
Anything built on one OS but never watched run on another goes in
TODO-verify.md — tick a box there only after seeing it work on that OS,
since a fire-and-forget op a shell ignores looks just like an unimplemented
one. `test/appsurface.html` is the self-driving page for those checks.
Example-app payloads (all platforms) live on GitHub Releases in
tinyjsapp-examples (tag `<dir>-v<version>`; binaries purged from its git
history 2026-07-25) — runbook in ../tinyjsapp-examples/CLAUDE.md.

## Linux specifics that cost real debugging — read before touching

- `tinyjs dev` auto-rebuilds the launcher when launcher-linux.cc or
  runtime/tiny.js is newer than the binary. Manual build: see setup.sh.
- Web Audio reaching ctx.destination crackles under WebKitGTK (graph renders
  on a normal-priority thread; rtkit won't promote it). This is measured
  fact, not theory — don't retry latencyHint/buffering "fixes".
- PipeWire limits (all measured, PipeWire 1.0.5): filter-chain segfaults
  past ~30 declared nodes; the `gain` builtin kills any config containing
  it (use `linear`); `pw-cli set-param` silently emits an EMPTY pod past
  ~20 key/value pairs — chunk to ≤18 (launcher does).
- NEVER make system-wide audio changes (pw-metadata clock.force-quantum
  etc.) — a forced quantum once broke ALL system audio including Firefox.
  Per-app nodes only, named `tinyjs-<kind>-<pid>`, destroyed by exact
  node.name match with the awk id-carry pattern (a windowed grep once
  destroyed OTHER apps' nodes and silenced Firefox).

## Testing recipes (headless, no clicking)

- Self-driving test page: `TINYJS_HTML=/abs/path/page.html tinyjs dev`
  (from any app dir). Page saves results via `tiny.store.set(...)`; read
  them from `~/.local/share/<app-id>/store.json`. Wait ~2s for `tiny`.
- Drive a running app via MPRIS:
  `gdbus call --session -d org.mpris.MediaPlayer2.<app_id_underscored> -o
  /org/mpris/MediaPlayer2 -m org.mpris.MediaPlayer2.Player.Play` (also
  Stop, Properties.Get Position/Volume, Properties.Set Volume).
- Kill launchers by exact name: `for p in $(pgrep -x launcher-linux); do
  kill $p; done`. NEVER `pkill -f` a pattern that matches your own shell —
  it kills the agent's command (exit 144) and reads as a mystery failure.
  A BUILT app's copy is named `launcher` (dist/), not `launcher-linux`, so
  that loop misses it — `pgrep -x launcher` too, or kill by pid. A survivor
  is not harmless: it keeps its modal dialogs up, it swallows the XTest keys
  the next run wanted, and (apps being single-instance) a second launch just
  forwards its argv to the old process and exits. Host `pgrep` also sees
  processes inside running docker containers — a cleanup loop during a
  containerised check.py run kills ITS launchers (reads as a timeout).
- Audio measurement: `pw-cat --record --target <sink-or-name> -P
  '{ stream.capture.sink=true }'` + a few lines of Python (rms/peak).
  CAUTION: the built-in ALSA sink's monitor MONO-ISES — L/R comparisons
  through it are meaningless. For stereo truth, create a null sink
  (`support.null-audio-sink`, audio.position=[FL FR]), point the stream at
  it via `pw-metadata <id> target.object <name>`, capture ITS monitor —
  and validate the rig with a hard-panned reference first.
  Xruns: `pw-top -b` ERR column. Destroy test sinks when done.
  If EVERY capture reads pure zeros (rig included): check
  `~/.local/state/wireplumber/restore-stream` for a stale
  `Output/Audio:media.role:Music:channelVolumes=0.0;0.0;` — pw-cat tags its
  playback `media.role Music`, WirePlumber restores the saved zero onto it,
  and nothing anywhere says so (links active, volume 1.0, mute false — the
  zeros ride channelVolumes). Cost hours on 2026-07-31.
