---
slug: 41c-desktop
title: Tu app en el escritorio (un binario)
description: Entregá una app web Synsema como app de escritorio — un binario que abre su UI en una ventana de app del navegador, lleva tu ícono, no necesita consola y se cierra cuando se cierra la ventana. `synsema build --serve --no-console --icon --bundle`, la cláusula `bind`, `shutdown()`, `platform()`. Sin Tauri, sin webview, sin framework.
example_ids: [desktop]
---

# 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](#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):

```synsema
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](/es/0.6.x/41b-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:

```sh
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

```sh
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`](/es/0.6.x/70-cli#synsema-build--un-programa-un-binario):
`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](/es/0.6.x/41b-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-console` no
  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-console` descarta 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)](/es/0.6.x/41b-pwa).
- Rutas `socket`, `state_*`, `select`, el shutdown ordenado en detalle: [Apps agénticas](/es/0.6.x/47-agentic-apps).
- Todos los flags de `build`, códigos de salida, `--engine`: [CLI](/es/0.6.x/70-cli).
