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
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Generate extension bridge
|
||||||
|
run: pnpm run ext:bridge
|
||||||
|
|
||||||
- name: Run linter
|
- name: Run linter
|
||||||
run: pnpm run lint
|
run: pnpm run lint
|
||||||
|
|
||||||
@@ -57,5 +60,8 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Generate extension bridge
|
||||||
|
run: pnpm run ext:bridge
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: pnpm run build:vite
|
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
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Generate extension bridge
|
||||||
|
run: pnpm run ext:bridge
|
||||||
|
|
||||||
- name: Rebuild Electron binary
|
- name: Rebuild Electron binary
|
||||||
run: pnpm rebuild electron
|
run: pnpm rebuild electron
|
||||||
|
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -69,3 +69,7 @@ docs/pr-session-notes-*.md
|
|||||||
.claude/
|
.claude/
|
||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
package-lock.json
|
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;
|
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') {
|
if (url.pathname === '/api/clawhub/search' && req.method === 'POST') {
|
||||||
try {
|
try {
|
||||||
const body = await parseJsonBody<Record<string, unknown>>(req);
|
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 { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
||||||
import { getPort } from '../utils/config';
|
import { getPort } from '../utils/config';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
import { extensionRegistry } from '../extensions/registry';
|
||||||
import type { HostApiContext } from './context';
|
import type { HostApiContext } from './context';
|
||||||
import { handleAppRoutes } from './routes/app';
|
import { handleAppRoutes } from './routes/app';
|
||||||
import { handleGatewayRoutes } from './routes/gateway';
|
import { handleGatewayRoutes } from './routes/gateway';
|
||||||
@@ -25,7 +26,7 @@ type RouteHandler = (
|
|||||||
ctx: HostApiContext,
|
ctx: HostApiContext,
|
||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
|
|
||||||
const routeHandlers: RouteHandler[] = [
|
const coreRouteHandlers: RouteHandler[] = [
|
||||||
handleAppRoutes,
|
handleAppRoutes,
|
||||||
handleGatewayRoutes,
|
handleGatewayRoutes,
|
||||||
handleSettingsRoutes,
|
handleSettingsRoutes,
|
||||||
@@ -41,6 +42,11 @@ const routeHandlers: RouteHandler[] = [
|
|||||||
handleUsageRoutes,
|
handleUsageRoutes,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function buildRouteHandlers(): RouteHandler[] {
|
||||||
|
const extensionHandlers = extensionRegistry.getRouteHandlers();
|
||||||
|
return [...coreRouteHandlers, ...extensionHandlers];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-session secret token used to authenticate Host API requests.
|
* Per-session secret token used to authenticate Host API requests.
|
||||||
* Generated once at server start and shared with the renderer via IPC.
|
* 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const routeHandlers = buildRouteHandlers();
|
||||||
for (const handler of routeHandlers) {
|
for (const handler of routeHandlers) {
|
||||||
if (await handler(req, res, requestUrl, ctx)) {
|
if (await handler(req, res, requestUrl, ctx)) {
|
||||||
return;
|
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;
|
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 {
|
export class ClawHubService {
|
||||||
private workDir: string;
|
private workDir: string;
|
||||||
private cliPath: string;
|
private cliPath: string;
|
||||||
private cliEntryPath: string;
|
private cliEntryPath: string;
|
||||||
private useNodeRunner: boolean;
|
private useNodeRunner: boolean;
|
||||||
private ansiRegex: RegExp;
|
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() {
|
constructor() {
|
||||||
// Use the user's OpenClaw config directory (~/.openclaw) for skill management
|
// 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[]> {
|
async search(params: ClawHubSearchParams): Promise<ClawHubSkillResult[]> {
|
||||||
|
if (this.marketplaceProvider) {
|
||||||
|
return this.marketplaceProvider.search(params);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// If query is empty, use 'explore' to show trending skills
|
// If query is empty, use 'explore' to show trending skills
|
||||||
if (!params.query || params.query.trim() === '') {
|
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> {
|
async install(params: ClawHubInstallParams): Promise<void> {
|
||||||
|
if (this.marketplaceProvider) {
|
||||||
|
return this.marketplaceProvider.install(params);
|
||||||
|
}
|
||||||
const args = ['install', params.slug];
|
const args = ['install', params.slug];
|
||||||
|
|
||||||
if (params.version) {
|
if (params.version) {
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ import { warmupNetworkOptimization } from '../utils/uv-env';
|
|||||||
import { initTelemetry } from '../utils/telemetry';
|
import { initTelemetry } from '../utils/telemetry';
|
||||||
|
|
||||||
import { ClawHubService } from '../gateway/clawhub';
|
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 { ensureClawXContext, repairClawXOnlyBootstrapFiles } from '../utils/openclaw-workspace';
|
||||||
import { autoInstallCliIfNeeded, generateCompletionCache, installCompletionToProfile } from '../utils/openclaw-cli';
|
import { autoInstallCliIfNeeded, generateCompletionCache, installCompletionToProfile } from '../utils/openclaw-cli';
|
||||||
import { isQuitting, setQuitting } from './app-state';
|
import { isQuitting, setQuitting } from './app-state';
|
||||||
@@ -340,6 +344,19 @@ async function initialize(): Promise<void> {
|
|||||||
mainWindow: window,
|
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
|
// Register update handlers
|
||||||
registerUpdateHandlers(appUpdater, window);
|
registerUpdateHandlers(appUpdater, window);
|
||||||
|
|
||||||
@@ -521,6 +538,13 @@ if (gotTheLock) {
|
|||||||
clawHubService = new ClawHubService();
|
clawHubService = new ClawHubService();
|
||||||
hostEventBus = new HostEventBus();
|
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.
|
// When a second instance is launched, focus the existing window instead.
|
||||||
app.on('second-instance', () => {
|
app.on('second-instance', () => {
|
||||||
logger.info('Second ClawX instance detected; redirecting to the existing window');
|
logger.info('Second ClawX instance detected; redirecting to the existing window');
|
||||||
@@ -578,6 +602,7 @@ if (gotTheLock) {
|
|||||||
|
|
||||||
hostEventBus.closeAll();
|
hostEventBus.closeAll();
|
||||||
hostApiServer?.close();
|
hostApiServer?.close();
|
||||||
|
void extensionRegistry.teardownAll();
|
||||||
|
|
||||||
const stopPromise = gatewayManager.stop().catch((err) => {
|
const stopPromise = gatewayManager.stop().catch((err) => {
|
||||||
logger.warn('gatewayManager.stop() error during quit:', err);
|
logger.warn('gatewayManager.stop() error during quit:', err);
|
||||||
|
|||||||
@@ -180,8 +180,7 @@ const electronAPI = {
|
|||||||
'openclaw:cli-installed',
|
'openclaw:cli-installed',
|
||||||
];
|
];
|
||||||
|
|
||||||
if (validChannels.includes(channel)) {
|
if (validChannels.includes(channel) || channel.startsWith('ext:')) {
|
||||||
// Wrap the callback to strip the event
|
|
||||||
const subscription = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => {
|
const subscription = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => {
|
||||||
callback(...args);
|
callback(...args);
|
||||||
};
|
};
|
||||||
@@ -228,7 +227,7 @@ const electronAPI = {
|
|||||||
'oauth:error',
|
'oauth:error',
|
||||||
];
|
];
|
||||||
|
|
||||||
if (validChannels.includes(channel)) {
|
if (validChannels.includes(channel) || channel.startsWith('ext:')) {
|
||||||
ipcRenderer.once(channel, (_event, ...args) => callback(...args));
|
ipcRenderer.once(channel, (_event, ...args) => callback(...args));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,9 +33,10 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"init": "pnpm install && pnpm run uv:download",
|
"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",
|
"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",
|
"build:vite": "vite build",
|
||||||
"bundle:openclaw-plugins": "zx scripts/bundle-openclaw-plugins.mjs",
|
"bundle:openclaw-plugins": "zx scripts/bundle-openclaw-plugins.mjs",
|
||||||
"bundle:preinstalled-skills": "zx scripts/bundle-preinstalled-skills.mjs",
|
"bundle:preinstalled-skills": "zx scripts/bundle-preinstalled-skills.mjs",
|
||||||
@@ -80,6 +81,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@larksuite/openclaw-lark": "2026.4.7",
|
"@larksuite/openclaw-lark": "2026.4.7",
|
||||||
|
"@playwright/test": "^1.56.1",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
@@ -92,7 +94,6 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-toast": "^1.2.15",
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@playwright/test": "^1.56.1",
|
|
||||||
"@soimy/dingtalk": "^3.5.3",
|
"@soimy/dingtalk": "^3.5.3",
|
||||||
"@tencent-weixin/openclaw-weixin": "^2.1.8",
|
"@tencent-weixin/openclaw-weixin": "^2.1.8",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@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':
|
'@types/body-parser@1.19.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/connect': 3.4.38
|
'@types/connect': 3.4.38
|
||||||
'@types/node': 25.5.0
|
'@types/node': 25.6.0
|
||||||
|
|
||||||
'@types/bun@1.3.11':
|
'@types/bun@1.3.11':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -9946,7 +9946,7 @@ snapshots:
|
|||||||
|
|
||||||
'@types/connect@3.4.38':
|
'@types/connect@3.4.38':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 25.5.0
|
'@types/node': 25.6.0
|
||||||
|
|
||||||
'@types/debug@4.1.13':
|
'@types/debug@4.1.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -9966,7 +9966,7 @@ snapshots:
|
|||||||
|
|
||||||
'@types/express-serve-static-core@5.1.1':
|
'@types/express-serve-static-core@5.1.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 25.5.0
|
'@types/node': 25.6.0
|
||||||
'@types/qs': 6.15.0
|
'@types/qs': 6.15.0
|
||||||
'@types/range-parser': 1.2.7
|
'@types/range-parser': 1.2.7
|
||||||
'@types/send': 1.2.1
|
'@types/send': 1.2.1
|
||||||
@@ -10056,12 +10056,12 @@ snapshots:
|
|||||||
|
|
||||||
'@types/send@1.2.1':
|
'@types/send@1.2.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 25.5.0
|
'@types/node': 25.6.0
|
||||||
|
|
||||||
'@types/serve-static@2.2.0':
|
'@types/serve-static@2.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/http-errors': 2.0.5
|
'@types/http-errors': 2.0.5
|
||||||
'@types/node': 25.5.0
|
'@types/node': 25.6.0
|
||||||
|
|
||||||
'@types/unist@2.0.11': {}
|
'@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 { useGatewayStore } from './stores/gateway';
|
||||||
import { useProviderStore } from './stores/providers';
|
import { useProviderStore } from './stores/providers';
|
||||||
import { applyGatewayTransportPreference } from './lib/api-client';
|
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();
|
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 (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<TooltipProvider delayDuration={300}>
|
<TooltipProvider delayDuration={300}>
|
||||||
@@ -180,6 +192,9 @@ function App() {
|
|||||||
<Route path="/skills" element={<Skills />} />
|
<Route path="/skills" element={<Skills />} />
|
||||||
<Route path="/cron" element={<Cron />} />
|
<Route path="/cron" element={<Cron />} />
|
||||||
<Route path="/settings/*" element={<Settings />} />
|
<Route path="/settings/*" element={<Settings />} />
|
||||||
|
{extraRoutes.map((r) => (
|
||||||
|
<Route key={r.path} path={r.path} element={<r.component />} />
|
||||||
|
))}
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
Cpu,
|
Cpu,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { rendererExtensionRegistry } from '@/extensions/registry';
|
||||||
import { useSettingsStore } from '@/stores/settings';
|
import { useSettingsStore } from '@/stores/settings';
|
||||||
import { useChatStore } from '@/stores/chat';
|
import { useChatStore } from '@/stores/chat';
|
||||||
import { useGatewayStore } from '@/stores/gateway';
|
import { useGatewayStore } from '@/stores/gateway';
|
||||||
@@ -208,7 +209,10 @@ export function Sidebar() {
|
|||||||
sessionBucketMap[bucketKey].sessions.push(session);
|
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: '/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: '/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' },
|
{ 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' },
|
{ 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 (
|
return (
|
||||||
<aside
|
<aside
|
||||||
data-testid="sidebar"
|
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);
|
return groups.filter((group) => group.channelType !== target.channelType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_GATEWAY_HEALTH: GatewayHealthSummary = {
|
||||||
|
state: 'healthy',
|
||||||
|
reasons: [],
|
||||||
|
consecutiveHeartbeatMisses: 0,
|
||||||
|
};
|
||||||
|
|
||||||
export function Channels() {
|
export function Channels() {
|
||||||
const { t } = useTranslation('channels');
|
const { t } = useTranslation('channels');
|
||||||
const gatewayStatus = useGatewayStore((state) => state.status);
|
const gatewayStatus = useGatewayStore((state) => state.status);
|
||||||
const lastGatewayStateRef = useRef(gatewayStatus.state);
|
const lastGatewayStateRef = useRef(gatewayStatus.state);
|
||||||
const defaultGatewayHealth = useMemo<GatewayHealthSummary>(() => ({
|
|
||||||
state: 'healthy',
|
|
||||||
reasons: [],
|
|
||||||
consecutiveHeartbeatMisses: 0,
|
|
||||||
}), []);
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [channelGroups, setChannelGroups] = useState<ChannelGroupItem[]>([]);
|
const [channelGroups, setChannelGroups] = useState<ChannelGroupItem[]>([]);
|
||||||
const [agents, setAgents] = useState<AgentItem[]>([]);
|
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 [diagnosticsSnapshot, setDiagnosticsSnapshot] = useState<GatewayDiagnosticSnapshot | null>(null);
|
||||||
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
||||||
const [diagnosticsLoading, setDiagnosticsLoading] = useState(false);
|
const [diagnosticsLoading, setDiagnosticsLoading] = useState(false);
|
||||||
@@ -208,7 +209,7 @@ export function Channels() {
|
|||||||
|
|
||||||
setChannelGroups(channelsPayload.channels || []);
|
setChannelGroups(channelsPayload.channels || []);
|
||||||
setAgents(agentsRes.agents || []);
|
setAgents(agentsRes.agents || []);
|
||||||
setGatewayHealth(channelsPayload.gatewayHealth || defaultGatewayHealth);
|
setGatewayHealth(channelsPayload.gatewayHealth || DEFAULT_GATEWAY_HEALTH);
|
||||||
setDiagnosticsSnapshot(null);
|
setDiagnosticsSnapshot(null);
|
||||||
setShowDiagnostics(false);
|
setShowDiagnostics(false);
|
||||||
console.info(
|
console.info(
|
||||||
|
|||||||
@@ -5,6 +5,30 @@
|
|||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import '@testing-library/jest-dom';
|
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
|
// Mock window.electron API
|
||||||
const mockElectron = {
|
const mockElectron = {
|
||||||
ipcRenderer: {
|
ipcRenderer: {
|
||||||
|
|||||||
@@ -28,5 +28,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,38 @@ import react from '@vitejs/plugin-react';
|
|||||||
import electron from 'vite-plugin-electron';
|
import electron from 'vite-plugin-electron';
|
||||||
import renderer from 'vite-plugin-electron-renderer';
|
import renderer from 'vite-plugin-electron-renderer';
|
||||||
import { resolve } from 'path';
|
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 {
|
function isMainProcessExternal(id: string): boolean {
|
||||||
if (!id || id.startsWith('\0')) return false;
|
if (!id || id.startsWith('\0')) return false;
|
||||||
if (id.startsWith('.') || id.startsWith('/') || /^[A-Za-z]:[\\/]/.test(id)) return false;
|
if (id.startsWith('.') || id.startsWith('/') || /^[A-Za-z]:[\\/]/.test(id)) return false;
|
||||||
if (id.startsWith('@/') || id.startsWith('@electron/')) 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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user