feat: ship skill and MCP workflows with signed updater for v0.0.7
This commit is contained in:
1 parent
1b52df0603
commit
a01085c656
136 files changed
+18646
-217
No files matched your search
@@ -0,0 +1,666 @@
|
||||
#!/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.
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user