From 584384487c1294e6aa6e9859706b507b1bf35972 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 17 Jul 2026 20:20:22 +0200 Subject: [PATCH] fix(vk): route the Wallet public-offer link out of the Mini App WebView 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. --- ui/src/lib/links.test.ts | 49 +++++++++++++++++++++++++++++++++++- ui/src/lib/links.ts | 17 ++++++++++++- ui/src/screens/Wallet.svelte | 4 +-- 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/ui/src/lib/links.test.ts b/ui/src/lib/links.test.ts index cb72f30..02184cd 100644 --- a/ui/src/lib/links.test.ts +++ b/ui/src/lib/links.test.ts @@ -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(); + }); +}); diff --git a/ui/src/lib/links.ts b/ui/src/lib/links.ts index c520e09..f7d4281 100644 --- a/ui/src/lib/links.ts +++ b/ui/src/lib/links.ts @@ -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 diff --git a/ui/src/screens/Wallet.svelte b/ui/src/screens/Wallet.svelte index e3f5ce9..2b5c2c8 100644 --- a/ui/src/screens/Wallet.svelte +++ b/ui/src/screens/Wallet.svelte @@ -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}

- {t('wallet.offer')} + {t('wallet.offer')}

{:else if !loading}

{t('wallet.empty')}

-- 2.52.0