Skip to content
SharX Connect

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:

  1. Share-link lines — one per line; recognised prefixes are vless://, vmess://, trojan://, ss://, hy2:// / hysteria2://. Node name is the URL-decoded fragment after #.
  2. Base64 — if the whole payload looks Base64-encoded, it is decoded and the JSON / line parsers run again.
  3. 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 named protocol (Xray) or type (sing-box). The object is normalised to Xray form for LibXray (sing-box Trojan with server / transport is 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.
  4. 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:

  1. As HTTP response headers on the subscription response (names are case-insensitive).
  2. As #key: value / #key value lines at the top of the bodybefore 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):

  1. Raw Base64 — entire value is the Base64 JSON profile (optional base64: prefix). Treated as onadd: after upsert, the profile becomes active and routing is enabled.
  2. 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 as RoutingProfileStore.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 / #routinglast body linkembedded 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.
  • routingRoutingSubscriptionAnnouncementParser.parse (see routing subsection above).
  • Interval (profile-update-interval) — SubscriptionFetcher.parseProfileUpdateIntervalHours: strictly Int > 0, otherwise nil.

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-node

Equivalent 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-node

Header 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_profile
  • announce, routing_announce, routingAnnounce

Value may be:

  • Base64 string — decoded via RoutingAnnounceDecoder (same format as the payload in sharx://routing/add/…).
  • String URL — e.g. sharx://routing/onadd/<base64> (same rules as the HTTP routing header 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 LastUpdated field is greater than the one recorded in metadata (compared as Unix timestamp or 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:

  1. “Refreshing “X”…” — progress banner, shown even before the network response lands (manual refresh only).
  2. “Routing profile “Y” added / refreshed [and activated]” — on .routingImported.
  3. “Updating geo files…” — on .geoDownloadStarted.
  4. “Geo files updated…” — on .geoDownloadFinished (only when something was actually re-downloaded; skipped when the files were already fresh).
  5. “Subscription “X” refreshed · N servers” — final success banner once refresh returns.

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.


See also

Russian version