diff --git a/CHANGELOG.md b/CHANGELOG.md index 0030543..277c96c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ ## Unreleased +- **Store art of a live app comes from the stores, not a re-render.** `storebound art pull [--store apple|play] + [--write] [--force]` downloads every App Store screenshot set and app preview per locale and display type (the + original size from `imageAsset.templateUrl`), the App Store icon (the newest build's icon at 1024 px, else the + public artwork) and per Play language the icon, feature graphic and phone / 7" / 10" / TV / Wear screenshots (the + original files, `=s0`, checked against Play's sha256) into the store art layout (`store/renders///NN.png`, + `feature/1024x500.*`, the icons at `app-store-1024.png` / `play.icon`). `store/renders/pulled.json` records each + file's sha256, the store, the URL host (never the URL) and the store's own checksum, so `play images` and + `apple screenshots` / `apple previews` see a pulled file as the store's and upload nothing. An upsert: a re-run + changes nothing, a store change updates an unedited pull, and a local file that differs (your own render, an edit + after the pull) is kept and listed unless `--force`. Read-only on the stores. `storebound onboard` runs it too + (`--no-art` skips it). +- **check / validate with pulled art.** Missing render sources (the icon artwork, the logo, screenshot captions) are + notes instead of problems once the art is pulled; pulled files pass the local store checks as the store's own, and + a set the store had nothing for (a Play language on the default language's graphics, a locale without that set) is + not demanded locally (validate reports the store side). +- **Desktop: the live store art.** The Store art tab shows the art that is live on both stores (thumbnails per store, + language and set; same / differs / not pulled per image) with **Pull from stores** (dry run, dialog, then the files), + and store-only apps get the tab read-only. **Replace with a file** (the lightbox, and each scene's frame in the art + editor) copies a picked PNG / JPEG into the app folder after a dry run with the store checks (`art.replace`; native + picker `image_pick`). docs/console-parity.md: 76 rows, 57 complete, 19 with the reason. - **Parallel Play reads no longer break each other.** Google Play keeps one open edit per app, so two reads of one app at once (the desktop's auto-read) deleted each other's edits ("This Edit has been deleted") and the Releases tab then said "not on Google Play yet". Every Play edit of a package now runs under a machine-wide per-package lock diff --git a/apps/desktop/src-tauri/capabilities/main.json b/apps/desktop/src-tauri/capabilities/main.json index b2f6d41..4bebd39 100644 --- a/apps/desktop/src-tauri/capabilities/main.json +++ b/apps/desktop/src-tauri/capabilities/main.json @@ -49,6 +49,7 @@ "allow-project-import-fastlane", "allow-export-save", "allow-price-sheet-pick", + "allow-image-pick", "allow-project-reveal", "allow-project-locate", "allow-state-reset", diff --git a/apps/desktop/src-tauri/permissions/autogenerated/image_pick.toml b/apps/desktop/src-tauri/permissions/autogenerated/image_pick.toml new file mode 100644 index 0000000..222f0ca --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/image_pick.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-image-pick" +description = "Enables the image_pick command without any pre-configured scope." +commands.allow = ["image_pick"] + +[[permission]] +identifier = "deny-image-pick" +description = "Denies the image_pick command without any pre-configured scope." +commands.deny = ["image_pick"] diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index dcf4efd..e80bfc7 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -712,6 +712,25 @@ pub async fn price_sheet_pick(window: WebviewWindow, state: State<'_, AppState>, Ok(Some(p.to_string_lossy().to_string())) } +/// An image (PNG / JPEG) to replace one store image with, picked in a native dialog. None = cancelled. The path goes +/// back to the window only as the `from` argument of art.replace (a write: dry run, dialog, then the copy into the app +/// folder). +#[tauri::command] +pub async fn image_pick(window: WebviewWindow, state: State<'_, AppState>, project_id: String) -> Res> { + guard(&window)?; + let start = saved(&state)?.projects.iter().find(|p| p.id == project_id).map(|p| PathBuf::from(&p.path)).ok_or_else(|| Fail::host("unknown project"))?; + let w = window.clone(); + let picked = tauri::async_runtime::spawn_blocking(move || w.app_handle().dialog().file().set_title("Choose an image (PNG or JPEG)").add_filter("Image", &["png", "jpg", "jpeg"]).set_directory(start).set_parent(&w).blocking_pick_file()) + .await + .map_err(|e| Fail::host(e.to_string()))?; + let Some(picked) = picked else { return Ok(None) }; + let p = picked.into_path().map_err(|e| Fail::host(e.to_string()))?; + if !p.extension().is_some_and(|e| ["png", "jpg", "jpeg"].iter().any(|x| e.eq_ignore_ascii_case(x))) { + return Err(Fail::host("pick a .png or .jpg file")); + } + Ok(Some(p.to_string_lossy().to_string())) +} + /// Shows a folder or file of an app folder in the file manager (`rel` empty: the app folder), or opens it with /// `open` (a folder, or the gallery page storebound writes: /index.html in the default browser). #[tauri::command] diff --git a/apps/desktop/src-tauri/src/commands_list.rs b/apps/desktop/src-tauri/src/commands_list.rs index 91a7f36..9ccdf1c 100644 --- a/apps/desktop/src-tauri/src/commands_list.rs +++ b/apps/desktop/src-tauri/src/commands_list.rs @@ -41,6 +41,7 @@ pub const COMMANDS: &[&str] = &[ "project_import_fastlane", "export_save", "price_sheet_pick", + "image_pick", "project_reveal", "project_locate", "state_reset", diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 75ca92b..1bf574e 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -251,6 +251,7 @@ pub fn run() { commands::project_import_fastlane, commands::export_save, commands::price_sheet_pick, + commands::image_pick, commands::project_reveal, commands::project_locate, commands::state_reset, diff --git a/apps/desktop/src-tauri/src/policy.rs b/apps/desktop/src-tauri/src/policy.rs index a5ee29b..d8c608a 100644 --- a/apps/desktop/src-tauri/src/policy.rs +++ b/apps/desktop/src-tauri/src/policy.rs @@ -131,8 +131,8 @@ pub fn store_only_write(method: &str, kind: Option) -> bool { kind == Some(Kind::Write) && STORE_ONLY_WRITES.contains(&method) } -/// Reads that are never kept in the offline cache (big: one rendered image as a data: URL). -pub const NO_CACHE_METHODS: &[&str] = &["project.render"]; +/// Reads that are never kept in the offline cache (big: one rendered image or one live store thumbnail as a data: URL). +pub const NO_CACHE_METHODS: &[&str] = &["project.render", "art.thumb"]; /// File kinds the window may export through `export_save`, with their extension. pub const EXPORT_KINDS: &[&str] = &["csv", "md", "json", "txt"]; diff --git a/apps/desktop/src-tauri/tests/complete.rs b/apps/desktop/src-tauri/tests/complete.rs index c786de8..798002e 100644 --- a/apps/desktop/src-tauri/tests/complete.rs +++ b/apps/desktop/src-tauri/tests/complete.rs @@ -38,8 +38,8 @@ fn app_folder_paths() { #[test] fn new_folder_methods_and_big_reads() { assert_eq!(NEW_FOLDER_METHODS, &["project.init", "import.fastlane"]); - assert_eq!(NO_CACHE_METHODS, &["project.render"]); - for c in ["project_import_fastlane", "export_save", "price_sheet_pick", "project_reveal"] { + assert_eq!(NO_CACHE_METHODS, &["project.render", "art.thumb"]); + for c in ["project_import_fastlane", "export_save", "price_sheet_pick", "image_pick", "project_reveal"] { assert!(COMMANDS.contains(&c), "{c}"); } } diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts index b58bf3f..2ec3a68 100644 --- a/apps/desktop/src/api.ts +++ b/apps/desktop/src/api.ts @@ -12,6 +12,7 @@ export type { 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, + ArtThumb, LiveArtResult, LiveArtSet, LiveArtTile, } from '../../../cli/lib/desktop-protocol'; export interface AppInfo { version: string; identifier: string; platform: string; debug: boolean; home: string | null } @@ -128,6 +129,8 @@ export const api = { exportSave: (name: string, kind: ExportKind, content: string, personal = false) => invoke('export_save', { name, kind, content, personal }), /** Native open dialog for a price sheet (CSV); its path, or null. */ pickPriceSheet: (projectId: string) => invoke('price_sheet_pick', { projectId }), + /** Native open dialog for an image (PNG / JPEG) to replace one store image with; its path, or null. */ + pickImage: (projectId: string) => invoke('image_pick', { projectId }), /** Shows a path of an app folder in the file manager, or opens a folder / the gallery page. */ reveal: (projectId: string, rel = '', open = false) => invoke('project_reveal', { projectId, rel, open }), /** "Locate…" for a folder that is gone: native folder picker; the same app with its new path, or null. */ diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 6a6d959..6940a7c 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1098,9 +1098,37 @@ export const en = { 'art.prev': 'Previous', 'art.next': 'Next', 'art.emptyTitle': 'No renders yet', - 'art.emptyText': 'Render the store screenshots from the frames and the copy file: press Render. It runs locally (ffmpeg and Chrome) and uploads nothing.', + 'art.emptyText': 'An app that is already live: Pull from stores below saves the live art here. A new app: render the store screenshots from the frames and the copy file with Render (locally, ffmpeg and Chrome; nothing is uploaded).', 'art.noIssues': 'No issues here', 'art.noIssuesText': 'Every file of this language passes the local store checks.', + 'art.liveTitle': 'Live on the stores', + 'art.liveRead': 'Read the stores', + 'art.liveEmpty': 'Live store art not read yet', + 'art.liveEmptyText': 'Reads the screenshots, previews, feature graphic and icon that are live on App Store Connect and Google Play. Read-only.', + 'art.liveCount': '{count} images', + 'art.liveCount_one': '1 image', + 'art.liveFailed': 'could not read', + 'art.liveReadOnly': 'A store-only app: the live art is shown read-only. Add the app folder to pull it into the project and edit it.', + 'art.liveSummary': '{missing} store images are not in the app folder yet, {differs} differ from your files. Pull from stores adds the missing ones and keeps yours.', + 'art.liveAllSame': 'The app folder has every live image, the same as on the stores: uploads send nothing.', + 'art.liveStore': 'Store', + 'art.liveNone': 'No store art yet', + 'art.liveNoneText': 'The stores have no screenshots for this app yet.', + 'art.liveNoThumb': 'no preview', + 'art.liveSame': 'same as the store', + 'art.liveDiffers': 'yours differs', + 'art.liveMissing': 'not pulled yet', + 'art.liveFoot': 'Read-only on the stores. Pull from stores saves the originals into the renders folder (pulled.json), so checks and uploads treat them as the store\'s own.', + 'art.liveFootStoreOnly': 'Read-only on the stores.', + 'art.iconApple': 'App Store icon', + 'art.iconPlay': 'Play icon', + 'art.pull': 'Pull from stores', + 'art.pulled': 'Store art pulled into the app folder', + 'art.pullHint': 'Downloads the live screenshots, previews, feature graphic and icons into the app folder (dry run first). Your own files that differ are kept.', + 'art.replace': 'Replace with a file', + 'art.replaceTitle': 'Replace {name}', + 'art.replaced': 'Image replaced in the app folder', + 'ed.frameLang': 'Frames of', 'art.foot': 'Local files only, read {when}. Uploading is on the Listings tab.', 'gr.analytics': 'Store analytics', diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index 3d7cc9e..8e4f3a5 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -1100,9 +1100,37 @@ export const tr: Record = { 'art.prev': 'Önceki', 'art.next': 'Sonraki', 'art.emptyTitle': 'Henüz görsel yok', - 'art.emptyText': 'Mağaza ekran görüntülerini karelerden ve metin dosyasından oluşturun: Oluştur\'a basın. Yerelde çalışır (ffmpeg ve Chrome) ve hiçbir şey yüklemez.', + 'art.emptyText': 'Zaten yayında olan bir uygulama: aşağıdaki Mağazalardan çek yayındaki görselleri buraya kaydeder. Yeni bir uygulama: ekran görüntülerini karelerden ve metin dosyasından Oluştur ile oluşturun (yerelde, ffmpeg ve Chrome; hiçbir şey yüklenmez).', 'art.noIssues': 'Burada sorun yok', 'art.noIssuesText': 'Bu dilin her dosyası yerel mağaza kontrollerinden geçiyor.', + 'art.liveTitle': 'Mağazalarda yayında', + 'art.liveRead': 'Mağazaları oku', + 'art.liveEmpty': 'Yayındaki görseller henüz okunmadı', + 'art.liveEmptyText': 'App Store Connect ve Google Play\'de yayında olan ekran görüntülerini, önizlemeleri, öne çıkan görseli ve simgeyi okur. Yalnızca okur.', + 'art.liveCount': '{count} görsel', + 'art.liveCount_one': '1 görsel', + 'art.liveFailed': 'okunamadı', + 'art.liveReadOnly': 'Yalnızca mağazada olan bir uygulama: yayındaki görseller salt okunur gösterilir. Projeye çekmek ve düzenlemek için uygulama klasörünü ekleyin.', + 'art.liveSummary': '{missing} mağaza görseli henüz uygulama klasöründe yok, {differs} tanesi sizin dosyalarınızdan farklı. Mağazalardan çek eksikleri ekler, sizinkileri korur.', + 'art.liveAllSame': 'Uygulama klasöründe yayındaki her görsel mağazadakiyle aynı: yüklemeler hiçbir şey göndermez.', + 'art.liveStore': 'Mağaza', + 'art.liveNone': 'Henüz mağaza görseli yok', + 'art.liveNoneText': 'Mağazalarda bu uygulamanın henüz ekran görüntüsü yok.', + 'art.liveNoThumb': 'önizleme yok', + 'art.liveSame': 'mağazadakiyle aynı', + 'art.liveDiffers': 'sizinki farklı', + 'art.liveMissing': 'henüz çekilmedi', + 'art.liveFoot': 'Mağazalarda yalnızca okur. Mağazalardan çek, orijinalleri renders klasörüne kaydeder (pulled.json); denetimler ve yüklemeler onları mağazanın kendi dosyası sayar.', + 'art.liveFootStoreOnly': 'Mağazalarda yalnızca okur.', + 'art.iconApple': 'App Store simgesi', + 'art.iconPlay': 'Play simgesi', + 'art.pull': 'Mağazalardan çek', + 'art.pulled': 'Mağaza görselleri uygulama klasörüne çekildi', + 'art.pullHint': 'Yayındaki ekran görüntülerini, önizlemeleri, öne çıkan görseli ve simgeleri uygulama klasörüne indirir (önce deneme). Farklı olan kendi dosyalarınız korunur.', + 'art.replace': 'Dosyayla değiştir', + 'art.replaceTitle': '{name} değiştir', + 'art.replaced': 'Görsel uygulama klasöründe değiştirildi', + 'ed.frameLang': 'Kareler', 'art.foot': 'Yalnızca yerel dosyalar, {when} okundu. Yükleme Mağaza metinleri sekmesinde.', 'gr.analytics': 'Mağaza analizleri', diff --git a/apps/desktop/src/lib/freshness.ts b/apps/desktop/src/lib/freshness.ts index 9104ca9..98d9887 100644 --- a/apps/desktop/src/lib/freshness.ts +++ b/apps/desktop/src/lib/freshness.ts @@ -17,6 +17,7 @@ export const TTL: Record = { 'project.listings': 30 * MIN, 'project.editable': 30 * MIN, 'listing.live': 30 * MIN, + 'art.live': 6 * 60 * MIN, 'apple.ppo_status': 30 * MIN, validate: 30 * MIN, scan: 30 * MIN, diff --git a/apps/desktop/src/nav.ts b/apps/desktop/src/nav.ts index 8a244cb..a8885af 100644 --- a/apps/desktop/src/nav.ts +++ b/apps/desktop/src/nav.ts @@ -6,9 +6,9 @@ import type { IconName } from './ui/icons'; export const APP_TABS = ['releases', 'catalog', 'prices', 'listings', 'art', 'health', 'reviews', 'testflight', 'growth', 'appinfo', 'checks'] as const; export type AppTab = (typeof APP_TABS)[number]; -/** Tabs of a store-only app: store reads only (catalog, prices, listings, art, app info and checks need the app folder; - * Growth shows the Product Page Optimization tests only). */ -export const STORE_TABS: readonly AppTab[] = ['releases', 'listings', 'health', 'reviews', 'testflight', 'growth']; +/** Tabs of a store-only app: store reads only (catalog, prices, app info and checks need the app folder; listings and + * store art are the live ones; Growth shows the Product Page Optimization tests only). */ +export const STORE_TABS: readonly AppTab[] = ['releases', 'listings', 'art', 'health', 'reviews', 'testflight', 'growth']; export const tabsFor = (hasFolder: boolean): readonly AppTab[] => (hasFolder ? APP_TABS : STORE_TABS); export const TAB_KEY: Record = { releases: 'tab.releases', catalog: 'tab.catalog', prices: 'tab.prices', listings: 'tab.listings', art: 'tab.art', health: 'tab.health', reviews: 'tab.reviews', diff --git a/apps/desktop/src/screens/AppView.tsx b/apps/desktop/src/screens/AppView.tsx index 0d0eec9..49ba9b0 100644 --- a/apps/desktop/src/screens/AppView.tsx +++ b/apps/desktop/src/screens/AppView.tsx @@ -14,6 +14,7 @@ import { FolderMissing } from '../ui/FolderMissing'; import { AppIcon, Chip, Notice, PhaseChip, StoreBadge, StoreOnlyTag, StudioMark } from '../ui/kit'; import { AppInfoTab } from './tabs/AppInfo'; import { ArtTab } from './tabs/Art'; +import { LiveArt } from './tabs/LiveArt'; import { CatalogTab } from './tabs/Catalog'; import { ChecksTab } from './tabs/Checks'; import { HealthTab } from './tabs/Health'; @@ -163,6 +164,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) { {tab === 'listings' && app.project && <>} {tab === 'listings' && !app.project && } {tab === 'art' && app.project && } + {tab === 'art' && !app.project && } {tab === 'health' && } {tab === 'reviews' && } {tab === 'testflight' && } diff --git a/apps/desktop/src/screens/editors/ArtEditor.tsx b/apps/desktop/src/screens/editors/ArtEditor.tsx index 5becbf4..4b40d05 100644 --- a/apps/desktop/src/screens/editors/ArtEditor.tsx +++ b/apps/desktop/src/screens/editors/ArtEditor.tsx @@ -2,7 +2,8 @@ // 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 { useState } from 'react'; -import type { EditableResult, Project } from '../../api'; +import { api, errorText, type EditableResult, type Project } from '../../api'; +import { useData, useWrite } from '../../store'; import { t } from '../../i18n'; import { ApplyPanel, EditBar, TextField, useEdits } from '../../ui/Editor'; import { Icon, StoreGlyph } from '../../ui/icons'; @@ -13,6 +14,20 @@ type Scene = { id: string; palette?: unknown; frames?: { src: string }[] } & Rec export function ArtEditor({ project, data, onSaved }: { project: Project; data: EditableResult; onSaved: () => void }) { const e = useEdits(); + const w = useWrite(project.id); + const d = useData(); + const langs = data.languages.map(l => l.id); + const [frameLang, setFrameLang] = useState(langs[0] ?? ''); + const fl = langs.includes(frameLang) ? frameLang : langs[0] ?? ''; + const framesDir = ((data.config as unknown as { shots?: { frames?: string } | null }).shots?.frames ?? 'store/shots/frames').replace(/\/+$/, ''); + // replace the frame a scene shows (this language, phone) with a file the owner picks: native open dialog, then + // art.replace (dry run with the checks, the native dialog, the copy into the app folder); render again afterwards + const replaceFrame = async (src: string) => { + let from: string | null = null; + try { from = await api.pickImage(project.id); } catch (err) { d.toast({ kind: 'bad', title: t('art.replace'), detail: errorText(err) }); return; } + if (!from) return; + await w.run({ method: 'art.replace', args: { from, to: `${framesDir}/${fl}/phone/${src}` }, title: t('art.replaceTitle', { name: src }), done: t('art.replaced') }); + }; 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)); @@ -28,7 +43,8 @@ export function ArtEditor({ project, data, onSaved }: { project: Project; data: }; return (
- + 1 ? : undefined}>
    {scenes.map((s, i) => (
  1. @@ -38,6 +54,7 @@ export function ArtEditor({ project, data, onSaved }: { project: Project; data: x.id === s.id)?.frames?.[0]?.src ?? ''} label={t('ed.frame')} mono />
+ {s.frames?.[0]?.src && }
diff --git a/apps/desktop/src/screens/tabs/Art.tsx b/apps/desktop/src/screens/tabs/Art.tsx index d501fd7..66ff5b9 100644 --- a/apps/desktop/src/screens/tabs/Art.tsx +++ b/apps/desktop/src/screens/tabs/Art.tsx @@ -11,6 +11,7 @@ import { useData, useRead, useWrite } from '../../store'; import { Icon } from '../../ui/icons'; import { Card, Chip, Empty, ErrorNotice, Kpi, Seg, Skeleton } from '../../ui/kit'; import { WriteButton } from '../../ui/WriteButton'; +import { LiveArt } from './LiveArt'; // loaded images by project + file + render time, three at a time const cache = new Map(); @@ -68,7 +69,7 @@ function Thumb({ projectId, tile, stamp, onOpen }: { projectId: string; tile: Re ); } -function Lightbox({ projectId, tiles, index, stamp, onClose, onMove }: { projectId: string; tiles: RenderTile[]; index: number; stamp: string; onClose: () => void; onMove: (i: number) => void }) { +function Lightbox({ projectId, tiles, index, stamp, onClose, onMove, onReplace }: { projectId: string; tiles: RenderTile[]; index: number; stamp: string; onClose: () => void; onMove: (i: number) => void; onReplace?: (tile: RenderTile) => void }) { const tile = tiles[index]!; const file = tile.kind === 'video' ? tile.poster : tile.src; const [src, setSrc] = useState(null); @@ -87,6 +88,7 @@ function Lightbox({ projectId, tiles, index, stamp, onClose, onMove }: { project
{tile.src}
{tile.w}×{tile.h} · {bytes(tile.bytes)} · {num(index + 1)} / {num(tiles.length)}
{tile.issues.map((i, k) =>
{i.msg}
)}
+ {onReplace && tile.kind === 'image' && } @@ -113,8 +115,17 @@ export function ArtTab({ project }: { project: Project }) { const dir = data?.dir ?? 'store/renders'; const reveal = useCallback((rel: string, openIt = false) => { api.reveal(project.id, rel, openIt).catch(e => d.toast({ kind: 'info', title: t('art.notThere'), detail: errorText(e) })); }, [project.id, d]); const stamp = data?.generated ?? ''; + // replace one image with a file the owner picks: native open dialog, then art.replace (dry run with the store + // checks, the native dialog, the copy into the app folder) + const replace = useCallback(async (tile: RenderTile) => { + let from: string | null = null; + try { from = await api.pickImage(project.id); } catch (e) { d.toast({ kind: 'bad', title: t('art.replace'), detail: errorText(e) }); return; } + if (!from) return; + await w.run({ method: 'art.replace', args: { from, to: `${data?.dir ?? 'store/renders'}/${tile.src}` }, title: t('art.replaceTitle', { name: tile.name }), done: t('art.replaced'), after: r.refresh }); + }, [project.id, d, w, data?.dir, r.refresh]); - if (!data) return r.error ? : ; + // the live art stays the first child in both branches, so it keeps its state when the local renders arrive + if (!data) return <>{r.error ? : }; const total = data.totals.images + data.totals.videos; const toolbar = ( <> @@ -126,6 +137,7 @@ export function ArtTab({ project }: { project: Project }) { ); return ( <> +
@@ -162,7 +174,7 @@ export function ArtTab({ project }: { project: Project }) { )}
{t('art.foot', { when: ago(r.at) })}
- {open && setOpen(null)} onMove={i => setOpen(o => (o ? { ...o, i } : o))} />} + {open && setOpen(null)} onMove={i => setOpen(o => (o ? { ...o, i } : o))} onReplace={tile => { setOpen(null); replace(tile); }} />} ); } diff --git a/apps/desktop/src/screens/tabs/LiveArt.tsx b/apps/desktop/src/screens/tabs/LiveArt.tsx new file mode 100644 index 0000000..92866fd --- /dev/null +++ b/apps/desktop/src/screens/tabs/LiveArt.tsx @@ -0,0 +1,133 @@ +// The store art that is live on App Store Connect and Google Play (art.live), per store, language and set, with +// thumbnails (art.thumb: one data: URL each, loaded as they scroll into view; the window loads no remote images +// itself) and, for an app folder, whether the folder has the same file. "Pull from stores" runs art.pull (dry run, +// native dialog, then the files are written: local only, read-only on the stores). Store-only apps: read-only. +import { useEffect, useMemo, useRef, useState } from 'react'; +import { api, type ArtThumb, type LiveArtResult, type LiveArtTile, type Project, type ToolResult } from '../../api'; +import { t } from '../../i18n'; +import { num } from '../../lib/format'; +import { useRead, useWrite } from '../../store'; +import { Icon } from '../../ui/icons'; +import { Card, Chip, Empty, ErrorNotice, Notice, ReadControl, Skeleton } from '../../ui/kit'; +import { WriteButton } from '../../ui/WriteButton'; + +// thumbnails by target + URL, three at a time +const cache = new Map(); +const waiting: (() => Promise)[] = []; +let active = 0; +function pump() { + while (active < 3 && waiting.length) { + const job = waiting.shift()!; + active++; + job().finally(() => { active--; pump(); }); + } +} +export function loadThumb(target: string, url: string): Promise { + const key = `${target}|${url}`; + if (cache.has(key)) return Promise.resolve(cache.get(key)!); + return new Promise(resolve => { + waiting.push(async () => { + try { + const r = await api.call('art.thumb', target, { args: { url } }); + cache.set(key, r.data?.dataUrl ?? null); + } catch { cache.set(key, null); } + resolve(cache.get(key) ?? null); + }); + pump(); + }); +} + +function LiveThumb({ target, tile, name }: { target: string; tile: LiveArtTile; name: string }) { + const ref = useRef(null); + const [src, setSrc] = useState(tile.thumb ? cache.get(`${target}|${tile.thumb}`) : null); + useEffect(() => { + if (!tile.thumb || src !== undefined) return; + const el = ref.current; + if (!el) return; + let alive = true; + const io = new IntersectionObserver(es => { + if (!es.some(x => x.isIntersecting)) return; + io.disconnect(); + loadThumb(target, tile.thumb).then(u => { if (alive) setSrc(u); }); + }, { rootMargin: '300px' }); + io.observe(el); + return () => { alive = false; io.disconnect(); }; + }, [target, tile.thumb, src]); + const w = tile.w ?? 9, h = tile.h ?? 16; + const state = tile.state === 'n/a' ? null : tile.state; + return ( +
+
+ {src ? : {src === null && tile.thumb ? <>{t('art.liveNoThumb')} : }} +
+
{name}{tile.w && tile.h ? `${tile.w}×${tile.h}` : ''}
+ {state && {t(state === 'same' ? 'art.liveSame' : state === 'differs' ? 'art.liveDiffers' : 'art.liveMissing')}} +
+ ); +} + +/** Live store art of one app: `target` is the project id, or a store-only app's store target. */ +export function LiveArt({ target, project }: { target: string; project?: Project }) { + const r = useRead(target, 'art.live', { network: true, title: t('art.liveTitle') }); + const w = useWrite(project?.id ?? null); + const [store, setStore] = useState<'apple' | 'play' | ''>(''); + const [locale, setLocale] = useState(''); + const data = r.data; + const stores = useMemo(() => [...new Set((data?.sets ?? []).map(s => s.store))], [data]); + const st = stores.includes(store as 'apple') ? store : stores[0] ?? ''; + const locales = useMemo(() => [...new Set((data?.sets ?? []).filter(s => s.store === st).map(s => s.locale))].sort(), [data, st]); + const loc = locales.includes(locale) ? locale : locales[0] ?? ''; + const sets = (data?.sets ?? []).filter(s => s.store === st && s.locale === loc); + const pull = project ? ( + { r.refresh(); } }} hint={t('art.pullHint')}>{t('art.pull')} + ) : null; + const actions = <>{pull}; + if (!data) { + return ( + + {r.loading && !r.error ?
+ : { r.refresh(); }}>{t('art.liveRead')}}>{r.error ?? t('art.liveEmptyText')}} +
+ ); + } + const total = data.sets.reduce((n, s) => n + s.tiles.length, 0); + const differs = data.sets.reduce((n, s) => n + s.tiles.filter(x => x.state === 'differs').length, 0); + const missing = data.sets.reduce((n, s) => n + s.tiles.filter(x => x.state === 'missing').length, 0); + const sub = data.stores.map(s => `${s.store === 'apple' ? 'App Store' : 'Google Play'}: ${s.ok ? t('art.liveCount', { count: s.images }) : t('art.liveFailed')}${s.version ? ` · ${s.version}` : ''}`).join(' · '); + return ( + + {data.stores.filter(s => !s.ok).map(s =>
)} +
+ {!project && {t('art.liveReadOnly')}} + {project && total > 0 && (missing > 0 || differs > 0) && {t('art.liveSummary', { missing: num(missing), differs: num(differs) })}} + {project && total > 0 && !missing && !differs && {t('art.liveAllSame')}} + {data.icons.length > 0 && ( +
+ {data.icons.map(i =>
)} +
+ )} + {stores.length > 1 && ( +
+ {stores.map(s => )} +
+ )} +
+ {locales.map(l => )} +
+
+ {!total ? {t('art.liveNoneText')} : ( +
+ {sets.map(s => ( +
+

{s.label}{s.lang && {s.lang}/{s.size}}{s.store === 'apple' ? 'App Store' : 'Google Play'}{t('art.count', { count: s.tiles.length })}

+
(s.tiles[0].h ?? 1) ? '260px' : '150px' }}> + {s.tiles.map(tile => )} +
+
+ ))} +
+ )} +
{project ? t('art.liveFoot') : t('art.liveFootStoreOnly')}
+
+ ); +} diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 07aeeda..453f10c 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -838,7 +838,7 @@ textarea.input { height: auto; padding: 8px 10px; line-height: 1.45; resize: ver .err-foot .grow { display: grid; gap: 4px; } .err-foot .fix-hint { color: var(--ink-2); } .chips { display: flex; flex-wrap: wrap; gap: 4px; } -.bad-ink { color: var(--bad-ink); } .warn-ink { color: var(--warn-ink); } +.bad-ink { color: var(--bad-ink); } .warn-ink { color: var(--warn-ink); } .ok-ink { color: var(--ok-ink); } /* prices */ .px-matrix td { vertical-align: top; } diff --git a/cli/api/wrapped.gen.ts b/cli/api/wrapped.gen.ts index 647dd64..9dd955b 100644 --- a/cli/api/wrapped.gen.ts +++ b/cli/api/wrapped.gen.ts @@ -34,11 +34,11 @@ export const WRAPPED: { apple: Record; play: Record; play: Record; play: Record; play: Record; play: Record ASC previewType, for this project (iPad only with apple.ipad). */ @@ -89,7 +90,7 @@ export async function run(ctx: Ctx): Promise { const tag = `${locale} ${type}`; let set: J = existingSets.find(s => s.attributes.previewType === type); const existing: J[] = set ? await c.all(`/v1/appPreviewSets/${set.id}/appPreviews`) : []; - const want = fs.map(f => md5(readFileSync(f))); + const want = fs.map(f => pulledChecksum(f, 'apple') ?? md5(readFileSync(f))); const state = (p: J) => p.attributes.videoDeliveryState?.state ?? p.attributes.assetDeliveryState?.state; const sameFiles = setUpToDate(existing.map(p => ({ checksum: p.attributes.sourceFileChecksum, state: state(p) })), want); if (sameFiles) { diff --git a/cli/apple/screenshots.ts b/cli/apple/screenshots.ts index 5d9029b..9565c4c 100644 --- a/cli/apple/screenshots.ts +++ b/cli/apple/screenshots.ts @@ -15,6 +15,7 @@ import { done, log, warn } from '../lib/log'; import { numberedImages } from '../play/images'; import { variantRoot } from '../shots/lib'; import { findOrCreate } from '../lib/upsert'; +import { pulledChecksum } from '../lib/art-pull'; import { type Asc, ascClient, keptPrefix, md5, setUpToDate, type J } from './asc'; /** Render size key -> ASC screenshotDisplayType (iPhone sets). */ @@ -41,7 +42,8 @@ export const requiredDisplayTypes = (apple: { ipad?: boolean; screenshotTypes?: export async function syncScreenshotSet(c: Asc, parent: { rel: string; type: string; id: string }, sets: J[], type: string, files: string[], tag: string, dry: boolean): Promise { let set = sets.find(s => s.attributes.screenshotDisplayType === type); const existing = set ? await c.all(`/v1/appScreenshotSets/${set.id}/appScreenshots?limit=50`) : []; - const want = files.map(f => md5(readFileSync(f))); + // a file still exactly as `art pull` saved it is the store's own: compare the store's checksum of the original + const want = files.map(f => pulledChecksum(f, 'apple') ?? md5(readFileSync(f))); const state = (s: J) => s.attributes.assetDeliveryState?.state; if (set && setUpToDate(existing.map(s => ({ checksum: s.attributes.sourceFileChecksum, state: state(s) })), want)) { log('apple', `${tag}: ${files.length} up to date`); return; } if (dry) { diff --git a/cli/art.ts b/cli/art.ts new file mode 100644 index 0000000..0633995 --- /dev/null +++ b/cli/art.ts @@ -0,0 +1,519 @@ +// Store art that is already live: pull it into the project instead of rendering it again, look at it, replace one file. +// storebound art pull [--store apple|play] [--write] [--force] [--dry] [--json] +// Every App Store screenshot set and app preview per locale and display type (of the version being prepared, else +// the live one), the App Store icon (the newest build's icon at 1024 px, else the public lookup artwork), and per +// Play language the icon, feature graphic and phone / 7" / 10" / TV / Wear screenshots (the original files, =s0). +// Saved in the store art layout (lib/art-pull.ts): store/renders///NN.png, feature/1024x500.*, the +// icons at app-store-1024.png / play.icon, with /pulled.json (sha256 of each file, store, URL host, when). +// check / validate then treat them like rendered art, and `play images` / `apple screenshots` see "same as the +// store" and upload nothing. Without --write it only prints the plan (nothing is downloaded but an icon to compare). +// An upsert: a re-run changes nothing; a local file that differs from the store (your own render, an edit after the +// pull) is never overwritten without --force (listed as a conflict). Read-only toward the stores (GETs, one Play +// read edit that is deleted). No personal data: the manifest keeps the URL host, never the URL. +// storebound art live [--store apple|play] [--json] the live art per store, language and set with thumbnail +// URLs and whether the project has the same file (the desktop's live view; store-only apps too) +// storebound art thumb --url --json one thumbnail as a data: +// URL (at most 2 MB; the desktop window loads no remote images itself) +// storebound art replace --from --to [--dry] [--json] +// replaces one image of the app folder with a file the owner picked (the dry run shows old -> new and the checks) +import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { dirname, isAbsolute, join, relative, resolve as resolvePath } from 'node:path'; +import { ascClient, type J } from './apple/asc'; +import { playCodes, type ShipConfig } from './lib/config'; +import type { Ctx } from './lib/ctx'; +import { done, log, printJson, warn } from './lib/log'; +import { textTable } from './lib/table'; +import type { LiveArtResult, LiveArtSet, LiveArtTile } from './lib/desktop-protocol'; +import { + appleDir, ART_HOST_RE, type ArtStep, type ArtStore, decide, emptyPulled, extraStep, fillTemplate, hostOf, type LocalFile, md5Of, PLAY_SHOT_DIR, PLAY_TYPES, playUrl, + posix, type Pulled, PULLED_FILE, readPulled, type RemoteArt, sha256Of, slotOf, sniffExt, storeEmptySets, summarize, thumbSize, +} from './lib/art-pull'; +import { playClient } from './play/client'; +import { decodePng, hashGray, imageSimilarity } from './lib/imagehash'; +import { IMAGE_SPECS, checkImage, imageInfo } from './shots/spec'; + +const TAG = 'art'; +const FETCH_TIMEOUT_MS = 120_000; +const JOBS = 6; + +/** Runs jobs `n` at a time. */ +async function pool(items: T[], n: number, fn: (x: T) => Promise): Promise { + let next = 0; + await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => { while (next < items.length) await fn(items[next++]!); })); +} + +/** GET bytes from Apple's / Google's image CDN, with a timeout and two retries. */ +export async function download(url: string, max = 600 * 1024 * 1024): Promise { + if (!ART_HOST_RE.test(url)) throw new Error(`not a store image host: ${hostOf(url)}`); + let last: unknown; + for (let attempt = 0; attempt < 3; attempt++) { + try { + const r = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { 'User-Agent': 'storebound (art pull)' } }); + if (!r.ok) throw new Error(`${hostOf(url)}: HTTP ${r.status}`); + const b = new Uint8Array(await r.arrayBuffer()); + if (b.length > max) throw new Error(`${hostOf(url)}: ${b.length} bytes, more than ${max}`); + return b; + } catch (e) { last = e; if (/HTTP 4\d\d|more than/.test(String(e))) break; } + } + throw last; +} + +// --------------------------------------------------------------------------------------------------------------------- +// Reading the stores (GETs only) + +export interface StoreRead { items: RemoteArt[]; version?: string | null; notes: string[] } + +/** App Store art: the version being prepared (else the live one, else the newest): every screenshot set and preview + * set per locale, plus the icon of the newest build (else the public lookup artwork). */ +export async function readApple(root: string, config: ShipConfig): Promise { + const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId); + const appId = await c.app(); + const versions = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=10`); + const state = (v: J) => v.attributes.appStoreState ?? v.attributes.appVersionState; + const v: J = (await c.editableVersion()) ?? versions.find((x: J) => state(x) === 'READY_FOR_SALE') ?? versions[0] ?? null; + const out: StoreRead = { items: [], version: v ? `${v.attributes.versionString} (${state(v)})` : null, notes: [] }; + if (v) { + const locs = await c.all(`/v1/appStoreVersions/${v.id}/appStoreVersionLocalizations?limit=50`); + await pool(locs, 4, async (loc: J) => { + const locale: string = loc.attributes.locale; + for (const set of await c.all(`/v1/appStoreVersionLocalizations/${loc.id}/appScreenshotSets?limit=50`)) { + const type: string = set.attributes.screenshotDisplayType; + const shots = await c.all(`/v1/appScreenshotSets/${set.id}/appScreenshots?limit=50`); + shots.forEach((s: J, index: number) => { + const a = s.attributes ?? {}, img = a.imageAsset; + if (!img?.templateUrl || !img.width || !img.height) { out.notes.push(`${locale} ${type} ${index + 1}: no image yet (${a.assetDeliveryState?.state ?? 'processing'})`); return; } + const [tw, th] = thumbSize(img.width, img.height); + out.items.push({ store: 'apple', kind: 'screenshot', type, locale, index, url: fillTemplate(img.templateUrl, img.width, img.height, 'png'), + thumb: fillTemplate(img.templateUrl, tw, th, 'jpg'), w: img.width, h: img.height, checksum: a.sourceFileChecksum ?? null, id: s.id }); + }); + } + for (const set of await c.all(`/v1/appStoreVersionLocalizations/${loc.id}/appPreviewSets?limit=50`)) { + const type: string = set.attributes.previewType; + const previews = await c.all(`/v1/appPreviewSets/${set.id}/appPreviews?limit=50`); + previews.forEach((p: J, index: number) => { + const a = p.attributes ?? {}, img = a.previewImage; + if (!a.videoUrl) { out.notes.push(`${locale} preview ${type} ${index + 1}: no video URL yet (${a.assetDeliveryState?.state ?? 'processing'})`); return; } + const [tw, th] = img?.width ? thumbSize(img.width, img.height) : [300, 300]; + out.items.push({ store: 'apple', kind: 'preview', type, locale, index, url: a.videoUrl, thumb: img?.templateUrl ? fillTemplate(img.templateUrl, tw, th, 'jpg') : '', + w: img?.width, h: img?.height, checksum: a.sourceFileChecksum ?? null, id: p.id }); + }); + } + }); + } else out.notes.push('no App Store version yet: no screenshots to pull'); + // the icon: the newest build's (the one in the binary), else the public artwork of the live app + const builds = await c.all(`/v1/builds?filter[app]=${appId}&sort=-uploadedDate&limit=1&fields[builds]=iconAssetToken,version`).catch(() => [] as J[]); + const tok = builds[0]?.attributes?.iconAssetToken; + if (tok?.templateUrl) { + out.items.push({ store: 'apple', kind: 'icon', type: 'icon', locale: '', index: 0, url: fillTemplate(tok.templateUrl, 1024, 1024, 'png'), thumb: fillTemplate(tok.templateUrl, 256, 256, 'jpg'), + w: 1024, h: 1024, checksum: null, id: `build-${builds[0].attributes.version}-${sha256Of(new TextEncoder().encode(tok.templateUrl)).slice(0, 16)}` }); + } else { + const art = await lookupArtwork(config.apple!.bundleId); + if (art) out.items.push({ store: 'apple', kind: 'icon', type: 'icon', locale: '', index: 0, url: art.replace(/\/\d+x\d+bb\.(png|jpg)$/, '/1024x1024bb.png'), + thumb: art.replace(/\/\d+x\d+bb\.(png|jpg)$/, '/256x256bb.jpg'), w: 1024, h: 1024, checksum: null, id: `lookup-${sha256Of(new TextEncoder().encode(art)).slice(0, 16)}` }); + else out.notes.push('App Store icon: no build with an icon and not on the public store yet'); + } + return out; +} + +/** The public App Store artwork URL of a bundle id (iTunes lookup API, no credentials), or null. */ +async function lookupArtwork(bundleId: string): Promise { + try { + const r = await fetch(`https://itunes.apple.com/lookup?bundleId=${encodeURIComponent(bundleId)}`, { signal: AbortSignal.timeout(20_000) }); + const j = await r.json() as { results?: { artworkUrl512?: string; artworkUrl100?: string }[] }; + const u = j.results?.[0]?.artworkUrl512 ?? j.results?.[0]?.artworkUrl100; + return u && ART_HOST_RE.test(u) ? u : null; + } catch { return null; } +} + +/** Play art: per listing language every image type (one read-only edit, deleted afterwards). */ +export async function readPlay(root: string, config: ShipConfig): Promise { + const play = await playClient(root, config.play!.packageName); + const out: StoreRead = { items: [], notes: [] }; + await play.read(async id => { + const ls = await play.call<{ listings?: { language: string }[] }>('GET', `${play.API}/edits/${id}/listings`); + const jobs = (ls.listings ?? []).flatMap(l => PLAY_TYPES.map(type => ({ code: l.language, type }))); + await pool(jobs, 4, async ({ code, type }) => { + const r = await play.call<{ images?: { id: string; url: string; sha256?: string }[] }>('GET', `${play.API}/edits/${id}/listings/${code}/${type}`); + (r.images ?? []).forEach((img, index) => { + out.items.push({ store: 'play', kind: type === 'icon' ? 'icon' : type === 'featureGraphic' ? 'featureGraphic' : 'screenshot', type, locale: code, index, + url: playUrl(img.url, 'original'), thumb: playUrl(img.url, type === 'featureGraphic' ? 400 : 300), checksum: img.sha256 ?? null, id: img.id }); + }); + }); + }); + return out; +} + +// --------------------------------------------------------------------------------------------------------------------- +// The plan + +export interface ArtPaths { root: string; renders: string; rendersRel: string; appleIcon: string; playIcon: string } + +export function artPaths(ctx: Ctx): ArtPaths { + const { config, root, resolve } = ctx.loaded; + const renders = resolve(config.renders ?? config.shots?.out ?? 'store/renders'); + const icons = config.icons?.out ?? 'store/icons'; + return { root, renders, rendersRel: posix(relative(root, renders)), appleIcon: posix(join(icons, 'app-store-1024.png')), playIcon: posix(config.play?.icon ?? join(icons, 'play-512.png')) }; +} + +/** Store locale -> config language id, and the preference of a store locale for its language (lower first): a Play + * language with several codes (es-419, es-ES) takes the first code of the config that has the image. */ +export function languageMap(config: ShipConfig) { + const apple = new Map(), play = new Map(); + config.languages.forEach((l, i) => { + if (l.apple) apple.set(l.apple, { id: l.id, rank: i * 100 }); + playCodes(l).forEach((code, j) => { if (!play.has(code)) play.set(code, { id: l.id, rank: i * 100 + j }); }); + }); + return { of: (store: ArtStore, locale: string) => (store === 'apple' ? apple : play).get(locale) ?? null }; +} + +const EXTS = ['png', 'jpg', 'jpeg', 'mp4', 'mov', 'm4v']; +/** The local file in a slot (any extension), with its hashes. The file the upload sends wins: Play takes a .jpg next to + * a .png of the same number (play images), the App Store the .png (apple screenshots). */ +function localIn(root: string, slotRel: string, store: ArtStore = 'apple'): LocalFile | null { + for (const e of store === 'play' ? ['jpg', 'jpeg', 'png', 'mp4', 'mov', 'm4v'] : EXTS) { + const f = join(root, `${slotRel}.${e}`); + if (existsSync(f)) { const b = readFileSync(f); return { rel: `${slotRel}.${e}`, sha256: sha256Of(b), md5: md5Of(b) }; } + } + return null; +} + +export interface ArtPlan { steps: ArtStep[]; unmapped: string[]; storeEmpty: string[]; filled: Set; notes: string[] } + +/** The upsert plan: each live image against its slot (pure apart from reading the local files). */ +export function planArt(p: ArtPaths, config: ShipConfig, remote: RemoteArt[], pulled: Pulled | null, force: boolean): ArtPlan { + const lm = languageMap(config); + const notes: string[] = []; + const unmapped = new Set(); + // one live image per slot: the preferred store locale of the language + const best = new Map & object }>(); + const ctx = { renders: p.rendersRel, appleIcon: p.appleIcon, playIcon: p.playIcon, langOf: (s: ArtStore, l: string) => lm.of(s, l)?.id ?? null }; + for (const r of remote) { + const slot = slotOf(r, ctx); + if (!slot) { if (r.locale) unmapped.add(`${r.store === 'apple' ? 'App Store' : 'Play'} ${r.locale}`); continue; } + // the Play icon: the default (first) language's; the App Store icon is one + const rank = r.kind === 'icon' && r.store === 'apple' ? 0 : lm.of(r.store, r.locale)?.rank ?? 1e9; + const key = `${slot.rel}`; + const cur = best.get(key); + // a set comes whole from one locale: compare by the locale's rank, then keep the first seen + if (!cur || rank < cur.rank) best.set(key, { r, rank, slot }); + } + // a set must come from one locale only (es-419 vs es-ES): keep, per /, the best-ranked locale's items + const setLocale = new Map(); + for (const { r, rank, slot } of best.values()) { + if (r.kind === 'icon') continue; + const k = `${r.store}|${slot.lang}/${slot.size}`; + const cur = setLocale.get(k); + if (!cur || rank < cur.rank) setLocale.set(k, { locale: r.locale, rank }); + } + const steps: ArtStep[] = []; + const filled = new Set(); + const planned = new Set(); + const counts = new Map(); + for (const { r, slot } of [...best.values()].sort((a, b) => a.slot.rel.localeCompare(b.slot.rel))) { + if (r.kind !== 'icon' && setLocale.get(`${r.store}|${slot.lang}/${slot.size}`)?.locale !== r.locale) continue; + const local = localIn(p.root, slot.rel, r.store); + const entry = local && pulled ? pulled.files[posix(relative(p.renders, join(p.root, local.rel)))] ?? null : null; + steps.push(decide(r, slot.rel, local, entry, force, slot.ext)); + planned.add(slot.rel); + if (r.kind !== 'icon') { filled.add(`${slot.lang}/${slot.size}`); counts.set(dirname(slot.rel), Math.max(counts.get(dirname(slot.rel)) ?? 0, r.index + 1)); } + } + // local files of a pulled set beyond the store's count, and earlier pulls no longer on the store + for (const [dirRel] of counts) { + const d = join(p.root, dirRel); + if (!existsSync(d)) continue; + for (const f of readdirSync(d).filter(x => /^\d+\.(png|jpe?g|mp4|mov|m4v)$/i.test(x)).sort()) { + const slotRel = `${dirRel}/${f.replace(/\.[^.]+$/, '')}`; + if (planned.has(slotRel)) continue; + const b = readFileSync(join(d, f)); + const lf = { rel: `${dirRel}/${f}`, sha256: sha256Of(b), md5: md5Of(b) }; + steps.push(extraStep(lf, pulled?.files[posix(relative(p.renders, join(d, f)))] ?? null, force)); + planned.add(slotRel); + } + } + for (const [rel, e] of Object.entries(pulled?.files ?? {})) { + const abs = join(p.renders, rel); + const slotRel = posix(relative(p.root, abs)).replace(/\.[^.]+$/, ''); + if (planned.has(slotRel) || !existsSync(abs)) continue; + // a store whose read failed keeps its files + if (!remote.some(r => r.store === e.store)) continue; + const b = readFileSync(abs); + steps.push(extraStep({ rel: posix(relative(p.root, abs)), sha256: sha256Of(b), md5: md5Of(b) }, e, force)); + } + // every set size of the store (not only today's required ones: Play tablets become required once any language has them) + const sizesOf = (store: ArtStore) => Object.keys(IMAGE_SPECS).filter(s => IMAGE_SPECS[s]!.store === store); + const stores = new Set(remote.map(r => r.store)); + const storeEmpty = storeEmptySets(config.languages.map(l => ({ id: l.id, apple: !!l.apple && !!config.apple && stores.has('apple'), play: playCodes(l).length > 0 && !!config.play && stores.has('play') })), + filled, { apple: sizesOf('apple'), play: sizesOf('play') }); + return { steps, unmapped: [...unmapped].sort(), storeEmpty, filled, notes }; +} + +// --------------------------------------------------------------------------------------------------------------------- +// pull + +export interface PullReport { + stores: { store: ArtStore; ok: boolean; version?: string | null; images: number; error?: string }[]; + summary: ReturnType; + steps: { action: string; file: string; why: string; store?: string; type?: string; locale?: string }[]; + conflicts: string[]; + unmapped: string[]; + storeEmpty: string[]; + notes: string[]; + written: boolean; + manifest: string; +} + +/** Downloads a preview: an .mp4 / .mov file straight, an HLS stream through ffmpeg (copy, no re-encode). */ +async function downloadPreview(url: string, out: string): Promise { + if (!/\.m3u8(\?|$)/i.test(url)) { writeFileSync(out, await download(url)); return; } + if (spawnSync('sh', ['-c', 'command -v ffmpeg']).status !== 0) throw new Error('the preview is a stream (HLS): ffmpeg is needed to save it (storebound doctor)'); + const r = spawnSync('ffmpeg', ['-v', 'error', '-y', '-i', url, '-c', 'copy', '-movflags', '+faststart', out], { timeout: 300_000 }); + if (r.status !== 0) throw new Error(`ffmpeg: ${String(r.stderr).slice(0, 300)}`); +} + +/** Two PNGs showing the same picture (perceptual hashes nearly equal; sizes may differ). False when one does not decode. */ +export function samePicture(a: Uint8Array, b: Uint8Array): boolean { + try { return imageSimilarity(hashGray(decodePng(Buffer.from(a))), hashGray(decodePng(Buffer.from(b)))) >= 0.97; } catch { return false; } +} + +/** `art pull` as a function (onboard runs it too). Writes only with `write`. */ +export async function artPull(ctx: Ctx, o: { store?: string; write: boolean; force: boolean; quiet?: boolean }): Promise { + const { config, root } = ctx.loaded; + const p = artPaths(ctx); + const report: PullReport = { stores: [], summary: { add: 0, same: 0, update: 0, conflict: 0, remove: 0 }, steps: [], conflicts: [], unmapped: [], storeEmpty: [], notes: [], written: false, manifest: posix(relative(root, join(p.renders, PULLED_FILE))) }; + const remote: RemoteArt[] = []; + const say = (m: string) => { if (!o.quiet) log(TAG, m); }; + for (const store of ['apple', 'play'] as const) { + if (o.store && o.store !== store) continue; + if (!config[store]) continue; + say(`reading the ${store === 'apple' ? 'App Store' : 'Google Play'} art`); + try { + const r = store === 'apple' ? await readApple(root, config) : await readPlay(root, config); + remote.push(...r.items); + report.notes.push(...r.notes); + report.stores.push({ store, ok: true, version: r.version, images: r.items.length }); + } catch (e) { + report.stores.push({ store, ok: false, images: 0, error: (e as Error).message.slice(0, 300) }); + warn(TAG, `${store}: ${(e as Error).message.slice(0, 300)}`); + } + } + const pulled = readPulled(p.renders); + // an icon without a store checksum (the App Store one): compare the bytes when a local file is there + const iconBytes = new Map(); + for (const r of remote.filter(x => x.checksum === null && x.kind === 'icon')) { + const slot = slotOf(r, { renders: p.rendersRel, appleIcon: p.appleIcon, playIcon: p.playIcon, langOf: () => null }); + const local = slot && localIn(root, slot.rel, r.store); + if (!local) continue; + try { + const b = await download(r.url); + iconBytes.set(r, b); + // Apple re-encodes the icon of the build: the same picture is never the same bytes, so compare the pictures + if (sha256Of(b) === local.sha256 || samePicture(b, readFileSync(join(root, local.rel)))) r.checksum = local.md5; + } catch (e) { report.notes.push(`icon: ${(e as Error).message}`); } + } + const plan = planArt(p, config, remote, pulled, o.force); + report.unmapped = plan.unmapped; + report.storeEmpty = plan.storeEmpty; + report.summary = summarize(plan.steps); + report.steps = plan.steps.filter(s => s.action !== 'same').map(s => ({ action: s.action, file: s.rel, why: s.why, store: s.remote?.store, type: s.remote?.type, locale: s.remote?.locale })); + report.conflicts = plan.steps.filter(s => s.action === 'conflict').map(s => s.rel); + if (!o.write) return report; + + const next: Pulled = pulled ? { ...pulled, files: { ...pulled.files } } : emptyPulled(); + const now = new Date().toISOString(); + const keyOf = (rel: string) => posix(relative(p.renders, join(root, rel))); + const entryFor = (s: ArtStep, rel: string, sha: string) => ({ store: s.remote!.store, kind: s.remote!.kind, type: s.remote!.type, locale: s.remote!.locale, host: hostOf(s.remote!.url), + sha256: sha, storeChecksum: s.remote!.checksum, storeId: s.remote!.id, pulledAt: now }); + const failed: string[] = []; + await pool(plan.steps.filter(s => s.action === 'add' || s.action === 'update'), JOBS, async s => { + const r = s.remote!; + try { + const base = s.rel.replace(/\.[^.]+$/, ''); + const tmp = join(root, `${base}.pulling`); + mkdirSync(dirname(tmp), { recursive: true }); + if (r.kind === 'preview') await downloadPreview(r.url, tmp); + else writeFileSync(tmp, iconBytes.get(r) ?? await download(r.url)); + const b = readFileSync(tmp); + if (r.store === 'play' && r.checksum && sha256Of(b) !== r.checksum) throw new Error('the download does not match the sha256 Play gives (retry)'); + // the App Store icon has no store checksum: the bytes' MD5 stands in (a re-run compares the download with it) + if (r.checksum === null && r.kind === 'icon') r.checksum = md5Of(b); + const ext = sniffExt(b) ?? (r.kind === 'preview' ? 'mp4' : 'png'); + if (r.kind !== 'preview' && ext !== 'png' && ext !== 'jpg') throw new Error(`not a PNG or JPEG (${ext})`); + const finalRel = `${base}.${r.kind === 'icon' && /\.png$/i.test(s.rel) && ext === 'png' ? 'png' : ext}`; + // the slot's old file with another extension goes (it is an unchanged pull, or --force said so) + for (const e of EXTS) { const old = `${base}.${e}`; if (old !== finalRel && existsSync(join(root, old))) { rmSync(join(root, old)); delete next.files[keyOf(old)]; } } + renameSync(tmp, join(root, finalRel)); + next.files[keyOf(finalRel)] = entryFor(s, finalRel, sha256Of(b)); + s.rel = finalRel; + } catch (e) { + failed.push(`${s.rel}: ${(e as Error).message.slice(0, 200)}`); + rmSync(join(root, `${s.rel.replace(/\.[^.]+$/, '')}.pulling`), { force: true }); + } + }); + for (const s of plan.steps) { + if (s.action === 'same' && s.adopt) next.files[keyOf(s.rel)] = entryFor(s, s.rel, sha256Of(readFileSync(join(root, s.rel)))); + if (s.action === 'remove') { rmSync(join(root, s.rel), { force: true }); delete next.files[keyOf(s.rel)]; } + } + // entries whose file is gone + for (const k of Object.keys(next.files)) if (!existsSync(join(p.renders, k))) delete next.files[k]; + next.storeEmpty = plan.storeEmpty; + next.files = Object.fromEntries(Object.entries(next.files).sort(([a], [b]) => a.localeCompare(b))); + const strip = (x: Pulled | null) => JSON.stringify(x ? { files: x.files, storeEmpty: x.storeEmpty } : null); + if (strip(next) !== strip(pulled)) { + next.pulledAt = now; + mkdirSync(p.renders, { recursive: true }); + writeFileSync(join(p.renders, PULLED_FILE), JSON.stringify(next, null, 2) + '\n'); + report.written = true; + } + if (failed.length) { report.notes.push(...failed.map(f => `failed: ${f}`)); process.exitCode = 1; } + return report; +} + +/** Plan rows with one row per folder, action and reason ("store/renders/en/play/ 01-06"). Pure. */ +export function groupSteps(steps: PullReport['steps']): string[][] { + const groups = new Map(); + for (const s of steps) { + const i = s.file.lastIndexOf('/'); + const dir = s.file.slice(0, i + 1), name = s.file.slice(i + 1); + const k = `${s.action}|${dir}|${s.why}`; + const g = groups.get(k) ?? { action: s.action, dir, why: s.why, names: [] }; + g.names.push(name); + groups.set(k, g); + } + return [...groups.values()].map(g => [g.action, g.names.length === 1 ? g.dir + g.names[0] : `${g.dir} ${g.names.length} files (${g.names[0]} .. ${g.names[g.names.length - 1]})`, g.why]); +} + +export function printPull(r: PullReport, write: boolean): void { + for (const s of r.stores) (s.ok ? log : warn)(TAG, s.ok ? `${s.store === 'apple' ? 'App Store' : 'Google Play'}: ${s.images} image(s)${s.version ? `, version ${s.version}` : ''}` : `${s.store}: could not read: ${s.error}`); + if (r.steps.length) console.log(textTable(['action', 'files', 'why'], groupSteps(r.steps))); + for (const u of r.unmapped) log(TAG, `${u}: no language in the config has this locale (not pulled)`); + for (const n of r.notes) warn(TAG, n); + if (r.conflicts.length) warn(TAG, `${r.conflicts.length} local file(s) differ from the store and are kept: ${r.conflicts.slice(0, 8).join(', ')}${r.conflicts.length > 8 ? ', ...' : ''} (--force replaces them with the store's)`); + const s = r.summary; + const line = `${s.add} new, ${s.update} changed, ${s.remove} removed, ${s.same} same as the store, ${s.conflict} kept (differ)`; + done(TAG, write ? `${line}${r.written ? `; ${r.manifest} updated` : '; nothing changed'}` : `dry run: would write ${line} (run with --write)`); +} + +// --------------------------------------------------------------------------------------------------------------------- +// live / thumb / replace + +type LiveTile = LiveArtTile; +type LiveSet = LiveArtSet; +type LiveResult = LiveArtResult; + +const LABELS: Record = { + APP_IPHONE_67: 'App Store iPhone 6.9"', APP_IPHONE_65: 'App Store iPhone 6.5"', APP_IPHONE_61: 'App Store iPhone 6.3"', APP_IPHONE_55: 'App Store iPhone 5.5"', APP_IPAD_PRO_3GEN_129: 'App Store iPad 13"', APP_IPAD_PRO_129: 'App Store iPad 12.9" (2nd gen)', + IPHONE_67: 'App Store iPhone app preview', IPAD_PRO_3GEN_129: 'App Store iPad app preview', featureGraphic: 'Play feature graphic', phoneScreenshots: 'Play phone', sevenInchScreenshots: 'Play 7" tablet', tenInchScreenshots: 'Play 10" tablet', tvScreenshots: 'Play TV', wearScreenshots: 'Play Wear OS', +}; + +export async function artLive(ctx: Ctx, store?: string): Promise { + const { config, root } = ctx.loaded; + // a store-only app (the desktop's generated config in a temp folder): no project files to compare with + const virtual = /storebound-virtual-/.test(root); + const out: LiveResult = { generated: new Date().toISOString(), stores: [], icons: [], sets: [], notes: [], pulled: false }; + const remote: RemoteArt[] = []; + await Promise.all((['apple', 'play'] as const).filter(s => (!store || store === s) && config[s]).map(async s => { + try { + const r = s === 'apple' ? await readApple(root, config) : await readPlay(root, config); + remote.push(...r.items); out.notes.push(...r.notes); + out.stores.push({ store: s, ok: true, version: r.version, images: r.items.length }); + } catch (e) { out.stores.push({ store: s, ok: false, images: 0, error: (e as Error).message.slice(0, 300) }); } + })); + out.stores.sort((a, b) => a.store.localeCompare(b.store)); + const p = artPaths(ctx); + const pulled = virtual ? null : readPulled(p.renders); + out.pulled = !!pulled; + const steps = virtual ? [] : planArt(p, config, remote, pulled, false).steps; + const stateOf = (r: RemoteArt): { state: LiveTile['state']; file?: string } => { + if (virtual) return { state: 'n/a' }; + const s = steps.find(x => x.remote === r); + if (!s) return { state: 'n/a' }; + return s.action === 'add' ? { state: 'missing' } : s.action === 'same' ? { state: 'same', file: s.rel } : { state: 'differs', file: s.rel }; + }; + const lm = languageMap(config); + const sets = new Map(); + for (const r of remote.sort((a, b) => a.store.localeCompare(b.store) || a.locale.localeCompare(b.locale) || a.type.localeCompare(b.type) || a.index - b.index)) { + const tile: LiveTile = { index: r.index, type: r.type, w: r.w, h: r.h, thumb: r.thumb, ...stateOf(r) }; + if (r.kind === 'icon') { if (!out.icons.some(i => i.store === r.store)) out.icons.push({ ...tile, store: r.store }); continue; } + const key = `${r.store}|${r.locale}|${r.type}`; + let set = sets.get(key); + if (!set) { + const size = r.store === 'apple' ? appleDir(r.kind, r.type) : r.kind === 'featureGraphic' ? 'feature' : PLAY_SHOT_DIR[r.type] ?? r.type; + set = { store: r.store, locale: r.locale, lang: lm.of(r.store, r.locale)?.id ?? null, kind: r.kind, type: r.type, size, label: LABELS[r.type] ?? r.type, tiles: [] }; + sets.set(key, set); + } + set.tiles.push(tile); + } + out.sets = [...sets.values()]; + return out; +} + +const THUMB_MAX = 2 * 1024 * 1024; + +/** The file `art replace` may write: inside the renders folder or the shots frames folder, a PNG / JPEG. */ +export function replaceTarget(root: string, allowed: string[], to: string): string { + if (isAbsolute(to) || to.split(/[\\/]/).includes('..')) throw new Error('--to: a path relative to the app folder, without ..'); + if (!/\.(png|jpe?g)$/i.test(to)) throw new Error('--to: a .png or .jpg file'); + const abs = resolvePath(root, to); + if (!allowed.some(a => abs.startsWith(resolvePath(root, a) + '/'))) throw new Error(`--to: only files in ${allowed.join(' or ')} are replaced here`); + return abs; +} + +export async function run(ctx: Ctx): Promise { + const a = ctx.args; + const sub = a._[0] ?? 'pull'; + const store = a.get('store'); + if (store && store !== 'apple' && store !== 'play') throw new Error('--store apple | play'); + if (sub === 'pull') { + const write = a.has('write') && !a.has('dry'); + const r = await artPull(ctx, { store, write, force: a.has('force') }); + if (a.has('json')) printJson(r); else printPull(r, write); + return; + } + if (sub === 'live') { + const r = await artLive(ctx, store); + if (a.has('json')) { printJson(r); return; } + for (const s of r.stores) (s.ok ? log : warn)(TAG, s.ok ? `${s.store}: ${s.images} image(s)${s.version ? `, version ${s.version}` : ''}` : `${s.store}: ${s.error}`); + console.log(textTable(['store', 'locale', 'lang', 'set', 'images', 'in the project'], r.sets.map(s => [s.store, s.locale, s.lang ?? '-', s.label, String(s.tiles.length), + s.tiles.every(t => t.state === 'same') ? 'same' : s.tiles.some(t => t.state === 'differs') ? 'differs' : s.tiles.every(t => t.state === 'missing') ? 'not pulled' : s.tiles[0]?.state === 'n/a' ? '-' : 'partly']))); + return; + } + if (sub === 'thumb') { + const url = a.get('url') ?? ''; + if (!ART_HOST_RE.test(url)) throw new Error('--url: an https URL of the App Store or Google Play image CDN'); + const b = await download(url, THUMB_MAX); + const ext = sniffExt(b); + if (ext !== 'png' && ext !== 'jpg') throw new Error('not an image'); + printJson({ dataUrl: `data:image/${ext === 'jpg' ? 'jpeg' : 'png'};base64,${Buffer.from(b).toString('base64')}`, bytes: b.length }); + return; + } + if (sub === 'replace') { + const { config, root } = ctx.loaded; + const from = a.get('from') ?? ''; + if (!from || !isAbsolute(from) || !/\.(png|jpe?g)$/i.test(from) || !existsSync(from) || !statSync(from).isFile()) throw new Error('--from: an existing .png or .jpg file (absolute path)'); + const p = artPaths(ctx); + const allowed = [p.rendersRel, ...(config.shots?.frames ? [config.shots.frames] : []), ...(config.shots?.capture?.folder ? [config.shots.capture.folder] : [])]; + const to = a.get('to') ?? ''; + const abs = replaceTarget(root, allowed, to); + const nb = readFileSync(from); + const ni = imageInfo(nb); + if (ni.format === 'unknown') throw new Error(`${from}: not a PNG or JPEG`); + if ((ni.format === 'png') !== /\.png$/i.test(to)) throw new Error(`--to ${to}: the picked file is a ${ni.format.toUpperCase()}; keep the format (or pick a ${/\.png$/i.test(to) ? 'PNG' : 'JPEG'})`); + const old = existsSync(abs) ? imageInfo(readFileSync(abs)) : null; + const rel = posix(relative(p.renders, abs)); + const size = rel.startsWith('..') ? null : rel.split('/').find(x => IMAGE_SPECS[x]) ?? null; + const issues = size ? checkImage(ni, size) : []; + const res = { file: posix(relative(root, abs)), from: posix(from).split('/').pop(), old: old ? { w: old.w, h: old.h, bytes: old.bytes } : null, new: { w: ni.w, h: ni.h, bytes: ni.bytes }, issues, written: false }; + const errors = issues.filter(i => i.level === 'error'); + if (!a.has('dry') && !errors.length) { mkdirSync(dirname(abs), { recursive: true }); copyFileSync(from, abs); res.written = true; } + if (a.has('json')) printJson(res); + else { + log(TAG, `${res.file}: ${old ? `${old.w}x${old.h} -> ` : 'new, '}${ni.w}x${ni.h} (${res.from})`); + for (const i of issues) (i.level === 'error' ? warn : log)(TAG, `${i.level}: ${i.msg}`); + done(TAG, errors.length ? 'not replaced (the store would refuse it)' : res.written ? 'replaced (upload it with play images / apple screenshots after the dry run)' : 'dry run: would replace it'); + } + if (errors.length) process.exitCode = 1; + return; + } + throw new Error('storebound art pull | live | thumb | replace'); +} diff --git a/cli/check.ts b/cli/check.ts index 0918113..381a6ad 100644 --- a/cli/check.ts +++ b/cli/check.ts @@ -25,6 +25,7 @@ import { detectProject } from './lib/scan/project'; import { reviewShotProblem } from './apple/iap-review'; import { allSubscriptions } from './lib/subscriptions'; import { type CopyFile, validateCopy } from './shots/lib'; +import { posix, readPulled } from './lib/art-pull'; import { type CapProject, capacitorProject, prebuildFor, staleMessage, webState } from './lib/web-assets'; /** The newest .ipa in a folder (apple build's export), or null. */ @@ -159,7 +160,18 @@ export async function run(ctx: Ctx): Promise { const bad = reviewShotProblem(readFileSync(file)); if (bad) problems.push(`products[].reviewScreenshot ${f}: ${bad}`); } - if (config.shots) tryDo('shots copy', () => { + // store art pulled from the live stores (art pull): the render sources (screenshot captions, icon artwork, logo) + // are needed only to render again, so their gaps are notes, not problems + const pulled = readPulled(resolve(config.renders ?? config.shots?.out ?? 'store/renders')); + const fromStores = !!pulled && Object.keys(pulled.files).length > 0; + if (fromStores) log('check', `store art: ${Object.keys(pulled!.files).length} file(s) pulled from the stores (${posix(relative(ctx.loaded.root, resolve(config.renders ?? config.shots?.out ?? 'store/renders')))}/pulled.json); render sources are optional`); + if (config.shots && fromStores) { + try { + const copy = JSON.parse(readFileSync(resolve(config.shots.copy), 'utf8')) as CopyFile; + const e = validateCopy(copy, config.shots, config.languages.map(l => l.id)); + if (e.length) note(`shots copy (only needed to render again; the store art is pulled): ${e.join('; ')}`); + } catch (e) { note(`shots copy (only needed to render again; the store art is pulled): ${(e as Error).message}`); } + } else if (config.shots) tryDo('shots copy', () => { const copy = JSON.parse(readFileSync(resolve(config.shots!.copy), 'utf8')) as CopyFile; const e = validateCopy(copy, config.shots!, config.languages.map(l => l.id)); if (e.length) throw new Error(e.join('; ')); @@ -198,7 +210,11 @@ export async function run(ctx: Ctx): Promise { if (subs.length) log('check', `subscriptions: ${subs.length} in ${config.subscriptions!.length} group(s)`); const files = [config.icons?.composite, config.icons?.foreground, config.icons?.background, config.shots?.logo, ...(config.products ?? []).map(p => p.reviewScreenshot), ...subs.map(x => x.sub.reviewScreenshot)].filter((f): f is string => !!f); - for (const f of new Set(files)) if (!existsSync(resolve(f))) problems.push(`missing file: ${f}`); + const renderSources = new Set([config.icons?.composite, config.icons?.foreground, config.icons?.background, config.shots?.logo].filter(Boolean)); + for (const f of new Set(files)) if (!existsSync(resolve(f))) { + if (fromStores && renderSources.has(f)) note(`render source not there: ${f} (only needed to render icons / screenshots again; the store art is pulled)`); + else problems.push(`missing file: ${f}`); + } if (existsSync(resolve('.gitignore')) && !/^\/?\.storebound\/?$/m.test(readFileSync(resolve('.gitignore'), 'utf8'))) problems.push('.gitignore does not ignore .storebound/ (credentials live there)'); diff --git a/cli/commands.ts b/cli/commands.ts index f417fe0..9918485 100644 --- a/cli/commands.ts +++ b/cli/commands.ts @@ -31,6 +31,7 @@ export const COMMANDS: Record Promise, string]> = { 'validate': [() => import('./validate'), 'read-only store readiness report with ordered blockers (--apple / --play), plus local renders checks (--no-local skips); exit 1 on blockers'], 'analytics': [() => import('./analytics'), 'setup (App Store report request) | snapshot (impressions, page views, installs, conversion -> .storebound/analytics) | compare (vs listing changes in git)'], 'health': [() => import('./health'), 'crash / ANR vitals (Play) and diagnostics (App Store), read-only; --weekly writes a markdown report; exit 1 over a threshold'], + 'art': [() => import('./art'), 'pull [--store apple|play] [--write] [--force]: the live store art (App Store screenshot sets, previews, icon; Play icon, feature graphic, screenshots per language) into store/renders + pulled.json, so checks and uploads treat it like rendered art (upsert; differing local files kept without --force; read-only on the stores) | live [--json]: the live art with thumbnails | replace --from --to : one image of the app folder'], 'onboard': [() => import('./onboard'), 'an app that is already live: the live products + subscriptions with their USD / base prices, contact e-mail, website, privacy and support URLs, the App Review contact and demo account, sign-in for review and the account deletion page, from the stores, the code and the app\'s site -> config and .storebound/credentials.json (--write; read-only on the stores, never changes store prices); then the owner questions nothing proves'], 'catalog': [() => import('./catalog'), 'diff: cross-store parity of products + subscriptions (missing / extra / mismatched, prices per region, offers); read-only, exit 1 on mismatches | pull [--write]: store texts of IAPs + subscriptions as a config proposal'], 'reports': [() => import('./reports'), 'apple-sales | apple-finance --month | play-earnings --month | play-sales --month: download to .storebound/reports/, summary table + CSV; read-only'], diff --git a/cli/data/flags.json b/cli/data/flags.json index 27537e1..b689fa7 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -269,6 +269,16 @@ "name", "store" ], + "art": [ + "apple", + "force", + "from", + "play", + "store", + "to", + "url", + "write" + ], "aso": [ "competitors", "delay", @@ -451,6 +461,7 @@ "onboard": [ "all", "allow-price-changes", + "apple", "base", "capacitor", "ceiling", @@ -462,19 +473,25 @@ "file", "floor", "flutter", + "force", + "from", "godot", + "no-art", "no-catalog", "no-subscriptions", "no-web", "offline", "out", + "play", "react-native", "reconvert", "source", "store", "threshold", + "to", "unity", "update-bigmac", + "url", "with-automatic", "write" ], @@ -899,6 +916,7 @@ "month", "name", "new-version", + "no-art", "no-captions", "no-catalog", "no-declarations", @@ -1160,6 +1178,12 @@ "storebound apps add [] | apps remove add / remove a folder in that list (the folder itself stays)", "storebound apps add --store apple|play --app-id [--account ] [--name ] [--bundle-id ]" ], + "art": [ + "storebound art pull [--store apple|play] [--write] [--force] [--dry] [--json]", + "storebound art live [--store apple|play] [--json] the live art per store, language and set with thumbnail", + "storebound art thumb --url --json one thumbnail as a data:", + "storebound art replace --from --to [--dry] [--json]" + ], "aso": [ "storebound aso rank [--storefronts us,de=de-DE] [--keywords \"a,b\"] [--only-keywords] [--competitors 3] [--limit 200]", "storebound aso competitors [--term ]... [--storefront us,tr] [--only-terms] [--top 10] [--delay 3500] [--no-save] [--dry] [--json]", @@ -1236,7 +1260,7 @@ "storebound listing live [--json] | set --store play|apple --lang --fields '' [--dry] the texts straight on" ], "onboard": [ - "storebound onboard [--store apple|play] [--write] [--dry] [--json] [--no-web] [--no-catalog]" + "storebound onboard [--store apple|play] [--write] [--dry] [--json] [--no-web] [--no-catalog] [--no-art]" ], "originality": [ "storebound originality [--against [,]] [--threshold 0.7] [--json]" diff --git a/cli/lib/art-pull.ts b/cli/lib/art-pull.ts new file mode 100644 index 0000000..3ad9358 --- /dev/null +++ b/cli/lib/art-pull.ts @@ -0,0 +1,256 @@ +// Store art pulled from the live stores (`storebound art pull`): where each live image goes in the project's store art +// layout, the upsert plan against the local files, and the manifest (/pulled.json) that lets the checks and +// the uploads treat pulled files like rendered art. Pure except readPulled / pulledChecksum (they read files). +// +// Layout (the same as `shots render` writes, so check / validate / play images / apple screenshots read it unchanged): +// //ios69|ios65|iphone-medium|ipad13/NN.png App Store screenshot sets (APP_IPHONE_67, _65, _61, +// APP_IPAD_PRO_3GEN_129); other display types go to +// apple-/ (kept, not checked or uploaded) +// //preview/iphone|ipad/NN.mp4 App Store app previews (IPHONE_67, IPAD_PRO_3GEN_129) +// //play|tab7|tab10|tv|wear/NN.png|jpg Play screenshots per type +// //feature/1024x500.png|jpg Play feature graphic +// /app-store-1024.png, play.icon (or /play-512.png) the icons +// pulled.json: per file (path relative to the renders folder) the store, type, store locale, the original URL's host +// (never the URL), the local file's sha256, the store's own checksum of the uploaded file (App Store: MD5 +// sourceFileChecksum; Play: sha256) and when it was pulled; plus `storeEmpty`: the / sets the store had +// nothing for (a Play language using the default language's graphics, an App Store locale without that set), which +// the local checks then do not demand. +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join, relative, sep } from 'node:path'; + +export type ArtStore = 'apple' | 'play'; +export type ArtKind = 'screenshot' | 'preview' | 'icon' | 'featureGraphic'; + +/** One live image (or preview) on a store. */ +export interface RemoteArt { + store: ArtStore; + kind: ArtKind; + /** ASC screenshotDisplayType / previewType, or the Play image type. */ + type: string; + /** Store locale (App Store locale, Play language code); '' for the App Store icon. */ + locale: string; + /** Position in its set, from 0. */ + index: number; + /** Download URL of the original (App Store: the template at the original size; Play: =s0). */ + url: string; + /** A small version for thumbnails. */ + thumb: string; + w?: number; + h?: number; + /** The store's checksum of the uploaded file (ASC sourceFileChecksum = MD5, Play sha256); null when it has none. */ + checksum: string | null; + /** Stable id of the store asset (ASC id, Play image id, icon token). */ + id: string; +} + +/** App Store display type -> renders size folder. */ +export const APPLE_SHOT_DIR: Record = { APP_IPHONE_67: 'ios69', APP_IPHONE_65: 'ios65', APP_IPHONE_61: 'iphone-medium', APP_IPAD_PRO_3GEN_129: 'ipad13' }; +/** App Store preview type -> preview folder. */ +export const APPLE_PREVIEW_DIR: Record = { IPHONE_67: 'iphone', IPAD_PRO_3GEN_129: 'ipad' }; +/** Play image type -> renders size folder (screenshots; the feature graphic and icon are files of their own). */ +export const PLAY_SHOT_DIR: Record = { phoneScreenshots: 'play', sevenInchScreenshots: 'tab7', tenInchScreenshots: 'tab10', tvScreenshots: 'tv', wearScreenshots: 'wear' }; +export const PLAY_TYPES = ['icon', 'featureGraphic', ...Object.keys(PLAY_SHOT_DIR)] as const; + +export const nn = (i: number): string => String(i + 1).padStart(2, '0'); +export const posix = (p: string): string => p.split(sep).join('/'); + +/** The size folder of an App Store type (unknown display types keep their own folder, outside the checks). */ +export function appleDir(kind: ArtKind, type: string): string { + if (kind === 'preview') return `preview/${APPLE_PREVIEW_DIR[type] ?? `apple-${type.toLowerCase()}`}`; + return APPLE_SHOT_DIR[type] ?? `apple-${type.toLowerCase()}`; +} + +/** App Store template URL (`.../{w}x{h}{c}.{f}`) filled in. */ +export function fillTemplate(t: string, w: number, h: number, f: 'png' | 'jpg'): string { + return t.replace('{w}', String(w)).replace('{h}', String(h)).replace('{c}', 'bb').replace('{f}', f); +} +/** Thumbnail size: the long side at most `max`, the aspect kept. */ +export function thumbSize(w: number, h: number, max = 300): [number, number] { + const s = Math.min(1, max / Math.max(w, h || 1)); + return [Math.max(1, Math.round(w * s)), Math.max(1, Math.round(h * s))]; +} +/** Play image URL: the original bytes (=s0) or a thumbnail (=w). Any size suffix already there is replaced. */ +export const playUrl = (url: string, size: 'original' | number): string => url.replace(/=[a-z0-9-]+$/i, '') + (size === 'original' ? '=s0' : `=w${size}`); + +/** Where a live image goes, as a path relative to the project root WITHOUT its extension for images whose format is + * known only after the download (Play), with `ext` the default. null: no config language for this locale. */ +export interface Slot { rel: string; ext: string; lang: string; size: string } + +export interface SlotCtx { + renders: string; + appleIcon: string; + playIcon: string; + /** Store locale -> config language id. */ + langOf: (store: ArtStore, locale: string) => string | null; +} + +export function slotOf(r: RemoteArt, c: SlotCtx): Slot | null { + if (r.kind === 'icon') { + const f = r.store === 'apple' ? c.appleIcon : c.playIcon; + const m = /\.(png|jpe?g)$/i.exec(f); + return { rel: m ? f.slice(0, -m[0].length) : f, ext: m ? m[1]!.toLowerCase() : 'png', lang: '', size: 'icon' }; + } + const lang = c.langOf(r.store, r.locale); + if (!lang) return null; + if (r.store === 'apple') { + const size = appleDir(r.kind, r.type); + return { rel: `${c.renders}/${lang}/${size}/${nn(r.index)}`, ext: r.kind === 'preview' ? 'mp4' : 'png', lang, size }; + } + if (r.kind === 'featureGraphic') return { rel: `${c.renders}/${lang}/feature/1024x500`, ext: 'png', lang, size: 'feature' }; + const size = PLAY_SHOT_DIR[r.type]; + if (!size) return null; + return { rel: `${c.renders}/${lang}/${size}/${nn(r.index)}`, ext: 'png', lang, size }; +} + +// --------------------------------------------------------------------------------------------------------------------- +// The manifest + +export interface PulledEntry { + store: ArtStore; + kind: ArtKind; + type: string; + locale: string; + /** Host of the original URL (is1-ssl.mzstatic.com, lh3.googleusercontent.com); never the URL itself. */ + host: string; + /** sha256 of the local file as pulled. */ + sha256: string; + /** The store's checksum of the uploaded original (App Store MD5, Play sha256), when it gives one. */ + storeChecksum: string | null; + /** The store asset id (App Store screenshot id, Play image id, icon token hash). */ + storeId: string; + pulledAt: string; +} + +export interface Pulled { + version: 1; + note: string; + /** Path relative to the renders folder -> entry. */ + files: Record; + /** / sets the store had nothing for when pulled. */ + storeEmpty: string[]; + pulledAt: string; +} + +export const PULLED_FILE = 'pulled.json'; +export const PULLED_NOTE = 'Store art pulled from the live stores by `storebound art pull` (sha256 of each file, the store and its checksum). Files still matching their sha256 are the store\'s own: uploads skip them and the checks do not ask for render sources. Edit or re-render a file and it counts as your own again.'; + +export const emptyPulled = (): Pulled => ({ version: 1, note: PULLED_NOTE, files: {}, storeEmpty: [], pulledAt: '' }); + +const cache = new Map(); +/** /pulled.json, or null when there is none (or it does not parse). */ +export function readPulled(rendersDir: string): Pulled | null { + const f = join(rendersDir, PULLED_FILE); + let mtime = -1; + try { mtime = statSync(f).mtimeMs; } catch { return null; } + const hit = cache.get(f); + if (hit && hit.mtime === mtime) return hit.data; + let data: Pulled | null = null; + try { + const j = JSON.parse(readFileSync(f, 'utf8')) as Partial; + if (j && typeof j === 'object' && j.files && typeof j.files === 'object') data = { ...emptyPulled(), ...j, files: j.files, storeEmpty: Array.isArray(j.storeEmpty) ? j.storeEmpty : [] } as Pulled; + } catch { data = null; } + cache.set(f, { mtime, data }); + return data; +} + +export const sha256Of = (b: Uint8Array): string => createHash('sha256').update(b).digest('hex'); +export const md5Of = (b: Uint8Array): string => createHash('md5').update(b).digest('hex'); + +/** The pulled entry of a file that is still exactly as pulled (sha256 equal), else null. */ +export function pulledEntry(p: Pulled | null, rendersDir: string, file: string, bytes?: Uint8Array): PulledEntry | null { + if (!p) return null; + const e = p.files[posix(relative(rendersDir, file))]; + if (!e) return null; + try { return sha256Of(bytes ?? readFileSync(file)) === e.sha256 ? e : null; } catch { return null; } +} + +/** For uploads: the store's checksum of a file that is still exactly as pulled from that store (App Store MD5 / Play + * sha256 of the original upload), so the upload compares the store with itself and sends nothing. null otherwise. + * The manifest is looked up in the file's parent folders (renders///NN.png: three up). */ +export function pulledChecksum(file: string, store: ArtStore): string | null { + let d = dirname(file); + for (let i = 0; i < 5; i++) { + if (existsSync(join(d, PULLED_FILE))) { + const e = pulledEntry(readPulled(d), d, file); + return e && e.store === store && e.storeChecksum ? e.storeChecksum : null; + } + const up = dirname(d); + if (up === d) break; + d = up; + } + return null; +} + +// --------------------------------------------------------------------------------------------------------------------- +// The plan + +export type ArtAction = 'add' | 'same' | 'update' | 'conflict' | 'remove'; + +export interface LocalFile { rel: string; sha256: string; md5: string } + +export interface ArtStep { + action: ArtAction; + /** Target path relative to the project root (with the extension of the local file, or the default one). */ + rel: string; + remote: RemoteArt | null; + why: string; + /** Overwrite / remove only with --force. */ + needsForce?: boolean; + /** `same` because the local file is the store's (checksum equal) but not yet in the manifest: record it. */ + adopt?: boolean; +} + +/** What to do with one live image given the local file in its slot (any extension) and the manifest entry of that + * file. Pure. `force`: a local file that differs and is not an unchanged pull is overwritten. */ +export function decide(r: RemoteArt, slotRel: string, local: LocalFile | null, entry: PulledEntry | null, force: boolean, defaultExt: string): ArtStep { + if (!local) return { action: 'add', rel: `${slotRel}.${defaultExt}`, remote: r, why: 'not in the project yet' }; + const ours = entry && entry.sha256 === local.sha256; + if (ours) { + if (entry.storeId === r.id && (entry.storeChecksum ?? null) === (r.checksum ?? null)) return { action: 'same', rel: local.rel, remote: r, why: 'pulled before, unchanged on the store' }; + return { action: 'update', rel: local.rel, remote: r, why: 'changed on the store since the last pull (the local file is the earlier pull, unedited)' }; + } + const sameAsStore = !!r.checksum && (r.store === 'play' ? local.sha256 === r.checksum : local.md5 === r.checksum); + if (sameAsStore) return { action: 'same', rel: local.rel, remote: r, why: 'the local file is the one on the store', adopt: true }; + return force + ? { action: 'update', rel: local.rel, remote: r, why: 'the local file differs from the store (--force: replaced)', needsForce: true } + : { action: 'conflict', rel: local.rel, remote: r, why: entry ? 'edited after the pull: differs from the store' : 'your own file differs from the store', needsForce: true }; +} + +/** Local NN files of a set beyond what the store has: removed when they are unchanged pulls (or with --force), else a + * conflict (the set would not match the store). Pure. */ +export function extraStep(local: LocalFile, entry: PulledEntry | null, force: boolean): ArtStep { + const ours = !!entry && entry.sha256 === local.sha256; + if (ours) return { action: 'remove', rel: local.rel, remote: null, why: 'no longer on the store (an earlier pull)' }; + return force ? { action: 'remove', rel: local.rel, remote: null, why: 'not on the store (--force: removed)', needsForce: true } + : { action: 'conflict', rel: local.rel, remote: null, why: 'your own file, not on the store: the set would differ from the store', needsForce: true }; +} + +export interface ArtSummary { add: number; same: number; update: number; conflict: number; remove: number } +export const summarize = (steps: ArtStep[]): ArtSummary => { + const s: ArtSummary = { add: 0, same: 0, update: 0, conflict: 0, remove: 0 }; + for (const x of steps) s[x.action]++; + return s; +}; + +/** File extension from the first bytes (PNG / JPEG / MP4 / QuickTime). */ +export function sniffExt(b: Uint8Array): 'png' | 'jpg' | 'mp4' | 'mov' | null { + if (b.length > 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'png'; + if (b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'jpg'; + if (b.length > 12 && String.fromCharCode(b[4]!, b[5]!, b[6]!, b[7]!) === 'ftyp') return String.fromCharCode(b[8]!, b[9]!) === 'qt' ? 'mov' : 'mp4'; + return null; +} + +/** Hosts storebound downloads store art from (and the desktop's thumbnails): Apple's and Google's image CDNs only. */ +export const ART_HOST_RE = /^https:\/\/([a-z0-9-]+\.)*(mzstatic\.com|googleusercontent\.com|ggpht\.com)\//i; +export const hostOf = (url: string): string => { try { return new URL(url).host; } catch { return ''; } }; + +/** Every / of the required store sets that got no file from the store. Pure. */ +export function storeEmptySets(langs: { id: string; apple: boolean; play: boolean }[], filled: Set, sizes: { apple: string[]; play: string[] }): string[] { + const out: string[] = []; + for (const l of langs) { + for (const s of l.apple ? sizes.apple : []) if (!filled.has(`${l.id}/${s}`)) out.push(`${l.id}/${s}`); + for (const s of l.play ? sizes.play : []) if (!filled.has(`${l.id}/${s}`)) out.push(`${l.id}/${s}`); + } + return out.sort(); +} diff --git a/cli/lib/desktop-protocol.ts b/cli/lib/desktop-protocol.ts index 12909da..3395981 100644 --- a/cli/lib/desktop-protocol.ts +++ b/cli/lib/desktop-protocol.ts @@ -365,6 +365,22 @@ export interface DeclarationsResult { export interface ListingText { play: { path: string; text: string } | null; apple: { path: string; text: string } | null } /** project.render: one rendered image as a data: URL (null when missing or too big). */ export interface RenderImage { file: string; dataUrl: string | null; bytes: number } +/** art.live (`art live --json`): the store art that is live, per store, locale and set, with thumbnail URLs (art.thumb + * turns one into a data: URL) and whether the app folder has the same file (same / differs / missing; n/a for a + * store-only app). */ +export interface LiveArtTile { index: number; type: string; w?: number; h?: number; thumb: string; state: 'same' | 'differs' | 'missing' | 'n/a'; file?: string } +export interface LiveArtSet { store: 'apple' | 'play'; locale: string; lang: string | null; kind: string; type: string; size: string; label: string; tiles: LiveArtTile[] } +export interface LiveArtResult { + generated: string; + stores: { store: 'apple' | 'play'; ok: boolean; version?: string | null; images: number; error?: string }[]; + icons: (LiveArtTile & { store: 'apple' | 'play' })[]; + sets: LiveArtSet[]; + notes: string[]; + /** The app folder has a pulled.json (art pull ran). */ + pulled: boolean; +} +/** art.thumb: one live thumbnail. */ +export interface ArtThumb { dataUrl: string; bytes: number } /** project.renders (`shots gallery --json`): every render per variant, language and size, with the store check issues. */ export interface RenderIssue { level: 'error' | 'warn'; code: string; msg: string } export interface RenderTile { src: string; kind: 'image' | 'video'; poster?: string; name: string; w: number; h: number; bytes: number; issues: RenderIssue[] } diff --git a/cli/mcp-tools.ts b/cli/mcp-tools.ts index f726dda..bc894f9 100644 --- a/cli/mcp-tools.ts +++ b/cli/mcp-tools.ts @@ -333,8 +333,11 @@ export const TOOLS: ToolDef[] = [ description: 'Store titles and descriptions (Play: benefits) of in-app purchases and subscriptions vs the config listings, as a proposal diff. storebound.config.ts is never rewritten; with dry_run false the proposal is saved to .storebound/pull/catalog.json.', props: { store: { type: 'string', enum: ['apple', 'play'] } }, args: i => [...opt('store', i.store), '--json', ...(i.dry_run === false ? ['--write'] : [])] }, { name: 'onboard', title: 'Onboard a live app', command: ['onboard'], write: true, network: true, idempotent: true, - description: 'For an app that is already live: reads the live products and subscriptions (USD / base prices), contact e-mail, website, privacy and support URLs, the App Review contact and demo account, sign-in for review and the account deletion page from the stores, the code and the app\'s site, and lists the owner questions nothing proves. Read-only on the stores, never changes store prices. dry_run shows the plan; with dry_run false storebound.config.ts and .storebound/credentials.json are updated (personal data masked, never printed).', + description: 'For an app that is already live: reads the live products and subscriptions (USD / base prices), contact e-mail, website, privacy and support URLs, the App Review contact and demo account, sign-in for review and the account deletion page from the stores, the code and the app\'s site, plus the live store art (art_pull: screenshots, previews, icon, feature graphic into store/renders), and lists the owner questions nothing proves. Read-only on the stores, never changes store prices. dry_run shows the plan; with dry_run false storebound.config.ts, .storebound/credentials.json and the store art files are updated (personal data masked, never printed; differing local art is kept).', props: { store: { type: 'string', enum: ['apple', 'play'] } }, args: i => [...opt('store', i.store), '--json', ...(i.dry_run === false ? ['--write'] : [])] }, + { name: 'art_pull', title: 'Pull the live store art', command: ['art', 'pull'], write: true, network: true, idempotent: true, + description: 'For an app that is already live: every App Store screenshot set and app preview per locale and display type, the App Store icon, and per Play language the icon, feature graphic and phone / tablet / TV / Wear screenshots, downloaded at their original size into the store art layout (store/renders///NN.png, feature/1024x500, the icons) with store/renders/pulled.json (sha256, store, URL host, when), so check / validate treat them like rendered art and play_images / apple_screenshots see them as the store\'s own (nothing uploaded). Read-only on the stores. An upsert: a re-run changes nothing; a local file that differs from the store (your own render) is kept and listed (only the owner\'s terminal run with --force replaces it). dry_run shows the plan; with dry_run false the files are written (local files only).', + props: { store: { type: 'string', enum: ['apple', 'play'] } }, args: i => [...opt('store', i.store), ...(i.dry_run === false ? ['--write'] : [])] }, { name: 'whats_new_languages', title: 'Release notes per language', command: ['whats-new'], write: true, network: false, idempotent: true, description: 'Draft release notes from git into one file per language (store/whats-new/.md): the first language gets the draft, the others status=translate files for you to translate (keep the header line, set status=translated). Then whats_new_check and whats_new_apply. Local files only.', props: { languages: { type: 'string', pattern: '^[A-Za-z0-9_-]+(,[A-Za-z0-9_-]+)*$', description: 'Comma-separated language ids or store codes, source language first' }, since: { type: 'string', description: 'Git tag or ref (default: latest tag)' } }, @@ -426,7 +429,7 @@ export const FORBIDDEN_ARGS = ['--write', '--yes', '--manual-release', '--cancel * `scan --write` (compliance answers into the config) stays unreachable: scan is not in this list. */ export const LOCAL_WRITE_TOOLS: Record = { - apple_listing_pull: ['--write'], play_listing_pull: ['--write'], catalog_pull: ['--write'], onboard: ['--write'], + apple_listing_pull: ['--write'], play_listing_pull: ['--write'], catalog_pull: ['--write'], onboard: ['--write'], art_pull: ['--write'], }; export function inputSchema(t: ToolDef): Record { diff --git a/cli/onboard.ts b/cli/onboard.ts index 92cebb5..f9605c3 100644 --- a/cli/onboard.ts +++ b/cli/onboard.ts @@ -1,6 +1,6 @@ // Onboarding an app that is already live on a store (after `storebound init` and `listing pull`): reads what the // stores and the code prove and puts it into the project, so the owner is asked only what nothing proves. -// storebound onboard [--store apple|play] [--write] [--dry] [--json] [--no-web] [--no-catalog] +// storebound onboard [--store apple|play] [--write] [--dry] [--json] [--no-web] [--no-catalog] [--no-art] // Read-only on both stores (GETs; a Play edit that is opened, read and deleted, never committed). Without --write it // prints the plan; --write (never with --dry) changes only local files: // 1. products / subscriptions in storebound.config.ts from the live catalog of both stores: ids, type, listings per @@ -13,9 +13,13 @@ // account set on App Store Connect goes into .storebound/credentials.json reviewDemoAccount, never printed. // 4. accounts.deletionUrl, when the app has accounts and none is set: a deletion page found on the app's own site // (website, privacy policy links). Not found is reported as the gap it is, never invented. +// 5. the live store art (art pull): App Store screenshot sets, previews and icon, Play icon, feature graphic and +// screenshots into store/renders with pulled.json, so check / validate / uploads treat them like rendered art; +// local files that differ are kept (art pull --force replaces them). // Then the owner questions that are left (one batch). import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import { artPull, printPull } from './art'; import { appleItems, appleTexts, playItems, playTexts } from './catalog'; import { ascClient, type J } from './apple/asc'; import type { StoreItem } from './lib/catalog'; @@ -171,6 +175,16 @@ export async function run(ctx: Ctx): Promise { } else if (config.play) questions.push('Account deletion page (Play requires a web page to request account deletion): none was found on the app\'s site; publish one, then set accounts.deletionUrl (account-deletion skill)'); } report.deletion = deletion; + + // 5. the store art (icon, screenshots, previews, feature graphic) from the live stores into store/renders, so + // nothing has to be rendered again for an app that is already live (art pull; upsert, local files kept) + if (!a.has('no-art')) { + try { + const art = await artPull(ctx, { store, write, force: false, quiet: json }); + report.art = { summary: art.summary, conflicts: art.conflicts, unmapped: art.unmapped, written: art.written, stores: art.stores }; + if (!json) printPull(art, write); + } catch (e) { warn(TAG, `store art: ${(e as Error).message.slice(0, 300)}`); } + } report.questions = questions; const changed = text !== before; diff --git a/cli/play/images.ts b/cli/play/images.ts index 91e4e17..d064338 100644 --- a/cli/play/images.ts +++ b/cli/play/images.ts @@ -25,6 +25,7 @@ import { byPlayCode, need } from '../lib/ctx'; import { draftReason, draftSections } from '../lib/listing-plan'; import { done, log, step } from '../lib/log'; import { playClient } from './client'; +import { pulledChecksum } from '../lib/art-pull'; import { readPlayCopy } from './listing'; import { variantRoot } from '../shots/lib'; import { resolvedTablets } from '../shots/report'; @@ -114,6 +115,8 @@ export function describePlan(plan: ImageChange[]): string { export const noListing = (e: unknown): boolean => /-> 404\b/.test(String((e as Error)?.message ?? e)) && /listing/i.test(String((e as Error)?.message ?? e)); const sha256 = (f: string): string => createHash('sha256').update(readFileSync(f)).digest('hex'); +/** sha256 for the compare with Play: a file still exactly as `art pull` saved it counts with Play's own sha256. */ +const storeSha = (f: string): string => pulledChecksum(f, 'play') ?? sha256(f); export async function run(ctx: Ctx): Promise { const { config, root, resolve } = ctx.loaded; @@ -162,7 +165,7 @@ export async function run(ctx: Ctx): Promise { for (const code of codes) { const li = sets.get(code)!, out: ImageChange[] = []; try { - for (const [type, files] of li.sets) out.push(imageChange(type, await remoteOf(id, code, type), files.map(sha256))); + for (const [type, files] of li.sets) out.push(imageChange(type, await remoteOf(id, code, type), files.map(storeSha))); for (const type of li.missing) out.push(missingChange(type, (await remoteOf(id, code, type)).length, prune)); } catch (e) { if (!noListing(e)) throw e; @@ -199,7 +202,7 @@ export async function run(ctx: Ctx): Promise { await Promise.all([ ...li.sets.map(async ([type, files]) => { const remote = await remoteOf(id, code, type); - if (imageChange(type, remote, files.map(sha256)).change === 'same') return; + if (imageChange(type, remote, files.map(storeSha)).change === 'same') return; types++; if (remote.length) await play.call('DELETE', `${play.API}/edits/${id}/listings/${code}/${type}`); for (const f of files) { diff --git a/cli/serve.ts b/cli/serve.ts index 5f37b33..35b92da 100644 --- a/cli/serve.ts +++ b/cli/serve.ts @@ -41,6 +41,11 @@ const opt = (flag: string, v: unknown): string[] => (v === undefined || v === nu const lang = { type: 'string', pattern: '^[A-Za-z0-9_-]{1,20}(,[A-Za-z0-9_-]{1,20}){0,99}$', description: 'Comma-separated language ids from the config (default: all)' }; /** A price sheet: any .csv file path the owner picked (read only), never an option. */ export const PRICE_SHEET_RE = /^(?!-)[^\x00-\x1f]{1,1000}\.[cC][sS][vV]$/; +/** A live art thumbnail: https on Apple's or Google's image CDN only. */ +export const ART_THUMB_RE = /^https:\/\/([a-z0-9-]+\.)*(mzstatic\.com|googleusercontent\.com|ggpht\.com)\/[^\s"'<>]{1,1500}$/; +/** The picked image of art.replace (absolute, PNG / JPEG) and the app folder file it replaces (relative, no ..). */ +export const ART_FROM_RE = /^\/[^\x00-\x1f]{1,1000}\.([pP][nN][gG]|[jJ][pP][eE]?[gG])$/; +export const ART_TO_RE = /^(?![/-])(?!.*\.\.)[A-Za-z0-9 _./-]{1,500}\.(png|jpe?g|PNG|JPE?G)$/; /** MCP tools the desktop profile leaves out (sending builds to testers on Play, release state changes: the window has * the owner actions for those; workspace and release-train reads: the window has portfolio.status for those; the raw API reader api_get). */ @@ -51,12 +56,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_editable', 'listing_live']); + 'project_icon', 'project_listings', 'reviews_drafts', 'project_declarations', 'project_listing_text', 'project_renders', 'project_render', 'prices_current', 'project_editable', 'listing_live', 'art_live', 'art_thumb']); /** 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', 'project_edit', 'listing_set']); +export const JSON_WRITES = new Set(['console_fill', 'owner_apple_submit', 'owner_apple_resubmit', 'project_edit', 'listing_set', 'art_replace']); /** 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']); @@ -116,6 +121,17 @@ export const DESKTOP_ONLY: ToolDef[] = [ props: { form: { type: 'string', enum: CONSOLE_FORMS, description: 'The console form' }, store: { type: 'string', enum: ['apple', 'play'], description: 'Default: from the form' }, fresh_profile: { type: 'boolean', default: false, description: "Skip attaching to a Chrome that runs with remote debugging. storebound's own browser profile (the owner signs in there once) is always used otherwise; the owner's Chrome cookies are never copied here" } }, required: ['form'], args: i => [String(i.form), ...opt('store', i.store), ...(i.fresh_profile === true ? ['--fresh-profile'] : [])] }, + { name: 'art_live', title: 'Live store art', command: ['art', 'live'], write: false, network: true, + description: 'Read-only: the store art that is live per store, language and set (App Store screenshot sets and previews of the version being prepared, else the live one, and the icon; Play icon, feature graphic and screenshots per language) with thumbnail URLs and whether the app folder has the same file (pulled.json).' }, + { name: 'art_thumb', title: 'One live thumbnail', command: ['art', 'thumb'], write: false, network: true, + description: 'One thumbnail of the live store art as a data: URL (at most 2 MB), from the App Store or Google Play image CDN only.', + props: { url: { type: 'string', pattern: ART_THUMB_RE.source, description: 'A thumb URL from art.live' } }, + required: ['url'], args: i => ['--url', String(i.url)] }, + { name: 'art_replace', title: 'Replace one store image', command: ['art', 'replace'], write: true, network: false, idempotent: true, + description: 'Replaces one image of the app folder (a screenshot in the renders folder, or a frame a scene shows) with a PNG / JPEG file the owner picked. The dry run shows old -> new size and the store checks (size, alpha, bytes); an error stops it. Nothing goes to a store: upload with the Play / App Store apply after its dry run.', + props: { from: { type: 'string', pattern: ART_FROM_RE.source, description: 'The picked file (absolute path)' }, + to: { type: 'string', pattern: ART_TO_RE.source, description: 'The image to replace, relative to the app folder' } }, + required: ['from', 'to'], args: i => ['--from', String(i.from), '--to', String(i.to)] }, { name: 'prices_current', title: 'Current prices', command: ['prices', 'export'], write: false, network: true, description: 'Read-only: the current price of every product on both stores per region (Apple manual prices, with with_automatic also the equalized ones). Writes nothing.', props: { store: { type: 'string', enum: ['apple', 'play'] }, with_automatic: { type: 'boolean', default: false } }, @@ -246,7 +262,7 @@ const SYSTEM: MethodDescriptor[] = [ ]; /** Read methods a store-only app (params.virtual, no app folder) may use: store reads that need only the store ids. */ -export const VIRTUAL_READS = new Set(['apple_status', 'play_status', 'health', 'health_issue', 'reviews_list', 'apple_testflight_status', 'apple_ppo_status', 'listing_live']); +export const VIRTUAL_READS = new Set(['apple_status', 'play_status', 'health', 'health_issue', 'reviews_list', 'apple_testflight_status', 'apple_ppo_status', 'listing_live', 'art_live', 'art_thumb']); /** Writes a store-only app may run (no app folder: they send only what the call carries, never the generated config's * placeholder links). The confirm token binds them to the store ids (`virtual`) instead of a folder. */ export const VIRTUAL_WRITES = new Set(['listing_set']); diff --git a/cli/shots/report.ts b/cli/shots/report.ts index 20b0206..d5e4baf 100644 --- a/cli/shots/report.ts +++ b/cli/shots/report.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; import { boxOutside, type DeviceBox, PLAY_TABLET_SIZES, SIZES } from './lib'; import { fontIssues, type FontUsage } from './glyphs'; +import { pulledEntry, readPulled } from '../lib/art-pull'; import { checkCount, checkImage, checkVideo, type ImageInfo, imageInfo, type Issue, IMAGE_SPECS, parseProbe, PREVIEW_RULES, type ProbeInfo, VIDEO_TARGETS } from './spec'; /** /report.json: path relative to the renders dir -> flags found while rendering. missing = 404s (frames); @@ -51,6 +52,10 @@ export function scanRenders(dir: string, o: { langs: string[]; variants?: string required?: Record | ((lang: string) => Record); sizes?: string[]; probe?: (file: string) => ProbeInfo | null }): RendersScan { const report = readReport(dir); + // art pulled from the stores (art pull): an unchanged pulled file is the store's own (the store took it), and a set + // the store had nothing for (Play's default-language fallback, a locale without that set) is not demanded here: + // validate reports the store side + const pulled = readPulled(dir); const out: RendersScan = { images: [], videos: [], sets: [], variants: [], langs: o.langs }; const probe = o.probe ?? ffprobe; for (const variant of [undefined, ...(o.variants ?? [])]) { @@ -66,9 +71,11 @@ export function scanRenders(dir: string, o: { langs: string[]; variants?: string const numbers = new Set(files.map(f => f.split('.')[0])); for (const f of files) { const file = join(sdir, f); - const info = imageInfo(readFileSync(file)); + const bytes = readFileSync(file); + const info = imageInfo(bytes); const rel = (variant ? `${variant}/` : '') + `${lang}/${size}/${f}`; - const issues = checkImage(info, size); + const fromStore = !variant && !!pulledEntry(pulled, dir, file, bytes); + const issues = fromStore ? [] : checkImage(info, size); const entry = report[rel.replace(/\.jpe?g$/i, '.png')]; for (const msg of entry?.overflow ?? []) issues.push({ level: 'warn', code: 'overflow', msg }); issues.push(...fontIssues(entry?.fonts)); @@ -76,7 +83,7 @@ export function scanRenders(dir: string, o: { langs: string[]; variants?: string if (entry && !entry.fonts) issues.push({ level: 'warn', code: 'fonts', msg: 'font usage not recorded (rendered by an older storebound): run shots fonts + shots render again so missing glyphs are caught' }); out.images.push({ rel, lang, variant, size, file, info, issues }); } - const required = req[size] ?? false; + const required = (req[size] ?? false) && !(!variant && !files.length && pulled?.storeEmpty.includes(`${lang}/${size}`)); if (!files.length && !required) continue; out.sets.push({ lang, variant, size, count: numbers.size, issues: checkCount(numbers.size, size, required) }); } diff --git a/docs/api-coverage.md b/docs/api-coverage.md index b0282d5..26523b6 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -257,7 +257,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | `appInfos_territoryAgeRatings_getToManyRelationship` | GET | `/v1/appInfos/{id}/relationships/territoryAgeRatings` | read | | | `appInfos_updateInstance` | PATCH | `/v1/appInfos/{id}` | write (dry by default) | apple/appinfo | | **appPreviewSets** (6) | | | | | -| `appPreviewSets_appPreviews_getToManyRelated` | GET | `/v1/appPreviewSets/{id}/appPreviews` | read | apple/previews | +| `appPreviewSets_appPreviews_getToManyRelated` | GET | `/v1/appPreviewSets/{id}/appPreviews` | read | apple/previews, art | | `appPreviewSets_appPreviews_getToManyRelationship` | GET | `/v1/appPreviewSets/{id}/relationships/appPreviews` | read | | | `appPreviewSets_appPreviews_replaceToManyRelationship` | PATCH | `/v1/appPreviewSets/{id}/relationships/appPreviews` | write (dry by default) | | | `appPreviewSets_createInstance` | POST | `/v1/appPreviewSets` | write (dry by default) | apple/previews | @@ -282,7 +282,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | `appPriceSchedules_manualPrices_getToManyRelated` | GET | `/v1/appPriceSchedules/{id}/manualPrices` | read | | | `appPriceSchedules_manualPrices_getToManyRelationship` | GET | `/v1/appPriceSchedules/{id}/relationships/manualPrices` | read | | | **appScreenshotSets** (6) | | | | | -| `appScreenshotSets_appScreenshots_getToManyRelated` | GET | `/v1/appScreenshotSets/{id}/appScreenshots` | read | apple/ppo, apple/screenshots, validate | +| `appScreenshotSets_appScreenshots_getToManyRelated` | GET | `/v1/appScreenshotSets/{id}/appScreenshots` | read | apple/ppo, apple/screenshots, art, validate | | `appScreenshotSets_appScreenshots_getToManyRelationship` | GET | `/v1/appScreenshotSets/{id}/relationships/appScreenshots` | read | | | `appScreenshotSets_appScreenshots_replaceToManyRelationship` | PATCH | `/v1/appScreenshotSets/{id}/relationships/appScreenshots` | write (dry by default) | | | `appScreenshotSets_createInstance` | POST | `/v1/appScreenshotSets` | write (dry by default) | apple/screenshots | @@ -335,9 +335,9 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | `appStoreVersionExperiments_getInstance` | GET | `/v1/appStoreVersionExperiments/{id}` | read | | | `appStoreVersionExperiments_updateInstance` | PATCH | `/v1/appStoreVersionExperiments/{id}` | owner only (typed confirm) | | | **appStoreVersionLocalizations** (14) | | | | | -| `appStoreVersionLocalizations_appPreviewSets_getToManyRelated` | GET | `/v1/appStoreVersionLocalizations/{id}/appPreviewSets` | read | apple/previews | +| `appStoreVersionLocalizations_appPreviewSets_getToManyRelated` | GET | `/v1/appStoreVersionLocalizations/{id}/appPreviewSets` | read | apple/previews, art | | `appStoreVersionLocalizations_appPreviewSets_getToManyRelationship` | GET | `/v1/appStoreVersionLocalizations/{id}/relationships/appPreviewSets` | read | | -| `appStoreVersionLocalizations_appScreenshotSets_getToManyRelated` | GET | `/v1/appStoreVersionLocalizations/{id}/appScreenshotSets` | read | apple/screenshots, validate | +| `appStoreVersionLocalizations_appScreenshotSets_getToManyRelated` | GET | `/v1/appStoreVersionLocalizations/{id}/appScreenshotSets` | read | apple/screenshots, art, validate | | `appStoreVersionLocalizations_appScreenshotSets_getToManyRelationship` | GET | `/v1/appStoreVersionLocalizations/{id}/relationships/appScreenshotSets` | read | | | `appStoreVersionLocalizations_createInstance` | POST | `/v1/appStoreVersionLocalizations` | write (dry by default) | listing-store | | `appStoreVersionLocalizations_deleteInstance` | DELETE | `/v1/appStoreVersionLocalizations/{id}` | owner only (typed confirm) | | @@ -371,7 +371,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | `appStoreVersions_appStoreVersionExperimentsV2_getToManyRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreVersionExperimentsV2` | read | | | `appStoreVersions_appStoreVersionExperiments_getToManyRelated` | GET | `/v1/appStoreVersions/{id}/appStoreVersionExperiments` | read | | | `appStoreVersions_appStoreVersionExperiments_getToManyRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreVersionExperiments` | read | | -| `appStoreVersions_appStoreVersionLocalizations_getToManyRelated` | GET | `/v1/appStoreVersions/{id}/appStoreVersionLocalizations` | read | apple/listing, apple/previews, apple/screenshots, listing-store, onboard, validate | +| `appStoreVersions_appStoreVersionLocalizations_getToManyRelated` | GET | `/v1/appStoreVersions/{id}/appStoreVersionLocalizations` | read | apple/listing, apple/previews, apple/screenshots, art, listing-store, onboard, validate | | `appStoreVersions_appStoreVersionLocalizations_getToManyRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreVersionLocalizations` | read | | | `appStoreVersions_appStoreVersionPhasedRelease_getToOneRelated` | GET | `/v1/appStoreVersions/{id}/appStoreVersionPhasedRelease` | read | apple/release, apple/status | | `appStoreVersions_appStoreVersionPhasedRelease_getToOneRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreVersionPhasedRelease` | read | | @@ -421,7 +421,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | `apps_appPriceSchedule_getToOneRelationship` | GET | `/v1/apps/{id}/relationships/appPriceSchedule` | read | | | `apps_appStoreVersionExperimentsV2_getToManyRelated` | GET | `/v1/apps/{id}/appStoreVersionExperimentsV2` | read | apple/ppo | | `apps_appStoreVersionExperimentsV2_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/appStoreVersionExperimentsV2` | read | | -| `apps_appStoreVersions_getToManyRelated` | GET | `/v1/apps/{id}/appStoreVersions` | read | apple/asc, apple/iap-submit, apple/listing, apple/release, apple/resubmit, apple/review-details, apple/status, lib/store-apps, listing-store, onboard, validate | +| `apps_appStoreVersions_getToManyRelated` | GET | `/v1/apps/{id}/appStoreVersions` | read | apple/asc, apple/iap-submit, apple/listing, apple/release, apple/resubmit, apple/review-details, apple/status, art, lib/store-apps, listing-store, onboard, validate | | `apps_appStoreVersions_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/appStoreVersions` | read | | | `apps_appTags_getToManyRelated` | GET | `/v1/apps/{id}/appTags` | read | | | `apps_appTags_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/appTags` | read | | @@ -651,7 +651,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | `builds_buildBetaDetail_getToOneRelationship` | GET | `/v1/builds/{id}/relationships/buildBetaDetail` | read | | | `builds_diagnosticSignatures_getToManyRelated` | GET | `/v1/builds/{id}/diagnosticSignatures` | read | health | | `builds_diagnosticSignatures_getToManyRelationship` | GET | `/v1/builds/{id}/relationships/diagnosticSignatures` | read | | -| `builds_getCollection` | GET | `/v1/builds` | read | apple/build, apple/encryption, apple/listing, apple/next-build, apple/resubmit, apple/status, apple/testflight, apple/upload, health, lib/store-apps, triage | +| `builds_getCollection` | GET | `/v1/builds` | read | apple/build, apple/encryption, apple/listing, apple/next-build, apple/resubmit, apple/status, apple/testflight, apple/upload, art, health, lib/store-apps, triage | | `builds_getInstance` | GET | `/v1/builds/{id}` | read | | | `builds_icons_getToManyRelated` | GET | `/v1/builds/{id}/icons` | read | | | `builds_icons_getToManyRelationship` | GET | `/v1/builds/{id}/relationships/icons` | read | | @@ -1592,13 +1592,13 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | **androidpublisher.edits.images** (4) | | | | | | `androidpublisher.edits.images.delete` | DELETE | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}/{imageType}/{imageId}` | owner only (typed confirm) | | | `androidpublisher.edits.images.deleteall` | DELETE | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}/{imageType}` | owner only (typed confirm) | play/images | -| `androidpublisher.edits.images.list` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}/{imageType}` | read | apps, play/images, validate | +| `androidpublisher.edits.images.list` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}/{imageType}` | read | apps, art, play/images, validate | | `androidpublisher.edits.images.upload` | POST | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}/{imageType}` | write (dry by default) | play/images | | **androidpublisher.edits.listings** (6) | | | | | | `androidpublisher.edits.listings.delete` | DELETE | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}` | owner only (typed confirm) | | | `androidpublisher.edits.listings.deleteall` | DELETE | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings` | owner only (typed confirm) | | | `androidpublisher.edits.listings.get` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}` | read | | -| `androidpublisher.edits.listings.list` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings` | read | listing-store, play/listing, play/status, validate | +| `androidpublisher.edits.listings.list` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings` | read | art, listing-store, play/listing, play/status, validate | | `androidpublisher.edits.listings.patch` | PATCH | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}` | write (dry by default) | listing-store | | `androidpublisher.edits.listings.update` | PUT | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}` | write (dry by default) | listing-store, play/listing | | **androidpublisher.edits.testers** (3) | | | | | diff --git a/docs/console-parity.md b/docs/console-parity.md index fe3d857..a4284a3 100644 --- a/docs/console-parity.md +++ b/docs/console-parity.md @@ -17,8 +17,8 @@ Columns: real run; owner-only steps through the owner-action dialog). - **Gap**: "none", or why a cell stays "no" on purpose. -Status: **after the editors (2026-10-14)**. The audit before them (Phase 1) had 70 rows, 62 of them with a gap. Now: 73 -rows, 55 with nothing missing, 18 with the reason a cell stays "no" (no store API, an owner-only public step, a +Status: **after the editors and the live store art (2026-10-14)**. The audit before them (Phase 1) had 70 rows, 62 of them with a gap. Now: 76 +rows, 57 with nothing missing, 19 with the reason a cell stays "no" (no store API, an owner-only public step, a secret, or personal data). ## App Store Connect @@ -36,7 +36,7 @@ secret, or personal data). | App Privacy > Privacy Policy URL | `apple listing` (app.privacyPolicyUrl) | yes | yes | yes | none | | Version > Copyright | `apple listing` (app.copyright) | yes | yes | yes | none | | Version > version string, build | `apple listing --version --build` | yes | yes | yes | none | -| Version > Screenshots (per device, locale) | `shots render`, `apple screenshots` (shots.scenes, the shots copy file) | yes | yes (scene order, the frame of each scene, the captions per language) | yes (`apple.screenshots`) | none | +| Version > Screenshots (per device, locale) | `art pull` (the live sets), `shots render`, `apple screenshots` (shots.scenes, the shots copy file) | yes (the live sets: `art.live` thumbnails per locale and display type; the local files) | yes (scene order, the frame of each scene, the captions per language; one image replaced with a picked file: `art.replace`) | yes (`apple.screenshots`; pulled files count as the store's own and upload nothing) | none | | Version > App Previews | `shots record`, `shots video`, `apple previews` | yes | n/a | yes (`apple.previews`) | reason: a preview is cut from recorded clips, not a field; replacing one is a new recording (`shots.video` render job in the window) | | App Information > Category, subcategories | `apple appinfo` (apple.info.categories) | yes | yes | yes (`apple.appinfo`) | none | | App Information > Age Rating | `apple appinfo` (apple.info.ageRating), `console fill apple-age-rating` | yes | yes (every question) | yes | none | @@ -78,7 +78,7 @@ secret, or personal data). | Main store listing > Short description | `play listing` (`### short`) | yes | yes | yes | none | | Main store listing > Full description | `play listing` (`### full`) | yes | yes (stuffing and claims checks while typing) | yes | none | | Release > Release notes (per language) | `play release`, `whats-new apply` (`### What's new`) | yes | yes | yes (with the release: `owner.play_production`) | none | -| Main store listing > Graphics (icon, feature graphic, screenshots) | `icons`, `shots render`, `play images` | yes | yes (scene order, frames, captions) | yes (`play.images`) | none | +| Main store listing > Graphics (icon, feature graphic, screenshots) | `art pull` (the live graphics), `icons`, `shots render`, `play images` | yes (the live graphics: `art.live` per language and type; the local files) | yes (scene order, frames, captions; one image replaced with a picked file: `art.replace`) | yes (`play.images`; pulled files count as the store's own and upload nothing) | none | | Main store listing > Video (YouTube URL) | `play listing` (play.video) | yes | yes | yes | none | | Store settings > Contact email | `play listing` app details (app.contactEmail) | yes | yes | yes | none | | Store settings > Website | `play listing` app details (app.website) | yes | yes | yes | none | @@ -106,6 +106,8 @@ secret, or personal data). | What | storebound | View | Edit | Store write | Gap | | --- | --- | --- | --- | --- | --- | | Pull the live texts into the files | `apple listing pull`, `play listing pull` | yes | n/a | n/a (writes the listing files after the diff) | none | +| Pull the live store art into the app folder (screenshots, previews, feature graphic, icons) | `art pull` (also run by `onboard`), store/renders/pulled.json | yes (`art.live`: live thumbnails, same / differs / not pulled per image) | n/a | n/a (`art.pull`: local files only after the dry run, read-only on the stores; differing local files kept) | none | | Store-only app (no folder): listing text per language | `listing live`, `listing set` | yes | yes | yes (`listing.set`, one language per apply) | none | -| Store-only app (no folder): URLs, contact, catalog, art | (the config of an app folder) | yes (reads) | no | no | reason: those writes send what an app folder's config holds; "Link a folder" or "Start an app folder" turns the app into a full one | +| Store-only app (no folder): store art | `art live` | yes (`art.live`: live thumbnails per language and set, read-only) | no | no | reason: replacing store art uploads files from an app folder; "Link a folder" or "Start an app folder" turns the app into a full one, whose Pull from stores (`art.pull`) brings the live art into it | +| Store-only app (no folder): URLs, contact, catalog | (the config of an app folder) | yes (reads) | no | no | reason: those writes send what an app folder's config holds; "Link a folder" or "Start an app folder" turns the app into a full one | | Uncommitted changes in the app folder | git status of the edited files | yes | n/a | n/a | none (storebound never commits) | diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 8c42949..60e247b 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -688,3 +688,15 @@ that fails without the fix (see CONTRIBUTING.md). `laneGap` / `storesDiffer` / `withLastGood` (`cli/lib/train.ts`), `tests/play-edit-lock.test.ts` (a fake Play where a second insert invalidates the first), the desktop boot check's auto-read case (two Play reads of one app at once, Orbit Hop on different versions per store). + +## 59. A live app showed missing art and render blockers although both stores had its screenshots +- **Do:** for an app that is already live, pull the art instead of asking for render sources: `storebound art pull + --dry`, then `--write` (`onboard` runs it too). The App Store CDN re-encodes: a downloaded screenshot never has the + MD5 App Store Connect keeps for the uploaded file (`sourceFileChecksum`), so without a record `apple screenshots` + would re-upload every set. `store/renders/pulled.json` keeps each pulled file's sha256 with the store's own checksum, + and the uploads compare an unchanged pulled file with that checksum (nothing sent). Play serves the original bytes + at `=s0` (its sha256 equals the one `images.list` gives; checked on download). Never overwrite a local file that + differs from the store without `--force`: it may be the owner's new render. +- **Guard:** `cli/art.ts`, `cli/lib/art-pull.ts` (`decide`, `pulledChecksum`, `storeEmptySets`), the pulled-file rules + in `scanRenders` (`cli/shots/report.ts`) and `check`, `tests/art-pull.test.ts` (recorded ASC screenshot sets with + `templateUrl` and Play `images.list`, the layout, the upsert and `--force`). diff --git a/skills/store-screenshots/SKILL.md b/skills/store-screenshots/SKILL.md index 9281187..cda6d3f 100644 --- a/skills/store-screenshots/SKILL.md +++ b/skills/store-screenshots/SKILL.md @@ -9,6 +9,25 @@ CLI: `storebound` (or `bunx storebound` in a project that has it, or `bun //NN.png`, `feature/1024x500.*`, `/app-store-1024.png` and + `play.icon`, plus `store/renders/pulled.json` (sha256, store, URL host, store checksum; no URLs, no personal data). + Read-only on the stores. A re-run changes nothing; `--force` (the owner's call) replaces differing local files. +3. Then `check` / `validate` treat the files like rendered art: render sources (icon artwork, logo, captions) become + notes, pulled files pass the local checks as the store's own, and sets the store has none of are not demanded + locally. `play images --dry` / `apple screenshots --dry` show "unchanged": a pulled file counts with the store's + own checksum until you edit or re-render it. +4. Change one image: edit or replace the file (desktop: Store art > open the image > Replace with a file), then the + upload's dry run shows exactly that set. Render new art (the steps below) only when the owner wants new screenshots. +Old display types without a size key here (e.g. `APP_IPHONE_55`) land in `apple-/` and are kept, never checked +or uploaded as another size. `store/renders` can be large (hundreds of MB): keep it out of git unless the project +wants it there. + ## Sizes (what each store wants) | size key | pixels | where | |---|---|---| diff --git a/tests/art-pull.test.ts b/tests/art-pull.test.ts new file mode 100644 index 0000000..682edda --- /dev/null +++ b/tests/art-pull.test.ts @@ -0,0 +1,297 @@ +// Store art pulled from the live stores (storebound art pull): the live shapes of App Store Connect screenshot sets +// (imageAsset.templateUrl) and Google Play images.list, where each file lands in the store art layout, the upsert +// (a re-run changes nothing, a differing local file is kept without --force), the manifest (host, never the URL), and +// what check / the local renders check / the uploads make of pulled files. Every id and URL is a placeholder. +import { afterEach, describe, expect, test } from 'bun:test'; +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { artPull, groupSteps, run as art } from '../cli/art'; +import { run as check } from '../cli/check'; +import { + decide, extraStep, fillTemplate, playUrl, pulledChecksum, readPulled, type RemoteArt, slotOf, sniffExt, storeEmptySets, thumbSize, +} from '../cli/lib/art-pull'; +import type { ShipConfig } from '../cli/lib/config'; +import { rendersCheck } from '../cli/shots/check'; +import { toArgv, TOOLS, LOCAL_WRITE_TOOLS } from '../cli/mcp-tools'; +import { png, jpeg } from './fixtures'; +import { ascEnv, BASE, capture, jsonOut, mockFetch, playEnv, project, restore } from './helpers/store-mock'; + +afterEach(restore); + +const sha = (b: Uint8Array) => createHash('sha256').update(b).digest('hex'); +const md5 = (b: Uint8Array) => createHash('md5').update(b).digest('hex'); +/** A PNG whose bytes differ per tag (imageInfo reads only the header). */ +const pic = (w: number, h: number, tag: string): Uint8Array => { const b = png(w, h, { pad: 200 }); b.set(new TextEncoder().encode(tag), 100); return b; }; + +// ---- live shapes (as App Store Connect and Google Play answered, ids and paths replaced) +/** GET /v1/appScreenshotSets/{id}/appScreenshots: one item. */ +const ascShot = (id: string, path: string, w: number, h: number, checksum: string) => ({ + type: 'appScreenshots', id, + attributes: { + fileSize: 900427, fileName: '01.png', sourceFileChecksum: checksum, + imageAsset: { templateUrl: `https://is1-ssl.mzstatic.com/image/thumb/${path}/{w}x{h}bb.{f}`, width: w, height: h }, + assetToken: `PurpleSource221/v4/00/00/00/${id}/01.png`, assetType: 'SortedD74ScreenShot', uploadOperations: [], + assetDeliveryState: { errors: [], warnings: null, state: 'COMPLETE' }, + }, + links: { self: `https://api.appstoreconnect.apple.com/v1/appScreenshots/${id}` }, +}); +/** GET .../edits/{id}/listings/{lang}/{imageType}: one image. */ +const playImage = (id: string, path: string, bytes: Uint8Array) => ({ id, url: `https://lh3.googleusercontent.com/${path}`, sha1: createHash('sha1').update(bytes).digest('hex'), sha256: sha(bytes), aiGeneratedState: 'aiGeneratedStateUnspecified' }); +const page = (data: unknown[]) => ({ data, links: { self: 'https://api.appstoreconnect.apple.com/v1/x' }, meta: { paging: { total: data.length, limit: 50 } } }); + +// what the CDNs serve (App Store: re-encoded, so its MD5 differs from the uploaded file's checksum; Play: the original) +const A1 = pic(1320, 2868, 'apple-en-1'), A2 = pic(1320, 2868, 'apple-en-2'), ICON = pic(1024, 1024, 'apple-icon'); +const P_ICON = pic(512, 512, 'play-icon'), P_FEAT = jpeg(1024, 500, 300), P1 = pic(1080, 1920, 'play-en-1'), P2 = pic(1080, 1920, 'play-en-2'), PDE1 = pic(1080, 1920, 'play-de-1'), PDE2 = pic(1080, 1920, 'play-de-2'); +const UPLOADED_MD5 = ['0a1b2c3d4e5f60718293a4b5c6d7e8f9', '1a1b2c3d4e5f60718293a4b5c6d7e8f9']; + +function stores(o: { apple2?: Uint8Array; apple2Id?: string } = {}) { + const P = '/androidpublisher/v3/applications/com.example.notes'; + const images: Record = { + 'en-US/icon': [playImage('101', 'p-icon', P_ICON)], 'en-US/featureGraphic': [playImage('102', 'p-feat', P_FEAT)], + 'en-US/phoneScreenshots': [playImage('103', 'p-en-1', P1), playImage('104', 'p-en-2', P2)], + 'de-DE/icon': [playImage('101', 'p-icon', P_ICON)], 'de-DE/phoneScreenshots': [playImage('105', 'p-de-1', PDE1), playImage('106', 'p-de-2', PDE2)], + }; + return mockFetch([ + { match: /^\/v1\/apps\/6400000101\/appStoreVersions/, answer: page([{ type: 'appStoreVersions', id: 'v1', attributes: { versionString: '2.0', appStoreState: 'READY_FOR_SALE', appVersionState: 'READY_FOR_DISTRIBUTION' } }]) }, + { match: /^\/v1\/appStoreVersions\/v1\/appStoreVersionLocalizations/, answer: page([ + { type: 'appStoreVersionLocalizations', id: 'loc-en', attributes: { locale: 'en-US' } }, + { type: 'appStoreVersionLocalizations', id: 'loc-de', attributes: { locale: 'de-DE' } }, + { type: 'appStoreVersionLocalizations', id: 'loc-fr', attributes: { locale: 'fr-FR' } }]) }, + { match: /^\/v1\/appStoreVersionLocalizations\/loc-en\/appScreenshotSets/, answer: page([{ type: 'appScreenshotSets', id: 'set-en', attributes: { screenshotDisplayType: 'APP_IPHONE_67' } }]) }, + { match: /^\/v1\/appStoreVersionLocalizations\/loc-fr\/appScreenshotSets/, answer: page([{ type: 'appScreenshotSets', id: 'set-fr', attributes: { screenshotDisplayType: 'APP_IPHONE_67' } }]) }, + { match: /^\/v1\/appStoreVersionLocalizations\/loc-de\/appScreenshotSets/, answer: page([]) }, + { match: /^\/v1\/appStoreVersionLocalizations\/loc-\w+\/appPreviewSets/, answer: page([]) }, + { match: /^\/v1\/appScreenshotSets\/set-en\/appScreenshots/, answer: page([ascShot('s-en-1', 'Purple1/v4/en1.png', 1320, 2868, UPLOADED_MD5[0]!), (o.apple2Id ? ascShot(o.apple2Id, 'Purple1/v4/en2.png', 1320, 2868, '3a1b2c3d4e5f60718293a4b5c6d7e8f9') : ascShot('s-en-2', 'Purple1/v4/en2.png', 1320, 2868, UPLOADED_MD5[1]!))]) }, + { match: /^\/v1\/appScreenshotSets\/set-fr\/appScreenshots/, answer: page([ascShot('s-fr-1', 'Purple1/v4/fr1.png', 1320, 2868, '2a1b2c3d4e5f60718293a4b5c6d7e8f9')]) }, + { match: /^\/v1\/builds\?filter\[app\]=6400000101/, answer: page([{ type: 'builds', id: 'b1', attributes: { version: '42', iconAssetToken: { templateUrl: 'https://is1-ssl.mzstatic.com/image/thumb/Purple1/v4/icon.png/{w}x{h}bb.{f}', width: 1024, height: 1024 } } }]) }, + // the CDNs + { match: /^\/image\/thumb\/Purple1\/v4\/en1\.png\/1320x2868bb\.png$/, answer: () => new Response(Buffer.from(A1)) }, + { match: /^\/image\/thumb\/Purple1\/v4\/en2\.png\/1320x2868bb\.png$/, answer: () => new Response(Buffer.from(o.apple2 ?? A2)) }, + { match: /^\/image\/thumb\/Purple1\/v4\/icon\.png\/1024x1024bb\.png$/, answer: () => new Response(Buffer.from(ICON)) }, + ...Object.entries({ 'p-icon': P_ICON, 'p-feat': P_FEAT, 'p-en-1': P1, 'p-en-2': P2, 'p-de-1': PDE1, 'p-de-2': PDE2 }).map(([k, b]) => ({ match: new RegExp(`^/${k}=s0$`), answer: () => new Response(Buffer.from(b)) })), + // Google Play (one read-only edit, deleted) + { method: 'POST', match: new RegExp(`^${P}/edits$`), answer: { id: 'e1' } }, + { match: new RegExp(`^${P}/edits/e1/listings$`), answer: { listings: [{ language: 'en-US' }, { language: 'de-DE' }, { language: 'tr-TR' }] } }, + { match: new RegExp(`^${P}/edits/e1/listings/[\\w-]+/\\w+$`), answer: c => ({ images: images[c.path.split('/listings/')[1]!] ?? [] }) }, + { method: 'DELETE', match: new RegExp(`^${P}/edits/e1$`), answer: {} }, + ]); +} + +const CONFIG: ShipConfig = { ...BASE, icons: { composite: 'store/icon/icon.png', out: 'store/icons' } as ShipConfig['icons'] }; + +function setup(files: Record = {}) { + ascEnv(); playEnv(); + return project(CONFIG, files); +} + +describe('where live art goes', () => { + const ctx = { renders: 'store/renders', appleIcon: 'store/icons/app-store-1024.png', playIcon: 'store/icons/play-512.png', langOf: (_s: string, l: string) => ({ 'en-US': 'en', 'de-DE': 'de' } as Record)[l] ?? null }; + const r = (o: Partial): RemoteArt => ({ store: 'apple', kind: 'screenshot', type: 'APP_IPHONE_67', locale: 'en-US', index: 0, url: '', thumb: '', checksum: null, id: 'x', ...o }); + test('App Store display types map onto the render size folders, NN from 01', () => { + expect(slotOf(r({}), ctx)!.rel).toBe('store/renders/en/ios69/01'); + expect(slotOf(r({ type: 'APP_IPHONE_65', index: 9 }), ctx)!.rel).toBe('store/renders/en/ios65/10'); + expect(slotOf(r({ type: 'APP_IPAD_PRO_3GEN_129' }), ctx)!.rel).toBe('store/renders/en/ipad13/01'); + expect(slotOf(r({ type: 'APP_IPHONE_61' }), ctx)!.rel).toBe('store/renders/en/iphone-medium/01'); + // an old display type keeps its own folder (kept, never checked or uploaded as another size) + expect(slotOf(r({ type: 'APP_IPHONE_55' }), ctx)!.rel).toBe('store/renders/en/apple-app_iphone_55/01'); + expect(slotOf(r({ kind: 'preview', type: 'IPHONE_67' }), ctx)).toMatchObject({ rel: 'store/renders/en/preview/iphone/01', ext: 'mp4' }); + expect(slotOf(r({ locale: 'fr-FR' }), ctx)).toBeNull(); + }); + test('Play types, the feature graphic and the icons', () => { + expect(slotOf(r({ store: 'play', type: 'phoneScreenshots', index: 1 }), ctx)!.rel).toBe('store/renders/en/play/02'); + expect(slotOf(r({ store: 'play', type: 'sevenInchScreenshots' }), ctx)!.rel).toBe('store/renders/en/tab7/01'); + expect(slotOf(r({ store: 'play', type: 'tenInchScreenshots' }), ctx)!.rel).toBe('store/renders/en/tab10/01'); + expect(slotOf(r({ store: 'play', type: 'tvScreenshots' }), ctx)!.rel).toBe('store/renders/en/tv/01'); + expect(slotOf(r({ store: 'play', type: 'wearScreenshots' }), ctx)!.rel).toBe('store/renders/en/wear/01'); + expect(slotOf(r({ store: 'play', kind: 'featureGraphic', type: 'featureGraphic' }), ctx)!.rel).toBe('store/renders/en/feature/1024x500'); + expect(slotOf(r({ store: 'play', kind: 'icon', type: 'icon' }), ctx)).toMatchObject({ rel: 'store/icons/play-512', ext: 'png' }); + expect(slotOf(r({ kind: 'icon', type: 'icon', locale: '' }), ctx)).toMatchObject({ rel: 'store/icons/app-store-1024', ext: 'png' }); + }); + test('URLs: the App Store template at the original size, Play =s0; thumbnails', () => { + expect(fillTemplate('https://is1-ssl.mzstatic.com/image/thumb/a/b.png/{w}x{h}bb.{f}', 1320, 2868, 'png')).toBe('https://is1-ssl.mzstatic.com/image/thumb/a/b.png/1320x2868bb.png'); + expect(fillTemplate('https://is1-ssl.mzstatic.com/image/thumb/a/{w}x{h}{c}.{f}', 10, 20, 'jpg')).toBe('https://is1-ssl.mzstatic.com/image/thumb/a/10x20bb.jpg'); + expect(playUrl('https://lh3.googleusercontent.com/abc', 'original')).toBe('https://lh3.googleusercontent.com/abc=s0'); + expect(playUrl('https://lh3.googleusercontent.com/abc=w526', 300)).toBe('https://lh3.googleusercontent.com/abc=w300'); + expect(thumbSize(1320, 2868)).toEqual([138, 300]); + expect(sniffExt(png(1, 1))).toBe('png'); + expect(sniffExt(jpeg(1, 1))).toBe('jpg'); + }); + test('store-empty sets: the required sets no file came for', () => { + expect(storeEmptySets([{ id: 'en', apple: true, play: true }, { id: 'de', apple: true, play: false }], new Set(['en/ios69', 'en/play']), { apple: ['ios69'], play: ['play', 'feature'] })) + .toEqual(['de/ios69', 'en/feature']); + }); +}); + +describe('the upsert decision', () => { + const remote: RemoteArt = { store: 'play', kind: 'screenshot', type: 'phoneScreenshots', locale: 'en-US', index: 0, url: 'u', thumb: 't', checksum: 'S1', id: 'i1' }; + const local = (sha256: string, m = 'm') => ({ rel: 'store/renders/en/play/01.png', sha256, md5: m }); + const entry = (sha256: string, storeId = 'i1', storeChecksum = 'S1') => ({ store: 'play' as const, kind: 'screenshot' as const, type: 'phoneScreenshots', locale: 'en-US', host: 'h', sha256, storeChecksum, storeId, pulledAt: '' }); + test('missing -> add; an unchanged pull -> same; the store changed -> update', () => { + expect(decide(remote, 'store/renders/en/play/01', null, null, false, 'png')).toMatchObject({ action: 'add', rel: 'store/renders/en/play/01.png' }); + expect(decide(remote, 'x', local('L'), entry('L'), false, 'png').action).toBe('same'); + expect(decide({ ...remote, id: 'i2', checksum: 'S2' }, 'x', local('L'), entry('L'), false, 'png').action).toBe('update'); + }); + test('the local file is the store\'s (Play sha256 / App Store MD5): same, recorded', () => { + expect(decide(remote, 'x', local('S1'), null, false, 'png')).toMatchObject({ action: 'same', adopt: true }); + expect(decide({ ...remote, store: 'apple', checksum: 'M1' }, 'x', local('L', 'M1'), null, false, 'png')).toMatchObject({ action: 'same', adopt: true }); + }); + test('your own file or an edit after the pull: kept (conflict) unless --force', () => { + expect(decide(remote, 'x', local('MINE'), null, false, 'png')).toMatchObject({ action: 'conflict', needsForce: true }); + expect(decide(remote, 'x', local('EDITED'), entry('L'), false, 'png')).toMatchObject({ action: 'conflict', why: expect.stringContaining('edited after the pull') }); + expect(decide(remote, 'x', local('MINE'), null, true, 'png')).toMatchObject({ action: 'update', needsForce: true }); + expect(extraStep(local('L'), entry('L'), false).action).toBe('remove'); + expect(extraStep(local('MINE'), null, false).action).toBe('conflict'); + expect(extraStep(local('MINE'), null, true).action).toBe('remove'); + }); + test('plan rows group a folder', () => { + expect(groupSteps([{ action: 'add', file: 'r/en/play/01.png', why: 'w' }, { action: 'add', file: 'r/en/play/02.png', why: 'w' }, { action: 'add', file: 'r/en/feature/1024x500.jpg', why: 'w' }])) + .toEqual([['add', 'r/en/play/ 2 files (01.png .. 02.png)', 'w'], ['add', 'r/en/feature/1024x500.jpg', 'w']]); + }); +}); + +describe('art pull against recorded store shapes', () => { + test('dry run: the plan, nothing written, the stores only read', async () => { + const p = setup(); + const calls = stores(); + const r = await artPull(p.ctx([]), { write: false, force: false, quiet: true }); + expect(r.summary).toMatchObject({ add: 9, same: 0, update: 0, conflict: 0, remove: 0 }); + expect(r.unmapped).toEqual(['App Store fr-FR']); + expect(existsSync(join(p.root, 'store/renders'))).toBe(false); + expect(calls.filter(c => c.method !== 'GET').map(c => c.method).sort()).toEqual(['DELETE', 'POST']); // the Play read edit only + expect(calls.some(c => /=s0$|bb\.png$/.test(c.path))).toBe(false); // nothing downloaded in the dry run + }); + + test('write: the store art layout, pulled.json with hosts and checksums, a re-run changes nothing', async () => { + const p = setup(); + stores(); + const r = await artPull(p.ctx(['--write']), { write: true, force: false, quiet: true }); + expect(r.summary.add).toBe(9); + expect(r.written).toBe(true); + const f = (rel: string) => readFileSync(join(p.root, rel)); + expect(sha(f('store/renders/en/ios69/01.png'))).toBe(sha(A1)); + expect(sha(f('store/renders/en/ios69/02.png'))).toBe(sha(A2)); + expect(sha(f('store/renders/en/play/02.png'))).toBe(sha(P2)); + expect(sha(f('store/renders/de/play/01.png'))).toBe(sha(PDE1)); + // a JPEG from Play keeps its format + expect(existsSync(join(p.root, 'store/renders/en/feature/1024x500.jpg'))).toBe(true); + expect(sha(f('store/icons/play-512.png'))).toBe(sha(P_ICON)); + expect(sha(f('store/icons/app-store-1024.png'))).toBe(sha(ICON)); + const m = readPulled(join(p.root, 'store/renders'))!; + expect(m.files['en/ios69/01.png']).toMatchObject({ store: 'apple', type: 'APP_IPHONE_67', locale: 'en-US', host: 'is1-ssl.mzstatic.com', sha256: sha(A1), storeChecksum: UPLOADED_MD5[0], storeId: 's-en-1' }); + expect(m.files['en/play/01.png']).toMatchObject({ store: 'play', host: 'lh3.googleusercontent.com', sha256: sha(P1), storeChecksum: sha(P1) }); + expect(m.files['../icons/app-store-1024.png']).toMatchObject({ kind: 'icon', storeChecksum: md5(ICON) }); + // no URL and no personal data in the manifest + const raw = readFileSync(join(p.root, 'store/renders/pulled.json'), 'utf8'); + expect(raw).not.toContain('https://'); + expect(raw).not.toContain('@'); + // the sets the stores had nothing for: de on the App Store, tr on Play, the de feature graphic + expect(m.storeEmpty).toEqual(expect.arrayContaining(['de/ios69', 'de/feature', 'tr/play', 'tr/feature', 'tr/ios69'])); + + const before = statSync(join(p.root, 'store/renders/pulled.json')).mtimeMs; + const ico = statSync(join(p.root, 'store/icons/app-store-1024.png')).mtimeMs; + stores(); + const again = await artPull(p.ctx(['--write']), { write: true, force: false, quiet: true }); + expect(again.summary).toMatchObject({ add: 0, update: 0, remove: 0, conflict: 0, same: 9 }); + expect(again.written).toBe(false); + expect(statSync(join(p.root, 'store/renders/pulled.json')).mtimeMs).toBe(before); + expect(statSync(join(p.root, 'store/icons/app-store-1024.png')).mtimeMs).toBe(ico); + }); + + test('a differing local file is kept and listed; --force replaces it; the store changing updates an unedited pull', async () => { + const mine = pic(1320, 2868, 'my-own-render'); + const p = setup({ 'store/renders/en/ios69/01.png': mine, 'store/renders/en/ios69/03.png': pic(1320, 2868, 'my-third') }); + stores(); + const r = await artPull(p.ctx(['--write']), { write: true, force: false, quiet: true }); + expect(r.conflicts.sort()).toEqual(['store/renders/en/ios69/01.png', 'store/renders/en/ios69/03.png']); + expect(sha(readFileSync(join(p.root, 'store/renders/en/ios69/01.png')))).toBe(sha(mine)); + expect(readPulled(join(p.root, 'store/renders'))!.files['en/ios69/01.png']).toBeUndefined(); + const out = await capture(async () => { stores(); await art(p.ctx(['pull'])); }); + expect(out.out + out.err).toContain('--force replaces them'); + + stores(); + const forced = await artPull(p.ctx(['--write', '--force']), { write: true, force: true, quiet: true }); + expect(forced.summary).toMatchObject({ update: 1, remove: 1 }); + expect(sha(readFileSync(join(p.root, 'store/renders/en/ios69/01.png')))).toBe(sha(A1)); + expect(existsSync(join(p.root, 'store/renders/en/ios69/03.png'))).toBe(false); + + // the store's 2nd screenshot changes: the unedited earlier pull is updated without --force + const NEW2 = pic(1320, 2868, 'apple-en-2-new'); + // an unchanged store asset (same id and checksum) is never downloaded again, even when the CDN bytes differ + stores({ apple2: NEW2 }); + const same = await artPull(p.ctx(['--write']), { write: true, force: false, quiet: true }); + expect(same.summary.update).toBe(0); + // a new upload on the store (new id and checksum) + stores({ apple2: NEW2, apple2Id: 's-en-2b' }); + const changed = await artPull(p.ctx(['--write']), { write: true, force: false, quiet: true }); + expect(changed.summary).toMatchObject({ update: 1, conflict: 0 }); + expect(sha(readFileSync(join(p.root, 'store/renders/en/ios69/02.png')))).toBe(sha(NEW2)); + expect(readPulled(join(p.root, 'store/renders'))!.files['en/ios69/02.png']).toMatchObject({ storeId: 's-en-2b', storeChecksum: '3a1b2c3d4e5f60718293a4b5c6d7e8f9' }); + }); + + test('the App Store icon is compared as a picture (Apple re-encodes the build\'s icon)', async () => { + const { encodePng } = await import('./build-fixtures'); + const { samePicture } = await import('../cli/art'); + const draw = (x: number, y: number): [number, number, number] => [x * 8 % 256, y * 8 % 256, (x + y) * 4 % 256]; + const a = encodePng(64, 64, draw, { colorType: 2 }), b = encodePng(64, 64, draw, { colorType: 2, filter: 1 }); + expect(sha(a)).not.toBe(sha(b)); + expect(samePicture(a, b)).toBe(true); + expect(samePicture(a, encodePng(64, 64, (x, y) => [255 - x * 4, y * 2, 30], { colorType: 2 }))).toBe(false); + expect(samePicture(a, jpeg(64, 64))).toBe(false); + }); + + test('rendered Play art with JPEG copies: the .jpg (what play images uploads) is compared, not the .png', async () => { + // seen live: a project with 01.png + 01.jpg per Play slot was reported as "differs" everywhere + const p = setup({ 'store/renders/en/play/01.png': pic(1080, 1920, 'rendered-png'), 'store/renders/en/play/01.jpg': P1, 'store/renders/en/play/02.jpg': P2 }); + stores(); + const r = await artPull(p.ctx([]), { write: false, force: false, quiet: true }); + expect(r.conflicts).toEqual([]); + expect(r.steps.filter(s => s.file.includes('/en/play/'))).toEqual([]); + }); + + test('uploads compare a pulled file with the store\'s own checksum; an edited file counts as yours', async () => { + const p = setup(); + stores(); + await artPull(p.ctx(['--write']), { write: true, force: false, quiet: true }); + const shot = join(p.root, 'store/renders/en/ios69/01.png'); + expect(pulledChecksum(shot, 'apple')).toBe(UPLOADED_MD5[0]!); + expect(pulledChecksum(join(p.root, 'store/renders/en/play/01.png'), 'play')).toBe(sha(P1)); + expect(pulledChecksum(shot, 'play')).toBeNull(); + writeFileSync(shot, pic(1320, 2868, 'edited')); + expect(pulledChecksum(shot, 'apple')).toBeNull(); + }); + + test('the local checks: pulled files pass as the store\'s, sets the store had none for are not demanded', async () => { + const p = setup(); + stores(); + await artPull(p.ctx(['--write']), { write: true, force: false, quiet: true }); + const rc = rendersCheck(CONFIG, join(p.root, 'store/renders'))!; + expect(rc.apple.issues.filter(i => i.level === 'error')).toEqual([]); + expect(rc.play.issues.filter(i => i.level === 'error')).toEqual([]); + expect(rc.play.images).toBeGreaterThan(0); + }); + + test('check: render sources (icon artwork, logo, captions) are notes once the art is pulled', async () => { + const files = { 'store/listing.md': '', 'store/appstore.md': '' }; + const p = setup(files); + const before = await capture(() => check(p.ctx(['--json']))); + expect(String((jsonOut(before.out).problems as string[]))).toContain('missing file: store/icon/icon.png'); + stores(); + await artPull(p.ctx(['--write']), { write: true, force: false, quiet: true }); + const after = await capture(() => check(p.ctx(['--json']))); + const j = jsonOut(after.out); + expect(String(j.problems)).not.toContain('store/icon/icon.png'); + expect(String(j.warnings)).toContain('render source not there: store/icon/icon.png'); + }); +}); + +describe('MCP / desktop', () => { + test('art_pull is a dry run unless dry_run is false; --write only then, never --force', () => { + const t = TOOLS.find(x => x.name === 'art_pull')!; + expect(toArgv(t, {}).argv).toEqual(['art', 'pull', '--dry']); + expect(toArgv(t, { dry_run: false, store: 'play' }).argv).toEqual(['art', 'pull', '--store', 'play', '--write']); + expect(LOCAL_WRITE_TOOLS.art_pull).toEqual(['--write']); + expect(Object.keys(t.props ?? {})).toEqual(['store']); + expect(toArgv(t, { dry_run: false }).argv).not.toContain('--force'); + }); +}); diff --git a/tests/console-parity.test.ts b/tests/console-parity.test.ts index 098bcc4..8e83b44 100644 --- a/tests/console-parity.test.ts +++ b/tests/console-parity.test.ts @@ -26,7 +26,7 @@ describe('console parity checklist', () => { }); test('the desktop methods the table names exist', () => { const methods = new Set(describeMethods().map(m => m.method)); - const named = [...DOC.matchAll(/`((?:apple|play|owner|project|listing|shots|reviews|prices|console)\.[a-z_]+)`/g)].map(m => m[1]!); + const named = [...DOC.matchAll(/`((?:apple|play|owner|project|listing|shots|reviews|prices|console|art)\.[a-z_]+)`/g)].map(m => m[1]!); expect(named.length).toBeGreaterThan(20); for (const m of new Set(named)) expect(methods.has(m), m).toBe(true); }); diff --git a/tests/onboard.test.ts b/tests/onboard.test.ts index a276a30..3ca6e16 100644 --- a/tests/onboard.test.ts +++ b/tests/onboard.test.ts @@ -169,7 +169,7 @@ describe('storebound onboard (recorded live shapes)', () => { ascEnv(); playEnv(); const p = liveProject(); const calls = liveStores(); - const r = await capture(() => onboard(p.ctx(['--write'], ['write']))); + const r = await capture(() => onboard(p.ctx(['--write', '--no-art'], ['write']))); const all = r.out + r.err; // personal data and the demo password never reach the output expect(all).not.toContain('demo-secret-sentinel'); @@ -215,7 +215,7 @@ describe('storebound onboard (recorded live shapes)', () => { const p = liveProject(); liveStores({ review: { demoAccountRequired: null, demoAccountName: null, demoAccountPassword: null } }); const before = readFileSync(p.loaded.path, 'utf8'); - const r = await capture(() => onboard(p.ctx([]))); + const r = await capture(() => onboard(p.ctx(['--no-art']))); expect(readFileSync(p.loaded.path, 'utf8')).toBe(before); expect(r.out).toContain('owner questions'); expect(r.out).toContain('Can a reviewer use every main feature without signing in?'); diff --git a/tools/desktop-boot-check.ts b/tools/desktop-boot-check.ts index d65f720..bb3a763 100644 --- a/tools/desktop-boot-check.ts +++ b/tools/desktop-boot-check.ts @@ -18,7 +18,7 @@ // 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 { cpSync, existsSync, mkdtempSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { mint } from '../cli/lib/confirm-token'; import { extname, join, resolve } from 'node:path'; @@ -968,6 +968,131 @@ async function runEditorCase(browser: Browser, sidecar: Sidecar): Promise { + const name = 'store art (live thumbnails, pull dry run, replace with a file)'; + 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(); + const logged: string[] = []; + const dir = mkdtempSync(join(tmpdir(), 'sb-boot-art-')); + cpSync(join(EXAMPLES, 'demo-app'), dir, { recursive: true }); + // a pulled Play screenshot in the renders folder (a real PNG, so the gallery and the replace dry run read it) + const { encodePng } = await import('../tests/build-fixtures'); + const shot = encodePng(540, 960, () => [40, 120, 200], { colorType: 2 }); + mkdirSync(join(dir, 'store/renders/en/play'), { recursive: true }); + writeFileSync(join(dir, 'store/renders/en/play/01.png'), shot); + writeFileSync(join(dir, 'store/renders/en/play/02.png'), shot); + const picked = join(dir, 'picked-new.png'); + writeFileSync(picked, encodePng(540, 960, () => [200, 80, 40], { colorType: 2 })); + const fx = loadFixture('empty'); + fx.projects_list = [{ id: 'art-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: { method: string; args: unknown; dry?: string }[] = []; + const thumbs: string[] = []; + const answer = async (cmd: string, a: Record): Promise => { + if (cmd === 'sidecar_call' && a.method === 'art.live') return toolResult('art.live', LIVE_ART); + if (cmd === 'sidecar_call' && a.method === 'art.thumb') { thumbs.push(String((a.params as { args?: { url?: string } } | null)?.args?.url)); return toolResult('art.thumb', { dataUrl: THUMB, bytes: 68 }); } + if (cmd === 'image_pick') return picked; + if (cmd === 'write_with_confirm') { + const method = String(a.method); + if (method === 'art.replace') { + // the Rust host: the real dry run first (the native dialog shows it), then the owner presses Cancel + const dryRun = await sidecar.call('art.replace', { project: dir, args: a.args as Record, dryRun: true }) as { ok: boolean; output: string; data?: unknown }; + writes.push({ method, args: a.args, dry: JSON.stringify(dryRun.data ?? dryRun.output) }); + return { confirmed: false, stage: dryRun.ok ? 'dialog' : 'dry-run', dryRun }; + } + writes.push({ method, args: a.args, dry: method === 'art.pull' ? ART_PULL_DRY : undefined }); + return { confirmed: false, stage: 'dialog', dryRun: { ...toolResult(method, null), dryRun: true, output: method === 'art.pull' ? ART_PULL_DRY : '' } }; + } + return base(cmd, a); + }; + await page.exposeFunction('__bootCheckHost', async (cmd: string, args: Record) => { + 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, ' '); + 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-art').click({ timeout: WAIT_MS }).catch(e => problems.push(`no Store art tab: ${String(e).split('\n')[0]}`)); + await page.waitForSelector('#live-art', { timeout: WAIT_MS }).catch(() => problems.push('no live store art card')); + // nothing is read from the stores until asked (auto-read is off in the saved state) + await page.locator('#live-art button', { hasText: 'Read the stores' }).click({ timeout: WAIT_MS }).catch(e => problems.push(`no "Read the stores" button: ${String(e).split('\n')[0]}`)); + const drawn = await page.waitForFunction(() => document.querySelectorAll('#live-art [data-live-tile] img[src^="data:image/"]').length >= 2, null, { timeout: WAIT_MS, polling: 50 }).then(() => true, () => false); + if (!drawn) problems.push(`live thumbnails not drawn (${(await text('#live-art')).slice(0, 300)})`); + const live = await text('#live-art'); + for (const want of ['Live on the stores', 'App Store iPhone 6.9"', 'not pulled yet', 'App Store: 3 images']) if (!live.includes(want)) problems.push(`live art: no "${want}" (${live.slice(0, 300)})`); + await page.locator('#live-art button[role="tab"]', { hasText: 'Google Play' }).click({ timeout: WAIT_MS }).catch(e => problems.push(`no Google Play store switch: ${String(e).split('\n')[0]}`)); + if (!(await shows(page, '#live-art', 'yours differs')) || !(await shows(page, '#live-art', 'same as the store'))) problems.push('live art: no same / differs state on the Play set'); + if (thumbs.some(u => !/^https:\/\/(is1-ssl\.mzstatic\.com|lh3\.googleusercontent\.com)\//.test(u))) problems.push(`a thumbnail from another host: ${thumbs.join(', ')}`); + else notes.push(`live: ${thumbs.length} thumbnails as data: URLs, same / differs / not pulled per image`); + // Pull from stores: the confirm dialog with the dry run (answered Cancel) + await page.locator('#live-art button', { hasText: 'Pull from stores' }).click({ timeout: WAIT_MS }).catch(e => problems.push(`no "Pull from stores" button: ${String(e).split('\n')[0]}`)); + await settle(page); + const pull = writes.find(x => x.method === 'art.pull'); + if (!pull) problems.push('Pull from stores did not go through the confirm dialog (write_with_confirm art.pull)'); + else notes.push(`pull: dialog with the dry run (${pull.dry!.split('\n').pop()})`); + // Replace with a file: the pulled render's lightbox, the picker, the real art.replace dry run + const tile = page.locator('.art-rows .art-tile button.art-pic').first(); + await tile.click({ timeout: WAIT_MS }).catch(async e => problems.push(`no rendered tile to open: ${String(e).split('\n')[0]} (${(await text('#tabpanel')).slice(0, 300)})`)); + await page.locator('[data-action="replace-image"]').click({ timeout: WAIT_MS }).catch(e => problems.push(`no "Replace with a file" in the lightbox: ${String(e).split('\n')[0]}`)); + await settle(page); + const rep = writes.find(x => x.method === 'art.replace'); + if (!rep) problems.push('Replace with a file did not reach the confirm dialog (write_with_confirm art.replace)'); + else { + const args = rep.args as { from?: string; to?: string }; + if (args.to !== 'store/renders/en/play/01.png' || args.from !== picked) problems.push(`art.replace args: ${JSON.stringify(args)}`); + if (!/"new":\{"w":540,"h":960/.test(rep.dry ?? '')) problems.push(`art.replace dry run: ${rep.dry?.slice(0, 300)}`); + else notes.push('replace: picked file -> store/renders/en/play/01.png, the sidecar dry run 540x960 -> 540x960 (dialog: Cancel)'); + } + if (readFileSync(join(dir, 'store/renders/en/play/01.png')).compare(shot) !== 0) problems.push('the render changed although the dialog was cancelled'); + 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}`); + if (unmocked.size) notes.push(`not answered (the window handled the rejection): ${[...unmocked].join(', ')}`); + return { name, ok: problems.length === 0, problems: [...new Set(problems)], visited: 1, notes }; +} + async function launch(): Promise<{ browser: Browser; kill: () => Promise }> { const { chromium } = await import('playwright'); const opts = { headless: true, timeout: 60_000 }; @@ -1000,6 +1125,7 @@ export async function bootCheck(): Promise { 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)); + if (!ONLY.length || ONLY.includes('art')) cases.push(() => runLiveArtCase(browser, sidecar)); const results: CaseResult[] = new Array(cases.length); let next = 0; try {