150 lines
12 KiB
JavaScript
150 lines
12 KiB
JavaScript
import { spawnSync } from 'node:child_process'
|
|
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
import { writeFile } from 'node:fs/promises'
|
|
import { dirname, resolve } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
const profile = process.env.FREECAD_ORACLE_PROFILE || 'headless'
|
|
if (!['headless', 'desktop'].includes(profile)) throw new Error(`Unknown FreeCAD oracle profile: ${profile}`)
|
|
const scope = process.env.FREECAD_REFERENCE_SCOPE || 'full'
|
|
if (!['full', 'gui-commands'].includes(scope)) throw new Error(`Unknown FreeCAD oracle scope: ${scope}`)
|
|
if (scope === 'gui-commands' && profile !== 'desktop') throw new Error('The gui-commands oracle scope requires the desktop profile.')
|
|
const shardToken = process.env.FREECAD_GUI_COMMAND_SHARD
|
|
const shardMatch = shardToken?.match(/^(\d+)\/(\d+)$/)
|
|
if (shardToken && (!shardMatch || scope !== 'gui-commands')) throw new Error('FREECAD_GUI_COMMAND_SHARD requires gui-commands scope and zero-based INDEX/COUNT syntax.')
|
|
const requestedShard = shardMatch ? { index: Number(shardMatch[1]), count: Number(shardMatch[2]) } : null
|
|
if (requestedShard && (requestedShard.count < 1 || requestedShard.index >= requestedShard.count)) throw new Error('FREECAD_GUI_COMMAND_SHARD is outside its declared range.')
|
|
const workbenchFilter = process.env.FREECAD_GUI_WORKBENCH_FILTER
|
|
if (workbenchFilter && scope !== 'gui-commands') throw new Error('FREECAD_GUI_WORKBENCH_FILTER requires gui-commands scope.')
|
|
const registrationOnly = process.env.FREECAD_GUI_REGISTRATION_ONLY === '1'
|
|
if (registrationOnly && (!workbenchFilter || requestedShard)) throw new Error('FREECAD_GUI_REGISTRATION_ONLY requires one workbench filter and cannot use a command shard.')
|
|
const timeoutMs = Number(process.env.FREECAD_REFERENCE_TIMEOUT_MS || (profile === 'desktop' ? 300000 : 120000))
|
|
if (!Number.isInteger(timeoutMs) || timeoutMs < 10000 || timeoutMs > 900000) throw new Error('FREECAD_REFERENCE_TIMEOUT_MS must be an integer from 10000 through 900000.')
|
|
const configIdentity = requestedShard
|
|
? `gui-commands-${requestedShard.index}-of-${requestedShard.count}`
|
|
: workbenchFilter ? `gui-workbench-${workbenchFilter.replace(/[^A-Za-z0-9_.-]/g, '_')}`
|
|
: `${profile}-${scope}`
|
|
const oracleConfigDirectory = resolve(root, '.cache/freecad/oracle-config')
|
|
const oracleUserConfig = resolve(oracleConfigDirectory, `${configIdentity}-user.cfg`)
|
|
const oracleSystemConfig = resolve(oracleConfigDirectory, `${configIdentity}-system.cfg`)
|
|
if (profile === 'desktop') {
|
|
const emptyConfig = '<?xml version="1.0" encoding="UTF-8" standalone="no" ?>\n<FCParameters><FCParamGroup Name="Root"/></FCParameters>\n'
|
|
mkdirSync(oracleConfigDirectory, { recursive: true })
|
|
writeFileSync(oracleUserConfig, emptyConfig)
|
|
writeFileSync(oracleSystemConfig, emptyConfig)
|
|
}
|
|
const localOracle = resolve(root, profile === 'desktop' ? '.cache/freecad/install-desktop/bin/FreeCAD' : '.cache/freecad/install-native/bin/FreeCADCmd')
|
|
const fallbackOracle = profile === 'desktop' ? ['FreeCAD', 'freecad'] : ['FreeCADCmd', 'freecadcmd']
|
|
const referenceCommand = process.env.FREECAD_REFERENCE_CMD || process.env.FREECAD_CMD
|
|
const candidates = referenceCommand ? [referenceCommand] : [localOracle, ...fallbackOracle]
|
|
let command = null
|
|
for (const candidate of candidates) {
|
|
const probe = spawnSync(candidate, profile === 'desktop' ? ['--console', '--version'] : ['--version'], { encoding: 'utf8', timeout: 15000 })
|
|
if (!probe.error || probe.error.code !== 'ENOENT') { command = candidate; break }
|
|
}
|
|
if (!command) throw new Error('FreeCADCmd 1.1.1 is unavailable. Set FREECAD_CMD to the locked oracle executable.')
|
|
|
|
// Desktop mode must stay in the normal GUI run mode so the command-line Python
|
|
// file is processed by the Qt application; --console switches to a blocking
|
|
// stdin loop and prevents a deterministic probe exit.
|
|
const sysroot = resolve(root, '.cache/freecad/sysroot')
|
|
const commandArgs = profile === 'desktop'
|
|
? ['--user-cfg', oracleUserConfig, '--system-cfg', oracleSystemConfig, '--python-path', resolve(sysroot, 'usr/lib/python3/dist-packages'), resolve(root, 'scripts/freecad-reference-probe.py')]
|
|
: [resolve(root, 'scripts/freecad-reference-probe.py')]
|
|
const runtimeEnv = profile === 'desktop'
|
|
? {
|
|
...process.env,
|
|
PYTHONPATH: `${resolve(sysroot, 'usr/lib/python3/dist-packages')}${process.env.PYTHONPATH ? `:${process.env.PYTHONPATH}` : ''}`,
|
|
LD_LIBRARY_PATH: `${resolve(sysroot, 'usr/lib/x86_64-linux-gnu')}${process.env.LD_LIBRARY_PATH ? `:${process.env.LD_LIBRARY_PATH}` : ''}`,
|
|
MATPLOTLIBRC: resolve(sysroot, 'usr/share/matplotlib/mpl-data/matplotlibrc'),
|
|
MPLBACKEND: 'Agg',
|
|
FREECAD_ORACLE_ISOLATED_CONFIG: '1',
|
|
}
|
|
: process.env
|
|
const desktopNeedsVirtualDisplay = profile === 'desktop' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY
|
|
const launchCommand = desktopNeedsVirtualDisplay ? 'xvfb-run' : command
|
|
const launchArgs = desktopNeedsVirtualDisplay ? ['-a', command, ...commandArgs] : commandArgs
|
|
const execution = spawnSync(launchCommand, launchArgs, {
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
timeout: timeoutMs,
|
|
maxBuffer: 20 * 1024 * 1024,
|
|
env: runtimeEnv,
|
|
})
|
|
const output = `${execution.stdout || ''}\n${execution.stderr || ''}`
|
|
const resultLine = output.split(/\r?\n/).find((line) => line.startsWith('FREECAD_REFERENCE_RESULT='))
|
|
if (execution.error || execution.status !== 0 || !resultLine) {
|
|
throw new Error(`FreeCAD reference probe failed with status ${execution.status}: ${[execution.error?.message, output.trim()].filter(Boolean).join('\n')}`)
|
|
}
|
|
const result = JSON.parse(resultLine.slice('FREECAD_REFERENCE_RESULT='.length))
|
|
if (result.freecadVersion !== '1.1.1') throw new Error(`Expected FreeCAD 1.1.1, received ${result.freecadVersion}`)
|
|
if (result.determinism?.schemaVersion !== 1 || result.determinism.normalizedProcessFields?.join(',') !== 'pointer-address,uuid,freecad-document-cache-run') throw new Error('Reference probe lacks deterministic process-field normalization.')
|
|
if (result.probeScope !== scope) throw new Error(`Reference probe returned scope ${result.probeScope || '<missing>'}; expected ${scope}.`)
|
|
if (profile === 'desktop' && (result.oracleSetup?.isolatedConfig !== true || result.oracleSetup.bimFirstTimeWelcome !== 'suppressed-before-workbench-activation')) throw new Error('Desktop reference probe lacks the isolated modal-dialog boundary.')
|
|
if (profile === 'desktop' && !registrationOnly && (result.guiCommands?.probeMode !== 'state-matrix' || result.guiCommands.stateProbe !== 'native-is-active-after-workbench-registration-with-Std_Expressions-qaction-guard' || result.guiCommands.stateCases?.join(',') !== 'noDocument,documentNoSelection,selectedPartBox' || result.guiCommands.stateCommandCount !== result.guiCommands.commands?.length)) throw new Error('Desktop reference probe lacks the locked GUI state matrix.')
|
|
if (registrationOnly && (result.guiCommands?.probeMode !== 'registration-only' || result.guiCommands.stateCommandCount !== 0 || result.guiCommands.workbenches?.length !== 1 || result.guiCommands.workbenches[0].name !== workbenchFilter)) throw new Error('Desktop reference probe returned an invalid single-workbench registration report.')
|
|
if (profile === 'desktop' && (!Number.isInteger(result.guiCommands?.commandUniverseCount) || result.guiCommands.commandUniverseCount < result.guiCommands.stateCommandCount || !/^[0-9a-f]{64}$/.test(result.guiCommands.commandUniverseSha256 || ''))) throw new Error('Desktop reference probe lacks a stable GUI command universe.')
|
|
if (requestedShard && (result.guiCommands?.shard?.index !== requestedShard.index || result.guiCommands.shard.count !== requestedShard.count)) throw new Error('Desktop reference probe returned the wrong GUI command shard.')
|
|
if (!requestedShard && result.guiCommands?.shard != null) throw new Error('Unsharded desktop reference probe returned shard metadata.')
|
|
const moduleStatuses = new Set(['not-built', 'compiled-importable', 'compiled-import-failure', 'gui-only-unprobeable'])
|
|
if (scope === 'full') {
|
|
if (!Array.isArray(result.modules) || result.modules.length !== 34 || result.moduleCount !== 34) throw new Error('Full reference probe must report the 34 configured FreeCAD modules.')
|
|
for (const module of result.modules) {
|
|
if (typeof module.name !== 'string' || typeof module.compiled !== 'boolean' || typeof module.importable !== 'boolean' || typeof module.guiRequired !== 'boolean' || typeof module.guiAvailable !== 'boolean' || !moduleStatuses.has(module.runtimeStatus)) {
|
|
throw new Error(`Reference probe returned an invalid module status for ${module.name || '<unknown>'}.`)
|
|
}
|
|
}
|
|
} else if (result.moduleCount !== 0 || result.modules?.length !== 0) {
|
|
throw new Error('GUI command probe must not include the module oracle scope.')
|
|
}
|
|
const runtimeObjects = result.runtimeObjects
|
|
if (scope === 'full') {
|
|
if (!runtimeObjects?.available || runtimeObjects.candidateSource !== 'Document.supportedTypes' || !Array.isArray(runtimeObjects.types)) {
|
|
throw new Error('Reference probe must include the complete Document.supportedTypes runtime inventory.')
|
|
}
|
|
if (runtimeObjects.candidateCount !== runtimeObjects.types.length || runtimeObjects.availableCount + runtimeObjects.unavailableCount !== runtimeObjects.candidateCount) {
|
|
throw new Error('Reference probe returned inconsistent runtime object counts.')
|
|
}
|
|
const runtimeTypeIds = new Set()
|
|
for (const candidate of runtimeObjects.types) {
|
|
if (typeof candidate.typeId !== 'string' || !candidate.typeId || runtimeTypeIds.has(candidate.typeId) || !['available', 'unavailable'].includes(candidate.probeStatus)) {
|
|
throw new Error(`Reference probe returned an invalid runtime object candidate: ${candidate.typeId || '<unknown>'}.`)
|
|
}
|
|
runtimeTypeIds.add(candidate.typeId)
|
|
if (candidate.available) {
|
|
if (candidate.probeStatus !== 'available' || typeof candidate.runtimeTypeId !== 'string' || !candidate.runtimeTypeId || !Array.isArray(candidate.properties)) {
|
|
throw new Error(`Reference probe returned incomplete metadata for ${candidate.typeId}.`)
|
|
}
|
|
for (const property of candidate.properties) {
|
|
if (typeof property.name !== 'string' || typeof property.typeId !== 'string' || !Array.isArray(property.status) || !Object.hasOwn(property, 'default')) {
|
|
throw new Error(`Reference probe returned invalid property metadata for ${candidate.typeId}.`)
|
|
}
|
|
}
|
|
} else if (candidate.probeStatus !== 'unavailable' || typeof candidate.error !== 'string' || !candidate.error) {
|
|
throw new Error(`Reference probe did not explain why ${candidate.typeId} is unavailable.`)
|
|
}
|
|
}
|
|
}
|
|
const reportName = requestedShard
|
|
? `reference-desktop-gui-commands-${requestedShard.index}-of-${requestedShard.count}.json`
|
|
: workbenchFilter ? `reference-desktop-gui-commands-${workbenchFilter.replace(/[^A-Za-z0-9_.-]/g, '_')}.json`
|
|
: scope === 'gui-commands' ? 'reference-desktop-gui-commands.json' : `reference-${profile}.json`
|
|
await writeFile(resolve(root, '.cache/freecad', reportName), `${JSON.stringify(result, null, 2)}\n`)
|
|
console.log(JSON.stringify({
|
|
command: launchCommand === command ? command : `${launchCommand} ${command}`,
|
|
profile,
|
|
scope,
|
|
shard: requestedShard,
|
|
freecadVersion: result.freecadVersion,
|
|
moduleStatusSummary: result.moduleStatusSummary,
|
|
guiCommandCount: result.guiCommands?.commands?.length ?? 0,
|
|
runtimeObjectSummary: {
|
|
candidateCount: runtimeObjects?.candidateCount ?? 0,
|
|
availableCount: runtimeObjects?.availableCount ?? 0,
|
|
unavailableCount: runtimeObjects?.unavailableCount ?? 0,
|
|
propertyCount: runtimeObjects?.types?.reduce((total, candidate) => total + (candidate.properties?.length ?? 0), 0) ?? 0,
|
|
},
|
|
report: `.cache/freecad/${reportName}`,
|
|
}, null, 2))
|