| /* | |
| * Licensed to the Apache Software Foundation (ASF) under one | |
| * or more contributor license agreements. See the NOTICE file | |
| * distributed with this work for additional information | |
| * regarding copyright ownership. The ASF licenses this file | |
| * to you under the Apache License, Version 2.0 (the | |
| * "License"); you may not use this file except in compliance | |
| * with the License. You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, | |
| * software distributed under the License is distributed on an | |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| * KIND, either express or implied. See the License for the | |
| * specific language governing permissions and limitations | |
| * under the License. | |
| */ | |
| 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, posix, 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( | |
| Object.assign( | |
| new Error( | |
| `${command} ${args.join(' ')} failed with ${ | |
| signal ? `signal ${signal}` : `exit code ${code}` | |
| }` + | |
| `${stdout.trim() ? `\nstdout: ${stdout.trim()}` : ''}` + | |
| `${stderr.trim() ? `\nstderr: ${stderr.trim()}` : ''}`, | |
| ), | |
| { code, signal, stdout, stderr }, | |
| ), | |
| ); | |
| }); | |
| }); | |
| } | |
| 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(); | |
| // Startup and recovery windows can appear before the application. | |
| // Only the packaged main entry owns the bridge and app-shell contract; | |
| // selecting the first page pins subsequent probes to a temporary window. | |
| const page = targets.find((target) => { | |
| if (target.type !== 'page' || !target.webSocketDebuggerUrl) return false; | |
| try { | |
| const url = new URL(target.url); | |
| return url.protocol === 'file:' && url.pathname.endsWith('/dist-renderer/index.html'); | |
| } catch { | |
| return false; | |
| } | |
| }); | |
| 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); | |
| } | |
| } | |
| const WINDOW_LAYOUT_EXPRESSION = `(async () => { | |
| await new Promise((resolve) => requestAnimationFrame(() => resolve())); | |
| const rect = (selector) => { | |
| const element = document.querySelector(selector); | |
| if (!element) return null; | |
| const bounds = element.getBoundingClientRect(); | |
| return { | |
| x: bounds.x, | |
| y: bounds.y, | |
| width: bounds.width, | |
| height: bounds.height, | |
| }; | |
| }; | |
| return { | |
| devicePixelRatio: window.devicePixelRatio, | |
| innerWidth: window.innerWidth, | |
| innerHeight: window.innerHeight, | |
| outerWidth: window.outerWidth, | |
| outerHeight: window.outerHeight, | |
| documentWidth: document.documentElement.clientWidth, | |
| documentHeight: document.documentElement.clientHeight, | |
| visualViewportWidth: window.visualViewport?.width ?? null, | |
| visualViewportHeight: window.visualViewport?.height ?? null, | |
| screenAvailWidth: window.screen.availWidth, | |
| screenAvailHeight: window.screen.availHeight, | |
| html: rect('html'), | |
| body: rect('body'), | |
| root: rect('#root'), | |
| appFrame: rect('.appFrame'), | |
| }; | |
| })()`; | |
| function dimensionsMatch(actual, expected, tolerance = 1) { | |
| return Number.isFinite(actual) && Math.abs(actual - expected) <= tolerance; | |
| } | |
| export function rendererLayoutMatchesViewport(layout) { | |
| if (!Number.isFinite(layout?.innerWidth) || layout.innerWidth <= 0) return false; | |
| if (!Number.isFinite(layout?.innerHeight) || layout.innerHeight <= 0) return false; | |
| if (!dimensionsMatch(layout.documentWidth, layout.innerWidth)) return false; | |
| if (!dimensionsMatch(layout.documentHeight, layout.innerHeight)) return false; | |
| if (!dimensionsMatch(layout.visualViewportWidth, layout.innerWidth)) return false; | |
| if (!dimensionsMatch(layout.visualViewportHeight, layout.innerHeight)) return false; | |
| for (const bounds of [layout.html, layout.body, layout.root, layout.appFrame]) { | |
| if (!bounds) return false; | |
| if (!dimensionsMatch(bounds.x, 0) || !dimensionsMatch(bounds.y, 0)) return false; | |
| if (!dimensionsMatch(bounds.width, layout.innerWidth)) return false; | |
| if (!dimensionsMatch(bounds.height, layout.innerHeight)) return false; | |
| } | |
| return true; | |
| } | |
| export function rendererViewportMatchesNativeClient(layout, nativeWindow) { | |
| if (!rendererLayoutMatchesViewport(layout)) return false; | |
| if (!Number.isFinite(layout.devicePixelRatio) || layout.devicePixelRatio <= 0) return false; | |
| if (!Number.isFinite(nativeWindow?.clientWidth) || nativeWindow.clientWidth <= 0) return false; | |
| if (!Number.isFinite(nativeWindow?.clientHeight) || nativeWindow.clientHeight <= 0) return false; | |
| const widthScale = nativeWindow.clientWidth / layout.innerWidth; | |
| const heightScale = nativeWindow.clientHeight / layout.innerHeight; | |
| // Electron can report CSS or physical viewport pixels depending on the | |
| // packaged app's DPI-awareness mode. Proportional agreement with the native | |
| // client is the stable contract; equating that scale to DPR is not. | |
| return dimensionsMatch(widthScale, heightScale, 0.01); | |
| } | |
| function windowsWindowProbeScript(processId, nextWindowState, restoredBounds) { | |
| const stateTransition = | |
| nextWindowState === undefined | |
| ? '' | |
| : String.raw` | |
| [void][MakaNativeWindow]::ShowWindowAsync($handle, ${nextWindowState === 'maximized' ? 3 : 9}) | |
| $expectedZoomed = ${nextWindowState === 'maximized' ? '$true' : '$false'} | |
| $stateDeadline = (Get-Date).AddSeconds(2) | |
| while ([MakaNativeWindow]::IsZoomed($handle) -ne $expectedZoomed) { | |
| if ((Get-Date) -ge $stateDeadline) { | |
| throw 'Packaged Maka did not enter the requested native window state.' | |
| } | |
| Start-Sleep -Milliseconds 25 | |
| }`; | |
| const resizeRestoredWindow = restoredBounds | |
| ? String.raw` | |
| if (-not [MakaNativeWindow]::MoveWindow( | |
| $handle, | |
| ${restoredBounds.x}, | |
| ${restoredBounds.y}, | |
| ${restoredBounds.width}, | |
| ${restoredBounds.height}, | |
| $true | |
| )) { | |
| throw 'MoveWindow failed for packaged Maka.' | |
| }` | |
| : ''; | |
| return String.raw` | |
| $ErrorActionPreference = 'Stop' | |
| Add-Type -TypeDefinition @' | |
| using System; | |
| using System.Runtime.InteropServices; | |
| public static class MakaNativeWindow { | |
| [StructLayout(LayoutKind.Sequential)] | |
| public struct RECT { | |
| public int Left; | |
| public int Top; | |
| public int Right; | |
| public int Bottom; | |
| } | |
| [DllImport("user32.dll")] | |
| public static extern bool GetClientRect(IntPtr hWnd, out RECT rect); | |
| [DllImport("user32.dll")] | |
| public static extern bool IsZoomed(IntPtr hWnd); | |
| [DllImport("user32.dll")] | |
| public static extern bool MoveWindow( | |
| IntPtr hWnd, | |
| int x, | |
| int y, | |
| int width, | |
| int height, | |
| bool repaint | |
| ); | |
| [DllImport("user32.dll")] | |
| public static extern bool ShowWindowAsync(IntPtr hWnd, int command); | |
| } | |
| '@ | |
| $process = Get-Process -Id ${processId} -ErrorAction Stop | |
| $process.Refresh() | |
| $handle = $process.MainWindowHandle | |
| if ($handle -eq [IntPtr]::Zero) { | |
| throw 'Packaged Maka has no main window handle.' | |
| } | |
| ${stateTransition} | |
| ${resizeRestoredWindow} | |
| $rect = New-Object MakaNativeWindow+RECT | |
| if (-not [MakaNativeWindow]::GetClientRect($handle, [ref]$rect)) { | |
| throw 'GetClientRect failed for packaged Maka.' | |
| } | |
| $windowState = if ([MakaNativeWindow]::IsZoomed($handle)) { 'maximized' } else { 'normal' } | |
| [pscustomobject]@{ | |
| windowState = $windowState | |
| clientWidth = $rect.Right - $rect.Left | |
| clientHeight = $rect.Bottom - $rect.Top | |
| } | ConvertTo-Json -Compress | |
| `; | |
| } | |
| async function readWindowsNativeWindow(processId, nextWindowState, restoredBounds) { | |
| const { stdout } = await runCommand( | |
| 'powershell', | |
| [ | |
| '-NoProfile', | |
| '-NonInteractive', | |
| '-Command', | |
| windowsWindowProbeScript(processId, nextWindowState, restoredBounds), | |
| ], | |
| { timeoutMs: 10_000 }, | |
| ); | |
| const json = stdout.trim().split(/\r?\n/u).at(-1); | |
| if (!json) throw new Error('Windows native window probe returned no state.'); | |
| return JSON.parse(json); | |
| } | |
| async function captureWindowLayout(rendererUrl, processId) { | |
| const [nativeWindow, layout] = await Promise.all([ | |
| readWindowsNativeWindow(processId), | |
| evaluateInRenderer(rendererUrl, WINDOW_LAYOUT_EXPRESSION, { | |
| awaitPromise: true, | |
| timeoutMs: 10_000, | |
| }), | |
| ]); | |
| return { nativeWindow, layout }; | |
| } | |
| async function waitForWindowLayout( | |
| rendererUrl, | |
| child, | |
| expectedWindowState, | |
| { timeoutMs = 30_000 } = {}, | |
| ) { | |
| const deadline = Date.now() + timeoutMs; | |
| let observed; | |
| let lastError; | |
| while (Date.now() < deadline) { | |
| if (child.exitCode !== null) { | |
| throw new Error(`Packaged Maka exited during the ${expectedWindowState} transition.`); | |
| } | |
| try { | |
| observed = await captureWindowLayout(rendererUrl, child.pid); | |
| lastError = undefined; | |
| if ( | |
| observed.nativeWindow?.windowState === expectedWindowState && | |
| rendererViewportMatchesNativeClient(observed.layout, observed.nativeWindow) | |
| ) { | |
| return observed; | |
| } | |
| } catch (error) { | |
| lastError = error; | |
| } | |
| await delay(250); | |
| } | |
| throw new Error( | |
| `Packaged renderer did not settle in ${expectedWindowState} state within ${timeoutMs}ms: ${ | |
| lastError ? lastError.message : JSON.stringify(observed) | |
| }`, | |
| ); | |
| } | |
| export async function exercisePackagedRendererMaximizeRestore(rendererTarget, child) { | |
| const rendererUrl = rendererTarget.webSocketDebuggerUrl; | |
| await readWindowsNativeWindow(child.pid, 'normal', { | |
| x: 80, | |
| y: 60, | |
| width: 800, | |
| height: 600, | |
| }); | |
| const restored = await waitForWindowLayout(rendererUrl, child, 'normal'); | |
| await readWindowsNativeWindow(child.pid, 'maximized'); | |
| const maximized = await waitForWindowLayout(rendererUrl, child, 'maximized'); | |
| await readWindowsNativeWindow(child.pid, 'normal'); | |
| const restoredAgain = await waitForWindowLayout(rendererUrl, child, 'normal'); | |
| const restoredClient = restored.nativeWindow; | |
| const maximizedClient = maximized.nativeWindow; | |
| const restoredAgainClient = restoredAgain.nativeWindow; | |
| if ( | |
| maximizedClient.clientWidth < restoredClient.clientWidth || | |
| maximizedClient.clientHeight < restoredClient.clientHeight || | |
| (maximizedClient.clientWidth === restoredClient.clientWidth && | |
| maximizedClient.clientHeight === restoredClient.clientHeight) | |
| ) { | |
| throw new Error( | |
| `Packaged Maka maximize smoke did not grow the native client: ${JSON.stringify({ | |
| restored: restoredClient, | |
| maximized: maximizedClient, | |
| })}`, | |
| ); | |
| } | |
| if ( | |
| !dimensionsMatch(restoredAgainClient.clientWidth, restoredClient.clientWidth, 2) || | |
| !dimensionsMatch(restoredAgainClient.clientHeight, restoredClient.clientHeight, 2) | |
| ) { | |
| throw new Error( | |
| `Packaged Maka did not restore its original native client size: ${JSON.stringify({ | |
| restored: restoredClient, | |
| restoredAgain: restoredAgainClient, | |
| })}`, | |
| ); | |
| } | |
| console.log( | |
| `[packaged-renderer] window transition: ${JSON.stringify({ | |
| restored, | |
| maximized, | |
| restoredAgain, | |
| })}`, | |
| ); | |
| } | |
| export async function stopChild(child) { | |
| if (child.exitCode !== null || child.signalCode !== null) return; | |
| let onExit; | |
| const exited = new Promise((resolvePromise) => { | |
| onExit = resolvePromise; | |
| child.once('exit', onExit); | |
| }); | |
| let timeout; | |
| try { | |
| child.kill('SIGTERM'); | |
| await Promise.race([ | |
| exited, | |
| new Promise((resolvePromise) => { | |
| timeout = setTimeout(resolvePromise, 5_000); | |
| }), | |
| ]); | |
| clearTimeout(timeout); | |
| if (child.exitCode === null && child.signalCode === null) { | |
| child.kill('SIGKILL'); | |
| // Sending a signal does not mean the process has stopped writing to its | |
| // profile. Reap it before callers remove the temporary verification tree. | |
| await Promise.race([ | |
| exited, | |
| new Promise((_, reject) => { | |
| timeout = setTimeout(() => { | |
| reject(new Error(`Process ${child.pid} did not exit within 10000ms after SIGKILL`)); | |
| }, 10_000); | |
| }), | |
| ]); | |
| } | |
| } finally { | |
| clearTimeout(timeout); | |
| child.removeListener('exit', onExit); | |
| } | |
| } | |
| 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, verifyMaximizeRestore = false } = {}, | |
| ) { | |
| 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 = ''; | |
| let startupStderr = ''; | |
| let stderrLength = 0; | |
| child.stderr.setEncoding('utf8'); | |
| child.stderr.on('data', (chunk) => { | |
| startupStderr = `${startupStderr}${chunk}`.slice(0, 16_384); | |
| stderrLength += chunk.length; | |
| stderr = `${stderr}${chunk}`.slice(-16_384); | |
| }); | |
| try { | |
| const port = await waitForDevToolsPort(child); | |
| const target = await findRendererTarget(port, child); | |
| await waitForUsableRenderer(target.webSocketDebuggerUrl, child); | |
| if (verifyMaximizeRestore) { | |
| await exercisePackagedRendererMaximizeRestore(target, child); | |
| } | |
| } catch (error) { | |
| const diagnostics = | |
| stderrLength > 16_384 | |
| ? `${startupStderr}\n[packaged-renderer] last stderr output:\n${stderr}` | |
| : stderr; | |
| throw new Error(`${error.message}${diagnostics.trim() ? `\n${diagnostics.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 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, | |
| ]; | |
| function importedSpecifiers(source) { | |
| const specifiers = new Set(); | |
| for (const pattern of IMPORT_PATTERNS) { | |
| for (const [, specifier] of source.matchAll(pattern)) { | |
| specifiers.add(specifier); | |
| } | |
| } | |
| return specifiers; | |
| } | |
| // 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; | |
| const source = asar().extractFile(asarPath, asarLookupPath(path)).toString('utf8'); | |
| for (const specifier of importedSpecifiers(source)) { | |
| if (specifier.startsWith('.')) { | |
| const target = posix.normalize(posix.join(posix.dirname(path), specifier)); | |
| try { | |
| asar().statFile(asarPath, asarLookupPath(target)); | |
| } catch { | |
| throw new Error(`app.asar ships ${path} importing missing file ${target}`); | |
| } | |
| continue; | |
| } | |
| if (/^\/|^node:/.test(specifier)) continue; | |
| const segments = specifier.split('/'); | |
| const name = specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0]; | |
| // 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(', ')}`, | |
| ); | |
| } | |
| } | |
| /** Icon files the app ships, named by the artwork that exists in the repo. */ | |
| async function packagedIconCatalog() { | |
| const directory = new URL('../apps/desktop/assets/app-icons/', import.meta.url); | |
| const entries = await readdir(directory).catch(() => []); | |
| return entries | |
| .filter((name) => name.endsWith('.png')) | |
| .sort() | |
| .map((name) => join('assets', 'app-icons', name)); | |
| } | |
| export async function assertPackagedResources( | |
| resourcesPath, | |
| { | |
| requirePath, | |
| forbidPath = assertMissing, | |
| requireWindowsSandbox = 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, | |
| // Same shape as the disclaimer: the canonical icon began shipping as an | |
| // extra resource with the window-icon fix, and the permission overlay | |
| // reads it at runtime, so current builds must carry it — but a | |
| // previously released baseline predates it. | |
| requireCanonicalIcon = true, | |
| // Same shape again, for the picker's catalog: `assets/app-icons/` arrived | |
| // with the icon picker, so a previously released baseline necessarily | |
| // lacks all of it, and requiring it there would fail an upgrade check over | |
| // artifacts that were correct when they shipped. The canonical icon itself | |
| // is `requireCanonicalIcon` above, not this. | |
| requireAppIconCatalog = true, | |
| // Current Desktop builds ship the direct-peer Client addon beside its Rust | |
| // notices. Upgrade baselines may predate both resources. | |
| requireDirectPeerArtifact = true, | |
| // Upgrade baselines may still ship the worker as `filesystem-worker.js`. | |
| requireMjsFilesystemWorker = true, | |
| } = {}, | |
| ) { | |
| const required = [ | |
| 'app.asar', | |
| 'bundled-tools.json', | |
| ...(requireCanonicalIcon ? [join('assets', 'icon.png')] : []), | |
| ...(requireMjsFilesystemWorker ? [join('workers', 'filesystem-worker.mjs')] : []), | |
| ...(requireDirectPeerArtifact | |
| ? [ | |
| join('runtime-host-peer', 'maka_runtime_host_peer.node'), | |
| join('licenses', 'runtime-host-peer', 'THIRD_PARTY_NOTICES.txt'), | |
| ] | |
| : []), | |
| // The picker's catalog is read at runtime, and Electron reports a missing | |
| // file as an empty image rather than an error — a packaging change that | |
| // dropped one would ship a blank tile silently. | |
| // | |
| // The list comes from the artwork directory rather than from `APP_ICONS`, | |
| // because that enum only exists as build output: importing it would make | |
| // this verifier — and its test — unable to even load before a compile. The | |
| // other half of the chain is covered where it belongs, by the desktop test | |
| // that walks `APP_ICONS` and requires a 1024px master for every id. | |
| ...(requireAppIconCatalog ? await packagedIconCatalog() : []), | |
| join('licenses', 'maka', 'LICENSE'), | |
| join('licenses', 'maka', 'NOTICE'), | |
| ...(requireDisclaimer ? [join('licenses', 'maka', 'DISCLAIMER-WIP')] : []), | |
| 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', 'SIMPLE_ICONS_LICENSE.md'), | |
| join('licenses', 'renderer', 'TDESIGN_ICONS_LICENSE.txt'), | |
| join('licenses', 'renderer', 'ALLOGO_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 = [ | |
| // No packaged artifact carries Git any more, and no baseline that did is | |
| // still published, so the absence rule applies to every build this | |
| // verifier can be pointed at. | |
| 'git', | |
| 'bundled-git.json', | |
| join('licenses', 'dugite'), | |
| join('licenses', 'git'), | |
| 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'), | |
| ]; | |
| for (const path of forbidden) { | |
| await forbidPath(join(resourcesPath, path)); | |
| } | |
| } | |
| /** | |
| * 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'); | |
| } |