Expose la gestion des appareils appairés introduite en B1-B4, sur une surface unique partagée par le web et le desktop. - Écran Appareils : liste, renommage, révocation unitaire ou globale, activité formatée, panneau de code éphémère. - Le parcours d'appairage demande un nom d'appareil, pour qu'une révocation porte sur quelque chose d'identifiable par l'utilisateur. - Gateways DeviceGateway en trois adapters (Tauri, HTTP, Mock), le port restant le seul contrat connu de la feature. Les erreurs sont mappées localement et le message du serveur n'est jamais affiché tel quel : un échec d'appairage ne doit pas devenir un oracle pour qui teste des codes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
150 lines
5.7 KiB
TypeScript
150 lines
5.7 KiB
TypeScript
/**
|
|
* Web (client/server) adapter set — ticket #13, lot F1.
|
|
*
|
|
* `createHttpWsGateways()` is the third gateway implementation, alongside
|
|
* `createTauriGateways()` (desktop) and `createMockGateways()` (tests/dev). It
|
|
* wires every UI port to the shared backend core over HTTP request/response and a
|
|
* single multiplexed WebSocket, exactly behind the *unchanged* ports — no
|
|
* component is aware of it (selected in `app/di.tsx`).
|
|
*
|
|
* Scope reminder (F1): request/response gateways are fully implemented; the WS
|
|
* PTY/chat streams are a contract-conformant **skeleton** (full round-trip lands
|
|
* in F3/B5/B6 once a server exists — B3/B4). Desktop-only surfaces (pickFolder,
|
|
* OS windows, focused-project, remote) fail with an explicit "unsupported on web"
|
|
* error or an inert stub, never a Tauri call.
|
|
*/
|
|
|
|
import type { Gateways } from "@/ports";
|
|
import { LocalStorageUiPreferencesGateway } from "../uiPreferences";
|
|
import { HttpInvoker } from "./httpInvoker";
|
|
import { WsLiveClient } from "./wsLiveClient";
|
|
import { getWebSession } from "./webSession";
|
|
import { setWebLiveClient } from "./webLive";
|
|
import { HttpDeviceGateway } from "./deviceGateway";
|
|
import {
|
|
HttpConversationGateway,
|
|
HttpEmbedderGateway,
|
|
HttpGitGateway,
|
|
HttpInputGateway,
|
|
HttpLayoutGateway,
|
|
HttpMemoryGateway,
|
|
HttpModelServerGateway,
|
|
HttpPermissionGateway,
|
|
HttpProfileGateway,
|
|
HttpProjectGateway,
|
|
HttpSkillGateway,
|
|
HttpTemplateGateway,
|
|
HttpWorkStateGateway,
|
|
} from "./requestResponseGateways";
|
|
import {
|
|
HttpAgentGateway,
|
|
HttpSystemGateway,
|
|
HttpTerminalGateway,
|
|
HttpTicketGateway,
|
|
} from "./streamGateways";
|
|
import {
|
|
WebDesktopServerGateway,
|
|
WebFocusedProjectGateway,
|
|
WebRemoteGateway,
|
|
WebWindowGateway,
|
|
} from "./unsupported";
|
|
|
|
/** Endpoint configuration for the web transport. */
|
|
export interface HttpWsGatewaysConfig {
|
|
/** Backend HTTP base URL (no trailing slash). Defaults to the page origin. */
|
|
baseUrl?: string;
|
|
/** WebSocket base URL. Defaults to the page origin with `http(s)`→`ws(s)`. */
|
|
wsUrl?: string;
|
|
/** Bearer token from pairing (ticket #13 auth). */
|
|
token?: string;
|
|
}
|
|
|
|
/** Derives default `{baseUrl, wsUrl}` from `window.location` when available. */
|
|
function resolveEndpoints(config: HttpWsGatewaysConfig): {
|
|
baseUrl: string;
|
|
wsUrl: string;
|
|
} {
|
|
const origin =
|
|
typeof window !== "undefined" && window.location
|
|
? window.location.origin
|
|
: "http://localhost";
|
|
const baseUrl = config.baseUrl ?? origin;
|
|
const wsUrl =
|
|
config.wsUrl ?? baseUrl.replace(/^http(s?):\/\//, (_m, s) => `ws${s}://`);
|
|
return { baseUrl, wsUrl };
|
|
}
|
|
|
|
/**
|
|
* Builds the full set of web (HTTP+WS) gateways. `config` is optional; endpoints
|
|
* default to the current page origin so a browser build talks to its own server.
|
|
* The {@link HttpInvoker} and {@link WsLiveClient} are shared across gateways.
|
|
*/
|
|
export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateways {
|
|
const { baseUrl, wsUrl } = resolveEndpoints(config);
|
|
// Route 401s back to the pairing screen via the shared web session (F2/F6).
|
|
const session = getWebSession();
|
|
// `ws` is referenced by the invoker's 401 handler (declared below); the closure
|
|
// only runs after both are constructed, so the forward reference is safe.
|
|
let ws: WsLiveClient;
|
|
const http = new HttpInvoker({
|
|
baseUrl,
|
|
token: config.token,
|
|
onUnauthorized: () => {
|
|
// F6: a 401 means the session cookie is gone/revoked. Route back to pairing
|
|
// AND tear down the live socket so it stops its (now hopeless) reconnect
|
|
// loop. Re-pairing brings the same client back up cleanly.
|
|
session.notifyUnauthorized();
|
|
ws?.disconnect();
|
|
},
|
|
});
|
|
ws = new WsLiveClient({
|
|
wsUrl,
|
|
token: config.token,
|
|
// F6: when a WS reconnect fails we cannot read its HTTP status, so probe with
|
|
// a cheap `health` call. A revoked session answers `401` → the invoker's
|
|
// `onUnauthorized` above bounces to pairing; a network error is swallowed and
|
|
// the WS backoff keeps retrying.
|
|
onReconnectFailed: () => {
|
|
void http.invoke("health", { request: null }).catch(() => {});
|
|
},
|
|
});
|
|
// Share the live client with the web feature so live surfaces can re-sync on
|
|
// reconnect (F5). Inert on desktop (never called there).
|
|
setWebLiveClient(ws);
|
|
|
|
return {
|
|
system: new HttpSystemGateway(http, ws),
|
|
agent: new HttpAgentGateway(http, ws),
|
|
input: new HttpInputGateway(http),
|
|
terminal: new HttpTerminalGateway(ws),
|
|
project: new HttpProjectGateway(http),
|
|
layout: new HttpLayoutGateway(http),
|
|
git: new HttpGitGateway(http),
|
|
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),
|
|
embedder: new HttpEmbedderGateway(http),
|
|
device: new HttpDeviceGateway(http),
|
|
permission: new HttpPermissionGateway(http),
|
|
workState: new HttpWorkStateGateway(http),
|
|
conversation: new HttpConversationGateway(http),
|
|
ticket: new HttpTicketGateway(http, ws),
|
|
window: new WebWindowGateway(),
|
|
focusedProject: new WebFocusedProjectGateway(),
|
|
// Frontend-owned UI prefs are transport-neutral (localStorage) — reuse as-is.
|
|
uiPreferences: new LocalStorageUiPreferencesGateway(),
|
|
};
|
|
}
|
|
|
|
export { HttpInvoker } from "./httpInvoker";
|
|
export { WsLiveClient } from "./wsLiveClient";
|
|
export { WebSession, getWebSession, setWebSessionForTests } from "./webSession";
|
|
export { getWebLiveClient, setWebLiveClient, disconnectWebLive } from "./webLive";
|
|
export type { ConnectionState } from "./wsLiveClient";
|