---
slug: 41c-desktop
title: Your app on the desktop (one binary)
description: Ship a Synsema web app as a desktop app — one binary that opens its UI in a browser app window, carries your icon, needs no console and quits when the window closes. `synsema build --serve --no-console --icon --bundle`, the `bind` clause, `shutdown()`, `platform()`. No Tauri, no webview, no framework.
example_ids: [desktop]
---

# Your app on the desktop (one binary)

A Synsema web app becomes a **desktop app** without changing what it is: one binary that carries the
engine, your program, its templates and its static files, listens on `127.0.0.1`, opens the UI as an
**app window** of the browser the user already has (Edge or Chrome in `--app=` mode: no address bar,
its own taskbar entry), shows **your icon**, opens **without a console** and **quits by itself when the
last window closes**. Double-click, use, close. The backend, the agents and the secrets stay in the
process, as on a server — the browser is only the screen.

What it is **not**: a native UI toolkit, a webview wrapper, a system-tray API. The engine does not
grow a GUI layer (see [What this is not](#what-this-is-not)); the desktop pieces are surgery on the
binary and a few files next to it, done by `synsema build` (engine v0.6.18+).

## The shape of a desktop app

Four decisions, all in the program (this is the whole `desk.syn`; every line is verified live):

```synsema
require serve(8123)
require exec("cmd")            -- Windows: Edge ships with Windows; --app= gives a window without address bar
require exec("open")           -- macOS: Chrome/Edge in app mode; else the default browser (a tab)
require exec("google-chrome")  -- Linux: app mode…
require exec("chromium")
require exec("xdg-open")       -- …or a tab (Firefox has no app mode)
require file.read("index.html")
require time

-- Each attempt is a run() under its own exec(); "could not launch" is a catchable error,
-- a non-zero exit_code is data. The first one that opens wins.
task try_open(cmd, args)
    try
        let r be run(cmd, args)
        give r["exit_code"] == 0
    recover err
        give false

task open_window()
    let url be "http://127.0.0.1:8123/"
    let p be platform()
    when p["os"] == "windows"
        give try_open("cmd", ["/c", "start", "", "msedge", "--app=" + url])
    when p["os"] == "macos"
        when try_open("open", ["-a", "Google Chrome", "--args", "--app=" + url])
            give true
        when try_open("open", ["-a", "Microsoft Edge", "--args", "--app=" + url])
            give true
        give try_open("open", [url])
    otherwise
        when try_open("google-chrome", ["--app=" + url])
            give true
        when try_open("chromium", ["--app=" + url])
            give true
        give try_open("xdg-open", [url])

let started be now()

task maybe_quit()
    when state_get("windows", 0) > 0
        give nothing
    let closed be state_get("last_close", nothing)
    -- a window was open and closed more than 3 s ago → quit (a reload closes and reopens the socket in < 1 s)
    when closed != nothing and now() - closed > 3
        shutdown("window closed")
    -- no window ever opened (no browser, broken profile, a `start` that did not navigate):
    -- never stay invisible forever — 30 s covers a cold start of Edge
    when closed == nothing and now() - started > 30
        shutdown("no window opened in 30 s")

serve on 8123
    bind "127.0.0.1"
    route "GET /"
        give render("index.html", {"title": "My app"})
    route "GET /ws"
        socket
            state_incr("windows")
            while true
                let ev be ws_recv(socket, 30)
                when ev != nothing and ev["type"] == "close"
                    stop
            state_incr("windows", -1)
            state_set("last_close", now())

cron_every(2, maybe_quit)
open_window()                  -- runs AFTER the listener is up: the top level continues past the serve block
```

And in `index.html`, one line inside a `{ raw } … { end }` block (braces are template holes):
`new WebSocket("ws://127.0.0.1:8123/ws")` — every window load opens its own socket.

1. **`bind "127.0.0.1"`** — the listener is local; nothing on the LAN reaches it. The clause is part of
   the serve block (engine v0.6.18+), so the intent travels with the program; `--bind` on the command
   line still wins (the operator's call), the default without either stays `0.0.0.0` as always.
2. **The window is the browser in app mode**, launched with `run()` under `require exec("<cmd>")`
   — the same capability rules as any child process; nothing is granted ambiently. Edge and Chrome are
   single-instance: if a window is already open, `--app=` hands the URL to the running browser and the
   launcher exits at once — that is why watching the launcher's process would lie.
3. **The socket is the truth.** There is a window ⇔ there is a WebSocket. The `socket` route counts
   them in `state_*`; the browser closes the socket when the window closes (also on a crash of the tab).
4. **`shutdown("window closed")`** runs the same ordered drain Ctrl-C does (listener closed, in-flight
   work drained, cron and agents stopped, exit **0**). It is idempotent, its reason goes to the log
   (`[serve] shutdown requested by the program: window closed`), and a `secret` is refused as a
   reason — nothing sealed reaches a log through it. The second branch matters: if **no window
   ever opens** (no browser installed, a broken profile, a launcher that "succeeds" without
   navigating) a `--no-console` process would otherwise stay invisible forever; 30 s without a
   first socket is the honest cut (verified: exit 0 with `no window opened in 30 s`).

The same program is a normal server: `synsema serve desk.syn` opens the window too (the top level
continues after the serve block), and Ctrl-C still works.

## From a scaffold: `synsema init --desktop` (engine v0.6.19+)

You don't have to type the recipe: `synsema init myapp --desktop` writes the
[PWA scaffold](/en/0.6.x/41b-pwa) with its API in `api.syn` (an `export routes api` group) plus
`desk.syn` — exactly the program above, mounting the same API, `DESK_NO_WINDOW=1` to skip the
browser in tests — and `public/desk.js`, the socket each window opens (`index.html` loads it only
under `{ when desktop }`, so `app.syn` serves the same page without it). One API, two entries:

```sh
synsema init myapp --desktop
cd myapp
synsema serve desk.syn        # the app window opens; close it and the process ends
synsema serve app.syn         # the same app as a site / PWA on :8080
```

The API's per-route limits (`/api/push/subscribe` 10/min, `/api/push/test` 2/min) travel with the
group: since v0.6.19 `rate_limit` and `timeout` inside an `export routes` group work as on a direct
route (a mounted route gets its own zone; a mount prefix is another zone). `stream` and `socket`
routes still belong in the serve block — `synsema check` says so before `serve` does.

## Build it

```sh
synsema build desk.syn -o desk --serve --no-console --icon icon.svg              # Windows: desk.exe — icon, no console window
synsema build desk.syn -o desk --serve --icon icon.svg --bundle                  # macOS host: "desk.app/"
synsema build desk.syn -o desk --serve --icon icon.svg --bundle --name "My App" --id com.example.myapp
synsema build desk.syn -o desk --serve --icon icon.svg --bundle --engine-binary ./synsema-macos-aarch64   # from any host: a donor engine
synsema build desk.syn -o desk --serve --icon icon.svg --bundle --engine-binary ./synsema-linux-x86_64    # → "desk/" + install.sh
```

Everything of [`synsema build --serve`](/en/0.6.x/70-cli#synsema-build--one-program-one-binary) applies:
`bind` comes from the clause (or `--bind`), the static mounts of the serve block are bundled, `--sandbox`
/ `--cap-set` bake a ceiling the program can't raise. The desktop flags look at the **format of the
engine being wrapped** (PE, Mach-O or ELF), never at the machine that builds — a Linux donor built from
Windows gets no `.exe`, a Windows donor built from a Mac does.

| Flag | What it does | Where |
|---|---|---|
| (none) | `-o desk` becomes `desk.exe` when the engine is a Windows executable and `-o` has no extension; any extension is kept as is | PE |
| `--no-console` | flips the executable's subsystem from console to GUI (two bytes, before the bundle is appended). Double-click opens no console window. **stdout/stderr go nowhere unless the process inherits them** — launched by double-click, `Start-Process` or `start`, `Serving HTTP…` and the `[serve]` log lines are discarded (verified: the program neither panics nor stops); launched from a shell that hands its handles down (Git Bash `./desk.exe`, a redirect to a file) the output does arrive. If you want a log you can count on, write it yourself (`append_file` under `file.write`). Refused with exit 2 on a non-PE engine | PE |
| `--icon <file.svg\|.png\|.ico>` | an `.svg` is rasterized with the embedded engine (16/32/48/256 px for Windows; 16…1024 for the `.icns`; 256/512 for Linux), a `.png` is rescaled, an `.ico` is used as is on Windows (for macOS/Linux its largest PNG entry is rescaled; an `.ico` with only BMP entries is refused there). Windows: a `.rsrc` section is **merged** with whatever the engine carries (a manifest, in engines built with the GNU toolchain) and Explorer, the taskbar and shortcuts show it. On macOS/Linux the icon lives in the bundle, so `--icon` there needs `--bundle` | PE, or with `--bundle` |
| `--bundle` | no value — the engine's format decides. **Mach-O → `Name.app/`** (`Info.plist`, `PkgInfo`, `MacOS/<stem>` executable, `Resources/<stem>.icns`); **ELF → `<stem>/`** with the binary, `<stem>.desktop`, the PNGs and `install.sh`; **PE → nothing to do** (the `.exe` is the app), said on the `built` line so one script serves all three | all |
| `--name "My App"` / `--id com.example.myapp` | the visible name (default: the `-o` stem) and the reverse-DNS identifier (default `dev.synsema.<stem>`) of the bundle; XML-escaped into the plist; refused without `--bundle` | with `--bundle` |
| `-o desk.app` | on a Mach-O engine, the same as `--bundle` | Mach-O |

The `built …` line says what was done: `built desk.exe (2 files, 37998880 bytes) · serve · bind
127.0.0.1 · no-console · icon 16/32/48/256`. A note on stderr tells you when the `.app` or the Linux dir was built
on Windows (NTFS has no `+x` bit: run `chmod +x` on the target) or has no icon.

## What each OS does with it

**Windows** (verified live, engine v0.6.18 on Windows 11): `desk.exe` double-clicks into the Edge app
window; Explorer, the taskbar and a shortcut show the icon (the shell reads the new resource section;
Explorer caches icons per file name — a new build with the same name may show the old icon until
Explorer restarts); no console; closing the window ends the process (exit 0, ~5 s: the cron tick plus
the 3 s guard). The window's own icon and title come from the page (`<link rel="icon">`, `<title>`),
and because Chromium treats `127.0.0.1` as a secure context, a page with a manifest and a service
worker — the [PWA](/en/0.6.x/41b-pwa) layout — **installs from Edge's menu**: a Start-menu entry with
the manifest icon, its own taskbar identity, and the user opens it from there while the process runs.
Same `desk.exe`, zero extra code.

**macOS** (verified by CI on Apple Silicon — the `.app` built from the real engine launches with `open`,
serves and shuts down; not hand-probed on a Mac): `Name.app` is a real bundle with `LSUIElement = true`
— the process is **an agent**: no Dock icon, no menu bar, no Terminal, exactly what a program that is
not a Cocoa app should be (without the key the Dock shows a bouncing icon that ends in "not
responding"). The visible icon in the Dock is the browser's app window; installed as a PWA it gets the
manifest icon. The appended bundle **keeps the linker's ad-hoc signature valid** for the kernel (the
signature covers the code up to its own blob; that is what CI checks on every push). Downloaded from the
internet without a Developer ID signature and notarization, Gatekeeper blocks it like any unsigned app
(right-click → Open on macOS ≤ 14; "Open Anyway" in Privacy & Security on 15) — signing is an Apple
account, not a build flag; a locally built `.app` carries no quarantine and opens. Built from Windows:
`chmod +x "Name.app/Contents/MacOS/desk"` once on the Mac (the build says so).

**Linux** (verified by tests on CI: the layout, and `install.sh` installing and uninstalling under a
`$HOME`): `desk/` holds the binary, `desk.desktop` (`Terminal=false` is the `--no-console` of Linux,
`Exec=__INSTALL_DIR__/desk`) and the PNGs; `./install.sh` copies to `~/.local/bin`, rewrites `Exec`
with the absolute path, drops the launcher in `~/.local/share/applications` and the icons in
`hicolor` — ten POSIX lines, no root; `./install.sh --uninstall` undoes it. Double-clicking a bare
binary depends on the file manager; the menu entry is the standard way. Chrome/Chromium give an app
window; a Firefox-only desktop gets a tab (Firefox dropped app mode in 2021) — `xdg-open` is the last
resort in the recipe.

## The engine side (engine v0.6.18+)

- **`bind <expr>`** — a clause of the serve block: `bind "127.0.0.1"`. Evaluated when the server starts
  (a non-empty text: an IP or a host name); `--bind` on `synsema serve` overrides it; no clause and no
  flag = `0.0.0.0`, unchanged. Inside a `host` block it is an error ("bind belongs to the serve block").
  `synsema build --serve` bakes the clause's **literal** when there is no `--bind`; without either the
  build stops with exit 2 (a distributable must say where it listens).
- **`shutdown(reason?)`** — asks the running server for its ordered drain, from a route, a socket, a
  cron job or an agent. Returns `nothing`; a second call is a no-op. Under `synsema run` it is an error
  (a run program ends when its top level ends; `stop` leaves a loop or a task); before anything
  listens, an error too (`nothing is running yet`). No capability: quitting is not a host resource.
  A `secret` as the reason is refused (the reason is logged).
- **`platform()`** → `{os, arch}` (`"windows"` / `"macos"` / `"linux"` / other names as Rust reports
  them; `"x86_64"`, `"aarch64"`, …; `"wasm"` / `"wasm32"` in the browser build). A fact of the binary,
  like `args()` and `self_path()`: **no capability**, the same answer under `--sandbox` and
  `--profile pure`. It is what lets one `.syn` pick `cmd` / `open` / `xdg-open` at run time.

## What this is not

- **No system tray, no native menus, no notifications API of the OS.** There is no web API for the
  tray; the notifications you have are the browser's (Web Push works on `127.0.0.1` too). If a project
  needs a tray, a third-party wrapper is its decision — outside the engine.
- **No `.dmg`, `.msi`, AppImage, `.deb`.** The ecosystem's packagers take a `.app`, an `.exe` or a
  directory; `--bundle` produces exactly those inputs and stops there.
- **A fixed port.** Two instances, or a port in use, fail at bind — and with `--no-console` you don't
  see the error. Pick an unusual port; a future `serve on 0` that reports the port is not in this
  version.
- **No log without a console.** `--no-console` discards stdout/stderr by construction; a desktop app
  that wants a log writes one (`append_file(...)` under `require file.write(...)`). `desk.exe --engine
  version` from a terminal does print (engine v0.6.19+: in `--engine` mode a console-less build
  attaches to the parent's console; a redirected file or pipe is respected as is), and an output
  whose reader is gone — PowerShell capturing a GUI program it does not wait for, `| head` —
  ends the process quietly with exit 0 instead of a panic.

## Next

- The installable layout the desktop app reuses: [Your app on the phone (PWA)](/en/0.6.x/41b-pwa).
- `socket` routes, `state_*`, `select`, the ordered shutdown in detail: [Agentic apps](/en/0.6.x/47-agentic-apps).
- Every `build` flag, exit codes, `--engine`: [CLI](/en/0.6.x/70-cli).
