diff --git a/apps/desktop/src/dev/fixtures.ts b/apps/desktop/src/dev/fixtures.ts index 8f1383f..9acb44a 100644 --- a/apps/desktop/src/dev/fixtures.ts +++ b/apps/desktop/src/dev/fixtures.ts @@ -423,6 +423,13 @@ const TOOLS = [ ]; let chromeInstalled = false; +// games status (cli/games-status.ts) of the game demo: Game Center on, one leaderboard to create; PGS project set +const GAMES_PRISM = { + apple: { capability: true, enabled: true, version: '1.2.0', versionEnabled: true, leaderboards: 1, achievements: 5, config: { leaderboards: 2, achievements: 5 }, changes: ['leaderboard com.example.prismpuddle.daily: create'] }, + 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' }], +}; const ok = (method: string, data: unknown): ToolResult => ({ method, ok: true, exitCode: 0, dryRun: false, command: `storebound ${method.replace('.', ' ')} --json`, output: '', data }); const STORE_REVIEWS: Review[] = [ @@ -491,6 +498,7 @@ function read(method: string, id: string, args: Record = {}): T // send for review: Prism Puddle is ready to submit, Tidy Lists is back from App Review (UNRESOLVED_ISSUES) case 'apple.status': return ok(method, id === 'p-tidy' ? APPLE_STATUS_UNRESOLVED : APPLE_STATUS_READY); case 'play.status': return ok(method, PLAY_STATUS); + case 'games.status': return ok(method, id === 'p-prism' ? GAMES_PRISM : { apple: null, play: null, findings: [], links: [] }); case 'console.plan': return ok(method, consolePlan(String(args.form))); case 'console.verify': return ok(method, consoleVerify(String(args.form))); default: @@ -520,7 +528,7 @@ const CONSOLE_FILL: Record = { }; const CACHED_AGO: Record = { health: 60 * 3, 'reviews.list': 45, 'apple.testflight_status': 60 * 5, 'catalog.diff': 60 * 26, validate: 60 * 2, 'prices.current': 60 * 4, 'apple.ppo_status': 60 * 6 }; -const NETWORK = new Set(['health', 'health.issue', 'reviews.list', 'apple.testflight_status', 'catalog.diff', 'validate', 'report', 'prices.current', 'prices.diff', 'apple.ppo_status', 'apple.status', 'play.status', 'console.verify']); +const NETWORK = new Set(['games.status', 'health', 'health.issue', 'reviews.list', 'apple.testflight_status', 'catalog.diff', 'validate', 'report', 'prices.current', 'prices.diff', 'apple.ppo_status', 'apple.status', 'play.status', 'console.verify']); async function progressLines(job: string | null, lines: string[]) { for (const line of lines) { await sleep(260); if (job) emit('sidecar-progress', { params: { requestId: 1, line, stream: 'stderr' }, job }); } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index c604f3e..3585c39 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1841,4 +1841,27 @@ export const en = { 'tools.o.ads_change': 'Apple Ads bid, budget or pause', 'tools.o.rc_sync': 'Sync the catalog to RevenueCat', 'tools.o.shots_login': 'Sign in to the demo account for screenshots (by hand, Enter saves)', + 'cp.playGames': 'Play Games Services setup', + 'cp.playGamesText': 'Games project, Android credential with the app signing SHA-1, web OAuth client; you press Publish.', + 'gs.title': 'Game services', + 'gs.sub': 'Game Center and Play Games Services: switches, ids, leaderboards and achievements from the config.', + 'gs.gameCenter': 'Game Center', + 'gs.playGames': 'Play Games Services', + 'gs.enabled': 'On for the app', + 'gs.capability': 'App ID capability', + 'gs.version': 'Version {v}', + 'gs.items': 'Leaderboards / achievements', + 'gs.counts': '{lb} of {lbWant} leaderboards · {ach} of {achWant} achievements', + 'gs.pending': '{n} change(s) to write', + 'gs.projectId': 'Project id', + 'gs.resource': 'games_APP_ID resource', + 'gs.notSet': 'Not set', + 'gs.yes': 'Yes', + 'gs.no': 'No', + 'gs.unknown': 'Unknown', + 'gs.syncApple': 'Sync Game Center', + 'gs.syncPlay': 'Sync Play Games', + 'gs.syncHint': 'Dry run first, then you confirm; nothing is submitted or published.', + 'gs.synced': 'Game services in step', + 'gs.setup': 'Setup plan', } as const; diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index 6356927..e4847bc 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -1843,4 +1843,27 @@ export const tr: Record = { 'tools.o.ads_change': 'Apple Ads teklif, bütçe veya duraklatma', 'tools.o.rc_sync': 'Kataloğu RevenueCat ile eşitle', 'tools.o.shots_login': 'Ekran görüntüleri için demo hesaba giriş (elle, Enter kaydeder)', + 'cp.playGames': 'Play Games Hizmetleri kurulumu', + 'cp.playGamesText': 'Oyun projesi, uygulama imzalama SHA-1 ile Android kimliği, web OAuth istemcisi; Yayınla düğmesine sen basarsın.', + 'gs.title': 'Oyun hizmetleri', + 'gs.sub': 'Game Center ve Play Games Hizmetleri: anahtarlar, kimlikler, yapılandırmadaki skor tabloları ve başarımlar.', + 'gs.gameCenter': 'Game Center', + 'gs.playGames': 'Play Games Hizmetleri', + 'gs.enabled': 'Uygulamada açık', + 'gs.capability': 'App ID yeteneği', + 'gs.version': 'Sürüm {v}', + 'gs.items': 'Skor tabloları / başarımlar', + 'gs.counts': '{lbWant} skor tablosundan {lb} · {achWant} başarımdan {ach}', + 'gs.pending': 'Yazılacak {n} değişiklik', + 'gs.projectId': 'Proje kimliği', + 'gs.resource': 'games_APP_ID kaynağı', + 'gs.notSet': 'Ayarlı değil', + 'gs.yes': 'Evet', + 'gs.no': 'Hayır', + 'gs.unknown': 'Bilinmiyor', + 'gs.syncApple': 'Game Center eşitle', + 'gs.syncPlay': 'Play Games eşitle', + 'gs.syncHint': 'Önce deneme çalıştırması, sonra sen onaylarsın; hiçbir şey gönderilmez ya da yayınlanmaz.', + 'gs.synced': 'Oyun hizmetleri eşit', + 'gs.setup': 'Kurulum planı', }; diff --git a/apps/desktop/src/screens/tabs/AppInfo.tsx b/apps/desktop/src/screens/tabs/AppInfo.tsx index dc3f73e..7b7e5bf 100644 --- a/apps/desktop/src/screens/tabs/AppInfo.tsx +++ b/apps/desktop/src/screens/tabs/AppInfo.tsx @@ -17,6 +17,7 @@ import { CONSOLE_FORM_NAME, ConsoleFillButton } from '../../ui/ConsoleFill'; import { ConsolePlanButton, PLAN_FORMS } from '../../ui/ConsolePlan'; import { Bar, Card, Chip, Empty, ErrorNotice, Notice, Skeleton } from '../../ui/kit'; 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' }; @@ -237,6 +238,7 @@ export function AppInfoTab({ project }: { project: Project }) { )} +

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

diff --git a/apps/desktop/src/ui/ConsolePlan.tsx b/apps/desktop/src/ui/ConsolePlan.tsx index d4fb356..fba0c48 100644 --- a/apps/desktop/src/ui/ConsolePlan.tsx +++ b/apps/desktop/src/ui/ConsolePlan.tsx @@ -16,7 +16,7 @@ import { Chip, Notice } from './kit'; /** The forms of `console plan` (cli/mcp-tools.ts CONSOLE_PLAN_FORMS). */ export type PlanForm = 'apple-app-record' | 'apple-age-rating' | 'apple-app-privacy' | 'play-app-record' | 'play-content-rating' | 'play-target-audience' | 'play-store-settings' | 'play-app-content' | 'play-privacy-policy' | 'play-ads' | 'play-sign-in-details' | 'play-advertising-id' | 'play-government-apps' - | 'play-financial-features' | 'play-health-apps' | 'play-production-countries' | 'play-production-release'; + | 'play-financial-features' | 'play-health-apps' | 'play-production-countries' | 'play-production-release' | 'play-games-services'; /** `console plan
--json` (cli/lib/console-plan.ts ConsolePlan), the parts shown here. */ export interface Plan { form: PlanForm; store: 'apple' | 'play'; title: string; @@ -47,6 +47,7 @@ export const PLAN_FORMS: { form: PlanForm; store: 'apple' | 'play'; name: Key; t { form: 'play-target-audience', store: 'play', name: 'cf.playTargetAudience', text: 'cf.playTargetAudienceText' }, { form: 'play-production-countries', store: 'play', name: 'cp.playCountries', text: 'cp.playCountriesText' }, { form: 'play-production-release', store: 'play', name: 'cp.playProductionRelease', text: 'cp.playProductionReleaseText' }, + { form: 'play-games-services', store: 'play', name: 'cp.playGames', text: 'cp.playGamesText' }, ]; const nameOf = (form: PlanForm): string => t(PLAN_FORMS.find(f => f.form === form)?.name ?? 'cp.title'); /** Forms whose result `console verify` reads from the App Store Connect API (no page text needed). */ diff --git a/apps/desktop/src/ui/GameServicesCard.tsx b/apps/desktop/src/ui/GameServicesCard.tsx new file mode 100644 index 0000000..3aa77cd --- /dev/null +++ b/apps/desktop/src/ui/GameServicesCard.tsx @@ -0,0 +1,76 @@ +// Game services: Game Center (App Store) and Play Games Services (Google Play) side by side, from `games status --json` +// (cli/games-status.ts): enabled, App ID capability, entitlement / APP_ID resource, ids, leaderboard and achievement +// counts on the store vs the config, pending changes and findings. The sync buttons are the confirm-token writes +// (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 { ConsolePlanButton } from './ConsolePlan'; +import { StoreGlyph } from './icons'; +import { Card, Chip, ErrorNotice, Skeleton } from './kit'; +import { OpenLink } from './OpenLink'; +import { WriteButton } from './WriteButton'; + +interface Finding { store: 'apple' | 'play'; level: 'error' | 'warn' | 'ok'; id: string; msg: string; fix?: string } +interface Counts { leaderboards: number; achievements: number } +export interface GameServicesStatus { + apple: null | { capability: boolean | null; enabled: boolean; version: string | null; versionEnabled: boolean | null; config: Counts; changes: string[]; error?: string } & Counts; + play: null | { appId: string | null; resource: string | null; config: Counts; changes: string[]; error?: string } & Counts; + findings: Finding[]; + links: { label: string; url: string }[]; +} + +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') }); + const w = useWrite(project.id); + if (!r.data) return r.error ? : ; + const s = r.data; + if (!s.apple && !s.play && !s.findings.length) return null; + const f = (store: 'apple' | 'play') => s.findings.filter(x => x.store === store); + const count = (have: Counts, want: Counts) => t('gs.counts', { lb: have.leaderboards, lbWant: want.leaderboards, ach: have.achievements, achWant: want.achievements }); + return ( + +
+ {s.apple && ( +
+
{t('gs.gameCenter')}
+ {s.apple.error ? {s.apple.error} : ( +
+
{t('gs.enabled')}
{yesNo(s.apple.enabled)}
+
{t('gs.capability')}
{yesNo(s.apple.capability)}
+
{t('gs.version', { v: s.apple.version ?? '-' })}
{yesNo(s.apple.versionEnabled)}
+
{t('gs.items')}
{count(s.apple, s.apple.config)}
+
+ )} + {f('apple').map(x => {x.msg})} + {!!s.apple.changes.length && {t('gs.pending', { n: s.apple.changes.length })}: {s.apple.changes.slice(0, 4).join('; ')}} +
+ {t('gs.syncApple')} +
+
+ )} + {s.play && ( +
+
{t('gs.playGames')}
+
+
{t('gs.projectId')}
{s.play.appId ? {s.play.appId} : {t('gs.notSet')}}
+
{t('gs.resource')}
{s.play.resource ? {s.play.resource} : {t('gs.notSet')}}
+ {!s.play.error && <>
{t('gs.items')}
{count(s.play, s.play.config)}
} +
+ {s.play.error && {s.play.error}} + {f('play').map(x => {x.msg})} + {!!s.play.changes.length && {t('gs.pending', { n: s.play.changes.length })}: {s.play.changes.slice(0, 4).join('; ')}} +
+ {t('gs.syncPlay')} + +
+
+ )} +
+ {!!s.links.length &&
{s.links.map(l => {l.label})}
} +
+ ); +}