desktop: Game services card in App info (Game Center and Play Games Services status, ids, counts vs config, pending changes, sync buttons through the confirm-token write, PGS setup plan, console links); en + tr strings; dev fixture

This commit is contained in:
CRTkafa 2026-10-21 05:11:46 +07:00
parent 26f0723641
commit 22286c7e02
6 changed files with 135 additions and 2 deletions

View file

@ -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<string, unknown> = {}): 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<ConsoleForm, ConsoleFillResult> = {
};
const CACHED_AGO: Record<string, number> = { 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 }); }

View file

@ -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;

View file

@ -1843,4 +1843,27 @@ export const tr: Record<keyof typeof en, string> = {
'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ı',
};

View file

@ -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<DeclAsk['area'], Key> = { 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 }) {
</Card>
</div>
)}
<GameServicesCard project={project} />
<ConsoleSteps project={project} stores={d.stores} />
<p className="small muted">{t('ai.foot', { when: ago(r.at) })}</p>

View file

@ -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 <form> --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). */

View file

@ -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 ? <Chip tone="neutral">{t('gs.unknown')}</Chip> : <Chip tone={v ? 'ok' : 'bad'}>{v ? t('gs.yes') : t('gs.no')}</Chip>);
export function GameServicesCard({ project }: { project: Project }) {
const r = useRead<GameServicesStatus>(project.id, 'games.status', { network: true, title: t('gs.title') });
const w = useWrite(project.id);
if (!r.data) return r.error ? <ErrorNotice error={r.error} tone="warn" /> : <Card title={t('gs.title')} icon="sparkle"><Skeleton lines={3} block /></Card>;
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 (
<Card title={t('gs.title')} icon="sparkle" sub={t('gs.sub')}>
<div className="grid g2" style={{ alignItems: 'start' }}>
{s.apple && (
<div className="stack" style={{ gap: 8 }}>
<div className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" /><strong>{t('gs.gameCenter')}</strong></div>
{s.apple.error ? <span className="small muted">{s.apple.error}</span> : (
<dl className="kv">
<dt>{t('gs.enabled')}</dt><dd>{yesNo(s.apple.enabled)}</dd>
<dt>{t('gs.capability')}</dt><dd>{yesNo(s.apple.capability)}</dd>
<dt>{t('gs.version', { v: s.apple.version ?? '-' })}</dt><dd>{yesNo(s.apple.versionEnabled)}</dd>
<dt>{t('gs.items')}</dt><dd className="small">{count(s.apple, s.apple.config)}</dd>
</dl>
)}
{f('apple').map(x => <Chip key={x.id} tone={tone(x.level)} title={x.fix}>{x.msg}</Chip>)}
{!!s.apple.changes.length && <span className="small muted">{t('gs.pending', { n: s.apple.changes.length })}: {s.apple.changes.slice(0, 4).join('; ')}</span>}
<div className="row" style={{ gap: 6 }}>
<WriteButton run={w} primary icon="upload" w={{ method: 'apple.game_center', args: {}, title: t('gs.syncApple'), done: t('gs.synced'), after: r.refresh }} hint={t('gs.syncHint')}>{t('gs.syncApple')}</WriteButton>
</div>
</div>
)}
{s.play && (
<div className="stack" style={{ gap: 8 }}>
<div className="row" style={{ gap: 6 }}><StoreGlyph store="android" className="ic13" /><strong>{t('gs.playGames')}</strong></div>
<dl className="kv">
<dt>{t('gs.projectId')}</dt><dd>{s.play.appId ? <span className="mono-id">{s.play.appId}</span> : <Chip tone="warn">{t('gs.notSet')}</Chip>}</dd>
<dt>{t('gs.resource')}</dt><dd>{s.play.resource ? <span className="mono-id small">{s.play.resource}</span> : <Chip tone="warn">{t('gs.notSet')}</Chip>}</dd>
{!s.play.error && <><dt>{t('gs.items')}</dt><dd className="small">{count(s.play, s.play.config)}</dd></>}
</dl>
{s.play.error && <span className="small muted">{s.play.error}</span>}
{f('play').map(x => <Chip key={x.id} tone={tone(x.level)} title={x.fix}>{x.msg}</Chip>)}
{!!s.play.changes.length && <span className="small muted">{t('gs.pending', { n: s.play.changes.length })}: {s.play.changes.slice(0, 4).join('; ')}</span>}
<div className="row" style={{ gap: 6 }}>
<WriteButton run={w} primary icon="upload" disabled={!s.play.appId} w={{ method: 'play.games', args: {}, title: t('gs.syncPlay'), done: t('gs.synced'), after: r.refresh }} hint={t('gs.syncHint')}>{t('gs.syncPlay')}</WriteButton>
<ConsolePlanButton project={project} form="play-games-services" label={t('gs.setup')} />
</div>
</div>
)}
</div>
{!!s.links.length && <div className="row small" style={{ gap: 12, marginTop: 10, flexWrap: 'wrap' }}>{s.links.map(l => <span key={l.url} className="row" style={{ gap: 4 }}>{l.label}<OpenLink url={l.url} /></span>)}</div>}
</Card>
);
}