diff --git a/electron-builder.yml b/electron-builder.yml index d0e818928..0e3eda8b3 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -26,9 +26,9 @@ extraResources: # because electron-builder respects .gitignore which excludes node_modules/) - from: build/openclaw/ to: openclaw/ - # Bundled OpenClaw plugin mirrors (dingtalk, etc.) - - from: build/openclaw-plugins/ - to: openclaw-plugins/ + # NOTE: OpenClaw plugin mirrors (dingtalk, etc.) are bundled by the + # afterPack hook (after-pack.cjs) directly from node_modules, so they + # don't need an extraResources entry here. afterPack: ./scripts/after-pack.cjs diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index d17b088b5..2866384ad 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -627,9 +627,10 @@ function registerOpenClawHandlers(gatewayManager: GatewayManager): void { const sourceDir = candidateSources.find((dir) => existsSync(join(dir, 'openclaw.plugin.json'))); if (!sourceDir) { + logger.warn('Bundled DingTalk plugin mirror not found in candidate paths', { candidateSources }); return { installed: false, - warning: 'Bundled DingTalk plugin mirror not found. Run: pnpm run bundle:openclaw-plugins', + warning: `Bundled DingTalk plugin mirror not found. Checked: ${candidateSources.join(' | ')}`, }; } @@ -718,7 +719,8 @@ function registerOpenClawHandlers(gatewayManager: GatewayManager): void { error: installResult.warning || 'DingTalk plugin install failed', }; } - saveChannelConfig(channelType, config); + await saveChannelConfig(channelType, config); + gatewayManager.debouncedRestart(); return { success: true, pluginInstalled: installResult.installed, diff --git a/package.json b/package.json index 3a7957f4f..4825c1642 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "uv:download:linux": "zx scripts/download-bundled-uv.mjs --platform=linux", "uv:download:all": "zx scripts/download-bundled-uv.mjs --all", "icons": "zx scripts/generate-icons.mjs", - "package": "electron-builder", + "package": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && electron-builder", "package:mac": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && electron-builder --mac", "package:win": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && electron-builder --win", "package:linux": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && electron-builder --linux", diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs index 66ba2d625..3244d1cec 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -19,8 +19,18 @@ * @mariozechner/clipboard). */ -const { cpSync, existsSync, readdirSync, rmSync, statSync } = require('fs'); -const { join } = require('path'); +const { cpSync, existsSync, readdirSync, rmSync, statSync, mkdirSync, realpathSync } = require('fs'); +const { join, dirname, basename } = require('path'); + +// On Windows, paths in pnpm's virtual store can exceed the default MAX_PATH +// limit (260 chars). Node.js 18.17+ respects the system LongPathsEnabled +// registry key, but as a safety net we normalize paths to use the \\?\ prefix +// on Windows, which bypasses the limit unconditionally. +function normWin(p) { + if (process.platform !== 'win32') return p; + if (p.startsWith('\\\\?\\')) return p; + return '\\\\?\\' + p.replace(/\//g, '\\'); +} // ── Arch helpers ───────────────────────────────────────────────────────────── // electron-builder Arch enum: 0=ia32, 1=x64, 2=armv7l, 3=arm64, 4=universal @@ -128,6 +138,119 @@ function cleanupNativePlatformPackages(nodeModulesDir, platform, arch) { return removed; } +// ── Plugin bundler ─────────────────────────────────────────────────────────── +// Bundles a single OpenClaw plugin (and its transitive deps) from node_modules +// directly into the packaged resources directory. Mirrors the logic in +// bundle-openclaw-plugins.mjs so the packaged app is self-contained even when +// build/openclaw-plugins/ was not pre-generated. + +function getVirtualStoreNodeModules(realPkgPath) { + let dir = realPkgPath; + while (dir !== dirname(dir)) { + if (basename(dir) === 'node_modules') return dir; + dir = dirname(dir); + } + return null; +} + +function listPkgs(nodeModulesDir) { + const result = []; + const nDir = normWin(nodeModulesDir); + if (!existsSync(nDir)) return result; + for (const entry of readdirSync(nDir)) { + if (entry === '.bin') continue; + // Use original (non-normWin) join for the logical path stored in result.fullPath, + // so callers can still call getVirtualStoreNodeModules() on it correctly. + const fullPath = join(nodeModulesDir, entry); + if (entry.startsWith('@')) { + let subs; + try { subs = readdirSync(normWin(fullPath)); } catch { continue; } + for (const sub of subs) { + result.push({ name: `${entry}/${sub}`, fullPath: join(fullPath, sub) }); + } + } else { + result.push({ name: entry, fullPath }); + } + } + return result; +} + +function bundlePlugin(nodeModulesRoot, npmName, destDir) { + const pkgPath = join(nodeModulesRoot, ...npmName.split('/')); + if (!existsSync(pkgPath)) { + console.warn(`[after-pack] ⚠️ Plugin package not found: ${pkgPath}. Run pnpm install.`); + return false; + } + + let realPluginPath; + try { realPluginPath = realpathSync(normWin(pkgPath)); } catch { realPluginPath = pkgPath; } + + // Copy plugin package itself + if (existsSync(normWin(destDir))) rmSync(normWin(destDir), { recursive: true, force: true }); + mkdirSync(normWin(destDir), { recursive: true }); + cpSync(normWin(realPluginPath), normWin(destDir), { recursive: true, dereference: true }); + + // Collect transitive deps via pnpm virtual store BFS + const collected = new Map(); + const queue = []; + + const rootVirtualNM = getVirtualStoreNodeModules(realPluginPath); + if (!rootVirtualNM) { + console.warn(`[after-pack] ⚠️ Could not find virtual store for ${npmName}, skipping deps.`); + return true; + } + queue.push({ nodeModulesDir: rootVirtualNM, skipPkg: npmName }); + + // Read peerDependencies from the plugin's package.json so we don't bundle + // packages that are provided by the host environment (e.g. openclaw itself). + const SKIP_PACKAGES = new Set(['typescript', '@playwright/test']); + const SKIP_SCOPES = ['@types/']; + try { + const pluginPkg = JSON.parse( + require('fs').readFileSync(join(destDir, 'package.json'), 'utf8') + ); + for (const peer of Object.keys(pluginPkg.peerDependencies || {})) { + SKIP_PACKAGES.add(peer); + } + } catch { /* ignore */ } + + while (queue.length > 0) { + const { nodeModulesDir, skipPkg } = queue.shift(); + for (const { name, fullPath } of listPkgs(nodeModulesDir)) { + if (name === skipPkg) continue; + if (SKIP_PACKAGES.has(name) || SKIP_SCOPES.some(s => name.startsWith(s))) continue; + let rp; + try { rp = realpathSync(normWin(fullPath)); } catch { continue; } + if (collected.has(rp)) continue; + collected.set(rp, name); + const depVirtualNM = getVirtualStoreNodeModules(rp); + if (depVirtualNM && depVirtualNM !== nodeModulesDir) { + queue.push({ nodeModulesDir: depVirtualNM, skipPkg: name }); + } + } + } + + // Copy flattened deps into destDir/node_modules + const destNM = join(destDir, 'node_modules'); + mkdirSync(destNM, { recursive: true }); + const copiedNames = new Set(); + let count = 0; + for (const [rp, pkgName] of collected) { + if (copiedNames.has(pkgName)) continue; + copiedNames.add(pkgName); + const d = join(destNM, pkgName); + try { + mkdirSync(normWin(dirname(d)), { recursive: true }); + cpSync(normWin(rp), normWin(d), { recursive: true, dereference: true }); + count++; + } catch (e) { + console.warn(`[after-pack] Skipped dep ${pkgName}: ${e.message}`); + } + } + console.log(`[after-pack] ✅ Plugin ${npmName}: copied ${count} deps to ${destDir}`); + return true; +} + // ── Main hook ──────────────────────────────────────────────────────────────── exports.default = async function afterPack(context) { @@ -149,7 +272,7 @@ exports.default = async function afterPack(context) { const openclawRoot = join(resourcesDir, 'openclaw'); const dest = join(openclawRoot, 'node_modules'); - const pluginsSrcRoot = join(__dirname, '..', 'build', 'openclaw-plugins'); + const nodeModulesRoot = join(__dirname, '..', 'node_modules'); const pluginsDestRoot = join(resourcesDir, 'openclaw-plugins'); if (!existsSync(src)) { @@ -166,27 +289,28 @@ exports.default = async function afterPack(context) { cpSync(src, dest, { recursive: true }); console.log('[after-pack] ✅ openclaw node_modules copied.'); - // 1.1 Copy plugin node_modules (also skipped due to .gitignore) - if (existsSync(pluginsSrcRoot) && existsSync(pluginsDestRoot)) { - const pluginDirs = readdirSync(pluginsSrcRoot, { withFileTypes: true }) - .filter((d) => d.isDirectory()) - .map((d) => d.name); + // 1.1 Bundle OpenClaw plugins directly from node_modules into packaged resources. + // This is intentionally done in afterPack (not extraResources) because: + // - electron-builder silently skips extraResources entries whose source + // directory doesn't exist (build/openclaw-plugins/ may not be pre-generated) + // - node_modules/ is excluded by .gitignore so the deps copy must be manual + const BUNDLED_PLUGINS = [ + { npmName: '@soimy/dingtalk', pluginId: 'dingtalk' }, + ]; - for (const pluginId of pluginDirs) { - const pluginSrcNM = join(pluginsSrcRoot, pluginId, 'node_modules'); - const pluginDestRoot = join(pluginsDestRoot, pluginId); - const pluginDestNM = join(pluginDestRoot, 'node_modules'); - if (!existsSync(pluginSrcNM) || !existsSync(pluginDestRoot)) continue; - - console.log(`[after-pack] Copying plugin deps for ${pluginId} -> ${pluginDestNM}`); - cpSync(pluginSrcNM, pluginDestNM, { recursive: true }); - - // Apply the same cleanup strategy for plugin bundles. - cleanupUnnecessaryFiles(pluginDestRoot); - cleanupKoffi(pluginDestNM, platform, arch); - cleanupNativePlatformPackages(pluginDestNM, platform, arch); + mkdirSync(pluginsDestRoot, { recursive: true }); + for (const { npmName, pluginId } of BUNDLED_PLUGINS) { + const pluginDestDir = join(pluginsDestRoot, pluginId); + console.log(`[after-pack] Bundling plugin ${npmName} -> ${pluginDestDir}`); + const ok = bundlePlugin(nodeModulesRoot, npmName, pluginDestDir); + if (ok) { + const pluginNM = join(pluginDestDir, 'node_modules'); + cleanupUnnecessaryFiles(pluginDestDir); + if (existsSync(pluginNM)) { + cleanupKoffi(pluginNM, platform, arch); + cleanupNativePlatformPackages(pluginNM, platform, arch); + } } - console.log('[after-pack] ✅ openclaw plugin node_modules copied.'); } // 2. General cleanup on the full openclaw directory (not just node_modules) diff --git a/scripts/bundle-openclaw-plugins.mjs b/scripts/bundle-openclaw-plugins.mjs index 0b92a828c..cecb658ce 100644 --- a/scripts/bundle-openclaw-plugins.mjs +++ b/scripts/bundle-openclaw-plugins.mjs @@ -20,6 +20,17 @@ const ROOT = path.resolve(__dirname, '..'); const OUTPUT_ROOT = path.join(ROOT, 'build', 'openclaw-plugins'); const NODE_MODULES = path.join(ROOT, 'node_modules'); +// On Windows, pnpm virtual store paths can exceed MAX_PATH (260 chars). +// Adding \\?\ prefix bypasses the limit for Win32 fs calls. +// Node.js 18.17+ also handles this transparently when LongPathsEnabled=1, +// but this is an extra safety net for build machines where the registry key +// may not be set yet. +function normWin(p) { + if (process.platform !== 'win32') return p; + if (p.startsWith('\\\\?\\')) return p; + return '\\\\?\\' + p.replace(/\//g, '\\'); +} + const PLUGINS = [ { npmName: '@soimy/dingtalk', pluginId: 'dingtalk' }, ]; @@ -35,18 +46,19 @@ function getVirtualStoreNodeModules(realPkgPath) { function listPackages(nodeModulesDir) { const result = []; - if (!fs.existsSync(nodeModulesDir)) return result; + const nDir = normWin(nodeModulesDir); + if (!fs.existsSync(nDir)) return result; - for (const entry of fs.readdirSync(nodeModulesDir)) { + for (const entry of fs.readdirSync(nDir)) { if (entry === '.bin') continue; + // Use original (non-normWin) path so callers can call + // getVirtualStoreNodeModules() on fullPath correctly. const entryPath = path.join(nodeModulesDir, entry); - const stat = fs.lstatSync(entryPath); if (entry.startsWith('@')) { - if (!(stat.isDirectory() || stat.isSymbolicLink())) continue; let scopeEntries = []; try { - scopeEntries = fs.readdirSync(entryPath); + scopeEntries = fs.readdirSync(normWin(entryPath)); } catch { continue; } @@ -69,7 +81,7 @@ function bundleOnePlugin({ npmName, pluginId }) { throw new Error(`Missing dependency "${npmName}". Run pnpm install first.`); } - const realPluginPath = fs.realpathSync(pkgPath); + const realPluginPath = fs.realpathSync(normWin(pkgPath)); const outputDir = path.join(OUTPUT_ROOT, pluginId); echo`📦 Bundling plugin ${npmName} -> ${outputDir}`; @@ -91,8 +103,15 @@ function bundleOnePlugin({ npmName, pluginId }) { } queue.push({ nodeModulesDir: rootVirtualNM, skipPkg: npmName }); + // Skip peerDependencies — they're provided by the host openclaw gateway. const SKIP_PACKAGES = new Set(['typescript', '@playwright/test']); const SKIP_SCOPES = ['@types/']; + try { + const pluginPkg = JSON.parse(fs.readFileSync(path.join(outputDir, 'package.json'), 'utf8')); + for (const peer of Object.keys(pluginPkg.peerDependencies || {})) { + SKIP_PACKAGES.add(peer); + } + } catch { /* ignore */ } while (queue.length > 0) { const { nodeModulesDir, skipPkg } = queue.shift(); @@ -102,7 +121,7 @@ function bundleOnePlugin({ npmName, pluginId }) { let realPath; try { - realPath = fs.realpathSync(fullPath); + realPath = fs.realpathSync(normWin(fullPath)); } catch { continue; } @@ -133,8 +152,8 @@ function bundleOnePlugin({ npmName, pluginId }) { const dest = path.join(outputNodeModules, pkgName); try { - fs.mkdirSync(path.dirname(dest), { recursive: true }); - fs.cpSync(realPath, dest, { recursive: true, dereference: true }); + fs.mkdirSync(normWin(path.dirname(dest)), { recursive: true }); + fs.cpSync(normWin(realPath), normWin(dest), { recursive: true, dereference: true }); copiedCount++; } catch (err) { echo` ⚠️ Skipped ${pkgName}: ${err.message}`; diff --git a/scripts/bundle-openclaw.mjs b/scripts/bundle-openclaw.mjs index 35230089f..5dfc90361 100644 --- a/scripts/bundle-openclaw.mjs +++ b/scripts/bundle-openclaw.mjs @@ -22,6 +22,13 @@ const ROOT = path.resolve(__dirname, '..'); const OUTPUT = path.join(ROOT, 'build', 'openclaw'); const NODE_MODULES = path.join(ROOT, 'node_modules'); +// On Windows, pnpm virtual store paths can exceed MAX_PATH (260 chars). +function normWin(p) { + if (process.platform !== 'win32') return p; + if (p.startsWith('\\\\?\\')) return p; + return '\\\\?\\' + p.replace(/\//g, '\\'); +} + echo`📦 Bundling openclaw for electron-builder...`; // 1. Resolve the real path of node_modules/openclaw (follows pnpm symlink) @@ -31,7 +38,7 @@ if (!fs.existsSync(openclawLink)) { process.exit(1); } -const openclawReal = fs.realpathSync(openclawLink); +const openclawReal = fs.realpathSync(normWin(openclawLink)); echo` openclaw resolved: ${openclawReal}`; // 2. Clean and create output directory @@ -85,30 +92,26 @@ function getVirtualStoreNodeModules(realPkgPath) { */ function listPackages(nodeModulesDir) { const result = []; - if (!fs.existsSync(nodeModulesDir)) return result; + const nDir = normWin(nodeModulesDir); + if (!fs.existsSync(nDir)) return result; - for (const entry of fs.readdirSync(nodeModulesDir)) { + for (const entry of fs.readdirSync(nDir)) { if (entry === '.bin') continue; - + // Use original (non-normWin) path so callers can call + // getVirtualStoreNodeModules() on fullPath correctly. 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 + try { + const scopeEntries = fs.readdirSync(normWin(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 }); @@ -149,7 +152,7 @@ while (queue.length > 0) { let realPath; try { - realPath = fs.realpathSync(fullPath); + realPath = fs.realpathSync(normWin(fullPath)); } catch { continue; // broken symlink, skip } @@ -194,9 +197,8 @@ for (const [realPath, pkgName] of collected) { 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 }); + fs.mkdirSync(normWin(path.dirname(dest)), { recursive: true }); + fs.cpSync(normWin(realPath), normWin(dest), { recursive: true, dereference: true }); copiedCount++; } catch (err) { echo` ⚠️ Skipped ${pkgName}: ${err.message}`; diff --git a/scripts/installer.nsh b/scripts/installer.nsh index c646e46f0..c40d76c5b 100644 --- a/scripts/installer.nsh +++ b/scripts/installer.nsh @@ -1,7 +1,14 @@ -; ClawX Custom NSIS Uninstaller Script -; Provides a "Complete Removal" option during uninstallation -; to delete .openclaw config and AppData resources. -; Handles both per-user and per-machine (all users) installations. +; ClawX Custom NSIS Installer/Uninstaller Script + +!macro customInstall + ; Enable Windows long path support for all-user (admin) installs. + ; pnpm virtual store and node_modules paths can exceed the default 260-char + ; MAX_PATH limit on Windows. This registry key enables the modern NTFS + ; long-path behavior on Windows 10 1607+ / Windows 11. + ${If} $MultiUser.InstallMode == "AllUsers" + WriteRegDWORD HKLM "SYSTEM\CurrentControlSet\Control\FileSystem" "LongPathsEnabled" 1 + ${EndIf} +!macroend !macro customUnInstall ; Ask user if they want to completely remove all user data diff --git a/src/pages/Channels/index.tsx b/src/pages/Channels/index.tsx index f47fc6c15..b63162286 100644 --- a/src/pages/Channels/index.tsx +++ b/src/pages/Channels/index.tsx @@ -572,11 +572,12 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded const config: Record = { ...configValues }; const saveResult = await window.electron.ipcRenderer.invoke('channel:saveConfig', selectedType, config) as { success?: boolean; + error?: string; warning?: string; pluginInstalled?: boolean; }; if (!saveResult?.success) { - throw new Error('Failed to save channel config'); + throw new Error(saveResult?.error || 'Failed to save channel config'); } if (typeof saveResult.warning === 'string' && saveResult.warning) { toast.warning(saveResult.warning);