667 lines
23 KiB
JavaScript
667 lines
23 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict'
|
|
|
|
const assert = require('node:assert/strict')
|
|
const crypto = require('node:crypto')
|
|
const fs = require('node:fs')
|
|
const fsp = require('node:fs/promises')
|
|
const http = require('node:http')
|
|
const net = require('node:net')
|
|
const os = require('node:os')
|
|
const path = require('node:path')
|
|
const { execFileSync, spawn } = require('node:child_process')
|
|
|
|
const ROOT = path.resolve(__dirname, '..')
|
|
const MANIFEST_URL = 'http://192.168.10.6/ycode/releases/latest.json'
|
|
const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
|
|
|
|
function readExpectedVersion() {
|
|
const args = process.argv.slice(2)
|
|
let value = null
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const argument = args[index]
|
|
if (argument === '--expected-version') {
|
|
value = args[++index]
|
|
} else if (argument.startsWith('--expected-version=')) {
|
|
value = argument.slice('--expected-version='.length)
|
|
} else {
|
|
throw new Error(`Unknown argument: ${argument}`)
|
|
}
|
|
}
|
|
if (value === null) value = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version
|
|
if (typeof value !== 'string' || !SEMVER_RE.test(value)) throw new Error(`Invalid expected version: ${value}`)
|
|
return value
|
|
}
|
|
|
|
const EXPECTED_VERSION = readExpectedVersion()
|
|
const PACKAGED_EXE = path.resolve(process.env.YCODE_QA_PACKAGED_EXE || path.join(ROOT, 'release', 'win-unpacked', 'YCode.exe'))
|
|
const PUBLIC_KEY = path.join(ROOT, 'resources', 'updates', 'public-key.pem')
|
|
const OUTPUT_ROOT = path.join(ROOT, '.tmp', 'updates-live')
|
|
const STAMP = new Date().toISOString().replace(/[:.]/g, '-')
|
|
const RUN_ROOT = path.join(OUTPUT_ROOT, STAMP)
|
|
const REPORT_PATH = path.join(RUN_ROOT, 'report.json')
|
|
const LATEST_REPORT_PATH = path.join(OUTPUT_ROOT, 'latest-report.json')
|
|
const SCREENSHOT_PATH = path.join(RUN_ROOT, 'production-boot.png')
|
|
const REGISTRY_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\8a4ee941-d893-569b-8bcb-2a55d270c04b'
|
|
|
|
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
|
let activeBootChild = null
|
|
|
|
async function handleInterrupt(signal) {
|
|
await stopExactChild(activeBootChild)
|
|
process.exit(signal === 'SIGINT' ? 130 : 143)
|
|
}
|
|
|
|
process.once('SIGINT', () => void handleInterrupt('SIGINT'))
|
|
process.once('SIGTERM', () => void handleInterrupt('SIGTERM'))
|
|
|
|
function sha256(value) {
|
|
return crypto.createHash('sha256').update(value).digest('hex')
|
|
}
|
|
|
|
async function sha256File(filename) {
|
|
const hash = crypto.createHash('sha256')
|
|
for await (const chunk of fs.createReadStream(filename)) hash.update(chunk)
|
|
return hash.digest('hex')
|
|
}
|
|
|
|
async function writeJsonAtomic(filename, value) {
|
|
await fsp.mkdir(path.dirname(filename), { recursive: true })
|
|
const temporary = `${filename}.${process.pid}.tmp`
|
|
await fsp.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
|
await fsp.rename(temporary, filename)
|
|
}
|
|
|
|
function errorDetails(error) {
|
|
if (error instanceof Error) return { name: error.name, message: error.message, stack: error.stack }
|
|
return { name: 'Error', message: String(error) }
|
|
}
|
|
|
|
async function fetchBytes(url, maximum = 1024 * 1024, timeoutMs = 30_000) {
|
|
let current = new URL(url)
|
|
const origin = current.origin
|
|
for (let redirects = 0; redirects <= 5; redirects += 1) {
|
|
const response = await fetch(current, {
|
|
redirect: 'manual',
|
|
cache: 'no-store',
|
|
signal: AbortSignal.timeout(timeoutMs)
|
|
})
|
|
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
const location = response.headers.get('location')
|
|
await response.body?.cancel().catch(() => undefined)
|
|
if (!location || redirects === 5) throw new Error('Manifest redirect chain is invalid')
|
|
const next = new URL(location, current)
|
|
if (next.origin !== origin) throw new Error('Manifest redirect left the configured origin')
|
|
current = next
|
|
continue
|
|
}
|
|
if (!response.ok) throw new Error(`Manifest request failed with HTTP ${response.status}`)
|
|
const declared = Number(response.headers.get('content-length') || 0)
|
|
if (declared > maximum) throw new Error('Manifest response exceeds the size limit')
|
|
const bytes = Buffer.from(await response.arrayBuffer())
|
|
if (bytes.length > maximum) throw new Error('Manifest response exceeds the size limit')
|
|
return bytes
|
|
}
|
|
throw new Error('Manifest redirect chain is invalid')
|
|
}
|
|
|
|
async function reserveLoopbackPort() {
|
|
const server = net.createServer()
|
|
await new Promise((resolve, reject) => {
|
|
server.once('error', reject)
|
|
server.listen(0, '127.0.0.1', resolve)
|
|
})
|
|
const address = server.address()
|
|
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
|
if (!address || typeof address === 'string') throw new Error('Unable to reserve a debug port')
|
|
return address.port
|
|
}
|
|
|
|
function getJson(url, timeoutMs = 2_000) {
|
|
return new Promise((resolve, reject) => {
|
|
const request = http.get(url, { timeout: timeoutMs, family: 4 }, response => {
|
|
const chunks = []
|
|
response.on('data', chunk => chunks.push(chunk))
|
|
response.on('end', () => {
|
|
if (response.statusCode !== 200) {
|
|
reject(new Error(`HTTP ${response.statusCode} from ${url}`))
|
|
return
|
|
}
|
|
try {
|
|
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
|
} catch (error) {
|
|
reject(error)
|
|
}
|
|
})
|
|
})
|
|
request.on('timeout', () => request.destroy(new Error('CDP endpoint timed out')))
|
|
request.on('error', reject)
|
|
})
|
|
}
|
|
|
|
async function waitForCdpPage(port, child) {
|
|
const deadline = Date.now() + 45_000
|
|
while (Date.now() < deadline) {
|
|
if (child.exitCode !== null) throw new Error(`Packaged app exited early with code ${child.exitCode}`)
|
|
try {
|
|
const targets = await getJson(`http://127.0.0.1:${port}/json/list`)
|
|
const page = targets.find(target => target.type === 'page'
|
|
&& typeof target.webSocketDebuggerUrl === 'string'
|
|
&& (/\/index\.html(?:[?#]|$)/.test(target.url) || /\/$/.test(target.url)))
|
|
if (page) return page
|
|
} catch {
|
|
// Chromium's endpoint is not ready yet.
|
|
}
|
|
await sleep(250)
|
|
}
|
|
throw new Error('Timed out waiting for the packaged app renderer target')
|
|
}
|
|
|
|
function openWebSocket(url) {
|
|
const WebSocketCtor = globalThis.WebSocket || require('ws')
|
|
return new Promise((resolve, reject) => {
|
|
const socket = new WebSocketCtor(url)
|
|
const timer = setTimeout(() => reject(new Error('CDP WebSocket open timed out')), 15_000)
|
|
const cleanup = () => {
|
|
clearTimeout(timer)
|
|
socket.removeEventListener?.('open', onOpen)
|
|
socket.removeEventListener?.('error', onError)
|
|
}
|
|
const onOpen = () => {
|
|
cleanup()
|
|
resolve(socket)
|
|
}
|
|
const onError = error => {
|
|
cleanup()
|
|
reject(error instanceof Error ? error : new Error('CDP WebSocket failed'))
|
|
}
|
|
socket.addEventListener('open', onOpen)
|
|
socket.addEventListener('error', onError)
|
|
})
|
|
}
|
|
|
|
class CdpClient {
|
|
constructor(socket) {
|
|
this.socket = socket
|
|
this.nextId = 1
|
|
this.pending = new Map()
|
|
socket.addEventListener('message', event => {
|
|
let message
|
|
try {
|
|
const text = typeof event.data === 'string' ? event.data : Buffer.from(event.data).toString('utf8')
|
|
message = JSON.parse(text)
|
|
} catch {
|
|
return
|
|
}
|
|
if (!message.id) return
|
|
const pending = this.pending.get(message.id)
|
|
if (!pending) return
|
|
this.pending.delete(message.id)
|
|
clearTimeout(pending.timer)
|
|
if (message.error) pending.reject(new Error(`${message.error.message || 'CDP error'} (${message.error.code})`))
|
|
else pending.resolve(message.result)
|
|
})
|
|
socket.addEventListener('close', () => {
|
|
for (const pending of this.pending.values()) {
|
|
clearTimeout(pending.timer)
|
|
pending.reject(new Error('CDP WebSocket closed'))
|
|
}
|
|
this.pending.clear()
|
|
})
|
|
}
|
|
|
|
static async connect(url) {
|
|
return new CdpClient(await openWebSocket(url))
|
|
}
|
|
|
|
request(method, params = {}, timeoutMs = 30_000) {
|
|
const id = this.nextId
|
|
this.nextId += 1
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
this.pending.delete(id)
|
|
reject(new Error(`CDP request timed out: ${method}`))
|
|
}, timeoutMs)
|
|
this.pending.set(id, { resolve, reject, timer })
|
|
this.socket.send(JSON.stringify({ id, method, params }))
|
|
})
|
|
}
|
|
|
|
async evaluate(expression) {
|
|
const result = await this.request('Runtime.evaluate', {
|
|
expression,
|
|
awaitPromise: true,
|
|
returnByValue: true,
|
|
userGesture: false
|
|
})
|
|
if (result.exceptionDetails) {
|
|
const description = result.exceptionDetails.exception?.description
|
|
|| result.exceptionDetails.text
|
|
|| 'Renderer evaluation failed'
|
|
throw new Error(description)
|
|
}
|
|
return result.result?.value
|
|
}
|
|
|
|
close() {
|
|
try {
|
|
this.socket.close()
|
|
} catch {
|
|
// The renderer may already be gone.
|
|
}
|
|
}
|
|
}
|
|
|
|
async function waitForUpdatesApi(client, child) {
|
|
const deadline = Date.now() + 30_000
|
|
while (Date.now() < deadline) {
|
|
if (child.exitCode !== null) throw new Error(`Packaged app exited early with code ${child.exitCode}`)
|
|
try {
|
|
const ready = await client.evaluate(
|
|
"typeof window.ycode?.updates?.getState === 'function' && document.readyState === 'complete'"
|
|
)
|
|
if (ready === true) return
|
|
} catch {
|
|
// React/preload may still be starting.
|
|
}
|
|
await sleep(250)
|
|
}
|
|
throw new Error('Timed out waiting for window.ycode.updates')
|
|
}
|
|
|
|
const UPDATER_INSPECTION_EXPRESSION = `(async () => {
|
|
const api = window.ycode?.updates
|
|
if (!api) throw new Error('window.ycode.updates is unavailable')
|
|
const apiMethods = ['getState', 'check', 'download', 'install', 'dismiss', 'openDownloadPage', 'openLog', 'onState']
|
|
.filter(name => typeof api[name] === 'function')
|
|
const before = await api.getState()
|
|
const events = []
|
|
const unsubscribe = api.onState(state => events.push(state))
|
|
let after
|
|
try {
|
|
after = await api.check()
|
|
const deadline = Date.now() + 15000
|
|
while (Date.now() < deadline) {
|
|
if (events.some(state => state.status === 'up-to-date')) break
|
|
if (events.some(state => state.status === 'error' || state.status === 'unsupported')) break
|
|
await new Promise(resolve => setTimeout(resolve, 100))
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, 500))
|
|
} finally {
|
|
unsubscribe()
|
|
}
|
|
return { apiMethods, before, after, events }
|
|
})()`
|
|
|
|
async function captureScreenshot(client, filename) {
|
|
await client.request('Page.enable')
|
|
const screenshot = await client.request('Page.captureScreenshot', {
|
|
format: 'png',
|
|
fromSurface: true,
|
|
captureBeyondViewport: false
|
|
})
|
|
await fsp.writeFile(filename, Buffer.from(screenshot.data, 'base64'))
|
|
}
|
|
|
|
async function stopExactChild(child) {
|
|
if (!child || child.exitCode !== null || child.signalCode !== null) return
|
|
const exited = new Promise(resolve => child.once('exit', resolve))
|
|
child.kill()
|
|
await Promise.race([exited, sleep(5_000)])
|
|
if (child.exitCode === null && child.signalCode === null) {
|
|
child.kill('SIGKILL')
|
|
await Promise.race([exited, sleep(2_000)])
|
|
}
|
|
}
|
|
|
|
async function runProductionBoot(expectedManifest) {
|
|
if (!fs.existsSync(PACKAGED_EXE)) throw new Error(`Packaged executable is missing: ${PACKAGED_EXE}`)
|
|
const port = await reserveLoopbackPort()
|
|
const userData = path.join(RUN_ROOT, 'production-boot-user-data')
|
|
await fsp.mkdir(userData, { recursive: true })
|
|
const stdoutPath = path.join(RUN_ROOT, 'production-boot.stdout.log')
|
|
const stderrPath = path.join(RUN_ROOT, 'production-boot.stderr.log')
|
|
const stdout = fs.createWriteStream(stdoutPath, { flags: 'w' })
|
|
const stderr = fs.createWriteStream(stderrPath, { flags: 'w' })
|
|
const env = {
|
|
...process.env,
|
|
YCODE_USER_DATA_DIR: userData,
|
|
YCODE_SKIP_KERNEL_DATA_RESET: '1',
|
|
YCODE_QA_OFFSCREEN: '1'
|
|
}
|
|
delete env.ELECTRON_RENDERER_URL
|
|
delete env.YCODE_QA_DIRECTORY
|
|
for (const key of ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'YCODE_API_KEY', 'CODEBUDDY_API_KEY']) delete env[key]
|
|
|
|
const child = spawn(PACKAGED_EXE, [
|
|
`--remote-debugging-port=${port}`,
|
|
'--remote-debugging-address=127.0.0.1'
|
|
], {
|
|
cwd: RUN_ROOT,
|
|
env,
|
|
windowsHide: true,
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
})
|
|
child.stdout.pipe(stdout)
|
|
child.stderr.pipe(stderr)
|
|
activeBootChild = child
|
|
let client
|
|
let inspection
|
|
let screenshotWritten = false
|
|
try {
|
|
if (!child.pid) throw new Error('Packaged app did not expose a child PID')
|
|
const page = await waitForCdpPage(port, child)
|
|
client = await CdpClient.connect(page.webSocketDebuggerUrl)
|
|
await waitForUpdatesApi(client, child)
|
|
inspection = await client.evaluate(UPDATER_INSPECTION_EXPRESSION)
|
|
await captureScreenshot(client, SCREENSHOT_PATH)
|
|
screenshotWritten = true
|
|
|
|
assert.equal(inspection.before.currentVersion, EXPECTED_VERSION)
|
|
assert.equal(inspection.after.currentVersion, EXPECTED_VERSION)
|
|
assert.equal(inspection.after.status, 'up-to-date')
|
|
assert.equal(inspection.after.lastExplicit, true)
|
|
assert.equal(inspection.apiMethods.length, 8)
|
|
assert.equal(inspection.events.some(state => state.status === 'checking'), true)
|
|
assert.equal(inspection.events.some(state => state.status === 'up-to-date'), true)
|
|
assert.equal(inspection.events.some(state => ['downloading', 'ready', 'installing'].includes(state.status)), false)
|
|
assert.equal(inspection.events.every(state => state.currentVersion === EXPECTED_VERSION), true)
|
|
|
|
return {
|
|
mode: 'actual-production-packaged-boot',
|
|
executable: PACKAGED_EXE,
|
|
childPid: child.pid,
|
|
debugPort: port,
|
|
isolatedUserData: userData,
|
|
expectedServerVersion: expectedManifest.version,
|
|
apiMethods: inspection.apiMethods,
|
|
before: inspection.before,
|
|
after: inspection.after,
|
|
sharedIpcEvents: inspection.events,
|
|
screenshot: screenshotWritten ? SCREENSHOT_PATH : null,
|
|
stdoutLog: stdoutPath,
|
|
stderrLog: stderrPath,
|
|
installerOrUpgradeRequested: false,
|
|
passed: true
|
|
}
|
|
} finally {
|
|
client?.close()
|
|
await stopExactChild(child)
|
|
if (activeBootChild === child) activeBootChild = null
|
|
stdout.end()
|
|
stderr.end()
|
|
}
|
|
}
|
|
|
|
function createRuntimeEntry(filename) {
|
|
const relative = value => `./${path.relative(path.dirname(filename), value).replace(/\\/g, '/')}`
|
|
return [
|
|
`export { UpdateManager } from ${JSON.stringify(relative(path.join(ROOT, 'src/main/updates/manager.ts')))}`,
|
|
`export { FileUpdatePersistence } from ${JSON.stringify(relative(path.join(ROOT, 'src/main/updates/storage.ts')))}`,
|
|
`export { verifySignedManifest } from ${JSON.stringify(relative(path.join(ROOT, 'src/main/updates/manifest.ts')))}`,
|
|
''
|
|
].join('\n')
|
|
}
|
|
|
|
function bundleActualManager(runtimeDirectory) {
|
|
fs.mkdirSync(runtimeDirectory, { recursive: true })
|
|
const entry = path.join(runtimeDirectory, 'entry.ts')
|
|
const output = path.join(runtimeDirectory, 'updater-runtime.cjs')
|
|
fs.writeFileSync(entry, createRuntimeEntry(entry), 'utf8')
|
|
const esbuild = path.join(ROOT, 'node_modules', 'esbuild', 'bin', 'esbuild')
|
|
if (!fs.existsSync(esbuild)) throw new Error(`esbuild is missing: ${esbuild}`)
|
|
execFileSync(process.execPath, [
|
|
esbuild,
|
|
entry,
|
|
'--bundle',
|
|
'--platform=node',
|
|
'--format=cjs',
|
|
'--target=node22',
|
|
`--outfile=${output}`,
|
|
'--log-level=warning'
|
|
], { cwd: ROOT, stdio: 'inherit' })
|
|
return { entry, output, runtime: require(output) }
|
|
}
|
|
|
|
function forbiddenInstaller() {
|
|
const calls = { validateEnvironment: 0, verifyInstaller: 0, launch: 0 }
|
|
const fail = name => {
|
|
calls[name] += 1
|
|
throw new Error('Live verification is download-only; installer access is forbidden')
|
|
}
|
|
return {
|
|
calls,
|
|
adapter: {
|
|
validateEnvironment: async () => fail('validateEnvironment'),
|
|
verifyInstaller: async () => fail('verifyInstaller'),
|
|
launch: async () => fail('launch')
|
|
}
|
|
}
|
|
}
|
|
|
|
async function runSimulatedPredecessorDownload(expectedManifest, runtime, publicKeyPem) {
|
|
const directory = path.join(RUN_ROOT, 'simulated-predecessor')
|
|
const cacheDirectory = path.join(directory, 'cache')
|
|
const persistence = new runtime.FileUpdatePersistence(path.join(directory, 'state.json'))
|
|
const forbidden = forbiddenInstaller()
|
|
const callbacks = { beforeInstall: 0, quitForUpdate: 0, installFailed: 0 }
|
|
const events = []
|
|
const manager = new runtime.UpdateManager({
|
|
currentVersion: '0.0.1',
|
|
manifestUrl: MANIFEST_URL,
|
|
cacheDirectory,
|
|
logFile: path.join(directory, 'updater.log'),
|
|
persistence,
|
|
publicKey: async () => publicKeyPem,
|
|
supported: true,
|
|
installer: forbidden.adapter,
|
|
beforeInstall: async () => {
|
|
callbacks.beforeInstall += 1
|
|
throw new Error('beforeInstall is forbidden in live download verification')
|
|
},
|
|
quitForUpdate: async () => {
|
|
callbacks.quitForUpdate += 1
|
|
throw new Error('quitForUpdate is forbidden in live download verification')
|
|
},
|
|
installFailed: () => {
|
|
callbacks.installFailed += 1
|
|
},
|
|
autoDownload: false,
|
|
manifestTimeoutMs: 60_000,
|
|
downloadTimeoutMs: 20 * 60_000
|
|
})
|
|
let lastLoggedProgress = -1
|
|
const unsubscribe = manager.subscribe(state => {
|
|
events.push(state)
|
|
if (state.status === 'downloading' && typeof state.progress === 'number') {
|
|
const bucket = Math.floor(state.progress / 10) * 10
|
|
if (bucket > lastLoggedProgress) {
|
|
lastLoggedProgress = bucket
|
|
console.log(`simulated predecessor download: ${state.progress}%`)
|
|
}
|
|
}
|
|
})
|
|
try {
|
|
const checked = await manager.check(true)
|
|
assert.equal(checked.status, 'available')
|
|
assert.equal(checked.version, expectedManifest.version)
|
|
const downloaded = await manager.download(true)
|
|
assert.equal(downloaded.status, 'ready')
|
|
assert.equal(downloaded.progress, 100)
|
|
assert.equal(downloaded.currentVersion, '0.0.1')
|
|
assert.equal(downloaded.version, expectedManifest.version)
|
|
assert.equal(downloaded.lastExplicit, true)
|
|
|
|
const filename = `YCode-${expectedManifest.version}-Setup.exe`
|
|
const cachedPath = path.join(cacheDirectory, filename)
|
|
const info = await fsp.stat(cachedPath)
|
|
const digest = await sha256File(cachedPath)
|
|
assert.equal(info.isFile(), true)
|
|
assert.equal(info.size, expectedManifest.size)
|
|
assert.equal(digest, expectedManifest.sha256)
|
|
assert.equal(path.basename(cachedPath), filename)
|
|
|
|
const persisted = await persistence.load()
|
|
assert.equal(persisted.unfinishedVersion, undefined)
|
|
assert.equal(persisted.target, undefined)
|
|
assert.deepEqual(forbidden.calls, { validateEnvironment: 0, verifyInstaller: 0, launch: 0 })
|
|
assert.deepEqual(callbacks, { beforeInstall: 0, quitForUpdate: 0, installFailed: 0 })
|
|
assert.equal(events.some(state => state.status === 'downloading'), true)
|
|
|
|
return {
|
|
mode: 'simulated-predecessor-real-network-download',
|
|
simulatedPredecessorVersion: '0.0.1',
|
|
actualApplicationVersionChanged: false,
|
|
manifestUrl: MANIFEST_URL,
|
|
expectedVersion: expectedManifest.version,
|
|
state: downloaded,
|
|
observedDownloading: true,
|
|
cachedPath,
|
|
size: info.size,
|
|
sha256: digest,
|
|
installerAdapterCalls: forbidden.calls,
|
|
installCallbacks: callbacks,
|
|
passed: true
|
|
}
|
|
} finally {
|
|
unsubscribe()
|
|
manager.dispose()
|
|
}
|
|
}
|
|
|
|
function readRealUserState() {
|
|
let registryFingerprint = null
|
|
let registryEntryPresent = false
|
|
try {
|
|
const output = execFileSync('reg.exe', ['query', REGISTRY_KEY], {
|
|
encoding: 'utf8',
|
|
windowsHide: true,
|
|
timeout: 10_000,
|
|
maxBuffer: 64 * 1024
|
|
})
|
|
registryFingerprint = sha256(output)
|
|
registryEntryPresent = true
|
|
} catch {
|
|
// The current user may not have a production installation.
|
|
}
|
|
|
|
let installedExeFingerprint = null
|
|
let installedExePresent = false
|
|
const installedExe = path.join(process.env.LOCALAPPDATA || '', 'Programs', 'YCode', 'YCode.exe')
|
|
try {
|
|
const info = fs.statSync(installedExe)
|
|
installedExePresent = true
|
|
installedExeFingerprint = sha256(`${installedExe}|${info.size}|${info.mtimeMs}`)
|
|
} catch {
|
|
// Optional read-only check only.
|
|
}
|
|
return { registryEntryPresent, registryFingerprint, installedExePresent, installedExeFingerprint }
|
|
}
|
|
|
|
async function main() {
|
|
await fsp.mkdir(RUN_ROOT, { recursive: true })
|
|
const realUserStateBefore = readRealUserState()
|
|
const report = {
|
|
schemaVersion: 1,
|
|
startedAt: new Date().toISOString(),
|
|
finishedAt: null,
|
|
manifestUrl: MANIFEST_URL,
|
|
expectedVersion: EXPECTED_VERSION,
|
|
realApplicationUpgradePerformed: false,
|
|
installerLaunched: false,
|
|
checks: {
|
|
productionPackagedBoot: null,
|
|
simulatedPredecessorDownload: null
|
|
},
|
|
realUserStateBefore,
|
|
realUserStateAfter: null,
|
|
limitations: [
|
|
'Production boot verifies only packaged updater IPC and up-to-date state.',
|
|
'Installer download uses a simulated predecessor version 0.0.1 manager.',
|
|
'No real application upgrade, installer launch, deployment, or replacement is performed.'
|
|
]
|
|
}
|
|
|
|
let exitCode = 0
|
|
try {
|
|
const publicKeyPem = await fsp.readFile(PUBLIC_KEY, 'utf8')
|
|
const runtime = bundleActualManager(path.join(RUN_ROOT, 'runtime')).runtime
|
|
const manifestBytes = await fetchBytes(MANIFEST_URL)
|
|
const manifest = runtime.verifySignedManifest(manifestBytes, {
|
|
manifestUrl: MANIFEST_URL,
|
|
publicKeyPem
|
|
})
|
|
assert.equal(manifest.payload.version, EXPECTED_VERSION)
|
|
const platform = manifest.payload.platforms['windows-x64']
|
|
const expectedManifest = {
|
|
version: manifest.payload.version,
|
|
url: platform.url,
|
|
size: platform.size,
|
|
sha256: platform.sha256
|
|
}
|
|
report.verifiedManifest = expectedManifest
|
|
|
|
try {
|
|
report.checks.productionPackagedBoot = await runProductionBoot(expectedManifest)
|
|
} catch (error) {
|
|
exitCode = 1
|
|
report.checks.productionPackagedBoot = {
|
|
mode: 'actual-production-packaged-boot',
|
|
passed: false,
|
|
error: errorDetails(error)
|
|
}
|
|
}
|
|
|
|
try {
|
|
report.checks.simulatedPredecessorDownload = await runSimulatedPredecessorDownload(
|
|
expectedManifest,
|
|
runtime,
|
|
publicKeyPem
|
|
)
|
|
} catch (error) {
|
|
exitCode = 1
|
|
report.checks.simulatedPredecessorDownload = {
|
|
mode: 'simulated-predecessor-real-network-download',
|
|
simulatedPredecessorVersion: '0.0.1',
|
|
actualApplicationVersionChanged: false,
|
|
passed: false,
|
|
error: errorDetails(error)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
exitCode = 1
|
|
report.preflightError = errorDetails(error)
|
|
} finally {
|
|
report.realUserStateAfter = readRealUserState()
|
|
report.realUserStateUnchanged = JSON.stringify(report.realUserStateBefore) === JSON.stringify(report.realUserStateAfter)
|
|
report.finishedAt = new Date().toISOString()
|
|
report.passed = exitCode === 0
|
|
await writeJsonAtomic(REPORT_PATH, report)
|
|
await writeJsonAtomic(LATEST_REPORT_PATH, report)
|
|
console.log(`report: ${REPORT_PATH}`)
|
|
if (fs.existsSync(SCREENSHOT_PATH)) console.log(`screenshot: ${SCREENSHOT_PATH}`)
|
|
}
|
|
process.exitCode = exitCode
|
|
}
|
|
|
|
main().catch(async error => {
|
|
console.error(error)
|
|
process.exitCode = 1
|
|
try {
|
|
await fsp.mkdir(RUN_ROOT, { recursive: true })
|
|
await writeJsonAtomic(REPORT_PATH, {
|
|
schemaVersion: 1,
|
|
startedAt: null,
|
|
finishedAt: new Date().toISOString(),
|
|
manifestUrl: MANIFEST_URL,
|
|
expectedVersion: EXPECTED_VERSION,
|
|
fatalError: errorDetails(error),
|
|
passed: false,
|
|
realApplicationUpgradePerformed: false,
|
|
installerLaunched: false
|
|
})
|
|
} catch {
|
|
// Preserve the original failure.
|
|
}
|
|
})
|