diff --git a/electron/utils/runtime-package-resolution.ts b/electron/utils/runtime-package-resolution.ts new file mode 100644 index 00000000..5a8ef475 --- /dev/null +++ b/electron/utils/runtime-package-resolution.ts @@ -0,0 +1,59 @@ +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { getOpenClawDir, getOpenClawResolvedDir } from './paths'; + +export type RuntimeModuleResolver = { + label: string; + resolve(specifier: string): string; +}; + +export function resolveModulePathWithFallbacks( + specifier: string, + resolvers: RuntimeModuleResolver[], +): string { + const errors: string[] = []; + + for (const resolver of resolvers) { + try { + return resolver.resolve(specifier); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + errors.push(`${resolver.label}: ${message}`); + } + } + + throw new Error( + `Failed to resolve "${specifier}" from any runtime context. ${errors.join(' | ')}`, + ); +} + +function getRuntimeModuleResolvers(): RuntimeModuleResolver[] { + const candidates: Array<{ label: string; base: string | URL }> = [ + { label: 'openclaw-resolved', base: join(getOpenClawResolvedDir(), 'package.json') }, + { label: 'openclaw', base: join(getOpenClawDir(), 'package.json') }, + { label: 'app', base: import.meta.url }, + ]; + + const seen = new Set(); + const resolvers: RuntimeModuleResolver[] = []; + + for (const candidate of candidates) { + const key = typeof candidate.base === 'string' ? candidate.base : candidate.base.toString(); + if (seen.has(key)) { + continue; + } + seen.add(key); + + const runtimeRequire = createRequire(candidate.base); + resolvers.push({ + label: candidate.label, + resolve: runtimeRequire.resolve.bind(runtimeRequire), + }); + } + + return resolvers; +} + +export function resolveOpenClawRuntimeModulePath(specifier: string): string { + return resolveModulePathWithFallbacks(specifier, getRuntimeModuleResolvers()); +} diff --git a/electron/utils/wechat-login.ts b/electron/utils/wechat-login.ts index 743b37ca..bfaff6e7 100644 --- a/electron/utils/wechat-login.ts +++ b/electron/utils/wechat-login.ts @@ -6,7 +6,7 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { deflateSync } from 'node:zlib'; import { normalizeOpenClawAccountId } from './channel-alias'; -import { getOpenClawResolvedDir } from './paths'; +import { resolveOpenClawRuntimeModulePath } from './runtime-package-resolution'; export const DEFAULT_WECHAT_BASE_URL = 'https://ilinkai.weixin.qq.com'; const DEFAULT_ILINK_BOT_TYPE = '3'; @@ -43,9 +43,10 @@ function getQrRenderDeps(): QrRenderDeps { return qrRenderDeps; } - const openclawRequire = createRequire(join(getOpenClawResolvedDir(), 'package.json')); - const qrCodeModulePath = openclawRequire.resolve('qrcode-terminal/vendor/QRCode/index.js'); - const qrErrorCorrectLevelPath = openclawRequire.resolve('qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js'); + const qrCodeModulePath = resolveOpenClawRuntimeModulePath('qrcode-terminal/vendor/QRCode/index.js'); + const qrErrorCorrectLevelPath = resolveOpenClawRuntimeModulePath( + 'qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js', + ); qrRenderDeps = { QRCode: require(qrCodeModulePath), QRErrorCorrectLevel: require(qrErrorCorrectLevelPath), diff --git a/electron/utils/whatsapp-login.ts b/electron/utils/whatsapp-login.ts index cbe13752..3915928a 100644 --- a/electron/utils/whatsapp-login.ts +++ b/electron/utils/whatsapp-login.ts @@ -5,6 +5,7 @@ import { EventEmitter } from 'events'; import { existsSync, mkdirSync, rmSync, readdirSync } from 'fs'; import { deflateSync } from 'zlib'; import { getOpenClawDir, getOpenClawResolvedDir } from './paths'; +import { resolveOpenClawRuntimeModulePath } from './runtime-package-resolution'; const require = createRequire(import.meta.url); @@ -40,8 +41,6 @@ function resolveOpenClawPackageJson(packageName: string): string { } const baileysPath = dirname(resolveOpenClawPackageJson('@whiskeysockets/baileys')); -const qrCodeModulePath = openclawRequire.resolve('qrcode-terminal/vendor/QRCode/index.js'); -const qrErrorCorrectLevelPath = openclawRequire.resolve('qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js'); // Load Baileys dependencies dynamically const { @@ -51,10 +50,6 @@ const { fetchLatestBaileysVersion } = require(baileysPath); -// Load QRCode dependencies dynamically -const QRCodeModule = require(qrCodeModulePath); -const QRErrorCorrectLevelModule = require(qrErrorCorrectLevelPath); - // Types from Baileys (approximate since we don't have types for dynamic require) interface BaileysError extends Error { output?: { statusCode?: number }; @@ -68,12 +63,43 @@ type ConnectionState = { qr?: string; }; +type QrCodeMatrix = { + addData(input: string): void; + make(): void; + getModuleCount(): number; + isDark(row: number, col: number): boolean; +}; +type QrCodeConstructor = new (typeNumber: number, errorCorrectionLevel: unknown) => QrCodeMatrix; +type QrErrorCorrectLevelModule = { + L: unknown; +}; +type QrRenderDeps = { + QRCode: QrCodeConstructor; + QRErrorCorrectLevel: QrErrorCorrectLevelModule; +}; + +let qrRenderDeps: QrRenderDeps | null = null; + +function getQrRenderDeps(): QrRenderDeps { + if (qrRenderDeps) { + return qrRenderDeps; + } + + const qrCodeModulePath = resolveOpenClawRuntimeModulePath('qrcode-terminal/vendor/QRCode/index.js'); + const qrErrorCorrectLevelPath = resolveOpenClawRuntimeModulePath( + 'qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js', + ); + qrRenderDeps = { + QRCode: require(qrCodeModulePath), + QRErrorCorrectLevel: require(qrErrorCorrectLevelPath), + }; + return qrRenderDeps; +} + // --- QR Generation Logic (Adapted from OpenClaw) --- -const QRCode = QRCodeModule; -const QRErrorCorrectLevel = QRErrorCorrectLevelModule; - function createQrMatrix(input: string) { + const { QRCode, QRErrorCorrectLevel } = getQrRenderDeps(); const qr = new QRCode(-1, QRErrorCorrectLevel.L); qr.addData(input); qr.make(); diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs index e3184e5a..59abadbc 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -615,11 +615,12 @@ exports.default = async function afterPack(context) { const srcNM = join(buildExtDir, extEntry.name, 'node_modules'); if (!existsSync(srcNM)) continue; - // Copy to extension's own node_modules (for direct requires from extension code) + // Copy to extension's own node_modules (for direct requires from extension code). + // electron-builder may leave behind an empty destination directory when it + // skips node_modules content via .gitignore filtering, so always replace it. const destExtNM = join(packExtDir, extEntry.name, 'node_modules'); - if (!existsSync(destExtNM)) { - cpSync(srcNM, destExtNM, { recursive: true }); - } + rmSync(destExtNM, { recursive: true, force: true }); + cpSync(srcNM, destExtNM, { recursive: true }); extNMCount++; // Merge into top-level openclaw/node_modules/ (for shared chunks in dist/) diff --git a/scripts/bundle-openclaw.mjs b/scripts/bundle-openclaw.mjs index bb176835..e39d0643 100644 --- a/scripts/bundle-openclaw.mjs +++ b/scripts/bundle-openclaw.mjs @@ -17,6 +17,7 @@ */ import 'zx/globals'; +import { EXTRA_BUNDLED_PACKAGES } from './openclaw-bundle-config.mjs'; const ROOT = path.resolve(__dirname, '..'); const OUTPUT = path.join(ROOT, 'build', 'openclaw'); @@ -41,6 +42,20 @@ if (!fs.existsSync(openclawLink)) { const openclawReal = fs.realpathSync(openclawLink); echo` openclaw resolved: ${openclawReal}`; +function shouldCopyOpenClawPackageEntry(src) { + const rel = path.relative(openclawReal, src); + if (!rel || rel.startsWith('..')) return true; + const parts = rel.split(path.sep); + + for (let i = 0; i < parts.length - 1; i += 1) { + if (parts[i] === 'node_modules' && parts[i + 1] === '.bin') { + return false; + } + } + + return true; +} + // 2. Clean and create output directory if (fs.existsSync(OUTPUT)) { fs.rmSync(OUTPUT, { recursive: true }); @@ -49,7 +64,11 @@ 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 }); +fs.cpSync(openclawReal, OUTPUT, { + recursive: true, + dereference: true, + filter: shouldCopyOpenClawPackageEntry, +}); // 4. Recursively collect ALL transitive dependencies via pnpm virtual store BFS // @@ -186,10 +205,6 @@ echo` Skipped ${skippedDevCount} dev-only package references`; // // For each package we resolve it from the workspace's own node_modules, // then BFS its transitive deps exactly like we did for openclaw above. -const EXTRA_BUNDLED_PACKAGES = [ - '@whiskeysockets/baileys', // WhatsApp channel (was a dep of old clawdbot, not openclaw) -]; - let extraCount = 0; for (const pkgName of EXTRA_BUNDLED_PACKAGES) { const pkgLink = path.join(NODE_MODULES, ...pkgName.split('/')); @@ -248,7 +263,31 @@ const copiedNames = new Set(); // Track package names already copied let copiedCount = 0; let skippedDupes = 0; -for (const [realPath, pkgName] of collected) { +const preferredBundledPackages = new Set(EXTRA_BUNDLED_PACKAGES); +const preferredBundledPackageRealPaths = new Set(); +for (const pkgName of EXTRA_BUNDLED_PACKAGES) { + const pkgLink = path.join(NODE_MODULES, ...pkgName.split('/')); + if (!fs.existsSync(pkgLink)) continue; + try { + preferredBundledPackageRealPaths.add(fs.realpathSync(pkgLink)); + } catch { + // ignore + } +} + +const collectedEntries = [...collected].sort(([leftRealPath, leftName], [rightRealPath, rightName]) => { + const leftPreferredRealPath = preferredBundledPackageRealPaths.has(leftRealPath); + const rightPreferredRealPath = preferredBundledPackageRealPaths.has(rightRealPath); + if (leftPreferredRealPath !== rightPreferredRealPath) return leftPreferredRealPath ? -1 : 1; + + const leftPreferred = preferredBundledPackages.has(leftName); + const rightPreferred = preferredBundledPackages.has(rightName); + if (leftPreferred !== rightPreferred) return leftPreferred ? -1 : 1; + + return 0; +}); + +for (const [realPath, pkgName] of collectedEntries) { if (copiedNames.has(pkgName)) { skippedDupes++; continue; // Keep the first version (closer to openclaw in dep tree) @@ -280,50 +319,106 @@ for (const [realPath, pkgName] of collected) { // resolvable from shared chunks. Skip-if-exists preserves version priority // (openclaw's own deps take precedence over extension deps). const extensionsDir = path.join(OUTPUT, 'dist', 'extensions'); +function readJsonSafe(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch { + return null; + } +} + +function listPackageDeps(pkgJson) { + return Object.keys({ + ...(pkgJson?.dependencies && typeof pkgJson.dependencies === 'object' ? pkgJson.dependencies : {}), + ...(pkgJson?.optionalDependencies && typeof pkgJson.optionalDependencies === 'object' ? pkgJson.optionalDependencies : {}), + }).sort((a, b) => a.localeCompare(b)); +} + let mergedExtCount = 0; +let mirroredExtRuntimeDeps = 0; if (fs.existsSync(extensionsDir)) { for (const extEntry of fs.readdirSync(extensionsDir, { withFileTypes: true })) { if (!extEntry.isDirectory()) continue; - const extNM = path.join(extensionsDir, extEntry.name, 'node_modules'); - if (!fs.existsSync(extNM)) continue; + const extRoot = path.join(extensionsDir, extEntry.name); + const extNM = path.join(extRoot, 'node_modules'); - for (const pkgEntry of fs.readdirSync(extNM, { withFileTypes: true })) { - if (!pkgEntry.isDirectory() || pkgEntry.name === '.bin') continue; - const srcPkg = path.join(extNM, pkgEntry.name); + if (fs.existsSync(extNM)) { + for (const pkgEntry of fs.readdirSync(extNM, { withFileTypes: true })) { + if (!pkgEntry.isDirectory() || pkgEntry.name === '.bin') continue; + const srcPkg = path.join(extNM, pkgEntry.name); - if (pkgEntry.name.startsWith('@')) { - // Scoped package — iterate sub-entries - let scopeEntries; - try { scopeEntries = fs.readdirSync(srcPkg, { withFileTypes: true }); } catch { continue; } - for (const scopeEntry of scopeEntries) { - if (!scopeEntry.isDirectory()) continue; - const scopedName = `${pkgEntry.name}/${scopeEntry.name}`; - if (copiedNames.has(scopedName)) continue; - const srcScoped = path.join(srcPkg, scopeEntry.name); - const destScoped = path.join(outputNodeModules, pkgEntry.name, scopeEntry.name); + if (pkgEntry.name.startsWith('@')) { + // Scoped package — iterate sub-entries + let scopeEntries; + try { scopeEntries = fs.readdirSync(srcPkg, { withFileTypes: true }); } catch { continue; } + for (const scopeEntry of scopeEntries) { + if (!scopeEntry.isDirectory()) continue; + const scopedName = `${pkgEntry.name}/${scopeEntry.name}`; + if (copiedNames.has(scopedName)) continue; + const srcScoped = path.join(srcPkg, scopeEntry.name); + const destScoped = path.join(outputNodeModules, pkgEntry.name, scopeEntry.name); + try { + fs.mkdirSync(normWin(path.dirname(destScoped)), { recursive: true }); + fs.cpSync(normWin(srcScoped), normWin(destScoped), { recursive: true, dereference: true }); + copiedNames.add(scopedName); + mergedExtCount++; + } catch { /* skip on copy error */ } + } + } else { + if (copiedNames.has(pkgEntry.name)) continue; + const destPkg = path.join(outputNodeModules, pkgEntry.name); try { - fs.mkdirSync(normWin(path.dirname(destScoped)), { recursive: true }); - fs.cpSync(normWin(srcScoped), normWin(destScoped), { recursive: true, dereference: true }); - copiedNames.add(scopedName); + fs.cpSync(normWin(srcPkg), normWin(destPkg), { recursive: true, dereference: true }); + copiedNames.add(pkgEntry.name); mergedExtCount++; } catch { /* skip on copy error */ } } - } else { - if (copiedNames.has(pkgEntry.name)) continue; - const destPkg = path.join(outputNodeModules, pkgEntry.name); - try { - fs.cpSync(normWin(srcPkg), normWin(destPkg), { recursive: true, dereference: true }); - copiedNames.add(pkgEntry.name); - mergedExtCount++; - } catch { /* skip on copy error */ } } } + + const extPkg = readJsonSafe(path.join(extRoot, 'package.json')); + for (const depName of listPackageDeps(extPkg)) { + const srcPkg = path.join(outputNodeModules, ...depName.split('/')); + const destPkg = path.join(extNM, ...depName.split('/')); + if (!fs.existsSync(srcPkg) || fs.existsSync(destPkg)) continue; + try { + fs.mkdirSync(normWin(path.dirname(destPkg)), { recursive: true }); + fs.cpSync(normWin(srcPkg), normWin(destPkg), { recursive: true, dereference: true }); + mirroredExtRuntimeDeps++; + } catch { /* skip on copy error */ } + } } } if (mergedExtCount > 0) { echo` Merged ${mergedExtCount} extension packages into top-level node_modules`; } +if (mirroredExtRuntimeDeps > 0) { + echo` Mirrored ${mirroredExtRuntimeDeps} extension runtime deps into dist/extensions/*/node_modules`; +} + +function patchBundledExtensionPackageJsons(extensionsRoot) { + let patchedCount = 0; + + const discordPkgPath = path.join(extensionsRoot, 'discord', 'package.json'); + if (fs.existsSync(discordPkgPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(discordPkgPath, 'utf8')); + if (pkg?.dependencies?.opusscript === '^0.0.8') { + pkg.dependencies.opusscript = '^0.1.1'; + fs.writeFileSync(discordPkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8'); + patchedCount++; + echo` 🩹 Patched discord bundled runtime dep range: opusscript ^0.0.8 -> ^0.1.1`; + } + } catch { + // ignore + } + } + + return patchedCount; +} + +patchBundledExtensionPackageJsons(extensionsDir); // 6. Clean up the bundle to reduce package size // diff --git a/scripts/openclaw-bundle-config.mjs b/scripts/openclaw-bundle-config.mjs new file mode 100644 index 00000000..90dadf88 --- /dev/null +++ b/scripts/openclaw-bundle-config.mjs @@ -0,0 +1,23 @@ +export const EXTRA_BUNDLED_PACKAGES = [ + '@whiskeysockets/baileys', + + // Built-in channel/runtime extension deps that are not always pulled in by the + // OpenClaw package's own transitive dependency graph, but are required in + // packaged builds when dist/extensions//*.js resolves bare imports + // from resources/openclaw/node_modules. + '@larksuiteoapi/node-sdk', + '@grammyjs/runner', + '@grammyjs/transformer-throttler', + 'grammy', + '@buape/carbon', + '@discordjs/voice', + 'discord-api-types', + 'opusscript', + '@tencent-connect/qqbot-connector', + 'mpg123-decoder', + 'silk-wasm', + + // Electron main process QR login flows resolve these files from the + // bundled OpenClaw runtime context in packaged builds. + 'qrcode-terminal', +]; diff --git a/tests/unit/openclaw-bundle-config.test.ts b/tests/unit/openclaw-bundle-config.test.ts new file mode 100644 index 00000000..9a64c4b2 --- /dev/null +++ b/tests/unit/openclaw-bundle-config.test.ts @@ -0,0 +1,24 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; + +describe('openclaw bundle config', () => { + it('includes Electron runtime-only packages needed in packaged builds', async () => { + const { EXTRA_BUNDLED_PACKAGES } = await import('../../scripts/openclaw-bundle-config.mjs'); + + expect(EXTRA_BUNDLED_PACKAGES).toEqual(expect.arrayContaining([ + '@whiskeysockets/baileys', + '@larksuiteoapi/node-sdk', + '@grammyjs/runner', + '@grammyjs/transformer-throttler', + 'grammy', + '@buape/carbon', + '@discordjs/voice', + 'discord-api-types', + 'opusscript', + '@tencent-connect/qqbot-connector', + 'mpg123-decoder', + 'silk-wasm', + 'qrcode-terminal', + ])); + }); +}); diff --git a/tests/unit/runtime-package-resolution.test.ts b/tests/unit/runtime-package-resolution.test.ts new file mode 100644 index 00000000..ad4401b0 --- /dev/null +++ b/tests/unit/runtime-package-resolution.test.ts @@ -0,0 +1,30 @@ +// @vitest-environment node +import { describe, expect, it, vi } from 'vitest'; +import { resolveModulePathWithFallbacks } from '@electron/utils/runtime-package-resolution'; + +describe('runtime package resolution', () => { + it('returns the first successful resolver result', () => { + const openclawResolve = vi.fn(() => { + throw new Error('missing from openclaw'); + }); + const appResolve = vi.fn(() => '/tmp/app/node_modules/qrcode-terminal/vendor/QRCode/index.js'); + + const resolved = resolveModulePathWithFallbacks('qrcode-terminal/vendor/QRCode/index.js', [ + { label: 'openclaw', resolve: openclawResolve }, + { label: 'app', resolve: appResolve }, + ]); + + expect(resolved).toBe('/tmp/app/node_modules/qrcode-terminal/vendor/QRCode/index.js'); + expect(openclawResolve).toHaveBeenCalledOnce(); + expect(appResolve).toHaveBeenCalledOnce(); + }); + + it('surfaces all resolver failures in the final error', () => { + expect(() => resolveModulePathWithFallbacks('qrcode-terminal/vendor/QRCode/index.js', [ + { label: 'openclaw', resolve: () => { throw new Error('not bundled'); } }, + { label: 'app', resolve: () => { throw new Error('not packaged'); } }, + ])).toThrow( + 'openclaw: not bundled | app: not packaged', + ); + }); +});