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