diff --git a/.vscode/launch.json b/.vscode/launch.json index 956d99b3..e4f7c079 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,6 +1,28 @@ { "version": "0.2.0", "configurations": [ + { + // Wipes `dist/` and `node_modules/.vite`, then ALWAYS + // rebuilds the SPA with `vite build --mode development` + // so Hono's static fallback at port 3000 serves a bundle + // baked against `.env.local` (dev Convex URL) โ€” never + // `.env.production`. Without `--mode development`, + // `bun run build` defaults to production mode, loads + // `.env.production` AFTER `.env.local`, and the prod URL + // wins; that's why localhost:3000 sign-ins were bouncing + // through the prod GitHub OAuth App even after wiping + // dist (Hyo Dev / martie triage). + "name": "๐Ÿงฐ Kit: Dev (Vite + Hono + Convex)", + "type": "node", + "request": "launch", + "runtimeExecutable": "bash", + "runtimeArgs": [ + "-lc", + "npx --yes kill-port 3000 5173 || true && rm -rf dist node_modules/.vite && ([ -d ../../node_modules ] || (echo '๐Ÿ“ฆ Installing workspace dependencies...' && cd ../.. && bun install)) && bunx vite build --mode development && trap 'kill 0' INT TERM EXIT && (bun run dev:convex & bun run dev:server & bun run dev; wait)" + ], + "cwd": "${workspaceFolder}/packages/kit", + "console": "integratedTerminal" + }, { "type": "node-terminal", "request": "launch", @@ -96,6 +118,34 @@ "cwd": "${workspaceFolder}/libraries/flutter_inapp_purchase/example", "console": "integratedTerminal" }, + { + // Targets iPad (UDID 00008130-001929642642001C). To switch devices, + // run `xcrun xctrace list devices` and replace the UDID below. + // -tl:off + -v:n + MlaunchVerbosity=4 + MlaunchExtraArgs surface + // per-stage install progress (AFC byte transfer, codesign, launch). + // The leading `cd` re-asserts cwd because `bash -lc` reloads the + // login profile, which can move the shell out of cwd before + // dotnet runs. + "name": "๐ŸŸฃ MAUI IAP: iOS", + "type": "node", + "request": "launch", + "runtimeExecutable": "bash", + "runtimeArgs": ["-lc", "cd \"${workspaceFolder}/libraries/maui-iap/example/OpenIap.Maui.Example\" && dotnet build OpenIap.Maui.Example.csproj -t:Run -f net9.0-ios -p:RuntimeIdentifier=ios-arm64 -p:_DeviceName=:v2:udid=00008130-001929642642001C -tl:off -v:n -p:MlaunchVerbosity=4 -p:MlaunchExtraArgs=\"-v -v -v\""], + "cwd": "${workspaceFolder}/libraries/maui-iap/example/OpenIap.Maui.Example", + "console": "integratedTerminal" + }, + { + // -tl:off + -v:n โ€” same fix as iOS. The terminal logger collapses + // every MSBuild target into a rolling counter; turning it off makes + // _CompileToDalvik / _InstallApk / etc. stream line-by-line. + "name": "๐ŸŸฃ MAUI IAP: Android", + "type": "node", + "request": "launch", + "runtimeExecutable": "bash", + "runtimeArgs": ["-lc", "cd \"${workspaceFolder}/libraries/maui-iap/example/OpenIap.Maui.Example\" && (adb uninstall dev.hyo.openiap.maui.example || true) && dotnet build OpenIap.Maui.Example.csproj -t:Run -f net9.0-android -tl:off -v:n"], + "cwd": "${workspaceFolder}/libraries/maui-iap/example/OpenIap.Maui.Example", + "console": "integratedTerminal" + }, { "name": "๐ŸŽฎ Godot IAP: Open in Editor", "type": "node", @@ -122,18 +172,6 @@ "runtimeArgs": ["-lc", "adb uninstall dev.hyo.martie || true && ./gradlew :example:composeApp:installDebug && adb shell am start -n dev.hyo.martie/.MainActivity"], "cwd": "${workspaceFolder}/libraries/kmp-iap", "console": "integratedTerminal" - }, - { - "name": "๐Ÿงฐ Kit: Dev (Vite + Hono + Convex)", - "type": "node", - "request": "launch", - "runtimeExecutable": "bash", - "runtimeArgs": [ - "-lc", - "npx --yes kill-port 3000 5173 || true && ([ -d ../../node_modules ] || (echo '๐Ÿ“ฆ Installing workspace dependencies...' && cd ../.. && bun install)) && trap 'kill 0' INT TERM EXIT && (bun run dev:convex & bun run dev:server & bun run dev; wait)" - ], - "cwd": "${workspaceFolder}/packages/kit", - "console": "integratedTerminal" } ] } diff --git a/packages/kit/convex/products/asc.ts b/packages/kit/convex/products/asc.ts index a1f47782..d204a162 100644 --- a/packages/kit/convex/products/asc.ts +++ b/packages/kit/convex/products/asc.ts @@ -37,14 +37,37 @@ async function resolveAscCredentials( project: Awaited>, options: { detailedErrors?: boolean } = {}, ): Promise { - // Resolve as a *pair* โ€” never mix the new ASC Issuer ID with the - // legacy Server API Key ID (or vice versa). If only one of the - // new fields is populated the operator is mid-migration; in that - // case fall back to the legacy pair entirely so we don't sign a - // request with mismatched identifiers Apple will reject as 401. - const useAsc = project.iosAscIssuerId && project.iosAscKeyId; + // Apple uses ONE Issuer ID per team across both API gateways + // (App Store Server API + App Store Connect API), so the + // Settings UI deliberately exposes a single shared Issuer ID + // input that writes to `iosAppStoreIssuerId` โ€” `iosAscIssuerId` + // is never populated through the UI and only exists for + // backwards-compat with the brief window when both were + // separate inputs. + // + // The Key IDs are NOT shared: `iosAppStoreKeyId` is the In-App + // Purchase key (receipt verification) and `iosAscKeyId` is the + // App Store Connect API Team / Individual key (catalog + // management). They authenticate against different gateways and + // every Apple-issued key has a unique 10-char id. + // + // Pair-resolution rule: if `iosAscKeyId` is set, sign with the + // ASC pair (issuer falls back to the shared `iosAppStoreIssuerId` + // when `iosAscIssuerId` is missing). If `iosAscKeyId` is missing, + // fall back to the legacy single-slot Server API pair so projects + // mid-migration still work โ€” `call()` surfaces a wrong-kind 401 + // hint when Apple rejects a Server-API key on an ASC endpoint. + // + // Earlier the gate required BOTH `iosAscIssuerId` AND + // `iosAscKeyId` to be set, which never happened in production + // (UI doesn't expose the Issuer field). The fallback then sent + // the JWT with `kid: iosAppStoreKeyId` (Server API key id) but + // signed with the ASC private key, and Apple rejected every + // request with a 401 across all production deployments + // (LukasB-DEV's report on PR #127). + const useAsc = !!project.iosAscKeyId; const issuerId = useAsc - ? project.iosAscIssuerId + ? (project.iosAscIssuerId ?? project.iosAppStoreIssuerId) : project.iosAppStoreIssuerId; const keyId = useAsc ? project.iosAscKeyId : project.iosAppStoreKeyId; if (!issuerId || !keyId) { diff --git a/packages/kit/src/pages/auth/organization/project/products.tsx b/packages/kit/src/pages/auth/organization/project/products.tsx index 0961633e..cd66f582 100644 --- a/packages/kit/src/pages/auth/organization/project/products.tsx +++ b/packages/kit/src/pages/auth/organization/project/products.tsx @@ -54,14 +54,38 @@ export default function ProjectProducts() { // Sync state now lives in `productSyncJobs` and is read reactively // via `getActiveSyncJob` per platform โ€” the worker writes progress - // back to the row, so the dashboard re-renders without polling. The - // local `lastShownJobId*` refs gate the completion toast so a - // succeeded job only toasts once even if the row updates again - // (e.g. on dismiss). - const lastShownJobIdRef = useRef>({ + // back to the row, so the dashboard re-renders without polling. + // + // Toast policy: only fire a completion toast for jobs the operator + // *actively triggered in this mounted session*. We track the + // previous status per platform; a toast fires only on the + // transition `running/queued โ†’ succeeded/failed`, never on the + // first observed status. That way revisiting the page (where the + // first observation is already terminal) shows the result banner + // but does NOT pop a stale toast for a sync the operator didn't + // just run. + // Pull the field types straight off `SyncJob` (= `Doc<"productSyncJobs">`) + // so the snapshot stays in lockstep with the schema โ€” adding a new + // status literal in `convex/schema.ts` automatically widens the + // local type instead of silently drifting (Gemini SSOT review on + // PR #128). + type JobStatusSnapshot = { + jobId: SyncJob["_id"]; + status: SyncJob["status"]; + }; + const prevJobStatusRef = useRef< + Record<"IOS" | "Android", JobStatusSnapshot | null> + >({ IOS: null, Android: null, }); + // Job ids the operator triggered FROM THIS MOUNT (Sync / Dry-run / + // Reset clicks). Result banner + completion toast both gate on + // this so a stale terminal job from a previous session โ€” left + // over after a code edit / HMR reload / page revisit โ€” doesn't + // re-surface as if a sync had just happened. Reset on remount so + // the gate is automatic and never sticky. + const sessionTriggeredJobIdsRef = useRef>(new Set()); // The draft form holds every field the push-sync flow consumes. // Optional fields are stored as empty strings here and converted to // `undefined` on submit so an unfilled price doesn't end up @@ -86,25 +110,41 @@ export default function ProjectProducts() { }; }, [products]); - // Show success/failure toast exactly once when a job transitions to - // a terminal state. The lastShownJobIdRef guard prevents the toast - // from re-firing on subsequent reactive updates of the same row. + // Toast on the running โ†’ terminal transition only. + // + // Earlier versions used a "shown jobIds" set, which fired on + // every fresh mount because the ref reset to empty โ€” landing on + // the page with a pre-existing terminal job re-toasted it every + // time. The transition rule means: the very first observation + // of a job (no matter its status) just records state without + // toasting; subsequent observations fire only when the status + // crossed from non-terminal to terminal. useEffect(() => { for (const platform of ["IOS", "Android"] as const) { const job = platform === "IOS" ? iosJob : androidJob; if (!job) continue; + const prev = prevJobStatusRef.current[platform]; const terminal = job.status === "succeeded" || job.status === "failed"; + // Update the snapshot before deciding whether to toast โ€” so + // even if we don't toast (initial observation, dismissed, or + // unchanged status) we still track the latest state. + prevJobStatusRef.current[platform] = { + jobId: job._id, + status: job.status, + }; if (!terminal) continue; - // Once the operator has dismissed a terminal job, the row's - // `progress.phase` flips to "dismissed". The result banner - // already gates on this; the toast effect needs the same - // gate or it re-fires the success/failure toast on every - // subsequent page reload (because `lastShownJobIdRef` is - // in-memory and resets) until the pruner deletes the row - // (CodeRabbit review on PR #127). if (job.progress.phase === "dismissed") continue; - if (lastShownJobIdRef.current[platform] === job._id) continue; - lastShownJobIdRef.current[platform] = job._id; + // Initial observation (no prev snapshot) OR a new jobId we've + // never seen โ†’ don't toast. We only toast for the same jobId + // when the previous render saw it in a non-terminal state. + if (!prev || prev.jobId !== job._id) continue; + if (prev.status !== "queued" && prev.status !== "running") continue; + // Belt-and-braces: only toast for jobs the operator triggered + // FROM THIS MOUNT. Without this gate a sync started in another + // tab that completes while this tab is open would also pop a + // toast here, which the operator would read as "did I just + // run that?". + if (!sessionTriggeredJobIdsRef.current.has(job._id)) continue; const label = platform === "IOS" ? "App Store Connect" : "Play Console"; const result = job.result; if (job.status === "succeeded" && result) { @@ -206,12 +246,13 @@ export default function ProjectProducts() { const label = platform === "IOS" ? "App Store Connect" : "Play Console"; const dryRun = options?.dryRun === true; try { - const { deduped } = await enqueueSync({ + const { jobId, deduped } = await enqueueSync({ apiKey: project.apiKey, platform, direction: "both", ...(dryRun ? { dryRun: true } : {}), }); + sessionTriggeredJobIdsRef.current.add(jobId); if (deduped) { toast.message(`${label} sync already running`, { duration: 4_000 }); } else { @@ -233,11 +274,12 @@ export default function ProjectProducts() { if (isActive) return; const label = platform === "IOS" ? "App Store Connect" : "Play Console"; try { - const { deduped } = await enqueueSync({ + const { jobId, deduped } = await enqueueSync({ apiKey: project.apiKey, platform, direction: "purge-local", }); + sessionTriggeredJobIdsRef.current.add(jobId); if (deduped) { toast.message(`${label} reset already running`, { duration: 4_000 }); } else { @@ -466,6 +508,9 @@ export default function ProjectProducts() { platform="IOS" rows={grouped.ios} job={iosJob ?? null} + triggeredInSession={ + !!iosJob?._id && sessionTriggeredJobIdsRef.current.has(iosJob._id) + } onSync={() => { void onSync("IOS"); }} @@ -486,6 +531,10 @@ export default function ProjectProducts() { platform="Android" rows={grouped.android} job={androidJob ?? null} + triggeredInSession={ + !!androidJob?._id && + sessionTriggeredJobIdsRef.current.has(androidJob._id) + } onSync={() => { void onSync("Android"); }} @@ -622,15 +671,18 @@ function formatIsoDuration(iso: string): string { // Reorder a flat product list so subscriptions sharing the same ASC // `subscriptionGroupName` are visually clustered under a single -// "Subscription Group ยท {name}" header row. One-time products (no -// group) and rows missing group metadata pass through unchanged at -// the bottom โ€” we don't want to invent a synthetic group label for -// non-subscriptions or for legacy rows synced before group capture -// landed. Original ordering is preserved within each cluster. +// "Subscription Group ยท {name}" header row. One-time products +// (Consumable / NonConsumable) and any row missing group metadata +// fall into an "Other products" cluster rendered after the +// subscription groups so the section breaks are explicit โ€” without +// the second header, consumables visually inherited the previous +// "Subscription Group" header and looked like part of it. +// Original ordering is preserved within each cluster. function groupRowsByHierarchy( rows: Array, ): Array< | { kind: "groupHeader"; id: string; name: string } + | { kind: "otherHeader"; id: string } | { kind: "row"; row: ProductRow } > { const buckets = new Map>(); @@ -650,6 +702,7 @@ function groupRowsByHierarchy( } const out: Array< | { kind: "groupHeader"; id: string; name: string } + | { kind: "otherHeader"; id: string } | { kind: "row"; row: ProductRow } > = []; for (const name of groupOrder) { @@ -658,6 +711,14 @@ function groupRowsByHierarchy( out.push({ kind: "row", row }); } } + // Only emit the "Other products" delimiter when at least one + // subscription group is also rendering โ€” when there are no + // groups, the table is just a flat list and the extra header + // is noise. With at least one group above, the explicit + // delimiter is what visually closes the group section. + if (groupOrder.length > 0 && ungrouped.length > 0) { + out.push({ kind: "otherHeader", id: "other-products" }); + } for (const row of ungrouped) { out.push({ kind: "row", row }); } @@ -868,6 +929,7 @@ function ProductGroup({ platform, rows, job, + triggeredInSession, onSync, onDryRun, onPurge, @@ -877,6 +939,7 @@ function ProductGroup({ platform: "IOS" | "Android"; rows: Array; job: SyncJob | null; + triggeredInSession: boolean; onSync: () => void; onDryRun?: () => void; onPurge: () => void; @@ -887,7 +950,11 @@ function ProductGroup({ const isActive = job?.status === "queued" || job?.status === "running"; const isTerminal = job?.status === "succeeded" || job?.status === "failed"; const dismissed = job?.progress.phase === "dismissed"; - const showResult = isTerminal && !dismissed; + // Result banner only surfaces for jobs the operator triggered + // FROM THIS MOUNT โ€” stale terminal jobs from prior sessions + // (HMR reload, page revisit) stay hidden so the operator can't + // mistake them for a sync that just ran. + const showResult = isTerminal && !dismissed && triggeredInSession; const [purgeOpen, setPurgeOpen] = useState(false); return (
@@ -1015,13 +1082,37 @@ function ProductGroup({ entry.kind === "groupHeader" ? ( + + + + Subscription Group + + + ยท + + + {entry.name} + + + + + ) : entry.kind === "otherHeader" ? ( + - Subscription Group ยท {entry.name} + + Other products + ) : (