diff --git a/config/before-sign.cjs b/config/before-sign.cjs new file mode 100644 index 00000000..3863cb58 --- /dev/null +++ b/config/before-sign.cjs @@ -0,0 +1,98 @@ +const path = require('path'); +const fs = require('fs'); + +/** + * After pack hook - clean invalid symlinks after packing, before signing + */ +exports.default = async function afterPack(context) { + if (process.platform !== 'darwin') { + return; + } + + const appOutDir = context.appOutDir; + const appName = context.packager.appInfo.productName; + const appPath = path.join(appOutDir, `${appName}.app`); + + if (!fs.existsSync(appPath)) { + console.log('App bundle not found, skipping symlink cleanup'); + return; + } + + console.log('🧹 Cleaning invalid symlinks before signing...'); + + const resourcesPath = path.join(appPath, 'Contents', 'Resources'); + const prebuiltPath = path.join(resourcesPath, 'prebuilt'); + + if (!fs.existsSync(prebuiltPath)) { + return; + } + + // Clean Python symlinks in venv/bin + const venvBinDir = path.join(prebuiltPath, 'venv', 'bin'); + if (fs.existsSync(venvBinDir)) { + const pythonNames = ['python', 'python3', 'python3.10', 'python3.11', 'python3.12']; + const bundlePath = path.resolve(appPath); + + for (const pythonName of pythonNames) { + const pythonSymlink = path.join(venvBinDir, pythonName); + + if (fs.existsSync(pythonSymlink)) { + try { + const stats = fs.lstatSync(pythonSymlink); + if (stats.isSymbolicLink()) { + const target = fs.readlinkSync(pythonSymlink); + const resolvedPath = path.resolve(path.dirname(pythonSymlink), target); + + // If symlink points outside bundle, remove it + if (!resolvedPath.startsWith(bundlePath)) { + console.log(`Removing invalid ${pythonName} symlink: ${target}`); + fs.unlinkSync(pythonSymlink); + } + } + } catch (error) { + console.warn(`Warning: Could not process ${pythonName} symlink: ${error.message}`); + } + } + } + } + + // Recursively clean other invalid symlinks + function cleanSymlinks(dir, bundleRoot) { + if (!fs.existsSync(dir)) { + return; + } + + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + try { + if (entry.isSymbolicLink()) { + const target = fs.readlinkSync(fullPath); + const resolvedPath = path.resolve(path.dirname(fullPath), target); + const bundlePath = path.resolve(bundleRoot); + + if (!fs.existsSync(resolvedPath) || !resolvedPath.startsWith(bundlePath)) { + console.log(`Removing invalid symlink: ${fullPath} -> ${target}`); + fs.unlinkSync(fullPath); + } + } else if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === '__pycache__') { + continue; + } + cleanSymlinks(fullPath, bundleRoot); + } + } catch (error) { + // Ignore errors + } + } + } catch (error) { + // Ignore errors + } + } + + cleanSymlinks(prebuiltPath, appPath); + console.log('✅ Symlink cleanup completed'); +}; diff --git a/electron-builder.json b/electron-builder.json index dc8bc03f..ca6c46b7 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -47,6 +47,7 @@ } ], "icon": "build/icon.ico", + "afterPack": "./config/before-sign.cjs", "afterSign": "./config/notarize.cjs", "mac": { "icon": "build/icon.icns", diff --git a/package.json b/package.json index 03ef78b6..d3ae994b 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,10 @@ "dev": "npm run clean-cache && vite", "preinstall-deps": "node scripts/preinstall-deps.js", "clean-symlinks": "node scripts/clean-symlinks.js", + "test-signing": "node scripts/test-signing.js", "build": "npm run preinstall-deps && npm run clean-symlinks && npm run compile-babel && tsc && vite build && electron-builder -- --publish always", - "build:mac": "npm run preinstall-deps && npm run compile-babel && tsc && vite build && electron-builder --mac", + "build:mac": "npm run preinstall-deps && npm run clean-symlinks && npm run compile-babel && tsc && vite build && electron-builder --mac", + "build:mac:test": "npm run preinstall-deps && npm run clean-symlinks && npm run compile-babel && tsc && vite build && electron-builder --mac && npm run test-signing", "build:win": "npm run preinstall-deps && npm run compile-babel && tsc && vite build && electron-builder --win", "build:all": "npm run preinstall-deps && npm run compile-babel && tsc && vite build && electron-builder --mac --win", "preview": "vite preview", diff --git a/scripts/clean-symlinks.js b/scripts/clean-symlinks.js index 49245bde..e011ba45 100644 --- a/scripts/clean-symlinks.js +++ b/scripts/clean-symlinks.js @@ -45,6 +45,41 @@ function isValidSymlink(symlinkPath, bundleRoot) { } } +/** + * Fix Python symlinks in venv/bin + * Remove symlinks that point outside the bundle (to cache directory) + */ +function fixPythonSymlinks(venvBinDir, bundleRoot) { + if (!fs.existsSync(venvBinDir)) { + return; + } + + const bundlePath = path.resolve(bundleRoot); + const pythonNames = ['python', 'python3', 'python3.10', 'python3.11', 'python3.12']; + + for (const pythonName of pythonNames) { + const pythonSymlink = path.join(venvBinDir, pythonName); + + if (fs.existsSync(pythonSymlink)) { + try { + const stats = fs.lstatSync(pythonSymlink); + if (stats.isSymbolicLink()) { + const target = fs.readlinkSync(pythonSymlink); + const resolvedPath = path.resolve(path.dirname(pythonSymlink), target); + + // If symlink points outside bundle (especially to cache), remove it + if (!resolvedPath.startsWith(bundlePath)) { + console.log(`Removing invalid ${pythonName} symlink pointing to: ${target}`); + fs.unlinkSync(pythonSymlink); + } + } + } catch (error) { + console.warn(`Warning: Could not process ${pythonName} symlink: ${error.message}`); + } + } + } +} + /** * Remove invalid symlinks recursively */ @@ -92,6 +127,14 @@ function main() { console.log('🧹 Cleaning invalid symbolic links...'); const bundleRoot = path.join(projectRoot, 'resources', 'prebuilt'); + const venvBinDir = path.join(bundleRoot, 'venv', 'bin'); + + // First, try to fix Python symlinks specifically + if (fs.existsSync(venvBinDir)) { + fixPythonSymlinks(venvBinDir, bundleRoot); + } + + // Then clean all other invalid symlinks const removed = cleanSymlinks(bundleRoot, bundleRoot); if (removed.length > 0) { diff --git a/scripts/preinstall-deps.js b/scripts/preinstall-deps.js index 23039e7f..4862b1bb 100644 --- a/scripts/preinstall-deps.js +++ b/scripts/preinstall-deps.js @@ -30,7 +30,7 @@ fs.mkdirSync(BIN_DIR, { recursive: true }); fs.mkdirSync(VENV_DIR, { recursive: true }); /** - * 验证下载的文件是否是有效的 ZIP 文件 + * Validate if the downloaded file is a valid ZIP file */ function isValidZip(filePath) { try { @@ -46,7 +46,7 @@ function isValidZip(filePath) { } /** - * 验证下载的文件是否是有效的 tar.gz 文件 + * Validate if the downloaded file is a valid tar.gz file */ function isValidTarGz(filePath) { try { @@ -60,7 +60,7 @@ function isValidTarGz(filePath) { } /** - * 下载文件并验证完整性 + * Download file and validate integrity */ async function downloadFileWithValidation(urlsToTry, dest, validateFn, fileType = 'file') { const maxRetries = 2; @@ -74,106 +74,93 @@ async function downloadFileWithValidation(urlsToTry, dest, validateFn, fileType await new Promise((resolve, reject) => { const protocol = url.startsWith('https') ? https : http; const timeout = 180000; // 3 minutes + let redirectCount = 0; + const maxRedirects = 10; - const request = protocol.get(url, { - timeout: timeout, - headers: { - 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36' - } - }, (response) => { - // 处理重定向 - if (response.statusCode === 301 || response.statusCode === 302) { - const redirectUrl = response.headers.location; - console.log(` Following redirect...`); - - const redirectProtocol = redirectUrl.startsWith('https') ? https : http; - const redirectRequest = redirectProtocol.get(redirectUrl, { - timeout: timeout, - headers: { - 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36' - } - }, (redirectResponse) => { - if (redirectResponse.statusCode === 200) { - const file = fs.createWriteStream(dest); - let downloadedSize = 0; - const totalSize = parseInt(redirectResponse.headers['content-length'] || '0'); - - redirectResponse.on('data', (chunk) => { - downloadedSize += chunk.length; - if (totalSize > 0) { - const progress = ((downloadedSize / totalSize) * 100).toFixed(1); - process.stdout.write(`\r Progress: ${progress}% (${(downloadedSize / 1024 / 1024).toFixed(2)}MB / ${(totalSize / 1024 / 1024).toFixed(2)}MB)`); - } - }); - - redirectResponse.pipe(file); - file.on('finish', () => { - file.close(); - console.log(''); // 新行 - resolve(); - }); - file.on('error', (err) => { - file.close(); - if (fs.existsSync(dest)) fs.unlinkSync(dest); - reject(err); - }); - } else { - reject(new Error(`HTTP ${redirectResponse.statusCode}`)); - } - }); - - redirectRequest.on('error', reject); - redirectRequest.on('timeout', () => { - redirectRequest.destroy(); - reject(new Error('Request timeout')); - }); - - return; - } - - if (response.statusCode !== 200) { - reject(new Error(`HTTP ${response.statusCode}`)); - return; - } - - const file = fs.createWriteStream(dest); - let downloadedSize = 0; - const totalSize = parseInt(response.headers['content-length'] || '0'); - - response.on('data', (chunk) => { - downloadedSize += chunk.length; - if (totalSize > 0) { - const progress = ((downloadedSize / totalSize) * 100).toFixed(1); - process.stdout.write(`\r Progress: ${progress}% (${(downloadedSize / 1024 / 1024).toFixed(2)}MB / ${(totalSize / 1024 / 1024).toFixed(2)}MB)`); + const makeRequest = (requestUrl) => { + const requestProtocol = requestUrl.startsWith('https') ? https : http; + const request = requestProtocol.get(requestUrl, { + timeout: timeout, + headers: { + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36' } + }, (response) => { + // Handle redirects (301, 302, 307, 308) + if (response.statusCode === 301 || response.statusCode === 302 || + response.statusCode === 307 || response.statusCode === 308) { + redirectCount++; + if (redirectCount > maxRedirects) { + reject(new Error(`Too many redirects (${redirectCount})`)); + return; + } + + const redirectUrl = response.headers.location; + if (!redirectUrl) { + reject(new Error(`Redirect without location header`)); + return; + } + + // Handle relative redirects + const absoluteRedirectUrl = redirectUrl.startsWith('http') + ? redirectUrl + : new URL(redirectUrl, requestUrl).href; + + console.log(` Following redirect ${redirectCount} to: ${absoluteRedirectUrl}`); + + // Close current response + response.destroy(); + + // Recursively handle redirects + makeRequest(absoluteRedirectUrl); + return; + } + + if (response.statusCode !== 200) { + reject(new Error(`HTTP ${response.statusCode}`)); + return; + } + + const file = fs.createWriteStream(dest); + let downloadedSize = 0; + const totalSize = parseInt(response.headers['content-length'] || '0'); + + response.on('data', (chunk) => { + downloadedSize += chunk.length; + if (totalSize > 0) { + const progress = ((downloadedSize / totalSize) * 100).toFixed(1); + process.stdout.write(`\r Progress: ${progress}% (${(downloadedSize / 1024 / 1024).toFixed(2)}MB / ${(totalSize / 1024 / 1024).toFixed(2)}MB)`); + } + }); + + response.pipe(file); + file.on('finish', () => { + file.close(); + console.log(''); // New line + resolve(); + }); + file.on('error', (err) => { + file.close(); + if (fs.existsSync(dest)) fs.unlinkSync(dest); + reject(err); + }); }); - response.pipe(file); - file.on('finish', () => { - file.close(); - console.log(''); // 新行 - resolve(); - }); - file.on('error', (err) => { - file.close(); + request.on('error', (err) => { if (fs.existsSync(dest)) fs.unlinkSync(dest); reject(err); }); - }); - request.on('error', (err) => { - if (fs.existsSync(dest)) fs.unlinkSync(dest); - reject(err); - }); + request.on('timeout', () => { + request.destroy(); + if (fs.existsSync(dest)) fs.unlinkSync(dest); + reject(new Error('Request timeout')); + }); + }; - request.on('timeout', () => { - request.destroy(); - if (fs.existsSync(dest)) fs.unlinkSync(dest); - reject(new Error('Request timeout')); - }); + makeRequest(url); }); - // 验证下载的文件 + // Validate downloaded file if (!fs.existsSync(dest)) { throw new Error('Downloaded file does not exist'); } @@ -211,7 +198,7 @@ async function downloadFileWithValidation(urlsToTry, dest, validateFn, fileType } /** - * 获取 Bun 的下载 URL 列表 + * Get Bun download URL list */ function getBunUrls(platform, arch) { const filename = `bun-${platform}-${arch}.zip`; @@ -222,7 +209,7 @@ function getBunUrls(platform, arch) { } /** - * 获取 UV 的下载 URL 列表 + * Get UV download URL list */ function getUvUrls(archStr, platformStr, isWindows = false) { const extension = isWindows ? '.zip' : '.tar.gz'; @@ -438,18 +425,46 @@ async function installBun() { // Try to find bun in system PATH try { const whichCommand = platform === 'win32' ? 'where bun' : 'which bun'; - const systemBunPath = execSync(whichCommand, { encoding: 'utf-8', stdio: 'pipe' }).trim().split('\n')[0]; - if (systemBunPath && fs.existsSync(systemBunPath)) { - console.log(`📋 Using system bun: ${systemBunPath}`); - fs.copyFileSync(systemBunPath, bunPath); - if (platform !== 'win32') { - fs.chmodSync(bunPath, '755'); + const output = execSync(whichCommand, { encoding: 'utf-8', stdio: 'pipe' }).trim(); + const paths = output.split(/[\r\n]+/).map(p => p.trim()).filter(p => p && !p.includes('INFO:')); + + for (const systemBunPath of paths) { + if (systemBunPath && fs.existsSync(systemBunPath)) { + console.log(`📋 Using system bun: ${systemBunPath}`); + fs.copyFileSync(systemBunPath, bunPath); + if (platform !== 'win32') { + fs.chmodSync(bunPath, '755'); + } + return bunPath; + } + } + + // Also try common Windows paths (npm global install locations) + if (platform === 'win32') { + const npmPrefix = execSync('npm config get prefix', { encoding: 'utf-8', stdio: 'pipe' }).trim(); + const commonPaths = [ + path.join(npmPrefix, 'bun.exe'), + path.join(npmPrefix, 'bun.cmd'), + path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'bun.exe'), + path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'bun.cmd'), + path.join(os.homedir(), 'AppData', 'Local', 'npm', 'bun.exe'), + path.join(os.homedir(), '.bun', 'bin', 'bun.exe'), + 'C:\\Program Files\\nodejs\\bun.exe', + 'C:\\Program Files\\bun\\bun.exe', + 'C:\\bun\\bun.exe', + ]; + + for (const commonPath of commonPaths) { + if (fs.existsSync(commonPath)) { + console.log(`📋 Using bun from common path: ${commonPath}`); + fs.copyFileSync(commonPath, bunPath); + return bunPath; + } } - return bunPath; } } catch (error) { // bun not found in PATH, continue to download - console.log(' bun not found in system PATH, will download...'); + console.log(` bun not found in system PATH (${error.message}), will download...`); } // Determine platform and architecture @@ -472,8 +487,27 @@ async function installBun() { console.log(` Platform: ${bunPlatform}-${bunArch}`); - const urlsToTry = getBunUrls(bunPlatform, bunArch); - await downloadFileWithValidation(urlsToTry, tempFilename, isValidZip, 'ZIP'); + // Try using curl first (more reliable for redirects) + try { + const urlsToTry = getBunUrls(bunPlatform, bunArch); + const url = urlsToTry[0].url; + + console.log(` Trying to download with curl: ${url}`); + execSync(`curl -L -o "${tempFilename}" "${url}"`, { stdio: 'inherit' }); + + if (fs.existsSync(tempFilename) && isValidZip(tempFilename)) { + console.log(' ✅ Downloaded successfully with curl'); + } else { + throw new Error('Downloaded file is invalid'); + } + } catch (curlError) { + console.log(` ⚠️ curl download failed: ${curlError.message}`); + console.log(' Falling back to manual download...'); + + // Fallback to manual download + const urlsToTry = getBunUrls(bunPlatform, bunArch); + await downloadFileWithValidation(urlsToTry, tempFilename, isValidZip, 'ZIP'); + } // Extract console.log(' Extracting...');