Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
1152 lines
43 KiB
JavaScript
1152 lines
43 KiB
JavaScript
/*
|
|
* 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, 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);
|
|
}
|
|
}
|
|
|
|
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) 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, 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 = '';
|
|
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);
|
|
if (verifyMaximizeRestore) {
|
|
await exercisePackagedRendererMaximizeRestore(target, 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(', ')}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** 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,
|
|
} = {},
|
|
) {
|
|
const required = [
|
|
'app.asar',
|
|
'bundled-tools.json',
|
|
...(requireCanonicalIcon ? [join('assets', 'icon.png')] : []),
|
|
join('workers', 'filesystem-worker.js'),
|
|
...(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', '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 = [
|
|
// 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');
|
|
}
|