Files
openclaw/ui/src/app/native-gateways.runtime.ts
Peter Steinberger adfb59c19b feat(mac): dashboard gateway picker with in-place switching (#113965)
* feat(mac): add dashboard gateway switching

* feat(ui): add dashboard gateway picker

* docs(mac): document dashboard gateway picker

* fix(mac): harden gateway switching after review

* fix(ui): refresh gateway picker snapshots

* fix(mac): carry TLS pins through promotion and serialize gateway switches

* style(ui): satisfy lint rules in gateway picker files

* fix(ui): tolerate absent context in gateway picker pane props

* chore(ci): refresh generated inventories

* style(mac): satisfy Swift CI checks

* refactor(mac): drop dead GatewayEndpointStore.requireConfig

* perf(ui): own gateway capability in the chat chunk

* chore(ui): keep gateway capability factory module-private
2026-07-25 20:57:28 -07:00

90 lines
3.0 KiB
TypeScript

export type NativeGateway = {
id: string;
name: string;
kind: "local" | "remote";
isPrimary: boolean;
canPromote: boolean;
health: "ok" | "error" | "unknown";
};
export type NativeGatewaysSnapshot = { gateways: NativeGateway[]; currentId: string };
type NativeGatewaysMessage =
| { type: "select" | "open-window" | "set-primary"; id: string }
| { type: "open-settings" };
type NativeGatewaysWindow = Window & {
__OPENCLAW_NATIVE_GATEWAYS__?: unknown;
webkit?: {
messageHandlers?: { openclawGateways?: { postMessage(message: NativeGatewaysMessage): void } };
};
};
const NATIVE_GATEWAYS_CHANGED_EVENT = "openclaw:native-gateways-changed";
export type NativeGatewaysCapability = {
readonly snapshot: NativeGatewaysSnapshot | null;
subscribe(listener: (snapshot: NativeGatewaysSnapshot) => void): () => void;
select(id: string): void;
openWindow(id: string): void;
setPrimary(id: string): void;
openSettings(): void;
};
function snapshotFrom(value: unknown): NativeGatewaysSnapshot | null {
if (!value || typeof value !== "object") {
return null;
}
const snapshot = value as Partial<NativeGatewaysSnapshot>;
// The embedder owns this payload; deep validation only defends the Mac app from itself.
return Array.isArray(snapshot.gateways) && typeof snapshot.currentId === "string"
? (snapshot as NativeGatewaysSnapshot)
: null;
}
function createNativeGatewaysCapability(): NativeGatewaysCapability | null {
if (typeof window === "undefined") {
return null;
}
const nativeWindow = window as NativeGatewaysWindow;
const handler = nativeWindow.webkit?.messageHandlers?.openclawGateways;
if (!handler?.postMessage) {
return null;
}
const post = handler.postMessage.bind(handler);
const postWithId = (type: "select" | "open-window" | "set-primary", id: string) =>
post({ type, id });
let snapshot = snapshotFrom(nativeWindow["__OPENCLAW_NATIVE_GATEWAYS__"]);
const listeners = new Set<(snapshot: NativeGatewaysSnapshot) => void>();
const onChange = (event: Event) => {
const next = snapshotFrom((event as CustomEvent<unknown>).detail);
if (!next) {
return;
}
snapshot = next;
listeners.forEach((listener) => listener(next));
};
window.addEventListener(NATIVE_GATEWAYS_CHANGED_EVENT, onChange);
return {
get snapshot() {
return snapshot;
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
select: (id) => postWithId("select", id),
openWindow: (id) => postWithId("open-window", id),
setPrimary: (id) => postWithId("set-primary", id),
openSettings: () => post({ type: "open-settings" }),
};
}
let singleton: NativeGatewaysCapability | null | undefined;
// Chat-chunk-owned so this capability never enters the QA-smoke startup bundle.
export function nativeGatewaysCapability(): NativeGatewaysCapability | null {
if (singleton === undefined) {
singleton = createNativeGatewaysCapability();
}
return singleton;
}