One product per machine: the CLI and the desktop app share one version, the store accounts and the app folder list

- one version: package.json's version is the desktop app's too (desktopVersion removed); tools/desktop-version.ts
  keeps the plugin manifests and the app's copies equal, desktop-release publish refuses another version, release
  order app first then npm publish; the unpublished desktop 0.1.0 candidate is noted in the docs and CHANGELOG
- storebound desktop installs (per user, no admin: macOS DMG -> /Applications or ~/Applications, Windows NSIS /S,
  Linux AppImage + .desktop with the storebound:// handler), updates an older app, notes a newer one (downgrade only
  with --force), asks once ([Y/n], --yes, exit 3 without a terminal), opens on the folder; --dry / --json / --download;
  the install steps are pure plans (cli/lib/desktop-install.ts); the version's own manifest dl/<version>/latest.json
- credentials: one store, the CLI's credential profiles in ~/.storebound/credentials; the desktop writes and reads
  them (profiles.rs, files 0600), moves old keychain accounts into profiles on first start, shows profiles the CLI
  made; precedence env > named profile > project file > default profile; config `account`, "account" in the
  project's credentials.json; storebound accounts list | import (dedup by key id / service account e-mail, key files
  copied 0600, project file keeps keystore and review contact, backup, --dry, never prints values) | use
- app folders: one list ~/.storebound/projects.json shared by the desktop (registry.rs), storebound desktop,
  apps folders | add | remove, ws init and ws without a workspace file
- desktop: notice when a folder pins another storebound version; storebound-desktop --version
- tests: version drift, desktop decisions and per-OS plans, credential precedence and import, registry (TS + Rust),
  keychain migration and profile round trip (Rust), boot check with old keychain state and the new shared state
This commit is contained in:
CRTkafa 2026-10-13 06:17:31 +08:00
parent bba46b5c7f
commit d2ce185441
60 changed files with 2443 additions and 246 deletions

View file

@ -20,9 +20,9 @@ export type { AndroidSdkLookup } from '../../../cli/lib/tools';
export interface LocalTools { tools: import('../../../cli/lib/tools').ToolStatus[]; androidSdk?: import('../../../cli/lib/tools').AndroidSdkLookup }
export interface SidecarStatus { running: boolean; version: string; protocol: string; methods: Pick<MethodDescriptor, 'method' | 'title' | 'description' | 'kind' | 'network' | 'destructive' | 'json'>[]; tools: LocalTools | null }
/** An app folder (one storebound.config.ts = one app on iOS and / or Android) and the accounts it uses. */
export interface Project { id: string; path: string; name: string; playAccount: string | null; appleAccount: string | null; added: string; /** The folder is gone (moved, renamed, deleted): shown as "Folder not found". */ missing?: boolean }
export interface Project { id: string; path: string; name: string; playAccount: string | null; appleAccount: string | null; added: string; /** The folder is gone (moved, renamed, deleted): shown as "Folder not found". */ missing?: boolean; /** The storebound version the folder's package.json pins, when it is not this app's. */ storeboundPin?: string | null }
export type AccountKind = 'play' | 'apple';
export interface Account { id: string; label: string; kind: AccountKind; created: string; studio?: string | null }
export interface Account { id: string; label: string; kind: AccountKind; created: string; studio?: string | null; /** The machine profile that holds the key (~/.storebound, shared with the CLI); null while an old keychain key has not moved. */ profile?: string | null }
export const STUDIO_COLORS = ['indigo', 'violet', 'rose', 'amber', 'emerald', 'teal', 'sky', 'slate'] as const;
export type StudioColor = (typeof STUDIO_COLORS)[number];
/** A user-named group of store accounts (and so of apps). */

View file

@ -47,11 +47,11 @@ let projects: Project[] = [
{ id: 'p-tidy', path: `${HOME}/apps/tidy-lists`, name: 'Tidy Lists', playAccount: P1, appleAccount: A2, added: iso(60 * 24 * 5) },
];
let accounts: Account[] = [
{ id: P1, label: 'Example Games · Play', kind: 'play', created: iso(60 * 24 * 30), studio: S1 },
{ id: A1, label: 'Example Games · App Store (company)', kind: 'apple', created: iso(60 * 24 * 30), studio: S1 },
{ id: P2, label: 'Sample Labs · Play', kind: 'play', created: iso(60 * 24 * 20), studio: S2 },
{ id: A2, label: 'Sample Labs · App Store', kind: 'apple', created: iso(60 * 24 * 20), studio: S2 },
{ id: A3, label: 'Demo Indie · App Store (individual)', kind: 'apple', created: iso(60 * 24 * 9), studio: S3 },
{ id: P1, label: 'Example Games · Play', kind: 'play', created: iso(60 * 24 * 30), studio: S1, profile: 'example-games' },
{ id: A1, label: 'Example Games · App Store (company)', kind: 'apple', created: iso(60 * 24 * 30), studio: S1, profile: 'example-games' },
{ id: P2, label: 'Sample Labs · Play', kind: 'play', created: iso(60 * 24 * 20), studio: S2, profile: 'sample-labs' },
{ id: A2, label: 'Sample Labs · App Store', kind: 'apple', created: iso(60 * 24 * 20), studio: S2, profile: 'sample-labs' },
{ id: A3, label: 'Demo Indie · App Store (individual)', kind: 'apple', created: iso(60 * 24 * 9), studio: S3, profile: 'demo-indie' },
];
if (import.meta.env.VITE_FIXTURES === 'empty') { projects = []; accounts = []; studios = []; }

View file

@ -411,7 +411,7 @@ export const en = {
'checks.sev.accepted': 'Accepted',
'checks.sev.low': 'Low',
'folders.lead': 'One folder per app with its storebound.config.ts (iOS and Android in one config). Pick the store accounts each app uses; the keys stay in the keychain.',
'folders.lead': 'One folder per app with its storebound.config.ts (iOS and Android in one config). This list is the machine\'s (~/.storebound/projects.json): `storebound desktop` or `storebound apps add` in a terminal adds to it too. Pick the store accounts each app uses.',
'folders.list': 'App folders',
'folders.listSub': '{count} folders',
'folders.listSub_one': '1 folder',
@ -445,16 +445,19 @@ export const en = {
'run.dryFailed': 'The dry run failed: nothing was written',
'run.done': 'Done',
'accounts.lead': 'Store API keys live in the system keychain (Keychain on macOS, Credential Manager on Windows, Secret Service on Linux). They are never shown again, never written to your app folders, and reach storebound in memory for one call.',
'accounts.lead': 'Store accounts live once per machine in ~/.storebound, shared with the storebound CLI (`storebound accounts`): one credential profile per account, key files readable only by you. They are never shown again, never written to your app folders, and reach storebound in memory for one call.',
'accounts.list': 'Store accounts',
'accounts.listSub': 'Labels only: the keys are in the keychain',
'accounts.listSub': 'Labels and profile names only: the keys are in ~/.storebound',
'accounts.emptyTitle': 'No store accounts yet',
'accounts.emptyText': 'Add a Google Play service account or an App Store Connect API key below. Store reads need them; app folders work without.',
'accounts.unused': 'Not used',
'accounts.remove': 'Remove',
'accounts.removeAria': 'Remove {label} and delete its key from the keychain',
'accounts.removeAria': 'Remove {label}; its key file moves to a .removed folder in ~/.storebound',
'accounts.saved': '"{label}" saved',
'accounts.savedDetail': 'The key is in the system keychain.',
'accounts.savedDetail': 'The key is in a credential profile in ~/.storebound (the CLI uses it too).',
'acct.profile': 'profile {name}',
'acct.noProfile': 'key not moved from the keychain yet',
'app.pinNotice': 'This project uses storebound {pin} (package.json), the app runs {app}. The app works with its own copy; update the project with `bun add -d storebound@{app}` (or npm i -D) to keep both the same.',
'accounts.playSub': 'Service account JSON key',
'accounts.appleSub': 'Team API key (.p8) from Users and Access, Integrations',
'accounts.label': 'Name',
@ -465,7 +468,7 @@ export const en = {
'accounts.issuerId': 'Issuer ID',
'accounts.choosePlay': 'Choose the JSON key',
'accounts.chooseApple': 'Choose the .p8 key',
'accounts.safety': 'Keys are picked in the system file dialog and go straight to the keychain. The window never sees them.',
'accounts.safety': 'Keys are picked in the system file dialog and go straight into ~/.storebound (readable only by you). The window never sees them.',
'deps.lead': 'Local tools for screenshots, app previews, icons and simulator or emulator captures. Store work (listings, products, releases, reviews) needs none of them.',
'deps.checkAgain': 'Check again',
@ -547,7 +550,7 @@ export const en = {
'wz.p2t': 'Safe by design',
'wz.p2s': 'Reads are read-only. Every change shows a dry run and a native confirmation first.',
'wz.p3t': 'Your keys stay yours',
'wz.p3s': 'Store keys live in the system keychain. No account, no telemetry.',
'wz.p3s': 'Store keys live in ~/.storebound, shared with the storebound CLI. No account, no telemetry.',
'wz.appsTitle': 'Add your apps',
'wz.appsLead': 'Each app is a folder with a storebound.config.ts that describes it on both stores.',
'wz.addTitle': 'Add an existing folder',
@ -607,11 +610,11 @@ export const en = {
'color.teal': 'Teal',
'color.sky': 'Sky',
'color.slate': 'Slate',
'accounts.leadStudios': 'Studios group your store accounts: Google Play service accounts and App Store Connect team keys. Keys stay in the system keychain; apps come in with Import apps.',
'accounts.leadStudios': 'Studios group your store accounts: Google Play service accounts and App Store Connect team keys. Keys live in ~/.storebound, shared with the CLI; apps come in with Import apps.',
'accounts.emptyTextStudios': 'Add a studio, then its Google Play service account and App Store Connect keys. Import apps then lists every app those keys can see.',
'accounts.playSubMany': 'Service account JSON key. One service account can be invited to several Play developer accounts: it sees all their apps.',
'accounts.appleSubMany': 'One API key per team (company or individual): key id, issuer id and the .p8 file.',
'accounts.safetyStudios': 'Keys are read once from the file you pick, stored in the system keychain and never shown again. storebound gets one account\'s key in memory for one call. Studio names, labels and app lists stay on this computer.',
'accounts.safetyStudios': 'Keys are read once from the file you pick, stored in ~/.storebound (readable only by you, shared with the storebound CLI) and never shown again. storebound gets one account\'s key in memory for one call. Studio names, labels and app lists stay on this computer.',
'acct.ok': 'Works',
'acct.noApps': 'No apps',
'acct.permission': 'Permission missing',
@ -768,7 +771,7 @@ export const en = {
'set.writesText': 'Dry run first, then your Apply in a system dialog. Submit, release and publish stay in the consoles.',
'set.privacy': 'Privacy',
'set.p1': 'No telemetry, no analytics, no crash reports: the app talks only to the stores, with your keys, when you ask.',
'set.p2': 'Store keys live in the system keychain; the app data folder holds names, settings and saved reads, never keys.',
'set.p2': 'Store keys live in ~/.storebound and the app folder list in ~/.storebound/projects.json, shared with the CLI; the app data folder holds labels, settings and saved reads, never keys.',
'set.p3': 'Nothing is written into your app folders unless you confirm it in a system dialog.',
'set.p4': 'Tester emails stay masked; review drafts stay in each app folder\'s .storebound/ (gitignored).',
'set.reveal': 'Show the data folder',
@ -785,7 +788,7 @@ export const en = {
'set.updateInstall': 'Install {version}…',
'set.updateInstalling': 'Downloading the update… {done}',
'set.reset': 'Reset',
'set.resetText': 'Forgets {accounts} accounts (their keys are removed from the keychain), {studios} studios, {folders} app folders (the folders stay on disk), pairing, the ignore list, settings and saved reads. A system dialog asks first.',
'set.resetText': 'Forgets {studios} studios, the labels of {accounts} accounts, pairing, the ignore list, settings and saved reads. The accounts in ~/.storebound and the {folders} app folders stay: the CLI uses them too. A system dialog asks first.',
'set.resetButton': 'Reset the app',
'set.resetDone': 'storebound desktop was reset',
'set.footer': '{n} apps known to this computer',
@ -1275,7 +1278,7 @@ export const en = {
'crash.retry': 'Try again',
'crash.reload': 'Reload',
'crash.reset': 'Reset saved state (keeps a backup)',
'crash.resetHint': 'Starts with an empty list. The saved state is kept as a backup file, store keys stay in the keychain, app folders are not touched.',
'crash.resetHint': 'Starts again from the machine\'s accounts and app folders. The saved state is kept as a backup file, store keys stay in ~/.storebound, app folders are not touched.',
'crash.resetDone': 'Saved state reset. Backup: {path}. Reloading…',
'crash.logNote': 'Also written to logs/webview.log in the app data folder (home folder shown as ~, no keys).',
'folder.missing': 'Folder not found.',

View file

@ -413,7 +413,7 @@ export const tr: Record<keyof typeof en, string> = {
'checks.sev.accepted': 'Kabul',
'checks.sev.low': 'Düşük',
'folders.lead': 'Her uygulama için storebound.config.ts dosyası olan bir klasör (iOS ve Android tek ayarda). Her uygulamanın kullandığı mağaza hesaplarını seçin; anahtarlar anahtar zincirinde kalır.',
'folders.lead': 'Her uygulama için storebound.config.ts dosyası olan bir klasör (iOS ve Android tek ayarda). Bu liste bilgisayarın listesidir (~/.storebound/projects.json): terminalde `storebound desktop` ya da `storebound apps add` da buraya ekler. Her uygulamanın kullandığı mağaza hesaplarını seçin.',
'folders.list': 'Uygulama klasörleri',
'folders.listSub': '{count} klasör',
'folders.listSub_one': '1 klasör',
@ -447,16 +447,19 @@ export const tr: Record<keyof typeof en, string> = {
'run.dryFailed': 'Deneme çalıştırması başarısız: hiçbir şey yazılmadı',
'run.done': 'Bitti',
'accounts.lead': 'Mağaza API anahtarları sistemin anahtar zincirinde durur (macOS\'ta Anahtar Zinciri, Windows\'ta Kimlik Bilgisi Yöneticisi, Linux\'ta Secret Service). Bir daha gösterilmez, uygulama klasörlerinize yazılmaz ve storebound\'a yalnızca bir çağrı için bellekte verilir.',
'accounts.lead': 'Mağaza hesapları bilgisayarda bir kez, ~/.storebound içinde durur ve storebound CLI ile paylaşılır (`storebound accounts`): her hesap için bir kimlik profili, anahtar dosyalarını yalnızca siz okuyabilirsiniz. Bir daha gösterilmez, uygulama klasörlerinize yazılmaz, storebound\'a bir çağrı için bellekte ulaşır.',
'accounts.list': 'Mağaza hesapları',
'accounts.listSub': 'Yalnızca etiketler: anahtarlar anahtar zincirinde',
'accounts.listSub': 'Yalnızca etiketler ve profil adları: anahtarlar ~/.storebound içinde',
'accounts.emptyTitle': 'Henüz mağaza hesabı yok',
'accounts.emptyText': 'Aşağıdan bir Google Play hizmet hesabı veya App Store Connect API anahtarı ekleyin. Mağaza okumaları bunlara ihtiyaç duyar; uygulama klasörleri onlarsız da çalışır.',
'accounts.unused': 'Kullanılmıyor',
'accounts.remove': 'Kaldır',
'accounts.removeAria': '{label} hesabını kaldır ve anahtarını anahtar zincirinden sil',
'accounts.removeAria': '{label} hesabını kaldır; anahtar dosyası ~/.storebound içindeki .removed klasörüne taşınır',
'accounts.saved': '"{label}" kaydedildi',
'accounts.savedDetail': 'Anahtar sistemin anahtar zincirinde.',
'accounts.savedDetail': 'Anahtar ~/.storebound içindeki bir kimlik profilinde (CLI da kullanır).',
'acct.profile': 'profil {name}',
'acct.noProfile': 'anahtar henüz anahtar zincirinden taşınmadı',
'app.pinNotice': 'Bu proje storebound {pin} kullanıyor (package.json), uygulama {app} çalıştırıyor. Uygulama kendi kopyasıyla çalışır; ikisini aynı tutmak için projeyi `bun add -d storebound@{app}` (ya da npm i -D) ile güncelleyin.',
'accounts.playSub': 'Hizmet hesabı JSON anahtarı',
'accounts.appleSub': 'Kullanıcılar ve Erişim, Entegrasyonlar altındaki ekip API anahtarı (.p8)',
'accounts.label': 'Ad',
@ -467,7 +470,7 @@ export const tr: Record<keyof typeof en, string> = {
'accounts.issuerId': 'Veren kimliği',
'accounts.choosePlay': 'JSON anahtarını seç',
'accounts.chooseApple': '.p8 anahtarını seç',
'accounts.safety': 'Anahtarlar sistemin dosya penceresinde seçilir ve doğrudan anahtar zincirine gider. Uygulama penceresi onları hiç görmez.',
'accounts.safety': 'Anahtarlar sistemin dosya penceresinde seçilir ve doğrudan ~/.storebound klasörüne gider (yalnızca siz okuyabilirsiniz). Pencere onları hiç görmez.',
'deps.lead': 'Ekran görüntüleri, uygulama önizlemeleri, simgeler ve simülatör ya da emülatör kayıtları için yerel araçlar. Mağaza işleri (metinler, ürünler, sürümler, yorumlar) hiçbirine ihtiyaç duymaz.',
'deps.checkAgain': 'Yeniden kontrol et',
@ -549,7 +552,7 @@ export const tr: Record<keyof typeof en, string> = {
'wz.p2t': 'Tasarımı gereği güvenli',
'wz.p2s': 'Okumalar salt okunurdur. Her değişiklik önce bir deneme çalıştırması ve sistem onayı gösterir.',
'wz.p3t': 'Anahtarlarınız sizde kalır',
'wz.p3s': 'Mağaza anahtarları sistemin anahtar zincirinde durur. Hesap yok, telemetri yok.',
'wz.p3s': 'Mağaza anahtarları ~/.storebound içinde, storebound CLI ile paylaşılır. Hesap yok, telemetri yok.',
'wz.appsTitle': 'Uygulamalarınızı ekleyin',
'wz.appsLead': 'Her uygulama, onu iki mağazada anlatan bir storebound.config.ts dosyası olan bir klasördür.',
'wz.addTitle': 'Var olan klasörü ekle',
@ -609,11 +612,11 @@ export const tr: Record<keyof typeof en, string> = {
'color.teal': 'Deniz mavisi',
'color.sky': 'Gök mavisi',
'color.slate': 'Arduvaz',
'accounts.leadStudios': 'Stüdyolar mağaza hesaplarınızı gruplar: Google Play hizmet hesapları ve App Store Connect ekip anahtarları. Anahtarlar sistem anahtar zincirinde kalır; uygulamalar "Uygulamaları içe aktar" ile gelir.',
'accounts.leadStudios': 'Stüdyolar mağaza hesaplarınızı gruplar: Google Play hizmet hesapları ve App Store Connect ekip anahtarları. Anahtarlar ~/.storebound içinde, CLI ile paylaşılır; uygulamalar Uygulamaları içe aktar ile gelir.',
'accounts.emptyTextStudios': 'Bir stüdyo, sonra onun Google Play hizmet hesabını ve App Store Connect anahtarlarını ekleyin. "Uygulamaları içe aktar" bu anahtarların gördüğü tüm uygulamaları listeler.',
'accounts.playSubMany': 'Hizmet hesabı JSON anahtarı. Bir hizmet hesabı birden çok Play geliştirici hesabına davet edilebilir: hepsinin uygulamalarını görür.',
'accounts.appleSubMany': 'Ekip başına bir API anahtarı (şirket ya da bireysel): anahtar kimliği, issuer kimliği ve .p8 dosyası.',
'accounts.safetyStudios': 'Anahtarlar seçtiğiniz dosyadan bir kez okunur, sistem anahtar zincirine kaydedilir ve bir daha gösterilmez. storebound bir hesabın anahtarını tek bir çağrı için bellekte alır. Stüdyo adları, etiketler ve uygulama listeleri bu bilgisayarda kalır.',
'accounts.safetyStudios': 'Anahtarlar seçtiğiniz dosyadan bir kez okunur, ~/.storebound içinde saklanır (yalnızca siz okuyabilirsiniz, storebound CLI ile paylaşılır) ve bir daha gösterilmez. storebound bir hesabın anahtarını bir çağrı için bellekte alır. Stüdyo adları, etiketler ve uygulama listeleri bu bilgisayarda kalır.',
'acct.ok': 'Çalışıyor',
'acct.noApps': 'Uygulama yok',
'acct.permission': 'İzin eksik',
@ -770,7 +773,7 @@ export const tr: Record<keyof typeof en, string> = {
'set.writesText': 'Önce deneme, sonra sistem penceresinde sizin Uygula onayınız. Gönderme, yayınlama ve yayımlama konsollarda kalır.',
'set.privacy': 'Gizlilik',
'set.p1': 'Telemetri, analiz ya da çökme raporu yok: uygulama yalnızca siz istediğinizde, anahtarlarınızla mağazalarla konuşur.',
'set.p2': 'Mağaza anahtarları sistem anahtar zincirinde durur; uygulama veri klasöründe adlar, ayarlar ve kayıtlı okumalar vardır, anahtar asla.',
'set.p2': 'Mağaza anahtarları ~/.storebound, uygulama klasörü listesi ~/.storebound/projects.json içinde, CLI ile paylaşılır; uygulama veri klasöründe etiketler, ayarlar ve kayıtlı okumalar durur, anahtar asla.',
'set.p3': 'Siz bir sistem penceresinde onaylamadıkça uygulama klasörlerinize hiçbir şey yazılmaz.',
'set.p4': 'Test kullanıcısı e-postaları maskeli kalır; yorum taslakları her uygulama klasörünün .storebound/ klasöründe (gitignore) kalır.',
'set.reveal': 'Veri klasörünü göster',
@ -787,7 +790,7 @@ export const tr: Record<keyof typeof en, string> = {
'set.updateInstall': '{version} sürümünü yükle…',
'set.updateInstalling': 'Güncelleme indiriliyor… {done}',
'set.reset': 'Sıfırla',
'set.resetText': '{accounts} hesabı (anahtarları anahtar zincirinden silinir), {studios} stüdyoyu, {folders} uygulama klasörünü (klasörler diskte kalır), eşleştirmeyi, yok sayılanları, ayarları ve kayıtlı okumaları unutur. Önce bir sistem penceresi sorar.',
'set.resetText': '{studios} stüdyoyu, {accounts} hesabın etiketlerini, eşleştirmeyi, yok sayılanları, ayarları ve kayıtlı okumaları unutur. ~/.storebound içindeki hesaplar ve {folders} uygulama klasörü kalır: CLI da kullanır. Önce bir sistem penceresi sorar.',
'set.resetButton': 'Uygulamayı sıfırla',
'set.resetDone': 'storebound masaüstü sıfırlandı',
'set.footer': 'Bu bilgisayarın bildiği {n} uygulama',
@ -1277,7 +1280,7 @@ export const tr: Record<keyof typeof en, string> = {
'crash.retry': 'Yeniden dene',
'crash.reload': 'Yeniden yükle',
'crash.reset': 'Kayıtlı durumu sıfırla (yedek tutulur)',
'crash.resetHint': 'Boş bir listeyle başlar. Kayıtlı durum bir yedek dosyada saklanır, mağaza anahtarları anahtar zincirinde kalır, uygulama klasörlerine dokunulmaz.',
'crash.resetHint': 'Bilgisayarın hesapları ve uygulama klasörleriyle yeniden başlar. Kayıtlı durum yedek dosya olarak saklanır, mağaza anahtarları ~/.storebound içinde kalır, uygulama klasörlerine dokunulmaz.',
'crash.resetDone': 'Kayıtlı durum sıfırlandı. Yedek: {path}. Yeniden yükleniyor…',
'crash.logNote': 'Uygulama veri klasöründeki logs/webview.log dosyasına da yazıldı (ana klasör ~ olarak, anahtarlar olmadan).',
'folder.missing': 'Klasör bulunamadı.',

View file

@ -15,13 +15,13 @@ export function normalizeProjects(v: unknown): Project[] {
const seen = new Set<string>();
return arr(v).filter(isRec).filter(p => typeof p.id === 'string' && typeof p.path === 'string' && !seen.has(p.id) && !!seen.add(p.id)).map(p => {
const path = p.path as string;
return { id: p.id as string, path, name: str(p.name).trim() || folderName(path), playAccount: strOrNull(p.playAccount), appleAccount: strOrNull(p.appleAccount), added: str(p.added), missing: p.missing === true };
return { id: p.id as string, path, name: str(p.name).trim() || folderName(path), playAccount: strOrNull(p.playAccount), appleAccount: strOrNull(p.appleAccount), added: str(p.added), missing: p.missing === true, storeboundPin: strOrNull(p.storeboundPin) };
});
}
export function normalizeAccounts(v: unknown): Account[] {
return arr(v).filter(isRec).filter(a => typeof a.id === 'string' && (a.kind === 'play' || a.kind === 'apple'))
.map(a => ({ id: a.id as string, label: str(a.label) || (a.id as string), kind: a.kind as Account['kind'], created: str(a.created), studio: strOrNull(a.studio) }));
.map(a => ({ id: a.id as string, label: str(a.label) || (a.id as string), kind: a.kind as Account['kind'], created: str(a.created), studio: strOrNull(a.studio), profile: strOrNull(a.profile) }));
}
function studios(v: unknown): Studio[] {

View file

@ -1,6 +1,7 @@
// Studios & accounts: studios (name, colour, logo) group store accounts; each account is a Google Play service account
// (one JSON key; it can be invited to several Play developer accounts) or an App Store Connect team key (key id,
// issuer id, .p8). Keys live in the system keychain; the app keeps a label and the studio only. Per account: a health
// issuer id, .p8). Keys live in the machine's credential profiles (~/.storebound, shared with the CLI);
// the app keeps a label, the studio and the profile name only. Per account: a health
// check (key works / permission missing / revoked, with the fix) and "Import apps" (every app the key can see).
import { type FormEvent, useEffect, useState } from 'react';
import { type Account, type AccountCheck, api, errorText, STUDIO_COLORS, type Studio, type StudioColor } from '../api';
@ -59,6 +60,7 @@ function AccountRow({ a, busy, setBusy, onError }: { a: Account; busy: string |
<div className="row" style={{ gap: 8 }}><span className="strong">{a.label}</span><HealthChip h={h} />{ignored && <Chip tone="neutral" icon="eyeOff">{t('acct.ignored')}</Chip>}</div>
<div className="small muted">
{a.kind === 'play' ? t('acct.kindPlay') : t('acct.kindApple')}
{' · '}{a.profile ? <span className="mono-id selectable" title="~/.storebound">{t('acct.profile', { name: a.profile })}</span> : t('acct.noProfile')}
{' · '}{list ? t('acct.appsRead', { count: list.value.apps.length, when: ago(list.at) }) : t('acct.notImported')}
{used ? ` · ${t('acct.folders', { count: used })}` : ''}
{check?.principal ? <> · <span className="mono-id selectable">{check.principal}</span></> : null}

View file

@ -140,6 +140,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
</div>
<AppControls app={app} />
{!app.project && <StoreOnlyBanner app={app} />}
{app.project?.storeboundPin && <Notice tone="info" icon="info">{t('app.pinNotice', { pin: app.project.storeboundPin, app: d.info?.version ?? '' })}</Notice>}
{app.project?.missing ? <FolderMissing project={app.project} /> : <>
<div className="tabs" role="tablist" aria-label={t('app.sections', { name })} onKeyDown={onKey}>
{tabs.map((x, i) => (