Тело подписки, директивы и вложенный routing
Парсинг списка узлов
SubscriptionParser.parse принимает plaintext после HTTP. Приложение ожидает тело подписки в открытом виде (TLS защищает канал); отдельной обфускации поверх TLS на стороне клиента нет.
Поддерживается:
- Строки со share-ссылками — по одной на строку; распознаются префиксы
vless://,vmess://,trojan://,ss://,hy2:///hysteria2://. Имя узла — фрагмент после#(URL-decoded). - Base64 — если весь текст похож на Base64, он декодируется; затем снова пробуются JSON и построчный разбор.
- 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-ссылки. - Свободный текст — если построчно узлов нет, из текста regex-ом вытягиваются подстроки
vless://,vmess://,trojan://,ss://,hy2://,hysteria2://(без дубликатов по полной строке).
Ошибки:
emptyPayload— пусто после trim.noRecognizedLinks— нет ни одной из поддерживаемых ссылок.receivedMarkupInsteadOfSubscription— ответ начинается как HTML (<!DOCTYPE,<html…). Клиент ожидает сырой текст подписки, не веб-страницу.
Важно: для перечисленных протоколов туннель строится, если соответствующий outbound поддерживается текущим Xray в LibXray — см. protocols-and-vless-params.md.
Директивы подписки
Клиент читает один и тот же набор key: value-пар двумя путями:
- Как HTTP-заголовки ответа на запрос подписки (имена регистронезависимы).
- Как
#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
Поддерживаемые формы (типичны для панелей подписок):
- Сырой Base64 — всё значение — Base64 JSON профиля (опционально префикс
base64:). Семантикаonadd: после upsert профиль становится активным, routing включается. - Полная ссылка — любая схема, в том числе без схемы
://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 после двоеточия.routing—RoutingSubscriptionAnnouncementParser.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_profileannounce,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:
- «Обновление подписки «X»…» — progress-баннер, показывается ещё до сетевого ответа (для ручного рефреша).
- «Добавлен / обновлён [и активирован] профиль маршрутизации «Y»» — по событию
.routingImported. - «Обновление геофайлов…» — по событию
.geoDownloadStarted. - «Геофайлы обновлены…» — по событию
.geoDownloadFinished(если реально что-то скачалось; если все файлы уже свежие, шаг пропускается). - «Подписка «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), если что-то реально изменилось.