Compare commits
8 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73e4614b34 | |||
| 108b362cd9 | |||
| 5cc3da486d | |||
| 7e362ef962 | |||
| c1bed9440e | |||
| 793c322f99 | |||
| ab97d6d72f | |||
| c5173e4cae |
19 changed files with 144 additions and 86 deletions
|
|
@ -9,7 +9,7 @@
|
||||||
"name": "storebound",
|
"name": "storebound",
|
||||||
"source": ".",
|
"source": ".",
|
||||||
"description": "Take a mobile app or game from nothing to Submit for review on both stores: skills for every store step and questionnaire plus the storebound CLI and MCP server.",
|
"description": "Take a mobile app or game from nothing to Submit for review on both stores: skills for every store step and questionnaire plus the storebound CLI and MCP server.",
|
||||||
"version": "0.8.73",
|
"version": "0.8.77",
|
||||||
"category": "development",
|
"category": "development",
|
||||||
"tags": [
|
"tags": [
|
||||||
"mobile",
|
"mobile",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "storebound",
|
"name": "storebound",
|
||||||
"version": "0.8.73",
|
"version": "0.8.77",
|
||||||
"description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.",
|
"description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "CRTkafa"
|
"name": "CRTkafa"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "storebound",
|
"name": "storebound",
|
||||||
"version": "0.8.73",
|
"version": "0.8.77",
|
||||||
"description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.",
|
"description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "CRTkafa"
|
"name": "CRTkafa"
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ runs:
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.bun/install/cache
|
~/.bun/install/cache
|
||||||
|
~/.cache/tauri
|
||||||
~/.cargo/registry/index
|
~/.cargo/registry/index
|
||||||
~/.cargo/registry/cache
|
~/.cargo/registry/cache
|
||||||
~/.cargo/git/db
|
~/.cargo/git/db
|
||||||
|
|
|
||||||
|
|
@ -173,7 +173,9 @@ jobs:
|
||||||
working-directory: apps/desktop
|
working-directory: apps/desktop
|
||||||
run: |
|
run: |
|
||||||
rm -rf "$CARGO_TARGET_DIR/release/bundle"
|
rm -rf "$CARGO_TARGET_DIR/release/bundle"
|
||||||
bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles appimage,deb --ci
|
# the AppImage tools come from GitHub releases, which answer 504 now and then (cached in ~/.cache/tauri)
|
||||||
|
bash ../../tools/ci-tauri-tools.sh
|
||||||
|
for i in 1 2 3; do bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles appimage,deb --ci && break; [ $i = 3 ] && exit 1; sleep 30; done
|
||||||
- name: Self-test the exact artifacts
|
- name: Self-test the exact artifacts
|
||||||
run: |
|
run: |
|
||||||
V=$(bun tools/desktop-version.ts --print)
|
V=$(bun tools/desktop-version.ts --print)
|
||||||
|
|
@ -237,7 +239,8 @@ jobs:
|
||||||
bun tools/build-sidecars.ts --target bun-linux-x64
|
bun tools/build-sidecars.ts --target bun-linux-x64
|
||||||
patchelf --set-rpath '$ORIGIN/../lib' apps/desktop/src-tauri/binaries/storebound-x86_64-unknown-linux-gnu
|
patchelf --set-rpath '$ORIGIN/../lib' apps/desktop/src-tauri/binaries/storebound-x86_64-unknown-linux-gnu
|
||||||
B="$CARGO_TARGET_DIR/release/bundle"; rm -rf "$B"
|
B="$CARGO_TARGET_DIR/release/bundle"; rm -rf "$B"
|
||||||
(cd apps/desktop && bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles appimage,deb --ci)
|
bash tools/ci-tauri-tools.sh
|
||||||
|
for i in 1 2 3; do (cd apps/desktop && bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles appimage,deb --ci) && break; [ $i = 3 ] && exit 1; sleep 30; done
|
||||||
mkdir -p dev && cp "$B/appimage/storebound_${V}_amd64.AppImage" "$B/deb/storebound_${V}_amd64.deb" dev/
|
mkdir -p dev && cp "$B/appimage/storebound_${V}_amd64.AppImage" "$B/deb/storebound_${V}_amd64.deb" dev/
|
||||||
chmod +x dev/*.AppImage
|
chmod +x dev/*.AppImage
|
||||||
(cd /tmp && rm -rf squashfs-root && "$GITHUB_WORKSPACE/dev/storebound_${V}_amd64.AppImage" --appimage-extract >/dev/null && squashfs-root/usr/bin/storebound-desktop --self-test)
|
(cd /tmp && rm -rf squashfs-root && "$GITHUB_WORKSPACE/dev/storebound_${V}_amd64.AppImage" --appimage-extract >/dev/null && squashfs-root/usr/bin/storebound-desktop --self-test)
|
||||||
|
|
|
||||||
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -3,6 +3,22 @@
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## 0.8.77 · 2026-11-08
|
||||||
|
|
||||||
|
- **iPhone Duo:** outer and inner files are no longer cut to the first 10: the limit comes from Apple's reference data, and over it nothing changes and nothing is dropped. `apple screenshots --duo-only` writes only the Duo set. `apple cpp` no longer lists creative-assets flags.
|
||||||
|
|
||||||
|
## 0.8.76 · 2026-11-08
|
||||||
|
|
||||||
|
- 0.8.75 was tagged but not published (GitHub 502/504 in the Linux AppImage bundling). CI now takes the AppImage tools from a checked mirror instead of GitHub.
|
||||||
|
|
||||||
|
## 0.8.75 · 2026-11-08
|
||||||
|
|
||||||
|
- 0.8.74 was tagged but not published (GitHub release downloads answered 504 during the Linux AppImage bundling); 0.8.75 ships its section.
|
||||||
|
|
||||||
|
## 0.8.74 · 2026-11-08
|
||||||
|
|
||||||
|
- 0.8.73 was tagged but not published (a GitHub 504 in the Linux AppImage bundling); 0.8.74 ships its section below. CI caches the AppImage tools and retries the Linux bundle.
|
||||||
|
|
||||||
## 0.8.73 · 2026-11-08
|
## 0.8.73 · 2026-11-08
|
||||||
|
|
||||||
- **iPhone Duo:** the live API names the platform IPHONE_APP_STORE (not IOS); accepted. A Duo problem now warns and the other screenshot sets still upload.
|
- **iPhone Duo:** the live API names the platform IPHONE_APP_STORE (not IOS); accepted. A Duo problem now warns and the other screenshot sets still upload.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@storebound/desktop",
|
"name": "@storebound/desktop",
|
||||||
"version": "0.8.73",
|
"version": "0.8.77",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
2
apps/desktop/src-tauri/Cargo.lock
generated
2
apps/desktop/src-tauri/Cargo.lock
generated
|
|
@ -4096,7 +4096,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "storebound-desktop"
|
name = "storebound-desktop"
|
||||||
version = "0.8.73"
|
version = "0.8.77"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "storebound-desktop"
|
name = "storebound-desktop"
|
||||||
version = "0.8.73"
|
version = "0.8.77"
|
||||||
description = "storebound desktop: every game's releases on both stores in one window"
|
description = "storebound desktop: every game's releases on both stores in one window"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "storebound",
|
"productName": "storebound",
|
||||||
"mainBinaryName": "storebound-desktop",
|
"mainBinaryName": "storebound-desktop",
|
||||||
"version": "0.8.73",
|
"version": "0.8.77",
|
||||||
"identifier": "dev.storebound.desktop",
|
"identifier": "dev.storebound.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|
|
||||||
57
cli/apple/asset-library.ts
Normal file
57
cli/apple/asset-library.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
// App Asset Library states and upsert rules shared by creative assets and iPhone Duo screenshots. No command flags here.
|
||||||
|
import * as rt from '../lib/rt';
|
||||||
|
import type { J } from './asc';
|
||||||
|
|
||||||
|
export const REF = (sum: string): string => `storebound ${sum}`;
|
||||||
|
export const sumOf = (a: J): string | undefined => /^storebound ([0-9a-f]{32})$/.exec(String(a?.attributes?.referenceName ?? ''))?.[1];
|
||||||
|
|
||||||
|
/** Asset states (AppAssetLibraryAssetState, API 4.5.1) by what storebound may do with them. */
|
||||||
|
export const ASSET_STATES = {
|
||||||
|
/** still uploading / processing: wait (bounded), then select */
|
||||||
|
processing: ['AWAITING_UPLOAD', 'UPLOAD_COMPLETE'],
|
||||||
|
/** processed and usable in a slot; not reviewed yet: goes into a review submission */
|
||||||
|
unreviewed: ['COMPLETE', 'PREPARE_FOR_SUBMISSION'],
|
||||||
|
/** in a submission or reviewed: usable in a slot, nothing to add */
|
||||||
|
reviewing: ['READY_FOR_REVIEW', 'WAITING_FOR_REVIEW', 'IN_REVIEW', 'ACCEPTED', 'APPROVED'],
|
||||||
|
} as const;
|
||||||
|
export type AssetClass = 'processing' | 'unreviewed' | 'reviewing' | 'failed' | 'rejected' | 'archived' | 'unknown';
|
||||||
|
|
||||||
|
/** What a library asset's state means for storebound. Pure. */
|
||||||
|
export function classify(state: string | undefined): AssetClass {
|
||||||
|
const s = String(state ?? '');
|
||||||
|
for (const [k, v] of Object.entries(ASSET_STATES)) if ((v as readonly string[]).includes(s)) return k as AssetClass;
|
||||||
|
if (s === 'FAILED') return 'failed';
|
||||||
|
if (s === 'REJECTED') return 'rejected';
|
||||||
|
if (s === 'ARCHIVED') return 'archived';
|
||||||
|
return s ? 'unknown' : 'processing';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Selectable for a slot: processed and not failed / rejected / archived (Prepare for Submission is enough). */
|
||||||
|
export const selectable = (c: AssetClass): boolean => c === 'unreviewed' || c === 'reviewing';
|
||||||
|
|
||||||
|
/** Stop re-uploading a file whose earlier uploads failed processing this many times. */
|
||||||
|
export const MAX_FAILED = 2;
|
||||||
|
|
||||||
|
/** The library asset to use for one MD5: a live one first (newest state wins: reviewing > unreviewed > processing),
|
||||||
|
* else an archived one (unarchive it), else none; plus why not to upload (a rejected file, repeated failures). Pure. */
|
||||||
|
export function pickAsset(assets: J[], sum: string): { asset?: J; unarchive?: boolean; refuse?: string } {
|
||||||
|
const mine = assets.filter(x => sumOf(x) === sum);
|
||||||
|
const rank: Record<AssetClass, number> = { reviewing: 0, unreviewed: 1, processing: 2, unknown: 3, archived: 4, rejected: 5, failed: 6 };
|
||||||
|
const by = (c: AssetClass) => mine.filter(x => classify(x.attributes?.state) === c);
|
||||||
|
const live = mine.filter(x => rank[classify(x.attributes?.state)] <= 3).sort((a, b) => rank[classify(a.attributes?.state)] - rank[classify(b.attributes?.state)]);
|
||||||
|
if (live[0]) return { asset: live[0] };
|
||||||
|
if (by('archived')[0]) return { asset: by('archived')[0], unarchive: true };
|
||||||
|
const rej = by('rejected')[0];
|
||||||
|
if (rej) return { refuse: `rejected by App Review${reason(rej)}: uploading the same file again changes nothing; change the file` };
|
||||||
|
if (by('failed').length >= MAX_FAILED) return { refuse: `${by('failed').length} earlier uploads of this file failed processing${reason(by('failed')[0]!)}: check the file (format, size) before trying again` };
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reason = (a: J): string => {
|
||||||
|
const d = ((a.attributes?.stateDetails ?? []) as J[]).map(x => x?.code ?? x?.message ?? x?.description).filter(Boolean);
|
||||||
|
return d.length ? ` (${d.join(', ')})` : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Bounded wait for processing (every wait has a timeout and a heartbeat); tests set waitMs 0 / timeoutMs 0. */
|
||||||
|
export const processingTiming = { pollMs: 3000, timeoutMs: 120_000, heartbeatMs: 20_000, sleep: (ms: number): Promise<void> => (ms > 0 ? rt.sleep(ms) : Promise.resolve()) };
|
||||||
|
|
||||||
|
|
@ -29,61 +29,17 @@ import { need } from '../lib/ctx';
|
||||||
import { consoleLink } from '../lib/console-links';
|
import { consoleLink } from '../lib/console-links';
|
||||||
import { done, log, warn } from '../lib/log';
|
import { done, log, warn } from '../lib/log';
|
||||||
import { elapsed } from '../lib/progress';
|
import { elapsed } from '../lib/progress';
|
||||||
import * as rt from '../lib/rt';
|
|
||||||
import { ffprobe } from '../shots/report';
|
import { ffprobe } from '../shots/report';
|
||||||
import { imageInfo } from '../shots/spec';
|
import { imageInfo } from '../shots/spec';
|
||||||
import { checkCreativeImage, checkCreativeVideo, type CreativeFile, creativeFiles, PLACEMENT_TYPE, type Placement } from '../shots/creative';
|
import { checkCreativeImage, checkCreativeVideo, type CreativeFile, creativeFiles, PLACEMENT_TYPE, type Placement } from '../shots/creative';
|
||||||
import { type Asc, ascClient, md5, type J } from './asc';
|
import { type Asc, ascClient, md5, type J } from './asc';
|
||||||
|
import { type AssetClass, ASSET_STATES, classify, MAX_FAILED, pickAsset, processingTiming, reason, REF, selectable, sumOf } from './asset-library';
|
||||||
|
export { type AssetClass, ASSET_STATES, classify, MAX_FAILED, pickAsset, processingTiming, REF, selectable };
|
||||||
import { partHeaders } from './screenshot-health';
|
import { partHeaders } from './screenshot-health';
|
||||||
import { AmbiguousWriteError } from '../lib/upsert';
|
import { AmbiguousWriteError } from '../lib/upsert';
|
||||||
import { addItem, ensureDraft, OtherSubmissionError } from './submission';
|
import { addItem, ensureDraft, OtherSubmissionError } from './submission';
|
||||||
|
|
||||||
const TAG = 'apple';
|
const TAG = 'apple';
|
||||||
export const REF = (sum: string): string => `storebound ${sum}`;
|
|
||||||
const sumOf = (a: J): string | undefined => /^storebound ([0-9a-f]{32})$/.exec(String(a?.attributes?.referenceName ?? ''))?.[1];
|
|
||||||
|
|
||||||
/** Asset states (AppAssetLibraryAssetState, API 4.5.1) by what storebound may do with them. */
|
|
||||||
export const ASSET_STATES = {
|
|
||||||
/** still uploading / processing: wait (bounded), then select */
|
|
||||||
processing: ['AWAITING_UPLOAD', 'UPLOAD_COMPLETE'],
|
|
||||||
/** processed and usable in a slot; not reviewed yet: goes into a review submission */
|
|
||||||
unreviewed: ['COMPLETE', 'PREPARE_FOR_SUBMISSION'],
|
|
||||||
/** in a submission or reviewed: usable in a slot, nothing to add */
|
|
||||||
reviewing: ['READY_FOR_REVIEW', 'WAITING_FOR_REVIEW', 'IN_REVIEW', 'ACCEPTED', 'APPROVED'],
|
|
||||||
} as const;
|
|
||||||
export type AssetClass = 'processing' | 'unreviewed' | 'reviewing' | 'failed' | 'rejected' | 'archived' | 'unknown';
|
|
||||||
|
|
||||||
/** What a library asset's state means for storebound. Pure. */
|
|
||||||
export function classify(state: string | undefined): AssetClass {
|
|
||||||
const s = String(state ?? '');
|
|
||||||
for (const [k, v] of Object.entries(ASSET_STATES)) if ((v as readonly string[]).includes(s)) return k as AssetClass;
|
|
||||||
if (s === 'FAILED') return 'failed';
|
|
||||||
if (s === 'REJECTED') return 'rejected';
|
|
||||||
if (s === 'ARCHIVED') return 'archived';
|
|
||||||
return s ? 'unknown' : 'processing';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Selectable for a slot: processed and not failed / rejected / archived (Prepare for Submission is enough). */
|
|
||||||
export const selectable = (c: AssetClass): boolean => c === 'unreviewed' || c === 'reviewing';
|
|
||||||
|
|
||||||
/** Stop re-uploading a file whose earlier uploads failed processing this many times. */
|
|
||||||
export const MAX_FAILED = 2;
|
|
||||||
|
|
||||||
/** The library asset to use for one MD5: a live one first (newest state wins: reviewing > unreviewed > processing),
|
|
||||||
* else an archived one (unarchive it), else none; plus why not to upload (a rejected file, repeated failures). Pure. */
|
|
||||||
export function pickAsset(assets: J[], sum: string): { asset?: J; unarchive?: boolean; refuse?: string } {
|
|
||||||
const mine = assets.filter(x => sumOf(x) === sum);
|
|
||||||
const rank: Record<AssetClass, number> = { reviewing: 0, unreviewed: 1, processing: 2, unknown: 3, archived: 4, rejected: 5, failed: 6 };
|
|
||||||
const by = (c: AssetClass) => mine.filter(x => classify(x.attributes?.state) === c);
|
|
||||||
const live = mine.filter(x => rank[classify(x.attributes?.state)] <= 3).sort((a, b) => rank[classify(a.attributes?.state)] - rank[classify(b.attributes?.state)]);
|
|
||||||
if (live[0]) return { asset: live[0] };
|
|
||||||
if (by('archived')[0]) return { asset: by('archived')[0], unarchive: true };
|
|
||||||
const rej = by('rejected')[0];
|
|
||||||
if (rej) return { refuse: `rejected by App Review${reason(rej)}: uploading the same file again changes nothing; change the file` };
|
|
||||||
if (by('failed').length >= MAX_FAILED) return { refuse: `${by('failed').length} earlier uploads of this file failed processing${reason(by('failed')[0]!)}: check the file (format, size) before trying again` };
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "uploaded 2026-10-19, waiting for Apple's approval (IN_REVIEW); run `storebound apple creative-assets` again later".
|
/** "uploaded 2026-10-19, waiting for Apple's approval (IN_REVIEW); run `storebound apple creative-assets` again later".
|
||||||
* The version page's header / search slots offer only approved assets ("Browse approved assets"), so an asset that
|
* The version page's header / search slots offer only approved assets ("Browse approved assets"), so an asset that
|
||||||
* is not APPROVED waits; a later run places it. Pure. */
|
* is not APPROVED waits; a later run places it. Pure. */
|
||||||
|
|
@ -107,14 +63,6 @@ export async function creativeWaiting(c: Asc, appId: string, local: Map<string,
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reason = (a: J): string => {
|
|
||||||
const d = ((a.attributes?.stateDetails ?? []) as J[]).map(x => x?.code ?? x?.message ?? x?.description).filter(Boolean);
|
|
||||||
return d.length ? ` (${d.join(', ')})` : '';
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Bounded wait for processing (every wait has a timeout and a heartbeat); tests set waitMs 0 / timeoutMs 0. */
|
|
||||||
export const processingTiming = { pollMs: 3000, timeoutMs: 120_000, heartbeatMs: 20_000, sleep: (ms: number): Promise<void> => (ms > 0 ? rt.sleep(ms) : Promise.resolve()) };
|
|
||||||
|
|
||||||
export interface Row { locale: string; placement: Placement; file: string; action: 'keep' | 'upload' | 'reuse' | 'wait' | 'select' | 'replace' | 'approve' | 'refuse'; state?: string; asset?: J }
|
export interface Row { locale: string; placement: Placement; file: string; action: 'keep' | 'upload' | 'reuse' | 'wait' | 'select' | 'replace' | 'approve' | 'refuse'; state?: string; asset?: J }
|
||||||
|
|
||||||
/** Local files per App Store locale, checked; throws on any error before a network call. */
|
/** Local files per App Store locale, checked; throws on any error before a network call. */
|
||||||
|
|
|
||||||
|
|
@ -8,18 +8,18 @@
|
||||||
// 3. POST appAssetLibraryPlacements (placementType APP_SCREENSHOT, placementGroup, the version localization).
|
// 3. POST appAssetLibraryPlacements (placementType APP_SCREENSHOT, placementGroup, the version localization).
|
||||||
// Screenshots are reviewed with the version, so a processed asset is placed at once (no approval wait).
|
// Screenshots are reviewed with the version, so a processed asset is placed at once (no approval wait).
|
||||||
// Upsert: a group whose placements already show the local files in order is left alone; otherwise its placements are
|
// Upsert: a group whose placements already show the local files in order is left alone; otherwise its placements are
|
||||||
// removed and the files placed in order (outer display files first, then inner; at most 10). Read back at the end.
|
// removed and the files placed in order (outer display files first, then inner). Never truncated: more files than
|
||||||
|
// the limit Apple publishes for the group (refData groupLimits) changes nothing and says so. Read back at the end.
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { basename } from 'node:path';
|
import { basename } from 'node:path';
|
||||||
import { log, warn } from '../lib/log';
|
import { log, warn } from '../lib/log';
|
||||||
import { elapsed } from '../lib/progress';
|
import { elapsed } from '../lib/progress';
|
||||||
import { classify, pickAsset, processingTiming, REF, selectable } from './creative-assets';
|
import { classify, pickAsset, processingTiming, REF, selectable } from './asset-library';
|
||||||
import { type Asc, md5, type J } from './asc';
|
import { type Asc, md5, type J } from './asc';
|
||||||
import { partHeaders } from './screenshot-health';
|
import { partHeaders } from './screenshot-health';
|
||||||
|
|
||||||
const TAG = 'apple';
|
const TAG = 'apple';
|
||||||
export const DUO_CATEGORY = 'APP_SCREENSHOTS_AND_PREVIEWS';
|
export const DUO_CATEGORY = 'APP_SCREENSHOTS_AND_PREVIEWS';
|
||||||
export const DUO_MAX = 10;
|
|
||||||
const sumOf = (a: J | undefined): string | undefined => /^storebound ([0-9a-f]{32})$/.exec(String(a?.attributes?.referenceName ?? ''))?.[1];
|
const sumOf = (a: J | undefined): string | undefined => /^storebound ([0-9a-f]{32})$/.exec(String(a?.attributes?.referenceName ?? ''))?.[1];
|
||||||
|
|
||||||
/** iPhone placement profile group ids (platform IOS or IPHONE_APP_STORE, as the live API answers) of the IPHONE_DUO display class in an appAssetLibraryRefData response. Pure. */
|
/** iPhone placement profile group ids (platform IOS or IPHONE_APP_STORE, as the live API answers) of the IPHONE_DUO display class in an appAssetLibraryRefData response. Pure. */
|
||||||
|
|
@ -30,26 +30,44 @@ export function duoGroups(refData: J[]): string[] {
|
||||||
return [...ids];
|
return [...ids];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Apple's screenshot limit for a placement group on a version page, or null when refData publishes none. Pure. */
|
||||||
|
export function duoLimit(refData: J[], group: string): number | null {
|
||||||
|
for (const r of refData) for (const f of (r?.attributes?.features ?? []) as J[]) {
|
||||||
|
if (f?.featureId !== 'APP_STORE_VERSIONS') continue;
|
||||||
|
for (const p of (f.placementPolicies ?? []) as J[]) {
|
||||||
|
if (p?.placementType !== 'APP_SCREENSHOT') continue;
|
||||||
|
for (const l of (p.groupLimits ?? []) as J[]) if ((l?.groupIds ?? []).includes(group) && typeof l.maxCount === 'number') return l.maxCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether a group's current placements (asset MD5s in order) already show `want`. Pure. */
|
/** Whether a group's current placements (asset MD5s in order) already show `want`. Pure. */
|
||||||
export const duoUpToDate = (shown: (string | undefined)[], want: string[]): boolean => shown.length === want.length && shown.every((s, i) => s === want[i]);
|
export const duoUpToDate = (shown: (string | undefined)[], want: string[]): boolean => shown.length === want.length && shown.every((s, i) => s === want[i]);
|
||||||
|
|
||||||
export interface DuoResult { locale: string; action: 'keep' | 'place' | 'wait' | 'refuse' | 'plan'; files: number }
|
export interface DuoResult { locale: string; action: 'keep' | 'place' | 'wait' | 'refuse' | 'plan'; files: number }
|
||||||
|
|
||||||
/** Resolves the Duo placement group once per run; throws with the guardrail text when the API lists none. */
|
/** Resolves the Duo placement group once per run; throws with the guardrail text when the API lists none. */
|
||||||
export async function duoGroup(c: Asc): Promise<string> {
|
export async function duoGroup(c: Asc): Promise<{ id: string; limit: number | null }> {
|
||||||
const ref = await c.all('/v1/appAssetLibraryRefData?filter[placementTypes]=APP_SCREENSHOT');
|
const ref = await c.all('/v1/appAssetLibraryRefData?filter[placementTypes]=APP_SCREENSHOT');
|
||||||
const g = duoGroups(ref);
|
const g = duoGroups(ref);
|
||||||
if (!g.length) throw new Error('appAssetLibraryRefData lists no iOS IPHONE_DUO placement group for APP_SCREENSHOT: report it (pitfall 130); the console version page > App Previews and Screenshots > iPhone Duo takes them meanwhile');
|
if (!g.length) throw new Error('appAssetLibraryRefData lists no iOS IPHONE_DUO placement group for APP_SCREENSHOT: report it (pitfall 130); the console version page > App Previews and Screenshots > iPhone Duo takes them meanwhile');
|
||||||
if (g.length > 1) log(TAG, `iPhone Duo: ${g.length} placement groups (${g.join(', ')}), using ${g[0]}`);
|
if (g.length > 1) log(TAG, `iPhone Duo: ${g.length} placement groups (${g.join(', ')}), using ${g[0]}`);
|
||||||
return g[0]!;
|
const limit = duoLimit(ref, g[0]!);
|
||||||
|
log(TAG, `iPhone Duo: placement group ${g[0]}, limit ${limit ?? 'not published'}`);
|
||||||
|
return { id: g[0]!, limit };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Makes the iPhone Duo screenshots of one version localization show `files` in order. */
|
/** Makes the iPhone Duo screenshots of one version localization show `files` in order. */
|
||||||
export async function syncDuo(c: Asc, o: { appId: string; group: string; loc: J; files: string[]; dry: boolean }): Promise<DuoResult> {
|
export async function syncDuo(c: Asc, o: { appId: string; group: string; limit: number | null; loc: J; files: string[]; dry: boolean }): Promise<DuoResult> {
|
||||||
const locale: string = o.loc.attributes.locale;
|
const locale: string = o.loc.attributes.locale;
|
||||||
const tag = `${locale} IPHONE_DUO`;
|
const tag = `${locale} IPHONE_DUO`;
|
||||||
const files = o.files.slice(0, DUO_MAX);
|
const files = o.files;
|
||||||
if (o.files.length > DUO_MAX) warn(TAG, `${tag}: ${o.files.length} files, only the first ${DUO_MAX} are used`);
|
if (o.limit !== null && files.length > o.limit) {
|
||||||
|
warn(TAG, `${tag}: ${files.length} files (outer + inner) but Apple allows ${o.limit} in this group: nothing changed, none dropped. Keep at most ${o.limit} across iphone-duo-outer/ and iphone-duo-inner/`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return { locale, action: 'refuse', files: files.length };
|
||||||
|
}
|
||||||
const lib = (await c.asc('GET', `/v1/apps/${o.appId}/assetLibrary`)).data as J;
|
const lib = (await c.asc('GET', `/v1/apps/${o.appId}/assetLibrary`)).data as J;
|
||||||
const library = () => c.all(`/v1/appAssetLibraries/${lib.id}/images?filter[category]=${DUO_CATEGORY}&limit=200`);
|
const library = () => c.all(`/v1/appAssetLibraries/${lib.id}/images?filter[category]=${DUO_CATEGORY}&limit=200`);
|
||||||
let assets = await library();
|
let assets = await library();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Store screenshots of every locale on the editable App Store version (idempotent: a set whose files already match
|
// Store screenshots of every locale on the editable App Store version (idempotent: a set whose files already match
|
||||||
// by MD5 and order is left alone, otherwise it is emptied and re-uploaded).
|
// by MD5 and order is left alone, otherwise it is emptied and re-uploaded).
|
||||||
// storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--dry]
|
// storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--duo-only] [--dry]
|
||||||
// storebound apple screenshots --repair-stuck [--dry] (only the screenshots stuck outside COMPLETE, in place)
|
// storebound apple screenshots --repair-stuck [--dry] (only the screenshots stuck outside COMPLETE, in place)
|
||||||
// Source per language: <renders>/<language id>/ios69/NN.png (6.9", 1320 x 2868 -> APP_IPHONE_67),
|
// Source per language: <renders>/<language id>/ios69/NN.png (6.9", 1320 x 2868 -> APP_IPHONE_67),
|
||||||
// ios65/NN.png (6.5", 1284 x 2778 -> APP_IPHONE_65) and, only with apple.ipad, ipad13/NN.png (13" iPad, 2064 x 2752 ->
|
// ios65/NN.png (6.5", 1284 x 2778 -> APP_IPHONE_65) and, only with apple.ipad, ipad13/NN.png (13" iPad, 2064 x 2752 ->
|
||||||
|
|
@ -98,7 +98,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
||||||
log('apple', `screenshots for version ${versionLabel(version)}`);
|
log('apple', `screenshots for version ${versionLabel(version)}`);
|
||||||
let duo = platform === 'MAC_OS' ? [] : duoDirs(config.apple);
|
let duo = platform === 'MAC_OS' ? [] : duoDirs(config.apple);
|
||||||
// a Duo problem never stops the other sets
|
// a Duo problem never stops the other sets
|
||||||
const duoGroupId = duo.length ? await duoGroup(c).catch((e: Error) => { warn('apple', `iPhone Duo skipped: ${e.message}`); duo = []; return ''; }) : '';
|
const duoGroupId = duo.length ? await duoGroup(c).catch((e: Error) => { warn('apple', `iPhone Duo skipped: ${e.message}`); duo = []; return null; }) : null;
|
||||||
const appId = duo.length ? await c.app() : '';
|
const appId = duo.length ? await c.app() : '';
|
||||||
if (platform === 'MAC_OS' && !config.apple?.mac) throw new Error('macOS version: set apple.mac: true and render the mac size (storebound shots render --size mac) first');
|
if (platform === 'MAC_OS' && !config.apple?.mac) throw new Error('macOS version: set apple.mac: true and render the mac size (storebound shots render --size mac) first');
|
||||||
if (platform !== 'MAC_OS' && config.apple?.mac) log('apple', 'Mac screenshots (mac/, APP_DESKTOP) go to the macOS version: run apple screenshots --platform macos');
|
if (platform !== 'MAC_OS' && config.apple?.mac) log('apple', 'Mac screenshots (mac/, APP_DESKTOP) go to the macOS version: run apple screenshots --platform macos');
|
||||||
|
|
@ -121,16 +121,16 @@ export async function run(ctx: Ctx): Promise<void> {
|
||||||
if (!lang) { warn('apple', `${locale}: no language with apple: "${locale}", skipped`); continue; }
|
if (!lang) { warn('apple', `${locale}: no language with apple: "${locale}", skipped`); continue; }
|
||||||
tasks.push(async () => {
|
tasks.push(async () => {
|
||||||
const sets = await c.all(`/v1/appStoreVersionLocalizations/${loc.id}/appScreenshotSets`);
|
const sets = await c.all(`/v1/appStoreVersionLocalizations/${loc.id}/appScreenshotSets`);
|
||||||
for (const [dir, type] of Object.entries(displaySets(config.apple, platform))) {
|
for (const [dir, type] of Object.entries(ctx.args.has('duo-only') ? {} : displaySets(config.apple, platform))) {
|
||||||
const d = join(src, lang.id, dir);
|
const d = join(src, lang.id, dir);
|
||||||
const files = numberedImages(d).filter(f => f.endsWith('.png')).slice(0, 10);
|
const files = numberedImages(d).filter(f => f.endsWith('.png')).slice(0, 10);
|
||||||
if (!existsSync(d) || !files.length) { warn('apple', `${locale}: nothing in ${d}, skipped`); continue; }
|
if (!existsSync(d) || !files.length) { warn('apple', `${locale}: nothing in ${d}, skipped`); continue; }
|
||||||
await syncSet(loc.id, sets, type, files, `${locale} ${type}`);
|
await syncSet(loc.id, sets, type, files, `${locale} ${type}`);
|
||||||
}
|
}
|
||||||
if (duo.length) {
|
if (duo.length && duoGroupId) {
|
||||||
const files = duo.flatMap(dir => numberedImages(join(src, lang.id, dir)).filter(f => f.endsWith('.png')));
|
const files = duo.flatMap(dir => numberedImages(join(src, lang.id, dir)).filter(f => f.endsWith('.png')));
|
||||||
if (!files.length) warn('apple', `${locale}: nothing in ${duo.map(d => join(src, lang.id, d)).join(' or ')}, iPhone Duo skipped`);
|
if (!files.length) warn('apple', `${locale}: nothing in ${duo.map(d => join(src, lang.id, d)).join(' or ')}, iPhone Duo skipped`);
|
||||||
else await syncDuo(c, { appId, group: duoGroupId, loc, files, dry: ctx.args.has('dry') }).catch((e: Error) => { warn('apple', `${locale} iPhone Duo: ${e.message}`); process.exitCode = 1; });
|
else await syncDuo(c, { appId, group: duoGroupId.id, limit: duoGroupId.limit, loc, files, dry: ctx.args.has('dry') }).catch((e: Error) => { warn('apple', `${locale} iPhone Duo: ${e.message}`); process.exitCode = 1; });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@
|
||||||
"name"
|
"name"
|
||||||
],
|
],
|
||||||
"apple cpp": [
|
"apple cpp": [
|
||||||
"add-for-review",
|
"duo-only",
|
||||||
"include-drafts",
|
"include-drafts",
|
||||||
"jobs",
|
"jobs",
|
||||||
"name",
|
"name",
|
||||||
|
|
@ -117,7 +117,6 @@
|
||||||
"repair-stuck",
|
"repair-stuck",
|
||||||
"src",
|
"src",
|
||||||
"variant",
|
"variant",
|
||||||
"verify",
|
|
||||||
"write"
|
"write"
|
||||||
],
|
],
|
||||||
"apple creative-assets": [
|
"apple creative-assets": [
|
||||||
|
|
@ -206,7 +205,7 @@
|
||||||
"write"
|
"write"
|
||||||
],
|
],
|
||||||
"apple ppo": [
|
"apple ppo": [
|
||||||
"add-for-review",
|
"duo-only",
|
||||||
"experiment",
|
"experiment",
|
||||||
"include-drafts",
|
"include-drafts",
|
||||||
"jobs",
|
"jobs",
|
||||||
|
|
@ -218,7 +217,6 @@
|
||||||
"src",
|
"src",
|
||||||
"traffic",
|
"traffic",
|
||||||
"variant",
|
"variant",
|
||||||
"verify",
|
|
||||||
"write"
|
"write"
|
||||||
],
|
],
|
||||||
"apple preorder": [
|
"apple preorder": [
|
||||||
|
|
@ -274,7 +272,7 @@
|
||||||
"platform"
|
"platform"
|
||||||
],
|
],
|
||||||
"apple screenshots": [
|
"apple screenshots": [
|
||||||
"add-for-review",
|
"duo-only",
|
||||||
"include-drafts",
|
"include-drafts",
|
||||||
"jobs",
|
"jobs",
|
||||||
"not-sent-for-review",
|
"not-sent-for-review",
|
||||||
|
|
@ -283,7 +281,6 @@
|
||||||
"repair-stuck",
|
"repair-stuck",
|
||||||
"src",
|
"src",
|
||||||
"variant",
|
"variant",
|
||||||
"verify",
|
|
||||||
"write"
|
"write"
|
||||||
],
|
],
|
||||||
"apple signing": [
|
"apple signing": [
|
||||||
|
|
@ -1119,6 +1116,7 @@
|
||||||
"cordova-android",
|
"cordova-android",
|
||||||
"cordova-ios",
|
"cordova-ios",
|
||||||
"days",
|
"days",
|
||||||
|
"duo-only",
|
||||||
"expo",
|
"expo",
|
||||||
"flutter",
|
"flutter",
|
||||||
"godot",
|
"godot",
|
||||||
|
|
@ -1250,6 +1248,7 @@
|
||||||
"device",
|
"device",
|
||||||
"dir",
|
"dir",
|
||||||
"download",
|
"download",
|
||||||
|
"duo-only",
|
||||||
"enable",
|
"enable",
|
||||||
"end",
|
"end",
|
||||||
"entitlements",
|
"entitlements",
|
||||||
|
|
@ -1594,7 +1593,7 @@
|
||||||
"storebound apple review-details [--platform ios|macos|tvos|visionos] [--contact-from <Apple ID of another app>] [--dry]"
|
"storebound apple review-details [--platform ios|macos|tvos|visionos] [--contact-from <Apple ID of another app>] [--dry]"
|
||||||
],
|
],
|
||||||
"apple screenshots": [
|
"apple screenshots": [
|
||||||
"storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--dry]",
|
"storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--duo-only] [--dry]",
|
||||||
"storebound apple screenshots --repair-stuck [--dry] (only the screenshots stuck outside COMPLETE, in place)"
|
"storebound apple screenshots --repair-stuck [--dry] (only the screenshots stuck outside COMPLETE, in place)"
|
||||||
],
|
],
|
||||||
"apple signing": [
|
"apple signing": [
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "storebound",
|
"name": "storebound",
|
||||||
"version": "0.8.73",
|
"version": "0.8.77",
|
||||||
"description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.",
|
"description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.",
|
||||||
"contextFileName": "GEMINI.md",
|
"contextFileName": "GEMINI.md",
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "storebound",
|
"name": "storebound",
|
||||||
"version": "0.8.73",
|
"version": "0.8.77",
|
||||||
"description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every store questionnaire (incl. account deletion and App Review demo accounts), privacy, icons, screenshots and previews, TestFlight. After launch: crash vitals, review replies, price sheets, analytics and Product Page Optimization. One config, a CLI (Node 20+ or Bun), an MCP server and Agent Skills for your AI coding agent.",
|
"description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every store questionnaire (incl. account deletion and App Review demo accounts), privacy, icons, screenshots and previews, TestFlight. After launch: crash vitals, review replies, price sheets, analytics and Product Page Optimization. One config, a CLI (Node 20+ or Bun), an MCP server and Agent Skills for your AI coding agent.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "CRTkafa",
|
"author": "CRTkafa",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
import { displaySets, duoDirs } from '../cli/apple/screenshots';
|
import { displaySets, duoDirs } from '../cli/apple/screenshots';
|
||||||
import { duoGroups, duoUpToDate } from '../cli/apple/duo-library';
|
import { duoGroups, duoLimit, duoUpToDate } from '../cli/apple/duo-library';
|
||||||
import { appleRules } from '../cli/lib/validate-rules';
|
import { appleRules } from '../cli/lib/validate-rules';
|
||||||
import { checkCreate, createExperimentReq, defaultName, experimentFromApi, pickExperiment, planTreatmentLocales, planTreatments, startStopReq } from '../cli/lib/ppo';
|
import { checkCreate, createExperimentReq, defaultName, experimentFromApi, pickExperiment, planTreatmentLocales, planTreatments, startStopReq } from '../cli/lib/ppo';
|
||||||
import { validateConfig } from '../cli/lib/config';
|
import { validateConfig } from '../cli/lib/config';
|
||||||
|
|
@ -105,6 +105,9 @@ describe('iPhone Duo (foldable) sizes', () => {
|
||||||
expect(duoGroups(ref)).toEqual(['g-duo']);
|
expect(duoGroups(ref)).toEqual(['g-duo']);
|
||||||
expect(duoGroups([])).toEqual([]);
|
expect(duoGroups([])).toEqual([]);
|
||||||
expect(duoGroups([{ attributes: { placementProfileGroups: [{ placementProfileGroupId: 'IPHONE_DUO_PROFILE', platform: 'IPHONE_APP_STORE', displayClassId: 'IPHONE_DUO' }] } }])).toEqual(['IPHONE_DUO_PROFILE']);
|
expect(duoGroups([{ attributes: { placementProfileGroups: [{ placementProfileGroupId: 'IPHONE_DUO_PROFILE', platform: 'IPHONE_APP_STORE', displayClassId: 'IPHONE_DUO' }] } }])).toEqual(['IPHONE_DUO_PROFILE']);
|
||||||
|
const lim = [{ attributes: { features: [{ featureId: 'IN_APP_EVENTS', placementPolicies: [{ placementType: 'APP_SCREENSHOT', groupLimits: [{ groupIds: ['g'], maxCount: 1 }] }] }, { featureId: 'APP_STORE_VERSIONS', placementPolicies: [{ placementType: 'APP_SCREENSHOT', groupLimits: [{ groupIds: ['g'], maxCount: 20 }] }] }] } }];
|
||||||
|
expect(duoLimit(lim, 'g')).toBe(20);
|
||||||
|
expect(duoLimit(lim, 'other')).toBeNull();
|
||||||
expect(duoUpToDate(['a', 'b'], ['a', 'b'])).toBe(true);
|
expect(duoUpToDate(['a', 'b'], ['a', 'b'])).toBe(true);
|
||||||
expect(duoUpToDate(['b', 'a'], ['a', 'b'])).toBe(false);
|
expect(duoUpToDate(['b', 'a'], ['a', 'b'])).toBe(false);
|
||||||
expect(duoUpToDate(['a', undefined], ['a', 'b'])).toBe(false);
|
expect(duoUpToDate(['a', undefined], ['a', 'b'])).toBe(false);
|
||||||
|
|
|
||||||
13
tools/ci-tauri-tools.sh
Executable file
13
tools/ci-tauri-tools.sh
Executable file
|
|
@ -0,0 +1,13 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Puts the AppImage tools Tauri's bundler would download from GitHub releases (which answer 502/504 now and then) into
|
||||||
|
# ~/.cache/tauri from our Forgejo package mirror, checked against its SHA256SUMS. Tauri skips the download when they exist.
|
||||||
|
set -eu
|
||||||
|
B="${TAURI_TOOLS_MIRROR:-https://git.crt.fyi/api/packages/CRT/generic/tauri-linux-tools/2026.11.08}"
|
||||||
|
D="$HOME/.cache/tauri"; mkdir -p "$D"; cd "$D"
|
||||||
|
curl -fsSL --retry 3 --max-time 60 -o SHA256SUMS "$B/SHA256SUMS"
|
||||||
|
while read -r sum f; do
|
||||||
|
f="${f#\*}"
|
||||||
|
if ! echo "$sum $f" | sha256sum -c --status 2>/dev/null; then curl -fsSL --retry 3 --max-time 300 -o "$f" "$B/$f"; echo "$sum $f" | sha256sum -c --status; fi
|
||||||
|
chmod +x "$f"
|
||||||
|
done < SHA256SUMS
|
||||||
|
rm -f SHA256SUMS; ls "$D"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue