diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 0000000..1f5c9aa --- /dev/null +++ b/.github/workflows/ios.yml @@ -0,0 +1,52 @@ +name: iOS + +on: + push: + branches: [ios, main] + pull_request: + branches: [ios, main] + +jobs: + build: + name: Build iOS app + runs-on: macos-26 + steps: + - uses: actions/checkout@v4 + + - name: Select latest Xcode + run: sudo xcode-select -s /Applications/Xcode.app + + - uses: actions/setup-go@v5 + with: + go-version: '1.26' + cache-dependency-path: ios/XrayBridge/go.sum + + # Xray-core compiled for iOS with gomobile. This is the app's only + # native dependency — no tun2socks, no sing-box. + - name: Cache XrayCore.xcframework + id: framework-cache + uses: actions/cache@v4 + with: + path: ios/Frameworks + key: xraycore-${{ runner.os }}-${{ hashFiles('ios/XrayBridge/go.sum', 'ios/XrayBridge/xray.go') }} + + - name: Build XrayCore.xcframework + if: steps.framework-cache.outputs.cache-hit != 'true' + run: Scripts/ios/build-xraycore.sh + + # The iOS targets compile the same Models/ and Core/ sources as the + # desktop app, so keep the desktop build honest too. + - name: Fetch desktop core binaries + run: | + Scripts/fetch-xray.sh + Scripts/fetch-singbox.sh + Scripts/fetch-tun2socks.sh + + - name: Test shared code + run: swift test + + - name: Build for simulator + run: Scripts/ios/build-app.sh simulator Release + + - name: Build for device (unsigned) + run: Scripts/ios/build-app.sh device Release diff --git a/.gitignore b/.gitignore index 458b6c4..95ea8ae 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,16 @@ Sources/XrayClient/Resources/tun2socks *.dmg dist/ +# iOS: the Xray-core xcframework is built locally +# (Scripts/ios/build-xraycore.sh), never committed. +ios/Frameworks/ +ios/build/ + +# Xcode +DerivedData/ +*.xcuserdatad/ +xcuserdata/ +*.xcworkspace + # Editor .vscode/ -*.xcodeproj -*.xcworkspace diff --git a/README.md b/README.md index 382760b..44ffc7c 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,59 @@ A native macOS VPN client for the [Xray-core](https://github.com/XTLS/Xray-core) - **Localized** in 12 languages: English, Русский, 中文, Español, हिन्दी, العربية, Français, Português, Deutsch, 日本語, Bahasa Indonesia, Türkçe. - **Safe shutdown** — restores routes/DNS on quit and recovers from a crashed previous session so you're never left without internet. +## iOS + +This branch also contains the iPhone/iPad app (`ios/`), built on Apple's +**NetworkExtension** so all traffic on the device is tunnelled. It ships +**Xray-core only** — no tun2socks and no second core: Xray's own layer-3 `tun` +inbound takes the utun descriptor straight from `NEPacketTunnelProvider`. + +It shares its entire model and core layer with the Mac app (`Sources/XrayClient/`), +so parsers, config builder, subscriptions, routing and localization behave +identically on both platforms. + +```bash +Scripts/ios/build-xraycore.sh # Xray-core -> XrayCore.xcframework +Scripts/ios/build-app.sh simulator Release +``` + +See **[docs/ios.md](docs/ios.md)** for the packet path, signing requirements and +the app↔extension protocol. + +### Why there is no iOS build to download yet + +The tunnel needs the `packet-tunnel-provider` Network Extension entitlement. +Apple only issues that entitlement through a **paid Apple Developer Program +membership** ($99/year) — a free personal team cannot provision it. + +**Please don't try to sign the app with an ordinary certificate.** It will not +work, and the failure is confusing rather than obvious: + +- Sign it with a free personal team and signing fails outright — the + entitlement is rejected, because entitlements have to be authorised by a + provisioning profile Apple issued. +- Strip the entitlement to make it build and the app installs and launches + fine, but the VPN never starts: `NETunnelProviderManager` refuses the + configuration, and you get an error with no obvious cause. + +Re-signing tools that rely on free accounts (AltStore, Sideloadly and friends) +hit the same wall, for the same reason — none of them can grant an entitlement +Apple has not issued. + +So the app is buildable from source by anyone who already has a paid account, +but there is no signed build to hand out until the membership is funded. + +**Raising the money for the Apple Developer Program membership.** If you'd like +to help, TON: + +``` +UQDsbwQEaspICRDSW4oSNmL0PXxDlnfkiMuqoUbK7ufiCVXj +``` + +Nothing in the app is paywalled and none of this changes the licence — it is +MIT either way. The membership only buys the ability to ship a build that iOS +will actually run. + ## Requirements - macOS 14 (Sonoma) or later diff --git a/Scripts/ios/build-app.sh b/Scripts/ios/build-app.sh new file mode 100755 index 0000000..48488ca --- /dev/null +++ b/Scripts/ios/build-app.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# Builds the iOS app. Defaults to the simulator, which needs no signing; +# pass `device` to build for a real iPhone (requires a Team ID). +# +# Usage: +# Scripts/ios/build-app.sh # simulator, Debug +# Scripts/ios/build-app.sh simulator Release +# Scripts/ios/build-app.sh device Release TEAMID +# +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PROJECT="$ROOT/ios/Veil.xcodeproj" +FRAMEWORK="$ROOT/ios/Frameworks/XrayCore.xcframework" + +TARGET_KIND="${1:-simulator}" +CONFIGURATION="${2:-Debug}" +TEAM_ID="${3:-}" + +if [[ ! -d "$FRAMEWORK" ]]; then + echo "error: $FRAMEWORK is missing." + echo " Run Scripts/ios/build-xraycore.sh first — the app cannot link without it." + exit 1 +fi + +case "$TARGET_KIND" in + simulator) + DESTINATION='generic/platform=iOS Simulator' + SDK=iphonesimulator + EXTRA=() + ;; + device) + DESTINATION='generic/platform=iOS' + SDK=iphoneos + if [[ -n "$TEAM_ID" ]]; then + EXTRA=("DEVELOPMENT_TEAM=$TEAM_ID") + else + echo "note: no Team ID given — building without code signing." + echo " The result will not install on a device; pass your Team ID as arg 3." + EXTRA=(CODE_SIGNING_ALLOWED=NO) + fi + ;; + *) + echo "usage: $0 [simulator|device] [Debug|Release] [TEAM_ID]" + exit 1 + ;; +esac + +echo "==> building Veil ($TARGET_KIND, $CONFIGURATION)" +xcodebuild \ + -project "$PROJECT" \ + -scheme Veil \ + -configuration "$CONFIGURATION" \ + -sdk "$SDK" \ + -destination "$DESTINATION" \ + -derivedDataPath "$ROOT/ios/build" \ + ${EXTRA[@]+"${EXTRA[@]}"} \ + build + +echo +echo "==> done: $ROOT/ios/build/Build/Products/$CONFIGURATION-$SDK/Veil.app" diff --git a/Scripts/ios/build-xraycore.sh b/Scripts/ios/build-xraycore.sh new file mode 100755 index 0000000..67bb685 --- /dev/null +++ b/Scripts/ios/build-xraycore.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# +# Builds XrayCore.xcframework — Xray-core compiled for iOS device + simulator +# via gomobile. This is the ONLY native dependency of the iOS app: no +# tun2socks, no sing-box. Xray's own `tun` inbound terminates the packets it +# gets from NetworkExtension. +# +# Usage: Scripts/ios/build-xraycore.sh +# +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +BRIDGE="$ROOT/ios/XrayBridge" +OUT_DIR="$ROOT/ios/Frameworks" +OUT="$OUT_DIR/XrayCore.xcframework" + +command -v go >/dev/null || { echo "error: Go toolchain not found (brew install go)"; exit 1; } +command -v xcodebuild >/dev/null || { echo "error: Xcode not found"; exit 1; } + +export PATH="$PATH:$(go env GOPATH)/bin" + +if ! command -v gomobile >/dev/null; then + echo "==> installing gomobile" + go install golang.org/x/mobile/cmd/gomobile@latest + go install golang.org/x/mobile/cmd/gobind@latest +fi + +echo "==> resolving Go modules" +cd "$BRIDGE" +go mod download + +echo "==> gomobile init" +gomobile init + +echo "==> building XrayCore.xcframework (device + simulator, arm64)" +mkdir -p "$OUT_DIR" +rm -rf "$OUT" +gomobile bind \ + -target=ios,iossimulator \ + -iosversion=16.0 \ + -o "$OUT" \ + -ldflags="-s -w" \ + -trimpath \ + . + +echo +echo "==> done: $OUT" +du -sh "$OUT" diff --git a/Sources/XrayClient/Core/AddInput.swift b/Sources/XrayClient/Core/AddInput.swift new file mode 100644 index 0000000..68fc119 --- /dev/null +++ b/Sources/XrayClient/Core/AddInput.swift @@ -0,0 +1,56 @@ +import Foundation + +/// What the user just pasted, scanned or picked. +enum AddInput: Equatable { + /// One or more share links, or a wg-quick profile — nothing to fetch. + case servers([ProxyConfig]) + /// A subscription URL that still has to be downloaded. + case subscription(String) + case unrecognized +} + +/// Works out what a blob of pasted text actually is, so the UI can offer a +/// single "add" action instead of making the user classify it first. +/// +/// Order matters: share links and subscription URLs live in disjoint schemes +/// (`vless://` … vs `http(s)://`), so links are tried first and a URL is only +/// considered once nothing parsed as a server. +enum AddInputClassifier { + + static func classify(_ raw: String) -> AddInput { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return .unrecognized } + + // A wg-quick profile pasted whole. + if trimmed.lowercased().contains("[interface]"), + let wireguard = LinkParser.parseWireGuardConf(trimmed) { + return .servers([wireguard]) + } + + // Share links, one per line — or a whole base64-wrapped subscription + // body, which some users paste instead of the URL. + let servers = BalancerGrouper.group(SubscriptionFetcher.decode(trimmed)) + if !servers.isEmpty { return .servers(servers) } + + // A subscription URL, plain… + if let url = subscriptionURL(trimmed) { return .subscription(url) } + + // …or base64-wrapped, the way some panels hand them out. + if let data = LinkParser.decodeBase64(trimmed), + let decoded = String(data: data, encoding: .utf8), + let url = subscriptionURL(decoded.trimmingCharacters(in: .whitespacesAndNewlines)) { + return .subscription(url) + } + + return .unrecognized + } + + private static func subscriptionURL(_ text: String) -> String? { + guard !text.contains(where: \.isNewline), + let url = URL(string: text), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https", + let host = url.host, !host.isEmpty else { return nil } + return text + } +} diff --git a/Sources/XrayClient/Core/BalancerGrouper.swift b/Sources/XrayClient/Core/BalancerGrouper.swift index 6601b94..7afe90e 100644 --- a/Sources/XrayClient/Core/BalancerGrouper.swift +++ b/Sources/XrayClient/Core/BalancerGrouper.swift @@ -9,24 +9,24 @@ enum BalancerGrouper { /// Groups servers by normalized name, protocol and auth key. Servers that do /// not share a bucket are returned unchanged. static func group(_ servers: [ProxyConfig]) -> [ProxyConfig] { + // Bucket order follows first appearance, not the dictionary's hash + // order — otherwise the server list reshuffles on every refresh. + var order: [String] = [] var buckets: [String: [ProxyConfig]] = [:] for server in servers { let key = groupKey(for: server) + if buckets[key] == nil { order.append(key) } buckets[key, default: []].append(server) } - var result: [ProxyConfig] = [] - for group in buckets.values { - guard group.count > 1 else { - result.append(group[0]) - continue - } + return order.map { key in + let group = buckets[key]! + guard group.count > 1 else { return group[0] } var main = group[0] main.name = baseName(main.name) main.alternates = Array(group.dropFirst()) - result.append(main) + return main } - return result } private static func groupKey(for server: ProxyConfig) -> String { diff --git a/Sources/XrayClient/Core/ConnectionManager.swift b/Sources/XrayClient/Core/ConnectionManager.swift index 11dcfd1..1150887 100644 --- a/Sources/XrayClient/Core/ConnectionManager.swift +++ b/Sources/XrayClient/Core/ConnectionManager.swift @@ -1,8 +1,11 @@ import Foundation import Observation import Network -import AppKit +// The macOS coordinator drives an out-of-process core plus the system proxy or +// tun2socks. iOS has neither: there the tunnel lives in a NetworkExtension and +// is driven by `TunnelController` (ios/App), which speaks to the provider over +// NETunnelProviderSession. `ConnectionState` is shared by both. enum ConnectionState: Equatable { case disconnected case connecting @@ -19,6 +22,9 @@ enum ConnectionState: Equatable { } } +#if os(macOS) +import AppKit + /// Top-level coordinator: owns the xray process, the active transport mode /// (system proxy or TUN), uptime tracking, and logs. @MainActor @@ -500,3 +506,4 @@ final class ConnectionManager { } } } +#endif diff --git a/Sources/XrayClient/Core/DeviceID.swift b/Sources/XrayClient/Core/DeviceID.swift index 19b7e0d..9e77e76 100644 --- a/Sources/XrayClient/Core/DeviceID.swift +++ b/Sources/XrayClient/Core/DeviceID.swift @@ -1,12 +1,18 @@ import Foundation -/// Generates a stable hardware identifier (HWID) from the macOS platform UUID. -/// Some subscription providers require this to identify the device. +/// Generates a stable hardware identifier (HWID). Some subscription providers +/// require it to identify the device. +/// +/// On macOS this is the `IOPlatformUUID`. iOS exposes no system-wide hardware +/// identifier at all, so we mint one on first use and persist it in the shared +/// app group — that keeps the HWID stable across launches and identical between +/// the app and the tunnel extension, which is what panels expect. enum DeviceID { /// Cached HWID — computed once on first access. static let hwid: String = generate() + #if os(macOS) /// Reads `IOPlatformUUID` via `ioreg` and returns it. /// Falls back to a random UUID if the value cannot be obtained. private static func generate() -> String { @@ -34,4 +40,22 @@ enum DeviceID { } catch {} return UUID().uuidString } + #else + /// Reads (or mints) the identifier stored in the shared app group. + /// + /// `UIDevice.identifierForVendor` would be the obvious choice, but it is + /// main-actor isolated and this runs from wherever the first subscription + /// fetch happens — including inside the tunnel extension. A stored UUID has + /// the same lifetime anyway: both reset when the app is removed. + private static func generate() -> String { + let key = "hwid" + let defaults = UserDefaults(suiteName: AppGroup.identifier) ?? .standard + if let stored = defaults.string(forKey: key), !stored.isEmpty { + return stored + } + let generated = UUID().uuidString + defaults.set(generated, forKey: key) + return generated + } + #endif } diff --git a/Sources/XrayClient/Core/GeoAssetManager.swift b/Sources/XrayClient/Core/GeoAssetManager.swift index d5e04fd..d176a96 100644 --- a/Sources/XrayClient/Core/GeoAssetManager.swift +++ b/Sources/XrayClient/Core/GeoAssetManager.swift @@ -21,11 +21,17 @@ final class GeoAssetManager { init() { let fm = FileManager.default + #if os(iOS) + // Shared app group: the app downloads the .dat files, the tunnel + // extension is the one that actually reads them. + let dir = AppGroup.geoDirectory + #else let base = (try? fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)) ?? fm.temporaryDirectory let dir = base.appendingPathComponent("XrayClient/geo", isDirectory: true) + #endif try? fm.createDirectory(at: dir, withIntermediateDirectories: true) self.directory = dir refreshState() diff --git a/Sources/XrayClient/Core/LocalizationTable.swift b/Sources/XrayClient/Core/LocalizationTable.swift index 626af65..65a51d7 100644 --- a/Sources/XrayClient/Core/LocalizationTable.swift +++ b/Sources/XrayClient/Core/LocalizationTable.swift @@ -449,5 +449,347 @@ extension Loc { "ru": "Системный", "zh": "系统", "es": "Sistema", "hi": "सिस्टम", "ar": "النظام", "fr": "Système", "pt": "Sistema", "de": "System", "ja": "システム", "id": "Sistem", "tr": "Sistem"], + + // MARK: Enum titles shown in pickers (LogLevel, RoutingPreset, AppAppearance, RuleOutbound, GeoAssetSource) + "Debug": [ + "ru": "Отладка", "zh": "调试", "es": "Depuración", "hi": "डिबग", + "ar": "تصحيح", "fr": "Débogage", "pt": "Depuração", "de": "Debug", + "ja": "デバッグ", "id": "Debug", "tr": "Hata ayıklama"], + "Info": [ + "ru": "Инфо", "zh": "信息", "es": "Información", "hi": "जानकारी", + "ar": "معلومات", "fr": "Info", "pt": "Informação", "de": "Info", + "ja": "情報", "id": "Info", "tr": "Bilgi"], + "Warning": [ + "ru": "Предупреждения", "zh": "警告", "es": "Advertencia", "hi": "चेतावनी", + "ar": "تحذير", "fr": "Avertissement", "pt": "Aviso", "de": "Warnung", + "ja": "警告", "id": "Peringatan", "tr": "Uyarı"], + "Error": [ + "ru": "Ошибки", "zh": "错误", "es": "Error", "hi": "त्रुटि", + "ar": "خطأ", "fr": "Erreur", "pt": "Erro", "de": "Fehler", + "ja": "エラー", "id": "Kesalahan", "tr": "Hata"], + "None": [ + "ru": "Нет", "zh": "无", "es": "Ninguno", "hi": "कोई नहीं", + "ar": "بدون", "fr": "Aucun", "pt": "Nenhum", "de": "Keine", + "ja": "なし", "id": "Tidak ada", "tr": "Yok"], + "Global": [ + "ru": "Глобально", "zh": "全局", "es": "Global", "hi": "ग्लोबल", + "ar": "عام", "fr": "Global", "pt": "Global", "de": "Global", + "ja": "グローバル", "id": "Global", "tr": "Genel"], + "Bypass LAN": [ + "ru": "В обход LAN", "zh": "绕过局域网", "es": "Omitir LAN", "hi": "LAN बायपास", + "ar": "تجاوز الشبكة المحلية", "fr": "Contourner le LAN", "pt": "Ignorar LAN", "de": "LAN umgehen", + "ja": "LAN をバイパス", "id": "Lewati LAN", "tr": "LAN'ı atla"], + "Bypass China": [ + "ru": "В обход Китая", "zh": "绕过中国", "es": "Omitir China", "hi": "चीन बायपास", + "ar": "تجاوز الصين", "fr": "Contourner la Chine", "pt": "Ignorar China", "de": "China umgehen", + "ja": "中国をバイパス", "id": "Lewati Tiongkok", "tr": "Çin'i atla"], + "Bypass Russia": [ + "ru": "В обход России", "zh": "绕过俄罗斯", "es": "Omitir Rusia", "hi": "रूस बायपास", + "ar": "تجاوز روسيا", "fr": "Contourner la Russie", "pt": "Ignorar Rússia", "de": "Russland umgehen", + "ja": "ロシアをバイパス", "id": "Lewati Rusia", "tr": "Rusya'yı atla"], + "Custom": [ + "ru": "Свои", "zh": "自定义", "es": "Personalizado", "hi": "कस्टम", + "ar": "مخصص", "fr": "Personnalisé", "pt": "Personalizado", "de": "Eigene", + "ja": "カスタム", "id": "Kustom", "tr": "Özel"], + "Light": [ + "ru": "Светлая", "zh": "浅色", "es": "Claro", "hi": "लाइट", + "ar": "فاتح", "fr": "Clair", "pt": "Claro", "de": "Hell", + "ja": "ライト", "id": "Terang", "tr": "Açık"], + "Dark": [ + "ru": "Тёмная", "zh": "深色", "es": "Oscuro", "hi": "डार्क", + "ar": "داكن", "fr": "Sombre", "pt": "Escuro", "de": "Dunkel", + "ja": "ダーク", "id": "Gelap", "tr": "Koyu"], + "Proxy": [ + "ru": "Прокси", "zh": "代理", "es": "Proxy", "hi": "प्रॉक्सी", + "ar": "الوكيل", "fr": "Proxy", "pt": "Proxy", "de": "Proxy", + "ja": "プロキシ", "id": "Proxy", "tr": "Proxy"], + "Direct": [ + "ru": "Напрямую", "zh": "直连", "es": "Directo", "hi": "सीधा", + "ar": "مباشر", "fr": "Direct", "pt": "Direto", "de": "Direkt", + "ja": "直接", "id": "Langsung", "tr": "Doğrudan"], + "Block": [ + "ru": "Блокировать", "zh": "阻止", "es": "Bloquear", "hi": "ब्लॉक", + "ar": "حظر", "fr": "Bloquer", "pt": "Bloquear", "de": "Blockieren", + "ja": "ブロック", "id": "Blokir", "tr": "Engelle"], + "All traffic through the proxy.": [ + "ru": "Весь трафик через прокси.", "zh": "所有流量走代理。", "es": "Todo el tráfico por el proxy.", "hi": "सारा ट्रैफ़िक प्रॉक्सी से।", + "ar": "كل حركة البيانات عبر الوكيل.", "fr": "Tout le trafic passe par le proxy.", "pt": "Todo o tráfego pelo proxy.", "de": "Gesamter Verkehr über den Proxy.", + "ja": "すべての通信をプロキシ経由にします。", "id": "Semua lalu lintas lewat proxy.", "tr": "Tüm trafik proxy üzerinden."], + "Proxy everything except local/LAN addresses.": [ + "ru": "Всё через прокси, кроме локальных адресов.", "zh": "除本地/局域网地址外全部走代理。", "es": "Todo por el proxy salvo las direcciones locales/LAN.", "hi": "लोकल/LAN पतों को छोड़कर सब प्रॉक्सी से।", + "ar": "كل شيء عبر الوكيل عدا العناوين المحلية.", "fr": "Tout via le proxy sauf les adresses locales/LAN.", "pt": "Tudo pelo proxy exceto endereços locais/LAN.", "de": "Alles über den Proxy außer lokalen/LAN-Adressen.", + "ja": "ローカル/LAN アドレス以外をプロキシ経由にします。", "id": "Semua lewat proxy kecuali alamat lokal/LAN.", "tr": "Yerel/LAN adresleri dışında her şey proxy üzerinden."], + "Mainland China sites & LAN go direct, rest via proxy.": [ + "ru": "Сайты Китая и локальная сеть — напрямую, остальное через прокси.", "zh": "中国大陆站点与局域网直连,其余走代理。", "es": "Sitios de China continental y LAN directos, el resto por proxy.", "hi": "मुख्यभूमि चीन की साइटें और LAN सीधे, बाकी प्रॉक्सी से।", + "ar": "مواقع الصين والشبكة المحلية مباشرة، والباقي عبر الوكيل.", "fr": "Sites de Chine continentale et LAN en direct, le reste via le proxy.", "pt": "Sites da China continental e LAN diretos, o resto pelo proxy.", "de": "Festlandchina-Seiten und LAN direkt, der Rest über den Proxy.", + "ja": "中国本土のサイトと LAN は直接接続、それ以外はプロキシ経由。", "id": "Situs Tiongkok daratan dan LAN langsung, sisanya lewat proxy.", "tr": "Çin anakarası siteleri ve LAN doğrudan, gerisi proxy üzerinden."], + "Russian & .ru-gov sites go direct, rest via proxy.": [ + "ru": "Российские и госсайты — напрямую, остальное через прокси.", "zh": "俄罗斯及 .ru 政府站点直连,其余走代理。", "es": "Sitios rusos y gubernamentales directos, el resto por proxy.", "hi": "रूसी और सरकारी साइटें सीधे, बाकी प्रॉक्सी से।", + "ar": "المواقع الروسية والحكومية مباشرة، والباقي عبر الوكيل.", "fr": "Sites russes et gouvernementaux en direct, le reste via le proxy.", "pt": "Sites russos e governamentais diretos, o resto pelo proxy.", "de": "Russische und Regierungsseiten direkt, der Rest über den Proxy.", + "ja": "ロシアと政府系サイトは直接接続、それ以外はプロキシ経由。", "id": "Situs Rusia dan pemerintah langsung, sisanya lewat proxy.", "tr": "Rus ve devlet siteleri doğrudan, gerisi proxy üzerinden."], + "Your own ordered rule list.": [ + "ru": "Ваш собственный упорядоченный список правил.", "zh": "你自己排序的规则列表。", "es": "Tu propia lista ordenada de reglas.", "hi": "आपकी अपनी क्रमबद्ध नियम सूची।", + "ar": "قائمة قواعدك المرتّبة الخاصة.", "fr": "Votre propre liste de règles ordonnée.", "pt": "Sua própria lista ordenada de regras.", "de": "Ihre eigene, geordnete Regelliste.", + "ja": "独自の順序付きルールリスト。", "id": "Daftar aturan berurutan milik Anda.", "tr": "Kendi sıralı kural listeniz."], + "Loyalsoldier (global + CN)": [ + "ru": "Loyalsoldier (мир + Китай)", "zh": "Loyalsoldier(全球 + 中国)", "es": "Loyalsoldier (global + CN)", "hi": "Loyalsoldier (वैश्विक + CN)", + "ar": "Loyalsoldier (عالمي + الصين)", "fr": "Loyalsoldier (monde + CN)", "pt": "Loyalsoldier (global + CN)", "de": "Loyalsoldier (global + CN)", + "ja": "Loyalsoldier(全世界 + 中国)", "id": "Loyalsoldier (global + CN)", "tr": "Loyalsoldier (küresel + ÇH)"], + "runetfreedom (RU)": [ + "ru": "runetfreedom (Россия)", "zh": "runetfreedom(俄罗斯)", "es": "runetfreedom (Rusia)", "hi": "runetfreedom (रूस)", + "ar": "runetfreedom (روسيا)", "fr": "runetfreedom (Russie)", "pt": "runetfreedom (Rússia)", "de": "runetfreedom (Russland)", + "ja": "runetfreedom(ロシア)", "id": "runetfreedom (Rusia)", "tr": "runetfreedom (Rusya)"], + "v2fly (official)": [ + "ru": "v2fly (официальный)", "zh": "v2fly(官方)", "es": "v2fly (oficial)", "hi": "v2fly (आधिकारिक)", + "ar": "v2fly (رسمي)", "fr": "v2fly (officiel)", "pt": "v2fly (oficial)", "de": "v2fly (offiziell)", + "ja": "v2fly(公式)", "id": "v2fly (resmi)", "tr": "v2fly (resmî)"], + "Custom URLs": [ + "ru": "Свои ссылки", "zh": "自定义地址", "es": "URL personalizadas", "hi": "कस्टम URL", + "ar": "روابط مخصصة", "fr": "URL personnalisées", "pt": "URLs personalizadas", "de": "Eigene URLs", + "ja": "カスタム URL", "id": "URL kustom", "tr": "Özel adresler"], + + // MARK: iOS app + "About": [ + "ru": "О программе", "zh": "关于", "es": "Acerca de", "hi": "परिचय", + "ar": "حول", "fr": "À propos", "pt": "Sobre", "de": "Über", + "ja": "情報", "id": "Tentang", "tr": "Hakkında"], + "App version": [ + "ru": "Версия приложения", "zh": "应用版本", "es": "Versión de la app", "hi": "ऐप संस्करण", + "ar": "إصدار التطبيق", "fr": "Version de l'app", "pt": "Versão do app", "de": "App-Version", + "ja": "アプリバージョン", "id": "Versi aplikasi", "tr": "Uygulama sürümü"], + "Block ads": [ + "ru": "Блокировать рекламу", "zh": "拦截广告", "es": "Bloquear anuncios", "hi": "विज्ञापन ब्लॉक करें", + "ar": "حظر الإعلانات", "fr": "Bloquer les pubs", "pt": "Bloquear anúncios", "de": "Werbung blockieren", + "ja": "広告をブロック", "id": "Blokir iklan", "tr": "Reklamları engelle"], + "Blocking": [ + "ru": "Блокировка", "zh": "拦截", "es": "Bloqueo", "hi": "ब्लॉकिंग", + "ar": "الحظر", "fr": "Blocage", "pt": "Bloqueio", "de": "Blockieren", + "ja": "ブロック", "id": "Pemblokiran", "tr": "Engelleme"], + "Changes apply the next time you connect or switch servers.": [ + "ru": "Изменения применятся при следующем подключении или смене сервера.", "zh": "更改将在下次连接或切换服务器时生效。", "es": "Los cambios se aplican la próxima vez que te conectes o cambies de servidor.", "hi": "परिवर्तन अगली बार कनेक्ट करने या सर्वर बदलने पर लागू होंगे।", + "ar": "تُطبَّق التغييرات عند الاتصال التالي أو عند تبديل الخادم.", "fr": "Les modifications s'appliqueront à la prochaine connexion ou au changement de serveur.", "pt": "As alterações serão aplicadas na próxima conexão ou troca de servidor.", "de": "Änderungen gelten beim nächsten Verbinden oder Serverwechsel.", + "ja": "変更は次回の接続またはサーバー切り替え時に適用されます。", "id": "Perubahan berlaku saat Anda terhubung berikutnya atau berganti server.", "tr": "Değişiklikler bir sonraki bağlantıda veya sunucu değişiminde uygulanır."], + "Copy link": [ + "ru": "Скопировать ссылку", "zh": "复制链接", "es": "Copiar enlace", "hi": "लिंक कॉपी करें", + "ar": "نسخ الرابط", "fr": "Copier le lien", "pt": "Copiar link", "de": "Link kopieren", + "ja": "リンクをコピー", "id": "Salin tautan", "tr": "Bağlantıyı kopyala"], + "Could not read that image.": [ + "ru": "Не удалось прочитать изображение.", "zh": "无法读取该图片。", "es": "No se pudo leer la imagen.", "hi": "वह छवि पढ़ी नहीं जा सकी।", + "ar": "تعذّرت قراءة الصورة.", "fr": "Impossible de lire cette image.", "pt": "Não foi possível ler a imagem.", "de": "Bild konnte nicht gelesen werden.", + "ja": "画像を読み取れませんでした。", "id": "Tidak dapat membaca gambar itu.", "tr": "Görsel okunamadı."], + "Could not render a QR code for this server.": [ + "ru": "Не удалось создать QR-код для этого сервера.", "zh": "无法为该服务器生成二维码。", "es": "No se pudo generar el código QR de este servidor.", "hi": "इस सर्वर के लिए QR कोड नहीं बनाया जा सका।", + "ar": "تعذّر إنشاء رمز QR لهذا الخادم.", "fr": "Impossible de générer le QR code pour ce serveur.", "pt": "Não foi possível gerar o QR code deste servidor.", "de": "QR-Code für diesen Server konnte nicht erstellt werden.", + "ja": "このサーバーの QR コードを生成できませんでした。", "id": "Tidak dapat membuat kode QR untuk server ini.", "tr": "Bu sunucu için QR kod oluşturulamadı."], + "Custom rules": [ + "ru": "Свои правила", "zh": "自定义规则", "es": "Reglas personalizadas", "hi": "कस्टम नियम", + "ar": "قواعد مخصصة", "fr": "Règles personnalisées", "pt": "Regras personalizadas", "de": "Eigene Regeln", + "ja": "カスタムルール", "id": "Aturan kustom", "tr": "Özel kurallar"], + "Device ID": [ + "ru": "ID устройства", "zh": "设备 ID", "es": "ID del dispositivo", "hi": "डिवाइस आईडी", + "ar": "معرّف الجهاز", "fr": "Identifiant de l'appareil", "pt": "ID do dispositivo", "de": "Geräte-ID", + "ja": "デバイス ID", "id": "ID perangkat", "tr": "Cihaz kimliği"], + "Download geo databases": [ + "ru": "Скачать geo-базы", "zh": "下载 geo 数据库", "es": "Descargar bases geo", "hi": "geo डेटाबेस डाउनलोड करें", + "ar": "تنزيل قواعد geo", "fr": "Télécharger les bases geo", "pt": "Baixar bases geo", "de": "Geo-Datenbanken laden", + "ja": "geo データベースをダウンロード", "id": "Unduh basis data geo", "tr": "Geo veritabanlarını indir"], + "Enabled": [ + "ru": "Включено", "zh": "已启用", "es": "Activada", "hi": "सक्षम", + "ar": "مفعّل", "fr": "Activée", "pt": "Ativada", "de": "Aktiviert", + "ja": "有効", "id": "Aktif", "tr": "Etkin"], + "Every": [ + "ru": "Каждые", "zh": "每", "es": "Cada", "hi": "हर", + "ar": "كل", "fr": "Toutes les", "pt": "A cada", "de": "Alle", + "ja": "間隔", "id": "Setiap", "tr": "Her"], + "Expires": [ + "ru": "Истекает", "zh": "到期", "es": "Vence", "hi": "समाप्ति", + "ar": "ينتهي", "fr": "Expire", "pt": "Expira", "de": "Läuft ab", + "ja": "有効期限", "id": "Kedaluwarsa", "tr": "Bitiş"], + "Failed": [ + "ru": "Ошибка", "zh": "失败", "es": "Error", "hi": "विफल", + "ar": "فشل", "fr": "Échec", "pt": "Falhou", "de": "Fehlgeschlagen", + "ja": "失敗", "id": "Gagal", "tr": "Başarısız"], + "From image…": [ + "ru": "Из изображения…", "zh": "从图片…", "es": "Desde imagen…", "hi": "छवि से…", + "ar": "من صورة…", "fr": "Depuis une image…", "pt": "De uma imagem…", "de": "Aus Bild…", + "ja": "画像から…", "id": "Dari gambar…", "tr": "Görselden…"], + "Geo databases": [ + "ru": "Geo-базы", "zh": "Geo 数据库", "es": "Bases geo", "hi": "Geo डेटाबेस", + "ar": "قواعد geo", "fr": "Bases geo", "pt": "Bases geo", "de": "Geo-Datenbanken", + "ja": "geo データベース", "id": "Basis data geo", "tr": "Geo veritabanları"], + "IPs": [ + "ru": "IP-адреса", "zh": "IP 地址", "es": "IP", "hi": "IP पते", + "ar": "عناوين IP", "fr": "IP", "pt": "IPs", "de": "IPs", + "ja": "IP アドレス", "id": "Alamat IP", "tr": "IP adresleri"], + "IPv6 inside tunnel": [ + "ru": "IPv6 в туннеле", "zh": "隧道内 IPv6", "es": "IPv6 en el túnel", "hi": "टनल में IPv6", + "ar": "IPv6 داخل النفق", "fr": "IPv6 dans le tunnel", "pt": "IPv6 no túnel", "de": "IPv6 im Tunnel", + "ja": "トンネル内の IPv6", "id": "IPv6 dalam terowongan", "tr": "Tünel içinde IPv6"], + "Installed": [ + "ru": "Установлен", "zh": "已安装", "es": "Instalado", "hi": "इंस्टॉल्ड", + "ar": "مثبَّت", "fr": "Installé", "pt": "Instalado", "de": "Installiert", + "ja": "インストール済み", "id": "Terpasang", "tr": "Yüklü"], + "Installed.": [ + "ru": "Установлены.", "zh": "已安装。", "es": "Instaladas.", "hi": "इंस्टॉल्ड।", + "ar": "مثبَّتة.", "fr": "Installées.", "pt": "Instaladas.", "de": "Installiert.", + "ja": "インストール済み。", "id": "Terpasang.", "tr": "Yüklü."], + "Name": [ + "ru": "Название", "zh": "名称", "es": "Nombre", "hi": "नाम", + "ar": "الاسم", "fr": "Nom", "pt": "Nome", "de": "Name", + "ja": "名前", "id": "Nama", "tr": "Ad"], + "Network Extension (all apps)": [ + "ru": "Network Extension (все приложения)", "zh": "Network Extension(所有应用)", "es": "Network Extension (todas las apps)", "hi": "Network Extension (सभी ऐप्स)", + "ar": "Network Extension (كل التطبيقات)", "fr": "Network Extension (toutes les apps)", "pt": "Network Extension (todos os apps)", "de": "Network Extension (alle Apps)", + "ja": "Network Extension(全アプリ)", "id": "Network Extension (semua aplikasi)", "tr": "Network Extension (tüm uygulamalar)"], + "New rule": [ + "ru": "Новое правило", "zh": "新规则", "es": "Nueva regla", "hi": "नया नियम", + "ar": "قاعدة جديدة", "fr": "Nouvelle règle", "pt": "Nova regra", "de": "Neue Regel", + "ja": "新しいルール", "id": "Aturan baru", "tr": "Yeni kural"], + "No QR code found in that image.": [ + "ru": "В изображении нет QR-кода.", "zh": "该图片中未找到二维码。", "es": "No se encontró ningún código QR en la imagen.", "hi": "उस छवि में कोई QR कोड नहीं मिला।", + "ar": "لم يُعثر على رمز QR في الصورة.", "fr": "Aucun QR code trouvé dans cette image.", "pt": "Nenhum QR code encontrado na imagem.", "de": "Kein QR-Code im Bild gefunden.", + "ja": "画像に QR コードが見つかりません。", "id": "Tidak ada kode QR pada gambar itu.", "tr": "Görselde QR kod bulunamadı."], + "No logs yet.": [ + "ru": "Логов пока нет.", "zh": "暂无日志。", "es": "Aún no hay registros.", "hi": "अभी कोई लॉग नहीं।", + "ar": "لا توجد سجلات بعد.", "fr": "Aucun journal pour l'instant.", "pt": "Ainda não há logs.", "de": "Noch keine Logs.", + "ja": "ログはまだありません。", "id": "Belum ada log.", "tr": "Henüz günlük yok."], + "Not a link or subscription URL": [ + "ru": "Это не ссылка и не URL подписки", "zh": "不是链接或订阅地址", "es": "No es un enlace ni una URL de suscripción", "hi": "यह लिंक या सब्सक्रिप्शन URL नहीं है", + "ar": "ليس رابطًا ولا عنوان اشتراك", "fr": "Ni un lien ni une URL d'abonnement", "pt": "Não é um link nem uma URL de assinatura", "de": "Weder Link noch Abo-URL", + "ja": "リンクでもサブスクリプション URL でもありません", "id": "Bukan tautan atau URL langganan", "tr": "Bağlantı ya da abonelik adresi değil"], + "Not installed": [ + "ru": "Не установлен", "zh": "未安装", "es": "No instalado", "hi": "इंस्टॉल नहीं है", + "ar": "غير مثبَّت", "fr": "Non installé", "pt": "Não instalado", "de": "Nicht installiert", + "ja": "未インストール", "id": "Belum terpasang", "tr": "Yüklü değil"], + "Notify on connect": [ + "ru": "Уведомлять о подключении", "zh": "连接时通知", "es": "Notificar al conectar", "hi": "कनेक्ट होने पर सूचित करें", + "ar": "تنبيه عند الاتصال", "fr": "Notifier à la connexion", "pt": "Notificar ao conectar", "de": "Bei Verbindung benachrichtigen", + "ja": "接続時に通知", "id": "Beri tahu saat terhubung", "tr": "Bağlanınca bildir"], + "Outbound": [ + "ru": "Исходящий", "zh": "出站", "es": "Salida", "hi": "आउटबाउंड", + "ar": "الوجهة", "fr": "Sortie", "pt": "Saída", "de": "Ausgang", + "ja": "アウトバウンド", "id": "Keluar", "tr": "Çıkış"], + "Paste a link, subscription URL or config": [ + "ru": "Вставьте ссылку, URL подписки или конфиг", "zh": "粘贴链接、订阅地址或配置", "es": "Pega un enlace, URL de suscripción o configuración", "hi": "लिंक, सब्सक्रिप्शन URL या कॉन्फ़िग पेस्ट करें", + "ar": "الصق رابطًا أو عنوان اشتراك أو إعدادًا", "fr": "Collez un lien, une URL d'abonnement ou une config", "pt": "Cole um link, URL de assinatura ou config", "de": "Link, Abo-URL oder Config einfügen", + "ja": "リンク・サブスクリプション URL・設定を貼り付け", "id": "Tempel tautan, URL langganan, atau konfigurasi", "tr": "Bağlantı, abonelik adresi veya yapılandırma yapıştırın"], + "Paste a server link or a subscription URL to get started.": [ + "ru": "Вставьте ссылку на сервер или URL подписки, чтобы начать.", "zh": "粘贴服务器链接或订阅地址即可开始。", "es": "Pega un enlace de servidor o una URL de suscripción para empezar.", "hi": "शुरू करने के लिए सर्वर लिंक या सब्सक्रिप्शन URL पेस्ट करें।", + "ar": "الصق رابط خادم أو عنوان اشتراك للبدء.", "fr": "Collez un lien de serveur ou une URL d'abonnement pour commencer.", "pt": "Cole um link de servidor ou uma URL de assinatura para começar.", "de": "Server-Link oder Abo-URL einfügen, um zu starten.", + "ja": "サーバーのリンクかサブスクリプション URL を貼り付けて始めましょう。", "id": "Tempel tautan server atau URL langganan untuk memulai.", "tr": "Başlamak için bir sunucu bağlantısı veya abonelik adresi yapıştırın."], + "Paste from clipboard": [ + "ru": "Вставить из буфера", "zh": "从剪贴板粘贴", "es": "Pegar del portapapeles", "hi": "क्लिपबोर्ड से पेस्ट करें", + "ar": "لصق من الحافظة", "fr": "Coller depuis le presse-papiers", "pt": "Colar da área de transferência", "de": "Aus Zwischenablage einfügen", + "ja": "クリップボードから貼り付け", "id": "Tempel dari papan klip", "tr": "Panodan yapıştır"], + "Port (optional)": [ + "ru": "Порт (необязательно)", "zh": "端口(可选)", "es": "Puerto (opcional)", "hi": "पोर्ट (वैकल्पिक)", + "ar": "المنفذ (اختياري)", "fr": "Port (facultatif)", "pt": "Porta (opcional)", "de": "Port (optional)", + "ja": "ポート(任意)", "id": "Port (opsional)", "tr": "Bağlantı noktası (isteğe bağlı)"], + "Remove VPN profile": [ + "ru": "Удалить VPN-профиль", "zh": "删除 VPN 配置", "es": "Eliminar perfil VPN", "hi": "VPN प्रोफ़ाइल हटाएँ", + "ar": "إزالة ملف VPN", "fr": "Supprimer le profil VPN", "pt": "Remover perfil VPN", "de": "VPN-Profil entfernen", + "ja": "VPN プロファイルを削除", "id": "Hapus profil VPN", "tr": "VPN profilini kaldır"], + "Remove VPN profile?": [ + "ru": "Удалить VPN-профиль?", "zh": "删除 VPN 配置?", "es": "¿Eliminar el perfil VPN?", "hi": "VPN प्रोफ़ाइल हटाएँ?", + "ar": "إزالة ملف VPN؟", "fr": "Supprimer le profil VPN ?", "pt": "Remover perfil VPN?", "de": "VPN-Profil entfernen?", + "ja": "VPN プロファイルを削除しますか?", "id": "Hapus profil VPN?", "tr": "VPN profili kaldırılsın mı?"], + "Rules": [ + "ru": "Правила", "zh": "规则", "es": "Reglas", "hi": "नियम", + "ar": "القواعد", "fr": "Règles", "pt": "Regras", "de": "Regeln", + "ja": "ルール", "id": "Aturan", "tr": "Kurallar"], + "Scan camera": [ + "ru": "Сканировать камерой", "zh": "用相机扫描", "es": "Escanear con la cámara", "hi": "कैमरे से स्कैन करें", + "ar": "المسح بالكاميرا", "fr": "Scanner avec l'appareil photo", "pt": "Escanear com a câmera", "de": "Mit Kamera scannen", + "ja": "カメラでスキャン", "id": "Pindai dengan kamera", "tr": "Kamerayla tara"], + "Send device ID (HWID)": [ + "ru": "Отправлять ID устройства (HWID)", "zh": "发送设备 ID (HWID)", "es": "Enviar ID del dispositivo (HWID)", "hi": "डिवाइस आईडी (HWID) भेजें", + "ar": "إرسال معرّف الجهاز (HWID)", "fr": "Envoyer l'identifiant (HWID)", "pt": "Enviar ID do dispositivo (HWID)", "de": "Geräte-ID (HWID) senden", + "ja": "デバイス ID (HWID) を送信", "id": "Kirim ID perangkat (HWID)", "tr": "Cihaz kimliğini (HWID) gönder"], + "Server": [ + "ru": "Сервер", "zh": "服务器", "es": "Servidor", "hi": "सर्वर", + "ar": "الخادم", "fr": "Serveur", "pt": "Servidor", "de": "Server", + "ja": "サーバー", "id": "Server", "tr": "Sunucu"], + "Servers found": [ + "ru": "Найдено серверов", "zh": "找到服务器", "es": "Servidores encontrados", "hi": "सर्वर मिले", + "ar": "الخوادم الموجودة", "fr": "Serveurs trouvés", "pt": "Servidores encontrados", "de": "Gefundene Server", + "ja": "見つかったサーバー", "id": "Server ditemukan", "tr": "Bulunan sunucular"], + "Share": [ + "ru": "Поделиться", "zh": "分享", "es": "Compartir", "hi": "शेयर करें", + "ar": "مشاركة", "fr": "Partager", "pt": "Compartilhar", "de": "Teilen", + "ja": "共有", "id": "Bagikan", "tr": "Paylaş"], + "Show QR code": [ + "ru": "Показать QR-код", "zh": "显示二维码", "es": "Mostrar código QR", "hi": "QR कोड दिखाएँ", + "ar": "عرض رمز QR", "fr": "Afficher le QR code", "pt": "Mostrar QR code", "de": "QR-Code anzeigen", + "ja": "QR コードを表示", "id": "Tampilkan kode QR", "tr": "QR kodu göster"], + "Source": [ + "ru": "Источник", "zh": "来源", "es": "Fuente", "hi": "स्रोत", + "ar": "المصدر", "fr": "Source", "pt": "Fonte", "de": "Quelle", + "ja": "ソース", "id": "Sumber", "tr": "Kaynak"], + "Startup": [ + "ru": "Запуск", "zh": "启动", "es": "Inicio", "hi": "स्टार्टअप", + "ar": "بدء التشغيل", "fr": "Démarrage", "pt": "Inicialização", "de": "Start", + "ja": "起動", "id": "Mulai", "tr": "Başlangıç"], + "Status": [ + "ru": "Статус", "zh": "状态", "es": "Estado", "hi": "स्थिति", + "ar": "الحالة", "fr": "État", "pt": "Status", "de": "Status", + "ja": "状態", "id": "Status", "tr": "Durum"], + "The subscription returned no servers.": [ + "ru": "Подписка не вернула ни одного сервера.", "zh": "订阅未返回任何服务器。", "es": "La suscripción no devolvió servidores.", "hi": "सब्सक्रिप्शन से कोई सर्वर नहीं मिला।", + "ar": "لم يُرجع الاشتراك أي خادم.", "fr": "L'abonnement n'a renvoyé aucun serveur.", "pt": "A assinatura não retornou servidores.", "de": "Das Abo hat keine Server geliefert.", + "ja": "サブスクリプションからサーバーが返されませんでした。", "id": "Langganan tidak mengembalikan server.", "tr": "Abonelik hiç sunucu döndürmedi."], + "Untitled rule": [ + "ru": "Правило без названия", "zh": "未命名规则", "es": "Regla sin nombre", "hi": "बिना नाम का नियम", + "ar": "قاعدة بلا اسم", "fr": "Règle sans nom", "pt": "Regra sem nome", "de": "Unbenannte Regel", + "ja": "名称未設定のルール", "id": "Aturan tanpa nama", "tr": "Adsız kural"], + "VPN profile": [ + "ru": "VPN-профиль", "zh": "VPN 配置", "es": "Perfil VPN", "hi": "VPN प्रोफ़ाइल", + "ar": "ملف VPN", "fr": "Profil VPN", "pt": "Perfil VPN", "de": "VPN-Profil", + "ja": "VPN プロファイル", "id": "Profil VPN", "tr": "VPN profili"], + "Xray core": [ + "ru": "Ядро Xray", "zh": "Xray 内核", "es": "Núcleo Xray", "hi": "Xray कोर", + "ar": "نواة Xray", "fr": "Noyau Xray", "pt": "Núcleo Xray", "de": "Xray-Kern", + "ja": "Xray コア", "id": "Inti Xray", "tr": "Xray çekirdeği"], + "geosite:/geoip: rules need these files.": [ + "ru": "Правила geosite:/geoip: требуют эти файлы.", "zh": "geosite:/geoip: 规则需要这些文件。", "es": "Las reglas geosite:/geoip: necesitan estos archivos.", "hi": "geosite:/geoip: नियमों के लिए ये फ़ाइलें ज़रूरी हैं।", + "ar": "قواعد geosite:/geoip: تحتاج هذه الملفات.", "fr": "Les règles geosite:/geoip: nécessitent ces fichiers.", "pt": "As regras geosite:/geoip: precisam destes arquivos.", "de": "geosite:/geoip:-Regeln brauchen diese Dateien.", + "ja": "geosite:/geoip: ルールにはこれらのファイルが必要です。", "id": "Aturan geosite:/geoip: memerlukan berkas ini.", "tr": "geosite:/geoip: kuralları bu dosyaları gerektirir."], + "iOS asks you to allow the VPN configuration the first time you connect.": [ + "ru": "При первом подключении iOS попросит разрешить VPN-конфигурацию.", "zh": "首次连接时 iOS 会请求允许该 VPN 配置。", "es": "iOS te pedirá permitir la configuración VPN la primera vez que te conectes.", "hi": "पहली बार कनेक्ट करने पर iOS VPN कॉन्फ़िगरेशन की अनुमति माँगेगा।", + "ar": "سيطلب منك iOS السماح بإعداد VPN عند أول اتصال.", "fr": "iOS vous demandera d'autoriser la configuration VPN à la première connexion.", "pt": "O iOS pedirá para permitir a configuração VPN na primeira conexão.", "de": "iOS fragt beim ersten Verbinden nach Erlaubnis für die VPN-Konfiguration.", + "ja": "初回接続時に iOS が VPN 構成の許可を求めます。", "id": "iOS akan meminta izin konfigurasi VPN saat pertama kali terhubung.", "tr": "iOS ilk bağlantıda VPN yapılandırmasına izin vermenizi ister."], + "needs sing-box": [ + "ru": "нужен sing-box", "zh": "需要 sing-box", "es": "requiere sing-box", "hi": "sing-box चाहिए", + "ar": "يتطلب sing-box", "fr": "nécessite sing-box", "pt": "requer sing-box", "de": "braucht sing-box", + "ja": "sing-box が必要", "id": "butuh sing-box", "tr": "sing-box gerekir"], + "not supported on iOS": [ + "ru": "не поддерживается на iOS", "zh": "在 iOS 上不支持", "es": "no compatible con iOS", "hi": "iOS पर समर्थित नहीं", + "ar": "غير مدعوم على iOS", "fr": "non pris en charge sur iOS", "pt": "sem suporte no iOS", "de": "unter iOS nicht unterstützt", + "ja": "iOS では非対応", "id": "tidak didukung di iOS", "tr": "iOS'ta desteklenmiyor"], + "timeout": [ + "ru": "таймаут", "zh": "超时", "es": "tiempo agotado", "hi": "टाइमआउट", + "ar": "انتهت المهلة", "fr": "délai dépassé", "pt": "tempo esgotado", "de": "Zeitüberschreitung", + "ja": "タイムアウト", "id": "waktu habis", "tr": "zaman aşımı"], + + // MARK: macOS settings + "Launch at login": [ + "ru": "Запускать при входе", "zh": "登录时启动", "es": "Abrir al iniciar sesión", "hi": "लॉगिन पर लॉन्च करें", + "ar": "التشغيل عند تسجيل الدخول", "fr": "Lancer à l'ouverture de session", "pt": "Abrir ao iniciar sessão", "de": "Beim Anmelden starten", + "ja": "ログイン時に起動", "id": "Jalankan saat masuk", "tr": "Oturum açınca başlat"], + "Notify on connect / disconnect": [ + "ru": "Уведомлять о подключении / отключении", "zh": "连接/断开时通知", "es": "Notificar al conectar / desconectar", "hi": "कनेक्ट / डिस्कनेक्ट पर सूचित करें", + "ar": "تنبيه عند الاتصال / قطع الاتصال", "fr": "Notifier à la connexion / déconnexion", "pt": "Notificar ao conectar / desconectar", "de": "Bei Verbindung / Trennung benachrichtigen", + "ja": "接続 / 切断時に通知", "id": "Beri tahu saat terhubung / terputus", "tr": "Bağlanınca / kesilince bildir"], + "Edit": [ + "ru": "Изменить", "zh": "编辑", "es": "Editar", "hi": "संपादित करें", + "ar": "تحرير", "fr": "Modifier", "pt": "Editar", "de": "Bearbeiten", + "ja": "編集", "id": "Ubah", "tr": "Düzenle"], + "Hold to connect": [ + "ru": "Удерживайте, чтобы подключить", "zh": "长按以连接", "es": "Mantén pulsado para conectar", "hi": "कनेक्ट करने के लिए दबाए रखें", + "ar": "اضغط مطولًا للاتصال", "fr": "Maintenez pour connecter", "pt": "Mantenha pressionado para conectar", "de": "Zum Verbinden gedrückt halten", + "ja": "長押しで接続", "id": "Tahan untuk menghubungkan", "tr": "Bağlanmak için basılı tutun"], + "Hold to disconnect": [ + "ru": "Удерживайте, чтобы отключить", "zh": "长按以断开", "es": "Mantén pulsado para desconectar", "hi": "डिस्कनेक्ट करने के लिए दबाए रखें", + "ar": "اضغط مطولًا لقطع الاتصال", "fr": "Maintenez pour déconnecter", "pt": "Mantenha pressionado para desconectar", "de": "Zum Trennen gedrückt halten", + "ja": "長押しで切断", "id": "Tahan untuk memutuskan", "tr": "Bağlantıyı kesmek için basılı tutun"], ] } diff --git a/Sources/XrayClient/Core/PingTester.swift b/Sources/XrayClient/Core/PingTester.swift index 8745669..a8dbe95 100644 --- a/Sources/XrayClient/Core/PingTester.swift +++ b/Sources/XrayClient/Core/PingTester.swift @@ -24,6 +24,7 @@ final class PingTester { } Task.detached(priority: .userInitiated) { + #if os(macOS) // Resolve hostnames to IPs and pin them off the tunnel for the test. var pinnedIPs: [String] = [] if tunActive { @@ -38,6 +39,7 @@ final class PingTester { // Give the routing table a moment to settle. try? await Task.sleep(nanoseconds: 200_000_000) } + #endif await withTaskGroup(of: (UUID, Int?).self) { group in let maxConcurrent = 16 @@ -86,9 +88,11 @@ final class PingTester { if !pending.isEmpty { await self.applyResults(pending) } } + #if os(macOS) if tunActive { TunManager.pingRouteDel() } + #endif } } diff --git a/Sources/XrayClient/Core/QRCode.swift b/Sources/XrayClient/Core/QRCode.swift index 755272f..3034ac9 100644 --- a/Sources/XrayClient/Core/QRCode.swift +++ b/Sources/XrayClient/Core/QRCode.swift @@ -1,12 +1,30 @@ import Foundation import CoreImage + +#if canImport(AppKit) import AppKit +typealias PlatformImage = NSImage +#elseif canImport(UIKit) +import UIKit +typealias PlatformImage = UIImage +#endif /// QR-code generation and decoding helpers built on CoreImage. enum QRCode { - /// Renders `string` into a crisp QR-code NSImage of roughly `size` points. - static func image(from string: String, size: CGFloat = 240) -> NSImage? { + /// Renders `string` into a crisp QR-code image of roughly `size` points. + static func image(from string: String, size: CGFloat = 240) -> PlatformImage? { + guard let cg = cgImage(from: string, size: size) else { return nil } + #if canImport(AppKit) + return NSImage(cgImage: cg, size: NSSize(width: size, height: size)) + #else + return UIImage(cgImage: cg) + #endif + } + + /// The raw CGImage behind `image(from:size:)`, handy when the caller wants + /// the bitmap itself rather than a view-ready image. + static func cgImage(from string: String, size: CGFloat = 240) -> CGImage? { let data = Data(string.utf8) guard let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil } filter.setValue(data, forKey: "inputMessage") @@ -18,15 +36,18 @@ enum QRCode { let scale = size / output.extent.width let scaled = output.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) - let context = CIContext() - guard let cg = context.createCGImage(scaled, from: scaled.extent) else { return nil } - return NSImage(cgImage: cg, size: NSSize(width: size, height: size)) + return CIContext().createCGImage(scaled, from: scaled.extent) } /// Detects and returns the first QR-code payload found in an image, if any. - static func decode(from image: NSImage) -> String? { + static func decode(from image: PlatformImage) -> String? { + #if canImport(AppKit) guard let tiff = image.tiffRepresentation, let ci = CIImage(data: tiff) else { return nil } + #else + guard let cg = image.cgImage else { return nil } + let ci = CIImage(cgImage: cg) + #endif return decode(ciImage: ci) } diff --git a/Sources/XrayClient/Core/ServerStore.swift b/Sources/XrayClient/Core/ServerStore.swift index 38c70a2..810a8bf 100644 --- a/Sources/XrayClient/Core/ServerStore.swift +++ b/Sources/XrayClient/Core/ServerStore.swift @@ -14,11 +14,17 @@ final class ServerStore { init() { let fm = FileManager.default + #if os(iOS) + // Live in the shared app group so the tunnel extension reads the same + // servers and settings the app writes. + let dir = AppGroup.supportDirectory + #else let base = (try? fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)) ?? fm.temporaryDirectory let dir = base.appendingPathComponent("XrayClient", isDirectory: true) + #endif try? fm.createDirectory(at: dir, withIntermediateDirectories: true) self.fileURL = dir.appendingPathComponent("store.json") load() diff --git a/Sources/XrayClient/Core/SingBoxConfigBuilder.swift b/Sources/XrayClient/Core/SingBoxConfigBuilder.swift index a9d0b8e..11d9f53 100644 --- a/Sources/XrayClient/Core/SingBoxConfigBuilder.swift +++ b/Sources/XrayClient/Core/SingBoxConfigBuilder.swift @@ -1,3 +1,7 @@ +// macOS-only: sing-box is the second bundled core. The iOS build ships +// Xray-core alone, so protocols that need sing-box are unavailable there +// (see ProxyConfig.xraySupported). +#if os(macOS) import Foundation /// Builds a sing-box JSON configuration from a `ProxyConfig`. @@ -255,3 +259,4 @@ enum SingBoxConfigBuilder { return hasMatcher ? r : nil } } +#endif diff --git a/Sources/XrayClient/Core/SystemIntegration.swift b/Sources/XrayClient/Core/SystemIntegration.swift index 1944a4f..e3053ec 100644 --- a/Sources/XrayClient/Core/SystemIntegration.swift +++ b/Sources/XrayClient/Core/SystemIntegration.swift @@ -1,7 +1,9 @@ import Foundation -import ServiceManagement import UserNotifications +#if os(macOS) +import ServiceManagement + /// Manages the "launch at login" state via the modern SMAppService API /// (macOS 13+). Registering adds the app as a login item; unregistering removes /// it. Requires the app to be a real bundle (it is, via package-app.sh). @@ -33,6 +35,7 @@ enum LoginItem { } } } +#endif /// Thin wrapper around UNUserNotificationCenter for connection status alerts. @MainActor diff --git a/Sources/XrayClient/Core/SystemProxy.swift b/Sources/XrayClient/Core/SystemProxy.swift index b691b30..f7047b0 100644 --- a/Sources/XrayClient/Core/SystemProxy.swift +++ b/Sources/XrayClient/Core/SystemProxy.swift @@ -1,3 +1,7 @@ +// macOS-only: this file drives the system proxy / tun2socks / a bundled +// core subprocess, none of which exist on iOS. The iOS build runs Xray +// in-process inside the NetworkExtension instead (see ios/Tunnel). +#if os(macOS) import Foundation /// Controls the macOS system-wide proxy via `networksetup`. @@ -139,3 +143,4 @@ enum SystemProxy { return String(data: data, encoding: .utf8) ?? "" } } +#endif diff --git a/Sources/XrayClient/Core/TunManager.swift b/Sources/XrayClient/Core/TunManager.swift index faf26d7..b86168e 100644 --- a/Sources/XrayClient/Core/TunManager.swift +++ b/Sources/XrayClient/Core/TunManager.swift @@ -1,3 +1,7 @@ +// macOS-only: this file drives the system proxy / tun2socks / a bundled +// core subprocess, none of which exist on iOS. The iOS build runs Xray +// in-process inside the NetworkExtension instead (see ios/Tunnel). +#if os(macOS) import Foundation /// Manages TUN (full-traffic) mode using `tun2socks` + route manipulation. @@ -205,3 +209,4 @@ enum TunManager { .joined(separator: " ") } } +#endif diff --git a/Sources/XrayClient/Core/XrayConfigBuilder.swift b/Sources/XrayClient/Core/XrayConfigBuilder.swift index 16ec721..8f6dc7c 100644 --- a/Sources/XrayClient/Core/XrayConfigBuilder.swift +++ b/Sources/XrayClient/Core/XrayConfigBuilder.swift @@ -7,25 +7,81 @@ struct InboundPorts { let listen: String = "127.0.0.1" } +/// How traffic reaches the core. +/// +/// macOS runs Xray as a subprocess and feeds it through local SOCKS/HTTP +/// inbounds (the system proxy or tun2socks points at them). iOS runs Xray +/// inside the NetworkExtension and hands it the utun descriptor directly, so +/// there the core uses its own layer-3 `tun` inbound and there is no local +/// proxy hop at all. +enum XrayInbound { + case localProxy(InboundPorts) + case tun(name: String, mtu: Int) +} + /// Builds an Xray-core JSON configuration from a `ProxyConfig`. /// -/// Produces two inbounds (SOCKS5 + HTTP) on localhost and one outbound for the -/// selected server, plus a direct outbound for routing rules. +/// Produces the requested inbound plus one outbound for the selected server and +/// direct/block outbounds for routing rules. enum XrayConfigBuilder { static func build(for cfg: ProxyConfig, ports: InboundPorts = InboundPorts(), rules: [RoutingRule] = [], logLevel: String = "warning") -> [String: Any] { + build(for: cfg, inbound: .localProxy(ports), rules: rules, logLevel: logLevel) + } + + /// - Parameters: + /// - inbound: local SOCKS/HTTP proxy, or Xray's native layer-3 TUN. + /// - logFile: when set, the core appends its error log here instead of + /// stderr — the only way to read logs out of a NetworkExtension. + /// - dnsServers: when non-empty, an Xray `dns` section is emitted and all + /// port-53 traffic is answered by the core. Needed in TUN mode, where + /// the OS resolver's queries arrive as raw packets. + /// - stats: enables traffic counters on the proxy outbound. + static func build(for cfg: ProxyConfig, + inbound: XrayInbound, + rules: [RoutingRule] = [], + logLevel: String = "warning", + logFile: String? = nil, + dnsServers: [String] = [], + stats: Bool = false) -> [String: Any] { let isBalancer = cfg.isBalancer let proxyOutbounds = outbounds(for: cfg) let balancerTags = isBalancer ? proxyOutbounds.compactMap { $0["tag"] as? String } : [] - return [ - "log": ["loglevel": logLevel], - "inbounds": inbounds(ports), - "outbounds": proxyOutbounds + [directOutbound(), blockOutbound()], - "routing": routing(rules: rules, isBalancer: isBalancer, balancerTags: balancerTags) + + var log: [String: Any] = ["loglevel": logLevel] + if let logFile, !logFile.isEmpty { + log["error"] = logFile + log["access"] = "none" + } + + var auxOutbounds = [directOutbound(), blockOutbound()] + if !dnsServers.isEmpty { auxOutbounds.append(dnsOutbound()) } + + var config: [String: Any] = [ + "log": log, + "inbounds": inbounds(inbound), + "outbounds": proxyOutbounds + auxOutbounds, + "routing": routing(rules: rules, + isBalancer: isBalancer, + balancerTags: balancerTags, + hasDNS: !dnsServers.isEmpty) ] + if !dnsServers.isEmpty { + config["dns"] = ["servers": dnsServers, "queryStrategy": "UseIP"] + } + if stats { + config["stats"] = [String: Any]() + config["policy"] = [ + "system": [ + "statsOutboundUplink": true, + "statsOutboundDownlink": true + ] + ] + } + return config } static func jsonData(for cfg: ProxyConfig, @@ -37,9 +93,30 @@ enum XrayConfigBuilder { options: [.prettyPrinted, .sortedKeys]) } + static func jsonString(for cfg: ProxyConfig, + inbound: XrayInbound, + rules: [RoutingRule] = [], + logLevel: String = "warning", + logFile: String? = nil, + dnsServers: [String] = [], + stats: Bool = false) throws -> String { + let dict = build(for: cfg, inbound: inbound, rules: rules, logLevel: logLevel, + logFile: logFile, dnsServers: dnsServers, stats: stats) + let data = try JSONSerialization.data(withJSONObject: dict, + options: [.prettyPrinted, .sortedKeys]) + return String(decoding: data, as: UTF8.self) + } + // MARK: - Inbounds - private static func inbounds(_ p: InboundPorts) -> [[String: Any]] { + private static func inbounds(_ inbound: XrayInbound) -> [[String: Any]] { + switch inbound { + case .localProxy(let p): return proxyInbounds(p) + case .tun(let name, let mtu): return [tunInbound(name: name, mtu: mtu)] + } + } + + private static func proxyInbounds(_ p: InboundPorts) -> [[String: Any]] { [ [ "tag": "socks-in", @@ -59,6 +136,25 @@ enum XrayConfigBuilder { ] } + /// Xray's own layer-3 inbound. It ignores `listen`/`port` and instead reads + /// raw IP packets from the interface — on iOS from the descriptor published + /// through the `xray.tun.fd` environment flag by the tunnel provider. + /// Sniffing is what lets domain-based routing rules still work when all the + /// core sees is IP packets. + private static func tunInbound(name: String, mtu: Int) -> [String: Any] { + [ + "tag": "tun-in", + "port": 0, + "protocol": "tun", + "settings": ["name": name, "MTU": mtu], + "sniffing": [ + "enabled": true, + "destOverride": ["http", "tls", "quic"], + "routeOnly": false + ] + ] + } + // MARK: - Outbound dispatch private static func singleOutbound(_ cfg: ProxyConfig, tag: String) -> [String: Any] { @@ -68,14 +164,17 @@ enum XrayConfigBuilder { case .vmess: out = vmessOutbound(cfg) case .trojan: out = trojanOutbound(cfg) case .shadowsocks: out = shadowsocksOutbound(cfg) - case .hysteria2, .tuic, .wireguard, .anytls: + case .wireguard: out = wireguardOutbound(cfg) + case .hysteria2, .tuic, .anytls: // Handled by the sing-box core, never here. ConnectionManager routes // them to SingBoxConfigBuilder; this is only reachable if called // directly, so emit a harmless freedom outbound. out = ["protocol": "freedom", "settings": [:]] } out["tag"] = tag - if let mux = muxSettings(cfg) { out["mux"] = mux } + // WireGuard carries its own transport; stream settings and mux do not + // apply to it. + if cfg.proto != .wireguard, let mux = muxSettings(cfg) { out["mux"] = mux } return out } @@ -170,6 +269,27 @@ enum XrayConfigBuilder { ] } + /// Xray's native WireGuard outbound. Used by the iOS build, where Xray is + /// the only core; macOS routes WireGuard through sing-box instead. + private static func wireguardOutbound(_ cfg: ProxyConfig) -> [String: Any] { + var peer: [String: Any] = [ + "endpoint": "\(cfg.address):\(cfg.port)", + "publicKey": cfg.peerPublicKey ?? "", + "keepAlive": 25 + ] + if let psk = cfg.presharedKey, !psk.isEmpty { peer["preSharedKey"] = psk } + + var settings: [String: Any] = [ + "secretKey": cfg.privateKey ?? "", + "address": cfg.localAddresses ?? ["10.0.0.2/32"], + "peers": [peer] + ] + if let mtu = cfg.mtu, mtu > 0 { settings["mtu"] = mtu } + if let reserved = cfg.reserved, !reserved.isEmpty { settings["reserved"] = reserved } + + return ["protocol": "wireguard", "settings": settings] + } + // MARK: - Stream settings (transport + security) private static func streamSettings(_ cfg: ProxyConfig) -> [String: Any] { @@ -268,13 +388,31 @@ enum XrayConfigBuilder { ["tag": "block", "protocol": "blackhole", "settings": [:]] } + /// Answers DNS queries from the core's own `dns` section instead of letting + /// them travel as opaque UDP. Only emitted alongside a `dns` config. + private static func dnsOutbound() -> [String: Any] { + ["tag": "dns-out", "protocol": "dns", "settings": [:]] + } + /// Builds the routing section from an ordered rule list. The first matching /// rule wins. For a balancer group the final catch-all rule uses an Xray /// `balancerTag` so traffic is distributed across all nodes. private static func routing(rules: [RoutingRule], isBalancer: Bool, - balancerTags: [String] = []) -> [String: Any] { - var ruleList = rules.compactMap { $0.xrayRule(useBalancerForProxy: isBalancer) } + balancerTags: [String] = [], + hasDNS: Bool = false) -> [String: Any] { + var ruleList: [[String: Any]] = [] + // DNS first: in TUN mode the resolver's queries arrive as plain UDP + // packets, and we want the core to answer them (and to fetch the answer + // through the tunnel) rather than forwarding them verbatim. + if hasDNS { + ruleList.append([ + "type": "field", + "port": "53", + "outboundTag": "dns-out" + ]) + } + ruleList += rules.compactMap { $0.xrayRule(useBalancerForProxy: isBalancer) } let finalRule: [String: Any] = [ "type": "field", "network": "tcp,udp", diff --git a/Sources/XrayClient/Core/XrayProcess.swift b/Sources/XrayClient/Core/XrayProcess.swift index f3b213c..da6c900 100644 --- a/Sources/XrayClient/Core/XrayProcess.swift +++ b/Sources/XrayClient/Core/XrayProcess.swift @@ -1,3 +1,7 @@ +// macOS-only: this file drives the system proxy / tun2socks / a bundled +// core subprocess, none of which exist on iOS. The iOS build runs Xray +// in-process inside the NetworkExtension instead (see ios/Tunnel). +#if os(macOS) import Foundation /// Safe replacement for the SPM-synthesized `Bundle.module`. @@ -142,3 +146,4 @@ final class XrayProcess { process = nil } } +#endif diff --git a/Sources/XrayClient/Models/AppSettings.swift b/Sources/XrayClient/Models/AppSettings.swift index 1fac86a..acd62a7 100644 --- a/Sources/XrayClient/Models/AppSettings.swift +++ b/Sources/XrayClient/Models/AppSettings.swift @@ -79,6 +79,13 @@ struct AppSettings: Codable, Equatable { // Subscription var sendHwid: Bool = true + // Tunnel shape. Only the iOS build reads these — there the whole tunnel is + // Xray's own layer-3 inbound behind NetworkExtension, so the interface MTU, + // the address families we claim and the resolver are ours to pick. + var tunnelMTU: Int = 1500 + var ipv6Enabled: Bool = true + var dnsServers: [String] = ["1.1.1.1", "8.8.8.8"] + init() {} /// Resilient decoding: any missing key falls back to its default so old @@ -108,6 +115,18 @@ struct AppSettings: Codable, Equatable { launchAtLogin = get(.launchAtLogin, false) notifyOnConnect = get(.notifyOnConnect, false) sendHwid = get(.sendHwid, true) + tunnelMTU = get(.tunnelMTU, 1500) + ipv6Enabled = get(.ipv6Enabled, true) + dnsServers = get(.dnsServers, ["1.1.1.1", "8.8.8.8"]) + } + + /// DNS servers to hand the tunnel, never empty — an empty resolver list + /// would leave the device with no way to resolve anything at all. + var effectiveDNSServers: [String] { + let cleaned = dnsServers + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + return cleaned.isEmpty ? ["1.1.1.1", "8.8.8.8"] : cleaned } /// The ordered routing rules to feed Xray, derived from the active preset diff --git a/Sources/XrayClient/Models/ProxyConfig.swift b/Sources/XrayClient/Models/ProxyConfig.swift index 4888712..a322988 100644 --- a/Sources/XrayClient/Models/ProxyConfig.swift +++ b/Sources/XrayClient/Models/ProxyConfig.swift @@ -110,7 +110,9 @@ struct ProxyConfig: Codable, Identifiable, Equatable { [address] + (alternates?.map(\.address) ?? []) } - /// The core engine that handles this protocol. + /// The core engine that handles this protocol on macOS, where both cores + /// are bundled. WireGuard is handled by sing-box there because its endpoint + /// model is a better fit for the desktop routing setup. var engine: CoreEngine { switch proto { case .vless, .vmess, .trojan, .shadowsocks: return .xray @@ -118,6 +120,15 @@ struct ProxyConfig: Codable, Identifiable, Equatable { } } + /// Whether Xray-core alone can run this protocol. The iOS build ships no + /// second core, so entries where this is false cannot be connected there. + var xraySupported: Bool { + switch proto { + case .vless, .vmess, .trojan, .shadowsocks, .wireguard: return true + case .hysteria2, .tuic, .anytls: return false + } + } + /// How the ping tester should probe this server for reachability + RTT. enum PingStrategy: Sendable, Equatable { case tcp // standard TCP connect (has a TCP listener) diff --git a/Tests/XrayClientTests/AddInputTests.swift b/Tests/XrayClientTests/AddInputTests.swift new file mode 100644 index 0000000..e32fbb2 --- /dev/null +++ b/Tests/XrayClientTests/AddInputTests.swift @@ -0,0 +1,111 @@ +import XCTest +@testable import XrayClient + +/// The add screen offers a single action and infers what was pasted, so the +/// inference is the thing that has to be right. +final class AddInputTests: XCTestCase { + + private let vless = "vless://11111111-2222-3333-4444-555555555555@1.2.3.4:443" + + "?encryption=none&security=reality&sni=www.microsoft.com&fp=chrome" + + "&pbk=xr0PXbLQvB0qCLLm7d5MO_9y2Bqk5DoMHKcVAKTZ1UA&sid=0123abcd" + + "&type=tcp&flow=xtls-rprx-vision#NL" + + func testSingleShareLink() { + guard case .servers(let servers) = AddInputClassifier.classify(vless) else { + return XCTFail("expected servers") + } + XCTAssertEqual(servers.count, 1) + XCTAssertEqual(servers[0].name, "NL") + } + + func testMultipleLinksOnePerLine() { + let text = """ + \(vless) + trojan://pw@us.example.com:443?security=tls#US + ss://YWVzLTI1Ni1nY206aHVudGVyMg@jp.example.com:8388#JP + """ + guard case .servers(let servers) = AddInputClassifier.classify(text) else { + return XCTFail("expected servers") + } + XCTAssertEqual(servers.count, 3) + } + + func testLinksSurvivedLeadingAndTrailingWhitespace() { + guard case .servers = AddInputClassifier.classify("\n \(vless) \n\n") else { + return XCTFail("expected servers") + } + } + + /// Balancer members must already be folded together here, so the add sheet + /// reports "1 server", not "3", and the store gets the grouped entry. + func testNumberedNodesAreGrouped() { + let text = """ + \(vless.replacingOccurrences(of: "#NL", with: "#NL-01")) + \(vless.replacingOccurrences(of: "#NL", with: "#NL-02")) + \(vless.replacingOccurrences(of: "#NL", with: "#NL-03")) + """ + guard case .servers(let servers) = AddInputClassifier.classify(text) else { + return XCTFail("expected servers") + } + XCTAssertEqual(servers.count, 1) + XCTAssertEqual(servers[0].alternates?.count, 2) + } + + func testHTTPSSubscriptionURL() { + XCTAssertEqual(AddInputClassifier.classify("https://panel.example.com/sub/abc"), + .subscription("https://panel.example.com/sub/abc")) + XCTAssertEqual(AddInputClassifier.classify(" http://panel.example.com/s "), + .subscription("http://panel.example.com/s")) + } + + /// Some panels hand out the subscription URL base64-wrapped. + func testBase64WrappedSubscriptionURL() { + let encoded = Data("https://panel.example.com/sub/abc".utf8).base64EncodedString() + XCTAssertEqual(AddInputClassifier.classify(encoded), + .subscription("https://panel.example.com/sub/abc")) + } + + /// …and a user may paste the subscription *body* rather than its URL. + func testBase64SubscriptionBodyBecomesServers() { + let body = Data("\(vless)\ntrojan://pw@us.example.com:443#US".utf8) + .base64EncodedString() + guard case .servers(let servers) = AddInputClassifier.classify(body) else { + return XCTFail("expected servers") + } + XCTAssertEqual(servers.count, 2) + } + + func testWireGuardConfProfile() { + let conf = """ + [Interface] + PrivateKey = cHJpdmF0ZQ== + Address = 10.2.0.2/32 + MTU = 1420 + + [Peer] + PublicKey = cHVibGlj + Endpoint = wg.example.com:51820 + """ + guard case .servers(let servers) = AddInputClassifier.classify(conf) else { + return XCTFail("expected servers") + } + XCTAssertEqual(servers.count, 1) + XCTAssertEqual(servers[0].proto, .wireguard) + XCTAssertEqual(servers[0].port, 51820) + } + + func testGarbageAndEmptyAreRejected() { + XCTAssertEqual(AddInputClassifier.classify(""), .unrecognized) + XCTAssertEqual(AddInputClassifier.classify(" \n "), .unrecognized) + XCTAssertEqual(AddInputClassifier.classify("hello world"), .unrecognized) + XCTAssertEqual(AddInputClassifier.classify("ftp://example.com/x"), .unrecognized) + // A scheme we do not support must not be mistaken for a subscription. + XCTAssertEqual(AddInputClassifier.classify("ssh://host"), .unrecognized) + } + + /// A bare hostname is not a subscription — requiring a scheme keeps typos + /// from silently becoming a download attempt. + func testHostWithoutSchemeIsRejected() { + XCTAssertEqual(AddInputClassifier.classify("panel.example.com/sub"), .unrecognized) + } +} diff --git a/Tests/XrayClientTests/LocalizationTests.swift b/Tests/XrayClientTests/LocalizationTests.swift new file mode 100644 index 0000000..2f08f8f --- /dev/null +++ b/Tests/XrayClientTests/LocalizationTests.swift @@ -0,0 +1,94 @@ +import XCTest +@testable import XrayClient + +/// Guards against the failure mode where a screen ends up half-translated: +/// `Loc` falls back to the English key when a string is missing, so an +/// untranslated entry is invisible in development and only shows up as mixed +/// language to a user running the app in their own locale. +/// +/// These tests read the sources rather than the built product, so they cover +/// the iOS targets too even though the test bundle itself is macOS. +/// `Loc` is main-actor isolated, and so is its table. +@MainActor +final class LocalizationTests: XCTestCase { + + /// Every language the table claims to support. + private static let languages: Set = [ + "ru", "zh", "es", "hi", "ar", "fr", "pt", "de", "ja", "id", "tr" + ] + + private static let repoRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // XrayClientTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // repo root + + /// Directories whose `loc("…")` calls must all resolve. + private static let uiDirectories = [ + "Sources/XrayClient/Views", + "ios/App", + ] + + func testEveryKeyHasEveryLanguage() { + for (key, translations) in Loc.table { + let missing = Self.languages.subtracting(translations.keys) + XCTAssertTrue(missing.isEmpty, + "\"\(key)\" is missing: \(missing.sorted().joined(separator: ", "))") + for (language, text) in translations { + XCTAssertFalse(text.trimmingCharacters(in: .whitespaces).isEmpty, + "\"\(key)\" has an empty \(language) translation") + } + } + } + + func testNoUnsupportedLanguageCodesSlippedIn() { + for (key, translations) in Loc.table { + let extra = Set(translations.keys).subtracting(Self.languages) + XCTAssertTrue(extra.isEmpty, + "\"\(key)\" has unknown language(s): \(extra.sorted().joined(separator: ", "))") + } + } + + /// Any string the UI passes through `loc(…)` must exist in the table, + /// otherwise it silently renders in English. + func testEveryStringUsedByTheUIIsTranslated() throws { + let keys = Set(Loc.table.keys) + var used: Set = [] + + for directory in Self.uiDirectories { + let url = Self.repoRoot.appendingPathComponent(directory) + guard FileManager.default.fileExists(atPath: url.path) else { + XCTFail("UI directory not found: \(directory)") + continue + } + used.formUnion(try Self.locCalls(in: url)) + } + + XCTAssertFalse(used.isEmpty, "found no loc(…) calls — did the scan break?") + + let untranslated = used.subtracting(keys).sorted() + XCTAssertTrue(untranslated.isEmpty, + "not in LocalizationTable: \(untranslated.joined(separator: " | "))") + } + + /// Extracts the literal arguments of every `loc("…")` call under `url`. + private static func locCalls(in url: URL) throws -> Set { + let regex = try NSRegularExpression(pattern: #"loc\("((?:[^"\\]|\\.)*)"\)"#) + var found: Set = [] + + let enumerator = FileManager.default.enumerator(at: url, + includingPropertiesForKeys: nil) + while let file = enumerator?.nextObject() as? URL { + guard file.pathExtension == "swift" else { continue } + let source = try String(contentsOf: file, encoding: .utf8) + let range = NSRange(source.startIndex..., in: source) + for match in regex.matches(in: source, range: range) { + guard let literal = Range(match.range(at: 1), in: source) else { continue } + // Undo the Swift escaping so the key matches the table's. + found.insert(String(source[literal]) + .replacingOccurrences(of: "\\\"", with: "\"") + .replacingOccurrences(of: "\\\\", with: "\\")) + } + } + return found + } +} diff --git a/Tests/XrayClientTests/TunConfigTests.swift b/Tests/XrayClientTests/TunConfigTests.swift new file mode 100644 index 0000000..fd635ca --- /dev/null +++ b/Tests/XrayClientTests/TunConfigTests.swift @@ -0,0 +1,170 @@ +import XCTest +@testable import XrayClient + +/// Covers the layer-3 config the iOS build feeds to Xray inside the +/// NetworkExtension. The desktop app never uses this shape, so without these +/// tests a change to the builder could silently break the phone. +final class TunConfigTests: XCTestCase { + + private func realityServer() -> ProxyConfig { + var cfg = ProxyConfig(name: "NL", proto: .vless, address: "1.2.3.4", port: 443) + cfg.uuid = "11111111-2222-3333-4444-555555555555" + cfg.flow = "xtls-rprx-vision" + cfg.security = .reality + cfg.sni = "www.microsoft.com" + cfg.fingerprint = "chrome" + cfg.publicKey = "xr0PXbLQvB0qCLLm7d5MO_9y2Bqk5DoMHKcVAKTZ1UA" + cfg.shortId = "0123abcd" + return cfg + } + + private func tunConfig(_ cfg: ProxyConfig, + rules: [RoutingRule] = [], + dns: [String] = ["1.1.1.1"], + stats: Bool = true) -> [String: Any] { + XrayConfigBuilder.build(for: cfg, + inbound: .tun(name: "utun9", mtu: 1500), + rules: rules, + logLevel: "warning", + logFile: "/tmp/xray.log", + dnsServers: dns, + stats: stats) + } + + // MARK: - Inbound + + func testTunInboundReplacesLocalProxies() { + let config = tunConfig(realityServer()) + let inbounds = config["inbounds"] as! [[String: Any]] + + XCTAssertEqual(inbounds.count, 1) + XCTAssertEqual(inbounds[0]["protocol"] as? String, "tun") + XCTAssertEqual(inbounds[0]["tag"] as? String, "tun-in") + + let settings = inbounds[0]["settings"] as! [String: Any] + XCTAssertEqual(settings["name"] as? String, "utun9") + XCTAssertEqual(settings["MTU"] as? Int, 1500) + } + + /// All the core sees is IP packets, so without sniffing no domain rule + /// would ever match. + func testTunInboundEnablesSniffing() { + let inbounds = tunConfig(realityServer())["inbounds"] as! [[String: Any]] + let sniffing = inbounds[0]["sniffing"] as! [String: Any] + XCTAssertEqual(sniffing["enabled"] as? Bool, true) + XCTAssertEqual(sniffing["destOverride"] as? [String], ["http", "tls", "quic"]) + } + + func testLocalProxyInboundsStillDefaultForDesktop() { + let inbounds = XrayConfigBuilder.build(for: realityServer())["inbounds"] as! [[String: Any]] + XCTAssertEqual(inbounds.map { $0["protocol"] as? String }, ["socks", "http"]) + } + + // MARK: - Outbound + + /// The TUN path must reuse the exact same outbound builder as the desktop + /// path — Reality/XHTTP settings have to be byte-for-byte identical. + func testOutboundMatchesDesktopBuild() { + let server = realityServer() + let tun = tunConfig(server)["outbounds"] as! [[String: Any]] + let desktop = XrayConfigBuilder.build(for: server)["outbounds"] as! [[String: Any]] + + let tunProxy = tun.first { $0["tag"] as? String == "proxy" }! + let desktopProxy = desktop.first { $0["tag"] as? String == "proxy" }! + XCTAssertEqual(NSDictionary(dictionary: tunProxy), + NSDictionary(dictionary: desktopProxy)) + } + + func testWireGuardOutboundIsNativeXray() { + var wg = ProxyConfig(name: "WG", proto: .wireguard, address: "9.9.9.9", port: 51820) + wg.privateKey = "cHJpdmF0ZQ==" + wg.peerPublicKey = "cHVibGlj" + wg.presharedKey = "cHNr" + wg.localAddresses = ["10.2.0.2/32"] + wg.mtu = 1420 + + let outbounds = tunConfig(wg)["outbounds"] as! [[String: Any]] + let proxy = outbounds.first { $0["tag"] as? String == "proxy" }! + XCTAssertEqual(proxy["protocol"] as? String, "wireguard") + // WireGuard brings its own transport — mux must not be attached. + XCTAssertNil(proxy["mux"]) + + let settings = proxy["settings"] as! [String: Any] + XCTAssertEqual(settings["secretKey"] as? String, "cHJpdmF0ZQ==") + XCTAssertEqual(settings["address"] as? [String], ["10.2.0.2/32"]) + XCTAssertEqual(settings["mtu"] as? Int, 1420) + + let peer = (settings["peers"] as! [[String: Any]])[0] + XCTAssertEqual(peer["endpoint"] as? String, "9.9.9.9:51820") + XCTAssertEqual(peer["publicKey"] as? String, "cHVibGlj") + XCTAssertEqual(peer["preSharedKey"] as? String, "cHNr") + } + + // MARK: - DNS + + /// In TUN mode the resolver's queries arrive as raw UDP, so they have to be + /// picked off by a port-53 rule and answered by the core's DNS outbound — + /// otherwise they would leave the device unproxied. + func testDNSSectionAndPort53RuleComeFirst() { + let config = tunConfig(realityServer(), + rules: RoutingPreset.bypassLAN.builtInRules(blockAds: false)) + + let dns = config["dns"] as! [String: Any] + XCTAssertEqual(dns["servers"] as? [String], ["1.1.1.1"]) + + let outbounds = config["outbounds"] as! [[String: Any]] + XCTAssertTrue(outbounds.contains { $0["tag"] as? String == "dns-out" }) + + let rules = (config["routing"] as! [String: Any])["rules"] as! [[String: Any]] + XCTAssertEqual(rules.first?["port"] as? String, "53") + XCTAssertEqual(rules.first?["outboundTag"] as? String, "dns-out") + } + + func testNoDNSSectionWhenNoServersGiven() { + let config = tunConfig(realityServer(), dns: []) + XCTAssertNil(config["dns"]) + let outbounds = config["outbounds"] as! [[String: Any]] + XCTAssertFalse(outbounds.contains { $0["tag"] as? String == "dns-out" }) + let rules = (config["routing"] as! [String: Any])["rules"] as! [[String: Any]] + XCTAssertNotEqual(rules.first?["outboundTag"] as? String, "dns-out") + } + + // MARK: - Log & stats + + /// A NetworkExtension has no stderr the app can read, so the core must be + /// told to append to a file in the shared container instead. + func testLogGoesToFile() { + let log = tunConfig(realityServer())["log"] as! [String: Any] + XCTAssertEqual(log["error"] as? String, "/tmp/xray.log") + XCTAssertEqual(log["access"] as? String, "none") + XCTAssertEqual(log["loglevel"] as? String, "warning") + } + + func testStatsCountersEnabled() { + let config = tunConfig(realityServer()) + XCTAssertNotNil(config["stats"]) + let system = (config["policy"] as! [String: Any])["system"] as! [String: Any] + XCTAssertEqual(system["statsOutboundUplink"] as? Bool, true) + XCTAssertEqual(system["statsOutboundDownlink"] as? Bool, true) + } + + func testDesktopBuildStillHasNoStatsOrLogFile() { + let config = XrayConfigBuilder.build(for: realityServer()) + XCTAssertNil(config["stats"]) + XCTAssertNil(config["dns"]) + XCTAssertNil((config["log"] as! [String: Any])["error"]) + } + + // MARK: - Protocol support + + func testProtocolsXrayCanRunAlone() { + for proto in [ProxyProtocol.vless, .vmess, .trojan, .shadowsocks, .wireguard] { + XCTAssertTrue(ProxyConfig(name: "x", proto: proto, address: "a", port: 1).xraySupported, + "\(proto) should be supported by the Xray-only iOS build") + } + for proto in [ProxyProtocol.hysteria2, .tuic, .anytls] { + XCTAssertFalse(ProxyConfig(name: "x", proto: proto, address: "a", port: 1).xraySupported, + "\(proto) needs sing-box and cannot run on the iOS build") + } + } +} diff --git a/docs/ios.md b/docs/ios.md new file mode 100644 index 0000000..6a94457 --- /dev/null +++ b/docs/ios.md @@ -0,0 +1,183 @@ +# Veil for iOS + +The iOS app is a real VPN, not a proxy: it uses Apple's **NetworkExtension** +(`NEPacketTunnelProvider`) so every app on the device is tunnelled — Telegram, +Safari, games, UDP, background traffic. + +It ships **one** core: Xray. No tun2socks, no sing-box, no second binary. + +## How the packet path works + +``` +apps ──► iOS routing table ──► utun (created by NetworkExtension) + │ file descriptor + ▼ + Xray-core `tun` inbound (gVisor TCP/IP stack) + │ routing rules, sniffing, DNS + ▼ + VLESS / VMess / Trojan / SS / WireGuard outbound + │ + ▼ + server +``` + +Xray-core has had a native layer-3 `tun` inbound since v26 (`proxy/tun`), with an +explicit iOS mode: if the `xray.tun.fd` environment flag is set, it adopts that +descriptor instead of opening an interface itself. That is exactly what the +tunnel provider does: + +1. `NEPacketTunnelProvider.startTunnel` applies the network settings, which is + when iOS actually materialises the utun interface. +2. It finds the interface's descriptor — there is no API for this, but the utun + socket is the only one in the process that answers + `getsockopt(SYSPROTO_CONTROL, UTUN_OPT_IFNAME)`. +3. `XrayStart(config, fd, …)` publishes the descriptor via `os.Setenv` and boots + the core. + +So the packets never leave the extension process until Xray sends them to the +server. There is no local SOCKS listener and nothing translating TUN to SOCKS. + +## Layout + +| Path | What it is | +|---|---| +| `ios/Veil.xcodeproj` | App + extension targets | +| `ios/App/` | SwiftUI app (server list, settings, routing, QR) | +| `ios/Tunnel/` | `PacketTunnelProvider` — the whole VPN | +| `ios/Shared/` | App-group paths, app↔extension IPC, config writer | +| `ios/XrayBridge/` | Go package binding Xray-core for gomobile | +| `ios/Config/` | Entitlements + the extension's `Info.plist` | +| `Sources/XrayClient/Models`, `.../Core` | **Shared with macOS**, compiled into both iOS targets | + +The parsers, config builder, subscription fetcher, routing model, store, ping +tester and localization are the *same files* the desktop app uses — platform +differences are handled with `#if os(macOS)`, so there is one source of truth +and `swift test` covers both. + +## Adding servers + +There is one entry point, not a "link vs. subscription" choice. Whatever the +user pastes, scans or picks from the photo library goes through +`AddInputClassifier` (in `Sources/XrayClient/Core/AddInput.swift`), which +recognises: + +- share links, one per line (`vless://`, `vmess://`, `trojan://`, `ss://`, + `wireguard://` …) — balancer members are folded together before they land in + the store; +- a wg-quick `[Interface]` profile pasted whole; +- a subscription URL, plain or base64-wrapped the way some panels hand it out; +- a base64 subscription *body*, for users who paste the payload instead of the + address. + +The sheet reports what it recognised before the user commits, and the confirm +button stays disabled for anything it cannot place. The classifier lives in the +shared Core layer and is covered by `AddInputTests`. + +## Protocols + +Everything Xray-core can do on its own: + +| Supported | Not supported on iOS | +|---|---| +| VLESS (Reality, TLS, XTLS Vision, post-quantum encryption) | Hysteria2 | +| VMess | TUIC | +| Trojan | AnyTLS | +| Shadowsocks | | +| WireGuard (Xray's native outbound) | | + +Transports: `tcp`, `ws`, `grpc`, `http`, `xhttp`, `kcp`. + +The three unsupported ones need the sing-box core, which the iOS build +deliberately does not ship. They still appear in the server list (imported from +subscriptions) but are greyed out and cannot be connected — see +`ProxyConfig.xraySupported`. + +## Building + +```bash +# 1. Compile Xray-core for iOS (device + simulator). Takes a few minutes. +Scripts/ios/build-xraycore.sh + +# 2. Build the app +Scripts/ios/build-app.sh simulator Release +Scripts/ios/build-app.sh device Release +``` + +`build-xraycore.sh` installs `gomobile` if missing and writes +`ios/Frameworks/XrayCore.xcframework` (gitignored — it is ~110 MB and +reproducible from source). The framework is a **static** library, so it is +linked into the extension and never embedded. + +To work in Xcode, open `ios/Veil.xcodeproj` after step 1. + +## Signing and capabilities + +Running the tunnel on a real device needs a paid Apple Developer account — +there is no way around it, and no re-signing tool can substitute for it. See +[Why there is no iOS build to download yet](../README.md#why-there-is-no-ios-build-to-download-yet). + +The App ID for **both** the app and the extension must have: + +- **Network Extensions** → `packet-tunnel-provider` +- **App Groups** → `group.dev.local.veil` + +Bundle identifiers are `dev.local.veil` and `dev.local.veil.tunnel`. If you +change them, update `AppGroup.identifier` and +`AppGroup.tunnelBundleIdentifier` in `ios/Shared/AppGroup.swift` and both +`.entitlements` files in `ios/Config/` to match. + +The simulator builds without a team, but NetworkExtension does not run there — +the simulator has no VPN stack. Use it for UI work only. + +## App ↔ extension + +They are separate processes, so everything goes through the shared app group +container `group.dev.local.veil`: + +| File | Written by | Read by | +|---|---|---| +| `store.json` | app | app | +| `xray-config.json` | app | extension | +| `session.json` | app | extension | +| `xray.log` | Xray | app (tailed) | +| `last-error.txt` | extension | app | +| `geo/*.dat` | app | Xray | + +Live state (traffic counters, log tail, core version) is pulled with +`NETunnelProviderSession.sendProviderMessage` once a second while connected. + +**Switching servers does not reconnect the VPN.** The app writes a new config +and sends a `reload` message; the provider restarts only Xray, keeping the same +utun descriptor. iOS never sees the tunnel drop, and the switch is sub-second — +the same trick the desktop app uses with its transport. + +## Tunnel shape + +- IPv4 `198.18.0.1/24`, default route. +- IPv6 `fd6e:a81b:704f:1211::1/64`, default route — **on by default**. If the + tunnel only claimed IPv4, IPv6-capable apps would route around it and leak. + Turn it off in Settings if your server has no IPv6. +- DNS is claimed with `matchDomains = [""]`, so every query enters the tunnel. + Xray picks port-53 traffic off with a routing rule and answers it from its own + `dns` section, which resolves through the proxy. +- MTU 1500 by default (adjustable 1280–1500). +- The Go heap is capped at 48 MB with an aggressive GC. NetworkExtension + processes have a small memory budget and are killed outright when they exceed + it. + +## Reconnect behaviour + +The provider watches `NWPathMonitor`. A Wi-Fi ↔ cellular handover invalidates +Xray's sockets but not the utun, so it just restarts the core (debounced 1.5 s) +while `reasserting` is set. `wake()` does the same after the device sleeps. + +## Verifying a generated config + +The config the app writes is ordinary Xray JSON, so a desktop core can check it: + +```bash +XRAY_TUN_FD=1 ./xray run -test -config xray-config.json +``` + +Without `XRAY_TUN_FD` the desktop core tries to create the interface itself and +fails on permissions — that is expected, and unrelated to the config. diff --git a/ios/App/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/App/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..fc9e0f2 --- /dev/null +++ b/ios/App/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.906", + "green" : "0.318", + "red" : "0.408" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/ios/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 0000000..e4f68be Binary files /dev/null and b/ios/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/ios/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..f22e10c --- /dev/null +++ b/ios/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/App/Assets.xcassets/Contents.json b/ios/App/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/App/TunnelController.swift b/ios/App/TunnelController.swift new file mode 100644 index 0000000..0c5fe1e --- /dev/null +++ b/ios/App/TunnelController.swift @@ -0,0 +1,321 @@ +import Foundation +import Observation +import NetworkExtension +import UserNotifications + +/// Drives the VPN from the app side. +/// +/// The app owns no networking of its own: it writes the Xray config into the +/// shared container, installs/updates the tunnel profile, and then asks +/// NetworkExtension to start it. Everything after that happens in the provider +/// process; state and counters come back over `sendProviderMessage`. +@MainActor +@Observable +final class TunnelController { + + private(set) var state: ConnectionState = .disconnected + private(set) var activeServerID: UUID? + private(set) var activeServerName: String = "" + private(set) var coreVersion: String = "" + private(set) var uplinkBytes: Int64 = 0 + private(set) var downlinkBytes: Int64 = 0 + private(set) var logs: String = "" + private(set) var uptimeText: String = "" + private(set) var isInstalled: Bool = false + + /// Post a local notification on connect / disconnect. + var notifyOnConnect: Bool = false + + private var manager: NETunnelProviderManager? + private var connectedSince: Date? + /// Held in a box so `deinit` — which is never main-actor isolated — can + /// still unregister the observer. + private let statusObserver = ObserverToken() + private var pollTask: Task? + /// Server the user asked for, kept so a switch can reuse the live tunnel. + private var pendingServerID: UUID? + + var isConnected: Bool { state == .connected } + + init() { + Task { await loadManager() } + } + + deinit { + statusObserver.clear() + } + + // MARK: - Profile + + /// Loads the existing VPN profile, if the user has already allowed one. + func loadManager() async { + let managers = (try? await NETunnelProviderManager.loadAllFromPreferences()) ?? [] + let existing = managers.first { + ($0.protocolConfiguration as? NETunnelProviderProtocol)? + .providerBundleIdentifier == AppGroup.tunnelBundleIdentifier + } + manager = existing + isInstalled = existing != nil + if let existing { + observe(existing) + syncState(from: existing.connection.status) + } + } + + /// Creates the profile if needed and points it at the current server. iOS + /// asks the user to allow the VPN configuration the first time this runs. + private func prepareManager(server: ProxyConfig) async throws -> NETunnelProviderManager { + let target = manager ?? NETunnelProviderManager() + + let proto = NETunnelProviderProtocol() + proto.providerBundleIdentifier = AppGroup.tunnelBundleIdentifier + // Shown in Settings > VPN. Routing is Xray's job, this is cosmetic. + proto.serverAddress = server.address + proto.providerConfiguration = ["server": server.name] + // Bring the tunnel back automatically if the provider is ever killed. + proto.disconnectOnSleep = false + + target.protocolConfiguration = proto + target.localizedDescription = "Veil" + target.isEnabled = true + + try await target.saveToPreferences() + // A save invalidates the in-memory object; NE requires a reload before + // the connection can be started. + try await target.loadFromPreferences() + + manager = target + isInstalled = true + observe(target) + return target + } + + private func observe(_ manager: NETunnelProviderManager) { + statusObserver.clear() + statusObserver.value = NotificationCenter.default.addObserver( + forName: .NEVPNStatusDidChange, + object: manager.connection, + queue: .main + ) { [weak self] notification in + guard let connection = notification.object as? NEVPNConnection else { return } + let status = connection.status + Task { @MainActor in self?.syncState(from: status) } + } + } + + // MARK: - Connect / disconnect + + /// Connects to `server`, or switches to it without dropping the tunnel when + /// one is already up. + func connect(to server: ProxyConfig, settings: AppSettings) async { + guard server.xraySupported else { + fail(TunnelConfigWriter.WriteError.unsupportedProtocol(server.proto).localizedDescription) + return + } + + do { + try TunnelConfigWriter.write(server: server, settings: settings) + } catch { + fail(error.localizedDescription) + return + } + + pendingServerID = server.id + activeServerName = server.name + + // Live tunnel: just tell the provider to reload. The utun stays up, iOS + // never sees a reconnect, and the switch takes well under a second. + if state == .connected, let session = manager?.connection as? NETunnelProviderSession { + do { + try session.sendProviderMessage(TunnelRequest(.reload).encoded()) { [weak self] data in + Task { @MainActor in + self?.activeServerID = server.id + if let data, let status = TunnelStatus.decode(data) { + self?.apply(status) + } + } + } + return + } catch { + // Provider not reachable — fall through to a full restart. + } + } + + state = .connecting + do { + let target = try await prepareManager(server: server) + try target.connection.startVPNTunnel() + } catch { + fail(Self.describe(error)) + } + } + + /// NetworkExtension reports its problems as bare `NEVPNError`s whose + /// `localizedDescription` is developer-speak ("IPC failed"). Translate the + /// ones a user can actually act on. + private static func describe(_ error: Error) -> String { + let nsError = error as NSError + guard nsError.domain == NEVPNErrorDomain, + let code = NEVPNError.Code(rawValue: nsError.code) else { + return error.localizedDescription + } + switch code { + case .configurationInvalid: + return "The VPN configuration was rejected by the system." + case .configurationDisabled: + return "The VPN configuration is turned off in Settings." + case .connectionFailed: + return "The tunnel could not start. Check the log for details." + case .configurationStale: + return "The VPN configuration changed — try again." + case .configurationReadWriteFailed: + // Also what the Simulator returns: it has no VPN stack at all. + return "Could not talk to the VPN service. " + + "NetworkExtension does not run in the Simulator — use a real device." + case .configurationUnknown: + return "No VPN configuration is installed yet." + @unknown default: + return error.localizedDescription + } + } + + func disconnect() { + manager?.connection.stopVPNTunnel() + pendingServerID = nil + } + + /// Removes the VPN profile from system settings. + func removeProfile() async { + guard let manager else { return } + try? await manager.removeFromPreferences() + self.manager = nil + isInstalled = false + state = .disconnected + } + + // MARK: - State + + private func syncState(from status: NEVPNStatus) { + switch status { + case .connected: + let wasConnected = (state == .connected) + activeServerID = pendingServerID ?? activeServerID + state = .connected + if connectedSince == nil { connectedSince = Date() } + startPolling() + if notifyOnConnect && !wasConnected { + notify(title: "Connected", body: activeServerName) + } + case .connecting, .reasserting, .disconnecting: + state = .connecting + case .disconnected, .invalid: + let wasActive = (state == .connected || state == .connecting) + stopPolling() + connectedSince = nil + uptimeText = "" + activeServerID = nil + if let reason = readLastError(), wasActive { + state = .failed(reason) + } else { + state = .disconnected + if notifyOnConnect && wasActive { + notify(title: "Disconnected", body: activeServerName) + } + } + @unknown default: + state = .disconnected + } + } + + private func fail(_ message: String) { + state = .failed(message) + } + + private func readLastError() -> String? { + guard let data = try? Data(contentsOf: AppGroup.lastErrorURL), + let text = String(data: data, encoding: .utf8), + !text.isEmpty else { return nil } + try? FileManager.default.removeItem(at: AppGroup.lastErrorURL) + return text + } + + // MARK: - Live status + + /// Polls the provider once a second for counters, log and uptime. Only runs + /// while connected, so an idle app costs nothing. + private func startPolling() { + guard pollTask == nil else { return } + pollTask = Task { [weak self] in + while !Task.isCancelled { + await self?.refreshStatus() + self?.tickUptime() + try? await Task.sleep(nanoseconds: 1_000_000_000) + } + } + } + + private func stopPolling() { + pollTask?.cancel() + pollTask = nil + } + + func refreshStatus() async { + guard let session = manager?.connection as? NETunnelProviderSession, + session.status == .connected else { return } + let response: Data? = await withCheckedContinuation { continuation in + do { + try session.sendProviderMessage(TunnelRequest(.status).encoded()) { data in + continuation.resume(returning: data) + } + } catch { + continuation.resume(returning: nil) + } + } + guard let response, let status = TunnelStatus.decode(response) else { return } + apply(status) + } + + func clearLogs() { + logs = "" + guard let session = manager?.connection as? NETunnelProviderSession else { return } + try? session.sendProviderMessage(TunnelRequest(.clearLog).encoded(), responseHandler: nil) + } + + private func apply(_ status: TunnelStatus) { + coreVersion = status.coreVersion + uplinkBytes = status.uplinkBytes + downlinkBytes = status.downlinkBytes + logs = status.log + if !status.serverName.isEmpty { activeServerName = status.serverName } + if let startedAt = status.startedAt, connectedSince == nil { + connectedSince = startedAt + } + } + + private func tickUptime() { + guard let since = connectedSince else { uptimeText = ""; return } + let total = Int(Date().timeIntervalSince(since)) + let h = total / 3600, m = (total % 3600) / 60, s = total % 60 + uptimeText = h > 0 + ? String(format: "%d:%02d:%02d", h, m, s) + : String(format: "%02d:%02d", m, s) + } + + // MARK: - Notifications + + private func notify(title: String, body: String) { + NotificationManager.notify(title: title, body: body) + } +} + +/// Holds a NotificationCenter observer token outside of any actor, so both the +/// main-actor code that installs it and `deinit` can reach it. +private final class ObserverToken: @unchecked Sendable { + var value: NSObjectProtocol? + + func clear() { + guard let value else { return } + NotificationCenter.default.removeObserver(value) + self.value = nil + } +} diff --git a/ios/App/VeilApp.swift b/ios/App/VeilApp.swift new file mode 100644 index 0000000..b706e30 --- /dev/null +++ b/ios/App/VeilApp.swift @@ -0,0 +1,53 @@ +import SwiftUI + +@main +struct VeilApp: App { + @State private var store = ServerStore() + @State private var tunnel = TunnelController() + @State private var pinger = PingTester() + @State private var loc = Loc() + + var body: some Scene { + WindowGroup { + ContentView() + .environment(store) + .environment(tunnel) + .environment(pinger) + .environment(loc) + .preferredColorScheme(colorScheme) + .environment(\.layoutDirection, loc.isRTL ? .rightToLeft : .leftToRight) + .task { await bootstrap() } + } + } + + private func bootstrap() async { + loc.language = store.settings.language + tunnel.notifyOnConnect = store.settings.notifyOnConnect + if store.settings.notifyOnConnect { + NotificationManager.requestAuthorization() + } + + await tunnel.loadManager() + await SubscriptionService.refreshDue(store) + + // Geo databases only matter for presets that reference geosite:/geoip:. + if store.settings.routingPreset.needsGeoAssets, !GeoAssetManager.shared.hasAssets { + await GeoAssetManager.shared.download(source: store.settings.geoSource, + customGeoip: store.settings.customGeoipURL, + customGeosite: store.settings.customGeositeURL) + } + + if store.settings.autoConnectOnLaunch, + let server = store.server(withID: store.selectedServerID) { + await tunnel.connect(to: server, settings: store.settings) + } + } + + private var colorScheme: ColorScheme? { + switch store.settings.appearance { + case .system: return nil + case .light: return .light + case .dark: return .dark + } + } +} diff --git a/ios/App/Views/AddView.swift b/ios/App/Views/AddView.swift new file mode 100644 index 0000000..0590f54 --- /dev/null +++ b/ios/App/Views/AddView.swift @@ -0,0 +1,189 @@ +import SwiftUI +import PhotosUI + +/// The single "add something" screen. +/// +/// There is deliberately no subscription-vs-link choice: the user pastes, +/// scans or picks whatever they have and `AddInputClassifier` works out which +/// it is. The sheet just reports what it found and does the right thing. +struct AddView: View { + @Environment(ServerStore.self) private var store + @Environment(Loc.self) private var loc + @Environment(\.dismiss) private var dismiss + + @State private var text = "" + @State private var nameText = "" + @State private var isLoading = false + @State private var errorMessage: String? + @State private var showScanner = false + @State private var photoItem: PhotosPickerItem? + + private var input: AddInput { AddInputClassifier.classify(text) } + + var body: some View { + // PhotosPicker's label builder is @Sendable, so the localized title has + // to be resolved here rather than inside the closure. + let fromImageTitle = loc("From image…") + + return NavigationStack { + Form { + Section { + TextEditor(text: $text) + .font(.system(.footnote, design: .monospaced)) + .frame(minHeight: 120) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } header: { + Text(loc("Paste a link, subscription URL or config")) + } footer: { + detectionFooter + } + + if case .subscription = input { + Section { + TextField(loc("Name (optional)"), text: $nameText) + .autocorrectionDisabled() + } + } + + Section { + Button { + showScanner = true + } label: { + Label(loc("Scan camera"), systemImage: "qrcode.viewfinder") + } + PhotosPicker(selection: $photoItem, matching: .images) { + Label(fromImageTitle, systemImage: "photo") + } + // A system paste button rather than reading + // UIPasteboard ourselves: that would pop the "Allow Paste?" + // alert on every tap, and a denied alert looks exactly like + // a broken button. + HStack { + Label(loc("Paste from clipboard"), systemImage: "doc.on.clipboard") + Spacer() + PasteButton(payloadType: String.self) { strings in + guard let pasted = strings.first else { return } + append(pasted) + } + .labelStyle(.iconOnly) + .buttonBorderShape(.capsule) + } + } + + if let errorMessage { + Section { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(.red) + } + } + } + .navigationTitle(loc("Add")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(loc("Cancel")) { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + if isLoading { + ProgressView() + } else { + Button(loc("Add")) { Task { await commit() } } + .disabled(input == .unrecognized) + } + } + } + .sheet(isPresented: $showScanner) { + QRScannerView { payload in + showScanner = false + append(payload) + } + } + .onChange(of: photoItem) { _, item in + guard let item else { return } + Task { await decode(item) } + } + } + } + + /// Live feedback so the user can see the app understood the paste before + /// committing to it. + @ViewBuilder + private var detectionFooter: some View { + switch input { + case .servers(let servers): + // Never interpolate a count into a translated noun — plural forms + // differ per language. "Label: N" reads correctly everywhere. + Label(servers.count == 1 + ? "\(loc("Server")): \(servers[0].name)" + : "\(loc("Servers found")): \(servers.count)", + systemImage: "checkmark.circle") + .foregroundStyle(.green) + case .subscription(let url): + Label("\(loc("Subscription")) · \(URL(string: url)?.host ?? url)", + systemImage: "arrow.down.circle") + .foregroundStyle(.green) + case .unrecognized where text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty: + Text("vless:// · vmess:// · trojan:// · ss:// · wireguard:// · https://…/sub") + case .unrecognized: + Label(loc("Not a link or subscription URL"), systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + } + } + + private func append(_ payload: String) { + let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + errorMessage = nil + text += text.isEmpty ? trimmed : "\n" + trimmed + } + + private func decode(_ item: PhotosPickerItem) async { + defer { photoItem = nil } + guard let data = try? await item.loadTransferable(type: Data.self), + let image = UIImage(data: data) else { + errorMessage = loc("Could not read that image.") + return + } + guard let payload = QRCode.decode(from: image) else { + errorMessage = loc("No QR code found in that image.") + return + } + append(payload) + } + + private func commit() async { + errorMessage = nil + switch input { + case .servers(let servers): + store.addManualServers(servers) + dismiss() + + case .subscription(let url): + isLoading = true + defer { isLoading = false } + let hwid = store.settings.sendHwid ? DeviceID.hwid : nil + do { + let result = try await SubscriptionFetcher.fetch(url, hwid: hwid) + guard !result.servers.isEmpty else { + errorMessage = loc("The subscription returned no servers.") + return + } + let name = nameText.isEmpty + ? (result.profileTitle ?? URL(string: url)?.host ?? loc("Subscription")) + : nameText + store.addOrUpdateSubscription(name: name, url: url, + servers: result.servers, + userinfo: result.userinfo, + announce: result.announce) + dismiss() + } catch { + errorMessage = error.localizedDescription + } + + case .unrecognized: + errorMessage = loc("Not a link or subscription URL") + } + } +} diff --git a/ios/App/Views/ContentView.swift b/ios/App/Views/ContentView.swift new file mode 100644 index 0000000..5d3efc2 --- /dev/null +++ b/ios/App/Views/ContentView.swift @@ -0,0 +1,554 @@ +import SwiftUI + +struct ContentView: View { + @Environment(ServerStore.self) private var store + @Environment(TunnelController.self) private var tunnel + @Environment(PingTester.self) private var pinger + @Environment(Loc.self) private var loc + + @State private var showAddSheet = false + @State private var showSettings = false + @State private var showLog = false + @State private var isRefreshing = false + @State private var searchText = "" + @State private var aliveOnly = false + @State private var sortByPing = false + @State private var qrServer: ProxyConfig? + + var body: some View { + NavigationStack { + List { + Section { StatusCard() .listRowInsets(EdgeInsets()) } + + if !store.allServers.isEmpty { + Section { filterRow } + } + + if store.subscriptions.isEmpty { + Section { emptyState } + } else { + ForEach(store.subscriptions) { sub in + SubscriptionSection(subscription: sub, + searchText: searchText, + aliveOnly: aliveOnly, + sortByPing: sortByPing, + qrServer: $qrServer) + } + } + } + .listStyle(.insetGrouped) + .navigationTitle("Veil") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $searchText, prompt: loc("Search servers…")) + .refreshable { await refreshAll() } + .toolbar { toolbarContent } + .sheet(isPresented: $showAddSheet) { AddView() } + .sheet(isPresented: $showSettings) { SettingsView() } + .sheet(isPresented: $showLog) { LogView() } + .sheet(item: $qrServer) { QRDisplayView(server: $0) } + } + } + + // MARK: - Toolbar + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .topBarLeading) { + Button { showAddSheet = true } label: { + Image(systemName: "plus") + } + .accessibilityLabel(loc("Add")) + } + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button { + Task { await refreshAll() } + } label: { + Label(loc("Refresh"), systemImage: "arrow.clockwise") + } + .disabled(isRefreshing) + + Button { + pinger.test(store.allServers) + } label: { + Label(loc("Test Ping"), systemImage: "speedometer") + } + .disabled(store.allServers.isEmpty) + + Divider() + + Button { showLog = true } label: { + Label(loc("Log"), systemImage: "text.alignleft") + } + Button { showSettings = true } label: { + Label(loc("Settings"), systemImage: "gearshape") + } + } label: { + if isRefreshing { + ProgressView() + } else { + Image(systemName: "ellipsis.circle") + } + } + } + } + + // MARK: - Pieces + + private var filterRow: some View { + HStack(spacing: 10) { + Toggle(loc("Alive"), isOn: $aliveOnly) + .toggleStyle(.button) + .buttonStyle(.bordered) + .font(.footnote) + Toggle(loc("By ping"), isOn: $sortByPing) + .toggleStyle(.button) + .buttonStyle(.bordered) + .font(.footnote) + Spacer() + Button { + pinger.test(store.allServers) + } label: { + // An HStack rather than a Label: the button style lays a Label + // out on a fixed icon column, which leaves a gap here. + HStack(spacing: 5) { + Image(systemName: "speedometer") + Text(loc("Test Ping")) + } + .font(.footnote) + } + .buttonStyle(.bordered) + .disabled(store.allServers.isEmpty) + } + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0)) + } + + private var emptyState: some View { + VStack(spacing: 10) { + Image(systemName: "tray") + .font(.system(size: 34)) + .foregroundStyle(.secondary) + Text(loc("No servers yet")).font(.headline) + Text(loc("Paste a server link or a subscription URL to get started.")) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + Button { + showAddSheet = true + } label: { + // Without an explicit label style the button drops the icon and + // shows a bare word. + Label(loc("Add"), systemImage: "plus") + .labelStyle(.titleAndIcon) + .padding(.horizontal, 8) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .padding(.top, 4) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 28) + .listRowBackground(Color.clear) + } + + private func refreshAll() async { + isRefreshing = true + await SubscriptionService.refreshAll(store) + isRefreshing = false + } + + /// Shared filter/sort so every list on screen agrees on the ordering. + static func filterServers(_ servers: [ProxyConfig], search: String, + aliveOnly: Bool, sortByPing: Bool, + pinger: PingTester) -> [ProxyConfig] { + var list = servers + let q = search.trimmingCharacters(in: .whitespaces).lowercased() + if !q.isEmpty { + list = list.filter { $0.name.lowercased().contains(q) + || $0.address.lowercased().contains(q) } + } + if aliveOnly { + list = list.filter { + if let outer = pinger.latency(for: $0.id), outer != nil { return true } + return false + } + } + if sortByPing { + list.sort { a, b in + let la = (pinger.latency(for: a.id) ?? nil) ?? Int.max + let lb = (pinger.latency(for: b.id) ?? nil) ?? Int.max + return la < lb + } + } + return list + } +} + +// MARK: - Status card + +/// The connect/disconnect hero at the top of the list: state, active server, +/// uptime and live traffic counters read back from the tunnel provider. +struct StatusCard: View { + @Environment(ServerStore.self) private var store + @Environment(TunnelController.self) private var tunnel + @Environment(Loc.self) private var loc + + @State private var holdProgress: CGFloat = 0 + @State private var isHolding = false + + private var selected: ProxyConfig? { store.server(withID: store.selectedServerID) } + + var body: some View { + // Kept compact on purpose: this sits above the server list, and every + // point it takes is a row the user has to scroll for. + VStack(spacing: 12) { + shieldControl + + VStack(spacing: 2) { + Text(stateLabel) + .font(.headline) + .foregroundStyle(statusColor) + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + + if tunnel.isConnected { + HStack(spacing: 20) { + trafficLabel(icon: "arrow.up", bytes: tunnel.uplinkBytes) + trafficLabel(icon: "arrow.down", bytes: tunnel.downlinkBytes) + Label(tunnel.uptimeText, systemImage: "clock") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + + Text(holdHint) + .font(.caption) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 20) + .padding(.vertical, 16) + } + + /// The shield *is* the connect control — hold it to toggle the tunnel. + /// A deliberate press beats a button you can hit by accident, since + /// dropping the VPN mid-session is disruptive. + private var shieldControl: some View { + ZStack { + Circle() + .fill(statusColor.opacity(0.16)) + .frame(width: 104, height: 104) + + // Fills while held; completing the ring is what fires the action. + Circle() + .trim(from: 0, to: holdProgress) + .stroke(statusColor, style: StrokeStyle(lineWidth: 4, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .frame(width: 104, height: 104) + + Image(systemName: tunnel.isConnected ? "shield.lefthalf.filled" : "shield.slash") + .font(.system(size: 40, weight: .medium)) + .foregroundStyle(statusColor) + } + .opacity(canToggle ? 1 : 0.4) + .scaleEffect(isHolding ? 0.93 : 1) + .animation(.easeOut(duration: 0.15), value: isHolding) + .contentShape(Circle()) + .onLongPressGesture(minimumDuration: Self.holdDuration) { + toggle() + } onPressingChanged: { pressing in + guard canToggle else { return } + isHolding = pressing + withAnimation(.linear(duration: pressing ? Self.holdDuration : 0.2)) { + holdProgress = pressing ? 1 : 0 + } + } + .accessibilityLabel(holdHint) + } + + private static let holdDuration: TimeInterval = 0.6 + + /// False while a connect is already in flight, or when the selected server + /// is one the Xray-only build cannot run. + private var canToggle: Bool { + if tunnel.isConnected { return true } + guard tunnel.state != .connecting else { return false } + return selected?.xraySupported ?? false + } + + private var holdHint: String { + if !canToggle && !tunnel.isConnected { return " " } + return tunnel.isConnected ? loc("Hold to disconnect") : loc("Hold to connect") + } + + private func toggle() { + guard canToggle else { return } + withAnimation(.easeOut(duration: 0.2)) { holdProgress = 0 } + isHolding = false + if tunnel.isConnected { + tunnel.disconnect() + } else if let server = selected { + Task { await tunnel.connect(to: server, settings: store.settings) } + } + } + + private func trafficLabel(icon: String, bytes: Int64) -> some View { + Label(ByteFormat.string(bytes), systemImage: icon) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + + private var stateLabel: String { + switch tunnel.state { + case .disconnected: return loc("Disconnected") + case .connecting: return loc("Connecting…") + case .connected: return loc("Connected") + case .failed: return loc("Failed") + } + } + + private var subtitle: String { + if case .failed(let message) = tunnel.state { return message } + if tunnel.isConnected { return tunnel.activeServerName } + if let selected { + return selected.xraySupported + ? selected.name + : "\(selected.name) — \(loc("not supported on iOS"))" + } + return loc("Select a server") + } + + private var statusColor: Color { + switch tunnel.state { + case .connected: return .green + case .connecting: return .orange + case .failed: return .red + case .disconnected: return .secondary + } + } +} + +// MARK: - Subscription section + +struct SubscriptionSection: View { + @Environment(ServerStore.self) private var store + @Environment(TunnelController.self) private var tunnel + @Environment(PingTester.self) private var pinger + @Environment(Loc.self) private var loc + + let subscription: Subscription + let searchText: String + let aliveOnly: Bool + let sortByPing: Bool + @Binding var qrServer: ProxyConfig? + + private var visibleServers: [ProxyConfig] { + ContentView.filterServers(subscription.servers, search: searchText, + aliveOnly: aliveOnly, sortByPing: sortByPing, + pinger: pinger) + } + + /// Hide a group that an active filter has emptied out. + private var isHidden: Bool { + (!searchText.isEmpty || aliveOnly) && visibleServers.isEmpty + } + + var body: some View { + if !isHidden { + // The heading is the section's first *row*, not its header, so it + // shares the one rounded card the list draws around a section + // instead of floating on its own slab next to it. + Section { + header + if !subscription.isCollapsed { + ForEach(visibleServers) { server in + row(for: server) + } + } + } + } + } + + @ViewBuilder + private func row(for server: ProxyConfig) -> some View { + let isActive = tunnel.activeServerID == server.id && tunnel.isConnected + ServerRow(server: server, + isSelected: store.selectedServerID == server.id, + isActive: isActive, + latency: pinger.latency(for: server.id), + isTesting: pinger.isTesting(server.id)) + .contentShape(Rectangle()) + .onTapGesture { handleTap(server) } + .contextMenu { + Button(tunnel.isConnected ? loc("Switch here") : loc("Connect")) { + store.select(server.id) + Task { await tunnel.connect(to: server, settings: store.settings) } + } + .disabled(!server.xraySupported) + Button(loc("Test ping")) { pinger.test([server]) } + Divider() + Button(loc("Copy link")) { + UIPasteboard.general.string = LinkBuilder.link(for: server) + } + Button(loc("Show QR code")) { qrServer = server } + } + .swipeActions(edge: .trailing) { + if subscription.isManual && !isActive { + Button(role: .destructive) { + store.removeServer(id: server.id) + } label: { + Label(loc("Delete"), systemImage: "trash") + } + } + } + } + + /// Name, note, traffic and expiry sit above the servers: a description + /// under the last row reads as if it belonged to that row. + private var header: some View { + VStack(alignment: .leading, spacing: 8) { + titleRow + details + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + + private var titleRow: some View { + HStack(spacing: 8) { + // Tapping anywhere across the name collapses the group, but the + // menu keeps its own hit area. + HStack(spacing: 8) { + Image(systemName: "chevron.down") + .font(.caption.weight(.bold)) + .foregroundStyle(.secondary) + // Rotating one glyph animates; swapping two would not. + .rotationEffect(.degrees(subscription.isCollapsed ? -90 : 0)) + .frame(width: 20) + + Text(subscription.name) + .font(.headline) + .foregroundStyle(.primary) + .lineLimit(1) + Text("\(subscription.servers.count)") + .font(.caption2) + .padding(.horizontal, 6).padding(.vertical, 1) + .background(Capsule().fill(Color.secondary.opacity(0.18))) + + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + .onTapGesture { toggleCollapsed() } + + Menu { + Button(loc("Test ping")) { pinger.test(subscription.servers) } + if !subscription.isManual { + Divider() + Button(loc("Refresh now")) { + Task { await SubscriptionService.refresh(subscription, into: store) } + } + Toggle(loc("Auto-update"), isOn: Binding( + get: { subscription.autoUpdate }, + set: { store.setAutoUpdate($0, id: subscription.id) } + )) + Divider() + let holdsActive = tunnel.isConnected + && subscription.servers.contains { $0.id == tunnel.activeServerID } + Button(loc("Remove"), role: .destructive) { + store.removeSubscription(id: subscription.id) + } + .disabled(holdsActive) + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + + /// Shown in full. Providers pad the Announce header with runs of blank + /// lines; those collapse to a single break so the paragraphs survive + /// without the dead space between them. + private var noteText: String { + let lines = (subscription.note ?? "") + .split(separator: "\n", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespaces) } + + var kept: [String] = [] + for line in lines { + // Drops leading blanks and every repeat of one. + if line.isEmpty && (kept.last?.isEmpty ?? true) { continue } + kept.append(line) + } + while kept.last?.isEmpty == true { kept.removeLast() } + return kept.joined(separator: "\n") + } + + @ViewBuilder + private var details: some View { + VStack(alignment: .leading, spacing: 8) { + if !noteText.isEmpty { + Text(noteText) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + + if let used = subscription.usedBytes, let total = subscription.totalBytes { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 6) { + Text("\(ByteFormat.string(used)) / \(ByteFormat.string(total))") + .monospacedDigit() + Spacer(minLength: 8) + if let expiry = subscription.expiresAt { + Text(expiry.formatted(date: .abbreviated, time: .omitted)) + } + } + .font(.caption2) + .foregroundStyle(.secondary) + + if let fraction = subscription.usageFraction { + usageBar(fraction) + } + } + } else if let expiry = subscription.expiresAt { + Text("\(loc("Expires")) \(expiry.formatted(date: .abbreviated, time: .omitted))") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + + /// Hand-drawn rather than a `ProgressView`, whose bar is too tall and too + /// loud for a line of metadata. + private func usageBar(_ fraction: Double) -> some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + Capsule().fill(Color.secondary.opacity(0.18)) + Capsule() + .fill(fraction > 0.9 ? Color.red : Color.accentColor) + .frame(width: max(3, geometry.size.width * fraction)) + } + } + .frame(height: 4) + } + + private func toggleCollapsed() { + withAnimation(.snappy(duration: 0.28)) { + store.toggleCollapsed(id: subscription.id) + } + } + + /// Disconnected: tap selects. Connected: tap switches straight away, which + /// only restarts the core inside the extension — the tunnel never drops. + private func handleTap(_ server: ProxyConfig) { + store.select(server.id) + guard tunnel.isConnected else { return } + Task { await tunnel.connect(to: server, settings: store.settings) } + } +} diff --git a/ios/App/Views/LogView.swift b/ios/App/Views/LogView.swift new file mode 100644 index 0000000..4b92ac8 --- /dev/null +++ b/ios/App/Views/LogView.swift @@ -0,0 +1,62 @@ +import SwiftUI + +/// Tail of the Xray log. The core runs in the extension, so the log is a file +/// in the shared container that the provider ships back a chunk at a time. +struct LogView: View { + @Environment(TunnelController.self) private var tunnel + @Environment(Loc.self) private var loc + @Environment(\.dismiss) private var dismiss + + private var text: String { + tunnel.logs.isEmpty ? loc("No logs yet.") : tunnel.logs + } + + var body: some View { + NavigationStack { + ScrollViewReader { proxy in + ScrollView { + Text(text) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(tunnel.logs.isEmpty ? Color.secondary : .primary) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + .padding(12) + .id("bottom") + } + .onChange(of: tunnel.logs) { _, _ in + withAnimation { proxy.scrollTo("bottom", anchor: .bottom) } + } + } + .navigationTitle(loc("Log")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(loc("Done")) { dismiss() } + } + ToolbarItemGroup(placement: .topBarTrailing) { + Button { + UIPasteboard.general.string = tunnel.logs + } label: { + Image(systemName: "doc.on.doc") + } + .disabled(tunnel.logs.isEmpty) + + Button { + tunnel.clearLogs() + } label: { + Image(systemName: "trash") + } + .disabled(tunnel.logs.isEmpty) + } + } + .task { + // Keep the tail fresh while this screen is open even when the + // list view's poll loop isn't running. + while !Task.isCancelled { + await tunnel.refreshStatus() + try? await Task.sleep(nanoseconds: 1_000_000_000) + } + } + } + } +} diff --git a/ios/App/Views/QRDisplayView.swift b/ios/App/Views/QRDisplayView.swift new file mode 100644 index 0000000..2379efe --- /dev/null +++ b/ios/App/Views/QRDisplayView.swift @@ -0,0 +1,65 @@ +import SwiftUI + +/// Shows a server as a QR code so it can be moved to another device, plus the +/// raw share link for copy/paste. +struct QRDisplayView: View { + @Environment(Loc.self) private var loc + @Environment(\.dismiss) private var dismiss + + let server: ProxyConfig + + private var link: String { LinkBuilder.link(for: server) } + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 20) { + if let image = QRCode.image(from: link, size: 260) { + Image(uiImage: image) + .interpolation(.none) + .resizable() + .scaledToFit() + .frame(width: 260, height: 260) + .padding(12) + .background(.white, in: RoundedRectangle(cornerRadius: 16)) + } else { + Text(loc("Could not render a QR code for this server.")) + .foregroundStyle(.secondary) + } + + Text(link) + .font(.system(.caption2, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(Color.secondary.opacity(0.1), + in: RoundedRectangle(cornerRadius: 10)) + + HStack { + Button { + UIPasteboard.general.string = link + } label: { + Label(loc("Copy link"), systemImage: "doc.on.doc") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + + ShareLink(item: link) { + Label(loc("Share"), systemImage: "square.and.arrow.up") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + } + } + .padding() + } + .navigationTitle(server.name) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(loc("Done")) { dismiss() } + } + } + } + } +} diff --git a/ios/App/Views/QRScannerView.swift b/ios/App/Views/QRScannerView.swift new file mode 100644 index 0000000..16965fe --- /dev/null +++ b/ios/App/Views/QRScannerView.swift @@ -0,0 +1,175 @@ +import SwiftUI +@preconcurrency import AVFoundation + +/// Live camera QR scanner. Wraps `AVCaptureSession` in a plain UIKit view +/// controller — SwiftUI has no first-party scanner, and this keeps the preview +/// layer's lifecycle tied to the view rather than to view-graph updates. +struct QRScannerView: View { + @Environment(Loc.self) private var loc + @Environment(\.dismiss) private var dismiss + + let onScan: (String) -> Void + + @State private var status: String = "" + + var body: some View { + NavigationStack { + ZStack { + ScannerRepresentable(onScan: handle, onStatus: { status = $0 }) + .ignoresSafeArea() + + VStack { + Spacer() + RoundedRectangle(cornerRadius: 20) + .strokeBorder(.white.opacity(0.8), lineWidth: 3) + .frame(width: 240, height: 240) + Spacer() + if !status.isEmpty { + Text(status) + .font(.footnote) + .foregroundStyle(.white) + .padding(10) + .background(.black.opacity(0.6), in: Capsule()) + .padding(.bottom, 40) + } + } + } + .navigationTitle(loc("Scan camera")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(loc("Cancel")) { dismiss() } + } + } + } + } + + private func handle(_ payload: String) { + onScan(payload) + dismiss() + } +} + +private struct ScannerRepresentable: UIViewControllerRepresentable { + let onScan: (String) -> Void + let onStatus: (String) -> Void + + func makeUIViewController(context: Context) -> ScannerViewController { + let controller = ScannerViewController() + controller.onScan = onScan + controller.onStatus = onStatus + return controller + } + + func updateUIViewController(_ controller: ScannerViewController, context: Context) {} +} + +final class ScannerViewController: UIViewController { + // Captured locally by the delegate callback so we never touch `self`'s + // SwiftUI closures across actor boundaries. + var onScan: ((String) -> Void)? + var onStatus: ((String) -> Void)? + + private let session = AVCaptureSession() + private var previewLayer: AVCaptureVideoPreviewLayer? + private var hasScanned = false + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + configureSession() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + previewLayer?.frame = view.bounds + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + guard !session.isRunning else { return } + // Starting the session blocks; keep it off the main thread. + DispatchQueue.global(qos: .userInitiated).async { [session] in + session.startRunning() + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + guard session.isRunning else { return } + DispatchQueue.global(qos: .userInitiated).async { [session] in + session.stopRunning() + } + } + + private func configureSession() { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + buildSession() + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + DispatchQueue.main.async { + guard let self else { return } + if granted { + self.buildSession() + let session = self.session + DispatchQueue.global(qos: .userInitiated).async { session.startRunning() } + } else { + self.onStatus?("Camera access denied") + } + } + } + default: + onStatus?("Camera access denied — enable it in Settings") + } + } + + private func buildSession() { + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) else { + onStatus?("No camera available") + return + } + session.beginConfiguration() + session.addInput(input) + + let output = AVCaptureMetadataOutput() + guard session.canAddOutput(output) else { + session.commitConfiguration() + onStatus?("Cannot read QR codes on this device") + return + } + session.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: .main) + output.metadataObjectTypes = [.qr] + session.commitConfiguration() + + let layer = AVCaptureVideoPreviewLayer(session: session) + layer.videoGravity = .resizeAspectFill + layer.frame = view.bounds + view.layer.insertSublayer(layer, at: 0) + previewLayer = layer + onStatus?("Point the camera at a QR code") + } + + fileprivate func handleScan(_ metadataObjects: [AVMetadataObject]) { + guard !hasScanned, + let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + let value = object.stringValue, !value.isEmpty else { return } + hasScanned = true + UINotificationFeedbackGenerator().notificationOccurred(.success) + onScan?(value) + } +} + +// The metadata output is configured with `queue: .main`, so the callback really +// does arrive on the main actor — `@preconcurrency` states that fact for a +// delegate protocol that predates concurrency annotations. +extension ScannerViewController: @preconcurrency AVCaptureMetadataOutputObjectsDelegate { + func metadataOutput(_ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection) { + handleScan(metadataObjects) + } +} diff --git a/ios/App/Views/RoutingView.swift b/ios/App/Views/RoutingView.swift new file mode 100644 index 0000000..8618d19 --- /dev/null +++ b/ios/App/Views/RoutingView.swift @@ -0,0 +1,176 @@ +import SwiftUI + +/// Routing editor: preset picker, geo database source, and the custom ordered +/// rule list. Rules are the same `RoutingRule` model the desktop app compiles +/// into Xray `field` rules. +struct RoutingView: View { + @Environment(ServerStore.self) private var store + @Environment(Loc.self) private var loc + + @State private var geo = GeoAssetManager.shared + /// Drives edit mode by hand instead of using `EditButton`: system controls + /// draw their own title from the *device* language, which would ignore the + /// language picked in this app's settings. + @State private var isEditing = false + + var body: some View { + @Bindable var store = store + + Form { + Section(loc("Preset")) { + Picker(loc("Preset"), selection: $store.settings.routingPreset) { + ForEach(RoutingPreset.allCases) { Text(loc($0.title)).tag($0) } + } + .pickerStyle(.inline) + .labelsHidden() + Text(loc(store.settings.routingPreset.subtitle)) + .font(.caption) + .foregroundStyle(.secondary) + } + + Section(loc("Blocking")) { + Toggle(loc("Block ads"), isOn: $store.settings.blockAds) + } + + if store.settings.routingPreset.needsGeoAssets || store.settings.blockAds { + Section { + Picker(loc("Source"), selection: $store.settings.geoSource) { + ForEach(GeoAssetSource.allCases) { Text(loc($0.title)).tag($0) } + } + if store.settings.geoSource == .custom { + TextField("geoip.dat URL", text: $store.settings.customGeoipURL) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + TextField("geosite.dat URL", text: $store.settings.customGeositeURL) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + Button { + Task { + await geo.download(source: store.settings.geoSource, + customGeoip: store.settings.customGeoipURL, + customGeosite: store.settings.customGeositeURL) + } + } label: { + if geo.isDownloading { + HStack { ProgressView(); Text(loc("Downloading…")) } + } else { + Label(loc("Download geo databases"), systemImage: "arrow.down.circle") + } + } + .disabled(geo.isDownloading) + } header: { + Text(loc("Geo databases")) + } footer: { + if let error = geo.lastError { + Text(error).foregroundStyle(.red) + } else if geo.hasAssets { + Text(loc("Installed.")) + } else { + Text(loc("geosite:/geoip: rules need these files.")) + } + } + } + + if store.settings.routingPreset == .custom { + Section(loc("Custom rules")) { + ForEach($store.settings.customRules) { $rule in + RuleEditor(rule: $rule) + } + .onDelete { offsets in + store.settings.customRules.remove(atOffsets: offsets) + store.save() + } + .onMove { source, destination in + store.settings.customRules.move(fromOffsets: source, + toOffset: destination) + store.save() + } + + Button { + store.settings.customRules.append( + RoutingRule(name: loc("New rule"), outbound: .direct)) + store.save() + } label: { + Label(loc("Add rule"), systemImage: "plus") + } + } + } + } + .navigationTitle(loc("Routing")) + .navigationBarTitleDisplayMode(.inline) + .environment(\.editMode, .constant(isEditing ? .active : .inactive)) + .toolbar { + if store.settings.routingPreset == .custom { + Button(isEditing ? loc("Done") : loc("Edit")) { isEditing.toggle() } + } + } + .onDisappear { store.save() } + } +} + +/// One rule: where it sends traffic, and the domain / IP / port it matches. +private struct RuleEditor: View { + @Environment(Loc.self) private var loc + @Binding var rule: RoutingRule + + @State private var domainsText = "" + @State private var ipsText = "" + + var body: some View { + DisclosureGroup { + Picker(loc("Outbound"), selection: $rule.outbound) { + ForEach(RuleOutbound.allCases) { Text(loc($0.title)).tag($0) } + } + .pickerStyle(.segmented) + + TextField(loc("Name"), text: $rule.name) + + VStack(alignment: .leading, spacing: 4) { + Text(loc("Domains")).font(.caption).foregroundStyle(.secondary) + TextField("example.com, geosite:cn", text: $domainsText, axis: .vertical) + .font(.system(.footnote, design: .monospaced)) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .onChange(of: domainsText) { _, value in + rule.domains = Self.split(value) + } + } + + VStack(alignment: .leading, spacing: 4) { + Text(loc("IPs")).font(.caption).foregroundStyle(.secondary) + TextField("10.0.0.0/8, geoip:ru", text: $ipsText, axis: .vertical) + .font(.system(.footnote, design: .monospaced)) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .onChange(of: ipsText) { _, value in + rule.ips = Self.split(value) + } + } + + TextField(loc("Port (optional)"), text: $rule.port) + .keyboardType(.numbersAndPunctuation) + + Toggle(loc("Enabled"), isOn: $rule.enabled) + } label: { + HStack { + Text(rule.name.isEmpty ? loc("Untitled rule") : rule.name) + Spacer() + Text(loc(rule.outbound.title)) + .font(.caption) + .foregroundStyle(.secondary) + } + .opacity(rule.enabled ? 1 : 0.5) + } + .onAppear { + domainsText = rule.domains.joined(separator: ", ") + ipsText = rule.ips.joined(separator: ", ") + } + } + + private static func split(_ text: String) -> [String] { + text.split(whereSeparator: { $0 == "," || $0 == "\n" }) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } +} diff --git a/ios/App/Views/ServerRow.swift b/ios/App/Views/ServerRow.swift new file mode 100644 index 0000000..fd1969e --- /dev/null +++ b/ios/App/Views/ServerRow.swift @@ -0,0 +1,74 @@ +import SwiftUI + +struct ServerRow: View { + @Environment(Loc.self) private var loc + + let server: ProxyConfig + let isSelected: Bool + let isActive: Bool + /// Outer nil = never tested; inner nil = tested and unreachable. + let latency: Int?? + let isTesting: Bool + + var body: some View { + HStack(spacing: 10) { + Circle() + .fill(isActive ? Color.green : Color.secondary.opacity(0.3)) + .frame(width: 8, height: 8) + + VStack(alignment: .leading, spacing: 2) { + Text(server.name) + .lineLimit(1) + .foregroundStyle(server.xraySupported ? .primary : .secondary) + HStack(spacing: 6) { + Text(server.proto.rawValue.uppercased()) + if server.isBalancer { + Text("×\((server.alternates?.count ?? 0) + 1)") + } + if !server.xraySupported { + Text(loc("needs sing-box")) + .foregroundStyle(.orange) + } + } + .font(.caption2) + .foregroundStyle(.secondary) + } + + Spacer() + + latencyBadge + + if isSelected { + Image(systemName: "checkmark") + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.accentColor) + } + } + .padding(.vertical, 2) + } + + @ViewBuilder + private var latencyBadge: some View { + if isTesting { + ProgressView().controlSize(.mini) + } else if let outer = latency { + if let ms = outer { + Text("\(ms) ms") + .font(.caption2.monospacedDigit()) + .foregroundStyle(latencyColor(ms)) + } else { + Text(loc("timeout")) + .font(.caption2) + .foregroundStyle(.red) + } + } + } + + private func latencyColor(_ ms: Int) -> Color { + switch ms { + case ..<150: return .green + case ..<350: return .orange + default: return .red + } + } +} diff --git a/ios/App/Views/SettingsView.swift b/ios/App/Views/SettingsView.swift new file mode 100644 index 0000000..cc1dc62 --- /dev/null +++ b/ios/App/Views/SettingsView.swift @@ -0,0 +1,143 @@ +import SwiftUI + +struct SettingsView: View { + @Environment(ServerStore.self) private var store + @Environment(TunnelController.self) private var tunnel + @Environment(Loc.self) private var loc + @Environment(\.dismiss) private var dismiss + + @State private var dnsText = "" + @State private var showRemoveConfirm = false + + var body: some View { + @Bindable var store = store + + NavigationStack { + Form { + Section { + // No mode picker: iOS has exactly one way to capture + // traffic, and it is the one this app uses. + LabeledContent(loc("Mode"), value: loc("Network Extension (all apps)")) + Picker(loc("Log level"), selection: $store.settings.logLevel) { + ForEach(LogLevel.allCases) { Text(loc($0.title)).tag($0) } + } + Toggle(loc("IPv6 inside tunnel"), isOn: $store.settings.ipv6Enabled) + Stepper(value: $store.settings.tunnelMTU, in: 1280...1500, step: 20) { + LabeledContent("MTU", value: "\(store.settings.tunnelMTU)") + } + LabeledContent("DNS") { + TextField("1.1.1.1, 8.8.8.8", text: $dnsText) + .multilineTextAlignment(.trailing) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .onSubmit(commitDNS) + } + } header: { + Text(loc("Tunnel")) + } footer: { + Text(loc("Changes apply the next time you connect or switch servers.")) + } + + Section(loc("Routing")) { + NavigationLink { + RoutingView() + } label: { + LabeledContent(loc("Rules"), value: loc(store.settings.routingPreset.title)) + } + } + + Section(loc("Startup")) { + Toggle(loc("Auto-connect on launch"), isOn: $store.settings.autoConnectOnLaunch) + Toggle(loc("Notify on connect"), isOn: $store.settings.notifyOnConnect) + .onChange(of: store.settings.notifyOnConnect) { _, on in + tunnel.notifyOnConnect = on + if on { NotificationManager.requestAuthorization() } + } + } + + Section(loc("Subscriptions")) { + Toggle(loc("Auto-update subscriptions"), + isOn: $store.settings.autoUpdateSubscriptions) + if store.settings.autoUpdateSubscriptions { + Stepper(value: $store.settings.autoUpdateIntervalHours, in: 1...168) { + LabeledContent(loc("Every"), + value: "\(store.settings.autoUpdateIntervalHours) h") + } + } + Toggle(loc("Send device ID (HWID)"), isOn: $store.settings.sendHwid) + } + + Section(loc("Appearance")) { + Picker(loc("Theme"), selection: $store.settings.appearance) { + ForEach(AppAppearance.allCases) { Text(loc($0.title)).tag($0) } + } + Picker(loc("Language"), selection: $store.settings.language) { + ForEach(AppLanguage.allCases) { Text($0.displayName).tag($0) } + } + .onChange(of: store.settings.language) { _, language in + loc.language = language + } + } + + Section { + LabeledContent(loc("Status"), + value: tunnel.isInstalled ? loc("Installed") : loc("Not installed")) + if tunnel.isInstalled { + Button(loc("Remove VPN profile"), role: .destructive) { + showRemoveConfirm = true + } + } + } header: { + Text(loc("VPN profile")) + } footer: { + Text(loc("iOS asks you to allow the VPN configuration the first time you connect.")) + } + + Section(loc("About")) { + LabeledContent(loc("App version"), value: appVersion) + LabeledContent(loc("Xray core"), + value: tunnel.coreVersion.isEmpty ? "—" : tunnel.coreVersion) + LabeledContent(loc("Device ID"), value: DeviceID.hwid) + .font(.caption) + .textSelection(.enabled) + } + } + .navigationTitle(loc("Settings")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(loc("Done")) { + commitDNS() + store.save() + dismiss() + } + } + } + .onAppear { dnsText = store.settings.effectiveDNSServers.joined(separator: ", ") } + .onDisappear { store.save() } + .confirmationDialog(loc("Remove VPN profile?"), + isPresented: $showRemoveConfirm, titleVisibility: .visible) { + Button(loc("Remove"), role: .destructive) { + Task { await tunnel.removeProfile() } + } + Button(loc("Cancel"), role: .cancel) {} + } + } + } + + private func commitDNS() { + let servers = dnsText + .split(whereSeparator: { $0 == "," || $0 == " " }) + .map { String($0).trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + store.settings.dnsServers = servers + dnsText = store.settings.effectiveDNSServers.joined(separator: ", ") + } + + private var appVersion: String { + let info = Bundle.main.infoDictionary + let short = info?["CFBundleShortVersionString"] as? String ?? "1.0" + let build = info?["CFBundleVersion"] as? String ?? "1" + return "\(short) (\(build))" + } +} diff --git a/ios/Config/App-Info.plist b/ios/Config/App-Info.plist new file mode 100644 index 0000000..59f22e9 --- /dev/null +++ b/ios/Config/App-Info.plist @@ -0,0 +1,29 @@ + + + + + + CFBundleLocalizations + + en + ru + zh + es + hi + ar + fr + pt + de + ja + id + tr + + + diff --git a/ios/Config/Tunnel-Info.plist b/ios/Config/Tunnel-Info.plist new file mode 100644 index 0000000..3059459 --- /dev/null +++ b/ios/Config/Tunnel-Info.plist @@ -0,0 +1,13 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.networkextension.packet-tunnel + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).PacketTunnelProvider + + + diff --git a/ios/Config/Veil.entitlements b/ios/Config/Veil.entitlements new file mode 100644 index 0000000..bb83c8c --- /dev/null +++ b/ios/Config/Veil.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.developer.networking.networkextension + + packet-tunnel-provider + + com.apple.security.application-groups + + group.dev.local.veil + + + diff --git a/ios/Config/VeilTunnel.entitlements b/ios/Config/VeilTunnel.entitlements new file mode 100644 index 0000000..bb83c8c --- /dev/null +++ b/ios/Config/VeilTunnel.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.developer.networking.networkextension + + packet-tunnel-provider + + com.apple.security.application-groups + + group.dev.local.veil + + + diff --git a/ios/Shared/AppGroup.swift b/ios/Shared/AppGroup.swift new file mode 100644 index 0000000..d8dc7d6 --- /dev/null +++ b/ios/Shared/AppGroup.swift @@ -0,0 +1,51 @@ +import Foundation + +/// Every path the app and the tunnel extension both need. +/// +/// A NetworkExtension runs in its own process with its own container, so the +/// only way the two sides can exchange the config, the geo databases and the +/// core log is through the shared app group container. +enum AppGroup { + + /// Must match the App Groups entitlement on both targets. + static let identifier = "group.dev.local.veil" + + /// Bundle identifier of the packet tunnel provider extension. + static let tunnelBundleIdentifier = "dev.local.veil.tunnel" + + /// Root of the shared container. Falls back to the process-local caches + /// directory when the app group is unavailable (unsigned local builds), so + /// nothing crashes — the tunnel simply won't have anything to read. + static let containerURL: URL = { + FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: identifier) + ?? FileManager.default.temporaryDirectory + }() + + /// Where `ServerStore` keeps store.json. + static let supportDirectory: URL = ensure(containerURL.appendingPathComponent("Veil", + isDirectory: true)) + + /// geoip.dat / geosite.dat, downloaded by the app, read by the core. + static let geoDirectory: URL = ensure(supportDirectory.appendingPathComponent("geo", + isDirectory: true)) + + /// The generated Xray configuration the tunnel provider boots from. + static let configURL = supportDirectory.appendingPathComponent("xray-config.json") + + /// Sidecar describing the session the config belongs to (server name, MTU…). + static let sessionURL = supportDirectory.appendingPathComponent("session.json") + + /// Xray writes its error log here; the app tails it for the log screen. + static let logURL = supportDirectory.appendingPathComponent("xray.log") + + /// Why the tunnel last refused to come up. The provider process is already + /// gone by the time the app notices the failed connection, so it leaves the + /// reason here on its way out. + static let lastErrorURL = supportDirectory.appendingPathComponent("last-error.txt") + + private static func ensure(_ url: URL) -> URL { + try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } +} diff --git a/ios/Shared/TunnelConfigWriter.swift b/ios/Shared/TunnelConfigWriter.swift new file mode 100644 index 0000000..8421b04 --- /dev/null +++ b/ios/Shared/TunnelConfigWriter.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Renders a `ProxyConfig` into the Xray configuration the tunnel provider +/// boots from, and drops it in the shared container. +/// +/// The iOS config differs from the desktop one in exactly one place: instead of +/// local SOCKS/HTTP inbounds it uses Xray's native layer-3 `tun` inbound, which +/// reads the descriptor NetworkExtension handed us. Outbounds, transports, +/// Reality, XHTTP and the routing rules are the same code path as macOS. +enum TunnelConfigWriter { + + enum WriteError: LocalizedError { + case unsupportedProtocol(ProxyProtocol) + + var errorDescription: String? { + switch self { + case .unsupportedProtocol(let proto): + return "\(proto.rawValue.uppercased()) needs the sing-box core, " + + "which the iOS build does not ship. Use a VLESS, VMess, " + + "Trojan, Shadowsocks or WireGuard server." + } + } + } + + /// Builds the config + session sidecar and writes both to the app group. + @discardableResult + static func write(server: ProxyConfig, settings: AppSettings) throws -> TunnelSession { + guard server.xraySupported else { + throw WriteError.unsupportedProtocol(server.proto) + } + + let session = TunnelSession( + serverName: server.name, + serverAddress: server.address, + mtu: settings.tunnelMTU, + ipv6Enabled: settings.ipv6Enabled, + dnsServers: settings.effectiveDNSServers, + maxMemoryMB: 48 + ) + + let json = try XrayConfigBuilder.jsonString( + for: server, + // On iOS the name is unused: Xray takes the descriptor from the + // `xray.tun.fd` environment flag instead of opening an interface + // itself. It still has to be a well-formed `utunN` so the very same + // config can be validated with a desktop `xray run -test`. + inbound: .tun(name: "utun9", mtu: session.mtu), + rules: settings.effectiveRoutingRules, + logLevel: settings.logLevel.rawValue, + logFile: AppGroup.logURL.path, + dnsServers: session.dnsServers, + stats: true + ) + + try Data(json.utf8).write(to: AppGroup.configURL, options: .atomic) + try session.write() + return session + } + + /// Reads back the config the extension should boot. + static func loadConfig() throws -> String { + let data = try Data(contentsOf: AppGroup.configURL) + return String(decoding: data, as: UTF8.self) + } +} diff --git a/ios/Shared/TunnelIPC.swift b/ios/Shared/TunnelIPC.swift new file mode 100644 index 0000000..dec9801 --- /dev/null +++ b/ios/Shared/TunnelIPC.swift @@ -0,0 +1,76 @@ +import Foundation + +/// Wire format for `NETunnelProviderSession.sendProviderMessage`. +/// +/// The app never talks to Xray directly — the core only ever runs inside the +/// extension. Everything the UI shows (state, traffic counters, core log) comes +/// back through these messages. +enum TunnelCommand: String, Codable { + /// Current core state + counters + log tail. + case status + /// Re-read the config file and restart the core without dropping the + /// tunnel. This is how switching servers stays sub-second: the utun stays + /// up, iOS never shows a reconnect, only Xray restarts. + case reload + /// Truncate the core log. + case clearLog +} + +struct TunnelRequest: Codable { + var command: TunnelCommand + + init(_ command: TunnelCommand) { self.command = command } + + func encoded() -> Data { (try? JSONEncoder().encode(self)) ?? Data() } + + static func decode(_ data: Data) -> TunnelRequest? { + try? JSONDecoder().decode(TunnelRequest.self, from: data) + } +} + +struct TunnelStatus: Codable { + var running: Bool = false + var coreVersion: String = "" + var serverName: String = "" + var uplinkBytes: Int64 = 0 + var downlinkBytes: Int64 = 0 + var startedAt: Date? + var lastError: String? + /// Tail of the core log, capped so provider messages stay small. + var log: String = "" + + func encoded() -> Data { (try? JSONEncoder().encode(self)) ?? Data() } + + static func decode(_ data: Data) -> TunnelStatus? { + try? JSONDecoder().decode(TunnelStatus.self, from: data) + } +} + +/// Everything the provider needs that isn't in the Xray config itself. Written +/// next to the config by the app, read by the extension on start and on reload. +struct TunnelSession: Codable { + var serverName: String = "" + /// Only used to populate `tunnelRemoteAddress`; routing is Xray's job. + var serverAddress: String = "127.0.0.1" + var mtu: Int = 1500 + /// Advertise IPv6 inside the tunnel. On by default: if we only claim IPv4, + /// IPv6-capable apps route around the tunnel and leak. + var ipv6Enabled: Bool = true + var dnsServers: [String] = ["1.1.1.1", "8.8.8.8"] + /// Hard cap on the Go heap. NetworkExtension processes get a small memory + /// budget and are killed outright when they exceed it. + var maxMemoryMB: Int = 48 + + func write() throws { + let data = try JSONEncoder().encode(self) + try data.write(to: AppGroup.sessionURL, options: .atomic) + } + + static func load() -> TunnelSession { + guard let data = try? Data(contentsOf: AppGroup.sessionURL), + let session = try? JSONDecoder().decode(TunnelSession.self, from: data) else { + return TunnelSession() + } + return session + } +} diff --git a/ios/Tunnel/PacketTunnelProvider.swift b/ios/Tunnel/PacketTunnelProvider.swift new file mode 100644 index 0000000..41b3f83 --- /dev/null +++ b/ios/Tunnel/PacketTunnelProvider.swift @@ -0,0 +1,314 @@ +import Foundation +import NetworkExtension +import Network +import os + +import XrayCore + +/// The whole VPN. NetworkExtension creates a utun interface for us and hands +/// over its file descriptor; we give that descriptor to Xray-core, whose native +/// layer-3 `tun` inbound terminates TCP/UDP off the wire with its own gVisor +/// stack and dispatches every connection through the configured outbound. +/// +/// There is no tun2socks, no local SOCKS hop and no second core: the packet +/// path is utun -> Xray -> server, entirely in this process. +/// +/// Concurrency: NetworkExtension calls in on several queues, so every piece of +/// mutable state below is confined to `stateQueue`. Methods whose names end in +/// `Locked` must already be running on it. +final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { + + private let log = Logger(subsystem: "dev.local.veil", category: "tunnel") + private let stateQueue = DispatchQueue(label: "dev.local.veil.tunnel.state") + + private var session = TunnelSession() + private var startedAt: Date? + private var lastError: String? + /// Descriptor of the utun NetworkExtension gave us. Owned by the system — + /// we hand it to Xray but never close it. + private var tunFileDescriptor: Int32 = -1 + + private var pathMonitor: NWPathMonitor? + private var restartWorkItem: DispatchWorkItem? + + // MARK: - Lifecycle + + override func startTunnel(options: [String: NSObject]?, + completionHandler: @escaping (Error?) -> Void) { + let completion = UncheckedSendable(completionHandler) + Task { + do { + try? FileManager.default.removeItem(at: AppGroup.lastErrorURL) + + let session = TunnelSession.load() + let configJSON = try TunnelConfigWriter.loadConfig() + + try await setTunnelNetworkSettings(Self.makeNetworkSettings(session)) + + // The utun only exists once the settings are applied, so look + // for it here rather than at the top of startTunnel. + guard let fd = Self.findUtunDescriptor() else { + throw TunnelError.noTunDescriptor + } + log.info("utun descriptor: \(fd, privacy: .public)") + + try stateQueue.sync { + self.session = session + self.tunFileDescriptor = fd + try self.startCoreLocked(configJSON: configJSON, truncateLog: true) + self.startedAt = Date() + } + + startPathMonitor() + completion.value(nil) + } catch { + stateQueue.sync { self.lastError = error.localizedDescription } + // This process is about to be torn down, so leave the reason + // somewhere the app can still find it. + try? Data(error.localizedDescription.utf8) + .write(to: AppGroup.lastErrorURL, options: .atomic) + log.error("startTunnel failed: \(error.localizedDescription, privacy: .public)") + completion.value(error) + } + } + } + + override func stopTunnel(with reason: NEProviderStopReason, + completionHandler: @escaping () -> Void) { + log.info("stopTunnel: \(reason.rawValue, privacy: .public)") + stopPathMonitor() + stateQueue.sync { + stopCoreLocked() + startedAt = nil + tunFileDescriptor = -1 + } + completionHandler() + } + + override func sleep(completionHandler: @escaping () -> Void) { + // Nothing to flush — Xray owns its connections. Just make sure a + // pending restart doesn't fire while the device is asleep. + stateQueue.sync { + restartWorkItem?.cancel() + restartWorkItem = nil + } + completionHandler() + } + + override func wake() { + // The link almost certainly changed underneath us; rebuild it. + scheduleCoreRestart(reason: "wake", after: 1.0) + } + + // MARK: - Core control + + /// - Parameter truncateLog: only true for a fresh tunnel. A restart caused + /// by a network change would otherwise wipe the very lines explaining why + /// it happened. + private func startCoreLocked(configJSON: String, truncateLog: Bool) throws { + var error: NSError? + if truncateLog { + XrayPrepareLog(AppGroup.logURL.path, &error) + } + + error = nil + let started = XrayStart(configJSON, + Int(tunFileDescriptor), + AppGroup.geoDirectory.path, + session.maxMemoryMB, + &error) + guard started else { + throw error ?? TunnelError.coreStartFailed("unknown") + } + lastError = nil + log.info("xray \(XrayVersion(), privacy: .public) running") + } + + private func stopCoreLocked() { + var error: NSError? + XrayStop(&error) + if let error { + log.error("xray stop: \(error.localizedDescription, privacy: .public)") + } + } + + /// Restarts Xray against the current config file while keeping the tunnel + /// (and its descriptor) up. Used both for switching servers and for + /// recovering from a network change. + private func restartCoreLocked(reason: String) { + guard tunFileDescriptor >= 0 else { return } + log.info("restarting core: \(reason, privacy: .public)") + reasserting = true + defer { reasserting = false } + + stopCoreLocked() + do { + session = TunnelSession.load() + let configJSON = try TunnelConfigWriter.loadConfig() + try startCoreLocked(configJSON: configJSON, truncateLog: false) + if startedAt == nil { startedAt = Date() } + } catch { + lastError = error.localizedDescription + log.error("restart failed: \(error.localizedDescription, privacy: .public)") + } + } + + // MARK: - Network settings + + private static func makeNetworkSettings(_ session: TunnelSession) -> NEPacketTunnelNetworkSettings { + let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: session.serverAddress) + settings.mtu = NSNumber(value: session.mtu) + + // Benchmark/test-network range: no home or carrier network uses it, so + // the interface address can never collide with the physical one. + let ipv4 = NEIPv4Settings(addresses: ["198.18.0.1"], subnetMasks: ["255.255.255.0"]) + ipv4.includedRoutes = [NEIPv4Route.default()] + settings.ipv4Settings = ipv4 + + if session.ipv6Enabled { + let ipv6 = NEIPv6Settings(addresses: ["fd6e:a81b:704f:1211::1"], + networkPrefixLengths: [64]) + ipv6.includedRoutes = [NEIPv6Route.default()] + settings.ipv6Settings = ipv6 + } + + let dns = NEDNSSettings(servers: session.dnsServers) + // Empty match domain = "resolve everything through us", which is what + // sends the queries into the tunnel where Xray's DNS outbound answers. + dns.matchDomains = [""] + settings.dnsSettings = dns + + return settings + } + + // MARK: - utun discovery + + /// Finds the descriptor of the utun socket NetworkExtension opened for this + /// provider. There is no API for it, but that socket answers + /// `getsockopt(SYSPROTO_CONTROL, UTUN_OPT_IFNAME)` with its interface name, + /// which nothing else in the process does. + private static func findUtunDescriptor() -> Int32? { + let sysprotoControl: Int32 = 2 + let utunOptIfname: Int32 = 2 + var name = [CChar](repeating: 0, count: Int(IFNAMSIZ)) + + for fd in Int32(0)...1024 { + var length = socklen_t(name.count) + guard getsockopt(fd, sysprotoControl, utunOptIfname, &name, &length) == 0 else { + continue + } + let bytes = name.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } + if String(decoding: bytes, as: UTF8.self).hasPrefix("utun") { return fd } + } + return nil + } + + // MARK: - Path monitoring + + /// Wi-Fi <-> cellular handovers invalidate every socket Xray holds. iOS + /// keeps the utun alive across them, so all we have to do is rebuild the + /// core's connections — debounced, because one handover produces a burst of + /// path updates. + private func startPathMonitor() { + stopPathMonitor() + let monitor = NWPathMonitor() + monitor.pathUpdateHandler = { [weak self] path in + guard path.status == .satisfied else { return } + self?.scheduleCoreRestart(reason: "network change", after: 1.5) + } + monitor.start(queue: stateQueue) + stateQueue.sync { pathMonitor = monitor } + } + + private func stopPathMonitor() { + stateQueue.sync { + restartWorkItem?.cancel() + restartWorkItem = nil + pathMonitor?.cancel() + pathMonitor = nil + } + } + + private func scheduleCoreRestart(reason: String, after delay: TimeInterval) { + stateQueue.async { [self] in + restartWorkItem?.cancel() + let work = DispatchWorkItem { [weak self] in + self?.restartCoreLocked(reason: reason) + } + restartWorkItem = work + stateQueue.asyncAfter(deadline: .now() + delay, execute: work) + } + } + + // MARK: - App messages + + override func handleAppMessage(_ messageData: Data, + completionHandler: ((Data?) -> Void)?) { + guard let request = TunnelRequest.decode(messageData) else { + completionHandler?(nil) + return + } + + let completion = UncheckedSendable(completionHandler) + stateQueue.async { [self] in + switch request.command { + case .status: + break + case .reload: + // Server switch: same tunnel, same descriptor, new outbound. + restartWorkItem?.cancel() + restartWorkItem = nil + restartCoreLocked(reason: "config reload") + case .clearLog: + var error: NSError? + XrayPrepareLog(AppGroup.logURL.path, &error) + } + completion.value?(currentStatusLocked().encoded()) + } + } + + private func currentStatusLocked() -> TunnelStatus { + TunnelStatus(running: XrayIsRunning(), + coreVersion: XrayVersion(), + serverName: session.serverName, + uplinkBytes: XrayUplink(), + downlinkBytes: XrayDownlink(), + startedAt: startedAt, + lastError: lastError, + log: Self.logTail()) + } + + /// Last few KB of the core log. Provider messages travel over XPC, so we + /// keep the payload small rather than shipping the whole file. + private static func logTail(maxBytes: Int = 16_000) -> String { + guard let handle = try? FileHandle(forReadingFrom: AppGroup.logURL) else { return "" } + defer { try? handle.close() } + let size = (try? handle.seekToEnd()) ?? 0 + let offset = size > UInt64(maxBytes) ? size - UInt64(maxBytes) : 0 + try? handle.seek(toOffset: offset) + let data = (try? handle.readToEnd()) ?? Data() + return String(decoding: data, as: UTF8.self) + } +} + +/// Escape hatch for NetworkExtension's completion handlers: they are plain +/// non-`@Sendable` closures, but Apple documents them as callable from any +/// thread, so boxing them is the honest way to move them across a queue hop. +private struct UncheckedSendable: @unchecked Sendable { + let value: Value + init(_ value: Value) { self.value = value } +} + +enum TunnelError: LocalizedError { + case noTunDescriptor + case coreStartFailed(String) + + var errorDescription: String? { + switch self { + case .noTunDescriptor: + return "Could not find the tunnel interface descriptor." + case .coreStartFailed(let message): + return "Xray failed to start: \(message)" + } + } +} diff --git a/ios/Veil.xcodeproj/project.pbxproj b/ios/Veil.xcodeproj/project.pbxproj new file mode 100644 index 0000000..e4a4924 --- /dev/null +++ b/ios/Veil.xcodeproj/project.pbxproj @@ -0,0 +1,449 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + AA00000000000000000090 /* VeilTunnel.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA00000000000000000031 /* VeilTunnel.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + AA00000000000000000091 /* Frameworks/XrayCore.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA00000000000000000032 /* Frameworks/XrayCore.xcframework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + AA000000000000000000A1 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = AA00000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = AA00000000000000000011; + remoteInfo = VeilTunnel; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + AA00000000000000000053 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + AA00000000000000000090 /* VeilTunnel.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + AA00000000000000000030 /* Veil.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Veil.app; sourceTree = BUILT_PRODUCTS_DIR; }; + AA00000000000000000031 /* VeilTunnel.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = VeilTunnel.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + AA00000000000000000032 /* Frameworks/XrayCore.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = Frameworks/XrayCore.xcframework; sourceTree = ""; }; + AA00000000000000000033 /* Veil.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Veil.entitlements; sourceTree = ""; }; + AA00000000000000000034 /* VeilTunnel.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = VeilTunnel.entitlements; sourceTree = ""; }; + AA00000000000000000035 /* Tunnel-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Tunnel-Info.plist"; sourceTree = ""; }; + AA00000000000000000036 /* App-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "App-Info.plist"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + AA00000000000000000040 /* App */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = App; + sourceTree = ""; + }; + AA00000000000000000041 /* Tunnel */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Tunnel; + sourceTree = ""; + }; + AA00000000000000000042 /* Shared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Shared; + sourceTree = ""; + }; + AA00000000000000000043 /* Models */ = { + isa = PBXFileSystemSynchronizedRootGroup; + name = Models; + path = ../Sources/XrayClient/Models; + sourceTree = ""; + }; + AA00000000000000000044 /* Core */ = { + isa = PBXFileSystemSynchronizedRootGroup; + name = Core; + path = ../Sources/XrayClient/Core; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + AA00000000000000000051 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AA00000000000000000061 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + AA00000000000000000091 /* Frameworks/XrayCore.xcframework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + AA00000000000000000020 = { + isa = PBXGroup; + children = ( + AA00000000000000000040 /* App */, + AA00000000000000000041 /* Tunnel */, + AA00000000000000000042 /* Shared */, + AA00000000000000000043 /* Models */, + AA00000000000000000044 /* Core */, + AA00000000000000000023 /* Config */, + AA00000000000000000022 /* Frameworks */, + AA00000000000000000021 /* Products */, + ); + sourceTree = ""; + }; + AA00000000000000000021 /* Products */ = { + isa = PBXGroup; + children = ( + AA00000000000000000030 /* Veil.app */, + AA00000000000000000031 /* VeilTunnel.appex */, + ); + name = Products; + sourceTree = ""; + }; + AA00000000000000000022 /* Frameworks */ = { + isa = PBXGroup; + children = ( + AA00000000000000000032 /* Frameworks/XrayCore.xcframework */, + ); + name = Frameworks; + sourceTree = ""; + }; + AA00000000000000000023 /* Config */ = { + isa = PBXGroup; + children = ( + AA00000000000000000033 /* Veil.entitlements */, + AA00000000000000000034 /* VeilTunnel.entitlements */, + AA00000000000000000035 /* Tunnel-Info.plist */, + AA00000000000000000036 /* App-Info.plist */, + ); + path = Config; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + AA00000000000000000010 /* Veil */ = { + isa = PBXNativeTarget; + buildConfigurationList = AA00000000000000000081 /* Build configuration list for PBXNativeTarget "Veil" */; + buildPhases = ( + AA00000000000000000050 /* Sources */, + AA00000000000000000051 /* Frameworks */, + AA00000000000000000052 /* Resources */, + AA00000000000000000053 /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + AA000000000000000000A0 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + AA00000000000000000040 /* App */, + AA00000000000000000042 /* Shared */, + AA00000000000000000043 /* Models */, + AA00000000000000000044 /* Core */, + ); + name = Veil; + packageProductDependencies = ( + ); + productName = Veil; + productReference = AA00000000000000000030 /* Veil.app */; + productType = "com.apple.product-type.application"; + }; + AA00000000000000000011 /* VeilTunnel */ = { + isa = PBXNativeTarget; + buildConfigurationList = AA00000000000000000082 /* Build configuration list for PBXNativeTarget "VeilTunnel" */; + buildPhases = ( + AA00000000000000000060 /* Sources */, + AA00000000000000000061 /* Frameworks */, + AA00000000000000000062 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + AA00000000000000000041 /* Tunnel */, + AA00000000000000000042 /* Shared */, + AA00000000000000000043 /* Models */, + AA00000000000000000044 /* Core */, + ); + name = VeilTunnel; + packageProductDependencies = ( + ); + productName = VeilTunnel; + productReference = AA00000000000000000031 /* VeilTunnel.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + AA00000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2700; + LastUpgradeCheck = 2700; + TargetAttributes = { + AA00000000000000000010 = { + CreatedOnToolsVersion = 27.0; + }; + AA00000000000000000011 = { + CreatedOnToolsVersion = 27.0; + }; + }; + }; + buildConfigurationList = AA00000000000000000080 /* Build configuration list for PBXProject "Veil" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = AA00000000000000000020; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = AA00000000000000000021 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + AA00000000000000000010 /* Veil */, + AA00000000000000000011 /* VeilTunnel */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + AA00000000000000000052 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AA00000000000000000062 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + AA00000000000000000050 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AA00000000000000000060 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + AA000000000000000000A0 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = AA00000000000000000011 /* VeilTunnel */; + targetProxy = AA000000000000000000A1 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + AA00000000000000000070 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + AA00000000000000000071 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + AA00000000000000000072 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Config/Veil.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Config/App-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = Veil; + INFOPLIST_KEY_NSCameraUsageDescription = "Veil uses the camera to scan server and subscription QR codes."; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.local.veil; + PRODUCT_NAME = Veil; + SWIFT_EMIT_LOC_STRINGS = YES; + }; + name = Debug; + }; + AA00000000000000000073 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Config/Veil.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Config/App-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = Veil; + INFOPLIST_KEY_NSCameraUsageDescription = "Veil uses the camera to scan server and subscription QR codes."; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.local.veil; + PRODUCT_NAME = Veil; + SWIFT_EMIT_LOC_STRINGS = YES; + }; + name = Release; + }; + AA00000000000000000074 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Config/VeilTunnel.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Config/Tunnel-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "Veil Tunnel"; + MARKETING_VERSION = 1.0.0; + OTHER_LDFLAGS = "-lresolv"; + PRODUCT_BUNDLE_IDENTIFIER = dev.local.veil.tunnel; + PRODUCT_NAME = VeilTunnel; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + }; + name = Debug; + }; + AA00000000000000000075 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Config/VeilTunnel.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Config/Tunnel-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "Veil Tunnel"; + MARKETING_VERSION = 1.0.0; + OTHER_LDFLAGS = "-lresolv"; + PRODUCT_BUNDLE_IDENTIFIER = dev.local.veil.tunnel; + PRODUCT_NAME = VeilTunnel; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + AA00000000000000000080 /* Build configuration list for PBXProject "Veil" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA00000000000000000070 /* Debug */, + AA00000000000000000071 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + AA00000000000000000081 /* Build configuration list for PBXNativeTarget "Veil" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA00000000000000000072 /* Debug */, + AA00000000000000000073 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + AA00000000000000000082 /* Build configuration list for PBXNativeTarget "VeilTunnel" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA00000000000000000074 /* Debug */, + AA00000000000000000075 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = AA00000000000000000001 /* Project object */; +} diff --git a/ios/Veil.xcodeproj/xcshareddata/xcschemes/Veil.xcscheme b/ios/Veil.xcodeproj/xcshareddata/xcschemes/Veil.xcscheme new file mode 100644 index 0000000..28bcab6 --- /dev/null +++ b/ios/Veil.xcodeproj/xcshareddata/xcschemes/Veil.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/XrayBridge/go.mod b/ios/XrayBridge/go.mod new file mode 100644 index 0000000..a0c44f8 --- /dev/null +++ b/ios/XrayBridge/go.mod @@ -0,0 +1,46 @@ +module github.com/faustyu1/veil/ios/xraybridge + +go 1.26.0 + +require github.com/xtls/xray-core v1.260327.0 + +require ( + github.com/andybalholm/brotli v1.0.6 // indirect + github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 // indirect + github.com/google/btree v1.1.2 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/juju/ratelimit v1.0.2 // indirect + github.com/klauspost/compress v1.17.4 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/miekg/dns v1.1.72 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect + github.com/pires/go-proxyproto v0.11.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect + github.com/sagernet/sing v0.5.1 // indirect + github.com/sagernet/sing-shadowsocks v0.2.7 // indirect + github.com/vishvananda/netlink v1.3.1 // indirect + github.com/vishvananda/netns v0.0.5 // indirect + github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f // indirect + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect + golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/time v0.12.0 // indirect + golang.org/x/tools v0.48.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect + golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect + lukechampine.com/blake3 v1.4.1 // indirect +) diff --git a/ios/XrayBridge/go.sum b/ios/XrayBridge/go.sum new file mode 100644 index 0000000..692fe15 --- /dev/null +++ b/ios/XrayBridge/go.sum @@ -0,0 +1,142 @@ +github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= +github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22 h1:00ziBGnLWQEcR9LThDwvxOznJJquJ9bYUdmBFnawLMU= +github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4= +github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U= +github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= +github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI= +github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk= +github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= +github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4= +github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er2acxbi3N1nvEq6HXHUAR1nTWEJmQfqiGR8EVT9rfs= +github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/sagernet/sing v0.5.1 h1:mhL/MZVq0TjuvHcpYcFtmSD1BFOxZ/+8ofbNZcg1k1Y= +github.com/sagernet/sing v0.5.1/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= +github.com/sagernet/sing-shadowsocks v0.2.7 h1:zaopR1tbHEw5Nk6FAkM05wCslV6ahVegEZaKMv9ipx8= +github.com/sagernet/sing-shadowsocks v0.2.7/go.mod h1:0rIKJZBR65Qi0zwdKezt4s57y/Tl1ofkaq6NlkzVuyE= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= +github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= +github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8= +github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI= +github.com/xtls/xray-core v1.260327.0 h1:g4TzxMwyPrxslZh6uD+FiG3lXKTrnNO+b4ky2OhogHE= +github.com/xtls/xray-core v1.260327.0/go.mod h1:OXMlhBloFry8mw0KwWLWLd3RQyXJzEYsCGlgsX36h60= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= +golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5 h1:Mn1OzFmF0ZKX/ZayHz/UdnWHufPp1wlD9lZ5U8LRDFY= +golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5/go.mod h1:YX+n47s+53POxN3dx9cIGxG3hGUm/lD64hvrRJFbcSA= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= +golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 h1:Lk6hARj5UPY47dBep70OD/TIMwikJ5fGUGX0Rm3Xigk= +gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= +lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= +lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= diff --git a/ios/XrayBridge/xray.go b/ios/XrayBridge/xray.go new file mode 100644 index 0000000..90be5d9 --- /dev/null +++ b/ios/XrayBridge/xray.go @@ -0,0 +1,171 @@ +// Package xray embeds Xray-core into an iOS NetworkExtension. +// +// The whole tunnel lives inside Xray: the packet tunnel provider hands us the +// utun file descriptor it got from NetworkExtension, we publish it through the +// `xray.tun.fd` environment flag, and Xray's native `tun` inbound (gVisor based) +// terminates TCP/UDP straight off the interface. There is no tun2socks, no +// second core and no local SOCKS hop — packets go utun -> Xray -> server. +// +// Built with `gomobile bind` into XrayCore.xcframework, see +// Scripts/ios/build-xraycore.sh. +package xray + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "runtime/debug" + "strconv" + "sync" + + "github.com/xtls/xray-core/common/platform" + "github.com/xtls/xray-core/core" + "github.com/xtls/xray-core/features/stats" + "github.com/xtls/xray-core/infra/conf/serial" + + // Registers every protocol, transport and JSON parser. Importing the conf + // package (pulled in via main/json) is also what registers the `tun` + // inbound handler. + _ "github.com/xtls/xray-core/main/distro/all" +) + +var ( + mu sync.Mutex + instance *core.Instance + counters stats.Manager +) + +// Version returns the embedded Xray-core version, e.g. "26.3.27". +func Version() string { + return core.Version() +} + +// IsRunning reports whether a core instance is currently up. +func IsRunning() bool { + mu.Lock() + defer mu.Unlock() + return instance != nil +} + +// Start boots Xray-core from a JSON configuration. +// +// - configJSON is a full Xray config; it must contain a `tun` inbound. +// - tunFd is the utun descriptor owned by NEPacketTunnelProvider. Xray reads +// and writes raw IP packets on it and never closes it. +// - assetDir is where geoip.dat / geosite.dat live (may be empty). +// - maxMemoryMB caps the Go heap. NetworkExtension processes are killed hard +// when they exceed their (small) memory budget, so we make the collector +// aggressive instead of letting the jetsam killer do it for us. +func Start(configJSON string, tunFd int, assetDir string, maxMemoryMB int) error { + mu.Lock() + defer mu.Unlock() + + if instance != nil { + return errors.New("xray is already running") + } + if tunFd <= 0 { + return errors.New("invalid tun file descriptor") + } + + if maxMemoryMB > 0 { + limit := int64(maxMemoryMB) << 20 + debug.SetGCPercent(10) + debug.SetMemoryLimit(limit) + } + + if err := os.Setenv(platform.TunFdKey, strconv.Itoa(tunFd)); err != nil { + return err + } + if assetDir != "" { + if err := os.Setenv(platform.AssetLocation, assetDir); err != nil { + return err + } + } + + config, err := serial.LoadJSONConfig(bytes.NewReader([]byte(configJSON))) + if err != nil { + return err + } + + inst, err := core.New(config) + if err != nil { + return err + } + if err := inst.Start(); err != nil { + _ = inst.Close() + return err + } + + instance = inst + if manager, ok := inst.GetFeature(stats.ManagerType()).(stats.Manager); ok { + counters = manager + } + return nil +} + +// Stop tears the core down. The tun descriptor stays open — it belongs to +// NetworkExtension, which closes it when the tunnel goes away. +func Stop() error { + mu.Lock() + defer mu.Unlock() + + if instance == nil { + return nil + } + err := instance.Close() + instance = nil + counters = nil + _ = os.Unsetenv(platform.TunFdKey) + debug.FreeOSMemory() + return err +} + +// Uplink returns the bytes sent through the "proxy" outbound since start. +// Returns 0 when statistics are not enabled in the config. +func Uplink() int64 { + return counter("outbound>>>proxy>>>traffic>>>uplink") +} + +// Downlink returns the bytes received through the "proxy" outbound since start. +func Downlink() int64 { + return counter("outbound>>>proxy>>>traffic>>>downlink") +} + +func counter(name string) int64 { + mu.Lock() + manager := counters + mu.Unlock() + if manager == nil { + return 0 + } + c := manager.GetCounter(name) + if c == nil { + return 0 + } + return c.Value() +} + +// CheckConfig validates a configuration without starting anything. Used by the +// app to surface bad server entries before the tunnel is brought up. +func CheckConfig(configJSON string) error { + _, err := serial.LoadJSONConfig(bytes.NewReader([]byte(configJSON))) + return err +} + +// PrepareLog truncates the core log file so each session starts clean, and +// returns the path it was given. Xray itself appends to this file through the +// `log.error` config field. +func PrepareLog(path string) error { + if path == "" { + return nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + if err != nil { + return err + } + return f.Close() +}