diff --git a/agent-workspace/domain-skills/appstoreconnect.apple.com/_architecture.md b/agent-workspace/domain-skills/appstoreconnect.apple.com/_architecture.md new file mode 100644 index 00000000..49c0356c --- /dev/null +++ b/agent-workspace/domain-skills/appstoreconnect.apple.com/_architecture.md @@ -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. diff --git a/agent-workspace/domain-skills/appstoreconnect.apple.com/pricing-and-availability.md b/agent-workspace/domain-skills/appstoreconnect.apple.com/pricing-and-availability.md new file mode 100644 index 00000000..a93ea1fe --- /dev/null +++ b/agent-workspace/domain-skills/appstoreconnect.apple.com/pricing-and-availability.md @@ -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//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. diff --git a/agent-workspace/domain-skills/appstoreconnect.apple.com/promo-codes.md b/agent-workspace/domain-skills/appstoreconnect.apple.com/promo-codes.md new file mode 100644 index 00000000..db4511a6 --- /dev/null +++ b/agent-workspace/domain-skills/appstoreconnect.apple.com/promo-codes.md @@ -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//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 `` 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 (` | | … | `) → 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.)