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
2 changes: 1 addition & 1 deletion .github/workflows/release-core.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ jobs:
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: cd apps/desktop && npm run notarize:mac:dmg

- name: Validate macOS release artifacts
- name: Validate macOS release artifacts and runtime payloads
run: cd apps/desktop && npm run validate:mac:artifacts

- name: Upload validated artifacts to workflow run
Expand Down
10 changes: 7 additions & 3 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"author": "Arul Sharma",
"license": "AGPL-3.0",
"scripts": {
"predev": "node ./scripts/clear-vite-cache.cjs",
"predev": "node ./scripts/clear-vite-cache.cjs && node ./scripts/normalize-runtime-binaries.cjs",
"prebuild": "node ./scripts/normalize-runtime-binaries.cjs",
"dev": "node ./scripts/ensure-electron.cjs && node ./scripts/dev.cjs",
"build": "tsup && vite build",
"dist:mac": "npm run build && electron-builder --mac --publish never",
Expand Down Expand Up @@ -130,13 +131,16 @@
"electron.cjs",
"package.json",
"vendor/**/*",
"!node_modules/onnxruntime-node/**",
"!node_modules/node-pty/build/**"
"!node_modules/onnxruntime-node/**"
],
"asarUnpack": [
"dist/main/adeMcpProxy.cjs",
"dist/main/packagedRuntimeSmoke.cjs",
"node_modules/node-pty/**/*",
"node_modules/@huggingface/transformers/node_modules/onnxruntime-node/**",
"vendor/crsqlite/**"
],
"afterPack": "./scripts/after-pack-runtime-fixes.cjs",
"publish": {
"provider": "github",
"owner": "arul28",
Expand Down
35 changes: 35 additions & 0 deletions apps/desktop/scripts/after-pack-runtime-fixes.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
const fs = require("node:fs");
const path = require("node:path");
const {
normalizeDesktopRuntimeBinaries,
resolvePackagedRuntimeRoot,
} = require("./runtimeBinaryPermissions.cjs");

module.exports = async function afterPack(context) {
const productFilename = context?.packager?.appInfo?.productFilename || "ADE";
const appBundlePath = path.join(context?.appOutDir || "", `${productFilename}.app`);
if (!appBundlePath || !fs.existsSync(appBundlePath)) {
throw new Error(`[afterPack] Missing packaged app bundle: ${String(appBundlePath)}`);
}

const runtimeRoot = resolvePackagedRuntimeRoot(appBundlePath);
if (!fs.existsSync(runtimeRoot)) {
throw new Error(`[afterPack] Missing unpacked runtime payload: ${runtimeRoot}`);
}

const normalized = normalizeDesktopRuntimeBinaries(runtimeRoot);
for (const entry of normalized) {
console.log(`[afterPack] Restored executable mode: ${entry.label} -> ${path.relative(appBundlePath, entry.filePath)}`);
}

const requiredScripts = [
path.join(runtimeRoot, "dist", "main", "adeMcpProxy.cjs"),
path.join(runtimeRoot, "dist", "main", "packagedRuntimeSmoke.cjs"),
];

for (const scriptPath of requiredScripts) {
if (!fs.existsSync(scriptPath)) {
throw new Error(`[afterPack] Missing unpacked runtime entry: ${scriptPath}`);
}
}
};
14 changes: 14 additions & 0 deletions apps/desktop/scripts/normalize-runtime-binaries.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
const path = require("node:path");
const { normalizeDesktopRuntimeBinaries } = require("./runtimeBinaryPermissions.cjs");

const appDir = path.resolve(__dirname, "..");
const normalized = normalizeDesktopRuntimeBinaries(appDir);

if (normalized.length === 0) {
console.log("[runtime-binaries] No executable mode fixes were needed.");
process.exit(0);
}

for (const entry of normalized) {
console.log(`[runtime-binaries] Restored executable mode: ${entry.label} -> ${path.relative(appDir, entry.filePath)}`);
}
57 changes: 52 additions & 5 deletions apps/desktop/scripts/prepare-universal-mac-inputs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,50 @@ async function assertPathExists(targetPath, description) {
}
}

async function findFirstNodeAddon(rootPath) {
const entries = await fs.readdir(rootPath, { withFileTypes: true });

for (const entry of entries) {
const entryPath = path.join(rootPath, entry.name);

if (entry.isDirectory()) {
const nestedMatch = await findFirstNodeAddon(entryPath);
if (nestedMatch) {
return nestedMatch;
}
continue;
}

if (entry.isFile() && entry.name.endsWith(".node")) {
return entryPath;
}
}

return null;
}

async function findNodePtyAddon(moduleRootPath) {
const candidateRoots = [
path.join(moduleRootPath, "build", "Release"),
path.join(moduleRootPath, "build", "Debug"),
path.join(moduleRootPath, "prebuilds", "darwin-arm64"),
path.join(moduleRootPath, "prebuilds", "darwin-x64"),
];

for (const candidateRoot of candidateRoots) {
if (!(await pathExists(candidateRoot))) {
continue;
}

const addonPath = await findFirstNodeAddon(candidateRoot);
if (addonPath) {
return addonPath;
}
}

return null;
}

async function loadPackageLock() {
return JSON.parse(await fs.readFile(packageLockPath, "utf8"));
}
Expand Down Expand Up @@ -246,6 +290,14 @@ async function assertUniversalInputsReady() {
path.join(appDir, "vendor", "crsqlite", "darwin-x64", "crsqlite.dylib"),
"x64 crsqlite dylib",
);
await assertPathExists(path.join(appDir, "node_modules", "node-pty"), "node-pty package");
const nodePtyAddon = await findNodePtyAddon(path.join(appDir, "node_modules", "node-pty"));
if (!nodePtyAddon) {
throw new Error(
"[release:mac] Missing node-pty native addon under node_modules/node-pty/build or node_modules/node-pty/prebuilds",
);
}
console.log(`[release:mac] Verified node-pty native addon: ${path.relative(appDir, nodePtyAddon)}`);
}

const x64App = await resolveX64AppPath();
Expand All @@ -257,11 +309,6 @@ try {
await seedFromLockfileAndPinnedArtifacts();
}

await fs.rm(path.join(appDir, "node_modules", "node-pty", "build"), {
recursive: true,
force: true,
});

await assertUniversalInputsReady();
console.log("[release:mac] Universal macOS source tree now contains the required x64 runtime payloads");
} finally {
Expand Down
19 changes: 17 additions & 2 deletions apps/desktop/scripts/release-mac-local.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import fs from "node:fs/promises";
import { mkdtempSync, writeFileSync, chmodSync, rmSync } from "node:fs";
import { mkdtempSync, writeFileSync, chmodSync, rmSync, existsSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
Expand Down Expand Up @@ -76,13 +76,23 @@ function run(command, args, env) {

function maybeUseInstalledIdentity(env) {
const hasImportedCertificate = Boolean(env.CSC_LINK && env.CSC_KEY_PASSWORD);
const cscLinkPath = typeof env.CSC_LINK === "string" && env.CSC_LINK.startsWith("/") ? env.CSC_LINK : null;
const missingImportedCertificate = Boolean(cscLinkPath && !existsSync(cscLinkPath));

if (hasImportedCertificate) {
if (hasImportedCertificate && !missingImportedCertificate) {
delete env.CSC_NAME;
return;
}

if (env.CSC_NAME) {
if (missingImportedCertificate) {
console.warn(
`[release:mac] CSC_LINK points to a missing certificate file (${cscLinkPath}); ` +
`falling back to installed identity ${env.CSC_NAME}.`,
);
delete env.CSC_LINK;
delete env.CSC_KEY_PASSWORD;
}
return;
}

Expand Down Expand Up @@ -110,6 +120,11 @@ function maybeUseInstalledIdentity(env) {
identities.find((identity) => authorName && identity.includes(authorName)) ?? identities[0] ?? null;

if (!preferredIdentity) {
if (missingImportedCertificate) {
throw new Error(
`[release:mac] CSC_LINK points to a missing certificate file (${cscLinkPath}) and no installed Developer ID Application identity was found`,
);
}
return;
}

Expand Down
20 changes: 18 additions & 2 deletions apps/desktop/scripts/require-macos-release-secrets.cjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#!/usr/bin/env node
"use strict";

const fs = require("node:fs");

function hasEnv(name) {
return Boolean(process.env[name] && String(process.env[name]).trim().length > 0);
}
Expand All @@ -13,8 +15,14 @@ const missing = [];

const hasImportedCertificate = ["CSC_LINK", "CSC_KEY_PASSWORD"].every(hasEnv);
const hasInstalledIdentity = hasEnv("CSC_NAME");
const cscLink = hasEnv("CSC_LINK") ? String(process.env.CSC_LINK).trim() : "";
const cscLinkIsAbsolutePath = cscLink.startsWith("/");
const cscLinkPathExists = !cscLinkIsAbsolutePath || fs.existsSync(cscLink);

if (!hasImportedCertificate && !hasInstalledIdentity) {
if (hasImportedCertificate && cscLinkIsAbsolutePath && !cscLinkPathExists && !hasInstalledIdentity) {
missing.push(`CSC_LINK points to a missing certificate file: ${cscLink}`);
missing.push("Provide a valid CSC_LINK + CSC_KEY_PASSWORD pair or set CSC_NAME to an installed Developer ID identity");
} else if (!hasImportedCertificate && !hasInstalledIdentity) {
missing.push("Provide either CSC_LINK + CSC_KEY_PASSWORD or CSC_NAME");
}

Expand Down Expand Up @@ -67,8 +75,16 @@ if (matchingProfile.vars.includes("APPLE_API_KEY") && !String(process.env.APPLE_
process.exit(1);
}

if (hasImportedCertificate && cscLinkIsAbsolutePath && !cscLinkPathExists && hasInstalledIdentity) {
process.stdout.write(
`[release:mac] CSC_LINK points to a missing file (${cscLink}); continuing with installed identity ${process.env.CSC_NAME}.\n`
);
}

process.stdout.write(
`[release:mac] macOS signing and notarization environment looks complete (` +
`${hasImportedCertificate ? "imported Developer ID certificate" : `installed identity ${process.env.CSC_NAME}`}, ` +
`${hasImportedCertificate && (!cscLinkIsAbsolutePath || cscLinkPathExists)
? "imported Developer ID certificate"
: `installed identity ${process.env.CSC_NAME}`}, ` +
`${matchingProfile.label}).\n`
);
125 changes: 125 additions & 0 deletions apps/desktop/scripts/runtimeBinaryPermissions.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
const fs = require("node:fs");
const path = require("node:path");

const EXECUTABLE_MASK = 0o111;
const NODE_PTY_HELPER_PATH_PATCHES = [
{
from: "helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
to: "helperPath = helperPath.replace(/app\\.asar(?!\\.unpacked)/, 'app.asar.unpacked');",
},
{
from: "helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
to: "helperPath = helperPath.replace(/node_modules\\.asar(?!\\.unpacked)/, 'node_modules.asar.unpacked');",
},
];

function pathExists(targetPath) {
try {
fs.accessSync(targetPath);
return true;
} catch {
return false;
}
}

function listDirectories(rootPath) {
if (!pathExists(rootPath)) return [];
return fs.readdirSync(rootPath, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(rootPath, entry.name));
}

function ensureExecutable(filePath) {
const stat = fs.statSync(filePath);
if (!stat.isFile()) return false;
const currentMode = stat.mode & 0o777;
if ((currentMode & EXECUTABLE_MASK) === EXECUTABLE_MASK) {
return false;
}
fs.chmodSync(filePath, currentMode | EXECUTABLE_MASK);
return true;
}

function normalizeFileSet(filePaths, label) {
const normalized = [];
for (const filePath of filePaths) {
if (!pathExists(filePath)) continue;
if (ensureExecutable(filePath)) normalized.push(filePath);
}
return normalized.map((filePath) => ({ filePath, label }));
}

function collectDesktopRuntimeExecutableCandidates(rootPath) {
const candidates = [];

for (const prebuildDir of listDirectories(path.join(rootPath, "node_modules", "node-pty", "prebuilds"))) {
candidates.push({
filePath: path.join(prebuildDir, "spawn-helper"),
label: "node-pty spawn helper",
});
}

for (const packageDir of listDirectories(path.join(rootPath, "node_modules", "@openai"))) {
if (!path.basename(packageDir).startsWith("codex-darwin-")) continue;
for (const vendorDir of listDirectories(path.join(packageDir, "vendor"))) {
candidates.push({
filePath: path.join(vendorDir, "codex", "codex"),
label: "Codex CLI binary",
});
candidates.push({
filePath: path.join(vendorDir, "path", "rg"),
label: "Codex ripgrep helper",
});
}
}

for (const vendorDir of listDirectories(path.join(rootPath, "node_modules", "@anthropic-ai", "claude-agent-sdk", "vendor", "ripgrep"))) {
candidates.push({
filePath: path.join(vendorDir, "rg"),
label: "Claude ripgrep helper",
});
}

return candidates;
}

function normalizeDesktopRuntimeBinaries(rootPath) {
const normalized = [];
for (const candidate of collectDesktopRuntimeExecutableCandidates(rootPath)) {
if (!pathExists(candidate.filePath)) continue;
if (ensureExecutable(candidate.filePath)) {
normalized.push(candidate);
}
}

const helperPathFiles = [
path.join(rootPath, "node_modules", "node-pty", "lib", "unixTerminal.js"),
path.join(rootPath, "node_modules", "node-pty", "src", "unixTerminal.ts"),
];

for (const filePath of helperPathFiles) {
if (!pathExists(filePath)) continue;
const original = fs.readFileSync(filePath, "utf8");
let updated = original;
for (const patch of NODE_PTY_HELPER_PATH_PATCHES) {
updated = updated.replace(patch.from, patch.to);
}
if (updated === original) continue;
fs.writeFileSync(filePath, updated, "utf8");
normalized.push({
filePath,
label: "node-pty helper path patch",
});
}

return normalized;
}

function resolvePackagedRuntimeRoot(appBundlePath) {
return path.join(appBundlePath, "Contents", "Resources", "app.asar.unpacked");
}

module.exports = {
normalizeDesktopRuntimeBinaries,
resolvePackagedRuntimeRoot,
};
Loading
Loading