// Pure pieces of `portfolio.status` (serve.ts): an app's identity across platforms, its release trains (one version // across the App Store and Google Play) and the in-app catalog parity between the config and both stores. // Inputs are the `apple status --json` / `play status --json` results, read defensively: "unknown, never false". import type { AppIdentity, CatalogParity, ProjectSummary, ReleasePhase, ReleaseTrain, StoreRelease } from './desktop-protocol'; type J = Record; // eslint-disable-line @typescript-eslint/no-explicit-any export function appIdentity(s: ProjectSummary): AppIdentity { return { id: s.dir, name: s.name, platforms: { ios: s.apple && s.platforms.includes('ios') ? { bundleId: s.apple.bundleId, ascAppId: s.apple.appId } : null, android: s.play && s.platforms.includes('android') ? { packageName: s.play.packageName } : null, }, }; } /** Numeric compare of dotted versions ("2.10.0" > "2.9.1"); non-numeric parts compare as 0. */ export function cmpVersion(a: string, b: string): number { const pa = a.split('.').map(x => parseInt(x, 10) || 0), pb = b.split('.').map(x => parseInt(x, 10) || 0); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const d = (pa[i] ?? 0) - (pb[i] ?? 0); if (d) return d; } return 0; } /** Matching key of a version: numeric parts without trailing zero parts, so 1 == 1.0 == 1.0.0 ("1"), 2.10 != 2.1. * A version with non-numeric parts keeps its own spelling. The stores' strings are shown as they are; this only joins. */ export function versionKey(v: string): string { const parts = v.trim().split('.'); if (!parts.every(p => /^\d+$/.test(p))) return v.trim(); const n = parts.map(p => String(parseInt(p, 10))); while (n.length > 1 && n.at(-1) === '0') n.pop(); return n.join('.'); } /** The same release on both stores (App Store "1.0" and Play "1.0.0"). */ export const sameVersion = (a: string | null | undefined, b: string | null | undefined): boolean => !!a && !!b && versionKey(a) === versionKey(b); /** The spelling a joined row shows: the longest of the stores' strings (1.0.0 over 1.0), the first one on a tie. */ export function displayVersion(vs: (string | null | undefined)[]): string { const xs = vs.filter((x): x is string => !!x); return xs.reduce((a, b) => (b.split('.').length > a.split('.').length ? b : a), xs[0] ?? ''); } /** App Store version states (appStoreState / appVersionState) -> phase. */ export function applePhase(state: string): ReleasePhase { if (/^(READY_FOR_SALE|READY_FOR_DISTRIBUTION)$/.test(state)) return 'live'; if (/^(PENDING_DEVELOPER_RELEASE|PENDING_APPLE_RELEASE|ACCEPTED)$/.test(state)) return 'approved'; if (/^(WAITING_FOR_REVIEW|IN_REVIEW|WAITING_FOR_EXPORT_COMPLIANCE|PROCESSING_FOR_DISTRIBUTION|PROCESSING_FOR_APP_STORE)$/.test(state)) return 'review'; if (/REJECTED$/.test(state)) return 'rejected'; if (/^(PREPARE_FOR_SUBMISSION|DEVELOPER_ACTION_NEEDED)$/.test(state)) return 'draft'; return 'other'; } /** Apple's phased release schedule: share of users with automatic updates on day 1-7. */ export const PHASED_SCHEDULE = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1] as const; /** Phased release of a version (`apple status --json` phased[versionId]) -> rollout share, or null. */ export function phasedFraction(p: { state?: string; currentDayNumber?: number | null } | null | undefined): number | null { if (!p?.state) return null; if (p.state === 'COMPLETE') return 1; const d = typeof p.currentDayNumber === 'number' ? Math.min(7, Math.max(1, Math.round(p.currentDayNumber))) : null; return d ? PHASED_SCHEDULE[d - 1]! : p.state === 'INACTIVE' ? 0 : null; } export function appleReleases(data: unknown): StoreRelease[] { const versions = ((data as J)?.versions ?? []) as J[]; const recent = (((data as J)?.builds ?? []) as J[]); const builds = new Map([...recent, ...(((data as J)?.attachedBuilds ?? []) as J[])].map(b => [b.id, b.attributes?.version] as const)); const phasedById = (((data as J)?.phased ?? {}) as Record); const rows = versions.filter(v => typeof v?.attributes?.versionString === 'string' && (v.attributes.platform ?? 'IOS') === 'IOS').map(v => { const state = String(v.attributes.appVersionState ?? v.attributes.appStoreState ?? 'UNKNOWN'); const buildId = v.relationships?.build?.data?.id; let phase = applePhase(state); const pr = (typeof v.id === 'string' ? phasedById[v.id] : null) ?? null; const prState = typeof pr?.attributes?.phasedReleaseState === 'string' ? String(pr.attributes.phasedReleaseState) : null; const day = typeof pr?.attributes?.currentDayNumber === 'number' ? pr.attributes.currentDayNumber as number : null; const fraction = phase === 'live' && prState ? phasedFraction({ state: prState, currentDayNumber: day }) : null; // live but still rolling out (phased release on, not complete): the same as a Play staged rollout if (phase === 'live' && prState === 'ACTIVE') phase = 'rollout'; if (phase === 'live' && prState === 'PAUSED') phase = 'halted'; const r: StoreRelease = { store: 'ios', version: v.attributes.versionString as string, build: (buildId && builds.get(buildId)) ? String(builds.get(buildId)) : null, state, phase, fraction, track: null }; if (prState) r.phased = { state: prState, day }; if (typeof v.attributes.createdDate === 'string') r.date = v.attributes.createdDate; return r; }); return [...rows, ...testflightReleases(recent, ((data as J)?.buildVersions ?? {}) as Record, new Set(rows.map(r => r.version)))]; } /** Marketing versions that have a processed build but no App Store version yet: on the train at "testing" (TestFlight), * with the highest build number. Seen live: builds of 0.6.0 in TestFlight while 0.5.0 waited for review. */ export function testflightReleases(builds: J[], buildVersions: Record, versions: Set): StoreRelease[] { const best = new Map(); for (const b of builds) { const v = buildVersions[b?.id]; const n = b?.attributes?.version; if (!v || versions.has(v) || typeof n !== 'string' || b.attributes.processingState !== 'VALID' || b.attributes.expired) continue; const cur = best.get(v); if (!cur || cmpVersion(n, cur) > 0) best.set(v, n); } return [...best].map(([version, build]) => ({ store: 'ios' as const, version, build, state: 'TESTFLIGHT', phase: 'testing' as const, fraction: null, track: 'TestFlight' })); } /** Leading dotted version of a Play release name ("2.3.0 (45)" -> "2.3.0"). */ export const versionOfName = (name: unknown): string | null => (typeof name === 'string' ? name.match(/\d+(?:\.\d+){0,3}/)?.[0] ?? null : null); /** * Play tracks (`play status --json`) -> releases. The Developer API has no review state: a completed production release * is only "live" when the public store page proves it (`publicPage` in the status JSON): 404 = sent to Google (in * review or not yet published); 200 = live for the first release or when the page shows this versionName, otherwise * "released" (an update Google may still be reviewing); page not read = "released" with publicPage unknown. */ export function playReleases(data: unknown): StoreRelease[] { const out: StoreRelease[] = []; const page = (data as J)?.publicPage as { state?: string; versionName?: string | null } | undefined; const pageState = page?.state === 'public' || page?.state === 'notPublic' ? page.state : 'unknown'; const raw = (data as J)?.tracks; const tracks: J[] = Array.isArray(raw) ? raw : []; const codesOf = (r: J): number[] => (Array.isArray(r?.versionCodes) ? (r.versionCodes as unknown[]) : []).map(Number).filter(Number.isFinite); const rels = (t: J): J[] => (Array.isArray(t?.releases) ? t.releases : []); const allCodes = tracks.flatMap(t => rels(t).flatMap(codesOf)); for (const t of tracks) { for (const r of rels(t)) { const codes = codesOf(r); const production = t.track === 'production'; const status = String(r.status ?? 'unknown'); const version = versionOfName(r.name); const rel: StoreRelease = { store: 'android', version, build: codes.length ? String(Math.max(...codes)) : null, state: status, phase: 'other', fraction: typeof r.userFraction === 'number' ? r.userFraction : null, track: String(t.track) }; rel.phase = !production ? (status === 'draft' ? 'draft' : 'testing') : status === 'inProgress' ? 'rollout' : status === 'halted' ? 'halted' : status === 'draft' ? 'draft' : 'other'; if (production && status === 'completed') { const low = codes.length ? Math.min(...codes) : Infinity; const first = !allCodes.some(c => c < low); // nothing older on any track: the app's first release rel.phase = pageState === 'notPublic' ? 'sent' : pageState === 'public' && (first || sameVersion(page?.versionName, version)) ? 'live' : 'released'; rel.publicPage = pageState; } out.push(rel); } } return out; } /** Plain words for a Play production release's phase (CLI text; the desktop has the same words in i18n). */ export function playReleaseLabel(r: StoreRelease): string { if (r.phase === 'sent') return 'Sent to Google (in review or not yet published)'; if (r.phase === 'released') return r.publicPage === 'unknown' ? 'Released to production (public store page not readable: live state unknown)' : 'Released to production (Google may still be reviewing)'; if (r.phase === 'live') return 'Live'; return r.state; } export const PHASE_RANK: Record = { live: 0, rollout: 1, halted: 2, released: 3, approved: 4, review: 5, sent: 6, rejected: 7, testing: 8, draft: 9, other: 10 }; /** Phases where the version reaches (or may reach) users. */ export const REACHING: ReadonlySet = new Set(['live', 'rollout', 'halted', 'released']); const pick = (rs: StoreRelease[]): StoreRelease | null => [...rs].sort((a, b) => PHASE_RANK[a.phase] - PHASE_RANK[b.phase])[0] ?? null; /** * Release trains: one version across both stores. current = the highest version that reaches users on a store (live, * or rolling out: a staged rollout or an iOS phased release, also when halted / paused); next = the highest version * above it on either store (review, testing, draft ...). A store without that version has null in the train. */ export function releaseTrains(ios: StoreRelease[], android: StoreRelease[]): { current: ReleaseTrain | null; next: ReleaseTrain | null } { const all = [...ios, ...android].filter((r): r is StoreRelease & { version: string } => !!r.version); const train = (v: string): ReleaseTrain => { const i = pick(all.filter(r => r.store === 'ios' && sameVersion(r.version, v))), a = pick(all.filter(r => r.store === 'android' && sameVersion(r.version, v))); return { version: displayVersion([a?.version, i?.version, v]), ios: i, android: a }; }; const live = all.filter(r => REACHING.has(r.phase)).map(r => r.version).sort(cmpVersion).at(-1) ?? null; const above = all.filter(r => !live || cmpVersion(r.version, live) > 0).map(r => r.version).sort(cmpVersion).at(-1) ?? null; return { current: live ? train(live) : null, next: above ? train(above) : null }; } /** Product ids on App Store Connect (`apple status --json` iaps) and Google Play (`play status --json` products). */ export const appleProductIds = (data: unknown): string[] => (((data as J)?.iaps ?? []) as J[]).map(p => p?.attributes?.productId).filter((x): x is string => typeof x === 'string'); export const playProductIds = (data: unknown): string[] => (((data as J)?.products ?? []) as J[]).map(p => p?.productId ?? p?.sku).filter((x): x is string => typeof x === 'string'); /** * Catalog parity. Without store data (offline) only the config side is known. With it: ids only on one store, * and config ids missing from a store the app is on. */ export function catalogParity(s: ProjectSummary, apple?: string[], play?: string[]): CatalogParity { const live = apple !== undefined || play !== undefined; const onlyIos = apple && play ? apple.filter(x => !play.includes(x)) : []; const onlyAndroid = apple && play ? play.filter(x => !apple.includes(x)) : []; // the status reads list one-time products; subscriptions are compared by `catalog diff` (catalog.diff method) const sold = (ids: string[]) => ids.filter(x => s.productIds.includes(x)); const missingIos = apple ? sold(s.catalog.ios).filter(x => !apple.includes(x)) : []; const missingAndroid = play ? sold(s.catalog.android).filter(x => !play.includes(x)) : []; return { checked: live ? 'live' : 'config', products: s.products, subscriptions: s.subscriptions, onlyIos, onlyAndroid, missingIos, missingAndroid, ok: !onlyIos.length && !onlyAndroid.length && !missingIos.length && !missingAndroid.length, }; }