# Tynd — v0.2
> Desktop apps in TypeScript. Small native binaries, zero-codegen typed RPC.
Complete documentation for Tynd v0.2. Each page below includes its URL, title, description, and full body content. Intended for LLM ingestion.
--- SECTION: Overview ---
URL: https://tynd.kvnpetit.com/docs/v0.2
TITLE: Introduction
DESCRIPTION: Tynd is a desktop-app framework with a TypeScript backend, a native WebView front-end, and small native binaries. One language end-to-end, zero codegen, two runtime modes.
# Tynd
**Desktop apps in TypeScript. One language, native binary, no glue code.**
Tynd is a desktop-app framework with a **TypeScript backend** and a native WebView front-end. Your frontend and backend are both TypeScript. RPC types flow from `typeof backend` — no codegen, no IDL, no schema file. Build produces a small self-contained binary: `~6.5 MB` for the `lite` runtime, `~44 MB` for `full` (Bun packed).
```bash
bunx @tynd/cli create my-app
```
**New here?** Start with the [5-minute quickstart](/docs/v0.2/getting-started) — scaffold an app, run it with HMR, ship a binary.
## At a glance
- **TypeScript top to bottom.** Backend, frontend, IPC, config — same language, no codegen.
- **Two runtime modes, one API.** Start with `lite` (~6.5 MB binary, embedded QuickJS). Switch to `full` with one config line when you need Bun's JIT or native npm bindings.
- **Native window, zero network.** No HTTP server, no loopback TCP port, no firewall prompt. Frontend and IPC ride a native custom scheme.
- **26 OS APIs, identical in both modes** — `fs`, `sql`, `http`, `websocket`, `terminal` (real PTY), `compute`, `dialog`, `tray`, `menu`, `notification`, `clipboard`, `shell`, `process`, `sidecar`, `singleInstance`, `shortcuts`, `keyring`, `autolaunch`, `store`, `updater`, `workers`, `app`, `os`, `path`, `tyndWindow`, plus typed emitters and streaming RPC.
- **Zero-copy binary IPC.** Multi-MB payloads skip JSON/base64 — `ArrayBuffer` end-to-end, 5-10× faster than the usual webview-framework binary path.
- **First-class installers.** `.app`, `.dmg`, `.deb`, `.rpm`, `.AppImage`, NSIS, MSI. Build tools auto-download on first build. Built-in code signing + macOS notarization.
- **Framework-agnostic.** React, Vue, Svelte, Solid, Preact, Lit, Angular — anything that outputs a pure SPA.
## Why Tynd
Most desktop-app frameworks force you to pick a language for the native side (Rust for Tauri, Go for Wails, or the entire Chromium for Electron). Tynd keeps the native layer invisible: **you write TypeScript, you get a native binary**.
Backend, frontend, RPC — same language, same types.
`lite` for a 6.5 MB binary, `full` for the full Node/Bun surface.
RPC and assets never touch TCP — no loopback port, no firewall prompt.
Every backend, frontend, and OS API with signatures and examples.
## How it works
```
TypeScript backend Native OS window
────────────────────────── ─────────────────────────
export async function greet() ◄── IPC ─ await api.greet("Alice")
events.emit("ready", payload) ─── push ─► api.on("ready", handler)
│
▼
tynd-full — your TypeScript runs on Bun, wrapped in a native host
tynd-lite — your TypeScript runs inside the native host, no extra runtime
```
Frontend assets and IPC ride a native custom scheme — no HTTP server, no loopback port, no firewall prompt. Multi-MB binary payloads (`fs.readBinary`, `fs.writeBinary`, `compute.hash`) skip JSON entirely via a dedicated binary channel.
## Two runtime modes
| | `lite` | `full` |
|---|---|---|
| JS runtime | embedded QuickJS — ships inside the native binary | Bun, packed into the native binary |
| Hot JS speed | interpreter — fine for IPC glue, slower on tight loops | **Bun JIT — often 10-100× faster on CPU-bound JS** |
| IPC overhead | in-process, no serialization hop | one serialization hop (OS pipe) |
| Tynd OS APIs (`fs`, `http`, `sql`, …) | ✓ | ✓ |
| Web-standard globals (`fetch`, `WebSocket`, `crypto.subtle`) | ✓ polyfilled | ✓ native |
| JS-level `Bun.*` / `node:*` | ✗ | ✓ |
| npm with native bindings | ✗ | ✓ |
| Binary size | smaller (~6.5 MB) | larger (~44 MB, Bun compressed) |
| Startup | faster (everything in-process) | slower (spawns Bun) |
See [Runtimes](/docs/v0.2/runtimes) for the full parity table.
## Requirements
**[Bun](https://bun.sh) is required for app developers.** Tynd is a Bun-first framework — the CLI, the dev server, and the full runtime all run on Bun. Node.js is not supported as a replacement.
```bash
# macOS / Linux
curl -fsSL https://bun.sh/install | bash
# Windows (PowerShell)
powershell -c "irm bun.sh/install.ps1 | iex"
```
**End users of your built app need nothing** — whichever runtime you shipped is already packed into the distributed binary.
## What to read next
Scaffold a project, run it with HMR, produce a release binary.
Frontend, Rust host, TS backend — how the three surfaces fit.
Recipes for streaming, multi-window, auto-update, signing, bundling.
Tynd vs Tauri, Electron, Wails — 39 categories, 503 rows.
----
--- SECTION: Getting Started ---
URL: https://tynd.kvnpetit.com/docs/v0.2/getting-started
TITLE: Getting Started
DESCRIPTION: Scaffold your first Tynd app, run it with hot reload, and produce a release binary in under five minutes.
# Getting Started
Build a native desktop app in TypeScript in under 5 minutes. By the end of this section you'll have:
- a running dev window with HMR on both the frontend and the backend,
- a typed RPC boundary — rename a function in `backend/main.ts` and the TypeScript compiler catches every stale frontend call,
- a single-file `.exe` / binary in `release/`, around 6.6 MB for `lite` or 44 MB for `full`, that you can ship as-is.
### Install prerequisites
[Bun](https://bun.sh) is required. On Linux you also need the WebKitGTK dev packages — see [Installation](/docs/v0.2/getting-started/installation).
### Scaffold a project
```bash
bunx @tynd/cli create my-app
```
The CLI asks for a project name, a frontend framework (React, Vue, Svelte, Solid, Preact, Lit, or Angular), and a runtime mode (`lite` or `full`).
### Run it
```bash
cd my-app
bun run dev
```
A native window opens with your frontend + HMR. Save `backend/main.ts` — the backend hot-reloads without tearing down the window.
### Ship it
```bash
tynd build # raw binary
tynd build --bundle # + platform installers (.app/.dmg, .deb/.rpm/.AppImage, NSIS/MSI)
```
Output lands in `release/`.
## Keep reading
Bun, WebView deps per OS, verifying the setup.
Walk through the backend, the frontend, and the RPC boundary.
What each file does and where your code goes.
`tynd dev` vs `tynd start`, caching, hot reload.
----
URL: https://tynd.kvnpetit.com/docs/v0.2/getting-started/dev-workflow
TITLE: Development Workflow
DESCRIPTION: The day-to-day loop — dev mode with HMR, classic builds without HMR, cache hits, and troubleshooting.
# Development Workflow
Tynd gives you three run modes: **development with hot reload**, **start from a fresh build**, and **release binary**.
## `tynd dev` — hot reload everywhere
```bash
bun run dev # alias for `tynd dev`
```
- **Frontend HMR** — the dev server (Vite, Angular CLI, …) runs in-process; Tynd mounts it inside the native window via the `tynd://` custom scheme proxying through to `localhost`.
- **Backend hot reload** — Tynd watches `backend/**` and rebuilds + restarts the backend while **keeping the WebView alive**. Open windows persist; in-flight IPC calls reject with a reconnect-required error.
- **OS-API calls** work unchanged — they go through the same Rust host, which doesn't restart.
**Backend restart is selective.** The backend bundle (`bundle.dev.js` for `lite`, `bundle.dist.js` for `full`) is rebuilt on every backend file change. The frontend is left untouched.
### What HMR covers per framework
| Framework | Fast Refresh (HMR) |
|---|---|
| Vue / Svelte / Solid / Preact | ✅ |
| React | ⚠ OK; breaks if React Compiler is enabled at dev time |
| Lit | ♻ Full reload — Web Components by design |
| Angular | ♻ Full reload by default (opt-in HMR via `ng serve --hmr`) |
See [Frontend Frameworks](/docs/v0.2/guides/frontend-frameworks) for per-framework details.
## `tynd start` — classic build, no HMR
```bash
tynd start
```
Builds frontend + backend from scratch (cache-hash keyed), then runs the host against the built artifacts. Every run reflects the latest source but there's no dev server and no watcher.
Use this when:
- you want to reproduce what a release build does without producing an installer,
- you want to profile production-shaped startup latency,
- the Vite HMR server is giving you grief.
## `tynd build` — release binary
```bash
tynd build # raw single-file binary
tynd build --bundle # + installers (per host OS)
```
Produces under `release/`:
- `lite` — ~6.5 MB self-contained executable,
- `full` — ~44 MB self-contained executable (Bun zstd-packed).
With `--bundle`:
| Host OS | Output |
|---|---|
| macOS | `.app` bundle + `.dmg` |
| Linux | `.deb`, `.rpm` (needs `rpmbuild`), `.AppImage` |
| Windows | NSIS `.exe` setup + `.msi` |
See [Bundling & Distribution](/docs/v0.2/guides/bundling).
## The build cache
`tynd dev`, `tynd start`, and `tynd build` all cache by hashing source dirs + key config files into `.tynd/cache/`. Cache keys:
- `frontend` — frontend build output,
- `backend` — production backend bundle,
- `backend-dev` — dev backend bundle (lite only).
When the hash matches and the output still exists, the step is skipped. Miss the cache? The source changed, the config changed, or the output was deleted.
Flush with:
```bash
tynd clean # removes .tynd/cache and release/
tynd clean --dry-run # preview without deleting
```
## Typechecking + linting
Tynd doesn't enforce a particular checker — use your framework's defaults:
```bash
bunx tsc --noEmit # typecheck
bunx biome check . # lint + format
```
CI in the Tynd monorepo runs `bun run check:all` which combines both sides. See the root `CLAUDE.md` / `CONTRIBUTING.md` in the repo for the exact commands.
## Debugging
- **Verbose logs** — `tynd dev --verbose` prints IPC traffic, cache decisions, and Rust-side events.
- **DevTools** — open from the frontend with `await tyndWindow.openDevTools()`. Works in debug builds; release builds compile DevTools out.
- **Full-runtime only** — `bun --inspect-brk` on the backend command line hooks up the Chrome DevTools inspector to the Bun subprocess.
## `tynd info` + `tynd validate`
- `tynd info` prints Bun version, Rust toolchain, WebView2 status (Windows), OS info, cache paths.
- `tynd validate` typechecks `tynd.config.ts` against the `TyndConfig` schema and checks binary availability.
Run both if something feels off.
## Next
- [Backend RPC guide](/docs/v0.2/guides/backend-rpc)
- [Architecture](/docs/v0.2/concepts/architecture)
- [Troubleshooting](/docs/v0.2/troubleshooting)
----
URL: https://tynd.kvnpetit.com/docs/v0.2/getting-started/first-app
TITLE: Your First App
DESCRIPTION: Build a minimal Tynd app from scratch — backend function, frontend call, typed RPC, and a production binary.
# Your First App
Walk through every surface of Tynd — the TypeScript backend, the typed RPC proxy on the frontend, and the native OS APIs that the frontend can call directly.
## Scaffold
```bash
bunx @tynd/cli create hello-tynd
cd hello-tynd
```
Pick any frontend framework. Examples below use framework-agnostic TypeScript — swap `document.getElementById` for your framework's idiomatic equivalent (`useState` in React, `ref()` in Vue, `$state` in Svelte, etc.).
## Backend — your TypeScript functions
```ts filename="backend/main.ts"
import { app, createEmitter } from "@tynd/core";
export const events = createEmitter<{
tick: { count: number };
}>();
export async function greet(name: string): Promise {
return `Hello, ${name}!`;
}
export async function add(a: number, b: number): Promise {
return a + b;
}
app.onReady(() => {
let count = 0;
setInterval(() => {
count += 1;
events.emit("tick", { count });
}, 1000);
});
app.start({
frontendDir: import.meta.dir + "/../dist",
window: {
title: "Hello Tynd",
width: 1000,
height: 700,
center: true,
},
});
```
Every exported function and emitter is **automatically callable from the frontend** — no codegen, no `.d.ts` generation, no glue.
## Frontend — typed RPC proxy
```ts filename="src/main.ts"
import { createBackend } from "@tynd/core/client";
import type * as backend from "../backend/main";
const api = createBackend();
async function render() {
const greeting = await api.greet("Alice");
document.getElementById("greeting")!.textContent = greeting;
const sum = await api.add(2, 3);
document.getElementById("sum")!.textContent = String(sum);
}
api.on("tick", ({ count }) => {
document.getElementById("count")!.textContent = String(count);
});
render();
```
Types come from `typeof backend`. Rename `greet` to `sayHello` in the backend — the compiler lights up every stale call on the frontend.
## OS APIs — no round-trip to the backend
Native things (dialogs, clipboard, notifications) are callable directly from the frontend. No IPC round-trip through your backend, no boilerplate:
```ts filename="src/main.ts"
import {
dialog,
tyndWindow,
clipboard,
notification,
} from "@tynd/core/client";
document.getElementById("open")!.addEventListener("click", async () => {
const path = await dialog.openFile({
filters: [{ name: "Text", extensions: ["txt", "md"] }],
});
if (path) {
await notification.send("File picked", { body: path });
await clipboard.writeText(path);
}
});
document.getElementById("max")!.addEventListener("click", async () => {
await tyndWindow.toggleMaximize();
});
```
OS calls bypass the TypeScript backend entirely — the frontend talks straight to the Rust host over the native IPC channel. See [IPC Model](/docs/v0.2/concepts/ipc).
## Run it
```bash
bun run dev
```
A native window opens. Save `backend/main.ts` — the backend hot-reloads without tearing down the window. Save frontend files — the frontend HMR reloads in place.
## Build it
```bash
tynd build
```
Single-file binary under `release/`:
- `lite` runtime — ~6.5 MB
- `full` runtime — ~44 MB (Bun zstd-packed)
No installer, no runtime to install on the user's machine, no Node/Bun on the target.
### Ship it as an installer
```bash
tynd build --bundle
```
Produces platform-native installers:
| Host OS | Output |
|---|---|
| macOS | `MyApp.app` + `MyApp-1.0.0.dmg` |
| Linux | `.deb` + `.rpm` (if `rpmbuild` is available) + `.AppImage` |
| Windows | NSIS `.exe` setup + `.msi` |
See [Bundling & Distribution](/docs/v0.2/guides/bundling) for the full workflow.
## Next
- [Project Structure](/docs/v0.2/getting-started/project-structure)
- [Backend RPC guide](/docs/v0.2/guides/backend-rpc)
- [API Reference](/docs/v0.2/api)
----
URL: https://tynd.kvnpetit.com/docs/v0.2/getting-started/installation
TITLE: Installation
DESCRIPTION: Install Bun and the OS-level dependencies Tynd needs to build and run desktop apps on Windows, macOS, and Linux.
# Installation
Tynd is a Bun-first framework. The CLI, the dev server, and the `full` runtime all run on Bun — Node.js is not a supported replacement for app developers.
## Install Bun
```bash
curl -fsSL https://bun.sh/install | bash
```
```powershell
powershell -c "irm bun.sh/install.ps1 | iex"
```
Verify:
```bash
bun --version
```
## OS-level WebView dependencies
Tynd uses the system WebView — Edge WebView2 on Windows, WKWebView on macOS, WebKitGTK 4.1 on Linux. End users of a built binary need nothing extra on Windows and macOS; Linux users may need the runtime WebKitGTK package.
**WebView2** is pre-installed on Windows 11 and most Windows 10 versions.
Missing the runtime? Download the Evergreen Bootstrapper from [developer.microsoft.com/microsoft-edge/webview2](https://developer.microsoft.com/microsoft-edge/webview2/).
**WKWebView** ships with macOS — nothing to install.
End-user runtime (for installed `.deb`/`.rpm`/`.AppImage`):
```bash
# Debian / Ubuntu
sudo apt install libwebkit2gtk-4.1-0 libjavascriptcoregtk-4.1-0 libsoup-3.0-0
```
Development (building from source):
```bash
sudo apt install libgtk-3-dev libwebkit2gtk-4.1-dev \
libjavascriptcoregtk-4.1-dev libsoup-3.0-dev \
libxdo-dev
```
**Fedora / RHEL:**
```bash
sudo dnf install gtk3-devel webkit2gtk4.1-devel libsoup3-devel
```
## Install the CLI
You don't have to install anything globally — use `bunx`:
```bash
bunx @tynd/cli create my-app
```
Or add it to a project:
```bash
bun add -d @tynd/cli @tynd/core @tynd/host
```
`@tynd/host`'s postinstall downloads the pre-built `tynd-full` and `tynd-lite` binaries for your OS/arch from GitHub Releases. Bun blocks postinstall scripts by default — `tynd init` / `tynd create` auto-add `@tynd/host` to your `package.json` `trustedDependencies` so the download runs on the next `bun install`.
If the postinstall was skipped or failed (old `@tynd/host` version, network glitch), `tynd dev` / `start` / `build` / `validate` fall back to fetching the matching release asset on demand and cache it under `node_modules/@tynd/host/bin/-/`. If that also fails, see [Troubleshooting](/docs/v0.2/troubleshooting).
## Verify the install
```bash
bunx tynd info
```
Prints Bun version, Rust toolchain (if present), WebView2 status (Windows), OS info, and detected cache paths.
**No Rust toolchain required.** Unless you're building the native host from source, you don't need Rust — `@tynd/host` ships pre-built binaries.
## Editor setup
Any TypeScript-capable editor works. The project's `tsconfig.json` points at ES2023 + Bun's type declarations (for `full`) or pure Web standards (for `lite`). No special language-server setup needed.
## Next
- [Your First App](/docs/v0.2/getting-started/first-app)
- [Development Workflow](/docs/v0.2/getting-started/dev-workflow)
----
URL: https://tynd.kvnpetit.com/docs/v0.2/getting-started/project-structure
TITLE: Project Structure
DESCRIPTION: The files and directories `tynd create` scaffolds, what each one does, and where your code goes.
# Project Structure
A fresh `tynd create` project looks like this:
## Top-level files
### `tynd.config.ts`
Project config — runtime mode, backend entry, frontend directory, window defaults, bundle settings.
```ts filename="tynd.config.ts"
import type { TyndConfig } from "@tynd/cli";
export default {
runtime: "lite", // "lite" | "full"
backend: "backend/main.ts",
frontendDir: "dist",
window: {
title: "My App",
width: 1200,
height: 800,
center: true,
},
} satisfies TyndConfig;
```
See [tynd.config.ts reference](/docs/v0.2/cli/config) for every field.
### `package.json`
Standard Node package manifest. Tynd reads `name`, `version`, `description`, `author`, and `homepage` for installer metadata (unless overridden in `bundle`).
### `tsconfig.json`
Standard TS config. The `lite` runtime exposes **Web-standards only** — for lite projects the template includes the DOM / WebWorker lib but not `@types/node` / `@types/bun`.
### `vite.config.ts`
Only present for Vite-based frameworks. Tynd's dev flow proxies Vite's HMR server; your config is the source of truth for frontend bundling.
## `backend/`
The TypeScript side that runs in the `lite` QuickJS or `full` Bun runtime. Declared via the `backend` field in `tynd.config.ts`.
- **Exported functions** become frontend RPC calls (via `createBackend()`).
- **Emitters from `createEmitter`** become frontend event sources (subscribed via `api.on`).
- **`app.start(config)`** must be the final top-level call.
See [Backend API](/docs/v0.2/api/backend).
## `src/`
Frontend source. The exact layout depends on your framework — `tynd create` scaffolds the standard Vite/Angular template for each.
Imports from `@tynd/core/client` give you:
- `createBackend()` — typed RPC proxy
- OS APIs: `dialog`, `tyndWindow`, `clipboard`, `shell`, `notification`, `tray`, `menu`, `fs`, `http`, `sql`, `store`, … (27 total)
- Web-platform re-exports: `fetch`, `WebSocket`, `crypto`, `URL`, … (one import surface for everything)
## `public/`
Static assets copied verbatim into the frontend bundle. Tynd also reads this directory for **icon auto-detection**:
- `public/favicon.svg` (preferred — renders pixel-perfect at every size),
- `public/favicon.{png,ico}` (degraded fallbacks),
- `public/{icon,logo}.{svg,png,ico}`, …
Override via `icon` in `tynd.config.ts`.
## `dist/`
Frontend build output (auto-generated). For Vite-based apps this matches Vite's `build.outDir`. For Angular, Tynd reads `angular.json` and picks up `dist//browser` (Angular 17+) or `dist/`.
## `.tynd/cache/`
Build cache. Three keys:
- `frontend` — frontend build output hash,
- `backend` — production backend bundle hash,
- `backend-dev` — dev backend bundle hash (lite only).
Source hashes + key config files feed the cache key. When the hash matches and the output still exists, the step is skipped.
Delete with `tynd clean`.
## `release/`
Where `tynd build` drops its output:
- the raw binary (`my-app.exe` on Windows, `my-app` elsewhere),
- optionally any installer artifacts from `--bundle` (`.app`, `.dmg`, `.deb`, `.rpm`, `.AppImage`, NSIS setup, MSI).
Delete with `tynd clean`.
## What's not committed
Default `.gitignore` excludes:
```
node_modules/
dist/
.tynd/
release/
```
Keep `tynd.config.ts`, `backend/`, `src/`, `public/`, and the root manifest/lockfile tracked.
## Next
- [Development Workflow](/docs/v0.2/getting-started/dev-workflow)
- [Architecture](/docs/v0.2/concepts/architecture)
----
--- SECTION: Core Concepts ---
URL: https://tynd.kvnpetit.com/docs/v0.2/concepts/architecture
TITLE: Architecture
DESCRIPTION: How Tynd splits an app across three surfaces — frontend WebView, Rust host, and TypeScript backend — and what each one owns.
# Architecture
A Tynd app has **three surfaces**:
```
┌──────────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────────┐
│ Frontend │ │ Rust Host │ │ TS Backend │
│ (WebView) │◄──IPC─►│ (native layer) │◄──IPC─►│ (Bun or QuickJS) │
│ │ │ │ │ │
│ • Your UI │ │ • Window + event loop │ │ • Your exported fns │
│ • @tynd/core/client │ │ • 26 OS APIs (Rust) │ │ • createEmitter │
│ • createBackend() │ │ • tynd:// + tynd-bin:// │ │ • app.start(config) │
│ • OS calls (direct) │ │ • Streaming RPC │ │ • Lifecycle hooks │
└──────────────────────────┘ └──────────────────────────┘ └──────────────────────────┘
```
Each surface has a single, narrow responsibility. The glue between them is **typed, zero-codegen, and transport-agnostic** (no HTTP, no WebSocket, no TCP).
## The three surfaces
### 1. Frontend — your UI
A pure SPA. Whatever framework you picked (React, Vue, Svelte, Solid, Preact, Lit, Angular) emits static assets into `dist/`. Tynd serves those through the `tynd://localhost/` custom scheme — `window.location.origin` is `tynd://localhost` (no `http://localhost:xxxx`, no TCP port, no firewall prompt).
Three imports:
- `import { createBackend } from "@tynd/core/client"` — typed RPC proxy to your backend.
- `import { dialog, tyndWindow, fs, … } from "@tynd/core/client"` — direct OS calls to the Rust host.
- `import * as tynd from "@tynd/core/client"` — everything above *plus* the Web platform globals (`fetch`, `WebSocket`, `crypto`, `URL`, …) re-exported on the same namespace.
### 2. Rust host — the native layer
Built from two crates:
- **`tynd-host`** (library) — the native event loop, the IPC bridge, and the 26 OS APIs. Shared between both runtimes.
- **`tynd-full`** / **`tynd-lite`** (binaries) — one of them is the actual executable. `full` spawns a Bun subprocess; `lite` embeds QuickJS in-process.
OS APIs (`dialog`, `tyndWindow`, `clipboard`, `shell`, `notification`, `tray`, `menu`, `fs`, `http`, `websocket`, `sql`, `process`, `sidecar`, `store`, `terminal`, `compute`, `workers`, `keyring`, `autolaunch`, `monitor`, `single-instance`, `shortcuts`, `updater`, `app`, `os`, `path`) all live in Rust. They're called from the **frontend directly** — no round-trip through the TypeScript backend.
### 3. Backend — your TypeScript
Your exported functions, your typed emitters, your lifecycle hooks. Runs in one of two environments:
- **`full`** — a Bun subprocess. You get full Bun + Node.js globals (`fetch`, `Bun.*`, `node:*`), JIT, and native npm bindings.
- **`lite`** — embedded QuickJS inside the Rust host. You get **Web standards only** (WHATWG + W3C + TC39), plus all 26 Tynd OS APIs. No `Buffer`, no `process.*`, no `Bun.*`.
Same TypeScript, same frontend. The only difference is what JS-level globals you can reach inside backend code. See [Runtime Modes](/docs/v0.2/concepts/runtimes).
## Two topology variants
### Full mode
```
Frontend (WebView) ──postMessage──► Rust host (IPC handler) ──stdin JSON──► Bun backend
▲ ▲ │
│ evaluate_script │ stdout JSON ◄───────────────┘
```
- Frontend → backend calls travel over the WebView's `window.ipc.postMessage` → Rust parses → stdin JSON → Bun reads a line, dispatches, writes stdout JSON → Rust evaluates the response on the originating webview.
- OS calls take a shortcut: frontend → Rust (direct), Rust → frontend (direct). Backend never sees them.
### Lite mode
```
Frontend (WebView) ──postMessage──► Rust host (IPC handler) ──direct QuickJS call──► Embedded backend
▲ ▲ │
│ evaluate_script │ return value ◄────────────────────────┘
```
- No subprocess. The backend runs in QuickJS, in-process with the Rust host.
- Events emit via `globalThis.__tynd_emit__(name, json)` — injected by Rust.
- Window/frontend config is read from `globalThis.__tynd_config__` after backend eval (QuickJS can't read env vars).
## What's shared, what's runtime-specific
**Shared (identical lite + full):**
- All 26 OS APIs (`@tynd/core/client`) — they're Rust, independent of the JS runtime.
- The RPC wire format (`{ type: "call", id, fn, args }` / `{ type: "return", id, value }`).
- The streaming RPC mechanism (per-stream credit, yield batching, cancellation on window close).
- The `tynd://` frontend scheme + the `tynd-bin://` binary IPC scheme.
- TYNDPKG packing (how your built binary embeds the frontend + backend + assets + sidecars).
**Runtime-specific:**
- JS-level globals inside backend code — see [Runtimes](/docs/v0.2/runtimes).
- Bun vs QuickJS binary packing: full packs Bun + zstd; lite packs QuickJS directly into the host.
- `workers` — lite uses an isolated QuickJS runtime on a fresh thread; full uses `Bun.Worker`. Same API surface.
## Why this split matters
- **No HTTP, no TCP.** Frontend assets and IPC ride a native custom scheme. No loopback port, no firewall prompt on first launch, no MITM window for an attacker on the same machine.
- **No Rust in your code.** You never learn, write, or maintain Rust — the host is prebuilt and downloaded by `@tynd/host`'s postinstall.
- **OS APIs are uniformly fast.** Every OS call runs on a fresh Rust thread (or, for long-lived resources like PTY and workers, a dedicated thread). The JS event loop is never blocked on a native call.
- **Migration between runtimes is a one-line diff.** Change `runtime: "lite"` to `runtime: "full"` in `tynd.config.ts`, re-run `tynd build`. Same source, same API surface, different binary.
## Next
- [Runtime Modes](/docs/v0.2/concepts/runtimes) — lite vs full, parity table.
- [IPC Model](/docs/v0.2/concepts/ipc) — call / event / stream flow in detail.
- [Security](/docs/v0.2/concepts/security) — CSP, scheme allowlists, structural model.
----
URL: https://tynd.kvnpetit.com/docs/v0.2/concepts/build-pipeline
TITLE: Build Pipeline
DESCRIPTION: How `tynd dev`, `tynd start`, and `tynd build` turn your TypeScript into frontend assets, a backend bundle, and a self-contained native binary.
# Build Pipeline
The CLI exposes three run modes. All three share the same underlying build steps; they only differ in whether HMR is wired up and whether the final packing step runs.
## The three modes
```
┌───────────────┐ frontend build ┌──────────┐ backend bundle ┌──────────┐ pack ┌──────────┐
│ tynd dev │ ─────────────────▶ │ cache │ ─────────────────▶ │ cache │ ────(×)──▶│ binary │
│ tynd start │ ─────────────────▶ │ cache │ ─────────────────▶ │ cache │ ────(×)──▶│ binary │
│ tynd build │ ─────────────────▶ │ cache │ ─────────────────▶ │ cache │ ───(✓)───▶│ release/ │
└───────────────┘ └──────────┘ └──────────┘ └──────────┘
```
- **`tynd dev`** runs the framework dev server (Vite, Angular CLI, …) + a backend watcher that rebuilds the dev backend on each file change. No final pack.
- **`tynd start`** produces a clean frontend + backend build, then runs the host pointing at them. No HMR, no watcher. Good for smoke tests.
- **`tynd build`** does the same, then runs the pack step to produce a self-contained binary under `release/`. With `--bundle`, also emits platform-native installers.
## Frontend build
The framework's native build command:
- Vite apps: `vite build` → `dist/`
- Angular: `ng build` → `dist//browser/` (Angular 17+ `@angular/build:application`) or `dist//` (older builders)
- CRA: `react-scripts build` → `build/`
- Parcel / Rsbuild / Webpack: tool-specific
Tynd reads the framework from `package.json`'s dependency graph (`packages/cli/src/lib/detect.ts`). Config encodes `outDir` when the framework allows overriding it (Vite `outDir`, Angular `outputPath`, Rsbuild `distPath.root`).
## Backend bundle
Tynd bundles the backend using Bun's built-in bundler, with a runtime-specific entry and configuration:
- **`lite`** — `.tynd/cache/bundle.dev.js` (dev) or `bundle.js` (build). A single ESM file that QuickJS eval's in-process. Any `node:*` / `Bun.*` reference fails at runtime.
- **`full`** — `.tynd/cache/bundle.dist.js`. A single ESM file that Bun (subprocess) runs. Full Bun + Node globals available.
### Runtime detection via compile-time define
The CLI sets `globalThis.__TYND_RUNTIME__` as a literal string:
```ts
// @tynd/core internals
if (globalThis.__TYND_RUNTIME__ === "full") _startFull();
else _startLite();
```
Bun replaces the literal at bundle time (`define: { "globalThis.__TYND_RUNTIME__": '"lite"' }`). Dead-code elimination drops the unused branch entirely. **Don't introduce dynamic checks that defeat this** — use capability checks instead.
## Cache
All three modes cache by hashing source dirs + key config files into `.tynd/cache/`. Three keys:
| Key | Covers | Used by |
|---|---|---|
| `frontend` | `src/**` + `vite.config.*` / `angular.json` / similar + framework version | `dev`, `start`, `build` |
| `backend` | `backend/**` + `tynd.config.ts` + `@tynd/core` version | `start`, `build` |
| `backend-dev` | same as `backend` + dev flag | `dev` (lite only) |
When the hash matches and the output still exists, the step is skipped. On cache miss, the build runs and populates the cache.
Flush with:
```bash
tynd clean # removes .tynd/cache + release/
```
### Icon regeneration
Icons are **not** cached — the source stays in `public/` and bundlers render the sizes they need on each build. SVG rendering is fast, and producing sharp per-DPI artwork every build is preferable to cache-drifted stale ICOs.
## Pack step (`tynd build` only)
After the frontend and backend builds succeed, `tynd build` concatenates them into a self-extracting binary by appending a **TYNDPKG** trailer to the host executable. See [TYNDPKG Format](/docs/v0.2/concepts/tyndpkg).
Flow:
1. Copy the host binary (`tynd-full` or `tynd-lite`) to `release/.exe`.
2. For `full`: pack `bun.version` + the local `Bun.version` binary as `bun.zst` (zstd-compressed).
3. Pack `bundle.js` (never compressed — QuickJS reads it directly) or `bundle.dist.js`.
4. Pack frontend assets — text files (`html|htm|js|mjs|cjs|css|json|svg`) auto-compressed with zstd, `.zst` appended to their `rel`. Binary assets passed through raw.
5. Pack sidecars (declared in `tynd.config.ts::sidecars`) under `sidecar/` prefix.
6. Append the TYNDPKG trailer + magic bytes.
7. Platform-specific post-processing:
- **Windows** — patch PE subsystem (console → GUI), embed multi-size ICO (16/32/48/256). Full mode also embeds the ICO into the inner packed Bun copy before zstd so Task Manager shows the right icon for the subprocess.
- **macOS** — nothing at raw-binary level; the `.app` bundler copies the binary into `Contents/MacOS/` and handles `Info.plist` + ICNS.
- **Linux** — nothing at raw-binary level; `.deb` / `.rpm` / `.AppImage` bundlers handle `.desktop` + hicolor icons.
## Bundle step (`tynd build --bundle`)
Opt-in. Turns the raw binary into platform-native installers:
| Host OS | Formats | Tool |
|---|---|---|
| macOS | `.app` + `.dmg` | pure TS + `hdiutil` (ships with macOS) |
| Linux | `.deb` + `.rpm` + `.AppImage` | pure TS (`.deb`) + `rpmbuild` (required) + auto-downloaded `appimagetool` |
| Windows | NSIS `.exe` setup + `.msi` | auto-downloaded NSIS 3.09 + WiX v3.11.2 |
Auto-downloaded tools cache to `.tynd/cache/tools///`. No manual install needed except `rpmbuild`.
Cross-compilation **is not supported** — each host produces installers only for its own OS. Use a GitHub Actions matrix to cover all three.
See the [Bundling guide](/docs/v0.2/guides/bundling).
## Code signing (`tynd build --bundle` with `bundle.sign`)
When `bundle.sign` is declared in `tynd.config.ts`, the raw binary is signed right after pack, **before** any bundler copies it into an installer. Every downstream artifact (`.app`, NSIS setup, MSI, raw `.exe`) carries the signature. Bundlers also re-sign the **outer** artifact — Gatekeeper / SmartScreen ignore an inner signature when the outer wrapper is unsigned.
- **Windows** → `signtool.exe` (auto-discovered: `SIGNTOOL` env var → Windows SDK → PATH).
- **macOS** → `codesign --options runtime --timestamp` with optional `xcrun notarytool submit --wait` + `xcrun stapler staple`.
See the [Code Signing guide](/docs/v0.2/guides/code-signing).
## CLI flag summary
| Command | Fast? (cache) | Produces |
|---|---|---|
| `tynd dev` | yes | in-memory dev server + watched backend |
| `tynd start` | yes | runs the host against built `dist/` + bundled backend |
| `tynd build` | yes | `release/[.exe]` |
| `tynd build --bundle` | yes | `release/[.exe]` + installers |
| `tynd clean` | N/A | deletes `.tynd/cache` + `release/` |
## Next
- [TYNDPKG Format](/docs/v0.2/concepts/tyndpkg)
- [Security](/docs/v0.2/concepts/security)
- [Bundling guide](/docs/v0.2/guides/bundling)
----
URL: https://tynd.kvnpetit.com/docs/v0.2/concepts/ipc
TITLE: IPC Model
DESCRIPTION: The transport under RPC calls, events, streaming, and OS calls — how Tynd moves data between frontend, Rust host, and backend without touching the network.
# IPC Model
Tynd's IPC is intentionally boring: **no HTTP, no WebSocket, no TCP port**. Everything rides native WebView bindings + a pair of custom schemes.
## The transport matrix
| Channel | Direction | Transport |
|---|---|---|
| `api.()` | Frontend → Backend | `window.ipc.postMessage` → stdin JSON |
| `api.on("evt")` | Backend → Frontend | Rust `evaluate_script` |
| `events.emit()` | Backend → Frontend | stdout JSON → Rust → `evaluate_script` |
| `dialog` / `fs` / `http` / … | Frontend → Rust (OS APIs) | `window.ipc.postMessage` (direct, no backend round-trip) |
| `terminal:data` / `http:progress` | Rust → Frontend (stream) | native user events → `evaluate_script` |
| `fs.readBinary` / `compute.hash` / … | Frontend ↔ Rust (binary) | `tynd-bin://` custom scheme, raw bytes |
**No HTTP. No WebSocket. No firewall prompt.**
## Frontend serving — `tynd://localhost/`
A native custom protocol maps `tynd://localhost/` to the built frontend directory (dev: proxied to the dev server; prod: read from the TYNDPKG-packed assets). The asset cache is pre-warmed on a background thread before the WebView is built, so the first request is instant.
```
GET tynd://localhost/index.html → reads dist/index.html
GET tynd://localhost/main.js → reads dist/main.js
```
`window.location.origin` is `tynd://localhost`. You can reason about same-origin exactly as on a normal HTTPS site.
## RPC calls — frontend → backend
```ts
// backend/main.ts
export async function greet(name: string) {
return `Hello, ${name}!`;
}
// frontend
import { createBackend } from "@tynd/core/client";
import type * as backend from "../backend/main";
const api = createBackend();
const msg = await api.greet("Alice"); // "Hello, Alice!"
```
Wire:
```json
// frontend → rust (via postMessage)
{ "type": "call", "id": "c42", "fn": "greet", "args": ["Alice"] }
// rust → backend (full: stdin; lite: direct QuickJS call)
{ "type": "call", "id": "c42", "fn": "greet", "args": ["Alice"] }
// backend → rust
{ "type": "return", "id": "c42", "value": "Hello, Alice!" }
// rust → frontend (via evaluate_script of the originating webview)
```
### Zero-codegen types
The frontend only knows the backend by `typeof backend` — no generated files, no IDL, no `.d.ts` step. Rename a backend function and every stale frontend call lights up in the compiler.
```ts
const api = createBackend();
// ^^^^^^^^^^^^^^^^ compile-time-only type import
```
`createBackend` is a thin `Proxy` that sends the call by method name. The runtime code is tiny (one `postMessage` + a `Promise.withResolvers`).
## Events — backend → frontend
Typed via `createEmitter()`:
```ts
// backend
import { createEmitter } from "@tynd/core";
export const events = createEmitter<{
userCreated: { id: string; name: string };
}>();
events.emit("userCreated", { id: "1", name: "Alice" });
// frontend
api.on("userCreated", (user) => console.log(user.name)); // user: { id, name }
api.once("userCreated", (user) => { /* fires once */ });
```
Wire (full mode): `{ "type": "emit", "name": "userCreated", "data": {...} }` lands on stdout, Rust reads the line and `evaluate_script`s `__tynd_emit__(name, data)` on every subscribed webview.
## Streaming RPC — async-generator handlers
If a backend export is an `async function*`, the frontend gets a **StreamCall handle** — awaitable and async-iterable:
```ts
// backend
export async function* processFiles(paths: string[]) {
let ok = 0;
for (const [i, path] of paths.entries()) {
await doWork(path);
ok++;
yield { path, progress: (i + 1) / paths.length };
}
return { ok, failed: paths.length - ok };
}
// frontend
const stream = api.processFiles(["a.txt", "b.txt"]);
for await (const chunk of stream) {
render(chunk.progress); // yields
}
const summary = await stream; // return value
// early stop: await stream.cancel();
```
Three flow-control mechanisms keep streaming safe at arbitrary yield rates:
1. **Per-stream credit** — backend generator starts with 64 credits. Every yield decrements. At 0 credit, the backend awaits a waiter; the frontend replenishes by ACK-ing every 32 consumed chunks. Bounded memory regardless of producer speed.
2. **Yield batching** — Rust buffers yields and flushes either every 10ms or at 64 items per bucket. One `evaluate_script` per webview per flush, not one per chunk.
3. **Cleanup on window close** — when a secondary window closes, Rust cancels every active stream that originated there so generators don't leak.
Combined guarantee: 10k+ yields/sec to the UI without freezing it.
See the [Streaming RPC guide](/docs/v0.2/guides/streaming).
## OS calls — frontend → Rust directly
Native things bypass the TypeScript backend entirely:
```ts
// frontend
import { dialog } from "@tynd/core/client";
const path = await dialog.openFile({ filters: [{ name: "Images", extensions: ["png", "jpg"] }] });
```
Wire: frontend posts `{ type: "os_call", api: "dialog", method: "openFile", args: [...] }`. Rust dispatches via `host-rs/src/os/mod.rs::dispatch` — one fresh `std::thread` per call (dialog blocks for seconds on user input; a shared pool would starve).
**Backend never sees OS calls** — the TypeScript backend is only for *your* app logic, not for bridging the frontend to the OS.
### Main-thread vs worker-thread dispatch
- `tyndWindow.*` calls run on the **main thread** (event loop proxy) because native windowing requires it. Each request carries the label of the webview that issued it; responses route back to the originating window only.
- **Every other OS call** runs on a fresh `std::thread` per call.
- **Long-lived resources** (terminal PTYs, workers, WebSocket sessions, SQL connections) run on their own dedicated thread and survive across calls; their handles live in `Mutex>` statics inside each module.
## Binary IPC — `tynd-bin://localhost/`
Multi-MB payloads skip JSON entirely. A second custom scheme, `tynd-bin://localhost//?`, carries raw bytes in the request and response bodies — no base64, no JSON envelope, `ArrayBuffer` on arrival.
Current routes:
| Route | Method | In | Out |
|---|---|---|---|
| `fs/readBinary?path=...` | `GET` | — | file bytes |
| `fs/writeBinary?path=...&createDirs=0\|1` | `POST` | bytes | `204` |
| `compute/hash?algo=blake3\|sha256\|sha384\|sha512&encoding=base64` | `POST` | bytes | UTF-8 digest |
The TS client wraps these — `fs.readBinary(path)`, `compute.hash(bytes)` — users never touch the scheme directly. Small / non-binary calls (`randomBytes`, text helpers, terminal events) stay on the JSON IPC where it's simpler.
See the [Binary Data guide](/docs/v0.2/guides/binary-data).
## Event emitter infrastructure
Any API that pushes async events to the frontend goes through a central emit helper in the Rust host. At startup, the app wires an emitter that turns each emit into a native user event, which the event loop then forwards to the WebView via `evaluate_script`.
Subscribe on the frontend via `window.__tynd__.os_on(name, handler)` — or use the typed wrappers (`tyndWindow.onResized`, `terminal.onData`, `http.onProgress`, …).
## Multi-window routing
- The primary window has label `"main"`. Additional windows created via `tyndWindow.create({ label })` get their own WebView + IPC channel.
- Every frontend call includes the originating window's label; responses route back to it only.
- Window events (resize, focus, close, …) are broadcast to every webview with a `label` field in the payload. Client-side helpers filter by `__TYND_WINDOW_LABEL__` (injected at WebView creation) so handlers only fire for their own window.
See the [Multi-Window guide](/docs/v0.2/guides/multi-window).
## Next
- [Build Pipeline](/docs/v0.2/concepts/build-pipeline)
- [TYNDPKG Format](/docs/v0.2/concepts/tyndpkg)
- [Streaming RPC guide](/docs/v0.2/guides/streaming)
----
URL: https://tynd.kvnpetit.com/docs/v0.2/concepts/runtimes
TITLE: Runtime Modes
DESCRIPTION: The difference between `lite` (embedded QuickJS, ~6.5 MB) and `full` (Bun subprocess, ~44 MB) and how to pick between them.
# Runtime Modes
Tynd ships **two backend runtimes** from the same TypeScript source. Switch between them with a single config line:
```ts filename="tynd.config.ts"
export default {
runtime: "full", // or "lite"
}
```
## What each runtime is
- **`full`** spawns a **Bun subprocess**. You get the entire Bun + Node.js + Web-API environment: `fetch`, `WebSocket`, `Bun.*`, `node:*` imports, native npm packages, JSC JIT. Binary overhead: ~37 MB (Bun packed + zstd-compressed at build time).
- **`lite`** embeds **QuickJS** inside the Rust host. QuickJS provides the ES2023 language (Promises, classes, Proxy, BigInt, Maps/Sets, …). Tynd layers a strict **Web-standards polyfill layer** on top — nothing Node-specific, nothing Bun-specific. Target binary: ~6 MB, single self-contained executable.
Both runtimes expose the **same 26 Tynd OS APIs** (`fs`, `http`, `sql`, `process`, `store`, `compute`, `workers`, `terminal`, `sidecar`, `singleInstance`, `dialog`, `clipboard`, `shell`, `notification`, `tray`, `tyndWindow`, `menu`, `shortcuts`, `keyring`, `autolaunch`, `monitor`, `updater`, `websocket`, `app`, `os`, `path`) via `@tynd/core/client`. They're Rust-backed and behave identically.
## Pick lite by default
**Default to `lite`.** Most desktop apps only need:
- the Web-standard surface (`fetch`, `WebSocket`, `crypto.subtle.digest`, `URL`, `Blob`, `TextEncoder`, …),
- plus Tynd's OS APIs.
A ~6.5 MB binary with no Bun download is a real UX win on every platform. Lite is strictly a restricted surface — the things it doesn't expose are absent, not broken. Everything listed in the table below behaves the same on both runtimes.
## Switch to full when
Pick `full` if any of these apply:
- Your dependency graph contains an **npm with native bindings** you can't replace (`sharp`, `better-sqlite3`, `canvas`, `bcrypt-native`, `rocksdb`).
- You have a **JS hot path** profiled as the bottleneck — JSC JIT is often 10-100× faster than QuickJS interpretation on tight loops.
- You depend on **specific Bun or Node APIs** not exposed as Web standards or Tynd OS APIs.
- You need **full `Intl.*` locale data** (DateTimeFormat, Collator, Segmenter, RelativeTimeFormat) and can't ship `date-fns`.
- You need **HTTP/2 or HTTP/3** (lite's fetch is HTTP/1.1 only).
- You need **dynamic `import(path)`** at runtime — lite ships a single eval'd bundle.
## Absences in lite — read before shipping
Most code written against Web standards runs unchanged on both runtimes. The table below lists the behavioral differences most likely to bite when porting code from `full` to `lite`:
| What | Behavior in lite | Fix |
|---|---|---|
| **`Response.clone()` / `Request.clone()`** | Throws `"not supported in lite runtime"`. | Consume the body once, stash the bytes, rebuild as needed. |
| **HTTP/2, HTTP/3** | Not supported — lite's fetch is HTTP/1.1 only. | Upgrade your server to HTTP/1.1 fallback, or switch to `full`. |
| **`WritableStream` / `TransformStream`** | Not implemented — only `ReadableStream` (for fetch body). | `web-streams-polyfill`. |
| **Streaming upload with backpressure** | `ReadableStream` body is drained into memory before sending. | Chunk manually via `http.request` + multiple calls, or switch to `full`. |
| **`structuredClone` with `{ transfer }`** | Throws — transfer lists unsupported. | Drop the transfer list; or use `workers` message passing. |
| **`CompressionStream` / `DecompressionStream`** | Absent. | [`fflate`](https://github.com/101arrowz/fflate). |
| **`crypto.subtle` asym sign/verify, AES encrypt/decrypt** | Only HMAC + digest; AES / RSA / ECDSA throw. | [`@noble/ciphers`](https://github.com/paulmillr/noble-ciphers) + [`@noble/curves`](https://github.com/paulmillr/noble-curves). |
| **Dynamic `import(path)` at runtime** | Not supported. | Bundle all modules at build time. |
| **`SharedArrayBuffer` / `Atomics` / `WeakRef` / `FinalizationRegistry`** | QuickJS limitation. | Use `workers` with JSON message passing. |
| **Full `Intl.*` locale data** | QuickJS ships a stub. | `date-fns` / `dayjs` / `i18next`. |
## Parity table (JS surface)
| JS surface | `lite` | `full` |
|---|---|---|
| ES2023 language (Promises, classes, Proxy, BigInt, Maps/Sets) | ✓ | ✓ |
| `fetch` + `Request` / `Response` / `Headers` | ✓ HTTP/1.1 | ✓ HTTP/1/2/3 |
| `ReadableStream` (fetch body) | ✓ | ✓ |
| `WritableStream` / `TransformStream` / `CompressionStream` | ✗ | ✓ |
| `WebSocket` | ✓ | ✓ |
| `EventSource` | ✓ | ✓ |
| `AbortController` / `AbortSignal` / `AbortSignal.timeout` | ✓ | ✓ |
| `crypto.getRandomValues` / `crypto.randomUUID` | ✓ | ✓ |
| `crypto.subtle.digest` (SHA-256/384/512) | ✓ | ✓ |
| `crypto.subtle.sign` / `verify` / `importKey` — HMAC | ✓ | ✓ |
| `crypto.subtle` AES / RSA / ECDSA | ✗ | ✓ |
| `TextEncoder` / `TextDecoder` | ✓ | ✓ |
| `URL` / `URLSearchParams` | ✓ | ✓ |
| `Blob` / `File` / `FormData` | ✓ | ✓ |
| `atob` / `btoa` | ✓ | ✓ |
| `structuredClone` | ✓ (no transfer list) | ✓ |
| `performance.now()` | ✓ | ✓ |
| `Bun.*`, `Deno.*`, `node:*` | ✗ | ✓ |
| `Buffer` (Node global) | ✗ | ✓ |
| `process.*` (Node global) | ✗ | ✓ |
| Dynamic `import(path)` | ✗ | ✓ |
| `SharedArrayBuffer` / `Atomics` | ✗ | ✓ |
| Chrome DevTools inspector | ✗ | ✓ |
## Parity table (Tynd OS APIs)
**All identical on both runtimes** — implemented in Rust, called the same way from the same `@tynd/core/client` import. See the [API Reference](/docs/v0.2/api) for the complete list.
## Size and startup
| | `lite` | `full` |
|---|---|---|
| Binary size (Windows x64 release) | ~6.5 MB host + packed assets | ~6.4 MB host + ~37 MB Bun (zstd) |
| Typical shipped app | ~8-10 MB | ~44 MB |
| Cold start | sub-50ms (everything in-process) | ~200-500ms (spawns Bun) |
## Detecting the runtime at compile time
Tynd's CLI injects a compile-time constant so the bundler dead-code-eliminates the unused branch per-runtime:
```ts
// In @tynd/core internals — don't reintroduce dynamic checks that defeat DCE.
declare const __TYND_RUNTIME__: "full" | "lite";
if (__TYND_RUNTIME__ === "full") {
_startFull();
} else {
_startLite();
}
```
In your app, prefer conditional imports or capability checks (`"clone" in Response.prototype`) over runtime branching on a mode flag.
## Runtime-specific npm dependencies
You can still ship code that only works in one mode — just make sure the opposite path is runtime-safe. Pattern:
```ts
async function hash(data: Uint8Array) {
// Tynd OS API works in both modes — prefer this.
const { compute } = await import("@tynd/core/client");
return compute.hash(data, { algo: "sha256" });
}
```
For Web-standard work that lite doesn't polyfill, see [Alternatives (pure-JS libs)](https://github.com/kvnpetit/tynd/blob/main/ALTERNATIVES.md) in the repo.
## Next
- [Architecture](/docs/v0.2/concepts/architecture)
- [Full runtime reference page](/docs/v0.2/runtimes)
- [Compare Tynd vs other frameworks](/docs/v0.2/compare)
----
URL: https://tynd.kvnpetit.com/docs/v0.2/concepts/security
TITLE: Security
DESCRIPTION: The security model — CSP defaults, scheme allowlists, structural exposure surface, and OS-backed secret storage.
# Security
Tynd's security model is **structural + conservative-by-default**. The exposure surface of your app is the exported module — code and policy can't drift apart — and the WebView runs with an auto-injected CSP.
## Threat model
Tynd targets a single-user desktop environment. The primary concerns:
- **Untrusted content injection** into the WebView (XSS from user-supplied HTML, data URLs, `postMessage`-based smuggling).
- **Local-machine attackers** sniffing IPC traffic if it were on TCP. *(It isn't — see below.)*
- **Supply-chain compromise** of a bundled `.exe` (solved by code signing + notarization).
- **Update-channel hijacking** (solved by Ed25519-signed auto-updates).
What's **not** in scope:
- Protecting app secrets from a user with admin access on their own machine. Disk-encryption + OS-level isolation are the right answer, not app-level obfuscation.
- Fine-grained permission ACLs (capability system). That's a Tauri v2 differentiator; Tynd currently has none.
## Transport — no TCP, no firewall prompt
RPC and frontend asset serving never touch a TCP port. Everything rides:
- **`tynd://localhost/`** (wry custom scheme) for frontend assets.
- **`tynd-bin://localhost//?`** (wry custom scheme) for binary IPC.
- **`window.ipc.postMessage`** for JSON IPC.
- **`evaluate_script`** for backend → frontend pushes.
Consequence: no loopback port, no firewall prompt on first launch, and no MITM window for another process on the same machine that could listen on a socket.
## CSP — auto-injected
Every HTML response served through the `tynd://` scheme carries a baseline Content-Security-Policy header that:
- Blocks **inline scripts** (no `` from smuggled HTML).
- Disables `frame-src` and `object-src`.
- Restricts `connect-src` to same-origin (`tynd://localhost`) plus HTTPS/WSS.
**Override per-page** via a ` ` tag if you need a custom policy.
**Don't relax CSP without a reason.** Every loosening (`'unsafe-inline'`, `'unsafe-eval'`, broad `connect-src *`) widens the attack surface for any bug that injects HTML into your WebView.
## `shell.openExternal` — scheme allowlist
```ts
shell.openExternal(url); // opens in the default browser
```
Rust-side, only **`http://`**, **`https://`**, and **`mailto:`** schemes are accepted. Passing `file://`, `javascript:`, `data:`, or a registered custom scheme throws.
## `shell.openPath` — absolute paths only
```ts
shell.openPath("/Users/me/document.pdf"); // opens in OS default handler
```
Passed directly to the OS's "open with default app" handler. No scheme-level filter — the OS decides what's handled.
## Custom URL schemes — reserved list
If you declare `protocols: ["myapp"]` in `tynd.config.ts`, the following are rejected at config-validation time:
- `http`, `https`, `file`, `ftp`, `mailto`, `javascript`, `data`, `about`, `blob`, `tynd`, `tynd-bin`
See [Deep Linking](/docs/v0.2/guides/deep-linking).
## Secret storage — `keyring` > `store`
For anything sensitive (OAuth tokens, API keys, session cookies, passwords):
- **`keyring`** — OS-encrypted credential storage. Keychain (macOS), Credential Manager + DPAPI (Windows), Secret Service / GNOME Keyring / KWallet (Linux). Secrets are encrypted at rest with the user's login credentials.
- **`store`** — plain JSON k/v at `config_dir()//store.json`. **Readable by any process with user-level access** — use only for non-sensitive preferences.
```ts
import { keyring, createStore } from "@tynd/core/client";
// Sensitive — use keyring
await keyring.set({ service: "com.example.app", account: "alice" }, "s3cr3t-token");
// Non-sensitive — use store
const prefs = createStore("com.example.app");
await prefs.set("theme", "dark");
```
See the [Persistence guide](/docs/v0.2/guides/persistence).
## Code signing — trust the binary
Unsigned binaries trigger SmartScreen warnings (Windows), Gatekeeper quarantine (macOS), and download flags in Chrome/Edge. Tynd ships built-in signing via the `bundle.sign` block in `tynd.config.ts`:
- **Windows** — `signtool.exe` (SHA-256 + timestamp server).
- **macOS** — `codesign --options runtime` + optional `xcrun notarytool submit --wait` + stapler.
See the [Code Signing guide](/docs/v0.2/guides/code-signing).
## Auto-update — Ed25519 signatures
The updater downloads an artifact and **verifies an Ed25519 signature** over the raw file bytes before installing. The public key is supplied by the app (typically baked in at build time), so a compromised manifest server can only redirect to a URL whose bytes still have to verify against the local pubkey.
- Manifest format is Tauri-compatible.
- The CLI ships `tynd keygen` / `tynd sign` so you can produce signed manifests without third-party tools.
- WebCrypto Ed25519 on the signing side, the Ed25519 verifier on the verifying side — raw 32-byte pubkeys, raw 64-byte signatures, no format conversions.
See the [Auto-Updates guide](/docs/v0.2/guides/auto-updates).
## OS-level `process.exec` / `execShell`
```ts
process.exec("git", { args: ["status"] }); // direct exec — no shell interpolation
process.execShell("ls -la | grep tynd"); // shell=true — cmd.exe / sh
```
`process.exec` is the **safe default** — arguments are passed as an array, no shell interpolation. Only use `process.execShell` if you need pipes / globs / shell builtins, and validate or quote untrusted inputs yourself.
## Structural security — export = exposure
The frontend can only call what the backend **explicitly exports**:
```ts
// backend/main.ts
export async function greet(name: string) { … } // callable from frontend
export const events = createEmitter<…>(); // subscribeable from frontend
async function internal() { … } // NOT callable — not exported
```
There's no command allowlist to maintain, no capability manifest to keep in sync. **The TypeScript module is the policy.**
Consequences:
- **Safe default** — you have to opt a function in (via `export`) to make it reachable. Forgetting to export something is a "safer than intended" outcome, not the other way around.
- **No drift** — there is no second config file describing "what RPC calls are allowed" that could fall out of date.
- **Grep-auditable** — search for `export async function` or `export const events =` to enumerate the entire surface.
## What Tynd does NOT offer (yet)
- **Capability-based ACL** (per-command / per-path / per-URL permissions) — Tauri v2 has this; Tynd doesn't.
- **Context isolation** (renderer ↔ preload boundary) — N/A because there is no Node/Bun in the renderer; the only injected globals are the Tynd IPC shims.
- **Renderer sandbox mode** — no configurable sandbox; the WebView runs with the OS's default web-content permissions.
- **Permission request handlers** (camera, mic, geolocation prompts) — the WebView handles them per the OS default, Tynd doesn't intercept.
- **Scoped FS / HTTP access patterns** — your backend is what enforces access control.
## Security checklist for shipping an app
### Sign and notarize
Configure `bundle.sign` in `tynd.config.ts` (Windows `signtool` + macOS `codesign` + optional notarization). Unsigned binaries hurt user trust and trip every OS defense mechanism.
### Bake your updater public key
Generate a keypair with `tynd keygen`. Store the private key **offline**; commit only the `.pub`. Bake the pubkey into your app source. Every `tynd sign` step produces a signature; only signatures verifiable against the baked pubkey install.
### Keep CSP tight
Don't add `'unsafe-inline'` / `'unsafe-eval'` unless you know why you're adding them. If you must, scope it to a single route via ` `.
### Use `keyring` for secrets
Never put tokens / passwords / session cookies in `store`. Use `keyring`.
### Validate user-supplied paths
If you let a user pass a path to `fs.readText` / `process.exec`, validate the path stays inside a directory you control. Don't trust normalization — canonicalize first and check for prefix.
### Use `process.exec`, not `execShell`, with user input
`execShell` runs through `cmd.exe` / `sh` — any unquoted user input is a shell injection. Default to `process.exec("git", { args: [...] })` with arguments as an array.
## Next
- [Code Signing guide](/docs/v0.2/guides/code-signing)
- [Auto-Updates guide](/docs/v0.2/guides/auto-updates)
- [keyring API](/docs/v0.2/api/os/keyring)
----
URL: https://tynd.kvnpetit.com/docs/v0.2/concepts/tyndpkg
TITLE: TYNDPKG Format
DESCRIPTION: The self-extracting trailer appended to a Tynd binary — how your frontend, backend, Bun, and sidecars live inside a single .exe.
# TYNDPKG Format
Every Tynd binary is **self-extracting**. `tynd build` appends a packed section to the host executable; at launch, the host reads its own tail, extracts assets to a temp directory, and runs the app.
## Wire format
```
┌──────────────────────────────────┐
│ host binary │ (tynd-full or tynd-lite)
│ ... │
├──────────────────────────────────┤
│ [file_count: u32 LE] │ ← TYNDPKG trailer starts here
│ per file: │
│ [path_len: u16 LE] │
│ [path: UTF-8] │
│ [data_len: u32 LE] │
│ [data: bytes] │
│ ... │
│ [section_size: u64 LE] │
│ [magic: "TYNDPKG\0"] │ ← last 8 bytes of the file
└──────────────────────────────────┘
```
At startup, the host seeks to the last 8 bytes, verifies the `TYNDPKG\0` magic, reads `section_size` to find the trailer offset, then iterates the file list.
## What gets packed
### Frontend assets
Read from `frontendDir` (default `dist/`). Text files auto-compressed with zstd:
- Extensions compressed: `html|htm|js|mjs|cjs|css|json|svg`
- `.zst` is appended to the packed `rel` path (`index.html` → `index.html.zst`)
- Binary assets (`png`, `jpg`, `woff2`, `wasm`, …) are packed raw
Bun uses `node:zlib.zstdCompressSync` at pack time. Rust decompresses with the `zstd` crate when the asset is first requested (result cached in memory per launch).
### Backend bundle
- **`lite`** — `bundle.js` packed **raw** (not zstd). QuickJS reads it directly; a decompress step would add latency and memory.
- **`full`** — `bundle.dist.js` packed raw as well (Bun reads it from the extracted path).
### Bun (full mode only)
Two entries, in this order (order matters — Rust reads `bun.version` first to decide cache path before reading `bun.zst`):
1. `bun.version` — text file, the Bun version used at pack time.
2. `bun.zst` — zstd-compressed Bun binary (`Bun.version` found at pack time on PATH).
At launch, the full host extracts `bun.zst` to a versioned cache dir (`/bun//bun[.exe]`), reusing the already-extracted copy on subsequent launches.
### Sidecars
Declared in `tynd.config.ts`:
```ts
sidecars: [
{ name: "ffmpeg.exe", path: "bin/ffmpeg.exe" },
]
```
Packed under the `sidecar/` prefix (`sidecar/ffmpeg.exe`). At launch, the host extracts each one to `/sidecar/`, chmods it +755 on Unix, and registers the path in `os::sidecar`. Your TypeScript retrieves the path via `sidecar.path("ffmpeg.exe")`.
## Extraction flow
### Full mode
1. Read TYNDPKG trailer.
2. Extract `bundle.dist.js` to `/bundle.dist.js`.
3. Extract Bun binary to `/bun//bun[.exe]` (cached across launches).
4. Extract frontend assets to an in-memory cache (pre-warmed on a background thread before the WebView is built).
5. Extract sidecars to `/sidecar/`.
6. Spawn Bun with `bundle.dist.js` as the entry, `TYND_ENTRY` / `TYND_FRONTEND_DIR` / `TYND_DEV_URL` env vars.
7. Wait for the backend's `{ type: "tynd:config" }` first line on stdout.
8. Build the WebView with the config.
### Lite mode
1. Read TYNDPKG trailer.
2. Extract frontend assets to the in-memory cache.
3. Extract sidecars to `/sidecar/`.
4. QuickJS eval's `bundle.js` (from memory — never written to disk).
5. Read `globalThis.__tynd_config__` after eval to get window / menu / tray config.
6. Build the WebView.
## Platform tweaks
### Windows — PE patching
Two post-processing steps run on Windows `.exe` files:
- **`patchPeSubsystem`** — flips the PE subsystem from `IMAGE_SUBSYSTEM_WINDOWS_CUI` (console) to `IMAGE_SUBSYSTEM_WINDOWS_GUI` (no console window on launch).
- **`setWindowsExeIcon`** — embeds a multi-size ICO (16/32/48/256) as a Win32 resource via ResEdit. For **full mode**, the same ICO bytes are also embedded into the inner Bun copy **before** zstd compression, so Task Manager shows the app icon for the Bun subprocess.
### Icon rendering
Single source of truth: **one file in `public/`** (SVG preferred, PNG or ICO accepted).
- **Windows ICO** — sizes `[16, 32, 48, 256]` via `renderIconPngSet` → `pngToIco`.
- **macOS ICNS** — sizes `[32, 128, 256, 512, 1024]`.
- **Linux hicolor** — sizes `[16, 32, 48, 64, 128, 256, 512]` dropped into `usr/share/icons/hicolor/x/apps/.png` for `.deb` / `.rpm` / `.AppImage`.
PNG source degrades to single-size (native resolution). ICO source passes through to Windows bundles and is skipped (with a warning) for macOS/Linux.
Non-square SVGs are wrapped in a square viewBox before rasterising — Windows PE and macOS ICNS reject or distort non-square inputs.
## Reading the trailer programmatically
If you want to inspect a packed binary:
```bash
# Last 8 bytes must be TYNDPKG\0
tail -c 8 release/my-app.exe | xxd
# Full trailer layout — parse the last (8 + 8) bytes to get section_size,
# then read the last (8 + 8 + section_size) bytes and walk the file list.
```
The Rust reader is `packages/{full,lite}/src/embed.rs`. Both runtimes share the wire format.
## Size guidance
| Component | Size (zstd where applicable) |
|---|---|
| Host (lite or full, Windows x64 release) | ~6.4 MB |
| Bun binary (full mode, zstd-compressed) | ~37 MB |
| Typical frontend (React SPA, 200KB text) | ~80 KB packed |
| Typical backend bundle | ~30-200 KB depending on deps |
| Sidecars | whatever the binary weighs |
So a real `lite` app ships around ~6.5-10 MB; a `full` app ships around ~44-50 MB before sidecars.
## What's not in TYNDPKG
- **Frontend dev server** — in dev mode, `tynd://localhost` proxies to the framework's dev server (`http://localhost:5173/`, etc.). TYNDPKG is only used for builds.
- **User data** — `store` writes to `//store.json`, `sql.open(path)` takes an on-disk path. None of that lives inside the binary.
- **Installer metadata** — `.app`'s `Info.plist`, NSIS `.nsi`, MSI `.wxs`, `.desktop` files — those are produced by the bundlers and live outside the raw binary.
## Next
- [Build Pipeline](/docs/v0.2/concepts/build-pipeline)
- [Bundling guide](/docs/v0.2/guides/bundling)
- [Sidecars guide](/docs/v0.2/guides/sidecars)
----
--- SECTION: Guides ---
URL: https://tynd.kvnpetit.com/docs/v0.2/guides
TITLE: Guides
DESCRIPTION: Task-focused guides for real Tynd work — frontend frameworks, streaming RPC, multi-window, auto-updates, code signing, bundling, and more.
# Guides
Topic-oriented guides. Each one walks through a concept you'll hit while building a real app.
## Core
React, Vue, Svelte, Solid, Preact, Lit, Angular — what's supported, HMR caveats, SSR frameworks that are blocked.
Zero-codegen typed RPC. How arguments / returns / errors serialize, emitters, modularization.
Async-generator handlers with flow control, cancellation, multi-window routing.
Secondary windows, per-window events, cross-window coordination.
Zero-copy multi-MB payloads via the `tynd-bin://` channel.
Picking between `store`, `sql`, `keyring`, and `fs` for app state.
## Desktop integration
Prevent duplicate launches, forward argv, auto-focus the primary.
Register `myapp://` URLs — registration, testing, validation.
System-wide hotkeys that fire even when the app is unfocused.
Bundle and execute a native CLI (ffmpeg, yt-dlp, …).
## Release workflow
`.app`, `.dmg`, `.deb`, `.rpm`, `.AppImage`, NSIS, MSI — produce platform-native installers.
Windows `signtool`, macOS `codesign` + notarization, Linux detached `.sig`.
Ed25519-signed updates with a Tauri-compatible manifest.
One SVG source → Windows ICO, macOS ICNS, Linux hicolor.
## Quality
`bun test` for backend logic, mocking OS APIs, smoke tests in CI.
Startup latency, IPC overhead, CPU-bound JS, memory.
DevTools, verbose logs, the backend inspector, common symptoms.
Screen readers, focus management, reduced motion, color contrast.
Locale detection, string catalogs, RTL layouts.
## Migration
Port `#[tauri::command]`s to backend `export`s. Keep the same installers + updater.
Replace `ipcMain.handle` with typed RPC; drop the ~160 MB Chromium.
## Related
- [API Reference](/docs/v0.2/api) — signatures for every surface the guides touch.
- [Recipes](/docs/v0.2/recipes) — shorter self-contained snippets.
- [Tutorials](/docs/v0.2/tutorials) — full app walkthroughs.
----
URL: https://tynd.kvnpetit.com/docs/v0.2/guides/accessibility
TITLE: Accessibility
DESCRIPTION: Ship an app that works for screen readers, keyboard-only users, and high-contrast / reduced-motion preferences.
# Accessibility
Tynd hands off rendering to the native WebView, so your app inherits the OS's accessibility tree for free — **if your HTML cooperates**. This guide is about the app-level concerns Tynd can't solve for you.
## What the WebView gives you
- **Screen readers** — VoiceOver (macOS), Narrator (Windows), Orca (Linux). They walk the ARIA tree exposed by the WebView. Same semantics as a browser: `` is a button, `aria-label` is read, `role="..."` is respected.
- **Keyboard navigation** — Tab order from the DOM. Focus rings rendered by the WebView.
- **Reduced motion** — `@media (prefers-reduced-motion: reduce)` works.
- **High contrast** — `@media (prefers-contrast: more)` / Windows High Contrast Mode both flow through.
- **Dark mode** — `@media (prefers-color-scheme: dark)` is respected; `os.isDarkMode()` gives you a programmatic read.
What **doesn't** come for free:
- Global hotkeys for screen-reader shortcuts (the OS owns those).
- `aria-live` regions still need you to write them.
- Focus management across multi-window scenarios.
## Checklist
### Semantic HTML
- Use `` not ``.
- Landmarks: `
`, ``, ``, ``, ``.
- Headings in order (don't skip `` → ``).
- ` ` — or `alt=""` for decorative.
- Forms: `` tied to ` `.
### Focus management
- Every interactive element must be reachable by Tab.
- Focus indicator must be **visible** (don't `outline: none` without a replacement).
- Trap focus inside modal dialogs; restore on close.
- On multi-window, focus the content area when a window gains focus:
```ts
tyndWindow.onFocused(() => {
document.getElementById("main-content")?.focus();
});
```
### Live updates
Long-running operations (upload, search, stream) need an `aria-live` region:
```html
{statusMessage}
```
Where `sr-only` visually hides but keeps it in the AT tree:
```css
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
```
### Reduced motion
Respect the user preference:
```css
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
```
Or with JS:
```ts
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduced) runFancyAnimation();
```
### Color contrast
- Body text: AA = 4.5:1, AAA = 7:1.
- Large text (18pt+ or 14pt+ bold): AA = 3:1.
- Don't convey information through color alone (error + icon, not just red).
### Keyboard shortcuts
- Document them in an in-app help dialog.
- Don't hijack OS-reserved combos (⌘Q on macOS, Ctrl+Alt+Del on Windows).
- Let users remap — see [Keyboard Shortcuts](/docs/v0.2/guides/keyboard-shortcuts) for a user-configurable pattern.
### Tray menu keyboard access
Tray menus open on left-click. Keyboard access to the tray is OS-specific (Windows: `Win+B` then Tab; macOS: accessibility menu; Linux: varies). You can't change this — design your app so the tray is an accelerator, not the only way to reach critical features.
### Dialog focus
Native dialogs (`dialog.openFile`, `dialog.confirm`) are OS-managed — they get focus correctly. In-app modals you build yourself must:
- Move focus into the modal on open.
- Trap Tab inside the modal.
- Close on Esc.
- Restore focus to the trigger on close.
### Multi-monitor / DPI changes
Re-measure on DPI change:
```ts
tyndWindow.onDpiChanged(({ scale }) => {
applyScaleFactor(scale);
});
```
User-increased font size should reflow your layout. Design for `rem`, not `px`, for text.
## Testing
- **macOS** — VoiceOver (⌘F5 to toggle). Navigate with VO+arrows.
- **Windows** — Narrator (Win+Ctrl+Enter) or NVDA (free). Tab through the app.
- **Linux** — Orca (`orca` command).
- **All OSes** — unplug the mouse. Can you still use the app?
Automated checks help but don't replace manual testing:
- `axe-core` as a devDependency, run in a dev-mode check.
- Lighthouse audit against `tynd://localhost/index.html` — paste the URL into `await tyndWindow.openDevTools()` → Lighthouse tab.
## Platform quirks
- **macOS VoiceOver** — may not announce dynamically inserted content unless in an `aria-live` region.
- **Windows Narrator** — reads `title` attributes; don't duplicate with `aria-label` unless you want both read.
- **Linux Orca** — coverage varies by app. WebKitGTK's AT tree is usually complete.
## What Tynd will not help with
- No programmatic API for "announce this" — use `aria-live` regions.
- No way to query screen-reader activity. Design assuming it's on; the CSS media queries above are the programmatic signal.
## Related
- [Keyboard Shortcuts](/docs/v0.2/guides/keyboard-shortcuts).
- [Multi-Window](/docs/v0.2/guides/multi-window) — focus management.
- [Security](/docs/v0.2/concepts/security) — CSP and trusted-content defaults.
----
URL: https://tynd.kvnpetit.com/docs/v0.2/guides/auto-updates
TITLE: Auto-Updates
DESCRIPTION: Ship Ed25519-signed auto-updates with a Tauri-compatible manifest. The CLI handles keygen, signing, and verification.
# Auto-Updates
Tynd ships a first-class auto-updater with **Ed25519 signature verification**. The CLI has `tynd keygen` + `tynd sign` so you can produce signed manifests without third-party tools.
## Flow
```
┌───────────────────┐ GET /update.json ┌─────────────────────┐
│ Running app │──────────────────────▶│ Your update server │
│ │◀──────────────────────│ (any HTTPS host) │
│ currentVersion │ manifest JSON └─────────────────────┘
│ = 1.0.0 │
│ │ download url
│ │──────────────────────▶ artifact (MyApp-1.2.3.exe)
│ │ verify Ed25519 sig
│ │ over raw bytes
│ │
│ match? install. │
└───────────────────┘
```
## Generate an updater keypair
```bash
tynd keygen --out release/updater
```
Produces:
- `release/updater.key` — PKCS#8 private key. **Store offline, never commit.**
- `release/updater.pub` — raw 32-byte public key (base64). Commit this into your app source.
## Bake the public key into your app
```ts filename="src/updater-key.ts"
export const UPDATER_PUB_KEY = "cFpG...RVDv/RQ=";
```
Import and pass to `updater.downloadAndVerify`:
```ts
import { updater } from "@tynd/core/client";
import { UPDATER_PUB_KEY } from "./updater-key";
const info = await updater.check({
endpoint: "https://releases.example.com/update.json",
currentVersion: "1.0.0",
});
if (info) {
const off = updater.onProgress(({ phase, loaded, total }) => {
console.log(`${phase}: ${loaded}/${total ?? "?"}`);
});
const { path } = await updater.downloadAndVerify({
url: info.url,
signature: info.signature,
pubKey: UPDATER_PUB_KEY,
});
off();
await updater.install({ path }); // swaps binary + relaunches
}
```
## Publish a release
### Build the signed artifact
```bash
tynd build --bundle nsis # or app, msi, deb, etc.
```
### Sign it
```bash
tynd sign release/MyApp-1.2.3-setup.exe \
--key release/updater.key \
--out release/MyApp-1.2.3-setup.exe.sig
```
Outputs a base64 signature over the raw bytes of the artifact.
### Write the manifest
Tauri-compatible format:
```json filename="update.json"
{
"version": "1.2.3",
"notes": "Bug fixes & perf.",
"pub_date": "2026-04-19T12:00:00Z",
"platforms": {
"windows-x86_64": {
"url": "https://releases.example.com/MyApp-1.2.3-setup.exe",
"signature": ""
},
"darwin-aarch64": {
"url": "https://releases.example.com/MyApp-1.2.3.dmg",
"signature": ""
},
"linux-x86_64": {
"url": "https://releases.example.com/MyApp-1.2.3.AppImage",
"signature": ""
}
}
}
```
### Publish both the manifest and the artifact
Host the manifest at a stable URL (`https://releases.example.com/update.json`). Upload the signed artifacts alongside. Serve both over HTTPS.
## Manifest details
- **Platform key** — `-`, where `os ∈ { windows | darwin | linux }` and `arch ∈ { x86_64 | aarch64 }`. `macos` is `darwin` for parity with GitHub Releases / Tauri.
- **Version comparison** — semver. An update is offered iff `manifest.version` is **strictly newer** than `currentVersion`.
- **Manifest is plain HTTPS** — no meta-signature. Trust model: a compromised manifest server can only redirect to a URL whose bytes still have to verify against your local pubkey.
## Install semantics
`install({ path, relaunch? = true })`:
- **Windows** — `cmd /c timeout 2 & move /y & start `. The timeout lets the current process exit so the `.exe` unlocks, then cmd replaces the binary and relaunches.
- **Linux** (AppImage + any single-file binary) — `fs::rename` + chmod +x + spawn + exit. Linux keeps the old inode mapped as long as the exe is live, so this is safe mid-run.
- **macOS** — **not yet implemented.** `.app` bundles are directories and updates ship as archives; handle the swap manually via the returned `path` for now.
Returns just before the current process exits. Your frontend code should not rely on any state after `install` resolves.
## Trust model
- The **public key** travels bundled with the app. Rotating it requires shipping a new build — an attacker who compromises your release server can't replace the key without also signing with the current private key.
- The **private key** never touches your build server. Sign artifacts on a trusted workstation (or a hardened CI runner) and upload the `.sig` alongside the artifact.
- `downloadAndVerify` streams the artifact to disk while hashing; verification happens over the **full downloaded bytes** before `install` touches anything.
## CI pattern
```yaml
# .github/workflows/release.yml (partial)
- name: Sign artifact
env:
UPDATER_KEY: ${{ secrets.UPDATER_KEY }} # base64-encoded PKCS#8 from tynd keygen
run: |
echo "$UPDATER_KEY" | base64 -d > /tmp/updater.key
bunx tynd sign release/MyApp-1.2.3-setup.exe \
--key /tmp/updater.key \
--out release/MyApp-1.2.3-setup.exe.sig
rm /tmp/updater.key
- name: Upload to release
uses: softprops/action-gh-release@v2
with:
files: |
release/MyApp-1.2.3-setup.exe
release/MyApp-1.2.3-setup.exe.sig
```
Generate the `update.json` manifest as the last step of the release job (or regenerate on any publish via a small script that reads the signature files).
## Periodic checks — do it yourself
```ts
import { updater } from "@tynd/core/client";
import pkg from "../package.json";
async function checkForUpdates() {
try {
const info = await updater.check({
endpoint: "https://releases.example.com/update.json",
currentVersion: pkg.version,
});
if (info) showUpdatePrompt(info);
} catch (err) {
console.warn("update check failed:", err.message);
}
}
// Check at startup, then every 6 hours.
void checkForUpdates();
setInterval(checkForUpdates, 6 * 60 * 60 * 1000);
```
## Not yet handled
- macOS `.app` swap (extract from `.dmg` / `.tar.gz`).
- Delta updates (binary diff).
- Rollback on failed install.
- Signed manifest (double-sig).
For macOS today, download the artifact, extract it manually, then tell the user where to drop the new `.app`.
## Next
- [updater API](/docs/v0.2/api/os/updater)
- [tynd keygen](/docs/v0.2/cli/keygen)
- [tynd sign](/docs/v0.2/cli/sign)
----
URL: https://tynd.kvnpetit.com/docs/v0.2/guides/backend-rpc
TITLE: Backend RPC
DESCRIPTION: Typed RPC between frontend and backend without codegen — `createBackend()`, emitters, and the rules of engagement.
# Backend RPC
Tynd's RPC is **zero-codegen** and **zero-schema**. Every `export` from your backend file is a callable RPC method on the frontend, typed via `typeof backend`.
## Core pattern
```ts filename="backend/main.ts"
import { app, createEmitter } from "@tynd/core";
// Named exports are RPC methods
export async function greet(name: string): Promise {
return `Hello, ${name}!`;
}
export async function add(a: number, b: number): Promise {
return a + b;
}
// Emitters are frontend event sources
export const events = createEmitter<{
tick: { at: number };
}>();
app.start({
window: { title: "RPC demo", width: 900, height: 600 },
});
```
```ts filename="src/main.ts"
import { createBackend } from "@tynd/core/client";
import type * as backend from "../backend/main";
const api = createBackend();
const msg = await api.greet("Alice"); // string, typed
const sum = await api.add(2, 3); // number, typed
const unsub = api.on("tick", ({ at }) => {
console.log("tick at", at);
});
// unsub() when done
```
Under the hood `createBackend` is a thin `Proxy` that serialises the call as JSON and sends it over `window.ipc.postMessage`. The return value deserialises as JSON. No codegen, no IDL — types flow from `typeof backend`.
## Supported argument and return types
JSON-serializable:
- Primitives: `string`, `number`, `boolean`, `null`, `bigint` (stringified)
- Arrays, plain objects
- Nested structures
**Not serializable directly:**
- `Function` — no way to roundtrip across IPC
- `Map` / `Set` — convert to array first
- `Date` — serializes as ISO string; pass epoch ms (`Date.now()`) or parse on arrival
- `ArrayBuffer` / `Uint8Array` — use [binary IPC](/docs/v0.2/guides/binary-data) for anything large; for small payloads, base64-encode manually
## Error handling
Throwing in the backend rejects the frontend promise:
```ts
// backend
export async function divide(a: number, b: number) {
if (b === 0) throw new Error("division by zero");
return a / b;
}
// frontend
try {
await api.divide(10, 0);
} catch (err) {
console.error(err.message); // "division by zero"
}
```
Error objects are serialized with `name` and `message`. Stack traces are stripped in production. Use a custom error class + `instanceof` check if you want structured errors:
```ts
export class BusinessError extends Error {
constructor(public code: string, message: string) { super(message); }
}
export async function doThing() {
throw new BusinessError("not_found", "widget not found");
}
```
Frontend receives `{ name: "BusinessError", message: "widget not found" }` — enough to switch on `err.name`.
## Lifecycle hooks
```ts filename="backend/main.ts"
import { app } from "@tynd/core";
app.onReady(() => {
console.log("window shown, WebView alive");
});
app.onClose(() => {
// 2-second watchdog — if handlers don't complete in time, the host force-exits.
console.log("user clicked X, cleaning up");
});
app.start({ window: { title: "…" } });
```
- `onReady` fires when the WebView emits `DOMContentLoaded` on the primary window.
- `onClose` fires when the user closes the primary window (`WindowEvent::CloseRequested`). The window hides immediately; you have ~2 s to finish before a watchdog force-exits.
## Emitters — typed events
```ts filename="backend/main.ts"
export const events = createEmitter<{
userCreated: { id: string; name: string };
progress: { percent: number };
}>();
// anywhere in backend code
events.emit("userCreated", { id: "1", name: "Alice" });
events.emit("progress", { percent: 42 });
```
Frontend subscribes:
```ts
api.on("userCreated", (user) => console.log(user.name));
api.once("userCreated", (user) => { /* fires once */ });
```
**Emitters must be exported.** The frontend's type-only import (`typeof backend`) needs to see them to type-check `api.on("name", …)`. Forgetting `export` is a silent type-only error.
## Async generators — streaming
An `async function*` export is a streaming RPC:
```ts
export async function* count(to: number) {
for (let i = 1; i <= to; i++) {
yield i;
await Bun.sleep(100);
}
return "done";
}
// frontend
const stream = api.count(5);
for await (const n of stream) console.log(n); // 1, 2, 3, 4, 5
console.log(await stream); // "done"
```
See [Streaming RPC](/docs/v0.2/guides/streaming) for flow control, cancellation, and multi-window routing.
## Calling between backend functions
Just regular function calls:
```ts filename="backend/main.ts"
async function loadFromDB(id: string) { /* internal */ }
export async function getUser(id: string) {
return loadFromDB(id);
}
```
Unexported functions are **not** RPC-accessible — the frontend type import can't see them.
## Modularization
```ts filename="backend/main.ts"
export { greet, farewell } from "./user";
export { list, create } from "./items";
export { app } from "@tynd/core";
import { app } from "@tynd/core";
app.start({ window: { title: "…" } });
```
```ts filename="backend/user.ts"
export async function greet(name: string) { return `Hi, ${name}`; }
export async function farewell(name: string) { return `Bye, ${name}`; }
```
Frontend:
```ts
api.greet("Alice");
api.list();
```
The typed proxy flattens all re-exports.
## Rules of engagement
### One backend file per app
`tynd.config.ts::backend` points at one entry (default `backend/main.ts`). All RPC methods and emitters live in the exports of that module (directly or re-exported). This is what types `typeof backend` — nothing else is reachable.
### Never throw sensitive strings
Error messages cross IPC verbatim. Don't put DB connection strings, secrets, or file paths you wouldn't show the user in a thrown message.
### Keep RPC granularity moderate
10 calls for one user action cost 10 round-trips + 10 promise resolutions. Batch when you control both sides: expose a single `loadDashboard()` RPC that returns everything the view needs, not 12 small getters.
### Return POJOs, not class instances
Class methods don't survive serialization. If you use classes internally, convert to plain objects at the RPC boundary.
### Don't return cycles
`JSON.stringify` throws on circular references. Break cycles before returning.
## Full-runtime-only patterns
In `full` mode, the backend has full Bun + Node + npm access:
```ts
// full-only — don't do this in lite
import { readFile } from "node:fs/promises";
export async function readConfig() {
return JSON.parse(await readFile("./config.json", "utf8"));
}
```
For cross-runtime code, use the **Tynd OS APIs** (`fs`, `sql`, `http`, …) — they work identically in both modes:
```ts
// works in full AND lite
import { fs } from "@tynd/core/client";
export async function readConfig() {
return JSON.parse(await fs.readText("./config.json"));
}
```
Wait — `fs` lives in `@tynd/core/client`. Can the backend use it? Yes: `@tynd/core/client` is importable from the backend too. The OS APIs dispatch to Rust regardless of which side calls them.
## Next
- [Streaming RPC](/docs/v0.2/guides/streaming)
- [Binary Data](/docs/v0.2/guides/binary-data)
- [Backend API reference](/docs/v0.2/api/backend)
----
URL: https://tynd.kvnpetit.com/docs/v0.2/guides/binary-data
TITLE: Binary Data
DESCRIPTION: Move multi-MB payloads between frontend and Rust without JSON or base64 — the `tynd-bin://` channel.
# Binary Data
JSON IPC is text-only — base64 is fine for kilobytes but chokes on multi-megabyte files (encoding overhead, memory spikes, main-thread stalls). Tynd ships a **second custom protocol** dedicated to raw bytes.
## The `tynd-bin://` channel
A wry custom protocol `tynd-bin://localhost//?` carries raw bytes in the request body and response body:
- No base64.
- No JSON envelope.
- `ArrayBuffer` on arrival.
Current routes:
| Route | Method | In | Out |
|---|---|---|---|
| `fs/readBinary?path=...` | `GET` | — | file bytes |
| `fs/writeBinary?path=...&createDirs=0\|1` | `POST` | bytes | `204` |
| `compute/hash?algo=blake3\|sha256\|sha384\|sha512&encoding=base64` | `POST` | bytes | UTF-8 digest |
Typical throughput is **5-10× faster** than a base64-over-JSON round-trip on multi-MB payloads.
## TS client wrappers
You don't hit the scheme directly — use the wrappers:
```ts
import { fs, compute } from "@tynd/core/client";
// Read
const bytes = await fs.readBinary("image.png"); // Uint8Array
// Write
await fs.writeBinary("./out/copy.png", bytes, { createDirs: true });
// Hash
const digest = await compute.hash(bytes, { algo: "sha256" }); // base64 string
```
## Small payloads stay on JSON IPC
Sub-MB or text-shaped calls stay on the JSON channel where it's simpler:
- `fs.readText` / `fs.writeText` — text
- `compute.randomBytes(32)` — small, text protocol is fine
- `terminal:data` events — base64-encoded PTY chunks (small + streaming)
Use binary only when you have multi-MB payloads.
## When to use fetch vs binary IPC for network I/O
For HTTP downloads, prefer `http.download` or `fetch` — they already stream and never round-trip through JSON:
```ts
import { http } from "@tynd/core/client";
// Streams bytes straight to disk, emits progress events
await http.download("https://.../bigfile.zip", "./downloads/bigfile.zip", {
onProgress: ({ loaded, total }) => {
console.log(total ? `${((loaded / total) * 100).toFixed(1)}%` : `${loaded}B`);
},
});
// Or read into memory as ArrayBuffer
const { body: bytes } = await http.getBinary("https://.../image.png");
```
`http.getBinary` / `http.download` use the Rust HTTP client (TLS, HTTP/1.1) — no detour through the JS fetch polyfill in `lite`.
## Hashing large buffers
```ts
const bytes = await fs.readBinary("video.mp4");
const digest = await compute.hash(bytes, { algo: "sha256" });
```
For ~100 MB of video, `compute.hash` is roughly 10× faster than hashing in JS on lite (interpreter) and 2-3× faster than Node/Bun's `crypto.createHash("sha256")` because:
- The bytes travel via the zero-copy scheme, not base64.
- Rust computes the hash on a fresh thread, off the JS event loop.
## Gotchas
- **Don't pass `ArrayBuffer` across the JSON RPC channel.** If you return a 50 MB buffer from a regular RPC call, it gets JSON-encoded (→ `{"0":12,"1":7, …}`) and the app will grind. Use `fs.readBinary` / `compute.hash` or a handle-based pattern instead.
- **No transferable objects** — wry's IPC bridge doesn't have `Transferable` semantics. You always receive a fresh ArrayBuffer, the original is still yours.
- **In lite, `Response.clone()` / `Request.clone()` throw.** If you're writing a fetch-based helper that reads the body twice, consume it once and stash the bytes.
## Under the hood
The Rust side of the binary scheme lives at `host-rs/src/scheme_bin.rs`. The TS client wraps the scheme through the WebView's native `fetch` (which handles `tynd-bin://` transparently):
```ts
// Simplified version of what fs.readBinary does
const res = await fetch(`tynd-bin://localhost/fs/readBinary?path=${encodeURIComponent(path)}`);
const buf = await res.arrayBuffer();
return new Uint8Array(buf);
```
Custom APIs could add new routes in the Rust host, but the scheme is currently **closed** — only `fs.readBinary`, `fs.writeBinary`, `compute.hash` are registered.
## Next
- [fs API reference](/docs/v0.2/api/os/fs)
- [compute API reference](/docs/v0.2/api/os/compute)
- [http API reference](/docs/v0.2/api/os/http)
----
URL: https://tynd.kvnpetit.com/docs/v0.2/guides/bundling
TITLE: Bundling & Distribution
DESCRIPTION: Turn the raw Tynd binary into platform-native installers — `.app`, `.dmg`, `.deb`, `.rpm`, `.AppImage`, NSIS `.exe`, MSI.
# Bundling & Distribution
`tynd build --bundle` turns the raw self-contained binary into platform-native installers. Tools auto-download on first build — no manual setup beyond `rpmbuild` on Linux.
## Quick start
```bash
tynd build --bundle # all formats for the host OS
tynd build --bundle app,dmg # comma-separated list
```
Output lands in `release/`:
| Host OS | Outputs |
|---|---|
| macOS | `MyApp.app` + `MyApp-1.0.0.dmg` |
| Linux | `.deb` + `.rpm` (if `rpmbuild` is installed) + `.AppImage` |
| Windows | `MyApp-1.0.0-setup.exe` (NSIS) + `MyApp-1.0.0-x64.msi` |
**No cross-compilation.** Each host produces installers only for its own OS. Use a GitHub Actions matrix to cover Windows, macOS, and Linux.
## Required config
```ts filename="tynd.config.ts"
export default {
runtime: "lite",
backend: "backend/main.ts",
frontendDir: "dist",
bundle: {
identifier: "com.example.myapp", // reverse-DNS, required
categories: ["Utility"], // XDG / Launch Services
shortDescription: "A tiny app",
},
} satisfies TyndConfig;
```
Other fields (`author`, `description`, `homepage`, `version`) default to reading from `package.json`.
## Formats
### macOS `.app` + `.dmg`
- `.app` — TS builder (`bundle/app.ts`). Copies the binary into `Contents/MacOS/`, emits `Info.plist`, renders ICNS from the source icon.
- `.dmg` — shells out to `hdiutil` (ships with macOS).
### Linux `.deb`
Pure-TS builder (handwritten `ar` + `tar-stream`). No `dpkg-deb` required.
Produces:
- `control` with name, version, description, maintainer, dependencies (`libwebkit2gtk-4.1-0`, …).
- `postinst` / `prerm` scripts that update desktop mime caches.
- The `.desktop` file under `usr/share/applications/`.
- Hicolor icon tree under `usr/share/icons/hicolor/x/apps/`.
### Linux `.rpm`
Uses system `rpmbuild`. **Required** — install with `sudo apt install rpm` or `sudo dnf install rpm-build` on the build machine. Tynd fails fast with a clear message if not found.
### Linux `.AppImage`
Auto-downloads `appimagetool` to `.tynd/cache/tools/appimagetool//`. Produces a portable single-file AppImage that runs on any modern distro.
### Windows NSIS `.exe` setup
Auto-downloads NSIS 3.09 portable zip to `.tynd/cache/tools/nsis//`. Generates `MyApp.nsi` from a template that honors:
- `bundle.nsis.installMode` — `currentUser` (default) or `perMachine`.
- `bundle.nsis.languages` — list of NSIS language files (default: English).
- `bundle.nsis.license` — path to a license `.txt` shown during install.
**Default install mode is `currentUser`**, so the produced setup never prompts for UAC and installs into `%LOCALAPPDATA%\Programs\`. Set `installMode: "perMachine"` for a system-wide install.
### Windows MSI
Auto-downloads WiX Toolset v3.11.2. Builds an MSI via `candle.exe` + `light.exe` from a generated `.wxs`.
## Auto-downloaded tools
| Tool | Version | Where | When |
|---|---|---|---|
| `appimagetool` | latest | `.tynd/cache/tools/appimagetool//` | first `--bundle appimage` on Linux |
| `NSIS` | 3.09 | `.tynd/cache/tools/nsis//` | first `--bundle nsis` on Windows |
| `WiX` | v3.11.2 | `.tynd/cache/tools/wix//` | first `--bundle msi` on Windows |
Orchestrated by `bundle/tools.ts::ensureTool`. Extraction uses `adm-zip` for zips, `tar-stream` + `node:zlib` for `tar.gz`, raw stream for single-file AppImage.
## Icon handling
Single source of truth: **one file in `public/`** (SVG preferred).
Auto-detection order:
1. `public/{favicon,icon,logo}.svg`
2. `public/{favicon,icon,logo}.{ico,png}`
3. `assets/icon.{svg,png,ico}`
4. `icon.{svg,png,ico}`
Override with `icon` in `tynd.config.ts`.
### Per-format rendering
| Format | Sizes | Notes |
|---|---|---|
| Windows ICO | 16, 32, 48, 256 | via `renderIconPngSet` → `pngToIco` |
| macOS ICNS | 32, 128, 256, 512, 1024 | via `generateIcns`, one entry per size bucket |
| Linux hicolor | 16, 32, 48, 64, 128, 256, 512 | dropped into `usr/share/icons/hicolor/x/apps/.png` |
- **PNG source** — degrades to single-size (native resolution). SVG recommended for pixel-perfect rendering.
- **ICO source** — passes through directly to `.exe` / NSIS / MSI; skipped (with warning) for macOS/Linux.
- **Non-square SVG** — wrapped in a square viewBox before rasterising. Windows PE + macOS ICNS reject / distort non-square inputs.
Icons are **not cached** — rendering is fast and each build produces fresh, per-DPI artwork.
## Config reference — `bundle` block
```ts
bundle: {
identifier: "com.example.myapp", // required
categories: ["Utility"],
shortDescription: "…",
longDescription: "…",
copyright: "© 2026 Example Inc.",
homepage: "https://example.com",
nsis: {
installMode: "currentUser" | "perMachine",
languages: ["English", "French"],
license: "./LICENSE.txt",
headerImage: "./installer-header.bmp",
welcomeImage: "./installer-welcome.bmp",
},
deb: {
depends: ["libwebkit2gtk-4.1-0"],
recommends: [],
suggests: [],
},
rpm: {
requires: ["webkit2gtk4.1"],
},
appimage: {
continuous: false,
},
sign: { /* see Code Signing */ },
}
```
## CI — build on all three platforms
```yaml
# .github/workflows/release.yml
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: linux-x64
bundles: deb,rpm,appimage
- os: macos-latest
target: macos-arm64
bundles: app,dmg
- os: windows-latest
target: windows-x64
bundles: nsis,msi
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- name: Install Linux deps
if: runner.os == 'Linux'
run: |
sudo apt-get install -y rpm \
libgtk-3-dev libwebkit2gtk-4.1-dev \
libjavascriptcoregtk-4.1-dev libsoup-3.0-dev \
libxdo-dev
- run: bun install
- run: bunx tynd build --bundle ${{ matrix.bundles }}
- uses: actions/upload-artifact@v4
with:
name: bundles-${{ matrix.target }}
path: release/
```
## Known issues
- **`.deb` dependency miss** — if your app uses `fs.watch` (inotify), `terminal` (util-linux), or similar, add those to `bundle.deb.depends`.
- **AppImage + old glibc** — AppImages link against the glibc version used at build time. Build on the oldest supported system (Ubuntu 20.04 LTS) to maximise compatibility.
- **NSIS Unicode** — the generated `.nsi` is UTF-8. Include the `Unicode true` directive if you rely on non-ASCII in metadata (Tynd does this by default).
## Next
- [Code Signing](/docs/v0.2/guides/code-signing)
- [Auto-Updates](/docs/v0.2/guides/auto-updates)
- [tynd.config.ts](/docs/v0.2/cli/config)
----
URL: https://tynd.kvnpetit.com/docs/v0.2/guides/code-signing
TITLE: Code Signing
DESCRIPTION: Sign Windows binaries with signtool, sign + notarize macOS builds with codesign + notarytool, and integrate signing into `tynd build`.
# Code Signing
`tynd build` signs Windows `.exe` / NSIS / MSI via `signtool` and macOS binaries / `.app` via `codesign` (+ optional notarization) **for you** when a `bundle.sign` block is present in `tynd.config.ts`.
**Why sign?** Unsigned binaries trigger SmartScreen warnings on Windows, are quarantined by Gatekeeper on macOS, and are flagged by Chrome/Edge on download. Signing + notarizing eliminates those prompts.
## Built-in signing
Declare certs once in `tynd.config.ts`; every `tynd build` signs automatically.
```ts filename="tynd.config.ts"
export default {
runtime: "lite",
backend: "backend/main.ts",
frontendDir: "dist",
bundle: {
identifier: "com.example.myapp",
sign: {
windows: {
certificate: "./cert.pfx", // or "cert:subject=My Publisher"
password: "env:WIN_CERT_PASSWORD", // reads from env var
timestampUrl: "http://timestamp.digicert.com",
},
macos: {
identity: "Developer ID Application: Name (TEAMID)",
entitlements: "./entitlements.plist", // optional
notarize: {
appleId: "env:APPLE_ID",
password: "env:APPLE_APP_PASSWORD", // app-specific password
teamId: "env:APPLE_TEAM_ID",
},
},
},
},
} satisfies TyndConfig;
```
- `env:NAME` resolves from `process.env.NAME` at build time and throws if missing — no secrets in source control.
- The raw binary is signed first, then each installer artifact on top. Both layers need valid signatures for Gatekeeper / SmartScreen to trust the final download.
- `notarize` is opt-in. When present, the signed `.app` is zipped and submitted with `xcrun notarytool submit --wait`, then stapled so offline launches work.
- On Windows, `signtool.exe` is auto-discovered: `SIGNTOOL` env var → Windows SDK → PATH.
Omit `bundle.sign` and builds stay unsigned — fine for dev and internal CI smoke tests.
## Windows
### Get a certificate
| Type | Cost/yr | SmartScreen |
|---|---|---|
| **EV code-signing** (hardware token) | ~$300-600 | removed immediately |
| Standard code-signing (OV) | ~$100-250 | removed after reputation builds (1-2 months) |
| Free for open source: [SignPath.io](https://signpath.io) (Foundation tier) | $0 | OV-level |
EV certs ship on a USB token (YubiKey or SafeNet); the key can't be exported. Standard certs come as a `.pfx`.
### Built-in signing
Point `certificate` at a `.pfx` file or a certificate store lookup:
```ts
sign: {
windows: {
certificate: "./cert.pfx", // file path
// or
certificate: "cert:subject=My Publisher Inc", // Windows cert store by subject
// or
certificate: "cert:sha1=AAAAAAAA...FFFFFFFF", // Windows cert store by thumbprint
password: "env:WIN_CERT_PASSWORD",
timestampUrl: "http://timestamp.digicert.com",
}
}
```
### Manual `signtool` (advanced)
```powershell
signtool sign `
/fd SHA256 /tr http://timestamp.digicert.com /td SHA256 `
/f cert.pfx /p $env:CERT_PASSWORD `
release\my-app.exe
```
**Always include `/tr `** — without it, the signature expires when the cert does.
Verify:
```powershell
signtool verify /pa /v release\my-app.exe
```
## macOS
### Get a Developer ID
- Enroll in the [Apple Developer Program](https://developer.apple.com/programs/) ($99/yr).
- Create a **Developer ID Application** certificate in Xcode or the web console.
- For `.dmg` / `.pkg` installers, also create a **Developer ID Installer** certificate.
- Export both as `.p12` (password-protected) for CI.
### Built-in signing
```ts
sign: {
macos: {
identity: "Developer ID Application: Your Name (TEAMID)",
entitlements: "./entitlements.plist", // optional
notarize: {
appleId: "env:APPLE_ID",
password: "env:APPLE_APP_PASSWORD",
teamId: "env:APPLE_TEAM_ID",
},
}
}
```
### Entitlements
WebKit needs JIT — minimal `entitlements.plist`:
```xml filename="entitlements.plist"
com.apple.security.cs.allow-jit
com.apple.security.cs.allow-unsigned-executable-memory
com.apple.security.cs.disable-library-validation
com.apple.security.network.client
```
### Manual `codesign` + `notarytool`
```bash
codesign --deep --force --options runtime \
--entitlements entitlements.plist \
--sign "Developer ID Application: Your Name (TEAMID)" \
release/MyApp.app
codesign --force --sign "Developer ID Application: Your Name (TEAMID)" \
release/MyApp-1.0.0.dmg
xcrun notarytool submit release/MyApp-1.0.0.dmg \
--apple-id "you@example.com" --team-id "TEAMID" --password "app-specific-password" \
--wait
xcrun stapler staple release/MyApp-1.0.0.dmg
xcrun stapler staple release/MyApp.app
```
Generate an **app-specific password** at [appleid.apple.com](https://appleid.apple.com) → Sign-in and security → App-specific passwords.
### Verify
```bash
spctl --assess -vv release/MyApp.app
# Expected: "source=Notarized Developer ID"
codesign --verify --deep --strict --verbose=2 release/MyApp.app
```
## Linux
Linux has no centralized signature-verification model. Two practical paths:
### Detached `.sig` (manual verification)
```bash
gpg --armor --detach-sign release/my-app-1.0.0.AppImage
gpg --armor --detach-sign release/my-app-1.0.0.deb
# Users verify with:
gpg --verify my-app-1.0.0.AppImage.asc my-app-1.0.0.AppImage
```
Publish the public key alongside the downloads.
### Signed `.deb` / `.rpm` repos
- **Debian/APT** — `dpkg-sig --sign builder file.deb` + sign the `Release` file with `gpg --clearsign`. Users drop the key in `/etc/apt/trusted.gpg.d/`.
- **RPM/dnf** — `rpm --addsign file.rpm` (needs `%_gpg_name` in `~/.rpmmacros`). Users import via `rpm --import`.
### AppImage embedded signature
```bash
./appimagetool --sign --sign-key release/MyApp.AppImage
```
Verification is then done by the AppImage runtime itself.
For most desktop apps shipping via GitHub Releases, **detached `.sig` is enough** — serious distros will package you themselves anyway.
## CI — GitHub Actions example
```yaml
jobs:
build:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: bunx tynd build --bundle
env:
# Windows
WIN_CERT_PASSWORD: ${{ secrets.WIN_CERT_PASSWORD }}
# macOS
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
- uses: actions/upload-artifact@v4
with:
name: signed-${{ matrix.os }}
path: release/
```
For macOS, import the cert into the runner's keychain before the build step:
```yaml
- name: Import macOS certs
if: runner.os == 'macOS'
env:
CERT_BASE64: ${{ secrets.MACOS_DEV_ID_P12 }}
CERT_PASSWORD: ${{ secrets.MACOS_DEV_ID_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
echo "$CERT_BASE64" | base64 --decode > cert.p12
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security import cert.p12 -k build.keychain -P "$CERT_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
```
For Windows, write the PFX from a base64-encoded secret before the build:
```yaml
- name: Stage Windows cert
if: runner.os == 'Windows'
env:
CERT_BASE64: ${{ secrets.WINDOWS_PFX_BASE64 }}
run: |
[IO.File]::WriteAllBytes("cert.pfx", [Convert]::FromBase64String($env:CERT_BASE64))
```
## EV certs + CI
EV certs ship on hardware tokens and **don't work in most CI runners**. Use a cloud-signing service instead — DigiCert KeyLocker, SSL.com eSigner, Azure Trusted Signing. They expose a `signtool`-compatible interface over an authenticated API.
## Next
- [tynd.config.ts reference](/docs/v0.2/cli/config)
- [Auto-Updates](/docs/v0.2/guides/auto-updates)
- [Bundling](/docs/v0.2/guides/bundling)
----
URL: https://tynd.kvnpetit.com/docs/v0.2/guides/debugging
TITLE: Debugging
DESCRIPTION: DevTools, verbose logs, backend inspector, and common diagnostic commands for Tynd apps.
# Debugging
Three surfaces can go wrong — backend, frontend, or IPC between them. Each has its own tool.
## Frontend — DevTools
Open Chromium DevTools inside the WebView (debug builds only; release builds compile DevTools out):
```ts
import { tyndWindow } from "@tynd/core/client";
await tyndWindow.openDevTools();
await tyndWindow.closeDevTools();
```
Works like any browser DevTools — Console, Elements, Network (you'll see `tynd://localhost` and `tynd-bin://localhost` requests), Sources (set breakpoints), Performance.
### Bind to a keyboard shortcut
```ts
import { shortcuts, tyndWindow } from "@tynd/core/client";
if (import.meta.env.DEV) {
await shortcuts.register("CmdOrCtrl+Shift+I", () => {
void tyndWindow.openDevTools();
});
}
```
## Backend — logs
Both runtimes redirect `console.log` to stderr (so stdout stays clean for IPC). Watch them:
```bash
tynd dev --verbose # prints IPC traffic, cache decisions, Rust events
tynd start --verbose # same, for the no-HMR mode
```
In a production binary, the backend still writes to stderr — redirect to a file if you want persistence:
```bash
./release/my-app 2> app.log
```
### Structured logging
Default `console.*` works but gives you unstructured text. For production apps consider a small wrapper:
```ts filename="backend/log.ts"
type Level = "debug" | "info" | "warn" | "error";
export function log(level: Level, msg: string, data?: Record) {
console.error(JSON.stringify({
ts: new Date().toISOString(),
level,
msg,
...data,
}));
}
log("info", "user signed in", { userId: "abc" });
```
Or use a lib — `pino` / `consola` work in `full`; in `lite`, any pure-JS lib is fine.
## Backend — inspector (full only)
`full` mode runs on Bun, which supports the Chrome DevTools inspector:
```bash
TYND_BUN_ARGS="--inspect-brk" tynd dev
```
Open `chrome://inspect` in Chrome and click the target. You get JS breakpoints, stepping, memory profiling — the full package.
Not available in `lite` (QuickJS has no inspector protocol). Switch to `full` temporarily if you need step-debugging.
## IPC traffic
`tynd dev --verbose` / `tynd start --verbose` prints every RPC call, emit, and OS call with timing. Useful for answering "is the call going through? what did it return?".
## Rust host errors
The host writes its own errors to stderr with a `[tynd-host]` prefix:
```
[tynd-host] dispatch error: api=fs method=readText: No such file
```
Usually that's enough to figure out the issue. For deeper inspection:
```bash
TYND_LOG=debug tynd dev
```
Sets the host's `tracing` subscriber to debug level — you'll see every IPC hop and thread spawn. Verbose, but useful when something mysterious is happening.
## Common symptoms
### RPC call hangs forever
- Backend didn't register the function (`export` missing, typo in name).
- Backend crashed before the response (check stderr).
- In `full`, the Bun subprocess exited (the host logs this).
**Verify:** `tynd dev --verbose` shows `RPC call greet sent` but no corresponding `return`. Backend crashed.
### Events don't arrive
- Emitter not `export`ed from `backend/main.ts`.
- Handler registered before `createBackend` set up the proxy (subscribe after page ready).
- In `lite`, the host's `__tynd_emit__` shim missing — usually means you're running the lite bundle outside `tynd-lite`.
### OS call rejects with "Permission denied"
Look at the path. On macOS, the app may need an entitlement (disk access, network client, etc.). On Linux, `chmod +x` might be needed for a sidecar.
### Backend hot reload stalls
Usually a syntax error in the new backend file — the old backend stays alive because the bundler failed. Check `tynd dev` output for a bundle error.
## `tynd info` + `tynd validate`
Before digging deep, run these two:
```bash
tynd info --verbose # Bun version, Rust, WebView2, host binary path, cache
tynd validate # tynd.config.ts schema, binary discoverability
```
They catch 90% of "it's not working" reports.
## Remote / shipped-app debugging
Once the app is on a user's machine, you don't have DevTools. Options:
- **Ship a log file toggle.** Behind an "Advanced" / "Debug" settings pane, let the user enable verbose logging. Log to `os.dataDir() + "/logs/"`.
- **Crash reporter.** Wire up a simple uncaught-error handler:
```ts
window.addEventListener("error", (e) => {
// POST to your crash collector
void fetch("https://telemetry.example.com/crash", {
method: "POST",
body: JSON.stringify({
message: e.message,
stack: e.error?.stack,
version: await app.getVersion(),
}),
});
});
```
- **Log the user's environment.** `await os.info()` + `tynd info` output pasted into a bug report is usually enough.
## Related
- [Testing](/docs/v0.2/guides/testing) — catch bugs before users see them.
- [Performance](/docs/v0.2/guides/performance) — profiling specifics.
- [Troubleshooting](/docs/v0.2/troubleshooting) — common errors + fixes.
----
URL: https://tynd.kvnpetit.com/docs/v0.2/guides/deep-linking
TITLE: Deep Linking
DESCRIPTION: Register a custom URL scheme (`myapp://`) so the OS launches your app for matching links.
# Deep Linking
A **deep link** is a URL like `myapp://invite/abc123` that the OS routes to your app. Tynd handles the platform-specific registration at build time and delivers the URL to your frontend.
## Declare the scheme
```ts filename="tynd.config.ts"
export default {
runtime: "lite",
backend: "backend/main.ts",
frontendDir: "dist",
protocols: ["myapp"],
bundle: { identifier: "com.example.myapp" },
} satisfies TyndConfig;
```
- `protocols` — array of scheme names (no `://`).
- Reserved schemes (`http`, `https`, `file`, `ftp`, `mailto`, `javascript`, `data`, `about`, `blob`, `tynd`, `tynd-bin`) are rejected at config-validation time.
## Registration per OS
`tynd build` wires each installer format:
- **macOS `.app`** — writes `CFBundleURLTypes` into `Info.plist`.
- **Windows NSIS / MSI** — creates `HKCU\Software\Classes\\shell\open\command` registry entries (current user — no admin prompt).
- **Linux `.deb` / `.rpm` / `.AppImage`** — adds `MimeType=x-scheme-handler/;` + `%U` in the `Exec=` line of the generated `.desktop` file.
## Handle URLs at runtime
Pair with `singleInstance` so duplicate launches forward the URL to the primary:
```ts
import { singleInstance } from "@tynd/core/client";
const { acquired } = await singleInstance.acquire("com.example.myapp");
if (!acquired) process.exit(0);
singleInstance.onOpenUrl((url) => {
// url = "myapp://invite/abc123"
const parsed = new URL(url);
router.navigate(parsed.pathname); // navigate to "/invite/abc123"
});
```
`onOpenUrl` fires both on **cold start** (argv contains the URL) and on **duplicate launch** (the primary receives the forwarded URL).
## Testing
### macOS
After running `tynd build --bundle app`, open the `.app` once so Launch Services registers the scheme, then:
```bash
open "myapp://test/path"
```
### Windows
After running the NSIS installer:
```powershell
start myapp://test/path
```
Check registration with:
```powershell
Get-ItemProperty "HKCU:\Software\Classes\myapp"
```
### Linux
After installing the `.deb` / `.rpm`:
```bash
xdg-open "myapp://test/path"
```
Check registration:
```bash
xdg-mime query default x-scheme-handler/myapp
```
During development (outside an installer), you can hand-register for testing:
```bash
# Create a temporary .desktop file
cat > ~/.local/share/applications/myapp-dev.desktop < {
const parsed = new URL(url);
// parsed.protocol === "myapp:"
// parsed.hostname === "invite"
// parsed.pathname === "/abc123"
// parsed.searchParams.get("foo")
if (parsed.hostname === "invite") {
acceptInvite(parsed.pathname.slice(1));
}
});
```
## Multiple schemes
```ts
protocols: ["myapp", "myapp-dev"]
```
Useful if you want a prod + dev scheme that route differently. Both are handled by the same `onOpenUrl` callback — inspect `new URL(url).protocol` to branch.
## Security
- **Treat deep-link input as untrusted.** Users and websites can craft arbitrary payloads. Validate path / query against a whitelist before acting.
- **Don't run privileged actions on link open** without confirmation. `myapp://delete-all` is a foot-gun.
- **URL encoding** — browsers encode before launching. `URL` automatically decodes pathname / searchParams.
## File type associations
Not currently exposed as a first-class config field. If you need `.myapp` files to launch your app:
- **macOS** — edit the generated `Info.plist` post-build (`CFBundleDocumentTypes`).
- **Windows** — add registry entries under `HKCU\Software\Classes\.myapp` + `HKCU\Software\Classes\MyApp.Document\shell\open\command`.
- **Linux** — extend the `.desktop` file with `MimeType=application/x-myapp;`.
A future release may expose a `fileAssociations` config field; for now, roll your own post-build step.
## Next
- [Single Instance](/docs/v0.2/guides/single-instance)
- [singleInstance API](/docs/v0.2/api/os/single-instance)
----
URL: https://tynd.kvnpetit.com/docs/v0.2/guides/frontend-frameworks
TITLE: Frontend Frameworks
DESCRIPTION: Tynd is framework-agnostic — any framework that builds to a pure SPA works. Detailed notes for React, Vue, Svelte, Solid, Preact, Lit, and Angular.
# Frontend Frameworks
Tynd is **framework-agnostic at runtime** — the frontend is a plain folder of static assets served over `tynd://` in production (or a dev server in development). This guide covers which frameworks the CLI knows how to scaffold, detect, and drive, and what's blocked.
## Summary matrix
Scaffold and production build have been verified for each row. The HMR column reflects the **default** experience after `tynd create`.
| Framework | `tynd create` | `tynd build` | Binary launches | Fast Refresh (HMR) | Scaffold source |
|---|---|---|---|---|---|
| React | ✅ | ✅ | ✅ | ⚠ OK; breaks if [React Compiler](#react) is enabled | Vite `react-ts` |
| Vue | ✅ | ✅ | ✅ | ✅ | Vite `vue-ts` |
| Svelte | ✅ | ✅ | ✅ | ✅ | Vite `svelte-ts` |
| Solid | ✅ | ✅ | ✅ | ✅ | Vite `solid-ts` |
| Preact | ✅ | ✅ | ✅ | ✅ | Vite `preact-ts` |
| Lit | ✅ | ✅ | ✅ | ♻ Full reload — [by design](#lit) | Vite `lit-ts` |
| Angular | ✅ | ✅ | ✅ | ♻ Full reload by default — [see note](#angular) | Angular CLI |
```bash
tynd create my-app --framework --runtime
```
## Build tools detected by `tynd init`
`tynd init` adds Tynd to an existing project by inspecting `package.json`. It identifies the build tool from the dependency graph and fills `frontendDir` / dev command accordingly:
| Build tool | Trigger dep | `devUrl` (default) | `outDir` (default) |
|---|---|---|---|
| Vite | `vite` or `@vitejs/*` | `http://localhost:5173` | `dist` |
| Create React App | `react-scripts` | `http://localhost:3000` | `build` |
| Angular CLI | `@angular/cli` | `http://localhost:4200` | `dist//browser` |
| Parcel | `parcel` | `http://localhost:1234` | `dist` |
| Rsbuild | `@rsbuild/core` | `http://localhost:3000` | `dist` |
| Webpack | `webpack` + `webpack-cli` | `http://localhost:8080` | `dist` |
## Blocked — SSR / server-owning frameworks
Tynd owns the HTTP layer (`tynd://` custom protocol) and packages the app as a pure SPA. Frameworks that need Node/Deno/Bun running on the end-user's machine are incompatible:
| Framework | SPA alternative |
|---|---|
| **Next.js** | Use Vite + React |
| **Nuxt** | Use Vite + Vue |
| **SvelteKit** | Plain Svelte (`tynd create … -f svelte`) |
| **Remix** | Vite + React Router SPA |
| **Gatsby** | Any other SSG → static `dist/` |
| **SolidStart** | Plain Solid |
| **Angular Universal** | Plain Angular |
| **Qwik City**, **Astro**, **TanStack Start**, **Vike** | Their SPA variants |
`tynd init` / `tynd dev` / `tynd build` exit with an error when any of these are found in `dependencies` / `devDependencies`.
---
## React
- Scaffolded via `bun create vite@latest --template react-ts` → React 19 + TypeScript.
- HMR via `@vitejs/plugin-react@6` (oxc-based Fast Refresh).
### React Compiler caveat
The Vite template ships `babel-plugin-react-compiler` and `@rolldown/plugin-babel` as devDependencies, and the generated `vite.config.ts` wires them after `react()`:
```ts
plugins: [react(), babel({ presets: [reactCompilerPreset()] })]
```
**This breaks HMR in dev.** `@vitejs/plugin-react@6` uses oxc to inject Fast Refresh markers (`$RefreshReg$`, `$RefreshSig$`); `@rolldown/plugin-babel` then re-parses the output and strips them.
Enable the Compiler **only in build mode** so dev HMR stays intact:
```ts filename="vite.config.ts"
import { defineConfig } from "vite";
import react, { reactCompilerPreset } from "@vitejs/plugin-react";
import babel from "@rolldown/plugin-babel";
export default defineConfig(({ command }) => ({
plugins: [
react(),
...(command === "build"
? [babel({ presets: [reactCompilerPreset()] })]
: []),
],
}));
```
Tradeoff: dev components are not memoized (matches every pre-Compiler React app). Production output is.
## Vue
- Scaffolded via `bun create vite@latest --template vue-ts` → Vue 3 + TypeScript + `vue-tsc`.
- HMR via `@vitejs/plugin-vue`. No known issues.
- `build:ui` script uses `vue-tsc -b && vite build`.
## Svelte
- Scaffolded via `bun create vite@latest --template svelte-ts` → Svelte 5 + TypeScript + `svelte-check`.
- HMR via `@sveltejs/vite-plugin-svelte`. No known issues.
**Do not install `@sveltejs/kit`** — it's SSR and will be rejected by `tynd init` / `tynd dev` / `tynd build`.
## Solid
- Scaffolded via `bun create vite@latest --template solid-ts` → Solid 1.x + TypeScript.
- HMR via `vite-plugin-solid`. No known issues.
**Do not install `@solidjs/start`** — SSR framework, blocked.
## Preact
- Scaffolded via `bun create vite@latest --template preact-ts` → Preact + TypeScript.
- HMR via `@preact/preset-vite`. Uses Preact's own refresh implementation (separate from React's Fast Refresh) — the React Compiler caveat above does **not** apply here.
## Lit
- Scaffolded via `bun create vite@latest --template lit-ts` → Lit 3 + TypeScript.
- **No Fast Refresh.** Vite performs a full page reload on any change. This is intentional: Web Components persist state in custom element registrations and shadow DOM, and cannot be hot-swapped safely.
- Not a Tynd limitation — the same applies in a plain Vite + Lit project outside Tynd.
## Angular
- Scaffolded via `bunx @angular/cli@latest new --defaults --skip-git --skip-install --ssr=false`.
- Dev server: `bunx ng serve` on `http://localhost:4200`.
- **HMR behavior.** `ng serve` **full-reloads by default**. Opt into component-level HMR via `devCommand`:
```ts filename="tynd.config.ts"
export default {
// ...
devCommand: "bunx ng serve --hmr",
} satisfies TyndConfig;
```
HMR reliability is version-dependent; test before relying on it.
- **`frontendDir` resolution:** Tynd reads `angular.json`, finds the first `projectType: "application"`, and combines `outputPath` with the builder:
- `@angular/build:application` (Angular 17+) → `/browser`
- older builders → ``
**Do not install `@angular/platform-server`, `@nguniversal/express-engine`, or `@analogjs/*`** — SSR variants, blocked.
## Adding a new framework
Two extension points in the CLI source (see the Tynd monorepo):
1. **`FRAMEWORKS` array** in `packages/cli/src/commands/create.ts` — what `tynd create` can scaffold.
2. **`detectFrontend` + `TOOL_COMMANDS` + `DEFAULT_OUT_DIR`** in `packages/cli/src/lib/detect.ts` — what `tynd init` / `tynd dev` / `tynd build` recognize in an existing project.
If the new framework is SSR (owns a server process at end-user runtime), add its entry package to `SERVER_FRAMEWORKS` instead — it will fail-fast with a clear message directing to the SPA alternative.
## Next
- [Backend RPC](/docs/v0.2/guides/backend-rpc)
- [Development Workflow](/docs/v0.2/getting-started/dev-workflow)
----
URL: https://tynd.kvnpetit.com/docs/v0.2/guides/i18n
TITLE: Internationalization
DESCRIPTION: Detect the user's locale, localize UI strings, format dates and numbers — in both `lite` (stubbed Intl) and `full` (full ICU).
# Internationalization
Tynd exposes the OS locale via `os.locale()` and the dark-mode preference via `os.isDarkMode()`. Beyond that, i18n is just "pick a lib and go" — with one caveat: **`Intl.*` is stubbed in lite**.
## Detect the user's locale
```ts
import { os } from "@tynd/core/client";
const locale = await os.locale(); // "en-US", "fr-FR", "ja-JP", …
```
BCP-47 tag, matches what the OS reports. Read at app startup; re-read if you support "language" preference changes (OS-level language changes require app restart on all three OSes).
## UI strings — pick a lib
Any pure-JS i18n library works:
| Lib | Size | Notes |
|---|---|---|
| [`i18next`](https://www.i18next.com) | ~40 KB | Ecosystem leader. React-i18next, Vue-i18n integrations. |
| [`format-message`](https://formatjs.io/docs/format-js/) | ~15 KB | ICU MessageFormat syntax. |
| [`@lingui/core`](https://lingui.dev) | ~10 KB | Lightweight; compile-time extraction. |
| **Rolling your own** | ~0 KB | Flat JSON + a `t("key")` function — fine for small apps. |
### Minimal roll-your-own
```ts filename="src/i18n.ts"
import { os } from "@tynd/core/client";
import en from "./locales/en.json";
import fr from "./locales/fr.json";
const catalogs: Record> = { en, fr };
let currentLocale = "en";
export async function initI18n() {
const full = await os.locale();
const short = full.split("-")[0];
currentLocale = catalogs[short] ? short : "en";
}
export function t(key: string, vars?: Record) {
let s = catalogs[currentLocale][key] ?? key;
if (vars) {
for (const [k, v] of Object.entries(vars)) {
s = s.replace(`{${k}}`, v);
}
}
return s;
}
```
```json filename="src/locales/en.json"
{
"greeting": "Hello, {name}!",
"save": "Save",
"cancel": "Cancel"
}
```
```json filename="src/locales/fr.json"
{
"greeting": "Bonjour, {name} !",
"save": "Enregistrer",
"cancel": "Annuler"
}
```
## Dates + numbers
### Full runtime
`Intl.DateTimeFormat`, `Intl.NumberFormat`, `Intl.RelativeTimeFormat`, `Intl.Collator`, `Intl.Segmenter` — all available, with full ICU locale data.
```ts
const formatter = new Intl.DateTimeFormat("fr-FR", { dateStyle: "long" });
formatter.format(new Date()); // "25 avril 2026"
```
### Lite runtime
QuickJS ships a **stub** — `Intl.DateTimeFormat` exists but ignores locale-specific rules. Workaround: bundle a pure-JS date lib.
```ts
import { format } from "date-fns";
import { fr, ja, enUS } from "date-fns/locale";
const localeMap = { fr, ja, en: enUS };
const locale = localeMap[currentLocale.split("-")[0]] ?? enUS;
format(new Date(), "PPP", { locale }); // "25 avril 2026"
```
For numbers, [`numbro`](https://numbrojs.com/) or rolling your own with a locale-specific separator map works.
See [Alternatives](https://github.com/kvnpetit/tynd/blob/main/ALTERNATIVES.md) for the full list of pure-JS libs that replace `Intl`.
## RTL layouts
The WebView handles `dir="rtl"` natively:
```html
```
Toggle at runtime by setting `document.dir = "rtl" | "ltr"` when the user switches language.
CSS logical properties make this easier:
```css
.card {
padding-inline-start: 1rem; /* margin-left in LTR, margin-right in RTL */
border-inline-end: 1px solid #ccc;
}
```
## Plurals
ICU MessageFormat (`{count, plural, one {# item} other {# items}}`) is the standard. `format-message`, `i18next`, `@lingui/core` all support it. Rolling your own for plurals is harder than for basic substitution — reach for a lib.
## Persisting the user's choice
```ts
import { createStore } from "@tynd/core/client";
const prefs = createStore("com.example.myapp");
export async function setLanguage(lang: string) {
await prefs.set("lang", lang);
currentLocale = lang;
// re-render the app
}
export async function initI18n() {
const saved = await prefs.get("lang");
const full = await os.locale();
currentLocale = saved ?? full.split("-")[0];
}
```
## Installer / bundle metadata
The NSIS bundler on Windows accepts a list of languages:
```ts filename="tynd.config.ts"
bundle: {
nsis: {
languages: ["English", "French", "Japanese"],
},
}
```
This controls the installer wizard's language, not the app's. Your app's language is whatever you initialize from `os.locale()` / user preference.
## OS-integrated surfaces
Some UI is system-rendered and uses the OS language regardless of your app's preference:
- **Native dialogs** (`dialog.openFile`, `dialog.confirm`) — button labels ("Open", "Save", "Cancel") come from the OS.
- **System tray** — OS-native.
- **Notifications** — your title/body are shown as-is, but "Quit" on an interactive notification (if you add one in a future release) uses the OS language.
- **Auto-update** — you control the user-facing strings; OS-level installer dialogs (MSI, notarization on macOS) are OS-localized.
## Accessibility intersects
- Screen readers read text in the page's `lang`. Set `` correctly or VoiceOver / Narrator may mispronounce.
- RTL users expect RTL focus order — use logical CSS, not hard-coded left/right.
See [Accessibility](/docs/v0.2/guides/accessibility).
## Related
- [os.locale API](/docs/v0.2/api/os/os).
- [Runtimes](/docs/v0.2/runtimes) — what's in `Intl` per runtime.
----
URL: https://tynd.kvnpetit.com/docs/v0.2/guides/icons-branding
TITLE: Icons & Branding
DESCRIPTION: One source of truth for your app icon — SVG preferred. Tynd renders per-format sizes for Windows ICO, macOS ICNS, Linux hicolor.
# Icons & Branding
Tynd uses **one icon source** and renders every output format at build time. No manual multi-size ICO, no separate macOS ICNS step.
## Source icon
Put one file in `public/`:
```
public/favicon.svg ← preferred
public/favicon.png ← fallback
public/favicon.ico ← Windows-only; skipped for macOS/Linux
```
Alternative locations auto-detected in this order:
1. `public/{favicon,icon,logo}.svg`
2. `public/{favicon,icon,logo}.{ico,png}`
3. `assets/icon.{svg,png,ico}`
4. `icon.{svg,png,ico}`
Override via `icon` in `tynd.config.ts`:
```ts
export default {
icon: "./branding/app-icon.svg",
} satisfies TyndConfig;
```
## What's rendered per format
| Target | Sizes | Notes |
|---|---|---|
| Windows ICO (raw `.exe` + NSIS + MSI) | 16, 32, 48, 256 | Embedded via ResEdit. Also embedded into the inner Bun copy (full mode) so Task Manager shows the right icon. |
| macOS ICNS (`.app`) | 32, 128, 256, 512, 1024 | One entry per size bucket. |
| Linux hicolor (`.deb`, `.rpm`, `.AppImage`) | 16, 32, 48, 64, 128, 256, 512 | Dropped into `usr/share/icons/hicolor/x/apps/.png`. |
## Why SVG
- **Pixel-perfect at every size** — rasterized at render time per DPI bucket.
- **Small on disk** — typically 2-10 KB.
- **Editable** — you can tweak colors in an SVG editor and rebuild in seconds.
PNG source degrades to single-size (native resolution). ICO source passes through to Windows bundles only — macOS/Linux bundlers skip with a warning.
## SVG gotchas
- **Must be square.** Tynd auto-wraps non-square SVGs in a square viewBox (Windows PE + macOS ICNS reject/distort non-square inputs). Design your icon with a square canvas.
- **Avoid ``, ``, heavy ``.** Some rasterizers (resvg) fall back or skip. Simple paths + solid fills render the same everywhere.
- **Flatten external fonts.** `` with a web font won't render identically in the rasterizer. Convert text to paths in your SVG editor.
- **Use absolute coordinates.** Relative paths (`d="m 1 1 l 2 3"`) work in browsers but some rasterizers mis-handle them.
## Taskbar / Dock
The icon shown in the taskbar (Windows) / Dock (macOS) / activities view (Linux) is the same as your build icon — Tynd doesn't expose a separate runtime icon API yet.
## Window icon at runtime
Not currently exposed. If you want to change the window icon after launch (e.g. show an "unread" badge), track the feature request on GitHub.
## Installer imagery (Windows)
NSIS wizard imagery is separate from the app icon:
```ts filename="tynd.config.ts"
bundle: {
nsis: {
headerImage: "./installer-header.bmp", // 150×57 BMP, top of wizard
welcomeImage: "./installer-welcome.bmp", // 164×314 BMP, welcome page
},
}
```
Must be 24-bit BMP (NSIS is strict). Optional — default is no imagery.
## Splash screen
Not currently a first-class feature. If your app needs a splash, render it in the frontend — your first-paint HTML is the splash. Once `app.onReady` fires (DOMContentLoaded), swap to the real UI.
```tsx filename="src/App.tsx"
export default function App() {
const [ready, setReady] = useState(false);
useEffect(() => {
// heavy init
initApp().then(() => setReady(true));
}, []);
return ready ? : ;
}
```
## App name in installers
| Source | Used in |
|---|---|
| `package.json::name` | npm metadata, binary filename |
| `bundle.identifier` (reverse-DNS) | macOS bundle ID, Windows registry key |
| `package.json::description` | `.deb` / `.rpm` summary |
| `package.json::author` | Installer publisher |
| `package.json::homepage` | Installer "more info" link |
Override any of these via `bundle.*` fields in `tynd.config.ts`.
## Brand consistency checklist
### One source file in `public/`
SVG preferred, square canvas.
### Set `bundle.identifier` in `tynd.config.ts`
Reverse-DNS (`com.yourco.myapp`). Required for `tynd build --bundle`.
### Update `package.json` metadata
`name`, `description`, `author`, `homepage` — they all flow into installer metadata.
### Verify on each platform
```bash
tynd build --bundle
```
Open the `.app` / installer and check Finder / File Explorer / `.desktop` shows the icon. On Windows, check Task Manager for the running process icon (full mode embeds into the Bun subprocess too).
### Lock the icon in a CI check
If you want to guarantee your icon survives a merge:
```bash
test -f public/favicon.svg || { echo "icon missing"; exit 1; }
```
## Related
- [Bundling guide](/docs/v0.2/guides/bundling).
- [tynd.config.ts reference](/docs/v0.2/cli/config).
- [TYNDPKG format](/docs/v0.2/concepts/tyndpkg) — how the icon gets embedded.
----
URL: https://tynd.kvnpetit.com/docs/v0.2/guides/keyboard-shortcuts
TITLE: Keyboard Shortcuts
DESCRIPTION: Register system-wide global shortcuts that fire even when the app is unfocused.
# Keyboard Shortcuts
Global shortcuts register with the OS and fire **even when your app is unfocused**. Typical uses: quick-capture / scratchpad apps (`Cmd+Shift+N`), toggle-visibility apps (`F12`), press-to-talk, clipboard manager hotkeys.
## API
```ts
import { shortcuts, tyndWindow } from "@tynd/core/client";
const handle = await shortcuts.register("CmdOrCtrl+Shift+P", () => {
tyndWindow.setFocus();
}, "open-palette");
const ok = await shortcuts.isRegistered("open-palette"); // true
await handle.unregister();
// Or bulk:
await shortcuts.unregisterAll();
```
- First arg — accelerator string (standard format — `CmdOrCtrl+Shift+P`, etc.).
- Second arg — handler function.
- Third arg — optional stable id for `isRegistered` / later lookup. If omitted, a random id is generated.
## Accelerator format
Uses the standard accelerator syntax's accelerator syntax:
| Modifier | Keys |
|---|---|
| `CmdOrCtrl` | ⌘ on macOS, Ctrl elsewhere |
| `Cmd` / `Super` | Command / Windows key |
| `Ctrl` | Control |
| `Alt` / `Option` | Alt / Option |
| `Shift` | Shift |
Keys:
- Letters: `A-Z`
- Numbers: `0-9`
- Function keys: `F1-F24`
- Named: `Space`, `Tab`, `Escape`, `Enter`, `Backspace`, `Delete`, `Insert`, `Home`, `End`, `PageUp`, `PageDown`, `ArrowUp`, `ArrowDown`, `ArrowLeft`, `ArrowRight`, …
Examples:
- `CmdOrCtrl+S`
- `Alt+F4`
- `Shift+Space`
- `CmdOrCtrl+Shift+P`
- `Ctrl+Alt+Delete` *(blocked by some OSes)*
## What triggers the callback
Global shortcuts fire on **key-down** of the full combo. They fire regardless of focus — even if your app is minimized or in the background.
**Not the same as keyboard events in the WebView.** Use DOM `keydown` listeners for in-app shortcuts (Ctrl+F find, etc.). Global shortcuts are for chord-like system-wide hotkeys.
## Gotchas
### Conflicts
If another process (or the OS itself) has already registered the same shortcut, `register()` throws. Either pick a different combo or catch and report to the user.
```ts
try {
await shortcuts.register("CmdOrCtrl+Shift+P", handler);
} catch (err) {
alert("That shortcut is in use. Pick another.");
}
```
### Wayland (Linux)
Global hotkeys on Wayland need the `org.freedesktop.portal.GlobalShortcuts` portal, which not every compositor implements. On Wayland compositors without portal support, `register()` may silently fail or never fire. X11 sessions work fine via `XGrabKey`.
### macOS — Input Monitoring permission
On macOS Monterey and later, the OS may prompt the user to grant **Input Monitoring** permission on first registration. Registration succeeds regardless; the callback won't fire until the user accepts. The prompt appears in System Settings → Privacy & Security → Input Monitoring.
### Unregister on app exit
The OS auto-releases registrations on process exit, so you don't need to clean up manually — but you *should* call `shortcuts.unregisterAll()` during `app.onClose` if you want to be explicit.
## Example — toggle window visibility
```ts
import { shortcuts, tyndWindow } from "@tynd/core/client";
let visible = true;
await shortcuts.register("CmdOrCtrl+Shift+Space", async () => {
visible = !visible;
if (visible) {
await tyndWindow.show();
await tyndWindow.setFocus();
} else {
await tyndWindow.hide();
}
}, "toggle-visibility");
```
## Example — user-configurable
Let users pick their own hotkey:
```ts
import { shortcuts, createStore } from "@tynd/core/client";
const prefs = createStore("com.example.myapp");
const DEFAULT = "CmdOrCtrl+Shift+P";
async function bindQuickOpen(accel: string) {
await shortcuts.unregisterAll(); // or track the prior handle
try {
await shortcuts.register(accel, onQuickOpen, "quick-open");
await prefs.set("shortcuts.quickOpen", accel);
} catch {
// fall back to default
await shortcuts.register(DEFAULT, onQuickOpen, "quick-open");
}
}
const saved = (await prefs.get("shortcuts.quickOpen")) ?? DEFAULT;
await bindQuickOpen(saved);
```
## Next
- [shortcuts API](/docs/v0.2/api/os/shortcuts)
- [Menu guide](/docs/v0.2/api/os/menu) — menu accelerators (app-focused, not global)
----
URL: https://tynd.kvnpetit.com/docs/v0.2/guides/migrate-from-electron
TITLE: Migrate from Electron
DESCRIPTION: Move an Electron app to Tynd — replace `ipcMain.handle` with backend `export`s, drop the 160 MB Chromium, keep your TypeScript.
# Migrating from Electron
Electron apps ship the whole Chromium stack + Node.js. Moving to Tynd cuts your binary from ~160 MB to ~7-44 MB and simplifies the main/renderer split.
## The big differences
| Electron | Tynd |
|---|---|
| Bundled Chromium | System WebView (WebView2 / WKWebView / WebKitGTK) |
| Full Node.js in main process | Bun (`full` runtime) or QuickJS (`lite` runtime) |
| `ipcMain.handle("cmd", fn)` + `ipcRenderer.invoke("cmd", …)` | `export async function cmd(…)` + `api.cmd(...)` (typed) |
| `webContents.send("evt", …)` | `events.emit("evt", …)` |
| `contextBridge.exposeInMainWorld` + preload | **Not needed.** Export = exposure. |
| `BrowserWindow` | `app.start({ window })` / `tyndWindow.create({ label })` |
| `BrowserWindow.loadFile` / `loadURL` | `tynd://localhost/` served from `dist/` |
| `session` API (cookies, cache, proxy) | **Not exposed.** WebView uses OS defaults. |
| `desktopCapturer`, `printToPDF` | **Not available** (WebView limitation). |
## What you inherit
- **Smaller binary** — ~7 MB (lite) or ~44 MB (full) vs Electron's 160-200 MB.
- **Lower memory** — native WebView uses ~30-80 MB idle; Chromium averages 200-400 MB.
- **Same TypeScript** — your frontend ports as-is; only the main-process glue changes.
- **Better typing** — `createBackend()` beats manual typing of `ipcRenderer.invoke` calls.
## What you lose
- **Rendering consistency.** WebView2 version varies by Windows install, WebKitGTK varies by distro, WKWebView tracks macOS. Your CSS/JS feature detection becomes more important.
- **Full Node stdlib in main.** `node:fs`, `node:child_process`, `node:net` — available in `full` runtime, **not** in `lite`. Replace with Tynd OS APIs if you want to go lite.
- **Chromium-only features.** Screen capture (`desktopCapturer`), `printToPDF`, `findInPage`, built-in spellchecker, Chrome extensions, Touch Bar (macOS), StoreKit IAP. Most have no direct Tynd equivalent.
- **Bundled runtime.** Users' OS WebView version matters. Windows 10 VMs without WebView2 fail at launch — ship an Evergreen Bootstrapper on Windows or document the requirement.
## Step-by-step
### Decide on runtime: `lite` or `full`
- **`lite`** — if your main process only uses `fs`, `path`, `http`, basic stdlib. Smallest binary.
- **`full`** — if you have npm deps with native bindings (`sharp`, `better-sqlite3`, `canvas`, `bcrypt`), or use specific Bun/Node APIs.
Start with `lite` unless you know you need `full`.
### Init Tynd
In your existing project:
```bash
bunx @tynd/cli init
```
This detects your frontend build tool, writes `tynd.config.ts`, scaffolds `backend/main.ts`.
### Port the main process
Electron main processes typically look like this:
```ts
// OLD — main.ts (Electron)
import { app, BrowserWindow, ipcMain } from "electron";
import { readFile } from "node:fs/promises";
ipcMain.handle("read-config", async () => {
const body = await readFile("./config.json", "utf8");
return JSON.parse(body);
});
app.whenReady().then(() => {
const win = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
contextIsolation: true,
preload: "preload.js",
},
});
win.loadFile("dist/index.html");
});
```
Tynd equivalent:
```ts filename="backend/main.ts"
import { app } from "@tynd/core";
import { fs } from "@tynd/core/client";
export async function readConfig() {
return JSON.parse(await fs.readText("./config.json"));
}
app.start({
window: { width: 1200, height: 800 },
});
```
Gone:
- `BrowserWindow` — `app.start` creates the window.
- `contextIsolation` / `preload` — Tynd injects IPC shims only. No Node globals leak into the renderer.
- `ipcMain.handle` — every `export` is an RPC method.
### Port the renderer
```ts
// OLD — renderer.ts (Electron + contextBridge)
const config = await window.api.readConfig();
// preload.ts had:
// contextBridge.exposeInMainWorld("api", {
// readConfig: () => ipcRenderer.invoke("read-config"),
// });
```
```ts
// NEW — src/main.ts (Tynd)
import { createBackend } from "@tynd/core/client";
import type * as backend from "../backend/main";
const api = createBackend();
const config = await api.readConfig();
```
Zero preload, zero glue, types flow from `typeof backend`.
### Port events
```ts
// OLD — Electron
// main.ts
win.webContents.send("progress", 0.42);
// renderer.ts
window.api.onProgress((pct) => render(pct));
```
```ts
// NEW — Tynd
// backend/main.ts
import { createEmitter } from "@tynd/core";
export const events = createEmitter<{ progress: number }>();
events.emit("progress", 0.42);
// frontend
api.on("progress", (pct) => render(pct));
```
### Port Node APIs
In `full` mode, most Node APIs work unchanged. In `lite`, replace with Tynd OS APIs:
| Node / Electron | Tynd OS API (works in both runtimes) |
|---|---|
| `node:fs` → `readFile`, `writeFile`, `mkdir`, `rm`, `stat` | `fs.readText`, `fs.writeText`, `fs.mkdir`, `fs.remove`, `fs.stat` |
| `node:fs` → `readFile(bytes)` | `fs.readBinary` (uses zero-copy channel) |
| `node:path` | `path.join`, `path.resolve`, `path.normalize`, … |
| `node:child_process` → `spawn`, `exec` | `process.exec`, `process.execShell` |
| `node:os` → `homedir`, `tmpdir`, `platform`, `arch` | `os.homeDir`, `os.tmpDir`, `os.info()` |
| `node:http` / `fetch` | `http.get/post/put/…` or built-in `fetch` |
| `node:crypto` → `createHash` | `compute.hash` (faster, off-thread) |
| `node:crypto` → `randomBytes` | `compute.randomBytes` |
| Electron `session` → cookies | **No replacement.** Use `keyring` for tokens, `store` for other state. |
| Electron `safeStorage` | [`keyring`](/docs/v0.2/api/os/keyring) — OS credential manager |
| Electron `shell.openExternal` | [`shell.openExternal`](/docs/v0.2/api/os/shell) |
| Electron `clipboard` | [`clipboard`](/docs/v0.2/api/os/clipboard) |
| Electron `dialog` | [`dialog`](/docs/v0.2/api/os/dialog) |
| Electron `Notification` | [`notification.send`](/docs/v0.2/api/os/notification) |
| Electron `Tray` | declare `tray` in `app.start`, handle in [`tray`](/docs/v0.2/api/os/tray) |
| Electron `Menu` | declare `menu` in `app.start`, handle in [`menu`](/docs/v0.2/api/os/menu) |
| Electron `globalShortcut` | [`shortcuts`](/docs/v0.2/api/os/shortcuts) |
| Electron `autoUpdater` | [`updater`](/docs/v0.2/api/os/updater) (Tauri-compatible manifest) |
### What has no Tynd replacement
- **`desktopCapturer`** (screen capture). No alternative. Use a native CLI via `sidecar` (e.g. `ffmpeg`).
- **`printToPDF`** (webview to PDF). Use a pure-JS lib like `jspdf`, or render server-side via `sidecar`.
- **`findInPage`**. Implement in JS with a content-search overlay on your app HTML.
- **Built-in spellcheck**. Use `nspell` + Hunspell dictionaries, or the browser's native spellcheck attribute.
- **Chrome extensions**. Not supported by the WebView stack.
- **Touch Bar (macOS)**. Not exposed.
- **StoreKit IAP**. Not exposed.
### Port the build
Replace your Electron build tooling (electron-builder, electron-forge) with `tynd build --bundle`:
```bash
tynd build --bundle # .app, .dmg, .deb, .rpm, .AppImage, NSIS, MSI for host OS
```
See [Bundling](/docs/v0.2/guides/bundling).
### Port CI
Remove Electron-specific steps (Electron download cache, ASAR packaging). Use the matrix pattern from the Bundling guide — `tynd build --bundle` per OS.
### Port code signing
If you were signing Electron builds, your certs work as-is — `signtool` and `codesign` are the same tools. Just declare `bundle.sign` in `tynd.config.ts` and `tynd build` handles signing + (optional) notarization. See [Code Signing](/docs/v0.2/guides/code-signing).
### Port the updater
Tynd's updater uses the **Tauri-compatible** manifest format. If you were already using [`electron-updater`](https://www.electron.build/auto-update), you'll need a new manifest (different shape). But if you were using a custom update flow on top of `autoUpdater`, the primitives are similar:
- Fetch manifest JSON.
- Compare versions.
- Download + verify signature.
- Swap + relaunch.
See [Auto-Updates](/docs/v0.2/guides/auto-updates).
## Frontend compatibility
- **Works as-is** — any framework that builds to a pure SPA. Vite + React / Vue / Svelte / Solid / Preact / Lit / Angular.
- **Won't work** — SSR frameworks (Next.js, Nuxt, SvelteKit, Remix, …). Use the SPA variant.
- **WebView differences** — no `window.electronAPI` unless you re-export it yourself on top of `createBackend`.
## Security model difference
Electron's security relies on `contextIsolation` + preload scripts exposing a minimal API. Tynd's security is **structural**: the frontend can only call what the backend exports. There's no separate preload boundary to maintain; if it's not `export`ed from `backend/main.ts`, the frontend can't reach it.
See [Security concept](/docs/v0.2/concepts/security).
## Related
- [vs Electron comparison](/docs/v0.2/compare/vs-electron).
- [Full 500-row matrix](https://github.com/kvnpetit/tynd/blob/main/COMPARISON.md).
----
URL: https://tynd.kvnpetit.com/docs/v0.2/guides/migrate-from-tauri
TITLE: Migrate from Tauri
DESCRIPTION: Move a Tauri v2 app to Tynd — rewrite `#[tauri::command]`s as backend `export`s, port events to `createEmitter`, keep Rust out of your source tree.
# Migrating from Tauri v2
Tauri and Tynd share the **same native stack** (wry + tao) and many of the same IPC primitives. The biggest change is the backend language: **Rust → TypeScript**. You stop maintaining two languages.
## What changes
| Tauri v2 | Tynd |
|---|---|
| `#[tauri::command] fn greet(name: String) -> String` | `export async function greet(name: string): Promise` |
| `invoke("greet", { name })` | `await api.greet(name)` (typed via `typeof backend`) |
| `app.emit("event", payload)` | `events.emit("event", payload)` (typed via `createEmitter`) |
| `await listen("event", handler)` | `api.on("event", handler)` |
| Capability ACLs in `capabilities/*.toml` | **No equivalent.** Export = exposure; do auth/authz in backend logic. |
| Plugins (fs, http, shell, dialog, …) | Built-in OS APIs in `@tynd/core/client`. |
| `src-tauri/` with `Cargo.toml` | **Removed.** TypeScript only. |
| `tauri.conf.json` | `tynd.config.ts` (TypeScript, validated by valibot). |
## What stays the same
- **Native WebView** (WebView2 / WKWebView / WebKitGTK) — identical rendering target.
- **Zero-network IPC** — custom scheme, no TCP port, no firewall prompt.
- **Code signing** — `signtool` / `codesign` / `notarytool` — same tools.
- **Updater manifest format** — Tynd is **Tauri-compatible** (`version`, `pub_date`, `notes`, `platforms.-.{url,signature}`). Reuse existing manifests.
- **Ed25519 update signatures** — same primitive, same verification path.
## Step-by-step
### Init Tynd next to Tauri
Don't delete `src-tauri/` yet. Run:
```bash
bunx @tynd/cli init
```
This:
- Adds `@tynd/cli`, `@tynd/core`, `@tynd/host` to `package.json`.
- Writes `tynd.config.ts` pointing at your existing frontend `outDir`.
- Scaffolds `backend/main.ts` with a minimal `app.start`.
### Port commands
For each `#[tauri::command]` in `src-tauri/src/`, write a TypeScript equivalent in `backend/main.ts`:
```rust
// src-tauri/src/lib.rs — OLD
#[tauri::command]
async fn greet(name: String) -> Result {
Ok(format!("Hello, {}!", name))
}
#[tauri::command]
async fn read_config(app: AppHandle) -> Result {
let path = app.path().app_config_dir().unwrap().join("config.json");
let body = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
serde_json::from_str(&body).map_err(|e| e.to_string())
}
```
```ts filename="backend/main.ts — NEW"
import { app } from "@tynd/core";
import { os, path, fs } from "@tynd/core/client";
export async function greet(name: string): Promise {
return `Hello, ${name}!`;
}
export async function readConfig(): Promise {
const configDir = await os.configDir();
const p = await path.join(configDir ?? "", "myapp", "config.json");
return JSON.parse(await fs.readText(p));
}
app.start({
window: { title: "My App", width: 1200, height: 800 },
});
```
Notice:
- No `Result` — throw exceptions; they reject the frontend promise.
- No `#[derive(Serialize, Deserialize)]` — `T` flows through `typeof backend`.
- No `AppHandle` — Tynd OS APIs are free functions (`os.configDir()`, `path.join(...)`, `fs.readText(...)`).
### Port frontend calls
```ts
// OLD
import { invoke } from "@tauri-apps/api/core";
const msg = await invoke("greet", { name: "Alice" });
// NEW
import { createBackend } from "@tynd/core/client";
import type * as backend from "../backend/main";
const api = createBackend();
const msg = await api.greet("Alice");
```
The Tynd version is typed from `typeof backend` — no `` annotation needed.
### Port events
```rust
// OLD — backend
app.emit("user-created", serde_json::json!({ "id": "1", "name": "Alice" }))?;
```
```ts
// NEW — backend/main.ts
import { createEmitter } from "@tynd/core";
export const events = createEmitter<{
userCreated: { id: string; name: string };
}>();
events.emit("userCreated", { id: "1", name: "Alice" });
```
```ts
// OLD — frontend
import { listen } from "@tauri-apps/api/event";
await listen<{ id: string; name: string }>("user-created", (e) => { /* e.payload.id */ });
// NEW — frontend
api.on("userCreated", (user) => { /* user.id, user.name — typed */ });
```
### Port plugin calls
| Tauri plugin | Tynd equivalent |
|---|---|
| `@tauri-apps/plugin-fs` | [`fs`](/docs/v0.2/api/os/fs) |
| `@tauri-apps/plugin-http` | [`http`](/docs/v0.2/api/os/http) (or `fetch`) |
| `@tauri-apps/plugin-shell` | [`shell`](/docs/v0.2/api/os/shell) + [`process`](/docs/v0.2/api/os/process) |
| `@tauri-apps/plugin-dialog` | [`dialog`](/docs/v0.2/api/os/dialog) |
| `@tauri-apps/plugin-clipboard-manager` | [`clipboard`](/docs/v0.2/api/os/clipboard) |
| `@tauri-apps/plugin-notification` | [`notification`](/docs/v0.2/api/os/notification) |
| `@tauri-apps/plugin-os` | [`os`](/docs/v0.2/api/os/os) |
| `@tauri-apps/plugin-process` | [`process`](/docs/v0.2/api/os/process) + [`app.exit`](/docs/v0.2/api/os/app) |
| `@tauri-apps/plugin-global-shortcut` | [`shortcuts`](/docs/v0.2/api/os/shortcuts) |
| `@tauri-apps/plugin-autostart` | [`autolaunch`](/docs/v0.2/api/os/autolaunch) |
| `@tauri-apps/plugin-deep-link` | [`singleInstance.onOpenUrl`](/docs/v0.2/api/os/single-instance) + `protocols` in config |
| `@tauri-apps/plugin-single-instance` | [`singleInstance`](/docs/v0.2/api/os/single-instance) |
| `@tauri-apps/plugin-updater` | [`updater`](/docs/v0.2/api/os/updater) — same manifest format |
| `@tauri-apps/plugin-store` | [`store`](/docs/v0.2/api/os/store) |
| `@tauri-apps/plugin-sql` | [`sql`](/docs/v0.2/api/os/sql) (bundled SQLite) |
| `@tauri-apps/plugin-websocket` | [`websocket`](/docs/v0.2/api/os/websocket) (or native `WebSocket`) |
Tynd doesn't ship equivalents for: Stronghold, biometric, NFC, positioner, persisted-scope, log. For those, either use pure-JS libs or write your own wrapper on top of the available primitives.
### Port window config
```json
// OLD tauri.conf.json
{
"app": {
"windows": [
{
"title": "My App",
"width": 1200,
"height": 800,
"center": true,
"resizable": true
}
]
}
}
```
```ts
// NEW tynd.config.ts (or app.start config)
app.start({
window: {
title: "My App",
width: 1200,
height: 800,
center: true,
resizable: true,
},
});
```
### Port menu + tray
Menus and trays are declared in `app.start(config)`:
```ts filename="backend/main.ts"
app.start({
menu: [
{
type: "submenu",
label: "File",
items: [
{ label: "New", id: "file.new", accelerator: "CmdOrCtrl+N" },
{ label: "Open", id: "file.open", accelerator: "CmdOrCtrl+O" },
{ type: "separator" },
{ role: "quit" },
],
},
],
tray: {
icon: import.meta.dir + "/../assets/tray.png",
tooltip: "My App",
menu: [
{ label: "Show", id: "show" },
{ label: "Quit", id: "quit" },
],
},
window: { /* ... */ },
});
```
Handle clicks in the frontend:
```ts
import { menu, tray } from "@tynd/core/client";
menu.onClick("file.new", () => createDocument());
tray.onMenu("quit", () => app.exit(0));
```
### Delete `src-tauri/`
Once the Tynd app builds and runs, remove the Tauri sub-crate, the Rust toolchain from CI, and any Tauri-specific plugins from `package.json`.
### Update CI
Replace `tauri-apps/tauri-action` with a direct `tynd build --bundle` step:
```yaml
- run: bunx tynd build --bundle
```
See the [Bundling guide](/docs/v0.2/guides/bundling) for a full matrix example.
## What you lose
- **Capability-based ACLs.** Tauri's `capabilities/*.toml` per-command / per-path / per-URL permissions have no Tynd equivalent. Do access control in backend logic.
- **Mobile (iOS + Android).** Tynd is desktop-only.
- **Official plugin ecosystem.** The 30+ Tauri plugins don't have 1:1 Tynd equivalents. Check the table above; for missing ones, use pure-JS libs or implement on the Tynd primitives.
- **Cross-compilation.** Tauri builds Windows binaries from Linux. Tynd requires the target host OS.
- **Delta updates.** Tynd ships full-binary updates; Tauri supports binary-diff.
## What you gain
- **TypeScript end-to-end.** No Rust in your source tree.
- **Zero-codegen typed RPC.** `typeof backend` — rename a function and the compiler catches every stale call.
- **Dual runtimes.** `lite` (~6.5 MB) vs `full` (~44 MB) with a one-line config change. No Rust equivalent.
- **Simpler security model.** Export = exposure. No ACL config to drift out of sync.
## Related
- [vs Tauri comparison](/docs/v0.2/compare/vs-tauri).
- [Full 500-row matrix](https://github.com/kvnpetit/tynd/blob/main/COMPARISON.md).
----
URL: https://tynd.kvnpetit.com/docs/v0.2/guides/multi-window
TITLE: Multi-Window
DESCRIPTION: Create secondary windows, route events per-window, and clean up state on close.
# Multi-Window
The primary window has label `"main"`. Additional windows are created with a unique label and get their own WebView + IPC channel.
## Create a secondary window
```ts
import { tyndWindow } from "@tynd/core/client";
await tyndWindow.create({
label: "settings",
url: "/settings", // optional — defaults to the primary entry
title: "Settings",
width: 600,
height: 480,
});
```
Every `@tynd/core/client` API call **auto-targets the window it runs in** — no label argument needed.
## Enumerate + close
```ts
const labels = await tyndWindow.all(); // ["main", "settings", ...]
await tyndWindow.close("settings"); // "main" cannot be closed this way
console.log(tyndWindow.label()); // "main" or "settings" depending on caller
```
## Event routing
Window events (`onResized`, `onMoved`, `onFocused`, …) are broadcast to **every webview** with a `label` field in the payload. The `@tynd/core/client/window.ts` helper filters by `__TYND_WINDOW_LABEL__` (injected at WebView creation) so handlers only fire for their own window.
```ts
// Inside the settings window — handler only fires for "settings".
tyndWindow.onResized(({ width, height }) => {
console.log("settings window resized to", width, height);
});
```
Don't try to "listen for the main window's events from the settings window" — the routing logic filters that out. Coordinate cross-window state via the backend instead.
## Cross-window coordination
Two patterns:
### Via the backend (recommended)
The backend is a single JS context shared by every window. Use an emitter as the event bus:
```ts filename="backend/main.ts"
export const events = createEmitter<{
themeChanged: { theme: "light" | "dark" };
}>();
export async function setTheme(theme: "light" | "dark") {
events.emit("themeChanged", { theme });
// persist via store, etc.
}
```
```ts
// any window
api.on("themeChanged", ({ theme }) => applyTheme(theme));
api.setTheme("dark");
```
`emit` broadcasts to every subscribed webview. Combined with per-window event filtering, each window gets the update.
### Via `store` + manual refresh
For state that doesn't change often, write to `store` from the producer and re-read from the consumer on explicit refresh triggers.
## Per-window routes
Frontend routing (React Router, Vue Router, Svelte kit-free router, …) decides what's rendered inside each window. Pass the desired route as `url`:
```ts
await tyndWindow.create({ label: "about", url: "/about", title: "About" });
```
Inside the `about` window your router sees `window.location.pathname === "/about"` and renders the corresponding view.
## Per-window RPC + streaming
Every window uses the same `createBackend()` import — each call / stream is independent. Streams originating in a window are automatically canceled when that window closes (`cancel_streams_for_label` in the Rust host).
## Window lifecycle
```ts
// in any window
tyndWindow.onCloseRequested((e) => {
if (hasUnsavedChanges()) {
e.preventDefault();
void showSavePrompt();
}
});
// manual cancel e.g. from a modal opened elsewhere during the 500ms window:
await tyndWindow.cancelClose();
```
- `onCloseRequested` lets you preventDefault() synchronously to cancel the close.
- If nothing cancels within 500 ms, the close proceeds.
- If you `preventDefault`, call `tyndWindow.close(label)` or `tyndWindow.destroy()` yourself when ready.
## Sizing, positioning, monitors
Each window has its own geometry:
```ts
await tyndWindow.setSize(1400, 900);
await tyndWindow.setPosition(100, 100);
const pos = await tyndWindow.getPosition();
const size = await tyndWindow.getSize();
const dpi = await tyndWindow.scaleFactor(); // 1.0 / 1.5 / 2.0
```
Enumerate monitors:
```ts
import { monitors } from "@tynd/core/client";
const all = await monitors.all();
const primary = await monitors.primary();
const current = await monitors.current(); // where the calling window is
```
## Gotchas
- **`tyndWindow.close("main")`** is a no-op. To quit the app, call `app.exit(0)` from the backend or the user clicks the OS close button.
- **Window events are broadcast**, not routed. Always rely on `tyndWindow.on*` helpers, not manually subscribing to raw `window:*` events, so the label filtering does its job.
- **Streams don't survive window close.** If a user closes Settings mid-stream, the generator on the backend is canceled.
## Next
- [Window API reference](/docs/v0.2/api/os/window)
- [Menu API](/docs/v0.2/api/os/menu)
- [Monitor API](/docs/v0.2/api/os/monitor)
----
URL: https://tynd.kvnpetit.com/docs/v0.2/guides/performance
TITLE: Performance
DESCRIPTION: Startup latency, IPC overhead, binary size, and CPU-bound hot paths — how to profile and optimize a Tynd app.
# Performance
Most Tynd apps are I/O-bound (IPC round-trips, disk, network). The CPU-bound case matters when you hit it. This guide covers where time actually goes, how to measure, and how to fix it.
## Typical timings
| Operation | `lite` | `full` |
|---|---|---|
| Cold start (blank app) | ~40-80 ms | ~200-500 ms (spawn Bun) |
| Frontend first paint | ~100-200 ms after window creation | same |
| RPC round-trip (JSON, under 1 KB) | ~0.2-1 ms | ~0.5-2 ms |
| OS call (`fs.readText` for a 10 KB file) | ~1-3 ms | same |
| Binary IPC (`fs.readBinary` 10 MB) | ~20-40 ms | same |
| Backend hot reload (dev) | ~100-300 ms | ~200-500 ms |
These are reference points on a mid-range machine (M2, Ryzen 7). Your mileage varies.
## Startup
The biggest pain point is cold-start-to-first-pixel.
### What happens at launch
1. OS loads the binary (~10-50 ms, dominated by AV scan on Windows).
2. TYNDPKG trailer read; frontend assets pre-warmed on a background thread.
3. Backend bundle loaded:
- **lite** — QuickJS eval's `bundle.js` (~20-50 ms for typical app).
- **full** — Bun binary extracted (cached across launches) + subprocess spawned (~200-500 ms).
4. Window created, WebView initialized.
5. Frontend entry executes; first RPC can round-trip.
### Shrinking startup
- **Pick `lite`** when you can. 10× faster cold start, smaller binary, smaller RAM footprint.
- **Keep the backend bundle small.** Every 100 KB of backend code is ~5-15 ms of eval time in lite. Audit your deps with `bun build --target=bun backend/main.ts --minify` and check the output size.
- **Defer OS calls.** `app.onReady` fires after the WebView is alive. Don't block it with synchronous-looking loops — move work into the first user interaction.
- **Lazy-load frontend routes.** Use your framework's code-splitting (Vite's dynamic `import()`). The TYNDPKG scheme serves assets on-demand — smaller initial chunk = faster first paint.
- **Pre-warm caches.** If your app reads a config file at startup, let Rust cache it (the frontend asset cache is already pre-warmed) or start the read early from a background initializer.
### Measuring cold start
```ts filename="src/main.ts"
const t0 = performance.now();
// ... bootstrap your app ...
console.log("frontend ready in", performance.now() - t0, "ms");
```
For the full end-to-end, launch from a shell wrapper that prints its own time around the binary:
```bash
time ./release/my-app --exit-after-ready # if you add a debug flag
```
## IPC overhead
A typical JSON RPC round-trip is **~0.5-2 ms**. That's fast for one call but adds up quickly.
**Bad:**
```ts
// 100 RPC calls for one user action
for (const file of files) {
await api.processOne(file);
}
```
**Good:**
```ts
// One RPC call
await api.processAll(files);
```
### Batching guidance
- If you catch yourself calling RPC in a tight loop, batch on the backend.
- If the call returns a lot of small objects, consider streaming (`async function*`) so the UI can render progressively.
- For multi-MB binary, use [`tynd-bin://`](/docs/v0.2/guides/binary-data) — `fs.readBinary` / `compute.hash`. Don't encode binary as base64 through the JSON channel.
## CPU-bound JS
QuickJS (lite) is an interpreter — ~5-30× slower than V8 / JSC on tight JS loops (parsing, crypto, image processing). Three options:
1. **Move the hot path to Rust.** `compute.hash` is 10× faster than a JS implementation. Same pattern for hash, compression, parsing — if it's hot and deterministic, a Rust API is a better answer.
2. **Offload to a worker.** `workers.spawn(fn)` runs on its own thread. UI stays responsive, but the JS is still interpreted — pure CPU work gains little from just threading.
3. **Switch to `full`.** Bun's JSC JIT closes the gap. Costs ~37 MB of binary size; worth it for apps where the JS itself is the bottleneck.
```ts
// ❌ lite — 200 ms to parse 50 MB of JSON on mid-range
const data = JSON.parse(text);
// ✅ lite — offload
const data = await workers.spawn((s: string) => JSON.parse(s)).then((w) => w.run(text));
// ✅ full — JIT handles it in ~40 ms, no offload needed
const data = JSON.parse(text);
```
## Memory
- Lite baseline: ~30-60 MB at idle.
- Full baseline: ~80-120 MB at idle (Bun subprocess adds ~50 MB).
- WebView memory depends entirely on your frontend — React + state libraries easily reach 100 MB before your data does.
### Leaks to watch
- **Event listeners not unsubscribed.** Every `api.on(…)`, `tyndWindow.onResized(…)`, `singleInstance.onSecondLaunch(…)` returns an unsubscribe. Call it.
- **Unbounded streams.** Async generators whose consumers disappeared. Window-close-cancel handles it for secondary windows; for in-app stream handles, `stream.cancel()` when done.
- **Cached query results in `sql`.** No auto-eviction. If you read 100k rows into memory every query, you'll feel it.
### Measuring
```ts
console.log(performance.memory); // available in full (Bun JSC) and most WebViews
```
For Rust-side memory (the host), use your OS's task manager — `tynd-full` / `tynd-lite` is visible as a process.
## Binary size
| Runtime | Host | Typical app |
|---|---|---|
| `lite` | ~6.4 MB | ~7-12 MB (+frontend + backend + sidecars) |
| `full` | ~6.4 MB + ~37 MB Bun (zstd) | ~44-60 MB |
**Shrink the bundle:**
- Avoid `moment`, `lodash` — use native `Intl.DateTimeFormat` (lite has a stub; use `date-fns`) or per-function imports.
- Tree-shake aggressively. `bun build --minify --tree-shaking` at bundle time.
- Strip source maps for release (`--sourcemap=none`).
- Sidecars dominate — if you ship a 60 MB ffmpeg, your "~10 MB lite app" becomes ~70 MB. Consider auto-download on first launch instead.
## Profiling
### Backend
- **Full mode** — `bun --inspect-brk` on the backend spawn command; attach Chrome DevTools.
- **Lite mode** — no inspector yet. Add `performance.now()` around suspect sections. Or switch to `full` temporarily for profiling.
### Frontend
Your framework's tools work normally. React DevTools, Vue DevTools — load them as browser extensions into the WebView (debug builds only) via `await tyndWindow.openDevTools()`.
### Rust host
You usually don't need to profile the host unless you're a Tynd contributor. If you do:
```bash
cargo build --release -p tynd-lite --features tracing
# Run with TYND_LOG=debug to see IPC traffic + timing
```
## Anti-patterns
- **`await` in a loop** — 10 awaits = 10 ms of latency. Use `Promise.all` for independent work.
- **Round-tripping binary through JSON RPC** — kills throughput. Use `fs.readBinary` / `compute.hash`.
- **Re-parsing JSON every render** — cache the parsed object.
- **Polling `fs.stat` instead of `fs.watch`** — watch is cheaper.
- **Loading the whole SQL table into memory** — paginate or stream.
## Related
- [Binary Data](/docs/v0.2/guides/binary-data) — zero-copy multi-MB IPC.
- [Streaming RPC](/docs/v0.2/guides/streaming) — progressive delivery.
- [Runtimes](/docs/v0.2/runtimes) — lite vs full tradeoff.
----
URL: https://tynd.kvnpetit.com/docs/v0.2/guides/persistence
TITLE: Persistence
DESCRIPTION: Store app state — plain k/v with `store`, relational with `sql`, secrets with `keyring`, arbitrary files with `fs`.
# Persistence
Four layers, pick the right one for the data you're persisting.
| Data shape | API | Backing |
|---|---|---|
| Non-sensitive k/v (theme, recent files, preferences) | `createStore(ns)` | JSON file at `config_dir()//store.json` |
| Relational / SQL | `sql.open(path)` | Bundled SQLite (the SQLite driver) |
| Secrets (tokens, passwords) | `keyring.set/get` | OS credential manager (Keychain / DPAPI / Secret Service) |
| Arbitrary files (documents, images, logs) | `fs.*` | Filesystem |
## Key-value — `store`
```ts
import { createStore } from "@tynd/core/client";
const prefs = createStore("com.example.myapp");
await prefs.set("theme", "dark");
await prefs.set("recentFiles", ["/tmp/a.md", "/tmp/b.md"]);
const theme = await prefs.get("theme");
const keys = await prefs.keys(); // string[]
const hasKey = keys.includes("theme"); // no `has` — derive from keys()
await prefs.delete("theme");
await prefs.clear();
```
- Namespaced per `createStore(ns)` — each namespace maps to `//store.json`.
- Writes flush synchronously (durable; no "dirty, will flush later" window).
- JSON-serializable values only — no functions, no class instances, no cycles.
**Not encrypted.** Anything on disk under the user's config dir is readable by any process with user-level access. Don't put tokens / passwords / session cookies in `store` — use `keyring`.
## Secrets — `keyring`
```ts
import { keyring } from "@tynd/core/client";
const entry = { service: "com.example.myapp", account: "alice" };
await keyring.set(entry, "s3cr3t-token");
const token = await keyring.get(entry); // string | null
await keyring.delete(entry); // true if it existed
```
Backing per OS:
- **macOS** — Keychain (encrypted with the user's login password).
- **Windows** — Credential Manager + DPAPI.
- **Linux** — Secret Service API (GNOME Keyring / KWallet).
Anything in `keyring` is encrypted at rest and only accessible when the user is logged in. Strictly better than `store` for OAuth tokens, API keys, session cookies, passwords.
## Relational — `sql`
Bundled SQLite (no system dependency):
```ts
import { sql } from "@tynd/core/client";
const db = await sql.open("./data.db"); // or ":memory:"
await db.exec(`
CREATE TABLE IF NOT EXISTS users(
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE
)
`);
const { changes, lastInsertId } = await db.exec(
"INSERT INTO users(name, email) VALUES (?1, ?2)",
["Alice", "alice@example.com"],
);
const rows = await db.query<{ id: number; name: string; email: string }>(
"SELECT * FROM users WHERE name LIKE ?1",
["A%"],
);
const one = await db.queryOne<{ count: number }>(
"SELECT COUNT(*) AS count FROM users",
);
await db.close();
```
### Params
- Strings, numbers, booleans, nulls.
- Arrays / objects → stored as JSON text (use `json_extract` in SQL to read back).
### BLOBs
- Stored as base64 strings on the JSON IPC channel (not zero-copy).
- Fine for small BLOBs (icons, thumbnails). For large ones, prefer `fs.writeBinary` + store the path.
### Storing the DB in the right place
Use the OS data directory:
```ts
import { os, path } from "@tynd/core/client";
const dataDir = await os.dataDir();
const dbPath = await path.join(dataDir, "com.example.myapp", "data.db");
const db = await sql.open(dbPath);
```
### Migrations
There's no built-in migrator. Pin schema version in a table:
```ts
await db.exec(`CREATE TABLE IF NOT EXISTS meta(k TEXT PRIMARY KEY, v TEXT)`);
const versionRow = await db.queryOne<{ v: string }>("SELECT v FROM meta WHERE k='schema_version'");
const version = versionRow ? parseInt(versionRow.v) : 0;
const migrations: Array<() => Promise> = [
async () => {
await db.exec(`CREATE TABLE notes(id INTEGER PRIMARY KEY, body TEXT)`);
},
async () => {
await db.exec(`ALTER TABLE notes ADD COLUMN created_at INTEGER`);
},
];
for (let i = version; i < migrations.length; i++) {
await migrations[i]();
await db.exec("INSERT OR REPLACE INTO meta VALUES('schema_version', ?1)", [String(i + 1)]);
}
```
## Files — `fs`
```ts
import { fs } from "@tynd/core/client";
// text
await fs.writeText("./notes/hello.md", "# Hi\n", { createDirs: true });
const body = await fs.readText("./notes/hello.md");
// binary
const bytes = await fs.readBinary("./logo.png");
await fs.writeBinary("./out/logo-copy.png", bytes);
// metadata
const info = await fs.stat("./notes/hello.md");
// { isFile, isDir, size, modifiedMs }
// directories
await fs.mkdir("./notes/archive", { recursive: true });
const entries = await fs.readDir("./notes");
// [{ name, path, isDir }, ...]
await fs.remove("./notes/hello.md");
await fs.rename("./a.txt", "./b.txt");
await fs.copy("./a.txt", "./c.txt");
// watch
const watcher = await fs.watch("./notes", { recursive: true }, (event) => {
console.log(event.kind, event.path);
});
// later
await watcher.unwatch();
```
`fs.watch` uses `ReadDirectoryChangesW` (Windows), `FSEvents` (macOS), `inotify` (Linux).
## Where to put files
Use the right OS directory:
```ts
import { os, path } from "@tynd/core/client";
const configDir = await os.configDir(); // ~/.config/myapp (Linux)
const dataDir = await os.dataDir(); // ~/.local/share/myapp or equivalent
const cacheDir = await os.cacheDir(); // user cache dir
const tmpDir = await os.tmpDir(); // system temp
const homeDir = await os.homeDir(); // $HOME / USERPROFILE
```
Convention:
- **`configDir`** — user-editable settings, YAML/TOML/JSON.
- **`dataDir`** — databases, caches the app rebuilds on demand, user-authored documents.
- **`cacheDir`** — purely derived data; OK to lose.
- **`tmpDir`** — ephemeral, don't expect anything to survive a reboot.
## Export / import
To let users back up and restore:
```ts
import { fs, sql, dialog } from "@tynd/core/client";
async function exportBackup() {
const dest = await dialog.saveFile({ defaultPath: "backup.db" });
if (!dest) return;
// Close the db to ensure journal is flushed
await db.close();
await fs.copy(dbPath, dest);
db = await sql.open(dbPath);
}
async function importBackup() {
const src = await dialog.openFile();
if (!src) return;
await db.close();
await fs.copy(src, dbPath);
db = await sql.open(dbPath);
}
```
## Next
- [fs API](/docs/v0.2/api/os/fs)
- [sql API](/docs/v0.2/api/os/sql)
- [store API](/docs/v0.2/api/os/store)
- [keyring API](/docs/v0.2/api/os/keyring)
----
URL: https://tynd.kvnpetit.com/docs/v0.2/guides/sidecars
TITLE: Sidecars
DESCRIPTION: Ship a helper binary (ffmpeg, yt-dlp, etc.) embedded inside your Tynd app and execute it at runtime.
# Sidecars
Some apps need a native CLI binary bundled alongside the app — `ffmpeg` for video, `yt-dlp` for media downloads, a proprietary compiled tool, a custom Go/Rust binary. Tynd's **sidecar** mechanism packs these into the TYNDPKG trailer, extracts them at launch, and gives you a path at runtime.
## Declare sidecars
```ts filename="tynd.config.ts"
import type { TyndConfig } from "@tynd/cli";
export default {
runtime: "lite",
backend: "backend/main.ts",
frontendDir: "dist",
sidecars: [
{ name: "ffmpeg.exe", path: "bin/ffmpeg.exe" },
{ name: "yt-dlp", path: "bin/yt-dlp" },
],
} satisfies TyndConfig;
```
- `name` — what your TS code asks for at runtime. Usually matches the binary's original filename.
- `path` — where the binary lives on disk at build time, relative to the project root.
## Use at runtime
```ts
import { sidecar, process } from "@tynd/core/client";
const ffmpegPath = await sidecar.path("ffmpeg.exe");
const { stdout, code } = await process.exec(ffmpegPath, {
args: ["-version"],
});
console.log(stdout);
```
`sidecar.path("ffmpeg.exe")` returns the **extracted on-disk path** — e.g. `/tmp/tynd-xxxx/sidecar/ffmpeg.exe` on Linux or `%TEMP%/tynd-xxxx/sidecar/ffmpeg.exe` on Windows. The binary is ready to execute when `path()` resolves.
## What happens at build time
When `tynd build` packs a sidecar:
1. Reads the file from `path`.
2. Packs it under `sidecar/` in the TYNDPKG trailer.
3. No compression — most sidecars are already-compressed formats (`.exe`, `ELF`, pre-compressed zips).
## What happens at launch
The Rust host's embed loader walks TYNDPKG and, for every entry under `sidecar/`:
1. Extracts the bytes to `/sidecar/`.
2. On Unix, chmods +755 so the binary is executable.
3. Registers the path in a `Mutex>` inside `os::sidecar`.
`sidecar.path("ffmpeg.exe")` looks up the map.
## Platform-specific binaries
A single app ships for multiple platforms, and your sidecar is probably **per-platform** (different binary on Windows, Linux, macOS, even per-arch). Two options:
### One app build per platform (recommended)
Your CI matrix runs `tynd build` on each target host. Before each build, the matrix step copies the right sidecar into `bin/`:
```yaml
# .github/workflows/release.yml (partial)
- name: Stage sidecar for this host
shell: bash
run: |
case "${{ matrix.target }}" in
windows-x64) curl -L -o bin/ffmpeg.exe https://…/ffmpeg-win-x64.exe ;;
linux-x64) curl -L -o bin/ffmpeg https://…/ffmpeg-linux-x64 ;;
macos-arm64) curl -L -o bin/ffmpeg https://…/ffmpeg-macos-arm64 ;;
esac
- run: bunx tynd build --bundle
```
### Conditional sidecar at runtime
If you can ship all platforms' binaries in one build (niche), check the OS and pick the right entry:
```ts
import { os, sidecar } from "@tynd/core/client";
async function ffmpegPath() {
const { platform, arch } = await os.info();
const name = `ffmpeg-${platform}-${arch}${platform === "windows" ? ".exe" : ""}`;
return sidecar.path(name);
}
```
## Interacting with a sidecar
`process.exec` captures stdout/stderr/code:
```ts
const { stdout, stderr, code } = await process.exec(ffmpegPath, {
args: ["-i", input, "-c:v", "libx264", output],
cwd: "/some/working/dir", // optional
env: { FFREPORT: "file=log.txt" }, // optional, merged with current env
});
if (code !== 0) throw new Error(stderr);
```
For long-running sidecars (transcoding, server processes), pair with `terminal.spawn` to stream stdout/stderr via a PTY, or roll your own streaming by structuring the command's output as discrete lines and polling from TS.
## File-size tradeoff
Sidecars **inflate your final binary** by exactly their uncompressed size (no zstd step). A 60 MB `ffmpeg-static` turns your ~10 MB `lite` app into a ~70 MB `.exe`. Consider:
- **Auto-download on first launch** (via `http.download`) instead of bundling — trades startup latency for a slimmer installer.
- **System-wide binary** — if most users already have `ffmpeg`, try `process.exec("ffmpeg", ...)` first and fall back to a bundled copy.
- **Stripped / smaller variants** — ffmpeg without every codec is much smaller than a kitchen-sink build.
## Code signing notes
Windows: if your sidecar is unsigned but your outer `.exe` is signed, SmartScreen may still flag the outer binary (the signed outer is trusted; the sidecar executes inside your process boundary and inherits your trust). No extra work needed.
macOS: a signed outer `.app` doesn't extend its signature to extracted sidecars. If you execute an unsigned sidecar on a notarized app, Gatekeeper may refuse. Either sign your sidecar too, or accept that specific sidecars need to be re-downloaded on first use.
## Next
- [process API](/docs/v0.2/api/os/process)
- [terminal API](/docs/v0.2/api/os/terminal)
- [TYNDPKG format](/docs/v0.2/concepts/tyndpkg)
----
URL: https://tynd.kvnpetit.com/docs/v0.2/guides/single-instance
TITLE: Single Instance
DESCRIPTION: Prevent duplicate launches, forward argv / cwd from the second instance to the primary, and auto-focus the existing window.
# Single Instance
Most desktop apps should **only ever run one copy per user**. When a user double-clicks the app icon while it's already open, you want the existing window to focus — not a second window.
## API
```ts
import { singleInstance } from "@tynd/core/client";
const { acquired } = await singleInstance.acquire("com.example.myapp");
if (!acquired) {
// We're the second instance. The primary has already auto-focused itself
// and will receive {argv, cwd} via onSecondLaunch. Exit silently.
process.exit(0); // or return from main()
}
// Only the primary reaches this point. Set up second-launch handling:
singleInstance.onSecondLaunch(({ argv, cwd }) => {
console.log("user tried to launch again with", argv, "from", cwd);
// typical: focus the main window + interpret argv as a file-open request
});
```
- `acquire(id)` uses a cross-OS exclusive lock + a local socket for forwarding argv/cwd.
- **Hold the lock for the process lifetime** — don't release and re-acquire. The lock releases on process exit.
- `id` doubles as the OS lock name and the socket name. Use a stable reverse-DNS identifier.
## What happens under the hood
When `acquire()` returns `{ acquired: false }`, the host has already:
1. Connected to the primary instance's local socket (named pipe on Windows, abstract socket on Linux, CFMessagePort on macOS).
2. Sent the forwarded payload as a single JSON line `{ argv, cwd }`.
3. Triggered the primary window's `setFocus()` + un-minimize **via the host's native event loop** — no IPC round-trip needed.
## Use cases
### Deep-link handling
Register a custom scheme in `tynd.config.ts`:
```ts
export default {
runtime: "lite",
backend: "backend/main.ts",
frontendDir: "dist",
protocols: ["myapp"], // myapp:// links now launch this app
bundle: { identifier: "com.example.myapp" },
} satisfies TyndConfig;
```
Handle both cold-start and duplicate-launch cases with `onOpenUrl`:
```ts
singleInstance.onOpenUrl((url) => {
// e.g. "myapp://invite/abc123"
const parsed = new URL(url);
router.navigate(parsed.pathname);
});
```
`onOpenUrl` fires for:
- Cold start — argv contains the URL.
- Duplicate launch — the primary receives the forwarded URL.
See [Deep Linking](/docs/v0.2/guides/deep-linking) for scheme registration details per OS.
### File-open
Users drag a file onto your app icon while it's running. The OS relaunches the app with the file path as argv[1]:
```ts
singleInstance.onSecondLaunch(({ argv }) => {
const file = argv.find((a) => a.endsWith(".myapp"));
if (file) openFile(file);
});
```
File type associations are a separate concern (not currently exposed as a first-class config field — you'd hand-edit the `.desktop` / `.app` / installer metadata).
## Platform notes
- **Windows** — named pipe (`\\.\pipe\`). Windows frees the pipe when the owning process dies; no stale-lock cleanup needed.
- **Linux** — abstract socket (Linux-specific, not filesystem-visible). Auto-released by the kernel on process exit.
- **macOS** — `CFMessagePort` (user-session-scoped, Mach-kernel-backed). If a crash leaves a zombie, `killall MyApp` or reboot.
## Error modes
### `acquire()` returns `false` even when alone
Stale lock. Usually self-heals on process exit (see above). If it doesn't:
- **Windows** — nothing to do; Windows frees named pipes aggressively.
- **Linux** — abstract socket auto-released.
- **macOS** — `killall MyApp` to clear the zombie.
### `onSecondLaunch` fires twice
You called `singleInstance.onSecondLaunch(...)` inside a handler that itself re-fires (React `useEffect` rerender, component re-mount). The API is idempotent for a given handler function, but registering two different handler functions means both fire.
```ts
// Register once, not in a render function
const unsub = singleInstance.onSecondLaunch(onRelaunch);
// call unsub() if you want to stop handling
```
## Id convention
Use a stable reverse-DNS identifier that matches `bundle.identifier`:
```ts
singleInstance.acquire("com.example.myapp");
```
**Don't include version numbers** — a user upgrading from 1.0.0 to 1.1.0 expects the upgrade to auto-focus the already-running old version (so the old one can shut down cleanly, then the new one starts). Different ids break that.
## What this doesn't give you
- **Multi-user isolation on Windows (service / tenancy setups)** — `single-instance` is user-scoped, so two users on the same box each get their own primary.
- **Cross-machine coordination** — no network-wide lock. If you want a license server, build your own.
## Next
- [singleInstance API](/docs/v0.2/api/os/single-instance)
- [Deep Linking](/docs/v0.2/guides/deep-linking)
----
URL: https://tynd.kvnpetit.com/docs/v0.2/guides/streaming
TITLE: Streaming RPC
DESCRIPTION: Async-generator backend handlers stream yields to the frontend with flow control, batched DOM updates, and cancellation — safe at 10k+ yields/sec.
# Streaming RPC
If a backend export is an `async function*`, the frontend gets a **StreamCall** handle that's both awaitable and async-iterable. Cancellation propagates end-to-end.
## Basic pattern
```ts filename="backend/main.ts"
export async function* processFiles(paths: string[]) {
let ok = 0;
for (const [i, path] of paths.entries()) {
await doWork(path);
ok++;
yield { path, progress: (i + 1) / paths.length };
}
return { ok, failed: paths.length - ok };
}
```
```ts filename="src/main.ts"
const stream = api.processFiles(["a.txt", "b.txt", "c.txt"]);
for await (const chunk of stream) {
render(chunk.progress); // yields: { path, progress }
}
const summary = await stream; // return value: { ok, failed }
console.log(summary);
```
- **Yields** come in through the async iterator.
- **Return value** resolves on the `await stream` promise.
- **Errors** reject both the iterator and the promise.
## Cancellation
```ts
const stream = api.processFiles(hugeBatch);
setTimeout(async () => {
await stream.cancel(); // stops the backend generator, rejects the iterator
}, 3000);
```
`stream.cancel()` calls `iterator.return()` in the frontend, which sends a cancel IPC. Rust forwards it to the backend, the generator's next `yield` throws, and its `try/finally` cleanup runs.
Also cancels automatically:
- `break` out of `for await` — implicit `iterator.return()`.
- The calling window closes — Rust walks `dispatch::call_labels()` and cancels every stream that originated in that window.
## Flow control — three mechanisms
Streaming is safe at arbitrary yield rates (10k+ tokens/s from an LLM, thousands of file-scan events per second) thanks to three layers.
### 1. Per-stream credit
- Backend generator starts with **64 credits** (`STREAM_CREDIT`).
- Every yield decrements. At 0 credit the generator awaits a waiter.
- The frontend replenishes by sending `{ type: "ack", id, n: 32 }` after every **32 consumed chunks** (`ACK_CHUNK`).
- Bounded memory (≤ 64 × payload size) regardless of producer speed.
### 2. Yield batching
- Rust buffers `BackendEvent::Yield` per window.
- Flushes either after **10 ms** or synchronously when any bucket hits **64 items** (`YIELD_BATCH_MAX`).
- Single `evaluate_script` per webview per flush — not one per chunk.
- Cuts per-chunk main-thread cost ~30× on bursty streams.
### 3. Cleanup on window close
- Secondary window closes → Rust cancels every active stream originating there.
- Without this, closed Settings / About windows would leak generators on the backend.
Combined guarantee: **10k+ yields/sec to the UI without freezing it**, bounded memory, correct multi-window routing.
## Patterns
### Progress with a final summary
```ts
export async function* uploadFiles(files: string[]) {
const results: string[] = [];
for (const file of files) {
const uploadId = await upload(file);
results.push(uploadId);
yield { file, uploadId };
}
return { results, total: files.length };
}
// frontend
const stream = api.uploadFiles(files);
for await (const { file, uploadId } of stream) {
updateRowStatus(file, uploadId);
}
const summary = await stream;
showToast(`Uploaded ${summary.total} files`);
```
### Cancellable long task
```ts
export async function* searchFiles(root: string, pattern: string) {
const stack = [root];
while (stack.length) {
const dir = stack.pop()!;
for (const entry of await scan(dir)) {
if (entry.isDir) stack.push(entry.path);
else if (entry.name.match(pattern)) yield entry;
}
}
}
// frontend
const stream = api.searchFiles("/home/me", "\\.ts$");
const abort = () => stream.cancel();
cancelButton.addEventListener("click", abort);
try {
for await (const match of stream) {
if (results.length > 500) await stream.cancel();
results.push(match);
}
} finally {
cancelButton.removeEventListener("click", abort);
}
```
### LLM token streaming
```ts
import { fetch } from "@tynd/core/client";
export async function* askLLM(prompt: string) {
const res = await fetch("https://api.example.com/chat", {
method: "POST",
body: JSON.stringify({ prompt, stream: true }),
headers: { "content-type": "application/json" },
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
yield decoder.decode(value, { stream: true });
}
}
// frontend
const stream = api.askLLM("Hello");
let text = "";
for await (const chunk of stream) {
text += chunk;
messageEl.textContent = text;
}
```
10k tokens/sec is fine — yield batching keeps the main thread responsive.
### Fan-out to multiple consumers
You can't share a single stream — each `await api.foo()` call creates a new backend generator. If you need fan-out, buffer on one consumer and re-broadcast:
```ts
const stream = api.subscribe();
const listeners = new Set<(x: unknown) => void>();
(async () => {
for await (const event of stream) {
for (const fn of listeners) fn(event);
}
})();
export function subscribe(fn: (x: unknown) => void) {
listeners.add(fn);
return () => listeners.delete(fn);
}
```
## Error handling
```ts
export async function* flaky() {
yield 1;
yield 2;
throw new Error("oops");
}
// frontend
try {
for await (const n of api.flaky()) {
console.log(n); // 1, 2
}
} catch (err) {
console.error(err.message); // "oops"
}
```
The error propagates into the `for await` loop and also rejects `await stream`.
## Under the hood — wire format
Per yield (full mode):
```json
{ "type": "yield", "id": "c42", "value": { "path": "a.txt", "progress": 0.33 } }
```
Per ACK (frontend → backend):
```json
{ "type": "ack", "id": "c42", "n": 32 }
```
Per return:
```json
{ "type": "return", "id": "c42", "value": { "ok": 3, "failed": 0 } }
```
Yield batches render as `__tynd_yield_batch__([[id, val], [id, val], …])` on the frontend side, delivered via a single `evaluate_script`.
`BackendEvent::Return` flushes all pending yields before resolving, so the frontend iterator finalizes with every chunk in order.
## Limitations
- **No transferable objects** — values are JSON-serialized each time. For multi-MB binary chunks, use the [binary IPC channel](/docs/v0.2/guides/binary-data) separately and yield handles/IDs over JSON.
- **No backpressure from the iterator back to the producer's I/O** — the generator is credit-paused, not paused at the network / disk level. If you're reading from a `fetch` body, the body reader is only called between yields, so the TCP buffer naturally backs up.
## Next
- [Binary Data](/docs/v0.2/guides/binary-data)
- [Backend RPC](/docs/v0.2/guides/backend-rpc)
- [IPC model](/docs/v0.2/concepts/ipc)
----
URL: https://tynd.kvnpetit.com/docs/v0.2/guides/testing
TITLE: Testing
DESCRIPTION: Unit-test backend logic with `bun test`, exercise OS APIs in isolation, and run integration smoke tests with the native host.
# Testing
Tynd apps are plain TypeScript with a native runtime attached. The testing strategy has three layers:
| Layer | What it covers | Tool |
|---|---|---|
| **Unit** | Pure functions, backend logic, reducers, validators | `bun test` |
| **OS API integration** | `fs`, `sql`, `store`, `compute`, `http` round-trips | `bun test` + a running `tynd-lite` / `tynd-full` binary |
| **GUI / end-to-end** | Window rendering, IPC across the native bridge, menus, tray | Manual + a playground harness |
## Unit tests — `bun test`
Bun ships a Jest-compatible test runner. No install needed.
```ts filename="backend/add.test.ts"
import { test, expect, describe } from "bun:test";
import { add } from "./add";
describe("add", () => {
test("sums two numbers", () => {
expect(add(2, 3)).toBe(5);
});
test("handles negatives", () => {
expect(add(-1, 1)).toBe(0);
});
});
```
Run:
```bash
bun test
bun test --watch
bun test backend/add.test.ts
```
## Testing backend RPC in isolation
Export your RPC functions from a module the test can import:
```ts filename="backend/users.ts"
export async function createUser(name: string) {
if (!name.trim()) throw new Error("name is required");
return { id: crypto.randomUUID(), name };
}
```
```ts filename="backend/users.test.ts"
import { test, expect } from "bun:test";
import { createUser } from "./users";
test("createUser rejects empty names", async () => {
await expect(createUser("")).rejects.toThrow("name is required");
});
test("createUser returns id + name", async () => {
const u = await createUser("Alice");
expect(u.name).toBe("Alice");
expect(u.id).toMatch(/^[0-9a-f-]{36}$/);
});
```
No host needed — you're testing pure business logic.
## Testing OS API-using code
OS APIs (`fs`, `sql`, `store`, `compute`, `http`) dispatch through the Rust host. Two options:
### Option A — launch against a running host
Run `tynd start` in one terminal, then run tests in another that hit the same process. Good for one-off verification, awkward for CI.
### Option B — mock at the module boundary
Wrap OS calls in a thin adapter layer your tests can swap out:
```ts filename="backend/data-store.ts"
import { createStore } from "@tynd/core/client";
export interface Storage {
get(key: string): Promise;
set(key: string, value: unknown): Promise;
}
export function createRealStorage(ns: string): Storage {
return createStore(ns);
}
export function createMemoryStorage(): Storage {
const m = new Map();
return {
get: async (k) => (m.get(k) as unknown) ?? null,
set: async (k, v) => void m.set(k, v),
};
}
```
```ts filename="backend/app.ts"
import { type Storage } from "./data-store";
export async function saveTheme(storage: Storage, theme: string) {
await storage.set("theme", theme);
}
```
```ts filename="backend/app.test.ts"
import { test, expect } from "bun:test";
import { createMemoryStorage } from "./data-store";
import { saveTheme } from "./app";
test("saveTheme writes to storage", async () => {
const storage = createMemoryStorage();
await saveTheme(storage, "dark");
expect(await storage.get("theme")).toBe("dark");
});
```
The real storage and the memory storage share the same interface. Unit tests use the fake; a smoke test exercises the real one.
## Integration smoke tests
The simplest "does the binary actually launch" check:
```ts filename="scripts/smoke.ts"
import { spawn } from "bun";
const proc = spawn(["./release/my-app"], {
stdout: "pipe",
stderr: "pipe",
});
await Bun.sleep(2000);
proc.kill();
const exitCode = await proc.exited;
if (exitCode !== 0 && exitCode !== 143 /* SIGTERM */) {
console.error(await new Response(proc.stderr).text());
process.exit(1);
}
console.log("smoke ok");
```
```bash
bun run scripts/smoke.ts
```
Do this after `tynd build` in CI to catch "launches but immediately crashes" regressions.
## GUI / IPC end-to-end
WebView-driven tests (Playwright, WebdriverIO) don't work — `tynd://` isn't a browser-reachable URL, and the native window isn't a Chromium DevTools target.
Options:
- **Keep it manual.** Run `tynd dev`, exercise the app, use browser devtools (`await tyndWindow.openDevTools()`).
- **Headless probe.** Spawn the binary and poke RPC through a backend-internal test harness you expose under a debug flag.
- **Future.** A dedicated `tynd-test-runner` is on the backlog — not shipping in this version.
## Testing in CI
```yaml filename=".github/workflows/test.yml"
name: Test
on: [push, pull_request]
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- name: Linux WebView deps
if: runner.os == 'Linux'
run: sudo apt install -y libgtk-3-dev libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev libxdo-dev
- run: bun install
- run: bun test
- run: bunx tynd build
- run: bun run scripts/smoke.ts
```
## What to test
- **Business logic** — validators, reducers, formatters, calculators. `bun test` covers this.
- **RPC payload shapes** — if the backend returns a complex object, test that it serializes round-trip correctly. JSON-serialize, parse back, assert deep-equal.
- **Error paths** — every `throw` in the backend. The frontend sees a rejected promise; tests confirm the message is correct.
- **Store / SQL schema migrations** — run the migration from an empty DB, assert table shapes.
- **Signature verification** — if you ship `updater`, write a test that verifies a known-good signature passes and a tampered one fails.
## What not to test
- **GUI pixel-perfect layouts** — rendering is the WebView's job. Trust it.
- **The Rust host itself** — pre-tested by the Tynd project CI. You don't need to re-verify `fs.readText` works.
- **End-to-end click scenarios in CI** — too flaky for its cost. Keep those manual.
## Related
- [Performance](/docs/v0.2/guides/performance) — profiling and optimization.
- [Debugging](/docs/v0.2/guides/debugging) — devtools, logs, verbose mode.
- [CONTRIBUTING.md](https://github.com/kvnpetit/tynd/blob/main/CONTRIBUTING.md) — how the Tynd monorepo tests itself.
----
--- SECTION: Tutorials ---
URL: https://tynd.kvnpetit.com/docs/v0.2/tutorials
TITLE: Tutorials
DESCRIPTION: Build complete Tynd apps step-by-step — markdown editor, file browser, LLM chat client.
# Tutorials
End-to-end walkthroughs. Each tutorial starts from `bunx tynd create` and ends with a shippable binary.
File open/save, live preview, keyboard shortcuts, native menu — ~150 LOC.
Directory walker, binary thumbnails via `fs.readBinary`, reveal-in-folder.
Streaming RPC over `fetch`, token-by-token rendering, cancellation.
Each tutorial is self-contained. Pick whichever matches what you're building.
## Prerequisites
- [Bun installed](/docs/v0.2/getting-started/installation).
- Comfortable with TypeScript.
- A framework you like — the tutorials use framework-agnostic DOM APIs; swap in React hooks / Vue refs / Svelte runes as you prefer.
----
URL: https://tynd.kvnpetit.com/docs/v0.2/tutorials/chat-client
TITLE: LLM Chat Client
DESCRIPTION: Build a streaming LLM chat — backend proxies token-by-token via `async function*`, frontend renders progressively with a cancel button.
# LLM Chat Client
A native chat app backed by any OpenAI-compatible endpoint (OpenAI, Anthropic, Ollama, etc.). Demonstrates streaming RPC, cancellation, and keyring-stored API keys.
Exercises: [async-generator RPC](/docs/v0.2/guides/streaming), [`keyring`](/docs/v0.2/api/os/keyring), [`fetch`](/docs/v0.2/api).
## Scaffold
```bash
bunx @tynd/cli create chat --framework react --runtime lite
cd chat
```
## Backend — streaming proxy
```ts filename="backend/main.ts"
import { app } from "@tynd/core";
import { keyring } from "@tynd/core/client";
const KEYRING = { service: "com.example.chat", account: "openai_api_key" };
export async function setApiKey(key: string) {
await keyring.set(KEYRING, key);
}
export async function hasApiKey() {
return (await keyring.get(KEYRING)) != null;
}
export async function* chat(messages: Array<{ role: string; content: string }>) {
const key = await keyring.get(KEYRING);
if (!key) throw new Error("API key not set");
const res = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${key}`,
},
body: JSON.stringify({
model: "gpt-4o-mini",
messages,
stream: true,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let nl = buf.indexOf("\n");
while (nl !== -1) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
nl = buf.indexOf("\n");
if (!line.startsWith("data: ")) continue;
const data = line.slice("data: ".length);
if (data === "[DONE]") return;
try {
const parsed = JSON.parse(data);
const delta = parsed.choices?.[0]?.delta?.content;
if (delta) yield delta as string;
} catch {
// ignore malformed chunks
}
}
}
}
app.start({
window: { title: "Chat", width: 900, height: 700, center: true },
});
```
Key points:
- **`keyring`** — the API key never touches disk as plain text.
- **`async function*`** — yields one token at a time. Tynd batches yields (10ms / 64 items) to keep the UI smooth even at 10k+ tokens/s.
- **Cancellation** — if the frontend calls `stream.cancel()`, the generator's next `yield` throws; the SSE reader breaks out.
## Frontend
```tsx filename="src/App.tsx"
import { useRef, useState } from "react";
import { createBackend } from "@tynd/core/client";
import type * as backend from "../backend/main";
const api = createBackend();
interface Message {
role: "user" | "assistant";
content: string;
}
export default function App() {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
const streamRef = useRef | null>(null);
const [streaming, setStreaming] = useState(false);
async function send() {
if (!input.trim()) return;
const user: Message = { role: "user", content: input };
const assistant: Message = { role: "assistant", content: "" };
const next = [...messages, user, assistant];
setMessages(next);
setInput("");
setStreaming(true);
try {
const stream = api.chat(next.slice(0, -1));
streamRef.current = stream;
for await (const chunk of stream) {
assistant.content += chunk;
setMessages([...next]); // force re-render
}
} catch (err) {
assistant.content += `\n\n[error: ${(err as Error).message}]`;
setMessages([...next]);
} finally {
setStreaming(false);
streamRef.current = null;
}
}
async function cancel() {
await streamRef.current?.cancel();
}
return (
{messages.map((m, i) => (
{m.role}: {m.content}
))}
);
}
```
## Setting the API key
First launch — add a small settings modal that calls `await api.setApiKey(key)`. Or hardcode for local testing:
```ts
if (!(await api.hasApiKey())) {
await api.setApiKey(prompt("OpenAI API key?")!);
}
```
## Build
```bash
tynd build
```
~10 MB binary. The API key lives in the OS Keychain / Credential Manager — safe at rest.
## Why this pattern
- **Streaming RPC** is tuned for this — 10k+ tokens/s flow to the UI without blocking.
- **Cancellation propagates end-to-end.** `stream.cancel()` → backend generator throws → SSE reader breaks → HTTP connection closes (OpenAI stops billing).
- **`keyring` > `store`** for secrets. A curious user digging through `~/.config/chat/store.json` won't find the API key.
## Next ideas
- **Save conversations** — JSON in `os.dataDir()` + `fs.writeText`.
- **Model picker** — store the selected model in `createStore`.
- **Multi-window** — one conversation per `tyndWindow.create({ label: convoId })`.
- **System tray** — minimize to tray for quick ⌘-Space access with a [global shortcut](/docs/v0.2/guides/keyboard-shortcuts).
## Related
- [Streaming RPC guide](/docs/v0.2/guides/streaming).
- [keyring API](/docs/v0.2/api/os/keyring).
- [Persistence guide](/docs/v0.2/guides/persistence).
----
URL: https://tynd.kvnpetit.com/docs/v0.2/tutorials/file-browser
TITLE: File Browser
DESCRIPTION: Build a file browser with directory walking, image thumbnails via zero-copy binary IPC, and reveal-in-folder.
# File Browser
A working cross-platform file browser: directory tree, image thumbnails, double-click to open with the OS default handler, right-click to reveal.
Exercises: [`fs.readDir`](/docs/v0.2/api/os/fs), [`fs.readBinary`](/docs/v0.2/api/os/fs) (zero-copy), [`shell.openPath`](/docs/v0.2/api/os/shell), [`os.homeDir`](/docs/v0.2/api/os/os), [`path.join`](/docs/v0.2/api/os/path).
## Scaffold
```bash
bunx @tynd/cli create file-browser --framework react --runtime lite
cd file-browser
```
## Backend — minimal
No RPC needed — every operation is an OS call from the frontend.
```ts filename="backend/main.ts"
import { app } from "@tynd/core";
app.start({
window: { title: "Files", width: 1100, height: 720, center: true },
});
```
## Frontend
```tsx filename="src/App.tsx"
import { useEffect, useState } from "react";
import { fs, os, path, shell } from "@tynd/core/client";
interface Entry {
name: string;
path: string;
isDir: boolean;
}
export default function App() {
const [cwd, setCwd] = useState("");
const [entries, setEntries] = useState([]);
useEffect(() => {
os.homeDir().then((h) => h && setCwd(h));
}, []);
useEffect(() => {
if (!cwd) return;
fs.readDir(cwd).then((items) => {
items.sort((a, b) =>
a.isDir === b.isDir ? a.name.localeCompare(b.name) : a.isDir ? -1 : 1,
);
setEntries(items);
});
}, [cwd]);
async function goUp() {
const parent = await path.dirname(cwd);
if (parent !== cwd) setCwd(parent);
}
async function onOpen(e: Entry) {
if (e.isDir) setCwd(e.path);
else await shell.openPath(e.path);
}
return (
{entries.map((e) => (
onOpen(e)}
style={{
padding: "8px 12px",
display: "flex",
alignItems: "center",
gap: 12,
cursor: "pointer",
}}
>
{e.isDir ? "📁" : isImage(e.name) ? : "📄"}
{e.name}
))}
);
}
function isImage(name: string) {
return /\.(png|jpe?g|gif|webp|bmp)$/i.test(name);
}
function Thumbnail({ path }: { path: string }) {
const [url, setUrl] = useState(null);
useEffect(() => {
let dead = false;
fs.readBinary(path).then((bytes) => {
if (dead) return;
const blob = new Blob([bytes]);
setUrl(URL.createObjectURL(blob));
});
return () => {
dead = true;
if (url) URL.revokeObjectURL(url);
};
}, [path]);
return url ? : 🖼️ ;
}
```
## Why this matters
- **`fs.readBinary`** goes through the `tynd-bin://` zero-copy channel — multi-MB thumbnails don't stall the main thread.
- **`shell.openPath`** delegates to the OS default handler — PDFs open in Preview/Acrobat, images in your image viewer, etc. No need to build your own viewer.
- **No backend RPC.** Every operation is a direct OS call from the frontend. The backend is essentially a config file.
## Build
```bash
tynd build
```
~9 MB binary on `lite`. Launches to the user's home directory.
## Next ideas
- **Watch the current directory** — `fs.watch(cwd, { recursive: false }, reload)` to pick up changes from other apps.
- **Search** — recursive walk with [`async function*`](/docs/v0.2/guides/streaming) yielding matches as they're found.
- **Copy / move / delete** — `fs.copy`, `fs.rename`, `fs.remove`, wrapped with a `dialog.confirm` for destructive ops.
- **Reveal in file manager** — use `process.exec("explorer", { args: [path] })` on Windows, `open` on macOS, `xdg-open` on Linux.
- **Drag-drop to add files** — frontend HTML5 drag-drop; the dropped paths are returned as native file paths.
## Related
- [fs API](/docs/v0.2/api/os/fs).
- [Binary Data guide](/docs/v0.2/guides/binary-data) — why `fs.readBinary` is fast.
- [shell API](/docs/v0.2/api/os/shell).
- [process API](/docs/v0.2/api/os/process).
----
URL: https://tynd.kvnpetit.com/docs/v0.2/tutorials/markdown-editor
TITLE: Markdown Editor
DESCRIPTION: Build a complete markdown editor — file open/save, live preview, keyboard shortcuts, native menu. ~150 LOC end-to-end.
# Markdown Editor
Build a native markdown editor with:
- Side-by-side editor + preview.
- `Cmd/Ctrl+O` / `Cmd/Ctrl+S` to open / save files.
- Native File menu.
- Dirty-state tracking with preventable close.
End result: a ~10 MB `.exe` / `.app` / `.AppImage`.
## Scaffold
```bash
bunx @tynd/cli create md-editor --framework react --runtime lite
cd md-editor
bun install
bun add marked
```
## Backend — open / save
Only the thing the WebView can't do safely: file I/O. Everything else stays on the frontend.
```ts filename="backend/main.ts"
import { app } from "@tynd/core";
app.start({
frontendDir: import.meta.dir + "/../dist",
window: {
title: "Markdown Editor",
width: 1280,
height: 800,
center: true,
},
menu: [
{
type: "submenu",
label: "File",
items: [
{ label: "Open", id: "file.open", accelerator: "CmdOrCtrl+O" },
{ label: "Save", id: "file.save", accelerator: "CmdOrCtrl+S" },
{ label: "Save As", id: "file.saveAs", accelerator: "CmdOrCtrl+Shift+S" },
{ type: "separator" },
{ role: "quit" },
],
},
{
type: "submenu",
label: "Edit",
items: [
{ role: "undo" }, { role: "redo" },
{ type: "separator" },
{ role: "cut" }, { role: "copy" }, { role: "paste" },
],
},
],
});
```
That's the whole backend. The file dialogs, reads, writes all go through OS APIs called **directly from the frontend** — no round-trip.
## Frontend — editor + preview
```tsx filename="src/App.tsx"
import { useEffect, useRef, useState } from "react";
import { marked } from "marked";
import {
dialog,
fs,
menu,
tyndWindow,
} from "@tynd/core/client";
export default function App() {
const [path, setPath] = useState(null);
const [body, setBody] = useState("# Hello\n\nStart typing…");
const [saved, setSaved] = useState(body);
const dirty = body !== saved;
// Window title reflects file + dirty state
useEffect(() => {
const name = path ? path.split(/[/\\]/).pop() : "Untitled";
void tyndWindow.setTitle(`${dirty ? "● " : ""}${name} — Markdown Editor`);
}, [path, dirty]);
// Menu handlers
useEffect(() => {
const offOpen = menu.onClick("file.open", openFile);
const offSave = menu.onClick("file.save", () => saveFile(false));
const offSaveAs = menu.onClick("file.saveAs", () => saveFile(true));
return () => { offOpen(); offSave(); offSaveAs(); };
}, [path, body]);
// Preventable close when dirty
useEffect(() => {
const off = tyndWindow.onCloseRequested(async (e) => {
if (!dirty) return;
e.preventDefault();
const keep = await dialog.confirm(
"You have unsaved changes. Close anyway?",
);
if (keep) {
await tyndWindow.close("main");
}
});
return off;
}, [dirty]);
async function openFile() {
const picked = await dialog.openFile({
filters: [{ name: "Markdown", extensions: ["md", "markdown", "txt"] }],
});
if (!picked) return;
const text = await fs.readText(picked);
setPath(picked);
setBody(text);
setSaved(text);
}
async function saveFile(forceDialog: boolean) {
let dest = path;
if (!dest || forceDialog) {
const picked = await dialog.saveFile({
defaultPath: path ?? "untitled.md",
filters: [{ name: "Markdown", extensions: ["md"] }],
});
if (!picked) return;
dest = picked;
}
await fs.writeText(dest, body);
setPath(dest);
setSaved(body);
}
return (
);
}
```
## Build
```bash
tynd build --bundle
```
Output in `release/`. On macOS, open the `.app`; on Windows, run the NSIS setup; on Linux, install the `.deb`.
## What this tutorial covered
- Native menu bar with accelerators (`CmdOrCtrl+O`, `Cmd+S`).
- `menu.onClick` handlers that reach straight into frontend state.
- `dialog.openFile` / `dialog.saveFile` without a backend round-trip.
- `fs.readText` / `fs.writeText` for user files.
- `tyndWindow.onCloseRequested` + `preventDefault` for the "unsaved changes" dialog.
- Reactive window title via `tyndWindow.setTitle`.
## Next ideas
- **Watch the file on disk** — `fs.watch(path, { recursive: false }, (e) => reload())` to detect external edits.
- **Recent files list** — persist to `createStore("com.example.mdeditor")`.
- **Export to PDF** — bundle a `wkhtmltopdf` [sidecar](/docs/v0.2/api/os/sidecar) and run via `process.exec`.
- **Sync with cloud** — `http.post` to your API with the body; auth token in [`keyring`](/docs/v0.2/api/os/keyring).
## Related
- [fs API](/docs/v0.2/api/os/fs).
- [dialog API](/docs/v0.2/api/os/dialog).
- [menu API](/docs/v0.2/api/os/menu).
- [Bundling](/docs/v0.2/guides/bundling).
----
--- SECTION: Recipes ---
URL: https://tynd.kvnpetit.com/docs/v0.2/recipes
TITLE: Recipes
DESCRIPTION: Short self-contained snippets for common desktop-app tasks — remember window size, minimize to tray, handle deep links, and more.
# Recipes
Drop-in code for common tasks. Each recipe is short, focused, and runnable.
Persist width/height/position; restore on next launch.
Hide window on close, restore on tray click.
Register autostart with `--minimized` arg.
React to `myapp://…` URLs on cold start and duplicate launch.
Periodic connectivity probe — no backend polling.
Text, image (RGBA), HTML.
Stream to disk, render a progress bar.
Safe exec, shell exec, cancel on timeout.
Bundle `ffmpeg`; resolve its path at runtime.
Read `os.isDarkMode()`; update on change.
Forward launch args to the primary instance.
Show "unsaved changes" dialog on close.
----
URL: https://tynd.kvnpetit.com/docs/v0.2/recipes/confirm-before-quit
TITLE: Confirm Before Quit
DESCRIPTION: Intercept the close request; show a native confirm dialog if there are unsaved changes.
# Confirm Before Quit
```ts
import { tyndWindow, dialog } from "@tynd/core/client";
let dirty = false;
let closing = false;
// track your app's dirty state wherever it changes
export function markDirty() { dirty = true; }
export function markClean() { dirty = false; }
tyndWindow.onCloseRequested(async (e) => {
if (!dirty || closing) return; // allow close
e.preventDefault(); // block the close for 500 ms
const discard = await dialog.confirm(
"You have unsaved changes. Close anyway?",
{ title: "Are you sure?" },
);
if (discard) {
closing = true;
await tyndWindow.close("main"); // or app.exit(0) from backend
}
});
```
`onCloseRequested` gives you a 500 ms synchronous window to decide. `preventDefault()` blocks the close; call `tyndWindow.close(label)` or `app.exit()` once the user confirms.
**If the dialog takes longer than 500 ms** — add `cancelClose()` to explicitly halt the pending close:
```ts
tyndWindow.onCloseRequested(async (e) => {
if (!dirty) return;
e.preventDefault();
await tyndWindow.cancelClose(); // stop the watchdog
const discard = await dialog.confirm("Unsaved changes. Close?");
if (discard) await tyndWindow.close("main");
});
```
## Save-and-quit variant
```ts
const choice = await dialog.openFile({
// ... use a 3-button native dialog if you add support for it, or roll your own in HTML
});
// Or use a HTML modal with 3 buttons — Save, Discard, Cancel.
```
Tynd's `dialog.confirm` is 2-button (OK / Cancel). For Save/Discard/Cancel, use an in-app HTML modal.
**Related:** [tyndWindow events](/docs/v0.2/api/os/window) · [dialog API](/docs/v0.2/api/os/dialog).
----
URL: https://tynd.kvnpetit.com/docs/v0.2/recipes/copy-to-clipboard
TITLE: Copy to Clipboard
DESCRIPTION: Text, image, and HTML. Native OS clipboard.
# Copy to Clipboard
## Text
```ts
import { clipboard } from "@tynd/core/client";
await clipboard.writeText("Hello, world");
const got = await clipboard.readText();
```
## Image (base64 PNG)
`clipboard.writeImage` takes a **base64-encoded PNG string**. Convert a canvas to PNG, strip the data-URL prefix, write it.
```ts
const canvas = document.querySelector("canvas")!;
const dataUrl = canvas.toDataURL("image/png");
const base64 = dataUrl.replace(/^data:image\/png;base64,/, "");
await clipboard.writeImage(base64);
```
Reading an image (returns `{ png, width, height }` with `png` as base64):
```ts
const img = await clipboard.readImage(); // null if no image
if (img) {
const el = document.createElement("img");
el.src = `data:image/png;base64,${img.png}`;
el.width = img.width;
el.height = img.height;
document.body.appendChild(el);
}
```
## HTML (write-only)
```ts
await clipboard.writeHtml(`Hello world
`);
```
Paste into a rich-text target (email, Google Doc) → formatted content. Plain-text targets get a stripped fallback (the OS handles this).
## Clear
```ts
await clipboard.clear();
```
**Related:** [clipboard API](/docs/v0.2/api/os/clipboard).
----
URL: https://tynd.kvnpetit.com/docs/v0.2/recipes/dark-mode-sync
TITLE: Sync with OS Dark Mode
DESCRIPTION: Read the OS dark-mode preference and react to theme changes.
# Sync with OS Dark Mode
## Read at startup
```ts
import { os, tyndWindow } from "@tynd/core/client";
export async function applyInitialTheme() {
const dark = await os.isDarkMode();
document.documentElement.dataset.theme = dark ? "dark" : "light";
}
```
## React to changes
`tyndWindow.onThemeChanged` fires when the OS theme flips:
```ts
tyndWindow.onThemeChanged(({ theme }) => {
document.documentElement.dataset.theme = theme;
});
```
## CSS
```css
:root[data-theme="light"] {
--bg: #ffffff;
--text: #111111;
}
:root[data-theme="dark"] {
--bg: #0a0a0a;
--text: #fafafa;
}
body {
background: var(--bg);
color: var(--text);
}
```
Or rely on pure CSS via `@media (prefers-color-scheme: dark)` — matches the same OS signal:
```css
@media (prefers-color-scheme: dark) {
:root {
--bg: #0a0a0a;
--text: #fafafa;
}
}
```
Both work. Pick JS if you need programmatic access or want to override (user toggles "always light"); pick CSS if OS-synced behavior is all you need.
**Related:** [os API](/docs/v0.2/api/os/os) · [tyndWindow events](/docs/v0.2/api/os/window).
----
URL: https://tynd.kvnpetit.com/docs/v0.2/recipes/detect-online
TITLE: Detect Online / Offline
DESCRIPTION: Periodic connectivity probe. The DOM `navigator.onLine` lies; use a real network check.
# Detect Online / Offline
`navigator.onLine` only reflects the local network state — a connected-but-no-internet laptop still reports `true`. For real connectivity, probe a known endpoint.
```ts
import { http } from "@tynd/core/client";
async function isOnline(): Promise {
try {
const res = await http.get("https://www.gstatic.com/generate_204", {
timeoutMs: 3000,
});
return res.status === 204;
} catch {
return false;
}
}
export function watchConnectivity(cb: (online: boolean) => void) {
let online = true;
const tick = async () => {
const now = await isOnline();
if (now !== online) {
online = now;
cb(now);
}
};
void tick();
const id = setInterval(tick, 30_000);
return () => clearInterval(id);
}
// usage
const off = watchConnectivity((online) => {
console.log(online ? "back online" : "offline");
});
```
`gstatic.com/generate_204` is Google's captive-portal probe — returns `204 No Content`, no body, ~150 bytes on the wire. Cheap to hit every 30 s.
**Alternative endpoints:**
- `https://www.cloudflare.com/cdn-cgi/trace` — Cloudflare equivalent.
- Your own API's `/health` endpoint — gives you "the server is up" not just "internet works".
**Related:** [http API](/docs/v0.2/api/os/http).
----
URL: https://tynd.kvnpetit.com/docs/v0.2/recipes/embed-sidecar
TITLE: Embed and Run a Sidecar
DESCRIPTION: Bundle `ffmpeg` (or any CLI) inside your app binary; resolve the extracted path at runtime.
# Embed a Sidecar
## Declare at build time
```ts filename="tynd.config.ts"
export default {
runtime: "lite",
backend: "backend/main.ts",
frontendDir: "dist",
sidecars: [
{ name: "ffmpeg", path: "bin/ffmpeg" },
],
} satisfies TyndConfig;
```
- `name` — what your code asks for at runtime.
- `path` — where the file lives at build time, relative to project root.
## Use at runtime
```ts
import { sidecar, process } from "@tynd/core/client";
export async function transcode(input: string, output: string) {
const ffmpeg = await sidecar.path("ffmpeg");
const { stdout, stderr, code } = await process.exec(ffmpeg, {
args: ["-i", input, "-c:v", "libx264", "-y", output],
});
if (code !== 0) throw new Error(stderr);
return output;
}
```
`sidecar.path()` returns the extracted on-disk path (chmod +755 on Unix, ready to execute).
## Platform-specific binaries
Tynd doesn't cross-compile. For CI matrix builds, stage the right binary per host before `tynd build`:
```yaml
- name: Stage ffmpeg
shell: bash
run: |
case "${{ matrix.target }}" in
windows-x64) curl -L -o bin/ffmpeg.exe https://…/ffmpeg-win.exe ;;
linux-x64) curl -L -o bin/ffmpeg https://…/ffmpeg-linux ;;
macos-arm64) curl -L -o bin/ffmpeg https://…/ffmpeg-macos-arm64 ;;
esac
chmod +x bin/ffmpeg*
- run: bunx tynd build --bundle
```
## Long-running sidecar (streaming output)
```ts
import { terminal, sidecar } from "@tynd/core/client";
const ffmpeg = await sidecar.path("ffmpeg");
const t = await terminal.spawn({
shell: ffmpeg,
args: ["-i", input, "-progress", "pipe:1", "-c:v", "libx264", output],
});
t.onData((bytes) => {
const text = new TextDecoder().decode(bytes);
const m = text.match(/out_time_ms=(\d+)/);
if (m) reportProgress(parseInt(m[1]) / 1_000_000);
});
t.onExit((code) => {
if (code !== 0 && code !== null) alert("ffmpeg failed");
});
```
## Size tradeoff
Sidecars **don't** get zstd-compressed — they inflate your binary by their raw size. A 60 MB ffmpeg-static ships in your installer. Alternatives:
- **Download on first launch** — ship a lean binary, `http.download` the sidecar to `os.dataDir()` the first time the user needs it.
- **Use the system binary if available** — `process.exec("ffmpeg", …)` first; fall back to bundled.
**Related:** [sidecar API](/docs/v0.2/api/os/sidecar) · [Sidecars guide](/docs/v0.2/guides/sidecars).
----
URL: https://tynd.kvnpetit.com/docs/v0.2/recipes/handle-deep-link
TITLE: Handle Deep Link (myapp://)
DESCRIPTION: React to custom-scheme URLs on both cold start and duplicate launch.
# Handle Deep Link
## Register the scheme
```ts filename="tynd.config.ts"
export default {
runtime: "lite",
backend: "backend/main.ts",
frontendDir: "dist",
protocols: ["myapp"],
bundle: { identifier: "com.example.myapp" },
} satisfies TyndConfig;
```
`tynd build` wires the scheme into the NSIS / MSI / `.app` / `.deb` / `.rpm` / `.AppImage` installer.
## Handle URLs
```ts
import { singleInstance } from "@tynd/core/client";
const { acquired } = await singleInstance.acquire("com.example.myapp");
if (!acquired) process.exit(0); // primary has already been focused + notified
singleInstance.onOpenUrl((url) => {
const parsed = new URL(url);
// url = "myapp://invite/abc123?ref=twitter"
// parsed.hostname === "invite"
// parsed.pathname === "/abc123"
// parsed.searchParams.get("ref") === "twitter"
handleDeepLink(parsed);
});
```
`onOpenUrl` fires for:
- **Cold start** — the argv carries the URL.
- **Duplicate launch** — the primary receives the forwarded URL.
## Validate aggressively
Treat deep-link input as untrusted. Validate against a whitelist before acting:
```ts
function handleDeepLink(url: URL) {
if (url.hostname === "invite" && /^\/[a-z0-9]+$/i.test(url.pathname)) {
router.navigate(url.pathname);
} else {
console.warn("ignored unknown deep link", url.href);
}
}
```
**Related:** [Deep Linking guide](/docs/v0.2/guides/deep-linking) · [singleInstance API](/docs/v0.2/api/os/single-instance).
----
URL: https://tynd.kvnpetit.com/docs/v0.2/recipes/launch-on-boot
TITLE: Launch on System Boot
DESCRIPTION: Register the app to start at login; pass `--minimized` so it lands in the tray.
# Launch on System Boot
```ts
import { autolaunch } from "@tynd/core/client";
export async function enableAutolaunch() {
await autolaunch.enable({ args: ["--minimized"] });
}
export async function disableAutolaunch() {
await autolaunch.disable();
}
export async function isAutolaunchEnabled() {
return autolaunch.isEnabled();
}
```
Respect the flag at startup:
```ts
const minimized = /* parse argv for --minimized */ false;
if (minimized) {
await tyndWindow.hide(); // land in tray
}
```
Combine with [Minimize to Tray](/docs/v0.2/recipes/minimize-to-tray) so the window stays hidden on boot.
**Platform storage:** Windows registry `Run` key, macOS `LaunchAgents`, Linux XDG autostart. All user-scoped — no UAC prompt.
**Related:** [autolaunch API](/docs/v0.2/api/os/autolaunch).
----
URL: https://tynd.kvnpetit.com/docs/v0.2/recipes/minimize-to-tray
TITLE: Minimize to Tray
DESCRIPTION: Hide window on close; restore on tray icon click. Keeps the app alive in the background.
# Minimize to Tray
Declare a tray in `app.start`, preventDefault the close, toggle visibility on tray click.
```ts filename="backend/main.ts"
app.start({
window: { title: "My App", width: 900, height: 600 },
tray: {
icon: import.meta.dir + "/../assets/tray.png",
tooltip: "My App",
menu: [
{ label: "Show", id: "show" },
{ label: "Quit", id: "quit" },
],
},
});
```
```ts filename="src/main.ts"
import { tyndWindow, tray, menu, app } from "@tynd/core/client";
let quitting = false;
tyndWindow.onCloseRequested((e) => {
if (quitting) return; // allow actual quit
e.preventDefault();
void tyndWindow.hide();
});
tray.onClick(async () => {
const visible = await tyndWindow.isVisible();
if (visible) await tyndWindow.hide();
else {
await tyndWindow.show();
await tyndWindow.setFocus();
}
});
menu.onClick("show", async () => {
await tyndWindow.show();
await tyndWindow.setFocus();
});
menu.onClick("quit", async () => {
quitting = true;
await app.exit(0);
});
```
**Gotcha** — `app.exit(0)` triggers `onCloseRequested` too. The `quitting` flag lets it through.
**Related:** [tray API](/docs/v0.2/api/os/tray) · [tyndWindow API](/docs/v0.2/api/os/window).
----
URL: https://tynd.kvnpetit.com/docs/v0.2/recipes/progress-download
TITLE: Download with Progress
DESCRIPTION: Stream a file to disk, render a progress bar, handle cancellation.
# Download with Progress
```ts
import { http, os, path } from "@tynd/core/client";
export async function downloadWithProgress(
url: string,
filename: string,
onProgress: (pct: number, loaded: number, total: number | null) => void,
) {
const downloads = (await os.downloadsDir()) ?? (await os.homeDir());
const dest = path.join(downloads ?? "", filename);
await http.download(url, dest, {
onProgress: ({ loaded, total }) => {
const pct = total ? (loaded / total) * 100 : 0;
onProgress(pct, loaded, total);
},
});
return dest;
}
// usage
const saved = await downloadWithProgress(
"https://example.com/big.zip",
"big.zip",
(pct, loaded, total) => {
progressBar.style.width = `${pct.toFixed(1)}%`;
label.textContent = total
? `${formatBytes(loaded)} / ${formatBytes(total)} (${pct.toFixed(1)}%)`
: formatBytes(loaded);
},
);
function formatBytes(n: number) {
const u = ["B", "KB", "MB", "GB"];
let i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return `${n.toFixed(1)} ${u[i]}`;
}
```
- Bytes stream **straight to disk** — never round-tripped through JS memory.
- `onProgress` throttled to ~50 ms on the Rust side — no DOM thrash.
- No cancel method yet — if you need it, wrap the call in an `AbortController` + switch to `fetch` with manual streaming (at the cost of no disk-streaming).
**Related:** [http API](/docs/v0.2/api/os/http) · [Binary Data guide](/docs/v0.2/guides/binary-data).
----
URL: https://tynd.kvnpetit.com/docs/v0.2/recipes/remember-window-size
TITLE: Remember Window Size
DESCRIPTION: Persist window dimensions + position to `store`; restore on next launch.
# Remember Window Size
```ts
import { tyndWindow, createStore } from "@tynd/core/client";
const prefs = createStore("com.example.myapp");
interface WindowState {
width: number;
height: number;
x: number;
y: number;
maximized: boolean;
}
export async function restoreWindowState() {
const saved = await prefs.get("window");
if (!saved) return;
await tyndWindow.setSize(saved.width, saved.height);
await tyndWindow.setPosition(saved.x, saved.y);
if (saved.maximized) await tyndWindow.maximize();
}
export function trackWindowState() {
let t: ReturnType | null = null;
const save = () => {
if (t) clearTimeout(t);
t = setTimeout(async () => {
const size = await tyndWindow.getSize();
const pos = await tyndWindow.getPosition();
const maximized = await tyndWindow.isMaximized();
await prefs.set("window", { ...size, ...pos, maximized });
}, 200);
};
const offResize = tyndWindow.onResized(save);
const offMove = tyndWindow.onMoved(save);
return () => { offResize(); offMove(); };
}
// Call at startup
await restoreWindowState();
trackWindowState();
```
**Debounced** — avoid writing on every pixel of a resize drag. 200 ms delay is enough to coalesce without feeling laggy.
**Related:** [tyndWindow API](/docs/v0.2/api/os/window) · [store API](/docs/v0.2/api/os/store).
----
URL: https://tynd.kvnpetit.com/docs/v0.2/recipes/run-shell-command
TITLE: Run a Shell Command
DESCRIPTION: Direct `exec` (safe, no shell interpolation) vs `execShell` (pipes + globs). With timeout and output capture.
# Run a Shell Command
## Direct exec — safest
```ts
import { process } from "@tynd/core/client";
const { stdout, stderr, code } = await process.exec("git", {
args: ["status", "--short"],
cwd: "/path/to/repo",
timeoutMs: 5000,
});
if (code !== 0) throw new Error(stderr);
console.log(stdout);
```
Arguments are passed as an array — **no shell interpolation**. Safe against injection.
## Shell exec — when you need pipes
```ts
const { stdout } = await process.execShell("ls -la | grep tynd | wc -l");
```
Interpolates through `cmd.exe /c` (Windows) or `sh -c` (elsewhere). Pipes, globs, shell builtins work.
**Never pass user input directly** — that's a shell injection. Quote aggressively or switch back to `process.exec` with array arguments.
## Cancellation / timeout
`timeoutMs` is the cleanest option:
```ts
try {
const res = await process.exec("slow-tool", { timeoutMs: 10_000 });
} catch (err) {
// timed out — process was killed, output captured up to the kill is in the err
}
```
For explicit cancel mid-run, use [`terminal.spawn`](/docs/v0.2/api/os/terminal) instead — it returns a handle with `kill()`.
## Environment
```ts
await process.exec("node", {
args: ["build.js"],
env: {
NODE_ENV: "production",
PATH: "/custom/path:/usr/bin",
},
});
```
`env` is **merged** with the current environment. Pass a single key to add to PATH / override a var; other vars remain.
**Related:** [process API](/docs/v0.2/api/os/process) · [terminal API](/docs/v0.2/api/os/terminal) · [sidecar API](/docs/v0.2/api/os/sidecar).
----
URL: https://tynd.kvnpetit.com/docs/v0.2/recipes/single-instance-argv
TITLE: Single Instance + argv Forwarding
DESCRIPTION: Duplicate launch focuses the primary; its argv reaches the primary's handler.
# Single Instance + argv
Typical flow: user double-clicks the app icon while it's already open. You want:
1. The second launch **exits silently** (no duplicate window).
2. The primary window **focuses + unminimizes**.
3. The primary **receives the launch argv** (e.g. a file path).
```ts
import { singleInstance, tyndWindow } from "@tynd/core/client";
const { acquired } = await singleInstance.acquire("com.example.myapp");
if (!acquired) {
// Second instance — host already forwarded argv+cwd and focused the primary.
process.exit(0);
}
// Only the primary reaches here. Handle forwarded launches:
singleInstance.onSecondLaunch(({ argv, cwd }) => {
// First argv entry is the exe path; look at argv[1..] for args
const file = argv.slice(1).find((a) => a.endsWith(".myapp"));
if (file) {
openFile(file);
}
});
```
Host auto-focus + unminimize is **built in** — no IPC round-trip needed. Your code only handles what to *do* with the forwarded args.
## Combine with deep links
```ts
singleInstance.onOpenUrl((url) => {
// Fires on cold start (argv has the URL) AND duplicate launch (forwarded)
const parsed = new URL(url);
router.navigate(parsed.pathname);
});
```
`onOpenUrl` and `onSecondLaunch` can coexist — the URL-scheme detection happens in the host, custom-scheme URLs fire `onOpenUrl`, plain paths fire `onSecondLaunch`.
## Use stable, reverse-DNS ids
```ts
singleInstance.acquire("com.example.myapp"); // ✓
singleInstance.acquire("MyApp-v1.0.0"); // ✗ — version breaks upgrade UX
```
**Related:** [Single Instance guide](/docs/v0.2/guides/single-instance) · [singleInstance API](/docs/v0.2/api/os/single-instance).
----
--- SECTION: API Reference ---
URL: https://tynd.kvnpetit.com/docs/v0.2/api
TITLE: API Reference
DESCRIPTION: Every public Tynd surface — backend, frontend RPC, and 26 OS APIs — with signatures and examples.
# API Reference
Every Tynd app has three surfaces:
- **Backend** (`@tynd/core`) — imported by `backend/main.ts`, exposes `app.start`, emitters, lifecycle hooks.
- **Frontend RPC** (`@tynd/core/client`) — typed proxy to your backend functions via `createBackend()`.
- **OS APIs** (`@tynd/core/client`) — direct bridge from the frontend to the Rust host: dialog, window, clipboard, shell, notification, tray, process, fs, http, websocket, sql, sidecar, terminal, store, os, path, compute, and more.
**Lite vs full parity.** All OS APIs live in Rust (`packages/host-rs/src/os/`) so both runtimes expose the exact same surface. See [Runtime Modes](/docs/v0.2/runtimes) for JS-runtime differences.
## Backend
- [`@tynd/core`](/docs/v0.2/api/backend) — `app.start`, `app.onReady`, `app.onClose`, `createEmitter`, `AppConfig`, `WindowConfig`, menu/tray config.
- [Streaming RPC](/docs/v0.2/api/streaming-rpc) — async-generator handlers.
## OS APIs
Name, version, exit, relaunch.
Window control, events, multi-window.
Enumerate displays, scale factors.
Native pickers, message / confirm.
Text / image / HTML.
Open URLs and files in default handler.
Native OS notifications.
System tray + menu.
App menu bar click handlers.
Global system-wide hotkeys.
Filesystem — text, binary, watcher.
Cross-OS path helpers.
Platform, arch, system dirs.
Subprocess exec + shell exec.
Bundled binaries — resolve runtime path.
Persistent JSON k/v.
Embedded SQLite.
HTTP client, upload/download progress.
WebSocket client.
Real PTY — cross-OS.
Offload CPU-bound JS to OS threads.
Rust-native hash + CSPRNG.
OS-encrypted secret storage.
Start at system boot.
Single-launch lock + deep links.
Ed25519-signed auto-update.
Fetch, WebSocket, crypto, URL — Web globals.
## Web-platform re-exports
`@tynd/core/client` also re-exports the standard Web globals as named exports so `import * as tynd` surfaces them in one namespace alongside the Tynd OS APIs:
```ts
import * as tynd from "@tynd/core/client";
await tynd.fetch(url);
const ws = new tynd.WebSocket(wsUrl);
const hash = await tynd.crypto.subtle.digest("SHA-256", bytes);
await tynd.fs.readText(path); // Tynd OS API on the same namespace
await tynd.sql.open(dbPath);
```
Available re-exports: `fetch`, `Request`, `Response`, `Headers`, `AbortController`, `AbortSignal`, `ReadableStream`, `WebSocket`, `EventSource`, `crypto`, `URL`, `URLSearchParams`, `TextEncoder`, `TextDecoder`, `atob`, `btoa`, `Blob`, `File`, `FormData`, `structuredClone`, `performance`.
Behavior matches the Web spec in both runtimes. In `lite` they point at Tynd's polyfills; in `full` they point at Bun's native implementations.
----
URL: https://tynd.kvnpetit.com/docs/v0.2/api/backend
TITLE: Backend (@tynd/core)
DESCRIPTION: Backend API — app.start, app.onReady, app.onClose, createEmitter, AppConfig, WindowConfig, MenuSubmenu, TrayConfig.
# Backend — `@tynd/core`
The backend module drives the app's lifecycle and declares window/menu/tray config.
## `app.start(config)`
Call once at the bottom of your backend entry file. The host reads the config on stdout's first line, builds the window, and starts the event loop.
```ts
import { app } from "@tynd/core";
app.start({
frontendDir: import.meta.dir + "/../dist",
window: {
title: "My App",
width: 1200,
height: 800,
center: true,
},
});
```
### `AppConfig`
| Field | Type | Description |
|---|---|---|
| `window` | `WindowConfig` | Window options (see below) |
| `frontendDir` | `string` | Path to built frontend assets |
| `devUrl` | `string` | Dev server URL (auto-detected; overrides `frontendDir` in dev) |
| `menu` | `MenuSubmenu[]` | Native menu bar |
| `tray` | `TrayConfig` | System tray |
### `WindowConfig`
| Field | Default | Description |
|---|---|---|
| `title` | `""` | Window title |
| `width` | `1200` | Initial width |
| `height` | `800` | Initial height |
| `minWidth` / `minHeight` | — | Minimum size |
| `maxWidth` / `maxHeight` | — | Maximum size |
| `resizable` | `true` | Allow resize |
| `decorations` | `true` | Show title bar |
| `transparent` | `false` | Transparent background |
| `alwaysOnTop` | `false` | Pin above other windows |
| `center` | `false` | Center on screen at startup |
| `fullscreen` | `false` | Start fullscreen |
| `maximized` | `false` | Start maximized |
## `app.onReady(fn)`
Fires on `__tynd_page_ready` — a one-shot `postMessage` sent from the `JS_PAGE_READY` init script on `DOMContentLoaded`.
```ts
app.onReady(() => {
console.log("window shown, WebView alive");
});
```
## `app.onClose(fn)`
Fires when the user closes the primary window. The window is hidden immediately; **you have 2 seconds** to run handlers before a watchdog force-exits.
```ts
app.onClose(() => {
// quick cleanup only — don't block
});
```
## `createEmitter()`
Create a typed event bus. Exporting the result makes it subscribable from the frontend.
```ts
import { createEmitter } from "@tynd/core";
export const events = createEmitter<{
fileChanged: { path: string };
progress: { percent: number };
}>();
events.emit("fileChanged", { path: "/foo.ts" });
events.emit("progress", { percent: 42 });
```
Frontend subscribes:
```ts
api.on("fileChanged", ({ path }) => { /* … */ });
api.once("progress", ({ percent }) => { /* … */ });
```
**Emitters must be `export`ed.** The frontend's type-only `typeof backend` import needs to see them for `api.on("…", …)` to type-check.
## Native menu bar
```ts
app.start({
menu: [
{
type: "submenu",
label: "File",
items: [
{ label: "New", id: "file.new", accelerator: "CmdOrCtrl+N" },
{ label: "Open", id: "file.open", accelerator: "CmdOrCtrl+O" },
{ type: "separator" },
{ role: "quit" },
],
},
{
type: "submenu",
label: "Edit",
items: [
{ role: "undo" }, { role: "redo" },
{ type: "separator" },
{ role: "cut" }, { role: "copy" }, { role: "paste" },
],
},
],
// ...
});
```
React to clicks from the frontend with the [`menu`](/docs/v0.2/api/os/menu) API:
```ts
import { menu } from "@tynd/core/client";
menu.onClick("file.new", () => createDocument());
```
### Menu item shapes
- **Action** — `{ label, id, accelerator?, enabled?, checkbox?, radio? }`
- **Separator** — `{ type: "separator" }`
- **Role** — `{ role: "quit" | "copy" | "paste" | "undo" | "redo" | "cut" | "selectAll" | "minimize" | "close" | … }`
## System tray
```ts
app.start({
tray: {
icon: import.meta.dir + "/assets/tray.png",
tooltip: "My App",
menu: [
{ label: "Show", id: "show" },
{ label: "Quit", id: "quit" },
],
},
// ...
});
```
Handle clicks with the [`tray`](/docs/v0.2/api/os/tray) API:
```ts
import { tray } from "@tynd/core/client";
tray.onClick(() => tyndWindow.show());
tray.onMenu("quit", () => process.exit(0));
```
## Frontend RPC — `createBackend()`
From the frontend:
```ts
import { createBackend } from "@tynd/core/client";
import type * as backend from "../../backend/main";
const api = createBackend();
const msg = await api.greet("Alice"); // fully typed
api.on("fileChanged", (evt) => { /* … */ });
api.once("progress", (evt) => { /* … */ });
```
- Types flow from `typeof backend` — no codegen.
- Errors thrown on the backend surface as rejected promises with `{ name, message }` preserved.
- `async function*` backend handlers return [StreamCall](/docs/v0.2/api/streaming-rpc) handles — awaitable + async-iterable.
## Next
- [Streaming RPC](/docs/v0.2/api/streaming-rpc)
- [Backend RPC guide](/docs/v0.2/guides/backend-rpc)
- [OS API reference](/docs/v0.2/api)
----
URL: https://tynd.kvnpetit.com/docs/v0.2/api/os/app
TITLE: app
DESCRIPTION: App identity, exit, and relaunch — the process-level controls.
# `app`
```ts
import { app } from "@tynd/core/client";
```
App identity and lifecycle. Callable from both frontend and backend.
## `app.setInfo({ name, version })`
Sets the app's identity. Typically called once from the backend at startup using fields from `package.json`:
```ts
import pkg from "../package.json";
await app.setInfo({ name: pkg.name, version: pkg.version });
```
Used by other APIs (notifications, tray tooltip, OS integration) to identify the app consistently.
## `app.getName(): Promise`
Returns the registered name. Falls back to the binary file stem if `setInfo` wasn't called.
```ts
const name = await app.getName();
```
## `app.getVersion(): Promise`
Returns the registered version. Falls back to `"0.0.0"`.
```ts
const version = await app.getVersion();
```
## `app.relaunch(): Promise`
Spawns a fresh copy of the current binary and exits the current process. Equivalent to user-initiated relaunch.
```ts
await app.relaunch();
```
Useful after applying an update, toggling a runtime flag, or loading a new backend.
## `app.exit(code?: number): Promise`
Exits the app gracefully. The `app.onClose` hook runs (with its usual 2-second watchdog) before the process terminates.
```ts
await app.exit(0);
```
Pass a non-zero code to signal an error to the invoking shell / installer. Default is `0`.
## Notes
- `app.exit(0)` is preferable to `process.exit(0)` — the former runs lifecycle hooks, the latter doesn't.
- On Windows, `relaunch` uses `cmd /c start` so the current `.exe` unlocks before the new one runs.
- On Linux, `relaunch` re-exec's via `/proc/self/exe`.
- On macOS, `relaunch` spawns via `NSWorkspace.openApplication`.
## Related
- [Single Instance guide](/docs/v0.2/guides/single-instance) — argv forwarding.
- [Updater](/docs/v0.2/api/os/updater) — `install({ relaunch: true })` uses `app.relaunch` internally.
----
URL: https://tynd.kvnpetit.com/docs/v0.2/api/os/autolaunch
TITLE: autolaunch
DESCRIPTION: Register / unregister the app to start at system boot.
# `autolaunch`
```ts
import { autolaunch } from "@tynd/core/client";
```
Start at system boot.
## `enable(opts?): Promise<{ enabled: boolean }>`
```ts
interface AutolaunchOptions {
name?: string; // display name (registry key / .plist label / .desktop Name)
args?: string[]; // extra CLI args appended when the OS relaunches
}
const { enabled } = await autolaunch.enable({ args: ["--minimized"] });
```
## `disable(opts?): Promise<{ enabled: boolean }>`
```ts
const { enabled } = await autolaunch.disable();
// enabled is the new state (false on success)
```
`opts` takes the same shape as `enable` — usually you can pass nothing.
## `isEnabled(opts?): Promise`
```ts
const on = await autolaunch.isEnabled();
```
## Platform-specific storage
- **Windows** — `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` registry entry.
- **macOS** — `~/Library/LaunchAgents/.plist`.
- **Linux** — `~/.config/autostart/.desktop`.
All user-scoped — no admin / UAC prompt.
## Path registered
The registered command is **whatever `std::env::current_exe()` resolves to at the time of `enable()`**. Typically:
- Installed app → path to the installed binary.
- Dev binary → `target/release/tynd-full.exe` or similar.
Re-register after an upgrade if the binary path changes.
## Notes
- No per-user vs per-machine toggle. All three OSes use the user-scoped autostart store.
- `enable()` is idempotent — calling twice doesn't duplicate the entry.
- Linux autostart obeys the XDG spec — the `.desktop` file needs correct `Exec=` / `Name=` / `Type=Application`. `autolaunch` writes these for you.
## Example — toggle via settings
```ts
import { autolaunch } from "@tynd/core/client";
async function setAutolaunch(enabled: boolean) {
if (enabled) await autolaunch.enable({ args: ["--minimized"] });
else await autolaunch.disable();
}
const current = await autolaunch.isEnabled();
```
## Related
- [singleInstance](/docs/v0.2/api/os/single-instance) — argv forwarding.
----
URL: https://tynd.kvnpetit.com/docs/v0.2/api/os/clipboard
TITLE: clipboard
DESCRIPTION: Read / write text, base64-PNG images, and HTML on the system clipboard.
# `clipboard`
```ts
import { clipboard } from "@tynd/core/client";
```
## Text
```ts
const text = await clipboard.readText();
await clipboard.writeText("Hello!");
```
## HTML
```ts
await clipboard.writeHtml(`Hello world
`);
await clipboard.writeHtml(`rich
`, "rich"); // optional plain-text fallback
const html = await clipboard.readHtml(); // always returns null — no reliable cross-OS HTML read
```
`writeHtml` sets both `text/html` and (optionally) a plain-text variant. `readHtml` **always returns `null`** — the OS clipboard usually flattens HTML to text for other readers. Use `readText` as a fallback.
## Image
```ts
interface ClipboardImage {
png: string; // base64-encoded PNG bytes
width: number;
height: number;
}
const img = await clipboard.readImage(); // ClipboardImage | null
if (img) {
// Decode: atob(img.png) → binary string → Uint8Array → src
const bin = atob(img.png);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const url = URL.createObjectURL(new Blob([bytes], { type: "image/png" }));
document.querySelector("img")!.src = url;
}
await clipboard.writeImage(pngBase64); // string — base64 PNG
```
**Image format is base64 PNG, not RGBA.** `writeImage` takes a base64-encoded PNG string; `readImage` returns `{ png, width, height }` where `png` is base64. Convert to/from `Uint8Array` via `atob`/`btoa` or use a Blob.
## Clear
```ts
await clipboard.clear();
```
## Notes
- Clipboard state is shared with the rest of the OS — writes replace whatever the user had copied.
- No clipboard-change monitoring. Poll periodically or spawn a platform-specific native helper via `process.exec`.
- On Wayland, clipboard semantics vary by compositor; a few compositors (without `wlr_data_control`) may return empty.
## Related
- [shell.openExternal](/docs/v0.2/api/os/shell) — for "copy link + open" flows.
----
URL: https://tynd.kvnpetit.com/docs/v0.2/api/os/compute
TITLE: compute
DESCRIPTION: Rust-native CPU helpers — hash (blake3/sha256/384/512) and CSPRNG. Runs off the JS event loop.
# `compute`
```ts
import { compute } from "@tynd/core/client";
```
Fast Rust-native compute — avoids the JS event loop on hot paths. Works identically in `lite` and `full`.
## `hash(bytes, opts?): Promise`
Returns a base64 digest of the bytes. Default algo: **blake3**.
```ts
const digest = await compute.hash(bytes); // blake3 by default
const sha256 = await compute.hash(bytes, { algo: "sha256" });
```
### Algorithms
- `blake3` (default) — fast, cryptographically secure, ~4 GB/s on modern CPUs.
- `sha256`, `sha384`, `sha512`.
Bytes travel via the [binary IPC channel](/docs/v0.2/guides/binary-data) — no base64 overhead.
Digest is always returned as **base64**; convert to hex in userland if needed:
```ts
const digest = await compute.hash(bytes, { algo: "sha256" });
const hex = [...Uint8Array.from(atob(digest), (c) => c.charCodeAt(0))]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
```
## `randomBytes(n): Promise`
Fills `n` bytes from the OS CSPRNG (`getrandom` / `BCryptGenRandom` / `SecRandomCopyBytes`).
```ts
const token = await compute.randomBytes(32);
```
**Capped at 1 MiB per call** — if you genuinely need more, call in a loop or use `crypto.getRandomValues` from the Web API (identical semantics).
## Why native?
- **Speed** — blake3 in Rust hashes multi-GB files in seconds. In lite (QuickJS interpreter) the JS equivalent is slower; in full (JIT) it's closer but still limited by engine overhead.
- **Off the event loop** — each call runs on a fresh Rust thread. Hashing a 500 MB file keeps your UI smooth.
- **Uniform API** — same import, same behavior on lite + full. Web-standard `crypto.subtle.digest` works too (HMAC + digest are polyfilled in lite), but `compute.hash` is the fast path for large inputs.
## Not exposed
- **Compression** (`compute.compress`) — zstd is an internal TYNDPKG detail; use `fflate` (pure JS gzip) or similar at app level.
- **AES / RSA / ECDSA** — use `@noble/ciphers` + `@noble/curves` in lite, or `crypto.subtle` in full.
- **Password hashing** (argon2, bcrypt) — use `@noble/hashes/argon2` in lite, or a native binding in full.
## Related
- Web `crypto.getRandomValues` / `crypto.subtle.digest` — works on both runtimes; same semantics as `compute` for the overlap.
- [Binary Data guide](/docs/v0.2/guides/binary-data).
----
URL: https://tynd.kvnpetit.com/docs/v0.2/api/os/dialog
TITLE: dialog
DESCRIPTION: Native file pickers and message / confirm / warn / error dialogs.
# `dialog`
```ts
import { dialog } from "@tynd/core/client";
```
## File pickers
### `openFile(opts?): Promise`
Pick one file. Returns the chosen path or `null` if cancelled.
```ts
const path = await dialog.openFile({
title: "Open image",
defaultPath: "/home/me",
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg"] }],
});
```
### `openFiles(opts?): Promise`
Pick multiple files. Returns `string[]` on success, `null` if cancelled.
```ts
const paths = await dialog.openFiles({
filters: [{ name: "Text", extensions: ["txt", "md"] }],
});
if (paths) { /* ... */ }
```
### `openDirectory(opts?): Promise`
Pick a directory.
```ts
const dir = await dialog.openDirectory({ title: "Choose project root" });
```
Options:
```ts
interface OpenDirectoryOptions {
title?: string;
defaultDir?: string;
}
```
### `saveFile(opts?): Promise`
Ask the user where to save a new file.
```ts
const dest = await dialog.saveFile({
defaultPath: "export.csv",
filters: [{ name: "CSV", extensions: ["csv"] }],
});
```
## Message dialogs
### `message(text, opts?): Promise`
Informational message with an OK button.
```ts
await dialog.message("Operation complete.", { title: "Success" });
```
### `confirm(text, opts?): Promise`
OK/Cancel. Returns `true` if the user clicked OK.
```ts
const ok = await dialog.confirm("Delete this file?", { title: "Are you sure?" });
if (ok) await fs.remove(path);
```
### `warn(text, opts?): Promise`
Same shape as `message` with a warning icon (shorthand for `message(..., { kind: "warning" })`).
```ts
await dialog.warn("Unsaved changes will be lost.");
```
### `error(text, opts?): Promise`
Same shape as `message` with an error icon.
```ts
await dialog.error("Network unreachable.", { title: "Cannot sync" });
```
## Options
Shared:
- `title` — window title (defaults to the app name).
File-picker-specific:
- `defaultPath` — initial file/dir.
- `filters` — array of `{ name, extensions }`. Extensions without leading dots.
## Notes
- Each dialog runs on a fresh Rust thread — won't block the JS event loop.
- Dialogs are not modal to your main window by default. OS-specific attachment is handled for you.
- Custom button labels, show-hidden-files, and a few advanced options are not currently exposed.
## Related
- [fs](/docs/v0.2/api/os/fs) — read/write the picked path.
----
URL: https://tynd.kvnpetit.com/docs/v0.2/api/os/fs
TITLE: fs
DESCRIPTION: Filesystem — read / write text and binary, walk directories, watch for changes.
# `fs`
```ts
import { fs } from "@tynd/core/client";
```
Cross-OS filesystem operations. Binary I/O uses the [zero-copy binary channel](/docs/v0.2/guides/binary-data).
## Text I/O
```ts
await fs.writeText("data.json", JSON.stringify(state), { createDirs: true });
const text = await fs.readText("data.json");
```
### `readText(path): Promise`
Reads the file as UTF-8.
### `writeText(path, content, opts?): Promise`
Writes UTF-8 text.
- `createDirs?: boolean` — create parent directories if missing.
## Binary I/O
```ts
const bytes = await fs.readBinary("image.png"); // Uint8Array
await fs.writeBinary("copy.png", bytes, { createDirs: true });
```
Uses the `tynd-bin://` channel — no base64, zero-copy. Accepts `Uint8Array | ArrayBuffer`. See the [Binary Data guide](/docs/v0.2/guides/binary-data).
## Metadata
### `exists(path): Promise`
```ts
if (await fs.exists("data.json")) { /* … */ }
```
### `stat(path): Promise`
```ts
interface FileStat {
size: number;
isFile: boolean;
isDir: boolean;
isSymlink: boolean;
mtime: number | null; // ms since epoch; null if unsupported
}
const info = await fs.stat("data.json");
```
## Directories
### `readDir(path): Promise`
```ts
interface DirEntry {
name: string;
isFile: boolean;
isDir: boolean;
isSymlink: boolean;
}
const entries = await fs.readDir(".");
```
Join with the parent path for an absolute path: `await path.join(dir, entry.name)`.
### `mkdir(path, opts?): Promise`
```ts
await fs.mkdir("./nested/dir", { recursive: true });
```
### `remove(path, opts?): Promise`
Works on files and directories.
```ts
await fs.remove("./old.json");
await fs.remove("./dir", { recursive: true });
```
### `rename(from, to): Promise`
```ts
await fs.rename("./a.txt", "./b.txt");
```
### `copy(from, to): Promise`
```ts
await fs.copy("./src.json", "./dst.json");
```
## Watcher
```ts
interface FsChangeEvent {
id: number;
kind: "create" | "modify" | "delete" | "rename" | "error" | "other";
path?: string;
error?: string;
}
const watcher = await fs.watch("./notes", { recursive: true }, (event) => {
console.log(event.kind, event.path);
});
// later
await watcher.unwatch();
```
- Uses `ReadDirectoryChangesW` (Windows), `FSEvents` (macOS), `inotify` (Linux).
- Event coalescing behavior differs by OS — `modify` may fire multiple times for a single logical save. Debounce on your side if needed.
- `kind: "error"` events include an `error` field; watcher remains active.
## Notes
- Paths accept both absolute and relative. Relative paths resolve against the app's **current working directory** at launch. Prefer absolute paths built from `os.dataDir()`, `os.configDir()`, etc.
- All methods run on a fresh Rust thread — JS event loop is never blocked.
## Related
- [path](/docs/v0.2/api/os/path) — path helpers.
- [os](/docs/v0.2/api/os/os) — `dataDir`, `configDir`, `cacheDir`, `tmpDir`.
- [Binary Data guide](/docs/v0.2/guides/binary-data).
----
URL: https://tynd.kvnpetit.com/docs/v0.2/api/os/http
TITLE: http
DESCRIPTION: HTTP client with upload / download progress. TLS is bundled — no OpenSSL runtime dep.
# `http`
```ts
import { http } from "@tynd/core/client";
```
HTTP/1.1 client with bundled TLS. For HTTP/2 or HTTP/3, use `fetch` in `full` mode.
## Methods
All return `Promise>` (except `download`).
### `get(url, opts?): Promise>`
```ts
const { body, status, headers } = await http.get("https://example.com");
// body is a UTF-8 string
```
### `getJson(url, opts?): Promise>`
```ts
const { body } = await http.getJson("https://api.github.com/users/kvnpetit/repos");
// body is typed as Repo[]
```
### `getBinary(url, opts?): Promise>`
```ts
const { body: bytes } = await http.getBinary("https://example.com/image.png");
```
### `post(url, opts?)` / `request(url, opts?)`
Same shape as `get`. `request` lets you supply the method yourself.
```ts
await http.post("https://api.example.com/events", {
body: { name: "click" },
headers: { authorization: "Bearer …" },
});
await http.request(url, { method: "OPTIONS" });
```
### `download(url, dest, opts?): Promise<{ path: string; bytes: number }>`
Streams the response body to disk. Good for multi-MB downloads — bytes never round-trip through JS memory.
```ts
const { path, bytes } = await http.download("https://.../big.zip", "./downloads/big.zip", {
onProgress: ({ loaded, total }) => {
const pct = total ? ((loaded / total) * 100).toFixed(1) : "?";
console.log(`${pct}%`);
},
});
```
## Options
```ts
interface HttpRequestOptions {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
headers?: Record;
body?: string | Record | unknown[]; // object → auto-JSON
timeoutMs?: number;
onProgress?: (p: HttpProgress) => void;
}
interface HttpProgress {
phase: "upload" | "download";
loaded: number;
total: number | null;
}
```
`onProgress` fires during both upload (when `body` is set) and download. Throttled ~50 ms by the Rust side.
Download-specific options are a subset:
```ts
{ headers?, timeoutMs?, onProgress? }
```
## `HttpResponse`
```ts
interface HttpResponse {
status: number;
statusText: string;
headers: Record;
body: T;
}
```
Non-2xx responses **don't throw** — inspect `status` yourself.
## Notes
- **HTTP/1.1 only**. For h2 / h3, use `fetch` in `full` mode.
- TLS is bundled — no OpenSSL runtime dep.
- Cookies: no cookie jar is maintained. Read `Set-Cookie` and replay manually if needed.
- Proxy: reads `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` env vars at startup.
## Related
- [websocket](/docs/v0.2/api/os/websocket) — full-duplex.
- Web `fetch` — `import { fetch } from "@tynd/core/client"` for the spec API.
----
URL: https://tynd.kvnpetit.com/docs/v0.2/api/os/keyring
TITLE: keyring
DESCRIPTION: OS-encrypted credential storage. Keychain on macOS, Credential Manager + DPAPI on Windows, Secret Service on Linux.
# `keyring`
```ts
import { keyring } from "@tynd/core/client";
```
Encrypted secret storage backed by the OS credential manager. Use this for anything sensitive — tokens, passwords, session cookies, API keys.
## `set(entry, value): Promise`
```ts
const entry = { service: "com.example.myapp", account: "alice" };
await keyring.set(entry, "s3cr3t-token");
```
- `service` — reverse-DNS recommended. Namespaces your secrets.
- `account` — arbitrary string identifying the credential (username, `"access_token"`, etc.).
- `value` — the secret as a UTF-8 string.
## `get(entry): Promise`
```ts
const token = await keyring.get(entry); // string | null
```
Returns `null` if no entry matches.
## `delete(entry): Promise`
```ts
const existed = await keyring.delete(entry); // true if something was deleted
```
## Backing store per OS
| OS | Backend |
|---|---|
| macOS | Keychain — encrypted with the user's login password |
| Windows | Credential Manager + DPAPI |
| Linux | Secret Service API (GNOME Keyring / KWallet / others via D-Bus) |
On Linux, a Secret Service provider must be running (virtually every desktop environment has one). Headless VMs may not — `get` / `set` throw in that case. Fall back to a file-backed encrypted store or require the user to install `gnome-keyring`.
## `keyring` vs `store`
| | `keyring` | `store` |
|---|---|---|
| Encrypted at rest | ✓ | ✗ |
| Readable by other processes with user access | ✗ | ✓ |
| Suitable for tokens / passwords | ✓ | ✗ |
| Suitable for UI preferences | ✗ (overkill) | ✓ |
## Example — OAuth token round-trip
```ts
import { keyring } from "@tynd/core/client";
const ENTRY = { service: "com.example.myapp", account: "access_token" };
async function getOrRefreshToken() {
let token = await keyring.get(ENTRY);
if (!token || isExpired(token)) {
token = await refreshOAuth(); // your refresh flow
await keyring.set(ENTRY, token);
}
return token;
}
```
## Notes
- Values are UTF-8 strings — encode binary secrets as base64 on the way in and decode on the way out.
- macOS prompts the user to grant Keychain access on first call from a newly-signed binary. A signed / notarized build avoids repeat prompts.
- There's no "list all entries" API. Track the set of known `account` strings in `store` if you need enumeration.
## Related
- [store](/docs/v0.2/api/os/store) — plaintext k/v.
- [Persistence guide](/docs/v0.2/guides/persistence).
----
URL: https://tynd.kvnpetit.com/docs/v0.2/api/os/menu
TITLE: menu
DESCRIPTION: Handle clicks on the native app menu bar. Items are declared in `app.start({ menu: … })`.
# `menu`
```ts
import { menu } from "@tynd/core/client";
```
The menu bar is declared in `app.start({ menu: … })` on the backend. This module subscribes to menu-item clicks (both app menu bar and tray menu).
## Declare the menu
```ts filename="backend/main.ts"
app.start({
menu: [
{
type: "submenu",
label: "File",
items: [
{ label: "New", id: "file.new", accelerator: "CmdOrCtrl+N" },
{ label: "Open", id: "file.open", accelerator: "CmdOrCtrl+O" },
{ label: "Save", id: "file.save", accelerator: "CmdOrCtrl+S" },
{ type: "separator" },
{ role: "quit" },
],
},
{
type: "submenu",
label: "Edit",
items: [
{ role: "undo" }, { role: "redo" },
{ type: "separator" },
{ role: "cut" }, { role: "copy" }, { role: "paste" },
{ type: "separator" },
{ label: "Find", id: "edit.find", accelerator: "CmdOrCtrl+F" },
],
},
{
type: "submenu",
label: "View",
items: [
{ label: "Toggle Theme", id: "view.theme", checkbox: true },
],
},
],
// ...
});
```
## Subscribe to clicks
```ts
const unsub1 = menu.onClick("file.new", () => createDocument());
const unsub2 = menu.onClick("file.open", () => openPicker());
const unsub3 = menu.onClick("file.save", () => saveCurrent());
const unsub4 = menu.onClick("edit.find", () => focusSearch());
const unsub5 = menu.onClick("view.theme", (e) => {
// e.checked = new checked state for checkbox items
applyTheme(e.checked ? "dark" : "light");
});
```
Each returns `unsubscribe()`.
## Item shapes
### Action item
```ts
{
type?: "action", // default — optional
label: string,
id: string, // passed to menu.onClick
accelerator?: string, // "CmdOrCtrl+S"
enabled?: boolean,
checkbox?: boolean, // shows a check mark when checked
radio?: boolean, // single-selection within a group
}
```
### Separator
```ts
{ type: "separator" }
```
### Role
OS-native actions with platform-correct labels and accelerators:
```ts
{ role: "quit" | "copy" | "paste" | "undo" | "redo" | "cut" | "selectAll"
| "minimize" | "close" | "about" | "hide" | "hideOthers" | "unhide" }
```
Roles don't need `id` / `accelerator` — the OS provides both.
## Accelerators
Use the standard accelerator syntax's format — same as [`shortcuts`](/docs/v0.2/api/os/shortcuts):
- `CmdOrCtrl+S` — ⌘S on macOS, Ctrl+S elsewhere
- `Alt+F4`
- `Shift+Space`
Accelerators on menu items fire **only when your app is focused** (unlike `shortcuts.register` which is global).
## Notes
- Menu item clicks are broadcast — a single `menu.onClick` handler fires from any window or the backend.
- Radio items don't have native single-selection grouping yet; track the selected id manually.
- Icons in menu items, tooltips, and dynamic-menu-edit-at-runtime aren't exposed yet.
## Related
- [tray](/docs/v0.2/api/os/tray) — tray menu handlers share the same `menu.onClick` dispatcher.
- [shortcuts](/docs/v0.2/api/os/shortcuts) — global hotkeys (fire unfocused).
----
URL: https://tynd.kvnpetit.com/docs/v0.2/api/os/monitor
TITLE: monitors
DESCRIPTION: Enumerate displays; read per-monitor DPI scale and position.
# `monitors`
```ts
import { monitors } from "@tynd/core/client";
```
Inspect connected displays.
## `monitors.all(): Promise`
Every monitor connected to the machine.
```ts
const all = await monitors.all();
```
## `monitors.primary(): Promise`
The OS's "primary" display (menu bar on macOS, taskbar start on Windows). `null` on headless / unusual setups.
```ts
const primary = await monitors.primary();
```
## `monitors.current(): Promise`
Monitor hosting the primary window. `null` if the window isn't placed yet.
```ts
const current = await monitors.current();
```
## `Monitor` shape
```ts
interface Monitor {
name: string | null; // null on some Linux setups
position: { x: number; y: number };
size: { width: number; height: number };
scale: number; // 1.0 / 1.5 / 2.0
isPrimary: boolean;
}
```
`position` and `size` are **physical pixels**. Divide by `scale` for logical pixels (what `tyndWindow.setSize` / `setPosition` use).
## Example — centre on the current monitor
```ts
import { monitors, tyndWindow } from "@tynd/core/client";
const m = await monitors.current();
if (m) {
const [winW, winH] = [800, 600];
const logicalW = m.size.width / m.scale;
const logicalH = m.size.height / m.scale;
await tyndWindow.setSize(winW, winH);
await tyndWindow.setPosition(
Math.round((logicalW - winW) / 2 + m.position.x / m.scale),
Math.round((logicalH - winH) / 2 + m.position.y / m.scale),
);
}
```
Or just `tyndWindow.center()` — the Rust side does the math.
## Related
- [tyndWindow](/docs/v0.2/api/os/window) — `setPosition`, `setSize`, `scaleFactor`, `center`.
----
URL: https://tynd.kvnpetit.com/docs/v0.2/api/os/notification
TITLE: notification
DESCRIPTION: Send native OS toast notifications.
# `notification`
```ts
import { notification } from "@tynd/core/client";
```
## `send(title, opts?): Promise`
Fires a native notification.
```ts
await notification.send("Build Complete", { body: "0 errors." });
```
### Options
```ts
interface NotificationOptions {
body?: string;
icon?: string; // path to an image file (platform-dependent support)
}
```
- `body` — secondary text under the title.
- `icon` — path to an image. Support varies by OS; fallback to the app icon if unavailable.
## Platform notes
- **macOS** — uses `UNUserNotificationCenter` (notification center). The first call may prompt for permission.
- **Windows** — uses the `ToastNotification` Win32 API. Windows 10+ toast UI.
- **Linux** — uses D-Bus (`org.freedesktop.Notifications`). Requires a running notification daemon (GNOME Shell, KDE, `dunst`, etc.).
## Not supported yet
- Action buttons.
- Interactive text input.
- Sound.
- Scheduled / recurring notifications.
- Custom notification channels (Android-specific; N/A on desktop).
- On-click / on-action callbacks.
For those, wrap a platform-specific native tool via `process.exec` (e.g. `terminal-notifier` on macOS, `SnoreToast` on Windows).
## Related
- [tray](/docs/v0.2/api/os/tray) — persistent tray icon + menu for recurring actions.
----
URL: https://tynd.kvnpetit.com/docs/v0.2/api/os/os
TITLE: os
DESCRIPTION: Platform / arch / OS version, common user directories, env var lookup, EOL, hostname, locale, dark-mode detection.
# `os`
```ts
import { os } from "@tynd/core/client";
```
Platform info and OS-blessed directories. ## `os.info(): Promise`
```ts
interface OsInfo {
platform: "linux" | "macos" | "windows" | string;
arch: string;
family: string;
version: string | null;
}
const info = await os.info();
```
## `os.hostname(): Promise`
```ts
const host = await os.hostname();
```
## `os.locale(): Promise`
BCP-47 locale tag, e.g. `"en-US"`, `"fr-FR"`. `null` if not derivable.
```ts
const locale = await os.locale();
```
## `os.isDarkMode(): Promise`
```ts
if (await os.isDarkMode()) applyDarkStyles();
```
Per-OS detection:
- macOS — `NSApplication.effectiveAppearance`.
- Windows — `HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize\AppsUseLightTheme`.
- Linux — GTK theme name heuristic.
## `os.eol(): Promise`
`"\r\n"` on Windows, `"\n"` elsewhere.
```ts
console.log(`a${await os.eol()}b`);
```
## Directories
All return `Promise` — `null` if the OS doesn't define the directory. `tmpDir` is the only always-available one (returns `Promise`).
```ts
const home = await os.homeDir(); // $HOME / USERPROFILE
const tmp = await os.tmpDir(); // always set
const config = await os.configDir(); // ~/.config, Library/Preferences, %APPDATA%
const data = await os.dataDir(); // ~/.local/share, Library/Application Support, %LOCALAPPDATA%
const cache = await os.cacheDir(); // ~/.cache, Library/Caches, %LOCALAPPDATA%\Caches
const desktop = await os.desktopDir();
const documents = await os.documentsDir();
const downloads = await os.downloadsDir();
const pictures = await os.picturesDir();
const music = await os.musicDir();
const video = await os.videoDir(); // note: singular — not `videosDir`
```
## `os.exePath(): Promise`
Path to the running executable.
```ts
const exe = await os.exePath();
```
## `os.cwd(): Promise`
Current working directory.
```ts
const wd = await os.cwd();
```
## `os.env(key: string): Promise`
Environment variable lookup.
```ts
const home = await os.env("HOME");
const key = await os.env("API_KEY");
```
## Notes
- Every directory getter returns `null` when the OS doesn't define that path (rare on desktop). Always handle `null` when composing absolute paths.
- For per-app paths, join with your reverse-DNS identifier: `await path.join(await os.configDir() ?? "", "com.example.myapp", "settings.json")`.
## Related
- [path](/docs/v0.2/api/os/path) — `path.join` to compose per-app paths.
- [store](/docs/v0.2/api/os/store) — uses `configDir` automatically.
----
URL: https://tynd.kvnpetit.com/docs/v0.2/api/os/path
TITLE: path
DESCRIPTION: Cross-OS path helpers — join, dirname, basename, extname, sep. Synchronous.
# `path`
```ts
import { path } from "@tynd/core/client";
```
Small synchronous path utilities. Platform-aware via `navigator.platform` detection. No round-trip to Rust — these run entirely in the WebView / JS runtime.
## `sep(): "/" | "\\"`
Platform path separator.
```ts
path.sep(); // "\\" on Windows, "/" elsewhere
```
## `join(...parts: string[]): string`
Joins path segments with the platform separator, collapsing duplicate slashes.
```ts
path.join("/tmp", "notes", "hello.md"); // "/tmp/notes/hello.md"
path.join("C:\\", "Users", "me"); // "C:\\Users\\me" on Windows
```
Empty strings are ignored.
## `dirname(p: string): string`
Parent directory of the path.
```ts
path.dirname("/tmp/notes/hello.md"); // "/tmp/notes"
path.dirname("/tmp"); // ""
```
## `basename(p: string, ext?: string): string`
Last segment, optionally with an extension stripped.
```ts
path.basename("/tmp/notes/hello.md"); // "hello.md"
path.basename("/tmp/notes/hello.md", ".md"); // "hello"
```
## `extname(p: string): string`
File extension including the leading dot, or `""` if none.
```ts
path.extname("hello.md"); // ".md"
path.extname("Makefile"); // ""
```
## Notes
- **Synchronous.** These functions do not return promises — they run in the WebView.
- No `resolve()`, `normalize()`, or `isAbsolute()` — compose them yourself or use `URL` / framework helpers if you need them.
- Absolute paths are produced by joining from a known root (`await os.homeDir()`, etc.) — not by a `resolve` function.
## Related
- [fs](/docs/v0.2/api/os/fs) — every `fs.*` method accepts path strings.
- [os](/docs/v0.2/api/os/os) — `homeDir`, `dataDir`, `tmpDir`, etc. for building absolute paths.
----
URL: https://tynd.kvnpetit.com/docs/v0.2/api/os/process
TITLE: process
DESCRIPTION: Spawn subprocesses and capture stdout / stderr / exit code. Direct exec and shell exec.
# `process`
```ts
import { process } from "@tynd/core/client";
```
Run subprocesses and collect buffered output.
## `exec(cmd, opts?): Promise`
Direct execution — no shell interpolation.
```ts
const { stdout, stderr, code } = await process.exec("git", {
args: ["status", "--short"],
});
```
## `execShell(cmd, opts?): Promise`
Runs via `cmd.exe /c` on Windows, `sh -c` elsewhere.
```ts
const { stdout } = await process.execShell("ls -la | grep tynd");
```
`execShell` does not accept `args` — the whole command is a single string, interpreted by the shell.
## Options
```ts
interface ExecOptions {
args?: string[];
cwd?: string;
env?: Record;
input?: string; // written to stdin
}
```
- `env` is **merged** with the current process env (vars you supply override, others pass through).
- `input` is piped to the child's stdin. Useful for passing large payloads without shell-quoting.
## Return
```ts
interface ExecResult {
code: number | null; // null if the process was killed by a signal
stdout: string;
stderr: string;
}
```
## Security
`execShell` interpolates through a shell. Any unquoted user input is a shell injection. Default to `exec` with array arguments; reach for `execShell` only when pipes / globs are genuinely needed and inputs are fully trusted.
## Notes
- **Buffered output.** stdout/stderr are fully captured and returned at the end — not suitable for long-running processes that print steadily (memory grows). Use [`terminal.spawn`](/docs/v0.2/api/os/terminal) for streaming.
- Each call runs on a fresh Rust thread — JS event loop never blocks.
- No direct cancel / kill while the call is pending. For cancellable processes, use `terminal.spawn` which gives you a handle with `kill()`.
## Related
- [terminal](/docs/v0.2/api/os/terminal) — real PTY, streaming stdout.
- [sidecar](/docs/v0.2/api/os/sidecar) — resolve a bundled binary's runtime path.
- [shell](/docs/v0.2/api/os/shell) — OS default-handler launches.
----
URL: https://tynd.kvnpetit.com/docs/v0.2/api/os/shell
TITLE: shell
DESCRIPTION: Open URLs in the default browser and files in their OS-default handler.
# `shell`
```ts
import { shell } from "@tynd/core/client";
```
Launch URLs / files through the OS's default handler.
## `openExternal(url): Promise`
Opens the URL in the default browser.
```ts
await shell.openExternal("https://example.com");
await shell.openExternal("mailto:hi@example.com");
```
**Scheme allowlist** — only `http://`, `https://`, `mailto:` are accepted. Passing `file://`, `javascript:`, `data:`, or a registered custom scheme throws.
## `openPath(path): Promise`
Opens a filesystem path in the OS default handler for that file type.
```ts
await shell.openPath("/Users/me/document.pdf");
await shell.openPath("C:\\Users\\Me\\spreadsheet.xlsx");
```
- Absolute paths only (relative paths resolve against the app's cwd, which is unreliable).
- The OS decides what app handles the file type.
- Returns when the launch is initiated, not when the target app is ready.
## Notes
- Reveal-in-file-manager isn't exposed yet — workaround: `process.exec("explorer", { args: [folder] })` on Windows, `open` on macOS, `xdg-open` on Linux.
- Neither function throws on missing files / unreachable URLs — the OS handler surfaces those errors to the user.
## Related
- [process](/docs/v0.2/api/os/process) — `process.exec` for more control over what's launched.
----
URL: https://tynd.kvnpetit.com/docs/v0.2/api/os/shortcuts
TITLE: shortcuts
DESCRIPTION: Register system-wide global keyboard hotkeys that fire even when the app is unfocused.
# `shortcuts`
```ts
import { shortcuts } from "@tynd/core/client";
```
Global keyboard shortcuts — fire even when your app is unfocused. Backed by `RegisterHotKey` (Windows), Event Tap (macOS), `XGrabKey` (Linux X11) / desktop portal (Wayland).
## `register(accelerator, handler, id?): Promise`
```ts
interface ShortcutHandle {
id: string;
unregister(): Promise;
}
const h = await shortcuts.register("CmdOrCtrl+Shift+P", () => {
openCommandPalette();
}, "open-palette");
console.log(h.id); // "open-palette"
await h.unregister(); // true if existed
```
- `accelerator` — accelerator string (standard format — `CmdOrCtrl+Shift+P`, etc.).
- `handler` — fires on key-down of the full combo.
- `id` — optional stable id. Auto-generated from the accelerator if omitted.
## `unregister(id): Promise`
Unregister by id without the handle. Returns `true` if the id existed.
```ts
await shortcuts.unregister("open-palette");
```
## `unregisterAll(): Promise`
```ts
await shortcuts.unregisterAll();
```
## `isRegistered(id): Promise`
```ts
const ok = await shortcuts.isRegistered("open-palette");
```
## Accelerator format
| Modifier | Effect |
|---|---|
| `CmdOrCtrl` | ⌘ on macOS, Ctrl elsewhere |
| `Cmd` / `Super` | Command / Windows key |
| `Ctrl` | Control |
| `Alt` / `Option` | Alt / Option |
| `Shift` | Shift |
Key names: `A-Z`, `0-9`, `F1-F24`, `Space`, `Tab`, `Escape`, `Enter`, `Backspace`, `Delete`, `Insert`, `Home`, `End`, `PageUp`, `PageDown`, `ArrowUp`, `ArrowDown`, `ArrowLeft`, `ArrowRight`.
## Notes
- **Conflicts** — if another process holds the combo, `register` throws. Catch and let the user pick something else.
- **Wayland** — needs `org.freedesktop.portal.GlobalShortcuts`, which not every compositor implements.
- **macOS Input Monitoring** — first registration on macOS Monterey+ may prompt for Input Monitoring permission.
- OS auto-releases registrations on process exit; calling `unregisterAll` in `app.onClose` is optional.
See the [Keyboard Shortcuts guide](/docs/v0.2/guides/keyboard-shortcuts).
## Related
- [menu](/docs/v0.2/api/os/menu) — menu accelerators (fire only when app focused).
----
URL: https://tynd.kvnpetit.com/docs/v0.2/api/os/sidecar
TITLE: sidecar
DESCRIPTION: Resolve the on-disk path of a bundled sidecar binary extracted at launch.
# `sidecar`
```ts
import { sidecar } from "@tynd/core/client";
```
Registry of binaries extracted from the TYNDPKG `sidecar/*` section at startup.
## `path(name): Promise`
Returns the extracted on-disk path of a sidecar. Throws if no sidecar by that name was packed.
```ts
const ffmpegPath = await sidecar.path("ffmpeg.exe");
```
## `list(): Promise>`
Enumerate every registered sidecar with its extracted path.
```ts
const all = await sidecar.list();
// [{ name: "ffmpeg.exe", path: "/tmp/tynd-xxxx/sidecar/ffmpeg.exe" }, …]
```
## Declare at build time
```ts filename="tynd.config.ts"
sidecars: [
{ name: "ffmpeg.exe", path: "bin/ffmpeg.exe" },
{ name: "yt-dlp", path: "bin/yt-dlp" },
]
```
- `name` — what your runtime code queries by.
- `path` — where the binary lives at build time (relative to project root).
## Execute via `process.exec`
```ts
import { sidecar, process } from "@tynd/core/client";
const ffmpeg = await sidecar.path("ffmpeg.exe");
const { stdout, code } = await process.exec(ffmpeg, {
args: ["-i", input, "-c:v", "libx264", output],
});
```
## What happens under the hood
At launch, the Rust host walks TYNDPKG and, for every entry under `sidecar/`:
1. Extracts the bytes to `/sidecar/`.
2. On Unix, chmods +755.
3. Registers the path in a `Mutex>` inside `os::sidecar`.
`sidecar.path(name)` looks up the map.
## Platform-specific binaries
Cross-compilation isn't supported. CI matrix pattern — stage the right binary per host before `tynd build`:
```yaml
- name: Stage sidecar for this host
shell: bash
run: |
case "${{ matrix.target }}" in
windows-x64) curl -L -o bin/ffmpeg.exe https://…/ffmpeg-win-x64.exe ;;
linux-x64) curl -L -o bin/ffmpeg https://…/ffmpeg-linux-x64 ;;
macos-arm64) curl -L -o bin/ffmpeg https://…/ffmpeg-macos-arm64 ;;
esac
```
See the [Sidecars guide](/docs/v0.2/guides/sidecars).
## Notes
- Sidecars are **not zstd-compressed** in TYNDPKG — they're already-compressed formats.
- They inflate your final binary by their uncompressed size.
- On macOS, a signed outer `.app` doesn't extend its signature to extracted sidecars; sign them separately or accept Gatekeeper prompts.
## Related
- [process](/docs/v0.2/api/os/process) — invoke the sidecar.
- [terminal](/docs/v0.2/api/os/terminal) — long-running sidecars streaming stdout.
- [TYNDPKG format](/docs/v0.2/concepts/tyndpkg).
----
URL: https://tynd.kvnpetit.com/docs/v0.2/api/os/single-instance
TITLE: singleInstance
DESCRIPTION: Prevent duplicate launches, forward argv / cwd to the primary, and auto-focus the existing window.
# `singleInstance`
```ts
import { singleInstance } from "@tynd/core/client";
```
Cross-OS exclusive lock + argv/cwd forwarding.
## `acquire(id): Promise`
```ts
interface SingleInstanceResult {
acquired: boolean;
already: boolean; // true if another instance currently holds the lock
}
const { acquired, already } = await singleInstance.acquire("com.example.myapp");
if (!acquired) {
// Duplicate launch. The primary has already been focused and notified.
process.exit(0);
}
```
- `id` — stable reverse-DNS identifier. Doubles as the OS lock name and the local socket name.
- When `acquired: false`, `already: true` means a peer instance is running. Both fields coincide for the typical case; `already` exists to distinguish "lock unavailable for some other reason" when debugging.
## `isAcquired(): Promise`
Whether this process currently holds the lock.
```ts
if (await singleInstance.isAcquired()) { /* we're the primary */ }
```
## `onSecondLaunch(handler): () => void`
Fires in the **primary** instance whenever a duplicate launch is detected.
```ts
interface SecondLaunchPayload {
argv: string[]; // duplicate's argv (argv[0] is the exe path)
cwd: string;
}
singleInstance.onSecondLaunch(({ argv, cwd }) => {
console.log("reopened with", argv.slice(1), "at", cwd);
});
```
## `onOpenUrl(handler): () => void`
Fires when the app is opened via a registered URL scheme (declared in `tynd.config.ts::protocols`). Fires both on cold start (argv has the URL) and on duplicate launch.
```ts
singleInstance.onOpenUrl((url) => {
// url = "myapp://invite/abc123"
const parsed = new URL(url);
router.navigate(parsed.pathname);
});
```
## Backing mechanisms
- **Windows** — named pipe (`\\.\pipe\`) + the single-instance lock for the lock.
- **Linux** — abstract socket + kernel-managed lock. Auto-released on process exit.
- **macOS** — CFMessagePort + Mach-kernel-backed port.
Argv/cwd forwarding uses the local socket library for the local socket — one JSON line, then close.
## Reserved schemes
When declaring `protocols: ["myapp"]` in `tynd.config.ts`, these schemes are **rejected at config-validation time**:
`http`, `https`, `file`, `ftp`, `mailto`, `javascript`, `data`, `about`, `blob`, `tynd`, `tynd-bin`
## Notes
- Use a stable id — **don't include the version**. Users upgrading need the old version to auto-focus before it shuts down.
- `acquire()` should be called **before** rendering any UI or starting background work. The return value determines the entire process lifecycle.
## Related
- [Single Instance guide](/docs/v0.2/guides/single-instance).
- [Deep Linking guide](/docs/v0.2/guides/deep-linking).
----
URL: https://tynd.kvnpetit.com/docs/v0.2/api/os/sql
TITLE: sql
DESCRIPTION: Embedded SQLite via the SQLite driver — no system dependency, bundled with your binary.
# `sql`
```ts
import { sql } from "@tynd/core/client";
```
Bundled SQLite. No system libsqlite needed. Connections are keyed by an opaque numeric id on the Rust side.
## `open(path): Promise`
Opens or creates a database file. `":memory:"` for in-memory.
```ts
const db = await sql.open("./data.db");
const mem = await sql.open(":memory:");
```
Prefer joining against `os.dataDir()`:
```ts
import { os, path } from "@tynd/core/client";
const dataDir = await os.dataDir();
const dbPath = await path.join(dataDir, "com.example.myapp", "data.db");
const db = await sql.open(dbPath);
```
## `Database` methods
### `exec(sql, params?): Promise`
Run a statement that doesn't return rows (`INSERT`, `UPDATE`, `DELETE`, `CREATE`, …).
```ts
await db.exec(`CREATE TABLE IF NOT EXISTS users(
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
)`);
const { changes, lastInsertId } = await db.exec(
"INSERT INTO users(name) VALUES (?1)",
["Alice"],
);
```
### `query(sql, params?): Promise`
Run a `SELECT` and return every row.
```ts
const rows = await db.query<{ id: number; name: string }>(
"SELECT * FROM users WHERE name LIKE ?1",
["A%"],
);
```
### `queryOne(sql, params?): Promise`
First row only, or `null` if no match.
```ts
const user = await db.queryOne<{ id: number; name: string }>(
"SELECT * FROM users WHERE id = ?1",
[1],
);
```
### `close(): Promise`
Release the connection.
```ts
await db.close();
```
## `list(): Promise`
IDs of all currently-open connections (useful for debugging).
```ts
const ids = await sql.list();
```
## Param binding
Positional:
```ts
await db.exec("INSERT INTO x(a, b) VALUES (?1, ?2)", ["hi", 42]);
```
Type coercion:
- `string` → `TEXT`
- `number` (integer) → `INTEGER`
- `number` (float) → `REAL`
- `boolean` → `INTEGER` (0/1)
- `null` / `undefined` → `NULL`
- `Array` / plain object → `TEXT` (JSON-serialized) — read back with `json_extract(col, '$.field')`
## BLOBs
`BLOB` columns come back as **base64 strings** over the JSON IPC channel. For large blobs, store a path in the table and use [`fs.readBinary`](/docs/v0.2/api/os/fs) on the payload.
## Migrations
No built-in migrator. Standard pattern:
```ts
await db.exec(`CREATE TABLE IF NOT EXISTS meta(k TEXT PRIMARY KEY, v TEXT)`);
const row = await db.queryOne<{ v: string }>(
"SELECT v FROM meta WHERE k='schema_version'",
);
const version = row ? parseInt(row.v) : 0;
const migrations: Array<() => Promise> = [
async () => { await db.exec(`CREATE TABLE notes(id INTEGER PRIMARY KEY, body TEXT)`); },
async () => { await db.exec(`ALTER TABLE notes ADD COLUMN created_at INTEGER`); },
];
for (let i = version; i < migrations.length; i++) {
await migrations[i]();
await db.exec(
"INSERT OR REPLACE INTO meta VALUES('schema_version', ?1)",
[String(i + 1)],
);
}
```
## Transactions
Standard SQL — wrap in `BEGIN` / `COMMIT` / `ROLLBACK`:
```ts
await db.exec("BEGIN");
try {
await db.exec("INSERT INTO a VALUES(?1)", [1]);
await db.exec("INSERT INTO b VALUES(?1)", [2]);
await db.exec("COMMIT");
} catch (err) {
await db.exec("ROLLBACK");
throw err;
}
```
## Notes
- No prepared-statement handle returned to the frontend — each call prepares + binds + executes on the Rust side. Still fast for typical desktop-app workloads.
- WAL journaling is the SQLite default for file DBs; enable with `await db.exec("PRAGMA journal_mode=WAL")` if you want crash-resilient writes.
## Related
- [store](/docs/v0.2/api/os/store) — k/v, simpler for flat preferences.
- [fs](/docs/v0.2/api/os/fs) — for large BLOBs stored out-of-row.
- [Persistence guide](/docs/v0.2/guides/persistence).
----
URL: https://tynd.kvnpetit.com/docs/v0.2/api/os/store
TITLE: store
DESCRIPTION: Persistent JSON key-value store under the OS config directory.
# `store`
```ts
import { createStore } from "@tynd/core/client";
```
Namespaced JSON k/v. Writes flush synchronously to `//store.json`.
## `createStore(namespace)`
```ts
const prefs = createStore("com.example.myapp");
```
- `namespace` — reverse-DNS recommended. Maps to `//store.json`.
## Methods
```ts
await prefs.set("theme", "dark");
const theme = await prefs.get("theme"); // T | null
await prefs.delete("theme");
await prefs.clear();
const keys = await prefs.keys(); // string[]
```
That's the whole surface — five methods. No `has`, `values`, `entries`, `length` — compose from `keys()` + `get()` if you need them.
```ts
// has()
const has = (await prefs.keys()).includes("theme");
// entries()
const keys = await prefs.keys();
const entries = await Promise.all(keys.map(async (k) => [k, await prefs.get(k)] as const));
// length
const length = (await prefs.keys()).length;
```
## Types
- Values must be **JSON-serializable** — primitives, arrays, plain objects, nulls. No functions, class instances, Maps / Sets, or cycles.
- `get` is an unchecked cast. Validate the shape yourself if you depend on it.
## Where it writes
| OS | Path |
|---|---|
| Linux | `~/.config//store.json` |
| macOS | `~/Library/Preferences//store.json` |
| Windows | `%APPDATA%\\store.json` |
The directory is created lazily on first write.
## Durability
- Each `set` / `delete` / `clear` **flushes synchronously** before resolving. On resolve, the change is on disk.
- **No atomic multi-key writes.** For transactional semantics across keys, use [`sql`](/docs/v0.2/api/os/sql) with a single `UPDATE` statement, or write a flat JSON blob with one key.
## Security
**Not encrypted.** Plain JSON readable by any process with user-level access. For sensitive data (tokens, passwords, API keys), use [`keyring`](/docs/v0.2/api/os/keyring).
## Example — per-instance cache + preferences
```ts
import { createStore } from "@tynd/core/client";
const prefs = createStore("com.example.myapp");
const cache = createStore("com.example.myapp.cache");
await prefs.set("theme", "dark");
await cache.set("lastSync", Date.now());
```
Each namespace is a separate file.
## Related
- [keyring](/docs/v0.2/api/os/keyring) — encrypted secrets.
- [sql](/docs/v0.2/api/os/sql) — relational + transactional.
- [fs](/docs/v0.2/api/os/fs) — raw file writes for custom formats.
----
URL: https://tynd.kvnpetit.com/docs/v0.2/api/os/terminal
TITLE: terminal
DESCRIPTION: Real PTY inside your app — cross-OS (ConPTY on Windows, POSIX elsewhere).
# `terminal`
```ts
import { terminal } from "@tynd/core/client";
```
Real PTY sessions. ConPTY on Windows, POSIX PTY elsewhere. Pair with [xterm.js](https://xtermjs.org/) for a full interactive terminal UI.
## `spawn(opts?): Promise`
```ts
interface TerminalSpawnOptions {
shell?: string; // default: $SHELL on Unix, %COMSPEC% on Windows
args?: string[];
cwd?: string;
env?: Record;
cols?: number; // default 80
rows?: number; // default 24
}
const t = await terminal.spawn({ cols: 120, rows: 30 });
```
**Option name is `shell`**, not `command`.
## `TerminalHandle`
```ts
interface TerminalHandle {
id: number;
write(data: string | Uint8Array): Promise;
resize(cols: number, rows: number): Promise;
kill(): Promise;
onData(handler: (chunk: Uint8Array) => void): () => void;
onExit(handler: (code: number | null) => void): () => void;
}
```
### `onData(handler)`
Streams raw PTY bytes (base64-encoded on the IPC channel; decoded to `Uint8Array` for you).
```ts
t.onData((bytes) => xterm.write(bytes));
```
### `onExit(handler)`
Fires once on child exit. `code` is `null` if killed by a signal (e.g. via `kill()`).
```ts
t.onExit((code) => console.log("shell exited:", code));
```
### `write(data)`
```ts
await t.write("ls -la\n");
await t.write(new Uint8Array([0x03])); // Ctrl+C
```
### `resize(cols, rows)`
Call after the user resizes the terminal UI.
### `kill()`
Force-close the session. `onExit` fires with `null`.
## `list(): Promise`
IDs of currently-open sessions.
```ts
const ids = await terminal.list();
```
## Example — xterm.js integration
```ts
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { terminal } from "@tynd/core/client";
import "@xterm/xterm/css/xterm.css";
const xterm = new Terminal();
const fit = new FitAddon();
xterm.loadAddon(fit);
xterm.open(document.getElementById("terminal")!);
fit.fit();
const pty = await terminal.spawn({ cols: xterm.cols, rows: xterm.rows });
pty.onData((bytes) => xterm.write(bytes));
pty.onExit((code) => xterm.writeln(`\r\n[process exited: ${code ?? "killed"}]`));
xterm.onData((data) => pty.write(data));
new ResizeObserver(() => {
fit.fit();
void pty.resize(xterm.cols, xterm.rows);
}).observe(document.getElementById("terminal")!);
```
## Notes
- Output from the PTY is base64-encoded on the IPC channel.
- Multiple concurrent sessions are supported — each gets its own thread and id.
- Long-lived sessions survive across calls; handles live in a `Mutex>` inside `os::terminal`.
## Related
- [process](/docs/v0.2/api/os/process) — one-shot `exec` with buffered output.
- [sidecar](/docs/v0.2/api/os/sidecar) — run a bundled binary as a PTY.
----
URL: https://tynd.kvnpetit.com/docs/v0.2/api/os/tray
TITLE: tray
DESCRIPTION: System tray icon + menu event handling. The tray is configured in `app.start`, this API handles clicks.
# `tray`
```ts
import { tray } from "@tynd/core/client";
```
The tray icon + menu are declared in `app.start({ tray: … })` on the backend. This module lets the frontend (or backend) subscribe to user interactions.
## Declare the tray
```ts filename="backend/main.ts"
app.start({
tray: {
icon: import.meta.dir + "/assets/tray.png",
tooltip: "My App",
menu: [
{ label: "Show", id: "show" },
{ label: "Preferences", id: "prefs" },
{ type: "separator" },
{ label: "Quit", id: "quit" },
],
},
// ...
});
```
## Click handlers
```ts
tray.onClick(() => tyndWindow.show());
tray.onRightClick(() => { /* context menu is native; this fires alongside */ });
tray.onDoubleClick(() => tyndWindow.show());
```
Each returns an `unsubscribe()` function.
## Menu item handlers
```ts
tray.onMenu("show", () => tyndWindow.show());
tray.onMenu("prefs", () => openSettings());
tray.onMenu("quit", () => app.exit(0));
```
The `id` matches the `id` field you set on the tray menu item.
## Notes
- Tray events are broadcast — a handler registered in any window (or in the backend) fires on matching events.
- Platform quirks: macOS fires double-click only if `onClick` doesn't handle it; Windows fires both. Guard on `Date.now()` deltas if you need strict discrimination.
- Dynamic tray-icon updates and mouse-enter/leave events aren't exposed yet.
## Related
- [menu](/docs/v0.2/api/os/menu) — app menu bar click handling.
- [Backend API](/docs/v0.2/api/backend) — tray config shape.
----
URL: https://tynd.kvnpetit.com/docs/v0.2/api/os/updater
TITLE: updater
DESCRIPTION: Check for, download, verify, and install Ed25519-signed auto-updates. Tauri-compatible manifest format.
# `updater`
```ts
import { updater } from "@tynd/core/client";
```
Auto-update checker + signed binary downloader. Verifies Ed25519 signatures over the raw downloaded bytes.
## `check(opts): Promise`
Fetch the manifest and return metadata if a newer version is available.
```ts
interface UpdaterCheckOptions {
endpoint: string; // URL of the update.json manifest
currentVersion: string; // semver
}
interface UpdateInfo {
version: string;
notes: string | null;
pubDate: string | null; // ISO-8601 if the manifest has one
url: string;
signature: string; // base64 Ed25519
platform: string; // e.g. "windows-x86_64"
}
const info = await updater.check({
endpoint: "https://releases.example.com/update.json",
currentVersion: "1.0.0",
});
if (info) console.log(`Update available: ${info.version}`);
```
Returns `null` when already up to date.
## `downloadAndVerify(opts): Promise`
Streams the artifact to a temp file while hashing, then verifies the Ed25519 signature over the full downloaded bytes.
```ts
interface UpdaterDownloadOptions {
url: string;
signature: string; // base64 Ed25519
pubKey: string; // raw 32-byte Ed25519 pubkey, base64
progressId?: string; // scopes onProgress events to this download
}
interface UpdaterDownloadResult {
path: string;
size: number;
}
const off = updater.onProgress(({ phase, loaded, total }) => {
console.log(`${phase}: ${loaded}/${total ?? "?"}`);
});
const { path, size } = await updater.downloadAndVerify({
url: info.url,
signature: info.signature,
pubKey: UPDATER_PUB_KEY,
progressId: "update-download",
});
off();
```
Rejects with a `signature check failed` error if verification fails.
## `onProgress(fn): () => void`
Fires during download + verification. Throttled ~50 ms.
```ts
interface UpdaterProgress {
id?: string;
phase: "download" | "verified";
loaded: number;
total: number | null;
}
const off = updater.onProgress((p) => {
console.log(p.phase, p.loaded, p.total);
});
```
## `install(opts): Promise`
Swaps the downloaded binary for the running one and optionally relaunches.
```ts
interface UpdaterInstallOptions {
path: string;
relaunch?: boolean; // defaults to true
}
interface UpdaterInstallResult {
installed: boolean;
path: string; // final on-disk path of the running binary
relaunch: boolean;
}
const result = await updater.install({ path, relaunch: true });
```
### Platform semantics
- **Windows** — delegates to a short cmd script (`timeout /t 2 /nobreak > nul & move /y & start ""`). Lets the current `.exe` unlock, then swaps + relaunches.
- **Linux** (AppImage + any single-file binary) — `fs::rename` + chmod +x + spawn + exit. Linux keeps the old inode mapped while the exe is live.
- **macOS** — **not yet implemented.** `.app` bundles are directories; callers manage the swap manually using the returned `path`.
Returns just before the current process exits. Don't rely on any state after `install` resolves.
## Manifest format (Tauri-compatible)
```json
{
"version": "1.2.3",
"notes": "Bug fixes & perf.",
"pub_date": "2026-04-19T12:00:00Z",
"platforms": {
"windows-x86_64": {
"url": "https://.../MyApp-1.2.3-setup.exe",
"signature": ""
},
"darwin-aarch64": {
"url": "https://.../MyApp-1.2.3.dmg",
"signature": ""
},
"linux-x86_64": {
"url": "https://.../MyApp-1.2.3.AppImage",
"signature": "