diff --git a/native/build-ios.mjs b/native/build-ios.mjs new file mode 100644 index 0000000..d12280c --- /dev/null +++ b/native/build-ios.mjs @@ -0,0 +1,509 @@ +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; + } +} diff --git a/native/package.json b/native/package.json index d273e5e..ba2a080 100644 --- a/native/package.json +++ b/native/package.json @@ -14,8 +14,9 @@ "build:android": "tauri android build --debug --apk --target aarch64", "smoke:macos": "cargo test --manifest-path src-tauri/Cargo.toml --locked --features smoke-test,custom-protocol --test macos_smoke", "dev:ios": "tauri ios dev", - "build:ios:simulator": "rm -rf src-tauri/gen/apple/build/arm64-sim/KanbanGit.app && tauri ios build --debug --target aarch64-sim --no-sign --ci", - "build:ios:device": "tauri ios build --debug --target aarch64 --export-method debugging --archive-only", + "build:ios:simulator": "node build-ios.mjs --mode simulator", + "build:ios:device": "node build-ios.mjs --mode development", + "release:ios": "node build-ios.mjs --mode testflight", "open:ios": "tauri ios dev --open --no-dev-server", "prepare:ios": "xcodegen generate --spec src-tauri/gen/apple/project.yml", "test:ios": "xcodebuild -project src-tauri/gen/apple/kanbangit-native.xcodeproj -scheme kanbangit-native_iOS -configuration debug -destination \"platform=iOS Simulator,id=${IOS_SIMULATOR_ID:?Set IOS_SIMULATOR_ID to an installed simulator UUID}\" -parallel-testing-enabled NO CODE_SIGNING_ALLOWED=NO test", diff --git a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png index 3ba6c85..a17fcb8 100644 Binary files a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png and b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png differ diff --git a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png index e3f34d5..3305487 100644 Binary files a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png and b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png differ diff --git a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png index e3f34d5..3305487 100644 Binary files a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png and b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png differ diff --git a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png index 8bcff76..62c3ed2 100644 Binary files a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png and b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png differ diff --git a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png index 1ed85c0..33334e0 100644 Binary files a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png and b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png differ diff --git a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png index b9cd314..e73794d 100644 Binary files a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png and b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png differ diff --git a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png index b9cd314..e73794d 100644 Binary files a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png and b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png differ diff --git a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png index 6b56941..8a3e48c 100644 Binary files a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png and b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png differ diff --git a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png index e3f34d5..3305487 100644 Binary files a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png and b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png differ diff --git a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png index 51b1c2a..bca916d 100644 Binary files a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png and b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png differ diff --git a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png index 51b1c2a..bca916d 100644 Binary files a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png and b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png differ diff --git a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png index 30decc9..a9a36ff 100644 Binary files a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png and b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png differ diff --git a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png index beb2f50..bf7966e 100644 Binary files a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png and b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png differ diff --git a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png index 30decc9..4d76835 100644 Binary files a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png and b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png differ diff --git a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png index cb0c0b2..c75d204 100644 Binary files a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png and b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png differ diff --git a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png index cdd488d..a2c30be 100644 Binary files a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png and b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png differ diff --git a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png index 56e188e..b993207 100644 Binary files a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png and b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png differ diff --git a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png index 283715a..89e6d03 100644 Binary files a/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png and b/native/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png differ diff --git a/native/src-tauri/gen/apple/Tests/WorkspaceTests.swift b/native/src-tauri/gen/apple/Tests/WorkspaceTests.swift index 4ee2972..4eb8e70 100644 --- a/native/src-tauri/gen/apple/Tests/WorkspaceTests.swift +++ b/native/src-tauri/gen/apple/Tests/WorkspaceTests.swift @@ -40,7 +40,7 @@ final class WorkspaceTests: XCTestCase { controller.open(url) let webview = try XCTUnwrap(controller.workspace) webview.stopLoading() - webview.loadHTMLString("", baseURL: url) + webview.loadSimulatedRequest(URLRequest(url: url), responseHTML: "") var loaded = false for _ in 0..<50 { if let value = try? await webview.evaluateJavaScript("document.getElementById('draft')?.value"), @@ -57,10 +57,12 @@ final class WorkspaceTests: XCTestCase { XCTAssertTrue(webview.canGoBack) XCTAssertTrue(toolbar.items?.first?.isEnabled == true) webview.goBack() + var returned = false for _ in 0..<20 { - if let hash = try? await webview.evaluateJavaScript("location.hash"), hash as? String == "" { break } + if let hash = try? await webview.evaluateJavaScript("location.hash"), hash as? String == "" { returned = true; break } try await Task.sleep(for: .milliseconds(50)) } + XCTAssertTrue(returned) let draft = try await webview.evaluateJavaScript("document.getElementById('draft').value") XCTAssertEqual(draft as? String, "unsaved") } diff --git a/native/src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj b/native/src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj index ec3205a..7d6c1ee 100644 --- a/native/src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj +++ b/native/src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj @@ -49,7 +49,9 @@ 47C531812A31207F97A89C5F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; 4C76059D31C50B198779A5F7 /* en.lproj */ = {isa = PBXFileReference; path = en.lproj; sourceTree = ""; }; 554AB059B4228085924F4959 /* WorkspacePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePolicy.swift; sourceTree = ""; }; + 5F71F06AB639140AB9714425 /* metadata.rs */ = {isa = PBXFileReference; path = metadata.rs; sourceTree = ""; }; 63685B1189795CAEA85F7AB7 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; + 6610D376CF901AC1733D394E /* updates.rs */ = {isa = PBXFileReference; path = updates.rs; sourceTree = ""; }; 7440C7F0426E6B3977DD1D94 /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = ""; }; 8243D0C3821DAC731FECF105 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; 91991DFDB0C064B06DF854ED /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = ""; }; @@ -146,7 +148,9 @@ 2B96972F58CC158AB3DFF94A /* lib.rs */, AEBAD9ABE758897C50BC1F2E /* locale.rs */, 7440C7F0426E6B3977DD1D94 /* main.rs */, + 5F71F06AB639140AB9714425 /* metadata.rs */, E8BA104C81071623E1FF367D /* server.rs */, + 6610D376CF901AC1733D394E /* updates.rs */, ); name = src; path = ../../src; @@ -269,12 +273,18 @@ LastUpgradeCheck = 1430; TargetAttributes = { 721CF221CEDDC28FF2C5EBD5 = { - DevelopmentTeam = ""; - ProvisioningStyle = Automatic; + DevelopmentTeam = ZZX2KZGTHU; + ProvisioningStyle = Manual; }; 988CFF304CB81C8F3A4D6612 = { + DevelopmentTeam = ZZX2KZGTHU; + ProvisioningStyle = Manual; TestTargetID = 721CF221CEDDC28FF2C5EBD5; }; + CBC4561F8018D37DD49A0598 = { + DevelopmentTeam = ZZX2KZGTHU; + ProvisioningStyle = Manual; + }; }; }; buildConfigurationList = A9CA551DD3F3EBCCF8C41CC1 /* Build configuration list for PBXProject "kanbangit-native" */; @@ -332,7 +342,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "export PATH=\"$(dirname \"$(rustup which cargo)\"):$PATH\"\nexport RUSTC=\"$(rustup which rustc)\"\ncd \"$SRCROOT/../../..\"\n./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}\n"; + shellScript = "set -eu\nif [ -n \"${KANBANGIT_IOS_LIBRARY:-}\" ]; then\n mkdir -p \"$SRCROOT/Externals/arm64/$CONFIGURATION\"\n cp \"$KANBANGIT_IOS_LIBRARY\" \"$SRCROOT/Externals/arm64/$CONFIGURATION/libapp.a\"\n exit 0\nfi\nexport PATH=\"$(dirname \"$(rustup which cargo)\"):$PATH\"\nexport RUSTC=\"$(rustup which rustc)\"\ncd \"$SRCROOT/../../..\"\n./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR:-} ${ARCHS:?}\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -404,9 +414,10 @@ ); ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements"; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; + CODE_SIGN_IDENTITY = "Apple Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ZZX2KZGTHU; ENABLE_BITCODE = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO; "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; @@ -421,9 +432,10 @@ ); "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; - PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.desktop; + MARKETING_VERSION = 0.2.1; + PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios; PRODUCT_MODULE_NAME = KanbanGitNative; - PRODUCT_NAME = "KanbanGit"; + PRODUCT_NAME = KanbanGit; SDKROOT = iphoneos; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -498,13 +510,15 @@ isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = ZZX2KZGTHU; GENERATE_INFOPLIST_FILE = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.uitests; + PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.uitests; SDKROOT = iphoneos; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -577,9 +591,10 @@ ); ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements"; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ZZX2KZGTHU; ENABLE_BITCODE = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO; "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; @@ -594,9 +609,10 @@ ); "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; - PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.desktop; + MARKETING_VERSION = 0.2.1; + PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios; PRODUCT_MODULE_NAME = KanbanGitNative; - PRODUCT_NAME = "KanbanGit"; + PRODUCT_NAME = KanbanGit; SDKROOT = iphoneos; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -608,13 +624,15 @@ isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = ZZX2KZGTHU; GENERATE_INFOPLIST_FILE = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.uitests; + PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.uitests; SDKROOT = iphoneos; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -626,13 +644,15 @@ isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = ZZX2KZGTHU; GENERATE_INFOPLIST_FILE = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.tests; + PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.tests; SDKROOT = iphoneos; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -643,13 +663,15 @@ isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = ZZX2KZGTHU; GENERATE_INFOPLIST_FILE = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.tests; + PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.tests; SDKROOT = iphoneos; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/native/src-tauri/gen/apple/kanbangit-native_iOS/Info.plist b/native/src-tauri/gen/apple/kanbangit-native_iOS/Info.plist index d20aab9..124cc20 100644 --- a/native/src-tauri/gen/apple/kanbangit-native_iOS/Info.plist +++ b/native/src-tauri/gen/apple/kanbangit-native_iOS/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.1.0 + $(MARKETING_VERSION) CFBundleVersion - 0.1.0 + $(CURRENT_PROJECT_VERSION) LSRequiresIPhoneOS NSCameraUsageDescription @@ -45,4 +45,4 @@ UIInterfaceOrientationLandscapeRight - \ No newline at end of file + diff --git a/native/src-tauri/gen/apple/project.yml b/native/src-tauri/gen/apple/project.yml index d2d3e82..4eadb84 100644 --- a/native/src-tauri/gen/apple/project.yml +++ b/native/src-tauri/gen/apple/project.yml @@ -1,6 +1,6 @@ name: kanbangit-native options: - bundleIdPrefix: de.kanbangit.desktop + bundleIdPrefix: de.kanbangit.ios deploymentTarget: iOS: 17.0 fileGroups: [../../src] @@ -14,10 +14,17 @@ settingGroups: SWIFT_VERSION: "5.0" PRODUCT_MODULE_NAME: KanbanGitNative TARGETED_DEVICE_FAMILY: "1,2" - CODE_SIGN_STYLE: Automatic - DEVELOPMENT_TEAM: "" + CODE_SIGN_STYLE: Manual + DEVELOPMENT_TEAM: ZZX2KZGTHU ENABLE_USER_SCRIPT_SANDBOXING: false - PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.desktop + PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios + MARKETING_VERSION: "0.2.1" + CURRENT_PROJECT_VERSION: "1" + configs: + debug: + CODE_SIGN_IDENTITY: Apple Development + release: + CODE_SIGN_IDENTITY: Apple Distribution targetTemplates: app: type: application @@ -55,8 +62,8 @@ targets: - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - CFBundleShortVersionString: 0.1.0 - CFBundleVersion: "0.1.0" + CFBundleShortVersionString: $(MARKETING_VERSION) + CFBundleVersion: $(CURRENT_PROJECT_VERSION) entitlements: path: kanbangit-native_iOS/kanbangit-native_iOS.entitlements scheme: @@ -88,10 +95,16 @@ targets: - sdk: WebKit.framework preBuildScripts: - script: | + set -eu + if [ -n "${KANBANGIT_IOS_LIBRARY:-}" ]; then + mkdir -p "$SRCROOT/Externals/arm64/$CONFIGURATION" + cp "$KANBANGIT_IOS_LIBRARY" "$SRCROOT/Externals/arm64/$CONFIGURATION/libapp.a" + exit 0 + fi export PATH="$(dirname "$(rustup which cargo)"):$PATH" export RUSTC="$(rustup which rustc)" cd "$SRCROOT/../../.." - ./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?} + ./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR:-} ${ARCHS:?} name: Build Rust Code basedOnDependencyAnalysis: false outputFiles: @@ -108,7 +121,9 @@ targets: settings: base: GENERATE_INFOPLIST_FILE: YES - PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.tests + PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.tests + DEVELOPMENT_TEAM: ZZX2KZGTHU + CODE_SIGN_STYLE: Manual SWIFT_VERSION: "5.0" WorkspaceUITests: type: bundle.ui-testing @@ -119,6 +134,8 @@ targets: settings: base: GENERATE_INFOPLIST_FILE: YES - PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.uitests + PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.uitests + DEVELOPMENT_TEAM: ZZX2KZGTHU + CODE_SIGN_STYLE: Manual SWIFT_VERSION: "5.0" TEST_TARGET_NAME: kanbangit-native_iOS diff --git a/native/src-tauri/tauri.ios.conf.json b/native/src-tauri/tauri.ios.conf.json index 6722463..9721cb6 100644 --- a/native/src-tauri/tauri.ios.conf.json +++ b/native/src-tauri/tauri.ios.conf.json @@ -1,6 +1,11 @@ { "$schema": "https://schema.tauri.app/config/2", + "identifier": "de.kanbangit.ios", "bundle": { - "iOS": { "minimumSystemVersion": "17.0" } + "iOS": { + "minimumSystemVersion": "17.0", + "developmentTeam": "ZZX2KZGTHU", + "bundleVersion": "1" + } } } diff --git a/native/tests/ios-build.test.js b/native/tests/ios-build.test.js new file mode 100644 index 0000000..7462bd5 --- /dev/null +++ b/native/tests/ios-build.test.js @@ -0,0 +1,534 @@ +import assert from 'node:assert/strict'; +import { Buffer } from 'node:buffer'; +import { createHash } from 'node:crypto'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { + appleTeamId, + buildIos, + bundleIdentifier, + selectIdentity, + validateProfile, +} from '../build-ios.mjs'; + +const certificate = Buffer.from('synthetic test certificate'); +const fingerprint = createHash('sha1') + .update(certificate) + .digest('hex') + .toUpperCase(); + +function profileFor(mode = 'testflight') { + return { + UUID: '00000000-1111-2222-3333-444444444444', + TeamIdentifier: [appleTeamId], + Platform: ['iOS'], + ExpirationDate: '2099-01-01T00:00:00Z', + DeveloperCertificates: [certificate.toString('base64')], + ...(mode === 'development' + ? { ProvisionedDevices: ['test-device'] } + : {}), + Entitlements: { + 'application-identifier': `${appleTeamId}.${bundleIdentifier}`, + 'com.apple.developer.team-identifier': appleTeamId, + 'get-task-allow': mode === 'development', + 'beta-reports-active': mode === 'testflight', + }, + }; +} + +function fixture(t, mode = 'testflight', overrides = {}) { + const root = mkdtempSync(join(tmpdir(), 'kanbangit-ios-test-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const directory = join(root, 'repo/native'); + const profilePath = join(root, 'profile.mobileprovision'); + const config = { + productName: 'KanbanGit', + identifier: 'de.kanbangit.desktop', + version: '1.2.3', + }; + const ios = { + identifier: bundleIdentifier, + bundle: { + iOS: { + developmentTeam: appleTeamId, + bundleVersion: '7', + minimumSystemVersion: '17.0', + }, + }, + }; + mkdirSync(join(directory, 'src-tauri'), { recursive: true }); + writeFileSync( + join(directory, 'src-tauri/tauri.conf.json'), + JSON.stringify(config), + ); + writeFileSync( + join(directory, 'src-tauri/tauri.ios.conf.json'), + JSON.stringify(ios), + ); + writeFileSync(profilePath, 'external provisioning profile'); + const profile = profileFor(mode); + const calls = []; + const destination = join(directory, `src-tauri/target/ios-${mode}/1.2.3-7`); + const home = join(root, 'home'); + const installedProfile = join( + home, + `Library/Developer/Xcode/UserData/Provisioning Profiles/${profile.UUID}.mobileprovision`, + ); + const info = { + CFBundleIdentifier: bundleIdentifier, + CFBundleShortVersionString: '1.2.3', + CFBundleVersion: '7', + }; + function app(path) { + mkdirSync(path, { recursive: true }); + writeFileSync( + join(path, 'Info.plist'), + JSON.stringify({ ...info, ...overrides.info }), + ); + } + const options = { + directory, + home, + mode, + platform: 'darwin', + log() {}, + env: { PATH: '/test/bin', IOS_PROVISIONING_PROFILE: profilePath }, + run(command, args, settings) { + calls.push({ command, args, settings }); + if (overrides.failStep === settings.step) { + throw new Error('Simulated build failure'); + } + if (command === 'python3') { + return settings.input.toString(); + } + if (command === 'security') { + if (args[0] === 'cms') { + const embedded = args + .at(-1) + .endsWith('embedded.mobileprovision'); + return JSON.stringify( + embedded && overrides.embeddedProfile + ? overrides.embeddedProfile + : profile, + ); + } + return `1) ${fingerprint} "Apple ${mode === 'development' ? 'Development' : 'Distribution'}: Example (${appleTeamId})"`; + } + if (command === 'rustup') { + if (args[0] === 'target') { + return ( + overrides.targets ?? + 'aarch64-apple-ios\naarch64-apple-ios-sim' + ); + } + return `/permanent/toolchain/bin/${args[1]}`; + } + if (command === '/permanent/toolchain/bin/cargo') { + const library = join( + directory, + 'src-tauri/target', + args[args.indexOf('--target') + 1], + args.includes('--release') ? 'release' : 'debug', + 'libkanbangit_native.a', + ); + mkdirSync(dirname(library), { recursive: true }); + writeFileSync(library, 'compiled library'); + } + if (command === 'xcodebuild' && args.includes('archive')) { + assert.ok(mode === 'simulator' || existsSync(installedProfile)); + app( + join( + args[args.indexOf('-archivePath') + 1], + 'Products/Applications/KanbanGit.app', + ), + ); + } + if (command === 'xcodebuild' && args.includes('-exportArchive')) { + const output = args[args.indexOf('-exportPath') + 1]; + mkdirSync(output, { recursive: true }); + if (!overrides.missingIpa) { + writeFileSync( + join(output, 'KanbanGit.ipa'), + 'signed ipa bytes', + ); + } + } + if (command === 'ditto') { + app(join(args.at(-1), 'Payload/KanbanGit.app')); + } + if (command === 'xcrun') { + return '/permanent/Xcode/SDK'; + } + return ''; + }, + }; + return { + options, + calls, + profile, + profilePath, + destination, + installedProfile, + ios, + }; +} + +test('TestFlight produces a verified local IPA with manual signing and no Apple mutation or upload flags', (t) => { + const f = fixture(t); + f.options.env.APPLE_API_KEY = 'must-not-reach-build'; + f.options.env.IOS_CERTIFICATE = 'must-not-reach-build'; + assert.equal(buildIos(f.options), f.destination); + const options = JSON.parse( + readFileSync(join(f.destination, 'ExportOptions.plist'), 'utf8'), + ); + assert.equal(options.method, 'app-store-connect'); + assert.equal(options.destination, 'export'); + assert.equal(options.signingStyle, 'manual'); + assert.equal(options.manageAppVersionAndBuildNumber, false); + assert.equal(options.signingCertificate, fingerprint); + assert.deepEqual(options.provisioningProfiles, { + [bundleIdentifier]: f.profile.UUID, + }); + assert.equal( + readFileSync( + join(f.destination, 'export/KanbanGit.ipa.sha256'), + 'utf8', + ), + `${createHash('sha256').update('signed ipa bytes').digest('hex')} KanbanGit.ipa\n`, + ); + assert.equal( + f.calls.filter( + (call) => + call.command === 'codesign' && call.args.includes('--verify'), + ).length, + 2, + ); + assert.equal(existsSync(f.installedProfile), false); + const verification = f.calls.find((call) => call.command === 'codesign'); + const requirement = verification.args[verification.args.indexOf('-R') + 1]; + assert.ok(requirement.includes('! entitlement["get-task-allow"] exists')); + assert.ok( + requirement.includes('entitlement["beta-reports-active"] exists'), + ); + assert.ok(requirement.includes(`certificate leaf = H"${fingerprint}"`)); + for (const call of f.calls) { + assert.equal(call.settings.env.APPLE_API_KEY, undefined); + assert.equal(call.settings.env.IOS_CERTIFICATE, undefined); + assert.ok( + !call.args.some((arg) => + /allowProvisioning|authenticationKey|upload|downloadPlatform/.test( + arg, + ), + ), + ); + } + const archive = f.calls.find((call) => call.args.includes('archive')); + assert.ok(archive.args.includes('CODE_SIGN_STYLE=Manual')); + assert.ok( + archive.args.includes( + `PROVISIONING_PROFILE_SPECIFIER=${f.profile.UUID}`, + ), + ); + assert.ok( + archive.settings.env.KANBANGIT_IOS_LIBRARY.endsWith( + '/aarch64-apple-ios/release/libkanbangit_native.a', + ), + ); +}); + +test('the simulator needs no certificate or profile and disables signing explicitly', (t) => { + const f = fixture(t, 'simulator'); + delete f.options.env.IOS_PROVISIONING_PROFILE; + buildIos(f.options); + assert.ok( + f.calls.some((call) => call.args.includes('CODE_SIGNING_ALLOWED=NO')), + ); + assert.ok( + f.calls.some((call) => call.args.includes('aarch64-apple-ios-sim')), + ); + assert.ok( + !f.calls.some( + (call) => + ['security', 'codesign'].includes(call.command) || + call.args.includes('-exportArchive'), + ), + ); +}); + +test('local device development uses a registered-device development profile', (t) => { + const f = fixture(t, 'development'); + buildIos(f.options); + const options = JSON.parse( + readFileSync(join(f.destination, 'ExportOptions.plist'), 'utf8'), + ); + assert.equal(options.method, 'debugging'); + assert.equal(options.destination, 'export'); +}); + +for (const [name, change, message] of [ + [ + 'different team', + (p) => { + p.TeamIdentifier = ['OLDTEAM']; + }, + /explicitly match/, + ], + [ + 'wildcard identifier', + (p) => { + p.Entitlements['application-identifier'] = `${appleTeamId}.*`; + }, + /explicitly match/, + ], + [ + 'macOS identifier', + (p) => { + p.Entitlements['application-identifier'] = + `${appleTeamId}.de.kanbangit.desktop`; + }, + /explicitly match/, + ], + [ + 'expired profile', + (p) => { + p.ExpirationDate = '2000-01-01'; + }, + /expired/, + ], + [ + 'invalid expiry', + (p) => { + p.ExpirationDate = 'invalid'; + }, + /expired/, + ], + [ + 'invalid UUID', + (p) => { + p.UUID = '../unexpected'; + }, + /invalid/, + ], + [ + 'missing certificate', + (p) => { + p.DeveloperCertificates = []; + }, + /invalid/, + ], + [ + 'wrong platform', + (p) => { + p.Platform = ['OSX']; + }, + /iOS profile/, + ], + [ + 'Ad Hoc profile', + (p) => { + p.ProvisionedDevices = ['device']; + }, + /App Store Connect/, + ], + [ + 'enterprise profile', + (p) => { + p.ProvisionsAllDevices = true; + }, + /App Store Connect/, + ], + [ + 'development entitlement', + (p) => { + p.Entitlements['get-task-allow'] = true; + }, + /App Store Connect/, + ], + [ + 'missing beta entitlement', + (p) => { + delete p.Entitlements['beta-reports-active']; + }, + /App Store Connect/, + ], +]) { + test(`rejects ${name} before signing`, () => { + const profile = profileFor(); + change(profile); + assert.throws(() => validateProfile(profile, 'testflight'), message); + }); +} + +test('a distribution profile cannot be used for device development', () => { + assert.throws( + () => validateProfile(profileFor(), 'development'), + /App Development/, + ); +}); + +test('identity selection requires the profile certificate and a usable private key of the correct kind', () => { + const profile = profileFor(); + for (const identities of [ + '0 valid identities found', + `1) ${'A'.repeat(40)} "Apple Distribution: Other"`, + `1) ${fingerprint} "Developer ID Application: Example"`, + `1) ${fingerprint} "Apple Development: Example"`, + ]) { + assert.throws( + () => selectIdentity(profile, identities, 'testflight'), + /private key/, + ); + } +}); + +for (const step of [ + 'Generating the Xcode project', + 'Building the iOS Rust library', + 'Archiving locally without Apple provisioning updates', + 'Verifying the Apple signature and team', + 'Exporting the signed IPA locally (no upload)', +]) { + test(`failure at ${step} does not publish a successful build and cleans its profile`, (t) => { + const f = fixture(t, 'testflight', { failStep: step }); + assert.throws(() => buildIos(f.options), /Simulated build failure/); + assert.equal(existsSync(f.destination), false); + assert.equal(existsSync(f.installedProfile), false); + }); +} + +test('an existing identical local profile is preserved after the build', (t) => { + const f = fixture(t); + mkdirSync(dirname(f.installedProfile), { recursive: true }); + writeFileSync(f.installedProfile, readFileSync(f.profilePath)); + buildIos(f.options); + assert.equal(existsSync(f.installedProfile), true); +}); + +test('a conflicting local profile is never overwritten or deleted', (t) => { + const f = fixture(t); + mkdirSync(dirname(f.installedProfile), { recursive: true }); + writeFileSync(f.installedProfile, 'user profile'); + assert.throws(() => buildIos(f.options), /same UUID/); + assert.equal(readFileSync(f.installedProfile, 'utf8'), 'user profile'); +}); + +test('missing toolchain targets fail before compilation without installing anything', (t) => { + const f = fixture(t, 'testflight', { targets: 'aarch64-apple-darwin' }); + assert.throws( + () => buildIos(f.options), + /rustup target add aarch64-apple-ios/, + ); + assert.ok( + !f.calls.some( + (call) => call.args.includes('build') || call.args.includes('add'), + ), + ); +}); + +test('missing profile and ambient team overrides fail clearly', (t) => { + const f = fixture(t); + delete f.options.env.IOS_PROVISIONING_PROFILE; + assert.throws(() => buildIos(f.options), /IOS_PROVISIONING_PROFILE/); + f.options.env.APPLE_DEVELOPMENT_TEAM = 'OLDTEAM'; + assert.throws(() => buildIos(f.options), /must be ZZX2KZGTHU/); + delete f.options.env.APPLE_DEVELOPMENT_TEAM; + f.options.env.TAURI_CONFIG = '{}'; + assert.throws(() => buildIos(f.options), /Remove TAURI_CONFIG/); +}); + +for (const [name, overrides, message] of [ + [ + 'wrong app version', + { info: { CFBundleVersion: '8' } }, + /unexpected identifier, version/, + ], + [ + 'changed embedded profile', + { + embeddedProfile: { + ...profileFor(), + UUID: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + }, + }, + /unexpected provisioning profile/, + ], + ['missing IPA', { missingIpa: true }, /did not produce/], +]) { + test(`rejects ${name} in the build output`, (t) => { + const f = fixture(t, 'testflight', overrides); + assert.throws(() => buildIos(f.options), message); + assert.equal(existsSync(f.destination), false); + }); +} + +test('completed artifacts are not silently overwritten', (t) => { + const f = fixture(t); + buildIos(f.options); + assert.throws(() => buildIos(f.options), /already exists/); +}); + +for (const mode of ['simulator', 'development']) { + test(`${mode} can rebuild the same version and preserves the previous build on failure`, (t) => { + const overrides = {}; + const f = fixture(t, mode, overrides); + buildIos(f.options); + const previousBuild = join(f.destination, 'previous-build'); + writeFileSync(previousBuild, 'verified artifact'); + overrides.failStep = 'Building the iOS Rust library'; + assert.throws(() => buildIos(f.options), /Simulated build failure/); + assert.equal(readFileSync(previousBuild, 'utf8'), 'verified artifact'); + delete overrides.failStep; + buildIos(f.options); + assert.equal(existsSync(previousBuild), false); + assert.ok(existsSync(join(f.destination, 'KanbanGit.xcarchive'))); + assert.equal(existsSync(f.installedProfile), false); + }); +} + +test('every iOS icon has its declared dimensions and contains no alpha channel or transparency chunk', () => { + const assets = fileURLToPath( + new URL( + '../src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/', + import.meta.url, + ), + ); + const catalog = JSON.parse( + readFileSync(join(assets, 'Contents.json'), 'utf8'), + ); + assert.ok( + catalog.images.some( + (icon) => + icon.idiom === 'ios-marketing' && icon.size === '1024x1024', + ), + ); + for (const icon of catalog.images) { + const png = readFileSync(join(assets, icon.filename)); + assert.equal(png.subarray(0, 8).toString('hex'), '89504e470d0a1a0a'); + const [width, height] = icon.size.split('x').map(Number); + const scale = Number.parseInt(icon.scale, 10); + assert.equal(png.readUInt32BE(16), width * scale, icon.filename); + assert.equal(png.readUInt32BE(20), height * scale, icon.filename); + assert.equal(png[25], 2, `${icon.filename} must use RGB without alpha`); + for ( + let offset = 8; + offset < png.length; + offset += png.readUInt32BE(offset) + 12 + ) { + assert.notEqual( + png.subarray(offset + 4, offset + 8).toString(), + 'tRNS', + icon.filename, + ); + } + } +});