# 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.

2026-09-02 · https://synsema.com/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](/lang/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).

