Subscription body, directives and embedded routing
Node list parsing
SubscriptionParser.parse consumes plaintext after HTTP. The app expects the subscription body in the clear (TLS protects the channel); there is no extra obfuscation layer on top of TLS in the client.
Supported input shapes:
- Share-link lines — one per line; recognised prefixes are
vless://,vmess://,trojan://,ss://,hy2:///hysteria2://. Node name is the URL-decoded fragment after#. - Base64 — if the whole payload looks Base64-encoded, it is decoded and the JSON / line parsers run again.
- JSON — if the root (or array elements) are sing-box / xray configs with
outbounds, the parser picks the main proxy outbound (tag: proxy, otherwise the first supported one). The protocol field can be namedprotocol(Xray) ortype(sing-box). The object is normalised to Xray form for LibXray (sing-box Trojan withserver/transportis converted; a ready xray outbound is passed through). If outbounds are missing and the JSON leaves contain share-link strings, they are harvested and re-parsed. - Free text — if no line-based nodes are found, the body is scanned with a regex for embedded
vless://,vmess://,trojan://,ss://,hy2://,hysteria2://substrings (dedup by full string).
Errors:
emptyPayload— empty after trim.noRecognizedLinks— no supported link type found.receivedMarkupInsteadOfSubscription— response starts like HTML (<!DOCTYPE,<html…). The client expects raw subscription text, not a web page.
Note: the tunnel only starts for supported protocols available in the current LibXray build — see protocols-and-vless-params.md.
Subscription directives
The client reads the same set of key: value pairs from two places:
- As HTTP response headers on the subscription response (names are case-insensitive).
- As
#key: value/#key valuelines at the top of the body — before the first share link / base64 blob. Only the leading block is scanned; once a non-#line appears, directive parsing stops.
Both paths are accepted, but the HTTP header wins over a body directive when the same key is present in both places (SubscriptionRefreshService.refresh does merged = body.merging(headers) { $1 }). Reason: the header is harder to stale via CDN caches or panel templates and reflects server state more faithfully.
Panel → client UI theme: the panel can send sharx-color-scheme and sharx-accent-palette in the response (HTTP headers or leading #… lines). The client writes them to UserDefaults keys used by Theme.swift. Details and allowed tokens — subscription-http.md (section Panel → client: UI theme).
The authoritative registry of keys lives in SubscriptionBodyDirectives.knownKeys (all lowercase):
| Key | Value type | Effect | Storage |
|---|---|---|---|
profile-title |
string or base64:<utf8> |
Overrides the subscription display name (card, settings screen). If empty / missing, the local name entered by the user is used. | SubscriptionEntity.remoteDisplayName |
subscription-userinfo |
raw value string (format: upload=…; download=…; total=…; expire=…) |
Stored as-is for traffic / expiry rendering; parsed on the UI side. | SubscriptionEntity.subscriptionUserInfoRaw |
profile-update-interval |
integer hours | Panel hint for auto-refresh cadence. Effective interval = max(userInterval, panelHours × 3600) — the panel can only slow down refreshes compared to the user setting, never push them below 5 min. |
SubscriptionEntity.panelProfileUpdateIntervalHours |
support-url |
URL | Shows a "Support" button on the card (Telegram / web / mailto …). Standard sanitisation applies: the URL must have a non-empty scheme. | SubscriptionEntity.supportURLString |
profile-web-page-url |
URL | "Website" button on the card — panel / user cabinet. | SubscriptionEntity.webPageURLString |
announce |
string or base64:<utf8> |
Short panel announcement shown on the card. Text is trimmed to 200 characters. | SubscriptionEntity.announceText |
routing-enable |
bool (common panel style) | The panel can disable app-side routing for this subscription (false / 0 / no / off / any non-true token). true enables it. Missing key → nil (the app uses its global flag). |
SubscriptionEntity.panelRoutingEnabled + RoutingProfileStore.setRoutingEnabled |
routing |
Routing import (see below) | Imports a RoutingProfile from the subscription response. Parsed by RoutingSubscriptionAnnouncementParser; decoded with RoutingAnnounceDecoder. Overrides plaintext body links and embedded JSON routing when the header / leading #routing decodes successfully. |
RoutingProfileStore.upsert (+ active routing when mode is onadd) |
hide-source-url |
bool (common panel style) | true → the "Subscription settings" screen hides the source-URL section. The user cannot see or copy the URL. Deleting the whole subscription is still available. Missing key → value is preserved (a refresh without the directive does not reset a previously hidden URL). |
SubscriptionEntity.hideSourceURL |
sharx-color-scheme |
token | Panel sets app appearance: automatic, light, dark. Invalid token ignored; missing key → no change. |
UserDefaults sharxColorSchemePreference |
sharx-accent-palette |
token | Panel sets accent palette: system, aurora, midnight, sakura, sunset, graphite, matrix, crimson. Invalid token ignored; missing key → no change. |
UserDefaults sharxAccentPalette |
routing header / #routing
Supported shapes (common among subscription panels):
- Raw Base64 — entire value is the Base64 JSON profile (optional
base64:prefix). Treated asonadd: after upsert, the profile becomes active and routing is enabled. - Full link — any scheme, including scheme-less
://routing/…, with path/routing/add/<base64>or/routing/onadd/<base64>:add— upsert only (active profile unchanged unless the list was empty — same asRoutingProfileStore.upsert).onadd— upsert, then set this profile active and turn routing on.
The Base64 segment must be URL-safe or standard Base64 without path / characters inside the first path segment (same constraint as URL-based deep links).
Routing line in plaintext body
Anywhere in the subscription body (including after node lines), a line or substring containing …://routing/add/… or …://routing/onadd/… (or ://routing/… without a scheme) is scanned; the last match wins if several appear. This is evaluated only when the merged routing key is absent or does not decode. Precedence: routing header / #routing → last body link → embedded JSON (SubscriptionRoutingExtractor.extract).
Value parsing
- Bool (
routing-enable,hide-source-url) —SubscriptionBodyDirectives.parseBool.true/1/yes/on(case-insensitive, trimmed) →true. Any other non-empty value →false. Empty / missing →nil(do not touch state). base64:<...>prefix (profile-title,announce) —SubscriptionBodyDirectives.decodeMaybeBase64. The prefix is stripped and the payload is UTF-8 decoded; on failure the value is ignored (nil). The prefix is case-insensitive; any whitespace after the colon is allowed.routing—RoutingSubscriptionAnnouncementParser.parse(seeroutingsubsection above).- Interval (
profile-update-interval) —SubscriptionFetcher.parseProfileUpdateIntervalHours: strictlyInt > 0, otherwisenil.
Example body with leading directives
#profile-title: base64:TXkgVlBO
#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:SGVsbG8sIHRoaXMgaXMgYW4gYW5ub3VuY2VtZW50
#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-nodeEquivalent HTTP response
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
profile-title: base64:TXkgVlBO
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:SGVsbG8sIHRoaXMgaXMgYW4gYW5ub3VuY2VtZW50
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-nodeHeader naming compatibility
Key names, separators (key: value or key value with a single space) and the boolean rules above match what many subscription panels already emit. The hide-source-url, sharx-color-scheme, and sharx-accent-palette keys are SharX Connect–specific but use the same naming style; a panel can send them to all clients, and those that do not support them will ignore the keys.
Embedded routing profile in JSON
If the merged routing key is absent or invalid and no /routing/add/ / /routing/onadd/ link is found in plaintext, SubscriptionRoutingExtractor.extract looks for a routing profile inside a JSON body, without replacing the node parser. JSON-derived imports use add semantics only (no forced activation).
Top-level candidate keys (case-insensitive):
routing,routingProfile,Routing,routing_profileannounce,routing_announce,routingAnnounce
Value may be:
- Base64 string — decoded via
RoutingAnnounceDecoder(same format as the payload insharx://routing/add/…). - String URL — e.g.
sharx://routing/onadd/<base64>(same rules as the HTTProutingheader value). - Nested JSON object — decoded as
RoutingProfile.
If the root JSON has a GlobalProxy / globalProxy field, the whole object is treated as a RoutingProfile.
On successful extraction the profile is handed to RoutingProfileStore.upsert (an empty name is back-filled from the subscription display name when needed).
Auto-downloading geo data files
Whenever refresh results in an imported or updated RoutingProfile, SubscriptionRefreshService calls GeoDataDownloader.update(for:). It downloads geoip.dat and geosite.dat from the profile's Geoipurl / Geositeurl into AppGroupPaths.geoDataDirectoryURL/geo/ (shared by all profiles — PacketTunnel reads them from there when building the xray config).
Downloads are not performed on every refresh; they kick in only when one of the following is true:
- the destination file does not exist on disk;
- the URL changed since the last successful download for this profile;
- the profile's
LastUpdatedfield is greater than the one recorded in metadata (compared asUnix timestampor ISO-8601 — numerically / by date).
Metadata lives in …/geo/metadata.json (versioned structure, keyed by RoutingProfile.id).
The result (GeoDataDownloader.Update) bubbles up via SubscriptionRefreshOutcome.geoUpdate, and HomeConnectView renders a short green banner announcing the updated files.
User feedback
SubscriptionRefreshService.refresh takes an onEvent: (SubscriptionRefreshEvent) -> Void callback and also returns a final SubscriptionRefreshOutcome. The home screen uses both to drive a sequential banner queue in StatusBannerCenter:
- “Refreshing “X”…” — progress banner, shown even before the network response lands (manual refresh only).
- “Routing profile “Y” added / refreshed [and activated]” — on
.routingImported. - “Updating geo files…” — on
.geoDownloadStarted. - “Geo files updated…” — on
.geoDownloadFinished(only when something was actually re-downloaded; skipped when the files were already fresh). - “Subscription “X” refreshed · N servers” — final success banner once
refreshreturns.
Events fire in any viable combination, and StatusBannerCenter keeps a proper queue with per-item minDuration so rapid steps do not flicker past in split-seconds. Errors (showError) instantly drop the queue and surface a red banner that auto-dismisses after 5 s.
The banner itself is a top-anchored drop-down overlay (StatusBannerOverlay). Enter/leave animations use move(edge: .top) + opacity + scale(anchor: .top) wrapped in spring(response: 0.45, dampingFraction: 0.78).
Scheduled auto-refresh uses the same pipeline but without the leading progress banner (announceStart: false): users only see the content-carrying success banners (routing imported / geo updated) when something actually changed.