# 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: ` ) : ( )} ); } ``` ## 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 (
{cwd}
    {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 (