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

@ -8,9 +8,11 @@ import type {
Agent,
AgentDrift,
AgentProfile,
DiagnosticWarning,
DomainEvent,
EmbedderEngines,
EmbedderProfile,
EmbeddedServerStatus,
FirstRunState,
GatewayError,
GitBranches,
@ -36,6 +38,8 @@ import type {
ProjectWorkState,
ProfileAvailability,
ResumableAgent,
ServerExposurePreview,
ServerExposureSettings,
Skill,
ReplyChunk,
SkillScope,
@ -63,6 +67,7 @@ import type {
CreateAgentInput,
CreateMemoryInput,
CreateSkillInput,
DesktopServerGateway,
EmbedderGateway,
CreateTemplateInput,
Gateways,
@ -1373,6 +1378,148 @@ export class MockModelServerGateway implements ModelServerGateway {
}
}
/** Mirror of the backend `default_settings()` (ticket #68). */
const DEFAULT_EXPOSURE_SETTINGS: ServerExposureSettings = {
mode: "localOnly",
port: 17373,
trustedProxies: [],
};
/** Fixed LAN candidates so the offline UI has something to pick from. */
const MOCK_LAN_ADDRESSES = ["192.168.1.42", "10.0.0.17"];
function invalid(message: string): never {
const err: GatewayError = { code: "INVALID", message };
throw err;
}
/**
* In-memory embedded-server gateway (ticket #68).
*
* Mirrors the backend `validate_settings` / `preview_settings` closely enough
* that the Deployment UI — including its rejection paths — is testable offline.
* It is never the source of truth: production runs the real Tauri commands.
* `candidateLanAddresses` is fixed data here precisely because the UI must take
* addresses from the gateway rather than deriving them.
*/
export class MockDesktopServerGateway implements DesktopServerGateway {
private settings: ServerExposureSettings = structuredClone(
DEFAULT_EXPOSURE_SETTINGS,
);
private current: EmbeddedServerStatus = { state: "stopped" };
private readonly handlers = new Set<(s: EmbeddedServerStatus) => void>();
async getExposureSettings(): Promise<ServerExposureSettings> {
return structuredClone(this.settings);
}
async saveExposureSettings(settings: ServerExposureSettings): Promise<void> {
this.validate(settings);
this.settings = structuredClone(settings);
}
async previewExposure(
settings: ServerExposureSettings,
): Promise<ServerExposurePreview> {
// The real `preview_server_exposure_settings` validates *before* previewing,
// so an incomplete draft is rejected rather than previewed. Mirrored here —
// the UI relies on it as its validation authority.
this.validate(settings);
const warnings: DiagnosticWarning[] = [];
if (
settings.mode === "remoteProxyOtherMachine" &&
settings.trustedProxies.length === 0
) {
// Mirrors the backend's `missingTrustedProxy` warning — which the backend
// itself cannot currently reach, since `validate_settings` rejects an
// empty `trustedProxies` for this mode first. Kept aligned on purpose: if
// the backend reorders, the mock already matches.
warnings.push({
code: "missingTrustedProxy",
message:
"Add the IP address or CIDR of the reverse proxy that connects to this server.",
});
}
const bindAddress =
settings.mode === "remoteProxyOtherMachine"
? settings.lanBindAddress
: "127.0.0.1";
const upstreamUrl =
settings.mode !== "localOnly" && bindAddress
? `http://${bindAddress}:${settings.port}`
: undefined;
return {
candidateLanAddresses: [...MOCK_LAN_ADDRESSES],
upstreamUrl,
warnings,
};
}
async status(): Promise<EmbeddedServerStatus> {
return structuredClone(this.current);
}
async start(): Promise<EmbeddedServerStatus> {
// The backend re-validates on start; a bad config fails there, not silently.
this.validate(this.settings);
const preview = await this.previewExposure(this.settings);
this.emit({
state: "running",
localUrl: `http://127.0.0.1:${this.settings.port}`,
publicUrl:
this.settings.mode === "localOnly"
? undefined
: this.settings.publicOrigin,
upstreamUrl: preview.upstreamUrl,
// Runtime-only, regenerated per start — never persisted.
pairingCode: "MOCK-PAIR-4242",
});
return structuredClone(this.current);
}
async stop(): Promise<EmbeddedServerStatus> {
this.emit({ state: "stopped" });
return structuredClone(this.current);
}
async onStatusChanged(
handler: (status: EmbeddedServerStatus) => void,
): Promise<Unsubscribe> {
this.handlers.add(handler);
return () => this.handlers.delete(handler);
}
/** Test hook: force a status (e.g. a `failed` state) and notify subscribers. */
setStatus(status: EmbeddedServerStatus): void {
this.emit(status);
}
private emit(status: EmbeddedServerStatus): void {
this.current = status;
for (const handler of this.handlers) handler(structuredClone(status));
}
private validate(settings: ServerExposureSettings): void {
if (settings.mode !== "localOnly") {
const origin = settings.publicOrigin;
if (!origin) invalid("remote exposure requires publicOrigin");
if (!origin.startsWith("https://")) {
invalid("remote exposure requires publicOrigin to start with https://");
}
if (origin.includes("*")) invalid("publicOrigin must be exact, not a wildcard");
if (origin.endsWith("/")) invalid("publicOrigin must not end with '/'");
}
if (settings.mode === "remoteProxyOtherMachine") {
if (!settings.lanBindAddress) {
invalid("remoteProxyOtherMachine requires lanBindAddress");
}
if (settings.trustedProxies.length === 0) {
invalid("remoteProxyOtherMachine requires at least one trustedProxies entry");
}
}
}
}
/**
* Stateful in-memory template gateway.
*
@ -2630,6 +2777,7 @@ export function createMockGateways(): Gateways {
remote: new MockRemoteGateway(),
profile: new MockProfileGateway(),
modelServer: new MockModelServerGateway(),
desktopServer: new MockDesktopServerGateway(),
template: new MockTemplateGateway(agentGateway),
skill: new MockSkillGateway(agentGateway),
memory: new MockMemoryGateway(),