fix(connection): открывать deep-link приложения на iOS через top-level переход, а не iframe

На iOS кастомная схема (incy://, happ://, …) запускается только top-level
навигацией, привязанной к жесту пользователя. Запуск из скрытого iframe iOS
молча игнорирует — приложение не открывается даже если установлено, поэтому
кнопка «Добавить подписку» на iPhone (Safari и TG-миниапп) не срабатывала.
Заметнее всего на INCY: это iOS-first клиент, его аудитория целиком на iPhone.

openAppScheme и public/miniapp/redirect.html теперь на iOS используют
window.location.href (вызов синхронный, внутри обработчика клика — жест
сохраняется), а contained-iframe остаётся для Android in-app браузеров, где
top-level переход к нерешаемой схеме рисует полноэкранный
net::ERR_UNKNOWN_URL_SCHEME и стирает fallback-UI (Telegram bug #654272).

Добавлен юнит-тест на выбор пути: iOS и iPadOS-as-Mac → location.href,
Android и desktop → iframe.
This commit is contained in:
airp0wer
2026-07-21 10:36:42 +03:00
parent f04675b249
commit 03aa2e6312
3 changed files with 153 additions and 24 deletions

View File

@@ -148,22 +148,35 @@
const manualBtn = document.getElementById('manualBtn'); const manualBtn = document.getElementById('manualBtn');
manualBtn.href = url; manualBtn.href = url;
// Attempt to launch the app WITHOUT a top-level navigation. A direct // iPadOS 13+ reports itself as desktop Safari, so a touch-capable "Mac"
// `location.href = scheme` paints a full-page net::ERR_UNKNOWN_URL_SCHEME // is treated as an iPad.
// inside Android in-app browsers (Telegram/Yandex/…) and silently fails on function isIOS() {
// iOS — hiding the manual button (Telegram bug #654272). A hidden iframe is var ua = navigator.userAgent || '';
// contained: the app opens if installed, otherwise the failure stays inside if (/iP(ad|hone|od)/.test(ua)) return true;
// the (invisible) frame and the button below stays usable. return navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1;
try { }
var frame = document.createElement('iframe');
frame.style.display = 'none'; // Attempt to auto-launch the app. Two opposite fixes:
frame.src = url; // - iOS launches a custom scheme only from a top-level navigation; a hidden
document.body.appendChild(frame); // iframe is a silent no-op there, so use location.href.
setTimeout(function() { // - Android in-app browsers (Telegram/Yandex/…) paint a full-page
try { frame.parentNode.removeChild(frame); } catch (e) {} // net::ERR_UNKNOWN_URL_SCHEME on a top-level nav to an unresolved scheme
}, 2000); // (Telegram bug #654272), so a contained hidden iframe is used instead.
} catch (e) { // Either way the manual "Open app" button below stays as the reliable path.
if (isIOS()) {
window.location.href = url; window.location.href = url;
} else {
try {
var frame = document.createElement('iframe');
frame.style.display = 'none';
frame.src = url;
document.body.appendChild(frame);
setTimeout(function() {
try { frame.parentNode.removeChild(frame); } catch (e) {}
}, 2000);
} catch (e) {
window.location.href = url;
}
} }
// Programmatic opening is unreliable in in-app browsers, so surface the // Programmatic opening is unreliable in in-app browsers, so surface the

View File

@@ -0,0 +1,90 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { openAppScheme } from './openAppScheme';
// openAppScheme touches window/document/navigator; the suite runs in a plain node
// environment, so stub just enough of the DOM to observe which launch path is taken.
interface FakeIframe {
style: { display: string };
src: string;
remove: () => void;
}
function setup(nav: { userAgent?: string; platform?: string; maxTouchPoints?: number }) {
const location = { href: '' };
const createdIframes: FakeIframe[] = [];
vi.stubGlobal('navigator', {
userAgent: nav.userAgent ?? '',
platform: nav.platform ?? '',
maxTouchPoints: nav.maxTouchPoints ?? 0,
});
vi.stubGlobal('window', {
location,
setTimeout: () => 0,
});
vi.stubGlobal('document', {
body: { appendChild: () => undefined },
createElement: () => {
const iframe: FakeIframe = { style: { display: '' }, src: '', remove: () => undefined };
createdIframes.push(iframe);
return iframe;
},
});
return { location, createdIframes };
}
describe('openAppScheme', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('navigates directly for http(s) links (no iframe)', () => {
const { location, createdIframes } = setup({ userAgent: 'Mozilla/5.0 (Android)' });
openAppScheme('https://example.com/sub');
expect(location.href).toBe('https://example.com/sub');
expect(createdIframes).toHaveLength(0);
});
it('opens a custom scheme via top-level navigation on iOS (iPhone), not via iframe', () => {
const { location, createdIframes } = setup({
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Safari/605.1',
});
openAppScheme('incy://import/https://sp.example.com/abc');
// iOS ignores iframe scheme launches — must be a real top-level navigation.
expect(location.href).toBe('incy://import/https://sp.example.com/abc');
expect(createdIframes).toHaveLength(0);
});
it('treats touch-capable iPadOS (reports as Mac) as iOS', () => {
const { location, createdIframes } = setup({
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) Safari/605.1',
platform: 'MacIntel',
maxTouchPoints: 5,
});
openAppScheme('happ://add/https://sp.example.com/abc');
expect(location.href).toBe('happ://add/https://sp.example.com/abc');
expect(createdIframes).toHaveLength(0);
});
it('uses a contained iframe for custom schemes on Android (keeps page alive)', () => {
const { location, createdIframes } = setup({
userAgent: 'Mozilla/5.0 (Linux; Android 13) Chrome/120',
});
openAppScheme('incy://import/https://sp.example.com/abc');
// Android in-app browsers would paint a full-page error on a top-level nav.
expect(location.href).toBe('');
expect(createdIframes).toHaveLength(1);
expect(createdIframes[0].src).toBe('incy://import/https://sp.example.com/abc');
});
it('treats a non-touch Mac (desktop Safari) as non-iOS', () => {
const { createdIframes } = setup({
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) Safari/605.1',
platform: 'MacIntel',
maxTouchPoints: 0,
});
openAppScheme('incy://import/https://sp.example.com/abc');
expect(createdIframes).toHaveLength(1);
});
});

View File

@@ -1,17 +1,36 @@
/** /**
* Launch a custom-scheme app deep link (happ://, v2rayng://, vless://, …) without * Launch a custom-scheme app deep link (happ://, incy://, v2rayng://, vless://, …)
* crashing the page inside in-app browsers. * without crashing the page inside in-app browsers, and — crucially — in a way that
* actually opens the app on iOS.
* *
* Why not `window.location.href = scheme`: a programmatic top-level navigation to a * Two failure modes have to be worked around, and they need opposite fixes:
* scheme the WebView can't resolve renders a full-page error — on Android in-app *
* browsers (Telegram/Yandex/…) `net::ERR_UNKNOWN_URL_SCHEME`, on iOS it silently does * - Android in-app browsers (Telegram/Yandex/…): a top-level `location.href = scheme`
* nothing — which destroys the fallback UI (Telegram bug #654272). A hidden <iframe> * the WebView can't resolve paints a full-page `net::ERR_UNKNOWN_URL_SCHEME`, wiping
* navigation is contained: if the app is installed the OS intercepts it; if not, the * the fallback UI (Telegram bug #654272). A hidden <iframe> navigation is contained:
* failure stays inside the (invisible) frame and our page — with its manual "Open app" * the OS intercepts it if the app is installed, otherwise the failure stays inside the
* link — survives so the user can tap it (a real user gesture is the reliable trigger). * invisible frame and our manual "Open app" link survives.
*
* - iOS (Safari + WKWebView): the OS launches a custom scheme ONLY from a top-level
* navigation tied to a user gesture. A hidden-iframe `src` to a custom scheme is a
* silent no-op — the app never opens even when installed. So on iOS we must use
* `location.href`. openAppScheme is called synchronously from the button's click
* handler, so the gesture is still active here and the installed app launches.
* *
* http(s) links are normal navigations and are passed straight to location.href. * http(s) links are normal navigations and are passed straight to location.href.
*/ */
/**
* True on iOS/iPadOS. iPadOS 13+ reports itself as desktop Safari, so a touch-capable
* "Mac" is treated as an iPad.
*/
function isIOS(): boolean {
if (typeof navigator === 'undefined') return false;
const ua = navigator.userAgent || '';
if (/iP(ad|hone|od)/.test(ua)) return true;
return navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1;
}
export function openAppScheme(url: string): void { export function openAppScheme(url: string): void {
const isHttp = /^https?:\/\//i.test(url); const isHttp = /^https?:\/\//i.test(url);
if (isHttp) { if (isHttp) {
@@ -19,6 +38,13 @@ export function openAppScheme(url: string): void {
return; return;
} }
// iOS ignores custom-scheme launches from an iframe — it needs a gesture-bound
// top-level navigation, which this is (called synchronously from the click handler).
if (isIOS()) {
window.location.href = url;
return;
}
try { try {
const iframe = document.createElement('iframe'); const iframe = document.createElement('iframe');
iframe.style.display = 'none'; iframe.style.display = 'none';