Every release is a git tag; tinyjs update gets you the latest.
"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/prompt get native panels headlined by the page's origin (confirm() used to return false silently, a permanent "Cancel" for any site guarding a destructive action); downloads actually download ("downloads": auto to ~/Downloads with de-duplicated names, ask for a save panel, or deny) with start/progress/done events; window.open is configurable ("popups": external to the real browser, window, or deny); navigation start/commit/finish/fail/crash reach onNavigate, which can also veto a main-frame navigation or send it to the browser; and tiny.win.find() searches the page."api" — a capability gate, so a wrapped site isn't handed the machine. tiny is 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.denied lets 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 (.ts via esbuild) — the supported way to shim a wrapped site, replacing the undocumented TINYJS_INJECT env 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.beforeunload is no longer auto-accepted (a wrapped app with unsaved work navigated away silently); a download's reported filename now names the file that exists on disk rather than the engine's suggestion; Back/Forward survive a navigation policy ask; and menu.update no longer applies stale specs.tiny.win.id inside a popup is the popup's own id (it read main, so app.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.tinyjs dev is quiet again. 0.37.0's always-has-devtools change seeded TINYJS_DEBUG=1 into 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=0 couldn't hush it. Dev now seeds a sentinel the trace ignores: devtools still arm on all three platforms, an explicit TINYJS_DEBUG=1 (or open) traces exactly as documented, and 0/false mean silence."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 dev always has F12, whatever the manifest says."browserAccelerators": true opts back in). Menu accelerators are untouched either way. WKWebView and WebKitGTK have no such set, so the flag is a no-op there.{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.keystroke can send function keys on Linux. 'F12' parsed to NoSymbol (the combo parser lowercases and XStringToKeysym is case-sensitive) and answered ok:false; F-keys now map to their keysyms directly, matching macOS and Windows."about": "menu" in tinyjs.json turns macOS's About item into an ordinary menu click — it arrives in tiny.menu.on / the backend's onMenu with the reserved id about instead 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 way setLevel('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, like transparent — 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 and WINCLOSED arrives for every one of them.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 — SetDefaultExtension there, nothing on Linux, because GTK has no equivalent — so the same call answered …/untitled-note on one OS and …/untitled-note.md on 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.md is left alone, never doubled), the type filter rather than "All files" must be the one selected, and a call with no types appends 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 bare gtk_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 resolved true. 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 the win.onState listener sees the minimized:false it should always have seen.tiny.dialog.openFile({ types: ['md', 'markdown', 'txt'] }) — and the same on openFiles and saveFile — limits what's choosable, mapped natively on each OS: allowedContentTypes on macOS, a COMDLG_FILTERSPEC on Windows, GtkFileFilter on Linux. Windows and Linux keep an explicit "All files" entry so a filter never hard-hides the disk; saveFile on Windows also appends the first extension by default. Extensions only, dots and case forgiven; omit types and nothing changes. Filtering is always explicit and per-call — deliberately not defaulted from the manifest's fileExtensions, so a picker never silently narrows because of an unrelated file-association declaration.windowControls: false or squareCorners booted titled, traffic lights and all, while tinyjs dev and 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 from trafficLights, the pre-0.30 name of windowControls, 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.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. 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 }), then v.set({ pan }) live and v.stop() with a click-free fade; s.master(v), s.unload(name). 32 voices, oldest-stolen — play() never fails for "too many". Backend twin app.audio.sampler drives the same mixer, one state owner (the bridge), so every window and main.js agree. 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, the documented cost). On Linux, where Web Audio reaching ctx.destination crackles under WebKitGTK (measured — the reason tiny.audio.filters is native too), the launcher decodes with miniaudio and mixes in a PipeWire pw_stream on the RT data loop — the page can be melting under rAF load and the audio doesn't care (measured: 20 live-automated voices, zero xruns, ~240µs a quantum). Same equal-power pan law as StereoPanner on all three, verified against a null-sink capture on Linux. Launcher decode also means SFX can't be broken by missing GStreamer plugins, and an active tiny.audio.filters chain picks the sampler up like everything else the app plays. capabilities().sampler says '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>).tinyjs new used 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 had app.badge unsupported on Linux (it works on LauncherEntry docks). Scaffolded projects get the whole skill dir now, not one file.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.mouseTracking.start() answering unsupported. 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 — joined tiny.system.requirements as 'mouseTracking', so promptMissing can put the fix in front of the user the same way it does for codecs."offscreenRescue": false in 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.squareCorners app 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.tiny.win.onState(fn) fires with { win, fullscreen, maximized, minimized, focused } on every transition, whatever caused it — the green button, a menu item, a shortcut, or your own setFullscreen — so nobody has to fake it by debouncing resize and re-reading getState(), which can't tell a fullscreen transition from a manual resize to screen size. Same vocabulary as getState(), deduped in the launcher so a drag-resize never spams the wire, broadcast to every window with the id attached. Backend twin: export function onWindowState(info, app). Like every on… since 0.30.0 it returns its own unsubscribe. Wayland never reports minimized — the compositor keeps minimize private.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 in tinyjs.json "chrome" (before first paint), setChrome, and win.open's per-window chrome; null restores the OS layout and getState().chrome reports 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.setHideOnClose no longer strands the app on Windows and Linux. It's a macOS idea — there an app outlives its last window and the Dock 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. 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. macOS unchanged, tray apps unchanged, programmatic win.hide() unchanged.main. Multi-window apps lost their menu in exactly the windows that needed it. tiny.menu.set is now the APP menu: every window shows a copy, including windows opened later, and menu.update moves every copy of an id at once — what one shared macOS bar already did. Nothing to change in an existing app. To differ per window there's tiny.win.menu.set/update/reset (backend app.window(id).setMenu) for a window that says something else, and chrome.menu: false for a window that shows no bar while the app menu carries on elsewhere — whether a bar shows is chrome, what's in it is menu. macOS swaps the bar on focus so a per-window menu means the same thing on all three platforms, and ignores chrome.menu, since a bar-less mac app isn't a thing. Two Windows fixes rode along: size now means the page's box whether or not there's a menu bar (attaching one used to silently shorten the page by its row), and secondary windows only drop the GDI redirection bitmap when transparent or frameless — a window without one cannot draw a menu bar at all.{ 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 the setMenu array and the standard menu takes that slot. Omit it and nothing changes; Windows and Linux skip the entry and keep the order the app declared. A related Windows fix: a menu declared immediately before the role line used to come out empty — the launcher flushed its items at the role slot and then flushed an empty list back over them.document.hasFocus() answered false from launch — and an app that gates menu events on it (the standard multi-window pattern) dropped every page-side menu action until you happened to click the document first. The launcher now moves focus into the page on window creation and every WM_SETFOCUS, matching macOS.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.js and the page agree. Old names are gone rather than deprecated.app.progress(0..1) — new. A determinate bar on the app icon; null clears. macOS draws it into the Dock tile, Windows uses ITaskbarList3, Linux emits the Unity LauncherEntry signal (KDE Plasma, Ubuntu Dock and Dash-to-Dock listen; vanilla GNOME Shell doesn't). On macOS it composes with app.icon() — a custom Dock tile keeps its icon while the bar is up.tiny.macos.* holds everything macOS-only. applescript and quickLook are joined by ocr, recorder, ai, selectedText, otherWindows and moveWindow — every tiny.app call that answered 'unsupported' on both other platforms. The namespace is now the signal it looked like: if it's on tiny.app, it does something somewhere other than macOS. Calling tiny.macos.* off macOS rejects with the reason, which is a behaviour change and not just a rename — otherWindows() used to answer null there and now throws, so an if (!wins) branch becomes a try. A null meaning "wrong OS" is indistinguishable from one meaning "nothing selected", which is the whole problem. If any of these grows a Windows or Linux implementation it moves back to tiny.app with the tiny.macos name kept alongside for a release or two. Three stayed put deliberately: win.share is window-scoped and belongs on tiny.win; authenticate also works on Windows; spotlight, pickColor and nowPlaying also work on Linux.haptic is removed (was app.haptic, briefly tiny.macos.haptic). NSHapticFeedbackManager only fires while a finger is resting on a Force Touch trackpad with Force Click and haptic feedback enabled — so 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 confusion this release spent its time removing elsewhere. Nothing replaces it; tiny.app.playSound() is the portable way to acknowledge an action.battery / wifi / idleTime move to tiny.system.*. Facts about the machine rather than things the app does — the same line that put the app surface on tiny.app. Mirrored on the backend as app.system.*. tiny.system also gained TypeScript definitions, which it never had at all.tiny.dialog.*. openFile, openFiles, pickFolder, saveFile, alert, confirm, prompt. Nothing about them was window-scoped — the launcher runs them application-modally, so a dialog opened from a satellite behaved exactly like one from main. All three platforms use their real native dialogs.chrome.trafficLights → chrome.windowControls, and cross-platform. The macOS nickname described a group every OS has. Takes true, false, a subset array like ['close','minimize'], or [] for none — macOS hides each button individually, Windows maps them onto WS_SYSMENU/WS_MINIMIZEBOX/WS_MAXIMIZEBOX, Linux uses gtk_window_set_deletable plus an _MOTIF_WM_HINTS request the WM may ignore. getState() reports what's actually shown (read off the buttons on macOS, the styles on Windows) and null on Linux, where the WM owns the decision. Before this it was macOS-only in effect, but Linux stored the bit and echoed it back — so setting it and reading it back said it had worked.win.open({ size }), win.setSize(), setMinSize() and getState().width/height used to be up to three different units depending on the platform and the window: creation took a content size on all three, but setSize and getState spoke frame units on macOS and Windows and content units on Linux. An app declaring 1100x720 got a 688-tall page on macOS and a 720-tall one on Windows and Linux; open({ size: '150x150', chrome: { frame: false } }) produced a 150x182 page, dropping the title bar having handed those points to the document; and on Linux the first menu bar to appear took its height out of the page 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 back to setSize is a no-op.getState().outer — new: { width, height }, the window's footprint on screen with decorations included. width/height are the page, outer is 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 can't compute it — window.outerWidth/outerHeight are 0 in a WKWebView. On Linux it comes from the WM's frame extents, so under Wayland expect it to equal the page box.minTinyjsVersion in tinyjs.json. Optional: the oldest tinyjs an app works with. Running it on an older one fails with myapp needs tinyjs 0.30.0 or newer — you have 0.28.3 rather than half-working, which is what calling an API your runtime predates actually looks like — an unexplained TypeError in the page naming nothing. tinyjs new stamps the version it scaffolded with; a source checkout reports dev and is never blocked.tinyjs.json apply everywhere; an optional macos / windows / linux block merges on top for that platform, so only the keys that genuinely differ get repeated (an .ico, a signing identity, a vibrancy that means nothing elsewhere). Plain objects merge, scalars and arrays replace; block names match tiny.system.os().tiny.win.print() and tiny.win.printToPDF() print the window that called them. Both went straight to the main window whichever page asked — 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 on all three platforms, and app.window(id).print() / .printToPDF(path) expose the same from the backend. Single-window apps see no change.fetch has two wire bugs: a root-path URL goes out as GET // (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. The bridge now wraps globalThis.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 hostile Location: file:///… throws instead of reaching curl. Both the backend's fetch() and every page's tiny.fetch inherit it; verified end-to-end on all three platforms. The shim dies the day a patched txiki ships (the plan is TODO-txiki.md).tiny.api.on(event, fn) was push-only — additive like addEventListener (N handlers per event, all fire), but nothing could ever unhook one. It now returns an unsubscribe function, and tiny.api.off(event, fn) removes by reference. Every tiny.*.on sugar wrapper (menu.on, tray.on, theme.on, …) passes the unsubscribe through — for those it's the only way to unhook, since the sugar wraps your callback and off can't match the wrapper by reference.TINYJS_SIGN_IDENTITY and TINYJS_NOTARY_PROFILE now take precedence over tinyjs.json instead of only filling a gap, and the build announces it when one displaces a value that was really there. A variable left exported in a shell quietly changing how a project signs is worth shouting about. Order: root → OS block → env.tiny.audio.filters works on macOS (14.2+), not just Linux, so an app can ask for an EQ once instead of branching. 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's bq_* 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 reports false.tinyjs dev has no bundle identity to hold the audio-capture permission — the app keeps playing unfiltered instead of going quiet.tinyjs dev shows your app's icon on macOS too. Windows and Linux already handed 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 uses icon from tinyjs.json (or icon.png), and app.icon('') resets to that rather than to nothing — the closest equivalent to a packaged app resetting to its bundle icon.app.playSound gained 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, the SystemAsterisk family on Windows, the freedesktop sound theme on Linux). Platform names and file paths still pass through untouched; a name from the wrong platform resolves false, as before.app.playSound crashed 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 and the next play messaged dead memory. Whether it actually aborted depended on the freed memory having been reused, which is why it looked like a bad sound name rather than a leak.readAccess could stop the app loading at all (macOS). WebKit refuses to load a page unless the page itself sits under the directory passed to allowingReadAccessToURL:, so a readAccess that didn't contain the page produced a blank window with nothing said. The read root is now the nearest common ancestor of the two.secrets.get leaked its result. The launcher builds without ARC, so the __bridge_transfer on SecItemCopyMatching's +1 return was a no-op and the NSData leaked on every read. Autoreleased explicitly now.capabilities() stopped over-claiming, on two platforms. Windows declared none of the app-surface keys while silently no-opping them — and under the table's "absent = true" rule that read as support. On Linux, app.icon, app.attention and app.presence emit 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 gated on X11 and report per session. Badge/progress detection is now a bus-name probe, not an XDG_CURRENT_DESKTOP guess that was wrong in both directions.otherWindows, moveOtherWindows, pickColor, spotlight and mediaKeys — the first falls through GET to null, the next three reach got_unsupported, and nothing in the Windows launcher has ever written a media-key line, so onMediaKey could not fire. Linux claimed otherWindows, selectedText and moveOtherWindows on X11, none of which are implemented. X11 could do all three — nobody has written it, which is a different statement from the one the table was making.tiny.system.locale() — { language, languages, system, region, timeZone } read from the OS, plus a locale event when the user changes their language or region. A page mostly doesn't need it (navigator.language, all of Intl and languagechange work in the webview); the BACKEND is the gap — txiki has no Intl at all, and reading LANG is wrong since Windows has no such variable and elsewhere it describes the parent process. Two lists because they answer different questions: languages is filtered to the localizations the app bundle declares (what to render), system is the raw user preference (what they actually read) — they differ for an English-only app on a French Mac. macOS only for now; capabilities().locale is false elsewhere.macos.ai.generate can call your functions. Pass tools: [{ name, description, parameters, run }] and the on-device model decides which to invoke; run(args) is ordinary JavaScript and its return value goes back to the model. Backend only — a tool's run is a real function and can't cross the bridge from a page. Declaring tools in JS meant building the schema at runtime (DynamicGenerationSchema + untyped GeneratedContent) rather than the compile-time @Generable struct the framework's examples use; the call round-trips Swift → a blocking C hop → the socket → your handler, with a 20s deadline so a handler that never answers can't wedge generation. With tools it resolves { text, calls }, and calls is the record — asked for three tool calls in one turn the model made all three in ONE run of four, and its prose claimed all three EVERY time, including the runs where it silently skipped one. Never read completion off the text."permissions": { "speechRecognition": "why" } — injects NSSpeechRecognitionUsageDescription. Speech-to-text isn't a tiny.* call: the page's own webkitSpeechRecognition already works (WebKit has it, so does WebView2), but a bundled app needs that usage string alongside the microphone one — without it the OS refuses the service rather than the mic, and the page sees service-not-allowed with no prompt to accept. Measured: adding the key alone turned that into start + audiostart on an otherwise identical build. Pairs with macos.ai and app.say() for a hands-free listen → generate → speak loop, which the deck's AI card now demonstrates.win.hide({ app: false }) — put one window away without hiding the app. A plain hide() on the main window is [NSApp hide:], right for a palette and wrong for an app whose main window is a launcher screen: putting it away deactivated the app around its document windows. Only changes what 'main' can do, and only when asked; Windows and Linux hides were already window-scoped.key was ⌘ plus the character with no way to say ⌥⌘P or ⌃⌘K. Now anything extra is a prefix — key: 'alt+p', 'alt+shift+f', ctrl+ — with the same grammar on all three platforms (⌘ reads as Ctrl off macOS). Windows also stopped firing an accelerator when Shift was held but not declared.onOpenFiles. Nothing read process argv before, so myapp notes.md was a launch with the document silently dropped. Argv is parsed on every platform — flags skipped, relative paths resolved, non-existent paths dropped — and on Windows and Linux a second launch forwards its documents over the instance pipe to the running copy. A fix rode along: AppKit hands the whole command line to application:openFiles: during launch, so every built macOS app with a handler was receiving its own index.html and app.sock as user documents; the launcher now drops its own argv."openFolders": true — a folder document type (public.folder in CFBundleDocumentTypes, inode/directory in the .desktop MimeType=). A folder has no extension to match, so it needed saying separately.tinyjs build --cli [name] — writes a terminal shim beside the app in dist/bin/. On macOS it targets the 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. It also stopped printing a Unix hint on Windows.app.setAsDefaultHandler(ext) — 'ok' | 'unsupported' | 'failed'. Linux runs xdg-mime default; macOS and Windows answer 'unsupported' rather than no-oping. Deliberately NOT automatic at registration the way urlScheme is: claiming .md competes with the user's editor, and an app that does that on first run is an app people uninstall.tiny.app.ai was only ever available to people who built tinyjs themselves with TINYJS_AI=1 — the release pipeline ran on a macos-14 runner with no macOS 26 SDK, so for anyone who installed a release availability() was permanently 'unsupported'. The release job now builds on macos-26 with the Swift shim linked in, and setup.sh detects the toolchain rather than asking — it links the shim when the SDK it compiles against carries FoundationModels, and builds without when it doesn't. Two traps on the way: the workflow never passed -mmacosx-version-min, so the deployment floor came from the host SDK (measured: minos 26.0 without it, 14.0 with — a binary that refuses to launch below macOS 26); and swiftc takes a single -target where clang takes -arch a -arch b, and passing two doesn't error — the last silently wins — so the universal build is per-arch plus lipo. The job now asserts the floor, the weak FoundationModels link and the shim's presence per slice, because each of those failures is silent. ~62 KB.capabilities().ai reflects the build, not the OS. tiny.app.ai needs the macOS 26 SDK and swiftc to compile at all, so whether it's present is a property of the build; a launcher built without it has a generate() rejects 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.app.thumbnail works for any path — folders, .app bundles, .css, .wasm, executables. It asked Quick Look for a content preview only, which exists just for types Quick Look has a generator for, so those failed with "QLThumbnailErrorDomain error 0" while .js, .md, .html and images worked — indistinguishable from random. It now takes the best available representation and falls back to the document icon. A path that does not exist still rejects, explicitly: the icon fallback would otherwise have made a typo look like a success.getUserMedia works, gated on the app's declared permissions. WebKitGTK's default for an unhandled permission-request signal is deny, so every camera/mic app failed with NotAllowedError while permissions.check said granted. 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. enumerateDevices labels ride the same gate; getDisplayMedia stays denied.mousePosition().inside is 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 left inside stuck true; it goes honest-false now. New tiny.app.mouseTracking.start()/stop() for apps that need the pointer outside their windows: a no-op ok where tracking is already global (macOS, Windows, real X11); on Wayland 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. The desktop's sharing indicator shows while armed, as it should.windowControls: false replaced _MOTIF_WM_HINTS with a property declaring only its functions section — and one that doesn't declare decorations means "WM default", decorated — so it erased the frame: false GDK had just written and Mutter framed the window. Frameless apps always declare both, so the second request undid the first, every time. The property now carries both sections, and startup chrome rides the spawn env so it applies before first show instead of flashing a decorated frame at launch.app.icon had 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×1024 icon.png, so this silently swallowed both app.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).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 — unlike the transient macOS bounce and Windows flash. A focus handler drops it now.win.setSize → getState round-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 so long. Window creation still takes a content size.app.icon accepts png — the one format every tinyjs app actually ships, so it used to silently no-op on its own icon.png — and icon('') restores the startup icon rather than falling through to the window class. setZoom, setMinSize and startResize are implemented; startDrag honours the per-window form so a satellite's drag handle works; frameless windows get the size they asked for; and nowPlaying/haptic stopped claiming support they never had.setZoom, setMinSize and startResize implemented — the same three, missing on the other side. All three reached the launcher's op chain, matched nothing and were dropped, so each resolved true and did nothing: a fire-and-forget window op looks identical whether it's unimplemented or merely ineffective, which is how they stayed hidden while shipping as cross-platform. setMinSize also pulls a window already under the new floor back up to it, since AppKit otherwise applies contentMinSize only to the next resize. startResize has no AppKit equivalent to hand off to, so the launcher runs the drag itself — and refuses to start unless a button is really held, because the loop waits on mouse events and one begun without a live gesture would hang the app. startDrag honours DRAGWIN@<id> now too, and checks for a live mouse-down first: performWindowDragWithEvent: raises on anything else rather than no-oping.tinyjs build refreshes a stale launcher the way dev already did, so a dev checkout can't ship a launcher predating its own sources — or silently skip the app icon, since --embed-icon is run by that binary.tiny.audio.pageChain(ctx) — the filter chain as Web Audio nodes in the page. Same band vocabulary, same RBJ curves (measured: ask +12 dB at the centre, get 12.00), and the same four verbs as tiny.audio, so an app picks its backend once and the code after is identical. It exists because Windows measured out: the only way to take the direct signal off the speakers there is session volume, which Windows persists on a key every WebView2 app shares — a crash while filtering would have near-silenced Teams and the rest until something rewrote it. So capabilities().audioFilters stays honestly false on Windows and this is the sanctioned fallback: page-scoped (it filters what you route through it, not native HLS the page never gets samples for), shelf q and per-filter gainR ignored, and not for Linux, where Web Audio crackles and the native chain exists precisely because of that.app.badge works on Windows, and capabilities().badge now says so. It was written but never run there, so the capability deliberately reported false rather than guess; it has since been seen drawing a count on the taskbar button, composing with app.progress, and clearing back to an untouched button. Non-numeric text collapses to a dot — 16px fits one or two glyphs.app.spotlight() answered [] on Windows instead of rejecting. The launcher correctly reports the op as unsupported; the bridge threw that away with a ?? [] and handed back an empty array, so a caller was told "no files matched" where the truth was "there is no search backend on this platform". Those are opposite answers and only one was true. It now rejects, the way the neighbouring pickColor already did.CredWriteW fails with Win32 1783 (RPC_X_BAD_STUB_DATA), which names nothing; the app saw a bare "credential write failed". secrets.set now rejects oversized values up front, naming the byte count and the limit. The limit counts bytes of UTF-8, not characters, so 4-byte emoji reach it four times faster than ASCII.app.thumbnail on Windows is broader than the docs said, and sized differently. Folders, executables and plain text all come back as their shell icon — the same breadth macOS has, so it's Linux that's the images-only one, not "Windows and Linux". And where macOS and Linux treat size as points and render @2x, Windows treats it as pixels and returns exactly what you asked for. Read width/height off the result rather than assuming either.curl --version probe now go through launcher --run (CREATE_NO_WINDOW), the same route reg add and the updater's tar already took; it was missed here only because the fetch shim lives outside createApp, where the launcher path was resolved. --run also puts the tool in a job object with KILL_ON_JOB_CLOSE, so killing the wrapper — what aborting a curl-backed stream does — takes curl with it rather than 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.update.js probed for its bundle with a top-level await, which makes it — and bridge.js, which imports it — an async module, and ES module evaluation lets a synchronous sibling run while an async one is still pending. A backend whose module body fetched before its own first await therefore got txiki's unrepaired fetch: measured, a top-level fetch('https://rss.art19.com/') failed with mbedtls connect -1 5 0 while the identical call one await later returned 200. The probe is lazy now (bundlePath() is async) and nothing in bridge.js's import graph awaits at the top level, so the first line of a backend gets the same fetch as the hundredth.test/appsurface.html, a self-driving page for the whole app surface, and TODO-verify.md for what's been built on one OS but not yet watched run on another.dev/build/publish and auto-update all work cross-platform — X11 and Wayland, Ubuntu 24.04+ and current distros with webkit2gtk-4.1. Built apps register their own .desktop entry on first run (app menu, icon, deep links, file associations, single instance); publish emits <name>-<version>-linux-<arch>.tar.gz with a per-arch linux manifest block beside the mac and win ones. Nearly the whole API surface is live — windows and frameless chrome, menus, tray, dialogs, clipboard, notifications with actions, secrets, global hotkeys, MPRIS nowPlaying + media keys, speech, spotlight, printToPDF, and more; the gaps fail cleanly so cross-platform code can feature-detect. Burn-down: TODO-linux.md.audioTap on Linux. scope:'app' is real per-app capture — a private PipeWire null sink fed by the app's own output ports, so it hears itself and nothing else, playback untouched.tiny.audio — native DSP on your own output (Linux). Web Audio's graph crackles on WebKitGTK (rendered on a normal-priority thread), so audio.filters() runs the EQ/crossover/correction chain in PipeWire instead: up to 28 filters, live gapless retunes, per-channel gains, and it covers audio the page never sees — raw radio, native HLS. audio.balance() pans on the chain's output. Gated by capabilities().audioFilters; on macOS/Windows keep using Web Audio, which works there.tiny.system — know the machine, name what's missing. Sync os()/isLinux() & friends, info(), capabilities(). On Linux, requirements() maps missing features to per-distro packages, and promptMissing() shows a native dialog with a Copy install command button — and shows nothing when all is well, so call it straight from a failure path.data-tiny-noresize), win.startResize(edge) for custom handles, and setResizable(false) no longer blocks the app's own setSize. Plus, cross-cutting: win.setMinSize() / minSize in win.open (a WM-enforced floor), and win.setZoom() — native page zoom for crisp double-size modes on hi-dpi screens.windowPlacement manifest key. Apps whose design is windows placing each other opt into X11/XWayland, where self-placement works; everything else stays native Wayland.curl -fsSL https://tinyjs.app/install | sh detects Linux; releases ship tinyjs-linux-x86_64 and -arm64 tarballs plus standalone tjs builds, checksummed beside the macOS and Windows assets.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 carries System.AppUserModel relaunch 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.tjs.spawn can't pass CREATE_NO_WINDOW, so every console child (tasklist, tar, reg, …) spawned from a GUI-subsystem app popped a visible console. New launcher-win.exe --run mode runs any console tool hidden with stdio and exit code passed through; the bridge and updater route their own spawns through it, and apps get app.spawnHidden(args, opts) — plain tjs.spawn on macOS, hidden on Windows.tinyjs build stamps the icon into a clean copy of the runtime before app compile appends the TPK bundle, so dist/<name>.exe shows the real icon in Explorer, the taskbar, and registry DefaultIcon entries.dist/frontend/ and dist/icon.png — a built app is <name>.exe + launcher.exe, nothing else. Older dists keep working.win.notes field in the update manifest overrides the top-level notes on Windows.url/sha256 stay the macOS zip; a win: { url, sha256 } block carries the Windows build. Windows apps overlay it on check and report "no update" when a release ships no Windows zip — never a mac download. tinyjs publish on Windows names the zip <name>-<version>-win.zip and emits the win block; publishing both platforms merges the two manifests (mac owns the top-level fields).tiny.fileURL(path) — one correct file:// builder for both OSes (drive letters, backslashes, UNC hosts, percent-encoding). A hand-rolled 'file://' + path was the single most common porting bug; stop writing it.file://server/share URLs (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.win.open webview 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 (a visualizer analysing a silent twin stream) start without ever being clicked.WS_EX_NOREDIRECTIONBITMAP at creation), setChrome transparency 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.emoji: tray icons — the asset-free twin of macOS sf: 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.audioTap scope 'app' rides the system loopback rather than rejecting — honest degradation until true per-process capture lands.store.set bursts are serialized and renames retry — Defender/indexers transiently lock fresh files on Windows, which surfaced as EPERM under load.window.chrome shadowing. A page declaring its own chrome no longer severs the RPC channel (the post hook is stashed at document-start). Related rule now in the skill: never declare a top-level chrome identifier — on WebView2 a const chrome is a parse-time SyntaxError that kills the entire script.SWP_NOACTIVATE), new windows clamp onto the virtual screen (stale saved positions can't open apps in the void), and win.open position/size honor DPI scaling everywhere.setPosition, win.open size/position, SIZE, getState, mousePosition, screens, tray.position. Also: WebView2 now gets --ignore-gpu-blocklist --enable-unsafe-webgpu, so navigator.gpu yields a real adapter even on virtualized GPUs — WebGPU parity with the macOS launcher.file:// URL an opaque origin, so createMediaElementSource on 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-files to WebView2 (via a small patch to the vendored webview loader, which ignored WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS), making file:// same-origin like WKWebView treats page-dir files on macOS. Verified end-to-end with platter: art + audio both work.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 (IDropTarget in, DoDragDrop/CF_HDROP out), printToPDF, captureScreen (no permission dance), thumbnail (shell image factory), say/voices (SAPI), clipboard image write (CF_DIB), menu key: accelerators (Ctrl+<key> via WebView2 AcceleratorKeyPressed), launchAtLogin for built apps (HKCU Run), chrome transparent + vibrancy→mica/acrylic backdrops (Win11 22H2+), accessory apps drop the taskbar button, and tinyjs 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.irm https://tinyjs.app/install.ps1 | iex installs a prebuilt tinyjs-windows-x86_64.zip (built by the release workflow's new windows-latest job, checksummed alongside the macOS tarballs) into %LOCALAPPDATA%\tinyjs and adds it to the user PATH — no git, no compiler. tinyjs update re-runs the installer on Windows too. Dev nicety: tinyjs dev from a source checkout rebuilds launcher-win.exe automatically when native/launcher-win.cc or runtime/tiny.js changed.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. Working: the full page↔backend bridge (calls, events, tiny.fetch), dev mode with hot reload, Vite devUrl frontends, tinyjs build (portable dist/ 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 (cmd maps to Ctrl), shell open/reveal/trash, secrets (Credential Manager), power.preventSleep, theme + sleep/wake events, window ops (chrome/fullscreen/ontop/click-through/…). The macOS-specific APIs (Quick Look, OCR, AppleScript, Touch ID, vibrancy, Dock, Spaces…) 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 with TINYJS_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. Proven working end-to-end locally (real generation, ~250ms).createApp({ userAgent }) or "userAgent" in tinyjs.json overrides the webview's User-Agent string (packaged apps read the TinyjsUserAgent Info.plist key the build writes; dev passes it via TINYJS_UA). WKWebView's default UA omits the trailing Version/x Safari/x, so UA-sniffing sites reject it as an unknown browser — mainly useful when pointing a devUrl at 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 via devUrl. 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.tiny.proxyURL now plays live internet radio through the Web Audio / EQ graph. A live (icecast/shoutcast) stream answers 200 with no Content-Length and 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-less audio/*/video/* response and serves it with a synthetic large Content-Length (dropping the contradictory range/chunked headers), so the element plays it progressively as one long non-seekable resource and MediaElementSource taps 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. Tradeoff: for a live stream audio.duration/currentTime are meaningless (the synthetic length maps to a huge timeline) — don't wire a seekbar to one. The fake length is gated to media MIME types, so ordinary fetch() through proxyURL is unaffected.tjs runtime 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_spawn returns EBADARCH) and shows a plain "This app needs an Apple Silicon Mac" apology, then exits cleanly. The tinyjs CLI also prints a non-fatal heads-up when run on an Intel Mac. tinyjs remains Apple-Silicon-first."contextMenu": false in 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 custom tiny.menu.setContext(...) still overrides, and clearing it falls back to suppressed (not the WebKit default) while the option is on.tiny.audioTap TCC behavior. The first start() does prompt for "System Audio Recording" — even scope:'app', because WKWebView renders audio in a separate com.apple.WebKit.GPU helper, 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 first start() (declaring the manifest key does nothing at runtime), so the tap can be lazy-armed. Under tinyjs dev the 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 to start().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's proxyURL; the two compose). await tiny.audioTap.start({ scope: 'app' | 'system', interval }) then tiny.audioTap.on(({ pcm, sampleRate, channels, frames, t }) => …) where pcm is 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 bakes NSAudioCaptureUsageDescription). Implemented with Core Audio process taps (macOS 14.2+): a CATapDescription → AudioHardwareCreateProcessTap → an aggregate device → an IOProc that downmixes to stereo Int16. scope:'app' selects exactly this app's audio by matching the responsible pid (WKWebView renders audio in a com.apple.WebKit.GPU XPC helper that isn't a child process). Authorization is deferred to the first start() (declaring the manifest does nothing until then), so the tap can be lazy-armed; that first start() prompts for "System Audio Recording" — even scope:'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 by re-arming. macOS-only for now; cross-platform by design. Note: under tinyjs dev the 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.tiny.proxyURL — driving <audio> through the proxy needs a byte-range-capable (HTTP 206) stream; a non-seekable live stream that answers 200 can't play through the proxy element (fall back to the raw URL). Redirects were never the issue.tiny.proxyURL(url) — a cross-origin stream (internet radio) into Web Audio. A MediaElementSource on a cross-origin <audio> outputs silence by spec, so radio can't drive an EQ or analyser. proxyURL returns a same-app tiny-media://… URL that streams the remote http(s) resource through the native layer (NSURLSession — redirects, byte-range/seek, native buffering) and injects Access-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 a WKURLSchemeHandler registered on every webview via a swizzle of -[WKWebView initWithFrame:configuration:] — no backend hop, no base64, no TCP port. Verified end-to-end: a MediaElementSource on a proxied SomaFM stream drove a live AnalyserNode with real (non-silent) waveform data. macOS only for now; the design maps to WebView2 and WebKitGTK when those backends land.tiny.fetch(url, init) — a fetch that runs in the backend, so no CORS. The request runs in the native process, so it has no CORS, CSP, or mixed-content limits: the page can reach any origin. Same shape as window.fetch and resolves to a real Response (res.json() / res.text() / res.headers / res.ok all work); request bodies can be strings, ArrayBuffer/typed arrays, Blob, or URLSearchParams. Pass { stream: true } for a live streaming body — the backend holds the connection open and the page pulls chunks on demand (res.body.getReader()) with natural backpressure, which is what an endless source like internet radio needs (a buffered fetch would never resolve); reader.cancel() or closing the window tears the upstream connection down. Reuses the existing request/response bridge (base64 chunks over CALL→RET), so no new wire frame and no launcher change. A page with CORS-free network reach has the app's full network reach — not a new trust boundary (every page already holds an RPC channel to a backend with full system access), but worth knowing.tinyjs notarize --dmg rebuilds the installer from the stapled .app. stapler mutates the bundle in place after Apple's verdict, so a dmg made at build --dmg time 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. notarize now rebuilds the dmg from the stapled bundle when --dmg is passed, and refreshes an existing dmg on disk automatically (that copy is guaranteed stale). Ship a dmg with tinyjs build --dmg && tinyjs notarize --dmg.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), via tiny.win.setChrome, app.openWindow's chrome, or tinyjs.json "chrome" (applied before first paint); reported back in getState().chrome. Off by default — matches the platform, no change for existing apps.app.eval, a window handle's eval, and the event pushes behind app.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.tinyjs notarize fails fast on the wrong signature. It now inspects what the built .app is actually signed with (via codesign) 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.TINYJS_SIGN_IDENTITY env var, not just signIdentity in tinyjs.json — both have always worked (as does TINYJS_NOTARY_PROFILE for the notary profile); the message just never said so.tinyjs uninstall. Cleanly removes the install (~/.tinyjs, or $TINYJS_HOME) and the tinyjs PATH symlink — the exact inverse of the installer. It removes only a symlink that points back into the install it's running from (an unrelated tinyjs on your PATH is left alone), prints what it'll delete, and prompts first (--yes skips the prompt; a non-interactive run without --yes refuses rather than deleting silently). It won't touch a source checkout, /, or a directory containing .git. The shell-profile PATH line is left in place with a note.tinyjs: command not found, run hash -r or open a new one.sf: SF Symbol icons were never affected.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. Deliberate/un-native — you lose the native titlebar drag (use data-tiny-drag) but keep resize edges, shadow, and keyboard focus (a canBecomeKeyWindow override restores focus). Works on main + secondary windows; survives setSize; reported in getState().chrome.store.set()/delete() can no longer crash the backend — a write failure used to surface as an unhandled rejection and take the process down. The store now swallows its own write errors (resolves false, keeps the in-memory value) and uses a unique temp file per write so un-awaited bursts don't race.openWindow gains chrome + { x, y }, applied before first paint — a frameless/vibrancy panel never flashes its titlebar and a positioned window opens where you asked. getState().chrome is now per-window.readAccess option — widen the file:// read root (createApp({ readAccess: true }) = home, or a path; also "readAccess" in tinyjs.json) so <audio>/<img> can load media outside the frontend dir without a base64 round-trip.win.printToPDF(path) — render the page to a vector PDF (WKWebView) → { path }; invoices/reports without a dialog.app.haptic(pattern) — trackpad haptics ('generic'|'alignment'|'level').app.dockIcon(pngPath) — replace the Dock icon (render a canvas for progress rings/badges); '' resets.app.battery() — { percent, charging, plugged, minutesRemaining } | null (IOPowerSources).app.wifi() — { ssid, bssid, rssi, noise, txRate } | null (CoreWLAN; ssid needs Location).app.spotlight(query) — find files by name/content (NSMetadataQuery, up to 100 paths) — no mdfind spawn.win.setClickThrough(bool) (mouse events pass through — overlays), win.setLevel('normal'|'floating'|'overlay'|'desktop') (overlay floats above fullscreen apps, desktop pins behind windows for pets/wallpaper), win.setAllSpaces(bool) (follow onto every Space). All round-trip through getState(); backend twins on app and app.window(id).app.selectedText() — the text selected in the frontmost app (PopClip-style popovers; Accessibility).app.otherWindows() / app.moveWindow(pid, rect) — enumerate and arrange other apps' windows (Rectangle/Magnet; Accessibility).tray.position() — the tray icon's on-screen rect, to anchor a dropdown window under it.app.recorder — record a display to an .mp4: start({ path, screenId? }) resolves once capturing, stop() resolves { path, duration } once finalized. SCStream → H.264 → AVAssetWriter, video only for now, one at a time. Needs the 'screen' permission + macOS 14 (preflighted, so an ungranted start rejects immediately instead of hanging).app.nowPlaying — set({ title, artist, album, duration, elapsed, playing }) shows your app in Control Center / the lock screen and arms the hardware media keys; presses (F7/F8/F9, AirPods, Control Center) arrive as the media-key event / onMediaKey export with { command, time? }.app.say(text, { voice, rate }) — text-to-speech (AVSpeechSynthesizer), resolves when playback finishes; app.voices() lists installed voices, app.stopSpeaking() interrupts.tiny.notify(title, body, { actions: [{ id, title, reply?, destructive? }] }) adds buttons / a reply field (packaged apps); taps arrive as notification-action / onNotificationAction with { id, action, reply }.app.pickColor() — the system eyedropper: any pixel, any app, no screen-recording permission. '#rrggbb' or null on cancel.app.ocr(path) — on-device OCR (Vision) → { text, blocks } with normalized 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…), rendered @2x.app.secrets — Keychain get/set/delete under the app id (the keytar/safeStorage role) — tokens go here, never in tiny.store.app.authenticate(reason) — Touch ID (or the account-password sheet) → true | false.app.applescript(source) — AppleScript in-process, no osascript spawn, under the existing automation permission."update": { "auto": "launch" | "daily" } background checks fire an update-available event / onUpdateAvailable export with { current, latest, notes }; tinyjs publish --notes "…" ships release notes in the manifest.app.quickLook(paths) — the real Finder-spacebar preview panel (QLPreviewPanel, no qlmanage spawn); an array pages with the arrow keys, quickLook() closes.app.captureScreen(screenId?) — screenshot a display via ScreenCaptureKit → { path, width, height } (png in the temp dir, yours to keep). Display id from screens(), default primary. Needs the 'screen' permission + macOS 14; rejects with the reason otherwise.app.idleTime() — seconds since the user's last input, session-wide (pause pollers when they walk away).app.power — preventSleep(reason, { display? }) / allowSleep(): one IOPMAssertion instead of spawning caffeinate — it dies with the process, so a crashed app never wedges the Mac's sleep. The reason shows in pmset -g assertions.app.frontmostApp() — { name, bundleId, pid } of the active app (who focus returns to after hide() — pairs with paste()).app.beep() / app.playSound(target) — system beep, a system sound by name ('Ping'), or an audio file path; false if it didn't load. No more afplay spawns.win.share({ text?, url?, paths?, x?, y? }) — the native share sheet, anchored at page coordinates (pass the click's clientX/clientY). Backend twin: app.window(id).share(opts).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 resolves true or rejects with the reason. No more open spawns.app.launchAtLogin — get()/set(v) → enabled | disabled | requires-approval | unsupported via SMAppService (packaged .apps, macOS 13+). requires-approval: the user must allow it in System Settings → Login Items.app.screens() — every display in the same top-left coordinates as setPosition: { id, name, x, y, width, height, visible, scale, primary }; visible excludes 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 over hardcoding ~/Library paths.app.dock — setBadge('3') / setBadge(''), bounce() / bounce({ critical: true }).'screen' permission never reads undetermined (macOS only exposes a yes/no preflight); dev-mode TCC grants attach to the shared launcher binary, not your app.clipboard.read() gains concealed (org.nspasteboard Concealed/Transient markers — clipboard-history apps skip password-manager copies by default), sourceApp ({ name, bundleId } the content came from; exact while watch() runs), sourceURL (the page a Chromium-browser copy came from), and imageSize (px — no more sips for dimensions).export function onClipboardChange(e) was silently dead in tinyjs dev/build apps — the generated entry didn't forward it to createApp.getUserMedia() now just works in tinyjs pages: the launcher answers WebKit's per-origin media prompt itself, so users see a single system dialog naming your app instead of a double prompt naming file://. tiny.app.permissions.check/request() gain microphone and camera for onboarding flows."permissions" in tinyjs.json — packaged apps declare { "microphone": "why", "camera": "why" }: injected as the required Info.plist usage strings, and builds signed with signIdentity get the matching hardened-runtime device entitlements — without which the hardened runtime denies capture even after the TCC grant.tiny.clipboard read/writes text, html, multiple files, images (png in/out), and colors via NSPasteboard in the launcher process; changeCount() plus watch()/onChange change events with a self flag for your own writes. No more per-second osascript/pbpaste spawns, scratch files, or lost multi-file writes.tiny.win.startDrag({ files, image? }) from a page mousedown starts a real native drag session: drop into Finder, Slack, anywhere.tiny.app.keystroke('cmd+v') / tiny.app.paste() post a CGEvent from the launcher: one permission (Accessibility) whose prompt names your app, not osascript or the terminal.tiny.app.permissions.check/request() for accessibility, screen, notifications, automation[:<bundle-id>] → granted / denied / undetermined, so apps can onboard instead of failing at first use.hide() now hides the app — macOS returns focus to the previously active app on its own, so palettes can hide-then-paste with no frontmost bookkeeping. show({ activate: false }) surfaces overlay/HUD windows without stealing focus.tiny.app.mousePosition() — the cursor in global, window-relative, and screen coordinates (open a palette at the mouse).signIdentity sign with the hardened runtime + secure timestamp, as notarization requires; codesign errors are no longer swallowed.app.notify() could crash the backend in dev builds (unhandled rejection from the osascript fallback). notify never rejects now — it resolves false if delivery failed.backend/tsconfig.json so editors resolve the tinyjs types in the backend folder.tinyjs new myapp --template react-ts; HMR dev server inside the native window, built assets ship as usual.tiny injected everywhere — no script tag; full TypeScript definitions ship in scaffolds."activation": "accessory" in tinyjs.json launches with no Dock icon and the window hidden, with no flash of either; tiny.win.show() when wanted, setDockVisible(true) to become a regular app.tiny.tray.set({ icon: 'sf:cup.and.saucer.fill' }): crisp, auto-templating menu-bar icons with zero shipped assets (png paths still work).primaryAction: true splits clicks: left click fires onClick (the Caffeine-style toggle), right/ctrl-click opens the menu.tiny.win.open('settings', { page: 'settings.html' }): any frontend html file becomes a window with the full tiny.* bridge; win.* targets the caller's window; backend app.window(id) handles, broadcasts, close events, and meta.window caller identity.tiny.win.setChrome({ frame, trafficLights, transparent, vibrancy }) (or "chrome" in tinyjs.json, applied before first paint in packaged apps); native resize/focus/fullscreen kept.data-tiny-drag elements move the window natively; double-click zooms.tiny.win.zoom(), startDrag(); getState() reports chrome.setResizable(false) survive later resizes.tiny.app.onNotificationClick; notify gains { id, subtitle, sound }. Ad-hoc/dev builds fall back to osascript automatically.checked, enabled: false, and nested submenu items everywhere; tiny.menu.update(id, patch) patches live items, tiny.menu.get(id) reads them back.tiny.win.getState(): position, size, fullscreen/minimized/visible/focused/on-top/resizable + screen info; plus restore() and absolute setFullscreen(bool).tiny.app.info(): app version, the tinyjs it was built with, and the runtime."urlScheme": "myapp" in tinyjs.json; myapp://… opens deliver tiny.app.onOpenUrl, cold starts included."fileExtensions": ["md"]; "Open With" and Dock drops arrive as real paths via tiny.app.onOpenFiles.open activates the running app instead of launching a copy.tiny.hotkey.register('boss', 'cmd+shift+k'); fires system-wide, even while other apps are focused.tiny.theme.get() / tiny.theme.on() for dark/light, plus sleep / wake events.get / set / delete / all, atomic writes.tiny.menu.setContext(items) replaces the right-click menu with native items.tiny.win.print() opens the native print panel.tinyjs build --dmg emits a classic installer image.tinyjs notarize wraps notarytool + stapler for Developer ID builds.tjs:sqlite, built into the runtime) documented with examples.tiny.tray.set(…), hide-on-close, Dock-less accessory mode.src/frontend/ ships as real files and relative assets just work. Hot reload bypasses caches.tinyjs publish + update.check() / update.install(): sha256 + code-signature verified, Team-ID pinned, atomic swap with rollback, relaunch.tiny.notify(title, body).file:// documents; navigator.gpu works out of the box.tinyjs update (and a once-a-day notice in tinyjs dev).tiny.api.call/on, tiny.win.*, tiny.menu.*.new / dev / build, hot reload, native file dialogs, codesigned .app bundles, ~6 MB shipped.