From 7c769c18f419494bc799b0086199d43e19fcb133 Mon Sep 17 00:00:00 2001 From: CRT Date: Fri, 23 Oct 2026 09:18:03 +0800 Subject: [PATCH] Desktop: folders keep claiming their store records before the config read returns; a store app with a folder's id is never listed twice --- apps/desktop/src/store.tsx | 11 +++++++++-- apps/desktop/tests/identity.test.ts | 15 +++++++++++++++ cli/lib/identity.ts | 14 ++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/store.tsx b/apps/desktop/src/store.tsx index 845e577..711dbf5 100644 --- a/apps/desktop/src/store.tsx +++ b/apps/desktop/src/store.tsx @@ -224,11 +224,18 @@ export function DataProvider({ children }: { children: ReactNode }) { setStoreApps(Object.fromEntries((Array.isArray(s) ? s : []).filter(x => x && typeof x.account === 'string').map(x => [x.account, x.entry ?? null]))); }, []); + // the last config summary seen per folder: a folder whose read has not come back yet (start) or failed this time keeps + // claiming its store records, so its App Store / Play entries never show up a second time as store-only apps + const lastSummary = useRef(new Map>()); const apps = useMemo(() => buildApps({ projects, accounts, overrides, - summaryOf: p => portfolio?.apps.find(a => a.project === p.path)?.summary ?? liveRef.current?.value.apps.find(a => a.project === p.path)?.summary, + summaryOf: p => { + const fresh = portfolio?.apps.find(a => a.project === p.path)?.summary ?? live?.value.apps.find(a => a.project === p.path)?.summary; + if (fresh) lastSummary.current.set(p.path, fresh); + return fresh ?? lastSummary.current.get(p.path); + }, storeApps: Object.entries(storeApps).filter(([, e]) => !!e).map(([account, e]) => ({ account, apps: e!.value.apps })), - }), [projects, accounts, overrides, portfolio, storeApps]); + }), [projects, accounts, overrides, portfolio, live, storeApps]); const appsRef = useRef(apps); appsRef.current = apps; diff --git a/apps/desktop/tests/identity.test.ts b/apps/desktop/tests/identity.test.ts index fc0bda4..b640586 100644 --- a/apps/desktop/tests/identity.test.ts +++ b/apps/desktop/tests/identity.test.ts @@ -53,6 +53,21 @@ describe('identity pairing', () => { const apps2 = buildApps({ projects: [prism], accounts, storeApps: twin, overrides: NO_OVERRIDES, summaryOf: () => summary('com.example.prism', 'com.example.prism') }); expect(apps2[0]!.ios?.account).toBe(A1); }); + test('a folder with a config summary claims its iOS and Android records; ignore flags do not break it', () => { + for (const o of [NO_OVERRIDES, withIgnored(NO_OVERRIDES, ['project:p1:ios'], true), withIgnored(NO_OVERRIDES, ['project:p1:android'], true)]) { + const apps = build(o); + expect(apps.filter(a => a.ios?.app.bundleId === 'com.example.prism' || a.android?.app.id === 'com.example.prism').length).toBeLessThanOrEqual(1); + expect(apps.filter(a => a.name === 'Prism' && !a.project)).toEqual([]); + } + }); + test('without a summary the folder claims nothing (the start-up race); a store app of a folder bundle id is never shown separately', () => { + const none = buildApps({ projects: [prism], accounts, storeApps, overrides: NO_OVERRIDES, summaryOf: () => null }); + expect(none.filter(a => !a.project && a.name === 'Prism')).toHaveLength(1); + // guard: the folder is linked to other records, its summary's ids still hide the duplicates + const linked = { ...NO_OVERRIDES, links: [{ key: `ios:${A1}:102`, project: 'p1' }] }; + const apps = build(linked); + expect(apps.filter(a => !a.project && (a.ios?.app.bundleId === 'com.example.prism' || a.android?.app.id === 'com.example.prism'))).toEqual([]); + }); test('unpair blocks the automatic pair; manual pair beats it', () => { const o1 = withUnpair(NO_OVERRIDES, `ios:${A1}:102`, `android:${P1}:com.example.orbit`); const apps1 = build(o1); diff --git a/cli/lib/identity.ts b/cli/lib/identity.ts index 192e718..60fa2ff 100644 --- a/cli/lib/identity.ts +++ b/cli/lib/identity.ts @@ -134,6 +134,20 @@ export function buildApps

(i: BuildInput

): UnifiedApp

[ }); } + // guard: a store app with the same App Store id / bundle id / package name as a folder is that folder's record, never a + // second entry (the folder could not take it above, e.g. it is linked elsewhere); it is dropped, not shown separately + const folderIds = new Set(); + for (const p of i.projects) { + const s = i.summaryOf(p); + if (s?.apple?.appId) folderIds.add(`ios:id:${s.apple.appId}`); + if (s?.apple?.bundleId) folderIds.add(`ios:bundle:${s.apple.bundleId}`); + if (s?.play?.packageName) folderIds.add(`android:${s.play.packageName}`); + } + for (const r of refs) { + if (used.has(r.key)) continue; + if (r.store === 'ios' ? folderIds.has(`ios:id:${r.app.id}`) || folderIds.has(`ios:bundle:${r.app.bundleId}`) : folderIds.has(`android:${r.app.id}`)) used.add(r.key); + } + // 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));