Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# App Store Connect — architecture & gotchas (appstoreconnect.apple.com)

Shared notes for driving ASC. Read this first, then the per-flow file (`promo-codes.md`, `pricing-and-availability.md`). Requires an already-signed-in session — if redirected to an Apple ID / 2FA wall, stop and hand back to the user; never type credentials.

## Two eras of UI live side by side

ASC is half modern SPA, half legacy WebObjects. Which one you're on changes how you read and drive the page:

- **Modern "Distribution" pages** (App Information, Pricing and Availability, App Privacy, …) are an Angular/React SPA. The **left sidebar nav is rendered in shadow DOM** — a plain `document.querySelectorAll('a')` will NOT find "Promo Codes" / "Pricing and Availability". Either deep-walk shadow roots, or click by coordinate (compositor-level clicks pass through shadow DOM).
- **Legacy pages** (notably **Promo Codes**, `/distribution/promo_codes/generate`) are a WebObjects view **embedded in a same-origin iframe** whose `src` is `/WebObjects/iTunesConnect.woa/…`. The light/shadow DOM of the top page is nearly empty — everything real is inside `document.querySelector('iframe').contentDocument`. Same-origin, so `contentDocument` is directly accessible.

Quick test for "why can't I find this element": if `js()` returns empty for visible controls, check for an iframe (`document.querySelectorAll('iframe')`) before assuming shadow DOM.

## Deep-walk shadow DOM (modern pages)

```python
js(r"""(()=>{const hits=[];
function walk(root){for(const e of root.querySelectorAll('*')){
if(e.shadowRoot) walk(e.shadowRoot);
if(e.childElementCount===0 && /^promo codes$/i.test((e.textContent||'').trim())){
const r=e.getBoundingClientRect(); hits.push({x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)});}
}}
walk(document); return JSON.stringify(hits);
})()""")
```

`getBoundingClientRect()` returns CSS px that match `click_at_xy` directly. **Prefer DOM-located coords over reading pixels off a screenshot** — on a HiDPI display the screenshot is downscaled for viewing, so eyeballed pixels are off by the device-pixel-ratio.

## Multi-tab targeting (the big time-sink)

When several tabs are open (e.g. a Themis dashboard, X, YouTube Studio alongside ASC), the daemon's default `js()` / `click_at_xy` target is often NOT the ASC tab — it silently runs against whichever tab it last attached to. Symptoms: `page_info()` suddenly shows a different URL; `js()` returns "no-iframe".

Pin every call to the ASC tab:

```python
ts = cdp("Target.getTargets")["targetInfos"]
asc = [t for t in ts if t["type"]=="page" and "appstoreconnect.apple.com" in t["url"]][0]["targetId"]
cdp("Target.activateTarget", targetId=asc) # needed before any coordinate click_at_xy
js("…", target_id=asc) # DOM reads/writes hit the right document
```

Driving the page through `iframe.contentDocument` + `js(target_id=…)` with DOM `.click()` / `.value=` is far more reliable here than coordinate clicks, because it does not depend on which tab is frontmost.

## Don't type credentials

If a navigation lands on `idmsa.apple.com` / an Apple ID sign-in or 2FA prompt, stop and ask the user. The session is expected to be pre-authenticated.
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# App Store Connect — pricing & availability

Read `_architecture.md` first. This is a **modern SPA** page (sidebar nav in shadow DOM), NOT an iframe.

- URL: `https://appstoreconnect.apple.com/apps/<appId>/distribution/pricing`
- **Price and availability are decoupled** — a correct base price tells you nothing about which territories the app actually sells in. Check both.

## Reading the current price

The summary view shows "Current Price · N Countries or Regions · May Adjust Automatically" but **not the amounts**. To see real per-storefront values, click the **"Current Price"** row (or "All Prices and Currencies") — it expands an inline table. Then scrape from the DOM rather than the screenshot:

```python
js(r"""(()=>{return document.body.innerText.split('\n').map(s=>s.trim())
.filter(s=>/€|\$|£|USD|EUR|GBP|\d+[.,]\d{2}/.test(s)).slice(0,60).join(' | ');})()""")
```

Each storefront shows two numbers: **price** then **proceeds** (after Apple's commission). Apple **auto-equalizes** non-base storefronts for local tax/FX, so outliers (e.g. base $3.99 but Albania/Armenia at $4.99–$5.99) are **normal**, not a bug — only the base storefront is the price you set. Apple tiers map cleanly: e.g. Tier 4 = €3.99 / $3.99 / £3.99.

## Availability — verify after every release

A freshly created app frequently ships with availability **never affirmatively configured**: the price grid seeds ~174 storefronts but the **App Availability** panel still shows a "Set Up Availability" prompt, so the app is NOT live in all 175 territories. No automated check flags this — verify by hand at launch.

Flow:

1. On the pricing page, find the **"Set Up Availability"** button. It's in the App Availability panel; locate via DOM (`getBoundingClientRect` → `click_at_xy`) since the SPA nav/buttons are unreliable to eyeball on HiDPI.
2. The modal offers three radios: **All Countries or Regions (175)** / Specific Countries or Regions / Publish as Pre-Order. Public/Discoverable is the default visibility. For a normal launch, leave **All Countries or Regions** selected.
3. **Next** → confirmation: *"Make app available in all 175 countries or regions after releasing it?"* → **Confirm**.
4. After confirm the URL becomes `…/distribution/pricing/availability` and the page lists every territory at status **"Processing to Available"**. Apple propagates this to all storefronts in **~24h**. Header should read **"Availability (175 Countries or Regions)"**.

Verify the result from the DOM:

```python
js(r"""(()=>{const h=[...document.querySelectorAll('h1,h2,h3')].map(e=>e.innerText)
.find(t=>/Availability\s*\(/i.test(t))||'';
const proc=(document.body.innerText.match(/Processing to Available/g)||[]).length;
return JSON.stringify({header:h, processingCount:proc});})()""")
```

The confirm dialog commits the change directly — there's no separate Save button to chase.
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# App Store Connect — minting promo codes

Read `_architecture.md` first (this page is a **legacy WebObjects iframe**, and multi-tab targeting bites hard here).

There is **no ASC API** for one-time-purchase app promo codes (the API's promo/offer-code resources are subscription / IAP only). The web UI is the only path, and it's the old WebObjects view embedded in a same-origin iframe.

## URL & scope

- `https://appstoreconnect.apple.com/apps/<appId>/distribution/promo_codes/generate` — the page. Reach it from the app's left sidebar → **Promo Codes** (under GROWTH & MARKETING) if the deep link 404s.
- Only available once the app version is **approved / released** (`PENDING_DEVELOPER_RELEASE` / `READY_FOR_SALE`). Before that the Generate button is disabled — there's nothing to automate.
- Quota: **100 codes per app version**, refreshed quarterly. Codes are single-use, expire 28 days after generation or end of calendar year (whichever is first).
- The yellow banner about "offer codes have replaced promo codes" applies to **in-app purchases only** — app-level codes for a paid app still work.

## All real elements are inside the iframe

```python
js("(()=>{const d=document.querySelector('iframe').contentDocument; return d.body.innerText;})()", target_id=asc)
```

## Generate flow

1. **Set the quantity.** The Generate tab has a table row per version; the count is an `<input type=text>` that starts at `"0"`. Set it via the iframe DOM and fire events — coordinate typing is unreliable and the field **clamps to the max (100)** if mis-cleared (fat-fingering it mints the entire quota, which is irreversible):
```python
js(r"""(()=>{const d=document.querySelector('iframe').contentDocument;
const t=[...d.querySelectorAll('input[type=text]')].find(i=>i.value==='0');
t.focus(); t.value='25';
['input','change','keyup','blur'].forEach(e=>t.dispatchEvent(new Event(e,{bubbles:true})));
return t.value;})()""", target_id=asc)
```
2. **The "N of 100 codes remaining" counter is OPTIMISTIC** — it updates the instant you type a quantity and is NOT proof anything was generated. Do not trust it.
3. **Click the page's "Generate Codes" button** (DOM `.click()` on the element whose text matches `/generate codes?/i` with the smallest `y`) → opens a license-agreement modal.
4. **The agreement checkbox is a styled `span.itc-checkbox`** wrapping a hidden input. Setting the hidden `input.checked=true` may NOT trip the page's enable logic — the confirm button can stay effectively disabled. Drive the page's own handler and verify the modal actually submits.
5. **Click the modal's confirm "Generate Codes"** (the `/generate codes?/i` element with the *largest* `y` — bottom of the dialog). On success the modal shows: *"Your promo codes have been generated… view or download from the History tab."*

## History tab is the source of truth

After generating, **verify on the History tab** — it is authoritative:

- "You have not requested any codes in the past 60 days" → **nothing was minted** (the confirm click silently failed; common when the tab switched mid-flow). Safe to retry — you have not burned quota.
- A dated row (`<date> | <email> | … | <count>`) → it worked.

This check is what prevents accidental double-minting. The optimistic counter will happily show "75 of 100 remaining" even when zero codes exist.

## Reading the codes — no download needed

History → **View Codes** opens a modal that renders the codes **directly in the DOM**. Scrape them; there's no need to capture a file download:

```python
js(r"""(()=>{const d=document.querySelector('iframe').contentDocument;
return JSON.stringify([...new Set((d.body.innerText.match(/\b[A-Z0-9]{12}\b/g)||[]))]);})()""", target_id=asc)
```

Codes are 12-char uppercase alphanumeric. (A "Download the Promotional Code Distribution Terms" link in the same modal fetches the legal PDF, not the codes — the codes themselves are the on-screen list and are also emailed to the account holder.)