Files
adolf/ui/src/app/bootstrap.ts
alvis bedb527145
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Vendor OpenClaw source as Adolf fork baseline
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
2026-07-05 09:36:54 +00:00

631 lines
19 KiB
TypeScript

import type { RouteLocation } from "@openclaw/uirouter";
import type { EventLogEntry } from "../api/event-log.ts";
import {
GatewayBrowserClient,
type GatewayEventListener,
type GatewayHelloOk,
} from "../api/gateway.ts";
import {
createApplicationRouter,
inferBasePathFromPathname,
locationForRoute,
normalizeBasePath,
pathForRoute,
routeIdFromPath,
startApplicationRouter,
type ApplicationRouter,
type RouteId,
} from "../app-routes.ts";
import { createAgentIdentityCapability } from "../lib/agents/identity.ts";
import { createAgentCapability } from "../lib/agents/index.ts";
import { createChannelCapability } from "../lib/channels/index.ts";
import { createRuntimeConfigCapability } from "../lib/config/index.ts";
import { createSessionCapability, resolveSessionKey } from "../lib/sessions/index.ts";
import { generateUUID } from "../lib/uuid.ts";
import { createWorkboardCapability } from "../lib/workboard/capability.ts";
import { createAgentSelectionCapability } from "./agent-selection.ts";
import { createBrowserHistory } from "./browser.ts";
import { createApplicationConfigCapability } from "./config.ts";
import type {
ApplicationGateway,
ApplicationGatewayConnectOptions,
ApplicationGatewayConnection,
ApplicationNavigationOptions,
ApplicationGatewaySnapshot,
ApplicationContext,
ApplicationNavigationPreferences,
ApplicationNavigationPreferencesSnapshot,
ApplicationSkillWorkshopRevisionHandoff,
ApplicationTheme,
} from "./context.ts";
import { syncCustomThemeStyleTag } from "./custom-theme.ts";
import { createNativeChatDrafts } from "./native-bridge.ts";
import { createApplicationOverlays } from "./overlays.ts";
import {
loadSettings,
patchSettings,
resolveApplicationStartupSettings,
saveSettings,
type UiSettings,
} from "./settings.ts";
import { startThemeTransition } from "./theme-transition.ts";
import { resolveTheme, type ThemeMode } from "./theme.ts";
import { createWebPushCapability } from "./web-push.ts";
function normalizeInitialApplicationLocation(
location: RouteLocation,
basePath: string,
sessionKey: string,
) {
const routeId = routeIdFromPath(location.pathname, basePath);
if ((routeId !== null && routeId !== "chat") || !sessionKey.trim()) {
return location;
}
const search = new URLSearchParams(location.search);
if (!search.get("session")?.trim()) {
search.set("session", sessionKey);
}
return {
...location,
pathname: routeId === null ? pathForRoute("chat", basePath) : location.pathname,
search: `?${search.toString()}`,
};
}
function applyStartupPresentation(settings: ReturnType<typeof loadSettings>): void {
if (typeof document === "undefined") {
return;
}
const root = document.documentElement;
const resolvedTheme = resolveTheme(settings.theme, settings.themeMode);
root.dataset.theme = resolvedTheme;
root.dataset.themeMode = resolvedTheme.endsWith("light") ? "light" : "dark";
root.style.colorScheme = root.dataset.themeMode;
root.style.setProperty("--control-ui-text-scale", `${(settings.textScale ?? 100) / 100}`);
syncCustomThemeStyleTag(settings.customTheme);
}
function createApplicationTheme(
initialSettings: UiSettings,
): ApplicationTheme & { dispose: () => void } {
let settings = initialSettings;
let systemThemeCleanup: (() => void) | undefined;
const listeners = new Set<() => void>();
const publish = () => {
applyStartupPresentation(settings);
for (const listener of listeners) {
listener();
}
};
const detachSystemThemeListener = () => {
systemThemeCleanup?.();
systemThemeCleanup = undefined;
};
const syncSystemThemeListener = () => {
detachSystemThemeListener();
if (settings.themeMode !== "system" || typeof globalThis.matchMedia !== "function") {
return;
}
const mediaQuery = globalThis.matchMedia("(prefers-color-scheme: light)");
const onChange = () => {
if (settings.themeMode === "system") {
publish();
}
};
if (typeof mediaQuery.addEventListener === "function") {
mediaQuery.addEventListener("change", onChange);
systemThemeCleanup = () => mediaQuery.removeEventListener("change", onChange);
} else if (typeof mediaQuery.addListener === "function") {
mediaQuery.addListener(onChange);
systemThemeCleanup = () => mediaQuery.removeListener(onChange);
}
};
syncSystemThemeListener();
return {
get mode() {
return settings.themeMode;
},
setMode(mode: ThemeMode, element) {
const currentSettings = loadSettings();
const nextSettings = { ...currentSettings, themeMode: mode };
const currentTheme = resolveTheme(currentSettings.theme, currentSettings.themeMode);
const nextTheme = resolveTheme(nextSettings.theme, nextSettings.themeMode);
startThemeTransition({
nextTheme,
currentTheme,
context: { element },
applyTheme: () => {
settings = patchSettings({ themeMode: mode });
publish();
syncSystemThemeListener();
},
});
},
refresh() {
settings = loadSettings();
publish();
syncSystemThemeListener();
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
dispose() {
detachSystemThemeListener();
listeners.clear();
},
};
}
function createApplicationNavigationPreferences(
initialSettings: UiSettings,
): ApplicationNavigationPreferences {
let settings = initialSettings;
let snapshot: ApplicationNavigationPreferencesSnapshot = {
navCollapsed: settings.navCollapsed,
navGroupsCollapsed: settings.navGroupsCollapsed,
recentSessionsCollapsed: settings.recentSessionsCollapsed ?? false,
};
const listeners = new Set<(next: ApplicationNavigationPreferencesSnapshot) => void>();
return {
get snapshot() {
return snapshot;
},
update(patch) {
const nextSnapshot = { ...snapshot, ...patch };
if (
nextSnapshot.navCollapsed === snapshot.navCollapsed &&
nextSnapshot.recentSessionsCollapsed === snapshot.recentSessionsCollapsed &&
nextSnapshot.navGroupsCollapsed === snapshot.navGroupsCollapsed
) {
return;
}
settings = patchSettings({
navCollapsed: nextSnapshot.navCollapsed,
navGroupsCollapsed: nextSnapshot.navGroupsCollapsed,
recentSessionsCollapsed: nextSnapshot.recentSessionsCollapsed,
});
snapshot = nextSnapshot;
for (const listener of listeners) {
listener(snapshot);
}
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
function createSkillWorkshopRevisionHandoff(): ApplicationSkillWorkshopRevisionHandoff {
let pending: Parameters<ApplicationSkillWorkshopRevisionHandoff["prepare"]>[0] | null = null;
return {
prepare: (handoff) => {
pending = handoff;
},
consume: (sessionKey) => {
if (!pending || pending.sessionKey !== sessionKey) {
return null;
}
const handoff = pending;
pending = null;
return handoff;
},
clear: () => {
pending = null;
},
};
}
function createApplicationGateway(
initialSettings: ReturnType<typeof loadSettings>,
initialPassword = "",
): ApplicationGateway {
let settings = initialSettings;
let connection: ApplicationGatewayConnection = {
gatewayUrl: settings.gatewayUrl,
token: settings.token,
password: initialPassword,
};
let snapshot: ApplicationGatewaySnapshot = {
client: null,
connected: false,
hello: null,
assistantAgentId: "main",
sessionKey: settings.sessionKey,
lastError: null,
lastErrorCode: null,
};
let client: GatewayBrowserClient | null = null;
const listeners = new Set<(next: ApplicationGatewaySnapshot) => void>();
const eventListeners = new Set<GatewayEventListener>();
const eventLogListeners = new Set<(events: readonly EventLogEntry[]) => void>();
let eventLog: EventLogEntry[] = [];
let stopClientEvents: (() => void) | undefined;
const syncClientEvents = (nextClient: GatewayBrowserClient | null) => {
stopClientEvents?.();
stopClientEvents = undefined;
if (!nextClient || eventListeners.size === 0) {
return;
}
const removers = [...eventListeners].map((listener) => nextClient.addEventListener(listener));
stopClientEvents = () => {
for (const remove of removers) {
remove();
}
};
};
const notify = () => {
for (const listener of listeners) {
listener(snapshot);
}
};
const setSnapshot = (next: ApplicationGatewaySnapshot) => {
snapshot = next;
notify();
};
const publishEventLog = () => {
for (const listener of eventLogListeners) {
listener(eventLog);
}
};
const recordGatewayEvent = (event: Parameters<GatewayEventListener>[0]) => {
eventLog = [{ ts: Date.now(), event: event.event, payload: event.payload }, ...eventLog].slice(
0,
250,
);
publishEventLog();
};
const connect = (overrides: ApplicationGatewayConnectOptions = {}) => {
const { sessionKey: requestedSessionKey, ...connectionOverrides } = overrides;
const nextConnection = { ...connection, ...connectionOverrides };
const hasRequestedSessionKey = requestedSessionKey !== undefined;
const nextSessionKey = hasRequestedSessionKey
? requestedSessionKey.trim()
: snapshot.sessionKey;
connection = nextConnection;
settings = patchSettings({
gatewayUrl: nextConnection.gatewayUrl,
token: nextConnection.token,
...(hasRequestedSessionKey
? {
sessionKey: nextSessionKey,
lastActiveSessionKey: nextSessionKey,
}
: {}),
});
client?.stop();
stopClientEvents?.();
stopClientEvents = undefined;
const nextClient = new GatewayBrowserClient({
url: nextConnection.gatewayUrl,
token: nextConnection.token.trim() ? nextConnection.token : undefined,
password: nextConnection.password.trim() ? nextConnection.password : undefined,
clientName: "openclaw-control-ui",
clientVersion: "dev",
mode: "webchat",
instanceId: generateUUID(),
onHello: (hello: GatewayHelloOk) => {
if (client !== nextClient) {
return;
}
settings = loadSettings();
const sessionDefaults = readSessionDefaults(hello);
const sessionKey = resolveSessionKey(snapshot.sessionKey, hello);
const lastActiveSessionKey = resolveSessionKey(settings.lastActiveSessionKey, hello);
if (
sessionKey !== settings.sessionKey ||
lastActiveSessionKey !== settings.lastActiveSessionKey
) {
settings = patchSettings({
sessionKey,
lastActiveSessionKey,
});
}
setSnapshot({
...snapshot,
client: nextClient,
connected: true,
hello,
assistantAgentId: sessionDefaults?.defaultAgentId ?? "main",
sessionKey,
lastError: null,
lastErrorCode: null,
});
},
onClose: ({ code, reason, error }) => {
if (client !== nextClient) {
return;
}
setSnapshot({
...snapshot,
client: nextClient,
connected: false,
hello: null,
lastError: error?.message ?? `disconnected (${code}): ${reason || "no reason"}`,
lastErrorCode: error?.code ?? null,
});
},
onGap: ({ expected, received }) => {
if (client !== nextClient) {
return;
}
setSnapshot({
...snapshot,
lastError: `event gap detected (expected seq ${expected}, got ${received}); reconnecting`,
lastErrorCode: null,
});
connect();
},
onEvent: recordGatewayEvent,
});
client = nextClient;
syncClientEvents(nextClient);
setSnapshot({
...snapshot,
client: nextClient,
connected: false,
hello: null,
sessionKey: nextSessionKey,
lastError: null,
lastErrorCode: null,
});
nextClient.start();
};
const gateway: ApplicationGateway = {
get snapshot() {
return snapshot;
},
get connection() {
return connection;
},
get eventLog() {
return eventLog;
},
connect,
setSessionKey: (sessionKey) => {
const nextSessionKey = sessionKey.trim();
if (!nextSessionKey || nextSessionKey === snapshot.sessionKey) {
return;
}
settings = patchSettings({
sessionKey: nextSessionKey,
lastActiveSessionKey: nextSessionKey,
});
setSnapshot({ ...snapshot, sessionKey: nextSessionKey });
},
start: () => connect(),
stop: () => {
stopClientEvents?.();
stopClientEvents = undefined;
client?.stop();
client = null;
setSnapshot({
...snapshot,
client: null,
connected: false,
hello: null,
lastError: null,
lastErrorCode: null,
});
},
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
subscribeEventLog: (listener) => {
eventLogListeners.add(listener);
return () => eventLogListeners.delete(listener);
},
subscribeEvents: (listener) => {
eventListeners.add(listener);
syncClientEvents(client);
return () => {
if (eventListeners.delete(listener)) {
syncClientEvents(client);
}
};
},
};
return gateway;
}
function readSessionDefaults(
hello: GatewayHelloOk,
): { defaultAgentId?: string | null } | undefined {
const snapshot = hello.snapshot;
if (!snapshot || typeof snapshot !== "object" || !("sessionDefaults" in snapshot)) {
return undefined;
}
const defaults = snapshot.sessionDefaults;
return defaults && typeof defaults === "object"
? (defaults as { defaultAgentId?: string | null })
: undefined;
}
export type ApplicationRuntime = {
readonly context: ApplicationContext<RouteId>;
readonly router: ApplicationRouter;
readonly pendingGatewayConnection: {
readonly gatewayUrl: string;
readonly token: string;
} | null;
readonly confirmPendingGatewayConnection: () => void;
readonly cancelPendingGatewayConnection: () => void;
start: () => Promise<void>;
stop: () => void;
};
export function bootstrapApplication(): ApplicationRuntime {
const initialSettings = loadSettings();
const history = createBrowserHistory();
const startup = resolveApplicationStartupSettings(initialSettings, history.location());
if (startup.changed) {
saveSettings(startup.settings);
}
const basePath = normalizeBasePath(
inferBasePathFromPathname(startup.location.pathname || globalThis.location?.pathname || "/"),
);
const initialLocation = normalizeInitialApplicationLocation(
startup.location,
basePath,
startup.settings.sessionKey,
);
const currentLocation = history.location();
if (
currentLocation.pathname !== initialLocation.pathname ||
currentLocation.search !== initialLocation.search ||
currentLocation.hash !== initialLocation.hash
) {
history.replace(initialLocation);
}
const settings = startup.settings;
const gateway = createApplicationGateway(settings, startup.password ?? "");
const agents = createAgentCapability(gateway);
const agentIdentity = createAgentIdentityCapability(gateway);
const agentSelection = createAgentSelectionCapability(gateway);
const channels = createChannelCapability(gateway);
const config = createApplicationConfigCapability({
basePath,
auth: {
settings: { token: settings.token },
password: startup.password ?? "",
},
});
const sessions = createSessionCapability(gateway);
const workboard = createWorkboardCapability();
const runtimeConfig = createRuntimeConfigCapability(gateway);
const overlays = createApplicationOverlays(gateway);
const navigation = createApplicationNavigationPreferences(settings);
const theme = createApplicationTheme(settings);
const nativeChatDrafts = createNativeChatDrafts();
const webPush = createWebPushCapability(gateway);
const skillWorkshopRevision = createSkillWorkshopRevisionHandoff();
applyStartupPresentation(settings);
const router = createApplicationRouter();
let pendingGatewayConnection =
startup.pendingGatewayUrl !== null
? {
gatewayUrl: startup.pendingGatewayUrl,
token: startup.pendingGatewayToken ?? "",
}
: null;
let lastConfigRefreshClient: GatewayBrowserClient | null = null;
const stopConfigRefresh = gateway.subscribe((snapshot) => {
if (!snapshot.connected || !snapshot.client) {
lastConfigRefreshClient = null;
return;
}
if (lastConfigRefreshClient === snapshot.client) {
return;
}
lastConfigRefreshClient = snapshot.client;
void config.refresh({
auth: {
hello: snapshot.hello,
settings: { token: gateway.connection.token },
password: gateway.connection.password,
},
});
});
const routeLocation = (routeId: RouteId, options?: ApplicationNavigationOptions) => {
const location = locationForRoute(routeId, basePath);
if (options?.search !== undefined || options?.hash !== undefined) {
return {
...location,
search: options?.search ?? "",
hash: options?.hash ?? "",
};
}
return location;
};
const confirmPendingGatewayConnection = () => {
const pending = pendingGatewayConnection;
if (!pending) {
return;
}
pendingGatewayConnection = null;
gateway.connect({
gatewayUrl: pending.gatewayUrl,
token: pending.token,
});
};
const cancelPendingGatewayConnection = () => {
pendingGatewayConnection = null;
};
const context: ApplicationContext<RouteId> = {
basePath,
gateway,
agents,
agentIdentity,
agentSelection,
channels,
config,
runtimeConfig,
sessions,
workboard,
overlays,
navigation,
theme,
nativeChatDrafts,
webPush,
skillWorkshopRevision,
navigate: (routeId, options) => {
void router
.navigate(routeId, context, { history: "push" }, routeLocation(routeId, options))
.catch((error: unknown) => {
console.error("[openclaw] route navigation failed", error);
});
},
replace: (routeId, options) => {
void router
.navigate(routeId, context, { history: "replace" }, routeLocation(routeId, options))
.catch((error: unknown) => {
console.error("[openclaw] route replacement failed", error);
});
},
preload: (routeId) => router.preloadRoute(routeId, context),
};
return {
context,
router,
get pendingGatewayConnection() {
return pendingGatewayConnection;
},
confirmPendingGatewayConnection,
cancelPendingGatewayConnection,
start: async () => {
void config.refresh({ skipWithoutAuthCandidate: true });
const routerStart = startApplicationRouter(router, history, basePath, context);
gateway.start();
await routerStart;
},
stop: () => {
stopConfigRefresh();
router.stop();
gateway.stop();
agents.dispose();
channels.dispose();
sessions.dispose();
workboard.dispose();
runtimeConfig.dispose();
overlays.dispose();
theme.dispose();
nativeChatDrafts.dispose();
webPush.dispose();
skillWorkshopRevision.clear();
},
};
}