diff --git a/cli/package.json b/cli/package.json index 5e725b4fb3..e981f835a3 100644 --- a/cli/package.json +++ b/cli/package.json @@ -92,8 +92,9 @@ "test:payload-split": "bun test/test-payload-split.mjs", "test:macos-signing": "bun test/test-macos-signing.mjs", "test:apple-api-import-helpers": "bun test/test-apple-api-import-helpers.mjs", + "test:bundle-id-detector": "bun test/test-bundle-id-detector.mjs", "test:manifest-path-encoding": "bun test/test-manifest-path-encoding.mjs", - "test": "bun run build && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:credentials && bun run test:credentials-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:posthog-exception && bun run test:build-platform-selection && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:apple-api-import-helpers && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:ai-render-markdown", + "test": "bun run build && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:credentials && bun run test:credentials-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:posthog-exception && bun run test:build-platform-selection && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:apple-api-import-helpers && bun run test:bundle-id-detector && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:ai-render-markdown", "test:build-platform-selection": "bun test/test-build-platform-selection.mjs", "test:ai-log-capture": "bun test/test-ai-log-capture.mjs", "test:ai-analyze-flow": "bun test/test-ai-analyze-flow.mjs", @@ -106,7 +107,8 @@ "jsonwebtoken": "^9.0.3", "node-forge": "^1.3.3", "qrcode": "^1.5.4", - "react": "^18.3.1" + "react": "^18.3.1", + "string-width": "^8.2.1" }, "devDependencies": { "@antfu/eslint-config": "^7.0.0", diff --git a/cli/src/build/onboarding/apple-api.ts b/cli/src/build/onboarding/apple-api.ts index bd46636f15..0a0067b029 100644 --- a/cli/src/build/onboarding/apple-api.ts +++ b/cli/src/build/onboarding/apple-api.ts @@ -111,6 +111,98 @@ export interface AscDistributionCert { certificateContent?: string } +// ─── Cert availability classifier ────────────────────────────────── + +/** + * Why a local Keychain cert can't be used to ship builds. + * + * Concrete enumeration so the import-pick-identity UI can render a stable + * Reason column and so we can add specific guidance per reason (e.g. + * "managed" certs get a "can't sign locally" note, "not-visible" certs get + * a "Open Developer Portal to verify" note). + */ +export type CertAvailabilityReason + = | 'expired' + | 'managed' // DISTRIBUTION_MANAGED — Apple-HSM signed, can't sign locally + | 'not-visible' // lookup didn't find a SHA1 match (revoked / wrong team / filter limitation) + | 'check-failed' // network or API error during lookup + | 'no-private-key' // for .p12 path — cert exists but key was stripped + +export interface CertAvailability { + available: boolean + reason?: CertAvailabilityReason + /** Short human-readable reason for display in the picker. */ + reasonText?: string + /** When available — Apple-side cert resource id for downstream API calls. */ + appleCertId?: string +} + +/** + * Pure classifier: given a local cert + the result of an Apple-side lookup, + * decide whether it's usable for shipping builds and surface a short + * reasonText for the picker UI. + * + * Exported separately from the lookup function so we can unit-test the + * decision logic without mocking network calls. Callers compose: + * + * const certId = await findCertIdBySha1(token, identity.sha1) + * .catch(err => { lookupError = err; return null }) + * const availability = classifyCertAvailability({ + * localExpirationDate: identity.expirationDate, + * appleCertId: certId, + * lookupError, + * }) + * + * The `expired` and `managed` branches don't need a lookup — they're checked + * up-front from local metadata. Callers can pass null `appleCertId` without + * having run the lookup at all when those local-side conditions already + * disqualify the identity. + */ +export function classifyCertAvailability(args: { + localExpirationDate?: string + isManaged?: boolean + appleCertId: string | null + lookupError?: unknown +}): CertAvailability { + // Local-side disqualifiers first — these don't require an API round-trip. + if (args.localExpirationDate) { + const exp = Date.parse(args.localExpirationDate) + if (!Number.isNaN(exp) && exp < Date.now()) { + return { + available: false, + reason: 'expired', + reasonText: `Expired (${args.localExpirationDate.split('T')[0]})`, + } + } + } + if (args.isManaged) { + return { + available: false, + reason: 'managed', + reasonText: 'Apple-managed — can\'t sign locally', + } + } + // Apple-side lookup outcomes. + if (args.lookupError) { + return { + available: false, + reason: 'check-failed', + reasonText: `Lookup failed: ${args.lookupError instanceof Error ? args.lookupError.message : String(args.lookupError)}`, + } + } + if (args.appleCertId) { + return { available: true, appleCertId: args.appleCertId } + } + // No match returned. We can't distinguish revoked vs. wrong-team vs. our + // own lookup having a buggy filter from the response alone, so surface a + // neutral reasonText that doesn't claim revocation we can't prove. + return { + available: false, + reason: 'not-visible', + reasonText: 'Not visible to current API key (revoked, different team, or lookup limitation)', + } +} + /** * List all iOS distribution certificates. * @@ -121,8 +213,27 @@ export async function listDistributionCerts( token: string, options: { includeContent?: boolean } = {}, ): Promise { + // Query BOTH cert types — the legacy iOS-specific and the newer cross- + // platform "Apple Distribution" — because Apple deprecated + // IOS_DISTRIBUTION around 2021 and new certs created from App Store + // Connect now default to DISTRIBUTION. A team that has churned through + // certs over the years almost always has both types in its ledger; an + // IOS_DISTRIBUTION-only filter silently excludes the newer ones and + // produces a false negative when matching against a local Keychain + // identity named "Apple Distribution:" (= DISTRIBUTION type). + // + // limit=200 is Apple's documented max for this endpoint and is wildly + // higher than the per-team cert limits, so pagination is unnecessary + // even for the most prolific teams. + // + // DISTRIBUTION_MANAGED is intentionally NOT in the filter — those certs + // are signed using Apple-held private keys (Xcode Cloud / managed + // signing) and cannot be used to sign builds on third-party CI like + // Capgo. Including them would surface unusable identities in the + // picker. They'll still appear in the Available/Unavailable table view + // (Phase 2) marked "Apple-managed — can't sign locally". const body = await ascFetch( - '/certificates?filter[certificateType]=IOS_DISTRIBUTION&limit=10', + '/certificates?filter[certificateType]=DISTRIBUTION,IOS_DISTRIBUTION&limit=200', token, ) return (body.data || []).map((c: any): AscDistributionCert => ({ @@ -166,13 +277,31 @@ export function computeCertSha1(certificateContentBase64: string): string { * creation. Returns null if no Apple-side cert matches the SHA1. */ export async function findCertIdBySha1(token: string, sha1: string): Promise { + const match = await findCertBySha1(token, sha1) + return match ? match.id : null +} + +/** + * Like {@link findCertIdBySha1} but returns the full Apple-side cert + * record (id + name + expirationDate + serialNumber) when matched. Used + * by the eager batch validation so the picker / manual-portal-walkthrough + * step can surface concrete disambiguators (expiration date, last few + * chars of serial number — both visible in the Apple Developer Portal + * when the user clicks into a cert) that help the user pick the right + * row when multiple distribution certs are listed for the same team. + * + * Apple's API does NOT expose a "created by" field on certs (the portal + * UI shows it, but `/v1/certificates` doesn't return that column). The + * disambiguators we can give are expirationDate + serialNumber. + */ +export async function findCertBySha1(token: string, sha1: string): Promise { const target = sha1.toLowerCase() const certs = await listDistributionCerts(token, { includeContent: true }) for (const cert of certs) { if (!cert.certificateContent) continue if (computeCertSha1(cert.certificateContent) === target) - return cert.id + return cert } return null } @@ -195,9 +324,21 @@ export async function listProfilesForCert( token: string, certificateId: string, ): Promise { - // The relationships filter does the server-side join for us + // There's no direct "profiles for a given cert" endpoint on the ASC + // API — both naïve attempts return 4xx: + // - `/profiles?filter[certificates]=X` → 400, "'certificates' is not + // a valid filter type" (filter is whitelisted to id / name / + // profileState / profileType only). + // - `/certificates/{id}/profiles` → 404, "The relationship + // 'profiles' does not exist" (certificates is the to-many + // side; profiles → certificates is the navigable direction). + // + // The supported approach is to list ALL profiles with + // `include=certificates,bundleId`, then filter client-side to those + // whose `relationships.certificates.data[]` array includes our cert id. + // Limit=200 is Apple's documented max for /profiles. const body = await ascFetch( - `/profiles?filter[certificates]=${encodeURIComponent(certificateId)}&include=bundleId&limit=50`, + `/profiles?include=certificates,bundleId&limit=200`, token, ) const included: any[] = body.included || [] @@ -206,7 +347,14 @@ export async function listProfilesForCert( if (item.type === 'bundleIds' && item.attributes?.identifier) bundleById.set(item.id, item.attributes.identifier) } - return (body.data || []).map((p: any): AscProfileSummary => { + // Client-side cert-id filter on `relationships.certificates.data[].id`. + // Apple's included response includes the cert resources too, but we + // only need the reference array to decide which profiles to keep. + const profiles = (body.data || []).filter((p: any) => { + const certs: { id: string }[] = p.relationships?.certificates?.data ?? [] + return certs.some(c => c.id === certificateId) + }) + return profiles.map((p: any): AscProfileSummary => { const bundleRelId = p.relationships?.bundleId?.data?.id as string | undefined return { id: p.id, diff --git a/cli/src/build/onboarding/bundle-id-detector.ts b/cli/src/build/onboarding/bundle-id-detector.ts new file mode 100644 index 0000000000..89c95827a4 --- /dev/null +++ b/cli/src/build/onboarding/bundle-id-detector.ts @@ -0,0 +1,238 @@ +// src/build/onboarding/bundle-id-detector.ts +// +// Detect the iOS bundle ID for Apple-side operations (cert/profile lookup, +// provisioning_map keys). Falls back to capacitor.config.appId only when the +// pbxproj / Info.plist sources can't be read. +// +// We split this off from the existing pbxproj-parser.ts because that module +// returns ALL signable targets (multi-target apps with extensions) and the +// onboarding flow only cares about the main app's bundle ID for the +// confirm-app-id step. Routing through one mainline-target helper keeps the +// UI simple ("Use X from pbxproj / Use Y from capacitor.config / type your +// own") and the detection deterministic. +// +// All sources are read as plain text. project.pbxproj is a NeXT-step plist +// (mostly regex-friendly) and Info.plist is XML — we use simple regexes +// rather than pulling in a plist parser, matching the conservative approach +// already taken in src/app/updateProbe.ts. + +import { readFileSync } from 'node:fs' + +import { findXcodeProject } from '../pbxproj-parser.js' + +export type BundleIdSource = 'pbxproj-release' | 'pbxproj-fallback' | 'plist' | 'capacitor-config' + +export interface BundleIdCandidate { + value: string + source: BundleIdSource + /** Short human-readable label for the picker, e.g. "project.pbxproj (Release)". */ + label: string +} + +export interface DetectedBundleIds { + /** PRODUCT_BUNDLE_IDENTIFIER from project.pbxproj, preferring Release config. */ + pbxproj: BundleIdCandidate | null + /** + * Info.plist CFBundleIdentifier when it's a literal value (not the common + * `$(PRODUCT_BUNDLE_IDENTIFIER)` placeholder, which we drop because it + * adds nothing the pbxproj source doesn't already cover). + */ + plist: BundleIdCandidate | null + /** capacitor.config.ts/json's appId — always present (it's a required arg). */ + capacitor: BundleIdCandidate + /** + * The best-guess Apple-side bundle ID, picked in priority order: + * pbxproj-release > pbxproj-fallback > plist > capacitor. Returned for + * convenience so callers don't have to re-implement the precedence. + */ + recommended: BundleIdCandidate + /** + * True when the recommended value differs from capacitor.config.appId. + * Used by the confirm-app-id step to decide whether to surface a question + * at all — when they match, nothing's worth asking about. + */ + mismatch: boolean + /** + * Deduplicated, ordered list of candidates ready to render as Select + * options. Empty list is impossible (capacitor is always included). + */ + candidates: BundleIdCandidate[] +} + +/** + * Parse `PRODUCT_BUNDLE_IDENTIFIER = "..."` lines from pbxproj content. + * Returns the Release-config value if present, else the first non-Release + * value. Returns null when no bundle id can be extracted. + * + * Looks like a re-implementation of pbxproj-parser.ts's resolveBundleId, but + * that one needs an XCConfigurationList id (it walks from a target). This + * one needs to work standalone given only the file contents — so it + * collects all PRODUCT_BUNDLE_IDENTIFIER values, groups by adjacent + * `name = Release`/`name = Debug` markers, and prefers Release. Less + * accurate for multi-target projects but good enough for the "what should + * we pre-fill" use case here. + */ +export function parsePbxprojBundleId(pbxprojContent: string): BundleIdCandidate | null { + if (!pbxprojContent) + return null + + // Look for XCBuildConfiguration blocks (one level of nested braces tolerated + // for `buildSettings = { ... }`). Each block has a `name = Release;` and + // a `PRODUCT_BUNDLE_IDENTIFIER = "...";` somewhere inside. + // + // Capacitor app extensions (e.g. NotificationServiceExtension) have their + // own PRODUCT_BUNDLE_IDENTIFIER in the same pbxproj — typically a child + // of the main bundle id (com.example.app.notif). We prefer the main app + // target by looking for a configuration block whose containing pbxproj + // section has a target name containing "App" (the Capacitor default). + // Since we don't have target context here, we settle for the heuristic + // "shortest bundle id" — the parent is always a prefix of any child. + + const buildConfigRegex = /\w+\s*\/\*[^*]*\*\/\s*=\s*\{[^{}]*(?:\{[^}]*\}[^{}]*)*\}/g + const candidates: { value: string, configName: string }[] = [] + + for (const match of pbxprojContent.matchAll(buildConfigRegex)) { + const block = match[0] + if (!block.includes('XCBuildConfiguration')) + continue + const nameMatch = block.match(/name\s*=\s*("[^"]*"|[^;\s]+)\s*;/) + const bundleIdMatch = block.match(/PRODUCT_BUNDLE_IDENTIFIER\s*=\s*"?([^";\s]+)"?\s*;/) + if (!nameMatch || !bundleIdMatch) + continue + const configName = nameMatch[1].replace(/^"|"$/g, '') + const value = bundleIdMatch[1] + // Skip pbxproj variable references like $(PRODUCT_BUNDLE_IDENTIFIER:rfc1034identifier) + if (value.includes('$(')) + continue + candidates.push({ value, configName }) + } + + if (candidates.length === 0) + return null + + // Heuristic: prefer the shortest bundle id at the Release level (the main + // app, not an extension). Falls back to the shortest at any level. + const release = candidates + .filter(c => c.configName === 'Release') + .sort((a, b) => a.value.length - b.value.length)[0] + if (release) { + return { + value: release.value, + source: 'pbxproj-release', + label: 'project.pbxproj (Release config)', + } + } + + const fallback = candidates.sort((a, b) => a.value.length - b.value.length)[0] + return { + value: fallback.value, + source: 'pbxproj-fallback', + label: `project.pbxproj (${fallback.configName} config)`, + } +} + +/** + * Parse Info.plist's CFBundleIdentifier from raw XML. + * Returns null when the file is empty, when CFBundleIdentifier is absent, + * or when it's a `$(PRODUCT_BUNDLE_IDENTIFIER)` variable reference (we drop + * the placeholder so the picker doesn't list a non-actionable option). + */ +export function parseInfoPlistBundleId(plistContent: string): BundleIdCandidate | null { + if (!plistContent) + return null + const match = plistContent.match(/CFBundleIdentifier<\/key>\s*([^<]+)<\/string>/) + if (!match) + return null + const value = match[1].trim() + if (!value || value.includes('$(')) + return null + return { + value, + source: 'plist', + label: 'Info.plist (CFBundleIdentifier)', + } +} + +/** + * Read project.pbxproj and Info.plist from the iOS dir and return all + * available bundle id candidates, plus the recommended one and a + * mismatch flag. + * + * Filesystem reads are best-effort — when either file is missing or + * unreadable, we silently skip that source. The capacitor candidate is + * always present. + */ +export function detectIosBundleIds(opts: { + /** Project root (typically `process.cwd()`). */ + cwd: string + /** Subdirectory under cwd holding the iOS project (typically "ios"). */ + iosDir: string + /** Bundle id read from capacitor.config.ts/json — always known. */ + capacitorAppId: string +}): DetectedBundleIds { + const { cwd, iosDir, capacitorAppId } = opts + + const capacitor: BundleIdCandidate = { + value: capacitorAppId, + source: 'capacitor-config', + label: 'capacitor.config.ts (appId)', + } + + let pbxproj: BundleIdCandidate | null = null + let plist: BundleIdCandidate | null = null + + // pbxproj — use the existing finder so we agree with the rest of the CLI + // on what counts as the canonical iOS project location. + const pbxprojPath = findXcodeProject(`${cwd}/${iosDir}`) ?? findXcodeProject(cwd) + if (pbxprojPath) { + try { + const content = readFileSync(pbxprojPath, 'utf-8') + pbxproj = parsePbxprojBundleId(content) + } + catch { + // unreadable — silently skip + } + } + + // Info.plist — most Capacitor templates put it at ios/App/App/Info.plist. + // We try that path first; absence is fine (Info.plist is a weaker source + // than pbxproj anyway). + const plistPaths = [ + `${cwd}/${iosDir}/App/App/Info.plist`, + `${cwd}/${iosDir}/App/Info.plist`, + ] + for (const path of plistPaths) { + try { + const content = readFileSync(path, 'utf-8') + plist = parseInfoPlistBundleId(content) + if (plist) + break + } + catch { + // missing — try the next candidate path + } + } + + // Build a deduplicated candidate list. Order is priority-first so the + // confirm-app-id step renders the recommended choice on top. + const seen = new Set() + const ordered: BundleIdCandidate[] = [] + for (const c of [pbxproj, plist, capacitor]) { + if (!c || seen.has(c.value)) + continue + seen.add(c.value) + ordered.push(c) + } + + const recommended = ordered[0] + const mismatch = recommended.value !== capacitor.value + + return { + pbxproj, + plist, + capacitor, + recommended, + mismatch, + candidates: ordered, + } +} diff --git a/cli/src/build/onboarding/command.ts b/cli/src/build/onboarding/command.ts index 5d44577b6e..7df7e0b3e9 100644 --- a/cli/src/build/onboarding/command.ts +++ b/cli/src/build/onboarding/command.ts @@ -76,11 +76,19 @@ export async function onboardingBuilderCommand(options: OnboardingBuilderOptions // Detect app ID and platform directories from capacitor.config.ts let appId: string | undefined + // `iosBundleIdInitial` is the iOS-side default — the top-level + // `config.appId` (what `cap sync` writes into PRODUCT_BUNDLE_IDENTIFIER). + // This is distinct from `appId` above, which `getAppId` resolves to the + // CapacitorUpdater plugin override when present (e.g. a Capgo dev-tunnel + // suffix). The iOS onboarding flow uses these for different purposes — + // never collapse them — see the AppProps doc-block in ui/app.tsx. + let iosBundleIdInitial: string | undefined let iosDir = 'ios' let androidDir = 'android' try { const extConfig = await getConfig() appId = getAppId(undefined, extConfig?.config) + iosBundleIdInitial = extConfig?.config?.appId iosDir = getPlatformDirFromCapacitorConfig(extConfig?.config, 'ios') androidDir = getPlatformDirFromCapacitorConfig(extConfig?.config, 'android') } @@ -92,6 +100,11 @@ export async function onboardingBuilderCommand(options: OnboardingBuilderOptions log.error('Could not detect app ID from capacitor.config.ts. Make sure you are in a Capacitor project directory.') process.exit(1) } + // If config.appId is missing (very rare — CapacitorConfig.appId is required + // for `cap sync` to produce a working iOS project), fall back to the + // resolved Capgo lookup key. Mismatch detection will still surface the + // pbxproj/plist values; the user can pick the right one from there. + const iosBundleIdForOnboarding = iosBundleIdInitial || appId const platform = await resolvePlatform(options, iosDir, androidDir) @@ -111,7 +124,13 @@ export async function onboardingBuilderCommand(options: OnboardingBuilderOptions const progress = await loadProgress(appId) const { waitUntilExit } = render( - React.createElement(OnboardingApp, { appId, initialProgress: progress, iosDir, apikey: options.apikey }), + React.createElement(OnboardingApp, { + appId, + iosBundleIdInitial: iosBundleIdForOnboarding, + initialProgress: progress, + iosDir, + apikey: options.apikey, + }), ) await waitUntilExit() } diff --git a/cli/src/build/onboarding/file-picker.ts b/cli/src/build/onboarding/file-picker.ts index c2f88aa1ea..50b93d4b58 100644 --- a/cli/src/build/onboarding/file-picker.ts +++ b/cli/src/build/onboarding/file-picker.ts @@ -80,3 +80,18 @@ export function openKeystorePicker(): Promise { 'POSIX path of (choose file of type {"jks", "keystore", "p12"} with prompt "Select your Android keystore")', ) } + +/** + * Open the macOS native file picker filtered to .mobileprovision files. + * Returns the selected path, or null if the user cancelled. + * + * Used by the no-match-recovery "Use a .mobileprovision file from disk" + * option — covers users who have a profile downloaded somewhere outside + * Xcode's standard provisioning-profile directories (e.g. a downloads + * folder, an artifact from another machine, a shared team archive). + */ +export function openMobileprovisionPicker(): Promise { + return openMacFilePicker( + 'POSIX path of (choose file of type {"mobileprovision"} with prompt "Select your .mobileprovision file")', + ) +} diff --git a/cli/src/build/onboarding/macos-signing.ts b/cli/src/build/onboarding/macos-signing.ts index b40d205c15..1fba53d0dc 100644 --- a/cli/src/build/onboarding/macos-signing.ts +++ b/cli/src/build/onboarding/macos-signing.ts @@ -251,6 +251,27 @@ export function matchIdentitiesToProfiles( })) } +/** + * Filter profiles that are actually usable for a given Capacitor app + iOS + * distribution mode. Used by the import-existing flow to detect dead-end + * situations where an identity has profiles for a different app or the wrong + * distribution mode — in which case the no-match-recovery menu can offer + * "fetch / create via Apple" instead of dropping the user at an empty picker. + * + * `importDistribution` is null/undefined when the user hasn't picked yet — + * in that case any profileType is accepted. + */ +export function filterProfilesForApp( + profiles: readonly DiscoveredProfile[], + appId: string, + importDistribution: 'app_store' | 'ad_hoc' | null | undefined, +): DiscoveredProfile[] { + return profiles.filter(p => + p.bundleId === appId + && (!importDistribution || p.profileType === importDistribution), + ) +} + // ─── P12 export ────────────────────────────────────────────────────── /** diff --git a/cli/src/build/onboarding/types.ts b/cli/src/build/onboarding/types.ts index e214ce3986..0788bfce8b 100644 --- a/cli/src/build/onboarding/types.ts +++ b/cli/src/build/onboarding/types.ts @@ -10,13 +10,19 @@ export type OnboardingStep | 'backing-up' // ── Setup-method fork (macOS only) ── | 'setup-method-select' + // ── Apple-side bundle id confirmation (only shown when capacitor.config and + // project.pbxproj disagree) ── + | 'confirm-app-id' // ── Import-existing sub-flow (macOS only) ── | 'import-scanning' | 'import-distribution-mode' | 'import-pick-identity' | 'import-pick-profile' + | 'import-validating-all-certs' + | 'import-checking-apple-cert' | 'import-no-match-recovery' - | 'import-fetching-profile' + | 'import-portal-explanation' + | 'import-provide-profile-path' | 'import-create-profile-only' | 'import-export-warning' | 'import-compiling-helper' @@ -54,6 +60,47 @@ export interface ApiKeyData { issuerId: string } +/** + * Per-identity result of the eager Apple-side validation run. Populated by + * the `import-validating-all-certs` step useEffect, consumed by the two- + * table picker in `import-pick-identity`. Kept here (alongside the Step + * type) so the renderer and the validation logic share a single shape. + */ +export interface EnrichedIdentityAvailability { + /** True when Apple's API returned a SHA1 match for this identity. */ + available: boolean + /** + * Stable reason code for unavailable identities. Drives the per-reason + * detail rendering in the unavailable table (e.g. notice about the + * Apple-managed signing constraint, or about private-key-missing). + */ + reason?: 'expired' | 'managed' | 'not-visible' | 'check-failed' | 'no-private-key' + /** One-line summary shown in the Reason column of the unavailable table. */ + reasonText?: string + /** When available — Apple-side cert resource id, reused downstream. */ + appleCertId?: string + /** + * Apple-side cert name as returned by /v1/certificates. Useful when + * the local Keychain name differs from the portal name (e.g. multiple + * "iOS Distribution" certs in the same team — the portal column says + * exactly which one). + */ + appleCertName?: string + /** + * ISO timestamp from Apple's expiration field. Shown in the manual- + * portal walkthrough so the user can tell which row to click when + * multiple certs are listed. + */ + appleCertExpirationDate?: string + /** + * Full serial number from Apple. The portal shows it in the cert + * detail view; surfacing the last 8 chars here gives the user a + * concrete disambiguator without leaking the full 40-byte serial + * into the terminal. + */ + appleCertSerialNumber?: string +} + export interface CertificateData { certificateId: string expirationDate: string @@ -98,6 +145,28 @@ export interface OnboardingProgress { * Only meaningful when `setupMethod === 'import-existing'`. */ importDistribution?: 'app_store' | 'ad_hoc' + /** + * When set, the user explicitly confirmed an iOS bundle id different from + * `capacitor.config.appId`. Used for Apple-side operations (cert lookup, + * profile filtering, `ensureBundleId`, `createProfile`) and as the key in + * the provisioning_map. The progress-file key and Capgo SaaS API calls + * still use `appId` so existing build commands continue to find these + * credentials without forcing the user to edit `capacitor.config`. + * + * Persisted so the confirm-app-id step doesn't re-ask on resume — once + * confirmed, the override sticks unless the configuration context (see + * `iosBundleIdContextAppId`) changes between CLI runs. + */ + iosBundleIdOverride?: string + /** + * Snapshot of `config.appId` at the time the user confirmed the + * `iosBundleIdOverride`. On the next run we compare this to the current + * `config.appId`; if it changed (user renamed the app, added/removed a + * dev-tunnel suffix, etc.) the saved override is stale and we re-ask + * via the confirm-app-id step. Without this we'd silently keep using a + * bundle id the user already moved on from. + */ + iosBundleIdContextAppId?: string completedSteps: { apiKeyVerified?: ApiKeyData certificateCreated?: CertificateData @@ -116,12 +185,16 @@ export const STEP_PROGRESS: Record = { 'backing-up': 0, // Import-existing sub-flow (re-ordered: distribution-mode first) 'setup-method-select': 5, + 'confirm-app-id': 12, 'import-scanning': 10, 'import-distribution-mode': 15, 'import-pick-identity': 40, 'import-pick-profile': 55, + 'import-validating-all-certs': 38, + 'import-checking-apple-cert': 50, 'import-no-match-recovery': 55, - 'import-fetching-profile': 60, + 'import-portal-explanation': 56, + 'import-provide-profile-path': 58, 'import-create-profile-only': 60, 'import-export-warning': 70, 'import-compiling-helper': 72, @@ -165,18 +238,26 @@ export function getPhaseLabel(step: OnboardingStep): string { return '' case 'setup-method-select': return 'Setup method' + case 'confirm-app-id': + return 'Confirm iOS bundle ID' case 'import-scanning': return 'Step 1 of 4 · Scanning your Mac' case 'import-distribution-mode': return 'Step 1 of 4 · Distribution mode' case 'import-pick-identity': return 'Step 2 of 4 · Choose certificate' + case 'import-validating-all-certs': + return 'Step 2 of 4 · Validating certificates with Apple' case 'import-pick-profile': return 'Step 3 of 4 · Choose provisioning profile' + case 'import-checking-apple-cert': + return 'Step 3 of 4 · Checking certificate on Apple' case 'import-no-match-recovery': return 'Step 3 of 4 · No matching profile — recover' - case 'import-fetching-profile': - return 'Step 3 of 4 · Fetching profile from Apple' + case 'import-portal-explanation': + return 'Step 3 of 4 · Manual portal walkthrough' + case 'import-provide-profile-path': + return 'Step 3 of 4 · Provide .mobileprovision file' case 'import-create-profile-only': return 'Step 3 of 4 · Creating profile via Apple' case 'import-export-warning': diff --git a/cli/src/build/onboarding/ui/app.tsx b/cli/src/build/onboarding/ui/app.tsx index 7334b092e2..72c1903415 100644 --- a/cli/src/build/onboarding/ui/app.tsx +++ b/cli/src/build/onboarding/ui/app.tsx @@ -1,7 +1,7 @@ import type { FC } from 'react' import type { BuildLogger } from '../../request.js' import type { DiscoveredProfile, IdentityProfileMatch, SigningIdentity } from '../macos-signing.js' -import type { ApiKeyData, CertificateData, OnboardingProgress, OnboardingStep, ProfileData } from '../types.js' +import type { ApiKeyData, CertificateData, EnrichedIdentityAvailability, OnboardingProgress, OnboardingStep, ProfileData } from '../types.js' import { handleCustomMsg } from '../../qr.js' import { spawn } from 'node:child_process' import { Buffer } from 'node:buffer' @@ -14,16 +14,18 @@ import { Alert, ProgressBar, Select } from '@inkjs/ui' import { Box, Newline, Text, useApp, useInput, useStdout } from 'ink' import open from 'open' // src/build/onboarding/ui/app.tsx -import React, { useCallback, useEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { writeOnboardingSupportBundle } from '../../../onboarding-support.js' import { formatRunnerCommand, splitRunnerCommand } from '../../../runner-command.js' import { findSavedKeySilent, getPMAndCommand } from '../../../utils.js' import { loadSavedCredentials, updateSavedCredentials } from '../../credentials.js' import { requestBuildInternal } from '../../request.js' -import { CertificateLimitError, createCertificate, createProfile, deleteProfile, DuplicateProfileError, ensureBundleId, findCertIdBySha1, generateJwt, listProfilesForCert, revokeCertificate, verifyApiKey } from '../apple-api.js' +import { CertificateLimitError, classifyCertAvailability, createCertificate, createProfile, deleteProfile, DuplicateProfileError, ensureBundleId, findCertBySha1, findCertIdBySha1, generateJwt, listProfilesForCert, revokeCertificate, verifyApiKey } from '../apple-api.js' +import { detectIosBundleIds } from '../bundle-id-detector.js' import { createP12, DEFAULT_P12_PASSWORD, generateCsr } from '../csr.js' -import { canUseFilePicker, openFilePicker } from '../file-picker.js' -import { exportP12FromKeychain, isHelperCached, isMacOS, listSigningIdentities, matchIdentitiesToProfiles, precompileSwiftHelper, scanProvisioningProfiles } from '../macos-signing.js' +import { canUseFilePicker, openFilePicker, openMobileprovisionPicker } from '../file-picker.js' +import { parseMobileprovisionDetailed } from '../../mobileprovision-parser.js' +import { exportP12FromKeychain, filterProfilesForApp, isHelperCached, isMacOS, listSigningIdentities, matchIdentitiesToProfiles, precompileSwiftHelper, scanProvisioningProfiles } from '../macos-signing.js' import { deleteProgress, getImportEntryStep, getResumeStep, loadProgress, saveProgress } from '../progress.js' import { getBuildOnboardingRecoveryAdvice } from '../recovery.js' import { createCiSecretEntries, detectCiSecretTargets, getCiSecretTargetLabel, listExistingCiSecretKeys, uploadCiSecrets } from '../ci-secrets.js' @@ -33,7 +35,7 @@ import { STEP_PROGRESS, } from '../types.js' -import { Divider, ErrorLine, FilteredTextInput, Header, SpinnerLine, SuccessLine } from './components.js' +import { Divider, ErrorLine, FilteredTextInput, Header, SpinnerLine, SuccessLine, Table } from './components.js' const OUTPUT_LINE_SPLIT_RE = /\r?\n/ const CARRIAGE_RETURN_RE = /\r/g @@ -41,7 +43,27 @@ const CARRIAGE_RETURN_RE = /\r/g interface LogEntry { text: string, color?: string } interface AppProps { + /** + * Capgo lookup key (used for progress files, saved credentials, and the + * Capgo SaaS build API). This is what `getAppId()` returns — which prefers + * `config.plugins.CapacitorUpdater.appId` over `config.appId` so dev-tunnel + * sandboxes can override the Capgo-side identifier without renaming the + * iOS bundle. + * + * Do NOT use this for Apple-side operations — see `iosBundleIdInitial`. + */ appId: string + /** + * Default value for the iOS bundle ID used for Apple-side operations + * (cert lookup, profile filtering, ensureBundleId, createProfile, and the + * provisioning_map key). Sourced from `config.appId` directly because + * `cap sync` writes that into project.pbxproj's + * PRODUCT_BUNDLE_IDENTIFIER — not the plugin override. + * + * When `config.appId` is missing, command.ts falls back to `appId` so + * the prop is always a valid string. + */ + iosBundleIdInitial: string initialProgress: OnboardingProgress | null /** Resolved iOS directory from capacitor.config (defaults to 'ios') */ iosDir: string @@ -86,7 +108,7 @@ async function runRunnerCommand(runner: string, args: string[]): Promise<{ succe }) } -const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) => { +const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgress, iosDir, apikey }) => { const { exit } = useApp() const startStep = getResumeStep(initialProgress) @@ -100,6 +122,12 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) const [existingCerts, setExistingCerts] = useState>([]) const [certToRevoke, setCertToRevoke] = useState(null) const pickerOpenedRef = useRef(false) + /** + * Separate guard for the mobileprovision picker so it doesn't re-open on + * re-render. Reset to false whenever the user navigates away from the + * import-provide-profile-path step (e.g. cancels back to recovery menu). + */ + const mobileprovisionPickerOpenedRef = useRef(false) const exitRequestedRef = useRef(false) // overwriteConfirmedRef removed — credential check happens at start now @@ -185,7 +213,7 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) * .p8 → verifying-key chain completes. `null` means there's no pending action * (i.e. .p8 was the entry point for app_store, not a recovery side-trip). */ - const [pendingRecoveryAction, setPendingRecoveryAction] = useState<'fetching-profile' | 'create-profile-only' | null>(null) + const [pendingRecoveryAction, setPendingRecoveryAction] = useState<'create-profile-only' | null>(null) /** * Records which step triggered the shared `duplicate-profile-prompt` so the * `deleting-duplicate-profiles` handler routes the retry correctly. Without @@ -194,9 +222,126 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) * succeed in import mode because `certData.certificateId` is never set. */ const [duplicateProfileOrigin, setDuplicateProfileOrigin] = useState<'creating-profile' | 'import-create-profile-only'>('creating-profile') + /** + * Result of the proactive Apple-side cert lookup for the user's chosen + * identity, used to curate the no-match-recovery menu so we only offer + * actions that can actually succeed: + * - `undefined` → not checked yet (e.g. ad_hoc without ASC API key — + * menu falls back to the legacy "Provide ASC API key, then …" options + * which route through `api-key-instructions` first) + * - `null` → checked, Apple's API returned no match. Hide the + * "Fetch profile / Create profile via Apple" options (they can't + * work) and surface "Switch to Create new" as the escape hatch. + * - `string` → checked, Apple has the cert. Show the API-driven + * options; the cached id is reused so the handlers don't re-query. + * Reset to `undefined` whenever the chosen identity changes. + */ + const [appleCertIdForChosen, setAppleCertIdForChosen] = useState(undefined) + /** + * Per-identity Apple-side availability — keyed by Keychain SHA1. Populated + * by the `import-validating-all-certs` step (when we have a verified API + * key) and consumed by the two-table picker to classify each identity as + * Available vs Unavailable with a stable reason string. Empty map = no + * eager check performed (e.g. ad_hoc users who haven't provided a .p8); + * the picker falls back to its single-list layout in that case. + */ + const [identityAvailability, setIdentityAvailability] = useState>({}) + + // ─── iOS bundle id detection + confirmation ─────────────────────────── + // + // The Capgo lookup key (`appId` prop, resolved by getAppId()) prefers + // config.plugins.CapacitorUpdater.appId — which is correct for tracking + // a dev-tunnel sandbox inside Capgo SaaS but is the wrong value for iOS + // signing. `cap sync` only ever writes `config.appId` (the top-level + // field) into project.pbxproj's PRODUCT_BUNDLE_IDENTIFIER, so that's the + // value Apple Dev Portal will know about. + // + // `iosBundleIdInitial` is wired in from command.ts as `config.appId` + // (falling back to the resolved `appId` only when config.appId is + // missing). We use it as the default for everything Apple-side; the + // resolved `appId` keeps owning the progress file key, the credentials + // store key, and the `capgo build request` command shown to the user. + // + // Detection is synchronous (small files, no network), so a single useMemo + // captures the result for the lifetime of the component. The + // confirm-app-id step renders only when `detectedIds.mismatch === true` + // AND the user hasn't already chosen this session (tracked via + // `appIdConfirmed`, persisted in progress as `iosBundleIdOverride`). + const detectedIds = useMemo( + () => detectIosBundleIds({ cwd: process.cwd(), iosDir, capacitorAppId: iosBundleIdInitial }), + [iosDir, iosBundleIdInitial], + ) + // Trust the saved override only when it was confirmed for the SAME + // `config.appId` we're seeing this run. If the user renamed the app + // (added/removed a dev-tunnel suffix, changed reverse-DNS, etc.) the + // previously-saved override is stale relative to the new files — fall + // back to `iosBundleIdInitial` so the mismatch detector can re-ask via + // the confirm-app-id step instead of silently using the old value. + const savedOverrideIsFresh = initialProgress?.iosBundleIdOverride !== undefined + && initialProgress.iosBundleIdContextAppId === iosBundleIdInitial + const [iosBundleId, setIosBundleId] = useState( + savedOverrideIsFresh && initialProgress?.iosBundleIdOverride + ? initialProgress.iosBundleIdOverride + : iosBundleIdInitial, + ) + // Distinct from `iosBundleId !== iosBundleIdInitial` because the user is + // allowed to pick the capacitor value at the confirm step — we still want + // to suppress the question for the rest of the session in that case. + // Stale overrides (context drift between runs) don't count as confirmed + // for this session — the next redirect re-asks. + const [appIdConfirmed, setAppIdConfirmed] = useState(savedOverrideIsFresh) + // The step we would have routed to had there been no mismatch. The + // confirm-app-id onChange handler picks this up and continues there. + // `null` = no confirmation pending. + const [pendingAppIdNext, setPendingAppIdNext] = useState(null) + // The shared sites that fan out into Apple-side work (end of + // import-scanning, end of verifying-key) wrap their setStep call with + // this so the confirmation question gets injected at the right moment + // without duplicating the "is there a mismatch?" logic per call site. + const redirectIfMismatch = (target: OnboardingStep): OnboardingStep => { + if (appIdConfirmed) + return target + if (!detectedIds.mismatch) + return target + setPendingAppIdNext(target) + return 'confirm-app-id' + } + // Sub-mode for the confirm-app-id step. `false` = render the suggestion + // Select; `true` = render a FilteredTextInput so the user can type a + // custom value (e.g. when neither pbxproj nor capacitor matches what + // they want to sign with). Reset when leaving the step so a future re- + // visit (shouldn't happen, but) starts fresh. + const [confirmAppIdTyping, setConfirmAppIdTyping] = useState(false) const addLog = useCallback((text: string, color = 'green') => { - setLog(prev => [...prev, { text, color }]) + // Dedupe consecutive identical entries. The import-distribution-mode + // Select's async onChange can fire more than once on rapid Enter presses + // (and on resume + repick), producing repeated "✔ Distribution · …" lines. + // Guarding here covers every caller without changing call sites. + setLog((prev) => { + const last = prev.at(-1) + if (last && last.text === text && last.color === color) + return prev + return [...prev, { text, color }] + }) + }, []) + + /** + * Append OR replace a log entry identified by a stable prefix. Used for + * field-update events that the user can re-enter mid-session (Key file, + * Key ID, Issuer ID, Distribution, Identity, Profile). When the user + * edits a value, the previous "✔ · OLD" line is rewritten to + * "✔ · NEW" in place instead of stacking — otherwise the log + * grows misleading audit-trail entries every time the user edits a typo. + * Pure addLog still applies for one-shot events (verified, created, etc). + */ + const upsertLog = useCallback((prefix: string, text: string, color = 'green') => { + setLog((prev) => { + const idx = prev.findIndex(entry => entry.text.startsWith(prefix)) + if (idx < 0) + return [...prev, { text, color }] + return prev.map((entry, i) => i === idx ? { text, color } : entry) + }) }, []) const pm = getPMAndCommand() @@ -292,13 +437,13 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) return // Show partial input steps if (initialProgress.p8Path) { - addLog(`✔ Key file selected · ${initialProgress.p8Path}`) + upsertLog('✔ Key file', `✔ Key file selected · ${initialProgress.p8Path}`) } if (initialProgress.keyId && !initialProgress.completedSteps.apiKeyVerified) { - addLog(`✔ Key ID · ${initialProgress.keyId}`) + upsertLog('✔ Key ID · ', `✔ Key ID · ${initialProgress.keyId}`) } if (initialProgress.issuerId && !initialProgress.completedSteps.apiKeyVerified) { - addLog(`✔ Issuer ID · ${initialProgress.issuerId}`) + upsertLog('✔ Issuer ID · ', `✔ Issuer ID · ${initialProgress.issuerId}`) } // Show fully completed steps const { completedSteps } = initialProgress @@ -373,8 +518,12 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) } // Use the bundle ID from the imported profile when available; falls back - // to the Capacitor app ID for the create-new path. - const provisioningBundleId = importMode && chosenProfile?.bundleId ? chosenProfile.bundleId : appId + // to the user-confirmed iOS bundle id (or capacitor.config.appId when no + // override) for the create-new path. Whichever we end up writing here + // becomes the provisioning_map key, which the iOS build system looks up + // by PRODUCT_BUNDLE_IDENTIFIER at sign time — so capacitor.config.appId + // would be wrong for any project where the two diverge. + const provisioningBundleId = importMode && chosenProfile?.bundleId ? chosenProfile.bundleId : iosBundleId const provisioningMap: Record = { [provisioningBundleId]: { profile: profileData!.profileBase64, @@ -419,7 +568,9 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) // checks that platform-select used to gatekeep: // 1. If ios/ doesn't exist → no-platform recovery flow // 2. If iOS credentials already exist → credentials-exist confirmation - // 3. Otherwise → api-key-instructions + // 3. macOS, fresh run → setup-method-select (Import vs Create new fork) + // 4. Non-macOS, fresh run → api-key-instructions (import needs Keychain + // access, which Linux/Windows hosts don't have) setTimeout(() => { if (cancelled) return @@ -433,6 +584,8 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) return if (existing?.ios) setStep('credentials-exist') + else if (isMacOS()) + setStep('setup-method-select') else setStep('api-key-instructions') })() @@ -540,7 +693,13 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) // already saved in progress, jump past distribution-mode and the // .p8 input chain. See progress.ts → getImportEntryStep for the // full decision table and tests. - setStep(getImportEntryStep(await loadProgress(appId))) + // + // redirectIfMismatch then short-circuits to confirm-app-id when + // capacitor.config.appId and project.pbxproj's + // PRODUCT_BUNDLE_IDENTIFIER disagree — surfaced after p8 setup + // (i.e. by the time we reach this code path for app_store) so + // the user has context before Apple-side filtering kicks in. + setStep(redirectIfMismatch(getImportEntryStep(await loadProgress(appId)))) } catch (err) { if (!cancelled) @@ -611,32 +770,151 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) })() } - if (step === 'import-fetching-profile') { + // Defense-in-depth: if we land on `import-pick-profile` with no profile + // usable for THIS app + distribution mode, route to no-match-recovery + // instead of rendering an empty picker with only "Back". This covers + // every entry point uniformly (identity pick, Apple fetch, resume, back + // navigation from later steps). + if (step === 'import-pick-profile' && chosenIdentity) { + const profilesForIdentity = importMatches.find(m => m.identity.sha1 === chosenIdentity.sha1)?.profiles ?? [] + const usable = filterProfilesForApp(profilesForIdentity, iosBundleId, importDistribution) + if (usable.length === 0) { + // Same gating as the identity-pick onChange — pre-check Apple when + // we have an API key so the recovery menu only offers viable + // options. Skip the pre-check on resume if we already have a result + // for this identity (avoid a redundant round-trip). + if (appleCertIdForChosen !== undefined) { + setStep('import-no-match-recovery') + } + else { + ;(async () => { + const apiKeyAvailable = !!(p8ContentRef.current || (await loadProgress(appId))?.completedSteps?.apiKeyVerified) + setStep(apiKeyAvailable ? 'import-checking-apple-cert' : 'import-no-match-recovery') + })() + } + } + } + + // Eager batch validation: classify every scanned distribution identity + // against Apple's API up-front, so the picker can show two tables + // (Available + Unavailable) with concrete reasons instead of + // user-clicks-an-option → lookup-fails-late. + if (step === 'import-validating-all-certs' && importMatches.length > 0) { ;(async () => { try { - if (!chosenIdentity) - throw new Error('Internal error: no identity chosen for profile fetch.') const token = await getFreshToken() - const certId = await findCertIdBySha1(token, chosenIdentity.sha1) + // Run lookups in parallel — N typically 1-3 for most teams, max + // ~10 even for prolific accounts. Allow each to fail independently + // (a network blip on one lookup shouldn't disqualify all certs). + // + // Uses findCertBySha1 (not findCertIdBySha1) so we capture the + // full Apple-side record — name, expirationDate, serialNumber — + // and cache it in identityAvailability. The manual-portal + // walkthrough surfaces those as disambiguators when multiple + // distribution certs are listed for the same team. + const results = await Promise.all(importMatches.map(async (m) => { + try { + const cert = await findCertBySha1(token, m.identity.sha1) + return { sha1: m.identity.sha1, cert, error: null as unknown } + } + catch (err) { + return { sha1: m.identity.sha1, cert: null, error: err } + } + })) if (cancelled) return - if (!certId) { - throw new Error( - `Apple does not have a certificate matching the Keychain identity "${chosenIdentity.name}". ` - + `Either it was revoked on Apple's side or it was never uploaded. Use "Create new" instead.`, - ) + const map: Record = {} + let availableCount = 0 + for (const r of results) { + const classified = classifyCertAvailability({ + appleCertId: r.cert ? r.cert.id : null, + lookupError: r.error, + }) + // Build an EnrichedIdentityAvailability by widening the + // classifier output with the Apple-side cert metadata when + // we have it. Kept separate from CertAvailability so the + // pure classifier stays decoupled from rendering concerns. + const entry: EnrichedIdentityAvailability = { + available: classified.available, + reason: classified.reason, + reasonText: classified.reasonText, + appleCertId: classified.appleCertId, + ...(r.cert && classified.available + ? { + appleCertName: r.cert.name, + appleCertExpirationDate: r.cert.expirationDate, + appleCertSerialNumber: r.cert.serialNumber, + } + : {}), + } + map[r.sha1] = entry + if (entry.available) + availableCount++ } + setIdentityAvailability(map) + addLog(`✔ Apple validation complete — ${availableCount} available, ${results.length - availableCount} unavailable`) + setStep('import-pick-identity') + } + catch (err) { + if (!cancelled) + handleError(err, 'import-validating-all-certs') + } + })() + } + + if (step === 'import-checking-apple-cert' && chosenIdentity) { + ;(async () => { + try { + // Trust the cached appleCertId from the eager batch validation + // (`import-validating-all-certs`) when present — that step + // already proved Apple has this cert via the same + // findCertIdBySha1 call. A redundant lookup here just burns an + // API round-trip and exposes us to transient-blip false + // negatives that flip a known-good cert into "Scenario B". + // Falls back to the live lookup when no cache exists (e.g. + // resume from progress before batch validation ran). + const token = await getFreshToken() + const cached = identityAvailability[chosenIdentity.sha1] + let certId: string | null + if (cached?.available && cached.appleCertId) { + certId = cached.appleCertId + } + else { + certId = await findCertIdBySha1(token, chosenIdentity.sha1) + if (cancelled) + return + setAppleCertIdForChosen(certId) + if (!certId) { + // Should be unreachable from the table picker (only + // available certs are selectable), but kept as a defensive + // route — log + bounce to recovery rather than crashing. + addLog( + `⚠ Apple lookup returned no match for "${chosenIdentity.name}". ` + + `Open Developer Portal or use "Switch to Create new" from the picker.`, + 'yellow', + ) + setStep('import-no-match-recovery') + return + } + } + setAppleCertIdForChosen(certId) + addLog(`✔ Apple recognizes this certificate (ASC id ${certId.slice(0, 8)}…)`) + + // Auto-fetch profiles for this cert. Sends the user straight to + // import-pick-profile when Apple has a matching profile waiting, + // skipping the recovery menu entirely in the happy path. const profiles = await listProfilesForCert(token, certId) if (cancelled) return + if (profiles.length === 0) { - addLog('⚠ Apple has the cert but no profiles linked to it — try "Create new profile" instead.', 'yellow') + addLog('ℹ️ Apple has the cert but no profiles are linked to it yet — use "Create a new App Store profile" to add one.', 'yellow') setStep('import-no-match-recovery') return } - // Synthesize DiscoveredProfile entries from the Apple-side data so - // they can be picked through the normal `import-pick-profile` UI. - // path is left empty; the export step reads profileBase64 directly. + + // Synthesize so `filterProfilesForApp` and the picker can use + // them the same way as on-disk profiles. const synthesized: DiscoveredProfile[] = profiles.map(p => ({ path: '', uuid: p.id, @@ -647,20 +925,141 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) expirationDate: p.expirationDate, profileType: (p.profileType === 'IOS_APP_STORE' ? 'app_store' : p.profileType === 'IOS_APP_ADHOC' ? 'ad_hoc' : 'unknown') as DiscoveredProfile['profileType'], certificateSha1s: [chosenIdentity.sha1], - // Embed the base64 so the export step can use it without reading from disk profileBase64: p.profileContent, } as DiscoveredProfile & { profileBase64: string })) - // Inject into the match list so import-pick-profile shows them setImportMatches(prev => prev.map(m => m.identity.sha1 === chosenIdentity.sha1 ? { ...m, profiles: [...m.profiles, ...synthesized] } : m, )) - addLog(`✔ Apple returned ${profiles.length} profile${profiles.length === 1 ? '' : 's'} for this cert`) - setStep('import-pick-profile') + + const usableHere = filterProfilesForApp(synthesized, iosBundleId, importDistribution) + if (usableHere.length > 0) { + addLog(`✔ Apple has ${usableHere.length} matching profile${usableHere.length === 1 ? '' : 's'} for "${iosBundleId}" — opening the picker`) + setStep('import-pick-profile') + return + } + + // Apple returned profiles but none target this app. Surface what + // WAS returned so the user understands why we're still on the + // recovery screen. + const otherBundleIds = Array.from(new Set(synthesized.map(p => p.bundleId).filter(b => b && b !== iosBundleId))) + const otherDistribTypes = Array.from(new Set(synthesized.filter(p => p.bundleId === iosBundleId && p.profileType !== importDistribution).map(p => p.profileType))) + if (otherBundleIds.length > 0) { + addLog( + `⚠ Apple returned ${profiles.length} profile${profiles.length === 1 ? '' : 's'} for this cert but none target "${iosBundleId}". ` + + `Bundle ID${otherBundleIds.length === 1 ? '' : 's'} found: ${otherBundleIds.join(', ')}. ` + + `Use "Create a new App Store profile for this cert" to add one for "${iosBundleId}".`, + 'yellow', + ) + } + else if (otherDistribTypes.length > 0) { + addLog( + `⚠ Apple has ${profiles.length} profile${profiles.length === 1 ? '' : 's'} for "${iosBundleId}" but with distribution type ${otherDistribTypes.join(', ')} (need ${importDistribution}). ` + + `Use "Create a new App Store profile for this cert" to add the right one.`, + 'yellow', + ) + } + setStep('import-no-match-recovery') } catch (err) { if (!cancelled) - handleError(err, 'import-fetching-profile') + handleError(err, 'import-checking-apple-cert') + } + })() + } + + if (step === 'import-provide-profile-path' && !mobileprovisionPickerOpenedRef.current && chosenIdentity) { + mobileprovisionPickerOpenedRef.current = true + ;(async () => { + const handleSelectedPath = async (filePath: string) => { + // Parse the .mobileprovision (CMS-signed plist) and validate + // every constraint that would otherwise produce a broken build + // far downstream: bundleId must match THIS app, profileType + // must match THIS distribution, and one of the linked cert + // SHA1s must match the user's chosen Keychain identity. + let detail + try { + detail = parseMobileprovisionDetailed(filePath) + } + catch (err) { + handleError( + new Error(`Couldn't parse "${filePath}": ${err instanceof Error ? err.message : String(err)}`), + 'import-provide-profile-path', + ) + return + } + if (detail.bundleId !== iosBundleId) { + handleError( + new Error( + `This .mobileprovision is for bundle ID "${detail.bundleId}" but the current app is "${iosBundleId}". ` + + `Pick a profile that targets the right app, or use "Create a new App Store profile" in the recovery menu.`, + ), + 'import-provide-profile-path', + ) + return + } + if (importDistribution && detail.profileType !== importDistribution) { + handleError( + new Error( + `This .mobileprovision has distribution type "${detail.profileType}" but you picked "${importDistribution}". ` + + `Pick a profile of the correct type, or go back and change the distribution mode.`, + ), + 'import-provide-profile-path', + ) + return + } + if (!detail.certificateSha1s.includes(chosenIdentity.sha1)) { + handleError( + new Error( + `This .mobileprovision doesn't include "${chosenIdentity.name}" in its allowed certificate list. ` + + `The profile lists ${detail.certificateSha1s.length} cert SHA1${detail.certificateSha1s.length === 1 ? '' : 's'}, none matching this Keychain identity. ` + + `Either pick a profile that trusts this cert, or go back to identity selection and pick the one this profile expects.`, + ), + 'import-provide-profile-path', + ) + return + } + // All checks pass — build a DiscoveredProfile and route to the + // existing export flow. We keep the file path so the + // import-exporting step reads the bytes (no need to embed + // profileBase64 here). + const synth: DiscoveredProfile = { + path: filePath, + uuid: detail.uuid, + name: detail.name, + applicationIdentifier: detail.applicationIdentifier, + bundleId: detail.bundleId, + teamId: detail.teamId, + expirationDate: detail.expirationDate, + profileType: detail.profileType, + certificateSha1s: detail.certificateSha1s, + } + setChosenProfile(synth) + upsertLog('✔ Profile · ', `✔ Profile · ${detail.name} (from ${filePath.split('/').pop()})`) + setStep('import-export-warning') + } + + // On macOS use the native picker; everywhere else (and as a + // fallback when the picker fails) the user types the path in + // the dedicated render below — handled outside this handler. + if (canUseFilePicker()) { + try { + const picked = await openMobileprovisionPicker() + if (cancelled) + return + if (picked) { + await handleSelectedPath(picked) + return + } + // User cancelled the native dialog — bounce back to recovery + // menu rather than leaving them on a spinner-less screen. + mobileprovisionPickerOpenedRef.current = false + setStep('import-no-match-recovery') + } + catch (err) { + if (!cancelled) + handleError(err, 'import-provide-profile-path') + } } })() } @@ -679,7 +1078,7 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) if (importDistribution === 'ad_hoc') { throw new Error( 'Creating a new profile via Apple is not implemented for ad_hoc distribution yet. ' - + 'Use "Fetch matching profile from Apple" or "Open Apple Developer Portal" instead.', + + 'Use "Use a .mobileprovision file from disk" or "Open Apple Developer Portal" instead.', ) } const token = await getFreshToken() @@ -687,15 +1086,24 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) if (cancelled) return if (!certId) { - throw new Error( - `Apple does not have a certificate matching "${chosenIdentity.name}". ` - + `Cannot create a profile without an Apple-side cert ID. Use "Create new" path instead.`, + // Route back to the recovery menu instead of dead-ending at the + // support bundle. The listDistributionCerts filter occasionally + // misses a cert legitimately on Apple (eager batch already + // succeeded — see the note in apple-api.ts) so the user can + // recover via "Open Apple Developer Portal" or a different + // identity rather than restarting. + addLog( + `⚠ Apple did not return a cert match for "${chosenIdentity.name}". ` + + `Can't create a profile without an Apple-side cert ID. Returning to recovery menu — try "Open Apple Developer Portal" or pick a different identity.`, + 'yellow', ) + setStep('import-no-match-recovery') + return } - const { bundleIdResourceId } = await ensureBundleId(token, appId) + const { bundleIdResourceId } = await ensureBundleId(token, iosBundleId) if (cancelled) return - const profile = await createProfile(token, bundleIdResourceId, certId, appId) + const profile = await createProfile(token, bundleIdResourceId, certId, iosBundleId) if (cancelled) return // Use the freshly-created profile directly as the chosen profile. @@ -704,7 +1112,7 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) uuid: profile.profileId, name: profile.profileName, applicationIdentifier: '', - bundleId: appId, + bundleId: iosBundleId, teamId: chosenIdentity.teamId, expirationDate: profile.expirationDate, profileType: 'app_store' as const, @@ -760,7 +1168,7 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) const extracted = extractKeyIdFromPath(selected) if (extracted) setKeyId(extracted) - addLog(`✔ Key file selected · ${selected}`) + upsertLog('✔ Key file', `✔ Key file selected · ${selected}`) void savePartialProgress({ p8Path: selected }) setStep('input-key-id') } @@ -822,7 +1230,18 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) setStep(`import-${action}` as OnboardingStep) } else if (importMode) { - setStep('import-pick-identity') + // Eager Apple-side validation BEFORE the picker renders: fan out + // findCertIdBySha1 across every scanned identity in parallel so + // the picker can split them into Available / Unavailable tables + // and surface specific reasons for the unavailable rows. + // Bypass when there's nothing to check (defensive — scanning + // already routed away on zero identities). + // + // redirectIfMismatch wrapping covers the second entry point + // into Apple-side work: app_store users finish verifying-key + // here, and we want to confirm the bundle id BEFORE the eager + // batch fans out lookups using the (possibly wrong) appId. + setStep(redirectIfMismatch(importMatches.length > 0 ? 'import-validating-all-certs' : 'import-pick-identity')) } else { setStep('creating-certificate') @@ -910,8 +1329,8 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) ;(async () => { try { const token = await getFreshToken() - const { bundleIdResourceId } = await ensureBundleId(token, appId) - const profile = await createProfile(token, bundleIdResourceId, certData!.certificateId, appId) + const { bundleIdResourceId } = await ensureBundleId(token, iosBundleId) + const profile = await createProfile(token, bundleIdResourceId, certData!.certificateId, iosBundleId) if (cancelled) return const profileResult: ProfileData = { @@ -1368,6 +1787,131 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) )} + {/* Confirm iOS bundle id when capacitor.config and project.pbxproj + disagree. Routed in only by redirectIfMismatch — never shown on + fresh runs where everything lines up. */} + {step === 'confirm-app-id' && (() => { + const onChoose = async (chosen: string) => { + setIosBundleId(chosen) + setAppIdConfirmed(true) + setConfirmAppIdTyping(false) + // Persist immediately so resume / restart picks the override + // without re-prompting. Merge with whatever progress already + // exists (setupMethod, importDistribution, etc.) — never reset. + const existing = await loadProgress(appId) || { + platform: 'ios' as const, + appId, + startedAt: new Date().toISOString(), + completedSteps: {}, + } + existing.iosBundleIdOverride = chosen + // Snapshot the current config.appId so the next CLI run can + // detect "the user changed the app id, our saved override is + // stale" and re-ask instead of silently using `chosen`. See + // the savedOverrideIsFresh check at component init. + existing.iosBundleIdContextAppId = iosBundleIdInitial + await saveProgress(appId, existing) + if (chosen !== iosBundleIdInitial) { + addLog(`✔ Using "${chosen}" as the iOS bundle ID for Apple operations (capacitor.config.appId is "${iosBundleIdInitial}")`) + } + else { + addLog(`✔ Confirmed "${chosen}" as the iOS bundle ID`) + } + // Resume the journey at whichever step requested the redirect. + // Fallback to import-pick-identity for defensive completeness — + // every site that calls redirectIfMismatch sets the destination + // first, but a future code path might forget. + setStep(pendingAppIdNext ?? 'import-pick-identity') + setPendingAppIdNext(null) + } + + if (confirmAppIdTyping) { + return ( + + + Type the iOS bundle ID to use for Apple operations. + + + + {`Press Enter when done. This is what we'll send to Apple's API for cert and profile lookups — it must match `} + PRODUCT_BUNDLE_IDENTIFIER + {' in your Xcode project (and the App ID on developer.apple.com).'} + + + { + const trimmed = value.trim() + if (!trimmed) + return + void onChoose(trimmed) + }} + /> + + ) + } + + return ( + + + {`The iOS bundle ID in your Xcode project doesn't match capacitor.config.`} + + + + {`We use the iOS bundle ID for Apple Developer Portal operations (looking up certs, fetching/creating provisioning profiles). Picking the wrong one is the cause of "Apple returned X profiles but none target …" errors. capacitor.config.appId stays untouched — this only affects what we send to Apple.`} + + + + {detectedIds.pbxproj && ( + + • Xcode ( + {detectedIds.pbxproj.label} + ): + {' '} + {detectedIds.pbxproj.value} + + )} + {detectedIds.plist && ( + + • Info.plist (CFBundleIdentifier): + {' '} + {detectedIds.plist.value} + + )} + + • Capacitor (capacitor.config.appId): + {' '} + {detectedIds.capacitor.value} + + + + Which value should we use for Apple-side operations? + + { - const matchCount = m.profiles.length - const label = matchCount > 0 - ? `🔑 ${m.identity.name} · ${matchCount} matching profile${matchCount === 1 ? '' : 's'}` - : `🔑 ${m.identity.name} · ⚠ no matching profiles on this Mac (recovery available)` - return { label, value: m.identity.sha1 } - }), - { label: '↩️ Cancel and use Create new instead', value: '__cancel__' }, - ]} - onChange={async (value) => { - if (value === '__cancel__') { - setImportMode(false) - // Persist the switch so a CLI restart doesn't resume into - // the import flow the user just abandoned. Mirrors the same - // pattern in import-distribution-mode's cancel path. - const existing = await loadProgress(appId) - if (existing) { - existing.setupMethod = 'create-new' - delete existing.importDistribution - await saveProgress(appId, existing) + {step === 'import-pick-identity' && (() => { + // Partition scanned identities by Apple-side availability. When the + // batch validation didn't run (ad_hoc with no .p8), every identity + // lands in the "unclassified" bucket — rendered identically to + // Available since we have no evidence to mark them unusable. + const haveClassification = Object.keys(identityAvailability).length > 0 + const available: IdentityProfileMatch[] = [] + const unavailable: IdentityProfileMatch[] = [] + for (const m of importMatches) { + const a = identityAvailability[m.identity.sha1] + if (!haveClassification || a?.available) + available.push(m) + else + unavailable.push(m) + } + + // Helper: build the row data shape ink-table consumes. Includes a + // "#" column so users can correlate the visual table row with the + // labelled choice in the Select below. The order of `availableRows` + // is identical to the order of `availableOptions` so [n] in the + // table maps to the (n-1)th option in the picker. + // + // "Profile" column: a binary readiness signal — does this identity + // already have at least one on-disk profile matching this app's + // bundle id + distribution mode? Previously this column showed an + // X/Y ratio with a checkmark, which forced users to mentally decode + // "1/1 ✓" → "yes that means usable". Replaced with green AVAILABLE + // / red UNAVAILABLE so the at-a-glance signal is unambiguous; the + // user can still recover from UNAVAILABLE via the no-match recovery + // menu (file picker, Apple create, etc.), so picking such an + // identity isn't fatal — just an extra step. + const availableRows = available.map((m, i) => { + const matchCount = filterProfilesForApp(m.profiles, iosBundleId, importDistribution).length + return { + '#': `${i + 1}`, + 'Name': `🔑 ${m.identity.name}`, + 'Team': m.identity.teamId, + 'Profile': matchCount > 0 ? 'AVAILABLE' : 'UNAVAILABLE', + } + }) + + const unavailableRows = unavailable.map(m => ({ + 'Name': `🔒 ${m.identity.name}`, + 'Team': m.identity.teamId, + 'Reason': identityAvailability[m.identity.sha1]?.reasonText || 'Not classified', + })) + + return ( + + {available.length > 0 && ( + <> + {`✅ AVAILABLE (${available.length})`} + + { + if (col !== 'Profile') + return undefined + if (val === 'AVAILABLE') + return 'green' + if (val === 'UNAVAILABLE') + return 'red' + return undefined + }} + /> + + + )} + + {available.length === 0 && ( + + {`✖ NO AVAILABLE CERTIFICATES`} + + All + {' '} + {unavailable.length} + {' '} + identit + {unavailable.length === 1 ? 'y is' : 'ies are'} + {' '} + unavailable on Apple's side. See the table below for the reason, or use "Create new" to generate a fresh cert + profile. + + + + )} + + Pick an option: +
(col === 'Reason' ? 'yellow' : undefined)} + cellDim={(col) => col !== 'Reason'} + /> + + + 💡 Unavailable certificates can't be used to sign builds. Even downloading them from the Apple Developer Portal won't help — the private key was only on the Mac that generated the original CSR. Use "Create new" above to generate a fresh cert + profile that Apple recognizes. + + + )} + + ) + })()} {/* Import: pick profile */} {step === 'import-pick-profile' && chosenIdentity && (() => { @@ -1506,14 +2152,11 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) // Filter to profiles that are actually usable for THIS app + THIS // distribution mode. Without this filter, a user with a cert reused // across multiple apps (or with both app_store and ad_hoc profiles - // linked to one cert) could pick a profile whose bundleId !== appId + // linked to one cert) could pick a profile whose bundleId !== iosBundleId // or whose profileType !== importDistribution. `doSaveCredentials` // would then persist a mismatched provisioning_map / distribution // pair, producing unusable signing credentials. - const matchedProfiles = allMatchedProfiles.filter(p => - p.bundleId === appId - && (!importDistribution || p.profileType === importDistribution), - ) + const matchedProfiles = filterProfilesForApp(allMatchedProfiles, iosBundleId, importDistribution) const droppedCount = allMatchedProfiles.length - matchedProfiles.length return ( @@ -1563,20 +2206,43 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) // committing. The filter above should make this unreachable, // but if the filter regresses, we'd rather hard-fail than // silently save bad creds. - if (profile.bundleId !== appId + if (profile.bundleId !== iosBundleId || (importDistribution && profile.profileType !== importDistribution)) { handleError( new Error( `Profile "${profile.name}" doesn't match this app: ` - + `bundle ${profile.bundleId} (expected ${appId}), ` + + `bundle ${profile.bundleId} (expected ${iosBundleId}), ` + `type ${profile.profileType} (expected ${importDistribution ?? 'any'}).`, ), 'import-pick-profile', ) return } + // Belt-and-suspenders: the upstream matchIdentitiesToProfiles + // filter and Apple-fetched profile synthesizing should both + // guarantee `profile.certificateSha1s` contains + // `chosenIdentity.sha1`. But the file-picker recovery path + // imports a .mobileprovision the user might have hand-created + // in the portal — if they ticked the wrong cert in step 5 of + // the manual walkthrough, we'd otherwise save credentials + // that the build server can't actually sign with (private + // key from chosenIdentity but profile only trusts a different + // cert). Catch that here with a clear error rather than + // discovering it during a build hours later. + if (!profile.certificateSha1s.includes(chosenIdentity.sha1)) { + const shownSha1s = profile.certificateSha1s.map(s => `${s.slice(0, 8)}…`).join(', ') || '(none listed)' + handleError( + new Error( + `Profile "${profile.name}" doesn't trust your chosen certificate "${chosenIdentity.name}". ` + + `The profile's allowed-certs list contains ${profile.certificateSha1s.length} entr${profile.certificateSha1s.length === 1 ? 'y' : 'ies'} (SHA1: ${shownSha1s}); your cert's SHA1 starts with ${chosenIdentity.sha1.slice(0, 8)}…. ` + + `Either pick a different profile, or re-create this profile in the Apple Developer Portal and tick the right cert at step 4.`, + ), + 'import-pick-profile', + ) + return + } setChosenProfile(profile) - addLog(`✔ Profile · ${profile.name}`) + upsertLog('✔ Profile · ', `✔ Profile · ${profile.name}`) setStep('import-export-warning') }} /> @@ -1584,6 +2250,22 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) ) })()} + {/* Import: checking Apple for the chosen cert (curates the recovery menu) */} + {/* Import: eager batch validation against Apple before showing the picker */} + {step === 'import-validating-all-certs' && ( + + + Splitting into Available / Unavailable so we only offer options that can succeed. + + )} + + {step === 'import-checking-apple-cert' && chosenIdentity && ( + + + Looking up the cert + listing its profiles so we either auto-import or only show recovery options that can succeed. + + )} + {/* Import: no-match recovery menu */} {step === 'import-no-match-recovery' && chosenIdentity && (() => { const hasAscKey = !!(p8ContentRef.current || p8PathRef.current) @@ -1595,61 +2277,139 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) // can't end up with an app_store profile saved under // CAPGO_IOS_DISTRIBUTION='ad_hoc'. Browser + Fetch still work. const canCreateProfile = importDistribution !== 'ad_hoc' + // Distinguish "no profiles at all for this identity" (alert wording + // about a missing on-disk file) from "profiles exist but none match + // this app's bundle ID + distribution mode" (alert wording about a + // mismatch). Both share the same recovery options. + const identityProfiles = importMatches.find(m => m.identity.sha1 === chosenIdentity.sha1)?.profiles ?? [] + const hasAnyProfiles = identityProfiles.length > 0 + // Two states the menu cares about: + // - certKnownOnApple → Apple confirmed the cert (eager batch or + // live lookup). Show all API-driven options. + // - checkSkipped → No API key yet (ad_hoc users who didn't + // go through verifying-key). Show options with the "Provide + // ASC API key, then …" prefix so picking them routes through + // api-key-instructions before retrying. + // + // The "Apple lookup returned null" case is **unreachable** from + // the table picker because unavailable rows aren't selectable, + // and the per-identity step now trusts the cached appleCertId + // instead of running a redundant findCertIdBySha1. We removed + // the Scenario B menu branch entirely; if a future code path + // somehow lands here with appleCertIdForChosen === null, it + // will render as if checkSkipped (with "Provide ASC API key" + // wording even though the key is verified) — visually obvious + // and recoverable, vs. a silent dead-end. + const certKnownOnApple = typeof appleCertIdForChosen === 'string' + const checkSkipped = !certKnownOnApple + + const createOption = canCreateProfile && certKnownOnApple + ? [{ label: `✨ Create a new App Store profile for this cert via Apple`, value: 'create' }] + : canCreateProfile + ? [{ label: hasAscKey ? `✨ Create a new App Store profile for this cert via Apple` : `✨ Provide ASC API key, then create a new App Store profile for this cert`, value: 'create' }] + : [] + return ( - No provisioning profile on this Mac is linked to " - {chosenIdentity.name} - ". + {hasAnyProfiles + ? ( + <> + No provisioning profile on this Mac matches this app ( + {iosBundleId} + {importDistribution + ? ( + <> + {' '} + for + {' '} + {importDistribution} + {' '} + distribution + + ) + : null} + ) under " + {chosenIdentity.name} + ". + + ) + : ( + <> + No provisioning profile on this Mac is linked to " + {chosenIdentity.name} + ". + + )} - The cert is in your Keychain but the matching profile isn't on disk. Pick a recovery path: + {hasAnyProfiles + ? `The cert is in your Keychain but no on-disk profile matches this app's bundle ID${importDistribution ? ` and ${importDistribution} distribution` : ''}. Pick a recovery path:` + : `The cert is in your Keychain but the matching profile isn't on disk. Pick a recovery path:`} { + if (value === 'use-create') { + // Reuse the existing recovery menu's create handler by + // jumping straight to its target step. apiKeyVerified is + // guaranteed at this point (the create option only + // surfaced because the eager validation found the cert + // on Apple, which requires a verified key). + setStep('import-create-profile-only') + return + } + if (value === 'use-file') { + mobileprovisionPickerOpenedRef.current = false + setStep('import-provide-profile-path') + return + } + if (value === 'open-anyway') { + open('https://developer.apple.com/account/resources/profiles/list') + addLog('🌐 Opened Apple Developer Portal — once you have downloaded the .mobileprovision file, come back and pick "📁 Use a .mobileprovision file from disk".', 'yellow') + setStep('import-no-match-recovery') + return + } + setStep('import-no-match-recovery') + }} + /> + + ) + })()} + + {/* Import: native picker for a .mobileprovision file on disk */} + {step === 'import-provide-profile-path' && ( - + + If the dialog doesn't appear, check behind other windows or in the menu bar. )} @@ -1829,14 +2789,32 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) options={[ { label: '📂 Open file picker', value: 'picker' }, { label: '📝 Type the path', value: 'manual' }, + // Escape hatch when the user landed here from "Switch to + // Create new" in the import flow but actually wanted to + // try Import again (e.g. they hit cert-limit on resume). + // Only offered when we're not currently in import mode AND + // macOS (since import requires Keychain access). + ...(isMacOS() && !importMode + ? [{ label: '🔄 Switch to Import existing (use a cert from your Keychain instead)', value: 'switch-import' }] + : []), ]} - onChange={(value) => { + onChange={async (value) => { if (value === 'picker') { setStep('p8-method-select') } - else { + else if (value === 'manual') { setStep('input-p8-path') } + else if (value === 'switch-import') { + const existing = await loadProgress(appId) + if (existing) { + existing.setupMethod = 'import-existing' + await saveProgress(appId, existing) + } + setImportMode(true) + addLog('🔄 Switched to Import existing — scanning your Keychain', 'cyan') + setStep('import-scanning') + } }} /> @@ -1856,7 +2834,7 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) const extracted = extractKeyIdFromPath(filePath) if (extracted) setKeyId(extracted) - addLog(`✔ Key file found · ${filePath}`) + upsertLog('✔ Key file', `✔ Key file found · ${filePath}`) void savePartialProgress({ p8Path: filePath }) setStep('input-key-id') } @@ -1894,7 +2872,7 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) const extracted = extractKeyIdFromPath(filePath) if (extracted) setKeyId(extracted) - addLog(`✔ Key file found · ${filePath}`) + upsertLog('✔ Key file', `✔ Key file found · ${filePath}`) void savePartialProgress({ p8Path: filePath }) setStep('input-key-id') } @@ -1927,10 +2905,19 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) s.toUpperCase()} onSubmit={(value) => { const finalKeyId = (value || keyId).trim() setKeyId(finalKeyId) - addLog(`✔ Key ID · ${finalKeyId}`) + upsertLog('✔ Key ID · ', `✔ Key ID · ${finalKeyId}`) void savePartialProgress({ keyId: finalKeyId }) setStep('input-issuer-id') }} @@ -1948,13 +2935,19 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) s.toUpperCase()} onSubmit={(value) => { const cleaned = value.trim() if (!cleaned) return setKeyId(cleaned) - addLog(`✔ Key ID · ${cleaned}`) + upsertLog('✔ Key ID · ', `✔ Key ID · ${cleaned}`) void savePartialProgress({ keyId: cleaned }) setStep('input-issuer-id') }} @@ -1983,12 +2976,22 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) { const cleaned = value.trim() if (!cleaned) return setIssuerId(cleaned) - addLog(`✔ Issuer ID · ${cleaned}`) + upsertLog('✔ Issuer ID · ', `✔ Issuer ID · ${cleaned}`) void savePartialProgress({ issuerId: cleaned }) setStep('verifying-key') }} @@ -2012,12 +3015,13 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) )} - {/* Certificate limit — ask which to revoke */} + {/* Certificate limit — ask which to revoke (or escape to Import) */} {step === 'cert-limit-prompt' && ( - Select a certificate to revoke: + You can revoke one of the existing certs, OR switch back to Import existing + (if a usable cert is already in your Keychain, importing is faster than creating a new one) = ({ appId, initialProgress, iosDir, apikey }) pickerOpenedRef.current = false setStep(retryStep) } + else if (value === 'edit-key-id') { + setError(null) + setStep('input-key-id') + } + else if (value === 'edit-issuer-id') { + setError(null) + setStep('input-issuer-id') + } + else if (value === 'change-key-file') { + setError(null) + // Reset the picker-opened guard so p8-method-select can + // re-open the macOS file picker dialog from a fresh state. + pickerOpenedRef.current = false + setStep('api-key-instructions') + } else if (value === 'restart') { // Wipe persisted progress so the next run starts truly fresh. // Without this, getResumeStep would skip the user back to diff --git a/cli/src/build/onboarding/ui/components.tsx b/cli/src/build/onboarding/ui/components.tsx index a303bc6d76..51d0b7f4c6 100644 --- a/cli/src/build/onboarding/ui/components.tsx +++ b/cli/src/build/onboarding/ui/components.tsx @@ -3,11 +3,129 @@ import { Box, Text, useInput } from 'ink' import Spinner from 'ink-spinner' // src/build/onboarding/ui/components.tsx import React, { useState } from 'react' +import stringWidth from 'string-width' + +/** + * Truncate a string to a maximum *terminal display width* (not codepoint + * count). Emoji like 🔑 render as 2 columns; combining marks render as 0. + * Array.from(s).length is wrong for either. Uses string-width for the + * per-char width and leaves 1 column for the ellipsis. + */ +function truncateByDisplayWidth(s: string, maxWidth: number): string { + if (stringWidth(s) <= maxWidth) + return s + const ellipsisWidth = 1 + let total = 0 + let out = '' + for (const ch of s) { + const w = stringWidth(ch) + if (total + w > maxWidth - ellipsisWidth) + break + total += w + out += ch + } + return `${out}…` +} + +/** Pad a string with trailing spaces until its display width hits `width`. */ +function padByDisplayWidth(s: string, width: number): string { + const current = stringWidth(s) + if (current >= width) + return s + return s + ' '.repeat(width - current) +} export const Divider: FC<{ width?: number }> = ({ width = 60 }) => ( {'─'.repeat(width)} ) +/** + * Minimal in-house Table component. Auto-sizes each column to the widest + * value (header or any row cell) up to `maxColumnWidth`, truncates with + * an ellipsis when a cell exceeds that width, and renders box-drawing + * borders. + * + * Why inline instead of `ink-table`: the published `ink-table@3.1.0` is + * CommonJS and modern `ink` (v5+) is ESM with top-level await, so bundling + * fails. This component is the small subset of ink-table's API we need + * (rows of plain string cells) without the compat headache. + * + * The `data` rows must share a single key order so columns line up — we + * derive the column list from the first row's keys. + * + * `cellColor` runs per-cell and returns an Ink color name (or undefined + * for default). Used by the unavailable-certs table to colour the Reason + * column yellow while keeping Name/Team dim. + */ +export interface TableProps { + data: Record[] + /** Hard cap on column width before truncation. Default 50. */ + maxColumnWidth?: number + /** Optional per-cell color function. */ + cellColor?: (column: string, value: string, rowIndex: number) => string | undefined + /** Optional per-cell dim flag (defaults to false). */ + cellDim?: (column: string, value: string, rowIndex: number) => boolean + /** Padding inside each cell (left/right). Default 1. */ + cellPadding?: number +} + +export const Table: FC = ({ data, maxColumnWidth = 50, cellColor, cellDim, cellPadding = 1 }) => { + if (data.length === 0) + return null + const columns = Object.keys(data[0]) + // Column widths are computed in TERMINAL DISPLAY WIDTH (not codepoint + // count) — so a 🔑 emoji (2 cols wide) doesn't push the rendered row + // past the border. See truncateByDisplayWidth comment for the gotcha. + const widths: Record = {} + for (const col of columns) { + let max = stringWidth(col) + for (const row of data) { + const v = row[col] ?? '' + const w = stringWidth(v) + if (w > max) + max = w + } + widths[col] = Math.min(max, maxColumnWidth) + } + const pad = ' '.repeat(cellPadding) + const borderRow = (left: string, mid: string, right: string, fill: string): string => { + const segments = columns.map(c => fill.repeat(widths[c] + cellPadding * 2)) + return left + segments.join(mid) + right + } + const renderRow = (cells: { col: string, value: string, rowIndex?: number }[]): React.ReactNode => ( + + │ + {cells.map((cell) => { + const truncated = truncateByDisplayWidth(cell.value, widths[cell.col]) + const padded = padByDisplayWidth(truncated, widths[cell.col]) + const colorName = cellColor && cell.rowIndex !== undefined ? cellColor(cell.col, cell.value, cell.rowIndex) : undefined + const dim = cellDim && cell.rowIndex !== undefined ? cellDim(cell.col, cell.value, cell.rowIndex) : false + return ( + + {pad} + {padded} + {pad} + │ + + ) + })} + + ) + return ( + + {borderRow('┌', '┬', '┐', '─')} + {renderRow(columns.map(c => ({ col: c, value: c })))} + {borderRow('├', '┼', '┤', '─')} + {data.map((row, i) => ( + + {renderRow(columns.map(c => ({ col: c, value: row[c] ?? '', rowIndex: i })))} + + ))} + {borderRow('└', '┴', '┘', '─')} + + ) +} + export const SpinnerLine: FC<{ text: string }> = ({ text }) => ( @@ -47,11 +165,43 @@ export const ErrorLine: FC<{ text: string }> = ({ text }) => ( */ export const FilteredTextInput: FC<{ placeholder?: string + /** + * Blacklist of characters to strip from input. Each char in this string is + * removed from the buffer after every keystroke. Used for casual filtering + * (e.g. stripping `=` from env-var values). + */ filter?: string + /** + * Whitelist regex matched per-character. Anything not matching is dropped. + * Takes precedence over `filter` when both are set. Used when the field has + * a tight format (Apple Key ID is exactly 10 alphanumeric chars; Issuer ID + * is a UUID; etc.) so users can't even type invalid characters. + */ + allowedPattern?: RegExp + /** + * Hard cap on input length. Extra characters past the cap are dropped + * silently (paste-safe). Pair with `allowedPattern` for known-format fields + * — e.g. Apple Key ID has `maxLength=10` so a paste of "Key ID: KDTXMK292V" + * truncates to the first 10 valid chars after filtering. + */ + maxLength?: number + /** + * Post-filter transform applied to the entire buffer after each keystroke. + * Most common use: `(s) => s.toUpperCase()` for fields that are case- + * insensitive but conventionally uppercase. Runs after filter + maxLength. + */ + transform?: (value: string) => string mask?: boolean + /** + * Pre-fills the input. Used when the user is editing an already-entered + * value (e.g. fixing a typo in their ASC Key ID / Issuer ID after a + * verifying-key failure) so they don't have to retype everything. + * Backspace works normally to delete from the pre-filled value. + */ + initialValue?: string onSubmit: (value: string) => void -}> = ({ placeholder = '', filter = '=', mask = false, onSubmit }) => { - const [value, setValue] = useState('') +}> = ({ placeholder = '', filter = '=', allowedPattern, maxLength, transform, mask = false, initialValue = '', onSubmit }) => { + const [value, setValue] = useState(() => applyConstraints(initialValue, { filter, allowedPattern, maxLength, transform })) useInput((input, key) => { if (key.return) { @@ -66,14 +216,14 @@ export const FilteredTextInput: FC<{ if (key.ctrl || key.meta || key.escape || key.upArrow || key.downArrow || key.leftArrow || key.rightArrow || key.tab) { return } - // Append input then strip all forbidden characters (handles paste) + // Append input then apply the full constraint pipeline (paste-safe). if (input) { - const filterRegex = new RegExp(`[${filter.replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&')}]`, 'g') - setValue(prev => (prev + input).replace(filterRegex, '')) + setValue(prev => applyConstraints(prev + input, { filter, allowedPattern, maxLength, transform })) } }) const display = mask ? '•'.repeat(value.length) : value + const showCounter = maxLength !== undefined && !mask return ( @@ -81,10 +231,47 @@ export const FilteredTextInput: FC<{ ? {display} : {placeholder}} + {showCounter && ( + + {' '} + {value.length} + / + {maxLength} + + )} ) } +/** + * Apply the FilteredTextInput constraint pipeline in a single deterministic + * pass: blacklist filter → allowedPattern whitelist → maxLength truncate → + * transform. Pulled out so the initial-value prefill goes through the same + * pipeline as user keystrokes (an initialValue with invalid chars would + * otherwise appear briefly before the user typed anything). + */ +function applyConstraints( + raw: string, + opts: { filter: string, allowedPattern?: RegExp, maxLength?: number, transform?: (value: string) => string }, +): string { + let out = raw + if (opts.filter) { + const escape = (c: string) => c.replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&') + out = out.replace(new RegExp(`[${escape(opts.filter)}]`, 'g'), '') + } + if (opts.allowedPattern) { + // Match each character against the per-character pattern. If the regex + // is global or anchored we still treat it as a single-char test. + const perChar = new RegExp(opts.allowedPattern.source, opts.allowedPattern.flags.replace(/g/g, '')) + out = Array.from(out).filter(ch => perChar.test(ch)).join('') + } + if (opts.maxLength !== undefined && out.length > opts.maxLength) + out = out.slice(0, opts.maxLength) + if (opts.transform) + out = opts.transform(out) + return out +} + export const Header: FC = () => ( { assert.equal(computeCertSha1(der.toString('base64')), expected) }) +// ─── classifyCertAvailability ────────────────────────────────────── + +t('classifyCertAvailability marks expired certs unavailable from local date alone', () => { + const result = classifyCertAvailability({ + localExpirationDate: '2020-01-01T00:00:00.000Z', + appleCertId: 'cert-123', // even with a hit, expiration trumps + }) + assert.equal(result.available, false) + assert.equal(result.reason, 'expired') + assert.match(result.reasonText, /Expired \(2020-01-01\)/) +}) + +t('classifyCertAvailability marks managed certs as unsignable', () => { + const result = classifyCertAvailability({ + isManaged: true, + appleCertId: 'cert-123', + }) + assert.equal(result.available, false) + assert.equal(result.reason, 'managed') + assert.match(result.reasonText, /Apple-managed/) +}) + +t('classifyCertAvailability surfaces network errors as check-failed', () => { + const result = classifyCertAvailability({ + appleCertId: null, + lookupError: new Error('ECONNREFUSED'), + }) + assert.equal(result.available, false) + assert.equal(result.reason, 'check-failed') + assert.match(result.reasonText, /ECONNREFUSED/) +}) + +t('classifyCertAvailability surfaces non-Error throw values as check-failed', () => { + const result = classifyCertAvailability({ + appleCertId: null, + lookupError: 'string-thrown', + }) + assert.equal(result.reason, 'check-failed') + assert.match(result.reasonText, /string-thrown/) +}) + +t('classifyCertAvailability marks valid certs as available with the Apple id', () => { + const result = classifyCertAvailability({ + localExpirationDate: '2099-01-01T00:00:00.000Z', + appleCertId: 'apple-id-abc', + }) + assert.equal(result.available, true) + assert.equal(result.appleCertId, 'apple-id-abc') + assert.equal(result.reason, undefined) +}) + +t('classifyCertAvailability marks null Apple result as not-visible (neutral wording)', () => { + const result = classifyCertAvailability({ + appleCertId: null, + }) + assert.equal(result.available, false) + assert.equal(result.reason, 'not-visible') + // Should NOT claim revocation — we can't prove that from the response. + assert.doesNotMatch(result.reasonText, /Revoked/) + assert.match(result.reasonText, /Not visible|different team|lookup/) +}) + +t('classifyCertAvailability tolerates malformed expiration date strings', () => { + const result = classifyCertAvailability({ + localExpirationDate: 'not-a-date', + appleCertId: 'apple-id-xyz', + }) + // Bad date should not crash; falls through to the lookup result. + assert.equal(result.available, true) + assert.equal(result.appleCertId, 'apple-id-xyz') +}) + process.stdout.write('OK\n') diff --git a/cli/test/test-bundle-id-detector.mjs b/cli/test/test-bundle-id-detector.mjs new file mode 100644 index 0000000000..4e25fdac8d --- /dev/null +++ b/cli/test/test-bundle-id-detector.mjs @@ -0,0 +1,356 @@ +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { detectIosBundleIds, parseInfoPlistBundleId, parsePbxprojBundleId } from '../src/build/onboarding/bundle-id-detector.ts' + +function t(name, fn) { + try { + fn() + process.stdout.write(`✓ ${name}\n`) + } + catch (e) { + process.stderr.write(`✗ ${name}\n`) + throw e + } +} + +// ─── parsePbxprojBundleId ───────────────────────────────────────────── + +t('parsePbxprojBundleId returns null for empty content', () => { + assert.equal(parsePbxprojBundleId(''), null) +}) + +t('parsePbxprojBundleId returns null when no PRODUCT_BUNDLE_IDENTIFIER is set', () => { + const noBundle = ` + 1A2B3C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { OTHER_SETTING = foo; }; + name = Release; + }; + ` + assert.equal(parsePbxprojBundleId(noBundle), null) +}) + +t('parsePbxprojBundleId returns the Release bundle id', () => { + const pbxproj = ` + 1A2B3C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = ee.forgr.example; + }; + name = Release; + }; + 1D2E3F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = ee.forgr.example.debug; + }; + name = Debug; + }; + ` + const result = parsePbxprojBundleId(pbxproj) + assert.deepEqual(result, { + value: 'ee.forgr.example', + source: 'pbxproj-release', + label: 'project.pbxproj (Release config)', + }) +}) + +t('parsePbxprojBundleId tolerates quoted values', () => { + // Bundle IDs don't legally contain whitespace, but Xcode still quotes the + // value in pbxproj output. Make sure the regex strips quotes. + const pbxproj = ` + A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = "ee.forgr.example"; + }; + name = Release; + }; + ` + const result = parsePbxprojBundleId(pbxproj) + assert.equal(result?.value, 'ee.forgr.example') +}) + +t('parsePbxprojBundleId skips $(PRODUCT_BUNDLE_IDENTIFIER) variable references', () => { + const pbxproj = ` + A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER:rfc1034identifier)"; + }; + name = Release; + }; + B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = ee.forgr.real; + }; + name = Release; + }; + ` + const result = parsePbxprojBundleId(pbxproj) + assert.equal(result?.value, 'ee.forgr.real') +}) + +t('parsePbxprojBundleId picks the shortest Release id when extensions are present', () => { + // The main app target uses ee.forgr.example; an extension uses + // ee.forgr.example.notif. We want the parent (shorter) for the + // confirm-app-id step. + const pbxproj = ` + A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = ee.forgr.example.notif; + }; + name = Release; + }; + B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = ee.forgr.example; + }; + name = Release; + }; + ` + const result = parsePbxprojBundleId(pbxproj) + assert.equal(result?.value, 'ee.forgr.example') +}) + +t('parsePbxprojBundleId falls back to non-Release when no Release config exists', () => { + const pbxproj = ` + A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = ee.forgr.debug; + }; + name = Debug; + }; + ` + const result = parsePbxprojBundleId(pbxproj) + assert.deepEqual(result, { + value: 'ee.forgr.debug', + source: 'pbxproj-fallback', + label: 'project.pbxproj (Debug config)', + }) +}) + +// ─── parseInfoPlistBundleId ─────────────────────────────────────────── + +t('parseInfoPlistBundleId returns null for empty content', () => { + assert.equal(parseInfoPlistBundleId(''), null) +}) + +t('parseInfoPlistBundleId returns null when CFBundleIdentifier is absent', () => { + const plist = ` + + CFBundleNameApp +` + assert.equal(parseInfoPlistBundleId(plist), null) +}) + +t('parseInfoPlistBundleId returns the literal CFBundleIdentifier value', () => { + const plist = ` + + CFBundleIdentifieree.forgr.example +` + const result = parseInfoPlistBundleId(plist) + assert.deepEqual(result, { + value: 'ee.forgr.example', + source: 'plist', + label: 'Info.plist (CFBundleIdentifier)', + }) +}) + +t('parseInfoPlistBundleId skips $(PRODUCT_BUNDLE_IDENTIFIER) placeholder', () => { + // This is the Capacitor default — useless as a candidate because it just + // delegates to pbxproj. Returning null lets the picker omit it entirely. + const plist = ` + + CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER) +` + assert.equal(parseInfoPlistBundleId(plist), null) +}) + +// ─── detectIosBundleIds ──────────────────────────────────────────────── + +t('detectIosBundleIds returns just capacitor when no iOS project exists', () => { + const tmp = mkdtempSync(join(tmpdir(), 'bundle-id-detect-')) + try { + const result = detectIosBundleIds({ + cwd: tmp, + iosDir: 'ios', + capacitorAppId: 'com.example.app', + }) + assert.equal(result.pbxproj, null) + assert.equal(result.plist, null) + assert.equal(result.capacitor.value, 'com.example.app') + assert.equal(result.recommended.value, 'com.example.app') + assert.equal(result.mismatch, false) + assert.equal(result.candidates.length, 1) + } + finally { + rmSync(tmp, { recursive: true, force: true }) + } +}) + +t('detectIosBundleIds picks pbxproj over capacitor when they disagree', () => { + const tmp = mkdtempSync(join(tmpdir(), 'bundle-id-detect-')) + try { + const xcodeDir = join(tmp, 'ios', 'App.xcodeproj') + mkdirSync(xcodeDir, { recursive: true }) + writeFileSync(join(xcodeDir, 'project.pbxproj'), ` + A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = ee.forgr.real; + }; + name = Release; + }; + `, 'utf-8') + + const result = detectIosBundleIds({ + cwd: tmp, + iosDir: 'ios', + // Capgo-generated dev-tunnel-suffixed capacitor appId + capacitorAppId: 'ee.forgr.real.dev-abcd-efgh-ijkl', + }) + + assert.equal(result.pbxproj?.value, 'ee.forgr.real') + assert.equal(result.recommended.source, 'pbxproj-release') + assert.equal(result.recommended.value, 'ee.forgr.real') + assert.equal(result.mismatch, true) + // pbxproj first (recommended), capacitor second + assert.equal(result.candidates[0].source, 'pbxproj-release') + assert.equal(result.candidates[1].source, 'capacitor-config') + } + finally { + rmSync(tmp, { recursive: true, force: true }) + } +}) + +t('detectIosBundleIds reports no mismatch when pbxproj and capacitor agree', () => { + const tmp = mkdtempSync(join(tmpdir(), 'bundle-id-detect-')) + try { + const xcodeDir = join(tmp, 'ios', 'App.xcodeproj') + mkdirSync(xcodeDir, { recursive: true }) + writeFileSync(join(xcodeDir, 'project.pbxproj'), ` + A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = ee.forgr.same; + }; + name = Release; + }; + `, 'utf-8') + + const result = detectIosBundleIds({ + cwd: tmp, + iosDir: 'ios', + capacitorAppId: 'ee.forgr.same', + }) + + assert.equal(result.mismatch, false) + // Deduplicated — pbxproj and capacitor are the same value, so only one candidate + assert.equal(result.candidates.length, 1) + } + finally { + rmSync(tmp, { recursive: true, force: true }) + } +}) + +t('detectIosBundleIds reads Info.plist when pbxproj is missing', () => { + const tmp = mkdtempSync(join(tmpdir(), 'bundle-id-detect-')) + try { + const appDir = join(tmp, 'ios', 'App', 'App') + mkdirSync(appDir, { recursive: true }) + writeFileSync(join(appDir, 'Info.plist'), ` + + CFBundleIdentifieree.forgr.fromplist +`, 'utf-8') + + const result = detectIosBundleIds({ + cwd: tmp, + iosDir: 'ios', + capacitorAppId: 'ee.forgr.different', + }) + + assert.equal(result.pbxproj, null) + assert.equal(result.plist?.value, 'ee.forgr.fromplist') + assert.equal(result.recommended.source, 'plist') + assert.equal(result.mismatch, true) + } + finally { + rmSync(tmp, { recursive: true, force: true }) + } +}) + +t('detectIosBundleIds tolerates an Info.plist that delegates to pbxproj variable', () => { + const tmp = mkdtempSync(join(tmpdir(), 'bundle-id-detect-')) + try { + const appDir = join(tmp, 'ios', 'App', 'App') + mkdirSync(appDir, { recursive: true }) + // Default Capacitor template — Info.plist uses the pbxproj variable + writeFileSync(join(appDir, 'Info.plist'), ` + + CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER) +`, 'utf-8') + + const xcodeDir = join(tmp, 'ios', 'App.xcodeproj') + mkdirSync(xcodeDir, { recursive: true }) + writeFileSync(join(xcodeDir, 'project.pbxproj'), ` + A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = ee.forgr.pbx; }; + name = Release; + }; + `, 'utf-8') + + const result = detectIosBundleIds({ + cwd: tmp, + iosDir: 'ios', + capacitorAppId: 'ee.forgr.cap', + }) + + // pbxproj wins, plist placeholder ignored + assert.equal(result.plist, null) + assert.equal(result.recommended.value, 'ee.forgr.pbx') + assert.equal(result.candidates.length, 2) // pbxproj + capacitor + } + finally { + rmSync(tmp, { recursive: true, force: true }) + } +}) + +t('detectIosBundleIds finds pbxproj at the root when no ios/ subdir exists', () => { + // Some React Native / non-Capacitor layouts put .xcodeproj directly at + // the project root. The existing findXcodeProject() helper supports + // both — this test pins the contract. + const tmp = mkdtempSync(join(tmpdir(), 'bundle-id-detect-')) + try { + const xcodeDir = join(tmp, 'App.xcodeproj') + mkdirSync(xcodeDir, { recursive: true }) + writeFileSync(join(xcodeDir, 'project.pbxproj'), ` + A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = ee.forgr.rootlevel; }; + name = Release; + }; + `, 'utf-8') + + const result = detectIosBundleIds({ + cwd: tmp, + iosDir: 'ios', // doesn't exist + capacitorAppId: 'ee.forgr.cap', + }) + + assert.equal(result.pbxproj?.value, 'ee.forgr.rootlevel') + assert.equal(result.mismatch, true) + } + finally { + rmSync(tmp, { recursive: true, force: true }) + } +}) + +console.log('OK') diff --git a/cli/test/test-macos-signing.mjs b/cli/test/test-macos-signing.mjs index 2891431ca3..bd24ad7be2 100644 --- a/cli/test/test-macos-signing.mjs +++ b/cli/test/test-macos-signing.mjs @@ -5,6 +5,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { + filterProfilesForApp, generateP12Passphrase, isMacOS, matchIdentitiesToProfiles, @@ -251,4 +252,62 @@ t('parseHelperJson throws clearly when JSON is not an object', () => { assert.throws(() => parseHelperJson('"a string, not object"', '', 1), /not an object/) }) +// ─── filterProfilesForApp ──────────────────────────────────────────── + +function mockProfile({ bundleId, profileType }) { + return { + path: `/Mobile/${bundleId}-${profileType}.mobileprovision`, + uuid: `uuid-${bundleId}-${profileType}`, + name: `${bundleId} ${profileType}`, + applicationIdentifier: `TEAM.${bundleId}`, + bundleId, + teamId: 'TEAM', + expirationDate: '2099-01-01T00:00:00Z', + profileType, + certificateSha1s: ['abcd'], + creationDate: '2024-01-01T00:00:00Z', + } +} + +t('filterProfilesForApp returns only profiles matching bundleId + distribution', () => { + const profiles = [ + mockProfile({ bundleId: 'com.example.app', profileType: 'app_store' }), + mockProfile({ bundleId: 'com.example.app', profileType: 'ad_hoc' }), + mockProfile({ bundleId: 'com.other.app', profileType: 'app_store' }), + ] + const filtered = filterProfilesForApp(profiles, 'com.example.app', 'app_store') + assert.equal(filtered.length, 1) + assert.equal(filtered[0].bundleId, 'com.example.app') + assert.equal(filtered[0].profileType, 'app_store') +}) + +t('filterProfilesForApp returns empty when bundleId never matches', () => { + const profiles = [ + mockProfile({ bundleId: 'com.other.app', profileType: 'app_store' }), + mockProfile({ bundleId: 'com.another.app', profileType: 'app_store' }), + ] + const filtered = filterProfilesForApp(profiles, 'com.example.app', 'app_store') + assert.equal(filtered.length, 0) +}) + +t('filterProfilesForApp returns empty when bundleId matches but distribution does not', () => { + const profiles = [mockProfile({ bundleId: 'com.example.app', profileType: 'ad_hoc' })] + const filtered = filterProfilesForApp(profiles, 'com.example.app', 'app_store') + assert.equal(filtered.length, 0) +}) + +t('filterProfilesForApp returns all bundleId matches when distribution is null/undefined', () => { + const profiles = [ + mockProfile({ bundleId: 'com.example.app', profileType: 'app_store' }), + mockProfile({ bundleId: 'com.example.app', profileType: 'ad_hoc' }), + mockProfile({ bundleId: 'com.other.app', profileType: 'app_store' }), + ] + assert.equal(filterProfilesForApp(profiles, 'com.example.app', null).length, 2) + assert.equal(filterProfilesForApp(profiles, 'com.example.app', undefined).length, 2) +}) + +t('filterProfilesForApp returns empty for empty input', () => { + assert.equal(filterProfilesForApp([], 'com.example.app', 'app_store').length, 0) +}) + process.stdout.write('OK\n')