Frontend
Tu app en el escritorio (un binario)
Una app web Synsema se vuelve app de escritorio sin cambiar lo que es: un binario que lleva el engine, tu programa, sus templates y sus archivos estáticos, escucha en 127.0.0.1, abre la UI como ventana de app del navegador que el usuario ya tiene (Edge o Chrome en modo --app=: sin barra de direcciones, con su propia entrada en la barra de tareas), muestra tu ícono, abre sin consola y se apaga sola cuando se cierra la última ventana. Doble clic, usar, cerrar. El backend, los agentes y los secretos se quedan en el proceso, como en un servidor — el navegador es sólo la pantalla.
Lo que no es: un toolkit de UI nativa, un envoltorio de webview, una API de bandeja del sistema. El engine no crece una capa de GUI (ver Lo que esto no es); las piezas de escritorio son cirugía sobre el binario y unos archivos al lado, hechas por synsema build (engine v0.6.18+).
La forma de una app de escritorio§
Cuatro decisiones, todas en el programa (este es el desk.syn completo; cada línea está verificada en vivo):
require serve(8123)
require exec("cmd") -- Windows: Edge viene con Windows; --app= da una ventana sin barra de direcciones
require exec("open") -- macOS: Chrome/Edge en modo app; si no, el navegador por defecto (una pestaña)
require exec("google-chrome") -- Linux: modo app…
require exec("chromium")
require exec("xdg-open") -- …o una pestaña (Firefox no tiene modo app)
require file.read("index.html")
require time
-- Cada intento es un run() bajo su propio exec(); "no se pudo lanzar" es un error atrapable,
-- un exit_code distinto de cero es un dato. El primero que abre, gana.
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)
-- hubo ventana y se cerró hace más de 3 s → apagar (un reload cierra y reabre el socket en < 1 s)
when closed != nothing and now() - closed > 3
shutdown("window closed")
-- nunca hubo ventana (sin navegador, perfil roto, un `start` que no navega):
-- no quedarse invisible para siempre — 30 s cubren un arranque frío de 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": "Mi 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() -- corre DESPUÉS de que el listener está arriba: el top level sigue tras el bloque serve
Y en index.html, una línea dentro de un bloque { raw } … { end } (las llaves son huecos del template): new WebSocket("ws://127.0.0.1:8123/ws") — cada carga de la ventana abre su propio socket.
1. bind "127.0.0.1" — el listener es local; nada de la LAN llega. La cláusula es parte del bloque serve (engine v0.6.18+), así que la intención viaja con el programa; --bind en la línea de comandos le sigue ganando (es decisión del operador), y el default sin ninguno de los dos sigue siendo 0.0.0.0, como siempre. 2. La ventana es el navegador en modo app, lanzado con run() bajo require exec("<cmd>") — las mismas reglas de capability que cualquier proceso hijo; nada se concede de ambiente. Edge y Chrome son single-instance: si ya hay una ventana abierta, --app= le pasa la URL al navegador que corre y el lanzador termina al instante — por eso vigilar el proceso del lanzador mentiría. 3. El socket es la verdad. Hay ventana ⇔ hay WebSocket. La ruta socket las cuenta en state_*; el navegador cierra el socket cuando la ventana se cierra (también si la pestaña se cae). 4. shutdown("window closed") corre el mismo drain ordenado que Ctrl-C (listener cerrado, trabajo en vuelo drenado, cron y agentes detenidos, exit 0). Es idempotente, su motivo va al log ([serve] shutdown requested by the program: window closed), y un secret como motivo se rechaza — nada sellado llega a un log por acá. La segunda rama importa: si nunca se abre una ventana (sin navegador instalado, un perfil roto, un lanzador que "tiene éxito" sin navegar), un proceso --no-console quedaría invisible para siempre; 30 s sin un primer socket es el corte honesto (verificado: exit 0 con no window opened in 30 s).
El mismo programa es un servidor normal: synsema serve desk.syn también abre la ventana (el top level sigue después del bloque serve), y Ctrl-C sigue funcionando.
Desde un scaffold: synsema init --desktop (engine v0.6.19+)§
No hace falta tipear la receta: synsema init miapp --desktop escribe el scaffold PWA con su API en api.syn (un grupo export routes api) más desk.syn — exactamente el programa de arriba, montando la misma API, DESK_NO_WINDOW=1 para saltear el navegador en tests — y public/desk.js, el socket que abre cada ventana (index.html lo carga sólo bajo { when desktop }, así app.syn sirve la misma página sin él). Una API, dos entradas:
synsema init miapp --desktop
cd miapp
synsema serve desk.syn # se abre la ventana de app; cerrala y el proceso termina
synsema serve app.syn # la misma app como sitio / PWA en :8080
Los límites por ruta del API (/api/push/subscribe 10/min, /api/push/test 2/min) viajan con el grupo: desde v0.6.19 rate_limit y timeout dentro de un grupo export routes funcionan como en una ruta directa (una ruta montada tiene su propia zona; un prefijo de mount es otra zona). Las rutas stream y socket siguen yendo en el bloque serve — synsema check lo dice antes que serve.
Construirla§
synsema build desk.syn -o desk --serve --no-console --icon icon.svg # Windows: desk.exe — ícono, sin ventana de consola
synsema build desk.syn -o desk --serve --icon icon.svg --bundle # host macOS: "desk.app/"
synsema build desk.syn -o desk --serve --icon icon.svg --bundle --name "Mi App" --id com.example.miapp
synsema build desk.syn -o desk --serve --icon icon.svg --bundle --engine-binary ./synsema-macos-aarch64 # desde cualquier host: un motor donante
synsema build desk.syn -o desk --serve --icon icon.svg --bundle --engine-binary ./synsema-linux-x86_64 # → "desk/" + install.sh
Aplica todo lo de synsema build --serve: bind sale de la cláusula (o de --bind), los mounts estáticos del bloque serve se empaquetan, --sandbox / --cap-set hornean un techo que el programa no puede subir. Los flags de escritorio miran el formato del motor que se envuelve (PE, Mach-O o ELF), nunca la máquina que construye — un donante Linux construido desde Windows no recibe .exe, un donante Windows construido desde un Mac sí.
| Flag | Qué hace | Dónde |
|---|---|---|
| (ninguno) | -o desk pasa a ser desk.exe cuando el motor es un ejecutable de Windows y -o no tiene extensión; cualquier extensión se respeta tal cual | PE |
--no-console | cambia el subsistema del ejecutable de consola a GUI (dos bytes, antes de anexar el bundle). El doble clic no abre ventana de consola. stdout/stderr no van a ningún lado salvo que el proceso los herede — lanzado por doble clic, Start-Process o start, la línea Serving HTTP… y los logs [serve] se descartan (verificado: el programa ni panica ni se detiene); lanzado desde una shell que le pasa sus handles (Git Bash ./desk.exe, una redirección a archivo) la salida sí llega. Si querés un log con el que contar, escribilo vos (append_file bajo file.write). Rechazado con exit 2 sobre un motor que no es PE | PE |
--icon <archivo.svg|.png|.ico> | un .svg se rasteriza con el motor embebido (16/32/48/256 px para Windows; 16…1024 para el .icns; 256/512 para Linux), un .png se re-escala, un .ico se usa tal cual en Windows (para macOS/Linux se re-escala su entrada PNG más grande; un .ico sólo con entradas BMP se rechaza ahí). Windows: se mergea una sección .rsrc con lo que el motor traiga (un manifest, en motores compilados con la toolchain GNU) y el Explorador, la barra de tareas y los accesos directos lo muestran. En macOS/Linux el ícono vive en el bundle, así que ahí --icon necesita --bundle | PE, o con --bundle |
--bundle | sin valor — decide el formato del motor. Mach-O → Nombre.app/ (Info.plist, PkgInfo, ejecutable MacOS/<stem>, Resources/<stem>.icns); ELF → <stem>/ con el binario, <stem>.desktop, los PNG e install.sh; PE → nada que hacer (el .exe es la app), dicho en la línea built para que un mismo script sirva para los tres | todos |
--name "Mi App" / --id com.example.miapp | el nombre visible (default: el stem de -o) y el identificador inverso (default dev.synsema.<stem>) del bundle; escapados como XML en el plist; rechazados sin --bundle | con --bundle |
-o desk.app | sobre un motor Mach-O, lo mismo que --bundle | Mach-O |
La línea built … dice qué se hizo: built desk.exe (2 files, 37998880 bytes) · serve · bind 127.0.0.1 · no-console · icon 16/32/48/256. Un aviso en stderr te dice cuando el .app o el directorio Linux se construyó desde Windows (NTFS no tiene bit +x: corré chmod +x en el destino) o no tiene ícono.
Qué hace cada sistema con ella§
Windows (verificado en vivo, engine v0.6.18 en Windows 11): desk.exe con doble clic abre la ventana de app de Edge; el Explorador, la barra de tareas y un acceso directo muestran el ícono (el shell lee la sección de recursos nueva; el Explorador cachea íconos por nombre de archivo — un build nuevo con el mismo nombre puede mostrar el ícono viejo hasta reiniciar el Explorador); sin consola; cerrar la ventana termina el proceso (exit 0, ~5 s: el tick del cron más la guarda de 3 s). El ícono y el título de la ventana salen de la página (<link rel="icon">, <title>), y como Chromium trata 127.0.0.1 como contexto seguro, una página con manifest y service worker — el layout de la PWA — se instala desde el menú de Edge: una entrada en el Menú Inicio con el ícono del manifest, identidad propia en la barra de tareas, y el usuario la abre desde ahí mientras el proceso corre. El mismo desk.exe, cero código extra.
macOS (verificado por el CI en Apple Silicon — el .app construido desde el motor real se lanza con open, sirve y se apaga; no sondeado a mano en un Mac): Nombre.app es un bundle real con LSUIElement = true — el proceso es un agente: sin ícono en el Dock, sin barra de menú, sin Terminal, exactamente lo que debe ser un programa que no es una app Cocoa (sin esa clave el Dock muestra un ícono rebotando que termina en "no responde"). El ícono visible en el Dock es la ventana de app del navegador; instalada como PWA recibe el ícono del manifest. El bundle anexado mantiene válida la firma ad-hoc del linker para el kernel (la firma cubre el código hasta su propio blob; eso es lo que el CI verifica en cada push). Descargado de internet sin firma Developer ID ni notarización, Gatekeeper lo bloquea como a cualquier app sin firmar (clic derecho → Abrir en macOS ≤ 14; "Abrir de todos modos" en Privacidad y seguridad en 15) — firmar es una cuenta de Apple, no un flag del build; un .app construido localmente no lleva cuarentena y abre. Construido desde Windows: chmod +x "Nombre.app/Contents/MacOS/desk" una vez en el Mac (el build lo dice).
Linux (verificado por tests en el CI: el layout, e install.sh instalando y desinstalando bajo un $HOME): desk/ tiene el binario, desk.desktop (Terminal=false es el --no-console de Linux, Exec=__INSTALL_DIR__/desk) y los PNG; ./install.sh copia a ~/.local/bin, reescribe Exec con la ruta absoluta, deja el lanzador en ~/.local/share/applications y los íconos en hicolor — diez líneas POSIX, sin root; ./install.sh --uninstall lo deshace. El doble clic sobre un binario suelto depende del gestor de archivos; la entrada del menú es la forma estándar. Chrome/Chromium dan ventana de app; un escritorio sólo con Firefox recibe una pestaña (Firefox dejó el modo app en 2021) — xdg-open es el último recurso de la receta.
El lado del engine (engine v0.6.18+)§
bind <expr>— una cláusula del bloque serve:bind "127.0.0.1". Se evalúa al arrancar el
server (un texto no vacío: una IP o un nombre de host); --bind en synsema serve la sobreescribe; sin cláusula y sin flag = 0.0.0.0, sin cambios. Dentro de un bloque host es un error ("bind belongs to the serve block"). synsema build --serve hornea el literal de la cláusula cuando no hay --bind; sin ninguno de los dos el build se detiene con exit 2 (un distribuible tiene que decir dónde escucha).
shutdown(reason?)— le pide al server que corre su drain ordenado, desde una ruta, un socket,
un job de cron o un agente. Devuelve nothing; una segunda llamada no hace nada. Bajo synsema run es un error (un programa run termina cuando termina su top level; stop sale de un loop o de una task); antes de que algo escuche, también (nothing is running yet). Sin capability: apagarse no es un recurso del host. Un secret como motivo se rechaza (el motivo se loguea).
platform()→{os, arch}("windows"/"macos"/"linux"/ otros nombres tal como los
reporta Rust; "x86_64", "aarch64", …; "wasm" / "wasm32" en el build para navegador). Un hecho del binario, como args() y self_path(): sin capability, la misma respuesta bajo --sandbox y --profile pure. Es lo que permite que un mismo .syn elija cmd / open / xdg-open en runtime.
Lo que esto no es§
- Sin bandeja del sistema, sin menús nativos, sin API de notificaciones del SO. No hay API web para
la bandeja; las notificaciones que tenés son las del navegador (Web Push también funciona en 127.0.0.1). Si un proyecto necesita bandeja, un envoltorio de terceros es su decisión — fuera del engine.
- Sin
.dmg,.msi, AppImage,.deb. Los empaquetadores del ecosistema toman un.app, un.exe
o un directorio; --bundle produce exactamente esas entradas y ahí se detiene.
- Un puerto fijo. Dos instancias, o un puerto ocupado, fallan en el bind — y con
--no-consoleno
ves el error. Elegí un puerto poco común; un futuro serve on 0 que reporte el puerto no está en esta versión.
- Sin log si no hay consola.
--no-consoledescarta stdout/stderr por construcción; una app de
escritorio que quiere un log lo escribe (append_file(...) bajo require file.write(...)). desk.exe --engine version desde una terminal sí imprime (engine v0.6.19+: en modo --engine un build sin consola se pega a la consola del padre; un archivo o tubería redirigidos se respetan tal cual), y una salida cuyo lector se fue — PowerShell capturando un programa GUI al que no espera, | head — termina el proceso en silencio con exit 0 en vez de un pánico.
Siguiente§
- El layout instalable que la app de escritorio reutiliza: Tu app en el teléfono (PWA).
- Rutas
socket,state_*,select, el shutdown ordenado en detalle: Apps agénticas. - Todos los flags de
build, códigos de salida,--engine: CLI.