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:
Ilia Denisov
2026-07-12 14:58:35 +02:00
parent aaf2825260
commit de003e862a
13 changed files with 163 additions and 20 deletions
+20 -2
View File
@@ -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);
});
});
+25 -4
View File
@@ -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';
}
+1
View File
@@ -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': '⭐',
+1
View File
@@ -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': '⭐',
+28
View File
@@ -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('');
});
});
+17
View File
@@ -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 : '');
}
+7 -2
View File
@@ -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(() => {});
}