---
issueRef: "#68"
version: 8
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784277313706
---
# Ticket #68 — Serveur embarqué activable depuis le desktop : carnet de chantier

Statut : **livré et mergé dans `develop` (`f965f64`)** le 2026-07-16, en exécution autonome de nuit.
**Reste à faire : la validation live utilisateur.** Rien n'a été cliqué dans l'app réelle — voir la réserve plus bas.

## Livraison en deux temps

**B1 — seam shared-core** (mergé plus tôt, `b7d38f1` + `328c941`). `web_server::run_embedded_with_core(config, Arc<BackendCore>)`. Sans lui, le desktop aurait construit une **seconde composition root dans le même processus** — deux event bus, deux registres live, deux jeux de stores — et le conflit d'écriture app-data-dir serait réapparu à l'intérieur d'un seul process.

**B2/F1/F2 — le reste** (`49e1d5a` backend, `7efa634` frontend, merge `b30c9c7`).
- `crates/app-tauri/src/embedded_server.rs` : controller de cycle de vie sur `AppState`, commandes `embedded_server_start/stop/status` et `get/save/preview_server_exposure_settings`, `FsServerExposureSettingsStore`.
- `frontend/src/features/settings/` : `SettingsView`, `DeploymentSettings`, `useDeployment` ; `adapters/desktopServer.ts` ; port `DesktopServerGateway`.
- `ProjectsView` : `showSettings: boolean` → navigation interne `AI Profiles` / `Deployment`. Le libellé alternant « Close AI Profiles » a disparu.

## Décisions structurantes appliquées

- **Pas de `PanelId: "settings"`.** Architect s'est corrigé en cours de route : Settings existe déjà dans `ProjectsView` comme surface principale, hors modèle `viewPlacement`. C'est de la configuration globale persistée, pas une vue projet dockable/détachable.
- **Persistance dans `<app-data-dir>/deployment/server-exposure.json`**, pas `localStorage`/`uiPreferences` : falsifier cette config peut exposer le serveur, c'est de la config de sécurité. Écriture atomique, `0600` posé **avant** le `rename`.
- **Le pairing code n'est jamais persisté** — secret runtime, affiché seulement si `running`.
- **Le frontend n'invente jamais une IP** : `candidateLanAddresses` et `upstreamUrl` viennent du backend, d'où l'existence de `preview`.
- `EmbedderSettings` / `ModelServersPanel` **non rapatriés** : refonte latérale hors sprint. La liste de sections est le seam où ils s'inséreront.

## ⚠️ Le brief de Main était FAUX — intercepté par DevFrontend

Main décrivait le mode `remoteProxyOtherMachine` avec deux champs (origine publique + IP du proxy). **`validate_settings` en exige un troisième : `lanBindAddress`**, et rejette loopback/unspecified. Construit selon le brief, le lot aurait été **vert et cassé** : chaque `save` et chaque `start` en mode 3 auraient échoué, sans qu'aucun test ne le voie.

Corrigé par un select alimenté par `candidateLanAddresses` — la règle « le frontend n'invente jamais une IP » tient. Leçon consignée dans `7efa634`.

## Vérification réelle (Main puis Git, tous deux hors sandbox)

```
npm run typecheck        → propre
npx vitest run           → 87 files / 789 passed
cargo test -p app-tauri  → 43 passed, 1 ignored
cargo test -p web-server → 66 passed
```
Re-vérifié sur `develop` après merge : 24 suites Rust, 0 échec.

**Invariants vérifiés par exécution, pas par lecture de rapport :** `run_embedded_with_core` est le seul appelant (aucun `run_embedded` nu dans `app-tauri`) ; aucune fuite d'`EmbeddedServer*`/`ServerExposure*` dans `domain` ni `application` ; ordre du store `write(tmp)` → `chmod 0600` → `rename` ; `stop()` appelé à la sortie d'app (`lib.rs:153`) ; `features/web` n'importe pas la surface Settings (épinglé par test) et le transport web rend `UNSUPPORTED_ON_WEB`.

**Piège d'environnement :** `app-tauri` et `web-server` ont un sandbox qui bloque `TcpListener::bind` (EPERM). Le test `end_to_end_over_real_loopback` est `#[ignore]` **préexistant** — lancé hors sandbox avec `--ignored`, il passe. Garde d'environnement confirmée par exécution, pas faux vert.

## RÉSERVE — aucune validation live

Tout est vert, **rien n'a été cliqué**. Le panneau n'a jamais été ouvert dans l'app réelle : ni le démarrage/arrêt, ni l'affichage du pairing code, ni les trois modes, ni la persistance entre deux lancements. jsdom ne prouve pas le rendu. Une AppImage a été construite pour que l'utilisateur teste — c'est le point de vérité qui manque.

## Dette identifiée, non traitée (ne bloque rien)

1. **`preview` valide avant de prévisualiser** (`embedded_server.rs:211`) : un brouillon mode 3 ne peut pas être prévisualisé tant qu'il n'a pas le `lanBindAddress` que la liste des candidats doit justement fournir. Contourné côté UI par une sonde `localOnly` toujours valide. Défaut d'ordonnancement backend.
2. **Code mort** : le warning `missingTrustedProxy` (`embedded_server.rs:452`) est inatteignable — `validate_settings` rejette les `trustedProxies` vides en mode 3 avant que `preview_settings` ne puisse le produire.
3. **Aucun événement de statut** : le backend n'émet rien, `onStatusChanged` est du polling **dans l'adapter Tauri**. La forme du port est prête pour un vrai événement, sans toucher l'UI.
4. **Fenêtre umask sur le temporaire** du store : le `0600` arrive après la création. Aucun secret dans ce fichier (modes, ports, IP), risque faible. Un `OpenOptions::mode(0o600)` à la création fermerait le sujet.
5. **Piège latent hors #68** : les stubs `unsupported.ts` préexistants (`WebRemoteGateway`, `WebWindowGateway`) *throwent de façon synchrone* depuis des méthodes qui rendent des Promise — `.then(ok, err)` ne les attraperait pas. Sans effet aujourd'hui (les appelants `await`), mais mérite un nettoyage.

## Contexte : pourquoi #68 est passé avant #71 et #73

Arbitrage **utilisateur**, contre la recommandation d'Architect. Ce dernier proposait #72 → #73 → #71 → #68, ce qui reléguait en dernier la fonctionnalité demandée le matin même. L'utilisateur a tranché : #72 ajusté, puis #68. #71 (diagnostics) et #73 (TLS intégré) suivent.
