diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 451a7ad..db427f8 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -114,6 +114,10 @@ The native Android app (`ANDROID_PLAN.md`) is a Capacitor 8 wrapper of the `ui` eyeball per-variant tiles. - **Durable Playwright MCP servers** (chromium + webkit) exist for UI inspection (there's a plugin-config gotcha in wiring them). +- **The `playwright test` runner can't fetch browsers in this sandbox** — `playwright install` dies + with `EBADF` against the Playwright CDN (blocked network), even with the sandbox off. Run the e2e in + **CI** (the `ui` job installs chromium+webkit), or drive a state live through the **Playwright MCP** + browser against a local `vite --mode mock` server for visual verification. - **`docker run -p ...` boot tests fail from the shell** (published ports unreachable in this env). Use **testcontainers** for container-backed tests. - **Distroless images run as UID 65532 (nonroot).** Bind-mounted TLS keys must be **0644** (not 0600) diff --git a/ANDROID_PLAN.md b/ANDROID_PLAN.md index 4876a05..0312637 100644 --- a/ANDROID_PLAN.md +++ b/ANDROID_PLAN.md @@ -71,7 +71,17 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif **Launcher icon:** a **temporary** placeholder is in place — `ui/assets/icon.png` (the maskable «Э» brand mark upscaled 512→1024) → `pnpm android:assets` regenerated the Android launcher/adaptive icons + splashes. Superseded by the icon rebrand below. -- **B–G — pending.** +- **B. Native web-build correctness — ✅ DONE & verified (2026-07-12).** New `ui/src/lib/origin.ts` + `gatewayOrigin()`; the two `location.origin` landmines fixed (`game/Game.svelte` export/share URL, + `screens/Wallet.svelte` site-root) — `transport.ts:32` already resolves via `VITE_GATEWAY_URL`, left + as-is. Service worker skipped on the native channel (`lib/pwa.svelte.ts`). Payments hidden in the MVP + via new `purchasesHidden()` (`lib/distribution.ts`: folds `VITE_PAYMENTS_DISABLED` + the GP flag, plus + a `?nopay` mock force); the `Wallet.svelte` buy tab shows a neutral pointer-free note + (`wallet.purchasesSoon`) for the RuStore MVP, RuStore stub kept GP-only. Native env types in + `vite-env.d.ts`. `svelte-check` 0 errors, `vitest` 590 passed, web + native `vite build` clean; + `?nopay`/`?gp` wallet states verified live via the Playwright MCP browser (the e2e runner can't fetch + browsers in this sandbox — the states are covered by `e2e/wallet.spec.ts`, which CI runs). +- **C–G — pending.** Open logistics (not code): **mandatory icon rebrand (future)** — author ONE vector master and generate *every* icon from it (web `favicon.svg` / PWA `icon-*` / maskable, Android adaptive, future iOS). Today @@ -232,7 +242,7 @@ is: scaffold → native-correct → gate → offline-first → CI → docs → r **Done when:** `npx cap sync android` succeeds against a real `pnpm build`, and (from `ui/android/`) `./gradlew assembleDebug` produces a debug APK that installs and cold-launches to the login screen. -### B. Native web-build correctness (file:// origin) +### B. Native web-build correctness (file:// origin) — ✅ DONE 1. **One origin helper** — new `ui/src/lib/origin.ts`: ```ts @@ -264,8 +274,13 @@ is: scaffold → native-correct → gate → offline-first → CI → docs → r } ``` Switch the wallet/purchase consumers from `isGooglePlayBuild()` to `purchasesHidden()` where they - hide the buy actions (grep `isGooglePlayBuild` under `ui/src`). Keep the "go to RuStore" stub - `isGooglePlayBuild()`-only. Add `VITE_PAYMENTS_DISABLED?: string`, `VITE_STORE_URL?: string`, + hide the buy actions (the sole consumer is `Wallet.svelte`). Keep the "go to RuStore" stub + `isGooglePlayBuild()`-only. **As built (owner decision):** in the RuStore MVP (`purchasesHidden() && + !isGooglePlayBuild()`) the buy tab shows a neutral, pointer-free note — new i18n key + `wallet.purchasesSoon`, `data-testid="purchases-hidden"` — not an empty tab and not a store link; the + same note can replace the RuStore stub in the later Google Play anti-steering variant. + `purchasesHidden()` also carries a mock-only `?nopay` force (mirrors `?gp`) so the e2e drives the + state without a separate build. Add `VITE_PAYMENTS_DISABLED?: string`, `VITE_STORE_URL?: string`, `VITE_DICT_VERSION?: string` to `ui/src/vite-env.d.ts`. 4. **Native env matrix** — see Build & env. diff --git a/ui/e2e/wallet.spec.ts b/ui/e2e/wallet.spec.ts index 1651bc0..c10935e 100644 --- a/ui/e2e/wallet.spec.ts +++ b/ui/e2e/wallet.spec.ts @@ -105,6 +105,22 @@ test('wallet: the Google Play build hides the money purchases behind the RuStore await expect(page.locator('[data-kind="value"]').first().getByTestId('buy')).toBeVisible(); }); +test('wallet: the native MVP hides the money purchases behind a neutral note (no store pointer)', async ({ page }) => { + await page.goto('/?nopay'); + await page.getByRole('button', { name: /guest/i }).click(); + await expect(page.getByText('Your turn')).toBeVisible(); + await openWallet(page); + + // Buy chips: the chip pack is gone; a neutral note shows and — unlike the Google Play build — it + // carries no RuStore (or any store) pointer. + await expect(page.getByTestId('purchases-hidden')).toBeVisible(); + await expect(page.getByTestId('gp-stub')).toHaveCount(0); + await expect(page.locator('[data-kind="pack"]')).toHaveCount(0); + // Spending earned chips still works — the values live in the Spend tab. + await page.getByTestId('tab-spend').click(); + await expect(page.locator('[data-kind="value"]').first().getByTestId('buy')).toBeVisible(); +}); + test('wallet: a web spend that would draw store chips confirms with a warning, then completes', async ({ page }) => { await loginLobby(page); await openWallet(page); diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index f2ab090..a7d6a4c 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -28,6 +28,7 @@ import { alphabetLetters, hasAlphabet } from '../lib/alphabet'; import { hintsLeft, hintGateRemainingMs, hintLockMinutes, HINT_GATE_MS } from '../lib/hints'; import { downloadUrl, shareOrDownloadGcg, shareUrlAsFile } from '../lib/share'; + import { gatewayOrigin } from '../lib/origin'; import { insideVK, vkAndroidWebView, vkCopyText, vkDownloadFile, vkPlatform, vkShowImages } from '../lib/vk'; import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache'; import { patchLobbyGame } from '../lib/lobbycache'; @@ -1211,7 +1212,7 @@ const dateLocale = typeof navigator !== 'undefined' ? (navigator.language ?? '') : ''; const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? ''; const { path, filename } = await gateway.exportUrl(id, kind, dateLocale, labels, timeZone); - const url = new URL(path, location.origin).href; + const url = new URL(path, gatewayOrigin()).href; const mime = kind === 'png' ? 'image/png' : 'text/plain'; if (insideTelegram() && !tgShareSheet && telegramDownloadFile(url, filename)) return; if (insideVK()) { diff --git a/ui/src/lib/distribution.test.ts b/ui/src/lib/distribution.test.ts index a429e35..ba9d72a 100644 --- a/ui/src/lib/distribution.test.ts +++ b/ui/src/lib/distribution.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest'; -import { isGooglePlayBuild } from './distribution'; +import { afterEach, describe, it, expect, vi } from 'vitest'; +import { isGooglePlayBuild, purchasesHidden } from './distribution'; describe('isGooglePlayBuild', () => { it('is false by default (no VITE_GP_BUILD flag, not the mock ?gp force)', () => { @@ -8,3 +8,21 @@ describe('isGooglePlayBuild', () => { expect(isGooglePlayBuild()).toBe(false); }); }); + +describe('purchasesHidden', () => { + afterEach(() => vi.unstubAllEnvs()); + + it('is false by default (the build sells normally)', () => { + expect(purchasesHidden()).toBe(false); + }); + + it('is true for the native MVP that defers billing (VITE_PAYMENTS_DISABLED)', () => { + vi.stubEnv('VITE_PAYMENTS_DISABLED', '1'); + expect(purchasesHidden()).toBe(true); + }); + + it('is true for the Google Play build (folds isGooglePlayBuild)', () => { + vi.stubEnv('VITE_GP_BUILD', '1'); + expect(purchasesHidden()).toBe(true); + }); +}); diff --git a/ui/src/lib/distribution.ts b/ui/src/lib/distribution.ts index e7b10a0..4ddcef9 100644 --- a/ui/src/lib/distribution.ts +++ b/ui/src/lib/distribution.ts @@ -1,7 +1,9 @@ -// Distribution channel of the native build. Google Play forbids selling in-app currency through -// an external gate, so the Google Play build hides the purchase actions and shows a stub pointing -// the user to the RuStore build; every other build (web, VK, Telegram, RuStore native) sells -// normally. The channel is a build-time flag the Google Play build sets, not a runtime guess. +// Distribution flags for the native builds. Two independent build-time flags hide the in-app-currency +// purchase actions: VITE_GP_BUILD marks the Google Play build (Google forbids selling in-app currency +// through an external gate — it hides the actions and shows a stub pointing at the RuStore build), and +// VITE_PAYMENTS_DISABLED marks the thin native MVP that defers store billing (it hides the actions with +// no store pointer). Every other build (web, VK, Telegram, a billing-enabled RuStore native build) sells +// normally. purchasesHidden() folds both; isGooglePlayBuild() stays separate for the RuStore-pointer stub. /** * isGooglePlayBuild reports whether this is the Google Play native build, where in-app-currency @@ -19,3 +21,22 @@ export function isGooglePlayBuild(): boolean { } return (import.meta.env as Record).VITE_GP_BUILD === '1'; } + +/** + * purchasesHidden reports whether this build hides the in-app-currency purchase actions. It is true + * for the Google Play build (external-payment policy — see isGooglePlayBuild) and for the thin native + * MVP that defers store billing (the build-time flag VITE_PAYMENTS_DISABLED set to "1"). Every other + * build sells normally. Under the mock e2e it can be forced on with a `?nopay` query parameter so the + * hidden-purchases state is exercisable without a separate build. + */ +export function purchasesHidden(): boolean { + if (isGooglePlayBuild()) return true; + if ( + import.meta.env.MODE === 'mock' && + typeof window !== 'undefined' && + new URLSearchParams(window.location.search).has('nopay') + ) { + return true; + } + return (import.meta.env as Record).VITE_PAYMENTS_DISABLED === '1'; +} diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index d3a50de..67dad18 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -252,6 +252,7 @@ export const en = { 'wallet.iosBlockedLink': 'other version', 'wallet.iosBlockedPost': ' of the game.', 'wallet.gpStub': 'To buy chips, install the RuStore build.', + 'wallet.purchasesSoon': 'Purchases will be available in a future update.', 'wallet.cur.RUB': '₽', 'wallet.cur.VOTE': 'votes', 'wallet.cur.XTR': '⭐', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index b177751..6c5c5d7 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -252,6 +252,7 @@ export const ru: Record = { 'wallet.iosBlockedLink': 'другой версией', 'wallet.iosBlockedPost': ' игры.', 'wallet.gpStub': 'Чтобы покупать фишки, установите версию из RuStore.', + 'wallet.purchasesSoon': 'Покупки появятся в одном из следующих обновлений.', 'wallet.cur.RUB': '₽', 'wallet.cur.VOTE': 'голосов', 'wallet.cur.XTR': '⭐', diff --git a/ui/src/lib/origin.test.ts b/ui/src/lib/origin.test.ts new file mode 100644 index 0000000..5fe23d5 --- /dev/null +++ b/ui/src/lib/origin.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { gatewayOrigin } from './origin'; + +describe('gatewayOrigin', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it('returns the configured VITE_GATEWAY_URL (the packaged native build)', () => { + vi.stubEnv('VITE_GATEWAY_URL', 'https://erudit-game.ru'); + // Even with a page origin present, the configured gateway wins (native loads from a local origin). + vi.stubGlobal('location', { origin: 'https://localhost' }); + expect(gatewayOrigin()).toBe('https://erudit-game.ru'); + }); + + it('falls back to the page origin when VITE_GATEWAY_URL is unset (web/mock)', () => { + vi.stubEnv('VITE_GATEWAY_URL', ''); + vi.stubGlobal('location', { origin: 'https://erudit-game.ru' }); + expect(gatewayOrigin()).toBe('https://erudit-game.ru'); + }); + + it('returns an empty string with neither a configured gateway nor a location (server-side)', () => { + vi.stubEnv('VITE_GATEWAY_URL', ''); + // No location stub — location is undefined in the node test env, so the path degrades to relative. + expect(gatewayOrigin()).toBe(''); + }); +}); diff --git a/ui/src/lib/origin.ts b/ui/src/lib/origin.ts new file mode 100644 index 0000000..644fc75 --- /dev/null +++ b/ui/src/lib/origin.ts @@ -0,0 +1,17 @@ +// The absolute origin the client talks to. A packaged native build (Capacitor) loads the SPA from +// its own local origin (https://localhost / file://), so an absolute URL built from location.origin +// would point at the app itself, not the gateway. The native build therefore sets VITE_GATEWAY_URL +// to the real gateway; web/mock leave it empty and fall back to the page origin. Centralised so every +// absolute-URL construction resolves identically. transport.ts computes the same fallback inline for +// the Connect base URL and is intentionally left untouched. + +/** + * gatewayOrigin returns the absolute origin every client build talks to: the configured + * VITE_GATEWAY_URL when set (the native build), otherwise the current page origin. It returns an + * empty string when there is no location (server-side), so callers appending a path degrade to a + * relative URL. + */ +export function gatewayOrigin(): string { + const configured = import.meta.env.VITE_GATEWAY_URL ?? ''; + return configured || (typeof location !== 'undefined' ? location.origin : ''); +} diff --git a/ui/src/lib/pwa.svelte.ts b/ui/src/lib/pwa.svelte.ts index 0357a6e..472b7bf 100644 --- a/ui/src/lib/pwa.svelte.ts +++ b/ui/src/lib/pwa.svelte.ts @@ -5,6 +5,7 @@ import { isIosSafari, isStandalone, resolveInstallMode, type InstallMode } from './pwa'; import { insideTelegram } from './telegram'; import { insideVK } from './vk'; +import { clientChannel } from './channel'; /** inMiniApp reports whether the app runs inside a Telegram or VK Mini App webview, where a web * install makes no sense (the platform manages its own launcher). Kept here, out of the pure @@ -79,12 +80,16 @@ export async function promptInstall(): Promise { * registerServiceWorker registers the app-shell service worker (built from ui/src/sw.ts to * dist/sw.js by vite-plugin-pwa): it satisfies Chromium's installability requirement and precaches * the shell + assets so an installed PWA cold-launches offline. Web-only: skipped in the mock build - * (a real worker would perturb the Playwright run) and inside a Telegram/VK Mini App. Best-effort — - * a failed registration only means the app is not installable and cannot launch offline. + * (a real worker would perturb the Playwright run), inside a Telegram/VK Mini App, and inside the + * Capacitor native shell (the bundle is already local, and a worker would risk serving stale assets + * across store updates). Best-effort — a failed registration only means the app is not installable + * and cannot launch offline. */ export function registerServiceWorker(): void { if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return; if (import.meta.env.MODE === 'mock') return; if (inMiniApp()) return; + const ch = clientChannel(); + if (ch === 'android' || ch === 'ios') return; void navigator.serviceWorker.register('sw.js').catch(() => {}); } diff --git a/ui/src/screens/Wallet.svelte b/ui/src/screens/Wallet.svelte index 1dbbe42..ff4c1c5 100644 --- a/ui/src/screens/Wallet.svelte +++ b/ui/src/screens/Wallet.svelte @@ -6,8 +6,9 @@ import { normalizeSubtype } from '../lib/platform'; import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte'; import { executionContext, formatAmount, needsWebSpendWarning, spendableChips, type SpendContext } from '../lib/wallet'; - import { isGooglePlayBuild } from '../lib/distribution'; + import { isGooglePlayBuild, purchasesHidden } from '../lib/distribution'; import { onExternalLinkClick, openExternalUrl } from '../lib/links'; + import { gatewayOrigin } from '../lib/origin'; import { vkPlatform, vkShowOrderBox } from '../lib/vk'; import { telegramOpenInvoice } from '../lib/telegram'; import { rewardedReady, showRewarded } from '../lib/ads'; @@ -16,8 +17,9 @@ // The Wallet section: a compact chip balance, the active benefits, and the storefront. The store // splits into "buy chips" (chip packs, funded with money — a provider redirect) and "spend chips" - // (values bought with chips — an instant exchange). On the Google Play build the money purchases - // are hidden behind a RuStore stub (store policy), while spending already-earned chips still works. + // (values bought with chips — an instant exchange). When purchases are hidden — the Google Play + // build (behind a RuStore stub, store policy) or the native MVP that defers billing (a neutral + // note) — the money purchases disappear while spending already-earned chips still works. let wallet = $state(null); let catalog = $state(null); @@ -38,13 +40,16 @@ ); const gpBuild = isGooglePlayBuild(); + // Money purchases are hidden in the Google Play build (policy) and in the native MVP that defers + // billing; the RuStore-pointer stub shows only for the former (gpBuild). + const noPurchases = purchasesHidden(); // Money purchases are not permitted inside the VK iOS app (Apple ToS); the pack CTA is shown // muted and taps explain why, rather than hiding the storefront. VK's device family is not on the // client platformSubtype (server-derived) — read it from the signed vk_platform launch param. const purchaseBlocked = context === 'vk' && normalizeSubtype(vkPlatform()) === 'ios'; // The "other version" link points at this deployment's own site root (the landing lists every // platform), so it follows prod vs the contour without a hardcoded host. - const siteRoot = typeof location !== 'undefined' ? `${location.origin}/` : '/'; + const siteRoot = `${gatewayOrigin()}/`; const values = $derived(catalog?.products.filter((p) => p.kind === 'value') ?? []); const packs = $derived(catalog?.products.filter((p) => p.kind === 'pack') ?? []); @@ -237,8 +242,12 @@ > {/if} - {#if gpBuild} -

{t('wallet.gpStub')}

+ {#if noPurchases} + {#if gpBuild} +

{t('wallet.gpStub')}

+ {:else} +

{t('wallet.purchasesSoon')}

+ {/if} {:else} {#each packs as p (p.productId)}
diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts index 02e18e8..f607cb6 100644 --- a/ui/src/vite-env.d.ts +++ b/ui/src/vite-env.d.ts @@ -2,8 +2,15 @@ /// interface ImportMetaEnv { - /** Base URL of the gateway Connect endpoint. Empty in dev (same-origin proxy). */ + /** Base URL of the gateway Connect endpoint. Empty in dev (same-origin proxy); set to the real + * gateway origin in a packaged native build. */ readonly VITE_GATEWAY_URL?: string; + /** "1" hides the in-app-currency purchase actions in the thin native MVP that defers store billing. */ + readonly VITE_PAYMENTS_DISABLED?: string; + /** Store listing URL the native update overlay opens for its "update" action (e.g. the RuStore page). */ + readonly VITE_STORE_URL?: string; + /** Bundled-dictionary version the native offline path requests; matches the packaged DAWG files. */ + readonly VITE_DICT_VERSION?: string; } interface ImportMeta {