import { spawnSync } from 'node:child_process' import { existsSync, statSync } from 'node:fs' import { readdir, readFile, stat } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' const scriptDir = dirname(fileURLToPath(import.meta.url)) const packageRoot = resolve(scriptDir, '..') const repoRoot = resolve(packageRoot, '..', '..') const runtimeGatewayDir = join(packageRoot, 'runtime', 'gateway') const sourceMainPath = join(packageRoot, 'src', 'main.ts') const compiledMainPath = join(packageRoot, 'dist', 'main.js') const packageJsonPath = join(packageRoot, 'package.json') const desktopOutputDir = join(repoRoot, 'dist', 'desktop-electron') const failures = [] function fail(message) { failures.push(message) } function pathIsFileSync(path) { try { return statSync(path).isFile() } catch { return false } } function gatewayBinary(root, platform) { const binaryName = platform === 'win32' ? 'opensquilla-gateway.exe' : 'opensquilla-gateway' const candidates = [join(root, 'opensquilla-gateway', binaryName), join(root, binaryName)] const binary = candidates.find(pathIsFileSync) return { binary, candidates } } function verificationEnv() { const keys = [ 'PATH', 'Path', 'HOME', 'USERPROFILE', 'TMPDIR', 'TEMP', 'TMP', 'SystemRoot', 'ComSpec', 'PATHEXT', 'LANG', 'LC_ALL', ] const env = { PYTHONUNBUFFERED: '1', PYTHONUTF8: '1', PYTHONIOENCODING: 'utf-8:replace', } for (const key of keys) { if (process.env[key] !== undefined) env[key] = process.env[key] } return env } function outputTail(output) { const tail = [output.stdout, output.stderr] .filter(Boolean) .join('\n') .trim() .split(/\r?\n/) .slice(-12) .join('\n') .trim() return tail ? `\nOutput tail:\n${tail}` : '' } function requireGatewayBinary(root, label, platform) { const { binary, candidates } = gatewayBinary(root, platform) if (!binary) { fail(`${label} gateway binary is missing; checked ${candidates.join(', ')}`) return null } return binary } function verifyGatewayCommand(binary, label, args, options = {}) { const result = spawnSync(binary, args, { cwd: dirname(binary), encoding: 'utf8', env: verificationEnv(), input: options.input, timeout: options.timeout ?? 30000, windowsHide: true, }) const commandLabel = `${label} gateway command ${args.join(' ')}` if (result.error) { fail(`${commandLabel} could not start: ${result.error.message}${outputTail(result)}`) return } if (result.status !== 0) { const exitReason = result.signal ? `signal ${result.signal}` : `exit ${result.status}` fail(`${commandLabel} failed with ${exitReason}${outputTail(result)}`) } } function verifyMacLightgbmRuntime(files, label) { const lightgbmLibs = files.filter((path) => path.endsWith(join('lightgbm', 'lib', 'lib_lightgbm.dylib'))) if (lightgbmLibs.length === 0) { fail(`${label} runtime is missing lightgbm/lib/lib_lightgbm.dylib`) return } for (const lightgbmLib of lightgbmLibs) { const bundledLibomp = join(dirname(lightgbmLib), 'libomp.dylib') if (!files.includes(bundledLibomp)) { fail(`${label} runtime is missing bundled libomp.dylib next to ${lightgbmLib}`) } if (process.platform !== 'darwin') continue const result = spawnSync('otool', ['-L', lightgbmLib], { encoding: 'utf8', windowsHide: true, }) if (result.error) { fail(`${label} could not inspect ${lightgbmLib} with otool: ${result.error.message}`) } else if (result.status !== 0) { fail(`${label} otool -L failed for ${lightgbmLib}${outputTail(result)}`) } else if (!result.stdout.includes('@loader_path/libomp.dylib')) { fail(`${label} ${lightgbmLib} does not load libomp.dylib via @loader_path`) } } } async function listFiles(root) { const files = [] async function walk(dir) { let entries try { entries = await readdir(dir, { withFileTypes: true }) } catch (error) { fail(`${root} could not be read: ${error instanceof Error ? error.message : String(error)}`) return } for (const entry of entries) { const path = join(dir, entry.name) if (entry.isDirectory()) { await walk(path) } else if (entry.isFile() && entry.name !== '.DS_Store') { files.push(path) } } } await walk(root) return files } async function verifyRuntime(root, label, { platform, executeCommands }) { if (!existsSync(root)) { fail(`${label} runtime is missing at ${root}`) return } const info = await stat(root).catch(() => null) if (!info?.isDirectory()) { fail(`${label} runtime is not a directory: ${root}`) return } const files = await listFiles(root) if (files.length === 0) { fail(`${label} runtime is empty: ${root}`) return } const compatFile = files.find((path) => path.endsWith(join('opensquilla', 'compat', 'aiosqlite.py'))) if (!compatFile) { fail(`${label} runtime is missing opensquilla/compat/aiosqlite.py`) } else { const source = await readFile(compatFile, 'utf8') if (!source.includes('async def create_function(') || !source.includes('self._conn.create_function')) { fail(`${label} runtime aiosqlite.py does not contain _AsyncConnection.create_function`) } } const binary = requireGatewayBinary(root, label, platform) if (executeCommands) { if (!binary) return verifyGatewayCommand(binary, label, ['--help']) verifyGatewayCommand(binary, label, ['code-task', '--help']) verifyGatewayCommand(binary, label, ['code-task', 'stage-task-file'], { input: 'desktop package smoke\n' }) verifyGatewayCommand(binary, label, ['code-task', 'smoke-imports'], { timeout: 120000 }) verifyGatewayCommand(binary, label, ['code-task', 'smoke-router'], { timeout: 120000 }) } if (platform === 'darwin') { verifyMacLightgbmRuntime(files, label) } } function verifyMainProcess(source, label) { for (const expected of [ 'gatewayStartPromise', 'openOrResumeDesktopApp', 'ensureGatewayStarted', 'isCurrentWindowAtControlUi', ]) { if (!source.includes(expected)) fail(`${label} main process is missing ${expected}`) } const helperIndex = source.indexOf('async function openOrResumeDesktopApp') const createIndex = source.indexOf('await createMainWindow()', helperIndex) const ensureIndex = source.indexOf('ensureGatewayStarted()', helperIndex) if (helperIndex === -1 || createIndex === -1 || ensureIndex === -1 || createIndex > ensureIndex) { fail(`${label} main process does not create the desktop window before gateway startup`) } if (!/app\.on\(['"]activate['"][\s\S]{0,240}openOrResumeDesktopApp/.test(source)) { fail(`${label} main process activate handler does not route through openOrResumeDesktopApp`) } if (!/second-instance[\s\S]{0,240}openOrResumeDesktopApp/.test(source)) { fail(`${label} main process second-instance handler does not route through openOrResumeDesktopApp`) } const loadCurrentIndex = source.indexOf('async function loadControlUiIntoCurrentWindow') const controlGuardIndex = source.indexOf('isCurrentWindowAtControlUi(window, gatewayUrl)', loadCurrentIndex) const controlLoadIndex = source.indexOf('loadControlUi(window, gatewayUrl)', loadCurrentIndex) if ( loadCurrentIndex === -1 || controlGuardIndex === -1 || controlLoadIndex === -1 || controlGuardIndex > controlLoadIndex ) { fail(`${label} main process reloads the Control UI before checking whether it is already loaded`) } const onboardingIndex = source.indexOf('async function runOnboarding') const onboardingWindowIndex = source.indexOf('onboardingWindow = new BrowserWindow', onboardingIndex) const parentIndex = source.indexOf('const parentWindow = currentMainWindow()', onboardingIndex) const parentOptionIndex = source.indexOf('parent: parentWindow ?? undefined', onboardingWindowIndex) const modalOptionIndex = source.indexOf('modal: Boolean(parentWindow)', onboardingWindowIndex) if ( onboardingIndex === -1 || onboardingWindowIndex === -1 || parentIndex === -1 || parentOptionIndex === -1 || modalOptionIndex === -1 || parentIndex > onboardingWindowIndex ) { fail(`${label} main process does not make first-run onboarding an owned modal child window`) } const focusIndex = source.indexOf('function focusMainWindow') const focusSource = focusIndex === -1 ? '' : source.slice(focusIndex, focusIndex + 800) const onboardingFocusMatch = /if\s*\(\s*focusOnboardingWindow\(\)\s*\)\s*(?:\{\s*)?return true\s*;?/.exec(focusSource) const mainFocusMatch = /if\s*\(\s*!mainWindow\s*\|\|\s*mainWindow\.isDestroyed\(\)\s*\)\s*(?:\{\s*)?return false\s*;?/.exec( focusSource, ) if ( focusIndex === -1 || !onboardingFocusMatch || !mainFocusMatch || onboardingFocusMatch.index > mainFocusMatch.index ) { fail(`${label} main process does not prefer the onboarding window when focusing`) } } async function verifySourceMain() { if (!existsSync(sourceMainPath)) { fail(`source Electron main process is missing at ${sourceMainPath}`) return } verifyMainProcess(await readFile(sourceMainPath, 'utf8'), 'source') } async function verifyInstallerDataPolicy() { const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) if (packageJson.build?.nsis?.deleteAppDataOnUninstall !== false) { fail('NSIS uninstall must preserve Desktop profile data (deleteAppDataOnUninstall=false)') } } async function verifyCompiledMain() { if (!existsSync(compiledMainPath)) { fail(`compiled Electron main process is missing at ${compiledMainPath}; run npm run build first`) return } verifyMainProcess(await readFile(compiledMainPath, 'utf8'), 'compiled') } async function findGeneratedBundles(root) { const bundles = [] const seenResourcesDirs = new Set() if (!existsSync(root)) return bundles function addBundle(bundle) { if (seenResourcesDirs.has(bundle.resourcesDir)) return seenResourcesDirs.add(bundle.resourcesDir) bundles.push(bundle) } async function walk(dir, depth) { if (depth > 5) return const entries = await readdir(dir, { withFileTypes: true }).catch(() => []) for (const entry of entries) { if (!entry.isDirectory()) continue const path = join(dir, entry.name) if (entry.name.endsWith('.app')) { addBundle({ label: `app bundle ${path}`, resourcesDir: join(path, 'Contents', 'Resources'), platform: 'darwin', }) } else if (entry.name === 'win-unpacked' || entry.name === 'linux-unpacked') { addBundle({ label: `generated bundle ${path}`, resourcesDir: join(path, 'resources'), platform: entry.name === 'win-unpacked' ? 'win32' : 'linux', }) } else { await walk(path, depth + 1) } } } await walk(root, 0) return bundles } async function readAsarText(asarPath, innerPath) { const asar = await import('@electron/asar') return asar.extractFile(asarPath, innerPath).toString('utf8') } async function verifyAsarPackageVersion(asarPath, label) { let packageJson try { packageJson = JSON.parse(await readAsarText(asarPath, 'package.json')) } catch (error) { fail(`${label} app.asar package.json could not be inspected: ${error instanceof Error ? error.message : String(error)}`) return } const version = typeof packageJson.version === 'string' ? packageJson.version : '' const semverPattern = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/ if (!semverPattern.test(version) || /\d(?:a|b|rc)\d+$/.test(version)) { fail( `${label} app.asar package.json version is not npm semver: ${JSON.stringify(version)}; ` + 'prereleases must use 0.5.0-rc2 style, not 0.5.0rc2' ) } } async function verifyGeneratedBundle({ label, resourcesDir, platform }) { await verifyRuntime(join(resourcesDir, 'runtime', 'gateway'), label, { platform, executeCommands: platform === process.platform, }) const asarPath = join(resourcesDir, 'app.asar') if (!existsSync(asarPath)) { fail(`${label} is missing app.asar`) return } try { await verifyAsarPackageVersion(asarPath, label) verifyMainProcess(await readAsarText(asarPath, 'dist/main.js'), label) } catch (error) { fail(`${label} app.asar could not be inspected: ${error instanceof Error ? error.message : String(error)}`) } } await verifyRuntime(runtimeGatewayDir, 'source', { platform: process.platform, executeCommands: true }) await verifyInstallerDataPolicy() await verifySourceMain() await verifyCompiledMain() const generatedBundles = await findGeneratedBundles(desktopOutputDir) for (const bundle of generatedBundles) { await verifyGeneratedBundle(bundle) } if (failures.length > 0) { console.error('OpenSquilla desktop package verification failed:') for (const failure of failures) console.error(`- ${failure}`) process.exit(1) } console.log('OpenSquilla desktop package verification passed.')