# Distribuí tu app web como app de escritorio — un binario, una ventana del navegador, tu ícono, sin consola

> synsema build --serve --no-console --icon convierte la misma app renderizada en el servidor en una app de escritorio de doble clic — un .exe con tu ícono en Windows, un .app en macOS, una carpeta lanzadora en Linux. La ventana es el navegador que el usuario ya tiene, y el proceso termina cuando se cierra la última. Sin Tauri, sin webview, sin segundo código.

Published 2026-09-02 · https://synsema.org/es/blog/desktop-app-one-binary-browser-window


Una app de escritorio es una app web que abre en su propia ventana, lleva un ícono, no muestra
consola y se va cuando la cerrás. Synsema llega ahí sin toolkit nativo: el servidor es el programa,
la ventana es el navegador que el usuario ya tiene, y `synsema build` hace la cirugía sobre el
binario. El backend, los agentes y los secretos se quedan en el proceso, exactamente igual que en un
servidor.

## La forma

Cuatro decisiones, todas en el programa. Esta es la rama de Windows de la receta; las de macOS y
Linux (`open -a "Google Chrome" --args --app=…`, `google-chrome --app=…`, `xdg-open`) se eligen
solas con `platform()`, y `synsema init --desktop` te escribe todo.

```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"`** es una cláusula del bloque serve, así que la intención viaja con el
   programa.
2. **La ventana es el navegador en modo app**, lanzado con `run()` bajo `require exec` — las mismas
   reglas de capacidad que cualquier proceso hijo. Edge y Chrome son de instancia única, así que
   mirar el proceso del lanzador mentiría; el socket es la verdad: hay ventana ⇔ hay WebSocket (la
   página abre uno con `new WebSocket("ws://" + location.host + "/ws")`).
3. **`shutdown()`** corre el mismo drenaje ordenado que Ctrl-C — listener cerrado, trabajo en vuelo
   drenado, cron y agentes detenidos, exit 0. Es idempotente, rechaza un `secret` como motivo y bajo
   `synsema run` es un error claro, nunca una salida silenciosa.
4. **La guarda de 30 segundos** importa: un proceso sin consola cuyo navegador nunca abrió quedaría
   invisible para siempre.

## Construilo

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

Las banderas miran **el motor que se está envolviendo**, nunca la máquina que construye. `-o desk`
se vuelve `desk.exe` cuando el motor es un ejecutable de Windows. `--no-console` da vuelta dos bytes
en el encabezado del ejecutable para que un doble clic no abra ninguna consola. `--icon` toma un
`.svg`, un `.png` o un `.ico`: en Windows se convierte en una sección de recursos que leen el
Explorador, la barra de tareas y los accesos directos; en macOS en un `.icns` dentro del `.app`; en
Linux en los PNG al lado del lanzador. `--bundle` escribe `My App.app/` (con `LSUIElement`, así el
proceso corre como agente: sin ícono en el Dock, sin Terminal) o `desk/` con un archivo `.desktop` y
un `install.sh` de diez líneas que instala bajo `~/.local`, sin root. La línea `built …` dice
exactamente qué se hizo:

```
built desk.exe (15 files, 38333285 bytes) · serve · bind 127.0.0.1 · no-console · icon 16/32/48/256
```

## Qué hace cada sistema operativo con esto

- **Windows** — verificado en vivo: el doble clic abre la ventana de app de Edge, el Explorador
  muestra tu ícono y cerrar la ventana termina el proceso con exit 0 en unos cinco segundos. Como
  Chromium trata a `127.0.0.1` como contexto seguro, una página con manifiesto y service worker
  además se instala desde el menú de Edge, con su propia entrada en el menú Inicio y su identidad en
  la barra de tareas — los mismos archivos que trae el
  [andamiaje PWA](/es/blog/installable-app-pwa-native-push).
- **macOS** — verificado por CI en Apple Silicon: el `.app` construido con el motor real se lanza
  con `open`, sirve y se detiene, y el bundle adosado mantiene válida la firma ad-hoc del enlazador.
  Descargado sin firma de Developer ID se encuentra con Gatekeeper como cualquier app sin firmar
  (clic derecho → Abrir); un `.app` construido localmente abre directo.
- **Linux** — la estructura de carpetas y el `install.sh` están verificados por tests:
  `Terminal=false` en la entrada `.desktop` es el `--no-console` de Linux. Chrome y Chromium dan una
  ventana de app; un escritorio con solo Firefox recibe una pestaña.

## Qué no es

Sin bandeja del sistema, sin menús nativos, sin `.dmg`/`.msi`/AppImage — los empaquetadores del
ecosistema toman un `.app`, un `.exe` o una carpeta, y eso es exactamente lo que produce `--bundle`.
El puerto es fijo, así que elegí uno poco común. Y sin consola no hay log salvo que lo escribas
(`append_file` bajo `file.write`); `desk.exe --engine version` desde una terminal sigue imprimiendo,
y una salida que nadie lee es un cierre silencioso, no un panic.

## Arrancá desde el andamiaje

```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` escribe el [andamiaje PWA](/es/blog/installable-app-pwa-native-push) con su API en
`api.syn` — un grupo `export routes` que montan las dos entradas, con los límites de tasa por ruta
incluidos — más `desk.syn` y `public/desk.js`. Una API, dos entradas, tres formas de distribuir.

**Probalo:** [instalá Synsema](/es/install) (0.6.19 o más nuevo), corré las tres líneas de arriba y
después leé [Tu app en el escritorio](https://synsema.dev/es/0.6.x/41c-desktop) para cada bandera y
las notas honestas por sistema operativo. Podés probar el lenguaje primero en
[try.synsema.org](https://try.synsema.org).

