| import { spawn } from 'node:child_process'; |
| import { createHash } from 'node:crypto'; |
| import { createReadStream, readFileSync } from 'node:fs'; |
| import { access, mkdir, readFile, readdir } from 'node:fs/promises'; |
| import { createRequire } from 'node:module'; |
| import { createServer } from 'node:net'; |
| import { join, relative, resolve, sep } from 'node:path'; |
| import { |
| ASSET_LICENSED_RENDERER_PACKAGES, |
| collectProductionClosure, |
| collectWorkspaceClosure, |
| } from './third-party-closure.mjs'; |
| |
| // `timeoutMs` is opt-in, for the commands that have actually hung: node-pty |
| // under conpty keeps a handle open after its child exits. Everything else runs |
| // unbounded on purpose — codesign and notarization assessment on a full app |
| // bundle have no honest upper bound, and a wrong deadline fails a good release. |
| // The workflow timeout is the outer bound; the verifier's stage log says where. |
| export function runCommand(command, args, options = {}) { |
| return new Promise((resolvePromise, reject) => { |
| const child = spawn(command, args, { |
| cwd: options.cwd ?? process.cwd(), |
| env: { ...process.env, ...options.env }, |
| stdio: [options.input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], |
| }); |
| let stdout = ''; |
| let stderr = ''; |
| const deadline = |
| options.timeoutMs === undefined |
| ? null |
| : setTimeout(() => { |
| child.kill('SIGKILL'); |
| reject( |
| new Error( |
| `${command} ${args.join(' ')} did not finish within ${options.timeoutMs}ms` + |
| `${stdout.trim() ? `\nstdout: ${stdout.trim()}` : ''}` + |
| `${stderr.trim() ? `\nstderr: ${stderr.trim()}` : ''}`, |
| ), |
| ); |
| }, options.timeoutMs); |
| const settle = (finish) => (value) => { |
| if (deadline) clearTimeout(deadline); |
| finish(value); |
| }; |
| resolvePromise = settle(resolvePromise); |
| reject = settle(reject); |
| child.stdout.setEncoding('utf8'); |
| child.stderr.setEncoding('utf8'); |
| child.stdout.on('data', (chunk) => { |
| stdout += chunk; |
| }); |
| child.stderr.on('data', (chunk) => { |
| stderr += chunk; |
| }); |
| child.once('error', reject); |
| if (options.input !== undefined) child.stdin.end(options.input); |
| child.once('exit', (code, signal) => { |
| if (code === 0) { |
| resolvePromise({ stdout, stderr }); |
| return; |
| } |
| reject( |
| new Error( |
| `${command} ${args.join(' ')} failed with ${ |
| signal ? `signal ${signal}` : `exit code ${code}` |
| }\n${stderr.trim()}`, |
| ), |
| ); |
| }); |
| }); |
| } |
| |
| export async function assertMissing(path) { |
| try { |
| await access(path); |
| } catch (error) { |
| if (error?.code === 'ENOENT') return; |
| throw error; |
| } |
| throw new Error(`Forbidden release resource exists: ${path}`); |
| } |
| |
| /** |
| * The authoritative CDP port: Chromium announces it on stderr once the |
| * DevTools socket is actually bound. Callers spawn with |
| * `--remote-debugging-port=0` and wait for this instead of pre-reserving a |
| * port — reserve-then-release had a race window in which another process |
| * could take the port, leaving Electron listening elsewhere while the |
| * verifier polled the stale number for its full deadline ("did not expose |
| * CDP ... fetch failed", observed repeatedly on busy CI runners). |
| */ |
| export function waitForDevToolsPort(child, { timeoutMs = 30_000 } = {}) { |
| return new Promise((resolvePromise, reject) => { |
| let buffer = ''; |
| const cleanup = () => { |
| clearTimeout(timeout); |
| child.stderr.off('data', onData); |
| child.off('exit', onExit); |
| }; |
| const timeout = setTimeout(() => { |
| cleanup(); |
| reject( |
| new Error( |
| `Packaged Maka did not announce a DevTools port within ${timeoutMs}ms.` + |
| `${buffer.trim() ? `\n${buffer.trim()}` : ''}`, |
| ), |
| ); |
| }, timeoutMs); |
| const onData = (chunk) => { |
| buffer = `${buffer}${chunk}`.slice(-16_384); |
| const match = /DevTools listening on ws:\/\/127\.0\.0\.1:(\d+)\//.exec(buffer); |
| if (match) { |
| cleanup(); |
| resolvePromise(Number(match[1])); |
| } |
| }; |
| const onExit = () => { |
| cleanup(); |
| reject( |
| new Error( |
| `Packaged Maka exited before announcing a DevTools port.` + |
| `${buffer.trim() ? `\n${buffer.trim()}` : ''}`, |
| ), |
| ); |
| }; |
| child.stderr.on('data', onData); |
| child.once('exit', onExit); |
| }); |
| } |
| |
| function delay(milliseconds) { |
| return new Promise((resolvePromise) => { |
| setTimeout(resolvePromise, milliseconds); |
| }); |
| } |
| |
| // The default deadline is generous on purpose: windows-2025 runners have shown |
| // first-page creation taking beyond 30 seconds when a smoke follows multiple |
| // installs in the same job, and a too-tight deadline fails a good build. The |
| // wait is still bounded and fail-closed; a dead child short-circuits it. |
| export async function findRendererTarget(port, child, { timeoutMs = 90_000 } = {}) { |
| const deadline = Date.now() + timeoutMs; |
| let lastError; |
| while (Date.now() < deadline) { |
| if (child.exitCode !== null) { |
| throw new Error(`Packaged Maka exited before its renderer was ready.`); |
| } |
| try { |
| // A connect that hangs (half-open or filtered socket) would otherwise |
| // run into the OS connect timeout and overshoot the stated deadline by |
| // minutes — observed as a ~6-minute "90 seconds" failure on CI. |
| const response = await fetch(`http://127.0.0.1:${port}/json/list`, { |
| signal: AbortSignal.timeout(2_000), |
| }); |
| if (response.ok) { |
| const targets = await response.json(); |
| const page = targets.find( |
| (target) => target.type === 'page' && target.webSocketDebuggerUrl, |
| ); |
| if (page) return page; |
| } |
| } catch (error) { |
| lastError = error; |
| } |
| await delay(250); |
| } |
| // `fetch failed` alone says nothing; the cause chain carries the socket |
| // errno (ECONNREFUSED vs ETIMEDOUT vs ECONNRESET), which is the evidence |
| // that distinguishes "DevTools never listened" from "something filtered it". |
| const described = []; |
| for (let error = lastError; error; error = error.cause) { |
| if (Array.isArray(error.errors) && error.errors.length) { |
| described.push(error.errors.map((inner) => inner.message ?? String(inner)).join(' & ')); |
| } else { |
| described.push(error.message ?? String(error)); |
| } |
| } |
| throw new Error( |
| `Packaged Maka renderer did not expose CDP within ${Math.round(timeoutMs / 1000)} seconds${ |
| described.length ? `: ${described.join(' <- ')}` : '' |
| }.`, |
| ); |
| } |
| |
| /** |
| * Evaluate one expression in a renderer over CDP and return its |
| * `returnByValue` result. `awaitPromise` resolves a returned promise before |
| * reporting, which is how the auto-update harness drives `window.maka.app` |
| * calls; the plain smoke below keeps its original synchronous expression. |
| */ |
| export async function evaluateInRenderer( |
| webSocketDebuggerUrl, |
| expression, |
| { awaitPromise = false, timeoutMs = 10_000 } = {}, |
| ) { |
| if (typeof WebSocket !== 'function') { |
| throw new Error('The release verifier requires Node.js WebSocket support.'); |
| } |
| const socket = new WebSocket(webSocketDebuggerUrl); |
| try { |
| // The handshake needs its own bound: a DevTools port that accepts TCP |
| // but never speaks raises neither `open` nor `error`, and an unbounded |
| // await here would make every retry loop built on this helper hang to |
| // the workflow timeout instead of failing one probe. |
| await new Promise((resolvePromise, reject) => { |
| const timeout = setTimeout(() => { |
| reject(new Error(`CDP WebSocket did not open within ${timeoutMs}ms.`)); |
| }, timeoutMs); |
| socket.addEventListener( |
| 'open', |
| () => { |
| clearTimeout(timeout); |
| resolvePromise(); |
| }, |
| { once: true }, |
| ); |
| socket.addEventListener( |
| 'error', |
| (event) => { |
| clearTimeout(timeout); |
| reject(event.error ?? new Error('CDP WebSocket connection failed.')); |
| }, |
| { once: true }, |
| ); |
| }); |
| } catch (error) { |
| socket.close(); |
| throw error; |
| } |
| |
| try { |
| return await new Promise((resolvePromise, reject) => { |
| const timeout = setTimeout(() => { |
| reject(new Error('CDP renderer evaluation timed out.')); |
| }, timeoutMs); |
| socket.addEventListener('message', (event) => { |
| const message = JSON.parse(String(event.data)); |
| if (message.id !== 1) return; |
| clearTimeout(timeout); |
| if (message.error) { |
| reject(new Error(message.error.message)); |
| return; |
| } |
| if (message.result?.exceptionDetails) { |
| reject( |
| new Error( |
| message.result.exceptionDetails.exception?.description ?? |
| message.result.exceptionDetails.text ?? |
| 'Renderer evaluation threw.', |
| ), |
| ); |
| return; |
| } |
| resolvePromise(message.result?.result?.value); |
| }); |
| socket.send( |
| JSON.stringify({ |
| id: 1, |
| method: 'Runtime.evaluate', |
| params: { |
| expression, |
| returnByValue: true, |
| awaitPromise, |
| }, |
| }), |
| ); |
| }); |
| } finally { |
| socket.close(); |
| } |
| } |
| |
| export const RENDERER_STATE_EXPRESSION = `({ |
| readyState: document.readyState, |
| hasBridge: Boolean(window.maka), |
| hasRoot: Boolean(document.querySelector('#root')), |
| hasPreloadSkeleton: Boolean(document.querySelector('#root > .maka-preload')), |
| hasAppShell: Boolean(document.querySelector('#root [data-agents-page]')) |
| })`; |
| |
| function evaluateRenderer(webSocketDebuggerUrl, timeoutMs) { |
| return evaluateInRenderer(webSocketDebuggerUrl, RENDERER_STATE_EXPRESSION, { timeoutMs }); |
| } |
| |
| export function isPackagedRendererUsable(rendererState) { |
| return ( |
| rendererState?.readyState === 'complete' && |
| rendererState.hasBridge === true && |
| rendererState.hasRoot === true && |
| rendererState.hasPreloadSkeleton === false && |
| rendererState.hasAppShell === true |
| ); |
| } |
| |
| /** |
| * Poll a freshly booted packaged app over CDP until its renderer reports the |
| * usable state. One evaluation can stall past its own socket timeout while |
| * the renderer is still booting — observed on the Windows release runners, |
| * where a single timed-out `Runtime.evaluate` used to fail the whole gate. |
| * The deadline here is the authority: an individual failed probe is retried, |
| * not fatal, and only the deadline (or child exit) fails the wait. The last |
| * probe error or renderer state is reported as evidence either way. |
| */ |
| export async function waitForUsableRenderer( |
| webSocketDebuggerUrl, |
| child, |
| { deadlineMs = 30_000, description = 'Packaged renderer' } = {}, |
| ) { |
| const deadline = Date.now() + deadlineMs; |
| let state; |
| let lastError; |
| for (;;) { |
| try { |
| state = await evaluateRenderer( |
| webSocketDebuggerUrl, |
| Math.max(1, Math.min(10_000, deadline - Date.now())), |
| ); |
| lastError = undefined; |
| if (isPackagedRendererUsable(state)) return; |
| } catch (error) { |
| lastError = error; |
| } |
| if (child.exitCode !== null) { |
| throw new Error(`${description} exited before it became usable.`); |
| } |
| if (Date.now() >= deadline) { |
| throw new Error( |
| `${description} did not become usable within ${deadlineMs}ms: ${ |
| lastError ? lastError.message : JSON.stringify(state) |
| }`, |
| ); |
| } |
| await delay(250); |
| } |
| } |
| |
| export async function stopChild(child) { |
| if (child.exitCode !== null) return; |
| child.kill('SIGTERM'); |
| const exited = await Promise.race([ |
| new Promise((resolvePromise) => child.once('exit', () => resolvePromise(true))), |
| delay(5_000).then(() => false), |
| ]); |
| if (!exited && child.exitCode === null) { |
| child.kill('SIGKILL'); |
| } |
| } |
| |
| export function makePtyProbe(shellFile, shellArgs, runtimeHostSetupPackage) { |
| return String.raw` |
| const { createRequire } = require('node:module'); |
| const requireFromApp = createRequire(process.argv[1]); |
| const appManifest = requireFromApp('./package.json'); |
| const expectedRuntimeHostSetupPackage = ${JSON.stringify(runtimeHostSetupPackage)}; |
| if ( |
| expectedRuntimeHostSetupPackage !== undefined && |
| appManifest.runtimeHostSetupPackage !== expectedRuntimeHostSetupPackage |
| ) { |
| console.error( |
| 'Packaged Runtime Host setup package mismatch: expected ' + |
| expectedRuntimeHostSetupPackage + |
| ', found ' + |
| JSON.stringify(appManifest.runtimeHostSetupPackage), |
| ); |
| process.exit(1); |
| } |
| const pty = requireFromApp('node-pty'); |
| const child = pty.spawn(${JSON.stringify(shellFile)}, ${JSON.stringify(shellArgs)}, { |
| name: 'xterm-color', |
| cols: 80, |
| rows: 24, |
| cwd: process.cwd(), |
| env: process.env, |
| }); |
| let output = ''; |
| const timeout = setTimeout(() => { |
| console.error('node-pty packaged smoke timed out'); |
| process.exit(1); |
| }, 5000); |
| child.onData((data) => { |
| output += data; |
| }); |
| child.onExit(({ exitCode }) => { |
| clearTimeout(timeout); |
| const ok = exitCode === 0 && output.includes('maka-node-pty-ok'); |
| // conpty keeps a handle open after its child exits, so on Windows this process |
| // never ends on its own and the probe would hang instead of report. Writing |
| // through the callback exits only once the output has been flushed. |
| const stream = ok ? process.stdout : process.stderr; |
| const message = ok ? 'maka-node-pty-ok' : 'node-pty packaged smoke failed'; |
| stream.write(message + '\n', () => process.exit(ok ? 0 : 1)); |
| }); |
| `; |
| } |
| |
| // A packaged app is verified against the user state of whoever runs the |
| // verifier, so every probe gets its own home. The macOS and Windows variables |
| // are set together because Electron and Node read different ones per platform |
| // and setting the unused ones is inert. |
| export function isolatedUserEnv(homeDirectory, { temporaryDirectory = homeDirectory } = {}) { |
| return { |
| HOME: homeDirectory, |
| USERPROFILE: homeDirectory, |
| APPDATA: join(homeDirectory, 'AppData', 'Roaming'), |
| LOCALAPPDATA: join(homeDirectory, 'AppData', 'Local'), |
| TMPDIR: temporaryDirectory, |
| TEMP: temporaryDirectory, |
| TMP: temporaryDirectory, |
| }; |
| } |
| |
| export async function smokePackagedRenderer(executable, { workingDirectory } = {}) { |
| const home = join(workingDirectory, 'home'); |
| const userData = join(workingDirectory, 'user-data'); |
| const userEnv = isolatedUserEnv(home); |
| await mkdir(home, { recursive: true }); |
| await mkdir(userData, { recursive: true }); |
| await mkdir(userEnv.APPDATA, { recursive: true }); |
| await mkdir(userEnv.LOCALAPPDATA, { recursive: true }); |
| const child = spawn( |
| executable, |
| ['--remote-debugging-port=0', `--user-data-dir=${userData}`, '--enable-logging=stderr'], |
| { |
| cwd: workingDirectory, |
| env: { |
| ...process.env, |
| MAKA_SKIP_SHELL_ENV: '1', |
| ...userEnv, |
| }, |
| stdio: ['ignore', 'ignore', 'pipe'], |
| }, |
| ); |
| let stderr = ''; |
| child.stderr.setEncoding('utf8'); |
| child.stderr.on('data', (chunk) => { |
| stderr = `${stderr}${chunk}`.slice(-16_384); |
| }); |
| |
| try { |
| const port = await waitForDevToolsPort(child); |
| const target = await findRendererTarget(port, child); |
| await waitForUsableRenderer(target.webSocketDebuggerUrl, child); |
| } catch (error) { |
| throw new Error(`${error.message}${stderr.trim() ? `\n${stderr.trim()}` : ''}`); |
| } finally { |
| await stopChild(child); |
| } |
| } |
| |
| /** |
| * What `app.asar` actually carries under `node_modules`, read from the archive |
| * header rather than inferred from a manifest. |
| */ |
| const desktopRoot = resolve(import.meta.dirname, '..', 'apps', 'desktop'); |
| |
| /** Every file path under `prefix` inside the archive, depth first. */ |
| // Archive paths are stored `/`-joined, but `@electron/asar` resolves a lookup |
| // by splitting it on `path.sep`. On Windows that turns `dist/main/x.js` into a |
| // single name and the file is reported missing, so the lookup — and only the |
| // lookup — is localized before it crosses the API. |
| export function asarLookupPath(archivePath, separator = sep) { |
| return separator === '/' ? archivePath : archivePath.split('/').join(separator); |
| } |
| |
| function asarFilesUnder(header, prefix) { |
| const root = prefix.split('/').reduce((node, part) => node?.files?.[part], header); |
| const paths = []; |
| const walk = (node, path) => { |
| for (const [name, child] of Object.entries(node?.files ?? {})) { |
| const next = `${path}/${name}`; |
| if (child.files) walk(child, next); |
| else paths.push(next); |
| } |
| }; |
| walk(root, prefix); |
| return paths; |
| } |
| |
| // Line-bounded on purpose: a lazy cross-line match reads the word `from` |
| // inside a comment as an import and reports the prose that follows it. A |
| // multi-line `import {` list is covered by its closing line. |
| const BARE_IMPORT_PATTERNS = [ |
| /^[ \t]*(?:import|export)[ \t]+(?:[^'"\n]*?[ \t]+from[ \t]+)?['"]([^'"\n]+)['"]/gm, |
| /^[ \t]*\}[ \t]+from[ \t]+['"]([^'"\n]+)['"]/gm, |
| /\b(?:import|require)\([ \t]*['"]([^'"\n]+)['"][ \t]*\)/g, |
| ]; |
| |
| /** Package names the given module text imports by name, ignoring builtins. */ |
| export function bareImportedPackages(source) { |
| const names = new Set(); |
| for (const pattern of BARE_IMPORT_PATTERNS) { |
| for (const [, specifier] of source.matchAll(pattern)) { |
| if (/^[./]|^node:/.test(specifier)) continue; |
| const segments = specifier.split('/'); |
| names.add(specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0]); |
| } |
| } |
| return names; |
| } |
| |
| // Provided by the Electron runtime rather than the archive's node_modules, so |
| // they are resolvable without appearing in the packaged closure. |
| const RUNTIME_PROVIDED_PACKAGES = new Set(['electron']); |
| |
| // Loaded on first use, not at module load. `verify-windows-harness.test.mjs` |
| // imports this module in the CI step that deliberately runs before `npm ci` |
| // ("on Node alone"), so a top-level import of a declared dependency would |
| // fail there even though the dependency is correctly declared. |
| const requirePeer = createRequire(import.meta.url); |
| let asarApi; |
| function asar() { |
| asarApi ??= requirePeer('@electron/asar'); |
| return asarApi; |
| } |
| |
| /** |
| * Every package in the archive as `name` -> set of versions, read from each |
| * package's own shipped `package.json`. |
| * |
| * Names alone were not enough: the closure declares exact versions, so an |
| * archive carrying `react@18` against a closure that declares `react@19` |
| * matched by name and passed. A version that does not appear in the closure |
| * is a leak whatever it is called. |
| */ |
| function asarNodeModules(asarPath) { |
| const { header } = asar().getRawHeader(asarPath); |
| const names = new Map(); |
| const unpackedRoot = `${asarPath}.unpacked`; |
| const versionOf = (node, packagePath) => { |
| const manifestNode = node?.files?.['package.json']; |
| if (!manifestNode) return undefined; |
| try { |
| // Native modules are packaged with `unpacked: true`: the header still |
| // lists them, but the bytes live beside the archive in |
| // `app.asar.unpacked`, where `extractFile` cannot reach them. Reading |
| // the header alone would report every native module as version-less |
| // and fail the identity comparison for packages that are perfectly |
| // correct. |
| const source = manifestNode.unpacked |
| ? readFileSync(join(unpackedRoot, ...packagePath.split('/'), 'package.json'), 'utf8') |
| : asar() |
| .extractFile(asarPath, asarLookupPath(`${packagePath}/package.json`)) |
| .toString('utf8'); |
| const manifest = JSON.parse(source); |
| return typeof manifest.version === 'string' ? manifest.version : undefined; |
| } catch { |
| // A package whose manifest cannot be read is reported by name with no |
| // version, which fails the identity comparison rather than skipping it. |
| return undefined; |
| } |
| }; |
| // Recursive: npm nests a second copy under a package when versions |
| // conflict (node_modules/foo/node_modules/bar), and a walk that stops at |
| // the top level would certify an archive it has not fully inspected. |
| const record = (name, node, packagePath) => { |
| if (!names.has(name)) names.set(name, new Set()); |
| names.get(name).add(versionOf(node, packagePath)); |
| }; |
| const collect = (modules, prefix) => { |
| for (const [name, node] of Object.entries(modules ?? {})) { |
| if (name.startsWith('.')) continue; // .bin, .package-lock.json |
| if (name.startsWith('@')) { |
| for (const [scoped, scopedNode] of Object.entries(node.files ?? {})) { |
| const path = `${prefix}/${name}/${scoped}`; |
| record(`${name}/${scoped}`, scopedNode, path); |
| collect(scopedNode.files?.node_modules?.files, `${path}/node_modules`); |
| } |
| } else { |
| const path = `${prefix}/${name}`; |
| record(name, node, path); |
| collect(node.files?.node_modules?.files, `${path}/node_modules`); |
| } |
| } |
| }; |
| collect(header.files?.node_modules?.files, 'node_modules'); |
| return names; |
| } |
| |
| /** |
| * The packaged archive is the only thing that can answer this. A manifest |
| * assertion would still pass if electron-builder changed how it walks the |
| * closure, if a transitive package leaked back in, or if the renderer stopped |
| * bundling one of these — none of which are visible from `package.json`. |
| */ |
| export async function assertPackagedDependencyClosure( |
| resourcesPath, |
| { collectClosure, collectPackagedAllowlist } = {}, |
| ) { |
| const asarPath = join(resourcesPath, 'app.asar'); |
| const packaged = asarNodeModules(asarPath); |
| |
| // The archive may carry exactly the Node production closure — that is the |
| // graph electron-builder walks. Comparing against it catches any leak, a |
| // renderer-only transitive package included, not just the declared roots. |
| const allowed = collectPackagedAllowlist |
| ? await collectPackagedAllowlist() |
| : collectProductionClosure('@maka/desktop'); |
| const leaked = []; |
| for (const [name, versions] of packaged) { |
| const permitted = allowed.get(name); |
| for (const version of versions) { |
| if (permitted?.has(version)) continue; |
| leaked.push(version === undefined ? name : `${name}@${version}`); |
| } |
| } |
| if (leaked.length > 0) { |
| throw new Error( |
| `app.asar carries packages outside the production closure: ${leaked.join(', ')}`, |
| ); |
| } |
| // The PTY stack reaches these from the main process, so their absence would |
| // mean the opposite failure — a closure trimmed past what actually runs. |
| for (const required of ['@xterm/headless', '@xterm/addon-unicode11']) { |
| if (!packaged.has(required)) { |
| throw new Error(`app.asar is missing ${required}, which the PTY stack loads`); |
| } |
| } |
| |
| // Validate what ships using what ships: the notice inside the artifact, not |
| // the checkout copy — a package whose shipped notice is stale or empty must |
| // fail here even while the source tree's copy is complete. |
| const notices = await readFile( |
| join(resourcesPath, 'licenses', 'npm', 'THIRD_PARTY_NOTICES.txt'), |
| 'utf8', |
| ); |
| // The same closure the generator wrote the notices from — the Node |
| // production closure plus everything reachable from the renderer roots — |
| // so coverage is the complete shipped set, not only the declared roots. |
| const closure = collectClosure |
| ? await collectClosure() |
| : collectWorkspaceClosure({ |
| workspaceName: '@maka/desktop', |
| manifestPath: join(desktopRoot, 'package.json'), |
| }); |
| const uncovered = closure |
| .filter(({ name }) => !ASSET_LICENSED_RENDERER_PACKAGES.has(name)) |
| .filter(({ name, version }) => !notices.includes(`\nPackage: ${name}@${version}\n`)) |
| .map(({ name, version }) => `${name}@${version}`); |
| if (uncovered.length > 0) { |
| throw new Error( |
| `shipped THIRD_PARTY_NOTICES.txt is missing packages the artifact ships: ${uncovered.join(', ')}`, |
| ); |
| } |
| // Asset-licensed packages (the OFL Geist fonts) carry their license as a |
| // vendored file instead of an npm-notice entry; that file must ship too. |
| const closureNames = new Set(closure.map(({ name }) => name)); |
| for (const [name, licensePath] of ASSET_LICENSED_RENDERER_PACKAGES) { |
| if (!closureNames.has(name)) continue; |
| await access(join(resourcesPath, licensePath)).catch(() => { |
| throw new Error(`shipped license file for ${name} is missing: ${licensePath}`); |
| }); |
| } |
| |
| // Matching node_modules against the closure proves no package leaked in or |
| // was trimmed out; it says nothing about whether the shipped code can |
| // resolve what it imports. A module that imports a package the archive no |
| // longer carries throws ERR_MODULE_NOT_FOUND only in the packaged app, and |
| // only when something loads it — a lazily loaded main module would reach a |
| // user rather than a build. |
| const unresolvable = new Map(); |
| for (const path of asarFilesUnder(asar().getRawHeader(asarPath).header, 'dist')) { |
| if (!/\.(?:js|cjs|mjs)$/.test(path)) continue; |
| for (const name of bareImportedPackages( |
| asar().extractFile(asarPath, asarLookupPath(path)).toString('utf8'), |
| )) { |
| // Being in the closure is not enough — the code has to resolve at |
| // runtime, and only the archive can answer that. A package that is |
| // allowed but absent is exactly the ERR_MODULE_NOT_FOUND this check |
| // exists to catch. |
| if (packaged.has(name) || RUNTIME_PROVIDED_PACKAGES.has(name)) continue; |
| if (!unresolvable.has(name)) unresolvable.set(name, path); |
| } |
| } |
| if (unresolvable.size > 0) { |
| const detail = [...unresolvable].map(([name, path]) => `${name} (${path})`).join(', '); |
| throw new Error(`app.asar ships code importing packages it does not carry: ${detail}`); |
| } |
| |
| // The artifact's own record of what the renderer bundle contains — written |
| // by the vite build from the rollup module graph plus emitted-asset origins. |
| // Every recorded package must be inside the declared closure, so a package |
| // that reaches the bundle through any path fails release verification even |
| // if it never appears under node_modules in the archive. |
| let recordBuffer; |
| try { |
| recordBuffer = asar().extractFile( |
| asarPath, |
| asarLookupPath('dist-renderer/bundled-npm-packages.json'), |
| ); |
| } catch { |
| throw new Error('app.asar does not carry dist-renderer/bundled-npm-packages.json'); |
| } |
| const bundled = JSON.parse(recordBuffer.toString('utf8')); |
| const undeclared = bundled.filter((name) => !closureNames.has(name)); |
| if (undeclared.length > 0) { |
| throw new Error( |
| `renderer bundle carries packages outside the declared closure: ${undeclared.join(', ')}`, |
| ); |
| } |
| } |
| |
| export async function assertPackagedResources( |
| resourcesPath, |
| { |
| requirePath, |
| forbidPath = assertMissing, |
| requireWindowsSandbox = process.platform === 'win32', |
| // The credential-manager exclusion is written against the POSIX Git |
| // distribution, whose commands sit in a flat `libexec/git-core`. The |
| // Windows distribution has a different layout (`git/cmd/git.exe`, and its |
| // own `.dll` set that git itself loads), so the exclusion does not apply |
| // there and neither does this assertion. Scoped rather than guessed: |
| // trimming Windows needs its own measurement first. |
| assertGitTree = process.platform !== 'win32', |
| // The upgrade-lifecycle check runs this against a previously released |
| // build, which predates the disclaimer being packaged. Requiring it there |
| // would fail a release that was correct when it shipped. |
| requireDisclaimer = true, |
| } = {}, |
| ) { |
| const required = [ |
| 'app.asar', |
| 'bundled-tools.json', |
| 'bundled-git.json', |
| join('licenses', 'git', 'LICENSE.txt'), |
| join('licenses', 'git', 'SOURCE_OFFER.txt'), |
| join('workers', 'filesystem-worker.js'), |
| join('licenses', 'maka', 'LICENSE'), |
| join('licenses', 'maka', 'NOTICE'), |
| ...(requireDisclaimer ? [join('licenses', 'maka', 'DISCLAIMER-WIP')] : []), |
| join('licenses', 'dugite', 'LICENSE'), |
| join('licenses', 'git', 'NOTICE.txt'), |
| join('licenses', 'electron', 'LICENSE'), |
| join('licenses', 'electron', 'LICENSES.chromium.html'), |
| join('licenses', 'npm', 'THIRD_PARTY_NOTICES.txt'), |
| join('licenses', 'renderer', 'THIRD_PARTY_LICENSES.txt'), |
| join('licenses', 'renderer', 'GEIST_LICENSE.txt'), |
| join('licenses', 'renderer', 'GEIST_MONO_LICENSE.txt'), |
| join('licenses', 'renderer', 'ANT_DESIGN_ICONS_LICENSE.txt'), |
| join('licenses', 'renderer', 'SIMPLE_ICONS_LICENSE.md'), |
| join('licenses', 'renderer', 'TDESIGN_ICONS_LICENSE.txt'), |
| join('licenses', 'renderer', 'ALLOGO_LICENSE.txt'), |
| join('licenses', 'renderer', 'SEMI_ICONS_LICENSE.txt'), |
| join('licenses', 'renderer', 'MINGCUTE_APACHE_LICENSE.txt'), |
| ...(requireWindowsSandbox |
| ? [ |
| join('windows-sandbox', 'maka-windows-sandbox.exe'), |
| join('licenses', 'cargo', 'THIRD_PARTY_NOTICES.txt'), |
| ] |
| : []), |
| ]; |
| for (const path of required) { |
| await requirePath(join(resourcesPath, path)); |
| } |
| const forbidden = [ |
| join('tools', 'officecli'), |
| join('licenses', 'officecli'), |
| // cua-driver is gone from this repository, and these two forbids stay for the |
| // same reason the officecli ones next to them do: `apps/desktop/resources/bin` |
| // is gitignored, so a binary a developer prepared before this change is still |
| // sitting in their tree and would be packaged without anything noticing. |
| join('bin', 'cua-driver'), |
| join('tools', 'cua-driver'), |
| // maka-cu is built from source locally and is not signed, so it may not be in |
| // a packaged build at all — an ad-hoc helper fails notarization for the whole |
| // app, and `distributionReady` is false for exactly this reason. |
| join('bin', 'maka-cu'), |
| join('tools', 'maka-cu'), |
| // Git Credential Manager and its .NET runtime are excluded from the |
| // packaged Git distribution: Maka sets `credential.helper=` on every git |
| // invocation, so nothing can reach them. Naming the entry point rather |
| // than the runtime keeps this readable; `assertPackagedGitIsComplete` |
| // covers the rest by listing the directory. |
| ...(assertGitTree ? [join('git', 'libexec', 'git-core', 'git-credential-manager')] : []), |
| ]; |
| for (const path of forbidden) { |
| await forbidPath(join(resourcesPath, path)); |
| } |
| if (assertGitTree) await assertPackagedGitIsComplete(resourcesPath, requirePath); |
| } |
| |
| /** |
| * The credential-manager exclusion is a name filter over one flat directory |
| * that also holds git's own commands, so it can over-match — and a git that |
| * lost `git-remote-http` fails at clone time in a user's hands, not here. |
| * |
| * Both halves are asserted: the commands Maka actually invokes must be |
| * present, and nothing from the .NET runtime may be. Checking only the |
| * absence would pass just as well for an empty directory. |
| */ |
| async function assertPackagedGitIsComplete(resourcesPath, requirePath) { |
| const gitCore = join(resourcesPath, 'git', 'libexec', 'git-core'); |
| // `git-workspace-service.ts` drives add / cat-file / commit / config / |
| // for-each-ref / init / rev-parse / status / worktree. Those are builtins |
| // reached through the `git` binary; what has to exist on disk is the |
| // binary itself plus the helpers git dispatches to as separate programs. |
| for (const name of ['git', 'git-remote-http', 'git-http-fetch', 'git-shell']) { |
| await requirePath(join(gitCore, name)); |
| } |
| await requirePath(join(resourcesPath, 'git', 'bin', 'git')); |
| await requirePath(join(resourcesPath, 'git', 'share', 'git-core', 'templates')); |
| |
| // Recursive, because the runtime is not flat: GCM ships 13 localisation |
| // directories each holding one `System.CommandLine.resources.dll`, and on |
| // Linux two native UI libraries beside the binary. A top-level-only scan |
| // reported a clean tree while all of that was still packaged — which is |
| // exactly what it did until a review checked a real artifact. |
| const runtimeLeftovers = await findCredentialManagerLeftovers(gitCore, gitCore); |
| if (runtimeLeftovers.length > 0) { |
| throw new Error( |
| `packaged git still carries the credential-manager runtime: ${runtimeLeftovers.join(', ')}`, |
| ); |
| } |
| } |
| |
| /** |
| * Every credential-manager artefact under one directory, as paths relative to |
| * the git-core root so the failure names where to look. |
| * |
| * Git's own commands here are executables and shell scripts, so matching on |
| * the .NET/native extensions cannot implicate them. `NOTICE` and |
| * `uninstall.sh` are GCM's installation leftovers, not git's — git keeps its |
| * notices in `share/doc`. |
| */ |
| async function findCredentialManagerLeftovers(directory, root) { |
| const leftovers = []; |
| const entries = await readdir(directory, { withFileTypes: true }); |
| for (const entry of entries) { |
| const absolute = join(directory, entry.name); |
| if (entry.isDirectory()) { |
| leftovers.push(...(await findCredentialManagerLeftovers(absolute, root))); |
| continue; |
| } |
| if (!entry.isFile()) continue; |
| const isRuntimeLibrary = /\.(?:dll|dylib|so)$/u.test(entry.name); |
| const isCredentialManager = |
| entry.name.startsWith('git-credential-manager') || |
| entry.name === 'createdump' || |
| entry.name === 'NOTICE' || |
| entry.name === 'uninstall.sh'; |
| if (isRuntimeLibrary || isCredentialManager) { |
| leftovers.push(relative(root, absolute)); |
| } |
| } |
| return leftovers; |
| } |
| |
| /** |
| * Recursive content manifest of a directory tree: POSIX-normalized relative |
| * paths, sorted, each with its file's SHA-256. Nothing is skipped — an install |
| * tree has no entries whose drift would be acceptable — and anything that is |
| * not a plain file or directory (symlinks, junctions, devices) throws: an |
| * install tree must not contain them, and silently hashing a link target would |
| * make two different trees compare equal. |
| */ |
| export async function directoryTreeManifest(rootDirectory) { |
| const entries = []; |
| const walk = async (directory, prefix) => { |
| const children = await readdir(directory, { withFileTypes: true }); |
| // Empty directories are recorded (trailing slash, null hash) so a |
| // restore that loses one shows up as `missing` — files alone cannot |
| // witness an empty directory. |
| if (children.length === 0 && prefix !== '') { |
| entries.push({ path: `${prefix}/`, sha256: null }); |
| return; |
| } |
| for (const child of children) { |
| const absolute = join(directory, child.name); |
| const relative = prefix === '' ? child.name : `${prefix}/${child.name}`; |
| if (child.isDirectory()) { |
| await walk(absolute, relative); |
| } else if (child.isFile()) { |
| entries.push({ path: relative, sha256: await sha256File(absolute) }); |
| } else { |
| throw new Error(`Unsupported directory entry in ${rootDirectory}: ${relative}`); |
| } |
| } |
| }; |
| await walk(rootDirectory, ''); |
| entries.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)); |
| return entries; |
| } |
| |
| /** Difference between two directoryTreeManifest results, keyed by path. */ |
| export function diffTreeManifests(before, after) { |
| const beforeByPath = new Map(before.map((entry) => [entry.path, entry.sha256])); |
| const afterByPath = new Map(after.map((entry) => [entry.path, entry.sha256])); |
| const missing = before.filter((entry) => !afterByPath.has(entry.path)).map((entry) => entry.path); |
| const extra = after.filter((entry) => !beforeByPath.has(entry.path)).map((entry) => entry.path); |
| const changed = before |
| .filter((entry) => afterByPath.has(entry.path) && afterByPath.get(entry.path) !== entry.sha256) |
| .map((entry) => entry.path); |
| return { missing, extra, changed }; |
| } |
| |
| export async function sha256File(path) { |
| const hash = createHash('sha256'); |
| const file = createReadStream(path); |
| for await (const chunk of file) hash.update(chunk); |
| return hash.digest('hex'); |
| } |