tarwin/tinyjsapp

▲ 134 stars today★ 895⑂ 28

Build native apps in JS. Backend, frontend. ~5Mb.

About tarwin/tinyjsapp

tarwin/tinyjsapp is an open-source project on GitHub, mainly written in C. Build native apps in JS. Backend, frontend. ~5Mb. It currently holds 895 stars and 28 forks with 10 open issues, and was last pushed on 2026-10-08 (repository created 2026-07-12).

Project Overview

Git Homed tracks it on the Today's Trending board, currently at rank #36 with 134 new stars today.

GitHub Repository Details

Repository tarwin/tinyjsapp · default branch main · size 5142 KB · watchers 5 · source: GitHub REST API and repository README

README

tinyjs

tinyjs — desktop apps ~6mb

Tiny desktop apps for macOS — and, in beta, Windows and Linux: a txiki.js backend + a native webview window.

domain socket in a private temp directory processes, FFI) via txiki.js the system WebKit with real file paths, window control out of publish

Install

curl -fsSL https://tinyjs.app/install | sh

The same script now handles Linux too (it detects the OS). Installs to ~/.tinyjs and symlinks tinyjs onto your PATH. Pin a version with TINYJS_VERSION=vX.Y.Z. Later, tinyjs update runs the copy of the installer that came with your install if a newer release exists (--check only reports, --dry-run shows what it would run); tinyjs dev also mentions new releases, checking at most once a day. Linux needs the system WebKitGTK runtime: sudo apt install libwebkit2gtk-4.1-0 on Debian/Ubuntu, sudo zypper install libwebkit2gtk-4_1-0 on openSUSE. Prebuilt binaries ship for Linux x86_64 and arm64 with the first tagged release after Linux support merged — the installer says so plainly against older releases. To install from source instead:

git clone https://github.com/tarwin/tinyjsapp && cd tinyjsapp
./setup.sh    # downloads the txiki.js runtime, compiles the launcher
ln -s "$(pwd)/tinyjs" /usr/local/bin/tinyjs

On Linux, setup.sh needs the system dev packages first. Debian/Ubuntu: `sudo apt install build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev`. openSUSE: `sudo zypper install gcc-c++ make pkgconf-pkg-config gtk3-devel webkitgtk3-devel libayatana-appindicator3-devel` (WebKitGTK 4.1's dev package is webkitgtk3-devel there — the "3" says GTK3, not an older WebKit). It downloads a prebuilt tjs from the tinyjsapp releases, or builds txiki.js from source (TJS_BUILD=1 ./setup.sh, needs cmake + ninja).

Windows (beta):

irm https://tinyjs.app/install.ps1 | iex

Installs prebuilt binaries to %LOCALAPPDATA%\tinyjs (override with TINYJS_HOME; pin with TINYJS_VERSION) and adds it to your user PATH — open a new terminal afterwards. Needs only the WebView2 runtime (preinstalled on Windows 11). tinyjs update runs the installer's bundled copy. (Requires a release that ships Windows assets — the first one after Windows support merged; the installer says so plainly against older releases.)

To develop tinyjs itself from source (needs MinGW-w64: winget install BrechtSanders.WinLibs.POSIX.UCRT):

git clone https://github.com/tarwin/tinyjsapp; cd tinyjsapp
powershell -ExecutionPolicy Bypass -File setup.ps1

adds the checkout to your user PATH (-SkipPath to opt out); after that,

tinyjs dev auto-rebuilds the launcher whenever the native sources change

Linux (beta):

Runs on X11 and Wayland sessions — Ubuntu 24.04+ and current distros with webkit2gtk-4.1. Use the same install command above (it detects Linux) or build from source with ./setup.sh (see the distro deps above). Needs the WebKitGTK 4.1 runtime — libwebkit2gtk-4.1-0 on Debian/Ubuntu, libwebkit2gtk-4_1-0 on openSUSE.

Playing media? WebKitGTK decodes through GStreamer, and a stock desktop install carries only some of it. Without the rest, `/

# Debian/Ubuntu
sudo apt install gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-libav

openSUSE

sudo zypper install gstreamer-plugins-bad gstreamer-plugins-ugly gstreamer-plugins-libav

WebKit hints at this itself, logging "WebKit wasn't able to find a WebVTT encoder … unless gst-plugins-bad is installed" on startup.

Don't play audio through Web Audio on Linux. WebKitGTK renders the Web Audio graph on a normal-priority (SCHED_OTHER) thread while its media threads get real-time priority, so anything reaching ctx.destination misses its deadline and crunches — on an idle machine, at any latencyHint, whether the source is an element or a decoded buffer. A plain `` element goes through GStreamer instead and is flawless. There is no graph-side fix; buffering only softens it, and rtkit won't promote the thread.

So on Linux, play the element directly and get analysis from tiny.audioTap:

if (tiny.system.isLinux()) {
  audio.volume = volume;          // straight to the speakers, no graph
  await tiny.audioTap.start({ scope: 'app' });   // PCM for your visualiser
} else {
  const src = ctx.createMediaElementSource(audio);
  src.connect(gain); gain.connect(ctx.destination);
}

scope: 'app' captures only your own output (a private PipeWire null sink fed by your app's ports), so playback is unaffected and you don't hear the rest of the desktop. What you lose is anything that was a graph node — an equalizer, a StereoPanner — so tell the user rather than leaving dead controls on screen.

For sound effects none of this applies: tiny.audio.sampler mixes decoded samples with per-voice volume/pan/pitch natively in the launcher on Linux (PipeWire's real-time data path — clean under any page load) and via Web Audio on macOS/Windows, same API, same equal-power pan numbers. Use it instead of createBufferSource graphs. See examples/amp for the whole pattern, visualisers included.

Analysis-only graphs are fine: a MediaElementSource feeding nothing but an AnalyserNode never reaches destination, so a missed deadline costs a dropped frame rather than audible crackle. Keep such elements at volume = 0, since WebKitGTK also plays a graph-routed element's own output straight to the speakers (macOS/Windows mute it).

See Portability below for what's supported on Windows and Linux.

Full docs: tinyjs.app/docs · release history: tinyjs.app/changelog.

Create and run an app

tinyjs new myapp
cd myapp
tinyjs dev

A window opens. dev hot-reloads: edit anything in src/frontend/ and the page re-renders in place, caches bypassed (no restart, backend state survives); edit backend sources and the process restarts automatically. TINYJS_DEBUG=1 tinyjs dev traces every message crossing the bridge.

Frameworks welcome: tinyjs new myapp --template react-ts (or vue-ts, svelte-ts, solid-ts, preact-ts, lit-ts, alpine-ts, vanilla-ts, …) scaffolds a Vite app wired to tinyjs — tinyjs dev runs Vite's dev server with HMR inside the native window, and tinyjs build ships the built assets as usual. Plain --template asks which framework and language; --pm npm|pnpm|yarn|bun|vp picks the package manager (otherwise you choose from the ones that work on your machine, or get npm when there's no terminal to ask on), and dependencies install straight away unless --no-install. The chosen manager runs the project's dev and build scripts ("dev": "pnpm run dev" in tinyjs.json). --pm vp uses Vite+ 1.0 or newer over another manager (vp:pnpm, vp:bun, …; asked if left out); an older vp is refused, since its projects can't build against current Vite+ packages (vp upgrade). alpine is Vite's vanilla template with Alpine.js and a small component that calls the backend. TypeScript backends are bundled with esbuild automatically (which also makes npm packages usable in the backend). The zero-dependency default scaffold is unchanged.

The tiny global is injected into every page by the launcher — no script tag needed — and ships with full TypeScript definitions (types/tiny.d.ts).

A project is just:

myapp/
  tinyjs.json            # { name, title, size, id, version, icon?, signIdentity?,
                         #   urlScheme?, fileExtensions?, chrome?, update?, notarize?,
                         #   permissions? ({ microphone: "why", camera: "why" } for getUserMedia),
                         #   contextMenu? (false suppresses WebKit's default right-click menu),
                         #   debug? (true = F12 opens devtools, "open" = every window auto-opens
                         #   them; default off — tinyjs dev always has F12),
                         #   browserAccelerators? (true re-enables the engine's own keys —
                         #   Ctrl+F find, Ctrl+R reload…; default suppressed, Windows only),
                         #   activation? ("accessory" = menu-bar agent: no Dock, starts hidden),
                         #   macos?/windows?/linux? (merged on top for that OS),
                         #   minTinyjsVersion? (refuse to run on an older tinyjs) }
  icon.png                # 1024×1024 app icon (template ships a default)
  src/main.js             # backend: export const api = {...}; export function init(app) {}
  src/frontend/           # index.html + any local js/css/images

Both folders are conventions, not requirements. "backend": "path/main.js" picks the backend entry (default: the first of src/main.js, src/main.ts, backend/main.js, backend/main.ts), and the folder it sits in is the backend's root: a plain-JS backend ships that whole folder, and a .ts entry is bundled from it. Separately, "frontend": { "dir": "web" } picks the plain page folder. A bundler project uses `"frontend": { "build", "dist", "dev", "devUrl" } instead, as the --template` scaffolds do. Its packages must be installed before tinyjs dev or build (the scaffolds do that unless --no-install; otherwise the build stops and says so).

Keys that genuinely differ per platform go in a macos / windows / linux block, merged on top of the root ones for that OS — the block names are the strings tiny.system.os() returns:

{
  "name": "myapp",
  "icon": "icon.png",
  "chrome": { "frame": false },

"macos": { "signIdentity": "Developer ID Application: …", "chrome": { "vibrancy": "hud" } }, "windows": { "icon": "icon.ico" }, "linux": { "icon": "icon-512.png" } }

Plain objects merge (the macOS build above gets `{ frame: false, vibrancy: 'hud' }), scalars and arrays replace. TINYJS_SIGN_IDENTITY` and TINYJS_NOTARY_PROFILE override the file — and say so when they displace a value that was really there. Resolution order: root → OS block → env.

Writing the app

Backend (src/main.js) — every api function is callable from the page; handlers receive (params, app):

export const api = {
  hello: async ({ name }) => hi ${name},
};
export function init(app) {          // window is up
  setInterval(() => app.push('tick', Date.now()), 1000);
  // app also has: setTitle(t), setSize(w, h), setMenu(menus), eval(js),
  // reload(), quit(), notify({title, body}), hide()/show()/center()/
  // minimize()/fullscreen(), setPosition(x, y), setAlwaysOnTop(v),
  // setResizable(v), setHideOnClose(v), presence(mode), print(),
  // restore(), setFullscreen(v), getWinState(), setChrome(opts),
  // startDrag(), zoom(), tray.set/remove,
  // updateMenuItem(id, patch), getMenuItem(id), info, store.get/set/delete/all,
  // hotkey.register(id, combo)/unregister(id), setContextMenu(items),
  // update.check()/update.install(),
  // clipboard.read()/write(data)/changeCount()/watch(ms)/unwatch(),
  // keystroke(combo), paste(), permissions.check(name)/request(name),
  // mousePosition(), screens(), paths, show({ activate: false }),
  // shell.open(target)/reveal(path)/trash(path),
  // launchAtLogin.get()/set(v), badge(text), attention(opts),
  // power.preventSleep(reason, opts)/allowSleep(), frontmostApp(),
  // beep(), playSound(target), window(id).share(opts),
  // idleTime(), captureScreen(screenId),
  // pickColor(), ocr(path), thumbnail(path, size),
  // secrets.get/set/delete, authenticate(reason),
  // nowPlaying.set/clear, say(text, opts), voices(), stopSpeaking(),
  // recorder.start({ path, screenId })/stop(),
  // selectedText(), otherWindows(), moveWindow(pid, rect),
  // window(id).setClickThrough/setLevel/setAllSpaces, tray.position(),
  // printToPDF(path), icon(png), presence(mode),
  // macos.applescript(source)/quickLook(paths),
  // battery(), wifi(),
  // spotlight(query)
}

export function onMenu(id, app) {} // optional: handle menu clicks backend-side export function onTray(id, app) {} // optional: tray clicks (id null = bare icon) export function onHotkey(id, app) {} // optional: global hotkey presses export function onContextMenu(id, app) {} // optional: context menu clicks export function onSystem(kind, value, app) {} // optional: 'theme'|'sleep'|'wake'

The backend runtime ships SQLite built in, handy for anything tiny.store is too small for. Backend only: the page runs in the system webview, which has no tjs global and can't import tjs:* modules (doing so throws ReferenceError: tjs is not defined). Query in the backend and hand the page results through an api function (example below).

import { Database } from 'tjs:sqlite';
await tjs.makeDir(dataDir, { recursive: true });  // every tjs.* fs call is
// async — miss this await and the Database() below races the mkdir
const db = new Database(dataDir + '/notes.db');
db.exec('CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, text)');
const st = db.prepare('INSERT INTO notes (text) VALUES (?)');
st.run('hello'); st.finalize();
db.prepare('SELECT * FROM notes').all();   // [{ id: 1, text: 'hello' }]

Coming from better-sqlite3, mind the shape: tjs:sqlite itself is fully synchronous (never await it), a statement has only run() / all() / finalize() — no get(), so use .all()[0] — and run() returns nothing. For the last insert id, ask SQLite:

db.prepare('INSERT INTO notes (text) VALUES (?) RETURNING id').all('hi')[0].id;
// or: db.prepare('SELECT last_insert_rowid() AS id').all()[0].id

To reach the database from the page, expose what it needs from the backend:

// backend
export const api = {
  notes: () => db.prepare('SELECT * FROM notes').all(),
  addNote: ({ text }) => { db.prepare('INSERT INTO notes (text) VALUES (?)').run(text); },
};
// frontend
const notes = await tiny.api.call('notes');
await tiny.api.call('addNote', { text: 'hi' });

Expose named operations rather than a generic query(sql) function, so the page can't run arbitrary SQL.

Frontend — the tiny global is injected into every page automatically:

```js const greeting = await tiny.api.call('hello', { name: 'world' }); // request/response tiny.api.on('tick', (t) => ...); // backend push // on() is additive (addEventListener-style: N handlers, all fire) and returns // an unsubscribe; there's also tiny.api.off('tick', fn). The tiny.*.on sugar // (menu.on, tray.on, theme.on, …) returns the unsubscribe too — and since the // sugar wraps your callback, that return value is the only way to unhook it: const stop = tiny.api.on('tick', onTick); stop(); tiny.api.off('tick', onTick); // same thing, by reference

tiny.log('debug msg'); tiny.quit(); // tiny.log prints from the BACKEND, tagged [web] — so the page's lines and // the backend's interleave in one terminal, in the order they happened. // Objects arrive as objects; it resolves true once the backend has the line. tiny.notify('Done', 'Your export finished'); // desktop notification // packaged apps get REAL Notification Center banners (your app's icon, // permission prompt on first use) when built with a signing identity — // even "Apple Development" works. Ad-hoc/dev builds fall back to osascript. tiny.notify('Ping', 'body', { id: 'x', subtitle: '…', sound: true }); tiny.app.onNotificationClick((id) => ...); // backend: export onNotificationClick await tiny.app.info(); // { version: , tinyjs: , runtime: }

// window control // setSize/size are the PAGE's box (decorations excluded) — 1200x800 of // document on every OS, whatever the title bar adds around it. tiny.win.setTitle('My App'); tiny.win.setSize(1200, 800); tiny.win.center(); tiny.win.setPosition(100, 80); // top-left origin tiny.win.minimize(); tiny.win.restore(); tiny.win.fullscreen(); tiny.win.setFullscreen(true); // toggle / absolute tiny.win.setAlwaysOnTop(true); tiny.win.setResizable(false); tiny.win.hide(); tiny.win.show(); // hide() hides the APP (NSApp hide) — macOS returns focus to the previously // active app on its own, so a palette can hide() then app.paste() with no // frontmost-pid bookkeeping. show() re-activates; tiny.win.show({ activate: false }); // …or surface WITHOUT stealing focus // (overlay/HUD panels) tiny.win.hide({ app: false }); // …or put away THIS WINDOW only, app // stays frontmost (a welcome/launcher // window stepping aside for documents) tiny.win.setHideOnClose(true); // close button hides instead of quitting

// global cursor position (same top-left coords as setPosition — handy for // popping a palette at the mouse) const { x, y, window, screen } = await tiny.app.mousePosition(); // window = relative to this window's content area, clientX/clientY units, // valid even while the cursor is OUTSIDE it: { x, y, inside } // screen = the display the cursor is on: { x, y, width, height, scale }

// frameless / transparent / vibrancy windows (native resize + focus kept) tiny.win.setChrome({ frame: false, windowControls: false, vibrancy: 'hud' }); // mark your own titlebar: … — drag moves the // window, double-click zooms; interactive children are excluded automatically // (or opt out with data-tiny-nodrag). Also in tinyjs.json as "chrome": {…} // (packaged apps apply it before first paint — no titlebar flash). tiny.win.startDrag(); tiny.win.zoom(); // manual equivalents

// Resizing a frameless window: only macOS keeps native edges throughout. An // undecorated GTK window has none, and neither does a frameless SECONDARY // window on Windows (WebView2's child HWND covers the whole rect, so the // resize border is never hit-tested) — so on Linux, and for Windows // satellites, the client adds invisible 5px grips around the edge // automatically. Nothing to do per app. The Windows MAIN window is the // exception: it keeps real left/right/bottom borders, trading the top edge. tiny.win.startResize('se'); // 'n','ne','e','se','s','sw','w','nw' — for your own handle // A fixed-size window (a Winamp-style deck) opts out of the grips: // …or make it non-resizable: tiny.win.setResizable(false); // setResizable(false) means the USER can't drag the edges — your own // setSize() still works, including shrinking to a titlebar for a shade view. tiny.win.setMinSize(900, 640); // a FLOOR rather than a lock (win.open takes // the same as minSize: '900x640'). Same rule as above on macOS and Windows — // it binds the user, not your setSize; GTK clamps both. tiny.win.setZoom(2); // native page zoom (0.25–5), rendered by the webview so // it stays crisp. The page keeps laying out in CSS px and just has fewer of // them — pair with setSize(w*2, h*2) for a real "double size" mode.

// square corners (drop macOS's rounded window corners). This makes the // window BORDERLESS — square, no titlebar, no traffic lights — and is // deliberately un-native: you lose the native titlebar drag (use // data-tiny-drag) but keep resize edges, the shadow, and keyboard focus. tiny.win.setChrome({ squareCorners: true }); // declare it in tinyjs.json "chrome": { "squareCorners": true } so it // applies before first paint (no rounded→square flash on launch).

// acceptsFirstMouse: true delivers the click that focuses an unfocused window // through to the page (macOS swallows it by default — "click once to focus, // again to act"). Handy for palettes/toolbars and DOM drag regions. tiny.win.setChrome({ acceptsFirstMouse: true });

// move the traffic lights (macOS): { x, y } from the window's top-left, for // frameless windows whose custom titlebar is taller than the default corner // assumes. One call — the launcher re-applies it across resizes and // fullscreen round-trips. null restores the OS layout. Ignored on Windows // and Linux. Also in tinyjs.json "chrome" so it applies before first paint. tiny.win.setChrome({ windowControlsPos: { x: 12, y: 24 } });

// read the window back const s = await tiny.win.getState(); // { x, y, width, height, outer: { width, height }, // fullscreen, minimized, visible, focused, // alwaysOnTop, resizable, chrome: { frame, windowControls, // windowControlsPos, transparent, vibrancy, squareCorners, // acceptsFirstMouse }, // screen: { width, height, scale } } // width/height are the page's box — hand them straight back to setSize and // nothing moves. outer is the footprint on screen, decorations in (the page // can't work that out itself: window.outerWidth is 0 in a WKWebView). // x/y are the window's top-left, the units setPosition takes.

// …or don't poll it: window-state transitions arrive as events, whatever // the cause (green button, menu item, F11, your own setFullscreen call). const off = tiny.win.onState(({ win, fullscreen, maximized, minimized, focused }) => { // same vocabulary as getState(); win because events are broadcast — // every page hears about every window and filters by id ('main' or a // win.open id). Wayland never reports minimized (compositor-private). }); off(); // like every tiny on…, it returns its own unsubscribe

// files dragged onto the window arrive with REAL filesystem paths tiny.win.onDrop((paths) => tiny.log(paths.join(', '))); tiny.win.print(); // native print panel for the page

// persistent settings (JSON in ~/Library/Application Support//) await tiny.store.set('recent', ['/tmp/a.txt']); const recent = await tiny.store.get('recent'); // value | null await tiny.store.delete('recent'); await tiny.store.all();

// fetch that runs in the BACKEND — no CORS, CSP, or mixed-content limits, // so the page can hit any origin. Same shape as window.fetch, returns a // real Response (res.json()/res.text()/res.headers/res.ok all work). const r = await tiny.fetch('https://api.example.com/data', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ q: 'hi' }), }); const data = await r.json(); // { stream: true } gives a LIVE streaming body — the whole point for endless // sources like internet radio (a buffered fetch would never resolve). The // backend keeps the connection and the page pulls chunks on demand, with // natural backpressure; res.body.getReader() is the endless tap. const radio = await tiny.fetch('https://ice1.somafm.com/groovesalad-128-mp3', { stream: true }); const reader = radio.body.getReader(); for (;;) { const { value, done } = await reader.read(); if (done) break; / feed decodeAudioData, MediaSource, … / } // reader.cancel() (or closing the window) tears the upstream connection down. // // Reachability: tiny.fetch (and plain fetch() in the backend) transparently // hands two cases the bundled runtime can't do on its own to the system curl: // root-path URLs like https://feeds.example.com/ (the runtime emits 'GET //', // which strict CDNs 404) and TLS 1.2-only host

GitHub Stars & Activity

895Stars
28Forks
10Open issues
CLanguage

GitHub Popularity

GitHub stars895
Forks28
Open issues10
Primary languageC
LicenseMIT
Stars gained today134
Created2026-07-12
Last pushed2026-10-08

Trending History

Daily boardrank #36 · ▲ 134 stars

Related GitHub Projects

1

openssl / openssl

C★ 30,918⑂ 11,524▲ 67 stars
→
2

duixcom / Duix-Avatar

C★ 15,847⑂ 2,699▲ 56 stars
→
3

openclaw / openclaw

TypeScript★ 391,685⑂ 82,361▲ 150 stars
→
4

mattpocock / skills

Shell★ 285,981⑂ 23,987▲ 1,768 stars
→
5

tensorflow / tensorflow

C++★ 200,869⑂ 79,102▲ 346 stars
→
6

jackfrued / Python-100-Days

Jupyter Notebook★ 187,041⑂ 55,779▲ 49 stars
→
7

flutter / flutter

Dart★ 179,777⑂ 33,748▲ 468 stars
→
8

f / prompts.chat

HTML★ 172,609⑂ 22,141▲ 227 stars
→

More Trending Repositories