feat(android): resolve gateway origin on native, skip SW, hide MVP purchases
Make the web SPA behave correctly inside the Capacitor native shell, where the bundle loads from a local origin: - New lib/origin.ts gatewayOrigin(): absolute URLs resolve to VITE_GATEWAY_URL on native (the finished-game export/share URL in Game.svelte and the Wallet site-root link), falling back to the page origin on web. transport.ts already resolved via VITE_GATEWAY_URL and is left as-is. - Skip the PWA service worker on the native channel (the assets are already local; a worker would risk serving stale content across store updates). - Hide the money-purchase UI in the MVP: new distribution.purchasesHidden() folds VITE_PAYMENTS_DISABLED and the Google Play flag. The Wallet "buy" tab shows a neutral, pointer-free note (wallet.purchasesSoon) for the RuStore MVP and keeps the RuStore stub Google-Play-only. A ?nopay mock force mirrors ?gp for the e2e. - Native env types in vite-env.d.ts. Client-only, contour-safe: no wire/proto/schema change; web/VK/Telegram unchanged. Tests: origin + purchasesHidden unit tests, a ?nopay wallet e2e. svelte-check clean, vitest green, web + native vite build clean.
This commit is contained in:
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string | undefined>).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<string, string | undefined>).VITE_PAYMENTS_DISABLED === '1';
|
||||
}
|
||||
|
||||
@@ -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': '⭐',
|
||||
|
||||
@@ -252,6 +252,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'wallet.iosBlockedLink': 'другой версией',
|
||||
'wallet.iosBlockedPost': ' игры.',
|
||||
'wallet.gpStub': 'Чтобы покупать фишки, установите версию из RuStore.',
|
||||
'wallet.purchasesSoon': 'Покупки появятся в одном из следующих обновлений.',
|
||||
'wallet.cur.RUB': '₽',
|
||||
'wallet.cur.VOTE': 'голосов',
|
||||
'wallet.cur.XTR': '⭐',
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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 : '');
|
||||
}
|
||||
@@ -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<void> {
|
||||
* 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(() => {});
|
||||
}
|
||||
|
||||
@@ -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<Wallet | null>(null);
|
||||
let catalog = $state<Catalog | null>(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 @@
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if gpBuild}
|
||||
<p class="stub" data-testid="gp-stub">{t('wallet.gpStub')}</p>
|
||||
{#if noPurchases}
|
||||
{#if gpBuild}
|
||||
<p class="stub" data-testid="gp-stub">{t('wallet.gpStub')}</p>
|
||||
{:else}
|
||||
<p class="stub" data-testid="purchases-hidden">{t('wallet.purchasesSoon')}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
{#each packs as p (p.productId)}
|
||||
<div class="row product" data-testid="product" data-kind="pack" data-pid={p.productId}>
|
||||
|
||||
Vendored
+8
-1
@@ -2,8 +2,15 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user