store art from the live stores: storebound art pull (App Store screenshot sets and previews per locale and display type from imageAsset.templateUrl at the original size, the App Store icon from the newest build or the public artwork; per Play language icon, feature graphic, phone / 7" / 10" / TV / Wear screenshots as the =s0 originals checked against Play's sha256) into store/renders with pulled.json (sha256, store, URL host, store checksum); upsert (a re-run changes nothing, differing local files kept unless --force, the upload's file of a slot compared: Play .jpg, App Store .png, the App Store icon as a picture); onboard runs it; apple screenshots / previews and play images see unchanged pulled files as the store's own; check: render sources are notes with pulled art; local renders check: pulled files pass, store-empty sets not demanded; art live / thumb / replace; desktop Store art tab: live thumbnails per store, language and set (same / differs / not pulled), Pull from stores, store-only apps read-only, Replace with a file (image_pick, art.replace) in the lightbox and the art editor; boot check store art case; tests/art-pull.test.ts; console parity 76 rows (57 complete, 19 with the reason); store-screenshots skill (pull first for live apps), pitfall 59
This commit is contained in:
parent
49a73c90e4
commit
00be838b76
40 changed files with 1669 additions and 58 deletions
20
CHANGELOG.md
20
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/<lang>/<size>/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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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<Option<String>> {
|
||||
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: <renders>/index.html in the default browser).
|
||||
#[tauri::command]
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ pub const COMMANDS: &[&str] = &[
|
|||
"project_import_fastlane",
|
||||
"export_save",
|
||||
"price_sheet_pick",
|
||||
"image_pick",
|
||||
"project_reveal",
|
||||
"project_locate",
|
||||
"state_reset",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -131,8 +131,8 @@ pub fn store_only_write(method: &str, kind: Option<Kind>) -> 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"];
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string | null>('export_save', { name, kind, content, personal }),
|
||||
/** Native open dialog for a price sheet (CSV); its path, or null. */
|
||||
pickPriceSheet: (projectId: string) => invoke<string | null>('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<string | null>('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<void>('project_reveal', { projectId, rel, open }),
|
||||
/** "Locate…" for a folder that is gone: native folder picker; the same app with its new path, or null. */
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1100,9 +1100,37 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'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',
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export const TTL: Record<string, number> = {
|
|||
'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,
|
||||
|
|
|
|||
|
|
@ -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<AppTab, Key> = {
|
||||
releases: 'tab.releases', catalog: 'tab.catalog', prices: 'tab.prices', listings: 'tab.listings', art: 'tab.art', health: 'tab.health', reviews: 'tab.reviews',
|
||||
|
|
|
|||
|
|
@ -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 && <><TabEditor tab={tab} project={app.project} /><ListingsTab project={app.project} /></>}
|
||||
{tab === 'listings' && !app.project && <StoreListingEditor app={app} />}
|
||||
{tab === 'art' && app.project && <ArtTab project={app.project} />}
|
||||
{tab === 'art' && !app.project && <LiveArt target={app.target} />}
|
||||
{tab === 'health' && <HealthTab app={app} />}
|
||||
{tab === 'reviews' && <ReviewsTab app={app} />}
|
||||
{tab === 'testflight' && <TestFlightTab app={app} />}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="stack" id="art-editor" style={{ gap: 16 }}>
|
||||
<Card title={t('ed.artTitle')} icon="image" sub={t('ed.artSub')} pad={false}>
|
||||
<Card title={t('ed.artTitle')} icon="image" sub={t('ed.artSub')} pad={false}
|
||||
actions={langs.length > 1 ? <label className="row small" style={{ gap: 6 }}>{t('ed.frameLang')}<select value={fl} onChange={ev => setFrameLang(ev.target.value)} data-frame-lang>{langs.map(l => <option key={l} value={l}>{l}</option>)}</select></label> : undefined}>
|
||||
<ol className="ed-scenes">
|
||||
{scenes.map((s, i) => (
|
||||
<li key={s.id} data-scene={s.id}>
|
||||
|
|
@ -38,6 +54,7 @@ export function ArtEditor({ project, data, onSaved }: { project: Project; data:
|
|||
<TextField project={project} area="art" op={{ kind: 'config', path: `shots.scenes[id=${s.id}].frames[0].src` }} saved={saved.find(x => x.id === s.id)?.frames?.[0]?.src ?? ''} label={t('ed.frame')} mono />
|
||||
</div>
|
||||
<div className="row" style={{ gap: 4 }}>
|
||||
{s.frames?.[0]?.src && <button className="btn btn-xs" disabled={!!w.busy} onClick={() => { replaceFrame(s.frames![0]!.src); }} title={`${framesDir}/${fl}/phone/${s.frames[0].src}`} data-action="replace-frame"><Icon name="upload" />{t('art.replace')}</button>}
|
||||
<button className="btn btn-xs btn-ghost" disabled={i === 0} onClick={() => move(i, -1)} aria-label={t('ed.moveUp', { id: s.id })}><Icon name="arrowLeft" className="rot90" />{t('ed.up')}</button>
|
||||
<button className="btn btn-xs btn-ghost" disabled={i === scenes.length - 1} onClick={() => move(i, 1)} aria-label={t('ed.moveDown', { id: s.id })}><Icon name="arrowRight" className="rot90" />{t('ed.down')}</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<string, string | null>();
|
||||
|
|
@ -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<string | null>(null);
|
||||
|
|
@ -87,6 +88,7 @@ function Lightbox({ projectId, tiles, index, stamp, onClose, onMove }: { project
|
|||
<div className="lb-foot">
|
||||
<div className="grow"><b className="mono-id">{tile.src}</b><div className="small muted">{tile.w}×{tile.h} · {bytes(tile.bytes)} · {num(index + 1)} / {num(tiles.length)}</div>
|
||||
{tile.issues.map((i, k) => <div key={k} className={`small ${i.level === 'error' ? 'bad-ink' : 'warn-ink'}`}>{i.msg}</div>)}</div>
|
||||
{onReplace && tile.kind === 'image' && <button className="btn btn-sm" onClick={() => onReplace(tile)} data-action="replace-image"><Icon name="upload" />{t('art.replace')}</button>}
|
||||
<button className="btn btn-sm" onClick={() => onMove((index - 1 + tiles.length) % tiles.length)} aria-label={t('art.prev')}><Icon name="arrowLeft" /></button>
|
||||
<button className="btn btn-sm" onClick={() => onMove((index + 1) % tiles.length)} aria-label={t('art.next')}><Icon name="arrowRight" /></button>
|
||||
<button className="btn btn-sm btn-ghost" onClick={onClose}><Icon name="x" />{t('common.close')}</button>
|
||||
|
|
@ -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 ? <ErrorNotice error={r.error} tone="bad" /> : <Card><Skeleton lines={6} block /></Card>;
|
||||
// the live art stays the first child in both branches, so it keeps its state when the local renders arrive
|
||||
if (!data) return <><LiveArt target={project.id} project={project} />{r.error ? <ErrorNotice error={r.error} tone="bad" /> : <Card><Skeleton lines={6} block /></Card>}</>;
|
||||
const total = data.totals.images + data.totals.videos;
|
||||
const toolbar = (
|
||||
<>
|
||||
|
|
@ -126,6 +137,7 @@ export function ArtTab({ project }: { project: Project }) {
|
|||
);
|
||||
return (
|
||||
<>
|
||||
<LiveArt target={project.id} project={project} />
|
||||
<div className="kpis" style={{ gridTemplateColumns: 'repeat(4, minmax(0, 1fr))' }}>
|
||||
<Kpi icon="image" label={t('art.images')} value={num(data.totals.images)} sub={t('art.imagesSub', { count: langs.length })} />
|
||||
<Kpi icon="play" label={t('art.videos')} value={num(data.totals.videos)} sub={t('art.videosSub')} />
|
||||
|
|
@ -162,7 +174,7 @@ export function ArtTab({ project }: { project: Project }) {
|
|||
)}
|
||||
<div className="card-foot"><Icon name="info" />{t('art.foot', { when: ago(r.at) })}</div>
|
||||
</Card>
|
||||
{open && <Lightbox projectId={project.id} tiles={open.tiles} index={open.i} stamp={stamp} onClose={() => setOpen(null)} onMove={i => setOpen(o => (o ? { ...o, i } : o))} />}
|
||||
{open && <Lightbox projectId={project.id} tiles={open.tiles} index={open.i} stamp={stamp} onClose={() => setOpen(null)} onMove={i => setOpen(o => (o ? { ...o, i } : o))} onReplace={tile => { setOpen(null); replace(tile); }} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
133
apps/desktop/src/screens/tabs/LiveArt.tsx
Normal file
133
apps/desktop/src/screens/tabs/LiveArt.tsx
Normal file
|
|
@ -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<string, string | null>();
|
||||
const waiting: (() => Promise<void>)[] = [];
|
||||
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<string | null> {
|
||||
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<ToolResult & { data?: ArtThumb }>('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<HTMLDivElement>(null);
|
||||
const [src, setSrc] = useState<string | null | undefined>(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 (
|
||||
<figure className={`art-tile${state === 'differs' ? ' warn' : ''}`} style={{ ['--ar' as string]: `${w} / ${h}` }} data-live-tile={tile.state}>
|
||||
<div className="art-pic" ref={ref} role="img" aria-label={name}>
|
||||
{src ? <img src={src} alt="" draggable={false} /> : <span className="art-ph">{src === null && tile.thumb ? <><Icon name="image" />{t('art.liveNoThumb')}</> : <span className="sk sk-block" />}</span>}
|
||||
</div>
|
||||
<figcaption><span className="mono-id">{name}</span><span className="muted">{tile.w && tile.h ? `${tile.w}×${tile.h}` : ''}</span></figcaption>
|
||||
{state && <span className={`small ${state === 'same' ? 'ok-ink' : state === 'differs' ? 'warn-ink' : 'muted'}`}>{t(state === 'same' ? 'art.liveSame' : state === 'differs' ? 'art.liveDiffers' : 'art.liveMissing')}</span>}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<LiveArtResult>(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 ? (
|
||||
<WriteButton run={w} primary icon="download" w={{ method: 'art.pull', args: {}, title: t('art.pull'), done: t('art.pulled'), after: () => { r.refresh(); } }} hint={t('art.pullHint')}>{t('art.pull')}</WriteButton>
|
||||
) : null;
|
||||
const actions = <>{pull}<ReadControl r={r} /></>;
|
||||
if (!data) {
|
||||
return (
|
||||
<Card title={t('art.liveTitle')} icon="cloud" actions={actions} id="live-art" pad={false}>
|
||||
{r.loading && !r.error ? <div className="card-body"><Skeleton lines={4} block /></div>
|
||||
: <Empty compact illo="text" title={t('art.liveEmpty')} actions={<button className="btn btn-primary btn-sm" onClick={() => { r.refresh(); }}><Icon name="refresh" />{t('art.liveRead')}</button>}>{r.error ?? t('art.liveEmptyText')}</Empty>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<Card title={t('art.liveTitle')} icon="cloud" sub={sub} actions={actions} id="live-art" pad={false}>
|
||||
{data.stores.filter(s => !s.ok).map(s => <div className="card-body" key={s.store}><ErrorNotice error={`${s.store === 'apple' ? 'App Store' : 'Google Play'}: ${s.error ?? ''}`} /></div>)}
|
||||
<div className="card-body stack" style={{ gap: 10 }}>
|
||||
{!project && <Notice tone="info" icon="info">{t('art.liveReadOnly')}</Notice>}
|
||||
{project && total > 0 && (missing > 0 || differs > 0) && <Notice tone={differs ? 'warn' : 'info'} icon="info">{t('art.liveSummary', { missing: num(missing), differs: num(differs) })}</Notice>}
|
||||
{project && total > 0 && !missing && !differs && <Notice tone="ok" icon="check">{t('art.liveAllSame')}</Notice>}
|
||||
{data.icons.length > 0 && (
|
||||
<div className="row" style={{ gap: 16, flexWrap: 'wrap', alignItems: 'flex-start' }} data-live-icons>
|
||||
{data.icons.map(i => <div key={i.store} style={{ width: 96 }}><LiveThumb target={target} tile={{ ...i, w: 1, h: 1 }} name={i.store === 'apple' ? t('art.iconApple') : t('art.iconPlay')} /></div>)}
|
||||
</div>
|
||||
)}
|
||||
{stores.length > 1 && (
|
||||
<div className="row" style={{ gap: 8 }} role="tablist" aria-label={t('art.liveStore')}>
|
||||
{stores.map(s => <button key={s} role="tab" aria-selected={s === st} className={`btn btn-sm${s === st ? ' btn-primary' : ''}`} onClick={() => setStore(s)}>{s === 'apple' ? 'App Store' : 'Google Play'}</button>)}
|
||||
</div>
|
||||
)}
|
||||
<div className="fchips" role="group" aria-label={t('col.language')}>
|
||||
{locales.map(l => <button key={l} className="fchip" aria-pressed={l === loc} onClick={() => setLocale(l)} data-live-locale={l}>{l}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
{!total ? <Empty compact illo="text" title={t('art.liveNone')}>{t('art.liveNoneText')}</Empty> : (
|
||||
<div className="art-rows">
|
||||
{sets.map(s => (
|
||||
<section className="art-row" key={`${s.store}-${s.type}`} data-live-set={`${s.store}:${s.locale}:${s.type}`}>
|
||||
<h3><span>{s.label}</span>{s.lang && <code>{s.lang}/{s.size}</code>}<Chip tone="outline" dot={false}>{s.store === 'apple' ? 'App Store' : 'Google Play'}</Chip><span className="muted small">{t('art.count', { count: s.tiles.length })}</span></h3>
|
||||
<div className="art-tiles" style={{ ['--tw' as string]: s.size === 'feature' ? '300px' : s.tiles[0] && (s.tiles[0].w ?? 0) > (s.tiles[0].h ?? 1) ? '260px' : '150px' }}>
|
||||
{s.tiles.map(tile => <LiveThumb key={`${tile.index}-${tile.thumb}`} target={target} tile={tile} name={String(tile.index + 1).padStart(2, '0')} />)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="card-foot"><Icon name="info" />{project ? t('art.liveFoot') : t('art.liveFootStoreOnly')}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -34,11 +34,11 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'appInfos_appInfoLocalizations_getToManyRelated': 'apple/listing listing-store onboard validate',
|
||||
'appInfos_getInstance': 'apple/appinfo',
|
||||
'appInfos_updateInstance': 'apple/appinfo',
|
||||
'appPreviewSets_appPreviews_getToManyRelated': 'apple/previews',
|
||||
'appPreviewSets_appPreviews_getToManyRelated': 'apple/previews art',
|
||||
'appPreviewSets_createInstance': 'apple/previews',
|
||||
'appPreviews_updateInstance': 'apple/previews',
|
||||
'appPriceSchedules_createInstance': 'apple/appinfo',
|
||||
'appScreenshotSets_appScreenshots_getToManyRelated': 'apple/ppo apple/screenshots validate',
|
||||
'appScreenshotSets_appScreenshots_getToManyRelated': 'apple/ppo apple/screenshots art validate',
|
||||
'appScreenshotSets_createInstance': 'apple/screenshots',
|
||||
'appStoreReviewDetails_createInstance': 'apple/review-details',
|
||||
'appStoreReviewDetails_updateInstance': 'apple/review-details',
|
||||
|
|
@ -51,15 +51,15 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'appStoreVersionExperimentsV2_createInstance': 'lib/ppo',
|
||||
'appStoreVersionExperimentsV2_getInstance': 'apple/ppo',
|
||||
'appStoreVersionExperimentsV2_updateInstance': 'lib/ppo',
|
||||
'appStoreVersionLocalizations_appPreviewSets_getToManyRelated': 'apple/previews',
|
||||
'appStoreVersionLocalizations_appScreenshotSets_getToManyRelated': 'apple/screenshots validate',
|
||||
'appStoreVersionLocalizations_appPreviewSets_getToManyRelated': 'apple/previews art',
|
||||
'appStoreVersionLocalizations_appScreenshotSets_getToManyRelated': 'apple/screenshots art validate',
|
||||
'appStoreVersionLocalizations_createInstance': 'listing-store',
|
||||
'appStoreVersionLocalizations_updateInstance': 'listing-store',
|
||||
'appStoreVersionPhasedReleases_createInstance': 'apple/release',
|
||||
'appStoreVersionPhasedReleases_updateInstance': 'apple/release',
|
||||
'appStoreVersionReleaseRequests_createInstance': 'apple/release',
|
||||
'appStoreVersions_appStoreReviewDetail_getToOneRelated': 'apple/review-details onboard validate',
|
||||
'appStoreVersions_appStoreVersionLocalizations_getToManyRelated': 'apple/listing apple/previews apple/screenshots listing-store onboard validate',
|
||||
'appStoreVersions_appStoreVersionLocalizations_getToManyRelated': 'apple/listing apple/previews apple/screenshots art listing-store onboard validate',
|
||||
'appStoreVersions_appStoreVersionPhasedRelease_getToOneRelated': 'apple/release apple/status',
|
||||
'appStoreVersions_build_getToOneRelated': 'apple/listing apple/resubmit validate',
|
||||
'appStoreVersions_build_updateToOneRelationship': 'apple/listing apple/resubmit',
|
||||
|
|
@ -74,7 +74,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'apps_appPricePoints_getToManyRelated': 'apple/appinfo',
|
||||
'apps_appPriceSchedule_getToOneRelated': 'apple/appinfo validate',
|
||||
'apps_appStoreVersionExperimentsV2_getToManyRelated': 'apple/ppo',
|
||||
'apps_appStoreVersions_getToManyRelated': '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': '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_betaAppLocalizations_getToManyRelated': 'apple/testflight',
|
||||
'apps_betaAppReviewDetail_getToOneRelated': 'apple/testflight',
|
||||
'apps_betaFeedbackCrashSubmissions_getToManyRelated': 'health',
|
||||
|
|
@ -117,7 +117,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'builds_betaAppReviewSubmission_getToOneRelated': 'apple/testflight',
|
||||
'builds_betaBuildLocalizations_getToManyRelated': 'apple/testflight',
|
||||
'builds_diagnosticSignatures_getToManyRelated': 'health',
|
||||
'builds_getCollection': 'apple/build apple/encryption apple/listing apple/next-build apple/resubmit apple/status apple/testflight apple/upload health lib/store-apps triage',
|
||||
'builds_getCollection': 'apple/build apple/encryption apple/listing apple/next-build apple/resubmit apple/status apple/testflight apple/upload art health lib/store-apps triage',
|
||||
'builds_updateInstance': 'apple/encryption lib/build-ops',
|
||||
'bundleIdCapabilities_createInstance': 'lib/signing',
|
||||
'bundleIds_createInstance': 'apple/bundle',
|
||||
|
|
@ -218,10 +218,10 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'androidpublisher.edits.details.get': 'apps onboard play/listing play/status validate',
|
||||
'androidpublisher.edits.details.update': 'play/listing',
|
||||
'androidpublisher.edits.images.deleteall': 'play/images',
|
||||
'androidpublisher.edits.images.list': 'apps play/images validate',
|
||||
'androidpublisher.edits.images.list': 'apps art play/images validate',
|
||||
'androidpublisher.edits.images.upload': 'play/images',
|
||||
'androidpublisher.edits.insert': 'apps onboard play/client validate',
|
||||
'androidpublisher.edits.listings.list': 'listing-store play/listing play/status validate',
|
||||
'androidpublisher.edits.listings.list': 'art listing-store play/listing play/status validate',
|
||||
'androidpublisher.edits.listings.patch': 'listing-store',
|
||||
'androidpublisher.edits.listings.update': 'listing-store play/listing',
|
||||
'androidpublisher.edits.tracks.get': 'play/release play/rollout play/upload',
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { ffprobe } from '../shots/report';
|
|||
import { checkVideo, PREVIEW_RULES, VIDEO_TARGETS } from '../shots/spec';
|
||||
import { timeCode } from '../shots/video';
|
||||
import { findOrCreate } from '../lib/upsert';
|
||||
import { pulledChecksum } from '../lib/art-pull';
|
||||
import { ascClient, keptPrefix, md5, setUpToDate, type J } from './asc';
|
||||
|
||||
/** Video target folder -> ASC previewType, for this project (iPad only with apple.ipad). */
|
||||
|
|
@ -89,7 +90,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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) {
|
||||
|
|
|
|||
519
cli/art.ts
Normal file
519
cli/art.ts
Normal file
|
|
@ -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/<lang>/<size>/NN.png, feature/1024x500.*, the
|
||||
// icons at app-store-1024.png / play.icon, with <renders>/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 <https://...mzstatic.com|googleusercontent.com/...> --json one thumbnail as a data:
|
||||
// URL (at most 2 MB; the desktop window loads no remote images itself)
|
||||
// storebound art replace --from <png|jpg file> --to <path in the renders or frames folder> [--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<T>(items: T[], n: number, fn: (x: T) => Promise<void>): Promise<void> {
|
||||
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<Uint8Array> {
|
||||
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<StoreRead> {
|
||||
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<string | null> {
|
||||
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<StoreRead> {
|
||||
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<string, { id: string; rank: number }>(), play = new Map<string, { id: string; rank: number }>();
|
||||
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<string>; 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<string>();
|
||||
// one live image per slot: the preferred store locale of the language
|
||||
const best = new Map<string, { r: RemoteArt; rank: number; slot: ReturnType<typeof slotOf> & 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 <lang>/<size>, the best-ranked locale's items
|
||||
const setLocale = new Map<string, { locale: string; rank: number }>();
|
||||
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<string>();
|
||||
const planned = new Set<string>();
|
||||
const counts = new Map<string, number>();
|
||||
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<typeof summarize>;
|
||||
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<void> {
|
||||
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<PullReport> {
|
||||
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<RemoteArt, Uint8Array>();
|
||||
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<string, { action: string; dir: string; why: string; names: string[] }>();
|
||||
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<string, string> = {
|
||||
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<LiveResult> {
|
||||
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<string, LiveSet>();
|
||||
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<void> {
|
||||
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');
|
||||
}
|
||||
20
cli/check.ts
20
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<void> {
|
|||
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<void> {
|
|||
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)');
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, 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 <file> --to <path>: 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'],
|
||||
|
|
|
|||
|
|
@ -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 [<folder>] | apps remove <folder> add / remove a folder in that list (the folder itself stays)",
|
||||
"storebound apps add --store apple|play --app-id <id> [--account <profile>] [--name <n>] [--bundle-id <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 <https://...mzstatic.com|googleusercontent.com/...> --json one thumbnail as a data:",
|
||||
"storebound art replace --from <png|jpg file> --to <path in the renders or frames folder> [--dry] [--json]"
|
||||
],
|
||||
"aso": [
|
||||
"storebound aso rank [--storefronts us,de=de-DE] [--keywords \"a,b\"] [--only-keywords] [--competitors 3] [--limit 200]",
|
||||
"storebound aso competitors [--term <kw>]... [--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 <code> --fields '<json>' [--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 <folder>[,<folder>]] [--threshold 0.7] [--json]"
|
||||
|
|
|
|||
256
cli/lib/art-pull.ts
Normal file
256
cli/lib/art-pull.ts
Normal file
|
|
@ -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 (<renders>/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):
|
||||
// <renders>/<lang id>/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-<type>/ (kept, not checked or uploaded)
|
||||
// <renders>/<lang id>/preview/iphone|ipad/NN.mp4 App Store app previews (IPHONE_67, IPAD_PRO_3GEN_129)
|
||||
// <renders>/<lang id>/play|tab7|tab10|tv|wear/NN.png|jpg Play screenshots per type
|
||||
// <renders>/<lang id>/feature/1024x500.png|jpg Play feature graphic
|
||||
// <icons.out>/app-store-1024.png, play.icon (or <icons.out>/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 <lang>/<size> 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<string, string> = { 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<string, string> = { 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<string, string> = { 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<n>). 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<string, PulledEntry>;
|
||||
/** <lang id>/<size> 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<string, { mtime: number; data: Pulled | null }>();
|
||||
/** <renders>/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<Pulled>;
|
||||
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/<lang>/<size>/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 <lang>/<size> 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<string>, 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();
|
||||
}
|
||||
|
|
@ -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[] }
|
||||
|
|
|
|||
|
|
@ -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/<lang>/<size>/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/<language id>.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<string, readonly string[]> = {
|
||||
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<string, unknown> {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
} 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;
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
const { config, root, resolve } = ctx.loaded;
|
||||
|
|
@ -162,7 +165,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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<void> {
|
|||
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) {
|
||||
|
|
|
|||
22
cli/serve.ts
22
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']);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
/** <renders>/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<string, boolean> | ((lang: string) => Record<string, boolean>); 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) });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) | | | | |
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -9,6 +9,25 @@ CLI: `storebound` (or `bunx storebound` in a project that has it, or `bun <toolc
|
|||
(`bun add -d playwright`; it uses Playwright's Chromium or an installed Google Chrome) for rendering and web capture.
|
||||
iOS Simulator capture needs macOS + Xcode (`xcrun`); Android capture needs `adb` (platform-tools).
|
||||
|
||||
## An app that is already live: pull its art first (the onboarding default)
|
||||
Do not render art the stores already have. For an existing app (`storebound onboard` does this step too):
|
||||
1. `storebound art pull --dry`: per store, language and set what would be added, what is already the same, and which
|
||||
local files differ from the store (your own renders: kept, listed as conflicts).
|
||||
2. `storebound art pull --write`: downloads the originals (App Store screenshot sets and app previews per locale and
|
||||
display type, the App Store icon; per Play language the icon, feature graphic and phone / 7" / 10" / TV / Wear
|
||||
screenshots) into `store/renders/<lang>/<size>/NN.png`, `feature/1024x500.*`, `<icons.out>/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-<type>/` 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 |
|
||||
|---|---|---|
|
||||
|
|
|
|||
297
tests/art-pull.test.ts
Normal file
297
tests/art-pull.test.ts
Normal file
|
|
@ -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<string, unknown[]> = {
|
||||
'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<string, string | Uint8Array> = {}) {
|
||||
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<string, string>)[l] ?? null };
|
||||
const r = (o: Partial<RemoteArt>): 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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?');
|
||||
|
|
|
|||
|
|
@ -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<CaseRe
|
|||
return { name, ok: problems.length === 0, problems: [...new Set(problems)], visited: 1, notes };
|
||||
}
|
||||
|
||||
/** art.live of the store-art case: what the stores have live (made-up thumbnails on the store CDNs). */
|
||||
const LIVE_ART = {
|
||||
generated: '2026-10-14T09:00:00.000Z',
|
||||
stores: [{ store: 'apple', ok: true, version: '2.4.0 (READY_FOR_SALE)', images: 3 }, { store: 'play', ok: true, images: 3 }],
|
||||
icons: [{ store: 'apple', index: 0, type: 'icon', w: 1024, h: 1024, thumb: 'https://is1-ssl.mzstatic.com/image/thumb/example/icon.png/256x256bb.jpg', state: 'missing' }],
|
||||
sets: [
|
||||
{ store: 'apple', locale: 'en-US', lang: 'en', kind: 'screenshot', type: 'APP_IPHONE_67', size: 'ios69', label: 'App Store iPhone 6.9"', tiles: [
|
||||
{ index: 0, type: 'APP_IPHONE_67', w: 1320, h: 2868, thumb: 'https://is1-ssl.mzstatic.com/image/thumb/example/en1.png/138x300bb.jpg', state: 'missing' },
|
||||
{ index: 1, type: 'APP_IPHONE_67', w: 1320, h: 2868, thumb: 'https://is1-ssl.mzstatic.com/image/thumb/example/en2.png/138x300bb.jpg', state: 'missing' }] },
|
||||
{ store: 'play', locale: 'en-US', lang: 'en', kind: 'screenshot', type: 'phoneScreenshots', size: 'play', label: 'Play phone', tiles: [
|
||||
{ index: 0, type: 'phoneScreenshots', w: 540, h: 960, thumb: 'https://lh3.googleusercontent.com/example-en-1=w300', state: 'same', file: 'store/renders/en/play/01.png' },
|
||||
{ index: 1, type: 'phoneScreenshots', w: 540, h: 960, thumb: 'https://lh3.googleusercontent.com/example-en-2=w300', state: 'differs', file: 'store/renders/en/play/02.png' }] },
|
||||
],
|
||||
notes: [], pulled: true,
|
||||
};
|
||||
/** The dry run of art.pull the native dialog shows (canned: the boot check reads no store). */
|
||||
const ART_PULL_DRY = 'add store/renders/en/ios69/ 2 files (01.png .. 02.png) not in the project yet\nconflict store/renders/en/play/02.png your own file differs from the store\ndry run: would write 2 new, 0 changed, 0 removed, 1 same as the store, 1 kept (differ) (run with --write)';
|
||||
/** A 1x1 PNG as the thumbnail art.thumb answers. */
|
||||
const THUMB = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC';
|
||||
|
||||
/**
|
||||
* The Store art tab of an app folder with pulled art: the live store art (art.live, canned) draws its thumbnails
|
||||
* (art.thumb data: URLs) with same / differs / not pulled per image, "Pull from stores" goes through the confirm dialog
|
||||
* with its dry run (answered Cancel), and "Replace with a file" on a pulled render picks a file (image_pick) and runs
|
||||
* the real sidecar's art.replace dry run (size and checks) before the dialog. Text checks; any uncaught error fails.
|
||||
*/
|
||||
async function runLiveArtCase(browser: Browser, sidecar: Sidecar): Promise<CaseResult> {
|
||||
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<string>();
|
||||
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<string, unknown>): Promise<unknown> => {
|
||||
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<string, unknown>, 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<string, unknown>) => {
|
||||
try { return { ok: (await answer(cmd, args)) ?? null }; } catch (e) { return { err: e }; }
|
||||
});
|
||||
page.on('pageerror', e => problems.push(`uncaught: ${e.stack ?? e.message}`.split('\n').slice(0, 4).join(' | ')));
|
||||
page.on('console', m => { if (m.type() === 'error') problems.push(`console.error: ${m.text().slice(0, 400)}`); });
|
||||
await page.addInitScript(initScript(freeze));
|
||||
const text = async (sel: string) => (await page.locator(sel).first().innerText().catch(() => '')).replace(/\s+/g, ' ');
|
||||
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<void> }> {
|
||||
const { chromium } = await import('playwright');
|
||||
const opts = { headless: true, timeout: 60_000 };
|
||||
|
|
@ -1000,6 +1125,7 @@ export async function bootCheck(): Promise<CaseResult[]> {
|
|||
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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue