diff --git a/public/miniapp/redirect.html b/public/miniapp/redirect.html index cca3db1..93fe00a 100644 --- a/public/miniapp/redirect.html +++ b/public/miniapp/redirect.html @@ -148,22 +148,35 @@ const manualBtn = document.getElementById('manualBtn'); manualBtn.href = url; - // Attempt to launch the app WITHOUT a top-level navigation. A direct - // `location.href = scheme` paints a full-page net::ERR_UNKNOWN_URL_SCHEME - // inside Android in-app browsers (Telegram/Yandex/…) and silently fails on - // iOS — hiding the manual button (Telegram bug #654272). A hidden iframe is - // contained: the app opens if installed, otherwise the failure stays inside - // the (invisible) frame and the button below stays usable. - 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) { + // iPadOS 13+ reports itself as desktop Safari, so a touch-capable "Mac" + // is treated as an iPad. + function isIOS() { + var ua = navigator.userAgent || ''; + if (/iP(ad|hone|od)/.test(ua)) return true; + return navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1; + } + + // Attempt to auto-launch the app. Two opposite fixes: + // - iOS launches a custom scheme only from a top-level navigation; a hidden + // iframe is a silent no-op there, so use location.href. + // - Android in-app browsers (Telegram/Yandex/…) paint a full-page + // net::ERR_UNKNOWN_URL_SCHEME on a top-level nav to an unresolved scheme + // (Telegram bug #654272), so a contained hidden iframe is used instead. + // Either way the manual "Open app" button below stays as the reliable path. + if (isIOS()) { 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 diff --git a/src/utils/openAppScheme.test.ts b/src/utils/openAppScheme.test.ts new file mode 100644 index 0000000..d4f3cac --- /dev/null +++ b/src/utils/openAppScheme.test.ts @@ -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); + }); +}); diff --git a/src/utils/openAppScheme.ts b/src/utils/openAppScheme.ts index 6f60bc1..2876cb0 100644 --- a/src/utils/openAppScheme.ts +++ b/src/utils/openAppScheme.ts @@ -1,17 +1,36 @@ /** - * Launch a custom-scheme app deep link (happ://, v2rayng://, vless://, …) without - * crashing the page inside in-app browsers. + * Launch a custom-scheme app deep link (happ://, incy://, v2rayng://, vless://, …) + * 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 - * 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 - * nothing — which destroys the fallback UI (Telegram bug #654272). A hidden