feat(frontend): panneau Settings Deployment et gateway serveur desktop (#68 F1+F2)

Donne à l'utilisateur la surface pour activer le serveur depuis l'app.

- `features/settings/` : `SettingsView`, `DeploymentSettings`, `useDeployment`.
- `adapters/desktopServer.ts` + port `DesktopServerGateway` et DTO associés ;
  adapters mock et http/unsupported alignés (le mode web n'expose pas le
  contrôle du serveur qui l'héberge).
- `ProjectsView` : le `showSettings: boolean` devient une navigation interne
  `AI Profiles` / `Deployment`. Le libellé alternant « Close AI Profiles »
  disparaît — Settings existait déjà dans cette vue, la surface évolue au lieu
  d'ajouter un `PanelId`.

CORRECTION D'UN BRIEF FAUX, remontée spontanément par DevFrontend et qui mérite
de survivre : le cadrage décrivait le mode `remoteProxyOtherMachine` avec deux
champs. `validate_settings` en exige un troisième, `lanBindAddress`, et rejette
loopback comme unspecified. Construit selon la spec, chaque save et chaque start
en mode 3 aurait échoué — le lot serait parti vert et cassé. Le panneau expose
donc un select alimenté par `candidateLanAddresses` fourni par le backend :
la règle « le frontend n'invente jamais une IP » tient.

QA ré-exécutée par Git avant merge : `npm run typecheck` exit 0 ·
`npx vitest run` 87 files / 789 passed, 0 échec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 23:43:37 +02:00
parent 49e1d5a157
commit 7efa634f23
16 changed files with 1458 additions and 31 deletions

View File

@ -0,0 +1,82 @@
/**
* Tauri adapter for {@link DesktopServerGateway} (ticket #68). One of the only
* places that calls `invoke()`; features reach it exclusively through the port.
*
* Commands and payload keys are camelCase, matching the backend DTO convention.
* `save_server_exposure_settings` / `preview_server_exposure_settings` take the
* settings directly under a `settings` key (no `request` wrapper).
*
* **Polling note.** The backend exposes no status *event* — `embedded_server.rs`
* emits nothing — so `onStatusChanged` is implemented here by polling
* `embedded_server_status`. That keeps the port shape push-like (and swappable
* for a real event later) while the polling schedule stays an adapter detail.
*/
import { invoke } from "@tauri-apps/api/core";
import type {
EmbeddedServerStatus,
ServerExposurePreview,
ServerExposureSettings,
Unsubscribe,
} from "@/domain";
import type { DesktopServerGateway } from "@/ports";
/** Status poll interval (ms). Cheap, in-process command; transitions are coarse. */
const STATUS_POLL_MS = 2000;
export class TauriDesktopServerGateway implements DesktopServerGateway {
constructor(private readonly pollMs: number = STATUS_POLL_MS) {}
getExposureSettings(): Promise<ServerExposureSettings> {
return invoke<ServerExposureSettings>("get_server_exposure_settings");
}
async saveExposureSettings(settings: ServerExposureSettings): Promise<void> {
await invoke("save_server_exposure_settings", { settings });
}
previewExposure(
settings: ServerExposureSettings,
): Promise<ServerExposurePreview> {
return invoke<ServerExposurePreview>("preview_server_exposure_settings", {
settings,
});
}
status(): Promise<EmbeddedServerStatus> {
return invoke<EmbeddedServerStatus>("embedded_server_status");
}
start(): Promise<EmbeddedServerStatus> {
return invoke<EmbeddedServerStatus>("embedded_server_start");
}
stop(): Promise<EmbeddedServerStatus> {
return invoke<EmbeddedServerStatus>("embedded_server_stop");
}
async onStatusChanged(
handler: (status: EmbeddedServerStatus) => void,
): Promise<Unsubscribe> {
let stopped = false;
const timer = setInterval(() => {
void this.status().then(
(status) => {
// The unsubscribe may land while a poll is in flight; drop late results
// so a torn-down consumer never gets called.
if (!stopped) handler(status);
},
() => {
// A failed poll is not a status: swallow it and let the next tick try
// again, rather than tearing the subscription down.
},
);
}, this.pollMs);
return () => {
stopped = true;
clearInterval(timer);
};
}
}