desktop editors: edit in place what App Store Connect and Play Console let the owner edit (listings per language for both stores with live counters and the check / scan rules, store details, App info, age rating, App Review, Play store settings, Data safety and App Privacy answers, promotion blocks, catalog names / prices / offers, TestFlight information, scene order); storebound edit (project.edit) saves into the config by path keeping comments and formatting, the listing files and the review contact, dry run with the diff and the checks first; Apply shows each store's dry run in the window; unsaved edits survive tab switches with undo / redo; tests/edit.test.ts, apps/desktop/tests/edits.test.ts, boot check editor case

This commit is contained in:
CRTkafa 2026-10-14 00:35:29 +08:00
parent 714352ec18
commit 3cea2af4e1
37 changed files with 3020 additions and 54 deletions

View file

@ -10,6 +10,7 @@ import type { Overrides } from './lib/identity';
export type {
AccountCheck, AppIdentity, AppsListResult, AppStatus, CatalogParity, DraftsResult, StoreApp, IconResult, ListingField, ListingLocale, ListingsResult, MethodDescriptor, PortfolioResult,
ProgressParams, ProjectSummary, ReleasePhase, ReleaseTrain, ReplyDraft, StoreRelease, ToolResult,
EditableListing, EditableResult, EditResult,
AnalyticsCompare, AsoAuditView, AsoTrendRow, ConsoleAnswer, ConsoleFillResult, ConsoleForm, DeclAsk, DeclarationsResult, ListingText, PpoStatus, PpoTest, PriceDiffRow, PriceRowData, PricesCurrent, PricesDiff, RenderImage, RenderTile, RendersResult,
} from '../../../cli/lib/desktop-protocol';

View file

@ -1545,4 +1545,182 @@ export const en = {
'deps.sdkFrom': 'Found through',
'deps.sdkNone': 'Not found. play build needs it (install Android Studio, or set ANDROID_HOME). Checked:',
'ai.dsFrom': 'from {source}',
'ed.countAria': '{n} of {limit} characters',
'ed.unsavedField': 'Unsaved change',
'ed.was': 'Saved:',
'ed.empty': '(empty)',
'ed.removed': '(removed)',
'ed.reset': 'Reset',
'ed.notSet': 'Not set',
'ed.none': 'none',
'ed.notNumber': 'Not a number',
'ed.notJson': 'Not valid JSON: {error}',
'ed.noGit': 'Not a git folder',
'ed.gitClean': 'No uncommitted changes in these files',
'ed.gitDirty': 'Uncommitted in git: {files}. storebound never commits.',
'ed.also': 'the same section also serves {codes}',
'ed.newFile': 'new file',
'ed.masked': 'Personal data: not shown.',
'ed.unsaved': '{count} unsaved changes',
'ed.unsaved_one': '1 unsaved change',
'ed.noChanges': 'No unsaved changes',
'ed.undo': 'Undo',
'ed.redo': 'Redo',
'ed.discard': 'Discard',
'ed.review': 'Review and save',
'ed.close': 'Close',
'ed.fixFirst': 'Fix the errors first',
'ed.save': 'Save to the app folder',
'ed.saved': 'Saved to the app folder',
'ed.nothingToApply': 'Nothing to change on the store.',
'ed.applyNow': 'Apply to the store',
'ed.saveFirst': 'Save your edits first: the store gets what the files hold.',
'ed.statusDraft': 'Machine draft, not reviewed',
'ed.statusTranslate': 'Not translated yet',
'ed.alsoServes': 'also {codes}',
'ed.newSection': '{lang} has no section in the listing file yet. Fill in the required fields; saving adds the section.',
'ed.keywordsHint': 'Comma separated, no spaces after commas; do not repeat words from the name or subtitle.',
'ed.whatsNewApple': 'Not allowed on the first version.',
'ed.nameHint': 'Empty: the app name from the config.',
'ed.supportUrl': 'Support URL',
'ed.marketingUrl': 'Marketing URL',
'ed.privacyUrl': 'Privacy policy URL',
'ed.termsUrl': 'Terms of Use URL',
'ed.termsHint': 'Needed with subscriptions (Apple 3.1.2).',
'ed.website': 'Website',
'ed.websiteHint': 'Google Play: the domain that serves app-ads.txt.',
'ed.contactEmail': 'Contact email',
'ed.copyright': 'Copyright',
'ed.video': 'Promo video (YouTube URL)',
'ed.videoHint': 'Public or unlisted, ads off, embeddable. Play takes no video upload.',
'ed.listingTitle': 'Edit the listing',
'ed.listingSub': 'Per language, both stores side by side. Counters use the store limits; the checks of check and scan run as you type.',
'ed.pullPlay': 'Pull from Google Play',
'ed.pullApple': 'Pull from the App Store',
'ed.pulled': 'The listing file now matches the store',
'ed.pullHint': 'Reads the live texts and shows the diff before anything in the files changes.',
'ed.pullDirty': 'You have unsaved edits: they stay pending on top of the pulled text.',
'ed.pullNote': 'Pulling rewrites the listing files after a diff; your unsaved edits stay pending on top.',
'ed.playCode': 'Play language',
'ed.rtl': 'Right-to-left language.',
'ed.noStoreForLang': '{lang} is on neither store in the config.',
'ed.detailsTitle': 'Store details',
'ed.detailsSub': 'URLs, contact and copyright, shared by both stores (storebound.config.ts).',
'ed.applyTitle': 'Apply to the stores',
'ed.applySub': 'Each store: the dry run (old to new per language) in the window, then the confirm dialog, then the real run.',
'ed.applyPlay': 'Google Play listing: preview',
'ed.applyApple': 'App Store {version}: preview',
'ed.applyNote': 'Nothing goes to review: the texts wait in the store until you send the version or changes for review.',
'ed.appleInfoTitle': 'App Store app information',
'ed.appleInfoSub': 'Categories, content rights, price, export compliance and the App Store name.',
'ed.catPrimary': 'Primary category',
'ed.catSub1': 'Game subcategory 1',
'ed.catSub2': 'Game subcategory 2',
'ed.catSecondary': 'Secondary category',
'ed.free': 'Price',
'ed.freeHint': 'Free: available in every territory and new ones.',
'ed.encHint': 'Only you can answer this: export compliance skill.',
'ed.appleName': 'App Store name',
'ed.appleNameHint': 'When the app name is taken on the App Store.',
'ed.ageTitle': 'Age rating',
'ed.ageSub': 'Every question of the App Store declaration. Not set: the "nothing" answer.',
'ed.ageDefault': 'Default: {value}',
'ed.reviewSub': 'Sign-in, notes and contact for App Review. The demo account password is never shown.',
'ed.notesHint': 'App Store Connect takes at most {max} bytes.',
'ed.signInSteps': 'Sign-in steps for reviewers',
'ed.contact_firstName': 'Contact first name',
'ed.contact_lastName': 'Contact last name',
'ed.contact_phone': 'Contact phone',
'ed.contact_email': 'Contact email',
'ed.contactHint': 'Personal data: kept in the private .storebound/ folder (gitignored), shown masked. Type to replace.',
'ed.demoHint': 'The demo account lives only in the private .storebound/ folder; the window never reads the password.',
'ed.game': 'Game',
'ed.app': 'App',
'ed.playSettingsTitle': 'Google Play store settings',
'ed.playSettingsSub': 'Category, countries, managed publishing, target audience, delete-account page.',
'ed.playCategory': 'App category',
'ed.noApiHint': 'No API: the console plan step uses this value.',
'ed.managed': 'Managed publishing',
'ed.countries': 'Production countries',
'ed.countriesHint': 'Two-letter codes (US, GB, TR). Empty: all countries.',
'ed.targetAges': 'Target age groups',
'ed.targetAgesHint': 'Of {groups}.',
'ed.deletionUrl': 'Delete-account page',
'ed.declTitle': 'Data safety and App Privacy answers',
'ed.declSub': 'The answers both forms are filled from. Only you know them: change what the app and its SDKs really do.',
'ed.dsTypes': 'Data types (Play)',
'ed.dsTypesHint': 'Play data type id to usage: { "PSL_EMAIL": { "shared": false, "collect": ["account"] } }. Not listed: not collected.',
'ed.apAnswers': 'App Privacy (App Store)',
'ed.apHint': 'Checked with the whole config when you save; App Privacy has no API (console fill).',
'ed.promoTitle': 'App Store promotion',
'ed.promoSub': 'Promoted purchases, custom product pages and in-app events.',
'ed.promoted': 'Promoted in-app purchases',
'ed.promotedHint': 'Turning them on stays a terminal step (apple promoted --enable).',
'ed.cpp': 'Custom product pages',
'ed.cppHint': 'Submitting a page and making it visible stay terminal steps.',
'ed.events': 'In-app events',
'ed.eventsHint': 'Name 30, short 50, long 120 characters per locale. Sending to review stays a terminal step.',
'ed.applyCpp': 'Custom product pages: preview',
'ed.applyEvents': 'In-app events: preview',
'ed.applyInfoSub': 'App information, App Review, export compliance, Data safety and the Play app details.',
'ed.applyPlayDetails': 'Play contact email and website: preview',
'ed.playConsoleOnly': 'Category, countries and managed publishing have no API: App info > Console steps.',
'ed.itemName': 'Name',
'ed.itemDescription': 'Description',
'ed.benefits': 'Benefits (Play)',
'ed.benefitsHint': 'Comma separated, up to 4, 40 characters each.',
'ed.usd': 'Base price (USD)',
'ed.usdHint': 'A live product\'s new price waits for your decision (Apply the price changes).',
'ed.refName': 'Reference name',
'ed.reviewNote': 'Review note',
'ed.reviewShot': 'Review screenshot',
'ed.family': 'Family Sharing',
'ed.offerCodes': 'Offer codes',
'ed.offerCodesHint': 'Creating codes stays a terminal step (they work like vouchers).',
'ed.level': 'Level in the group',
'ed.levelHint': '1 = highest level of service.',
'ed.period': 'Period',
'ed.periodHint': 'Cannot change after the subscription is approved.',
'ed.intro': 'Introductory offer',
'ed.introHint': 'For example { "type": "free", "duration": "P1W" }.',
'ed.offers': 'Promotional offers',
'ed.winBack': 'Win-back offers',
'ed.winBackHint': 'Creating one stays a terminal step: it goes live by itself on its start date.',
'ed.catalogTitle': 'Edit the catalog',
'ed.catalogEmpty': 'No products or subscriptions in the config',
'ed.catalogEmptyText': 'Add them with the store-iap-setup or store-subscriptions skill.',
'ed.productsTitle': 'In-app products',
'ed.productsSub': 'Names, descriptions per language, price and review details.',
'ed.groupTitle': 'Subscription group {name}',
'ed.groupSub': 'Group display name per language and each subscription.',
'ed.groupName': 'Group display name',
'ed.applyCatalogSub': 'Each store: the dry run per product in the window, then the confirm dialog.',
'ed.applyPlayProducts': 'Play products: preview',
'ed.applyPlaySubs': 'Play subscriptions: preview',
'ed.applyAppleIap': 'App Store in-app purchases: preview',
'ed.applyAppleSubs': 'App Store subscriptions: preview',
'ed.applyIapReview': 'Review screenshots: preview',
'ed.priceGate': 'Price changes of live products are never applied here: they wait for your decision in the owner action.',
'ed.tfTitle': 'Edit TestFlight information',
'ed.tfSub': 'What to Test per locale, the beta description, notes for Beta App Review and the groups.',
'ed.whatToTest': 'What to Test',
'ed.whatToTestHint': 'Empty: the What\'s new text is used.',
'ed.betaDescription': 'Beta App Description',
'ed.betaDescriptionAll': 'Beta App Description (every locale)',
'ed.betaNotes': 'Notes for Beta App Review',
'ed.tfGroups': 'Beta groups',
'ed.tfGroupsHint': '[{ "name": "Studio team", "internal": true }]. Existing groups are never changed or deleted.',
'ed.applyTfSub': 'Test information and the missing groups. External testing and Beta App Review stay terminal steps.',
'ed.applyTfInfo': 'Test information: preview',
'ed.artTitle': 'Screenshot order and frames',
'ed.artSub': 'The stores show the screenshots in this order. Point a scene at another frame to replace its screenshot.',
'ed.artEmpty': 'No screenshot scenes in the config',
'ed.artEmptyText': 'Set up shots with the store-screenshots skill.',
'ed.frame': 'Frame',
'ed.up': 'Up',
'ed.down': 'Down',
'ed.moveUp': 'Move {id} up',
'ed.moveDown': 'Move {id} down',
'ed.applyArtSub': 'Render, then upload: an upload replaces the store\'s screenshot set (dry run first).',
'ed.loadFailed': 'Could not read the app folder\'s files: {error}',
} as const;

View file

@ -1547,4 +1547,182 @@ export const tr: Record<keyof typeof en, string> = {
'deps.sdkFrom': 'Bulunduğu yer',
'deps.sdkNone': 'Bulunamadı. play build buna ihtiyaç duyar (Android Studio\'yu kurun veya ANDROID_HOME ayarlayın). Bakılan yerler:',
'ai.dsFrom': 'kaynak: {source}',
'ed.countAria': '{limit} karakterin {n} tanesi',
'ed.unsavedField': 'Kaydedilmemiş değişiklik',
'ed.was': 'Kayıtlı:',
'ed.empty': '(boş)',
'ed.removed': '(kaldırıldı)',
'ed.reset': 'Geri al',
'ed.notSet': 'Ayarlanmadı',
'ed.none': 'yok',
'ed.notNumber': 'Sayı değil',
'ed.notJson': 'Geçerli JSON değil: {error}',
'ed.noGit': 'Git klasörü değil',
'ed.gitClean': 'Bu dosyalarda commit edilmemiş değişiklik yok',
'ed.gitDirty': 'Git\'te commit edilmemiş: {files}. storebound asla commit etmez.',
'ed.also': 'aynı bölüm {codes} için de geçerli',
'ed.newFile': 'yeni dosya',
'ed.masked': 'Kişisel veri: gösterilmiyor.',
'ed.unsaved': '{count} kaydedilmemiş değişiklik',
'ed.unsaved_one': '1 kaydedilmemiş değişiklik',
'ed.noChanges': 'Kaydedilmemiş değişiklik yok',
'ed.undo': 'Geri al',
'ed.redo': 'Yinele',
'ed.discard': 'Vazgeç',
'ed.review': 'Gözden geçir ve kaydet',
'ed.close': 'Kapat',
'ed.fixFirst': 'Önce hataları düzeltin',
'ed.save': 'Uygulama klasörüne kaydet',
'ed.saved': 'Uygulama klasörüne kaydedildi',
'ed.nothingToApply': 'Mağazada değişecek bir şey yok.',
'ed.applyNow': 'Mağazaya uygula',
'ed.saveFirst': 'Önce düzenlemeleri kaydedin: mağazaya dosyalardaki gönderilir.',
'ed.statusDraft': 'Makine taslağı, gözden geçirilmedi',
'ed.statusTranslate': 'Henüz çevrilmedi',
'ed.alsoServes': 'ayrıca {codes}',
'ed.newSection': '{lang} için listeleme dosyasında henüz bölüm yok. Zorunlu alanları doldurun; kaydetmek bölümü ekler.',
'ed.keywordsHint': 'Virgülle ayrılmış, virgülden sonra boşluk yok; ad veya alt başlıktaki kelimeleri tekrarlamayın.',
'ed.whatsNewApple': 'İlk sürümde kullanılamaz.',
'ed.nameHint': 'Boş: yapılandırmadaki uygulama adı.',
'ed.supportUrl': 'Destek URL\'si',
'ed.marketingUrl': 'Pazarlama URL\'si',
'ed.privacyUrl': 'Gizlilik politikası URL\'si',
'ed.termsUrl': 'Kullanım koşulları URL\'si',
'ed.termsHint': 'Aboneliklerde gerekli (Apple 3.1.2).',
'ed.website': 'Web sitesi',
'ed.websiteHint': 'Google Play: app-ads.txt dosyasını sunan alan adı.',
'ed.contactEmail': 'İletişim e-postası',
'ed.copyright': 'Telif hakkı',
'ed.video': 'Tanıtım videosu (YouTube URL\'si)',
'ed.videoHint': 'Herkese açık veya liste dışı, reklamsız, yerleştirilebilir. Play video yüklemesi almaz.',
'ed.listingTitle': 'Listelemeyi düzenle',
'ed.listingSub': 'Dil başına, iki mağaza yan yana. Sayaçlar mağaza sınırlarını kullanır; check ve scan denetimleri yazarken çalışır.',
'ed.pullPlay': 'Google Play\'den çek',
'ed.pullApple': 'App Store\'dan çek',
'ed.pulled': 'Listeleme dosyası artık mağazayla aynı',
'ed.pullHint': 'Canlı metinleri okur ve dosyalarda bir şey değişmeden önce farkı gösterir.',
'ed.pullDirty': 'Kaydedilmemiş düzenlemeleriniz var: çekilen metnin üzerinde bekler.',
'ed.pullNote': 'Çekme, farkı gösterdikten sonra listeleme dosyalarını yeniden yazar; kaydedilmemiş düzenlemeler üstte bekler.',
'ed.playCode': 'Play dili',
'ed.rtl': 'Sağdan sola yazılan dil.',
'ed.noStoreForLang': '{lang} yapılandırmada iki mağazada da yok.',
'ed.detailsTitle': 'Mağaza ayrıntıları',
'ed.detailsSub': 'URL\'ler, iletişim ve telif hakkı, iki mağazada ortak (storebound.config.ts).',
'ed.applyTitle': 'Mağazalara uygula',
'ed.applySub': 'Her mağaza: pencerede deneme çalıştırması (dil başına eskiden yeniye), sonra onay penceresi, sonra gerçek çalıştırma.',
'ed.applyPlay': 'Google Play listelemesi: önizle',
'ed.applyApple': 'App Store {version}: önizle',
'ed.applyNote': 'Hiçbir şey incelemeye gitmez: metinler sürümü veya değişiklikleri incelemeye gönderene kadar mağazada bekler.',
'ed.appleInfoTitle': 'App Store uygulama bilgileri',
'ed.appleInfoSub': 'Kategoriler, içerik hakları, fiyat, ihracat uyumluluğu ve App Store adı.',
'ed.catPrimary': 'Birincil kategori',
'ed.catSub1': 'Oyun alt kategorisi 1',
'ed.catSub2': 'Oyun alt kategorisi 2',
'ed.catSecondary': 'İkincil kategori',
'ed.free': 'Fiyat',
'ed.freeHint': 'Ücretsiz: her bölgede ve yenilerinde sunulur.',
'ed.encHint': 'Bunu yalnızca siz yanıtlayabilirsiniz: export-compliance becerisi.',
'ed.appleName': 'App Store adı',
'ed.appleNameHint': 'Uygulama adı App Store\'da alınmışsa.',
'ed.ageTitle': 'Yaş derecelendirmesi',
'ed.ageSub': 'App Store beyanının her sorusu. Ayarlanmadı: "hiç" yanıtı.',
'ed.ageDefault': 'Varsayılan: {value}',
'ed.reviewSub': 'App Review için oturum açma, notlar ve iletişim. Demo hesap parolası asla gösterilmez.',
'ed.notesHint': 'App Store Connect en fazla {max} bayt alır.',
'ed.signInSteps': 'İnceleyiciler için oturum açma adımları',
'ed.contact_firstName': 'İletişim adı',
'ed.contact_lastName': 'İletişim soyadı',
'ed.contact_phone': 'İletişim telefonu',
'ed.contact_email': 'İletişim e-postası',
'ed.contactHint': 'Kişisel veri: gizli .storebound/ klasöründe (gitignore) tutulur, maskeli gösterilir. Değiştirmek için yazın.',
'ed.demoHint': 'Demo hesap yalnızca gizli .storebound/ klasöründe; pencere parolayı asla okumaz.',
'ed.game': 'Oyun',
'ed.app': 'Uygulama',
'ed.playSettingsTitle': 'Google Play mağaza ayarları',
'ed.playSettingsSub': 'Kategori, ülkeler, yönetilen yayınlama, hedef kitle, hesap silme sayfası.',
'ed.playCategory': 'Uygulama kategorisi',
'ed.noApiHint': 'API yok: konsol planı adımı bu değeri kullanır.',
'ed.managed': 'Yönetilen yayınlama',
'ed.countries': 'Üretim ülkeleri',
'ed.countriesHint': 'İki harfli kodlar (US, GB, TR). Boş: tüm ülkeler.',
'ed.targetAges': 'Hedef yaş grupları',
'ed.targetAgesHint': 'Şunlardan: {groups}.',
'ed.deletionUrl': 'Hesap silme sayfası',
'ed.declTitle': 'Veri güvenliği ve App Privacy yanıtları',
'ed.declSub': 'İki formun doldurulduğu yanıtlar. Bunları yalnızca siz bilirsiniz: uygulamanın ve SDK\'larının gerçekte yaptığını yazın.',
'ed.dsTypes': 'Veri türleri (Play)',
'ed.dsTypesHint': 'Play veri türü kimliğinden kullanıma: { "PSL_EMAIL": { "shared": false, "collect": ["account"] } }. Listede olmayan: toplanmıyor.',
'ed.apAnswers': 'App Privacy (App Store)',
'ed.apHint': 'Kaydederken tüm yapılandırmayla denetlenir; App Privacy\'nin API\'si yok (konsolda doldurma).',
'ed.promoTitle': 'App Store tanıtımı',
'ed.promoSub': 'Öne çıkan satın almalar, özel ürün sayfaları ve uygulama içi etkinlikler.',
'ed.promoted': 'Öne çıkan uygulama içi satın almalar',
'ed.promotedHint': 'Etkinleştirme terminal adımı olarak kalır (apple promoted --enable).',
'ed.cpp': 'Özel ürün sayfaları',
'ed.cppHint': 'Sayfayı gönderme ve görünür yapma terminal adımları olarak kalır.',
'ed.events': 'Uygulama içi etkinlikler',
'ed.eventsHint': 'Yerel ayar başına ad 30, kısa 50, uzun 120 karakter. İncelemeye gönderme terminal adımı olarak kalır.',
'ed.applyCpp': 'Özel ürün sayfaları: önizle',
'ed.applyEvents': 'Uygulama içi etkinlikler: önizle',
'ed.applyInfoSub': 'Uygulama bilgileri, App Review, ihracat uyumluluğu, Veri güvenliği ve Play uygulama ayrıntıları.',
'ed.applyPlayDetails': 'Play iletişim e-postası ve web sitesi: önizle',
'ed.playConsoleOnly': 'Kategori, ülkeler ve yönetilen yayınlamanın API\'si yok: Uygulama bilgileri > Konsol adımları.',
'ed.itemName': 'Ad',
'ed.itemDescription': 'Açıklama',
'ed.benefits': 'Avantajlar (Play)',
'ed.benefitsHint': 'Virgülle ayrılmış, en fazla 4, her biri 40 karakter.',
'ed.usd': 'Taban fiyat (USD)',
'ed.usdHint': 'Canlı bir ürünün yeni fiyatı kararınızı bekler (Fiyat değişikliklerini uygula).',
'ed.refName': 'Referans adı',
'ed.reviewNote': 'İnceleme notu',
'ed.reviewShot': 'İnceleme ekran görüntüsü',
'ed.family': 'Aile Paylaşımı',
'ed.offerCodes': 'Teklif kodları',
'ed.offerCodesHint': 'Kod oluşturma terminal adımı olarak kalır (kupon gibi çalışırlar).',
'ed.level': 'Gruptaki düzey',
'ed.levelHint': '1 = en yüksek hizmet düzeyi.',
'ed.period': 'Dönem',
'ed.periodHint': 'Abonelik onaylandıktan sonra değiştirilemez.',
'ed.intro': 'Tanıtım teklifi',
'ed.introHint': 'Örneğin { "type": "free", "duration": "P1W" }.',
'ed.offers': 'Promosyon teklifleri',
'ed.winBack': 'Geri kazanma teklifleri',
'ed.winBackHint': 'Oluşturma terminal adımı olarak kalır: başlangıç tarihinde kendiliğinden yayına girer.',
'ed.catalogTitle': 'Kataloğu düzenle',
'ed.catalogEmpty': 'Yapılandırmada ürün veya abonelik yok',
'ed.catalogEmptyText': 'store-iap-setup veya store-subscriptions becerisiyle ekleyin.',
'ed.productsTitle': 'Uygulama içi ürünler',
'ed.productsSub': 'Dil başına adlar, açıklamalar, fiyat ve inceleme ayrıntıları.',
'ed.groupTitle': 'Abonelik grubu {name}',
'ed.groupSub': 'Dil başına grup görünen adı ve her abonelik.',
'ed.groupName': 'Grup görünen adı',
'ed.applyCatalogSub': 'Her mağaza: pencerede ürün başına deneme çalıştırması, sonra onay penceresi.',
'ed.applyPlayProducts': 'Play ürünleri: önizle',
'ed.applyPlaySubs': 'Play abonelikleri: önizle',
'ed.applyAppleIap': 'App Store uygulama içi satın almalar: önizle',
'ed.applyAppleSubs': 'App Store abonelikleri: önizle',
'ed.applyIapReview': 'İnceleme ekran görüntüleri: önizle',
'ed.priceGate': 'Canlı ürünlerin fiyat değişiklikleri burada asla uygulanmaz: sahip eyleminde kararınızı bekler.',
'ed.tfTitle': 'TestFlight bilgilerini düzenle',
'ed.tfSub': 'Yerel ayar başına Neyi Test Etmeli, beta açıklaması, Beta App Review notları ve gruplar.',
'ed.whatToTest': 'Neyi Test Etmeli',
'ed.whatToTestHint': 'Boş: Yenilikler metni kullanılır.',
'ed.betaDescription': 'Beta Uygulama Açıklaması',
'ed.betaDescriptionAll': 'Beta Uygulama Açıklaması (tüm yerel ayarlar)',
'ed.betaNotes': 'Beta App Review notları',
'ed.tfGroups': 'Beta grupları',
'ed.tfGroupsHint': '[{ "name": "Stüdyo ekibi", "internal": true }]. Mevcut gruplar asla değiştirilmez veya silinmez.',
'ed.applyTfSub': 'Test bilgileri ve eksik gruplar. Harici test ve Beta App Review terminal adımları olarak kalır.',
'ed.applyTfInfo': 'Test bilgileri: önizle',
'ed.artTitle': 'Ekran görüntüsü sırası ve kareler',
'ed.artSub': 'Mağazalar ekran görüntülerini bu sırayla gösterir. Görüntüsünü değiştirmek için bir sahneyi başka bir kareye yönlendirin.',
'ed.artEmpty': 'Yapılandırmada ekran görüntüsü sahnesi yok',
'ed.artEmptyText': 'shots ayarını store-screenshots becerisiyle yapın.',
'ed.frame': 'Kare',
'ed.up': 'Yukarı',
'ed.down': 'Aşağı',
'ed.moveUp': '{id} sahnesini yukarı taşı',
'ed.moveDown': '{id} sahnesini aşağı taşı',
'ed.applyArtSub': 'Oluştur, sonra yükle: yükleme mağazadaki ekran görüntüsü setinin yerini alır (önce deneme çalıştırması).',
'ed.loadFailed': 'Uygulama klasörünün dosyaları okunamadı: {error}',
};

View file

@ -0,0 +1,107 @@
// Unsaved edits of the window's editors, per app: one pending value per field (a config path, a listing field of one
// store and language, an App Review contact field), with undo / redo. Kept for the whole session, outside the tabs,
// so switching tabs or apps never loses them; the tab bar marks apps and tabs with unsaved edits, and closing the
// window asks first (beforeunload). Saving turns them into the ops of `storebound edit` (project.edit). Pure apart
// from the subscriber list (tests/edits.test.ts).
export type EditOp =
| { kind: 'config'; path: string; value?: unknown; remove?: boolean }
| { kind: 'listing'; store: 'play' | 'apple'; lang: string; field: string; value: string }
| { kind: 'contact'; field: 'firstName' | 'lastName' | 'phone' | 'email'; value: string };
/** Which editor (tab) a pending edit belongs to, for the unsaved marks. */
export type EditArea = 'listings' | 'appinfo' | 'catalog' | 'testflight' | 'art';
export interface Pending { key: string; op: EditOp; area: EditArea; /** The saved value, to show "was" and to drop an edit typed back to it. */ saved: unknown }
export const keyOf = (op: EditOp): string =>
op.kind === 'config' ? `config:${op.path}` : op.kind === 'listing' ? `listing:${op.store}:${op.lang}:${op.field}` : `contact:${op.field}`;
const valueOf = (op: EditOp): unknown => (op.kind === 'config' ? (op.remove ? undefined : op.value) : op.value);
const same = (a: unknown, b: unknown): boolean => {
const n = (v: unknown) => (v === undefined || v === null || v === '' ? null : v);
return JSON.stringify(n(a)) === JSON.stringify(n(b));
};
interface ProjectEdits { pending: Map<string, Pending>; undo: Map<string, Pending>[]; redo: Map<string, Pending>[]; lastKey: string | null; lastAt: number }
/** Typing into one field within this time is one undo step. */
export const COALESCE_MS = 1200;
export class EditStore {
private byProject = new Map<string, ProjectEdits>();
private subs = new Set<() => void>();
private version = 0;
constructor(private now: () => number = () => Date.now()) {}
private of(project: string): ProjectEdits {
let p = this.byProject.get(project);
if (!p) { p = { pending: new Map(), undo: [], redo: [], lastKey: null, lastAt: 0 }; this.byProject.set(project, p); }
return p;
}
private emit(): void { this.version++; for (const s of this.subs) s(); }
subscribe = (fn: () => void): (() => void) => { this.subs.add(fn); return () => { this.subs.delete(fn); }; };
snapshot = (): number => this.version;
/** Sets a field; a value equal to the saved one drops the pending edit. */
set(project: string, op: EditOp, area: EditArea, saved: unknown): void {
const p = this.of(project);
const key = keyOf(op);
const t = this.now();
if (!(p.lastKey === key && t - p.lastAt < COALESCE_MS)) { p.undo.push(new Map(p.pending)); if (p.undo.length > 200) p.undo.shift(); }
p.redo = [];
p.lastKey = key; p.lastAt = t;
if (same(valueOf(op), saved)) p.pending.delete(key);
else p.pending.set(key, { key, op, area, saved });
this.emit();
}
/** The pending value of a field, or `undefined` when it has none (use the saved value). */
get(project: string, key: string): Pending | undefined { return this.byProject.get(project)?.pending.get(key); }
list(project: string, area?: EditArea): Pending[] {
const all = [...(this.byProject.get(project)?.pending.values() ?? [])];
return area ? all.filter(p => p.area === area) : all;
}
count(project: string, area?: EditArea): number { return this.list(project, area).length; }
/** Every app with unsaved edits. */
dirtyProjects(): string[] { return [...this.byProject].filter(([, p]) => p.pending.size > 0).map(([k]) => k); }
canUndo(project: string): boolean { return (this.byProject.get(project)?.undo.length ?? 0) > 0; }
canRedo(project: string): boolean { return (this.byProject.get(project)?.redo.length ?? 0) > 0; }
undo(project: string): void {
const p = this.of(project);
const prev = p.undo.pop();
if (!prev) return;
p.redo.push(new Map(p.pending));
p.pending = prev; p.lastKey = null;
this.emit();
}
redo(project: string): void {
const p = this.of(project);
const next = p.redo.pop();
if (!next) return;
p.undo.push(new Map(p.pending));
p.pending = next; p.lastKey = null;
this.emit();
}
/** Drops the pending edits of one area (or all): after a save, or "Discard". Discard can be undone. */
clear(project: string, area?: EditArea, o: { undoable?: boolean } = {}): void {
const p = this.of(project);
if (o.undoable) p.undo.push(new Map(p.pending));
for (const [k, v] of p.pending) if (!area || v.area === area) p.pending.delete(k);
p.lastKey = null;
if (!o.undoable) { p.undo = []; p.redo = []; }
this.emit();
}
/** The ops of `storebound edit` for one area (or all), in a stable order. */
ops(project: string, area?: EditArea): EditOp[] { return this.list(project, area).sort((a, b) => a.key.localeCompare(b.key)).map(p => p.op); }
}
/** The window's one store of unsaved edits. */
export const edits = new EditStore();

View file

@ -15,6 +15,7 @@ export const TTL: Record<string, number> = {
'catalog.diff': 30 * MIN,
'prices.current': 30 * MIN,
'project.listings': 30 * MIN,
'project.editable': 30 * MIN,
'apple.ppo_status': 30 * MIN,
validate: 30 * MIN,
scan: 30 * MIN,
@ -38,7 +39,7 @@ export function isStale(method: string, at: string | null | undefined, now = Dat
export function lanesOf(method: string, o: { ios: boolean; android: boolean }): Lane[] {
if (method.startsWith('apple.')) return ['apple'];
if (method.startsWith('play.')) return ['play'];
if (['project.listings', 'scan', 'analytics.compare', 'aso.audit', 'reviews.drafts', 'project.declarations', 'project.renders', 'project.icon'].includes(method)) return ['local'];
if (['project.listings', 'project.editable', 'scan', 'analytics.compare', 'aso.audit', 'reviews.drafts', 'project.declarations', 'project.renders', 'project.icon'].includes(method)) return ['local'];
const l: Lane[] = [];
if (o.ios) l.push('apple');
if (o.android) l.push('play');

View file

@ -23,6 +23,8 @@ import { PricesTab } from './tabs/Prices';
import { ReleasesTab } from './tabs/Releases';
import { ReviewsTab } from './tabs/Reviews';
import { TestFlightTab } from './tabs/TestFlight';
import { EDIT_TABS, TabEditor, useUnsavedGuard } from './editors/TabEditor';
import { useEdits } from '../ui/Editor';
export const ascUrl = (appId: string | null | undefined): string => (appId ? `https://appstoreconnect.apple.com/apps/${encodeURIComponent(appId)}/distribution` : 'https://appstoreconnect.apple.com/apps');
export const PLAY_CONSOLE = 'https://play.google.com/console/';
@ -99,6 +101,8 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
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 ed = useEdits();
useUnsavedGuard();
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
const studio = d.studios.find(s => s.id === app.studio) ?? null;
const setTab = (x: AppTab) => nav.go({ screen: 'app', appId: app.key, tab: x });
@ -146,6 +150,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
{tabs.map((x, i) => (
<button key={x} ref={el => { tabRefs.current[i] = el; }} role="tab" id={`tab-${x}`} aria-selected={tab === x} aria-controls="tabpanel" tabIndex={tab === x ? 0 : -1} className="tab" onClick={() => setTab(x)}>
<Icon name={TAB_ICON[x]} />{t(TAB_KEY[x])}
{app.project && EDIT_TABS[x] && ed.count(app.project.id, EDIT_TABS[x]) > 0 && <span className="ed-dot" title={t('ed.unsaved', { count: ed.count(app.project.id, EDIT_TABS[x]) })} />}
</button>
))}
</div>
@ -154,7 +159,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
{tab === 'releases' && <ReleasesTab app={app} />}
{tab === 'catalog' && app.project && <CatalogTab project={app.project} />}
{tab === 'prices' && app.project && <PricesTab project={app.project} />}
{tab === 'listings' && app.project && <ListingsTab project={app.project} />}
{tab === 'listings' && app.project && <><TabEditor tab={tab} project={app.project} /><ListingsTab project={app.project} /></>}
{tab === 'art' && app.project && <ArtTab project={app.project} />}
{tab === 'health' && <HealthTab app={app} />}
{tab === 'reviews' && <ReviewsTab app={app} />}
@ -162,6 +167,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
{tab === 'growth' && <GrowthTab app={app} />}
{tab === 'appinfo' && app.project && <AppInfoTab project={app.project} />}
{tab === 'checks' && app.project && <ChecksTab project={app.project} />}
{tab !== 'listings' && app.project && !app.project.missing && EDIT_TABS[tab] && <TabEditor tab={tab} project={app.project} />}
</ErrorBoundary>
</div>
</>}

View file

@ -0,0 +1,152 @@
// App info editors (App info tab): App Store categories, age rating answers, content rights, price, export
// compliance, App Review (sign-in, notes, contact; the demo account stays masked and is never read out); Google Play
// store settings (category, countries, managed publishing, target ages, delete-account page); Data safety and App
// Privacy answers; the App Store promotion blocks (promoted purchases, custom product pages, in-app events). Saved
// into the config (and the review contact into the private file in .storebound/), then applied per store with the dry run first.
import type { EditableResult, Project } from '../../api';
import { type Key, t } from '../../i18n';
import { AGE_FREQUENCY, AGE_RATING_NONE, APPLE_CATEGORIES, APPLE_GAME_SUBCATEGORIES, KIDS_AGE_BANDS, PLAY_AGE_GROUPS, PLAY_APP_CATEGORIES, PLAY_GAME_CATEGORIES } from '../../../../../cli/lib/store-catalogs';
import { ApplyPanel, ChoiceField, EditBar, JsonField, ListField, TextField, useEdits } from '../../ui/Editor';
import { StoreGlyph } from '../../ui/icons';
import { Card } from '../../ui/kit';
type O = Record<string, unknown>;
const obj = (v: unknown): O => (v && typeof v === 'object' && !Array.isArray(v) ? (v as O) : {});
const str = (v: unknown): string => (typeof v === 'string' ? v : '');
const label = (id: string): string => id.replace(/^GAMES_/, '').toLowerCase().replace(/_/g, ' ').replace(/^\w/, c => c.toUpperCase());
/** "alcoholTobaccoOrDrugUseOrReferences" -> "Alcohol tobacco or drug use or references". */
const question = (q: string): string => q.replace(/([A-Z])/g, ' $1').toLowerCase().replace(/^\w/, c => c.toUpperCase());
const yesNo = (): [string, string][] => [['true', t('common.yes')], ['false', t('common.no')]];
function AppleInfo({ project, data }: { project: Project; data: EditableResult }) {
const apple = obj(data.config.apple);
const info = obj(apple.info);
const cats = obj(info.categories);
const age = obj(info.ageRating);
const ra = obj(data.config.reviewAccess);
const games = cats.primary === 'GAMES';
const catOpts = APPLE_CATEGORIES.map(c => [c, label(c)] as [string, string]);
const subOpts = APPLE_GAME_SUBCATEGORIES.map(c => [c, label(c)] as [string, string]);
const contact = data.contact;
return (
<>
<Card title={t('ed.appleInfoTitle')} icon="clipboard" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{t('ed.appleInfoSub')}</span>}>
<div className="ed-grid">
<ChoiceField project={project} area="appinfo" path="apple.info.categories.primary" saved={str(cats.primary)} label={t('ed.catPrimary')} options={catOpts} />
{games && <ChoiceField project={project} area="appinfo" path="apple.info.categories.primarySubOne" saved={str(cats.primarySubOne)} label={t('ed.catSub1')} options={subOpts} />}
{games && <ChoiceField project={project} area="appinfo" path="apple.info.categories.primarySubTwo" saved={str(cats.primarySubTwo)} label={t('ed.catSub2')} options={subOpts} />}
<ChoiceField project={project} area="appinfo" path="apple.info.categories.secondary" saved={str(cats.secondary)} label={t('ed.catSecondary')} options={catOpts} />
<ChoiceField project={project} area="appinfo" path="apple.info.contentRights" saved={str(info.contentRights)} label={t('ai.rights')}
options={[['DOES_NOT_USE_THIRD_PARTY_CONTENT', t('ai.rightsOwn')], ['USES_THIRD_PARTY_CONTENT', t('ai.rightsThird')]]} />
<ChoiceField project={project} area="appinfo" path="apple.info.free" saved={info.free === undefined ? null : (info.free as boolean)} label={t('ed.free')} options={[['true', t('ai.free')], ['false', t('ai.paid')]]} hint={t('ed.freeHint')} />
<ChoiceField project={project} area="appinfo" path="apple.usesNonExemptEncryption" saved={apple.usesNonExemptEncryption as boolean | undefined} label={t('ai.encTitle')}
options={[['false', t('ai.encNo')], ['true', t('ai.encYes')]]} hint={t('ed.encHint')} />
<TextField project={project} area="appinfo" op={{ kind: 'config', path: 'apple.name' }} saved={str(apple.name)} label={t('ed.appleName')} limit={30} placeholder={data.appName} hint={t('ed.appleNameHint')} />
</div>
</Card>
<Card title={t('ed.ageTitle')} icon="shield" sub={t('ed.ageSub')}>
<div className="ed-grid three">
{Object.entries(AGE_RATING_NONE).map(([q, none]) => (
<ChoiceField key={q} project={project} area="appinfo" path={`apple.info.ageRating.${q}`} saved={age[q] === undefined ? null : (age[q] as string | boolean | null)} label={question(q)}
options={q === 'kidsAgeBand' ? KIDS_AGE_BANDS.map(b => [b, label(b)] as [string, string]) : typeof none === 'boolean' ? yesNo() : AGE_FREQUENCY.map(f => [f, label(f)] as [string, string])}
hint={age[q] === undefined ? t('ed.ageDefault', { value: none === null ? t('ed.none') : typeof none === 'boolean' ? t('common.no') : label(String(none)) }) : undefined} />
))}
</div>
</Card>
<Card title={t('ai.reviewTitle')} icon="users" sub={t('ed.reviewSub')}>
<div className="ed-grid">
<ChoiceField project={project} area="appinfo" path="reviewAccess.signInRequired" saved={ra.signInRequired === undefined ? null : (ra.signInRequired as boolean)} label={t('ai.signIn')} options={yesNo()} />
<TextField project={project} area="appinfo" op={{ kind: 'config', path: 'apple.info.reviewNotes' }} saved={str(info.reviewNotes)} label={t('ai.notes')} multiline rows={5}
hint={t('ed.notesHint', { max: Number(data.limits.reviewNotesBytes) })} />
<TextField project={project} area="appinfo" op={{ kind: 'config', path: 'reviewAccess.instructions' }} saved={str(ra.instructions)} label={t('ed.signInSteps')} multiline rows={3} />
{(['firstName', 'lastName', 'phone', 'email'] as const).map(f => (
<TextField key={f} project={project} area="appinfo" op={{ kind: 'contact', field: f }} saved="" label={t(`ed.contact_${f}` as Key)} kind={f === 'email' ? 'email' : undefined}
placeholder={contact?.[f] || t('ed.notSet')} hint={f === 'email' ? t('ed.contactHint') : undefined} />
))}
<div className="ed-field"><div className="ed-head"><label>{t('ai.demo')}</label></div><span className="mono-id">{data.demoAccount ? `${data.demoAccount} · ••••••••` : t('ed.notSet')}</span><div className="ed-hint small muted">{t('ed.demoHint')}</div></div>
</div>
</Card>
</>
);
}
function PlaySettings({ project, data }: { project: Project; data: EditableResult }) {
const play = obj(data.config.play);
const acc = obj(data.config.accounts);
const cats: [string, string][] = [...PLAY_GAME_CATEGORIES.map(c => [c, `${t('ed.game')}: ${c}`] as [string, string]), ...PLAY_APP_CATEGORIES.map(c => [c, `${t('ed.app')}: ${c}`] as [string, string])];
return (
<Card title={t('ed.playSettingsTitle')} icon="sliders" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="android" className="ic13" />{t('ed.playSettingsSub')}</span>}>
<div className="ed-grid">
<ChoiceField project={project} area="appinfo" path="play.category" saved={str(play.category)} label={t('ed.playCategory')} options={cats} hint={t('ed.noApiHint')} />
<ChoiceField project={project} area="appinfo" path="play.managedPublishing" saved={play.managedPublishing === undefined ? null : (play.managedPublishing as boolean)} label={t('ed.managed')} options={yesNo()} hint={t('ed.noApiHint')} />
<ListField project={project} area="appinfo" path="play.countries" saved={(play.countries as string[] | undefined) ?? null} label={t('ed.countries')} upper hint={t('ed.countriesHint')} />
<ListField project={project} area="appinfo" path="play.targetAges" saved={(play.targetAges as string[] | undefined) ?? null} label={t('ed.targetAges')} hint={t('ed.targetAgesHint', { groups: PLAY_AGE_GROUPS.join(', ') })} />
<TextField project={project} area="appinfo" op={{ kind: 'config', path: 'accounts.deletionUrl' }} saved={str(acc.deletionUrl)} label={t('ed.deletionUrl')} kind="url" mono />
</div>
</Card>
);
}
function Declarations({ project, data }: { project: Project; data: EditableResult }) {
const ds = obj(data.config.dataSafety);
return (
<Card title={t('ed.declTitle')} icon="lock" sub={t('ed.declSub')}>
<div className="ed-grid">
{data.play && <ChoiceField project={project} area="appinfo" path="dataSafety.collectsData" saved={ds.collectsData === undefined ? null : (ds.collectsData as boolean)} label={t('ai.dsCollects')} options={yesNo()} />}
{data.play && <ChoiceField project={project} area="appinfo" path="dataSafety.encryptedInTransit" saved={ds.encryptedInTransit === undefined ? null : (ds.encryptedInTransit as boolean)} label={t('ai.dsEncrypted')} options={yesNo()} />}
{data.play && <ChoiceField project={project} area="appinfo" path="dataSafety.dataDeletion" saved={str(ds.dataDeletion)} label={t('ai.dsDeletion')} options={[['yes', t('common.yes')], ['no', t('common.no')], ['auto90', t('ai.dsAuto90')]]} />}
</div>
{data.play && <JsonField project={project} area="appinfo" path="dataSafety.data" saved={ds.data ?? null} label={t('ed.dsTypes')} hint={t('ed.dsTypesHint')} />}
{data.apple && <JsonField project={project} area="appinfo" path="appPrivacy" saved={data.config.appPrivacy} label={t('ed.apAnswers')} hint={t('ed.apHint')} />}
</Card>
);
}
function Promotion({ project, data }: { project: Project; data: EditableResult }) {
const apple = obj(data.config.apple);
return (
<Card title={t('ed.promoTitle')} icon="sparkle" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{t('ed.promoSub')}</span>}>
<JsonField project={project} area="appinfo" path="apple.promoted" saved={apple.promoted ?? null} label={t('ed.promoted')} rows={6} hint={t('ed.promotedHint')} />
<JsonField project={project} area="appinfo" path="apple.customProductPages" saved={apple.customProductPages ?? null} label={t('ed.cpp')} rows={8} hint={t('ed.cppHint')} />
<JsonField project={project} area="appinfo" path="apple.events" saved={apple.events ?? null} label={t('ed.events')} rows={8} hint={t('ed.eventsHint')} />
<div className="act-grid">
<ApplyPanel project={project} method="apple.cpp_create" label={t('ed.applyCpp')} done={t('ai.pushed')} blocked={null} />
<ApplyPanel project={project} method="apple.events_create" label={t('ed.applyEvents')} done={t('ai.pushed')} blocked={null} />
</div>
</Card>
);
}
export function AppInfoEditor({ project, data, onSaved }: { project: Project; data: EditableResult; onSaved: () => void }) {
const e = useEdits();
const unsaved = e.count(project.id, 'appinfo') ? t('ed.saveFirst') : null;
return (
<div className="stack" id="appinfo-editor" style={{ gap: 16 }}>
{data.apple && <AppleInfo project={project} data={data} />}
{data.play && <PlaySettings project={project} data={data} />}
<Declarations project={project} data={data} />
{data.apple && <Promotion project={project} data={data} />}
<EditBar project={project} area="appinfo" data={data} files={[data.files.config, data.files.contact]} onSaved={onSaved} />
<Card title={t('ed.applyTitle')} icon="upload" sub={t('ed.applyInfoSub')}>
<div className="act-grid">
{data.apple && (
<div className="act-col">
<div className="act-head"><StoreGlyph store="ios" />App Store</div>
<ApplyPanel project={project} method="apple.appinfo" label={t('ai.infoPush')} done={t('ai.pushed')} blocked={unsaved} />
<ApplyPanel project={project} method="apple.review_details" label={t('ai.reviewPush')} done={t('ai.pushed')} blocked={unsaved} />
<ApplyPanel project={project} method="apple.encryption" label={t('ai.encPush')} done={t('ai.pushed')} blocked={unsaved} />
</div>
)}
{data.play && (
<div className="act-col">
<div className="act-head"><StoreGlyph store="android" />Google Play</div>
<ApplyPanel project={project} method="play.data_safety" label={t('ai.dsUpload')} done={t('ai.dsUploaded')} blocked={unsaved} />
<ApplyPanel project={project} method="play.listing" label={t('ed.applyPlayDetails')} done={t('act.playListingDone')} blocked={unsaved} />
<span className="small muted">{t('ed.playConsoleOnly')}</span>
</div>
)}
</div>
</Card>
</div>
);
}

View file

@ -0,0 +1,57 @@
// Store art editor (Store art tab): the order of the screenshot scenes (the store shows them in this order) and the
// raw frame each scene shows (replace a screenshot by pointing the scene at another frame). Saved into the config;
// then render, and upload to each store with the dry run in the window (uploads replace the store's sets).
import type { EditableResult, Project } from '../../api';
import { t } from '../../i18n';
import { ApplyPanel, EditBar, TextField, useEdits } from '../../ui/Editor';
import { Icon, StoreGlyph } from '../../ui/icons';
import { Card, Empty } from '../../ui/kit';
import { keyOf } from '../../lib/edits';
type Scene = { id: string; palette?: unknown; frames?: { src: string }[] } & Record<string, unknown>;
export function ArtEditor({ project, data, onSaved }: { project: Project; data: EditableResult; onSaved: () => void }) {
const e = useEdits();
const saved = ((data.config as unknown as { shots?: { scenes?: Scene[] } | null }).shots?.scenes ?? []) as Scene[];
const op = { kind: 'config' as const, path: 'shots.scenes' };
const p = e.get(project.id, keyOf(op));
const scenes = (p && p.op.kind === 'config' ? p.op.value : saved) as Scene[];
const unsaved = e.count(project.id, 'art') ? t('ed.saveFirst') : null;
if (!saved.length) return <Card title={t('ed.artTitle')} icon="image"><Empty compact illo="text" title={t('ed.artEmpty')}>{t('ed.artEmptyText')}</Empty></Card>;
const move = (i: number, d: -1 | 1) => {
const next = [...scenes];
const j = i + d;
if (j < 0 || j >= next.length) return;
[next[i], next[j]] = [next[j]!, next[i]!];
e.set(project.id, { ...op, value: next }, 'art', saved);
};
return (
<div className="stack" id="art-editor" style={{ gap: 16 }}>
<Card title={t('ed.artTitle')} icon="image" sub={t('ed.artSub')} pad={false}>
<ol className="ed-scenes">
{scenes.map((s, i) => (
<li key={s.id} data-scene={s.id}>
<span className="num strong">{i + 1}</span>
<span className="mono-id strong">{s.id}</span>
<div className="ed-scene-frame">
<TextField project={project} area="art" op={{ kind: 'config', path: `shots.scenes[id=${s.id}].frames[0].src` }} saved={saved.find(x => x.id === s.id)?.frames?.[0]?.src ?? ''} label={t('ed.frame')} mono />
</div>
<div className="row" style={{ gap: 4 }}>
<button className="btn btn-xs btn-ghost" disabled={i === 0} onClick={() => move(i, -1)} aria-label={t('ed.moveUp', { id: s.id })}><Icon name="arrowLeft" className="rot90" />{t('ed.up')}</button>
<button className="btn btn-xs btn-ghost" disabled={i === scenes.length - 1} onClick={() => move(i, 1)} aria-label={t('ed.moveDown', { id: s.id })}><Icon name="arrowRight" className="rot90" />{t('ed.down')}</button>
</div>
</li>
))}
</ol>
</Card>
<EditBar project={project} area="art" data={data} files={[data.files.config]} onSaved={onSaved} />
<Card title={t('ed.applyTitle')} icon="upload" sub={t('ed.applyArtSub')}>
<div className="act-grid">
<div className="act-col"><div className="act-head"><Icon name="wand" />{t('act.render')}</div><ApplyPanel project={project} method="shots.render" label={t('act.renderShots')} done={t('act.rendered')} blocked={unsaved} /></div>
{data.play && <div className="act-col"><div className="act-head"><StoreGlyph store="android" />Google Play</div><ApplyPanel project={project} method="play.images" label={t('act.playImages')} done={t('act.uploaded')} blocked={unsaved} /></div>}
{data.apple && <div className="act-col"><div className="act-head"><StoreGlyph store="ios" />App Store</div><ApplyPanel project={project} method="apple.screenshots" label={t('act.appleShots')} done={t('act.uploaded')} blocked={unsaved} /><ApplyPanel project={project} method="apple.previews" label={t('act.applePreviews')} done={t('act.uploaded')} blocked={unsaved} /></div>}
</div>
</Card>
</div>
);
}

View file

@ -0,0 +1,148 @@
// Catalog editor (Catalog tab): every in-app product and subscription of the config with its names and descriptions
// per language (live counters against the store limits), base price in USD, reference name, review note, and the
// offer blocks (introductory and promotional offers, offer codes, win-back) as checked JSON. Saved into the config,
// then applied per store with the dry run in the window. A price change of a product that is already live goes
// through the owner's price decision (the Catalog tab's "Apply the price changes" owner actions).
import { useState } from 'react';
import type { EditableResult, Project } from '../../api';
import { t } from '../../i18n';
import { ApplyPanel, ChoiceField, EditBar, JsonField, ListField, NumberField, TextField, useEdits } from '../../ui/Editor';
import { Icon, StoreGlyph } from '../../ui/icons';
import { Card, Chip, Empty } from '../../ui/kit';
type O = Record<string, unknown>;
const obj = (v: unknown): O => (v && typeof v === 'object' && !Array.isArray(v) ? (v as O) : {});
const str = (v: unknown): string => (typeof v === 'string' ? v : '');
const PERIODS: [string, string][] = [['P1W', '1 week'], ['P1M', '1 month'], ['P2M', '2 months'], ['P3M', '3 months'], ['P6M', '6 months'], ['P1Y', '1 year']];
function Listings({ project, data, base, listings, limits, benefits }: { project: Project; data: EditableResult; base: string; listings: O; limits: { title: number; description: number }; benefits?: boolean }) {
return (
<div className="ed-langs">
{data.languages.map(l => {
const x = obj(listings[l.id]);
return (
<div className="ed-lang" key={l.id}>
<div className="small strong mono-id">{l.id}</div>
<div className="ed-grid">
<TextField project={project} area="catalog" op={{ kind: 'config', path: `${base}.listings.${l.id}.${'name' in x && !('title' in x) ? 'name' : 'title'}` }} saved={str(x.title ?? x.name)} label={t('ed.itemName')} limit={limits.title} />
<TextField project={project} area="catalog" op={{ kind: 'config', path: `${base}.listings.${l.id}.description` }} saved={str(x.description)} label={t('ed.itemDescription')} limit={limits.description} />
{benefits && <ListField project={project} area="catalog" path={`${base}.listings.${l.id}.benefits`} saved={(x.benefits as string[] | undefined) ?? null} label={t('ed.benefits')} hint={t('ed.benefitsHint')} />}
</div>
</div>
);
})}
</div>
);
}
function ProductCard({ project, data, p }: { project: Project; data: EditableResult; p: O }) {
const [open, setOpen] = useState(false);
const base = `products[id=${String(p.id)}]`;
const lim = data.limits.iap as Record<string, number>;
const e = useEdits();
const n = e.list(project.id, 'catalog').filter(x => x.key.startsWith(`config:${base}`)).length;
return (
<div className="ed-item" data-item={String(p.id)}>
<button className="ed-item-head" onClick={() => setOpen(o => !o)} aria-expanded={open}>
<Icon name={open ? 'chevronDown' : 'chevronRight'} /><span className="mono-id strong">{String(p.id)}</span><Chip tone="outline">{String(p.type)}</Chip>
<span className="num small muted">${String(p.usd)}</span>{n > 0 && <Chip tone="accent" icon="edit">{t('ed.unsaved', { count: n })}</Chip>}
</button>
{open && (
<div className="ed-item-body stack">
<div className="ed-grid">
<NumberField project={project} area="catalog" path={`${base}.usd`} saved={p.usd as number} label={t('ed.usd')} min={0} hint={t('ed.usdHint')} />
<TextField project={project} area="catalog" op={{ kind: 'config', path: `${base}.referenceName` }} saved={str(p.referenceName)} label={t('ed.refName')} limit={64} />
<TextField project={project} area="catalog" op={{ kind: 'config', path: `${base}.reviewNote` }} saved={str(p.reviewNote)} label={t('ed.reviewNote')} multiline rows={2} limit={4000} />
<TextField project={project} area="catalog" op={{ kind: 'config', path: `${base}.reviewScreenshot` }} saved={str(p.reviewScreenshot)} label={t('ed.reviewShot')} mono />
<ChoiceField project={project} area="catalog" path={`${base}.familySharable`} saved={p.familySharable === undefined ? null : (p.familySharable as boolean)} label={t('ed.family')} options={[['true', t('common.yes')], ['false', t('common.no')]]} />
</div>
<Listings project={project} data={data} base={base} listings={obj(p.listings)} limits={{ title: lim.name ?? 30, description: lim.description ?? 45 }} />
<JsonField project={project} area="catalog" path={`${base}.offerCodes`} saved={p.offerCodes ?? null} label={t('ed.offerCodes')} rows={5} hint={t('ed.offerCodesHint')} />
</div>
)}
</div>
);
}
function SubCard({ project, data, group, s }: { project: Project; data: EditableResult; group: string; s: O }) {
const [open, setOpen] = useState(false);
const base = `subscriptions[name=${group}].subscriptions[id=${String(s.id)}]`;
const lim = data.limits.appleSub as Record<string, number>;
return (
<div className="ed-item" data-item={String(s.id)}>
<button className="ed-item-head" onClick={() => setOpen(o => !o)} aria-expanded={open}>
<Icon name={open ? 'chevronDown' : 'chevronRight'} /><span className="mono-id strong">{String(s.id)}</span><Chip tone="outline">{String(s.period)}</Chip><span className="num small muted">${String(s.usd)}</span>
</button>
{open && (
<div className="ed-item-body stack">
<div className="ed-grid">
<NumberField project={project} area="catalog" path={`${base}.usd`} saved={s.usd as number} label={t('ed.usd')} min={0} hint={t('ed.usdHint')} />
<NumberField project={project} area="catalog" path={`${base}.level`} saved={s.level as number} label={t('ed.level')} min={1} step="1" hint={t('ed.levelHint')} />
<ChoiceField project={project} area="catalog" path={`${base}.period`} saved={str(s.period)} label={t('ed.period')} options={PERIODS} allowEmpty={false} hint={t('ed.periodHint')} />
<TextField project={project} area="catalog" op={{ kind: 'config', path: `${base}.referenceName` }} saved={str(s.referenceName)} label={t('ed.refName')} limit={64} />
<TextField project={project} area="catalog" op={{ kind: 'config', path: `${base}.reviewNote` }} saved={str(s.reviewNote)} label={t('ed.reviewNote')} multiline rows={2} limit={4000} />
</div>
<Listings project={project} data={data} base={base} listings={obj(s.listings)} limits={{ title: lim.name ?? 30, description: lim.description ?? 45 }} benefits />
<JsonField project={project} area="catalog" path={`${base}.intro`} saved={s.intro ?? null} label={t('ed.intro')} rows={4} hint={t('ed.introHint')} />
<JsonField project={project} area="catalog" path={`${base}.offers`} saved={s.offers ?? null} label={t('ed.offers')} rows={5} />
<JsonField project={project} area="catalog" path={`${base}.offerCodes`} saved={s.offerCodes ?? null} label={t('ed.offerCodes')} rows={5} hint={t('ed.offerCodesHint')} />
<JsonField project={project} area="catalog" path={`${base}.winBack`} saved={s.winBack ?? null} label={t('ed.winBack')} rows={5} hint={t('ed.winBackHint')} />
</div>
)}
</div>
);
}
export function CatalogEditor({ project, data, onSaved }: { project: Project; data: EditableResult; onSaved: () => void }) {
const e = useEdits();
const products = data.config.products as O[];
const groups = data.config.subscriptions as O[];
const unsaved = e.count(project.id, 'catalog') ? t('ed.saveFirst') : null;
if (!products.length && !groups.length) {
return <Card title={t('ed.catalogTitle')} icon="edit"><Empty compact illo="catalog" title={t('ed.catalogEmpty')}>{t('ed.catalogEmptyText')}</Empty></Card>;
}
return (
<div className="stack" id="catalog-editor" style={{ gap: 16 }}>
{products.length > 0 && (
<Card title={t('ed.productsTitle')} icon="edit" sub={t('ed.productsSub')} pad={false}>
<div className="ed-items">{products.map(p => <ProductCard key={String(p.id)} project={project} data={data} p={p} />)}</div>
</Card>
)}
{groups.map(g => (
<Card key={String(g.name)} title={t('ed.groupTitle', { name: String(g.name) })} icon="layers" sub={t('ed.groupSub')} pad={false}>
<div className="card-body">
<div className="ed-langs">
{data.languages.map(l => (
<div className="ed-lang" key={l.id}><div className="small strong mono-id">{l.id}</div>
<TextField project={project} area="catalog" op={{ kind: 'config', path: `subscriptions[name=${String(g.name)}].listings.${l.id}.name` }} saved={str(obj(obj(g.listings)[l.id]).name)} label={t('ed.groupName')} limit={30} />
</div>
))}
</div>
</div>
<div className="ed-items">{(g.subscriptions as O[]).map(s => <SubCard key={String(s.id)} project={project} data={data} group={String(g.name)} s={s} />)}</div>
</Card>
))}
<EditBar project={project} area="catalog" data={data} files={[data.files.config]} onSaved={onSaved} />
<Card title={t('ed.applyTitle')} icon="upload" sub={t('ed.applyCatalogSub')}>
<div className="act-grid">
{data.play && (
<div className="act-col">
<div className="act-head"><StoreGlyph store="android" />Google Play</div>
{products.length > 0 && <ApplyPanel project={project} method="play.products" label={t('ed.applyPlayProducts')} done={t('ai.pushed')} blocked={unsaved} />}
{groups.length > 0 && <ApplyPanel project={project} method="play.subscriptions" label={t('ed.applyPlaySubs')} done={t('ai.pushed')} blocked={unsaved} />}
</div>
)}
{data.apple && (
<div className="act-col">
<div className="act-head"><StoreGlyph store="ios" />App Store</div>
{products.length > 0 && <ApplyPanel project={project} method="apple.iap" label={t('ed.applyAppleIap')} done={t('ai.pushed')} blocked={unsaved} />}
{groups.length > 0 && <ApplyPanel project={project} method="apple.subscriptions" label={t('ed.applyAppleSubs')} done={t('ai.pushed')} blocked={unsaved} />}
{products.length > 0 && <ApplyPanel project={project} method="apple.iap_review" label={t('ed.applyIapReview')} done={t('act.uploaded')} blocked={unsaved} />}
</div>
)}
</div>
<p className="small muted act-note"><Icon name="shield" />{t('ed.priceGate')}</p>
</Card>
</div>
);
}

View file

@ -0,0 +1,163 @@
// The listing editor (Listings tab): per language, the Google Play and App Store texts side by side with live
// counters against the store limits and the checks of `check --aso` / `scan` as the owner types; the shared store
// details (URLs, contact, copyright, promo video); Save into store/listing.md, store/appstore.md and the config
// (project.edit), Pull the live texts into the files (diff first), and Apply to each store (dry run in the window).
import { useState } from 'react';
import type { EditableListing, EditableResult, Project } from '../../api';
import { type Key, t } from '../../i18n';
import { appleLocaleIssues, playLangIssues, type FieldIssue } from '../../../../../cli/lib/listing-rules';
import { keyOf } from '../../lib/edits';
import { useData, useWrite } from '../../store';
import { ApplyPanel, EditBar, type FieldIssueView, TextField, useEdits } from '../../ui/Editor';
import { Icon, StoreGlyph } from '../../ui/icons';
import { Card, Chip, Notice } from '../../ui/kit';
import { WriteButton } from '../../ui/WriteButton';
const PLAY: { f: string; label: Key; multiline?: boolean; rows?: number; mono?: boolean }[] = [
{ f: 'title', label: 'lst.title' }, { f: 'short', label: 'lst.short' }, { f: 'full', label: 'lst.full', multiline: true, rows: 10 }, { f: 'whatsNew', label: 'lst.whatsNew', multiline: true, rows: 4 },
];
const APPLE: { f: string; label: Key; multiline?: boolean; rows?: number; mono?: boolean }[] = [
{ f: 'name', label: 'lst.name' }, { f: 'subtitle', label: 'lst.subtitle' }, { f: 'promotionalText', label: 'lst.promo', multiline: true, rows: 3 },
{ f: 'keywords', label: 'lst.keywords', mono: true }, { f: 'description', label: 'lst.description', multiline: true, rows: 10 }, { f: 'whatsNew', label: 'lst.whatsNew', multiline: true, rows: 4 },
];
/** The values of one listing language with the unsaved edits on top. */
function current(e: ReturnType<typeof useEdits>, project: Project, store: 'play' | 'apple', l: EditableListing, fields: string[]): Record<string, string> {
const out: Record<string, string> = {};
for (const f of fields) {
const p = e.get(project.id, keyOf({ kind: 'listing', store, lang: l.lang, field: f, value: '' }));
out[f] = p && p.op.kind === 'listing' ? p.op.value : l.values[f] ?? '';
}
return out;
}
const byField = (issues: FieldIssue[], f: string): FieldIssueView[] => issues.filter(i => i.field === f).map(i => ({ level: i.level, msg: i.msg }));
function StoreColumn({ project, data, store, l }: { project: Project; data: EditableResult; store: 'play' | 'apple'; l: EditableListing }) {
const e = useEdits();
const defs = store === 'play' ? PLAY : APPLE;
const lim = (data.limits[store] ?? {}) as Record<string, number>;
const vals = current(e, project, store, l, defs.map(d => d.f));
const issues = store === 'play'
? playLangIssues(l.lang, { title: vals.title || undefined, short: vals.short, full: vals.full, whatsNew: vals.whatsNew }, data.appName)
: appleLocaleIssues(l.lang, Object.fromEntries(Object.entries(vals).filter(([, v]) => v)), data.appleName);
// a new section starts empty: say what is required instead of a wall of errors
const fresh = !l.exists && Object.values(vals).every(v => !v);
return (
<div className="ed-col" data-store={store} data-lang={l.lang}>
<div className="act-head"><StoreGlyph store={store === 'play' ? 'android' : 'ios'} />{store === 'play' ? 'Google Play' : 'App Store'}<span className="mono-id small muted">{l.lang}</span>
{l.status && <Chip tone="warn">{l.status === 'draft' ? t('ed.statusDraft') : t('ed.statusTranslate')}</Chip>}
{(l.codes?.length ?? 0) > 1 && <Chip tone="outline">{t('ed.alsoServes', { codes: l.codes!.filter(c => c !== l.lang).join(', ') })}</Chip>}
</div>
{fresh && <Notice tone="info" icon="info">{t('ed.newSection', { lang: l.lang })}</Notice>}
{defs.map(d => (
<TextField key={d.f} project={project} area="listings" op={{ kind: 'listing', store, lang: l.lang, field: d.f }} saved={l.values[d.f] ?? ''}
label={t(d.label)} limit={lim[d.f]} multiline={d.multiline} rows={d.rows} mono={d.mono}
placeholder={d.f === 'title' ? data.appName : d.f === 'name' ? data.appleName : undefined}
hint={d.f === 'keywords' ? t('ed.keywordsHint') : d.f === 'whatsNew' && store === 'apple' ? t('ed.whatsNewApple') : d.f === 'title' || d.f === 'name' ? t('ed.nameHint') : undefined}
issues={fresh ? [] : byField(issues, d.f === 'title' && store === 'play' ? 'title' : d.f)} />
))}
</div>
);
}
const DETAILS: { path: string; label: Key; kind?: 'url' | 'email'; store?: 'play' | 'apple'; hint?: Key }[] = [
{ path: 'app.supportUrl', label: 'ed.supportUrl', kind: 'url' }, { path: 'app.marketingUrl', label: 'ed.marketingUrl', kind: 'url' },
{ path: 'app.privacyPolicyUrl', label: 'ed.privacyUrl', kind: 'url' }, { path: 'app.termsUrl', label: 'ed.termsUrl', kind: 'url', hint: 'ed.termsHint' },
{ path: 'app.website', label: 'ed.website', kind: 'url', hint: 'ed.websiteHint' }, { path: 'app.contactEmail', label: 'ed.contactEmail', kind: 'email' },
{ path: 'app.copyright', label: 'ed.copyright' }, { path: 'play.video', label: 'ed.video', kind: 'url', store: 'play', hint: 'ed.videoHint' },
];
export function StoreDetails({ project, data }: { project: Project; data: EditableResult }) {
const c = data.config;
const get = (p: string): string => {
const [a, b] = p.split('.') as [string, string];
const o = (c as unknown as Record<string, Record<string, unknown> | null>)[a];
return o && typeof o[b] === 'string' ? (o[b] as string) : '';
};
return (
<div className="ed-grid">
{DETAILS.filter(d => !d.store || (d.store === 'play' ? !!data.play : !!data.apple)).map(d => {
const v = get(d.path);
return <TextField key={d.path} project={project} area="listings" op={{ kind: 'config', path: d.path }} saved={v} label={t(d.label)} kind={d.kind} mono={!!d.kind}
hint={d.hint ? t(d.hint) : undefined} />;
})}
</div>
);
}
export function ListingEditor({ project, data, onSaved }: { project: Project; data: EditableResult; onSaved: () => void }) {
const d = useData();
const e = useEdits();
const w = useWrite(project.id);
const live = d.liveOf(project);
const [lang, setLang] = useState(data.languages[0]?.id ?? '');
const [code, setCode] = useState<string | null>(null);
const [version, setVersion] = useState(live?.release.next?.ios?.version ?? live?.release.next?.version ?? '');
const L = data.languages.find(x => x.id === lang) ?? data.languages[0];
const playL = data.play?.filter(x => x.language === L?.id) ?? [];
const pl = playL.find(x => x.lang === code) ?? playL[0];
const al = data.apple?.find(x => x.language === L?.id);
const unsaved = e.count(project.id, 'listings');
const okVersion = /^\d+(\.\d+){0,2}$/.test(version);
const files = [data.files.play, data.files.apple, data.files.config];
return (
<>
<Card title={t('ed.listingTitle')} icon="edit" sub={t('ed.listingSub')} id="listing-editor"
actions={<>
{data.play && <WriteButton run={w} icon="download" w={{ method: 'play.listing_pull', args: {}, title: t('ed.pullPlay'), done: t('ed.pulled'), after: onSaved }} hint={unsaved ? t('ed.pullDirty') : t('ed.pullHint')}>{t('ed.pullPlay')}</WriteButton>}
{data.apple && <WriteButton run={w} icon="download" w={{ method: 'apple.listing_pull', args: {}, title: t('ed.pullApple'), done: t('ed.pulled'), after: onSaved }} hint={unsaved ? t('ed.pullDirty') : t('ed.pullHint')}>{t('ed.pullApple')}</WriteButton>}
</>}>
<div className="stack" style={{ gap: 12 }}>
{unsaved > 0 && <Notice tone="info" icon="info">{t('ed.pullNote')}</Notice>}
<div className="row" style={{ gap: 8, flexWrap: 'wrap' }} role="tablist" aria-label={t('col.language')}>
{data.languages.map(x => {
const n = e.list(project.id, 'listings').filter(p => p.op.kind === 'listing' && (p.op.lang === x.apple || x.play.includes(p.op.lang))).length;
return (
<button key={x.id} role="tab" aria-selected={x.id === L?.id} className={`btn btn-sm${x.id === L?.id ? ' btn-primary' : ''}`} onClick={() => { setLang(x.id); setCode(null); }} data-lang-tab={x.id}>
<Icon name="globe" />{x.id}{n > 0 && <span className="ed-dot" title={t('ed.unsaved', { count: n })} />}
</button>
);
})}
</div>
{playL.length > 1 && (
<div className="row" style={{ gap: 6 }}>
<span className="small muted">{t('ed.playCode')}</span>
{playL.map(x => <button key={x.lang} className={`btn btn-xs${x.lang === pl?.lang ? ' btn-primary' : ' btn-ghost'}`} onClick={() => setCode(x.lang)}>{x.lang}</button>)}
</div>
)}
{L?.rtl && <Notice tone="info" icon="info">{t('ed.rtl')}</Notice>}
<div className="ed-cols" dir={L?.rtl ? 'rtl' : undefined}>
{pl && <StoreColumn project={project} data={data} store="play" l={pl} />}
{al && <StoreColumn project={project} data={data} store="apple" l={al} />}
{!pl && !al && <Notice tone="warn">{t('ed.noStoreForLang', { lang: L?.id ?? '' })}</Notice>}
</div>
</div>
</Card>
<Card title={t('ed.detailsTitle')} icon="link" sub={t('ed.detailsSub')}>
<StoreDetails project={project} data={data} />
</Card>
<EditBar project={project} area="listings" data={data} files={files} onSaved={onSaved} />
<Card title={t('ed.applyTitle')} icon="upload" sub={t('ed.applySub')}>
<div className="act-grid">
{data.play && (
<div className="act-col">
<div className="act-head"><StoreGlyph store="android" />Google Play</div>
<ApplyPanel project={project} method="play.listing" label={t('ed.applyPlay')} done={t('act.playListingDone')} blocked={unsaved ? t('ed.saveFirst') : null} />
</div>
)}
{data.apple && (
<div className="act-col">
<div className="act-head"><StoreGlyph store="ios" />App Store</div>
<div className="row" style={{ gap: 8 }}>
<label className="small muted" htmlFor="ed-version">{t('act.version')}</label>
<input id="ed-version" className="input input-sm mono" style={{ width: 90 }} value={version} onChange={ev => setVersion(ev.target.value.trim())} placeholder="2.4.0" />
</div>
<ApplyPanel project={project} method="apple.listing" args={{ version }} label={t('ed.applyApple', { version: version || '…' })} done={t('act.appleListingDone')} disabled={!okVersion} blocked={unsaved ? t('ed.saveFirst') : null} />
</div>
)}
</div>
<p className="small muted act-note"><Icon name="shield" />{t('ed.applyNote')}</p>
</Card>
</>
);
}

View file

@ -0,0 +1,47 @@
// The editor of an app tab (console parity: every value the stores' consoles let the owner edit and storebound can
// write is edited here): loads what the files hold once (project.editable) and shows the tab's editor.
import { useEffect } from 'react';
import type { Project } from '../../api';
import { t } from '../../i18n';
import { edits } from '../../lib/edits';
import type { AppTab } from '../../nav';
import { useEditable } from '../../ui/Editor';
import { Card, ErrorNotice, Skeleton } from '../../ui/kit';
import { AppInfoEditor } from './AppInfoEditor';
import { ArtEditor } from './ArtEditor';
import { CatalogEditor } from './CatalogEditor';
import { ListingEditor } from './ListingEditor';
import { TestFlightEditor } from './TestFlightEditor';
/** Tabs with an editor, and the edit area each one keeps its unsaved edits in. */
export const EDIT_TABS: Partial<Record<AppTab, 'listings' | 'appinfo' | 'catalog' | 'testflight' | 'art'>> = {
listings: 'listings', appinfo: 'appinfo', catalog: 'catalog', testflight: 'testflight', art: 'art',
};
export function TabEditor({ tab, project }: { tab: AppTab; project: Project }) {
const r = useEditable(project);
if (!EDIT_TABS[tab]) return null;
if (!r.data) return r.error ? <ErrorNotice error={t('ed.loadFailed', { error: r.error })} tone="bad" /> : <Card><Skeleton lines={4} block /></Card>;
const p = { project, data: r.data, onSaved: () => { r.refresh(); } };
switch (tab) {
case 'listings': return <ListingEditor {...p} />;
case 'appinfo': return <AppInfoEditor {...p} />;
case 'catalog': return <CatalogEditor {...p} />;
case 'testflight': return <TestFlightEditor {...p} />;
case 'art': return <ArtEditor {...p} />;
default: return null;
}
}
/** Closing or reloading the window with unsaved edits asks first. */
export function useUnsavedGuard(): void {
useEffect(() => {
const on = (ev: BeforeUnloadEvent) => {
if (!edits.dirtyProjects().length) return;
ev.preventDefault();
ev.returnValue = '';
};
window.addEventListener('beforeunload', on);
return () => window.removeEventListener('beforeunload', on);
}, []);
}

View file

@ -0,0 +1,48 @@
// TestFlight editor (TestFlight tab): What to Test per locale (### whatToTest in the App Store listing file), the
// Beta App Description per language, notes for Beta App Review, and the beta groups (checked JSON). Saved into the
// files, then applied with the dry run in the window: test information, and creating the missing groups. Sending a
// build to external testers and Beta App Review stay the owner's terminal steps.
import type { EditableResult, Project } from '../../api';
import { t } from '../../i18n';
import { ApplyPanel, EditBar, JsonField, TextField, useEdits } from '../../ui/Editor';
import { Card } from '../../ui/kit';
type O = Record<string, unknown>;
const obj = (v: unknown): O => (v && typeof v === 'object' && !Array.isArray(v) ? (v as O) : {});
export function TestFlightEditor({ project, data, onSaved }: { project: Project; data: EditableResult; onSaved: () => void }) {
const e = useEdits();
const tf = obj(obj(data.config.apple).testflight);
const desc = tf.description;
const unsaved = e.count(project.id, 'testflight') ? t('ed.saveFirst') : null;
if (!data.apple) return null;
return (
<div className="stack" id="testflight-editor" style={{ gap: 16 }}>
<Card title={t('ed.tfTitle')} icon="edit" sub={t('ed.tfSub')}>
<div className="ed-langs">
{data.apple.map(l => (
<div className="ed-lang" key={l.lang}>
<div className="small strong mono-id">{l.lang}</div>
<TextField project={project} area="testflight" op={{ kind: 'listing', store: 'apple', lang: l.lang, field: 'whatToTest' }} saved={l.values.whatToTest ?? ''}
label={t('ed.whatToTest')} limit={4000} multiline rows={3} placeholder={l.values.whatsNew ?? ''} hint={l.values.whatToTest ? undefined : t('ed.whatToTestHint')} />
{typeof desc !== 'string' && (
<TextField project={project} area="testflight" op={{ kind: 'config', path: `apple.testflight.description.${l.language}` }} saved={typeof obj(desc)[l.language] === 'string' ? String(obj(desc)[l.language]) : ''}
label={t('ed.betaDescription')} limit={4000} multiline rows={3} />
)}
</div>
))}
</div>
{typeof desc === 'string' && <TextField project={project} area="testflight" op={{ kind: 'config', path: 'apple.testflight.description' }} saved={desc} label={t('ed.betaDescriptionAll')} limit={4000} multiline rows={3} />}
<TextField project={project} area="testflight" op={{ kind: 'config', path: 'apple.testflight.reviewNotes' }} saved={typeof tf.reviewNotes === 'string' ? tf.reviewNotes : ''} label={t('ed.betaNotes')} limit={4000} multiline rows={3} />
<JsonField project={project} area="testflight" path="apple.testflight.groups" saved={tf.groups ?? null} label={t('ed.tfGroups')} rows={6} hint={t('ed.tfGroupsHint')} />
</Card>
<EditBar project={project} area="testflight" data={data} files={[data.files.apple, data.files.config]} onSaved={onSaved} />
<Card title={t('ed.applyTitle')} icon="upload" sub={t('ed.applyTfSub')}>
<div className="act-grid">
<ApplyPanel project={project} method="apple.testflight_info" label={t('ed.applyTfInfo')} done={t('ai.pushed')} blocked={unsaved} />
<ApplyPanel project={project} method="apple.testflight_groups" label={t('tf.groupsSync')} done={t('tf.groupsSynced')} blocked={unsaved} />
</div>
</Card>
</div>
);
}

View file

@ -61,17 +61,14 @@ function StoreGrid({ store, locales }: { store: 'play' | 'apple'; locales: Listi
);
}
/** Push the listing text, upload the art, and render it (each a dry run, a native dialog, then the real run). */
/** Upload the art and render it (each a dry run, a native dialog, then the real run). The listing text is edited and
* applied in the listing editor above (screens/editors/ListingEditor.tsx). */
function StoreActions({ project, langs }: { project: Project; langs: string[] }) {
const d = useData();
const w = useWrite(project.id);
const l = d.liveOf(project);
const e = d.statusOf(project);
const suggested = l?.release.next?.ios?.version ?? l?.release.next?.version ?? '';
const [version, setVersion] = useState(suggested);
const [lang, setLang] = useState('');
const ios = !!e?.app?.platforms.ios, android = !!e?.app?.platforms.android;
const okVersion = /^\d+(\.\d+){0,2}$/.test(version);
const only = lang ? { lang } : {};
return (
<Card title={t('act.storeTitle')} icon="upload" sub={t('act.storeSub')}>
@ -79,18 +76,12 @@ function StoreActions({ project, langs }: { project: Project; langs: string[] })
{android && (
<div className="act-col">
<div className="act-head"><StoreGlyph store="android" />Google Play</div>
<WriteButton run={w} icon="text" w={{ method: 'play.listing', args: {}, title: t('act.playListing'), done: t('act.playListingDone') }}>{t('act.playListing')}</WriteButton>
<WriteButton run={w} icon="image" w={{ method: 'play.images', args: {}, title: t('act.playImages'), done: t('act.uploaded') }} hint={t('act.slowHint')}>{t('act.playImages')}</WriteButton>
</div>
)}
{ios && (
<div className="act-col">
<div className="act-head"><StoreGlyph store="ios" />App Store</div>
<div className="row" style={{ gap: 8 }}>
<label className="small muted" htmlFor="act-version">{t('act.version')}</label>
<input id="act-version" className="input input-sm mono" style={{ width: 90 }} value={version} onChange={ev => setVersion(ev.target.value.trim())} placeholder="2.4.0" />
</div>
<WriteButton run={w} icon="text" disabled={!okVersion} w={{ method: 'apple.listing', args: { version }, title: t('act.appleListing', { version }), done: t('act.appleListingDone') }}>{t('act.appleListing', { version: version || '…' })}</WriteButton>
<WriteButton run={w} icon="image" w={{ method: 'apple.screenshots', args: {}, title: t('act.appleShots'), done: t('act.uploaded') }} hint={t('act.slowHint')}>{t('act.appleShots')}</WriteButton>
<WriteButton run={w} icon="play" w={{ method: 'apple.previews', args: {}, title: t('act.applePreviews'), done: t('act.uploaded') }} hint={t('act.slowHint')}>{t('act.applePreviews')}</WriteButton>
</div>

View file

@ -957,3 +957,52 @@ button.ck.ck-pick[aria-checked="true"] { border-color: var(--accent-solid); box-
.crash-msg, .crash-detail { margin: 0; padding: 10px 12px; border-radius: 8px; background: var(--bg-sunken); color: var(--ink); font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 320px; overflow: auto; }
.crash-box summary { cursor: pointer; color: var(--ink-2); font-size: 13px; }
.crash-box .actions { display: flex; gap: 8px; flex-wrap: wrap; }
/* ---- editors (ui/Editor.tsx, screens/editors) ---- */
.btn-xs { height: 22px; padding: 0 7px; font-size: var(--t-micro); border-radius: 6px; gap: 4px; }
.btn-xs svg { width: 12px; height: 12px; }
.ed-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 18px; align-items: start; }
.ed-col { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.ed-col .act-head { flex-wrap: wrap; }
.ed-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px 16px; align-items: start; }
.ed-grid.three { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.ed-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.ed-field .input { width: 100%; max-width: none; }
.ed-field textarea.input { min-height: 0; }
.ed-field.dirty .input { border-color: var(--accent-line); background: color-mix(in srgb, var(--accent-soft) 45%, var(--surface)); }
.ed-head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; font-size: var(--t-meta); color: var(--muted); }
.ed-head label { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; color: var(--ink-2, var(--ink)); }
.ed-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--accent-solid); margin-left: 6px; flex: none; }
.ed-count { font-size: var(--t-micro); color: var(--muted); font-variant-numeric: tabular-nums; }
.ed-count.warn { color: var(--warn-ink); font-weight: 600; }
.ed-count.bad { color: var(--bad-ink); font-weight: 700; }
.ed-was { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.ed-was-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.ed-issues { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; font-size: var(--t-meta); }
.ed-issues li { display: flex; gap: 6px; align-items: flex-start; line-height: 1.4; }
.ed-issues li svg { flex: none; margin-top: 2px; }
.ed-issues li.error { color: var(--bad-ink); }
.ed-issues li.warn { color: var(--warn-ink); }
.ed-issues li.info { color: var(--muted); }
.ed-bar { position: sticky; bottom: 0; z-index: 5; padding: 10px 14px; border: 1px solid var(--line); border-radius: var(--r); background: var(--surface); box-shadow: 0 -4px 14px rgba(0, 0, 0, .05); }
.ed-bar.active { border-color: var(--accent-line); }
.ed-review { margin-top: 10px; display: flex; flex-direction: column; gap: 10px; }
.ed-review pre, .ed-diff pre { margin: 0; max-height: 320px; overflow: auto; padding: 10px 12px; border-radius: var(--r-sm); background: var(--code-bg); color: var(--code-ink); font: 12px/1.45 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; }
.ed-dry.bad { outline: 1px solid var(--bad-solid); }
.ed-changes { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.ed-changes li { min-width: 0; overflow-wrap: anywhere; }
.ed-from { color: var(--muted); text-decoration: line-through; }
.ed-to { color: var(--ink); font-weight: 600; }
.ed-diff summary { cursor: pointer; }
.ed-apply { display: flex; flex-direction: column; gap: 6px; width: 100%; }
.ed-langs { display: flex; flex-direction: column; gap: 12px; }
.ed-lang { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--r-sm); }
.ed-items { display: flex; flex-direction: column; }
.ed-item { border-top: 1px solid var(--line); }
.ed-item-head { display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 14px; background: none; border: 0; color: inherit; text-align: left; cursor: pointer; font: inherit; }
.ed-item-head:hover { background: var(--hover); }
.ed-item-body { padding: 4px 14px 14px; gap: 12px; }
.ed-scenes { list-style: none; margin: 0; padding: 0; }
.ed-scenes li { display: grid; grid-template-columns: 28px minmax(80px, 140px) minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 10px 14px; border-top: 1px solid var(--line); }
.ed-scene-frame { min-width: 0; }
.rot90 { transform: rotate(90deg); }

View file

@ -0,0 +1,298 @@
// The building blocks of the window's editors (console parity, docs/console-parity.md):
// - useEditable: the values the files hold (project.editable = `storebound edit --show --json`, offline)
// - fields (text, multi-line, number, yes / no, choice, list) that keep unsaved edits in lib/edits.ts, with the
// store limit as a live counter, the checks of `check` / `scan` as the owner types, and "was: …" with a reset
// - EditBar: unsaved count, undo / redo, discard, and "Review and save": the dry run of project.edit in the window
// (old -> new, the diff per file, the checks, git), then Save (native dialog, then the files are written)
// - ApplyPanel: a store write's dry run in the window (old -> new per store and language), then Apply (native
// dialog, the real run)
// - GitNote: the edited files are uncommitted in git (storebound never commits)
import { useCallback, useEffect, useState, useSyncExternalStore, type ReactNode } from 'react';
import { api, type EditableResult, type EditResult, type Project, type ToolResult } from '../api';
import { t } from '../i18n';
import { type EditArea, type EditOp, edits, keyOf } from '../lib/edits';
import { failureText } from '../lib/results';
import { useRead, useWrite } from '../store';
import { Icon, type IconName } from './icons';
import { Chip, Notice } from './kit';
import { emailIssue, urlIssue } from '../../../../cli/lib/listing-rules';
import { WriteButton } from './WriteButton';
/** Characters as the stores count them (code points). */
export const chars = (s: string): number => [...s].length;
export function useEditable(project: Project) {
return useRead<EditableResult>(project.id, 'project.editable', { network: false });
}
/** Re-renders on every change of the unsaved edits. */
export function useEdits(): typeof edits {
useSyncExternalStore(edits.subscribe, edits.snapshot);
return edits;
}
export interface FieldIssueView { level: 'error' | 'warn' | 'info'; msg: string }
/** The current value of a field: the unsaved edit, else the saved one. */
export function useValue<T>(project: Project, op: EditOp, saved: T): { value: T; dirty: boolean } {
const e = useEdits();
const p = e.get(project.id, keyOf(op));
if (!p) return { value: saved, dirty: false };
const v = p.op.kind === 'config' ? (p.op.remove ? undefined : p.op.value) : p.op.value;
return { value: (v ?? (typeof saved === 'string' ? '' : v)) as T, dirty: true };
}
function Counter({ n, limit }: { n: number; limit: number }) {
const tone = n > limit ? 'bad' : n > limit * 0.95 ? 'warn' : '';
return <span className={`ed-count num ${tone}`} aria-label={t('ed.countAria', { n, limit })}>{n} / {limit}</span>;
}
function Issues({ issues }: { issues: FieldIssueView[] }) {
if (!issues.length) return null;
return (
<ul className="ed-issues">
{issues.map((i, k) => <li key={k} className={i.level}><Icon name={i.level === 'error' ? 'alertCircle' : i.level === 'warn' ? 'alert' : 'info'} className="ic13" />{i.msg}</li>)}
</ul>
);
}
interface FieldBase { project: Project; area: EditArea; label: string; hint?: ReactNode; issues?: FieldIssueView[]; id?: string; disabled?: boolean }
/** A text field: one line or several, a live character counter against the store limit. */
export function TextField({ project, area, op, saved, label, limit, multiline, rows = 3, placeholder, hint, issues = [], mono, id, disabled, kind }: FieldBase & {
/** The edit without its value: { kind: 'listing', store, lang, field } or { kind: 'config', path } or contact. */
op: Omit<Extract<EditOp, { kind: 'listing' }>, 'value'> | { kind: 'config'; path: string } | { kind: 'contact'; field: Extract<EditOp, { kind: 'contact' }>['field'] };
saved: string; limit?: number; multiline?: boolean; rows?: number; placeholder?: string; mono?: boolean; kind?: 'url' | 'email' | 'text';
}) {
const e = useEdits();
const make = (v: string): EditOp => (op.kind === 'config' ? (v === '' ? { kind: 'config', path: op.path, remove: true } : { kind: 'config', path: op.path, value: v }) : { ...op, value: v } as EditOp);
const { value, dirty } = useValue<string>(project, make(saved), saved);
const fid = id ?? `ed-${keyOf(make('')).replace(/[^A-Za-z0-9_-]/g, '-')}`;
const n = chars(value ?? '');
const set = (v: string) => e.set(project.id, make(v), area, saved);
const common = { id: fid, className: `input${mono ? ' mono' : ''}`, value: value ?? '', placeholder, disabled, 'aria-invalid': issues.some(i => i.level === 'error') || undefined,
onChange: (ev: { target: { value: string } }) => set(ev.target.value), 'data-edit': keyOf(make('')) };
return (
<div className={`ed-field${dirty ? ' dirty' : ''}`}>
<div className="ed-head">
<label htmlFor={fid}>{label}{dirty && <span className="ed-dot" title={t('ed.unsavedField')} />}</label>
{limit !== undefined && <Counter n={n} limit={limit} />}
</div>
{multiline ? <textarea rows={rows} {...common} /> : <input type={kind === 'email' ? 'email' : kind === 'url' ? 'url' : 'text'} spellCheck={kind ? false : undefined} {...common} />}
{dirty && (
<div className="ed-was small muted">
<span className="ed-was-text">{t('ed.was')} {saved ? `“${saved.length > 90 ? `${saved.slice(0, 87)}…` : saved}”` : t('ed.empty')}</span>
<button className="btn btn-xs btn-ghost" onClick={() => set(saved)}><Icon name="rotate" />{t('ed.reset')}</button>
</div>
)}
{hint && <div className="ed-hint small muted">{hint}</div>}
<Issues issues={[...(kind === 'url' && urlIssue(value ?? '') ? [{ level: 'error' as const, msg: urlIssue(value ?? '')! }] : []),
...(kind === 'email' && emailIssue(value ?? '') ? [{ level: 'error' as const, msg: emailIssue(value ?? '')! }] : []), ...issues]} />
</div>
);
}
/** A config value from a fixed list (an empty choice removes the key). */
export function ChoiceField({ project, area, path, saved, label, options, hint, issues = [], allowEmpty = true }: FieldBase & {
path: string; saved: string | boolean | null | undefined; options: [string, string][]; allowEmpty?: boolean;
}) {
const e = useEdits();
const toValue = (s: string): unknown => (s === 'true' ? true : s === 'false' ? false : s);
const savedS = saved === null || saved === undefined ? '' : String(saved);
const make = (s: string): EditOp => (s === '' ? { kind: 'config', path, remove: true } : { kind: 'config', path, value: toValue(s) });
const { value, dirty } = useValue<unknown>(project, make(savedS), saved);
const cur = value === null || value === undefined ? '' : String(value);
const fid = `ed-${path.replace(/[^A-Za-z0-9_-]/g, '-')}`;
return (
<div className={`ed-field${dirty ? ' dirty' : ''}`}>
<div className="ed-head"><label htmlFor={fid}>{label}{dirty && <span className="ed-dot" title={t('ed.unsavedField')} />}</label></div>
<select id={fid} className="input" value={cur} data-edit={`config:${path}`} onChange={ev => e.set(project.id, make(ev.target.value), area, saved === undefined ? null : saved)}>
{allowEmpty && <option value="">{t('ed.notSet')}</option>}
{options.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
{hint && <div className="ed-hint small muted">{hint}</div>}
<Issues issues={issues} />
</div>
);
}
/** A number in the config (prices in USD, levels). */
export function NumberField({ project, area, path, saved, label, hint, issues = [], min, step = 'any' }: FieldBase & { path: string; saved: number | null | undefined; min?: number; step?: string }) {
const e = useEdits();
const make = (v: number | null): EditOp => (v === null ? { kind: 'config', path, remove: true } : { kind: 'config', path, value: v });
const { value, dirty } = useValue<number | null | undefined>(project, make(saved ?? null), saved);
const [text, setText] = useState(value === null || value === undefined ? '' : String(value));
useEffect(() => { setText(value === null || value === undefined ? '' : String(value)); }, [value]);
const fid = `ed-${path.replace(/[^A-Za-z0-9_-]/g, '-')}`;
const bad = text !== '' && !Number.isFinite(Number(text));
return (
<div className={`ed-field${dirty ? ' dirty' : ''}`}>
<div className="ed-head"><label htmlFor={fid}>{label}{dirty && <span className="ed-dot" title={t('ed.unsavedField')} />}</label></div>
<input id={fid} className="input num" inputMode="decimal" value={text} data-edit={`config:${path}`} aria-invalid={bad || undefined}
onChange={ev => { setText(ev.target.value); const n = Number(ev.target.value); if (ev.target.value === '') e.set(project.id, make(null), area, saved ?? null); else if (Number.isFinite(n)) e.set(project.id, make(n), area, saved ?? null); }}
min={min} step={step} />
{dirty && <div className="ed-was small muted"><span className="ed-was-text">{t('ed.was')} {saved ?? t('ed.empty')}</span><button className="btn btn-xs btn-ghost" onClick={() => e.set(project.id, make(saved ?? null), area, saved ?? null)}><Icon name="rotate" />{t('ed.reset')}</button></div>}
{hint && <div className="ed-hint small muted">{hint}</div>}
<Issues issues={[...(bad ? [{ level: 'error' as const, msg: t('ed.notNumber') }] : []), ...issues]} />
</div>
);
}
/** A list of short values (countries, age groups, keywords of a config list) as comma-separated text. */
export function ListField({ project, area, path, saved, label, hint, issues = [], upper }: FieldBase & { path: string; saved: string[] | null | undefined; upper?: boolean }) {
const e = useEdits();
const make = (v: string[] | null): EditOp => (v === null || !v.length ? { kind: 'config', path, remove: true } : { kind: 'config', path, value: v });
const { value, dirty } = useValue<string[] | null | undefined>(project, make(saved ?? null), saved);
const [text, setText] = useState((value ?? []).join(', '));
useEffect(() => { setText((value ?? []).join(', ')); }, [JSON.stringify(value)]); // eslint-disable-line react-hooks/exhaustive-deps
const fid = `ed-${path.replace(/[^A-Za-z0-9_-]/g, '-')}`;
return (
<div className={`ed-field${dirty ? ' dirty' : ''}`}>
<div className="ed-head"><label htmlFor={fid}>{label}{dirty && <span className="ed-dot" title={t('ed.unsavedField')} />}</label></div>
<input id={fid} className="input mono" value={text} data-edit={`config:${path}`}
onChange={ev => { setText(ev.target.value); const list = ev.target.value.split(',').map(s => (upper ? s.trim().toUpperCase() : s.trim())).filter(Boolean); e.set(project.id, make(list.length ? list : null), area, saved ?? null); }} />
{hint && <div className="ed-hint small muted">{hint}</div>}
<Issues issues={issues} />
</div>
);
}
/** A block of the config as JSON (offers, promoted purchases, custom product pages, events, data types): edited as
* text, checked as JSON here and validated with the whole config when saving (the dry run). */
export function JsonField({ project, area, path, saved, label, hint, rows = 10 }: FieldBase & { path: string; saved: unknown; rows?: number }) {
const e = useEdits();
const savedText = saved === undefined || saved === null ? '' : JSON.stringify(saved, null, 2);
const pending = e.get(project.id, `config:${path}`);
const [text, setText] = useState(pending ? (pending.op.kind === 'config' && !pending.op.remove ? JSON.stringify(pending.op.value, null, 2) : '') : savedText);
useEffect(() => { if (!e.get(project.id, `config:${path}`)) setText(savedText); }, [savedText]); // eslint-disable-line react-hooks/exhaustive-deps
let err: string | null = null;
if (text.trim()) { try { JSON.parse(text); } catch (x) { err = (x as Error).message; } }
const fid = `ed-${path.replace(/[^A-Za-z0-9_-]/g, '-')}`;
return (
<div className={`ed-field${pending ? ' dirty' : ''}`}>
<div className="ed-head"><label htmlFor={fid}>{label}{pending && <span className="ed-dot" title={t('ed.unsavedField')} />}</label><span className="small muted mono-id">{path}</span></div>
<textarea id={fid} className="input mono" rows={rows} value={text} spellCheck={false} data-edit={`config:${path}`} aria-invalid={!!err || undefined}
onChange={ev => {
setText(ev.target.value);
const v = ev.target.value.trim();
if (!v) { e.set(project.id, { kind: 'config', path, remove: true }, area, saved ?? null); return; }
try { e.set(project.id, { kind: 'config', path, value: JSON.parse(v) }, area, saved ?? null); } catch { /* shown below; kept as text until it parses */ }
}} />
{hint && <div className="ed-hint small muted">{hint}</div>}
<Issues issues={err ? [{ level: 'error', msg: t('ed.notJson', { error: err }) }] : []} />
</div>
);
}
/** "Uncommitted in git" for the files an editor writes (storebound never commits). */
export function GitNote({ data, files }: { data: EditableResult; files: (string | null)[] }) {
if (!data.git.repo) return <span className="small muted row" style={{ gap: 6 }}><Icon name="folder" className="ic13" />{t('ed.noGit')}</span>;
const dirty = data.git.uncommitted.filter(f => files.includes(f));
if (!dirty.length) return <span className="small muted row" style={{ gap: 6 }}><Icon name="check" className="ic13" />{t('ed.gitClean')}</span>;
return <span className="row" style={{ gap: 6 }} data-git="uncommitted"><Chip tone="warn" icon="alert">{t('ed.gitDirty', { files: dirty.join(', ') })}</Chip></span>;
}
/** The plan of project.edit (old -> new, the checks, the diff per file) in the window. */
export function EditPlanView({ r }: { r: EditResult }) {
return (
<div className="ed-plan stack" data-edit-plan>
<ul className="ed-changes">
{r.changes.map((c, i) => (
<li key={i}><span className="strong">{c.what}</span>{c.also?.length ? <span className="small muted"> · {t('ed.also', { codes: c.also.join(', ') })}</span> : null}
<div className="small"><span className="ed-from">{c.from ?? t('ed.empty')}</span> <Icon name="arrowRight" className="ic13" /> <span className="ed-to">{c.to ?? t('ed.removed')}</span></div></li>
))}
</ul>
{r.issues.length > 0 && <Issues issues={r.issues.map(i => ({ level: i.level, msg: `${i.where}: ${i.msg}` }))} />}
{r.files.map(f => (
<details key={f.rel} className="ed-diff" open={r.files.length === 1}>
<summary className="small"><span className="mono-id">{f.rel}</span>{f.created ? ` · ${t('ed.newFile')}` : ''}</summary>
{f.masked ? <p className="small muted">{t('ed.masked')}</p> : <pre className="selectable">{f.diff.join('\n')}</pre>}
</details>
))}
</div>
);
}
/** The unsaved edits of one editor: count, undo / redo, discard, and review + save. */
export function EditBar({ project, area, data, files, onSaved }: { project: Project; area: EditArea; data: EditableResult; files: (string | null)[]; onSaved?: () => void }) {
const e = useEdits();
const w = useWrite(project.id);
const n = e.count(project.id, area);
const [review, setReview] = useState<{ loading: boolean; r: EditResult | null; error: string | null } | null>(null);
const ops = e.ops(project.id, area);
const opsJson = JSON.stringify(ops);
// a review shows the edits it was made for; typing more closes it
useEffect(() => { setReview(null); }, [opsJson]);
const check = useCallback(async () => {
setReview({ loading: true, r: null, error: null });
try {
const res = await api.call<ToolResult & { data?: EditResult }>('project.edit', project.id, { args: { ops: opsJson } });
setReview({ loading: false, r: (res.data as EditResult | undefined) ?? null, error: res.data ? null : failureText(res) });
} catch (x) { setReview({ loading: false, r: null, error: String((x as { message?: string }).message ?? x) }); }
}, [project.id, opsJson]);
return (
<div className={`ed-bar${n ? ' active' : ''}`} data-edit-bar={area}>
<div className="row between" style={{ gap: 10, flexWrap: 'wrap' }}>
<div className="row" style={{ gap: 8 }}>
{n ? <Chip tone="accent" icon="edit">{t('ed.unsaved', { count: n })}</Chip> : <span className="small muted">{t('ed.noChanges')}</span>}
<GitNote data={data} files={files} />
</div>
<div className="row" style={{ gap: 6 }}>
<button className="btn btn-sm btn-ghost" disabled={!e.canUndo(project.id)} onClick={() => e.undo(project.id)} title={t('ed.undo')}><Icon name="rotate" />{t('ed.undo')}</button>
<button className="btn btn-sm btn-ghost" disabled={!e.canRedo(project.id)} onClick={() => e.redo(project.id)} title={t('ed.redo')}><Icon name="refresh" />{t('ed.redo')}</button>
<button className="btn btn-sm btn-ghost" disabled={!n} onClick={() => e.clear(project.id, area, { undoable: true })}><Icon name="x" />{t('ed.discard')}</button>
<button className="btn btn-sm btn-primary" disabled={!n || review?.loading} onClick={check} data-action="review"><Icon name={review?.loading ? 'refresh' : 'eye'} className={review?.loading ? 'spin' : undefined} />{t('ed.review')}</button>
</div>
</div>
{review && !review.loading && (
<div className="ed-review">
{review.error && <Notice tone="bad">{review.error}</Notice>}
{review.r && <EditPlanView r={review.r} />}
{review.r && (
<div className="row" style={{ gap: 8, justifyContent: 'flex-end' }}>
<button className="btn btn-sm btn-ghost" onClick={() => setReview(null)}>{t('ed.close')}</button>
<WriteButton run={w} primary icon="check" disabled={review.r.errors > 0 || !review.r.files.length}
hint={review.r.errors ? t('ed.fixFirst') : undefined}
w={{ method: 'project.edit', args: { ops: opsJson }, title: t('ed.save'), done: t('ed.saved'), after: () => { e.clear(project.id, area); setReview(null); onSaved?.(); } }}>{t('ed.save')}</WriteButton>
</div>
)}
</div>
)}
</div>
);
}
/** A store write with its dry run in the window first: the plan (old -> new per store and language), then Apply. */
export function ApplyPanel({ project, method, args = {}, label, icon = 'upload', done, disabled, hint, blocked }: {
project: Project; method: string; args?: Record<string, unknown>; label: string; icon?: IconName; done: string; disabled?: boolean; hint?: string;
/** Unsaved edits that the store write would not include. */ blocked?: string | null;
}) {
const w = useWrite(project.id);
const [st, setSt] = useState<{ loading: boolean; text: string; ok: boolean } | null>(null);
const preview = async () => {
setSt({ loading: true, text: '', ok: false });
try {
const r = await api.call<ToolResult>(method, project.id, { args });
const lines = r.output.split('\n').map(l => l.replace(/^\d\d:\d\d:\d\d \[[^\]]+\] /, '')).filter(l => l.trim());
const data = r.data as { diff?: string[] } | undefined;
setSt({ loading: false, text: [...lines, ...(data?.diff ?? [])].join('\n') || t('ed.nothingToApply'), ok: r.ok });
} catch (x) { setSt({ loading: false, text: String((x as { message?: string }).message ?? x), ok: false }); }
};
return (
<div className="ed-apply" data-apply={method}>
<div className="row" style={{ gap: 8, flexWrap: 'wrap' }}>
<button className="btn btn-sm" disabled={disabled || st?.loading} onClick={preview} title={hint}><Icon name={st?.loading ? 'refresh' : 'eye'} className={st?.loading ? 'spin' : undefined} />{label}</button>
{blocked && <span className="small muted">{blocked}</span>}
</div>
{st && !st.loading && (
<div className="ed-review">
<pre className={`selectable ed-dry${st.ok ? '' : ' bad'}`}>{st.text}</pre>
<div className="row" style={{ gap: 8, justifyContent: 'flex-end' }}>
<button className="btn btn-sm btn-ghost" onClick={() => setSt(null)}>{t('ed.close')}</button>
{st.ok && <WriteButton run={w} primary icon={icon} disabled={!!blocked} hint={blocked ?? undefined} w={{ method, args, title: label, done, after: () => setSt(null) }}>{t('ed.applyNow')}</WriteButton>}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,67 @@
// Unsaved edits of the editors (src/lib/edits.ts): one pending value per field, typing back to the saved value drops
// it, undo / redo (typing into one field within COALESCE_MS is one step), discard can be undone, areas and apps stay
// apart, and the ops sent to project.edit.
import { describe, expect, test } from 'bun:test';
import { COALESCE_MS, EditStore, keyOf } from '../src/lib/edits';
const sub = (value: string) => ({ kind: 'listing' as const, store: 'apple' as const, lang: 'en-US', field: 'subtitle', value });
describe('EditStore', () => {
test('pending values, back to the saved value drops the edit', () => {
let now = 0;
const s = new EditStore(() => now);
s.set('p1', sub('Tidy'), 'listings', 'Old');
expect(s.count('p1')).toBe(1);
expect(s.get('p1', keyOf(sub('')))!.op).toEqual(sub('Tidy'));
now += 10;
s.set('p1', sub('Old'), 'listings', 'Old');
expect(s.count('p1')).toBe(0);
expect(s.count('p2')).toBe(0);
});
test('undo / redo, one step per field burst', () => {
let now = 0;
const s = new EditStore(() => now);
s.set('p', sub('T'), 'listings', 'Old');
now += 100; s.set('p', sub('Ti'), 'listings', 'Old');
now += 100; s.set('p', sub('Tid'), 'listings', 'Old');
now += COALESCE_MS + 1;
s.set('p', { kind: 'config', path: 'app.supportUrl', value: 'https://example.com/h' }, 'listings', 'https://example.com/s');
expect(s.count('p')).toBe(2);
s.undo('p');
expect(s.count('p')).toBe(1);
expect((s.get('p', keyOf(sub('')))!.op as { value: string }).value).toBe('Tid');
s.undo('p');
expect(s.count('p')).toBe(0);
expect(s.canUndo('p')).toBe(false);
s.redo('p');
s.redo('p');
expect(s.count('p')).toBe(2);
});
test('areas, discard (undoable), clear after a save, ops in a stable order', () => {
const s = new EditStore(() => 0);
s.set('p', sub('Tidy'), 'listings', 'Old');
s.set('p', { kind: 'config', path: 'apple.info.free', value: false }, 'appinfo', true);
expect(s.count('p', 'listings')).toBe(1);
expect(s.ops('p', 'appinfo')).toEqual([{ kind: 'config', path: 'apple.info.free', value: false }]);
expect(s.ops('p').map(keyOf)).toEqual(['config:apple.info.free', 'listing:apple:en-US:subtitle']);
s.clear('p', 'listings', { undoable: true });
expect(s.count('p')).toBe(1);
s.undo('p');
expect(s.count('p')).toBe(2);
expect(s.dirtyProjects()).toEqual(['p']);
s.clear('p');
expect(s.dirtyProjects()).toEqual([]);
expect(s.canUndo('p')).toBe(false);
});
test('subscribers hear every change', () => {
const s = new EditStore(() => 0);
let n = 0;
const off = s.subscribe(() => { n++; });
s.set('p', sub('x'), 'listings', '');
s.undo('p');
off();
s.set('p', sub('y'), 'listings', '');
expect(n).toBe(2);
expect(s.snapshot()).toBe(3);
});
});

View file

@ -13,15 +13,8 @@ import { type ItemPlan, itemLines, planItem } from '../lib/listing-plan';
import { done, log, printJson, warn } from '../lib/log';
import { ascClient } from './asc';
/** Every question of the current age rating declaration at its "nothing" answer. */
export const AGE_RATING_NONE: Record<string, string | boolean | null> = {
advertising: false, ageAssurance: false, alcoholTobaccoOrDrugUseOrReferences: 'NONE', contests: 'NONE', gambling: false,
gamblingSimulated: 'NONE', gunsOrOtherWeapons: 'NONE', healthOrWellnessTopics: false, horrorOrFearThemes: 'NONE', lootBox: false,
matureOrSuggestiveThemes: 'NONE', medicalOrTreatmentInformation: 'NONE', messagingAndChat: false, parentalControls: false,
profanityOrCrudeHumor: 'NONE', socialMedia: false, socialMediaAgeRestricted: false, sexualContentGraphicAndNudity: 'NONE', sexualContentOrNudity: 'NONE', unrestrictedWebAccess: false,
userGeneratedContent: false, violenceCartoonOrFantasy: 'NONE', violenceRealistic: 'NONE',
violenceRealisticProlongedGraphicOrSadistic: 'NONE', kidsAgeBand: null,
};
export { AGE_RATING_NONE } from '../lib/store-catalogs';
import { AGE_RATING_NONE } from '../lib/store-catalogs';
const CATEGORY_RELS = ['primaryCategory', 'primarySubcategoryOne', 'primarySubcategoryTwo', 'secondaryCategory'] as const;
const str = (v: unknown): string => (v === null || v === undefined ? '' : String(v));

View file

@ -25,6 +25,7 @@ export const PLAIN: Record<string, [() => Promise<Plain>, string]> = {
'ads': [() => import('./ads'), 'scan --ios <path>: AdAttributionKit / SKAdNetwork checks of Info.plist (network ids, postback copy endpoints, attribution windows, cooldown) and sources; --json'],
};
export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
'edit': [() => import('./edit'), 'edit the app folder\'s sources of truth: --ops \'<json>\' (config values by path, listing fields per store and language, the App Review contact), dry run with the diff and the checks first; --show --json lists what the desktop editors show'],
'inspect': [() => import('./inspect'), 'offline summary of the config (name, store ids, languages, products, subscriptions); --json'],
'check': [() => import('./check'), 'validate config, copy files, data safety answers, products, subscriptions and the app profile (offline); --aso keyword audit'],
'validate': [() => import('./validate'), 'read-only store readiness report with ordered blockers (--apple / --play), plus local renders checks (--no-local skips); exit 1 on blockers'],

View file

@ -394,6 +394,10 @@
"doctor": [
"dir"
],
"edit": [
"ops",
"show"
],
"flow": [
"apply",
"from",
@ -918,6 +922,7 @@
"only-keywords",
"only-missing",
"only-terms",
"ops",
"originality",
"out",
"param",
@ -948,6 +953,7 @@
"sdk-dirs",
"search",
"seconds",
"show",
"signature",
"since",
"size",
@ -1188,6 +1194,10 @@
"doctor": [
"storebound doctor [--dir .] [--json]"
],
"edit": [
"storebound edit --ops '<json>' [--dry] [--json]",
"storebound edit --show --json every value the desktop's editors show (listings, config sections, git state)"
],
"flow": [
"storebound flow list [--json] the flows of this project and their steps",
"storebound flow validate [--json] definitions, sub-flow cycles, commands, variables; exit 1 on a problem",

118
cli/edit.ts Normal file
View file

@ -0,0 +1,118 @@
// Edit the app folder's sources of truth from the desktop (or a terminal): config values by path, listing fields per
// store and language, the App Review contact. Local files only; nothing goes to a store.
// storebound edit --ops '<json>' [--dry] [--json]
// storebound edit --show --json every value the desktop's editors show (listings, config sections, git state)
// --ops is one edit or a list:
// { "kind": "config", "path": "app.supportUrl", "value": "https://example.com/help" }
// { "kind": "config", "path": "products[id=com.example.no_ads].listings.en.title", "value": "No ads" }
// { "kind": "config", "path": "apple.info.reviewNotes", "remove": true }
// { "kind": "listing", "store": "apple", "lang": "en-US", "field": "subtitle", "value": "Lists that stay tidy" }
// { "kind": "listing", "store": "play", "lang": "tr-TR", "field": "short", "value": "..." }
// { "kind": "contact", "field": "email", "value": "review@example.com" } (.storebound/credentials.json, masked)
// Every run plans first: old -> new per edit, the diff per file and the checks (store limits, required fields, keyword
// and metadata rules of `check --aso` / `scan`, URLs, and the whole config validated as `check` loads it). --dry stops
// there. An error stops the write; the files are written only when every check passes (comments, order and
// formatting of the files stay as they are: only the edited values change). Never commits: a git folder shows the
// changed files as uncommitted.
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { appleName, validateConfig } from './lib/config';
import type { Ctx } from './lib/ctx';
import { type EditPlan, type EditSources, getByPath, parseOps, planEdits, planLines } from './lib/edit';
import { editableValues, gitState } from './lib/edit-values';
import { done, log, printJson, warn } from './lib/log';
import * as rt from './lib/rt';
const TAG = 'edit';
export function sources(ctx: Ctx): EditSources {
const { config, root, path, resolve } = ctx.loaded;
const rel = (f: string): string => relative(root, f) || f;
const file = (f: string) => ({ file: f, rel: rel(f), text: existsSync(f) ? readFileSync(f, 'utf8') : null });
const contact = join(root, '.storebound', 'credentials.json');
return {
config: { file: path, rel: rel(path), text: readFileSync(path, 'utf8') },
play: config.play ? file(resolve(config.play.listing ?? 'store/listing.md')) : null,
apple: config.apple ? file(resolve(config.apple.listing ?? 'store/appstore.md')) : null,
contact: file(contact),
appName: config.app.name,
appleName: appleName(config),
};
}
/** The edited config loads and validates like `check` loads it (a temp file next to it, so relative imports work). */
async function validateConfigText(path: string, text: string): Promise<string[]> {
const ext = path.slice(path.lastIndexOf('.'));
const tmp = join(dirname(path), `.storebound-edit-${process.pid}-${Date.now().toString(36)}${ext}`);
writeFileSync(tmp, text);
try {
if (ext === '.json') return validateConfig(JSON.parse(text));
const mod = await rt.importConfigModule(tmp);
return validateConfig(mod.default ?? mod);
} catch (e) {
return [`the edited config does not load: ${(e as Error).message.split('\n')[0]}`];
} finally {
rmSync(tmp, { force: true });
}
}
/** Writes a file through a temp file and a rename, keeping the old file's mode (credentials.json stays 0600). */
function writeAtomic(file: string, text: string): void {
let mode: number | null = null;
try { mode = statSync(file).mode & 0o777; } catch { /* new */ }
mkdirSync(dirname(file), { recursive: true, ...(file.includes(`${join('.storebound', '')}`) ? { mode: 0o700 } : {}) });
const tmp = `${file}.storebound-${process.pid}.tmp`;
writeFileSync(tmp, text, { mode: mode ?? (file.endsWith('credentials.json') ? 0o600 : 0o644) });
if (mode !== null) chmodSync(tmp, mode);
renameSync(tmp, file);
}
export async function plan(ctx: Ctx, opsJson: string): Promise<{ plan: EditPlan; texts: Map<string, string> }> {
const src = sources(ctx);
const ops = parseOps(opsJson);
const r = planEdits(ops, src, p => getByPath(ctx.loaded.config, p));
const cfgText = r.texts.get(src.config.file);
if (cfgText !== undefined) for (const e of await validateConfigText(src.config.file, cfgText)) {
r.plan.issues.push({ level: 'error', field: 'config', where: src.config.rel, msg: e });
r.plan.errors++;
}
return r;
}
export async function run(ctx: Ctx): Promise<void> {
const a = ctx.args;
const { root } = ctx.loaded;
if (a.has('show')) { printJson(editableValues(ctx.loaded)); return; }
const opsJson = a.get('ops');
if (!opsJson) throw new Error('usage: storebound edit --ops \'<json>\' [--dry] [--json] | storebound edit --show --json');
const { plan: p, texts } = await plan(ctx, opsJson);
const dry = a.has('dry');
// with --json in a terminal, stdout is the JSON result only: the lines go to stderr (under `serve` the result is a
// marked line, and the lines stay on stdout: the confirm dialog shows them)
const toErr = a.has('json') && process.env.STOREBOUND_MACHINE !== '1';
const say = (f: (tag: string, msg: string) => void) => (msg: string): void => { if (toErr) console.error(`[${TAG}] ${msg}`); else f(TAG, msg); };
const sDone = say(done), sLog = say(log), sWarn = say(warn);
const lines = planLines(p);
for (const l of lines) (toErr ? console.error : console.log)(l);
const git = () => gitState(root, p.files.map(f => f.file));
if (p.errors) {
if (a.has('json')) printJson({ ...p, written: false, dry, git: git() });
throw new Error(`nothing written: ${p.errors} error${p.errors === 1 ? '' : 's'} (see above)`);
}
if (!p.files.length) {
if (a.has('json')) printJson({ ...p, written: false, dry, git: git() });
sDone('nothing to change: the files already hold these values');
return;
}
if (dry) {
if (a.has('json')) printJson({ ...p, written: false, dry, git: git() });
sDone(`${p.changes.length} change(s) in ${p.files.map(f => f.rel).join(', ')}; nothing written (--dry)`);
return;
}
for (const f of p.files) writeAtomic(f.file, texts.get(f.file)!);
const g = git();
if (a.has('json')) printJson({ ...p, written: true, dry: false, git: g });
for (const f of p.files) sLog(`wrote ${f.rel}`);
if (g.repo && g.uncommitted.length) sWarn(`uncommitted in git: ${g.uncommitted.join(', ')} (storebound never commits)`);
sDone(`${p.changes.length} change(s) saved. Next: apply them to the stores (the desktop's Apply, or play listing / apple listing ...)`);
}

View file

@ -79,16 +79,8 @@ const coveredBy = (w: string, set: Iterable<string>, locale: string): boolean =>
// ---------------------------------------------------------------------------------------------------------------
// Competitors (iTunes Search API)
/** App Store Connect category id -> iTunes genre id (the "genreIds" of Search API results). Apple's genre ids. */
export const GENRE_IDS: Record<string, string> = {
BOOKS: '6018', BUSINESS: '6000', DEVELOPER_TOOLS: '6026', EDUCATION: '6017', ENTERTAINMENT: '6016', FINANCE: '6015', FOOD_AND_DRINK: '6023', GAMES: '6014',
GRAPHICS_AND_DESIGN: '6027', HEALTH_AND_FITNESS: '6013', LIFESTYLE: '6012', MAGAZINES_AND_NEWSPAPERS: '6021', MEDICAL: '6020', MUSIC: '6011', NAVIGATION: '6010',
NEWS: '6009', PHOTO_AND_VIDEO: '6008', PRODUCTIVITY: '6007', REFERENCE: '6006', SHOPPING: '6024', SOCIAL_NETWORKING: '6005', SPORTS: '6004', STICKERS: '6025',
TRAVEL: '6003', UTILITIES: '6002', WEATHER: '6001',
GAMES_ACTION: '7001', GAMES_ADVENTURE: '7002', GAMES_CASUAL: '7003', GAMES_BOARD: '7004', GAMES_CARD: '7005', GAMES_CASINO: '7006', GAMES_FAMILY: '7009',
GAMES_MUSIC: '7011', GAMES_PUZZLE: '7012', GAMES_RACING: '7013', GAMES_ROLE_PLAYING: '7014', GAMES_SIMULATION: '7015', GAMES_SPORTS: '7016',
GAMES_STRATEGY: '7017', GAMES_TRIVIA: '7018', GAMES_WORD: '7019',
};
export { GENRE_IDS } from './store-catalogs';
import { GENRE_IDS } from './store-catalogs';
export interface CompetitorApp {
rank: number; id: string; name: string; developer: string; ratings: number; rating: number | null;

297
cli/lib/config-path.ts Normal file
View file

@ -0,0 +1,297 @@
// One value of storebound.config.ts / .js / .json set by its path, as a text edit: the rest of the file (comments,
// order, quotes, formatting) stays byte for byte. Used by `storebound edit` (the desktop's editors). Pure.
//
// A path is dot-separated keys with array selectors: `app.supportUrl`, `apple.info.categories.primary`,
// `products[id=com.example.no_ads].listings.en.title`, `subscriptions[0].listings.tr.name`,
// `apple.testflight.groups[name=Friends].internal`. A selector `[key=value]` picks the array element whose `key` is that
// string; `[n]` the n-th element. Keys may be quoted in the file ('pt-BR': ...) or not.
// Setting `undefined` removes the property. Missing objects on the way are created; a missing array element is an
// error (adding a product is a config change for the owner's editor, not a path edit). A value on the way that is
// not a plain literal (a variable, a function call, a spread) is an error: the owner edits that in the file.
import { matchBrace, toTs } from './scan/config-edit';
export type PathPart = { key: string } | { index: number } | { match: string; eq: string };
/** `a.b[id=x].c[0]` -> parts. Throws on a malformed path. */
export function parsePath(path: string): PathPart[] {
const out: PathPart[] = [];
let i = 0;
while (i < path.length) {
if (path[i] === '.') { i++; continue; }
if (path[i] === '[') {
const end = path.indexOf(']', i);
if (end < 0) throw new Error(`path ${path}: unclosed [`);
const inner = path.slice(i + 1, end);
if (/^\d+$/.test(inner)) out.push({ index: Number(inner) });
else {
const m = /^([A-Za-z_$][\w$]*)=(.+)$/.exec(inner);
if (!m) throw new Error(`path ${path}: [${inner}] is neither [n] nor [key=value]`);
out.push({ match: m[1]!, eq: m[2]! });
}
i = end + 1;
continue;
}
const m = /^[A-Za-z0-9_$-]+/.exec(path.slice(i));
if (!m) throw new Error(`path ${path}: unexpected "${path[i]}"`);
out.push({ key: m[0] });
i += m[0].length;
}
if (!out.length) throw new Error('empty path');
return out;
}
export const pathText = (parts: PathPart[]): string =>
parts.map((p, i) => ('key' in p ? `${i ? '.' : ''}${p.key}` : 'index' in p ? `[${p.index}]` : `[${p.match}=${p.eq}]`)).join('');
// ---- a small scanner for object and array literals ----
const isWs = (c: string | undefined): boolean => c === ' ' || c === '\t' || c === '\n' || c === '\r';
/** Index after whitespace and comments from `i`. */
function skip(t: string, i: number): number {
for (;;) {
while (i < t.length && isWs(t[i])) i++;
if (t[i] === '/' && t[i + 1] === '/') { const n = t.indexOf('\n', i); i = n < 0 ? t.length : n + 1; continue; }
if (t[i] === '/' && t[i + 1] === '*') { const n = t.indexOf('*/', i + 2); i = n < 0 ? t.length : n + 2; continue; }
return i;
}
}
/** End (exclusive) of the string literal starting at `i`. */
function stringEnd(t: string, i: number): number {
const q = t[i]!;
for (let j = i + 1; j < t.length; j++) {
if (t[j] === '\\') { j++; continue; }
if (t[j] === q) return j + 1;
}
throw new Error('unterminated string in the config');
}
/** End (exclusive, trailing whitespace and comments not included) of the expression starting at `i`: up to the next
* `,` or closing bracket at depth 0. */
function exprEnd(t: string, i: number): number {
let last = i;
let j = i;
while (j < t.length) {
const c = t[j]!;
if (c === '/' && (t[j + 1] === '/' || t[j + 1] === '*')) { j = skip(t, j); continue; }
if (isWs(c)) { j++; continue; }
if (c === ',' || c === '}' || c === ']' || c === ')') return last;
if (c === '"' || c === '\'' || c === '`') { j = stringEnd(t, j); last = j; continue; }
if (c === '{' || c === '[' || c === '(') {
const close = matchBrace(t, j);
if (close < 0) throw new Error('unbalanced brackets in the config');
j = close + 1; last = j; continue;
}
j++; last = j;
}
return last;
}
interface Member { key: string | null; start: number; valueStart: number; valueEnd: number; end: number }
/** Members of the object (or elements of the array) whose opening bracket is at `open`. `end`: after the member's
* trailing comma, if any. key null: a spread, a computed key or an array element. */
function members(t: string, open: number): { list: Member[]; close: number } {
const close = matchBrace(t, open);
if (close < 0) throw new Error('unbalanced brackets in the config');
const isArray = t[open] === '[';
const list: Member[] = [];
let i = skip(t, open + 1);
while (i < close) {
const start = i;
let key: string | null = null;
let valueStart = i;
if (!isArray) {
if (t.startsWith('...', i)) { key = null; valueStart = i; }
else {
let k: string;
let after: number;
if (t[i] === '"' || t[i] === '\'') { after = stringEnd(t, i); k = JSON.parse(t[i] === '"' ? t.slice(i, after) : `"${t.slice(i + 1, after - 1).replace(/\\'/g, '\'').replace(/"/g, '\\"')}"`); }
else if (t[i] === '[') { after = matchBrace(t, i) + 1; k = ''; }
else { const m = /^[A-Za-z0-9_$]+/.exec(t.slice(i)); if (!m) throw new Error(`unexpected "${t[i]}" in the config`); k = m[0]; after = i + k.length; }
key = t[i] === '[' ? null : k;
const colon = skip(t, after);
if (t[colon] === ':') valueStart = skip(t, colon + 1);
else if (t[colon] === '(') { key = null; valueStart = i; } // a method
else valueStart = i; // shorthand `{ name }`: the value is the identifier
}
}
const valueEnd = exprEnd(t, valueStart);
let end = skip(t, valueEnd);
if (t[end] === ',') end++;
list.push({ key, start, valueStart, valueEnd, end });
i = skip(t, end);
if (end === valueEnd && i < close) throw new Error('could not read the config (a member without a comma)');
}
return { list, close };
}
/** The opening brace of the object the config exports: `export default {`, `export default defineConfig({`,
* `module.exports = {`, or the `const x = {` that `export default x` names. */
export function rootObject(t: string): number {
const direct = /(?:export\s+default|module\.exports\s*=)\s*(?:[A-Za-z_$][\w$]*\s*\(\s*)?\{/.exec(t);
if (direct) return direct.index + direct[0].length - 1;
const named = /export\s+default\s+([A-Za-z_$][\w$]*)\s*;?/.exec(t) ?? /module\.exports\s*=\s*([A-Za-z_$][\w$]*)/.exec(t);
if (named) {
const decl = new RegExp(`(?:const|let|var)\\s+${named[1]}\\b[^=]*=\\s*(?:[A-Za-z_$][\\w$]*\\s*\\(\\s*)?\\{`).exec(t);
if (decl) return decl.index + decl[0].length - 1;
}
throw new Error('could not find the exported config object (export default { ... })');
}
/** Indentation of the line that `at` is on. */
function lineIndent(t: string, at: number): string {
const ls = t.lastIndexOf('\n', at - 1) + 1;
return /^[ \t]*/.exec(t.slice(ls))![0];
}
/** One indentation step of the file (2 spaces by default). */
function step(t: string): string {
const m = /\n([ \t]+)\S/.exec(t);
return m ? (m[1]!.startsWith('\t') ? '\t' : m[1]!.length >= 4 && m[1]!.length % 4 === 0 && !/\n {2}\S/.test(t) ? ' ' : ' ') : ' ';
}
/** A literal for `v` in the style of the value it replaces (the quote character of an old string). */
function literal(v: unknown, ind: string, old?: string): string {
if (typeof v === 'string' && old && (old[0] === '"' || old[0] === '`') && !v.includes('\n')) {
if (old[0] === '"') return JSON.stringify(v);
if (!/[`$\\]/.test(v)) return `\`${v}\``;
}
return toTs(v, ind);
}
/** Is the text at [s, e) a literal we may read through or replace (no identifiers, calls or spreads)? */
const plain = (src: string): boolean => /^(['"`]|-?\d|true\b|false\b|null\b|undefined\b|\{|\[)/.test(src.trim());
/** The config text with the value at `path` set (undefined: removed). */
export function setByPath(text: string, file: string, path: string | PathPart[], value: unknown): string {
const parts = typeof path === 'string' ? parsePath(path) : path;
if (file.endsWith('.json')) return setJson(text, parts, value);
let open = rootObject(text);
for (let n = 0; n < parts.length; n++) {
const part = parts[n]!;
const last = n === parts.length - 1;
const { list, close } = members(text, open);
const isArray = text[open] === '[';
let hit: Member | undefined;
if ('key' in part) {
if (isArray) throw new Error(`${pathText(parts.slice(0, n))} is a list; use [n] or [key=value]`);
hit = [...list].reverse().find(m => m.key === part.key);
} else {
if (!isArray) throw new Error(`${pathText(parts.slice(0, n))} is not a list`);
if ('index' in part) hit = list[part.index];
else hit = list.find(m => {
const v = text.slice(m.valueStart, m.valueEnd);
if (!v.startsWith('{')) return false;
const inner = members(text, m.valueStart).list.find(x => x.key === part.match);
if (!inner) return false;
const s = text.slice(inner.valueStart, inner.valueEnd);
return plain(s) && /^['"`]/.test(s) && evalString(s) === part.eq;
});
if (!hit) throw new Error(`${pathText(parts.slice(0, n + 1))}: no such element in the config`);
}
if (last) {
if (value === undefined) {
if (!hit) return text;
return removeMember(text, hit, list, open, close);
}
if (hit) {
const old = text.slice(hit.valueStart, hit.valueEnd);
if (hit.valueStart === hit.start && !isArray) throw new Error(`${pathText(parts)} is written as a shorthand property; change it in the file`);
if (!plain(old)) throw new Error(`${pathText(parts)} is not a plain value in the config (${old.slice(0, 40)}); change it in the file`);
return text.slice(0, hit.valueStart) + literal(value, lineIndent(text, hit.start), old) + text.slice(hit.valueEnd);
}
return insertMember(text, open, close, list, (part as { key: string }).key, value);
}
if (!hit) {
if (value === undefined) return text;
// build the rest of the path as nested objects (only keys can be created)
let v: unknown = value;
for (let k = parts.length - 1; k > n; k--) {
const p = parts[k]!;
if (!('key' in p)) throw new Error(`${pathText(parts.slice(0, k + 1))}: no such element in the config`);
v = { [p.key]: v };
}
return insertMember(text, open, close, list, (part as { key: string }).key, v);
}
const src = text.slice(hit.valueStart, hit.valueEnd);
if (!src.startsWith('{') && !src.startsWith('[')) throw new Error(`${pathText(parts.slice(0, n + 1))} is not a plain object in the config (${src.slice(0, 40)}); change it in the file`);
open = hit.valueStart;
}
return text;
}
/** The value of a string literal (single, double or backtick quotes without ${}). */
function evalString(s: string): string {
const q = s[0];
if (q === '"') return JSON.parse(s) as string;
const body = s.slice(1, -1);
return body.replace(/\\(u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[\s\S])/g, (_m, e: string) => {
if (e[0] === 'u') return String.fromCodePoint(parseInt(e[1] === '{' ? e.slice(2, -1) : e.slice(1), 16));
if (e[0] === 'x') return String.fromCharCode(parseInt(e.slice(1), 16));
return ({ n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', v: '\v', '0': '\0' } as Record<string, string>)[e] ?? e;
});
}
function insertMember(t: string, open: number, close: number, list: Member[], key: string, value: unknown): string {
const k = /^[A-Za-z_$][\w$]*$/.test(key) ? key : `'${key.replace(/'/g, '\\\'')}'`;
const body = t.slice(open + 1, close);
if (!body.includes('\n')) {
const lit = toTs(value, lineIndent(t, open));
const inner = body.trim().replace(/,$/, '');
return `${t.slice(0, open + 1)} ${inner ? `${inner}, ` : ''}${k}: ${lit} ${t.slice(close)}`;
}
const lastM = list.at(-1);
const ind = lastM ? lineIndent(t, lastM.start) : lineIndent(t, open) + step(t);
const lit = toTs(value, ind);
if (lastM) {
const needComma = t.slice(lastM.valueEnd, lastM.end).indexOf(',') < 0;
// after the last member's comma and any comment on the same line
let at = lastM.end;
const nl = t.indexOf('\n', at);
if (nl >= 0 && nl < close && /^[ \t]*(\/\/[^\n]*)?$/.test(t.slice(at, nl))) at = nl;
const pre = needComma ? t.slice(0, lastM.valueEnd) + ',' + t.slice(lastM.valueEnd, at) : t.slice(0, at);
return `${pre}\n${ind}${k}: ${lit},${t.slice(at)}`;
}
return `${t.slice(0, open + 1)}\n${ind}${k}: ${lit},${t.slice(open + 1)}`;
}
function removeMember(t: string, m: Member, list: Member[], open: number, close: number): string {
const ls = t.lastIndexOf('\n', m.start - 1) + 1;
const ownLine = /^[ \t]*$/.test(t.slice(ls, m.start));
let end = m.end;
const nl = t.indexOf('\n', end);
if (ownLine && nl >= 0 && nl <= close && /^[ \t]*(\/\/[^\n]*)?$/.test(t.slice(end, nl))) end = nl + 1;
const start = ownLine ? ls : m.start;
let out = t.slice(0, start) + t.slice(end);
if (list.length === 1 && !t.slice(open, close).includes('\n')) out = t.slice(0, open + 1) + t.slice(close);
else if (m === list.at(-1) && t.slice(m.valueEnd, m.end).indexOf(',') < 0 && list.length > 1) {
// the previous member keeps a trailing comma: valid in TS and JS
}
return out;
}
function setJson(text: string, parts: PathPart[], value: unknown): string {
const root = JSON.parse(text) as Record<string, unknown>;
let cur: unknown = root;
for (let n = 0; n < parts.length; n++) {
const p = parts[n]!;
const last = n === parts.length - 1;
if ('key' in p) {
if (typeof cur !== 'object' || cur === null || Array.isArray(cur)) throw new Error(`${pathText(parts.slice(0, n))} is not an object`);
const o = cur as Record<string, unknown>;
if (last) { if (value === undefined) delete o[p.key]; else o[p.key] = value; break; }
if (o[p.key] === undefined) { if (value === undefined) return text; o[p.key] = {}; }
cur = o[p.key];
} else {
if (!Array.isArray(cur)) throw new Error(`${pathText(parts.slice(0, n))} is not a list`);
const i = 'index' in p ? p.index : cur.findIndex(x => x && typeof x === 'object' && (x as Record<string, unknown>)[p.match] === p.eq);
if (i < 0 || i >= cur.length) throw new Error(`${pathText(parts.slice(0, n + 1))}: no such element in the config`);
if (last) { if (value === undefined) cur.splice(i, 1); else cur[i] = value; break; }
cur = cur[i];
}
}
const ind = /\n([ \t]+)"/.exec(text)?.[1] ?? ' ';
return JSON.stringify(root, null, ind) + (text.endsWith('\n') ? '\n' : '');
}

View file

@ -850,14 +850,9 @@ export interface ConsoleConfig {
appContent?: AppContentConfig;
}
/** Play Console's game categories (Store settings > App category, app type Game). */
export const PLAY_GAME_CATEGORIES = ['Action', 'Adventure', 'Arcade', 'Board', 'Card', 'Casino', 'Casual', 'Educational', 'Music', 'Puzzle', 'Racing',
'Role Playing', 'Simulation', 'Sports', 'Strategy', 'Trivia', 'Word'] as const;
/** Play Console's app categories (Store settings > App category, app type App). */
export const PLAY_APP_CATEGORIES = ['Art & Design', 'Auto & Vehicles', 'Beauty', 'Books & Reference', 'Business', 'Comics', 'Communication', 'Dating',
'Education', 'Entertainment', 'Events', 'Finance', 'Food & Drink', 'Health & Fitness', 'House & Home', 'Libraries & Demo', 'Lifestyle',
'Maps & Navigation', 'Medical', 'Music & Audio', 'News & Magazines', 'Parenting', 'Personalization', 'Photography', 'Productivity', 'Shopping',
'Social', 'Sports', 'Tools', 'Travel & Local', 'Video Players & Editors', 'Weather'] as const;
export { PLAY_APP_CATEGORIES, PLAY_GAME_CATEGORIES } from './store-catalogs';
import { PLAY_APP_CATEGORIES, PLAY_GAME_CATEGORIES } from './store-catalogs';
/** App Store category ids with an exact Play counterpart. */
const APPLE_TO_PLAY: Record<string, string> = {
GAMES_ACTION: 'Action', GAMES_ADVENTURE: 'Adventure', GAMES_BOARD: 'Board', GAMES_CARD: 'Card', GAMES_CASINO: 'Casino', GAMES_CASUAL: 'Casual',

View file

@ -413,3 +413,44 @@ export interface ConsoleFillResult {
/** Real run only: fields filled, fields highlighted, whether it fell back to highlighting only, where it stopped. */
filled?: number; highlighted?: number; fallback?: unknown; stoppedAt?: string | null;
}
// ---- Editors (console parity, docs/console-parity.md) ----
/** One listing language of one store as the listing file holds it (project.editable). `exists`: the file has a section. */
export interface EditableListing { lang: string; language: string; status: string | null; exists: boolean; values: Record<string, string>; codes?: string[] }
/** project.editable (`edit --show --json`): what the desktop's editors show and change. */
export interface EditableResult {
root: string;
files: { config: string; play: string | null; apple: string | null; contact: string };
format: 'ts' | 'js' | 'json';
/** The app folder is a git work tree; which of the editable files have uncommitted changes. storebound never commits. */
git: { repo: boolean; uncommitted: string[] };
appName: string;
appleName: string;
languages: { id: string; play: string[]; apple: string | null; rtl: boolean }[];
play: EditableListing[] | null;
apple: EditableListing[] | null;
/** The config sections the editors change, as the config holds them (plain data). */
config: {
app: Record<string, unknown>; apple: Record<string, unknown> | null; play: Record<string, unknown> | null;
products: Record<string, unknown>[]; subscriptions: Record<string, unknown>[];
dataSafety: Record<string, unknown> | null; appPrivacy: Record<string, unknown> | null; reviewAccess: Record<string, unknown> | null;
accounts: Record<string, unknown> | null; social: Record<string, unknown> | null;
/** Screenshot scenes in store order and the feature graphic (shots). */
shots: { scenes: { id: string; frames?: { src: string }[] }[]; feature: Record<string, unknown> | null } | null;
};
/** App Review contact from .storebound/credentials.json, masked (personal data); null when not set. */
contact: { firstName: string; lastName: string; phone: string; email: string } | null;
/** The App Review demo account's user name, masked; the password is never read out. */
demoAccount: string | null;
limits: Record<string, Record<string, number> | number>;
}
/** project.edit (`edit --ops --json`): the plan (and with a confirm token, the write). */
export interface EditResult {
changes: { what: string; file: string; from: string | null; to: string | null; also?: string[] }[];
files: { file: string; rel: string; created: boolean; diff: string[]; masked?: boolean }[];
issues: { level: 'error' | 'warn' | 'info'; field: string; where: string; msg: string }[];
errors: number;
written: boolean;
dry: boolean;
git: { repo: boolean; uncommitted: string[] };
}

83
cli/lib/edit-values.ts Normal file
View file

@ -0,0 +1,83 @@
// What the desktop's editors show (`storebound edit --show --json`, serve method project.editable): the listing
// fields per store and language as the files hold them, the config sections the editors change, the App Review
// contact (masked: personal data), the store limits, and whether the files are uncommitted in git. Offline.
import { existsSync, readFileSync } from 'node:fs';
import { join, relative } from 'node:path';
import { APPLE_IAP_LIMITS, APPLE_LIMITS, PLAY_LIMITS } from './copy';
import { appleName, type Loaded, playCodes } from './config';
import { maskUser, reviewDemoAccount } from './credentials';
import type { EditableResult } from './desktop-protocol';
import { appleSection, playSection } from './listing-edit';
import { NOTES_MAX_BYTES } from './review-details';
import * as rt from './rt';
import { APPLE_SUB_LIMITS, PLAY_SUB_LIMITS } from './subscriptions';
/** Is `root` in a git work tree, and which of `files` have uncommitted changes (or are untracked)? */
export function gitState(root: string, files: string[]): { repo: boolean; uncommitted: string[] } {
try {
const inside = rt.spawnSync(['git', '-C', root, 'rev-parse', '--is-inside-work-tree'], { timeout: 5000 });
if (!inside.success || inside.stdout.toString().trim() !== 'true') return { repo: false, uncommitted: [] };
if (!files.length) return { repo: true, uncommitted: [] };
const st = rt.spawnSync(['git', '-C', root, 'status', '--porcelain', '--untracked-files=all', '--', ...files.map(f => relative(root, f) || f)], { timeout: 5000 });
if (!st.success) return { repo: true, uncommitted: [] };
const out = st.stdout.toString().split('\n').filter(Boolean).map(l => l.slice(3).replace(/^"|"$/g, ''));
// porcelain paths are relative to the repository top: keep the file names the window knows
const rels = files.map(f => relative(root, f) || f);
return { repo: true, uncommitted: rels.filter(r => out.some(o => o === r || o.endsWith(`/${r}`))) };
} catch {
return { repo: false, uncommitted: [] };
}
}
const mask = (field: string, v: string): string => (!v ? '' : field === 'email' ? maskUser(v) : field === 'phone' ? `${v.slice(0, 3)}***` : `${v[0]}***`);
export function editableValues(l: Loaded): EditableResult {
const { config, root, path, resolve } = l;
const rel = (f: string): string => relative(root, f) || f;
const playFile = config.play ? resolve(config.play.listing ?? 'store/listing.md') : null;
const appleFile = config.apple ? resolve(config.apple.listing ?? 'store/appstore.md') : null;
const playMd = playFile && existsSync(playFile) ? readFileSync(playFile, 'utf8') : null;
const appleMd = appleFile && existsSync(appleFile) ? readFileSync(appleFile, 'utf8') : null;
const contactFile = join(root, '.storebound', 'credentials.json');
let contact: EditableResult['contact'] = null;
try {
const c = existsSync(contactFile) ? (JSON.parse(readFileSync(contactFile, 'utf8')) as { reviewContact?: Record<string, string> }).reviewContact : undefined;
if (c) contact = { firstName: mask('firstName', c.firstName ?? ''), lastName: mask('lastName', c.lastName ?? ''), phone: mask('phone', c.phone ?? ''), email: mask('email', c.email ?? '') };
} catch { /* unreadable: the declarations card names it */ }
let demo: string | null = null;
try { const d = reviewDemoAccount(root); demo = d ? maskUser(d.username) : null; } catch { /* unreadable */ }
const play: EditableResult['play'] = [];
const apple: EditableResult['apple'] = [];
for (const lang of config.languages) {
if (config.play) for (const code of playCodes(lang)) {
const s = playMd ? playSection(playMd, code) : null;
play.push({ lang: code, language: lang.id, codes: s?.codes ?? [code], status: s?.status ?? null, exists: !!s, values: s?.values ?? {} });
}
if (config.apple && lang.apple) {
const s = appleMd ? appleSection(appleMd, lang.apple) : null;
apple.push({ lang: lang.apple, language: lang.id, status: s?.status ?? null, exists: !!s, values: s?.values ?? {} });
}
}
const files = [path, ...(playFile ? [playFile] : []), ...(appleFile ? [appleFile] : [])];
const { signing: _s, build: _b, adhoc: _a, ...appleRest } = config.apple ?? ({} as NonNullable<typeof config.apple>);
const { build: _pb, ...playRest } = config.play ?? ({} as NonNullable<typeof config.play>);
return {
root,
files: { config: rel(path), play: playFile ? rel(playFile) : null, apple: appleFile ? rel(appleFile) : null, contact: rel(contactFile) },
format: path.endsWith('.json') ? 'json' : path.endsWith('.js') ? 'js' : 'ts',
git: gitState(root, files),
appName: config.app.name,
appleName: appleName(config),
languages: config.languages.map(x => ({ id: x.id, play: playCodes(x), apple: x.apple ?? null, rtl: !!x.rtl })),
play: config.play ? play : null,
apple: config.apple ? apple : null,
config: JSON.parse(JSON.stringify({
app: config.app, apple: config.apple ? appleRest : null, play: config.play ? playRest : null, products: config.products ?? [], subscriptions: config.subscriptions ?? [],
dataSafety: config.dataSafety ?? null, appPrivacy: config.appPrivacy ?? null, reviewAccess: config.reviewAccess ?? null, accounts: config.accounts ?? null, social: config.social ?? null,
shots: config.shots ? { scenes: config.shots.scenes, feature: config.shots.feature ?? null } : null,
})) as EditableResult['config'],
contact,
demoAccount: demo,
limits: { play: PLAY_LIMITS, apple: APPLE_LIMITS, iap: APPLE_IAP_LIMITS, appleSub: APPLE_SUB_LIMITS, playSub: PLAY_SUB_LIMITS, reviewNotesBytes: NOTES_MAX_BYTES },
};
}

188
cli/lib/edit.ts Normal file
View file

@ -0,0 +1,188 @@
// The desktop's editors write through here (`storebound edit`, serve method project.edit): a list of edits of the
// app folder's sources of truth, planned as text edits, checked, shown as a diff, and written only when the checks
// pass. Sources:
// - storebound.config.ts / .js / .json: one value by path (config-path.ts), the rest of the file unchanged
// - store/listing.md (Google Play) and store/appstore.md (App Store): one field block (listing-edit.ts)
// - .storebound/credentials.json reviewContact (App Review contact: personal data, never in the config or a log line)
// The plan itself is pure (texts in, texts out); cli/edit.ts reads and writes the files.
import { compactDiff } from './diff';
import { setByPath } from './config-path';
import type { AppleField } from './copy';
import { appleSection, isAppleField, isPlayField, playSection, setAppleField, setPlayField } from './listing-edit';
import { appleLocaleIssues, emailIssue, type FieldIssue, playLangIssues, urlIssue } from './listing-rules';
export type EditOp =
| { kind: 'config'; path: string; value?: unknown; remove?: boolean }
| { kind: 'listing'; store: 'play' | 'apple'; lang: string; field: string; value: string }
| { kind: 'contact'; field: ContactField; value: string };
export type ContactField = 'firstName' | 'lastName' | 'phone' | 'email';
export const CONTACT_FIELDS: readonly ContactField[] = ['firstName', 'lastName', 'phone', 'email'];
export interface EditChange { what: string; file: string; from: string | null; to: string | null; also?: string[] }
export interface EditIssue extends FieldIssue { where: string }
export interface EditFile { file: string; rel: string; created: boolean; diff: string[]; masked?: boolean }
export interface EditPlan { changes: EditChange[]; files: EditFile[]; issues: EditIssue[]; errors: number }
/** What the plan needs to know about the folder: the files by role and their current text. */
export interface EditSources {
config: { file: string; rel: string; text: string };
play: { file: string; rel: string; text: string | null } | null;
apple: { file: string; rel: string; text: string | null } | null;
contact: { file: string; rel: string; text: string | null };
appName: string;
appleName: string;
}
/** Checks one edit's shape (it comes from the window or a terminal). Throws with the reason. */
export function checkOp(o: unknown, i: number): EditOp {
if (!o || typeof o !== 'object' || Array.isArray(o)) throw new Error(`edit ${i + 1}: an object`);
const e = o as Record<string, unknown>;
const str = (k: string, max: number): string => {
if (typeof e[k] !== 'string' || (e[k] as string).length > max) throw new Error(`edit ${i + 1}: ${k} must be text (at most ${max} characters)`);
return e[k] as string;
};
if (e.kind === 'config') {
const path = str('path', 300);
if (!/^[A-Za-z_$][\w$-]*(\.[A-Za-z0-9_$-]+|\[\d+\]|\[[A-Za-z_$][\w$]*=[^\]]{1,200}\])*$/.test(path)) throw new Error(`edit ${i + 1}: path ${path} is not a config path`);
if (/^(account|scan)\b/.test(path)) throw new Error(`edit ${i + 1}: ${path} is not edited from here`);
if (e.remove !== undefined && typeof e.remove !== 'boolean') throw new Error(`edit ${i + 1}: remove is true or false`);
if (e.value === undefined && e.remove !== true) throw new Error(`edit ${i + 1}: a value (or remove: true)`);
checkValue(e.value, `edit ${i + 1}`, 0);
return { kind: 'config', path, ...(e.remove === true ? { remove: true } : { value: e.value }) };
}
if (e.kind === 'listing') {
if (e.store !== 'play' && e.store !== 'apple') throw new Error(`edit ${i + 1}: store play or apple`);
const lang = str('lang', 20);
if (!/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(lang)) throw new Error(`edit ${i + 1}: ${lang} is not a language code`);
const field = str('field', 30);
if (e.store === 'play' ? !isPlayField(field) : !isAppleField(field)) throw new Error(`edit ${i + 1}: ${String(e.store)} has no field ${field}`);
return { kind: 'listing', store: e.store, lang, field, value: str('value', 20_000) };
}
if (e.kind === 'contact') {
const field = str('field', 20) as ContactField;
if (!CONTACT_FIELDS.includes(field)) throw new Error(`edit ${i + 1}: contact field ${field}`);
return { kind: 'contact', field, value: str('value', 200) };
}
throw new Error(`edit ${i + 1}: kind config, listing or contact`);
}
/** Plain JSON data only (no functions), not too deep or big. */
function checkValue(v: unknown, at: string, depth: number): void {
if (depth > 8) throw new Error(`${at}: value nested too deep`);
if (v === null || v === undefined || typeof v === 'boolean' || typeof v === 'string') return;
if (typeof v === 'number') { if (!Number.isFinite(v)) throw new Error(`${at}: a finite number`); return; }
if (Array.isArray(v)) { if (v.length > 500) throw new Error(`${at}: list too long`); for (const x of v) checkValue(x, at, depth + 1); return; }
if (typeof v === 'object') { const e = Object.entries(v as Record<string, unknown>); if (e.length > 300) throw new Error(`${at}: too many keys`); for (const [, x] of e) checkValue(x, at, depth + 1); return; }
throw new Error(`${at}: plain data only`);
}
export const parseOps = (json: string): EditOp[] => {
let raw: unknown;
try { raw = JSON.parse(json); } catch (e) { throw new Error(`--ops: not JSON (${(e as Error).message})`); }
const list = Array.isArray(raw) ? raw : [raw];
if (!list.length) throw new Error('--ops: no edits');
if (list.length > 400) throw new Error('--ops: at most 400 edits at once');
return list.map(checkOp);
};
const show = (v: unknown): string | null => (v === undefined || v === null ? null : typeof v === 'string' ? v : JSON.stringify(v));
const maskValue = (field: ContactField, v: string): string => (!v ? '' : field === 'email' ? `${v[0]}***${v.slice(v.indexOf('@'))}` : field === 'phone' ? `${v.slice(0, 3)}***` : `${v[0]}***`);
/** Values the config paths of the editors hold: URL and email fields get their format checked. */
const URL_PATHS = /^(app\.(privacyPolicyUrl|termsUrl|supportUrl|marketingUrl|website)|accounts\.deletionUrl|dataSafety\.partialDeletionUrl|play\.video)$/;
const EMAIL_PATHS = /^app\.contactEmail$/;
/**
* The plan of a list of edits: new file texts, what changes (old -> new), the diff per file and the checks. Pure.
* `getPath` reads a value of the loaded config by path (for the "from" side of config edits).
*/
export function planEdits(ops: EditOp[], src: EditSources, getPath: (path: string) => unknown): { plan: EditPlan; texts: Map<string, string> } {
const texts = new Map<string, string>();
const before = new Map<string, string | null>();
const rel = new Map<string, string>();
const read = (f: { file: string; rel: string; text: string | null }): string => {
if (!texts.has(f.file)) { texts.set(f.file, f.text ?? ''); before.set(f.file, f.text); rel.set(f.file, f.rel); }
return texts.get(f.file)!;
};
const changes: EditChange[] = [];
const issues: EditIssue[] = [];
const touched = { play: new Set<string>(), apple: new Set<string>() };
const onlyWhatToTest = new Map<string, boolean>();
let contact: Record<string, unknown> | null = null;
for (const op of ops) {
if (op.kind === 'config') {
const t = read(src.config);
const value = op.remove ? undefined : op.value;
const next = setByPath(t, src.config.file, op.path, value);
texts.set(src.config.file, next);
changes.push({ what: op.path, file: src.config.rel, from: show(getPath(op.path)), to: show(value) });
if (typeof value === 'string' && URL_PATHS.test(op.path)) { const u = urlIssue(value); if (u) issues.push({ level: 'error', field: op.path, where: op.path, msg: u }); }
if (typeof value === 'string' && EMAIL_PATHS.test(op.path)) { const u = emailIssue(value); if (u) issues.push({ level: 'error', field: op.path, where: op.path, msg: u }); }
} else if (op.kind === 'listing') {
const f = op.store === 'play' ? src.play : src.apple;
if (!f) throw new Error(`this app has no ${op.store === 'play' ? 'Google Play' : 'App Store'} section in the config`);
const t = read(f);
const r = op.store === 'play' ? setPlayField(t, op.lang, op.field as never, op.value) : setAppleField(t, op.lang, op.field as AppleField | 'whatToTest', op.value);
texts.set(f.file, r.md);
changes.push({ what: `${op.store === 'play' ? 'Google Play' : 'App Store'} ${op.lang} ${op.field}`, file: f.rel, from: r.from, to: op.value.trim() || null, ...(r.codes.length > 1 ? { also: r.codes.filter(c => c !== op.lang) } : {}) });
touched[op.store].add(op.lang);
if (op.store === 'apple') onlyWhatToTest.set(op.lang, (onlyWhatToTest.get(op.lang) ?? true) && op.field === 'whatToTest');
} else {
read(src.contact);
contact ??= src.contact.text ? JSON.parse(src.contact.text) as Record<string, unknown> : {};
const rc = { ...((contact.reviewContact ?? {}) as Record<string, string>) };
const from = rc[op.field] ?? '';
rc[op.field] = op.value.trim();
contact.reviewContact = rc;
if (op.field === 'email') { const u = emailIssue(op.value.trim()); if (u) issues.push({ level: 'error', field: 'email', where: 'App Review contact', msg: u }); }
texts.set(src.contact.file, JSON.stringify(contact, null, 2) + '\n');
changes.push({ what: `App Review contact ${op.field}`, file: src.contact.rel, from: from ? maskValue(op.field, from) : null, to: op.value.trim() ? maskValue(op.field, op.value.trim()) : null });
}
}
// the touched languages as they would be saved, with the checks of `check` / `check --aso` / `scan`
if (src.play) for (const lang of touched.play) {
const s = playSection(texts.get(src.play.file) ?? '', lang);
if (s) for (const x of playLangIssues(lang, s.values, src.appName)) issues.push({ ...x, where: `Google Play ${lang}` });
}
if (src.apple) for (const loc of touched.apple) {
const s = appleSection(texts.get(src.apple.file) ?? '', loc);
if (s && onlyWhatToTest.get(loc)) { const n = [...(s.values.whatToTest ?? '')].length; if (n > 4000) issues.push({ level: 'error', field: 'whatToTest', where: `TestFlight ${loc}`, msg: `What to Test is ${n} characters; TestFlight takes 4000` }); continue; }
if (s) for (const x of appleLocaleIssues(loc, s.values, src.appleName)) issues.push({ ...x, where: `App Store ${loc}` });
}
const files: EditFile[] = [];
for (const [file, after] of texts) {
const b = before.get(file) ?? null;
if (b === after) continue;
const masked = file === src.contact.file;
files.push({ file, rel: rel.get(file)!, created: b === null, diff: masked ? [] : compactDiff(b ?? '', after) , ...(masked ? { masked: true } : {}) });
}
for (const [file] of [...texts]) if (!files.some(f => f.file === file)) texts.delete(file);
return { plan: { changes, files, issues, errors: issues.filter(i => i.level === 'error').length }, texts };
}
/** A value of a plain object by config path (`products[id=x].listings.en.title`); undefined when missing. */
export function getByPath(root: unknown, path: string): unknown {
let cur: unknown = root;
const re = /([A-Za-z0-9_$-]+)|\[(\d+)\]|\[([A-Za-z_$][\w$]*)=([^\]]+)\]/g;
for (let m = re.exec(path); m; m = re.exec(path)) {
if (cur === null || cur === undefined) return undefined;
if (m[1] !== undefined) cur = (cur as Record<string, unknown>)[m[1]];
else if (m[2] !== undefined) cur = Array.isArray(cur) ? cur[Number(m[2])] : undefined;
else cur = Array.isArray(cur) ? cur.find(x => x && typeof x === 'object' && (x as Record<string, unknown>)[m![3]!] === m![4]) : undefined;
}
return cur;
}
/** Text lines of a plan for a terminal and the confirm dialog (personal data masked). */
export function planLines(p: EditPlan): string[] {
const cut = (s: string | null): string => (s === null ? '(none)' : s.length > 70 ? `"${s.slice(0, 67).replace(/\n/g, ' ')}..."` : `"${s.replace(/\n/g, ' ')}"`);
const out: string[] = [];
for (const c of p.changes) out.push(`${c.what}: ${cut(c.from)} -> ${cut(c.to)}${c.also?.length ? ` (the same section also serves ${c.also.join(', ')})` : ''}`);
for (const i of p.issues) out.push(`${i.level === 'error' ? 'ERROR' : i.level === 'warn' ? 'warn ' : 'hint '} [${i.where}] ${i.msg}`);
for (const f of p.files) {
out.push(`--- ${f.rel}${f.created ? ' (new file)' : ''}`);
if (f.masked) out.push(' (personal data: not shown)');
else out.push(...f.diff.map(l => ` ${l}`));
}
return out;
}

119
cli/lib/listing-edit.ts Normal file
View file

@ -0,0 +1,119 @@
// One field of the listing files (store/listing.md for Google Play, store/appstore.md for the App Store) set as a text
// edit: only the fenced block of that field changes; headings, prose, comments and the other sections stay as they
// are. Used by `storebound edit` (the desktop's listing editor). Pure.
import { APPLE_LIMITS, type AppleField, PLAY_LIMITS } from './copy';
export type PlayField = 'title' | 'short' | 'full' | 'whatsNew';
export const PLAY_FIELDS: readonly PlayField[] = ['title', 'short', 'full', 'whatsNew'];
export const APPLE_FIELDS: readonly AppleField[] = ['name', 'subtitle', 'promotionalText', 'keywords', 'description', 'whatsNew'];
/** TestFlight "What to Test" per locale lives in the App Store listing file too (`apple testflight what-to-test`). */
export const WHAT_TO_TEST = 'whatToTest';
export const WHAT_TO_TEST_MAX = 4000;
type AppleEditField = AppleField | typeof WHAT_TO_TEST;
/** The heading a field's block sits under (the parsers in copy.ts match these) and the one a new block gets. */
const PLAY_HEAD: Record<PlayField, { re: RegExp; heading: string }> = {
title: { re: /[^\n]*\btitle\b/i, heading: `Title (max ${PLAY_LIMITS.title})` },
short: { re: /[^\n]*\bshort\b/i, heading: `Short description (max ${PLAY_LIMITS.short})` },
full: { re: /[^\n]*\bfull\b/i, heading: `Full description (max ${PLAY_LIMITS.full})` },
whatsNew: { re: /[^\n]*(?:what'?s new|release notes)/i, heading: `What's new (max ${PLAY_LIMITS.whatsNew})` },
};
const fence = (t: string): string => '```\n' + t + '\n```';
const blockRe = (heading: RegExp): RegExp => new RegExp(`(^### (?:${heading.source})[^\\n]*\\n)\`\`\`[^\\n]*\\n([\\s\\S]*?)\\n?\`\`\`\\n?`, 'm' + (heading.flags.includes('i') ? 'i' : ''));
/** One section with `text` as the block under `heading` (replaced, appended, or removed when text is empty). */
function setBlock(sec: string, heading: RegExp, newHeading: string, text: string): { sec: string; from: string | null } {
const re = blockRe(heading);
const m = re.exec(sec);
const from = m ? m[2]!.trim() : null;
if (!text) {
if (!m) return { sec, from };
// the heading line, its block and one blank line after it
const start = m.index, end = m.index + m[0].length;
const rest = sec.slice(end).replace(/^\n/, '');
// the last block of the section: the section ends as it did before the block (one newline)
if (!rest.trim()) return { sec: sec.slice(0, start).replace(/\s*$/, '\n'), from };
return { sec: sec.slice(0, start) + rest, from };
}
if (m) return { sec: sec.slice(0, m.index) + m[1] + fence(text) + '\n' + sec.slice(m.index + m[0].length), from };
const body = sec.replace(/\s*$/, '');
return { sec: `${body}\n\n### ${newHeading}\n${fence(text)}\n\n`, from: null };
}
export interface ListingEditResult { md: string; from: string | null; codes: string[]; created: boolean }
/** Sets one Play field of the section that serves `lang` (all its codes change: one section can serve several Play
* languages). A language without a section gets a new one at the end. */
export function setPlayField(md: string, lang: string, field: PlayField, text: string): ListingEditResult {
const parts = md.split(/^(?=## )/m);
const v = text.replace(/\r\n/g, '\n').trim();
for (let i = 0; i < parts.length; i++) {
const m = /^<!--\s*play:\s*([^>]+?)\s*-->$/m.exec(parts[i]!);
if (!m || !parts[i]!.startsWith('## ')) continue;
const codes = m[1]!.split(',').map(s => s.trim()).filter(Boolean);
if (!codes.includes(lang)) continue;
const r = setBlock(parts[i]!, PLAY_HEAD[field].re, PLAY_HEAD[field].heading, v);
parts[i] = r.sec;
return { md: parts.join(''), from: r.from, codes, created: false };
}
if (!v) return { md, from: null, codes: [lang], created: false };
const sec = `## ${lang}\n<!-- play: ${lang} -->\n\n### ${PLAY_HEAD[field].heading}\n${fence(v)}\n`;
return { md: `${md.replace(/\s*$/, '')}\n\n${sec}`, from: null, codes: [lang], created: true };
}
/** Sets one App Store field of the `locale` section (a missing section is added at the end). */
export function setAppleField(md: string, locale: string, field: AppleEditField, text: string): ListingEditResult {
const parts = md.split(/^(?=## )/m);
const v = text.replace(/\r\n/g, '\n').trim();
for (let i = 0; i < parts.length; i++) {
const sec = parts[i]!;
if (!sec.startsWith('## ')) continue;
if (sec.slice(3).split(/[\s·]/)[0]!.trim() !== locale) continue;
const r = setBlock(sec, new RegExp(`${field}\\b`), field, v);
parts[i] = r.sec;
return { md: parts.join(''), from: r.from, codes: [locale], created: false };
}
if (!v) return { md, from: null, codes: [locale], created: false };
return { md: `${md.replace(/\s*$/, '')}\n\n## ${locale}\n\n### ${field}\n${fence(v)}\n`, from: null, codes: [locale], created: true };
}
export const isPlayField = (f: string): f is PlayField => (PLAY_FIELDS as readonly string[]).includes(f);
export const isAppleField = (f: string): f is AppleEditField => f in APPLE_LIMITS || f === WHAT_TO_TEST;
/** The fields of the Play section that serves `lang` (null: no section). */
export function playSection(md: string, lang: string): { codes: string[]; heading: string; values: Partial<Record<PlayField, string>>; status: string | null } | null {
for (const sec of md.split(/^(?=## )/m)) {
if (!sec.startsWith('## ')) continue;
const m = /^<!--\s*play:\s*([^>]+?)\s*-->$/m.exec(sec);
if (!m) continue;
const codes = m[1]!.split(',').map(s => s.trim()).filter(Boolean);
if (!codes.includes(lang)) continue;
const values: Partial<Record<PlayField, string>> = {};
for (const f of PLAY_FIELDS) { const b = blockRe(PLAY_HEAD[f].re).exec(sec); if (b) values[f] = b[2]!.trim(); }
return { codes, heading: sec.slice(3).split('\n')[0]!.trim(), values, status: translationStatus(sec) };
}
return null;
}
/** The fields of one App Store locale section (null: no section). */
export function appleSection(md: string, locale: string): { heading: string; values: Partial<Record<AppleEditField, string>>; status: string | null } | null {
for (const sec of md.split(/^(?=## )/m)) {
if (!sec.startsWith('## ') || sec.slice(3).split(/[\s·]/)[0]!.trim() !== locale) continue;
const values: Partial<Record<AppleEditField, string>> = {};
for (const f of [...APPLE_FIELDS, WHAT_TO_TEST] as const) { const b = blockRe(new RegExp(`${f}\\b`)).exec(sec); if (b) values[f] = b[2]!.trim(); }
return { heading: sec.slice(3).split('\n')[0]!.trim(), values, status: translationStatus(sec) };
}
return null;
}
/** `listing translate` marks: status=translate (not translated yet) or status=draft (machine draft, not reviewed). */
function translationStatus(sec: string): string | null {
return /<!--\s*storebound-translation\s+from=\S+\s+status=(\w+)/.exec(sec)?.[1] ?? null;
}
/** The store limit of a listing field (characters). */
export function listingLimit(store: 'play' | 'apple', field: string): number | null {
if (store === 'apple') return field === WHAT_TO_TEST ? WHAT_TO_TEST_MAX : field in APPLE_LIMITS ? APPLE_LIMITS[field as AppleField] : null;
return isPlayField(field) ? PLAY_LIMITS[field] : null;
}

114
cli/lib/listing-rules.ts Normal file
View file

@ -0,0 +1,114 @@
// Checks of one listing language as the owner types it: the store limits (characters), the fields the parsers need,
// and the metadata rules `scan` and `check --aso` apply to the whole file (other platforms named in App Store text,
// other apps' names or prices in the name / subtitle / keywords, placeholder text, beta wording, keyword stuffing).
// Pure, no Node imports: the desktop window runs it on every keystroke, `storebound edit` before it writes.
import { auditAppleKeywords, auditPlayListing } from './aso';
import { APPLE_LIMITS, type AppleCopy, len, PLAY_LIMITS } from './copy';
export type IssueLevel = 'error' | 'warn' | 'info';
export interface FieldIssue { level: IssueLevel; field: string; msg: string }
/** Other apps and trademarks that get App Store metadata rejected (2.3.7) in the name, subtitle and keywords. */
export const COMPETITORS = ['instagram', 'tiktok', 'whatsapp', 'facebook', 'snapchat', 'youtube', 'netflix', 'spotify', 'telegram', 'messenger', 'twitter', 'pinterest', 'uber', 'airbnb',
'amazon', 'chatgpt', 'openai', 'minecraft', 'roblox', 'fortnite', 'pubg', 'candy crush', 'clash of clans', 'pokemon', 'pokémon', 'among us', 'subway surfers', 'temple run',
'angry birds', 'tetris', 'wordle', 'duolingo', 'zoom', 'gmail', 'google maps', 'waze', 'shazam', 'tinder', 'notion', 'evernote', 'photoshop', 'canva', 'capcut', 'iphone', 'ipad', 'apple'];
/** Other mobile platforms or stores in App Store text (2.3.10). */
export const OTHER_PLATFORMS = /\b(Android|Google Play|Play Store|Galaxy Store|AppGallery|Huawei|Windows Phone|BlackBerry|Amazon Appstore)\b/;
// Developer markers count only in capitals ("TODO:", "FIXME"): "todo" is a real word and keyword (a to-do app).
export const PLACEHOLDER_MARKERS = /\bTODO\b|\bTBD\b|\bFIXME\b|\bXXX\b/;
export const PLACEHOLDER_TEXT = /lorem ipsum|\bplaceholder\b|your app (name|description) here|coming soon|First paragraph:|Short hook under the name|One or two sentences you can change|İsmin altındaki kısa kanca|Yeni sürüm göndermeden istediğin zaman|\bExample (Game|App|Pro|Studio)\b/i;
/** Demo, beta and trial wording (Apple 2.2: those belong on TestFlight). */
export const BETA_WORDS = /\b(beta|demo|trial version|test (version|build|app)|testing build|early access|prototype|preview (build|version)|alpha (build|version))\b/i;
/** Prices and superlatives in the App Store name, subtitle or keywords (2.3.7). */
export const PRICE_CLAIMS = /\b(free|best|#1|no\.? ?1|top|cheap|sale|\d+% off|\$\d)\b/i;
export const competitorRe = (): RegExp => new RegExp(`(^|[\\s,|/–-])(${COMPETITORS.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})(?=$|[\\s,|/–-])`, 'i');
export const PLAY_REQUIRED = ['short', 'full'] as const;
export const APPLE_REQUIRED = ['subtitle', 'promotionalText', 'keywords', 'description'] as const;
/**
* Issues of one App Store locale (the values as they would be saved). `appName`: what the name falls back to
* (apple.name, else app.name). Errors stop a save (the store would refuse it, or the listing file would no longer
* parse); warnings and hints do not.
*/
export function appleLocaleIssues(locale: string, v: AppleCopy, appName: string): FieldIssue[] {
const out: FieldIssue[] = [];
for (const f of APPLE_REQUIRED) if (!(v[f] ?? '').trim()) out.push({ level: 'error', field: f, msg: `${locale}: ${f} is required` });
for (const [f, lim] of Object.entries(APPLE_LIMITS) as [keyof typeof APPLE_LIMITS, number][]) {
const n = len(v[f] ?? '');
if (n > lim) out.push({ level: 'error', field: f, msg: `${f} is ${n} characters; the App Store takes ${lim}` });
}
const name = (v.name ?? appName).trim();
if (!v.name && len(name) > APPLE_LIMITS.name) out.push({ level: 'error', field: 'name', msg: `the name falls back to "${name}" (${len(name)} > ${APPLE_LIMITS.name}); give this locale its own name` });
if (v.keywords) {
// auditAppleKeywords: the 100-character limit, spaces around commas, duplicates, words the name / subtitle index
for (const a of auditAppleKeywords(locale, v, appName)) {
if (/characters > 100/.test(a.msg)) continue; // the limit above already says it
out.push({ level: a.level, field: 'keywords', msg: a.msg.replace(/^keywords:\s*/, '') });
}
}
const comp = competitorRe();
for (const f of ['name', 'subtitle', 'keywords'] as const) {
const t = f === 'name' ? name : v[f] ?? '';
if (!t) continue;
const c = comp.exec(t);
if (c) out.push({ level: 'warn', field: f, msg: `"${c[2]}" is another app's name or a trademark; Apple rejects it in the ${f} (2.3.7)` });
const p = PRICE_CLAIMS.exec(t);
if (p) out.push({ level: 'warn', field: f, msg: `"${p[0]}": prices and claims do not belong in the ${f} (2.3.7)` });
}
for (const f of ['name', 'subtitle'] as const) {
const t = f === 'name' ? name : v[f] ?? '';
if (/^([^,|]{1,24}[,|]){5,}/.test(t)) out.push({ level: 'warn', field: f, msg: `reads like a keyword list; the ${f} is a short phrase (2.3.7)` });
}
for (const f of ['name', 'subtitle', 'promotionalText', 'keywords', 'description', 'whatsNew'] as const) {
const t = f === 'name' ? (v.name ?? '') : v[f] ?? '';
if (!t) continue;
const o = OTHER_PLATFORMS.exec(t);
if (o) out.push({ level: 'warn', field: f, msg: `"${o[0]}": other platforms or stores in App Store text get it rejected (2.3.10)` });
if (PLACEHOLDER_MARKERS.test(t) || PLACEHOLDER_TEXT.test(t)) out.push({ level: 'warn', field: f, msg: 'placeholder text (template or TODO) is still in it' });
const b = f === 'whatsNew' ? null : BETA_WORDS.exec(t);
if (b && !/\bdemo account\b|\bdemo (video|mode)\b/i.test(t)) out.push({ level: 'warn', field: f, msg: `"${b[0]}": demos, betas and trials belong on TestFlight (2.2)` });
}
return out;
}
export interface PlayValues { title?: string; short?: string; full?: string; whatsNew?: string }
/** Issues of one Google Play language (title falls back to app.name). */
export function playLangIssues(lang: string, v: PlayValues, appName: string): FieldIssue[] {
const out: FieldIssue[] = [];
for (const f of PLAY_REQUIRED) if (!(v[f] ?? '').trim()) out.push({ level: 'error', field: f, msg: `${lang}: the ${f} description is required` });
const title = (v.title ?? '').trim() || appName;
for (const [f, lim] of Object.entries(PLAY_LIMITS) as [keyof typeof PLAY_LIMITS, number][]) {
const n = len(f === 'title' ? title : v[f] ?? '');
if (n > lim) out.push({ level: 'error', field: f, msg: f === 'title' && !v.title ? `the title falls back to "${title}" (${n} > ${lim}); give this language its own title` : `${f} is ${n} characters; Google Play takes ${lim}` });
}
if (v.short && v.full) {
for (const a of auditPlayListing({ lang, title, short: v.short, full: v.full, whatsNew: v.whatsNew ?? '' })) {
const field = /^full description/.test(a.msg) ? 'full' : /^title \/ short/.test(a.msg) ? 'short' : /^title/.test(a.msg) ? 'title' : /^short/.test(a.msg) ? 'short' : 'full';
out.push({ level: a.level, field, msg: a.msg });
}
}
for (const f of ['title', 'short', 'full', 'whatsNew'] as const) {
const t = v[f] ?? '';
if (t && (PLACEHOLDER_MARKERS.test(t) || PLACEHOLDER_TEXT.test(t))) out.push({ level: 'warn', field: f, msg: 'placeholder text (template or TODO) is still in it' });
}
return out;
}
/** An http(s) URL the stores take (empty: no issue; required fields say so themselves). */
export function urlIssue(v: string): string | null {
if (!v) return null;
try {
const u = new URL(v);
if (u.protocol !== 'https:' && u.protocol !== 'http:') return 'an http(s) address';
if (u.protocol === 'http:') return 'use https (the stores want a secure page)';
if (!u.hostname.includes('.')) return 'a public address (with a domain)';
return null;
} catch { return 'not a web address (https://...)'; }
}
export function emailIssue(v: string): string | null {
if (!v) return null;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? null : 'not an email address';
}

View file

@ -30,7 +30,7 @@ const ID = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
export function toTs(v: unknown, ind = ''): string {
const next = ind + ' ';
if (v === null || v === undefined) return 'undefined';
if (typeof v === 'string') return `'${v.replace(/\\/g, '\\\\').replace(/'/g, '\\\'')}'`;
if (typeof v === 'string') return `'${v.replace(/\\/g, '\\\\').replace(/'/g, '\\\'').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')}'`;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
if (Array.isArray(v)) {
const parts = v.map(x => toTs(x, next));

View file

@ -33,6 +33,7 @@
// error of Play Console (no policy page; developer.android.com/build/configure-app-module on choosing the id).
// The other Google Play rules (sensitive permissions, prominent disclosure, Families, advertising ID, 16 KB pages,
// testing for personal accounts, Play metadata, ads, payments, impersonation) are in play-rules.ts with their sources.
import { BETA_WORDS, competitorRe, OTHER_PLATFORMS, PLACEHOLDER_MARKERS, PLACEHOLDER_TEXT, PRICE_CLAIMS } from '../listing-rules';
import type { ShipConfig } from '../config';
import { allSubscriptions } from '../subscriptions';
import { sdkOfProjectPath } from '../privacy-files';
@ -127,13 +128,6 @@ export const SIGNALS = {
};
const ATT_SDKS_SHOW_PROMPT = new Set(['applovin', 'levelplay']);
const COMPETITORS = ['instagram', 'tiktok', 'whatsapp', 'facebook', 'snapchat', 'youtube', 'netflix', 'spotify', 'telegram', 'messenger', 'twitter', 'pinterest', 'uber', 'airbnb',
'amazon', 'chatgpt', 'openai', 'minecraft', 'roblox', 'fortnite', 'pubg', 'candy crush', 'clash of clans', 'pokemon', 'pokémon', 'among us', 'subway surfers', 'temple run',
'angry birds', 'tetris', 'wordle', 'duolingo', 'zoom', 'gmail', 'google maps', 'waze', 'shazam', 'tinder', 'notion', 'evernote', 'photoshop', 'canva', 'capcut', 'iphone', 'ipad', 'apple'];
const OTHER_PLATFORMS = /\b(Android|Google Play|Play Store|Galaxy Store|AppGallery|Huawei|Windows Phone|BlackBerry|Amazon Appstore)\b/;
// Developer markers count only in capitals ("TODO:", "FIXME"): "todo" is a real word and keyword (a to-do app).
const PLACEHOLDER_MARKERS = /\bTODO\b|\bTBD\b|\bFIXME\b|\bXXX\b/;
const PLACEHOLDER_TEXT = /lorem ipsum|\bplaceholder\b|your app (name|description) here|coming soon|First paragraph:|Short hook under the name|One or two sentences you can change|İsmin altındaki kısa kanca|Yeni sürüm göndermeden istediğin zaman|\bExample (Game|App|Pro|Studio)\b/i;
const PLACEHOLDER = { test: (l: string) => PLACEHOLDER_MARKERS.test(l) || PLACEHOLDER_TEXT.test(l) } as RegExp;
const FGS_PERMISSION: Record<string, string> = {
camera: 'CAMERA', connectedDevice: 'CONNECTED_DEVICE', dataSync: 'DATA_SYNC', health: 'HEALTH', location: 'LOCATION', mediaPlayback: 'MEDIA_PLAYBACK',
@ -368,12 +362,12 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
if (other.length)
add({ rule: 'other-platforms', guideline: '2.3.10', store: 'apple', severity: 'high', title: 'Other mobile platforms or stores named in the App Store metadata or screenshot captions', evidence: other.slice(0, 4),
fix: 'Remove "Android", "Google Play" and other platforms or stores from the App Store text and screenshots (Play listing text is separate: store/listing.md).', skill: 'aso-basics' });
const words = new RegExp(`(^|[\\s,|/–-])(${COMPETITORS.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})(?=$|[\\s,|/–-])`, 'i');
const words = competitorRe();
const kw = [...listingHits(inp.listings.apple, words, { field: /^(keywords|name|subtitle)$/ })];
if (kw.length)
add({ rule: 'metadata-keywords', guideline: '2.3.7', store: 'apple', severity: 'high', title: 'Other apps\' names or trademarks in the name, subtitle or keywords', evidence: kw.slice(0, 4),
fix: 'Use only terms that describe your app; popular app names, trademarks and Apple product names in keywords get the metadata rejected.', skill: 'aso-basics' });
const price = listingHits(inp.listings.apple, /\b(free|best|#1|no\.? ?1|top|cheap|sale|\d+% off|\$\d)\b/i, { field: /^(name|subtitle|keywords)$/ });
const price = listingHits(inp.listings.apple, PRICE_CLAIMS, { field: /^(name|subtitle|keywords)$/ });
if (price.length)
add({ rule: 'metadata-keywords', guideline: '2.3.7', store: 'apple', severity: 'medium', title: 'Prices or claims ("free", "best", "#1") in the name, subtitle or keywords', evidence: price.slice(0, 3),
fix: 'Keep prices and superlatives out of the name, subtitle and keywords (keywords already match "free" searches only by category).', skill: 'aso-basics' });
@ -381,7 +375,7 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
if (stuffed.length)
add({ rule: 'metadata-keywords', guideline: '2.3.7', store: 'apple', severity: 'medium', title: 'Keyword list in the name or subtitle', evidence: stuffed.slice(0, 2), fix: 'Name and subtitle are a short phrase; keywords belong in the keywords field.', skill: 'aso-basics' });
// 2.2: demos, betas and trial versions belong on TestFlight, not on the App Store (the name and the listing say so)
const BETA = /\b(beta|demo|trial version|test (version|build|app)|testing build|early access|prototype|preview (build|version)|alpha (build|version))\b/i;
const BETA = BETA_WORDS;
const betaName: Hit[] = [];
if (c?.app?.name && BETA.test(c.app.name)) betaName.push(...cfg(c.app.name, `app.name: ${c.app.name}`));
if (c?.apple?.name && c.apple.name !== c.app?.name && BETA.test(c.apple.name)) betaName.push(...cfg(c.apple.name, `apple.name: ${c.apple.name}`));

44
cli/lib/store-catalogs.ts Normal file
View file

@ -0,0 +1,44 @@
// Fixed lists of the consoles' choice fields, shared by the CLI and the desktop's editors (pure, no Node imports):
// App Store categories (with the iTunes genre ids), Google Play categories, the App Store age rating questions with
// their "nothing" answers, Play target audience age groups.
/** App Store Connect category id -> iTunes genre id (the "genreIds" of Search API results). Apple's genre ids. */
export const GENRE_IDS: Record<string, string> = {
BOOKS: '6018', BUSINESS: '6000', DEVELOPER_TOOLS: '6026', EDUCATION: '6017', ENTERTAINMENT: '6016', FINANCE: '6015', FOOD_AND_DRINK: '6023', GAMES: '6014',
GRAPHICS_AND_DESIGN: '6027', HEALTH_AND_FITNESS: '6013', LIFESTYLE: '6012', MAGAZINES_AND_NEWSPAPERS: '6021', MEDICAL: '6020', MUSIC: '6011', NAVIGATION: '6010',
NEWS: '6009', PHOTO_AND_VIDEO: '6008', PRODUCTIVITY: '6007', REFERENCE: '6006', SHOPPING: '6024', SOCIAL_NETWORKING: '6005', SPORTS: '6004', STICKERS: '6025',
TRAVEL: '6003', UTILITIES: '6002', WEATHER: '6001',
GAMES_ACTION: '7001', GAMES_ADVENTURE: '7002', GAMES_CASUAL: '7003', GAMES_BOARD: '7004', GAMES_CARD: '7005', GAMES_CASINO: '7006', GAMES_FAMILY: '7009',
GAMES_MUSIC: '7011', GAMES_PUZZLE: '7012', GAMES_RACING: '7013', GAMES_ROLE_PLAYING: '7014', GAMES_SIMULATION: '7015', GAMES_SPORTS: '7016',
GAMES_STRATEGY: '7017', GAMES_TRIVIA: '7018', GAMES_WORD: '7019',
};
/** App Store primary / secondary categories (no GAMES_* subcategories). */
export const APPLE_CATEGORIES = Object.keys(GENRE_IDS).filter(k => !k.startsWith('GAMES_'));
/** App Store game subcategories (primarySubOne / primarySubTwo when the primary is GAMES). */
export const APPLE_GAME_SUBCATEGORIES = Object.keys(GENRE_IDS).filter(k => k.startsWith('GAMES_'));
/** Play Console's game categories (Store settings > App category, app type Game). */
export const PLAY_GAME_CATEGORIES = ['Action', 'Adventure', 'Arcade', 'Board', 'Card', 'Casino', 'Casual', 'Educational', 'Music', 'Puzzle', 'Racing',
'Role Playing', 'Simulation', 'Sports', 'Strategy', 'Trivia', 'Word'] as const;
/** Play Console's app categories (Store settings > App category, app type App). */
export const PLAY_APP_CATEGORIES = ['Art & Design', 'Auto & Vehicles', 'Beauty', 'Books & Reference', 'Business', 'Comics', 'Communication', 'Dating',
'Education', 'Entertainment', 'Events', 'Finance', 'Food & Drink', 'Health & Fitness', 'House & Home', 'Libraries & Demo', 'Lifestyle',
'Maps & Navigation', 'Medical', 'Music & Audio', 'News & Magazines', 'Parenting', 'Personalization', 'Photography', 'Productivity', 'Shopping',
'Social', 'Sports', 'Tools', 'Travel & Local', 'Video Players & Editors', 'Weather'] as const;
/** Every question of the current App Store age rating declaration at its "nothing" answer. */
export const AGE_RATING_NONE: Record<string, string | boolean | null> = {
advertising: false, ageAssurance: false, alcoholTobaccoOrDrugUseOrReferences: 'NONE', contests: 'NONE', gambling: false,
gamblingSimulated: 'NONE', gunsOrOtherWeapons: 'NONE', healthOrWellnessTopics: false, horrorOrFearThemes: 'NONE', lootBox: false,
matureOrSuggestiveThemes: 'NONE', medicalOrTreatmentInformation: 'NONE', messagingAndChat: false, parentalControls: false,
profanityOrCrudeHumor: 'NONE', socialMedia: false, socialMediaAgeRestricted: false, sexualContentGraphicAndNudity: 'NONE', sexualContentOrNudity: 'NONE', unrestrictedWebAccess: false,
userGeneratedContent: false, violenceCartoonOrFantasy: 'NONE', violenceRealistic: 'NONE',
violenceRealisticProlongedGraphicOrSadistic: 'NONE', kidsAgeBand: null,
};
/** The answers of a frequency question (App Store Connect's None / Infrequent or mild / Frequent or intense). */
export const AGE_FREQUENCY = ['NONE', 'INFREQUENT_OR_MILD', 'FREQUENT_OR_INTENSE'] as const;
/** The Kids Category age band (null: not in the Kids Category). */
export const KIDS_AGE_BANDS = ['FIVE_AND_UNDER', 'SIX_TO_EIGHT', 'NINE_TO_ELEVEN'] as const;
/** Play target audience age groups (App content > Target audience and content). */
export const PLAY_AGE_GROUPS = ['0-5', '6-8', '9-12', '13-15', '16-17', '18+'] as const;

View file

@ -51,12 +51,12 @@ export const FORBIDDEN_METHOD_WORDS = /submit|release|publish|rollout|resume|com
/** Read tools whose command prints a structured result with --json. */
export const JSON_TOOLS = new Set(['check', 'check_aso', 'aso_audit', 'aso_suggest', 'aso_competitors', 'scan', 'validate', 'health', 'health_issue', 'reviews_list', 'prices_diff', 'analytics_compare', 'apple_status',
'apple_next_build', 'play_status', 'play_next_version_code', 'shots_check', 'apple_testflight_status', 'apple_ppo_status', 'catalog_diff',
'project_icon', 'project_listings', 'reviews_drafts', 'project_declarations', 'project_listing_text', 'project_renders', 'project_render', 'prices_current']);
'project_icon', 'project_listings', 'reviews_drafts', 'project_declarations', 'project_listing_text', 'project_renders', 'project_render', 'prices_current', 'project_editable']);
/** Console forms `storebound console fill` knows (cli/console/fill.ts). */
export const CONSOLE_FORMS = ['apple-app-record', 'apple-age-rating', 'apple-app-privacy', 'play-app-record', 'play-content-rating', 'play-target-audience'];
/** Write tools that print a structured result with --json in the dry run and the real run (console.fill: the answers). */
export const JSON_WRITES = new Set(['console_fill', 'owner_apple_submit', 'owner_apple_resubmit']);
export const JSON_WRITES = new Set(['console_fill', 'owner_apple_submit', 'owner_apple_resubmit', 'project_edit']);
/** Write tools whose command may ask its own question (confirm()): a verified host token answers it, as for owner actions. */
const TOKEN_ANSWERS = new Set(['console_fill']);
@ -98,6 +98,12 @@ export const DESKTOP_ONLY: ToolDef[] = [
description: 'One rendered image of the renders folder as a data: URL (at most 12 MB), for the desktop gallery.',
props: { file: { type: 'string', pattern: RENDER_FILE_RE.source, description: 'Path relative to the renders folder, as project.renders names it' } },
required: ['file'], args: i => ['--render', String(i.file)] },
{ name: 'project_editable', title: 'Editable values', command: ['edit', '--show'], write: false, network: false,
description: 'What the editors show: listing fields per store and language as the listing files hold them, the config sections the editors change, the App Review contact (masked), the store limits and whether the files are uncommitted in git.' },
{ name: 'project_edit', title: 'Save edits to the app folder', command: ['edit'], write: true, network: false, idempotent: true,
description: 'Writes edits of the app folder\'s sources of truth: config values by path (the rest of the file, comments and formatting kept), listing fields per store and language (store/listing.md, store/appstore.md), the App Review contact (.storebound/credentials.json). The dry run shows old -> new, the diff per file and the checks (store limits, required fields, keyword and metadata rules, the whole config validated); an error stops the write. Nothing goes to a store and nothing is committed.',
props: { ops: { type: 'string', pattern: '^[\\[{][\\s\\S]{1,400000}$', description: 'The edits as JSON (one edit or a list; see `storebound edit`)' } },
required: ['ops'], args: i => ['--ops', String(i.ops)] },
{ name: 'console_fill', title: 'Fill in a console form', command: ['console', 'fill'], write: true, network: true,
description: 'Fills one store console form that has no API (App Store app record, age rating, App Privacy; Play content rating, target audience) from the config in a visible Chromium the owner signs in to; uncertain fields are highlighted. It moves through the pages itself (Next; App Privacy: Get Started, Edit, each data type and the draft Save in its dialogs; content rating: Save draft) and never submits or publishes: the owner reviews every field and presses the final button (Publish, Submit, Save or Create). The dry run lists the answers; the real run lasts until the owner closes the browser.',
props: { form: { type: 'string', enum: CONSOLE_FORMS, description: 'The console form' }, store: { type: 'string', enum: ['apple', 'play'], description: 'Default: from the form' },

View file

@ -14,6 +14,7 @@ run in the background with a log (`storebound shots render > .storebound/render.
| `mcp` | local | stdio MCP server wrapping the commands |
| `serve --stdio` | local | JSON-RPC session for the storebound desktop app (see [Desktop machine interface](mcp.md#desktop-machine-interface)) |
| `inspect [--json]` | local | offline summary of the config: name, store ids, languages, products |
| `edit --ops '<json>' [--dry] [--json]` | local | edit the app folder's sources of truth: config values by path (comments and formatting kept), listing fields per store and language, the App Review contact; dry run with old -> new, the diff and the checks (store limits, keyword and metadata rules, the whole config validated); `--show --json` lists what the desktop editors show. Never commits |
| `check` | local | config, copy limits, data safety, products, subscriptions, profile (delete-account URL, demo account, terms URL), files; warns on untranslated release notes |
| `catalog diff [--store] [--threshold 10]` | API, read-only | one catalog vs both stores: missing / extra / mismatched products and subscriptions, US and override prices, locales, offers, per-region price differences between the stores; exit 1 on mismatches |
| `check --aso` | local | keyword audit: App Store keyword field rules, Play stuffing / claims |

View file

@ -190,6 +190,40 @@ host state file (`overrides_set`, every key checked in `workspace.rs`).
- Reset: native confirmation, then every saved state of the app is removed; the shared credential profiles and the
app folder list stay (the CLI uses them).
### Editors (console parity)
Everything the stores' consoles let the owner edit and storebound can write is edited in the window; the parity table
is [console-parity.md](console-parity.md).
- **Where:** Listings (texts per language for both stores side by side, store details: URLs, contact, copyright, promo
video), App info (App Store categories, age rating answers, content rights, price, export compliance, App Review
sign-in, notes and contact; Play category, countries, managed publishing, target ages, delete-account page; Data
safety and App Privacy answers; promoted purchases, custom product pages, in-app events), Catalog (each product and
subscription: names and descriptions per language, USD price, reference name, review note, offers), TestFlight (What
to Test per locale, Beta App Description, Beta App Review notes, groups), Store art (scene order, the frame of each
scene).
- **While typing:** live counters against the store limits (characters, as the consoles count them) and the checks of
`check --aso` and `scan` (`cli/lib/listing-rules.ts`, the same code: required fields, keyword field rules, other
platforms, other apps' names, prices and claims, placeholder text, beta wording, keyword stuffing); URLs and emails.
- **Unsaved edits** (`src/lib/edits.ts`) live outside the tabs for the whole session: switching tabs or apps keeps
them, the tab shows a dot, undo / redo / discard, and reloading the window with unsaved edits asks first.
- **Save** ("Review and save"): the dry run of `project.edit` (`storebound edit --ops`, `cli/edit.ts`) in the window
shows old -> new, the diff per file and the checks; an error blocks the save. Save then goes through the native
dialog and writes the files: `storebound.config.ts` by path as a text edit (`cli/lib/config-path.ts`: comments,
order, quotes and formatting stay, only the value changes; the edited config is loaded and validated like `check`
before anything is written), `store/listing.md` / `store/appstore.md` field blocks (`cli/lib/listing-edit.ts`), the
App Review contact in `.storebound/credentials.json` (masked everywhere, the demo account password is never read).
A folder under version control shows the files as uncommitted; storebound never commits.
- **Apply to the stores:** each store write's dry run (old -> new per store and language) is shown in the window
first, then the native dialog, then the real run. Price changes of live products stay the owner's price decision.
- **Pull from the store** (`play.listing_pull`, `apple.listing_pull`): the live texts into the files, the diff in the
dialog first; unsaved edits stay pending on top.
- **Store-only apps** (no folder) stay read-only in the window: a store write sends what the files hold (see
console-parity.md).
- `tests/edit.test.ts` (round trips, checks, dry run, CLI), `apps/desktop/tests/edits.test.ts` (unsaved edits, undo),
and the boot check's editor case (edit a field, counter and limit check, review, save: the fixture file changes by
one line, the config keeps its comments, Apply shows the dry run per language).
### Writes in the window
Each write is a dry run, then the native dialog, then the real run, with one progress toast and Cancel:

View file

@ -51,6 +51,9 @@ const D3_WRITES: [string, J][] = [
['owner.play_production', { version_code: '24' }], ['owner.play_production', { version_code: '24', status: 'inProgress', percent: 10 }], ['owner.play_production', { version_code: '24', status: 'draft' }],
['owner.prices_play_products', {}], ['owner.prices_play_subscriptions', {}], ['owner.prices_apple_subscriptions', { only: 'pro_monthly' }],
['apple.testflight_groups', {}],
// the editors: save into the app folder (local files), then the store writes they apply
['project.edit', { ops: JSON.stringify([{ kind: 'listing', store: 'apple', lang: 'en-US', field: 'subtitle', value: 'Lists that stay tidy' }]) }],
['apple.testflight_info', {}], ['apple.iap_review', {}], ['apple.cpp_create', {}], ['apple.events_create', {}], ['play.listing_pull', {}], ['apple.listing_pull', {}],
];
describe('D3 writes: token binding for every method the window offers', () => {

303
tests/edit.test.ts Normal file
View file

@ -0,0 +1,303 @@
// The desktop's editors write through `storebound edit` (serve method project.edit): config values by path as text
// edits (comments, order and formatting kept, only the edited value changes), listing fields per store and language,
// the App Review contact (masked), the checks of `check` / `scan` before anything is written, and the dry run.
import { describe, expect, test } from 'bun:test';
import { cpSync, existsSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { parsePath, rootObject, setByPath } from '../cli/lib/config-path';
import { parseAppStoreListing, parsePlayListing } from '../cli/lib/copy';
import { checkOp, getByPath, parseOps, planEdits, type EditSources } from '../cli/lib/edit';
import { appleSection, playSection, setAppleField, setPlayField } from '../cli/lib/listing-edit';
import { appleLocaleIssues, playLangIssues, urlIssue } from '../cli/lib/listing-rules';
const ROOT = join(import.meta.dir, '..');
const CLI = join(ROOT, 'cli', 'index.ts');
const CFG = `// storebound config of Example (a comment that must survive)
import type { ShipConfig } from 'storebound';
const config: ShipConfig = {
app: {
name: 'Example', // the on-device name
privacyPolicyUrl: 'https://example.com/privacy',
supportUrl: "https://example.com/support",
website: 'https://example.com',
contactEmail: 'support@example.com',
},
languages: [
{ id: 'en', play: 'en-US', apple: 'en-US' },
{ id: 'pt-BR', play: 'pt-BR', apple: 'pt-BR' },
],
apple: { bundleId: 'com.example.app' },
products: [
{
id: 'com.example.app.no_ads', type: 'non_consumable', usd: 2.99,
listings: {
en: { title: 'No ads', description: 'Removes every ad.' },
'pt-BR': { title: 'Sem anúncios', description: 'Remove os anúncios.' },
},
},
],
subscriptions: [
{ name: 'Example Pro', listings: { en: { name: 'Pro' } }, subscriptions: [
{ id: 'com.example.app.pro_monthly', period: 'P1M', level: 1, usd: 3.99, listings: { en: { title: 'Pro monthly', description: 'All of it.' } } },
] },
],
};
export default config;
`;
describe('config-path: one value set by path, the rest of the file byte for byte', () => {
const only = (before: string, after: string): string[] => {
const a = before.split('\n'), b = after.split('\n');
return b.filter((l, i) => l !== a[i]);
};
test('finds the exported object (const + export default, export default {}, defineConfig)', () => {
expect(CFG[rootObject(CFG)]).toBe('{');
expect(rootObject('export default { a: 1 }')).toBe(15);
expect(rootObject('export default defineConfig({ a: 1 })')).toBe(28);
});
test('replaces a string, keeps comments and the quote style', () => {
const out = setByPath(CFG, 'storebound.config.ts', 'app.supportUrl', 'https://example.com/help');
expect(only(CFG, out)).toEqual([' supportUrl: "https://example.com/help",']);
const n = setByPath(CFG, 'storebound.config.ts', 'app.name', 'Example 2');
expect(only(CFG, n)).toEqual([" name: 'Example 2', // the on-device name"]);
expect(n).toContain('// storebound config of Example (a comment that must survive)');
});
test('array elements by [key=value] (ids with dots), quoted keys, numbers', () => {
const out = setByPath(CFG, 'storebound.config.ts', 'products[id=com.example.app.no_ads].listings.pt-BR.title', 'Sem propaganda');
expect(only(CFG, out)).toEqual([" 'pt-BR': { title: 'Sem propaganda', description: 'Remove os anúncios.' },"]);
const price = setByPath(CFG, 'storebound.config.ts', 'products[id=com.example.app.no_ads].usd', 3.99);
expect(only(CFG, price)).toEqual([" id: 'com.example.app.no_ads', type: 'non_consumable', usd: 3.99,"]);
const sub = setByPath(CFG, 'storebound.config.ts', 'subscriptions[name=Example Pro].subscriptions[id=com.example.app.pro_monthly].listings.en.title', 'Pro (monthly)');
expect(only(CFG, sub)).toHaveLength(1);
expect(sub).toContain("title: 'Pro (monthly)'");
});
test('adds a missing key (and the objects on the way), multi-line text escaped', () => {
const out = setByPath(CFG, 'storebound.config.ts', 'apple.info.reviewNotes', "Line one\nIt's line two");
expect(out).toContain("apple: { bundleId: 'com.example.app', info: { reviewNotes: 'Line one\\nIt\\'s line two' } },");
const app = setByPath(CFG, 'storebound.config.ts', 'app.marketingUrl', 'https://example.com/m');
expect(app).toContain(" contactEmail: 'support@example.com',\n marketingUrl: 'https://example.com/m',\n },");
});
test('removes a key; the edited file still loads to the same object otherwise', async () => {
const out = setByPath(CFG, 'storebound.config.ts', 'app.website', undefined);
expect(out).not.toContain('website');
expect(only(CFG, out).length).toBeLessThan(CFG.split('\n').length);
const dir = mkdtempSync(join(tmpdir(), 'sb-path-'));
writeFileSync(join(dir, 'a.ts'), out.replace("import type { ShipConfig } from 'storebound';", "type ShipConfig = any;"));
const m = (await import(join(dir, 'a.ts'))).default;
expect(m.app).toEqual({ name: 'Example', privacyPolicyUrl: 'https://example.com/privacy', supportUrl: 'https://example.com/support', contactEmail: 'support@example.com' });
});
test('refuses values that are not plain literals and missing list elements', () => {
const t = "const shared = { a: 1 };\nexport default { app: shared, x: { y: f() } };\n";
expect(() => setByPath(t, 'c.ts', 'app.name', 'x')).toThrow(/not a plain object/);
expect(() => setByPath(t, 'c.ts', 'x.y', 'z')).toThrow(/not a plain value/);
expect(() => setByPath(CFG, 'c.ts', 'products[id=nope].usd', 1)).toThrow(/no such element/);
});
test('JSON configs are parsed and written back with their indent', () => {
const j = JSON.stringify({ app: { name: 'A' }, products: [{ id: 'p', usd: 1 }] }, null, 2) + '\n';
const out = setByPath(j, 'storebound.config.json', 'products[id=p].usd', 2);
expect(JSON.parse(out).products[0].usd).toBe(2);
expect(out.endsWith('\n')).toBe(true);
});
test('paths', () => {
expect(parsePath('products[id=a.b].listings.pt-BR.title')).toEqual([{ key: 'products' }, { match: 'id', eq: 'a.b' }, { key: 'listings' }, { key: 'pt-BR' }, { key: 'title' }]);
expect(getByPath({ products: [{ id: 'a.b', usd: 3 }] }, 'products[id=a.b].usd')).toBe(3);
});
});
const PLAY_MD = `# Listing
Prose the parser ignores.
## English
<!-- play: en-US, en-GB -->
### Play short description (max 80)
\`\`\`
Old short
\`\`\`
### Play full description (max 4000)
\`\`\`
Old full text.
\`\`\`
`;
const APPLE_MD = `# App Store
## en-US · English
### subtitle
\`\`\`
Old subtitle
\`\`\`
### promotionalText
\`\`\`
Promo
\`\`\`
### keywords
\`\`\`
lists,tidy,notes,todo,planner,groceries,checklist,tasks,organize,shopping,reminders,family,share
\`\`\`
### description
\`\`\`
Description.
\`\`\`
`;
describe('listing-edit: one field block, the rest of the file unchanged', () => {
test('Play: a field of a section serving two codes; a new block; a new language', () => {
const r = setPlayField(PLAY_MD, 'en-GB', 'short', 'New short');
expect(r.codes).toEqual(['en-US', 'en-GB']);
expect(r.from).toBe('Old short');
expect(r.md.replace('New short', 'Old short')).toBe(PLAY_MD);
const w = setPlayField(PLAY_MD, 'en-US', 'whatsNew', 'Faster sync.');
expect(parsePlayListing(w.md, 'Example')[0]!.whatsNew).toBe('Faster sync.');
const n = setPlayField(PLAY_MD, 'tr-TR', 'short', 'Kısa');
expect(n.created).toBe(true);
expect(playSection(n.md, 'tr-TR')!.values.short).toBe('Kısa');
const gone = setPlayField(w.md, 'en-US', 'whatsNew', '');
expect(gone.md).toBe(PLAY_MD);
});
test('App Store: replace, add and remove a block of one locale', () => {
const r = setAppleField(APPLE_MD, 'en-US', 'subtitle', 'Lists that stay tidy');
expect(r.md.replace('Lists that stay tidy', 'Old subtitle')).toBe(APPLE_MD);
const a = setAppleField(APPLE_MD, 'en-US', 'whatsNew', 'Dark mode');
expect(parseAppStoreListing(a.md)['en-US']!.whatsNew).toBe('Dark mode');
expect(setAppleField(a.md, 'en-US', 'whatsNew', '').md).toBe(APPLE_MD);
expect(appleSection(APPLE_MD, 'en-US')!.values.keywords).toContain('lists,tidy');
});
});
describe('listing-rules: the checks while typing', () => {
test('limits, required fields, keyword and metadata rules', () => {
const ok = appleLocaleIssues('en-US', { subtitle: 'Tidy lists', promotionalText: 'p', keywords: 'notes,todo,planner,groceries,checklist,tasks,organize,shopping,reminders,family,share,home,week', description: 'd' }, 'Example');
expect(ok.filter(i => i.level === 'error')).toEqual([]);
const bad = appleLocaleIssues('en-US', { subtitle: 'x'.repeat(31), promotionalText: '', keywords: 'a, b', description: 'Get it on Google Play' }, 'Example');
expect(bad.find(i => i.field === 'subtitle' && i.level === 'error')!.msg).toContain('31 characters');
expect(bad.some(i => i.field === 'promotionalText' && i.level === 'error')).toBe(true);
expect(bad.some(i => i.field === 'keywords' && /comma/.test(i.msg))).toBe(true);
expect(bad.some(i => i.field === 'description' && /Google Play/.test(i.msg))).toBe(true);
const comp = appleLocaleIssues('en-US', { subtitle: 'Best TikTok lists', promotionalText: 'p', keywords: 'k', description: 'd' }, 'Example');
expect(comp.filter(i => i.field === 'subtitle').map(i => i.level)).toEqual(['warn', 'warn']);
const play = playLangIssues('en-US', { short: 'The #1 best list app', full: 'lists '.repeat(100) }, 'Example');
expect(play.some(i => i.field === 'short' && /best/.test(i.msg))).toBe(true);
expect(play.some(i => i.field === 'full' && /stuffing/.test(i.msg))).toBe(true);
expect(playLangIssues('en-US', { short: 's', full: 'f' }, 'A name that is far too long for Play')[0]!.msg).toContain('falls back');
expect(urlIssue('http://example.com')).toContain('https');
expect(urlIssue('https://example.com/x')).toBeNull();
});
});
function sources(cfg: string, play: string | null, apple: string | null): EditSources {
return {
config: { file: '/x/storebound.config.ts', rel: 'storebound.config.ts', text: cfg },
play: { file: '/x/store/listing.md', rel: 'store/listing.md', text: play },
apple: { file: '/x/store/appstore.md', rel: 'store/appstore.md', text: apple },
contact: { file: '/x/.storebound/credentials.json', rel: '.storebound/credentials.json', text: '{"ASC_KEY_ID":"K","reviewContact":{"firstName":"Ada","email":"ada@example.com"}}' },
appName: 'Example', appleName: 'Example',
};
}
describe('planEdits: old -> new, the diff per file, the checks', () => {
test('a listing, a config and a contact edit in one plan; personal data masked', () => {
const ops = parseOps(JSON.stringify([
{ kind: 'listing', store: 'apple', lang: 'en-US', field: 'subtitle', value: 'Lists that stay tidy' },
{ kind: 'config', path: 'app.supportUrl', value: 'https://example.com/help' },
{ kind: 'contact', field: 'email', value: 'review@example.com' },
]));
const { plan, texts } = planEdits(ops, sources(CFG, PLAY_MD, APPLE_MD), p => getByPath({ app: { supportUrl: 'https://example.com/support' } }, p));
expect(plan.changes.map(c => c.what)).toEqual(['App Store en-US subtitle', 'app.supportUrl', 'App Review contact email']);
expect(plan.changes[0]).toMatchObject({ from: 'Old subtitle', to: 'Lists that stay tidy' });
expect(plan.changes[2]).toMatchObject({ from: 'a***@example.com', to: 'r***@example.com' });
expect(plan.files.map(f => f.rel).sort()).toEqual(['.storebound/credentials.json', 'store/appstore.md', 'storebound.config.ts']);
expect(plan.files.find(f => f.rel.endsWith('credentials.json'))).toMatchObject({ masked: true, diff: [] });
expect(plan.files.find(f => f.rel === 'store/appstore.md')!.diff).toEqual(expect.arrayContaining(['-Old subtitle', '+Lists that stay tidy']));
expect(JSON.parse(texts.get('/x/.storebound/credentials.json')!)).toEqual({ ASC_KEY_ID: 'K', reviewContact: { firstName: 'Ada', email: 'review@example.com' } });
expect(plan.errors).toBe(0);
});
test('errors: over the limit, a bad URL', () => {
const { plan } = planEdits(parseOps(JSON.stringify([
{ kind: 'listing', store: 'play', lang: 'en-US', field: 'short', value: 'x'.repeat(81) },
{ kind: 'config', path: 'app.supportUrl', value: 'example.com' },
])), sources(CFG, PLAY_MD, APPLE_MD), () => undefined);
expect(plan.issues.filter(i => i.level === 'error').map(i => i.where)).toEqual(['app.supportUrl', 'Google Play en-US']);
expect(plan.errors).toBe(2);
});
test('the op shape is checked (paths, stores, fields, sizes)', () => {
expect(() => checkOp({ kind: 'config', path: 'account', value: 'x' }, 0)).toThrow(/not edited from here/);
expect(() => checkOp({ kind: 'config', path: 'a;b', value: 'x' }, 0)).toThrow(/not a config path/);
expect(() => checkOp({ kind: 'listing', store: 'play', lang: 'en-US', field: 'keywords', value: 'x' }, 0)).toThrow(/no field/);
expect(() => checkOp({ kind: 'contact', field: 'password', value: 'x' }, 0)).toThrow(/contact field/);
expect(() => parseOps('[]')).toThrow(/no edits/);
});
});
describe('CLI: storebound edit on a copy of the demo app', () => {
const copy = (): string => {
const dir = mkdtempSync(join(tmpdir(), 'sb-edit-'));
cpSync(join(ROOT, 'examples', 'demo-app'), dir, { recursive: true });
return dir;
};
test('--show lists what the editors show', async () => {
const dir = copy();
const r = await Bun.$`bun ${CLI} edit --show --json`.cwd(dir).quiet().nothrow();
expect(r.exitCode).toBe(0);
const d = JSON.parse(r.stdout.toString());
expect(d.files).toMatchObject({ config: 'storebound.config.ts', play: 'store/listing.md', apple: 'store/appstore.md' });
expect(d.apple.map((x: { lang: string }) => x.lang)).toEqual(['en-US', 'tr']);
expect(d.play[0].values.short).toBeTruthy();
expect(d.config.app.supportUrl).toBe('https://example.com/support');
expect(d.limits.apple.keywords).toBe(100);
});
test('dry run writes nothing; the real run changes only the edited values; an error writes nothing', async () => {
const dir = copy();
const cfgBefore = readFileSync(join(dir, 'storebound.config.ts'), 'utf8');
const mdBefore = readFileSync(join(dir, 'store', 'appstore.md'), 'utf8');
const ops = JSON.stringify([
{ kind: 'listing', store: 'apple', lang: 'en-US', field: 'subtitle', value: 'Lists that stay tidy' },
{ kind: 'config', path: 'app.supportUrl', value: 'https://example.com/help' },
]);
const dry = await Bun.$`bun ${CLI} edit --ops ${ops} --dry --json`.cwd(dir).quiet().nothrow();
expect(dry.exitCode).toBe(0);
const plan = JSON.parse(dry.stdout.toString());
expect(plan).toMatchObject({ written: false, dry: true, errors: 0 });
expect(readFileSync(join(dir, 'storebound.config.ts'), 'utf8')).toBe(cfgBefore);
const real = await Bun.$`bun ${CLI} edit --ops ${ops}`.cwd(dir).quiet().nothrow();
expect(real.exitCode).toBe(0);
const cfg = readFileSync(join(dir, 'storebound.config.ts'), 'utf8');
expect(cfg).toBe(cfgBefore.replace("supportUrl: 'https://example.com/support'", "supportUrl: 'https://example.com/help'"));
const md = readFileSync(join(dir, 'store', 'appstore.md'), 'utf8');
expect(appleSection(md, 'en-US')!.values.subtitle).toBe('Lists that stay tidy');
expect(md.split('\n').filter((l, i) => l !== mdBefore.split('\n')[i])).toHaveLength(1);
// the edited config still loads (the check command reads it)
const check = await Bun.$`bun ${CLI} inspect --json`.cwd(dir).quiet().nothrow();
expect(check.exitCode).toBe(0);
// an over-limit value or a config the validator refuses: nothing is written
const bad = await Bun.$`bun ${CLI} edit --ops ${JSON.stringify([{ kind: 'listing', store: 'apple', lang: 'en-US', field: 'subtitle', value: 'x'.repeat(40) }])}`.cwd(dir).quiet().nothrow();
expect(bad.exitCode).not.toBe(0);
expect(bad.stderr.toString() + bad.stdout.toString()).toMatch(/40 characters/);
expect(readFileSync(join(dir, 'store', 'appstore.md'), 'utf8')).toBe(md);
const invalid = await Bun.$`bun ${CLI} edit --ops ${JSON.stringify([{ kind: 'config', path: 'languages', value: [] }])}`.cwd(dir).quiet().nothrow();
expect(invalid.exitCode).not.toBe(0);
expect(readFileSync(join(dir, 'storebound.config.ts'), 'utf8')).toBe(cfg);
expect(existsSync(join(dir, '.storebound'))).toBe(false);
});
test('the App Review contact goes into .storebound/credentials.json, 0600, never printed', async () => {
const dir = copy();
const ops = JSON.stringify([{ kind: 'contact', field: 'email', value: 'reviewer@example.com' }, { kind: 'contact', field: 'phone', value: '+15550100' }]);
const r = await Bun.$`bun ${CLI} edit --ops ${ops}`.cwd(dir).quiet().nothrow();
expect(r.exitCode).toBe(0);
const out = r.stdout.toString() + r.stderr.toString();
expect(out).not.toContain('reviewer@example.com');
expect(out).not.toContain('+15550100');
const f = join(dir, '.storebound', 'credentials.json');
expect(JSON.parse(readFileSync(f, 'utf8')).reviewContact).toEqual({ email: 'reviewer@example.com', phone: '+15550100' });
expect(statSync(f).mode & 0o777).toBe(0o600);
});
});

View file

@ -18,7 +18,9 @@
// condition (the element, the text, or settle(): no host call in flight and no DOM change for QUIET_MS).
// Uses playwright: its headless shell (bunx playwright install chromium-headless-shell), else Google Chrome
// (channel "chrome"), else its full Chromium. Part of `bun run ci` and of `tools/desktop-release.ts mac`.
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { cpSync, existsSync, mkdtempSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { mint } from '../cli/lib/confirm-token';
import { extname, join, resolve } from 'node:path';
import type { Browser, Page } from 'playwright';
import { APPLE_STATUS_FIRST_REVIEW, APPLE_STATUS_READY, APPLE_STATUS_UNRESOLVED, ownerDry, PLAY_STATUS, PLAY_STATUS_FIRST_200, PLAY_STATUS_FIRST_404 } from '../apps/desktop/src/dev/submit-fixtures';
@ -49,6 +51,8 @@ class Sidecar {
private next = 1;
private pending = new Map<number, { ok: (v: unknown) => void; bad: (e: unknown) => void }>();
hello: { protocol: string; server: { version: string }; methods: { method: string; kind: string }[] } | null = null;
/** The session secret (the editor case mints the host's confirm token for project.edit with it, as the Rust host does). */
secret = Buffer.alloc(0);
constructor() {
(async () => {
@ -82,6 +86,7 @@ class Sidecar {
}
async start(): Promise<void> {
const secret = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('hex');
this.secret = Buffer.from(secret, 'hex');
this.hello = await this.call('session.hello', { secret, client: { name: 'storebound-boot-check', version: conf.version } }) as Sidecar['hello'];
}
kind(method: string): string | undefined { return this.hello?.methods.find(m => m.method === method)?.kind; }
@ -827,6 +832,139 @@ async function runErrorCase(browser: Browser, sidecar: Sidecar, variant: 'api-di
* flush): Google Chrome's graceful close takes 10-30 s after http pages (tests/helpers/browser.ts). Playwright's
* headless shell first (fast to start), else Google Chrome (channel "chrome"), else playwright's full Chromium.
*/
/** Dry runs of the store writes the editor's Apply panels preview (no store is read: canned plans). */
const APPLY_DRY: Record<string, string> = {
'apple.listing': 'version 2.4.0: keep (PREPARE_FOR_SUBMISSION)\n en-US: update subtitle "Old subtitle" -> "Lists that stay tidy"\n tr: same',
'play.listing': 'app details: same\n en-US: same\n tr-TR: same',
};
/**
* The listing editor end to end on a copy of the demo app: the Listings tab edits a field (the counter and the store
* limit check while typing, the unsaved mark on the tab), "Review and save" shows the plan of the real sidecar
* (project.edit dry run), Save writes the file (the host's confirm token minted like the Rust host after its dialog),
* the fixture file changes and the config does not, then Apply shows the store's dry run (old -> new per language).
* Text checks only; any uncaught error fails.
*/
async function runEditorCase(browser: Browser, sidecar: Sidecar): Promise<CaseResult> {
const name = 'listing editor (edit, counter, check, save, apply dry run)';
const freeze = conf.app.security.freezePrototype === true;
const server = serve(DIST, cspHeader(conf.app.security.csp), false);
const ctx = await browser.newContext({ viewport: { width: 1360, height: 900 }, reducedMotion: 'reduce' });
const page = await ctx.newPage();
const problems: string[] = [];
const notes: string[] = [];
const unmocked = new Set<string>();
const logged: string[] = [];
const dir = mkdtempSync(join(tmpdir(), 'sb-boot-editor-'));
cpSync(join(EXAMPLES, 'demo-app'), dir, { recursive: true });
const appstore = join(dir, 'store', 'appstore.md');
const before = { md: readFileSync(appstore, 'utf8'), cfg: readFileSync(join(dir, 'storebound.config.ts'), 'utf8') };
const fx = loadFixture('empty');
fx.projects_list = [{ id: 'editor-app', path: dir, name: 'Tidy Lists', playAccount: null, appleAccount: null, added: '2026-10-14T00:00:00Z' }];
const base = host(fx, sidecar, unmocked, logged);
const writes: string[] = [];
const answer = async (cmd: string, a: Record<string, unknown>): Promise<unknown> => {
if (cmd === 'write_with_confirm' && a.method === 'project.edit') {
// the Rust host: dry run, the native dialog (answered "Save" here), a token for exactly this call, the real run
const bound = { project: dir, args: a.args as Record<string, unknown> };
const dryRun = await sidecar.call('project.edit', { ...bound, dryRun: true }) as { ok: boolean };
if (!dryRun.ok) return { confirmed: false, stage: 'dry-run', dryRun };
const result = await sidecar.call('project.edit', { ...bound, dryRun: false, confirmToken: mint(sidecar.secret, 'project.edit', bound) });
writes.push('project.edit');
return { confirmed: true, stage: 'done', dryRun, result };
}
// every other write: the owner presses Cancel in the native dialog (nothing is sent)
if (cmd === 'write_with_confirm') { writes.push(String(a.method)); return { confirmed: false, stage: 'dialog', dryRun: { ...toolResult(String(a.method), null), dryRun: true, output: APPLY_DRY[String(a.method)] ?? '' } }; }
// "Review and save": the window's dry run of project.edit (the Rust host forces dryRun for calls from the window)
if (cmd === 'sidecar_call' && a.method === 'project.edit') return sidecar.call('project.edit', { project: dir, args: (a.params as { args?: unknown } | null)?.args ?? {} });
if (cmd === 'sidecar_call' && APPLY_DRY[String(a.method)] !== undefined) return { ...toolResult(String(a.method), null), dryRun: true, output: APPLY_DRY[String(a.method)] };
return base(cmd, a);
};
await page.exposeFunction('__bootCheckHost', async (cmd: string, args: Record<string, unknown>) => {
try { return { ok: (await answer(cmd, args)) ?? null }; } catch (e) { return { err: e }; }
});
page.on('pageerror', e => problems.push(`uncaught: ${e.stack ?? e.message}`.split('\n').slice(0, 4).join(' | ')));
page.on('console', m => { if (m.type() === 'error') problems.push(`console.error: ${m.text().slice(0, 400)}`); });
await page.addInitScript(initScript(freeze));
const text = async (sel: string) => (await page.locator(sel).first().innerText().catch(() => '')).replace(/\s+/g, ' ');
const field = '[data-edit="listing:apple:en-US:subtitle"]';
const col = '[data-store="apple"][data-lang="en-US"]';
try {
await page.goto(`http://127.0.0.1:${server.port}/`, { waitUntil: 'load' });
await page.waitForFunction(() => document.documentElement.dataset.booted === '1', null, { timeout: WAIT_MS }).catch(() => problems.push('React never drew the window (no data-booted)'));
await settle(page);
const wizard = page.locator('.wizard[role="dialog"]');
if (await wizard.count()) { await wizard.press('Escape').catch(() => {}); await gone(page, '.wizard[role="dialog"]'); }
await page.locator('nav.side .nav-app', { hasText: 'Tidy Lists' }).first().click({ timeout: WAIT_MS }).catch(e => problems.push(`could not open the app: ${String(e).split('\n')[0]}`));
await settle(page);
await page.locator('[role="tab"]#tab-listings').click({ timeout: WAIT_MS }).catch(e => problems.push(`no Listings tab: ${String(e).split('\n')[0]}`));
await page.waitForSelector(field, { timeout: WAIT_MS }).catch(() => problems.push('the listing editor has no App Store en-US subtitle field'));
// over the limit: the counter and the check say so while typing
await page.locator(field).fill('Lists that stay tidy and organized forever');
const over = await text(col);
if (!over.includes('42 / 30')) problems.push(`no live counter "42 / 30" (${over.slice(0, 200)})`);
if (!over.includes('subtitle is 42 characters; the App Store takes 30')) problems.push('no limit check while typing');
else notes.push('typing: counter 42 / 30 and "the App Store takes 30"');
if (!(await page.locator('#tab-listings .ed-dot').count())) problems.push('the Listings tab has no unsaved mark');
// undo goes back to the saved text, redo brings the edit back
await page.locator('[data-edit-bar="listings"] button', { hasText: 'Undo' }).click({ timeout: WAIT_MS });
const undone = await page.locator(field).inputValue();
if (undone === 'Lists that stay tidy and organized forever') problems.push('Undo did not change the field');
// a valid value, then review: the real sidecar's dry run in the window
await page.locator(field).fill('Lists that stay tidy');
if (!(await text(col)).includes('20 / 30')) problems.push('the counter does not follow the text (20 / 30)');
await page.locator('[data-edit-bar="listings"] [data-action="review"]').click({ timeout: WAIT_MS });
if (!(await shows(page, '[data-edit-plan]', 'Lists that stay tidy'))) problems.push(`no review plan (${await text('[data-edit-bar="listings"]')})`);
else {
const plan = await text('[data-edit-plan]');
if (!plan.includes('App Store en-US subtitle') || !plan.includes('store/appstore.md')) problems.push(`the plan does not name the field and the file (${plan.slice(0, 300)})`);
else notes.push('review: App Store en-US subtitle -> "Lists that stay tidy" in store/appstore.md');
}
await page.locator('[data-edit-bar="listings"] button', { hasText: 'Save to the app folder' }).click({ timeout: WAIT_MS })
.catch(async e => 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 settle(page);
const md = readFileSync(appstore, 'utf8');
const changed = md.split('\n').filter((l, i) => l !== before.md.split('\n')[i]);
if (!md.includes('Lists that stay tidy') || changed.length !== 1) problems.push(`store/appstore.md: expected exactly the subtitle line to change, got ${JSON.stringify(changed).slice(0, 300)}`);
else notes.push(`saved: store/appstore.md changed one line (${changed[0]})`);
if (readFileSync(join(dir, 'storebound.config.ts'), 'utf8') !== before.cfg) problems.push('storebound.config.ts changed although only a listing field was edited');
// Apply: the store's dry run (old -> new per language) in the window first, then the confirm dialog
await page.locator('#ed-version').fill('2.4.0').catch(() => problems.push('no version field for the App Store apply'));
await page.locator('[data-apply="apple.listing"] button').first().click({ timeout: WAIT_MS }).catch(e => problems.push(`no App Store apply preview: ${String(e).split('\n')[0]}`));
if (!(await shows(page, '[data-apply="apple.listing"] .ed-dry', 'en-US: update subtitle'))) problems.push('the App Store apply preview does not show the dry run per language');
else notes.push('apply: App Store dry run shows en-US subtitle old -> new');
await page.locator('[data-apply="apple.listing"] button', { hasText: 'Apply to the store' }).click({ timeout: WAIT_MS }).catch(e => problems.push(`no Apply button after the preview: ${String(e).split('\n')[0]}`));
await settle(page);
if (!writes.includes('apple.listing')) problems.push('Apply did not go through the confirm dialog (write_with_confirm)');
// the other editors draw, and a config edit (App info) saves only that value, comments kept
for (const [tab, root] of [['appinfo', '#appinfo-editor'], ['testflight', '#testflight-editor'], ['art', '#art-editor'], ['catalog', '#catalog-editor, [data-edit-bar], .card']] as const) {
await page.locator(`[role="tab"]#tab-${tab}`).click({ timeout: WAIT_MS }).catch(e => problems.push(`no ${tab} tab: ${String(e).split('\n')[0]}`));
if (!(await page.waitForSelector(root, { timeout: WAIT_MS }).then(() => true, () => false))) problems.push(`${tab}: the editor did not draw (${root})`);
await settle(page);
}
await page.locator('[role="tab"]#tab-appinfo').click({ timeout: WAIT_MS });
const notes1 = '[data-edit="config:apple.info.reviewNotes"]';
await page.waitForSelector(notes1, { timeout: WAIT_MS }).catch(() => problems.push('App info: no review notes field'));
await page.locator(notes1).fill('Sign in with the demo account.');
await page.locator('[data-edit-bar="appinfo"] [data-action="review"]').click({ timeout: WAIT_MS });
if (!(await shows(page, '[data-edit-bar="appinfo"] [data-edit-plan]', 'apple.info.reviewNotes'))) problems.push(`App info: no review plan (${(await text('[data-edit-bar="appinfo"]')).slice(0, 400)})`);
await page.locator('[data-edit-bar="appinfo"] button', { hasText: 'Save to the app folder' }).click({ timeout: WAIT_MS })
.catch(async e => 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'));
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)}`);
else notes.push(`saved: storebound.config.ts changed one line (${cfgChanged[0]!.trim()})`);
for (const c of (await state(page)).crash) problems.push(`error panel ${c}`);
} finally {
await ctx.close();
server.stop(true);
}
for (const l of logged) (l.startsWith('error') ? problems : notes).push(`webview.log: ${l}`);
return { name, ok: problems.length === 0, problems: [...new Set(problems)], visited: 1, notes };
}
async function launch(): Promise<{ browser: Browser; kill: () => Promise<void> }> {
const { chromium } = await import('playwright');
const opts = { headless: true, timeout: 60_000 };
@ -858,6 +996,7 @@ export async function bootCheck(): Promise<CaseResult[]> {
for (const p404 of [true, false]) if (!ONLY.length || ONLY.includes('release-state')) cases.push(() => runReleaseStateCase(browser, sidecar, p404));
for (const v of ['api-disabled', 'scan-findings', 'scan-failed'] as const) if (!ONLY.length || ONLY.includes('errors') || ONLY.includes(`errors-${v}`)) cases.push(() => runErrorCase(browser, sidecar, v));
if (!ONLY.length || ONLY.includes('auto-read')) cases.unshift(() => runAutoReadCase(browser));
if (!ONLY.length || ONLY.includes('editor')) cases.push(() => runEditorCase(browser, sidecar));
const results: CaseResult[] = new Array(cases.length);
let next = 0;
try {