├── .gitignore ├── .swiftformat ├── Config.xcconfig ├── PacketTunnel ├── Info.plist ├── PacketTunnel.entitlements └── PacketTunnelProvider.swift ├── README.md ├── Shared └── Constant.swift ├── Xray.xcodeproj └── project.pbxproj └── Xray ├── ActionButtonStyle.swift ├── Assets.xcassets ├── AccentColor.colorset │ └── Contents.json ├── AppIcon.appiconset │ └── Contents.json └── Contents.json ├── Configuration.swift ├── ConnectedDurationView.swift ├── ContentView.swift ├── DownloadView.swift ├── Info.plist ├── InfoRow.swift ├── PacketTunnelManager.swift ├── PingView.swift ├── QRCodeScannerView.swift ├── ShareModalView.swift ├── TrafficStatsView.swift ├── Util.swift ├── VPNControlView.swift ├── VPNModePickerView.swift ├── VersionView.swift ├── Xray.entitlements └── XrayApp.swift /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | build/ 4 | DerivedData/ 5 | *.xcworkspace 6 | *.xccheckout 7 | *.moved-aside 8 | *.xcuserstate 9 | xcuserdata/ 10 | *.xcodeproj/*.pbxuser 11 | *.xcodeproj/*.mode1v3 12 | *.xcodeproj/*.mode2v3 13 | *.xcodeproj/*.perspectivev3 14 | 15 | # Swift Package Manager 16 | # 17 | .build/ 18 | .swiftpm/ 19 | Package.resolved 20 | 21 | # CocoaPods 22 | # 23 | Pods/ 24 | Podfile.lock 25 | 26 | # Carthage 27 | # 28 | Carthage/Build/ 29 | Carthage/Checkouts/ 30 | 31 | # Accio dependency manager 32 | # 33 | Dependencies/ 34 | AccioPackages/ 35 | 36 | # SwiftLint 37 | # 38 | .swiftlint.yml 39 | .swiftlint 40 | 41 | # Fastlane 42 | # 43 | fastlane/report.xml 44 | fastlane/Preview.html 45 | fastlane/screenshots/ 46 | fastlane/test_output/ 47 | fastlane/README.md 48 | 49 | # Archives 50 | # 51 | *.xcarchive 52 | 53 | # Personal settings 54 | # 55 | *.xcuserdatad/ 56 | 57 | # App data 58 | # 59 | *.ipa 60 | *.dSYM.zip 61 | *.dSYM 62 | 63 | # Temporary files 64 | # 65 | *.tmp 66 | *.swp 67 | *.lock 68 | 69 | # Other 70 | # 71 | *.orig 72 | *.log 73 | .DS_Store 74 | .env 75 | *.xcuserstate 76 | 77 | # Simulator devices 78 | # 79 | .DerivedData/ 80 | 81 | # Bundle artifacts 82 | # 83 | *.xcassets/ 84 | 85 | # Playground files 86 | # 87 | timeline.xctimeline 88 | playground.xcworkspace 89 | 90 | # SwiftUI Previews 91 | # 92 | *.swiftsourceinfo 93 | 94 | # SwiftPM Packages 95 | # 96 | Packages/ 97 | 98 | # Logs 99 | # 100 | *.log 101 | 102 | # mac​⬤ 103 | /Xray.xcodeproj/xcshareddata/xcschemes/PacketTunnel.xcscheme 104 | /Xray.xcodeproj/xcshareddata/xcschemes/Xray.xcscheme 105 | 106 | .idea -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --exclude Pods,build # 排除 Pods 和 build 文件夹 2 | -------------------------------------------------------------------------------- /Config.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // Config.xcconfig 3 | // Xray 4 | // 5 | // Created by pan on 2024/9/14. 6 | // 7 | 8 | // Configuration settings file format documentation can be found at: 9 | // https://help.apple.com/xcode/#/dev745c5c974 10 | APP_ID = com.app.Xray 11 | -------------------------------------------------------------------------------- /PacketTunnel/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | APP_ID 6 | ${APP_ID} 7 | NSExtension 8 | 9 | NSExtensionPointIdentifier 10 | com.apple.networkextension.packet-tunnel 11 | NSExtensionPrincipalClass 12 | $(PRODUCT_MODULE_NAME).PacketTunnelProvider 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /PacketTunnel/PacketTunnel.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.developer.networking.networkextension 6 | 7 | packet-tunnel-provider 8 | 9 | com.apple.security.application-groups 10 | 11 | group.$(APP_ID) 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /PacketTunnel/PacketTunnelProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PacketTunnelProvider.swift 3 | // PacketTunnel 4 | // 5 | // Created by pan on 2024/9/14. 6 | // 7 | 8 | import LibXray 9 | import Network 10 | import NetworkExtension 11 | import os 12 | import Tun2SocksKit 13 | 14 | // MARK: - Logger 15 | 16 | private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PacketTunnelProvider") 17 | 18 | // MARK: - 数据模型 19 | 20 | /// 用于向 Xray 核心传递配置参数的结构体,包含配置文件路径、数据文件目录等信息。 21 | struct RunXrayRequest: Codable { 22 | /// Xray 核心所需的数据文件目录(如 geoip、geosite 等)。 23 | var datDir: String? 24 | 25 | /// Xray 配置文件的本地路径,用于启动时加载其内容。 26 | var configPath: String? 27 | 28 | /// Xray 核心可占用的最大内存(字节为单位),可根据实际需求进行限制。 29 | var maxMemory: Int64? 30 | } 31 | 32 | // MARK: - PacketTunnelProvider 33 | 34 | /// 核心的 VPN 扩展入口类。通过重写 `startTunnel`、`stopTunnel` 来管理自定义隧道的创建与销毁。 35 | /// 内部包含了启动 Xray、设置网络环境以及启动 Socks5 隧道的完整逻辑。 36 | class PacketTunnelProvider: NEPacketTunnelProvider { 37 | // MARK: - 常量 38 | 39 | /// 虚拟网络接口的 MTU 值,可根据实际环境进行调整。 40 | let MTU = 8500 41 | 42 | // MARK: - 隧道生命周期 43 | 44 | /// 在创建或启用隧道时调用,用于启动 Xray 核心、配置虚拟网卡并启动 Tun2SocksKit(Socks5 隧道)。 45 | /// 46 | /// - Parameter options: 传递给隧道的键值对信息,通常包含 `sock5Port` 和 `path` 等。 47 | /// - Throws: 若缺失必要信息或启动过程中发生错误,抛出相应的错误。 48 | override func startTunnel(options: [String: NSObject]? = nil) async throws { 49 | // 1. 从 options 中提取 SOCKS5 端口与配置文件路径 50 | guard let sock5Port = options?["sock5Port"] as? Int else { 51 | throw NSError(domain: "PacketTunnel", code: -1, 52 | userInfo: [NSLocalizedDescriptionKey: "缺少 SOCKS5 端口配置"]) 53 | } 54 | 55 | guard let path = options?["path"] as? String else { 56 | throw NSError(domain: "PacketTunnel", code: -1, 57 | userInfo: [NSLocalizedDescriptionKey: "缺少配置路径"]) 58 | } 59 | 60 | do { 61 | // 2. 启动 Xray 核心 62 | try startXray(path: path) 63 | 64 | // 3. 设置隧道网络(虚拟网卡、路由、DNS 等) 65 | try await setTunnelNetworkSettings() 66 | 67 | // 4. 启动 SOCKS5 隧道(Tun2SocksKit) 68 | try startSocks5Tunnel(serverPort: sock5Port) 69 | 70 | } catch { 71 | Logger().error("启动服务时发生错误: \(error.localizedDescription)") 72 | throw error 73 | } 74 | } 75 | 76 | /// 在隧道停止时调用,可在此处释放所有资源、停止相关服务(如 Xray、Tun2SocksKit)。 77 | /// 78 | /// - Parameter reason: 系统定义的停止原因,通常可以根据实际需求进行相应处理。 79 | override func stopTunnel(with reason: NEProviderStopReason) async { 80 | // 1. 停止 SOCKS5 隧道 81 | Socks5Tunnel.quit() 82 | 83 | // 2. 停止 Xray 核心 84 | LibXrayStopXray() 85 | 86 | // 3.输出日志 87 | Logger().info("隧道停止, 原因: \(reason.rawValue)") 88 | } 89 | 90 | // MARK: - 启动 Xray 91 | 92 | /// 启动 Xray 核心进程,向其传递相关配置和内存限制。 93 | /// 94 | /// - Parameter path: 已生成的 Xray 配置文件路径。 95 | /// - Throws: 当编码请求或底层调用出现错误时,抛出相应错误。 96 | private func startXray(path: String) throws { 97 | // 1. 构造请求对象,填入配置路径、数据目录、内存限制等 98 | let request = RunXrayRequest( 99 | datDir: Constant.assetDirectory.path, 100 | configPath: path, 101 | maxMemory: 50 * 1024 * 1024 102 | ) 103 | 104 | do { 105 | // 2. 将请求对象编码为 JSON 106 | let jsonData = try JSONEncoder().encode(request) 107 | let base64String = jsonData.base64EncodedString() 108 | 109 | // 3. 调用 LibXrayRunXray 以启动 Xray 核心 110 | LibXrayRunXray(base64String) 111 | 112 | } catch { 113 | Logger().error("Xray请求编码失败: \(error.localizedDescription)") 114 | throw error 115 | } 116 | } 117 | 118 | // MARK: - 启动 Socks5 隧道(Tun2SocksKit) 119 | 120 | /// 使用 Tun2SocksKit 启动 Socks5 隧道,将指定端口的 SOCKS5 流量引导进虚拟网卡。 121 | /// 122 | /// - Parameter port: SOCKS5 服务所监听的端口号,默认为 10808。 123 | /// - Throws: 若配置字符串生成或启动过程中发生错误,抛出相应错误。 124 | private func startSocks5Tunnel(serverPort port: Int = 10808) throws { 125 | // 1. 构造 Socks5 隧道配置 126 | let socks5Config = """ 127 | tunnel: 128 | mtu: \(MTU) 129 | 130 | socks5: 131 | port: \(port) 132 | address: 127.0.0.1 133 | udp: 'udp' 134 | 135 | misc: 136 | task-stack-size: 20480 137 | connect-timeout: 5000 138 | read-write-timeout: 60000 139 | log-file: stderr 140 | log-level: debug 141 | limit-nofile: 65535 142 | """ 143 | 144 | // 2. 启动隧道 145 | Socks5Tunnel.run(withConfig: .string(content: socks5Config)) { code in 146 | if code == 0 { 147 | Logger().info("Tun2Socks启动成功") 148 | } else { 149 | Logger().error("Tun2Socks启动失败,代码: \(code)") 150 | } 151 | } 152 | } 153 | 154 | // MARK: - 配置虚拟网卡 155 | 156 | /// 配置隧道网络设置,如本地虚拟网卡 IP、路由、DNS 服务器等,并应用到当前隧道。 157 | /// 158 | /// - Throws: 当网络设置应用失败时,抛出相应错误。 159 | func setTunnelNetworkSettings() async throws { 160 | // 1. 创建基础的 NetworkSettings 161 | let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "fd00::1") 162 | settings.mtu = NSNumber(value: MTU) 163 | 164 | // 2. 配置 IPv4 设置 165 | settings.ipv4Settings = { 166 | // - addresses:本地虚拟网卡 IP(客户端侧) 167 | // - subnetMasks:对应的掩码 168 | let ipv4 = NEIPv4Settings( 169 | addresses: ["198.18.0.1"], 170 | subnetMasks: ["255.255.255.0"] 171 | ) 172 | 173 | let defaultV4Route = NEIPv4Route( 174 | destinationAddress: "0.0.0.0", 175 | subnetMask: "0.0.0.0" 176 | ) 177 | defaultV4Route.gatewayAddress = "198.18.0.1" 178 | 179 | ipv4.includedRoutes = [defaultV4Route] 180 | ipv4.excludedRoutes = [] 181 | return ipv4 182 | }() 183 | 184 | // 3. 配置 IPv6 设置 185 | settings.ipv6Settings = { 186 | let ipv6 = NEIPv6Settings( 187 | addresses: ["fd6e:a81b:704f:1211::1"], 188 | networkPrefixLengths: [64] 189 | ) 190 | 191 | let defaultV6Route = NEIPv6Route( 192 | destinationAddress: "::", 193 | networkPrefixLength: 0 194 | ) 195 | defaultV6Route.gatewayAddress = "fd6e:a81b:704f:1211::1" 196 | 197 | ipv6.includedRoutes = [defaultV6Route] 198 | ipv6.excludedRoutes = [] 199 | return ipv6 200 | }() 201 | 202 | // 4. 配置 DNS 服务器 203 | let dnsSettings = NEDNSSettings(servers: [ 204 | "1.1.1.1", // Cloudflare DNS (IPv4) 205 | "8.8.8.8", // Google DNS (IPv4) 206 | "2606:4700:4700::1111", // Cloudflare DNS (IPv6) 207 | "2001:4860:4860::8888", // Google DNS (IPv6) 208 | ]) 209 | // matchDomains = [""] 表示所有域名都走此 DNS 210 | dnsSettings.matchDomains = [""] 211 | 212 | settings.dnsSettings = dnsSettings 213 | 214 | // 5. 应用新建的网络设置 215 | try await setTunnelNetworkSettings(settings) 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # xray核心的简单例子 2 | 这个项目只是展示了libxray的基础使用方式 3 | This project only demonstrates the basic usage of libxray 4 | 5 | 实现了最基础的功能,安装以后首次使用需要从别的地方复制配置,类似 **vless://id@ip:port?security=none&encryption=none&type=tcp** 这样的链接,然后从剪切板粘贴进来,或者扫描二维码,配置会保存到 **UserDefaults**,再次使用的时候直接点击连接就可以 6 | 7 | The implementation includes only the most basic functionality. Upon first use after installation, the user needs to copy a configuration from elsewhere, similar to **vless://id@ip:port?security=none&encryption=none&type=tcp**, then paste it from the clipboard or scan a QR code. The configuration will be saved to **UserDefaults**, allowing the user to simply click 连接 for subsequent uses. 8 | 9 | ## 地理文件 10 | 如果您的网络可以自由的访问github,那么可以直接点击**地理文件**下载,下载成功以后会显示在页面上。如果有限制,建议先连接vpn,之后再点击**地理文件**下载 11 | 12 | If your network can freely access GitHub, you can directly click **地理文件** to download them. Once downloaded successfully, they will be displayed on the page. If there are restrictions, it is recommended to first connect to a VPN and then click **地理文件** to download. 13 | 14 | ## LibXrayPing 15 | 启动**xray**以后,就不能使用**LibXrayPing**方法,所以连接中的时候隐藏了**点击获取网速** 16 | 17 | After starting **Xray**, the **LibXrayPing** method can no longer be used, so the **点击获取网速** text is hidden while the VPN is connected. 18 | 19 | ## 测试机型 20 | iphone 15 plus,系统 ios 17.6.1 和 18 21 | iphone 12,系统 ios 17.6.1 22 | 低于17.6.1的版本我没有测试过,很抱歉我就只有一台ios17.6.1的iphone 23 | I have not tested versions below 17.6.1. Apologies, as I only have an iPhone running iOS 17.6.1. 24 | ios15和ios16参考 25 | For iOS 15 and iOS 16, refer to: 26 | [ios16无法使用](https://github.com/wanliyunyan/xray_ios/issues/14#issuecomment-2651015275) [ios 16 support](https://github.com/wanliyunyan/xray_ios/issues/16) 27 | 28 | ## 测试链接 29 | **vless**链接,其它的链接都没有测试过,所以有什么问题,我也不知道 30 | 31 | Only VLESS links have been tested. Other types of links have not been tested, so I am unsure about potential issues. 32 | 33 | ## 格式化 34 | ```shell 35 | swiftformat . 36 | ``` 37 | 38 | ## ipv6 39 | 我没有ipv6的网络,所以我没法调试代码,主要是修改listen 40 | 41 | I do not have an IPv6 network, so I am unable to debug the code. The main modification involves adjusting the listen setting. 42 | This project only demonstrates the basic usage of libxray. 43 | -------------------------------------------------------------------------------- /Shared/Constant.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Constant.swift 3 | // Xray 4 | // 5 | // Created by pan on 2024/9/14. 6 | // 7 | 8 | import Foundation 9 | import Network 10 | 11 | public enum Constant { 12 | public static let packageName = Bundle.main.infoDictionary?["APP_ID"] as? String ?? "unknown" 13 | } 14 | 15 | public extension Constant { 16 | static let groupName = "group.\(Constant.packageName)" 17 | static let tunnelName = "\(Constant.packageName).PacketTunnel" 18 | static let sock5Port: NWEndpoint.Port = 10808 19 | static let trafficPort: NWEndpoint.Port = 49227 20 | 21 | private static func createDirectory(at url: URL) -> URL { 22 | guard FileManager.default.fileExists(atPath: url.path) == false else { 23 | return url 24 | } 25 | do { 26 | try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) 27 | } catch { 28 | fatalError(error.localizedDescription) 29 | } 30 | return url 31 | } 32 | 33 | static let homeDirectory: URL = { 34 | guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupName) else { 35 | fatalError("无法加载共享文件路径") 36 | } 37 | let url = containerURL.appendingPathComponent("Library/Application Support/Xray") 38 | return createDirectory(at: url) 39 | }() 40 | 41 | static let assetDirectory = createDirectory(at: homeDirectory.appending(component: "assets", directoryHint: .isDirectory)) 42 | 43 | static let configDirectory = createDirectory(at: homeDirectory.appending(component: "configs", directoryHint: .isDirectory)) 44 | } 45 | -------------------------------------------------------------------------------- /Xray.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 3B08478E2CC1135400484E72 /* DownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B08478D2CC1135400484E72 /* DownloadView.swift */; }; 11 | 3B2DA9582C95337E00E8FCB9 /* XrayApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2DA9572C95337E00E8FCB9 /* XrayApp.swift */; }; 12 | 3B2DA95C2C95337F00E8FCB9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3B2DA95B2C95337F00E8FCB9 /* Assets.xcassets */; }; 13 | 3B2DA9882C95344600E8FCB9 /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B2DA9872C95344600E8FCB9 /* NetworkExtension.framework */; }; 14 | 3B2DA98B2C95344600E8FCB9 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2DA98A2C95344600E8FCB9 /* PacketTunnelProvider.swift */; }; 15 | 3B2DA9902C95344600E8FCB9 /* PacketTunnel.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3B2DA9852C95344600E8FCB9 /* PacketTunnel.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 16 | 3B2DA9972C9536F900E8FCB9 /* Config.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 3B2DA9962C9536F900E8FCB9 /* Config.xcconfig */; }; 17 | 3B2DA9982C9536F900E8FCB9 /* Config.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 3B2DA9962C9536F900E8FCB9 /* Config.xcconfig */; }; 18 | 3B2DA99C2C95393600E8FCB9 /* Constant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2DA99B2C95393600E8FCB9 /* Constant.swift */; }; 19 | 3B2DA99D2C95393600E8FCB9 /* Constant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2DA99B2C95393600E8FCB9 /* Constant.swift */; }; 20 | 3B2DA99F2C95398600E8FCB9 /* PacketTunnelManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2DA99E2C95398600E8FCB9 /* PacketTunnelManager.swift */; }; 21 | 3B2DA9AB2C953B7E00E8FCB9 /* libresolv.9.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B2DA9AA2C953B6200E8FCB9 /* libresolv.9.tbd */; }; 22 | 3B2DA9AD2C95573400E8FCB9 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2DA9AC2C95573400E8FCB9 /* ContentView.swift */; }; 23 | 3B3C13892CDA0CB60076B451 /* VPNModePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B3C13882CDA0CB40076B451 /* VPNModePickerView.swift */; }; 24 | 3B8050DE2C9D7C0800043697 /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B8050DD2C9D7C0800043697 /* Configuration.swift */; }; 25 | 3B99F5162CA11A8A00CDC946 /* ConnectedDurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B99F5152CA11A8A00CDC946 /* ConnectedDurationView.swift */; }; 26 | 3BA6A1D82C9D731100FF9E1A /* libresolv.9.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B2DA9AA2C953B6200E8FCB9 /* libresolv.9.tbd */; }; 27 | 3BA6A1DA2C9D756C00FF9E1A /* InfoRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BA6A1D92C9D756C00FF9E1A /* InfoRow.swift */; }; 28 | 3BA6A1DC2C9D759D00FF9E1A /* ActionButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BA6A1DB2C9D759D00FF9E1A /* ActionButtonStyle.swift */; }; 29 | 3BA6A1DE2C9D75C000FF9E1A /* VPNControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BA6A1DD2C9D75C000FF9E1A /* VPNControlView.swift */; }; 30 | 3BA6A1E02C9D75DF00FF9E1A /* VersionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BA6A1DF2C9D75DF00FF9E1A /* VersionView.swift */; }; 31 | 3BA6A1E22C9D779A00FF9E1A /* Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BA6A1E12C9D779A00FF9E1A /* Util.swift */; }; 32 | 3BAD4E2C2CAA747A006868C3 /* PingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAD4E2B2CAA747A006868C3 /* PingView.swift */; }; 33 | 3BC43D562CA2A76D00733C84 /* TrafficStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC43D552CA2A76D00733C84 /* TrafficStatsView.swift */; }; 34 | 3BCF8AEB2CA8E5C400723541 /* ShareModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BCF8AEA2CA8E5C400723541 /* ShareModalView.swift */; }; 35 | 3BF632AC2CB8CF4000FB040B /* QRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF632AB2CB8CF4000FB040B /* QRCodeScannerView.swift */; }; 36 | F3E0E5912D548849007B9B0F /* LibXray in Frameworks */ = {isa = PBXBuildFile; productRef = F3E0E5902D548849007B9B0F /* LibXray */; }; 37 | F3E0E5932D548851007B9B0F /* LibXray in Frameworks */ = {isa = PBXBuildFile; productRef = F3E0E5922D548851007B9B0F /* LibXray */; }; 38 | F3E0E5962D54887B007B9B0F /* Tun2SocksKit in Frameworks */ = {isa = PBXBuildFile; productRef = F3E0E5952D54887B007B9B0F /* Tun2SocksKit */; }; 39 | F3E0E5982D54887B007B9B0F /* Tun2SocksKitC in Frameworks */ = {isa = PBXBuildFile; productRef = F3E0E5972D54887B007B9B0F /* Tun2SocksKitC */; }; 40 | /* End PBXBuildFile section */ 41 | 42 | /* Begin PBXContainerItemProxy section */ 43 | 3B2DA98E2C95344600E8FCB9 /* PBXContainerItemProxy */ = { 44 | isa = PBXContainerItemProxy; 45 | containerPortal = 3B2DA94C2C95337E00E8FCB9 /* Project object */; 46 | proxyType = 1; 47 | remoteGlobalIDString = 3B2DA9842C95344600E8FCB9; 48 | remoteInfo = PacketTunnel; 49 | }; 50 | /* End PBXContainerItemProxy section */ 51 | 52 | /* Begin PBXCopyFilesBuildPhase section */ 53 | 3B2DA9942C95344600E8FCB9 /* Embed Foundation Extensions */ = { 54 | isa = PBXCopyFilesBuildPhase; 55 | buildActionMask = 2147483647; 56 | dstPath = ""; 57 | dstSubfolderSpec = 13; 58 | files = ( 59 | 3B2DA9902C95344600E8FCB9 /* PacketTunnel.appex in Embed Foundation Extensions */, 60 | ); 61 | name = "Embed Foundation Extensions"; 62 | runOnlyForDeploymentPostprocessing = 0; 63 | }; 64 | /* End PBXCopyFilesBuildPhase section */ 65 | 66 | /* Begin PBXFileReference section */ 67 | 3B08478D2CC1135400484E72 /* DownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = ""; }; 68 | 3B2DA9542C95337E00E8FCB9 /* Xray.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Xray.app; sourceTree = BUILT_PRODUCTS_DIR; }; 69 | 3B2DA9572C95337E00E8FCB9 /* XrayApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XrayApp.swift; sourceTree = ""; }; 70 | 3B2DA95B2C95337F00E8FCB9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 71 | 3B2DA9852C95344600E8FCB9 /* PacketTunnel.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PacketTunnel.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 72 | 3B2DA9872C95344600E8FCB9 /* NetworkExtension.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NetworkExtension.framework; path = System/Library/Frameworks/NetworkExtension.framework; sourceTree = SDKROOT; }; 73 | 3B2DA98A2C95344600E8FCB9 /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = ""; }; 74 | 3B2DA98C2C95344600E8FCB9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 75 | 3B2DA98D2C95344600E8FCB9 /* PacketTunnel.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PacketTunnel.entitlements; sourceTree = ""; }; 76 | 3B2DA9952C95346A00E8FCB9 /* Xray.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Xray.entitlements; sourceTree = ""; }; 77 | 3B2DA9962C9536F900E8FCB9 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; 78 | 3B2DA9992C95379100E8FCB9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 79 | 3B2DA99B2C95393600E8FCB9 /* Constant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constant.swift; sourceTree = ""; }; 80 | 3B2DA99E2C95398600E8FCB9 /* PacketTunnelManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelManager.swift; sourceTree = ""; }; 81 | 3B2DA9AA2C953B6200E8FCB9 /* libresolv.9.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.9.tbd; path = usr/lib/libresolv.9.tbd; sourceTree = SDKROOT; }; 82 | 3B2DA9AC2C95573400E8FCB9 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 83 | 3B3C13882CDA0CB40076B451 /* VPNModePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNModePickerView.swift; sourceTree = ""; }; 84 | 3B8050DD2C9D7C0800043697 /* Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = ""; }; 85 | 3B99F5152CA11A8A00CDC946 /* ConnectedDurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectedDurationView.swift; sourceTree = ""; }; 86 | 3BA6A1D92C9D756C00FF9E1A /* InfoRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoRow.swift; sourceTree = ""; }; 87 | 3BA6A1DB2C9D759D00FF9E1A /* ActionButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButtonStyle.swift; sourceTree = ""; }; 88 | 3BA6A1DD2C9D75C000FF9E1A /* VPNControlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNControlView.swift; sourceTree = ""; }; 89 | 3BA6A1DF2C9D75DF00FF9E1A /* VersionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionView.swift; sourceTree = ""; }; 90 | 3BA6A1E12C9D779A00FF9E1A /* Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Util.swift; sourceTree = ""; }; 91 | 3BAD4E2B2CAA747A006868C3 /* PingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PingView.swift; sourceTree = ""; }; 92 | 3BC43D552CA2A76D00733C84 /* TrafficStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrafficStatsView.swift; sourceTree = ""; }; 93 | 3BCF8AEA2CA8E5C400723541 /* ShareModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareModalView.swift; sourceTree = ""; }; 94 | 3BF632AB2CB8CF4000FB040B /* QRCodeScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeScannerView.swift; sourceTree = ""; }; 95 | /* End PBXFileReference section */ 96 | 97 | /* Begin PBXFrameworksBuildPhase section */ 98 | 3B2DA9512C95337E00E8FCB9 /* Frameworks */ = { 99 | isa = PBXFrameworksBuildPhase; 100 | buildActionMask = 2147483647; 101 | files = ( 102 | F3E0E5912D548849007B9B0F /* LibXray in Frameworks */, 103 | 3BA6A1D82C9D731100FF9E1A /* libresolv.9.tbd in Frameworks */, 104 | ); 105 | runOnlyForDeploymentPostprocessing = 0; 106 | }; 107 | 3B2DA9822C95344600E8FCB9 /* Frameworks */ = { 108 | isa = PBXFrameworksBuildPhase; 109 | buildActionMask = 2147483647; 110 | files = ( 111 | F3E0E5962D54887B007B9B0F /* Tun2SocksKit in Frameworks */, 112 | 3B2DA9882C95344600E8FCB9 /* NetworkExtension.framework in Frameworks */, 113 | F3E0E5982D54887B007B9B0F /* Tun2SocksKitC in Frameworks */, 114 | F3E0E5932D548851007B9B0F /* LibXray in Frameworks */, 115 | 3B2DA9AB2C953B7E00E8FCB9 /* libresolv.9.tbd in Frameworks */, 116 | ); 117 | runOnlyForDeploymentPostprocessing = 0; 118 | }; 119 | /* End PBXFrameworksBuildPhase section */ 120 | 121 | /* Begin PBXGroup section */ 122 | 3B2DA94B2C95337E00E8FCB9 = { 123 | isa = PBXGroup; 124 | children = ( 125 | 3B2DA99A2C9538FF00E8FCB9 /* Shared */, 126 | 3B2DA9962C9536F900E8FCB9 /* Config.xcconfig */, 127 | 3B2DA9562C95337E00E8FCB9 /* Xray */, 128 | 3B2DA9892C95344600E8FCB9 /* PacketTunnel */, 129 | 3B2DA9862C95344600E8FCB9 /* Frameworks */, 130 | 3B2DA9552C95337E00E8FCB9 /* Products */, 131 | ); 132 | sourceTree = ""; 133 | }; 134 | 3B2DA9552C95337E00E8FCB9 /* Products */ = { 135 | isa = PBXGroup; 136 | children = ( 137 | 3B2DA9542C95337E00E8FCB9 /* Xray.app */, 138 | 3B2DA9852C95344600E8FCB9 /* PacketTunnel.appex */, 139 | ); 140 | name = Products; 141 | sourceTree = ""; 142 | }; 143 | 3B2DA9562C95337E00E8FCB9 /* Xray */ = { 144 | isa = PBXGroup; 145 | children = ( 146 | 3B3C13882CDA0CB40076B451 /* VPNModePickerView.swift */, 147 | 3B2DA9992C95379100E8FCB9 /* Info.plist */, 148 | 3B2DA9952C95346A00E8FCB9 /* Xray.entitlements */, 149 | 3B2DA9572C95337E00E8FCB9 /* XrayApp.swift */, 150 | 3B2DA95B2C95337F00E8FCB9 /* Assets.xcassets */, 151 | 3B2DA99E2C95398600E8FCB9 /* PacketTunnelManager.swift */, 152 | 3B2DA9AC2C95573400E8FCB9 /* ContentView.swift */, 153 | 3BA6A1D92C9D756C00FF9E1A /* InfoRow.swift */, 154 | 3BA6A1DB2C9D759D00FF9E1A /* ActionButtonStyle.swift */, 155 | 3BA6A1DD2C9D75C000FF9E1A /* VPNControlView.swift */, 156 | 3BA6A1DF2C9D75DF00FF9E1A /* VersionView.swift */, 157 | 3BA6A1E12C9D779A00FF9E1A /* Util.swift */, 158 | 3B8050DD2C9D7C0800043697 /* Configuration.swift */, 159 | 3B99F5152CA11A8A00CDC946 /* ConnectedDurationView.swift */, 160 | 3BC43D552CA2A76D00733C84 /* TrafficStatsView.swift */, 161 | 3BCF8AEA2CA8E5C400723541 /* ShareModalView.swift */, 162 | 3BAD4E2B2CAA747A006868C3 /* PingView.swift */, 163 | 3BF632AB2CB8CF4000FB040B /* QRCodeScannerView.swift */, 164 | 3B08478D2CC1135400484E72 /* DownloadView.swift */, 165 | ); 166 | path = Xray; 167 | sourceTree = ""; 168 | }; 169 | 3B2DA9862C95344600E8FCB9 /* Frameworks */ = { 170 | isa = PBXGroup; 171 | children = ( 172 | 3B2DA9AA2C953B6200E8FCB9 /* libresolv.9.tbd */, 173 | 3B2DA9872C95344600E8FCB9 /* NetworkExtension.framework */, 174 | ); 175 | name = Frameworks; 176 | sourceTree = ""; 177 | }; 178 | 3B2DA9892C95344600E8FCB9 /* PacketTunnel */ = { 179 | isa = PBXGroup; 180 | children = ( 181 | 3B2DA98A2C95344600E8FCB9 /* PacketTunnelProvider.swift */, 182 | 3B2DA98C2C95344600E8FCB9 /* Info.plist */, 183 | 3B2DA98D2C95344600E8FCB9 /* PacketTunnel.entitlements */, 184 | ); 185 | path = PacketTunnel; 186 | sourceTree = ""; 187 | }; 188 | 3B2DA99A2C9538FF00E8FCB9 /* Shared */ = { 189 | isa = PBXGroup; 190 | children = ( 191 | 3B2DA99B2C95393600E8FCB9 /* Constant.swift */, 192 | ); 193 | path = Shared; 194 | sourceTree = ""; 195 | }; 196 | /* End PBXGroup section */ 197 | 198 | /* Begin PBXNativeTarget section */ 199 | 3B2DA9532C95337E00E8FCB9 /* Xray */ = { 200 | isa = PBXNativeTarget; 201 | buildConfigurationList = 3B2DA9782C95337F00E8FCB9 /* Build configuration list for PBXNativeTarget "Xray" */; 202 | buildPhases = ( 203 | 3B2DA9502C95337E00E8FCB9 /* Sources */, 204 | 3B2DA9512C95337E00E8FCB9 /* Frameworks */, 205 | 3B2DA9522C95337E00E8FCB9 /* Resources */, 206 | 3B2DA9942C95344600E8FCB9 /* Embed Foundation Extensions */, 207 | ); 208 | buildRules = ( 209 | ); 210 | dependencies = ( 211 | 3B2DA98F2C95344600E8FCB9 /* PBXTargetDependency */, 212 | ); 213 | name = Xray; 214 | packageProductDependencies = ( 215 | F3E0E5902D548849007B9B0F /* LibXray */, 216 | ); 217 | productName = Xray; 218 | productReference = 3B2DA9542C95337E00E8FCB9 /* Xray.app */; 219 | productType = "com.apple.product-type.application"; 220 | }; 221 | 3B2DA9842C95344600E8FCB9 /* PacketTunnel */ = { 222 | isa = PBXNativeTarget; 223 | buildConfigurationList = 3B2DA9912C95344600E8FCB9 /* Build configuration list for PBXNativeTarget "PacketTunnel" */; 224 | buildPhases = ( 225 | 3B2DA9812C95344600E8FCB9 /* Sources */, 226 | 3B2DA9822C95344600E8FCB9 /* Frameworks */, 227 | 3B2DA9832C95344600E8FCB9 /* Resources */, 228 | ); 229 | buildRules = ( 230 | ); 231 | dependencies = ( 232 | ); 233 | name = PacketTunnel; 234 | packageProductDependencies = ( 235 | F3E0E5922D548851007B9B0F /* LibXray */, 236 | F3E0E5952D54887B007B9B0F /* Tun2SocksKit */, 237 | F3E0E5972D54887B007B9B0F /* Tun2SocksKitC */, 238 | ); 239 | productName = PacketTunnel; 240 | productReference = 3B2DA9852C95344600E8FCB9 /* PacketTunnel.appex */; 241 | productType = "com.apple.product-type.app-extension"; 242 | }; 243 | /* End PBXNativeTarget section */ 244 | 245 | /* Begin PBXProject section */ 246 | 3B2DA94C2C95337E00E8FCB9 /* Project object */ = { 247 | isa = PBXProject; 248 | attributes = { 249 | BuildIndependentTargetsInParallel = 1; 250 | LastSwiftUpdateCheck = 1540; 251 | LastUpgradeCheck = 1540; 252 | TargetAttributes = { 253 | 3B2DA9532C95337E00E8FCB9 = { 254 | CreatedOnToolsVersion = 15.4; 255 | }; 256 | 3B2DA9842C95344600E8FCB9 = { 257 | CreatedOnToolsVersion = 15.4; 258 | }; 259 | }; 260 | }; 261 | buildConfigurationList = 3B2DA94F2C95337E00E8FCB9 /* Build configuration list for PBXProject "Xray" */; 262 | compatibilityVersion = "Xcode 14.0"; 263 | developmentRegion = en; 264 | hasScannedForEncodings = 0; 265 | knownRegions = ( 266 | en, 267 | Base, 268 | ); 269 | mainGroup = 3B2DA94B2C95337E00E8FCB9; 270 | packageReferences = ( 271 | F3E0E58F2D548849007B9B0F /* XCRemoteSwiftPackageReference "LibXray" */, 272 | F3E0E5942D54887B007B9B0F /* XCRemoteSwiftPackageReference "Tun2SocksKit" */, 273 | ); 274 | productRefGroup = 3B2DA9552C95337E00E8FCB9 /* Products */; 275 | projectDirPath = ""; 276 | projectRoot = ""; 277 | targets = ( 278 | 3B2DA9532C95337E00E8FCB9 /* Xray */, 279 | 3B2DA9842C95344600E8FCB9 /* PacketTunnel */, 280 | ); 281 | }; 282 | /* End PBXProject section */ 283 | 284 | /* Begin PBXResourcesBuildPhase section */ 285 | 3B2DA9522C95337E00E8FCB9 /* Resources */ = { 286 | isa = PBXResourcesBuildPhase; 287 | buildActionMask = 2147483647; 288 | files = ( 289 | 3B2DA9972C9536F900E8FCB9 /* Config.xcconfig in Resources */, 290 | 3B2DA95C2C95337F00E8FCB9 /* Assets.xcassets in Resources */, 291 | ); 292 | runOnlyForDeploymentPostprocessing = 0; 293 | }; 294 | 3B2DA9832C95344600E8FCB9 /* Resources */ = { 295 | isa = PBXResourcesBuildPhase; 296 | buildActionMask = 2147483647; 297 | files = ( 298 | 3B2DA9982C9536F900E8FCB9 /* Config.xcconfig in Resources */, 299 | ); 300 | runOnlyForDeploymentPostprocessing = 0; 301 | }; 302 | /* End PBXResourcesBuildPhase section */ 303 | 304 | /* Begin PBXSourcesBuildPhase section */ 305 | 3B2DA9502C95337E00E8FCB9 /* Sources */ = { 306 | isa = PBXSourcesBuildPhase; 307 | buildActionMask = 2147483647; 308 | files = ( 309 | 3B2DA99F2C95398600E8FCB9 /* PacketTunnelManager.swift in Sources */, 310 | 3BA6A1E22C9D779A00FF9E1A /* Util.swift in Sources */, 311 | 3B2DA9AD2C95573400E8FCB9 /* ContentView.swift in Sources */, 312 | 3B2DA9582C95337E00E8FCB9 /* XrayApp.swift in Sources */, 313 | 3B99F5162CA11A8A00CDC946 /* ConnectedDurationView.swift in Sources */, 314 | 3B3C13892CDA0CB60076B451 /* VPNModePickerView.swift in Sources */, 315 | 3B2DA99C2C95393600E8FCB9 /* Constant.swift in Sources */, 316 | 3BC43D562CA2A76D00733C84 /* TrafficStatsView.swift in Sources */, 317 | 3BAD4E2C2CAA747A006868C3 /* PingView.swift in Sources */, 318 | 3BA6A1DE2C9D75C000FF9E1A /* VPNControlView.swift in Sources */, 319 | 3B8050DE2C9D7C0800043697 /* Configuration.swift in Sources */, 320 | 3BA6A1E02C9D75DF00FF9E1A /* VersionView.swift in Sources */, 321 | 3BA6A1DA2C9D756C00FF9E1A /* InfoRow.swift in Sources */, 322 | 3BCF8AEB2CA8E5C400723541 /* ShareModalView.swift in Sources */, 323 | 3B08478E2CC1135400484E72 /* DownloadView.swift in Sources */, 324 | 3BA6A1DC2C9D759D00FF9E1A /* ActionButtonStyle.swift in Sources */, 325 | 3BF632AC2CB8CF4000FB040B /* QRCodeScannerView.swift in Sources */, 326 | ); 327 | runOnlyForDeploymentPostprocessing = 0; 328 | }; 329 | 3B2DA9812C95344600E8FCB9 /* Sources */ = { 330 | isa = PBXSourcesBuildPhase; 331 | buildActionMask = 2147483647; 332 | files = ( 333 | 3B2DA98B2C95344600E8FCB9 /* PacketTunnelProvider.swift in Sources */, 334 | 3B2DA99D2C95393600E8FCB9 /* Constant.swift in Sources */, 335 | ); 336 | runOnlyForDeploymentPostprocessing = 0; 337 | }; 338 | /* End PBXSourcesBuildPhase section */ 339 | 340 | /* Begin PBXTargetDependency section */ 341 | 3B2DA98F2C95344600E8FCB9 /* PBXTargetDependency */ = { 342 | isa = PBXTargetDependency; 343 | target = 3B2DA9842C95344600E8FCB9 /* PacketTunnel */; 344 | targetProxy = 3B2DA98E2C95344600E8FCB9 /* PBXContainerItemProxy */; 345 | }; 346 | /* End PBXTargetDependency section */ 347 | 348 | /* Begin XCBuildConfiguration section */ 349 | 3B2DA9762C95337F00E8FCB9 /* Debug */ = { 350 | isa = XCBuildConfiguration; 351 | baseConfigurationReference = 3B2DA9962C9536F900E8FCB9 /* Config.xcconfig */; 352 | buildSettings = { 353 | ALWAYS_SEARCH_USER_PATHS = NO; 354 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 355 | CLANG_ANALYZER_NONNULL = YES; 356 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 357 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 358 | CLANG_ENABLE_MODULES = YES; 359 | CLANG_ENABLE_OBJC_ARC = YES; 360 | CLANG_ENABLE_OBJC_WEAK = YES; 361 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 362 | CLANG_WARN_BOOL_CONVERSION = YES; 363 | CLANG_WARN_COMMA = YES; 364 | CLANG_WARN_CONSTANT_CONVERSION = YES; 365 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 366 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 367 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 368 | CLANG_WARN_EMPTY_BODY = YES; 369 | CLANG_WARN_ENUM_CONVERSION = YES; 370 | CLANG_WARN_INFINITE_RECURSION = YES; 371 | CLANG_WARN_INT_CONVERSION = YES; 372 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 373 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 374 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 375 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 376 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 377 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 378 | CLANG_WARN_STRICT_PROTOTYPES = YES; 379 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 380 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 381 | CLANG_WARN_UNREACHABLE_CODE = YES; 382 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 383 | COPY_PHASE_STRIP = NO; 384 | DEBUG_INFORMATION_FORMAT = dwarf; 385 | ENABLE_STRICT_OBJC_MSGSEND = YES; 386 | ENABLE_TESTABILITY = YES; 387 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 388 | GCC_C_LANGUAGE_STANDARD = gnu17; 389 | GCC_DYNAMIC_NO_PIC = NO; 390 | GCC_NO_COMMON_BLOCKS = YES; 391 | GCC_OPTIMIZATION_LEVEL = 0; 392 | GCC_PREPROCESSOR_DEFINITIONS = ( 393 | "DEBUG=1", 394 | "$(inherited)", 395 | ); 396 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 397 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 398 | GCC_WARN_UNDECLARED_SELECTOR = YES; 399 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 400 | GCC_WARN_UNUSED_FUNCTION = YES; 401 | GCC_WARN_UNUSED_VARIABLE = YES; 402 | IPHONEOS_DEPLOYMENT_TARGET = 17.5; 403 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 404 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 405 | MTL_FAST_MATH = YES; 406 | ONLY_ACTIVE_ARCH = YES; 407 | SDKROOT = iphoneos; 408 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 409 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 410 | SWIFT_VERSION = 6.0; 411 | }; 412 | name = Debug; 413 | }; 414 | 3B2DA9772C95337F00E8FCB9 /* Release */ = { 415 | isa = XCBuildConfiguration; 416 | baseConfigurationReference = 3B2DA9962C9536F900E8FCB9 /* Config.xcconfig */; 417 | buildSettings = { 418 | ALWAYS_SEARCH_USER_PATHS = NO; 419 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 420 | CLANG_ANALYZER_NONNULL = YES; 421 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 422 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 423 | CLANG_ENABLE_MODULES = YES; 424 | CLANG_ENABLE_OBJC_ARC = YES; 425 | CLANG_ENABLE_OBJC_WEAK = YES; 426 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 427 | CLANG_WARN_BOOL_CONVERSION = YES; 428 | CLANG_WARN_COMMA = YES; 429 | CLANG_WARN_CONSTANT_CONVERSION = YES; 430 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 431 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 432 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 433 | CLANG_WARN_EMPTY_BODY = YES; 434 | CLANG_WARN_ENUM_CONVERSION = YES; 435 | CLANG_WARN_INFINITE_RECURSION = YES; 436 | CLANG_WARN_INT_CONVERSION = YES; 437 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 438 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 439 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 440 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 441 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 442 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 443 | CLANG_WARN_STRICT_PROTOTYPES = YES; 444 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 445 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 446 | CLANG_WARN_UNREACHABLE_CODE = YES; 447 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 448 | COPY_PHASE_STRIP = NO; 449 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 450 | ENABLE_NS_ASSERTIONS = NO; 451 | ENABLE_STRICT_OBJC_MSGSEND = YES; 452 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 453 | GCC_C_LANGUAGE_STANDARD = gnu17; 454 | GCC_NO_COMMON_BLOCKS = YES; 455 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 456 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 457 | GCC_WARN_UNDECLARED_SELECTOR = YES; 458 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 459 | GCC_WARN_UNUSED_FUNCTION = YES; 460 | GCC_WARN_UNUSED_VARIABLE = YES; 461 | IPHONEOS_DEPLOYMENT_TARGET = 17.5; 462 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 463 | MTL_ENABLE_DEBUG_INFO = NO; 464 | MTL_FAST_MATH = YES; 465 | SDKROOT = iphoneos; 466 | SWIFT_COMPILATION_MODE = wholemodule; 467 | SWIFT_VERSION = 6.0; 468 | VALIDATE_PRODUCT = YES; 469 | }; 470 | name = Release; 471 | }; 472 | 3B2DA9792C95337F00E8FCB9 /* Debug */ = { 473 | isa = XCBuildConfiguration; 474 | buildSettings = { 475 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 476 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 477 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 478 | CODE_SIGN_ENTITLEMENTS = Xray/Xray.entitlements; 479 | CODE_SIGN_STYLE = Automatic; 480 | CURRENT_PROJECT_VERSION = 1; 481 | DEVELOPMENT_ASSET_PATHS = ""; 482 | DEVELOPMENT_TEAM = AQAUYR3N43; 483 | ENABLE_PREVIEWS = YES; 484 | GENERATE_INFOPLIST_FILE = YES; 485 | INFOPLIST_FILE = Xray/Info.plist; 486 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 487 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 488 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 489 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 490 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 491 | LD_RUNPATH_SEARCH_PATHS = ( 492 | "$(inherited)", 493 | "@executable_path/Frameworks", 494 | ); 495 | MARKETING_VERSION = 1.0; 496 | PRODUCT_BUNDLE_IDENTIFIER = "$(APP_ID)"; 497 | PRODUCT_NAME = "$(TARGET_NAME)"; 498 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 499 | SUPPORTS_MACCATALYST = NO; 500 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 501 | SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; 502 | SWIFT_EMIT_LOC_STRINGS = YES; 503 | SWIFT_VERSION = 6.0; 504 | TARGETED_DEVICE_FAMILY = 1; 505 | }; 506 | name = Debug; 507 | }; 508 | 3B2DA97A2C95337F00E8FCB9 /* Release */ = { 509 | isa = XCBuildConfiguration; 510 | buildSettings = { 511 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 512 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 513 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 514 | CODE_SIGN_ENTITLEMENTS = Xray/Xray.entitlements; 515 | CODE_SIGN_STYLE = Automatic; 516 | CURRENT_PROJECT_VERSION = 1; 517 | DEVELOPMENT_ASSET_PATHS = ""; 518 | DEVELOPMENT_TEAM = AQAUYR3N43; 519 | ENABLE_PREVIEWS = YES; 520 | GENERATE_INFOPLIST_FILE = YES; 521 | INFOPLIST_FILE = Xray/Info.plist; 522 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 523 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 524 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 525 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 526 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 527 | LD_RUNPATH_SEARCH_PATHS = ( 528 | "$(inherited)", 529 | "@executable_path/Frameworks", 530 | ); 531 | MARKETING_VERSION = 1.0; 532 | PRODUCT_BUNDLE_IDENTIFIER = "$(APP_ID)"; 533 | PRODUCT_NAME = "$(TARGET_NAME)"; 534 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 535 | SUPPORTS_MACCATALYST = NO; 536 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 537 | SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; 538 | SWIFT_EMIT_LOC_STRINGS = YES; 539 | SWIFT_VERSION = 6.0; 540 | TARGETED_DEVICE_FAMILY = 1; 541 | }; 542 | name = Release; 543 | }; 544 | 3B2DA9922C95344600E8FCB9 /* Debug */ = { 545 | isa = XCBuildConfiguration; 546 | buildSettings = { 547 | CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements; 548 | CODE_SIGN_STYLE = Automatic; 549 | CURRENT_PROJECT_VERSION = 1; 550 | DEVELOPMENT_TEAM = AQAUYR3N43; 551 | GENERATE_INFOPLIST_FILE = YES; 552 | INFOPLIST_FILE = PacketTunnel/Info.plist; 553 | INFOPLIST_KEY_CFBundleDisplayName = PacketTunnel; 554 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 555 | LD_RUNPATH_SEARCH_PATHS = ( 556 | "$(inherited)", 557 | "@executable_path/Frameworks", 558 | "@executable_path/../../Frameworks", 559 | ); 560 | MARKETING_VERSION = 1.0; 561 | PRODUCT_BUNDLE_IDENTIFIER = "$(APP_ID).PacketTunnel"; 562 | PRODUCT_NAME = "$(TARGET_NAME)"; 563 | SKIP_INSTALL = YES; 564 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 565 | SUPPORTS_MACCATALYST = NO; 566 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 567 | SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; 568 | SWIFT_EMIT_LOC_STRINGS = YES; 569 | SWIFT_VERSION = 6.0; 570 | TARGETED_DEVICE_FAMILY = 1; 571 | }; 572 | name = Debug; 573 | }; 574 | 3B2DA9932C95344600E8FCB9 /* Release */ = { 575 | isa = XCBuildConfiguration; 576 | buildSettings = { 577 | CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements; 578 | CODE_SIGN_STYLE = Automatic; 579 | CURRENT_PROJECT_VERSION = 1; 580 | DEVELOPMENT_TEAM = AQAUYR3N43; 581 | GENERATE_INFOPLIST_FILE = YES; 582 | INFOPLIST_FILE = PacketTunnel/Info.plist; 583 | INFOPLIST_KEY_CFBundleDisplayName = PacketTunnel; 584 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 585 | LD_RUNPATH_SEARCH_PATHS = ( 586 | "$(inherited)", 587 | "@executable_path/Frameworks", 588 | "@executable_path/../../Frameworks", 589 | ); 590 | MARKETING_VERSION = 1.0; 591 | PRODUCT_BUNDLE_IDENTIFIER = "$(APP_ID).PacketTunnel"; 592 | PRODUCT_NAME = "$(TARGET_NAME)"; 593 | SKIP_INSTALL = YES; 594 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 595 | SUPPORTS_MACCATALYST = NO; 596 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 597 | SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; 598 | SWIFT_EMIT_LOC_STRINGS = YES; 599 | SWIFT_VERSION = 6.0; 600 | TARGETED_DEVICE_FAMILY = 1; 601 | }; 602 | name = Release; 603 | }; 604 | /* End XCBuildConfiguration section */ 605 | 606 | /* Begin XCConfigurationList section */ 607 | 3B2DA94F2C95337E00E8FCB9 /* Build configuration list for PBXProject "Xray" */ = { 608 | isa = XCConfigurationList; 609 | buildConfigurations = ( 610 | 3B2DA9762C95337F00E8FCB9 /* Debug */, 611 | 3B2DA9772C95337F00E8FCB9 /* Release */, 612 | ); 613 | defaultConfigurationIsVisible = 0; 614 | defaultConfigurationName = Release; 615 | }; 616 | 3B2DA9782C95337F00E8FCB9 /* Build configuration list for PBXNativeTarget "Xray" */ = { 617 | isa = XCConfigurationList; 618 | buildConfigurations = ( 619 | 3B2DA9792C95337F00E8FCB9 /* Debug */, 620 | 3B2DA97A2C95337F00E8FCB9 /* Release */, 621 | ); 622 | defaultConfigurationIsVisible = 0; 623 | defaultConfigurationName = Release; 624 | }; 625 | 3B2DA9912C95344600E8FCB9 /* Build configuration list for PBXNativeTarget "PacketTunnel" */ = { 626 | isa = XCConfigurationList; 627 | buildConfigurations = ( 628 | 3B2DA9922C95344600E8FCB9 /* Debug */, 629 | 3B2DA9932C95344600E8FCB9 /* Release */, 630 | ); 631 | defaultConfigurationIsVisible = 0; 632 | defaultConfigurationName = Release; 633 | }; 634 | /* End XCConfigurationList section */ 635 | 636 | /* Begin XCRemoteSwiftPackageReference section */ 637 | F3E0E58F2D548849007B9B0F /* XCRemoteSwiftPackageReference "LibXray" */ = { 638 | isa = XCRemoteSwiftPackageReference; 639 | repositoryURL = "https://github.com/wanliyunyan/LibXray"; 640 | requirement = { 641 | kind = upToNextMajorVersion; 642 | minimumVersion = 25.5.16; 643 | }; 644 | }; 645 | F3E0E5942D54887B007B9B0F /* XCRemoteSwiftPackageReference "Tun2SocksKit" */ = { 646 | isa = XCRemoteSwiftPackageReference; 647 | repositoryURL = "https://github.com/EbrahimTahernejad/Tun2SocksKit"; 648 | requirement = { 649 | kind = upToNextMajorVersion; 650 | minimumVersion = 4.9.1; 651 | }; 652 | }; 653 | /* End XCRemoteSwiftPackageReference section */ 654 | 655 | /* Begin XCSwiftPackageProductDependency section */ 656 | F3E0E5902D548849007B9B0F /* LibXray */ = { 657 | isa = XCSwiftPackageProductDependency; 658 | package = F3E0E58F2D548849007B9B0F /* XCRemoteSwiftPackageReference "LibXray" */; 659 | productName = LibXray; 660 | }; 661 | F3E0E5922D548851007B9B0F /* LibXray */ = { 662 | isa = XCSwiftPackageProductDependency; 663 | package = F3E0E58F2D548849007B9B0F /* XCRemoteSwiftPackageReference "LibXray" */; 664 | productName = LibXray; 665 | }; 666 | F3E0E5952D54887B007B9B0F /* Tun2SocksKit */ = { 667 | isa = XCSwiftPackageProductDependency; 668 | package = F3E0E5942D54887B007B9B0F /* XCRemoteSwiftPackageReference "Tun2SocksKit" */; 669 | productName = Tun2SocksKit; 670 | }; 671 | F3E0E5972D54887B007B9B0F /* Tun2SocksKitC */ = { 672 | isa = XCSwiftPackageProductDependency; 673 | package = F3E0E5942D54887B007B9B0F /* XCRemoteSwiftPackageReference "Tun2SocksKit" */; 674 | productName = Tun2SocksKitC; 675 | }; 676 | /* End XCSwiftPackageProductDependency section */ 677 | }; 678 | rootObject = 3B2DA94C2C95337E00E8FCB9 /* Project object */; 679 | } 680 | -------------------------------------------------------------------------------- /Xray/ActionButtonStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActionButtonStyle.swift 3 | // Xray 4 | // 5 | // Created by pan on 2024/9/20. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | /// 一个自定义的 `ButtonStyle`,用于为按钮提供统一的视觉样式。 12 | /// 包括指定的背景颜色、白色文字、圆角,以及按压时透明度的变化。 13 | struct ActionButtonStyle: ButtonStyle { 14 | /// 按钮的背景颜色。 15 | var color: Color 16 | 17 | /// 根据给定的配置生成按钮的外观。 18 | /// 19 | /// - Parameter configuration: 系统提供的配置对象,包含按钮的文本标签和当前状态(是否按下)。 20 | /// - Returns: 应用指定样式的按钮视图。 21 | func makeBody(configuration: ButtonStyleConfiguration) -> some View { 22 | configuration.label 23 | // 让按钮在水平方向上尽量铺满 24 | .frame(maxWidth: .infinity) 25 | // 内边距,使按钮更易点按 26 | .padding() 27 | // 设置背景颜色 28 | .background(color) 29 | // 按钮文字颜色 30 | .foregroundColor(.white) 31 | // 为按钮添加圆角 32 | .cornerRadius(8) 33 | // 当按钮处于按压状态时,减小透明度 34 | .opacity(configuration.isPressed ? 0.7 : 1.0) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Xray/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Xray/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Xray/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Xray/Configuration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Configuration.swift 3 | // Xray 4 | // 5 | // Created by pan on 2024/9/20. 6 | // 7 | 8 | import Foundation 9 | import LibXray 10 | import Network 11 | 12 | /// `Configuration` 负责生成并整合 Xray 的最终配置. 13 | /// 14 | /// - 主要作用: 15 | /// 1. 解析用户分享的链接(如 VLESS 等); 16 | /// 2. 加入本地 inbounds、metrics、policy、routing、stats、dns 等; 17 | /// 3. 去除特定无用字段,防止 Xray 兼容性问题; 18 | /// 4. 最终将配置序列化为 JSON Data 供 Xray 使用. 19 | /// 20 | /// 配置生成流程包括以下主要步骤: 21 | /// 1. 从 UserDefaults 中加载 SOCKS 端口和流量统计端口; 22 | /// 2. 基于分享链接解析生成 outbounds; 23 | /// 3. 合并 inbound、metrics、policy、routing、stats、dns; 24 | /// 4. 递归移除空值; 25 | /// 5. 去除 `sendThrough`; 26 | /// 6. 将配置序列化输出。 27 | struct Configuration { 28 | // MARK: - Public Methods 29 | 30 | /// 生成最终可用于 Xray 运行的 JSON 格式二进制配置. 31 | /// 32 | /// - Parameter config: 用户从外部复制的分享链接字符串. 33 | /// - Returns: 封装完成的 JSON Data,Xray 可直接使用. 34 | /// - Throws: 当端口加载失败、或 JSON 生成失败时抛出错误. 35 | func buildRunConfigurationData(config: String) throws -> Data { 36 | // 1. 从 UserDefaults 中获取端口并转为 NWEndpoint.Port 37 | guard 38 | let inboundPortString = Util.loadFromUserDefaults(key: "sock5Port"), 39 | let trafficPortString = Util.loadFromUserDefaults(key: "trafficPort"), 40 | let inboundPort = NWEndpoint.Port(inboundPortString), 41 | let trafficPort = NWEndpoint.Port(trafficPortString) 42 | else { 43 | throw NSError( 44 | domain: "ConfigurationError", 45 | code: -1, 46 | userInfo: [NSLocalizedDescriptionKey: "无法从 UserDefaults 加载端口或端口格式不正确"] 47 | ) 48 | } 49 | 50 | // 2. 基于用户分享链接生成 Xray outbounds 51 | var configuration = try buildOutInbound(config: config) 52 | 53 | // 3. 添加自定义 inbound、metrics、policy、routing、stats、dns 等 54 | configuration["inbounds"] = buildInbound(inboundPort: inboundPort, trafficPort: trafficPort) 55 | configuration["metrics"] = buildMetrics() 56 | configuration["policy"] = buildPolicy() 57 | configuration["routing"] = try buildRoute() 58 | configuration["stats"] = [:] 59 | configuration["dns"] = buildDNSConfiguration() 60 | 61 | // 4. 递归移除配置中所有 NSNull 或 "" 值 62 | configuration = removeNullValues(from: configuration) 63 | 64 | // 5. 去除第一个 outbound 的 sendThrough 字段 65 | configuration = removeSendThroughFromOutbounds(from: configuration) 66 | 67 | // 6. 序列化为 JSON Data 输出 68 | return try JSONSerialization.data(withJSONObject: configuration, options: .prettyPrinted) 69 | } 70 | 71 | /// 生成最终可用于 Xray ping的 JSON 格式二进制配置. 72 | /// 73 | /// - Parameter config: 用户从外部复制的分享链接字符串. 74 | /// - Returns: 封装完成的 JSON Data,Xray 可直接使用. 75 | /// - Throws: 当端口加载失败、或 JSON 生成失败时抛出错误. 76 | func buildPingConfigurationData(config: String) throws -> Data { 77 | // 1. 从 UserDefaults 中获取端口并转为 NWEndpoint.Port 78 | guard 79 | let inboundPortString = Util.loadFromUserDefaults(key: "sock5Port"), 80 | let inboundPort = NWEndpoint.Port(inboundPortString) 81 | else { 82 | throw NSError( 83 | domain: "ConfigurationError", 84 | code: -1, 85 | userInfo: [NSLocalizedDescriptionKey: "无法从 UserDefaults 加载端口或端口格式不正确"] 86 | ) 87 | } 88 | 89 | // 2. 基于用户分享链接生成 Xray outbounds 90 | var configuration = try buildOutInbound(config: config) 91 | 92 | // 3. 添加自定义 inbound、metrics、policy、routing、stats、dns 等 93 | configuration["inbounds"] = buildInbound(inboundPort: inboundPort, trafficPort: nil) 94 | 95 | // 4. 递归移除配置中所有 NSNull 或 "" 值 96 | configuration = removeNullValues(from: configuration) 97 | 98 | // 5. 去除第一个 outbound 的 sendThrough 字段 99 | configuration = removeSendThroughFromOutbounds(from: configuration) 100 | 101 | // 6. 序列化为 JSON Data 输出 102 | return try JSONSerialization.data(withJSONObject: configuration, options: .prettyPrinted) 103 | } 104 | 105 | // MARK: - Private Methods 106 | 107 | /// 删除 outbounds 中的第一个 sendThrough 字段,防止 Xray 某些版本出现兼容性问题. 108 | /// 109 | /// - Parameter configuration: 当前 Xray 配置字典. 110 | /// - Returns: 处理后、无 `sendThrough` 的配置字典. 111 | private func removeSendThroughFromOutbounds(from configuration: [String: Any]) -> [String: Any] { 112 | var updatedConfig = configuration 113 | 114 | // 如果 outbounds 不为空,则移除第一个 outbound 的 sendThrough 115 | if var outbounds = configuration["outbounds"] as? [[String: Any]], !outbounds.isEmpty { 116 | outbounds[0].removeValue(forKey: "sendThrough") 117 | updatedConfig["outbounds"] = outbounds 118 | } 119 | 120 | return updatedConfig 121 | } 122 | 123 | /// 递归移除字典中所有为 NSNull 或 "" 的值. 124 | /// 125 | /// - Parameter dictionary: 待处理的 Xray 配置字典. 126 | /// - Returns: 移除空值后的新字典. 127 | private func removeNullValues(from dictionary: [String: Any]) -> [String: Any] { 128 | var updatedDictionary = dictionary 129 | 130 | for (key, value) in dictionary { 131 | if value is NSNull || "\(value)" == "" { 132 | updatedDictionary.removeValue(forKey: key) 133 | } else if let nestedDictionary = value as? [String: Any] { 134 | // 递归处理字典 135 | updatedDictionary[key] = removeNullValues(from: nestedDictionary) 136 | } else if let nestedArray = value as? [[String: Any]] { 137 | // 递归处理字典数组 138 | updatedDictionary[key] = nestedArray.map { removeNullValues(from: $0) } 139 | } 140 | } 141 | 142 | return updatedDictionary 143 | } 144 | 145 | /// 将用户分享的配置链接(如 VLESS)解析为基础 Xray JSON,同时添加自定义 outbounds. 146 | /// 147 | /// 步骤: 148 | /// 1. 将原始字符串转成 Data,并 Base64 编码; 149 | /// 2. 调用 LibXrayConvertShareLinksToXrayJson 转换; 150 | /// 3. 对转换结果再次 Base64 解码并转为字典; 151 | /// 4. 将第一个 outbound 的 tag 改为 "proxy"; 152 | /// 5. 追加 freedom("direct")、blackhole("block") 作为额外 outbounds; 153 | /// 154 | /// - Parameter config: 配置分享链接. 155 | /// - Returns: 含 outbounds 的 Xray 配置字典. 156 | /// - Throws: 当链接字符串无效或解析 Xray JSON 失败时抛出. 157 | private func buildOutInbound(config: String) throws -> [String: Any] { 158 | // 1. 将原始字符串转为 Data 159 | guard let configData = config.data(using: .utf8) else { 160 | throw NSError( 161 | domain: "InvalidConfig", 162 | code: -1, 163 | userInfo: [NSLocalizedDescriptionKey: "无效的配置字符串"] 164 | ) 165 | } 166 | 167 | // 2. Base64 编码后调用 LibXray 进行转换 168 | let base64EncodedConfig = configData.base64EncodedString() 169 | let xrayJsonString = LibXrayConvertShareLinksToXrayJson(base64EncodedConfig) 170 | 171 | // 3. 对转换后的字符串再次 Base64 解码,并解析为字典 172 | guard 173 | let decodedData = Data(base64Encoded: xrayJsonString), 174 | let decodedString = String(data: decodedData, encoding: .utf8), 175 | let jsonData = decodedString.data(using: .utf8), 176 | let jsonDict = try? JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any], 177 | let success = jsonDict["success"] as? Bool, success, 178 | var dataDict = jsonDict["data"] as? [String: Any], 179 | var outboundsArray = dataDict["outbounds"] as? [[String: Any]] 180 | else { 181 | throw NSError( 182 | domain: "InvalidXrayJson", 183 | code: -1, 184 | userInfo: [NSLocalizedDescriptionKey: "解析 Xray JSON 失败"] 185 | ) 186 | } 187 | 188 | // 4. 将第一个 outbound 的 tag 改为 "proxy" 189 | if var firstOutbound = outboundsArray.first { 190 | firstOutbound["tag"] = "proxy" 191 | outboundsArray[0] = firstOutbound 192 | } 193 | 194 | // 5. 追加 freedom 和 blackhole 195 | let freedomObject: [String: Any] = [ 196 | "protocol": "freedom", 197 | "tag": "direct", 198 | ] 199 | outboundsArray.append(freedomObject) 200 | 201 | let blockObject: [String: Any] = [ 202 | "protocol": "blackhole", 203 | "tag": "block", 204 | ] 205 | outboundsArray.append(blockObject) 206 | 207 | // 6. 更新 outbounds 208 | dataDict["outbounds"] = outboundsArray 209 | return dataDict 210 | } 211 | 212 | /// 构建两个 inbound 配置:一个用于 SOCKS 代理服务,一个用于流量统计. 213 | /// 214 | /// - Parameters: 215 | /// - inboundPort: SOCKS 代理端口. 216 | /// - trafficPort: 流量统计端口. 217 | /// - Returns: 含 socks、metricsIn 的 inbound 数组. 218 | private func buildInbound( 219 | inboundPort: NWEndpoint.Port, 220 | trafficPort: NWEndpoint.Port? 221 | ) -> [[String: Any]] { 222 | let socksInbound: [String: Any] = [ 223 | "listen": "127.0.0.1", 224 | "port": Int(inboundPort.rawValue), 225 | "protocol": "socks", 226 | "sniffing": [ 227 | "enabled": true, 228 | "destOverride": ["http", "tls", "quic"], 229 | "routeOnly": false, 230 | ], 231 | "settings": [ 232 | "udp": true, 233 | ], 234 | "tag": "socks", 235 | ] 236 | 237 | if trafficPort == nil { 238 | return [socksInbound] 239 | } 240 | 241 | let metricsInbound: [String: Any] = [ 242 | "listen": "127.0.0.1", 243 | "port": Int(trafficPort!.rawValue), 244 | "protocol": "dokodemo-door", 245 | "settings": [ 246 | "address": "127.0.0.1", 247 | ], 248 | "tag": "metricsIn", 249 | ] 250 | 251 | return [socksInbound, metricsInbound] 252 | } 253 | 254 | /// 构建 metrics 配置(仅含一个简单的 tag). 255 | /// 256 | /// - Returns: 带 `metricsOut` tag 的字典. 257 | private func buildMetrics() -> [String: Any] { 258 | return [ 259 | "tag": "metricsOut", 260 | ] 261 | } 262 | 263 | /// 构建 policy 配置,用于统计上下行流量. 264 | /// 265 | /// - Returns: 包含策略设置的字典. 266 | private func buildPolicy() -> [String: Any] { 267 | return [ 268 | "system": [ 269 | "statsInboundDownlink": true, 270 | "statsInboundUplink": true, 271 | "statsOutboundDownlink": true, 272 | "statsOutboundUplink": true, 273 | ], 274 | ] 275 | } 276 | 277 | /// 根据 VPN 模式构建路由规则,主要针对非全局模式添加一些直连或屏蔽策略. 278 | /// 279 | /// - Throws: 访问文件失败时抛出. 280 | /// - Returns: 包含 routing 配置信息的字典. 281 | private func buildRoute() throws -> [String: Any] { 282 | var route: [String: Any] = [ 283 | "domainStrategy": "AsIs", 284 | "rules": [ 285 | [ 286 | "inboundTag": ["metricsIn"], 287 | "outboundTag": "metricsOut", 288 | "type": "field", 289 | ], 290 | ], 291 | ] 292 | 293 | let fileManager = FileManager.default 294 | let assetDirectoryPath = Constant.assetDirectory.path 295 | let vpnMode = Util.loadFromUserDefaults(key: "VPNMode") ?? VPNMode.nonGlobal.rawValue 296 | 297 | // 在非全局模式下,如果本地有地理规则文件,则执行额外的路由配置 298 | if vpnMode == VPNMode.nonGlobal.rawValue, 299 | let files = try? fileManager.contentsOfDirectory(atPath: assetDirectoryPath), 300 | !files.isEmpty 301 | { 302 | var rulesArray = route["rules"] as? [[String: Any]] ?? [] 303 | 304 | // 屏蔽广告 305 | rulesArray.append([ 306 | "type": "field", 307 | "outboundTag": "block", 308 | "domain": [ 309 | "geosite:category-ads-all", 310 | ], 311 | ]) 312 | 313 | // 国内域名直连 314 | rulesArray.append([ 315 | "type": "field", 316 | "outboundTag": "direct", 317 | "domain": [ 318 | "geosite:private", 319 | "geosite:cn", 320 | ], 321 | ]) 322 | 323 | // 国内 IP、私有 IP 直连 324 | rulesArray.append([ 325 | "type": "field", 326 | "outboundTag": "direct", 327 | "ip": [ 328 | "geoip:private", 329 | "geoip:cn", 330 | ], 331 | ]) 332 | 333 | // 特定 IP 列表直连 334 | rulesArray.append([ 335 | "type": "field", 336 | "outboundTag": "direct", 337 | "ip": [ 338 | "223.5.5.5", 339 | "223.6.6.6", 340 | "2400:3200::1", 341 | "2400:3200:baba::1", 342 | "119.29.29.29", 343 | "1.12.12.12", 344 | "120.53.53.53", 345 | "2402:4e00::", 346 | "2402:4e00:1::", 347 | "180.76.76.76", 348 | "2400:da00::6666", 349 | "114.114.114.114", 350 | "114.114.115.115", 351 | "114.114.114.119", 352 | "114.114.115.119", 353 | "114.114.114.110", 354 | "114.114.115.110", 355 | "180.184.1.1", 356 | "180.184.2.2", 357 | "101.226.4.6", 358 | "218.30.118.6", 359 | "123.125.81.6", 360 | "140.207.198.6", 361 | "1.2.4.8", 362 | "210.2.4.8", 363 | "52.80.66.66", 364 | "117.50.22.22", 365 | "2400:7fc0:849e:200::4", 366 | "2404:c2c0:85d8:901::4", 367 | "117.50.10.10", 368 | "52.80.52.52", 369 | "2400:7fc0:849e:200::8", 370 | "2404:c2c0:85d8:901::8", 371 | "117.50.60.30", 372 | "52.80.60.30", 373 | ], 374 | ]) 375 | 376 | // 其他所有端口流量默认走 "proxy" 377 | rulesArray.append([ 378 | "type": "field", 379 | "port": "0-65535", 380 | "outboundTag": "proxy", 381 | ]) 382 | 383 | route["rules"] = rulesArray 384 | } 385 | 386 | return route 387 | } 388 | 389 | /// 构建 DNS 配置,包含 hosts 映射与自定义的 DNS 服务器. 390 | /// 391 | /// - Returns: 包含 hosts、servers 字段的 DNS 配置字典. 392 | private func buildDNSConfiguration() -> [String: Any] { 393 | let fileManager = FileManager.default 394 | let assetDirectoryPath = Constant.assetDirectory.path 395 | let files = (try? fileManager.contentsOfDirectory(atPath: assetDirectoryPath)) ?? [] 396 | let useGeoFiles = !files.isEmpty 397 | 398 | var servers: [Any] = [] 399 | if useGeoFiles { 400 | servers.append([ 401 | "address": "1.1.1.1", 402 | "domains": ["geosite:geolocation-!cn"], 403 | "expectIPs": ["geoip:!cn"], 404 | ]) 405 | servers.append([ 406 | "address": "223.5.5.5", 407 | "domains": ["geosite:cn"], 408 | "expectIPs": ["geoip:cn"], 409 | ]) 410 | } 411 | servers.append(contentsOf: ["8.8.8.8", "https://dns.google/dns-query"]) 412 | return [ 413 | "hosts": ["dns.google": "8.8.8.8"], 414 | "servers": servers, 415 | ] 416 | } 417 | } 418 | -------------------------------------------------------------------------------- /Xray/ConnectedDurationView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConnectedDurationView.swift 3 | // Xray 4 | // 5 | // Created by pan on 2024/9/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// 一个用于显示 VPN 连接时长的视图,当连接成功后会每秒自动更新显示时间。 11 | struct ConnectedDurationView: View { 12 | // MARK: - 环境变量 13 | 14 | /// 用于获取当前 VPN(或代理隧道)的状态和连接时间。 15 | @EnvironmentObject private var packetTunnelManager: PacketTunnelManager 16 | 17 | // MARK: - 主视图 18 | 19 | var body: some View { 20 | VStack(alignment: .leading) { 21 | Text("连接时长:") 22 | .font(.headline) // 标签在上方 23 | 24 | // 当状态为已连接时,显示连接时长;否则默认显示 "00:00" 25 | if let status = packetTunnelManager.status, status == .connected { 26 | if let connectedDate = packetTunnelManager.connectedDate { 27 | // TimelineView 会在指定时间间隔(此处为每秒)刷新视图 28 | TimelineView(.periodic(from: Date(), by: 1.0)) { context in 29 | Text(connectedDateString( 30 | connectedDate: connectedDate, 31 | current: context.date 32 | )) 33 | .monospacedDigit() // 将数字转化为等宽字体,视觉更整齐 34 | } 35 | } else { 36 | Text("00:00") // 若无 connectedDate,视为未获取到有效连接时间 37 | } 38 | } else { 39 | Text("00:00") // 未连接或断开时默认显示 "00:00" 40 | } 41 | } 42 | } 43 | 44 | // MARK: - 辅助方法 45 | 46 | /// 根据连接时间和当前时间,计算并返回格式化后的连接时长字符串。 47 | /// 48 | /// - Parameters: 49 | /// - connectedDate: 表示 VPN 开始连接的时间。 50 | /// - current: 用于对比计算的当前时间(由 `TimelineView` 提供)。 51 | /// - Returns: 格式形如 "HH:mm:ss" 或 "mm:ss" 的字符串。如果小时数为 0,则仅显示 "mm:ss"。 52 | private func connectedDateString(connectedDate: Date, current: Date) -> String { 53 | // 计算绝对时间差(单位:秒) 54 | let duration = Int64(abs(current.distance(to: connectedDate))) 55 | 56 | // 计算时、分、秒 57 | let hours = duration / 3600 58 | let minutes = (duration % 3600) / 60 59 | let seconds = duration % 60 60 | 61 | // 如果小时数为 0,则仅显示 mm:ss;否则显示 hh:mm:ss 62 | if hours <= 0 { 63 | return String(format: "%02d:%02d", minutes, seconds) 64 | } else { 65 | return String(format: "%02d:%02d:%02d", hours, minutes, seconds) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Xray/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // Xray 4 | // 5 | // Created by pan on 2024/9/14. 6 | // 7 | 8 | import LibXray 9 | import os 10 | import SwiftUI 11 | 12 | // MARK: - Logger 13 | 14 | private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ContentView") 15 | 16 | // MARK: - 数据模型 17 | 18 | /// 用于封装 Ping 请求参数的结构体,符合 Codable 协议,方便与 JSON 编解码。 19 | struct PingRequest: Codable { 20 | var datDir: String? 21 | var configPath: String? 22 | var timeout: Int? 23 | var url: String? 24 | var proxy: String? 25 | } 26 | 27 | // MARK: - 主视图 28 | 29 | /// 整个应用的核心主视图,负责展示和管理用户粘贴、二维码扫描、VPN 连接、流量统计、分享等功能模块。 30 | @MainActor 31 | struct ContentView: View { 32 | // MARK: - 环境对象 33 | 34 | /// 全局共享的 `PacketTunnelManager`,用于获取和更新当前 VPN 的连接状态。 35 | @EnvironmentObject var packetTunnelManager: PacketTunnelManager 36 | 37 | // MARK: - 本地状态 38 | 39 | /// 保存剪贴板的内容(例如 VMess / VLESS 配置链接)。 40 | @State private var clipboardText: String = "" 41 | 42 | /// 从配置内容中解析出的 ID。 43 | @State private var idText: String = "" 44 | 45 | /// 从配置内容中解析出的 IP 地址或域名。 46 | @State private var ipText: String = "" 47 | 48 | /// 从配置内容中解析出的端口号(如远程服务器端口)。 49 | @State private var portText: String = "" 50 | 51 | /// Xray 配置文件的路径(若使用本地存储或文件管理可用)。 52 | @State private var path: String = "" 53 | 54 | /// 控制 “分享配置” 弹窗是否显示。 55 | @State private var isShowingShareModal = false 56 | 57 | /// 控制当剪贴板为空时是否显示提示 Alert。 58 | @State private var showClipboardEmptyAlert = false 59 | 60 | /// 用于显示或存储当前的 Ping 速度(ms)。 61 | @State private var pingSpeed: Int = 0 62 | 63 | /// 用于在界面上显示 SOCKS 端口(本地代理端口)。 64 | @State private var sock5Port: String = "" 65 | 66 | /// 用于在界面上显示流量统计端口。 67 | @State private var trafficPort: String = "" 68 | 69 | /// 扫描到的二维码内容。 70 | @State private var scannedCode: String? = nil 71 | 72 | /// 控制二维码扫描器视图是否显示。 73 | @State private var isShowingScanner = false 74 | 75 | // MARK: - 主布局 76 | 77 | var body: some View { 78 | VStack(alignment: .leading) { 79 | // 顶部区域:展示配置信息、连接时长、流量统计等 80 | VStack(alignment: .leading) { 81 | Text("vps信息:") 82 | .font(.headline) 83 | 84 | InfoRow(label: "ID:", text: idText) 85 | InfoRow(label: "IP地址:", text: Util.maskIPAddress(ipText)) 86 | InfoRow(label: "端口:", text: portText) 87 | 88 | // 显示当前的 VPN 连接时长 89 | ConnectedDurationView() 90 | 91 | // 显示当前流量统计(上行、下行) 92 | TrafficStatsView() 93 | 94 | // 本机端口信息 95 | Text("本机端口:") 96 | .font(.headline) 97 | HStack { 98 | Text("Sock5: \(sock5Port)") 99 | Spacer() 100 | Text("流量: \(trafficPort)") 101 | } 102 | 103 | // Ping 测速视图 104 | PingView().environmentObject(PacketTunnelManager.shared) 105 | 106 | // 选择 VPN 工作模式(全局 / 非全局等) 107 | // VPNModePickerView() 108 | } 109 | .padding() 110 | 111 | // 下载相关视图(如果有需要展示或触发下载逻辑) 112 | // DownloadView() 113 | 114 | // 中间操作区:包含粘贴、扫描和分享按钮 115 | HStack { 116 | // 粘贴按钮 117 | Button(action: { 118 | handlePasteFromClipboard() 119 | }) { 120 | HStack { 121 | Image(systemName: "clipboard") 122 | .resizable() 123 | .frame(width: 30, height: 30) 124 | Text("粘贴") 125 | } 126 | } 127 | 128 | Spacer() 129 | 130 | // 扫描按钮 131 | Button(action: { 132 | isShowingScanner = true 133 | }) { 134 | HStack { 135 | Image(systemName: "qrcode.viewfinder") 136 | .resizable() 137 | .frame(width: 30, height: 30) 138 | Text("扫描") 139 | } 140 | } 141 | 142 | Spacer() 143 | 144 | // 分享按钮 145 | Button(action: { 146 | isShowingShareModal = true 147 | }) { 148 | HStack { 149 | Image(systemName: "square.and.arrow.up") 150 | .resizable() 151 | .frame(width: 30, height: 30) 152 | Text("分享") 153 | } 154 | } 155 | } 156 | .padding(.horizontal) 157 | 158 | // 底部区域:VPN 控制视图(连接 / 断开 / 连接中...) 159 | VPNControlView { 160 | await connectVPN() 161 | } 162 | 163 | // 底部显示版本号 164 | HStack { 165 | Spacer() 166 | VersionView() 167 | Spacer() 168 | } 169 | } 170 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 171 | // 在视图出现时执行的逻辑 172 | .onAppear { 173 | loadDataFromUserDefaults() 174 | fetchFreePorts() 175 | } 176 | // 弹出分享配置的模态视图 177 | .sheet(isPresented: $isShowingShareModal) { 178 | ShareModalView(isShowing: $isShowingShareModal) 179 | } 180 | // 剪贴板为空时提示 181 | .alert(isPresented: $showClipboardEmptyAlert) { 182 | Alert( 183 | title: Text("剪贴板为空"), 184 | message: Text("没有从剪贴板获取到内容"), 185 | dismissButton: .default(Text("确定")) 186 | ) 187 | } 188 | // 扫描二维码视图 189 | .sheet(isPresented: $isShowingScanner) { 190 | QRCodeScannerView(scannedCode: $scannedCode) 191 | .onChange(of: scannedCode) { _, newCode in 192 | if let code = newCode { 193 | handleScannedCode(code) 194 | } 195 | } 196 | } 197 | } 198 | 199 | // MARK: - VPN 连接操作 200 | 201 | /// 执行异步的 VPN 连接操作。若失败则在控制台打印错误信息。 202 | private func connectVPN() async { 203 | do { 204 | try await packetTunnelManager.start() 205 | } catch { 206 | logger.error("连接 VPN 时出错: \(error.localizedDescription)") 207 | } 208 | } 209 | 210 | // MARK: - UserDefaults 相关 211 | 212 | /// 从 UserDefaults 中读取上一次保存的配置链接,并解析其中的 ID、IP、端口信息。 213 | private func loadDataFromUserDefaults() { 214 | if let content = Util.loadFromUserDefaults(key: "configLink") { 215 | Util.parseContent(content, idText: &idText, ipText: &ipText, portText: &portText) 216 | } 217 | } 218 | 219 | // MARK: - 剪贴板处理 220 | 221 | /// 从剪贴板读取配置内容,若非空则保存至 UserDefaults 并更新相关字段;否则弹出提示。 222 | private func handlePasteFromClipboard() { 223 | if let clipboardContent = Util.pasteFromClipboard(), !clipboardContent.isEmpty { 224 | // 若粘贴板内容与之前保存的不同,则更新 225 | let storedContent = Util.loadFromUserDefaults(key: "configLink") 226 | if clipboardContent != storedContent { 227 | clipboardText = clipboardContent 228 | Util.saveToUserDefaults(value: clipboardContent, key: "configLink") 229 | Util.parseContent(clipboardContent, idText: &idText, ipText: &ipText, portText: &portText) 230 | } 231 | } else { 232 | logger.info("剪贴板内容为空") 233 | showClipboardEmptyAlert = true 234 | } 235 | } 236 | 237 | // MARK: - 二维码扫描 238 | 239 | /// 处理扫描到的二维码内容,与剪贴板处理逻辑相似,将其保存并解析。 240 | /// 241 | /// - Parameter code: 扫描到的二维码字符串。 242 | private func handleScannedCode(_ code: String) { 243 | logger.info("扫描到的二维码内容: \(code)") 244 | clipboardText = code 245 | Util.saveToUserDefaults(value: clipboardText, key: "configLink") 246 | Util.parseContent(clipboardText, idText: &idText, ipText: &ipText, portText: &portText) 247 | isShowingScanner = false 248 | } 249 | 250 | // MARK: - 本地端口获取 251 | 252 | /// 向 `LibXray` 请求可用的两个空闲端口号,并保存在 UserDefaults 中,同时更新页面显示。 253 | private func fetchFreePorts() { 254 | // 1. 从 LibXray 获取两个空闲端口 (Base64 编码的字符串) 255 | let freePortsBase64String = LibXrayGetFreePorts(2) 256 | 257 | // 2. 解析 Base64 并转为 JSON 字符串 258 | guard 259 | let decodedData = Data(base64Encoded: freePortsBase64String), 260 | let decodedString = String(data: decodedData, encoding: .utf8) 261 | else { 262 | logger.error("Base64 解码失败") 263 | return 264 | } 265 | 266 | // 3. 解析 JSON 并提取端口 267 | do { 268 | if let jsonObject = try JSONSerialization.jsonObject(with: Data(decodedString.utf8), options: []) as? [String: Any], 269 | let success = jsonObject["success"] as? Bool, success, 270 | let dataDict = jsonObject["data"] as? [String: Any], 271 | let ports = dataDict["ports"] as? [Int], ports.count == 2 272 | { 273 | // 4. 保存端口到 UserDefaults,并更新本地状态 274 | Util.saveToUserDefaults(value: String(ports[0]), key: "sock5Port") 275 | Util.saveToUserDefaults(value: String(ports[1]), key: "trafficPort") 276 | sock5Port = String(ports[0]) 277 | trafficPort = String(ports[1]) 278 | 279 | logger.info("获取到的端口: \(ports[0]), \(ports[1])") 280 | } else { 281 | logger.error("解析 JSON 失败或未找到所需字段") 282 | } 283 | } catch { 284 | logger.error("JSON 解析错误: \(error.localizedDescription)") 285 | } 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /Xray/DownloadView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadView.swift 3 | // Xray 4 | // 5 | // Created by pan on 2024/10/17. 6 | // 7 | 8 | import Foundation 9 | import os 10 | import SwiftUI 11 | 12 | // MARK: - Logger 13 | 14 | private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DownloadView") 15 | 16 | /** 17 | 下载并管理地理数据库文件的视图 18 | 19 | - 显示并管理地理数据库(geoip、geosite)文件的下载和清理 20 | - 下载完成后可自动重启 VPN(若当前已连接) 21 | */ 22 | struct DownloadView: View { 23 | /// 是否正在下载,控制按钮的禁用状态 24 | @State private var isDownloading: Bool = false 25 | 26 | /// 已下载的文件列表 27 | @State private var downloadedFiles: [String] = [] 28 | 29 | // MARK: - 界面布局 30 | 31 | var body: some View { 32 | VStack { 33 | // 按钮 34 | HStack { 35 | // 下载/更新按钮 36 | Button(action: { 37 | Task { 38 | await downloadAndUpdateGeoipDat() 39 | } 40 | }) { 41 | HStack { 42 | Image(systemName: "arrow.down.circle") 43 | .resizable() 44 | .frame(width: 30, height: 30) 45 | Text("地理文件") 46 | } 47 | } 48 | .padding() 49 | .disabled(isDownloading) 50 | .foregroundColor(isDownloading ? .gray : .blue) 51 | 52 | // 清空文件按钮 53 | Button(action: { 54 | Task { 55 | await clearAssetDirectory() 56 | } 57 | }) { 58 | HStack { 59 | Image(systemName: "trash") 60 | .resizable() 61 | .frame(width: 30, height: 30) 62 | Text("清空地理") 63 | } 64 | } 65 | .padding() 66 | .disabled(isDownloading) 67 | .foregroundColor(isDownloading ? .gray : .blue) 68 | } 69 | 70 | // 显示已下载的文件 71 | if !downloadedFiles.isEmpty { 72 | HStack { 73 | Text("已下载:") 74 | .padding(.top) 75 | 76 | // 水平展示多个文件名 77 | ForEach(downloadedFiles, id: \.self) { file in 78 | Text(file) 79 | .lineLimit(1) 80 | .truncationMode(.tail) 81 | .padding(.leading, 10) 82 | } 83 | } 84 | } 85 | } 86 | .onAppear { 87 | loadDownloadedFiles() 88 | } 89 | } 90 | 91 | // MARK: - 加载已下载文件列表 92 | 93 | /** 94 | 从应用内的 `Constant.assetDirectory` 路径中,获取当前所有下载后的地理数据库文件名,并更新到 `downloadedFiles`. 95 | 96 | - Note: 如果读取失败会在控制台打印错误信息,但不会抛出异常。 97 | */ 98 | private func loadDownloadedFiles() { 99 | let fileManager = FileManager.default 100 | let assetDirectoryPath = Constant.assetDirectory.path 101 | 102 | do { 103 | let files = try fileManager.contentsOfDirectory(atPath: assetDirectoryPath) 104 | downloadedFiles = files 105 | } catch { 106 | logger.error("加载文件失败: \(error.localizedDescription)") 107 | } 108 | } 109 | 110 | // MARK: - 使用 async/await 顺序下载并更新 geoip.dat & geosite.dat 111 | 112 | /** 113 | 顺序下载两个地理数据库文件(geoip.dat, geosite.dat),并在下载完成后根据 VPN 状态决定是否重启。 114 | 115 | - Important: 此方法会将 `isDownloading` 置为 `true` 并在结束时还原为 `false`,以便界面更新下载按钮的可用状态。 116 | - Note: 若当前 VPN 已连接,则下载完成后会调用 `PacketTunnelManager.shared.restart()` 进行重启。 117 | */ 118 | @MainActor 119 | private func downloadAndUpdateGeoipDat() async { 120 | let urls = [ 121 | ("https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip.dat"), 122 | ("https://github.com/v2fly/domain-list-community/releases/latest/download/dlc.dat", "geosite.dat"), 123 | ] 124 | 125 | isDownloading = true // 禁用按钮,避免重复点击 126 | 127 | do { 128 | // 逐一下载文件(顺序执行) 129 | for (urlString, fileName) in urls { 130 | guard let url = URL(string: urlString) else { 131 | logger.error("无效的下载链接: \(urlString)") 132 | continue 133 | } 134 | 135 | // 1) 下载到临时目录 136 | let downloadedTempURL = try await downloadFile(from: url) 137 | 138 | // 2) 将临时文件移动到指定位置并改名 139 | saveFileToDirectory(fileURL: downloadedTempURL, fileName: fileName) 140 | 141 | // 3) 刷新文件列表 142 | loadDownloadedFiles() 143 | } 144 | 145 | // 下载全部完成后,检查 VPN 状态,决定是否重启 146 | if PacketTunnelManager.shared.status == .connected { 147 | do { 148 | try await PacketTunnelManager.shared.restart() 149 | logger.info("VPN 已成功重启") 150 | } catch { 151 | logger.error("VPN 重启失败:\(error.localizedDescription)") 152 | } 153 | } else { 154 | logger.error("VPN 未处于连接状态,跳过重启") 155 | } 156 | } catch { 157 | logger.error("文件下载或保存失败: \(error.localizedDescription)") 158 | } 159 | 160 | // 恢复按钮可点击 161 | isDownloading = false 162 | } 163 | 164 | // MARK: - 利用 URLSession 的异步下载 165 | 166 | /** 167 | 从远程地址下载文件到本地临时目录,返回临时文件的本地 URL。 168 | 169 | - Parameter url: 文件下载链接 170 | - Throws: 若网络响应非 200 或下载中遇到网络错误,会抛出 `URLError` 171 | - Returns: 下载成功后,位于本地临时目录的临时文件 `URL` 172 | */ 173 | private func downloadFile(from url: URL) async throws -> URL { 174 | // 使用 async/await 的 download(from:) API 175 | let (tempLocalURL, response) = try await URLSession.shared.download(from: url) 176 | 177 | // 检查 HTTP 响应码 178 | guard let httpResponse = response as? HTTPURLResponse, 179 | httpResponse.statusCode == 200 180 | else { 181 | throw URLError(.badServerResponse) 182 | } 183 | 184 | // tempLocalURL 指向临时文件位置 185 | return tempLocalURL 186 | } 187 | 188 | // MARK: - 移动下载后的临时文件到自定义目录 189 | 190 | /** 191 | 将临时文件移动到 `Constant.assetDirectory` 指定的文件夹中,并改名为 `fileName`。 192 | 193 | - Parameter fileURL: 下载所得的临时文件路径 194 | - Parameter fileName: 目标文件名 195 | - Note: 若目标位置已存在同名文件,会先删除旧文件后再进行替换。 196 | */ 197 | @MainActor 198 | private func saveFileToDirectory(fileURL: URL, fileName: String) { 199 | let fileManager = FileManager.default 200 | let destinationURL = URL(fileURLWithPath: Constant.assetDirectory.path).appendingPathComponent(fileName) 201 | 202 | do { 203 | // 如果文件夹不存在就创建 204 | if !fileManager.fileExists(atPath: Constant.assetDirectory.path) { 205 | try fileManager.createDirectory(at: Constant.assetDirectory, withIntermediateDirectories: true) 206 | } 207 | 208 | // 检查临时文件是否确实存在 209 | guard fileManager.fileExists(atPath: fileURL.path) else { 210 | logger.error("临时文件不存在: \(fileURL.path)") 211 | return 212 | } 213 | 214 | // 如果目标位置已有同名文件,先删除旧文件 215 | if fileManager.fileExists(atPath: destinationURL.path) { 216 | try fileManager.removeItem(at: destinationURL) 217 | } 218 | 219 | // 将临时文件移动到目标路径 220 | try fileManager.moveItem(at: fileURL, to: destinationURL) 221 | logger.info("\(fileName) 文件已成功移动到 \(destinationURL.path)") 222 | } catch { 223 | logger.error("文件保存失败: \(error.localizedDescription)") 224 | } 225 | } 226 | 227 | // MARK: - 清空地理文件夹 228 | 229 | /** 230 | 清空 `Constant.assetDirectory` 文件夹,并在完成后根据 VPN 状态决定是否重启。 231 | 232 | - Important: 会先删除整个文件夹,再新建一个空文件夹,最后刷新 `downloadedFiles` 列表。 233 | - Note: 若当前 VPN 已连接,则清空后会尝试重启 VPN。 234 | */ 235 | private func clearAssetDirectory() async { 236 | let fileManager = FileManager.default 237 | let assetDirectoryPath = Constant.assetDirectory.path 238 | 239 | do { 240 | // 删除整个文件夹 241 | if fileManager.fileExists(atPath: assetDirectoryPath) { 242 | try fileManager.removeItem(atPath: assetDirectoryPath) 243 | logger.info("已删除文件夹: \(assetDirectoryPath)") 244 | } 245 | 246 | // 重新创建文件夹 247 | try fileManager.createDirectory(atPath: assetDirectoryPath, withIntermediateDirectories: true) 248 | logger.info("已重新创建文件夹: \(assetDirectoryPath)") 249 | 250 | // 清空文件列表 251 | downloadedFiles.removeAll() 252 | 253 | // 清空完成后,检查 VPN 状态,决定是否重启 254 | if PacketTunnelManager.shared.status == .connected { 255 | do { 256 | try await PacketTunnelManager.shared.restart() 257 | logger.info("VPN 已成功重启") 258 | } catch { 259 | logger.error("VPN 重启失败:\(error.localizedDescription)") 260 | } 261 | } else { 262 | logger.error("VPN 未处于连接状态,跳过重启") 263 | } 264 | } catch { 265 | logger.error("操作失败: \(error.localizedDescription)") 266 | } 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /Xray/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | APP_ID 6 | ${APP_ID} 7 | NSCameraUsageDescription 8 | 我们需要访问摄像头来扫描二维码 9 | 10 | 11 | -------------------------------------------------------------------------------- /Xray/InfoRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InfoRow.swift 3 | // Xray 4 | // 5 | // Created by pan on 2024/9/20. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// 一个展示两段文本信息的行视图:左侧是标签,右侧是主要信息。 11 | struct InfoRow: View { 12 | // MARK: - 属性 13 | 14 | /// 左侧标签文本 15 | var label: String 16 | 17 | /// 右侧展示的主要文本 18 | var text: String 19 | 20 | // MARK: - 主视图 21 | 22 | var body: some View { 23 | HStack { 24 | Text(label) 25 | Text(text) 26 | .lineLimit(1) // 限制为单行显示 27 | .truncationMode(.tail) // 超出长度时尾部省略 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Xray/PacketTunnelManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PacketTunnelManager.swift 3 | // Xray 4 | // 5 | // Created by pan on 2024/9/14. 6 | // 7 | 8 | import Combine 9 | @preconcurrency import NetworkExtension 10 | import os 11 | import UIKit 12 | 13 | // MARK: - Logger 14 | 15 | private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PacketTunnelManager") 16 | 17 | /// 一个单例类,用于管理自定义 VPN(或网络隧道)的连接状态、配置加载与保存等功能。 18 | /// 19 | /// 通过 `NETunnelProviderManager` 与系统交互,实现启动、停止和重启 VPN。 20 | /// 在初始化时,会自动加载或创建对应的 VPN 配置,并监听其连接状态。 21 | @MainActor 22 | final class PacketTunnelManager: ObservableObject { 23 | // MARK: - 公有静态属性 24 | 25 | /// 全局单例,用于在 App 内统一管理 VPN。 26 | static let shared = PacketTunnelManager() 27 | 28 | // MARK: - 私有属性 29 | 30 | /// 用于存储任意符合 Combine 取消协议的对象,用以在 deinit 时解除订阅或取消任务。 31 | private var cancellables = Set() 32 | 33 | /// 当前的 `NETunnelProviderManager` 实例,负责具体的 VPN 配置与连接。 34 | @Published private var manager: NETunnelProviderManager? 35 | 36 | // MARK: - 计算属性 37 | 38 | /// 返回当前 VPN 的连接状态,如果尚未初始化或无可用配置则返回 nil。 39 | var status: NEVPNStatus? { 40 | manager?.connection.status 41 | } 42 | 43 | /// 返回当前 VPN 的连接开始时间(`connectedDate`),如果尚未连接或无可用配置则为 nil。 44 | var connectedDate: Date? { 45 | manager?.connection.connectedDate 46 | } 47 | 48 | // MARK: - 初始化 49 | 50 | /// 构造函数,在创建单例时自动调用 `setupManager()` 来加载或创建 VPN 配置。 51 | private init() { 52 | Task { 53 | await setupManager() 54 | } 55 | } 56 | 57 | // MARK: - 配置初始化与监听 58 | 59 | /// 初始化并设置 VPN 的基础配置,若成功则监听连接状态的变化。 60 | private func setupManager() async { 61 | // 尝试加载或新建 `NETunnelProviderManager` 62 | manager = await loadTunnelProviderManager() 63 | 64 | // 监听 VPN 连接状态变化(当 status 改变时,通过 Combine 通知界面刷新) 65 | if let connection = manager?.connection { 66 | NotificationCenter.default 67 | .publisher(for: .NEVPNStatusDidChange, object: connection) 68 | .receive(on: RunLoop.main) 69 | .sink { [weak self] _ in 70 | // 手动触发 SwiftUI 界面刷新 71 | self?.objectWillChange.send() 72 | } 73 | .store(in: &cancellables) 74 | } 75 | } 76 | 77 | // MARK: - Manager 加载或创建 78 | 79 | /// 从系统加载所有自定义的 TunnelProviderManager,如无可用则创建新的。 80 | /// 81 | /// - Returns: 返回加载或新建的 `NETunnelProviderManager`。 82 | /// - Throws: 若在加载过程中出现系统错误,可能抛出异常(已在方法内捕获并返回 nil)。 83 | private func loadTunnelProviderManager() async -> NETunnelProviderManager? { 84 | do { 85 | // 尝试从系统中加载所有可用的 NETunnelProviderManager 86 | let managers = try await NETunnelProviderManager.loadAllFromPreferences() 87 | 88 | // 如果已存在与我们指定的 providerBundleIdentifier 相符的 manager,直接复用 89 | if let existingManager = managers.first(where: { 90 | ($0.protocolConfiguration as? NETunnelProviderProtocol)?.providerBundleIdentifier == Constant.tunnelName 91 | }) { 92 | return existingManager 93 | } else { 94 | // 否则创建一个全新的 manager 并配置相关参数 95 | let manager = NETunnelProviderManager() 96 | let configuration = NETunnelProviderProtocol() 97 | configuration.providerBundleIdentifier = Constant.tunnelName 98 | configuration.serverAddress = "localhost" 99 | configuration.excludeLocalNetworks = true 100 | 101 | manager.localizedDescription = "Xray" 102 | manager.protocolConfiguration = configuration 103 | manager.isEnabled = true 104 | 105 | // 保存并加载配置,确保系统识别此 VPN 106 | try await saveAndLoad(manager: manager) 107 | return manager 108 | } 109 | } catch { 110 | logger.error("加载或创建 TunnelProviderManager 失败: \(error.localizedDescription)") 111 | return nil 112 | } 113 | } 114 | 115 | // MARK: - 保存并加载配置 116 | 117 | /// 将给定的 `NETunnelProviderManager` 保存到系统偏好并重新加载,以使配置生效。 118 | /// 119 | /// - Parameter manager: 要保存的 `NETunnelProviderManager` 实例。 120 | /// - Throws: 如果保存或加载流程出现错误则抛出。 121 | private func saveAndLoad(manager: NETunnelProviderManager) async throws { 122 | do { 123 | manager.isEnabled = true 124 | try await manager.saveToPreferences() 125 | try await manager.loadFromPreferences() 126 | logger.info("VPN 配置已保存并加载") 127 | } catch { 128 | throw NSError( 129 | domain: "PacketTunnelManager", 130 | code: -1, 131 | userInfo: [NSLocalizedDescriptionKey: "保存或加载配置失败: \(error.localizedDescription)"] 132 | ) 133 | } 134 | } 135 | 136 | // MARK: - 检查其他 VPN 137 | 138 | /// 检测系统中是否有其他的自定义 VPN 在连接或正在连接中。 139 | /// 140 | /// - Returns: 如果检测到其他 VPN 正在连接/已连接,则返回 `true`;否则为 `false`。 141 | private func checkOtherVPNs() async -> Bool { 142 | do { 143 | let managers = try await NETunnelProviderManager.loadAllFromPreferences() 144 | for manager in managers { 145 | let status = manager.connection.status 146 | if status == .connected || status == .connecting { 147 | logger.info("检测到其他 VPN 正在运行: \(manager.localizedDescription ?? "未知")") 148 | return true 149 | } 150 | } 151 | } catch { 152 | logger.error("检查其他 VPN 状态失败: \(error.localizedDescription)") 153 | } 154 | return false 155 | } 156 | 157 | /// 当检测到有其他 VPN 正在使用时,弹出系统原生弹窗提示用户进行切换。 158 | private func showSwitchVPNAlert() { 159 | DispatchQueue.main.async { 160 | let alert = UIAlertController( 161 | title: "切换 VPN 配置", 162 | message: "系统检测到其他 VPN 配置正在使用,请前往设置切换到当前配置。", 163 | preferredStyle: .alert 164 | ) 165 | alert.addAction(UIAlertAction(title: "确定", style: .default, handler: nil)) 166 | 167 | guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, 168 | let rootViewController = windowScene 169 | .windows.first(where: { $0.isKeyWindow })? 170 | .rootViewController 171 | else { 172 | logger.error("未找到活动的 UIWindowScene 或 rootViewController") 173 | return 174 | } 175 | rootViewController.present(alert, animated: true, completion: nil) 176 | } 177 | } 178 | 179 | // MARK: - VPN 操作 180 | 181 | /// 启动 VPN,并传入必要的配置信息与端口。 182 | /// 183 | /// - Throws: 当无法初始化 manager、或端口 / 配置读取失败、或启动出错时,抛出相应错误。 184 | func start() async throws { 185 | guard let manager = manager else { 186 | throw NSError(domain: "PacketTunnelManager", code: -1, 187 | userInfo: [NSLocalizedDescriptionKey: "Manager 未初始化"]) 188 | } 189 | 190 | // 1. 检查是否有其他 VPN 正在运行 191 | if await checkOtherVPNs() { 192 | logger.info("检测到其他 VPN 正在运行") 193 | showSwitchVPNAlert() 194 | return 195 | } 196 | 197 | // 2. 保存并加载当前配置,以防止配置处于更新状态而无法启动 198 | try await saveAndLoad(manager: manager) 199 | 200 | // 3. 从 UserDefaults 加载 SOCKS 端口和配置链接 201 | guard let sock5PortString = Util.loadFromUserDefaults(key: "sock5Port"), 202 | let sock5Port = Int(sock5PortString) 203 | else { 204 | throw NSError(domain: "ConfigurationError", code: -1, 205 | userInfo: [NSLocalizedDescriptionKey: "无法从 UserDefaults 加载端口或端口格式不正确"]) 206 | } 207 | 208 | guard let config = Util.loadFromUserDefaults(key: "configLink") else { 209 | throw NSError(domain: "ContentView", code: -1, 210 | userInfo: [NSLocalizedDescriptionKey: "没有可用的配置"]) 211 | } 212 | 213 | // 4. 构建 Xray 配置文件内容并写入 App Group 容器 214 | let configData = try Configuration().buildRunConfigurationData(config: config) 215 | guard let mergedConfigString = String(data: configData, encoding: .utf8) else { 216 | throw NSError(domain: "ConfigDataError", code: -1, 217 | userInfo: [NSLocalizedDescriptionKey: "无法将配置数据转换为字符串"]) 218 | } 219 | let fileUrl = try Util.createConfigFile(with: mergedConfigString) 220 | 221 | // 5. 确认配置文件存在 222 | guard FileManager.default.fileExists(atPath: fileUrl.path) else { 223 | throw NSError(domain: "PacketTunnelManager", code: -1, 224 | userInfo: [NSLocalizedDescriptionKey: "配置文件不存在: \(fileUrl.path)"]) 225 | } 226 | 227 | // 6. 正式启动 VPN,传入 SOCKS 端口和配置文件路径 228 | do { 229 | try manager.connection.startVPNTunnel(options: [ 230 | "sock5Port": sock5Port as NSNumber, 231 | "path": fileUrl.path as NSString, 232 | ]) 233 | logger.info("VPN 尝试启动") 234 | } catch let error as NSError { 235 | logger.error("连接 VPN 时出错: \(error.localizedDescription), 错误代码: \(error.code)") 236 | throw error 237 | } 238 | } 239 | 240 | /// 停止 VPN 连接。 241 | func stop() { 242 | manager?.connection.stopVPNTunnel() 243 | } 244 | 245 | /// 重启 VPN(先停止再启动),可用于更新配置后重新生效。 246 | /// 247 | /// - Throws: 若在停止或启动过程中出现错误,则可能抛出异常。 248 | func restart() async throws { 249 | // 1. 先停止 VPN 250 | stop() 251 | 252 | // 2. 等待 VPN 真正停用(状态从 disconnecting / connected 过渡到 disconnected) 253 | while manager?.connection.status == .disconnecting 254 | || manager?.connection.status == .connected 255 | { 256 | try await Task.sleep(nanoseconds: 500_000_000) // 0.5 秒 257 | } 258 | 259 | // 3. 重新启动 260 | try await start() 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /Xray/PingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PingView.swift 3 | // Xray 4 | // 5 | // Created by pan on 2024/9/30. 6 | // 7 | 8 | import Combine 9 | import Foundation 10 | import LibXray 11 | import Network 12 | import os 13 | import SwiftUI 14 | 15 | // MARK: - Logger 16 | 17 | private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PingView") 18 | 19 | /// 一个用于测试网络延迟(Ping)的视图,展示并记录从服务器返回的网速信息。 20 | struct PingView: View { 21 | // MARK: - 环境变量 22 | 23 | /// 自定义的 PacketTunnelManager,用于管理代理隧道的连接状态。 24 | @EnvironmentObject var packetTunnelManager: PacketTunnelManager 25 | 26 | // MARK: - 本地状态 27 | 28 | /// 记录最新一次获取到的 Ping 值(单位 ms)。 29 | @State private var pingSpeed: Int = 0 30 | /// 标记是否已经成功获取到 Ping 数据。 31 | @State private var isPingFetched: Bool = false 32 | /// 标记是否正在加载(请求中)。 33 | @State private var isLoading: Bool = false 34 | 35 | // MARK: - 主视图 36 | 37 | var body: some View { 38 | VStack { 39 | // 如果正在请求数据,显示加载提示 40 | if isLoading { 41 | Text("正在获取网速...") 42 | } else { 43 | HStack { 44 | if isPingFetched { 45 | Text("Ping网速:") 46 | Text("\(pingSpeed)") 47 | .foregroundColor(pingSpeedColor(pingSpeed)) 48 | .font(.headline) 49 | Text("ms").foregroundColor(.black) 50 | } 51 | if packetTunnelManager.status != .connected { 52 | Text("点击获取网速") 53 | .foregroundColor(.blue) 54 | .onTapGesture { 55 | requestPing() 56 | } 57 | } 58 | } 59 | } 60 | } 61 | } 62 | 63 | // MARK: - 业务逻辑 64 | 65 | /// 请求 Ping 测试逻辑,包含获取配置、生成请求并调用 LibXrayPing。 66 | /// 67 | /// 调用此函数后,将首先显示加载状态,等待异步的 Ping 测试完成或出现错误。 68 | /// 若成功,将更新 `pingSpeed` 和 `isPingFetched`,并关闭加载状态。 69 | private func requestPing() { 70 | // 显示加载动画 71 | isLoading = true 72 | 73 | Task { 74 | do { 75 | // 1. 从 UserDefaults 或其他地方读取配置信息 76 | guard let savedContent = Util.loadFromUserDefaults(key: "configLink"), 77 | !savedContent.isEmpty 78 | else { 79 | throw NSError( 80 | domain: "PingView", 81 | code: -1, 82 | userInfo: [NSLocalizedDescriptionKey: "没有可用的配置,且剪贴板内容为空"] 83 | ) 84 | } 85 | 86 | // 2. 生成配置文件的最终字符串 87 | let configData = try Configuration().buildPingConfigurationData(config: savedContent) 88 | guard let mergedConfigString = String(data: configData, encoding: .utf8) else { 89 | throw NSError( 90 | domain: "PingView", 91 | code: -1, 92 | userInfo: [NSLocalizedDescriptionKey: "无法将配置数据转换为字符串"] 93 | ) 94 | } 95 | 96 | // 3. 将配置字符串写入临时文件 97 | let fileUrl = try Util.createConfigFile(with: mergedConfigString) 98 | 99 | // 4. 读取 SOCKS5 代理端口 100 | guard let sock5PortString = Util.loadFromUserDefaults(key: "sock5Port"), 101 | let sock5Port = NWEndpoint.Port(sock5PortString) 102 | else { 103 | throw NSError( 104 | domain: "ConfigurationError", 105 | code: -1, 106 | userInfo: [NSLocalizedDescriptionKey: "无法从 UserDefaults 加载端口或端口格式不正确"] 107 | ) 108 | } 109 | 110 | // 5. 构造 Ping 请求 111 | let pingRequest = try createPingRequest( 112 | configPath: fileUrl.path(), 113 | sock5Port: sock5Port 114 | ) 115 | 116 | // 6. 将请求转换成 Base64 再调用 LibXrayPing 117 | let pingBase64String = try JSONEncoder().encode(pingRequest).base64EncodedString() 118 | 119 | // 7. 解析返回结果 120 | let pingResponseBase64 = LibXrayPing(pingBase64String) 121 | 122 | if let pingResult = await decodePingResponse(base64String: pingResponseBase64) { 123 | // 更新本地状态 124 | pingSpeed = pingResult 125 | isPingFetched = true 126 | } else { 127 | logger.error("Ping 解码失败") 128 | } 129 | } catch let error as NSError { 130 | // 业务逻辑错误提示 131 | logger.error("Ping 请求失败: \(error.localizedDescription)") 132 | } catch { 133 | // 未知错误 134 | logger.error("发生了未知错误: \(error.localizedDescription)") 135 | } 136 | 137 | // 隐藏加载动画 138 | isLoading = false 139 | } 140 | } 141 | 142 | /// 根据 `pingSpeed` 值获取不同的颜色。 143 | /// 144 | /// - Parameter pingSpeed: 当前的 Ping 值(ms)。 145 | /// - Returns: 对应的颜色对象。 146 | private func pingSpeedColor(_ pingSpeed: Int) -> Color { 147 | // 如果还未获取到 ping 值,保持黑色 148 | if pingSpeed == 0 { 149 | return .black 150 | } 151 | switch pingSpeed { 152 | case ..<1000: 153 | return .green // 0 ~ 999 ms 视为网络相对畅通 154 | case 1000 ..< 5000: 155 | return .yellow // 1000 ~ 4999 ms 视为较慢 156 | default: 157 | return .red // 超过 5000 ms 视为网络极慢或无法连接 158 | } 159 | } 160 | 161 | // MARK: - 辅助方法 162 | 163 | /// 创建一个 `PingRequest` 对象,用于封装需要给后端的完整参数。 164 | /// 165 | /// - Parameters: 166 | /// - configPath: 配置文件在本地的路径。 167 | /// - sock5Port: SOCKS5 代理使用的端口号。 168 | /// - Throws: 当参数无效时可能抛出错误。 169 | /// - Returns: 生成的 `PingRequest` 对象。 170 | @MainActor 171 | private func createPingRequest(configPath: String, sock5Port: NWEndpoint.Port) throws -> PingRequest { 172 | PingRequest( 173 | datDir: Constant.assetDirectory.path, // 数据文件目录 174 | configPath: configPath, // Xray 配置文件路径 175 | timeout: 30, // 超时时间(秒) 176 | url: "https://1.1.1.1", // 用于检测的网络地址 177 | proxy: "socks5://127.0.0.1:\(sock5Port)" // 使用的代理地址 178 | ) 179 | } 180 | 181 | /// 解码 Base64 字符串为 JSON,并从中提取 "data" 字段中的网速信息。 182 | /// 183 | /// - Parameter base64String: Base64 编码的字符串,包含 Ping 测试结果。 184 | /// - Returns: 若成功解析,则返回表示 Ping 延迟(ms)的整数;若解析失败,返回 nil。 185 | @MainActor 186 | private func decodePingResponse(base64String: String) async -> Int? { 187 | guard let decodedData = Data(base64Encoded: base64String), 188 | let decodedString = String(data: decodedData, encoding: .utf8), 189 | let jsonData = decodedString.data(using: .utf8) 190 | else { 191 | logger.error("Base64 解码或字符串转 Data 失败") 192 | return nil 193 | } 194 | 195 | do { 196 | if let jsonObject = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any], 197 | let success = jsonObject["success"] as? Bool, 198 | success, 199 | let data = jsonObject["data"] as? Int 200 | { 201 | return data 202 | } 203 | } catch { 204 | logger.error("解析 JSON 失败: \(error.localizedDescription)") 205 | } 206 | 207 | return nil 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /Xray/QRCodeScannerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QRCodeScannerView.swift 3 | // Xray 4 | // 5 | // Created by pan on 2024/10/11. 6 | // 7 | 8 | @preconcurrency import AVFoundation 9 | import SwiftUI 10 | 11 | /// 一个 SwiftUI 视图,用于扫描二维码并将扫描结果通过 `scannedCode` 传递出去。 12 | struct QRCodeScannerView: UIViewControllerRepresentable { 13 | // MARK: - 属性 14 | 15 | /// 绑定属性,用于存储扫描到的二维码内容。当二维码扫描成功后会更新此属性。 16 | @Binding var scannedCode: String? 17 | 18 | // MARK: - Coordinator 19 | 20 | /// 用于协调二维码扫描的类,负责处理元数据输出的回调。 21 | class Coordinator: NSObject, @preconcurrency AVCaptureMetadataOutputObjectsDelegate { 22 | /// 父视图,便于在回调中访问和更新 `scannedCode`。 23 | var parent: QRCodeScannerView 24 | 25 | /// 初始化函数。 26 | /// - Parameter parent: 父 `QRCodeScannerView` 实例。 27 | init(parent: QRCodeScannerView) { 28 | self.parent = parent 29 | } 30 | 31 | /// 当捕捉到元数据(如二维码)时被调用的回调方法。 32 | /// - Parameters: 33 | /// - output: 元数据输出对象(此处用不到可以忽略)。 34 | /// - metadataObjects: 捕捉到的元数据对象数组。 35 | /// - connection: 捕捉连接对象(此处用不到可以忽略)。 36 | @MainActor func metadataOutput(_: AVCaptureMetadataOutput, 37 | didOutput metadataObjects: [AVMetadataObject], 38 | from _: AVCaptureConnection) 39 | { 40 | // 若检测到元数据,获取第一个有效对象 41 | guard let metadataObject = metadataObjects.first, 42 | let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject, 43 | let stringValue = readableObject.stringValue 44 | else { 45 | return 46 | } 47 | 48 | // 扫描到二维码后,震动提示 49 | AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) 50 | 51 | // 更新扫描结果 52 | DispatchQueue.main.async { 53 | self.parent.scannedCode = stringValue 54 | } 55 | } 56 | } 57 | 58 | // MARK: - UIViewControllerRepresentable 协议实现 59 | 60 | /// 创建协调器实例。 61 | /// - Returns: `Coordinator` 实例。 62 | func makeCoordinator() -> Coordinator { 63 | Coordinator(parent: self) 64 | } 65 | 66 | /// 创建并返回用于展示二维码扫描界面的 `UIViewController`。 67 | /// - Parameter context: 上下文环境对象,提供 `Coordinator` 等信息。 68 | /// - Returns: 一个用于展示摄像头预览并进行二维码扫描的 `UIViewController` 实例。 69 | func makeUIViewController(context: Context) -> UIViewController { 70 | let viewController = UIViewController() 71 | 72 | // 1. 检查相机权限 73 | let status = AVCaptureDevice.authorizationStatus(for: .video) 74 | switch status { 75 | case .notDetermined: 76 | // 如果尚未请求权限,则请求一次 77 | AVCaptureDevice.requestAccess(for: .video) { granted in 78 | DispatchQueue.main.async { 79 | if granted { 80 | // 再次初始化扫描界面 81 | _ = self.setupScanner(on: viewController, coordinator: context.coordinator) 82 | } else { 83 | // 权限被拒绝,您可以在此处进行 UI 提示 84 | } 85 | } 86 | } 87 | case .authorized: 88 | // 已授权,直接设置扫描功能 89 | setupScanner(on: viewController, coordinator: context.coordinator) 90 | case .denied, .restricted: 91 | // 权限被拒绝或受限制,您可以在此处进行 UI 提示 92 | break 93 | @unknown default: 94 | break 95 | } 96 | 97 | return viewController 98 | } 99 | 100 | /// 更新 `UIViewController` 时调用,此处无额外操作。 101 | /// - Parameters: 102 | /// - uiViewController: 将要更新的 `UIViewController` 实例。 103 | /// - context: 上下文环境对象。 104 | func updateUIViewController(_: UIViewController, context _: Context) { 105 | // 在这里可以根据需要对界面进行更新 106 | } 107 | 108 | // MARK: - 私有辅助方法 109 | 110 | /// 配置二维码扫描相关的捕捉会话和预览图层。 111 | /// - Parameters: 112 | /// - viewController: 需要添加摄像头预览图层和进行会话配置的视图控制器。 113 | /// - coordinator: 用于处理元数据输出回调的协调器。 114 | /// - Returns: 若成功配置则返回 `UIViewController`,否则返回空。 115 | @discardableResult 116 | private func setupScanner(on viewController: UIViewController, 117 | coordinator: Coordinator) -> UIViewController? 118 | { 119 | // 创建捕捉会话 120 | let captureSession = AVCaptureSession() 121 | 122 | // 获取默认的视频捕捉设备(后置摄像头) 123 | guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else { 124 | return nil 125 | } 126 | 127 | // 尝试将捕捉设备作为输入添加到会话中 128 | do { 129 | let videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice) 130 | if captureSession.canAddInput(videoInput) { 131 | captureSession.addInput(videoInput) 132 | } 133 | } catch { 134 | // 如果添加输入失败,可以在此处处理错误(例如提示用户或记录日志) 135 | return nil 136 | } 137 | 138 | // 创建元数据输出并设置代理,用于捕获二维码数据 139 | let metadataOutput = AVCaptureMetadataOutput() 140 | if captureSession.canAddOutput(metadataOutput) { 141 | captureSession.addOutput(metadataOutput) 142 | metadataOutput.setMetadataObjectsDelegate(coordinator, queue: DispatchQueue.main) 143 | // 仅扫描二维码(如果需要支持其他条码可自行添加) 144 | metadataOutput.metadataObjectTypes = [.qr] 145 | } 146 | 147 | // 创建预览图层,用于显示摄像头捕捉到的内容 148 | let previewLayer = AVCaptureVideoPreviewLayer(session: captureSession) 149 | previewLayer.videoGravity = .resizeAspectFill 150 | previewLayer.frame = viewController.view.bounds 151 | 152 | // 将预览图层添加到视图控制器的视图上 153 | viewController.view.layer.addSublayer(previewLayer) 154 | 155 | // 在后台线程启动会话,避免阻塞主线程 156 | DispatchQueue.global(qos: .userInitiated).async { 157 | // 启动会话 158 | captureSession.startRunning() 159 | } 160 | 161 | return viewController 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /Xray/ShareModalView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShareModalView.swift 3 | // Xray 4 | // 5 | // Created by pan on 2024/9/29. 6 | // 7 | 8 | import CoreImage.CIFilterBuiltins 9 | import os 10 | import SwiftUI 11 | 12 | // MARK: - Logger 13 | 14 | private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ShareModalView") 15 | 16 | /// 一个用于展示配置信息并生成二维码以供分享的视图。 17 | struct ShareModalView: View { 18 | // MARK: - 绑定变量 19 | 20 | /// 是否显示该弹窗视图的状态,由外部进行绑定与控制。 21 | @Binding var isShowing: Bool 22 | 23 | // MARK: - 本地状态 24 | 25 | /// 用于存储将要分享(展示)的配置信息字符串。 26 | @State private var shareLink: String = "" 27 | 28 | /// 通过 CoreImage 生成的二维码图片。 29 | @State private var qrCodeImage: UIImage? 30 | 31 | // MARK: - 主视图 32 | 33 | var body: some View { 34 | NavigationView { 35 | VStack { 36 | // 如果已经成功获取到文本信息,则进行展示 37 | if !shareLink.isEmpty { 38 | Text(shareLink) 39 | .font(.body) 40 | .padding() 41 | .lineLimit(nil) // 允许多行显示 42 | .frame(maxWidth: .infinity, 43 | alignment: .leading) 44 | } 45 | 46 | // 如果已经生成二维码,则显示;否则显示加载提示 47 | if let image = qrCodeImage { 48 | Image(uiImage: image) 49 | .resizable() 50 | .interpolation(.none) // 避免二维码缩放时的模糊 51 | .frame(width: 200, height: 200) 52 | .padding() 53 | } else { 54 | Text("正在生成二维码...") 55 | .font(.caption) 56 | .foregroundColor(.gray) 57 | } 58 | 59 | Spacer() 60 | } 61 | .navigationBarTitle("分享配置", displayMode: .inline) 62 | .navigationBarItems(trailing: Button("关闭") { 63 | isShowing = false 64 | }) 65 | .onAppear { 66 | // 视图出现后触发二维码生成逻辑 67 | generateQRCode() 68 | } 69 | } 70 | } 71 | 72 | // MARK: - 业务逻辑 73 | 74 | /// 从本地加载配置信息并生成二维码。 75 | /// 76 | /// 1. 从 `UserDefaults` 中读取保存的配置信息; 77 | /// 2. 若有值则更新 `shareLink`; 78 | /// 3. 使用 CoreImage 生成二维码并放大; 79 | /// 4. 将生成的二维码写入 `qrCodeImage` 状态以供显示。 80 | private func generateQRCode() { 81 | // 1. 从 UserDefaults 加载配置信息 82 | guard let link = Util.loadFromUserDefaults(key: "configLink"), 83 | !link.isEmpty 84 | else { 85 | logger.error("无法生成二维码,因为没有可用的配置内容") 86 | return 87 | } 88 | 89 | // 2. 将配置内容存储到本地状态用于 UI 显示 90 | shareLink = link 91 | 92 | // 3. 将字符串转换为 Data 93 | guard let data = link.data(using: .utf8) else { 94 | logger.error("无法将配置信息转换为数据") 95 | return 96 | } 97 | 98 | // 4. 创建二维码滤镜并设置输入 99 | let filter = CIFilter.qrCodeGenerator() 100 | filter.setValue(data, forKey: "inputMessage") 101 | 102 | // 5. 判断是否成功生成初步二维码图像 103 | guard let qrCodeImage = filter.outputImage else { 104 | logger.error("无法生成二维码图像") 105 | return 106 | } 107 | 108 | // 6. 放大二维码,使其分辨率更高 109 | let transform = CGAffineTransform(scaleX: 20, y: 20) // 数字越大,最终分辨率越高 110 | let scaledQRCodeImage = qrCodeImage.transformed(by: transform) 111 | 112 | // 7. 转换为可在 SwiftUI 中使用的 UIImage 113 | let context = CIContext() 114 | if let cgImage = context.createCGImage(scaledQRCodeImage, 115 | from: scaledQRCodeImage.extent) 116 | { 117 | let uiImage = UIImage(cgImage: cgImage) 118 | self.qrCodeImage = uiImage 119 | } else { 120 | logger.error("无法将二维码转换为 CGImage") 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Xray/TrafficStatsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TrafficStatsView.swift 3 | // Xray 4 | // 5 | // Created by pan on 2024/9/24. 6 | // 7 | 8 | import Foundation 9 | import LibXray 10 | import Network 11 | import os 12 | import SwiftUI 13 | 14 | // MARK: - Logger 15 | 16 | private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "TrafficStatsView") 17 | 18 | /// 一个显示网络流量统计信息的视图,包含下行和上行流量,并每秒更新一次。 19 | struct TrafficStatsView: View { 20 | // MARK: - 环境与状态 21 | 22 | /// 通过 @EnvironmentObject 监听应用内的 PacketTunnelManager,用于获取 VPN/隧道的连接状态。 23 | @EnvironmentObject var packetTunnelManager: PacketTunnelManager 24 | 25 | /// 记录当前的下行流量(单位字节,字符串类型便于处理和显示)。 26 | @State private var downlinkTraffic: String = "0" 27 | 28 | /// 记录当前的上行流量(单位字节,字符串类型便于处理和显示)。 29 | @State private var uplinkTraffic: String = "0" 30 | 31 | /// 保存将用于流量查询的字符串,Base64 编码后传给 LibXray 进行通信。 32 | @State private var base64TrafficString: String = "" 33 | 34 | /// 定时器:每隔 1 秒触发一次,用于刷新流量数据。 35 | private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() 36 | 37 | // MARK: - 主视图 38 | 39 | var body: some View { 40 | VStack(alignment: .leading) { 41 | Text("流量统计:") 42 | .font(.headline) 43 | Text("下行流量: \(formatBytes(downlinkTraffic))") 44 | Text("上行流量: \(formatBytes(uplinkTraffic))") 45 | } 46 | .onAppear { 47 | // 视图出现时,初始化流量查询所需的字符串 48 | do { 49 | try initializeTrafficString() 50 | } catch { 51 | // 您可以在此处使用 Alert 或其他方式提示用户错误信息 52 | logger.error("初始化流量字符串失败: \(error.localizedDescription)") 53 | } 54 | } 55 | .onReceive(timer) { _ in 56 | // 仅在隧道状态为已连接时才进行流量查询 57 | if packetTunnelManager.status == .connected { 58 | updateTrafficStats() 59 | } 60 | } 61 | } 62 | 63 | // MARK: - 初始化与更新流量 64 | 65 | /// 初始化 Base64 编码后的流量查询字符串,仅执行一次。 66 | /// 67 | /// - Throws: 当无法从 UserDefaults 中获取或解析端口数据时抛出错误。 68 | private func initializeTrafficString() throws { 69 | // 1. 从 UserDefaults 加载流量端口号字符串 70 | guard let trafficPortString = Util.loadFromUserDefaults(key: "trafficPort"), 71 | let trafficPort = NWEndpoint.Port(trafficPortString) 72 | else { 73 | throw NSError( 74 | domain: "ConfigurationError", 75 | code: -1, 76 | userInfo: [NSLocalizedDescriptionKey: "无法从 UserDefaults 加载端口或端口格式不正确"] 77 | ) 78 | } 79 | 80 | // 2. 组装可访问的流量查询地址,例如: http://127.0.0.1:xxxx/debug/vars 81 | let trafficQueryString = "http://127.0.0.1:\(trafficPort)/debug/vars" 82 | 83 | // 3. 转为 Data 并进行 Base64 编码 84 | if let trafficData = trafficQueryString.data(using: .utf8) { 85 | base64TrafficString = trafficData.base64EncodedString() 86 | } else { 87 | logger.error("无法将字符串转换为 Data") 88 | } 89 | } 90 | 91 | /// 每秒执行一次,向 Xray 查询最新的流量统计信息,并更新本地状态。 92 | private func updateTrafficStats() { 93 | // 1. 使用已保存的 base64TrafficString 向 LibXray 发送查询请求 94 | let responseBase64 = LibXrayQueryStats(base64TrafficString) 95 | 96 | // 2. 对返回结果做 Base64 解码 97 | guard let decodedData = Data(base64Encoded: responseBase64), 98 | let decodedString = String(data: decodedData, encoding: .utf8) 99 | else { 100 | logger.error("无法解码 LibXrayQueryStats 返回的数据") 101 | return 102 | } 103 | 104 | // 3. 对解码后的 JSON 进行二次解析,并提取上下行流量 105 | parseXrayResponse(decodedString) 106 | } 107 | 108 | // MARK: - 响应解析 109 | 110 | /// 将从 LibXray 返回的 JSON 字符串解析为字典结构,并提取所需的下行、上行流量信息。 111 | /// 112 | /// - Parameter response: Xray 返回的 JSON 字符串(解码后)。 113 | private func parseXrayResponse(_ response: String) { 114 | // 1. 将字符串转换为 JSON Data 115 | guard let jsonData = response.data(using: .utf8) else { 116 | logger.error("无法将响应字符串转换为 JSON Data") 117 | return 118 | } 119 | 120 | do { 121 | // 2. 将 JSON Data 转换为字典结构 122 | guard let jsonObject = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] else { 123 | logger.error("JSON 对象不是字典类型") 124 | return 125 | } 126 | 127 | // 3. 判断 success 是否为 1,表示请求成功 128 | guard let success = jsonObject["success"] as? Int, success == 1 else { 129 | logger.error("解析失败: success 字段不是 1") 130 | return 131 | } 132 | 133 | // 4. 获取 "data" 字段并确保其为字符串 134 | guard let dataValue = jsonObject["data"] else { 135 | logger.error("在 JSON 对象中找不到 data 字段") 136 | return 137 | } 138 | 139 | guard let dataString = dataValue as? String else { 140 | logger.error("data 字段不是字符串类型") 141 | return 142 | } 143 | 144 | // 5. 对 data 字段内嵌套的字符串再进行一次 JSON 解析 145 | guard let nestedJsonData = dataString.data(using: .utf8) else { 146 | logger.error("无法将 data 字符串转换为 Data") 147 | return 148 | } 149 | 150 | guard let dataDict = try JSONSerialization.jsonObject(with: nestedJsonData, options: []) as? [String: Any] else { 151 | logger.error("无法解析嵌套的 JSON 数据") 152 | return 153 | } 154 | 155 | // 6. 读取 "stats -> inbound -> socks" 对象 156 | guard let stats = dataDict["stats"] as? [String: Any], 157 | let inbound = stats["inbound"] as? [String: Any], 158 | let socks = inbound["socks"] as? [String: Any] 159 | else { 160 | logger.error("在 dataDict 中找不到 stats 或 inbound 或 socks 节点") 161 | return 162 | } 163 | 164 | // 7. 分别获取下行和上行流量,并转换为字符串存储 165 | guard let socksDownlink = socks["downlink"] as? Int, 166 | let socksUplink = socks["uplink"] as? Int 167 | else { 168 | logger.error("无法获取 socks 下行或上行流量字段") 169 | return 170 | } 171 | 172 | // 8. 更新视图状态 173 | downlinkTraffic = String(socksDownlink) 174 | uplinkTraffic = String(socksUplink) 175 | 176 | } catch { 177 | logger.error("解析 JSON 时出错: \(error)") 178 | } 179 | } 180 | 181 | // MARK: - 辅助方法 182 | 183 | /// 将字节数转换为带有单位的可读字符串格式,如 “x.xx KB”、“x.xx MB” 或 “x.xx GB”。 184 | /// 185 | /// - Parameter bytesString: 表示字节数的字符串。如果无法转换为数值,则返回 "0 bytes"。 186 | /// - Returns: 带有合适单位(bytes、KB、MB 或 GB)的字符串。 187 | private func formatBytes(_ bytesString: String) -> String { 188 | guard let bytes = Double(bytesString) else { return "0 bytes" } 189 | 190 | let kilobyte = 1024.0 191 | let megabyte = kilobyte * 1024 192 | let gigabyte = megabyte * 1024 193 | 194 | if bytes >= gigabyte { 195 | return String(format: "%.2f GB", bytes / gigabyte) 196 | } else if bytes >= megabyte { 197 | return String(format: "%.2f MB", bytes / megabyte) 198 | } else if bytes >= kilobyte { 199 | return String(format: "%.2f KB", bytes / kilobyte) 200 | } else { 201 | return "\(Int(bytes)) bytes" 202 | } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /Xray/Util.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Util.swift 3 | // Xray 4 | // 5 | // Created by pan on 2024/9/20. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | /// 一个通用的工具枚举,包含项目中常用的用户偏好存取、剪贴板读取、配置写入等功能。 12 | enum Util { 13 | // MARK: - UserDefaults 14 | 15 | /// 将字符串存储到用户默认(UserDefaults)中。 16 | /// 17 | /// - Parameters: 18 | /// - value: 需要存储的字符串值。 19 | /// - key: 存储时对应的键名。 20 | static func saveToUserDefaults(value: String, key: String) { 21 | UserDefaults.standard.set(value, forKey: key) 22 | } 23 | 24 | /// 从用户默认(UserDefaults)中读取字符串。 25 | /// 26 | /// - Parameter key: 存储时使用的键名。 27 | /// - Returns: 如果存在并且类型匹配,则返回对应的字符串;否则返回 `nil`。 28 | static func loadFromUserDefaults(key: String) -> String? { 29 | UserDefaults.standard.string(forKey: key) 30 | } 31 | 32 | // MARK: - 剪贴板操作 33 | 34 | /// 从系统剪贴板读取字符串内容。 35 | /// 36 | /// - Returns: 若剪贴板中存在非空字符串,则返回该字符串;否则返回 `nil`。 37 | static func pasteFromClipboard() -> String? { 38 | if let clipboardContent = UIPasteboard.general.string, !clipboardContent.isEmpty { 39 | return clipboardContent 40 | } 41 | return nil 42 | } 43 | 44 | // MARK: - 字符串解析 45 | 46 | /// 解析给定的 URL 字符串,从中提取 id、host (IP)、端口号等信息。 47 | /// 48 | /// - Parameters: 49 | /// - content: 待解析的内容(通常是一个含有 URL 协议头的字符串)。 50 | /// - idText: 用于承接解析后的用户标识部分(通常为 URL 的 user 字段)。 51 | /// - ipText: 用于承接解析后的 IP 地址或域名(通常为 URL 的 host 字段)。 52 | /// - portText: 用于承接解析后的端口号字符串(如果有)。 53 | static func parseContent(_ content: String, 54 | idText: inout String, 55 | ipText: inout String, 56 | portText: inout String) 57 | { 58 | if let urlComponents = URLComponents(string: content) { 59 | ipText = urlComponents.host ?? "" 60 | idText = urlComponents.user ?? "" 61 | portText = urlComponents.port.map(String.init) ?? "" 62 | } 63 | } 64 | 65 | // MARK: - IP 地址处理 66 | 67 | /// 对 IP 地址进行部分掩码处理,例如将 `192.168.1.100` 转换为 `*.*.*.100`。 68 | /// 69 | /// - Parameter ipAddress: 完整的 IP 地址字符串。 70 | /// - Returns: 掩码处理后的 IP 地址,用于隐藏除最后一段以外的信息。如果不符合 IPv4 格式则返回原字符串。 71 | static func maskIPAddress(_ ipAddress: String) -> String { 72 | let components = ipAddress.split(separator: ".") 73 | guard components.count == 4 else { return ipAddress } 74 | return "*.*.*." + components[3] 75 | } 76 | 77 | // MARK: - 文件写入 78 | 79 | /// 在 App Group 容器内创建或覆盖指定文件,并将内容写入其中。 80 | /// 81 | /// - Parameters: 82 | /// - content: 文件内容(一般为字符串形式的 JSON 或其他配置信息)。 83 | /// - fileName: 写入的文件名(默认为 "config.json")。 84 | /// - Returns: 写入成功后生成的文件 URL。 85 | /// - Throws: 当无法获取 App Group 容器 URL 或文件写入失败时,抛出相应错误。 86 | static func createConfigFile(with content: String, fileName: String = "config.json") throws -> URL { 87 | // 1. 获取共享容器 URL 88 | guard let sharedContainerURL = FileManager.default.containerURL( 89 | forSecurityApplicationGroupIdentifier: Constant.groupName 90 | ) else { 91 | throw NSError( 92 | domain: "AppGroupError", 93 | code: -1, 94 | userInfo: [NSLocalizedDescriptionKey: "无法找到 App Group 容器"] 95 | ) 96 | } 97 | 98 | // 2. 在共享容器中创建或获取文件路径 99 | let fileUrl = sharedContainerURL.appendingPathComponent(fileName) 100 | 101 | // 3. 将内容写入指定文件 102 | try content.write(to: fileUrl, atomically: true, encoding: .utf8) 103 | 104 | return fileUrl 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Xray/VPNControlView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VPNControlView.swift 3 | // Xray 4 | // 5 | // Created by pan on 2024/9/20. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | /// 一个用于管理和展示 VPN 连接状态及操作(连接 / 断开)的视图。 12 | struct VPNControlView: View { 13 | // MARK: - 环境变量 14 | 15 | /// 通过 EnvironmentObject 获取到全局的 PacketTunnelManager,用于读写 VPN 的连接状态。 16 | @EnvironmentObject var packetTunnelManager: PacketTunnelManager 17 | 18 | // MARK: - 外部依赖 19 | 20 | /// 一个异步方法,用于发起 VPN 连接操作。 21 | /// - 注意:您可以在外部实现此方法,以包含必要的端口或参数,并在此处传入。 22 | var connect: () async -> Void 23 | 24 | // MARK: - 主视图 25 | 26 | var body: some View { 27 | VStack { 28 | vpnControlButton() 29 | } 30 | .padding() 31 | } 32 | 33 | // MARK: - 辅助视图构建 34 | 35 | /// 根据 PacketTunnelManager 的当前状态来返回不同的操作按钮或提示。 36 | /// 37 | /// - Returns: 不同状态对应的 `View`,包括 “连接” 按钮、“断开” 按钮、加载进度视图、或错误提示文本。 38 | @ViewBuilder 39 | private func vpnControlButton() -> some View { 40 | switch packetTunnelManager.status { 41 | case .connected: 42 | // 已连接:显示“断开”按钮 43 | Button("断开") { 44 | packetTunnelManager.stop() 45 | } 46 | .buttonStyle(ActionButtonStyle(color: .red)) 47 | .frame(maxWidth: .infinity, alignment: .center) 48 | 49 | case .disconnected: 50 | // 已断开:显示“连接”按钮 51 | Button("连接") { 52 | Task { 53 | await connect() 54 | } 55 | } 56 | .buttonStyle(ActionButtonStyle(color: .green)) 57 | .frame(maxWidth: .infinity, alignment: .center) 58 | 59 | case .connecting, .reasserting: 60 | // 连接中或重新连接中:显示加载进度 61 | VStack { 62 | ProgressView("连接中...") 63 | } 64 | .frame(maxWidth: .infinity, alignment: .center) 65 | 66 | case .disconnecting: 67 | // 断开中:显示加载进度 68 | VStack { 69 | ProgressView("断开中...") 70 | } 71 | .frame(maxWidth: .infinity, alignment: .center) 72 | 73 | case .invalid, .none: 74 | // 无效或无法获取的状态 75 | Text("无法获取 VPN 状态") 76 | .frame(maxWidth: .infinity, alignment: .center) 77 | 78 | @unknown default: 79 | // 未来可能出现的新状态 80 | Text("未知状态") 81 | .frame(maxWidth: .infinity, alignment: .center) 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Xray/VPNModePickerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VPNModePickerView.swift 3 | // Xray 4 | // 5 | // Created by pan on 2024/11/5. 6 | // 7 | 8 | import os 9 | import SwiftUI 10 | 11 | // MARK: - Logger 12 | 13 | private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VPNMode") 14 | 15 | /// VPN 工作模式枚举:支持“全局”和“非全局”两种模式。 16 | enum VPNMode: String { 17 | case global = "全局" 18 | case nonGlobal = "非全局" 19 | } 20 | 21 | /// 一个让用户选择 VPN 路由模式(全局/非全局)的视图, 22 | /// 并在切换模式时保存到 UserDefaults,同时若 VPN 已连接则自动重启应用。 23 | struct VPNModePickerView: View { 24 | // MARK: - 属性 25 | 26 | /// 当前选中的 VPN 模式,默认设置为非全局。 27 | @State private var selectedMode: VPNMode = .nonGlobal 28 | 29 | /// 从环境中获取全局的 PacketTunnelManager,用于判断 VPN 状态并在必要时重启。 30 | @EnvironmentObject var packetTunnelManager: PacketTunnelManager 31 | 32 | // MARK: - 主视图 33 | 34 | var body: some View { 35 | VStack(alignment: .leading) { 36 | Text("路由模式:") 37 | .font(.headline) 38 | 39 | Picker("模式", selection: $selectedMode) { 40 | Text(VPNMode.nonGlobal.rawValue) 41 | .tag(VPNMode.nonGlobal) 42 | Text(VPNMode.global.rawValue) 43 | .tag(VPNMode.global) 44 | } 45 | .pickerStyle(SegmentedPickerStyle()) 46 | // 当用户切换 Picker 的值时,立即执行以下逻辑 47 | .onChange(of: selectedMode) { _, newMode in 48 | // 将新的模式存储到 UserDefaults 49 | saveModeToUserDefaults(newMode) 50 | 51 | // 如果当前 VPN 已处于连接状态,则进行重启以应用新的模式 52 | if packetTunnelManager.status == .connected { 53 | Task { 54 | do { 55 | try await packetTunnelManager.restart() 56 | logger.info("VPN 已成功重启") 57 | } catch { 58 | logger.error("VPN 重启失败:\(error.localizedDescription)") 59 | } 60 | } 61 | } 62 | } 63 | } 64 | // 视图出现时,从 UserDefaults 读取用户上一次选择的 VPN 模式。 65 | .onAppear { 66 | loadModeFromUserDefaults() 67 | } 68 | } 69 | 70 | // MARK: - UserDefaults 读写 71 | 72 | /// 将用户选择的 VPN 模式保存到 UserDefaults。 73 | /// 74 | /// - Parameter mode: 当前选中的 VPN 模式。 75 | private func saveModeToUserDefaults(_ mode: VPNMode) { 76 | Util.saveToUserDefaults(value: mode.rawValue, key: "VPNMode") 77 | } 78 | 79 | /// 从 UserDefaults 读取先前保存的 VPN 模式;如果没有找到,则默认设置为非全局模式。 80 | private func loadModeFromUserDefaults() { 81 | if let modeString = Util.loadFromUserDefaults(key: "VPNMode"), 82 | let mode = VPNMode(rawValue: modeString) 83 | { 84 | selectedMode = mode 85 | } else { 86 | selectedMode = .nonGlobal 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Xray/VersionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VersionView.swift 3 | // Xray 4 | // 5 | // Created by pan on 2024/9/20. 6 | // 7 | 8 | import Foundation 9 | import LibXray 10 | import SwiftUI 11 | 12 | /// 显示 Xray 版本号的视图。 13 | /// 14 | /// 内部调用 `LibXray` 提供的方法获取版本信息,并解析为可读的字符串。 15 | /// 若遇到错误,则以 Alert 的形式进行提示。 16 | struct VersionView: View { 17 | // MARK: - State 18 | 19 | /// 用于存储当前版本号。如果请求尚未完成,则显示 "Loading..."。 20 | @State private var versionText: String = "Loading..." 21 | 22 | /// 用于显示错误提示的弹窗。 23 | @State private var showErrorAlert: Bool = false 24 | 25 | /// 错误信息内容,配合 `showErrorAlert` 一起使用。 26 | @State private var errorMessage: String = "" 27 | 28 | // MARK: - Body 29 | 30 | var body: some View { 31 | VStack { 32 | HStack { 33 | Text("xray版本号:") 34 | Text(versionText) 35 | } 36 | } 37 | .onAppear { 38 | fetchVersion() 39 | } 40 | .alert(isPresented: $showErrorAlert) { 41 | Alert( 42 | title: Text("错误"), 43 | message: Text(errorMessage), 44 | dismissButton: .default(Text("确定")) 45 | ) 46 | } 47 | } 48 | 49 | // MARK: - 业务逻辑 50 | 51 | /// 从 LibXray 获取版本号的 Base64 字符串,并进行解码、解析。 52 | /// 53 | /// 调用 `LibXrayXrayVersion()` 获取版本信息的 Base64 表示,然后解码并转为 JSON 字符串, 54 | /// 最后调用 `parseVersion(jsonString:)` 进一步解析。 55 | private func fetchVersion() { 56 | // 1. 从 LibXray 获取版本号的 Base64 字符串 57 | let base64Version = LibXrayXrayVersion() 58 | 59 | // 2. 解码 Base64 60 | if let decodedData = Data(base64Encoded: base64Version), 61 | let decodedString = String(data: decodedData, encoding: .utf8) 62 | { 63 | // 3. 解析 JSON 获取版本号 64 | parseVersion(jsonString: decodedString) 65 | } else { 66 | showError("版本号解码失败") 67 | } 68 | } 69 | 70 | /// 解析 JSON 格式的版本信息,将其转为易读的字符串并更新界面。 71 | /// 72 | /// - Parameter jsonString: Base64 解码后得到的 JSON 字符串。 73 | /// 74 | /// JSON 示例结构: 75 | /// ```json 76 | /// { 77 | /// "success": true, 78 | /// "data": "1.0.0" 79 | /// } 80 | /// ``` 81 | private func parseVersion(jsonString: String) { 82 | // 定义一个内部使用的结构,用于匹配 JSON。 83 | struct VersionResponse: Codable { 84 | let success: Bool 85 | let data: String 86 | } 87 | 88 | do { 89 | // 1. 将 JSON 字符串转换为 Data 90 | let jsonData = Data(jsonString.utf8) 91 | // 2. 使用 JSONDecoder 解析 92 | let versionResponse = try JSONDecoder().decode(VersionResponse.self, from: jsonData) 93 | 94 | // 3. 根据 success 字段判断是否成功 95 | if versionResponse.success { 96 | versionText = versionResponse.data 97 | } else { 98 | showError("获取版本号失败:success 为 false") 99 | } 100 | } catch { 101 | showError("解析版本号失败: \(error.localizedDescription)") 102 | } 103 | } 104 | 105 | // MARK: - 辅助方法 106 | 107 | /// 设置错误信息并弹出错误提示。 108 | /// 109 | /// - Parameter message: 需要显示给用户的错误详情。 110 | private func showError(_ message: String) { 111 | errorMessage = message 112 | showErrorAlert = true 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Xray/Xray.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.developer.networking.networkextension 6 | 7 | packet-tunnel-provider 8 | 9 | com.apple.security.application-groups 10 | 11 | group.$(APP_ID) 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Xray/XrayApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XrayApp.swift 3 | // Xray 4 | // 5 | // Created by pan on 2024/9/14. 6 | // 7 | 8 | import NetworkExtension 9 | import SwiftUI 10 | 11 | @main 12 | struct XrayApp: App { 13 | var body: some Scene { 14 | WindowGroup { 15 | ContentView() 16 | .environmentObject(PacketTunnelManager.shared) 17 | } 18 | } 19 | } 20 | --------------------------------------------------------------------------------