mirror of
https://gh-proxy.org/https://github.com/klinsmaya/LobsterAI.git
synced 2026-11-08 01:06:44 +08:00
567 lines
18 KiB
JavaScript
567 lines
18 KiB
JavaScript
'use strict';
|
|
|
|
const path = require('path');
|
|
const { existsSync, readdirSync, statSync, mkdirSync, readFileSync, rmSync, cpSync, lstatSync } = require('fs');
|
|
const { spawnSync } = require('child_process');
|
|
const asar = require('@electron/asar');
|
|
const { ensurePortablePythonRuntime, checkRuntimeHealth } = require('./setup-python-runtime.js');
|
|
const { syncLocalOpenClawExtensions } = require('./sync-local-openclaw-extensions.cjs');
|
|
const { packMultipleSources } = require('./pack-openclaw-tar.cjs');
|
|
|
|
function isWindowsTarget(context) {
|
|
return context?.electronPlatformName === 'win32';
|
|
}
|
|
|
|
function isMacTarget(context) {
|
|
return context?.electronPlatformName === 'darwin';
|
|
}
|
|
|
|
function resolveTargetArch(context) {
|
|
if (context?.arch === 3) return 'arm64';
|
|
if (context?.arch === 0) return 'ia32';
|
|
if (context?.arch === 1) return 'x64';
|
|
if (process.arch === 'arm64') return 'arm64';
|
|
if (process.arch === 'ia32') return 'ia32';
|
|
return 'x64';
|
|
}
|
|
|
|
function resolveOpenClawRuntimeTargetId(context) {
|
|
const platform = context?.electronPlatformName;
|
|
const arch = resolveTargetArch(context);
|
|
|
|
if (platform === 'darwin') {
|
|
return arch === 'x64' ? 'mac-x64' : 'mac-arm64';
|
|
}
|
|
if (platform === 'win32') {
|
|
return arch === 'arm64' ? 'win-arm64' : 'win-x64';
|
|
}
|
|
if (platform === 'linux') {
|
|
return arch === 'arm64' ? 'linux-arm64' : 'linux-x64';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function readRuntimeBuildInfo(runtimeRoot) {
|
|
const buildInfoPath = path.join(runtimeRoot, 'runtime-build-info.json');
|
|
if (!existsSync(buildInfoPath)) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(readFileSync(buildInfoPath, 'utf8'));
|
|
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getOpenClawRuntimeBuildHint(targetId) {
|
|
if (!targetId) {
|
|
return 'npm run openclaw:runtime:host';
|
|
}
|
|
return `npm run openclaw:runtime:${targetId}`;
|
|
}
|
|
|
|
function syncCurrentOpenClawRuntimeForTarget(context) {
|
|
const runtimeBase = path.join(__dirname, '..', 'vendor', 'openclaw-runtime');
|
|
const currentRoot = path.join(runtimeBase, 'current');
|
|
const targetId = resolveOpenClawRuntimeTargetId(context);
|
|
|
|
if (!targetId) {
|
|
return { runtimeRoot: currentRoot, targetId: null };
|
|
}
|
|
|
|
const targetRoot = path.join(runtimeBase, targetId);
|
|
if (!existsSync(targetRoot)) {
|
|
return { runtimeRoot: currentRoot, targetId };
|
|
}
|
|
|
|
const currentBuildInfo = readRuntimeBuildInfo(currentRoot);
|
|
if (currentBuildInfo?.target !== targetId) {
|
|
rmSync(currentRoot, { recursive: true, force: true });
|
|
cpSync(targetRoot, currentRoot, { recursive: true, force: true });
|
|
console.log(`[electron-builder-hooks] Synced OpenClaw runtime ${targetId} -> current`);
|
|
}
|
|
|
|
return { runtimeRoot: currentRoot, targetId };
|
|
}
|
|
|
|
function verifyPreinstalledPlugins(runtimeRoot, buildHint) {
|
|
const pkgPath = path.join(__dirname, '..', 'package.json');
|
|
let plugins = [];
|
|
try {
|
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
plugins = (pkg.openclaw && pkg.openclaw.plugins) || [];
|
|
} catch {
|
|
return; // Cannot read package.json — skip verification
|
|
}
|
|
|
|
if (!Array.isArray(plugins) || plugins.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const extensionsDir = path.join(runtimeRoot, 'extensions');
|
|
const missing = [];
|
|
|
|
for (const plugin of plugins) {
|
|
if (!plugin.id) continue;
|
|
const pluginDir = path.join(extensionsDir, plugin.id);
|
|
if (!existsSync(pluginDir)) {
|
|
missing.push(plugin.id);
|
|
}
|
|
}
|
|
|
|
if (missing.length > 0) {
|
|
throw new Error(
|
|
'[electron-builder-hooks] Preinstalled OpenClaw plugins missing from runtime: '
|
|
+ missing.join(', ')
|
|
+ `. Run \`${buildHint}\` (which includes openclaw:plugins) before packaging.`,
|
|
);
|
|
}
|
|
|
|
console.log(`[electron-builder-hooks] Verified ${plugins.length} preinstalled OpenClaw plugin(s).`);
|
|
}
|
|
|
|
function ensureBundledOpenClawRuntime(context) {
|
|
const { runtimeRoot, targetId } = syncCurrentOpenClawRuntimeForTarget(context);
|
|
const buildHint = getOpenClawRuntimeBuildHint(targetId);
|
|
|
|
const localMcpBridgeDir = path.join(runtimeRoot, 'extensions', 'mcp-bridge');
|
|
if (!existsSync(localMcpBridgeDir)) {
|
|
syncLocalOpenClawExtensions(runtimeRoot);
|
|
}
|
|
|
|
const requiredExternalPaths = [
|
|
path.join(runtimeRoot, 'node_modules'),
|
|
];
|
|
const missingExternal = requiredExternalPaths.filter((candidate) => !existsSync(candidate));
|
|
if (missingExternal.length > 0) {
|
|
throw new Error(
|
|
'[electron-builder-hooks] Bundled OpenClaw runtime is incomplete. Missing: '
|
|
+ missingExternal.join(', ')
|
|
+ `. Run \`${buildHint}\` before packaging.`,
|
|
);
|
|
}
|
|
|
|
// Verify preinstalled plugins are present in the runtime extensions directory
|
|
verifyPreinstalledPlugins(runtimeRoot, buildHint);
|
|
|
|
// Verify gateway-bundle.mjs exists and is reasonably sized.
|
|
// Without it, Windows first-launch falls back to loading ~1100 ESM modules
|
|
// individually, causing 80-100s startup delay.
|
|
const gatewayBundlePath = path.join(runtimeRoot, 'gateway-bundle.mjs');
|
|
if (!existsSync(gatewayBundlePath)) {
|
|
throw new Error(
|
|
'[electron-builder-hooks] gateway-bundle.mjs is missing from '
|
|
+ runtimeRoot
|
|
+ '. Run `npm run openclaw:bundle` before packaging.',
|
|
);
|
|
}
|
|
const gatewayBundleStat = statSync(gatewayBundlePath);
|
|
if (gatewayBundleStat.size < 1_000_000) {
|
|
throw new Error(
|
|
'[electron-builder-hooks] gateway-bundle.mjs is suspiciously small ('
|
|
+ gatewayBundleStat.size
|
|
+ ' bytes, expected ~27MB). Rebuild with: `npm run openclaw:bundle`.',
|
|
);
|
|
}
|
|
|
|
const gatewayAsarPath = path.join(runtimeRoot, 'gateway.asar');
|
|
if (existsSync(gatewayAsarPath)) {
|
|
let entries;
|
|
try {
|
|
// Normalize paths: on Windows, asar.listPackage may return backslash paths
|
|
entries = new Set(asar.listPackage(gatewayAsarPath).map(e => e.replace(/\\/g, '/')));
|
|
} catch (error) {
|
|
throw new Error(
|
|
'[electron-builder-hooks] Failed to read OpenClaw gateway.asar: '
|
|
+ `${gatewayAsarPath}. ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
|
|
const hasOpenClawEntry = entries.has('/openclaw.mjs');
|
|
const hasControlUiIndex = entries.has('/dist/control-ui/index.html');
|
|
const hasGatewayEntry = entries.has('/dist/entry.js') || entries.has('/dist/entry.mjs');
|
|
|
|
if (!hasOpenClawEntry || !hasControlUiIndex || !hasGatewayEntry) {
|
|
throw new Error(
|
|
'[electron-builder-hooks] OpenClaw gateway.asar is incomplete. '
|
|
+ `openclaw.mjs=${hasOpenClawEntry}, control-ui=${hasControlUiIndex}, entry=${hasGatewayEntry}.`,
|
|
);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
const legacyRequiredPaths = [
|
|
path.join(runtimeRoot, 'openclaw.mjs'),
|
|
path.join(runtimeRoot, 'dist', 'control-ui', 'index.html'),
|
|
];
|
|
|
|
const hasLegacyEntry = existsSync(path.join(runtimeRoot, 'dist', 'entry.js'))
|
|
|| existsSync(path.join(runtimeRoot, 'dist', 'entry.mjs'));
|
|
if (!hasLegacyEntry) {
|
|
throw new Error(
|
|
'[electron-builder-hooks] Missing OpenClaw runtime entry. '
|
|
+ `Expected ${path.join(runtimeRoot, 'dist', 'entry.js')} or ${path.join(runtimeRoot, 'dist', 'entry.mjs')}, `
|
|
+ `or ${path.join(runtimeRoot, 'gateway.asar')}.`,
|
|
);
|
|
}
|
|
|
|
const missingLegacy = legacyRequiredPaths.filter((candidate) => !existsSync(candidate));
|
|
if (missingLegacy.length > 0) {
|
|
throw new Error(
|
|
'[electron-builder-hooks] Bundled OpenClaw legacy runtime is incomplete. Missing: '
|
|
+ missingLegacy.join(', ')
|
|
+ `. Run \`${buildHint}\` before packaging.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function findPackagedBash(appOutDir) {
|
|
const candidates = [
|
|
path.join(appOutDir, 'resources', 'mingit', 'bin', 'bash.exe'),
|
|
path.join(appOutDir, 'resources', 'mingit', 'usr', 'bin', 'bash.exe'),
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
if (existsSync(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function verifyPackagedPortableGitRuntimeDirs(appOutDir) {
|
|
const requiredDirs = [
|
|
path.join(appOutDir, 'resources', 'mingit', 'dev', 'shm'),
|
|
path.join(appOutDir, 'resources', 'mingit', 'dev', 'mqueue'),
|
|
];
|
|
const createdDirs = [];
|
|
|
|
for (const dir of requiredDirs) {
|
|
if (existsSync(dir)) continue;
|
|
mkdirSync(dir, { recursive: true });
|
|
createdDirs.push(dir);
|
|
}
|
|
|
|
const missingDirs = requiredDirs.filter((dir) => !existsSync(dir));
|
|
if (missingDirs.length > 0) {
|
|
throw new Error(
|
|
'Windows package is missing required PortableGit runtime directories. '
|
|
+ `Missing: ${missingDirs.join(', ')}`
|
|
);
|
|
}
|
|
|
|
if (createdDirs.length > 0) {
|
|
console.log(
|
|
'[electron-builder-hooks] Created missing PortableGit runtime directories: '
|
|
+ createdDirs.join(', ')
|
|
);
|
|
}
|
|
|
|
console.log(
|
|
'[electron-builder-hooks] Verified PortableGit runtime directories: '
|
|
+ requiredDirs.join(', ')
|
|
);
|
|
}
|
|
|
|
function findPackagedPythonExecutable(appOutDir) {
|
|
const candidates = [
|
|
path.join(appOutDir, 'resources', 'python-win', 'python.exe'),
|
|
path.join(appOutDir, 'resources', 'python-win', 'python3.exe'),
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
if (existsSync(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function applyMacIconFix(appPath) {
|
|
console.log('[electron-builder-hooks] Applying macOS icon fix for Apple Silicon compatibility...');
|
|
|
|
const infoPlistPath = path.join(appPath, 'Contents', 'Info.plist');
|
|
const resourcesPath = path.join(appPath, 'Contents', 'Resources');
|
|
const iconPath = path.join(resourcesPath, 'icon.icns');
|
|
|
|
if (!existsSync(infoPlistPath)) {
|
|
console.warn(`[electron-builder-hooks] Info.plist not found at ${infoPlistPath}`);
|
|
return;
|
|
}
|
|
|
|
if (!existsSync(iconPath)) {
|
|
console.warn(`[electron-builder-hooks] icon.icns not found at ${iconPath}`);
|
|
return;
|
|
}
|
|
|
|
// Check if CFBundleIconName already exists
|
|
const checkResult = spawnSync('plutil', [
|
|
'-extract', 'CFBundleIconName', 'raw', infoPlistPath
|
|
], { encoding: 'utf-8' });
|
|
|
|
if (checkResult.status !== 0) {
|
|
// CFBundleIconName doesn't exist, add it
|
|
console.log('[electron-builder-hooks] Adding CFBundleIconName to Info.plist...');
|
|
const addResult = spawnSync('plutil', [
|
|
'-insert', 'CFBundleIconName', '-string', 'icon', infoPlistPath
|
|
], { encoding: 'utf-8' });
|
|
|
|
if (addResult.status === 0) {
|
|
console.log('[electron-builder-hooks] ✓ CFBundleIconName added successfully');
|
|
} else {
|
|
console.warn('[electron-builder-hooks] Failed to add CFBundleIconName:', addResult.stderr);
|
|
}
|
|
} else {
|
|
console.log('[electron-builder-hooks] ✓ CFBundleIconName already present');
|
|
}
|
|
|
|
// Clear extended attributes
|
|
spawnSync('xattr', ['-cr', appPath], { encoding: 'utf-8' });
|
|
|
|
// Touch the app to update modification time
|
|
spawnSync('touch', [appPath], { encoding: 'utf-8' });
|
|
spawnSync('touch', [resourcesPath], { encoding: 'utf-8' });
|
|
|
|
console.log('[electron-builder-hooks] ✓ macOS icon fix applied');
|
|
}
|
|
|
|
/**
|
|
* Remove broken symlinks from a directory recursively.
|
|
* This fixes macOS code signing failures caused by dangling symlinks in node_modules/.bin
|
|
*/
|
|
function removeBrokenSymlinks(dir) {
|
|
if (!existsSync(dir)) return 0;
|
|
|
|
let removedCount = 0;
|
|
const entries = readdirSync(dir, { withFileTypes: true });
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(dir, entry.name);
|
|
|
|
try {
|
|
if (entry.isSymbolicLink()) {
|
|
// Check if symlink target exists
|
|
try {
|
|
statSync(fullPath); // follows symlink
|
|
} catch {
|
|
// Symlink is broken - remove it
|
|
rmSync(fullPath, { force: true });
|
|
removedCount++;
|
|
}
|
|
} else if (entry.isDirectory()) {
|
|
removedCount += removeBrokenSymlinks(fullPath);
|
|
}
|
|
} catch (err) {
|
|
// Skip entries we can't access
|
|
}
|
|
}
|
|
|
|
return removedCount;
|
|
}
|
|
|
|
/**
|
|
* Clean up broken symlinks in cfmind/extensions to prevent macOS signing failures.
|
|
*/
|
|
function cleanupBrokenSymlinksInExtensions(appOutDir) {
|
|
const extensionsDir = path.join(appOutDir, 'Contents', 'Resources', 'cfmind', 'extensions');
|
|
|
|
if (!existsSync(extensionsDir)) {
|
|
return;
|
|
}
|
|
|
|
console.log('[electron-builder-hooks] Cleaning up broken symlinks in cfmind/extensions...');
|
|
|
|
let totalRemoved = 0;
|
|
const extensionEntries = readdirSync(extensionsDir, { withFileTypes: true });
|
|
|
|
for (const entry of extensionEntries) {
|
|
if (!entry.isDirectory()) continue;
|
|
|
|
const nodeModulesBin = path.join(extensionsDir, entry.name, 'node_modules', '.bin');
|
|
if (existsSync(nodeModulesBin)) {
|
|
const removed = removeBrokenSymlinks(nodeModulesBin);
|
|
if (removed > 0) {
|
|
console.log(`[electron-builder-hooks] ${entry.name}: removed ${removed} broken symlink(s)`);
|
|
totalRemoved += removed;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (totalRemoved > 0) {
|
|
console.log(`[electron-builder-hooks] ✓ Removed ${totalRemoved} broken symlink(s) total`);
|
|
} else {
|
|
console.log('[electron-builder-hooks] ✓ No broken symlinks found');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a command exists in the system PATH.
|
|
*/
|
|
function hasCommand(command) {
|
|
const checker = process.platform === 'win32' ? 'where' : 'which';
|
|
const result = spawnSync(checker, [command], { stdio: 'ignore' });
|
|
return result.status === 0;
|
|
}
|
|
|
|
/**
|
|
* Install dependencies for all skills in the SKILLs directory.
|
|
* This ensures bundled skills include node_modules for users without npm.
|
|
*/
|
|
function installSkillDependencies() {
|
|
// Check if npm is available (should be available during build)
|
|
if (!hasCommand('npm')) {
|
|
console.warn('[electron-builder-hooks] npm not found in PATH, skipping skill dependency installation');
|
|
console.warn('[electron-builder-hooks] (This is only a warning - skills will be installed at runtime if needed)');
|
|
return;
|
|
}
|
|
|
|
const skillsDir = path.join(__dirname, '..', 'SKILLs');
|
|
if (!existsSync(skillsDir)) {
|
|
console.log('[electron-builder-hooks] SKILLs directory not found, skipping skill dependency installation');
|
|
return;
|
|
}
|
|
|
|
console.log('[electron-builder-hooks] Installing skill dependencies...');
|
|
|
|
const entries = readdirSync(skillsDir);
|
|
let installedCount = 0;
|
|
let skippedCount = 0;
|
|
let failedCount = 0;
|
|
|
|
for (const entry of entries) {
|
|
const skillPath = path.join(skillsDir, entry);
|
|
const stat = statSync(skillPath);
|
|
if (!stat.isDirectory()) continue;
|
|
|
|
const packageJsonPath = path.join(skillPath, 'package.json');
|
|
const nodeModulesPath = path.join(skillPath, 'node_modules');
|
|
|
|
if (!existsSync(packageJsonPath)) {
|
|
continue; // No package.json, skip
|
|
}
|
|
|
|
if (existsSync(nodeModulesPath)) {
|
|
console.log(`[electron-builder-hooks] ${entry}: node_modules exists, skipping`);
|
|
skippedCount++;
|
|
continue;
|
|
}
|
|
|
|
console.log(`[electron-builder-hooks] ${entry}: installing dependencies...`);
|
|
// On Windows, use shell: true so cmd.exe resolves npm.cmd correctly
|
|
const isWin = process.platform === 'win32';
|
|
const result = spawnSync('npm', ['install'], {
|
|
cwd: skillPath,
|
|
encoding: 'utf-8',
|
|
stdio: 'pipe',
|
|
timeout: 5 * 60 * 1000, // 5 minute timeout
|
|
shell: isWin,
|
|
});
|
|
|
|
if (result.status === 0) {
|
|
console.log(`[electron-builder-hooks] ${entry}: ✓ installed`);
|
|
installedCount++;
|
|
} else {
|
|
console.error(`[electron-builder-hooks] ${entry}: ✗ failed`);
|
|
if (result.error) {
|
|
console.error(`[electron-builder-hooks] Error: ${result.error.message}`);
|
|
}
|
|
if (result.stderr) {
|
|
console.error(`[electron-builder-hooks] ${result.stderr.substring(0, 200)}`);
|
|
}
|
|
failedCount++;
|
|
}
|
|
}
|
|
|
|
console.log(`[electron-builder-hooks] Skill dependencies: ${installedCount} installed, ${skippedCount} skipped, ${failedCount} failed`);
|
|
}
|
|
|
|
async function beforePack(context) {
|
|
ensureBundledOpenClawRuntime(context);
|
|
// Install skill dependencies first (for all platforms)
|
|
installSkillDependencies();
|
|
|
|
if (isWindowsTarget(context)) {
|
|
// Pack all large resource directories into a single tar for faster NSIS
|
|
// installation. NSIS extracts thousands of small files very slowly on NTFS;
|
|
// a single tar archive is extracted by 7z almost instantly, and we unpack
|
|
// it in the NSIS customInstall macro using Electron's Node runtime.
|
|
const buildTarDir = path.join(__dirname, '..', 'build-tar');
|
|
mkdirSync(buildTarDir, { recursive: true });
|
|
|
|
const outputTar = path.join(buildTarDir, 'win-resources.tar');
|
|
const sources = [
|
|
{
|
|
label: 'OpenClaw runtime',
|
|
dir: path.join(__dirname, '..', 'vendor', 'openclaw-runtime', 'current'),
|
|
prefix: 'cfmind',
|
|
},
|
|
{
|
|
label: 'SKILLs',
|
|
dir: path.join(__dirname, '..', 'SKILLs'),
|
|
prefix: 'SKILLs',
|
|
},
|
|
{
|
|
label: 'Python runtime',
|
|
dir: path.join(__dirname, '..', 'resources', 'python-win'),
|
|
prefix: 'python-win',
|
|
},
|
|
];
|
|
|
|
console.log(`[electron-builder-hooks] Packing combined Windows tar: ${outputTar}`);
|
|
const t0 = Date.now();
|
|
|
|
// Remove old tar if exists
|
|
if (existsSync(outputTar)) rmSync(outputTar);
|
|
|
|
const { totalFiles, skipped } = packMultipleSources(sources, outputTar);
|
|
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
|
const sizeMB = (statSync(outputTar).size / (1024 * 1024)).toFixed(1);
|
|
console.log(
|
|
`[electron-builder-hooks] Combined tar packed in ${elapsed}s: `
|
|
+ `${totalFiles} files, ${skipped} skipped, ${sizeMB} MB`
|
|
);
|
|
}
|
|
|
|
if (!isWindowsTarget(context)) {
|
|
return;
|
|
}
|
|
|
|
console.log('[electron-builder-hooks] Windows target detected, ensuring portable Python runtime is prepared...');
|
|
await ensurePortablePythonRuntime({ required: true });
|
|
const runtimeRoot = path.join(__dirname, '..', 'resources', 'python-win');
|
|
const runtimeHealth = checkRuntimeHealth(runtimeRoot, { requirePip: true });
|
|
if (!runtimeHealth.ok) {
|
|
throw new Error(
|
|
'Portable Python runtime health check failed before pack. Missing files: '
|
|
+ runtimeHealth.missing.join(', ')
|
|
);
|
|
}
|
|
|
|
}
|
|
|
|
async function afterPack(context) {
|
|
if (isMacTarget(context)) {
|
|
const appName = context.packager.appInfo.productFilename;
|
|
const appPath = path.join(context.appOutDir, `${appName}.app`);
|
|
|
|
if (existsSync(appPath)) {
|
|
// Clean up broken symlinks before signing to prevent ENOENT errors
|
|
cleanupBrokenSymlinksInExtensions(appPath);
|
|
applyMacIconFix(appPath);
|
|
} else {
|
|
console.warn(`[electron-builder-hooks] App not found at ${appPath}, skipping icon fix`);
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
beforePack,
|
|
afterPack,
|
|
};
|