const builder = require("electron-builder") const { execFileSync } = require("child_process") const fs = require("fs") const path = require("path") const packageMetadata = require("./package.json") const { normalizeBundledMacChromiumForPackaging, } = require("./scripts/prepare-export-chromium.cjs") const APP_ID = "com.presenton.presenton" const TEAM_ID = "S6W5C54KL6" const macTarget = process.env.PRESENTON_MAC_TARGET const isMasBuild = macTarget === "mas" || macTarget === "mas-dev" const isDirectMacBuild = !isMasBuild && (process.platform === "darwin" || !!macTarget) const requireDirectMacSigning = process.env.PRESENTON_REQUIRE_MAC_SIGNING === "1" const masDevProvisioningProfile = resolveProvisioningProfileForTarget({ target: "mas-dev", label: "MAS development", candidates: [ "build/AppleDevelopment.provisionprofile", "build/AppleDev.provisionprofile", "build/AppDev.provisionprofile", ], }) const masProvisioningProfile = resolveProvisioningProfileForTarget({ target: "mas", label: "Mac App Store distribution", candidates: [ "build/MacAppStore.provisionprofile", "build/AppDistri.provisionprofile", ], }) const masDevIdentity = process.env.PRESENTON_MAS_DEV_IDENTITY || process.env.CSC_NAME || "" const masSigningIdentities = resolveMasSigningIdentitiesForTarget() const masIdentityQualifier = masSigningIdentities.qualifier const masAppSigningIdentity = masSigningIdentities.appIdentity const masInstallerSigningIdentity = masSigningIdentities.installerIdentity const appStoreBundleShortVersion = macTarget === "mas" ? getAppStoreBundleShortVersion() : undefined const appStoreBundleVersion = macTarget === "mas" ? getAppStoreBundleVersion(appStoreBundleShortVersion) : undefined const macDistributionIdentity = process.env.PRESENTON_MAC_SIGN_IDENTITY || process.env.CSC_NAME || undefined const shouldNotarizeDirectMacBuild = isDirectMacBuild && process.env.PRESENTON_SKIP_NOTARIZATION !== "1" const masSigningExtraArgs = process.env.PRESENTON_CODESIGN_TIMESTAMP === "1" ? [] : ["--timestamp=none"] function getAppStoreBundleShortVersion() { const configuredVersion = process.env.PRESENTON_APP_STORE_VERSION if (configuredVersion) { validateAppStoreVersion(configuredVersion, "PRESENTON_APP_STORE_VERSION") return configuredVersion } const match = /^(\d+)\.(\d+)\.(\d+)/.exec(packageMetadata.version) if (!match) { throw new Error( `Cannot derive an App Store version from package version "${packageMetadata.version}". Set PRESENTON_APP_STORE_VERSION to three period-separated integers, for example 1.0.0.` ) } return `${match[1]}.${match[2]}.${match[3]}` } function getAppStoreBundleVersion(bundleShortVersion) { const configuredBuild = process.env.PRESENTON_APP_STORE_BUILD if (configuredBuild) { validateAppStoreBuild(configuredBuild, "PRESENTON_APP_STORE_BUILD") return configuredBuild } return bundleShortVersion } function validateAppStoreVersion(value, source) { if (!/^\d+\.\d+\.\d+$/.test(value)) { throw new Error( `${source} must be three period-separated integers, for example 1.0.0. Received: ${value}` ) } } function validateAppStoreBuild(value, source) { if (!/^\d+(\.\d+){0,2}$/.test(value)) { throw new Error( `${source} must be one to three period-separated integers, for example 42 or 1.0.1. Received: ${value}` ) } } function resolveProvisioningProfileForTarget({ target, label, candidates }) { try { return resolveProvisioningProfile({ target, label, candidates }) } catch (error) { console.error(`\n${error.message}\n`) process.exit(1) } } function resolveProvisioningProfile({ target, label, candidates }) { if (macTarget !== target) { return undefined } if (process.platform !== "darwin") { throw new Error( `${label} builds must be run on macOS because Apple signing tools are required.` ) } const undecodableProfiles = [] for (const candidate of candidates) { const candidatePath = path.join(__dirname, candidate) if (!fs.existsSync(candidatePath)) { continue } if (canDecodeProvisioningProfile(candidatePath)) { return candidate } undecodableProfiles.push(candidate) } if (undecodableProfiles.length > 0) { throw new Error( `Found ${label} provisioning profile, but macOS could not decode it: ${undecodableProfiles.join(", ")}. Re-download the matching Apple provisioning profile and replace the local file.` ) } throw new Error( `Missing ${label} provisioning profile. Expected: ${candidates.join(", ")}` ) } function canDecodeProvisioningProfile(profilePath) { if (commandSucceeds("security", ["cms", "-D", "-i", profilePath])) { return true } if ( commandSucceeds("openssl", [ "cms", "-inform", "DER", "-verify", "-noverify", "-in", profilePath, "-out", "/dev/null", ]) ) { return true } return false } function commandSucceeds(command, args) { try { execFileSync(command, args, { stdio: "ignore" }) return true } catch { return false } } function resolveMasSigningIdentitiesForTarget() { if (macTarget !== "mas") { return { qualifier: "", appIdentity: "", installerIdentity: "", } } try { return resolveMasSigningIdentities() } catch (error) { console.error(`\n${error.message}\n`) process.exit(1) } } function resolveMasSigningIdentities() { const identities = getAppleSigningIdentities() const explicitIdentity = process.env.PRESENTON_MAS_DISTRIBUTION_IDENTITY || process.env.PRESENTON_MAS_IDENTITY const qualifier = explicitIdentity ? validateMasIdentityQualifier( explicitIdentity, "PRESENTON_MAS_DISTRIBUTION_IDENTITY/PRESENTON_MAS_IDENTITY", identities ) : process.env.CSC_NAME ? validateMasIdentityQualifier( String(process.env.CSC_NAME), "CSC_NAME", identities ) : discoverMasIdentityQualifier(identities) const appIdentity = identities.find((identity) => isMasAppIdentityName(identity.name) && identity.name.includes(qualifier) ) const installerIdentity = identities.find((identity) => isMasInstallerIdentityName(identity.name) && identity.name.includes(qualifier) ) if (!appIdentity || !installerIdentity) { throw buildMissingMasIdentityError(qualifier, identities, { appIdentityFound: !!appIdentity, installerIdentityFound: !!installerIdentity, }) } return { qualifier, appIdentity: appIdentity.name, installerIdentity: installerIdentity.name, } } function validateMasIdentityQualifier(value, source, identities) { const identity = value.trim() if (!identity) { throw new Error(`${source} is set but empty.`) } const matchedIdentity = identities.find( (candidate) => candidate.hash === identity || candidate.name === identity ) const identityName = matchedIdentity?.name || identity if (isDevelopmentIdentityName(identityName)) { throw new Error( `${source} points to a development identity (${identityName}). MAS distribution builds require Apple Distribution or 3rd Party Mac Developer Application.` ) } if (isMasAppIdentityName(identityName) || isMasInstallerIdentityName(identityName)) { return getMasIdentityQualifier(identityName) } if (/^[A-Fa-f0-9]{40}$/.test(identity) && !matchedIdentity) { throw new Error( `${source} is a certificate hash that was not found in the keychain, so the matching MAS installer certificate cannot be discovered. Use the certificate name or team qualifier instead.` ) } return identity } function discoverMasIdentityQualifier(identities) { const appIdentity = identities.find((identity) => isMasAppIdentityName(identity.name)) if (appIdentity) { return getMasIdentityQualifier(appIdentity.name) } throw buildMissingMasIdentityError("", identities, { appIdentityFound: false, installerIdentityFound: identities.some((identity) => isMasInstallerIdentityName(identity.name) ), }) } function getAppleSigningIdentities() { const outputs = [] for (const args of [ ["find-identity", "-v"], ["find-identity", "-v", "-p", "codesigning"], ]) { try { outputs.push(execFileSync("security", args, { encoding: "utf8" })) } catch { continue } } const seen = new Set() return outputs .join("\n") .split(/\r?\n/) .map((line) => { const match = line.match(/^\s*\d+\)\s+([A-Fa-f0-9]{40})\s+"([^"]+)"/) return match ? { hash: match[1], name: match[2] } : undefined }) .filter(Boolean) .filter((identity) => { if (seen.has(identity.name)) { return false } seen.add(identity.name) return true }) } function isMasAppIdentityName(name) { return /^(Apple Distribution|3rd Party Mac Developer Application):/.test(name) } function isMasInstallerIdentityName(name) { return /^3rd Party Mac Developer Installer:/.test(name) } function isDevelopmentIdentityName(name) { return /^(Apple Development|Mac Developer):/.test(name) } function isDeveloperIdApplicationIdentityName(name) { return /^Developer ID Application:/.test(name) } function getMasIdentityQualifier(identityName) { return identityName.replace( /^(Apple Distribution|3rd Party Mac Developer Application|3rd Party Mac Developer Installer):\s*/, "" ) } function buildMissingMasIdentityError(qualifier, identities, status) { const availableIdentities = identities.length ? identities.map((identity) => ` - ${identity.name}`).join("\n") : " (no valid Apple signing identities found)" const expectedQualifier = qualifier ? ` matching "${qualifier}"` : "" return new Error( [ "Missing MAS signing identity.", `MAS distribution builds require both an app certificate and an installer certificate${expectedQualifier}:`, " - Apple Distribution or 3rd Party Mac Developer Application", " - 3rd Party Mac Developer Installer", "", `App certificate found: ${status.appIdentityFound ? "yes" : "no"}`, `Installer certificate found: ${status.installerIdentityFound ? "yes" : "no"}`, "", "Create/download the missing certificate from Apple Developer Certificates,", "install it in Keychain Access, then rerun the build.", "", "Available Apple signing identities:", availableIdentities, ].join("\n") ) } function assertCodesignCanUseIdentity(identity) { if (!identity || macTarget !== "mas" || process.platform !== "darwin") { return } const tempDir = fs.mkdtempSync(path.join(require("os").tmpdir(), "presenton-codesign-")) const tempFile = path.join(tempDir, "preflight") try { fs.writeFileSync(tempFile, "Presenton MAS signing preflight\n") execFileSync( "codesign", ["--force", "--sign", identity, "--timestamp=none", tempFile], { stdio: "ignore", timeout: 30000 } ) } catch (error) { const timedOut = error && error.signal === "SIGTERM" throw new Error( [ "Could not complete MAS signing preflight with the selected distribution identity.", timedOut ? "codesign timed out, usually because macOS is waiting for Keychain/private-key access." : "codesign failed before electron-builder started signing the app.", "Unlock the login keychain and allow codesign access to the Apple Distribution private key, then rerun the build.", `Identity: ${identity}`, ].join("\n") ) } finally { fs.rmSync(tempDir, { recursive: true, force: true }) } } function assertDirectMacReleaseReadiness() { if (!isDirectMacBuild || !requireDirectMacSigning) { return } if (process.platform !== "darwin") { throw new Error( "Signed macOS distribution builds must be run on macOS because Apple codesign and notarytool are required." ) } const identity = resolveDeveloperIdApplicationIdentity() assertDirectMacNotarizationCredentials() assertCodesignCanUseDirectIdentity(identity) } function resolveDeveloperIdApplicationIdentity() { const identities = getAppleSigningIdentities() const configuredIdentity = macDistributionIdentity if (configuredIdentity) { const matchedIdentity = identities.find( (candidate) => candidate.hash === configuredIdentity || candidate.name === configuredIdentity ) const identityName = matchedIdentity?.name || configuredIdentity if (!matchedIdentity) { throw new Error( [ "The configured macOS signing identity was not found in the keychain.", "Direct distribution requires a Developer ID Application certificate.", `Configured identity: ${configuredIdentity}`, "", buildAvailableDeveloperIdIdentityList(identities), ].join("\n") ) } if (!isDeveloperIdApplicationIdentityName(identityName)) { throw new Error( [ "The configured macOS signing identity is not valid for direct distribution.", "Use a Developer ID Application certificate, not Apple Development, Apple Distribution, or a Mac App Store certificate.", `Configured identity: ${identityName}`, ].join("\n") ) } return identityName } const discoveredIdentity = identities.find((identity) => isDeveloperIdApplicationIdentityName(identity.name) ) if (!discoveredIdentity) { throw new Error( [ "Missing Developer ID Application signing identity.", "Install the certificate in Keychain Access or set PRESENTON_MAC_SIGN_IDENTITY to its exact name.", "", buildAvailableDeveloperIdIdentityList(identities), ].join("\n") ) } return discoveredIdentity.name } function buildAvailableDeveloperIdIdentityList(identities) { const available = identities.length ? identities.map((identity) => ` - ${identity.name}`).join("\n") : " (no valid Apple signing identities found)" return ["Available Apple signing identities:", available].join("\n") } function assertDirectMacNotarizationCredentials() { if (!shouldNotarizeDirectMacBuild) { throw new Error( "PRESENTON_SKIP_NOTARIZATION=1 is not allowed when PRESENTON_REQUIRE_MAC_SIGNING=1." ) } const hasAppleIdCredentials = !!process.env.APPLE_ID && !!process.env.APPLE_APP_SPECIFIC_PASSWORD && !!process.env.APPLE_TEAM_ID const hasApiKeyCredentials = !!process.env.APPLE_API_KEY && !!process.env.APPLE_API_KEY_ID && !!process.env.APPLE_API_ISSUER const hasKeychainProfile = !!process.env.APPLE_KEYCHAIN_PROFILE if (hasAppleIdCredentials || hasApiKeyCredentials || hasKeychainProfile) { return } throw new Error( [ "Missing Apple notarization credentials.", "Set one of these credential groups before running the signed macOS release build:", " - APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID", " - APPLE_API_KEY, APPLE_API_KEY_ID, APPLE_API_ISSUER", " - APPLE_KEYCHAIN_PROFILE, optionally APPLE_KEYCHAIN", ].join("\n") ) } function assertCodesignCanUseDirectIdentity(identity) { const tempDir = fs.mkdtempSync(path.join(require("os").tmpdir(), "presenton-codesign-")) const tempFile = path.join(tempDir, "preflight") try { fs.writeFileSync(tempFile, "Presenton direct macOS signing preflight\n") execFileSync( "codesign", ["--force", "--sign", identity, "--options", "runtime", tempFile], { stdio: "ignore", timeout: 30000 } ) } catch (error) { const timedOut = error && error.signal === "SIGTERM" throw new Error( [ "Could not complete direct macOS signing preflight with the selected Developer ID identity.", timedOut ? "codesign timed out, usually because macOS is waiting for Keychain/private-key access." : "codesign failed before electron-builder started signing the app.", "Unlock the login keychain and allow codesign access to the Developer ID private key, then rerun the build.", `Identity: ${identity}`, ].join("\n") ) } finally { fs.rmSync(tempDir, { recursive: true, force: true }) } } // AfterPack hook: set executable permissions and repair packaged runtime bundles on macOS. const afterPack = async (context) => { if (context.electronPlatformName === "darwin") { const appPath = context.appOutDir const appBundleName = `${context.packager.appInfo.productFilename}.app` const resourcesRoot = resolvePackagedResourcesRoot( appPath, appBundleName ) const fastapiPath = path.join(resourcesRoot, "fastapi", "fastapi") const exportPyDir = path.join(resourcesRoot, "export", "py") const converterCandidates = [ `convert-${process.platform}-${process.arch}`, `convert-${process.platform}`, "convert", ] console.log("Setting executable permissions for FastAPI binary...") console.log("FastAPI path:", fastapiPath) if (fs.existsSync(fastapiPath)) { fs.chmodSync(fastapiPath, 0o755) console.log("✓ Execute permissions set for FastAPI") } else { console.warn("⚠ FastAPI binary not found at:", fastapiPath) } console.log("Setting executable permissions for export converter binary...") let converterFound = false for (const candidate of converterCandidates) { const candidatePath = path.join(exportPyDir, candidate) if (fs.existsSync(candidatePath)) { fs.chmodSync(candidatePath, 0o755) console.log("✓ Execute permissions set for converter:", candidatePath) converterFound = true } } if (!converterFound) { console.warn("⚠ No converter binary found in:", exportPyDir) } const fastapiDir = path.join(resourcesRoot, "fastapi") if (fs.existsSync(fastapiDir)) { console.log("FastAPI directory contents:", fs.readdirSync(fastapiDir)) } if (fs.existsSync(exportPyDir)) { console.log("Export py directory contents:", fs.readdirSync(exportPyDir)) } normalizeBundledMacChromiumForPackaging(resourcesRoot) } } function resolvePackagedResourcesRoot(appPath, appBundleName) { const contentsResourcesRoot = path.join(appPath, appBundleName, "Contents", "Resources") const candidates = [ path.join(contentsResourcesRoot, "app.asar.unpacked", "resources"), path.join(contentsResourcesRoot, "app", "resources"), ] return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0] } const config = { appId: APP_ID, productName: "Presenton", asar: true, asarUnpack: [ "resources/**", // LiteParse runs from FastAPI via Electron-as-Node and needs real package dirs. "node_modules/**", ], copyright: "Copyright © 2026 Presenton", directories: { output: "dist", buildResources: "build", }, files: [ "resources", "app_dist", "node_modules", "NOTICE" ], afterPack, mac: { artifactName: "Presenton-${version}.${ext}", target: [macTarget || "dmg"], category: "public.app-category.productivity", hardenedRuntime: !isMasBuild, gatekeeperAssess: false, entitlements: isMasBuild ? undefined : "build/entitlements.mac.plist", entitlementsInherit: isMasBuild ? undefined : "build/entitlements.mac.inherit.plist", identity: macTarget === "mas" || macTarget === "mas-dev" ? null : macDistributionIdentity, notarize: isMasBuild || !shouldNotarizeDirectMacBuild ? false : true, icon: "build/icon.icns", bundleShortVersion: appStoreBundleShortVersion, bundleVersion: appStoreBundleVersion, extendInfo: { ElectronTeamID: TEAM_ID, }, }, masDev: { type: "development", identity: masDevIdentity || undefined, provisioningProfile: masDevProvisioningProfile, entitlements: "build/entitlements.mas.plist", entitlementsInherit: "build/entitlements.mas.inherit.plist", // osx-sign always adds --timestamp; this later flag keeps local MAS dev signing offline-tolerant. additionalArguments: ["--timestamp=none"], }, mas: { type: "distribution", identity: masIdentityQualifier || undefined, provisioningProfile: masProvisioningProfile, entitlements: "build/entitlements.mas.plist", entitlementsInherit: "build/entitlements.mas.inherit.plist", // Avoid codesign hanging on Apple's timestamp service during local MAS packaging. additionalArguments: masSigningExtraArgs, }, linux: { artifactName: "Presenton-${version}.${ext}", target: ["AppImage", "deb"], icon: "build/icons", }, deb: { afterInstall: "build/after-install.tpl", }, win: { target: ["nsis", "appx"], icon: "build/icon.ico", artifactName: "Presenton-${version}.${ext}", executableName: "Presenton", }, nsis: { oneClick: false, perMachine: false, allowToChangeInstallationDirectory: true, allowElevation: true, installerIcon: "build/icon.ico", uninstallerIcon: "build/icon.ico", installerHeaderIcon: "build/icon.ico", createDesktopShortcut: true, createStartMenuShortcut: true, shortcutName: "Presenton", uninstallDisplayName: "Presenton", }, dmg: { sign: false, size: "2300m", }, appx: { identityName: "PresentonAI.Presenton", publisher: "CN=8A2C57B5-F1C6-473A-93EE-2E9B72134341", displayName: "Presenton", publisherDisplayName: "Presenton Inc.", applicationId: "PresentonAI.Presenton", }, } const effectiveMacTarget = macTarget || "dmg" const targets = process.platform === "darwin" ? builder.Platform.MAC.createTarget([effectiveMacTarget]) : undefined if (macTarget === "mas" && process.env.PRESENTON_SKIP_CODESIGN_PREFLIGHT !== "1") { assertCodesignCanUseIdentity(masAppSigningIdentity) } assertDirectMacReleaseReadiness() if (isDirectMacBuild && process.platform === "darwin") { console.log("[macOS direct] Distribution settings:", { target: effectiveMacTarget, identity: macDistributionIdentity || "auto", hardenedRuntime: true, notarize: shouldNotarizeDirectMacBuild, signingRequired: requireDirectMacSigning, }) } if (macTarget === "mas") { console.log("[MAS] Signing preflight:", { identityQualifier: masIdentityQualifier || "auto", appIdentity: masAppSigningIdentity || "none", installerIdentity: masInstallerSigningIdentity || "none", provisioningProfile: masProvisioningProfile || "none", additionalArguments: masSigningExtraArgs, }) } builder.build({ targets, config })