Frontend
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); 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):
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 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:
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§
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 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 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.exeor 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-consoleyou 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-consolediscards 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).
socketroutes,state_*,select, the ordered shutdown in detail: Agentic apps.- Every
buildflag, exit codes,--engine: CLI.