From 0ef27cf2e46ad400639e4d315c34b5540ee3605b Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 13:01:50 +0800 Subject: [PATCH 001/107] desktop: five sections by job (Release, Store page, Revenue, Players, Health), Game services view, sidebar by attention - app view: 12 tabs become 5 sections, each with a header (what it is for, the one next step from state already read) and its sub views in a segmented control; 1-5 jump, Ctrl+Shift+[ ] cycle, Ctrl+K and the context menu list sections and sub views; old tab ids, section and section/sub links all resolve - Players > Game services (games only): both stores, one row per leaderboard / achievement with its languages and each store's state, Game Center locale map, Play API-disabled state with the console plan - games status --json: items, localeMap, playApiDisabled; inspect summary: games - sidebar: apps grouped by attention / in progress / live / not on a store, one status line each, studio filter, workspace as a footer icon row - boot check opens places like a person (section, then sub view) --- apps/desktop/src/App.tsx | 147 +++++++++++++--------- apps/desktop/src/design.css | 42 +++++++ apps/desktop/src/dev/fixtures.ts | 10 ++ apps/desktop/src/i18n/en.ts | 102 +++++++++++++++ apps/desktop/src/i18n/tr.ts | 102 +++++++++++++++ apps/desktop/src/lib/model.ts | 47 ++++++- apps/desktop/src/lib/sections.ts | 94 ++++++++++++++ apps/desktop/src/nav.ts | 49 ++++++-- apps/desktop/src/screens/AppView.tsx | 26 ++-- apps/desktop/src/screens/SectionHead.tsx | 60 +++++++++ apps/desktop/src/screens/tabs/AppInfo.tsx | 2 - apps/desktop/src/screens/tabs/Games.tsx | 90 +++++++++++++ apps/desktop/src/store.tsx | 18 +++ apps/desktop/src/ui/GameServicesCard.tsx | 16 ++- apps/desktop/src/ui/app-menu.ts | 10 +- apps/desktop/src/ui/ds.tsx | 5 +- apps/desktop/tests/sections.test.ts | 79 ++++++++++++ cli/games-status.ts | 46 ++++++- cli/inspect.ts | 1 + cli/lib/desktop-protocol.ts | 2 + tests/game-services.test.ts | 14 ++- tools/desktop-boot-check.ts | 71 ++++++++--- 22 files changed, 918 insertions(+), 115 deletions(-) create mode 100644 apps/desktop/src/lib/sections.ts create mode 100644 apps/desktop/src/screens/SectionHead.tsx create mode 100644 apps/desktop/src/screens/tabs/Games.tsx create mode 100644 apps/desktop/tests/sections.test.ts diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index b1b01fe..4cfa717 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,4 +1,5 @@ -// Shell: sidebar (studios, collapsible, with their apps; the workspace), top bar (breadcrumbs, last read, refresh), +// Shell: sidebar (apps grouped by what they need: attention, in progress, live, not on a store yet; one status line +// each; a studio filter; the workspace as a compact footer row), top bar (breadcrumbs, last read, refresh), // the screen, the command palette (Cmd/Ctrl+K), toasts and the first-run welcome guide. import { DryRunPreview } from './ui/DryRunPreview'; import { useCallback, useEffect, useMemo, useState } from 'react'; @@ -6,8 +7,8 @@ import { FIXTURES, onDeepLink, onDeepLinkFolder, onTrayRoute } from './api'; import { devSwitch } from './dev/switches'; import { type Key, t } from './i18n'; import { ago, num } from './lib/format'; -import { groupByStudio, type UnifiedApp } from './lib/identity'; -import { NavCtx, parseRouteJson, parseStart, type Route, sameRoute, TAB_ICON, TAB_KEY, tabsFor, WORKSPACE, type WorkspaceScreen } from './nav'; +import type { UnifiedApp } from './lib/identity'; +import { NavCtx, parseRouteJson, parseStart, type Route, sameRoute, SECTION_ICON, SECTION_KEY, sectionOf, sectionsFor, subsOf, TAB_ICON, TAB_KEY, tabsFor, WORKSPACE, type WorkspaceScreen } from './nav'; import { usePrefs } from './prefs-ctx'; import { Accounts } from './screens/Accounts'; import { AppFolders } from './screens/AppFolders'; @@ -28,16 +29,18 @@ import { Toasts } from './ui/Toasts'; import { useContextMenu } from './ui/ContextMenu'; import { appMenu } from './ui/app-menu'; import { isEditable, isItem, type MenuInput } from './ui/menu-logic'; -import { worstTone } from './ui/ds'; -import { attention } from './lib/model'; +import { attention, isGameSummary, SIDE_GROUPS, type SideGroup, sideStatus } from './lib/model'; const WS_ICON: Record = { apps: 'folder', accounts: 'building', ignored: 'eyeOff', dependencies: 'package', settings: 'settings' }; export const WS_KEY: Record = { apps: 'nav.apps', accounts: 'nav.accounts', ignored: 'nav.ignored', dependencies: 'nav.dependencies', settings: 'nav.settings' }; const SIDE_KEY = 'storebound.side.v1'; +const STUDIO_KEY = 'storebound.side.studio.v1'; function loadCollapsed(): string[] { try { const v = JSON.parse(localStorage.getItem(SIDE_KEY) ?? '[]') as unknown; return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; } catch { return []; } } +function loadStudio(): string | null { try { return localStorage.getItem(STUDIO_KEY); } catch { return null; } } +const GROUP_KEY: Record = { attention: 'side.gAttention', progress: 'side.gProgress', live: 'side.gLive', draft: 'side.gDraft', ignored: 'nav.ignored' }; export function App({ onTheme }: { onTheme: () => void }) { const d = useData(); @@ -47,6 +50,8 @@ export function App({ onTheme }: { onTheme: () => void }) { const [welcome, setWelcome] = useState(false); const [started, setStarted] = useState(false); const [collapsed, setCollapsed] = useState(loadCollapsed); + const [studioFilter, setStudioFilterState] = useState(loadStudio); + const setStudioFilter = (v: string | null) => { setStudioFilterState(v); try { if (v) localStorage.setItem(STUDIO_KEY, v); else localStorage.removeItem(STUDIO_KEY); } catch { /* private mode */ } }; const exports = useExportItems(); const runExport = useRunExport(); @@ -56,16 +61,27 @@ export function App({ onTheme }: { onTheme: () => void }) { const allReads = useReadState({ tags: ['all'] }); const [showIgnored, setShowIgnored] = useShowIgnored(); const ignoredApps = useMemo(() => d.apps.filter(a => a.ignored).sort((a, b) => a.name.localeCompare(b.name)), [d.apps]); - const listed = showIgnored ? [...visible, ...ignoredApps] : visible; - const groups = d.studios.length ? groupByStudio(listed.map(a => ({ app: a })), d.studios) : [{ studio: null as string | null, rows: listed.map(a => ({ app: a })) }]; - // Alt+Up / Alt+Down: the previous / next app in sidebar order (keeps the tab when the other app has it) - const sideOrder = groups.flatMap(x => x.rows.map(r => r.app)); + const gameOf = useCallback((a: UnifiedApp) => isGameSummary(d.statusFor(a)?.summary), [d]); + const tabsOf = useCallback((a: UnifiedApp) => tabsFor(!!a.project, gameOf(a)), [gameOf]); + // the sidebar: apps by what they need (attention, in progress, live, not on a store yet), one status line each + const studioOn = studioFilter && (studioFilter === 'none' || d.studios.some(s => s.id === studioFilter)) ? studioFilter : null; + const inStudio = useCallback((a: UnifiedApp) => !studioOn || (studioOn === 'none' ? !a.studio : a.studio === studioOn), [studioOn]); + const sideRows = useMemo(() => visible.filter(inStudio).map(a => { + const e = d.statusFor(a), l = d.liveFor(a); + return { app: a, st: sideStatus({ att: attention(a.key, a.name, e, l), all: l?.release.all ?? [], read: !!l || e?.release.source === 'live', missing: !!a.project?.missing, storeOnly: !a.project }) }; + }), [visible, d, inStudio]); + const groups: { id: SideGroup | 'ignored'; rows: typeof sideRows }[] = [ + ...SIDE_GROUPS.map(g => ({ id: g, rows: sideRows.filter(r => r.st.group === g) })).filter(g => g.rows.length), + ...(showIgnored && ignoredApps.length ? [{ id: 'ignored' as const, rows: ignoredApps.filter(inStudio).map(a => ({ app: a, st: { group: 'draft' as SideGroup, text: t('side.ignored'), tone: 'neutral' as const } })) }] : []), + ]; + // Alt+Up / Alt+Down: the previous / next app in sidebar order (keeps the place when the other app has it) + const sideOrder = groups.flatMap(x => (collapsed.includes(`g:${x.id}`) ? [] : x.rows.map(r => r.app))); const stepApp = useCallback((dir: 1 | -1) => { if (!sideOrder.length) return; const i = route.screen === 'app' ? sideOrder.findIndex(a => a.key === route.appId) : -1; const next = sideOrder[i < 0 ? (dir === 1 ? 0 : sideOrder.length - 1) : (i + dir + sideOrder.length) % sideOrder.length]!; - go({ screen: 'app', appId: next.key, tab: route.screen === 'app' && tabsFor(!!next.project).includes(route.tab) ? route.tab : 'releases' }); - }, [sideOrder, route, go]); + go({ screen: 'app', appId: next.key, tab: route.screen === 'app' && tabsOf(next).includes(route.tab) ? route.tab : 'releases' }); + }, [sideOrder, route, go, tabsOf]); const toggleGroup = (id: string) => setCollapsed(c => { const next = c.includes(id) ? c.filter(x => x !== id) : [...c, id]; try { localStorage.setItem(SIDE_KEY, JSON.stringify(next)); } catch { /* private mode */ } @@ -94,9 +110,12 @@ export function App({ onTheme }: { onTheme: () => void }) { }, [go, d.reload]); const app = route.screen === 'app' ? d.appByKey(route.appId) : undefined; - const tabs = app ? tabsFor(!!app.project) : []; + const game = app ? gameOf(app) : false; + const tabs = app ? tabsOf(app) : []; + const sections = app ? sectionsFor(!!app.project, game) : []; - // keyboard: Cmd/Ctrl+K palette, Cmd/Ctrl+1 portfolio, 2-6 workspace, Cmd/Ctrl+Shift+[ ] app tabs + // keyboard: Cmd/Ctrl+K palette, Cmd/Ctrl+1 portfolio, 2-6 workspace, 1-5 (no modifier) the app's sections, + // Cmd/Ctrl+Shift+[ ] the previous / next section useEffect(() => { const onKey = (e: KeyboardEvent) => { const mod = e.metaKey || e.ctrlKey; @@ -109,15 +128,20 @@ export function App({ onTheme }: { onTheme: () => void }) { go(n === 1 ? { screen: 'portfolio' } : { screen: WORKSPACE[n - 2]! }); return; } - if (mod && e.shiftKey && (e.key === '[' || e.key === ']' || e.key === '{' || e.key === '}') && route.screen === 'app' && tabs.length) { + if (!mod && !e.shiftKey && !e.altKey && /^[1-9]$/.test(e.key) && route.screen === 'app' && app && !isEditable(e.target as HTMLInputElement) && !document.querySelector('.ds-menu, .ds-dialog')) { + const sec = sections[Number(e.key) - 1]; + if (sec) { e.preventDefault(); go({ ...route, tab: subsOf(sec, !!app.project, game)[0]! }); } + return; + } + if (mod && e.shiftKey && (e.key === '[' || e.key === ']' || e.key === '{' || e.key === '}') && route.screen === 'app' && app && sections.length) { e.preventDefault(); - const i = tabs.indexOf(route.tab) + (e.key === ']' || e.key === '}' ? 1 : -1); - go({ ...route, tab: tabs[(i + tabs.length) % tabs.length]! }); + const i = sections.indexOf(sectionOf(route.tab)) + (e.key === ']' || e.key === '}' ? 1 : -1); + go({ ...route, tab: subsOf(sections[(i + sections.length) % sections.length]!, !!app.project, game)[0]! }); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [palette, welcome, route, go, tabs, stepApp]); + }, [palette, welcome, route, go, app, sections, game, stepApp]); const closeWelcome = useCallback(() => { setWelcome(false); @@ -131,13 +155,18 @@ export function App({ onTheme }: { onTheme: () => void }) { const G = { apps: t('palette.gApps'), go: t('palette.gGo'), tabs: t('palette.gTabs'), act: t('palette.gActions'), exp: t('palette.gExport') }; const studioName = (a: UnifiedApp) => d.studios.find(s => s.id === a.studio)?.name; for (const a of visible) c.push({ id: `app-${a.key}`, group: G.apps, title: a.name, hint: studioName(a) ?? t('palette.openApp'), keywords: `${a.ios?.app.bundleId ?? ''} ${a.android?.app.id ?? ''} ${a.project?.name ?? ''}`, app: { name: a.name, icon: d.iconFor(a) }, - run: () => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsFor(!!a.project).includes(route.tab) ? route.tab : 'releases' }) }); + run: () => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsOf(a).includes(route.tab) ? route.tab : 'releases' }) }); c.push({ id: 'go-next-app', group: G.go, title: t('palette.nextApp'), icon: 'arrowRight', hint: 'Alt ↓', keywords: 'next app down j', run: () => stepApp(1) }); c.push({ id: 'go-prev-app', group: G.go, title: t('palette.prevApp'), icon: 'arrowLeft', hint: 'Alt ↑', keywords: 'previous app up k', run: () => stepApp(-1) }); c.push({ id: 'go-portfolio', group: G.go, title: t('nav.portfolio'), icon: 'portfolio', hint: `${MOD} 1`, run: () => go({ screen: 'portfolio' }) }); WORKSPACE.forEach((s, i) => c.push({ id: `go-${s}`, group: G.go, title: t(WS_KEY[s]), icon: WS_ICON[s], hint: `${MOD} ${i + 2}`, run: () => go({ screen: s }) })); for (const s of d.studios) c.push({ id: `studio-${s.id}`, group: G.go, title: s.name, icon: 'building', keywords: 'studio', hint: t('palette.studio'), run: () => go({ screen: 'accounts', section: s.id }) }); - if (app) for (const tab of tabs) c.push({ id: `tab-${tab}`, group: G.tabs, title: `${app.name} · ${t(TAB_KEY[tab])}`, icon: TAB_ICON[tab], run: () => go({ screen: 'app', appId: app.key, tab }) }); + // every section of the open app (1-5) and every sub view in it + if (app) sections.forEach((sec, i) => { + const subs = subsOf(sec, !!app.project, game); + c.push({ id: `sec-${sec}`, group: G.tabs, title: `${app.name} · ${t(SECTION_KEY[sec])}`, icon: SECTION_ICON[sec], hint: String(i + 1), keywords: subs.map(x => t(TAB_KEY[x])).join(' '), run: () => go({ screen: 'app', appId: app.key, tab: subs[0]! }) }); + if (subs.length > 1) for (const tab of subs) c.push({ id: `tab-${tab}`, group: G.tabs, title: `${app.name} · ${t(SECTION_KEY[sec])} · ${t(TAB_KEY[tab])}`, icon: TAB_ICON[tab], run: () => go({ screen: 'app', appId: app.key, tab }) }); + }); // the app's own menu (consoles, folder, copy ids, reads, ignore, unpair) as commands: every right-click action is // also one Ctrl+K away; opening a tab is already above if (app) { @@ -166,10 +195,10 @@ export function App({ onTheme }: { onTheme: () => void }) { c.push({ id: 'act-theme', group: G.act, title: t('action.toggleTheme'), icon: 'moon', keywords: 'dark light', run: onTheme }); c.push({ id: 'act-welcome', group: G.act, title: t('action.welcome'), icon: 'sparkle', keywords: 'onboarding start guide', run: () => setWelcome(true) }); return c; - }, [d, visible, app, tabs, route, go, onTheme, exports, runExport, showIgnored, stepApp, prefs, update]); + }, [d, visible, app, sections, game, route, go, onTheme, exports, runExport, showIgnored, stepApp, prefs, update, tabsOf]); const crumbs: { label: string; to?: Route }[] = route.screen === 'portfolio' ? [{ label: t('nav.portfolio') }] - : route.screen === 'app' ? [{ label: t('nav.portfolio'), to: { screen: 'portfolio' } }, { label: app?.name ?? '', to: app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined }, { label: t(TAB_KEY[route.tab]) }] + : route.screen === 'app' ? [{ label: t('nav.portfolio'), to: { screen: 'portfolio' } }, { label: app?.name ?? '', to: app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined }, { label: t(SECTION_KEY[sectionOf(route.tab)]), to: app ? { screen: 'app', appId: app.key, tab: subsOf(sectionOf(route.tab), !!app.project, game)[0] ?? route.tab } : undefined }, ...(app && subsOf(sectionOf(route.tab), !!app.project, game).length > 1 ? [{ label: t(TAB_KEY[route.tab]) }] : [])] : [{ label: t('nav.workspace') }, { label: t(WS_KEY[route.screen]) }]; const lastRead = d.live?.at ?? null; // the app on screen is read first by the background reader @@ -180,26 +209,19 @@ export function App({ onTheme }: { onTheme: () => void }) { const ignoredCount = d.apps.filter(a => a.ignored).length + d.overrides.ignored.filter(i => i.startsWith('account:')).length; const menu = useContextMenu(); - const appItem = (a: UnifiedApp) => { - const e = d.statusFor(a); - const missing = !!a.project?.missing; - const tone = missing ? 'bad' : (worstTone(attention(a.key, a.name, e, d.liveFor(a))) ?? (e ? 'ok' : null)); - const bad = missing ? 1 : (e?.errors.length ?? 0) + (e?.check?.problems.length ?? 0); - return ( -
- -
- ); - }; + const appItem = (a: UnifiedApp, st: { text: string; tone: string }) => ( +
+ +
+ ); + const WS_ROW: (WorkspaceScreen | 'portfolio')[] = ['portfolio', ...WORKSPACE]; return ( @@ -213,36 +235,41 @@ export function App({ onTheme }: { onTheme: () => void }) {
- -
{d.studios.length ? t('nav.studiosLabel') : t('nav.appsLabel')} +
{t('nav.appsLabel')} {visible.length}
+ {d.studios.length > 1 && ( +
+ + {d.studios.map(s => )} +
+ )} {groups.map(g => { - const s = d.studios.find(x => x.id === g.studio) ?? null; - const gid = g.studio ?? 'none'; + const gid = `g:${g.id}`; const open = !collapsed.includes(gid); - if (!d.studios.length) return
{g.rows.map(r => appItem(r.app))}
; return ( -
- - {open &&
{g.rows.map(r => appItem(r.app))}
} + {open &&
{g.rows.map(r => appItem(r.app, r.st))}
}
); })} {!visible.length && !showIgnored &&

{t('nav.noApps')}

} {ignoredApps.length > 0 && } -
{t('nav.workspace')}
- {WORKSPACE.map((s, i) => ( - - ))} +
+
+ {WS_ROW.map((s, i) => { + const label = s === 'portfolio' ? t('nav.portfolio') : t(WS_KEY[s]); + const n = s === 'accounts' ? d.accounts.length : s === 'ignored' ? ignoredCount : 0; + return ( + + ); + })}
diff --git a/apps/desktop/src/design.css b/apps/desktop/src/design.css index fc782ca..adfc21f 100644 --- a/apps/desktop/src/design.css +++ b/apps/desktop/src/design.css @@ -245,3 +245,45 @@ .table-wrap .table tbody tr:hover > :first-child, .ds-table-wrap .table tbody tr:hover > :first-child { background: color-mix(in srgb, var(--tint, var(--accent-solid)) 6%, var(--ds-surface, var(--surface))); } /* Settings > Appearance > Tables: compact */ :root[data-density="compact"] .table th, :root[data-density="compact"] .table td { padding-top: 5px; padding-bottom: 5px; padding-left: 8px; padding-right: 8px; font-size: 12.5px; } + +/* ---------- App sections (docs IA): section header, next step, sub views ---------- */ +.ds-tab-k { font: 600 10px/1 var(--mono, ui-monospace, monospace); min-width: 16px; height: 16px; display: inline-grid; place-items: center; border-radius: 4px; color: var(--muted); border: 1px solid var(--ds-stroke); background: var(--ds-surface); margin-left: 2px; opacity: .75; } +.ds-tab[aria-selected="true"] .ds-tab-k { opacity: 1; } +.sec-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "t next" "subs subs"; gap: 10px 16px; align-items: center; padding: 2px 4px 4px; } +.sec-head-t { grid-area: t; min-width: 0; } +.sec-head-name { display: flex; align-items: center; gap: 8px; margin: 0; font-size: var(--ds-t-h2, 18px); font-weight: 700; letter-spacing: -.01em; } +.sec-head-name svg { width: 18px; height: 18px; color: var(--tint, var(--accent-solid)); } +.sec-head-for { margin: 2px 0 0; color: var(--muted); font-size: var(--t-meta); } +.sec-next { grid-area: next; display: inline-flex; align-items: center; gap: 8px; padding: 6px 6px 6px 12px; min-height: 32px; box-sizing: border-box; border-radius: var(--ds-r); border: 1px solid var(--ds-stroke); background: var(--ds-surface); font-weight: 600; font-size: var(--t-meta); max-width: 560px; } +.sec-next > svg { width: 15px; height: 15px; flex: none; } +.sec-next-t { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sec-next:not(:has(.btn)) { padding-right: 12px; } +.sec-next.tone-bad { background: var(--bad-bg); color: var(--bad-ink); border-color: transparent; } +.sec-next.tone-warn { background: var(--warn-bg); color: var(--warn-ink); border-color: transparent; } +.sec-next.tone-ok > svg { color: var(--ok-solid); } +.sec-next.tone-info > svg { color: var(--accent-solid); } +.sec-next .btn { flex: none; } +.sec-subs { grid-area: subs; justify-self: start; } +.sec-subs .ed-dot { margin-left: 2px; } +@media (max-width: 1100px) { .sec-head { grid-template-columns: 1fr; grid-template-areas: "t" "next" "subs"; } .sec-next { justify-self: start; } } + +/* ---------- Sidebar: attention groups, one status line per app, workspace footer ---------- */ +.nav-app.has-line { height: auto; min-height: 40px; padding-top: 4px; padding-bottom: 4px; align-items: center; } +.nav-app .side-t { display: grid; min-width: 0; line-height: 1.2; } +.nav-app .side-n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.nav-app .side-s { font-size: 11px; font-weight: 500; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.nav-app .side-s.bad { color: var(--bad-ink); } .nav-app .side-s.warn { color: var(--warn-ink); } .nav-app .side-s.ok { color: var(--ok-ink); } +:root[data-theme="dark"] .nav-app .side-s.bad { color: color-mix(in srgb, var(--bad-solid) 70%, white); } +:root[data-theme="dark"] .nav-app .side-s.warn { color: color-mix(in srgb, var(--warn-solid) 70%, white); } +:root[data-theme="dark"] .nav-app .side-s.ok { color: color-mix(in srgb, var(--ok-solid) 70%, white); } +.side-studios { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 6px 2px; } +.side-studios button { all: unset; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; height: 22px; padding: 0 8px; border-radius: 99px; font-size: 11px; font-weight: 600; color: var(--muted); border: 1px solid var(--line); } +.side-studios button[aria-pressed="true"] { color: var(--ink); background: var(--surface); box-shadow: var(--shadow-1); } +.side-studios button:focus-visible { outline: 2px solid var(--focus); } +.side-ws { display: flex; gap: 2px; padding: 6px 8px; border-top: 1px solid var(--line); } +.side-ws button { all: unset; cursor: pointer; position: relative; flex: 1; height: 30px; display: grid; place-items: center; border-radius: 7px; color: var(--muted); } +.side-ws button:hover { background: var(--hover); color: var(--ink); } +.side-ws button[aria-current="page"] { background: var(--surface); color: var(--ink); box-shadow: var(--shadow-1); } +.side-ws button:focus-visible { outline: 2px solid var(--focus); } +.side-ws svg { width: 17px; height: 17px; } +.side-ws .ws-n { position: absolute; top: 2px; right: 4px; font-size: 9.5px; font-weight: 700; color: var(--muted); font-variant-numeric: tabular-nums; } diff --git a/apps/desktop/src/dev/fixtures.ts b/apps/desktop/src/dev/fixtures.ts index 4ba99d4..7e7be9e 100644 --- a/apps/desktop/src/dev/fixtures.ts +++ b/apps/desktop/src/dev/fixtures.ts @@ -430,6 +430,16 @@ const GAMES_PRISM = { play: { appId: '123456789012', resource: '123456789012 (android/app/src/main/res/values/games-ids.xml)', leaderboards: 2, achievements: 5, config: { leaderboards: 2, achievements: 5 }, changes: [] }, findings: [{ store: 'apple', level: 'ok', id: 'gc.entitlement', msg: 'com.apple.developer.game-center in ios/App/App/App.entitlements' }, { store: 'play', level: 'ok', id: 'pgs.app-id', msg: 'games_APP_ID 123456789012 matches the config' }], links: [{ label: 'Play Console: Play Games Services configuration', url: 'https://play.google.com/console/u/0/developers/4444/app/9876/play-games-services/setup' }], + items: [ + { kind: 'leaderboard', id: 'com.example.prismpuddle.best', name: 'Best score', locales: ['en', 'tr', 'ja', 'ar'], ios: true, android: true, + apple: { action: 'keep', changed: [], locales: [{ locale: 'en-US', action: 'keep' }, { locale: 'tr', action: 'keep' }, { locale: 'ja', action: 'keep' }, { locale: 'ar-SA', action: 'keep' }] }, play: { action: 'keep', changed: [] } }, + { kind: 'leaderboard', id: 'com.example.prismpuddle.daily', name: 'Daily puddle', locales: ['en', 'tr'], ios: true, android: true, + apple: { action: 'create', changed: ['referenceName'], locales: [{ locale: 'en-US', action: 'create' }, { locale: 'tr', action: 'create' }] }, play: { action: 'keep', changed: [] } }, + ...['first-drop', 'ten-levels', 'no-hints', 'rainbow', 'speedrun'].map((k, i) => ({ kind: 'achievement', id: `com.example.prismpuddle.${k}`, name: ['First drop', 'Ten levels', 'No hints', 'Rainbow', 'Speedrun'][i]!, locales: ['en', 'tr', 'ja', 'ar'], points: [10, 20, 30, 20, 20][i], hidden: k === 'speedrun', ios: true, android: true, + apple: { action: 'keep', changed: [], locales: [{ locale: 'en-US', action: 'keep' }] }, play: { action: i === 2 ? 'update' : 'keep', changed: i === 2 ? ['draft.description'] : [] } })), + ], + localeMap: { configured: { ar: 'ar-SA' }, suggested: [{ id: 'en', from: 'en-US', to: 'en-GB' }] }, + playApiDisabled: null, }; const ok = (method: string, data: unknown): ToolResult => ({ method, ok: true, exitCode: 0, dryRun: false, command: `storebound ${method.replace('.', ' ')} --json`, output: '', data }); diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 3e3d6ba..c4f37ed 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2116,4 +2116,106 @@ export const en = { 'cm.copyPrincipal': "Service account / key", 'cm.copyProfile': "Profile name", 'cm.openAccounts': "Open Studios & accounts", + "sec.release": "Release", + "sec.store": "Store page", + "sec.revenue": "Revenue", + "sec.players": "Players", + "sec.health": "Health", + "sec.release.for": "Get a version to players on both stores: builds, TestFlight, review and rollout.", + "sec.store.for": "What people see on the store: texts per language, screenshots and app details.", + "sec.revenue.for": "What the app sells and for how much: in-app products, subscriptions and prices.", + "sec.players.for": "What players say, and for games what they can earn: reviews, Game Center, Play Games.", + "sec.health.for": "Is the app well and ready: crashes and ANRs, growth, checks to fix first, more tools.", + "sec.views": "{section} views", + "tab.games": "Game services", + "next.open": "Open {where}", + "next.rejected": "{version} was rejected on {store}", + "next.halted": "{version} is halted on {store}", + "next.blockers": "{count} blockers before submit", + "next.blockers_one": "1 blocker before submit", + "next.rollout": "{version} is rolling out at {share}", + "next.approved": "{version} is approved: release it", + "next.submit": "Submit {version} for review", + "next.review": "{version} is in review", + "next.managed": "{version} waits in Publishing overview (managed publishing)", + "next.upload": "Upload a first build", + "next.live": "Live: {version}", + "next.notRead": "Stores not read yet", + "next.storeLive": "The live store page, read from the stores", + "next.listingProblems": "{count} listing problems", + "next.listingProblems_one": "1 listing problem", + "next.unsaved": "{count} unsaved edits", + "next.unsaved_one": "1 unsaved edit", + "next.listingsUnread": "Listings not checked yet", + "next.storeOk": "Store page in step with the config", + "next.catalogDiff": "{count} products differ between the stores", + "next.catalogDiff_one": "1 product differs between the stores", + "next.noProducts": "No products yet: add one", + "next.products": "{products} products · {subs} subscriptions", + "next.reviews": "{count} reviews without a reply", + "next.reviews_one": "1 review without a reply", + "next.reviewsUnread": "Reviews not read yet", + "next.reviewsOk": "All reviews answered", + "next.gamesApiOff": "Play Games Services API is off", + "next.gamesBlockers": "{count} game services blockers", + "next.gamesBlockers_one": "1 game services blocker", + "next.gamesSync": "Game services: {count} changes to sync", + "next.gamesSync_one": "Game services: 1 change to sync", + "next.fixFirst": "{count} problems to fix first", + "next.fixFirst_one": "1 problem to fix first", + "next.warnings": "{count} warnings to look at", + "next.warnings_one": "1 warning to look at", + "next.healthOk": "No problems found", + "side.gAttention": "Needs attention", + "side.gProgress": "In progress", + "side.gLive": "Live", + "side.gDraft": "Not on a store yet", + "side.missing": "Folder missing", + "side.rejected": "Rejected · {what}", + "side.halted": "Halted · {what}", + "side.blockers": "{count} blockers", + "side.blockers_one": "1 blocker", + "side.rollout": "Rollout {share} · {what}", + "side.review": "In review · {what}", + "side.approved": "Approved · {what}", + "side.draft": "Draft · {what}", + "side.live": "Live · {version}", + "side.liveWarn": "Live · {version} · {count} warnings", + "side.liveWarn_one": "Live · {version} · 1 warning", + "side.storeOnly": "Store only · not read yet", + "side.notRead": "Not read yet", + "side.warnings": "{count} warnings", + "side.warnings_one": "1 warning", + "side.notOnStore": "No store version yet", + "side.ignored": "Ignored", + "side.studioFilter": "Show apps of a studio", + "side.allStudios": "All", + "games.keep": "In step", + "games.update": "Differs", + "games.create": "Missing", + "games.notOnStore": "Not on this store", + "games.notListed": "Not planned", + "games.notRead": "Not read", + "games.item": "Item", + "games.name": "Name", + "games.points": "Points", + "games.languages": "Languages", + "games.leaderboard": "Leaderboard", + "games.achievement": "Achievement", + "games.hidden": "hidden until earned", + "games.localeChanges": "Game Center languages: {list}", + "games.checkAgain": "Check again", + "games.apiOffTitle": "Play Games Services API is off.", + "games.apiOffText": "Turn on {api} in the service account's Google Cloud project, wait a minute, then check again.", + "games.itemsTitle": "Leaderboards and achievements", + "games.itemsSub": "One list in the config, mapped to both stores: each store's state against the config.", + "games.refresh": "Read again", + "games.noItems": "No leaderboards or achievements in the config yet (games.leaderboards, games.achievements).", + "games.notReadYet": "Reading game services…", + "games.images": "Images of leaderboards and achievements are added in the consoles; on Play the existing icon is kept on every sync.", + "games.localeMap": "Game Center locale map", + "games.localeMapSub": "Config language to the locale the live Game Center uses (games.localeMap).", + "games.mapSet": "Set", + "games.mapSuggested": "Suggested", + "games.mapHint": "Add to games.localeMap: {entries}", } as const; diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index 8901d14..e19a2e7 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -2118,4 +2118,106 @@ export const tr: Record = { 'cm.copyPrincipal': "Hizmet hesabı / anahtar", 'cm.copyProfile': "Profil adı", 'cm.openAccounts': "Stüdyolar ve hesapları aç", + "sec.release": "Yayın", + "sec.store": "Mağaza sayfası", + "sec.revenue": "Gelir", + "sec.players": "Oyuncular", + "sec.health": "Sağlık", + "sec.release.for": "Bir sürümü iki mağazada oyunculara ulaştır: build, TestFlight, inceleme ve dağıtım.", + "sec.store.for": "Mağazada insanların gördüğü: dile göre metinler, ekran görüntüleri ve uygulama bilgileri.", + "sec.revenue.for": "Uygulamanın sattıkları ve fiyatları: uygulama içi ürünler, abonelikler ve fiyatlar.", + "sec.players.for": "Oyuncuların söyledikleri ve oyunlarda kazanabilecekleri: yorumlar, Game Center, Play Games.", + "sec.health.for": "Uygulama sağlıklı ve hazır mı: çökmeler ve ANR, büyüme, önce düzeltilecek kontroller, diğer araçlar.", + "sec.views": "{section} görünümleri", + "tab.games": "Oyun servisleri", + "next.open": "{where} aç", + "next.rejected": "{version}, {store} tarafından reddedildi", + "next.halted": "{version}, {store} üzerinde durduruldu", + "next.blockers": "Göndermeden önce {count} engel", + "next.blockers_one": "Göndermeden önce 1 engel", + "next.rollout": "{version} dağıtımı {share} seviyesinde", + "next.approved": "{version} onaylandı: yayınla", + "next.submit": "{version} sürümünü incelemeye gönder", + "next.review": "{version} incelemede", + "next.managed": "{version} Yayınlama özeti'nde bekliyor (yönetilen yayınlama)", + "next.upload": "İlk build'i yükle", + "next.live": "Yayında: {version}", + "next.notRead": "Mağazalar henüz okunmadı", + "next.storeLive": "Mağazalardan okunan canlı mağaza sayfası", + "next.listingProblems": "{count} mağaza metni sorunu", + "next.listingProblems_one": "1 mağaza metni sorunu", + "next.unsaved": "{count} kaydedilmemiş düzenleme", + "next.unsaved_one": "1 kaydedilmemiş düzenleme", + "next.listingsUnread": "Mağaza metinleri henüz kontrol edilmedi", + "next.storeOk": "Mağaza sayfası yapılandırmayla uyumlu", + "next.catalogDiff": "{count} ürün mağazalar arasında farklı", + "next.catalogDiff_one": "1 ürün mağazalar arasında farklı", + "next.noProducts": "Henüz ürün yok: bir tane ekle", + "next.products": "{products} ürün · {subs} abonelik", + "next.reviews": "{count} yorum yanıtsız", + "next.reviews_one": "1 yorum yanıtsız", + "next.reviewsUnread": "Yorumlar henüz okunmadı", + "next.reviewsOk": "Tüm yorumlar yanıtlandı", + "next.gamesApiOff": "Play Games Services API kapalı", + "next.gamesBlockers": "{count} oyun servisi engeli", + "next.gamesBlockers_one": "1 oyun servisi engeli", + "next.gamesSync": "Oyun servisleri: eşitlenecek {count} değişiklik", + "next.gamesSync_one": "Oyun servisleri: eşitlenecek 1 değişiklik", + "next.fixFirst": "Önce düzeltilecek {count} sorun", + "next.fixFirst_one": "Önce düzeltilecek 1 sorun", + "next.warnings": "Bakılacak {count} uyarı", + "next.warnings_one": "Bakılacak 1 uyarı", + "next.healthOk": "Sorun bulunmadı", + "side.gAttention": "İlgi bekliyor", + "side.gProgress": "Sürüyor", + "side.gLive": "Yayında", + "side.gDraft": "Henüz mağazada değil", + "side.missing": "Klasör bulunamadı", + "side.rejected": "Reddedildi · {what}", + "side.halted": "Durduruldu · {what}", + "side.blockers": "{count} engel", + "side.blockers_one": "1 engel", + "side.rollout": "Dağıtım {share} · {what}", + "side.review": "İncelemede · {what}", + "side.approved": "Onaylandı · {what}", + "side.draft": "Taslak · {what}", + "side.live": "Yayında · {version}", + "side.liveWarn": "Yayında · {version} · {count} uyarı", + "side.liveWarn_one": "Yayında · {version} · 1 uyarı", + "side.storeOnly": "Yalnız mağaza · henüz okunmadı", + "side.notRead": "Henüz okunmadı", + "side.warnings": "{count} uyarı", + "side.warnings_one": "1 uyarı", + "side.notOnStore": "Henüz mağaza sürümü yok", + "side.ignored": "Yok sayıldı", + "side.studioFilter": "Bir stüdyonun uygulamalarını göster", + "side.allStudios": "Tümü", + "games.keep": "Uyumlu", + "games.update": "Farklı", + "games.create": "Eksik", + "games.notOnStore": "Bu mağazada değil", + "games.notListed": "Planlanmadı", + "games.notRead": "Okunmadı", + "games.item": "Öğe", + "games.name": "Ad", + "games.points": "Puan", + "games.languages": "Diller", + "games.leaderboard": "Skor tablosu", + "games.achievement": "Başarım", + "games.hidden": "kazanılana kadar gizli", + "games.localeChanges": "Game Center dilleri: {list}", + "games.checkAgain": "Yeniden kontrol et", + "games.apiOffTitle": "Play Games Services API kapalı.", + "games.apiOffText": "Hizmet hesabının Google Cloud projesinde {api} açın, bir dakika bekleyin, sonra yeniden kontrol edin.", + "games.itemsTitle": "Skor tabloları ve başarımlar", + "games.itemsSub": "Yapılandırmada tek liste, iki mağazaya eşlenir: her mağazanın yapılandırmaya göre durumu.", + "games.refresh": "Yeniden oku", + "games.noItems": "Yapılandırmada henüz skor tablosu veya başarım yok (games.leaderboards, games.achievements).", + "games.notReadYet": "Oyun servisleri okunuyor…", + "games.images": "Skor tablosu ve başarım görselleri konsollarda eklenir; Play'de mevcut simge her eşitlemede korunur.", + "games.localeMap": "Game Center dil eşlemesi", + "games.localeMapSub": "Yapılandırma dilinden canlı Game Center'ın kullandığı dile (games.localeMap).", + "games.mapSet": "Ayarlı", + "games.mapSuggested": "Önerilen", + "games.mapHint": "games.localeMap'e ekleyin: {entries}", }; diff --git a/apps/desktop/src/lib/model.ts b/apps/desktop/src/lib/model.ts index e1979c0..a104d02 100644 --- a/apps/desktop/src/lib/model.ts +++ b/apps/desktop/src/lib/model.ts @@ -2,7 +2,7 @@ // inbox stats, catalog parity matrix, the portfolio's "needs attention" list, and the command palette matcher. // "Unknown, never false": every derivation keeps "not read" apart from "none" / "ok". import { plainError } from '../../../../cli/lib/google-errors'; -import type { AppStatus, ProjectSummary } from '../api'; +import type { AppStatus, ProjectSummary, StoreRelease } from '../api'; import { t } from '../i18n'; import type { Tone } from '../../../../cli/lib/train'; @@ -138,7 +138,7 @@ export function parityMatrix(s: ProjectSummary | null, diff: CatalogDiff | null) // ---- Portfolio: needs attention ---- -export interface Attention { app: string; projectId: string; tone: Tone; text: string; hint?: string; tab?: 'releases' | 'catalog' | 'listings' | 'health' | 'reviews' | 'testflight' | 'checks' } +export interface Attention { app: string; projectId: string; tone: Tone; text: string; hint?: string; tab?: 'releases' | 'catalog' | 'listings' | 'health' | 'reviews' | 'testflight' | 'checks' | 'games' } export function attention(projectId: string, name: string, e: AppStatus | undefined, live: AppStatus | undefined, health?: HealthSnapshot | null): Attention[] { const out: Attention[] = []; @@ -203,3 +203,46 @@ export function hueOf(name: string): number { for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0; return h % 360; } + +// ---- App kind and the sidebar ---- + +const PLAY_GAME_CATS = ['Action', 'Adventure', 'Arcade', 'Board', 'Card', 'Casino', 'Casual', 'Educational', 'Music', 'Puzzle', 'Racing', 'Role Playing', 'Simulation', 'Sports', 'Strategy', 'Trivia', 'Word']; +/** A game: profile game, a games section in the config, or a Play game category. */ +export function isGameSummary(s: ProjectSummary | null | undefined): boolean { + if (!s) return false; + return s.profile === 'game' || !!s.games || PLAY_GAME_CATS.includes(s.playRelease?.category ?? ''); +} + +export type SideGroup = 'attention' | 'progress' | 'live' | 'draft'; +export interface SideStatus { group: SideGroup; text: string; tone: Tone } + +/** One sidebar row's group and status line: the one thing worth knowing about the app (docs IA). */ +export function sideStatus(o: { att: Attention[]; all: StoreRelease[]; read: boolean; missing: boolean; storeOnly: boolean }): SideStatus { + const sn = (r: StoreRelease) => `${r.store === 'ios' ? 'iOS' : 'Play'} ${r.version ?? ''}`.trim(); + if (o.missing) return { group: 'attention', text: t('side.missing'), tone: 'bad' }; + const rej = o.all.find(r => r.phase === 'rejected'); + if (rej) return { group: 'attention', text: t('side.rejected', { what: sn(rej) }), tone: 'bad' }; + const halted = o.all.find(r => r.phase === 'halted'); + if (halted) return { group: 'attention', text: t('side.halted', { what: sn(halted) }), tone: 'bad' }; + const bad = o.att.filter(a => a.tone === 'bad').length; + if (bad) return { group: 'attention', text: t('side.blockers', { count: bad }), tone: 'bad' }; + const crash = o.att.find(a => a.tab === 'health'); + if (crash) return { group: 'attention', text: crash.text, tone: crash.tone }; + const rolling = o.all.find(r => r.phase === 'rollout' && r.fraction !== null && r.fraction < 1); + if (rolling) return { group: 'progress', text: t('side.rollout', { share: `${Math.round((rolling.fraction ?? 0) * 100)} %`, what: sn(rolling) }), tone: 'info' }; + const review = o.all.find(r => r.phase === 'review' || r.phase === 'sent'); + if (review) return { group: 'progress', text: t('side.review', { what: sn(review) }), tone: 'info' }; + const approved = o.all.find(r => r.phase === 'approved'); + if (approved) return { group: 'progress', text: t('side.approved', { what: sn(approved) }), tone: 'info' }; + const live = o.all.filter(r => r.phase === 'live' || r.phase === 'released' || r.phase === 'rollout'); + const draft = o.all.find(r => r.phase === 'draft' || r.phase === 'testing'); + if (draft && !live.length) return { group: 'progress', text: t('side.draft', { what: sn(draft) }), tone: 'neutral' }; + const warn = o.att.filter(a => a.tone === 'warn').length; + if (live.length) { + const vs = [...new Set(live.map(r => r.version).filter(Boolean))]; + return { group: 'live', text: warn ? t('side.liveWarn', { version: vs.join(' / '), count: warn }) : t('side.live', { version: vs.join(' / ') }), tone: warn ? 'warn' : 'ok' }; + } + if (!o.read) return { group: o.storeOnly ? 'live' : 'draft', text: o.storeOnly ? t('side.storeOnly') : t('side.notRead'), tone: 'neutral' }; + return { group: 'draft', text: warn ? t('side.warnings', { count: warn }) : t('side.notOnStore'), tone: warn ? 'warn' : 'neutral' }; +} +export const SIDE_GROUPS: SideGroup[] = ['attention', 'progress', 'live', 'draft']; diff --git a/apps/desktop/src/lib/sections.ts b/apps/desktop/src/lib/sections.ts new file mode 100644 index 0000000..5cad08e --- /dev/null +++ b/apps/desktop/src/lib/sections.ts @@ -0,0 +1,94 @@ +// The "what's next" line of each app section (docs IA): one sentence and the sub view it opens, from state that is +// already read (status, live releases, attention, cached reads). Pure, so tests pin the rules; a section never starts +// a read to fill it. +import type { CatalogParity, ListingsResult, StoreRelease } from '../api'; +import { t } from '../i18n'; +import type { AppSection, AppTab } from '../nav'; +import type { IconName } from '../ui/icons'; +import { type Attention, isGameSummary } from './model'; +import { share } from './format'; +import type { ProjectSummary } from '../api'; + +export type NextTone = 'bad' | 'warn' | 'ok' | 'info' | 'neutral'; +export interface SectionNext { text: string; tone: NextTone; tab: AppTab | null; icon: IconName } + +export interface GamesNext { changes: number; blockers: number; apiOff: boolean } +export interface SectionInput { + project: boolean; + att: Attention[]; + all: StoreRelease[]; + next: string | null; + summary: ProjectSummary | null; + catalog: CatalogParity | null; + /** Unsaved edits of the section's editors. */ + unsaved: number; + /** Cached reads (null: not read yet). */ + listings: ListingsResult | null; + reviews: { reply?: unknown }[] | null; + games: GamesNext | null; + /** Blockers of the last store readiness read (validate), null when never read. */ + readiness?: number | null; +} + +const storeName = (s: 'ios' | 'android') => (s === 'ios' ? 'App Store' : 'Google Play'); + +/** Problems of a listings read: file problems plus required fields missing or over the limit. */ +export function listingProblems(l: ListingsResult | null): number | null { + if (!l) return null; + let n = l.problems.length; + for (const loc of l.locales) for (const s of [loc.play, loc.apple]) for (const f of s?.fields ?? []) if (f.state === 'missing' || f.state === 'over') n++; + return n; +} + +export function sectionNext(section: AppSection, i: SectionInput): SectionNext { + const bad = i.att.filter(a => a.tone === 'bad'); + if (section === 'release') { + const rej = i.all.find(r => r.phase === 'rejected' || r.phase === 'halted'); + if (rej) return { text: t(rej.phase === 'rejected' ? 'next.rejected' : 'next.halted', { version: rej.version ?? '', store: storeName(rej.store) }), tone: 'bad', tab: 'releases', icon: 'alertCircle' }; + const checks = bad.filter(a => a.tab === 'checks').length + (i.readiness ?? 0); + if (checks) return { text: t('next.blockers', { count: checks }), tone: 'bad', tab: 'checks', icon: 'alertCircle' }; + const rolling = i.all.find(r => r.phase === 'rollout' && r.fraction !== null && r.fraction < 1); + if (rolling) return { text: t('next.rollout', { share: share(rolling.fraction ?? 0), version: rolling.version ?? '' }), tone: 'info', tab: 'releases', icon: 'rocket' }; + const approved = i.all.find(r => r.phase === 'approved'); + if (approved) return { text: t('next.approved', { version: approved.version ?? '' }), tone: 'info', tab: 'releases', icon: 'rocket' }; + const ready = i.all.find(r => r.phase === 'draft' || r.phase === 'testing'); + if (ready) return { text: t('next.submit', { version: ready.version ?? '' }), tone: 'info', tab: 'releases', icon: 'send' }; + const review = i.all.find(r => r.phase === 'review' || r.phase === 'sent'); + if (review) return { text: review.managed ? t('next.managed', { version: review.version ?? '' }) : t('next.review', { version: review.version ?? '' }), tone: 'neutral', tab: 'releases', icon: 'clock' }; + if (i.project && !i.next && !i.all.length) return { text: t('next.upload'), tone: 'neutral', tab: 'releases', icon: 'upload' }; + const live = i.all.find(r => r.phase === 'live' || r.phase === 'released'); + return live ? { text: t('next.live', { version: live.version ?? '' }), tone: 'ok', tab: null, icon: 'check' } : { text: t('next.notRead'), tone: 'neutral', tab: null, icon: 'clock' }; + } + if (section === 'store') { + if (!i.project) return { text: t('next.storeLive'), tone: 'neutral', tab: null, icon: 'cloud' }; + const n = listingProblems(i.listings); + if (n) return { text: t('next.listingProblems', { count: n }), tone: 'warn', tab: 'listings', icon: 'alert' }; + if (i.unsaved) return { text: t('next.unsaved', { count: i.unsaved }), tone: 'warn', tab: null, icon: 'edit' }; + return n === null ? { text: t('next.listingsUnread'), tone: 'neutral', tab: 'listings', icon: 'text' } : { text: t('next.storeOk'), tone: 'ok', tab: null, icon: 'check' }; + } + if (section === 'revenue') { + const c = i.catalog; + if (c && c.checked === 'live' && !c.ok) return { text: t('next.catalogDiff', { count: c.missingIos.length + c.missingAndroid.length + c.onlyIos.length + c.onlyAndroid.length }), tone: 'warn', tab: 'catalog', icon: 'alert' }; + const p = i.summary?.products ?? 0, s = i.summary?.subscriptions ?? 0; + if (!p && !s) return { text: t('next.noProducts'), tone: 'neutral', tab: 'catalog', icon: 'plus' }; + return { text: t('next.products', { products: p, subs: s }), tone: 'ok', tab: null, icon: 'check' }; + } + if (section === 'players') { + const open = i.reviews ? i.reviews.filter(r => !r.reply).length : null; + if (open) return { text: t('next.reviews', { count: open }), tone: 'warn', tab: 'reviews', icon: 'message' }; + if (i.games?.apiOff) return { text: t('next.gamesApiOff'), tone: 'bad', tab: 'games', icon: 'alertCircle' }; + if (i.games?.blockers) return { text: t('next.gamesBlockers', { count: i.games.blockers }), tone: 'bad', tab: 'games', icon: 'alertCircle' }; + if (i.games?.changes) return { text: t('next.gamesSync', { count: i.games.changes }), tone: 'warn', tab: 'games', icon: 'upload' }; + return open === null ? { text: t('next.reviewsUnread'), tone: 'neutral', tab: 'reviews', icon: 'message' } : { text: t('next.reviewsOk'), tone: 'ok', tab: null, icon: 'check' }; + } + const crash = i.att.find(a => a.tab === 'health'); + if (crash) return { text: crash.text, tone: crash.tone === 'bad' ? 'bad' : 'warn', tab: 'health', icon: 'activity' }; + const problems = i.att.filter(a => a.tab === 'checks'); + const fix = problems.filter(a => a.tone === 'bad').length + (i.readiness ?? 0); + if (fix) return { text: t('next.fixFirst', { count: fix }), tone: 'bad', tab: 'checks', icon: 'shield' }; + if (problems.length) return { text: t('next.warnings', { count: problems.length }), tone: 'warn', tab: 'checks', icon: 'shield' }; + return { text: t('next.healthOk'), tone: 'ok', tab: null, icon: 'check' }; +} + +/** A game: profile game, a games section in the config, or a Play game category. */ +export const isGame = (s: ProjectSummary | null | undefined): boolean => isGameSummary(s); diff --git a/apps/desktop/src/nav.ts b/apps/desktop/src/nav.ts index a6429e1..b442265 100644 --- a/apps/desktop/src/nav.ts +++ b/apps/desktop/src/nav.ts @@ -4,31 +4,58 @@ import { createContext, useContext } from 'react'; import type { Key } from './i18n'; import type { IconName } from './ui/icons'; -export const APP_TABS = ['releases', 'catalog', 'prices', 'listings', 'art', 'health', 'reviews', 'testflight', 'growth', 'appinfo', 'checks', 'tools'] as const; +export const APP_TABS = ['releases', 'testflight', 'listings', 'art', 'appinfo', 'catalog', 'prices', 'reviews', 'games', 'health', 'growth', 'checks', 'tools'] as const; export type AppTab = (typeof APP_TABS)[number]; -/** Tabs of a store-only app: store reads only (catalog, prices, app info and checks need the app folder; listings and - * store art are the live ones; Growth shows the Product Page Optimization tests only). */ -export const STORE_TABS: readonly AppTab[] = ['releases', 'listings', 'art', 'health', 'reviews', 'testflight', 'growth']; -export const tabsFor = (hasFolder: boolean): readonly AppTab[] => (hasFolder ? APP_TABS : STORE_TABS); + +/** The five sections of an app, by job (docs IA): each holds sub views (the old tabs). The route keeps the sub id. */ +export const SECTIONS = ['release', 'store', 'revenue', 'players', 'health'] as const; +export type AppSection = (typeof SECTIONS)[number]; +export const SECTION_TABS: Record = { + release: ['releases', 'testflight'], store: ['listings', 'art', 'appinfo'], revenue: ['catalog', 'prices'], players: ['reviews', 'games'], health: ['health', 'growth', 'checks', 'tools'], +}; +export const sectionOf = (tab: AppTab): AppSection => SECTIONS.find(s => SECTION_TABS[s].includes(tab)) ?? 'release'; +export const SECTION_KEY: Record = { release: 'sec.release', store: 'sec.store', revenue: 'sec.revenue', players: 'sec.players', health: 'sec.health' }; +export const SECTION_ICON: Record = { release: 'rocket', store: 'image', revenue: 'coins', players: 'users', health: 'activity' }; + +/** Sub views of a store-only app: store reads only (catalog, prices, app info, checks and tools need the app folder; + * listings and store art are the live ones; Growth shows the Product Page Optimization tests only). */ +export const STORE_TABS: readonly AppTab[] = ['releases', 'testflight', 'listings', 'art', 'reviews', 'health', 'growth']; +/** The sub views an app has: the folder decides most; Game services only for games. */ +export const tabsFor = (hasFolder: boolean, game = false): readonly AppTab[] => (hasFolder ? APP_TABS : STORE_TABS).filter(x => x !== 'games' || (game && hasFolder)); +/** The sections an app has (a section with no sub view is left out: Revenue for a store-only app). */ +export const sectionsFor = (hasFolder: boolean, game = false): AppSection[] => { const tabs = tabsFor(hasFolder, game); return SECTIONS.filter(s => SECTION_TABS[s].some(x => tabs.includes(x))); }; +export const subsOf = (s: AppSection, hasFolder: boolean, game = false): AppTab[] => { const tabs = tabsFor(hasFolder, game); return SECTION_TABS[s].filter(x => tabs.includes(x)); }; export const TAB_KEY: Record = { releases: 'tab.releases', catalog: 'tab.catalog', prices: 'tab.prices', listings: 'tab.listings', art: 'tab.art', health: 'tab.health', reviews: 'tab.reviews', - testflight: 'tab.testflight', growth: 'tab.growth', appinfo: 'tab.appinfo', checks: 'tab.checks', tools: 'tab.tools', + testflight: 'tab.testflight', growth: 'tab.growth', appinfo: 'tab.appinfo', checks: 'tab.checks', tools: 'tab.tools', games: 'tab.games', }; export const TAB_ICON: Record = { releases: 'rocket', catalog: 'tag', prices: 'coins', listings: 'text', art: 'grid', health: 'activity', reviews: 'message', testflight: 'plane', growth: 'chart', - appinfo: 'clipboard', checks: 'shield', tools: 'sliders', + appinfo: 'clipboard', checks: 'shield', tools: 'sliders', games: 'sparkle', }; + +/** A place in an app from a link: an old tab id ("prices"), a section ("revenue", its first sub view) or + * "section/sub" ("store/art"). Unknown: Release. */ +export function placeOf(v: string | undefined | null): AppTab { + if (!v) return 'releases'; + const [a, b] = v.split('/'); + if (b && (SECTIONS as readonly string[]).includes(a!) && SECTION_TABS[a as AppSection].includes(b as AppTab)) return b as AppTab; + if (APP_TABS.includes(a as AppTab)) return a as AppTab; + if ((SECTIONS as readonly string[]).includes(a!)) return SECTION_TABS[a as AppSection][0]!; + return 'releases'; +} + export const WORKSPACE = ['apps', 'accounts', 'ignored', 'dependencies', 'settings'] as const; export type WorkspaceScreen = (typeof WORKSPACE)[number]; export type Route = { screen: 'portfolio' } | { screen: 'app'; appId: string; tab: AppTab } | { screen: WorkspaceScreen; section?: string }; -/** storebound:// deep links and the dev-only start switch ("app:health" = first app, Health tab; - * "app:health:2" = second app; "settings:refresh" = a Settings section). `apps` are the app keys in list order. */ +/** storebound:// deep links and the dev-only start switch ("app:health" = first app, Health section; + * "app:store/art" = a sub view; "app:prices" = an old tab id; "app:health:2" = second app; "settings:refresh" = a Settings section). `apps` are the app keys in list order. */ export function parseStart(v: string, apps: string[]): Route | null { const [screen, tab, n] = v.split(':'); const app = apps[Math.max(0, (Number(n) || 1) - 1)]; - if (screen === 'app' && app) return { screen: 'app', appId: app, tab: APP_TABS.includes(tab as AppTab) ? tab as AppTab : 'releases' }; + if (screen === 'app' && app) return { screen: 'app', appId: app, tab: placeOf(tab) }; if (screen === 'portfolio') return { screen: 'portfolio' }; if (!(WORKSPACE as readonly string[]).includes(screen ?? '')) return null; return tab ? { screen: screen as WorkspaceScreen, section: tab } : { screen: screen as WorkspaceScreen }; @@ -41,7 +68,7 @@ export function parseRouteJson(v: string): Route | null { if (!o || typeof o !== 'object') return null; const r = o as Record; if (r.screen === 'portfolio') return { screen: 'portfolio' }; - if (r.screen === 'app') return typeof r.appId === 'string' && r.appId ? { screen: 'app', appId: r.appId, tab: APP_TABS.includes(r.tab as AppTab) ? r.tab as AppTab : 'releases' } : null; + if (r.screen === 'app') return typeof r.appId === 'string' && r.appId ? { screen: 'app', appId: r.appId, tab: placeOf(typeof r.tab === 'string' ? r.tab : null) } : null; if (typeof r.screen === 'string' && (WORKSPACE as readonly string[]).includes(r.screen)) return typeof r.section === 'string' ? { screen: r.screen as WorkspaceScreen, section: r.section } : { screen: r.screen as WorkspaceScreen }; return null; } diff --git a/apps/desktop/src/screens/AppView.tsx b/apps/desktop/src/screens/AppView.tsx index 1736d8d..1a8424d 100644 --- a/apps/desktop/src/screens/AppView.tsx +++ b/apps/desktop/src/screens/AppView.tsx @@ -1,16 +1,20 @@ -// One app across both stores: header (icon, studio, ids, store state, pairing), then the tabs. A store-only app (no +// One app across both stores: header (icon, studio, ids, store state, pairing), then five sections by job (Release, +// Store page, Revenue, Players, Health; docs IA), each with a header (what it is for, the one next step) and its sub views. A store-only app (no // app folder) shows its store reads (releases, health, reviews, TestFlight) read-only, with "Link a folder" and // "Start an app folder here" to make it a full app. import { useContextMenu, useMenuButton } from '../ui/ContextMenu'; import { appMenu, playUrlFor } from '../ui/app-menu'; import { useShowIgnored } from '../lib/show-ignored'; import { useState } from 'react'; +import { GamesTab } from './tabs/Games'; +import { SectionHead } from './SectionHead'; import { api, errorText } from '../api'; import { type Key, t } from '../i18n'; import { pairCandidates, type UnifiedApp, withLink, withPair, withStudio } from '../lib/identity'; import { attention, storeNow } from '../lib/model'; import { Hero, nextStep, Tabs } from '../ui/ds'; -import { type AppTab, TAB_ICON, TAB_KEY, tabsFor, useNav } from '../nav'; +import { isGameSummary } from '../lib/model'; +import { type AppSection, type AppTab, SECTION_ICON, SECTION_KEY, SECTION_TABS, sectionOf, sectionsFor, subsOf, useNav } from '../nav'; import { useData } from '../store'; import { Icon } from '../ui/icons'; import { ErrorBoundary } from '../ui/ErrorBoundary'; @@ -107,11 +111,15 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) { const ios = e?.app?.platforms.ios ?? (app.ios ? { bundleId: app.ios.app.bundleId, ascAppId: app.ios.app.id } : null); const android = e?.app?.platforms.android ?? (app.android ? { packageName: app.android.app.id } : null); const name = (app.project && e?.app?.name) || app.name; - const tabs = tabsFor(!!app.project); + const game = isGameSummary(e?.summary); + const sections = sectionsFor(!!app.project, game); + const section = sectionOf(tab); const ed = useEdits(); useUnsavedGuard(); const studio = d.studios.find(s => s.id === app.studio) ?? null; const setTab = (x: AppTab) => nav.go({ screen: 'app', appId: app.key, tab: x }); + const setSection = (x: AppSection) => setTab(subsOf(x, !!app.project, game)[0] ?? SECTION_TABS[x][0]!); + const editCount = (x: AppTab) => (EDIT_TABS[x] ? ed.count(app.project?.id ?? app.target, EDIT_TABS[x]) : 0); const all = l?.release.all ?? []; const iosNow = l ? storeNow(all, 'ios').now : null, androidNow = l ? storeNow(all, 'android').now : null; const att = attention(app.key, app.name, e, l); @@ -137,19 +145,20 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) { {android && } } all={all} ios={!!ios} android={!!android} next={l?.release.next?.version ?? null} - compact={tab !== 'releases'} + compact={section !== 'release' || tab !== 'releases'} cta={nextStep({ project: !!app.project, att, all, next: l?.release.next?.version ?? null })} onCta={setTab} /> {!app.project && } {app.project?.storeboundPin && {t('app.pinNotice', { pin: app.project.storeboundPin, app: d.info?.version ?? '' })}} {app.project?.missing ? : <>
- { - const n = EDIT_TABS[x] ? ed.count(app.project?.id ?? app.target, EDIT_TABS[x]) : 0; - return { id: x, label: t(TAB_KEY[x]), icon: TAB_ICON[x], count: att.filter(a => a.tab === x && a.tone === 'bad').length, dot: n > 0, dotTitle: n ? t('ed.unsaved', { count: n }) : undefined }; + { + const n = subsOf(x, !!app.project, game).reduce((a, s2) => a + editCount(s2), 0); + return { id: x, label: t(SECTION_KEY[x]), icon: SECTION_ICON[x], hint: String(i + 1), count: att.filter(a => a.tab && sectionOf(a.tab) === x && a.tone === 'bad').length, dot: n > 0, dotTitle: n ? t('ed.unsaved', { count: n }) : undefined }; })} />
-
+ a + editCount(s2), 0)} editCount={editCount} onTab={setTab} /> +
{tab === 'releases' && } {tab === 'catalog' && app.project && } @@ -160,6 +169,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) { {tab === 'art' && !app.project && } {tab === 'health' && } {tab === 'reviews' && } + {tab === 'games' && app.project && } {tab === 'testflight' && } {tab === 'growth' && } {tab === 'appinfo' && app.project && } diff --git a/apps/desktop/src/screens/SectionHead.tsx b/apps/desktop/src/screens/SectionHead.tsx new file mode 100644 index 0000000..75f04c1 --- /dev/null +++ b/apps/desktop/src/screens/SectionHead.tsx @@ -0,0 +1,60 @@ +// The header of one app section (docs IA): the section's name, one line on what it is for, the single next step +// derived from state that is already read (lib/sections.ts), and the sub views as a segmented control (only when the +// section has more than one). +import type { ListingsResult, StoreRelease } from '../api'; +import { t } from '../i18n'; +import type { UnifiedApp } from '../lib/identity'; +import type { Attention } from '../lib/model'; +import { sectionNext } from '../lib/sections'; +import { type AppSection, type AppTab, SECTION_ICON, SECTION_KEY, TAB_ICON, TAB_KEY } from '../nav'; +import { useCachedRead, useData } from '../store'; +import type { GameServicesStatus } from '../ui/GameServicesCard'; +import { Icon } from '../ui/icons'; + +const PURPOSE: Record = { + release: 'sec.release.for', store: 'sec.store.for', revenue: 'sec.revenue.for', players: 'sec.players.for', health: 'sec.health.for', +}; + +export function SectionHead({ app, section, tab, subs, att, all, next, unsaved, editCount, onTab }: { + app: UnifiedApp; section: AppSection; tab: AppTab; subs: AppTab[]; att: Attention[]; all: StoreRelease[]; next: string | null; unsaved: number; + editCount: (t: AppTab) => number; onTab: (t: AppTab) => void; +}) { + const d = useData(); + const e = d.statusFor(app); + const pid = app.project && !app.project.missing ? app.project.id : null; + // cached reads only: the header never starts a store call + const listings = useCachedRead(section === 'store' ? pid : null, 'project.listings'); + const reviews = useCachedRead<{ reply?: unknown }[]>(section === 'players' ? app.target : null, 'reviews.list', { days: 30 }); + const games = useCachedRead(section === 'players' && subs.includes('games') ? pid : null, 'games.status'); + const validate = useCachedRead<{ findings?: { level: string }[] }>(section === 'health' || section === 'release' ? pid : null, 'validate'); + const n = sectionNext(section, { + project: !!app.project, att, all, next, summary: e?.summary ?? null, catalog: (d.liveFor(app) ?? e)?.catalog ?? null, unsaved, listings, + reviews: Array.isArray(reviews) ? reviews : null, readiness: validate?.findings ? validate.findings.filter(f => f.level === 'blocker').length : null, + games: games ? { changes: (games.apple?.changes.length ?? 0) + (games.play?.changes.length ?? 0), blockers: games.findings.filter(f => f.level === 'error').length, apiOff: !!games.playApiDisabled } : null, + }); + return ( +
+
+

{t(SECTION_KEY[section])}

+

{t(PURPOSE[section])}

+
+
+ + {n.text} + {n.tab && n.tab !== tab && } +
+ {subs.length > 1 && ( +
+ {subs.map(x => { + const c = editCount(x); + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/apps/desktop/src/screens/tabs/AppInfo.tsx b/apps/desktop/src/screens/tabs/AppInfo.tsx index 7e6b1c3..8666d46 100644 --- a/apps/desktop/src/screens/tabs/AppInfo.tsx +++ b/apps/desktop/src/screens/tabs/AppInfo.tsx @@ -19,7 +19,6 @@ import { Bar, Chip, ErrorNotice, Notice, Skeleton } from '../../ui/kit'; import { EmptyState, Section } from '../../ui/ds'; import '../../styles/appinfo.css'; import { WriteButton } from '../../ui/WriteButton'; -import { GameServicesCard } from '../../ui/GameServicesCard'; import { ascUrl } from '../AppView'; const AREA: Record = { dataSafety: 'ai.aDataSafety', appPrivacy: 'ai.aAppPrivacy', appInfo: 'ai.aAppInfo', encryption: 'ai.aEncryption', review: 'ai.aReview' }; @@ -258,7 +257,6 @@ export function AppInfoTab({ project }: { project: Project }) {
)} -

{t('ai.foot', { when: ago(r.at) })}

diff --git a/apps/desktop/src/screens/tabs/Games.tsx b/apps/desktop/src/screens/tabs/Games.tsx new file mode 100644 index 0000000..26950fd --- /dev/null +++ b/apps/desktop/src/screens/tabs/Games.tsx @@ -0,0 +1,90 @@ +// Players > Game services: Game Center (App Store) and Play Games Services (Google Play) for a game, from +// `games status --json` (cli/games-status.ts). The two store panels (switches, ids, counts, sync writes with dry run + +// confirm) are the GameServicesCard; below it one row per leaderboard / achievement with its languages and each +// store's state against the config, the Game Center locale map with suggestions, and the Play API-disabled state with +// the enable link and the console plan. +import { Fragment } from 'react'; +import type { Project } from '../../api'; +import { t } from '../../i18n'; +import { useRead } from '../../store'; +import { ConsolePlanButton } from '../../ui/ConsolePlan'; +import { EmptyState, Section } from '../../ui/ds'; +import { type GameAction, type GameItemRow, GameServicesCard, type GameServicesStatus } from '../../ui/GameServicesCard'; +import { Icon, StoreGlyph } from '../../ui/icons'; +import { Chip, Notice } from '../../ui/kit'; +import { OpenLink } from '../../ui/OpenLink'; + +const ACTION_TONE: Record = { keep: 'ok', update: 'warn', create: 'info' }; +const ACTION_KEY = { keep: 'games.keep', update: 'games.update', create: 'games.create' } as const; + +function StoreState({ on, plan, read }: { on: boolean; plan: { action: GameAction; changed: string[] } | null; read: boolean }) { + if (!on) return {t('games.notOnStore')}; + if (!plan) return {read ? t('games.notListed') : t('games.notRead')}; + return {t(ACTION_KEY[plan.action])}; +} + +function ItemsTable({ rows, appleRead, playRead }: { rows: GameItemRow[]; appleRead: boolean; playRead: boolean }) { + return ( +
+ + + + + + + + {rows.map(r => { + const locs = r.apple?.locales.filter(l => l.action !== 'keep') ?? []; + return ( + + + + + + + + + ); + })} + +
{t('games.item')}{t('games.name')}{t('games.points')}{t('games.languages')}{t('gs.gameCenter')}{t('gs.playGames')}
{r.id} +
{t(r.kind === 'leaderboard' ? 'games.leaderboard' : 'games.achievement')}{r.hidden ? ` · ${t('games.hidden')}` : ''}
{r.name}{r.points ?? '–'}{r.locales.join(', ')}{locs.length > 0 &&
{t('games.localeChanges', { list: locs.map(l => `${l.locale} ${t(ACTION_KEY[l.action])}`).join(', ') })}
}
+
+ ); +} + +export function GamesTab({ project }: { project: Project }) { + const r = useRead(project.id, 'games.status', { network: true, title: t('gs.title') }); + const s = r.data; + const rows = s?.items ?? []; + const appleRead = !!s?.apple && !s.apple.error, playRead = !!s?.play && !s.play.error; + const map = s?.localeMap; + return ( + <> + {s?.playApiDisabled && ( + + + +
}> + {t('games.apiOffTitle')} {t('games.apiOffText', { api: s.playApiDisabled.title })} + + )} + +
r.refresh()} disabled={r.loading} aria-busy={r.loading}>{t('games.refresh')}}> + {rows.length ? + : {s ? t('games.noItems') : t('games.notReadYet')}} +

{t('games.images')}

+
+ {map && (Object.keys(map.configured).length > 0 || map.suggested.length > 0) && ( +
+
+ {Object.entries(map.configured).map(([k, v]) =>
{k}
{v} {t('games.mapSet')}
)} + {map.suggested.map(x =>
{x.id}
{x.from} → {x.to} {t('games.mapSuggested')}
)} +
+ {map.suggested.length > 0 &&

{t('games.mapHint', { entries: map.suggested.map(x => `${x.id}: '${x.to}'`).join(', ') })}

} +
+ )} + + ); +} diff --git a/apps/desktop/src/store.tsx b/apps/desktop/src/store.tsx index 877b9ca..cfe6af2 100644 --- a/apps/desktop/src/store.tsx +++ b/apps/desktop/src/store.tsx @@ -750,3 +750,21 @@ export function useRead(target: string, method: string, o: { network: boolean return { ...s, loading: !o.skip && (s.loading || st.busy), justNow: st.justNow, pausedUntil: st.pausedUntil, refresh }; } + +/** The cached value of one read (and every later result of it), without ever starting a read: for summaries such as a + * section's "what's next" line that must not cost a store call. Null until the read ran once. */ +export function useCachedRead(target: string | null, method: string, args: Record = {}): T | null { + const d = useData(); + const argsKey = JSON.stringify(args); + const [v, set] = useState(null); + useEffect(() => { + set(null); + if (!target) return; + let alive = true; + const a = JSON.parse(argsKey) as Record; + api.cached(method, target, a).then(c => { if (alive && c && c.value.data !== undefined && usable(method, c.value)) set(c.value.data as T); }, () => {}); + const un = d.onRead(readKey(target, method, a), e => { if (e.kind === 'done' && usable(method, e.result)) set(e.result.data as T); }); + return () => { alive = false; un(); }; + }, [target, method, argsKey]); // eslint-disable-line react-hooks/exhaustive-deps + return v; +} diff --git a/apps/desktop/src/ui/GameServicesCard.tsx b/apps/desktop/src/ui/GameServicesCard.tsx index 3aa77cd..50ae13f 100644 --- a/apps/desktop/src/ui/GameServicesCard.tsx +++ b/apps/desktop/src/ui/GameServicesCard.tsx @@ -4,7 +4,7 @@ // (dry run -> native dialog -> apply) of `apple game-center` and `play games`; the PGS setup is a console plan. import type { Project } from '../api'; import { t } from '../i18n'; -import { useRead, useWrite } from '../store'; +import { type ReadState, useRead, useWrite } from '../store'; import { ConsolePlanButton } from './ConsolePlan'; import { StoreGlyph } from './icons'; import { Card, Chip, ErrorNotice, Skeleton } from './kit'; @@ -18,13 +18,23 @@ export interface GameServicesStatus { play: null | { appId: string | null; resource: string | null; config: Counts; changes: string[]; error?: string } & Counts; findings: Finding[]; links: { label: string; url: string }[]; + items?: GameItemRow[]; + localeMap?: { configured: Record; suggested: { id: string; from: string; to: string }[] }; + playApiDisabled?: { service: string; title: string; url: string } | null; +} +export type GameAction = 'create' | 'update' | 'keep'; +export interface GameItemRow { + kind: 'leaderboard' | 'achievement'; id: string; name: string; locales: string[]; points?: number; hidden?: boolean; ios: boolean; android: boolean; + apple: { action: GameAction; changed: string[]; locales: { locale: string; action: GameAction }[] } | null; + play: { action: GameAction; changed: string[] } | null; } const tone = (l: Finding['level']) => (l === 'error' ? 'bad' : l === 'warn' ? 'warn' : 'ok'); const yesNo = (v: boolean | null | undefined) => (v === null || v === undefined ? {t('gs.unknown')} : {v ? t('gs.yes') : t('gs.no')}); -export function GameServicesCard({ project }: { project: Project }) { - const r = useRead(project.id, 'games.status', { network: true, title: t('gs.title') }); +export function GameServicesCard({ project, read }: { project: Project; read?: ReadState }) { + const own = useRead(project.id, 'games.status', { network: true, title: t('gs.title'), skip: !!read }); + const r = read ?? own; const w = useWrite(project.id); if (!r.data) return r.error ? : ; const s = r.data; diff --git a/apps/desktop/src/ui/app-menu.ts b/apps/desktop/src/ui/app-menu.ts index d82ba47..144e0aa 100644 --- a/apps/desktop/src/ui/app-menu.ts +++ b/apps/desktop/src/ui/app-menu.ts @@ -5,7 +5,8 @@ import { t } from '../i18n'; import { copyText } from '../lib/crash'; import { withIgnored, withSideIgnored, withUnpair, type UnifiedApp } from '../lib/identity'; import { setShowIgnored } from '../lib/show-ignored'; -import { type AppTab, type Nav, TAB_ICON, TAB_KEY, tabsFor } from '../nav'; +import { type AppTab, type Nav, SECTION_ICON, SECTION_KEY, sectionsFor, subsOf, TAB_ICON, TAB_KEY } from '../nav'; +import { isGameSummary } from '../lib/model'; import type { Data } from '../store'; import type { MenuInput, MenuItem } from './menu-logic'; import { pickConsoleId, playAppUrl } from '../../../../cli/lib/console-links'; @@ -36,7 +37,12 @@ export function appMenu(app: UnifiedApp, d: Data, nav: Nav, opts: { showIgnored: const storeName = (k: 'ios' | 'android') => (k === 'ios' ? 'App Store' : 'Google Play'); return [ { label: t('cm.open'), icon: 'arrowRight', onSelect: () => go('releases') }, - { label: t('cm.openTab'), icon: 'grid', submenu: tabsFor(!!p).map(tab => ({ label: t(TAB_KEY[tab]), icon: TAB_ICON[tab], onSelect: () => go(tab) })) }, + { label: t('cm.openTab'), icon: 'grid', submenu: sectionsFor(!!p, isGameSummary(d.statusFor(app)?.summary)).map((sec, i) => { + const subs = subsOf(sec, !!p, isGameSummary(d.statusFor(app)?.summary)); + return subs.length > 1 + ? { label: t(SECTION_KEY[sec]), icon: SECTION_ICON[sec], shortcut: String(i + 1), submenu: subs.map(tab => ({ label: t(TAB_KEY[tab]), icon: TAB_ICON[tab], onSelect: () => go(tab) })) } + : { label: t(SECTION_KEY[sec]), icon: SECTION_ICON[sec], shortcut: String(i + 1), onSelect: () => go(subs[0]!) }; + }) }, 'sep', (ios || android) && { label: t('cm.openConsole'), icon: 'external', submenu: [ ios && { label: 'App Store Connect', icon: 'external', onSelect: () => openUrl(d, ASC_APP(ios.id)) }, diff --git a/apps/desktop/src/ui/ds.tsx b/apps/desktop/src/ui/ds.tsx index b38aa8b..6460ba2 100644 --- a/apps/desktop/src/ui/ds.tsx +++ b/apps/desktop/src/ui/ds.tsx @@ -275,7 +275,7 @@ export function Toolbar({ label, children }: { label: string; children: ReactNod return
{children}
; } -export interface TabItem { id: T; label: string; icon?: IconName; count?: number | null; dot?: boolean; dotTitle?: string } +export interface TabItem { id: T; label: string; icon?: IconName; count?: number | null; dot?: boolean; dotTitle?: string; /** A keyboard hint shown in the tab ("1"). */ hint?: string } /** Pill tabs with icons and counts; arrows / Home / End move between them (roving tabindex). One row always: tabs that * do not fit go into a "More" menu at the end, the active tab always stays in the row. */ @@ -323,11 +323,12 @@ export function Tabs({ items, value, onChange, label, panelId return (
{items.map((x, i) => ( - ))} {hidden.length > 0 && ( diff --git a/apps/desktop/tests/sections.test.ts b/apps/desktop/tests/sections.test.ts new file mode 100644 index 0000000..e5a6494 --- /dev/null +++ b/apps/desktop/tests/sections.test.ts @@ -0,0 +1,79 @@ +// App sections (docs IA): old tab ids and section links resolve to one place, the sections an app has, the "what's +// next" line of each section and the sidebar's status line. +import { describe, expect, test } from 'bun:test'; +import type { StoreRelease } from '../src/api'; +import type { Attention } from '../src/lib/model'; +import { isGameSummary, sideStatus } from '../src/lib/model'; +import { listingProblems, type SectionInput, sectionNext } from '../src/lib/sections'; +import { APP_TABS, parseRouteJson, parseStart, placeOf, SECTION_TABS, SECTIONS, sectionOf, sectionsFor, subsOf } from '../src/nav'; + +const rel = (store: 'ios' | 'android', version: string, phase: StoreRelease['phase'], o: Partial = {}): StoreRelease => + ({ store, version, phase, fraction: null, track: null, ...o }) as StoreRelease; +const base: SectionInput = { project: true, att: [], all: [], next: null, summary: null, catalog: null, unsaved: 0, listings: null, reviews: null, games: null }; +const att = (tone: Attention['tone'], tab: Attention['tab'], text = 'x'): Attention => ({ app: 'A', projectId: 'a', tone, text, tab }); + +describe('places', () => { + test('every old tab id is in exactly one section', () => { + for (const tab of APP_TABS) expect(SECTIONS.filter(s => SECTION_TABS[s].includes(tab))).toHaveLength(1); + }); + test('old tab ids, sections and section/sub links', () => { + expect(placeOf('prices')).toBe('prices'); + expect(placeOf('revenue')).toBe('catalog'); + expect(placeOf('store/art')).toBe('art'); + expect(placeOf('store/prices')).toBe('listings'); + expect(placeOf('health')).toBe('health'); + expect(placeOf('nope')).toBe('releases'); + expect(parseStart('app:players/games:2', ['a', 'b'])).toEqual({ screen: 'app', appId: 'b', tab: 'games' }); + expect(parseStart('app:release', ['a'])).toEqual({ screen: 'app', appId: 'a', tab: 'releases' }); + expect(parseRouteJson('{"screen":"app","appId":"k","tab":"store/appinfo"}')).toEqual({ screen: 'app', appId: 'k', tab: 'appinfo' }); + expect(sectionOf('testflight')).toBe('release'); + }); + test('store-only apps have no Revenue; Game services only for games with a folder', () => { + expect(sectionsFor(false)).toEqual(['release', 'store', 'players', 'health']); + expect(subsOf('players', true, false)).toEqual(['reviews']); + expect(subsOf('players', true, true)).toEqual(['reviews', 'games']); + expect(subsOf('players', false, true)).toEqual(['reviews']); + expect(subsOf('store', false)).toEqual(['listings', 'art']); + }); + test('a game: profile, games config or a Play game category', () => { + expect(isGameSummary({ profile: 'app' } as never)).toBe(false); + expect(isGameSummary({ profile: 'game' } as never)).toBe(true); + expect(isGameSummary({ profile: 'app', games: { gameCenter: true, playGames: false, leaderboards: 0, achievements: 0 } } as never)).toBe(true); + expect(isGameSummary({ profile: 'app', playRelease: { managedPublishing: null, countries: null, category: 'Puzzle' } } as never)).toBe(true); + }); +}); + +describe('next step per section', () => { + test('release: rejected first, then blockers, rollout, review (managed), upload, live', () => { + expect(sectionNext('release', { ...base, all: [rel('ios', '2.1', 'rejected')], att: [att('bad', 'checks')] })).toMatchObject({ tone: 'bad', tab: 'releases' }); + expect(sectionNext('release', { ...base, att: [att('bad', 'checks'), att('bad', 'checks')] })).toMatchObject({ text: '2 blockers before submit', tab: 'checks' }); + expect(sectionNext('release', { ...base, all: [rel('android', '3.0', 'rollout', { fraction: 0.2 })] }).text).toContain('20'); + expect(sectionNext('release', { ...base, all: [rel('ios', '2.1', 'review', { managed: true })] }).text).toContain('Publishing overview'); + expect(sectionNext('release', base)).toMatchObject({ tab: 'releases', text: 'Upload a first build' }); + expect(sectionNext('release', { ...base, all: [rel('ios', '2.1', 'live')] })).toMatchObject({ tone: 'ok', tab: null }); + }); + test('store, revenue, players, health', () => { + const listings = { files: { play: null, apple: null }, problems: [], locales: [{ lang: 'en', rtl: false, screenshots: null, complete: 0.5, apple: null, play: { code: 'en-US', fields: [{ field: 'title', length: 0, limit: 30, required: true, state: 'missing' }] } }] } as never; + expect(listingProblems(listings)).toBe(1); + expect(sectionNext('store', { ...base, listings })).toMatchObject({ text: '1 listing problem', tab: 'listings' }); + expect(sectionNext('revenue', base).tab).toBe('catalog'); + expect(sectionNext('revenue', { ...base, catalog: { checked: 'live', ok: false, products: 2, subscriptions: 0, onlyIos: ['a'], onlyAndroid: [], missingIos: [], missingAndroid: ['b'] } })).toMatchObject({ tone: 'warn', text: '2 products differ between the stores' }); + expect(sectionNext('players', { ...base, reviews: [{}, { reply: { text: 'hi' } }, {}] })).toMatchObject({ text: '2 reviews without a reply', tab: 'reviews' }); + expect(sectionNext('players', { ...base, reviews: [], games: { changes: 0, blockers: 0, apiOff: true } })).toMatchObject({ tone: 'bad', tab: 'games' }); + expect(sectionNext('players', { ...base, reviews: [] }).tone).toBe('ok'); + expect(sectionNext('health', { ...base, att: [att('bad', 'health', 'Crash rate over the line')] })).toMatchObject({ text: 'Crash rate over the line', tab: 'health' }); + expect(sectionNext('health', { ...base, att: [att('bad', 'checks')] })).toMatchObject({ text: '1 problem to fix first', tab: 'checks' }); + }); +}); + +describe('sidebar status line', () => { + const o = { att: [] as Attention[], all: [] as StoreRelease[], read: true, missing: false, storeOnly: false }; + test('groups and lines', () => { + expect(sideStatus({ ...o, all: [rel('ios', '2.1', 'rejected')] })).toEqual({ group: 'attention', text: 'Rejected · iOS 2.1', tone: 'bad' }); + expect(sideStatus({ ...o, att: [att('bad', 'checks'), att('bad', 'checks')] })).toMatchObject({ group: 'attention', text: '2 blockers' }); + expect(sideStatus({ ...o, all: [rel('ios', '2.1', 'review'), rel('android', '2.0', 'live')] })).toMatchObject({ group: 'progress', text: 'In review · iOS 2.1' }); + expect(sideStatus({ ...o, all: [rel('ios', '2.1', 'live'), rel('android', '2.1', 'live')] })).toEqual({ group: 'live', text: 'Live · 2.1', tone: 'ok' }); + expect(sideStatus({ ...o, read: false })).toMatchObject({ group: 'draft', text: 'Not read yet' }); + expect(sideStatus({ ...o, missing: true })).toMatchObject({ group: 'attention', text: 'Folder missing' }); + }); +}); diff --git a/cli/games-status.ts b/cli/games-status.ts index 5a06489..814732c 100644 --- a/cli/games-status.ts +++ b/cli/games-status.ts @@ -7,7 +7,8 @@ import { ascClient } from './apple/asc'; import { readProjectEntitlements } from './apple/signing'; import type { Loaded } from './lib/config'; import { consoleUrl } from './lib/console-links'; -import { type Finding, gamesPrivacyFindings, gcFindings, hasAndroidProject, planGameCenter, planPlayGames, pgsFindings, readPgsProject, usesPlayGames } from './lib/game-services'; +import { type Action, type Finding, gamesPrivacyFindings, gcFindings, hasAndroidProject, type ItemPlan, liveGcLocales, onStore, planGameCenter, planPlayGames, pgsFindings, readPgsProject, suggestLocaleMap, usesPlayGames } from './lib/game-services'; +import { parseGoogleError } from './lib/google-errors'; import type { Finding as VFinding } from './lib/validate-rules'; import { gamesConfigClient, playDefaultLocale, readPlayGames } from './play/games'; @@ -18,6 +19,33 @@ export interface GameServicesStatus { config: { leaderboards: number; achievements: number }; changes: string[]; error?: string }; findings: (Finding & { store: 'apple' | 'play' })[]; links: { label: string; url: string }[]; + /** One row per configured leaderboard / achievement: its texts and where each store stands against the config + * (create / update / keep; null = not on that store or that store was not read). */ + items?: GameItemRow[]; + /** Game Center locale map: the configured entries and the suggestions from the live locales. */ + localeMap?: { configured: Record; suggested: { id: string; from: string; to: string }[] }; + /** Play: the Games Configuration API is off in the service account's Cloud project. */ + playApiDisabled?: { service: string; title: string; url: string } | null; +} + +export interface GameItemRow { + kind: 'leaderboard' | 'achievement'; id: string; name: string; locales: string[]; points?: number; hidden?: boolean; + ios: boolean; android: boolean; + apple: { action: Action; changed: string[]; locales: { locale: string; action: Action }[] } | null; + play: { action: Action; changed: string[] } | null; +} + +/** The per-item rows of the status (pure; plans are null when that store was not read). */ +export function gameItemRows(g: NonNullable, applePlan: ItemPlan[] | null, playPlan: ItemPlan[] | null): GameItemRow[] { + const find = (plan: ItemPlan[] | null, kind: ItemPlan['kind'], id: string) => plan?.find(p => p.kind === kind && p.id === id) ?? null; + const row = (kind: ItemPlan['kind'], x: { id: string; locales: Record; platforms?: ('ios' | 'android')[] }, extra: Partial): GameItemRow => { + const a = find(applePlan, kind, x.id), p = find(playPlan, kind, x.id); + return { kind, id: x.id, name: Object.values(x.locales)[0]?.name ?? x.id, locales: Object.keys(x.locales), ...extra, + ios: onStore(x, 'apple'), android: onStore(x, 'play'), + apple: a ? { action: a.action, changed: a.changed, locales: a.locales.map(l => ({ locale: l.locale, action: l.action })) } : null, + play: p ? { action: p.action, changed: p.changed } : null }; + }; + return [...(g.leaderboards ?? []).map(l => row('leaderboard', l, {})), ...(g.achievements ?? []).map(a => row('achievement', a, { points: a.points, hidden: !!a.hidden }))]; } /** Offline findings (check, scan-like): entitlement, APP_ID resource, declarations reminders. */ @@ -37,7 +65,8 @@ export async function gameServicesStatus(loaded: Loaded, o: { apple?: boolean; p const { config, root } = loaded; const g = config.games ?? {}; const findings = localGameFindings(loaded); - const out: GameServicesStatus = { apple: null, play: null, findings, links: [] }; + const out: GameServicesStatus = { apple: null, play: null, findings, links: [], localeMap: { configured: { ...(g.localeMap ?? {}) }, suggested: [] }, playApiDisabled: null }; + let applePlan: ItemPlan[] | null = null, playPlan: ItemPlan[] | null = null; const count = (store: 'apple' | 'play') => ({ leaderboards: (g.leaderboards ?? []).filter(x => !x.platforms || x.platforms.includes(store === 'apple' ? 'ios' : 'android')).length, achievements: (g.achievements ?? []).filter(x => !x.platforms || x.platforms.includes(store === 'apple' ? 'ios' : 'android')).length }); const gcUse = config.apple ? gameCenterInUse(config, root) : { inUse: false, why: null }; @@ -46,8 +75,10 @@ export async function gameServicesStatus(loaded: Loaded, o: { apple?: boolean; p try { const c = await ascClient(root, config.apple.bundleId, config.apple.appId); const s = await readGameCenter(c, config.apple.bundleId); + applePlan = planGameCenter(g, config.languages, s, g.localeMap); + out.localeMap!.suggested = suggestLocaleMap(config.languages, liveGcLocales(s), g.localeMap ?? {}); out.apple = { capability: s.capability, enabled: !!s.detail, version: s.version?.versionString ?? null, versionEnabled: s.version ? s.version.enabled : null, - leaderboards: s.leaderboards.length, achievements: s.achievements.length, config: count('apple'), changes: changesOf(planGameCenter(g, config.languages, s, g.localeMap)), why: gcUse.why ?? undefined }; + leaderboards: s.leaderboards.length, achievements: s.achievements.length, config: count('apple'), changes: changesOf(applePlan), why: gcUse.why ?? undefined }; } catch (e) { out.apple = { capability: null, enabled: false, version: null, versionEnabled: null, leaderboards: 0, achievements: 0, config: count('apple'), changes: [], error: (e as Error).message.slice(0, 300) }; } } if (config.play && usesPlayGames(config) && o.play !== false) { @@ -58,9 +89,14 @@ export async function gameServicesStatus(loaded: Loaded, o: { apple?: boolean; p if (!appId) out.play = { ...base, leaderboards: 0, achievements: 0, changes: [], error: 'no games project id yet (storebound console plan play-games-services)' }; else try { const have = await readPlayGames(gamesConfigClient(root), appId); - out.play = { ...base, leaderboards: have.leaderboards.length, achievements: have.achievements.length, changes: changesOf(planPlayGames(g, config.languages, playDefaultLocale(config), have)) }; - } catch (e) { out.play = { ...base, leaderboards: 0, achievements: 0, changes: [], error: (e as Error).message.slice(0, 300) }; } + playPlan = planPlayGames(g, config.languages, playDefaultLocale(config), have); + out.play = { ...base, leaderboards: have.leaderboards.length, achievements: have.achievements.length, changes: changesOf(playPlan) }; + } catch (e) { + const f = parseGoogleError((e as Error).message); + if (f?.kind === 'api-disabled') out.playApiDisabled = { service: f.service, title: f.serviceTitle, url: f.activationUrl }; + out.play = { ...base, leaderboards: 0, achievements: 0, changes: [], error: (e as Error).message.slice(0, 300) }; } } + out.items = gameItemRows(g, applePlan, playPlan); return out; } diff --git a/cli/inspect.ts b/cli/inspect.ts index 4c2e7d5..e3313d9 100644 --- a/cli/inspect.ts +++ b/cli/inspect.ts @@ -44,6 +44,7 @@ export function summarize(ctx: Ctx): ProjectSummary { variants: Object.keys(config.shots?.variants ?? {}), playReports: !!config.play?.reportsBucket, playRelease: config.play ? { managedPublishing: config.play.managedPublishing ?? null, countries: config.play.countries?.length ?? null, category: playCategoryOf(config)?.category ?? null } : null, + games: config.games ? { gameCenter: config.games.gameCenter === true, playGames: !!config.games.playGames, leaderboards: config.games.leaderboards?.length ?? 0, achievements: config.games.achievements?.length ?? 0 } : null, }; } diff --git a/cli/lib/desktop-protocol.ts b/cli/lib/desktop-protocol.ts index 58a2582..1a174d5 100644 --- a/cli/lib/desktop-protocol.ts +++ b/cli/lib/desktop-protocol.ts @@ -148,6 +148,8 @@ export interface ProjectSummary { /** Play production settings from the config: play.managedPublishing (null: not set, Play's default off), the number * of play.countries (null: all countries / regions) and the App category (play.category or the App Store match). */ playRelease?: { managedPublishing: boolean | null; countries: number | null; category: string | null } | null; + /** The games section of the config (Game Center / Play Games Services and the item counts), null without one. */ + games?: { gameCenter: boolean; playGames: boolean; leaderboards: number; achievements: number } | null; } // ---- App-centric model (one app = one product on up to two platforms; a workspace holds many apps) ---- diff --git a/tests/game-services.test.ts b/tests/game-services.test.ts index 636bcd8..8554834 100644 --- a/tests/game-services.test.ts +++ b/tests/game-services.test.ts @@ -8,7 +8,7 @@ import { capabilitySpecs, planCapabilities } from '../cli/lib/signing'; import { gamesPrivacyFindings, gcFindings, gcLocaleMap, liveGcLocales, suggestLocaleMap, pgsFindings, planGameCenter, playAchievementBody, playLeaderboardBody, readPgsProject, validateGames } from '../cli/lib/game-services'; import { consolePlan } from '../cli/lib/console-plan'; import { verifyText } from '../cli/lib/console-verify'; -import { gameServicesFindings, type GameServicesStatus } from '../cli/games-status'; +import { gameItemRows, gameServicesFindings, type GameServicesStatus } from '../cli/games-status'; import { run as gameCenter } from '../cli/apple/game-center'; import { run as playGames } from '../cli/play/games'; import { run as appleSubmit } from '../cli/apple/submit'; @@ -298,3 +298,15 @@ describe('apple submit with a Game Center build (live 409 STATE_ERROR.BUILD_INDI expect(s.asc.all('gameCenterAppVersions')).toHaveLength(1); }); }); + +describe('games status rows (desktop Players section)', () => { + test('one row per item, store states from the plans, null when a store was not read', () => { + const g = { leaderboards: [{ id: 'com.example.lb', locales: { en: { name: 'Best' } } }], achievements: [{ id: 'com.example.a1', points: 10, hidden: true, platforms: ['android' as const], locales: { en: { name: 'First', description: 'd' } } }] }; + const plan = [{ kind: 'leaderboard' as const, id: 'com.example.lb', action: 'update' as const, attributes: {}, changed: ['scoreSortType'], locales: [{ locale: 'en-US', action: 'create' as const, attributes: {}, changed: [] }] }]; + const rows = gameItemRows(g, plan, null); + expect(rows.map(r => [r.kind, r.id, r.name, r.ios, r.android])).toEqual([['leaderboard', 'com.example.lb', 'Best', true, true], ['achievement', 'com.example.a1', 'First', false, true]]); + expect(rows[0]!.apple).toEqual({ action: 'update', changed: ['scoreSortType'], locales: [{ locale: 'en-US', action: 'create' }] }); + expect(rows[0]!.play).toBeNull(); + expect(rows[1]).toMatchObject({ points: 10, hidden: true, apple: null }); + }); +}); diff --git a/tools/desktop-boot-check.ts b/tools/desktop-boot-check.ts index 6f7ced0..1943097 100644 --- a/tools/desktop-boot-check.ts +++ b/tools/desktop-boot-check.ts @@ -244,20 +244,51 @@ async function newPage(ctx: { newPage(): Promise }): return page; } -/** Opens an app tab like a person: the tab itself when it is in the row, else through the row's "More" menu (tabs - * that do not fit the window go there). */ +/** The app's places (docs IA): five sections, each with sub views in a segmented control. A place is named by its + * sub view's old tab id ("tab-prices"); the section of each, for opening it like a person. */ +const SECTION_OF: Record = { + releases: 'release', testflight: 'release', listings: 'store', art: 'store', appinfo: 'store', catalog: 'revenue', prices: 'revenue', + reviews: 'players', games: 'players', health: 'health', growth: 'health', checks: 'health', tools: 'health', +}; + +/** Opens a place in an app like a person: its section in the section row (or through the row's "More" menu when it + * does not fit), then the sub view in the section's segmented control when the section has more than one. */ async function clickTab(page: Page, id: string, o: { timeout?: number } = {}): Promise { - const tab = page.locator(`[role="tab"]#${id}`); + const sub = id.replace(/^tab-/, ''); + const sec = SECTION_OF[sub]; + if (!sec) throw new Error(`unknown place ${id}`); + const tab = page.locator(`[role="tab"]#tab-${sec}`); await tab.waitFor({ state: 'attached', timeout: o.timeout }); - if (await tab.isVisible()) { await tab.click(o); return; } - const label = ((await tab.textContent()) ?? '').replace(/\d+$/, '').trim(); - try { - await page.locator('.ds-tab-more').click(o); - await page.locator('.ds-menu [role="menuitem"]', { hasText: label }).first().click(o); - } catch (e) { - const why = await page.evaluate(() => JSON.stringify({ more: document.querySelectorAll('.ds-tab-more').length, menu: [...document.querySelectorAll('.ds-menu [role^="menuitem"]')].map(x => x.textContent), w: innerWidth })); - throw new Error(`${String(e).split('\n')[0]} (${label}; ${why})`); + if (await tab.isVisible()) await tab.click(o); + else { + const label = ((await tab.textContent()) ?? '').replace(/\d+$/, '').trim(); + try { + await page.locator('.ds-tab-more').click(o); + await page.locator('.ds-menu [role="menuitem"]', { hasText: label }).first().click(o); + } catch (e) { + const why = await page.evaluate(() => JSON.stringify({ more: document.querySelectorAll('.ds-tab-more').length, menu: [...document.querySelectorAll('.ds-menu [role^="menuitem"]')].map(x => x.textContent), w: innerWidth })); + throw new Error(`${String(e).split('\n')[0]} (${label}; ${why})`); + } } + const btn = page.locator(`.sec-subs #sub-${sub}`); + if (await btn.count()) { await btn.click(o); return; } + // a section with one sub view has no segmented control: the section itself is the place + if (await page.locator('.sec-subs button').count()) throw new Error(`${id}: not in the ${sec} section's views`); +} + +/** Every place of the open app, in order: each section, then each of its sub views (as clickTab ids). */ +async function places(page: Page, o: { timeout?: number } = {}): Promise { + const secs = await page.locator('[role="tab"]').evaluateAll(ts => ts.map(x => x.id.replace(/^tab-/, ''))); + const out: string[] = []; + for (const sec of secs) { + await page.locator(`[role="tab"]#tab-${sec}`).click(o).catch(() => {}); + await page.locator('[data-testid="section-head"]').waitFor({ state: 'visible', timeout: o.timeout }).catch(() => {}); + const subs = await page.locator('.sec-subs button').evaluateAll(bs => bs.map(b => b.id.replace(/^sub-/, ''))); + const first = Object.entries(SECTION_OF).find(([, s]) => s === sec)?.[0]; + // a one-view section: its only view (Players without games is Reviews) + out.push(...(subs.length ? subs : first ? [first] : []).map(x => `tab-${x}`)); + } + return out; } async function state(page: Page) { @@ -342,10 +373,12 @@ async function runCase(browser: Browser, name: string, sidecar: Sidecar, o: { br if (await wizard.count()) problems.push('the welcome guide does not close with Escape'); } // every sidebar entry: the portfolio, each app (folders that are gone included), each workspace screen - const count = await page.locator('nav.side button.nav').count(); + // (apps are .nav rows; the workspace is the footer's icon row, named by aria-label) + const SIDE = 'nav.side button.nav, nav.side button.nav-ws'; + const count = await page.locator(SIDE).count(); for (let i = 0; i < count; i++) { - const b = page.locator('nav.side button.nav').nth(i); - const label = ((await b.innerText().catch(() => '')) || `entry ${i}`).split('\n')[0]!.trim(); + const b = page.locator(SIDE).nth(i); + const label = ((await b.getAttribute('aria-label').catch(() => null)) || (await b.innerText().catch(() => '')) || `entry ${i}`).split('\n')[0]!.trim(); await b.click({ timeout: WAIT_MS }).catch(e => problems.push(`could not open ${label}: ${String(e).split('\n')[0]}`)); await settle(page); await check(label); @@ -365,7 +398,7 @@ async function runCase(browser: Browser, name: string, sidecar: Sidecar, o: { br // shared with the CLI: each account names its credential profile (or that its old keychain key has not moved) const accts = fx.accounts_list as { label?: string; profile?: string | null }[]; if (accts.length) { - const nav = page.locator('nav.side button.nav', { hasText: 'Accounts' }).first(); + const nav = page.locator('nav.side [data-screen="accounts"]').first(); if (await nav.count()) { await nav.click({ timeout: WAIT_MS }); await settle(page); @@ -510,8 +543,8 @@ async function runSubmitCase(browser: Browser, sidecar: Sidecar, base: 'd3-full' await gone(page, '.cp-panel'); await crashCheck('Releases'); visited++; - // every tab of the app, then App info's console steps and the Dependencies screen - const tabs = await page.locator('[role="tab"]').evaluateAll(ts => ts.map(x => x.id)); + // every place of the app (each section and its sub views), then App info's console steps and Dependencies + const tabs = await places(page, { timeout: WAIT_MS }); for (const id of tabs) { await clickTab(page, id, { timeout: WAIT_MS }).catch(e => problems.push(`tab ${id}: ${String(e).split('\n')[0]}`)); await settle(page); @@ -524,7 +557,7 @@ async function runSubmitCase(browser: Browser, sidecar: Sidecar, base: 'd3-full' if (id === 'tab-testflight' && !(await text()).length) problems.push('TestFlight: empty'); } notes.push(`tabs: ${tabs.join(', ')}`); - const deps = page.locator('nav.side button.nav', { hasText: 'Dependencies' }).first(); + const deps = page.locator('nav.side [data-screen="dependencies"]').first(); await deps.click({ timeout: WAIT_MS }).catch(() => {}); await shows(page, '#content', 'Android SDK'); await settle(page); @@ -710,7 +743,7 @@ async function runAutoReadCase(browser: Browser): Promise { await page.locator('nav.side .nav-app:visible').nth(i).click({ timeout: WAIT_MS }).catch(e => problems.push(`app ${i}: ${String(e).split('\n')[0]}`)); await settle(page); const appName = (await page.locator('nav.side .nav-app:visible').nth(i).innerText().catch(() => `app ${i}`)).split('\n')[0]!.trim(); - const tabs = await page.locator('[role="tab"]').evaluateAll(ts => ts.map(x => x.id)); + const tabs = await places(page, { timeout: WAIT_MS }); for (const id of tabs) { await clickTab(page, id, { timeout: WAIT_MS }).catch(e => problems.push(`${appName} ${id}: ${String(e).split('\n')[0]}`)); await settle(page); From 5b6e0992ca6c3632eb76b47494a08a8acd835b28 Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 13:05:40 +0800 Subject: [PATCH 002/107] desktop: boot check finds unsaved marks on the sub views; neutral next-step lines before a read --- apps/desktop/src/i18n/en.ts | 4 ++-- apps/desktop/src/i18n/tr.ts | 4 ++-- tools/desktop-boot-check.ts | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index c4f37ed..9c5642b 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2140,7 +2140,7 @@ export const en = { "next.managed": "{version} waits in Publishing overview (managed publishing)", "next.upload": "Upload a first build", "next.live": "Live: {version}", - "next.notRead": "Stores not read yet", + "next.notRead": "Release state shows once the stores are read", "next.storeLive": "The live store page, read from the stores", "next.listingProblems": "{count} listing problems", "next.listingProblems_one": "1 listing problem", @@ -2154,7 +2154,7 @@ export const en = { "next.products": "{products} products · {subs} subscriptions", "next.reviews": "{count} reviews without a reply", "next.reviews_one": "1 review without a reply", - "next.reviewsUnread": "Reviews not read yet", + "next.reviewsUnread": "See what players say", "next.reviewsOk": "All reviews answered", "next.gamesApiOff": "Play Games Services API is off", "next.gamesBlockers": "{count} game services blockers", diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index e19a2e7..6872f6b 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -2142,7 +2142,7 @@ export const tr: Record = { "next.managed": "{version} Yayınlama özeti'nde bekliyor (yönetilen yayınlama)", "next.upload": "İlk build'i yükle", "next.live": "Yayında: {version}", - "next.notRead": "Mağazalar henüz okunmadı", + "next.notRead": "Yayın durumu mağazalar okununca görünür", "next.storeLive": "Mağazalardan okunan canlı mağaza sayfası", "next.listingProblems": "{count} mağaza metni sorunu", "next.listingProblems_one": "1 mağaza metni sorunu", @@ -2156,7 +2156,7 @@ export const tr: Record = { "next.products": "{products} ürün · {subs} abonelik", "next.reviews": "{count} yorum yanıtsız", "next.reviews_one": "1 yorum yanıtsız", - "next.reviewsUnread": "Yorumlar henüz okunmadı", + "next.reviewsUnread": "Oyuncuların söylediklerine bak", "next.reviewsOk": "Tüm yorumlar yanıtlandı", "next.gamesApiOff": "Play Games Services API kapalı", "next.gamesBlockers": "{count} oyun servisi engeli", diff --git a/tools/desktop-boot-check.ts b/tools/desktop-boot-check.ts index 1943097..cc4d1e4 100644 --- a/tools/desktop-boot-check.ts +++ b/tools/desktop-boot-check.ts @@ -976,7 +976,7 @@ async function runEditorCase(browser: Browser, sidecar: Sidecar): Promise problems.push(`no enabled Save button: ${String(e).split('\n')[0]} (${(await text('[data-edit-bar="listings"]')).slice(0, 600)})`)); - await page.waitForFunction(() => !document.querySelector('#tab-listings .ed-dot'), null, { timeout: WAIT_MS, polling: 50 }).catch(() => problems.push('the unsaved mark stays after Save')); + await page.waitForFunction(() => !document.querySelector('#sub-listings .ed-dot'), null, { timeout: WAIT_MS, polling: 50 }).catch(() => problems.push('the unsaved mark stays after Save')); await settle(page); const md = readFileSync(appstore, 'utf8'); const changed = md.split('\n').filter((l, i) => l !== before.md.split('\n')[i]); @@ -1022,7 +1022,7 @@ async function runEditorCase(browser: Browser, sidecar: Sidecar): Promise problems.push(`App info: no enabled Save button: ${String(e).split('\n')[0]} (${(await text('[data-edit-bar="appinfo"]')).slice(0, 600)})`)); - await page.waitForFunction(() => !document.querySelector('#tab-appinfo .ed-dot'), null, { timeout: WAIT_MS, polling: 50 }).catch(() => problems.push('App info: the unsaved mark stays after Save')); + await page.waitForFunction(() => !document.querySelector('#sub-appinfo .ed-dot'), null, { timeout: WAIT_MS, polling: 50 }).catch(() => problems.push('App info: the unsaved mark stays after Save')); const cfg = readFileSync(join(dir, 'storebound.config.ts'), 'utf8'); const cfgChanged = cfg.split('\n').filter((l, i) => l !== before.cfg.split('\n')[i]); if (!cfg.includes("reviewNotes: 'Sign in with the demo account.'") || cfgChanged.length !== 1 || !cfg.includes('// storebound config of the demo app')) problems.push(`storebound.config.ts: expected one changed line with reviewNotes and the comments kept, got ${JSON.stringify(cfgChanged).slice(0, 300)}`); From 445ab1adbc1523b6d7588297735b24c8dc62e0a8 Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 13:02:35 +0800 Subject: [PATCH 003/107] apple signing: every signable target (extensions, App Group check), build keychain unlock over SSH, api confirm docs --- CHANGELOG.md | 6 + cli/api.ts | 4 +- cli/api/gate.ts | 4 +- cli/api/wrapped.gen.ts | 2 +- cli/apple/build-mas.ts | 4 +- cli/apple/build.ts | 65 ++++++- cli/apple/signing.ts | 159 +++++++++++++-- cli/data/flags.json | 22 ++- cli/lib/keychain.ts | 45 +++++ cli/lib/signing.ts | 29 ++- cli/lib/targets.ts | 138 +++++++++++++ cli/lib/xcode.ts | 4 +- docs/api-coverage.md | 2 +- skills/build-and-sign/SKILL.md | 9 +- skills/store-api/SKILL.md | 4 +- skills/store-pitfalls/SKILL.md | 36 +++- tests/signing-targets.test.ts | 341 +++++++++++++++++++++++++++++++++ 17 files changed, 832 insertions(+), 42 deletions(-) create mode 100644 cli/lib/keychain.ts create mode 100644 cli/lib/targets.ts create mode 100644 tests/signing-targets.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a36300..5a15c38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ - `shots.feature.rotate` (degrees) is honoured by the Play feature graphic like a scene frame's `rotate`: added to the template tilt and the phone is re-fitted (`-90` stands a landscape game frame upright); with `shots.layout: "bleed"` the frame image is turned and still covers the canvas. - `shots check` / `shots render` without frames for a language that has copy use the default language's frames (an info line, no failure); `shots.perLanguageFrames: true` requires per-language frames. `shots fonts --lang en,tr` limits the report to those languages (the font manifest keeps all of them). - `shots capture` web shots take `waitSelector` and `click` selector lists per layout (`{ phone, tablet, any }`, data attributes first, `text=` fallback). A miss names the selector and the size with every candidate's match count; `shots capture --dry` prints the candidates per size and `--dry --probe` opens the pages and reports which one matched or MISSed on which size. +||||||| parent of ae8d773 (apple signing: every signable target (extensions, App Group check), build keychain unlock over SSH, api confirm docs) + +- `apple signing ensure` signs every target of the Xcode project, not only the app: it reads the pbxproj targets (app, app extensions such as a share extension or widget, watch app, App Clip: Release PRODUCT_BUNDLE_IDENTIFIER and entitlements), registers each extension's bundle id (`POST /v1/bundleIds`, platform from the profile type), enables the capabilities its entitlements need (read back) and, with manual signing, creates its App Store profile; `--dry` lists every target. `apple.signing.extensions` (`bundleId`, `name`, `capabilities`, `appGroups`) replaces the detected extensions when detection is wrong; `status` shows a target table. +- App Groups: the API cannot assign a group to a bundle id, so `signing status` / `ensure` and (manual signing) `apple build` decode the profile and compare its `com.apple.security.application-groups` with the target's entitlements; a gap names the developer portal link (`.../identifiers/bundleId/edit/`) and the steps to tick the group. `signing ensure --refresh-profile` makes the profile again afterwards; `apple build --allow-app-group-gap` overrides. +- `apple build` over SSH / in CI: the refusal now names the fixes (GUI session `.command` file, a CI runner with an unlocked keychain, a build keychain). New `--keychain-unlock` (keychain password from `STOREBOUND_KEYCHAIN_PASSWORD`, never printed; runs `security unlock-keychain` and `set-key-partition-list`) and `--keychain ` (sign with a build keychain). +- `storebound api` writes: the usage, the dry-run message and the refusals show the agent form with an example (`--yes --confirm `), and the store-api skill says both flags are needed. - Play managed publishing: `storebound console plan play-publish-changes` (Publishing overview > "Changes ready to publish" > the owner's final "Publish N changes" + its confirm dialog; never-press: Discard changes, Stop managed publishing; `--lookup` lists the ready production releases from the API) and `console verify play-publish-changes` (API: passes when no production release is left ready to publish; or the page text). `play status` (and `--json` `readyToPublish`) prints the ready items and the plan hint, `storebound status` names the plan, and the desktop Releases tab shows the same hint with a button that opens Publishing overview. A release counts as ready to publish only when the public store page positively shows an older version; a 404, a failed or stale page read, a same or unparseable version is never ready (verify says "not determined" instead of a mismatch). - `apple upload` / `play upload` `--pre-check ""` (config `upload.preCheck`) runs your gate before any byte is sent; a nonzero exit refuses the upload (ship-step exit 1, `PRE_CHECK_FAILED`). `--evidence ` (`upload.evidence`) writes file, sha256, size, command, exit code, stdout tail, time and version, also on failure, and the result carries it as `preCheck`. - New `storebound shots copy translate --to de,es` fills store/shots/copy.json per language (agent translation or `--translate ""`), machine-translated until `shots copy reviewed`, caption limits respected; `shots copy check`; `check` / `shots render` name this command for a missing language. diff --git a/cli/api.ts b/cli/api.ts index a629aed..953919f 100644 --- a/cli/api.ts +++ b/cli/api.ts @@ -35,7 +35,9 @@ export const USAGE = `usage: GETs run right away. Writes print the exact request and are sent only with --yes in a terminal; owner-only operations (submit, release, delete, cancel, prices, testers, review replies, experiments) also ask you to type the operation id. Agents without a terminal: --yes --confirm (must equal the operation id; plain writes -only, never owner-only operations). Full table: docs/api-coverage.md`; +only, never owner-only operations), e.g. + storebound api apple bundleIds_createInstance --body bundle.json --yes --confirm bundleIds_createInstance +Full table: docs/api-coverage.md`; export interface Row { store: Store; id: string; method: string; path: string; gate: GateKind; wrapped: string } diff --git a/cli/api/gate.ts b/cli/api/gate.ts index d3f6ef7..c7c8cef 100644 --- a/cli/api/gate.ts +++ b/cli/api/gate.ts @@ -94,7 +94,7 @@ export type Decision = | { action: 'refuse'; why: string }; /** --yes without a terminal: refused loudly (exit 1, stderr), never a silent dry run (pitfall 77). */ -export const NOT_A_TTY = 'refused: writes through `storebound api` need an interactive terminal (stdout is not a TTY: piped or redirected). Nothing was sent. Run it without a pipe or redirect, add --confirm (agents, plain writes only), or use the dedicated storebound command.'; +export const NOT_A_TTY = 'refused: writes through `storebound api` need an interactive terminal (stdout is not a TTY: piped or redirected). Nothing was sent. Run it without a pipe or redirect, add --confirm (agents, plain writes only: --yes --confirm bundleIds_createInstance for the operation bundleIds_createInstance), or use the dedicated storebound command.'; /** --confirm never stands in for the owner's typed id on an owner-only operation. */ export const OWNER_NO_CONFIRM = 'refused: owner-only operation: --confirm does not apply; the owner types the operation id in an interactive terminal. Nothing was sent.'; @@ -107,7 +107,7 @@ export function decide(g: GateInput): Decision { if (g.agent && !g.bypass) return { action: 'refuse', why: g.kind === 'owner' ? 'owner-only operation: never through MCP or the desktop app; the owner runs it in a terminal' : 'writes through `storebound api` are not available to MCP or the desktop app; run it in a terminal' }; - if (g.dry || !g.yes) return { action: 'dry', why: g.dry ? '--dry' : 'writes are dry by default: add --yes (in a terminal) to send' }; + if (g.dry || !g.yes) return { action: 'dry', why: g.dry ? '--dry' : g.kind === 'owner' ? 'writes are dry by default: add --yes (in a terminal; owner-only operations also ask for the typed operation id)' : `writes are dry by default: add --yes in a terminal, or from an agent's shell --yes --confirm (e.g. \`storebound api apple ${g.opId ?? 'bundleIds_createInstance'} --body body.json --yes --confirm ${g.opId ?? 'bundleIds_createInstance'}\`)` }; if (g.confirm !== undefined) { // an agent's typed confirmation: only for plain writes, only when it names this exact operation if (g.kind === 'owner' && !g.bypass) return { action: 'refuse', why: OWNER_NO_CONFIRM }; diff --git a/cli/api/wrapped.gen.ts b/cli/api/wrapped.gen.ts index d361d9a..4a8fca5 100644 --- a/cli/api/wrapped.gen.ts +++ b/cli/api/wrapped.gen.ts @@ -134,7 +134,7 @@ export const WRAPPED: { apple: Record; play: Record { } if (!mac) throw new Error('apple build --platform macos needs macOS (tauri build, codesign, productbuild)'); const remote = remoteBuildSession(process.env, run1(['launchctl', 'managername'], 5000).out.trim()); - if (remote && !unsigned && !a.has('allow-ssh')) throw new Error(`apple build: ${remote}; codesign there fails with errSecInternalComponent (locked keychain). Run it in the Mac's logged-in session (write the command into a .command file and \`open -a Terminal \`), or add --unsigned for a pipeline check.`); + if (remote && !unsigned && !a.has('allow-ssh')) throw new Error(`${sshBuildRefusal(remote, { unlockFlag: false })} + Or add --unsigned for a pipeline check.`); mkdirSync(out, { recursive: true }); writeFileSync(entFile, entitlementsXml(ent)); writeFileSync(configFile, `${JSON.stringify(overlay, null, 2)}\n`); diff --git a/cli/apple/build.ts b/cli/apple/build.ts index 74f0056..97ac211 100644 --- a/cli/apple/build.ts +++ b/cli/apple/build.ts @@ -3,6 +3,11 @@ // (Mac App Store build of a Tauri app: cli/apple/build-mas.ts) // storebound apple build [--version 1.2.0] [--build-number N] [--scheme S] [--workspace W | --project P] // [--configuration Release] [--out build/ios] [--no-prebuild] [--allow-stale-web] [--dry] [--json] +// [--keychain ] [--keychain-unlock] [--allow-ssh] [--allow-app-group-gap] +// Over SSH (no GUI session) codesign needs an unlocked keychain: --keychain-unlock unlocks it with the password in +// STOREBOUND_KEYCHAIN_PASSWORD (never printed) and sets its key partition list; --keychain signs with a build +// keychain instead of the login keychain. Manual signing: stops before the build when a target's App Group is not in its +// installed profile (the developer portal assigns groups; storebound apple signing status/ensure names the steps). // First the prebuild (build.prebuild / apple.prebuild: e.g. the web build + npx cap sync; once per flow run), then a // Capacitor project's copied web assets (ios/App/App/public) must be newer than the source, else it stops. // Finds the workspace / project and scheme (native, Capacitor ios/App, React Native ios/, Flutter ios/Runner, @@ -24,20 +29,22 @@ import { pump } from '../lib/runner'; import { parseMobileprovision, toXmlPlist } from '../lib/plist'; import { nextBuildNumber } from '../lib/release'; import { exportOptions, profileDirs } from '../lib/signing'; +import { keychainPassword, keychainSigningArgs, parseKeychainList, showSecurity, sshBuildRefusal, unlockSteps } from '../lib/keychain'; +import { appGroupGapsFor, appGroupSteps } from '../lib/targets'; import { capacitorProject, webStep } from '../lib/web-assets'; import { archiveArgs, type AscAuth, distributionErrors, exportArgs, foreignRsync, remoteBuildSession, xcodePath, flutterConfigArgs, ipaInfo, ipaVersionProblem, pickXcode, scanXcode, showCmd } from '../lib/xcode'; import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files'; import { ascClient } from './asc'; import { parsePlatform } from './platform'; -import { findBundleId } from './signing'; +import { discoverTargets, findBundleId } from './signing'; import * as rt from '../lib/rt'; import { shipPhase } from '../lib/ship-step'; const TAG = 'build'; /** Installed App Store profiles for a bundle id, newest expiry first: { uuid, name, team }. */ -export function installedProfiles(bundleId: string, teamId: string | null, home = homedir()): { uuid: string; name: string; team: string; expires: string }[] { - const out: { uuid: string; name: string; team: string; expires: string }[] = []; +export function installedProfiles(bundleId: string, teamId: string | null, home = homedir()): { uuid: string; name: string; team: string; expires: string; entitlements: Record }[] { + const out: { uuid: string; name: string; team: string; expires: string; entitlements: Record }[] = []; for (const d of profileDirs(home)) { let files: string[] = []; try { files = readdirSync(d).filter(f => f.endsWith('.mobileprovision')); } catch { continue; } @@ -50,13 +57,35 @@ export function installedProfiles(bundleId: string, teamId: string | null, home const store = !p.ProvisionedDevices && !p.ProvisionsAllDevices && ent?.['get-task-allow'] !== true; if (!store || appId !== `${team}.${bundleId}` || (teamId && team !== teamId)) continue; if (Date.parse(String(p.ExpirationDate)) < Date.now()) continue; - if (!out.some(x => x.uuid === p.UUID)) out.push({ uuid: String(p.UUID), name: String(p.Name), team, expires: String(p.ExpirationDate) }); + if (!out.some(x => x.uuid === p.UUID)) out.push({ uuid: String(p.UUID), name: String(p.Name), team, expires: String(p.ExpirationDate), entitlements: ent ?? {} }); } catch { /* not a profile */ } } } return out.sort((a, b) => Date.parse(b.expires) - Date.parse(a.expires)); } +const security = (argv: string[]): { ok: boolean; out: string } => { const r = rt.spawnSync(argv, { stdout: 'pipe', stderr: 'pipe', timeout: 60_000 }); return { ok: r.success, out: (r.stdout.toString() + r.stderr.toString()).trim() }; }; + +/** Puts a build keychain on the user's keychain search list (xcodebuild -exportArchive finds identities there). */ +function addToSearchList(keychain: string): void { + const list = parseKeychainList(security(['security', 'list-keychains', '-d', 'user']).out); + const step = unlockSteps(keychain, list).find(x => x[1] === 'list-keychains'); + if (step) { const r = security(step); if (!r.ok) throw new Error(`${showSecurity(step)} failed: ${r.out.slice(0, 200)}`); log(TAG, `${keychain} added to the keychain search list`); } +} + +/** --keychain-unlock: unlocks the keychain (the login keychain, or --keychain) with STOREBOUND_KEYCHAIN_PASSWORD and + * sets the key partition list so codesign uses the keys without a prompt. The password is never printed. */ +function unlockKeychain(keychain: string | null): void { + const pw = keychainPassword(process.env); + const kc = keychain ?? parseKeychainList(security(['security', 'default-keychain', '-d', 'user']).out)[0] ?? join(homedir(), 'Library/Keychains/login.keychain-db'); + const list = parseKeychainList(security(['security', 'list-keychains', '-d', 'user']).out); + for (const step of unlockSteps(kc, list)) { + const r = security(step.map(x => (x === 'PASSWORD' ? pw : x))); + if (!r.ok) throw new Error(`${showSecurity(step)} failed: ${r.out.split(pw).join('***').slice(0, 300)}`); + } + log(TAG, `keychain ${kc} unlocked, key partition list set (password from ${'STOREBOUND_KEYCHAIN_PASSWORD'})`); +} + export async function runLogged(argv: string[], cwd: string, logFile: string, show: string, env?: Record): Promise { log(TAG, `$ ${show}`); log(TAG, `full output: ${logFile}`); @@ -142,33 +171,45 @@ export async function run(ctx: Ctx): Promise { if (build && !/^\d+(\.\d+){0,2}$/.test(build)) throw new Error('--build-number: digits, e.g. 42 or 1.0.42'); const buildNo = build ?? ''; - const ids = [bundleId, ...(signing.extraBundleIds ?? [])]; + const scan = discoverTargets(root, config); + const ids = [bundleId, ...scan.extensions.map(t => t.bundleId)]; const profiles: Record = {}; if (style === 'manual') for (const id of ids) { const p = installedProfiles(id, teamId)[0]; if (p) profiles[id] = p.uuid; else if (!dry) throw new Error(`no installed App Store profile for ${id}: storebound apple signing ensure`); else warn(TAG, `no installed App Store profile for ${id} (storebound apple signing ensure)`); } + // App Groups: a manual-signing profile carries a group only when the portal assigned it to the bundle id + const groupGaps = style === 'manual' ? appGroupGapsFor([...(scan.main ? [scan.main] : []), ...scan.extensions], id => { const p = installedProfiles(id, teamId)[0]; return p ? p.entitlements : null; }, () => null) : []; + for (const g of groupGaps) { + const msg = `${g.bundleId}: the installed profile lacks App Group(s) ${g.groups.join(', ')}`; + if (a.has('allow-app-group-gap') || dry) warn(TAG, msg); + else throw new Error(`${msg}; codesign would refuse the build.\n${appGroupSteps(g).slice(1).join('\n')}\n(--allow-app-group-gap builds anyway)`); + } const opts = exportOptions({ teamId: teamId ?? '', style, profiles: style === 'manual' ? (Object.keys(profiles).length ? profiles : { [bundleId]: '' }) : undefined }); const plist = toXmlPlist(opts); + const keychainFlag = a.get('keychain'); + const keychain = keychainFlag && keychainFlag !== 'true' ? resolve(keychainFlag) : null; + const unlock = a.has('keychain-unlock'); const archivePath = join(out, `${target.scheme}.xcarchive`); const exportPath = exportDir(out, version, buildNo); const authPreview: AscAuth = { keyPath: '$ASC_P8', keyId: '$ASC_KEY_ID', issuerId: '$ASC_ISSUER_ID' }; const auth = style === 'automatic' ? authPreview : null; const steps = [ ...(target.framework === 'flutter' ? [showCmd(flutterConfigArgs(buildNo, version), null)] : []), - showCmd(archiveArgs({ target, root, configuration, archivePath, version, build: buildNo, teamId: teamId ?? undefined, auth }), auth), + showCmd(archiveArgs({ target, root, configuration, archivePath, version, build: buildNo, teamId: teamId ?? undefined, auth, settings: keychain ? keychainSigningArgs(keychain) : undefined }), auth), showCmd(exportArgs(archivePath, join(out, 'ExportOptions.plist'), exportPath, auth), auth), ]; + const unlockPreview = unlock ? unlockSteps(keychain ?? '', []).map(showSecurity) : []; const plan = { framework: target.framework, workspace: target.workspace ?? null, project: target.project ?? null, scheme: target.scheme, configuration, - version: version ?? '(project value)', build: buildNo, teamId, signingStyle: style, exportOptions: opts, out: relative(root, out) || '.', steps, notes: target.notes }; + version: version ?? '(project value)', build: buildNo, teamId, signingStyle: style, exportOptions: opts, out: relative(root, out) || '.', steps: [...unlockPreview, ...steps], notes: target.notes }; const webOpts = { config, root, cap, store: 'apple' as const, tag: TAG, noPrebuild: a.has('no-prebuild'), allowStale: a.has('allow-stale-web') }; if (dry) { const web = await webStep({ ...webOpts, dry: true, quiet: json }); if (json) { printJson({ ...plan, web }); return; } log(TAG, `${target.framework}: ${target.workspace ?? target.project} scheme ${target.scheme} (${configuration}), build ${buildNo}${version ? `, version ${version}` : ''}, signing ${style}`); for (const n of target.notes) log(TAG, n); - for (const s of steps) log(TAG, `would run: ${s}`); + for (const s of plan.steps) log(TAG, `would run: ${s}`); console.log(`\n${relative(root, join(out, 'ExportOptions.plist'))}:\n${plist}`); done(TAG, 'build planned (dry run)'); return; @@ -177,7 +218,11 @@ export async function run(ctx: Ctx): Promise { // codesign in an SSH session cannot use the login keychain: the archive fails late with errSecInternalComponent // (Embed Pods Frameworks). Stop before the web build and the archive (pitfall 101). const remote = remoteBuildSession(process.env, rt.spawnSync(['launchctl', 'managername'], { timeout: 5000 }).stdout.toString().trim()); - if (remote && !a.has('allow-ssh')) throw new Error(`apple build: ${remote}; codesign there fails with errSecInternalComponent (locked keychain). Run it in the Mac's logged-in session: a Jenkins / Forgejo agent started in that session, or from SSH: write the command into a .command file and \`open -a Terminal \` (it runs in the GUI session; unset SSH_CONNECTION SSH_CLIENT SSH_TTY inside). --allow-ssh skips this check after \`security unlock-keychain\`.`); + if (remote && !a.has('allow-ssh') && !unlock && !keychain) throw new Error(sshBuildRefusal(remote)); + if (unlock || keychain) { + if (unlock) unlockKeychain(keychain); + else addToSearchList(keychain!); + } shipPhase('prebuild', 'web build / prebuild'); const web = await webStep({ ...webOpts, dry: false }); for (const n of target.notes) log(TAG, n); @@ -190,7 +235,7 @@ export async function run(ctx: Ctx): Promise { try { if (target.framework === 'flutter') await runLogged(flutterConfigArgs(build!, version), root, join(out, 'flutter-config.log'), showCmd(flutterConfigArgs(build!, version), null)); shipPhase('build', `xcodebuild archive, build ${build}`); - const arch = archiveArgs({ target, root, configuration, archivePath, version, build: build!, teamId: teamId ?? undefined, auth: realAuth }); + const arch = archiveArgs({ target, root, configuration, archivePath, version, build: build!, teamId: teamId ?? undefined, auth: realAuth, settings: keychain ? keychainSigningArgs(keychain) : undefined }); await runLogged(arch, root, join(out, 'xcodebuild-archive.log'), showCmd(arch, realAuth)); shipPhase('sign', 'xcodebuild -exportArchive (signing)'); await exportIpa({ root, out, archivePath, exportPath, auth: realAuth }); diff --git a/cli/apple/signing.ts b/cli/apple/signing.ts index cea09e0..09fb9b0 100644 --- a/cli/apple/signing.ts +++ b/cli/apple/signing.ts @@ -2,7 +2,13 @@ // provisioning profiles, so `apple build` can sign without anyone opening Xcode's Accounts settings. // storebound apple signing status [--json] read-only: capabilities, certificates (with / without a // private key on this machine), profiles, what is installed -// storebound apple signing ensure [--dry] [--yes] [--keychain [path]] [--no-install] [--json] +// storebound apple signing ensure [--dry] [--yes] [--keychain [path]] [--no-install] [--refresh-profile] [--json] +// every signable target of the Xcode project (app, app extensions such as a share extension or widget, watch app, +// App Clip: PRODUCT_BUNDLE_IDENTIFIER + entitlements of project.pbxproj, or apple.signing.extensions) gets its +// bundle id registered, the capabilities its entitlements need and (manual style) an App Store profile. +// App Groups: the API cannot assign a group to a bundle id, so a profile lacks the group until it is ticked in the +// developer portal; ensure compares the entitlements with the profile and names the portal link and the steps; +// --refresh-profile makes the new profile after that. // adds missing capabilities (apple.signing.capabilities) and reads them back (fails when one did not stick). // style automatic (the default) stops there: Xcode owns certificates and profiles. style manual also // creates a distribution certificate when this machine @@ -20,14 +26,15 @@ import { confirm, need } from '../lib/ctx'; import { buildCsr, csrContent, newSigningKey, spkiOfKey } from '../lib/csr'; import { done, log, printJson, warn } from '../lib/log'; import { - type ApiCert, type ApiProfile, type AppleSigning, type BuildEntitlements, buildEntitlements, type CapabilityType, type CapMismatch, type CheckoutInfo, + type ApiCert, type ApiProfile, type AppleSigning, type CapabilitySpec, type BuildEntitlements, buildEntitlements, type CapabilityType, type CapMismatch, type CheckoutInfo, ENTITLEMENT_CAPABILITIES, machoEntitlements, capabilitySpecs, CERT_LIMIT, certificateReq, certSha1, certSpki, type CertType, compareCapabilities, deleteProfileCmd, ensureScope, explainCertError, parseIdentities, pickCertificate, planCapabilities, planProfile, profileDirs, type ProfileType, type ProjectEntitlements, projectEntitlements, readProfiles, unusedStoreboundProfiles, verifyCapabilities, } from '../lib/signing'; import { isBuildCopyPath } from '../lib/privacy-manifest'; import { exportedIpas, ipaInfo, scanXcode } from '../lib/xcode'; -import { parsePlist } from '../lib/plist'; +import { parseMobileprovision, parsePlist } from '../lib/plist'; +import { appGroupGapsFor, appGroupSteps, type AppGroupGap, pbxTargets, type TargetKind } from '../lib/targets'; import { zipEntries, zipRead } from '../lib/zip'; import type { ShipConfig } from '../lib/config'; import { textTable } from '../lib/table'; @@ -37,7 +44,7 @@ import { createOnce, isAmbiguous, upsertTiming } from '../lib/upsert'; import * as rt from '../lib/rt'; const TAG = 'signing'; -const USAGE = 'usage: storebound apple signing status | ensure [--dry] [--keychain [path]] [--no-install]'; +const USAGE = 'usage: storebound apple signing status | ensure [--dry] [--keychain [path]] [--no-install] [--refresh-profile]'; export const signingDir = (root: string): string => join(root, '.storebound', 'signing'); @@ -98,6 +105,78 @@ export function readProjectEntitlements(root: string): ProjectEntitlements[] | n return out; } +export interface SigningTarget { + bundleId: string; name: string; kind: TargetKind | 'configured'; source: 'xcode' | 'config'; + /** the .entitlements file read (relative to the repository), if any */ + entitlements: string | null; + capabilities: CapabilitySpec[]; + /** com.apple.security.application-groups of the entitlements */ + appGroups: string[]; +} +export interface TargetScan { main: SigningTarget | null; extensions: SigningTarget[]; notes: string[] } + +/** Every signable target of the iOS project: the app and its extensions / widgets / watch app / App Clip, read from the + * pbxproj (Release configuration) and their entitlements files. apple.signing.extensions replaces the detected + * extensions; apple.signing.extraBundleIds are added (no capabilities). Capabilities come from the entitlements: + * keychain-access-groups has no App ID capability (nothing to enable). Offline. */ +export function discoverTargets(root: string, config: ShipConfig): TargetScan { + const main = config.apple?.bundleId ?? ''; + const s: AppleSigning = config.apple?.signing ?? {}; + const notes: string[] = []; + const found: SigningTarget[] = []; + const projects = scanXcode(root).projects.filter(p => !isBuildCopyPath(p) && !/(^|\/)Pods\.xcodeproj$/.test(p)); + for (const proj of projects) { + let pbx = ''; + try { pbx = readFileSync(join(root, proj, 'project.pbxproj'), 'utf8'); } catch { continue; } + for (const t of pbxTargets(pbx)) { + if (!t.bundleId) { notes.push(`${proj}: target ${t.name} has bundle id "${t.unresolved ?? '?'}" which storebound cannot resolve (an xcconfig or a build variable): list it in apple.signing.extensions`); continue; } + if (found.some(f => f.bundleId === t.bundleId)) continue; + let caps: CapabilitySpec[] = [], groups: string[] = [], file: string | null = null; + if (t.entitlements) { + const f = join(dirname(join(root, proj)), t.entitlements); + try { + const xml = readFileSync(f, 'utf8'); + file = relative(root, f).split(sep).join('/'); + caps = projectEntitlements(file, xml, true).caps.map((c): CapabilitySpec => ({ type: c.cap })); + const g = (parsePlist(xml) as Record)['com.apple.security.application-groups']; + groups = Array.isArray(g) ? g.map(String) : []; + } catch { notes.push(`${proj}: target ${t.name}: entitlements file ${t.entitlements} not readable`); } + } + found.push({ bundleId: t.bundleId, name: t.name, kind: t.kind, source: 'xcode', entitlements: file, capabilities: caps, appGroups: groups }); + } + } + const mainTarget = found.find(t => t.bundleId === main) ?? null; + let extensions: SigningTarget[]; + if (s.extensions?.length) { + extensions = s.extensions.map((x): SigningTarget => { + const det = found.find(t => t.bundleId === x.bundleId); + return { bundleId: x.bundleId, name: x.name ?? det?.name ?? x.bundleId, kind: det?.kind ?? 'configured', source: 'config', entitlements: det?.entitlements ?? null, + capabilities: (x.capabilities ?? det?.capabilities ?? []).map((c): CapabilitySpec => (typeof c === 'string' ? { type: c } : c)), appGroups: x.appGroups ?? det?.appGroups ?? [] }; + }); + notes.push(`apple.signing.extensions replaces the ${found.filter(t => t.bundleId !== main).length} extension target(s) found in the Xcode project`); + } else extensions = found.filter(t => t.bundleId !== main); + for (const b of s.extraBundleIds ?? []) if (b !== main && !extensions.some(t => t.bundleId === b)) extensions.push({ bundleId: b, name: b, kind: 'configured', source: 'config', entitlements: null, capabilities: [], appGroups: [] }); + return { main: mainTarget, extensions, notes }; +} + +/** Name of a bundle id on the developer portal (letters, digits, spaces). */ +export const bundleIdName = (appName: string, t: SigningTarget): string => `${appName} ${t.name}`.replace(/[^A-Za-z0-9 ]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 60); + +const platformOf = (type: ProfileType): 'IOS' | 'MAC_OS' => (type.startsWith('MAC_') ? 'MAC_OS' : 'IOS'); + +/** The entitlements of the newest ACTIVE profile of a bundle id that the API serves with its content (a managed one + * is not readable: null = unknown). */ +export function profileEntitlements(list: ApiProfile[]): Record | null { + const p = list.filter(x => !x.managed && x.attributes.profileState === 'ACTIVE' && x.attributes.profileContent) + .sort((a, b) => Date.parse(b.attributes.expirationDate ?? '') - Date.parse(a.attributes.expirationDate ?? ''))[0]; + if (!p) return null; + try { return (parseMobileprovision(Buffer.from(p.attributes.profileContent!, 'base64')).Entitlements ?? {}) as Record; } catch { return null; } +} + +export function warnAppGroupGaps(gaps: AppGroupGap[], tag: string): void { + for (const g of gaps) for (const l of appGroupSteps(g)) warn(tag, l); +} + const git = (root: string, args: string[]): string | null => { try { const r = rt.spawnSync(['git', '-C', root, ...args], { stdout: 'pipe', stderr: 'pipe' }); @@ -171,7 +250,9 @@ export async function capabilityReport(c: Asc, config: ShipConfig, root: string) const b = await findBundleId(c, bundleId); const appId = b ? (b.capabilities as string[]) : null; const cfg = capabilitySpecs(config.apple?.signing, config.games?.gameCenter === true).map(x => x.type); - const project = readProjectEntitlements(root); + // an extension's entitlements belong to its own bundle id (signing ensure handles it), not to the app's App ID + const extFiles = new Set(discoverTargets(root, config).extensions.map(t => t.entitlements).filter((f): f is string => !!f)); + const project = readProjectEntitlements(root)?.filter(p => !extFiles.has(p.file)) ?? null; let build: BuildEntitlements | null = null; try { build = builtIpaEntitlements(root, config); } catch { /* unreadable IPA: compared without it */ } const checkout = project && cfg.some(x => Object.values(ENTITLEMENT_CAPABILITIES).includes(x as CapabilityType)) && !project.some(p => p.caps.length) ? checkoutInfo(root) : null; @@ -219,6 +300,9 @@ export interface SigningState { capabilities: { present: string[]; missing: string[]; extra: string[] }; certificate: { type: CertType; chosen: string | null; why: string; onTeam: number; withKey: number; limit: number | null }; profiles: { bundleId: string; name: string | null; uuid: string | null; state: string; installed: boolean; why: string }[]; + /** every signable target: where it was found, its capabilities (present / missing on its App ID) and App Groups */ + targets: { bundleId: string; name: string; kind: string; source: string; registered: boolean; capabilities: string[]; missingCapabilities: string[]; appGroups: string[] }[]; + appGroupGaps: AppGroupGap[]; } export async function run(ctx: Ctx): Promise { @@ -237,7 +321,13 @@ export async function run(ctx: Ctx): Promise { const c = await ascClient(root, bundleId, config.apple?.appId); // --- read everything first (read-only) - const ids = [bundleId, ...(s.extraBundleIds ?? [])]; + const scan = discoverTargets(root, config); + const exts = scan.extensions; + const ids = [bundleId, ...exts.map(t => t.bundleId)]; + const targetOf = (id: string): SigningTarget | undefined => (id === bundleId ? scan.main ?? undefined : exts.find(t => t.bundleId === id)); + const refresh = a.has('refresh-profile'); + const refreshIds = new Set(ids.filter(id => (targetOf(id)?.appGroups.length ?? 0) > 0)); + if (refresh && !refreshIds.size) ids.forEach(id => refreshIds.add(id)); const bundles = new Map(); for (const id of ids) bundles.set(id, await findBundleId(c, id)); const main = bundles.get(bundleId); @@ -257,8 +347,11 @@ export async function run(ctx: Ctx): Promise { const b = bundles.get(id); if (!b) { profiles.set(id, { list: [], plan: null }); continue; } const list = await profilesOf(c, b.id, profileType); - profiles.set(id, { list, plan: choice.cert ? planProfile(list, { bundleId: id, bundleIdId: b.id, certId: choice.cert.id, type: profileType }) : null }); + profiles.set(id, { list, plan: choice.cert ? planProfile(list, { bundleId: id, bundleIdId: b.id, certId: choice.cert.id, type: profileType, refresh: refresh && refreshIds.has(id) }) : null }); } + const wantCaps = (t: SigningTarget): CapabilitySpec[] => (t.bundleId === bundleId ? capabilitySpecs(s, config.games?.gameCenter === true) : t.capabilities); + const targetList: SigningTarget[] = [...(scan.main ? [scan.main] : []), ...exts]; + const gapsOf = (): AppGroupGap[] => appGroupGapsFor(targetList, id => { const b = bundles.get(id); return b ? profileEntitlements(profiles.get(id)?.list ?? []) : null; }, id => bundles.get(id)?.id ?? null); const state: SigningState = { bundleId, teamId: s.teamId ?? main?.attributes?.seedId ?? null, registered: !!main, capabilities: { present: capPlan.present, missing: capabilitySpecs(s, config.games?.gameCenter === true).filter(w => !capPlan.present.includes(w.type)).map(w => w.type), extra: capPlan.extra }, @@ -266,7 +359,7 @@ export async function run(ctx: Ctx): Promise { onTeam: certs.filter(x => Date.parse(x.attributes.expirationDate ?? '') > Date.now()).length, withKey: choice.usable.length, limit: CERT_LIMIT[certType] ?? null }, profiles: ids.map(id => { const p = profiles.get(id)!; - if (!bundles.get(id)) return { bundleId: id, name: null, uuid: null, state: 'bundle id not registered', installed: false, why: 'register it first (storebound apple bundle for the app; extensions in Certificates, Identifiers & Profiles)' }; + if (!bundles.get(id)) return { bundleId: id, name: null, uuid: null, state: 'bundle id not registered', installed: false, why: id === bundleId ? 'register it first (storebound apple bundle)' : 'storebound apple signing ensure registers it' }; const r = p.plan?.reuse; const managed = p.list.find(x => x.managed && x.attributes.profileState === 'ACTIVE'); if (style === 'automatic') @@ -277,8 +370,17 @@ export async function run(ctx: Ctx): Promise { return { bundleId: id, name: r?.attributes.name ?? null, uuid: r?.attributes.uuid ?? null, state: r ? 'ACTIVE' : 'needs a new profile', installed: installedAt(r?.attributes.uuid).length > 0, why: p.plan?.why ?? 'waits for a usable certificate' }; }), + targets: [], appGroupGaps: [], }; + state.targets = ids.map(id => { + const t = targetOf(id), b = bundles.get(id); + const want = t ? wantCaps(t).map(w => w.type) : id === bundleId ? capabilitySpecs(s, config.games?.gameCenter === true).map(w => w.type) : []; + return { bundleId: id, name: t?.name ?? id, kind: t?.kind ?? 'app', source: t?.source ?? 'config', registered: !!b, capabilities: want, + missingCapabilities: want.filter(w => !(b?.capabilities ?? []).includes(w)), appGroups: t?.appGroups ?? [] }; + }); + state.appGroupGaps = style === 'manual' ? gapsOf() : []; + // storebound-made profiles that nothing uses (automatic signing: all of them; manual: all but the reused one) const inUse = new Set(style === 'manual' ? [...profiles.values()].map(p => p.plan?.reuse?.id).filter((x): x is string => !!x) : []); const unused = [...profiles.values()].flatMap(p => unusedStoreboundProfiles(p.list, inUse)); @@ -296,6 +398,9 @@ export async function run(ctx: Ctx): Promise { log(TAG, `capabilities: ${state.capabilities.present.join(', ') || 'none configured'}${state.capabilities.missing.length ? `; missing ${state.capabilities.missing.join(', ')}` : ''}${state.capabilities.extra.length ? `; also on the bundle id: ${state.capabilities.extra.join(', ')}` : ''}`); log(TAG, `certificate ${certType}: ${choice.why} (${state.certificate.onTeam} valid on the team${state.certificate.limit ? `, limit ${state.certificate.limit}` : ''})`); for (const x of choice.expiring) warn(TAG, `${x.attributes.name ?? x.id} expires ${x.attributes.expirationDate?.slice(0, 10)}: make a new one soon (storebound apple signing ensure)`); + for (const n of scan.notes) log(TAG, n); + console.log(textTable(['target', 'kind', 'bundle id', 'registered', 'capabilities', 'app groups'], state.targets.map(t => [t.name, t.kind, t.bundleId, t.registered ? 'yes' : 'no', t.capabilities.join(', ') || '-', t.appGroups.join(', ') || '-']))); + warnAppGroupGaps(state.appGroupGaps, TAG); console.log(textTable(['bundle id', 'profile', 'state', 'installed', ''], state.profiles.map(p => [p.bundleId, p.name ?? '', p.state, p.installed ? 'yes' : 'no', p.why]))); printCapabilityReport(report, TAG); cleanup(); @@ -305,11 +410,19 @@ export async function run(ctx: Ctx): Promise { // --- ensure: plan, show, ask once, then write if (!main) throw new Error(`bundle id ${bundleId} is not registered: storebound apple bundle --dry, then without --dry`); - const missingExtra = ids.filter(id => !bundles.get(id)); - if (missingExtra.length) throw new Error(`not registered: ${missingExtra.join(', ')} (register extension bundle ids in Certificates, Identifiers & Profiles first)`); + for (const n of scan.notes) log(TAG, n); + // extensions: register the bundle id (not there yet), then the capabilities its entitlements need + const toRegister = exts.filter(t => !bundles.get(t.bundleId)); + const registerReq = (t: SigningTarget): Req => ({ method: 'POST', path: '/v1/bundleIds', note: `register ${t.bundleId} (${t.kind} ${t.name})`, + body: { data: { type: 'bundleIds', attributes: { identifier: t.bundleId, name: bundleIdName(config.app.name, t), platform: platformOf(profileType) } } } }); + const extCapPlans = exts.map(t => { const b = bundles.get(t.bundleId); return { t, b, plan: b ? planCapabilities(b.id, b.capabilities ?? [], t.capabilities) : null }; }); + const extCapLines = extCapPlans.flatMap(({ t, b, plan: cp }) => (b ? (cp?.create ?? []).map(r => `${describeReq(r)} # ${t.bundleId}`) + : t.capabilities.map(w => `POST /v1/bundleIdCapabilities {capabilityType="${w.type}"} # ${t.bundleId}, after it is registered`))); const needCert = scope.certificates && !choice.cert; const plan: string[] = [ + ...toRegister.map(t => `${describeReq(registerReq(t))}`), ...capPlan.create.map(r => describeReq(r)), + ...extCapLines, ...(needCert ? [describeReq(certificateReq(certType, ''))] : []), ...(!scope.profiles ? [] : ids.flatMap(id => { const p = profiles.get(id)!.plan; @@ -339,6 +452,20 @@ export async function run(ctx: Ctx): Promise { const responses = await execute(c.asc, capPlan.create, { dry: false, tag: TAG }); if (capPlan.create.length) await checkCapabilitiesStuck(c, bundleId, capabilitySpecs(s, config.games?.gameCenter === true).map(w => w.type), responses); if (gcPlan.length && gcSw) await ensureGcSwitch(c, gcSw, TAG); + for (const t of toRegister) { + const made = (await createOnce({ what: `${t.bundleId} bundle id`, tag: TAG, + find: async () => findBundleId(c, t.bundleId), + create: async () => (await c.asc('POST', '/v1/bundleIds', registerReq(t).body)).data })).value; + bundles.set(t.bundleId, (await findBundleId(c, t.bundleId)) ?? { ...made, capabilities: [] }); + log(TAG, `${t.bundleId}: bundle id registered (${t.kind} ${t.name})`); + } + for (const { t } of extCapPlans) { + const bid = bundles.get(t.bundleId)!; + const cp = planCapabilities(bid.id, bid.capabilities ?? [], t.capabilities); + if (!cp.create.length) continue; + const rs = await execute(c.asc, cp.create, { dry: false, tag: TAG }); + await checkCapabilitiesStuck(c, t.bundleId, t.capabilities.map(w => w.type), rs); + } if (!scope.profiles) { printCapabilityReport(await capabilityReport(c, config, root), TAG); cleanup(); @@ -385,10 +512,11 @@ export async function run(ctx: Ctx): Promise { else warn(TAG, 'xcodebuild signs with identities from the keychain: re-run with --keychain to import it there (or import the .key + .cer into the login keychain with `security import`)'); cert = created; } + const finalEnt = new Map>(); for (const id of ids) { const b = bundles.get(id)!; const list = profiles.get(id)!.list; - const p = planProfile(list, { bundleId: id, bundleIdId: b.id, certId: cert!.id, type: profileType }); + const p = planProfile(list, { bundleId: id, bundleIdId: b.id, certId: cert!.id, type: profileType, refresh: refresh && refreshIds.has(id) }); let prof: ApiProfile; if (p.create) { // an unclear answer is checked by reading the bundle id's profiles again (one active profile with this @@ -407,9 +535,16 @@ export async function run(ctx: Ctx): Promise { log(TAG, `${id}: ${p.why}`); } if (!a.has('no-install')) for (const f of install(prof)) log(TAG, `${id}: installed ${f.replace(homedir(), '~')}`); + try { finalEnt.set(id, (parseMobileprovision(Buffer.from(prof.attributes.profileContent ?? '', 'base64')).Entitlements ?? {}) as Record); } catch { /* unreadable: not compared */ } } cleanup(); - if (json) printJson({ ...state, plan, done: true }); + // App Groups: the profile carries a group only when it is assigned to the bundle id in the developer portal + const gaps = appGroupGapsFor(targetList, id => finalEnt.get(id) ?? null, id => bundles.get(id)?.id ?? null); + if (json) printJson({ ...state, plan, appGroupGaps: gaps, done: !gaps.length }); + if (gaps.length) { + warnAppGroupGaps(gaps, TAG); + throw new Error(`profile(s) installed, but ${gaps.map(g => `${g.bundleId} lacks ${g.groups.join(', ')}${g.url ? ` (${g.url})` : ''}`).join('; ')}: the build would fail at codesign. Tick the App Group(s) in the developer portal (steps above), then run storebound apple signing ensure --refresh-profile.`); + } done(TAG, 'signing ready (storebound apple build --dry shows the ExportOptions.plist it will use)'); } diff --git a/cli/data/flags.json b/cli/data/flags.json index 7473ef4..2e3f8ba 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -66,6 +66,7 @@ "allow-price-changes" ], "apple build": [ + "allow-app-group-gap", "allow-ssh", "allow-stale-web", "allow-version-mismatch", @@ -73,6 +74,7 @@ "configuration", "env-file", "keychain", + "keychain-unlock", "locale-map", "no-install", "no-prebuild", @@ -82,6 +84,7 @@ "profile", "progress", "project", + "refresh-profile", "result-file", "scan-paths", "scheme", @@ -122,7 +125,8 @@ "apple game-center": [ "keychain", "locale-map", - "no-install" + "no-install", + "refresh-profile" ], "apple iap": [ "all", @@ -238,6 +242,7 @@ "no-submit", "platform", "progress", + "refresh-profile", "resolve-iaps", "result-file", "version", @@ -261,7 +266,8 @@ "apple signing": [ "keychain", "locale-map", - "no-install" + "no-install", + "refresh-profile" ], "apple status": [ "cancel-submission", @@ -269,6 +275,7 @@ "locale-map", "no-install", "platform", + "refresh-profile", "timeout", "version", "wait-review" @@ -283,6 +290,7 @@ "no-submit", "platform", "progress", + "refresh-profile", "resolve-iaps", "result-file", "version", @@ -460,6 +468,7 @@ "name", "no-install", "react-native", + "refresh-profile", "sku", "storefront", "unity" @@ -563,7 +572,8 @@ "games status": [ "keychain", "locale-map", - "no-install" + "no-install", + "refresh-profile" ], "health": [ "apple", @@ -1067,6 +1077,7 @@ "play", "prune", "react-native", + "refresh-profile", "repair-stuck", "signature", "skip-out-of-range-regions", @@ -1113,6 +1124,7 @@ "agent", "all", "all-pages", + "allow-app-group-gap", "allow-availability-changes", "allow-existing", "allow-price-changes", @@ -1212,6 +1224,7 @@ "keep", "keep-booted", "keychain", + "keychain-unlock", "keyword", "keywords", "kind", @@ -1292,6 +1305,7 @@ "rating", "react-native", "reconvert", + "refresh-profile", "region", "release", "release-date", @@ -1497,7 +1511,7 @@ ], "apple signing": [ "storebound apple signing status [--json] read-only: capabilities, certificates (with / without a", - "storebound apple signing ensure [--dry] [--yes] [--keychain [path]] [--no-install] [--json]" + "storebound apple signing ensure [--dry] [--yes] [--keychain [path]] [--no-install] [--refresh-profile] [--json]" ], "apple status": [ "storebound apple status [--platform ios|macos|tvos|visionos] [--json]", diff --git a/cli/lib/keychain.ts b/cli/lib/keychain.ts new file mode 100644 index 0000000..5106f43 --- /dev/null +++ b/cli/lib/keychain.ts @@ -0,0 +1,45 @@ +// Code signing keychain for `storebound apple build` outside the Mac's logged-in GUI session. codesign in an SSH +// session (or any session without the login keychain unlocked) fails late with errSecInternalComponent. The fixes: +// - run from the GUI session (a .command file opened with `open -a Terminal`) +// - a CI runner / Jenkins agent started in that session, or with an unlocked keychain +// - a build keychain: --keychain , unlocked with --keychain-unlock (password from STOREBOUND_KEYCHAIN_PASSWORD, +// never printed) and its key partition list set so codesign may use the keys without a prompt. +// Pure planning here; cli/apple/build.ts runs it. +export const KEYCHAIN_PASSWORD_ENV = 'STOREBOUND_KEYCHAIN_PASSWORD'; + +/** The refusal for a build in an SSH / non-GUI session, naming every fix. */ +export function sshBuildRefusal(reason: string, o: { unlockFlag?: boolean } = {}): string { + const unlock = o.unlockFlag !== false; + return `apple build: ${reason}; codesign there fails with errSecInternalComponent (locked keychain). Fixes, pick one:\n` + + ` 1. Run it in the Mac's logged-in GUI session: write the command into a .command file and \`open -a Terminal \` (unset SSH_CONNECTION SSH_CLIENT SSH_TTY inside).\n` + + ` 2. A CI runner / Jenkins agent started in the GUI session, or one whose keychain is unlocked first (\`security unlock-keychain -p "$${KEYCHAIN_PASSWORD_ENV}" \`, then \`security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$${KEYCHAIN_PASSWORD_ENV}" \`).\n` + + (unlock ? ` 3. Let storebound do step 2: export ${KEYCHAIN_PASSWORD_ENV}=... (the keychain password; never printed) and add --keychain-unlock (the login keychain), or --keychain --keychain-unlock for a build keychain that holds the Apple Distribution key + certificate (storebound apple signing ensure --keychain imports them).\n` + + ` --allow-ssh skips this check when the keychain is already unlocked in this session.` + : ' --allow-ssh skips this check when the keychain is already unlocked in this session.'); +} + +export interface KeychainPlan { keychain: string; steps: string[][] } + +/** The `security` calls --keychain-unlock runs, with PASSWORD as a placeholder (substituted by the caller, never logged). */ +export function unlockSteps(keychain: string, searchList: string[]): string[][] { + const steps: string[][] = [['security', 'unlock-keychain', '-p', 'PASSWORD', keychain], + ['security', 'set-key-partition-list', '-S', 'apple-tool:,apple:,codesign:', '-s', '-k', 'PASSWORD', keychain]]; + if (!searchList.includes(keychain)) steps.push(['security', 'list-keychains', '-d', 'user', '-s', keychain, ...searchList]); + return steps; +} + +/** `security list-keychains` output -> paths (quotes stripped). */ +export const parseKeychainList = (out: string): string[] => out.split('\n').map(l => l.trim().replace(/^"|"$/g, '')).filter(Boolean); + +/** The password from the environment, or an error that names the variable (the value is never in a message). */ +export function keychainPassword(env: Record): string { + const p = env[KEYCHAIN_PASSWORD_ENV]; + if (!p) throw new Error(`--keychain-unlock needs the keychain password in the environment variable ${KEYCHAIN_PASSWORD_ENV} (not an argument: arguments end up in shell history)`); + return p; +} + +/** A command line for logs with the password replaced. */ +export const showSecurity = (argv: string[]): string => argv.map(a => (a === 'PASSWORD' ? '$' + KEYCHAIN_PASSWORD_ENV : a)).join(' '); + +/** Settings that make xcodebuild sign with a given keychain. */ +export const keychainSigningArgs = (keychain: string): string[] => [`OTHER_CODE_SIGN_FLAGS=--keychain ${keychain}`]; diff --git a/cli/lib/signing.ts b/cli/lib/signing.ts index 70019c9..9b28e45 100644 --- a/cli/lib/signing.ts +++ b/cli/lib/signing.ts @@ -43,6 +43,20 @@ export interface AppleSigning { capabilities?: (CapabilityType | CapabilitySpec)[]; /** Bundle ids of app extensions / widgets / watch apps in the same archive: each gets its own profile. */ extraBundleIds?: string[]; + /** Override for the app extensions / widgets / watch app / App Clip that `signing ensure` finds in the Xcode project + * (every target's PRODUCT_BUNDLE_IDENTIFIER and entitlements): when set, these replace the detected ones. Each is + * registered as a bundle id, gets the capabilities its entitlements need (or `capabilities` here) and a profile. */ + extensions?: ExtensionSpec[]; +} + +export interface ExtensionSpec { + bundleId: string; + /** A label for messages and the bundle id's name on the developer portal. */ + name?: string; + /** Default: the capabilities of the target's entitlements file (none when there is none). */ + capabilities?: (CapabilityType | CapabilitySpec)[]; + /** App group ids the target's entitlements list (com.apple.security.application-groups); checked against the profile. */ + appGroups?: string[]; } export function validateSigning(s: AppleSigning | undefined): string[] { @@ -56,6 +70,14 @@ export function validateSigning(s: AppleSigning | undefined): string[] { const t = typeof c === 'string' ? c : c?.type; if (!CAPABILITY_TYPES.includes(t as CapabilityType)) e.push(`apple.signing.capabilities: unknown capability "${String(t)}" (${CAPABILITY_TYPES.slice(0, 6).join(', ')}, ...)`); } + for (const x of s.extensions ?? []) { + if (!x || typeof x.bundleId !== 'string' || !/^[A-Za-z0-9.-]+$/.test(x.bundleId)) { e.push(`apple.signing.extensions: "${String(x?.bundleId)}" is not a bundle id`); continue; } + for (const c of x.capabilities ?? []) { + const t = typeof c === 'string' ? c : c?.type; + if (!CAPABILITY_TYPES.includes(t as CapabilityType)) e.push(`apple.signing.extensions ${x.bundleId}: unknown capability "${String(t)}"`); + } + if (x.appGroups && (!Array.isArray(x.appGroups) || x.appGroups.some(g => typeof g !== 'string' || !/^group\.[A-Za-z0-9.-]+$/.test(g)))) e.push(`apple.signing.extensions ${x.bundleId}: appGroups are ids like group.com.example.app`); + } for (const b of s.extraBundleIds ?? []) if (!/^[A-Za-z0-9.-]+$/.test(b)) e.push(`apple.signing.extraBundleIds: "${b}" is not a bundle id`); return e; } @@ -158,19 +180,20 @@ export const profileName = (bundleId: string, type: ProfileType, date = new Date export interface ProfilePlan { reuse: ApiProfile | null; create: Req | null; why: string } /** Reuse an ACTIVE profile of the type that holds the certificate and is valid for 14 more days; else create one. */ -export function planProfile(existing: ApiProfile[], o: { bundleId: string; bundleIdId: string; certId: string; type: ProfileType; now?: number }): ProfilePlan { +export function planProfile(existing: ApiProfile[], o: { bundleId: string; bundleIdId: string; certId: string; type: ProfileType; now?: number; /** make a new profile even when one is reusable (after an App Group was ticked in the portal) */ refresh?: boolean }): ProfilePlan { const now = o.now ?? Date.now(); const ok = existing.filter(p => p.attributes.profileType === o.type && p.attributes.profileState === 'ACTIVE' && Date.parse(p.attributes.expirationDate ?? '') - now > 14 * 86_400_000 && (p.certIds ?? []).includes(o.certId)) .sort((a, b) => Date.parse(b.attributes.expirationDate!) - Date.parse(a.attributes.expirationDate!)); - if (ok[0]) return { reuse: ok[0], create: null, why: `profile "${ok[0].attributes.name}" is active (expires ${ok[0].attributes.expirationDate?.slice(0, 10)})` }; + if (ok[0] && !o.refresh) return { reuse: ok[0], create: null, why: `profile "${ok[0].attributes.name}" is active (expires ${ok[0].attributes.expirationDate?.slice(0, 10)})` }; const stale = existing.filter(p => p.attributes.profileType === o.type && !p.managed); const managed = existing.filter(p => p.attributes.profileType === o.type && p.managed); const why = [ stale.length ? `${stale.length} ${o.type} profile(s), none active with this certificate (states: ${[...new Set(stale.map(p => p.attributes.profileState))].join(', ')})` : '', managed.length ? `${managed.length} Xcode-managed (automatic signing; not readable through the API, not reused)` : '', ].filter(Boolean).join('; ') || `no ${o.type} profile`; - return { reuse: null, why, create: { + const why2 = ok[0] ? `--refresh-profile: a new profile instead of "${ok[0].attributes.name}"` : why; + return { reuse: null, why: why2, create: { method: 'POST', path: '/v1/profiles', note: `create ${o.type} profile for ${o.bundleId}`, body: { data: { type: 'profiles', attributes: { name: profileName(o.bundleId, o.type, new Date(now)), profileType: o.type }, relationships: { bundleId: one('bundleIds', o.bundleIdId), certificates: many('certificates', [o.certId]) } } }, diff --git a/cli/lib/targets.ts b/cli/lib/targets.ts new file mode 100644 index 0000000..fc8e8b9 --- /dev/null +++ b/cli/lib/targets.ts @@ -0,0 +1,138 @@ +// Signable targets of an Xcode project (the app, app extensions such as a share extension or widget, the watch app, an +// App Clip), read from project.pbxproj: product type, PRODUCT_BUNDLE_IDENTIFIER and CODE_SIGN_ENTITLEMENTS of the +// Release configuration. Pure parsing; the fs side (entitlements files, config override) is cli/apple/signing.ts. +// Also the App Group check: Apple's API can switch the APP_GROUPS capability on for a bundle id but cannot assign a +// group container to it, so a profile carries `com.apple.security.application-groups` only when the group was ticked in +// the developer portal; comparing the target's entitlements with the profile's entitlements finds the gap offline. +export type TargetKind = 'app' | 'extension' | 'watch' | 'clip'; + +export interface PbxTarget { name: string; productType: string; kind: TargetKind; /** null: not resolvable (set in an xcconfig or a variable) */ bundleId: string | null; unresolved?: string; entitlements: string | null } + +/** Product types that are code signed with their own bundle id and provisioning profile. Frameworks, bundles, test + * bundles, tools and libraries are not. */ +export function targetKind(productType: string): TargetKind | null { + const m = /^com\.apple\.product-type\.(.+)$/.exec(productType); + const t = m?.[1]; + if (!t) return null; + if (t === 'application.on-demand-install-capable') return 'clip'; + if (/^application\.watchapp/.test(t) || /^watchkit2?-extension$/.test(t)) return 'watch'; + if (t === 'application') return 'app'; + if (/^(app-extension(\..+)?|extensionkit-extension|tv-app-extension)$/.test(t)) return 'extension'; + return null; +} + +/** id -> body of every `<24 hex id> /* comment *\/ = { ... };` object (brace matched, quoted strings skipped). */ +function pbxObjects(pbx: string): Map { + const out = new Map(); + const re = /^[ \t]*([0-9A-Fa-f]{24})(?:[ \t]*\/\*[\s\S]*?\*\/)?[ \t]*=[ \t]*\{/gm; + for (let m = re.exec(pbx); m; m = re.exec(pbx)) { + const open = m.index + m[0].length - 1; + let depth = 0, q = false, i = open; + for (; i < pbx.length; i++) { + const ch = pbx[i]; + if (q) { if (ch === '\\') i++; else if (ch === '"') q = false; continue; } + if (ch === '"') q = true; + else if (ch === '{') depth++; + else if (ch === '}' && --depth === 0) break; + } + out.set(m[1]!, pbx.slice(open + 1, i)); + re.lastIndex = i; + } + return out; +} + +const unquote = (s: string): string => { const t = s.trim(); return t.startsWith('"') && t.endsWith('"') ? t.slice(1, -1).replace(/\\"/g, '"') : t; }; +const field = (body: string, name: string): string | null => { + const m = new RegExp(`(?:^|[;\\s])${name}\\s*=\\s*("(?:[^"\\\\]|\\\\.)*"|[^;\\s]+)\\s*(?:/\\*[\\s\\S]*?\\*/)?\\s*;`).exec(body); + return m ? unquote(m[1]!) : null; +}; + +function buildSettings(body: string): Record { + const at = body.search(/buildSettings\s*=\s*\{/); + if (at < 0) return {}; + const inner = body.slice(body.indexOf('{', at) + 1); + const out: Record = {}; + for (const m of inner.matchAll(/^[ \t]*("?[A-Za-z_][A-Za-z0-9_\[\]=*]*"?)[ \t]*=[ \t]*("(?:[^"\\]|\\.)*"|\([^)]*\)|[^;]*);/gm)) out[unquote(m[1]!)] = unquote(m[2]!); + return out; +} + +/** $(VAR) / ${VAR} from the target's own settings, TARGET_NAME and PRODUCT_NAME; a modifier (:rfc1034identifier) + * makes the value an identifier. Anything still containing $( afterwards is unresolved. */ +export function resolveSetting(value: string, vars: Record): string { + let v = value; + for (let n = 0; n < 5 && /\$[({]/.test(v); n++) { + v = v.replace(/\$[({]([A-Za-z0-9_]+)(?::([a-z0-9]+))?[)}]/g, (all, name: string, mod?: string) => { + const r = vars[name]; + if (r === undefined || r === `$(${name})`) return all; + return mod === 'rfc1034identifier' || mod === 'identifier' ? r.replace(/[^A-Za-z0-9.-]/g, '-') : r; + }); + } + return v; +} + +/** The signable targets of one project.pbxproj, in file order. */ +export function pbxTargets(pbx: string): PbxTarget[] { + const objs = pbxObjects(pbx); + const out: PbxTarget[] = []; + for (const body of objs.values()) { + if (field(body, 'isa') !== 'PBXNativeTarget') continue; + const productType = field(body, 'productType') ?? ''; + const kind = targetKind(productType); + if (!kind) continue; + const name = field(body, 'name') ?? field(body, 'productName') ?? '?'; + const listBody = objs.get((field(body, 'buildConfigurationList') ?? '').split(/\s/)[0]!); + const ids = [...(/buildConfigurations\s*=\s*\(([\s\S]*?)\)/.exec(listBody ?? '')?.[1] ?? '').matchAll(/([0-9A-Fa-f]{24})/g)].map(m => m[1]!); + const cfgs = ids.map(id => objs.get(id)).filter((b): b is string => !!b); + const cfg = cfgs.find(c => field(c, 'name') === 'Release') ?? cfgs[cfgs.length - 1]; + const s = cfg ? buildSettings(cfg) : {}; + const vars: Record = { ...s, TARGET_NAME: name, PRODUCT_NAME: resolveSetting(s.PRODUCT_NAME ?? name, { TARGET_NAME: name }) }; + const raw = s.PRODUCT_BUNDLE_IDENTIFIER; + const bundleId = raw ? resolveSetting(raw, vars) : null; + const ent = s.CODE_SIGN_ENTITLEMENTS ? resolveSetting(s.CODE_SIGN_ENTITLEMENTS, { ...vars, SRCROOT: '' }).replace(/^\/+/, '') : null; + out.push({ name, productType, kind, bundleId: bundleId && !/\$[({]/.test(bundleId) ? bundleId : null, ...(raw && (!bundleId || /\$[({]/.test(bundleId)) ? { unresolved: raw } : {}), entitlements: ent }); + } + return out; +} + +/** App groups (`com.apple.security.application-groups`) a profile's Entitlements allow. */ +export const profileAppGroups = (ent: Record | undefined): string[] => { + const g = ent?.['com.apple.security.application-groups']; + return Array.isArray(g) ? g.map(String) : []; +}; + +/** Groups the target's entitlements need that the profile does not carry. `*` in a profile allows any group. */ +export function appGroupGaps(want: string[], profileEnt: Record | undefined): string[] { + const have = profileAppGroups(profileEnt); + if (have.includes('*')) return []; + return want.filter(g => !have.includes(g)); +} + +/** The developer portal page of a bundle id (the id is the App Store Connect API resource id of the bundle id). */ +export const bundleIdPortalUrl = (resourceId: string): string => `https://developer.apple.com/account/resources/identifiers/bundleId/edit/${encodeURIComponent(resourceId)}`; + +export interface AppGroupGap { bundleId: string; resourceId: string | null; groups: string[]; url: string | null } + +/** The agent-first steps to tick the groups (the API cannot do it); the never-press list is part of the text. */ +export function appGroupSteps(g: AppGroupGap): string[] { + const url = g.url ?? 'https://developer.apple.com/account/resources/identifiers/list'; + return [ + `${g.bundleId}: the profile lacks App Group(s) ${g.groups.join(', ')} (the API can enable the App Groups capability but cannot assign a group to a bundle id).`, + `Open ${url} in the signed-in browser (agent-first: you drive it).`, + 'Capabilities list > App Groups: tick it if it is not ticked, press Configure, tick each group above (Continue if the group is new: register it first under Identifiers > App Groups), Continue, then Save at the top right (and Confirm in the dialog that says the profiles get invalidated).', + 'Never press: Remove (the bundle id), Delete, Revoke, or any Certificates / Keys / Account button.', + `Then run: storebound apple signing ensure --refresh-profile (makes a new profile that carries the group), and storebound apple signing status to read it back.`, + ]; +} + +/** Groups missing per bundle id -> one gap row each. `groupsOf` and `profileOf` are looked up by bundle id. */ +export function appGroupGapsFor(targets: { bundleId: string; appGroups: string[] }[], profileEnt: (bundleId: string) => Record | undefined | null, resourceId: (bundleId: string) => string | null): AppGroupGap[] { + const out: AppGroupGap[] = []; + for (const t of targets) { + if (!t.appGroups.length) continue; + const ent = profileEnt(t.bundleId); + if (ent === null) continue; // no readable profile: unknown, not a gap + const groups = appGroupGaps(t.appGroups, ent); + if (groups.length) { const rid = resourceId(t.bundleId); out.push({ bundleId: t.bundleId, resourceId: rid, groups, url: rid ? bundleIdPortalUrl(rid) : null }); } + } + return out; +} diff --git a/cli/lib/xcode.ts b/cli/lib/xcode.ts index fe0dd52..a961fd6 100644 --- a/cli/lib/xcode.ts +++ b/cli/lib/xcode.ts @@ -125,14 +125,14 @@ export interface AscAuth { keyPath: string; keyId: string; issuerId: string } const authArgs = (a: AscAuth | null): string[] => (a ? ['-allowProvisioningUpdates', '-authenticationKeyPath', a.keyPath, '-authenticationKeyID', a.keyId, '-authenticationKeyIssuerID', a.issuerId] : []); -export interface ArchiveInput { target: XcodeTarget; root: string; configuration: string; archivePath: string; version?: string; build: string; teamId?: string; auth: AscAuth | null } +export interface ArchiveInput { target: XcodeTarget; root: string; configuration: string; archivePath: string; version?: string; build: string; teamId?: string; auth: AscAuth | null; /** extra build settings, e.g. OTHER_CODE_SIGN_FLAGS=--keychain */ settings?: string[] } /** xcodebuild archive. Version / build go in as build settings (they beat the project's values). */ export function archiveArgs(i: ArchiveInput): string[] { return ['xcodebuild', ...(i.target.workspace ? ['-workspace', join(i.root, i.target.workspace)] : ['-project', join(i.root, i.target.project!)]), '-scheme', i.target.scheme, '-configuration', i.configuration, '-destination', 'generic/platform=iOS', '-archivePath', i.archivePath, ...authArgs(i.auth), 'archive', `CURRENT_PROJECT_VERSION=${i.build}`, ...(i.version ? [`MARKETING_VERSION=${i.version}`] : []), - ...(i.teamId ? [`DEVELOPMENT_TEAM=${i.teamId}`] : [])]; + ...(i.teamId ? [`DEVELOPMENT_TEAM=${i.teamId}`] : []), ...(i.settings ?? [])]; } export function exportArgs(archivePath: string, plist: string, exportPath: string, auth: AscAuth | null): string[] { diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 9388e08..5a00c04 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -672,7 +672,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | `bundleIds_app_getToOneRelationship` | GET | `/v1/bundleIds/{id}/relationships/app` | read | | | `bundleIds_bundleIdCapabilities_getToManyRelated` | GET | `/v1/bundleIds/{id}/bundleIdCapabilities` | read | | | `bundleIds_bundleIdCapabilities_getToManyRelationship` | GET | `/v1/bundleIds/{id}/relationships/bundleIdCapabilities` | read | | -| `bundleIds_createInstance` | POST | `/v1/bundleIds` | write (dry by default) | apple/bundle | +| `bundleIds_createInstance` | POST | `/v1/bundleIds` | write (dry by default) | apple/bundle, apple/signing | | `bundleIds_deleteInstance` | DELETE | `/v1/bundleIds/{id}` | owner only (typed confirm) | | | `bundleIds_getCollection` | GET | `/v1/bundleIds` | read | apple/adhoc, apple/bundle, apple/signing | | `bundleIds_getInstance` | GET | `/v1/bundleIds/{id}` | read | | diff --git a/skills/build-and-sign/SKILL.md b/skills/build-and-sign/SKILL.md index e54b8e9..9845d21 100644 --- a/skills/build-and-sign/SKILL.md +++ b/skills/build-and-sign/SKILL.md @@ -19,7 +19,7 @@ when the owner said so for this run). The MCP server offers only `apple_signing_ 1. **Read.** `storebound apple signing status`: is the bundle id registered (else `storebound apple bundle`), which capabilities it has vs `apple.signing.capabilities`, the distribution certificates of the team and whether this Mac holds a private key for one (a key file in `.storebound/signing/` or an identity in the keychain), App Store - profiles per bundle id (app + `apple.signing.extraBundleIds` for extensions) and whether Xcode has them installed. + profiles per bundle id (the app and every extension / widget / watch app / App Clip found in the Xcode project, or `apple.signing.extensions`; `extraBundleIds` adds more) and whether Xcode has them installed. `signing ensure` registers the extensions' bundle ids, enables the capabilities their entitlements need and (manual style) makes their profiles; an App Group the profile lacks is reported with the developer portal link and steps (the API cannot assign a group; then `signing ensure --refresh-profile`, store-pitfalls 121). It also compares three places a capability must be and names what is missing where: the App ID, the config's `apple.signing.capabilities` and the iOS project's `.entitlements` (`aps-environment` = PUSH_NOTIFICATIONS, `com.apple.developer.game-center` = GAME_CENTER, associated domains, Sign in with Apple, iCloud, app groups, ...; @@ -57,7 +57,12 @@ when the owner said so for this run). The MCP server offers only `apple_signing_ number**: later steps use exactly it. Any archive or export failure exits non-zero, so `apple build && apple upload` stops there; an export failure shows the IDEDistribution log's real error lines. xcodebuild runs with Apple's tool folders first on PATH (a Homebrew rsync breaks Xcode's IPA step: "exportArchive Copy failed"; `storebound doctor` - flags it). + flags it). Over SSH or in CI (no GUI session) codesign fails with errSecInternalComponent on a locked keychain: + `apple build` stops first and names the fixes. `STOREBOUND_KEYCHAIN_PASSWORD=... storebound apple build + --keychain-unlock` unlocks the login keychain and sets its key partition list (the password is read from the + environment and never printed); `--keychain ` signs with a build keychain (`--keychain --keychain-unlock` + unlocks that one; `signing ensure --keychain ` imports the key + certificate into it). With manual signing it + also stops when a target's App Group is missing from its installed profile (store-pitfalls 121). - Capacitor: the archive ships the web assets `npx cap sync` copied last. Set `build.prebuild` (e.g. `"npm run build && npx cap sync"`; per store `apple.prebuild` / `play.prebuild`) and `apple build` / `play build` run it first (once per `flow run`; `--no-prebuild` skips it). Then the copied `public/index.html` must be newer diff --git a/skills/store-api/SKILL.md b/skills/store-api/SKILL.md index dae8e94..74e61dc 100644 --- a/skills/store-api/SKILL.md +++ b/skills/store-api/SKILL.md @@ -42,7 +42,9 @@ storebound api apple salesReports_getCollection --param ... --out report.gz # on stderr (store-pitfalls 78). Never pipe or redirect a write; use the dedicated command the message names. An agent without a terminal sends a plain (non-owner) write with `--yes --confirm `, the id equal to the one the call resolved to (store-pitfalls 92), after showing the dry run. `--confirm` never applies to - owner-only operations. + owner-only operations. Both flags are needed, and `--confirm` repeats the operation id: + `storebound api apple bundleIds_createInstance --body bundle.json --yes --confirm bundleIds_createInstance` + (without `--yes` it stays a dry run; a `--confirm` that differs from the id is refused). 3. Owner-only operations (cli/api/gate.ts: every DELETE, submissions and their cancel, release requests, phased release changes, product page test start / stop, price schedules and price migrations, review replies, tester and user invitations, Play `edits.commit`, product activation, refunds, revokes and cancels, app signing) also ask for diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 15a874c..4d19934 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1229,8 +1229,13 @@ that fails without the fix (see CONTRIBUTING.md). (and read relative to the working directory, not the project). - **Do:** run the build in the Mac's logged-in session: a CI agent started there, or from SSH a `.command` file opened with `open -a Terminal ` (unset SSH_CONNECTION SSH_CLIENT SSH_TTY inside). `--allow-ssh` after - `security unlock-keychain` skips the check. -- **Guard:** `remoteBuildSession` in `cli/lib/xcode.ts` stops `apple build` before the archive; `credPath` in + `security unlock-keychain` skips the check. To build over SSH or in CI anyway: export `STOREBOUND_KEYCHAIN_PASSWORD` + (never printed, never an argument of storebound) and add `--keychain-unlock` (unlocks the login keychain and runs + `security set-key-partition-list -S apple-tool:,apple:,codesign:`), or `--keychain --keychain-unlock` for a + build keychain that holds the Apple Distribution key + certificate (`apple signing ensure --keychain ` imports + them); `--keychain` also signs the archive with `OTHER_CODE_SIGN_FLAGS=--keychain ` and puts the keychain on the search list. +- **Guard:** `remoteBuildSession` in `cli/lib/xcode.ts` stops `apple build` before the archive, and its message names + these fixes (`sshBuildRefusal`, `cli/lib/keychain.ts`; `tests/signing-targets.test.ts`); `credPath` in `cli/lib/credentials.ts` resolves relative credential paths from the project and `ascKeyFile` hands xcodebuild an absolute path (`tests/build-sign.test.ts`). @@ -1454,3 +1459,30 @@ that fails without the fix (see CONTRIBUTING.md). attributes first, `text=` last); `shots capture --dry` prints them per size, `--dry --probe` opens the pages and names the MISS with the size and every candidate's match count. - **Guard:** `tests/shots-gaps.test.ts`, `tests/browser/feature-fit.browser.ts` (rotate), `cli/shots/selectors.ts`. +||||||| parent of ae8d773 (apple signing: every signable target (extensions, App Group check), build keychain unlock over SSH, api confirm docs) + +## 121. An app extension (share extension, widget, watch app) could not be signed: bundle id not registered, capabilities missing, App Group not in the profile + +- **Symptom:** an iOS app with a share extension archived, then failed at export / codesign: "No profile for team 'X' + matching 'com.example.app.share' found", "Provisioning profile ... doesn't include the App Groups capability" or + "doesn't match the entitlements file's value for the com.apple.security.application-groups entitlement". `apple + signing ensure` knew only the app's bundle id and refused (`not registered`) for `extraBundleIds`. +- **Cause:** every signable target of the archive (app, app extension, widget, watch app, App Clip) is its own bundle id + with its own capabilities and profile. And Apple's API can switch the APP_GROUPS capability on for a bundle id but + cannot assign a group container to it: the profile carries `com.apple.security.application-groups` only after the + group was ticked in the developer portal (Identifiers > the bundle id > App Groups > Configure). +- **Do:** `storebound apple signing ensure --dry` lists every target found in the Xcode project (PRODUCT_BUNDLE_IDENTIFIER + and CODE_SIGN_ENTITLEMENTS of the Release configuration) and what it would register, enable and create; without + `--dry` it registers the extension bundle ids, enables the capabilities its entitlements need (the entitlement-to- + capability table; `keychain-access-groups` needs none) and, for manual signing, creates an App Store profile per + target. It then decodes each profile and compares its App Groups with the target's entitlements: a gap fails with + the developer portal deep link `https://developer.apple.com/account/resources/identifiers/bundleId/edit/` and the + steps to tick the group (drive it in the signed-in browser; never press Remove / Delete / Revoke). After that + `storebound apple signing ensure --refresh-profile` makes the profile again (a normal run reuses the active one, so it + does not pile up profiles while the group is unassigned). `apple build` (manual signing) stops before the archive on the + same gap (`--allow-app-group-gap` overrides). A variable bundle id (set in an xcconfig) is not resolvable: list the + extension in `apple.signing.extensions` (`bundleId`, `capabilities`, `appGroups`), which replaces the detected ones. +- **Guard:** `cli/lib/targets.ts` (pbxproj targets, App Group comparison, portal link and steps), `discoverTargets` in + `cli/apple/signing.ts`, `tests/signing-targets.test.ts` (fixture pbxproj with an app, a share extension and app group + entitlements, a decoded profile plist, a fake App Store Connect). There is no `console plan` form for the portal page: + the steps are in the error and the warning. diff --git a/tests/signing-targets.test.ts b/tests/signing-targets.test.ts new file mode 100644 index 0000000..0104925 --- /dev/null +++ b/tests/signing-targets.test.ts @@ -0,0 +1,341 @@ +// Signable targets of an Xcode project (app + share extension + App Group entitlements): `apple signing ensure` +// registers the extension's bundle id, enables the capabilities its entitlements need, creates its profile and checks +// the App Group against the profile (the API cannot assign a group to a bundle id); `apple build` over SSH names the +// keychain fixes; `storebound api` writes say how an agent confirms. Fixtures are placeholders (com.example.*). +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { toXmlPlist } from '../cli/lib/plist'; +import { run as signing, discoverTargets, profileEntitlements } from '../cli/apple/signing'; +import { decide, NOT_A_TTY } from '../cli/api/gate'; +import { USAGE } from '../cli/api'; +import { keychainPassword, KEYCHAIN_PASSWORD_ENV, parseKeychainList, showSecurity, sshBuildRefusal, unlockSteps } from '../cli/lib/keychain'; +import { planProfile, validateSigning } from '../cli/lib/signing'; +import { appGroupGaps, appGroupGapsFor, appGroupSteps, bundleIdPortalUrl, pbxTargets, targetKind } from '../cli/lib/targets'; +import { upsertTiming } from '../cli/lib/upsert'; +import { ascEnv, BASE, capture, mockFetch, project, restore } from './helpers/store-mock'; + +const APP_ENT = ` + + aps-environmentdevelopment + com.apple.security.application-groupsgroup.com.example.notes +`; +const SHARE_ENT = ` + + com.apple.security.application-groupsgroup.com.example.notes + keychain-access-groups$(AppIdentifierPrefix)com.example.notes + com.apple.developer.associated-domainsapplinks:example.com +`; + +/** A pbxproj the way Xcode 15 writes it: app, share extension, a unit test bundle, a framework, one variable bundle id. */ +const PBX = `// !$*UTF8*$! +{ + archiveVersion = 1; + objects = { +/* Begin PBXNativeTarget section */ + AAAAAAAAAAAAAAAAAAAAAA01 /* App */ = { + isa = PBXNativeTarget; + buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC01 /* Build configuration list for PBXNativeTarget "App" */; + buildPhases = ( ); + name = App; + productName = App; + productType = "com.apple.product-type.application"; + }; + AAAAAAAAAAAAAAAAAAAAAA02 /* ShareExt */ = { + isa = PBXNativeTarget; + buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC02 /* Build configuration list for PBXNativeTarget "ShareExt" */; + name = ShareExt; + productType = "com.apple.product-type.app-extension"; + }; + AAAAAAAAAAAAAAAAAAAAAA03 /* AppTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC03 /* x */; + name = AppTests; + productType = "com.apple.product-type.bundle.unit-test"; + }; + AAAAAAAAAAAAAAAAAAAAAA04 /* Kit */ = { + isa = PBXNativeTarget; + buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC03 /* x */; + name = Kit; + productType = "com.apple.product-type.framework"; + }; + AAAAAAAAAAAAAAAAAAAAAA05 /* Widget */ = { + isa = PBXNativeTarget; + buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC05 /* x */; + name = Widget; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ +/* Begin XCBuildConfiguration section */ + BBBBBBBBBBBBBBBBBBBBBB01 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = com.example.notes.debug; }; + name = Debug; + }; + BBBBBBBBBBBBBBBBBBBBBB02 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = App/App.entitlements; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "{braces}", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.notes; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; + BBBBBBBBBBBBBBBBBBBBBB03 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = "$(SRCROOT)/Share/Share.entitlements"; + PRODUCT_BUNDLE_IDENTIFIER = "com.example.notes.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; + BBBBBBBBBBBBBBBBBBBBBB04 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = "$(MY_BASE_ID).widget"; }; + name = Release; + }; +/* End XCBuildConfiguration section */ +/* Begin XCConfigurationList section */ + CCCCCCCCCCCCCCCCCCCCCC01 /* x */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BBBBBBBBBBBBBBBBBBBBBB01 /* Debug */, + BBBBBBBBBBBBBBBBBBBBBB02 /* Release */, + ); + }; + CCCCCCCCCCCCCCCCCCCCCC02 /* x */ = { + isa = XCConfigurationList; + buildConfigurations = ( BBBBBBBBBBBBBBBBBBBBBB03 /* Release */, ); + }; + CCCCCCCCCCCCCCCCCCCCCC03 /* x */ = { isa = XCConfigurationList; buildConfigurations = ( ); }; + CCCCCCCCCCCCCCCCCCCCCC05 /* x */ = { isa = XCConfigurationList; buildConfigurations = ( BBBBBBBBBBBBBBBBBBBBBB04 ); }; +/* End XCConfigurationList section */ + }; +} +`; + +const files = { 'ios/App/App.xcodeproj/project.pbxproj': PBX, 'ios/App/App/App.entitlements': APP_ENT, 'ios/App/Share/Share.entitlements': SHARE_ENT }; + +describe('pbxproj targets', () => { + test('targetKind: only signable product types', () => { + expect(targetKind('com.apple.product-type.application')).toBe('app'); + expect(targetKind('com.apple.product-type.app-extension')).toBe('extension'); + expect(targetKind('com.apple.product-type.app-extension.messages-sticker-pack')).toBe('extension'); + expect(targetKind('com.apple.product-type.application.watchapp2')).toBe('watch'); + expect(targetKind('com.apple.product-type.application.on-demand-install-capable')).toBe('clip'); + expect(targetKind('com.apple.product-type.framework')).toBeNull(); + expect(targetKind('com.apple.product-type.bundle.unit-test')).toBeNull(); + }); + + test('app + share extension + widget: bundle ids resolved from Release, entitlements paths, tests and frameworks skipped', () => { + const t = pbxTargets(PBX); + expect(t.map(x => x.name)).toEqual(['App', 'ShareExt', 'Widget']); + expect(t[0]).toMatchObject({ kind: 'app', bundleId: 'com.example.notes', entitlements: 'App/App.entitlements' }); + expect(t[1]).toMatchObject({ kind: 'extension', bundleId: 'com.example.notes.ShareExt', entitlements: 'Share/Share.entitlements' }); + expect(t[2]).toMatchObject({ bundleId: null, unresolved: '$(MY_BASE_ID).widget' }); + }); + + test('discoverTargets reads the entitlements: capabilities and app groups per target; unresolved ids are noted', () => { + const p = project({ ...BASE, apple: { bundleId: 'com.example.notes', appId: '6400000101' } }, files); + const s = discoverTargets(p.root, p.loaded.config); + expect(s.main).toMatchObject({ bundleId: 'com.example.notes', appGroups: ['group.com.example.notes'] }); + expect(s.main!.capabilities.map(c => c.type).sort()).toEqual(['APP_GROUPS', 'PUSH_NOTIFICATIONS']); + expect(s.extensions).toHaveLength(1); + expect(s.extensions[0]).toMatchObject({ bundleId: 'com.example.notes.ShareExt', kind: 'extension', source: 'xcode', appGroups: ['group.com.example.notes'] }); + expect(s.extensions[0]!.capabilities.map(c => c.type).sort()).toEqual(['APP_GROUPS', 'ASSOCIATED_DOMAINS']); // keychain-access-groups has no capability + expect(s.notes.join('\n')).toContain('Widget has bundle id "$(MY_BASE_ID).widget"'); + }); + + test('apple.signing.extensions replaces the detected extensions; extraBundleIds are added', () => { + const cfg = { ...BASE, apple: { bundleId: 'com.example.notes', signing: { extensions: [{ bundleId: 'com.example.notes.widget', capabilities: ['PUSH_NOTIFICATIONS' as const], appGroups: ['group.com.example.notes'] }], extraBundleIds: ['com.example.notes.watch'] } } }; + const p = project(cfg, files); + const s = discoverTargets(p.root, p.loaded.config); + expect(s.extensions.map(t => t.bundleId)).toEqual(['com.example.notes.widget', 'com.example.notes.watch']); + expect(s.extensions[0]).toMatchObject({ source: 'config', appGroups: ['group.com.example.notes'], capabilities: [{ type: 'PUSH_NOTIFICATIONS' }] }); + }); + + test('validateSigning checks the extensions override', () => { + expect(validateSigning({ extensions: [{ bundleId: 'com.example.app.share', capabilities: ['APP_GROUPS'], appGroups: ['group.com.example.app'] }] })).toEqual([]); + const e = validateSigning({ extensions: [{ bundleId: 'bad id', capabilities: ['NOPE' as never], appGroups: ['x'] }, { bundleId: 'com.example.a', capabilities: ['NOPE' as never], appGroups: ['x'] }] }); + expect(e.join('\n')).toContain('"bad id" is not a bundle id'); + expect(e.join('\n')).toContain('unknown capability "NOPE"'); + expect(e.join('\n')).toContain('appGroups are ids like group.'); + }); +}); + +describe('App Group check', () => { + const prov = (ent: Record): string => Buffer.from(`\x30\x82\x01junk${toXmlPlist({ Name: 'x', Entitlements: ent as Record })}\x00trailer`, 'latin1').toString('base64'); + const profile = (ent: Record) => ({ id: 'P1', attributes: { name: 'p', profileType: 'IOS_APP_STORE', profileState: 'ACTIVE', expirationDate: '2099-01-01T00:00:00Z', profileContent: prov(ent) } }); + + test('the profile plist is decoded and compared with the target\'s groups', () => { + const ent = profileEntitlements([profile({ 'application-identifier': 'ABCDE12345.com.example.notes.share', 'com.apple.security.application-groups': ['group.com.example.other'] })])!; + expect(appGroupGaps(['group.com.example.notes', 'group.com.example.other'], ent)).toEqual(['group.com.example.notes']); + expect(appGroupGaps(['group.com.example.notes'], { 'com.apple.security.application-groups': ['*'] })).toEqual([]); + expect(appGroupGaps(['group.com.example.notes'], {})).toEqual(['group.com.example.notes']); + }); + + test('a managed or contentless profile is unknown, not a gap', () => { + expect(profileEntitlements([{ id: 'M', attributes: { profileType: 'IOS_APP_STORE', profileState: 'ACTIVE' }, managed: true }])).toBeNull(); + expect(appGroupGapsFor([{ bundleId: 'com.example.notes', appGroups: ['group.com.example.notes'] }], () => null, () => 'X')).toEqual([]); + }); + + test('the gap carries the portal deep link and the agent-first steps with a never-press list', () => { + const g = appGroupGapsFor([{ bundleId: 'com.example.notes.share', appGroups: ['group.com.example.notes'] }], () => ({}), () => 'EXT0001'); + expect(g).toEqual([{ bundleId: 'com.example.notes.share', resourceId: 'EXT0001', groups: ['group.com.example.notes'], url: 'https://developer.apple.com/account/resources/identifiers/bundleId/edit/EXT0001' }]); + expect(bundleIdPortalUrl('EXT0001')).toBe(g[0]!.url!); + const steps = appGroupSteps(g[0]!).join('\n'); + expect(steps).toContain(g[0]!.url!); + expect(steps).toContain('Never press'); + expect(steps).toContain('signing ensure --refresh-profile'); + }); + + test('planProfile: --refresh-profile makes a new profile although an active one is reusable', () => { + const existing = [{ id: 'P1', certIds: ['C1'], attributes: { name: 'old', profileType: 'IOS_APP_STORE' as const, profileState: 'ACTIVE', expirationDate: '2099-01-01T00:00:00Z' } }]; + const o = { bundleId: 'com.example.notes', bundleIdId: 'B1', certId: 'C1', type: 'IOS_APP_STORE' as const }; + expect(planProfile(existing, o).reuse?.id).toBe('P1'); + const r = planProfile(existing, { ...o, refresh: true }); + expect(r.reuse).toBeNull(); + expect(r.create?.path).toBe('/v1/profiles'); + expect(r.why).toContain('--refresh-profile'); + }); +}); + +describe('signing ensure with an extension (fake App Store Connect)', () => { + beforeEach(() => { upsertTiming.waitMs = 0; }); + afterEach(() => { restore(); upsertTiming.waitMs = 3000; }); + + const MAIN = 'BUNDLE0001', EXT = 'EXT0001'; + const cfg = (style: 'automatic' | 'manual') => ({ ...BASE, apple: { bundleId: 'com.example.notes', appId: '6400000101', signing: { style, capabilities: ['IN_APP_PURCHASE' as const] } } }); + const prov = (ent: Record): string => Buffer.from(`\x30\x82\x01junk${toXmlPlist({ Name: 'x', Entitlements: ent as Record })}\x00`, 'latin1').toString('base64'); + + function store(o: { groupInProfile: boolean }) { + const caps: Record = { [MAIN]: ['IN_APP_PURCHASE', 'PUSH_NOTIFICATIONS', 'APP_GROUPS'], [EXT]: [] }; + let registered = false; + const bundle = (id: string, ident: string) => ({ type: 'bundleIds', id, attributes: { identifier: ident, platform: 'IOS', seedId: 'ABCDE12345' }, + relationships: { bundleIdCapabilities: { data: caps[id]!.map(t => ({ type: 'bundleIdCapabilities', id: `${id}_${t}` })) } } }); + const incl = (id: string) => caps[id]!.map(t => ({ type: 'bundleIdCapabilities', id: `${id}_${t}`, attributes: { capabilityType: t, settings: null } })); + const page = (data: unknown[], included: unknown[] = []) => ({ data, included, links: { self: '' }, meta: { paging: { total: data.length, limit: 200 } } }); + const routes = [ + { match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes\.ShareExt/, answer: () => (registered ? page([bundle(EXT, 'com.example.notes.ShareExt')], incl(EXT)) : page([])) }, + { match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes&/, answer: () => page([bundle(MAIN, 'com.example.notes')], incl(MAIN)) }, + { method: 'POST', match: /^\/v1\/bundleIds$/, answer: () => { registered = true; return { data: bundle(EXT, 'com.example.notes.ShareExt') }; } }, + { method: 'POST', match: /^\/v1\/bundleIdCapabilities$/, answer: (c: { body: unknown }) => { + const a = (c.body as { data: { attributes: { capabilityType: string }; relationships: { bundleId: { data: { id: string } } } } }).data; + caps[a.relationships.bundleId.data.id]!.push(a.attributes.capabilityType); + return { data: { type: 'bundleIdCapabilities', id: 'x', attributes: { capabilityType: a.attributes.capabilityType } } }; + } }, + { match: /^\/v1\/certificates\?/, answer: page([]) }, + { method: 'POST', match: /^\/v1\/certificates$/, answer: { data: { type: 'certificates', id: 'CERT0001', attributes: { certificateType: 'DISTRIBUTION', name: 'Apple Distribution: Example', expirationDate: '2099-01-01T00:00:00.000+00:00', certificateContent: Buffer.from('cert').toString('base64') } } } }, + { match: /^\/v1\/bundleIds\/[A-Z0-9]+\/profiles/, answer: page([]) }, + { method: 'POST', match: /^\/v1\/profiles$/, answer: (c: { body: unknown }) => { + const id = (c.body as { data: { relationships: { bundleId: { data: { id: string } } } } }).data.relationships.bundleId.data.id; + const groups = o.groupInProfile ? { 'com.apple.security.application-groups': ['group.com.example.notes'] } : {}; + return { data: { type: 'profiles', id: `PROF_${id}`, attributes: { name: `storebound ${id}`, profileType: 'IOS_APP_STORE', profileState: 'ACTIVE', uuid: `uuid-${id}`, expirationDate: '2099-01-01T00:00:00.000+00:00', profileContent: prov({ 'application-identifier': `ABCDE12345.${id}`, ...groups }) } } }; + } }, + { match: /^\/v1\/apps\/6400000101\/gameCenterDetail/, answer: { errors: [{ status: '404' }] }, status: 404 }, + ]; + return { routes: routes as never, caps, isRegistered: () => registered }; + } + + test('--dry lists every target: the extension bundle id, its capabilities and (manual) its profile', async () => { + ascEnv(); + const p = project(cfg('manual'), files); + const s = store({ groupInProfile: true }); + const calls = mockFetch(s.routes); + const r = await capture(() => signing(p.ctx(['ensure', '--dry']))); + const all = r.out + r.err; + expect(all).toContain('POST /v1/bundleIds'); + expect(all).toContain('register com.example.notes.ShareExt'); + expect(all).toMatch(/POST \/v1\/bundleIdCapabilities \{capabilityType="APP_GROUPS"\} # com\.example\.notes\.ShareExt, after it is registered/); + expect(all).toContain('ASSOCIATED_DOMAINS'); + expect(all).toContain('# com.example.notes.ShareExt: with the new certificate'); + expect(all).not.toContain('ASSOCIATED_DOMAINS is in ios/App/Share/Share.entitlements'); // the extension's entitlements are not compared with the app's App ID + expect(calls.filter(c => c.method !== 'GET')).toEqual([]); + }); + + test('automatic: registers the extension bundle id and its capabilities, reads them back, no profile', async () => { + ascEnv(); + const p = project(cfg('automatic'), files); + const s = store({ groupInProfile: true }); + const calls = mockFetch(s.routes); + const r = await capture(() => signing(p.ctx(['ensure', '--yes']))); + expect(r.exit).toBeNull(); + const writes = calls.filter(c => c.method !== 'GET').map(c => `${c.method} ${c.path}`); + expect(writes[0]).toBe('POST /v1/bundleIds'); + expect(writes.filter(w => w === 'POST /v1/bundleIdCapabilities')).toHaveLength(2); + expect(writes.some(w => /profiles|certificates/.test(w))).toBe(false); + expect(s.caps.EXT0001!.sort()).toEqual(['APP_GROUPS', 'ASSOCIATED_DOMAINS']); + const reg = calls.find(c => c.method === 'POST' && c.path === '/v1/bundleIds')!.body as { data: { attributes: Record } }; + expect(reg.data.attributes).toMatchObject({ identifier: 'com.example.notes.ShareExt', platform: 'IOS', name: 'Example Notes ShareExt' }); + }); + + test('manual: a profile for the app and the extension; the group missing from the profile fails with the portal link and steps', async () => { + ascEnv(); + const p = project(cfg('manual'), files); + const s = store({ groupInProfile: false }); + const calls = mockFetch(s.routes); + const r = await capture(() => signing(p.ctx(['ensure', '--yes', '--no-install']))).catch(e => ({ out: '', err: String(e.message ?? e), exit: 1 })); + const all = r.out + r.err; + expect(calls.filter(c => c.method === 'POST' && c.path === '/v1/profiles')).toHaveLength(2); + expect(all).toContain('com.example.notes.ShareExt lacks group.com.example.notes'); + expect(all).toContain('https://developer.apple.com/account/resources/identifiers/bundleId/edit/EXT0001'); + expect(all).toContain('signing ensure --refresh-profile'); + }); + + test('manual: the profile carries the group: ensure finishes', async () => { + ascEnv(); + const p = project(cfg('manual'), files); + const s = store({ groupInProfile: true }); + mockFetch(s.routes); + const r = await capture(() => signing(p.ctx(['ensure', '--yes', '--no-install']))); + expect(r.exit).toBeNull(); + expect(r.out + r.err).toContain('signing ready'); + }); +}); + +describe('apple build over SSH: the keychain fixes', () => { + test('the refusal names the GUI session, the CI runner and the keychain flags', () => { + const m = sshBuildRefusal('this is an SSH session'); + expect(m).toContain('errSecInternalComponent'); + expect(m).toContain('open -a Terminal'); + expect(m).toContain('Jenkins'); + expect(m).toContain('security unlock-keychain'); + expect(m).toContain('security set-key-partition-list'); + expect(m).toContain('--keychain-unlock'); + expect(m).toContain('--keychain '); + expect(m).toContain(KEYCHAIN_PASSWORD_ENV); + expect(sshBuildRefusal('x', { unlockFlag: false })).not.toContain('--keychain-unlock'); + }); + + test('unlock steps carry a PASSWORD placeholder that is never shown; the build keychain joins the search list', () => { + const steps = unlockSteps('/tmp/build.keychain-db', ['/Users/example/Library/Keychains/login.keychain-db']); + expect(steps.map(s => s[1])).toEqual(['unlock-keychain', 'set-key-partition-list', 'list-keychains']); + expect(steps[1]).toContain('apple-tool:,apple:,codesign:'); + expect(steps[2]).toEqual(['security', 'list-keychains', '-d', 'user', '-s', '/tmp/build.keychain-db', '/Users/example/Library/Keychains/login.keychain-db']); + expect(showSecurity(steps[0]!)).toBe(`security unlock-keychain -p $${KEYCHAIN_PASSWORD_ENV} /tmp/build.keychain-db`); + expect(unlockSteps('/a', ['/a']).map(s => s[1])).toEqual(['unlock-keychain', 'set-key-partition-list']); + expect(parseKeychainList(' "/Users/example/Library/Keychains/login.keychain-db"\n "/Library/Keychains/System.keychain"\n')).toEqual(['/Users/example/Library/Keychains/login.keychain-db', '/Library/Keychains/System.keychain']); + }); + + test('the password comes from the environment; its absence is an error that names the variable only', () => { + expect(() => keychainPassword({})).toThrow(KEYCHAIN_PASSWORD_ENV); + expect(keychainPassword({ [KEYCHAIN_PASSWORD_ENV]: 'pw-for-test' })).toBe('pw-for-test'); + try { keychainPassword({}); } catch (e) { expect((e as Error).message).not.toContain('pw-for-test'); } + }); +}); + +describe('storebound api writes: --yes --confirm ', () => { + const w = { store: 'apple' as const, method: 'POST', kind: 'write' as const, opId: 'bundleIds_createInstance', yes: false, tty: false, dry: false, agent: false }; + test('the usage and the refusals show the example', () => { + expect(USAGE).toContain('--yes --confirm bundleIds_createInstance'); + expect(NOT_A_TTY).toContain('--yes --confirm bundleIds_createInstance'); + const d = decide(w as never); + expect(d.action).toBe('dry'); + const why = (d as { why: string }).why; + expect(why).toContain('--yes --confirm bundleIds_createInstance'); + expect(why).toContain('storebound api apple bundleIds_createInstance --body body.json'); + }); +}); From 5236f002f195ea2011e6f4b0c4588485abfbcc36 Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 13:44:08 +0800 Subject: [PATCH 004/107] desktop: tray menu panels and pinnable context menus The tray's right-click menu is drawn by the app: one tree from tray.rs (stable submenu keys), one frameless always-on-top menu-N window per panel rendered by tray-menu.html with the design system, placed inside the monitor's work area. A pure panel book keeps transient and pinned panels apart; pinned panels (pin button, Ctrl+click, middle click) are draggable, step aside from each other and are saved in state.json (menuPins) and restored at start. tray_update pushes the tree to the open panels, which update in place; closed panels are destroyed. The native menu, rendered from the same tree, stays as the fallback and on Linux. Only menu-* windows get the menu_* commands (menu.json). In-app context menus get the same behaviour through a pure menu stack (ui/menu-stack.ts): pin a menu, tear off a submenu, pinned panels survive the next menu, are draggable and re-read their entries from live sources so data changes update them in place. Also drops a stray diff3 marker line from CHANGELOG.md. --- CHANGELOG.md | 3 +- apps/desktop/src-tauri/build.rs | 2 +- apps/desktop/src-tauri/capabilities/menu.json | 21 + apps/desktop/src-tauri/src/commands.rs | 81 ++ apps/desktop/src-tauri/src/commands_list.rs | 3 + apps/desktop/src-tauri/src/lib.rs | 83 +- apps/desktop/src-tauri/src/menu_panel.rs | 744 ++++++++++++++++++ apps/desktop/src-tauri/src/policy.rs | 11 + apps/desktop/src-tauri/src/store.rs | 6 +- apps/desktop/src-tauri/src/tray.rs | 58 +- apps/desktop/src-tauri/tauri.conf.json | 2 +- apps/desktop/src-tauri/tests/menu_panel.rs | 203 +++++ apps/desktop/src-tauri/tests/security.rs | 47 +- apps/desktop/src-tauri/tests/tray.rs | 2 +- apps/desktop/src/App.tsx | 2 +- apps/desktop/src/design.css | 13 + apps/desktop/src/i18n/en.ts | 6 + apps/desktop/src/i18n/tr.ts | 6 + apps/desktop/src/screens/AppView.tsx | 4 +- apps/desktop/src/screens/Ignored.tsx | 2 +- apps/desktop/src/screens/Portfolio.tsx | 6 +- apps/desktop/src/traymenu/TrayMenu.tsx | 166 ++++ apps/desktop/src/traymenu/demo.tsx | 55 ++ apps/desktop/src/traymenu/main.tsx | 70 ++ apps/desktop/src/traymenu/menu-api.ts | 33 + apps/desktop/src/traymenu/model.ts | 75 ++ apps/desktop/src/traymenu/traymenu.css | 28 + apps/desktop/src/ui/ContextMenu.tsx | 282 +++++-- apps/desktop/src/ui/icons.tsx | 4 +- apps/desktop/src/ui/menu-stack.ts | 140 ++++ apps/desktop/tests/menu-stack.test.ts | 150 ++++ apps/desktop/tests/tray-menu.test.ts | 85 ++ apps/desktop/tray-menu.html | 14 + apps/desktop/vite.config.ts | 5 +- docs/desktop.md | 19 + 35 files changed, 2308 insertions(+), 123 deletions(-) create mode 100644 apps/desktop/src-tauri/capabilities/menu.json create mode 100644 apps/desktop/src-tauri/src/menu_panel.rs create mode 100644 apps/desktop/src-tauri/tests/menu_panel.rs create mode 100644 apps/desktop/src/traymenu/TrayMenu.tsx create mode 100644 apps/desktop/src/traymenu/demo.tsx create mode 100644 apps/desktop/src/traymenu/main.tsx create mode 100644 apps/desktop/src/traymenu/menu-api.ts create mode 100644 apps/desktop/src/traymenu/model.ts create mode 100644 apps/desktop/src/traymenu/traymenu.css create mode 100644 apps/desktop/src/ui/menu-stack.ts create mode 100644 apps/desktop/tests/menu-stack.test.ts create mode 100644 apps/desktop/tests/tray-menu.test.ts create mode 100644 apps/desktop/tray-menu.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a15c38..ac914f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,11 @@ ## Unreleased +- Desktop tray: the right-click menu is drawn by the app (the design system's menu look) instead of the OS menu. Each submenu opens as its own small panel; the pin button, Ctrl+click or a middle click keeps a panel open (pinned): it can be dragged, steps aside so it never covers another pinned panel, and comes back after a restart. Values (the apps and their store state, mute) update in place after every store read without closing anything. The native menu stays as a fallback (and on Linux), built from the same menu tree. The left-click tray card is unchanged. +- Desktop right-click menus in the window work the same way: a menu's pin button keeps it open as a floating panel, a submenu can be torn off (its pin button, Ctrl+click or a middle click), pinned panels survive the next menu, can be dragged, and update in place when the data behind them changes ("Gone" when the thing they were for left the screen). - `shots.feature.rotate` (degrees) is honoured by the Play feature graphic like a scene frame's `rotate`: added to the template tilt and the phone is re-fitted (`-90` stands a landscape game frame upright); with `shots.layout: "bleed"` the frame image is turned and still covers the canvas. - `shots check` / `shots render` without frames for a language that has copy use the default language's frames (an info line, no failure); `shots.perLanguageFrames: true` requires per-language frames. `shots fonts --lang en,tr` limits the report to those languages (the font manifest keeps all of them). - `shots capture` web shots take `waitSelector` and `click` selector lists per layout (`{ phone, tablet, any }`, data attributes first, `text=` fallback). A miss names the selector and the size with every candidate's match count; `shots capture --dry` prints the candidates per size and `--dry --probe` opens the pages and reports which one matched or MISSed on which size. -||||||| parent of ae8d773 (apple signing: every signable target (extensions, App Group check), build keychain unlock over SSH, api confirm docs) - `apple signing ensure` signs every target of the Xcode project, not only the app: it reads the pbxproj targets (app, app extensions such as a share extension or widget, watch app, App Clip: Release PRODUCT_BUNDLE_IDENTIFIER and entitlements), registers each extension's bundle id (`POST /v1/bundleIds`, platform from the profile type), enables the capabilities its entitlements need (read back) and, with manual signing, creates its App Store profile; `--dry` lists every target. `apple.signing.extensions` (`bundleId`, `name`, `capabilities`, `appGroups`) replaces the detected extensions when detection is wrong; `status` shows a target table. - App Groups: the API cannot assign a group to a bundle id, so `signing status` / `ensure` and (manual signing) `apple build` decode the profile and compare its `com.apple.security.application-groups` with the target's entitlements; a gap names the developer portal link (`.../identifiers/bundleId/edit/`) and the steps to tick the group. `signing ensure --refresh-profile` makes the profile again afterwards; `apple build --allow-app-group-gap` overrides. diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index 673bde2..02a67cc 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -5,7 +5,7 @@ include!("src/commands_list.rs"); fn main() { tauri_build::try_build( tauri_build::Attributes::new() - .app_manifest(tauri_build::AppManifest::new().commands(Box::leak([COMMANDS, CARD_COMMANDS].concat().into_boxed_slice()))), + .app_manifest(tauri_build::AppManifest::new().commands(Box::leak([COMMANDS, CARD_COMMANDS, MENU_COMMANDS].concat().into_boxed_slice()))), ) .expect("tauri build script failed"); } diff --git a/apps/desktop/src-tauri/capabilities/menu.json b/apps/desktop/src-tauri/capabilities/menu.json new file mode 100644 index 0000000..2f5783e --- /dev/null +++ b/apps/desktop/src-tauri/capabilities/menu.json @@ -0,0 +1,21 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "menu-window", + "description": "The tray menu panels (menu-1, menu-2...: small frameless windows the app opens on a right click on the tray icon, one per open or pinned submenu) with the app's own bundled tray-menu page only. They may listen to app events (the live menu tree) and call the menu commands: read their panel, report their size, open, pin and close panels, pick a row, which runs the same tray action as the native menu, and start a drag of their own window (a pinned panel's header). They never read the stores or reach the sidecar; every command re-checks the window label and page origin in Rust.", + "windows": ["menu-*"], + "local": true, + "permissions": [ + "core:event:allow-listen", + "core:event:allow-unlisten", + "core:window:allow-start-dragging", + "allow-menu-state", + "allow-menu-ready", + "allow-menu-open-sub", + "allow-menu-pin-sub", + "allow-menu-close-subs", + "allow-menu-close", + "allow-menu-dismiss", + "allow-menu-pin", + "allow-menu-select" + ] +} diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index 2cbf8f9..9739e6a 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -49,6 +49,11 @@ fn guard_card(w: &WebviewWindow) -> Res<()> { policy::require_card(w.label(), &url, cfg!(debug_assertions)).map_err(Fail::host) } +fn guard_menu(w: &WebviewWindow) -> Res<()> { + let url = w.url().map_err(|e| Fail::host(e.to_string()))?; + policy::require_menu(w.label(), &url, cfg!(debug_assertions)).map_err(Fail::host) +} + fn saved(s: &AppState) -> Res> { s.saved.lock().map_err(|_| Fail::host("state poisoned")) } @@ -1665,3 +1670,79 @@ pub async fn ai_fix_undo(window: WebviewWindow, run_id: String) -> Res Res> { + guard_menu(&window)?; + Ok(crate::menu_panel::panel_state(&window)) +} + +/// A panel measured itself (CSS px): sized, placed inside the work area and shown. +#[tauri::command] +pub fn menu_ready(window: WebviewWindow, width: f64, height: f64) -> Res<()> { + guard_menu(&window)?; + crate::menu_panel::ready(&window, width, height); + Ok(()) +} + +/// Opens a submenu beside this panel at the row whose top is `top` (CSS px). +#[tauri::command] +pub fn menu_open_sub(window: WebviewWindow, key: String, top: f64, focus: bool) -> Res<()> { + guard_menu(&window)?; + crate::menu_panel::open_sub(&window, &key, top, focus, false); + Ok(()) +} + +/// Ctrl / middle click on a submenu row: opens it as its own pinned panel. +#[tauri::command] +pub fn menu_pin_sub(window: WebviewWindow, key: String, top: f64) -> Res<()> { + guard_menu(&window)?; + crate::menu_panel::open_sub(&window, &key, top, false, true); + Ok(()) +} + +/// The pointer moved to a plain row: this panel's submenus close. +#[tauri::command] +pub fn menu_close_subs(window: WebviewWindow) -> Res<()> { + guard_menu(&window)?; + crate::menu_panel::close_subs(&window); + Ok(()) +} + +/// Closes this panel (Left / Esc in a submenu, X on a pinned one). +#[tauri::command] +pub fn menu_close(window: WebviewWindow, refocus_parent: bool) -> Res<()> { + guard_menu(&window)?; + crate::menu_panel::close(&window, refocus_parent); + Ok(()) +} + +/// Esc on the root menu: every transient panel closes. +#[tauri::command] +pub fn menu_dismiss(window: WebviewWindow) -> Res<()> { + guard_menu(&window)?; + crate::menu_panel::dismiss(window.app_handle()); + Ok(()) +} + +/// Pins or unpins this panel. +#[tauri::command] +pub fn menu_pin(window: WebviewWindow, pinned: bool) -> Res<()> { + guard_menu(&window)?; + crate::menu_panel::pin(&window, pinned); + Ok(()) +} + +/// A pick: the same action as the native tray menu (an id tray.rs knows; anything else is refused). +#[tauri::command] +pub fn menu_select(window: WebviewWindow, id: String) -> Res<()> { + guard_menu(&window)?; + if id.len() > 300 || crate::tray::action_of(&id).is_none() { + return Err(Fail::host("unknown menu item")); + } + crate::menu_panel::select(&window, id); + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/commands_list.rs b/apps/desktop/src-tauri/src/commands_list.rs index 1daea05..a076389 100644 --- a/apps/desktop/src-tauri/src/commands_list.rs +++ b/apps/desktop/src-tauri/src/commands_list.rs @@ -56,3 +56,6 @@ pub const COMMANDS: &[&str] = &[ /// The tray card window's commands (capabilities/card.json grants them to the `card` window only). pub const CARD_COMMANDS: &[&str] = &["card_get", "card_action"]; + +/// The tray menu panels' commands (capabilities/menu.json grants them to the `menu-*` windows only). +pub const MENU_COMMANDS: &[&str] = &["menu_state", "menu_ready", "menu_open_sub", "menu_pin_sub", "menu_close_subs", "menu_close", "menu_dismiss", "menu_pin", "menu_select"]; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 2ef5046..e616bdd 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ pub mod commands; pub mod confirm; pub mod downloads; pub mod logs; +pub mod menu_panel; pub mod policy; pub mod profiles; pub mod registry; @@ -43,17 +44,53 @@ fn started_hidden() -> bool { std::env::args().any(|a| a == "--hidden") } -/// Tray icon: a click shows the window; the menu (tray.rs) opens the window, reads the stores, lists the apps with their -/// state, mutes notifications, checks for updates, opens Settings and quits (the only real quit on Windows and Linux, -/// where closing the window keeps the app running in the tray). The window refines tooltip, menu and icon after every -/// read (`tray_update`). +/// Tray icon: a left click shows the tray card (card.rs), a right click the app's own menu panels (menu_panel.rs, with +/// the tree from tray.rs: open the window, read the stores, the apps with their state, mute, updates, Settings and Quit, +/// the only real quit on Windows and Linux, where closing the window keeps the app running in the tray). The window +/// refines tooltip, menu and icon after every read (`tray_update`). Linux trays send no clicks: the native menu, +/// rendered from the same tree, stays on the icon there (and anywhere the panels fail). fn tray(app: &tauri::App) -> tauri::Result<()> { - let menu = tray::initial_menu(app.handle())?; - let mut b = TrayIconBuilder::with_id("main").tooltip("storebound").menu(&menu).show_menu_on_left_click(false); + let handle = app.handle(); + let panels = handle.state::(); + if let Ok(mut n) = panels.nodes.lock() { + *n = tray::initial_nodes(); + } + let mut b = TrayIconBuilder::with_id("main").tooltip("storebound").show_menu_on_left_click(false); + if cfg!(target_os = "linux") { + panels.native.store(true, std::sync::atomic::Ordering::Relaxed); + b = b.menu(&tray::native_menu(handle, &tray::initial_nodes())?); + } if let Some(icon) = app.default_window_icon() { b = b.icon(icon.clone()); } - b.on_menu_event(|app, e| match tray::action_of(e.id().as_ref()) { + b.on_menu_event(|app, e| dispatch_menu(app, e.id().as_ref())) + .on_tray_icon_event(|t, e| { + let TrayIconEvent::Click { button, button_state: MouseButtonState::Up, rect, position, .. } = e else { return }; + let app = t.app_handle(); + match button { + // right click: the menu panels at the click point + MouseButton::Right => menu_panel::open_root(app, position.x, position.y), + // left click: the tray card next to the icon + MouseButton::Left => { + if app.get_webview_window(card::LABEL).is_none() { + show_main(app); + return; + } + let scale = app.get_webview_window(policy::MAIN).and_then(|w| w.scale_factor().ok()).unwrap_or(1.0); + let p = rect.position.to_physical::(scale); + let s = rect.size.to_physical::(scale); + card::toggle(app, card::Rect { x: p.x, y: p.y, w: s.width, h: s.height }); + } + _ => {} + } + }) + .build(app)?; + Ok(()) +} + +/// A tray menu pick (a menu panel row or the native fallback menu): tray.rs maps the id to what it does. +pub fn dispatch_menu(app: &AppHandle, id: &str) { + match tray::action_of(id) { Some(tray::Action::Open) => show_main(app), Some(tray::Action::Quit) => app.exit(0), Some(tray::Action::Event(word)) => { @@ -64,23 +101,7 @@ fn tray(app: &tauri::App) -> tauri::Result<()> { let _ = app.emit_to(policy::MAIN, "tray-route", route); } None => {} - }) - .on_tray_icon_event(|t, e| { - // left click: the tray card next to the icon (right click keeps the native menu) - if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, rect, .. } = e { - let app = t.app_handle(); - if app.get_webview_window(card::LABEL).is_none() { - show_main(app); - return; - } - let scale = app.get_webview_window(policy::MAIN).and_then(|w| w.scale_factor().ok()).unwrap_or(1.0); - let p = rect.position.to_physical::(scale); - let s = rect.size.to_physical::(scale); - card::toggle(app, card::Rect { x: p.x, y: p.y, w: s.width, h: s.height }); - } - }) - .build(app)?; - Ok(()) + } } /// Silent self-update: at start (after a minute) and every 30 minutes, a signed update from storebound.dev is @@ -282,7 +303,7 @@ pub fn run() { .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| show_main(app))) .plugin(tauri_plugin_deep_link::init()) // never restore "visible": a --hidden start would otherwise pop the window up - .plugin(tauri_plugin_window_state::Builder::new().with_state_flags(tauri_plugin_window_state::StateFlags::all() & !tauri_plugin_window_state::StateFlags::VISIBLE).build()) + .plugin(tauri_plugin_window_state::Builder::new().with_filter(|label| !label.starts_with(menu_panel::LABEL_PREFIX)).with_state_flags(tauri_plugin_window_state::StateFlags::all() & !tauri_plugin_window_state::StateFlags::VISIBLE).build()) .plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, Some(vec!["--hidden"]))) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_opener::init()) @@ -337,7 +358,10 @@ pub fn run() { Err(e) => logs::host("warn", &format!("autostart not set: {e}")), } app.manage(state); + app.manage(menu_panel::MenuPanels::default()); tray(app)?; + // pinned tray menu panels of the last session come back + menu_panel::restore(app.handle()); if let Err(e) = card::create(app.handle()) { logs::host("warn", &format!("tray card not created: {e}")); } @@ -438,6 +462,15 @@ pub fn run() { commands::ai_fix_undo, commands::card_get, commands::card_action, + commands::menu_state, + commands::menu_ready, + commands::menu_open_sub, + commands::menu_pin_sub, + commands::menu_close_subs, + commands::menu_close, + commands::menu_dismiss, + commands::menu_pin, + commands::menu_select, ]) .build(tauri::generate_context!()) .expect("storebound desktop failed to start"); diff --git a/apps/desktop/src-tauri/src/menu_panel.rs b/apps/desktop/src-tauri/src/menu_panel.rs new file mode 100644 index 0000000..3809791 --- /dev/null +++ b/apps/desktop/src-tauri/src/menu_panel.rs @@ -0,0 +1,744 @@ +//! The tray's right-click menu, drawn by the app itself instead of the OS menu (tray-menu.html, src/traymenu/). +//! +//! - One tree: tray.rs builds the menu as `Entry`s, turned here into `Node`s. Every submenu has a stable key (its label +//! without the live part), so an open or pinned panel finds its submenu again after the values change. +//! - One window per panel: the root menu and each submenu are their own frameless, always-on-top `menu-N` windows. The +//! page measures itself and calls `menu_ready`; Rust sizes, places (inside the monitor's work area) and shows it. +//! - The panel book (`Book`, pure, tests/menu_panel.rs) records which panels are open, which are pinned and whose child +//! each one is. Transient panels close together (focus left all of them, Esc, a pick); pinned ones stay, can be +//! dragged, step aside so they never cover each other and come back after a restart (`menuPins` in state.json). +//! - Live: every `tray_update` pushes the tree to the open panels (`menu-model`), which update in place. Nothing polls. +//! - Idle: a closed panel is destroyed, never hidden. +//! - Fallback: when a panel window cannot be built or never reports ready, the native tray menu (rendered from the same +//! tree) comes back. Linux always uses it (its tray sends no click events). +//! +//! The top half is pure and covered by tests/menu_panel.rs. +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Mutex; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::tray::Entry; + +pub const LABEL_PREFIX: &str = "menu-"; +pub const PAGE: &str = "tray-menu.html"; +pub const MODEL_EVENT: &str = "menu-model"; +pub const PANEL_EVENT: &str = "menu-panel"; +/// Gap to the work area's edges (physical px at scale 1). +pub const GAP: f64 = 8.0; +/// The estimated size of a panel before it measured itself (logical px). +pub const EST: (f64, f64) = (280.0, 220.0); + +// ---------------------------------------------------------------- model + +#[derive(Serialize, Clone, Debug, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum Node { + Item { id: String, label: String, enabled: bool }, + Sep, + Sub { key: String, label: String, items: Vec }, +} + +/// A submenu's stable key: its label without status glyphs and without the live part after ':' / '·' / '(' +/// ("Apps (3)" -> "apps", "Apps · 2 need you" -> "apps"). Pinned panels are found again by these keys. +pub fn sub_key(label: &str) -> String { + let head = label.split([':', '\u{00B7}', '(']).next().unwrap_or(""); + let mut out = String::new(); + for c in head.chars() { + if c.is_alphanumeric() { + out.extend(c.to_lowercase()); + } else if (c == ' ' || c == '-' || c == '_') && !out.is_empty() && !out.ends_with('-') { + out.push('-'); + } + } + let out = out.trim_end_matches('-').to_string(); + if out.is_empty() { "sub".into() } else { out } +} + +/// Sibling submenus with the same key get "-2", "-3"... +pub fn unique_keys(mut nodes: Vec) -> Vec { + let mut seen: BTreeMap = BTreeMap::new(); + for n in nodes.iter_mut() { + if let Node::Sub { key, .. } = n { + let count = seen.entry(key.clone()).or_insert(0); + *count += 1; + if *count > 1 { + *key = format!("{key}-{count}"); + } + } + } + nodes +} + +/// The tray menu entries as panel nodes. A submenu keeps the fixed key tray.rs gives it (so a translated label never +/// changes it), else the key comes from its label. +pub fn from_entries(entries: &[Entry]) -> Vec { + let nodes = entries + .iter() + .map(|e| match e { + Entry::Item { id, label, enabled } => Node::Item { id: id.clone(), label: label.clone(), enabled: *enabled }, + Entry::Separator => Node::Sep, + Entry::Sub { key, label, items } => Node::Sub { key: if key.is_empty() { sub_key(label) } else { sub_key(key) }, label: label.clone(), items: from_entries(items) }, + }) + .collect(); + unique_keys(nodes) +} + +/// The items of the submenu at `path` (empty path: the root) and its label. +pub fn find<'a>(nodes: &'a [Node], path: &[String]) -> Option<(&'a [Node], Option<&'a str>)> { + let mut current = nodes; + let mut label = None; + for key in path { + let (items, l) = current.iter().find_map(|n| match n { + Node::Sub { key: k, label: l, items } if k == key => Some((items.as_slice(), l.as_str())), + _ => None, + })?; + current = items; + label = Some(l); + } + Some((current, label)) +} + +// ---------------------------------------------------------------- placement + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Anchor { + /// The tray click point (physical). + Point { x: f64, y: f64 }, + /// Beside a parent panel: its left and right edge and the row's top (physical). + Beside { left: f64, right: f64, top: f64 }, + /// A remembered top-left (pinned panels). + At { x: f64, y: f64 }, +} + +/// x, y, width, height (physical px). +pub type Rect = (f64, f64, f64, f64); + +/// Keeps a panel of size w x h fully inside the work area (with `GAP`). +pub fn clamp_into(x: f64, y: f64, w: f64, h: f64, work: Rect) -> (f64, f64) { + let (wx, wy, ww, wh) = work; + (x.clamp(wx + GAP, (wx + ww - w - GAP).max(wx + GAP)), y.clamp(wy + GAP, (wy + wh - h - GAP).max(wy + GAP))) +} + +/// Where a panel of `size` goes for `anchor`, inside the work area: the root opens away from the taskbar / menu bar the +/// click is near, a submenu beside its parent (flipped left at the screen edge), a pinned panel at its spot. +pub fn place_menu(anchor: Anchor, size: (f64, f64), work: Rect) -> (f64, f64) { + let (w, h) = size; + let (wx, wy, ww, wh) = work; + let (x, y) = match anchor { + Anchor::Point { x, y } => (if x + w > wx + ww { x - w } else { x }, if y + h > wy + wh { y - h } else { y }), + Anchor::Beside { left, right, top } => (if right + w <= wx + ww - GAP { right - 2.0 } else { left - w + 2.0 }, top - 5.0), + Anchor::At { x, y } => (x, y), + }; + clamp_into(x, y, w, h, work) +} + +pub fn overlaps(a: Rect, b: Rect) -> bool { + a.0 < b.0 + b.2 && b.0 < a.0 + a.2 && a.1 < b.1 + b.3 && b.1 < a.1 + a.3 +} + +/// A spot for a newly pinned panel that covers no other pinned panel: below whatever it overlaps, then the next column. +/// Gives up after a while (a screen full of pins) and returns the clamped wish. +pub fn free_spot(want: Rect, others: &[Rect], work: Rect) -> (f64, f64) { + let (mut x, mut y, w, h) = want; + for _ in 0..64 { + let Some(hit) = others.iter().find(|o| overlaps((x, y, w, h), **o)) else { + return clamp_into(x, y, w, h, work); + }; + y = hit.1 + hit.3 + GAP; + if y + h > work.1 + work.3 { + y = work.1 + GAP; + x += w + GAP; + if x + w > work.0 + work.2 { + x = work.0 + GAP; + } + } + } + clamp_into(want.0, want.1, w, h, work) +} + +// ---------------------------------------------------------------- panel book + +#[derive(Clone, Debug, PartialEq)] +pub struct Panel { + pub path: Vec, + pub pinned: bool, + pub parent: Option, + pub anchor: Anchor, + /// Measured and shown once. + pub shown: bool, + /// Physical top-left once shown (kept for pins). + pub pos: Option<(f64, f64)>, + /// Opened from the keyboard: takes focus and starts on its first row. + pub focus: bool, +} + +/// A pinned panel as state.json keeps it (`menuPins`). +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct PinRecord { + pub path: Vec, + pub x: i32, + pub y: i32, +} + +/// Which panels are open, which are pinned, who is whose child. +#[derive(Default, Debug)] +pub struct Book { + pub panels: BTreeMap, + next: u32, +} + +impl Book { + fn add(&mut self, path: Vec, pinned: bool, parent: Option, anchor: Anchor) -> String { + self.next += 1; + let label = format!("{LABEL_PREFIX}{}", self.next); + self.panels.insert(label.clone(), Panel { path, pinned, parent, anchor, shown: false, pos: None, focus: false }); + label + } + + fn remove_all(&mut self, labels: &[String]) { + for l in labels { + self.panels.remove(l); + } + } + + pub fn transient(&self) -> Vec { + self.panels.iter().filter(|(_, p)| !p.pinned).map(|(l, _)| l.clone()).collect() + } + + /// Transient panels below `label` (children first). + pub fn descendants(&self, label: &str) -> Vec { + let mut out = Vec::new(); + let mut frontier = vec![label.to_string()]; + while let Some(l) = frontier.pop() { + for (child, p) in &self.panels { + if p.parent.as_deref() == Some(l.as_str()) && !p.pinned && !out.contains(child) { + out.push(child.clone()); + frontier.push(child.clone()); + } + } + } + out + } + + /// A fresh root menu: every transient panel closes, pinned ones stay. Returns the new label and the closed ones. + pub fn open_root(&mut self, anchor: Anchor) -> (String, Vec) { + let closes = self.transient(); + self.remove_all(&closes); + (self.add(vec![], false, None, anchor), closes) + } + + /// Opens submenu `key` of `parent`; its other transient children close. None when that submenu already is the open + /// child, or the parent is gone. `pinned`: torn off as its own pinned panel (one pinned copy per submenu). + pub fn open_sub(&mut self, parent: &str, key: &str, anchor: Anchor, pinned: bool) -> Option<(String, Vec)> { + let mut path = self.panels.get(parent)?.path.clone(); + path.push(key.to_string()); + if !pinned && self.panels.values().any(|p| p.parent.as_deref() == Some(parent) && !p.pinned && p.path == path) { + return None; + } + let mut closes = self.descendants(parent); + self.remove_all(&closes); + if pinned { + let old: Vec = self.panels.iter().filter(|(_, p)| p.pinned && p.path == path).map(|(l, _)| l.clone()).collect(); + for l in old { + closes.extend(self.close(&l)); + } + return Some((self.add(path, true, None, anchor), closes)); + } + Some((self.add(path, false, Some(parent.to_string()), anchor), closes)) + } + + /// Closes `label` and its transient children. + pub fn close(&mut self, label: &str) -> Vec { + if !self.panels.contains_key(label) { + return vec![]; + } + let mut out = self.descendants(label); + out.push(label.to_string()); + self.remove_all(&out); + out + } + + /// Every transient panel closes (Esc on the root, a pick, focus left the menus). + pub fn dismiss(&mut self) -> Vec { + let t = self.transient(); + self.remove_all(&t); + t + } + + /// Pin or unpin. Pinning detaches the panel from its chain: the transient panels that are not its own children + /// close (returned). A second pinned copy of the same submenu closes too. + pub fn pin(&mut self, label: &str, on: bool) -> Vec { + let Some(p) = self.panels.get(label) else { return vec![] }; + let path = p.path.clone(); + if !on { + if let Some(p) = self.panels.get_mut(label) { + p.pinned = false; + } + return vec![]; + } + let mut closes: Vec = self.panels.iter().filter(|(l, p)| p.pinned && p.path == path && l.as_str() != label).map(|(l, _)| l.clone()).collect(); + if let Some(p) = self.panels.get_mut(label) { + p.pinned = true; + p.parent = None; + } + let mine = self.descendants(label); + closes.extend(self.transient().into_iter().filter(|l| l != label && !mine.contains(l))); + self.remove_all(&closes); + closes + } + + /// The tree changed: transient panels whose submenu is gone close (with their children). Pinned ones stay and show + /// that their submenu is gone until it comes back (an app that is read again) or they are closed. + pub fn prune(&mut self, nodes: &[Node]) -> Vec { + let gone: Vec = self.panels.iter().filter(|(_, p)| !p.pinned && find(nodes, &p.path).is_none()).map(|(l, _)| l.clone()).collect(); + let mut out = Vec::new(); + for l in gone { + if self.panels.contains_key(&l) { + out.extend(self.close(&l)); + } + } + out + } + + /// The pinned panels to save (only placed ones; in label order, so the order is stable). + pub fn pins(&self) -> Vec { + self.panels + .values() + .filter(|p| p.pinned) + .filter_map(|p| p.pos.map(|(x, y)| PinRecord { path: p.path.clone(), x: x.round() as i32, y: y.round() as i32 })) + .collect() + } + + /// Recreates pinned panels from a saved list (duplicates and more than `MAX_PINS` dropped). + pub fn restore(&mut self, pins: &[PinRecord]) -> Vec { + let mut out = Vec::new(); + for r in pins.iter().take(MAX_PINS) { + if self.panels.values().any(|p| p.pinned && p.path == r.path) || r.path.len() > 8 { + continue; + } + let (x, y) = (r.x as f64, r.y as f64); + let l = self.add(r.path.clone(), true, None, Anchor::At { x, y }); + if let Some(p) = self.panels.get_mut(&l) { + p.pos = Some((x, y)); + } + out.push(l); + } + out + } +} + +/// At most this many pinned panels are restored after a restart. +pub const MAX_PINS: usize = 12; + +/// A panel label the app made (`menu-` and a number), for the window / command checks. +pub fn is_menu_label(label: &str) -> bool { + label.strip_prefix(LABEL_PREFIX).is_some_and(|n| !n.is_empty() && n.len() <= 9 && n.chars().all(|c| c.is_ascii_digit())) +} + +// ---------------------------------------------------------------- runtime + +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct PanelInfo { + pub label: String, + pub path: Vec, + pub pinned: bool, + pub has_parent: bool, + pub focus_first: bool, + pub nodes: Vec, +} + +/// The panels' shared state (managed by the app). +#[derive(Default)] +pub struct MenuPanels { + pub book: Mutex, + pub nodes: Mutex>, + /// The native menu took over (a panel failed, or Linux). + pub native: AtomicBool, + /// Bumped on every move of a pinned panel; the save runs once the moves stop. + moves: AtomicU64, +} + +#[cfg(desktop)] +mod runtime { + use super::*; + use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; + + fn state(app: &AppHandle) -> tauri::State<'_, MenuPanels> { + app.state::() + } + + fn book(app: &AppHandle, f: impl FnOnce(&mut Book) -> R) -> R { + let s = state(app); + let mut b = s.book.lock().unwrap_or_else(|e| e.into_inner()); + f(&mut b) + } + + fn nodes(app: &AppHandle) -> Vec { + state(app).nodes.lock().map(|n| n.clone()).unwrap_or_default() + } + + pub fn native_fallback(app: &AppHandle) -> bool { + state(app).native.load(Ordering::Relaxed) + } + + /// Puts the native menu (from the current tree) on the tray: the fallback, and Linux. + pub fn set_native(app: &AppHandle) { + let Some(tray) = app.tray_by_id("main") else { return }; + match crate::tray::native_menu(app, &nodes(app)) { + Ok(m) => { + let _ = tray.set_menu(Some(m)); + } + Err(e) => crate::logs::host("warn", &format!("native tray menu not built: {e}")), + } + } + + /// Writes the pinned panels into state.json (`menuPins`). + fn save_pins(app: &AppHandle) { + let pins = book(app, |b| b.pins()); + let Some(s) = app.try_state::() else { return }; + let Ok(mut st) = s.saved.lock() else { return }; + if st.menu_pins == pins { + return; + } + st.menu_pins = pins; + if let Err(e) = crate::store::save(&s.data_dir, &st) { + crate::logs::host("warn", &format!("menu pins not saved: {e}")); + } + } + + /// Stores the latest tree and pushes it to every open panel (they update in place). Panels of a submenu that is gone + /// close when transient. Cheap when nothing changed or no panel is open. + pub fn set_nodes(app: &AppHandle, next: Vec) { + let changed = { + let s = state(app); + let mut g = s.nodes.lock().unwrap_or_else(|e| e.into_inner()); + let changed = *g != next; + *g = next.clone(); + changed + }; + if !changed { + return; + } + let (closes, open) = book(app, |b| (b.prune(&next), b.panels.keys().cloned().collect::>())); + destroy(app, &closes); + for l in open { + let _ = app.emit_to(l.as_str(), MODEL_EVENT, &next); + } + if native_fallback(app) { + set_native(app); + } + } + + fn destroy(app: &AppHandle, labels: &[String]) { + for l in labels { + if let Some(w) = app.get_webview_window(l) { + let _ = w.destroy(); + } + } + } + + fn build_window(app: &AppHandle, label: &str) -> tauri::Result { + let b = WebviewWindowBuilder::new(app, label, WebviewUrl::App(PAGE.into())) + .title("storebound") + .inner_size(EST.0, EST.1) + .decorations(false) + .always_on_top(true) + .skip_taskbar(true) + .resizable(false) + .shadow(true) + .visible(false) + .focused(false); + #[cfg(not(target_os = "macos"))] + let b = b.transparent(true); + let window = b.build()?; + #[cfg(target_os = "macos")] + let _ = window.set_visible_on_all_workspaces(true); + let handle = app.clone(); + let me = label.to_string(); + window.on_window_event(move |event| match event { + tauri::WindowEvent::Focused(false) => { + let handle = handle.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(160)); + close_transient_unless_focused(&handle); + }); + } + tauri::WindowEvent::Moved(pos) => { + let pinned = book(&handle, |b| match b.panels.get_mut(&me) { + Some(p) if p.shown => { + p.pos = Some((pos.x as f64, pos.y as f64)); + p.pinned + } + _ => false, + }); + if pinned { + // a drag sends many moves: save once they stop + let n = state(&handle).moves.fetch_add(1, Ordering::Relaxed) + 1; + let handle = handle.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(400)); + if state(&handle).moves.load(Ordering::Relaxed) == n { + save_pins(&handle); + } + }); + } + } + _ => {} + }); + Ok(window) + } + + /// Focus left a menu window: every transient panel closes unless one of them has focus now. + fn close_transient_unless_focused(app: &AppHandle) { + let transient = book(app, |b| b.transient()); + if transient.iter().any(|l| app.get_webview_window(l).and_then(|w| w.is_focused().ok()).unwrap_or(false)) { + return; + } + book(app, |b| { + for l in &transient { + b.panels.remove(l); + } + }); + destroy(app, &transient); + } + + /// The panels cannot be shown: the native menu goes on the tray and opens once now. + fn native_popup(app: &AppHandle) { + if !state(app).native.swap(true, Ordering::Relaxed) { + crate::logs::host("warn", "tray menu panels unavailable: the native menu is used"); + } + set_native(app); + let handle = app.clone(); + let _ = app.run_on_main_thread(move || { + if let (Some(w), Ok(menu)) = (handle.get_webview_window(crate::policy::MAIN), crate::tray::native_menu(&handle, &nodes(&handle))) { + let _ = w.popup_menu(&menu); + } + }); + } + + /// A right click on the tray icon: a fresh root menu at the click point (physical px). + pub fn open_root(app: &AppHandle, x: f64, y: f64) { + if native_fallback(app) { + return; // the native menu is on the tray and opens by itself + } + let (label, closes) = book(app, |b| b.open_root(Anchor::Point { x, y })); + destroy(app, &closes); + let handle = app.clone(); + std::thread::spawn(move || { + if let Err(e) = build_window(&handle, &label) { + crate::logs::host("warn", &format!("tray menu panel not built: {e}")); + book(&handle, |b| b.panels.remove(&label)); + native_popup(&handle); + return; + } + std::thread::sleep(Duration::from_millis(2500)); + if book(&handle, |b| b.panels.get(&label).map(|p| p.shown)) == Some(false) { + crate::logs::host("warn", "tray menu panel never reported ready"); + let closes = book(&handle, |b| b.close(&label)); + destroy(&handle, &closes); + native_popup(&handle); + } + }); + } + + /// Brings back the pinned panels of the last session (state.json `menuPins`). + pub fn restore(app: &AppHandle) { + let pins = app.try_state::().and_then(|s| s.saved.lock().ok().map(|st| st.menu_pins.clone())).unwrap_or_default(); + if pins.is_empty() || native_fallback(app) { + return; + } + let labels = book(app, |b| b.restore(&pins)); + let handle = app.clone(); + std::thread::spawn(move || { + for l in labels { + if build_window(&handle, &l).is_err() { + book(&handle, |b| b.panels.remove(&l)); + } + } + }); + } + + fn work_area(app: &AppHandle, x: f64, y: f64) -> Option { + let m = app.monitor_from_point(x, y).ok().flatten().or_else(|| app.primary_monitor().ok().flatten())?; + let w = m.work_area(); + Some((w.position.x as f64, w.position.y as f64, w.size.width as f64, w.size.height as f64)) + } + + fn anchor_point(a: Anchor) -> (f64, f64) { + match a { + Anchor::Point { x, y } | Anchor::At { x, y } => (x, y), + Anchor::Beside { right, top, .. } => (right, top), + } + } + + fn outer_rect(w: &WebviewWindow) -> Option { + let p = w.outer_position().ok()?; + let s = w.outer_size().ok()?; + Some((p.x as f64, p.y as f64, s.width as f64, s.height as f64)) + } + + fn pinned_rects(app: &AppHandle, skip: &str) -> Vec { + let labels: Vec = book(app, |b| b.panels.iter().filter(|(l, p)| p.pinned && l.as_str() != skip).map(|(l, _)| l.clone()).collect()); + labels.iter().filter_map(|l| app.get_webview_window(l).and_then(|w| outer_rect(&w))).collect() + } + + // ---- what the panel page calls (commands.rs checks the window first) ---- + + pub fn panel_state(window: &WebviewWindow) -> Option { + let app = window.app_handle(); + let p = book(app, |b| b.panels.get(window.label()).cloned())?; + Some(PanelInfo { label: window.label().into(), path: p.path, pinned: p.pinned, has_parent: p.parent.is_some(), focus_first: p.focus, nodes: nodes(app) }) + } + + /// The panel measured itself (CSS px): size it, place it inside the work area, show it the first time. + pub fn ready(window: &WebviewWindow, width: f64, height: f64) { + let app = window.app_handle().clone(); + let Some(panel) = book(&app, |b| b.panels.get(window.label()).cloned()) else { return }; + let scale = window.scale_factor().unwrap_or(1.0); + let size = ((width.clamp(120.0, 600.0) * scale).ceil(), (height.clamp(40.0, 900.0) * scale).ceil()); + let (ax, ay) = panel.pos.unwrap_or_else(|| anchor_point(panel.anchor)); + let Some(work) = work_area(&app, ax, ay) else { return }; + let (x, y) = if panel.shown { + let (x, y, _, _) = outer_rect(window).unwrap_or((ax, ay, 0.0, 0.0)); + clamp_into(x, y, size.0, size.1, work) + } else if let Some((x, y)) = panel.pos { + clamp_into(x, y, size.0, size.1, work) + } else { + place_menu(panel.anchor, size, work) + }; + let _ = window.set_size(PhysicalSize::new(size.0 as u32, size.1 as u32)); + let _ = window.set_position(PhysicalPosition::new(x as i32, y as i32)); + if !panel.shown { + book(&app, |b| { + if let Some(p) = b.panels.get_mut(window.label()) { + p.shown = true; + p.pos = Some((x, y)); + } + }); + let _ = window.show(); + if panel.focus || (panel.parent.is_none() && !panel.pinned) { + let _ = window.set_focus(); + } + if panel.pinned { + save_pins(&app); + } + } + } + + /// Opens submenu `key` beside this panel at the row whose top is `top` (CSS px). `pinned`: torn off (Ctrl / middle + /// click), placed so it covers no other pinned panel. + pub fn open_sub(window: &WebviewWindow, key: &str, top: f64, focus: bool, pinned: bool) { + let app = window.app_handle().clone(); + let Some((wx, wy, ww, _)) = outer_rect(window) else { return }; + let scale = window.scale_factor().unwrap_or(1.0); + let anchor = Anchor::Beside { left: wx, right: wx + ww, top: wy + top.max(0.0) * scale }; + let Some((label, closes)) = book(&app, |b| b.open_sub(window.label(), key, anchor, pinned)) else { return }; + destroy(&app, &closes); + if pinned { + let others = pinned_rects(&app, &label); + if let Some(work) = work_area(&app, wx, wy) { + let est = (EST.0 * scale, EST.1 * scale); + let (x, y) = place_menu(anchor, est, work); + let spot = free_spot((x, y, est.0, est.1), &others, work); + book(&app, |b| { + if let Some(p) = b.panels.get_mut(&label) { + p.pos = Some(spot); + } + }); + } + } + book(&app, |b| { + if let Some(p) = b.panels.get_mut(&label) { + p.focus = focus; + } + }); + let handle = app.clone(); + std::thread::spawn(move || { + if build_window(&handle, &label).is_err() { + book(&handle, |b| b.panels.remove(&label)); + } + }); + } + + /// The pointer moved to a plain row: this panel's transient submenus close. + pub fn close_subs(window: &WebviewWindow) { + let app = window.app_handle().clone(); + let closes = book(&app, |b| { + let d = b.descendants(window.label()); + for l in &d { + b.panels.remove(l); + } + d + }); + destroy(&app, &closes); + } + + /// Closes this panel (Left / Esc in a submenu, X on a pinned one). + pub fn close(window: &WebviewWindow, refocus_parent: bool) { + let app = window.app_handle().clone(); + let (closes, parent, was_pinned) = book(&app, |b| { + let p = b.panels.get(window.label()).cloned(); + (b.close(window.label()), p.as_ref().and_then(|p| p.parent.clone()), p.is_some_and(|p| p.pinned)) + }); + if refocus_parent { + if let Some(w) = parent.and_then(|p| app.get_webview_window(&p)) { + let _ = w.set_focus(); + let _ = app.emit_to(w.label(), PANEL_EVENT, "focus"); + } + } + destroy(&app, &closes); + if was_pinned { + save_pins(&app); + } + } + + /// Esc on the root menu: every transient panel closes. + pub fn dismiss(app: &AppHandle) { + let closes = book(app, |b| b.dismiss()); + destroy(app, &closes); + } + + /// Pin or unpin this panel. Pinning steps it aside from the other pinned panels. + pub fn pin(window: &WebviewWindow, on: bool) { + let app = window.app_handle().clone(); + let label = window.label().to_string(); + let closes = book(&app, |b| b.pin(&label, on)); + destroy(&app, &closes); + if on { + let others = pinned_rects(&app, &label); + if let Some(me) = outer_rect(window) { + if let Some(work) = work_area(&app, me.0, me.1) { + let (x, y) = free_spot(me, &others, work); + if (x, y) != (me.0, me.1) { + let _ = window.set_position(PhysicalPosition::new(x as i32, y as i32)); + } + book(&app, |b| { + if let Some(p) = b.panels.get_mut(&label) { + p.pos = Some((x, y)); + } + }); + } + } + } + let _ = app.emit_to(label.as_str(), PANEL_EVENT, if on { "pinned" } else { "unpinned" }); + save_pins(&app); + } + + /// A pick: the same action as the native menu. Transient panels close; pinned ones stay and update in place. + pub fn select(window: &WebviewWindow, id: String) { + let app = window.app_handle().clone(); + let pinned = book(&app, |b| b.panels.get(window.label()).map(|p| p.pinned).unwrap_or(false)); + if !pinned { + dismiss(&app); + } + let handle = app.clone(); + let _ = app.run_on_main_thread(move || crate::dispatch_menu(&handle, &id)); + } +} + +#[cfg(desktop)] +pub use runtime::*; diff --git a/apps/desktop/src-tauri/src/policy.rs b/apps/desktop/src-tauri/src/policy.rs index 8d6cfff..3381956 100644 --- a/apps/desktop/src-tauri/src/policy.rs +++ b/apps/desktop/src-tauri/src/policy.rs @@ -56,6 +56,17 @@ pub fn require_card(label: &str, url: &Url, dev: bool) -> Result<(), String> { Ok(()) } +/// The tray menu panels (`menu-1`, `menu-2`... menu_panel.rs): they may call only the menu commands. +pub fn require_menu(label: &str, url: &Url, dev: bool) -> Result<(), String> { + if !crate::menu_panel::is_menu_label(label) { + return Err(format!("not allowed from window '{label}'")); + } + if !is_local_origin(url, dev) { + return Err("not allowed from this page".into()); + } + Ok(()) +} + /// Method kinds the sidecar announces in its `session.hello` descriptors. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Kind { diff --git a/apps/desktop/src-tauri/src/store.rs b/apps/desktop/src-tauri/src/store.rs index 5d2c567..f999653 100644 --- a/apps/desktop/src-tauri/src/store.rs +++ b/apps/desktop/src-tauri/src/store.rs @@ -45,11 +45,14 @@ pub struct State { pub overrides: Overrides, #[serde(default)] pub settings: Settings, + /// Pinned tray menu panels and where they sit (menu_panel.rs); restored at start. + #[serde(default, rename = "menuPins", skip_serializing_if = "Vec::is_empty")] + pub menu_pins: Vec, } impl Default for State { fn default() -> State { - State { version: VERSION, projects: vec![], accounts: vec![], studios: vec![], overrides: Overrides::default(), settings: Settings::default() } + State { version: VERSION, projects: vec![], accounts: vec![], studios: vec![], overrides: Overrides::default(), settings: Settings::default(), menu_pins: vec![] } } } @@ -121,6 +124,7 @@ pub fn migrate(mut v: Value) -> (State, u32, Vec) { studios: list(&o, "studios", &mut notes), overrides: one(&o, "overrides", &mut notes), settings: one(&o, "settings", &mut notes), + menu_pins: list(&o, "menuPins", &mut notes), }; // two entries with one id (hand-edited or merged files): the first one wins let mut seen = std::collections::HashSet::new(); diff --git a/apps/desktop/src-tauri/src/tray.rs b/apps/desktop/src-tauri/src/tray.rs index e3a653f..0b23b9f 100644 --- a/apps/desktop/src-tauri/src/tray.rs +++ b/apps/desktop/src-tauri/src/tray.rs @@ -1,7 +1,7 @@ //! The tray: tooltip, attention icon, context menu and the "where to go" memory of the last notification. //! //! The window pushes a small summary after every store read (`tray_update`); this module turns it into a menu model -//! (pure, tested in tests/tray.rs), rebuilds the menu, sets the tooltip and swaps the icon for one with a red dot while +//! (pure, tested in tests/tray.rs), pushes it to the menu panels (menu_panel.rs), sets the tooltip and swaps the icon for one with a red dot while //! something needs the owner. Menu clicks come back as events to the window: `tray-route` (a route as JSON) and //! `tray-action` (`read`, `mute-toggle`). //! @@ -14,7 +14,9 @@ use std::time::{Duration, Instant}; use serde::Deserialize; use serde_json::Value; use tauri::image::Image; -use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu}; +use tauri::menu::{IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}; + +use crate::menu_panel::{from_entries, Node}; use tauri::{AppHandle, Runtime}; pub const ID_OPEN: &str = "tray-open"; @@ -88,7 +90,8 @@ pub struct TraySummary { pub enum Entry { Item { id: String, label: String, enabled: bool }, Separator, - Sub { label: String, items: Vec }, + /// `key`: the submenu's fixed key (menu_panel::sub_key), the same in every language. + Sub { key: String, label: String, items: Vec }, } fn clean(s: &str, max: usize) -> String { @@ -132,7 +135,7 @@ pub fn menu_model(s: &TraySummary) -> Vec { item(ID_OPEN, &l.open), item(ID_READ, &l.read_now), Entry::Separator, - Entry::Sub { label: clean(&l.apps, 40), items: apps }, + Entry::Sub { key: "apps".into(), label: clean(&l.apps, 40), items: apps }, Entry::Separator, item(ID_MUTE, if s.muted { &l.unmute } else { &l.mute }), item(ID_UPDATE, &l.update), @@ -233,40 +236,45 @@ pub fn take_pending() -> Option { static LAST: Mutex> = Mutex::new(None); -fn build_menu(app: &AppHandle, model: &[Entry]) -> tauri::Result> { - let menu = Menu::new(app)?; - for e in model { - match e { - Entry::Item { id, label, enabled } => menu.append(&MenuItem::with_id(app, id, label, *enabled, None::<&str>)?)?, - Entry::Separator => menu.append(&PredefinedMenuItem::separator(app)?)?, - Entry::Sub { label, items } => { - let sub = Submenu::new(app, label, true)?; - for it in items { - if let Entry::Item { id, label, enabled } = it { - sub.append(&MenuItem::with_id(app, id, label, *enabled, None::<&str>)?)?; - } - } - menu.append(&sub)?; +/// The native menu items for a panel tree (the fallback, and Linux), nested to any depth. +fn native_items(app: &AppHandle, nodes: &[Node]) -> tauri::Result>>> { + let mut out: Vec>> = Vec::new(); + for n in nodes { + match n { + Node::Item { id, label, enabled } => out.push(Box::new(MenuItem::with_id(app, id, label, *enabled, None::<&str>)?)), + Node::Sep => out.push(Box::new(PredefinedMenuItem::separator(app)?)), + Node::Sub { label, items, .. } => { + let children = native_items(app, items)?; + let refs: Vec<&dyn IsMenuItem> = children.iter().map(|b| b.as_ref()).collect(); + out.push(Box::new(Submenu::with_items(app, label, true, &refs)?)); } } } - Ok(menu) + Ok(out) } -/// The menu shown before the window has sent anything (English words). -pub fn initial_menu(app: &AppHandle) -> tauri::Result> { - build_menu(app, &menu_model(&TraySummary::default())) +/// The native tray menu, rendered from the same tree as the panels. +pub fn native_menu(app: &AppHandle, nodes: &[Node]) -> tauri::Result> { + let items = native_items(app, nodes)?; + let refs: Vec<&dyn IsMenuItem> = items.iter().map(|b| b.as_ref()).collect(); + Menu::with_items(app, &refs) } -/// Applies a summary: tooltip, menu and icon of the tray with id `main`. Skips the work when nothing changed. -pub fn apply(app: &AppHandle, s: &TraySummary) -> tauri::Result<()> { +/// The tree before the window has sent anything (English words). +pub fn initial_nodes() -> Vec { + from_entries(&menu_model(&TraySummary::default())) +} + +/// Applies a summary: tooltip and icon of the tray with id `main`, and the menu tree, which goes live to the open menu +/// panels (and to the native menu when that is in use). Skips the work when nothing changed. +pub fn apply(app: &AppHandle, s: &TraySummary) -> tauri::Result<()> { let Some(tray) = app.tray_by_id("main") else { return Ok(()) }; let key = format!("{s:?}"); if LAST.lock().ok().is_some_and(|l| l.as_deref() == Some(key.as_str())) { return Ok(()); } tray.set_tooltip(Some(tooltip_text(s)))?; - tray.set_menu(Some(build_menu(app, &menu_model(s))?))?; + crate::menu_panel::set_nodes(app, from_entries(&menu_model(s))); if let Some(icon) = app.default_window_icon() { let img = if s.attention > 0 { Image::new_owned(with_attention_dot(icon.rgba(), icon.width(), icon.height()), icon.width(), icon.height()) } else { icon.clone() }; tray.set_icon(Some(img))?; diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index c786df1..b95e93c 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -50,7 +50,7 @@ "form-action": "'none'" }, "freezePrototype": true, - "capabilities": ["main-window", "card-window"] + "capabilities": ["main-window", "card-window", "menu-window"] } }, "bundle": { diff --git a/apps/desktop/src-tauri/tests/menu_panel.rs b/apps/desktop/src-tauri/tests/menu_panel.rs new file mode 100644 index 0000000..0f89878 --- /dev/null +++ b/apps/desktop/src-tauri/tests/menu_panel.rs @@ -0,0 +1,203 @@ +//! The tray menu panels' pure half: the tree and its stable keys, live updates, placement inside the work area, the +//! panel book (transient vs pinned, parents, restore after a restart). +use storebound_desktop::menu_panel::*; +use storebound_desktop::store::State; +use storebound_desktop::tray::{menu_model, Entry, TrayApp, TrayLabels, TraySummary}; + +const WORK: Rect = (0.0, 0.0, 1920.0, 1040.0); +const AT: Anchor = Anchor::At { x: 0.0, y: 0.0 }; + +fn summary(state: &str, apps_label: &str) -> TraySummary { + TraySummary { + apps: vec![TrayApp { key: "k1".into(), name: "First".into(), state: state.into() }, TrayApp { key: "k2".into(), name: "Second".into(), state: String::new() }], + labels: TrayLabels { apps: apps_label.into(), ..TrayLabels::default() }, + ..TraySummary::default() + } +} + +fn tree() -> Vec { + from_entries(&[ + Entry::Item { id: "open".into(), label: "Open".into(), enabled: true }, + Entry::Separator, + Entry::Sub { key: String::new(), label: "Fleet".into(), items: vec![Entry::Sub { key: String::new(), label: "Recently lowered".into(), items: vec![] }] }, + Entry::Sub { key: String::new(), label: "Profile: Work".into(), items: vec![] }, + ]) +} + +#[test] +fn keys_ignore_live_parts_and_stay_unique() { + assert_eq!(sub_key("Profile: Work"), "profile"); + assert_eq!(sub_key("\u{25CF} First \u{00B7} ok"), "first"); + assert_eq!(sub_key("Queue (1 running, 2 queued)"), "queue"); + assert_eq!(sub_key("\u{2716}"), "sub"); + let nodes = unique_keys(vec![Node::Sub { key: "a".into(), label: "A".into(), items: vec![] }, Node::Sub { key: "a".into(), label: "A".into(), items: vec![] }]); + assert!(matches!(&nodes[1], Node::Sub { key, .. } if key == "a-2")); +} + +#[test] +fn find_walks_keys() { + let t = tree(); + let (items, label) = find(&t, &["fleet".into(), "recently-lowered".into()]).unwrap(); + assert_eq!((items.len(), label), (0, Some("Recently lowered"))); + assert!(find(&t, &["nope".into()]).is_none()); + assert_eq!(find(&t, &[]).unwrap().0.len(), 4); +} + +#[test] +fn the_tray_tree_keeps_its_apps_key_in_every_language_and_value() { + let en = from_entries(&menu_model(&summary("App Store: In review", "Apps"))); + let tr = from_entries(&menu_model(&summary("App Store: Live", "Uygulamalar"))); + let key = |n: &[Node]| n.iter().find_map(|n| if let Node::Sub { key, .. } = n { Some(key.clone()) } else { None }).unwrap(); + assert_eq!(key(&en), "apps"); + assert_eq!(key(&tr), "apps"); + // a live value changed: the tree differs (so it is pushed), the open Apps panel still resolves + assert_ne!(en, tr); + let (items, _) = find(&tr, &["apps".into()]).unwrap(); + assert!(matches!(&items[0], Node::Item { id, label, .. } if id == "tray-app:k1" && label.contains("Live"))); + // the same summary twice: the same tree (nothing is pushed) + assert_eq!(from_entries(&menu_model(&summary("x", "Apps"))), from_entries(&menu_model(&summary("x", "Apps")))); +} + +#[test] +fn the_tree_serializes_as_the_panel_page_reads_it() { + let v = serde_json::to_value(tree()).unwrap(); + assert_eq!(v[0], serde_json::json!({ "kind": "item", "id": "open", "label": "Open", "enabled": true })); + assert_eq!(v[1], serde_json::json!({ "kind": "sep" })); + assert_eq!(v[2]["kind"], "sub"); + assert_eq!(v[2]["key"], "fleet"); +} + +#[test] +fn root_opens_away_from_the_taskbar_and_stays_inside() { + // bottom taskbar, icon at the right: above and left of the click + let (x, y) = place_menu(Anchor::Point { x: 1900.0, y: 1060.0 }, (300.0, 400.0), WORK); + assert!(x + 300.0 <= 1920.0 - GAP && y + 400.0 <= 1040.0 - GAP && x >= 0.0 && y >= 0.0); + // macOS menu bar at the top: below it + let (_, y) = place_menu(Anchor::Point { x: 1800.0, y: 0.0 }, (300.0, 400.0), (0.0, 25.0, 1920.0, 1055.0)); + assert!(y >= 25.0 + GAP); + // a second monitor left of the primary (negative x) + let (x, _) = place_menu(Anchor::Point { x: -10.0, y: 500.0 }, (300.0, 400.0), (-1920.0, 0.0, 1920.0, 1080.0)); + assert!(x + 300.0 <= -GAP + 0.001 && x >= -1920.0); + // a panel taller than the work area: pinned to the top edge, never above it + let (_, y) = place_menu(Anchor::Point { x: 100.0, y: 100.0 }, (300.0, 2000.0), WORK); + assert_eq!(y, GAP); +} + +#[test] +fn submenus_open_beside_and_flip_left_at_the_edge() { + let (x, _) = place_menu(Anchor::Beside { left: 1600.0, right: 1880.0, top: 100.0 }, (280.0, 200.0), WORK); + assert!(x + 280.0 <= 1602.0); + let (x, y) = place_menu(Anchor::Beside { left: 100.0, right: 380.0, top: 100.0 }, (280.0, 200.0), WORK); + assert!((378.0..=380.0).contains(&x) && (90.0..=100.0).contains(&y)); + // a row near the bottom: the submenu moves up into the work area + let (_, y) = place_menu(Anchor::Beside { left: 100.0, right: 380.0, top: 1000.0 }, (280.0, 300.0), WORK); + assert!(y + 300.0 <= 1040.0 - GAP); +} + +#[test] +fn pinned_panels_step_aside_and_stay_inside() { + let others = [(100.0, 100.0, 280.0, 300.0), (100.0, 408.0, 280.0, 300.0)]; + let (x, y) = free_spot((120.0, 120.0, 280.0, 300.0), &others, WORK); + assert!(others.iter().all(|o| !overlaps((x, y, 280.0, 300.0), *o))); + assert!(x >= 0.0 && y >= 0.0 && x + 280.0 <= 1920.0 && y + 300.0 <= 1040.0); + // no room left below: the next column + let col = [(8.0, 8.0, 280.0, 1000.0)]; + let (x, _) = free_spot((8.0, 8.0, 280.0, 300.0), &col, WORK); + assert!(x >= 288.0); + // nothing in the way: where it was + assert_eq!(free_spot((500.0, 500.0, 280.0, 300.0), &others, WORK), (500.0, 500.0)); +} + +#[test] +fn several_menus_open_and_pinned_ones_survive_a_new_menu() { + let mut b = Book::default(); + let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 }); + let (fleet, _) = b.open_sub(&root, "fleet", AT, false).unwrap(); + assert!(b.open_sub(&root, "fleet", AT, false).is_none(), "the same submenu again is a no-op"); + let (deep, _) = b.open_sub(&fleet, "recently-lowered", AT, false).unwrap(); + assert_eq!(b.descendants(&root).len(), 2); + // pinning the Fleet panel detaches it; the root closes, its own child stays its own + let closes = b.pin(&fleet, true); + assert_eq!(closes, vec![root.clone()]); + assert!(b.panels[&fleet].pinned && b.panels[&fleet].parent.is_none()); + assert_eq!(b.descendants(&fleet), vec![deep.clone()]); + let (root2, closes) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 }); + assert_eq!(closes, vec![deep]); + assert!(b.panels.contains_key(&fleet)); + assert_eq!(b.transient(), vec![root2.clone()]); + // a pick / Esc: transient panels go, the pinned one stays + assert_eq!(b.dismiss(), vec![root2]); + assert_eq!(b.panels.len(), 1); + // unpinned, it behaves like a menu again and goes with the next one + b.pin(&fleet, false); + let (_, closes) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 }); + assert!(closes.contains(&fleet)); +} + +#[test] +fn a_torn_off_submenu_keeps_one_pinned_copy() { + let mut b = Book::default(); + let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 }); + let (p1, _) = b.open_sub(&root, "fleet", AT, true).unwrap(); + let (p2, closes) = b.open_sub(&root, "fleet", AT, true).unwrap(); + assert!(closes.contains(&p1) && b.panels[&p2].pinned && !b.panels.contains_key(&p1)); + assert_eq!(b.panels[&p2].path, vec!["fleet".to_string()]); + // the root stays open (a tear-off is not a pick) + assert!(b.panels.contains_key(&root)); + // closing a panel that is already gone does nothing + assert!(b.close("menu-99").is_empty()); +} + +#[test] +fn a_live_update_closes_nothing_that_still_exists() { + let mut b = Book::default(); + let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 }); + let (profile, _) = b.open_sub(&root, "profile", AT, true).unwrap(); + let (fleet, _) = b.open_sub(&root, "fleet", AT, false).unwrap(); + assert!(b.prune(&tree()).is_empty()); + // the Fleet submenu is gone from the tree: its transient panel closes, the pinned Profile panel stays even when its + // submenu goes too (it shows that it is gone) + let smaller = from_entries(&[Entry::Item { id: "open".into(), label: "Open".into(), enabled: true }]); + assert_eq!(b.prune(&smaller), vec![fleet]); + assert!(b.panels.contains_key(&root) && b.panels.contains_key(&profile)); +} + +#[test] +fn pins_are_saved_in_the_state_and_restored_after_a_restart() { + let mut b = Book::default(); + let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 }); + let (p, _) = b.open_sub(&root, "apps", AT, true).unwrap(); + // not placed yet: nothing to save + assert!(b.pins().is_empty()); + b.panels.get_mut(&p).unwrap().pos = Some((40.4, 50.6)); + let pins = b.pins(); + assert_eq!(pins, vec![PinRecord { path: vec!["apps".into()], x: 40, y: 51 }]); + // through state.json + let st = State { menu_pins: pins.clone(), ..State::default() }; + let text = serde_json::to_string(&st).unwrap(); + assert!(text.contains("\"menuPins\"")); + let back: State = serde_json::from_str(&text).unwrap(); + assert_eq!(back.menu_pins, pins); + // an empty list is not written at all (older files stay as they were) + assert!(!serde_json::to_string(&State::default()).unwrap().contains("menuPins")); + let mut fresh = Book::default(); + let mut saved = back.menu_pins.clone(); + saved.push(saved[0].clone()); // a duplicate is dropped + let labels = fresh.restore(&saved); + assert_eq!(labels.len(), 1); + let r = &fresh.panels[&labels[0]]; + assert!(r.pinned && r.path == vec!["apps".to_string()] && r.pos == Some((40.0, 51.0)) && r.parent.is_none()); + // a restored pin survives the next menu + let (_, closes) = fresh.open_root(Anchor::Point { x: 0.0, y: 0.0 }); + assert!(closes.is_empty()); + let many: Vec = (0..40).map(|i| PinRecord { path: vec![format!("s{i}")], x: 0, y: 0 }).collect(); + assert_eq!(Book::default().restore(&many).len(), MAX_PINS); +} + +#[test] +fn panel_labels_are_menu_and_a_number() { + assert!(is_menu_label("menu-1") && is_menu_label("menu-204")); + for bad in ["menu-", "menu-a", "card", "main", "menu-1-2", "xmenu-1"] { + assert!(!is_menu_label(bad), "{bad}"); + } +} diff --git a/apps/desktop/src-tauri/tests/security.rs b/apps/desktop/src-tauri/tests/security.rs index 1d31b45..6c6c4c6 100644 --- a/apps/desktop/src-tauri/tests/security.rs +++ b/apps/desktop/src-tauri/tests/security.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use serde_json::{json, Value}; use storebound_desktop::policy::*; -use storebound_desktop::{CARD_COMMANDS, COMMANDS}; +use storebound_desktop::{CARD_COMMANDS, COMMANDS, MENU_COMMANDS}; use url::Url; fn root() -> PathBuf { @@ -26,7 +26,7 @@ fn one_capability_for_the_main_window_with_exactly_the_app_commands() { let dir = root().join("capabilities"); let mut files: Vec = fs::read_dir(&dir).unwrap().map(|e| e.unwrap().path()).collect(); files.sort(); - assert_eq!(files.len(), 2, "main and card capability files expected, found {files:?}"); + assert_eq!(files.len(), 3, "main, card and menu capability files expected, found {files:?}"); let cap = read_json(&dir.join("main.json")); assert_eq!(cap["identifier"], "main-window"); assert_eq!(cap["windows"], json!(["main"])); @@ -71,6 +71,45 @@ fn the_card_window_gets_only_the_card_commands() { assert!(require_main("card", &u("tauri://localhost/card.html"), false).is_err()); } +#[test] +fn the_menu_panels_get_only_the_menu_commands() { + let cap = read_json(&root().join("capabilities/menu.json")); + assert_eq!(cap["identifier"], "menu-window"); + assert_eq!(cap["windows"], json!(["menu-*"])); + assert!(cap.get("webviews").is_none() && cap.get("remote").is_none()); + assert_ne!(cap["local"], json!(false)); + let got: BTreeSet = cap["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_string()).collect(); + let mut want: BTreeSet = MENU_COMMANDS.iter().map(|c| format!("allow-{}", c.replace('_', "-"))).collect(); + want.insert("core:event:allow-listen".into()); + want.insert("core:event:allow-unlisten".into()); + // a pinned panel is dragged by its header (data-tauri-drag-region): only its own window + want.insert("core:window:allow-start-dragging".into()); + assert_eq!(got, want); + // the menu commands never leak into the main or card capability + for other in ["main.json", "card.json"] { + let cap = read_json(&root().join("capabilities").join(other)); + let perms = cap["permissions"].to_string(); + for c in MENU_COMMANDS { + assert!(!perms.contains(&format!("\"allow-{}\"", c.replace('_', "-"))), "{c} granted in {other}"); + } + } + let cmds = fs::read_to_string(root().join("src/commands.rs")).unwrap(); + for c in MENU_COMMANDS { + let at = cmds.find(&format!("pub fn {c}(")).unwrap_or_else(|| panic!("{c} not in commands.rs")); + let body = &cmds[at..]; + let open = body.find('{').unwrap(); + assert!(body[open + 1..].trim_start().starts_with("guard_menu(&window)?;"), "{c} must start with guard_menu(&window)?"); + } + assert!(require_menu("menu-1", &u("tauri://localhost/tray-menu.html"), false).is_ok()); + assert!(require_menu("menu-12", &u("http://tauri.localhost/tray-menu.html"), false).is_ok()); + for bad in ["main", "card", "menu-", "menu-x", "menu-1a", "menus-1"] { + assert!(require_menu(bad, &u("tauri://localhost/tray-menu.html"), false).is_err(), "{bad}"); + } + assert!(require_menu("menu-1", &u("https://example.com/tray-menu.html"), false).is_err()); + assert!(require_main("menu-1", &u("tauri://localhost/tray-menu.html"), false).is_err()); + assert!(require_card("menu-1", &u("tauri://localhost/tray-menu.html"), false).is_err()); +} + #[test] fn every_registered_command_is_in_the_list_and_nothing_else() { let lib = fs::read_to_string(root().join("src/lib.rs")).unwrap(); @@ -81,7 +120,7 @@ fn every_registered_command_is_in_the_list_and_nothing_else() { .map(|s| s.trim().trim_start_matches("commands::").to_string()) .filter(|s| !s.is_empty()) .collect(); - assert_eq!(registered, COMMANDS.iter().chain(CARD_COMMANDS).map(|s| s.to_string()).collect::>()); + assert_eq!(registered, COMMANDS.iter().chain(CARD_COMMANDS).chain(MENU_COMMANDS).map(|s| s.to_string()).collect::>()); // every command checks the caller first let cmds = fs::read_to_string(root().join("src/commands.rs")).unwrap(); for c in COMMANDS { @@ -109,7 +148,7 @@ fn csp_is_strict_and_the_config_has_no_backdoors() { assert!(!v.contains("unsafe-eval") && !v.contains("unsafe-inline") && !v.contains('*'), "{k}: {v}"); assert!(!v.contains("https:") && !v.contains("http://") || v == "ipc: http://ipc.localhost", "{k}: no remote sources: {v}"); } - assert_eq!(conf["app"]["security"]["capabilities"], json!(["main-window", "card-window"])); + assert_eq!(conf["app"]["security"]["capabilities"], json!(["main-window", "card-window", "menu-window"])); assert_eq!(conf["app"]["security"]["freezePrototype"], json!(true)); assert!(conf["app"].get("withGlobalTauri").is_none() || conf["app"]["withGlobalTauri"] == json!(false)); let windows = conf["app"]["windows"].as_array().unwrap(); diff --git a/apps/desktop/src-tauri/tests/tray.rs b/apps/desktop/src-tauri/tests/tray.rs index 95d5b67..47c03e8 100644 --- a/apps/desktop/src-tauri/tests/tray.rs +++ b/apps/desktop/src-tauri/tests/tray.rs @@ -23,7 +23,7 @@ fn ids(m: &[Entry]) -> Vec { fn menu_has_every_entry_in_order() { let m = menu_model(&summary()); assert_eq!(ids(&m), vec![ID_OPEN, ID_READ, ID_MUTE, ID_UPDATE, ID_SETTINGS, ID_QUIT]); - let Entry::Sub { label, items } = &m[3] else { panic!("the Apps submenu comes after the first separator") }; + let Entry::Sub { label, items, .. } = &m[3] else { panic!("the Apps submenu comes after the first separator") }; assert_eq!(label, "Apps"); assert_eq!(items.len(), 2); let Entry::Item { id, label, enabled } = &items[0] else { panic!() }; diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 4cfa717..02a809d 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -211,7 +211,7 @@ export function App({ onTheme }: { onTheme: () => void }) { const menu = useContextMenu(); const appItem = (a: UnifiedApp, st: { text: string; tone: string }) => (
- +
); } @@ -130,7 +130,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) { return (
appMenu(app, d, nav, { showIgnored }))} + {...cm(() => appMenu(app, d, nav, { showIgnored }), `app:${app.key}`)} badges={<> {e?.summary?.profile && app.project && {t(`profile.${e.summary.profile}` as 'profile.app')}} {!app.project && } diff --git a/apps/desktop/src/screens/Ignored.tsx b/apps/desktop/src/screens/Ignored.tsx index 4c7ac27..fdc3167 100644 --- a/apps/desktop/src/screens/Ignored.tsx +++ b/apps/desktop/src/screens/Ignored.tsx @@ -30,7 +30,7 @@ export function Ignored() { {apps.map(a => { const s = d.studios.find(x => x.id === a.studio) ?? null; return ( -
appMenu(a, d, nav, { showIgnored: true }))}> +
appMenu(a, d, nav, { showIgnored: true }), `app:${a.key}`)}>
{a.name}{!a.project && } diff --git a/apps/desktop/src/screens/Portfolio.tsx b/apps/desktop/src/screens/Portfolio.tsx index c49926d..27eb5b0 100644 --- a/apps/desktop/src/screens/Portfolio.tsx +++ b/apps/desktop/src/screens/Portfolio.tsx @@ -153,7 +153,7 @@ function AppCard({ r, icon, health, studio }: { r: CardRow; icon: string | null; const menu = useContextMenu(); const [showIgnored] = useShowIgnored(); return ( -
appMenu(app, d, nav, { showIgnored }))}> +
appMenu(app, d, nav, { showIgnored }), `app:${app.key}`)}>