# Ship your web app as a desktop app — one binary, a browser app window, your icon, no console

> synsema build --serve --no-console --icon turns the same server-rendered app into a double-click desktop app — a .exe with your icon on Windows, a .app on macOS, a launcher folder on Linux. The browser the user already has is the window, and the process quits when the last one closes. No Tauri, no webview, no second codebase.

Published 2026-09-02 · https://synsema.org/blog/desktop-app-one-binary-browser-window


A desktop app is a web app that opens in its own window, carries an icon, shows no console and
goes away when you close it. Synsema gets there without a native toolkit: the server is the
program, the window is the browser the user already has, and `synsema build` does the surgery on
the binary. The backend, the agents and the secrets stay in the process, exactly as on a server.

## The shape

Four decisions, all in the program. This is the Windows branch of the recipe; the macOS and Linux
branches (`open -a "Google Chrome" --args --app=…`, `google-chrome --app=…`, `xdg-open`) pick
themselves with `platform()`, and `synsema init --desktop` writes the whole thing for you.

```synsema
require serve(8123)
require exec("cmd")             -- Windows: Edge in --app= mode, no address bar
require file.read("index.html")
require time

let started be now()

task maybe_quit()
    when state_get("windows", 0) > 0
        give nothing
    let closed be state_get("last_close", nothing)
    when closed != nothing and now() - closed > 3        -- last window closed 3 s ago
        shutdown("window closed")
    when closed == nothing and now() - started > 30       -- no window ever opened
        shutdown("no window opened in 30 s")

serve on 8123
    bind "127.0.0.1"                                      -- local only: the LAN never sees it
    route "GET /"
        give render("index.html", {"title": "My app"})
    route "GET /ws"                                       -- one socket per open window
        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)
run("cmd", ["/c", "start", "", "msedge", "--app=http://127.0.0.1:8123/"])
```

1. **`bind "127.0.0.1"`** is a clause of the serve block, so the intent travels with the program.
2. **The window is the browser in app mode**, launched with `run()` under `require exec` — the
   same capability rules as any child process. Edge and Chrome are single-instance, so watching
   the launcher's process would lie; the socket is the truth: there is a window ⇔ there is a
   WebSocket (the page opens one with `new WebSocket("ws://" + location.host + "/ws")`).
3. **`shutdown()`** runs the same ordered drain Ctrl-C does — listener closed, in-flight work
   drained, cron and agents stopped, exit 0. It is idempotent, it refuses a `secret` as its
   reason, and under `synsema run` it is a clear error, never a silent exit.
4. **The 30-second guard** matters: a console-less process whose browser never opened would
   otherwise stay invisible forever.

## Build it

```sh
synsema build desk.syn -o desk --serve --no-console --icon icon.svg                # Windows: desk.exe
synsema build desk.syn -o desk --serve --icon icon.svg --bundle                    # macOS: 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-linux-x86_64   # desk/ + install.sh
```

The flags look at the **engine being wrapped**, never at the machine that builds. `-o desk`
becomes `desk.exe` when the engine is a Windows executable. `--no-console` flips two bytes in the
executable's header so a double-click opens no console window. `--icon` takes an `.svg`, a `.png`
or an `.ico`: on Windows it becomes a resource section Explorer, the taskbar and shortcuts read;
on macOS an `.icns` inside the `.app`; on Linux the PNGs next to the launcher. `--bundle` writes
`My App.app/` (with `LSUIElement`, so the process runs as an agent: no Dock icon, no Terminal) or
`desk/` with a `.desktop` file and a ten-line `install.sh` that installs under `~/.local`, no root.
The `built …` line says exactly what was done:

```
built desk.exe (15 files, 38333285 bytes) · serve · bind 127.0.0.1 · no-console · icon 16/32/48/256
```

## What each OS does with it

- **Windows** — verified live: double-click opens the Edge app window, Explorer shows your icon,
  closing the window ends the process with exit 0 in about five seconds. Because Chromium treats
  `127.0.0.1` as a secure context, a page with a manifest and a service worker also installs from
  Edge's menu, with its own Start-menu entry and taskbar identity — the same files the
  [PWA scaffold](/blog/installable-app-pwa-native-push) ships.
- **macOS** — verified by CI on Apple Silicon: the `.app` built from the real engine launches
  with `open`, serves and stops, and the appended bundle keeps the linker's ad-hoc signature
  valid. Downloaded without a Developer ID signature it meets Gatekeeper like any unsigned app
  (right-click → Open); a locally built `.app` opens directly.
- **Linux** — the folder layout and `install.sh` are verified by tests: `Terminal=false` in the
  `.desktop` entry is the `--no-console` of Linux. Chrome and Chromium give an app window; a
  Firefox-only desktop gets a tab.

## What it is not

No system tray, no native menus, no `.dmg`/`.msi`/AppImage — the ecosystem's packagers take a
`.app`, a `.exe` or a folder, and that is exactly what `--bundle` produces. The port is fixed, so
pick an unusual one. And without a console there is no log unless you write one (`append_file`
under `file.write`); `desk.exe --engine version` from a terminal still prints, and an output
nobody reads is a quiet exit, not a panic.

## Start from the scaffold

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

`--desktop` writes the [PWA scaffold](/blog/installable-app-pwa-native-push) with its API in
`api.syn` — an `export routes` group both entries mount, per-route rate limits included — plus
`desk.syn` and `public/desk.js`. One API, two entries, three ways to ship.

**Try it:** [install Synsema](/install) (0.6.19 or newer), run the three lines above, then read
[Your app on the desktop](https://synsema.dev/en/0.6.x/41c-desktop) for every flag and the
honest notes per OS. You can try the language first at [try.synsema.org](https://try.synsema.org).

