Chore/build npm (#9)

Co-authored-by: DigHuang <114602213+DigHuang@users.noreply.github.com>
Co-authored-by: Felix <24791380+vcfgv@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Haze
2026-02-09 15:10:08 +08:00
committed by GitHub
Unverified
parent 0b7f1c700e
commit de445ae3d5
37 changed files with 7359 additions and 1586 deletions

View File

@@ -27,8 +27,6 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -49,8 +47,6 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -71,8 +67,6 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -97,8 +91,6 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with:
version: 10
- name: Get pnpm store directory - name: Get pnpm store directory
shell: bash shell: bash

View File

@@ -4,6 +4,9 @@
name: Release name: Release
on: on:
pull_request:
branches:
- main
push: push:
tags: tags:
- 'v*' - 'v*'
@@ -43,8 +46,6 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with:
version: 10
- name: Get pnpm store directory - name: Get pnpm store directory
shell: bash shell: bash
@@ -70,13 +71,13 @@ jobs:
if: matrix.platform == 'mac' if: matrix.platform == 'mac'
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# For code signing (optional) # Code signing
# CSC_LINK: ${{ secrets.MAC_CERTS }} CSC_LINK: ${{ secrets.MAC_CERTS }}
# CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
# For notarization (optional) # Notarization
# APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_ID: ${{ secrets.APPLE_ID }}
# APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
# APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: pnpm run package:mac run: pnpm run package:mac
# Windows specific steps # Windows specific steps

4
.gitignore vendored
View File

@@ -57,3 +57,7 @@ resources/bin
*.p12 *.p12
*.pem *.pem
*.key *.key
build/
.cursor/

3
.gitmodules vendored
View File

@@ -1,3 +0,0 @@
[submodule "openclaw"]
path = openclaw
url = https://github.com/openclaw/openclaw.git

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ClawX Team
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -21,23 +21,12 @@ extraResources:
- "!icons/*.md" - "!icons/*.md"
- "!icons/*.svg" - "!icons/*.svg"
- "!bin/**" - "!bin/**"
# OpenClaw submodule - include only necessary files for runtime # OpenClaw package (node_modules copied separately by afterPack hook
- from: openclaw/ # because electron-builder respects .gitignore which excludes node_modules/)
- from: build/openclaw/
to: openclaw/ to: openclaw/
filter:
- "openclaw.mjs" afterPack: ./scripts/after-pack.cjs
- "package.json"
- "dist/**/*"
- "skills/**/*"
- "extensions/**/*"
- "scripts/run-node.mjs"
- "!**/node_modules/**"
- "!**/*.test.ts"
- "!**/*.test.js"
- "!**/test/**"
- "!**/.git"
- "!**/.github"
- "!**/docs/**"
asar: true asar: true
asarUnpack: asarUnpack:
@@ -59,23 +48,19 @@ mac:
icon: resources/icons/icon.icns icon: resources/icons/icon.icns
target: target:
- target: dmg - target: dmg
arch:
- universal
- target: zip - target: zip
arch:
- universal
darkModeSupport: true darkModeSupport: true
hardenedRuntime: true hardenedRuntime: true
gatekeeperAssess: false gatekeeperAssess: false
entitlements: entitlements.mac.plist entitlements: entitlements.mac.plist
entitlementsInherit: entitlements.mac.plist entitlementsInherit: entitlements.mac.plist
notarize: false # Set to true when you have Apple credentials notarize: true
extendInfo: extendInfo:
NSMicrophoneUsageDescription: ClawX requires microphone access for voice features NSMicrophoneUsageDescription: ClawX requires microphone access for voice features
NSCameraUsageDescription: ClawX requires camera access for video features NSCameraUsageDescription: ClawX requires camera access for video features
dmg: dmg:
background: resources/dmg-background.png # background: resources/dmg-background.png
icon: resources/icons/icon.icns icon: resources/icons/icon.icns
iconSize: 100 iconSize: 100
contents: contents:
@@ -89,6 +74,9 @@ dmg:
# Windows Configuration # Windows Configuration
win: win:
forceCodeSigning: false
verifyUpdateCodeSignature: false
signAndEditExecutable: false
extraResources: extraResources:
- from: resources/bin/win32-${arch} - from: resources/bin/win32-${arch}
to: bin to: bin
@@ -97,11 +85,6 @@ win:
- target: nsis - target: nsis
arch: arch:
- x64 - x64
- arm64
publisherName: ClawX Inc.
# For code signing, uncomment and configure:
# certificateFile: path/to/certificate.pfx
# certificatePassword: ${env.WIN_CSC_KEY_PASSWORD}
nsis: nsis:
oneClick: false oneClick: false
@@ -141,10 +124,11 @@ linux:
OpenClaw Gateway to provide intelligent automation and assistance OpenClaw Gateway to provide intelligent automation and assistance
across multiple messaging platforms. across multiple messaging platforms.
desktop: desktop:
Name: ClawX entry:
Comment: AI Assistant powered by OpenClaw Name: ClawX
Categories: Utility;Network; Comment: AI Assistant powered by OpenClaw
Keywords: ai;assistant;automation;chat; Categories: Utility;Network;
Keywords: ai;assistant;automation;chat;
appImage: appImage:
license: LICENSE license: LICENSE

View File

@@ -13,13 +13,13 @@ import {
getOpenClawDir, getOpenClawDir,
getOpenClawEntryPath, getOpenClawEntryPath,
isOpenClawBuilt, isOpenClawBuilt,
isOpenClawSubmodulePresent, isOpenClawPresent
isOpenClawInstalled
} from '../utils/paths'; } from '../utils/paths';
import { getSetting } from '../utils/store'; import { getSetting } from '../utils/store';
import { getApiKey } from '../utils/secure-storage'; import { getApiKey } from '../utils/secure-storage';
import { getProviderEnvVar } from '../utils/openclaw-auth'; import { getProviderEnvVar } from '../utils/openclaw-auth';
import { GatewayEventType, JsonRpcNotification, isNotification, isResponse } from './protocol'; import { GatewayEventType, JsonRpcNotification, isNotification, isResponse } from './protocol';
import { logger } from '../utils/logger';
/** /**
* Gateway connection status * Gateway connection status
@@ -107,6 +107,7 @@ export class GatewayManager extends EventEmitter {
*/ */
async start(): Promise<void> { async start(): Promise<void> {
if (this.status.state === 'running') { if (this.status.state === 'running') {
logger.info('Gateway already running, skipping start');
return; return;
} }
@@ -116,27 +117,34 @@ export class GatewayManager extends EventEmitter {
try { try {
// Check if Gateway is already running // Check if Gateway is already running
logger.info('Checking for existing Gateway...');
const existing = await this.findExistingGateway(); const existing = await this.findExistingGateway();
if (existing) { if (existing) {
console.log('Found existing Gateway on port', existing.port); logger.info(`Found existing Gateway on port ${existing.port}`);
await this.connect(existing.port); await this.connect(existing.port);
this.startHealthCheck(); this.startHealthCheck();
return; return;
} }
logger.info('No existing Gateway found, starting new process...');
// Start new Gateway process // Start new Gateway process
await this.startProcess(); await this.startProcess();
// Wait for Gateway to be ready // Wait for Gateway to be ready
logger.info('Waiting for Gateway to be ready...');
await this.waitForReady(); await this.waitForReady();
// Connect WebSocket // Connect WebSocket
logger.info('Connecting WebSocket...');
await this.connect(this.status.port); await this.connect(this.status.port);
// Start health monitoring // Start health monitoring
this.startHealthCheck(); this.startHealthCheck();
logger.info('Gateway started successfully');
} catch (error) { } catch (error) {
logger.error('Gateway start failed:', error);
this.setStatus({ state: 'error', error: String(error) }); this.setStatus({ state: 'error', error: String(error) });
throw error; throw error;
} }
@@ -331,72 +339,84 @@ export class GatewayManager extends EventEmitter {
/** /**
* Start Gateway process * Start Gateway process
* Uses OpenClaw submodule - supports both production (dist) and development modes * Uses OpenClaw npm package from node_modules (dev) or resources (production)
*/ */
private async startProcess(): Promise<void> { private async startProcess(): Promise<void> {
const openclawDir = getOpenClawDir(); const openclawDir = getOpenClawDir();
const entryScript = getOpenClawEntryPath(); const entryScript = getOpenClawEntryPath();
// Verify OpenClaw submodule exists logger.info('=== Gateway startProcess begin ===');
if (!isOpenClawSubmodulePresent()) { logger.info(`app.isPackaged: ${app.isPackaged}`);
throw new Error( logger.info(`openclawDir: ${openclawDir}`);
'OpenClaw submodule not found. Please run: git submodule update --init' logger.info(`entryScript: ${entryScript}`);
); logger.info(`openclawDir exists: ${existsSync(openclawDir)}`);
} logger.info(`entryScript exists: ${existsSync(entryScript)}`);
logger.info(`process.execPath: ${process.execPath}`);
logger.info(`process.resourcesPath: ${process.resourcesPath}`);
logger.info(`process.cwd(): ${process.cwd()}`);
logger.info(`process.platform: ${process.platform}, process.arch: ${process.arch}`);
// Verify dependencies are installed // Verify OpenClaw package exists
if (!isOpenClawInstalled()) { if (!isOpenClawPresent()) {
throw new Error( const errMsg = `OpenClaw package not found at: ${openclawDir}`;
'OpenClaw dependencies not installed. Please run: cd openclaw && pnpm install' logger.error(errMsg);
); throw new Error(errMsg);
} }
// Get or generate gateway token // Get or generate gateway token
const gatewayToken = await getSetting('gatewayToken'); const gatewayToken = await getSetting('gatewayToken');
console.log('Using gateway token:', gatewayToken.substring(0, 10) + '...'); logger.info(`Using gateway token: ${gatewayToken.substring(0, 10)}...`);
let command: string; let command: string;
let args: string[]; let args: string[];
// Check if OpenClaw is built (production mode) or use pnpm dev mode // Determine the Node.js executable
if (isOpenClawBuilt() && existsSync(entryScript)) { // In packaged Electron app, use process.execPath with ELECTRON_RUN_AS_NODE=1
// Production mode: use openclaw.mjs directly // which makes the Electron binary behave as plain Node.js.
console.log('Starting Gateway in production mode (using dist)'); // In development, use system 'node'.
const gatewayArgs = ['gateway', '--port', String(this.status.port), '--token', gatewayToken, '--dev', '--allow-unconfigured'];
if (app.isPackaged) {
// Production: always use Electron binary as Node.js via ELECTRON_RUN_AS_NODE
if (existsSync(entryScript)) {
command = process.execPath;
args = [entryScript, ...gatewayArgs];
logger.info('Starting Gateway in PACKAGED mode (ELECTRON_RUN_AS_NODE)');
} else {
const errMsg = `OpenClaw entry script not found at: ${entryScript}`;
logger.error(errMsg);
throw new Error(errMsg);
}
} else if (isOpenClawBuilt() && existsSync(entryScript)) {
// Development with built package: use system node
command = 'node'; command = 'node';
args = [entryScript, 'gateway', '--port', String(this.status.port), '--token', gatewayToken, '--dev', '--allow-unconfigured']; args = [entryScript, ...gatewayArgs];
logger.info('Starting Gateway in DEV mode (node + built dist)');
} else { } else {
// Development mode: use pnpm gateway:dev which handles tsx compilation // Development without build: use pnpm dev
console.log('Starting Gateway in development mode (using pnpm)');
command = 'pnpm'; command = 'pnpm';
args = ['run', 'dev', 'gateway', '--port', String(this.status.port), '--token', gatewayToken, '--dev', '--allow-unconfigured']; args = ['run', 'dev', ...gatewayArgs];
logger.info('Starting Gateway in DEV mode (pnpm dev)');
} }
console.log(`Spawning Gateway: ${command} ${args.join(' ')}`); logger.info(`Spawning: ${command} ${args.join(' ')}`);
console.log(`Working directory: ${openclawDir}`); logger.info(`Working directory: ${openclawDir}`);
// Resolve bundled bin path for uv // Resolve bundled bin path for uv
let binPath = '';
const platform = process.platform; const platform = process.platform;
const arch = process.arch; const arch = process.arch;
// Map arch if necessary (e.g. x64 is standard, but ensure consistency with script)
const target = `${platform}-${arch}`; const target = `${platform}-${arch}`;
if (app.isPackaged) { const binPath = app.isPackaged
// In production, we flattened the structure to 'bin/' using electron-builder macros ? path.join(process.resourcesPath, 'bin')
binPath = path.join(process.resourcesPath, 'bin'); : path.join(process.cwd(), 'resources', 'bin', target);
} else {
// In dev, resources are at project root/resources/bin/<platform>-<arch>
binPath = path.join(process.cwd(), 'resources', 'bin', target);
}
// Only inject if the bundled directory exists const binPathExists = existsSync(binPath);
const finalPath = existsSync(binPath) const finalPath = binPathExists
? `${binPath}${path.delimiter}${process.env.PATH || ''}` ? `${binPath}${path.delimiter}${process.env.PATH || ''}`
: process.env.PATH || ''; : process.env.PATH || '';
if (existsSync(binPath)) { logger.info(`Bundled bin path: ${binPath}, exists: ${binPathExists}`);
console.log('Injecting bundled bin path:', binPath);
}
// Load provider API keys from secure storage to pass as environment variables // Load provider API keys from secure storage to pass as environment variables
const providerEnv: Record<string, string> = {}; const providerEnv: Record<string, string> = {};
@@ -408,72 +428,73 @@ export class GatewayManager extends EventEmitter {
const envVar = getProviderEnvVar(providerType); const envVar = getProviderEnvVar(providerType);
if (envVar) { if (envVar) {
providerEnv[envVar] = key; providerEnv[envVar] = key;
console.log(`Loaded API key for ${providerType} -> ${envVar}`); logger.info(`Loaded API key for ${providerType} -> ${envVar}`);
} }
} }
} catch (err) { } catch (err) {
console.warn(`Failed to load API key for ${providerType}:`, err); logger.warn(`Failed to load API key for ${providerType}:`, err);
} }
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const spawnEnv: Record<string, string | undefined> = {
...process.env,
PATH: finalPath,
...providerEnv,
OPENCLAW_GATEWAY_TOKEN: gatewayToken,
OPENCLAW_SKIP_CHANNELS: '',
CLAWDBOT_SKIP_CHANNELS: '',
};
// Critical: In packaged mode, make Electron binary act as Node.js
if (app.isPackaged) {
spawnEnv['ELECTRON_RUN_AS_NODE'] = '1';
}
this.process = spawn(command, args, { this.process = spawn(command, args, {
cwd: openclawDir, cwd: openclawDir,
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
detached: false, detached: false,
shell: process.platform === 'win32', // Use shell on Windows for pnpm shell: !app.isPackaged && process.platform === 'win32', // shell only in dev on Windows
env: { env: spawnEnv,
...process.env,
PATH: finalPath, // Inject bundled bin path if it exists
// Provider API keys
...providerEnv,
// Also set token via environment variable as fallback
OPENCLAW_GATEWAY_TOKEN: gatewayToken,
// Ensure OPENCLAW_SKIP_CHANNELS is NOT set so channels auto-start
// and config hot-reload can restart channels when config changes
OPENCLAW_SKIP_CHANNELS: '',
CLAWDBOT_SKIP_CHANNELS: '',
},
}); });
this.process.on('error', (error) => { this.process.on('error', (error) => {
console.error('Gateway process error:', error); logger.error('Gateway process spawn error:', error);
reject(error); reject(error);
}); });
this.process.on('exit', (code) => { this.process.on('exit', (code) => {
console.log('Gateway process exited with code:', code); logger.info(`Gateway process exited with code: ${code}`);
this.emit('exit', code); this.emit('exit', code);
if (this.status.state === 'running') { if (this.status.state === 'running') {
this.setStatus({ state: 'stopped' }); this.setStatus({ state: 'stopped' });
// Attempt to reconnect
this.scheduleReconnect(); this.scheduleReconnect();
} }
}); });
// Log stdout // Log stdout
this.process.stdout?.on('data', (data) => { this.process.stdout?.on('data', (data) => {
console.log('Gateway:', data.toString()); const msg = data.toString().trimEnd();
logger.debug(`[Gateway stdout] ${msg}`);
}); });
// Log stderr (filter out noisy control-ui token_mismatch messages) // Log stderr
this.process.stderr?.on('data', (data) => { this.process.stderr?.on('data', (data) => {
const msg = data.toString(); const msg = data.toString().trimEnd();
// Suppress the constant Control UI token_mismatch noise // Suppress noisy control-ui token_mismatch messages
// These come from the browser-based Control UI auto-polling with no token if (msg.includes('openclaw-control-ui') && msg.includes('token_mismatch')) return;
if (msg.includes('openclaw-control-ui') && msg.includes('token_mismatch')) { if (msg.includes('closed before connect') && msg.includes('token mismatch')) return;
return; logger.warn(`[Gateway stderr] ${msg}`);
}
if (msg.includes('closed before connect') && msg.includes('token mismatch')) {
return;
}
console.error('Gateway error:', msg);
}); });
// Store PID // Store PID
if (this.process.pid) { if (this.process.pid) {
logger.info(`Gateway process PID: ${this.process.pid}`);
this.setStatus({ pid: this.process.pid }); this.setStatus({ pid: this.process.pid });
} else {
logger.warn('Gateway process spawned but PID is undefined');
} }
resolve(); resolve();
@@ -486,7 +507,6 @@ export class GatewayManager extends EventEmitter {
private async waitForReady(retries = 30, interval = 1000): Promise<void> { private async waitForReady(retries = 30, interval = 1000): Promise<void> {
for (let i = 0; i < retries; i++) { for (let i = 0; i < retries; i++) {
try { try {
// Try a quick WebSocket connection to see if the gateway is listening
const ready = await new Promise<boolean>((resolve) => { const ready = await new Promise<boolean>((resolve) => {
const testWs = new WebSocket(`ws://localhost:${this.status.port}/ws`); const testWs = new WebSocket(`ws://localhost:${this.status.port}/ws`);
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
@@ -507,16 +527,22 @@ export class GatewayManager extends EventEmitter {
}); });
if (ready) { if (ready) {
logger.info(`Gateway ready after ${i + 1} attempt(s)`);
return; return;
} }
} catch { } catch {
// Gateway not ready yet // Gateway not ready yet
} }
if (i > 0 && i % 5 === 0) {
logger.info(`Still waiting for Gateway... (attempt ${i + 1}/${retries})`);
}
await new Promise((resolve) => setTimeout(resolve, interval)); await new Promise((resolve) => setTimeout(resolve, interval));
} }
throw new Error('Gateway failed to start'); logger.error(`Gateway failed to become ready after ${retries} attempts on port ${this.status.port}`);
throw new Error(`Gateway failed to start after ${retries} retries (port ${this.status.port})`);
} }
/** /**

View File

@@ -10,6 +10,7 @@ import { createTray } from './tray';
import { createMenu } from './menu'; import { createMenu } from './menu';
import { appUpdater, registerUpdateHandlers } from './updater'; import { appUpdater, registerUpdateHandlers } from './updater';
import { logger } from '../utils/logger';
// Disable GPU acceleration for better compatibility // Disable GPU acceleration for better compatibility
app.disableHardwareAcceleration(); app.disableHardwareAcceleration();
@@ -69,6 +70,17 @@ function createWindow(): BrowserWindow {
* Initialize the application * Initialize the application
*/ */
async function initialize(): Promise<void> { async function initialize(): Promise<void> {
// Initialize logger first
logger.init();
logger.info('=== ClawX Application Starting ===');
logger.info(`Platform: ${process.platform}, Arch: ${process.arch}`);
logger.info(`Electron: ${process.versions.electron}, Node: ${process.versions.node}`);
logger.info(`App path: ${app.getAppPath()}`);
logger.info(`User data: ${app.getPath('userData')}`);
logger.info(`Is packaged: ${app.isPackaged}`);
logger.info(`Resources path: ${process.resourcesPath}`);
logger.info(`Exec path: ${process.execPath}`);
// Set application menu // Set application menu
createMenu(); createMenu();
@@ -129,10 +141,11 @@ async function initialize(): Promise<void> {
// Start Gateway automatically (optional based on settings) // Start Gateway automatically (optional based on settings)
try { try {
logger.info('Auto-starting Gateway...');
await gatewayManager.start(); await gatewayManager.start();
console.log('Gateway started successfully'); logger.info('Gateway auto-start succeeded');
} catch (error) { } catch (error) {
console.error('Failed to start Gateway:', error); logger.error('Gateway auto-start failed:', error);
// Notify renderer about the error // Notify renderer about the error
mainWindow?.webContents.send('gateway:error', String(error)); mainWindow?.webContents.send('gateway:error', String(error));
} }

View File

@@ -20,9 +20,10 @@ import {
isEncryptionAvailable, isEncryptionAvailable,
type ProviderConfig, type ProviderConfig,
} from '../utils/secure-storage'; } from '../utils/secure-storage';
import { getOpenClawStatus } from '../utils/paths'; import { getOpenClawStatus, getOpenClawDir } from '../utils/paths';
import { getSetting } from '../utils/store'; import { getSetting } from '../utils/store';
import { saveProviderKeyToOpenClaw, setOpenClawDefaultModel } from '../utils/openclaw-auth'; import { saveProviderKeyToOpenClaw, setOpenClawDefaultModel } from '../utils/openclaw-auth';
import { logger } from '../utils/logger';
import { import {
saveChannelConfig, saveChannelConfig,
getChannelConfig, getChannelConfig,
@@ -68,6 +69,9 @@ export function registerIpcHandlers(
// UV handlers // UV handlers
registerUvHandlers(); registerUvHandlers();
// Log handlers (for UI to read gateway/app logs)
registerLogHandlers();
// Skill config handlers (direct file access, no Gateway RPC) // Skill config handlers (direct file access, no Gateway RPC)
registerSkillConfigHandlers(); registerSkillConfigHandlers();
@@ -306,6 +310,37 @@ function registerUvHandlers(): void {
}); });
} }
/**
* Log-related IPC handlers
* Allows the renderer to read application logs for diagnostics
*/
function registerLogHandlers(): void {
// Get recent logs from memory ring buffer
ipcMain.handle('log:getRecent', async (_, count?: number) => {
return logger.getRecentLogs(count);
});
// Read log file content (last N lines)
ipcMain.handle('log:readFile', async (_, tailLines?: number) => {
return logger.readLogFile(tailLines);
});
// Get log file path (so user can open in file explorer)
ipcMain.handle('log:getFilePath', async () => {
return logger.getLogFilePath();
});
// Get log directory path
ipcMain.handle('log:getDir', async () => {
return logger.getLogDir();
});
// List all log files
ipcMain.handle('log:listFiles', async () => {
return logger.listLogFiles();
});
}
/** /**
* Gateway-related IPC handlers * Gateway-related IPC handlers
*/ */
@@ -433,19 +468,26 @@ function registerGatewayHandlers(
/** /**
* OpenClaw-related IPC handlers * OpenClaw-related IPC handlers
* For checking submodule status and channel configuration * For checking package status and channel configuration
*/ */
function registerOpenClawHandlers(): void { function registerOpenClawHandlers(): void {
// Get OpenClaw submodule status // Get OpenClaw package status
ipcMain.handle('openclaw:status', () => { ipcMain.handle('openclaw:status', () => {
return getOpenClawStatus(); const status = getOpenClawStatus();
logger.info('openclaw:status IPC called', status);
return status;
}); });
// Check if OpenClaw is ready (submodule present and dependencies installed) // Check if OpenClaw is ready (package present)
ipcMain.handle('openclaw:isReady', () => { ipcMain.handle('openclaw:isReady', () => {
const status = getOpenClawStatus(); const status = getOpenClawStatus();
return status.submoduleExists && status.isInstalled; return status.packageExists;
});
// Get the resolved OpenClaw directory path (for diagnostics)
ipcMain.handle('openclaw:getDir', () => {
return getOpenClawDir();
}); });
// ==================== Channel Configuration Handlers ==================== // ==================== Channel Configuration Handlers ====================

View File

@@ -100,6 +100,14 @@ const electronAPI = {
'skill:updateConfig', 'skill:updateConfig',
'skill:getConfig', 'skill:getConfig',
'skill:getAllConfigs', 'skill:getAllConfigs',
// Logs
'log:getRecent',
'log:readFile',
'log:getFilePath',
'log:getDir',
'log:listFiles',
// OpenClaw extras
'openclaw:getDir',
]; ];
if (validChannels.includes(channel)) { if (validChannels.includes(channel)) {

View File

@@ -1,10 +1,10 @@
/** /**
* Logger Utility * Logger Utility
* Centralized logging with levels and file output * Centralized logging with levels, file output, and log retrieval for UI
*/ */
import { app } from 'electron'; import { app } from 'electron';
import { join } from 'path'; import { join } from 'path';
import { existsSync, mkdirSync, appendFileSync } from 'fs'; import { existsSync, mkdirSync, appendFileSync, readFileSync, readdirSync, statSync } from 'fs';
/** /**
* Log levels * Log levels
@@ -19,26 +19,37 @@ export enum LogLevel {
/** /**
* Current log level (can be changed at runtime) * Current log level (can be changed at runtime)
*/ */
let currentLevel = LogLevel.INFO; let currentLevel = LogLevel.DEBUG; // Default to DEBUG for better diagnostics
/** /**
* Log file path * Log file path
*/ */
let logFilePath: string | null = null; let logFilePath: string | null = null;
let logDir: string | null = null;
/** /**
* Initialize logger * In-memory ring buffer for recent logs (useful for UI display)
*/
const RING_BUFFER_SIZE = 500;
const recentLogs: string[] = [];
/**
* Initialize logger — safe to call before app.isReady()
*/ */
export function initLogger(): void { export function initLogger(): void {
try { try {
const logDir = join(app.getPath('userData'), 'logs'); logDir = join(app.getPath('userData'), 'logs');
if (!existsSync(logDir)) { if (!existsSync(logDir)) {
mkdirSync(logDir, { recursive: true }); mkdirSync(logDir, { recursive: true });
} }
const timestamp = new Date().toISOString().split('T')[0]; const timestamp = new Date().toISOString().split('T')[0];
logFilePath = join(logDir, `clawx-${timestamp}.log`); logFilePath = join(logDir, `clawx-${timestamp}.log`);
// Write a separator for new session
const sessionHeader = `\n${'='.repeat(80)}\n[${new Date().toISOString()}] === ClawX Session Start (v${app.getVersion()}) ===\n${'='.repeat(80)}\n`;
appendFileSync(logFilePath, sessionHeader);
} catch (error) { } catch (error) {
console.error('Failed to initialize logger:', error); console.error('Failed to initialize logger:', error);
} }
@@ -51,22 +62,53 @@ export function setLogLevel(level: LogLevel): void {
currentLevel = level; currentLevel = level;
} }
/**
* Get log file directory path
*/
export function getLogDir(): string | null {
return logDir;
}
/**
* Get current log file path
*/
export function getLogFilePath(): string | null {
return logFilePath;
}
/** /**
* Format log message * Format log message
*/ */
function formatMessage(level: string, message: string, ...args: unknown[]): string { function formatMessage(level: string, message: string, ...args: unknown[]): string {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const formattedArgs = args.length > 0 ? ' ' + args.map(arg => const formattedArgs = args.length > 0 ? ' ' + args.map(arg => {
typeof arg === 'object' ? JSON.stringify(arg) : String(arg) if (arg instanceof Error) {
).join(' ') : ''; return `${arg.message}\n${arg.stack || ''}`;
}
return `[${timestamp}] [${level}] ${message}${formattedArgs}`; if (typeof arg === 'object') {
try {
return JSON.stringify(arg, null, 2);
} catch {
return String(arg);
}
}
return String(arg);
}).join(' ') : '';
return `[${timestamp}] [${level.padEnd(5)}] ${message}${formattedArgs}`;
} }
/** /**
* Write to log file * Write to log file and ring buffer
*/ */
function writeToFile(formatted: string): void { function writeLog(formatted: string): void {
// Ring buffer
recentLogs.push(formatted);
if (recentLogs.length > RING_BUFFER_SIZE) {
recentLogs.shift();
}
// File
if (logFilePath) { if (logFilePath) {
try { try {
appendFileSync(logFilePath, formatted + '\n'); appendFileSync(logFilePath, formatted + '\n');
@@ -83,7 +125,7 @@ export function debug(message: string, ...args: unknown[]): void {
if (currentLevel <= LogLevel.DEBUG) { if (currentLevel <= LogLevel.DEBUG) {
const formatted = formatMessage('DEBUG', message, ...args); const formatted = formatMessage('DEBUG', message, ...args);
console.debug(formatted); console.debug(formatted);
writeToFile(formatted); writeLog(formatted);
} }
} }
@@ -94,7 +136,7 @@ export function info(message: string, ...args: unknown[]): void {
if (currentLevel <= LogLevel.INFO) { if (currentLevel <= LogLevel.INFO) {
const formatted = formatMessage('INFO', message, ...args); const formatted = formatMessage('INFO', message, ...args);
console.info(formatted); console.info(formatted);
writeToFile(formatted); writeLog(formatted);
} }
} }
@@ -105,7 +147,7 @@ export function warn(message: string, ...args: unknown[]): void {
if (currentLevel <= LogLevel.WARN) { if (currentLevel <= LogLevel.WARN) {
const formatted = formatMessage('WARN', message, ...args); const formatted = formatMessage('WARN', message, ...args);
console.warn(formatted); console.warn(formatted);
writeToFile(formatted); writeLog(formatted);
} }
} }
@@ -116,7 +158,66 @@ export function error(message: string, ...args: unknown[]): void {
if (currentLevel <= LogLevel.ERROR) { if (currentLevel <= LogLevel.ERROR) {
const formatted = formatMessage('ERROR', message, ...args); const formatted = formatMessage('ERROR', message, ...args);
console.error(formatted); console.error(formatted);
writeToFile(formatted); writeLog(formatted);
}
}
/**
* Get recent logs from ring buffer (for UI display)
* @param count Number of recent log lines to return (default: all)
* @param minLevel Minimum log level to include (default: DEBUG)
*/
export function getRecentLogs(count?: number, minLevel?: LogLevel): string[] {
const filtered = minLevel != null
? recentLogs.filter(line => {
if (minLevel <= LogLevel.DEBUG) return true;
if (minLevel === LogLevel.INFO) return !line.includes('] [DEBUG');
if (minLevel === LogLevel.WARN) return line.includes('] [WARN') || line.includes('] [ERROR');
return line.includes('] [ERROR');
})
: recentLogs;
return count ? filtered.slice(-count) : [...filtered];
}
/**
* Read the current day's log file content (last N lines)
*/
export function readLogFile(tailLines = 200): string {
if (!logFilePath || !existsSync(logFilePath)) {
return '(No log file found)';
}
try {
const content = readFileSync(logFilePath, 'utf-8');
const lines = content.split('\n');
if (lines.length <= tailLines) return content;
return lines.slice(-tailLines).join('\n');
} catch (err) {
return `(Failed to read log file: ${err})`;
}
}
/**
* List available log files
*/
export function listLogFiles(): Array<{ name: string; path: string; size: number; modified: string }> {
if (!logDir || !existsSync(logDir)) return [];
try {
return readdirSync(logDir)
.filter(f => f.endsWith('.log'))
.map(f => {
const fullPath = join(logDir!, f);
const stat = statSync(fullPath);
return {
name: f,
path: fullPath,
size: stat.size,
modified: stat.mtime.toISOString(),
};
})
.sort((a, b) => b.modified.localeCompare(a.modified));
} catch {
return [];
} }
} }
@@ -130,4 +231,9 @@ export const logger = {
error, error,
setLevel: setLogLevel, setLevel: setLogLevel,
init: initLogger, init: initLogger,
getLogDir,
getLogFilePath,
getRecentLogs,
readLogFile,
listLogFiles,
}; };

View File

@@ -5,7 +5,8 @@
import { app } from 'electron'; import { app } from 'electron';
import { join } from 'path'; import { join } from 'path';
import { homedir } from 'os'; import { homedir } from 'os';
import { existsSync, mkdirSync } from 'fs'; import { existsSync, mkdirSync, readFileSync } from 'fs';
import { logger } from './logger';
/** /**
* Expand ~ to home directory * Expand ~ to home directory
@@ -72,13 +73,16 @@ export function getPreloadPath(): string {
} }
/** /**
* Get OpenClaw submodule directory * Get OpenClaw package directory
* - Production (packaged): from resources/openclaw (copied by electron-builder extraResources)
* - Development: from node_modules/openclaw
*/ */
export function getOpenClawDir(): string { export function getOpenClawDir(): string {
if (app.isPackaged) { if (app.isPackaged) {
return join(process.resourcesPath, 'openclaw'); return join(process.resourcesPath, 'openclaw');
} }
return join(__dirname, '../../openclaw'); // Development: use node_modules/openclaw
return join(__dirname, '../../node_modules/openclaw');
} }
/** /**
@@ -89,44 +93,65 @@ export function getOpenClawEntryPath(): string {
} }
/** /**
* Check if OpenClaw submodule exists * Check if OpenClaw package exists
*/ */
export function isOpenClawSubmodulePresent(): boolean { export function isOpenClawPresent(): boolean {
return existsSync(getOpenClawDir()) && existsSync(join(getOpenClawDir(), 'package.json')); const dir = getOpenClawDir();
const pkgJsonPath = join(dir, 'package.json');
const exists = existsSync(dir) && existsSync(pkgJsonPath);
logger.debug(`isOpenClawPresent: dir=${dir}, exists=${exists}`);
return exists;
} }
/** /**
* Check if OpenClaw is built (has dist folder with entry.js) * Check if OpenClaw is built (has dist folder)
* For the npm package, this should always be true since npm publishes the built dist.
*/ */
export function isOpenClawBuilt(): boolean { export function isOpenClawBuilt(): boolean {
return existsSync(join(getOpenClawDir(), 'dist', 'entry.js')); const dir = getOpenClawDir();
} // Check for dist/entry.js or just the dist directory with JS files
const entryPath = join(dir, 'dist', 'entry.js');
/** const distDir = join(dir, 'dist');
* Check if OpenClaw has node_modules installed const hasEntry = existsSync(entryPath);
*/ const hasDist = existsSync(distDir);
export function isOpenClawInstalled(): boolean { logger.debug(`isOpenClawBuilt: distDir=${distDir}, hasDist=${hasDist}, hasEntry=${hasEntry}`);
return existsSync(join(getOpenClawDir(), 'node_modules')); return hasDist;
} }
/** /**
* Get OpenClaw status for environment check * Get OpenClaw status for environment check
*/ */
export interface OpenClawStatus { export interface OpenClawStatus {
submoduleExists: boolean; packageExists: boolean;
isInstalled: boolean;
isBuilt: boolean; isBuilt: boolean;
entryPath: string; entryPath: string;
dir: string; dir: string;
version?: string;
} }
export function getOpenClawStatus(): OpenClawStatus { export function getOpenClawStatus(): OpenClawStatus {
const dir = getOpenClawDir(); const dir = getOpenClawDir();
return { let version: string | undefined;
submoduleExists: isOpenClawSubmodulePresent(),
isInstalled: isOpenClawInstalled(), // Try to read version from package.json
try {
const pkgPath = join(dir, 'package.json');
if (existsSync(pkgPath)) {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
version = pkg.version;
}
} catch {
// Ignore version read errors
}
const status: OpenClawStatus = {
packageExists: isOpenClawPresent(),
isBuilt: isOpenClawBuilt(), isBuilt: isOpenClawBuilt(),
entryPath: getOpenClawEntryPath(), entryPath: getOpenClawEntryPath(),
dir, dir,
version,
}; };
logger.info('OpenClaw status:', status);
return status;
} }

View File

@@ -7,7 +7,7 @@ import globals from 'globals';
export default [ export default [
{ {
ignores: ['dist/**', 'dist-electron/**', 'openclaw/**'], ignores: ['dist/**', 'dist-electron/**', 'openclaw/**', 'release/**', 'build/**'],
}, },
{ {
files: ['**/*.{ts,tsx}'], files: ['**/*.{ts,tsx}'],

View File

@@ -3,8 +3,12 @@
"version": "0.1.0", "version": "0.1.0",
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"@whiskeysockets/baileys",
"electron", "electron",
"esbuild" "esbuild",
"node-llama-cpp",
"protobufjs",
"sharp"
] ]
}, },
"description": "ClawX - Graphical AI Assistant based on OpenClaw", "description": "ClawX - Graphical AI Assistant based on OpenClaw",
@@ -13,50 +17,27 @@
"license": "MIT", "license": "MIT",
"private": true, "private": true,
"scripts": { "scripts": {
"init": "pnpm install && pnpm run uv:download",
"dev": "vite", "dev": "vite",
"dev:electron": "electron .", "bundle:openclaw": "zx scripts/bundle-openclaw.mjs",
"build": "pnpm run uv:download && pnpm run build:vite && pnpm run package", "build": "vite build && zx scripts/bundle-openclaw.mjs && electron-builder",
"build:vite": "vite build", "build:vite": "vite build && zx scripts/bundle-openclaw.mjs",
"build:electron": "tsc -p tsconfig.node.json", "build:arm": "vite build && zx scripts/bundle-openclaw.mjs && electron-builder --mac --arm64",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint . --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --fix",
"lint:fix": "eslint . --fix",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"uv:download": "node scripts/download-bundled-uv.mjs", "clean": "node -e \"['dist','dist-electron','release'].forEach(d=>require('fs').rmSync(d,{recursive:true,force:true}))\"",
"uv:download:all": "node scripts/download-bundled-uv.mjs --all", "uv:download": "zx scripts/download-bundled-uv.mjs",
"icons": "bash scripts/generate-icons.sh", "icons": "zx scripts/generate-icons.mjs",
"clean": "rm -rf dist dist-electron release",
"package": "electron-builder", "package": "electron-builder",
"package:mac": "electron-builder --mac", "package:mac": "vite build && zx scripts/bundle-openclaw.mjs && electron-builder --mac",
"package:mac:universal": "electron-builder --mac --universal", "package:win": "vite build && zx scripts/bundle-openclaw.mjs && electron-builder --win",
"package:win": "electron-builder --win", "package:linux": "vite build && zx scripts/bundle-openclaw.mjs && electron-builder --linux",
"package:linux": "electron-builder --linux", "release": "pnpm run uv:download && vite build && electron-builder --publish always"
"package:all": "electron-builder -mwl",
"publish": "electron-builder --publish always",
"publish:mac": "electron-builder --mac --publish always",
"publish:win": "electron-builder --win --publish always",
"publish:linux": "electron-builder --linux --publish always",
"release": "pnpm run build:vite && pnpm run publish",
"postinstall": "git submodule update --init",
"openclaw:init": "git submodule update --init && cd openclaw && pnpm install",
"openclaw:install": "cd openclaw && pnpm install",
"openclaw:build": "cd openclaw && pnpm build",
"openclaw:update": "git submodule update --remote openclaw && cd openclaw && pnpm install"
}, },
"dependencies": { "dependencies": {
"electron-store": "^10.0.0",
"electron-updater": "^6.3.9",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"ws": "^8.18.0"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@playwright/test": "^1.49.1",
"@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1", "@radix-ui/react-label": "^2.1.1",
@@ -69,41 +50,55 @@
"@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4", "@radix-ui/react-toast": "^1.2.4",
"@radix-ui/react-tooltip": "^1.1.6", "@radix-ui/react-tooltip": "^1.1.6",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.1.0",
"@types/node": "^22.10.5",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@types/ws": "^8.5.13",
"@typescript-eslint/eslint-plugin": "^8.19.0",
"@typescript-eslint/parser": "^8.19.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clawhub": "^0.5.0", "clawhub": "^0.5.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"electron": "^33.3.0", "electron-store": "^11.0.2",
"electron-builder": "^25.1.8", "electron-updater": "^6.8.2",
"eslint": "^9.17.0", "framer-motion": "^12.33.0",
"eslint-plugin-react-hooks": "^5.1.0", "lucide-react": "^0.563.0",
"eslint-plugin-react-refresh": "^0.4.16", "react": "^19.2.4",
"framer-motion": "^11.15.0", "react-dom": "^19.2.4",
"globals": "^17.3.0", "react-markdown": "^10.1.0",
"jsdom": "^25.0.1", "react-router-dom": "^7.13.0",
"lucide-react": "^0.469.0", "remark-gfm": "^4.0.1",
"postcss": "^8.4.49", "sonner": "^2.0.7",
"react": "^19.0.0", "tailwind-merge": "^3.4.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"sonner": "^1.7.1",
"tailwind-merge": "^2.6.0",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"ws": "^8.19.0",
"zustand": "^5.0.11",
"openclaw": "2026.2.6-3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.49.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.1.0",
"@types/node": "^25.2.1",
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.3",
"@types/ws": "^8.5.13",
"@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0",
"@vitejs/plugin-react": "^5.1.3",
"autoprefixer": "^10.4.20",
"electron": "^40.2.1",
"electron-builder": "^26.7.0",
"eslint": "^10.0.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.0",
"globals": "^17.3.0",
"jsdom": "^28.0.0",
"png2icons": "^2.0.1",
"postcss": "^8.4.49",
"sharp": "^0.34.5",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.6", "vite": "^7.3.1",
"vite-plugin-electron": "^0.29.0", "vite-plugin-electron": "^0.29.0",
"vite-plugin-electron-renderer": "^0.14.6", "vite-plugin-electron-renderer": "^0.14.6",
"vitest": "^2.1.8", "vitest": "^4.0.18",
"zustand": "^5.0.2" "zx": "^8.8.5"
} },
"packageManager": "pnpm@10.29.1+sha512.48dae233635a645768a3028d19545cacc1688639eeb1f3734e42d6d6b971afbf22aa1ac9af52a173d9c3a20c15857cfa400f19994d79a2f626fcc73fccda9bbc"
} }

7393
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

BIN
resources/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

BIN
resources/icons/16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

BIN
resources/icons/256x256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
resources/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
resources/icons/48x48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
resources/icons/512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
resources/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

BIN
resources/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

BIN
resources/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

47
scripts/after-pack.cjs Normal file
View File

@@ -0,0 +1,47 @@
/**
* after-pack.cjs
*
* electron-builder afterPack hook.
*
* Problem: electron-builder respects .gitignore when copying extraResources.
* Since .gitignore contains "node_modules/", the openclaw bundle's
* node_modules directory is silently skipped during the extraResources copy.
*
* Solution: This hook runs AFTER electron-builder finishes packing. It manually
* copies build/openclaw/node_modules/ into the output resources directory,
* bypassing electron-builder's glob filtering entirely.
*/
const { cpSync, existsSync, readdirSync } = require('fs');
const { join } = require('path');
exports.default = async function afterPack(context) {
const appOutDir = context.appOutDir;
const platform = context.electronPlatformName; // 'win32' | 'darwin' | 'linux'
const src = join(__dirname, '..', 'build', 'openclaw', 'node_modules');
// On macOS, resources live inside the .app bundle
let resourcesDir;
if (platform === 'darwin') {
const appName = context.packager.appInfo.productFilename;
resourcesDir = join(appOutDir, `${appName}.app`, 'Contents', 'Resources');
} else {
resourcesDir = join(appOutDir, 'resources');
}
const dest = join(resourcesDir, 'openclaw', 'node_modules');
if (!existsSync(src)) {
console.warn('[after-pack] ⚠️ build/openclaw/node_modules not found. Run "pnpm run bundle:openclaw" first.');
return;
}
const depCount = readdirSync(src, { withFileTypes: true })
.filter(d => d.isDirectory() && d.name !== '.bin')
.length;
console.log(`[after-pack] Copying ${depCount} openclaw dependencies to ${dest} ...`);
cpSync(src, dest, { recursive: true });
console.log('[after-pack] ✅ openclaw node_modules copied successfully.');
};

208
scripts/bundle-openclaw.mjs Normal file
View File

@@ -0,0 +1,208 @@
#!/usr/bin/env zx
/**
* bundle-openclaw.mjs
*
* Bundles the openclaw npm package with ALL its dependencies (including
* transitive ones) into a self-contained directory (build/openclaw/) for
* electron-builder to pick up.
*
* pnpm uses a content-addressable virtual store with symlinks. A naive copy
* of node_modules/openclaw/ will miss runtime dependencies entirely. Even
* copying only direct siblings misses transitive deps (e.g. @clack/prompts
* depends on @clack/core which lives in a separate virtual store entry).
*
* This script performs a recursive BFS through pnpm's virtual store to
* collect every transitive dependency into a flat node_modules structure.
*/
import 'zx/globals';
const ROOT = path.resolve(__dirname, '..');
const OUTPUT = path.join(ROOT, 'build', 'openclaw');
const NODE_MODULES = path.join(ROOT, 'node_modules');
echo`📦 Bundling openclaw for electron-builder...`;
// 1. Resolve the real path of node_modules/openclaw (follows pnpm symlink)
const openclawLink = path.join(NODE_MODULES, 'openclaw');
if (!fs.existsSync(openclawLink)) {
echo`❌ node_modules/openclaw not found. Run pnpm install first.`;
process.exit(1);
}
const openclawReal = fs.realpathSync(openclawLink);
echo` openclaw resolved: ${openclawReal}`;
// 2. Clean and create output directory
if (fs.existsSync(OUTPUT)) {
fs.rmSync(OUTPUT, { recursive: true });
}
fs.mkdirSync(OUTPUT, { recursive: true });
// 3. Copy openclaw package itself to OUTPUT root
echo` Copying openclaw package...`;
fs.cpSync(openclawReal, OUTPUT, { recursive: true, dereference: true });
// 4. Recursively collect ALL transitive dependencies via pnpm virtual store BFS
//
// pnpm structure example:
// .pnpm/openclaw@ver/node_modules/
// openclaw/ <- real files
// chalk/ <- symlink -> .pnpm/chalk@ver/node_modules/chalk
// @clack/prompts/ <- symlink -> .pnpm/@clack+prompts@ver/node_modules/@clack/prompts
//
// .pnpm/@clack+prompts@ver/node_modules/
// @clack/prompts/ <- real files
// @clack/core/ <- symlink (transitive dep, NOT in openclaw's siblings!)
//
// We BFS from openclaw's virtual store node_modules, following each symlink
// to discover the target's own virtual store node_modules and its deps.
const collected = new Map(); // realPath -> packageName (for deduplication)
const queue = []; // BFS queue of virtual-store node_modules dirs to visit
/**
* Given a real path of a package, find the containing virtual-store node_modules.
* e.g. .pnpm/chalk@5.4.1/node_modules/chalk -> .pnpm/chalk@5.4.1/node_modules
* e.g. .pnpm/@clack+core@0.4.1/node_modules/@clack/core -> .pnpm/@clack+core@0.4.1/node_modules
*/
function getVirtualStoreNodeModules(realPkgPath) {
let dir = realPkgPath;
while (dir !== path.dirname(dir)) {
if (path.basename(dir) === 'node_modules') {
return dir;
}
dir = path.dirname(dir);
}
return null;
}
/**
* List all package entries in a virtual-store node_modules directory.
* Handles both regular packages (chalk) and scoped packages (@clack/prompts).
* Returns array of { name, fullPath }.
*/
function listPackages(nodeModulesDir) {
const result = [];
if (!fs.existsSync(nodeModulesDir)) return result;
for (const entry of fs.readdirSync(nodeModulesDir)) {
if (entry === '.bin') continue;
const entryPath = path.join(nodeModulesDir, entry);
const stat = fs.lstatSync(entryPath);
if (entry.startsWith('@')) {
// Scoped package: read sub-entries
if (stat.isDirectory() || stat.isSymbolicLink()) {
const resolvedScope = stat.isSymbolicLink() ? fs.realpathSync(entryPath) : entryPath;
// Check if this is actually a scoped directory or a package
try {
const scopeEntries = fs.readdirSync(entryPath);
for (const sub of scopeEntries) {
result.push({
name: `${entry}/${sub}`,
fullPath: path.join(entryPath, sub),
});
}
} catch {
// Not a directory, skip
}
}
} else {
result.push({ name: entry, fullPath: entryPath });
}
}
return result;
}
// Start BFS from openclaw's virtual store node_modules
const openclawVirtualNM = getVirtualStoreNodeModules(openclawReal);
if (!openclawVirtualNM) {
echo`❌ Could not determine pnpm virtual store for openclaw`;
process.exit(1);
}
echo` Virtual store root: ${openclawVirtualNM}`;
queue.push({ nodeModulesDir: openclawVirtualNM, skipPkg: 'openclaw' });
while (queue.length > 0) {
const { nodeModulesDir, skipPkg } = queue.shift();
const packages = listPackages(nodeModulesDir);
for (const { name, fullPath } of packages) {
// Skip the package that owns this virtual store entry (it's the package itself, not a dep)
if (name === skipPkg) continue;
let realPath;
try {
realPath = fs.realpathSync(fullPath);
} catch {
continue; // broken symlink, skip
}
if (collected.has(realPath)) continue; // already visited
collected.set(realPath, name);
// Find this package's own virtual store node_modules to discover ITS deps
const depVirtualNM = getVirtualStoreNodeModules(realPath);
if (depVirtualNM && depVirtualNM !== nodeModulesDir) {
// Determine the package's "self name" in its own virtual store
// For scoped: @clack/core -> skip "@clack/core" when scanning
queue.push({ nodeModulesDir: depVirtualNM, skipPkg: name });
}
}
}
echo` Found ${collected.size} total packages (direct + transitive)`;
// 5. Copy all collected packages into OUTPUT/node_modules/ (flat structure)
//
// IMPORTANT: BFS guarantees direct deps are encountered before transitive deps.
// When the same package name appears at different versions (e.g. chalk@5 from
// openclaw directly, chalk@4 from a transitive dep), we keep the FIRST one
// (direct dep version) and skip later duplicates. This prevents version
// conflicts like CJS chalk@4 overwriting ESM chalk@5.
const outputNodeModules = path.join(OUTPUT, 'node_modules');
fs.mkdirSync(outputNodeModules, { recursive: true });
const copiedNames = new Set(); // Track package names already copied
let copiedCount = 0;
let skippedDupes = 0;
for (const [realPath, pkgName] of collected) {
if (copiedNames.has(pkgName)) {
skippedDupes++;
continue; // Keep the first version (closer to openclaw in dep tree)
}
copiedNames.add(pkgName);
const dest = path.join(outputNodeModules, pkgName);
try {
// Ensure parent directory exists (for scoped packages like @clack/core)
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.cpSync(realPath, dest, { recursive: true, dereference: true });
copiedCount++;
} catch (err) {
echo` ⚠️ Skipped ${pkgName}: ${err.message}`;
}
}
// 6. Verify the bundle
const entryExists = fs.existsSync(path.join(OUTPUT, 'openclaw.mjs'));
const distExists = fs.existsSync(path.join(OUTPUT, 'dist', 'entry.js'));
echo``;
echo`✅ Bundle complete: ${OUTPUT}`;
echo` Unique packages copied: ${copiedCount}`;
echo` Duplicate versions skipped: ${skippedDupes}`;
echo` Total discovered: ${collected.size}`;
echo` openclaw.mjs: ${entryExists ? '✓' : '✗'}`;
echo` dist/entry.js: ${distExists ? '✓' : '✗'}`;
if (!entryExists || !distExists) {
echo`❌ Bundle verification failed!`;
process.exit(1);
}

View File

@@ -1,139 +1,124 @@
import { spawnSync } from 'node:child_process'; #!/usr/bin/env zx
import { mkdirSync, rmSync, existsSync, chmodSync, renameSync, writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { platform, arch } from 'node:os';
const __dirname = dirname(fileURLToPath(import.meta.url)); import 'zx/globals';
const ROOT_DIR = join(__dirname, '..');
// Configuration const ROOT_DIR = path.resolve(__dirname, '..');
const UV_VERSION = '0.10.0'; const UV_VERSION = '0.10.0';
const BASE_URL = `https://github.com/astral-sh/uv/releases/download/${UV_VERSION}`; const BASE_URL = `https://github.com/astral-sh/uv/releases/download/${UV_VERSION}`;
const OUTPUT_BASE = join(ROOT_DIR, 'resources', 'bin'); const OUTPUT_BASE = path.join(ROOT_DIR, 'resources', 'bin');
// Mapping Node platforms/archs to uv release naming // Mapping Node platforms/archs to uv release naming
const TARGETS = { const TARGETS = {
'darwin-arm64': { 'darwin-arm64': {
filename: 'uv-aarch64-apple-darwin.tar.gz', filename: 'uv-aarch64-apple-darwin.tar.gz',
binName: 'uv', binName: 'uv',
extractCmd: (src, dest) => spawnSync('tar', ['-xzf', src, '-C', dest])
}, },
'darwin-x64': { 'darwin-x64': {
filename: 'uv-x86_64-apple-darwin.tar.gz', filename: 'uv-x86_64-apple-darwin.tar.gz',
binName: 'uv', binName: 'uv',
extractCmd: (src, dest) => spawnSync('tar', ['-xzf', src, '-C', dest])
}, },
'win32-x64': { 'win32-x64': {
filename: 'uv-x86_64-pc-windows-msvc.zip', filename: 'uv-x86_64-pc-windows-msvc.zip',
binName: 'uv.exe', binName: 'uv.exe',
extractCmd: (src, dest) => {
if (platform() === 'win32') {
return spawnSync('powershell.exe', ['-Command', `Expand-Archive -Path "${src}" -DestinationPath "${dest}" -Force`]);
} else {
return spawnSync('unzip', ['-q', '-o', src, '-d', dest]);
}
}
} }
}; };
async function downloadFile(url, dest) {
console.log(`⬇️ Downloading: ${url}`);
const response = await fetch(url);
if (!response.ok) throw new Error(`Failed to download: ${response.statusText}`);
const arrayBuffer = await response.arrayBuffer();
writeFileSync(dest, Buffer.from(arrayBuffer));
}
async function setupTarget(id) { async function setupTarget(id) {
const target = TARGETS[id]; const target = TARGETS[id];
if (!target) { if (!target) {
console.warn(`⚠️ Target ${id} is not supported by this script.`); echo(chalk.yellow`⚠️ Target ${id} is not supported by this script.`);
return; return;
} }
const targetDir = join(OUTPUT_BASE, id); const targetDir = path.join(OUTPUT_BASE, id);
const tempDir = join(ROOT_DIR, 'temp_uv_extract'); const tempDir = path.join(ROOT_DIR, 'temp_uv_extract');
const archivePath = join(ROOT_DIR, target.filename); const archivePath = path.join(ROOT_DIR, target.filename);
const downloadUrl = `${BASE_URL}/${target.filename}`;
console.log(` echo(chalk.blue`\n📦 Setting up uv for ${id}...`);
📦 Setting up uv for ${id}...`);
// Cleanup & Prep // Cleanup & Prep
if (existsSync(targetDir)) rmSync(targetDir, { recursive: true }); await fs.remove(targetDir);
if (existsSync(tempDir)) rmSync(tempDir, { recursive: true }); await fs.remove(tempDir);
mkdirSync(targetDir, { recursive: true }); await fs.ensureDir(targetDir);
mkdirSync(tempDir, { recursive: true }); await fs.ensureDir(tempDir);
try { try {
// Download // Download
await downloadFile(`${BASE_URL}/${target.filename}`, archivePath); echo`⬇️ Downloading: ${downloadUrl}`;
const response = await fetch(downloadUrl);
if (!response.ok) throw new Error(`Failed to download: ${response.statusText}`);
const buffer = await response.arrayBuffer();
await fs.writeFile(archivePath, Buffer.from(buffer));
// Extract // Extract
console.log('📂 Extracting...'); echo`📂 Extracting...`;
target.extractCmd(archivePath, tempDir); if (target.filename.endsWith('.zip')) {
if (os.platform() === 'win32') {
// Use .NET Framework for ZIP extraction (more reliable than Expand-Archive)
const { execSync } = await import('child_process');
const psCommand = `Add-Type -AssemblyName System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::ExtractToDirectory('${archivePath.replace(/'/g, "''")}', '${tempDir.replace(/'/g, "''")}')`;
execSync(`powershell.exe -NoProfile -Command "${psCommand}"`, { stdio: 'inherit' });
} else {
await $`unzip -q -o ${archivePath} -d ${tempDir}`;
}
} else {
await $`tar -xzf ${archivePath} -C ${tempDir}`;
}
// Move binary to final location // Move binary
// uv archives usually contain a folder named after the target // uv archives usually contain a folder named after the target
const folderName = target.filename.replace('.tar.gz', '').replace('.zip', ''); const folderName = target.filename.replace('.tar.gz', '').replace('.zip', '');
const sourceBin = join(tempDir, folderName, target.binName); const sourceBin = path.join(tempDir, folderName, target.binName);
const destBin = join(targetDir, target.binName); const destBin = path.join(targetDir, target.binName);
if (existsSync(sourceBin)) { if (await fs.pathExists(sourceBin)) {
renameSync(sourceBin, destBin); await fs.move(sourceBin, destBin, { overwrite: true });
} else { } else {
// Fallback: search for the binary if folder structure changed echo(chalk.yellow`🔍 Binary not found in expected subfolder, searching...`);
console.log('🔍 Binary not found in expected subfolder, searching...'); const files = await glob(`**/${target.binName}`, { cwd: tempDir, absolute: true });
const findResult = spawnSync(platform() === 'win32' ? 'where' : 'find', if (files.length > 0) {
platform() === 'win32' ? ['/R', tempDir, target.binName] : [tempDir, '-name', target.binName]); await fs.move(files[0], destBin, { overwrite: true });
const foundPath = findResult.stdout.toString().trim().split('\n')[0];
if (foundPath && existsSync(foundPath)) {
renameSync(foundPath, destBin);
} else { } else {
throw new Error(`Could not find ${target.binName} in extracted files.`); throw new Error(`Could not find ${target.binName} in extracted files.`);
} }
} }
// Permission fix // Permission fix
if (platform() !== 'win32') { if (os.platform() !== 'win32') {
chmodSync(destBin, 0o755); await fs.chmod(destBin, 0o755);
} }
console.log(`✅ Success: ${destBin}`); echo(chalk.green`✅ Success: ${destBin}`);
} finally { } finally {
// Cleanup // Cleanup
if (existsSync(archivePath)) rmSync(archivePath); await fs.remove(archivePath);
if (existsSync(tempDir)) rmSync(tempDir, { recursive: true }); await fs.remove(tempDir);
} }
} }
async function main() { // Main logic
const args = process.argv.slice(2); const args = process.argv.slice(3); // zx scripts/file.mjs --all -> argv is [node, zx, file, --all] ? or similar.
const downloadAll = args.includes('--all'); // zx execution: process.argv is [node, script, users_args...]
// Let's use minimist which zx includes globally as `argv`
const downloadAll = argv.all;
if (downloadAll) {
echo(chalk.cyan`🌐 Downloading uv binaries for ALL supported platforms...`);
for (const id of Object.keys(TARGETS)) {
await setupTarget(id);
}
} else {
const currentId = `${os.platform()}-${os.arch()}`;
echo(chalk.cyan`💻 Detected system: ${currentId}`);
if (downloadAll) { if (TARGETS[currentId]) {
console.log('🌐 Downloading uv binaries for ALL supported platforms...'); await setupTarget(currentId);
for (const id of Object.keys(TARGETS)) {
await setupTarget(id);
}
} else { } else {
const currentId = `${platform()}-${arch()}`; echo(chalk.red`❌ Current system ${currentId} is not in the supported download list.`);
console.log(`💻 Detected system: ${currentId}`); echo(`Supported targets: ${Object.keys(TARGETS).join(', ')}`);
process.exit(1);
if (TARGETS[currentId]) {
await setupTarget(currentId);
} else {
console.error(`❌ Current system ${currentId} is not in the supported download list.`);
console.log('Supported targets:', Object.keys(TARGETS).join(', '));
process.exit(1);
}
} }
console.log('\n🎉 Done!');
} }
main().catch(err => { echo(chalk.green`\n🎉 Done!`);
console.error('\n❌ Error:', err.message);
process.exit(1);
});

View File

@@ -0,0 +1,85 @@
#!/usr/bin/env zx
import 'zx/globals';
import sharp from 'sharp';
import png2icons from 'png2icons';
import { fileURLToPath } from 'url';
// Calculate paths
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PROJECT_ROOT = path.resolve(__dirname, '..');
const ICONS_DIR = path.join(PROJECT_ROOT, 'resources', 'icons');
const SVG_SOURCE = path.join(ICONS_DIR, 'icon.svg');
echo`🎨 Generating ClawX icons using Node.js...`;
// Check if SVG source exists
if (!fs.existsSync(SVG_SOURCE)) {
echo`❌ SVG source not found: ${SVG_SOURCE}`;
process.exit(1);
}
// Ensure icons directory exists
await fs.ensureDir(ICONS_DIR);
try {
// 1. Generate Master PNG Buffer (1024x1024)
echo` Processing SVG source...`;
const masterPngBuffer = await sharp(SVG_SOURCE)
.resize(1024, 1024)
.png() // Ensure it's PNG
.toBuffer();
// Save the main icon.png (typically 512x512 for Electron root icon)
await sharp(masterPngBuffer)
.resize(512, 512)
.toFile(path.join(ICONS_DIR, 'icon.png'));
echo` ✅ Created icon.png (512x512)`;
// 2. Generate Windows .ico
// png2icons expects a buffer. It returns a buffer (or null).
// createICO(buffer, scalingAlgorithm, withSize, useMath)
// scalingAlgorithm: 1 = Bilinear (better), 2 = Hermite (good), 3 = Bezier (best/slowest)
// Defaulting to Bezier (3) for quality or Hermite (2) for speed. Let's use 2 (Hermite) as it's balanced.
echo`🪟 Generating Windows .ico...`;
const icoBuffer = png2icons.createICO(masterPngBuffer, png2icons.HERMITE, 0, false);
if (icoBuffer) {
fs.writeFileSync(path.join(ICONS_DIR, 'icon.ico'), icoBuffer);
echo` ✅ Created icon.ico`;
} else {
echo(chalk.red` ❌ Failed to create icon.ico`);
// detailed error might not be available from png2icons simple API, often returns null on failure
}
// 3. Generate macOS .icns
echo`🍎 Generating macOS .icns...`;
const icnsBuffer = png2icons.createICNS(masterPngBuffer, png2icons.HERMITE, 0);
if (icnsBuffer) {
fs.writeFileSync(path.join(ICONS_DIR, 'icon.icns'), icnsBuffer);
echo` ✅ Created icon.icns`;
} else {
echo(chalk.red` ❌ Failed to create icon.icns`);
}
// 4. Generate Linux PNGs (various sizes)
echo`🐧 Generating Linux PNG icons...`;
const linuxSizes = [16, 32, 48, 64, 128, 256, 512];
let generatedCount = 0;
for (const size of linuxSizes) {
await sharp(masterPngBuffer)
.resize(size, size)
.toFile(path.join(ICONS_DIR, `${size}x${size}.png`));
generatedCount++;
}
echo` ✅ Created ${generatedCount} Linux PNG icons`;
echo`\n✨ Icon generation complete! Files located in: ${ICONS_DIR}`;
} catch (error) {
echo(chalk.red`\n❌ Fatal Error: ${error.message}`);
process.exit(1);
}

View File

@@ -1,111 +0,0 @@
#!/bin/bash
# Icon Generation Script
# Generates app icons for macOS, Windows, and Linux from SVG source
#
# Prerequisites:
# - macOS: brew install imagemagick librsvg
# - Linux: apt install imagemagick librsvg2-bin
# - Windows: Install ImageMagick
#
# Usage: ./scripts/generate-icons.sh
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
ICONS_DIR="$PROJECT_DIR/resources/icons"
SVG_SOURCE="$ICONS_DIR/icon.svg"
echo "🎨 Generating ClawX icons..."
# Check if SVG source exists
if [ ! -f "$SVG_SOURCE" ]; then
echo "❌ SVG source not found: $SVG_SOURCE"
exit 1
fi
# Check for required tools
if ! command -v convert &> /dev/null; then
echo "❌ ImageMagick not found. Please install it:"
echo " macOS: brew install imagemagick"
echo " Linux: apt install imagemagick"
exit 1
fi
if ! command -v rsvg-convert &> /dev/null; then
echo "❌ rsvg-convert not found. Please install it:"
echo " macOS: brew install librsvg"
echo " Linux: apt install librsvg2-bin"
exit 1
fi
# Create temp directory
TEMP_DIR=$(mktemp -d)
trap "rm -rf $TEMP_DIR" EXIT
echo "📁 Using temp directory: $TEMP_DIR"
# Generate PNG files at various sizes
SIZES=(16 32 64 128 256 512 1024)
for SIZE in "${SIZES[@]}"; do
echo " Generating ${SIZE}x${SIZE} PNG..."
rsvg-convert -w $SIZE -h $SIZE "$SVG_SOURCE" -o "$TEMP_DIR/icon_${SIZE}.png"
done
# ============ macOS (.icns) ============
echo "🍎 Generating macOS .icns..."
ICONSET_DIR="$TEMP_DIR/ClawX.iconset"
mkdir -p "$ICONSET_DIR"
# macOS iconset requires specific file names
cp "$TEMP_DIR/icon_16.png" "$ICONSET_DIR/icon_16x16.png"
cp "$TEMP_DIR/icon_32.png" "$ICONSET_DIR/icon_16x16@2x.png"
cp "$TEMP_DIR/icon_32.png" "$ICONSET_DIR/icon_32x32.png"
cp "$TEMP_DIR/icon_64.png" "$ICONSET_DIR/icon_32x32@2x.png"
cp "$TEMP_DIR/icon_128.png" "$ICONSET_DIR/icon_128x128.png"
cp "$TEMP_DIR/icon_256.png" "$ICONSET_DIR/icon_128x128@2x.png"
cp "$TEMP_DIR/icon_256.png" "$ICONSET_DIR/icon_256x256.png"
cp "$TEMP_DIR/icon_512.png" "$ICONSET_DIR/icon_256x256@2x.png"
cp "$TEMP_DIR/icon_512.png" "$ICONSET_DIR/icon_512x512.png"
cp "$TEMP_DIR/icon_1024.png" "$ICONSET_DIR/icon_512x512@2x.png"
if command -v iconutil &> /dev/null; then
iconutil -c icns -o "$ICONS_DIR/icon.icns" "$ICONSET_DIR"
echo " ✅ Created icon.icns"
else
echo " ⚠️ iconutil not found (macOS only). Skipping .icns generation."
fi
# ============ Windows (.ico) ============
echo "🪟 Generating Windows .ico..."
# Windows ICO typically includes 16, 32, 48, 64, 128, 256
convert "$TEMP_DIR/icon_16.png" \
"$TEMP_DIR/icon_32.png" \
"$TEMP_DIR/icon_64.png" \
"$TEMP_DIR/icon_128.png" \
"$TEMP_DIR/icon_256.png" \
"$ICONS_DIR/icon.ico"
echo " ✅ Created icon.ico"
# ============ Linux (PNG set) ============
echo "🐧 Generating Linux PNG icons..."
LINUX_SIZES=(16 32 48 64 128 256 512)
for SIZE in "${LINUX_SIZES[@]}"; do
cp "$TEMP_DIR/icon_${SIZE}.png" "$ICONS_DIR/${SIZE}x${SIZE}.png" 2>/dev/null || \
rsvg-convert -w $SIZE -h $SIZE "$SVG_SOURCE" -o "$ICONS_DIR/${SIZE}x${SIZE}.png"
done
echo " ✅ Created Linux PNG icons"
# ============ Copy main icon ============
cp "$TEMP_DIR/icon_512.png" "$ICONS_DIR/icon.png"
echo " ✅ Created icon.png (512x512)"
echo ""
echo "✅ Icon generation complete!"
echo " Generated files in: $ICONS_DIR"
ls -la "$ICONS_DIR"

View File

@@ -3,14 +3,14 @@
*/ */
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom'; import { HashRouter } from 'react-router-dom';
import App from './App'; import App from './App';
import './styles/globals.css'; import './styles/globals.css';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <HashRouter>
<App /> <App />
</BrowserRouter> </HashRouter>
</React.StrictMode>, </React.StrictMode>,
); );

View File

@@ -2,7 +2,7 @@
* Channels Page * Channels Page
* Manage messaging channel connections with configuration UI * Manage messaging channel connections with configuration UI
*/ */
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { import {
Plus, Plus,
Radio, Radio,
@@ -71,7 +71,7 @@ export function Channels() {
}, [fetchChannels]); }, [fetchChannels]);
// Fetch configured channel types from config file // Fetch configured channel types from config file
const fetchConfiguredTypes = async () => { const fetchConfiguredTypes = useCallback(async () => {
try { try {
const result = await window.electron.ipcRenderer.invoke('channel:listConfigured') as { const result = await window.electron.ipcRenderer.invoke('channel:listConfigured') as {
success: boolean; success: boolean;
@@ -83,11 +83,12 @@ export function Channels() {
} catch { } catch {
// ignore // ignore
} }
}; }, []);
useEffect(() => { useEffect(() => {
fetchConfiguredTypes(); // eslint-disable-next-line react-hooks/set-state-in-effect
}, []); void fetchConfiguredTypes();
}, [fetchConfiguredTypes]);
// Get channel types to display // Get channel types to display
const displayedChannelTypes = showAllChannels ? getAllChannels() : getPrimaryChannels(); const displayedChannelTypes = showAllChannels ? getAllChannels() : getPrimaryChannels();

View File

@@ -4,7 +4,7 @@
* via gateway:rpc IPC. Session selector, thinking toggle, and refresh * via gateway:rpc IPC. Session selector, thinking toggle, and refresh
* are in the toolbar; messages render with markdown + streaming. * are in the toolbar; messages render with markdown + streaming.
*/ */
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useState } from 'react';
import { AlertCircle, Bot, MessageSquare, Sparkles } from 'lucide-react'; import { AlertCircle, Bot, MessageSquare, Sparkles } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { useChatStore } from '@/stores/chat'; import { useChatStore } from '@/stores/chat';
@@ -30,6 +30,7 @@ export function Chat() {
const clearError = useChatStore((s) => s.clearError); const clearError = useChatStore((s) => s.clearError);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const [streamingTimestamp, setStreamingTimestamp] = useState<number>(0);
// Load data when gateway is running // Load data when gateway is running
useEffect(() => { useEffect(() => {
@@ -44,6 +45,16 @@ export function Chat() {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streamingMessage, sending]); }, [messages, streamingMessage, sending]);
// Update timestamp when sending starts
useEffect(() => {
if (sending && streamingTimestamp === 0) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setStreamingTimestamp(Date.now() / 1000);
} else if (!sending && streamingTimestamp !== 0) {
setStreamingTimestamp(0);
}
}, [sending, streamingTimestamp]);
// Gateway not running // Gateway not running
if (!isGatewayRunning) { if (!isGatewayRunning) {
return ( return (
@@ -88,7 +99,7 @@ export function Chat() {
message={{ message={{
role: 'assistant', role: 'assistant',
content: streamingMessage as unknown as string, content: streamingMessage as unknown as string,
timestamp: Date.now() / 1000, timestamp: streamingTimestamp,
}} }}
showThinking={showThinking} showThinking={showThinking}
isStreaming isStreaming

View File

@@ -2,7 +2,7 @@
* Dashboard Page * Dashboard Page
* Main overview page showing system status and quick actions * Main overview page showing system status and quick actions
*/ */
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { import {
Activity, Activity,
MessageSquare, MessageSquare,
@@ -27,6 +27,7 @@ export function Dashboard() {
const { skills, fetchSkills } = useSkillsStore(); const { skills, fetchSkills } = useSkillsStore();
const isGatewayRunning = gatewayStatus.state === 'running'; const isGatewayRunning = gatewayStatus.state === 'running';
const [uptime, setUptime] = useState(0);
// Fetch data only when gateway is running // Fetch data only when gateway is running
useEffect(() => { useEffect(() => {
@@ -40,10 +41,24 @@ export function Dashboard() {
const connectedChannels = Array.isArray(channels) ? channels.filter((c) => c.status === 'connected').length : 0; const connectedChannels = Array.isArray(channels) ? channels.filter((c) => c.status === 'connected').length : 0;
const enabledSkills = Array.isArray(skills) ? skills.filter((s) => s.enabled).length : 0; const enabledSkills = Array.isArray(skills) ? skills.filter((s) => s.enabled).length : 0;
// Calculate uptime // Update uptime periodically
const uptime = gatewayStatus.connectedAt useEffect(() => {
? Math.floor((Date.now() - gatewayStatus.connectedAt) / 1000) const updateUptime = () => {
: 0; if (gatewayStatus.connectedAt) {
setUptime(Math.floor((Date.now() - gatewayStatus.connectedAt) / 1000));
} else {
setUptime(0);
}
};
// Update immediately
updateUptime();
// Update every second
const interval = setInterval(updateUptime, 1000);
return () => clearInterval(interval);
}, [gatewayStatus.connectedAt]);
return ( return (
<div className="space-y-6"> <div className="space-y-6">

View File

@@ -2,7 +2,7 @@
* Setup Wizard Page * Setup Wizard Page
* First-time setup experience for new users * First-time setup experience for new users
*/ */
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
@@ -119,13 +119,14 @@ const providers: Provider[] = [
export function Setup() { export function Setup() {
const navigate = useNavigate(); const navigate = useNavigate();
const [currentStep, setCurrentStep] = useState(0); const [currentStep, setCurrentStep] = useState(0);
const [canProceed, setCanProceed] = useState(true);
// Setup state // Setup state
const [selectedProvider, setSelectedProvider] = useState<string | null>(null); const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const [apiKey, setApiKey] = useState(''); const [apiKey, setApiKey] = useState('');
// Installation state for the Installing step // Installation state for the Installing step
const [installedSkills, setInstalledSkills] = useState<string[]>([]); const [installedSkills, setInstalledSkills] = useState<string[]>([]);
// Runtime check status
const [runtimeChecksPassed, setRuntimeChecksPassed] = useState(false);
const step = steps[currentStep]; const step = steps[currentStep];
const isFirstStep = currentStep === 0; const isFirstStep = currentStep === 0;
@@ -133,6 +134,26 @@ export function Setup() {
const markSetupComplete = useSettingsStore((state) => state.markSetupComplete); const markSetupComplete = useSettingsStore((state) => state.markSetupComplete);
// Derive canProceed based on current step - computed directly to avoid useEffect
const canProceed = useMemo(() => {
switch (currentStep) {
case STEP.WELCOME:
return true;
case STEP.RUNTIME:
return runtimeChecksPassed;
case STEP.PROVIDER:
return selectedProvider !== null && apiKey.length > 0;
case STEP.CHANNEL:
return true; // Always allow proceeding — channel step is optional
case STEP.INSTALLING:
return false; // Cannot manually proceed, auto-proceeds when done
case STEP.COMPLETE:
return true;
default:
return true;
}
}, [currentStep, selectedProvider, apiKey, runtimeChecksPassed]);
const handleNext = async () => { const handleNext = async () => {
if (isLastStep) { if (isLastStep) {
// Complete setup // Complete setup
@@ -162,31 +183,6 @@ export function Setup() {
}, 1000); }, 1000);
}, []); }, []);
// Update canProceed based on current step
useEffect(() => {
switch (currentStep) {
case STEP.WELCOME:
setCanProceed(true);
break;
case STEP.RUNTIME:
// Will be managed by RuntimeContent
break;
case STEP.PROVIDER:
setCanProceed(selectedProvider !== null && apiKey.length > 0);
break;
case STEP.CHANNEL:
// Always allow proceeding — channel step is optional
setCanProceed(true);
break;
case STEP.INSTALLING:
setCanProceed(false); // Cannot manually proceed, auto-proceeds when done
break;
case STEP.COMPLETE:
setCanProceed(true);
break;
}
}, [currentStep, selectedProvider, apiKey]);
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 text-white"> <div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 text-white">
{/* Progress Indicator */} {/* Progress Indicator */}
@@ -240,7 +236,7 @@ export function Setup() {
{/* Step-specific content */} {/* Step-specific content */}
<div className="rounded-xl bg-white/10 backdrop-blur p-8 mb-8"> <div className="rounded-xl bg-white/10 backdrop-blur p-8 mb-8">
{currentStep === STEP.WELCOME && <WelcomeContent />} {currentStep === STEP.WELCOME && <WelcomeContent />}
{currentStep === STEP.RUNTIME && <RuntimeContent onStatusChange={setCanProceed} />} {currentStep === STEP.RUNTIME && <RuntimeContent onStatusChange={setRuntimeChecksPassed} />}
{currentStep === STEP.PROVIDER && ( {currentStep === STEP.PROVIDER && (
<ProviderContent <ProviderContent
providers={providers} providers={providers}
@@ -353,6 +349,9 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
openclaw: { status: 'checking' as 'checking' | 'success' | 'error', message: '' }, openclaw: { status: 'checking' as 'checking' | 'success' | 'error', message: '' },
gateway: { status: 'checking' as 'checking' | 'success' | 'error', message: '' }, gateway: { status: 'checking' as 'checking' | 'success' | 'error', message: '' },
}); });
const [showLogs, setShowLogs] = useState(false);
const [logContent, setLogContent] = useState('');
const [openclawDir, setOpenclawDir] = useState('');
const runChecks = useCallback(async () => { const runChecks = useCallback(async () => {
// Reset checks // Reset checks
@@ -362,59 +361,53 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
gateway: { status: 'checking', message: '' }, gateway: { status: 'checking', message: '' },
}); });
// Check Node.js // Check Node.js — always available in Electron
try { setChecks((prev) => ({
// In Electron, we can assume Node.js is available ...prev,
setChecks((prev) => ({ nodejs: { status: 'success', message: 'Node.js is available (Electron built-in)' },
...prev, }));
nodejs: { status: 'success', message: 'Node.js is available' },
}));
} catch {
setChecks((prev) => ({
...prev,
nodejs: { status: 'error', message: 'Node.js not found' },
}));
}
// Check OpenClaw submodule status // Check OpenClaw package status
try { try {
const openclawStatus = await window.electron.ipcRenderer.invoke('openclaw:status') as { const openclawStatus = await window.electron.ipcRenderer.invoke('openclaw:status') as {
submoduleExists: boolean; packageExists: boolean;
isInstalled: boolean;
isBuilt: boolean; isBuilt: boolean;
dir: string; dir: string;
version?: string;
}; };
if (!openclawStatus.submoduleExists) { setOpenclawDir(openclawStatus.dir);
if (!openclawStatus.packageExists) {
setChecks((prev) => ({ setChecks((prev) => ({
...prev, ...prev,
openclaw: { openclaw: {
status: 'error', status: 'error',
message: 'OpenClaw submodule not found. Run: git submodule update --init' message: `OpenClaw package not found at: ${openclawStatus.dir}`
}, },
})); }));
} else if (!openclawStatus.isInstalled) { } else if (!openclawStatus.isBuilt) {
setChecks((prev) => ({ setChecks((prev) => ({
...prev, ...prev,
openclaw: { openclaw: {
status: 'error', status: 'error',
message: 'Dependencies not installed. Run: cd openclaw && pnpm install' message: 'OpenClaw package found but dist is missing'
}, },
})); }));
} else { } else {
const modeLabel = openclawStatus.isBuilt ? 'production' : 'development'; const versionLabel = openclawStatus.version ? ` v${openclawStatus.version}` : '';
setChecks((prev) => ({ setChecks((prev) => ({
...prev, ...prev,
openclaw: { openclaw: {
status: 'success', status: 'success',
message: `OpenClaw package ready (${modeLabel} mode)` message: `OpenClaw package ready${versionLabel}`
}, },
})); }));
} }
} catch (error) { } catch (error) {
setChecks((prev) => ({ setChecks((prev) => ({
...prev, ...prev,
openclaw: { status: 'error', message: `Failed to check: ${error}` }, openclaw: { status: 'error', message: `Check failed: ${error}` },
})); }));
} }
@@ -433,7 +426,10 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
} else { } else {
setChecks((prev) => ({ setChecks((prev) => ({
...prev, ...prev,
gateway: { status: 'error', message: 'Not running' }, gateway: {
status: 'error',
message: gatewayStatus.error || 'Not running'
},
})); }));
} }
}, [gatewayStatus]); }, [gatewayStatus]);
@@ -473,6 +469,28 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
await startGateway(); await startGateway();
}; };
const handleShowLogs = async () => {
try {
const logs = await window.electron.ipcRenderer.invoke('log:readFile', 100) as string;
setLogContent(logs);
setShowLogs(true);
} catch {
setLogContent('(Failed to load logs)');
setShowLogs(true);
}
};
const handleOpenLogDir = async () => {
try {
const logDir = await window.electron.ipcRenderer.invoke('log:getDir') as string;
if (logDir) {
await window.electron.ipcRenderer.invoke('shell:showItemInFolder', logDir);
}
} catch {
// ignore
}
};
const renderStatus = (status: 'checking' | 'success' | 'error', message: string) => { const renderStatus = (status: 'checking' | 'success' | 'error', message: string) => {
if (status === 'checking') { if (status === 'checking') {
return ( return (
@@ -502,10 +520,15 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Checking Environment</h2> <h2 className="text-xl font-semibold">Checking Environment</h2>
<Button variant="ghost" size="sm" onClick={runChecks}> <div className="flex gap-2">
<RefreshCw className="h-4 w-4 mr-2" /> <Button variant="ghost" size="sm" onClick={handleShowLogs}>
Re-check View Logs
</Button> </Button>
<Button variant="ghost" size="sm" onClick={runChecks}>
<RefreshCw className="h-4 w-4 mr-2" />
Re-check
</Button>
</div>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between p-3 rounded-lg bg-white/5"> <div className="flex items-center justify-between p-3 rounded-lg bg-white/5">
@@ -513,7 +536,14 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
{renderStatus(checks.nodejs.status, checks.nodejs.message)} {renderStatus(checks.nodejs.status, checks.nodejs.message)}
</div> </div>
<div className="flex items-center justify-between p-3 rounded-lg bg-white/5"> <div className="flex items-center justify-between p-3 rounded-lg bg-white/5">
<span>OpenClaw Package</span> <div>
<span>OpenClaw Package</span>
{openclawDir && (
<p className="text-xs text-slate-500 mt-0.5 font-mono truncate max-w-[300px]">
{openclawDir}
</p>
)}
</div>
{renderStatus(checks.openclaw.status, checks.openclaw.message)} {renderStatus(checks.openclaw.status, checks.openclaw.message)}
</div> </div>
<div className="flex items-center justify-between p-3 rounded-lg bg-white/5"> <div className="flex items-center justify-between p-3 rounded-lg bg-white/5">
@@ -536,12 +566,33 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
<div> <div>
<p className="font-medium text-red-400">Environment issue detected</p> <p className="font-medium text-red-400">Environment issue detected</p>
<p className="text-sm text-slate-300 mt-1"> <p className="text-sm text-slate-300 mt-1">
Please ensure Node.js is installed and OpenClaw is properly set up. Please ensure OpenClaw is properly installed. Check the logs for details.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
)} )}
{/* Log viewer panel */}
{showLogs && (
<div className="mt-4 p-4 rounded-lg bg-black/40 border border-slate-600">
<div className="flex items-center justify-between mb-2">
<p className="font-medium text-slate-200 text-sm">Application Logs</p>
<div className="flex gap-2">
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={handleOpenLogDir}>
<ExternalLink className="h-3 w-3 mr-1" />
Open Log Folder
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => setShowLogs(false)}>
Close
</Button>
</div>
</div>
<pre className="text-xs text-slate-300 bg-black/50 p-3 rounded max-h-60 overflow-auto whitespace-pre-wrap font-mono">
{logContent || '(No logs available yet)'}
</pre>
</div>
)}
</div> </div>
); );
} }

View File

@@ -548,7 +548,10 @@ export function Skills() {
setShowGatewayWarning(true); setShowGatewayWarning(true);
}, 1500); }, 1500);
} else { } else {
setShowGatewayWarning(false); // Use setTimeout to avoid synchronous setState in effect
timer = setTimeout(() => {
setShowGatewayWarning(false);
}, 0);
} }
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [isGatewayRunning]); }, [isGatewayRunning]);