401 lines
13 KiB
JavaScript
401 lines
13 KiB
JavaScript
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.')
|