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>
8
.github/workflows/ci.yml
vendored
@@ -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
|
||||||
|
|||||||
19
.github/workflows/release.yml
vendored
@@ -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
@@ -57,3 +57,7 @@ resources/bin
|
|||||||
*.p12
|
*.p12
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
|
||||||
|
build/
|
||||||
|
|
||||||
|
.cursor/
|
||||||
3
.gitmodules
vendored
@@ -1,3 +0,0 @@
|
|||||||
[submodule "openclaw"]
|
|
||||||
path = openclaw
|
|
||||||
url = https://github.com/openclaw/openclaw.git
|
|
||||||
21
LICENSE
Normal 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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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'.
|
||||||
command = 'node';
|
const gatewayArgs = ['gateway', '--port', String(this.status.port), '--token', gatewayToken, '--dev', '--allow-unconfigured'];
|
||||||
args = [entryScript, 'gateway', '--port', String(this.status.port), '--token', gatewayToken, '--dev', '--allow-unconfigured'];
|
|
||||||
} else {
|
|
||||||
// Development mode: use pnpm gateway:dev which handles tsx compilation
|
|
||||||
console.log('Starting Gateway in development mode (using pnpm)');
|
|
||||||
command = 'pnpm';
|
|
||||||
args = ['run', 'dev', 'gateway', '--port', String(this.status.port), '--token', gatewayToken, '--dev', '--allow-unconfigured'];
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Spawning Gateway: ${command} ${args.join(' ')}`);
|
|
||||||
console.log(`Working directory: ${openclawDir}`);
|
|
||||||
|
|
||||||
// Resolve bundled bin path for uv
|
|
||||||
let binPath = '';
|
|
||||||
const platform = process.platform;
|
|
||||||
const arch = process.arch;
|
|
||||||
// Map arch if necessary (e.g. x64 is standard, but ensure consistency with script)
|
|
||||||
const target = `${platform}-${arch}`;
|
|
||||||
|
|
||||||
if (app.isPackaged) {
|
if (app.isPackaged) {
|
||||||
// In production, we flattened the structure to 'bin/' using electron-builder macros
|
// Production: always use Electron binary as Node.js via ELECTRON_RUN_AS_NODE
|
||||||
binPath = path.join(process.resourcesPath, 'bin');
|
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';
|
||||||
|
args = [entryScript, ...gatewayArgs];
|
||||||
|
logger.info('Starting Gateway in DEV mode (node + built dist)');
|
||||||
} else {
|
} else {
|
||||||
// In dev, resources are at project root/resources/bin/<platform>-<arch>
|
// Development without build: use pnpm dev
|
||||||
binPath = path.join(process.cwd(), 'resources', 'bin', target);
|
command = 'pnpm';
|
||||||
|
args = ['run', 'dev', ...gatewayArgs];
|
||||||
|
logger.info('Starting Gateway in DEV mode (pnpm dev)');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only inject if the bundled directory exists
|
logger.info(`Spawning: ${command} ${args.join(' ')}`);
|
||||||
const finalPath = existsSync(binPath)
|
logger.info(`Working directory: ${openclawDir}`);
|
||||||
|
|
||||||
|
// Resolve bundled bin path for uv
|
||||||
|
const platform = process.platform;
|
||||||
|
const arch = process.arch;
|
||||||
|
const target = `${platform}-${arch}`;
|
||||||
|
|
||||||
|
const binPath = app.isPackaged
|
||||||
|
? path.join(process.resourcesPath, 'bin')
|
||||||
|
: path.join(process.cwd(), 'resources', 'bin', target);
|
||||||
|
|
||||||
|
const binPathExists = 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})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ====================
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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,19 +19,26 @@ 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 });
|
||||||
@@ -39,6 +46,10 @@ export function initLogger(): void {
|
|||||||
|
|
||||||
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 || ''}`;
|
||||||
|
}
|
||||||
|
if (typeof arg === 'object') {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(arg, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(arg);
|
||||||
|
}).join(' ') : '';
|
||||||
|
|
||||||
return `[${timestamp}] [${level}] ${message}${formattedArgs}`;
|
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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}'],
|
||||||
|
|||||||
129
package.json
@@ -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
BIN
resources/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
resources/icons/16x16.png
Normal file
|
After Width: | Height: | Size: 547 B |
BIN
resources/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
resources/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
resources/icons/48x48.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
resources/icons/512x512.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
resources/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
resources/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 422 KiB |
BIN
resources/icons/icon.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 9.1 KiB |
47
scripts/after-pack.cjs
Normal 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
@@ -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);
|
||||||
|
}
|
||||||
@@ -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) {
|
if (downloadAll) {
|
||||||
console.log('🌐 Downloading uv binaries for ALL supported platforms...');
|
echo(chalk.cyan`🌐 Downloading uv binaries for ALL supported platforms...`);
|
||||||
for (const id of Object.keys(TARGETS)) {
|
for (const id of Object.keys(TARGETS)) {
|
||||||
await setupTarget(id);
|
await setupTarget(id);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
const currentId = `${os.platform()}-${os.arch()}`;
|
||||||
|
echo(chalk.cyan`💻 Detected system: ${currentId}`);
|
||||||
|
|
||||||
|
if (TARGETS[currentId]) {
|
||||||
|
await setupTarget(currentId);
|
||||||
} 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);
|
|
||||||
});
|
|
||||||
|
|||||||
85
scripts/generate-icons.mjs
Normal 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);
|
||||||
|
}
|
||||||
@@ -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"
|
|
||||||
@@ -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>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||