Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
4 changes: 4 additions & 0 deletions apps/desktop/electron-builder.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ import {
} from '../../scripts/desktop-nightly.mjs';
import { workspaceReleaseManifest } from '../../scripts/release-cli-file-policy.mjs';
import { resolveProductManifestIdentity } from '../../scripts/product-release-identity.mjs';
// afterPack runs before signing, so the rewritten LC_UUIDs (#6026) are covered
// by the code signature and notarization sees a self-consistent bundle.
import { rewritePackagedMacosUuids as afterPack } from '../../scripts/macos-lc-uuid.mjs';

function readManifest(relativePath) {
return JSON.parse(readFileSync(new URL(relativePath, import.meta.url), 'utf8'));
Expand Down Expand Up @@ -73,6 +76,7 @@ const baseDesktopBuilderConfig = {
artifactName: 'Maka-${version}-mac-${arch}.${ext}',
asar: true,
beforePack: stageReleaseManifests,
afterPack,
extraMetadata: { runtimeHostSetupPackage, makaUpdateChannel: 'release' },
directories: {
output: 'release',
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-artifact-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-artifact-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-lc-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
1 change: 1 addition & 0 deletions scripts/ci-test-plan.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ const RELEASE_CONTRACT_FILES = new Set([
'scripts/verify-packaged-app.mjs',
'scripts/verify-packaged-app.test.mjs',
'scripts/macos-update-archive.test.mjs',
'scripts/macos-lc-uuid.test.mjs',
'scripts/verify-windows-autoupdate.mjs',
'scripts/verify-windows-installer-lifecycle.mjs',
'scripts/verify-windows-x64.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. The lifecycle hooks are the only
* 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
195 changes: 195 additions & 0 deletions scripts/macos-lc-uuid.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
/*
* 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.
*/

/**
* Rewrites the LC_UUID load command of every Mach-O in a packaged macOS app
Comment thread
ggbdpq marked this conversation as resolved.
Resolved
* bundle (#6026).
*
* electron-builder renames Electron's executable files but never relinks them,
* so every shipped binary keeps the UUID burned into it by Electron's build:
* identical across every Electron app of the same version. macOS 15+ local-
* network privacy (NECP) identifies a program by its main executable's
* LC_UUID, so Maka shares a privacy identity with every other Electron 43
* install and never gets an entry of its own.
*
* The replacement is UUIDv5 (SHA-1, RFC 4122) over the app id, the executable
* file name and the slice's CPU type — deterministic, so repackaging the same
* inputs yields the same identity, and running the rewrite twice changes
* nothing. It runs from the `afterPack` hook, which is before code signing:
* the signature then covers the rewritten bytes, and notarization sees a
* self-consistent bundle.
*/

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

const MH_MAGIC_64 = 0xfeedfacf;
// Fat headers store their magic big-endian, so a little-endian read of
// 0xcafebabe / 0xcafebabf yields the swapped values below.
const FAT_MAGICS = new Map([
[0xbebafeca, { archEntrySize: 20 }],
[0xbfbafeca, { archEntrySize: 32 }],
]);
const LC_UUID = 0x1b;
const MACHO_HEADER_64_SIZE = 32;
const LOAD_COMMAND_HEADER_SIZE = 8;
const UUID_SIZE = 16;

// RFC 4122 Appendix C.1 namespace for fully-qualified domain names.
const DNS_NAMESPACE_UUID = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
// Derived, not invented: no magic constant here needs to be trusted, only the
// project domain this namespace is pulled from.
const MAKA_UUID_NAMESPACE = uuidv5(DNS_NAMESPACE_UUID, 'maka.apache.org');

// The cputypes that actually appear in an Electron fat binary. arm64e shares
// the ARM64 cputype and differs only in cpusubtype, so it is not
// distinguishable — and not distinguishable is fine, because Electron ships
// no arm64e slices; anything unexpected still gets a stable name via the
// fallback below.
const CPU_TYPE_NAMES = new Map([
[0x01000007, 'x86_64'],
[0x0100000c, 'arm64'],
]);

export function uuidv5(namespaceUuid, name) {
const digest = createHash('sha1')
.update(Buffer.from(namespaceUuid.replaceAll('-', ''), 'hex'))
.update(name, 'utf8')
.digest();
digest[6] = (digest[6] & 0x0f) | 0x50; // version 5
digest[8] = (digest[8] & 0x3f) | 0x80; // RFC 4122 variant
const hex = digest.toString('hex', 0, 16);
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
}

function cpuTypeName(cpuType) {
return CPU_TYPE_NAMES.get(cpuType) ?? `cpu-0x${cpuType.toString(16)}`;
}

/**
* The deterministic replacement UUID for one executable slice: distinct per
* app, per executable and per architecture, yet stable across rebuilds.
*/
export function deterministicExecutableUuid(appId, executableName, cpuType) {
// '/' cannot appear in a file name, so the joined name is unambiguous.
return uuidv5(MAKA_UUID_NAMESPACE, `${appId}/${executableName}/${cpuTypeName(cpuType)}`);
Comment thread
ggbdpq marked this conversation as resolved.
Resolved
}

/**
* Offsets of every LC_UUID payload in a Mach-O buffer (thin 64-bit or fat),
* each with its slice's CPU type. Thin 32-bit slices cannot appear in an
* Electron bundle and are rejected rather than misparsed.
*/
export function machoLcUuidLocations(buffer) {
const magic = buffer.readUInt32LE(0);
const fat = FAT_MAGICS.get(magic);
if (!fat) return thinLcUuidLocations(buffer, 0);
const locations = [];
const sliceCount = buffer.readUInt32BE(4);
for (let index = 0; index < sliceCount; index += 1) {
const entry = 8 + index * fat.archEntrySize;
const sliceOffset = Number(
fat.archEntrySize === 32 ? buffer.readBigUInt64BE(entry + 8) : buffer.readUInt32BE(entry + 8),
);
locations.push(...thinLcUuidLocations(buffer, sliceOffset));
}
return locations;
}

function thinLcUuidLocations(buffer, base) {
const magic = buffer.readUInt32LE(base);
if (magic !== MH_MAGIC_64) {
throw new Error(`unsupported Mach-O magic 0x${magic.toString(16)} at offset ${base}`);
}
const cpuType = buffer.readUInt32LE(base + 4);
const commandCount = buffer.readUInt32LE(base + 16);
const locations = [];
let cursor = base + MACHO_HEADER_64_SIZE;
for (let index = 0; index < commandCount; index += 1) {
const commandSize = buffer.readUInt32LE(cursor + 4);
if (commandSize < LOAD_COMMAND_HEADER_SIZE) {
throw new Error(`corrupt load command size ${commandSize} at offset ${cursor}`);
}
if (buffer.readUInt32LE(cursor) === LC_UUID) {
locations.push({ offset: cursor + LOAD_COMMAND_HEADER_SIZE, cpuType });
}
cursor += commandSize;
}
return locations;
}

/**
* Replaces every LC_UUID payload in `buffer` in place; the buffer length and
* all other bytes are left exactly as they were.
*/
export function rewriteBufferUuids(buffer, appId, executableName) {
for (const { offset, cpuType } of machoLcUuidLocations(buffer)) {
Buffer.from(
deterministicExecutableUuid(appId, executableName, cpuType).replaceAll('-', ''),
'hex',
).copy(buffer, offset);
}
}

async function rewriteExecutableFile(path, appId) {
const buffer = await readFile(path);
if (machoLcUuidLocations(buffer).length === 0) {
throw new Error(`no LC_UUID load command in packaged executable ${path}`);
}
rewriteBufferUuids(buffer, appId, basename(path));
await writeFile(path, buffer);
}

async function findAppBundleExecutables(directory) {
const executables = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const path = join(directory, entry.name);
if (entry.name.endsWith('.app')) {
const macOS = join(path, 'Contents', 'MacOS');
for (const file of await readdir(macOS, { withFileTypes: true })) {
if (file.isFile()) executables.push(join(macOS, file.name));
}
}
// Helper bundles are nested inside the main one
// (`Maka.app/Contents/Frameworks/*.app`), so the walk has to descend
// through found bundles to reach them.
executables.push(...(await findAppBundleExecutables(path)));
}
return executables;
}

/**
* The electron-builder `afterPack` hook: rewrites the main executable and
* every helper executable of the packaged macOS app. Runs before signing, so
* the new UUIDs are covered by the signature. Other platforms have no Mach-O
* to fix and are untouched.
*/
export async function rewritePackagedMacosUuids(context) {
if (context.electronPlatformName !== 'darwin') return;
const appId = context.packager.appInfo.id;
const executables = await findAppBundleExecutables(context.appOutDir);
if (executables.length === 0) {
throw new Error(`no .app bundle found under ${context.appOutDir}`);
}
for (const executable of executables) {
await rewriteExecutableFile(executable, appId);
}
}
Loading
Loading