From a95aa6dd49b75754f8f582999fafcb227a6be4f1 Mon Sep 17 00:00:00 2001 From: CRT Date: Wed, 21 Oct 2026 05:02:20 +0700 Subject: [PATCH] desktop: More tools tab, one typed form per CLI command without a screen (schema from system.methods; reads run, writes dry run + native dialog + token) and the owner steps with their terminal command; en + tr --- apps/desktop/src/dev/fixtures.ts | 9 ++ apps/desktop/src/i18n/en.ts | 36 ++++++ apps/desktop/src/i18n/tr.ts | 36 ++++++ apps/desktop/src/nav.ts | 6 +- apps/desktop/src/screens/AppView.tsx | 2 + apps/desktop/src/screens/tabs/Tools.tsx | 140 ++++++++++++++++++++++++ apps/desktop/src/styles.css | 5 + 7 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/screens/tabs/Tools.tsx diff --git a/apps/desktop/src/dev/fixtures.ts b/apps/desktop/src/dev/fixtures.ts index 1189868..8f1383f 100644 --- a/apps/desktop/src/dev/fixtures.ts +++ b/apps/desktop/src/dev/fixtures.ts @@ -11,6 +11,14 @@ import type { CatalogDiff, HealthSnapshot, MetricRow, Review, TestFlightStatus, import { editableFixture, liveFixture } from './editor-fixtures'; import { APPLE_STATUS_READY, APPLE_STATUS_UNRESOLVED, consolePlan, consoleVerify, ownerDry, PLAY_STATUS } from './submit-fixtures'; import { ANALYTICS, ASO, DECLARATIONS, LISTING_TEXT, PPO, PPO_STORE, PRICE_DIFF, PRICES, renderArt, RENDERS } from './fixtures-more'; +import { TOOLBOX } from '../../../../cli/lib/desktop-coverage'; + +/** system.methods for the More tools tab: the toolbox methods with a small schema each (reads end in a read verb). */ +const toolMethods = () => TOOLBOX.flatMap(g => g.methods).map(m => { + const read = /status|list|devices|check|diff|next_|app_id|info|wait|plan|verify|rank|competitors|suggest|scan|manifest|triage|originality|aso/.test(m); + return { method: m, title: m.replace('.', ' ').replace(/_/g, ' '), description: `Fixture descriptor of ${m}.`, kind: read ? 'read' : 'write', network: m.startsWith('apple.') || m.startsWith('play.'), destructive: false, json: false, + args: { type: 'object', additionalProperties: false, properties: m === 'play.upload' ? { track: { type: 'string', enum: ['internal', 'alpha', 'beta'], default: 'internal' }, name: { type: 'string', description: 'Release name' } } : m === 'agent.install' ? { agent: { type: 'string', enum: ['claude', 'codex'] }, no_mcp: { type: 'boolean' } } : {}, ...(m === 'agent.install' ? { required: ['agent'] } : {}) } }; +}); export const FIXTURE_MARKER = 'storebound-dev-fixtures'; @@ -581,6 +589,7 @@ async function answer(cmd: string, a: Record): Promise case 'cache_clear': return null; case 'sidecar_call': { const method = a.method as string; + if (method === 'system.methods') return { methods: toolMethods() }; if (method === 'system.tools') return { tools: (await answer('sidecar_status', {}) as { tools: { tools: unknown[] } }).tools.tools, androidSdk: { path: `${HOME}/Library/Android/sdk`, source: 'default', checked: ['ANDROID_HOME: not set', 'ANDROID_SDK_ROOT: not set', `Android Studio default folder: ${HOME}/Library/Android/sdk`] } }; const id = a.projectId as string; if (NETWORK.has(method)) await progressLines(job, [`[${method}] reading App Store Connect`, `[${method}] reading Google Play`, `[${method}] done`]); diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 58c20e5..c604f3e 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1805,4 +1805,40 @@ export const en = { 'ed.targetAudience': 'Target audience and ads (Play)', 'ed.appContent': 'App content declarations (Play)', 'ed.appContentHint': 'Government, financial and health features, advertising ID. No API: the console plan step uses them.', + 'tab.tools': 'More tools', + 'tools.lead': 'Every CLI command without a tab of its own, as a form with the command\'s own options. Reads run here; writes show the dry run and ask in a native dialog first. Owner steps stay yours: copy the terminal command.', + 'tools.g.build': 'Build and upload', + 'tools.g.adhoc': 'Ad-hoc devices', + 'tools.g.store': 'Store features', + 'tools.g.text': 'Text and ASO', + 'tools.g.capture': 'Captures', + 'tools.g.quality': 'Quality', + 'tools.g.ads': 'Apple Ads', + 'tools.g.flows': 'Workflows and AI tools', + 'tools.count': '{count} commands', + 'tools.count_one': '1 command', + 'tools.show': 'Show', + 'tools.hide': 'Hide', + 'tools.read': 'read-only', + 'tools.write': 'write (dry run first)', + 'tools.network': 'store', + 'tools.run': 'Run', + 'tools.dryRun': 'Dry run, then apply', + 'tools.done': '{title}: done', + 'tools.default': 'default: {value}', + 'tools.exit': 'exit {code}', + 'tools.copy': 'Copy', + 'tools.copied': 'Copied', + 'tools.owner': 'Owner steps (terminal)', + 'tools.ownerSub': 'Public or price changes only you decide; run them in a terminal in the app folder', + 'tools.o.offer_codes': 'App Store offer codes for one-time purchases', + 'tools.o.win_back': 'Win-back offers for lapsed subscribers', + 'tools.o.promoted': 'Turn on promoted in-app purchases', + 'tools.o.preorder': 'Enable or end an App Store pre-order', + 'tools.o.cpp_submit': 'Submit a custom product page (then make it visible)', + 'tools.o.events_submit': 'Submit an in-app event', + 'tools.o.tf_expire': 'Expire old TestFlight builds', + '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)', } as const; diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index a660602..6356927 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -1807,4 +1807,40 @@ export const tr: Record = { 'ed.targetAudience': 'Hedef kitle ve reklamlar (Play)', 'ed.appContent': 'Uygulama içeriği beyanları (Play)', 'ed.appContentHint': 'Devlet, finans ve sağlık özellikleri, reklam kimliği. API yok: konsol planı adımı bunları kullanır.', + 'tab.tools': 'Diğer araçlar', + 'tools.lead': 'Kendi sekmesi olmayan her CLI komutu, komutun kendi seçenekleriyle bir form olarak. Okumalar burada çalışır; yazmalar önce prova sonucunu gösterir ve yerel bir pencerede onay ister. Sahip adımları sizindir: terminal komutunu kopyalayın.', + 'tools.g.build': 'Derleme ve yükleme', + 'tools.g.adhoc': 'Ad-hoc cihazlar', + 'tools.g.store': 'Mağaza özellikleri', + 'tools.g.text': 'Metin ve ASO', + 'tools.g.capture': 'Çekimler', + 'tools.g.quality': 'Kalite', + 'tools.g.ads': 'Apple Ads', + 'tools.g.flows': 'İş akışları ve yapay zekâ araçları', + 'tools.count': '{count} komut', + 'tools.count_one': '1 komut', + 'tools.show': 'Göster', + 'tools.hide': 'Gizle', + 'tools.read': 'salt okunur', + 'tools.write': 'yazma (önce prova)', + 'tools.network': 'mağaza', + 'tools.run': 'Çalıştır', + 'tools.dryRun': 'Prova, sonra uygula', + 'tools.done': '{title}: tamam', + 'tools.default': 'varsayılan: {value}', + 'tools.exit': 'çıkış {code}', + 'tools.copy': 'Kopyala', + 'tools.copied': 'Kopyalandı', + 'tools.owner': 'Sahip adımları (terminal)', + 'tools.ownerSub': 'Yalnızca sizin karar verdiğiniz herkese açık veya fiyat değişiklikleri; uygulama klasöründe bir terminalde çalıştırın', + 'tools.o.offer_codes': 'Tek seferlik satın almalar için App Store teklif kodları', + 'tools.o.win_back': 'Ayrılan aboneler için geri kazanma teklifleri', + 'tools.o.promoted': 'Öne çıkan uygulama içi satın almaları aç', + 'tools.o.preorder': 'App Store ön siparişini aç veya bitir', + 'tools.o.cpp_submit': 'Özel ürün sayfasını gönder (sonra görünür yap)', + 'tools.o.events_submit': 'Uygulama içi etkinliği gönder', + 'tools.o.tf_expire': 'Eski TestFlight derlemelerinin süresini bitir', + '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)', }; diff --git a/apps/desktop/src/nav.ts b/apps/desktop/src/nav.ts index a8885af..b438256 100644 --- a/apps/desktop/src/nav.ts +++ b/apps/desktop/src/nav.ts @@ -4,7 +4,7 @@ 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'] as const; +export const APP_TABS = ['releases', 'catalog', 'prices', 'listings', 'art', 'health', 'reviews', 'testflight', 'growth', 'appinfo', '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). */ @@ -12,11 +12,11 @@ export const STORE_TABS: readonly AppTab[] = ['releases', 'listings', 'art', 'he export const tabsFor = (hasFolder: boolean): readonly AppTab[] => (hasFolder ? APP_TABS : STORE_TABS); 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', + testflight: 'tab.testflight', growth: 'tab.growth', appinfo: 'tab.appinfo', checks: 'tab.checks', tools: 'tab.tools', }; 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', + appinfo: 'clipboard', checks: 'shield', tools: 'sliders', }; export const WORKSPACE = ['apps', 'accounts', 'ignored', 'dependencies', 'settings'] as const; export type WorkspaceScreen = (typeof WORKSPACE)[number]; diff --git a/apps/desktop/src/screens/AppView.tsx b/apps/desktop/src/screens/AppView.tsx index 49ba9b0..319e105 100644 --- a/apps/desktop/src/screens/AppView.tsx +++ b/apps/desktop/src/screens/AppView.tsx @@ -24,6 +24,7 @@ import { PricesTab } from './tabs/Prices'; import { ReleasesTab } from './tabs/Releases'; import { ReviewsTab } from './tabs/Reviews'; import { TestFlightTab } from './tabs/TestFlight'; +import { ToolsTab } from './tabs/Tools'; import { EDIT_TABS, TabEditor, useUnsavedGuard } from './editors/TabEditor'; import { StoreListingEditor } from './editors/StoreListingEditor'; import { useEdits } from '../ui/Editor'; @@ -171,6 +172,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) { {tab === 'growth' && } {tab === 'appinfo' && app.project && } {tab === 'checks' && app.project && } + {tab === 'tools' && app.project && } {tab !== 'listings' && app.project && !app.project.missing && EDIT_TABS[tab] && } diff --git a/apps/desktop/src/screens/tabs/Tools.tsx b/apps/desktop/src/screens/tabs/Tools.tsx new file mode 100644 index 0000000..edb48a0 --- /dev/null +++ b/apps/desktop/src/screens/tabs/Tools.tsx @@ -0,0 +1,140 @@ +// More tools: the CLI commands without a screen of their own (cli/lib/desktop-coverage.ts TOOLBOX), one typed form +// per serve method built from its argument schema (system.methods). Never a free-text shell: only the method's own +// options, validated by the sidecar. Reads run here; writes go through the dry run, the native dialog and the confirm +// token (useWrite). Owner-only steps without a button are listed with their terminal command to copy. +import { useEffect, useState } from 'react'; +import type { JsonSchemaLite } from '../../../../../cli/lib/desktop-protocol'; +import { OWNER_STEPS, TOOLBOX } from '../../../../../cli/lib/desktop-coverage'; +import { api, errorText, type MethodDescriptor, newJob, type Project, type ToolResult } from '../../api'; +import { type Key, t } from '../../i18n'; +import { useData, useWrite } from '../../store'; +import { Icon } from '../../ui/icons'; +import { Card, Chip, ErrorNotice, Notice, Skeleton } from '../../ui/kit'; +import { WriteButton } from '../../ui/WriteButton'; + +type Args = Record; + +/** Only the values the owner set (empty fields are left out, so the command's defaults apply). */ +export function cleanArgs(schema: MethodDescriptor['args'], v: Args): Args { + const out: Args = {}; + for (const [k, s] of Object.entries(schema.properties)) { + const x = v[k]; + if (x === undefined || x === '' || x === null) continue; + if (s.type === 'boolean') { if (x === true) out[k] = true; continue; } + out[k] = s.type === 'number' || s.type === 'integer' ? Number(x) : x; + } + return out; +} + +function Field({ name, s, value, required, onChange }: { name: string; s: JsonSchemaLite; value: unknown; required: boolean; onChange: (v: unknown) => void }) { + const label = {name.replace(/_/g, ' ')}{required ? ' *' : ''}; + if (s.type === 'boolean') { + return ; + } + if (s.enum) { + return ( + + ); + } + const num = s.type === 'number' || s.type === 'integer'; + return ( + + ); +} + +function Tool({ m, project }: { m: MethodDescriptor; project: Project }) { + const d = useData(); + const w = useWrite(project.id); + const [v, setV] = useState({}); + const [busy, setBusy] = useState(false); + const [res, setRes] = useState(null); + const [err, setErr] = useState(null); + const props = Object.entries(m.args?.properties ?? {}); + const required = new Set(m.args?.required ?? []); + const args = m.args ? cleanArgs(m.args, v) : {}; + const missing = [...required].some(k => args[k] === undefined); + const read = async () => { + setBusy(true); setErr(null); + try { setRes(await api.call(m.method, project.id, { args }, newJob(m.method))); } catch (e) { setErr(errorText(e)); setRes(null); } + setBusy(false); + }; + return ( +
+
+
+ {m.title} + {m.description} +
+
+ {m.kind === 'read' ? t('tools.read') : t('tools.write')} + {m.network && {t('tools.network')}} +
+
+ {props.length > 0 && ( +
+ {props.map(([k, s]) => setV(p => ({ ...p, [k]: x }))} />)} +
+ )} +
+ {m.kind === 'read' + ? + : {t('tools.dryRun')}} +
+ {err && } + {res && ( +
+ {res.command} · {t('tools.exit', { code: res.exitCode })} +
{res.output || res.error || (res.data !== undefined ? JSON.stringify(res.data, null, 2) : '')}
+ +
+ )} +
+ ); +} + +export function ToolsTab({ project }: { project: Project }) { + const d = useData(); + const [methods, setMethods] = useState(null); + const [err, setErr] = useState(null); + const [open, setOpen] = useState(TOOLBOX[0]!.id); + useEffect(() => { + api.call<{ methods: MethodDescriptor[] }>('system.methods').then(r => setMethods(r.methods), e => setErr(errorText(e))); + }, []); + const by = new Map((methods ?? []).map(m => [m.method, m] as const)); + const copy = (c: string) => { navigator.clipboard?.writeText(c).then(() => d.toast({ kind: 'ok', title: t('tools.copied') }), () => {}); }; + return ( + <> + {t('tools.lead')} + {err && } + {!methods && !err && } + {methods && TOOLBOX.map(g => { + const ms = g.methods.map(x => by.get(x)).filter((x): x is MethodDescriptor => !!x); + const isOpen = open === g.id; + return ( + setOpen(isOpen ? '' : g.id)}>{isOpen ? t('tools.hide') : t('tools.show')}}> + {isOpen &&
{ms.map(m => )}
} +
+ ); + })} + +
+ {OWNER_STEPS.map(s => ( +
+
{t(`tools.o.${s.id}` as Key)}{s.command}
+ +
+ ))} +
+
+ + ); +} diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index fadfe77..e04f89b 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -807,6 +807,11 @@ h2[tabindex="-1"]:focus, h2[tabindex="-1"]:focus-visible { outline: none; } .issue-row .chev.rot { transform: rotate(90deg); } .ellipsis { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .issue-detail { display: grid; gap: 12px; padding: 4px 0; min-width: 0; } +/* More tools tab: one typed form per command */ +.tool { display: grid; gap: 8px; padding-top: 12px; border-top: 1px solid var(--line); min-width: 0; } +.tool:first-child { border-top: 0; padding-top: 0; } +.tool-form { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px 12px; } +.tool pre { max-height: 260px; overflow: auto; } .issue-title { overflow-wrap: anywhere; } .stack-trace { margin: 0; max-height: 280px; overflow: auto; padding: 10px 12px; border-radius: 8px; background: var(--surface); border: 1px solid var(--line, var(--border)); font: 12px/1.5 var(--mono, ui-monospace, monospace); white-space: pre; } .build-edit { display: grid; gap: 10px; padding: 4px 0; max-width: 760px; }