feat(plugin): support enterprise extension (#861)
This commit is contained in:
6
.github/workflows/check.yml
vendored
6
.github/workflows/check.yml
vendored
@@ -28,6 +28,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate extension bridge
|
||||
run: pnpm run ext:bridge
|
||||
|
||||
- name: Run linter
|
||||
run: pnpm run lint
|
||||
|
||||
@@ -57,5 +60,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate extension bridge
|
||||
run: pnpm run ext:bridge
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build:vite
|
||||
|
||||
3
.github/workflows/electron-e2e.yml
vendored
3
.github/workflows/electron-e2e.yml
vendored
@@ -40,6 +40,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate extension bridge
|
||||
run: pnpm run ext:bridge
|
||||
|
||||
- name: Rebuild Electron binary
|
||||
run: pnpm rebuild electron
|
||||
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -69,3 +69,7 @@ docs/pr-session-notes-*.md
|
||||
.claude/
|
||||
.pnpm-store/
|
||||
package-lock.json
|
||||
|
||||
# Generated extension bridges (created by scripts/generate-ext-bridge.mjs)
|
||||
electron/extensions/_ext-bridge.generated.ts
|
||||
src/extensions/_ext-bridge.generated.ts
|
||||
|
||||
9
clawx-extensions.json
Normal file
9
clawx-extensions.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extensions": {
|
||||
"main": [
|
||||
"builtin/clawhub-marketplace",
|
||||
"builtin/diagnostics"
|
||||
],
|
||||
"renderer": []
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,18 @@ export async function handleSkillRoutes(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/clawhub/capability' && req.method === 'GET') {
|
||||
try {
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
capability: await ctx.clawHubService.getMarketplaceCapability(),
|
||||
});
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/clawhub/search' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<Record<string, unknown>>(req);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
||||
import { getPort } from '../utils/config';
|
||||
import { logger } from '../utils/logger';
|
||||
import { extensionRegistry } from '../extensions/registry';
|
||||
import type { HostApiContext } from './context';
|
||||
import { handleAppRoutes } from './routes/app';
|
||||
import { handleGatewayRoutes } from './routes/gateway';
|
||||
@@ -25,7 +26,7 @@ type RouteHandler = (
|
||||
ctx: HostApiContext,
|
||||
) => Promise<boolean>;
|
||||
|
||||
const routeHandlers: RouteHandler[] = [
|
||||
const coreRouteHandlers: RouteHandler[] = [
|
||||
handleAppRoutes,
|
||||
handleGatewayRoutes,
|
||||
handleSettingsRoutes,
|
||||
@@ -41,6 +42,11 @@ const routeHandlers: RouteHandler[] = [
|
||||
handleUsageRoutes,
|
||||
];
|
||||
|
||||
function buildRouteHandlers(): RouteHandler[] {
|
||||
const extensionHandlers = extensionRegistry.getRouteHandlers();
|
||||
return [...coreRouteHandlers, ...extensionHandlers];
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-session secret token used to authenticate Host API requests.
|
||||
* Generated once at server start and shared with the renderer via IPC.
|
||||
@@ -96,6 +102,7 @@ export function startHostApiServer(ctx: HostApiContext, port = getPort('CLAWX_HO
|
||||
return;
|
||||
}
|
||||
|
||||
const routeHandlers = buildRouteHandlers();
|
||||
for (const handler of routeHandlers) {
|
||||
if (await handler(req, res, requestUrl, ctx)) {
|
||||
return;
|
||||
|
||||
43
electron/extensions/builtin/clawhub-marketplace.ts
Normal file
43
electron/extensions/builtin/clawhub-marketplace.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type {
|
||||
Extension,
|
||||
ExtensionContext,
|
||||
MarketplaceProviderExtension,
|
||||
MarketplaceCapability,
|
||||
} from '../types';
|
||||
import type {
|
||||
ClawHubSearchParams,
|
||||
ClawHubInstallParams,
|
||||
ClawHubSkillResult,
|
||||
} from '../../gateway/clawhub';
|
||||
|
||||
class ClawHubMarketplaceExtension implements MarketplaceProviderExtension {
|
||||
readonly id = 'builtin/clawhub-marketplace';
|
||||
|
||||
setup(_ctx: ExtensionContext): void {
|
||||
// No setup needed -- search/install delegates to the ClawHubService CLI runner
|
||||
}
|
||||
|
||||
async getCapability(): Promise<MarketplaceCapability> {
|
||||
return {
|
||||
mode: 'clawhub',
|
||||
canSearch: true,
|
||||
canInstall: true,
|
||||
};
|
||||
}
|
||||
|
||||
async search(params: ClawHubSearchParams): Promise<ClawHubSkillResult[]> {
|
||||
const { ClawHubService } = await import('../../gateway/clawhub');
|
||||
const svc = new ClawHubService();
|
||||
return svc.search(params);
|
||||
}
|
||||
|
||||
async install(params: ClawHubInstallParams): Promise<void> {
|
||||
const { ClawHubService } = await import('../../gateway/clawhub');
|
||||
const svc = new ClawHubService();
|
||||
return svc.install(params);
|
||||
}
|
||||
}
|
||||
|
||||
export function createClawHubMarketplaceExtension(): Extension {
|
||||
return new ClawHubMarketplaceExtension();
|
||||
}
|
||||
25
electron/extensions/builtin/diagnostics.ts
Normal file
25
electron/extensions/builtin/diagnostics.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type {
|
||||
Extension,
|
||||
ExtensionContext,
|
||||
HostApiRouteExtension,
|
||||
RouteHandler,
|
||||
} from '../types';
|
||||
|
||||
class DiagnosticsExtension implements HostApiRouteExtension {
|
||||
readonly id = 'builtin/diagnostics';
|
||||
|
||||
setup(_ctx: ExtensionContext): void {
|
||||
// Diagnostics routes are stateless; no setup needed.
|
||||
}
|
||||
|
||||
getRouteHandler(): RouteHandler {
|
||||
return async (req, res, url, ctx) => {
|
||||
const { handleDiagnosticsRoutes } = await import('../../api/routes/diagnostics');
|
||||
return handleDiagnosticsRoutes(req, res, url, ctx);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createDiagnosticsExtension(): Extension {
|
||||
return new DiagnosticsExtension();
|
||||
}
|
||||
8
electron/extensions/builtin/index.ts
Normal file
8
electron/extensions/builtin/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { registerBuiltinExtension } from '../loader';
|
||||
import { createClawHubMarketplaceExtension } from './clawhub-marketplace';
|
||||
import { createDiagnosticsExtension } from './diagnostics';
|
||||
|
||||
export function registerAllBuiltinExtensions(): void {
|
||||
registerBuiltinExtension('builtin/clawhub-marketplace', createClawHubMarketplaceExtension);
|
||||
registerBuiltinExtension('builtin/diagnostics', createDiagnosticsExtension);
|
||||
}
|
||||
17
electron/extensions/index.ts
Normal file
17
electron/extensions/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export { extensionRegistry } from './registry';
|
||||
export { registerBuiltinExtension, loadExtensionsFromManifest } from './loader';
|
||||
export type {
|
||||
Extension,
|
||||
ExtensionContext,
|
||||
HostApiRouteExtension,
|
||||
MarketplaceProviderExtension,
|
||||
MarketplaceCapability,
|
||||
AuthProviderExtension,
|
||||
AuthStatus,
|
||||
RouteHandler,
|
||||
} from './types';
|
||||
export {
|
||||
isHostApiRouteExtension,
|
||||
isMarketplaceProviderExtension,
|
||||
isAuthProviderExtension,
|
||||
} from './types';
|
||||
76
electron/extensions/loader.ts
Normal file
76
electron/extensions/loader.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { app } from 'electron';
|
||||
import { logger } from '../utils/logger';
|
||||
import { extensionRegistry } from './registry';
|
||||
import type { Extension } from './types';
|
||||
|
||||
interface ExtensionManifest {
|
||||
extensions?: {
|
||||
main?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const builtinModules = new Map<string, () => Extension>();
|
||||
|
||||
export function registerBuiltinExtension(id: string, factory: () => Extension): void {
|
||||
builtinModules.set(id, factory);
|
||||
}
|
||||
|
||||
function resolveManifestPath(): string {
|
||||
if (app.isPackaged) {
|
||||
return join(process.resourcesPath, 'clawx-extensions.json');
|
||||
}
|
||||
return join(app.getAppPath(), 'clawx-extensions.json');
|
||||
}
|
||||
|
||||
export async function loadExtensionsFromManifest(): Promise<void> {
|
||||
const manifestPath = resolveManifestPath();
|
||||
let manifest: ExtensionManifest = {};
|
||||
|
||||
if (existsSync(manifestPath)) {
|
||||
try {
|
||||
manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as ExtensionManifest;
|
||||
logger.info(`[extensions] Loaded manifest from ${manifestPath}`);
|
||||
} catch (err) {
|
||||
logger.warn(`[extensions] Failed to parse ${manifestPath}, using defaults:`, err);
|
||||
}
|
||||
} else {
|
||||
logger.debug('[extensions] No clawx-extensions.json found, loading all builtin extensions');
|
||||
}
|
||||
|
||||
const mainExtensions = manifest.extensions?.main;
|
||||
|
||||
if (!mainExtensions || mainExtensions.length === 0) {
|
||||
for (const [id, factory] of builtinModules) {
|
||||
extensionRegistry.register(factory());
|
||||
logger.debug(`[extensions] Auto-registered builtin extension "${id}"`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const extensionId of mainExtensions) {
|
||||
if (builtinModules.has(extensionId)) {
|
||||
extensionRegistry.register(builtinModules.get(extensionId)!());
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const mod = require(extensionId) as { default?: Extension; extension?: Extension };
|
||||
const ext = mod.default ?? mod.extension;
|
||||
if (ext && typeof ext.setup === 'function') {
|
||||
extensionRegistry.register(ext);
|
||||
} else {
|
||||
logger.warn(`[extensions] Module "${extensionId}" does not export a valid Extension`);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes('Cannot find module')) {
|
||||
logger.debug(`[extensions] "${extensionId}" not loadable at runtime (expected when using ext-bridge)`);
|
||||
} else {
|
||||
logger.warn(`[extensions] Failed to load extension "${extensionId}": ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
76
electron/extensions/registry.ts
Normal file
76
electron/extensions/registry.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { logger } from '../utils/logger';
|
||||
import type {
|
||||
Extension,
|
||||
ExtensionContext,
|
||||
HostApiRouteExtension,
|
||||
MarketplaceProviderExtension,
|
||||
RouteHandler,
|
||||
} from './types';
|
||||
import {
|
||||
isHostApiRouteExtension,
|
||||
isMarketplaceProviderExtension,
|
||||
} from './types';
|
||||
|
||||
class ExtensionRegistry {
|
||||
private extensions = new Map<string, Extension>();
|
||||
private ctx: ExtensionContext | null = null;
|
||||
|
||||
async initialize(ctx: ExtensionContext): Promise<void> {
|
||||
this.ctx = ctx;
|
||||
for (const ext of this.extensions.values()) {
|
||||
try {
|
||||
await ext.setup(ctx);
|
||||
logger.info(`[extensions] Extension "${ext.id}" initialized`);
|
||||
} catch (err) {
|
||||
logger.error(`[extensions] Extension "${ext.id}" failed to initialize:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
register(extension: Extension): void {
|
||||
if (this.extensions.has(extension.id)) {
|
||||
logger.warn(`[extensions] Extension "${extension.id}" is already registered; skipping duplicate`);
|
||||
return;
|
||||
}
|
||||
this.extensions.set(extension.id, extension);
|
||||
logger.debug(`[extensions] Registered extension "${extension.id}"`);
|
||||
|
||||
if (this.ctx) {
|
||||
void Promise.resolve(extension.setup(this.ctx)).catch((err) => {
|
||||
logger.error(`[extensions] Late-registered extension "${extension.id}" failed to initialize:`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get(id: string): Extension | undefined {
|
||||
return this.extensions.get(id);
|
||||
}
|
||||
|
||||
getAll(): Extension[] {
|
||||
return [...this.extensions.values()];
|
||||
}
|
||||
|
||||
getRouteHandlers(): RouteHandler[] {
|
||||
return this.getAll()
|
||||
.filter(isHostApiRouteExtension)
|
||||
.map((ext: HostApiRouteExtension) => ext.getRouteHandler());
|
||||
}
|
||||
|
||||
getMarketplaceProvider(): MarketplaceProviderExtension | undefined {
|
||||
return this.getAll().find(isMarketplaceProviderExtension) as MarketplaceProviderExtension | undefined;
|
||||
}
|
||||
|
||||
async teardownAll(): Promise<void> {
|
||||
for (const ext of this.extensions.values()) {
|
||||
try {
|
||||
await ext.teardown?.();
|
||||
} catch (err) {
|
||||
logger.warn(`[extensions] Extension "${ext.id}" teardown failed:`, err);
|
||||
}
|
||||
}
|
||||
this.extensions.clear();
|
||||
this.ctx = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const extensionRegistry = new ExtensionRegistry();
|
||||
69
electron/extensions/types.ts
Normal file
69
electron/extensions/types.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import type { HostEventBus } from '../api/event-bus';
|
||||
import type { HostApiContext } from '../api/context';
|
||||
import type {
|
||||
ClawHubSearchParams,
|
||||
ClawHubInstallParams,
|
||||
ClawHubSkillResult,
|
||||
} from '../gateway/clawhub';
|
||||
|
||||
export type RouteHandler = (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
) => Promise<boolean>;
|
||||
|
||||
export interface ExtensionContext {
|
||||
gatewayManager: GatewayManager;
|
||||
eventBus: HostEventBus;
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
}
|
||||
|
||||
export interface Extension {
|
||||
id: string;
|
||||
setup(ctx: ExtensionContext): void | Promise<void>;
|
||||
teardown?(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface HostApiRouteExtension extends Extension {
|
||||
getRouteHandler(): RouteHandler;
|
||||
}
|
||||
|
||||
export interface MarketplaceCapability {
|
||||
mode: string;
|
||||
canSearch: boolean;
|
||||
canInstall: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface MarketplaceProviderExtension extends Extension {
|
||||
getCapability(): Promise<MarketplaceCapability>;
|
||||
search(params: ClawHubSearchParams): Promise<ClawHubSkillResult[]>;
|
||||
install(params: ClawHubInstallParams): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AuthStatus {
|
||||
authenticated: boolean;
|
||||
expired: boolean;
|
||||
user: { username: string; displayName: string; email: string } | null;
|
||||
}
|
||||
|
||||
export interface AuthProviderExtension extends Extension {
|
||||
getAuthStatus(): Promise<AuthStatus>;
|
||||
onStartup?(mainWindow: BrowserWindow): Promise<void>;
|
||||
}
|
||||
|
||||
export function isHostApiRouteExtension(ext: Extension): ext is HostApiRouteExtension {
|
||||
return 'getRouteHandler' in ext && typeof (ext as HostApiRouteExtension).getRouteHandler === 'function';
|
||||
}
|
||||
|
||||
export function isMarketplaceProviderExtension(ext: Extension): ext is MarketplaceProviderExtension {
|
||||
return 'getCapability' in ext && 'search' in ext && 'install' in ext;
|
||||
}
|
||||
|
||||
export function isAuthProviderExtension(ext: Extension): ext is AuthProviderExtension {
|
||||
return 'getAuthStatus' in ext && typeof (ext as AuthProviderExtension).getAuthStatus === 'function';
|
||||
}
|
||||
@@ -40,12 +40,30 @@ export interface ClawHubInstalledSkillResult {
|
||||
baseDir?: string;
|
||||
}
|
||||
|
||||
export interface MarketplaceProvider {
|
||||
getCapability(): Promise<{ mode: string; canSearch: boolean; canInstall: boolean; reason?: string }>;
|
||||
search(params: ClawHubSearchParams): Promise<ClawHubSkillResult[]>;
|
||||
install(params: ClawHubInstallParams): Promise<void>;
|
||||
}
|
||||
|
||||
export class ClawHubService {
|
||||
private workDir: string;
|
||||
private cliPath: string;
|
||||
private cliEntryPath: string;
|
||||
private useNodeRunner: boolean;
|
||||
private ansiRegex: RegExp;
|
||||
private marketplaceProvider: MarketplaceProvider | null = null;
|
||||
|
||||
setMarketplaceProvider(provider: MarketplaceProvider): void {
|
||||
this.marketplaceProvider = provider;
|
||||
}
|
||||
|
||||
async getMarketplaceCapability(): Promise<{ mode: string; canSearch: boolean; canInstall: boolean; reason?: string }> {
|
||||
if (this.marketplaceProvider) {
|
||||
return this.marketplaceProvider.getCapability();
|
||||
}
|
||||
return { mode: 'clawhub', canSearch: true, canInstall: true };
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Use the user's OpenClaw config directory (~/.openclaw) for skill management
|
||||
@@ -194,9 +212,13 @@ export class ClawHubService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for skills
|
||||
* Search for skills. Delegates to the marketplace provider if one is set,
|
||||
* otherwise falls back to the local ClawHub CLI.
|
||||
*/
|
||||
async search(params: ClawHubSearchParams): Promise<ClawHubSkillResult[]> {
|
||||
if (this.marketplaceProvider) {
|
||||
return this.marketplaceProvider.search(params);
|
||||
}
|
||||
try {
|
||||
// If query is empty, use 'explore' to show trending skills
|
||||
if (!params.query || params.query.trim() === '') {
|
||||
@@ -298,9 +320,13 @@ export class ClawHubService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a skill
|
||||
* Install a skill. Delegates to the marketplace provider if one is set,
|
||||
* otherwise falls back to the local ClawHub CLI.
|
||||
*/
|
||||
async install(params: ClawHubInstallParams): Promise<void> {
|
||||
if (this.marketplaceProvider) {
|
||||
return this.marketplaceProvider.install(params);
|
||||
}
|
||||
const args = ['install', params.slug];
|
||||
|
||||
if (params.version) {
|
||||
|
||||
@@ -16,6 +16,10 @@ import { warmupNetworkOptimization } from '../utils/uv-env';
|
||||
import { initTelemetry } from '../utils/telemetry';
|
||||
|
||||
import { ClawHubService } from '../gateway/clawhub';
|
||||
import { extensionRegistry } from '../extensions/registry';
|
||||
import { loadExtensionsFromManifest } from '../extensions/loader';
|
||||
import { registerAllBuiltinExtensions } from '../extensions/builtin';
|
||||
import { loadExternalMainExtensions } from '../extensions/_ext-bridge.generated';
|
||||
import { ensureClawXContext, repairClawXOnlyBootstrapFiles } from '../utils/openclaw-workspace';
|
||||
import { autoInstallCliIfNeeded, generateCompletionCache, installCompletionToProfile } from '../utils/openclaw-cli';
|
||||
import { isQuitting, setQuitting } from './app-state';
|
||||
@@ -340,6 +344,19 @@ async function initialize(): Promise<void> {
|
||||
mainWindow: window,
|
||||
});
|
||||
|
||||
// Initialize extension system
|
||||
await extensionRegistry.initialize({
|
||||
gatewayManager,
|
||||
eventBus: hostEventBus,
|
||||
getMainWindow: () => mainWindow,
|
||||
});
|
||||
|
||||
// Wire marketplace provider to ClawHubService if an extension provides one
|
||||
const marketplaceProvider = extensionRegistry.getMarketplaceProvider();
|
||||
if (marketplaceProvider) {
|
||||
clawHubService.setMarketplaceProvider(marketplaceProvider);
|
||||
}
|
||||
|
||||
// Register update handlers
|
||||
registerUpdateHandlers(appUpdater, window);
|
||||
|
||||
@@ -521,6 +538,13 @@ if (gotTheLock) {
|
||||
clawHubService = new ClawHubService();
|
||||
hostEventBus = new HostEventBus();
|
||||
|
||||
// Register builtin extensions and load manifest
|
||||
registerAllBuiltinExtensions();
|
||||
loadExternalMainExtensions();
|
||||
void loadExtensionsFromManifest().catch((err) => {
|
||||
logger.warn('Failed to load extensions from manifest:', err);
|
||||
});
|
||||
|
||||
// When a second instance is launched, focus the existing window instead.
|
||||
app.on('second-instance', () => {
|
||||
logger.info('Second ClawX instance detected; redirecting to the existing window');
|
||||
@@ -578,6 +602,7 @@ if (gotTheLock) {
|
||||
|
||||
hostEventBus.closeAll();
|
||||
hostApiServer?.close();
|
||||
void extensionRegistry.teardownAll();
|
||||
|
||||
const stopPromise = gatewayManager.stop().catch((err) => {
|
||||
logger.warn('gatewayManager.stop() error during quit:', err);
|
||||
|
||||
@@ -180,8 +180,7 @@ const electronAPI = {
|
||||
'openclaw:cli-installed',
|
||||
];
|
||||
|
||||
if (validChannels.includes(channel)) {
|
||||
// Wrap the callback to strip the event
|
||||
if (validChannels.includes(channel) || channel.startsWith('ext:')) {
|
||||
const subscription = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => {
|
||||
callback(...args);
|
||||
};
|
||||
@@ -228,7 +227,7 @@ const electronAPI = {
|
||||
'oauth:error',
|
||||
];
|
||||
|
||||
if (validChannels.includes(channel)) {
|
||||
if (validChannels.includes(channel) || channel.startsWith('ext:')) {
|
||||
ipcRenderer.once(channel, (_event, ...args) => callback(...args));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -33,9 +33,10 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"init": "pnpm install && pnpm run uv:download",
|
||||
"predev": "zx scripts/prepare-preinstalled-skills-dev.mjs",
|
||||
"predev": "node scripts/generate-ext-bridge.mjs && zx scripts/prepare-preinstalled-skills-dev.mjs",
|
||||
"dev": "vite",
|
||||
"build": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && electron-builder",
|
||||
"ext:bridge": "node scripts/generate-ext-bridge.mjs",
|
||||
"build": "node scripts/generate-ext-bridge.mjs && vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && electron-builder",
|
||||
"build:vite": "vite build",
|
||||
"bundle:openclaw-plugins": "zx scripts/bundle-openclaw-plugins.mjs",
|
||||
"bundle:preinstalled-skills": "zx scripts/bundle-preinstalled-skills.mjs",
|
||||
@@ -80,6 +81,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@larksuite/openclaw-lark": "2026.4.7",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
@@ -92,7 +94,6 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@soimy/dingtalk": "^3.5.3",
|
||||
"@tencent-weixin/openclaw-weixin": "^2.1.8",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -9921,7 +9921,7 @@ snapshots:
|
||||
'@types/body-parser@1.19.6':
|
||||
dependencies:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/node': 25.5.0
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@types/bun@1.3.11':
|
||||
dependencies:
|
||||
@@ -9946,7 +9946,7 @@ snapshots:
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
dependencies:
|
||||
'@types/node': 25.5.0
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@types/debug@4.1.13':
|
||||
dependencies:
|
||||
@@ -9966,7 +9966,7 @@ snapshots:
|
||||
|
||||
'@types/express-serve-static-core@5.1.1':
|
||||
dependencies:
|
||||
'@types/node': 25.5.0
|
||||
'@types/node': 25.6.0
|
||||
'@types/qs': 6.15.0
|
||||
'@types/range-parser': 1.2.7
|
||||
'@types/send': 1.2.1
|
||||
@@ -10056,12 +10056,12 @@ snapshots:
|
||||
|
||||
'@types/send@1.2.1':
|
||||
dependencies:
|
||||
'@types/node': 25.5.0
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@types/serve-static@2.2.0':
|
||||
dependencies:
|
||||
'@types/http-errors': 2.0.5
|
||||
'@types/node': 25.5.0
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@types/unist@2.0.11': {}
|
||||
|
||||
|
||||
162
scripts/generate-ext-bridge.mjs
Normal file
162
scripts/generate-ext-bridge.mjs
Normal file
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generates extension bridge files based on clawx-extensions.json and
|
||||
* which packages are actually installed in node_modules.
|
||||
*
|
||||
* Outputs:
|
||||
* electron/extensions/_ext-bridge.generated.ts (main process)
|
||||
* src/extensions/_ext-bridge.generated.ts (renderer)
|
||||
*
|
||||
* Both files are .gitignore'd. When no external extensions are installed,
|
||||
* they export no-op functions so the core compiles cleanly.
|
||||
*
|
||||
* Usage: node scripts/generate-ext-bridge.mjs
|
||||
*/
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..');
|
||||
|
||||
const MANIFEST_PATH = resolve(ROOT, 'clawx-extensions.json');
|
||||
const MAIN_OUT = resolve(ROOT, 'electron/extensions/_ext-bridge.generated.ts');
|
||||
const RENDERER_OUT = resolve(ROOT, 'src/extensions/_ext-bridge.generated.ts');
|
||||
|
||||
function readManifest() {
|
||||
if (!existsSync(MANIFEST_PATH)) return { extensions: {} };
|
||||
try {
|
||||
return JSON.parse(readFileSync(MANIFEST_PATH, 'utf-8'));
|
||||
} catch {
|
||||
return { extensions: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function isExternalId(id) {
|
||||
return id && !id.startsWith('builtin/');
|
||||
}
|
||||
|
||||
function resolvePackageName(extensionId) {
|
||||
// Convention: extension IDs like "@scope/pkg/sub" map to package "@scope/pkg"
|
||||
// IDs like "@scope/pkg" map to package "@scope/pkg"
|
||||
const parts = extensionId.split('/');
|
||||
if (parts[0].startsWith('@') && parts.length >= 2) {
|
||||
return parts.slice(0, 2).join('/');
|
||||
}
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
function isPackageInstalled(pkgName) {
|
||||
return existsSync(resolve(ROOT, 'node_modules', ...pkgName.split('/')));
|
||||
}
|
||||
|
||||
function generateMainBridge(manifest) {
|
||||
const externalMain = (manifest.extensions?.main ?? []).filter(isExternalId);
|
||||
const installedExts = externalMain.filter((id) => isPackageInstalled(resolvePackageName(id)));
|
||||
|
||||
if (installedExts.length === 0) {
|
||||
return [
|
||||
'// Auto-generated — no external main-process extensions installed.',
|
||||
'// To add extensions, configure clawx-extensions.json and link the package.',
|
||||
'export function loadExternalMainExtensions(): void { /* no-op */ }',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const lines = [
|
||||
'// Auto-generated by scripts/generate-ext-bridge.mjs — do not edit.',
|
||||
"import { extensionRegistry } from './registry';",
|
||||
'',
|
||||
];
|
||||
|
||||
installedExts.forEach((id, i) => {
|
||||
const pkg = resolvePackageName(id);
|
||||
const subpath = id.slice(pkg.length + 1); // e.g. "enterprise-auth"
|
||||
const importPath = subpath ? `${pkg}/${subpath}` : pkg;
|
||||
const factoryName = `ext${i}`;
|
||||
lines.push(`import { ${guessFactoryExport(subpath || id)} as ${factoryName} } from '${importPath}';`);
|
||||
});
|
||||
|
||||
lines.push('');
|
||||
lines.push('export function loadExternalMainExtensions(): void {');
|
||||
installedExts.forEach((_id, i) => {
|
||||
lines.push(` const e${i} = ext${i}();`);
|
||||
lines.push(` if (e${i}) extensionRegistry.register(e${i});`);
|
||||
});
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function generateRendererBridge(manifest) {
|
||||
const externalRenderer = (manifest.extensions?.renderer ?? []).filter(isExternalId);
|
||||
const installedExts = externalRenderer.filter((id) => isPackageInstalled(resolvePackageName(id)));
|
||||
|
||||
if (installedExts.length === 0) {
|
||||
return [
|
||||
'// Auto-generated — no external renderer extensions installed.',
|
||||
'// To add extensions, configure clawx-extensions.json and link the package.',
|
||||
'export function loadExternalRendererExtensions(): void { /* no-op */ }',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const lines = [
|
||||
'// Auto-generated by scripts/generate-ext-bridge.mjs — do not edit.',
|
||||
"import { rendererExtensionRegistry } from './registry';",
|
||||
'',
|
||||
];
|
||||
|
||||
installedExts.forEach((id, i) => {
|
||||
const pkg = resolvePackageName(id);
|
||||
const subpath = id.slice(pkg.length + 1);
|
||||
const importPath = subpath ? `${pkg}/${subpath}` : pkg;
|
||||
const factoryName = `ext${i}`;
|
||||
lines.push(`import { ${guessFactoryExport(subpath || id)} as ${factoryName} } from '${importPath}';`);
|
||||
});
|
||||
|
||||
lines.push('');
|
||||
lines.push('export function loadExternalRendererExtensions(): void {');
|
||||
installedExts.forEach((_id, i) => {
|
||||
lines.push(` const e${i} = ext${i}();`);
|
||||
lines.push(` if (e${i}) rendererExtensionRegistry.register(e${i});`);
|
||||
});
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function guessFactoryExport(subpath) {
|
||||
// "enterprise-auth" → "createEnterpriseAuthExtension"
|
||||
// "enterprise-ui" → "createEnterpriseUIExtension"
|
||||
// "skillshub-marketplace" → "createSkillshubMarketplaceExtension"
|
||||
const camel = subpath
|
||||
.replace(/[^a-zA-Z0-9]+(.)/g, (_, c) => c.toUpperCase())
|
||||
.replace(/^./, (c) => c.toUpperCase());
|
||||
return `create${camel}Extension`;
|
||||
}
|
||||
|
||||
// ─── Main ───
|
||||
const manifest = readManifest();
|
||||
|
||||
mkdirSync(dirname(MAIN_OUT), { recursive: true });
|
||||
mkdirSync(dirname(RENDERER_OUT), { recursive: true });
|
||||
|
||||
writeFileSync(MAIN_OUT, generateMainBridge(manifest));
|
||||
writeFileSync(RENDERER_OUT, generateRendererBridge(manifest));
|
||||
|
||||
const mainExts = (manifest.extensions?.main ?? []).filter(isExternalId);
|
||||
const rendererExts = (manifest.extensions?.renderer ?? []).filter(isExternalId);
|
||||
const totalExternal = mainExts.length + rendererExts.length;
|
||||
|
||||
if (totalExternal === 0) {
|
||||
console.log('[ext-bridge] No external extensions configured — generated empty stubs.');
|
||||
} else {
|
||||
const installedMain = mainExts.filter((id) => isPackageInstalled(resolvePackageName(id)));
|
||||
const installedRenderer = rendererExts.filter((id) => isPackageInstalled(resolvePackageName(id)));
|
||||
console.log(
|
||||
`[ext-bridge] Generated bridges: ${installedMain.length}/${mainExts.length} main, ${installedRenderer.length}/${rendererExts.length} renderer extensions resolved.`,
|
||||
);
|
||||
}
|
||||
15
src/App.tsx
15
src/App.tsx
@@ -21,6 +21,8 @@ import { useSettingsStore } from './stores/settings';
|
||||
import { useGatewayStore } from './stores/gateway';
|
||||
import { useProviderStore } from './stores/providers';
|
||||
import { applyGatewayTransportPreference } from './lib/api-client';
|
||||
import { rendererExtensionRegistry } from './extensions/registry';
|
||||
import { loadExternalRendererExtensions } from './extensions/_ext-bridge.generated';
|
||||
|
||||
|
||||
/**
|
||||
@@ -164,6 +166,16 @@ function App() {
|
||||
applyGatewayTransportPreference();
|
||||
}, []);
|
||||
|
||||
// Load external renderer extensions (generated by scripts/generate-ext-bridge.mjs)
|
||||
// and initialize all registered extensions.
|
||||
useEffect(() => {
|
||||
loadExternalRendererExtensions();
|
||||
void rendererExtensionRegistry.initializeAll();
|
||||
return () => rendererExtensionRegistry.teardownAll();
|
||||
}, []);
|
||||
|
||||
const extraRoutes = rendererExtensionRegistry.getExtraRoutes();
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
@@ -180,6 +192,9 @@ function App() {
|
||||
<Route path="/skills" element={<Skills />} />
|
||||
<Route path="/cron" element={<Cron />} />
|
||||
<Route path="/settings/*" element={<Settings />} />
|
||||
{extraRoutes.map((r) => (
|
||||
<Route key={r.path} path={r.path} element={<r.component />} />
|
||||
))}
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Cpu,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { rendererExtensionRegistry } from '@/extensions/registry';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
@@ -208,7 +209,10 @@ export function Sidebar() {
|
||||
sessionBucketMap[bucketKey].sessions.push(session);
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
const hiddenRoutes = rendererExtensionRegistry.getHiddenRoutes();
|
||||
const extraNavItems = rendererExtensionRegistry.getExtraNavItems();
|
||||
|
||||
const coreNavItems = [
|
||||
{ to: '/models', icon: <Cpu className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.models'), testId: 'sidebar-nav-models' },
|
||||
{ to: '/agents', icon: <Bot className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.agents'), testId: 'sidebar-nav-agents' },
|
||||
{ to: '/channels', icon: <Network className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.channels'), testId: 'sidebar-nav-channels' },
|
||||
@@ -216,6 +220,16 @@ export function Sidebar() {
|
||||
{ to: '/cron', icon: <Clock className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.cronTasks'), testId: 'sidebar-nav-cron' },
|
||||
];
|
||||
|
||||
const navItems = [
|
||||
...coreNavItems.filter((item) => !hiddenRoutes.has(item.to)),
|
||||
...extraNavItems.map((item) => ({
|
||||
to: item.to,
|
||||
icon: <item.icon className="h-[18px] w-[18px]" strokeWidth={2} />,
|
||||
label: item.labelI18nKey ? t(item.labelI18nKey) : item.label,
|
||||
testId: item.testId,
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<aside
|
||||
data-testid="sidebar"
|
||||
|
||||
12
src/extensions/index.ts
Normal file
12
src/extensions/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { rendererExtensionRegistry } from './registry';
|
||||
export { registerRendererExtensionModule, loadRendererExtensions } from './loader';
|
||||
export type {
|
||||
RendererExtension,
|
||||
NavItemDef,
|
||||
RouteDef,
|
||||
SettingsSectionDef,
|
||||
SidebarExtension,
|
||||
RouteExtension,
|
||||
SettingsSectionExtension,
|
||||
I18nResources,
|
||||
} from './types';
|
||||
34
src/extensions/loader.ts
Normal file
34
src/extensions/loader.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { rendererExtensionRegistry } from './registry';
|
||||
import type { RendererExtension } from './types';
|
||||
|
||||
interface RendererExtensionManifest {
|
||||
extensions?: {
|
||||
renderer?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const registeredModules = new Map<string, () => RendererExtension>();
|
||||
|
||||
export function registerRendererExtensionModule(id: string, factory: () => RendererExtension): void {
|
||||
registeredModules.set(id, factory);
|
||||
}
|
||||
|
||||
export function loadRendererExtensions(manifest?: RendererExtensionManifest): void {
|
||||
const extensionIds = manifest?.extensions?.renderer;
|
||||
|
||||
if (!extensionIds || extensionIds.length === 0) {
|
||||
for (const [, factory] of registeredModules) {
|
||||
rendererExtensionRegistry.register(factory());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const id of extensionIds) {
|
||||
const factory = registeredModules.get(id);
|
||||
if (factory) {
|
||||
rendererExtensionRegistry.register(factory());
|
||||
} else {
|
||||
console.warn(`[extensions] Renderer extension "${id}" not found in registered modules`);
|
||||
}
|
||||
}
|
||||
}
|
||||
81
src/extensions/registry.ts
Normal file
81
src/extensions/registry.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import i18n from '@/i18n';
|
||||
import type {
|
||||
RendererExtension,
|
||||
NavItemDef,
|
||||
RouteDef,
|
||||
SettingsSectionDef,
|
||||
} from './types';
|
||||
|
||||
class RendererExtensionRegistry {
|
||||
private extensions: RendererExtension[] = [];
|
||||
|
||||
register(extension: RendererExtension): void {
|
||||
if (this.extensions.some((e) => e.id === extension.id)) {
|
||||
console.warn(`[extensions] Renderer extension "${extension.id}" already registered`);
|
||||
return;
|
||||
}
|
||||
this.extensions.push(extension);
|
||||
if (extension.i18nResources) {
|
||||
this.loadI18nResources(extension.i18nResources);
|
||||
}
|
||||
}
|
||||
|
||||
private loadI18nResources(resources: Record<string, Record<string, unknown>>): void {
|
||||
for (const [lang, namespaces] of Object.entries(resources)) {
|
||||
for (const [ns, bundle] of Object.entries(namespaces)) {
|
||||
i18n.addResourceBundle(lang, ns, bundle, true, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getAll(): RendererExtension[] {
|
||||
return [...this.extensions];
|
||||
}
|
||||
|
||||
getExtraNavItems(): NavItemDef[] {
|
||||
return this.extensions.flatMap((ext) => ext.sidebar?.navItems ?? []);
|
||||
}
|
||||
|
||||
getHiddenRoutes(): Set<string> {
|
||||
const hidden = new Set<string>();
|
||||
for (const ext of this.extensions) {
|
||||
for (const route of ext.sidebar?.hiddenRoutes ?? []) {
|
||||
hidden.add(route);
|
||||
}
|
||||
}
|
||||
return hidden;
|
||||
}
|
||||
|
||||
getExtraRoutes(): RouteDef[] {
|
||||
return this.extensions.flatMap((ext) => ext.routes?.routes ?? []);
|
||||
}
|
||||
|
||||
getExtraSettingsSections(): SettingsSectionDef[] {
|
||||
return this.extensions
|
||||
.flatMap((ext) => ext.settings?.sections ?? [])
|
||||
.sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
|
||||
}
|
||||
|
||||
async initializeAll(): Promise<void> {
|
||||
for (const ext of this.extensions) {
|
||||
try {
|
||||
await ext.setup?.();
|
||||
} catch (err) {
|
||||
console.error(`[extensions] Renderer extension "${ext.id}" setup failed:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
teardownAll(): void {
|
||||
for (const ext of this.extensions) {
|
||||
try {
|
||||
ext.teardown?.();
|
||||
} catch (err) {
|
||||
console.warn(`[extensions] Renderer extension "${ext.id}" teardown failed:`, err);
|
||||
}
|
||||
}
|
||||
this.extensions = [];
|
||||
}
|
||||
}
|
||||
|
||||
export const rendererExtensionRegistry = new RendererExtensionRegistry();
|
||||
48
src/extensions/types.ts
Normal file
48
src/extensions/types.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
export interface NavItemDef {
|
||||
to: string;
|
||||
icon: ComponentType<{ className?: string; strokeWidth?: number }>;
|
||||
label: string;
|
||||
labelI18nKey?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export type I18nResources = Record<string, Record<string, unknown>>;
|
||||
|
||||
export interface SidebarExtension {
|
||||
id: string;
|
||||
navItems?: NavItemDef[];
|
||||
hiddenRoutes?: string[];
|
||||
}
|
||||
|
||||
export interface RouteDef {
|
||||
path: string;
|
||||
component: ComponentType;
|
||||
}
|
||||
|
||||
export interface RouteExtension {
|
||||
id: string;
|
||||
routes: RouteDef[];
|
||||
}
|
||||
|
||||
export interface SettingsSectionDef {
|
||||
id: string;
|
||||
component: ComponentType;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface SettingsSectionExtension {
|
||||
id: string;
|
||||
sections: SettingsSectionDef[];
|
||||
}
|
||||
|
||||
export interface RendererExtension {
|
||||
id: string;
|
||||
sidebar?: SidebarExtension;
|
||||
routes?: RouteExtension;
|
||||
settings?: SettingsSectionExtension;
|
||||
i18nResources?: I18nResources;
|
||||
setup?(): void | Promise<void>;
|
||||
teardown?(): void;
|
||||
}
|
||||
@@ -114,21 +114,22 @@ function removeDeletedTarget(groups: ChannelGroupItem[], target: DeleteTarget):
|
||||
return groups.filter((group) => group.channelType !== target.channelType);
|
||||
}
|
||||
|
||||
const DEFAULT_GATEWAY_HEALTH: GatewayHealthSummary = {
|
||||
state: 'healthy',
|
||||
reasons: [],
|
||||
consecutiveHeartbeatMisses: 0,
|
||||
};
|
||||
|
||||
export function Channels() {
|
||||
const { t } = useTranslation('channels');
|
||||
const gatewayStatus = useGatewayStore((state) => state.status);
|
||||
const lastGatewayStateRef = useRef(gatewayStatus.state);
|
||||
const defaultGatewayHealth = useMemo<GatewayHealthSummary>(() => ({
|
||||
state: 'healthy',
|
||||
reasons: [],
|
||||
consecutiveHeartbeatMisses: 0,
|
||||
}), []);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [channelGroups, setChannelGroups] = useState<ChannelGroupItem[]>([]);
|
||||
const [agents, setAgents] = useState<AgentItem[]>([]);
|
||||
const [gatewayHealth, setGatewayHealth] = useState<GatewayHealthSummary>(defaultGatewayHealth);
|
||||
const [gatewayHealth, setGatewayHealth] = useState<GatewayHealthSummary>(DEFAULT_GATEWAY_HEALTH);
|
||||
const [diagnosticsSnapshot, setDiagnosticsSnapshot] = useState<GatewayDiagnosticSnapshot | null>(null);
|
||||
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
||||
const [diagnosticsLoading, setDiagnosticsLoading] = useState(false);
|
||||
@@ -208,7 +209,7 @@ export function Channels() {
|
||||
|
||||
setChannelGroups(channelsPayload.channels || []);
|
||||
setAgents(agentsRes.agents || []);
|
||||
setGatewayHealth(channelsPayload.gatewayHealth || defaultGatewayHealth);
|
||||
setGatewayHealth(channelsPayload.gatewayHealth || DEFAULT_GATEWAY_HEALTH);
|
||||
setDiagnosticsSnapshot(null);
|
||||
setShowDiagnostics(false);
|
||||
console.info(
|
||||
|
||||
@@ -5,6 +5,30 @@
|
||||
import { vi } from 'vitest';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
// Provide a minimal `electron` mock so tests that transitively import
|
||||
// main-process code (logger, store, etc.) don't blow up when the Electron
|
||||
// binary is not present (e.g. CI with ELECTRON_SKIP_BINARY_DOWNLOAD=1).
|
||||
// Individual test files can override with their own vi.mock('electron', ...).
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: vi.fn().mockReturnValue('/tmp/clawx-test'),
|
||||
getVersion: vi.fn().mockReturnValue('0.0.0-test'),
|
||||
getName: vi.fn().mockReturnValue('clawx-test'),
|
||||
isPackaged: false,
|
||||
isReady: vi.fn().mockResolvedValue(true),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
quit: vi.fn(),
|
||||
whenReady: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
BrowserWindow: vi.fn(),
|
||||
ipcMain: { on: vi.fn(), handle: vi.fn(), removeHandler: vi.fn() },
|
||||
dialog: { showOpenDialog: vi.fn(), showMessageBox: vi.fn() },
|
||||
shell: { openExternal: vi.fn() },
|
||||
session: { defaultSession: { webRequest: { onBeforeSendHeaders: vi.fn() } } },
|
||||
utilityProcess: {},
|
||||
}));
|
||||
|
||||
// Mock window.electron API
|
||||
const mockElectron = {
|
||||
ipcRenderer: {
|
||||
|
||||
@@ -28,5 +28,6 @@
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
@@ -3,11 +3,38 @@ import react from '@vitejs/plugin-react';
|
||||
import electron from 'vite-plugin-electron';
|
||||
import renderer from 'vite-plugin-electron-renderer';
|
||||
import { resolve } from 'path';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
|
||||
function getExtensionPackages(): Set<string> {
|
||||
try {
|
||||
const manifestPath = resolve(__dirname, 'clawx-extensions.json');
|
||||
if (!existsSync(manifestPath)) return new Set();
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
||||
const allIds: string[] = [
|
||||
...(manifest.extensions?.main ?? []),
|
||||
...(manifest.extensions?.renderer ?? []),
|
||||
];
|
||||
const pkgs = new Set<string>();
|
||||
for (const id of allIds) {
|
||||
if (id.startsWith('builtin/')) continue;
|
||||
const parts = id.split('/');
|
||||
pkgs.add(parts[0].startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]);
|
||||
}
|
||||
return pkgs;
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
const extensionPackages = getExtensionPackages();
|
||||
|
||||
function isMainProcessExternal(id: string): boolean {
|
||||
if (!id || id.startsWith('\0')) return false;
|
||||
if (id.startsWith('.') || id.startsWith('/') || /^[A-Za-z]:[\\/]/.test(id)) return false;
|
||||
if (id.startsWith('@/') || id.startsWith('@electron/')) return false;
|
||||
for (const pkg of extensionPackages) {
|
||||
if (id === pkg || id.startsWith(pkg + '/')) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user