fix(vk): route the Wallet public-offer link out of the Mini App WebView
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 12s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m56s

The "Public offer" link in the Wallet pointed at the same-origin static
/offer/ page with a plain target=_blank anchor and no click handler. The
Android VK client ignores target=_blank and navigates its own WebView to
the page, stranding the player with no way back to the game (iOS opens a
child browser, so it was unaffected). The existing onExternalLinkClick
router could not help: it escapes only cross-origin links, and /offer/ is
same-origin (a static legal page, not an SPA route).

Add onInAppPageLinkClick, which routes a same-origin, non-SPA page out
through the host escape hatch (Telegram openLink, VK away.php) while
bypassing the cross-origin gate, and attach it to the offer link. Outside
a Mini App host it is a no-op and the anchor's own target=_blank stands.

The "Rules" (About) and ad-banner links already route correctly
(cross-origin + a handler) and are left unchanged.
This commit is contained in:
Ilia Denisov
2026-07-17 20:20:22 +02:00
parent 0ba8fb6514
commit 584384487c
3 changed files with 66 additions and 4 deletions
+48 -1
View File
@@ -1,5 +1,14 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { openExternalUrl } from './links';
import { onInAppPageLinkClick, openExternalUrl } from './links';
// clickInAppPage fires onInAppPageLinkClick on a fake anchor whose (already absolute) href is the
// same-origin /offer/ page, and reports whether the click's default navigation was suppressed.
function clickInAppPage(href = 'https://app.example/offer/') {
const preventDefault = vi.fn();
const a = { href, target: '_blank' };
onInAppPageLinkClick({ target: { closest: () => a }, preventDefault } as unknown as MouseEvent);
return preventDefault;
}
describe('openExternalUrl', () => {
afterEach(() => vi.unstubAllGlobals());
@@ -27,3 +36,41 @@ describe('openExternalUrl', () => {
expect(open).toHaveBeenCalledWith('https://telegram.me/some_bot', '_blank');
});
});
describe('onInAppPageLinkClick', () => {
afterEach(() => vi.unstubAllGlobals());
it('routes the same-origin offer page out through the VK away redirect', () => {
const open = vi.fn();
vi.stubGlobal('location', {
pathname: '/vk/',
search: '?vk_user_id=1&vk_platform=mobile_android&sign=x',
href: 'https://app.example/vk/',
origin: 'https://app.example',
});
vi.stubGlobal('window', { open });
const preventDefault = clickInAppPage();
// Same-origin, yet still escaped: the cross-origin gate is bypassed for a non-SPA page.
expect(open).toHaveBeenCalledWith(
`https://vk.com/away.php?to=${encodeURIComponent('https://app.example/offer/')}`,
'_blank',
);
expect(preventDefault).toHaveBeenCalled();
});
it('routes the offer page through the Telegram in-app browser', () => {
const openLink = vi.fn();
vi.stubGlobal('window', { Telegram: { WebApp: { openLink } } });
const preventDefault = clickInAppPage();
expect(openLink).toHaveBeenCalledWith('https://app.example/offer/');
expect(preventDefault).toHaveBeenCalled();
});
it('leaves the anchor to its own navigation outside a Mini App host', () => {
const open = vi.fn();
vi.stubGlobal('window', { open });
const preventDefault = clickInAppPage();
expect(open).not.toHaveBeenCalled();
expect(preventDefault).not.toHaveBeenCalled();
});
});
+16 -1
View File
@@ -4,7 +4,7 @@
// below route an external URL through the platform's escape hatch (Telegram's openLink, VK's
// away.php redirect) and leave the anchor's own navigation / window.open in effect elsewhere.
import { routeExternalLinkInTelegram } from './telegram';
import { routeExternalLinkInTelegram, telegramOpenExternalLink } from './telegram';
import { routeExternalLinkInVK, vkOpenExternalUrl } from './vk';
/**
@@ -20,6 +20,21 @@ export function onExternalLinkClick(e: MouseEvent): void {
if (routeExternalLinkInTelegram(a) || routeExternalLinkInVK(a)) e.preventDefault();
}
/**
* onInAppPageLinkClick is the anchor click handler for a link to a standalone, same-origin page
* that is not part of the SPA — the static legal /offer/ page. Such a page shares the app's origin
* yet must still leave the Mini App WebView: opened in place it replaces the game, and the Android
* VK client then strands the user there with no way back. Unlike onExternalLinkClick it therefore
* skips the cross-origin test (the page is same-origin by design) and routes the click straight
* through the Telegram in-app browser (openLink) or VK's away.php redirect. Outside those hosts it
* is a no-op and the anchor's own target=_blank opens the page in a new tab / child browser.
*/
export function onInAppPageLinkClick(e: MouseEvent): void {
const a = (e.target as HTMLElement | null)?.closest('a');
if (!a) return;
if (telegramOpenExternalLink(a.href) || vkOpenExternalUrl(a.href)) e.preventDefault();
}
/**
* openExternalUrl opens an external URL from code (no anchor to click): through VK's external
* redirect inside the Android VK client, else a plain new tab. Telegram callers try their native
+2 -2
View File
@@ -7,7 +7,7 @@
import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte';
import { executionContext, formatAmount, needsWebSpendWarning, spendableChips, type SpendContext } from '../lib/wallet';
import { isGooglePlayBuild, purchasesHidden } from '../lib/distribution';
import { onExternalLinkClick, openExternalUrl } from '../lib/links';
import { onExternalLinkClick, onInAppPageLinkClick, openExternalUrl } from '../lib/links';
import { gatewayOrigin } from '../lib/origin';
import { vkPlatform, vkShowOrderBox } from '../lib/vk';
import { telegramOpenInvoice } from '../lib/telegram';
@@ -274,7 +274,7 @@
{/each}
{#if packs.length > 0}
<p class="offer" data-testid="offer">
<a href="/offer/" target="_blank" rel="noopener noreferrer">{t('wallet.offer')}</a>
<a href="/offer/" target="_blank" rel="noopener noreferrer" onclick={onInAppPageLinkClick}>{t('wallet.offer')}</a>
</p>
{:else if !loading}
<p class="empty">{t('wallet.empty')}</p>