From c188e684657f5f6fc7b23998e87155ba3b2fbfa0 Mon Sep 17 00:00:00 2001 From: kewldan <40865857+kewldan@users.noreply.github.com> Date: Sun, 12 Jul 2026 01:33:38 +0300 Subject: [PATCH 01/23] =?UTF-8?q?build(docker):=20=D0=BD=D0=B5=20=D0=B3?= =?UTF-8?q?=D0=BE=D0=BD=D1=8F=D1=82=D1=8C=20tsc=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B5=20=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=B0=20=E2=80=94=20=D1=82=D0=B0=D0=B9=D0=BF=D1=87=D0=B5?= =?UTF-8?q?=D0=BA=20=D1=83=D0=B6=D0=B5=20=D0=B4=D0=B5=D0=BB=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 7 +++++-- package.json | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index de296d3..7380a52 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,8 +24,10 @@ ENV VITE_TELEGRAM_BOT_USERNAME=$VITE_TELEGRAM_BOT_USERNAME ENV VITE_APP_NAME=$VITE_APP_NAME ENV VITE_APP_LOGO=$VITE_APP_LOGO -# Build the application -RUN npm run build +# Build the application. Type-check намеренно пропущен: tsc --noEmit уже +# гоняется CI на каждый PR (lint.yml), образ собирается из проверенного +# коммита - повторная проверка стоила бы ~10s на каждую сборку. +RUN npm run build:docker # Stage 2: Serve with Nginx FROM nginx:alpine @@ -40,3 +42,4 @@ EXPOSE 80 # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1 + diff --git a/package.json b/package.json index f9298a5..48a2184 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", + "build:docker": "vite build", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"", From 8af8232e2f4adfb3a4b4a31855219442e531601e Mon Sep 17 00:00:00 2001 From: kewldan <40865857+kewldan@users.noreply.github.com> Date: Sun, 12 Jul 2026 01:56:52 +0300 Subject: [PATCH 02/23] =?UTF-8?q?fix(build):=20=D0=B2=D0=B5=D1=80=D0=BD?= =?UTF-8?q?=D1=83=D1=82=D1=8C=20build:docker,=20=D0=BF=D0=BE=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D1=8F=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D0=BC=D0=B5=D1=80=D0=B6=D0=B5=20dev?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 7d9fc8a..0047cc3 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", + "build:docker": "vite build", "lint": "biome lint .", "lint:fix": "biome lint --write .", "format": "biome format --write .", From f04675b2498abbbfb41ba48e0dd8cf7ee3a5a0a1 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 20 Jul 2026 21:41:08 +0300 Subject: [PATCH 03/23] =?UTF-8?q?fix(support):=20=D0=BD=D0=B5=20=D1=81?= =?UTF-8?q?=D0=BA=D0=BB=D0=B5=D0=B8=D0=B2=D0=B0=D1=82=D1=8C=20t.me=20?= =?UTF-8?q?=D1=81=D0=BE=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B2=D0=BD=D0=B5=D1=88=D0=BD=D0=B8=D0=B9=20?= =?UTF-8?q?=D1=85=D0=B5=D0=BB=D0=BF=D0=B4=D0=B5=D1=81=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SUPPORT_USERNAME на бэке принимает и @username, и произвольный URL. Кабинет же во всех трёх ветках клеил `https://t.me/${support_username}`, из-за чего внешний хелпдеск превращался в https://t.me/https://help.example.com и не открывался. Бэк теперь отдаёт контакт разрезолвленным (support_url + contact_is_telegram) — клеить на клиенте нечего. Логика вынесена в resolveSupportContact: три копии склейки (profile, fallback и карточка режима both) заменены одним вызовом. Старый бэк не шлёт support_url, для него сохранено прежнее поведение — новый фронт не ломается на неподнятом бэкенде. Заодно ушли три non-null assertion на support_username. --- src/pages/Support.tsx | 48 +++++++++------------- src/types/index.ts | 2 + src/utils/supportContact.test.ts | 69 ++++++++++++++++++++++++++++++++ src/utils/supportContact.ts | 36 +++++++++++++++++ 4 files changed, 125 insertions(+), 30 deletions(-) create mode 100644 src/utils/supportContact.test.ts create mode 100644 src/utils/supportContact.ts diff --git a/src/pages/Support.tsx b/src/pages/Support.tsx index 4bda888..0f44cfc 100644 --- a/src/pages/Support.tsx +++ b/src/pages/Support.tsx @@ -1,5 +1,5 @@ import { uiLocale } from '@/utils/uiLocale'; -import { useState, useRef, useEffect } from 'react'; +import { useState, useRef, useEffect, useCallback } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { motion } from 'framer-motion'; @@ -9,13 +9,14 @@ import { infoApi } from '../api/info'; import { useAuthStore } from '../store/auth'; import { logger } from '../utils/logger'; import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit'; -import type { TicketDetail } from '../types'; +import type { SupportConfig, TicketDetail } from '../types'; import { Card } from '@/components/data-display/Card'; import { Button } from '@/components/primitives/Button'; import { staggerContainer, staggerItem } from '@/components/motion/transitions'; import { ChatIcon, CloseIcon, ImageIcon, PlusIcon, SendIcon } from '@/components/icons'; import { usePlatform } from '@/platform'; import { linkifyText } from '../utils/linkify'; +import { resolveSupportContact } from '../utils/supportContact'; const log = logger.createLogger('Support'); @@ -36,6 +37,18 @@ export default function Support() { const isAdmin = useAuthStore((state) => state.isAdmin); const queryClient = useQueryClient(); const { openTelegramLink, openLink } = usePlatform(); + + const openSupportContact = useCallback( + (config: SupportConfig) => { + const target = resolveSupportContact(config); + if (target.kind === 'external') { + openLink(target.url, { tryInstantView: false }); + } else { + openTelegramLink(target.url); + } + }, + [openLink, openTelegramLink], + ); const [selectedTicket, setSelectedTicket] = useState(null); const [showCreateForm, setShowCreateForm] = useState(false); const [newTitle, setNewTitle] = useState(''); @@ -211,17 +224,7 @@ export default function Support() { buttonText: t('support.contactUs'), buttonAction: () => { log.debug('Button clicked, opening:', supportUsername); - - // Extract username without @ - const username = supportUsername.startsWith('@') - ? supportUsername.slice(1) - : supportUsername; - - const webUrl = `https://t.me/${username}`; - log.debug('Web URL:', webUrl); - - // Use platform's openTelegramLink - openTelegramLink(webUrl); + openSupportContact(supportConfig); }, }; } @@ -246,17 +249,7 @@ export default function Support() { buttonText: t('support.contactUs'), buttonAction: () => { log.debug('Fallback button clicked, opening:', supportUsername); - - // Extract username without @ - const username = supportUsername.startsWith('@') - ? supportUsername.slice(1) - : supportUsername; - - const webUrl = `https://t.me/${username}`; - log.debug('Fallback opening URL:', webUrl); - - // Use platform's openTelegramLink - openTelegramLink(webUrl); + openSupportContact(supportConfig); }, }; }; @@ -367,12 +360,7 @@ export default function Support() { diff --git a/src/types/index.ts b/src/types/index.ts index 9181884..6f10bb7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -530,6 +530,8 @@ export interface SupportConfig { support_type: 'tickets' | 'profile' | 'url' | 'both'; support_url?: string | null; support_username?: string | null; + /** Резолвнутый контакт ведёт в Telegram, а не на внешний хелпдеск. */ + contact_is_telegram?: boolean; } // Paginated response diff --git a/src/utils/supportContact.test.ts b/src/utils/supportContact.test.ts new file mode 100644 index 0000000..e8e0a5e --- /dev/null +++ b/src/utils/supportContact.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import type { SupportConfig } from '../types'; +import { resolveSupportContact } from './supportContact'; + +/** + * Кабинет склеивал `https://t.me/${support_username}` и ломал внешний хелпдеск: + * SUPPORT_USERNAME принимает и `@user`, и произвольный URL, поэтому из + * `https://help.example.com` получалось `https://t.me/https://help.example.com`. + * Бэк отдаёт контакт уже разрезолвленным — клеить на клиенте больше нечего. + */ + +const config = (overrides: Partial): SupportConfig => ({ + tickets_enabled: true, + support_type: 'both', + ...overrides, +}); + +describe('resolveSupportContact', () => { + it('внешний хелпдеск открывается как обычная ссылка, без t.me', () => { + const target = resolveSupportContact( + config({ + support_url: 'https://help.example.com', + support_username: 'https://help.example.com', + contact_is_telegram: false, + }), + ); + + expect(target).toEqual({ kind: 'external', url: 'https://help.example.com' }); + expect(target.url).not.toContain('t.me'); + }); + + it('телеграм-контакт открывается через telegram-ссылку', () => { + const target = resolveSupportContact( + config({ + support_url: 'https://t.me/help', + support_username: '@help', + contact_is_telegram: true, + }), + ); + + expect(target).toEqual({ kind: 'telegram', url: 'https://t.me/help' }); + }); + + describe('старый бэк без support_url — прежнее поведение', () => { + it.each([ + ['@help', 'https://t.me/help'], + ['help', 'https://t.me/help'], + ])('%s -> %s', (username, expected) => { + const target = resolveSupportContact(config({ support_username: username })); + + expect(target).toEqual({ kind: 'telegram', url: expected }); + }); + + it('контакт вообще не задан — дефолтный @support', () => { + expect(resolveSupportContact(config({}))).toEqual({ + kind: 'telegram', + url: 'https://t.me/support', + }); + }); + }); + + it('support_url без contact_is_telegram трактуется как телеграм', () => { + // Комбинация недостижима с текущим бэком, но деградировать надо в старое + // поведение, а не в переход по внешней ссылке. + const target = resolveSupportContact(config({ support_url: 'https://t.me/help' })); + + expect(target.kind).toBe('telegram'); + }); +}); diff --git a/src/utils/supportContact.ts b/src/utils/supportContact.ts new file mode 100644 index 0000000..07ac663 --- /dev/null +++ b/src/utils/supportContact.ts @@ -0,0 +1,36 @@ +import type { SupportConfig } from '../types'; + +/** + * Куда вести пользователя по кнопке «Написать в поддержку». + * + * Telegram-ссылку открывают через openTelegramLink, внешнюю — обычным переходом. + */ +export type SupportContactTarget = { + kind: 'telegram' | 'external'; + url: string; +}; + +/** + * Резолвит контакт поддержки из конфига. + * + * SUPPORT_USERNAME на бэке принимает и `@username`, и произвольный URL, поэтому + * бэк отдаёт уже разрезолвленные `support_url` + `contact_is_telegram`. Клиент + * раньше склеивал `https://t.me/${support_username}` — на внешнем хелпдеске это + * давало `https://t.me/https://help.example.com`, и ссылка не открывалась. + * + * Старый бэк не отдаёт `support_url`; для него сохранено прежнее поведение, + * иначе новый фронт сломался бы на неподнятом бэкенде. + */ +export function resolveSupportContact(config: SupportConfig): SupportContactTarget { + if (config.support_url) { + // contact_is_telegram отсутствует только у старого бэка, а он не шлёт и + // support_url — так что явная проверка на false, а не на truthy. + const kind = config.contact_is_telegram === false ? 'external' : 'telegram'; + return { kind, url: config.support_url }; + } + + const raw = config.support_username || '@support'; + const username = raw.startsWith('@') ? raw.slice(1) : raw; + + return { kind: 'telegram', url: `https://t.me/${username}` }; +} From ebde1f6174552ab4bef7f1fef8ef7950942781e7 Mon Sep 17 00:00:00 2001 From: airp0wer Date: Tue, 21 Jul 2026 10:28:31 +0300 Subject: [PATCH 04/23] =?UTF-8?q?fix(support):=20=D1=83=D0=B6=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=B8=D1=82=D1=8C=20resolveSupportContact=20?= =?UTF-8?q?=E2=80=94=20=D1=81=D1=85=D0=B5=D0=BC=D0=B0,=20legacy-username,?= =?UTF-8?q?=20null?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Инкремент поверх f04675b (#501 был технически перекрыт им). resolveSupportContact теперь не только не клеит t.me с внешним URL, но и отсекает битый конфиг вместо того, чтобы уводить пользователя по мусорной ссылке: 1. Валидация схемы support_url — открываем только http/https/tg, чужая схема (javascript:, data: и т.п.) больше не уходит в опенер как есть, а даёт null. 2. Валидация legacy username регуляркой ^[A-Za-z0-9_]{3,}$ — старый бэк с URL-образным SUPPORT_USERNAME больше не собирает битый t.me/https://…, URL-образное отдаём резолвить бэку. 3. null → кнопка не рендерится — при ненастроенном/битом контакте (в т.ч. ушёл дефолт @support) кнопку в Support.tsx не показываем, а не открываем молча https://t.me/support. Заодно url-ветка в ticketsDisabled и карточка both-режима ходят через resolveSupportContact — ушёл прямой openLink(support_url!) с non-null assertion, три ветки getSupportMessage схлопнуты в две (profile и fallback идентичны). --- src/pages/Support.tsx | 85 ++++++++++++++------------------ src/utils/supportContact.test.ts | 42 +++++++++++++--- src/utils/supportContact.ts | 35 +++++++++++-- 3 files changed, 103 insertions(+), 59 deletions(-) diff --git a/src/pages/Support.tsx b/src/pages/Support.tsx index 0f44cfc..90f0306 100644 --- a/src/pages/Support.tsx +++ b/src/pages/Support.tsx @@ -41,6 +41,7 @@ export default function Support() { const openSupportContact = useCallback( (config: SupportConfig) => { const target = resolveSupportContact(config); + if (!target) return; if (target.kind === 'external') { openLink(target.url, { tryInstantView: false }); } else { @@ -212,45 +213,29 @@ export default function Support() { if (supportConfig && !supportConfig.tickets_enabled) { log.debug('Tickets disabled, config:', supportConfig); + // Куда и чем открывать контакт — один резолв на весь блок. null → открывать + // нечего (пустой/битый конфиг), и кнопку тогда не рендерим вовсе. + const contact = resolveSupportContact(supportConfig); + const getSupportMessage = () => { log.debug('Getting support message for type:', supportConfig.support_type); - if (supportConfig.support_type === 'profile') { - const supportUsername = supportConfig.support_username || '@support'; - log.debug('Opening profile:', supportUsername); - return { - title: isAdmin ? t('support.ticketsDisabled') : t('support.title'), - message: t('support.contactSupport', { username: supportUsername }), - buttonText: t('support.contactUs'), - buttonAction: () => { - log.debug('Button clicked, opening:', supportUsername); - openSupportContact(supportConfig); - }, - }; - } + const title = isAdmin ? t('support.ticketsDisabled') : t('support.title'); if (supportConfig.support_type === 'url' && supportConfig.support_url) { return { - title: isAdmin ? t('support.ticketsDisabled') : t('support.title'), + title, message: t('support.useExternalLink'), buttonText: t('support.openSupport'), - buttonAction: () => { - openLink(supportConfig.support_url!, { tryInstantView: false }); - }, }; } - // Fallback: contact support (should not normally happen if config is correct) + // profile и любой fallback — контакт в телеграме const supportUsername = supportConfig.support_username || '@support'; - log.debug('Fallback: Opening profile:', supportUsername); return { - title: isAdmin ? t('support.ticketsDisabled') : t('support.title'), + title, message: t('support.contactSupport', { username: supportUsername }), buttonText: t('support.contactUs'), - buttonAction: () => { - log.debug('Fallback button clicked, opening:', supportUsername); - openSupportContact(supportConfig); - }, }; }; @@ -264,9 +249,11 @@ export default function Support() {

{supportMessage.title}

{supportMessage.message}

- + {contact && ( + + )} ); @@ -345,28 +332,30 @@ export default function Support() { {/* Contact support card for "both" mode — self-animated: mounts after the config query resolves, when the parent stagger orchestration has already finished and would leave it stuck at opacity 0 */} - {supportConfig?.support_type === 'both' && supportConfig.support_username && ( - - -
-
- + {supportConfig?.support_type === 'both' && + supportConfig.support_username && + resolveSupportContact(supportConfig) && ( + + +
+
+ +
+
+
{t('support.contactUs')}
+
{supportConfig.support_username}
+
-
-
{t('support.contactUs')}
-
{supportConfig.support_username}
-
-
- - - - )} + + + + )} {/* Tickets List */} diff --git a/src/utils/supportContact.test.ts b/src/utils/supportContact.test.ts index e8e0a5e..48cafd4 100644 --- a/src/utils/supportContact.test.ts +++ b/src/utils/supportContact.test.ts @@ -7,6 +7,10 @@ import { resolveSupportContact } from './supportContact'; * SUPPORT_USERNAME принимает и `@user`, и произвольный URL, поэтому из * `https://help.example.com` получалось `https://t.me/https://help.example.com`. * Бэк отдаёт контакт уже разрезолвленным — клеить на клиенте больше нечего. + * + * resolveSupportContact дополнительно защищается от битого конфига: чужие схемы + * (`javascript:` и т.п.) и URL-образный legacy-username не уводят в опенер, а + * возвращают null — кнопку в таком случае не рендерим. */ const config = (overrides: Partial): SupportConfig => ({ @@ -26,7 +30,7 @@ describe('resolveSupportContact', () => { ); expect(target).toEqual({ kind: 'external', url: 'https://help.example.com' }); - expect(target.url).not.toContain('t.me'); + expect(target?.url).not.toContain('t.me'); }); it('телеграм-контакт открывается через telegram-ссылку', () => { @@ -41,6 +45,24 @@ describe('resolveSupportContact', () => { expect(target).toEqual({ kind: 'telegram', url: 'https://t.me/help' }); }); + it('tg:// deep link — валидная схема', () => { + const target = resolveSupportContact( + config({ support_url: 'tg://resolve?domain=help', contact_is_telegram: true }), + ); + + expect(target).toEqual({ kind: 'telegram', url: 'tg://resolve?domain=help' }); + }); + + describe('чужие схемы в support_url отсекаются', () => { + it.each([ + 'javascript:alert(1)', + 'data:text/html,x', + 'not a url', + ])('%s -> null', (support_url) => { + expect(resolveSupportContact(config({ support_url }))).toBeNull(); + }); + }); + describe('старый бэк без support_url — прежнее поведение', () => { it.each([ ['@help', 'https://t.me/help'], @@ -51,11 +73,17 @@ describe('resolveSupportContact', () => { expect(target).toEqual({ kind: 'telegram', url: expected }); }); - it('контакт вообще не задан — дефолтный @support', () => { - expect(resolveSupportContact(config({}))).toEqual({ - kind: 'telegram', - url: 'https://t.me/support', - }); + it.each([ + 'https://help.example.com', + 'help.example.com', + 't.me/help', + ])('URL-образный legacy username (%s) не клеится в t.me — null', (support_username) => { + expect(resolveSupportContact(config({ support_username }))).toBeNull(); + }); + + it('контакт вообще не задан — кнопки нет (null, без дефолта @support)', () => { + expect(resolveSupportContact(config({}))).toBeNull(); + expect(resolveSupportContact(config({ support_username: ' ' }))).toBeNull(); }); }); @@ -64,6 +92,6 @@ describe('resolveSupportContact', () => { // поведение, а не в переход по внешней ссылке. const target = resolveSupportContact(config({ support_url: 'https://t.me/help' })); - expect(target.kind).toBe('telegram'); + expect(target?.kind).toBe('telegram'); }); }); diff --git a/src/utils/supportContact.ts b/src/utils/supportContact.ts index 07ac663..39d70f2 100644 --- a/src/utils/supportContact.ts +++ b/src/utils/supportContact.ts @@ -10,6 +10,19 @@ export type SupportContactTarget = { url: string; }; +// Открываем только то, что реально ведёт в браузер/телеграм. support_url приходит +// из админского SUPPORT_USERNAME — чужую схему (`javascript:`, `data:` и т.п.) +// нельзя пускать в опенер как есть. +const ALLOWED_SCHEMES = ['http:', 'https:', 'tg:']; + +function isAllowedScheme(url: string): boolean { + try { + return ALLOWED_SCHEMES.includes(new URL(url).protocol); + } catch { + return false; + } +} + /** * Резолвит контакт поддержки из конфига. * @@ -20,17 +33,31 @@ export type SupportContactTarget = { * * Старый бэк не отдаёт `support_url`; для него сохранено прежнее поведение, * иначе новый фронт сломался бы на неподнятом бэкенде. + * + * Возвращает `null`, когда открывать нечего (пустой/битый конфиг, чужая схема, + * URL-образный legacy username) — вызывающий в этом случае не рендерит кнопку, + * а не уводит пользователя по мусорной ссылке. */ -export function resolveSupportContact(config: SupportConfig): SupportContactTarget { - if (config.support_url) { +export function resolveSupportContact(config: SupportConfig): SupportContactTarget | null { + const serverUrl = config.support_url?.trim(); + + if (serverUrl) { + if (!isAllowedScheme(serverUrl)) return null; + // contact_is_telegram отсутствует только у старого бэка, а он не шлёт и // support_url — так что явная проверка на false, а не на truthy. const kind = config.contact_is_telegram === false ? 'external' : 'telegram'; - return { kind, url: config.support_url }; + return { kind, url: serverUrl }; } - const raw = config.support_username || '@support'; + const raw = config.support_username?.trim(); + if (!raw) return null; + const username = raw.startsWith('@') ? raw.slice(1) : raw; + // Legacy-путь: голый URL здесь дал бы `t.me/https://…`, поэтому клеим t.me + // только из настоящего юзернейма — всё URL-образное отдаём резолвить бэку. + if (!/^[A-Za-z0-9_]{3,}$/.test(username)) return null; + return { kind: 'telegram', url: `https://t.me/${username}` }; } From 03aa2e6312ae602f087d30d7134020acb6338537 Mon Sep 17 00:00:00 2001 From: airp0wer Date: Tue, 21 Jul 2026 10:36:42 +0300 Subject: [PATCH 05/23] =?UTF-8?q?fix(connection):=20=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D1=80=D1=8B=D0=B2=D0=B0=D1=82=D1=8C=20deep-link=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BD=D0=B0?= =?UTF-8?q?=20iOS=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20top-level=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D1=85=D0=BE=D0=B4,=20=D0=B0=20=D0=BD=D0=B5?= =?UTF-8?q?=20iframe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit На 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. --- public/miniapp/redirect.html | 43 ++++++++++------ src/utils/openAppScheme.test.ts | 90 +++++++++++++++++++++++++++++++++ src/utils/openAppScheme.ts | 44 ++++++++++++---- 3 files changed, 153 insertions(+), 24 deletions(-) create mode 100644 src/utils/openAppScheme.test.ts 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