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:
82
frontend/src/adapters/desktopServer.ts
Normal file
82
frontend/src/adapters/desktopServer.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -42,6 +42,7 @@ import {
|
||||
HttpTicketGateway,
|
||||
} from "./streamGateways";
|
||||
import {
|
||||
WebDesktopServerGateway,
|
||||
WebFocusedProjectGateway,
|
||||
WebRemoteGateway,
|
||||
WebWindowGateway,
|
||||
@ -121,6 +122,9 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
|
||||
remote: new WebRemoteGateway(),
|
||||
profile: new HttpProfileGateway(http),
|
||||
modelServer: new HttpModelServerGateway(http),
|
||||
// Desktop-only (#68): the web client is served *by* the embedded server and
|
||||
// must not reconfigure it.
|
||||
desktopServer: new WebDesktopServerGateway(),
|
||||
template: new HttpTemplateGateway(http),
|
||||
skill: new HttpSkillGateway(http),
|
||||
memory: new HttpMemoryGateway(http),
|
||||
|
||||
@ -9,8 +9,15 @@
|
||||
* browser to replace `pickFolder`), it gets its own lot.
|
||||
*/
|
||||
|
||||
import type { GatewayError, Unsubscribe } from "@/domain";
|
||||
import type {
|
||||
EmbeddedServerStatus,
|
||||
GatewayError,
|
||||
ServerExposurePreview,
|
||||
ServerExposureSettings,
|
||||
Unsubscribe,
|
||||
} from "@/domain";
|
||||
import type {
|
||||
DesktopServerGateway,
|
||||
FocusedProject,
|
||||
FocusedProjectGateway,
|
||||
RemoteGateway,
|
||||
@ -75,3 +82,40 @@ export class WebRemoteGateway implements RemoteGateway {
|
||||
return unsupportedOnWeb("Remote (SSH/WSL) connection");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Web stub: the embedded server is desktop-only (ticket #68). A web client is
|
||||
* *served by* this very server, so letting it reconfigure or stop the server
|
||||
* would let a remote device saw off the branch it sits on. The desktop app owns
|
||||
* this surface; every call fails explicitly rather than reaching for Tauri.
|
||||
*/
|
||||
export class WebDesktopServerGateway implements DesktopServerGateway {
|
||||
// `async` on purpose: these must *reject* rather than throw synchronously, so
|
||||
// callers using `.then(ok, err)` (not just `await`) still see the failure.
|
||||
async getExposureSettings(): Promise<ServerExposureSettings> {
|
||||
return unsupportedOnWeb("Embedded server settings");
|
||||
}
|
||||
async saveExposureSettings(_settings: ServerExposureSettings): Promise<void> {
|
||||
return unsupportedOnWeb("Embedded server settings");
|
||||
}
|
||||
async previewExposure(
|
||||
_settings: ServerExposureSettings,
|
||||
): Promise<ServerExposurePreview> {
|
||||
return unsupportedOnWeb("Embedded server settings");
|
||||
}
|
||||
async status(): Promise<EmbeddedServerStatus> {
|
||||
return unsupportedOnWeb("Embedded server status");
|
||||
}
|
||||
async start(): Promise<EmbeddedServerStatus> {
|
||||
return unsupportedOnWeb("Starting the embedded server");
|
||||
}
|
||||
async stop(): Promise<EmbeddedServerStatus> {
|
||||
return unsupportedOnWeb("Stopping the embedded server");
|
||||
}
|
||||
onStatusChanged(
|
||||
_handler: (status: EmbeddedServerStatus) => void,
|
||||
): Promise<Unsubscribe> {
|
||||
// Never fires on web; a no-op unsubscribe keeps callers' teardown honest.
|
||||
return Promise.resolve(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ import { TauriTerminalGateway } from "./terminal";
|
||||
import { TauriLayoutGateway } from "./layout";
|
||||
import { TauriProfileGateway } from "./profile";
|
||||
import { TauriModelServerGateway } from "./modelServer";
|
||||
import { TauriDesktopServerGateway } from "./desktopServer";
|
||||
import { TauriTemplateGateway } from "./template";
|
||||
import { TauriSkillGateway } from "./skill";
|
||||
import { TauriMemoryGateway } from "./memory";
|
||||
@ -60,6 +61,7 @@ export function createTauriGateways(): Gateways {
|
||||
remote: new TauriRemoteGateway(),
|
||||
profile: new TauriProfileGateway(),
|
||||
modelServer: new TauriModelServerGateway(),
|
||||
desktopServer: new TauriDesktopServerGateway(),
|
||||
template: new TauriTemplateGateway(),
|
||||
skill: new TauriSkillGateway(),
|
||||
memory: new TauriMemoryGateway(),
|
||||
@ -83,6 +85,7 @@ export {
|
||||
TauriLayoutGateway,
|
||||
TauriProfileGateway,
|
||||
TauriModelServerGateway,
|
||||
TauriDesktopServerGateway,
|
||||
TauriTemplateGateway,
|
||||
TauriSkillGateway,
|
||||
TauriMemoryGateway,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -16,6 +16,7 @@ describe("createMockGateways", () => {
|
||||
expect(Object.keys(gateways).sort()).toEqual([
|
||||
"agent",
|
||||
"conversation",
|
||||
"desktopServer",
|
||||
"embedder",
|
||||
"focusedProject",
|
||||
"git",
|
||||
|
||||
Reference in New Issue
Block a user