418 lines
12 KiB
JavaScript
418 lines
12 KiB
JavaScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const workspaceRoot = path.resolve(__dirname, '..');
|
|
const defaultEnvPath = path.join(workspaceRoot, '.env.desktop-signing.local');
|
|
const secretKeys = new Set([
|
|
'APPLE_APP_SPECIFIC_PASSWORD',
|
|
'CSC_KEY_PASSWORD',
|
|
'WIN_CSC_KEY_PASSWORD',
|
|
]);
|
|
|
|
function expandHome(filePath) {
|
|
if (!filePath) return filePath;
|
|
const cleanPath = filePath.startsWith('file://')
|
|
? filePath.slice('file://'.length)
|
|
: filePath;
|
|
if (cleanPath === '~') {
|
|
return process.env.HOME || cleanPath;
|
|
}
|
|
if (cleanPath.startsWith('~/')) {
|
|
return path.join(process.env.HOME || '', cleanPath.slice(2));
|
|
}
|
|
return cleanPath;
|
|
}
|
|
|
|
function parseEnvValue(rawValue) {
|
|
let value = rawValue.trim();
|
|
const quote = value[0];
|
|
if (
|
|
value.length >= 2 &&
|
|
(quote === '"' || quote === "'") &&
|
|
value[value.length - 1] === quote
|
|
) {
|
|
value = value.slice(1, -1);
|
|
if (quote === '"') {
|
|
value = value.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
}
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function loadEnvFile(filePath) {
|
|
if (!fs.existsSync(filePath)) {
|
|
return { loaded: false, values: {} };
|
|
}
|
|
const values = {};
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
for (const [index, line] of content.split(/\r?\n/).entries()) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
if (!match) {
|
|
throw new Error(
|
|
`Invalid env line in ${path.relative(workspaceRoot, filePath)}:${index + 1}`
|
|
);
|
|
}
|
|
values[match[1]] = parseEnvValue(match[2]);
|
|
}
|
|
return { loaded: true, values };
|
|
}
|
|
|
|
function mergeEnv(envFilePath) {
|
|
const { loaded, values } = loadEnvFile(envFilePath);
|
|
for (const [key, value] of Object.entries(values)) {
|
|
if (process.env[key] == null || process.env[key] === '') {
|
|
process.env[key] = value;
|
|
}
|
|
}
|
|
return loaded;
|
|
}
|
|
|
|
function readArgValue(args, name, fallback) {
|
|
const prefix = `${name}=`;
|
|
const match = args.find((arg) => arg.startsWith(prefix));
|
|
return match ? match.slice(prefix.length) : fallback;
|
|
}
|
|
|
|
function flag(args, name) {
|
|
return args.includes(name);
|
|
}
|
|
|
|
function run(command, args, options = {}) {
|
|
const result = spawnSync(command, args, {
|
|
cwd: workspaceRoot,
|
|
encoding: 'utf8',
|
|
env: process.env,
|
|
stdio: options.stdio || 'pipe',
|
|
timeout: options.timeout || 0,
|
|
});
|
|
if (result.error) {
|
|
throw result.error;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function runRequired(command, args) {
|
|
const result = spawnSync(command, args, {
|
|
cwd: workspaceRoot,
|
|
env: process.env,
|
|
stdio: 'inherit',
|
|
});
|
|
if (result.error) {
|
|
throw result.error;
|
|
}
|
|
if (result.status !== 0) {
|
|
throw new Error(`Command failed: ${command} ${args.join(' ')}`);
|
|
}
|
|
}
|
|
|
|
function maskEnvValue(key, value) {
|
|
if (!value) return '<empty>';
|
|
return secretKeys.has(key) ? '<set>' : value;
|
|
}
|
|
|
|
function certificateSubjectsFromP12(p12Path) {
|
|
const result = run('openssl', [
|
|
'pkcs12',
|
|
'-in',
|
|
p12Path,
|
|
'-nokeys',
|
|
'-clcerts',
|
|
'-passin',
|
|
'env:CSC_KEY_PASSWORD',
|
|
'-info',
|
|
]);
|
|
return {
|
|
ok: result.status === 0,
|
|
output: [result.stdout, result.stderr].filter(Boolean).join('\n'),
|
|
};
|
|
}
|
|
|
|
function codesigningIdentities() {
|
|
const result = run('security', ['find-identity', '-v', '-p', 'codesigning']);
|
|
return {
|
|
ok: result.status === 0,
|
|
output: [result.stdout, result.stderr].filter(Boolean).join('\n'),
|
|
};
|
|
}
|
|
|
|
function checkTool(command, args) {
|
|
try {
|
|
const result = run(command, args, { timeout: 5000 });
|
|
return result.status === 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function findXcodeDeveloperDir() {
|
|
try {
|
|
const result = run('xcode-select', ['-p'], { timeout: 5000 });
|
|
if (result.status === 0) {
|
|
const developerDir = result.stdout.trim();
|
|
return developerDir || '';
|
|
}
|
|
} catch {
|
|
return '';
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function hasXcodeTool(toolName) {
|
|
const developerDir = findXcodeDeveloperDir();
|
|
if (developerDir) {
|
|
const directTool = path.join(developerDir, 'usr/bin', toolName);
|
|
if (fs.existsSync(directTool)) {
|
|
return true;
|
|
}
|
|
}
|
|
return checkTool('xcrun', ['--find', toolName]);
|
|
}
|
|
|
|
function hasCompleteApiKeyLogin() {
|
|
return Boolean(
|
|
process.env.APPLE_API_KEY &&
|
|
process.env.APPLE_API_KEY_ID &&
|
|
process.env.APPLE_API_ISSUER
|
|
);
|
|
}
|
|
|
|
function hasPartialApiKeyLogin() {
|
|
return Boolean(
|
|
process.env.APPLE_API_KEY ||
|
|
process.env.APPLE_API_KEY_ID ||
|
|
process.env.APPLE_API_ISSUER
|
|
);
|
|
}
|
|
|
|
function hasCompleteAppleIdLogin() {
|
|
return Boolean(
|
|
process.env.APPLE_ID &&
|
|
process.env.APPLE_APP_SPECIFIC_PASSWORD &&
|
|
process.env.APPLE_TEAM_ID
|
|
);
|
|
}
|
|
|
|
function hasPartialAppleIdLogin() {
|
|
return Boolean(
|
|
process.env.APPLE_ID ||
|
|
process.env.APPLE_APP_SPECIFIC_PASSWORD ||
|
|
process.env.APPLE_TEAM_ID
|
|
);
|
|
}
|
|
|
|
function checkSigning({ requireNotarization = true, quiet = false } = {}) {
|
|
const errors = [];
|
|
const warnings = [];
|
|
const details = [];
|
|
|
|
if (process.platform !== 'darwin') {
|
|
errors.push('macOS signed builds must run on macOS.');
|
|
}
|
|
|
|
const cscLink = process.env.CSC_LINK ? expandHome(process.env.CSC_LINK) : '';
|
|
const cscName = process.env.CSC_NAME || '';
|
|
if (cscLink) {
|
|
process.env.CSC_LINK = cscLink;
|
|
if (!fs.existsSync(cscLink)) {
|
|
errors.push(`CSC_LINK does not exist: ${cscLink}`);
|
|
} else {
|
|
details.push(`CSC_LINK=${cscLink}`);
|
|
if (!process.env.CSC_KEY_PASSWORD) {
|
|
errors.push('CSC_KEY_PASSWORD is required when CSC_LINK points to a .p12 file.');
|
|
} else {
|
|
const certInfo = certificateSubjectsFromP12(cscLink);
|
|
if (!certInfo.ok) {
|
|
errors.push('Unable to read CSC_LINK .p12. Check the certificate password.');
|
|
} else if (!/Developer ID Application:/i.test(certInfo.output)) {
|
|
errors.push(
|
|
'CSC_LINK is not a "Developer ID Application" certificate. It cannot be used for direct customer distribution outside the Mac App Store.'
|
|
);
|
|
} else {
|
|
const subject =
|
|
certInfo.output.match(/subject=.*Developer ID Application:[^\n]+/i)?.[0] ||
|
|
'Developer ID Application certificate found in CSC_LINK';
|
|
details.push(subject);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
const identities = codesigningIdentities();
|
|
if (!identities.ok) {
|
|
errors.push('Unable to inspect macOS code signing identities with security.');
|
|
} else if (!/Developer ID Application:/i.test(identities.output)) {
|
|
errors.push(
|
|
'No valid "Developer ID Application" identity is available. Set CSC_LINK to a Developer ID .p12 or import one into Keychain.'
|
|
);
|
|
} else {
|
|
details.push('Developer ID Application identity found in Keychain.');
|
|
}
|
|
if (cscName) {
|
|
details.push(`CSC_NAME=${cscName}`);
|
|
}
|
|
}
|
|
|
|
const appleApiKey = process.env.APPLE_API_KEY
|
|
? expandHome(process.env.APPLE_API_KEY)
|
|
: '';
|
|
if (appleApiKey) {
|
|
process.env.APPLE_API_KEY = appleApiKey;
|
|
}
|
|
if (appleApiKey && !fs.existsSync(appleApiKey)) {
|
|
errors.push(`APPLE_API_KEY does not exist: ${appleApiKey}`);
|
|
}
|
|
|
|
if (requireNotarization) {
|
|
if (hasCompleteApiKeyLogin()) {
|
|
details.push(`APPLE_API_KEY=${appleApiKey}`);
|
|
details.push(`APPLE_API_KEY_ID=${process.env.APPLE_API_KEY_ID}`);
|
|
if (process.env.APPLE_TEAM_ID) {
|
|
details.push(`APPLE_TEAM_ID=${process.env.APPLE_TEAM_ID}`);
|
|
}
|
|
} else if (hasCompleteAppleIdLogin()) {
|
|
warnings.push(
|
|
'Apple ID notarization is configured. The App Store Connect API key method is recommended for repeatable production builds.'
|
|
);
|
|
details.push(`APPLE_ID=${process.env.APPLE_ID}`);
|
|
details.push(`APPLE_TEAM_ID=${process.env.APPLE_TEAM_ID}`);
|
|
} else {
|
|
if (hasPartialApiKeyLogin()) {
|
|
errors.push(
|
|
'Notarization API key config is incomplete. Set APPLE_API_KEY, APPLE_API_KEY_ID, and APPLE_API_ISSUER.'
|
|
);
|
|
} else if (hasPartialAppleIdLogin()) {
|
|
errors.push(
|
|
'Apple ID notarization config is incomplete. Set APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, and APPLE_TEAM_ID.'
|
|
);
|
|
} else {
|
|
errors.push(
|
|
'Notarization credentials are missing. Use APPLE_API_KEY + APPLE_API_KEY_ID + APPLE_API_ISSUER for production macOS builds.'
|
|
);
|
|
}
|
|
}
|
|
if (!hasXcodeTool('notarytool')) {
|
|
errors.push('xcrun notarytool is unavailable. Install or select Xcode command line tools.');
|
|
}
|
|
if (!hasXcodeTool('stapler')) {
|
|
warnings.push('xcrun stapler is unavailable. Notarization may succeed, but ticket validation/stapling checks will be limited.');
|
|
}
|
|
} else {
|
|
warnings.push('TRUEGROWTH_NOTARIZE=false: this build can be signed, but it is not a full commercial macOS release.');
|
|
}
|
|
|
|
if (!quiet) {
|
|
console.log('[desktop:signing] macOS signing inputs');
|
|
for (const key of [
|
|
'CSC_LINK',
|
|
'CSC_NAME',
|
|
'APPLE_API_KEY',
|
|
'APPLE_API_KEY_ID',
|
|
'APPLE_API_ISSUER',
|
|
'APPLE_ID',
|
|
'APPLE_TEAM_ID',
|
|
'TRUEGROWTH_MAC_TARGETS',
|
|
'TRUEGROWTH_NOTARIZE',
|
|
]) {
|
|
if (process.env[key]) {
|
|
console.log(` ${key}=${maskEnvValue(key, process.env[key])}`);
|
|
}
|
|
}
|
|
for (const detail of details) {
|
|
console.log(` ok: ${detail}`);
|
|
}
|
|
for (const warning of warnings) {
|
|
console.warn(` warn: ${warning}`);
|
|
}
|
|
for (const error of errors) {
|
|
console.error(` error: ${error}`);
|
|
}
|
|
}
|
|
|
|
return { ok: errors.length === 0, errors, warnings, details };
|
|
}
|
|
|
|
function resolveMacTargets(args) {
|
|
const fromArg = readArgValue(args, '--targets', '');
|
|
const fromEnv = process.env.TRUEGROWTH_MAC_TARGETS || '';
|
|
const raw = fromArg || fromEnv || 'zip';
|
|
return raw
|
|
.split(/[,\s]+/)
|
|
.map((target) => target.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function buildMac(args) {
|
|
const envFile = readArgValue(args, '--env', defaultEnvPath);
|
|
const envLoaded = mergeEnv(envFile);
|
|
const requireNotarization = process.env.TRUEGROWTH_NOTARIZE !== 'false';
|
|
const check = checkSigning({ requireNotarization });
|
|
if (!check.ok) {
|
|
throw new Error('macOS signing preflight failed; fix the errors above before building a customer release.');
|
|
}
|
|
const targets = resolveMacTargets(args);
|
|
const builderArgs = [
|
|
'exec',
|
|
'electron-builder',
|
|
'--config',
|
|
'electron-builder.yml',
|
|
'--mac',
|
|
...targets,
|
|
'--arm64',
|
|
'-c.mac.forceCodeSigning=true',
|
|
];
|
|
if (process.env.APPLE_TEAM_ID) {
|
|
builderArgs.push(`-c.mac.notarize.teamId=${process.env.APPLE_TEAM_ID}`);
|
|
}
|
|
|
|
console.log(
|
|
`[desktop:signing] Loaded ${envLoaded ? envFile : 'process environment'}`
|
|
);
|
|
console.log(`[desktop:signing] Building macOS targets: ${targets.join(', ')}`);
|
|
runRequired('pnpm', ['build:web']);
|
|
runRequired('pnpm', ['desktop:prepare']);
|
|
runRequired('pnpm', builderArgs);
|
|
runRequired('pnpm', ['desktop:release-manifest', '--', '--platform=mac']);
|
|
}
|
|
|
|
function printHelp() {
|
|
console.log(`Usage:
|
|
node scripts/desktop-signing.mjs check [--env=.env.desktop-signing.local] [--no-notarize]
|
|
node scripts/desktop-signing.mjs build-mac [--env=.env.desktop-signing.local] [--targets=zip|dmg,zip]
|
|
`);
|
|
}
|
|
|
|
function main() {
|
|
const [command = 'help', ...args] = process.argv.slice(2);
|
|
const envFile = readArgValue(args, '--env', defaultEnvPath);
|
|
if (command === 'help' || flag(args, '--help')) {
|
|
printHelp();
|
|
return;
|
|
}
|
|
if (command === 'check') {
|
|
const loaded = mergeEnv(envFile);
|
|
const requireNotarization =
|
|
!flag(args, '--no-notarize') && process.env.TRUEGROWTH_NOTARIZE !== 'false';
|
|
if (!loaded && !process.env.CSC_LINK && !process.env.CSC_NAME) {
|
|
console.warn(
|
|
`[desktop:signing] ${path.relative(workspaceRoot, envFile)} was not found; checking process environment only.`
|
|
);
|
|
}
|
|
const result = checkSigning({ requireNotarization });
|
|
if (!result.ok) {
|
|
process.exitCode = 1;
|
|
}
|
|
return;
|
|
}
|
|
if (command === 'build-mac') {
|
|
buildMac(args);
|
|
return;
|
|
}
|
|
throw new Error(`Unknown command: ${command}`);
|
|
}
|
|
|
|
main();
|