Changelog
All notable changes to tinyjs. Versions are git tags (vX.Y.Z); a tag push builds and publishes the release. The rendered version of this file lives at https://tinyjs.app/changelog.
0.67.1 — 2026-10-28
- Linux X11: backend dialogs from a tray action now come up in front (#95). A windowless app's
app.dialogpanel was mapped without a timestamp, so GNOME's focus-stealing prevention showed an "is ready" notice and placed the panel beside the focused window instead of over it. The launcher now presents confirm/alert/prompt panels and the file chooser with an X server timestamp, as it already does for windows (#89). Wayland is unchanged; it already raised them.
0.67.0 — 2026-10-28
- Backend native dialogs:
app.dialog(#95). All seventiny.dialogmethods are now available from the backend with the same arguments and results, including tray/menu hooks and"mainWindow": falseapps. The existing launcher panels and reply protocol are reused; no WebView is needed. Page calls retain their existing origin and capability gates.
0.66.3 — 2026-10-28
- macOS:
navigator.mediaDevicesis no longerundefinedundertinyjs devon macOS 26+ (#96). WebKit hides the whole API unless itsMediaDevicesEnabledpreference is on, and that default demands a TCC grant or a bundle usage string — dev mode has neither, because the bridge spawns the launcher through a title-named symlink, so WebKit sees no Info.plist at all (wrapping the launcher in a real.appcan't help for the same reason). The launcher now flips the private-but-stable_setMediaDevicesEnabled:preference (WebKit SPI since macOS 10.13) with the launcher's other private WKPreferences, before the first navigation — dev/spawn mode only, main and secondary windows; packaged apps keep WebKit's declare-usage-strings-or-die check. Actual capture is unchanged: the per-origin media gate and TCC still decide everything.
0.66.2 — 2026-10-25
- macOS:
data-tiny-dragdrags engage late or never on a real trackpad (#92).win.startDrag()lands on the main thread via an async dispatch, and a trackpad press is followed by a dense stream of pressure/touch/gesture events — so[NSApp currentEvent]was often no longer the mouse-down whendo_dragwinchecked it, and the drag silently no-oped (up to 15% of gestures never moved; another ~45% engaged 110–165 ms late). ScriptedCGEventgestures never show it — they carry no interleaved events. The launcher now caches the last left mouse-down from asendEvent:hook and hands it to the drag APIs whenever the button is still held ([NSEvent pressedMouseButtons]— the guarddo_resizewinalready uses), which is what AppKit asks for: "the original mouseDown event".tiny.win.dragOut()(startDrag({ files })) had the same race with the same symptom and now shares the cache. Retries landing while a drag is already engaged stay harmless. The cached press is dropped on its mouse-up and only drags the window it landed in, so astartDrag()can't glue the window to a drag in another app (the button mask is system-wide). Regression harness:test/drag-race/reproduces the race synthetically (scrollwheel burst between press and dispatch). (#92, #93, thanks to @skuridin for the diagnosis, the fix and the regression harness.)
0.66.1 — 2026-10-25
tiny.capabilities.readycan't hang a page. It resolves within 3 seconds even if the answer hasn't arrived — on Windows a window-mode popup's calls never return (a known WebView2 issue), and on Linux the first answer waits on D-Bus probes. A late answer still lands and firesonChange. The Linux probes now start when the app does, and windows opening together share one probe instead of each starting their own.- Linux Wayland:
setLevel,setAlwaysOnTopandsetAllSpacesare reported as unsupported. GTK has no way to do them on Wayland, so they did nothing whilesystem.capabilities()(and sotiny.capabilities.can()) claimed them. New keywindowLevel;setAllSpacesis nowfalseon Wayland too. X11 and XWayland (windowPlacement) are unchanged. - Linux Wayland:
setClickThrough(true)works. GTK rewrote the window's input region on every relayout, so the empty one the launcher set never reached the compositor and the window kept taking clicks.
0.66.0 — 2026-10-24
tiny.capabilities: ask whether a call will work, by the call. Everytinyfunction exists on every page whether or not it works there, so a page couldn't feature-detect without knowing two vocabularies:system.capabilities()'s platform keys (badge) and the"api"gate's wire names (app.badge,clip.write) inapi.denied. Nowtiny.capabilities.can('app.badge')answers for the path you'd call, folding in the OS, the Linux session and the gate for this page's origin.await tiny.capabilities.readyonce; after thatcan(),get()andlist()are synchronous.get(path)says why not:gate(withneeds, the names to add to"enable"),platform, orsession(Wayland).can('tray.*')is true if any tray call works, andonChangefires when the answer arrives. It works under any gate, even"none", and a page is told only about the names its owntinyuses: unlikeapi.denied, it never lists the app's own api methods to a site that can't call them. Docs: portability.
0.65.0 — 2026-10-23
- macOS:
tinyjs wrapno longer fails withcommand failed (osascript)(#91). The release didn't includenative/pad-icon.jxa, the script that pads a site's icon to the Apple icon grid, so with an installed tinyjs every wrap of a site with an icon stopped right after fetching it. The macOS download ships it again, and the padding step is now best-effort as intended: if it fails, the wrap finishes with the icon as fetched. - macOS: panel windows, for palettes over full-screen apps (#89). A menu-bar app couldn't open a window that both appears over a full-screen app and takes the keyboard:
show()activated the app, which made macOS switch to a desktop Space, andshow({ activate: false })stayed put but never got keyboard focus."chrome": { "panel": true }(tinyjs.json for the main window, orwin.open's chrome) makes a non-activating panel, the way Spotlight-style launchers do it:show()gives it the keyboard without activating the app, so withsetAllSpaces(true)it opens over the full-screen app. Edit shortcuts (⌘C, ⌘V, ⌘X, ⌘Z, ⌘A) work in the page, andfocus/blurfire. Open-time only; Windows and Linux ignore it. - Windows: a tray app's first
show()now reports focus. For an"activation": "accessory"app,onWindowState/tiny.win.onStatemissedfocused: trueon the first show and then thefocused: falsewhen the user clicked away, so a dropdown that hides when it loses focus stayed open the first time. - Linux X11:
show()takes the keyboard every time, not just the first (#89). A tray or hotkey app's window shown over another app — a full-screen one included — took focus on its first show only; after a hide, GNOME's focus-stealing prevention left it on top but marked "needs attention", with the keyboard still in the other app.win.openof a new or hidden window did the same. Showing now carries an X server timestamp, asrestore()already did. - Linux X11:
setLevel()andsetAlwaysOnTop()survive hide and show. A window given a level while it was on screen came back as an ordinary window afterhide()→show(); only a level set before the first show stuck. The launcher now re-applies it each time the window is mapped.
0.64.0 — 2026-10-23
- macOS: apps can ask for Location, so
tiny.system.wifi()can read the network name (#90). On macOS 14+ssidandbssidarenullwithout the Location permission, and there was no way to get it: the app never asked, and macOS only lists an app under Location Services once it has. Declare"permissions": { "location": "why" }in tinyjs.json (it writes the Info.plist usage string, and signed builds get thepersonal-information.locationentitlement), then calltiny.app.permissions.request('location')—checkworks too. The prompt only appears in a packaged app; intinyjs dev, or without the declaration,requestresolves'undetermined'without asking. Windows and Linux answer'unsupported'.
0.63.0 — 2026-10-21
- Security: a wrapped site can no longer open local files or programs.
tinyjs wrapgives the wrapped site the"wrapper"preset, which includesshell.open, andshell.openhanded any target to the OS — a local path, afile://URL, any URL scheme (on Windows, straight to ShellExecuteW). A site could open a program it had just downloaded. A page the app didn't ship now getshttp(s)andmailtolinks only; the app's own pages (file://, the dev server) can still open anything.test/shell-open-gate/has the headless check and the hostile-page run. - Auto-update no longer installs the wrong build. Without signed updates,
install()didn't check that an update had been found. When a release had no build for this OS and CPU — an arm64-only Mac release on an Intel Mac, a Windows arm64 app with nowin.arm64entry — it installed the manifest's top-level download anyway (on Windows arm64, the mac zip, whose swap then deleted the app'sresources/andexecutables/), and with nothing newer it reinstalled or downgraded. It now installs only whatcheck()calls an update for this machine, and rejects withno newer update available for this platformotherwise. Signed updates already worked this way.test/update-install-check/check.jscovers it. - Security:
"api"gate values that looked restrictive no longer allow everything."api": falseand""meant no gate at all, the same as leavingapiout; they now mean"none". An object with only anenablelist —{ "enable": ["notify"] }, at the top level or insideorigins— disabled nothing and so allowed every call; it now means "only these", like the list form. Any other value that isn't a string, list or object denies everything and logs why. Apps that wrote these get stricter, never looser.test/api-gate/check.jscovers every form. - Hotkeys and keystrokes: a combo is never mistaken for a bare key. On macOS
super/winweren't modifiers, so'super+k'registered a bare K as a global hotkey — every K typed in every app went to your app. On Linux any unknown word did the same ('hyper+k'), and on Windows'a+b'kept the last key. The three launchers now share one parser: modifiers in any order (Windows used to need them first), exactly one key, and anything else is refused and logged (tinyjs: hotkey "…" not registered: …). On macOSwin/supermean ⌘, as they already did in menus. A combo the OS won't register (taken by another app) is logged too, instead of failing silently. - One throwing
tiny.api.onlistener no longer silences the rest. If a page listener threw, the listeners registered after it never ran for that event — for tinyjs's own events too (menu,tray,drop…). Each listener now runs regardless; the error is still reported in the console. The backend's hooks already worked this way. - A
'gain'audio filter without againno longer mutes the app. The native filter chain defaulted every filter'sgainto 0 — right for the EQ shapes (dB), but for type'gain'it's a linear multiplier, so{ type: 'gain' }(say, with onlygainRto pan) silenced the output on macOS and Linux while the page chain left it unchanged. It now defaults to 1 there, as it does inpageChain. tiny.d.tssays what the code does. Editor hovers were wrong in about a dozen places:notify()resolvestrueonce the notification is handed to the OS (notfalseon failure);proxyURLdoes nothing on Windows;audioTapneeds macOS 14.2, not 14.4, and never rejects with'denied';TinyBattery.percentcan benull;tray.position()is alwaysnullon Linux;win.share()is macOS-only and resolvestrue;log()takes any JSON value; plus the filter limit per OS,attention()per OS, thecaptureScreendefault,spotlightper OS, tray icon paths,win.open'spage, and the call origin (stamped on all three OSes).- Security: update redirects are checked at every hop. The update manifest and download had to end at an
https://address, but the hops in between weren't checked, so https → http → https passed — and a network attacker on the plain-http hop could hand the app a manifest of their choosing. Each hop is now checked before it's requested.test/update-redirects/check.jscovers it. - A failed update can't leave a half-copied app (macOS, Linux). The current app was moved aside before the new one was in place; when the new one had to be copied (always on Linux, where the temp folder is another filesystem) and the copy failed partway, the app folder was left half-copied and the rollback silently failed. The new version is now staged beside the app first, and the current app is only touched once it's complete. The copy also keeps symbolic links.
test/update-swap/covers it. - Editor hints for
tinyjs.jsonmatch the docs. The schema's descriptions — what editors show on hover — now say which OSes each key works on and what it really does (activationworks on all three,permissionsdiffer per OS,downloads/popupsaren't wrapper-only,frontend.devneedsdevUrl, theapiforms…).titleandidno longer acceptnull, which replaced the default and builtdist/null.app. - Windows: clipboard reads stop leaving files behind. Every
clipboard.read()of an image wrote a new temp PNG, plus an empty placeholder file, and nothing removed them. There's now one file per clipboard change, deleted when the clipboard changes again (as on macOS).pathsis[]rather thannullwhen no files are copied, as on macOS and Linux. Screenshots stop leaving an empty placeholder too. - Windows: an interrupted
say()resolvesfalse. One cut off by a newersay()or bystopSpeaking()resolvedtrue, as if it had finished; it now resolvesfalse, as on macOS. - Linux under Wayland: keystrokes and permissions answer honestly.
keystroke()andpaste()posted keys into XWayland and answeredok: true, though the Wayland app in front never saw them; they now send nothing and answer{ ok: false, trusted: false }, matchingcapabilities().keystroke.permissions.check('accessibility')andcheck('screen')answer'unsupported'there instead of'granted'. - macOS: every popup goes through
onWindowOpen. A link withtarget="_blank" rel="noopener"(which WebKit sends as a navigation with no target frame) opened the browser directly —onWindowOpennever heard of it and the page got nopopupevent. It now gets the same ask and event aswindow.open. - Windows:
userAgentapplies to every window. It was set on the main window only, so a wrapped site's sign-in popup — and any other window — went out with WebView2's stock user agent. macOS and Linux already applied it everywhere. - Security: events to pages go through the
"api"gate. Every event the backend sent reached every window, so a wrapped site heard deep links (with their OAuth codes), dropped and downloaded file paths, navigation, the audio tap and every event the app pushed. With an"api"block, a page the app didn't ship now gets an event only if its rules allow it: tinyjs's own events count as their calls (menu.*,win.*,theme.get…, so the"wrapper"preset keeps menu, tray, window, theme, locale, Now Playing and notification events), and the rest — including every event your app pushes — needevent.<name>. The check is made against the page actually in the window when it's delivered. Apps without an"api"block are unchanged. An app that pushed its own events to a wrapped site now has to grant them.test/event-gate/has the headless check and the wrapped-site run. hotkey.register()says whether it worked. It resolvestrueonce the system has the hotkey andfalsewhen the combo isn't valid or another app holds it (the reason is logged), on the page and in the backend. It used to resolve the same either way. On Linux under X11 a combo another client has grabbed is now detected too; under Waylandtruemeans it was handed to the desktop, which asks the user.- Hotkeys and keystrokes: the same key names on every OS. Each launcher knew a different set: Windows had no
minus/slash/quote…, macOS nohome/end/pageup/pagedown/backspaceor keys above F12, Linux noquote, and only Windows took punctuation typed as itself. All of them now take the full set, punctuation characters ('cmd+,') and the aliases (enter,esc,apostrophe…). Windows:deletenow means Backspace, as it always did on macOS and Linux; useforwarddeletefor the Delete key. - Linux:
capabilities().globalHotkeysis honest under Wayland. It saidtrueeverywhere; on a Wayland desktop without the GlobalShortcuts portal hotkeys never fire, and it now saysfalsethere. A new requirement,tiny.system.requirements(['globalHotkeys']), explains why and what to install. - Linux: file associations join the existing file type. For each
fileExtensionsentry the app defined a type of its own (application/x-<id>-md) with the same*.mdpattern as the real one, which could change how file managers and other apps classify those files. It now lists the app under the type the system already has (text/markdown), and only an extension nothing claims gets its own. The type file an earlier version wrote is removed on the next launch. Types other tinyjs apps invented are ignored too (one still installed from an older version would otherwise be joined instead oftext/markdown).setAsDefaultHandler()uses the same type. - Full txiki.js types for the backend.
types/tjs.d.tswas a stub that typedtjsasany; it's now txiki.js's own definitions (@txikijs/types, matching the bundled 26.6.0) in one file, so editors complete and checktjs.*calls and everytjs:*module import — without npm. New projects get it, andtinyjs update-projectupgrades existing ones. Vite projects get it inbackend/only:src/is the page, which has notjs, so it no longer gets atjs.d.ts. - New docs site. The docs are now pages at tinyjs.app/docs, one per topic, with search. Old
docs.html#…andapi-map.html#…links still land on the right page.
0.62.1 — 2026-10-21
- macOS:
contextMenu: falsealso covers dialogs. The title and message of an alert, confirm or prompt (tiny.dialog.*and the page's ownalert()/confirm()/prompt()) could be selected, and right-clicking them opened the system text menu (Look Up, Translate, Services…). WithcontextMenu: falsethat text is now plain and shows no menu; a prompt's input keeps its menu. Apps without the setting keep the standard macOS behaviour. (#83, thanks to @Blatman for the report.)
0.62.0 — 2026-10-21
- Windows on ARM: native arm64 builds. Releases now include
tinyjs-windows-arm64.zip, and the Windows installer picks it on an ARM64 machine; before, Windows on ARM ran the x64 build under emulation. txiki.js publishes no Windows ARM64 runtime, so release CI buildstjs.exefrom source the way txiki builds its x64 one, and compiles the launcher with llvm-mingw. WebAssembly's WASI calls needed two patches to txiki's WebAssembly engine (WAMR) on Windows ARM64, innative/patches/: its upstream ARM64 call routine, and a fix to the argument layout it uses there; txiki's own WebAssembly tests run against every build. Apps build for the CPU of the tinyjs that builds them, so an arm64 tinyjs makes arm64 apps.tinyjs publishnames that zip<name>-<ver>-win-arm64.zip. Publishing the same project with an x64 and an arm64 tinyjs merges both into the manifest's"win"block; the x64 build also stays inwin.url, where existing installs look. An x64 app on an ARM64 machine updates to the arm64 build when there is one, and an arm64 app never falls back to an x64 build. In a source checkout,setup.ps1fetches the arm64tjs.exefrom the latest release and compiles with llvm-mingw when it finds it. (#82, thanks to @zhuyuanmin for the request.) tiny.system.architecture()saysarm64on Windows on ARM. It returnedx86_64on every Windows machine, an ARM64 one included, and so didinfo().arch. It now reports the machine, the waytinyjs doctordoes, so an x64 build running emulated on ARM64 saysarm64as well.
0.61.0 — 2026-10-20
- Signed app updates (opt in). The sha256 in an update manifest comes from the same host as the download, so anyone who controls that host (domain, DNS, bucket, release) could ship their own build to every installed copy. Set
"update": { "publicKey": [...] }to one or more Ed25519 public keys and the app only installs a manifest signed by a matching private key, whichtinyjs publishreads from the file named byTINYJS_UPDATE_PRIVATE_KEY_FILEand never copies into the project. Pin a signing key plus an offline backup, so a lost or leaked key can be replaced without reinstalling. The signed manifest keeps its plain fields, so installs without a key keep updating from the same url;--signed-onlycuts them off.--expires 30d(or a date) makes a manifest expire, andpublish --sign-manifest FILEsigns a manifest merged from several platforms or extends an expiry. Apps withoutpublicKeybehave as before. Guide: docs/signed-updates.md. (#40, #63, thanks to @Zulut30 for the implementation.)
0.60.0 — 2026-10-20
extras/: a README or licence beside the app in the download. Files in a project'sextras/folder ship next to the app, never inside it: at the root of the macOS dmg beside the .app and the Applications shortcut, and in the Windows/Linux app folder beside the executable (dist/and the publish archive).extras/macos/,extras/windows/andextras/linux/replace a file of the same name for that OS, and"extras": [...]in tinyjs.json picks paths instead. Folders ship whole and exec bits are kept, so aninstall.shin a Linux tarball runs. A name the build already uses there (launcher.exe, the .app,Applications, …) fails the build. On macOS only the dmg carries them (build --dmg, ornotarize --dmg, which re-reads the folder); the update zip doesn't. A Linux update replaces the extras with the new version's; a Windows update overwrites them by name, so one you drop or rename stays behind. A README or licence put inassets/gets a note pointing atextras/.onScreensChanged/onSystem('screens')no longer miss a real change. Since 0.59.0 a screen being plugged in, unplugged or rescaled went unreported if the app calledscreens(),screenAt(),screenFor(),foregroundScreen()ormatchScreen()around the same moment. Pollingscreens(), as a screen map does, could swallow every change. Reports where nothing changed, such as macOS's around fullscreen, are still dropped.
0.59.2 — 2026-10-20
- Windows:
tiny.dialogbutton labels.alert,confirmandpromptnow show theokandcancellabels you pass, as macOS and Linux already did; before, Windows always showed OK/Cancel. Buttons grow to fit a long label, and the box widens if they need the room. A&in a label shows as a&rather than underlining the next letter. The page's ownprompt()keeps OK/Cancel. (#81)
0.59.1 — 2026-10-20
tiny.dialog.confirm(message, 'detail')shows the detail. A string second argument was spread into the options as{ 0: 'D', 1: 'e', … }, so the detail and any button labels were silently dropped.confirmandalertnow take either the detail string or{ detail, ok, cancel }, andalertcan relabel its button ({ ok: 'Got it' }).promptrejects a non-object second argument with aTypeErrorinstead of ignoring it. Button labels apply on macOS and Linux; Windows still shows its own OK/Cancel. The docs now point out that the page's bareconfirm()is the browser's own: one argument, the app name (or the site's origin) as the headline, OK/Cancel only. Usetiny.dialog.confirmfor anything more. (#81, thanks to @Blatman for the report.)
0.59.0 — 2026-10-20
- Windows: one coordinate space across monitors with different scaling.
screens(),setPosition,getState()andmousePosition()each divided by a different monitor's scale, so on a mixed-DPI setup they disagreed. A 125% laptop to the right of a 100% 1920-wide monitor reportedx: 1536, a point inside the left monitor. They now share one layout, built the way Chromium builds its own: the laptop sits atx: 1920, andsetPosition(getState().x, getState().y)stays put on every monitor. If you worked around this by multiplying byscalebeforesetPosition(the workaround in #70), remove it. Single-monitor and same-scale setups are unaffected. - Place windows relative to one screen.
setPosition(x, y, { screen })takes coordinates relative to that screen's top-left, in its points, or in its pixels withunits: 'px'.setSize(w, h, { units: 'px' }),setFullscreen(true, { screen })(which also works on Wayland) andwin.open(id, { screen, x, y })work the same way. There is no global pixel space, because macOS doesn't have one, sopxneeds ascreen. - More about each screen.
screens()entries gainuid(the stable identity to persist: display UUID on macOS, monitor device path on Windows, connector + model on Linux),internal(the built-in panel) andpixels.getState()gainsscreenId,localandpixels;onStateevents carryscreenIdand fire when a window moves to another screen;mousePosition().screengainsid. New:screenAt(x, y),screenFor(rect),foregroundScreen()(the screen of the focused window of any app, no permission needed;nullon Wayland),matchScreen(saved)andonScreensChanged(fn)(backend:onSystem('screens')). "rememberWindows": truein tinyjs.json puts every window back where the user left it: position, size, screen and maximized/fullscreen, kept per set of plugged-in screens and matched across replugs. A window that was maximized comes back maximized on the screen it was on, and a move made just beforequit()is saved. The main window is shown once it has been placed. Off by default. (#70, thanks to @lionel-rowe for the report.)- Windows: windows keep their size when moved to a screen with a different scale. A secondary window kept its pixel size, so a 600×400 window dragged from a 100% screen to a 150% one came out 400×267, and one opened on a scaled screen started a few points short. A main window with a menu bar lost the bar's height on every crossing (720 → 700 → 680).
- Windows: no more crash on quit. An app that was still sending updates to its windows as it quit could make the launcher crash during teardown (amp did on every quit). Since 0.58.1 that showed up as
webview launcher exited with status -1073741819and a non-zero exit code. - Linux X11:
getState()x/y is the frame's top-left, as documented. For a window with a title bar it reported the top-left of the page area instead, one title bar lower than the spotsetPositionmoves the frame to, sosetPosition(getState().x, getState().y)moved the window down by one title bar each time. Frameless windows and Wayland are unaffected.
0.58.1 — 2026-10-19
- Linux: apps start on NVIDIA + Wayland. With NVIDIA's proprietary driver on a Wayland session, the window opened and the app died within a second, before the page loaded. The cause was the driver's explicit-sync path: the compositor rejected the window's surface, and GTK exited. The stock WebKitGTK MiniBrowser dies the same way. The launcher now sets the driver's own opt-out,
__NV_DISABLE_EXPLICIT_SYNC=1, when it finds the NVIDIA driver on Wayland. GPU acceleration stays on and fullscreen video works, which theWEBKIT_DISABLE_*workarounds broke. If you have already set the variable yourself, including to0, your value is kept. - A crashed webview no longer looks like a normal quit. When the launcher died,
tinyjs devand built apps still exited with code 0 and printed nothing. They now printtinyjs: webview launcher exited with status N(orkilled by <signal>) and exit with a non-zero code. Thanks to @AbrLis for the report and for tracking down the cause (#78).
0.58.0 — 2026-10-19
app.cookies: read and write the webview's cookies from the backend. The backend can now see the same cookie jar the app's windows and a wrapped site use, including HttpOnly cookies thatdocument.cookiecan't reach.get({ url?, name?, domain?, path? })lists them (with aurl, the ones a request to it would carry),set({ url | domain, name, value, … })adds or replaces one, andremove()deletes one cookie or everything for a url. The case it was built for: a site logs in, the app keeps the session (inapp.secrets), and a later launch restores it so the user skips the login.set()defaultssameSiteto'lax'on every OS. A cookie a site set without SameSite reads as that engine's own default ('none'on macOS,'lax'on Windows, either on Linux by WebKitGTK version). Pages get no cookie API: a page that could read the jar could read every site's session. All three OSes. Thanks to @zhuyuanmin for the request and @slabbdev for helping scope it (#46).
0.57.10 — 2026-10-19
app.paths()creates thedata,cacheandlogsfolders. The docs said these are created on first write, but nothing created them, so writing a log file intopaths.logsfailed until the app made the folder itself. They now exist by the timepaths()resolves, from the page (tiny.app.paths()) and the backend alike, on all three OSes. Thanks to @sheffieldnikki for the report (#71).tinyjs buildandpublishsay when the app is still running. Windows can't delete a running.exe, so rebuilding with the app open failed with a bareError: EEXIST: file already exists. The build now stops withcouldn't clear dist/ — dist/<name>.exe is in use, and asks whether the app is still running. Thanks to @sheffieldnikki for the report (#72).
0.57.9 — 2026-10-19
- Linux:
win.onDropfires once, when the file is released. It fired while a file was still being dragged over the window, every time the drag entered it, and not at the release itself, so a page couldn't tell a held file from a dropped one. Now it fires exactly once, on release, and a drag that leaves the window without dropping sends nothing. macOS and Windows already behaved this way. Thanks to @AbrLis for the detailed report (#77).
0.57.8 — 2026-10-19
- macOS: clicking the Dock icon brings back a window hidden by
setHideOnClose(true). The red close button hid the main window as expected, but nothing brought it back: clicking the app's Dock icon did nothing. Now it shows the same window again, with its page state intact. A visible or minimized window, and an app with"mainWindow": false, behave as before. Windows and Linux already showed the window when the app is launched again. Thanks to @9wick for the fix (#73).
0.57.7 — 2026-10-16
- New:
tinyjs update-projectbrings a project's types and agent skill up to date.tinyjs newcopies the TypeScript types (tiny.d.ts,tjs.d.ts) and the coding-agent skill (.claude/skills/tinyjs/,.agents/skills/tinyjs/) into a project once, and nothing updated them after that. Run it in a project aftertinyjs update. It goes file by file and names every file it updates, adds or removes, so the skill folders end up identical to your tinyjs's. Types are only updated where the project has them, and a project without the skill gets one. Edits to those files are overwritten (git has the old versions). It never writes through a symbolic link: a linked folder or file is skipped by name, so a cloned project can't point it at files elsewhere. A file it can't change, such as one another program has open, is named and the rest still updates (exit 1).tinyjs doctornow says, in a project, when these files are behind. - Docs: what
store.setresolvingfalsemeans. As withlocalStorage, memory is the source of truth: a failed disk write keeps the value in memory, the next successful save writes it, and it's lost if the app quits first. To retry, set the same value again rather than redoing a read-modify-write. In the docs, the types and the agent skill.
0.57.6 — 2026-10-16
- New:
tinyjs doctorfinds what's stopping tinyjs or your project. Run it (from your project folder, if you have one) when something doesn't work. It checks the bundled runtime and launcher; whichtinyjsyour PATH actually runs, and any second install behind it (the usual cause of "I updated but nothing changed"); WebView2 on Windows and WebKitGTK on Linux; which Node tools are on PATH; and the project'stinyjs.json: that it parses and has aname, thatminTinyjsVersionisn't newer than your tinyjs, that the tool your frontend build runs is installed, and thatnode_modulesexists. Each problem comes with how to fix it, and the exit status is 1 when there are any. It only reads files: it runs nothing, opens no windows and doesn't use the network. In a terminal the labels are coloured (NO_COLORturns that off). See docs/doctor.md. Thanks to @Zulut30 for the first version (#61).
0.57.5 — 2026-10-15
- Editor completion and checks for
tinyjs.json. A JSON Schema for the config now lives at https://tinyjs.app/schema/tinyjs.schema.json, andtinyjs newandtinyjs wrapput it in the first line of a new project'stinyjs.jsonas"$schema". VS Code, JetBrains IDEs, Zed and most other editors then complete keys, show what each one does, and flag wrong types or values (such as"popups": "tab") with no setup. tinyjs ignores the key, and the editor fetches the schema, never tinyjs. For an existing project, add the same line. See docs/config-schema.md. Thanks to @Zulut30 for the schema (#64).
0.57.4 — 2026-10-15
- Fix (macOS):
vibrancy: falseno longer paints a transparent secondary window. A window opened with{ transparent: true, vibrancy: false }came up solid white or black (following light/dark mode) instead of see-through, becausevibrancy: falseturned the page background back on for every window except main. Leavingvibrancyout was the workaround. Thanks to @Blatman for the report (#68).
0.57.3 — 2026-10-15
- Fix:
tinyjs updategets through on networks where it kept failing. On some connections txiki's fetch drops the request to GitHub ("closed before established") while curl and browsers get through, and the update failed with "could not reach GitHub to check the latest release". It now tries twice, then asks the system curl (macOS, Linux, Windows 10 1803+). If everything fails, the message says what each attempt hit instead of only "could not reach GitHub". A redirect to a non-http(s) URL is refused instead of crashing the CLI. Thanks to @lionel-rowe for the report and for tracking down the underlying error (#67).
0.57.2 — 2026-10-15
- Fix:
win.open('main')behaves the same on every platform. It now shows and focuses the main window, the waywin.openon any open window focuses it, and ignores the options. UndermainWindow: falseit throws. Before, macOS focused main, Linux ignored the call, Windows built a half-wired second window, and a windowless app got silence everywhere.
0.57.1 — 2026-10-15
- Fix (macOS):
setChrome({ squareCorners })no longer re-enables resizing. Switching square corners on or off rebuilt the window's style with the resize bit always set, so a window that had calledsetResizable(false)became resizable again. It hitapp.window(id)andtiny.winin named windows, and turning square corners off on the main window. Windows and Linux weren't affected.
0.57.0 — 2026-10-14
The backend app and the page's tiny had drifted into two dialects of the same API: app.setMenu against tiny.menu.set, app.notify({ title, body }) against tiny.notify(title, body), app.paths.data against await tiny.app.paths(), and a handful of calls that quietly did something different on one side. This release makes the backend speak the page's API. Code moves between the two without translating, and one set of docs covers both. The API map lists every backend call beside its page twin.
Tarwin: An apology for the big change. I hope not to do this kind of big change again, but wanted to get it right now. 25 years ago I did a lot of PHP, and I'd always have to look at the docs to know if it was
func(needle, haystack)orfunc(haystack, needle)because it was different per function. So I know the pain of inconsistent APIs.
- Behaviour change:
app.mouseTracking.start()throws on failure, as the page's always did: an Error with.code'unsupported','denied'or'failed'. It used to resolve{ ok: false, … }. Both sides resolve{ ok: true }on success, soif (!r.ok)code still works when tracking starts, but a failure now lands in yourcatch. Only Linux-Wayland can fail. This one can't warn, so check your backend if it callsstart(). - Behaviour change:
app.window('main').close()quits, astiny.win.close()does from the main page. It used to do nothing. - Behaviour change:
app.macos.*throws off macOS, as the page'stiny.macos.*does. It used to answertrue(quickLook) ornull(selectedText,otherWindows, which looked exactly like "nothing selected").macos.ai.availability()is the exception, on both sides: it resolves'unsupported'off macOS. The page's version used to throw, which made the check you're told to make first fail. - Behaviour change:
audio.filter(i, patch)patches. A field you leave out keeps the valuefilters()gave that band. It used to fall back to the default, sofilter(0, { gain: -3 })also reset the frequency and Q. A patch that changestypeorgainRnow rebuilds the chain; it used to be dropped.audio.clear()also re-centres the balance, aspageChain(ctx).clear()always did. Same on both sides, and inpageChain. - The backend uses the page's names. The old ones still work, log a one-line warning the first time each is used, are marked
@deprecatedintiny.d.ts, and will be removed in a future release:app.setMenu/updateMenuItem/getMenuItem/setContextMenu→app.menu.set/update/get/setContextapp.window(id).setMenu/resetMenu/updateMenuItem/getMenuItem→app.window(id).menu.set/reset/update/getapp.openWindow(id, opts)→app.win.open(id, opts);app.getWinState()→app.win.getState()app.setAudioFilters/setAudioFilter/setAudioBalance→app.audio.filters/filter/balance(andclear)app.notify({ title, body, … })→app.notify(title, body, opts)app.info.version,app.paths.data→(await app.info()).version,(await app.paths()).data.JSON.stringify(app.paths)still gives the fields.
app.winis the main window, under the names a page uses for its own (tiny.win.*), plusopen(),close(id?),windows()andsetHideOnClose().close(id)closes that window; no id closes main, which quits. Everyapp.window(id)handle now has itsid. The top-levelapp.setTitle()and friends still target main and aren't deprecated.app.systemhas everythingtiny.systemhas:os(),isMacOS()/isWindows()/isLinux(),info(),architecture(),capabilities(),requirements()andmissing(), alongside battery, wifi, idleTime and locale.app.theme.get()→{ dark } | null, liketiny.theme.get().tiny.app.update.check()/install()in the page. They used to needtiny.api.call('update.check'), which still works.- The clipboard watcher has one owner per side. The backend and each window keep their own
watch()interval, and the launcher polls at the fastest. A page callingtiny.clipboard.unwatch()no longer stops the backend'sonClipboardChange(or another window's watch).tiny.clipboard.onChange()starts a watch if the page hasn't, as the backend's export always did; a handler on its own used to hear nothing. app.presence()warns on anything but'normal'/'menubar'. A typo like'menuBar'used to give a normal app without a word. It will throw in a future release, as the page call already does.- Calls that ignored part of what you passed now say so:
app.startDrag({ files })drags the window (dragging files out only works from the page, inside a mousedown), andtiny.macos.ai.generatecan't usetools(theirrun()can't leave the backend). - Fix:
tiny.system.promptMissing()rejected with "unknown method: win.alert" whenever something was actually missing. It shows its dialog again. - The docs have been gone through end to end. Every page now matches the code as it is, checked call by call rather than by eye:
- A new API map lists every backend
appcall beside its pagetinytwin, with full signatures. It's generated fromtiny.d.tsand checked against the live objects, so it can't drift (scripts/api-map/run.shrebuilds it). - README, docs site and the agent skill use the new names throughout, including
await app.paths()/app.info()andtiny.app.update. - The tinyjs.json reference is complete.
mainWindow,audioTapReason,"activation": "regular",urlSchemearrays, the otherapiforms and everychromekey were missing; thecontextMenucomment had its default backwards. - Portability is accurate again: what's unported on Windows and Linux is checked against each platform's capability table (Linux does have
app.badge; Windows is missingwifi,shareand the native audio chain). Windows and Linux are no longer called beta. Everything is tested on all three, with per-OS handling where a system needs it.
- A new API map lists every backend
- Fix:
"chrome": { "vibrancy": false }in tinyjs.json means no vibrancy in a built macOS app, as it does everywhere else. The build passed it on as a material named "false". - Fix:
app.say(text, null)threw;app.playSound('')sent an empty sound name where the page beeps. Backendclipboard.watch()/unwatch()returntrue, like the page's. tiny.d.ts, removed:app.setZoom,app.battery,app.wifiandapp.idleTime, which were declared but never existed, so calling them threw. Useapp.window(id).setZoomandapp.system.*.tiny.d.ts, corrected: everyon…()helper returns its unsubscribe function (they were typedvoid);locale()can benull(Windows and Linux have no locale reader yet);promptMissingresolves{ missing, copied }; pagestore.set/deleteresolveboolean;app.hide()takes{ app: false }; themouseTrackingtypes match the runtime; a duplicateonNotificationClickis gone. Calls that only acknowledge (window setters, menus, tray, audio, …) resolvePromise<void>instead ofPromise<any>;win.getState()can benull;windowControlsPosis in the chrome options and window state;playSound()'s argument is optional;app.doneis nevernull;app.clipboard.watch()returnstrue.tiny.d.ts, added:tiny.system.battery/wifi/idleTime,tiny.api.off,tiny.win.setMinSize/ensureOnScreen/startResize/onState, the window handle'ssetMinSize/ensureOnScreen/startDrag/startResize,app.audio.sampler,app.spawnHidden,app.setAsDefaultHandler,app.system.locale,minSizein window options,primaryActionon tray specs,roleon menu items,canPositionon window state, and typed results forrequirements()andsystem.info().
0.56.0 — 2026-10-14
tiny.dialog.saveFile({ name })pre-fills the file name.saveFile({ types: ['docx'], name: 'report' })opens the save panel withreport.docxin the name field instead of "Untitled" (macOS) or an empty field (Windows, Linux), so an app can suggest the name the user would otherwise type every time. It is a file name, not a path: any folder part is dropped, so a page can't use it to open the panel somewhere else. The panel opens in its usual folder. Verified on all three platforms. Thanks to Blatman for the request (#59).
0.55.1 — 2026-10-14
- Windows:
"activation": "accessory"keeps a normal title bar. Hiding the taskbar button also turned the window into a tool window, with a small caption, no icon, no minimize or maximize, and an odd close button. The window now keeps its full title bar and still has no taskbar button; the same goes forapp.presence('menubar'). With nothing on the taskbar to minimize to, minimizing hides the window, and the tray (orshow()) brings it back. A showing accessory window is listed in Alt+Tab. Thanks to lionel-rowe for the report (#56). - Windows:
setResizable(false)no longer grows the page. The page came out 10px wider and taller than declared, because the thinner frame's spare pixels went to the page. The page now keeps its size and the window's frame shrinks around it. Thanks to sheffieldnikki for the report (#57).
0.55.0 — 2026-10-14
src/shared/: code the backend and the page both import (#58). Put it besidesrc/backend/(orshared/beside the Vite templates'backend/) and both sides use the same path,import { X } from '../shared/settings.js', intinyjs devand in a built app. It ships next to the frontend, the macOS page may read it, and editing it in dev restarts the app. The layout before 0.52 (src/main.js) gets a hint to move the backend intosrc/backend/instead.- Linux:
tinyjs devsees edits in subfolders. Linux file watching only reports a folder's own files, so editingsrc/backend/lib/util.jsnever restarted the app and editingsrc/frontend/css/app.cssnever reloaded the page. You had to save a top-level file or restart dev by hand. Dev now watches every subfolder of the backend,shared/and frontend folders, including folders you add while it runs. macOS and Windows already watched the whole tree.
0.54.0 — 2026-10-14
executables/: sidecar programs that ship with the app. Put the programs your backend launches (a Go or Rust CLI, abun build --compilebinary, ffmpeg) in anexecutables/folder. It works likeresources/: the whole folder ships,"executables": [...](per OS through themacos/windows/linuxblocks) narrows it, andapp.paths.executables((await tiny.app.paths()).executablesin the page) is where totjs.spawnthem from, ornullwithout any. A build ships only its own OS's binaries, so every OS's can live there together;executables/<os>/andexecutables/<os>-arm64/or-x86_64/override files by name for that OS and CPU. On macOS they go inContents/Helpers/and are signed with the app's identity, with the hardened runtime under a Developer ID, so a notarized app can carry them;"macos": { "executableEntitlements": { "my-cli": [...] } }gives a JIT sidecar (Bun, Deno, Node/pkg) theallow-jitit needs to run there. The build warns when a macOS sidecar can't run on every Mac the app runs on. Thanks to Luiz Américo, Sam LABBE and Blatman for the design discussion (#8).- A backend at the project root no longer copies
resources/andassets/into the app's sources ("backend": "main.js"); they ship only through their own paths. - Windows: an update removes the files the new version dropped from
executables/andresources/; until now they stayed in the install folder (macOS and Linux replace the whole app, so they never had this). The rest of the folder is left alone, and a failed update still restores every file. It takes effect from the update after an app built with this one, since the installed version's updater does the swap.
0.53.2 — 2026-10-14
- Windows: the first window no longer sometimes stays blank in
tinyjs dev. The fix in 0.48.0 made the first page load wait until WebView2 had finished an internal step before sending it, but a freshly started WebView2 could still silently drop it, leaving an empty window. The launcher now checks a second later that the load actually started, and sends it again if it didn't (up to 4 tries). Wrapped sites benefit too. Thanks to lionel-rowe for the report and the trace (#32).
0.53.1 — 2026-10-14
tinyjs newno longer tells you to runnpm installandnpm run dev. That advice was create-vite's own closing message, printed just before ours; it is now hidden, so the only next steps shown are tinyjs's (cd, thentinyjs dev). Thanks to Raymond Camden for the report (#43).- Docs: the devtools key on macOS.
"debug": trueopens devtools with F12 on every OS, and ⌥⌘I also works on macOS, where F12 needs fn on most keyboards."debug": "open"keeps the inspector open through page reloads andtinyjs devbackend restarts (#44).
0.53.0 — 2026-10-13
resources/: data files that ship with the app. Put files your code reads at runtime (a dictionary, a database seed, fonts) in aresources/folder and the whole folder ships with the app.app.paths.resourcesin the backend, and(await tiny.app.paths()).resourcesin the page, point at it: the folder itself in dev, the bundled copy in builds,nullwhen there is none."resources": [...]in tinyjs.json ships only the listed paths, and themacos/windows/linuxblocks can list different ones per OS. Thanks to Manacost Dev for the implementation (#49) and Luiz Américo and Sam LABBE for the design discussion (#8).assets/: the icon, About credits and help book in one place. New projects keepicon.pnginassets/, next to the other files the OS reads. Existing projects withicon.pngandCredits.htmlat the project root keep working unchanged. The build notes any file inassets/it doesn't use, since nothing else there ships.- macOS: help books. Put an Apple help book folder in
assets/and name it with"macos": { "helpBook": "My App Help" }. The build bundles it, registers it in Info.plist and rebuilds its search index, and the app gets a Help menu with "<App> Help" (⌘?) and a search field that searches the book. Thanks to Blatman for the request and a sample book (#47). { role: 'help' }insetMenu. Your own Help menu items: on macOS the menu always goes last and gets the system search field (plus "<App> Help" when there's a help book); on Windows and Linux it's a plain "Help" menu.- macOS:
Credits.rtfandCredits.rtfdwork as About panel credits, as well asCredits.html. - macOS: built apps carry
CFBundleShortVersionString, set fromversion, the key Finder and the rest of macOS read the version from. - Linux: clicking a menu item no longer gives it a check mark. GTK toggled every menu bar and tray item on click, so a plain item showed ✓ after one use and a checked one lost its ✓, whatever the app had set.
checkednow only changes when the app changes it, as on macOS and Windows. - Windows:
tinyjs publishfrom Git Bash writes a real zip. Git Bash puts GNU tar first on PATH, which ignores-aand wrote a tar archive named-win.zip, and an app started from Git Bash couldn't unpack its update zip. Both now use Windows' owntarfrom System32.
0.52.0 — 2026-10-13
tinyjs devno longer restarts the app on frontend edits in the Vite templates. The dev watcher watchedsrc/, which in the Vite templates is the frontend, so every save restarted the whole app after Vite's HMR had already applied it. Backend edits inbackend/were never watched, so they didn't restart at all. The watcher now follows the backend entry's own folder: frontend edits are left to HMR and backend edits restart. A backend at the project root ("backend": "main.js") skips.build/,node_modules/,.git/and the build output. Thanks to Anton Tsvilikhovskiy for the report (#53) and Sam LABBE for the fix (#54).- New vanilla apps put the backend in
src/backend/, next tosrc/frontend/instead of above it.tinyjs newscaffoldssrc/backend/main.js, and it is the first place the backend is looked for. Existing apps withsrc/main.jskeep working unchanged. "frontend": { "reload": false }stopstinyjs devfrom reloading the page on frontend changes. Use it when your own tool already does (a watch-mode bundler writing into the frontend folder plus a livereload script), so the page doesn't reload twice per save. Dev servers set withdevUrlnever had tinyjs reloads.
0.51.1 — 2026-10-12
- The installers no longer get rate limited on CI.
install,install.ps1,tinyjs updateand the daily update notice looked up the latest release through GitHub's REST API. Without a token, GitHub allows 60 of those calls an hour per IP, and shared CI runners use that up, so installs failed with a 403 ("could not determine latest release") or "API rate limit exceeded". They now read the tag from thegithub.com/tarwin/tinyjsapp/releases/latestredirect, which isn't limited this way and needs no token. Pinning withTINYJS_VERSIONworks as before. Thanks to Tulio Calil for the report (#51).
0.51.0 — 2026-10-12
"mainWindow": false: tray agents with no window until they need one. An app with"activation": "accessory"only hides its main window; the web engine still runs behind it. With"mainWindow": falsethere is no main window or WebView at all. The backend, tray, menus, hotkeys and clipboard run on their own, and the engine starts when the app opens a named window withapp.openWindow. Idle memory for a tray agent drops from ~65 to ~17 MiB on macOS, ~111 to ~12 MiB (private bytes) on Windows and ~329 to ~111 MiB (RSS) on Linux.app.windows()starts as[], noindex.htmlis needed, and closing the last window leaves the app running.app.hide()/app.show()hide and restore the open windows, andapp.reload()reloads them. Can't be combined with"url". Combine it with"activation": "accessory"for a menu-bar / tray-only app. Thanks to Mikhail Angelov for the macOS implementation (#50).- Linux: named windows open with the current
app.icon(). They always took the startup icon, so a window opened afterapp.icon()didn't match the rest.
0.50.1 — 2026-10-07
Security fix.
- macOS: an iframe can no longer call your app. WebKit gives the page's message handler to every frame, not only the top one, so any iframe in an app's page (a CodePen embed, an ad, a wrapped site's widgets) could post a hand-built call. Without an
"api"block every call was allowed, so that iframe could call the app's own backend methods and every built-in one, AppleScript, keychain secrets and the clipboard included. Its replies, including errors, also went to the top frame and settled the top frame's own pending calls with the same number. The launcher now marks calls from subframes, and the backend runs one only when an"api".originskey names the iframe's origin, under that key's lists. No"api", a preset, or top-level lists alone give subframes nothing. A subframe's replies no longer reach the top frame. Top-level pages, satellite windows and wrapped sites are unchanged. Linux already dropped subframe calls (0.46.0), and WebView2 on Windows only delivers messages from the top-level document. macOS apps that show third-party iframes should update.
0.50.0 — 2026-10-05
tinyjs new --templateasks, and works with your package manager.--templateon its own asks for the framework (vanilla, react, vue, svelte, solid, preact, lit, alpine) and the language, then which package manager to use. Only the ones that actually run on this machine are offered: npm, pnpm, yarn, bun, or Vite+ (vp) over one of them. It then installs the dependencies. Flags answer each question without a prompt:--template react-ts,--pm pnpm(or--pm vp:pnpm),--no-install. With no terminal it uses npm, and a bare--templatestops with the list. tinyjs.json runs the project's dev and build scripts through the chosen manager (pnpm run dev).- Alpine.js starter:
--template alpine/alpine-tsis Vite's vanilla template plus Alpine.js, with a small component that calls the backend. - Vite+ (
--pm vp) needs vp 1.0 or newer. A 0.1.xvpscaffolds projects that mix 0.1 and 1.0 Vite+ packages and fail every build with a misleading "Cannot find native binding", so tinyjs refuses it and says to runvp upgrade. - TypeScript backends build in projects that pin another package manager. npm 11 refuses
npxin a project whose package.json setsdevEngines.packageManagerto something else (Vite+ 1.0 writes pnpm's), so the esbuild step failed withEBADDEVENGINES. esbuild now runs from the project's ownnode_moduleswhen installed, otherwise throughnpxfrom a temp folder. - CLI prompts show up. The terminal check always failed, so no prompt ever appeared:
tinyjs wrapsilently took the exact origin instead of asking whether to include subdomains. It asks now.
0.49.0 — 2026-10-05
Thanks to @slabbdev for the About-panel change (#37) and to @cfjedimaster for the report (#41).
- macOS: your own credits in the About panel. A
Credits.htmlin the project root is bundled into the app bytinyjs build, and the standard About panel shows it (links work) instead of "Made with tinyjs". Start it with<meta charset="utf-8">and set a font, or AppKit reads it as Latin-1 and sets it in Times. Without aCredits.html,"attribution": "…"in tinyjs.json replaces the credit line with your own (#37). tinyjs build/tinyjs devsay "runnpm installfirst" when a bundler project (--template …) has nonode_modules, instead of failing inside the project's own tools. That was a TypeScriptCannot find type definition file for 'vite/client'error with a globaltscinstalled. Anode_modulesin a parent folder (workspaces) and Yarn PnP both count (#41).- A backend entry at the project root builds. With
"backend": "main.js"the build copied the whole project, including its own build folder, into the app until it failed withENAMETOOLONG. It now skips.build,node_modules,dist,.gitand the frontend folders. - Docs: the project folders are conventions, now documented:
"backend"picks the backend entry (its folder is the backend's root), and"frontend": { "dir": "…" }picks the plain page folder (defaultsrc/frontend).
0.48.0 — 2026-10-05
Thanks to @slabbdev for the report, triage and fix (#36, #38) and to @zmila for tracking down the Windows blank window (#32).
- A page's
tiny.win.printToPDF(path)can't write just anywhere.win.*is part of the"wrapper"preset, so a wrapped site could write PDF bytes over any file the user can write. From the app's own pages nothing changes. From any other origin, the PDF is written directly only into Downloads (a bare file name lands there), the app's data folder or the temp folder; any other path opens a save panel pre-filled with the name, and cancelling rejects. Backendapp.printToPDFis unchanged. All platforms (#36). - Windows: an app whose first page is
httpno longer opens blank. The launcher cancels a page's first load to askonNavigate, then retries it. On a cold start WebView2 takes ~800ms to finish the cancel, and the retry, sent before that, was silently dropped. That hit every dev-server app (--template react-ts,svelte-ts) and wrapped sites; adding a menu only hid it by changing the timing. The retry now waits for the cancel to finish (#32). tinyjs devno longer reports its own entry file as an opened document. Every dev launch sent anopen-filesevent naming.build/app/entry.js; parsing the command line for documents now only happens in built apps (#38).- Linux:
app.pathsDownloads, Desktop and Documents follow the user's real folders (user-dirs.dirs, so a translated~/Téléchargementsworks), the same ones the launcher already used.
0.47.1 — 2026-10-04
Security fix. Thanks to @slabbdev for the report (#30).
- A page can no longer get around the
"api"gate with malformed messages. The launchers passed part of each page message on to the backend unchecked, so a page that"api"restricts, such as a wrapped site or an iframe inside one, could send a message that ran with the app's own permissions, or trigger the app's own event handlers. Each launcher now drops malformed messages before they reach the backend. All three platforms. Apps that wrap a site or use"api".originsshould update.
0.47.0 — 2026-10-04
Security fixes from the rest of #30's audit. Thanks to @slabbdev for the report (#30).
tiny.proxyURLserves only the app's own pages. Thetiny-media://proxy fetches any http(s) URL and answers with CORS open, so any page that could reach it could read any site or local-network URL the machine can, including a wrapped site or a third-party iframe. The proxy now serves only the app's own pages and the dev server. Wrapped sites that usetiny.proxyURLneed"media.proxy"in their"api".originskey. It also refuses to serve pages, which closes a trick where a frame loaded its own page through the proxy and then read anything same-origin. macOS and Linux (Windows has no proxy).- Other URL schemes go to
onNavigate, blocked by default. Links and navigations tomailto:,ms-settings:,someapp://and the like, in any frame, never reachedonNavigate. On Windows, WebView2 offered to open them with its own prompt. They now get akind: 'policy'call on all three platforms. Return'external'to hand the URL to the OS; anything else, or no answer, blocks it. Amailto:link in a wrapped site now does nothing unlessonNavigatereturns'external'.isMainFrameisnullwhere the engine can't tell (Linux and Windows). tinyjs updateruns the installer that came with your install instead of downloading it from tinyjs.app each time, so the website no longer runs code on your machine at every update. It downloads from GitHub and checks the release checksums as before. An install in a customTINYJS_HOMEnow updates in place instead of moving to the default location.tinyjs update --dry-runshows what it would run.TINYJS_DEBUGno longer prints clipboard contents or keychain values. The message trace shows[redacted]for those calls and their replies.- Docs: what
*matches in"api".originskeys (any characters, dots included: never end a key with*after the host), and theonNavigateexample compares origins exactly.
0.46.0 — 2026-10-02
Security fixes. Thanks to @slabbdev for the reports (#18, #29).
- A page's
win.openstays inside the app.tiny.win.opentook any absolute path forpage, and the"wrapper"preset handswin.*to the wrapped site. Every launcher letsfile://pages read other files, so a hostile page could download an HTML file and then open it as a privilegedfile://window that reads the disk. From a page,pagemust now be an http(s) URL or a file inside the frontend directory.../walks, other absolute paths, UNC paths andfile:/javascript:URLs are refused with an error. Backendapp.openWindowis unchanged. All platforms. - Windows: the single-instance pipe belongs to the user. It was
\\.\pipe\tinyjs-app-<id>, the same name for every user on the machine, with Windows' default permissions. Other users could connect to read it, and a user who created the name first would receive a second launch's file paths and deep links (OAuth callbacks included). The pipe is now per user (tinyjs-app-<id>-<user>), locked to that user and SYSTEM with network access denied. A second launch or a registered URL/file handler hands over only to a pipe whose server runs as the same user. If another user holds the name, single instance turns off, matching Linux since 0.42.2 (#12). URL schemes and file types re-register with the new name on first launch. - Windows: the app only talks to its own window process. The private pipe between an app and its window has a random name, but anyone on the machine can list pipe names and connect to read. The app took the first connection to arrive as its window and sent it everything, so another user who connected first received the app's traffic. The window process now proves itself with a one-time secret before the app sends anything, and connections without it are hung up.
- Linux: an iframe can no longer borrow its page's
"api"access. WebKitGTK doesn't say which frame a message came from, so the launcher relies on a per-page token that only the top frame holds. Messages without a token were still accepted (a fallback for pages that never got one) and stamped with the top frame's origin. So a cross-origin iframe inside a trusted wrapped site could post a hand-built message and be gated as that site. Untokened messages are now dropped once the window has a token, which leaves the fallback only for a window whose first page hasn't loaded yet (#18).
0.45.0 — 2026-10-02
Security and robustness fixes. Thanks to @slabbdev for the reports (#26, #29, #30).
- Windows: each app has its own browser profile. WebView2 names its data folder after the executable, and every tinyjs app's window belongs to
launcher.exe, so all tinyjs apps on a machine shared one profile: cookies, IndexedDB, site permissions andlocalStorage. Because an app's own pages all count as one origin there, two apps savinglocalStorage.setItem('settings', …)overwrote each other. Each app now keeps its profile in%APPDATA%\<app-id>\WebView2. Nothing is carried over: after updating, Windows apps start with an empty profile, so wrapped sites ask you to log in again and pagelocalStoragestarts empty.tiny.storeis unaffected. - Built Windows and Linux apps ignore tinyjs's dev environment variables. A built app passed its whole environment on to the launcher, so whatever started it could replace its page (
TINYJS_HTML), inject script, widen camera/microphone or file access, or add WebView2 browser flags such as a remote-debugging port. Built apps now drop every inheritedTINYJS_*andWEBVIEW2_*variable and use only their manifest.tinyjs devand macOS are unchanged. - Streaming
tiny.fetchin two windows at once works. Each page numbers its streams from 1, so two windows streaming at the same time used the same id: the first window's body stopped after its first chunk, and any window could read or cancel another's stream. Streams now belong to the window that started them. tiny.storetreats every key as a plain key.set('__proto__', …)changed what other keys read back instead of storing a value, and backendget('constructor')returned a built-in function rather thannull. Astore.jsonholdingnullor an array now loads as empty instead of making every call fail.- Window titles flatten carriage returns and tabs, as they already did newlines.
- txiki.js downloads are checked against pinned hashes. Cross-arch and universal macOS builds bundle a txiki runtime from a cache in
~/Library/Caches/tinyjs, which any program running as you can write to, and it was then signed with your identity. Builds now check it against a hash shipped with tinyjs, after download and every time the cache is reused, and stop if it doesn't match.setup.sh,setup.ps1and the release workflow check their downloads the same way. setup.shuses a private temp folder instead of predictable/tmpnames another local user could set up in advance.
0.44.1 — 2026-10-02
Thanks to @slabbdev (#16, #21) and @samcharles93 (#31).
- Windows: a failed self-update no longer leaves the app half-updated. The update replaces the app's files one by one, and a file locked partway through (Defender, the indexer) stopped it there, leaving some files old and some new (#13). Renames now retry a briefly locked file, and if one still can't be replaced, every file already swapped is put back and the update reports that the current version is intact.
- The app comes back after a self-update. On Windows the relaunched app was killed along with the old one as it quit, and on every platform the new instance could find the old one still running and hand off to it, so no app was left open. The relaunch now starts outside the old process on Windows and waits up to 5 seconds for the old instance to exit. An app updating from an older version still runs the old update code that one time, so it won't reopen on its own after that update.
- macOS:
tinyjs devshows the app's name in the menu bar and the app switcher, not "launcher-macos". Built.apps already did. - openSUSE support.
setup.shprints the zypper packages to install, the README covers openSUSE's package names, and the tray requirement no longer suggests a package openSUSE doesn't have. The installer's WebKitGTK check also stopped failing on systems whereldconfigisn't on the user'sPATH(openSUSE, and others that keep it in/usr/sbin) even though WebKitGTK was installed.
0.44.0 — 2026-10-02
tinyjs wrap <url>turns a website into a desktop app. One command scaffolds a site wrapper: the site is the app's main window, there's no frontend to write, and everything it generates is ordinarytinyjs.jsonand backend code you can edit afterwards. It fetches the page's title and best icon (apple-touch-icon, then the page's icon links, then/favicon.ico, with the PNG pulled out of ICO files), and on macOS pads the icon to Apple's icon grid. The page title is cut at a tagline separator ("GitHub · Change is constant…" becomes "GitHub") and loses any characters a filename can't hold, since it becomes the.appname. The"api"gate gives the wrapped site's exact origin thewrapperpreset and every other origin only the window controls (close, minimize, zoom, drag).--origins subdomainswidens that to the site's registrable domain, using the public suffix list so it never stops at a shared suffix likeco.ukorgithub.io;--originsalso takes an explicit list. Popups open as windows (OAuth keeps working) and downloads ask where to save.--menubarmakes a menu-bar app with no Dock icon,--panel(with--menubar) a dropdown panel under the menu-bar icon,--topkeeps the window above others,--external a.com,b.comopens those sites in the default browser, and--uasets the user agent.--forcere-wraps an existing project in place: settings you don't pass flags for carry over,--no-menubar,--no-paneland--no-topturn a mode off, and an editedsrc/main.jsis saved assrc/main.js.bakfirst. Thanks to @slabbdev for the feature (#20).
0.43.0 — 2026-10-02
Security fixes. Thanks to @slabbdev for the reports, each with a runtime repro (#24, #27, #28).
- Only the app's own pages get the microphone and camera without asking. On macOS the launcher granted every
getUserMediarequest, so in a site wrapper the wrapped site, any page it redirected to, or a third-party iframe got the device with only the one system prompt, which names the app and not the site. On Linux the"permissions"block entitled every page in the app. Now the app's ownfile://pages (and the dev server intinyjs dev) are granted as before; any other origin gets WebKit's own permission prompt on macOS (for a third-party iframe inside the app's page, that prompt names the app, not the iframe's site) and is refused on Linux. An app can trust a site explicitly with an"api"originskey that allowsmedia.microphone/media.camera, e.g."https://meet.example.com": ["media.*", "notify"]. Presets and top-level lists never grant it. Windows is unchanged: WebView2 already asks per site. tiny.fetchno longer puts request headers on curl's command line. curl is only in the picture as a stopgap: txiki.js 26.6.0's own fetch sends a root-path URL asGET //and can't complete a handshake with TLS 1.2-only servers, so until fixed txiki releases ship, those two kinds of request go through the system curl instead. On those requests, headers such asAuthorizationandCookiewere readable by any local user withpswhile the request ran. They now go through a temp file in a folder only the current user can open, deleted when curl exits. Every other request never touched curl.tiny.fetchdrops credentials on cross-origin redirects.Authorization,CookieandProxy-Authorizationwere sent again to whatever origin a redirect pointed at. They're now dropped when a redirect changes origin, as browsers do, and a redirect that turns a POST into a GET also drops the body'sContent-*headers.tiny.audio.sampler.loadonly accepts audio files. A page could load any path and read the file back through the internalsampler.bytescall, so any page in an app without an"api"gate could read any file the user can.loadnow refuses a path unless it has an audio extension AND starts like an audio file (WAV, AIFF, CAF, Ogg, FLAC, MP3/AAC, M4A/MP4, WebM). The contents check alone let UTF-16 text files and HEIC photos through.sampler.bytesanswers only the main window on macOS and Windows.
0.42.3 — 2026-09-29
Security fix. Thanks to @slabbdev for the report, which came with a repro (#22), and the fix (#23).
- A window id from the page can no longer add launcher commands. Some window calls put the id the page passed straight onto the line the runtime sends to the launcher:
tiny.win.close(id),win.openwithminSizeor withx/y, and the menu item lookups. A newline in the id split that line in two, and the launcher ran the second half as a command of the page's choosing, which the"api"gate never saw. On macOS that included running AppleScript, so a page could run shell commands as the user. The line splitting worked on every platform. Any page that could callwin.*could do it: apps without an"api"gate, and apps using thewrapperpreset. Ids are now cleaned of tabs and newlines at these calls, as they already were everywhere else. Real window ids never contain them, so apps don't need to change anything.
0.42.2 — 2026-09-28
Thanks to @slabbdev for reporting the single-instance socket issue (#12).
- Fetch with a body no longer hangs when it goes through curl. tinyjs sends two kinds of request through the system curl instead of txiki.js's own fetch: root-path URLs such as
POST https://api.example.com/, and requests where the native fetch fails outright. The body used to reach curl over its stdin, and txiki.js 26.6.0 never finishes a small write to a child's stdin (saghul/txiki.js#1027, fixed upstream but not yet released). The server got the request and answered it, butawait fetch(...)never returned and no error was thrown. Bodies now go to curl through a temp file in a private folder, which is deleted once curl exits. This hit macOS and Linux; Windows wasn't affected. - Linux: the single-instance socket stays private without
XDG_RUNTIME_DIR. A built app finds its running copy through a socket named after the app id. WhenXDG_RUNTIME_DIRwasn't set (some services and containers), that socket went straight into the shared temp folder. Another user on the machine could create it first, so the app thought it was already running and quit, or connect to it and send the app URLs and file paths to open. It now goes in a per-usertinyjs-<uid>folder that only you can access. If that folder exists and isn't yours alone, the app prints a warning and runs with single instance and URL/file handoff off. The socket itself is also owner-only now. Desktop sessions, which setXDG_RUNTIME_DIR, work as before.
0.42.1 — 2026-09-28
Security fixes from an outside review of the runtime. Thanks to @slabbdev for the review, which came with repros (#11), and for the fixes (#14, #15).
debug.getno longer gets past the"api"gate. It was always allowed, listed as part of the client's startup, but the client never calls it. It reads raw values back from the launcher, including the clipboard, wifi, the frontmost app, other windows and selected text. So under"api": "wrapper"a hosted page could read the clipboard, which the preset says it excludes. It's now gated like every other method. If an app with an"api"gate calls it from the page, add"debug.get"toenable. Apps without a gate aren't affected, and neither is the backend'sapp.debug().- Fetch rejects malformed headers. A header value containing CR or LF went onto the wire as extra header lines, so a page could add headers of its choosing to a
tiny.fetchrequest. That happened on both routes: the curl fallback, and txiki.js's own fetch, which doesn't check either. A:in a header name let curl send a different header,Hostincluded. Names must now be valid header names and values can't contain CR, LF or NUL. Anything else throws aTypeErrorbefore the request is sent, as in a browser. This covers the backend'sfetch()too. Two smaller fixes on the curl route: headers passed as[[name, value]]arrays were misread, and a header with an empty value was dropped instead of sent. tinyjs buildescapes tinyjs.json values in Info.plist. OnlyurlanduserAgentwere escaped. A&or<in anything else, such as"title": "Tom & Jerry", made the plist invalid: the app was signed without complaint, but macOS refused to open it ("executable is missing"). All values are now escaped, and control characters, which XML can't hold at all, are dropped.
0.42.0 — 2026-09-24
tinyjsworks in Git Bash on Windows. The Windows download shipped onlytinyjs.cmd. cmd and PowerShell find it when you typetinyjs, but Git Bash doesn't try.cmdwhen it looks up a command, so there you got "command not found" and had to typetinyjs.cmd. The zip now also has the sh wrapper that macOS and Linux use, next totinyjs.cmd: Git Bash runs the wrapper, cmd and PowerShell keep runningtinyjs.cmd. To get it, runtinyjs update(from Git Bash:tinyjs.cmd update). A source checkout set up withsetup.ps1already had the wrapper. The release build now runs it in Git Bash before publishing, so it can't go missing unnoticed again. Thanks to @nudopnu for the report and the fix (#9, #10).macOS apps for Intel Macs too. A macOS build only carried the build Mac's CPU, so an app built on Apple Silicon was refused on Intel Macs ("not supported on this type of Mac"), and the build didn't warn you (#2). Three changes:
tinyjs buildnow prints which Macs the .app opens on, e.g.runs on: Apple Silicon (arm64), and says how to reach the others.tinyjs build --arch x86_64(orarm64) builds the .app for that CPU from any Mac. The other CPU's txiki.js comes from the same release and is cached in~/Library/Caches/tinyjs. The dmg and zip are named<name>-<ver>-macos-<arch>, so both builds can sit in one release.tinyjs publish --arch arm64followed by--arch x86_64merges both into one update manifest: a newmacblock keyed by CPU. The arm64 build also stays in the top-levelurl, so apps already out there keep updating. The updater picks the build for the Mac it's running on. An Intel build running on Apple Silicon under Rosetta moves to the native one.tinyjs build --universalmakes one .app with both CPUs instead. It's about 6 MB bigger and needs the Command Line Tools forlipo. Per-CPU builds are opt-in: without--archor--universala build targets the Mac it's built on, as before. Thanks to @akii09 for the build report (#6) and--universal(#7), and for finding that the bundled txiki.js needs macOS 15, not the documented 14. The docs now say macOS 15+.
onLocaleworks. The backend hook existed, but the generated app entry never passed it on, so exportingonLocaledid nothing.Windows
capabilities()owns up toproxyURLandsetAllSpaces. Both quietly do nothing there, but the capabilities table left them out, socaps.x !== falseread them as supported.The agent skill's release reference ships again. A case-insensitive
.gitignorerule meant for the rootRELEASE.mdalso ignoredskill/references/release.md, so SKILL.md linked to a file new projects never got. The skill is also brought up to date: current version, the full list of backend hooks, the page-to-backend name map, Windows' real toast notifications, and several wrong examples fixed.Docs refresh. The docs page gains the tinyjs.json keys, CLI commands and APIs it was missing (
debug,readAccess,windowPlacement, dialogtypes,role: 'app',tiny.fileURL,mouseTracking,requirements,tinyjs version/uninstall, and more), and drops per-feature macOS version notes that no longer mean anything now that apps need macOS 15. pickColor and spotlight are correctly marked as not on Windows yet, and the launcher's size is corrected to 1–2 MB.
0.41.1 — 2026-09-23
tinyjs new --templatefinishes again with create-vite 9. create-vite now asks "Install with npm and start now?" after scaffolding. npm's--yesdoesn't answer it, since that flag only covers npm's own prompts. Saying yes started a Vite dev server that never exits, so tinyjs never got to add its own files: the project had notinyjs.json,backend/, types, icon or agent skill. tinyjs now passes--no-interactive --no-immediate, and create-vite scaffolds and exits without asking. Thanks to @favasconcelos for the report, which arrived with the root cause and the verified fix (#5).- Fetch ids wrap instead of saturating. The client's per-fetch counter is now a
Uint32Array, so it wraps after 2³² fetches. A plain number stops increasing atNumber.MAX_SAFE_INTEGER, after which every fetch would get the same id. No app will reach either limit, but the wrap is the safer behavior. Thanks to @WebReflection (#4), the first outside contribution to tinyjs.
0.41.0 — 2026-09-22
- Your own items in the Edit menu.
{ role: 'edit', items: [...] }now appends those items to the standard macOS Edit menu, below Select All and after a separator, where Find… and friends belong. Before this the role ignored its items, and declaring a separate{ title: 'Edit' }gave the bar two Edit menus. The items behave like any other: ids, key equivalents, ticks,menu.update/menu.get, clicks throughtiny.menu.on. Windows and Linux have no stock Edit menu (their webviews handle Ctrl+C/V themselves), so there the items alone become an Edit menu in the declared slot. A bare{ role: 'edit' }still draws nothing on those platforms. Only the firsteditblock counts, as withapp. - Choose which stock Edit items appear, and where, on every platform. Put
{ role: 'undo' },redo,cut,copy,paste,selectAll, or{ role: 'standard' }for the whole group, insideitems. Once any of them is there, you set the order and nothing is added for you. For example,[{ id: 'find', … }, { separator: true }, { role: 'standard' }]puts your items above the stock ones. Stock roles render on all three platforms, so one declaration gives the same Edit menu on macOS, Windows and Linux. On Windows and Linux the items show their shortcut (Ctrl+C) without claiming it, so the webview keeps handling the keys in text fields. Linux runs WebKitGTK's editing commands and greys items out when they don't apply; Windows replays the shortcut into WebView2, and its items are always enabled. Roles work intiny.menu.setContexttoo, and the tray skips them. This is the Electron model (Tauri'sPredefinedMenuItemis the same idea). standard: false(macOS) drops the implicit stock items, and with noitemseither there is no Edit menu. Unlike Tauri, dropping Copy/Paste no longer breaks ⌘C/⌘V in text fields: the launcher handles ⌘C/⌘V/⌘X/⌘A/⌘Z/⌘⇧Z for any shortcut the menu bar doesn't claim, and an app item bound to one of them still wins. A menu holding stock items greys Copy out when nothing is selected, and an app item'senabled: falsestill holds in it. Windows and Linux add nothing implicitly, so the flag has nothing to remove there.
0.40.0 — 2026-08-24
- No display now fails fast, loud, and diagnosably — on all three platforms. An agent (or CI job) running where no window can exist — headless Linux, an SSH'd or sandboxed Mac, a Windows service session — used to see the launcher die with an opaque message or nothing at all, indistinguishable from an app bug (one tester burned real time blaming their npm cache). Each launcher now checks its environment before connecting to the backend: Linux for
DISPLAY/WAYLAND_DISPLAY+gtk_init_check, macOS for a window-server session (SSH/CI/sandboxes that block WindowServer), Windows for a visible window station. All print atinyjs:-prefixed explanation — saying the app code is likely fine and what kind of environment can show the window — and exit 3 before the socket connect, so the bridge reports "launcher exited before connecting" instead of hanging on a dead peer. - Docs: the
tjs.*-async /tjs:sqlite-sync footguns are now written down. Everytjs.*fs call returns a Promise while sqlite is fully synchronous — an unawaitedmakeDirbeforenew Database(...)is a race. The sqlite examples (README, docs, agent skill) now show theawait, and the better-sqlite3 differences are spelled out: statements have onlyrun()/all()/finalize()(noget()— use.all()[0]),run()returns void, and the last-insert-id idioms (INSERT … RETURNING id/last_insert_rowid()) are documented and verified against the shipped runtime.
0.39.1 — 2026-08-18
- macOS: a frameless secondary window is really frameless.
windowControlsrides the wire as a TOKEN —all,none, or a comma list — so the['close','minimize']form survives, but the titlebar-hiding branch tested it as a bit (== "0"), which is never true. So every window opened byapp.openWindowwithchrome: { frame: false, windowControls: false }kept a live titlebar container: invisible, but its rounded top edge drew over the page and it swallowed the top strip's mouse events. The main window was never affected — it derives the same decision from its own tracked state.
0.39.0 — 2026-08-06
{ role: 'app' }puts items in the macOS application menu. The one slot in the barsetMenucould not reach — and the one macOS reserves for Settings… (⌘,), which every app is expected to put there and none built on tinyjs could. Items declared in anapprole block land between About and Quit and behave like any other: ids, key equivalents, ticks,menu.update/menu.get, and clicks throughtiny.menu.on. Windows and Linux have no application menu, so the role is unknown there and its items are dropped rather than landing somewhere arbitrary — declare the same item in a menu of your own for those platforms. Only the firstappblock is used.The id→item registry now resets at the top of the bar build rather than just before the custom menus, because the application menu is built first and the old order threw its registrations away — an item that clicked correctly (the id rides on
representedObject) but thatmenu.getandmenu.updatecould not see.
0.38.0 — 2026-08-06
- Wrap a hosted web app as a real desktop app.
"url": "https://…"in tinyjs.json makes the main window a remote page (no local frontend needed), and the things a browser does — which a local-page app never missed — now exist on all three platforms:alert/confirm/promptget native panels headlined by the page's origin (confirm()used to returnfalsesilently, a permanent "Cancel" for any site guarding a destructive action); downloads actually download ("downloads":autoto ~/Downloads with de-duplicated names,askfor a save panel, ordeny) with start/progress/done events;window.openis configurable ("popups":externalto the real browser,window, ordeny); navigation start/commit/finish/fail/crash reachonNavigate, which can also veto a main-frame navigation or send it to the browser; andtiny.win.find()searches the page. "api"— a capability gate, so a wrapped site isn't handed the machine.tinyis injected into every origin, so without this a third-party page holds an RPC channel to a backend with full filesystem and process access. Now:"api": "wrapper"for the obvious posture,{disable, enable}name lists (enable wins), or per-origin keyholes —{"origins": {"file://*": "all", "https://app.example.com": ["notify", "store.*"]}}— keyed off the calling frame's origin as the ENGINE reports it, not as the page claims it. Denied calls reject with a readable reason;capabilities().api.deniedlets a page hide UI it can't use. Unknown preset names fail closed."inject"runs a script at document-start in every page, bundled at build (.tsvia esbuild) — the supported way to shim a wrapped site, replacing the undocumentedTINYJS_INJECTenv var, which had a silent 8KB cap on Windows that dropped bigger bundles.prompt()crashed the app on macOS — the result string was released before WebKit read it, so any site using a prompt died. Fixed.- Windows:
beforeunloadis no longer auto-accepted (a wrapped app with unsaved work navigated away silently); a download's reportedfilenamenow names the file that exists on disk rather than the engine's suggestion; Back/Forward survive a navigation policy ask; andmenu.updateno longer applies stale specs. - Linux:
tiny.win.idinside a popup is the popup's own id (it readmain, soapp.window(tiny.win.id)from a popup targeted the main window); find state no longer survives a navigation; and a download whose filename isn't valid UTF-8 — legal on Linux — can't put an unparseable line on the wire.
0.37.1 — 2026-08-04
tinyjs devis quiet again. 0.37.0's always-has-devtools change seededTINYJS_DEBUG=1into every dev run — the same env the bridge reads as its message-trace gate, so dev spewed the full bridge log on every launch, and (!!'0'being truthy)TINYJS_DEBUG=0couldn't hush it. Dev now seeds a sentinel the trace ignores: devtools still arm on all three platforms, an explicitTINYJS_DEBUG=1(oropen) traces exactly as documented, and0/falsemean silence.
0.37.0 — 2026-08-02
- Devtools are off by default — and yours to turn on. Every shipped app had an end-user-reachable inspector: right-click "Inspect Element" on macOS and Linux, F12 devtools on Windows. Now nothing ships one unless tinyjs.json says
"debug": true— which buys a launcher-owned F12 on all three platforms (plus ⌥⌘I and a Develop menu on macOS) opening the inspector in its own window, where it can't crush a small app window."debug": "open"auto-opens one for every window at creation, child windows included — which were never inspectable before, on any OS.tinyjs devalways has F12, whatever the manifest says. - The webview's own shortcuts can't fire under your app's nose. WebView2 ships a whole browser key set — Ctrl+P print preview, Ctrl+F find bar, Ctrl+R/F5 reload, Ctrl+O — that no native app has, and it's now suppressed by default (
"browserAccelerators": trueopts back in). Menu accelerators are untouched either way. WKWebView and WebKitGTK have no such set, so the flag is a no-op there. - A greyed-out menu item swallows its shortcut, like AppKit always has. On Windows a disabled item's combo fell through to the webview — nib with no folder open turned Ctrl+P (Open Quickly, disabled) into a print sheet. The accelerator now belongs to the menu whether or not the item can fire; disabled just means it fires nothing.
- Uppercase menu keys mean Shift on Linux too.
{key:'P'}is Ctrl+Shift+P everywhere else (macOS gets it free from AppKit; Windows already parsed it), but GTK lowercases the keyval and keeps the modifiers as passed — so'P'registered plain Ctrl+P, collided with a'p'item elsewhere in the bar, and Ctrl+Shift+P was bound to nothing. Every uppercase accelerator on Linux was affected; nib's Print… was eating Open Quickly's combo this way. app.keystrokecan send function keys on Linux.'F12'parsed to NoSymbol (the combo parser lowercases and XStringToKeysym is case-sensitive) and answeredok:false; F-keys now map to their keysyms directly, matching macOS and Windows.
0.36.0 — 2026-08-01
- The About menu item can be yours.
"about": "menu"in tinyjs.json turns macOS's About item into an ordinary menu click — it arrives intiny.menu.on/ the backend'sonMenuwith the reserved idaboutinstead of showing the standard panel, so an app can draw its own. Opt-in: apps that don't declare it keep the free panel. Windows and Linux have no default About item, so the flag changes nothing there. win.open({ parent })keeps a window above your own.parent: true(= main) or another window's id uses the native relation on each OS — child window on macOS, Win32 owner, GTK transient — so an about box or tool panel stays over the window it belongs to without floating over other apps the waysetLevel('floating')does. It also minimizes and hides with its parent, closes when the parent closes, and gets no taskbar button on Windows (GTK sets no skip-taskbar hint, so Linux taskbars may still list it). Open-time only, liketransparent— Win32 ownership can't be set after creation. One cross-OS difference, documented rather than fought: macOS child windows also move with their parent. Verified on all three OSes, including that closing a parent destroys its children andWINCLOSEDarrives for every one of them.- A throw in an app's own handler no longer takes the whole app down. An exception (or an async rejection) from any exported handler —
onWindowClosed,onMenu, … — propagated out of the bridge's read loop and killed it: no further events, no api answers, and nothing on screen to say why. Every handler call site now logs the failing handler by name and carries on; verified with handlers that throw and reject on purpose. saveFile({ types })appends the extension on Linux too. 0.35.0 shipped that as a Windows-only nicety —SetDefaultExtensionthere, nothing on Linux, because GTK has no equivalent — so the same call answered…/untitled-noteon one OS and…/untitled-note.mdon the other, and an app that trusted the extension wrote a file the picker had just filtered out. The Linux chooser now appends the first declared extension by hand, and only where it can't surprise anyone: the name must carry no extension at all (notes.mdis left alone, never doubled), the type filter rather than "All files" must be the one selected, and a call with notypesappends nothing. One difference from Windows remains and is unavoidable without reimplementing the dialog: GTK's overwrite confirmation runs before the extension goes on, so it prompts about the name as typed.tiny.win.restore()really un-minimizes on Linux. It was a baregtk_window_deiconify(), which Mutter drops on the floor when the client has no user timestamp to prove the request came from a person — the window stayed minimized and merely started demanding attention, while the call resolvedtrue. Nothing in an app's own logs could tell that apart from a working restore. It now deiconifies and presents, with a real server timestamp on X11, and thewin.onStatelistener sees theminimized:falseit should always have seen.
0.35.0 — 2026-07-31
- File pickers take a type filter.
tiny.dialog.openFile({ types: ['md', 'markdown', 'txt'] })— and the same onopenFilesandsaveFile— limits what's choosable, mapped natively on each OS:allowedContentTypeson macOS, aCOMDLG_FILTERSPECon Windows,GtkFileFilteron Linux. Windows and Linux keep an explicit "All files" entry so a filter never hard-hides the disk;saveFileon Windows also appends the first extension by default. Extensions only, dots and case forgiven; omittypesand nothing changes. Filtering is always explicit and per-call — deliberately not defaulted from the manifest'sfileExtensions, so a picker never silently narrows because of an unrelated file-association declaration.
0.34.1 — 2026-07-31
- Packaged macOS apps keep their declared chrome. A built app with
windowControls: falseorsquareCornersbooted titled, traffic lights and all, whiletinyjs devand the bare binary looked right — amp shipped that way twice before anyone caught it. Two independent bugs, either one sufficient: the build wrote the plist chrome fromtrafficLights, the pre-0.30 name ofwindowControls, so the buttons-off request never reached any packaged build; and bundle startup applied the plist chrome and then called a set-size that rewrites the styleMask wholesale, snapping the window straight back to titled. Dev never showed either, because the bridge re-sends chrome over the socket after boot — packaged apps have only the plist, so the wipe stuck. The plist now carries the same tokens the socket does, and startup re-applies it after the size call in the same runloop turn, so nothing ever paints titled.
0.34.0 — 2026-07-31
tiny.audio.sampler— sampled sound effects that work everywhere, including Linux. A bank of short decoded sounds (wav/mp3/flac guaranteed) fired with per-voice volume, pan and pitch, mixed into one output — game and UI SFX, the coo3d workload.await s.load('coo', path)(or an ArrayBuffer — spilled to the app cache once, never streamed over the wire),const v = await s.play('coo', { vol, pan, rate, loop }), thenv.set({ pan })live andv.stop()with a click-free fade;s.master(v),s.unload(name). 32 voices, oldest-stolen —play()never fails for "too many". Backend twinapp.audio.samplerdrives the SAME mixer, one state owner (the bridge), so every window andmain.jsagree. On macOS/Windows it mixes in the main window's page via Web Audio (already RT-scheduled there; the main window always exists and can't close, and a page reload re-arms the bank by itself — playing voices die, which is the documented cost). On Linux, where Web Audio reachingctx.destinationcrackles under WebKitGTK (measured, the reasontiny.audio.filtersis native too), the LAUNCHER decodes with miniaudio and mixes in a PipeWirepw_streamon the RT data loop — the page can be melting under rAF load and the audio doesn't care (measured: 20 live-automated voices, ERR 0, ~240µs/quantum). Same equal-power pan law asStereoPanneron all three, verified against a null-sink capture to four decimal places on Linux. Launcher decode also means SFX can't be broken by missing GStreamer plugins, and an activetiny.audio.filterschain picks the sampler up like everything else the app plays.capabilities().samplersays'native'or'page', but apps shouldn't care — the API is identical. Out of scope by design: sample-accurate scheduling, per-voice filters, music (that's<audio>).The agent skill is rebuilt: a thin core plus on-demand references.
tinyjs newused to drop a single 635-line SKILL.md into the project; a coding agent loaded all of it for every task. It's now a 156-line core with a routing table and six reference files the agent reads only when the job calls for them — the full API tour, per-OS truths, app-kind recipes (tray, desktop pet, media, document, wrapper), shipping/notarize/ auto-update, an Electron porting map, and page-vs-backend performance rules. The content was re-audited against the docs, which caught real drift: the old skill still said Linux needed Ubuntu 24.04+ (the floor is 22.04/Debian 12), called builds Apple-Silicon-only (they're universal), and hadapp.badgeunsupported on Linux (it works on LauncherEntry docks). Scaffolded projects get the whole skill dir now, not one file.
0.33.0 — 2026-07-31
Linux downloads load on Ubuntu 22.04 and Debian 12 again. A linked binary requires the newest glibc symbol versions its build machine offers, and the Linux release builds ran on Ubuntu 24.04 — so every download quietly demanded glibc 2.38 and refused to load on anything older (
version `GLIBC_2.38' not found: Ubuntu 22.04, Debian 12, Mint 21, Pop!_OS 22.04). CI builds on Ubuntu 22.04 runners now; the floor is glibc 2.35, which those distros and everything newer all clear. And because a raised floor is not a build error — the build stays green and the break only ever shows on a user's machine — the release now asserts the ABI floor, the architecture, and every optionally-linked feature on both arches before anything is packaged. That last check is earning its keep already: amp 0.8.0's arm64 launcher linked no AppIndicator, so its tray was silently dead on arm64 while working on x86_64. Both arches ship complete from here on, and none of this can regress without turning the build red.Wayland mouseTracking is actually in the Linux downloads now. Unlike the EQ, which spawns PipeWire's command-line tools and can therefore ask the user to install what's missing, mouseTracking consumes the ScreenCast portal's stream in-process — the PipeWire library is compiled into the launcher, or the feature doesn't exist in that binary at all. setup.sh treats the dev package as optional and skips it quietly, the CI machines didn't have it, and so every released launcher shipped with
mouseTracking.start()answeringunsupported. The build machines install it now, the release check refuses a launcher without PipeWire linked in, and the one piece a user's machine can genuinely be missing — a ScreenCast-capable portal — joinedtiny.system.requirementsas'mouseTracking', sopromptMissingcan put the fix in front of the user the same way it does for codecs.Linux: opening a file into an already-running app can't silently drop the payload. The single-instance handoff wrote the deep-link / file-open line to the socket once and ignored the result, so a partial or failed write reported success while the running app never got the whole message. It now loops until the line is fully sent and falls back to spawning the app when it can't be.
0.32.0 — 2026-07-30
Lost windows come back. Apps restore saved positions blindly, and coordinates from an unplugged external display land a window in empty space nobody can see or grab. The launchers now know how to rescue one — clamp it onto the nearest screen, titlebar first — and the bridge decides when: it fingerprints the screen layout per app, and only a boot whose layout CHANGED since the last run arms the rescue, chasing each window's first show / first position once. Unchanged screens (every ordinary day) mean zero interference — apps that fling windows off-screen on purpose (coo3d's pigeons) never feel a thing. A display departing mid-session sweeps the visible windows too. Automatic parts opt out with
"offscreenRescue": falsein tinyjs.json;tiny.win.ensureOnScreen()(backend:app.window(id).ensureOnScreen()) is the manual one-shot that always works. Windows deliberately parked half-off-screen keep more than a sliver visible and are never touched.Mission Control shows your windows now (macOS). A window without the Titled style bit is silently excluded from Mission Control / Exposé, so every
squareCornersapp was invisible to the four-finger swipe. Membership now follows what the window IS: normal and floating windows participate like documents; desktop-level windows (wallpaper pets), overlay-level ones (draw-on-screen HUDs) and click-through windows — which couldn't take the click Exposé would hand them — stay out. Re-derived whenever level, click-through or squareness changes, so call order doesn't matter, and nothing to change in any app.
0.31.0 — 2026-07-30
Window-state transitions are events now.
tiny.win.onState(fn)fires with{ win, fullscreen, maximized, minimized, focused }on every transition, whatever caused it — the green button, a menu item, F11, or a programmaticsetFullscreen— so nobody has to fake it by debouncingresizeand re-readinggetState()(which cannot tell a fullscreen transition from a manual resize to screen size). Same vocabulary asgetState(), deduped in the launcher so live-resize spam never crosses the wire, broadcast to every window with the id attached. Backend twin:export function onWindowState(info, app). Like everyon…since removable events landed, it returns its own unsubscribe. Wayland never reportsminimized— the compositor keeps minimize private, same honesty rule asmousePosition.The traffic lights can move (macOS).
chrome.windowControlsPos: { x, y }— offset from the window's top-left — recenters the close/minimize/zoom group in a taller custom titlebar, the thing every frameless app with its own header bar eventually wants. Works intinyjs.json "chrome"(applied before first paint),setChrome, andwin.open's per-window chrome;nullrestores the OS layout, andgetState().chromereports it back. AppKit re-lays-out the buttons on resizes, fullscreen round-trips and titlebar rebuilds, so the launcher re-applies the offset on those transitions — set it once. Ignored on Windows and Linux, whose window buttons live in a real titlebar.
0.30.0 — 2026-07-30
setHideOnCloseno longer strands the app on Windows and Linux. It is a macOS idea — there an app outlives its last window and the Dock icon brings it back. Neither other platform has anywhere to put that: a hidden window takes its taskbar button with it, so closing the last window of an app that set the flag left a process the user could neither see nor quit (nib did exactly this;tinyjs devnever returned). The flag is now honoured only when there IS a way back — a tray icon, accessory mode, or another window still on screen. Otherwise the close means what it means everywhere else on that OS and the app exits. macOS is unchanged, tray apps are unchanged, a programmaticwin.hide()still hides whatever it is told to, and closing the last secondary is left alone so an app that answers it by re-showing its main window (nib's Welcome screen) still can.The menu bar shows in EVERY window. It always did on macOS, which has one bar for the whole app — but Windows and Linux draw the bar inside a window, and the launchers only ever put it on
main. A multi-window app therefore lost its menu in exactly the windows that needed it: on Windows nib's document windows had no File menu at all, while the Welcome screen carried one it had no use for.tiny.menu.setis now the APP menu — every window shows a copy, including windows opened later — andmenu.updatemoves every copy of an id at once, which is what one shared macOS bar already did. Nothing to change in an existing app; the menu simply appears where it was missing.Two new ways to differ per window:
tiny.win.menu.set/update/reset(backend:app.window(id).setMenu,.updateMenuItem,.resetMenu) — this window says something else, and only this window. Clicks arrive as the samemenuevent either way, so a handler doesn't have to care which bar sent them. macOS swaps the bar in while that window is key and puts the app menu back when it isn't, so a per-window menu means the same thing on all three platforms.chrome.menu: false— this window shows no bar; the app menu carries on everywhere else. Whether a bar shows is chrome, what's in it is menu. Declare it in tinyjs.json"chrome"orwin.open's chrome to apply before first paint, or flip it live withsetChrome({ menu: false }). Accelerators keep firing with no bar showing. macOS ignores it — a bar-less mac app isn't a thing — and frameless or transparent windows never draw one on Windows, where the bar is GDI.
Four Windows fixes rode along. A page never had keyboard focus until the user clicked in it. WebView2's child window doesn't take focus when the host window does, so
document.hasFocus()answeredfalsefrom launch — and an app that gates on it (nib ignores menu events unless its own window is the focused one, the standard multi-window pattern) dropped every page-side menu action until you happened to click the document first. Menu items answered by the backend worked, ones answered by the page did nothing, which is a maddening thing to debug. The launcher now moves focus into the page when a window is created and on everyWM_SETFOCUS, matching macOS, where a new window's webview is first responder immediately. A menu declared immediately before{ role: 'edit' }came out empty — the launcher flushed its items into place at the role line and then, because the role slot claimed no entry of its own, flushed an empty list back over them at the next menu. nib's File menu is exactly that shape and had no items at all on Windows.sizenow means the page's box whether or not the window has a menu bar: attaching one used to silently shorten the page by its row (Linux had already made the same repair for its in-window bar). And secondary windows no longer drop the GDI redirection bitmap unconditionally — only transparent and frameless ones do, since a window without one cannot draw a menu bar at all.
Breaking. The Dock/taskbar/launcher calls are renamed, two macOS-only calls move to their own namespace, and haptic is removed outright. Old names are gone rather than deprecated.
The app surface is verbs named for intent, not macOS furniture. The Dock, the Windows taskbar and the Linux launcher are surfaces the OS owns and an app decorates — there is no shared object to hang a namespace off:
before after app.dock.setBadge(t)app.badge(t)app.dock.bounce(o)app.attention(o)app.dockIcon(p)app.icon(p)app.setDockVisible(v)app.presence('normal' | 'menubar')Same names on both sides of the bridge, so
main.jsand the page agree.app.progress(0..1)— new. A determinate bar on the app icon;nullclears. macOS draws it into the Dock tile, Windows usesITaskbarList3, Linux emits the UnityLauncherEntrysignal (KDE Plasma, Ubuntu Dock and Dash-to-Dock listen; vanilla GNOME Shell doesn't). On macOS it composes withapp.icon()— a custom Dock tile keeps its icon while the bar is up.tiny.macos.*holds everything macOS-only (andapp.macos.*on the backend).applescriptandquickLookwere joined byocr,recorder,ai,selectedText,otherWindowsandmoveWindow— everytiny.appcall that answers'unsupported'on both other platforms. The namespace is now the honest signal it looked like: if it's ontiny.app, it does something somewhere other than macOS.Calling
tiny.macos.*off macOS rejects with the reason instead of resolving something empty. That's a behaviour change for the moved calls, not just a rename:otherWindows()used to answernullon Windows and Linux and now throws, so aif (!wins)branch becomes atry. Deliberate — a call that cannot work on the platform you're running is a bug in the app, and anullthat means "wrong OS" is indistinguishable from one that means "nothing selected".If any of these grows a Windows or Linux implementation it moves back to
tiny.appwith thetiny.macosname kept working alongside it for a release or two, so that direction won't break anyone overnight.Three stayed put on purpose.
win.shareis window-scoped — it anchors the sheet to a specific window, and window-scoped calls belong ontiny.wineven when they're macOS-only.authenticatealso works on Windows, andspotlight,pickColorandnowPlaying/media keys also work on Linux — none of those are macOS-only at all, whatever the deck's old grouping implied.hapticis removed (wasapp.haptic, brieflytiny.macos.haptic).NSHapticFeedbackManageronly fires while a finger is resting on a Force Touch trackpad and the "Force Click and haptic feedback" setting is on, so the honest description of the call is that most presses of it — every one driven by a mouse, and every one on a desktop Mac — do nothing at all. An API whose normal outcome is silence is indistinguishable from an unimplemented one, which is the exact confusion this release spent its time removing elsewhere. Nothing replaces it;tiny.app.playSound()is the portable way to acknowledge an action.battery,wifiandidleTimemove totiny.system.*. They're facts about the machine, not things the app does — the same line that decided the app surface belongs ontiny.app. Mirrored on the backend asapp.system.*. (tiny.systemalso gained TypeScript definitions, which it never had at all.)Native dialogs move to
tiny.dialog.*—openFile,openFiles,pickFolder,saveFile,alert,confirm,prompt. They were ontiny.win.*, but nothing about them is window-scoped: the launcher runs them application-modally, so a dialog from a satellite behaved exactly like one from main. All three platforms use their real native dialogs.Breaking:
chrome.trafficLights→chrome.windowControls, and it's cross-platform now. The macOS nickname described a group every OS has, so the key is named for the thing rather than the Mac's word for it. It takestrue,false, a subset array like['close', 'minimize'], or[]for none — macOS hides each button individually, Windows maps them ontoWS_SYSMENU/WS_MINIMIZEBOX/WS_MAXIMIZEBOX, Linux usesgtk_window_set_deletableplus an_MOTIF_WM_HINTSrequest the WM may ignore.getState().chrome.windowControlsreports the array that is actually shown (read off the buttons on macOS and the styles on Windows), andnullon Linux, where the WM owns the decision and won't say. Previously the bit was macOS-only in effect but stored and echoed back on Linux, so an app that set it and read it back was told it had worked.Breaking: a window's size is the page's box, everywhere.
win.open({ size }),win.setSize(),setMinSize()andgetState().width/heightused to be up to three different units depending on the platform and the window. Creation took a content size on all three, butsetSizeandgetStatespoke frame units on macOS and Windows and content units on Linux. What that looked like: an app declaring1100x720got a 688-tall page on macOS and a 720-tall one on Windows and Linux, the title bar eaten out of the document;open({ size: '150x150', chrome: { frame: false } })produced a 150x182 page, because dropping the title bar handed those points to the page; and on Linux the first menu bar to appear took its own height out of the document the same way. All of them now mean the page's own box on all three platforms, so reading a window's size and handing it straight back tosetSizeis a no-op — which is what the old frame-units workaround was chasing, having fixed the round-trip by moving both ends away from the unit creation used.getState().outer— new:{ width, height }, the window's footprint on screen with decorations included.width/heightare the page;outeris what the screen sees, and for a frameless window the two are equal. A window that keeps itself inside a screen rect needs the outer number, and the page cannot work it out alone —window.outerWidth/outerHeightare0in a WKWebView. On Linux it comes from the WM's frame extents, so under Wayland, which has no server-side frame to ask about, expect it to equal the page box.{ role: 'edit' }places the standard Edit menu. macOS installs an Edit menu whether an app declares one or not — the webview needs its key equivalents for ⌘C/⌘V to work at all — and it always went first, so a custom File menu was stuck sitting after Edit, which no Mac app does. Put{ role: 'edit' }in thesetMenuarray and the standard menu takes that slot instead:jsapp.setMenu([{ title: 'File', items: [...] }, { role: 'edit' }, { title: 'View', items: [...] }]);Omit it and nothing changes. Windows and Linux have no launcher-owned menu, so they skip the entry and keep the order the app declared.
minTinyjsVersionintinyjs.json. Optional: the oldest tinyjs an app works with. Running it on an older one now fails withmyapp needs tinyjs 0.30.0 or newer — you have 0.28.3instead of the app half-working, which is what an app calling an API its runtime predates actually looks like — an unexplained TypeError in the page with nothing naming the cause.tinyjs newstamps the version it scaffolded with; a source checkout reportsdevand is never blocked.Per-OS config blocks. Root keys in
tinyjs.jsonapply everywhere; an optionalmacos/windows/linuxblock merges on top for that platform, so only the keys that genuinely differ get repeated. Plain objects merge, scalars and arrays replace. Block names matchtiny.system.os().TINYJS_SIGN_IDENTITY/TINYJS_NOTARY_PROFILEnow overridetinyjs.jsonrather than only filling a gap — but never silently: if one displaces a value that was really in the file, the build says so. Resolution order is root → OS block → env.tiny.win.print()andtiny.win.printToPDF()print the window that called them. Both went straight to the main window, whichever page asked — so in a multi-window app the document window's ⌘P printed the launcher screen, and Save as PDF wrote a PDF of it. They now route by calling window like every otherwin.*call (PRINT@<id>/PDF@<id>on the wire), on all three platforms, andapp.window(id).print()/.printToPDF(path)expose the same thing to the backend. Single-window apps see no change.capabilities()stopped over-claiming, on two platforms. Windows declared none of the app-surface keys while silently no-opping them, which under the table's "absent = true" rule claimed support that never existed. On Linux,app.icon,app.attentionandapp.presenceemit zero bytes of Wayland protocol — GTK3's Wayland backend has nowhere to put a window icon, an urgency bit or a skip-taskbar hint — so all three are now gated on X11 and report honestly per session. Badge/progress detection is a bus-name probe rather than anXDG_CURRENT_DESKTOPguess, which was wrong in both directions.A second pass (2026-07-27) found six more of exactly the same shape, by checking every capability name against what the launcher actually dispatches: Windows claimed
otherWindows,moveOtherWindows,pickColor,spotlightandmediaKeys— the first falls through the launcher'sGETtonull, the next three reachgot_unsupported, and nothing inlauncher-win.cchas ever written a media-key line, soonMediaKeycould not fire. Linux claimedotherWindows,selectedTextandmoveOtherWindowson X11; the first two have noGETarm at all andWINCTRLalready said "moving other apps' windows isn't supported on Linux". X11 could support all three — nobody has written it, which is a different statement from the one the table was making."permissions": { "speechRecognition": "why" }— a newtinyjs.jsonkey, injectingNSSpeechRecognitionUsageDescription. Speech-to-text isn't atiny.*call: the page's ownwebkitSpeechRecognitionalready works (WebKit has it, and so does WebView2), but a bundled app needs that usage string as well as the microphone one, and without it the OS refuses the service rather than the mic — the page seesservice-not-allowedwith no prompt to accept and nothing to click. Measured: adding the key alone turned that error intostart+audiostarton an otherwise identical build, and a human accepting the prompt confirmed real transcription. It also implies thecom.apple.security.device.audio-inputentitlement under the hardened runtime, same asmicrophone.Pairs with
macos.aiandapp.say()for a hands-free loop — listen, generate, speak — which is what the deck's AI card now demonstrates.macos.ai.generatecan call your functions. Passtools: [{ name, description, parameters, run }]and the on-device model decides which to invoke;run(args)is ordinary JavaScript and whatever it returns goes back to the model. Backend only — a tool'srunis a real function, so it can't cross the bridge from a page.Tools are declared in JS, not Swift, which needed the schema to be built at runtime:
DynamicGenerationSchema+ an untypedGeneratedContentargument, rather than the compile-time@Generablestruct the framework's examples use. The call round-trips Swift → a blocking C hop → the socket → your handler → back, with a 20s deadline so a handler that never answers can't wedge generation.With tools it resolves
{ text, calls }, andcallsis the record. This matters more than the plumbing: asked for three tool calls in one turn, the model made all three in one run out of four — and its prose claimed all three every time, including the runs where it silently skipped one. It passed a wrong argument once too. Never read completion offtext, and put anything irreversible behind a confirmation rather than a hopeful read of a sentence.win.hide({ app: false })— put one window away without hiding the app. A plainhide()on the main window is[NSApp hide:], which is right for a palette (focus goes back to whatever you were in) and wrong for an app whose main window is a launcher screen: putting it away while its document windows are up deactivated the app around them. Secondary windows always ordered out, so this only changes what'main'can do, and only when asked. Windows and Linux hides were already window-scoped; the flag is a no-op there.Menu keys can carry other modifiers — a menu
keywas ⌘ plus the character, with an uppercase letter meaning ⌘⇧, and no way at all to say ⌥⌘P or ⌃⌘K. Now anything extra is a prefix:key: 'alt+p'is ⌥⌘P,'alt+shift+f'is ⌥⇧⌘F, andctrl+/shift+spell themselves (cmd+is accepted and redundant — ⌘ is always in, since a menu bar without it isn't one). Same grammar on all three platforms, with ⌘ reading as Ctrl on Windows and Linux; Windows also stopped firing an accelerator when Shift was held but not declared, which made'F'and'f'the same key there.Files on the command line reach
onOpenFiles. Nothing read process argv before, somyapp notes.mdfrom a terminal was a launch with a document silently dropped. Now argv is parsed on every platform at startup — flags skipped, relative paths resolved against the cwd, and anything that isn't an existing path dropped rather than guessed at, somyapp --verbosedoesn't hand the app a document called--verbose. On Windows and Linux a second launch forwards its documents over the instance pipe to the running copy instead of only waking it.The backend hook is the reliable half:
onOpenFilesfires immediately. The page event is best-effort, exactly asOPENFILESfrom LaunchServices always was — there is no page-loaded signal to wait on.Fixed: every built macOS app was told to open its own plumbing. AppKit hands a process's whole command line to
application:openFiles:during launch, so an app with anonOpenFileshandler received its ownfrontend/index.htmlandapp.sock— and, once those were filtered, its own title and"960x640"— as documents the user had opened. On every launch, since the handler existed. The launcher now remembers its own argv and drops it. Found while testing the argv work, not by looking for it."openFolders": true— a folder document type:public.folderinCFBundleDocumentTypes,inode/directoryin the.desktopMimeType=. A folder has no extension to match, so it needed saying separately rather than being anotherfileExtensionsentry.tinyjs build --cli [name]— writes a shim beside the app, since only the build knows where the executable lands. It goes indist/bin/, notdist/, because the bare executable is alreadydist/<name>and a same-named shim overwrites it. On macOS it targets that bare binary, NOT the.app: the bundle's main executable is the launcher, which parses argv as<html> <socket>and dies on a file path. Caveat: the bare binary has no single-instance pipe on macOS, so running the shim while the app is open starts a second copy — cold start is what this covers well.app.setAsDefaultHandler(ext)—'ok' | 'unsupported' | 'failed'. Linux runsxdg-mime defaultfor the app's own mime (orinode/directoryfor'folder'); macOS and Windows answer'unsupported'rather than no-oping, since one wants a LaunchServices call and the other guards the setting deliberately. Deliberately NOT automatic at registration the wayurlSchemeis: claimingtinydeck://competes with nobody, claiming.mdcompetes with the user's editor, and an app that does that on first run is an app people uninstall.tiny.system.locale()—{ language, languages, system, region, timeZone }, read from the OS. Plus alocaleevent when the user changes their language or region, so an app can re-render instead of going stale until relaunch.A page mostly doesn't need it:
navigator.language,navigator.languages, the whole ofIntland thelanguagechangeevent all work in the webview (measured —Intl.DateTimeFormat().resolvedOptions()gives locale, time zone, calendar and numbering, and formatting is correct). The backend is the gap: txiki has noIntlat all, and readingLANGis the wrong instinct — Windows has no such variable, and elsewhere it describes the parent process rather than the user.Two lists because they answer different questions.
languagesisNSLocale.preferredLanguages, filtered to the localizations the app bundle declares — what to render.systemis the raw preference — what the user actually reads. They agree until they matter: an English-only app on a French Mac gets['en']and['fr-FR', …]. Rendering from the wrong one either produces text nobody asked for or hides that a translation is worth offering.macOS only for now;
capabilities().localeisfalseon Windows and Linux, with the route for each in their TODOs.On-device AI just ships now.
tiny.app.aiwas previously reachable only by people who built tinyjs themselves with an opt-in env var, because the release pipeline ran on amacos-14runner without the macOS 26 SDK — so for anyone who installed a release,availability()was permanently'unsupported'. The release job now builds onmacos-26with the Swift shim linked in, andsetup.shdetects the toolchain instead of asking: it links the shim when the SDK it compiles against carries FoundationModels, builds without when it doesn't, and says which it did. It checks the SDK, not the running OS — a Mac can run macOS 26 while its Command Line Tools still ship an older SDK, and that combination compiles fine right up until the import. (TINYJS_AI=0forces it off, for bisecting the Swift path.)Runtime requirements are unchanged and still worth guarding: macOS 26 with Apple Intelligence switched on.
availability()has three answers and only one of them can generate.Two things made this more than a runner-label change. The workflow never passed
-mmacosx-version-min, so the deployment floor came from the host SDK — invisible onmacos-14, and onmacos-26it would have published a universal binary refusing to launch below macOS 26 (measured:minos 26.0without the flag,14.0with it). Andswiftctakes a single-targetwhere clang takes-arch a -arch b; passing two targets doesn't error, the last silently wins, so the universal build is now per-arch pluslipo. Both slices cross-compile from one arm64 runner.The job asserts the three properties whose failures are silent, per slice: the floor is still 14.0, FoundationModels is still weak-linked (a strong link would stop the launcher loading at all below macOS 26 — the AI feature taking the whole app down), and the shim is actually present rather than a broken Swift step having quietly shipped a launcher that answers
'unsupported'. Cost: ~62 KB on a universal binary.Also fixes a latent hazard unrelated to AI:
macos-14is deprecated in GitHub's runner images, so that job had to move regardless — and moving it without pinning the floor was the trap waiting there.capabilities().aireflects the build, not the OS.tiny.app.aineeds the macOS 26 SDK andswiftcto compile at all, so whether a given launcher has it is a property of the build — and one built without it rejectsgenerate()with "not built in" while the table, by omitting the key, called it supported. It now asks the launcher (the only party that knows how the binary was compiled) and answersfalseon a stock build. The worst-placed over-claim of the set: the fallback for "no model here" is usually a whole different feature.app.thumbnailworks for any path now — folders,.appbundles,.css,.wasm, executables. It asked Quick Look for a content preview only (representationTypes: …Thumbnail), which exists just for types Quick Look has a generator for, so those all failed with "QLThumbnailErrorDomain error 0" while.js,.md,.htmland images worked — indistinguishable from random, from outside. It now asks for the best available representation and falls back to the document icon, which every file has. A path that doesn't exist still rejects, and now does so explicitly: the icon fallback would otherwise have turned a typo into a successful-looking generic page icon.tiny.audio.filtersworks on macOS (14.2+), not just Linux — so an app can ask for an EQ once instead of branching. There's no driver and no system install: a Core Audio process tap over the app's own WebKit audio processes is muted and fed back through an aggregate device wrapping the real output, with our IOProc filtering in between. The biquads are the same RBJ cookbook PipeWire'sbq_*builtins use, so the same numbers give the same curve on both platforms (Apple's own N-Band EQ AudioUnit would not have). Windows still reportsfalse.Muting is earned, never assumed: an unauthorized tap doesn't fail, it succeeds and returns silence, and muting on that would take the app silent entirely. So a chain starts unmuted and inaudible, and only promotes itself to muted-and-filtering once it has seen a real sample. If that never happens —
tinyjs devhas no bundle identity to hold the audio-capture permission — the app keeps playing unfiltered rather than going quiet.tinyjs devshows your app's icon on macOS too. Windows and Linux already passed the project's icon to the launcher in dev; macOS didn't, so a dev run wore the terminal's icon and every app you had open looked identical in the Dock and the ⌘-tab switcher. It now usesiconfromtinyjs.json(oricon.png), andapp.icon('')resets to that rather than to nothing — the closest equivalent to a packaged app resetting to its bundle icon.app.playSoundgained four portable names —'info','success','alert','error'. Every OS ships alert sounds and none agree on what they're called, so these ask for a meaning and get the platform's nearest equivalent (Ping/Glass/Funk/Basso on macOS, theSystemAsteriskfamily on Windows, the freedesktop sound theme on Linux). Platform names and file paths still pass through untouched; a name from the wrong platform resolvesfalse, as before.Feeds and hosts txiki's fetch couldn't reach now load. The runtime's
fetch(txiki v26.6.0) has two wire bugs, repro'd in TODO-txiki.md: any root-path URL goes out asGET //— S3/CloudFront-backed hosts answer 404 — and mbedtls never completes a handshake with a TLS 1.2-only host (rss.art19.com, anchor.fm). Eight of amp's ~60 baked-in podcast feeds hit one or the other. bridge.js now wrapsglobalThis.fetch: redirects are followed hop by hop, and exactly the broken cases route through the system curl — a real HTTP error is never retried, bodies stream, and every hop is re-validated as http(s) so a hostileLocation: file:///…throws instead of reaching curl. Both the backend'sfetch()and every page'stiny.fetchinherit it; a machine with no curl gets native behavior, bugs and all. Verified end-to-end on all three platforms; the shim dies the day a patched tjs ships (the plan is TODO-txiki.md).Event handlers can be removed.
tiny.api.on(event, fn)was push-only — additive likeaddEventListener(N handlers per event, all fire), but nothing could ever unhook one. It now returns an unsubscribe function, andtiny.api.off(event, fn)removes by reference. Everytiny.*.onsugar wrapper (menu.on,tray.on,theme.on, …) passes the unsubscribe through — and for those it's the only way to unhook, since the sugar wraps your callback andoffcan't match the wrapper by reference. A handler that unsubscribes mid-dispatch no longer makes the one after it get skipped.Linux:
getUserMediaworks, gated on the app's declared permissions. WebKitGTK's default for an unhandled permission-request signal is deny, so every camera/mic app failed withNotAllowedErrorwhilepermissions.checksaidgranted. The launcher now answers the signal — but desktop Linux has no consent layer underneath (no TCC, no WebView2 prompt;/dev/video*is simply open to the session), so the grant is manifest-gated: only what an app declares in tinyjs.json's"permissions"block is allowed.enumerateDeviceslabels ride the same gate;getDisplayMediastays denied.Linux:
mousePosition().insideis honest on Wayland, and outside tracking is opt-in. Wayland (and XWayland) hides the pointer once it leaves the app, and the stale bounds check leftinsidestuck true. The surface-under-pointer probe now vetoes it, so it goes honest-false instead. Newtiny.app.mouseTracking.start()/stop()for apps that need the pointer outside their windows: a no-opokwhere tracking is already global (macOS, Windows, real X11); on Wayland sessions it arms a ScreenCast portal session in cursor-metadata mode — pixels are never mapped — behind one consent dialog, with the restore token round-tripping through the app's store so a re-arm is dialog-free (24 ms measured). The desktop's sharing indicator shows while armed, as it should.
Fixed
readAccesscould stop the app loading at all (macOS). WebKit refuses to load a page unless the page itself sits under the directory passed toallowingReadAccessToURL:, so areadAccessthat didn't contain the page produced a blank window with nothing said. That's every app declaringreadAccessunder the documented headless-test recipe —TINYJS_HTMLpoints at a page in a temp dir whilereadAccessnames the home directory. The read root is now the nearest common ancestor of the two.app.playSoundcrashed the app on macOS, intermittently, usually on the second play.[NSSound soundNamed:]returns an autoreleased sound and the launcher stored it in a global without retaining it, so it was freed as soon as the handler's pool drained; the next play's[g_sound stop]messaged dead memory and aborted once that memory had been reused. Whether it died was a race, which is why it looked like a bad sound name rather than a leak.Linux
app.iconhad never worked, on any session. GDK only publishes_NET_WM_ICON— the property shells actually read — while the image fits X11's per-request limit; 256×256 lands, 512×512 is dropped. Every tinyjs app ships a 1024×1024icon.png, so this silently swallowed bothapp.icon()and the startup window icon for every app ever shipped, while returning success. The launcher now scales to an icon list (256/128/64/48/32, never upscaling).Linux
attention()latched forever — nothing cleared the urgency hint and Mutter doesn't clear it on focus, so one call left the window demanding attention for the rest of the run. A focus handler now drops it.Linux: every frameless window wore a full title bar on X11 sessions.
windowControls: falsereplaced_MOTIF_WM_HINTSwith a property declaring only its functions section, and a property that doesn't declare decorations means "WM default" — decorated — so it erased theframe: falseGDK had just written and Mutter wrapped the window in a frame. Frameless apps always declare both, so the second request undid the first, every time. The property now carries both sections. Startup chrome also rides the spawn env now so it applies before first show — the socket line landed after the window was mapped, which flashed a decorated frame at every frameless launch.macOS
secrets.getleaked its result. The launcher builds without ARC, so the__bridge_transferonSecItemCopyMatching's +1 return was a no-op and the NSData leaked on every read. Autoreleased explicitly now.win.setSize→getStateround-trips. Both the Windows main window and macOS titled satellites reported a frame size while setting a content size, so any read-modify-write ratcheted up by the title bar every pass (measured on macOS: 600×400 → 432 → 464 → 496). Borderless windows never showed it, which is why it hid for so long. Window creation still takes a content size — no feedback loop there.Windows
app.iconaccepts png (the one format every tinyjs app actually ships — it silently no-op'd on its ownicon.pngbefore), andicon('')restores the startup icon instead of falling through to the window class.Windows
setZoom,setMinSizeandstartResizeimplemented;startDragnow honours the per-windowDRAGWIN@<id>form, so a satellite's drag handle works. Frameless windows get the size they asked for.Windows: frameless satellite windows can be resized again. They had no grabbable edge at all. A frameless secondary answers
WM_NCCALCSIZEwith no non-client area and then hands WebView2 the entire window rect, so its child HWND — another process — owns every edge pixel:WindowFromPointnever resolves to us andWM_NCHITTESTis never asked, leavingWS_THICKFRAMEinert for grabbing. The grips that cover for exactly this on Linux were gated on__TINY_FRAMELESS, which only the Linux launcher injected. Windows now injects it for frameless secondaries too, so they get the same automatic 5px edges (data-tiny-noresizeandsetResizable(false)still opt out). The main window is deliberately unmarked — it keeps real left/right/bottom borders and trades away the top edge, as before.Windows
startDrag/startResizeno longer glue a window to the cursor.DefWindowProc'sSC_MOVE/SC_SIZEloop entered with the mouse button already up tracks until the next click, and the trip from a page'smousedownto the launcher is two process hops — so a click near an edge, rather than a drag, wedged the window to the pointer. Now guarded on a held button (as macOS already was), onsetResizable(false)read live, and against a second gesture nesting a modal loop inside the first. The live cursor position is passed as the message point, matching macOS and GTK.macOS
setZoom,setMinSizeandstartResizeimplemented — the same three, missing on the other side. All three reached the launcher's op chain, matched nothing and were dropped, so each resolvedtrueand did nothing; fire-and-forget window ops look identical whether they're unimplemented or merely ineffective, which is how they stayed hidden while shipping as cross-platform.setMinSizealso pulls a window that's already under the new floor back up to it, since AppKit otherwise appliescontentMinSizeonly to the next resize.startResizehas no AppKit equivalent to hand off to (Windows hasWM_NCLBUTTONDOWN, GTKbegin_resize_drag), so the launcher runs the drag itself and refuses to start unless a button is really held — the loop waits on mouse events, and one started without a live gesture would hang the app. macOSstartDraglikewise honoursDRAGWIN@<id>now, and checks for a live mouse-down first:performWindowDragWithEvent:raises on anything else rather than no-oping.Windows stopped claiming
nowPlayingandhaptic, which were no-ops.tinyjs buildrefreshes a stale launcher the waydevalready did, so a dev checkout can't ship a launcher predating its own sources — or silently skip the app icon, since--embed-iconis run by that binary.tiny.audio.pageChain(ctx)— the filter chain as Web Audio nodes in the page, for where the native chain doesn't exist. Same band vocabulary, same RBJ curves (measured in an OfflineAudioContext: ask +12 dB at the centre, get 12.00), and the same four verbs astiny.audio— so an app picks its backend once and the code after is identical. Page-scoped, honestly: it filters what you route throughinput→output, not audio the page never gets samples for; shelfqand per-filtergainRare ignored (usebalance()); and it's not for Linux, where Web Audio crackles and the native chain exists precisely because of that.It exists because Windows measured out: process-loopback capture is post-mute and post-volume, so the only way to silence the direct signal is session volume — which Windows persists, keyed on the EdgeWebView runtime path that every WebView2 app on the machine shares. A crash while filtering would have near-silenced Teams and every other WebView2 app until something rewrote the key.
capabilities().audioFilterstherefore staysfalsethere, permanently and with the numbers attached (TODO-audio-filters.md); the deck's EQ card now runs this chain on Windows instead of a dead panel.app.badgeworks on Windows, andcapabilities().badgesays so. It was written but never run there, so the capability deliberately reportedfalse; it has now been seen drawing on the taskbar button (and composing withapp.progress), so the claim is true rather than cautious.Fixed:
app.spotlight()answered[]on Windows instead of rejecting. The launcher correctly reports the op as unsupported; the bridge threw that away and returned an empty array, so a caller was told "no files matched" where the truth was "there is no search backend here". Those are opposite answers. It now rejects, like the neighbouringpickColoralready did.Fixed: a secret larger than 2560 bytes failed unexplainably on Windows. Credential Manager caps a credential blob there — smaller than the macOS keychain, which takes 4 KB without complaint — and over the cap
CredWriteWfails with a Win32 error (1783) that names nothing, which surfaced as a bare "credential write failed".tiny.app.secrets.setnow rejects oversized values up front, naming the byte count and the limit. Note the limit counts BYTES of UTF-8: 4-byte emoji reach it four times faster than ASCII.app.thumbnailon Windows is broader than the docs claimed, and sized differently. Folders, executables and plain text all render as their shell icon, the same breadth macOS has — it's Linux that is images-only. And where macOS and Linux treatsizeas points and render @2x, Windows treats it as pixels and returns exactly what was asked. Readwidth/heightoff the result rather than assuming either. Docs corrected.Fixed:
tinyjs build --cliprinted a Unix hint on Windows, suggesting anln -sfinto/usr/local/bin— neither of which exists there. It now prints asetx PATHline.Fixed: the curl fallback flashed a terminal window on Windows, once per hop. The fetch repair shim routes exactly two cases through the system curl — root-path URLs and TLS 1.2-only hosts — which is most podcast feeds (art19, anchor.fm), and a console tool spawned from a GUI-subsystem exe pops a console window for the life of the call. So loading a feed strobed one to three of them, and amp made it look like the app was shelling out to something. The curl hops and the
curl --versionprobe now go throughlauncher --run(CREATE_NO_WINDOW), the same routereg addand the updater'staralready took; it was only ever missed here because the fetch shim lives outsidecreateApp, where the launcher path was resolved.--runalso puts the tool in a job object withKILL_ON_JOB_CLOSEnow, so killing the wrapper — what aborting a curl-backed stream does — takes curl with it instead of leaving it downloading into a pipe nobody reads. Measured against a built app with a console-window watcher: 19 curl children, not one window; the same app spawning curl directly popped one per call.Fixed: an app's backend could run before the fetch repair was installed.
update.jsprobed for its bundle with a top-levelawait, which makes it — andbridge.js, which imports it — an async module, and ES module evaluation lets a synchronous sibling run while an async one is still pending. So a backend whose module body fetched before its own firstawaitgot txiki's unrepairedfetch: measured, a top-levelfetch('https://rss.art19.com/')failed withmbedtls connect -1 5 0while the identical call oneawaitlater returned 200. The probe is lazy now (bundlePath()is async), nothing in bridge.js's import graph awaits at the top level, and the first line of a backend gets the samefetchas the hundredth.
Also
test/appsurface.html— a self-driving page that steps through the whole app surface, holding each state long enough to see, and prints whatcapabilities()claims for the machine. It now minimizes itself beforeattention()and holds afterpresence('normal'): Windows definesFLASHW_TIMERNOFGas a no-op while the window is already foreground, and quitting straight after restoring a taskbar button makes it indistinguishable from the app simply leaving. Both checks were unfalsifiable on Windows before.TODO-verify.md— what's been built on one OS but not yet watched run on another. A fire-and-forget op reaching a shell that ignores it looks exactly like one that was never implemented, so "nothing appeared" needs a written home.- The docs site is now one page at tinyjs.app/docs (was /api): install, the full API, and how-it-works / portability / gotchas, with a section rail on wide screens.
0.29.0 — 2026-07-23
Linux support (beta). tinyjs apps now run on Linux: a third native launcher (
native/launcher-linux.cc, GTK3 + WebKitGTK 4.1 directly) speaks the identical wire protocol over the same Unix socket macOS uses, and the CLI, bridge, and build work cross-platform. Runs on X11 and Wayland sessions, Ubuntu 24.04+ and current distros withwebkit2gtk-4.1.tinyjs dev/build/publishand auto-update all work:buildproduces a portabledist/folder (compiled backend binary with the frontend and icon riding inside it, pluslauncherandicon.png) with no install step — a built app registers its own.desktopentry (app-menu listing, icon, deep links viaurlScheme, file associations viafileExtensions, single instance) on first run.publishemits<name>-<version>-linux-<arch>.tar.gzplus a manifest carrying a per-arch"linux": { "<arch>": { url, sha256 } }block alongside the existing mac and win blocks; installs verify and swap in place, then relaunch. Working: window ops, frameless/transparent chrome (vibrancyis a no-op), menus +key:accelerators, tray via AppIndicator/StatusNotifier (menu-based — a bare icon click is emulated via a menu entry,tray.position()returnsnull), native dialogs, clipboard (+ watch), notifications with action buttons (no reply fields), theme + sleep/wake,power.preventSleep(logind),secrets(Secret Service/GNOME Keyring),shell.open/reveal/trash,launchAtLogin(built apps, autostart.desktop), multi-window, custom context menus + suppression, hot reload,tiny.fetch/proxyURLstreaming,printToPDF, the print dialog,screens/mousePosition/getWinState,captureScreen(X11 sessions only),thumbnail(images only), global hotkeys (XGrabKey on X11; the GlobalShortcuts portal on pure Wayland — a one-time approval dialog),keystrokesynthesis (X11/XWayland via XTest only),playSound/beep,say/voices(via speech-dispatcher'sspd-saywhen installed),pickColor(portal),idleTime(GNOME),battery,dock.bounce(urgency hint),nowPlaying+ media keys (a real MPRIS player — metadata in the GNOME/KDE media widget and lock screen, transport back viaonMediaKey), andaudioTap—scope:'app'is REAL per-app capture on Linux (a private PipeWire null sink fed by the app's own output ports, so it hears itself and nothing else; playback untouched), with system scope falling back to the default sink's monitor — plusspotlight(name search via indexedplocate/locate, else a boundedfind). Not (yet) supported:recorder,ocr,quickLook,applescript,haptic, Dock badge/bounce({critical: true})/dockIcon,share,wifi,selectedText/otherWindows/moveWindow/frontmostApp,authenticate,tiny.app.ai, andsetAllSpaces(maps to sticky windows) — all fail cleanly so cross-platform code can feature-detect: capability calls reject with a specific reason, query calls resolvenull, fire-and-forget ones are silent no-ops. Burn-down list: TODO-linux.md.tiny.system— know where you are, and what's missing.os()/isMacOS()/isWindows()/isLinux()answer synchronously;info()adds arch and the Linux session/desktop;capabilities()reports what this machine can actually do so apps degrade deliberately. On Linux, where the media stack ships in optional pieces,requirements(ids)says what's missing and how to fix it (per-distro package lists + the exact install command),requirements(ids, { refresh: true })re-probes after the user installs, andpromptMissing(ids)puts a native dialog in front of them with a Copy install command button — showing nothing at all when everything's present, so it's safe to call from a failure path. Dialogdetailtext keeps its line breaks on Linux.tiny.audio— native DSP on the app's own output (Linux). Web Audio is the right tool for an EQ on macOS/Windows and unusable on Linux: WebKitGTK renders the graph on a normal-priority thread, so anything reachingctx.destinationcrackles.audio.filters(list)runs the chain in PipeWire instead (peaking/shelves/passes/notch/allpass + linear gain, up to 28 filters, per-channel gains viagainR), applies to audio the page never sees (raw radio streams, native HLS), retunes live with no gap, and survives reloads.audio.balance(v)pans on the chain's output stream — no filter slot, no rebuild.capabilities().audioFiltersgates it; the chain dies with the launcher, so nothing can be left behind.Frameless windows resize on Linux — and windowing grew up generally. Undecorated GTK windows have no WM resize edges, so the injected client mounts invisible grips on every frameless window (opt out per page with
data-tiny-noresize, or viasetResizable(false));tiny.win.startResize(edge)starts a native resize from your own handle.setResizable(false)now means what it means elsewhere — the USER can't drag the edges — without blocking the app's ownsetSize(GTK conflated the two, so a fixed-size deck couldn't collapse into its own shade view).startDrag/startResizeare correctly per-window (a satellite's grip used to move the main window). New:tiny.win.setMinSize(w, h)(+minSizeinwin.open) puts a WM-enforced floor under user resizes, andtiny.win.setZoom(factor)renders native page zoom — crisp double-size modes on hi-dpi screens with no page changes (Linux launcher today; no-ops elsewhere until the mac/win launchers grow the op).windowPlacementmanifest key. Wayland forbids clients placing their own toplevels; apps whose whole design is windows that place each other (amp's docked satellites) can declare"windowPlacement": trueand run on X11/XWayland where all of it works — everything else stays Wayland.Linux installer + prebuilt binaries.
curl -fsSL https://tinyjs.app/install | shnow detects Linux too, installing to~/.tinyjsthe same as macOS (needs the systemlibwebkit2gtk-4.1-0runtime). From source,./setup.shnow also builds on Linux — needsbuild-essential pkg-config libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev— and downloads a prebuilttjs, or builds txiki.js from source withTJS_BUILD=1(needs cmake + ninja). The release workflow gainedlinux-x86_64andlinux-arm64jobs that build txiki.js from source and ship it astjs-linux-<arch>.gz, checksummed alongside the macOS and Windows assets.
0.28.3 — 2026-07-22
- Windows: taskbar pins finally work. The visible window belongs to
launcher.exe(the WebView2 host), so pinning it pinned a binary that can't start on its own — the pin was dead on next launch. Every tinyjs window now carriesSystem.AppUserModelrelaunch properties (command/name/icon pointing at the app exe, plus a stable per-app AppUserModelID), so pins and Start-Menu launches start the actual app. The toast-notification Start-Menu shortcut targets the app exe too.
0.28.2 — 2026-07-22
The Windows polish release: no stray terminal windows, a real exe icon, and a two-file dist.
- Windows: console tools no longer flash terminal windows. txiki's
tjs.spawncan't passCREATE_NO_WINDOW, so every console child (tasklist,tar,reg, …) spawned from a GUI-subsystem app popped a visible console. Newlauncher-win.exe --run <cmd…>mode runs any console tool hidden with stdio and exit code passed through; the bridge routes its ownreg addcalls (deep-link registration) and the updater's zip extract through it, and apps getapp.spawnHidden(args, opts)— plaintjs.spawnon macOS, hidden on Windows. - Windows: built exes carry the app icon.
tinyjs buildnow stamps the icon into a clean copy of the runtime beforeapp compileappends the TPK bundle (the output itself can't be resource-edited), sodist/<name>.exeshows the real icon in Explorer, the taskbar, and registryDefaultIconentries. - Windows: dist is just two files. The frontend and icon already ride inside the compiled exe's TPK bundle (extracted to tmp at launch), so the build stops shipping the redundant
dist/frontend/anddist/icon.png— a built app is<name>.exe+launcher.exe, nothing else. Bundle detection (updates, single-instance, deep links) no longer requires afrontend/folder next to the exe; older dists keep working. - Per-platform release notes. A
win.notesfield in the update manifest overrides the top-levelnoteson Windows, for releases where the two platforms ship different things.
0.28.1 — 2026-07-22
- Per-platform update manifests.
url/sha256remain the macOS zip; awin: { url, sha256 }block carries the Windows build. Windows apps overlay it on check and report "no update" when a release has no Windows zip — never a mac download.tinyjs publishon Windows names the zip<name>-<version>-win.zipand emits thewinblock; publishing both platforms means merging the two manifests (mac owns the top-level fields).
0.28.0 — 2026-07-22
The whole example fleet now runs on Windows — this release is everything a one-by-one sweep of all 25 apps shook out of the framework.
tiny.fileURL(path)— one correctfile://builder for both OSes (drive letters, backslashes, UNC hosts, percent-encoding). Hand-rolled'file://' + pathwas the single most common porting bug; stop writing it.- Windows: UNC media plays untainted.
file://server/shareURLs (Parallels shared folders, network drives) are host-bearing and therefore cross-origin to Chromium — a WebResourceRequested interceptor now serves them with CORS headers and full Range/206 support, so seeking and WebAudio analysis work like local files. - Windows: secondary windows reached parity with main. Every
win.openwebview now gets the identical wiring: context menus, the UNC interceptor, drag-and-drop forwarding, menu accelerators — and Chromium's autoplay gesture-gate is off, so satellite windows (visualizers analysing a silent twin stream) start without ever being clicked. - Windows: transparency, the full story. Transparent windows composite correctly in every combination we could construct: WebGL content in secondaries (
WS_EX_NOREDIRECTIONBITMAPat creation),setChrometransparency applied at any time, and transparent MAIN windows — declare those in tinyjs.json"chrome"; a Windows window can host transparency or a Win32 menu bar, never both. - Windows:
emoji:tray icons — the asset-free twin of macOSsf:symbols.tray.set({ icon: 'emoji:🍵' })renders the glyph as a monochrome silhouette that follows the taskbar theme; branch per-OS and neither platform ships an icon file. - Windows:
audioTapscope'app'rides the system loopback rather than rejecting — honest degradation until true per-process capture lands. - Store hardening (both OSes):
store.setbursts are serialized and renames retry — Defender/indexers transiently lock fresh files on Windows, which surfaced as EPERM under load. - Bridge survives
window.chromeshadowing. A page declaring its ownchromeno longer severs the RPC channel (the post hook is stashed at document-start). Related rule now in the skill: never declare a top-levelchromeidentifier — on WebView2 aconst chromeis a parse-time SyntaxError that kills the entire script. - Plus: window moves no longer steal focus (
SWP_NOACTIVATE), new windows clamp onto the virtual screen (stale saved positions can't open apps in the void), andwin.openposition/size honor DPI scaling everywhere.
0.27.2 — 2026-07-21
- Windows: logical coordinates everywhere + WebGPU. The launcher is per-monitor-DPI-aware, so Win32 hands it PHYSICAL pixels — but the tinyjs coordinate contract is logical CSS-pixel units (macOS points). On scaled displays secondary windows opened at half size and app-driven window drags ran at half the cursor speed (amp). Every wire coordinate now converts at the boundary: setPosition, win.open size/position, SIZE, getState, mousePosition, screens, tray.position. Also: WebView2 now gets
--ignore-gpu-blocklist --enable-unsafe-webgpu, sonavigator.gpuyields a real adapter even on virtualized GPUs — WebGPU parity with the macOS launcher, which force-enables the WebKit feature flag.
0.27.1 — 2026-07-21
- Windows: local media now drives WebAudio. Chromium gives every
file://URL an opaque origin, socreateMediaElementSourceon a local track was CORS-tainted and output pure zeros — record players, EQs, and visualizers ran silent while the element "played". The bridge now passes--allow-file-access-from-filesto WebView2 (via a small patch to the vendored webview loader, which ignoredWEBVIEW2_ADDITIONAL_BROWSER_ ARGUMENTS), makingfile://same-origin like WKWebView treats page-dir files on macOS. Verified end-to-end with platter: art + audio both work.
0.27.0 — 2026-07-21
Windows: second wave — near feature parity. Multi-window (
win.open— each window hosts its own WebView2 with the full bridge;<winid>:<seq>call routing like macOS), drag & drop with real paths both directions (IDropTargetin,DoDragDrop/CF_HDROPout),printToPDF,captureScreen(no permission dance),thumbnail(shell image factory),say/voices(SAPI), clipboard image write (CF_DIB), menukey:accelerators (Ctrl+<key> via WebView2 AcceleratorKeyPressed),launchAtLoginfor built apps (HKCU Run), chrometransparent+ vibrancy→mica/acrylic backdrops (Win11 22H2+), accessory apps drop the taskbar button, andtinyjs publish+ app auto-update: manifest sha256 via WebCrypto (no shasum spawn on either OS), bsdtar zip, and an in-place file-by-file swap on install (Windows can't rename a folder holding a running exe; locked exes park as*.update-old, swept next update) — verified end-to-end with a real 1.0.0→1.0.1 self-update. Launchers now chdir out of the app folder so updates never block on a cwd handle.Windows installer + prebuilt binaries.
irm https://tinyjs.app/install.ps1 | iexinstalls a prebuilttinyjs-windows-x86_64.zip(built by the release workflow's newwindows-latestjob, checksummed alongside the macOS tarballs) into%LOCALAPPDATA%\tinyjsand adds it to the user PATH — no git, no compiler.tinyjs updatere-runs the installer on Windows too. Dev nicety:tinyjs devfrom a source checkout rebuildslauncher-win.exeautomatically whennative/launcher-win.ccorruntime/tiny.jschanged.Windows support (beta). tinyjs apps now run on Windows: a new native launcher (
native/launcher-win.cc, WebView2 via the same vendored webview library) speaks the identical wire protocol over a named pipe, and the CLI, bridge, and build work cross-platform. From a source checkout:powershell -ExecutionPolicy Bypass -File setup.ps1(downloads the Windows txiki.js runtime + WebView2 SDK header, compiles the launcher — needs MinGW-w64, e.g.winget install BrechtSanders.WinLibs.POSIX.UCRT— and adds the checkout to your user PATH sotinyjsworks from any new terminal;-SkipPathopts out). Working: the full page↔backend bridge (calls, events, tiny.fetch), dev mode with hot reload, VitedevUrlfrontends,tinyjs build(portabledist/folder with<name>.exe), native file/save/ folder dialogs, alert/confirm/prompt, menu bar + tray (+ balloon notifications), custom context menus, clipboard (text/html/files/image read), global hotkeys, keystroke synthesis (cmdmaps to Ctrl), shell open/reveal/trash, secrets (Credential Manager), power.preventSleep, theme + sleep/wake events, window ops (chrome/fullscreen/ontop/click-through/…). Not (yet) ported: multi-window, drag-out/drop-in with real paths, notifications with actions, printToPDF, audioTap, and the macOS-specific APIs (Quick Look, OCR, AppleScript, Touch ID, vibrancy, Dock, Spaces…), which reject or report'unsupported'cleanly so cross-platform app code can feature-detect.tiny.app.ai(on-device LLM) — landed behind an opt-in build, not yet in the released tarballs.ai.generate(prompt, { instructions })runs Apple's FoundationModels locally (offline, no API key, private);ai.availability()→'available' | 'unavailable' | 'unsupported'. Implemented as a small Swift shim linked into the launcher withTINYJS_AI=1 ./setup.sh(needs the macOS 26 SDK); the binary keeps the macOS 14 floor and weak-links FoundationModels, so stock builds and older systems just report'unsupported'. Shipping it in the release tarballs needs the CI runner bumped to a macOS 26 image — held for a deliberate pipeline change so the defaultmacos-14build never depends on the newer SDK. Proven working end-to-end locally (real generation, ~250ms).
0.26.0 — 2026-07-20
- Custom User-Agent.
createApp({ userAgent })/"userAgent"in tinyjs.json overrides the webview's User-Agent string (packaged apps read theTinyjsUserAgentInfo.plist key the build writes; dev passes it viaTINYJS_UA). WKWebView's default UA omits the trailingVersion/x Safari/x, so UA-sniffing sites reject it as an unknown browser — mainly useful when pointing adevUrlat a real hosted site. Caveat: a UA alone often isn't enough. Many SaaS apps (Slack, etc.) also feature-detect at boot and refuse embedded browsers, and an embedded WKWebView genuinely lacks some of what they require (e.g. Web Push) — so this gets you recognized as a browser, not necessarily in. TINYJS_INJECT(footgun, dev-only). An env var that injects arbitrary JS into every page at document-start on any origin, for shimming a third-party site wrapped viadevUrl. Deliberately undocumented in the public API with no packaged-app equivalent — it runs your code inside someone else's origin with full page privileges. A debugging tool, not for shipping.
0.25.3 — 2026-07-18
tiny.proxyURLnow plays live internet radio through the Web Audio / EQ graph. A live (icecast/shoutcast) stream answers200with noContent-Lengthand no byte-range support, so WKWebView's custom-scheme media loader rejected the<audio>with error 4 (SRC_NOT_SUPPORTED) — the limitation noted in 0.25.0. The proxy now detects a length-lessaudio/*/video/*response and serves it with a synthetic largeContent-Length(dropping the contradictory range/chunked headers), so the element plays it progressively as one long non-seekable resource andMediaElementSourcetaps it for real. Verified end-to-end on live StreamTheWorld MP3, both direct and via the redirect: error 4 → playing with live analyser data. Redirects were never the issue — they always worked. Tradeoff: for a live streamaudio.duration/currentTimeare meaningless (the fake length maps to a huge timeline) — don't wire a seekbar to one. The synthetic length is gated to media MIME types, so ordinaryfetch()throughproxyURLis unaffected.- Friendly apology on Intel Macs instead of a cryptic hang. The app launcher is a universal binary but the bundled
tjsruntime is Apple-Silicon-only, so a distributed app used to launch on an Intel Mac and then hang on a backend that could never start. The launcher now detects this (posix_spawnreturnsEBADARCH) and shows a plain "This app needs an Apple Silicon Mac" apology, then exits cleanly. ThetinyjsCLI also prints a non-fatal heads-up when run on an Intel Mac. tinyjs remains Apple-Silicon-first.
0.25.2 — 2026-07-17
"contextMenu": falsein tinyjs.json suppresses WebKit's default right-click menu (Reload / Back / Forward / Inspect Element…) for an app-like feel. Off by default — the browser menu shows unless you opt out. A customtiny.menu.setContext(...)still overrides, andnull/clearing it falls back to suppressed (not the WebKit default) while the option is on.
0.25.1 — 2026-07-17
- docs: corrected
tiny.audioTapTCC behavior. The firststart()does prompt for "System Audio Recording" — evenscope:'app', because WKWebView renders audio in a separatecom.apple.WebKit.GPUhelper, so the tap is a cross-process capture (the grant then persists per app, which is why repeat runs don't re-prompt). Authorization is deferred to the firststart()(declaring the manifest key does nothing at runtime), so the tap can be lazy-armed. Undertinyjs devthe audio owner is the terminal, so the tap delivers real PCM only if that terminal holds the grant (else silent chunks). No code change — the implementation already defers tostart().
0.25.0 — 2026-07-17
tiny.audioTap— read the app's (or the system's) rendered audio output as PCM, for VU meters and visualizers — including audio that never touches Web Audio (native HLS, CORS-tainted streams, other apps). Read-only: it observes the mix in sync with what's audible, it can't process it (EQ still needs the signal in the graph — that'sproxyURL; the two compose).await tiny.audioTap.start({ scope: 'app' | 'system', interval })thentiny.audioTap.on(({ pcm, sampleRate, channels, frames, t }) => …)wherepcmis base64 of interleaved little-endian Int16;stop()(or the owning window closing) tears it down. Gated by an"audioTap": "app" | "system"key in tinyjs.json (the build bakesNSAudioCaptureUsageDescription). Implemented with Core Audio process taps (macOS 14.2+): aCATapDescription→AudioHardwareCreateProcessTap→ an aggregate device → an IOProc that downmixes to stereo Int16, chunked out to the page over the existing bridge.scope:'app'selects exactly this app's audio process objects by matching the responsible pid (WKWebView renders audio in acom.apple.WebKit.GPUXPC helper that isn't a child process). Authorization is deferred to the firststart()(declaring the manifest key does nothing until then), so the tap can be lazy-armed; that firststart()prompts for "System Audio Recording" — evenscope:'app', since the GPU-helper tap is a cross-process capture — and once granted yields real audio (verified in a real bundle: RMS matched a test tone to 3 decimals).scope:'system'also hears other apps. Survives output-device changes (headphones) by re-arming. Cross-platform by design (Windows process-loopback / WASAPI, Linux PipeWire), macOS-only for now. Note: undertinyjs devthe audio owner is the terminal, not the app, so the tap delivers only if that terminal holds the grant (else silence); a built .app owns its own grant.- docs: corrected the
tiny.proxyURLdocs — driving<audio>through the proxy needs a byte-range-capable (HTTP206) stream; a non-seekable live stream that answers200can't play through the proxy element (fall back to the raw URL). Redirects were never the issue.
0.24.0 — 2026-07-16
tiny.proxyURL(url)— get a cross-origin stream (internet radio) into Web Audio. AMediaElementSourceon a cross-origin<audio>outputs silence by spec, so radio can't drive an EQ or analyser.proxyURLreturns a same-apptiny-media://…URL that streams the remote http(s) resource through the native layer (NSURLSession — redirects, byte-range/seek, native buffering) and injectsAccess-Control-Allow-Origin: *; with<audio crossorigin= "anonymous">the element is CORS-approved and untainted, so the full Web Audio graph gets real samples. Implemented as aWKURLSchemeHandlerregistered on every webview via a swizzle of-[WKWebView initWith Frame:configuration:]— no backend hop, no base64, no TCP port. Verified end-to-end: aMediaElementSourceon a proxied SomaFM stream drove a liveAnalyserNodewith real (non-silent) waveform data. macOS only for now; the design (custom scheme + streamed upstream + injected CORS) maps to WebView2 (WebResourceRequested) and WebKitGTK (register_uri_scheme) when those backends land.
0.23.0 — 2026-07-16
tiny.fetch(url, init)— afetchthat runs in the backend (a native process), so it has no CORS, CSP, or mixed-content limits: the page can reach any origin. Same shape aswindow.fetchand resolves to a realResponse(res.json()/res.text()/res.headers/res.okall work); request bodies can be strings,ArrayBuffer/typed arrays,Blob, orURLSearchParams. Pass{ stream: true }for a live streaming body — the backend keeps the connection open and the page pulls chunks on demand (res.body.getReader()) with natural backpressure. That's what an endless source like internet radio needs, where a buffered fetch would never resolve;reader.cancel()(or closing the window) tears the upstream connection down. Transport reuses the existing request/response bridge — chunks travel base64 over the CALL→RET channel, so there's no new wire frame and no launcher change. Note: a page with CORS-free network reach has the app's full network reach — not a new trust boundary for tinyjs (every page already has an RPC channel to a backend with full system access), but worth knowing.
0.22.6 — 2026-07-16
tinyjs notarize --dmg— rebuild the installer dmg from the stapled .app.staplermutates the bundle in place after Apple's verdict, so a dmg made atbuild --dmgtime holds the pre-staple .app with no ticket embedded — online Gatekeeper hides this (it fetches the ticket), but offline / first launch before network it blocks the app.notarizenow rebuilds the dmg from the stapled bundle when--dmgis passed, and refreshes an existing dmg on disk automatically (that copy is guaranteed stale). Ship a dmg withtinyjs build --dmg && tinyjs notarize --dmg.
0.22.5 — 2026-07-16
chrome: { acceptsFirstMouse: true }— opt a window into delivering the click that focuses it straight through to the page. macOS normally swallows that first click into web content ("click once to focus, again to act"), which also bites between an app's own windows and makes an unfocused window's DOM drag region need an extra click. On per window (main or secondary), viatiny.win.setChrome,app.openWindow'schrome, ortinyjs.json"chrome"(applied before first paint); reported back ingetState().chrome. Off by default — matches the platform, no change for existing apps.
0.22.4 — 2026-07-16
- Multi-line JavaScript sent to the page keeps its newlines. Script pushed across the bridge —
app.eval, a window handle'seval, and the event pushes behindapp.push/push— was flattened onto a single line before it crossed the socket, so a//line comment silently commented out everything after it (a whole multi-line snippet could vanish after its first comment). Snippets now travel wire-escaped and are unescaped in the launcher, so real newlines survive intact; event payloads containing newlines or backslashes round-trip unchanged.
0.22.3 — 2026-07-15
tinyjs notarizefails fast on the wrong signature. It now inspects what the built.appis actually signed with (viacodesign) and stops immediately unless that's a Developer ID Application certificate — naming what it found (ad-hoc, unsigned, or e.g. an "Apple Development" cert) instead of uploading, waiting minutes, and coming back with Apple's opaque "Invalid". It checks the artifact on disk, not just the configured identity, so a build that predates a config change is caught too.- The notarize "needs a real Developer ID" error now mentions the
TINYJS_SIGN_IDENTITYenv var, not justsignIdentityin tinyjs.json — both have always worked (so doesTINYJS_NOTARY_PROFILEfor the notary profile); the message just never said so.
0.22.2 — 2026-07-15
tinyjs uninstall. Cleanly removes the install (~/.tinyjs, or$TINYJS_HOME) and thetinyjsPATH symlink — the exact inverse of the installer. It removes only a symlink that points back into the install it's running from (so an unrelatedtinyjson your PATH is left alone), prints what it'll delete, and prompts before doing it (--yesskips the prompt; a non-interactive run without--yesrefuses rather than deleting silently). Safe by construction: it won't touch a source checkout,/, or a directory containing.git. The shell-profile PATH line is left in place with a note, since editing dotfiles on uninstall is too invasive to do silently.- Friendlier installer finish. The install script now explains that it linked into a dir because it's already on your PATH, and warns that a running shell caches command lookups — so if this same terminal still says
tinyjs: command not found, runhash -ror open a new one.
0.22.1 — 2026-07-15
- Tray icons keep their aspect ratio. A PNG tray icon was force-scaled to a hardcoded 18×18, squishing wide "pill"/wordmark icons into a square and throwing away the PNG's DPI. Custom icons now scale to the 18pt menu-bar height while preserving width (a 58×22pt icon becomes 47.5×18pt, not 18×18), and the variable-length status item widens to fit. Square icons are unchanged;
sf:SF Symbol icons were never affected.
0.22.0 — 2026-07-15
chrome.squareCorners— drop macOS's rounded window corners.setChrome({ squareCorners: true })(or"chrome": { "squareCorners": true }in tinyjs.json, applied before first paint) makes the window borderless: square, no titlebar, no traffic lights. It's a deliberate, explicit choice — you lose the native titlebar drag (usedata-tiny-drag) and it reads as un-native — but resize edges, the drop shadow, and keyboard focus are all kept (acanBecomeKeyWindowoverride restores focus that borderless windows normally can't take). Works on the main window and secondaryopenWindowwindows;getState().chrome.squareCornersreports it, and it survivessetSize.
0.21.0 — 2026-07-15
Fixes and window ergonomics from real-app usage.
- Fix:
store.set()/delete()can no longer crash the backend. A write failure (bad path, full disk) used to surface as an unhandled rejection and take the process down; the store now swallows its own write errors (resolvingfalse, keeping the in-memory value) and uses a unique temp file per write so a burst of un-awaitedset()s can't race on the rename. openWindowgainschromeand{ x, y }— applied before the window first paints, so a frameless/vibrancy panel never flashes its titlebar and a positioned window opens where you asked instead of center-then-jumping.getState().chromeis now per-window (secondary windows report their own frame/trafficLights/transparent, not the main window's).readAccessoption —<audio>/<video>/<img>can only loadfile://assets under the frontend dir by default (MEDIA_ERR_SRC_NOT_ SUPPORTEDotherwise).createApp({ readAccess: true })(home) orreadAccess: '/path'(also"readAccess"in tinyjs.json) widens the read root so media anywhere under it loads directly — no base64 → Blob round-trip.- Docs: noted that WebKit throttles
requestAnimationFrame/timers in occluded or off-screen windows (do continuous work in the visible window or the un-throttled backend).
0.20.0 — 2026-07-14
Deep-Mac citizen — the small native niceties apps otherwise shell out (or give up) for.
win.printToPDF(path)— render the page to a vector PDF via WKWebView →{ path }. Invoices, reports, receipts without a print dialog.app.haptic(pattern)— trackpad haptic feedback ('generic'|'alignment'|'level'), a no-op on Macs without a Force Touch pad.app.dockIcon(pngPath)— replace the Dock icon (render a canvas for progress rings or unread badges);''resets to the bundle icon.app.battery()—{ percent, charging, plugged, minutesRemaining }(ornullon a desktop) from IOPowerSources.app.wifi()—{ ssid, bssid, rssi, noise, txRate }from CoreWLAN (ssid/bssidneed the Location permission on macOS 14+).app.spotlight(query)— find files by name or content via NSMetadataQuery (home scope, up to 100 paths) — nomdfindspawn.
0.19.0 — 2026-07-14
Window superpowers — the primitives behind overlays, HUDs, desktop pets, and window managers.
- Window stacking & behaviour —
win.setClickThrough(bool)(mouse events pass straight through — draw-on-screen overlays),win.setLevel( 'normal'|'floating'|'overlay'|'desktop')(overlayfloats above almost everything incl. most fullscreen apps;desktoppins behind normal windows for wallpaper/pets),win.setAllSpaces(bool)(follow the user onto every Space). All three round-trip throughgetState()and have backend twins onappandapp.window(id). app.selectedText()— the text selected in the frontmost app, for PopClip-style popovers (Accessibility permission;nullif none).app.otherWindows()/app.moveWindow(pid, rect)— enumerate other apps' on-screen windows and move/resize their frontmost window: a Rectangle/Magnet "snap the active window" primitive (Accessibility).tray.position()— the tray icon's on-screen rect, so a dropdown window can anchor under it (the Tauripositionergap).
0.18.0 — 2026-07-14
app.recorder— record a display to an .mp4:start({ path, screenId? })resolves once capture is running,stop()resolves{ path, duration }once the file is finalized. ScreenCaptureKit'sSCStreamfeeds H.264 frames into anAVAssetWriteron a dedicated serial queue. Video only for now (no audio track); one recording at a time. Needs the'screen'permission and macOS 14+ — the permission is preflighted so an ungrantedstart()rejects immediately with a clear message instead of hanging.
0.17.0 — 2026-07-14
Media tier — apps that behave like real media citizens. (Screen recording to .mp4 is next, in its own release.)
app.nowPlaying—set({ title, artist, album, duration, elapsed, playing })puts your app in Control Center and on the lock screen, and arms the hardware media keys; presses (F7/F8/F9, AirPods taps, Control Center transport) arrive as themedia-keypage event /onMediaKey(info, app)export with{ command: 'play'|'pause'|'toggle'| 'next'|'previous'|'seek', time? }.clear()tears it down. (MediaPlayer framework: MPNowPlayingInfoCenter + MPRemoteCommandCenter.)app.say(text, { voice, rate })— text-to-speech via AVSpeechSynthesizer; resolves when playback finishes (false if interrupted).app.voices()lists the installed voices ({ id, name, lang, quality }),app.stopSpeaking()interrupts.- Notification actions & reply —
tiny.notify(title, body, { actions: [{ id, title, reply?, placeholder?, destructive? }] })adds buttons or a text-reply field to a banner (packaged apps); taps come back as thenotification-actionevent /onNotificationAction(info, app)export with{ id, action, reply }(reply = the typed text).
0.16.0 — 2026-07-14
The Mac's superpowers — the frameworks macOS ships that JS can't normally touch. All on-device, plus auto-update quality-of-life.
app.pickColor()— the system eyedropper (NSColorSampler): pick any pixel in any app, no screen-recording permission needed. Resolves'#rrggbb', ornullwhen the user cancels.app.ocr(path)— on-device OCR via Vision (accurate mode) →{ text, blocks: [{ text, confidence, box }] }with normalized top-left boxes.captureScreen()+ocr()= screenshot-to-text in two calls.app.thumbnail(path, size?)— a preview png for any file type Quick Look understands (PSD, video, 3D models, …) →{ path, width, height }, rendered @2x.app.secrets— Keychain-backedget/set/delete(generic passwords under the app id) — the keytar/safeStorage role. Tokens go here, never intiny.store; values survive reinstalls.app.authenticate(reason)— Touch ID, falling back to the account password sheet →true/false(false covers cancel).app.applescript(source)— run AppleScript in-process (NSAppleScript, no osascript spawn) under the same'automation'permission the framework already manages. Control Music, Spotify, Finder — anything scriptable.- Auto-update polish —
"update": { "auto": "launch" | "daily" }checks in the background (packaged apps only) and fires theupdate-availablepage event /onUpdateAvailable(info, app)backend export with{ current, latest, notes };tinyjs publish --notes "…"(or--notes-file FILE) puts release notes in the manifest, andupdate.check()now returns them.
0.15.0 — 2026-07-14
Round three: preview, capture, and idle.
app.quickLook(paths)— the real Finder-spacebar preview panel (QLPreviewPanel — noqlmanagespawn) for any file(s) tinyjs apps manage; an array pages with the arrow keys,quickLook()closes.app.captureScreen(screenId?)— screenshot a display via ScreenCaptureKit →{ path, width, height }(a png in the temp dir the caller owns). Takes a display id fromscreens(), defaults to the primary. Needs the'screen'permission and macOS 14+ — rejects with the reason otherwise (the framework is weak-linked, so older systems still launch).app.idleTime()— seconds since the user's last input, session-wide (pause pollers / dim UI when they walk away).
0.14.0 — 2026-07-14
More stop-shelling-out, round two.
app.power—preventSleep(reason, { display? })/allowSleep()hold a single IOPMAssertion instead of spawningcaffeinate: the assertion dies with the process, so a crashed app can never wedge the Mac's sleep. The reason string shows up inpmset -g assertions.app.frontmostApp()—{ name, bundleId, pid }of the active app (who focus returns to afterhide()— pairs withpaste()).app.beep()/app.playSound(target)— system beep, a system sound by name ('Ping','Glass', …), or an audio file path; resolvesfalseif the sound didn't load. No moreafplayspawns.win.share({ text?, url?, paths?, x?, y? })— the native share sheet (NSSharingServicePicker), anchored at page coordinates — pass the click'sclientX/clientY. Backend twin:app.window(id).share(opts).
0.13.0 — 2026-07-14
Theme: stop shelling out. Five small APIs replacing the open/osascript spawns and hardcoded paths tinyjs apps still needed.
app.shell—open(target)(URL with any scheme, or a file path, in its default app),reveal(path)(show in Finder),trash(path)(move to the Trash — recoverable, prefer it over deleting user files). Each resolvestrueor rejects with the reason ('no such file','no application registered for URL').app.launchAtLogin—get()/set(v)→'enabled' | 'disabled' | 'requires-approval' | 'unsupported'via SMAppService. Packaged .apps on macOS 13+;'requires-approval'means the user must allow it in System Settings → General → Login Items. Dev mode reports'unsupported'(no bundle identity to register).app.screens()— every display in the same top-left coordinates assetPosition:{ id, name, x, y, width, height, visible, scale, primary }—visibleexcludes the menu bar and Dock, so multi-monitor palette placement is one call.app.paths—{ home, data, cache, logs, temp, downloads, desktop, documents }, per-app-id where it matters. Prefer these over hardcoding~/Librarypaths (they're what a future non-macOS backend would remap).app.dock—setBadge('3')/setBadge(''),bounce()/bounce({ critical: true }).- Docs: noted that
'screen'permission never reads'undetermined'(macOS only exposes a yes/no preflight), and that dev-mode TCC grants attach to the shared launcher binary rather than your app.
0.12.1 — 2026-07-14
- Clipboard metadata —
clipboard.read()now also returnsconcealed(the org.nspasteboard Concealed/Transient markers password managers set — clipboard-history apps must skip these),sourceApp({ name, bundleId }of the app the content came from: the frontmost app when the change was first noticed — exact whilewatch()runs, best-effort on a later read; your ownwrite()s are attributed to your app),sourceURL(the page a Chromium-browser copy came from), andimageSize({ width, height }in pixels, so no more shelling out tosipsfor dimensions). - Fix: a backend
export function onClipboardChange(e)was silently dead intinyjs dev/buildapps — the generated entry didn't forward it tocreateApp(directcreateApp({ onClipboardChange })users were fine).
0.12.0 — 2026-07-14
- Microphone + camera —
getUserMedia()now just works in tinyjs pages: the launcher answers WebKit's per-origin media-capture prompt itself, so users see a single system dialog naming your app instead of a double prompt namingfile://or localhost.tiny.app.permissions.check/requestgain'microphone'and'camera'(the TCC layer underneath, via AVCaptureDevice) for onboarding flows. "permissions"in tinyjs.json — packaged apps declare{ "microphone": "why", "camera": "why" }: the strings are injected asNSMicrophoneUsageDescription/NSCameraUsageDescription(required — macOS kills a bundled app that captures without them), and builds signed withsignIdentityget the matching hardened-runtime device entitlements (com.apple.security.device.audio-input/.camera) — without which the hardened runtime denies capture outright even after the TCC grant.
0.11.0 — 2026-07-14
- Native clipboard —
tiny.clipboard/app.clipboard: NSPasteboard in the long-lived launcher process.read()returns{ kind, changeCount, text, html, paths, image, color }(image lands as a png temp file),write()takes any combination of the same (image: png path, data: URL, or base64),changeCount()is a cheap change probe, andwatch(ms)/onChangepushclipboard-changeevents with aselfflag for your own writes (backend:onClipboardChangeauto-starts the watcher). No more per-secondosascript/pbpastespawns or scratch-file plumbing — and multi-file writes can't lose their tail to the short-lived writer flush race. - Drag files out of the app —
tiny.win.startDrag({ files, image? })(aliastiny.win.dragOut) starts a native NSDraggingSession from a page mousedown, so real files can be dragged into Finder, Slack, or any other app. Optional custom drag-image png; file icons (stacked for multi-file drags) otherwise. - Native keystrokes —
tiny.app.keystroke('cmd+v')/tiny.app.paste()post a CGEvent from the launcher: one permission (Accessibility) whose prompt names your app, instead of osascript→System Events needing Automation + Accessibility with prompts naming osascript or the terminal. Resolves{ ok, trusted }so apps can detect the missing grant. - Permission checks —
tiny.app.permissions.check(name)/request(name)foraccessibility,screen,notifications, andautomation[:<bundle-id>]→granted/denied/undetermined/unsupported, so apps can build a proper onboarding flow ("needs Accessibility to paste — Open Settings") instead of discovering failure at first use.requestprompts (Accessibility opens System Settings pointed at the app). - hide() now hides the app, not just the window —
tiny.win.hide()/app.hide()useNSApp hide, so macOS deactivates the app and returns focus to the previously active app automatically. Palettes canhide()→paste()with no frontmost-pid bookkeeping. Secondary windows (app.window(id).hide()) still just order out.show()unhides + activates as before. show({ activate: false })— surface the window without stealing focus, for overlay/HUD panels that must appear over the active app (clicking the window still activates normally).tiny.app.mousePosition()— cursor position three ways: global (x/y, same top-left coordinates aswin.setPosition— open a palette at the mouse),window(relative to the calling window's content area inclientX/clientYunits, with aninsideflag — valid even while the cursor is outside the window), andscreen(the display the cursor is on:{ x, y, width, height, scale }).- Fix: builds with a real
signIdentitynow sign with the hardened runtime and a secure timestamp (codesign --options runtime --timestamp) — both required by notarization, which previously rejected the .app. Ad-hoc builds are unchanged. codesign stderr is no longer swallowed when a real identity is used, so keychain errors surface. - Docs: README gains a "Distributing to other people" walkthrough — why ad-hoc-signed apps are blocked on other Macs, that Developer ID + notarization require the paid Apple Developer Program ($99/yr, a free Apple ID won't do), and the one-time cert /
notarytool store-credentialssetup ending intinyjs build && tinyjs notarize.
0.10.2 — 2026-07-13
- Fix:
app.notify()/tiny.notify()can no longer crash the backend when fired withoutawait. The dev-mode osascript fallback could reject (e.g.ENOENTwhen launched with a minimalPATH), and an unhandled rejection kills the txiki process. notify now uses the absolute/usr/bin/osascriptpath and never rejects — it resolvesfalseif delivery failed.
0.10.1 — 2026-07-13
- Fix: Vite-template scaffolds now include a
backend/tsconfig.json(jsconfig.jsonfor JS templates), so editors resolvetjs/TinyApiHandler/TinyAppin the backend — the Vite tsconfig only coverssrc/, and TS inferred projects ignore sibling ambient.d.tsfiles.
0.10.0 — 2026-07-13
- Vite / React / Vue / TypeScript —
tinyjs new myapp --template react-ts(vue-ts, svelte-ts, solid-ts, vanilla-ts, …) scaffolds create-vite wired to tinyjs.tinyjs devruns the Vite dev server with HMR inside the native window (frontend.devUrl);tinyjs buildrunsfrontend.buildand shipsfrontend.dist. The zero-dependency default scaffold is unchanged. - TypeScript backends — a
.tsbackend entry (configurable via"backend") is bundled with esbuild, which also makes npm packages usable in the backend. tinyis injected everywhere — notiny.jsscript tag; every page in every window (file:// or dev server) gets the global automatically.- TypeScript definitions —
types/tiny.d.tsships in scaffolds: the fulltiny.*surface,TinyApp,TinyApiHandler, window state, menus, chrome. - Vite builds run under
file://(WebKit file-access flags — module scripts withcrossoriginnow load).
0.9.0 — 2026-07-13
- Menu-bar agents —
"activation": "accessory"in tinyjs.json launches the app with no Dock icon and the window hidden, with no flash of either (packaged apps getLSUIElement; dev mode matches). Show the window later withtiny.win.show();setDockVisible(true)restores a regular app. - SF Symbol tray icons —
tiny.tray.set({ icon: 'sf:cup.and.saucer.fill' })renders a crisp, auto-templating menu-bar icon straight from an SF Symbol name; no shipped assets needed. PNG paths still work. - Tray primary action —
tiny.tray.set({ primaryAction: true, menu })splits clicks: left click firestiny.tray.onClick(backendonTray(null, app)), right/ctrl-click opens the menu — the classic Caffeine-style toggle.
0.8.0 — 2026-07-13
- Multiple windows —
tiny.win.open(id, { page, title, size })turns any frontend html file into a window. Every window runs the fulltiny.*bridge;win.*calls target the caller's own window;tiny.win.id,tiny.win.close(),tiny.win.windows(). Backend:app.openWindow,app.window(id).…(eval/push/close/title/size/position/chrome/getState),app.pushbroadcasts,onWindowClosedexport +window-closedevent, and api handlers receivemeta.window(who called). - Unified page RPC — one injected bridge for all windows (call ids carry the window id), replacing the webview library's binding; dialogs reply through the same path.
0.7.0 — 2026-07-13
- Frameless windows —
tiny.win.setChrome({ frame, trafficLights, transparent, vibrancy })(also"chrome"in tinyjs.json; packaged apps apply it before first paint). Frameless keeps native resize edges, focus, shadows, and fullscreen. Vibrancy materials: sidebar, hud, menu, popover, window, content, and friends. - Drag regions — mark any element
data-tiny-dragto make it a titlebar: dragging moves the window natively, double-click zooms; buttons/inputs inside are excluded automatically (data-tiny-nodragto opt out). tiny.win.zoom(),tiny.win.startDrag();getState()now reports the chrome state.- Fix: window chrome and
setResizable(false)now survivesetSize()(the webview library rewrites the styleMask on every resize; overrides are re-applied).
0.6.0 — 2026-07-13
- Native Notification Center — packaged apps built with a signing identity (even "Apple Development") get real banners: the app's own icon, a proper permission prompt on first
tiny.notify(), banners while frontmost, and clicks routed back (tiny.app.onNotificationClick/ backendonNotificationClick, cold starts included).notifygains{ id, subtitle, sound }. Ad-hoc and dev builds fall back to the osascript banner automatically; a user's explicit "Don't Allow" is respected (notifications drop silently).
0.5.0 — 2026-07-13
- Stateful menus — items support
checked,enabled: false, and nestedsubmenueverywhere (menu bar, tray, context menus).tiny.menu.update(id, { label?, checked?, enabled? })patches a live item without redeclaring;tiny.menu.get(id)reads one back. - Window read-backs —
tiny.win.getState()returns position, size, fullscreen/minimized/visible/focused/always-on-top/resizable, and screen info; set → get round-trips exactly. Newtiny.win.restore()and absolutetiny.win.setFullscreen(bool). - Wire protocol grows its first launcher→backend replies (
GET/GOT). tiny.app.info()—{ version, tinyjs, runtime }: the app's version, the tinyjs version it was built with, and the txiki.js runtime.
0.4.0 — 2026-07-13
- Deep links — claim a scheme with
"urlScheme": "myapp"in tinyjs.json;open myapp://…deliverstiny.app.onOpenUrl/ backendonOpenUrl, on cold start too (launch events are buffered until the app is ready). - File associations —
"fileExtensions": ["md"]puts you in "Open With"; files arrive as real paths viatiny.app.onOpenFiles/onOpenFiles, including Dock-icon drops. - Single instance — a second
open(URL, file, Dock) activates the running app and delivers the event to it instead of launching a copy. - New .app architecture — the launcher is now the bundle executable (it listens on the socket and spawns the backend), which is what makes the above work; the C shim is gone. Dev mode and the bare
dist/<name>binary keep the backend-first arrangement. Bundles still sign and notarize clean.
0.3.1 — 2026-07-13
- Global hotkeys —
tiny.hotkey.register(id, 'cmd+shift+k')/.unregister(id)/.on(fn); fire system-wide, even while other apps are focused (backend:app.hotkey.*,onHotkeyexport). - System events —
tiny.theme.get()/tiny.theme.on(fn)for dark/light (initial state included), plussleep/wakeevents (onSystemexport). tiny.store— persistent JSON settings in~/Library/Application Support/<bundle id>/store.json:get/set/delete/all, atomic writes.- Custom context menus —
tiny.menu.setContext(items)replaces the right-click menu with native items (nullrestores WebKit's default); clicks viatiny.menu.onContext(fn)/onContextMenuexport. tiny.win.print()— native print panel for the page.tinyjs build --dmg— also emitdist/<name>-<version>.dmg(the .app plus an /Applications shortcut).tinyjs notarize— submit the built .app vianotarytooland staple the ticket (needs a Developer IDsignIdentityand a keychain profile).- SQLite documented: txiki ships
tjs:sqlite(Database) — examples in the README and agent skill. - The launcher now links Carbon (for hotkeys);
setup.shand the release workflow updated accordingly.
0.3.0 — 2026-07-13
- Tray / menu-bar apps —
tiny.tray.set({ title, icon, tooltip, menu }), click events,tiny.win.setHideOnClose(true), andtiny.app.setDockVisible(false)for menu-bar-only apps. - Multi-file frontends — the inliner is gone;
src/frontend/ships as real files and relative js/css/images just work. Hot reload bypasses WebKit's caches (reloadFromOrigin), so edited subresources show up. - Auto-update for your apps —
"update": { "url": … }in tinyjs.json plustinyjs publish(zip + manifest with sha256).update.check()/update.install()download, verify checksum and code signature, swap the .app with rollback, and relaunch. Hardened: https-only manifest/download (localhost exempt for testing), mandatory sha256, Team ID pinning when signed with a real identity, translocation detection. - Notifications —
tiny.notify(title, body). - Drag & drop with real paths —
tiny.win.onDrop((paths) => …); file drags are accepted even if the page doesn't handle HTML5 DnD. - Window controls —
center,minimize,fullscreen,setPosition(top-left origin),setAlwaysOnTop,setResizable,hide/show. - Installer: prefers a writable bin dir already on PATH (
/usr/local/bin, then/opt/homebrew/bin), offers to add~/.local/binto your shell profile otherwise, and the "not on PATH" warning is now unmissable.
0.2.1 — 2026-07-13
- WebGPU — pages load as
file://documents (secure context, sonavigator.gpuexists) and the launcher enables the WebKit feature flag on macOS 15 and earlier.requestAdapter()/requestDevice()verified. - CLI self-update —
tinyjs update(with--check), plus a once-a-day update notice intinyjs dev.
0.2.0 — 2026-07-12
tiny.*frontend namespace —tiny.api.call/on,tiny.win.*,tiny.menu.*,tiny.log,tiny.quitvia thetiny.jsshim.- Native menus — default app menu (About with app name/version + Quit) and Edit menu (copy/paste shortcuts); custom menus via
tiny.menu.set. - System dialogs —
alert/confirm/prompt(NSAlert) alongside the file panels. - Agent skill —
tinyjs newscaffolds.claude/skills/tinyjs/SKILL.mdso coding agents know the API. - tinyjs.app landing page +
curl tinyjs.app/install | shinstaller. - (v0.1.1 was superseded by this release the same day.)
0.1.0 — 2026-07-12
- Initial release: txiki.js backend + native WebKit launcher over a private Unix-domain socket (no HTTP server, no ports),
tinyjs new/dev/build, hot reload, native file dialogs, codesigned.appbundles (~6 MB shipped) via the stock-runtime + shim layout, tag-triggered release workflow.