К основному содержимому
SharX Connect

Тело подписки, директивы и вложенный routing

Парсинг списка узлов

SubscriptionParser.parse принимает plaintext после HTTP. Приложение ожидает тело подписки в открытом виде (TLS защищает канал); отдельной обфускации поверх TLS на стороне клиента нет.

Поддерживается:

  1. Строки со share-ссылками — по одной на строку; распознаются префиксы vless://, vmess://, trojan://, ss://, hy2:// / hysteria2://. Имя узла — фрагмент после # (URL-decoded).
  2. Base64 — если весь текст похож на Base64, он декодируется; затем снова пробуются JSON и построчный разбор.
  3. JSON — если корень или элементы массива — конфиги sing-box/xray с outbounds, выбирается основной proxy-outbound (tag: proxy или первый из поддерживаемых протоколов). Поле протокола может называться protocol (Xray) или type (sing-box). Объект приводится к форме Xray для LibXray (sing-box Trojan с полями server / transport конвертируется; готовый xray-outbound передаётся как есть). Если вместо outbounds только строки со ссылками — они собираются листьями JSON и снова разбираются как share-ссылки.
  4. Свободный текст — если построчно узлов нет, из текста regex-ом вытягиваются подстроки vless://, vmess://, trojan://, ss://, hy2://, hysteria2:// (без дубликатов по полной строке).

Ошибки:

  • emptyPayload — пусто после trim.
  • noRecognizedLinks — нет ни одной из поддерживаемых ссылок.
  • receivedMarkupInsteadOfSubscription — ответ начинается как HTML (<!DOCTYPE, <html …). Клиент ожидает сырой текст подписки, не веб-страницу.

Важно: для перечисленных протоколов туннель строится, если соответствующий outbound поддерживается текущим Xray в LibXray — см. protocols-and-vless-params.md.


Директивы подписки

Клиент читает один и тот же набор key: value-пар двумя путями:

  1. Как HTTP-заголовки ответа на запрос подписки (имена регистронезависимы).
  2. Как #key: value / #key value в начале тела подписки — до первой share-ссылки / base64-блока. Сканируется только leading-блок: после первой не-# строки разбор директив останавливается.

Оба способа равноправны, но HTTP-заголовок побеждает body-директиву при совпадении ключа (SubscriptionRefreshService.refresh делает merged = body.merging(headers) { $1 }). Это потому, что на заголовок труднее повлиять через CDN-кэш / шаблоны на панели — он чётче отражает состояние сервера.

Панель → клиент, тема оформления: панель может прислать sharx-color-scheme и sharx-accent-palette в ответе (HTTP-заголовки или ведущие строки #… в теле). Клиент записывает их в ключи UserDefaults, которые использует Theme.swift. Список допустимых значений — subscription-http.md, раздел «Панель → клиент: тема интерфейса».

Реестр поддерживаемых ключей живёт в SubscriptionBodyDirectives.knownKeys (lowercase):

Ключ Тип значения Что делает Куда попадает
profile-title string или base64:<utf8> Переопределяет имя подписки в UI (карточка, экран настроек). Если пусто/не прислано — используется локальное имя, введённое пользователем. SubscriptionEntity.remoteDisplayName
subscription-userinfo сырая строка значения (формат: upload=…; download=…; total=…; expire=…) Сохраняется как есть для отображения трафика и срока. Парсится на стороне UI. SubscriptionEntity.subscriptionUserInfoRaw
profile-update-interval целое число часов Рекомендация панели по частоте автообновления. Итоговый интервал = max(userInterval, panelHours × 3600) — сервер может только увеличить частоту, но не уменьшить пользовательский выбор ниже 5 минут. SubscriptionEntity.panelProfileUpdateIntervalHours
support-url URL Включает в карточке кнопку «Поддержка» (Telegram/web/mailto…). Применяются обычные правила sanitize: схема должна быть не пустой. SubscriptionEntity.supportURLString
profile-web-page-url URL Кнопка «Сайт» в карточке — на страницу панели/кабинета пользователя. SubscriptionEntity.webPageURLString
announce string или base64:<utf8> Короткое объявление панели, отображается в карточке. Текст обрезается до 200 символов. SubscriptionEntity.announceText
routing-enable bool (распространённый в панелях формат) Панель может выключить app-side routing для подписки (false / 0 / no / off / любой не-true token). Если true — routing включён. Если ключ не пришёл — значение nil (приложение использует свой глобальный флаг). SubscriptionEntity.panelRoutingEnabled + RoutingProfileStore.setRoutingEnabled
routing Импорт routing (см. ниже) Импорт RoutingProfile из ответа подписки: парсер RoutingSubscriptionAnnouncementParser, декодирование RoutingAnnounceDecoder. При успешном разборе перекрывает ссылки в теле plaintext и вложенный routing в JSON. RoutingProfileStore.upsert (+ активация профиля и routing при режиме onadd)
hide-source-url bool (распространённый в панелях формат) true → на экране «Настройки подписки» не показывать секцию с URL источника. Пользователь не увидит и не скопирует URL. Удалить подписку целиком всё ещё можно. Если ключ не пришёл — значение сохраняется прежним (не сбрасывается на refresh). SubscriptionEntity.hideSourceURL
sharx-color-scheme токен Панель задаёт тему приложения: automatic, light, dark. Неверный токен игнорируется; нет ключа — настройка не меняется. UserDefaults sharxColorSchemePreference
sharx-accent-palette токен Панель задаёт палитру: system, aurora, midnight, sakura, sunset, graphite, matrix, crimson. Неверный токен игнорируется; нет ключа — не меняется. UserDefaults sharxAccentPalette

Заголовок / директива routing

Поддерживаемые формы (типичны для панелей подписок):

  1. Сырой Base64 — всё значение — Base64 JSON профиля (опционально префикс base64:). Семантика onadd: после upsert профиль становится активным, routing включается.
  2. Полная ссылка — любая схема, в том числе без схемы ://routing/…, с путём /routing/add/<base64> или /routing/onadd/<base64>:
    • add — только upsert (активный профиль не меняется, если список не был пуст — как RoutingProfileStore.upsert).
    • onadd — upsert, затем этот профиль активируется и routing включается.

Сегмент Base64 не должен содержать символ / внутри первого path-сегмента (используйте URL-safe Base64), иначе разбор URL ломается — то же ограничение, что у ссылок в deep link.

Строка routing в plaintext-теле

В любом месте тела подписки (в том числе после строк с узлами) ищутся подстроки с …://routing/add/… или …://routing/onadd/… (или ://routing/… без схемы); если встретилось несколько раз, побеждает последнее вхождение. Учитывается только если merged-ключ routing отсутствует или не декодируется. Порядок: routing в заголовке / #routingпоследняя ссылка в телевложенный JSON (SubscriptionRoutingExtractor.extract).

Парсинг значений

  • Bool (routing-enable, hide-source-url)SubscriptionBodyDirectives.parseBool. true / 1 / yes / on (case-insensitive, с trim) → true. Любое другое не-пустое значение → false. Пустое/отсутствует → nil (не трогать состояние).
  • base64:<...> prefix (profile-title, announce) — SubscriptionBodyDirectives.decodeMaybeBase64. Снимает префикс, декодирует UTF-8; при ошибке возвращает nil (значение игнорируется). Префикс регистронезависим, допускается любой whitespace после двоеточия.
  • routingRoutingSubscriptionAnnouncementParser.parse (см. подраздел про заголовок выше).
  • Интервал (profile-update-interval) — SubscriptionFetcher.parseProfileUpdateIntervalHours: строго Int > 0, иначе nil.

Пример body с leading-директивами

#profile-title: base64:0JzQvtGPINC/0L7QtNC/0LjRgdC60LA=
#subscription-userinfo: upload=123; download=456; total=10737418240; expire=1735689600
#profile-update-interval: 12
#support-url: https://t.me/my_support_bot
#profile-web-page-url: https://panel.example.com/cabinet
#announce: base64:0J3QsCDQvdC10LTQtdC70Y8g0YLQtdGF0YDQsNCx0L7RgtGL
#routing-enable: true
#hide-source-url: true
#sharx-color-scheme: dark
#sharx-accent-palette: aurora
vless://uuid@example.com:443?security=reality&type=tcp#de-node
vless://uuid@example.com:443?security=reality&type=tcp#nl-node

Пример эквивалентного HTTP-ответа

HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
profile-title: base64:0JzQvtGPINC/0L7QtNC/0LjRgdC60LA=
subscription-userinfo: upload=123; download=456; total=10737418240; expire=1735689600
profile-update-interval: 12
support-url: https://t.me/my_support_bot
profile-web-page-url: https://panel.example.com/cabinet
announce: base64:0J3QsCDQvdC10LTQtdC70Y8g0YLQtdGF0YDQsNCx0L7RgtGL
routing-enable: true
hide-source-url: true
sharx-color-scheme: dark
sharx-accent-palette: aurora
 
vless://uuid@example.com:443?security=reality&type=tcp#de-node
vless://uuid@example.com:443?security=reality&type=tcp#nl-node

Имена заголовков и совместимость

Набор имён, разделители (key: value или key value с одним пробелом) и семантика bool совпадают с тем, что обычно шлют панели подписок. Ключи hide-source-url, sharx-color-scheme и sharx-accent-palette специфичны для SharX Connect; панель может отдавать их вместе с остальными: клиенты без поддержки игнорируют неизвестные ключи.


Вложенный профиль маршрутизации в JSON

Если merged-ключ routing отсутствует или невалиден и в plaintext нет ссылок /routing/add/ / /routing/onadd/, SubscriptionRoutingExtractor.extract ищет профиль маршрутизации внутри JSON тела подписки, не заменяя парсер узлов. Импорт только из JSON использует семантику add (без принудительной активации).

Кандидаты ключей верхнего уровня (регистр имени не важен):

  • routing, routingProfile, Routing, routing_profile
  • announce, routing_announce, routingAnnounce

Значение может быть:

  • Строка Base64 — декодируется через RoutingAnnounceDecoder (тот же формат, что payload в sharx://routing/add/…).
  • Строка-URL — например sharx://routing/onadd/<base64> (те же правила, что у HTTP-заголовка routing).
  • Вложенный JSON-объект — декодируется как RoutingProfile.

Если в корне JSON есть поле GlobalProxy / globalProxy, весь объект трактуется как RoutingProfile.

После успешного извлечения профиль передаётся в RoutingProfileStore.upsert (имя пустое подставляется из отображаемого имени подписки при необходимости).


Автозагрузка гео-файлов

Когда в результате refresh был импортирован / обновлён RoutingProfile, SubscriptionRefreshService вызывает GeoDataDownloader.update(for:). Тот скачивает geoip.dat и geosite.dat по URL из полей Geoipurl / Geositeurl в AppGroupPaths.geoDataDirectoryURL/geo/ (общих для всех профилей — PacketTunnel читает оттуда при построении xray-конфига).

Перекачивается не на каждый refresh, а только когда одно из условий:

  • файла физически нет;
  • URL поменялся относительно того, что был при прошлой загрузке;
  • поле LastUpdated профиля стало больше записанного в метаданных (Unix timestamp или ISO-8601 — сравниваются численно / по дате).

Метаданные хранятся в …/geo/metadata.json (версионированная структура, ключ — RoutingProfile.id).

Результат (GeoDataDownloader.Update) возвращается наверх через SubscriptionRefreshOutcome.geoUpdate, и UI HomeConnectView показывает короткий зелёный баннер об обновлении.


Обратная связь пользователю

SubscriptionRefreshService.refresh принимает callback onEvent: (SubscriptionRefreshEvent) -> Void, а также возвращает итоговый SubscriptionRefreshOutcome. Главный экран использует их для последовательной очереди баннеров в StatusBannerCenter:

  1. «Обновление подписки «X»…» — progress-баннер, показывается ещё до сетевого ответа (для ручного рефреша).
  2. «Добавлен / обновлён [и активирован] профиль маршрутизации «Y»» — по событию .routingImported.
  3. «Обновление геофайлов…» — по событию .geoDownloadStarted.
  4. «Геофайлы обновлены…» — по событию .geoDownloadFinished (если реально что-то скачалось; если все файлы уже свежие, шаг пропускается).
  5. «Подписка «X» обновлена · серверов: N» — финальный success-баннер по возврату refresh.

События из .refresh могут выпадать в произвольном сочетании, StatusBannerCenter поддерживает очередь и minDuration для каждого элемента, чтобы короткие шаги не мелькали за доли секунды. Ошибки (showError) моментально выбрасывают очередь и показывают красный баннер, который сам уходит через 5 с.

Сам баннер — верхний «выпадающий» overlay (StatusBannerOverlay). Анимация появления/ухода — move(edge: .top) + opacity + scale(anchor: .top), обёрнута в spring(response: 0.45, dampingFraction: 0.78).

Автообновление подписки по интервалу использует тот же pipeline, но без первичного progress-баннера (announceStart: false): пользователь увидит только content-full success-сообщения (импорт routing / обновление geo), если что-то реально изменилось.


См. также

English version