import { Buffer } from 'node:buffer'; import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, isAbsolute, join, relative } from 'node:path'; import process from 'node:process'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { parseArgs } from 'node:util'; export const appleTeamId = 'ZZX2KZGTHU'; export const bundleIdentifier = 'de.kanbangit.ios'; const plistReader = `import base64, datetime, json, plistlib, sys def encode(value): if isinstance(value, bytes): return base64.b64encode(value).decode() if isinstance(value, datetime.datetime): return value.isoformat() + 'Z' raise TypeError(type(value).__name__) print(json.dumps(plistlib.loads(sys.stdin.buffer.read()), default=encode))`; function execute(command, args, options) { const result = spawnSync(command, args, { cwd: options.cwd, env: options.env, input: options.input, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, stdio: options.inherit ? 'inherit' : 'pipe', }); if (result.error || result.status !== 0) { throw new Error( `${options.step} failed: ${result.error?.message || result.stderr?.trim() || `exit ${result.status ?? result.signal}`}`, ); } return result.stdout?.trim() || ''; } export function validateProfile(profile, mode, now = new Date()) { const entitlements = profile.Entitlements ?? {}; if ( profile.TeamIdentifier?.length !== 1 || profile.TeamIdentifier[0] !== appleTeamId || entitlements['com.apple.developer.team-identifier'] !== appleTeamId || entitlements['application-identifier'] !== `${appleTeamId}.${bundleIdentifier}` ) { throw new Error( `The profile must explicitly match ${bundleIdentifier} in team ${appleTeamId}.`, ); } if ( !/^[a-f\d]{8}-(?:[a-f\d]{4}-){3}[a-f\d]{12}$/i.test(profile.UUID) || !(new Date(profile.ExpirationDate) > now) || !profile.DeveloperCertificates?.length || !profile.Platform?.includes('iOS') ) { throw new Error( 'The iOS profile is invalid or expired. Download a current profile from your Apple account.', ); } if (mode === 'testflight') { if ( entitlements['get-task-allow'] !== false || entitlements['beta-reports-active'] !== true || 'ProvisionedDevices' in profile || profile.ProvisionsAllDevices ) { throw new Error( 'TestFlight requires an App Store Connect profile, not a development, Ad Hoc or enterprise profile.', ); } } else if ( entitlements['get-task-allow'] !== true || !profile.ProvisionedDevices?.length || profile.ProvisionsAllDevices ) { throw new Error( 'Device development requires an iOS App Development profile containing your registered device.', ); } return profile; } export function selectIdentity(profile, identities, mode) { const fingerprints = profile.DeveloperCertificates.map((certificate) => createHash('sha1') .update(Buffer.from(certificate, 'base64')) .digest('hex') .toUpperCase(), ); const prefix = mode === 'testflight' ? 'Apple Distribution: ' : 'Apple Development: '; const matches = [ ...identities.matchAll(/\) ([A-Fa-f\d]{40}) "([^"]+)"/g), ].filter( ([, fingerprint, name]) => fingerprints.includes(fingerprint.toUpperCase()) && name.startsWith(prefix), ); if (matches.length !== 1) { throw new Error( `The profile must match exactly one usable ${prefix.replace(': ', '')} certificate with its private key in your local keychain. Select a profile containing the intended certificate.`, ); } return matches[0][1].toUpperCase(); } export function buildIos({ mode = 'testflight', env = process.env, platform = process.platform, directory = dirname(fileURLToPath(import.meta.url)), home = homedir(), run = execute, log = console.log, now = new Date(), } = {}) { if (!['testflight', 'development', 'simulator'].includes(mode)) { throw new Error('Choose --mode testflight, development or simulator.'); } if (platform !== 'darwin') { throw new Error( 'iOS builds require a Mac with Xcode, XcodeGen and the matching Rust target installed permanently.', ); } if ( env.APPLE_DEVELOPMENT_TEAM && env.APPLE_DEVELOPMENT_TEAM !== appleTeamId ) { throw new Error(`APPLE_DEVELOPMENT_TEAM must be ${appleTeamId}.`); } if (env.TAURI_CONFIG) { throw new Error( 'Remove TAURI_CONFIG overrides before building the configured iOS app.', ); } const config = JSON.parse( readFileSync(join(directory, 'src-tauri/tauri.conf.json'), 'utf8'), ); const ios = JSON.parse( readFileSync(join(directory, 'src-tauri/tauri.ios.conf.json'), 'utf8'), ); const version = config.version; const buildNumber = ios.bundle?.iOS?.bundleVersion; if ( ios.identifier !== bundleIdentifier || ios.bundle?.iOS?.developmentTeam !== appleTeamId || config.identifier !== 'de.kanbangit.desktop' || config.productName !== 'KanbanGit' || !/^\d+\.\d+\.\d+$/.test(version) || !/^[1-9]\d*$/.test(buildNumber) ) { throw new Error( 'Check the iOS team, bundle identifier, version and positive integer bundleVersion in the Tauri configuration.', ); } const simulator = mode === 'simulator'; const configuration = mode === 'testflight' ? 'release' : 'debug'; const target = simulator ? 'aarch64-apple-ios-sim' : 'aarch64-apple-ios'; const sdk = simulator ? 'iphonesimulator' : 'iphoneos'; const project = join(directory, 'src-tauri/gen/apple'); const targetDirectory = join(directory, 'src-tauri/target'); const outputRoot = join(targetDirectory, `ios-${mode}`); const destination = join(outputRoot, `${version}-${buildNumber}`); if (mode === 'testflight' && existsSync(destination)) { throw new Error( `Build output already exists: ${destination}. Move it aside, or increment bundle.iOS.bundleVersion for a new TestFlight upload.`, ); } const buildEnv = Object.fromEntries( Object.entries(env).filter( ([key]) => !key.startsWith('APPLE_') && !key.startsWith('IOS_') && !key.startsWith('TAURI_') && ![ 'SDKROOT', 'CARGO_ENCODED_RUSTFLAGS', 'RUSTFLAGS', 'RUSTC_WRAPPER', 'RUSTC_WORKSPACE_WRAPPER', 'XCODE_XCCONFIG_FILE', ].includes(key), ), ); function step(label, command, args, options = {}) { log(label); return run(command, args, { cwd: directory, env: buildEnv, step: label, ...options, }); } function parsePlist(xml) { return JSON.parse( step( 'Reading local plist metadata', 'python3', ['-c', plistReader], { input: xml }, ), ); } function readProfile(path) { return validateProfile( parsePlist( step('Reading provisioning profile', 'security', [ 'cms', '-D', '-i', path, ]), ), mode, now, ); } step( 'Checking XcodeGen (install permanently with brew install xcodegen if missing)', 'xcodegen', ['--version'], ); step('Checking Xcode', 'xcodebuild', ['-version']); const installedTargets = step('Checking installed Rust targets', 'rustup', [ 'target', 'list', '--installed', ]); if (!installedTargets.split(/\s+/).includes(target)) { throw new Error( `Install the missing target permanently with rustup target add ${target}, then retry.`, ); } const cargo = step('Selecting the rustup Cargo toolchain', 'rustup', [ 'which', 'cargo', ]); buildEnv.RUSTC = step('Selecting the matching Rust compiler', 'rustup', [ 'which', 'rustc', ]); buildEnv.PATH = `${dirname(cargo)}:${buildEnv.PATH ?? ''}`; buildEnv.SDKROOT = step('Checking the iOS SDK', 'xcrun', [ '--sdk', sdk, '--show-sdk-path', ]); buildEnv.IPHONEOS_DEPLOYMENT_TARGET = ios.bundle.iOS.minimumSystemVersion; buildEnv.CARGO_TARGET_DIR = targetDirectory; let profile; let profilePath; let identity; if (!simulator) { if ( !env.IOS_PROVISIONING_PROFILE || !existsSync(env.IOS_PROVISIONING_PROFILE) ) { throw new Error( 'Set IOS_PROVISIONING_PROFILE to your downloaded .mobileprovision file outside the repository. Apple account setup is manual; this command never creates or downloads profiles.', ); } profilePath = realpathSync(env.IOS_PROVISIONING_PROFILE); const withinProject = relative( realpathSync(join(directory, '..')), profilePath, ); if ( !withinProject.startsWith( `..${process.platform === 'win32' ? '\\' : '/'}`, ) && !isAbsolute(withinProject) ) { throw new Error( 'Keep provisioning profiles outside the repository.', ); } profile = readProfile(profilePath); identity = selectIdentity( profile, step('Checking local signing identities', 'security', [ 'find-identity', '-v', '-p', 'codesigning', ]), mode, ); } mkdirSync(outputRoot, { recursive: true }); const working = mkdtempSync(join(outputRoot, '.building-')); let installedProfile; try { if (profile) { const installedPath = join( home, 'Library/Developer/Xcode/UserData/Provisioning Profiles', `${profile.UUID}.mobileprovision`, ); mkdirSync(dirname(installedPath), { recursive: true }); if (existsSync(installedPath)) { if ( !readFileSync(installedPath).equals( readFileSync(profilePath), ) ) { throw new Error( 'A different provisioning profile already exists with the same UUID. Resolve it in Xcode before retrying.', ); } } else { copyFileSync(profilePath, installedPath); installedProfile = installedPath; } } step('Generating the Xcode project', 'xcodegen', [ 'generate', '--spec', join(project, 'project.yml'), ]); step( 'Building the iOS Rust library', cargo, [ 'build', '--manifest-path', join(directory, 'src-tauri/Cargo.toml'), '--lib', '--locked', '--target', target, '--features', 'custom-protocol', ...(configuration === 'release' ? ['--release'] : []), ], { inherit: true }, ); const library = join( targetDirectory, target, configuration, 'libkanbangit_native.a', ); if (!existsSync(library)) { throw new Error( `The iOS Rust library was not produced: ${library}`, ); } buildEnv.KANBANGIT_IOS_LIBRARY = library; const archive = join(working, 'KanbanGit.xcarchive'); step( 'Archiving locally without Apple provisioning updates', 'xcodebuild', [ '-project', join(project, 'kanbangit-native.xcodeproj'), '-scheme', 'kanbangit-native_iOS', '-configuration', configuration, '-sdk', sdk, '-destination', `generic/platform=${simulator ? 'iOS Simulator' : 'iOS'}`, '-archivePath', archive, '-derivedDataPath', join(targetDirectory, `ios-derived-${mode}`), 'archive', `MARKETING_VERSION=${version}`, `CURRENT_PROJECT_VERSION=${buildNumber}`, `DEVELOPMENT_TEAM=${appleTeamId}`, `PRODUCT_BUNDLE_IDENTIFIER=${bundleIdentifier}`, 'CODE_SIGN_STYLE=Manual', 'ARCHS=arm64', ...(simulator ? ['CODE_SIGNING_ALLOWED=NO', 'CODE_SIGNING_REQUIRED=NO'] : [ `CODE_SIGN_IDENTITY=${identity}`, `PROVISIONING_PROFILE_SPECIFIER=${profile.UUID}`, ]), ], { inherit: true }, ); function verifyApp(app) { const info = parsePlist(readFileSync(join(app, 'Info.plist'))); if ( info.CFBundleIdentifier !== bundleIdentifier || info.CFBundleShortVersionString !== version || info.CFBundleVersion !== buildNumber ) { throw new Error( 'The built app has an unexpected identifier, version or build number.', ); } if (!simulator) { step('Verifying the Apple signature and team', 'codesign', [ '--verify', '--deep', '--strict', '-R', `=anchor apple generic and identifier "${bundleIdentifier}" and certificate leaf[subject.OU] = "${appleTeamId}" and certificate leaf = H"${identity}" and entitlement["application-identifier"] = "${appleTeamId}.${bundleIdentifier}" and entitlement["com.apple.developer.team-identifier"] = "${appleTeamId}" and ${mode === 'testflight' ? '! ' : ''}entitlement["get-task-allow"] exists${mode === 'testflight' ? ' and entitlement["beta-reports-active"] exists' : ''}`, app, ]); if ( readProfile(join(app, 'embedded.mobileprovision')).UUID !== profile.UUID ) { throw new Error( 'The app was signed with an unexpected provisioning profile.', ); } } } verifyApp(join(archive, 'Products/Applications/KanbanGit.app')); if (!simulator) { const exportOptions = join(working, 'ExportOptions.plist'); writeFileSync( exportOptions, JSON.stringify({ method: mode === 'testflight' ? 'app-store-connect' : 'debugging', destination: 'export', signingStyle: 'manual', teamID: appleTeamId, signingCertificate: identity, provisioningProfiles: { [bundleIdentifier]: profile.UUID }, manageAppVersionAndBuildNumber: false, }), ); step('Preparing local export options', 'plutil', [ '-convert', 'xml1', exportOptions, ]); step( 'Exporting the signed IPA locally (no upload)', 'xcodebuild', [ '-exportArchive', '-archivePath', archive, '-exportPath', join(working, 'export'), '-exportOptionsPlist', exportOptions, ], { inherit: true }, ); const ipa = join(working, 'export/KanbanGit.ipa'); if (!existsSync(ipa)) { throw new Error('Xcode did not produce the signed IPA.'); } const extracted = join(working, 'verify'); step('Verifying the exported IPA contents', 'ditto', [ '-x', '-k', ipa, extracted, ]); verifyApp(join(extracted, 'Payload/KanbanGit.app')); rmSync(extracted, { recursive: true, force: true }); writeFileSync( `${ipa}.sha256`, `${createHash('sha256').update(readFileSync(ipa)).digest('hex')} KanbanGit.ipa\n`, ); } if (mode !== 'testflight') { rmSync(destination, { recursive: true, force: true }); } renameSync(working, destination); log(`Verified local ${mode} build: ${destination}`); return destination; } catch (error) { log(`Build did not complete. Diagnostic output remains in ${working}`); throw error; } finally { if (installedProfile) { rmSync(installedProfile); } } } if ( process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href ) { try { const { values } = parseArgs({ options: { mode: { type: 'string', default: 'testflight' } }, }); buildIos({ mode: values.mode }); } catch (error) { console.error(error.message); process.exitCode = 1; } }