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.
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.
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§
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 ships.
- macOS — verified by CI on Apple Silicon: the
.appbuilt 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.share verified by tests:Terminal=falsein 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§
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 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 (0.6.19 or newer), run the three lines above, then read Your app on the desktop for every flag and the honest notes per OS. You can try the language first at try.synsema.org.