Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/desktop/electron-builder.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
resolveDesktopBuildVersion,
resolveRuntimeHostSetupPackage,
} from '../../scripts/desktop-nightly.mjs';
import { stampMacAppExecutableUuids } from '../../scripts/macos-executable-uuid.mjs';
import { workspaceReleaseManifest } from '../../scripts/release-cli-file-policy.mjs';
import { resolveProductManifestIdentity } from '../../scripts/product-release-identity.mjs';

Expand Down Expand Up @@ -60,6 +61,16 @@ async function stageReleaseManifests({ packager }) {
packager.config.files.push({ from: stage, to: 'node_modules/@maka' });
}

// Runs before signing, so the stamped executables are what gets signed. See
// scripts/macos-executable-uuid.mjs for why Electron's own LC_UUID cannot ship.
async function stampMacExecutableUuids({ electronPlatformName, appOutDir, packager }) {
if (electronPlatformName !== 'darwin') return;
await stampMacAppExecutableUuids(
join(appOutDir, `${packager.appInfo.productFilename}.app`),
packager.appInfo.id,
);
}

const rootManifest = readManifest('../../package.json');
const { runtimeHostSetupPackage } = resolveProductManifestIdentity({
rootManifest,
Expand All @@ -73,6 +84,7 @@ const baseDesktopBuilderConfig = {
artifactName: 'Maka-${version}-mac-${arch}.${ext}',
asar: true,
beforePack: stageReleaseManifests,
afterPack: stampMacExecutableUuids,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 note: I verified that in electron-builder 26.17.0 the config afterPack runs before fuses and doSignAfterPack, and mac.forceCodeSigning: true prevents an unsigned (linker-signature-invalidated) arm64 build. PR CI never runs this hook on macOS, though. The first real run is the post-merge signed Desktop Nightly (macos-15 / macos-15-intel), so please watch that run.

extraMetadata: { runtimeHostSetupPackage, makaUpdateChannel: 'release' },
directories: {
output: 'release',
Expand Down
5 changes: 3 additions & 2 deletions docs/windows-test-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ Locations intentionally omit line numbers so unrelated edits do not invalidate t
|---|---:|
| windows-backend-gap | 27 |
| portable-candidate | 60 |
| platform-contract | 49 |
| platform-contract | 50 |

Total Windows-excluded declarations: **136**
Total Windows-excluded declarations: **137**

## Inventory

Expand Down Expand Up @@ -155,6 +155,7 @@ Total Windows-excluded declarations: **136**
| platform-contract | `packages/storage/src/__tests__/workspace-identity.test.ts` an unreadable ancestor Git repository fails closed until permissions return | `process.platform === 'win32' ? 'POSIX permissions are required for an unreadable HEAD fixture' : false` |
| portable-candidate | `packages/storage/src/__tests__/workspace-identity.test.ts` a dangling .git symlink in the workspace itself blocks marker publication | `process.platform === 'win32' ? 'Windows symlink creation requires elevated privileges or Developer Mode' : false` |
| platform-contract | `packages/storage/src/__tests__/workspace-identity.test.ts` an unmarked read-only workspace fails without leaving marker state | `process.platform === 'win32' ? 'POSIX permissions are required to create a read-only workspace fixture' : false` |
| platform-contract | `scripts/macos-executable-uuid.test.mjs` rewrites the UUID of Electron's own executable | `process.platform !== 'darwin' && 'Electron ships a Mach-O executable only on macOS'` |
| platform-contract | `scripts/macos-update-archive.test.mjs` the macOS update ZIP keeps bundle symlinks and modification times | `process.platform !== 'darwin' && 'the macOS update ZIP is built on macOS'` |
| portable-candidate | `scripts/qualify-released-cli-state-root.test.mjs` starts the liveness window after a delayed Runtime Host Ready | `process.platform === 'win32'` |
| portable-candidate | `scripts/qualify-released-cli-state-root.test.mjs` starts the liveness window after the real Runtime Host is ready | `process.platform === 'win32'` |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@
"check:runtime-host-peer-dependencies": "node scripts/generate-runtime-host-peer-dependencies.mjs --check",
"generate:runtime-host-peer-notices": "node scripts/generate-runtime-host-peer-notices.mjs",
"check:runtime-host-peer-notices": "node scripts/generate-runtime-host-peer-notices.mjs --check",
"check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:model-metadata && npm run check:product-release-identity && npm run check:asf-npm && node --test --test-concurrency=1 scripts/staple-macos-artifact.test.mjs scripts/product-nightly.test.mjs scripts/desktop-release-targets.test.mjs scripts/verify-linux-harness.test.mjs scripts/desktop-nightly.test.mjs scripts/desktop-nightly-stage.test.mjs scripts/desktop-nightly-release.test.mjs scripts/desktop-nightly-workflow-policy.test.mjs scripts/audit-shipped-dependencies.test.mjs scripts/product-release.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/qualify-released-cli-state-root.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/macos-update-archive.test.mjs scripts/third-party-closure.test.mjs scripts/generate-third-party-notices.test.mjs scripts/source-legal-inventory.test.mjs scripts/sync-model-metadata.test.mjs scripts/prepare-windows-upgrade-baseline.test.mjs scripts/windows-package-source-closure.test.mjs",
"check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:model-metadata && npm run check:product-release-identity && npm run check:asf-npm && node --test --test-concurrency=1 scripts/staple-macos-artifact.test.mjs scripts/product-nightly.test.mjs scripts/desktop-release-targets.test.mjs scripts/verify-linux-harness.test.mjs scripts/desktop-nightly.test.mjs scripts/desktop-nightly-stage.test.mjs scripts/desktop-nightly-release.test.mjs scripts/desktop-nightly-workflow-policy.test.mjs scripts/audit-shipped-dependencies.test.mjs scripts/product-release.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/qualify-released-cli-state-root.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/macos-executable-uuid.test.mjs scripts/macos-update-archive.test.mjs scripts/third-party-closure.test.mjs scripts/generate-third-party-notices.test.mjs scripts/source-legal-inventory.test.mjs scripts/sync-model-metadata.test.mjs scripts/prepare-windows-upgrade-baseline.test.mjs scripts/windows-package-source-closure.test.mjs",
"package:macos-arm64": "node scripts/package-macos.mjs arm64",
"package:macos-x64": "node scripts/package-macos.mjs x64",
"verify:macos": "node scripts/verify-macos-dmg.mjs",
Expand Down
2 changes: 2 additions & 0 deletions scripts/ci-test-plan.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ const RELEASE_CONTRACT_FILES = new Set([
'scripts/product-nightly.test.mjs',
'scripts/verify-packaged-app.mjs',
'scripts/verify-packaged-app.test.mjs',
'scripts/macos-executable-uuid.mjs',
'scripts/macos-executable-uuid.test.mjs',
'scripts/macos-update-archive.test.mjs',
'scripts/verify-windows-autoupdate.mjs',
'scripts/verify-windows-installer-lifecycle.mjs',
Expand Down
9 changes: 5 additions & 4 deletions scripts/desktop-release-targets.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -192,12 +192,13 @@ const DESKTOP_PROJECT_DIRECTORY = fileURLToPath(new URL('../apps/desktop', impor
*
* Copied through JSON rather than `structuredClone`: everything electron-builder reads
* here has to survive serialization anyway, and the structured clone algorithm rejected
* this object on Node 24 while accepting it on 26. `beforePack` is the one function in
* the configuration; it is carried across by reference and never invoked by this test.
* this object on Node 24 while accepting it on 26. `beforePack` and `afterPack` are the
* functions in the configuration; they are carried across by reference and never invoked
* by this test.
*/
function isolatedBuilderConfig() {
const { beforePack, ...rest } = resolveDesktopBuilderConfig({});
return { ...JSON.parse(JSON.stringify(rest)), beforePack };
const { beforePack, afterPack, ...rest } = resolveDesktopBuilderConfig({});
return { ...JSON.parse(JSON.stringify(rest)), beforePack, afterPack };
}

/**
Expand Down
171 changes: 171 additions & 0 deletions scripts/macos-executable-uuid.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/*
* 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.
*/

// electron-builder ships Electron's prebuilt executables renamed, not relinked,
// so every app on the same Electron release carries the same Mach-O LC_UUID.
// macOS Local Network Privacy (NECP) identifies a program by its main
// executable's LC_UUID, so a shared UUID lets one app's grant or denial land on
// another (Apple TN3178, r. 30421029). The packaging hook stamps each executable
// with a UUID of its own before signing; the release verifier checks the stamp
// survived.

import { createHash } from 'node:crypto';
import { readFile, readdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';

const MH_MAGIC_64 = 0xfeedfacf;
const FAT_MAGIC = 0xcafebabe;
const LC_UUID = 0x1b;
const MACH_HEADER_64_SIZE = 32;
const FAT_HEADER_SIZE = 8;
const FAT_ARCH_SIZE = 20;
const UUID_COMMAND_SIZE = 24;

// Fixed namespace for the UUIDv5 names below. Changing it changes every
// shipped UUID, which macOS treats as a different program.
const MAKA_EXECUTABLE_UUID_NAMESPACE = Buffer.from('4195730630d44e84a842a14db6f67a8f', 'hex');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: this namespace and the appId/executableName/cputype name format are now a persistent identity contract, as is productName, because it names the executables. Consider a golden-value test, for example the expected UUID for com.maka.desktop/Maka/arm64, so a refactor can't silently re-key users' Local Network decisions.


/**
* The UUID an executable ships with. Deterministic, so the release verifier can
* recompute it from the bundle alone; the app id keeps it distinct from every
* other Electron app and the CPU type keeps the arm64 and x64 builds apart.
*/
export function expectedExecutableUuid({ appId, executableName, cputype }) {
const digest = createHash('sha1')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Preserve native-build identity, or validate the deliberate loss of it. Two different native payloads/upstream UUIDs currently get the same output UUID for the same app/name/CPU. Apple's TN3178 distinguishes native images for symbol matching. There is no repository crash-upload pipeline, but that does not eliminate OS crash-report or external-symbol costs. I have not reproduced an application failure; this is a follow-up tradeoff, not a P2 regression claim.

.update(MAKA_EXECUTABLE_UUID_NAMESPACE)
.update(`${appId}/${executableName}/${cputype}`)
.digest();
const uuid = digest.subarray(0, 16);
uuid[6] = (uuid[6] & 0x0f) | 0x50;
uuid[8] = (uuid[8] & 0x3f) | 0x80;
return formatUuid(uuid);
}

function formatUuid(bytes) {
const hex = bytes.toString('hex').toUpperCase();
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}

// Each slice as [start, end) within the file; a thin executable is one slice.
function slices(binary) {
if (binary.length < 4 || binary.readUInt32BE(0) !== FAT_MAGIC) {
return [{ start: 0, end: binary.length }];
}
if (binary.length < FAT_HEADER_SIZE) throw new Error('truncated universal header');
const count = binary.readUInt32BE(4);
if (count === 0) throw new Error('universal executable has no slices');
if (FAT_HEADER_SIZE + count * FAT_ARCH_SIZE > binary.length) {
throw new Error('truncated universal header');
}
return Array.from({ length: count }, (_, index) => {
const entry = FAT_HEADER_SIZE + index * FAT_ARCH_SIZE;
const start = binary.readUInt32BE(entry + 8);
const end = start + binary.readUInt32BE(entry + 12);
if (end > binary.length) throw new Error('universal slice extends past the file');
return { start, end };
});
}

function uuidCommandOffset(binary, { start, end }) {
if (end < start + MACH_HEADER_64_SIZE) throw new Error('truncated Mach-O header');
if (binary.readUInt32LE(start) !== MH_MAGIC_64) {
throw new Error('not a 64-bit Mach-O executable');
}
const cputype = binary.readUInt32LE(start + 4);
const commandCount = binary.readUInt32LE(start + 16);
const commandsEnd = start + MACH_HEADER_64_SIZE + binary.readUInt32LE(start + 20);
if (commandsEnd > end) throw new Error('Mach-O load commands extend past the slice');
let cursor = start + MACH_HEADER_64_SIZE;
for (let index = 0; index < commandCount; index++) {
if (cursor + 8 > commandsEnd) throw new Error('malformed Mach-O load command');
const command = binary.readUInt32LE(cursor);
const size = binary.readUInt32LE(cursor + 4);
if (size < 8 || cursor + size > commandsEnd) {
throw new Error('malformed Mach-O load command');
}
if (command === LC_UUID) {
if (size !== UUID_COMMAND_SIZE) throw new Error('malformed LC_UUID load command');
return { cputype, offset: cursor + 8 };
}
cursor += size;
}
throw new Error('Mach-O executable has no LC_UUID load command');
}

/** Every slice's CPU type and LC_UUID, in file order. */
export function machOUuids(binary) {
return slices(binary).map((slice) => {
const { cputype, offset } = uuidCommandOffset(binary, slice);
return { cputype, uuid: formatUuid(binary.subarray(offset, offset + 16)) };
});
}

/** A copy of `binary` with each slice's LC_UUID replaced by its expected UUID. */
export function stampMachOUuids(binary, { appId, executableName }) {
const stamped = Buffer.from(binary);
for (const slice of slices(stamped)) {
const { cputype, offset } = uuidCommandOffset(stamped, slice);
const uuid = expectedExecutableUuid({ appId, executableName, cputype });
Buffer.from(uuid.replaceAll('-', ''), 'hex').copy(stamped, offset);
}
return stamped;
}

/**
* The app's main executable and each helper app's, read from the bundle rather
* than from Electron's naming convention: one file in every `Contents/MacOS`.
*/
export async function macAppExecutables(appPath) {
const frameworks = join(appPath, 'Contents', 'Frameworks');
const helpers = (await readdir(frameworks)).filter((name) => name.endsWith('.app')).sort();
const bundles = [appPath, ...helpers.map((name) => join(frameworks, name))];
return Promise.all(
bundles.map(async (bundle) => {
const directory = join(bundle, 'Contents', 'MacOS');
const entries = await readdir(directory);
if (entries.length !== 1) {
throw new Error(`Expected one executable in ${directory}, found ${entries.length}.`);
}
return { name: entries[0], path: join(directory, entries[0]) };
}),
);
}

export async function stampMacAppExecutableUuids(appPath, appId) {
for (const { name, path } of await macAppExecutables(appPath)) {
await writeFile(path, stampMachOUuids(await readFile(path), { appId, executableName: name }));
}
}

export async function assertMacAppExecutableUuids(appPath, appId) {
const executables = await macAppExecutables(appPath);
if (executables.length < 2) {
throw new Error(`Expected Maka and its helper executables in ${appPath}.`);
}
for (const { name, path } of executables) {
for (const { cputype, uuid } of machOUuids(await readFile(path))) {
const expected = expectedExecutableUuid({ appId, executableName: name, cputype });
if (uuid !== expected) {
throw new Error(
`${name} ships LC_UUID ${uuid}, expected ${expected}; the packaging hook did not stamp it.`,
);
}
}
}
}
Loading
Loading