Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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",
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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