Ctrl K
ComunidadGuías…

docs/architecture/desktop-build.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Desktop build and window surfaces

The desktop build is a statically exported Next.js site served by Express, not a Next.js server.
Everything dynamic lives in the main process. This page states the boundary, because violating it
produces code that looks reasonable in a local build and has nowhere to run in a shipped one.

## The build-time fork

[`frontend/next.config.ts`](../../frontend/next.config.ts) reads `NEXT_BUILD_MODE`. When it equals
`desktop`, three settings change:

| Setting              | Desktop    | Otherwise                    |
| -------------------- | ---------- | ---------------------------- |
| `output`             | `'export'` | `undefined` (server runtime) |
| `trailingSlash`      | `true`     | default                      |
| `images.unoptimized` | `true`     | default                      |

`npm run build:frontend` sets `NEXT_BUILD_MODE=desktop`, so that is the build the application ships.

```mermaid
flowchart LR
  A["Source<br/>frontend/src"] --> B{"NEXT_BUILD_MODE"}
  B -->|"desktop (shipped)"| C["next build → static export<br/>frontend/out"]
  C --> D["Express :3001 serves files<br/>windows load http://localhost:PORT"]
  B -->|"unset"| E["next build / next start<br/>Next.js server runtime"]
  E --> F["not built, not run, not CI-exercised"]
```

## What does not exist in the desktop build

The export target has no server runtime, so the following are absent, and their absence is not
something the build reports. A route handler or a server action added under `frontend/src` compiles
without complaint and is dead code in every shipped build.

- No Next.js server runtime, and therefore no server-side rendering.
- No route handlers: there is no `route.ts` anywhere under `frontend/src`, and no
  `frontend/src/app/api` directory.
- No middleware: there is no `middleware.ts`.
- No server actions: no `'use server'` directive anywhere under `frontend/src`.
- No server cookies, and no server-only APIs such as `next/headers`.
- No image optimisation, because it requires a running server.

Of the 116 `.tsx` files under `frontend/src`, 92 open with `'use client'`. The renderer is the
norm, not the exception.

**All dynamic backend logic belongs in Express on `:3001`, or in Electron IPC for native
capabilities.** If a route needs a database row, a payment calculation, or a printer, it is an
Express route.

## The HTTP data plane

The renderer talks to the API over HTTP against a same-origin base URL, which is why the main
server serves the exported frontend from the same origin it serves `/api` on.

`main/server.ts` mounts the API routers first and serves static files afterwards, so `/api/*` is
never captured by the static fallback. The catch-all route for application pages is
`app.get(/^(?!\/api(?:\/|$)|\/kds(?:\/|$)).*$/, ...)`: it deliberately excludes both the API prefix
and the KDS WebSocket path. `resolveStaticPage` maps a route to its own exported `index.html` and
refuses to build a path that would escape the export directory; unknown or unsafe paths fall back to
the root page.

On Windows a middleware rewrites dotted `__next.` chunk requests, because Windows path handling
does not preserve the nesting the export emits elsewhere.

## The native IPC surface

IPC is a narrow native surface, not a second data plane. The renderer reaches native capability
through `main/preload.ts`, which exposes a `contextBridge` object. The window set is small: window
controls, window state, opening the KDS window, update status, database initialization, the theme
handshake, and native PDF export.

The main window is created with `contextIsolation: true`, `nodeIntegration: false`, and
`sandbox: true`, so `main/preload.ts` may use only `electron` and web APIs. See
[Authentication and authorization](authentication-and-authorization.md#boundaries-we-do-not-harden).

Every IPC handler validates the sender. Both general handlers in [`main/ipc.ts`](../../main/ipc.ts)
(registered via `handle`) and direct update, status, and theme handlers in
[`main/update-ipc.ts`](../../main/update-ipc.ts) (registered via `registerDirectUpdateIpcHandlers`)
enforce strict sender validation via `isTrustedMainWindowSender`, requiring the caller to be the
active main `BrowserWindow`, current top-level frame, and matching loopback server port.

## The native title bar

The main window defaults to 1400x900 with a minimum of 1024x768, persists its window state between
launches, is created hidden, and is shown only once its renderer reports ready (see the readiness
contract below).

### Mode resolution

`resolveTitleBarMode` in [`main/window-options.ts`](../../main/window-options.ts) returns
`native-overlay` or `html-fallback`:

| Platform                          | Result                                                                                                                                                             |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Not `darwin`, `win32`, or `linux` | `html-fallback`                                                                                                                                                    |
| `darwin`                          | `native-overlay` - macOS supplies traffic lights natively and never needs HTML buttons                                                                             |
| `win32` or `linux`                | `native-overlay` only when the Electron major version is at least 33 **and** `BrowserWindow.prototype.setTitleBarOverlay` is a function; otherwise `html-fallback` |

The probe is capability-based: it asks the runtime what it can do. It does not inspect a user-agent
string, and it must not grow one. The resolved mode is reported to the renderer through the
`get-status` IPC handler as `titleBarMode`, alongside the readiness epoch and document nonce.

### Window options

`titleBarStyle` is `'hiddenInset'` on macOS and `'hidden'` elsewhere. On macOS,
`trafficLightPosition` is set to `{ x: 16, y: 14 }`, which centres the 12-pixel traffic-light
buttons in the 40-pixel custom title bar. When the mode is `native-overlay`, `titleBarOverlay` is
supplied with the theme colours and `TITLE_BAR_HEIGHT` (40).

### The top-level application menu

`titleBarStyle: 'hidden'` makes the main window frameless on Windows and Linux, and Electron creates
no menu bar for a frameless window, so `Menu.setApplicationMenu` still registers the accelerators but
draws nothing. [`ApplicationMenuRow`](../../frontend/src/components/layout/ApplicationMenuRow.tsx)
renders the top-level labels inside the title bar's safe area (both in the authenticated dashboard
[`TitleBar`](../../frontend/src/components/layout/TitleBar.tsx) and in
[`DesktopDragSurface`](../../frontend/src/components/layout/DesktopDragSurface.tsx) on standalone
`/setup` and `/auth` routes) and opens the matching submenu through the `open-application-menu` IPC.

`createMenu()` is the single source of the menu. `get-application-menu` returns only the descriptor a
label needs, and `open-application-menu` pops the same `Menu` object that was applied, so roles,
accelerators, and click handlers are the native ones. The popup is a privileged native surface: in
addition to `handle`'s `isTrustedMainWindowSender` check, the handler verifies the main window's own
`webContents` and current renderer frame via `isApplicationMenuSender`.

macOS keeps its native top-level menu: the handler returns an empty entry list on `darwin` and the
renderer draws nothing.

### Theme

[`main/title-bar-theme.ts`](../../main/title-bar-theme.ts) owns the palette: white background with
near-black symbols in light mode, and the inverse in dark mode. `resolveThemeMode` reads the
`theme_mode` setting and resolves anything absent, null, or unrecognised to `system`;
`resolveInitialIsDark` then defers to the OS signal only in `system` mode.

`applyTitleBarOverlayTheme` returns `false` without touching the window on any platform other than
macOS and Windows, so **on Linux a runtime overlay theme update is a no-op**, and it also returns
`false` rather than throwing when a window manager rejects the change, so the last applied colours
survive. `attachTitleBarThemeSync` subscribes to `nativeTheme` updates on the same two platforms and
is inert elsewhere.

### The window-control verb set

The renderer's HTML fallback controls and the top bar's native toggle use one IPC verb set, exposed
as `windowAction` on the preload bridge and handled by the `window-action` channel. Exactly three
actions are accepted:

| Action            | Effect                                                       |
| ----------------- | ------------------------------------------------------------ |
| `minimize`        | minimize the window                                          |
| `toggle-maximize` | leave full screen if in it, otherwise unmaximize or maximize |
| `close`           | close the window, which hides it unless quitting             |

Any other value returns `{ error: 'Unsupported window action' }`. Keep the set at three verbs. Each
new verb is a new native capability the renderer can reach, and the set exists to stay narrow.

### Renderer readiness, epoch, nonce, and the fail-safe

[`main/window-readiness.ts`](../../main/window-readiness.ts) stops a window from being shown before
its controls exist.

- Each new document begins an **epoch**. `beginRendererDocument()` increments the epoch, clears the
  recorded nonce, and arms the fail-safe timer. A main-frame navigation that is not same-document,
  as decided by `isFullDocumentMainFrameNavigation`, starts a new epoch.
- The preload generates a UUID once per document and sends it to the main process with
  `sendSync`. `registerRendererDocument` accepts it only if it matches a UUID v4 pattern.
- The renderer reports ready with its epoch and that nonce. `markWindowRendererReady` accepts the
  report only when the epoch is the current one, is an integer of at least 1, and the nonce matches
  the registered one. Stale reports from a replaced document are rejected.
- Only then does `isWindowRendererReady` return true, and only then does `showMainWindow` show the
  window.
- A 10-second fail-safe timer runs alongside. If the renderer never confirms, the window is shown
  anyway and an error is logged. A window with no working caption buttons is better than an
  invisible window; the timer records that the fail-safe fired through
  `isRendererReadinessFailSafeShown`, which is itself part of the gate in `showMainWindow`.

`isCurrentRendererFrame` matches a sender frame against the live frame by `frameToken` and rejects
detached frames, which is how handlers confirm the caller is the live renderer.

## Other window surfaces

| Window     | Module             | URL                                                    | Preload |
| ---------- | ------------------ | ------------------------------------------------------ | ------- |
| POS        | `createMainWindow` | `http://localhost:<main port>`                         | yes     |
| KDS        | `createKdsWindow`  | `http://<LAN IP>:<kds port>/kds`                       | no      |
| Server app | served page        | `http://localhost:<server app port>/server-standalone` | no      |

The KDS window deliberately has no preload: `appendThemeQueryParam` seeds its first-paint palette
through a `theme` query parameter, and on mount KDS resolves any local `flo-kds-theme-override` or
syncs `theme_mode` from `GET /api/kds/info`. It is confined to its own origin: window-open requests
are denied outright, and navigation away from the KDS origin is prevented.

In the POS window, `target="_blank"` links are opened in a new window only when the URL passes the
local allowlist check in [`main/security/url-allowlist.ts`](../../main/security/url-allowlist.ts).
In-window navigation (`will-navigate`) is handled by `createMainWindowWillNavigateHandler` in
[`main/window-options.ts`](../../main/window-options.ts), which confines navigation to local
application URLs (`isAllowedLocalWindowUrl`). Safe external URLs (`isSafeExternalUrl`) are prevented
from navigating inside the window and opened in the default system browser via `shell.openExternal`;
unsafe schemes are blocked with a warning. Child windows spawned from the POS window are guarded by
`attachChildWindowLifecycleGuards`, which denies nested `window.open` calls and attaches the same
`will-navigate` handler while preserving `about:blank` print popups and local KDS windows.

## Content Security Policy

`buildCspHeader` in [`main/csp.ts`](../../main/csp.ts) applies to responses from the local servers.
The base policy is `default-src 'self'`, with `script-src` and `style-src` at `'self'`
`'unsafe-inline'`, `img-src` and `font-src` at `'self' data:`, and `frame-ancestors 'none'`.
`connect-src` additionally admits `http:`, `ws:`, and `wss:` for the request's own `Host` origin,
but only when that header matches a safe host pattern, so a hostile `Host` cannot smuggle extra
directives.

Note that `script-src` permits inline script. `eval` is not permitted. Adding a nonce or hash policy
here is a security change with its own review, not a documentation edit.