// One app card per product, across studios and store accounts: the pairing rules the desktop app (apps/desktop) and // the CLI workspace (`storebound ws`, cli/lib/workspace.ts) share. Pure, so tests pin every rule // (apps/desktop/tests/identity.test.ts, tests/workspace.test.ts). // // Inputs: app folders (with the store ids of their config), the apps each store account can see (`apps list`), and // the owner's overrides (manual pairs / unpairs, links of a store app to a folder, studio moves, the ignore list). // Order of matching: // 1. a folder takes the store apps its config names (explicit link > App Store app id > bundle id / package name), // preferring the accounts mapped to the folder; // 2. the remaining store apps pair iOS + Android: manual pairs, then the same bundle id and package name, then a // close id (".ios" / ".android" suffixes, case, punctuation) or the same name; unpairs block an automatic pair; // 3. what is left is a single-platform store-only app. // A store-only app is read through a "store target" (store:ios:::;android::). import type { ProjectSummary, StoreApp } from './desktop-protocol'; /** An app folder as both front ends know it (the desktop's Project carries more fields). */ export interface IdProject { id: string; path: string; name: string; playAccount: string | null; appleAccount: string | null } /** A store account: an id (16 hex) and its studio. */ export interface IdAccount { id: string; studio?: string | null } export interface Pair { ios: string; android: string } export interface Overrides { pairs: Pair[]; unpairs: Pair[]; links: { key: string; project: string }[]; appStudio: { key: string; studio: string }[]; ignored: string[]; } export const NO_OVERRIDES: Overrides = { pairs: [], unpairs: [], links: [], appStudio: [], ignored: [] }; /** One app record of one store account. */ export interface StoreRef { key: string; account: string; store: 'ios' | 'android'; app: StoreApp } export type Pairing = 'folder' | 'manual' | 'id' | 'close' | 'name' | 'single'; export interface UnifiedApp

{ /** Route and list key: the project id, else the store target. */ key: string; /** What reads use: the project id, else the store target ("store:..."). */ target: string; name: string; project: P | null; ios: StoreRef | null; android: StoreRef | null; studio: string | null; pairing: Pairing; /** The keys an "ignore" puts on the list ("project:" or "app:"). */ ignoreKeys: string[]; ignored: boolean; /** Store icon (data URL) when the folder has none. */ storeIcon: string | null; } export const refKey = (account: string, a: StoreApp): string => `${a.store}:${account}:${a.id}`; /** Store target of a store-only app. */ export function storeTarget(ios: StoreRef | null, android: StoreRef | null): string { const parts: string[] = []; if (ios) parts.push(`ios:${ios.account}:${ios.app.id}:${ios.app.bundleId}`); if (android) parts.push(`android:${android.account}:${android.app.id}`); return `store:${parts.join(';')}`; } /** "com.example.Puzzle.ios" -> "comexamplepuzzle": ids that differ only by a platform suffix, case or punctuation. */ export function normId(id: string): string { return id.toLowerCase().replace(/[._-](ios|iphone|android|droid|app)$/g, '').replace(/[^a-z0-9]/g, ''); } export const normName = (n: string): string => n.toLowerCase().normalize('NFKD').replace(/[^a-z0-9]/g, ''); const samePair = (p: Pair, ios: string, android: string) => p.ios === ios && p.android === android; export interface BuildInput

{ projects: P[]; /** Store ids of each folder's config (from the offline portfolio read). */ summaryOf: (p: P) => ProjectSummary | null | undefined; accounts: IdAccount[]; /** The last app list of each account. */ storeApps: { account: string; apps: StoreApp[] }[]; overrides: Overrides; } export function buildApps

(i: BuildInput

): UnifiedApp

[] { const o = i.overrides; const ignoredAccounts = new Set(o.ignored.filter(x => x.startsWith('account:')).map(x => x.slice(8))); const accountStudio = new Map(i.accounts.map(a => [a.id, a.studio ?? null] as const)); const known = new Set(i.accounts.map(a => a.id)); // every store app of every (not ignored) account const refs: StoreRef[] = []; for (const s of i.storeApps) { if (!known.has(s.account) || ignoredAccounts.has(s.account)) continue; for (const app of s.apps) refs.push({ key: refKey(s.account, app), account: s.account, store: app.store, app }); } const used = new Set(); const take = (r: StoreRef | null | undefined): StoreRef | null => { if (!r || used.has(r.key)) return null; used.add(r.key); return r; }; const studioOverride = new Map(o.appStudio.map(x => [x.key, x.studio] as const)); const ignored = new Set(o.ignored); const out: UnifiedApp

[] = []; // 1. folders for (const p of i.projects) { const s = i.summaryOf(p); const mine = (r: StoreRef) => r.account === (r.store === 'ios' ? p.appleAccount : p.playAccount); const rank = (rs: StoreRef[]) => [...rs].sort((a, b) => Number(mine(b)) - Number(mine(a))); const linked = o.links.filter(l => l.project === p.id).map(l => refs.find(r => r.key === l.key)).filter((r): r is StoreRef => !!r && !used.has(r.key)); let ios = take(linked.find(r => r.store === 'ios')); let android = take(linked.find(r => r.store === 'android')); if (!ios && s?.apple) { const free = refs.filter(r => r.store === 'ios' && !used.has(r.key) && !o.links.some(l => l.key === r.key)); ios = take(rank(free.filter(r => s.apple!.appId && r.app.id === s.apple!.appId))[0] ?? rank(free.filter(r => r.app.bundleId === s.apple!.bundleId))[0]); } if (!android && s?.play) { const free = refs.filter(r => r.store === 'android' && !used.has(r.key) && !o.links.some(l => l.key === r.key)); android = take(rank(free.filter(r => r.app.id === s.play!.packageName))[0]); } const studio = studioOverride.get(`project:${p.id}`) ?? (p.appleAccount && accountStudio.get(p.appleAccount)) ?? (p.playAccount && accountStudio.get(p.playAccount)) ?? (ios && accountStudio.get(ios.account)) ?? (android && accountStudio.get(android.account)) ?? null; out.push({ key: p.id, target: p.id, name: p.name, project: p, ios, android, studio, pairing: 'folder', ignoreKeys: [`project:${p.id}`], ignored: ignored.has(`project:${p.id}`), storeIcon: ios?.app.icon ?? android?.app.icon ?? null, }); } // 2. pairs among the remaining store apps const freeIos = () => refs.filter(r => r.store === 'ios' && !used.has(r.key)); const freeAndroid = () => refs.filter(r => r.store === 'android' && !used.has(r.key)); const blocked = (a: StoreRef, b: StoreRef) => o.unpairs.some(p => samePair(p, a.key, b.key)); const sameStudio = (a: StoreRef, b: StoreRef) => (accountStudio.get(a.account) ?? null) === (accountStudio.get(b.account) ?? null); const pairs: [StoreRef, StoreRef, Pairing][] = []; for (const m of o.pairs) { const a = freeIos().find(r => r.key === m.ios), b = freeAndroid().find(r => r.key === m.android); if (a && b) { take(a); take(b); pairs.push([a, b, 'manual']); } } const auto = (how: Pairing, match: (a: StoreRef, b: StoreRef) => boolean) => { for (const a of freeIos()) { const cands = freeAndroid().filter(b => !blocked(a, b) && match(a, b)).sort((x, y) => Number(sameStudio(a, y)) - Number(sameStudio(a, x))); const b = cands[0]; if (b) { take(a); take(b); pairs.push([a, b, how]); } } }; auto('id', (a, b) => a.app.bundleId === b.app.id); auto('close', (a, b) => normId(a.app.bundleId) !== '' && normId(a.app.bundleId) === normId(b.app.id)); auto('name', (a, b) => normName(a.app.name) !== '' && normName(a.app.name) === normName(b.app.name)); const storeOnly = (ios: StoreRef | null, android: StoreRef | null, pairing: Pairing) => { const target = storeTarget(ios, android); const keys = [ios, android].filter((r): r is StoreRef => !!r).map(r => r.key); const studio = keys.map(k => studioOverride.get(k)).find(Boolean) ?? (ios && accountStudio.get(ios.account)) ?? (android && accountStudio.get(android.account)) ?? null; out.push({ key: target, target, name: ios?.app.name ?? android?.app.name ?? '?', project: null, ios, android, studio, pairing, ignoreKeys: keys.map(k => `app:${k}`), ignored: keys.some(k => ignored.has(`app:${k}`)), storeIcon: ios?.app.icon ?? android?.app.icon ?? null, }); }; for (const [a, b, how] of pairs) storeOnly(a, b, how); // 3. singles for (const r of refs) if (!used.has(r.key)) { used.add(r.key); storeOnly(r.store === 'ios' ? r : null, r.store === 'android' ? r : null, 'single'); } return out; } /** Candidates to pair a single-platform app with (the other store's single apps), closest first. */ export function pairCandidates

(app: UnifiedApp

, all: UnifiedApp

[]): UnifiedApp

[] { if (app.project || (app.ios && app.android)) return []; const want = app.ios ? 'android' : 'ios'; const me = (app.ios ?? app.android)!; const score = (x: UnifiedApp

) => { const r = (want === 'ios' ? x.ios : x.android)!; return (normId(r.app.bundleId) === normId(me.app.bundleId) ? 4 : 0) + (normName(r.app.name) === normName(me.app.name) ? 2 : 0) + (x.studio === app.studio ? 1 : 0); }; return all.filter(x => !x.project && !x.ignored && (want === 'ios' ? x.ios && !x.android : x.android && !x.ios)).sort((a, b) => score(b) - score(a) || a.name.localeCompare(b.name)); } /** Overrides after pairing two single store apps by hand (drops an unpair of the same two). */ export function withPair(o: Overrides, ios: string, android: string): Overrides { return { ...o, pairs: [...o.pairs.filter(p => p.ios !== ios && p.android !== android), { ios, android }], unpairs: o.unpairs.filter(p => !samePair(p, ios, android)) }; } /** Overrides after splitting a paired app (keeps it apart from now on). */ export function withUnpair(o: Overrides, ios: string, android: string): Overrides { return { ...o, pairs: o.pairs.filter(p => !samePair(p, ios, android)), unpairs: [...o.unpairs.filter(p => !samePair(p, ios, android)), { ios, android }] }; } export function withIgnored(o: Overrides, keys: string[], on: boolean): Overrides { const set = new Set(o.ignored); for (const k of keys) { if (on) set.add(k); else set.delete(k); } return { ...o, ignored: [...set] }; } /** Moves an app to a studio (null: back to its accounts' studio). */ export function withStudio(o: Overrides, app: UnifiedApp, studio: string | null): Overrides { const keys = app.project ? [`project:${app.project.id}`] : [app.ios?.key, app.android?.key].filter((k): k is string => !!k); const rest = o.appStudio.filter(x => !keys.includes(x.key)); return { ...o, appStudio: studio ? [...rest, ...keys.map(key => ({ key, studio }))] : rest }; } /** Links the store apps of a store-only app to a folder. */ export function withLink(o: Overrides, app: UnifiedApp, project: string): Overrides { const keys = [app.ios?.key, app.android?.key].filter((k): k is string => !!k); return { ...o, links: [...o.links.filter(l => !keys.includes(l.key)), ...keys.map(key => ({ key, project }))] }; }