├── .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 |
--------------------------------------------------------------------------------