From d95688705e165089e59d0bc71ff3497f23a2d203 Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Fri, 13 Feb 2026 01:36:49 +0800 Subject: [PATCH 01/30] =?UTF-8?q?=E5=A4=87=E4=BB=BD=EF=BC=9A=E5=BC=80?= =?UTF-8?q?=E5=A7=8B=E4=BF=AE=E6=94=B9=E5=89=8D=202026-02-13=2001:36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 6148 -> 6148 bytes EasyTier/.DS_Store | Bin 6148 -> 0 bytes EasyTier/CoreService.swift | 74 -- EasyTier/EasyTierRunner.swift | 593 -------------- EasyTier/HelperManager.swift | 366 --------- EasyTier/PermissionManager.swift | 48 -- EasyTier/com.alick.swiftier.helper.plist | 40 - EasyTierCore/Cargo.lock | 728 ++++++++++++------ EasyTierCore/Cargo.toml | 2 +- EasyTierCore/build.rs | 1 + EasyTierCore/easytier-patched | 1 + EasyTierCore/include/EasyTierCore.h | 31 +- EasyTierCore/src/lib.rs | 15 +- EasyTierHelper/EasyTierCore.swift | 82 -- EasyTierHelper/EasyTierHelper.entitlements | 8 - EasyTierHelper/Info.plist | 22 - EasyTierHelper/LogProcessor.swift | 183 ----- EasyTierHelper/SharedTypes.swift | 39 - .../SwiftierHelper-Bridging-Header.h | 13 - EasyTierHelper/main.swift | 459 ----------- Swiftier.xcodeproj/project.pbxproj | 306 +++++--- Swiftier.xcodeproj/project.pbxproj.bak | 626 +++++++++++++++ .../xcschemes/xcschememanagement.plist | 5 + .../AccentColor.colorset/Contents.json | 0 .../Assets.xcassets/Contents.json | 0 {EasyTier => Swiftier}/CliClient.swift | 4 +- {EasyTier => Swiftier}/CodeEditor.swift | 4 +- {EasyTier => Swiftier}/ConfigEditorView.swift | 13 +- .../ConfigGeneratorView.swift | 37 +- {EasyTier => Swiftier}/ConfigManager.swift | 69 +- {EasyTier => Swiftier}/ContentView.swift | 200 ++--- {EasyTier => Swiftier}/CoreDownloader.swift | 0 Swiftier/EasyTierShared.swift | 164 ++++ {EasyTier => Swiftier}/EventListView.swift | 0 {EasyTier => Swiftier}/Extensions.swift | 0 {EasyTier => Swiftier}/HelperProtocol.swift | 0 {EasyTier => Swiftier}/Info.plist | 6 +- {EasyTier => Swiftier}/Localizable.xcstrings | 18 + {EasyTier => Swiftier}/LogListView.swift | 0 {EasyTier => Swiftier}/LogModels.swift | 4 +- {EasyTier => Swiftier}/LogParser.swift | 102 ++- {EasyTier => Swiftier}/LogView.swift | 7 +- .../Models/SwiftierNodeModels.swift | 4 +- {EasyTier => Swiftier}/PeerCard.swift | 6 +- {EasyTier => Swiftier}/RippleRingsView.swift | 0 {EasyTier => Swiftier}/ScrollFixer.swift | 0 {EasyTier => Swiftier}/SettingsView.swift | 39 +- {EasyTier => Swiftier}/SharedComponents.swift | 0 {EasyTier => Swiftier}/SparklineView.swift | 8 +- .../Swiftier.entitlements | 16 + ...oint.3.connected.trianglepath.dotted 2.png | Bin .../Swiftier.icon/icon.json | 0 .../SwiftierControlApp.swift | 47 +- Swiftier/SwiftierRunner.swift | 444 +++++++++++ Swiftier/VPNManager.swift | 200 +++++ SwiftierNE/AddressHelper.swift | 63 ++ SwiftierNE/EasyTierShared.swift | 164 ++++ SwiftierNE/Info.plist | 13 + SwiftierNE/InfoModels.swift | 85 ++ SwiftierNE/Logger.swift | 5 + SwiftierNE/OSLogExporter.swift | 80 ++ SwiftierNE/PacketTunnelProvider.swift | 441 +++++++++++ SwiftierNE/SwiftierCore.swift | 121 +++ SwiftierNE/SwiftierNE-Bridging-Header.h | 7 + SwiftierNE/SwiftierNE.entitlements | 20 + SwiftierNE/TunnelHelper.swift | 94 +++ build_rust_universal.sh | 1 + plans/migration_plan.md | 69 ++ 68 files changed, 3639 insertions(+), 2558 deletions(-) delete mode 100644 EasyTier/.DS_Store delete mode 100644 EasyTier/CoreService.swift delete mode 100644 EasyTier/EasyTierRunner.swift delete mode 100644 EasyTier/HelperManager.swift delete mode 100644 EasyTier/PermissionManager.swift delete mode 100644 EasyTier/com.alick.swiftier.helper.plist create mode 100644 EasyTierCore/build.rs create mode 160000 EasyTierCore/easytier-patched delete mode 100644 EasyTierHelper/EasyTierCore.swift delete mode 100644 EasyTierHelper/EasyTierHelper.entitlements delete mode 100644 EasyTierHelper/Info.plist delete mode 100644 EasyTierHelper/LogProcessor.swift delete mode 100644 EasyTierHelper/SharedTypes.swift delete mode 100644 EasyTierHelper/SwiftierHelper-Bridging-Header.h delete mode 100644 EasyTierHelper/main.swift create mode 100644 Swiftier.xcodeproj/project.pbxproj.bak rename {EasyTier => Swiftier}/Assets.xcassets/AccentColor.colorset/Contents.json (100%) rename {EasyTier => Swiftier}/Assets.xcassets/Contents.json (100%) rename {EasyTier => Swiftier}/CliClient.swift (99%) rename {EasyTier => Swiftier}/CodeEditor.swift (98%) rename {EasyTier => Swiftier}/ConfigEditorView.swift (82%) rename {EasyTier => Swiftier}/ConfigGeneratorView.swift (98%) rename {EasyTier => Swiftier}/ConfigManager.swift (67%) rename {EasyTier => Swiftier}/ContentView.swift (83%) rename {EasyTier => Swiftier}/CoreDownloader.swift (100%) create mode 100644 Swiftier/EasyTierShared.swift rename {EasyTier => Swiftier}/EventListView.swift (100%) rename {EasyTier => Swiftier}/Extensions.swift (100%) rename {EasyTier => Swiftier}/HelperProtocol.swift (100%) rename {EasyTier => Swiftier}/Info.plist (74%) rename {EasyTier => Swiftier}/Localizable.xcstrings (99%) rename {EasyTier => Swiftier}/LogListView.swift (100%) rename {EasyTier => Swiftier}/LogModels.swift (96%) rename {EasyTier => Swiftier}/LogParser.swift (87%) rename {EasyTier => Swiftier}/LogView.swift (91%) rename EasyTier/Models/EasyTierNodeModels.swift => Swiftier/Models/SwiftierNodeModels.swift (99%) rename {EasyTier => Swiftier}/PeerCard.swift (99%) rename {EasyTier => Swiftier}/RippleRingsView.swift (100%) rename {EasyTier => Swiftier}/ScrollFixer.swift (100%) rename {EasyTier => Swiftier}/SettingsView.swift (94%) rename {EasyTier => Swiftier}/SharedComponents.swift (100%) rename {EasyTier => Swiftier}/SparklineView.swift (98%) rename EasyTier/EasyTier.entitlements => Swiftier/Swiftier.entitlements (62%) rename {EasyTier => Swiftier}/Swiftier.icon/Assets/point.3.connected.trianglepath.dotted 2.png (100%) rename {EasyTier => Swiftier}/Swiftier.icon/icon.json (100%) rename EasyTier/EasyTierControlApp.swift => Swiftier/SwiftierControlApp.swift (75%) create mode 100644 Swiftier/SwiftierRunner.swift create mode 100644 Swiftier/VPNManager.swift create mode 100755 SwiftierNE/AddressHelper.swift create mode 100755 SwiftierNE/EasyTierShared.swift create mode 100644 SwiftierNE/Info.plist create mode 100755 SwiftierNE/InfoModels.swift create mode 100644 SwiftierNE/Logger.swift create mode 100755 SwiftierNE/OSLogExporter.swift create mode 100644 SwiftierNE/PacketTunnelProvider.swift create mode 100644 SwiftierNE/SwiftierCore.swift create mode 100644 SwiftierNE/SwiftierNE-Bridging-Header.h create mode 100644 SwiftierNE/SwiftierNE.entitlements create mode 100755 SwiftierNE/TunnelHelper.swift create mode 100644 plans/migration_plan.md diff --git a/.DS_Store b/.DS_Store index 5bd136e1d56cd04c47f73016d9fccf39cf014471..03f0203744945b5a74defc1ebccb1514a01d033b 100644 GIT binary patch delta 158 zcmZoMXfc=|#>B)qu~2NHo+39J0|Nsi1A_nqL&?Udjg0&tJ_kcELpehxLmETLWGR+& zoCw}W7SYM~*o>H&BVs4}vujPZW#?jBu&;urX5M69b~6N1i@zYlFgQ6sw*Y7y0|WDf n&4L_aEE5~pH?wo_a{z4z^1m}r<`;3~0LcR#z_K|)WDPR_o5>}1 delta 491 zcmZoMXfc=|#>B!ku~2NHo+3970|Nsi1A_nqL&(Ocjf{&}nD{|L91N}ui44UIl?)-1 zrI^lfB6uH}L^b&kvJ9CFsSHI79zZgOp#a2ADlaZb%E?axnzCcE4pU*hM0K^1vAK?d zk%@7wjzYDik%5kaiLqI2EhmSlvc7dte0EN5UVax)JrFPg9RmiuP#Q*c0~vVj%}F;5 zPR`FQfM`(8gsHEGcmva+x%n>h%8bc+fykX_~g zav)BhO#aN|%FMJ|bh0(G37dJKr~00`lQ%MJO`gxpgW#GoF+G@A=(?GmgP#K!m76~@ Xe`lV|FXG4n3_K=~@tY$=)-VGAjksr+ diff --git a/EasyTier/.DS_Store b/EasyTier/.DS_Store deleted file mode 100644 index 8f7af22eae75e47950bc64c335d99225da32c43d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKu};G<5PdF{C_;#ij9C~#>cE~>;S2Hu(3X}+ZBn%q7&9;;@d12LH^e9K32dyq zv#pS*&{iD~LU+>nIhQ*-_iWiY0H!nVw}2*q234@O$L1T6_o7Qu^A?FSc8nX$aEg2M zaJl5IhQG*wtlc5nT(=pnP+q^nG8(qxUN)ZC&)+?JSRXOYqNqE{y7ZKdbJBTSv^RW1%swFpQ|{n|J2*pH$sBJltG&xvKQE5w zA5S*x72}VyuZ%Z~sAP+GevpbXpbRJjTQDG}AEmMHP+Mg{8Bhia2IPDQsDh!#+@U=> z*w_+)Sf|+v`%+6tOyDu}m^^fgShQl>7hu=lZ`Iq)*C#GVre$F!d;oI!wvmtxL(tT^mp@s3PK* mJ1j!ju~#vD Void) { - if #available(macOS 13.0, *) { - let logLevel = UserDefaults.standard.string(forKey: "logLevel")?.lowercased() ?? "info" - HelperManager.shared.startCore(configPath: configPath, consoleLevel: logLevel) { success, error in - if !success { - print("CoreService start failed: \(error ?? "Unknown")") - } - completion(success) - } - } else { - print("CoreService: Unsupported macOS version") - completion(false) - } - } - - func stop(completion: @escaping (Bool) -> Void = { _ in }) { - if #available(macOS 13.0, *) { - HelperManager.shared.stopCore { success in - completion(success) - } - } else { - completion(true) - } - } - - /// Get core running status - func getStatus(completion: @escaping (Bool, Int32) -> Void) { - if #available(macOS 13.0, *) { - HelperManager.shared.getCoreStatus { pid in - completion(pid > 0, pid) - } - } else { - completion(false, 0) - } - } - - // MARK: - Helper Management (macOS 13+) - - @available(macOS 13.0, *) - func installHelper(completion: @escaping (Bool, String?) -> Void) { - HelperManager.shared.installHelper(completion: completion) - } - - @available(macOS 13.0, *) - func uninstallHelper(completion: @escaping (Bool, String?) -> Void) { - HelperManager.shared.uninstallHelper(completion: completion) - } - - @available(macOS 13.0, *) - var isHelperInstalled: Bool { - return HelperManager.shared.isHelperInstalled - } - - @available(macOS 13.0, *) - var helperStatus: String { - return HelperManager.shared.serviceStatus - } - - @available(macOS 13.0, *) - func quitHelper(completion: @escaping () -> Void) { - HelperManager.shared.quitHelper(completion: completion) - } -} diff --git a/EasyTier/EasyTierRunner.swift b/EasyTier/EasyTierRunner.swift deleted file mode 100644 index 62280e1..0000000 --- a/EasyTier/EasyTierRunner.swift +++ /dev/null @@ -1,593 +0,0 @@ -import Foundation -import Combine -import AppKit -import SwiftUI - -final class EasyTierRunner: ObservableObject { - static let shared = EasyTierRunner() - - @Published var isRunning = false - @Published var peers: [PeerInfo] = [] - @Published var peerCount: String = "0" - @Published var downloadSpeed: String = "0 KB/s" - @Published var uploadSpeed: String = "0 KB/s" - @Published var maxHistorySpeed: Double = 1_048_576.0 // 缓存最大网速计算结果 - // 优化:窗口可见性变化时自动管理 Timer - - @Published var isWindowVisible = true - - @Published var uptimeText: String = "00:00:00" - - // 公开最后一次数据更新的时间戳,供 UI 层做动画相位对齐 - @Published private(set) var lastDataTime: Date = Date.distantPast - - private var startedAt: Date? - - private var timer: AnyCancellable? - - @Published private(set) var sessionID = UUID() - - private var currentSessionID = UUID() - private var lastConfigPath: String? - - // Speed calculation - private var lastTotalRx: Int = 0 - private var lastTotalTx: Int = 0 - private var lastPollTime: Date? - private var lastProcessingTime: Date = .distantPast // 用于频率限制 - - // Peer-level speed tracking - private var lastPeerStats: [Int: (rx: Int, tx: Int, time: Date)] = [:] - private let jsonDecoder = JSONDecoder() - - // 最小化解码结构,用于后台静默模式以极速解析字节数 - private struct MinimalStatus: Codable { - struct Pair: Codable { - struct Peer: Codable { - struct Conn: Codable { - struct Stats: Codable { - let rx_bytes: Int - let tx_bytes: Int - } - let stats: Stats? - } - let conns: [Conn]? - } - let peer: Peer? - } - let peer_route_pairs: [Pair]? - } - - @Published var virtualIP: String = "-" - - // Speed history for graphs - @Published var downloadHistory: [Double] = Array(repeating: 0.0, count: 20) - @Published var uploadHistory: [Double] = Array(repeating: 0.0, count: 20) - - // Subscriber & Polling Control - private var subscriberCount = 0 - private var isAppActive = true - private var pollingTimer: AnyCancellable? - private let activeInterval: TimeInterval = 1.0 - private let lowPowerInterval: TimeInterval = 5.0 - - private init() { - // App Lifecycle Monitoring - NotificationCenter.default.addObserver(self, selector: #selector(handleAppDidBecomeActive), name: NSApplication.didBecomeActiveNotification, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(handleAppWillResignActive), name: NSApplication.willResignActiveNotification, object: nil) - - syncWithCoreState() - } - - @objc private func handleAppDidBecomeActive() { - print("[Runner] App Active -> High Perf Mode") - isAppActive = true - updatePollingMode() - } - - @objc private func handleAppWillResignActive() { - print("[Runner] App Background -> Low Power Mode") - isAppActive = false - updatePollingMode() - } - - func addSubscriber() { - subscriberCount += 1 - updatePollingMode() - } - - func removeSubscriber() { - subscriberCount = max(0, subscriberCount - 1) - updatePollingMode() - } - - private func updatePollingMode() { - guard isRunning else { - pollingTimer?.cancel() - return - } - - // Relaxed Logic: If ANY view is subscribed (Window is open), run at high speed. - // Don't care if App is backgrounded/inactive (user might be looking at it while typing elsewhere). - let interval: TimeInterval - if subscriberCount > 0 { - interval = activeInterval - } else { - interval = lowPowerInterval - } - - // Restart timer only if interval changed or timer stopped - pollingTimer?.cancel() - - if #available(macOS 13.0, *) { - // Re-implement Timer logic for both versions to control interval strictly - pollingTimer = Timer.publish(every: interval, on: .main, in: .common) - .autoconnect() - .sink { [weak self] _ in - self?.refreshPeersOnce() - } - } else { - // MacOS 12 Logic - pollingTimer = Timer.publish(every: interval, on: .main, in: .common) - .autoconnect() - .sink { [weak self] _ in - self?.refreshPeersOnce() - } - } - } - - // ... syncWithCoreState ... - - func syncWithCoreState(completion: ((Bool) -> Void)? = nil) { - let wasAlreadyRunning = self.isRunning - print("[Runner] Syncing with core state (current: \(wasAlreadyRunning))...") - - CoreService.shared.getStatus { [weak self] running, pid in - guard let self = self else { - completion?(false) - return - } - - let isDiscovery = running && pid > 0 - - if isDiscovery { - // Core 正在后台运行,获取真实启动时间 - if #available(macOS 13.0, *) { - HelperManager.shared.getCoreStartTime { [weak self] timestamp in - guard let self = self else { return } - DispatchQueue.main.async { - // 只有状态改变才触发 UI 更新,防止循环刷新 - if !self.isRunning { - self.isRunning = true - } - - if timestamp > 0 { - self.startedAt = Date(timeIntervalSince1970: timestamp) - } else if self.startedAt == nil { - self.startedAt = Date() - } - - // 只有在之前认定为停止的情况下,才重新初始化监控逻辑 - if !wasAlreadyRunning { - print("[Runner] Inheriting running core state, initializing monitoring...") - self.currentSessionID = UUID() - self.resetSpeedCounters() - self.startUptimeTimer() - self.startMonitoring() - } - - print("[Runner] Core detected (PID: \(pid)). Sync complete.") - completion?(true) - } - } - } else { - // 旧系统 fallback - DispatchQueue.main.async { - if !self.isRunning { self.isRunning = true } - if !wasAlreadyRunning { - self.startedAt = Date() - self.currentSessionID = UUID() - self.startUptimeTimer() - self.startMonitoring() - } - completion?(true) - } - } - } else { - // Core 未运行 - DispatchQueue.main.async { - if self.isRunning { - self.isRunning = false - } - self.startedAt = nil - - // 自动连接逻辑:仅在 App 刚启动、且发现 Core 未跑、且开启了开关时触发一次 - if !wasAlreadyRunning && UserDefaults.standard.bool(forKey: "connectOnStart") { - print("[Runner] Initial sync: Core not running, auto-connecting...") - if let lastConfig = ConfigManager.shared.configFiles.first?.path { - self.toggleService(configPath: lastConfig) - } - } - - self.uptimeText = "00:00:00" - self.peers = [] - self.downloadHistory = Array(repeating: 0.0, count: 20) - self.uploadHistory = Array(repeating: 0.0, count: 20) - if wasAlreadyRunning { - print("[Runner] Core process disappeared.") - } - completion?(false) - } - } - } - } - - // --- 保留功能:磁盘访问权限检查 --- - var hasFullDiskAccess: Bool { - let path = "/Library/Application Support/com.apple.TCC/TCC.db" - return FileManager.default.isReadableFile(atPath: path) - } - - func showPermissionAlert() { - DispatchQueue.main.async { - let alert = NSAlert() - alert.messageText = "需要完整磁盘访问权限" - alert.informativeText = "Swiftier 需要该权限来读取受保护文件夹中的配置文件。\n\n请在『系统设置 -> 隐私与安全性 -> 完整磁盘访问权限』中手动勾选此 App。" - alert.addButton(withTitle: "去设置") - alert.addButton(withTitle: "取消") - alert.window.level = .floating - if alert.runModal() == .alertFirstButtonReturn { - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles") { - NSWorkspace.shared.open(url) - } - } - } - } - - @Published var isProcessing = false - - func toggleService(configPath: String) { - if isProcessing { return } - isProcessing = true - - let targetState = !isRunning - - withAnimation(.spring(response: 1.0, dampingFraction: 0.8)) { - self.isRunning = targetState - if !targetState { self.peers = [] } - } - - if !targetState { - // STOP - self.startedAt = nil - self.uptimeText = "00:00:00" - self.stopUptimeTimer() - self.timer?.cancel() - - CoreService.shared.stop { [weak self] _ in - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - self?.isProcessing = false - } - } - } else { - // START - self.startedAt = Date() - self.uptimeText = "00:00:00" - self.startUptimeTimer() - - let newSessionID = UUID() - self.sessionID = newSessionID - self.currentSessionID = newSessionID - self.lastConfigPath = configPath - - print("[Runner] Optimistic start. Background cleanup initiated...") - - CoreService.shared.stop { [weak self] _ in - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - self?.performStart(configPath: configPath, newSessionID: newSessionID) - } - } - } - } - - private func performStart(configPath: String, newSessionID: UUID, retryCount: Int = 1) { - CoreService.shared.start(configPath: configPath) { [weak self] success in - DispatchQueue.main.async { - guard let self = self else { return } - - if success { - self.isRunning = true - // 避免启动流程中重复重置 startedAt,导致计时器“走两秒又归零” - if self.startedAt == nil { - self.startedAt = Date() - } - self.startUptimeTimer() - - print("[Runner] Service started successfully. Starting monitoring.") - self.startMonitoring() - self.isProcessing = false - } else { - if retryCount > 0 { - print("[Runner] Start failed, retrying in 1.5s...") - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - self.performStart(configPath: configPath, newSessionID: newSessionID, retryCount: retryCount - 1) - } - } else { - self.isProcessing = false - self.syncWithCoreState() - } - } - } - } - } - - func restartService() { - guard let path = lastConfigPath, isRunning else { return } - if isProcessing { return } - isProcessing = true - - CoreService.shared.stop { [weak self] _ in - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - guard let self = self else { return } - let newSessionID = UUID() - self.sessionID = newSessionID - self.currentSessionID = newSessionID - self.performStart(configPath: path, newSessionID: newSessionID) - } - } - } - - func openLogFile() { - let logPath = "/var/log/swiftier-helper.log" - if FileManager.default.fileExists(atPath: logPath) { - NSWorkspace.shared.open(URL(fileURLWithPath: logPath)) - } - } - - private func startMonitoring() { - resetSpeedCounters() - // We now rely on the unified polling timer which respects app state - updatePollingMode() - - // Initial fetch - refreshPeersOnce() - } - - private func resetSpeedCounters() { - lastTotalRx = 0 - lastTotalTx = 0 - lastPollTime = nil - lastPeerStats = [:] - downloadHistory = Array(repeating: 0.0, count: 20) - uploadHistory = Array(repeating: 0.0, count: 20) - } - - private func refreshPeersOnce() { - guard #available(macOS 13.0, *) else { return } - HelperManager.shared.getRunningInfo { [weak self] jsonStr in - if let str = jsonStr { - self?.processRunningInfo(str) - } - } - } - - private var throttleInterval: TimeInterval = 0.8 - - // 开启/关闭高频刷新模式(用于启动时的丝滑动画) - func setWarmUpMode(_ enabled: Bool) { - // 0.05s 允许最高 20FPS - self.throttleInterval = enabled ? 0.05 : 0.8 - print("[Runner] WarmUp Mode: \(enabled)") - } - - // 公开的手动刷新接口 - func forceRefresh() { - refreshPeersOnce() - } - - private func processRunningInfo(_ jsonStr: String) { - let now = Date() - // 动态频率限制 - guard now.timeIntervalSince(lastProcessingTime) >= throttleInterval else { - return - } - - // 静默模式检查已移除:后台也要持续计算流量历史,保证开窗即用,数据连贯。 - // SwiftUI 会自动处理 View 的渲染暂停,所以纯数据处理 CPU 开销极低。 - // guard isWindowVisible else { ... } - - lastProcessingTime = now - guard let data = jsonStr.data(using: .utf8) else { return } - - var totalRx = 0 - var totalTx = 0 - var fetchedPeers: [PeerInfo] = [] - - // --- 活跃模式:全量解析并更新 UI --- - guard let status = try? jsonDecoder.decode(EasyTierStatus.self, from: data) else { return } - - // 1. IP & 事件更新 (不直接触发 UI 刷新) - LogParser.shared.updateEventsFromRunningInfo(status.events) - if let myIp = status.myNodeInfo?.virtualIPv4?.description, self.virtualIP != myIp { - DispatchQueue.main.async { self.virtualIP = myIp } - } - - // 2. 统计流量 & 构建节点列表 - for pair in status.peerRoutePairs { - if let peer = pair.peer { - for conn in peer.conns { - if let stats = conn.stats { - totalRx += stats.rxBytes - totalTx += stats.txBytes - } - } - } - } - - // 3. 构建本地节点卡片 - if let myNode = status.myNodeInfo { - fetchedPeers.append(PeerInfo( - sessionID: self.currentSessionID, - ipv4: myNode.virtualIPv4?.description ?? "-", - hostname: myNode.hostname, - cost: "本机", - latency: "0", - loss: "0.0%", - rx: self.formatBytes(totalRx), - tx: self.formatBytes(totalTx), - tunnel: "LOCAL", - nat: myNode.stunInfo?.udpNATType.description ?? "Unknown", - version: myNode.version, - myNodeData: myNode - )) - } - - // 4. 构建远程节点列表 - for pair in status.peerRoutePairs { - var rxVal = "0 B", txVal = "0 B", latencyVal = "", lossVal = "", tunnelVal = "" - - if let peer = pair.peer { - var cRx = 0, cTx = 0, latSum = 0, latCount = 0, lossSum = 0.0, lossCount = 0, tunnels = Set() - for conn in peer.conns { - if let s = conn.stats { - latSum += s.latencyUs; latCount += 1 - cRx += s.rxBytes; cTx += s.txBytes - } - - lossSum += conn.lossRate - lossCount += 1 - - if let t = conn.tunnel?.tunnelType { tunnels.insert(t.uppercased()) } - } - rxVal = formatBytes(cRx); txVal = formatBytes(cTx) - if latCount > 0 { latencyVal = String(format: "%.1f", Double(latSum)/Double(latCount)/1000.0) } - if lossCount > 0 { lossVal = String(format: "%.1f%%", (lossSum/Double(lossCount))*100.0) } - tunnelVal = tunnels.sorted().joined(separator: "&") - } else if let pathLat = pair.route.pathLatency as Int?, pathLat > 0 { - latencyVal = String(format: "%.1f", Double(pathLat) / 1000.0) - } - - fetchedPeers.append(PeerInfo( - sessionID: self.currentSessionID, - ipv4: pair.route.ipv4Addr?.description ?? "", - hostname: pair.route.hostname, - cost: pair.route.cost == 1 ? "P2P" : "Relay(\(pair.route.cost))", - latency: latencyVal, loss: lossVal, rx: rxVal, tx: txVal, tunnel: tunnelVal, - nat: pair.route.stunInfo?.udpNATType.description ?? "Unknown", - version: pair.route.version, - fullData: pair - )) - } - - // --- 流量更新 --- - if let lastT = lastPollTime { - let d = now.timeIntervalSince(lastT) - if d > 0.1 { - let rSpeed = max(0, Double(totalRx - lastTotalRx) / d) - let tSpeed = max(0, Double(totalTx - lastTotalTx) / d) - DispatchQueue.main.async { - self.downloadSpeed = self.formatSpeed(rSpeed) - self.uploadSpeed = self.formatSpeed(tSpeed) - self.downloadHistory.removeFirst(); self.downloadHistory.append(rSpeed) - self.uploadHistory.removeFirst(); self.uploadHistory.append(tSpeed) - - // 性能优化:在此处预计算最大值,避免 View body 每秒计算多次 - self.maxHistorySpeed = max( - (self.downloadHistory.max() ?? 0.0), - (self.uploadHistory.max() ?? 0.0), - 1_048_576.0 - ) - } - } - } - self.lastTotalRx = totalRx - self.lastTotalTx = totalTx - self.lastPollTime = now - // Update public timestamp for UI phase sync - DispatchQueue.main.async { - self.lastDataTime = now - } - - // 5. 排序并发布 UI 列表 - let sorted = fetchedPeers.sorted { p1, p2 in - // 优先级 1: 本机始终第一 - let is1L = p1.cost == "本机"; let is2L = p2.cost == "本机" - if is1L != is2L { return is1L } - - // 优先级 2: 有虚拟 IP 的排前面,Public (IP 为空) 的排后面 - let is1Empty = p1.ipv4.isEmpty; let is2Empty = p2.ipv4.isEmpty - if is1Empty != is2Empty { return !is1Empty } - - // 优先级 3: 正常的按 IP 排序 - return p1.ipv4.localizedStandardCompare(p2.ipv4) == .orderedAscending - } - - DispatchQueue.main.async { - let oldIDs = self.peers.map(\.id) - let newIDs = sorted.map(\.id) - - // Debug: duplicate IDs will cause LazyHGrid to “跳格/错位” - let uniqueCount = Set(newIDs).count - if uniqueCount != newIDs.count { - var counts: [String: Int] = [:] - for id in newIDs { counts[id, default: 0] += 1 } - let dups = counts.filter { $0.value > 1 }.map { "\($0.key) x\($0.value)" }.joined(separator: ", ") - print("[Runner] WARNING: Duplicate PeerInfo.id detected: \(dups)") - } - - if oldIDs != newIDs { - withAnimation(.spring(response: 0.5, dampingFraction: 0.82)) { - self.peers = sorted - } - } else { - self.peers = sorted - } - - self.peerCount = "\(sorted.count)" - } - } - - private func formatSpeed(_ bytesPerSec: Double) -> String { - if bytesPerSec < 1024 { return String(format: "%.0f B/s", bytesPerSec) } - let kb = bytesPerSec / 1024.0 - if kb < 1024 { return String(format: "%.1f KB/s", kb) } - let mb = kb / 1024.0 - return String(format: "%.1f MB/s", mb) - } - - // REMOVED: private func natTypeString - - private func formatBytes(_ bytes: Int) -> String { - if bytes < 1024 { return "\(bytes) B" } - let kb = Double(bytes) / 1024.0 - if kb < 1024 { return String(format: "%.1f KB", kb) } - let mb = kb / 1024.0 - if mb < 1024 { return String(format: "%.1f MB", mb) } - return String(format: "%.2f GB", mb / 1024.0) - } - - private var uptimeTimer: Timer? - - private func startUptimeTimer() { - guard uptimeTimer == nil else { return } - updateUptimeText() - let t = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in - self?.updateUptimeText() - } - RunLoop.main.add(t, forMode: .common) - uptimeTimer = t - } - - private func stopUptimeTimer() { - uptimeTimer?.invalidate() - uptimeTimer = nil - } - - private func updateUptimeText() { - guard let sAt = startedAt else { return } - let interval = Int(Date().timeIntervalSince(sAt)) - let h = interval / 3600; let m = (interval % 3600) / 60; let s = interval % 60 - let newText = String(format: "%02d:%02d:%02d", h, m, s) - if uptimeText != newText { uptimeText = newText } - } -} diff --git a/EasyTier/HelperManager.swift b/EasyTier/HelperManager.swift deleted file mode 100644 index 41c957b..0000000 --- a/EasyTier/HelperManager.swift +++ /dev/null @@ -1,366 +0,0 @@ -import Foundation -import ServiceManagement -import AppKit - -@available(macOS 13.0, *) -final class HelperManager { - static let shared = HelperManager() - - private let service: SMAppService - private var xpcConnection: NSXPCConnection? - private let connectionLock = NSLock() - - private init() { - self.service = SMAppService.daemon(plistName: "com.alick.swiftier.helper.plist") - } - - deinit { - invalidateConnection() - } - - // MARK: - Logging - - private func log(_ message: String) { - let timestamp = DateFormatter.localizedString(from: Date(), dateStyle: .short, timeStyle: .medium) - print("\(timestamp) [HelperManager] \(message)") - } - - // MARK: - Service Status - - var isHelperInstalled: Bool { - return service.status == .enabled - } - - var serviceStatus: String { - switch service.status { - case .notRegistered: - return "未注册" - case .enabled: - return "已启用" - case .requiresApproval: - return "需要用户授权" - case .notFound: - return "未找到" - @unknown default: - return "未知状态" - } - } - - // MARK: - XPC Connection Management - - private func getConnection() -> NSXPCConnection { - connectionLock.lock() - defer { connectionLock.unlock() } - - if let connection = xpcConnection { - return connection - } - - // 关键修复:恢复 .privileged 选项。 - // 对于通过 SMAppService.daemon 注册的系统级守护进程,必须使用 .privileged 选项, - // 否则由于权限不足,主程序无法与特权 Helper 通信。 - let connection = NSXPCConnection(machServiceName: kHelperMachServiceName, options: .privileged) - - // --- 双向通信配置 --- - connection.exportedInterface = NSXPCInterface(with: HelperClientListener.self) - connection.exportedObject = ClientListener(manager: self) - - // 设置远程对象接口(Helper 侧实现的协议) - connection.remoteObjectInterface = NSXPCInterface(with: HelperProtocol.self) - - connection.invalidationHandler = { [weak self] in - self?.log("XPC connection invalidated") - self?.connectionLock.lock() - self?.xpcConnection = nil - self?.connectionLock.unlock() - } - - connection.interruptionHandler = { [weak self] in - self?.log("XPC connection interrupted") - } - - connection.resume() - xpcConnection = connection - - return connection - } - - private func invalidateConnection() { - connectionLock.lock() - defer { connectionLock.unlock() } - - xpcConnection?.invalidate() - xpcConnection = nil - } - - private func getHelper(errorHandler: ((Error) -> Void)? = nil) -> HelperProtocol? { - let connection = getConnection() - return connection.remoteObjectProxyWithErrorHandler { [weak self] error in - self?.log("XPC Error: \(error.localizedDescription)") - errorHandler?(error) - } as? HelperProtocol - } - - // MARK: - Public API - - /// 注册并启动 Helper daemon - func installHelper(force: Bool = false, completion: @escaping (Bool, String?) -> Void) { - log("Installing helper (force: \(force), current: \(serviceStatus))...") - log("App Path: \(Bundle.main.bundlePath)") - - Task { - // 1. Translocation Check - if Bundle.main.bundlePath.contains("/private/var/folders") { - await MainActor.run { - completion(false, "App 正在随机只读路径运行,请先将 Swiftier 移动到“应用程序”文件夹后再试。") - } - return - } - - do { - // 2. Unregister if forced - if force { - log("Forcing unregister...") - try? await service.unregister() - try? await Task.sleep(nanoseconds: 1_000_000_000) - } else if service.status == .enabled { - await MainActor.run { completion(true, nil) } - return - } - - // 3. Register - log("Calling SMAppService.register()...") - try service.register() - - // 4. Post-Registration Check - // 刷新状态以获得最准确的结果 - if service.status == .requiresApproval { - log("Status is requiresApproval. Opening settings.") - // 使用更精确的设置路径引导 - if let url = URL(string: "x-apple.systempreferences:com.apple.LoginItems-Settings.extension") { - NSWorkspace.shared.open(url) - } else { - SMAppService.openSystemSettingsLoginItems() - } - - await MainActor.run { - completion(false, "需要手动授权:请在“系统设置 -> 通用 -> 登录项”列表中,将 SwiftierHelper 的开关打开,然后重新启动服务。") - } - return - } - - // Wait for daemon launch - try? await Task.sleep(nanoseconds: 1_000_000_000) - await MainActor.run { completion(true, nil) } - - } catch { - log("SMAppService error: \(error)") - let nsError = error as NSError - if nsError.domain == "SMAppServiceErrorDomain" && nsError.code == 1 { - await MainActor.run { - completion(false, "系统拒绝注册 (Operation not permitted)。\n\n原因:通常是签名冲突或系统缓存错误。\n解决:请在终端执行 `sudo sfltool resetbtm` 后重启电脑重试。") - } - } else { - await MainActor.run { completion(false, error.localizedDescription) } - } - } - } - } - - func uninstallHelper(completion: @escaping (Bool, String?) -> Void) { - log("Uninstalling helper...") - invalidateConnection() - Task { - do { - try await service.unregister() - await MainActor.run { completion(true, nil) } - } catch { - await MainActor.run { completion(false, error.localizedDescription) } - } - } - } - - func startCore(configPath: String, consoleLevel: String, completion: @escaping (Bool, String?) -> Void) { - if service.status != .enabled { - log("Helper not enabled (\(serviceStatus)), attempting install...") - installHelper(force: false) { [weak self] success, error in - if success { - self?.startCore(configPath: configPath, consoleLevel: consoleLevel, completion: completion) - } else { - completion(false, error) - } - } - return - } - - var completionCalled = false - - // 超时保护 - let timeoutWork = DispatchWorkItem { - if !completionCalled { - self.log("startCore timed out. XPC might be hanging.") - completionCalled = true - self.invalidateConnection() - DispatchQueue.main.async { completion(false, "Helper 响应超时,请尝试重启 App。") } - } - } - DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: timeoutWork) - - let xpcErrorHandler: (Error) -> Void = { [weak self] error in - guard let self = self else { return } - self.log("XPC call failed: \(error.localizedDescription)") - self.invalidateConnection() - - if !completionCalled { - timeoutWork.cancel() - completionCalled = true - if self.service.status == .requiresApproval { - SMAppService.openSystemSettingsLoginItems() - completion(false, "Helper 已被系统拦截,请在“登录项”设置中允许其运行。") - } else { - completion(false, "Helper 通信异常: \(error.localizedDescription)") - } - } - } - - guard let helper = getHelper(errorHandler: xpcErrorHandler) else { - timeoutWork.cancel() - completion(false, "无法建立 XPC 连接") - return - } - - helper.getVersion { [weak self] version in - guard let self = self else { return } - if !completionCalled { - if version != kTargetHelperVersion { - timeoutWork.cancel() - completionCalled = true - self.log("Version mismatch (\(version) vs \(kTargetHelperVersion)). Updating...") - self.installHelper(force: true) { success, error in - if success { - self.startCore(configPath: configPath, consoleLevel: consoleLevel, completion: completion) - } else { - completion(false, "Helper 更新失败: \(error ?? "")") - } - } - return - } - - // Sync config to /tmp for root access - let tmpPath = "/tmp/easytier_config.toml" - try? FileManager.default.removeItem(atPath: tmpPath) - try? FileManager.default.copyItem(atPath: configPath, toPath: tmpPath) - try? FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: tmpPath) - - helper.startCore(configPath: tmpPath, corePath: "", consoleLevel: consoleLevel) { success, error in - if !completionCalled { - timeoutWork.cancel() - completionCalled = true - DispatchQueue.main.async { completion(success, error) } - } - } - } - } - } - - func stopCore(completion: @escaping (Bool) -> Void) { - log("Stopping core via XPC...") - var completionCalled = false - - let timeoutWork = DispatchWorkItem { - if !completionCalled { - self.log("stopCore timed out. Forcing completion.") - completionCalled = true - DispatchQueue.main.async { completion(false) } - self.invalidateConnection() - } - } - DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: timeoutWork) - - let errorHandler: (Error) -> Void = { [weak self] error in - self?.log("stopCore XPC failed: \(error.localizedDescription)") - self?.invalidateConnection() - if !completionCalled { - timeoutWork.cancel() - completionCalled = true - DispatchQueue.main.async { completion(false) } - } - } - - guard let helper = getHelper(errorHandler: errorHandler) else { - timeoutWork.cancel() - completion(false) - return - } - - helper.stopCore { success in - if !completionCalled { - timeoutWork.cancel() - completionCalled = true - DispatchQueue.main.async { completion(success) } - } - } - } - - func getCoreStatus(completion: @escaping (Int32) -> Void) { - guard let helper = getHelper(errorHandler: { _ in completion(0) }) else { - completion(0) - return - } - helper.getCoreStatus { pid in - DispatchQueue.main.async { completion(pid) } - } - } - - func getCoreStartTime(completion: @escaping (Double) -> Void) { - guard let helper = getHelper(errorHandler: { _ in completion(0) }) else { - completion(0) - return - } - helper.getCoreStartTime { ts in - DispatchQueue.main.async { completion(ts) } - } - } - - func quitHelper(completion: @escaping () -> Void) { - getHelper()?.quitHelper { _ in completion() } - } - - func getRecentEvents(sinceIndex: Int, completion: @escaping ([ProcessedEvent], Int) -> Void) { - let errorHandler: (Error) -> Void = { _ in completion([], sinceIndex) } - guard let helper = getHelper(errorHandler: errorHandler) else { - completion([], sinceIndex) - return - } - helper.getRecentEvents(sinceIndex: sinceIndex) { data, next in - let events = (try? JSONDecoder().decode([ProcessedEvent].self, from: data)) ?? [] - DispatchQueue.main.async { completion(events, next) } - } - } - - func getRunningInfo(reply: @escaping (String?) -> Void) { - let errorHandler: (Error) -> Void = { _ in reply(nil) } - guard let helper = getHelper(errorHandler: errorHandler) else { - reply(nil) - return - } - helper.getRunningInfo { info in - DispatchQueue.main.async { reply(info) } - } - } - - // MARK: - Internal - - private class ClientListener: NSObject, HelperClientListener { - weak var manager: HelperManager? - init(manager: HelperManager) { self.manager = manager } - func runningInfoUpdated(_ info: String) { manager?.handleRunningInfoUpdate(info) } - func logUpdated(_ lines: [String]) {} - } - - private var pushHandler: ((String) -> Void)? - func setPushHandler(_ handler: @escaping (String) -> Void) { self.pushHandler = handler } - private func handleRunningInfoUpdate(_ info: String) { - DispatchQueue.main.async { self.pushHandler?(info) } - } -} diff --git a/EasyTier/PermissionManager.swift b/EasyTier/PermissionManager.swift deleted file mode 100644 index 07a8d56..0000000 --- a/EasyTier/PermissionManager.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Foundation -import AppKit -import Combine - -class PermissionManager: ObservableObject { - static let shared = PermissionManager() - - @Published var isFDAGranted: Bool - - private init() { - // Synchronous initial check to prevent UI flash - let protectedPath = "/Library/Application Support/com.apple.TCC" - var granted = false - if let _ = try? FileManager.default.contentsOfDirectory(atPath: protectedPath) { - granted = true - } - self.isFDAGranted = granted - } - - func checkFullDiskAccess() { - // 对于非沙盒应用,尝试访问 TCC 数据库是触发系统将其加入列表的最佳方式 - // 我们尝试列出这个目录,如果成功说明有权限,如果失败(Permission Denied), - // 系统也会因为这次尝试而将 APP 自动登记到“完全磁盘访问权限”列表中。 - let protectedPath = "/Library/Application Support/com.apple.TCC" - - DispatchQueue.global(qos: .userInitiated).async { - var granted = false - if let _ = try? FileManager.default.contentsOfDirectory(atPath: protectedPath) { - granted = true - } - - DispatchQueue.main.async { - if self.isFDAGranted != granted { - self.isFDAGranted = granted - } - } - } - } - - func openFullDiskAccessSettings() { - let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles")! - NSWorkspace.shared.open(url) - } - - func revealAppInFinder() { - NSWorkspace.shared.activateFileViewerSelecting([Bundle.main.bundleURL]) - } -} diff --git a/EasyTier/com.alick.swiftier.helper.plist b/EasyTier/com.alick.swiftier.helper.plist deleted file mode 100644 index 18dce1d..0000000 --- a/EasyTier/com.alick.swiftier.helper.plist +++ /dev/null @@ -1,40 +0,0 @@ - - - - - Label - com.alick.swiftier.helper - - - - BundleProgram - Contents/MacOS/SwiftierHelper - - MachServices - - - com.alick.swiftier.helper - - - - RunAtLoad - - - KeepAlive - - SuccessfulExit - - - - StandardOutPath - /var/log/swiftier-helper-stdout.log - - StandardErrorPath - /var/log/swiftier-helper-stderr.log - - AssociatedBundleIdentifiers - - com.alick.swiftier - - - diff --git a/EasyTierCore/Cargo.lock b/EasyTierCore/Cargo.lock index 763a78c..7945292 100755 --- a/EasyTierCore/Cargo.lock +++ b/EasyTierCore/Cargo.lock @@ -29,6 +29,20 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -105,9 +119,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] name = "arbitrary" @@ -120,9 +134,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e" +checksum = "9ded5f9a03ac8f24d1b8a25101ee812cd32cdc8c50a4c50237de2c4915850e73" dependencies = [ "rustversion", ] @@ -141,7 +155,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -173,7 +187,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -184,7 +198,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -202,6 +216,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atomic_refcell" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41e67cd8309bbd06cd603a9e693a784ac2e5d1e955f11286e355089fcab3047c" + [[package]] name = "auto_impl" version = "1.3.0" @@ -210,7 +230,7 @@ checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -252,7 +272,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -350,9 +370,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "bzip2" @@ -385,9 +405,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.52" +version = "1.2.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" dependencies = [ "find-msvc-tools", "jobserver", @@ -448,9 +468,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", "js-sys", @@ -493,9 +513,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.54" +version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" dependencies = [ "clap_builder", "clap_derive", @@ -503,9 +523,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.54" +version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" dependencies = [ "anstream", "anstyle", @@ -518,9 +538,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.65" +version = "4.5.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "430b4dc2b5e3861848de79627b2bedc9f3342c7da5173a14eaa5d0f8dc18ae5d" +checksum = "c757a3b7e39161a4e56f9365141ada2a6c915a8622c408ab6bb4b5d047371031" dependencies = [ "clap", ] @@ -537,21 +557,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] name = "clap_lex" -version = "0.7.6" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "codepage" @@ -584,6 +604,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -716,6 +745,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -739,7 +777,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -763,7 +801,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -774,7 +812,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -843,7 +881,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -852,18 +890,29 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" dependencies = [ "powerfmt", ] +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -872,7 +921,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -893,7 +942,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -903,7 +952,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.114", + "syn 2.0.115", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.115", + "unicode-xid", ] [[package]] @@ -966,13 +1038,12 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] name = "easytier" version = "2.5.0" -source = "git+https://github.com/EasyTier/EasyTier.git?branch=main#f8b34e3c86abc5b2aa16da09a5f97b3973490a3c" dependencies = [ "anyhow", "arc-swap", @@ -981,6 +1052,7 @@ dependencies = [ "async-stream", "async-trait", "atomic-shim", + "atomic_refcell", "auto_impl", "base64 0.22.1", "bitflags 2.10.0", @@ -997,7 +1069,9 @@ dependencies = [ "crossbeam", "dashmap", "dbus", + "derivative", "derive_builder", + "derive_more", "easytier-rpc-build", "encoding", "flume", @@ -1039,6 +1113,7 @@ dependencies = [ "prost-reflect-build", "prost-types", "quinn", + "quinn-plaintext", "rand 0.8.5", "rcgen", "regex", @@ -1054,10 +1129,12 @@ dependencies = [ "sha2", "shellexpand", "smoltcp", + "snow", "socket2 0.5.10", "stun_codec", "sys-locale", "tabled", + "terminal_size", "thiserror 1.0.69", "thunk-rs", "time", @@ -1072,6 +1149,7 @@ dependencies = [ "tracing", "tracing-subscriber", "tun-easytier", + "unicode-width 0.1.11", "url", "uuid", "version-compare", @@ -1083,6 +1161,7 @@ dependencies = [ "windows-service", "windows-sys 0.52.0", "winreg 0.52.0", + "x25519-dalek", "zerocopy 0.7.35", "zip", "zstd", @@ -1105,8 +1184,6 @@ dependencies = [ [[package]] name = "easytier-rpc-build" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24829168c28f6a448f57d18116c255dcbd2b8c25e76dbc60f6cd16d68ad2cf07" dependencies = [ "heck 0.5.0", "prost-build", @@ -1217,7 +1294,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -1269,7 +1346,7 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] @@ -1280,9 +1357,9 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "find-msvc-tools" -version = "0.1.7" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "fixedbitset" @@ -1292,13 +1369,12 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ - "crc32fast", - "libz-rs-sys", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -1405,7 +1481,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -1460,9 +1536,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", @@ -1485,6 +1561,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "git-version" version = "0.3.9" @@ -1502,7 +1601,7 @@ checksum = "53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -1628,7 +1727,7 @@ dependencies = [ "once_cell", "radix_trie", "rand 0.9.2", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", ] @@ -1652,7 +1751,7 @@ dependencies = [ "rand 0.9.2", "ring", "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tokio", "tracing", @@ -1676,7 +1775,7 @@ dependencies = [ "resolv-conf", "serde", "smallvec", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", ] @@ -1698,7 +1797,7 @@ dependencies = [ "ipnet", "prefix-trie", "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "tokio", "tokio-util", @@ -1859,14 +1958,13 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", @@ -1875,7 +1973,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.1", + "socket2 0.6.2", "system-configuration", "tokio", "tower-service", @@ -1885,9 +1983,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -1988,6 +2086,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -2039,6 +2143,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -2185,9 +2291,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -2207,7 +2313,7 @@ dependencies = [ "dashmap", "parking_lot", "rand 0.8.5", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tokio-util", "tracing", @@ -2221,6 +2327,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libbz2-rs-sys" version = "0.2.2" @@ -2229,9 +2341,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.181" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" [[package]] name = "libdbus-sys" @@ -2264,9 +2376,9 @@ dependencies = [ [[package]] name = "liblzma-sys" -version = "0.4.4" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01b9596486f6d60c3bbe644c0e1be1aa6ccc472ad630fe8927b456973d7cb736" +checksum = "9f2db66f3268487b5033077f266da6777d057949b8f93c8ad82e441df25e6186" dependencies = [ "cc", "libc", @@ -2275,9 +2387,9 @@ dependencies = [ [[package]] name = "libm" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" @@ -2289,15 +2401,6 @@ dependencies = [ "libc", ] -[[package]] -name = "libz-rs-sys" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c10501e7805cee23da17c7790e59df2870c0d4043ec6d03f67d31e2b53e77415" -dependencies = [ - "zlib-rs", -] - [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -2371,9 +2474,9 @@ checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memoffset" @@ -2419,9 +2522,9 @@ dependencies = [ [[package]] name = "moka" -version = "0.12.12" +version = "0.12.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3dec6bd31b08944e08b58fd99373893a6c17054d6f3ea5006cc894f4f4eee2a" +checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" dependencies = [ "crossbeam-channel", "crossbeam-epoch", @@ -2500,9 +2603,9 @@ dependencies = [ [[package]] name = "netlink-sys" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16c903aa70590cb93691bf97a767c8d1d6122d2cc9070433deb3bbf36ce8bd23" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" dependencies = [ "bytes", "libc", @@ -2517,7 +2620,7 @@ checksum = "4ddcb8865ad3d9950f22f42ffa0ef0aecbfbf191867b3122413602b0a360b2a6" dependencies = [ "cc", "libc", - "thiserror 2.0.17", + "thiserror 2.0.18", "winapi", ] @@ -2591,9 +2694,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-traits" @@ -2658,7 +2761,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -2669,9 +2772,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-probe" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f50d9b3dabb09ecd771ad0aa242ca6894994c130308ca3d7684634df8037391" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" @@ -2866,7 +2969,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -2923,17 +3026,29 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" dependencies = [ "portable-atomic", ] @@ -2955,9 +3070,9 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppmd-rust" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d558c559f0450f16f2a27a1f017ef38468c1090c9ce63c8e51366232d53717b4" +checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" [[package]] name = "ppv-lite86" @@ -2965,7 +3080,7 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy 0.8.33", + "zerocopy 0.8.39", ] [[package]] @@ -2986,7 +3101,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -3032,14 +3147,14 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] name = "proc-macro2" -version = "1.0.105" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -3070,7 +3185,7 @@ dependencies = [ "prost", "prost-types", "regex", - "syn 2.0.114", + "syn 2.0.115", "tempfile", ] @@ -3084,7 +3199,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -3117,7 +3232,7 @@ checksum = "f4fce6b22f15cc8d8d400a2b98ad29202b33bd56c7d9ddd815bc803a807ecb65" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -3151,13 +3266,25 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.1", - "thiserror 2.0.17", + "socket2 0.6.2", + "thiserror 2.0.18", "tokio", "tracing", "web-time", ] +[[package]] +name = "quinn-plaintext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3e617feaeb6493018fa35fc47ae8b630ac8903d8159e9e747018841b99bad3d" +dependencies = [ + "bytes", + "quinn-proto", + "seahash", + "tracing", +] + [[package]] name = "quinn-proto" version = "0.11.13" @@ -3175,7 +3302,7 @@ dependencies = [ "rustls-pki-types", "rustls-platform-verifier", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -3190,16 +3317,16 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.1", + "socket2 0.6.2", "tracing", "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.43" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] @@ -3238,7 +3365,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -3258,7 +3385,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -3267,14 +3394,14 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] @@ -3306,7 +3433,7 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "libredox", "thiserror 1.0.69", ] @@ -3317,16 +3444,16 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "libredox", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -3336,9 +3463,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -3347,9 +3474,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "reqwest" @@ -3407,7 +3534,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -3452,7 +3579,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -3539,7 +3666,7 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe 0.2.0", + "openssl-probe 0.2.1", "rustls-pki-types", "schannel", "security-framework 3.5.1", @@ -3556,9 +3683,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.2" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "web-time", "zeroize", @@ -3593,9 +3720,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ "ring", "rustls-pki-types", @@ -3610,9 +3737,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "same-file" @@ -3638,6 +3765,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + [[package]] name = "security-framework" version = "2.11.1" @@ -3707,7 +3840,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -3836,15 +3969,15 @@ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "siphasher" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" @@ -3865,6 +3998,23 @@ dependencies = [ "managed", ] +[[package]] +name = "snow" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "599b506ccc4aff8cf7844bc42cf783009a434c1e26c964432560fb6d6ad02d82" +dependencies = [ + "aes-gcm", + "blake2", + "chacha20poly1305", + "curve25519-dalek", + "getrandom 0.3.4", + "ring", + "rustc_version", + "sha2", + "subtle", +] + [[package]] name = "socket2" version = "0.5.10" @@ -3877,9 +4027,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" dependencies = [ "libc", "windows-sys 0.60.2", @@ -3940,9 +4090,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.114" +version = "2.0.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" dependencies = [ "proc-macro2", "quote", @@ -3966,7 +4116,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -3990,9 +4140,9 @@ dependencies = [ [[package]] name = "system-configuration" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags 2.10.0", "core-foundation 0.9.4", @@ -4040,12 +4190,12 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "tempfile" -version = "3.24.0" +version = "3.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.1", "once_cell", "rustix 1.1.3", "windows-sys 0.61.2", @@ -4072,11 +4222,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -4087,18 +4237,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -4117,9 +4267,9 @@ source = "git+https://github.com/easytier/thunk.git#cbbeec75a66b7b3cf0824ae890d9 [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", @@ -4127,22 +4277,22 @@ dependencies = [ "num-conv", "num_threads", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -4191,7 +4341,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.1", + "socket2 0.6.2", "tokio-macros", "windows-sys 0.61.2", ] @@ -4204,7 +4354,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -4324,14 +4474,14 @@ dependencies = [ "prost-build", "prost-types", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -4392,7 +4542,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -4527,9 +4677,15 @@ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-width" @@ -4543,6 +4699,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "universal-hash" version = "0.5.1" @@ -4592,29 +4754,17 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" dependencies = [ "getrandom 0.3.4", "js-sys", "rand 0.9.2", "serde_core", - "uuid-macro-internal", "wasm-bindgen", ] -[[package]] -name = "uuid-macro-internal" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39d11901c36b3650df7acb0f9ebe624f35b5ac4e1922ecd3c57f444648429594" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "valuable" version = "0.1.1" @@ -4666,18 +4816,27 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -4688,11 +4847,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.56" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -4701,9 +4861,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4711,31 +4871,65 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" -version = "0.3.83" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", @@ -4763,9 +4957,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" dependencies = [ "rustls-pki-types", ] @@ -4776,14 +4970,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.5", + "webpki-roots 1.0.6", ] [[package]] name = "webpki-roots" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" dependencies = [ "rustls-pki-types", ] @@ -4925,7 +5119,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -4936,7 +5130,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -5332,9 +5526,91 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn 2.0.115", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.115", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" @@ -5388,7 +5664,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", "synstructure", ] @@ -5404,11 +5680,11 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.33" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" dependencies = [ - "zerocopy-derive 0.8.33", + "zerocopy-derive 0.8.39", ] [[package]] @@ -5419,18 +5695,18 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] name = "zerocopy-derive" -version = "0.8.33" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -5450,7 +5726,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", "synstructure", ] @@ -5471,7 +5747,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -5504,7 +5780,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.115", ] [[package]] @@ -5536,15 +5812,15 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" +checksum = "a7948af682ccbc3342b6e9420e8c51c1fe5d7bf7756002b4a3c6cabfe96a7e3c" [[package]] name = "zmij" -version = "1.0.12" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zopfli" diff --git a/EasyTierCore/Cargo.toml b/EasyTierCore/Cargo.toml index bd1bf6b..7e493a1 100755 --- a/EasyTierCore/Cargo.toml +++ b/EasyTierCore/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" crate-type = ["staticlib"] [dependencies] -easytier = { git = "https://github.com/EasyTier/EasyTier.git", branch = "main" } +easytier = { path = "easytier-patched/easytier" } once_cell = "1.18.0" serde = { version = "1.0", features = ["derive"] } diff --git a/EasyTierCore/build.rs b/EasyTierCore/build.rs new file mode 100644 index 0000000..f328e4d --- /dev/null +++ b/EasyTierCore/build.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/EasyTierCore/easytier-patched b/EasyTierCore/easytier-patched new file mode 160000 index 0000000..6475724 --- /dev/null +++ b/EasyTierCore/easytier-patched @@ -0,0 +1 @@ +Subproject commit 6475724d2edf01d71209d07d4a24bcea3d6981fe diff --git a/EasyTierCore/include/EasyTierCore.h b/EasyTierCore/include/EasyTierCore.h index 9683154..0145dc0 100644 --- a/EasyTierCore/include/EasyTierCore.h +++ b/EasyTierCore/include/EasyTierCore.h @@ -1,5 +1,5 @@ -#ifndef EasyTierCore_h -#define EasyTierCore_h +#ifndef SwiftierCore_h +#define SwiftierCore_h #include @@ -11,19 +11,22 @@ extern "C" { // Initialize logger // path: Log file path // level: Log level (e.g. "info", "debug") -// err_msg: Output error message if failed (needs to be freed if not null? Usually err_msg in this pattern is static or allocated. -// Looking at rust code: CString::new(e).into_raw(). So YES, it needs to be freed by free_string if not null.) -int init_logger(const char* path, const char* level, const char** err_msg); +// err_msg: Output error message if failed (needs to be freed if not null? +// Usually err_msg in this pattern is static or allocated. Looking at rust code: +// CString::new(e).into_raw(). So YES, it needs to be freed by free_string if +// not null.) +int init_logger(const char *path, const char *level, const char *subsystem, + const char **err_msg); // Set TUN file descriptor -// Note: On macOS Helper (Root), EasyTier might create TUN directly. -int set_tun_fd(int fd, const char** err_msg); +// Note: On macOS Helper (Root), Swiftier might create TUN directly. +int set_tun_fd(int fd, const char **err_msg); // Free string returned by Rust (including err_msg output) -void free_string(const char* s); +void free_string(const char *s); // Start network instance with TOML config string -int run_network_instance(const char* cfg_str, const char** err_msg); +int run_network_instance(const char *cfg_str, const char **err_msg); // Stop network instance int stop_network_instance(void); @@ -32,19 +35,19 @@ int stop_network_instance(void); typedef void (*VoidCallback)(void); // Register callbacks -int register_stop_callback(VoidCallback callback, const char** err_msg); -int register_running_info_callback(VoidCallback callback, const char** err_msg); +int register_stop_callback(VoidCallback callback, const char **err_msg); +int register_running_info_callback(VoidCallback callback, const char **err_msg); // Get running info JSON // json: Output pointer to json string (needs free) -int get_running_info(const char** json, const char** err_msg); +int get_running_info(const char **json, const char **err_msg); // Get latest error message // msg: Output pointer to msg string (needs free) -int get_latest_error_msg(const char** msg, const char** err_msg); +int get_latest_error_msg(const char **msg, const char **err_msg); #ifdef __cplusplus } #endif -#endif /* EasyTierCore_h */ +#endif /* SwiftierCore_h */ diff --git a/EasyTierCore/src/lib.rs b/EasyTierCore/src/lib.rs index 1289eb1..edd6b58 100755 --- a/EasyTierCore/src/lib.rs +++ b/EasyTierCore/src/lib.rs @@ -16,6 +16,7 @@ static INSTANCE: Lazy>>> = Lazy::new(|| Arc::n pub extern "C" fn init_logger( path: *const std::ffi::c_char, level: *const std::ffi::c_char, + subsystem: *const std::ffi::c_char, err_msg: *mut *const std::ffi::c_char, ) -> std::ffi::c_int { let path = unsafe { @@ -28,18 +29,18 @@ pub extern "C" fn init_logger( .to_string_lossy() .into_owned() }; + let subsystem = unsafe { + std::ffi::CStr::from_ptr(subsystem) + .to_string_lossy() + .into_owned() + }; let impl_func = || { - let file = std::fs::OpenOptions::new() - .create(true) - .write(true) - .append(true) - .open(path) - .map_err(|e| e.to_string())?; + let file = File::create(path).map_err(|e| e.to_string())?; let collector = tracing_subscriber::registry() .with(tracing_subscriber::EnvFilter::new(level)) .with(tracing_subscriber::fmt::layer().with_writer(file).with_ansi(false)) - .with(OsLogger::new("site.yinmo.easytier.tunnel", "rust")); + .with(OsLogger::new(&subsystem, "rust")); tracing::subscriber::set_global_default(collector).map_err(|e| e.to_string()) }; diff --git a/EasyTierHelper/EasyTierCore.swift b/EasyTierHelper/EasyTierCore.swift deleted file mode 100644 index 4357cae..0000000 --- a/EasyTierHelper/EasyTierCore.swift +++ /dev/null @@ -1,82 +0,0 @@ -import Foundation - -@_silgen_name("init_logger") -func c_init_logger(_ path: UnsafePointer, _ level: UnsafePointer, _ err_msg: UnsafeMutablePointer?>) -> Int32 - -@_silgen_name("run_network_instance") -func c_run_network_instance(_ cfg_str: UnsafePointer, _ err_msg: UnsafeMutablePointer?>) -> Int32 - -@_silgen_name("stop_network_instance") -func c_stop_network_instance() -> Int32 - -@_silgen_name("get_running_info") -func c_get_running_info(_ json: UnsafeMutablePointer?>, _ err_msg: UnsafeMutablePointer?>) -> Int32 - -@_silgen_name("free_string") -func c_free_string(_ s: UnsafePointer) - -class EasyTierCore { - static let shared = EasyTierCore() - - private init() {} - - /// Initialize logger - func initLogger(path: String, level: String) { - var errMsg: UnsafePointer? = nil - let _ = path.withCString { pPath in - level.withCString { pLevel in - c_init_logger(pPath, pLevel, &errMsg) - } - } - if let err = errMsg { - print("[EasyTierCore] Logger Init Error: \(String(cString: err))") - c_free_string(err) - } - } - - /// Start Network Instance - func startNetwork(config: String) throws { - print("[EasyTierCore] Starting network...") - var errMsg: UnsafePointer? = nil - let res = config.withCString { ptr in - c_run_network_instance(ptr, &errMsg) - } - - if res != 0 { - let msg: String - if let err = errMsg { - msg = String(cString: err) - c_free_string(err) - } else { - msg = "Unknown error" - } - throw NSError(domain: "EasyTierCore", code: Int(res), userInfo: [NSLocalizedDescriptionKey: msg]) - } - print("[EasyTierCore] Network started successfully.") - } - - /// Stop Network Instance - func stopNetwork() { - print("[EasyTierCore] Stopping network...") - let _ = c_stop_network_instance() - } - - /// Get Running Info (JSON) - func getRunningInfo() -> String? { - var json: UnsafePointer? = nil - var errMsg: UnsafePointer? = nil - let res = c_get_running_info(&json, &errMsg) - - if res == 0, let j = json { - let str = String(cString: j) - c_free_string(j) - return str - } - - if let e = errMsg { - c_free_string(e) - } - - return nil - } -} diff --git a/EasyTierHelper/EasyTierHelper.entitlements b/EasyTierHelper/EasyTierHelper.entitlements deleted file mode 100644 index e89b7f3..0000000 --- a/EasyTierHelper/EasyTierHelper.entitlements +++ /dev/null @@ -1,8 +0,0 @@ - - - - - com.apple.security.app-sandbox - - - diff --git a/EasyTierHelper/Info.plist b/EasyTierHelper/Info.plist deleted file mode 100644 index 27fc564..0000000 --- a/EasyTierHelper/Info.plist +++ /dev/null @@ -1,22 +0,0 @@ - - - - - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - SwiftierHelper - CFBundlePackageType - BNDL - CFBundleShortVersionString - 1.0.0 - CFBundleVersion - 1.0.0 - SMAuthorizedClients - - identifier "com.alick.swiftier" and anchor apple generic - - - diff --git a/EasyTierHelper/LogProcessor.swift b/EasyTierHelper/LogProcessor.swift deleted file mode 100644 index 9f06a33..0000000 --- a/EasyTierHelper/LogProcessor.swift +++ /dev/null @@ -1,183 +0,0 @@ -import Foundation - -class LogProcessor { - static let shared = LogProcessor() - - private let maxEventItems = 100 // Reduced from 500 to save memory - private var processedEvents: [ProcessedEvent] = [] - - // totalGlobalIndex tracks the number of events processed during the current CORE life cycle. - // When the Core restarts, this is reset to 0 to signal the App to clear its history. - private var totalGlobalIndex = 0 - private let lock = NSLock() - - // Regex for cleaning up terminal codes - private let ansiRegex = try! NSRegularExpression(pattern: "(\\x1B\\[[0-9;]*[a-zA-Z])|(\\[[0-9;]+m)", options: []) - - private init() {} - - func getEvents() -> [ProcessedEvent] { - lock.lock() - defer { lock.unlock() } - return processedEvents - } - - /// 获取从指定索引后的所有事件,并序列化为 JSON Data - func getSerializedEvents(sinceIndex: Int) -> (Data, Int) { - lock.lock() - defer { lock.unlock() } - - let bufferStartIndex = max(0, totalGlobalIndex - processedEvents.count) - - let requestedEvents: [ProcessedEvent] - if sinceIndex >= totalGlobalIndex { - requestedEvents = [] - } else if sinceIndex < bufferStartIndex { - // App missed some events or just started, send current buffer - requestedEvents = processedEvents - } else { - let offsetInBuffer = sinceIndex - bufferStartIndex - requestedEvents = Array(processedEvents.dropFirst(offsetInBuffer)) - } - - let data = (try? JSONEncoder().encode(requestedEvents)) ?? Data() - return (data, totalGlobalIndex) - } - - /// Reset the processor for a new Core life cycle - func clear() { - lock.lock() - defer { lock.unlock() } - processedEvents.removeAll() - totalGlobalIndex = 0 - } - - /// 处理从日志中提取的原始行 - func processRawLine(_ rawLine: String) { - let cleanLine = removeAnsiCodes(rawLine) - - // 尝试解析 JSON 事件 - if let jsonRange = cleanLine.range(of: "\\{.*\\}", options: .regularExpression), - let data = String(cleanLine[jsonRange]).data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - - parseAndAddEvent(json: json, rawLine: cleanLine) - } - } - - private func parseAndAddEvent(json: [String: Any], rawLine: String) { - let timeStr = json["time"] as? String ?? "" - let displayTimestamp = timeStr.replacingOccurrences(of: "Z", with: "").replacingOccurrences(of: "T", with: " ") - - guard let eventData = json["event"] else { return } - - let eventName: String? - if let eventDict = eventData as? [String: Any], let firstKey = eventDict.keys.first { - eventName = firstKey - } else { - eventName = eventData as? String - } - - let type = eventName ?? "unknown" - let detailsStr = collapsePrettyPrintedArrays(formatAsJson(eventData)) - // DEFERRED: Highlights calculation moved to App side to save Helper memory - - let event = ProcessedEvent( - id: UUID(), - timestamp: displayTimestamp, - time: ISO8601DateFormatter().date(from: timeStr), - type: type, - details: detailsStr, - highlights: [] // Empty here, calculated by App - ) - - lock.lock() - processedEvents.append(event) - totalGlobalIndex += 1 - if processedEvents.count > maxEventItems { - processedEvents.removeFirst() - } - lock.unlock() - } - - private func removeAnsiCodes(_ text: String) -> String { - let range = NSRange(location: 0, length: text.utf16.count) - return ansiRegex.stringByReplacingMatches(in: text, options: [], range: range, withTemplate: "") - } - - private func collapsePrettyPrintedArrays(_ input: String) -> String { - var res = input - let pattern = #"(?s)\[\s*([^\[\]{}]*?)\s*\]"# - guard let regex = try? NSRegularExpression(pattern: pattern) else { return res } - - let nsString = res as NSString - let matches = regex.matches(in: res, options: [], range: NSRange(location: 0, length: nsString.length)) - - for match in matches.reversed() { - let content = nsString.substring(with: match.range(at: 1)) - let collapsedContent = content.components(separatedBy: .newlines) - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } - .joined(separator: ", ") - .trimmingCharacters(in: .whitespaces) - - res = (res as NSString).replacingCharacters(in: match.range, with: "[\(collapsedContent)]") - } - return res - } - - private func formatAsJson(_ value: Any) -> String { - let options: JSONSerialization.WritingOptions = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] - if let data = try? JSONSerialization.data(withJSONObject: value, options: options), - let str = String(data: data, encoding: .utf8) { - return str - } - return "\(value)" - } - - private func calculateHighlights(for json: String) -> [HighlightRange] { - var ranges: [HighlightRange] = [] - let nsString = json as NSString - let fullRange = NSRange(location: 0, length: nsString.length) - - // 1. Strings (Green) - if let regex = try? NSRegularExpression(pattern: #""([^"\\]|\\.)*""#) { - let matches = regex.matches(in: json, range: fullRange) - for match in matches { - ranges.append(HighlightRange(start: match.range.location, length: match.range.length, color: "green", bold: false)) - } - } - - // 2. Keys (Blue) - if let regex = try? NSRegularExpression(pattern: #"("[^"]+"|\b[a-zA-Z_][a-zA-Z0-9_]*\b)\s*:"#) { - let matches = regex.matches(in: json, range: fullRange) - for match in matches { - if let keyRange = Range(match.range, in: json) { - let fullMatchStr = String(json[keyRange]) - if let colonIndex = fullMatchStr.firstIndex(of: ":") { - let keyPartLength = fullMatchStr[.. ([String], Int) { - eventLock.lock() - defer { eventLock.unlock() } - - let bufferStartIndex = max(0, eventIndex - eventBuffer.count) - - if sinceIndex >= eventIndex { return ([], eventIndex) } - - if sinceIndex < bufferStartIndex { - return (eventBuffer, eventIndex) - } - - let offsetInBuffer = sinceIndex - bufferStartIndex - let events = Array(eventBuffer.dropFirst(offsetInBuffer)) - return (events, eventIndex) - } - - private func addEvent(_ jsonLine: String) { - eventLock.lock() - defer { eventLock.unlock() } - - eventBuffer.append(jsonLine) - eventIndex += 1 - - if eventBuffer.count > maxEventBufferSize { - eventBuffer.removeFirst(eventBuffer.count - maxEventBufferSize) - } - } - - private func clearEvents() { - eventLock.lock() - defer { eventLock.unlock() } - eventBuffer.removeAll() - } - - // MARK: - Core Control - - private func initRustLogger(level: String) { - if !loggerInitialized { - // Rust will write to the same log file as we do - // To avoid corruption, usually you'd want independent files, but O_APPEND often works. - // Or better: Let Rust write to its own file? No, we unified it. - // Let's assume Rust uses the file path we give. - EasyTierCore.shared.initLogger(path: logPath, level: level) - loggerInitialized = true - } - } - - func start(configPath: String, consoleLevel: String) throws { - stateLock.lock() - defer { stateLock.unlock() } - - // 1. Reset logs and events for THIS core session - let url = URL(fileURLWithPath: logPath) - try? "".write(to: url, atomically: true, encoding: .utf8) - lastLogOffset = 0 - if let handle = logFileHandle { - try? handle.seek(toOffset: 0) - } - - LogProcessor.shared.clear() - - // 2. Setup Logger - initRustLogger(level: consoleLevel) - clearEvents() // Clear the old String buffer too - - // 3. Read Config - guard FileManager.default.fileExists(atPath: configPath) else { - throw NSError(domain: "HelperError", code: 2, - userInfo: [NSLocalizedDescriptionKey: "Config file not found: \(configPath)"]) - } - let configStr = try String(contentsOfFile: configPath, encoding: .utf8) - - // 4. Start Core - if isVPNActive { - EasyTierCore.shared.stopNetwork() - } - - try EasyTierCore.shared.startNetwork(config: configStr) - - isVPNActive = true - coreStartTime = Date() - log("Started EasyTier Core via FFI with config: \(configPath)") - } - - func stop() { - stateLock.lock() - defer { stateLock.unlock() } - - if !isVPNActive { return } - - // Use a background queue to call stopNetwork, as FFI calls might block - DispatchQueue.global(qos: .userInitiated).async { - log("Calling stopNetwork FFI...") - EasyTierCore.shared.stopNetwork() - log("stopNetwork FFI returned.") - } - - isVPNActive = false - coreStartTime = nil - log("EasyTier Core stop initiated") - } - - // MARK: - Log Monitoring - - private func startLogMonitor() { - // Poll log file every 0.5s for new content - // We can't use RunLoop.main in init easily? Yes we can if called after main loop starts or on bg queue. - // But main.swift runs RunLoop.main. - - DispatchQueue.main.async { - self.logMonitorTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in - self?.checkLogFile() - } - } - } - - private func checkLogFile() { - // If handle closed or not open, try open - if logFileHandle == nil { - if FileManager.default.fileExists(atPath: logPath) { - do { - let handle = try FileHandle(forReadingFrom: URL(fileURLWithPath: logPath)) - // Seek to end initially? Or start? - // If we just started Helper, we might want to see recent logs. - // But usually we only care about new logs generated by Core. - // Let's seek to current end if it's the first time we open it AND we assume old logs are old. - // But if we just restarted Helper, maybe we want to catch up? - // Let's rely on `lastLogOffset`. - if lastLogOffset == 0 { - // First time open, maybe seek to end to avoid parsing GBs of logs? - // But then we miss startup logs if Rust started fast. - // Tradeoff: seek to end. - // Wait, if Rust started before we opened handle? - // Let's seek to end. - lastLogOffset = handle.seekToEndOfFile() - } else { - handle.seek(toFileOffset: lastLogOffset) - } - logFileHandle = handle - } catch { - return - } - } else { - return - } - } - - guard let handle = logFileHandle else { return } - - // Read new data - let data = handle.readDataToEndOfFile() - if !data.isEmpty { - lastLogOffset += UInt64(data.count) - if let str = String(data: data, encoding: .utf8) { - processLogChunk(str) - } - } - } - - private func processLogChunk(_ chunk: String) { - let lines = chunk.components(separatedBy: "\n") - for line in lines { - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { continue } - - // Feed to LogProcessor for sophisticated parsing and highlighting - LogProcessor.shared.processRawLine(trimmed) - - // Legacy internal event tracking (optional, kept for now if you revert) - // if trimmed.contains("\"event\"") && trimmed.contains("{") { ... } - } - } -} - -// MARK: - XPC Protocol - -/// 客户端监听协议:Helper 主动调用此协议的方法向 App 推送数据 -@objc(HelperClientListener) -protocol HelperClientListener { - func runningInfoUpdated(_ info: String) - func logUpdated(_ lines: [String]) -} - -/// XPC 协议:主应用与 Helper 之间的通信接口 -@objc(HelperProtocol) -protocol HelperProtocol { - func registerListener(endpoint: NSXPCListenerEndpoint) - func startCore(configPath: String, corePath: String, consoleLevel: String, reply: @escaping (Bool, String?) -> Void) - func stopCore(reply: @escaping (Bool) -> Void) - func getCoreStatus(reply: @escaping (Int32) -> Void) - func getCoreStartTime(reply: @escaping (Double) -> Void) - func getVersion(reply: @escaping (String) -> Void) - func getRecentEvents(sinceIndex: Int, reply: @escaping (Data, Int) -> Void) - func quitHelper(reply: @escaping (Bool) -> Void) - func getRunningInfo(reply: @escaping (String?) -> Void) -} - -// MARK: - XPC Service Delegate - -class HelperDelegate: NSObject, NSXPCListenerDelegate, HelperProtocol { - - // Manage client connection - private var clientConnection: NSXPCConnection? - private let clientLock = NSLock() - - // Push Loop - private var pushTimer: Timer? - private var lastPushedInfo: String? - - // ... (listener implementation unchanged) - func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool { - log("New XPC connection from PID: \(newConnection.processIdentifier)") - - // 1. Helper 提供的接口 - newConnection.exportedInterface = NSXPCInterface(with: HelperProtocol.self) - newConnection.exportedObject = self - - // 2. App 侧提供的接口(用于推送数据) - newConnection.remoteObjectInterface = NSXPCInterface(with: HelperClientListener.self) - - newConnection.invalidationHandler = { [weak self, weak newConnection] in - log("XPC connection invalidated") - self?.clientLock.lock() - if self?.clientConnection == newConnection { - self?.clientConnection = nil - } - self?.clientLock.unlock() - } - - newConnection.interruptionHandler = { - log("XPC connection interrupted") - } - - // 保存连接用于推送 - clientLock.lock() - self.clientConnection = newConnection - clientLock.unlock() - - newConnection.resume() - - // 连接建立后启动心跳推送 - startPushLoop() - - return true - } - - // MARK: - HelperProtocol Implementation - - func registerListener(endpoint: NSXPCListenerEndpoint) { - log("registerListener called (legacy). New architecture uses direct bi-directional connection.") - } - - private func startPushLoop() { - DispatchQueue.main.async { - // 防止重复启动定时器 - if self.pushTimer?.isValid == true { return } - - self.pushTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in - self?.pushUpdates() - } - } - } - - private func pushUpdates() { - guard let connection = clientConnection else { return } - - // 获取运行时信息 - let info = EasyTierCore.shared.getRunningInfo() - - if let info = info { - // 通过同一个 XPC 连接将数据推回 App - if let proxy = connection.remoteObjectProxy as? HelperClientListener { - proxy.runningInfoUpdated(info) - } - } - } - - func startCore(configPath: String, corePath: String, consoleLevel: String, reply: @escaping (Bool, String?) -> Void) { - log("XPC: startCore(configPath: \(configPath), consoleLevel: \(consoleLevel))") - - do { - try CoreProcessManager.shared.start(configPath: configPath, consoleLevel: consoleLevel) - reply(true, nil) - } catch { - reply(false, error.localizedDescription) - } - } - - func stopCore(reply: @escaping (Bool) -> Void) { - log("Received stopCore request") - CoreProcessManager.shared.stop() - reply(true) - } - - func getCoreStatus(reply: @escaping (Int32) -> Void) { - let pid = CoreProcessManager.shared.pid - log("getCoreStatus: PID = \(pid)") - reply(pid) - } - - func getCoreStartTime(reply: @escaping (Double) -> Void) { - let startTime = CoreProcessManager.shared.startTime - log("getCoreStartTime: \(startTime)") - reply(startTime) - } - - func getVersion(reply: @escaping (String) -> Void) { - reply(kHelperVersion) - } - - func getRecentEvents(sinceIndex: Int, reply: @escaping (Data, Int) -> Void) { - // Now delegating to LogProcessor for structured data - let (data, nextIndex) = LogProcessor.shared.getSerializedEvents(sinceIndex: sinceIndex) - reply(data, nextIndex) - } - - func quitHelper(reply: @escaping (Bool) -> Void) { - log("Received quitHelper request. Goodbye!") - // 先停止 core - CoreProcessManager.shared.stop() - reply(true) - - // 延迟一秒退出,确保 reply 能发回去 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - exit(0) - } - } - - func getRunningInfo(reply: @escaping (String?) -> Void) { - log("getRunningInfo called") - let info = EasyTierCore.shared.getRunningInfo() - if let info = info { - log("getRunningInfo returned \(info.prefix(200))...") - } else { - log("getRunningInfo returned nil") - } - reply(info) - } -} - -// MARK: - Main Entry Point - -setupLogging() -log("=== Swiftier Helper Starting ===") -log("Version: \(kHelperVersion)") -log("Running as UID: \(getuid())") - -let delegate = HelperDelegate() -let listener = NSXPCListener(machServiceName: kHelperMachServiceName) -listener.delegate = delegate -listener.resume() - -log("XPC Listener started on: \(kHelperMachServiceName)") - -// 保持运行 -RunLoop.main.run() diff --git a/Swiftier.xcodeproj/project.pbxproj b/Swiftier.xcodeproj/project.pbxproj index dd9411c..993fa66 100644 --- a/Swiftier.xcodeproj/project.pbxproj +++ b/Swiftier.xcodeproj/project.pbxproj @@ -7,17 +7,18 @@ objects = { /* Begin PBXBuildFile section */ - E025243B2F1BDA8500F5065A /* SwiftierHelper in Copy Helper Executable */ = {isa = PBXBuildFile; fileRef = E0AC7EE12F192DD600FC425C /* SwiftierHelper */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + E0AB5CCB2F3CCD9C00BD8CBA /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E0AB5CCA2F3CCD9C00BD8CBA /* NetworkExtension.framework */; }; + E0AB5CD32F3CCD9C00BD8CBA /* SwiftierNE.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E0AB5CC92F3CCD9C00BD8CBA /* SwiftierNE.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; E0AC7EEE2F19307C00FC425C /* ServiceManagement.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E0AC7EED2F19307C00FC425C /* ServiceManagement.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - E0AC7EF52F1933C600FC425C /* PBXContainerItemProxy */ = { + E0AB5CD12F3CCD9C00BD8CBA /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = E0933DC42F16A25B00C7DE59 /* Project object */; proxyType = 1; - remoteGlobalIDString = E0AC7EE02F192DD600FC425C; - remoteInfo = EasyTierHelper; + remoteGlobalIDString = E0AB5CC82F3CCD9C00BD8CBA; + remoteInfo = SwiftierNE; }; /* End PBXContainerItemProxy section */ @@ -28,78 +29,71 @@ dstPath = ""; dstSubfolderSpec = 6; files = ( - E025243B2F1BDA8500F5065A /* SwiftierHelper in Copy Helper Executable */, ); name = "Copy Helper Executable"; runOnlyForDeploymentPostprocessing = 0; }; - E0AC7ED12F19208200FC425C /* CopyFiles */ = { + E0AB5CD82F3CCD9C00BD8CBA /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; - dstPath = Contents/Library/LaunchDaemons; - dstSubfolderSpec = 1; + dstPath = ""; + dstSubfolderSpec = 13; files = ( + E0AB5CD32F3CCD9C00BD8CBA /* SwiftierNE.appex in Embed Foundation Extensions */, ); + name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; - E0AC7EDF2F192DD600FC425C /* CopyFiles */ = { + E0AC7ED12F19208200FC425C /* CopyFiles */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; - dstPath = /usr/share/man/man1/; - dstSubfolderSpec = 0; + dstPath = Contents/Library/LaunchDaemons; + dstSubfolderSpec = 1; files = ( ); - runOnlyForDeploymentPostprocessing = 1; + runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ E0933DCC2F16A25B00C7DE59 /* Swiftier.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Swiftier.app; sourceTree = BUILT_PRODUCTS_DIR; }; - E0AC7EE12F192DD600FC425C /* SwiftierHelper */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = SwiftierHelper; sourceTree = BUILT_PRODUCTS_DIR; }; + E0AB5CC92F3CCD9C00BD8CBA /* SwiftierNE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SwiftierNE.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + E0AB5CCA2F3CCD9C00BD8CBA /* NetworkExtension.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NetworkExtension.framework; path = System/Library/Frameworks/NetworkExtension.framework; sourceTree = SDKROOT; }; E0AC7EED2F19307C00FC425C /* ServiceManagement.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ServiceManagement.framework; path = System/Library/Frameworks/ServiceManagement.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - E0DA09B32F17A75300C54228 /* Exceptions for "EasyTier" folder in "Swiftier" target */ = { + E0AB5CD72F3CCD9C00BD8CBA /* Exceptions for "SwiftierNE" folder in "SwiftierNE" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, ); - target = E0933DCB2F16A25B00C7DE59 /* Swiftier */; + target = E0AB5CC82F3CCD9C00BD8CBA /* SwiftierNE */; }; -/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ - -/* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ - E025243D2F1BDABB00F5065A /* Exceptions for "EasyTier" folder in "Copy Files" phase from "Swiftier" target */ = { - isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet; - attributesByRelativePath = { - com.alick.swiftier.helper.plist = (CodeSignOnCopy, ); - }; - buildPhase = E0AC7ED12F19208200FC425C /* CopyFiles */; + E0DA09B32F17A75300C54228 /* Exceptions for "Swiftier" folder in "Swiftier" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - com.alick.swiftier.helper.plist, + Info.plist, ); + target = E0933DCB2F16A25B00C7DE59 /* Swiftier */; }; -/* End PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - E0933DCE2F16A25B00C7DE59 /* EasyTier */ = { + E0933DCE2F16A25B00C7DE59 /* Swiftier */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( - E0DA09B32F17A75300C54228 /* Exceptions for "EasyTier" folder in "Swiftier" target */, - E025243D2F1BDABB00F5065A /* Exceptions for "EasyTier" folder in "Copy Files" phase from "Swiftier" target */, + E0DA09B32F17A75300C54228 /* Exceptions for "Swiftier" folder in "Swiftier" target */, ); - path = EasyTier; + path = Swiftier; sourceTree = ""; }; - E0AC7EA42F18E97F00FC425C /* EasyTierHelper */ = { + E0AB5CCC2F3CCD9C00BD8CBA /* SwiftierNE */ = { isa = PBXFileSystemSynchronizedRootGroup; - path = EasyTierHelper; - sourceTree = ""; - }; - E0AC7EE22F192DD600FC425C /* EasyTierHelper */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = EasyTierHelper; + exceptions = ( + E0AB5CD72F3CCD9C00BD8CBA /* Exceptions for "SwiftierNE" folder in "SwiftierNE" target */, + ); + path = SwiftierNE; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -113,10 +107,11 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - E0AC7EDE2F192DD600FC425C /* Frameworks */ = { + E0AB5CC62F3CCD9C00BD8CBA /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + E0AB5CCB2F3CCD9C00BD8CBA /* NetworkExtension.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -126,9 +121,8 @@ E0933DC32F16A25B00C7DE59 = { isa = PBXGroup; children = ( - E0933DCE2F16A25B00C7DE59 /* EasyTier */, - E0AC7EA42F18E97F00FC425C /* EasyTierHelper */, - E0AC7EE22F192DD600FC425C /* EasyTierHelper */, + E0933DCE2F16A25B00C7DE59 /* Swiftier */, + E0AB5CCC2F3CCD9C00BD8CBA /* SwiftierNE */, E0AC7EEC2F19307C00FC425C /* Frameworks */, E0933DCD2F16A25B00C7DE59 /* Products */, ); @@ -138,7 +132,7 @@ isa = PBXGroup; children = ( E0933DCC2F16A25B00C7DE59 /* Swiftier.app */, - E0AC7EE12F192DD600FC425C /* SwiftierHelper */, + E0AB5CC92F3CCD9C00BD8CBA /* SwiftierNE.appex */, ); name = Products; sourceTree = ""; @@ -147,6 +141,7 @@ isa = PBXGroup; children = ( E0AC7EED2F19307C00FC425C /* ServiceManagement.framework */, + E0AB5CCA2F3CCD9C00BD8CBA /* NetworkExtension.framework */, ); name = Frameworks; sourceTree = ""; @@ -163,43 +158,44 @@ E0933DCA2F16A25B00C7DE59 /* Resources */, E0933E002F16A42100C7DE59 /* Copy Helper Executable */, E0AC7ED12F19208200FC425C /* CopyFiles */, + E0AB5CD82F3CCD9C00BD8CBA /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( - E0AC7EF62F1933C600FC425C /* PBXTargetDependency */, + E0AB5CD22F3CCD9C00BD8CBA /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( - E0933DCE2F16A25B00C7DE59 /* EasyTier */, + E0933DCE2F16A25B00C7DE59 /* Swiftier */, ); name = Swiftier; packageProductDependencies = ( ); - productName = EasyTier; + productName = Swiftier; productReference = E0933DCC2F16A25B00C7DE59 /* Swiftier.app */; productType = "com.apple.product-type.application"; }; - E0AC7EE02F192DD600FC425C /* SwiftierHelper */ = { + E0AB5CC82F3CCD9C00BD8CBA /* SwiftierNE */ = { isa = PBXNativeTarget; - buildConfigurationList = E0AC7EE52F192DD600FC425C /* Build configuration list for PBXNativeTarget "SwiftierHelper" */; + buildConfigurationList = E0AB5CD42F3CCD9C00BD8CBA /* Build configuration list for PBXNativeTarget "SwiftierNE" */; buildPhases = ( - E0AC7EDD2F192DD600FC425C /* Sources */, - E0AC7EDE2F192DD600FC425C /* Frameworks */, - E0AC7EDF2F192DD600FC425C /* CopyFiles */, + E0AB5CC52F3CCD9C00BD8CBA /* Sources */, + E0AB5CC62F3CCD9C00BD8CBA /* Frameworks */, + E0AB5CC72F3CCD9C00BD8CBA /* Resources */, ); buildRules = ( ); dependencies = ( ); fileSystemSynchronizedGroups = ( - E0AC7EE22F192DD600FC425C /* EasyTierHelper */, + E0AB5CCC2F3CCD9C00BD8CBA /* SwiftierNE */, ); - name = SwiftierHelper; + name = SwiftierNE; packageProductDependencies = ( ); - productName = EasyTierHelper; - productReference = E0AC7EE12F192DD600FC425C /* SwiftierHelper */; - productType = "com.apple.product-type.tool"; + productName = SwiftierNE; + productReference = E0AB5CC92F3CCD9C00BD8CBA /* SwiftierNE.appex */; + productType = "com.apple.product-type.app-extension"; }; /* End PBXNativeTarget section */ @@ -208,14 +204,36 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 2620; + LastSwiftUpdateCheck = 2630; LastUpgradeCheck = 2630; TargetAttributes = { E0933DCB2F16A25B00C7DE59 = { CreatedOnToolsVersion = 26.2; + SystemCapabilities = { + com.apple.ApplicationGroups.iOS = { + enabled = 1; + }; + com.apple.NetworkExtensions = { + enabled = 1; + }; + com.apple.Sandbox = { + enabled = 1; + }; + }; }; - E0AC7EE02F192DD600FC425C = { - CreatedOnToolsVersion = 26.2; + E0AB5CC82F3CCD9C00BD8CBA = { + CreatedOnToolsVersion = 26.3; + SystemCapabilities = { + com.apple.ApplicationGroups.iOS = { + enabled = 1; + }; + com.apple.NetworkExtensions = { + enabled = 1; + }; + com.apple.Sandbox = { + enabled = 1; + }; + }; }; }; }; @@ -234,7 +252,7 @@ projectRoot = ""; targets = ( E0933DCB2F16A25B00C7DE59 /* Swiftier */, - E0AC7EE02F192DD600FC425C /* SwiftierHelper */, + E0AB5CC82F3CCD9C00BD8CBA /* SwiftierNE */, ); }; /* End PBXProject section */ @@ -247,6 +265,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E0AB5CC72F3CCD9C00BD8CBA /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -257,7 +282,7 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - E0AC7EDD2F192DD600FC425C /* Sources */ = { + E0AB5CC52F3CCD9C00BD8CBA /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -267,10 +292,10 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - E0AC7EF62F1933C600FC425C /* PBXTargetDependency */ = { + E0AB5CD22F3CCD9C00BD8CBA /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = E0AC7EE02F192DD600FC425C /* SwiftierHelper */; - targetProxy = E0AC7EF52F1933C600FC425C /* PBXContainerItemProxy */; + target = E0AB5CC82F3CCD9C00BD8CBA /* SwiftierNE */; + targetProxy = E0AB5CD12F3CCD9C00BD8CBA /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ @@ -404,26 +429,36 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = Swiftier; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; - AUTOMATION_APPLE_EVENTS = YES; - CODE_SIGN_ENTITLEMENTS = EasyTier/EasyTier.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + AUTOMATION_APPLE_EVENTS = NO; + CODE_SIGN_ENTITLEMENTS = Swiftier/Swiftier.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = KLU8GF65GP; - ENABLE_APP_SANDBOX = NO; + ENABLE_APP_SANDBOX = YES; + ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; + ENABLE_FILE_ACCESS_MOVIES_FOLDER = readwrite; + ENABLE_FILE_ACCESS_MUSIC_FOLDER = readwrite; + ENABLE_FILE_ACCESS_PICTURE_FOLDER = readwrite; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; ENABLE_RESOURCE_ACCESS_CALENDARS = NO; ENABLE_RESOURCE_ACCESS_CAMERA = NO; ENABLE_RESOURCE_ACCESS_CONTACTS = NO; ENABLE_RESOURCE_ACCESS_LOCATION = NO; ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; + ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = EasyTier/Info.plist; + INFOPLIST_FILE = Swiftier/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Swiftier; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSUIElement = YES; @@ -434,14 +469,15 @@ ); LIBRARY_SEARCH_PATHS = ""; MACOSX_DEPLOYMENT_TARGET = 13.5; - MARKETING_VERSION = 2.9.9; + MARKETING_VERSION = 1.0.0; OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = com.alick.swiftier; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; - RUNTIME_EXCEPTION_ALLOW_JIT = YES; - RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = YES; + RUNTIME_EXCEPTION_ALLOW_JIT = NO; + RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; @@ -459,26 +495,36 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = Swiftier; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; - AUTOMATION_APPLE_EVENTS = YES; - CODE_SIGN_ENTITLEMENTS = EasyTier/EasyTier.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + AUTOMATION_APPLE_EVENTS = NO; + CODE_SIGN_ENTITLEMENTS = Swiftier/Swiftier.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = KLU8GF65GP; - ENABLE_APP_SANDBOX = NO; + ENABLE_APP_SANDBOX = YES; + ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; + ENABLE_FILE_ACCESS_MOVIES_FOLDER = readwrite; + ENABLE_FILE_ACCESS_MUSIC_FOLDER = readwrite; + ENABLE_FILE_ACCESS_PICTURE_FOLDER = readwrite; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; ENABLE_RESOURCE_ACCESS_CALENDARS = NO; ENABLE_RESOURCE_ACCESS_CAMERA = NO; ENABLE_RESOURCE_ACCESS_CONTACTS = NO; ENABLE_RESOURCE_ACCESS_LOCATION = NO; ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; + ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = EasyTier/Info.plist; + INFOPLIST_FILE = Swiftier/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Swiftier; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSUIElement = YES; @@ -489,14 +535,15 @@ ); LIBRARY_SEARCH_PATHS = ""; MACOSX_DEPLOYMENT_TARGET = 13.5; - MARKETING_VERSION = 2.9.9; + MARKETING_VERSION = 1.0.0; OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = com.alick.swiftier; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; - RUNTIME_EXCEPTION_ALLOW_JIT = YES; - RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = YES; + RUNTIME_EXCEPTION_ALLOW_JIT = NO; + RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; @@ -509,25 +556,44 @@ }; name = Release; }; - E0AC7EE62F192DD600FC425C /* Debug */ = { + E0AB5CD52F3CCD9C00BD8CBA /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - AUTOMATION_APPLE_EVENTS = YES; - CODE_SIGN_ENTITLEMENTS = EasyTierHelper/EasyTierHelper.entitlements; + CODE_SIGN_ENTITLEMENTS = SwiftierNE/SwiftierNE.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - DEAD_CODE_STRIPPING = YES; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = KLU8GF65GP; - ENABLE_CODE_COVERAGE = NO; + ENABLE_APP_SANDBOX = YES; + ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; + ENABLE_FILE_ACCESS_MOVIES_FOLDER = readwrite; + ENABLE_FILE_ACCESS_MUSIC_FOLDER = readwrite; + ENABLE_FILE_ACCESS_PICTURE_FOLDER = readwrite; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; ENABLE_RESOURCE_ACCESS_CALENDARS = NO; ENABLE_RESOURCE_ACCESS_CAMERA = NO; ENABLE_RESOURCE_ACCESS_CONTACTS = NO; ENABLE_RESOURCE_ACCESS_LOCATION = NO; - ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; - INFOPLIST_FILE = EasyTierHelper/Info.plist; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; + ENABLE_USER_SELECTED_FILES = readwrite; + GENERATE_INFOPLIST_FILE = YES; + HEADER_SEARCH_PATHS = "$(PROJECT_DIR)/EasyTierCore/include/**"; + INFOPLIST_FILE = SwiftierNE/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = SwiftierNE; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/EasyTierCore/target/release"; - MACOSX_DEPLOYMENT_TARGET = 13.5; + MARKETING_VERSION = 1.0.0; + "OBJC_BRIDGING_HEADER[sdk=macosx*]" = "SwiftierNE/SwiftierNE-Bridging-Header.h"; OTHER_LDFLAGS = ( "-leasytier_ios", "-lc++", @@ -539,40 +605,57 @@ "-framework", CoreFoundation, ); - PRODUCT_BUNDLE_IDENTIFIER = com.alick.swiftier.helper; + PRODUCT_BUNDLE_IDENTIFIER = com.alick.swiftier.SwiftierNE; PRODUCT_NAME = "$(TARGET_NAME)"; - RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; - RUNTIME_EXCEPTION_ALLOW_JIT = YES; - RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = YES; - RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; - RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; - RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; + PROVISIONING_PROFILE_SPECIFIER = ""; + REGISTER_APP_GROUPS = YES; SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "SwiftierNE/SwiftierNE-Bridging-Header.h"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; }; name = Debug; }; - E0AC7EE72F192DD600FC425C /* Release */ = { + E0AB5CD62F3CCD9C00BD8CBA /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - AUTOMATION_APPLE_EVENTS = YES; - CODE_SIGN_ENTITLEMENTS = EasyTierHelper/EasyTierHelper.entitlements; + CODE_SIGN_ENTITLEMENTS = SwiftierNE/SwiftierNE.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - DEAD_CODE_STRIPPING = YES; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = KLU8GF65GP; - ENABLE_CODE_COVERAGE = NO; + ENABLE_APP_SANDBOX = YES; + ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; + ENABLE_FILE_ACCESS_MOVIES_FOLDER = readwrite; + ENABLE_FILE_ACCESS_MUSIC_FOLDER = readwrite; + ENABLE_FILE_ACCESS_PICTURE_FOLDER = readwrite; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; ENABLE_RESOURCE_ACCESS_CALENDARS = NO; ENABLE_RESOURCE_ACCESS_CAMERA = NO; ENABLE_RESOURCE_ACCESS_CONTACTS = NO; ENABLE_RESOURCE_ACCESS_LOCATION = NO; - ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; - INFOPLIST_FILE = EasyTierHelper/Info.plist; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; + ENABLE_USER_SELECTED_FILES = readwrite; + GENERATE_INFOPLIST_FILE = YES; + HEADER_SEARCH_PATHS = "$(PROJECT_DIR)/EasyTierCore/include/**"; + INFOPLIST_FILE = SwiftierNE/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = SwiftierNE; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/EasyTierCore/target/release"; - MACOSX_DEPLOYMENT_TARGET = 13.5; + MARKETING_VERSION = 1.0.0; OTHER_LDFLAGS = ( "-leasytier_ios", "-lc++", @@ -584,16 +667,15 @@ "-framework", CoreFoundation, ); - PRODUCT_BUNDLE_IDENTIFIER = com.alick.swiftier.helper; + PRODUCT_BUNDLE_IDENTIFIER = com.alick.swiftier.SwiftierNE; PRODUCT_NAME = "$(TARGET_NAME)"; - RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; - RUNTIME_EXCEPTION_ALLOW_JIT = YES; - RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = YES; - RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; - RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; - RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; + PROVISIONING_PROFILE_SPECIFIER = ""; + REGISTER_APP_GROUPS = YES; SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "SwiftierNE/SwiftierNE-Bridging-Header.h"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; }; @@ -620,11 +702,11 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - E0AC7EE52F192DD600FC425C /* Build configuration list for PBXNativeTarget "SwiftierHelper" */ = { + E0AB5CD42F3CCD9C00BD8CBA /* Build configuration list for PBXNativeTarget "SwiftierNE" */ = { isa = XCConfigurationList; buildConfigurations = ( - E0AC7EE62F192DD600FC425C /* Debug */, - E0AC7EE72F192DD600FC425C /* Release */, + E0AB5CD52F3CCD9C00BD8CBA /* Debug */, + E0AB5CD62F3CCD9C00BD8CBA /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; diff --git a/Swiftier.xcodeproj/project.pbxproj.bak b/Swiftier.xcodeproj/project.pbxproj.bak new file mode 100644 index 0000000..eaf72d7 --- /dev/null +++ b/Swiftier.xcodeproj/project.pbxproj.bak @@ -0,0 +1,626 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + E025243B2F1BDA8500F5065A /* SwiftierHelper in Copy Helper Executable */ = {isa = PBXBuildFile; fileRef = E0AC7EE12F192DD600FC425C /* SwiftierHelper */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + E0AC7EEE2F19307C00FC425C /* ServiceManagement.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E0AC7EED2F19307C00FC425C /* ServiceManagement.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + E0AC7EF52F1933C600FC425C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E0933DC42F16A25B00C7DE59 /* Project object */; + proxyType = 1; + remoteGlobalIDString = E0AC7EE02F192DD600FC425C; + remoteInfo = EasyTierHelper; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + E0933E002F16A42100C7DE59 /* Copy Helper Executable */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 6; + files = ( + E025243B2F1BDA8500F5065A /* SwiftierHelper in Copy Helper Executable */, + ); + name = "Copy Helper Executable"; + runOnlyForDeploymentPostprocessing = 0; + }; + E0AC7ED12F19208200FC425C /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = Contents/Library/LaunchDaemons; + dstSubfolderSpec = 1; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E0AC7EDF2F192DD600FC425C /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = /usr/share/man/man1/; + dstSubfolderSpec = 0; + files = ( + ); + runOnlyForDeploymentPostprocessing = 1; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + E0933DCC2F16A25B00C7DE59 /* Swiftier.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Swiftier.app; sourceTree = BUILT_PRODUCTS_DIR; }; + E0AC7EE12F192DD600FC425C /* SwiftierHelper */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = SwiftierHelper; sourceTree = BUILT_PRODUCTS_DIR; }; + E0AC7EED2F19307C00FC425C /* ServiceManagement.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ServiceManagement.framework; path = System/Library/Frameworks/ServiceManagement.framework; sourceTree = SDKROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + E0DA09B32F17A75300C54228 /* Exceptions for "Swiftier" folder in "Swiftier" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = E0933DCB2F16A25B00C7DE59 /* Swiftier */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ + E025243D2F1BDABB00F5065A /* Exceptions for "Swiftier" folder in "Copy Files" phase from "Swiftier" target */ = { + isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet; + attributesByRelativePath = { + com.alick.swiftier.helper.plist = (CodeSignOnCopy, ); + }; + buildPhase = E0AC7ED12F19208200FC425C /* CopyFiles */; + membershipExceptions = ( + com.alick.swiftier.helper.plist, + ); + }; +/* End PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + E0933DCE2F16A25B00C7DE59 /* Swiftier */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + E0DA09B32F17A75300C54228 /* Exceptions for "Swiftier" folder in "Swiftier" target */, + E025243D2F1BDABB00F5065A /* Exceptions for "Swiftier" folder in "Copy Files" phase from "Swiftier" target */, + ); + path = Swiftier; + sourceTree = ""; + }; + E0AC7EA42F18E97F00FC425C /* SwiftierHelper */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = SwiftierHelper; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + E0933DC92F16A25B00C7DE59 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E0AC7EEE2F19307C00FC425C /* ServiceManagement.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E0AC7EDE2F192DD600FC425C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + E0933DC32F16A25B00C7DE59 = { + isa = PBXGroup; + children = ( + E0933DCE2F16A25B00C7DE59 /* Swiftier */, + E0AC7EA42F18E97F00FC425C /* SwiftierHelper */, + E0AC7EEC2F19307C00FC425C /* Frameworks */, + E0933DCD2F16A25B00C7DE59 /* Products */, + ); + sourceTree = ""; + }; + E0933DCD2F16A25B00C7DE59 /* Products */ = { + isa = PBXGroup; + children = ( + E0933DCC2F16A25B00C7DE59 /* Swiftier.app */, + E0AC7EE12F192DD600FC425C /* SwiftierHelper */, + ); + name = Products; + sourceTree = ""; + }; + E0AC7EEC2F19307C00FC425C /* Frameworks */ = { + isa = PBXGroup; + children = ( + E0AC7EED2F19307C00FC425C /* ServiceManagement.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + E0933DCB2F16A25B00C7DE59 /* Swiftier */ = { + isa = PBXNativeTarget; + buildConfigurationList = E0933DED2F16A26200C7DE59 /* Build configuration list for PBXNativeTarget "Swiftier" */; + buildPhases = ( + E0933DC82F16A25B00C7DE59 /* Sources */, + E0933DC92F16A25B00C7DE59 /* Frameworks */, + E0933DCA2F16A25B00C7DE59 /* Resources */, + E0933E002F16A42100C7DE59 /* Copy Helper Executable */, + E0AC7ED12F19208200FC425C /* CopyFiles */, + ); + buildRules = ( + ); + dependencies = ( + E0AC7EF62F1933C600FC425C /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + E0933DCE2F16A25B00C7DE59 /* Swiftier */, + ); + name = Swiftier; + packageProductDependencies = ( + ); + productName = EasyTier; + productReference = E0933DCC2F16A25B00C7DE59 /* Swiftier.app */; + productType = "com.apple.product-type.application"; + }; + E0AC7EE02F192DD600FC425C /* SwiftierHelper */ = { + isa = PBXNativeTarget; + buildConfigurationList = E0AC7EE52F192DD600FC425C /* Build configuration list for PBXNativeTarget "SwiftierHelper" */; + buildPhases = ( + E0AC7EDD2F192DD600FC425C /* Sources */, + E0AC7EDE2F192DD600FC425C /* Frameworks */, + E0AC7EDF2F192DD600FC425C /* CopyFiles */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = SwiftierHelper; + packageProductDependencies = ( + ); + productName = EasyTierHelper; + productReference = E0AC7EE12F192DD600FC425C /* SwiftierHelper */; + productType = "com.apple.product-type.tool"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + E0933DC42F16A25B00C7DE59 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2620; + LastUpgradeCheck = 2630; + TargetAttributes = { + E0933DCB2F16A25B00C7DE59 = { + CreatedOnToolsVersion = 26.2; + }; + E0AC7EE02F192DD600FC425C = { + CreatedOnToolsVersion = 26.2; + }; + }; + }; + buildConfigurationList = E0933DC72F16A25B00C7DE59 /* Build configuration list for PBXProject "Swiftier" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = E0933DC32F16A25B00C7DE59; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = E0933DCD2F16A25B00C7DE59 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + E0933DCB2F16A25B00C7DE59 /* Swiftier */, + E0AC7EE02F192DD600FC425C /* SwiftierHelper */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + E0933DCA2F16A25B00C7DE59 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + E0933DC82F16A25B00C7DE59 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E0AC7EDD2F192DD600FC425C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + E0AC7EF62F1933C600FC425C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E0AC7EE02F192DD600FC425C /* SwiftierHelper */; + targetProxy = E0AC7EF52F1933C600FC425C /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + E0933DEB2F16A26200C7DE59 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = J33CVYBKCZ; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + E0933DEC2F16A26200C7DE59 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = J33CVYBKCZ; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + E0933DEE2F16A26200C7DE59 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = Swiftier; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + AUTOMATION_APPLE_EVENTS = YES; + CODE_SIGN_ENTITLEMENTS = EasyTier/EasyTier.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CREATE_INFOPLIST_SECTION_IN_BINARY = YES; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = KLU8GF65GP; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = EasyTier/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Swiftier; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_LSUIElement = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + LIBRARY_SEARCH_PATHS = ""; + MACOSX_DEPLOYMENT_TARGET = 13.5; + MARKETING_VERSION = 2.9.10; + OTHER_LDFLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = com.alick.swiftier; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; + RUNTIME_EXCEPTION_ALLOW_JIT = YES; + RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = YES; + RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; + RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; + RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + E0933DEF2F16A26200C7DE59 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = Swiftier; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + AUTOMATION_APPLE_EVENTS = YES; + CODE_SIGN_ENTITLEMENTS = EasyTier/EasyTier.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CREATE_INFOPLIST_SECTION_IN_BINARY = YES; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = KLU8GF65GP; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = EasyTier/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Swiftier; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_LSUIElement = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + LIBRARY_SEARCH_PATHS = ""; + MACOSX_DEPLOYMENT_TARGET = 13.5; + MARKETING_VERSION = 2.9.10; + OTHER_LDFLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = com.alick.swiftier; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; + RUNTIME_EXCEPTION_ALLOW_JIT = YES; + RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = YES; + RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; + RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; + RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + E0AC7EE62F192DD600FC425C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + AUTOMATION_APPLE_EVENTS = YES; + CODE_SIGN_ENTITLEMENTS = EasyTierHelper/EasyTierHelper.entitlements; + CODE_SIGN_STYLE = Automatic; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = KLU8GF65GP; + ENABLE_CODE_COVERAGE = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; + INFOPLIST_FILE = EasyTierHelper/Info.plist; + LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/EasyTierCore/target/release"; + MACOSX_DEPLOYMENT_TARGET = 13.5; + OTHER_LDFLAGS = ( + "-leasytier_ios", + "-lc++", + "-lresolv", + "-framework", + SystemConfiguration, + "-framework", + Security, + "-framework", + CoreFoundation, + ); + PRODUCT_BUNDLE_IDENTIFIER = com.alick.swiftier.helper; + PRODUCT_NAME = "$(TARGET_NAME)"; + RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; + RUNTIME_EXCEPTION_ALLOW_JIT = YES; + RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = YES; + RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; + RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; + RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + E0AC7EE72F192DD600FC425C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + AUTOMATION_APPLE_EVENTS = YES; + CODE_SIGN_ENTITLEMENTS = EasyTierHelper/EasyTierHelper.entitlements; + CODE_SIGN_STYLE = Automatic; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = KLU8GF65GP; + ENABLE_CODE_COVERAGE = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; + INFOPLIST_FILE = EasyTierHelper/Info.plist; + LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/EasyTierCore/target/release"; + MACOSX_DEPLOYMENT_TARGET = 13.5; + OTHER_LDFLAGS = ( + "-leasytier_ios", + "-lc++", + "-lresolv", + "-framework", + SystemConfiguration, + "-framework", + Security, + "-framework", + CoreFoundation, + ); + PRODUCT_BUNDLE_IDENTIFIER = com.alick.swiftier.helper; + PRODUCT_NAME = "$(TARGET_NAME)"; + RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; + RUNTIME_EXCEPTION_ALLOW_JIT = YES; + RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = YES; + RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; + RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; + RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + E0933DC72F16A25B00C7DE59 /* Build configuration list for PBXProject "Swiftier" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E0933DEB2F16A26200C7DE59 /* Debug */, + E0933DEC2F16A26200C7DE59 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E0933DED2F16A26200C7DE59 /* Build configuration list for PBXNativeTarget "Swiftier" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E0933DEE2F16A26200C7DE59 /* Debug */, + E0933DEF2F16A26200C7DE59 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E0AC7EE52F192DD600FC425C /* Build configuration list for PBXNativeTarget "SwiftierHelper" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E0AC7EE62F192DD600FC425C /* Debug */, + E0AC7EE72F192DD600FC425C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = E0933DC42F16A25B00C7DE59 /* Project object */; +} diff --git a/Swiftier.xcodeproj/xcuserdata/alick.xcuserdatad/xcschemes/xcschememanagement.plist b/Swiftier.xcodeproj/xcuserdata/alick.xcuserdatad/xcschemes/xcschememanagement.plist index a56dfd6..72d4b40 100644 --- a/Swiftier.xcodeproj/xcuserdata/alick.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Swiftier.xcodeproj/xcuserdata/alick.xcuserdatad/xcschemes/xcschememanagement.plist @@ -24,6 +24,11 @@ orderHint 1 + SwiftierNE.xcscheme_^#shared#^_ + + orderHint + 1 + SuppressBuildableAutocreation diff --git a/EasyTier/Assets.xcassets/AccentColor.colorset/Contents.json b/Swiftier/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from EasyTier/Assets.xcassets/AccentColor.colorset/Contents.json rename to Swiftier/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/EasyTier/Assets.xcassets/Contents.json b/Swiftier/Assets.xcassets/Contents.json similarity index 100% rename from EasyTier/Assets.xcassets/Contents.json rename to Swiftier/Assets.xcassets/Contents.json diff --git a/EasyTier/CliClient.swift b/Swiftier/CliClient.swift similarity index 99% rename from EasyTier/CliClient.swift rename to Swiftier/CliClient.swift index 94636ea..3a57493 100644 --- a/EasyTier/CliClient.swift +++ b/Swiftier/CliClient.swift @@ -29,9 +29,9 @@ struct PeerInfo: Identifiable, Equatable { // Add more as needed based on observation // 针对远程节点的完整数据信息 - var fullData: EasyTierStatus.PeerRoutePair? = nil + var fullData: SwiftierStatus.PeerRoutePair? = nil // 针对“本机”节点的完整信息 - var myNodeData: EasyTierStatus.NodeInfo? = nil + var myNodeData: SwiftierStatus.NodeInfo? = nil // 运行中必须保证稳定且唯一的 id:否则 SwiftUI 会把同一节点当成“删除+新增”,或出现跳格/错位 // 优先使用 route.peerId(数值通常最稳定、且唯一),再降级到 instId / nodeId,最后兜底 hostname+ipv4。 diff --git a/EasyTier/CodeEditor.swift b/Swiftier/CodeEditor.swift similarity index 98% rename from EasyTier/CodeEditor.swift rename to Swiftier/CodeEditor.swift index f8996a4..600eb90 100644 --- a/EasyTier/CodeEditor.swift +++ b/Swiftier/CodeEditor.swift @@ -77,8 +77,8 @@ struct CodeEditor: NSViewRepresentable { // Usually verbose, keep it subtle or blue applyStyle(pattern: "(?i)TRACE", color: NSColor.systemBlue, bold: true) - // Highlight EasyTier keywords - applyStyle(pattern: "EasyTier", color: NSColor.labelColor, bold: true) + // Highlight Swiftier keywords + applyStyle(pattern: "Swiftier", color: NSColor.labelColor, bold: true) } else if mode == .json { // JSON Syntax Highlighting storage.removeAttribute(.foregroundColor, range: fullRange) diff --git a/EasyTier/ConfigEditorView.swift b/Swiftier/ConfigEditorView.swift similarity index 82% rename from EasyTier/ConfigEditorView.swift rename to Swiftier/ConfigEditorView.swift index bc1374b..e09c9d1 100644 --- a/EasyTier/ConfigEditorView.swift +++ b/Swiftier/ConfigEditorView.swift @@ -44,7 +44,7 @@ struct ConfigEditorView: View { private func loadContent() { do { - content = try String(contentsOf: fileURL, encoding: .utf8) + content = try ConfigManager.shared.readConfigContent(fileURL) originalContent = content } catch { errorMessage = error.localizedDescription @@ -52,17 +52,18 @@ struct ConfigEditorView: View { } private func saveContent() { + // 获取安全域访问 + let dirURL = ConfigManager.shared.currentDirectory + let isScoped = dirURL?.startAccessingSecurityScopedResource() ?? false + defer { if isScoped { dirURL?.stopAccessingSecurityScopedResource() } } + do { try content.write(to: fileURL, atomically: true, encoding: .utf8) - originalContent = content // 更新原始状态 + originalContent = content withAnimation { isPresented = false } - - // 通知 ConfigManager 或 Runner 可能需要重载? - // 目前 EasyTier 可能需要重启服务才能生效,或者它支持热重载? - // 这里我们只负责保存文件。 } catch { errorMessage = "保存失败: \(error.localizedDescription)" } diff --git a/EasyTier/ConfigGeneratorView.swift b/Swiftier/ConfigGeneratorView.swift similarity index 98% rename from EasyTier/ConfigGeneratorView.swift rename to Swiftier/ConfigGeneratorView.swift index 54fb16c..ec62e1e 100644 --- a/EasyTier/ConfigGeneratorView.swift +++ b/Swiftier/ConfigGeneratorView.swift @@ -12,7 +12,7 @@ struct PortForwardRule: Identifiable, Equatable { var targetPort: String = "" } -struct EasyTierConfigModel: Equatable { +struct SwiftierConfigModel: Equatable { var instanceName: String = Host.current().localizedName ?? "swiftier-node" var instanceId: String = UUID().uuidString.lowercased() @@ -107,7 +107,7 @@ struct EasyTierConfigModel: Equatable { var onlyP2P: Bool = false } -extension EasyTierConfigModel { +extension SwiftierConfigModel { var vpnPortalIpBinding: String { get { let parts = vpnPortalClientCidr.split(separator: "/") @@ -147,13 +147,13 @@ class ConfigDraftManager { // Support multiple drafts for different files // Key nil represents "New Config" draft - private var drafts: [URL?: EasyTierConfigModel] = [:] - - func getDraft(for url: URL?) -> EasyTierConfigModel? { + private var drafts: [URL?: SwiftierConfigModel] = [:] + + func getDraft(for url: URL?) -> SwiftierConfigModel? { return drafts[url] } - func saveDraft(for url: URL?, model: EasyTierConfigModel) { + func saveDraft(for url: URL?, model: SwiftierConfigModel) { drafts[url] = model } @@ -190,7 +190,7 @@ struct ConfigGeneratorView: View { var editingFileURL: URL? = nil // 支持传入文件进行编辑 var onSave: () -> Void - @State private var model = EasyTierConfigModel() + @State private var model = SwiftierConfigModel() // Track if we've already loaded to avoid resetting user edits @State private var hasLoadedInitially = false @@ -275,7 +275,7 @@ struct ConfigGeneratorView: View { if editingFileURL != lastLoadedURL { if editingFileURL == nil { // New file without draft -> Reset - model = EasyTierConfigModel() + model = SwiftierConfigModel() } else { loadFromFile() } @@ -412,7 +412,7 @@ struct ConfigGeneratorView: View { .buttonStyle(.plain) } } - Button { model.proxySubnets.append(EasyTierConfigModel.ProxySubnet(cidr: "0.0.0.0/0")) } label: { + Button { model.proxySubnets.append(SwiftierConfigModel.ProxySubnet(cidr: "0.0.0.0/0")) } label: { HStack { Image(systemName: "plus.circle.fill") Text("添加代理网段") @@ -529,7 +529,7 @@ struct ConfigGeneratorView: View { } // 8. SOCKS5 服务器 (SOCKS5 Server) - SwiftUI.Section(header: Text(LocalizedStringKey("SOCKS5 服务器")), footer: Text(LocalizedStringKey("开启 SOCKS5 代理功能,Surge 等外部程序可通过此端口连接 EasyTier 网络。"))) { + SwiftUI.Section(header: Text(LocalizedStringKey("SOCKS5 服务器")), footer: Text(LocalizedStringKey("开启 SOCKS5 代理功能,Surge 等外部程序可通过此端口连接 Swiftier 网络。"))) { Toggle(LocalizedStringKey("启用"), isOn: $model.enableSocks5) if model.enableSocks5 { HStack { @@ -597,7 +597,7 @@ struct ConfigGeneratorView: View { toggleRow("禁用 P2P", "禁用 P2P 模式,所有流量通过手动指定的服务器中转。", isOn: $model.disableP2P) toggleRow("仅 P2P", "仅与已经建立 P2P 连接的对等节点通信,不通过其他节点中转。", isOn: $model.onlyP2P) - toggleRow("仅使用物理网卡", "仅使用物理网卡,避免 EasyTier 通过其他虚拟网建立连接。", isOn: $model.bindDevice) + toggleRow("仅使用物理网卡", "仅使用物理网卡,避免 Swiftier 通过其他虚拟网建立连接。", isOn: $model.bindDevice) toggleRow("无 TUN 模式", "不使用 TUN 网卡,适合无管理员权限时使用。本节点仅允许被访问。访问其他节点需要使用 SOCKS5。", isOn: $model.noTun) toggleRow("启用出口节点", "允许此节点成为出口节点。", isOn: $model.enableExitNode) @@ -615,7 +615,7 @@ struct ConfigGeneratorView: View { toggleRow("禁用 UDP 打洞", "禁用 UDP 打洞功能。", isOn: $model.disableUdpHolePunching) toggleRow("禁用对称 NAT 打洞", "禁用对标 NAT 的打洞 (生日攻击),将对称 NAT 视为锥形 NAT 处理。", isOn: $model.disableSymHolePunching) - toggleRow("启用 Magic DNS", "启用魔法 DNS,允许通过 EasyTier 的 DNS 服务器访问其他节点的虚拟 IPv4 地址,例如:node1.et.net。", isOn: $model.enableMagicDns) + toggleRow("启用 Magic DNS", "启用魔法 DNS,允许通过 Swiftier 的 DNS 服务器访问其他节点的虚拟 IPv4 地址,例如:node1.et.net。", isOn: $model.enableMagicDns) toggleRow("启用私有模式", "启用私有模式,则不允许使用了与本网络不同的网络名称和密码的节点通过本节点进行握手或中转。", isOn: $model.enablePrivateMode) } } @@ -969,6 +969,11 @@ struct ConfigGeneratorView: View { let content = generateTOML(peers: peersToSave) + // 获取安全域访问 + let dirURL = ConfigManager.shared.currentDirectory + let isScoped = dirURL?.startAccessingSecurityScopedResource() ?? false + defer { if isScoped { dirURL?.stopAccessingSecurityScopedResource() } } + do { try content.write(to: fileURL, atomically: true, encoding: .utf8) @@ -985,12 +990,12 @@ struct ConfigGeneratorView: View { private func loadFromFile() { guard let url = editingFileURL else { return } - guard let content = try? String(contentsOf: url) else { return } + guard let content = try? ConfigManager.shared.readConfigContent(url) else { return } self.model = parseTOML(content) } - private func parseTOML(_ content: String) -> EasyTierConfigModel { - var m = EasyTierConfigModel() + private func parseTOML(_ content: String) -> SwiftierConfigModel { + var m = SwiftierConfigModel() let lines = content.components(separatedBy: .newlines) var currentSection = "" @@ -1006,7 +1011,7 @@ struct ConfigGeneratorView: View { if trimmed.hasPrefix("[") { if trimmed.hasPrefix("[[") { currentSection = String(trimmed.dropFirst(2).dropLast(2)) - if currentSection == "proxy_network" { m.proxySubnets.append(EasyTierConfigModel.ProxySubnet()) } + if currentSection == "proxy_network" { m.proxySubnets.append(SwiftierConfigModel.ProxySubnet()) } if currentSection == "port_forward" { m.portForwards.append(PortForwardRule()) } } else { currentSection = String(trimmed.dropFirst(1).dropLast(1)) diff --git a/EasyTier/ConfigManager.swift b/Swiftier/ConfigManager.swift similarity index 67% rename from EasyTier/ConfigManager.swift rename to Swiftier/ConfigManager.swift index 56b5224..49c9e7e 100644 --- a/EasyTier/ConfigManager.swift +++ b/Swiftier/ConfigManager.swift @@ -11,9 +11,12 @@ class ConfigManager: ObservableObject { @AppStorage("custom_config_bookmark") var customPathBookmark: Data? var currentDirectory: URL? { + // 1. 优先尝试从书签恢复(支持沙盒访问) if let bookmark = customPathBookmark { var isStale = false - if let url = try? URL(resolvingBookmarkData: bookmark, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale) { + do { + let url = try URL(resolvingBookmarkData: bookmark, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale) + if isStale { // Update stale bookmark if needed if let newBookmark = try? url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil) { @@ -21,23 +24,36 @@ class ConfigManager: ObservableObject { } } return url + } catch { + print("解析书签失败: \(error)") + // 书签失效,清除 + DispatchQueue.main.async { self.customPathBookmark = nil } } } + // 2. 尝试使用路径字符串(非沙盒或已授权路径) if !customPathString.isEmpty { return URL(fileURLWithPath: customPathString) } - // 自动探测 iCloud 路径作为默认值 + // 3. 自动探测 iCloud 路径作为默认值 if let drive = iCloudDriveURL { let targetDir = drive.appendingPathComponent("Swiftier") - // 确保目录存在 if !FileManager.default.fileExists(atPath: targetDir.path) { try? FileManager.default.createDirectory(at: targetDir, withIntermediateDirectories: true) } return targetDir } + // 4. Fallback to local Application Support + if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { + let targetDir = appSupport.appendingPathComponent("Swiftier") + if !FileManager.default.fileExists(atPath: targetDir.path) { + try? FileManager.default.createDirectory(at: targetDir, withIntermediateDirectories: true) + } + return targetDir + } + return nil } @@ -51,6 +67,15 @@ class ConfigManager: ObservableObject { } // 自动将 iCloud 路径设为默认路径 self.customPathString = targetDir.path + } else { + // Fallback to local Application Support + if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { + let targetDir = appSupport.appendingPathComponent("Swiftier") + if !FileManager.default.fileExists(atPath: targetDir.path) { + try? FileManager.default.createDirectory(at: targetDir, withIntermediateDirectories: true) + } + self.customPathString = targetDir.path + } } } refreshConfigs() @@ -86,16 +111,17 @@ class ConfigManager: ObservableObject { // 尝试获取 iCloud Drive 路径 private var iCloudDriveURL: URL? { + // Debug: Check ubiquity identity + if FileManager.default.ubiquityIdentityToken == nil { + print("ConfigManager: Ubiquity Identity Token is nil. User might not be logged in or iCloud is disabled for this app.") + } + // 尝试标准路径 (适用于带有 iCloud 权限的 App) if let url = FileManager.default.url(forUbiquityContainerIdentifier: nil)?.appendingPathComponent("Documents") { return url } - // 尝试用户目录路径 (适用于非沙盒 App 或调试) - let home = FileManager.default.homeDirectoryForCurrentUser - let drive = home.appendingPathComponent("Library/Mobile Documents/com~apple~CloudDocs") - if FileManager.default.fileExists(atPath: drive.path) { - return drive - } + + // Removed hardcoded fallback to Mobile Documents as it violates sandbox and causes crashes. return nil } @@ -155,10 +181,16 @@ class ConfigManager: ObservableObject { return [] } + // 关键修复:必须在访问前请求权限 let isScoped = url.startAccessingSecurityScopedResource() defer { if isScoped { url.stopAccessingSecurityScopedResource() } } do { + // 再次确保目录存在(防止被外部删除) + if !FileManager.default.fileExists(atPath: url.path) { + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + } + let items = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) let tomlFiles = items.filter { $0.pathExtension == "toml" }.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) @@ -167,8 +199,25 @@ class ConfigManager: ObservableObject { } return tomlFiles } catch { - print("读取文件夹失败: \(error)") + print("读取配置文件列表失败: \(error) 路径: \(url.path)") + // 如果读取失败,尝试清空列表 + DispatchQueue.main.async { + self.configFiles = [] + } return [] } } + + func readConfigContent(_ fileURL: URL) throws -> String { + // 如果有书签,说明是用户选定的安全域目录 + if let _ = customPathBookmark, let dirURL = currentDirectory { + let isScoped = dirURL.startAccessingSecurityScopedResource() + defer { if isScoped { dirURL.stopAccessingSecurityScopedResource() } } + + return try String(contentsOf: fileURL, encoding: .utf8) + } + + // 普通路径直接读取 + return try String(contentsOf: fileURL, encoding: .utf8) + } } diff --git a/EasyTier/ContentView.swift b/Swiftier/ContentView.swift similarity index 83% rename from EasyTier/ContentView.swift rename to Swiftier/ContentView.swift index b23a218..7a1f5de 100644 --- a/EasyTier/ContentView.swift +++ b/Swiftier/ContentView.swift @@ -1,17 +1,18 @@ import SwiftUI import Combine +import NetworkExtension struct ContentView: View { // 性能优化:不再直接观察整个 runner,避免 uptime/speed 变化触发全量 Diff // 改为手动监听核心状态 - private var runner = EasyTierRunner.shared + private var runner = SwiftierRunner.shared + @ObservedObject private var vpnManager = VPNManager.shared @State private var isRunning = false @State private var isWindowVisible = true @State private var sessionID = UUID() @StateObject private var configManager = ConfigManager.shared - @StateObject private var permissionManager = PermissionManager.shared @State private var selectedConfig: URL? @State private var showLogView = false @@ -19,7 +20,6 @@ struct ContentView: View { @State private var showConfigGenerator = false @State private var editingConfigURL: URL? @State private var showCreatePrompt = false - @State private var showFDAOverlay = false @State private var newConfigName = "" @State private var createConfigError: String? @@ -157,11 +157,6 @@ struct ContentView: View { - // FDA Permission Guide - if showFDAOverlay && !permissionManager.isFDAGranted { - FDAGuideView(isPresented: $showFDAOverlay) - .zIndex(1000) - } } .onChange(of: configManager.configFiles) { newFiles in // 如果列表不为空,且当前没选中的,或者选中的不在新列表里 -> 选第一个 @@ -175,11 +170,9 @@ struct ContentView: View { } // 移除了 onChange(of: selectedConfig) 的自动连接逻辑 .onAppear { - // 检查权限 - permissionManager.checkFullDiskAccess() - if !permissionManager.isFDAGranted { - showFDAOverlay = true - } + // 加载 VPN 配置 + VPNManager.shared.loadManager() + // 初始启动时刷新一次列表 configManager.refreshConfigs() @@ -209,6 +202,17 @@ struct ContentView: View { HStack { Menu { Section("配置文件") { + // Show storage location with icon + if let _ = FileManager.default.ubiquityIdentityToken { + Label("存储位置: iCloud Drive", systemImage: "icloud") + .font(.caption) + .foregroundColor(.secondary) + } else { + Label("存储位置: 本地 (iCloud 未启用)", systemImage: "internaldrive") + .font(.caption) + .foregroundColor(.secondary) + } + if configManager.configFiles.isEmpty { Button("未发现配置") { } .disabled(true) @@ -303,38 +307,23 @@ struct ContentView: View { .buttonStyle(.plain) // 退出按钮 - Button(action: { + Button(action: { // 退出逻辑:根据 exitBehavior 设置决定行为 - let behavior = UserDefaults.standard.string(forKey: "exitBehavior") ?? "stopCore" + // 现在的 Network Extension 行为有所不同: + // keepRunning: 只是退出 UI,VPN 保持连接 (NetworkExtension 默认行为) + // stopCore/stopAll: 都是停止 VPN - switch behavior { - case "keepRunning": - // 保持连接运行,直接退出 UI - NSApplication.shared.terminate(nil) - - case "stopCore": - // 断开连接,但保留 Helper - CoreService.shared.stop { _ in - DispatchQueue.main.async { - NSApplication.shared.terminate(nil) - } - } - - case "stopAll": - // 完全退出(停止 Helper + Core) - if #available(macOS 13.0, *) { - CoreService.shared.quitHelper { - DispatchQueue.main.async { - NSApplication.shared.terminate(nil) - } - } - } else { - CoreService.shared.stop() + let behavior = UserDefaults.standard.string(forKey: "exitBehavior") ?? "stopVPN" + + if behavior == "keepRunning" { + NSApplication.shared.terminate(nil) + } else { + // 默认为停止 VPN + VPNManager.shared.stopVPN() + // 稍微延迟一下给 NE 发送停止信号 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { NSApplication.shared.terminate(nil) } - - default: - NSApplication.shared.terminate(nil) } }) { Image(systemName: "power") @@ -486,7 +475,8 @@ struct ContentView: View { let isPaused: Bool // 新增:是否暂停 // 直接订阅 runner,只有这个组件会被频繁刷新 - @ObservedObject private var runner = EasyTierRunner.shared + @ObservedObject private var runner = SwiftierRunner.shared + @ObservedObject private var vpnManager = VPNManager.shared var body: some View { let maxSpeed = runner.maxHistorySpeed // 直接使用缓存,不再遍历数组 @@ -508,12 +498,23 @@ struct ContentView: View { } Button { - if !runner.isRunning { - LogParser.shared.resetForNewCoreSession() + if vpnManager.isConnected { + vpnManager.stopVPN() + } else { + // 通过 ConfigManager 读取(处理安全域书签) + let configURL = URL(fileURLWithPath: selectedConfigPath) + if let content = try? ConfigManager.shared.readConfigContent(configURL) { + vpnManager.startVPN(configContent: content) + } else { + print("无法读取配置文件: \(selectedConfigPath)") } - runner.toggleService(configPath: selectedConfigPath) + } } label: { - StartStopButtonCore(isRunning: runner.isRunning, uptimeText: runner.uptimeText) + StartStopButtonCore( + isRunning: vpnManager.isConnected, + uptimeText: runner.uptimeText, // TODO: 需要从 VPN 获取真实的 uptime + status: vpnManager.status + ) } .buttonStyle(.plain) .zIndex(20) @@ -541,7 +542,7 @@ struct ContentView: View { // MARK: - 节点列表区域(独立组件,隔离 peers 刷新) struct PeerListArea: View { - @StateObject private var runner = EasyTierRunner.shared + @StateObject private var runner = SwiftierRunner.shared // 定义两行网格布局,自适应宽度 private let gridRows = [ @@ -686,6 +687,7 @@ struct ContentView: View { struct StartStopButtonCore: View { let isRunning: Bool let uptimeText: String + var status: NEVPNStatus = .disconnected var body: some View { ZStack { @@ -693,14 +695,20 @@ struct StartStopButtonCore: View { Circle() // 启动前:保持原样(蓝色或逻辑原色) // 启动后:变为 0.9 透明度的白色 - .fill(isRunning ? Color.white : Color.blue) + .fill(buttonColor) .frame(width: 84, height: 84) .shadow(color: .black.opacity(isRunning ? 0.12 : 0.25), radius: 10, y: 4) - - Image(systemName: "power") - .font(.system(size: 28, weight: .regular)) - // 启动后图标为黑色,启动前为白色 - .foregroundStyle(isRunning ? Color.black : Color.white) + + if status == .connecting || status == .disconnecting { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .controlSize(.regular) + } else { + Image(systemName: "power") + .font(.system(size: 28, weight: .regular)) + // 启动后图标为黑色,启动前为白色 + .foregroundStyle(isRunning ? Color.black : Color.white) + } if isRunning { Text(uptimeText) @@ -715,86 +723,22 @@ struct StartStopButtonCore: View { .frame(width: 84, height: 84) .padding(.vertical, 6) } + + private var buttonColor: Color { + if isRunning { return .white } + switch status { + case .connecting, .disconnecting: return .orange + case .connected: return .white + case .disconnected, .invalid: return .blue + case .reasserting: return .yellow + @unknown default: return .blue + } + } } -struct FDAGuideView: View { - @Binding var isPresented: Bool - @ObservedObject var permissionManager = PermissionManager.shared - - var body: some View { - ZStack { - Rectangle().fill(.ultraThinMaterial) - - VStack(spacing: 24) { - Image(systemName: "lock.shield.fill") - .font(.system(size: 60)) - .foregroundColor(.orange) - - VStack(spacing: 12) { - Text("需要完全磁盘访问权限") - .font(.title2.bold()) - - Text("为了能够读取您选择的任意文件夹及配置文件,Swiftier 需要“完全磁盘访问权限”。\n这不会泄露您的私有数据,仅用于解除系统文件夹读取限制。") - .multilineTextAlignment(.center) - .foregroundColor(.secondary) - .padding(.horizontal) - - VStack(alignment: .leading, spacing: 10) { - guideStep(number: "1", text: "点击“去开启”,进入系统设置") - guideStep(number: "2", text: "Swiftier 应该已自动出现在列表中") - guideStep(number: "3", text: "只需打开旁边的开关即可") - } - .padding(.vertical) - - VStack(spacing: 12) { - HStack(spacing: 15) { - Button("在 Finder 中显示") { - permissionManager.revealAppInFinder() - } - .buttonStyle(.bordered) - - Button("立即去开启") { - permissionManager.openFullDiskAccessSettings() - } - .buttonStyle(.borderedProminent) - } - - Button("以后再说") { - withAnimation { isPresented = false } - } - .buttonStyle(.plain) - .foregroundColor(.secondary) - } - } - } - .padding(30) - .background(RoundedRectangle(cornerRadius: 16).fill(Color(nsColor: .windowBackgroundColor))) - .shadow(radius: 20) - .frame(width: 380) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in - // 当用户从系统设置返回时,自动重新检查 - permissionManager.checkFullDiskAccess() - } - } - - private func guideStep(number: String, text: String) -> some View { - HStack(alignment: .top, spacing: 10) { - Text(number) - .font(.caption.bold()) - .foregroundColor(.white) - .frame(width: 20, height: 20) - .background(Circle().fill(Color.orange)) - - Text(text) - .font(.subheadline) - } - } -} // MARK: - Native Horizontal Scroller (Fixes SwiftUI vertical bounce bug on Mac) // MARK: - Native Horizontal Scroller (The Nuclear Option) diff --git a/EasyTier/CoreDownloader.swift b/Swiftier/CoreDownloader.swift similarity index 100% rename from EasyTier/CoreDownloader.swift rename to Swiftier/CoreDownloader.swift diff --git a/Swiftier/EasyTierShared.swift b/Swiftier/EasyTierShared.swift new file mode 100644 index 0000000..dc952ec --- /dev/null +++ b/Swiftier/EasyTierShared.swift @@ -0,0 +1,164 @@ +import NetworkExtension +import os + +public let APP_BUNDLE_ID: String = "com.alick.swiftier" +public let APP_GROUP_ID: String = "group.com.alick.swiftier" +public let ICLOUD_CONTAINER_ID: String = "iCloud.com.alick.swiftier" +public let LOG_FILENAME: String = "easytier.log" + +public enum LogLevel: String, Codable, CaseIterable { + case trace = "trace" + case debug = "debug" + case info = "info" + case warn = "warn" + case error = "error" +} + +public struct EasyTierOptions: Codable { + public var config: String = "" + public var ipv4: String? + public var ipv6: String? + public var mtu: Int? + public var routes: [String] = [] + public var logLevel: LogLevel = .info + public var magicDNS: Bool = false + public var dns: [String] = [] + + public init() {} +} + +public struct TunnelNetworkSettingsSnapshot: Codable, Equatable { + public struct IPv4Subnet: Codable, Hashable { + public var address: String + public var subnetMask: String + + public init(address: String, subnetMask: String) { + self.address = address + self.subnetMask = subnetMask + } + } + + public struct IPv6Subnet: Codable, Hashable { + public var address: String + public var networkPrefixLength: Int + + public init(address: String, networkPrefixLength: Int) { + self.address = address + self.networkPrefixLength = networkPrefixLength + } + } + + public struct IPv4: Codable, Equatable { + public var subnets: Set + public var includedRoutes: Set? + public var excludedRoutes: Set? + + public init( + addresses: [String], + subnetMasks: [String], + includedRoutes: [IPv4Subnet]? = nil, + excludedRoutes: [IPv4Subnet]? = nil + ) { + subnets = .init() + for (index, address) in addresses.enumerated() { + subnets.insert( + IPv4Subnet(address: address, subnetMask: subnetMasks[index]) + ) + } + if let includedRoutes, !includedRoutes.isEmpty { + self.includedRoutes = Set(includedRoutes) + } + if let excludedRoutes, !excludedRoutes.isEmpty { + self.excludedRoutes = Set(excludedRoutes) + } + } + } + + public struct IPv6: Codable, Equatable { + public var subnets: Set + public var includedRoutes: Set? + public var excludedRoutes: Set? + + public init( + addresses: [String], + networkPrefixLengths: [Int], + includedRoutes: [IPv6Subnet]? = nil, + excludedRoutes: [IPv6Subnet]? = nil + ) { + subnets = .init() + for (index, address) in addresses.enumerated() { + subnets.insert( + IPv6Subnet( + address: address, + networkPrefixLength: networkPrefixLengths[index] + ) + ) + } + if let includedRoutes { + self.includedRoutes = Set(includedRoutes) + } + if let excludedRoutes { + self.excludedRoutes = Set(excludedRoutes) + } + } + } + + public struct DNS: Codable, Equatable { + public var servers: Set + public var searchDomains: Set? + public var matchDomains: Set? + + public init( + servers: [String], + searchDomains: [String]? = nil, + matchDomains: [String]? = nil + ) { + self.servers = Set(servers) + if let searchDomains { + self.searchDomains = Set(searchDomains) + } + if let matchDomains { + self.matchDomains = Set(matchDomains) + } + } + } + + public var ipv4: IPv4? + public var ipv6: IPv6? + public var dns: DNS? + public var mtu: UInt32? + + public init(ipv4: IPv4? = nil, ipv6: IPv6? = nil, dns: DNS? = nil, mtu: UInt32? = nil) { + self.ipv4 = ipv4 + self.ipv6 = ipv6 + self.dns = dns + self.mtu = mtu + } +} + +public enum ProviderCommand: String, Codable, CaseIterable { + case exportOSLog = "export_oslog" + case runningInfo = "running_info" + case lastNetworkSettings = "last_network_settings" +} + +public func connectWithManager(_ manager: NETunnelProviderManager, logger: Logger? = nil) async throws { + manager.isEnabled = true + if let defaults = UserDefaults(suiteName: APP_GROUP_ID) { + manager.protocolConfiguration?.includeAllNetworks = defaults.bool(forKey: "includeAllNetworks") + manager.protocolConfiguration?.excludeLocalNetworks = defaults.bool(forKey: "excludeLocalNetworks") + if #available(iOS 16.4, *) { + manager.protocolConfiguration?.excludeCellularServices = defaults.bool(forKey: "excludeCellularServices") + manager.protocolConfiguration?.excludeAPNs = defaults.bool(forKey: "excludeAPNs") + } + if #available(iOS 17.4, macOS 14.4, *) { + manager.protocolConfiguration?.excludeDeviceCommunication = defaults.bool(forKey: "excludeDeviceCommunication") + } + manager.protocolConfiguration?.enforceRoutes = defaults.bool(forKey: "enforceRoutes") + if let logger { + logger.debug("connect with protocol configuration: \(manager.protocolConfiguration)") + } + } + try await manager.saveToPreferences() + try manager.connection.startVPNTunnel() +} diff --git a/EasyTier/EventListView.swift b/Swiftier/EventListView.swift similarity index 100% rename from EasyTier/EventListView.swift rename to Swiftier/EventListView.swift diff --git a/EasyTier/Extensions.swift b/Swiftier/Extensions.swift similarity index 100% rename from EasyTier/Extensions.swift rename to Swiftier/Extensions.swift diff --git a/EasyTier/HelperProtocol.swift b/Swiftier/HelperProtocol.swift similarity index 100% rename from EasyTier/HelperProtocol.swift rename to Swiftier/HelperProtocol.swift diff --git a/EasyTier/Info.plist b/Swiftier/Info.plist similarity index 74% rename from EasyTier/Info.plist rename to Swiftier/Info.plist index b7908d7..eb39e9d 100644 --- a/EasyTier/Info.plist +++ b/Swiftier/Info.plist @@ -2,11 +2,7 @@ - SMPrivilegedExecutables - - com.alick.swiftier.helper - identifier "com.alick.swiftier.helper" and anchor apple generic - + NSUbiquitousContainers iCloud.com.alick.swiftier diff --git a/EasyTier/Localizable.xcstrings b/Swiftier/Localizable.xcstrings similarity index 99% rename from EasyTier/Localizable.xcstrings rename to Swiftier/Localizable.xcstrings index d06d87d..a1d8877 100644 --- a/EasyTier/Localizable.xcstrings +++ b/Swiftier/Localizable.xcstrings @@ -407,6 +407,9 @@ } } } + }, + "仅使用物理网卡,避免 Swiftier 通过其他虚拟网建立连接。" : { + }, "仅保留 Helper 加速启动" : { "extractionState" : "manual", @@ -561,6 +564,9 @@ } } } + }, + "停止连接并退出" : { + }, "允许此节点成为出口节点。" : { "extractionState" : "manual", @@ -905,6 +911,9 @@ } } } + }, + "启用魔法 DNS,允许通过 Swiftier 的 DNS 服务器访问其他节点的虚拟 IPv4 地址,例如:node1.et.net。" : { + }, "在 Finder 中打开" : { "extractionState" : "manual", @@ -966,6 +975,12 @@ }, "如:tcp://1.1.1.1:11010" : { + }, + "存储位置: iCloud Drive" : { + + }, + "存储位置: 本地 (iCloud 未启用)" : { + }, "存储到 iCloud" : { "extractionState" : "manual", @@ -1153,6 +1168,9 @@ } } } + }, + "开启 SOCKS5 代理功能,Surge 等外部程序可通过此端口连接 Swiftier 网络。" : { + }, "开放" : { "extractionState" : "manual", diff --git a/EasyTier/LogListView.swift b/Swiftier/LogListView.swift similarity index 100% rename from EasyTier/LogListView.swift rename to Swiftier/LogListView.swift diff --git a/EasyTier/LogModels.swift b/Swiftier/LogModels.swift similarity index 96% rename from EasyTier/LogModels.swift rename to Swiftier/LogModels.swift index 4dcbad1..5ff0418 100644 --- a/EasyTier/LogModels.swift +++ b/Swiftier/LogModels.swift @@ -20,9 +20,7 @@ struct LogEntry: Identifiable, Equatable { } } -enum LogLevel: String { - case trace, debug, info, warn, error - +extension LogLevel { var color: Color { switch self { case .error: return .red diff --git a/EasyTier/LogParser.swift b/Swiftier/LogParser.swift similarity index 87% rename from EasyTier/LogParser.swift rename to Swiftier/LogParser.swift index 65b199b..4f0b156 100644 --- a/EasyTier/LogParser.swift +++ b/Swiftier/LogParser.swift @@ -30,7 +30,12 @@ class LogParser: ObservableObject { private var timer: Timer? private var xpcEventTimer: Timer? var xpcEventIndex: Int = 0 - private let logPath = "/var/log/swiftier-helper.log" + private var logPath: String { + if let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.alick.swiftier") { + return containerURL.appendingPathComponent("easytier.log").path + } + return "/var/log/swiftier-helper.log" + } private var isReading = false private var lastReadOffset: UInt64 = 0 private var trailingRemainder = "" @@ -68,40 +73,7 @@ class LogParser: ObservableObject { @available(macOS 13.0, *) private func pollXPCEvents() { - HelperManager.shared.getRecentEvents(sinceIndex: xpcEventIndex) { [weak self] processedEvents, nextIndex in - guard let self = self else { return } - - // Core Restart Detection - if nextIndex < self.xpcEventIndex { - DispatchQueue.main.async { - self.events.removeAll() - self.logs.removeAll() - self.seenEventHashes.removeAll() - } - } - - self.xpcEventIndex = nextIndex - if processedEvents.isEmpty { return } - - let newEntries = processedEvents.map { pe -> EventEntry in - let type = EventEntry.EventType(rawValue: pe.type) ?? .unknown - return EventEntry( - id: pe.id, - timestamp: pe.timestamp, - date: pe.time, - type: type, - details: pe.details, - highlights: self.calculateHighlights(for: pe.details) - ) - } - - DispatchQueue.main.async { - self.events.append(contentsOf: newEntries) - if self.events.count > self.maxEventItems { - self.events.removeFirst(self.events.count - self.maxEventItems) - } - } - } + readNewRawLines() } private func startRawLogMonitoring() { @@ -131,8 +103,8 @@ class LogParser: ObservableObject { var result: [LogEntry] = [] let now = ISO8601DateFormatter().string(from: Date()) - // 获取全局设置级别 - let settingLevelStr = UserDefaults.standard.string(forKey: "logLevel") ?? "INFO" + // 获取全局设置级别(从 App Group 读取) + let settingLevelStr = UserDefaults(suiteName: "group.com.alick.swiftier")?.string(forKey: "logLevel") ?? "INFO" let levelMap: [String: Int] = ["OFF": 0, "ERROR": 1, "WARN": 2, "INFO": 3, "DEBUG": 4, "TRACE": 5] let currentLevelValue = levelMap[settingLevelStr.uppercased()] ?? 5 @@ -193,7 +165,59 @@ class LogParser: ObservableObject { } private func readNewRawLines() { - // Removed for Sandbox compatibility + guard !isReading else { return } + isReading = true + defer { isReading = false } + + let path = logPath + guard FileManager.default.fileExists(atPath: path) else { return } + + guard let handle = FileHandle(forReadingAtPath: path) else { return } + defer { handle.closeFile() } + + // Get file size; reset offset if file was truncated (log rotation) + let fileSize = handle.seekToEndOfFile() + if lastReadOffset > fileSize { + lastReadOffset = 0 + trailingRemainder = "" + } + + guard fileSize > lastReadOffset else { return } + + handle.seek(toFileOffset: lastReadOffset) + let newData = handle.readDataToEndOfFile() + lastReadOffset = handle.offsetInFile + + guard let newText = String(data: newData, encoding: .utf8), !newText.isEmpty else { return } + + let fullText = trailingRemainder + newText + var lines = fullText.components(separatedBy: "\n") + + // Keep incomplete last line for next read + if !newText.hasSuffix("\n") { + trailingRemainder = lines.removeLast() + } else { + trailingRemainder = "" + if lines.last?.isEmpty == true { lines.removeLast() } + } + + let content = lines.joined(separator: "\n") + guard !content.isEmpty else { return } + + let parsed = quickParseLogs(content) + guard !parsed.isEmpty else { return } + + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + if self.isPaused { + self.pendingLogs.append(contentsOf: parsed) + } else { + self.logs.append(contentsOf: parsed) + if self.logs.count > self.maxLogItems { + self.logs.removeFirst(self.logs.count - self.maxLogItems) + } + } + } } func stopMonitoring() { @@ -216,7 +240,7 @@ class LogParser: ObservableObject { } /// Update events from get_running_info response - func updateEventsFromRunningInfo(_ eventsAnyArray: [Any]) { + func updateEventsFromRunningInfo(_ eventsAnyArray: [String]) { var newEvents: [EventEntry] = [] for item in eventsAnyArray { diff --git a/EasyTier/LogView.swift b/Swiftier/LogView.swift similarity index 91% rename from EasyTier/LogView.swift rename to Swiftier/LogView.swift index f1d4e79..1a6f09a 100644 --- a/EasyTier/LogView.swift +++ b/Swiftier/LogView.swift @@ -8,7 +8,12 @@ struct LogView: View { @State private var viewMode: ViewMode = .events @State private var logLevelFilter: LogLevel? = nil // nil = show all - private let logPath = "/var/log/swiftier-helper.log" + private var logPath: String { + if let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.alick.swiftier") { + return containerURL.appendingPathComponent("easytier.log").path + } + return "/var/log/swiftier-helper.log" // fallback + } enum ViewMode: Int { case events = 0 diff --git a/EasyTier/Models/EasyTierNodeModels.swift b/Swiftier/Models/SwiftierNodeModels.swift similarity index 99% rename from EasyTier/Models/EasyTierNodeModels.swift rename to Swiftier/Models/SwiftierNodeModels.swift index d30cdf5..f58580d 100644 --- a/EasyTier/Models/EasyTierNodeModels.swift +++ b/Swiftier/Models/SwiftierNodeModels.swift @@ -1,9 +1,9 @@ import Foundation import SwiftUI -// MARK: - Core Status Models (Ported from EasyTier-iOS) +// MARK: - Core Status Models (Ported from Swiftier-iOS) -struct EasyTierStatus: Codable { +struct SwiftierStatus: Codable { enum NATType: Int, Codable, Hashable { case unknown = 0 case openInternet = 1 diff --git a/EasyTier/PeerCard.swift b/Swiftier/PeerCard.swift similarity index 99% rename from EasyTier/PeerCard.swift rename to Swiftier/PeerCard.swift index 03dfd3f..9ef611d 100644 --- a/EasyTier/PeerCard.swift +++ b/Swiftier/PeerCard.swift @@ -309,7 +309,7 @@ struct PeerDetailView: View { } @ViewBuilder - private func localNodeSections(_ node: EasyTierStatus.NodeInfo) -> some View { + private func localNodeSections(_ node: SwiftierStatus.NodeInfo) -> some View { Section(header: Text(LocalizedStringKey("节点"))) { DetailRow(label: LocalizedStringKey("主机名"), value: node.hostname) DetailRow(label: LocalizedStringKey("版本"), value: node.version) @@ -328,7 +328,7 @@ struct PeerDetailView: View { } @ViewBuilder - private func remotePeerSections(_ pair: EasyTierStatus.PeerRoutePair) -> some View { + private func remotePeerSections(_ pair: SwiftierStatus.PeerRoutePair) -> some View { let route = pair.route Section(header: Text(LocalizedStringKey("节点"))) { @@ -389,7 +389,7 @@ struct PeerDetailView: View { } } - private func formatFlags(_ flags: EasyTierStatus.PeerFeatureFlag) -> String { + private func formatFlags(_ flags: SwiftierStatus.PeerFeatureFlag) -> String { var parts = [String]() if flags.isPublicServer { parts.append("public_server") } if flags.avoidRelayData { parts.append("avoid_relay") } diff --git a/EasyTier/RippleRingsView.swift b/Swiftier/RippleRingsView.swift similarity index 100% rename from EasyTier/RippleRingsView.swift rename to Swiftier/RippleRingsView.swift diff --git a/EasyTier/ScrollFixer.swift b/Swiftier/ScrollFixer.swift similarity index 100% rename from EasyTier/ScrollFixer.swift rename to Swiftier/ScrollFixer.swift diff --git a/EasyTier/SettingsView.swift b/Swiftier/SettingsView.swift similarity index 94% rename from EasyTier/SettingsView.swift rename to Swiftier/SettingsView.swift index 7976b3a..d201be3 100644 --- a/EasyTier/SettingsView.swift +++ b/Swiftier/SettingsView.swift @@ -1,6 +1,6 @@ import SwiftUI -import ServiceManagement import WebKit +import ServiceManagement struct SettingsView: View { @Binding var isPresented: Bool @@ -8,10 +8,9 @@ struct SettingsView: View { @AppStorage("connectOnStart") private var connectOnStart: Bool = true @AppStorage("breathEffect") private var breathEffect: Bool = true @AppStorage("launchAtLogin") private var launchAtLogin: Bool = false - @AppStorage("exitBehavior") private var exitBehavior: String = "stopCore" // keepRunning, stopCore, stopAll - @AppStorage("logLevel") private var logLevel: String = "INFO" + @AppStorage("exitBehavior") private var exitBehavior: String = "stopVPN" // keepRunning, stopVPN + @AppStorage("logLevel", store: UserDefaults(suiteName: "group.com.alick.swiftier")) private var logLevel: String = "INFO" - @ObservedObject private var permissionManager = PermissionManager.shared @State private var showLicense = false @@ -96,8 +95,7 @@ struct SettingsView: View { Spacer() Picker("", selection: $exitBehavior) { Text(LocalizedStringKey("保持连接运行")).tag("keepRunning") - Text(LocalizedStringKey("仅保留 Helper 加速启动")).tag("stopCore") - Text(LocalizedStringKey("完全退出")).tag("stopAll") + Text(LocalizedStringKey("停止连接并退出")).tag("stopVPN") } .pickerStyle(.menu) .labelsHidden() @@ -105,25 +103,6 @@ struct SettingsView: View { } } - Section(header: Text("隐私")) { - HStack { - Text("完全磁盘访问权限") - Spacer() - if permissionManager.isFDAGranted { - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - Text("已开启") - .foregroundColor(.secondary) - } - } else { - Button("去开启") { - permissionManager.openFullDiskAccessSettings() - } - .foregroundColor(.red) - } - } - } @@ -224,11 +203,9 @@ struct SettingsView: View { private var exitBehaviorDescription: String { switch exitBehavior { case "keepRunning": - return "退出 APP 后,Swiftier 连接将保持运行,您可以随时重新打开 APP 查看状态。" - case "stopCore": - return "退出 APP 后,断开 Swiftier 连接,但保留 Helper 进程以加速下次启动。" - case "stopAll": - return "退出 APP 后,完全停止所有后台服务(包括 Helper),下次启动需要重新授权。" + return "退出 APP 后,VPN 连接将保持运行。" + case "stopVPN": + return "退出 APP 后,断开 VPN 连接。" default: return "" } @@ -538,7 +515,7 @@ struct AppUpdateDetailView: View { // Filter for "Swiftier" in name to avoid other assets let validAssets = assets.filter { asset in guard let name = asset["name"] as? String else { return false } - return name.contains("Swiftier") || name.contains("EasyTier") + return name.contains("Swiftier") } // Priority 1: .dmg diff --git a/EasyTier/SharedComponents.swift b/Swiftier/SharedComponents.swift similarity index 100% rename from EasyTier/SharedComponents.swift rename to Swiftier/SharedComponents.swift diff --git a/EasyTier/SparklineView.swift b/Swiftier/SparklineView.swift similarity index 98% rename from EasyTier/SparklineView.swift rename to Swiftier/SparklineView.swift index 5fcaf08..66bd16a 100644 --- a/EasyTier/SparklineView.swift +++ b/Swiftier/SparklineView.swift @@ -47,7 +47,7 @@ struct SparklineView: NSViewRepresentable { } deinit { // Safety cleanup just in case - // DispatchQueue.main.async { EasyTierRunner.shared.removeSubscriber() } + // DispatchQueue.main.async { SwiftierRunner.shared.removeSubscriber() } } } } @@ -70,8 +70,8 @@ struct SmartSparklineView: View { var body: some View { SparklineView(data: data, color: color, maxScale: maxScale, paused: paused) - .onAppear { EasyTierRunner.shared.addSubscriber() } - .onDisappear { EasyTierRunner.shared.removeSubscriber() } + .onAppear { SwiftierRunner.shared.addSubscriber() } + .onDisappear { SwiftierRunner.shared.removeSubscriber() } } } @@ -341,7 +341,7 @@ final class SparklineNSView: NSView { let isFirstRealUpdate = (prevLastValue == nil) && !isLayoutPass if isFirstRealUpdate { - let lastTime = EasyTierRunner.shared.lastDataTime + let lastTime = SwiftierRunner.shared.lastDataTime let now = Date() let elapsed = now.timeIntervalSince(lastTime) if elapsed >= 0 && elapsed < animationDuration { diff --git a/EasyTier/EasyTier.entitlements b/Swiftier/Swiftier.entitlements similarity index 62% rename from EasyTier/EasyTier.entitlements rename to Swiftier/Swiftier.entitlements index a0a53e1..0f1251a 100644 --- a/EasyTier/EasyTier.entitlements +++ b/Swiftier/Swiftier.entitlements @@ -13,11 +13,27 @@ CloudDocuments CloudKit + com.apple.developer.networking.networkextension + + packet-tunnel-provider + com.apple.developer.ubiquity-container-identifiers iCloud.com.alick.swiftier com.apple.developer.ubiquity-kvstore-identifier $(TeamIdentifierPrefix)$(CFBundleIdentifier) + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.com.alick.swiftier + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + com.apple.security.network.server + diff --git a/EasyTier/Swiftier.icon/Assets/point.3.connected.trianglepath.dotted 2.png b/Swiftier/Swiftier.icon/Assets/point.3.connected.trianglepath.dotted 2.png similarity index 100% rename from EasyTier/Swiftier.icon/Assets/point.3.connected.trianglepath.dotted 2.png rename to Swiftier/Swiftier.icon/Assets/point.3.connected.trianglepath.dotted 2.png diff --git a/EasyTier/Swiftier.icon/icon.json b/Swiftier/Swiftier.icon/icon.json similarity index 100% rename from EasyTier/Swiftier.icon/icon.json rename to Swiftier/Swiftier.icon/icon.json diff --git a/EasyTier/EasyTierControlApp.swift b/Swiftier/SwiftierControlApp.swift similarity index 75% rename from EasyTier/EasyTierControlApp.swift rename to Swiftier/SwiftierControlApp.swift index 0f147ad..353fccc 100644 --- a/EasyTier/EasyTierControlApp.swift +++ b/Swiftier/SwiftierControlApp.swift @@ -3,9 +3,9 @@ import AppKit import Combine @main -struct EasyTierControlApp: App { +struct SwiftierControlApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - @StateObject private var runner = EasyTierRunner.shared + @StateObject private var runner = SwiftierRunner.shared @StateObject private var iconState = MenuBarIconState.shared @AppStorage("breathEffect") private var breathEffect: Bool = true @@ -44,7 +44,7 @@ class MenuBarIconState: ObservableObject { private init() { // 优化:监听运行状态变化,按需启停 Timer - EasyTierRunner.shared.$isRunning + SwiftierRunner.shared.$isRunning .receive(on: DispatchQueue.main) .sink { [weak self] isRunning in self?.handleRunningStateChange(isRunning: isRunning) @@ -66,7 +66,7 @@ class MenuBarIconState: ObservableObject { } private func updateTimerState() { - let isRunning = EasyTierRunner.shared.isRunning + let isRunning = SwiftierRunner.shared.isRunning let blinkEnabled = (UserDefaults.standard.object(forKey: "breathEffect") as? Bool) ?? true if isRunning && blinkEnabled { @@ -128,21 +128,30 @@ class AppDelegate: NSObject, NSApplicationDelegate { let autoConnect = (UserDefaults.standard.object(forKey: "connectOnStart") as? Bool) ?? true guard autoConnect else { return } - // 先检查 Core 是否已经在运行 - CoreService.shared.getStatus { running, _ in - if running { - // 已经在运行,同步状态即可 - print("Core already running, syncing state...") - EasyTierRunner.shared.syncWithCoreState() - } else { - // 未运行,执行自动连接 - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - let configs = ConfigManager.shared.refreshConfigs() - if let config = configs.first { - print("Auto-connecting with config: \(config.lastPathComponent)") - EasyTierRunner.shared.toggleService(configPath: config.path) - } - } + // 等待 VPNManager 完成 Profile 加载/创建后再自动连接 + // 使用 Combine 监听 isReady 状态,避免固定延时导致的竞态 + VPNManager.shared.$isReady + .filter { $0 } // 等待 isReady == true + .first() // 只触发一次 + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.performAutoConnect() + } + .store(in: &cancellables) + } + + private func performAutoConnect() { + let isConnected = VPNManager.shared.isConnected + + if isConnected { + print("VPN already connected, syncing state...") + SwiftierRunner.shared.syncWithVPNState() + } else { + // 未运行,执行自动连接 + let configs = ConfigManager.shared.refreshConfigs() + if let config = configs.first { + print("Auto-connecting with config: \(config.lastPathComponent)") + SwiftierRunner.shared.toggleService(configPath: config.path) } } } diff --git a/Swiftier/SwiftierRunner.swift b/Swiftier/SwiftierRunner.swift new file mode 100644 index 0000000..f699ed1 --- /dev/null +++ b/Swiftier/SwiftierRunner.swift @@ -0,0 +1,444 @@ +// +// SwiftierRunner.swift +// Swiftier +// +// Created by Alick on 2024. +// + +import Foundation +import Combine +import AppKit +import SwiftUI +import NetworkExtension + +final class SwiftierRunner: ObservableObject { + static let shared = SwiftierRunner() + + @Published var isRunning = false + @Published var peers: [PeerInfo] = [] + @Published var peerCount: String = "0" + @Published var downloadSpeed: String = "0 KB/s" + @Published var uploadSpeed: String = "0 KB/s" + @Published var maxHistorySpeed: Double = 1_048_576.0 // 缓存最大网速计算结果 + @Published var isWindowVisible = true + @Published var uptimeText: String = "00:00:00" + + // 公开最后一次数据更新的时间戳,供 UI 层做动画相位对齐 + @Published private(set) var lastDataTime: Date = Date.distantPast + + private var startedAt: Date? + private var timer: AnyCancellable? + @Published private(set) var sessionID = UUID() + private var currentSessionID = UUID() + private var lastConfigPath: String? + + // Speed calculation + private var lastTotalRx: Int = 0 + private var lastTotalTx: Int = 0 + private var lastPollTime: Date? + private var lastProcessingTime: Date = .distantPast // 用于频率限制 + + // Peer-level speed tracking + private var lastPeerStats: [Int: (rx: Int, tx: Int, time: Date)] = [:] + private let jsonDecoder = JSONDecoder() + + @Published var virtualIP: String = "-" + + // Speed history for graphs + @Published var downloadHistory: [Double] = Array(repeating: 0.0, count: 20) + @Published var uploadHistory: [Double] = Array(repeating: 0.0, count: 20) + + // Subscriber & Polling Control + private var subscriberCount = 0 + private var isAppActive = true + private var pollingTimer: AnyCancellable? + private let activeInterval: TimeInterval = 1.0 + private let lowPowerInterval: TimeInterval = 5.0 + + @Published var isProcessing = false + + private var statusObserver: AnyCancellable? + + private init() { + // App Lifecycle Monitoring + NotificationCenter.default.addObserver(self, selector: #selector(handleAppDidBecomeActive), name: NSApplication.didBecomeActiveNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handleAppWillResignActive), name: NSApplication.willResignActiveNotification, object: nil) + + // Listen to VPNManager status changes + statusObserver = VPNManager.shared.$status.sink { [weak self] status in + self?.handleVPNStatusChange(status) + } + + // Check initial state + syncWithVPNState() + } + + @objc private func handleAppDidBecomeActive() { + // print("[Runner] App Active -> High Perf Mode") + isAppActive = true + updatePollingMode() + } + + @objc private func handleAppWillResignActive() { + // print("[Runner] App Background -> Low Power Mode") + isAppActive = false + updatePollingMode() + } + + func addSubscriber() { + subscriberCount += 1 + updatePollingMode() + } + + func removeSubscriber() { + subscriberCount = max(0, subscriberCount - 1) + updatePollingMode() + } + + private func updatePollingMode() { + guard isRunning else { + pollingTimer?.cancel() + return + } + + let interval: TimeInterval + if subscriberCount > 0 { + interval = activeInterval + } else { + interval = lowPowerInterval + } + + pollingTimer?.cancel() + pollingTimer = Timer.publish(every: interval, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + self?.refreshPeersOnce() + } + } + + private func handleVPNStatusChange(_ status: NEVPNStatus) { + DispatchQueue.main.async { + switch status { + case .connected: + if !self.isRunning { + self.isRunning = true + self.startedAt = Date() // Approximate start time if not tracking precisely + self.startUptimeTimer() + self.startMonitoring() + } + self.isProcessing = false + case .disconnected, .invalid: + if self.isRunning { + self.isRunning = false + self.stopUptimeTimer() + self.peers = [] + self.uptimeText = "00:00:00" + self.downloadSpeed = "0 KB/s" + self.uploadSpeed = "0 KB/s" + self.virtualIP = "-" + self.downloadHistory = Array(repeating: 0.0, count: 20) + self.uploadHistory = Array(repeating: 0.0, count: 20) + } + self.isProcessing = false + case .connecting, .disconnecting, .reasserting: + self.isProcessing = true + @unknown default: + break + } + } + } + + func syncWithVPNState() { + // Initial sync relies on VPNManager's current state + handleVPNStatusChange(VPNManager.shared.status) + + // If we are disconnected but configured to auto-connect on start + if VPNManager.shared.status == .disconnected && UserDefaults.standard.bool(forKey: "connectOnStart") { + // Let SwiftierControlApp handle the auto-connect logic or do it here if appropriate. + // Usually better to let the App entry point handle "on launch" actions. + } + } + + // MARK: - Control Actions + + func toggleService(configPath: String) { + if isProcessing { return } + + if isRunning { + // Stop + VPNManager.shared.stopVPN() + } else { + // Start + // 使用 ConfigManager 读取(处理安全域) + do { + let configURL = URL(fileURLWithPath: configPath) + let configContent = try ConfigManager.shared.readConfigContent(configURL) + VPNManager.shared.startVPN(configContent: configContent) + + // 缓存路径用于重启 + self.lastConfigPath = configPath + } catch { + print("Failed to read config for VPN: \(error)") + let alert = NSAlert() + alert.messageText = "配置读取失败" + alert.informativeText = "无法读取配置文件:\(error.localizedDescription)" + // alert.runModal() // 不要在 toggle 中阻塞 UI,尤其是自动连接时 + // 而是发送通知或者只是 log? + // 如果是手动点击,modal 是可以的。如果是自动启动... + // 暂时保留,但在主线程操作 + DispatchQueue.main.async { + if self.isWindowVisible { + alert.runModal() + } + } + } + } + } + + func restartService() { + guard isRunning else { return } + VPNManager.shared.stopVPN() + + // 监听 VPN 断开后再重连,而非使用固定延迟 + guard let path = lastConfigPath else { return } + var restartObserver: AnyCancellable? + restartObserver = VPNManager.shared.$status + .filter { $0 == .disconnected } + .first() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.toggleService(configPath: path) + restartObserver?.cancel() + } + } + + func openLogFile() { + // Logs for NE are different. They might be in the Console.app or a shared file. + // If we implement file logging in PacketTunnelProvider to a shared container: + if let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.alick.swiftier") { + let logURL = containerURL.appendingPathComponent("easytier.log") + if FileManager.default.fileExists(atPath: logURL.path) { + NSWorkspace.shared.open(logURL) + } else { + print("Log file not found at \(logURL.path)") + } + } + } + + private func startMonitoring() { + resetSpeedCounters() + updatePollingMode() + refreshPeersOnce() + } + + private func resetSpeedCounters() { + lastTotalRx = 0 + lastTotalTx = 0 + lastPollTime = nil + lastPeerStats = [:] + downloadHistory = Array(repeating: 0.0, count: 20) + uploadHistory = Array(repeating: 0.0, count: 20) + } + + private func refreshPeersOnce() { + guard isRunning else { return } + + // Request running info directly from NE via IPC + VPNManager.shared.requestRunningInfo { [weak self] json in + guard let self = self, let json = json else { return } + self.processRunningInfo(json) + } + } + + // ... processRunningInfo, formatSpeed, formatBytes, Uptime Timer logic remains mostly the same ... + // Copying the rest of the logic to ensure it works. + + private var throttleInterval: TimeInterval = 0.8 + + func setWarmUpMode(_ enabled: Bool) { + self.throttleInterval = enabled ? 0.05 : 0.8 + } + + func forceRefresh() { + refreshPeersOnce() + } + + private func processRunningInfo(_ jsonStr: String) { + let now = Date() + guard now.timeIntervalSince(lastProcessingTime) >= throttleInterval else { + return + } + + lastProcessingTime = now + guard let data = jsonStr.data(using: .utf8) else { return } + + var totalRx = 0 + var totalTx = 0 + var fetchedPeers: [PeerInfo] = [] + + guard let status = try? jsonDecoder.decode(SwiftierStatus.self, from: data) else { return } + + // 1. IP & Events + LogParser.shared.updateEventsFromRunningInfo(status.events) + if let myIp = status.myNodeInfo?.virtualIPv4?.description, self.virtualIP != myIp { + DispatchQueue.main.async { self.virtualIP = myIp } + } + + // 2. Stats + for pair in status.peerRoutePairs { + if let peer = pair.peer { + for conn in peer.conns { + if let stats = conn.stats { + totalRx += stats.rxBytes + totalTx += stats.txBytes + } + } + } + } + + // 3. Local Node + if let myNode = status.myNodeInfo { + fetchedPeers.append(PeerInfo( + sessionID: self.currentSessionID, + ipv4: myNode.virtualIPv4?.description ?? "-", + hostname: myNode.hostname, + cost: "本机", + latency: "0", + loss: "0.0%", + rx: self.formatBytes(totalRx), + tx: self.formatBytes(totalTx), + tunnel: "LOCAL", + nat: myNode.stunInfo?.udpNATType.description ?? "Unknown", + version: myNode.version, + myNodeData: myNode + )) + } + + // 4. Remote Nodes + for pair in status.peerRoutePairs { + var rxVal = "0 B", txVal = "0 B", latencyVal = "", lossVal = "", tunnelVal = "" + + if let peer = pair.peer { + var cRx = 0, cTx = 0, latSum = 0, latCount = 0, lossSum = 0.0, lossCount = 0, tunnels = Set() + for conn in peer.conns { + if let s = conn.stats { + latSum += s.latencyUs; latCount += 1 + cRx += s.rxBytes; cTx += s.txBytes + } + + lossSum += conn.lossRate + lossCount += 1 + + if let t = conn.tunnel?.tunnelType { tunnels.insert(t.uppercased()) } + } + rxVal = formatBytes(cRx); txVal = formatBytes(cTx) + if latCount > 0 { latencyVal = String(format: "%.1f", Double(latSum)/Double(latCount)/1000.0) } + if lossCount > 0 { lossVal = String(format: "%.1f%%", (lossSum/Double(lossCount))*100.0) } + tunnelVal = tunnels.sorted().joined(separator: "&") + } else if let pathLat = pair.route.pathLatency as Int?, pathLat > 0 { + latencyVal = String(format: "%.1f", Double(pathLat) / 1000.0) + } + + fetchedPeers.append(PeerInfo( + sessionID: self.currentSessionID, + ipv4: pair.route.ipv4Addr?.description ?? "", + hostname: pair.route.hostname, + cost: pair.route.cost == 1 ? "P2P" : "Relay(\(pair.route.cost))", + latency: latencyVal, loss: lossVal, rx: rxVal, tx: txVal, tunnel: tunnelVal, + nat: pair.route.stunInfo?.udpNATType.description ?? "Unknown", + version: pair.route.version, + fullData: pair + )) + } + + // Traffic update + if let lastT = lastPollTime { + let d = now.timeIntervalSince(lastT) + if d > 0.1 { + let rSpeed = max(0, Double(totalRx - lastTotalRx) / d) + let tSpeed = max(0, Double(totalTx - lastTotalTx) / d) + DispatchQueue.main.async { + self.downloadSpeed = self.formatSpeed(rSpeed) + self.uploadSpeed = self.formatSpeed(tSpeed) + self.downloadHistory.removeFirst(); self.downloadHistory.append(rSpeed) + self.uploadHistory.removeFirst(); self.uploadHistory.append(tSpeed) + + self.maxHistorySpeed = max( + (self.downloadHistory.max() ?? 0.0), + (self.uploadHistory.max() ?? 0.0), + 1_048_576.0 + ) + } + } + } + self.lastTotalRx = totalRx + self.lastTotalTx = totalTx + self.lastPollTime = now + + DispatchQueue.main.async { + self.lastDataTime = now + + let sorted = fetchedPeers.sorted { p1, p2 in + let is1L = p1.cost == "本机"; let is2L = p2.cost == "本机" + if is1L != is2L { return is1L } + let is1Empty = p1.ipv4.isEmpty; let is2Empty = p2.ipv4.isEmpty + if is1Empty != is2Empty { return !is1Empty } + return p1.ipv4.localizedStandardCompare(p2.ipv4) == .orderedAscending + } + + let oldIDs = self.peers.map(\.id) + let newIDs = sorted.map(\.id) + + if oldIDs != newIDs { + withAnimation(.spring(response: 0.5, dampingFraction: 0.82)) { + self.peers = sorted + } + } else { + self.peers = sorted + } + self.peerCount = "\(sorted.count)" + } + } + + private func formatSpeed(_ bytesPerSec: Double) -> String { + if bytesPerSec < 1024 { return String(format: "%.0f B/s", bytesPerSec) } + let kb = bytesPerSec / 1024.0 + if kb < 1024 { return String(format: "%.1f KB/s", kb) } + let mb = kb / 1024.0 + return String(format: "%.1f MB/s", mb) + } + + private func formatBytes(_ bytes: Int) -> String { + if bytes < 1024 { return "\(bytes) B" } + let kb = Double(bytes) / 1024.0 + if kb < 1024 { return String(format: "%.1f KB", kb) } + let mb = kb / 1024.0 + if mb < 1024 { return String(format: "%.1f MB", mb) } + return String(format: "%.2f GB", mb / 1024.0) + } + + private var uptimeTimer: Timer? + + private func startUptimeTimer() { + guard uptimeTimer == nil else { return } + updateUptimeText() + let t = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in + self?.updateUptimeText() + } + RunLoop.main.add(t, forMode: .common) + uptimeTimer = t + } + + private func stopUptimeTimer() { + uptimeTimer?.invalidate() + uptimeTimer = nil + } + + private func updateUptimeText() { + guard let sAt = startedAt else { return } + let interval = Int(Date().timeIntervalSince(sAt)) + let h = interval / 3600; let m = (interval % 3600) / 60; let s = interval % 60 + let newText = String(format: "%02d:%02d:%02d", h, m, s) + if uptimeText != newText { uptimeText = newText } + } +} diff --git a/Swiftier/VPNManager.swift b/Swiftier/VPNManager.swift new file mode 100644 index 0000000..6215e1e --- /dev/null +++ b/Swiftier/VPNManager.swift @@ -0,0 +1,200 @@ +import Foundation +import NetworkExtension +import Combine + +class VPNManager: ObservableObject { + static let shared = VPNManager() + + @Published var isConnected = false + @Published var statusText = "未连接" + @Published var status: NEVPNStatus = .disconnected + @Published var isReady = false + + private var manager: NETunnelProviderManager? + + init() { + loadPreferences() + + // 监听状态变化 + NotificationCenter.default.addObserver( + self, + selector: #selector(vpnStatusDidChange), + name: .NEVPNStatusDidChange, + object: nil + ) + } + + func loadManager() { + loadPreferences() + } + + private func loadPreferences() { + NETunnelProviderManager.loadAllFromPreferences { [weak self] managers, error in + guard let self = self else { return } + + if let error = error { + print("Error loading VPN preferences: \(error)") + return + } + + if let existingManager = managers?.first { + self.manager = existingManager + self.updateStatus() + self.isReady = true + } else { + self.setupVPNProfile() + } + } + } + + private func setupVPNProfile() { + print("VPNManager: Starting setupVPNProfile...") + print("VPNManager: Current Bundle ID: \(Bundle.main.bundleIdentifier ?? "Unknown")") + + let manager = NETunnelProviderManager() + manager.localizedDescription = "Swiftier VPN" + + let protocolConfiguration = NETunnelProviderProtocol() + // 关键点:这个 ID 必须和 Extension 的 Bundle ID 完全一致 + let extensionBundleID = "com.alick.swiftier.SwiftierNE" + protocolConfiguration.providerBundleIdentifier = extensionBundleID + protocolConfiguration.serverAddress = "Swiftier" + + print("VPNManager: Setting providerBundleIdentifier to: \(extensionBundleID)") + + manager.protocolConfiguration = protocolConfiguration + manager.isEnabled = true + + manager.saveToPreferences { [weak self] error in + if let error = error { + print("VPNManager: Critical Error saving VPN profile!") + print("VPNManager: Error Domain: \((error as NSError).domain)") + print("VPNManager: Error Code: \((error as NSError).code)") + print("VPNManager: Description: \(error.localizedDescription)") + print("VPNManager: UserInfo: \((error as NSError).userInfo)") + } else { + print("VPNManager: VPN Profile saved successfully.") + self?.manager = manager + self?.isReady = true + + // Save again for good measure (sometimes required to persist fully) + manager.saveToPreferences { error in + if let error = error { + print("VPNManager: Error on second save: \(error)") + } else { + print("VPNManager: Second save successful.") + } + } + + self?.loadPreferences() // Reload to be sure + } + } + } + + func saveConfigToAppGroup(configContent: String) -> URL? { + guard let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.alick.swiftier") else { + print("Failed to get App Group container") + return nil + } + + let configURL = groupURL.appendingPathComponent("config.toml") + do { + try configContent.write(to: configURL, atomically: true, encoding: .utf8) + return configURL + } catch { + print("Failed to write config to App Group: \(error)") + return nil + } + } + + func startVPN(configContent: String) { + guard let manager = manager else { + print("VPN Manager not ready") + return + } + + // 我们不直接通过 options 传递大文本,而是保存到 App Group + guard let _ = saveConfigToAppGroup(configContent: configContent) else { + self.statusText = "保存配置失败" + return + } + + let options: [String: NSObject] = [:] // Config is read from App Group file by NE + + do { + try manager.connection.startVPNTunnel(options: options) + print("VPN Start requested") + } catch { + print("Error starting VPN: \(error)") + self.statusText = "启动失败: \(error.localizedDescription)" + } + } + + func stopVPN() { + manager?.connection.stopVPNTunnel() + } + + /// Send a message to the running NE provider and get a response + func sendProviderMessage(_ message: String, completion: @escaping (Data?) -> Void) { + guard let session = manager?.connection as? NETunnelProviderSession, + let messageData = message.data(using: .utf8) else { + completion(nil) + return + } + + do { + try session.sendProviderMessage(messageData) { response in + completion(response) + } + } catch { + print("sendProviderMessage failed: \(error)") + completion(nil) + } + } + + /// Request running info JSON from NE via IPC + func requestRunningInfo(completion: @escaping (String?) -> Void) { + sendProviderMessage("running_info") { data in + if let data = data, let json = String(data: data, encoding: .utf8) { + completion(json) + } else { + completion(nil) + } + } + } + + @objc private func vpnStatusDidChange(_ notification: Notification) { + updateStatus() + } + + private func updateStatus() { + guard let connection = manager?.connection else { return } + + DispatchQueue.main.async { + self.status = connection.status + + switch connection.status { + case .connected: + self.isConnected = true + self.statusText = "已连接" + case .connecting: + self.isConnected = false + self.statusText = "连接中..." + case .disconnected: + self.isConnected = false + self.statusText = "未连接" + case .disconnecting: + self.isConnected = false + self.statusText = "断开中..." + case .invalid: + self.isConnected = false + self.statusText = "无效状态" + case .reasserting: + self.isConnected = false + self.statusText = "重连中..." + @unknown default: + self.statusText = "未知状态" + } + } + } +} diff --git a/SwiftierNE/AddressHelper.swift b/SwiftierNE/AddressHelper.swift new file mode 100755 index 0000000..5ad26e2 --- /dev/null +++ b/SwiftierNE/AddressHelper.swift @@ -0,0 +1,63 @@ +import Foundation + +func normalizeCIDR(_ cidr: String) -> RunningIPv4CIDR? { + guard var cidrStruct = RunningIPv4CIDR(from: cidr) else { return nil } + cidrStruct.address = ipv4MaskedSubnet(cidrStruct) + return cidrStruct +} + +func cidrToSubnetMask(_ cidr: Int) -> String? { + guard cidr >= 0 && cidr <= 32 else { return nil } + + let mask: UInt32 = cidr == 0 ? 0 : UInt32.max << (32 - cidr) + + let octet1 = (mask >> 24) & 0xFF + let octet2 = (mask >> 16) & 0xFF + let octet3 = (mask >> 8) & 0xFF + let octet4 = mask & 0xFF + + return "\(octet1).\(octet2).\(octet3).\(octet4)" +} + +func ipv4MaskedSubnet(_ cidr: RunningIPv4CIDR) -> RunningIPv4Addr { + let mask: UInt32 = cidr.networkLength == 0 ? 0 : UInt32.max << (32 - cidr.networkLength) + return RunningIPv4Addr(addr: cidr.address.addr & mask) +} + +func ipv4SubnetsOverlap(bigger: RunningIPv4CIDR, smaller: RunningIPv4CIDR) -> Bool { + if bigger.networkLength > smaller.networkLength { + return ipv4SubnetsOverlap(bigger: smaller, smaller: bigger) + } + let mask: UInt32 = bigger.networkLength == 0 ? 0 : UInt32.max << (32 - bigger.networkLength) + return (bigger.address.addr & mask) == (smaller.address.addr & mask) +} + +// Added from PacketTunnelProvider.swift to centralize logic + +func maskedAddress(_ addr: RunningIPv4Addr, networkLength: Int) -> String { + let mask = networkLength == 0 ? UInt32(0) : UInt32.max << (32 - networkLength) + let network = addr.addr & mask + return "\((network >> 24) & 0xFF).\((network >> 16) & 0xFF).\((network >> 8) & 0xFF).\(network & 0xFF)" +} + +func maskedAddressFromStrings(_ ip: String, mask: String) -> String { + let ipParts = ip.split(separator: ".").compactMap { UInt32($0) } + let maskParts = mask.split(separator: ".").compactMap { UInt32($0) } + guard ipParts.count == 4, maskParts.count == 4 else { return ip } + return "\(ipParts[0] & maskParts[0]).\(ipParts[1] & maskParts[1]).\(ipParts[2] & maskParts[2]).\(ipParts[3] & maskParts[3])" +} + +func parseCIDR(_ cidrStr: String) -> (address: String, mask: String)? { + let parts = cidrStr.split(separator: "/") + guard parts.count == 2, + let cidr = Int(parts[1]), + let mask = cidrToSubnetMask(cidr) else { return nil } + + // Apply mask to get network address + let ipParts = String(parts[0]).split(separator: ".").compactMap { UInt32($0) } + let maskParts = mask.split(separator: ".").compactMap { UInt32($0) } + guard ipParts.count == 4, maskParts.count == 4 else { return nil } + + let networkAddr = "\(ipParts[0] & maskParts[0]).\(ipParts[1] & maskParts[1]).\(ipParts[2] & maskParts[2]).\(ipParts[3] & maskParts[3])" + return (networkAddr, mask) +} diff --git a/SwiftierNE/EasyTierShared.swift b/SwiftierNE/EasyTierShared.swift new file mode 100755 index 0000000..dc952ec --- /dev/null +++ b/SwiftierNE/EasyTierShared.swift @@ -0,0 +1,164 @@ +import NetworkExtension +import os + +public let APP_BUNDLE_ID: String = "com.alick.swiftier" +public let APP_GROUP_ID: String = "group.com.alick.swiftier" +public let ICLOUD_CONTAINER_ID: String = "iCloud.com.alick.swiftier" +public let LOG_FILENAME: String = "easytier.log" + +public enum LogLevel: String, Codable, CaseIterable { + case trace = "trace" + case debug = "debug" + case info = "info" + case warn = "warn" + case error = "error" +} + +public struct EasyTierOptions: Codable { + public var config: String = "" + public var ipv4: String? + public var ipv6: String? + public var mtu: Int? + public var routes: [String] = [] + public var logLevel: LogLevel = .info + public var magicDNS: Bool = false + public var dns: [String] = [] + + public init() {} +} + +public struct TunnelNetworkSettingsSnapshot: Codable, Equatable { + public struct IPv4Subnet: Codable, Hashable { + public var address: String + public var subnetMask: String + + public init(address: String, subnetMask: String) { + self.address = address + self.subnetMask = subnetMask + } + } + + public struct IPv6Subnet: Codable, Hashable { + public var address: String + public var networkPrefixLength: Int + + public init(address: String, networkPrefixLength: Int) { + self.address = address + self.networkPrefixLength = networkPrefixLength + } + } + + public struct IPv4: Codable, Equatable { + public var subnets: Set + public var includedRoutes: Set? + public var excludedRoutes: Set? + + public init( + addresses: [String], + subnetMasks: [String], + includedRoutes: [IPv4Subnet]? = nil, + excludedRoutes: [IPv4Subnet]? = nil + ) { + subnets = .init() + for (index, address) in addresses.enumerated() { + subnets.insert( + IPv4Subnet(address: address, subnetMask: subnetMasks[index]) + ) + } + if let includedRoutes, !includedRoutes.isEmpty { + self.includedRoutes = Set(includedRoutes) + } + if let excludedRoutes, !excludedRoutes.isEmpty { + self.excludedRoutes = Set(excludedRoutes) + } + } + } + + public struct IPv6: Codable, Equatable { + public var subnets: Set + public var includedRoutes: Set? + public var excludedRoutes: Set? + + public init( + addresses: [String], + networkPrefixLengths: [Int], + includedRoutes: [IPv6Subnet]? = nil, + excludedRoutes: [IPv6Subnet]? = nil + ) { + subnets = .init() + for (index, address) in addresses.enumerated() { + subnets.insert( + IPv6Subnet( + address: address, + networkPrefixLength: networkPrefixLengths[index] + ) + ) + } + if let includedRoutes { + self.includedRoutes = Set(includedRoutes) + } + if let excludedRoutes { + self.excludedRoutes = Set(excludedRoutes) + } + } + } + + public struct DNS: Codable, Equatable { + public var servers: Set + public var searchDomains: Set? + public var matchDomains: Set? + + public init( + servers: [String], + searchDomains: [String]? = nil, + matchDomains: [String]? = nil + ) { + self.servers = Set(servers) + if let searchDomains { + self.searchDomains = Set(searchDomains) + } + if let matchDomains { + self.matchDomains = Set(matchDomains) + } + } + } + + public var ipv4: IPv4? + public var ipv6: IPv6? + public var dns: DNS? + public var mtu: UInt32? + + public init(ipv4: IPv4? = nil, ipv6: IPv6? = nil, dns: DNS? = nil, mtu: UInt32? = nil) { + self.ipv4 = ipv4 + self.ipv6 = ipv6 + self.dns = dns + self.mtu = mtu + } +} + +public enum ProviderCommand: String, Codable, CaseIterable { + case exportOSLog = "export_oslog" + case runningInfo = "running_info" + case lastNetworkSettings = "last_network_settings" +} + +public func connectWithManager(_ manager: NETunnelProviderManager, logger: Logger? = nil) async throws { + manager.isEnabled = true + if let defaults = UserDefaults(suiteName: APP_GROUP_ID) { + manager.protocolConfiguration?.includeAllNetworks = defaults.bool(forKey: "includeAllNetworks") + manager.protocolConfiguration?.excludeLocalNetworks = defaults.bool(forKey: "excludeLocalNetworks") + if #available(iOS 16.4, *) { + manager.protocolConfiguration?.excludeCellularServices = defaults.bool(forKey: "excludeCellularServices") + manager.protocolConfiguration?.excludeAPNs = defaults.bool(forKey: "excludeAPNs") + } + if #available(iOS 17.4, macOS 14.4, *) { + manager.protocolConfiguration?.excludeDeviceCommunication = defaults.bool(forKey: "excludeDeviceCommunication") + } + manager.protocolConfiguration?.enforceRoutes = defaults.bool(forKey: "enforceRoutes") + if let logger { + logger.debug("connect with protocol configuration: \(manager.protocolConfiguration)") + } + } + try await manager.saveToPreferences() + try manager.connection.startVPNTunnel() +} diff --git a/SwiftierNE/Info.plist b/SwiftierNE/Info.plist new file mode 100644 index 0000000..3059459 --- /dev/null +++ b/SwiftierNE/Info.plist @@ -0,0 +1,13 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.networkextension.packet-tunnel + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).PacketTunnelProvider + + + diff --git a/SwiftierNE/InfoModels.swift b/SwiftierNE/InfoModels.swift new file mode 100755 index 0000000..2cbebd4 --- /dev/null +++ b/SwiftierNE/InfoModels.swift @@ -0,0 +1,85 @@ +import Foundation + +struct RunningInfo: Decodable { + var myNodeInfo: RunningNodeInfo? + var routes: [RunningRoute] + + enum CodingKeys: String, CodingKey { + case myNodeInfo = "my_node_info" + case routes + } +} + +struct RunningNodeInfo: Decodable { + var virtualIPv4: RunningIPv4CIDR? + + enum CodingKeys: String, CodingKey { + case virtualIPv4 = "virtual_ipv4" + } +} + +struct RunningRoute: Decodable { + var proxyCIDRs: [String] + + enum CodingKeys: String, CodingKey { + case proxyCIDRs = "proxy_cidrs" + } +} + +struct RunningIPv4CIDR: Decodable, Hashable { + var address: RunningIPv4Addr + var networkLength: Int + + init?(from string: String) { + // Expect CIDR notation like "192.168.1.10/24" + let toParsed = string.contains("/") ? string : string + "/32" + let parts = toParsed.split(separator: "/") + guard parts.count == 2, + let addr = RunningIPv4Addr(from: String(parts[0])), + let length = Int(parts[1]), + (0...32).contains(length) else { + return nil + } + self.address = addr + self.networkLength = length + } + + init(address: RunningIPv4Addr, length: Int) { + self.address = address + networkLength = length + } + + enum CodingKeys: String, CodingKey { + case address + case networkLength = "network_length" + } +} + +struct RunningIPv4Addr: Decodable, Hashable { + var addr: UInt32 + + init(addr: UInt32) { + self.addr = addr + } + + init?(from string: String) { + // Expect dotted-quad IPv4, e.g., "192.168.1.10" + let parts = string.split(separator: ".") + guard parts.count == 4 else { return nil } + var bytes = [UInt32]() + bytes.reserveCapacity(4) + for p in parts { + guard let val = UInt32(p), val <= 255 else { return nil } + bytes.append(val) + } + // Pack into network-order (big-endian) 32-bit integer + self.addr = (bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3] + } + + var description: String { + let ip = addr + return "\((ip >> 24) & 0xFF).\((ip >> 16) & 0xFF).\((ip >> 8) & 0xFF).\(ip & 0xFF)" + } +} + + diff --git a/SwiftierNE/Logger.swift b/SwiftierNE/Logger.swift new file mode 100644 index 0000000..09877ba --- /dev/null +++ b/SwiftierNE/Logger.swift @@ -0,0 +1,5 @@ +import os +import Foundation + +let loggerSubsystem = "\(APP_BUNDLE_ID).ne" +let logger = Logger(subsystem: loggerSubsystem, category: "swift") diff --git a/SwiftierNE/OSLogExporter.swift b/SwiftierNE/OSLogExporter.swift new file mode 100755 index 0000000..40c8fae --- /dev/null +++ b/SwiftierNE/OSLogExporter.swift @@ -0,0 +1,80 @@ +import Foundation +import OSLog + +enum OSLogExporter { + private static let dateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + enum ExportError: Error { + case containerUnavailable + case emptyLogs + } + + static func exportToAppGroup(appGroupID: String) throws -> URL { + guard let containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupID + ) else { + throw ExportError.containerUnavailable + } + let store = try OSLogStore(scope: .currentProcessIdentifier) + let startPosition = store.position(timeIntervalSinceLatestBoot: 0) + let entries = try store.getEntries(at: startPosition) + var output = "" + + for entry in entries { + if let log = entry as? OSLogEntryLog { + output.append(format(log)) + output.append("\n") + } else if let signpost = entry as? OSLogEntrySignpost { + output.append(format(signpost)) + output.append("\n") + } + } + + guard !output.isEmpty else { + throw ExportError.emptyLogs + } + + let filename = "EasyTier-NE-oslog-\(Int(Date().timeIntervalSince1970)).log" + let url = containerURL.appendingPathComponent(filename) + try output.write(to: url, atomically: true, encoding: .utf8) + return url + } + + private static func format(_ log: OSLogEntryLog) -> String { + let timestamp = dateFormatter.string(from: log.date) + let level = formatLevel(log.level) + let subsystem = log.subsystem + let category = log.category + return "[\(timestamp)] [\(level)] [\(subsystem)] [\(category)] \(log.composedMessage)" + } + + private static func format(_ signpost: OSLogEntrySignpost) -> String { + let timestamp = dateFormatter.string(from: signpost.date) + let type = String(describing: signpost.signpostType).uppercased() + let subsystem = signpost.subsystem + let category = signpost.category + let name = signpost.signpostName + return "[\(timestamp)] [SIGNPOST \(type)] [\(subsystem)] [\(category)] \(name)" + } + + private static func formatLevel(_ level: OSLogEntryLog.Level) -> String { + switch level { + case .debug: + return "DEBUG" + case .info: + return "INFO" + case .notice: + return "NOTICE" + case .error: + return "ERROR" + case .fault: + return "FAULT" + default: + return "UNKNOWN" + } + } +} diff --git a/SwiftierNE/PacketTunnelProvider.swift b/SwiftierNE/PacketTunnelProvider.swift new file mode 100644 index 0000000..d36d158 --- /dev/null +++ b/SwiftierNE/PacketTunnelProvider.swift @@ -0,0 +1,441 @@ +import NetworkExtension +import os + + +let debounceInterval: TimeInterval = 0.5 + +class PacketTunnelProvider: NEPacketTunnelProvider { + + // Hold a weak reference for C callback bridging + private static weak var current: PacketTunnelProvider? + + + private var lastAppliedSettings: SettingsSnapshot? + private var needReapplySettings = false + private var debounceWorkItem: DispatchWorkItem? + private var parsedIPv4: String? // from config.toml + private var parsedSubnet: String? // e.g. "255.255.255.0" + private var parsedMTU: Int? + + // MARK: - Config Loading + + private func loadConfig() -> String? { + guard let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP_ID) else { + logger.error("无法访问 App Group 容器: \(APP_GROUP_ID)") + return nil + } + let configURL = groupURL.appendingPathComponent("config.toml") + do { + let content = try String(contentsOf: configURL, encoding: .utf8) + logger.info("成功从 App Group 读取配置文件") + return content + } catch { + logger.error("读取配置文件失败: \(error.localizedDescription)") + return nil + } + } + + /// Parse ipv4 and mtu from TOML config for initial network settings + private func parseConfigHints(_ toml: String) { + for line in toml.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("#") { continue } + + let parts = trimmed.split(separator: "=", maxSplits: 1).map { $0.trimmingCharacters(in: .whitespaces) } + guard parts.count == 2 else { continue } + let key = parts[0] + let val = parts[1].replacingOccurrences(of: "\"", with: "") + + switch key { + case "ipv4": + // e.g. "10.126.126.1/24" + let cidrParts = val.split(separator: "/") + if cidrParts.count == 2 { + parsedIPv4 = String(cidrParts[0]) + if let cidr = Int(cidrParts[1]) { + parsedSubnet = cidrToSubnetMask(cidr) + } + } + case "mtu": + parsedMTU = Int(val) + default: + break + } + } + } + + // MARK: - Running Info Callback + + private func registerRunningInfoCallback() { + let callback: @convention(c) () -> Void = { + PacketTunnelProvider.current?.handleRunningInfoChanged() + } + do { + try EasyTierCore.registerRunningInfoCallback(callback) + logger.info("已注册 running info callback") + } catch { + logger.error("注册 running info callback 失败: \(error)") + } + } + + private func handleRunningInfoChanged() { + logger.info("Running info 已变化,触发网络设置更新") + enqueueSettingsUpdate() + } + + // MARK: - Stop Callback + + private func registerStopCallback() { + let callback: @convention(c) () -> Void = { + PacketTunnelProvider.current?.handleRustStop() + } + do { + try EasyTierCore.registerStopCallback(callback) + logger.info("已注册 stop callback") + } catch { + logger.error("注册 stop callback 失败: \(error)") + } + } + + private func handleRustStop() { + let msg = EasyTierCore.getLatestErrorMessage() ?? "Unknown" + logger.error("Rust Core 已停止: \(msg)") + + // Save error to App Group for host app + if let defaults = UserDefaults(suiteName: APP_GROUP_ID) { + defaults.set(msg, forKey: "TunnelLastError") + defaults.synchronize() + } + + DispatchQueue.main.async { + self.cancelTunnelWithError(NSError( + domain: "SwiftierNE", code: 2, + userInfo: [NSLocalizedDescriptionKey: msg] + )) + } + } + + // MARK: - Dynamic Network Settings + + private func enqueueSettingsUpdate() { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + // Cancel previous pending debounce to batch rapid changes + self.debounceWorkItem?.cancel() + + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + if self.reasserting { + logger.info("设置更新已在进行中,排队等待") + self.needReapplySettings = true + return + } + self.applyNetworkSettings { error in + if let error { + logger.error("设置更新失败: \(error)") + } + } + } + self.debounceWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + debounceInterval, execute: workItem) + } + } + + private func applyNetworkSettings(_ completion: @escaping (Error?) -> Void) { + guard !reasserting else { + completion(NSError(domain: "SwiftierNE", code: 3, userInfo: [NSLocalizedDescriptionKey: "still in progress"])) + return + } + reasserting = true + + needReapplySettings = false + let settings = buildSettings() + let newSnapshot = SettingsSnapshot(from: settings) + + let wrappedCompletion: (Error?) -> Void = { error in + DispatchQueue.main.async { + if error == nil { + self.lastAppliedSettings = newSnapshot + } + completion(error) + self.reasserting = false + if self.needReapplySettings { + self.needReapplySettings = false + self.applyNetworkSettings(completion) + } + } + } + + // Skip if settings haven't changed + if newSnapshot == lastAppliedSettings { + logger.info("网络设置未变化,跳过更新") + wrappedCompletion(nil) + return + } + + let needSetTunFd = shouldUpdateTunFd(old: lastAppliedSettings, new: newSnapshot) + logger.info("应用网络设置, needTunFd=\(needSetTunFd)") + + setTunnelNetworkSettings(settings) { [weak self] error in + guard let self else { + wrappedCompletion(error) + return + } + if let error { + logger.error("setTunnelNetworkSettings 失败: \(error)") + wrappedCompletion(error) + return + } + + // Pass TUN fd to Rust Core + if needSetTunFd { + // Prefer packetFlow fd (the correct NE-created utun) + let packetFlowFd = self.packetFlow.value(forKeyPath: "socket.fileDescriptor") as? Int32 + let scanFd = self.findTunnelFileDescriptor() + let tunFd = packetFlowFd ?? scanFd + + logger.error("TUN fd 诊断: packetFlow=\(packetFlowFd.map { String($0) } ?? "nil", privacy: .public), scan=\(scanFd.map { String($0) } ?? "nil", privacy: .public), chosen=\(tunFd.map { String($0) } ?? "nil", privacy: .public)") + + if let fd = tunFd { + do { + try EasyTierCore.setTunFd(fd) + logger.error("TUN fd 已设置: \(fd, privacy: .public)") + } catch { + logger.error("设置 TUN fd 失败: \(error, privacy: .public)") + wrappedCompletion(error) + return + } + } else { + logger.error("无法获取 TUN fd(packetFlow 和 scan 均失败)") + } + } + + logger.info("网络设置已应用") + wrappedCompletion(nil) + } + } + + /// Build NEPacketTunnelNetworkSettings dynamically from get_running_info() + private func buildSettings() -> NEPacketTunnelNetworkSettings { + let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1") + let runningInfo = fetchRunningInfo() + + // Determine IPv4 address: prefer running info, fallback to config + let ipv4Address: String + let subnetMask: String + + + if let info = runningInfo, + let nodeIp = info.myNodeInfo?.virtualIPv4 { + ipv4Address = nodeIp.address.description + + subnetMask = cidrToSubnetMask(nodeIp.networkLength) ?? "255.255.255.0" + } else if let configIp = parsedIPv4, let configMask = parsedSubnet { + ipv4Address = configIp + subnetMask = configMask + } else { + logger.warning("无 IPv4 地址可用,返回空设置") + return settings + } + + let ipv4Settings = NEIPv4Settings(addresses: [ipv4Address], subnetMasks: [subnetMask]) + + // Build routes from running info + var routes: [NEIPv4Route] = [] + + if let info = runningInfo { + // Add routes from peer proxy CIDRs + for route in info.routes { + for cidrStr in route.proxyCIDRs { + if let parsed = parseCIDR(cidrStr) { + routes.append(NEIPv4Route( + destinationAddress: parsed.address, + subnetMask: parsed.mask + )) + } + } + } + + // Add the virtual network route + if let nodeIp = info.myNodeInfo?.virtualIPv4 { + let networkAddr = maskedAddress(nodeIp.address, networkLength: nodeIp.networkLength) + let netMask = cidrToSubnetMask(nodeIp.networkLength) ?? "255.255.255.0" + routes.append(NEIPv4Route(destinationAddress: networkAddr, subnetMask: netMask)) + } + } + + // Fallback: if no routes from running info, use config subnet + if routes.isEmpty { + if let configIp = parsedIPv4, let configMask = parsedSubnet { + // Route only the virtual subnet, not all traffic + let networkAddr = maskedAddressFromStrings(configIp, mask: configMask) + routes.append(NEIPv4Route(destinationAddress: networkAddr, subnetMask: configMask)) + } else { + // Last resort: still don't route all traffic to avoid breaking connectivity + routes.append(NEIPv4Route(destinationAddress: ipv4Address, subnetMask: "255.255.255.255")) + } + } + + ipv4Settings.includedRoutes = routes + settings.ipv4Settings = ipv4Settings + settings.mtu = NSNumber(value: parsedMTU ?? 1380) + + return settings + } + + // MARK: - Tunnel Lifecycle + + override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) { + logger.info("正在启动 VPN Tunnel...") + PacketTunnelProvider.current = self + + // 1. 读取配置 + guard let configToml = loadConfig() else { + let error = NSError(domain: "SwiftierNE", code: 1, userInfo: [NSLocalizedDescriptionKey: "无法读取 VPN 配置"]) + completionHandler(error) + return + } + + // 2. 解析配置中的 IPv4 和 MTU 信息 + parseConfigHints(configToml) + + // 3. 初始化 Logger(从 App Group 读取用户设置的日志等级) + let savedLevel: LogLevel = { + if let defaults = UserDefaults(suiteName: APP_GROUP_ID), + let raw = defaults.string(forKey: "logLevel"), + let level = LogLevel(rawValue: raw.lowercased()) { + return level + } + return .info + }() + initRustLogger(level: savedLevel) + + // 4. 启动 Core(macOS cfg 已 patch,不会自动创建 TUN,通过 set_tun_fd 传入) + do { + try EasyTierCore.runNetworkInstance(config: configToml) + logger.info("EasyTier Core 启动成功") + } catch { + logger.error("EasyTier Core 启动失败: \(error)") + completionHandler(error) + return + } + + // 5. 注册回调 + registerStopCallback() + registerRunningInfoCallback() + + // 6. 应用网络设置并传入 TUN fd + applyNetworkSettings(completionHandler) + } + + override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { + logger.info("正在停止 VPN Tunnel, reason: \(reason.rawValue)") + EasyTierCore.stopNetworkInstance() + PacketTunnelProvider.current = nil + completionHandler() + } + + // MARK: - App IPC (handleAppMessage) + + override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) { + guard let handler = completionHandler else { return } + + // Command: "running_info" -> return get_running_info JSON + if let command = String(data: messageData, encoding: .utf8) { + switch command { + case "running_info": + if let json = EasyTierCore.getRunningInfo(), + let data = json.data(using: .utf8) { + handler(data) + } else { + handler(nil) + } + default: + handler(nil) + } + } else { + handler(nil) + } + } + + override func sleep(completionHandler: @escaping () -> Void) { + completionHandler() + } + + override func wake() { + // Trigger a settings refresh on wake + enqueueSettingsUpdate() + } + + // MARK: - Helpers + + private func fetchRunningInfo() -> RunningInfo? { + guard let json = EasyTierCore.getRunningInfo(), + let data = json.data(using: .utf8) else { return nil } + do { + return try JSONDecoder().decode(RunningInfo.self, from: data) + } catch { + logger.error("解析 running info 失败: \(error)") + return nil + } + } + + /// Find the utun file descriptor by scanning open FDs + /// Delegates to the shared implementation in TunnelHelper.swift + private func findTunnelFileDescriptor() -> Int32? { + logger.info("尝试通过 FD 扫描查找 TUN 接口") + return tunnelFileDescriptor() + } + + private func shouldUpdateTunFd(old: SettingsSnapshot?, new: SettingsSnapshot) -> Bool { + // 只要有 IP 地址就应该设置 TUN fd + guard new.hasIPAddresses else { + logger.info("shouldUpdateTunFd: new snapshot has no IP addresses") + return false + } + // 每次 setTunnelNetworkSettings 成功后都应该重新设置 TUN fd, + // 因为系统可能会重建 utun 接口,导致之前的 fd 失效。 + // 只有当设置完全相同时(会被上层 skip),才不需要更新。 + logger.info("shouldUpdateTunFd: hasIP=true, always update tun fd") + return true + } +} + +// MARK: - Helper Models + + + +// MARK: - Settings Snapshot (for change detection) + +struct SettingsSnapshot: Equatable { + var ipv4Addresses: [String] + var ipv4SubnetMasks: [String] + var routes: [(String, String)] // (destination, mask) + var mtu: Int? + + var hasIPAddresses: Bool { + !ipv4Addresses.isEmpty && ipv4Addresses.first?.isEmpty == false + } + + init(from settings: NEPacketTunnelNetworkSettings) { + ipv4Addresses = settings.ipv4Settings?.addresses ?? [] + ipv4SubnetMasks = settings.ipv4Settings?.subnetMasks ?? [] + routes = settings.ipv4Settings?.includedRoutes?.map { + ($0.destinationAddress, $0.destinationSubnetMask) + } ?? [] + mtu = settings.mtu?.intValue + } + + static func == (lhs: SettingsSnapshot, rhs: SettingsSnapshot) -> Bool { + lhs.ipv4Addresses == rhs.ipv4Addresses && + lhs.ipv4SubnetMasks == rhs.ipv4SubnetMasks && + lhs.routes.count == rhs.routes.count && + zip(lhs.routes, rhs.routes).allSatisfy { $0.0 == $1.0 && $0.1 == $1.1 } && + lhs.mtu == rhs.mtu + } +} + +// MARK: - Network Utility Functions + + diff --git a/SwiftierNE/SwiftierCore.swift b/SwiftierNE/SwiftierCore.swift new file mode 100644 index 0000000..0dd9770 --- /dev/null +++ b/SwiftierNE/SwiftierCore.swift @@ -0,0 +1,121 @@ +import Foundation + +enum EasyTierError: Error { + case initializationFailed(String) + case executionFailed(String) +} + +// 封装 Rust FFI 调用 +struct EasyTierCore { + // 提取 Rust 返回的错误信息 + static func extractRustError(_ errPtr: UnsafePointer?) -> String? { + guard let errPtr = errPtr else { return nil } + let message = String(cString: errPtr) + free_string(errPtr) + return message + } + + // 初始化日志 + static func initLogger(path: String, level: String, subsystem: String) throws { + var errPtr: UnsafePointer? = nil + let ret = path.withCString { pathPtr in + level.withCString { levelPtr in + subsystem.withCString { subsystemPtr in + init_logger(pathPtr, levelPtr, subsystemPtr, &errPtr) + } + } + } + + if ret != 0 { + let msg = extractRustError(errPtr) ?? "Unknown logger error" + throw EasyTierError.initializationFailed(msg) + } + } + + // 启动网络实例 + static func runNetworkInstance(config: String) throws { + var errPtr: UnsafePointer? = nil + let ret = config.withCString { configPtr in + run_network_instance(configPtr, &errPtr) + } + + if ret != 0 { + let msg = extractRustError(errPtr) ?? "Unknown network start error" + throw EasyTierError.executionFailed(msg) + } + } + + // 停止网络实例 + static func stopNetworkInstance() { + stop_network_instance() + } + + // 设置 TUN 文件描述符 + static func setTunFd(_ fd: Int32) throws { + var errPtr: UnsafePointer? = nil + let ret = set_tun_fd(fd, &errPtr) + + if ret != 0 { + let msg = extractRustError(errPtr) ?? "Unknown tun fd error" + throw EasyTierError.executionFailed(msg) + } + } + + // 注册停止回调 + static func registerStopCallback(_ callback: @convention(c) () -> Void) throws { + var errPtr: UnsafePointer? = nil + let ret = register_stop_callback(callback, &errPtr) + if ret != 0 { + let msg = extractRustError(errPtr) ?? "Failed to register stop callback" + throw EasyTierError.initializationFailed(msg) + } + } + + // 获取最新错误信息 + static func getLatestErrorMessage() -> String? { + var msgPtr: UnsafePointer? = nil + var errPtr: UnsafePointer? = nil + + let ret = get_latest_error_msg(&msgPtr, &errPtr) + + if ret == 0, let ptr = msgPtr { + let msg = String(cString: ptr) + free_string(ptr) + return msg + } + + if let ptr = errPtr { + free_string(ptr) + } + return nil + } + + // 注册运行信息变化回调 + static func registerRunningInfoCallback(_ callback: @convention(c) () -> Void) throws { + var errPtr: UnsafePointer? = nil + let ret = register_running_info_callback(callback, &errPtr) + if ret != 0 { + let msg = extractRustError(errPtr) ?? "Failed to register running info callback" + throw EasyTierError.initializationFailed(msg) + } + } + + // 获取运行状态 JSON + static func getRunningInfo() -> String? { + var jsonPtr: UnsafePointer? = nil + var errPtr: UnsafePointer? = nil + + let ret = get_running_info(&jsonPtr, &errPtr) + + if ret == 0, let ptr = jsonPtr { + let json = String(cString: ptr) + free_string(ptr) + return json + } + + if let ptr = errPtr { + free_string(ptr) + } + return nil + } +} diff --git a/SwiftierNE/SwiftierNE-Bridging-Header.h b/SwiftierNE/SwiftierNE-Bridging-Header.h new file mode 100644 index 0000000..a1387cd --- /dev/null +++ b/SwiftierNE/SwiftierNE-Bridging-Header.h @@ -0,0 +1,7 @@ +#ifndef SwiftierNE_Bridging_Header_h +#define SwiftierNE_Bridging_Header_h + +#include "../EasyTierCore/include/EasyTierCore.h" +#include + +#endif /* SwiftierNE_Bridging_Header_h */ diff --git a/SwiftierNE/SwiftierNE.entitlements b/SwiftierNE/SwiftierNE.entitlements new file mode 100644 index 0000000..9a6ed63 --- /dev/null +++ b/SwiftierNE/SwiftierNE.entitlements @@ -0,0 +1,20 @@ + + + + + com.apple.developer.networking.networkextension + + packet-tunnel-provider + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.com.alick.swiftier + + com.apple.security.network.client + + com.apple.security.network.server + + + diff --git a/SwiftierNE/TunnelHelper.swift b/SwiftierNE/TunnelHelper.swift new file mode 100755 index 0000000..2505620 --- /dev/null +++ b/SwiftierNE/TunnelHelper.swift @@ -0,0 +1,94 @@ +import Foundation +import NetworkExtension +import os + +// MARK: - TUN File Descriptor Discovery + +func tunnelFileDescriptor() -> Int32? { + let CTLIOCGINFO_VALUE: UInt = 0xc0644e03 + logger.warning("tunnelFileDescriptor() use fallback") + var ctlInfo = ctl_info() + withUnsafeMutablePointer(to: &ctlInfo.ctl_name) { + $0.withMemoryRebound(to: CChar.self, capacity: MemoryLayout.size(ofValue: $0.pointee)) { + _ = strcpy($0, "com.apple.net.utun_control") + } + } + for fd: Int32 in 0...1024 { + var addr = sockaddr_ctl() + var ret: Int32 = -1 + var len = socklen_t(MemoryLayout.size(ofValue: addr)) + withUnsafeMutablePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + ret = getpeername(fd, $0, &len) + } + } + if ret != 0 || addr.sc_family != AF_SYSTEM { + continue + } + if ctlInfo.ctl_id == 0 { + ret = ioctl(fd, CTLIOCGINFO_VALUE, &ctlInfo) + if ret != 0 { + continue + } + } + if addr.sc_id == ctlInfo.ctl_id { + let dupFd = dup(fd) + logger.info("tunnelFileDescriptor() found fd: \(fd, privacy: .public), dup to: \(dupFd, privacy: .public)") + return dupFd + } + } + return nil +} + +func initRustLogger(level: LogLevel) { + guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP_ID) else { + logger.error("initRustLogger() failed: App Group container not found") + return + } + let path = containerURL.appendingPathComponent(LOG_FILENAME).path + logger.info("initRustLogger() write to: \(path, privacy: .public)") + + var errPtr: UnsafePointer? = nil + let ret = path.withCString { pathPtr in + level.rawValue.withCString { levelPtr in + loggerSubsystem.withCString { subsystemPtr in + return init_logger(pathPtr, levelPtr, subsystemPtr, &errPtr) + } + } + } + if ret != 0 { + let err = extractRustString(errPtr) + logger.error("initRustLogger() failed to init: \(err ?? "Unknown", privacy: .public)") + } +} + +func extractRustString(_ strPtr: UnsafePointer?) -> String? { + guard let strPtr else { + logger.error("extractRustString(): nullptr") + return nil + } + let str = String(cString: strPtr) + free_string(strPtr) + return str +} + +func fetchRunningInfo() -> RunningInfo? { + var infoPtr: UnsafePointer? = nil + var errPtr: UnsafePointer? = nil + if get_running_info(&infoPtr, &errPtr) == 0, let info = extractRustString(infoPtr) { + guard let data = info.data(using: .utf8) else { + logger.error("fetchRunningInfo() invalid utf8 data") + return nil + } + do { + let decoded = try JSONDecoder().decode(RunningInfo.self, from: data) + logger.info("fetchRunningInfo() routes: \(decoded.routes.count)") + return decoded + } catch { + logger.error("fetchRunningInfo() json decode failed: \(error, privacy: .public)") + } + } else if let err = extractRustString(errPtr) { + logger.error("fetchRunningInfo() failed: \(err, privacy: .public)") + } + return nil +} diff --git a/build_rust_universal.sh b/build_rust_universal.sh index 2fe3156..cdfb95d 100755 --- a/build_rust_universal.sh +++ b/build_rust_universal.sh @@ -21,6 +21,7 @@ export PATH="$HOME/.cargo/bin:$PATH" # Set deployment target to avoid linker warnings (built for newer macOS) export MACOSX_DEPLOYMENT_TARGET=13.0 + echo "🚀 Starting Universal Rust Build..." echo "Using cargo: $(which cargo)" diff --git a/plans/migration_plan.md b/plans/migration_plan.md new file mode 100644 index 0000000..0fe10f1 --- /dev/null +++ b/plans/migration_plan.md @@ -0,0 +1,69 @@ +# NetworkExtension 迁移计划 + +本计划旨在将 EasyTier 的特权 Helper 替换为 Apple 推荐的 `NetworkExtension` (Packet Tunnel Provider)。这将移除复杂的 Helper 安装流程,提升用户体验。 + +## 第一阶段:Xcode 项目配置 (需手动操作) + +由于我无法操作 Xcode UI,请你完成以下步骤: + +1. **添加 Network Extension Target**: + - 在 Xcode 中,File -> New -> Target... + - 选择 **Network Extension** (macOS)。 + - Product Name 填 `EasyTierPT` (或者你喜欢的名字,代表 Packet Tunnel)。 + - Language 选择 **Swift**。 + - Provider Type 选择 **Packet Tunnel**。 + - **重要**:Embed in Application 选择 `Swiftier`。 + +2. **配置 Entitlements (App & Extension)**: + - **主 App (`Swiftier`)**: + - 添加 `Network Extensions` 能力 (Capability)。 + - 勾选 `Packet Tunnel`。 + - 添加 `App Groups` 能力,并创建一个 group (例如 `group.com.alick.swiftier`),用于共享配置和日志文件。 + - **Extension (`EasyTierPT`)**: + - 确保 `Network Extensions` 能力已启用,且勾选 `Packet Tunnel`。 + - 添加 **相同的** `App Groups` (`group.com.alick.swiftier`)。 + - **移除 Sandbox (可选但推荐)**: + - NetworkExtension 默认必须沙盒化。如果 EasyTier core 需要访问非沙盒路径,可能需要调整。但通常我们通过 App Group 共享配置。 + +3. **链接库文件**: + - 将 `EasyTierCore` (Rust 库) 链接到新的 `EasyTierPT` target。 + - 确保 `libEasyTierCore.a` 被包含在 Extension 的 `Link Binary With Libraries` 中。 + - 确保 Bridging Header 配置正确,以便 Swift 能调用 Rust C 函数。 + +## 第二阶段:核心代码迁移 + +一旦 Target 创建完成,我将协助你: + +1. **共享 FFI 代码**: + - 将 `EasyTierCore.swift` 和 `SharedTypes.swift` 移动到 App 和 Extension 都能访问的共享目录(或添加到两个 Target)。 + - 确保 Bridging Header 在 Extension 中也能正确引用。 + +2. **实现 `PacketTunnelProvider`**: + - 重写 `PacketTunnelProvider.swift`。 + - 在 `startTunnel` 中调用 `EasyTierCore.startNetwork`。 + - 在 `stopTunnel` 中调用 `EasyTierCore.stopNetwork`。 + - 实现日志重定向:将 Rust 日志写入 App Group 下的共享文件,以便主 App 读取。 + +## 第三阶段:IPC 与控制逻辑更新 + +1. **更新 `CoreService`**: + - 移除 `SMAppService` 和 `HelperManager` 相关代码。 + - 引入 `NETunnelProviderManager` 来管理 VPN 配置和生命周期。 + - 实现 `loadAllFromPreferences` -> `saveToPreferences` 流程来安装 VPN 配置文件。 + +2. **日志与状态同步**: + - **状态**: 使用 `NEVPNStatus` (connected, disconnected, etc.)。 + - **日志**: App 改为从 App Group 共享路径读取日志文件。 + - **实时信息 (Running Info)**: 使用 `provider.sendProviderMessage` 获取实时 JSON 数据。 + +## 第四阶段:清理与测试 + +1. 移除 `EasyTierHelper` target 和相关 plist 文件。 +2. 测试 VPN 连接、断开、自启动行为。 +3. 验证日志实时显示。 + +--- + +**准备好后,请先执行“第一阶段”的 Xcode 操作。** +完成后,请告诉我 **Extension 的 Bundle Identifier** (例如 `com.alick.swiftier.EasyTierPT`) 和 **App Group ID**。 +这将用于接下来的代码编写。 From 4f6464277edd3a8d948a9e2078b1f149e185254a Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Fri, 13 Feb 2026 01:38:30 +0800 Subject: [PATCH 02/30] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E4=B8=AD=E7=9A=84=E6=A3=80=E6=9F=A5=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E3=80=81=E8=87=AA=E5=8A=A8=E6=9B=B4=E6=96=B0=E3=80=81Beta?= =?UTF-8?q?=E9=80=9A=E9=81=93=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BB=85=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E7=89=88=E6=9C=AC=E5=8F=B7=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Swiftier/SettingsView.swift | 486 +----------------------------------- 1 file changed, 6 insertions(+), 480 deletions(-) diff --git a/Swiftier/SettingsView.swift b/Swiftier/SettingsView.swift index d201be3..4e6ac75 100644 --- a/Swiftier/SettingsView.swift +++ b/Swiftier/SettingsView.swift @@ -1,5 +1,4 @@ import SwiftUI -import WebKit import ServiceManagement struct SettingsView: View { @@ -15,13 +14,7 @@ struct SettingsView: View { @State private var showLicense = false - // APP Update Check - @State private var isCheckingAppUpdate = false - @State private var appUpdateStatus: String? - @State private var showAppUpdateDetail = false - @State private var appVersionInfo: (version: String, body: String, downloadURL: String, assets: [[String: Any]])? - @AppStorage("appAutoUpdate") private var appAutoUpdate: Bool = true - @AppStorage("appBetaChannel") private var appBetaChannel: Bool = false + private let logLevels = ["OFF", "ERROR", "WARN", "INFO", "DEBUG", "TRACE"] @@ -41,45 +34,12 @@ struct SettingsView: View { // Native Form Form { Section(header: Text("通用")) { - // macOS 风格的更新状态卡片 - HStack(spacing: 12) { - // 状态图标 - ZStack { - RoundedRectangle(cornerRadius: 8) - .fill(appUpdateStatus == "有新版本可用" ? Color.orange : Color.green) - .frame(width: 40, height: 40) - Image(systemName: appUpdateStatus == "有新版本可用" ? "arrow.down.circle.fill" : "checkmark") - .font(.system(size: 20, weight: .semibold)) - .foregroundColor(.white) - } - - // 状态文字 - VStack(alignment: .leading, spacing: 2) { - Text(LocalizedStringKey(appUpdateStatus ?? "Swiftier 已是最新版本")) - .font(.headline) - let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" - Text("Swiftier \(version)") - .font(.subheadline) - .foregroundColor(.secondary) - } - + HStack { + Text("版本") Spacer() - - // 检查更新按钮 - Button(LocalizedStringKey("检查更新")) { - checkAppUpdate() - } - .disabled(isCheckingAppUpdate) - .buttonStyle(.bordered) - - if isCheckingAppUpdate { - ProgressView().controlSize(.small) - } + Text(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown") + .foregroundColor(.secondary) } - .padding(.vertical, 4) - - Toggle("自动更新", isOn: $appAutoUpdate) - Toggle("接收 Beta 版本", isOn: $appBetaChannel) Toggle("启动 APP 时自动连接", isOn: $connectOnStart) Toggle("连接时图标呼吸闪烁", isOn: $breathEffect) @@ -177,17 +137,7 @@ struct SettingsView: View { .zIndex(100) } - if showAppUpdateDetail, let info = appVersionInfo { - AppUpdateDetailView( - isPresented: $showAppUpdateDetail, - version: info.version, - releaseNotes: info.body, - downloadURL: info.downloadURL, - assets: info.assets - ) - .transition(.move(edge: .bottom)) - .zIndex(101) - } + } .onAppear { checkLaunchAtLogin() @@ -212,91 +162,7 @@ struct SettingsView: View { } - private func checkAppUpdate() { - isCheckingAppUpdate = true - appUpdateStatus = "正在检查..." - - Task { - do { - // 根据是否接收 Beta 版本选择不同的 API 端点 - let apiURL = appBetaChannel - ? "https://api.github.com/repos/AlickH/Swiftier/releases" - : "https://api.github.com/repos/AlickH/Swiftier/releases/latest" - - guard let url = URL(string: apiURL) else { - throw URLError(.badURL) - } - - var request = URLRequest(url: url) - request.setValue("application/vnd.github.v3+json", forHTTPHeaderField: "Accept") - - let (data, _) = try await URLSession.shared.data(for: request) - - var tagName: String? - var body: String? - var htmlURL: String? - - if appBetaChannel { - // Beta 模式:获取所有 releases,取第一个(包括 prerelease) - if let releases = try JSONSerialization.jsonObject(with: data) as? [[String: Any]], - let firstRelease = releases.first { - tagName = firstRelease["tag_name"] as? String - body = firstRelease["body"] as? String - htmlURL = firstRelease["html_url"] as? String - } - } else { - // 正式版模式:只获取 latest - if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { - tagName = json["tag_name"] as? String - body = json["body"] as? String - htmlURL = json["html_url"] as? String - } - } - - guard let tag = tagName, let releaseBody = body, let downloadURL = htmlURL else { - throw NSError(domain: "ParseError", code: 0, userInfo: [NSLocalizedDescriptionKey: "无法解析版本信息"]) - } - - // 清理版本号(去掉 v 前缀) - let remoteVersion = tag.trimmingCharacters(in: CharacterSet(charactersIn: "vV")) - let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" - - if remoteVersion.compare(currentVersion, options: .numeric) == .orderedDescending { - - // Parse assets to find the best download URL - - // For now we just pass the raw assets list, filtering logic will be in the Detail View - // However, we are parsing the RELEASE object above, so we need to get assets from it - - var releaseAssets: [[String: Any]] = [] - if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let assets = json["assets"] as? [[String: Any]] { - releaseAssets = assets - } else if let releases = try JSONSerialization.jsonObject(with: data) as? [[String: Any]], - let first = releases.first, - let assets = first["assets"] as? [[String: Any]] { - releaseAssets = assets - } - await MainActor.run { - appVersionInfo = (remoteVersion, releaseBody, downloadURL, releaseAssets) - showAppUpdateDetail = true - appUpdateStatus = "有新版本可用" - } - } else { - await MainActor.run { - appUpdateStatus = nil // 清空状态,显示默认的"已是最新版本" - } - } - } catch { - await MainActor.run { - appUpdateStatus = "检查失败" - } - } - - await MainActor.run { isCheckingAppUpdate = false } - } - } private func toggleLaunchAtLogin(enabled: Bool) { if #available(macOS 13.0, *) { @@ -316,346 +182,6 @@ struct SettingsView: View { -struct AppUpdateDetailView: View { - @Binding var isPresented: Bool - let version: String - let releaseNotes: String - let downloadURL: String - let assets: [[String: Any]] - - @State private var isDownloading = false - @State private var downloadProgress: Double = 0 - @State private var downloadTask: URLSessionDownloadTask? - @State private var showRevealButton = false - @State private var downloadedFileURL: URL? - - var body: some View { - VStack(spacing: 0) { - // Header - UnifiedHeader(title: LocalizedStringKey("Swiftier \(version) 可用")) { - Button(LocalizedStringKey("稍后")) { - if isDownloading { - downloadTask?.cancel() - isDownloading = false - } - withAnimation { isPresented = false } - } - .buttonStyle(.bordered) - } right: { - if showRevealButton { - HStack { - Button(LocalizedStringKey("查看文件")) { - if let url = downloadedFileURL { - NSWorkspace.shared.activateFileViewerSelecting([url]) - } - } - .buttonStyle(.bordered) - - Button(LocalizedStringKey("安装并自启")) { - smartInstall() - } - .buttonStyle(.borderedProminent) - } - } else if isDownloading { - ProgressView(value: downloadProgress) - .progressViewStyle(.linear) - .frame(width: 100) - } else { - Button(LocalizedStringKey("立即下载")) { - startDownload() - } - .buttonStyle(.borderedProminent) - } - } - - // Release Notes - MarkdownWebView(markdown: releaseNotes) - } - .background(Color(nsColor: .windowBackgroundColor)) - } - - private func startDownload() { - // Find best asset (dmg > zip, arm64 vs x64 logic if needed, but usually universal or specific) - // For simplicity, find first .dmg, then .zip - // In a real scenario, check architecture - - guard let assetURL = findBestAssetURL() else { - // Fallback to opening browser - if let url = URL(string: downloadURL) { - NSWorkspace.shared.open(url) - } - return - } - - isDownloading = true - downloadProgress = 0 - - let url = URL(string: assetURL)! - let session = URLSession(configuration: .default, delegate: DownloadDelegate(progress: { p in - DispatchQueue.main.async { self.downloadProgress = p } - }, completion: { location, error in - // Must be careful to capture values before jumping to async - guard let location = location else { return } - let fileManager = FileManager.default - let downloadsURL = fileManager.urls(for: .downloadsDirectory, in: .userDomainMask).first! - let destinationURL = downloadsURL.appendingPathComponent(url.lastPathComponent) - - do { - try? fileManager.removeItem(at: destinationURL) // Overwrite - try fileManager.moveItem(at: location, to: destinationURL) - - // Force UI update on Main Thread - Task { @MainActor in - self.downloadedFileURL = destinationURL - self.showRevealButton = true - self.isDownloading = false - - // Reveal file - NSWorkspace.shared.activateFileViewerSelecting([destinationURL]) - } - } catch { - print("File move error: \(error)") - Task { @MainActor in - self.isDownloading = false - } - } - }), delegateQueue: nil) - - let task = session.downloadTask(with: url) - task.resume() - self.downloadTask = task - } - - private func smartInstall() { - guard let assetURL = downloadedFileURL else { return } - let currentAppURL = Bundle.main.bundleURL - - // Safety: Only proceed if we are a .app bundle - guard currentAppURL.pathExtension == "app" else { - NSWorkspace.shared.open(assetURL) - return - } - - let fileManager = FileManager.default - let tempScriptURL = fileManager.temporaryDirectory.appendingPathComponent("swiftier_update.sh") - - // Script Logic: - // 1. Wait for PID to close (passed as arg) - // 2. Extract/Mount - // 3. Replace - // 4. Relaunch - // 5. Self-destruct script - - let script = """ - #!/bin/bash - PID=$1 - DMG_PATH="$2" - DEST_APP="$3" - TEMP_MOUNT="/tmp/Swiftier_Update_Mount" - - # 1. Wait for parent to exit - while kill -0 $PID 2>/dev/null; do sleep 0.5; done - - echo "Starting update..." - - # 2. Extract payload - SOURCE_APP="" - - if [[ "$DMG_PATH" == *.dmg ]]; then - hdiutil attach -nobrowse "$DMG_PATH" -mountpoint "$TEMP_MOUNT" - SOURCE_APP=$(find "$TEMP_MOUNT" -maxdepth 1 -name "*.app" -print -quit) - elif [[ "$DMG_PATH" == *.zip ]]; then - unzip -o "$DMG_PATH" -d "$TEMP_MOUNT" - SOURCE_APP=$(find "$TEMP_MOUNT" -maxdepth 2 -name "*.app" -print -quit) - fi - - if [ -z "$SOURCE_APP" ]; then - echo "Failed to find app in update" - open "$DMG_PATH" - exit 1 - fi - - # 3. Replace - rm -rf "$DEST_APP" - cp -R "$SOURCE_APP" "$DEST_APP" - - # 4. Cleanup - if [[ "$DMG_PATH" == *.dmg ]]; then - hdiutil detach "$TEMP_MOUNT" - else - rm -rf "$TEMP_MOUNT" - fi - - # 5. Relaunch - open "$DEST_APP" - - # Cleanup Script - rm -- "$0" - """ - - do { - try script.write(to: tempScriptURL, atomically: true, encoding: .utf8) - try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tempScriptURL.path) - - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/bash") - process.arguments = [tempScriptURL.path, String(ProcessInfo.processInfo.processIdentifier), assetURL.path, currentAppURL.path] - - try process.run() - - NSApplication.shared.terminate(nil) - } catch { - print("Install failed: \(error)") - NSWorkspace.shared.open(assetURL) - } - } - - private func findBestAssetURL() -> String? { - // Simple logic: prefer .dmg, then .zip - // Filter for "Swiftier" in name to avoid other assets - let validAssets = assets.filter { asset in - guard let name = asset["name"] as? String else { return false } - return name.contains("Swiftier") - } - - // Priority 1: .dmg - if let dmg = validAssets.first(where: { ($0["name"] as? String)?.hasSuffix(".dmg") ?? false }) { - return dmg["browser_download_url"] as? String - } - - // Priority 2: .zip - if let zip = validAssets.first(where: { ($0["name"] as? String)?.hasSuffix(".zip") ?? false }) { - return zip["browser_download_url"] as? String - } - - return nil - } -} - -// Delegate for progress -class DownloadDelegate: NSObject, URLSessionDownloadDelegate { - let progress: (Double) -> Void - let completion: (URL?, Error?) -> Void - - init(progress: @escaping (Double) -> Void, completion: @escaping (URL?, Error?) -> Void) { - self.progress = progress - self.completion = completion - } - - func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { - completion(location, nil) - } - - func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { - let p = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) - progress(p) - } - - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - if let error = error { - completion(nil, error) - } - } -} - -struct MarkdownWebView: NSViewRepresentable { - let markdown: String - - func makeNSView(context: Context) -> WKWebView { - let webView = WKWebView() - webView.setValue(false, forKey: "drawsBackground") // Transparent background - return webView - } - - func updateNSView(_ nsView: WKWebView, context: Context) { - let html = generateHTML(from: markdown) - nsView.loadHTMLString(html, baseURL: nil) - } - - func generateHTML(from markdown: String) -> String { - // Escape backticks and backslashes for JS template string - let escapedMarkdown = markdown - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "`", with: "\\`") - .replacingOccurrences(of: "$", with: "\\$") - // Ensure newlines are preserved for JS string - .replacingOccurrences(of: "\r\n", with: "\n") - .replacingOccurrences(of: "\n", with: "\\n") - - // Use marked.js for robust parsing - // Dark mode CSS - return """ - - - - - - - - -
- - - - """ - } -} - struct LicenseView: View { @Binding var isPresented: Bool From 1f5003274c4957251240090c7890102096c39f24 Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Fri, 13 Feb 2026 01:40:49 +0800 Subject: [PATCH 03/30] =?UTF-8?q?=E5=B0=86=E5=BC=80=E6=BA=90=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=E4=BB=8E=20MIT=20=E6=94=B9=E4=B8=BA=20GPL-3.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Swiftier/Localizable.xcstrings | 6 --- Swiftier/SettingsView.swift | 72 +++++++++++++++++++++++----------- 2 files changed, 50 insertions(+), 28 deletions(-) diff --git a/Swiftier/Localizable.xcstrings b/Swiftier/Localizable.xcstrings index a1d8877..9976d43 100644 --- a/Swiftier/Localizable.xcstrings +++ b/Swiftier/Localizable.xcstrings @@ -152,12 +152,6 @@ } } } - }, - "Swiftier %@" : { - - }, - "Swiftier %@ 可用" : { - }, "Swiftier 已是最新版本" : { "extractionState" : "manual", diff --git a/Swiftier/SettingsView.swift b/Swiftier/SettingsView.swift index 4e6ac75..4e53c2e 100644 --- a/Swiftier/SettingsView.swift +++ b/Swiftier/SettingsView.swift @@ -112,7 +112,7 @@ struct SettingsView: View { .font(.caption) .foregroundColor(Color(nsColor: .tertiaryLabelColor)) } - Text("本项目遵循 MIT 开源许可证。") + Text("本项目遵循 GPL-3.0 开源许可证。") .font(.caption) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -185,33 +185,61 @@ struct SettingsView: View { struct LicenseView: View { @Binding var isPresented: Bool - private let mitLicense = """ -MIT License + private let gplLicense = """ +GNU GENERAL PUBLIC LICENSE +Version 3, 29 June 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +Preamble + +The GNU General Public License is a free, copyleft license for software +and other kinds of works. + +The licenses for most software and other practical works are designed to +take away your freedom to share and change the works. By contrast, the +GNU General Public License is intended to guarantee your freedom to share +and change all versions of a program--to make sure it remains free software +for all its users. + +When we speak of free software, we are referring to freedom, not price. +Our General Public Licenses are designed to make sure that you have the +freedom to distribute copies of free software (and charge for them if you +wish), that you receive source code or can get it if you want it, that +you can change the software or use pieces of it in new free programs, and +that you know you can do these things. + +To protect your rights, we need to prevent others from denying you these +rights or asking you to surrender the rights. Therefore, you have certain +responsibilities if you distribute copies of the software, or if you +modify it: responsibilities to respect the freedom of others. + +For the complete license text, visit: +https://www.gnu.org/licenses/gpl-3.0.en.html Copyright (c) 2024 Alick Huang -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . """ var body: some View { VStack(spacing: 0) { - UnifiedHeader(title: "MIT License") { + UnifiedHeader(title: "GPL-3.0 License") { Button(LocalizedStringKey("关闭")) { withAnimation { isPresented = false } } @@ -219,7 +247,7 @@ SOFTWARE. } right: { EmptyView() } ScrollView { - Text(mitLicense) + Text(gplLicense) .font(.system(.body, design: .monospaced)) .padding() .textSelection(.enabled) From 05bbdb9b4b6d5dd51d454bed8c0d5fc0ac1a6fba Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Fri, 13 Feb 2026 01:46:41 +0800 Subject: [PATCH 04/30] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E4=B8=AD=E7=9A=84=E8=81=94=E7=B3=BB=E9=82=AE=E7=AE=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Swiftier/Localizable.xcstrings | 9 ++++++--- Swiftier/SettingsView.swift | 8 -------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/Swiftier/Localizable.xcstrings b/Swiftier/Localizable.xcstrings index 9976d43..34159b7 100644 --- a/Swiftier/Localizable.xcstrings +++ b/Swiftier/Localizable.xcstrings @@ -85,6 +85,9 @@ } } } + }, + "GPL-3.0 License" : { + }, "INFO" : { @@ -102,9 +105,6 @@ }, "minamike2007@gmail.com" : { - }, - "MIT License" : { - }, "MTU" : { @@ -1388,6 +1388,9 @@ } } } + }, + "本项目遵循 GPL-3.0 开源许可证。" : { + }, "本项目遵循 MIT 开源许可证。" : { "extractionState" : "manual", diff --git a/Swiftier/SettingsView.swift b/Swiftier/SettingsView.swift index 4e53c2e..8b91863 100644 --- a/Swiftier/SettingsView.swift +++ b/Swiftier/SettingsView.swift @@ -82,14 +82,6 @@ struct SettingsView: View { .foregroundColor(.secondary) } - HStack { - Text("联系邮箱") - Spacer() - Text("minamike2007@gmail.com") - .foregroundColor(.secondary) - .textSelection(.enabled) - } - HStack { Text("源代码") Spacer() From fcadf816abcb35e2f56f355d1cd90a075ff8f3ed Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Fri, 13 Feb 2026 01:53:16 +0800 Subject: [PATCH 05/30] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20NE=20=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E7=BB=A7=E6=89=BF=E5=92=8C=E8=87=AA=E5=8A=A8=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E7=9A=84=E7=AB=9E=E6=80=81=E6=9D=A1=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VPNManager.loadPreferences: 将 @Published 属性更新统一到主线程,确保 isReady 在 status 同步完成后才设置 - VPNManager.updateStatus -> updateStatusSync: 去掉二次 DispatchQueue.main.async 派发,避免状态延迟 - AppDelegate.performAutoConnect: 增加 connecting 状态处理和调试日志 - SwiftierRunner.syncWithVPNState: 简化逻辑,移除冗余代码 --- Swiftier/SwiftierControlApp.swift | 16 ++++++-- Swiftier/SwiftierRunner.swift | 11 ++---- Swiftier/VPNManager.swift | 66 +++++++++++++++++-------------- 3 files changed, 51 insertions(+), 42 deletions(-) diff --git a/Swiftier/SwiftierControlApp.swift b/Swiftier/SwiftierControlApp.swift index 353fccc..e294f29 100644 --- a/Swiftier/SwiftierControlApp.swift +++ b/Swiftier/SwiftierControlApp.swift @@ -141,17 +141,25 @@ class AppDelegate: NSObject, NSApplicationDelegate { } private func performAutoConnect() { - let isConnected = VPNManager.shared.isConnected + let vpn = VPNManager.shared + let status = vpn.status + print("[AutoConnect] VPN status: \(status.rawValue), isConnected: \(vpn.isConnected)") - if isConnected { - print("VPN already connected, syncing state...") + if vpn.isConnected || status == .connected { + print("[AutoConnect] VPN already connected, syncing state...") SwiftierRunner.shared.syncWithVPNState() + } else if status == .connecting { + print("[AutoConnect] VPN is connecting, waiting...") + // 正在连接中,不需要重复操作,statusObserver 会处理 } else { // 未运行,执行自动连接 let configs = ConfigManager.shared.refreshConfigs() + print("[AutoConnect] Found \(configs.count) config(s)") if let config = configs.first { - print("Auto-connecting with config: \(config.lastPathComponent)") + print("[AutoConnect] Auto-connecting with config: \(config.lastPathComponent)") SwiftierRunner.shared.toggleService(configPath: config.path) + } else { + print("[AutoConnect] No config files found, skipping auto-connect") } } } diff --git a/Swiftier/SwiftierRunner.swift b/Swiftier/SwiftierRunner.swift index f699ed1..d16c7c3 100644 --- a/Swiftier/SwiftierRunner.swift +++ b/Swiftier/SwiftierRunner.swift @@ -149,14 +149,9 @@ final class SwiftierRunner: ObservableObject { } func syncWithVPNState() { - // Initial sync relies on VPNManager's current state - handleVPNStatusChange(VPNManager.shared.status) - - // If we are disconnected but configured to auto-connect on start - if VPNManager.shared.status == .disconnected && UserDefaults.standard.bool(forKey: "connectOnStart") { - // Let SwiftierControlApp handle the auto-connect logic or do it here if appropriate. - // Usually better to let the App entry point handle "on launch" actions. - } + let status = VPNManager.shared.status + print("[Runner] syncWithVPNState: status = \(status.rawValue)") + handleVPNStatusChange(status) } // MARK: - Control Actions diff --git a/Swiftier/VPNManager.swift b/Swiftier/VPNManager.swift index 6215e1e..173e3be 100644 --- a/Swiftier/VPNManager.swift +++ b/Swiftier/VPNManager.swift @@ -38,9 +38,14 @@ class VPNManager: ObservableObject { } if let existingManager = managers?.first { - self.manager = existingManager - self.updateStatus() - self.isReady = true + // 必须在主线程同步设置所有 @Published 属性,避免竞态 + DispatchQueue.main.async { + self.manager = existingManager + // 同步更新状态(不再二次派发) + self.updateStatusSync() + // 状态已就绪后再标记 isReady,确保 performAutoConnect 能读到正确的 isConnected + self.isReady = true + } } else { self.setupVPNProfile() } @@ -164,37 +169,38 @@ class VPNManager: ObservableObject { } @objc private func vpnStatusDidChange(_ notification: Notification) { - updateStatus() + DispatchQueue.main.async { + self.updateStatusSync() + } } - private func updateStatus() { + /// 同步更新状态,必须在主线程调用 + private func updateStatusSync() { guard let connection = manager?.connection else { return } - DispatchQueue.main.async { - self.status = connection.status - - switch connection.status { - case .connected: - self.isConnected = true - self.statusText = "已连接" - case .connecting: - self.isConnected = false - self.statusText = "连接中..." - case .disconnected: - self.isConnected = false - self.statusText = "未连接" - case .disconnecting: - self.isConnected = false - self.statusText = "断开中..." - case .invalid: - self.isConnected = false - self.statusText = "无效状态" - case .reasserting: - self.isConnected = false - self.statusText = "重连中..." - @unknown default: - self.statusText = "未知状态" - } + self.status = connection.status + + switch connection.status { + case .connected: + self.isConnected = true + self.statusText = "已连接" + case .connecting: + self.isConnected = false + self.statusText = "连接中..." + case .disconnected: + self.isConnected = false + self.statusText = "未连接" + case .disconnecting: + self.isConnected = false + self.statusText = "断开中..." + case .invalid: + self.isConnected = false + self.statusText = "无效状态" + case .reasserting: + self.isConnected = false + self.statusText = "重连中..." + @unknown default: + self.statusText = "未知状态" } } } From 947fed73b4947aa87e23c1f3bb159aeb720bf8d7 Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Fri, 13 Feb 2026 01:53:57 +0800 Subject: [PATCH 06/30] =?UTF-8?q?2026-02-13=20=E5=87=86=E5=A4=87=E5=AE=9E?= =?UTF-8?q?=E6=96=BD=20Connect=20On=20Demand=20=E6=94=B9=E9=80=A0=E5=89=8D?= =?UTF-8?q?=E7=9A=84=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Swiftier/Localizable.xcstrings | 3 --- 1 file changed, 3 deletions(-) diff --git a/Swiftier/Localizable.xcstrings b/Swiftier/Localizable.xcstrings index 34159b7..ed2c324 100644 --- a/Swiftier/Localizable.xcstrings +++ b/Swiftier/Localizable.xcstrings @@ -102,9 +102,6 @@ } } } - }, - "minamike2007@gmail.com" : { - }, "MTU" : { From 6a6f46f4067371752cc5ed8c3fc4bd54b5004ca3 Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Fri, 13 Feb 2026 01:55:51 +0800 Subject: [PATCH 07/30] =?UTF-8?q?=E9=87=87=E7=94=A8=20Connect=20On=20Deman?= =?UTF-8?q?d=20=E6=9C=BA=E5=88=B6=E7=AE=A1=E7=90=86=20VPN=20=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VPNManager: 新增 applyOnDemandRules/updateOnDemand,WiFi/Ethernet 可用时系统自动启动 NE - VPNManager: 已有 Profile 加载时也同步 On Demand 规则 - AppDelegate: 简化为状态同步 + 首次兜底连接,不再手动管理自动连接 - ContentView: 退出按钮不再停 VPN,NE 由系统管理生命周期 - SettingsView: 移除 exitBehavior 设置,connectOnStart 联动 On Demand 开关 --- Swiftier/ContentView.swift | 20 ++----- Swiftier/SettingsView.swift | 28 ++-------- Swiftier/SwiftierControlApp.swift | 51 ++++++++---------- Swiftier/VPNManager.swift | 86 ++++++++++++++++++++++++------- 4 files changed, 97 insertions(+), 88 deletions(-) diff --git a/Swiftier/ContentView.swift b/Swiftier/ContentView.swift index 7a1f5de..20dfa19 100644 --- a/Swiftier/ContentView.swift +++ b/Swiftier/ContentView.swift @@ -308,23 +308,9 @@ struct ContentView: View { // 退出按钮 Button(action: { - // 退出逻辑:根据 exitBehavior 设置决定行为 - // 现在的 Network Extension 行为有所不同: - // keepRunning: 只是退出 UI,VPN 保持连接 (NetworkExtension 默认行为) - // stopCore/stopAll: 都是停止 VPN - - let behavior = UserDefaults.standard.string(forKey: "exitBehavior") ?? "stopVPN" - - if behavior == "keepRunning" { - NSApplication.shared.terminate(nil) - } else { - // 默认为停止 VPN - VPNManager.shared.stopVPN() - // 稍微延迟一下给 NE 发送停止信号 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - NSApplication.shared.terminate(nil) - } - } + // Connect On Demand 模式:退出 App 不影响 VPN + // VPN 由系统管理,App 只是 UI 控制面板 + NSApplication.shared.terminate(nil) }) { Image(systemName: "power") .font(.system(size: 14)) // 恢复默认粗细 diff --git a/Swiftier/SettingsView.swift b/Swiftier/SettingsView.swift index 8b91863..220ffd9 100644 --- a/Swiftier/SettingsView.swift +++ b/Swiftier/SettingsView.swift @@ -7,7 +7,7 @@ struct SettingsView: View { @AppStorage("connectOnStart") private var connectOnStart: Bool = true @AppStorage("breathEffect") private var breathEffect: Bool = true @AppStorage("launchAtLogin") private var launchAtLogin: Bool = false - @AppStorage("exitBehavior") private var exitBehavior: String = "stopVPN" // keepRunning, stopVPN + @AppStorage("logLevel", store: UserDefaults(suiteName: "group.com.alick.swiftier")) private var logLevel: String = "INFO" @@ -42,6 +42,9 @@ struct SettingsView: View { } Toggle("启动 APP 时自动连接", isOn: $connectOnStart) + .onChange(of: connectOnStart) { newValue in + VPNManager.shared.updateOnDemand(enabled: newValue) + } Toggle("连接时图标呼吸闪烁", isOn: $breathEffect) // 开机自启 @@ -49,18 +52,6 @@ struct SettingsView: View { .onChange(of: launchAtLogin) { newValue in toggleLaunchAtLogin(enabled: newValue) } - - HStack { - Text(LocalizedStringKey("退出 APP 时")) - Spacer() - Picker("", selection: $exitBehavior) { - Text(LocalizedStringKey("保持连接运行")).tag("keepRunning") - Text(LocalizedStringKey("停止连接并退出")).tag("stopVPN") - } - .pickerStyle(.menu) - .labelsHidden() - .fixedSize() - } } @@ -142,16 +133,7 @@ struct SettingsView: View { } } - private var exitBehaviorDescription: String { - switch exitBehavior { - case "keepRunning": - return "退出 APP 后,VPN 连接将保持运行。" - case "stopVPN": - return "退出 APP 后,断开 VPN 连接。" - default: - return "" - } - } + diff --git a/Swiftier/SwiftierControlApp.swift b/Swiftier/SwiftierControlApp.swift index e294f29..f2d604a 100644 --- a/Swiftier/SwiftierControlApp.swift +++ b/Swiftier/SwiftierControlApp.swift @@ -110,56 +110,47 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) - // 自动连接 - checkAndAutoConnect() + // 等待 VPNManager 加载完成后同步状态 + // Connect On Demand 由系统管理自动连接,App 只需同步 UI 状态 + waitForVPNReady() } // MARK: - Lifecycle func applicationWillTerminate(_ notification: Notification) { - // 退出行为由 ContentView 的退出按钮处理 - // 这里只处理意外关闭的情况 + // NE 由系统通过 Connect On Demand 管理,App 退出不影响 VPN 连接 } - // MARK: - Auto Connect + // MARK: - VPN State Sync - private func checkAndAutoConnect() { - // Default true, key match SettingsView - let autoConnect = (UserDefaults.standard.object(forKey: "connectOnStart") as? Bool) ?? true - guard autoConnect else { return } - - // 等待 VPNManager 完成 Profile 加载/创建后再自动连接 - // 使用 Combine 监听 isReady 状态,避免固定延时导致的竞态 + private func waitForVPNReady() { VPNManager.shared.$isReady - .filter { $0 } // 等待 isReady == true - .first() // 只触发一次 + .filter { $0 } + .first() .receive(on: DispatchQueue.main) .sink { [weak self] _ in - self?.performAutoConnect() + self?.syncStateOnLaunch() } .store(in: &cancellables) } - private func performAutoConnect() { + private func syncStateOnLaunch() { let vpn = VPNManager.shared - let status = vpn.status - print("[AutoConnect] VPN status: \(status.rawValue), isConnected: \(vpn.isConnected)") + print("[Launch] VPN status: \(vpn.status.rawValue), isConnected: \(vpn.isConnected), onDemand: \(vpn.isOnDemandEnabled)") - if vpn.isConnected || status == .connected { - print("[AutoConnect] VPN already connected, syncing state...") - SwiftierRunner.shared.syncWithVPNState() - } else if status == .connecting { - print("[AutoConnect] VPN is connecting, waiting...") - // 正在连接中,不需要重复操作,statusObserver 会处理 - } else { - // 未运行,执行自动连接 + // 同步 Runner 的 UI 状态 + SwiftierRunner.shared.syncWithVPNState() + + // 确保 On Demand 规则与用户设置一致 + let connectOnStart = (UserDefaults.standard.object(forKey: "connectOnStart") as? Bool) ?? true + vpn.updateOnDemand(enabled: connectOnStart) + + // 如果 NE 未运行且开启了自动连接,手动触发一次(首次安装或 On Demand 尚未生效时) + if !vpn.isConnected && vpn.status != .connecting && connectOnStart { let configs = ConfigManager.shared.refreshConfigs() - print("[AutoConnect] Found \(configs.count) config(s)") if let config = configs.first { - print("[AutoConnect] Auto-connecting with config: \(config.lastPathComponent)") + print("[Launch] Triggering initial connect with: \(config.lastPathComponent)") SwiftierRunner.shared.toggleService(configPath: config.path) - } else { - print("[AutoConnect] No config files found, skipping auto-connect") } } } diff --git a/Swiftier/VPNManager.swift b/Swiftier/VPNManager.swift index 173e3be..4a8ccb6 100644 --- a/Swiftier/VPNManager.swift +++ b/Swiftier/VPNManager.swift @@ -10,6 +10,10 @@ class VPNManager: ObservableObject { @Published var status: NEVPNStatus = .disconnected @Published var isReady = false + var isOnDemandEnabled: Bool { + manager?.isOnDemandEnabled ?? false + } + private var manager: NETunnelProviderManager? init() { @@ -43,8 +47,16 @@ class VPNManager: ObservableObject { self.manager = existingManager // 同步更新状态(不再二次派发) self.updateStatusSync() - // 状态已就绪后再标记 isReady,确保 performAutoConnect 能读到正确的 isConnected + // 状态已就绪后再标记 isReady,确保后续逻辑能读到正确的 isConnected self.isReady = true + + // 确保已有 Profile 的 On Demand 规则与用户设置一致 + self.applyOnDemandRules(to: existingManager) + existingManager.saveToPreferences { error in + if let error = error { + print("VPNManager: Error updating On Demand rules: \(error)") + } + } } } else { self.setupVPNProfile() @@ -54,44 +66,82 @@ class VPNManager: ObservableObject { private func setupVPNProfile() { print("VPNManager: Starting setupVPNProfile...") - print("VPNManager: Current Bundle ID: \(Bundle.main.bundleIdentifier ?? "Unknown")") let manager = NETunnelProviderManager() manager.localizedDescription = "Swiftier VPN" let protocolConfiguration = NETunnelProviderProtocol() - // 关键点:这个 ID 必须和 Extension 的 Bundle ID 完全一致 let extensionBundleID = "com.alick.swiftier.SwiftierNE" protocolConfiguration.providerBundleIdentifier = extensionBundleID protocolConfiguration.serverAddress = "Swiftier" - print("VPNManager: Setting providerBundleIdentifier to: \(extensionBundleID)") - manager.protocolConfiguration = protocolConfiguration manager.isEnabled = true + // Connect On Demand: 网络可用时系统自动启动 NE + applyOnDemandRules(to: manager) + manager.saveToPreferences { [weak self] error in if let error = error { - print("VPNManager: Critical Error saving VPN profile!") - print("VPNManager: Error Domain: \((error as NSError).domain)") - print("VPNManager: Error Code: \((error as NSError).code)") - print("VPNManager: Description: \(error.localizedDescription)") - print("VPNManager: UserInfo: \((error as NSError).userInfo)") + print("VPNManager: Error saving VPN profile: \(error.localizedDescription)") } else { print("VPNManager: VPN Profile saved successfully.") - self?.manager = manager - self?.isReady = true - - // Save again for good measure (sometimes required to persist fully) + // 二次保存确保持久化 manager.saveToPreferences { error in if let error = error { - print("VPNManager: Error on second save: \(error)") + print("VPNManager: Error on second save: \(error)") } else { - print("VPNManager: Second save successful.") + print("VPNManager: Second save successful.") } } - - self?.loadPreferences() // Reload to be sure + self?.loadPreferences() + } + } + } + + /// 配置 Connect On Demand 规则 + private func applyOnDemandRules(to manager: NETunnelProviderManager) { + let connectOnStart = (UserDefaults.standard.object(forKey: "connectOnStart") as? Bool) ?? true + + if connectOnStart { + // 任何网络可用时自动连接 + let wifiRule = NEOnDemandRuleConnect() + wifiRule.interfaceTypeMatch = .wiFi + + let ethernetRule = NEOnDemandRuleConnect() + ethernetRule.interfaceTypeMatch = .ethernet + + manager.onDemandRules = [wifiRule, ethernetRule] + manager.isOnDemandEnabled = true + print("VPNManager: Connect On Demand enabled") + } else { + manager.onDemandRules = [] + manager.isOnDemandEnabled = false + print("VPNManager: Connect On Demand disabled") + } + } + + /// 外部调用:更新 On Demand 设置(设置页切换时调用) + func updateOnDemand(enabled: Bool) { + guard let manager = manager else { return } + + if enabled { + let wifiRule = NEOnDemandRuleConnect() + wifiRule.interfaceTypeMatch = .wiFi + let ethernetRule = NEOnDemandRuleConnect() + ethernetRule.interfaceTypeMatch = .ethernet + manager.onDemandRules = [wifiRule, ethernetRule] + manager.isOnDemandEnabled = true + } else { + manager.onDemandRules = [] + manager.isOnDemandEnabled = false + } + + manager.saveToPreferences { error in + if let error = error { + print("VPNManager: Error updating On Demand: \(error)") + } else { + print("VPNManager: On Demand updated to \(enabled)") } } } From bebbdfc44340ea69e249825c84080e5a5b5a305d Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Fri, 13 Feb 2026 01:59:59 +0800 Subject: [PATCH 08/30] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=BC=BA=E5=A4=B1?= =?UTF-8?q?=E7=9A=84=20NetworkExtension=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Swiftier/SwiftierControlApp.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Swiftier/SwiftierControlApp.swift b/Swiftier/SwiftierControlApp.swift index f2d604a..3b0b1a2 100644 --- a/Swiftier/SwiftierControlApp.swift +++ b/Swiftier/SwiftierControlApp.swift @@ -1,6 +1,7 @@ import SwiftUI import AppKit import Combine +import NetworkExtension @main struct SwiftierControlApp: App { From a0a23ce8ae93e56d1988034bd155bd680c43c3b0 Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Fri, 13 Feb 2026 02:02:09 +0800 Subject: [PATCH 09/30] =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E9=A1=B9=E5=90=8D?= =?UTF-8?q?=E7=A7=B0=E6=94=B9=E4=B8=BA=20Connect=20On=20Demand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Swiftier/SettingsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Swiftier/SettingsView.swift b/Swiftier/SettingsView.swift index 220ffd9..dc0ea67 100644 --- a/Swiftier/SettingsView.swift +++ b/Swiftier/SettingsView.swift @@ -41,7 +41,7 @@ struct SettingsView: View { .foregroundColor(.secondary) } - Toggle("启动 APP 时自动连接", isOn: $connectOnStart) + Toggle("Connect On Demand", isOn: $connectOnStart) .onChange(of: connectOnStart) { newValue in VPNManager.shared.updateOnDemand(enabled: newValue) } From 55f6466ccda9315592a329c22add09e95496ea3d Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Fri, 13 Feb 2026 02:05:55 +0800 Subject: [PATCH 10/30] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E6=97=B6=E9=95=BF=EF=BC=9A=E4=BD=BF=E7=94=A8=20NE=20connectedD?= =?UTF-8?q?ate=20=E8=80=8C=E9=9D=9E=20App=20=E5=90=AF=E5=8A=A8=E6=97=B6?= =?UTF-8?q?=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VPNManager: 暴露 connectedDate 属性 - SwiftierRunner: startedAt 从 connectedDate 获取,App 重启后计时连续 --- Swiftier/SwiftierRunner.swift | 3 ++- Swiftier/VPNManager.swift | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Swiftier/SwiftierRunner.swift b/Swiftier/SwiftierRunner.swift index d16c7c3..7957725 100644 --- a/Swiftier/SwiftierRunner.swift +++ b/Swiftier/SwiftierRunner.swift @@ -122,7 +122,8 @@ final class SwiftierRunner: ObservableObject { case .connected: if !self.isRunning { self.isRunning = true - self.startedAt = Date() // Approximate start time if not tracking precisely + // 使用 NE 的实际连接时间,而非 App 启动时间 + self.startedAt = VPNManager.shared.connectedDate ?? Date() self.startUptimeTimer() self.startMonitoring() } diff --git a/Swiftier/VPNManager.swift b/Swiftier/VPNManager.swift index 4a8ccb6..bd1c6ea 100644 --- a/Swiftier/VPNManager.swift +++ b/Swiftier/VPNManager.swift @@ -14,6 +14,11 @@ class VPNManager: ObservableObject { manager?.isOnDemandEnabled ?? false } + /// NE 隧道的实际连接时间 + var connectedDate: Date? { + manager?.connection.connectedDate + } + private var manager: NETunnelProviderManager? init() { From a71ac919a746dda4498ced3bc348c4b141f04452 Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Fri, 13 Feb 2026 02:08:21 +0800 Subject: [PATCH 11/30] =?UTF-8?q?=E8=A1=A5=E5=85=A8=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E5=8C=96=EF=BC=9AGPL=20=E5=A3=B0=E6=98=8E=E3=80=81Connect=20On?= =?UTF-8?q?=20Demand=20=E5=8F=8A=E5=85=B6=E4=BB=96=E7=BC=BA=E5=A4=B1?= =?UTF-8?q?=E8=8B=B1=E6=96=87=E7=BF=BB=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Swiftier/Localizable.xcstrings | 114 +++++++++++++++++++++++++++++---- Swiftier/SettingsView.swift | 4 +- 2 files changed, 103 insertions(+), 15 deletions(-) diff --git a/Swiftier/Localizable.xcstrings b/Swiftier/Localizable.xcstrings index ed2c324..de44bd7 100644 --- a/Swiftier/Localizable.xcstrings +++ b/Swiftier/Localizable.xcstrings @@ -21,6 +21,17 @@ }, "Alick Huang" : { + }, + "Connect On Demand" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connect On Demand" + } + } + } }, "DEBUG" : { @@ -400,7 +411,15 @@ } }, "仅使用物理网卡,避免 Swiftier 通过其他虚拟网建立连接。" : { - + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avoid virtual NICs." + } + } + } }, "仅保留 Helper 加速启动" : { "extractionState" : "manual", @@ -555,9 +574,6 @@ } } } - }, - "停止连接并退出" : { - }, "允许此节点成为出口节点。" : { "extractionState" : "manual", @@ -714,7 +730,15 @@ } }, "加载失败: %@" : { - + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Load failed: %@" + } + } + } }, "协议" : { "extractionState" : "manual", @@ -904,7 +928,15 @@ } }, "启用魔法 DNS,允许通过 Swiftier 的 DNS 服务器访问其他节点的虚拟 IPv4 地址,例如:node1.et.net。" : { - + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Access by node name." + } + } + } }, "在 Finder 中打开" : { "extractionState" : "manual", @@ -940,7 +972,15 @@ } }, "在系统控制台中打开完整日志" : { - + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Full Logs in Console" + } + } + } }, "地址" : { "extractionState" : "manual", @@ -965,13 +1005,37 @@ } }, "如:tcp://1.1.1.1:11010" : { - + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "e.g. tcp://1.1.1.1:11010" + } + } + } }, "存储位置: iCloud Drive" : { - + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Storage: iCloud Drive" + } + } + } }, "存储位置: 本地 (iCloud 未启用)" : { - + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Storage: Local (iCloud disabled)" + } + } + } }, "存储到 iCloud" : { "extractionState" : "manual", @@ -1161,7 +1225,15 @@ } }, "开启 SOCKS5 代理功能,Surge 等外部程序可通过此端口连接 Swiftier 网络。" : { - + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enable SOCKS5 proxy so external apps like Surge can connect to the Swiftier network." + } + } + } }, "开放" : { "extractionState" : "manual", @@ -1219,7 +1291,15 @@ } }, "手动分配路由 CIDR,将禁用子网代理和从对等节点传播的 wireguard 路由。例如:192.168.0.0/16" : { - + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Assign route CIDRs manually. This disables subnet proxying and Wireguard route propagation." + } + } + } }, "手动分配路由 CIDR,将禁用子网代理和从对等节点传播的 wireguard路由。例如:192.168.0.0/16" : { "extractionState" : "manual", @@ -1387,7 +1467,15 @@ } }, "本项目遵循 GPL-3.0 开源许可证。" : { - + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Licensed under GPL-3.0." + } + } + } }, "本项目遵循 MIT 开源许可证。" : { "extractionState" : "manual", diff --git a/Swiftier/SettingsView.swift b/Swiftier/SettingsView.swift index dc0ea67..3553c69 100644 --- a/Swiftier/SettingsView.swift +++ b/Swiftier/SettingsView.swift @@ -87,7 +87,7 @@ struct SettingsView: View { }) { VStack(alignment: .leading, spacing: 6) { HStack { - Text("开源声明") + Text(LocalizedStringKey("开源声明")) .font(.body) .foregroundColor(.primary) Spacer() @@ -95,7 +95,7 @@ struct SettingsView: View { .font(.caption) .foregroundColor(Color(nsColor: .tertiaryLabelColor)) } - Text("本项目遵循 GPL-3.0 开源许可证。") + Text(LocalizedStringKey("本项目遵循 GPL-3.0 开源许可证。")) .font(.caption) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) From c583b11aedefa249b4c118c5b780efef3fc834e9 Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Fri, 13 Feb 2026 02:09:20 +0800 Subject: [PATCH 12/30] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E7=9A=84=E6=9C=AC=E5=9C=B0=E5=8C=96=20key=EF=BC=88wireguard?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=20vs=20wireguard=20=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Swiftier/Localizable.xcstrings | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/Swiftier/Localizable.xcstrings b/Swiftier/Localizable.xcstrings index de44bd7..3bc1690 100644 --- a/Swiftier/Localizable.xcstrings +++ b/Swiftier/Localizable.xcstrings @@ -1301,17 +1301,7 @@ } } }, - "手动分配路由 CIDR,将禁用子网代理和从对等节点传播的 wireguard路由。例如:192.168.0.0/16" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Assign route CIDRs manually. This disables subnet proxying and Wireguard route propagation." - } - } - } - }, + "手动指定监听器的公网地址,其他节点可以使用该地址连接到本节点。例如:tcp://123.123.123.123:11223,可以指定多个。" : { "extractionState" : "manual", "localizations" : { From 8b77203bb4670e8f765d10bfc18939d089d685b6 Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Fri, 13 Feb 2026 02:12:40 +0800 Subject: [PATCH 13/30] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20UI=20=E9=87=8D?= =?UTF-8?q?=E5=90=AF=E5=AF=BC=E8=87=B4=20NE=20=E8=A2=AB=E6=9D=80=E9=87=8D?= =?UTF-8?q?=E8=BF=9E=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - loadPreferences: 仅在 On Demand 设置不一致时才 saveToPreferences - syncStateOnLaunch: NE 已运行时跳过所有 save 操作 - 避免 saveToPreferences 触发系统重启隧道 --- Swiftier/SwiftierControlApp.swift | 18 +++++++++++++----- Swiftier/VPNManager.swift | 13 ++++++++----- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/Swiftier/SwiftierControlApp.swift b/Swiftier/SwiftierControlApp.swift index 3b0b1a2..3892b92 100644 --- a/Swiftier/SwiftierControlApp.swift +++ b/Swiftier/SwiftierControlApp.swift @@ -137,17 +137,25 @@ class AppDelegate: NSObject, NSApplicationDelegate { private func syncStateOnLaunch() { let vpn = VPNManager.shared + let connectOnStart = (UserDefaults.standard.object(forKey: "connectOnStart") as? Bool) ?? true print("[Launch] VPN status: \(vpn.status.rawValue), isConnected: \(vpn.isConnected), onDemand: \(vpn.isOnDemandEnabled)") // 同步 Runner 的 UI 状态 SwiftierRunner.shared.syncWithVPNState() - // 确保 On Demand 规则与用户设置一致 - let connectOnStart = (UserDefaults.standard.object(forKey: "connectOnStart") as? Bool) ?? true - vpn.updateOnDemand(enabled: connectOnStart) + // 如果 NE 已经在运行,不做任何操作,避免 saveToPreferences 导致隧道重启 + if vpn.isConnected || vpn.status == .connecting { + print("[Launch] NE already running, skip") + return + } + + // NE 未运行时,确保 On Demand 规则与用户设置一致 + if vpn.isOnDemandEnabled != connectOnStart { + vpn.updateOnDemand(enabled: connectOnStart) + } - // 如果 NE 未运行且开启了自动连接,手动触发一次(首次安装或 On Demand 尚未生效时) - if !vpn.isConnected && vpn.status != .connecting && connectOnStart { + // 如果开启了自动连接,手动触发一次(首次安装或 On Demand 尚未生效时) + if connectOnStart { let configs = ConfigManager.shared.refreshConfigs() if let config = configs.first { print("[Launch] Triggering initial connect with: \(config.lastPathComponent)") diff --git a/Swiftier/VPNManager.swift b/Swiftier/VPNManager.swift index bd1c6ea..045633c 100644 --- a/Swiftier/VPNManager.swift +++ b/Swiftier/VPNManager.swift @@ -55,11 +55,14 @@ class VPNManager: ObservableObject { // 状态已就绪后再标记 isReady,确保后续逻辑能读到正确的 isConnected self.isReady = true - // 确保已有 Profile 的 On Demand 规则与用户设置一致 - self.applyOnDemandRules(to: existingManager) - existingManager.saveToPreferences { error in - if let error = error { - print("VPNManager: Error updating On Demand rules: \(error)") + // 仅在 On Demand 设置不一致时才 save,避免 saveToPreferences 导致系统重启隧道 + let connectOnStart = (UserDefaults.standard.object(forKey: "connectOnStart") as? Bool) ?? true + if existingManager.isOnDemandEnabled != connectOnStart { + self.applyOnDemandRules(to: existingManager) + existingManager.saveToPreferences { error in + if let error = error { + print("VPNManager: Error updating On Demand rules: \(error)") + } } } } From 7d14b3b968496258eea2e0d4b78289fb16dfc70f Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Fri, 13 Feb 2026 02:16:59 +0800 Subject: [PATCH 14/30] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E5=85=B3=E9=97=AD=20VPN=20=E6=97=B6=20Connect=20On=20Demand=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E9=87=8D=E8=BF=9E=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 手动关闭时先禁用 On Demand 再 stop - 手动启动时恢复 On Demand 设置 --- Swiftier/SwiftierRunner.swift | 10 ++++++++-- Swiftier/VPNManager.swift | 13 +++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/Swiftier/SwiftierRunner.swift b/Swiftier/SwiftierRunner.swift index 7957725..aac95dc 100644 --- a/Swiftier/SwiftierRunner.swift +++ b/Swiftier/SwiftierRunner.swift @@ -161,10 +161,16 @@ final class SwiftierRunner: ObservableObject { if isProcessing { return } if isRunning { - // Stop - VPNManager.shared.stopVPN() + // 手动关闭时先禁用 On Demand,否则系统会立刻重连 + VPNManager.shared.disableOnDemandAndStop() } else { // Start + // 手动启动时恢复 On Demand(之前手动关闭时会禁用) + let connectOnStart = (UserDefaults.standard.object(forKey: "connectOnStart") as? Bool) ?? true + if connectOnStart { + VPNManager.shared.updateOnDemand(enabled: true) + } + // 使用 ConfigManager 读取(处理安全域) do { let configURL = URL(fileURLWithPath: configPath) diff --git a/Swiftier/VPNManager.swift b/Swiftier/VPNManager.swift index 045633c..942117b 100644 --- a/Swiftier/VPNManager.swift +++ b/Swiftier/VPNManager.swift @@ -197,6 +197,19 @@ class VPNManager: ObservableObject { manager?.connection.stopVPNTunnel() } + /// 手动关闭:先禁用 On Demand 再断开,防止系统自动重连 + func disableOnDemandAndStop() { + guard let manager = manager else { return } + manager.isOnDemandEnabled = false + manager.saveToPreferences { [weak self] error in + if let error = error { + print("VPNManager: Error disabling On Demand: \(error)") + } + // save 完成后再 stop,确保 On Demand 已关闭 + self?.manager?.connection.stopVPNTunnel() + } + } + /// Send a message to the running NE provider and get a response func sendProviderMessage(_ message: String, completion: @escaping (Data?) -> Void) { guard let session = manager?.connection as? NETunnelProviderSession, From 4c9ae2a62eaca1f7542f602e8cc5b3779b51e1a7 Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Fri, 13 Feb 2026 02:18:48 +0800 Subject: [PATCH 15/30] =?UTF-8?q?=E6=94=B9=E8=BF=9B=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E9=80=BB=E8=BE=91=EF=BC=9A=E5=85=88=20stop?= =?UTF-8?q?=20=E5=86=8D=E7=A6=81=E7=94=A8=20On=20Demand=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=20saveToPreferences=20=E8=A7=A6=E5=8F=91=E9=9A=A7?= =?UTF-8?q?=E9=81=93=E9=87=8D=E5=90=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Swiftier/VPNManager.swift | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/Swiftier/VPNManager.swift b/Swiftier/VPNManager.swift index 942117b..6ea1e52 100644 --- a/Swiftier/VPNManager.swift +++ b/Swiftier/VPNManager.swift @@ -197,16 +197,34 @@ class VPNManager: ObservableObject { manager?.connection.stopVPNTunnel() } - /// 手动关闭:先禁用 On Demand 再断开,防止系统自动重连 + /// 手动关闭:先 stop 隧道,等断开后再禁用 On Demand,防止系统自动重连 func disableOnDemandAndStop() { guard let manager = manager else { return } - manager.isOnDemandEnabled = false - manager.saveToPreferences { [weak self] error in - if let error = error { - print("VPNManager: Error disabling On Demand: \(error)") + + // 先 stop + manager.connection.stopVPNTunnel() + + // 监听断开后立即禁用 On Demand + var observer: NSObjectProtocol? + observer = NotificationCenter.default.addObserver( + forName: .NEVPNStatusDidChange, + object: manager.connection, + queue: .main + ) { [weak self] _ in + guard let self = self, let mgr = self.manager else { return } + if mgr.connection.status == .disconnected { + if let obs = observer { + NotificationCenter.default.removeObserver(obs) + } + mgr.isOnDemandEnabled = false + mgr.saveToPreferences { error in + if let error = error { + print("VPNManager: Error disabling On Demand after stop: \(error)") + } else { + print("VPNManager: On Demand disabled after manual stop") + } + } } - // save 完成后再 stop,确保 On Demand 已关闭 - self?.manager?.connection.stopVPNTunnel() } } From 3a9b3f66da6264195486ea2f117a22b377702c6a Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Fri, 13 Feb 2026 02:20:56 +0800 Subject: [PATCH 16/30] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20On=20Demand=20?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=EF=BC=9Asave=20=E5=89=8D=E5=85=88=20loadFrom?= =?UTF-8?q?Preferences=20=E7=A1=AE=E4=BF=9D=E7=8A=B6=E6=80=81=E6=9C=80?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - disableOnDemandAndStop: load → disable → save → stop - updateOnDemand: load → modify → save --- Swiftier/VPNManager.swift | 78 ++++++++++++++++++++------------------- 1 file changed, 41 insertions(+), 37 deletions(-) diff --git a/Swiftier/VPNManager.swift b/Swiftier/VPNManager.swift index 6ea1e52..fa8e200 100644 --- a/Swiftier/VPNManager.swift +++ b/Swiftier/VPNManager.swift @@ -133,23 +133,31 @@ class VPNManager: ObservableObject { func updateOnDemand(enabled: Bool) { guard let manager = manager else { return } - if enabled { - let wifiRule = NEOnDemandRuleConnect() - wifiRule.interfaceTypeMatch = .wiFi - let ethernetRule = NEOnDemandRuleConnect() - ethernetRule.interfaceTypeMatch = .ethernet - manager.onDemandRules = [wifiRule, ethernetRule] - manager.isOnDemandEnabled = true - } else { - manager.onDemandRules = [] - manager.isOnDemandEnabled = false - } - - manager.saveToPreferences { error in + manager.loadFromPreferences { [weak self] error in + guard let self = self, let mgr = self.manager else { return } if let error = error { - print("VPNManager: Error updating On Demand: \(error)") + print("VPNManager: Error loading preferences for On Demand update: \(error)") + return + } + + if enabled { + let wifiRule = NEOnDemandRuleConnect() + wifiRule.interfaceTypeMatch = .wiFi + let ethernetRule = NEOnDemandRuleConnect() + ethernetRule.interfaceTypeMatch = .ethernet + mgr.onDemandRules = [wifiRule, ethernetRule] + mgr.isOnDemandEnabled = true } else { - print("VPNManager: On Demand updated to \(enabled)") + mgr.onDemandRules = [] + mgr.isOnDemandEnabled = false + } + + mgr.saveToPreferences { error in + if let error = error { + print("VPNManager: Error updating On Demand: \(error)") + } else { + print("VPNManager: On Demand updated to \(enabled)") + } } } } @@ -197,33 +205,29 @@ class VPNManager: ObservableObject { manager?.connection.stopVPNTunnel() } - /// 手动关闭:先 stop 隧道,等断开后再禁用 On Demand,防止系统自动重连 + /// 手动关闭:load → 禁用 On Demand → save → stop,防止系统自动重连 func disableOnDemandAndStop() { guard let manager = manager else { return } - // 先 stop - manager.connection.stopVPNTunnel() - - // 监听断开后立即禁用 On Demand - var observer: NSObjectProtocol? - observer = NotificationCenter.default.addObserver( - forName: .NEVPNStatusDidChange, - object: manager.connection, - queue: .main - ) { [weak self] _ in + // Apple 要求 save 前先 load 最新状态 + manager.loadFromPreferences { [weak self] error in guard let self = self, let mgr = self.manager else { return } - if mgr.connection.status == .disconnected { - if let obs = observer { - NotificationCenter.default.removeObserver(obs) - } - mgr.isOnDemandEnabled = false - mgr.saveToPreferences { error in - if let error = error { - print("VPNManager: Error disabling On Demand after stop: \(error)") - } else { - print("VPNManager: On Demand disabled after manual stop") - } + if let error = error { + print("VPNManager: Error loading preferences: \(error)") + // 即使 load 失败也尝试 stop + mgr.connection.stopVPNTunnel() + return + } + + mgr.isOnDemandEnabled = false + mgr.saveToPreferences { error in + if let error = error { + print("VPNManager: Error disabling On Demand: \(error)") + } else { + print("VPNManager: On Demand disabled, now stopping tunnel") } + // save 完成后再 stop + mgr.connection.stopVPNTunnel() } } } From f31579b710a27f907594659e1fa3177b9b91802a Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Fri, 13 Feb 2026 02:33:11 +0800 Subject: [PATCH 17/30] =?UTF-8?q?=E5=BC=80=E5=A7=8B=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E5=89=8D=E5=A4=87=E4=BB=BD=202026-02-13=20-=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20ContentView=20=E4=B8=AD=20stop=20=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E6=9C=AA=E8=B0=83=E7=94=A8=20disableOnDemandAndStop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 8f4d700d635e8ae9c342ad8b294bf217dbee9bcb Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Fri, 13 Feb 2026 02:46:02 +0800 Subject: [PATCH 18/30] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=A4=E4=B8=AA?= =?UTF-8?q?=E9=97=AE=E9=A2=98:=201.=20ContentView=20stop=20=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E6=94=B9=E7=94=A8=20disableOnDemandAndStop=20?= =?UTF-8?q?=E9=98=B2=E6=AD=A2=20On=20Demand=20=E8=87=AA=E5=8A=A8=E9=87=8D?= =?UTF-8?q?=E8=BF=9E=202.=20openiCloudFolder=20=E6=94=B9=E7=94=A8=20select?= =?UTF-8?q?File+inFileViewerRootedAtPath=20=E9=81=BF=E5=85=8D=E6=B2=99?= =?UTF-8?q?=E7=9B=92=E6=9D=83=E9=99=90=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Swiftier/ConfigManager.swift | 26 ++++++++++++++++++++++++-- Swiftier/ContentView.swift | 2 +- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/Swiftier/ConfigManager.swift b/Swiftier/ConfigManager.swift index 49c9e7e..52e331b 100644 --- a/Swiftier/ConfigManager.swift +++ b/Swiftier/ConfigManager.swift @@ -101,8 +101,30 @@ class ConfigManager: ObservableObject { } func openiCloudFolder() { - guard let url = currentDirectory else { return } - NSWorkspace.shared.open(url) + var targetURL: URL? + + // 优先从书签恢复带权限的 URL + if let bookmark = customPathBookmark { + var isStale = false + if let url = try? URL(resolvingBookmarkData: bookmark, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale) { + _ = url.startAccessingSecurityScopedResource() + targetURL = url + } + } + + // Fallback: iCloud 容器路径 + if targetURL == nil, let drive = iCloudDriveURL { + targetURL = drive.appendingPathComponent("Swiftier") + } + + // 最终 fallback + if targetURL == nil { + targetURL = currentDirectory + } + + guard let url = targetURL else { return } + // 使用 selectFile 在 Finder 中显示目录,避免沙盒权限问题 + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: url.path) } func editConfigFile(url: URL) { diff --git a/Swiftier/ContentView.swift b/Swiftier/ContentView.swift index 20dfa19..1721d2c 100644 --- a/Swiftier/ContentView.swift +++ b/Swiftier/ContentView.swift @@ -485,7 +485,7 @@ struct ContentView: View { Button { if vpnManager.isConnected { - vpnManager.stopVPN() + vpnManager.disableOnDemandAndStop() } else { // 通过 ConfigManager 读取(处理安全域书签) let configURL = URL(fileURLWithPath: selectedConfigPath) From 39982d687b5f594ee4f2093f0153316c88e85769 Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Fri, 13 Feb 2026 02:48:50 +0800 Subject: [PATCH 19/30] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E5=88=B0=20iCloud=20=E5=90=8E=20Open=20in=20Finder=20=E4=BB=8D?= =?UTF-8?q?=E6=89=93=E5=BC=80=E6=97=A7=E8=B7=AF=E5=BE=84=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - migrateToiCloud 完成后清除旧的 customPathBookmark - 书签优先级最高,不清除的话 currentDirectory 和 openiCloudFolder 都会指向旧书签路径 - 同时将 Finder 打开方式统一为 selectFile(inFileViewerRootedAtPath:) --- Swiftier/ConfigManager.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Swiftier/ConfigManager.swift b/Swiftier/ConfigManager.swift index 52e331b..5880f10 100644 --- a/Swiftier/ConfigManager.swift +++ b/Swiftier/ConfigManager.swift @@ -183,12 +183,13 @@ class ConfigManager: ObservableObject { } } - // 3. 切换目录并刷新 + // 3. 切换目录并刷新(清除旧书签,iCloud 容器路径 App 自身有权限) DispatchQueue.main.async { + self.customPathBookmark = nil self.customPathString = targetDir.path self.refreshConfigs() // 打开 Finder 确认 - NSWorkspace.shared.open(targetDir) + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: targetDir.path) } } catch { From ff86a502378041d5b8eef2a8db3340e041d41e0c Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Fri, 13 Feb 2026 02:51:30 +0800 Subject: [PATCH 20/30] =?UTF-8?q?=E5=90=AF=E5=8A=A8=E6=97=B6=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=B8=85=E9=99=A4=E4=B8=8E=20customPathString=20?= =?UTF-8?q?=E4=B8=8D=E4=B8=80=E8=87=B4=E7=9A=84=E6=AE=8B=E7=95=99=E4=B9=A6?= =?UTF-8?q?=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复已迁移到 iCloud 但旧桌面书签仍残留在 UserDefaults 中, 导致 currentDirectory 和 openiCloudFolder 始终指向桌面的问题 --- Swiftier/ConfigManager.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Swiftier/ConfigManager.swift b/Swiftier/ConfigManager.swift index 5880f10..0f9243c 100644 --- a/Swiftier/ConfigManager.swift +++ b/Swiftier/ConfigManager.swift @@ -58,6 +58,20 @@ class ConfigManager: ObservableObject { } private init() { + // 修正:如果 customPathString 已指向 iCloud 容器,清除可能残留的旧书签 + // (旧书签可能指向桌面等非 iCloud 路径,导致 currentDirectory 返回错误位置) + if let bookmark = customPathBookmark, !customPathString.isEmpty { + var isStale = false + if let bookmarkURL = try? URL(resolvingBookmarkData: bookmark, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale) { + let bookmarkPath = bookmarkURL.path + // 书签路径与当前设置路径不一致,说明书签是残留的旧数据 + if bookmarkPath != customPathString { + print("[ConfigManager] 清除残留书签: \(bookmarkPath) != \(customPathString)") + self.customPathBookmark = nil + } + } + } + // 首次运行或未设置路径时,自动尝试初始化 iCloud if customPathString.isEmpty { if let drive = iCloudDriveURL { From 92f613cd141238041404775766022177deba873b Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Fri, 13 Feb 2026 11:21:02 +0800 Subject: [PATCH 21/30] Pre-modification commit - 2026-02-13 03:20 --- .DS_Store | Bin 6148 -> 6148 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.DS_Store b/.DS_Store index 03f0203744945b5a74defc1ebccb1514a01d033b..2da62b214640d4e2ddef390b11365fc09842acf2 100644 GIT binary patch delta 55 zcmZoMXffDe$;{LmG})Tjgw1s~{|VjslQ%MJO`gxpgWy^)9@zYmS&U_31N&xnj=%f> D3)>SO delta 55 zcmZoMXffDe$;=cHJK37qgl)mT3Z9yIlQ%MJO`gxpgWy^)PT2gBS&U_31N&xnj=%f> D8QT+n From 7646b9a6797afd3074d421ac55bd54f787992a83 Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Mon, 16 Feb 2026 21:20:22 +0800 Subject: [PATCH 22/30] Refactor: Rename project to Spotier; Fix App Group ID mismatch; Bump version to 2 --- .DS_Store | Bin 6148 -> 6148 bytes .../project.pbxproj | 124 +++---- .../project.pbxproj.bak | 302 +++++++++++------- .../contents.xcworkspacedata | 0 .../xcshareddata/xcschemes/Spotier.xcscheme | 30 +- .../AccentColor.colorset/Contents.json | 0 .../Assets.xcassets/Contents.json | 0 {Swiftier => Spotier}/CliClient.swift | 4 +- {Swiftier => Spotier}/CodeEditor.swift | 2 +- {Swiftier => Spotier}/ConfigEditorView.swift | 0 .../ConfigGeneratorView.swift | 22 +- {Swiftier => Spotier}/ConfigManager.swift | 46 ++- {Swiftier => Spotier}/ContentView.swift | 6 +- {Swiftier => Spotier}/CoreDownloader.swift | 0 {SwiftierNE => Spotier}/EasyTierShared.swift | 4 +- {Swiftier => Spotier}/EventListView.swift | 0 {Swiftier => Spotier}/Extensions.swift | 0 {Swiftier => Spotier}/HelperProtocol.swift | 0 {Swiftier => Spotier}/Info.plist | 6 +- {Swiftier => Spotier}/Localizable.xcstrings | 36 ++- {Swiftier => Spotier}/LogListView.swift | 0 {Swiftier => Spotier}/LogModels.swift | 0 {Swiftier => Spotier}/LogParser.swift | 2 +- {Swiftier => Spotier}/LogView.swift | 0 .../Models/SpotierNodeModels.swift | 2 +- {Swiftier => Spotier}/PeerCard.swift | 6 +- {Swiftier => Spotier}/RippleRingsView.swift | 0 {Swiftier => Spotier}/ScrollFixer.swift | 0 {Swiftier => Spotier}/SettingsView.swift | 9 +- {Swiftier => Spotier}/SharedComponents.swift | 0 {Swiftier => Spotier}/SparklineView.swift | 8 +- .../Spotier.entitlements | 6 +- ...oint.3.connected.trianglepath.dotted 2.png | Bin .../Spotier.icon}/icon.json | 0 .../SpotierControlApp.swift | 12 +- .../SpotierRunner.swift | 10 +- {Swiftier => Spotier}/VPNManager.swift | 8 +- {SwiftierNE => SpotierNE}/AddressHelper.swift | 0 {Swiftier => SpotierNE}/EasyTierShared.swift | 4 +- {SwiftierNE => SpotierNE}/Info.plist | 0 {SwiftierNE => SpotierNE}/InfoModels.swift | 0 {SwiftierNE => SpotierNE}/Logger.swift | 0 {SwiftierNE => SpotierNE}/OSLogExporter.swift | 0 .../PacketTunnelProvider.swift | 0 .../SpotierNE-Bridging-Header.h | 0 .../SpotierNE.entitlements | 2 +- {SwiftierNE => SpotierNE}/SwiftierCore.swift | 0 {SwiftierNE => SpotierNE}/TunnelHelper.swift | 0 .../xcschemes/xcschememanagement.plist | 52 --- 49 files changed, 383 insertions(+), 320 deletions(-) rename {Swiftier.xcodeproj => Spotier.xcodeproj}/project.pbxproj (85%) rename {Swiftier.xcodeproj => Spotier.xcodeproj}/project.pbxproj.bak (67%) rename {Swiftier.xcodeproj => Spotier.xcodeproj}/project.xcworkspace/contents.xcworkspacedata (100%) rename Swiftier.xcodeproj/xcshareddata/xcschemes/Swiftier.xcscheme => Spotier.xcodeproj/xcshareddata/xcschemes/Spotier.xcscheme (79%) rename {Swiftier => Spotier}/Assets.xcassets/AccentColor.colorset/Contents.json (100%) rename {Swiftier => Spotier}/Assets.xcassets/Contents.json (100%) rename {Swiftier => Spotier}/CliClient.swift (99%) rename {Swiftier => Spotier}/CodeEditor.swift (98%) rename {Swiftier => Spotier}/ConfigEditorView.swift (100%) rename {Swiftier => Spotier}/ConfigGeneratorView.swift (99%) rename {Swiftier => Spotier}/ConfigManager.swift (85%) rename {Swiftier => Spotier}/ContentView.swift (99%) rename {Swiftier => Spotier}/CoreDownloader.swift (100%) rename {SwiftierNE => Spotier}/EasyTierShared.swift (97%) mode change 100755 => 100644 rename {Swiftier => Spotier}/EventListView.swift (100%) rename {Swiftier => Spotier}/Extensions.swift (100%) rename {Swiftier => Spotier}/HelperProtocol.swift (100%) rename {Swiftier => Spotier}/Info.plist (78%) rename {Swiftier => Spotier}/Localizable.xcstrings (98%) rename {Swiftier => Spotier}/LogListView.swift (100%) rename {Swiftier => Spotier}/LogModels.swift (100%) rename {Swiftier => Spotier}/LogParser.swift (99%) rename {Swiftier => Spotier}/LogView.swift (100%) rename Swiftier/Models/SwiftierNodeModels.swift => Spotier/Models/SpotierNodeModels.swift (99%) rename {Swiftier => Spotier}/PeerCard.swift (98%) rename {Swiftier => Spotier}/RippleRingsView.swift (100%) rename {Swiftier => Spotier}/ScrollFixer.swift (100%) rename {Swiftier => Spotier}/SettingsView.swift (96%) rename {Swiftier => Spotier}/SharedComponents.swift (100%) rename {Swiftier => Spotier}/SparklineView.swift (98%) rename Swiftier/Swiftier.entitlements => Spotier/Spotier.entitlements (89%) rename {Swiftier/Swiftier.icon => Spotier/Spotier.icon}/Assets/point.3.connected.trianglepath.dotted 2.png (100%) rename {Swiftier/Swiftier.icon => Spotier/Spotier.icon}/icon.json (100%) rename Swiftier/SwiftierControlApp.swift => Spotier/SpotierControlApp.swift (94%) rename Swiftier/SwiftierRunner.swift => Spotier/SpotierRunner.swift (98%) rename {Swiftier => Spotier}/VPNManager.swift (97%) rename {SwiftierNE => SpotierNE}/AddressHelper.swift (100%) rename {Swiftier => SpotierNE}/EasyTierShared.swift (97%) mode change 100644 => 100755 rename {SwiftierNE => SpotierNE}/Info.plist (100%) rename {SwiftierNE => SpotierNE}/InfoModels.swift (100%) rename {SwiftierNE => SpotierNE}/Logger.swift (100%) rename {SwiftierNE => SpotierNE}/OSLogExporter.swift (100%) rename {SwiftierNE => SpotierNE}/PacketTunnelProvider.swift (100%) rename SwiftierNE/SwiftierNE-Bridging-Header.h => SpotierNE/SpotierNE-Bridging-Header.h (100%) rename SwiftierNE/SwiftierNE.entitlements => SpotierNE/SpotierNE.entitlements (92%) rename {SwiftierNE => SpotierNE}/SwiftierCore.swift (100%) rename {SwiftierNE => SpotierNE}/TunnelHelper.swift (100%) delete mode 100644 Swiftier.xcodeproj/xcuserdata/alick.xcuserdatad/xcschemes/xcschememanagement.plist diff --git a/.DS_Store b/.DS_Store index 2da62b214640d4e2ddef390b11365fc09842acf2..a87d9c5e56fbf3eea933f9c847a799d83e350f93 100644 GIT binary patch delta 157 zcmZoMXfc=&$(_lN%21S4UR;orv-u>Pjj0m;%U2LJ#7 delta 196 zcmZoMXfc=&$z8&b$&kuWlvG|^u=yeLaz*8*7!Ls5Da*2%o#QV*04 + BuildableName = "Spotier.app" + BlueprintName = "Spotier" + ReferencedContainer = "container:Spotier.xcodeproj"> @@ -36,9 +36,9 @@ + BuildableName = "SpotierTests.xctest" + BlueprintName = "SpotierTests" + ReferencedContainer = "container:Spotier.xcodeproj"> + BuildableName = "SpotierUITests.xctest" + BlueprintName = "SpotierUITests" + ReferencedContainer = "container:Spotier.xcodeproj"> @@ -69,9 +69,9 @@ + BuildableName = "Spotier.app" + BlueprintName = "Spotier" + ReferencedContainer = "container:Spotier.xcodeproj"> @@ -86,9 +86,9 @@ + BuildableName = "Spotier.app" + BlueprintName = "Spotier" + ReferencedContainer = "container:Spotier.xcodeproj"> diff --git a/Swiftier/Assets.xcassets/AccentColor.colorset/Contents.json b/Spotier/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from Swiftier/Assets.xcassets/AccentColor.colorset/Contents.json rename to Spotier/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/Swiftier/Assets.xcassets/Contents.json b/Spotier/Assets.xcassets/Contents.json similarity index 100% rename from Swiftier/Assets.xcassets/Contents.json rename to Spotier/Assets.xcassets/Contents.json diff --git a/Swiftier/CliClient.swift b/Spotier/CliClient.swift similarity index 99% rename from Swiftier/CliClient.swift rename to Spotier/CliClient.swift index 3a57493..1a861c2 100644 --- a/Swiftier/CliClient.swift +++ b/Spotier/CliClient.swift @@ -29,9 +29,9 @@ struct PeerInfo: Identifiable, Equatable { // Add more as needed based on observation // 针对远程节点的完整数据信息 - var fullData: SwiftierStatus.PeerRoutePair? = nil + var fullData: SpotierStatus.PeerRoutePair? = nil // 针对“本机”节点的完整信息 - var myNodeData: SwiftierStatus.NodeInfo? = nil + var myNodeData: SpotierStatus.NodeInfo? = nil // 运行中必须保证稳定且唯一的 id:否则 SwiftUI 会把同一节点当成“删除+新增”,或出现跳格/错位 // 优先使用 route.peerId(数值通常最稳定、且唯一),再降级到 instId / nodeId,最后兜底 hostname+ipv4。 diff --git a/Swiftier/CodeEditor.swift b/Spotier/CodeEditor.swift similarity index 98% rename from Swiftier/CodeEditor.swift rename to Spotier/CodeEditor.swift index 600eb90..604cb16 100644 --- a/Swiftier/CodeEditor.swift +++ b/Spotier/CodeEditor.swift @@ -78,7 +78,7 @@ struct CodeEditor: NSViewRepresentable { applyStyle(pattern: "(?i)TRACE", color: NSColor.systemBlue, bold: true) // Highlight Swiftier keywords - applyStyle(pattern: "Swiftier", color: NSColor.labelColor, bold: true) + applyStyle(pattern: "Spotier", color: NSColor.labelColor, bold: true) } else if mode == .json { // JSON Syntax Highlighting storage.removeAttribute(.foregroundColor, range: fullRange) diff --git a/Swiftier/ConfigEditorView.swift b/Spotier/ConfigEditorView.swift similarity index 100% rename from Swiftier/ConfigEditorView.swift rename to Spotier/ConfigEditorView.swift diff --git a/Swiftier/ConfigGeneratorView.swift b/Spotier/ConfigGeneratorView.swift similarity index 99% rename from Swiftier/ConfigGeneratorView.swift rename to Spotier/ConfigGeneratorView.swift index ec62e1e..66ea201 100644 --- a/Swiftier/ConfigGeneratorView.swift +++ b/Spotier/ConfigGeneratorView.swift @@ -12,7 +12,7 @@ struct PortForwardRule: Identifiable, Equatable { var targetPort: String = "" } -struct SwiftierConfigModel: Equatable { +struct SpotierConfigModel: Equatable { var instanceName: String = Host.current().localizedName ?? "swiftier-node" var instanceId: String = UUID().uuidString.lowercased() @@ -107,7 +107,7 @@ struct SwiftierConfigModel: Equatable { var onlyP2P: Bool = false } -extension SwiftierConfigModel { +extension SpotierConfigModel { var vpnPortalIpBinding: String { get { let parts = vpnPortalClientCidr.split(separator: "/") @@ -147,13 +147,13 @@ class ConfigDraftManager { // Support multiple drafts for different files // Key nil represents "New Config" draft - private var drafts: [URL?: SwiftierConfigModel] = [:] + private var drafts: [URL?: SpotierConfigModel] = [:] - func getDraft(for url: URL?) -> SwiftierConfigModel? { + func getDraft(for url: URL?) -> SpotierConfigModel? { return drafts[url] } - func saveDraft(for url: URL?, model: SwiftierConfigModel) { + func saveDraft(for url: URL?, model: SpotierConfigModel) { drafts[url] = model } @@ -190,7 +190,7 @@ struct ConfigGeneratorView: View { var editingFileURL: URL? = nil // 支持传入文件进行编辑 var onSave: () -> Void - @State private var model = SwiftierConfigModel() + @State private var model = SpotierConfigModel() // Track if we've already loaded to avoid resetting user edits @State private var hasLoadedInitially = false @@ -275,7 +275,7 @@ struct ConfigGeneratorView: View { if editingFileURL != lastLoadedURL { if editingFileURL == nil { // New file without draft -> Reset - model = SwiftierConfigModel() + model = SpotierConfigModel() } else { loadFromFile() } @@ -412,7 +412,7 @@ struct ConfigGeneratorView: View { .buttonStyle(.plain) } } - Button { model.proxySubnets.append(SwiftierConfigModel.ProxySubnet(cidr: "0.0.0.0/0")) } label: { + Button { model.proxySubnets.append(SpotierConfigModel.ProxySubnet(cidr: "0.0.0.0/0")) } label: { HStack { Image(systemName: "plus.circle.fill") Text("添加代理网段") @@ -994,8 +994,8 @@ struct ConfigGeneratorView: View { self.model = parseTOML(content) } - private func parseTOML(_ content: String) -> SwiftierConfigModel { - var m = SwiftierConfigModel() + private func parseTOML(_ content: String) -> SpotierConfigModel { + var m = SpotierConfigModel() let lines = content.components(separatedBy: .newlines) var currentSection = "" @@ -1011,7 +1011,7 @@ struct ConfigGeneratorView: View { if trimmed.hasPrefix("[") { if trimmed.hasPrefix("[[") { currentSection = String(trimmed.dropFirst(2).dropLast(2)) - if currentSection == "proxy_network" { m.proxySubnets.append(SwiftierConfigModel.ProxySubnet()) } + if currentSection == "proxy_network" { m.proxySubnets.append(SpotierConfigModel.ProxySubnet()) } if currentSection == "port_forward" { m.portForwards.append(PortForwardRule()) } } else { currentSection = String(trimmed.dropFirst(1).dropLast(1)) diff --git a/Swiftier/ConfigManager.swift b/Spotier/ConfigManager.swift similarity index 85% rename from Swiftier/ConfigManager.swift rename to Spotier/ConfigManager.swift index 0f9243c..4dac87f 100644 --- a/Swiftier/ConfigManager.swift +++ b/Spotier/ConfigManager.swift @@ -38,7 +38,7 @@ class ConfigManager: ObservableObject { // 3. 自动探测 iCloud 路径作为默认值 if let drive = iCloudDriveURL { - let targetDir = drive.appendingPathComponent("Swiftier") + let targetDir = drive if !FileManager.default.fileExists(atPath: targetDir.path) { try? FileManager.default.createDirectory(at: targetDir, withIntermediateDirectories: true) } @@ -47,7 +47,7 @@ class ConfigManager: ObservableObject { // 4. Fallback to local Application Support if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { - let targetDir = appSupport.appendingPathComponent("Swiftier") + let targetDir = appSupport.appendingPathComponent("Spotier") if !FileManager.default.fileExists(atPath: targetDir.path) { try? FileManager.default.createDirectory(at: targetDir, withIntermediateDirectories: true) } @@ -75,7 +75,7 @@ class ConfigManager: ObservableObject { // 首次运行或未设置路径时,自动尝试初始化 iCloud if customPathString.isEmpty { if let drive = iCloudDriveURL { - let targetDir = drive.appendingPathComponent("Swiftier") + let targetDir = drive if !FileManager.default.fileExists(atPath: targetDir.path) { try? FileManager.default.createDirectory(at: targetDir, withIntermediateDirectories: true) } @@ -84,7 +84,7 @@ class ConfigManager: ObservableObject { } else { // Fallback to local Application Support if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { - let targetDir = appSupport.appendingPathComponent("Swiftier") + let targetDir = appSupport.appendingPathComponent("Spotier") if !FileManager.default.fileExists(atPath: targetDir.path) { try? FileManager.default.createDirectory(at: targetDir, withIntermediateDirectories: true) } @@ -128,7 +128,7 @@ class ConfigManager: ObservableObject { // Fallback: iCloud 容器路径 if targetURL == nil, let drive = iCloudDriveURL { - targetURL = drive.appendingPathComponent("Swiftier") + targetURL = drive } // 最终 fallback @@ -168,15 +168,39 @@ class ConfigManager: ObservableObject { return } - let targetDir = drive.appendingPathComponent("Swiftier") + let targetDir = drive let oldDir = drive.appendingPathComponent("EasyTier") do { - // 0. 自动迁移旧 EasyTier 文件夹(如果存在且新 Swiftier 不存在) - if FileManager.default.fileExists(atPath: oldDir.path) && - !FileManager.default.fileExists(atPath: targetDir.path) { - try FileManager.default.moveItem(at: oldDir, to: targetDir) - print("已将旧 EasyTier 文件夹迁移到 Swiftier") + // 0a. 自动迁移旧 EasyTier 文件夹内容 + if FileManager.default.fileExists(atPath: oldDir.path) { + let oldItems = try? FileManager.default.contentsOfDirectory(at: oldDir, includingPropertiesForKeys: nil) + if let oldItems = oldItems { + for item in oldItems { + let dest = targetDir.appendingPathComponent(item.lastPathComponent) + if !FileManager.default.fileExists(atPath: dest.path) { + try FileManager.default.moveItem(at: item, to: dest) + } + } + try? FileManager.default.removeItem(at: oldDir) + print("已迁移 EasyTier 内容到跟目录") + } + } + + // 0b. 自动迁移错误的 nested Spotier 文件夹 (修复之前的 Bug) + let nestedSpotierDir = drive.appendingPathComponent("Spotier") + if FileManager.default.fileExists(atPath: nestedSpotierDir.path) { + let nestedItems = try? FileManager.default.contentsOfDirectory(at: nestedSpotierDir, includingPropertiesForKeys: nil) + if let nestedItems = nestedItems { + for item in nestedItems { + let dest = targetDir.appendingPathComponent(item.lastPathComponent) + if !FileManager.default.fileExists(atPath: dest.path) { + try FileManager.default.moveItem(at: item, to: dest) + } + } + try? FileManager.default.removeItem(at: nestedSpotierDir) + print("已修复嵌套的 Spotier 文件夹") + } } // 1. 创建目标目录 diff --git a/Swiftier/ContentView.swift b/Spotier/ContentView.swift similarity index 99% rename from Swiftier/ContentView.swift rename to Spotier/ContentView.swift index 1721d2c..7f2fee7 100644 --- a/Swiftier/ContentView.swift +++ b/Spotier/ContentView.swift @@ -6,7 +6,7 @@ struct ContentView: View { // 性能优化:不再直接观察整个 runner,避免 uptime/speed 变化触发全量 Diff // 改为手动监听核心状态 - private var runner = SwiftierRunner.shared + private var runner = SpotierRunner.shared @ObservedObject private var vpnManager = VPNManager.shared @State private var isRunning = false @State private var isWindowVisible = true @@ -461,7 +461,7 @@ struct ContentView: View { let isPaused: Bool // 新增:是否暂停 // 直接订阅 runner,只有这个组件会被频繁刷新 - @ObservedObject private var runner = SwiftierRunner.shared + @ObservedObject private var runner = SpotierRunner.shared @ObservedObject private var vpnManager = VPNManager.shared var body: some View { @@ -528,7 +528,7 @@ struct ContentView: View { // MARK: - 节点列表区域(独立组件,隔离 peers 刷新) struct PeerListArea: View { - @StateObject private var runner = SwiftierRunner.shared + @StateObject private var runner = SpotierRunner.shared // 定义两行网格布局,自适应宽度 private let gridRows = [ diff --git a/Swiftier/CoreDownloader.swift b/Spotier/CoreDownloader.swift similarity index 100% rename from Swiftier/CoreDownloader.swift rename to Spotier/CoreDownloader.swift diff --git a/SwiftierNE/EasyTierShared.swift b/Spotier/EasyTierShared.swift old mode 100755 new mode 100644 similarity index 97% rename from SwiftierNE/EasyTierShared.swift rename to Spotier/EasyTierShared.swift index dc952ec..a366925 --- a/SwiftierNE/EasyTierShared.swift +++ b/Spotier/EasyTierShared.swift @@ -2,8 +2,8 @@ import NetworkExtension import os public let APP_BUNDLE_ID: String = "com.alick.swiftier" -public let APP_GROUP_ID: String = "group.com.alick.swiftier" -public let ICLOUD_CONTAINER_ID: String = "iCloud.com.alick.swiftier" +public let APP_GROUP_ID: String = "group.com.alick.spotier" +public let ICLOUD_CONTAINER_ID: String = "iCloud.com.alick.spotier" public let LOG_FILENAME: String = "easytier.log" public enum LogLevel: String, Codable, CaseIterable { diff --git a/Swiftier/EventListView.swift b/Spotier/EventListView.swift similarity index 100% rename from Swiftier/EventListView.swift rename to Spotier/EventListView.swift diff --git a/Swiftier/Extensions.swift b/Spotier/Extensions.swift similarity index 100% rename from Swiftier/Extensions.swift rename to Spotier/Extensions.swift diff --git a/Swiftier/HelperProtocol.swift b/Spotier/HelperProtocol.swift similarity index 100% rename from Swiftier/HelperProtocol.swift rename to Spotier/HelperProtocol.swift diff --git a/Swiftier/Info.plist b/Spotier/Info.plist similarity index 78% rename from Swiftier/Info.plist rename to Spotier/Info.plist index eb39e9d..5993343 100644 --- a/Swiftier/Info.plist +++ b/Spotier/Info.plist @@ -3,14 +3,16 @@ + CFBundleDisplayName + Spotier NSUbiquitousContainers - iCloud.com.alick.swiftier + iCloud.com.alick.spotier NSUbiquitousContainerIsDocumentScopePublic NSUbiquitousContainerName - Swiftier + Spotier NSUbiquitousContainerSupportedFolderLevels Any diff --git a/Swiftier/Localizable.xcstrings b/Spotier/Localizable.xcstrings similarity index 98% rename from Swiftier/Localizable.xcstrings rename to Spotier/Localizable.xcstrings index 3bc1690..83fc257 100644 --- a/Swiftier/Localizable.xcstrings +++ b/Spotier/Localizable.xcstrings @@ -71,6 +71,12 @@ }, "easytier" : { + }, + "EasyTier" : { + + }, + "easytier.cn" : { + }, "ERROR" : { @@ -161,24 +167,24 @@ } } }, - "Swiftier 已是最新版本" : { + "Spotier 已是最新版本" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Swiftier is up to date" + "value" : "Spotier is up to date" } } } }, - "Swiftier 应该已自动出现在列表中" : { + "Spotier 应该已自动出现在列表中" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "2. Swiftier should be in the list" + "value" : "2. Spotier should be in the list" } } } @@ -322,13 +328,13 @@ } } }, - "为了能够读取您选择的任意文件夹及配置文件,Swiftier 需要“完全磁盘访问权限”。\n这不会泄露您的私有数据,仅用于解除系统文件夹读取限制。" : { + "为了能够读取您选择的任意文件夹及配置文件,Spotier 需要“完全磁盘访问权限”。\n这不会泄露您的私有数据,仅用于解除系统文件夹读取限制。" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Swiftier needs Full Disk Access to read your config folders. Your private data is never accessed." + "value" : "Spotier needs Full Disk Access to read your config folders. Your private data is never accessed." } } } @@ -410,7 +416,7 @@ } } }, - "仅使用物理网卡,避免 Swiftier 通过其他虚拟网建立连接。" : { + "仅使用物理网卡,避免 Spotier 通过其他虚拟网建立连接。" : { "extractionState" : "manual", "localizations" : { "en" : { @@ -420,6 +426,9 @@ } } } + }, + "仅使用物理网卡,避免 Swiftier 通过其他虚拟网建立连接。" : { + }, "仅保留 Helper 加速启动" : { "extractionState" : "manual", @@ -927,7 +936,7 @@ } } }, - "启用魔法 DNS,允许通过 Swiftier 的 DNS 服务器访问其他节点的虚拟 IPv4 地址,例如:node1.et.net。" : { + "启用魔法 DNS,允许通过 Spotier 的 DNS 服务器访问其他节点的虚拟 IPv4 地址,例如:node1.et.net。" : { "extractionState" : "manual", "localizations" : { "en" : { @@ -937,6 +946,9 @@ } } } + }, + "启用魔法 DNS,允许通过 Swiftier 的 DNS 服务器访问其他节点的虚拟 IPv4 地址,例如:node1.et.net。" : { + }, "在 Finder 中打开" : { "extractionState" : "manual", @@ -1224,16 +1236,19 @@ } } }, - "开启 SOCKS5 代理功能,Surge 等外部程序可通过此端口连接 Swiftier 网络。" : { + "开启 SOCKS5 代理功能,Surge 等外部程序可通过此端口连接 Spotier 网络。" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Enable SOCKS5 proxy so external apps like Surge can connect to the Swiftier network." + "value" : "Enable SOCKS5 proxy so external apps like Surge can connect to the Spotier network." } } } + }, + "开启 SOCKS5 代理功能,Surge 等外部程序可通过此端口连接 Swiftier 网络。" : { + }, "开放" : { "extractionState" : "manual", @@ -1301,7 +1316,6 @@ } } }, - "手动指定监听器的公网地址,其他节点可以使用该地址连接到本节点。例如:tcp://123.123.123.123:11223,可以指定多个。" : { "extractionState" : "manual", "localizations" : { diff --git a/Swiftier/LogListView.swift b/Spotier/LogListView.swift similarity index 100% rename from Swiftier/LogListView.swift rename to Spotier/LogListView.swift diff --git a/Swiftier/LogModels.swift b/Spotier/LogModels.swift similarity index 100% rename from Swiftier/LogModels.swift rename to Spotier/LogModels.swift diff --git a/Swiftier/LogParser.swift b/Spotier/LogParser.swift similarity index 99% rename from Swiftier/LogParser.swift rename to Spotier/LogParser.swift index 4f0b156..13940d1 100644 --- a/Swiftier/LogParser.swift +++ b/Spotier/LogParser.swift @@ -13,7 +13,7 @@ class LogParser: ObservableObject { // Persistence private var eventsFileURL: URL { let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - let dir = appSupport.appendingPathComponent("Swiftier") + let dir = appSupport.appendingPathComponent("Spotier") try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) return dir.appendingPathComponent("events.json") } diff --git a/Swiftier/LogView.swift b/Spotier/LogView.swift similarity index 100% rename from Swiftier/LogView.swift rename to Spotier/LogView.swift diff --git a/Swiftier/Models/SwiftierNodeModels.swift b/Spotier/Models/SpotierNodeModels.swift similarity index 99% rename from Swiftier/Models/SwiftierNodeModels.swift rename to Spotier/Models/SpotierNodeModels.swift index f58580d..dcf3f9e 100644 --- a/Swiftier/Models/SwiftierNodeModels.swift +++ b/Spotier/Models/SpotierNodeModels.swift @@ -3,7 +3,7 @@ import SwiftUI // MARK: - Core Status Models (Ported from Swiftier-iOS) -struct SwiftierStatus: Codable { +struct SpotierStatus: Codable { enum NATType: Int, Codable, Hashable { case unknown = 0 case openInternet = 1 diff --git a/Swiftier/PeerCard.swift b/Spotier/PeerCard.swift similarity index 98% rename from Swiftier/PeerCard.swift rename to Spotier/PeerCard.swift index 9ef611d..15ee754 100644 --- a/Swiftier/PeerCard.swift +++ b/Spotier/PeerCard.swift @@ -309,7 +309,7 @@ struct PeerDetailView: View { } @ViewBuilder - private func localNodeSections(_ node: SwiftierStatus.NodeInfo) -> some View { + private func localNodeSections(_ node: SpotierStatus.NodeInfo) -> some View { Section(header: Text(LocalizedStringKey("节点"))) { DetailRow(label: LocalizedStringKey("主机名"), value: node.hostname) DetailRow(label: LocalizedStringKey("版本"), value: node.version) @@ -328,7 +328,7 @@ struct PeerDetailView: View { } @ViewBuilder - private func remotePeerSections(_ pair: SwiftierStatus.PeerRoutePair) -> some View { + private func remotePeerSections(_ pair: SpotierStatus.PeerRoutePair) -> some View { let route = pair.route Section(header: Text(LocalizedStringKey("节点"))) { @@ -389,7 +389,7 @@ struct PeerDetailView: View { } } - private func formatFlags(_ flags: SwiftierStatus.PeerFeatureFlag) -> String { + private func formatFlags(_ flags: SpotierStatus.PeerFeatureFlag) -> String { var parts = [String]() if flags.isPublicServer { parts.append("public_server") } if flags.avoidRelayData { parts.append("avoid_relay") } diff --git a/Swiftier/RippleRingsView.swift b/Spotier/RippleRingsView.swift similarity index 100% rename from Swiftier/RippleRingsView.swift rename to Spotier/RippleRingsView.swift diff --git a/Swiftier/ScrollFixer.swift b/Spotier/ScrollFixer.swift similarity index 100% rename from Swiftier/ScrollFixer.swift rename to Spotier/ScrollFixer.swift diff --git a/Swiftier/SettingsView.swift b/Spotier/SettingsView.swift similarity index 96% rename from Swiftier/SettingsView.swift rename to Spotier/SettingsView.swift index 3553c69..c0bd140 100644 --- a/Swiftier/SettingsView.swift +++ b/Spotier/SettingsView.swift @@ -76,7 +76,14 @@ struct SettingsView: View { HStack { Text("源代码") Spacer() - Link("GitHub 仓库", destination: URL(string: "https://github.com/AlickH/Swiftier")!) + Link("GitHub 仓库", destination: URL(string: "https://github.com/AlickH/Spotier")!) + .foregroundColor(.blue) + } + + HStack { + Text("EasyTier") + Spacer() + Link("easytier.cn", destination: URL(string: "https://easytier.cn/")!) .foregroundColor(.blue) } diff --git a/Swiftier/SharedComponents.swift b/Spotier/SharedComponents.swift similarity index 100% rename from Swiftier/SharedComponents.swift rename to Spotier/SharedComponents.swift diff --git a/Swiftier/SparklineView.swift b/Spotier/SparklineView.swift similarity index 98% rename from Swiftier/SparklineView.swift rename to Spotier/SparklineView.swift index 66bd16a..754d717 100644 --- a/Swiftier/SparklineView.swift +++ b/Spotier/SparklineView.swift @@ -47,7 +47,7 @@ struct SparklineView: NSViewRepresentable { } deinit { // Safety cleanup just in case - // DispatchQueue.main.async { SwiftierRunner.shared.removeSubscriber() } + // DispatchQueue.main.async { SpotierRunner.shared.removeSubscriber() } } } } @@ -70,8 +70,8 @@ struct SmartSparklineView: View { var body: some View { SparklineView(data: data, color: color, maxScale: maxScale, paused: paused) - .onAppear { SwiftierRunner.shared.addSubscriber() } - .onDisappear { SwiftierRunner.shared.removeSubscriber() } + .onAppear { SpotierRunner.shared.addSubscriber() } + .onDisappear { SpotierRunner.shared.removeSubscriber() } } } @@ -341,7 +341,7 @@ final class SparklineNSView: NSView { let isFirstRealUpdate = (prevLastValue == nil) && !isLayoutPass if isFirstRealUpdate { - let lastTime = SwiftierRunner.shared.lastDataTime + let lastTime = SpotierRunner.shared.lastDataTime let now = Date() let elapsed = now.timeIntervalSince(lastTime) if elapsed >= 0 && elapsed < animationDuration { diff --git a/Swiftier/Swiftier.entitlements b/Spotier/Spotier.entitlements similarity index 89% rename from Swiftier/Swiftier.entitlements rename to Spotier/Spotier.entitlements index 0f1251a..ad57bfd 100644 --- a/Swiftier/Swiftier.entitlements +++ b/Spotier/Spotier.entitlements @@ -6,7 +6,7 @@ development com.apple.developer.icloud-container-identifiers - iCloud.com.alick.swiftier + iCloud.com.alick.spotier com.apple.developer.icloud-services @@ -19,7 +19,7 @@ com.apple.developer.ubiquity-container-identifiers - iCloud.com.alick.swiftier + iCloud.com.alick.spotier com.apple.developer.ubiquity-kvstore-identifier $(TeamIdentifierPrefix)$(CFBundleIdentifier) @@ -27,7 +27,7 @@ com.apple.security.application-groups - group.com.alick.swiftier + group.com.alick.spotier com.apple.security.files.user-selected.read-write diff --git a/Swiftier/Swiftier.icon/Assets/point.3.connected.trianglepath.dotted 2.png b/Spotier/Spotier.icon/Assets/point.3.connected.trianglepath.dotted 2.png similarity index 100% rename from Swiftier/Swiftier.icon/Assets/point.3.connected.trianglepath.dotted 2.png rename to Spotier/Spotier.icon/Assets/point.3.connected.trianglepath.dotted 2.png diff --git a/Swiftier/Swiftier.icon/icon.json b/Spotier/Spotier.icon/icon.json similarity index 100% rename from Swiftier/Swiftier.icon/icon.json rename to Spotier/Spotier.icon/icon.json diff --git a/Swiftier/SwiftierControlApp.swift b/Spotier/SpotierControlApp.swift similarity index 94% rename from Swiftier/SwiftierControlApp.swift rename to Spotier/SpotierControlApp.swift index 3892b92..0678311 100644 --- a/Swiftier/SwiftierControlApp.swift +++ b/Spotier/SpotierControlApp.swift @@ -4,9 +4,9 @@ import Combine import NetworkExtension @main -struct SwiftierControlApp: App { +struct SpotierControlApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - @StateObject private var runner = SwiftierRunner.shared + @StateObject private var runner = SpotierRunner.shared @StateObject private var iconState = MenuBarIconState.shared @AppStorage("breathEffect") private var breathEffect: Bool = true @@ -45,7 +45,7 @@ class MenuBarIconState: ObservableObject { private init() { // 优化:监听运行状态变化,按需启停 Timer - SwiftierRunner.shared.$isRunning + SpotierRunner.shared.$isRunning .receive(on: DispatchQueue.main) .sink { [weak self] isRunning in self?.handleRunningStateChange(isRunning: isRunning) @@ -67,7 +67,7 @@ class MenuBarIconState: ObservableObject { } private func updateTimerState() { - let isRunning = SwiftierRunner.shared.isRunning + let isRunning = SpotierRunner.shared.isRunning let blinkEnabled = (UserDefaults.standard.object(forKey: "breathEffect") as? Bool) ?? true if isRunning && blinkEnabled { @@ -141,7 +141,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { print("[Launch] VPN status: \(vpn.status.rawValue), isConnected: \(vpn.isConnected), onDemand: \(vpn.isOnDemandEnabled)") // 同步 Runner 的 UI 状态 - SwiftierRunner.shared.syncWithVPNState() + SpotierRunner.shared.syncWithVPNState() // 如果 NE 已经在运行,不做任何操作,避免 saveToPreferences 导致隧道重启 if vpn.isConnected || vpn.status == .connecting { @@ -159,7 +159,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { let configs = ConfigManager.shared.refreshConfigs() if let config = configs.first { print("[Launch] Triggering initial connect with: \(config.lastPathComponent)") - SwiftierRunner.shared.toggleService(configPath: config.path) + SpotierRunner.shared.toggleService(configPath: config.path) } } } diff --git a/Swiftier/SwiftierRunner.swift b/Spotier/SpotierRunner.swift similarity index 98% rename from Swiftier/SwiftierRunner.swift rename to Spotier/SpotierRunner.swift index aac95dc..b92af12 100644 --- a/Swiftier/SwiftierRunner.swift +++ b/Spotier/SpotierRunner.swift @@ -1,5 +1,5 @@ // -// SwiftierRunner.swift +// SpotierRunner.swift // Swiftier // // Created by Alick on 2024. @@ -11,8 +11,8 @@ import AppKit import SwiftUI import NetworkExtension -final class SwiftierRunner: ObservableObject { - static let shared = SwiftierRunner() +final class SpotierRunner: ObservableObject { + static let shared = SpotierRunner() @Published var isRunning = false @Published var peers: [PeerInfo] = [] @@ -217,7 +217,7 @@ final class SwiftierRunner: ObservableObject { func openLogFile() { // Logs for NE are different. They might be in the Console.app or a shared file. // If we implement file logging in PacketTunnelProvider to a shared container: - if let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.alick.swiftier") { + if let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.alick.spotier") { let logURL = containerURL.appendingPathComponent("easytier.log") if FileManager.default.fileExists(atPath: logURL.path) { NSWorkspace.shared.open(logURL) @@ -278,7 +278,7 @@ final class SwiftierRunner: ObservableObject { var totalTx = 0 var fetchedPeers: [PeerInfo] = [] - guard let status = try? jsonDecoder.decode(SwiftierStatus.self, from: data) else { return } + guard let status = try? jsonDecoder.decode(SpotierStatus.self, from: data) else { return } // 1. IP & Events LogParser.shared.updateEventsFromRunningInfo(status.events) diff --git a/Swiftier/VPNManager.swift b/Spotier/VPNManager.swift similarity index 97% rename from Swiftier/VPNManager.swift rename to Spotier/VPNManager.swift index fa8e200..2ec96c8 100644 --- a/Swiftier/VPNManager.swift +++ b/Spotier/VPNManager.swift @@ -76,12 +76,12 @@ class VPNManager: ObservableObject { print("VPNManager: Starting setupVPNProfile...") let manager = NETunnelProviderManager() - manager.localizedDescription = "Swiftier VPN" + manager.localizedDescription = "Spotier VPN" let protocolConfiguration = NETunnelProviderProtocol() - let extensionBundleID = "com.alick.swiftier.SwiftierNE" + let extensionBundleID = "com.alick.spotier.SpotierNE" protocolConfiguration.providerBundleIdentifier = extensionBundleID - protocolConfiguration.serverAddress = "Swiftier" + protocolConfiguration.serverAddress = "Spotier" manager.protocolConfiguration = protocolConfiguration manager.isEnabled = true @@ -163,7 +163,7 @@ class VPNManager: ObservableObject { } func saveConfigToAppGroup(configContent: String) -> URL? { - guard let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.alick.swiftier") else { + guard let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP_ID) else { print("Failed to get App Group container") return nil } diff --git a/SwiftierNE/AddressHelper.swift b/SpotierNE/AddressHelper.swift similarity index 100% rename from SwiftierNE/AddressHelper.swift rename to SpotierNE/AddressHelper.swift diff --git a/Swiftier/EasyTierShared.swift b/SpotierNE/EasyTierShared.swift old mode 100644 new mode 100755 similarity index 97% rename from Swiftier/EasyTierShared.swift rename to SpotierNE/EasyTierShared.swift index dc952ec..a366925 --- a/Swiftier/EasyTierShared.swift +++ b/SpotierNE/EasyTierShared.swift @@ -2,8 +2,8 @@ import NetworkExtension import os public let APP_BUNDLE_ID: String = "com.alick.swiftier" -public let APP_GROUP_ID: String = "group.com.alick.swiftier" -public let ICLOUD_CONTAINER_ID: String = "iCloud.com.alick.swiftier" +public let APP_GROUP_ID: String = "group.com.alick.spotier" +public let ICLOUD_CONTAINER_ID: String = "iCloud.com.alick.spotier" public let LOG_FILENAME: String = "easytier.log" public enum LogLevel: String, Codable, CaseIterable { diff --git a/SwiftierNE/Info.plist b/SpotierNE/Info.plist similarity index 100% rename from SwiftierNE/Info.plist rename to SpotierNE/Info.plist diff --git a/SwiftierNE/InfoModels.swift b/SpotierNE/InfoModels.swift similarity index 100% rename from SwiftierNE/InfoModels.swift rename to SpotierNE/InfoModels.swift diff --git a/SwiftierNE/Logger.swift b/SpotierNE/Logger.swift similarity index 100% rename from SwiftierNE/Logger.swift rename to SpotierNE/Logger.swift diff --git a/SwiftierNE/OSLogExporter.swift b/SpotierNE/OSLogExporter.swift similarity index 100% rename from SwiftierNE/OSLogExporter.swift rename to SpotierNE/OSLogExporter.swift diff --git a/SwiftierNE/PacketTunnelProvider.swift b/SpotierNE/PacketTunnelProvider.swift similarity index 100% rename from SwiftierNE/PacketTunnelProvider.swift rename to SpotierNE/PacketTunnelProvider.swift diff --git a/SwiftierNE/SwiftierNE-Bridging-Header.h b/SpotierNE/SpotierNE-Bridging-Header.h similarity index 100% rename from SwiftierNE/SwiftierNE-Bridging-Header.h rename to SpotierNE/SpotierNE-Bridging-Header.h diff --git a/SwiftierNE/SwiftierNE.entitlements b/SpotierNE/SpotierNE.entitlements similarity index 92% rename from SwiftierNE/SwiftierNE.entitlements rename to SpotierNE/SpotierNE.entitlements index 9a6ed63..9fcc07d 100644 --- a/SwiftierNE/SwiftierNE.entitlements +++ b/SpotierNE/SpotierNE.entitlements @@ -10,7 +10,7 @@ com.apple.security.application-groups - group.com.alick.swiftier + group.com.alick.spotier com.apple.security.network.client diff --git a/SwiftierNE/SwiftierCore.swift b/SpotierNE/SwiftierCore.swift similarity index 100% rename from SwiftierNE/SwiftierCore.swift rename to SpotierNE/SwiftierCore.swift diff --git a/SwiftierNE/TunnelHelper.swift b/SpotierNE/TunnelHelper.swift similarity index 100% rename from SwiftierNE/TunnelHelper.swift rename to SpotierNE/TunnelHelper.swift diff --git a/Swiftier.xcodeproj/xcuserdata/alick.xcuserdatad/xcschemes/xcschememanagement.plist b/Swiftier.xcodeproj/xcuserdata/alick.xcuserdatad/xcschemes/xcschememanagement.plist deleted file mode 100644 index 72d4b40..0000000 --- a/Swiftier.xcodeproj/xcuserdata/alick.xcuserdatad/xcschemes/xcschememanagement.plist +++ /dev/null @@ -1,52 +0,0 @@ - - - - - SchemeUserState - - EasyTier.xcscheme_^#shared#^_ - - orderHint - 1 - - EasyTierHelper.xcscheme_^#shared#^_ - - orderHint - 0 - - Swiftier.xcscheme_^#shared#^_ - - orderHint - 0 - - SwiftierHelper.xcscheme_^#shared#^_ - - orderHint - 1 - - SwiftierNE.xcscheme_^#shared#^_ - - orderHint - 1 - - - SuppressBuildableAutocreation - - E0933DCB2F16A25B00C7DE59 - - primary - - - E0933DD82F16A26200C7DE59 - - primary - - - E0933DE22F16A26200C7DE59 - - primary - - - - - From 820d23f0496dbd3a24120083d1653a1c31adf494 Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Sat, 21 Feb 2026 22:15:47 +0800 Subject: [PATCH 23/30] Refactor: Rename project to Spotier, update branding, and add TestFlight link to README --- .DS_Store | Bin 6148 -> 6148 bytes README.md | 90 ++++++++++++++++++------------ Spotier.xcodeproj/project.pbxproj | 16 ------ 3 files changed, 53 insertions(+), 53 deletions(-) diff --git a/.DS_Store b/.DS_Store index a87d9c5e56fbf3eea933f9c847a799d83e350f93..451578ec53b426eed4caaf75de1954d851a82f43 100644 GIT binary patch delta 19 acmZoMXffDe%*-_B<>Y*3jm@#l%S8Z2Fb2^8 delta 19 acmZoMXffDe%*?cS?c{uBjm@#l%S8Z0B?g=T diff --git a/README.md b/README.md index 856aa49..c125827 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,69 @@ -# Swiftier (EasyTier for macOS) +# Spotier (EasyTier for macOS)

- Swiftier Icon + Spotier Icon

- - + +

-[中文说明](#简介) | [English](#introduction) +[中文说明](#简介) | [English](#introduction) | [TestFlight Beta](https://testflight.apple.com/join/TDsVHaTx) --- + ## Introduction -**Swiftier** is a native, modern GUI wrapper for [EasyTier](https://github.com/EasyTier/EasyTier), designed to simplify decentralized mesh networking on your Mac. Built entirely with **SwiftUI**, it offers a clean, responsive, and powerful interface for managing your virtual network. +**Spotier** is a native, modern GUI wrapper for [EasyTier](https://github.com/EasyTier/EasyTier), designed to simplify decentralized mesh networking on your Mac. Built entirely with **SwiftUI**, it offers a clean, responsive, and powerful interface for managing your virtual network. > [!IMPORTANT] > This project was entirely generated using **Antigravity** vibe coding. +### Get the Beta + +Join our public beta via TestFlight: +👉 **[Join Spotier TestFlight Beta](https://testflight.apple.com/join/TDsVHaTx)** + ### Key Features -* ✨ **Native macOS Experience**: Designed with modern SwiftUI components, dark mode support, and smooth animations following the latest macOS guidelines. -* 🤖 **Background Service**: Utilizes a privileged Helper tool to run the VPN core in the background, ensuring your connection stays alive even when the main app is closed. -* 🛠 **Visual Configuration**: A comprehensive editor to generate and modify EasyTier configurations without touching text files. -* 📊 **Real-time Monitoring**: Visualize peer connections, latency, and traffic statistics instantly with a beautiful UI. -* 📝 **Activity Timeline**: A dual-mode log viewer that separates high-level "Interaction Events" (peer join/leave) from low-level debugging logs. -* 📦 **Auto Core Management**: Automatically detects, downloads, and manages the correct `easytier-core` binary for your system architecture. +- ✨ **Native macOS Experience**: Designed with modern SwiftUI components, dark mode support, and smooth animations following the latest macOS guidelines. +- 🤖 **Background Service**: Utilizes a privileged Helper tool to run the VPN core in the background, ensuring your connection stays alive even when the main app is closed. +- 🛠 **Visual Configuration**: A comprehensive editor to generate and modify EasyTier configurations without touching text files. +- 📊 **Real-time Monitoring**: Visualize peer connections, latency, and traffic statistics instantly with a beautiful UI. +- 📝 **Activity Timeline**: A dual-mode log viewer that separates high-level "Interaction Events" (peer join/leave) from low-level debugging logs. +- 📦 **Auto Core Management**: Automatically detects, downloads, and manages the correct `easytier-core` binary for your system architecture. ### Troubleshooting -> ⚠️ **"Swiftier is damaged and can't be opened"** +> ⚠️ **"Spotier is damaged and can't be opened"** > > Since this app is not notarized by Apple (requires a paid developer account), you may see a warning that the app is damaged. To fix this, run the following command in Terminal: +> > ```bash -> sudo xattr -cr /Applications/Swiftier.app +> sudo xattr -cr /Applications/Spotier.app > ``` -> *(Adjust the path if your app is not in the Applications folder)* +> +> _(Adjust the path if your app is not in the Applications folder)_ ### Requirements -* macOS 13.0 or later -* Xcode 15+ (for building) +- macOS 13.0 or later +- Xcode 15+ (for building) ### Building from Source 1. Clone this repository. -2. Open `Swiftier.xcodeproj` in Xcode. +2. Open `Spotier.xcodeproj` in Xcode. 3. Select your Development Team in the "Signing & Capabilities" tab. 4. Build and Run (`Cmd + R`). ### Contact & Developer -* **Developer**: Alick Huang -* **Email**: [minamike2007@gmail.com](mailto:minamike2007@gmail.com) -* **GitHub**: [AlickH/Swiftier](https://github.com/AlickH/Swiftier) +- **Developer**: Alick Huang +- **Email**: [minamike2007@gmail.com](mailto:minamike2007@gmail.com) +- **GitHub**: [AlickH/Spotier](https://github.com/AlickH/Spotier) ### License @@ -63,54 +71,62 @@ Distributed under the **MIT License**. --- -[中文说明](#简介) | [English](#introduction) +[中文说明](#简介) | [English](#introduction) | [TestFlight Beta](https://testflight.apple.com/join/TDsVHaTx) --- + ## 简介 -**Swiftier** 是专为 macOS 打造的原生 [EasyTier](https://github.com/EasyTier/EasyTier) 图形客户端。它采用最新的 **SwiftUI** 技术构建,为您提供简单、美观且强大的去中心化组网管理体验。 +**Spotier** 是专为 macOS 打造的原生 [EasyTier](https://github.com/EasyTier/EasyTier) 图形客户端。它采用最新的 **SwiftUI** 技术构建,为您提供简单、美观且强大的去中心化组网管理体验。 > [!IMPORTANT] > 本项目完全采用 **Antigravity** vibe coding 模式开发生成。 +### 参与测试 + +点击下方链接加入我们的 TestFlight 公开测试: +👉 **[加入 Spotier TestFlight 测试版](https://testflight.apple.com/join/TDsVHaTx)** + ### 主要功能 -* ✨ **原生体验**:遵循 macOS 最新设计规范,原生支持深色模式,拥有流畅的动画和细腻的交互。 -* 🤖 **后台服务**:内置特权辅助程序(Helper),支持将 VPN 核心作为系统服务在后台运行,主界面关闭后网络依然保持连通。 -* 🛠 **可视化配置**:提供完整的图形化配置编辑器,无需手动编辑 `.toml` 配置文件即可完成所有设置。 -* 📊 **实时监控**:直观展示节点列表、P2P 连接状态、延迟和实时流量统计,并配有可视化拓朴指示。 -* 📝 **活动时间轴**:独创的双模式日志视图,通过“交互事件”时间轴清晰展示节点加入、断开等关键动态,同时保留详细的调试日志。 -* 📦 **核心管理**:自动检测系统架构(Intel/Apple Silicon)并自动下载管理 `easytier-core` 内核,真正实现开箱即用。 +- ✨ **原生体验**:遵循 macOS 最新设计规范,原生支持深色模式,拥有流畅的动画和细腻的交互。 +- 🤖 **后台服务**:内置特权辅助程序(Helper),支持将 VPN 核心作为系统服务在后台运行,主界面关闭后网络依然保持连通。 +- 🛠 **可视化配置**:提供完整的图形化配置编辑器,无需手动编辑 `.toml` 配置文件即可完成所有设置。 +- 📊 **实时监控**:直观展示节点列表、P2P 连接状态、延迟和实时流量统计,并配有可视化拓朴指示。 +- 📝 **活动时间轴**:独创的双模式日志视图,通过“交互事件”时间轴清晰展示节点加入、断开等关键动态,同时保留详细的调试日志。 +- 📦 **核心管理**:自动检测系统架构(Intel/Apple Silicon)并自动下载管理 `easytier-core` 内核,真正实现开箱即用。 ### 常见问题 > ⚠️ **打开时提示“应用已损坏,无法打开”** > > 这是 macOS 安全机制对未签名应用的拦截。由于没有付费开发者账号进行公证,您需要在终端运行以下命令来解除限制: +> > ```bash -> sudo xattr -cr /Applications/Swiftier.app +> sudo xattr -cr /Applications/Spotier.app > ``` -> *(如果应用不在“应用程序”目录,请修改为实际路径)* +> +> _(如果应用不在“应用程序”目录,请修改为实际路径)_ ### 运行环境 -* macOS 13.0 或更高版本 -* 编译需要 Xcode 15+ +- macOS 13.0 或更高版本 +- 编译需要 Xcode 15+ ### 编译指南 1. 克隆本项目到本地。 -2. 使用 Xcode 打开 `Swiftier.xcodeproj`。 +2. 使用 Xcode 打开 `Spotier.xcodeproj`。 3. 在 Project 设置的 `Signing & Capabilities` 中选择您的 Apple Developer 账号并配置签名。 4. 运行项目 (`Cmd + R`)。 ### 联系与开发者 -* **开发者**: Alick Huang -* **电子邮箱**: [minamike2007@gmail.com](mailto:minamike2007@gmail.com) -* **GitHub**: [AlickH/Swiftier](https://github.com/AlickH/Swiftier) +- **Developer**: Alick Huang +- **电子邮箱**: [minamike2007@gmail.com](mailto:minamike2007@gmail.com) +- **GitHub**: [AlickH/Spotier](https://github.com/AlickH/Spotier) ### 开源协议 diff --git a/Spotier.xcodeproj/project.pbxproj b/Spotier.xcodeproj/project.pbxproj index c9c7139..cf1613d 100644 --- a/Spotier.xcodeproj/project.pbxproj +++ b/Spotier.xcodeproj/project.pbxproj @@ -439,10 +439,6 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = KLU8GF65GP; ENABLE_APP_SANDBOX = YES; - ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; - ENABLE_FILE_ACCESS_MOVIES_FOLDER = readwrite; - ENABLE_FILE_ACCESS_MUSIC_FOLDER = readwrite; - ENABLE_FILE_ACCESS_PICTURE_FOLDER = readwrite; ENABLE_HARDENED_RUNTIME = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; @@ -505,10 +501,6 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = KLU8GF65GP; ENABLE_APP_SANDBOX = YES; - ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; - ENABLE_FILE_ACCESS_MOVIES_FOLDER = readwrite; - ENABLE_FILE_ACCESS_MUSIC_FOLDER = readwrite; - ENABLE_FILE_ACCESS_PICTURE_FOLDER = readwrite; ENABLE_HARDENED_RUNTIME = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; @@ -565,10 +557,6 @@ CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = KLU8GF65GP; ENABLE_APP_SANDBOX = YES; - ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; - ENABLE_FILE_ACCESS_MOVIES_FOLDER = readwrite; - ENABLE_FILE_ACCESS_MUSIC_FOLDER = readwrite; - ENABLE_FILE_ACCESS_PICTURE_FOLDER = readwrite; ENABLE_HARDENED_RUNTIME = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; @@ -628,10 +616,6 @@ CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = KLU8GF65GP; ENABLE_APP_SANDBOX = YES; - ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; - ENABLE_FILE_ACCESS_MOVIES_FOLDER = readwrite; - ENABLE_FILE_ACCESS_MUSIC_FOLDER = readwrite; - ENABLE_FILE_ACCESS_PICTURE_FOLDER = readwrite; ENABLE_HARDENED_RUNTIME = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; From 66ace4eb98525b568bd5eb396c3f983c36f5e49d Mon Sep 17 00:00:00 2001 From: "A.Lick" Date: Sat, 21 Feb 2026 22:32:11 +0800 Subject: [PATCH 24/30] Update README.md --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c125827..f693b14 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # Spotier (EasyTier for macOS)

- Spotier Icon + Spotier Icon

- - + +

[中文说明](#简介) | [English](#introduction) | [TestFlight Beta](https://testflight.apple.com/join/TDsVHaTx) From 224fe9650aad6195a98adca216c6d9c2ae4ce66b Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Fri, 13 Mar 2026 00:55:12 +0800 Subject: [PATCH 25/30] Fix VPN startup, CloudKit sync, and IPv6/MagicDNS handling --- Spotier.xcodeproj/project.pbxproj | 125 +++++- .../xcshareddata/xcschemes/Spotier.xcscheme | 17 +- Spotier/CloudKitConfigSync.swift | 207 ++++++++++ Spotier/ConfigManager.swift | 356 ++++++++++-------- Spotier/ContentView.swift | 12 +- Spotier/Localizable.xcstrings | 3 + Spotier/VPNManager.swift | 70 +++- SpotierNE/AddressHelper.swift | 56 +++ SpotierNE/InfoModels.swift | 50 +++ SpotierNE/PacketTunnelProvider.swift | 178 ++++++--- SpotierNETests/AddressHelper.swift | 1 + SpotierNETests/InfoModelIPv6Tests.swift | 19 + SpotierNETests/InfoModels.swift | 1 + 13 files changed, 835 insertions(+), 260 deletions(-) create mode 100644 Spotier/CloudKitConfigSync.swift create mode 120000 SpotierNETests/AddressHelper.swift create mode 100644 SpotierNETests/InfoModelIPv6Tests.swift create mode 120000 SpotierNETests/InfoModels.swift diff --git a/Spotier.xcodeproj/project.pbxproj b/Spotier.xcodeproj/project.pbxproj index cf1613d..9f182d4 100644 --- a/Spotier.xcodeproj/project.pbxproj +++ b/Spotier.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ E0AB5CCB2F3CCD9C00BD8CBA /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E0AB5CCA2F3CCD9C00BD8CBA /* NetworkExtension.framework */; }; E0AB5CD32F3CCD9C00BD8CBA /* SpotierNE.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E0AB5CC92F3CCD9C00BD8CBA /* SpotierNE.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; E0AC7EEE2F19307C00FC425C /* ServiceManagement.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E0AC7EED2F19307C00FC425C /* ServiceManagement.framework */; }; + E1AA00012F50000100AAA001 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E1AA00042F50000100AAA001 /* XCTest.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -20,6 +21,13 @@ remoteGlobalIDString = E0AB5CC82F3CCD9C00BD8CBA; remoteInfo = SwiftierNE; }; + E1AA00022F50000100AAA001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E0933DC42F16A25B00C7DE59 /* Project object */; + proxyType = 1; + remoteGlobalIDString = E0AB5CC82F3CCD9C00BD8CBA; + remoteInfo = SpotierNE; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -60,6 +68,8 @@ E0AB5CC92F3CCD9C00BD8CBA /* SpotierNE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SpotierNE.appex; sourceTree = BUILT_PRODUCTS_DIR; }; E0AB5CCA2F3CCD9C00BD8CBA /* NetworkExtension.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NetworkExtension.framework; path = System/Library/Frameworks/NetworkExtension.framework; sourceTree = SDKROOT; }; E0AC7EED2F19307C00FC425C /* ServiceManagement.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ServiceManagement.framework; path = System/Library/Frameworks/ServiceManagement.framework; sourceTree = SDKROOT; }; + E1AA00032F50000100AAA001 /* SpotierNETests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SpotierNETests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + E1AA00042F50000100AAA001 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = System/Library/Frameworks/XCTest.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -96,6 +106,11 @@ path = SpotierNE; sourceTree = ""; }; + E1AA00052F50000100AAA001 /* SpotierNETests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = SpotierNETests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -115,6 +130,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E1AA00062F50000100AAA001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E1AA00012F50000100AAA001 /* XCTest.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -123,6 +146,7 @@ children = ( E0933DCE2F16A25B00C7DE59 /* Spotier */, E0AB5CCC2F3CCD9C00BD8CBA /* SpotierNE */, + E1AA00052F50000100AAA001 /* SpotierNETests */, E0AC7EEC2F19307C00FC425C /* Frameworks */, E0933DCD2F16A25B00C7DE59 /* Products */, ); @@ -133,6 +157,7 @@ children = ( E0933DCC2F16A25B00C7DE59 /* Spotier.app */, E0AB5CC92F3CCD9C00BD8CBA /* SpotierNE.appex */, + E1AA00032F50000100AAA001 /* SpotierNETests.xctest */, ); name = Products; sourceTree = ""; @@ -142,6 +167,7 @@ children = ( E0AC7EED2F19307C00FC425C /* ServiceManagement.framework */, E0AB5CCA2F3CCD9C00BD8CBA /* NetworkExtension.framework */, + E1AA00042F50000100AAA001 /* XCTest.framework */, ); name = Frameworks; sourceTree = ""; @@ -197,6 +223,29 @@ productReference = E0AB5CC92F3CCD9C00BD8CBA /* SpotierNE.appex */; productType = "com.apple.product-type.app-extension"; }; + E1AA00072F50000100AAA001 /* SpotierNETests */ = { + isa = PBXNativeTarget; + buildConfigurationList = E1AA000D2F50000100AAA001 /* Build configuration list for PBXNativeTarget "SpotierNETests" */; + buildPhases = ( + E1AA00092F50000100AAA001 /* Sources */, + E1AA00062F50000100AAA001 /* Frameworks */, + E1AA00082F50000100AAA001 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + E1AA000A2F50000100AAA001 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + E1AA00052F50000100AAA001 /* SpotierNETests */, + ); + name = SpotierNETests; + packageProductDependencies = ( + ); + productName = SpotierNETests; + productReference = E1AA00032F50000100AAA001 /* SpotierNETests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -235,6 +284,9 @@ }; }; }; + E1AA00072F50000100AAA001 = { + CreatedOnToolsVersion = 26.3; + }; }; }; buildConfigurationList = E0933DC72F16A25B00C7DE59 /* Build configuration list for PBXProject "Spotier" */; @@ -253,6 +305,7 @@ targets = ( E0933DCB2F16A25B00C7DE59 /* Spotier */, E0AB5CC82F3CCD9C00BD8CBA /* SpotierNE */, + E1AA00072F50000100AAA001 /* SpotierNETests */, ); }; /* End PBXProject section */ @@ -272,6 +325,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E1AA00082F50000100AAA001 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -289,6 +349,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E1AA00092F50000100AAA001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -297,6 +364,11 @@ target = E0AB5CC82F3CCD9C00BD8CBA /* SpotierNE */; targetProxy = E0AB5CD12F3CCD9C00BD8CBA /* PBXContainerItemProxy */; }; + E1AA000A2F50000100AAA001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E0AB5CC82F3CCD9C00BD8CBA /* SpotierNE */; + targetProxy = E1AA00022F50000100AAA001 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -435,7 +507,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 4; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = KLU8GF65GP; ENABLE_APP_SANDBOX = YES; @@ -497,7 +569,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 4; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = KLU8GF65GP; ENABLE_APP_SANDBOX = YES; @@ -554,7 +626,7 @@ CODE_SIGN_ENTITLEMENTS = SpotierNE/SpotierNE.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = KLU8GF65GP; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -580,6 +652,7 @@ "@executable_path/../../../../Frameworks", ); LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/EasyTierCore/target/release"; + MACOSX_DEPLOYMENT_TARGET = 13.5; MARKETING_VERSION = 1.0.0; "OBJC_BRIDGING_HEADER[sdk=macosx*]" = "SpotierNE/SpotierNE-Bridging-Header.h"; OTHER_LDFLAGS = ( @@ -613,7 +686,7 @@ CODE_SIGN_ENTITLEMENTS = SpotierNE/SpotierNE.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = KLU8GF65GP; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -639,6 +712,7 @@ "@executable_path/../../../../Frameworks", ); LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/EasyTierCore/target/release"; + MACOSX_DEPLOYMENT_TARGET = 13.5; MARKETING_VERSION = 1.0.0; OTHER_LDFLAGS = ( "-leasytier_ios", @@ -665,6 +739,40 @@ }; name = Release; }; + E1AA000B2F50000100AAA001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = ""; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = KLU8GF65GP; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + PRODUCT_BUNDLE_IDENTIFIER = com.alick.spotier.SpotierNETests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = SpotierNE; + }; + name = Debug; + }; + E1AA000C2F50000100AAA001 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = ""; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = KLU8GF65GP; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + PRODUCT_BUNDLE_IDENTIFIER = com.alick.spotier.SpotierNETests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = SpotierNE; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -695,6 +803,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + E1AA000D2F50000100AAA001 /* Build configuration list for PBXNativeTarget "SpotierNETests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E1AA000B2F50000100AAA001 /* Debug */, + E1AA000C2F50000100AAA001 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = E0933DC42F16A25B00C7DE59 /* Project object */; diff --git a/Spotier.xcodeproj/xcshareddata/xcschemes/Spotier.xcscheme b/Spotier.xcodeproj/xcshareddata/xcschemes/Spotier.xcscheme index 33d714a..bc2fa2a 100644 --- a/Spotier.xcodeproj/xcshareddata/xcschemes/Spotier.xcscheme +++ b/Spotier.xcodeproj/xcshareddata/xcschemes/Spotier.xcscheme @@ -35,20 +35,9 @@ parallelizable = "YES"> - - - - diff --git a/Spotier/CloudKitConfigSync.swift b/Spotier/CloudKitConfigSync.swift new file mode 100644 index 0000000..c677296 --- /dev/null +++ b/Spotier/CloudKitConfigSync.swift @@ -0,0 +1,207 @@ +import CloudKit +import Foundation + +struct CloudConfigFile { + let name: String + let content: String + let updatedAt: Date +} + +actor CloudKitConfigSync { + static let shared = CloudKitConfigSync() + + private let database = CKContainer.default().privateCloudDatabase + private let recordType = "SpotierConfig" + private let nameKey = "name" + private let contentKey = "content" + private let updatedAtKey = "updated_at" + + func sync(localDirectory: URL) async throws -> Bool { + let remoteFiles = try await fetchRemoteConfigs() + let localFiles = try loadLocalConfigs(from: localDirectory) + + var localChanged = false + + for (name, remote) in remoteFiles { + guard let local = localFiles[name] else { + try writeLocalConfig(remote, to: localDirectory) + localChanged = true + continue + } + + if shouldOverwriteLocal(remote: remote, local: local) { + try writeLocalConfig(remote, to: localDirectory) + localChanged = true + } + } + + for (name, local) in localFiles { + guard let remote = remoteFiles[name] else { + try await upsertRemoteConfig(local) + continue + } + + if shouldOverwriteRemote(local: local, remote: remote) { + try await upsertRemoteConfig(local) + } + } + + return localChanged + } + + func deleteConfig(named fileName: String) async throws { + let recordID = CKRecord.ID(recordName: recordName(for: fileName)) + do { + _ = try await deleteRecord(with: recordID) + } catch { + let ckError = error as? CKError + if ckError?.code != .unknownItem { + throw error + } + } + } + + private func fetchRemoteConfigs() async throws -> [String: CloudConfigFile] { + let query = CKQuery(recordType: recordType, predicate: NSPredicate(value: true)) + let records = try await perform(query: query) + + var files: [String: CloudConfigFile] = [:] + for record in records { + guard let name = record[nameKey] as? String, + let content = record[contentKey] as? String else { + continue + } + + let updatedAt = (record[updatedAtKey] as? Date) + ?? record.modificationDate + ?? Date.distantPast + + files[name] = CloudConfigFile(name: name, content: content, updatedAt: updatedAt) + } + return files + } + + private func loadLocalConfigs(from directory: URL) throws -> [String: CloudConfigFile] { + if !FileManager.default.fileExists(atPath: directory.path) { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + let items = try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) + + var files: [String: CloudConfigFile] = [:] + for url in items where url.pathExtension.lowercased() == "toml" { + let values = try url.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey]) + guard values.isRegularFile == true else { continue } + + let content = try String(contentsOf: url, encoding: .utf8) + let modifiedAt = values.contentModificationDate ?? Date.distantPast + files[url.lastPathComponent] = CloudConfigFile( + name: url.lastPathComponent, + content: content, + updatedAt: modifiedAt + ) + } + return files + } + + private func writeLocalConfig(_ file: CloudConfigFile, to directory: URL) throws { + let url = directory.appendingPathComponent(file.name) + try file.content.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.modificationDate: file.updatedAt], ofItemAtPath: url.path) + } + + private func upsertRemoteConfig(_ file: CloudConfigFile) async throws { + let recordID = CKRecord.ID(recordName: recordName(for: file.name)) + let record = CKRecord(recordType: recordType, recordID: recordID) + record[nameKey] = file.name as CKRecordValue + record[contentKey] = file.content as CKRecordValue + record[updatedAtKey] = file.updatedAt as CKRecordValue + _ = try await save(record: record) + } + + private func shouldOverwriteLocal(remote: CloudConfigFile, local: CloudConfigFile) -> Bool { + if remote.content == local.content { + return false + } + return remote.updatedAt.timeIntervalSince(local.updatedAt) > 1.0 + } + + private func shouldOverwriteRemote(local: CloudConfigFile, remote: CloudConfigFile) -> Bool { + if local.content == remote.content { + return false + } + return local.updatedAt.timeIntervalSince(remote.updatedAt) > 1.0 + } + + private func recordName(for fileName: String) -> String { + "config_\(fileName)" + } + + private func perform(query: CKQuery) async throws -> [CKRecord] { + var allRecords: [CKRecord] = [] + let desiredKeys = [nameKey, contentKey, updatedAtKey] + + var page = try await database.records( + matching: query, + inZoneWith: nil, + desiredKeys: desiredKeys, + resultsLimit: CKQueryOperation.maximumResults + ) + try appendMatchedRecords(page.matchResults, into: &allRecords) + + var cursor = page.queryCursor + while let currentCursor = cursor { + page = try await database.records( + continuingMatchFrom: currentCursor, + desiredKeys: desiredKeys, + resultsLimit: CKQueryOperation.maximumResults + ) + try appendMatchedRecords(page.matchResults, into: &allRecords) + cursor = page.queryCursor + } + + return allRecords + } + + private func appendMatchedRecords( + _ matchResults: [(CKRecord.ID, Result)], + into records: inout [CKRecord] + ) throws { + for (_, result) in matchResults { + switch result { + case .success(let record): + records.append(record) + case .failure(let error): + throw error + } + } + } + + private func save(record: CKRecord) async throws -> CKRecord { + try await withCheckedThrowingContinuation { continuation in + database.save(record) { saved, error in + if let error { + continuation.resume(throwing: error) + return + } + continuation.resume(returning: saved ?? record) + } + } + } + + private func deleteRecord(with recordID: CKRecord.ID) async throws -> CKRecord.ID { + try await withCheckedThrowingContinuation { continuation in + database.delete(withRecordID: recordID) { deletedID, error in + if let error { + continuation.resume(throwing: error) + return + } + continuation.resume(returning: deletedID ?? recordID) + } + } + } +} diff --git a/Spotier/ConfigManager.swift b/Spotier/ConfigManager.swift index 4dac87f..6b4e475 100644 --- a/Spotier/ConfigManager.swift +++ b/Spotier/ConfigManager.swift @@ -9,52 +9,16 @@ class ConfigManager: ObservableObject { @Published var configFiles: [URL] = [] @AppStorage("custom_config_path") var customPathString: String = "" @AppStorage("custom_config_bookmark") var customPathBookmark: Data? + @AppStorage("cloudkit_sync_enabled") var cloudKitSyncEnabled: Bool = false + private var isCloudSyncInProgress = false var currentDirectory: URL? { - // 1. 优先尝试从书签恢复(支持沙盒访问) - if let bookmark = customPathBookmark { - var isStale = false - do { - let url = try URL(resolvingBookmarkData: bookmark, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale) - - if isStale { - // Update stale bookmark if needed - if let newBookmark = try? url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil) { - DispatchQueue.main.async { self.customPathBookmark = newBookmark } - } - } - return url - } catch { - print("解析书签失败: \(error)") - // 书签失效,清除 - DispatchQueue.main.async { self.customPathBookmark = nil } - } - } - - // 2. 尝试使用路径字符串(非沙盒或已授权路径) - if !customPathString.isEmpty { - return URL(fileURLWithPath: customPathString) + // 开启 CloudKit 后,强制只使用默认目录作为同步源 + if cloudKitSyncEnabled { + return defaultLocalDirectory() } - - // 3. 自动探测 iCloud 路径作为默认值 - if let drive = iCloudDriveURL { - let targetDir = drive - if !FileManager.default.fileExists(atPath: targetDir.path) { - try? FileManager.default.createDirectory(at: targetDir, withIntermediateDirectories: true) - } - return targetDir - } - - // 4. Fallback to local Application Support - if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { - let targetDir = appSupport.appendingPathComponent("Spotier") - if !FileManager.default.fileExists(atPath: targetDir.path) { - try? FileManager.default.createDirectory(at: targetDir, withIntermediateDirectories: true) - } - return targetDir - } - - return nil + + return directoryFromUserPreference() } private init() { @@ -72,30 +36,27 @@ class ConfigManager: ObservableObject { } } - // 首次运行或未设置路径时,自动尝试初始化 iCloud + // 首次运行或未设置路径时,使用本地 Application Support 默认目录 if customPathString.isEmpty { - if let drive = iCloudDriveURL { - let targetDir = drive - if !FileManager.default.fileExists(atPath: targetDir.path) { - try? FileManager.default.createDirectory(at: targetDir, withIntermediateDirectories: true) - } - // 自动将 iCloud 路径设为默认路径 + if let targetDir = defaultLocalDirectory() { self.customPathString = targetDir.path - } else { - // Fallback to local Application Support - if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { - let targetDir = appSupport.appendingPathComponent("Spotier") - if !FileManager.default.fileExists(atPath: targetDir.path) { - try? FileManager.default.createDirectory(at: targetDir, withIntermediateDirectories: true) - } - self.customPathString = targetDir.path - } } } + + // 开启 CloudKit 后,强制回到默认目录 + if cloudKitSyncEnabled, let targetDir = defaultLocalDirectory() { + self.customPathBookmark = nil + self.customPathString = targetDir.path + } refreshConfigs() } func selectCustomFolder() { + if cloudKitSyncEnabled { + print("CloudKit 同步已启用,已锁定默认目录。") + return + } + let panel = NSOpenPanel() panel.canChooseFiles = false panel.canChooseDirectories = true @@ -115,28 +76,7 @@ class ConfigManager: ObservableObject { } func openiCloudFolder() { - var targetURL: URL? - - // 优先从书签恢复带权限的 URL - if let bookmark = customPathBookmark { - var isStale = false - if let url = try? URL(resolvingBookmarkData: bookmark, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale) { - _ = url.startAccessingSecurityScopedResource() - targetURL = url - } - } - - // Fallback: iCloud 容器路径 - if targetURL == nil, let drive = iCloudDriveURL { - targetURL = drive - } - - // 最终 fallback - if targetURL == nil { - targetURL = currentDirectory - } - - guard let url = targetURL else { return } + guard let url = currentDirectory else { return } // 使用 selectFile 在 Finder 中显示目录,避免沙盒权限问题 NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: url.path) } @@ -145,98 +85,26 @@ class ConfigManager: ObservableObject { NSWorkspace.shared.open(url) } - // 尝试获取 iCloud Drive 路径 - private var iCloudDriveURL: URL? { - // Debug: Check ubiquity identity - if FileManager.default.ubiquityIdentityToken == nil { - print("ConfigManager: Ubiquity Identity Token is nil. User might not be logged in or iCloud is disabled for this app.") - } - - // 尝试标准路径 (适用于带有 iCloud 权限的 App) - if let url = FileManager.default.url(forUbiquityContainerIdentifier: nil)?.appendingPathComponent("Documents") { - return url - } - - // Removed hardcoded fallback to Mobile Documents as it violates sandbox and causes crashes. - return nil + private var isICloudEnabled: Bool { + FileManager.default.ubiquityIdentityToken != nil } func migrateToiCloud() { - guard let drive = iCloudDriveURL else { - // TODO: 可以添加回调通知 UI 显示错误,这里暂时打印 - print("未找到 iCloud Drive,请确保已登录 iCloud。") + guard isICloudEnabled else { + print("未检测到 iCloud 账户,无法启用 CloudKit 同步。") return } - - let targetDir = drive - let oldDir = drive.appendingPathComponent("EasyTier") - - do { - // 0a. 自动迁移旧 EasyTier 文件夹内容 - if FileManager.default.fileExists(atPath: oldDir.path) { - let oldItems = try? FileManager.default.contentsOfDirectory(at: oldDir, includingPropertiesForKeys: nil) - if let oldItems = oldItems { - for item in oldItems { - let dest = targetDir.appendingPathComponent(item.lastPathComponent) - if !FileManager.default.fileExists(atPath: dest.path) { - try FileManager.default.moveItem(at: item, to: dest) - } - } - try? FileManager.default.removeItem(at: oldDir) - print("已迁移 EasyTier 内容到跟目录") - } - } - // 0b. 自动迁移错误的 nested Spotier 文件夹 (修复之前的 Bug) - let nestedSpotierDir = drive.appendingPathComponent("Spotier") - if FileManager.default.fileExists(atPath: nestedSpotierDir.path) { - let nestedItems = try? FileManager.default.contentsOfDirectory(at: nestedSpotierDir, includingPropertiesForKeys: nil) - if let nestedItems = nestedItems { - for item in nestedItems { - let dest = targetDir.appendingPathComponent(item.lastPathComponent) - if !FileManager.default.fileExists(atPath: dest.path) { - try FileManager.default.moveItem(at: item, to: dest) - } - } - try? FileManager.default.removeItem(at: nestedSpotierDir) - print("已修复嵌套的 Spotier 文件夹") - } - } - - // 1. 创建目标目录 - if !FileManager.default.fileExists(atPath: targetDir.path) { - try FileManager.default.createDirectory(at: targetDir, withIntermediateDirectories: true) - } - - // 2. 复制当前配置文件 - if let currentDir = currentDirectory { - let items = try FileManager.default.contentsOfDirectory(at: currentDir, includingPropertiesForKeys: nil) - let configs = items.filter { $0.pathExtension == "toml" } - - for file in configs { - let destUrl = targetDir.appendingPathComponent(file.lastPathComponent) - if !FileManager.default.fileExists(atPath: destUrl.path) { - try FileManager.default.copyItem(at: file, to: destUrl) - } - } - } - - // 3. 切换目录并刷新(清除旧书签,iCloud 容器路径 App 自身有权限) - DispatchQueue.main.async { - self.customPathBookmark = nil - self.customPathString = targetDir.path - self.refreshConfigs() - // 打开 Finder 确认 - NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: targetDir.path) - } - - } catch { - print("迁移到 iCloud 失败: \(error)") - } + enableCloudKitSync() } @discardableResult func refreshConfigs() -> [URL] { + refreshConfigs(skipCloudSync: false) + } + + @discardableResult + private func refreshConfigs(skipCloudSync: Bool) -> [URL] { guard let url = currentDirectory else { DispatchQueue.main.async { self.configFiles = [] } return [] @@ -258,6 +126,11 @@ class ConfigManager: ObservableObject { DispatchQueue.main.async { self.configFiles = tomlFiles } + + if !skipCloudSync { + triggerCloudSyncIfNeeded(force: false) + } + return tomlFiles } catch { print("读取配置文件列表失败: \(error) 路径: \(url.path)") @@ -281,4 +154,161 @@ class ConfigManager: ObservableObject { // 普通路径直接读取 return try String(contentsOf: fileURL, encoding: .utf8) } + + func deleteConfig(_ fileURL: URL) { + do { + try FileManager.default.removeItem(at: fileURL) + } catch { + print("删除配置失败: \(error)") + } + + if cloudKitSyncEnabled && isICloudEnabled { + let fileName = fileURL.lastPathComponent + Task.detached { + do { + try await CloudKitConfigSync.shared.deleteConfig(named: fileName) + } catch { + print("CloudKit 删除配置失败: \(error)") + } + } + } + + refreshConfigs() + } + + private func defaultLocalDirectory() -> URL? { + guard let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return nil + } + + let targetDir = appSupport + .appendingPathComponent("Spotier", isDirectory: true) + .appendingPathComponent("Configs", isDirectory: true) + + if !FileManager.default.fileExists(atPath: targetDir.path) { + try? FileManager.default.createDirectory(at: targetDir, withIntermediateDirectories: true) + } + + return targetDir + } + + private func triggerCloudSyncIfNeeded(force: Bool) { + guard cloudKitSyncEnabled else { return } + guard isICloudEnabled else { return } + guard !isCloudSyncInProgress || force else { return } + guard let localDir = currentDirectory else { return } + + isCloudSyncInProgress = true + + Task.detached { [weak self] in + guard let self else { return } + + do { + let localChanged = try await CloudKitConfigSync.shared.sync(localDirectory: localDir) + DispatchQueue.main.async { + self.isCloudSyncInProgress = false + if localChanged { + _ = self.refreshConfigs(skipCloudSync: true) + } + } + } catch { + DispatchQueue.main.async { + self.isCloudSyncInProgress = false + print("CloudKit 同步失败: \(error)") + } + } + } + } + + private func directoryFromUserPreference() -> URL? { + // 1. 优先尝试从书签恢复(支持沙盒访问) + if let bookmark = customPathBookmark { + var isStale = false + do { + let url = try URL( + resolvingBookmarkData: bookmark, + options: .withSecurityScope, + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) + + if isStale { + if let newBookmark = try? url.bookmarkData( + options: .withSecurityScope, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) { + DispatchQueue.main.async { self.customPathBookmark = newBookmark } + } + } + return url + } catch { + print("解析书签失败: \(error)") + DispatchQueue.main.async { self.customPathBookmark = nil } + } + } + + // 2. 尝试使用路径字符串(非沙盒或已授权路径) + if !customPathString.isEmpty { + return URL(fileURLWithPath: customPathString) + } + + // 3. 默认使用 Application Support(更符合应用配置文件惯例) + return defaultLocalDirectory() + } + + private func enableCloudKitSync() { + guard let targetDir = defaultLocalDirectory() else { return } + + // 在切换前先拿到用户当前目录,自动迁移配置,避免用户手动搬文件 + let sourceDir = directoryFromUserPreference() + do { + try migrateConfigsToDefaultDirectory(from: sourceDir, to: targetDir) + } catch { + print("迁移配置到默认目录失败: \(error)") + return + } + + customPathBookmark = nil + customPathString = targetDir.path + cloudKitSyncEnabled = true + + _ = refreshConfigs(skipCloudSync: true) + triggerCloudSyncIfNeeded(force: true) + } + + private func migrateConfigsToDefaultDirectory(from sourceDir: URL?, to targetDir: URL) throws { + guard let sourceDir else { return } + if sourceDir.standardizedFileURL == targetDir.standardizedFileURL { return } + + if !FileManager.default.fileExists(atPath: targetDir.path) { + try FileManager.default.createDirectory(at: targetDir, withIntermediateDirectories: true) + } + + let isScoped = sourceDir.startAccessingSecurityScopedResource() + defer { if isScoped { sourceDir.stopAccessingSecurityScopedResource() } } + + let items = try FileManager.default.contentsOfDirectory( + at: sourceDir, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles] + ) + + for sourceFile in items where sourceFile.pathExtension.lowercased() == "toml" { + let targetFile = targetDir.appendingPathComponent(sourceFile.lastPathComponent) + + if !FileManager.default.fileExists(atPath: targetFile.path) { + try FileManager.default.copyItem(at: sourceFile, to: targetFile) + continue + } + + let sourceDate = (try? sourceFile.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? Date.distantPast + let targetDate = (try? targetFile.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? Date.distantPast + + if sourceDate.timeIntervalSince(targetDate) > 1.0 { + try FileManager.default.removeItem(at: targetFile) + try FileManager.default.copyItem(at: sourceFile, to: targetFile) + } + } + } } diff --git a/Spotier/ContentView.swift b/Spotier/ContentView.swift index 7f2fee7..4f052c3 100644 --- a/Spotier/ContentView.swift +++ b/Spotier/ContentView.swift @@ -252,7 +252,7 @@ struct ContentView: View { Divider() - Button("存储到 iCloud") { configManager.migrateToiCloud() } + Button("同步到 iCloud") { configManager.migrateToiCloud() } Button("选择文件夹") { configManager.selectCustomFolder() } Button("在 Finder 中打开") { configManager.openiCloudFolder() } @@ -487,11 +487,17 @@ struct ContentView: View { if vpnManager.isConnected { vpnManager.disableOnDemandAndStop() } else { + guard !selectedConfigPath.isEmpty else { + vpnManager.statusText = "未选择配置文件" + return + } + // 通过 ConfigManager 读取(处理安全域书签) let configURL = URL(fileURLWithPath: selectedConfigPath) if let content = try? ConfigManager.shared.readConfigContent(configURL) { vpnManager.startVPN(configContent: content) } else { + vpnManager.statusText = "读取配置失败: \(configURL.lastPathComponent)" print("无法读取配置文件: \(selectedConfigPath)") } } @@ -503,6 +509,7 @@ struct ContentView: View { ) } .buttonStyle(.plain) + .disabled(!vpnManager.isConnected && selectedConfigPath.isEmpty) .zIndex(20) if runner.isRunning && runner.isWindowVisible { @@ -660,8 +667,7 @@ struct ContentView: View { private func deleteSelectedConfig() { guard let url = selectedConfig else { return } - try? FileManager.default.removeItem(at: url) - configManager.refreshConfigs() + configManager.deleteConfig(url) // Auto select next if available is handled by onChange } diff --git a/Spotier/Localizable.xcstrings b/Spotier/Localizable.xcstrings index 83fc257..13f8d91 100644 --- a/Spotier/Localizable.xcstrings +++ b/Spotier/Localizable.xcstrings @@ -814,6 +814,9 @@ } } } + }, + "同步到 iCloud" : { + }, "名称" : { "extractionState" : "manual", diff --git a/Spotier/VPNManager.swift b/Spotier/VPNManager.swift index 2ec96c8..55f3a29 100644 --- a/Spotier/VPNManager.swift +++ b/Spotier/VPNManager.swift @@ -20,6 +20,7 @@ class VPNManager: ObservableObject { } private var manager: NETunnelProviderManager? + private var pendingStartConfigContent: String? init() { loadPreferences() @@ -43,6 +44,9 @@ class VPNManager: ObservableObject { if let error = error { print("Error loading VPN preferences: \(error)") + DispatchQueue.main.async { + self.statusText = "加载 VPN 配置失败: \(error.localizedDescription)" + } return } @@ -54,6 +58,7 @@ class VPNManager: ObservableObject { self.updateStatusSync() // 状态已就绪后再标记 isReady,确保后续逻辑能读到正确的 isConnected self.isReady = true + self.processPendingStartIfNeeded() // 仅在 On Demand 设置不一致时才 save,避免 saveToPreferences 导致系统重启隧道 let connectOnStart = (UserDefaults.standard.object(forKey: "connectOnStart") as? Bool) ?? true @@ -92,12 +97,18 @@ class VPNManager: ObservableObject { manager.saveToPreferences { [weak self] error in if let error = error { print("VPNManager: Error saving VPN profile: \(error.localizedDescription)") + DispatchQueue.main.async { + self?.statusText = "创建 VPN 配置失败: \(error.localizedDescription)" + } } else { print("VPNManager: VPN Profile saved successfully.") // 二次保存确保持久化 manager.saveToPreferences { error in if let error = error { print("VPNManager: Error on second save: \(error)") + DispatchQueue.main.async { + self?.statusText = "保存 VPN 配置失败: \(error.localizedDescription)" + } } else { print("VPNManager: Second save successful.") } @@ -179,26 +190,16 @@ class VPNManager: ObservableObject { } func startVPN(configContent: String) { - guard let manager = manager else { - print("VPN Manager not ready") + guard let manager else { + print("VPN Manager not ready, queue start request") + DispatchQueue.main.async { + self.pendingStartConfigContent = configContent + self.statusText = "VPN 初始化中,已排队启动..." + } + loadPreferences() return } - - // 我们不直接通过 options 传递大文本,而是保存到 App Group - guard let _ = saveConfigToAppGroup(configContent: configContent) else { - self.statusText = "保存配置失败" - return - } - - let options: [String: NSObject] = [:] // Config is read from App Group file by NE - - do { - try manager.connection.startVPNTunnel(options: options) - print("VPN Start requested") - } catch { - print("Error starting VPN: \(error)") - self.statusText = "启动失败: \(error.localizedDescription)" - } + performStartVPN(using: manager, configContent: configContent) } func stopVPN() { @@ -296,4 +297,37 @@ class VPNManager: ObservableObject { self.statusText = "未知状态" } } + + private func processPendingStartIfNeeded() { + guard let manager, let pendingConfig = pendingStartConfigContent else { return } + + switch manager.connection.status { + case .connected, .connecting, .reasserting: + pendingStartConfigContent = nil + return + default: + break + } + + pendingStartConfigContent = nil + performStartVPN(using: manager, configContent: pendingConfig) + } + + private func performStartVPN(using manager: NETunnelProviderManager, configContent: String) { + // 我们不直接通过 options 传递大文本,而是保存到 App Group + guard saveConfigToAppGroup(configContent: configContent) != nil else { + statusText = "保存配置失败" + return + } + + let options: [String: NSObject] = [:] // Config is read from App Group file by NE + + do { + try manager.connection.startVPNTunnel(options: options) + print("VPN Start requested") + } catch { + print("Error starting VPN: \(error)") + statusText = "启动失败: \(error.localizedDescription)" + } + } } diff --git a/SpotierNE/AddressHelper.swift b/SpotierNE/AddressHelper.swift index 5ad26e2..ecea16f 100755 --- a/SpotierNE/AddressHelper.swift +++ b/SpotierNE/AddressHelper.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation func normalizeCIDR(_ cidr: String) -> RunningIPv4CIDR? { @@ -61,3 +62,58 @@ func parseCIDR(_ cidrStr: String) -> (address: String, mask: String)? { let networkAddr = "\(ipParts[0] & maskParts[0]).\(ipParts[1] & maskParts[1]).\(ipParts[2] & maskParts[2]).\(ipParts[3] & maskParts[3])" return (networkAddr, mask) } + +func parseIPv6CIDR(_ cidrStr: String) -> (address: String, prefixLength: Int)? { + let parts = cidrStr.split(separator: "/") + guard parts.count == 2, + let prefixLength = Int(parts[1]), + (0...128).contains(prefixLength) else { return nil } + return (String(parts[0]), prefixLength) +} + +func maskedIPv6Address(_ address: String, networkLength: Int) -> String? { + guard (0...128).contains(networkLength) else { return nil } + + var ipv6 = in6_addr() + let parseResult = address.withCString { ptr in + inet_pton(AF_INET6, ptr, &ipv6) + } + guard parseResult == 1 else { return nil } + + var bytes = withUnsafeBytes(of: &ipv6) { Array($0) } + let fullBytes = networkLength / 8 + let remainingBits = networkLength % 8 + + if fullBytes < bytes.count { + if remainingBits > 0 { + let mask = UInt8(0xFF << (8 - remainingBits)) + bytes[fullBytes] &= mask + if fullBytes + 1 < bytes.count { + for index in (fullBytes + 1).. Bool { + maskedAddressFromStrings(address, mask: subnetMask) == destination +} diff --git a/SpotierNE/InfoModels.swift b/SpotierNE/InfoModels.swift index 2cbebd4..8b78626 100755 --- a/SpotierNE/InfoModels.swift +++ b/SpotierNE/InfoModels.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation struct RunningInfo: Decodable { @@ -12,9 +13,11 @@ struct RunningInfo: Decodable { struct RunningNodeInfo: Decodable { var virtualIPv4: RunningIPv4CIDR? + var virtualIPv6: RunningIPv6CIDR? enum CodingKeys: String, CodingKey { case virtualIPv4 = "virtual_ipv4" + case virtualIPv6 = "virtual_ipv6" } } @@ -82,4 +85,51 @@ struct RunningIPv4Addr: Decodable, Hashable { } } +struct RunningIPv6CIDR: Decodable, Hashable { + var address: RunningIPv6Addr + var networkLength: Int + enum CodingKeys: String, CodingKey { + case address + case networkLength = "network_length" + } +} + +struct RunningIPv6Addr: Decodable, Hashable { + var part1: UInt32 + var part2: UInt32 + var part3: UInt32 + var part4: UInt32 + + var description: String { + var bytes = [UInt8]() + bytes.reserveCapacity(16) + bytes.append(contentsOf: part1.bigEndianBytes) + bytes.append(contentsOf: part2.bigEndianBytes) + bytes.append(contentsOf: part3.bigEndianBytes) + bytes.append(contentsOf: part4.bigEndianBytes) + + var ipv6 = in6_addr() + withUnsafeMutableBytes(of: &ipv6) { destination in + bytes.withUnsafeBytes { source in + destination.copyBytes(from: source) + } + } + + var buffer = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN)) + let result = inet_ntop(AF_INET6, &ipv6, &buffer, socklen_t(buffer.count)) + guard result != nil else { return "::" } + return String(cString: buffer) + } +} + +private extension UInt32 { + var bigEndianBytes: [UInt8] { + return [ + UInt8((self >> 24) & 0xFF), + UInt8((self >> 16) & 0xFF), + UInt8((self >> 8) & 0xFF), + UInt8(self & 0xFF) + ] + } +} diff --git a/SpotierNE/PacketTunnelProvider.swift b/SpotierNE/PacketTunnelProvider.swift index d36d158..8f9adb7 100644 --- a/SpotierNE/PacketTunnelProvider.swift +++ b/SpotierNE/PacketTunnelProvider.swift @@ -15,7 +15,13 @@ class PacketTunnelProvider: NEPacketTunnelProvider { private var debounceWorkItem: DispatchWorkItem? private var parsedIPv4: String? // from config.toml private var parsedSubnet: String? // e.g. "255.255.255.0" + private var parsedIPv6: String? + private var parsedIPv6Prefix: Int? private var parsedMTU: Int? + private var parsedMagicDNS = false + private var parsedMagicDNSZone = "et.net" + + private let magicDNSResolver = "100.100.100.101" // MARK: - Config Loading @@ -37,6 +43,14 @@ class PacketTunnelProvider: NEPacketTunnelProvider { /// Parse ipv4 and mtu from TOML config for initial network settings private func parseConfigHints(_ toml: String) { + parsedIPv4 = nil + parsedSubnet = nil + parsedIPv6 = nil + parsedIPv6Prefix = nil + parsedMTU = nil + parsedMagicDNS = false + parsedMagicDNSZone = "et.net" + for line in toml.components(separatedBy: .newlines) { let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("#") { continue } @@ -56,8 +70,20 @@ class PacketTunnelProvider: NEPacketTunnelProvider { parsedSubnet = cidrToSubnetMask(cidr) } } + case "ipv6": + if let parsed = parseIPv6CIDR(val) { + parsedIPv6 = parsed.address + parsedIPv6Prefix = parsed.prefixLength + } case "mtu": parsedMTU = Int(val) + case "enable_magic_dns", "accept_dns": + parsedMagicDNS = val.lowercased() == "true" + case "tld_dns_zone": + let zone = val.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + if !zone.isEmpty { + parsedMagicDNSZone = zone + } default: break } @@ -220,67 +246,83 @@ class PacketTunnelProvider: NEPacketTunnelProvider { private func buildSettings() -> NEPacketTunnelNetworkSettings { let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1") let runningInfo = fetchRunningInfo() - - // Determine IPv4 address: prefer running info, fallback to config - let ipv4Address: String - let subnetMask: String - - if let info = runningInfo, - let nodeIp = info.myNodeInfo?.virtualIPv4 { - ipv4Address = nodeIp.address.description + let runtimeIPv4 = runningInfo?.myNodeInfo?.virtualIPv4 + let ipv4Address = runtimeIPv4?.address.description ?? parsedIPv4 + let subnetMask = runtimeIPv4 + .flatMap { cidrToSubnetMask($0.networkLength) } + ?? parsedSubnet - subnetMask = cidrToSubnetMask(nodeIp.networkLength) ?? "255.255.255.0" - } else if let configIp = parsedIPv4, let configMask = parsedSubnet { - ipv4Address = configIp - subnetMask = configMask - } else { - logger.warning("无 IPv4 地址可用,返回空设置") - return settings - } - - let ipv4Settings = NEIPv4Settings(addresses: [ipv4Address], subnetMasks: [subnetMask]) - - // Build routes from running info - var routes: [NEIPv4Route] = [] - - if let info = runningInfo { - // Add routes from peer proxy CIDRs - for route in info.routes { - for cidrStr in route.proxyCIDRs { - if let parsed = parseCIDR(cidrStr) { - routes.append(NEIPv4Route( - destinationAddress: parsed.address, - subnetMask: parsed.mask - )) + if let ipv4Address, let subnetMask { + let ipv4Settings = NEIPv4Settings(addresses: [ipv4Address], subnetMasks: [subnetMask]) + var routes: [NEIPv4Route] = [] + + if let info = runningInfo { + for route in info.routes { + for cidrStr in route.proxyCIDRs { + if let parsed = parseCIDR(cidrStr) { + routes.append(NEIPv4Route( + destinationAddress: parsed.address, + subnetMask: parsed.mask + )) + } } } + + if let nodeIp = info.myNodeInfo?.virtualIPv4 { + let networkAddr = maskedAddress(nodeIp.address, networkLength: nodeIp.networkLength) + let netMask = cidrToSubnetMask(nodeIp.networkLength) ?? "255.255.255.0" + routes.append(NEIPv4Route(destinationAddress: networkAddr, subnetMask: netMask)) + } } - - // Add the virtual network route - if let nodeIp = info.myNodeInfo?.virtualIPv4 { - let networkAddr = maskedAddress(nodeIp.address, networkLength: nodeIp.networkLength) - let netMask = cidrToSubnetMask(nodeIp.networkLength) ?? "255.255.255.0" - routes.append(NEIPv4Route(destinationAddress: networkAddr, subnetMask: netMask)) + + if routes.isEmpty { + let networkAddr = maskedAddressFromStrings(ipv4Address, mask: subnetMask) + routes.append(NEIPv4Route(destinationAddress: networkAddr, subnetMask: subnetMask)) } + + if parsedMagicDNS, + !routes.contains(where: { ipv4RouteContainsAddress(destination: $0.destinationAddress, subnetMask: $0.destinationSubnetMask, address: magicDNSResolver) }) { + routes.append(NEIPv4Route(destinationAddress: magicDNSResolver, subnetMask: "255.255.255.255")) + } + + ipv4Settings.includedRoutes = routes + settings.ipv4Settings = ipv4Settings } - - // Fallback: if no routes from running info, use config subnet - if routes.isEmpty { - if let configIp = parsedIPv4, let configMask = parsedSubnet { - // Route only the virtual subnet, not all traffic - let networkAddr = maskedAddressFromStrings(configIp, mask: configMask) - routes.append(NEIPv4Route(destinationAddress: networkAddr, subnetMask: configMask)) - } else { - // Last resort: still don't route all traffic to avoid breaking connectivity - routes.append(NEIPv4Route(destinationAddress: ipv4Address, subnetMask: "255.255.255.255")) + + let runtimeIPv6 = runningInfo?.myNodeInfo?.virtualIPv6 + let ipv6Address = runtimeIPv6?.address.description ?? parsedIPv6 + let ipv6PrefixLength = runtimeIPv6?.networkLength ?? parsedIPv6Prefix + + if let ipv6Address, let prefixLength = ipv6PrefixLength { + let ipv6Settings = NEIPv6Settings( + addresses: [ipv6Address], + networkPrefixLengths: [NSNumber(value: prefixLength)] + ) + if let networkAddress = maskedIPv6Address(ipv6Address, networkLength: prefixLength) { + ipv6Settings.includedRoutes = [ + NEIPv6Route( + destinationAddress: networkAddress, + networkPrefixLength: NSNumber(value: prefixLength) + ) + ] } + settings.ipv6Settings = ipv6Settings } - - ipv4Settings.includedRoutes = routes - settings.ipv4Settings = ipv4Settings + + if parsedMagicDNS { + let dnsSettings = NEDNSSettings(servers: [magicDNSResolver]) + dnsSettings.searchDomains = [parsedMagicDNSZone] + dnsSettings.matchDomains = [parsedMagicDNSZone] + settings.dnsSettings = dnsSettings + } + settings.mtu = NSNumber(value: parsedMTU ?? 1380) - + + if settings.ipv4Settings == nil && settings.ipv6Settings == nil { + logger.warning("无可用 IP 地址,返回空设置") + } + return settings } @@ -411,31 +453,51 @@ class PacketTunnelProvider: NEPacketTunnelProvider { struct SettingsSnapshot: Equatable { var ipv4Addresses: [String] var ipv4SubnetMasks: [String] - var routes: [(String, String)] // (destination, mask) + var ipv4Routes: [(String, String)] // (destination, mask) + var ipv6Addresses: [String] + var ipv6PrefixLengths: [Int] + var ipv6Routes: [(String, Int)] + var dnsServers: [String] + var dnsSearchDomains: [String] + var dnsMatchDomains: [String] var mtu: Int? var hasIPAddresses: Bool { - !ipv4Addresses.isEmpty && ipv4Addresses.first?.isEmpty == false + (!ipv4Addresses.isEmpty && ipv4Addresses.first?.isEmpty == false) + || (!ipv6Addresses.isEmpty && ipv6Addresses.first?.isEmpty == false) } init(from settings: NEPacketTunnelNetworkSettings) { ipv4Addresses = settings.ipv4Settings?.addresses ?? [] ipv4SubnetMasks = settings.ipv4Settings?.subnetMasks ?? [] - routes = settings.ipv4Settings?.includedRoutes?.map { + ipv4Routes = settings.ipv4Settings?.includedRoutes?.map { ($0.destinationAddress, $0.destinationSubnetMask) } ?? [] + ipv6Addresses = settings.ipv6Settings?.addresses ?? [] + ipv6PrefixLengths = settings.ipv6Settings?.networkPrefixLengths.map(\.intValue) ?? [] + ipv6Routes = settings.ipv6Settings?.includedRoutes?.map { + ($0.destinationAddress, $0.destinationNetworkPrefixLength.intValue) + } ?? [] + dnsServers = settings.dnsSettings?.servers ?? [] + dnsSearchDomains = settings.dnsSettings?.searchDomains ?? [] + dnsMatchDomains = settings.dnsSettings?.matchDomains ?? [] mtu = settings.mtu?.intValue } static func == (lhs: SettingsSnapshot, rhs: SettingsSnapshot) -> Bool { lhs.ipv4Addresses == rhs.ipv4Addresses && lhs.ipv4SubnetMasks == rhs.ipv4SubnetMasks && - lhs.routes.count == rhs.routes.count && - zip(lhs.routes, rhs.routes).allSatisfy { $0.0 == $1.0 && $0.1 == $1.1 } && + lhs.ipv4Routes.count == rhs.ipv4Routes.count && + zip(lhs.ipv4Routes, rhs.ipv4Routes).allSatisfy { $0.0 == $1.0 && $0.1 == $1.1 } && + lhs.ipv6Addresses == rhs.ipv6Addresses && + lhs.ipv6PrefixLengths == rhs.ipv6PrefixLengths && + lhs.ipv6Routes.count == rhs.ipv6Routes.count && + zip(lhs.ipv6Routes, rhs.ipv6Routes).allSatisfy { $0.0 == $1.0 && $0.1 == $1.1 } && + lhs.dnsServers == rhs.dnsServers && + lhs.dnsSearchDomains == rhs.dnsSearchDomains && + lhs.dnsMatchDomains == rhs.dnsMatchDomains && lhs.mtu == rhs.mtu } } // MARK: - Network Utility Functions - - diff --git a/SpotierNETests/AddressHelper.swift b/SpotierNETests/AddressHelper.swift new file mode 120000 index 0000000..0ffd3f0 --- /dev/null +++ b/SpotierNETests/AddressHelper.swift @@ -0,0 +1 @@ +../SpotierNE/AddressHelper.swift \ No newline at end of file diff --git a/SpotierNETests/InfoModelIPv6Tests.swift b/SpotierNETests/InfoModelIPv6Tests.swift new file mode 100644 index 0000000..c4eb06a --- /dev/null +++ b/SpotierNETests/InfoModelIPv6Tests.swift @@ -0,0 +1,19 @@ +import XCTest + +final class InfoModelIPv6Tests: XCTestCase { + func testRunningIPv6AddrDescriptionUsesNetworkByteOrder() { + let addr = RunningIPv6Addr( + part1: 0x20010DB8, + part2: 0x00010002, + part3: 0x00030004, + part4: 0x00050006 + ) + + XCTAssertEqual(addr.description, "2001:db8:1:2:3:4:5:6") + } + + func testMaskedIPv6AddressMasksHostBits() throws { + let masked = try XCTUnwrap(maskedIPv6Address("2001:db8:1:2:3:4:5:6", networkLength: 64)) + XCTAssertEqual(masked, "2001:db8:1:2::") + } +} diff --git a/SpotierNETests/InfoModels.swift b/SpotierNETests/InfoModels.swift new file mode 120000 index 0000000..9eb863f --- /dev/null +++ b/SpotierNETests/InfoModels.swift @@ -0,0 +1 @@ +../SpotierNE/InfoModels.swift \ No newline at end of file From d906791af92ec546e0324e51f36f519a24d21a30 Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Fri, 13 Mar 2026 01:28:01 +0800 Subject: [PATCH 26/30] Handle missing CloudKit record type gracefully --- Spotier/CloudKitConfigSync.swift | 57 ++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/Spotier/CloudKitConfigSync.swift b/Spotier/CloudKitConfigSync.swift index c677296..0a2908b 100644 --- a/Spotier/CloudKitConfigSync.swift +++ b/Spotier/CloudKitConfigSync.swift @@ -15,8 +15,14 @@ actor CloudKitConfigSync { private let nameKey = "name" private let contentKey = "content" private let updatedAtKey = "updated_at" + private var recordTypeUnavailable = false func sync(localDirectory: URL) async throws -> Bool { + // CloudKit schema is missing in this container/environment; keep local mode. + if recordTypeUnavailable { + return false + } + let remoteFiles = try await fetchRemoteConfigs() let localFiles = try loadLocalConfigs(from: localDirectory) @@ -37,12 +43,26 @@ actor CloudKitConfigSync { for (name, local) in localFiles { guard let remote = remoteFiles[name] else { - try await upsertRemoteConfig(local) + do { + try await upsertRemoteConfig(local) + } catch { + if markRecordTypeUnavailableIfNeeded(error) { + return localChanged + } + throw error + } continue } if shouldOverwriteRemote(local: local, remote: remote) { - try await upsertRemoteConfig(local) + do { + try await upsertRemoteConfig(local) + } catch { + if markRecordTypeUnavailableIfNeeded(error) { + return localChanged + } + throw error + } } } @@ -63,7 +83,15 @@ actor CloudKitConfigSync { private func fetchRemoteConfigs() async throws -> [String: CloudConfigFile] { let query = CKQuery(recordType: recordType, predicate: NSPredicate(value: true)) - let records = try await perform(query: query) + let records: [CKRecord] + do { + records = try await perform(query: query) + } catch { + if markRecordTypeUnavailableIfNeeded(error) { + return [:] + } + throw error + } var files: [String: CloudConfigFile] = [:] for record in records { @@ -81,6 +109,29 @@ actor CloudKitConfigSync { return files } + private func markRecordTypeUnavailableIfNeeded(_ error: Error) -> Bool { + guard let ckError = error as? CKError, ckError.code == .unknownItem else { + return false + } + + let userInfo = (ckError as NSError).userInfo + let serverMessage = (userInfo["ServerDescription"] as? String) + ?? (userInfo[NSDebugDescriptionErrorKey] as? String) + ?? ckError.localizedDescription + + guard serverMessage.contains("Did not find record type"), + serverMessage.contains(recordType) else { + return false + } + + if !recordTypeUnavailable { + recordTypeUnavailable = true + print("CloudKit schema unavailable: missing record type '\(recordType)'. Falling back to local-only config storage.") + } + + return true + } + private func loadLocalConfigs(from directory: URL) throws -> [String: CloudConfigFile] { if !FileManager.default.fileExists(atPath: directory.path) { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) From a2608ab060086088e76bb99e27490eae5c08391f Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Fri, 13 Mar 2026 01:41:24 +0800 Subject: [PATCH 27/30] Add CloudKit on/off controls and storage label localization --- Spotier/ConfigManager.swift | 15 ++++++++++++ Spotier/ContentView.swift | 17 +++++++++---- Spotier/Localizable.xcstrings | 45 +++++++++++++++++++++++++++++++++-- 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/Spotier/ConfigManager.swift b/Spotier/ConfigManager.swift index 6b4e475..4e2c92a 100644 --- a/Spotier/ConfigManager.swift +++ b/Spotier/ConfigManager.swift @@ -90,6 +90,8 @@ class ConfigManager: ObservableObject { } func migrateToiCloud() { + guard !cloudKitSyncEnabled else { return } + guard isICloudEnabled else { print("未检测到 iCloud 账户,无法启用 CloudKit 同步。") return @@ -98,6 +100,19 @@ class ConfigManager: ObservableObject { enableCloudKitSync() } + func disableCloudKitSync() { + guard cloudKitSyncEnabled else { return } + + cloudKitSyncEnabled = false + isCloudSyncInProgress = false + + if customPathString.isEmpty, let targetDir = defaultLocalDirectory() { + customPathString = targetDir.path + } + + _ = refreshConfigs(skipCloudSync: true) + } + @discardableResult func refreshConfigs() -> [URL] { refreshConfigs(skipCloudSync: false) diff --git a/Spotier/ContentView.swift b/Spotier/ContentView.swift index 4f052c3..c124f7e 100644 --- a/Spotier/ContentView.swift +++ b/Spotier/ContentView.swift @@ -202,9 +202,13 @@ struct ContentView: View { HStack { Menu { Section("配置文件") { - // Show storage location with icon - if let _ = FileManager.default.ubiquityIdentityToken { - Label("存储位置: iCloud Drive", systemImage: "icloud") + // Show storage mode with icon + if configManager.cloudKitSyncEnabled { + Label("存储方式: CloudKit 同步", systemImage: "icloud") + .font(.caption) + .foregroundColor(.secondary) + } else if FileManager.default.ubiquityIdentityToken != nil { + Label("存储位置: 本地(可启用 CloudKit)", systemImage: "internaldrive") .font(.caption) .foregroundColor(.secondary) } else { @@ -252,8 +256,13 @@ struct ContentView: View { Divider() - Button("同步到 iCloud") { configManager.migrateToiCloud() } + if configManager.cloudKitSyncEnabled { + Button("关闭 CloudKit 同步") { configManager.disableCloudKitSync() } + } else { + Button("同步到 iCloud") { configManager.migrateToiCloud() } + } Button("选择文件夹") { configManager.selectCustomFolder() } + .disabled(configManager.cloudKitSyncEnabled) Button("在 Finder 中打开") { configManager.openiCloudFolder() } diff --git a/Spotier/Localizable.xcstrings b/Spotier/Localizable.xcstrings index 13f8d91..f7057dc 100644 --- a/Spotier/Localizable.xcstrings +++ b/Spotier/Localizable.xcstrings @@ -816,7 +816,26 @@ } }, "同步到 iCloud" : { - + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enable CloudKit Sync" + } + } + } + }, + "关闭 CloudKit 同步" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disable CloudKit Sync" + } + } + } }, "名称" : { "extractionState" : "manual", @@ -1041,6 +1060,17 @@ } } }, + "存储位置: 本地(可启用 CloudKit)" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Storage: Local (CloudKit available)" + } + } + } + }, "存储位置: 本地 (iCloud 未启用)" : { "extractionState" : "manual", "localizations" : { @@ -1052,6 +1082,17 @@ } } }, + "存储方式: CloudKit 同步" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Storage: CloudKit Sync" + } + } + } + }, "存储到 iCloud" : { "extractionState" : "manual", "localizations" : { @@ -2531,4 +2572,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +} From 74e266394f27aa89660fc624aee4be966947daad Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Fri, 13 Mar 2026 01:42:31 +0800 Subject: [PATCH 28/30] Bump build number to 5 for TestFlight --- Spotier.xcodeproj/project.pbxproj | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Spotier.xcodeproj/project.pbxproj b/Spotier.xcodeproj/project.pbxproj index 9f182d4..c70a806 100644 --- a/Spotier.xcodeproj/project.pbxproj +++ b/Spotier.xcodeproj/project.pbxproj @@ -507,7 +507,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = KLU8GF65GP; ENABLE_APP_SANDBOX = YES; @@ -569,7 +569,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = KLU8GF65GP; ENABLE_APP_SANDBOX = YES; @@ -626,7 +626,7 @@ CODE_SIGN_ENTITLEMENTS = SpotierNE/SpotierNE.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = KLU8GF65GP; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -686,7 +686,7 @@ CODE_SIGN_ENTITLEMENTS = SpotierNE/SpotierNE.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = KLU8GF65GP; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -744,7 +744,7 @@ buildSettings = { BUNDLE_LOADER = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = KLU8GF65GP; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; @@ -761,7 +761,7 @@ buildSettings = { BUNDLE_LOADER = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = KLU8GF65GP; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; From 1bb694f0fbeeed568452a4ae8b3635b9e80a2ce7 Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Thu, 26 Mar 2026 11:19:15 +0800 Subject: [PATCH 29/30] Simplify config storage and dashboard flow --- Spotier.xcodeproj/project.pbxproj | 123 +- Spotier/CliClient.swift | 207 --- Spotier/CloudKitConfigSync.swift | 258 --- Spotier/CodeEditor.swift | 92 +- Spotier/Config/ConfigDirectoryAccess.swift | 65 + Spotier/Config/ConfigFileRepository.swift | 40 + Spotier/Config/ConfigTemplateFactory.swift | 35 + Spotier/ConfigEditorView.swift | 10 +- .../ConfigGenerator/ConfigDraftStore.swift | 29 + .../ConfigGeneratorBehavior.swift | 48 + .../ConfigGeneratorForms.swift | 618 +++++++ .../ConfigGeneratorStore.swift | 65 + .../ConfigGenerator/SpotierConfigCodec.swift | 285 ++++ .../ConfigGenerator/SpotierConfigDomain.swift | 149 ++ Spotier/ConfigGeneratorView.swift | 1468 ++--------------- Spotier/ConfigManager.swift | 324 ++-- Spotier/ContentView.swift | 834 ++-------- Spotier/CopyableDetailRow.swift | 58 + Spotier/EasyTierShared.swift | 76 +- Spotier/EventListView.swift | 28 +- Spotier/FloatingScrollTopButton.swift | 28 + Spotier/Localizable.xcstrings | 79 +- Spotier/LogListView.swift | 45 +- Spotier/LogModels.swift | 7 +- Spotier/LogParser.swift | 301 +--- Spotier/LogView.swift | 90 +- Spotier/Logging/LogContainerResolver.swift | 22 + Spotier/Logging/LogEventFormatter.swift | 214 +++ Spotier/Logging/LogSettingsStore.swift | 19 + Spotier/Main/CreateConfigPrompt.swift | 46 + Spotier/Main/DashboardLayoutMetrics.swift | 17 + Spotier/Main/DashboardOverlayRoute.swift | 24 + Spotier/Main/MainConnectionUseCase.swift | 38 + Spotier/Main/MainDashboardHeader.swift | 139 ++ Spotier/Main/MainDashboardState.swift | 153 ++ Spotier/Main/PeerListArea.swift | 58 + Spotier/Main/SpeedDashboard.swift | 187 +++ Spotier/Models/SpotierNodeModels.swift | 17 +- Spotier/NativeHorizontalScroller.swift | 52 + Spotier/PeerCard.swift | 337 +--- Spotier/PeerCardComponents.swift | 71 + Spotier/PeerDetailView.swift | 152 ++ Spotier/ScrollFixer.swift | 18 +- Spotier/SettingsView.swift | 19 +- Spotier/SharedComponents.swift | 42 +- Spotier/SparklineView.swift | 49 +- Spotier/Spotier.entitlements | 1 - Spotier/SpotierControlApp.swift | 45 +- Spotier/SpotierRunner.swift | 426 +++-- Spotier/VPNManager.swift | 172 +- SpotierNE/EasyTierShared.swift | 76 +- SpotierNE/PacketTunnelProvider.swift | 118 +- SpotierNE/SwiftierCore.swift | 10 +- SpotierNE/TunnelHelper.swift | 7 +- .../ConfigGeneratorBehaviorTests.swift | 54 + SpotierTests/ConfigGeneratorStoreTests.swift | 93 ++ SpotierTests/ConfigTemplateFactoryTests.swift | 23 + SpotierTests/LogContainerResolverTests.swift | 23 + SpotierTests/LogEventFormatterTests.swift | 59 + SpotierTests/LogSettingsStoreTests.swift | 32 + SpotierTests/MainConnectionUseCaseTests.swift | 119 ++ SpotierTests/SpotierConfigCodecTests.swift | 191 +++ plans/refactor_checklist_plan.md | 334 ++++ plans/review_fix_checklist.md | 12 + 64 files changed, 4827 insertions(+), 4004 deletions(-) delete mode 100644 Spotier/CloudKitConfigSync.swift create mode 100644 Spotier/Config/ConfigDirectoryAccess.swift create mode 100644 Spotier/Config/ConfigFileRepository.swift create mode 100644 Spotier/Config/ConfigTemplateFactory.swift create mode 100644 Spotier/ConfigGenerator/ConfigDraftStore.swift create mode 100644 Spotier/ConfigGenerator/ConfigGeneratorBehavior.swift create mode 100644 Spotier/ConfigGenerator/ConfigGeneratorForms.swift create mode 100644 Spotier/ConfigGenerator/ConfigGeneratorStore.swift create mode 100644 Spotier/ConfigGenerator/SpotierConfigCodec.swift create mode 100644 Spotier/ConfigGenerator/SpotierConfigDomain.swift create mode 100644 Spotier/CopyableDetailRow.swift create mode 100644 Spotier/FloatingScrollTopButton.swift create mode 100644 Spotier/Logging/LogContainerResolver.swift create mode 100644 Spotier/Logging/LogEventFormatter.swift create mode 100644 Spotier/Logging/LogSettingsStore.swift create mode 100644 Spotier/Main/CreateConfigPrompt.swift create mode 100644 Spotier/Main/DashboardLayoutMetrics.swift create mode 100644 Spotier/Main/DashboardOverlayRoute.swift create mode 100644 Spotier/Main/MainConnectionUseCase.swift create mode 100644 Spotier/Main/MainDashboardHeader.swift create mode 100644 Spotier/Main/MainDashboardState.swift create mode 100644 Spotier/Main/PeerListArea.swift create mode 100644 Spotier/Main/SpeedDashboard.swift create mode 100644 Spotier/NativeHorizontalScroller.swift create mode 100644 Spotier/PeerCardComponents.swift create mode 100644 Spotier/PeerDetailView.swift create mode 100644 SpotierTests/ConfigGeneratorBehaviorTests.swift create mode 100644 SpotierTests/ConfigGeneratorStoreTests.swift create mode 100644 SpotierTests/ConfigTemplateFactoryTests.swift create mode 100644 SpotierTests/LogContainerResolverTests.swift create mode 100644 SpotierTests/LogEventFormatterTests.swift create mode 100644 SpotierTests/LogSettingsStoreTests.swift create mode 100644 SpotierTests/MainConnectionUseCaseTests.swift create mode 100644 SpotierTests/SpotierConfigCodecTests.swift create mode 100644 plans/refactor_checklist_plan.md create mode 100644 plans/review_fix_checklist.md diff --git a/Spotier.xcodeproj/project.pbxproj b/Spotier.xcodeproj/project.pbxproj index c70a806..ca3446c 100644 --- a/Spotier.xcodeproj/project.pbxproj +++ b/Spotier.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ E0AB5CD32F3CCD9C00BD8CBA /* SpotierNE.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E0AB5CC92F3CCD9C00BD8CBA /* SpotierNE.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; E0AC7EEE2F19307C00FC425C /* ServiceManagement.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E0AC7EED2F19307C00FC425C /* ServiceManagement.framework */; }; E1AA00012F50000100AAA001 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E1AA00042F50000100AAA001 /* XCTest.framework */; }; + E1BB00012F60000100BBB001 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E1AA00042F50000100AAA001 /* XCTest.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -28,6 +29,13 @@ remoteGlobalIDString = E0AB5CC82F3CCD9C00BD8CBA; remoteInfo = SpotierNE; }; + E1BB00022F60000100BBB001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E0933DC42F16A25B00C7DE59 /* Project object */; + proxyType = 1; + remoteGlobalIDString = E0933DCB2F16A25B00C7DE59; + remoteInfo = Spotier; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -70,6 +78,7 @@ E0AC7EED2F19307C00FC425C /* ServiceManagement.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ServiceManagement.framework; path = System/Library/Frameworks/ServiceManagement.framework; sourceTree = SDKROOT; }; E1AA00032F50000100AAA001 /* SpotierNETests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SpotierNETests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; E1AA00042F50000100AAA001 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = System/Library/Frameworks/XCTest.framework; sourceTree = SDKROOT; }; + E1BB00032F60000100BBB001 /* SpotierTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SpotierTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -111,6 +120,11 @@ path = SpotierNETests; sourceTree = ""; }; + E1BB00052F60000100BBB001 /* SpotierTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = SpotierTests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -138,6 +152,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E1BB00062F60000100BBB001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E1BB00012F60000100BBB001 /* XCTest.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -147,6 +169,7 @@ E0933DCE2F16A25B00C7DE59 /* Spotier */, E0AB5CCC2F3CCD9C00BD8CBA /* SpotierNE */, E1AA00052F50000100AAA001 /* SpotierNETests */, + E1BB00052F60000100BBB001 /* SpotierTests */, E0AC7EEC2F19307C00FC425C /* Frameworks */, E0933DCD2F16A25B00C7DE59 /* Products */, ); @@ -158,6 +181,7 @@ E0933DCC2F16A25B00C7DE59 /* Spotier.app */, E0AB5CC92F3CCD9C00BD8CBA /* SpotierNE.appex */, E1AA00032F50000100AAA001 /* SpotierNETests.xctest */, + E1BB00032F60000100BBB001 /* SpotierTests.xctest */, ); name = Products; sourceTree = ""; @@ -246,6 +270,29 @@ productReference = E1AA00032F50000100AAA001 /* SpotierNETests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + E1BB00072F60000100BBB001 /* SpotierTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = E1BB000D2F60000100BBB001 /* Build configuration list for PBXNativeTarget "SpotierTests" */; + buildPhases = ( + E1BB00092F60000100BBB001 /* Sources */, + E1BB00062F60000100BBB001 /* Frameworks */, + E1BB00082F60000100BBB001 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + E1BB000A2F60000100BBB001 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + E1BB00052F60000100BBB001 /* SpotierTests */, + ); + name = SpotierTests; + packageProductDependencies = ( + ); + productName = SpotierTests; + productReference = E1BB00032F60000100BBB001 /* SpotierTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -287,6 +334,9 @@ E1AA00072F50000100AAA001 = { CreatedOnToolsVersion = 26.3; }; + E1BB00072F60000100BBB001 = { + CreatedOnToolsVersion = 26.3; + }; }; }; buildConfigurationList = E0933DC72F16A25B00C7DE59 /* Build configuration list for PBXProject "Spotier" */; @@ -306,6 +356,7 @@ E0933DCB2F16A25B00C7DE59 /* Spotier */, E0AB5CC82F3CCD9C00BD8CBA /* SpotierNE */, E1AA00072F50000100AAA001 /* SpotierNETests */, + E1BB00072F60000100BBB001 /* SpotierTests */, ); }; /* End PBXProject section */ @@ -332,6 +383,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E1BB00082F60000100BBB001 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -356,6 +414,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E1BB00092F60000100BBB001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -369,6 +434,11 @@ target = E0AB5CC82F3CCD9C00BD8CBA /* SpotierNE */; targetProxy = E1AA00022F50000100AAA001 /* PBXContainerItemProxy */; }; + E1BB000A2F60000100BBB001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E0933DCB2F16A25B00C7DE59 /* Spotier */; + targetProxy = E1BB00022F60000100BBB001 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -507,7 +577,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = KLU8GF65GP; ENABLE_APP_SANDBOX = YES; @@ -569,7 +639,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = KLU8GF65GP; ENABLE_APP_SANDBOX = YES; @@ -626,7 +696,7 @@ CODE_SIGN_ENTITLEMENTS = SpotierNE/SpotierNE.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = KLU8GF65GP; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -686,7 +756,7 @@ CODE_SIGN_ENTITLEMENTS = SpotierNE/SpotierNE.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = KLU8GF65GP; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -773,6 +843,42 @@ }; name = Release; }; + E1BB000B2F60000100BBB001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 5; + DEVELOPMENT_TEAM = KLU8GF65GP; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + PRODUCT_BUNDLE_IDENTIFIER = com.alick.spotier.SpotierTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Spotier.app/Contents/MacOS/Spotier"; + TEST_TARGET_NAME = Spotier; + }; + name = Debug; + }; + E1BB000C2F60000100BBB001 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 5; + DEVELOPMENT_TEAM = KLU8GF65GP; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + PRODUCT_BUNDLE_IDENTIFIER = com.alick.spotier.SpotierTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Spotier.app/Contents/MacOS/Spotier"; + TEST_TARGET_NAME = Spotier; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -812,6 +918,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + E1BB000D2F60000100BBB001 /* Build configuration list for PBXNativeTarget "SpotierTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E1BB000B2F60000100BBB001 /* Debug */, + E1BB000C2F60000100BBB001 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = E0933DC42F16A25B00C7DE59 /* Project object */; diff --git a/Spotier/CliClient.swift b/Spotier/CliClient.swift index 1a861c2..b1d85ef 100644 --- a/Spotier/CliClient.swift +++ b/Spotier/CliClient.swift @@ -1,5 +1,4 @@ import Foundation -import AppKit struct PeerInfo: Identifiable, Equatable { // 使用 sessionID 加上业务字段组合成唯一 ID @@ -67,209 +66,3 @@ struct PeerInfo: Identifiable, Equatable { lhs.myNodeData == rhs.myNodeData } } - -actor CliClient { - private let rpcPort: String - private var cachedBinaryPath: String? - - init(rpcPort: String) { - self.rpcPort = rpcPort - } - - /// 获取节点列表,必须传入当前运行周期的 sessionID - func fetchPeers(sessionID: UUID) async -> [PeerInfo] { - // 1. 尝试通过 JSON 获取完整信息 (优先) - // 使用 -o json peer - if let peerJson = runCLI(arguments: ["-o", "json", "peer"]), - let parsedPeers = parseCLIJSON(peerJson, sessionID: sessionID), !parsedPeers.isEmpty { - - var peers = parsedPeers - - // 尝试获取路由信息并合并 - if let routeJson = runCLI(arguments: ["-o", "json", "route"]), - let data = routeJson.data(using: .utf8), - let routes = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] { - - var routeMap: [String: [String: Any]] = [:] - for r in routes { - if let h = r["hostname"] as? String { routeMap[h] = r } - } - - for i in 0.. String? { - guard let cliBin = getBinaryPath(name: "easytier-cli") else { return nil } - - let task = Process() - task.executableURL = URL(fileURLWithPath: cliBin) - task.arguments = arguments - - let pipe = Pipe() - task.standardOutput = pipe - - do { - try task.run() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - return String(data: data, encoding: .utf8) - } catch { - return nil - } - } - - private func parseCLITable(_ output: String, sessionID: UUID) -> [PeerInfo] { - let lines = output.components(separatedBy: .newlines) - var peers: [PeerInfo] = [] - - for line in lines { - let trimmed = line.trimmingCharacters(in: .whitespaces) - // 过滤掉表头和空行 - guard trimmed.hasPrefix("|"), trimmed.contains("|") else { continue } - if trimmed.localizedCaseInsensitiveContains("ipv4") || trimmed.hasPrefix("|---") { continue } - - var cols = trimmed.components(separatedBy: "|") - .map { $0.trimmingCharacters(in: .whitespaces) } - - // 处理表格边界切分出的多余空元素 - if !cols.isEmpty { cols.removeFirst() } - if !cols.isEmpty { cols.removeLast() } - - // 清理不间断空格 - cols = cols.map { $0.replacingOccurrences(of: "\u{00A0}", with: " ") } - - // 确保表格列数足够 - guard cols.count >= 10 else { continue } - - let ipv4 = cols[0] - let hostname = cols[1] - let finalIPv4 = ipv4.isEmpty ? "Public Peer" : ipv4 - - let peer = PeerInfo( - sessionID: sessionID, - ipv4: finalIPv4, - hostname: hostname, - cost: cols[2], - latency: cols[3], - loss: cols[4], - rx: cols[5], - tx: cols[6], - tunnel: cols[7], - nat: cols[8], - version: cols[9] - ) - peers.append(peer) - } - return peers - } - - private func parseCLIJSON(_ jsonString: String, sessionID: UUID) -> [PeerInfo]? { - guard let data = jsonString.data(using: .utf8) else { return nil } - - do { - if let array = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] { - return array.compactMap { dict in - // Extract basic fields based on actual CLI JSON output - let rawIPv4 = (dict["ipv4"] as? String) ?? "" - let ipv4 = rawIPv4.isEmpty ? "Public Peer" : rawIPv4 - let hostname = (dict["hostname"] as? String) ?? "" - let cost = anyToString(dict["cost"]) - let latency = anyToString(dict["lat_ms"]) - let loss = anyToString(dict["loss_rate"]) - let rx = anyToString(dict["rx_bytes"]) - let tx = anyToString(dict["tx_bytes"]) - let tunnel = (dict["tunnel_proto"] as? String) ?? "" - let nat = (dict["nat_type"] as? String) ?? "" - let version = (dict["version"] as? String) ?? "" - - // Fallback keys check if primary guess fails - // ... This is tricky without schema. best effort. - - // Create basic info - var extra: [String: String] = [:] - for (k, v) in dict { - extra[k] = anyToString(v) - } - - // Refined parsing from extra if direct dict access failed or to normalize units? - // Table parser deals with "10 ms" strings. JSON might be raw numbers (ms, bytes). - // If JSON returns raw numbers, we might need to format them (e.g. bytes to KB). - // For now, store raw. UI might need update if format changes. - - return PeerInfo( - sessionID: sessionID, - ipv4: ipv4, - hostname: hostname, - cost: cost, - latency: latency, - loss: loss, - rx: rx, - tx: tx, - tunnel: tunnel, - nat: nat, - version: version, - extraInfo: extra - ) - } - } - } catch { - print("JSON Parse Error: \(error)") - } - return nil - } - - private func anyToString(_ value: Any?) -> String { - guard let v = value else { return "" } - if let str = v as? String { return str } - if let num = v as? NSNumber { return num.stringValue } - return String(describing: v) - } - - private func getBinaryPath(name: String) -> String? { - if name == "easytier-cli", let cached = cachedBinaryPath { - return cached - } - - // 1. Check Application Support - if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { - let customPath = appSupport.appendingPathComponent("Swiftier/bin/\(name)").path - if FileManager.default.fileExists(atPath: customPath) { - if name == "easytier-cli" { cachedBinaryPath = customPath } - return customPath - } - } - - let bundlePath = Bundle.main.executableURL? - .deletingLastPathComponent() - .appendingPathComponent(name).path - - if let path = bundlePath, FileManager.default.fileExists(atPath: path) { - if name == "easytier-cli" { cachedBinaryPath = path } - return path - } - - return bundlePath - } -} diff --git a/Spotier/CloudKitConfigSync.swift b/Spotier/CloudKitConfigSync.swift deleted file mode 100644 index 0a2908b..0000000 --- a/Spotier/CloudKitConfigSync.swift +++ /dev/null @@ -1,258 +0,0 @@ -import CloudKit -import Foundation - -struct CloudConfigFile { - let name: String - let content: String - let updatedAt: Date -} - -actor CloudKitConfigSync { - static let shared = CloudKitConfigSync() - - private let database = CKContainer.default().privateCloudDatabase - private let recordType = "SpotierConfig" - private let nameKey = "name" - private let contentKey = "content" - private let updatedAtKey = "updated_at" - private var recordTypeUnavailable = false - - func sync(localDirectory: URL) async throws -> Bool { - // CloudKit schema is missing in this container/environment; keep local mode. - if recordTypeUnavailable { - return false - } - - let remoteFiles = try await fetchRemoteConfigs() - let localFiles = try loadLocalConfigs(from: localDirectory) - - var localChanged = false - - for (name, remote) in remoteFiles { - guard let local = localFiles[name] else { - try writeLocalConfig(remote, to: localDirectory) - localChanged = true - continue - } - - if shouldOverwriteLocal(remote: remote, local: local) { - try writeLocalConfig(remote, to: localDirectory) - localChanged = true - } - } - - for (name, local) in localFiles { - guard let remote = remoteFiles[name] else { - do { - try await upsertRemoteConfig(local) - } catch { - if markRecordTypeUnavailableIfNeeded(error) { - return localChanged - } - throw error - } - continue - } - - if shouldOverwriteRemote(local: local, remote: remote) { - do { - try await upsertRemoteConfig(local) - } catch { - if markRecordTypeUnavailableIfNeeded(error) { - return localChanged - } - throw error - } - } - } - - return localChanged - } - - func deleteConfig(named fileName: String) async throws { - let recordID = CKRecord.ID(recordName: recordName(for: fileName)) - do { - _ = try await deleteRecord(with: recordID) - } catch { - let ckError = error as? CKError - if ckError?.code != .unknownItem { - throw error - } - } - } - - private func fetchRemoteConfigs() async throws -> [String: CloudConfigFile] { - let query = CKQuery(recordType: recordType, predicate: NSPredicate(value: true)) - let records: [CKRecord] - do { - records = try await perform(query: query) - } catch { - if markRecordTypeUnavailableIfNeeded(error) { - return [:] - } - throw error - } - - var files: [String: CloudConfigFile] = [:] - for record in records { - guard let name = record[nameKey] as? String, - let content = record[contentKey] as? String else { - continue - } - - let updatedAt = (record[updatedAtKey] as? Date) - ?? record.modificationDate - ?? Date.distantPast - - files[name] = CloudConfigFile(name: name, content: content, updatedAt: updatedAt) - } - return files - } - - private func markRecordTypeUnavailableIfNeeded(_ error: Error) -> Bool { - guard let ckError = error as? CKError, ckError.code == .unknownItem else { - return false - } - - let userInfo = (ckError as NSError).userInfo - let serverMessage = (userInfo["ServerDescription"] as? String) - ?? (userInfo[NSDebugDescriptionErrorKey] as? String) - ?? ckError.localizedDescription - - guard serverMessage.contains("Did not find record type"), - serverMessage.contains(recordType) else { - return false - } - - if !recordTypeUnavailable { - recordTypeUnavailable = true - print("CloudKit schema unavailable: missing record type '\(recordType)'. Falling back to local-only config storage.") - } - - return true - } - - private func loadLocalConfigs(from directory: URL) throws -> [String: CloudConfigFile] { - if !FileManager.default.fileExists(atPath: directory.path) { - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - } - - let items = try FileManager.default.contentsOfDirectory( - at: directory, - includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], - options: [.skipsHiddenFiles] - ) - - var files: [String: CloudConfigFile] = [:] - for url in items where url.pathExtension.lowercased() == "toml" { - let values = try url.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey]) - guard values.isRegularFile == true else { continue } - - let content = try String(contentsOf: url, encoding: .utf8) - let modifiedAt = values.contentModificationDate ?? Date.distantPast - files[url.lastPathComponent] = CloudConfigFile( - name: url.lastPathComponent, - content: content, - updatedAt: modifiedAt - ) - } - return files - } - - private func writeLocalConfig(_ file: CloudConfigFile, to directory: URL) throws { - let url = directory.appendingPathComponent(file.name) - try file.content.write(to: url, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.modificationDate: file.updatedAt], ofItemAtPath: url.path) - } - - private func upsertRemoteConfig(_ file: CloudConfigFile) async throws { - let recordID = CKRecord.ID(recordName: recordName(for: file.name)) - let record = CKRecord(recordType: recordType, recordID: recordID) - record[nameKey] = file.name as CKRecordValue - record[contentKey] = file.content as CKRecordValue - record[updatedAtKey] = file.updatedAt as CKRecordValue - _ = try await save(record: record) - } - - private func shouldOverwriteLocal(remote: CloudConfigFile, local: CloudConfigFile) -> Bool { - if remote.content == local.content { - return false - } - return remote.updatedAt.timeIntervalSince(local.updatedAt) > 1.0 - } - - private func shouldOverwriteRemote(local: CloudConfigFile, remote: CloudConfigFile) -> Bool { - if local.content == remote.content { - return false - } - return local.updatedAt.timeIntervalSince(remote.updatedAt) > 1.0 - } - - private func recordName(for fileName: String) -> String { - "config_\(fileName)" - } - - private func perform(query: CKQuery) async throws -> [CKRecord] { - var allRecords: [CKRecord] = [] - let desiredKeys = [nameKey, contentKey, updatedAtKey] - - var page = try await database.records( - matching: query, - inZoneWith: nil, - desiredKeys: desiredKeys, - resultsLimit: CKQueryOperation.maximumResults - ) - try appendMatchedRecords(page.matchResults, into: &allRecords) - - var cursor = page.queryCursor - while let currentCursor = cursor { - page = try await database.records( - continuingMatchFrom: currentCursor, - desiredKeys: desiredKeys, - resultsLimit: CKQueryOperation.maximumResults - ) - try appendMatchedRecords(page.matchResults, into: &allRecords) - cursor = page.queryCursor - } - - return allRecords - } - - private func appendMatchedRecords( - _ matchResults: [(CKRecord.ID, Result)], - into records: inout [CKRecord] - ) throws { - for (_, result) in matchResults { - switch result { - case .success(let record): - records.append(record) - case .failure(let error): - throw error - } - } - } - - private func save(record: CKRecord) async throws -> CKRecord { - try await withCheckedThrowingContinuation { continuation in - database.save(record) { saved, error in - if let error { - continuation.resume(throwing: error) - return - } - continuation.resume(returning: saved ?? record) - } - } - } - - private func deleteRecord(with recordID: CKRecord.ID) async throws -> CKRecord.ID { - try await withCheckedThrowingContinuation { continuation in - database.delete(withRecordID: recordID) { deletedID, error in - if let error { - continuation.resume(throwing: error) - return - } - continuation.resume(returning: deletedID ?? recordID) - } - } - } -} diff --git a/Spotier/CodeEditor.swift b/Spotier/CodeEditor.swift index 604cb16..3c42e0c 100644 --- a/Spotier/CodeEditor.swift +++ b/Spotier/CodeEditor.swift @@ -5,6 +5,31 @@ struct CodeEditor: NSViewRepresentable { @Binding var text: String var mode: Mode = .toml var isEditable: Bool = true + + private static let tomlPatterns: [(NSRegularExpression, NSColor, Bool)] = [ + (try! NSRegularExpression(pattern: "^\\s*\\[.+\\]", options: [.anchorsMatchLines]), .systemOrange, true), + (try! NSRegularExpression(pattern: "^\\s*[a-zA-Z0-9_-]+\\s*(?==)", options: [.anchorsMatchLines]), .systemBlue, false), + (try! NSRegularExpression(pattern: "#.*$", options: [.anchorsMatchLines]), .secondaryLabelColor, false) + ] + + private static let logPatterns: [(NSRegularExpression, NSColor, Bool)] = [ + (try! NSRegularExpression(pattern: "^\\[[^\\]]+\\]", options: [.anchorsMatchLines]), .secondaryLabelColor, false), + (try! NSRegularExpression(pattern: "\\[easytier_core::[^\\]]+\\]", options: []), .secondaryLabelColor, false), + (try! NSRegularExpression(pattern: "(?i)ERROR|FATAL", options: []), .systemRed, true), + (try! NSRegularExpression(pattern: "(?i)WARN|WARNING", options: []), .systemOrange, true), + (try! NSRegularExpression(pattern: "(?i)INFO", options: []), .systemGreen, true), + (try! NSRegularExpression(pattern: "(?i)DEBUG", options: []), .systemCyan, true), + (try! NSRegularExpression(pattern: "(?i)TRACE", options: []), .systemBlue, true), + (try! NSRegularExpression(pattern: "Spotier", options: []), .labelColor, true) + ] + + private static let jsonPatterns: [(NSRegularExpression, NSColor, Bool)] = [ + (try! NSRegularExpression(pattern: "\"[^\"]+\"\\s*:", options: []), .systemBlue, true), + (try! NSRegularExpression(pattern: ":\\s*\"[^\"]*\"", options: []), .systemGreen, false), + (try! NSRegularExpression(pattern: ":\\s*[0-9]+\\.?[0-9]*", options: []), .systemOrange, false), + (try! NSRegularExpression(pattern: "\\b(true|false|null)\\b", options: []), .systemPurple, true), + (try! NSRegularExpression(pattern: "[\\[\\]\\{\\}]", options: []), .secondaryLabelColor, false) + ] enum Mode { case toml @@ -17,9 +42,7 @@ struct CodeEditor: NSViewRepresentable { let string = storage.string as NSString let fullRange = NSRange(location: 0, length: string.length) - // Helper - func applyStyle(pattern: String, color: NSColor, bold: Bool = false) { - guard let regex = try? NSRegularExpression(pattern: pattern, options: .anchorsMatchLines) else { return } + func applyStyle(_ regex: NSRegularExpression, color: NSColor, bold: Bool = false) { regex.enumerateMatches(in: storage.string, options: [], range: fullRange) { match, _, _ in if let range = match?.range { storage.addAttribute(.foregroundColor, value: color, range: range) @@ -36,72 +59,27 @@ struct CodeEditor: NSViewRepresentable { storage.removeAttribute(.font, range: fullRange) storage.addAttribute(.foregroundColor, value: NSColor.labelColor, range: fullRange) storage.addAttribute(.font, value: NSFont.monospacedSystemFont(ofSize: 13, weight: .regular), range: fullRange) - - // [Section] - applyStyle(pattern: "^\\s*\\[.+\\]", color: NSColor.systemOrange, bold: true) - // Key = - applyStyle(pattern: "^\\s*[a-zA-Z0-9_-]+\\s*(?==)", color: NSColor.systemBlue) - // # Comment - applyStyle(pattern: "#.*$", color: NSColor.secondaryLabelColor) + for (regex, color, bold) in Self.tomlPatterns { + applyStyle(regex, color: color, bold: bold) + } } else if mode == .log { - // 1. 重置基础样式 (Cleaner Log Style) storage.removeAttribute(.foregroundColor, range: fullRange) storage.removeAttribute(.font, range: fullRange) - - // Standard Text Color (Adaptive White/Black) instead of Matrix Green storage.addAttribute(.foregroundColor, value: NSColor.textColor, range: fullRange) - - // Menlo or Monospace Font let font = NSFont(name: "Menlo", size: 12) ?? NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) storage.addAttribute(.font, value: font, range: fullRange) - - // Time & Meta Styling (Gray) - // Matches [2026-01-18 ...] or [Helper] - applyStyle(pattern: "^\\[[^\\]]+\\]", color: NSColor.secondaryLabelColor) - applyStyle(pattern: "\\[easytier_core::[^\\]]+\\]", color: NSColor.secondaryLabelColor) - - // Log Level Highlighting - // ERROR / FATAL -> Red - applyStyle(pattern: "(?i)ERROR|FATAL", color: NSColor.systemRed, bold: true) - - // WARN -> Yellow - applyStyle(pattern: "(?i)WARN|WARNING", color: NSColor.systemOrange, bold: true) - - // INFO -> Green - applyStyle(pattern: "(?i)INFO", color: NSColor.systemGreen, bold: true) - - // DEBUG -> Cyan - applyStyle(pattern: "(?i)DEBUG", color: NSColor.systemCyan, bold: true) - - // TRACE -> Blue/Gray - // Usually verbose, keep it subtle or blue - applyStyle(pattern: "(?i)TRACE", color: NSColor.systemBlue, bold: true) - - // Highlight Swiftier keywords - applyStyle(pattern: "Spotier", color: NSColor.labelColor, bold: true) + for (regex, color, bold) in Self.logPatterns { + applyStyle(regex, color: color, bold: bold) + } } else if mode == .json { - // JSON Syntax Highlighting storage.removeAttribute(.foregroundColor, range: fullRange) storage.removeAttribute(.font, range: fullRange) storage.addAttribute(.foregroundColor, value: NSColor.textColor, range: fullRange) - let font = NSFont(name: "Menlo", size: 12) ?? NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) storage.addAttribute(.font, value: font, range: fullRange) - - // Keys ("key":) - Blue - applyStyle(pattern: "\"[^\"]+\"\\s*:", color: NSColor.systemBlue, bold: true) - - // String values ("value") - Green - applyStyle(pattern: ":\\s*\"[^\"]*\"", color: NSColor.systemGreen) - - // Numbers - Orange - applyStyle(pattern: ":\\s*[0-9]+\\.?[0-9]*", color: NSColor.systemOrange) - - // Boolean and null - Purple - applyStyle(pattern: "\\b(true|false|null)\\b", color: NSColor.systemPurple, bold: true) - - // Brackets and braces - Gray - applyStyle(pattern: "[\\[\\]\\{\\}]", color: NSColor.secondaryLabelColor) + for (regex, color, bold) in Self.jsonPatterns { + applyStyle(regex, color: color, bold: bold) + } } } diff --git a/Spotier/Config/ConfigDirectoryAccess.swift b/Spotier/Config/ConfigDirectoryAccess.swift new file mode 100644 index 0000000..b689734 --- /dev/null +++ b/Spotier/Config/ConfigDirectoryAccess.swift @@ -0,0 +1,65 @@ +import Foundation + +struct ConfigDirectoryAccess { + private let fileManager: FileManager + + init(fileManager: FileManager = .default) { + self.fileManager = fileManager + } + + func defaultLocalDirectory() -> URL? { + guard let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return nil + } + + let targetDirectory = appSupport + .appendingPathComponent("Spotier", isDirectory: true) + .appendingPathComponent("Configs", isDirectory: true) + + if !fileManager.fileExists(atPath: targetDirectory.path) { + try! fileManager.createDirectory(at: targetDirectory, withIntermediateDirectories: true) + } + + return targetDirectory + } + + func iCloudDriveDirectory() -> URL? { + guard let containerURL = fileManager.url(forUbiquityContainerIdentifier: ICLOUD_CONTAINER_ID) else { + return nil + } + + let targetDirectory = containerURL + .appendingPathComponent("Documents", isDirectory: true) + + if !fileManager.fileExists(atPath: targetDirectory.path) { + try! fileManager.createDirectory(at: targetDirectory, withIntermediateDirectories: true) + } + + return targetDirectory + } + + func legacyICloudDriveDirectory() -> URL? { + guard let containerURL = fileManager.url(forUbiquityContainerIdentifier: ICLOUD_CONTAINER_ID) else { + return nil + } + + return containerURL + .appendingPathComponent("Documents", isDirectory: true) + .appendingPathComponent("Configs", isDirectory: true) + } + + func withScopedAccess(to directoryURL: URL?, operation: (URL) throws -> T) throws -> T { + guard let directoryURL else { + throw ConfigAccessError.missingDirectory + } + + let isScoped = directoryURL.startAccessingSecurityScopedResource() + defer { + if isScoped { + directoryURL.stopAccessingSecurityScopedResource() + } + } + + return try operation(directoryURL) + } +} diff --git a/Spotier/Config/ConfigFileRepository.swift b/Spotier/Config/ConfigFileRepository.swift new file mode 100644 index 0000000..3171048 --- /dev/null +++ b/Spotier/Config/ConfigFileRepository.swift @@ -0,0 +1,40 @@ +import Foundation + +protocol ConfigFileAccessing { + func refreshConfigs() -> [URL] + func readContent(at fileURL: URL) throws -> String + func createConfig(named filename: String, content: String) throws -> URL + func updateContent(at fileURL: URL, content: String) throws + func deleteConfig(at fileURL: URL) +} + +struct ConfigFileRepository: ConfigFileAccessing { + static let shared = ConfigFileRepository() + + private let manager: ConfigManager + + init(manager: ConfigManager = .shared) { + self.manager = manager + } + + @discardableResult + func refreshConfigs() -> [URL] { + manager.refreshConfigs() + } + + func readContent(at fileURL: URL) throws -> String { + try manager.readConfigContent(fileURL) + } + + func createConfig(named filename: String, content: String) throws -> URL { + try manager.createConfig(named: filename, content: content) + } + + func updateContent(at fileURL: URL, content: String) throws { + try manager.updateConfig(fileURL, content: content) + } + + func deleteConfig(at fileURL: URL) { + manager.deleteConfig(fileURL) + } +} diff --git a/Spotier/Config/ConfigTemplateFactory.swift b/Spotier/Config/ConfigTemplateFactory.swift new file mode 100644 index 0000000..44544d4 --- /dev/null +++ b/Spotier/Config/ConfigTemplateFactory.swift @@ -0,0 +1,35 @@ +import Foundation + +enum ConfigTemplateFactory { + static func sanitizedName(from rawName: String) -> String { + let trimmed = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "new-network" : trimmed + } + + static func filename(from rawName: String) -> String { + "\(sanitizedName(from: rawName)).toml" + } + + static func content(for rawName: String, instanceID: UUID = UUID()) -> String { + let safeName = sanitizedName(from: rawName) + + return """ + instance_name = "\(safeName)" + instance_id = "\(instanceID.uuidString.lowercased())" + dhcp = true + listeners = ["tcp://0.0.0.0:11010", "udp://0.0.0.0:11010", "wg://0.0.0.0:11011"] + + [network_identity] + network_name = "easytier" + network_secret = "" + + [[peer]] + uri = "tcp://public.easytier.top:11010" + + [flags] + mtu = 1380 + disable_ipv6 = false + disable_encryption = false + """ + } +} diff --git a/Spotier/ConfigEditorView.swift b/Spotier/ConfigEditorView.swift index e09c9d1..bb58c61 100644 --- a/Spotier/ConfigEditorView.swift +++ b/Spotier/ConfigEditorView.swift @@ -3,6 +3,7 @@ import SwiftUI struct ConfigEditorView: View { @Binding var isPresented: Bool let fileURL: URL + private let configRepository: ConfigFileAccessing = ConfigFileRepository.shared @State private var content: String = "" @State private var originalContent: String = "" @@ -44,7 +45,7 @@ struct ConfigEditorView: View { private func loadContent() { do { - content = try ConfigManager.shared.readConfigContent(fileURL) + content = try configRepository.readContent(at: fileURL) originalContent = content } catch { errorMessage = error.localizedDescription @@ -52,13 +53,8 @@ struct ConfigEditorView: View { } private func saveContent() { - // 获取安全域访问 - let dirURL = ConfigManager.shared.currentDirectory - let isScoped = dirURL?.startAccessingSecurityScopedResource() ?? false - defer { if isScoped { dirURL?.stopAccessingSecurityScopedResource() } } - do { - try content.write(to: fileURL, atomically: true, encoding: .utf8) + try configRepository.updateContent(at: fileURL, content: content) originalContent = content withAnimation { diff --git a/Spotier/ConfigGenerator/ConfigDraftStore.swift b/Spotier/ConfigGenerator/ConfigDraftStore.swift new file mode 100644 index 0000000..65c396c --- /dev/null +++ b/Spotier/ConfigGenerator/ConfigDraftStore.swift @@ -0,0 +1,29 @@ +import Foundation + +final class ConfigDraftStore { + static let shared = ConfigDraftStore() + + private var drafts: [URL?: SpotierConfigModel] = [:] + + private init() {} + + func draft(for url: URL?) -> SpotierConfigModel? { + drafts[url] + } + + func saveDraft(for url: URL?, model: SpotierConfigModel) { + drafts[url] = model + } + + func clearDraft(for url: URL? = nil) { + if let url { + drafts.removeValue(forKey: url) + } else { + drafts.removeValue(forKey: nil) + } + } + + func clearAll() { + drafts.removeAll() + } +} diff --git a/Spotier/ConfigGenerator/ConfigGeneratorBehavior.swift b/Spotier/ConfigGenerator/ConfigGeneratorBehavior.swift new file mode 100644 index 0000000..56abfbe --- /dev/null +++ b/Spotier/ConfigGenerator/ConfigGeneratorBehavior.swift @@ -0,0 +1,48 @@ +import Foundation + +enum CIDRStringBehavior { + static func ip(from value: String) -> String { + let parts = value.split(separator: "/", omittingEmptySubsequences: false).map(String.init) + return parts.isEmpty ? "" : parts[0] + } + + static func mask(from value: String, defaultMask: String = "24") -> String { + let parts = value.split(separator: "/", omittingEmptySubsequences: false).map(String.init) + guard parts.count > 1, !parts[1].isEmpty else { return defaultMask } + return parts[1] + } + + static func updatingIP(_ newIP: String, in value: String, defaultMask: String = "24") -> String { + "\(newIP)/\(mask(from: value, defaultMask: defaultMask))" + } + + static func updatingMask(_ newMask: String, in value: String, defaultMask: String = "24") -> String { + "\(ip(from: value))/\(newMask.isEmpty ? defaultMask : newMask)" + } +} + +enum ConfigGeneratorListBehavior { + static func appended(_ items: [EditableStringItem], value: String = "") -> [EditableStringItem] { + items + [EditableStringItem(value: value)] + } + + static func removing(_ id: EditableStringItem.ID, from items: [EditableStringItem]) -> [EditableStringItem] { + items.filter { $0.id != id } + } + + static func appended(_ items: [SpotierConfigModel.ProxySubnet], cidr: String = "0.0.0.0/0") -> [SpotierConfigModel.ProxySubnet] { + items + [SpotierConfigModel.ProxySubnet(cidr: cidr)] + } + + static func removing(_ id: SpotierConfigModel.ProxySubnet.ID, from items: [SpotierConfigModel.ProxySubnet]) -> [SpotierConfigModel.ProxySubnet] { + items.filter { $0.id != id } + } + + static func appended(_ items: [PortForwardRule], rule: PortForwardRule = PortForwardRule()) -> [PortForwardRule] { + items + [rule] + } + + static func removing(_ id: PortForwardRule.ID, from items: [PortForwardRule]) -> [PortForwardRule] { + items.filter { $0.id != id } + } +} diff --git a/Spotier/ConfigGenerator/ConfigGeneratorForms.swift b/Spotier/ConfigGenerator/ConfigGeneratorForms.swift new file mode 100644 index 0000000..1563ee3 --- /dev/null +++ b/Spotier/ConfigGenerator/ConfigGeneratorForms.swift @@ -0,0 +1,618 @@ +import SwiftUI + +struct ConfigGeneratorAdvancedForm: View { + @Binding var model: SpotierConfigModel + + var body: some View { + Form { + generalSection + overrideDNSSection + proxySubnetSection + vpnPortalSection + listenersSection + relayWhitelistSection + manualRoutesSection + socks5Section + exitNodesSection + mappedListenersSection + featureToggleSection + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + } + + private var generalSection: some View { + SwiftUI.Section(header: Text(LocalizedStringKey("通用"))) { + HStack { + Text(LocalizedStringKey("主机名称")) + TextField(LocalizedStringKey("默认"), text: $model.instanceName) + .multilineTextAlignment(.trailing) + .textFieldStyle(.plain) + .labelsHidden() + .textContentType(.none) + .disableAutocorrection(true) + } + + HStack { + Text("实例 ID") + .fixedSize() + Spacer() + Text(model.instanceId) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .monospaced() + + Button { + model.regenerateInstanceId() + } label: { + Image(systemName: "arrow.triangle.2.circlepath") + } + .buttonStyle(.plain) + .help("重新生成 UUID") + } + + HStack { + Text("MTU") + Spacer() + TextField("默认", value: Binding( + get: { model.mtu == 1380 ? nil : model.mtu }, + set: { model.mtu = $0 ?? 1380 } + ), format: .number.grouping(.never)) + .multilineTextAlignment(.trailing) + .frame(width: 80) + .textFieldStyle(.plain) + .labelsHidden() + .textContentType(.none) + } + } + } + + private var overrideDNSSection: some View { + SwiftUI.Section(header: Text("覆盖 DNS"), footer: Text("覆盖系统 DNS。如果也同时启用了魔法 DNS,需要手动添加。")) { + Toggle("启用", isOn: $model.enableOverrideDns) + if model.enableOverrideDns { + ForEach($model.overrideDns) { $dns in + HStack { + Text("地址") + Spacer() + ConfigGeneratorView.IPv4Field(ip: $dns.value) + .fixedSize() + + Button { + removeEditableString(withID: dns.id, from: \.overrideDns) + } label: { + Image(systemName: "minus.circle.fill") + .foregroundColor(.red) + } + .buttonStyle(.plain) + } + } + + Button { + appendEditableString(to: \.overrideDns) + } label: { + HStack { + Image(systemName: "plus.circle.fill") + Text("添加 DNS") + } + .foregroundColor(.blue) + } + .buttonStyle(.plain) + } + } + } + + private var proxySubnetSection: some View { + SwiftUI.Section(header: Text(LocalizedStringKey("代理网段"))) { + ForEach($model.proxySubnets) { $subnet in + HStack { + Text(LocalizedStringKey("代理:")) + .foregroundColor(.secondary) + Spacer() + ConfigGeneratorView.IPv4CidrField( + ip: Binding( + get: { CIDRStringBehavior.ip(from: subnet.cidr) }, + set: { subnet.cidr = CIDRStringBehavior.updatingIP($0, in: subnet.cidr, defaultMask: "0") } + ), + cidr: Binding( + get: { CIDRStringBehavior.mask(from: subnet.cidr, defaultMask: "0") }, + set: { subnet.cidr = CIDRStringBehavior.updatingMask($0, in: subnet.cidr, defaultMask: "0") } + ) + ) + .fixedSize() + + Button { + model.proxySubnets = ConfigGeneratorListBehavior.removing(subnet.id, from: model.proxySubnets) + } label: { + Image(systemName: "minus.circle.fill") + .foregroundColor(.red) + } + .buttonStyle(.plain) + } + } + Button { + model.proxySubnets = ConfigGeneratorListBehavior.appended(model.proxySubnets, cidr: "0.0.0.0/0") + } label: { + HStack { + Image(systemName: "plus.circle.fill") + Text("添加代理网段") + } + .foregroundColor(.blue) + } + .buttonStyle(.plain) + } + } + + private var vpnPortalSection: some View { + SwiftUI.Section("VPN 门户配置") { + Toggle("启用", isOn: $model.enableVpnPortal) + if model.enableVpnPortal { + HStack { + Text("客户端网段") + Spacer() + ConfigGeneratorView.IPv4CidrField(ip: $model.vpnPortalIpBinding, cidr: $model.vpnPortalCidrBinding) + .fixedSize() + } + HStack { + Text("监听端口") + Spacer() + TextField("22022", value: $model.vpnPortalListenPort, format: .number.grouping(.never)) + .multilineTextAlignment(.trailing) + .frame(width: 80) + .labelsHidden() + .textContentType(.none) + } + } + } + } + + private var listenersSection: some View { + SwiftUI.Section("监听地址") { + ForEach($model.listeners) { $listener in + HStack { + TextField("如:tcp://1.1.1.1:11010", text: $listener.value) + .textFieldStyle(.plain) + .labelsHidden() + .textContentType(.none) + .disableAutocorrection(true) + + Spacer() + + Button { + removeEditableString(withID: listener.id, from: \.listeners) + } label: { + Image(systemName: "minus.circle.fill") + .foregroundColor(.red) + } + .buttonStyle(.plain) + } + } + Button { appendEditableString(to: \.listeners) } label: { + HStack { + Image(systemName: "plus.circle.fill") + Text("添加监听地址") + } + .foregroundColor(.blue) + } + .buttonStyle(.plain) + } + } + + private var relayWhitelistSection: some View { + SwiftUI.Section(header: Text("网络白名单"), footer: Text("仅转发白名单网络的流量,支持通配符字符串。多个网络名称间可以使用英文空格间隔。如果该参数为空,则禁用转发。默认允许所有网络。例如:* (所有网络), def* (以 def 为前缀的网络), net1 net2 (只允许 net1 和 net2)。")) { + Toggle("启用", isOn: $model.enableRelayNetworkWhitelist) + if model.enableRelayNetworkWhitelist { + editableStringListSection(list: $model.relayNetworkWhitelist, placeholder: "CIDR (e.g. 10.0.0.0/24)") + } + } + } + + private var manualRoutesSection: some View { + SwiftUI.Section(header: Text("自定义路由"), footer: Text("手动分配路由 CIDR,将禁用子网代理和从对等节点传播的 wireguard 路由。例如:192.168.0.0/16")) { + Toggle("启用", isOn: $model.enableManualRoutes) + if model.enableManualRoutes { + ForEach($model.manualRoutes) { $route in + HStack { + Text("路由:") + .foregroundColor(.secondary) + Spacer() + ConfigGeneratorView.IPv4CidrField( + ip: cidrIPBinding(for: $route.value), + cidr: cidrMaskBinding(for: $route.value) + ) + .fixedSize() + + Button { + removeEditableString(withID: route.id, from: \.manualRoutes) + } label: { + Image(systemName: "minus.circle.fill") + .foregroundColor(.red) + } + .buttonStyle(.plain) + } + } + Button { appendEditableString("0.0.0.0/0", to: \.manualRoutes) } label: { + HStack { + Image(systemName: "plus.circle.fill") + Text("添加路由") + } + .foregroundColor(.blue) + } + .buttonStyle(.plain) + } + } + } + + private var socks5Section: some View { + SwiftUI.Section(header: Text(LocalizedStringKey("SOCKS5 服务器")), footer: Text(LocalizedStringKey("开启 SOCKS5 代理功能,Surge 等外部程序可通过此端口连接 Swiftier 网络。"))) { + Toggle(LocalizedStringKey("启用"), isOn: $model.enableSocks5) + if model.enableSocks5 { + HStack { + Text(LocalizedStringKey("监听端口")) + Spacer() + TextField("", value: $model.socks5Port, format: .number.grouping(.never)) + .multilineTextAlignment(.trailing) + .frame(width: 80) + .textContentType(.none) + } + } + } + } + + private var exitNodesSection: some View { + SwiftUI.Section(header: Text(LocalizedStringKey("出口节点列表")), footer: Text(LocalizedStringKey("转发所有流量的出口节点,虚拟 IPv4 地址,优先级由列表顺序决定。"))) { + ForEach($model.exitNodes) { $node in + HStack { + Text(LocalizedStringKey("节点:")) + .foregroundColor(.secondary) + Spacer() + ConfigGeneratorView.IPv4Field(ip: $node.value) + .fixedSize() + + Button { + removeEditableString(withID: node.id, from: \.exitNodes) + } label: { + Image(systemName: "minus.circle.fill") + .foregroundColor(.red) + } + .buttonStyle(.plain) + } + } + Button { appendEditableString(to: \.exitNodes) } label: { + HStack { + Image(systemName: "plus.circle.fill") + Text(LocalizedStringKey("添加出口节点")) + } + .foregroundColor(.blue) + } + .buttonStyle(.plain) + } + } + + private var mappedListenersSection: some View { + SwiftUI.Section(header: Text(LocalizedStringKey("监听映射")), footer: Text(LocalizedStringKey("手动指定监听器的公网地址,其他节点可以使用该地址连接到本节点。例如:tcp://123.123.123.123:11223,可以指定多个。"))) { + editableStringListSection(list: $model.mappedListeners, placeholder: "URI (e.g. tcp://...)") + } + } + + private var featureToggleSection: some View { + SwiftUI.Section(header: Text(LocalizedStringKey("功能开关"))) { + toggleRow("延迟优先模式", "忽略中转跳数,选择总延迟最低的路径。", isOn: $model.latencyFirst) + toggleRow("使用用户态协议栈", "使用用户态 TCP/IP 协议栈,避免操作系统防火墙问题导致无法子网代理 / KCP 代理。", isOn: $model.useSmoltcp) + toggleRow("禁用 IPv6", "禁用此节点的 IPv6 功能,仅使用 IPv4 进行网络通信。", isOn: Binding( + get: { !model.enableIPv6 }, + set: { model.enableIPv6 = !$0 } + )) + toggleRow("启用 KCP 代理", "将 TCP 流量转为 KCP 流量,降低传输延迟,提升传输速度。", isOn: $model.enableKcpProxy) + toggleRow("禁用 KCP 输入", "禁用 KCP 入站流量,其他开启 KCP 代理的节点仍然使用 TCP 连接到本节点。", isOn: $model.disableKcpInput) + toggleRow("启用 QUIC 代理", "将 TCP 流量转为 QUIC 流量,降低传输延迟,提升传输速度。", isOn: $model.enableQuicProxy) + toggleRow("禁用 QUIC 输入", "禁用 QUIC 入站流量,其他开启 QUIC 代理的节点仍然使用 TCP 连接到本节点。", isOn: $model.disableQuicInput) + toggleRow("禁用 P2P", "禁用 P2P 模式,所有流量通过手动指定的服务器中转。", isOn: $model.disableP2P) + toggleRow("仅 P2P", "仅与已经建立 P2P 连接的对等节点通信,不通过其他节点中转。", isOn: $model.onlyP2P) + toggleRow("仅使用物理网卡", "仅使用物理网卡,避免 Swiftier 通过其他虚拟网建立连接。", isOn: $model.bindDevice) + toggleRow("无 TUN 模式", "不使用 TUN 网卡,适合无管理员权限时使用。本节点仅允许被访问。访问其他节点需要使用 SOCKS5。", isOn: $model.noTun) + toggleRow("启用出口节点", "允许此节点成为出口节点。", isOn: $model.enableExitNode) + toggleRow("转发 RPC 包", "允许转发所有对等节点的 RPC 数据包,即使对等节点不在转发网络白名单中。这可以帮助白名单外网络中的对等节点建立 P2P 连接。", isOn: $model.relayAllPeerRpc) + toggleRow("启用多线程", "使用多线程运行时。", isOn: $model.multiThread) + toggleRow("系统转发", "通过系统内核转发子网代理数据包,禁用内置 NAT。", isOn: $model.proxyForwardBySystem) + toggleRow("禁用加密", "禁用对等节点通信的加密,默认为 false,必须与对等节点相同。", isOn: Binding( + get: { !model.enableEncryption }, + set: { model.enableEncryption = !$0 } + )) + toggleRow("禁用 UDP 打洞", "禁用 UDP 打洞功能。", isOn: $model.disableUdpHolePunching) + toggleRow("禁用对称 NAT 打洞", "禁用对标 NAT 的打洞 (生日攻击),将对称 NAT 视为锥形 NAT 处理。", isOn: $model.disableSymHolePunching) + toggleRow("启用 Magic DNS", "启用魔法 DNS,允许通过 Swiftier 的 DNS 服务器访问其他节点的虚拟 IPv4 地址,例如:node1.et.net。", isOn: $model.enableMagicDns) + toggleRow("启用私有模式", "启用私有模式,则不允许使用了与本网络不同的网络名称和密码的节点通过本节点进行握手或中转。", isOn: $model.enablePrivateMode) + } + } + + private func editableStringListSection(list: Binding<[EditableStringItem]>, placeholder: String) -> some View { + Group { + ForEach(list) { $item in + HStack { + TextField(placeholder, text: $item.value) + .textFieldStyle(.plain) + .labelsHidden() + .textContentType(.none) + .disableAutocorrection(true) + + Spacer() + + Button { + list.wrappedValue = ConfigGeneratorListBehavior.removing(item.id, from: list.wrappedValue) + } label: { + Image(systemName: "minus.circle.fill") + .foregroundColor(.red) + } + .buttonStyle(.plain) + } + } + Button { list.wrappedValue = ConfigGeneratorListBehavior.appended(list.wrappedValue) } label: { + Label(LocalizedStringKey("添加"), systemImage: "plus.circle.fill").foregroundColor(.blue) + } + .buttonStyle(.plain) + } + } + + private func toggleRow(_ title: LocalizedStringKey, _ subtitle: LocalizedStringKey, isOn: Binding) -> some View { + VStack(alignment: .leading, spacing: 4) { + Toggle(title, isOn: isOn) + Text(subtitle) + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 4) + } + + private func appendEditableString(_ value: String = "", to keyPath: WritableKeyPath) { + model[keyPath: keyPath] = ConfigGeneratorListBehavior.appended(model[keyPath: keyPath], value: value) + } + + private func removeEditableString(withID id: EditableStringItem.ID, from keyPath: WritableKeyPath) { + model[keyPath: keyPath] = ConfigGeneratorListBehavior.removing(id, from: model[keyPath: keyPath]) + } + + private func cidrIPBinding(for value: Binding) -> Binding { + Binding( + get: { + CIDRStringBehavior.ip(from: value.wrappedValue) + }, + set: { newIP in + value.wrappedValue = CIDRStringBehavior.updatingIP(newIP, in: value.wrappedValue) + } + ) + } + + private func cidrMaskBinding(for value: Binding) -> Binding { + Binding( + get: { + CIDRStringBehavior.mask(from: value.wrappedValue) + }, + set: { newCIDR in + value.wrappedValue = CIDRStringBehavior.updatingMask(newCIDR, in: value.wrappedValue) + } + ) + } +} + +struct ConfigGeneratorMainForm: View { + @Binding var model: SpotierConfigModel + let onOpenAdvanced: () -> Void + let onOpenPortForwarding: () -> Void + + var body: some View { + Form { + virtualIPv4Section + networkSection + navigationSection + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + } + + private var virtualIPv4Section: some View { + Section(header: Text(LocalizedStringKey("虚拟 IPv4 地址"))) { + Toggle(LocalizedStringKey("DHCP"), isOn: $model.dhcp) + + if !model.dhcp { + HStack(spacing: 0) { + Text(LocalizedStringKey("地址")) + Spacer() + ConfigGeneratorView.IPv4CidrField(ip: $model.ipv4, cidr: $model.cidr) + .fixedSize() + } + } + } + } + + private var networkSection: some View { + Section(header: Text(LocalizedStringKey("网络"))) { + HStack { + Text(LocalizedStringKey("名称")) + TextField("easytier", text: $model.networkName) + .multilineTextAlignment(.trailing) + .labelsHidden() + .frame(maxWidth: .infinity) + .textContentType(.none) + .disableAutocorrection(true) + } + + HStack { + Text(LocalizedStringKey("密码")) + TextField(LocalizedStringKey("选填"), text: $model.networkSecret) + .multilineTextAlignment(.trailing) + .labelsHidden() + .frame(maxWidth: .infinity) + .textContentType(.none) + .disableAutocorrection(true) + } + + Picker(LocalizedStringKey("节点模式"), selection: $model.peerMode) { + ForEach(PeerMode.allCases) { mode in + Text(mode.localizedTitle).tag(mode) + } + } + .pickerStyle(.segmented) + .padding(.vertical, 4) + + if model.peerMode == .manual { + ForEach($model.manualPeers) { $peer in + HStack { + TextField("tcp://...", text: $peer.value) + .textFieldStyle(.plain) + .labelsHidden() + .textContentType(.none) + .disableAutocorrection(true) + + Spacer() + + Button { + model.manualPeers = ConfigGeneratorListBehavior.removing(peer.id, from: model.manualPeers) + } label: { + Image(systemName: "minus.circle.fill") + .foregroundColor(.red) + } + .buttonStyle(.plain) + } + } + + Button { + model.manualPeers = ConfigGeneratorListBehavior.appended(model.manualPeers, value: "tcp://") + } label: { + HStack { + Image(systemName: "plus.circle.fill") + Text(LocalizedStringKey("添加节点")) + } + .foregroundColor(.blue) + } + .buttonStyle(.plain) + } else if model.peerMode == .publicServer { + HStack { + Text(LocalizedStringKey("服务器")) + Spacer() + Text("tcp://public.easytier.top:11010") + .foregroundColor(.secondary) + .font(.caption) + } + } + } + } + + private var navigationSection: some View { + Section { + Button(action: onOpenAdvanced) { + HStack { + Text(LocalizedStringKey("高级设置")) + Spacer() + Image(systemName: "chevron.right") + .foregroundColor(.secondary) + .font(.caption) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Button(action: onOpenPortForwarding) { + HStack { + Text(LocalizedStringKey("端口转发")) + Spacer() + Image(systemName: "chevron.right") + .foregroundColor(.secondary) + .font(.caption) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } +} + +struct ConfigGeneratorPortForwardingForm: View { + @Binding var model: SpotierConfigModel + + var body: some View { + Form { + ForEach($model.portForwards) { $rule in + Section { + VStack(spacing: 12) { + HStack { + Text(LocalizedStringKey("协议")) + Spacer() + Picker("", selection: $rule.protocolType) { + Text("TCP").tag("TCP") + Text("UDP").tag("UDP") + } + .pickerStyle(.segmented) + .frame(width: 120) + } + + Divider() + + HStack { + Text(LocalizedStringKey("绑定地址")) + Spacer() + ConfigGeneratorView.IPv4Field(ip: $rule.bindIp) + .fixedSize() + Text(":") + TextField("0", text: $rule.bindPort) + .frame(width: 50) + } + .textFieldStyle(.plain) + .labelsHidden() + + HStack { + Spacer() + Image(systemName: "arrow.down") + .font(.caption) + .foregroundColor(.secondary) + Text(LocalizedStringKey("转发到")) + .font(.caption) + .foregroundColor(.secondary) + Spacer() + } + + HStack { + Text(LocalizedStringKey("目标地址")) + Spacer() + ConfigGeneratorView.IPv4Field(ip: $rule.targetIp) + .fixedSize() + Text(":") + TextField("0", text: $rule.targetPort) + .frame(width: 50) + } + .textFieldStyle(.plain) + .labelsHidden() + } + .padding(.vertical, 4) + } header: { + HStack { + Spacer() + Button("删除") { + model.portForwards = ConfigGeneratorListBehavior.removing(rule.id, from: model.portForwards) + } + .font(.caption) + .foregroundColor(.red) + .buttonStyle(.plain) + } + } + } + + Section { + Button { + model.portForwards = ConfigGeneratorListBehavior.appended(model.portForwards) + } label: { + HStack { + Image(systemName: "plus.circle.fill") + Text(LocalizedStringKey("添加端口转发")) + } + .foregroundColor(.blue) + } + .buttonStyle(.plain) + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + } +} diff --git a/Spotier/ConfigGenerator/ConfigGeneratorStore.swift b/Spotier/ConfigGenerator/ConfigGeneratorStore.swift new file mode 100644 index 0000000..32b8f74 --- /dev/null +++ b/Spotier/ConfigGenerator/ConfigGeneratorStore.swift @@ -0,0 +1,65 @@ +import Foundation + +enum ConfigGeneratorStore { + private static let configRepository: ConfigFileAccessing = ConfigFileRepository.shared + + static func loadModel( + editingFileURL: URL?, + forceReset: Bool, + currentModel: SpotierConfigModel, + lastLoadedURL: URL? + ) -> (model: SpotierConfigModel, lastLoadedURL: URL?) { + if forceReset { + ConfigDraftStore.shared.clearDraft(for: editingFileURL) + return (modelForFile(editingFileURL), editingFileURL) + } + + if let draft = ConfigDraftStore.shared.draft(for: editingFileURL) { + return (draft, editingFileURL) + } + + guard editingFileURL != lastLoadedURL else { + return (currentModel, lastLoadedURL) + } + + return (modelForFile(editingFileURL), editingFileURL) + } + + static func save(model: SpotierConfigModel, editingFileURL: URL?) throws { + let peers = peersToSave(for: model) + let content = SpotierConfigCodec.generate(from: model, peers: peers) + + if let editingFileURL { + try configRepository.updateContent(at: editingFileURL, content: content) + } else { + let name = model.instanceName.isEmpty ? "easytier.toml" : "\(model.instanceName).toml" + _ = try configRepository.createConfig(named: name, content: content) + } + + ConfigDraftStore.shared.clearDraft(for: editingFileURL) + } + + static func saveDraft(_ model: SpotierConfigModel, editingFileURL: URL?) { + ConfigDraftStore.shared.saveDraft(for: editingFileURL, model: model) + } + + static func clearDraft(editingFileURL: URL?) { + ConfigDraftStore.shared.clearDraft(for: editingFileURL) + } + + private static func modelForFile(_ editingFileURL: URL?) -> SpotierConfigModel { + guard let editingFileURL else { return SpotierConfigModel() } + return SpotierConfigCodec.parse(try! configRepository.readContent(at: editingFileURL)) + } + + private static func peersToSave(for model: SpotierConfigModel) -> [String] { + switch model.peerMode { + case .publicServer: + return ["tcp://public.easytier.top:11010"] + case .manual: + return model.manualPeers.values + case .standalone: + return [] + } + } +} diff --git a/Spotier/ConfigGenerator/SpotierConfigCodec.swift b/Spotier/ConfigGenerator/SpotierConfigCodec.swift new file mode 100644 index 0000000..4825975 --- /dev/null +++ b/Spotier/ConfigGenerator/SpotierConfigCodec.swift @@ -0,0 +1,285 @@ +import Foundation + +enum SpotierConfigCodec { + static func parse(_ content: String) -> SpotierConfigModel { + var model = SpotierConfigModel() + let lines = content.components(separatedBy: .newlines) + var currentSection = "" + + model.manualPeers = [] + model.listeners = [] + model.mappedListeners = [] + model.relayNetworkWhitelist = [] + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } + + if trimmed.hasPrefix("[") { + if trimmed.hasPrefix("[[") { + currentSection = String(trimmed.dropFirst(2).dropLast(2)) + if currentSection == "proxy_network" { model.proxySubnets.append(SpotierConfigModel.ProxySubnet()) } + if currentSection == "port_forward" { model.portForwards.append(PortForwardRule()) } + } else { + currentSection = String(trimmed.dropFirst().dropLast()) + if currentSection == "vpn_portal_config" { model.enableVpnPortal = true } + } + continue + } + + let parts = trimmed.split(separator: "=", maxSplits: 1).map(String.init) + if parts.count != 2 { continue } + + let key = parts[0].trimmingCharacters(in: .whitespaces) + var val = parts[1].trimmingCharacters(in: .whitespaces) + if val.hasPrefix("\"") && val.hasSuffix("\"") { + val = String(val.dropFirst().dropLast()) + } + + if currentSection == "" || currentSection == "network_identity" || currentSection == "flags" { + switch key { + case "instance_name": + model.instanceName = val + case "instance_id": + model.instanceId = val + case "ipv4": + let components = val.split(separator: "/") + if components.count == 2 { + model.ipv4 = String(components[0]) + model.cidr = String(components[1]) + } + case "dhcp": + model.dhcp = (val == "true") + case "mtu": + model.mtu = Int(val) ?? 1380 + case "network_name": + model.networkName = val + case "network_secret": + model.networkSecret = val + case "listeners": + model.listeners = parseStringArray(val) + case "mapped_listeners": + model.mappedListeners = parseStringArray(val) + case "socks5_proxy": + model.enableSocks5 = true + if let portStr = val.split(separator: ":").last, let port = Int(portStr) { + model.socks5Port = port + } + case "exit_nodes": + model.exitNodes = parseStringArray(val) + case "routes": + model.enableManualRoutes = true + model.manualRoutes = parseStringArray(val) + case "latency_first": + model.latencyFirst = (val == "true") + case "disable_ipv6": + model.enableIPv6 = (val != "true") + case "disable_encryption": + model.enableEncryption = (val != "true") + case "use_smoltcp": + model.useSmoltcp = (val == "true") + case "no_tun": + model.noTun = (val == "true") + case "disable_p2p": + model.disableP2P = (val == "true") + case "p2p_only": + model.onlyP2P = (val == "true") + case "disable_udp_hole_punching": + model.disableUdpHolePunching = (val == "true") + case "enable_exit_node": + model.enableExitNode = (val == "true") + case "bind_device": + model.bindDevice = (val == "true") + case "enable_kcp_proxy": + model.enableKcpProxy = (val == "true") + case "disable_kcp_input": + model.disableKcpInput = (val == "true") + case "enable_quic_proxy": + model.enableQuicProxy = (val == "true") + case "disable_quic_input": + model.disableQuicInput = (val == "true") + case "relay_all_peer_rpc": + model.relayAllPeerRpc = (val == "true") + case "multi_thread": + model.multiThread = (val == "true") + case "proxy_forward_by_system": + model.proxyForwardBySystem = (val == "true") + case "disable_sym_hole_punching": + model.disableSymHolePunching = (val == "true") + case "enable_magic_dns": + model.enableMagicDns = (val == "true") + case "enable_private_mode": + model.enablePrivateMode = (val == "true") + case "relay_network_whitelist": + model.enableRelayNetworkWhitelist = true + model.relayNetworkWhitelist = .init(values: val.replacingOccurrences(of: "\"", with: "").components(separatedBy: " ").filter { !$0.isEmpty }) + default: + break + } + } else if currentSection == "peer" { + if key == "uri" { + model.manualPeers.append(EditableStringItem(value: val)) + model.peerMode = .manual + } + } else if currentSection == "vpn_portal_config" { + if key == "client_cidr" { model.vpnPortalClientCidr = val } + if key == "wireguard_listen", + let portStr = val.split(separator: ":").last, + let port = Int(portStr) { + model.vpnPortalListenPort = port + } + } else if currentSection == "proxy_network" { + if !model.proxySubnets.isEmpty { + var last = model.proxySubnets.removeLast() + if key == "cidr" { last.cidr = val } + model.proxySubnets.append(last) + } + } else if currentSection == "port_forward" { + if !model.portForwards.isEmpty { + var last = model.portForwards.removeLast() + if key == "proto" { last.protocolType = val.uppercased() } + if key == "bind_addr" { + let components = val.split(separator: ":") + if components.count >= 2 { + last.bindPort = String(components.last!) + last.bindIp = components.dropLast().joined(separator: ":") + } + } + if key == "dst_addr" { + let components = val.split(separator: ":") + if components.count >= 2 { + last.targetPort = String(components.last!) + last.targetIp = components.dropLast().joined(separator: ":") + } + } + model.portForwards.append(last) + } + } + } + + if model.listeners.isEmpty { + model.listeners = .init(values: SpotierConfigModel.defaultListenerValues) + } + return model + } + + static func generate(from model: SpotierConfigModel, peers: [String]) -> String { + var toml = """ + instance_name = "\(model.instanceName)" + instance_id = "\(model.instanceId)" + dhcp = \(model.dhcp) + """ + + let listeners = model.listeners.values.filter { !$0.isEmpty } + if !listeners.isEmpty { + toml += "\nlisteners = [\(quotedList(listeners))]" + } + + let mappedListeners = model.mappedListeners.values.filter { !$0.isEmpty } + if !mappedListeners.isEmpty { + toml += "\nmapped_listeners = [\(quotedList(mappedListeners))]" + } + + if !model.dhcp && !model.ipv4.isEmpty { + toml += "\nipv4 = \"\(model.ipv4)/\(model.cidr)\"" + } + + if model.enableSocks5 { + toml += "\nsocks5_proxy = \"socks5://0.0.0.0:\(model.socks5Port)\"" + } + + let exitNodes = model.exitNodes.values.filter { !$0.isEmpty } + if !exitNodes.isEmpty { + toml += "\nexit_nodes = [\(quotedList(exitNodes))]" + } + + let manualRoutes = model.manualRoutes.values.filter { !$0.isEmpty } + if model.enableManualRoutes && !manualRoutes.isEmpty { + toml += "\nroutes = [\(quotedList(manualRoutes))]" + } + + toml += """ + + [network_identity] + network_name = "\(model.networkName)" + network_secret = "\(model.networkSecret)" + """ + + for peer in peers where !peer.isEmpty { + toml += "\n\n[[peer]]\nuri = \"\(peer)\"" + } + + var flags = "" + flags += "\nmtu = \(model.mtu)" + if model.latencyFirst { flags += "\nlatency_first = true" } + if !model.enableIPv6 { flags += "\ndisable_ipv6 = true" } + if !model.enableEncryption { flags += "\ndisable_encryption = true" } + if model.useSmoltcp { flags += "\nuse_smoltcp = true" } + if model.noTun { flags += "\nno_tun = true" } + if model.disableP2P { flags += "\ndisable_p2p = true" } + if model.onlyP2P { flags += "\np2p_only = true" } + if model.disableUdpHolePunching { flags += "\ndisable_udp_hole_punching = true" } + if model.enableExitNode { flags += "\nenable_exit_node = true" } + if model.enableKcpProxy { flags += "\nenable_kcp_proxy = true" } + if model.disableKcpInput { flags += "\ndisable_kcp_input = true" } + if model.enableQuicProxy { flags += "\nenable_quic_proxy = true" } + if model.disableQuicInput { flags += "\ndisable_quic_input = true" } + if model.relayAllPeerRpc { flags += "\nrelay_all_peer_rpc = true" } + if model.bindDevice { flags += "\nbind_device = true" } + flags += "\nmulti_thread = \(model.multiThread)" + if model.proxyForwardBySystem { flags += "\nproxy_forward_by_system = true" } + if model.disableSymHolePunching { flags += "\ndisable_sym_hole_punching = true" } + if model.enableMagicDns { flags += "\nenable_magic_dns = true" } + if model.enablePrivateMode { flags += "\nenable_private_mode = true" } + + let relayWhitelist = model.relayNetworkWhitelist.values.filter { !$0.isEmpty } + if model.enableRelayNetworkWhitelist && !relayWhitelist.isEmpty { + flags += "\nrelay_network_whitelist = \"\(relayWhitelist.joined(separator: " "))\"" + } + + if !flags.isEmpty { + toml += "\n\n[flags]" + flags + } + + if model.enableVpnPortal { + toml += """ + + [vpn_portal_config] + client_cidr = "\(model.vpnPortalClientCidr)" + wireguard_listen = "0.0.0.0:\(model.vpnPortalListenPort)" + """ + } + + for subnet in model.proxySubnets where !subnet.cidr.isEmpty { + toml += """ + + [[proxy_network]] + cidr = "\(subnet.cidr)" + """ + } + + for rule in model.portForwards where !rule.bindPort.isEmpty && !rule.targetPort.isEmpty { + toml += """ + + [[port_forward]] + proto = "\(rule.protocolType.lowercased())" + bind_addr = "\(rule.bindIp.isEmpty ? "0.0.0.0" : rule.bindIp):\(rule.bindPort)" + dst_addr = "\(rule.targetIp):\(rule.targetPort)" + """ + } + + return toml + } + + private static func parseStringArray(_ value: String) -> [EditableStringItem] { + guard value.hasPrefix("[") && value.hasSuffix("]") else { return [] } + let inner = value.dropFirst().dropLast() + return .init(values: inner.split(separator: ",").map { + $0.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: "\"", with: "") + }) + } + + private static func quotedList(_ values: [String]) -> String { + values.map { "\"\($0)\"" }.joined(separator: ", ") + } +} diff --git a/Spotier/ConfigGenerator/SpotierConfigDomain.swift b/Spotier/ConfigGenerator/SpotierConfigDomain.swift new file mode 100644 index 0000000..5d90447 --- /dev/null +++ b/Spotier/ConfigGenerator/SpotierConfigDomain.swift @@ -0,0 +1,149 @@ +import Foundation +import SwiftUI + +struct PortForwardRule: Identifiable, Equatable { + let id = UUID() + var protocolType: String = "TCP" + var bindIp: String = "0.0.0.0" + var bindPort: String = "" + var targetIp: String = "10.126.126.1" + var targetPort: String = "" +} + +struct EditableStringItem: Identifiable, Equatable { + let id: UUID + var value: String + + init(id: UUID = UUID(), value: String = "") { + self.id = id + self.value = value + } +} + +struct SpotierConfigModel: Equatable { + static let defaultListenerValues = [ + "tcp://0.0.0.0:11010", + "udp://0.0.0.0:11010", + "wg://0.0.0.0:11011" + ] + + var instanceName: String = Host.current().localizedName! + var instanceId: String = UUID().uuidString.lowercased() + + mutating func regenerateInstanceId() { + instanceId = UUID().uuidString.lowercased() + } + + var dhcp: Bool = true + var ipv4: String = "10.126.126.4" + var cidr: String = "24" + var mtu: Int = 1380 + + var networkName: String = "easytier" + var networkSecret: String = "" + + var peerMode: PeerMode = .publicServer + var manualPeers: [EditableStringItem] = [ + EditableStringItem(value: "tcp://public.easytier.top:11010") + ] + + var listeners: [EditableStringItem] = Array(values: SpotierConfigModel.defaultListenerValues) + + var portForwards: [PortForwardRule] = [] + + var latencyFirst: Bool = false + var enableIPv6: Bool = true + var enableEncryption: Bool = true + var useSmoltcp: Bool = false + var noTun: Bool = false + var disableP2P: Bool = false + var disableUdpHolePunching: Bool = false + var enableExitNode: Bool = false + var enableKcpProxy: Bool = false + var enableQuicProxy: Bool = false + var rpcPort: Int = 15888 + + var disableKcpInput: Bool = false + var disableQuicInput: Bool = false + var disableUdp: Bool = false + var relayAllPeerRpc: Bool = false + var disableEntryNode: Bool = false + var enableSocks5: Bool = false + var socks5Port: Int = 1080 + var foreignNetworkWhitelist: String = "" + + var enableVpnPortal: Bool = false + var vpnPortalClientCidr: String = "10.14.14.0/24" + var vpnPortalListenPort: Int = 22022 + + struct ProxySubnet: Identifiable, Equatable { + let id = UUID() + var cidr: String = "192.168.1.0/24" + } + var proxySubnets: [ProxySubnet] = [] + + var enableManualRoutes: Bool = false + var manualRoutes: [EditableStringItem] = [] + var exitNodes: [EditableStringItem] = [] + var enableRelayNetworkWhitelist: Bool = false + var relayNetworkWhitelist: [EditableStringItem] = [] + var mappedListeners: [EditableStringItem] = [] + var enableOverrideDns: Bool = false + var overrideDns: [EditableStringItem] = [] + + var bindDevice: Bool = false + var multiThread: Bool = true + var proxyForwardBySystem: Bool = false + var disableSymHolePunching: Bool = false + var enableMagicDns: Bool = false + var enablePrivateMode: Bool = false + var onlyP2P: Bool = false +} + +extension SpotierConfigModel { + var vpnPortalIpBinding: String { + get { + CIDRStringBehavior.ip(from: vpnPortalClientCidr) + } + set { + vpnPortalClientCidr = CIDRStringBehavior.updatingIP(newValue, in: vpnPortalClientCidr) + } + } + + var vpnPortalCidrBinding: String { + get { + CIDRStringBehavior.mask(from: vpnPortalClientCidr) + } + set { + vpnPortalClientCidr = CIDRStringBehavior.updatingMask(newValue, in: vpnPortalClientCidr) + } + } +} + +extension Array where Element == EditableStringItem { + init(values: [String]) { + self = values.map { EditableStringItem(value: $0) } + } + + var values: [String] { + map(\.value) + } +} + +enum PeerMode: String, CaseIterable, Identifiable { + case publicServer = "公共服务器" + case manual = "手动" + case standalone = "独立" + + var id: String { rawValue } + + var localizedTitle: LocalizedStringKey { + LocalizedStringKey(rawValue) + } +} + +enum ConfigScreen { + case main + case advanced + case portForwarding +} diff --git a/Spotier/ConfigGeneratorView.swift b/Spotier/ConfigGeneratorView.swift index 66ea201..4055fa4 100644 --- a/Spotier/ConfigGeneratorView.swift +++ b/Spotier/ConfigGeneratorView.swift @@ -1,205 +1,14 @@ import SwiftUI import AppKit -// MARK: - Configuration Models - -struct PortForwardRule: Identifiable, Equatable { - let id = UUID() - var protocolType: String = "TCP" // TCP/UDP - var bindIp: String = "0.0.0.0" - var bindPort: String = "" - var targetIp: String = "10.126.126.1" - var targetPort: String = "" -} - -struct SpotierConfigModel: Equatable { - var instanceName: String = Host.current().localizedName ?? "swiftier-node" - var instanceId: String = UUID().uuidString.lowercased() - - mutating func regenerateInstanceId() { - instanceId = UUID().uuidString.lowercased() - } - - var dhcp: Bool = true - var ipv4: String = "10.126.126.4" - var cidr: String = "24" - var mtu: Int = 1380 - - var networkName: String = "easytier" - var networkSecret: String = "" - - var peerMode: PeerMode = .publicServer - var manualPeers: [String] = [ - "tcp://public.easytier.top:11010" - ] - - var listeners: [String] = [ - "tcp://0.0.0.0:11010", - "udp://0.0.0.0:11010", - "wg://0.0.0.0:11011" - ] - - var portForwards: [PortForwardRule] = [] - - // Flags - var latencyFirst: Bool = false - var enableIPv6: Bool = true - var enableEncryption: Bool = true - var useSmoltcp: Bool = false - var noTun: Bool = false - // var privateMode: Bool = false // Removed as not clearly in latest screenshot or redundant - var disableP2P: Bool = false - var disableUdpHolePunching: Bool = false - var enableExitNode: Bool = false - var enableKcpProxy: Bool = false - var enableQuicProxy: Bool = false - var rpcPort: Int = 15888 - - // New Flags from Screenshot - var disableKcpInput: Bool = false - var disableQuicInput: Bool = false - var disableUdp: Bool = false - var relayAllPeerRpc: Bool = false - var disableEntryNode: Bool = false // 禁用入口节点 - var enableSocks5: Bool = false // SOCKS5 - var socks5Port: Int = 1080 - var foreignNetworkWhitelist: String = "" // 网络白名单? Toggle only for now - - // MARK: - Advanced Features (Missing from previous version) - - // VPN Portal - var enableVpnPortal: Bool = false - var vpnPortalClientCidr: String = "10.14.14.0/24" // Default - var vpnPortalListenPort: Int = 22022 - - // Proxy Networks (Subnet Proxy) - struct ProxySubnet: Identifiable, Equatable { - let id = UUID() - var cidr: String = "192.168.1.0/24" - } - var proxySubnets: [ProxySubnet] = [] - - // Manual Routes - var enableManualRoutes: Bool = false - var manualRoutes: [String] = [] - - // Exit Nodes (Use explicit nodes) - var exitNodes: [String] = [] - - // Relay Network Whitelist - var enableRelayNetworkWhitelist: Bool = false - var relayNetworkWhitelist: [String] = [] - - // Listener Mappings - var mappedListeners: [String] = [] - - // DNS - var enableOverrideDns: Bool = false - var overrideDns: [String] = [] - - // Flags - var bindDevice: Bool = false - var multiThread: Bool = true - var proxyForwardBySystem: Bool = false - var disableSymHolePunching: Bool = false - var enableMagicDns: Bool = false - var enablePrivateMode: Bool = false - var onlyP2P: Bool = false -} - -extension SpotierConfigModel { - var vpnPortalIpBinding: String { - get { - let parts = vpnPortalClientCidr.split(separator: "/") - return parts.count > 0 ? String(parts[0]) : "" - } - set { - let cidr = vpnPortalCidrBinding - vpnPortalClientCidr = "\(newValue)/\(cidr)" - } - } - - var vpnPortalCidrBinding: String { - get { - let parts = vpnPortalClientCidr.split(separator: "/") - return parts.count > 1 ? String(parts[1]) : "24" - } - set { - let ip = vpnPortalIpBinding - vpnPortalClientCidr = "\(ip)/\(newValue)" - } - } -} - -enum PeerMode: String, CaseIterable, Identifiable { - case publicServer = "公共服务器" - case manual = "手动" - case standalone = "独立" - var id: String { rawValue } - - var localizedTitle: LocalizedStringKey { - LocalizedStringKey(rawValue) - } -} -// MARK: - Draft Manager -class ConfigDraftManager { - static let shared = ConfigDraftManager() - - // Support multiple drafts for different files - // Key nil represents "New Config" draft - private var drafts: [URL?: SpotierConfigModel] = [:] - - func getDraft(for url: URL?) -> SpotierConfigModel? { - return drafts[url] - } - - func saveDraft(for url: URL?, model: SpotierConfigModel) { - drafts[url] = model - } - - func clearDraft(for url: URL? = nil) { - // If specific URL provided, clear that. - // NOTE: Our previous usage was clearDraft() implying current. - // We should update call sites to pass URL or handle "current" context if needed. - // But wait, clearDraft is called after save. We know the URL there. - // So we should change the signature to require URL or context. - // However, to keep it simple and compatible with the single-draft thought process: - // Actually, let's just make it clear specific draft. - if let url = url { - drafts.removeValue(forKey: url) - } else { - // If nil passed, clear "New Config" draft (key nil) - drafts.removeValue(forKey: nil) - } - } - - // Helper to clear all if needed (optional) - func clearAll() { - drafts.removeAll() - } -} - -enum ConfigScreen { - case main - case advanced - case portForwarding -} - struct ConfigGeneratorView: View { @Binding var isPresented: Bool var editingFileURL: URL? = nil // 支持传入文件进行编辑 var onSave: () -> Void @State private var model = SpotierConfigModel() - - // Track if we've already loaded to avoid resetting user edits - @State private var hasLoadedInitially = false @State private var lastLoadedURL: URL? = nil - - // Alerts - @State private var saveMessage: String? - @State private var showSaveError = false - + // Navigation @State private var path: [ConfigScreen] = [.main] @@ -224,470 +33,45 @@ struct ConfigGeneratorView: View { } } .animation(.default, value: path.last) - .animation(.default, value: path.last) - .animation(.default, value: path.last) .onAppear { - // Initial load (e.g. preview or first render) - // If already visible, don't force reset unless necessary logic dictates - if isPresented && !hasLoadedInitially { - loadContent(forceReset: true) - hasLoadedInitially = true + if isPresented { + loadContent(forceReset: true) } } .onChange(of: editingFileURL) { _ in - // File changed underneath (unlikely in modal) but handle it - loadContent(forceReset: true) + loadContent(forceReset: true) } .onChange(of: isPresented) { presented in if presented { - // Fresh session: Always clear old drafts and load from disk/new loadContent(forceReset: true) } } // Save draft on every change .onChange(of: model) { newModel in if isPresented { - ConfigDraftManager.shared.saveDraft(for: editingFileURL, model: newModel) + ConfigGeneratorStore.saveDraft(newModel, editingFileURL: editingFileURL) } } } private func loadContent(forceReset: Bool = false) { - if forceReset { - ConfigDraftManager.shared.clearDraft(for: editingFileURL) - self.lastLoadedURL = nil // Force reload check - } - - // 1. Try to restore draft (memory cache) - if !forceReset, let draft = ConfigDraftManager.shared.getDraft(for: editingFileURL) { - // Only restore if our current model is different - if model != draft { - self.model = draft - } - self.lastLoadedURL = editingFileURL - return - } - - // 2. If no draft, load from file if needed - // We load if: - // - We haven't loaded this URL yet (lastLoadedURL != editingFileURL) - // - Or editingFileURL is nil (New Config) AND we want to ensure fresh start if no draft - if editingFileURL != lastLoadedURL { - if editingFileURL == nil { - // New file without draft -> Reset - model = SpotierConfigModel() - } else { - loadFromFile() - } - lastLoadedURL = editingFileURL - } + let result = ConfigGeneratorStore.loadModel( + editingFileURL: editingFileURL, + forceReset: forceReset, + currentModel: model, + lastLoadedURL: lastLoadedURL + ) + model = result.model + lastLoadedURL = result.lastLoadedURL } // MARK: - Advanced View var advancedView: some View { VStack(spacing: 0) { header(title: LocalizedStringKey("高级设置"), leftBtn: LocalizedStringKey("返回"), leftRole: .cancel) { pop() } - - Form { - // 1. 通用 (General) - SwiftUI.Section(header: Text(LocalizedStringKey("通用"))) { - HStack { - Text(LocalizedStringKey("主机名称")) - TextField(LocalizedStringKey("默认"), text: $model.instanceName) - .multilineTextAlignment(.trailing) - .textFieldStyle(.plain) - .labelsHidden() - .textContentType(.none) - .disableAutocorrection(true) - } - - HStack { - Text("实例 ID") - .fixedSize() - Spacer() - Text(model.instanceId) - .foregroundColor(.secondary) - .lineLimit(1) - .truncationMode(.middle) - .monospaced() - - Button { - model.regenerateInstanceId() - } label: { - Image(systemName: "arrow.triangle.2.circlepath") - } - .buttonStyle(.plain) - .help("重新生成 UUID") - } - - HStack { - Text("MTU") - Spacer() - TextField("默认", value: Binding( - get: { self.model.mtu == 1380 ? nil : self.model.mtu }, - set: { self.model.mtu = $0 ?? 1380 } - ), format: .number.grouping(.never)) - .multilineTextAlignment(.trailing) - .frame(width: 80) - .textFieldStyle(.plain) - .labelsHidden() - .textContentType(.none) - } - } - - // 2. 覆盖 DNS (Override DNS) - SwiftUI.Section(header: Text("覆盖 DNS"), footer: Text("覆盖系统 DNS。如果也同时启用了魔法 DNS,需要手动添加。")) { - Toggle("启用", isOn: $model.enableOverrideDns) - if model.enableOverrideDns { - ForEach($model.overrideDns.indices, id: \.self) { i in - HStack { - Text("地址") - Spacer() - IPv4Field(ip: Binding( - get: { - if i < model.overrideDns.count { return model.overrideDns[i] } - return "" - }, - set: { val in - if i < model.overrideDns.count { model.overrideDns[i] = val } - } - )) - .fixedSize() - - Button { - if model.overrideDns.indices.contains(i) { - model.overrideDns.remove(at: i) - } - } label: { - Image(systemName: "minus.circle.fill") - .foregroundColor(.red) - } - .buttonStyle(.plain) - } - } - - Button { - model.overrideDns.append("") - } label: { - HStack { - Image(systemName: "plus.circle.fill") - Text("添加 DNS") - } - .foregroundColor(.blue) - } - .buttonStyle(.plain) - } - } - - // 3. 代理网段 (Proxy CIDR) - SwiftUI.Section(header: Text(LocalizedStringKey("代理网段"))) { - ForEach($model.proxySubnets) { $subnet in - HStack { - Text(LocalizedStringKey("代理:")) - .foregroundColor(.secondary) - Spacer() - IPv4CidrField(ip: Binding( - get: { subnet.cidr.split(separator: "/").first.map(String.init) ?? "" }, - set: { - let oldCidr = subnet.cidr.split(separator: "/").last.map(String.init) ?? "0" - subnet.cidr = "\($0)/\(oldCidr)" - } - ), cidr: Binding( - get: { subnet.cidr.split(separator: "/").last.map(String.init) ?? "" }, - set: { - let oldIp = subnet.cidr.split(separator: "/").first.map(String.init) ?? "" - subnet.cidr = "\(oldIp)/\($0)" - } - )) - .fixedSize() - - Button { - if let idx = model.proxySubnets.firstIndex(of: subnet) { - model.proxySubnets.remove(at: idx) - } - } label: { - Image(systemName: "minus.circle.fill") - .foregroundColor(.red) - } - .buttonStyle(.plain) - } - } - Button { model.proxySubnets.append(SpotierConfigModel.ProxySubnet(cidr: "0.0.0.0/0")) } label: { - HStack { - Image(systemName: "plus.circle.fill") - Text("添加代理网段") - } - .foregroundColor(.blue) - }.buttonStyle(.plain) - } - - // 4. VPN 门户配置 (VPN Portal) - SwiftUI.Section("VPN 门户配置") { - Toggle("启用", isOn: $model.enableVpnPortal) - if model.enableVpnPortal { - HStack { - Text("客户端网段") - Spacer() - IPv4CidrField(ip: $model.vpnPortalIpBinding, cidr: $model.vpnPortalCidrBinding) - .fixedSize() - } - HStack { - Text("监听端口") - Spacer() - TextField("22022", value: $model.vpnPortalListenPort, format: .number.grouping(.never)) - .multilineTextAlignment(.trailing) - .frame(width: 80) - .labelsHidden() - .textContentType(.none) - } - } - } - - // 5. 监听地址 (Listening Addresses) - // 5. 监听地址 (Listening Addresses) - SwiftUI.Section("监听地址") { - ForEach($model.listeners.indices, id: \.self) { i in - HStack { - TextField("如:tcp://1.1.1.1:11010", text: Binding( - get: { model.listeners[i] }, - set: { model.listeners[i] = $0 } - )) - .textFieldStyle(.plain) - .labelsHidden() - .textContentType(.none) - .disableAutocorrection(true) - - Spacer() - - Button { - model.listeners.remove(at: i) - } label: { - Image(systemName: "minus.circle.fill") - .foregroundColor(.red) - } - .buttonStyle(.plain) - } - } - Button { model.listeners.append("") } label: { - HStack { - Image(systemName: "plus.circle.fill") - Text("添加监听地址") - } - .foregroundColor(.blue) - }.buttonStyle(.plain) - } - - // 6. 网络白名单 (Network Whitelist) - SwiftUI.Section(header: Text("网络白名单"), footer: Text("仅转发白名单网络的流量,支持通配符字符串。多个网络名称间可以使用英文空格间隔。如果该参数为空,则禁用转发。默认允许所有网络。例如:* (所有网络), def* (以 def 为前缀的网络), net1 net2 (只允许 net1 和 net2)。")) { - Toggle("启用", isOn: $model.enableRelayNetworkWhitelist) - if model.enableRelayNetworkWhitelist { - stringListSection(list: $model.relayNetworkWhitelist, placeholder: "CIDR (e.g. 10.0.0.0/24)") - } - } - - // 7. 自定义路由 (Custom Routes) - SwiftUI.Section(header: Text("自定义路由"), footer: Text("手动分配路由 CIDR,将禁用子网代理和从对等节点传播的 wireguard 路由。例如:192.168.0.0/16")) { - Toggle("启用", isOn: $model.enableManualRoutes) - if model.enableManualRoutes { - ForEach($model.manualRoutes.indices, id: \.self) { i in - HStack { - Text("路由:") - .foregroundColor(.secondary) - Spacer() - IPv4CidrField(ip: Binding( - get: { model.manualRoutes[i].split(separator: "/").first.map(String.init) ?? "" }, - set: { - let oldCidr = model.manualRoutes[i].split(separator: "/").last.map(String.init) ?? "24" - model.manualRoutes[i] = "\($0)/\(oldCidr)" - } - ), cidr: Binding( - get: { model.manualRoutes[i].split(separator: "/").last.map(String.init) ?? "24" }, - set: { - let oldIp = model.manualRoutes[i].split(separator: "/").first.map(String.init) ?? "" - model.manualRoutes[i] = "\(oldIp)/\($0)" - } - )) - .fixedSize() - - Button { - model.manualRoutes.remove(at: i) - } label: { - Image(systemName: "minus.circle.fill") - .foregroundColor(.red) - } - .buttonStyle(.plain) - } - } - Button { model.manualRoutes.append("0.0.0.0/0") } label: { - HStack { - Image(systemName: "plus.circle.fill") - Text("添加路由") - } - .foregroundColor(.blue) - }.buttonStyle(.plain) - } - } - - // 8. SOCKS5 服务器 (SOCKS5 Server) - SwiftUI.Section(header: Text(LocalizedStringKey("SOCKS5 服务器")), footer: Text(LocalizedStringKey("开启 SOCKS5 代理功能,Surge 等外部程序可通过此端口连接 Swiftier 网络。"))) { - Toggle(LocalizedStringKey("启用"), isOn: $model.enableSocks5) - if model.enableSocks5 { - HStack { - Text(LocalizedStringKey("监听端口")) - Spacer() - TextField("", value: $model.socks5Port, format: .number.grouping(.never)) - .multilineTextAlignment(.trailing) - .frame(width: 80) - .textContentType(.none) - } - } - } - - // 9. 出口节点列表 (Exit Nodes) - SwiftUI.Section(header: Text(LocalizedStringKey("出口节点列表")), footer: Text(LocalizedStringKey("转发所有流量的出口节点,虚拟 IPv4 地址,优先级由列表顺序决定。"))) { - ForEach($model.exitNodes.indices, id: \.self) { i in - HStack { - Text(LocalizedStringKey("节点:")) - .foregroundColor(.secondary) - Spacer() - IPv4Field(ip: Binding( - get: { model.exitNodes[i] }, - set: { model.exitNodes[i] = $0 } - )) - .fixedSize() - - Button { - model.exitNodes.remove(at: i) - } label: { - Image(systemName: "minus.circle.fill") - .foregroundColor(.red) - } - .buttonStyle(.plain) - } - } - Button { model.exitNodes.append("") } label: { - HStack { - Image(systemName: "plus.circle.fill") - Text(LocalizedStringKey("添加出口节点")) - } - .foregroundColor(.blue) - }.buttonStyle(.plain) - } - - // 10. 监听映射 (Listener Mapping) - SwiftUI.Section(header: Text(LocalizedStringKey("监听映射")), footer: Text(LocalizedStringKey("手动指定监听器的公网地址,其他节点可以使用该地址连接到本节点。例如:tcp://123.123.123.123:11223,可以指定多个。"))) { - stringListSection(list: $model.mappedListeners, placeholder: "URI (e.g. tcp://...)") - } - - // 11. 功能开关 (Feature Toggles) - SwiftUI.Section(header: Text(LocalizedStringKey("功能开关"))) { - toggleRow("延迟优先模式", "忽略中转跳数,选择总延迟最低的路径。", isOn: $model.latencyFirst) - toggleRow("使用用户态协议栈", "使用用户态 TCP/IP 协议栈,避免操作系统防火墙问题导致无法子网代理 / KCP 代理。", isOn: $model.useSmoltcp) - - toggleRow("禁用 IPv6", "禁用此节点的 IPv6 功能,仅使用 IPv4 进行网络通信。", isOn: Binding( - get: { !self.model.enableIPv6 }, - set: { self.model.enableIPv6 = !$0 } - )) - - toggleRow("启用 KCP 代理", "将 TCP 流量转为 KCP 流量,降低传输延迟,提升传输速度。", isOn: $model.enableKcpProxy) - toggleRow("禁用 KCP 输入", "禁用 KCP 入站流量,其他开启 KCP 代理的节点仍然使用 TCP 连接到本节点。", isOn: $model.disableKcpInput) - toggleRow("启用 QUIC 代理", "将 TCP 流量转为 QUIC 流量,降低传输延迟,提升传输速度。", isOn: $model.enableQuicProxy) - toggleRow("禁用 QUIC 输入", "禁用 QUIC 入站流量,其他开启 QUIC 代理的节点仍然使用 TCP 连接到本节点。", isOn: $model.disableQuicInput) - - toggleRow("禁用 P2P", "禁用 P2P 模式,所有流量通过手动指定的服务器中转。", isOn: $model.disableP2P) - toggleRow("仅 P2P", "仅与已经建立 P2P 连接的对等节点通信,不通过其他节点中转。", isOn: $model.onlyP2P) - - toggleRow("仅使用物理网卡", "仅使用物理网卡,避免 Swiftier 通过其他虚拟网建立连接。", isOn: $model.bindDevice) - toggleRow("无 TUN 模式", "不使用 TUN 网卡,适合无管理员权限时使用。本节点仅允许被访问。访问其他节点需要使用 SOCKS5。", isOn: $model.noTun) - - toggleRow("启用出口节点", "允许此节点成为出口节点。", isOn: $model.enableExitNode) - - toggleRow("转发 RPC 包", "允许转发所有对等节点的 RPC 数据包,即使对等节点不在转发网络白名单中。这可以帮助白名单外网络中的对等节点建立 P2P 连接。", isOn: $model.relayAllPeerRpc) - - toggleRow("启用多线程", "使用多线程运行时。", isOn: $model.multiThread) - toggleRow("系统转发", "通过系统内核转发子网代理数据包,禁用内置 NAT。", isOn: $model.proxyForwardBySystem) - - toggleRow("禁用加密", "禁用对等节点通信的加密,默认为 false,必须与对等节点相同。", isOn: Binding( - get: { !self.model.enableEncryption }, - set: { self.model.enableEncryption = !$0 } - )) - - toggleRow("禁用 UDP 打洞", "禁用 UDP 打洞功能。", isOn: $model.disableUdpHolePunching) - toggleRow("禁用对称 NAT 打洞", "禁用对标 NAT 的打洞 (生日攻击),将对称 NAT 视为锥形 NAT 处理。", isOn: $model.disableSymHolePunching) - - toggleRow("启用 Magic DNS", "启用魔法 DNS,允许通过 Swiftier 的 DNS 服务器访问其他节点的虚拟 IPv4 地址,例如:node1.et.net。", isOn: $model.enableMagicDns) - toggleRow("启用私有模式", "启用私有模式,则不允许使用了与本网络不同的网络名称和密码的节点通过本节点进行握手或中转。", isOn: $model.enablePrivateMode) - } - } - .formStyle(.grouped) - .scrollContentBackground(.hidden) - } - } - - // Helper for List Sections - private func stringListSection(list: Binding<[String]>, placeholder: String) -> some View { - Group { - ForEach(list.wrappedValue.indices, id: \.self) { i in - HStack { - TextField(placeholder, text: Binding( - get: { - if i < list.wrappedValue.count { return list.wrappedValue[i] } - return "" - }, - set: { - if i < list.wrappedValue.count { list.wrappedValue[i] = $0 } - } - )) - .textFieldStyle(.plain) - .labelsHidden() - .textContentType(.none) - .disableAutocorrection(true) - - Spacer() - - Button { - if list.wrappedValue.indices.contains(i) { - list.wrappedValue.remove(at: i) - } - } label: { - Image(systemName: "minus.circle.fill") - .foregroundColor(.red) - } - .buttonStyle(.plain) - } - } - Button { list.wrappedValue.append("") } label: { - Label(LocalizedStringKey("添加"), systemImage: "plus.circle.fill").foregroundColor(.blue) - }.buttonStyle(.plain) - } - } - - private func toggleRow(_ title: LocalizedStringKey, _ subtitle: LocalizedStringKey, isOn: Binding) -> some View { - VStack(alignment: .leading, spacing: 4) { - Toggle(title, isOn: isOn) - Text(subtitle) - .font(.caption) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) + + ConfigGeneratorAdvancedForm(model: $model) } - .padding(.vertical, 4) - } - - // 安全绑定:防止删除操作导致的越界崩溃 - private func safePeerBinding(at index: Int) -> Binding { - Binding( - get: { - if index >= 0 && index < model.manualPeers.count { - return model.manualPeers[index] - } - return "" - }, - set: { - if index >= 0 && index < model.manualPeers.count { - model.manualPeers[index] = $0 - } - } - ) } // MARK: - Main View @@ -695,132 +79,17 @@ struct ConfigGeneratorView: View { VStack(spacing: 0) { header(title: LocalizedStringKey("配置生成器"), leftBtn: LocalizedStringKey("取消"), leftRole: .destructive, rightBtn: LocalizedStringKey("生成")) { // Clear draft on cancel so next open reads from disk - ConfigDraftManager.shared.clearDraft(for: editingFileURL) + ConfigGeneratorStore.clearDraft(editingFileURL: editingFileURL) withAnimation { isPresented = false } } rightAction: { generateAndSave() } - - Form { - // Section 1: Virtual IPv4 - Section(header: Text(LocalizedStringKey("虚拟 IPv4 地址"))) { - Toggle(LocalizedStringKey("DHCP"), isOn: $model.dhcp) - - if !model.dhcp { - HStack(spacing: 0) { - Text(LocalizedStringKey("地址")) - Spacer() - IPv4CidrField(ip: $model.ipv4, cidr: $model.cidr) - .fixedSize() // 关键:使用固有尺寸,防止被拉伸,确保 Spacer 能将其推至最右 - } - } - } - - // Section 2: Network & Peers (Merged as per screenshot) - Section(header: Text(LocalizedStringKey("网络"))) { - HStack { - Text(LocalizedStringKey("名称")) - TextField("easytier", text: $model.networkName) - .multilineTextAlignment(.trailing) - .labelsHidden() - .frame(maxWidth: .infinity) - .textContentType(.none) - .disableAutocorrection(true) - } - - HStack { - Text(LocalizedStringKey("密码")) - TextField(LocalizedStringKey("选填"), text: $model.networkSecret) - .multilineTextAlignment(.trailing) - .labelsHidden() - .frame(maxWidth: .infinity) - .textContentType(.none) - .disableAutocorrection(true) - } - - Picker(LocalizedStringKey("节点模式"), selection: $model.peerMode) { - ForEach(PeerMode.allCases) { mode in - Text(mode.localizedTitle).tag(mode) - } - } - .pickerStyle(.segmented) - .padding(.vertical, 4) - - // Inline Peers List (Image 2 style) - if model.peerMode == .manual { - ForEach(model.manualPeers.indices, id: \.self) { i in - HStack { - TextField("tcp://...", text: safePeerBinding(at: i)) - .textFieldStyle(.plain) - .labelsHidden() - .textContentType(.none) - .disableAutocorrection(true) - - Spacer() - - Button { - if model.manualPeers.indices.contains(i) { - model.manualPeers.remove(at: i) - } - } label: { - Image(systemName: "minus.circle.fill") - .foregroundColor(.red) - } - .buttonStyle(.plain) - } - } - - Button { - model.manualPeers.append("tcp://") - } label: { - HStack { - Image(systemName: "plus.circle.fill") - Text(LocalizedStringKey("添加节点")) - } - .foregroundColor(.blue) - } - .buttonStyle(.plain) - - } else if model.peerMode == .publicServer { - HStack { - Text(LocalizedStringKey("服务器")) - Spacer() - Text("tcp://public.easytier.top:11010") - .foregroundColor(.secondary) - .font(.caption) - } - } - } - - // Section 3: Navigation Entries - Section { - Button { push(.advanced) } label: { - HStack { - Text(LocalizedStringKey("高级设置")) - Spacer() - Image(systemName: "chevron.right") - .foregroundColor(.secondary) - .font(.caption) - } - .contentShape(Rectangle()) // Make entire row clickable - } - .buttonStyle(.plain) - - Button { push(.portForwarding) } label: { - HStack { - Text(LocalizedStringKey("端口转发")) - Spacer() - Image(systemName: "chevron.right") - .foregroundColor(.secondary) - .font(.caption) - } - .contentShape(Rectangle()) // Make entire row clickable - } - .buttonStyle(.plain) - } - } - .formStyle(.grouped) - .scrollContentBackground(.hidden) + + ConfigGeneratorMainForm( + model: $model, + onOpenAdvanced: { push(.advanced) }, + onOpenPortForwarding: { push(.portForwarding) } + ) } .background(Color(nsColor: .windowBackgroundColor)) } @@ -829,94 +98,8 @@ struct ConfigGeneratorView: View { var portForwardingView: some View { VStack(spacing: 0) { header(title: LocalizedStringKey("端口转发"), leftBtn: LocalizedStringKey("返回"), leftRole: .cancel) { pop() } - - Form { - ForEach($model.portForwards) { $rule in - Section { - VStack(spacing: 12) { - // Protocol Row - HStack { - Text(LocalizedStringKey("协议")) - Spacer() - Picker("", selection: $rule.protocolType) { - Text("TCP").tag("TCP") - Text("UDP").tag("UDP") - } - .pickerStyle(.segmented) - .frame(width: 120) - } - - Divider() - - // Bind Row - HStack { - Text(LocalizedStringKey("绑定地址")) - Spacer() - IPv4Field(ip: $rule.bindIp) - .fixedSize() - Text(":") - TextField("0", text: $rule.bindPort) - .frame(width: 50) - } - .textFieldStyle(.plain) - .labelsHidden() - - // Arrow - HStack { - Spacer() - Image(systemName: "arrow.down") - .font(.caption) - .foregroundColor(.secondary) - Text(LocalizedStringKey("转发到")) - .font(.caption) - .foregroundColor(.secondary) - Spacer() - } - - // Target Row - HStack { - Text(LocalizedStringKey("目标地址")) - Spacer() - IPv4Field(ip: $rule.targetIp) - .fixedSize() - Text(":") - TextField("0", text: $rule.targetPort) - .frame(width: 50) - } - .textFieldStyle(.plain) - .labelsHidden() - } - .padding(.vertical, 4) - } header: { - HStack { - Spacer() - Button("删除") { - if let idx = model.portForwards.firstIndex(of: rule) { - model.portForwards.remove(at: idx) - } - } - .font(.caption) - .foregroundColor(.red) - .buttonStyle(.plain) - } - } - } - - Section { - Button { - model.portForwards.append(PortForwardRule()) - } label: { - HStack { - Image(systemName: "plus.circle.fill") - Text(LocalizedStringKey("添加端口转发")) - } - .foregroundColor(.blue) - } - .buttonStyle(.plain) - } - } - .formStyle(.grouped) - .scrollContentBackground(.hidden) + + ConfigGeneratorPortForwardingForm(model: $model) } } @@ -951,334 +134,9 @@ struct ConfigGeneratorView: View { } private func generateAndSave() { - let fileURL: URL - if let editing = editingFileURL { - fileURL = editing - } else { - // Should not happen as we pass URL for new files too in ContentView, - // but just in case: - guard let cur = ConfigManager.shared.currentDirectory else { return } - let name = model.instanceName.isEmpty ? "easytier.toml" : "\(model.instanceName).toml" - fileURL = cur.appendingPathComponent(name) - } - - // Prepare peers based on mode - var peersToSave = model.manualPeers - if model.peerMode == .publicServer { peersToSave = ["tcp://public.easytier.top:11010"] } - else if model.peerMode == .standalone { peersToSave = [] } - - let content = generateTOML(peers: peersToSave) - - // 获取安全域访问 - let dirURL = ConfigManager.shared.currentDirectory - let isScoped = dirURL?.startAccessingSecurityScopedResource() ?? false - defer { if isScoped { dirURL?.stopAccessingSecurityScopedResource() } } - - do { - try content.write(to: fileURL, atomically: true, encoding: .utf8) - - // Clear draft on successful save, so next open reads from file (which is now same as draft) - ConfigDraftManager.shared.clearDraft(for: editingFileURL) - - onSave() - isPresented = false - } catch { - saveMessage = "保存失败: \(error.localizedDescription)" - showSaveError = true - } - } - - private func loadFromFile() { - guard let url = editingFileURL else { return } - guard let content = try? ConfigManager.shared.readConfigContent(url) else { return } - self.model = parseTOML(content) - } - - private func parseTOML(_ content: String) -> SpotierConfigModel { - var m = SpotierConfigModel() - let lines = content.components(separatedBy: .newlines) - var currentSection = "" - - m.manualPeers = [] - m.listeners = [] - m.mappedListeners = [] - m.relayNetworkWhitelist = [] - - for line in lines { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } - - if trimmed.hasPrefix("[") { - if trimmed.hasPrefix("[[") { - currentSection = String(trimmed.dropFirst(2).dropLast(2)) - if currentSection == "proxy_network" { m.proxySubnets.append(SpotierConfigModel.ProxySubnet()) } - if currentSection == "port_forward" { m.portForwards.append(PortForwardRule()) } - } else { - currentSection = String(trimmed.dropFirst(1).dropLast(1)) - if currentSection == "vpn_portal_config" { m.enableVpnPortal = true } - } - continue - } - - let parts = trimmed.split(separator: "=", maxSplits: 1).map(String.init) - if parts.count != 2 { continue } - let key = parts[0].trimmingCharacters(in: .whitespaces) - var val = parts[1].trimmingCharacters(in: .whitespaces) - if val.hasPrefix("\"") && val.hasSuffix("\"") { val = String(val.dropFirst().dropLast()) } - - // Root & Sections - if currentSection == "" || currentSection == "network_identity" || currentSection == "flags" { - switch key { - case "instance_name": m.instanceName = val - case "instance_id": m.instanceId = val - case "ipv4": - let c = val.split(separator: "/") - if c.count == 2 { m.ipv4 = String(c[0]); m.cidr = String(c[1]) } - case "dhcp": m.dhcp = (val == "true") - case "mtu": m.mtu = Int(val) ?? 1380 - case "network_name": m.networkName = val - case "network_secret": m.networkSecret = val - case "listeners": - if val.hasPrefix("[") && val.hasSuffix("]") { - let inner = val.dropFirst().dropLast() - m.listeners = inner.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: "\"", with: "") } - } - case "mapped_listeners": - if val.hasPrefix("[") && val.hasSuffix("]") { - let inner = val.dropFirst().dropLast() - m.mappedListeners = inner.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: "\"", with: "") } - } - case "socks5_proxy": - m.enableSocks5 = true - if let portStr = val.split(separator: ":").last, let p = Int(portStr) { - m.socks5Port = p - } - case "exit_nodes": - if val.hasPrefix("[") && val.hasSuffix("]") { - let inner = val.dropFirst().dropLast() - m.exitNodes = inner.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: "\"", with: "") } - } - case "routes": - m.enableManualRoutes = true - if val.hasPrefix("[") && val.hasSuffix("]") { - let inner = val.dropFirst().dropLast() - m.manualRoutes = inner.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: "\"", with: "") } - } - - // Flags - case "latency_first": m.latencyFirst = (val == "true") - case "disable_ipv6": m.enableIPv6 = (val != "true") - case "disable_encryption": m.enableEncryption = (val != "true") - case "use_smoltcp": m.useSmoltcp = (val == "true") - case "no_tun": m.noTun = (val == "true") - case "disable_p2p": m.disableP2P = (val == "true") - case "p2p_only": m.onlyP2P = (val == "true") - case "disable_udp_hole_punching": m.disableUdpHolePunching = (val == "true") - case "enable_exit_node": m.enableExitNode = (val == "true") - case "bind_device": m.bindDevice = (val == "true") - case "enable_kcp_proxy": m.enableKcpProxy = (val == "true") - case "disable_kcp_input": m.disableKcpInput = (val == "true") - case "enable_quic_proxy": m.enableQuicProxy = (val == "true") - case "disable_quic_input": m.disableQuicInput = (val == "true") - case "relay_all_peer_rpc": m.relayAllPeerRpc = (val == "true") - - case "multi_thread": m.multiThread = (val == "true") - case "proxy_forward_by_system": m.proxyForwardBySystem = (val == "true") - case "disable_sym_hole_punching": m.disableSymHolePunching = (val == "true") - case "enable_magic_dns": m.enableMagicDns = (val == "true") - case "enable_private_mode": m.enablePrivateMode = (val == "true") - - case "relay_network_whitelist": - m.enableRelayNetworkWhitelist = true - m.relayNetworkWhitelist = val.replacingOccurrences(of: "\"", with: "").components(separatedBy: " ").filter{!$0.isEmpty} - - default: break - } - } else if currentSection == "peer" { - if key == "uri" { m.manualPeers.append(val); m.peerMode = .manual } - } else if currentSection == "vpn_portal_config" { - if key == "client_cidr" { m.vpnPortalClientCidr = val } - if key == "wireguard_listen" { - let p = val.split(separator: ":").last - if let p = p, let post = Int(p) { m.vpnPortalListenPort = post } - } - } else if currentSection == "proxy_network" { - if !m.proxySubnets.isEmpty { - var last = m.proxySubnets.removeLast() - if key == "cidr" { last.cidr = val } - m.proxySubnets.append(last) - } - } else if currentSection == "port_forward" { - if !m.portForwards.isEmpty { - var last = m.portForwards.removeLast() - if key == "proto" { last.protocolType = val.uppercased() } - if key == "bind_addr" { - let parts = val.split(separator: ":") - if parts.count >= 2 { - last.bindPort = String(parts.last!) - last.bindIp = parts.dropLast().joined(separator: ":") - } - } - if key == "dst_addr" { - let parts = val.split(separator: ":") - if parts.count >= 2 { - last.targetPort = String(parts.last!) - last.targetIp = parts.dropLast().joined(separator: ":") - } - } - m.portForwards.append(last) - } - } - } - if m.listeners.isEmpty { m.listeners = ["tcp://0.0.0.0:11010", "udp://0.0.0.0:11010", "wg://0.0.0.0:11011"] } - return m - } - - private func generateTOML(peers: [String]) -> String { - var toml = """ - instance_name = "\(model.instanceName)" - instance_id = "\(model.instanceId)" - dhcp = \(model.dhcp) - """ - // Listeners - if !model.listeners.isEmpty { - let lList = model.listeners.filter{!$0.isEmpty}.map { "\"\($0)\"" }.joined(separator: ", ") - toml += "\nlisteners = [\(lList)]" - } - - // Mapped Listeners - if !model.mappedListeners.isEmpty { - let mlList = model.mappedListeners.filter{!$0.isEmpty}.map { "\"\($0)\"" }.joined(separator: ", ") - toml += "\nmapped_listeners = [\(mlList)]" - } - - if !model.dhcp && !model.ipv4.isEmpty { - // FIX: Key should be 'ipv4', not 'ipv4_cidr' - toml += "\nipv4 = \"\(model.ipv4)/\(model.cidr)\"" - } - - if model.enableSocks5 { - toml += "\nsocks5_proxy = \"socks5://0.0.0.0:\(model.socks5Port)\"" - } - - // Exit Nodes - if !model.exitNodes.isEmpty { - let exits = model.exitNodes.filter{!$0.isEmpty}.map { "\"\($0)\"" }.joined(separator: ", ") - if !exits.isEmpty { - toml += "\nexit_nodes = [\(exits)]" - } - } - - // Manual Routes - if model.enableManualRoutes && !model.manualRoutes.isEmpty { - let rts = model.manualRoutes.filter{!$0.isEmpty}.map { "\"\($0)\"" }.joined(separator: ", ") - if !rts.isEmpty { - toml += "\nroutes = [\(rts)]" - } - } - toml += """ - - [network_identity] - network_name = "\(model.networkName)" - network_secret = "\(model.networkSecret)" - """ - for peer in peers { - if !peer.isEmpty { - toml += "\n\n[[peer]]\nuri = \"\(peer)\"" - } - } - - - - // Flags - var flags = "" - flags += "\nmtu = \(model.mtu)" - - if model.latencyFirst { flags += "\nlatency_first = true" } - // Logic inversion for disable flags (default false means enabled) - if !model.enableIPv6 { flags += "\ndisable_ipv6 = true" } - if !model.enableEncryption { flags += "\ndisable_encryption = true" } - - if model.useSmoltcp { flags += "\nuse_smoltcp = true" } - if model.noTun { flags += "\nno_tun = true" } - if model.disableP2P { flags += "\ndisable_p2p = true" } - if model.onlyP2P { flags += "\np2p_only = true" } - - if model.disableUdpHolePunching { flags += "\ndisable_udp_hole_punching = true" } - if model.enableExitNode { flags += "\nenable_exit_node = true" } - - if model.enableKcpProxy { flags += "\nenable_kcp_proxy = true" } - if model.disableKcpInput { flags += "\ndisable_kcp_input = true" } - if model.enableQuicProxy { flags += "\nenable_quic_proxy = true" } - if model.disableQuicInput { flags += "\ndisable_quic_input = true" } - - if model.relayAllPeerRpc { flags += "\nrelay_all_peer_rpc = true" } - - // New Flags - if model.bindDevice { flags += "\nbind_device = true" } - if !model.multiThread { flags += "\nmulti_thread = false" } // Default true? - // Wait, current Model says `multiThread: Bool = true`. - // If default is true, we write false if model is false. - // If user wants to FORCE turn it on (if core default is false), we write true. - // Let's assume default is false in Core (to be safe) or simply write it if true. - // iOS: `init() { multiThread = true }`. So default is true. - // So we write `multi_thread = true` if it is true, or `multi_thread = false` if false? - // Let's write it if it deviates from a "False" default? Or write explicit? - // Let's stick to "Write if True" for safety unless we know strict defaults. - // Actually, if iOS defaults to true, and user toggles it off, we must write `multi_thread = false`. - // But if user keeps it true, and core default is false, we must write `multi_thread = true`. - // Suggestion: Write `multi_thread = true` since model defaults to true. - if model.multiThread { flags += "\nmulti_thread = true" } - - if model.proxyForwardBySystem { flags += "\nproxy_forward_by_system = true" } - if model.disableSymHolePunching { flags += "\ndisable_sym_hole_punching = true" } - if model.enableMagicDns { flags += "\nenable_magic_dns = true" } - if model.enablePrivateMode { flags += "\nenable_private_mode = true" } - - if model.enableRelayNetworkWhitelist && !model.relayNetworkWhitelist.isEmpty { - let wl = model.relayNetworkWhitelist.joined(separator: " ") - flags += "\nrelay_network_whitelist = \"\(wl)\"" - } - - if !flags.isEmpty { - toml += "\n\n[flags]" + flags - } - - // VPN Portal - if model.enableVpnPortal { - toml += """ - - [vpn_portal_config] - client_cidr = "\(model.vpnPortalClientCidr)" - wireguard_listen = "0.0.0.0:\(model.vpnPortalListenPort)" - """ - } - - // Proxy Networks (Subnet Proxy) - for subnet in model.proxySubnets { - if !subnet.cidr.isEmpty { - toml += """ - - [[proxy_network]] - cidr = "\(subnet.cidr)" - """ - } - } - - // Port Forwarding (Port Forward) - for rule in model.portForwards { - if !rule.bindPort.isEmpty && !rule.targetPort.isEmpty { - toml += """ - - [[port_forward]] - proto = "\(rule.protocolType.lowercased())" - bind_addr = "\(rule.bindIp.isEmpty ? "0.0.0.0" : rule.bindIp):\(rule.bindPort)" - dst_addr = "\(rule.targetIp):\(rule.targetPort)" - """ - } - } - - return toml + try! ConfigGeneratorStore.save(model: model, editingFileURL: editingFileURL) + onSave() + isPresented = false } @@ -1354,134 +212,34 @@ struct ConfigGeneratorView: View { Coordinator(parent: self) } - // MARK: - Coordinator - class Coordinator: NSObject, NSTextFieldDelegate, OctetTextFieldDelegate { + class Coordinator: OctetFieldCoordinator { var parent: IPv4CidrField - weak var stackView: NSStackView? - var isInternalUpdate = false init(parent: IPv4CidrField) { self.parent = parent } - // Sync Data -> View + override func syncToModel() { + parent.ip = currentIP() + } + func updateFields(from ip: String, cidr: String) { + updateFields(from: ip) guard !isInternalUpdate, let stack = stackView else { return } - - let parts = ip.split(separator: ".", omittingEmptySubsequences: false).map(String.init) - var tfIndex = 0 - for view in stack.arrangedSubviews { - if let tf = view as? NSTextField, view is MacOctetTextField { - let val = tfIndex < parts.count ? parts[tfIndex] : "" - if tf.stringValue != val { - tf.stringValue = val - } - tfIndex += 1 - } else if let popup = view as? NSPopUpButton { + if let popup = view as? NSPopUpButton { let title = cidr.isEmpty ? "24" : cidr if popup.titleOfSelectedItem != title { popup.selectItem(withTitle: title) } + } else if let popup = view as? NSPopUpButton { + popup.selectItem(withTitle: cidr) } } } - // Sync View -> Data - func syncToModel() { - guard let stack = stackView else { return } - isInternalUpdate = true - - var parts = [String]() - for view in stack.arrangedSubviews { - if let tf = view as? MacOctetTextField { - parts.append(tf.stringValue) - } - } - // 补齐 4 位,避免 "10.." 导致数组越界或格式错误 - while parts.count < 4 { parts.append("") } - - parent.ip = parts.joined(separator: ".") - - // 稍微延迟重置标志,以防 SwiftUI updateNSView 立即触发 - DispatchQueue.main.async { - self.isInternalUpdate = false - } - } - @objc func cidrChanged(_ sender: NSPopUpButton) { - parent.cidr = sender.titleOfSelectedItem ?? "24" - } - - // MARK: - Field Edit Logic - - func controlTextDidChange(_ obj: Notification) { - guard let tf = obj.object as? MacOctetTextField else { return } - - // 1. 过滤非数字 - let filtered = tf.stringValue.filter { "0123456789".contains($0) } - if filtered != tf.stringValue { - tf.stringValue = filtered - } - - // 2. 限制长度 3 位 - if tf.stringValue.count > 3 { - tf.stringValue = String(tf.stringValue.prefix(3)) - } - - // 3. 范围限制 0-255 (可选:如果想允许用户输入过程中暂存大于255的值,可以等 end editing 再校验。但为了体验,这里做实时截断或校验) - if let num = Int(tf.stringValue), num > 255 { - tf.stringValue = "255" - } - - syncToModel() - - // 4. 自动跳转:输入满 3 位且不是最后一个框 -> 跳下一个 - if tf.stringValue.count == 3 { - focusField(at: tf.tag + 1) - } - } - - // Handle Backspace on Empty Field - func didPressBackspaceOnEmpty(in textField: MacOctetTextField) { - let prevIndex = textField.tag - 1 - if prevIndex >= 0 { - focusField(at: prevIndex, placeCursorAtEnd: true, deleteLastChar: true) - } - } - - // MARK: - Focus Helper - func focusField(at index: Int, placeCursorAtEnd: Bool = false, deleteLastChar: Bool = false) { - guard let stack = stackView, index >= 0 && index < 4 else { return } - - // 找到对应的 TextField - let targetView = stack.arrangedSubviews.compactMap { $0 as? MacOctetTextField }.first { $0.tag == index } - - if let tf = targetView { - tf.window?.makeFirstResponder(tf) - - if deleteLastChar && !tf.stringValue.isEmpty { - tf.stringValue = String(tf.stringValue.dropLast()) - syncToModel() - } - - // 关键修复:防止全选。将光标移动到末尾。 - if let editor = tf.currentEditor() { - let length = tf.stringValue.count - editor.selectedRange = NSRange(location: length, length: 0) - } - } - } - - // MARK: - NSTextFieldDelegate (Handle Special Keys) - func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { - if commandSelector == #selector(NSStandardKeyBindingResponding.deleteBackward(_:)) { - if let tf = control as? MacOctetTextField, tf.stringValue.isEmpty { - didPressBackspaceOnEmpty(in: tf) - return true // Consume event - } - } - return false + parent.cidr = sender.titleOfSelectedItem! } } } @@ -1532,83 +290,109 @@ struct ConfigGeneratorView: View { Coordinator(parent: self) } - class Coordinator: NSObject, NSTextFieldDelegate, OctetTextFieldDelegate { + class Coordinator: OctetFieldCoordinator { var parent: IPv4Field - weak var stackView: NSStackView? - var isInternalUpdate = false init(parent: IPv4Field) { self.parent = parent } - func updateFields(from ip: String) { - guard !isInternalUpdate, let stack = stackView else { return } - let parts = ip.split(separator: ".", omittingEmptySubsequences: false).map(String.init) - var tfIndex = 0 - for view in stack.arrangedSubviews { - if let tf = view as? NSTextField, view is MacOctetTextField { - let val = tfIndex < parts.count ? parts[tfIndex] : "" - if tf.stringValue != val { tf.stringValue = val } - tfIndex += 1 - } + override func syncToModel() { + parent.ip = currentIP() + } + } + } + + class OctetFieldCoordinator: NSObject, NSTextFieldDelegate, OctetTextFieldDelegate { + weak var stackView: NSStackView? + var isInternalUpdate = false + + func updateFields(from ip: String) { + guard !isInternalUpdate, let stack = stackView else { return } + + let parts = ip.split(separator: ".", omittingEmptySubsequences: false).map(String.init) + var tfIndex = 0 + for view in stack.arrangedSubviews { + guard let tf = view as? NSTextField, view is MacOctetTextField else { continue } + let val = tfIndex < parts.count ? parts[tfIndex] : "" + if tf.stringValue != val { + tf.stringValue = val } + tfIndex += 1 } - - func syncToModel() { - guard let stack = stackView else { return } - isInternalUpdate = true - var parts = [String]() - for view in stack.arrangedSubviews { - if let tf = view as? MacOctetTextField { - parts.append(tf.stringValue) - } + } + + func currentIP() -> String { + guard let stack = stackView else { return "" } + var parts = [String]() + for view in stack.arrangedSubviews { + if let tf = view as? MacOctetTextField { + parts.append(tf.stringValue) } - while parts.count < 4 { parts.append("") } - parent.ip = parts.joined(separator: ".") - DispatchQueue.main.async { self.isInternalUpdate = false } } - - func controlTextDidChange(_ obj: Notification) { - guard let tf = obj.object as? MacOctetTextField else { return } - let filtered = tf.stringValue.filter { "0123456789".contains($0) } - if filtered != tf.stringValue { tf.stringValue = filtered } - if tf.stringValue.count > 3 { tf.stringValue = String(tf.stringValue.prefix(3)) } - if let num = Int(tf.stringValue), num > 255 { tf.stringValue = "255" } - syncToModel() - if tf.stringValue.count == 3 { focusField(at: tf.tag + 1) } + while parts.count < 4 { parts.append("") } + return parts.joined(separator: ".") + } + + func syncToModel() {} + + func controlTextDidChange(_ obj: Notification) { + guard let tf = obj.object as? MacOctetTextField else { return } + + let filtered = tf.stringValue.filter { "0123456789".contains($0) } + if filtered != tf.stringValue { + tf.stringValue = filtered } - - func didPressBackspaceOnEmpty(in textField: MacOctetTextField) { - let prevIndex = textField.tag - 1 - if prevIndex >= 0 { focusField(at: prevIndex, placeCursorAtEnd: true, deleteLastChar: true) } + if tf.stringValue.count > 3 { + tf.stringValue = String(tf.stringValue.prefix(3)) } - - func focusField(at index: Int, placeCursorAtEnd: Bool = false, deleteLastChar: Bool = false) { - guard let stack = stackView, index >= 0 && index < 4 else { return } - let targetView = stack.arrangedSubviews.compactMap { $0 as? MacOctetTextField }.first { $0.tag == index } - if let tf = targetView { - tf.window?.makeFirstResponder(tf) - if deleteLastChar && !tf.stringValue.isEmpty { - tf.stringValue = String(tf.stringValue.dropLast()) - syncToModel() - } - if let editor = tf.currentEditor() { - let length = tf.stringValue.count - editor.selectedRange = NSRange(location: length, length: 0) - } - } + if let num = Int(tf.stringValue), num > 255 { + tf.stringValue = "255" } - - func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { - if commandSelector == #selector(NSStandardKeyBindingResponding.deleteBackward(_:)) { - if let tf = control as? MacOctetTextField, tf.stringValue.isEmpty { - didPressBackspaceOnEmpty(in: tf) - return true - } - } - return false + + isInternalUpdate = true + syncToModel() + isInternalUpdate = false + + if tf.stringValue.count == 3 { + focusField(at: tf.tag + 1) + } + } + + func didPressBackspaceOnEmpty(in textField: MacOctetTextField) { + let prevIndex = textField.tag - 1 + if prevIndex >= 0 { + focusField(at: prevIndex, deleteLastChar: true) + } + } + + func focusField(at index: Int, deleteLastChar: Bool = false) { + guard let stack = stackView, index >= 0 && index < 4 else { return } + let targetView = stack.arrangedSubviews.compactMap { $0 as? MacOctetTextField }.first { $0.tag == index } + guard let tf = targetView else { return } + + tf.window?.makeFirstResponder(tf) + if deleteLastChar && !tf.stringValue.isEmpty { + tf.stringValue = String(tf.stringValue.dropLast()) + isInternalUpdate = true + syncToModel() + isInternalUpdate = false + } + if let editor = tf.currentEditor() { + let length = tf.stringValue.count + editor.selectedRange = NSRange(location: length, length: 0) } } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + if commandSelector == #selector(NSStandardKeyBindingResponding.deleteBackward(_:)), + let tf = control as? MacOctetTextField, + tf.stringValue.isEmpty { + didPressBackspaceOnEmpty(in: tf) + return true + } + return false + } } // 代理协议:用于传递 Backspace 事件 @@ -1623,5 +407,3 @@ struct ConfigGeneratorView: View { } } - - diff --git a/Spotier/ConfigManager.swift b/Spotier/ConfigManager.swift index 4e2c92a..62ebd26 100644 --- a/Spotier/ConfigManager.swift +++ b/Spotier/ConfigManager.swift @@ -3,57 +3,69 @@ import Combine import SwiftUI import AppKit +enum ConfigAccessError: LocalizedError { + case missingDirectory + case fileAlreadyExists(String) + + var errorDescription: String? { + switch self { + case .missingDirectory: + return "请先选择配置目录" + case let .fileAlreadyExists(name): + return "文件已存在: \(name)" + } + } +} + class ConfigManager: ObservableObject { static let shared = ConfigManager() @Published var configFiles: [URL] = [] @AppStorage("custom_config_path") var customPathString: String = "" @AppStorage("custom_config_bookmark") var customPathBookmark: Data? - @AppStorage("cloudkit_sync_enabled") var cloudKitSyncEnabled: Bool = false - private var isCloudSyncInProgress = false + @AppStorage("icloud_drive_enabled") var iCloudDriveEnabled: Bool = false + private let directoryAccess = ConfigDirectoryAccess() + private var localDirectory: URL? { + directoryAccess.defaultLocalDirectory() + } + private var iCloudDriveDirectory: URL? { + directoryAccess.iCloudDriveDirectory() + } + private var legacyICloudDriveDirectory: URL? { + directoryAccess.legacyICloudDriveDirectory() + } var currentDirectory: URL? { - // 开启 CloudKit 后,强制只使用默认目录作为同步源 - if cloudKitSyncEnabled { - return defaultLocalDirectory() - } - - return directoryFromUserPreference() + iCloudDriveEnabled ? iCloudDriveDirectory : directoryFromUserPreference() } private init() { - // 修正:如果 customPathString 已指向 iCloud 容器,清除可能残留的旧书签 - // (旧书签可能指向桌面等非 iCloud 路径,导致 currentDirectory 返回错误位置) - if let bookmark = customPathBookmark, !customPathString.isEmpty { - var isStale = false - if let bookmarkURL = try? URL(resolvingBookmarkData: bookmark, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale) { - let bookmarkPath = bookmarkURL.path - // 书签路径与当前设置路径不一致,说明书签是残留的旧数据 - if bookmarkPath != customPathString { - print("[ConfigManager] 清除残留书签: \(bookmarkPath) != \(customPathString)") - self.customPathBookmark = nil - } - } - } - - // 首次运行或未设置路径时,使用本地 Application Support 默认目录 - if customPathString.isEmpty { - if let targetDir = defaultLocalDirectory() { - self.customPathString = targetDir.path - } + migrateLegacyICloudDriveDirectoryIfNeeded() + + let bookmarkPath = resolvedBookmarkPathForInitialization() + var resolvedPath = customPathString + var resolvedBookmark = customPathBookmark + + if let bookmarkPath, !resolvedPath.isEmpty, bookmarkPath != resolvedPath { + resolvedBookmark = nil + print("[ConfigManager] 清除残留书签: \(bookmarkPath) != \(customPathString)") } - // 开启 CloudKit 后,强制回到默认目录 - if cloudKitSyncEnabled, let targetDir = defaultLocalDirectory() { - self.customPathBookmark = nil - self.customPathString = targetDir.path + if iCloudDriveEnabled, let iCloudDriveDirectory { + resolvedPath = iCloudDriveDirectory.path + resolvedBookmark = nil + } else if resolvedPath.isEmpty, let localDirectory { + resolvedPath = localDirectory.path } + + customPathString = resolvedPath + customPathBookmark = resolvedBookmark refreshConfigs() } func selectCustomFolder() { - if cloudKitSyncEnabled { - print("CloudKit 同步已启用,已锁定默认目录。") + if iCloudDriveEnabled { + print("iCloud Drive 存储已启用,已锁定配置目录。") return } @@ -61,18 +73,22 @@ class ConfigManager: ObservableObject { panel.canChooseFiles = false panel.canChooseDirectories = true panel.allowsMultipleSelection = false - if panel.runModal() == .OK { - if let url = panel.url { - self.customPathString = url.path - - // 沙盒适配:保存安全域书签 (Security Scoped Bookmark) - if let bookmark = try? url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil) { - self.customPathBookmark = bookmark - } - - self.refreshConfigs() - } + guard panel.runModal() == .OK, let url = panel.url else { return } + + customPathString = url.path + + do { + customPathBookmark = try url.bookmarkData( + options: .withSecurityScope, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + } catch { + customPathBookmark = nil + print("创建目录书签失败: \(error)") } + + refreshConfigs() } func openiCloudFolder() { @@ -81,158 +97,95 @@ class ConfigManager: ObservableObject { NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: url.path) } - func editConfigFile(url: URL) { - NSWorkspace.shared.open(url) - } - private var isICloudEnabled: Bool { FileManager.default.ubiquityIdentityToken != nil } - func migrateToiCloud() { - guard !cloudKitSyncEnabled else { return } + func enableICloudDrive() { + guard !iCloudDriveEnabled else { return } guard isICloudEnabled else { - print("未检测到 iCloud 账户,无法启用 CloudKit 同步。") + print("未检测到 iCloud 账户,无法启用 iCloud Drive。") return } - enableCloudKitSync() + enableICloudDriveStorage() } - func disableCloudKitSync() { - guard cloudKitSyncEnabled else { return } + func disableICloudDrive() { + guard iCloudDriveEnabled else { return } - cloudKitSyncEnabled = false - isCloudSyncInProgress = false + iCloudDriveEnabled = false - if customPathString.isEmpty, let targetDir = defaultLocalDirectory() { + if customPathString.isEmpty, let targetDir = localDirectory { customPathString = targetDir.path } - _ = refreshConfigs(skipCloudSync: true) + _ = refreshConfigs() } @discardableResult func refreshConfigs() -> [URL] { - refreshConfigs(skipCloudSync: false) - } - - @discardableResult - private func refreshConfigs(skipCloudSync: Bool) -> [URL] { - guard let url = currentDirectory else { - DispatchQueue.main.async { self.configFiles = [] } + guard let directory = currentDirectory else { + configFiles = [] return [] } - - // 关键修复:必须在访问前请求权限 - let isScoped = url.startAccessingSecurityScopedResource() - defer { if isScoped { url.stopAccessingSecurityScopedResource() } } - + do { - // 再次确保目录存在(防止被外部删除) - if !FileManager.default.fileExists(atPath: url.path) { - try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) - } - - let items = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) - let tomlFiles = items.filter { $0.pathExtension == "toml" }.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) - - DispatchQueue.main.async { - self.configFiles = tomlFiles - } + let tomlFiles = try directoryAccess.withScopedAccess(to: directory) { directoryURL in + if !FileManager.default.fileExists(atPath: directoryURL.path) { + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + } - if !skipCloudSync { - triggerCloudSyncIfNeeded(force: false) + return try FileManager.default + .contentsOfDirectory(at: directoryURL, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "toml" } + .sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) } + configFiles = tomlFiles return tomlFiles } catch { - print("读取配置文件列表失败: \(error) 路径: \(url.path)") - // 如果读取失败,尝试清空列表 - DispatchQueue.main.async { - self.configFiles = [] - } + print("读取配置文件列表失败: \(error) 路径: \(directory.path)") + configFiles = [] return [] } } func readConfigContent(_ fileURL: URL) throws -> String { - // 如果有书签,说明是用户选定的安全域目录 - if let _ = customPathBookmark, let dirURL = currentDirectory { - let isScoped = dirURL.startAccessingSecurityScopedResource() - defer { if isScoped { dirURL.stopAccessingSecurityScopedResource() } } - + try directoryAccess.withScopedAccess(to: currentDirectory) { _ in return try String(contentsOf: fileURL, encoding: .utf8) } - - // 普通路径直接读取 - return try String(contentsOf: fileURL, encoding: .utf8) } - func deleteConfig(_ fileURL: URL) { - do { - try FileManager.default.removeItem(at: fileURL) - } catch { - print("删除配置失败: \(error)") - } - - if cloudKitSyncEnabled && isICloudEnabled { - let fileName = fileURL.lastPathComponent - Task.detached { - do { - try await CloudKitConfigSync.shared.deleteConfig(named: fileName) - } catch { - print("CloudKit 删除配置失败: \(error)") - } + func createConfig(named filename: String, content: String) throws -> URL { + try directoryAccess.withScopedAccess(to: currentDirectory) { directoryURL in + let fileURL = directoryURL.appendingPathComponent(filename) + guard !FileManager.default.fileExists(atPath: fileURL.path) else { + throw ConfigAccessError.fileAlreadyExists(filename) } - } - - refreshConfigs() - } - private func defaultLocalDirectory() -> URL? { - guard let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { - return nil + try content.write(to: fileURL, atomically: true, encoding: .utf8) + return fileURL } + } - let targetDir = appSupport - .appendingPathComponent("Spotier", isDirectory: true) - .appendingPathComponent("Configs", isDirectory: true) - - if !FileManager.default.fileExists(atPath: targetDir.path) { - try? FileManager.default.createDirectory(at: targetDir, withIntermediateDirectories: true) + func updateConfig(_ fileURL: URL, content: String) throws { + try directoryAccess.withScopedAccess(to: currentDirectory) { _ in + try content.write(to: fileURL, atomically: true, encoding: .utf8) } - - return targetDir } - private func triggerCloudSyncIfNeeded(force: Bool) { - guard cloudKitSyncEnabled else { return } - guard isICloudEnabled else { return } - guard !isCloudSyncInProgress || force else { return } - guard let localDir = currentDirectory else { return } - - isCloudSyncInProgress = true - - Task.detached { [weak self] in - guard let self else { return } - - do { - let localChanged = try await CloudKitConfigSync.shared.sync(localDirectory: localDir) - DispatchQueue.main.async { - self.isCloudSyncInProgress = false - if localChanged { - _ = self.refreshConfigs(skipCloudSync: true) - } - } - } catch { - DispatchQueue.main.async { - self.isCloudSyncInProgress = false - print("CloudKit 同步失败: \(error)") - } + func deleteConfig(_ fileURL: URL) { + do { + try directoryAccess.withScopedAccess(to: currentDirectory) { _ in + try FileManager.default.removeItem(at: fileURL) } + } catch { + print("删除配置失败: \(error)") } + + refreshConfigs() } private func directoryFromUserPreference() -> URL? { @@ -248,48 +201,61 @@ class ConfigManager: ObservableObject { ) if isStale { - if let newBookmark = try? url.bookmarkData( + customPathBookmark = try url.bookmarkData( options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil - ) { - DispatchQueue.main.async { self.customPathBookmark = newBookmark } - } + ) } return url } catch { print("解析书签失败: \(error)") - DispatchQueue.main.async { self.customPathBookmark = nil } + customPathBookmark = nil } } - // 2. 尝试使用路径字符串(非沙盒或已授权路径) if !customPathString.isEmpty { return URL(fileURLWithPath: customPathString) } - // 3. 默认使用 Application Support(更符合应用配置文件惯例) - return defaultLocalDirectory() + return localDirectory } - private func enableCloudKitSync() { - guard let targetDir = defaultLocalDirectory() else { return } + private func enableICloudDriveStorage() { + guard let targetDir = iCloudDriveDirectory else { return } + + migrateLegacyICloudDriveDirectoryIfNeeded() - // 在切换前先拿到用户当前目录,自动迁移配置,避免用户手动搬文件 let sourceDir = directoryFromUserPreference() do { try migrateConfigsToDefaultDirectory(from: sourceDir, to: targetDir) } catch { - print("迁移配置到默认目录失败: \(error)") + print("迁移配置到 iCloud Drive 失败: \(error)") return } customPathBookmark = nil customPathString = targetDir.path - cloudKitSyncEnabled = true + iCloudDriveEnabled = true + _ = refreshConfigs() + } + + private func migrateLegacyICloudDriveDirectoryIfNeeded() { + guard + let legacyDir = legacyICloudDriveDirectory, + let targetDir = iCloudDriveDirectory, + FileManager.default.fileExists(atPath: legacyDir.path), + legacyDir.standardizedFileURL != targetDir.standardizedFileURL + else { + return + } - _ = refreshConfigs(skipCloudSync: true) - triggerCloudSyncIfNeeded(force: true) + do { + try migrateConfigsToDefaultDirectory(from: legacyDir, to: targetDir) + try FileManager.default.removeItem(at: legacyDir) + } catch { + print("迁移旧 iCloud Drive Configs 目录失败: \(error)") + } } private func migrateConfigsToDefaultDirectory(from sourceDir: URL?, to targetDir: URL) throws { @@ -317,13 +283,31 @@ class ConfigManager: ObservableObject { continue } - let sourceDate = (try? sourceFile.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? Date.distantPast - let targetDate = (try? targetFile.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? Date.distantPast + let sourceDate = try sourceFile.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate + let targetDate = try targetFile.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate - if sourceDate.timeIntervalSince(targetDate) > 1.0 { - try FileManager.default.removeItem(at: targetFile) - try FileManager.default.copyItem(at: sourceFile, to: targetFile) + if let sourceDate, let targetDate, sourceDate <= targetDate { + continue } + + try FileManager.default.removeItem(at: targetFile) + try FileManager.default.copyItem(at: sourceFile, to: targetFile) + } + } + + private func resolvedBookmarkPathForInitialization() -> String? { + guard let bookmark = customPathBookmark, !customPathString.isEmpty else { return nil } + + var isStale = false + do { + return try URL( + resolvingBookmarkData: bookmark, + options: .withSecurityScope, + relativeTo: nil, + bookmarkDataIsStale: &isStale + ).path + } catch { + return nil } } } diff --git a/Spotier/ContentView.swift b/Spotier/ContentView.swift index c124f7e..c955a5e 100644 --- a/Spotier/ContentView.swift +++ b/Spotier/ContentView.swift @@ -1,47 +1,37 @@ import SwiftUI import Combine -import NetworkExtension struct ContentView: View { - - // 性能优化:不再直接观察整个 runner,避免 uptime/speed 变化触发全量 Diff - // 改为手动监听核心状态 - private var runner = SpotierRunner.shared + @StateObject private var runner = SpotierRunner.shared @ObservedObject private var vpnManager = VPNManager.shared - @State private var isRunning = false - @State private var isWindowVisible = true - @State private var sessionID = UUID() @StateObject private var configManager = ConfigManager.shared - - - @State private var selectedConfig: URL? - @State private var showLogView = false - @State private var showSettingsView = false - @State private var showConfigGenerator = false - @State private var editingConfigURL: URL? - @State private var showCreatePrompt = false - @State private var newConfigName = "" - @State private var createConfigError: String? - - private let windowWidth: CGFloat = 420 - private let windowHeight: CGFloat = 520 - - // 逻辑:判断当前是否有全屏覆盖层显示 - private var isAnyOverlayShown: Bool { - showLogView || showSettingsView || showConfigGenerator || editingConfigURL != nil - } + @StateObject private var dashboardState = MainDashboardState() var body: some View { ZStack { - // 不再手动设置背景,利用 MenuBarExtra 原生窗口的 Vibrancy - - // 主内容层 - if isWindowVisible { + if runner.isWindowVisible { VStack(spacing: 0) { - headerView + MainDashboardHeader( + configFiles: configManager.configFiles, + selectedConfig: dashboardState.selectedConfig, + iCloudDriveEnabled: configManager.iCloudDriveEnabled, + hasICloudIdentity: FileManager.default.ubiquityIdentityToken != nil, + onSelectConfig: dashboardState.selectConfig, + onCreateNetwork: dashboardState.openCreatePrompt, + onOpenGenerator: { dashboardState.openOverlay(.generator) }, + onOpenEditor: dashboardState.openEditor, + onDisableICloudDrive: { configManager.disableICloudDrive() }, + onEnableICloudDrive: { configManager.enableICloudDrive() }, + onSelectFolder: { configManager.selectCustomFolder() }, + onOpenFolder: { configManager.openiCloudFolder() }, + onDeleteSelected: dashboardState.deleteSelectedConfig, + onOpenLog: { dashboardState.openOverlay(.log) }, + onOpenSettings: { dashboardState.openOverlay(.settings) }, + onQuit: { NSApplication.shared.terminate(nil) } + ) ZStack { - if !isAnyOverlayShown { + if !dashboardState.isAnyOverlayShown { contentArea } else { // 覆盖层显示时,用透明占位保持几何结构稳固 @@ -50,751 +40,155 @@ struct ContentView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } - .frame(width: windowWidth, height: windowHeight, alignment: .top) + .frame(width: DashboardLayoutMetrics.windowWidth, height: DashboardLayoutMetrics.windowHeight, alignment: .top) .background(Color(nsColor: .windowBackgroundColor).opacity(0.01)) // 确保点击区域 } else { Color.clear - .frame(width: windowWidth, height: windowHeight) - } - - // Generator and Editor overlays - Removed from isWindowVisible check to preserve state - // 日志全屏覆盖层 - if showLogView { - LogView(isPresented: $showLogView) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(.regularMaterial) - .compositingGroup() - .zIndex(100) - .transition(.move(edge: .bottom)) - } - - // 设置全屏覆盖层 - if showSettingsView { - SettingsView(isPresented: $showSettingsView) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(.regularMaterial) - .zIndex(101) - .transition(.move(edge: .bottom)) - } - - // 编辑器全屏覆盖层 - if let url = editingConfigURL { - ConfigEditorView( - isPresented: Binding( - get: { true }, - set: { if !$0 { editingConfigURL = nil } } - ), - fileURL: url - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(.regularMaterial) - .zIndex(102) - .transition(.move(edge: .bottom)) - } - - // 生成器全屏覆盖层 - if showConfigGenerator { - ConfigGeneratorView( - isPresented: $showConfigGenerator, - editingFileURL: selectedConfig, - onSave: { configManager.refreshConfigs() } - ) - .id(selectedConfig) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .zIndex(103) - .transition(.move(edge: .bottom)) - } - - // 新建配置弹窗 - if showCreatePrompt { - Color.black.opacity(0.3).zIndex(104) - .onTapGesture { withAnimation { showCreatePrompt = false } } - - VStack(spacing: 20) { - Text(LocalizedStringKey("创建新网络")) - .font(.headline) - - if let error = createConfigError { - Text(error) - .font(.caption) - .foregroundColor(.red) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - - VStack(alignment: .leading, spacing: 8) { - Text(LocalizedStringKey("配置文件名:")) - TextField(LocalizedStringKey("例如: my-network"), text: $newConfigName) - .textFieldStyle(.roundedBorder) - .textContentType(.none) - .disableAutocorrection(true) - .onSubmit { createConfig() } - Text(LocalizedStringKey("将自动添加 .toml 后缀")) - .font(.caption) - .foregroundColor(.secondary) - } - .padding(.horizontal) - - HStack { - Button(LocalizedStringKey("取消")) { - withAnimation { - showCreatePrompt = false - createConfigError = nil - } - } - Button(LocalizedStringKey("创建")) { createConfig() } - .buttonStyle(.borderedProminent) - .disabled(newConfigName.isEmpty) - } - } - .padding() - .frame(width: 300) - .background(RoundedRectangle(cornerRadius: 12).fill(Color(nsColor: .windowBackgroundColor))) - .shadow(radius: 20) - .zIndex(105) - .transition(.scale.combined(with: .opacity)) - } - - - - } - .onChange(of: configManager.configFiles) { newFiles in - // 如果列表不为空,且当前没选中的,或者选中的不在新列表里 -> 选第一个 - if !newFiles.isEmpty { - if selectedConfig == nil || !newFiles.contains(selectedConfig!) { - selectedConfig = newFiles.first - } - } else { - selectedConfig = nil + .frame(width: DashboardLayoutMetrics.windowWidth, height: DashboardLayoutMetrics.windowHeight) } + overlayView } - // 移除了 onChange(of: selectedConfig) 的自动连接逻辑 .onAppear { // 加载 VPN 配置 VPNManager.shared.loadManager() // 初始启动时刷新一次列表 - configManager.refreshConfigs() - - // 刷新后立刻尝试选中 - if !configManager.configFiles.isEmpty && selectedConfig == nil { - selectedConfig = configManager.configFiles.first - } + dashboardState.handleConfigFilesChanged(configManager.refreshConfigs()) // 设置窗口可见,开始动画 runner.isWindowVisible = true } + .onChange(of: configManager.configFiles) { dashboardState.handleConfigFilesChanged($0) } .onDisappear { runner.isWindowVisible = false } - .onReceive(runner.$isRunning) { self.isRunning = $0 } - .onReceive(runner.$isWindowVisible) { self.isWindowVisible = $0 } - .onReceive(runner.$sessionID) { self.sessionID = $0 } .lockVerticalScroll() // 🔒 Global Lock: Prevents the entire window container from bouncing } - // MARK: - Header - - // MARK: - Header - - private var headerView: some View { - HStack { - Menu { - Section("配置文件") { - // Show storage mode with icon - if configManager.cloudKitSyncEnabled { - Label("存储方式: CloudKit 同步", systemImage: "icloud") - .font(.caption) - .foregroundColor(.secondary) - } else if FileManager.default.ubiquityIdentityToken != nil { - Label("存储位置: 本地(可启用 CloudKit)", systemImage: "internaldrive") - .font(.caption) - .foregroundColor(.secondary) - } else { - Label("存储位置: 本地 (iCloud 未启用)", systemImage: "internaldrive") - .font(.caption) - .foregroundColor(.secondary) - } - - if configManager.configFiles.isEmpty { - Button("未发现配置") { } - .disabled(true) - } else { - ForEach(configManager.configFiles, id: \.self) { url in - Button(action: { selectedConfig = url }) { - HStack { - Text(url.deletingPathExtension().lastPathComponent) - if selectedConfig == url { - Image(systemName: "checkmark") - } - } - } - } - } - } - - Divider() - - Button("创建新网络") { - newConfigName = "" - createConfigError = nil - withAnimation { showCreatePrompt = true } - } - - Button("编辑配置") { - withAnimation { showConfigGenerator = true } - } - .disabled(selectedConfig == nil) - - Button("编辑配置为文件") { - withAnimation { - editingConfigURL = selectedConfig - } - } - .disabled(selectedConfig == nil) - - Divider() - - if configManager.cloudKitSyncEnabled { - Button("关闭 CloudKit 同步") { configManager.disableCloudKitSync() } - } else { - Button("同步到 iCloud") { configManager.migrateToiCloud() } - } - Button("选择文件夹") { configManager.selectCustomFolder() } - .disabled(configManager.cloudKitSyncEnabled) - Button("在 Finder 中打开") { configManager.openiCloudFolder() } - - - Divider() - - Button(role: .destructive) { deleteSelectedConfig() } label: { - Text(LocalizedStringKey("删除选中的配置")) - .foregroundColor(.red) - } - .disabled(selectedConfig == nil) - } label: { - HStack { - Image(systemName: "point.3.connected.trianglepath.dotted") - Text(selectedConfig?.deletingPathExtension().lastPathComponent ?? NSLocalizedString("请选择配置", comment: "")) - .lineLimit(1) - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .cornerRadius(6) - } - .menuStyle(.borderlessButton) - - Spacer() - - // 右侧按钮组:日志、设置、退出 - HStack(spacing: 6) { // 极简间距 - // 日志按钮 - Button(action: { - withAnimation { - showLogView = true - } - }) { - Image(systemName: "doc.text") - .font(.system(size: 14)) - .padding(5) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - - // 设置按钮 - Button(action: { - withAnimation { - showSettingsView = true - } - }) { - Image(systemName: "gearshape") - .font(.system(size: 14)) - .padding(5) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - - // 退出按钮 - Button(action: { - // Connect On Demand 模式:退出 App 不影响 VPN - // VPN 由系统管理,App 只是 UI 控制面板 - NSApplication.shared.terminate(nil) - }) { - Image(systemName: "power") - .font(.system(size: 14)) // 恢复默认粗细 - .foregroundColor(.red) - .padding(5) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - // 移除了 .padding(.trailing, 12),让左右边距一致(由外层 padding 控制) - } - .padding(12) - .zIndex(200) // 确保 Header 在最上层,防止点击被下方内容遮挡 - } - private var contentArea: some View { GeometryReader { geo in ZStack { // 1) 水波纹层 (放在最底层) - UIKit 高性能实现 - if isRunning && isWindowVisible { + if runner.isRunning && runner.isWindowVisible { RippleRingsView(isVisible: true, duration: 4.0, maxScale: 5.5) - .frame(width: 500, height: 500) - .position(x: geo.size.width / 2, y: buttonCenterY(in: geo.size.height)) + .frame(width: DashboardLayoutMetrics.rippleSize, height: DashboardLayoutMetrics.rippleSize) + .position( + x: geo.size.width / 2, + y: DashboardLayoutMetrics.buttonCenterY( + isRunning: runner.isRunning, + contentHeight: geo.size.height + ) + ) .allowsHitTesting(false) .transition(.opacity) // Fade in .zIndex(0) } // 2) 节点列表区域 - 使用独立组件隔离刷新 - if isRunning && isWindowVisible && !isAnyOverlayShown { + if runner.isRunning && runner.isWindowVisible && !dashboardState.isAnyOverlayShown { PeerListArea() - .id(sessionID) + .id(runner.sessionID) .frame(width: geo.size.width, height: geo.size.height) .transition(.move(edge: .bottom).combined(with: .opacity)) .zIndex(1) } // 3) 启动按钮与网速仪表盘层 - 使用独立组件隔离刷新 - if isWindowVisible { + if runner.isWindowVisible { SpeedDashboard( - selectedConfigPath: selectedConfig?.path ?? configManager.configFiles.first?.path ?? "", geoSize: geo.size, - buttonCenterY: buttonCenterY(in: geo.size.height), - isPaused: isAnyOverlayShown + buttonCenterY: DashboardLayoutMetrics.buttonCenterY( + isRunning: runner.isRunning, + contentHeight: geo.size.height + ), + isPaused: dashboardState.isAnyOverlayShown, + isConnected: vpnManager.isConnected, + status: vpnManager.status, + canToggleConnection: dashboardState.canToggleConnection, + onToggleConnection: dashboardState.toggleConnection ) .zIndex(10) } } - .animation(.spring(response: 1.0, dampingFraction: 0.8), value: isRunning) - .animation(.spring(response: 0.55, dampingFraction: 0.8), value: showLogView) - .blur(radius: isAnyOverlayShown ? 10 : 0) - .opacity(isAnyOverlayShown ? 0.3 : 1.0) + .animation(.spring(response: 1.0, dampingFraction: 0.8), value: runner.isRunning) + .animation(.spring(response: 0.55, dampingFraction: 0.8), value: dashboardState.overlayRoute) + .blur(radius: dashboardState.isAnyOverlayShown ? 10 : 0) + .opacity(dashboardState.isAnyOverlayShown ? 0.3 : 1.0) } } - - private func buttonCenterY(in contentHeight: CGFloat) -> CGFloat { - isRunning ? 133 : (contentHeight / 2) // Centered between duration (Y=20) and peer cards (Y=234) - } - - // MARK: - SpeedCard Component - struct SpeedCard: View, Equatable { - let title: String - let value: String // e.g. "133.3 KB/s" - let icon: String - let color: Color - let history: [Double] - let maxVal: Double - let isVisible: Bool - let isPaused: Bool - - // 性能关键:手动实现 Equatable 避开不必要的重绘 - static func == (lhs: SpeedCard, rhs: SpeedCard) -> Bool { - lhs.value == rhs.value && - lhs.history == rhs.history && - lhs.maxVal == rhs.maxVal && - lhs.isVisible == rhs.isVisible && - lhs.isPaused == rhs.isPaused - } - - // Helper to split value and unit - private var splitValue: (number: String, unit: String) { - let components = value.components(separatedBy: " ") - if components.count >= 2 { - return (components[0], components[1]) - } - return (value, "") - } - - var body: some View { - ZStack(alignment: .bottom) { - // Sparkline (Background layer) - UIKit 高性能实现 - // 当整体可见时,传入 paused=false - SmartSparklineView(data: history, color: color, maxScale: maxVal, paused: isPaused) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.top, 24) - .zIndex(0) - .allowsHitTesting(false) - - // Content (Foreground layer - Floating above sparkline) - VStack(alignment: .leading, spacing: 2) { - // Title (Left Aligned) - HStack(spacing: 4) { - Image(systemName: icon) - .foregroundColor(color) - .font(.system(size: 10, weight: .bold)) - Text(title) - .font(.system(size: 9, weight: .bold)) - .foregroundColor(color.opacity(0.8)) - } - .padding(.top, 10) - - Spacer() - - // Value (Split style - Centered) - HStack(alignment: .firstTextBaseline, spacing: 3) { - Text(splitValue.number) - .font(.system(size: 24, weight: .bold, design: .monospaced)) - Text(splitValue.unit) - .font(.system(size: 11, weight: .bold, design: .monospaced)) - } - .frame(maxWidth: .infinity, alignment: .center) - - Spacer() - - Color.clear.frame(height: 12) - } - .padding(.horizontal, 12) - .zIndex(20) - } - .frame(maxWidth: .infinity) - .frame(height: 85) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color(nsColor: .windowBackgroundColor).opacity(0.6)) - ) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(color.opacity(0.2), lineWidth: 1) - ) - } - } - - // MARK: - 速度仪表盘(独立组件,隔离频繁刷新) - struct SpeedDashboard: View { - let selectedConfigPath: String - let geoSize: CGSize - let buttonCenterY: CGFloat - let isPaused: Bool // 新增:是否暂停 - - // 直接订阅 runner,只有这个组件会被频繁刷新 - @ObservedObject private var runner = SpotierRunner.shared - @ObservedObject private var vpnManager = VPNManager.shared - - var body: some View { - let maxSpeed = runner.maxHistorySpeed // 直接使用缓存,不再遍历数组 - - HStack(spacing: -6) { - if runner.isRunning && runner.isWindowVisible { - SpeedCard( - title: "DOWNLOAD", - value: runner.downloadSpeed, - icon: "arrow.down.square.fill", - color: .blue, - history: runner.downloadHistory, - maxVal: maxSpeed, - isVisible: true, - isPaused: isPaused - ) - .equatable() - .transition(.move(edge: .bottom).combined(with: .opacity)) - } - - Button { - if vpnManager.isConnected { - vpnManager.disableOnDemandAndStop() - } else { - guard !selectedConfigPath.isEmpty else { - vpnManager.statusText = "未选择配置文件" - return - } - - // 通过 ConfigManager 读取(处理安全域书签) - let configURL = URL(fileURLWithPath: selectedConfigPath) - if let content = try? ConfigManager.shared.readConfigContent(configURL) { - vpnManager.startVPN(configContent: content) - } else { - vpnManager.statusText = "读取配置失败: \(configURL.lastPathComponent)" - print("无法读取配置文件: \(selectedConfigPath)") - } - } - } label: { - StartStopButtonCore( - isRunning: vpnManager.isConnected, - uptimeText: runner.uptimeText, // TODO: 需要从 VPN 获取真实的 uptime - status: vpnManager.status - ) - } - .buttonStyle(.plain) - .disabled(!vpnManager.isConnected && selectedConfigPath.isEmpty) - .zIndex(20) - - if runner.isRunning && runner.isWindowVisible { - SpeedCard( - title: "UPLOAD", - value: runner.uploadSpeed, - icon: "arrow.up.square.fill", - color: .orange, - history: runner.uploadHistory, - maxVal: maxSpeed, - isVisible: true, - isPaused: isPaused - ) - .equatable() - .transition(.move(edge: .bottom).combined(with: .opacity)) - } - } - .padding(.horizontal, 16) - .frame(width: geoSize.width) - .position(x: geoSize.width / 2, y: buttonCenterY) - } - } - - // MARK: - 节点列表区域(独立组件,隔离 peers 刷新) - struct PeerListArea: View { - @StateObject private var runner = SpotierRunner.shared - - // 定义两行网格布局,自适应宽度 - private let gridRows = [ - GridItem(.fixed(105), spacing: 12), - GridItem(.fixed(105), spacing: 12) - ] - - var body: some View { - let peerIDs = runner.peers.map(\.id) - - return VStack { - Spacer() - - ZStack { - // 1) Grid 永远存在:保证后续插入/删除是“对已有容器的增删”,让 transition 生效 - ScrollView(.horizontal, showsIndicators: false) { - LazyHGrid(rows: gridRows, spacing: 12) { - ForEach(runner.peers) { peer in - PeerCard(peer: peer) - .equatable() - .frame(width: 188) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .transition( - .asymmetric( - insertion: .move(edge: .bottom).combined(with: .opacity), - removal: .opacity - ) - ) - } - } - .padding(.horizontal, 16) - .contentShape(Rectangle()) - } - .preventVerticalBounce() - .frame(height: 222) - // 2) Loading 仅作为覆盖层,不控制 Grid 的创建/销毁(避免“只有第一张动、后面闪现”) - if runner.isRunning && runner.peers.isEmpty { - VStack(spacing: 20) { - ProgressView().scaleEffect(1.2).controlSize(.large) - Text(LocalizedStringKey("节点加载中")) - .font(.title3.bold()) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity) - .frame(height: 222) - .transition(.opacity) - } - } - .frame(maxWidth: .infinity) - .frame(height: 222) - .padding(.bottom, 16) - } - .frame(maxWidth: .infinity) - // 只对「ID 列表」绑定动画:增/减/重排会动画,纯数值刷新不会每秒抖动 - .animation(.spring(response: 0.5, dampingFraction: 0.82), value: peerIDs) - } - } - - // MARK: - Sparkline Component (Wrapper for External Implementation) - struct Sparkline: View { - let data: [Double] - let color: Color - let maxScale: Double - let paused: Bool - - var body: some View { - SmartSparklineView(data: data, color: color, maxScale: maxScale, paused: paused) - } - } - - // MARK: - Helper Functions - - private func createConfig() { - let name = newConfigName.trimmingCharacters(in: .whitespacesAndNewlines) - let safeName = name.isEmpty ? "new-network" : name - let filename = "\(safeName).toml" - - let header = """ - instance_name = "\(safeName)" - instance_id = "\(UUID().uuidString.lowercased())" - dhcp = true - listeners = ["tcp://0.0.0.0:11010", "udp://0.0.0.0:11010", "wg://0.0.0.0:11011"] - - [network_identity] - network_name = "easytier" - network_secret = "" - - [[peer]] - uri = "tcp://public.easytier.top:11010" - - [flags] - mtu = 1380 - disable_ipv6 = false - disable_encryption = false - """ - - guard let currentDir = configManager.currentDirectory else { - createConfigError = "请先在菜单中选择配置文件夹" - return - } - let fileURL = currentDir.appendingPathComponent(filename) - - if FileManager.default.fileExists(atPath: fileURL.path) { - createConfigError = "文件已存在: \(filename)" - return - } - - do { - try header.write(to: fileURL, atomically: true, encoding: .utf8) - let updatedFiles = configManager.refreshConfigs() - - // Find and select - if let newURL = updatedFiles.first(where: { $0.lastPathComponent == filename }) { - selectedConfig = newURL - // Open Editor - withAnimation { - showCreatePrompt = false - showConfigGenerator = true - } - } else { - withAnimation { showCreatePrompt = false } - } - } catch { - createConfigError = "创建失败: \(error.localizedDescription)" - print("Failed to create file: \(error)") - } - } - - private func deleteSelectedConfig() { - guard let url = selectedConfig else { return } - configManager.deleteConfig(url) - - // Auto select next if available is handled by onChange - } -} + @ViewBuilder + private var overlayView: some View { + if let route = dashboardState.overlayRoute { + switch route { + case .log: + LogView(isPresented: overlayBinding(for: .log)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.regularMaterial) + .compositingGroup() + .zIndex(100) + .transition(.move(edge: .bottom)) + case .settings: + SettingsView(isPresented: overlayBinding(for: .settings)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.regularMaterial) + .zIndex(101) + .transition(.move(edge: .bottom)) -// MARK: - Start/Stop Button 核心视图 + case let .editor(url): + ConfigEditorView( + isPresented: Binding( + get: { dashboardState.editingConfigURL == url }, + set: { if !$0 { dashboardState.closeOverlay() } } + ), + fileURL: url + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.regularMaterial) + .zIndex(102) + .transition(.move(edge: .bottom)) -struct StartStopButtonCore: View { - let isRunning: Bool - let uptimeText: String - var status: NEVPNStatus = .disconnected + case .generator: + ConfigGeneratorView( + isPresented: overlayBinding(for: .generator), + editingFileURL: dashboardState.selectedConfig, + onSave: { dashboardState.handleConfigFilesChanged(configManager.refreshConfigs()) } + ) + .id(dashboardState.selectedConfig) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .zIndex(103) + .transition(.move(edge: .bottom)) - var body: some View { - ZStack { - // 圆按钮背景 - Circle() - // 启动前:保持原样(蓝色或逻辑原色) - // 启动后:变为 0.9 透明度的白色 - .fill(buttonColor) - .frame(width: 84, height: 84) - .shadow(color: .black.opacity(isRunning ? 0.12 : 0.25), radius: 10, y: 4) - - if status == .connecting || status == .disconnecting { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - .controlSize(.regular) - } else { - Image(systemName: "power") - .font(.system(size: 28, weight: .regular)) - // 启动后图标为黑色,启动前为白色 - .foregroundStyle(isRunning ? Color.black : Color.white) - } + case .createPrompt: + Color.black.opacity(0.3) + .zIndex(104) + .onTapGesture { dashboardState.closeCreatePrompt() } - if isRunning { - Text(uptimeText) - .font(.system(size: 22, weight: .bold, design: .monospaced)) // Increased size & weight - .foregroundColor(.primary) - .frame(width: 140) - .lineLimit(1) - .minimumScaleFactor(0.7) - .offset(y: -113) // Restored to top position (133 - 113 = 20) to match original layout while centering button below it + CreateConfigPrompt( + newConfigName: $dashboardState.newConfigName, + errorMessage: dashboardState.createConfigError, + onCancel: dashboardState.closeCreatePrompt, + onConfirm: dashboardState.createConfig + ) + .zIndex(105) + .transition(.scale.combined(with: .opacity)) } } - .frame(width: 84, height: 84) - .padding(.vertical, 6) } - - private var buttonColor: Color { - if isRunning { return .white } - switch status { - case .connecting, .disconnecting: return .orange - case .connected: return .white - case .disconnected, .invalid: return .blue - case .reasserting: return .yellow - @unknown default: return .blue - } - } -} - - - - - -// MARK: - Native Horizontal Scroller (Fixes SwiftUI vertical bounce bug on Mac) -// MARK: - Native Horizontal Scroller (The Nuclear Option) -class HorizontalOnlyScrollView: NSScrollView { - override func scrollWheel(with event: NSEvent) { - // Logic: Swallow vertical-dominant events to prevent bounce propagation. - // Allow horizontal events to pass through naturally. - - if abs(event.scrollingDeltaY) > abs(event.scrollingDeltaX) { - // Dominantly vertical: Swallow the event. - // Do NOT call super. This stops scrolling AND stops bounce propagation upwards. - } else { - // Dominantly horizontal (or zero/stationary): Pass it to the scroll view to handle. - super.scrollWheel(with: event) - } - } -} + // MARK: - Helper Functions -struct NativeHorizontalScroller: NSViewRepresentable { - let content: Content - init(@ViewBuilder content: @escaping () -> Content) { self.content = content() } - - func makeNSView(context: Context) -> NSScrollView { - let scroller = HorizontalOnlyScrollView() // Use our subclass - scroller.hasHorizontalScroller = false - scroller.hasVerticalScroller = false - scroller.drawsBackground = false - scroller.autohidesScrollers = true - scroller.horizontalScrollElasticity = .allowed - scroller.verticalScrollElasticity = .none // The Holy Grail - - let hostingView = NSHostingView(rootView: content) - hostingView.translatesAutoresizingMaskIntoConstraints = false - scroller.documentView = hostingView - - if let doc = scroller.documentView { - // 🚫 CRITICAL FIX: Only anchor Top and Left. - // Do NOT anchor Bottom. This allows content (218pt) to be smaller than View (222pt). - // When content < viewport, macOS physically cannot rubber-band vertically. - doc.topAnchor.constraint(equalTo: scroller.contentView.topAnchor).isActive = true - doc.leadingAnchor.constraint(equalTo: scroller.contentView.leadingAnchor).isActive = true - // We DO need to ensure the hosting view takes its own intrinsic size - } - return scroller - } - - func updateNSView(_ nsView: NSScrollView, context: Context) { - if let host = nsView.documentView as? NSHostingView { - host.rootView = content - - // 确保同步更新尺寸以适应内容变化,这能让 SwiftUI 内部的 transition 更稳定 - let fittingSize = host.fittingSize - if host.frame.size != fittingSize { - host.frame.size = fittingSize + private func overlayBinding(for route: DashboardOverlayRoute) -> Binding { + Binding( + get: { dashboardState.isPresenting(route) }, + set: { isPresented in + if isPresented { + dashboardState.openOverlay(route) + } else if dashboardState.isPresenting(route) { + dashboardState.closeOverlay() + } } - } + ) } } diff --git a/Spotier/CopyableDetailRow.swift b/Spotier/CopyableDetailRow.swift new file mode 100644 index 0000000..afb5c05 --- /dev/null +++ b/Spotier/CopyableDetailRow.swift @@ -0,0 +1,58 @@ +import SwiftUI +import AppKit + +struct CopyableDetailRow: View { + let label: LocalizedStringKey + let value: String + var isMonospaced: Bool = false + + @State private var isCopied = false + + var body: some View { + Button(action: copyValue) { + HStack(alignment: .center, spacing: 0) { + Text(label) + .font(.system(size: 13)) + .foregroundColor(.primary) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .layoutPriority(1) + + Spacer(minLength: 8) + + Text(LocalizedStringKey(value)) + .font( + isMonospaced + ? .system(size: 13, weight: .regular, design: .monospaced) + : .system(size: 13) + ) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(0) + .opacity(isCopied ? 0.5 : 1.0) + } + .padding(.vertical, 2) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(value) + } + + private func copyValue() { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(value, forType: .string) + + withAnimation(.easeInOut(duration: 0.1)) { + isCopied = true + } + + Task { @MainActor in + try? await Task.sleep(for: .seconds(0.2)) + withAnimation { + isCopied = false + } + } + } +} diff --git a/Spotier/EasyTierShared.swift b/Spotier/EasyTierShared.swift index a366925..228e91b 100644 --- a/Spotier/EasyTierShared.swift +++ b/Spotier/EasyTierShared.swift @@ -1,12 +1,26 @@ +import Foundation import NetworkExtension import os -public let APP_BUNDLE_ID: String = "com.alick.swiftier" +public let APP_BUNDLE_ID: String = "com.alick.spotier" public let APP_GROUP_ID: String = "group.com.alick.spotier" public let ICLOUD_CONTAINER_ID: String = "iCloud.com.alick.spotier" public let LOG_FILENAME: String = "easytier.log" +public func appGroupDefaults() -> UserDefaults? { + UserDefaults(suiteName: APP_GROUP_ID) +} + +public func appGroupContainerURL(fileManager: FileManager = .default) -> URL? { + fileManager.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP_ID) +} + +public func appGroupFileURL(_ filename: String, fileManager: FileManager = .default) -> URL? { + appGroupContainerURL(fileManager: fileManager)?.appendingPathComponent(filename) +} + public enum LogLevel: String, Codable, CaseIterable { + case off = "off" case trace = "trace" case debug = "debug" case info = "info" @@ -14,6 +28,64 @@ public enum LogLevel: String, Codable, CaseIterable { case error = "error" } +public enum StoredLogLevel: String, Codable, CaseIterable { + case off = "OFF" + case error = "ERROR" + case warn = "WARN" + case info = "INFO" + case debug = "DEBUG" + case trace = "TRACE" + + public init(storedValue: String) { + self = StoredLogLevel(rawValue: storedValue.uppercased()) ?? .info + } + + public var effectiveLogLevel: LogLevel { + switch self { + case .off: return .off + case .error: return .error + case .warn: return .warn + case .info: return .info + case .debug: return .debug + case .trace: return .trace + } + } + + public func allows(_ logLevel: LogLevel) -> Bool { + rank(of: logLevel) <= rank + } + + private var rank: Int { + switch self { + case .off: return 0 + case .error: return 1 + case .warn: return 2 + case .info: return 3 + case .debug: return 4 + case .trace: return 5 + } + } + + private func rank(of logLevel: LogLevel) -> Int { + switch logLevel { + case .off: return 0 + case .error: return 1 + case .warn: return 2 + case .info: return 3 + case .debug: return 4 + case .trace: return 5 + } + } +} + +public func readStoredLogLevel(defaults: UserDefaults? = appGroupDefaults()) -> StoredLogLevel { + StoredLogLevel(storedValue: defaults?.string(forKey: "logLevel") ?? StoredLogLevel.info.rawValue) +} + +public func writeStoredLogLevel(_ level: StoredLogLevel, defaults: UserDefaults? = appGroupDefaults()) { + defaults?.set(level.rawValue, forKey: "logLevel") +} + public struct EasyTierOptions: Codable { public var config: String = "" public var ipv4: String? @@ -144,7 +216,7 @@ public enum ProviderCommand: String, Codable, CaseIterable { public func connectWithManager(_ manager: NETunnelProviderManager, logger: Logger? = nil) async throws { manager.isEnabled = true - if let defaults = UserDefaults(suiteName: APP_GROUP_ID) { + if let defaults = appGroupDefaults() { manager.protocolConfiguration?.includeAllNetworks = defaults.bool(forKey: "includeAllNetworks") manager.protocolConfiguration?.excludeLocalNetworks = defaults.bool(forKey: "excludeLocalNetworks") if #available(iOS 16.4, *) { diff --git a/Spotier/EventListView.swift b/Spotier/EventListView.swift index ce6c717..d14f55e 100644 --- a/Spotier/EventListView.swift +++ b/Spotier/EventListView.swift @@ -64,7 +64,7 @@ struct EventListView: View { .padding(.trailing, 8) VStack(alignment: .leading, spacing: 8) { - Text(event.type.rawValue) + Text(event.name) .font(.system(size: 16, weight: .bold)) CharWrappingJSONView(json: event.details, highlights: event.highlights ?? [], eventId: event.id) @@ -83,18 +83,13 @@ struct EventListView: View { .scrollContentBackground(.hidden) .listStyle(.plain) - Button { + FloatingScrollTopButton(action: { if let topEvent = events.last { withAnimation(.easeInOut(duration: 0.3)) { proxy.scrollTo(topEvent.id, anchor: .top) } } - } label: { - Image(systemName: "arrow.up") - .font(.system(size: 20, weight: .bold)) - .modifier(FlatCircleButtonModifier()) - } - .buttonStyle(.plain) + }) .padding(16) } } @@ -175,20 +170,3 @@ struct CharWrappingJSONView: NSViewRepresentable { return attributed } } - -struct FlatCircleButtonModifier: ViewModifier { - func body(content: Content) -> some View { - content - .foregroundStyle(.white) - .padding(12) // 保持足够的点击区域 - .background( - Circle() - .fill(Color.blue) - ) - .contentShape(Circle()) - .onHover { isHovering in - if isHovering { NSCursor.pointingHand.push() } - else { NSCursor.pop() } - } - } -} diff --git a/Spotier/FloatingScrollTopButton.swift b/Spotier/FloatingScrollTopButton.swift new file mode 100644 index 0000000..6c7a13f --- /dev/null +++ b/Spotier/FloatingScrollTopButton.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct FloatingScrollTopButton: View { + let action: () -> Void + var size: CGFloat = 20 + + var body: some View { + Button(action: action) { + Image(systemName: "arrow.up") + .font(.system(size: size, weight: .bold)) + .foregroundStyle(.white) + .padding(12) + .background( + Circle() + .fill(Color.blue) + ) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .onHover { isHovering in + if isHovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + } +} diff --git a/Spotier/Localizable.xcstrings b/Spotier/Localizable.xcstrings index f7057dc..875cb8a 100644 --- a/Spotier/Localizable.xcstrings +++ b/Spotier/Localizable.xcstrings @@ -3,9 +3,6 @@ "strings" : { "" : { - }, - "- ms" : { - }, ":" : { @@ -661,6 +658,17 @@ } } }, + "关闭 iCloud Drive" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Turn Off iCloud Drive" + } + } + } + }, "出口节点列表" : { "extractionState" : "manual", "localizations" : { @@ -815,28 +823,6 @@ } } }, - "同步到 iCloud" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Enable CloudKit Sync" - } - } - } - }, - "关闭 CloudKit 同步" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Disable CloudKit Sync" - } - } - } - }, "名称" : { "extractionState" : "manual", "localizations" : { @@ -1060,46 +1046,57 @@ } } }, - "存储位置: 本地(可启用 CloudKit)" : { + "存储位置: 本地 (iCloud 未启用)" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Storage: Local (CloudKit available)" + "value" : "Storage: Local (iCloud disabled)" } } } }, - "存储位置: 本地 (iCloud 未启用)" : { + "存储位置: 本地(可切换到 iCloud Drive)" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Storage: Local (iCloud disabled)" + "value" : "Storage: Local (switch to iCloud Drive)" } } } }, - "存储方式: CloudKit 同步" : { + "存储到 iCloud" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Storage: CloudKit Sync" + "value" : "Store in iCloud" } } } }, - "存储到 iCloud" : { + "存储到 iCloud Drive" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Store in iCloud" + "value" : "Store in iCloud Drive" + } + } + } + }, + "存储方式: iCloud Drive" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Storage: iCloud Drive" } } } @@ -1470,17 +1467,6 @@ } } }, - "未知节点" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Unknown Peer" - } - } - } - }, "本地地址" : { "extractionState" : "manual", "localizations" : { @@ -2360,6 +2346,9 @@ } } } + }, + "连接 %lld" : { + }, "连接 %lld [%@]" : { "extractionState" : "manual", @@ -2572,4 +2561,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/Spotier/LogListView.swift b/Spotier/LogListView.swift index 91252cf..c46c0bf 100644 --- a/Spotier/LogListView.swift +++ b/Spotier/LogListView.swift @@ -39,21 +39,27 @@ struct LogListView: View { let displayLogs = Array(filteredLogs.reversed()) ForEach(displayLogs) { log in let index = displayLogs.firstIndex(where: { $0.id == log.id }) ?? 0 - LogListRow(log: log, timestampFormatted: formatTimestamp(log.timestamp), isSelected: selectedLog?.id == log.id) - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) - .listRowSeparator(.hidden) - .listRowBackground( - (selectedLog?.id == log.id) - ? Color.blue.opacity(0.15) - : (index % 2 == 0 ? Color(nsColor: .textBackgroundColor) : Color.primary.opacity(0.04)) - ) - .contentShape(Rectangle()) - .onTapGesture { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - selectedLog = (selectedLog?.id == log.id) ? nil : log - } + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + selectedLog = (selectedLog?.id == log.id) ? nil : log } - .id(log.id) + } label: { + LogListRow( + log: log, + timestampFormatted: formatTimestamp(log.timestamp), + isSelected: selectedLog?.id == log.id + ) + } + .buttonStyle(.plain) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listRowSeparator(.hidden) + .listRowBackground( + (selectedLog?.id == log.id) + ? Color.blue.opacity(0.15) + : (index % 2 == 0 ? Color(nsColor: .textBackgroundColor) : Color.primary.opacity(0.04)) + ) + .contentShape(Rectangle()) + .id(log.id) } } .listStyle(.plain) @@ -61,16 +67,11 @@ struct LogListView: View { .background(Color(nsColor: .textBackgroundColor)) if logs.count > 5 { - Button { + FloatingScrollTopButton(action: { if let topLog = logs.last { withAnimation(.easeInOut(duration: 0.3)) { proxy.scrollTo(topLog.id, anchor: .top) } } - } label: { - Image(systemName: "arrow.up") - .font(.system(size: 16, weight: .bold)) - .modifier(FlatCircleButtonModifier()) - } - .buttonStyle(.plain) + }, size: 16) .padding(12) .transition(.scale.combined(with: .opacity)) } @@ -112,7 +113,7 @@ struct LogListRow: View { // Bottom Meta Row: Timestamp | Level HStack(spacing: 6) { - Text(timestampFormatted.isEmpty ? "---- -- -- --:--:--" : timestampFormatted) + Text(timestampFormatted) .font(.system(size: 9, design: .monospaced)) .foregroundColor(.secondary) diff --git a/Spotier/LogModels.swift b/Spotier/LogModels.swift index 5ff0418..29ded58 100644 --- a/Spotier/LogModels.swift +++ b/Spotier/LogModels.swift @@ -23,6 +23,7 @@ struct LogEntry: Identifiable, Equatable { extension LogLevel { var color: Color { switch self { + case .off: return .gray case .error: return .red case .warn: return .orange case .info: return .green @@ -34,14 +35,16 @@ extension LogLevel { struct EventEntry: Identifiable, Equatable, Codable { let id: UUID + let name: String let timestamp: String let date: Date? let type: EventType let details: String let highlights: [HighlightRange]? - init(id: UUID = UUID(), timestamp: String, date: Date?, type: EventType, details: String, highlights: [HighlightRange]? = nil) { + init(id: UUID = UUID(), name: String, timestamp: String, date: Date?, type: EventType, details: String, highlights: [HighlightRange]? = nil) { self.id = id + self.name = name self.timestamp = timestamp self.date = date self.type = type @@ -60,7 +63,7 @@ struct EventEntry: Identifiable, Equatable, Codable { case tunDeviceReady = "TunDeviceReady" case listenerAdded = "ListenerAdded" case handshake = "Handshake" - case unknown = "Event" + case unknown = "" var color: Color { switch self { diff --git a/Spotier/LogParser.swift b/Spotier/LogParser.swift index 13940d1..3c37560 100644 --- a/Spotier/LogParser.swift +++ b/Spotier/LogParser.swift @@ -1,6 +1,7 @@ import SwiftUI import Combine +@MainActor class LogParser: ObservableObject { static let shared = LogParser() @@ -9,49 +10,24 @@ class LogParser: ObservableObject { var isPaused = false private var pendingLogs: [LogEntry] = [] - - // Persistence - private var eventsFileURL: URL { - let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - let dir = appSupport.appendingPathComponent("Spotier") - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - return dir.appendingPathComponent("events.json") - } - - private var fileHandle: FileHandle? + private let logResolver: LogContainerResolver + private let logSettingsStore: LogSettingsStore private init() { + self.logResolver = .shared + self.logSettingsStore = .shared self.events = [] } - - private func saveEvents() { - // Disabled: We no longer persist events to disk to ensure Core-driven lifecycle. - } - private var timer: Timer? private var xpcEventTimer: Timer? - var xpcEventIndex: Int = 0 - private var logPath: String { - if let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.alick.swiftier") { - return containerURL.appendingPathComponent("easytier.log").path - } - return "/var/log/swiftier-helper.log" - } private var isReading = false private var lastReadOffset: UInt64 = 0 private var trailingRemainder = "" - private let maxFullTextLength = 20000 private let maxLogItems = 1000 private let maxEventItems = 200 private var seenEventHashes: Set = [] - private let iso8601Formatter: ISO8601DateFormatter = { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter - }() - func startMonitoring() { // Source 1: XPC Events (Structured, Real-time) // In Sandbox mode, we cannot read /var/log/ directly. @@ -64,9 +40,10 @@ class LogParser: ObservableObject { @available(macOS 13.0, *) private func startXPCEventPolling() { xpcEventTimer?.invalidate() - // Frequency back to 1.0s xpcEventTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in - self?.pollXPCEvents() + Task { @MainActor [weak self] in + self?.pollXPCEvents() + } } pollXPCEvents() } @@ -75,10 +52,6 @@ class LogParser: ObservableObject { private func pollXPCEvents() { readNewRawLines() } - - private func startRawLogMonitoring() { - // Removed for Sandbox compatibility - } // MARK: - Pre-compiled Regex Cache (Optimized for performance) private enum Regex { @@ -86,11 +59,6 @@ class LogParser: ObservableObject { static let timestamp = try! NSRegularExpression(pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}[T ][0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?(Z|[+-][0-9]{2}:?[0-9]{2})?", options: []) static let levelPrefix = try! NSRegularExpression(pattern: "^(INFO|WARN|ERROR|DEBUG|TRACE)\\b", options: [.caseInsensitive]) static let helperPrefix = try! NSRegularExpression(pattern: "^(\\[.*?\\] )?\\[Helper\\]( Core output:)?", options: []) - - static let highlightStrings = try! NSRegularExpression(pattern: #""([^"\\]|\\.)*""#, options: []) - static let highlightKeys = try! NSRegularExpression(pattern: #"("[^"]+"|\b[a-zA-Z_][a-zA-Z0-9_]*\b)\s*:"#, options: []) - static let highlightNumbers = try! NSRegularExpression(pattern: #"\b\d+(\.\d+)?\b"#, options: []) - static let highlightKeywords = try! NSRegularExpression(pattern: #"\b(true|false|null|None|Some|Ok|Err)\b"#, options: []) } private func removeAnsiCodes(_ text: String) -> String { @@ -103,10 +71,7 @@ class LogParser: ObservableObject { var result: [LogEntry] = [] let now = ISO8601DateFormatter().string(from: Date()) - // 获取全局设置级别(从 App Group 读取) - let settingLevelStr = UserDefaults(suiteName: "group.com.alick.swiftier")?.string(forKey: "logLevel") ?? "INFO" - let levelMap: [String: Int] = ["OFF": 0, "ERROR": 1, "WARN": 2, "INFO": 3, "DEBUG": 4, "TRACE": 5] - let currentLevelValue = levelMap[settingLevelStr.uppercased()] ?? 5 + let currentLogLevel = logSettingsStore.readLevel() for line in lines { let raw = removeAnsiCodes(line).replacingOccurrences(of: "\0", with: "") @@ -121,8 +86,6 @@ class LogParser: ObservableObject { var timestamp = "" var level: LogLevel = .info - var lineLevelValue = 3 // Default INFO - let contentRange = NSRange(location: 0, length: content.utf16.count) if let match = Regex.timestamp.firstMatch(in: content, range: contentRange) { timestamp = (content as NSString).substring(with: match.range) @@ -132,17 +95,12 @@ class LogParser: ObservableObject { } let levelSearchArea = content.prefix(20).uppercased() - if levelSearchArea.contains("ERROR") { level = .error; lineLevelValue = 1 } - else if levelSearchArea.contains("WARN") { level = .warn; lineLevelValue = 2 } - else if levelSearchArea.contains("DEBUG") { level = .debug; lineLevelValue = 4 } - else if levelSearchArea.contains("TRACE") { level = .trace; lineLevelValue = 5 } - - // 硬性过滤:如果行级别超过了设置级别,则不显示 (注: 数值越小级别越高) - // 在我们的逻辑里,ERROR=1, WARN=2... - // 所以如果 lineLevelValue > currentLevelValue,就不应该显示 - if lineLevelValue > currentLevelValue { - continue - } + if levelSearchArea.contains("ERROR") { level = .error } + else if levelSearchArea.contains("WARN") { level = .warn } + else if levelSearchArea.contains("DEBUG") { level = .debug } + else if levelSearchArea.contains("TRACE") { level = .trace } + + if !currentLogLevel.allows(level) { continue } if let match = Regex.levelPrefix.firstMatch(in: content, range: NSRange(location: 0, length: content.utf16.count)) { content = (content as NSString).replacingCharacters(in: match.range, with: "").trimmingCharacters(in: .whitespaces) @@ -158,21 +116,15 @@ class LogParser: ObservableObject { return result } - private struct ParseResults { - let logs: [LogEntry] - let events: [EventEntry] - let restartDetected: Bool - } - private func readNewRawLines() { guard !isReading else { return } isReading = true defer { isReading = false } - let path = logPath - guard FileManager.default.fileExists(atPath: path) else { return } + guard let logURL = logResolver.logFileURL() else { return } + guard FileManager.default.fileExists(atPath: logURL.path) else { return } - guard let handle = FileHandle(forReadingAtPath: path) else { return } + guard let handle = FileHandle(forReadingAtPath: logURL.path) else { return } defer { handle.closeFile() } // Get file size; reset offset if file was truncated (log rotation) @@ -207,36 +159,31 @@ class LogParser: ObservableObject { let parsed = quickParseLogs(content) guard !parsed.isEmpty else { return } - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - if self.isPaused { - self.pendingLogs.append(contentsOf: parsed) - } else { - self.logs.append(contentsOf: parsed) - if self.logs.count > self.maxLogItems { - self.logs.removeFirst(self.logs.count - self.maxLogItems) - } + if isPaused { + pendingLogs.append(contentsOf: parsed) + } else { + logs.append(contentsOf: parsed) + if logs.count > maxLogItems { + logs.removeFirst(logs.count - maxLogItems) } } } func stopMonitoring() { - timer?.invalidate() - timer = nil xpcEventTimer?.invalidate() xpcEventTimer = nil isReading = false - fileHandle = nil // Release handle } func resetForNewCoreSession() { stopMonitoring() - DispatchQueue.main.async { - self.events.removeAll() - self.logs.removeAll() - } - xpcEventIndex = 0 + events.removeAll() + logs.removeAll() + pendingLogs.removeAll() isReading = false + lastReadOffset = 0 + trailingRemainder = "" + seenEventHashes.removeAll() } /// Update events from get_running_info response @@ -244,8 +191,8 @@ class LogParser: ObservableObject { var newEvents: [EventEntry] = [] for item in eventsAnyArray { - guard let event = parseEventEntry(from: item) else { continue } - let dedupKey = "\(event.type.rawValue)|\(event.timestamp)|\(event.details)" + guard let event = LogEventFormatter.parseEventEntry(from: item) else { continue } + let dedupKey = "\(event.name)|\(event.timestamp)|\(event.details)" let eventHash = dedupKey.hashValue if seenEventHashes.contains(eventHash) { continue } seenEventHashes.insert(eventHash) @@ -255,185 +202,21 @@ class LogParser: ObservableObject { guard !newEvents.isEmpty else { return } - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - self.events.append(contentsOf: newEvents) - // Sort by date (oldest first) - self.events.sort { ($0.date ?? .distantPast) < ($1.date ?? .distantPast) } - if self.events.count > self.maxEventItems { - self.events = Array(self.events.suffix(self.maxEventItems)) - } + events.append(contentsOf: newEvents) + events.sort { $0.timestamp < $1.timestamp } + if events.count > maxEventItems { + events = Array(events.suffix(maxEventItems)) } } func flushPending() { - // Now optional as logs are simpler - } - - // MARK: - Legacy Cleanup (Keeping it tidy) - - - private func applyResults(_ results: ParseResults) { - if results.restartDetected { self.events.removeAll() } - let validLogs = results.logs.filter { !$0.cleanContent.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).isEmpty } - if isPaused { pendingLogs.append(contentsOf: validLogs) } - else { - self.logs.append(contentsOf: validLogs) - if self.logs.count > maxLogItems { self.logs.removeFirst(self.logs.count - maxLogItems) } - } - if !results.events.isEmpty { - self.events.append(contentsOf: results.events) - if self.events.count > maxEventItems { self.events.removeFirst(self.events.count - maxEventItems) } + guard !pendingLogs.isEmpty else { + return } - } - - private func capString(_ s: String, limit: Int) -> String { - return s.count > limit ? String(s.prefix(limit)) + "\n... [已自动截断]" : s - } - - private func cleanLogContent(_ text: String) -> String { - return text.components(separatedBy: .newlines).map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty }.joined(separator: "\n") - } - - private func parseEventEntry(from input: Any) -> EventEntry? { - let json: [String: Any]? - if let dict = input as? [String: Any] { json = dict } - else if let str = input as? String, let data = str.data(using: .utf8) { json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] } - else { return nil } - guard let j = json else { return nil } - - let timeStr = j["time"] as? String ?? "" - let eventDate = iso8601Formatter.date(from: timeStr) - let displayTimestamp = timeStr.replacingOccurrences(of: "Z", with: "").replacingOccurrences(of: "T", with: " ") - guard let eventData = j["event"] else { return nil } - - let eventName: String? - let eventPayload: Any - if let eventDict = eventData as? [String: Any], eventDict.count == 1, let firstKey = eventDict.keys.first { - eventName = firstKey - eventPayload = eventDict[firstKey] ?? eventData - } else { - eventName = eventData as? String - eventPayload = eventData + logs.append(contentsOf: pendingLogs) + pendingLogs.removeAll() + if logs.count > maxLogItems { + logs.removeFirst(logs.count - maxLogItems) } - - let type = mapEventType(eventName ?? "unknown") - let cleanedPayload = recursiveJsonClean(eventPayload, depth: 0) - let detailsStr = formatAsJson(cleanedPayload) - let highlights = calculateHighlights(for: detailsStr) - - return EventEntry(timestamp: displayTimestamp, date: eventDate, type: type, details: detailsStr, highlights: highlights) - } - - private func parseTextEvent(content: String, timestamp: String, dateParser: (String) -> Date?, into newEvents: inout [EventEntry]) { - var eventType: EventEntry.EventType? - var eventPayload: Any? - if content.contains("PeerConnAdded") { eventType = .peerConnAdded; eventPayload = ["raw": content] } - else if content.contains("PeerAdded") { eventType = .peerAdded; eventPayload = ["event": "PeerAdded", "raw": content] } - else if content.contains("PeerRemoved") { eventType = .peerRemoved; eventPayload = ["event": "PeerRemoved", "raw": content] } - else if content.contains("Connecting") { eventType = .connecting; eventPayload = ["event": "Connecting", "raw": content] } - - if let type = eventType { - let detailsStr = formatAsJson(recursiveJsonClean(eventPayload, depth: 0)) - newEvents.append(EventEntry(timestamp: timestamp, date: dateParser(timestamp), type: type, details: detailsStr, highlights: calculateHighlights(for: detailsStr))) - } - } - - private func recursiveJsonClean(_ value: Any?, depth: Int) -> Any? { - guard let value = value, depth < 5 else { return value } - if let dict = value as? [String: Any] { - var newDict = [String: Any]() - for (k, v) in dict { newDict[k] = recursiveJsonClean(v, depth: depth + 1) } - return newDict - } else if let arr = value as? [Any] { - return arr.map { recursiveJsonClean($0, depth: depth + 1) } - } - return value - } - - private func mapEventType(_ name: String) -> EventEntry.EventType { - switch name { - case "Connecting", "ConnectingTo": return .connecting - case "Connected", "ConnectionAccepted": return .connected - case "ConnectError", "ConnectionError": return .connectError - case "PeerConnAdded": return .peerConnAdded - case "PeerAdded", "NewPeer": return .peerAdded - case "PeerRemoved", "PeerLost": return .peerRemoved - case "RouteChanged", "RouteUpdate": return .routeChanged - case "TunDeviceReady": return .tunDeviceReady - case "ListenerAdded": return .listenerAdded - case "Handshake": return .handshake - default: return .unknown - } - } - - private func collapsePrettyPrintedArrays(_ input: String) -> String { - var res = input - let pattern = #"(?s)\[\s*([^\[\]{}]*?)\s*\]"# - guard let regex = try? NSRegularExpression(pattern: pattern) else { return res } - let matches = regex.matches(in: res, options: [], range: NSRange(location: 0, length: (res as NSString).length)) - for match in matches.reversed() { - let content = (res as NSString).substring(with: match.range(at: 1)) - let collapsed = "[\(content.components(separatedBy: .newlines).map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }.joined(separator: ", "))]" - res = (res as NSString).replacingCharacters(in: match.range, with: collapsed) - } - return res - } - - private func formatAsJson(_ value: Any?) -> String { - guard let v = value else { return "null" } - - // JSONSerialization requires Array or Dictionary as top-level type. - // If it's a simple type, just return its string representation to avoid crash. - if !(v is [String: Any]) && !(v is [Any]) { - return "\(v)" - } - - var options: JSONSerialization.WritingOptions = [.sortedKeys] - if #available(macOS 10.13, *) { - options.insert(.prettyPrinted) - } - if #available(macOS 10.15, *) { - options.insert(.withoutEscapingSlashes) - } - - if let data = try? JSONSerialization.data(withJSONObject: v, options: options), - let str = String(data: data, encoding: .utf8) { - return collapsePrettyPrintedArrays(str) - } - return "\(v)" - } - - private func calculateHighlights(for json: String) -> [HighlightRange] { - var ranges: [HighlightRange] = [] - let nsString = json as NSString - let fullRange = NSRange(location: 0, length: nsString.length) - - // 1. Strings (Green) - if let regex = try? NSRegularExpression(pattern: #""([^"\\]|\\.)*""#) { - let matches = regex.matches(in: json, range: fullRange) - for m in matches { ranges.append(HighlightRange(start: m.range.location, length: m.range.length, color: "green", bold: false)) } - } - - // 2. Keys (Blue) - if let regex = try? NSRegularExpression(pattern: #"("[^"]+"|\b[a-zA-Z_][a-zA-Z0-9_]*\b)\s*:"#) { - let matches = regex.matches(in: json, range: fullRange) - for m in matches { ranges.append(HighlightRange(start: m.range.location, length: m.range.length, color: "blue", bold: true)) } - } - - // 3. Numbers (Orange) - if let regex = try? NSRegularExpression(pattern: #"\b\d+(\.\d+)?\b"#) { - let matches = regex.matches(in: json, range: fullRange) - for m in matches { ranges.append(HighlightRange(start: m.range.location, length: m.range.length, color: "orange", bold: false)) } - } - - // 4. Keywords (Purple) - if let regex = try? NSRegularExpression(pattern: #"\b(true|false|null|None|Some|Ok|Err)\b"#) { - let matches = regex.matches(in: json, range: fullRange) - for m in matches { ranges.append(HighlightRange(start: m.range.location, length: m.range.length, color: "purple", bold: true)) } - } - - return ranges } } diff --git a/Spotier/LogView.swift b/Spotier/LogView.swift index 1a6f09a..cb5441a 100644 --- a/Spotier/LogView.swift +++ b/Spotier/LogView.swift @@ -7,13 +7,7 @@ struct LogView: View { @State private var selectedLog: LogEntry? @State private var viewMode: ViewMode = .events @State private var logLevelFilter: LogLevel? = nil // nil = show all - - private var logPath: String { - if let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.alick.swiftier") { - return containerURL.appendingPathComponent("easytier.log").path - } - return "/var/log/swiftier-helper.log" // fallback - } + private let logResolver = LogContainerResolver.shared enum ViewMode: Int { case events = 0 @@ -22,59 +16,55 @@ struct LogView: View { var body: some View { VStack(spacing: 0) { - // Custom Header - VStack(spacing: 0) { - HStack { + UnifiedHeader { + HStack(spacing: 12) { Picker("", selection: $viewMode) { Text(LocalizedStringKey("交互事件")).tag(ViewMode.events) Text(LocalizedStringKey("调试日志")).tag(ViewMode.logs) } .pickerStyle(.segmented) .frame(width: 200) - - Spacer() - - HStack(spacing: 12) { - if viewMode == .logs { - Picker("", selection: $logLevelFilter) { - Text("全部").tag(nil as LogLevel?) - Text("ERROR").tag(LogLevel.error as LogLevel?) - Text("WARN").tag(LogLevel.warn as LogLevel?) - Text("INFO").tag(LogLevel.info as LogLevel?) - Text("DEBUG").tag(LogLevel.debug as LogLevel?) - Text("TRACE").tag(LogLevel.trace as LogLevel?) - } - .pickerStyle(.menu) - .frame(width: 100) + + if viewMode == .logs { + Picker("", selection: $logLevelFilter) { + Text("全部").tag(nil as LogLevel?) + Text("ERROR").tag(LogLevel.error as LogLevel?) + Text("WARN").tag(LogLevel.warn as LogLevel?) + Text("INFO").tag(LogLevel.info as LogLevel?) + Text("DEBUG").tag(LogLevel.debug as LogLevel?) + Text("TRACE").tag(LogLevel.trace as LogLevel?) } - - Button { - NSWorkspace.shared.open(URL(fileURLWithPath: logPath)) - } label: { - Image(systemName: "doc.text.magnifyingglass") - .font(.title3) - .foregroundColor(.primary) + .pickerStyle(.menu) + .frame(width: 100) + } + } + } center: { + EmptyView() + } right: { + HStack(spacing: 12) { + Button { + if let url = logResolver.logFileURL() { + NSWorkspace.shared.open(url) } - .buttonStyle(.plain) - .help("在系统控制台中打开完整日志") - - Button { - withAnimation { - isPresented = false - } - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - .font(.title2) + } label: { + Image(systemName: "doc.text.magnifyingglass") + .font(.title3) + .foregroundColor(.primary) + } + .buttonStyle(.plain) + .help("在系统控制台中打开完整日志") + + Button { + withAnimation { + isPresented = false } - .buttonStyle(.plain) + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + .font(.title2) } + .buttonStyle(.plain) } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(Color(nsColor: .windowBackgroundColor)) - - Divider() } if viewMode == .events { @@ -89,8 +79,6 @@ struct LogView: View { } .background(Color(nsColor: .windowBackgroundColor)) .task { - // Delay log loading until the slide-up animation completes (0.4s) - try? await Task.sleep(nanoseconds: 400_000_000) logParser.startMonitoring() } .onDisappear { diff --git a/Spotier/Logging/LogContainerResolver.swift b/Spotier/Logging/LogContainerResolver.swift new file mode 100644 index 0000000..61152ce --- /dev/null +++ b/Spotier/Logging/LogContainerResolver.swift @@ -0,0 +1,22 @@ +import Foundation + +struct LogContainerResolver { + static let shared = LogContainerResolver() + + typealias FileURLProvider = (String, FileManager) -> URL? + + private let fileManager: FileManager + private let fileURLProvider: FileURLProvider + + init( + fileManager: FileManager = .default, + fileURLProvider: @escaping FileURLProvider = appGroupFileURL + ) { + self.fileManager = fileManager + self.fileURLProvider = fileURLProvider + } + + func logFileURL() -> URL? { + fileURLProvider(LOG_FILENAME, fileManager) + } +} diff --git a/Spotier/Logging/LogEventFormatter.swift b/Spotier/Logging/LogEventFormatter.swift new file mode 100644 index 0000000..ddbd34a --- /dev/null +++ b/Spotier/Logging/LogEventFormatter.swift @@ -0,0 +1,214 @@ +import Foundation + +enum LogEventFormatter { + private static let stringRegex = try! NSRegularExpression(pattern: #""([^"\\]|\\.)*""#) + private static let keyRegex = try! NSRegularExpression(pattern: #"("[^"]+"|\b[a-zA-Z_][a-zA-Z0-9_]*\b)\s*:"#) + private static let numberRegex = try! NSRegularExpression(pattern: #"\b\d+(\.\d+)?\b"#) + private static let keywordRegex = try! NSRegularExpression(pattern: #"\b(true|false|null|None|Some|Ok|Err)\b"#) + private static let arrayRegex = try! NSRegularExpression(pattern: #"(?s)\[\s*([^\[\]{}]*?)\s*\]"#) + + private static let iso8601Formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + static func parseEventEntry(from input: Any) -> EventEntry? { + guard let json = jsonObject(from: input) else { return nil } + guard let timeString = json["time"] as? String else { return nil } + guard let eventData = json["event"] else { return nil } + + let eventName: String + let eventPayload: Any + if let eventDict = eventData as? [String: Any], + eventDict.count == 1, + let firstKey = eventDict.keys.first { + eventName = firstKey + eventPayload = eventDict[firstKey]! + } else { + guard let rawEventName = eventData as? String else { return nil } + eventName = rawEventName + eventPayload = eventData + } + + let eventDate = iso8601Formatter.date(from: timeString) + let displayTimestamp = timeString + .replacingOccurrences(of: "Z", with: "") + .replacingOccurrences(of: "T", with: " ") + let type = mapEventType(eventName) + let cleanedPayload = recursiveJsonClean(eventPayload, depth: 0) + let details = formatAsJson(cleanedPayload) + + return EventEntry( + name: eventName, + timestamp: displayTimestamp, + date: eventDate, + type: type, + details: details, + highlights: calculateHighlights(for: details) + ) + } + + static func calculateHighlights(for json: String) -> [HighlightRange] { + let nsString = json as NSString + let fullRange = NSRange(location: 0, length: nsString.length) + var ranges: [HighlightRange] = [] + + appendHighlights( + in: json, + regex: stringRegex, + fullRange: fullRange, + color: "green", + bold: false, + into: &ranges + ) + appendHighlights( + in: json, + regex: keyRegex, + fullRange: fullRange, + color: "blue", + bold: true, + into: &ranges + ) + appendHighlights( + in: json, + regex: numberRegex, + fullRange: fullRange, + color: "orange", + bold: false, + into: &ranges + ) + appendHighlights( + in: json, + regex: keywordRegex, + fullRange: fullRange, + color: "purple", + bold: true, + into: &ranges + ) + + return ranges + } + + private static func appendHighlights( + in text: String, + regex: NSRegularExpression, + fullRange: NSRange, + color: String, + bold: Bool, + into ranges: inout [HighlightRange] + ) { + for match in regex.matches(in: text, range: fullRange) { + ranges.append( + HighlightRange( + start: match.range.location, + length: match.range.length, + color: color, + bold: bold + ) + ) + } + } + + private static func recursiveJsonClean(_ value: Any?, depth: Int) -> Any? { + guard let value, depth < 5 else { + return value + } + + if let dict = value as? [String: Any] { + var cleaned = [String: Any]() + for (key, nestedValue) in dict { + cleaned[key] = recursiveJsonClean(nestedValue, depth: depth + 1) + } + return cleaned + } + + if let array = value as? [Any] { + return array.map { recursiveJsonClean($0, depth: depth + 1) } + } + + return value + } + + private static func jsonObject(from input: Any) -> [String: Any]? { + if let dict = input as? [String: Any] { + return dict + } + + guard let str = input as? String, let data = str.data(using: .utf8) else { + return nil + } + + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + + private static func mapEventType(_ name: String) -> EventEntry.EventType { + switch name { + case "Connecting", "ConnectingTo": + return .connecting + case "Connected", "ConnectionAccepted": + return .connected + case "ConnectError", "ConnectionError": + return .connectError + case "PeerConnAdded": + return .peerConnAdded + case "PeerAdded", "NewPeer": + return .peerAdded + case "PeerRemoved", "PeerLost": + return .peerRemoved + case "RouteChanged", "RouteUpdate": + return .routeChanged + case "TunDeviceReady": + return .tunDeviceReady + case "ListenerAdded": + return .listenerAdded + case "Handshake": + return .handshake + default: + return .unknown + } + } + + private static func collapsePrettyPrintedArrays(_ input: String) -> String { + var result = input + let matches = arrayRegex.matches( + in: result, + options: [], + range: NSRange(location: 0, length: (result as NSString).length) + ) + + for match in matches.reversed() { + let content = (result as NSString).substring(with: match.range(at: 1)) + let collapsedContent = content + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + .joined(separator: ", ") + let collapsed = "[\(collapsedContent)]" + result = (result as NSString).replacingCharacters(in: match.range, with: collapsed) + } + + return result + } + + private static func formatAsJson(_ value: Any?) -> String { + guard let value else { + return "null" + } + + if !(value is [String: Any]) && !(value is [Any]) { + return "\(value)" + } + + var options: JSONSerialization.WritingOptions = [.sortedKeys] + if #available(macOS 10.13, *) { + options.insert(.prettyPrinted) + } + if #available(macOS 10.15, *) { + options.insert(.withoutEscapingSlashes) + } + + let data = try! JSONSerialization.data(withJSONObject: value, options: options) + return collapsePrettyPrintedArrays(String(decoding: data, as: UTF8.self)) + } +} diff --git a/Spotier/Logging/LogSettingsStore.swift b/Spotier/Logging/LogSettingsStore.swift new file mode 100644 index 0000000..36b6a6d --- /dev/null +++ b/Spotier/Logging/LogSettingsStore.swift @@ -0,0 +1,19 @@ +import Foundation + +struct LogSettingsStore { + static let shared = LogSettingsStore() + + private let defaults: UserDefaults? + + init(defaults: UserDefaults? = appGroupDefaults()) { + self.defaults = defaults + } + + func readLevel() -> StoredLogLevel { + readStoredLogLevel(defaults: defaults) + } + + func writeLevel(_ level: StoredLogLevel) { + writeStoredLogLevel(level, defaults: defaults) + } +} diff --git a/Spotier/Main/CreateConfigPrompt.swift b/Spotier/Main/CreateConfigPrompt.swift new file mode 100644 index 0000000..92d22b7 --- /dev/null +++ b/Spotier/Main/CreateConfigPrompt.swift @@ -0,0 +1,46 @@ +import SwiftUI + +struct CreateConfigPrompt: View { + @Binding var newConfigName: String + let errorMessage: String? + let onCancel: () -> Void + let onConfirm: () -> Void + + var body: some View { + VStack(spacing: 20) { + Text(LocalizedStringKey("创建新网络")) + .font(.headline) + + if let errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundColor(.red) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + + VStack(alignment: .leading, spacing: 8) { + Text(LocalizedStringKey("配置文件名:")) + TextField(LocalizedStringKey("例如: my-network"), text: $newConfigName) + .textFieldStyle(.roundedBorder) + .textContentType(.none) + .disableAutocorrection(true) + .onSubmit(onConfirm) + Text(LocalizedStringKey("将自动添加 .toml 后缀")) + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.horizontal) + + HStack { + Button(LocalizedStringKey("取消"), action: onCancel) + Button(LocalizedStringKey("创建"), action: onConfirm) + .buttonStyle(.borderedProminent) + } + } + .padding() + .frame(width: 300) + .background(RoundedRectangle(cornerRadius: 12).fill(Color(nsColor: .windowBackgroundColor))) + .shadow(radius: 20) + } +} diff --git a/Spotier/Main/DashboardLayoutMetrics.swift b/Spotier/Main/DashboardLayoutMetrics.swift new file mode 100644 index 0000000..0736d6d --- /dev/null +++ b/Spotier/Main/DashboardLayoutMetrics.swift @@ -0,0 +1,17 @@ +import CoreGraphics + +enum DashboardLayoutMetrics { + static let windowWidth: CGFloat = 420 + static let windowHeight: CGFloat = 520 + static let rippleSize: CGFloat = 500 + static let peerCardWidth: CGFloat = 188 + static let peerAreaHeight: CGFloat = 222 + static let peerAreaBottomPadding: CGFloat = 16 + static let headerPadding: CGFloat = 12 + static let speedDashboardHorizontalPadding: CGFloat = 16 + static let runningButtonCenterY: CGFloat = 133 + + static func buttonCenterY(isRunning: Bool, contentHeight: CGFloat) -> CGFloat { + isRunning ? runningButtonCenterY : (contentHeight / 2) + } +} diff --git a/Spotier/Main/DashboardOverlayRoute.swift b/Spotier/Main/DashboardOverlayRoute.swift new file mode 100644 index 0000000..22e860f --- /dev/null +++ b/Spotier/Main/DashboardOverlayRoute.swift @@ -0,0 +1,24 @@ +import Foundation + +enum DashboardOverlayRoute: Equatable, Identifiable { + case log + case settings + case generator + case editor(URL) + case createPrompt + + var id: String { + switch self { + case .log: + return "log" + case .settings: + return "settings" + case .generator: + return "generator" + case let .editor(url): + return "editor:\(url.path)" + case .createPrompt: + return "createPrompt" + } + } +} diff --git a/Spotier/Main/MainConnectionUseCase.swift b/Spotier/Main/MainConnectionUseCase.swift new file mode 100644 index 0000000..bcb4dda --- /dev/null +++ b/Spotier/Main/MainConnectionUseCase.swift @@ -0,0 +1,38 @@ +import Foundation + +protocol VPNControlling: AnyObject { + var isConnected: Bool { get } + var statusText: String { get set } + + func startVPN(configContent: String) + func disableOnDemandAndStop() +} + +struct MainConnectionUseCase { + let configRepository: ConfigFileAccessing + let vpnController: VPNControlling + + func canToggleConnection(selectedConfig: URL?) -> Bool { + vpnController.isConnected || selectedConfig != nil + } + + func toggleConnection(selectedConfig: URL?) { + if vpnController.isConnected { + vpnController.disableOnDemandAndStop() + return + } + + guard let selectedConfig else { + vpnController.statusText = "未选择配置文件" + return + } + + do { + let content = try configRepository.readContent(at: selectedConfig) + vpnController.startVPN(configContent: content) + } catch { + vpnController.statusText = "读取配置失败: \(selectedConfig.lastPathComponent)" + print("无法读取配置文件: \(selectedConfig.path)") + } + } +} diff --git a/Spotier/Main/MainDashboardHeader.swift b/Spotier/Main/MainDashboardHeader.swift new file mode 100644 index 0000000..b1d9c8c --- /dev/null +++ b/Spotier/Main/MainDashboardHeader.swift @@ -0,0 +1,139 @@ +import SwiftUI + +struct MainDashboardHeader: View { + let configFiles: [URL] + let selectedConfig: URL? + let iCloudDriveEnabled: Bool + let hasICloudIdentity: Bool + let onSelectConfig: (URL) -> Void + let onCreateNetwork: () -> Void + let onOpenGenerator: () -> Void + let onOpenEditor: () -> Void + let onDisableICloudDrive: () -> Void + let onEnableICloudDrive: () -> Void + let onSelectFolder: () -> Void + let onOpenFolder: () -> Void + let onDeleteSelected: () -> Void + let onOpenLog: () -> Void + let onOpenSettings: () -> Void + let onQuit: () -> Void + + var body: some View { + HStack { + configMenu + + Spacer() + + actionButtons + } + .padding(DashboardLayoutMetrics.headerPadding) + .zIndex(200) + } + + private var configMenu: some View { + Menu { + Section("配置文件") { + storageLabel + + if configFiles.isEmpty { + Button("未发现配置") { } + .disabled(true) + } else { + ForEach(configFiles, id: \.self) { url in + Button(action: { onSelectConfig(url) }) { + HStack { + Text(url.deletingPathExtension().lastPathComponent) + if selectedConfig == url { + Image(systemName: "checkmark") + } + } + } + } + } + } + + Divider() + + Button("创建新网络", action: onCreateNetwork) + Button("编辑配置", action: onOpenGenerator) + .disabled(selectedConfig == nil) + Button("编辑配置为文件", action: onOpenEditor) + .disabled(selectedConfig == nil) + + Divider() + + if iCloudDriveEnabled { + Button("关闭 iCloud Drive", action: onDisableICloudDrive) + } else { + Button("存储到 iCloud Drive", action: onEnableICloudDrive) + } + Button("选择文件夹", action: onSelectFolder) + .disabled(iCloudDriveEnabled) + Button("在 Finder 中打开", action: onOpenFolder) + + Divider() + + Button(role: .destructive, action: onDeleteSelected) { + Text(LocalizedStringKey("删除选中的配置")) + .foregroundColor(.red) + } + .disabled(selectedConfig == nil) + } label: { + HStack { + Image(systemName: "point.3.connected.trianglepath.dotted") + Text(selectedConfig?.deletingPathExtension().lastPathComponent ?? NSLocalizedString("请选择配置", comment: "")) + .lineLimit(1) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .cornerRadius(6) + } + .menuStyle(.borderlessButton) + } + + @ViewBuilder + private var storageLabel: some View { + if iCloudDriveEnabled { + Label("存储位置: iCloud Drive", systemImage: "icloud") + .font(.caption) + .foregroundColor(.secondary) + } else if hasICloudIdentity { + Label("存储位置: 本地(可切换到 iCloud Drive)", systemImage: "internaldrive") + .font(.caption) + .foregroundColor(.secondary) + } else { + Label("存储位置: 本地 (iCloud 未启用)", systemImage: "internaldrive") + .font(.caption) + .foregroundColor(.secondary) + } + } + + private var actionButtons: some View { + HStack(spacing: 6) { + Button(action: onOpenLog) { + Image(systemName: "doc.text") + .font(.system(size: 14)) + .padding(5) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Button(action: onOpenSettings) { + Image(systemName: "gearshape") + .font(.system(size: 14)) + .padding(5) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Button(action: onQuit) { + Image(systemName: "power") + .font(.system(size: 14)) + .foregroundColor(.red) + .padding(5) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } +} diff --git a/Spotier/Main/MainDashboardState.swift b/Spotier/Main/MainDashboardState.swift new file mode 100644 index 0000000..a0ee1ee --- /dev/null +++ b/Spotier/Main/MainDashboardState.swift @@ -0,0 +1,153 @@ +import Foundation +import Combine +import SwiftUI + +@MainActor +final class MainDashboardState: ObservableObject { + private static let selectedConfigPathDefaultsKey = "selected_config_path" + + @Published private(set) var selectedConfig: URL? + @Published private(set) var overlayRoute: DashboardOverlayRoute? + @Published var newConfigName = "" + @Published private(set) var createConfigError: String? + + private let configRepository: ConfigFileAccessing + private let connectionUseCase: MainConnectionUseCase + + convenience init() { + self.init( + initialConfigFiles: [], + configRepository: ConfigFileRepository.shared, + vpnController: VPNManager.shared + ) + } + + convenience init(configRepository: ConfigFileAccessing) { + self.init( + initialConfigFiles: [], + configRepository: configRepository, + vpnController: VPNManager.shared + ) + } + + init( + initialConfigFiles: [URL], + configRepository: ConfigFileAccessing, + vpnController: VPNControlling + ) { + self.configRepository = configRepository + self.connectionUseCase = MainConnectionUseCase( + configRepository: configRepository, + vpnController: vpnController + ) + syncSelection(with: initialConfigFiles) + } + + var isAnyOverlayShown: Bool { + overlayRoute != nil + } + + var editingConfigURL: URL? { + guard case let .editor(url) = overlayRoute else { return nil } + return url + } + + var canToggleConnection: Bool { + connectionUseCase.canToggleConnection(selectedConfig: selectedConfig) + } + + func selectConfig(_ url: URL) { + selectedConfig = url + UserDefaults.standard.set(url.path, forKey: Self.selectedConfigPathDefaultsKey) + } + + func handleConfigFilesChanged(_ files: [URL]) { + syncSelection(with: files) + } + + func isPresenting(_ route: DashboardOverlayRoute) -> Bool { + overlayRoute == route + } + + func openOverlay(_ route: DashboardOverlayRoute) { + withAnimation { + overlayRoute = route + } + } + + func closeOverlay() { + withAnimation { + overlayRoute = nil + } + } + + func openEditor() { + guard let selectedConfig else { return } + openOverlay(.editor(selectedConfig)) + } + + func openCreatePrompt() { + newConfigName = "" + createConfigError = nil + openOverlay(.createPrompt) + } + + func closeCreatePrompt() { + createConfigError = nil + closeOverlay() + } + + func createConfig() { + let filename = ConfigTemplateFactory.filename(from: newConfigName) + let content = ConfigTemplateFactory.content(for: newConfigName) + + do { + _ = try configRepository.createConfig(named: filename, content: content) + let updatedFiles = configRepository.refreshConfigs() + + createConfigError = nil + if let newURL = updatedFiles.first(where: { $0.lastPathComponent == filename }) { + selectConfig(newURL) + openOverlay(.generator) + } else { + closeCreatePrompt() + } + } catch { + createConfigError = "创建失败: \(error.localizedDescription)" + print("Failed to create file: \(error)") + } + } + + func deleteSelectedConfig() { + guard let selectedConfig else { return } + configRepository.deleteConfig(at: selectedConfig) + } + + func toggleConnection() { + connectionUseCase.toggleConnection(selectedConfig: selectedConfig) + } + + private func syncSelection(with files: [URL]) { + let nextSelection: URL? + if let savedPath = UserDefaults.standard.string(forKey: Self.selectedConfigPathDefaultsKey) { + nextSelection = files.first { $0.path == savedPath } + ?? currentSelection(in: files) + } else { + nextSelection = currentSelection(in: files) + } + selectedConfig = nextSelection + if let nextSelection { + UserDefaults.standard.set(nextSelection.path, forKey: Self.selectedConfigPathDefaultsKey) + } else { + UserDefaults.standard.removeObject(forKey: Self.selectedConfigPathDefaultsKey) + } + } + + private func currentSelection(in files: [URL]) -> URL? { + guard !files.isEmpty else { return nil } + if let selectedConfig, files.contains(selectedConfig) { + return selectedConfig + } + return files.first + } +} diff --git a/Spotier/Main/PeerListArea.swift b/Spotier/Main/PeerListArea.swift new file mode 100644 index 0000000..dc175b7 --- /dev/null +++ b/Spotier/Main/PeerListArea.swift @@ -0,0 +1,58 @@ +import SwiftUI + +struct PeerListArea: View { + @StateObject private var runner = SpotierRunner.shared + + private let gridRows = [ + GridItem(.fixed(105), spacing: 12), + GridItem(.fixed(105), spacing: 12) + ] + + var body: some View { + let peerIDs = runner.peers.map(\.id) + + return VStack { + Spacer() + + ZStack { + ScrollView(.horizontal, showsIndicators: false) { + LazyHGrid(rows: gridRows, spacing: 12) { + ForEach(runner.peers) { peer in + PeerCard(peer: peer) + .equatable() + .frame(width: DashboardLayoutMetrics.peerCardWidth) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .transition( + .asymmetric( + insertion: .move(edge: .bottom).combined(with: .opacity), + removal: .opacity + ) + ) + } + } + .padding(.horizontal, DashboardLayoutMetrics.speedDashboardHorizontalPadding) + .contentShape(Rectangle()) + } + .preventVerticalBounce() + .frame(height: DashboardLayoutMetrics.peerAreaHeight) + + if runner.isRunning && runner.peers.isEmpty { + VStack(spacing: 20) { + ProgressView().scaleEffect(1.2).controlSize(.large) + Text(LocalizedStringKey("节点加载中")) + .font(.title3.bold()) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .frame(height: DashboardLayoutMetrics.peerAreaHeight) + .transition(.opacity) + } + } + .frame(maxWidth: .infinity) + .frame(height: DashboardLayoutMetrics.peerAreaHeight) + .padding(.bottom, DashboardLayoutMetrics.peerAreaBottomPadding) + } + .frame(maxWidth: .infinity) + .animation(.spring(response: 0.5, dampingFraction: 0.82), value: peerIDs) + } +} diff --git a/Spotier/Main/SpeedDashboard.swift b/Spotier/Main/SpeedDashboard.swift new file mode 100644 index 0000000..9d3f8c4 --- /dev/null +++ b/Spotier/Main/SpeedDashboard.swift @@ -0,0 +1,187 @@ +import SwiftUI +import NetworkExtension + +struct SpeedCard: View, Equatable { + let title: String + let value: String + let icon: String + let color: Color + let history: [Double] + let maxVal: Double + let isVisible: Bool + let isPaused: Bool + + static func == (lhs: SpeedCard, rhs: SpeedCard) -> Bool { + lhs.value == rhs.value && + lhs.history == rhs.history && + lhs.maxVal == rhs.maxVal && + lhs.isVisible == rhs.isVisible && + lhs.isPaused == rhs.isPaused + } + + private var splitValue: (number: String, unit: String) { + let components = value.components(separatedBy: " ") + if components.count >= 2 { + return (components[0], components[1]) + } + return (value, "") + } + + var body: some View { + ZStack(alignment: .bottom) { + SmartSparklineView(data: history, color: color, maxScale: maxVal, paused: isPaused) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.top, 24) + .zIndex(0) + .allowsHitTesting(false) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Image(systemName: icon) + .foregroundColor(color) + .font(.system(size: 10, weight: .bold)) + Text(title) + .font(.system(size: 9, weight: .bold)) + .foregroundColor(color.opacity(0.8)) + } + .padding(.top, 10) + + Spacer() + + HStack(alignment: .firstTextBaseline, spacing: 3) { + Text(splitValue.number) + .font(.system(size: 24, weight: .bold, design: .monospaced)) + Text(splitValue.unit) + .font(.system(size: 11, weight: .bold, design: .monospaced)) + } + .frame(maxWidth: .infinity, alignment: .center) + + Spacer() + + Color.clear.frame(height: 12) + } + .padding(.horizontal, 12) + .zIndex(20) + } + .frame(maxWidth: .infinity) + .frame(height: 85) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(nsColor: .windowBackgroundColor).opacity(0.6)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(color.opacity(0.2), lineWidth: 1) + ) + } +} + +struct SpeedDashboard: View { + let geoSize: CGSize + let buttonCenterY: CGFloat + let isPaused: Bool + let isConnected: Bool + let status: NEVPNStatus + let canToggleConnection: Bool + let onToggleConnection: () -> Void + + @ObservedObject private var runner = SpotierRunner.shared + + var body: some View { + let maxSpeed = runner.maxHistorySpeed + + HStack(spacing: -6) { + if runner.isRunning && runner.isWindowVisible { + SpeedCard( + title: "DOWNLOAD", + value: runner.downloadSpeed, + icon: "arrow.down.square.fill", + color: .blue, + history: runner.downloadHistory, + maxVal: maxSpeed, + isVisible: true, + isPaused: isPaused + ) + .equatable() + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + + Button(action: onToggleConnection) { + StartStopButtonCore( + isRunning: isConnected, + uptimeText: runner.uptimeText, + status: status + ) + } + .buttonStyle(.plain) + .disabled(!canToggleConnection) + .zIndex(20) + + if runner.isRunning && runner.isWindowVisible { + SpeedCard( + title: "UPLOAD", + value: runner.uploadSpeed, + icon: "arrow.up.square.fill", + color: .orange, + history: runner.uploadHistory, + maxVal: maxSpeed, + isVisible: true, + isPaused: isPaused + ) + .equatable() + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .padding(.horizontal, 16) + .frame(width: geoSize.width) + .position(x: geoSize.width / 2, y: buttonCenterY) + } +} + +struct StartStopButtonCore: View { + let isRunning: Bool + let uptimeText: String + var status: NEVPNStatus = .disconnected + + var body: some View { + ZStack { + Circle() + .fill(buttonColor) + .frame(width: 84, height: 84) + .shadow(color: .black.opacity(isRunning ? 0.12 : 0.25), radius: 10, y: 4) + + if status == .connecting || status == .disconnecting { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .controlSize(.regular) + } else { + Image(systemName: "power") + .font(.system(size: 28, weight: .regular)) + .foregroundStyle(isRunning ? Color.black : Color.white) + } + + if isRunning { + Text(uptimeText) + .font(.system(size: 22, weight: .bold, design: .monospaced)) + .foregroundColor(.primary) + .frame(width: 140) + .lineLimit(1) + .minimumScaleFactor(0.7) + .offset(y: -113) + } + } + .frame(width: 84, height: 84) + .padding(.vertical, 6) + } + + private var buttonColor: Color { + if isRunning { return .white } + switch status { + case .connecting, .disconnecting: return .orange + case .connected: return .white + case .disconnected, .invalid: return .blue + case .reasserting: return .yellow + @unknown default: return .blue + } + } +} diff --git a/Spotier/Models/SpotierNodeModels.swift b/Spotier/Models/SpotierNodeModels.swift index dcf3f9e..6e74b38 100644 --- a/Spotier/Models/SpotierNodeModels.swift +++ b/Spotier/Models/SpotierNodeModels.swift @@ -18,7 +18,7 @@ struct SpotierStatus: Codable { var description: String { switch self { - case .unknown: return "Unknown" + case .unknown: return "" case .openInternet: return "Open Internet" case .noPAT: return "No PAT" case .fullCone: return "Full Cone" @@ -127,16 +127,11 @@ struct SpotierStatus: Codable { } var buffer = [Int8](repeating: 0, count: Int(INET6_ADDRSTRLEN)) - - if inet_ntop(AF_INET6, &addr, &buffer, socklen_t(INET6_ADDRSTRLEN)) != nil { - return String(cString: buffer) - } - // fallback - let parts = [part1, part2, part3, part4] - let segments = parts.flatMap { part -> [UInt16] in - [UInt16(part >> 16), UInt16(part & 0xFFFF)] - } - return segments.map { String(format: "%04x", $0) }.joined(separator: ":") + precondition( + inet_ntop(AF_INET6, &addr, &buffer, socklen_t(INET6_ADDRSTRLEN)) != nil, + "Failed to format IPv6 address" + ) + return String(cString: buffer) } } diff --git a/Spotier/NativeHorizontalScroller.swift b/Spotier/NativeHorizontalScroller.swift new file mode 100644 index 0000000..f1a9f74 --- /dev/null +++ b/Spotier/NativeHorizontalScroller.swift @@ -0,0 +1,52 @@ +import SwiftUI +import AppKit + +class HorizontalOnlyScrollView: NSScrollView { + override func scrollWheel(with event: NSEvent) { + if abs(event.scrollingDeltaY) > abs(event.scrollingDeltaX) { + return + } + + super.scrollWheel(with: event) + } +} + +struct NativeHorizontalScroller: NSViewRepresentable { + let content: Content + + init(@ViewBuilder content: @escaping () -> Content) { + self.content = content() + } + + func makeNSView(context: Context) -> NSScrollView { + let scroller = HorizontalOnlyScrollView() + scroller.hasHorizontalScroller = false + scroller.hasVerticalScroller = false + scroller.drawsBackground = false + scroller.autohidesScrollers = true + scroller.horizontalScrollElasticity = .allowed + scroller.verticalScrollElasticity = .none + + let hostingView = NSHostingView(rootView: content) + hostingView.translatesAutoresizingMaskIntoConstraints = false + scroller.documentView = hostingView + + if let doc = scroller.documentView { + doc.topAnchor.constraint(equalTo: scroller.contentView.topAnchor).isActive = true + doc.leadingAnchor.constraint(equalTo: scroller.contentView.leadingAnchor).isActive = true + } + + return scroller + } + + func updateNSView(_ nsView: NSScrollView, context: Context) { + if let host = nsView.documentView as? NSHostingView { + host.rootView = content + + let fittingSize = host.fittingSize + if host.frame.size != fittingSize { + host.frame.size = fittingSize + } + } + } +} diff --git a/Spotier/PeerCard.swift b/Spotier/PeerCard.swift index 15ee754..f7eb33c 100644 --- a/Spotier/PeerCard.swift +++ b/Spotier/PeerCard.swift @@ -6,12 +6,11 @@ struct PeerCard: View, Equatable { static func == (lhs: PeerCard, rhs: PeerCard) -> Bool { lhs.peer == rhs.peer } - @State private var isHovering = false + @State private var showDetail = false - @Environment(\.colorScheme) var colorScheme private var shortVersion: String { - peer.version.split(separator: "-").first.map(String.init) ?? peer.version + String(peer.version.split(separator: "-").first ?? Substring(peer.version)) } private func formatSpeed(_ speedStr: String) -> String { @@ -62,8 +61,6 @@ struct PeerCard: View, Equatable { private func translateNAT(_ raw: String) -> String { let text = clean(raw) let lower = text.lowercased() - - if lower == "unknown" { return "Unknown" } if lower == "nopat" { return "No PAT" } if lower.hasPrefix("openinternet") { return "Open" } @@ -117,14 +114,16 @@ struct PeerCard: View, Equatable { // 2. IP & Latency HStack { - Text(peer.ipv4.isEmpty ? "Public Peer" : peer.ipv4) + Text(peer.ipv4.isEmpty ? peer.hostname : peer.ipv4) .font(.system(size: 11, design: .monospaced)) .foregroundColor(.secondary) .lineLimit(1) Spacer() - Text(peer.latency.isEmpty || peer.latency == "-" ? "- ms" : "\(peer.latency) ms") - .font(.system(size: 11, weight: .medium, design: .monospaced)) - .foregroundColor(.green) + if !peer.latency.isEmpty { + Text("\(peer.latency) ms") + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundColor(.green) + } } // 3. Speed & Loss @@ -155,31 +154,18 @@ struct PeerCard: View, Equatable { .lineLimit(1) // 4. Tags - GeometryReader { geo in - let spacing: CGFloat = 6 - let totalWidth = geo.size.width - (spacing * 3) - - let rawTunnel = translateTunnel(peer.tunnel.isEmpty ? "-" : peer.tunnel) - let tTunnel = (rawTunnel.uppercased().contains("TCP") || rawTunnel.uppercased().contains("UDP")) ? rawTunnel : "-" - let tNat = translateNAT(peer.nat) - let tCost = translateTunnel(peer.cost) - - HStack(spacing: spacing) { - Tag(text: LocalizedStringKey(tCost), color: tagColor(for: tCost)) - .frame(width: totalWidth * 0.22) - - Tag(text: LocalizedStringKey(tTunnel), color: tagColor(for: tTunnel)) - .frame(width: totalWidth * 0.18) - + let tTunnel = translateTunnel(peer.tunnel) + let tNat = translateNAT(peer.nat) + let tCost = translateTunnel(peer.cost) + + HStack(spacing: 6) { + Tag(text: LocalizedStringKey(tCost), color: tagColor(for: tCost)) + Tag(text: LocalizedStringKey(tTunnel), color: tagColor(for: tTunnel)) + if !tNat.isEmpty { Tag(text: LocalizedStringKey(tNat), color: natColor(for: tNat)) - .frame(width: totalWidth * 0.40) - - Tag(text: LocalizedStringKey(shortVersion), color: .gray) - .frame(width: totalWidth * 0.20) } - .fixedSize(horizontal: true, vertical: true) + Tag(text: LocalizedStringKey(shortVersion), color: .gray) } - .frame(height: 22) .fixedSize(horizontal: false, vertical: true) } .padding(.horizontal, 10) @@ -218,292 +204,3 @@ struct PeerCard: View, Equatable { return .green } } - -// MARK: - Scrolling Text -struct ScrollingText: View { - let text: String - @State private var offset: CGFloat = 0 - @State private var isHovering = false - @State private var containerWidth: CGFloat = 0 - @State private var textWidth: CGFloat = 0 - - var body: some View { - GeometryReader { geo in - ZStack(alignment: .leading) { - Text(text) - .font(.system(size: 13, weight: .semibold)) - .fixedSize(horizontal: true, vertical: false) - .background(GeometryReader { textGeo in - Color.clear.onAppear { textWidth = textGeo.size.width } - }) - .opacity(0) - - Text(text) - .font(.system(size: 13, weight: .semibold)) - .fixedSize(horizontal: true, vertical: false) - .offset(x: offset) - .animation(shouldAnimate ? .linear(duration: Double(textWidth / 60)).repeatForever(autoreverses: true) : .default, value: offset) - } - .onAppear { containerWidth = geo.size.width } - .onChange(of: isHovering) { hovering in - if hovering && textWidth > containerWidth { - offset = -(textWidth - containerWidth + 8) - } else { - offset = 0 - } - } - } - .contentShape(Rectangle()) - .onHover { isHovering = $0 } - .clipped() - } - - private var shouldAnimate: Bool { - isHovering && textWidth > containerWidth - } -} - -struct Tag: View { - let text: LocalizedStringKey - var color: Color = .gray - - var body: some View { - Text(text) - .font(.system(size: 9)) // Reduced to 9 - .foregroundColor(color) - .lineLimit(1) - .minimumScaleFactor(0.7) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, 3) - .background( - Capsule() - .fill(color.opacity(0.12)) - ) - } -} - -// MARK: - Peer Detail View -struct PeerDetailView: View { - let peer: PeerInfo - @Environment(\.presentationMode) var presentationMode - - var body: some View { - VStack(spacing: 0) { - Text(LocalizedStringKey("点击条目以复制内容")) - .font(.caption) - .foregroundColor(.secondary) - .padding(.vertical, 8) - - Form { - if let myNode = peer.myNodeData { - localNodeSections(myNode) - } else if let pair = peer.fullData { - remotePeerSections(pair) - } else { - basicSections - } - } - .formStyle(.grouped) - } - .frame(width: 320, height: 450) - } - - @ViewBuilder - private func localNodeSections(_ node: SpotierStatus.NodeInfo) -> some View { - Section(header: Text(LocalizedStringKey("节点"))) { - DetailRow(label: LocalizedStringKey("主机名"), value: node.hostname) - DetailRow(label: LocalizedStringKey("版本"), value: node.version) - DetailRow(label: LocalizedStringKey("虚拟 IP"), value: node.virtualIPv4?.description ?? "-") - DetailRow(label: LocalizedStringKey("UDP NAT 类型"), value: node.stunInfo?.udpNATType.description ?? "-") - DetailRow(label: LocalizedStringKey("TCP NAT 类型"), value: node.stunInfo?.tcpNATType.description ?? "-") - } - - if let listeners = node.listeners, !listeners.isEmpty { - Section(header: Text(LocalizedStringKey("监听地址"))) { - ForEach(listeners.indices, id: \.self) { i in - DetailRow(label: LocalizedStringKey("监听 \(i+1)"), value: listeners[i].url) - } - } - } - } - - @ViewBuilder - private func remotePeerSections(_ pair: SpotierStatus.PeerRoutePair) -> some View { - let route = pair.route - - Section(header: Text(LocalizedStringKey("节点"))) { - DetailRow(label: LocalizedStringKey("主机名"), value: route.hostname) - DetailRow(label: LocalizedStringKey("节点 ID"), value: "\(route.peerId)", isMonospaced: true) - DetailRow(label: LocalizedStringKey("实例 ID"), value: route.instId, isMonospaced: true) - DetailRow(label: LocalizedStringKey("版本"), value: route.version) - DetailRow(label: LocalizedStringKey("下一跳 ID"), value: "\(route.nextHopPeerId)", isMonospaced: true) - DetailRow(label: LocalizedStringKey("代价"), value: "\(route.cost)") - DetailRow(label: LocalizedStringKey("路径延迟"), value: "\(route.pathLatency/1000) ms") - - if let nhLatFirst = route.nextHopPeerIdLatencyFirst { - DetailRow(label: LocalizedStringKey("下一跳 (延迟优先)"), value: "\(nhLatFirst)", isMonospaced: true) - DetailRow(label: LocalizedStringKey("代价 (延迟优先)"), value: "\(route.costLatencyFirst ?? 0)") - DetailRow(label: LocalizedStringKey("路径延迟 (延迟优先)"), value: "\((route.pathLatencyLatencyFirst ?? 0)/1000) ms") - } - - if let flags = route.featureFlag { - DetailRow(label: LocalizedStringKey("特性标志"), value: formatFlags(flags)) - } - } - - if let pInfo = pair.peer { - Section(header: Text(LocalizedStringKey("连接状态"))) { - DetailRow(label: LocalizedStringKey("默认连接"), value: pInfo.defaultConnId?.description ?? "-") - } - - ForEach(pInfo.conns.indices, id: \.self) { i in - let conn = pInfo.conns[i] - Section(header: Text(LocalizedStringKey("连接 \(i + 1) [\(conn.tunnel?.tunnelType ?? "Unknown")]"))) { - DetailRow(label: LocalizedStringKey("角色"), value: conn.isClient ? "Client" : "Server") - DetailRow(label: LocalizedStringKey("丢包率"), value: String(format: "%.2f%%", conn.lossRate * 100)) - DetailRow(label: LocalizedStringKey("本地地址"), value: conn.tunnel?.localAddr.url ?? "-") - DetailRow(label: LocalizedStringKey("远程地址"), value: conn.tunnel?.remoteAddr.url ?? "-") - - if let s = conn.stats { - DetailRow(label: LocalizedStringKey("接收"), value: formatBytes(s.rxBytes)) - DetailRow(label: LocalizedStringKey("发送"), value: formatBytes(s.txBytes)) - DetailRow(label: LocalizedStringKey("延迟"), value: String(format: "%.1f ms", Double(s.latencyUs)/1000.0)) - } - } - } - } - } - - @ViewBuilder - private var basicSections: some View { - Section(header: Text(LocalizedStringKey("基础信息"))) { - DetailRow(label: LocalizedStringKey("主机名"), value: peer.hostname) - DetailRow(label: LocalizedStringKey("虚拟 IP"), value: peer.ipv4) - DetailRow(label: LocalizedStringKey("版本"), value: peer.version) - } - Section(header: Text(LocalizedStringKey("网络信息"))) { - DetailRow(label: LocalizedStringKey("代价"), value: peer.cost) - DetailRow(label: LocalizedStringKey("延迟"), value: peer.latency + " ms") - DetailRow(label: LocalizedStringKey("丢包率"), value: peer.loss) - DetailRow(label: LocalizedStringKey("隧道方式"), value: peer.tunnel) - } - } - - private func formatFlags(_ flags: SpotierStatus.PeerFeatureFlag) -> String { - var parts = [String]() - if flags.isPublicServer { parts.append("public_server") } - if flags.avoidRelayData { parts.append("avoid_relay") } - if flags.kcpInput { parts.append("kcp_input") } - if flags.noRelayKcp { parts.append("no_relay_kcp") } - if flags.supportConnListSync { parts.append("conn_list_sync") } - return parts.isEmpty ? "None" : parts.joined(separator: ", ") - } - - private func formatBytes(_ bytes: Int) -> String { - if bytes < 1024 { return "\(bytes) B" } - let kb = Double(bytes) / 1024.0 - if kb < 1024 { return String(format: "%.1f KB", kb) } - let mb = kb / 1024.0 - if mb < 1024 { return String(format: "%.1f MB", mb) } - return String(format: "%.2f GB", mb / 1024.0) - } -} - -struct DetailRow: View { - let label: LocalizedStringKey - let value: String - var isMonospaced: Bool = false - - @State private var isCopied = false - - var body: some View { - HStack(alignment: .center, spacing: 0) { - Text(label) - .font(.system(size: 13)) - .foregroundColor(.primary) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - .layoutPriority(1) - - Spacer(minLength: 8) - - Text(LocalizedStringKey(value.isEmpty ? "-" : value)) - .font(isMonospaced ? .system(size: 13, weight: .regular, design: .monospaced) : .system(size: 13)) - .foregroundColor(.secondary) - .lineLimit(1) - .truncationMode(.tail) - .layoutPriority(0) - .opacity(isCopied ? 0.5 : 1.0) - } - .padding(.vertical, 2) - .contentShape(Rectangle()) - .onTapGesture { - copyToClipboard(value) - withAnimation(.easeInOut(duration: 0.1)) { - isCopied = true - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - withAnimation { - isCopied = false - } - } - } - .help(value) - } - - private func copyToClipboard(_ text: String) { - let pb = NSPasteboard.general - pb.clearContents() - pb.setString(text, forType: .string) - } -} - -struct ScrollingTextValue: View { - let text: String - let isMonospaced: Bool - - @State private var offset: CGFloat = 0 - @State private var isHovering = false - @State private var containerWidth: CGFloat = 0 - @State private var textWidth: CGFloat = 0 - - var body: some View { - GeometryReader { geo in - ZStack(alignment: .trailing) { - // Invisible text to measure width - Text(text) - .font(isMonospaced ? .system(size: 13, weight: .regular, design: .monospaced) : .system(size: 13)) - .fixedSize(horizontal: true, vertical: false) - .background(GeometryReader { textGeo in - Color.clear.onAppear { textWidth = textGeo.size.width } - }) - .opacity(0) - - // Visible text - Text(text) - .font(isMonospaced ? .system(size: 13, weight: .regular, design: .monospaced) : .system(size: 13)) - .foregroundColor(.secondary) - .fixedSize(horizontal: true, vertical: false) - .offset(x: offset) - .animation(shouldAnimate ? .linear(duration: Double(textWidth / 40)).repeatForever(autoreverses: true) : .default, value: offset) - } - .frame(maxWidth: .infinity, alignment: .trailing) - .onAppear { containerWidth = geo.size.width } - .onChange(of: isHovering) { hovering in - if hovering && textWidth > containerWidth { - offset = -(textWidth - containerWidth + 10) - } else { - offset = 0 - } - } - } - .frame(height: 18) - .contentShape(Rectangle()) - .onHover { isHovering = $0 } - .clipped() - } - - private var shouldAnimate: Bool { - isHovering && textWidth > containerWidth - } -} diff --git a/Spotier/PeerCardComponents.swift b/Spotier/PeerCardComponents.swift new file mode 100644 index 0000000..5618cc7 --- /dev/null +++ b/Spotier/PeerCardComponents.swift @@ -0,0 +1,71 @@ +import SwiftUI + +struct ScrollingText: View { + let text: String + + @State private var offset: CGFloat = 0 + @State private var isHovering = false + @State private var containerWidth: CGFloat = 0 + @State private var textWidth: CGFloat = 0 + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + Text(text) + .font(.system(size: 13, weight: .semibold)) + .fixedSize(horizontal: true, vertical: false) + .background( + GeometryReader { textGeometry in + Color.clear.onAppear { textWidth = textGeometry.size.width } + } + ) + .opacity(0) + + Text(text) + .font(.system(size: 13, weight: .semibold)) + .fixedSize(horizontal: true, vertical: false) + .offset(x: offset) + .animation( + shouldAnimate + ? .linear(duration: Double(textWidth / 60)).repeatForever(autoreverses: true) + : .default, + value: offset + ) + } + .onAppear { containerWidth = geometry.size.width } + .onChange(of: isHovering) { hovering in + if hovering && textWidth > containerWidth { + offset = -(textWidth - containerWidth + 8) + } else { + offset = 0 + } + } + } + .contentShape(Rectangle()) + .onHover { isHovering = $0 } + .clipped() + } + + private var shouldAnimate: Bool { + isHovering && textWidth > containerWidth + } +} + +struct Tag: View { + let text: LocalizedStringKey + var color: Color = .gray + + var body: some View { + Text(text) + .font(.system(size: 9)) + .foregroundColor(color) + .lineLimit(1) + .minimumScaleFactor(0.7) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 3) + .background( + Capsule() + .fill(color.opacity(0.12)) + ) + } +} diff --git a/Spotier/PeerDetailView.swift b/Spotier/PeerDetailView.swift new file mode 100644 index 0000000..9ddb333 --- /dev/null +++ b/Spotier/PeerDetailView.swift @@ -0,0 +1,152 @@ +import SwiftUI + +struct PeerDetailView: View { + let peer: PeerInfo + + @Environment(\.presentationMode) var presentationMode + + var body: some View { + VStack(spacing: 0) { + Text(LocalizedStringKey("点击条目以复制内容")) + .font(.caption) + .foregroundColor(.secondary) + .padding(.vertical, 8) + + Form { + if let myNode = peer.myNodeData { + localNodeSections(myNode) + } else if let pair = peer.fullData { + remotePeerSections(pair) + } else { + basicSections + } + } + .formStyle(.grouped) + } + .frame(width: 320, height: 450) + } + + @ViewBuilder + private func localNodeSections(_ node: SpotierStatus.NodeInfo) -> some View { + Section(header: Text(LocalizedStringKey("节点"))) { + CopyableDetailRow(label: LocalizedStringKey("主机名"), value: node.hostname) + CopyableDetailRow(label: LocalizedStringKey("版本"), value: node.version) + if let virtualIPv4 = node.virtualIPv4 { + CopyableDetailRow(label: LocalizedStringKey("虚拟 IP"), value: virtualIPv4.description) + } + if let stunInfo = node.stunInfo { + if !stunInfo.udpNATType.description.isEmpty { + CopyableDetailRow(label: LocalizedStringKey("UDP NAT 类型"), value: stunInfo.udpNATType.description) + } + if !stunInfo.tcpNATType.description.isEmpty { + CopyableDetailRow(label: LocalizedStringKey("TCP NAT 类型"), value: stunInfo.tcpNATType.description) + } + } + } + + if let listeners = node.listeners, !listeners.isEmpty { + Section(header: Text(LocalizedStringKey("监听地址"))) { + ForEach(listeners.indices, id: \.self) { index in + CopyableDetailRow(label: LocalizedStringKey("监听 \(index + 1)"), value: listeners[index].url) + } + } + } + } + + @ViewBuilder + private func remotePeerSections(_ pair: SpotierStatus.PeerRoutePair) -> some View { + let route = pair.route + + Section(header: Text(LocalizedStringKey("节点"))) { + CopyableDetailRow(label: LocalizedStringKey("主机名"), value: route.hostname) + CopyableDetailRow(label: LocalizedStringKey("节点 ID"), value: "\(route.peerId)", isMonospaced: true) + CopyableDetailRow(label: LocalizedStringKey("实例 ID"), value: route.instId, isMonospaced: true) + CopyableDetailRow(label: LocalizedStringKey("版本"), value: route.version) + CopyableDetailRow(label: LocalizedStringKey("下一跳 ID"), value: "\(route.nextHopPeerId)", isMonospaced: true) + CopyableDetailRow(label: LocalizedStringKey("代价"), value: "\(route.cost)") + CopyableDetailRow(label: LocalizedStringKey("路径延迟"), value: "\(route.pathLatency / 1000) ms") + + if let nextHopLatencyFirst = route.nextHopPeerIdLatencyFirst { + CopyableDetailRow(label: LocalizedStringKey("下一跳 (延迟优先)"), value: "\(nextHopLatencyFirst)", isMonospaced: true) + CopyableDetailRow(label: LocalizedStringKey("代价 (延迟优先)"), value: "\(route.costLatencyFirst ?? 0)") + CopyableDetailRow(label: LocalizedStringKey("路径延迟 (延迟优先)"), value: "\((route.pathLatencyLatencyFirst ?? 0) / 1000) ms") + } + + if let flags = route.featureFlag { + let formattedFlags = formatFlags(flags) + if !formattedFlags.isEmpty { + CopyableDetailRow(label: LocalizedStringKey("特性标志"), value: formattedFlags) + } + } + } + + if let peerInfo = pair.peer { + Section(header: Text(LocalizedStringKey("连接状态"))) { + if let defaultConnId = peerInfo.defaultConnId { + CopyableDetailRow(label: LocalizedStringKey("默认连接"), value: defaultConnId.description) + } + } + + ForEach(peerInfo.conns.indices, id: \.self) { index in + let connection = peerInfo.conns[index] + Section(header: Text(connectionTitle(for: connection, index: index))) { + CopyableDetailRow(label: LocalizedStringKey("角色"), value: connection.isClient ? "Client" : "Server") + CopyableDetailRow(label: LocalizedStringKey("丢包率"), value: String(format: "%.2f%%", connection.lossRate * 100)) + if let localAddr = connection.tunnel?.localAddr.url { + CopyableDetailRow(label: LocalizedStringKey("本地地址"), value: localAddr) + } + if let remoteAddr = connection.tunnel?.remoteAddr.url { + CopyableDetailRow(label: LocalizedStringKey("远程地址"), value: remoteAddr) + } + + if let stats = connection.stats { + CopyableDetailRow(label: LocalizedStringKey("接收"), value: formatBytes(stats.rxBytes)) + CopyableDetailRow(label: LocalizedStringKey("发送"), value: formatBytes(stats.txBytes)) + CopyableDetailRow(label: LocalizedStringKey("延迟"), value: String(format: "%.1f ms", Double(stats.latencyUs) / 1000.0)) + } + } + } + } + } + + @ViewBuilder + private var basicSections: some View { + Section(header: Text(LocalizedStringKey("基础信息"))) { + CopyableDetailRow(label: LocalizedStringKey("主机名"), value: peer.hostname) + CopyableDetailRow(label: LocalizedStringKey("虚拟 IP"), value: peer.ipv4) + CopyableDetailRow(label: LocalizedStringKey("版本"), value: peer.version) + } + Section(header: Text(LocalizedStringKey("网络信息"))) { + CopyableDetailRow(label: LocalizedStringKey("代价"), value: peer.cost) + CopyableDetailRow(label: LocalizedStringKey("延迟"), value: peer.latency + " ms") + CopyableDetailRow(label: LocalizedStringKey("丢包率"), value: peer.loss) + CopyableDetailRow(label: LocalizedStringKey("隧道方式"), value: peer.tunnel) + } + } + + private func connectionTitle(for connection: SpotierStatus.PeerConnInfo, index: Int) -> LocalizedStringKey { + if let tunnelType = connection.tunnel?.tunnelType { + return LocalizedStringKey("连接 \(index + 1) [\(tunnelType)]") + } + return LocalizedStringKey("连接 \(index + 1)") + } + + private func formatFlags(_ flags: SpotierStatus.PeerFeatureFlag) -> String { + var parts = [String]() + if flags.isPublicServer { parts.append("public_server") } + if flags.avoidRelayData { parts.append("avoid_relay") } + if flags.kcpInput { parts.append("kcp_input") } + if flags.noRelayKcp { parts.append("no_relay_kcp") } + if flags.supportConnListSync { parts.append("conn_list_sync") } + return parts.joined(separator: ", ") + } + + private func formatBytes(_ bytes: Int) -> String { + if bytes < 1024 { return "\(bytes) B" } + let kilobytes = Double(bytes) / 1024.0 + if kilobytes < 1024 { return String(format: "%.1f KB", kilobytes) } + let megabytes = kilobytes / 1024.0 + if megabytes < 1024 { return String(format: "%.1f MB", megabytes) } + return String(format: "%.2f GB", megabytes / 1024.0) + } +} diff --git a/Spotier/ScrollFixer.swift b/Spotier/ScrollFixer.swift index 35838df..99cd6bd 100644 --- a/Spotier/ScrollFixer.swift +++ b/Spotier/ScrollFixer.swift @@ -5,19 +5,15 @@ import AppKit /// This bypasses SwiftUI's limitations by interacting directly with the AppKit layer. struct ScrollFixer: NSViewRepresentable { func makeNSView(context: Context) -> NSView { - let view = NSView() - // Delay execution slightly to ensure the view hierarchy is built - DispatchQueue.main.async { - if let scrollView = findScrollView(for: view) { - scrollView.verticalScrollElasticity = .none - scrollView.hasVerticalScroller = false - scrollView.usesPredominantAxisScrolling = false // Forces strict axis locking - } - } - return view + NSView() } - func updateNSView(_ nsView: NSView, context: Context) {} + func updateNSView(_ nsView: NSView, context: Context) { + guard let scrollView = findScrollView(for: nsView) else { return } + scrollView.verticalScrollElasticity = .none + scrollView.hasVerticalScroller = false + scrollView.usesPredominantAxisScrolling = false + } private func findScrollView(for view: NSView) -> NSScrollView? { var current: NSView? = view.superview diff --git a/Spotier/SettingsView.swift b/Spotier/SettingsView.swift index c0bd140..6d6e691 100644 --- a/Spotier/SettingsView.swift +++ b/Spotier/SettingsView.swift @@ -8,15 +8,10 @@ struct SettingsView: View { @AppStorage("breathEffect") private var breathEffect: Bool = true @AppStorage("launchAtLogin") private var launchAtLogin: Bool = false - @AppStorage("logLevel", store: UserDefaults(suiteName: "group.com.alick.swiftier")) private var logLevel: String = "INFO" - - - + @State private var logLevel = StoredLogLevel.info @State private var showLicense = false - - - private let logLevels = ["OFF", "ERROR", "WARN", "INFO", "DEBUG", "TRACE"] + private let logSettingsStore = LogSettingsStore.shared var body: some View { ZStack { @@ -37,7 +32,7 @@ struct SettingsView: View { HStack { Text("版本") Spacer() - Text(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown") + Text(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String) .foregroundColor(.secondary) } @@ -59,8 +54,8 @@ struct SettingsView: View { Section(header: Text("日志"), footer: Text("修改日志等级后,需要停止并重新启动服务才能生效。")) { Picker(LocalizedStringKey("日志等级"), selection: $logLevel) { - ForEach(logLevels, id: \.self) { level in - Text(level).tag(level) + ForEach(StoredLogLevel.allCases, id: \.self) { level in + Text(level.rawValue).tag(level) } } } @@ -131,6 +126,10 @@ struct SettingsView: View { } .onAppear { checkLaunchAtLogin() + logLevel = logSettingsStore.readLevel() + } + .onChange(of: logLevel) { newValue in + logSettingsStore.writeLevel(newValue) } } diff --git a/Spotier/SharedComponents.swift b/Spotier/SharedComponents.swift index 82ea34a..70900f1 100644 --- a/Spotier/SharedComponents.swift +++ b/Spotier/SharedComponents.swift @@ -5,30 +5,46 @@ import SwiftUI /// 统一风格的页面 Header 布局容器 /// 仅负责布局结构,样式完全由内部按钮的系统 Style 决定 struct UnifiedHeader: View { - let title: LocalizedStringKey - let leftButton: () -> LeftBtn - let rightButton: () -> RightBtn - - init(title: LocalizedStringKey, @ViewBuilder left: @escaping () -> LeftBtn, @ViewBuilder right: @escaping () -> RightBtn) { - self.title = title - self.leftButton = left - self.rightButton = right + let leftButton: LeftBtn + let centerContent: AnyView + let rightButton: RightBtn + + init( + title: LocalizedStringKey, + @ViewBuilder left: @escaping () -> LeftBtn, + @ViewBuilder right: @escaping () -> RightBtn + ) { + self.leftButton = left() + self.centerContent = AnyView( + Text(title) + .font(.headline) + .lineLimit(1) + ) + self.rightButton = right() + } + + init( + @ViewBuilder left: @escaping () -> LeftBtn, + @ViewBuilder center: @escaping () -> some View, + @ViewBuilder right: @escaping () -> RightBtn + ) { + self.leftButton = left() + self.centerContent = AnyView(center()) + self.rightButton = right() } var body: some View { VStack(spacing: 0) { HStack { - leftButton() + leftButton Spacer() - Text(title) - .font(.headline) - .lineLimit(1) + centerContent Spacer() - rightButton() + rightButton } .padding(12) .background(Color(nsColor: .windowBackgroundColor)) // 保持背景一致 diff --git a/Spotier/SparklineView.swift b/Spotier/SparklineView.swift index 754d717..63398b8 100644 --- a/Spotier/SparklineView.swift +++ b/Spotier/SparklineView.swift @@ -35,30 +35,6 @@ struct SparklineView: NSViewRepresentable { nsView.needsLayout = true } } - - func makeCoordinator() -> Coordinator { - Coordinator() - } - - class Coordinator { - init() { - // Register as subscriber when view is actively instantiated (and likely to appear) - // But better done in onAppear. - } - deinit { - // Safety cleanup just in case - // DispatchQueue.main.async { SpotierRunner.shared.removeSubscriber() } - } - } -} - -extension SparklineView { - // SwiftUI View Modifier wrapper to handle lifecycle - // Actually, NSViewRepresentable does not have body. - // We should rely on the PARENT view to add these modifiers or wrap this in a View. - // However, we can hack it by invoking side effects in updateNSView? No, that's bad. - // Best practice: The container (ConfigGeneratorView or PeerCard list) applies the logic. - // OR: We wrap this struct in a View. } // Wrapper to handle Lifecycle comfortably @@ -112,9 +88,6 @@ final class SparklineNSView: NSView { // Scale Smoothing State private var currentRenderScale: Double = 100.0 - // Phase Sync State - private var isFirstPhase: Bool = true - override init(frame frameRect: NSRect) { super.init(frame: frameRect) setup() @@ -164,12 +137,7 @@ final class SparklineNSView: NSView { dotLayer.borderColor = NSColor.white.cgColor dotLayer.position = .zero pulseContainer.addSublayer(dotLayer) - - dotLayer.borderColor = NSColor.white.cgColor - dotLayer.position = .zero - pulseContainer.addSublayer(dotLayer) - - // Removed auto-start halo. Now triggered by data. + updateColors() } @@ -341,12 +309,13 @@ final class SparklineNSView: NSView { let isFirstRealUpdate = (prevLastValue == nil) && !isLayoutPass if isFirstRealUpdate { - let lastTime = SpotierRunner.shared.lastDataTime - let now = Date() - let elapsed = now.timeIntervalSince(lastTime) - if elapsed >= 0 && elapsed < animationDuration { - catchUpProgress = elapsed / animationDuration - remainingDuration = animationDuration - elapsed + if let lastTime = SpotierRunner.shared.lastDataTime { + let now = Date() + let elapsed = now.timeIntervalSince(lastTime) + if elapsed >= 0 && elapsed < animationDuration { + catchUpProgress = elapsed / animationDuration + remainingDuration = animationDuration - elapsed + } } prevLastValue = newData.last } else if !isLayoutPass { @@ -437,4 +406,4 @@ final class SparklineNSView: NSView { } return points } -} \ No newline at end of file +} diff --git a/Spotier/Spotier.entitlements b/Spotier/Spotier.entitlements index ad57bfd..0c1bd53 100644 --- a/Spotier/Spotier.entitlements +++ b/Spotier/Spotier.entitlements @@ -11,7 +11,6 @@ com.apple.developer.icloud-services CloudDocuments - CloudKit com.apple.developer.networking.networkextension diff --git a/Spotier/SpotierControlApp.swift b/Spotier/SpotierControlApp.swift index 0678311..9d63bd1 100644 --- a/Spotier/SpotierControlApp.swift +++ b/Spotier/SpotierControlApp.swift @@ -8,7 +8,13 @@ struct SpotierControlApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var runner = SpotierRunner.shared @StateObject private var iconState = MenuBarIconState.shared - @AppStorage("breathEffect") private var breathEffect: Bool = true + + init() { + UserDefaults.standard.register(defaults: [ + "connectOnStart": true, + "breathEffect": true + ]) + } var body: some Scene { MenuBarExtra { @@ -32,6 +38,7 @@ struct MenuBarLabelView: View { } // 菜单栏图标状态管理 +@MainActor class MenuBarIconState: ObservableObject { static let shared = MenuBarIconState() @@ -46,7 +53,6 @@ class MenuBarIconState: ObservableObject { private init() { // 优化:监听运行状态变化,按需启停 Timer SpotierRunner.shared.$isRunning - .receive(on: DispatchQueue.main) .sink { [weak self] isRunning in self?.handleRunningStateChange(isRunning: isRunning) } @@ -54,7 +60,6 @@ class MenuBarIconState: ObservableObject { // 监听 breathEffect 设置变化 NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification) - .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.updateTimerState() } @@ -68,7 +73,7 @@ class MenuBarIconState: ObservableObject { private func updateTimerState() { let isRunning = SpotierRunner.shared.isRunning - let blinkEnabled = (UserDefaults.standard.object(forKey: "breathEffect") as? Bool) ?? true + let blinkEnabled = UserDefaults.standard.bool(forKey: "breathEffect") if isRunning && blinkEnabled { startTimer() @@ -82,10 +87,9 @@ class MenuBarIconState: ObservableObject { guard animationTimer == nil else { return } animationTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in - guard let self = self else { return } - // 呼吸效果:切换实心/空心 - self.isShowingFilled.toggle() - self.currentIcon = self.isShowingFilled ? self.iconFilled : self.iconOutline + Task { @MainActor [weak self] in + self?.tickAnimation() + } } } @@ -95,18 +99,21 @@ class MenuBarIconState: ObservableObject { } private func updateIcon(isRunning: Bool) { - if isRunning { - currentIcon = iconFilled - isShowingFilled = true - } else { - currentIcon = iconOutline - isShowingFilled = true - } + isShowingFilled = true + currentIcon = isRunning ? iconFilled : iconOutline + } + + private func tickAnimation() { + isShowingFilled.toggle() + currentIcon = isShowingFilled ? iconFilled : iconOutline } } +@MainActor class AppDelegate: NSObject, NSApplicationDelegate { + private static let selectedConfigPathDefaultsKey = "selected_config_path" private var cancellables = Set() + private let configRepository: ConfigFileAccessing = ConfigFileRepository.shared func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) @@ -128,7 +135,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { VPNManager.shared.$isReady .filter { $0 } .first() - .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.syncStateOnLaunch() } @@ -137,7 +143,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { private func syncStateOnLaunch() { let vpn = VPNManager.shared - let connectOnStart = (UserDefaults.standard.object(forKey: "connectOnStart") as? Bool) ?? true + let connectOnStart = UserDefaults.standard.bool(forKey: "connectOnStart") print("[Launch] VPN status: \(vpn.status.rawValue), isConnected: \(vpn.isConnected), onDemand: \(vpn.isOnDemandEnabled)") // 同步 Runner 的 UI 状态 @@ -156,8 +162,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { // 如果开启了自动连接,手动触发一次(首次安装或 On Demand 尚未生效时) if connectOnStart { - let configs = ConfigManager.shared.refreshConfigs() - if let config = configs.first { + let configs = configRepository.refreshConfigs() + if let savedPath = UserDefaults.standard.string(forKey: Self.selectedConfigPathDefaultsKey), + let config = configs.first(where: { $0.path == savedPath }) { print("[Launch] Triggering initial connect with: \(config.lastPathComponent)") SpotierRunner.shared.toggleService(configPath: config.path) } diff --git a/Spotier/SpotierRunner.swift b/Spotier/SpotierRunner.swift index b92af12..b4ace1b 100644 --- a/Spotier/SpotierRunner.swift +++ b/Spotier/SpotierRunner.swift @@ -7,12 +7,13 @@ import Foundation import Combine -import AppKit import SwiftUI import NetworkExtension +@MainActor final class SpotierRunner: ObservableObject { static let shared = SpotierRunner() + private let configRepository: ConfigFileAccessing = ConfigFileRepository.shared @Published var isRunning = false @Published var peers: [PeerInfo] = [] @@ -24,25 +25,21 @@ final class SpotierRunner: ObservableObject { @Published var uptimeText: String = "00:00:00" // 公开最后一次数据更新的时间戳,供 UI 层做动画相位对齐 - @Published private(set) var lastDataTime: Date = Date.distantPast + @Published private(set) var lastDataTime: Date? private var startedAt: Date? - private var timer: AnyCancellable? @Published private(set) var sessionID = UUID() private var currentSessionID = UUID() - private var lastConfigPath: String? // Speed calculation private var lastTotalRx: Int = 0 private var lastTotalTx: Int = 0 private var lastPollTime: Date? - private var lastProcessingTime: Date = .distantPast // 用于频率限制 + private var lastProcessingTime: Date? - // Peer-level speed tracking - private var lastPeerStats: [Int: (rx: Int, tx: Int, time: Date)] = [:] private let jsonDecoder = JSONDecoder() - @Published var virtualIP: String = "-" + @Published var virtualIP: String = "" // Speed history for graphs @Published var downloadHistory: [Double] = Array(repeating: 0.0, count: 20) @@ -50,7 +47,6 @@ final class SpotierRunner: ObservableObject { // Subscriber & Polling Control private var subscriberCount = 0 - private var isAppActive = true private var pollingTimer: AnyCancellable? private let activeInterval: TimeInterval = 1.0 private let lowPowerInterval: TimeInterval = 5.0 @@ -60,31 +56,17 @@ final class SpotierRunner: ObservableObject { private var statusObserver: AnyCancellable? private init() { - // App Lifecycle Monitoring - NotificationCenter.default.addObserver(self, selector: #selector(handleAppDidBecomeActive), name: NSApplication.didBecomeActiveNotification, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(handleAppWillResignActive), name: NSApplication.willResignActiveNotification, object: nil) - // Listen to VPNManager status changes statusObserver = VPNManager.shared.$status.sink { [weak self] status in - self?.handleVPNStatusChange(status) + Task { @MainActor [weak self] in + self?.handleVPNStatusChange(status) + } } // Check initial state syncWithVPNState() } - @objc private func handleAppDidBecomeActive() { - // print("[Runner] App Active -> High Perf Mode") - isAppActive = true - updatePollingMode() - } - - @objc private func handleAppWillResignActive() { - // print("[Runner] App Background -> Low Power Mode") - isAppActive = false - updatePollingMode() - } - func addSubscriber() { subscriberCount += 1 updatePollingMode() @@ -117,35 +99,21 @@ final class SpotierRunner: ObservableObject { } private func handleVPNStatusChange(_ status: NEVPNStatus) { - DispatchQueue.main.async { - switch status { - case .connected: - if !self.isRunning { - self.isRunning = true - // 使用 NE 的实际连接时间,而非 App 启动时间 - self.startedAt = VPNManager.shared.connectedDate ?? Date() - self.startUptimeTimer() - self.startMonitoring() - } - self.isProcessing = false - case .disconnected, .invalid: - if self.isRunning { - self.isRunning = false - self.stopUptimeTimer() - self.peers = [] - self.uptimeText = "00:00:00" - self.downloadSpeed = "0 KB/s" - self.uploadSpeed = "0 KB/s" - self.virtualIP = "-" - self.downloadHistory = Array(repeating: 0.0, count: 20) - self.uploadHistory = Array(repeating: 0.0, count: 20) - } - self.isProcessing = false - case .connecting, .disconnecting, .reasserting: - self.isProcessing = true - @unknown default: - break + switch status { + case .connected: + if !isRunning { + beginSession(connectedDate: VPNManager.shared.connectedDate ?? Date()) + } + isProcessing = false + case .disconnected, .invalid: + if isRunning { + endSession() } + isProcessing = false + case .connecting, .disconnecting, .reasserting: + isProcessing = true + @unknown default: + break } } @@ -166,67 +134,21 @@ final class SpotierRunner: ObservableObject { } else { // Start // 手动启动时恢复 On Demand(之前手动关闭时会禁用) - let connectOnStart = (UserDefaults.standard.object(forKey: "connectOnStart") as? Bool) ?? true - if connectOnStart { + if UserDefaults.standard.bool(forKey: "connectOnStart") { VPNManager.shared.updateOnDemand(enabled: true) } // 使用 ConfigManager 读取(处理安全域) do { let configURL = URL(fileURLWithPath: configPath) - let configContent = try ConfigManager.shared.readConfigContent(configURL) + let configContent = try configRepository.readContent(at: configURL) VPNManager.shared.startVPN(configContent: configContent) - - // 缓存路径用于重启 - self.lastConfigPath = configPath } catch { print("Failed to read config for VPN: \(error)") - let alert = NSAlert() - alert.messageText = "配置读取失败" - alert.informativeText = "无法读取配置文件:\(error.localizedDescription)" - // alert.runModal() // 不要在 toggle 中阻塞 UI,尤其是自动连接时 - // 而是发送通知或者只是 log? - // 如果是手动点击,modal 是可以的。如果是自动启动... - // 暂时保留,但在主线程操作 - DispatchQueue.main.async { - if self.isWindowVisible { - alert.runModal() - } - } } } } - func restartService() { - guard isRunning else { return } - VPNManager.shared.stopVPN() - - // 监听 VPN 断开后再重连,而非使用固定延迟 - guard let path = lastConfigPath else { return } - var restartObserver: AnyCancellable? - restartObserver = VPNManager.shared.$status - .filter { $0 == .disconnected } - .first() - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.toggleService(configPath: path) - restartObserver?.cancel() - } - } - - func openLogFile() { - // Logs for NE are different. They might be in the Console.app or a shared file. - // If we implement file logging in PacketTunnelProvider to a shared container: - if let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.alick.spotier") { - let logURL = containerURL.appendingPathComponent("easytier.log") - if FileManager.default.fileExists(atPath: logURL.path) { - NSWorkspace.shared.open(logURL) - } else { - print("Log file not found at \(logURL.path)") - } - } - } - private func startMonitoring() { resetSpeedCounters() updatePollingMode() @@ -237,7 +159,8 @@ final class SpotierRunner: ObservableObject { lastTotalRx = 0 lastTotalTx = 0 lastPollTime = nil - lastPeerStats = [:] + lastProcessingTime = nil + lastDataTime = nil downloadHistory = Array(repeating: 0.0, count: 20) uploadHistory = Array(repeating: 0.0, count: 20) } @@ -248,7 +171,9 @@ final class SpotierRunner: ObservableObject { // Request running info directly from NE via IPC VPNManager.shared.requestRunningInfo { [weak self] json in guard let self = self, let json = json else { return } - self.processRunningInfo(json) + Task { @MainActor in + self.processRunningInfo(json) + } } } @@ -256,150 +181,166 @@ final class SpotierRunner: ObservableObject { // Copying the rest of the logic to ensure it works. private var throttleInterval: TimeInterval = 0.8 - - func setWarmUpMode(_ enabled: Bool) { - self.throttleInterval = enabled ? 0.05 : 0.8 - } - - func forceRefresh() { - refreshPeersOnce() - } private func processRunningInfo(_ jsonStr: String) { let now = Date() - guard now.timeIntervalSince(lastProcessingTime) >= throttleInterval else { + if let lastProcessingTime, now.timeIntervalSince(lastProcessingTime) < throttleInterval { return } - - lastProcessingTime = now guard let data = jsonStr.data(using: .utf8) else { return } - + let status: SpotierStatus + do { + status = try jsonDecoder.decode(SpotierStatus.self, from: data) + } catch { + return + } + + lastProcessingTime = now + LogParser.shared.updateEventsFromRunningInfo(status.events) + var totalRx = 0 var totalTx = 0 var fetchedPeers: [PeerInfo] = [] - - guard let status = try? jsonDecoder.decode(SpotierStatus.self, from: data) else { return } - - // 1. IP & Events - LogParser.shared.updateEventsFromRunningInfo(status.events) - if let myIp = status.myNodeInfo?.virtualIPv4?.description, self.virtualIP != myIp { - DispatchQueue.main.async { self.virtualIP = myIp } + + for pair in status.peerRoutePairs { + guard let peer = pair.peer else { continue } + for conn in peer.conns { + guard let stats = conn.stats else { continue } + totalRx += stats.rxBytes + totalTx += stats.txBytes } - - // 2. Stats - for pair in status.peerRoutePairs { - if let peer = pair.peer { - for conn in peer.conns { - if let stats = conn.stats { - totalRx += stats.rxBytes - totalTx += stats.txBytes - } + } + + if let myNode = status.myNodeInfo { + fetchedPeers.append(PeerInfo( + sessionID: currentSessionID, + ipv4: myNode.virtualIPv4?.description ?? "", + hostname: myNode.hostname, + cost: "本机", + latency: "0", + loss: "0.0%", + rx: formatBytes(totalRx), + tx: formatBytes(totalTx), + tunnel: "LOCAL", + nat: myNode.stunInfo?.udpNATType.description ?? "", + version: myNode.version, + myNodeData: myNode + )) + } + + for pair in status.peerRoutePairs { + var rxText = "0 B" + var txText = "0 B" + var latencyText = "" + var lossText = "" + var tunnelText = "" + + if let peer = pair.peer { + var rxBytes = 0 + var txBytes = 0 + var latencySum = 0 + var latencyCount = 0 + var lossSum = 0.0 + var lossCount = 0 + var tunnels = Set() + + for conn in peer.conns { + if let stats = conn.stats { + latencySum += stats.latencyUs + latencyCount += 1 + rxBytes += stats.rxBytes + txBytes += stats.txBytes } - } - } - - // 3. Local Node - if let myNode = status.myNodeInfo { - fetchedPeers.append(PeerInfo( - sessionID: self.currentSessionID, - ipv4: myNode.virtualIPv4?.description ?? "-", - hostname: myNode.hostname, - cost: "本机", - latency: "0", - loss: "0.0%", - rx: self.formatBytes(totalRx), - tx: self.formatBytes(totalTx), - tunnel: "LOCAL", - nat: myNode.stunInfo?.udpNATType.description ?? "Unknown", - version: myNode.version, - myNodeData: myNode - )) - } - - // 4. Remote Nodes - for pair in status.peerRoutePairs { - var rxVal = "0 B", txVal = "0 B", latencyVal = "", lossVal = "", tunnelVal = "" - - if let peer = pair.peer { - var cRx = 0, cTx = 0, latSum = 0, latCount = 0, lossSum = 0.0, lossCount = 0, tunnels = Set() - for conn in peer.conns { - if let s = conn.stats { - latSum += s.latencyUs; latCount += 1 - cRx += s.rxBytes; cTx += s.txBytes - } - - lossSum += conn.lossRate - lossCount += 1 - - if let t = conn.tunnel?.tunnelType { tunnels.insert(t.uppercased()) } + + lossSum += conn.lossRate + lossCount += 1 + + if let tunnelType = conn.tunnel?.tunnelType { + tunnels.insert(tunnelType.uppercased()) } - rxVal = formatBytes(cRx); txVal = formatBytes(cTx) - if latCount > 0 { latencyVal = String(format: "%.1f", Double(latSum)/Double(latCount)/1000.0) } - if lossCount > 0 { lossVal = String(format: "%.1f%%", (lossSum/Double(lossCount))*100.0) } - tunnelVal = tunnels.sorted().joined(separator: "&") - } else if let pathLat = pair.route.pathLatency as Int?, pathLat > 0 { - latencyVal = String(format: "%.1f", Double(pathLat) / 1000.0) } - - fetchedPeers.append(PeerInfo( - sessionID: self.currentSessionID, - ipv4: pair.route.ipv4Addr?.description ?? "", - hostname: pair.route.hostname, - cost: pair.route.cost == 1 ? "P2P" : "Relay(\(pair.route.cost))", - latency: latencyVal, loss: lossVal, rx: rxVal, tx: txVal, tunnel: tunnelVal, - nat: pair.route.stunInfo?.udpNATType.description ?? "Unknown", - version: pair.route.version, - fullData: pair - )) - } - - // Traffic update - if let lastT = lastPollTime { - let d = now.timeIntervalSince(lastT) - if d > 0.1 { - let rSpeed = max(0, Double(totalRx - lastTotalRx) / d) - let tSpeed = max(0, Double(totalTx - lastTotalTx) / d) - DispatchQueue.main.async { - self.downloadSpeed = self.formatSpeed(rSpeed) - self.uploadSpeed = self.formatSpeed(tSpeed) - self.downloadHistory.removeFirst(); self.downloadHistory.append(rSpeed) - self.uploadHistory.removeFirst(); self.uploadHistory.append(tSpeed) - - self.maxHistorySpeed = max( - (self.downloadHistory.max() ?? 0.0), - (self.uploadHistory.max() ?? 0.0), - 1_048_576.0 - ) + + rxText = formatBytes(rxBytes) + txText = formatBytes(txBytes) + if latencyCount > 0 { + latencyText = String(format: "%.1f", Double(latencySum) / Double(latencyCount) / 1000.0) } + if lossCount > 0 { + lossText = String(format: "%.1f%%", (lossSum / Double(lossCount)) * 100.0) + } + tunnelText = tunnels.sorted().joined(separator: "&") + } else if pair.route.pathLatency > 0 { + latencyText = String(format: "%.1f", Double(pair.route.pathLatency) / 1000.0) } + + fetchedPeers.append(PeerInfo( + sessionID: currentSessionID, + ipv4: pair.route.ipv4Addr?.description ?? "", + hostname: pair.route.hostname, + cost: pair.route.cost == 1 ? "P2P" : "Relay(\(pair.route.cost))", + latency: latencyText, + loss: lossText, + rx: rxText, + tx: txText, + tunnel: tunnelText, + nat: pair.route.stunInfo?.udpNATType.description ?? "", + version: pair.route.version, + fullData: pair + )) } + + let sortedPeers = fetchedPeers.sorted { p1, p2 in + let is1Local = p1.cost == "本机" + let is2Local = p2.cost == "本机" + if is1Local != is2Local { return is1Local } + + let is1Empty = p1.ipv4.isEmpty + let is2Empty = p2.ipv4.isEmpty + if is1Empty != is2Empty { return !is1Empty } + + return p1.ipv4.localizedStandardCompare(p2.ipv4) == .orderedAscending + } + + let receiveSpeed: Double + let transmitSpeed: Double + if let lastPollTime, now.timeIntervalSince(lastPollTime) > 0.1 { + let elapsed = now.timeIntervalSince(lastPollTime) + receiveSpeed = max(0, Double(totalRx - lastTotalRx) / elapsed) + transmitSpeed = max(0, Double(totalTx - lastTotalTx) / elapsed) + } else { + receiveSpeed = 0 + transmitSpeed = 0 + } + self.lastTotalRx = totalRx self.lastTotalTx = totalTx self.lastPollTime = now - - DispatchQueue.main.async { - self.lastDataTime = now - - let sorted = fetchedPeers.sorted { p1, p2 in - let is1L = p1.cost == "本机"; let is2L = p2.cost == "本机" - if is1L != is2L { return is1L } - let is1Empty = p1.ipv4.isEmpty; let is2Empty = p2.ipv4.isEmpty - if is1Empty != is2Empty { return !is1Empty } - return p1.ipv4.localizedStandardCompare(p2.ipv4) == .orderedAscending - } - - let oldIDs = self.peers.map(\.id) - let newIDs = sorted.map(\.id) - - if oldIDs != newIDs { - withAnimation(.spring(response: 0.5, dampingFraction: 0.82)) { - self.peers = sorted - } - } else { - self.peers = sorted + + lastDataTime = now + virtualIP = status.myNodeInfo?.virtualIPv4?.description ?? "" + downloadSpeed = formatSpeed(receiveSpeed) + uploadSpeed = formatSpeed(transmitSpeed) + downloadHistory.removeFirst() + downloadHistory.append(receiveSpeed) + uploadHistory.removeFirst() + uploadHistory.append(transmitSpeed) + maxHistorySpeed = max( + downloadHistory.max() ?? 0.0, + uploadHistory.max() ?? 0.0, + 1_048_576.0 + ) + + let oldIDs = peers.map(\.id) + let newIDs = sortedPeers.map(\.id) + + if oldIDs != newIDs { + withAnimation(.spring(response: 0.5, dampingFraction: 0.82)) { + peers = sortedPeers } - self.peerCount = "\(sorted.count)" + } else { + peers = sortedPeers } + peerCount = "\(sortedPeers.count)" } private func formatSpeed(_ bytesPerSec: Double) -> String { @@ -420,12 +361,39 @@ final class SpotierRunner: ObservableObject { } private var uptimeTimer: Timer? - + + private func beginSession(connectedDate: Date) { + guard !isRunning else { return } + + let nextSessionID = UUID() + isRunning = true + startedAt = connectedDate + currentSessionID = nextSessionID + sessionID = nextSessionID + startUptimeTimer() + startMonitoring() + } + + private func endSession() { + isRunning = false + startedAt = nil + stopUptimeTimer() + peers = [] + peerCount = "0" + uptimeText = "00:00:00" + downloadSpeed = "0 KB/s" + uploadSpeed = "0 KB/s" + virtualIP = "" + resetSpeedCounters() + } + private func startUptimeTimer() { guard uptimeTimer == nil else { return } updateUptimeText() let t = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in - self?.updateUptimeText() + Task { @MainActor [weak self] in + self?.updateUptimeText() + } } RunLoop.main.add(t, forMode: .common) uptimeTimer = t diff --git a/Spotier/VPNManager.swift b/Spotier/VPNManager.swift index 55f3a29..441a722 100644 --- a/Spotier/VPNManager.swift +++ b/Spotier/VPNManager.swift @@ -2,7 +2,7 @@ import Foundation import NetworkExtension import Combine -class VPNManager: ObservableObject { +class VPNManager: ObservableObject, VPNControlling { static let shared = VPNManager() @Published var isConnected = false @@ -11,7 +11,7 @@ class VPNManager: ObservableObject { @Published var isReady = false var isOnDemandEnabled: Bool { - manager?.isOnDemandEnabled ?? false + manager?.isOnDemandEnabled == true } /// NE 隧道的实际连接时间 @@ -44,35 +44,28 @@ class VPNManager: ObservableObject { if let error = error { print("Error loading VPN preferences: \(error)") - DispatchQueue.main.async { - self.statusText = "加载 VPN 配置失败: \(error.localizedDescription)" - } + self.statusText = "加载 VPN 配置失败: \(error.localizedDescription)" return } - if let existingManager = managers?.first { - // 必须在主线程同步设置所有 @Published 属性,避免竞态 - DispatchQueue.main.async { - self.manager = existingManager - // 同步更新状态(不再二次派发) - self.updateStatusSync() - // 状态已就绪后再标记 isReady,确保后续逻辑能读到正确的 isConnected - self.isReady = true - self.processPendingStartIfNeeded() - - // 仅在 On Demand 设置不一致时才 save,避免 saveToPreferences 导致系统重启隧道 - let connectOnStart = (UserDefaults.standard.object(forKey: "connectOnStart") as? Bool) ?? true - if existingManager.isOnDemandEnabled != connectOnStart { - self.applyOnDemandRules(to: existingManager) - existingManager.saveToPreferences { error in - if let error = error { - print("VPNManager: Error updating On Demand rules: \(error)") - } - } + guard let existingManager = managers?.first else { + self.setupVPNProfile() + return + } + + self.manager = existingManager + self.updateStatusSync() + self.isReady = true + self.processPendingStartIfNeeded() + + let connectOnStartEnabled = UserDefaults.standard.bool(forKey: "connectOnStart") + if existingManager.isOnDemandEnabled != connectOnStartEnabled { + self.applyOnDemandRules(to: existingManager, enabled: connectOnStartEnabled) + self.saveManager(existingManager) { error in + if let error = error { + print("VPNManager: Error updating On Demand rules: \(error)") } } - } else { - self.setupVPNProfile() } } } @@ -92,78 +85,51 @@ class VPNManager: ObservableObject { manager.isEnabled = true // Connect On Demand: 网络可用时系统自动启动 NE - applyOnDemandRules(to: manager) + applyOnDemandRules(to: manager, enabled: UserDefaults.standard.bool(forKey: "connectOnStart")) - manager.saveToPreferences { [weak self] error in + saveManager(manager) { [weak self] error in if let error = error { print("VPNManager: Error saving VPN profile: \(error.localizedDescription)") - DispatchQueue.main.async { - self?.statusText = "创建 VPN 配置失败: \(error.localizedDescription)" - } + self?.statusText = "创建 VPN 配置失败: \(error.localizedDescription)" } else { print("VPNManager: VPN Profile saved successfully.") - // 二次保存确保持久化 - manager.saveToPreferences { error in - if let error = error { - print("VPNManager: Error on second save: \(error)") - DispatchQueue.main.async { - self?.statusText = "保存 VPN 配置失败: \(error.localizedDescription)" - } - } else { - print("VPNManager: Second save successful.") - } - } self?.loadPreferences() } } } - - /// 配置 Connect On Demand 规则 - private func applyOnDemandRules(to manager: NETunnelProviderManager) { - let connectOnStart = (UserDefaults.standard.object(forKey: "connectOnStart") as? Bool) ?? true - - if connectOnStart { - // 任何网络可用时自动连接 + + private func applyOnDemandRules(to manager: NETunnelProviderManager, enabled: Bool) { + if enabled { let wifiRule = NEOnDemandRuleConnect() wifiRule.interfaceTypeMatch = .wiFi - + let ethernetRule = NEOnDemandRuleConnect() ethernetRule.interfaceTypeMatch = .ethernet - + manager.onDemandRules = [wifiRule, ethernetRule] manager.isOnDemandEnabled = true - print("VPNManager: Connect On Demand enabled") } else { manager.onDemandRules = [] manager.isOnDemandEnabled = false - print("VPNManager: Connect On Demand disabled") } + + print("VPNManager: Connect On Demand \(enabled ? "enabled" : "disabled")") } /// 外部调用:更新 On Demand 设置(设置页切换时调用) func updateOnDemand(enabled: Bool) { guard let manager = manager else { return } - manager.loadFromPreferences { [weak self] error in - guard let self = self, let mgr = self.manager else { return } + manager.loadFromPreferences { [weak self, manager] error in + guard let self else { return } if let error = error { print("VPNManager: Error loading preferences for On Demand update: \(error)") return } - if enabled { - let wifiRule = NEOnDemandRuleConnect() - wifiRule.interfaceTypeMatch = .wiFi - let ethernetRule = NEOnDemandRuleConnect() - ethernetRule.interfaceTypeMatch = .ethernet - mgr.onDemandRules = [wifiRule, ethernetRule] - mgr.isOnDemandEnabled = true - } else { - mgr.onDemandRules = [] - mgr.isOnDemandEnabled = false - } + self.applyOnDemandRules(to: manager, enabled: enabled) - mgr.saveToPreferences { error in + self.saveManager(manager) { error in if let error = error { print("VPNManager: Error updating On Demand: \(error)") } else { @@ -173,29 +139,31 @@ class VPNManager: ObservableObject { } } - func saveConfigToAppGroup(configContent: String) -> URL? { - guard let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP_ID) else { + private func saveManager(_ manager: NETunnelProviderManager, completion: @escaping (Error?) -> Void) { + manager.saveToPreferences(completionHandler: completion) + } + + func saveConfigToAppGroup(configContent: String) -> Bool { + guard let groupURL = appGroupContainerURL() else { print("Failed to get App Group container") - return nil + return false } let configURL = groupURL.appendingPathComponent("config.toml") do { try configContent.write(to: configURL, atomically: true, encoding: .utf8) - return configURL + return true } catch { print("Failed to write config to App Group: \(error)") - return nil + return false } } func startVPN(configContent: String) { guard let manager else { print("VPN Manager not ready, queue start request") - DispatchQueue.main.async { - self.pendingStartConfigContent = configContent - self.statusText = "VPN 初始化中,已排队启动..." - } + pendingStartConfigContent = configContent + statusText = "VPN 初始化中,已排队启动..." loadPreferences() return } @@ -211,24 +179,23 @@ class VPNManager: ObservableObject { guard let manager = manager else { return } // Apple 要求 save 前先 load 最新状态 - manager.loadFromPreferences { [weak self] error in - guard let self = self, let mgr = self.manager else { return } + manager.loadFromPreferences { [manager] error in if let error = error { print("VPNManager: Error loading preferences: \(error)") // 即使 load 失败也尝试 stop - mgr.connection.stopVPNTunnel() + manager.connection.stopVPNTunnel() return } - mgr.isOnDemandEnabled = false - mgr.saveToPreferences { error in + manager.isOnDemandEnabled = false + self.saveManager(manager) { error in if let error = error { print("VPNManager: Error disabling On Demand: \(error)") } else { print("VPNManager: On Demand disabled, now stopping tunnel") } // save 完成后再 stop - mgr.connection.stopVPNTunnel() + manager.connection.stopVPNTunnel() } } } @@ -254,48 +221,43 @@ class VPNManager: ObservableObject { /// Request running info JSON from NE via IPC func requestRunningInfo(completion: @escaping (String?) -> Void) { sendProviderMessage("running_info") { data in - if let data = data, let json = String(data: data, encoding: .utf8) { - completion(json) - } else { + guard let data, let json = String(data: data, encoding: .utf8) else { completion(nil) + return } + completion(json) } } @objc private func vpnStatusDidChange(_ notification: Notification) { - DispatchQueue.main.async { - self.updateStatusSync() - } + updateStatusSync() } /// 同步更新状态,必须在主线程调用 private func updateStatusSync() { guard let connection = manager?.connection else { return } - self.status = connection.status - - switch connection.status { + status = connection.status + + let state: (Bool, String) = switch connection.status { case .connected: - self.isConnected = true - self.statusText = "已连接" + (true, "已连接") case .connecting: - self.isConnected = false - self.statusText = "连接中..." + (false, "连接中...") case .disconnected: - self.isConnected = false - self.statusText = "未连接" + (false, "未连接") case .disconnecting: - self.isConnected = false - self.statusText = "断开中..." + (false, "断开中...") case .invalid: - self.isConnected = false - self.statusText = "无效状态" + (false, "无效状态") case .reasserting: - self.isConnected = false - self.statusText = "重连中..." + (false, "重连中...") @unknown default: - self.statusText = "未知状态" + (false, "未知状态") } + + isConnected = state.0 + statusText = state.1 } private func processPendingStartIfNeeded() { @@ -315,7 +277,7 @@ class VPNManager: ObservableObject { private func performStartVPN(using manager: NETunnelProviderManager, configContent: String) { // 我们不直接通过 options 传递大文本,而是保存到 App Group - guard saveConfigToAppGroup(configContent: configContent) != nil else { + guard saveConfigToAppGroup(configContent: configContent) else { statusText = "保存配置失败" return } diff --git a/SpotierNE/EasyTierShared.swift b/SpotierNE/EasyTierShared.swift index a366925..228e91b 100755 --- a/SpotierNE/EasyTierShared.swift +++ b/SpotierNE/EasyTierShared.swift @@ -1,12 +1,26 @@ +import Foundation import NetworkExtension import os -public let APP_BUNDLE_ID: String = "com.alick.swiftier" +public let APP_BUNDLE_ID: String = "com.alick.spotier" public let APP_GROUP_ID: String = "group.com.alick.spotier" public let ICLOUD_CONTAINER_ID: String = "iCloud.com.alick.spotier" public let LOG_FILENAME: String = "easytier.log" +public func appGroupDefaults() -> UserDefaults? { + UserDefaults(suiteName: APP_GROUP_ID) +} + +public func appGroupContainerURL(fileManager: FileManager = .default) -> URL? { + fileManager.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP_ID) +} + +public func appGroupFileURL(_ filename: String, fileManager: FileManager = .default) -> URL? { + appGroupContainerURL(fileManager: fileManager)?.appendingPathComponent(filename) +} + public enum LogLevel: String, Codable, CaseIterable { + case off = "off" case trace = "trace" case debug = "debug" case info = "info" @@ -14,6 +28,64 @@ public enum LogLevel: String, Codable, CaseIterable { case error = "error" } +public enum StoredLogLevel: String, Codable, CaseIterable { + case off = "OFF" + case error = "ERROR" + case warn = "WARN" + case info = "INFO" + case debug = "DEBUG" + case trace = "TRACE" + + public init(storedValue: String) { + self = StoredLogLevel(rawValue: storedValue.uppercased()) ?? .info + } + + public var effectiveLogLevel: LogLevel { + switch self { + case .off: return .off + case .error: return .error + case .warn: return .warn + case .info: return .info + case .debug: return .debug + case .trace: return .trace + } + } + + public func allows(_ logLevel: LogLevel) -> Bool { + rank(of: logLevel) <= rank + } + + private var rank: Int { + switch self { + case .off: return 0 + case .error: return 1 + case .warn: return 2 + case .info: return 3 + case .debug: return 4 + case .trace: return 5 + } + } + + private func rank(of logLevel: LogLevel) -> Int { + switch logLevel { + case .off: return 0 + case .error: return 1 + case .warn: return 2 + case .info: return 3 + case .debug: return 4 + case .trace: return 5 + } + } +} + +public func readStoredLogLevel(defaults: UserDefaults? = appGroupDefaults()) -> StoredLogLevel { + StoredLogLevel(storedValue: defaults?.string(forKey: "logLevel") ?? StoredLogLevel.info.rawValue) +} + +public func writeStoredLogLevel(_ level: StoredLogLevel, defaults: UserDefaults? = appGroupDefaults()) { + defaults?.set(level.rawValue, forKey: "logLevel") +} + public struct EasyTierOptions: Codable { public var config: String = "" public var ipv4: String? @@ -144,7 +216,7 @@ public enum ProviderCommand: String, Codable, CaseIterable { public func connectWithManager(_ manager: NETunnelProviderManager, logger: Logger? = nil) async throws { manager.isEnabled = true - if let defaults = UserDefaults(suiteName: APP_GROUP_ID) { + if let defaults = appGroupDefaults() { manager.protocolConfiguration?.includeAllNetworks = defaults.bool(forKey: "includeAllNetworks") manager.protocolConfiguration?.excludeLocalNetworks = defaults.bool(forKey: "excludeLocalNetworks") if #available(iOS 16.4, *) { diff --git a/SpotierNE/PacketTunnelProvider.swift b/SpotierNE/PacketTunnelProvider.swift index 8f9adb7..1228f6b 100644 --- a/SpotierNE/PacketTunnelProvider.swift +++ b/SpotierNE/PacketTunnelProvider.swift @@ -4,6 +4,16 @@ import os let debounceInterval: TimeInterval = 0.5 +private struct ConfigHints { + var ipv4: String? + var subnet: String? + var ipv6: String? + var ipv6Prefix: Int? + var mtu: Int? + var magicDNS = false + var magicDNSZone = "et.net" +} + class PacketTunnelProvider: NEPacketTunnelProvider { // Hold a weak reference for C callback bridging @@ -12,21 +22,15 @@ class PacketTunnelProvider: NEPacketTunnelProvider { private var lastAppliedSettings: SettingsSnapshot? private var needReapplySettings = false - private var debounceWorkItem: DispatchWorkItem? - private var parsedIPv4: String? // from config.toml - private var parsedSubnet: String? // e.g. "255.255.255.0" - private var parsedIPv6: String? - private var parsedIPv6Prefix: Int? - private var parsedMTU: Int? - private var parsedMagicDNS = false - private var parsedMagicDNSZone = "et.net" + private var debounceTask: Task? + private var configHints = ConfigHints() private let magicDNSResolver = "100.100.100.101" // MARK: - Config Loading private func loadConfig() -> String? { - guard let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP_ID) else { + guard let groupURL = appGroupContainerURL() else { logger.error("无法访问 App Group 容器: \(APP_GROUP_ID)") return nil } @@ -43,13 +47,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { /// Parse ipv4 and mtu from TOML config for initial network settings private func parseConfigHints(_ toml: String) { - parsedIPv4 = nil - parsedSubnet = nil - parsedIPv6 = nil - parsedIPv6Prefix = nil - parsedMTU = nil - parsedMagicDNS = false - parsedMagicDNSZone = "et.net" + var hints = ConfigHints() for line in toml.components(separatedBy: .newlines) { let trimmed = line.trimmingCharacters(in: .whitespaces) @@ -65,29 +63,31 @@ class PacketTunnelProvider: NEPacketTunnelProvider { // e.g. "10.126.126.1/24" let cidrParts = val.split(separator: "/") if cidrParts.count == 2 { - parsedIPv4 = String(cidrParts[0]) + hints.ipv4 = String(cidrParts[0]) if let cidr = Int(cidrParts[1]) { - parsedSubnet = cidrToSubnetMask(cidr) + hints.subnet = cidrToSubnetMask(cidr) } } case "ipv6": if let parsed = parseIPv6CIDR(val) { - parsedIPv6 = parsed.address - parsedIPv6Prefix = parsed.prefixLength + hints.ipv6 = parsed.address + hints.ipv6Prefix = parsed.prefixLength } case "mtu": - parsedMTU = Int(val) + hints.mtu = Int(val) case "enable_magic_dns", "accept_dns": - parsedMagicDNS = val.lowercased() == "true" + hints.magicDNS = val.lowercased() == "true" case "tld_dns_zone": let zone = val.trimmingCharacters(in: CharacterSet(charactersIn: ".")) if !zone.isEmpty { - parsedMagicDNSZone = zone + hints.magicDNSZone = zone } default: break } } + + configHints = hints } // MARK: - Running Info Callback @@ -124,16 +124,10 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } private func handleRustStop() { - let msg = EasyTierCore.getLatestErrorMessage() ?? "Unknown" + let msg = EasyTierCore.getLatestErrorMessage() ?? "" logger.error("Rust Core 已停止: \(msg)") - - // Save error to App Group for host app - if let defaults = UserDefaults(suiteName: APP_GROUP_ID) { - defaults.set(msg, forKey: "TunnelLastError") - defaults.synchronize() - } - - DispatchQueue.main.async { + + Task { @MainActor in self.cancelTunnelWithError(NSError( domain: "SwiftierNE", code: 2, userInfo: [NSLocalizedDescriptionKey: msg] @@ -144,27 +138,21 @@ class PacketTunnelProvider: NEPacketTunnelProvider { // MARK: - Dynamic Network Settings private func enqueueSettingsUpdate() { - DispatchQueue.main.async { [weak self] in + debounceTask?.cancel() + debounceTask = Task { @MainActor [weak self] in guard let self else { return } - - // Cancel previous pending debounce to batch rapid changes - self.debounceWorkItem?.cancel() - - let workItem = DispatchWorkItem { [weak self] in - guard let self else { return } - if self.reasserting { - logger.info("设置更新已在进行中,排队等待") - self.needReapplySettings = true - return - } - self.applyNetworkSettings { error in - if let error { - logger.error("设置更新失败: \(error)") - } + try? await Task.sleep(for: .seconds(debounceInterval)) + guard !Task.isCancelled else { return } + if self.reasserting { + logger.info("设置更新已在进行中,排队等待") + self.needReapplySettings = true + return + } + self.applyNetworkSettings { error in + if let error { + logger.error("设置更新失败: \(error)") } } - self.debounceWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + debounceInterval, execute: workItem) } } @@ -180,7 +168,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { let newSnapshot = SettingsSnapshot(from: settings) let wrappedCompletion: (Error?) -> Void = { error in - DispatchQueue.main.async { + Task { @MainActor in if error == nil { self.lastAppliedSettings = newSnapshot } @@ -234,6 +222,12 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } } else { logger.error("无法获取 TUN fd(packetFlow 和 scan 均失败)") + wrappedCompletion(NSError( + domain: "SwiftierNE", + code: 4, + userInfo: [NSLocalizedDescriptionKey: "无法获取 TUN 文件描述符"] + )) + return } } @@ -248,10 +242,10 @@ class PacketTunnelProvider: NEPacketTunnelProvider { let runningInfo = fetchRunningInfo() let runtimeIPv4 = runningInfo?.myNodeInfo?.virtualIPv4 - let ipv4Address = runtimeIPv4?.address.description ?? parsedIPv4 + let ipv4Address = runtimeIPv4?.address.description ?? configHints.ipv4 let subnetMask = runtimeIPv4 .flatMap { cidrToSubnetMask($0.networkLength) } - ?? parsedSubnet + ?? configHints.subnet if let ipv4Address, let subnetMask { let ipv4Settings = NEIPv4Settings(addresses: [ipv4Address], subnetMasks: [subnetMask]) @@ -271,7 +265,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { if let nodeIp = info.myNodeInfo?.virtualIPv4 { let networkAddr = maskedAddress(nodeIp.address, networkLength: nodeIp.networkLength) - let netMask = cidrToSubnetMask(nodeIp.networkLength) ?? "255.255.255.0" + let netMask = cidrToSubnetMask(nodeIp.networkLength)! routes.append(NEIPv4Route(destinationAddress: networkAddr, subnetMask: netMask)) } } @@ -281,7 +275,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { routes.append(NEIPv4Route(destinationAddress: networkAddr, subnetMask: subnetMask)) } - if parsedMagicDNS, + if configHints.magicDNS, !routes.contains(where: { ipv4RouteContainsAddress(destination: $0.destinationAddress, subnetMask: $0.destinationSubnetMask, address: magicDNSResolver) }) { routes.append(NEIPv4Route(destinationAddress: magicDNSResolver, subnetMask: "255.255.255.255")) } @@ -291,8 +285,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } let runtimeIPv6 = runningInfo?.myNodeInfo?.virtualIPv6 - let ipv6Address = runtimeIPv6?.address.description ?? parsedIPv6 - let ipv6PrefixLength = runtimeIPv6?.networkLength ?? parsedIPv6Prefix + let ipv6Address = runtimeIPv6?.address.description ?? configHints.ipv6 + let ipv6PrefixLength = runtimeIPv6?.networkLength ?? configHints.ipv6Prefix if let ipv6Address, let prefixLength = ipv6PrefixLength { let ipv6Settings = NEIPv6Settings( @@ -310,14 +304,14 @@ class PacketTunnelProvider: NEPacketTunnelProvider { settings.ipv6Settings = ipv6Settings } - if parsedMagicDNS { + if configHints.magicDNS { let dnsSettings = NEDNSSettings(servers: [magicDNSResolver]) - dnsSettings.searchDomains = [parsedMagicDNSZone] - dnsSettings.matchDomains = [parsedMagicDNSZone] + dnsSettings.searchDomains = [configHints.magicDNSZone] + dnsSettings.matchDomains = [configHints.magicDNSZone] settings.dnsSettings = dnsSettings } - settings.mtu = NSNumber(value: parsedMTU ?? 1380) + settings.mtu = NSNumber(value: configHints.mtu ?? 1380) if settings.ipv4Settings == nil && settings.ipv6Settings == nil { logger.warning("无可用 IP 地址,返回空设置") @@ -344,7 +338,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { // 3. 初始化 Logger(从 App Group 读取用户设置的日志等级) let savedLevel: LogLevel = { - if let defaults = UserDefaults(suiteName: APP_GROUP_ID), + if let defaults = appGroupDefaults(), let raw = defaults.string(forKey: "logLevel"), let level = LogLevel(rawValue: raw.lowercased()) { return level diff --git a/SpotierNE/SwiftierCore.swift b/SpotierNE/SwiftierCore.swift index 0dd9770..0a30b86 100644 --- a/SpotierNE/SwiftierCore.swift +++ b/SpotierNE/SwiftierCore.swift @@ -27,7 +27,7 @@ struct EasyTierCore { } if ret != 0 { - let msg = extractRustError(errPtr) ?? "Unknown logger error" + let msg = extractRustError(errPtr) ?? "" throw EasyTierError.initializationFailed(msg) } } @@ -40,7 +40,7 @@ struct EasyTierCore { } if ret != 0 { - let msg = extractRustError(errPtr) ?? "Unknown network start error" + let msg = extractRustError(errPtr) ?? "" throw EasyTierError.executionFailed(msg) } } @@ -56,7 +56,7 @@ struct EasyTierCore { let ret = set_tun_fd(fd, &errPtr) if ret != 0 { - let msg = extractRustError(errPtr) ?? "Unknown tun fd error" + let msg = extractRustError(errPtr) ?? "" throw EasyTierError.executionFailed(msg) } } @@ -66,7 +66,7 @@ struct EasyTierCore { var errPtr: UnsafePointer? = nil let ret = register_stop_callback(callback, &errPtr) if ret != 0 { - let msg = extractRustError(errPtr) ?? "Failed to register stop callback" + let msg = extractRustError(errPtr) ?? "" throw EasyTierError.initializationFailed(msg) } } @@ -95,7 +95,7 @@ struct EasyTierCore { var errPtr: UnsafePointer? = nil let ret = register_running_info_callback(callback, &errPtr) if ret != 0 { - let msg = extractRustError(errPtr) ?? "Failed to register running info callback" + let msg = extractRustError(errPtr) ?? "" throw EasyTierError.initializationFailed(msg) } } diff --git a/SpotierNE/TunnelHelper.swift b/SpotierNE/TunnelHelper.swift index 2505620..faf2f73 100755 --- a/SpotierNE/TunnelHelper.swift +++ b/SpotierNE/TunnelHelper.swift @@ -6,7 +6,7 @@ import os func tunnelFileDescriptor() -> Int32? { let CTLIOCGINFO_VALUE: UInt = 0xc0644e03 - logger.warning("tunnelFileDescriptor() use fallback") + logger.info("tunnelFileDescriptor() scan existing descriptors") var ctlInfo = ctl_info() withUnsafeMutablePointer(to: &ctlInfo.ctl_name) { $0.withMemoryRebound(to: CChar.self, capacity: MemoryLayout.size(ofValue: $0.pointee)) { @@ -41,7 +41,7 @@ func tunnelFileDescriptor() -> Int32? { } func initRustLogger(level: LogLevel) { - guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP_ID) else { + guard let containerURL = appGroupContainerURL() else { logger.error("initRustLogger() failed: App Group container not found") return } @@ -58,13 +58,12 @@ func initRustLogger(level: LogLevel) { } if ret != 0 { let err = extractRustString(errPtr) - logger.error("initRustLogger() failed to init: \(err ?? "Unknown", privacy: .public)") + logger.error("initRustLogger() failed to init: \(err ?? "", privacy: .public)") } } func extractRustString(_ strPtr: UnsafePointer?) -> String? { guard let strPtr else { - logger.error("extractRustString(): nullptr") return nil } let str = String(cString: strPtr) diff --git a/SpotierTests/ConfigGeneratorBehaviorTests.swift b/SpotierTests/ConfigGeneratorBehaviorTests.swift new file mode 100644 index 0000000..c43a4e7 --- /dev/null +++ b/SpotierTests/ConfigGeneratorBehaviorTests.swift @@ -0,0 +1,54 @@ +import XCTest +@testable import Spotier + +final class ConfigGeneratorBehaviorTests: XCTestCase { + func testCIDRStringBehaviorParsesAndUpdatesConsistently() { + XCTAssertEqual(CIDRStringBehavior.ip(from: "10.0.0.1/16"), "10.0.0.1") + XCTAssertEqual(CIDRStringBehavior.mask(from: "10.0.0.1/16"), "16") + XCTAssertEqual(CIDRStringBehavior.mask(from: "10.0.0.1"), "24") + XCTAssertEqual(CIDRStringBehavior.updatingIP("10.0.0.9", in: "10.0.0.1/16"), "10.0.0.9/16") + XCTAssertEqual(CIDRStringBehavior.updatingMask("20", in: "10.0.0.1/16"), "10.0.0.1/20") + XCTAssertEqual(CIDRStringBehavior.updatingMask("", in: "10.0.0.1/16"), "10.0.0.1/24") + } + + func testVpnPortalBindingsUseCentralizedCIDRBehavior() { + var model = SpotierConfigModel() + model.vpnPortalClientCidr = "10.14.14.0/24" + + XCTAssertEqual(model.vpnPortalIpBinding, "10.14.14.0") + XCTAssertEqual(model.vpnPortalCidrBinding, "24") + + model.vpnPortalIpBinding = "10.20.30.0" + XCTAssertEqual(model.vpnPortalClientCidr, "10.20.30.0/24") + + model.vpnPortalCidrBinding = "20" + XCTAssertEqual(model.vpnPortalClientCidr, "10.20.30.0/20") + } + + func testEditableStringListBehaviorAppendsAndRemovesByStableIdentity() { + let first = EditableStringItem(value: "a") + let second = EditableStringItem(value: "b") + + let appended = ConfigGeneratorListBehavior.appended([first, second], value: "c") + XCTAssertEqual(appended.map(\.value), ["a", "b", "c"]) + + let removed = ConfigGeneratorListBehavior.removing(second.id, from: appended) + XCTAssertEqual(removed.map(\.value), ["a", "c"]) + } + + func testStructuredListBehaviorRemovesProxySubnetAndPortForwardByIdentity() { + let subnetA = SpotierConfigModel.ProxySubnet(cidr: "10.0.0.0/24") + let subnetB = SpotierConfigModel.ProxySubnet(cidr: "10.0.1.0/24") + XCTAssertEqual( + ConfigGeneratorListBehavior.removing(subnetA.id, from: [subnetA, subnetB]).map(\.cidr), + ["10.0.1.0/24"] + ) + + let ruleA = PortForwardRule(protocolType: "TCP", bindIp: "0.0.0.0", bindPort: "8080", targetIp: "10.0.0.8", targetPort: "80") + let ruleB = PortForwardRule(protocolType: "UDP", bindIp: "0.0.0.0", bindPort: "5353", targetIp: "10.0.0.9", targetPort: "53") + XCTAssertEqual( + ConfigGeneratorListBehavior.removing(ruleA.id, from: [ruleA, ruleB]).map(\.bindPort), + ["5353"] + ) + } +} diff --git a/SpotierTests/ConfigGeneratorStoreTests.swift b/SpotierTests/ConfigGeneratorStoreTests.swift new file mode 100644 index 0000000..b1cc923 --- /dev/null +++ b/SpotierTests/ConfigGeneratorStoreTests.swift @@ -0,0 +1,93 @@ +import XCTest +@testable import Spotier + +final class ConfigGeneratorStoreTests: XCTestCase { + override func setUp() { + super.setUp() + ConfigDraftStore.shared.clearAll() + } + + override func tearDown() { + ConfigDraftStore.shared.clearAll() + super.tearDown() + } + + func testLoadModelReturnsDraftWhenPresent() { + let editingURL = URL(fileURLWithPath: "/tmp/example.toml") + var draft = SpotierConfigModel() + draft.instanceName = "draft-node" + ConfigDraftStore.shared.saveDraft(for: editingURL, model: draft) + + let result = ConfigGeneratorStore.loadModel( + editingFileURL: editingURL, + forceReset: false, + currentModel: SpotierConfigModel(), + lastLoadedURL: nil + ) + + XCTAssertEqual(result.model.instanceName, "draft-node") + XCTAssertEqual(result.lastLoadedURL, editingURL) + } + + func testLoadModelReturnsDraftWhenDraftMatchesCurrentState() { + let editingURL = URL(fileURLWithPath: "/tmp/example.toml") + var draft = SpotierConfigModel() + draft.instanceName = "same-model" + ConfigDraftStore.shared.saveDraft(for: editingURL, model: draft) + + let result = ConfigGeneratorStore.loadModel( + editingFileURL: editingURL, + forceReset: false, + currentModel: draft, + lastLoadedURL: nil + ) + + XCTAssertEqual(result.model, draft) + XCTAssertEqual(result.lastLoadedURL, editingURL) + } + + func testLoadModelReturnsCurrentModelWhenEditingURLDidNotChange() { + let editingURL = URL(fileURLWithPath: "/tmp/example.toml") + var current = SpotierConfigModel() + current.instanceName = "current-model" + + let result = ConfigGeneratorStore.loadModel( + editingFileURL: editingURL, + forceReset: false, + currentModel: current, + lastLoadedURL: editingURL + ) + + XCTAssertEqual(result.model, current) + XCTAssertEqual(result.lastLoadedURL, editingURL) + } + + func testForceResetClearsDraftForNewConfigFlow() { + var draft = SpotierConfigModel() + draft.instanceName = "stale-draft" + ConfigDraftStore.shared.saveDraft(for: nil, model: draft) + + let result = ConfigGeneratorStore.loadModel( + editingFileURL: nil, + forceReset: true, + currentModel: draft, + lastLoadedURL: nil + ) + + XCTAssertNil(ConfigDraftStore.shared.draft(for: nil)) + XCTAssertEqual(result.lastLoadedURL, nil) + XCTAssertNotEqual(result.model.instanceId, draft.instanceId) + } + + func testSaveDraftAndClearDraftDelegateToDraftStore() { + let editingURL = URL(fileURLWithPath: "/tmp/example.toml") + var draft = SpotierConfigModel() + draft.instanceName = "draft-node" + + ConfigGeneratorStore.saveDraft(draft, editingFileURL: editingURL) + XCTAssertEqual(ConfigDraftStore.shared.draft(for: editingURL)?.instanceName, "draft-node") + + ConfigGeneratorStore.clearDraft(editingFileURL: editingURL) + XCTAssertNil(ConfigDraftStore.shared.draft(for: editingURL)) + } +} diff --git a/SpotierTests/ConfigTemplateFactoryTests.swift b/SpotierTests/ConfigTemplateFactoryTests.swift new file mode 100644 index 0000000..42d9912 --- /dev/null +++ b/SpotierTests/ConfigTemplateFactoryTests.swift @@ -0,0 +1,23 @@ +import XCTest +@testable import Spotier + +final class ConfigTemplateFactoryTests: XCTestCase { + func testSanitizedNameFallsBackForBlankInput() { + XCTAssertEqual(ConfigTemplateFactory.sanitizedName(from: " "), "new-network") + XCTAssertEqual(ConfigTemplateFactory.filename(from: " "), "new-network.toml") + } + + func testFilenameAndContentUseTrimmedUserName() { + let content = ConfigTemplateFactory.content( + for: " office-node ", + instanceID: UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")! + ) + + XCTAssertEqual(ConfigTemplateFactory.filename(from: " office-node "), "office-node.toml") + XCTAssertTrue(content.contains("instance_name = \"office-node\"")) + XCTAssertTrue(content.contains("instance_id = \"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\"")) + XCTAssertTrue(content.contains("listeners = [\"tcp://0.0.0.0:11010\", \"udp://0.0.0.0:11010\", \"wg://0.0.0.0:11011\"]")) + XCTAssertTrue(content.contains("network_name = \"easytier\"")) + XCTAssertTrue(content.contains("uri = \"tcp://public.easytier.top:11010\"")) + } +} diff --git a/SpotierTests/LogContainerResolverTests.swift b/SpotierTests/LogContainerResolverTests.swift new file mode 100644 index 0000000..6b866d1 --- /dev/null +++ b/SpotierTests/LogContainerResolverTests.swift @@ -0,0 +1,23 @@ +import XCTest +@testable import Spotier + +final class LogContainerResolverTests: XCTestCase { + func testResolverUsesProvidedContainerURL() { + let expectedURL = URL(fileURLWithPath: "/tmp/easytier.log") + let resolver = LogContainerResolver( + fileManager: .default, + fileURLProvider: { _, _ in expectedURL } + ) + + XCTAssertEqual(resolver.logFileURL(), expectedURL) + } + + func testResolverReturnsNilWhenContainerURLMissing() { + let resolver = LogContainerResolver( + fileManager: .default, + fileURLProvider: { _, _ in nil } + ) + + XCTAssertNil(resolver.logFileURL()) + } +} diff --git a/SpotierTests/LogEventFormatterTests.swift b/SpotierTests/LogEventFormatterTests.swift new file mode 100644 index 0000000..2d1a61d --- /dev/null +++ b/SpotierTests/LogEventFormatterTests.swift @@ -0,0 +1,59 @@ +import XCTest +@testable import Spotier + +final class LogEventFormatterTests: XCTestCase { + func testParseEventEntryMapsWrappedEventTypeAndTimestamp() { + let input = """ + { + "time": "2026-03-22T10:20:30Z", + "event": { + "PeerAdded": { + "peer_id": 7, + "hostname": "demo" + } + } + } + """ + + let event = LogEventFormatter.parseEventEntry(from: input) + + XCTAssertEqual(event?.type, .peerAdded) + XCTAssertEqual(event?.name, "PeerAdded") + XCTAssertEqual(event?.timestamp, "2026-03-22 10:20:30") + XCTAssertTrue(event?.details.contains("\"peer_id\" : 7") ?? false) + } + + func testParseEventEntryReturnsUnknownForUnexpectedEventName() { + let input: [String: Any] = [ + "time": "2026-03-22T10:20:30Z", + "event": [ + "CustomEvent": [ + "enabled": true + ] + ] + ] + + let event = LogEventFormatter.parseEventEntry(from: input) + + XCTAssertEqual(event?.type, .unknown) + XCTAssertEqual(event?.name, "CustomEvent") + XCTAssertTrue(event?.details.contains("\"enabled\" : true") ?? false) + } + + func testCalculateHighlightsMarksStringsNumbersAndKeywords() { + let highlights = LogEventFormatter.calculateHighlights( + for: """ + { + "peer_id" : 7, + "enabled" : true, + "name" : "demo" + } + """ + ) + + XCTAssertTrue(highlights.contains(where: { $0.color == "green" })) + XCTAssertTrue(highlights.contains(where: { $0.color == "blue" && $0.bold })) + XCTAssertTrue(highlights.contains(where: { $0.color == "orange" })) + XCTAssertTrue(highlights.contains(where: { $0.color == "purple" && $0.bold })) + } +} diff --git a/SpotierTests/LogSettingsStoreTests.swift b/SpotierTests/LogSettingsStoreTests.swift new file mode 100644 index 0000000..2b4fa3d --- /dev/null +++ b/SpotierTests/LogSettingsStoreTests.swift @@ -0,0 +1,32 @@ +import XCTest +@testable import Spotier + +final class LogSettingsStoreTests: XCTestCase { + func testReadLevelDefaultsToInfo() { + let suiteName = "LogSettingsStoreTests.default.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName) + defaults?.removePersistentDomain(forName: suiteName) + let store = LogSettingsStore(defaults: defaults) + + XCTAssertEqual(store.readLevel(), .info) + } + + func testWriteLevelRoundTripsOffValue() { + let suiteName = "LogSettingsStoreTests.roundtrip.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName) + defaults?.removePersistentDomain(forName: suiteName) + let store = LogSettingsStore(defaults: defaults) + + store.writeLevel(.off) + + XCTAssertEqual(store.readLevel(), .off) + XCTAssertEqual(readStoredLogLevel(defaults: defaults), .off) + } + + func testStoredLogLevelAllowsExpectedRuntimeLevels() { + XCTAssertTrue(StoredLogLevel.warn.allows(.error)) + XCTAssertTrue(StoredLogLevel.warn.allows(.warn)) + XCTAssertFalse(StoredLogLevel.warn.allows(.info)) + XCTAssertFalse(StoredLogLevel.off.allows(.error)) + } +} diff --git a/SpotierTests/MainConnectionUseCaseTests.swift b/SpotierTests/MainConnectionUseCaseTests.swift new file mode 100644 index 0000000..28962bb --- /dev/null +++ b/SpotierTests/MainConnectionUseCaseTests.swift @@ -0,0 +1,119 @@ +import XCTest +@testable import Spotier + +final class MainConnectionUseCaseTests: XCTestCase { + func testCanToggleConnectionWhenVPNIsConnected() { + let vpnController = MockVPNController() + vpnController.isConnected = true + let useCase = MainConnectionUseCase( + configRepository: MockConfigRepository(), + vpnController: vpnController + ) + + XCTAssertTrue(useCase.canToggleConnection(selectedConfig: nil)) + } + + func testToggleConnectionStartsVPNWithSelectedConfigContent() { + let configURL = URL(fileURLWithPath: "/tmp/test.toml") + let repository = MockConfigRepository(readResults: [configURL: "instance_name = \"spotier\""]) + let vpnController = MockVPNController() + let useCase = MainConnectionUseCase( + configRepository: repository, + vpnController: vpnController + ) + + useCase.toggleConnection(selectedConfig: configURL) + + XCTAssertEqual(vpnController.startedContents, ["instance_name = \"spotier\""]) + XCTAssertFalse(vpnController.didStop) + } + + func testToggleConnectionStopsConnectedVPN() { + let vpnController = MockVPNController() + vpnController.isConnected = true + let useCase = MainConnectionUseCase( + configRepository: MockConfigRepository(), + vpnController: vpnController + ) + + useCase.toggleConnection(selectedConfig: nil) + + XCTAssertTrue(vpnController.didStop) + XCTAssertTrue(vpnController.startedContents.isEmpty) + } + + func testToggleConnectionReportsMissingSelection() { + let vpnController = MockVPNController() + let useCase = MainConnectionUseCase( + configRepository: MockConfigRepository(), + vpnController: vpnController + ) + + useCase.toggleConnection(selectedConfig: nil) + + XCTAssertEqual(vpnController.statusText, "未选择配置文件") + } + + func testToggleConnectionReportsReadFailureWithFilename() { + let configURL = URL(fileURLWithPath: "/tmp/test.toml") + let repository = MockConfigRepository(error: MockError.readFailed) + let vpnController = MockVPNController() + let useCase = MainConnectionUseCase( + configRepository: repository, + vpnController: vpnController + ) + + useCase.toggleConnection(selectedConfig: configURL) + + XCTAssertEqual(vpnController.statusText, "读取配置失败: test.toml") + XCTAssertTrue(vpnController.startedContents.isEmpty) + } +} + +private final class MockVPNController: VPNControlling { + var isConnected = false + var statusText = "" + var startedContents: [String] = [] + var didStop = false + + func startVPN(configContent: String) { + startedContents.append(configContent) + } + + func disableOnDemandAndStop() { + didStop = true + } +} + +private struct MockConfigRepository: ConfigFileAccessing { + var readResults: [URL: String] = [:] + var error: Error? + + func refreshConfigs() -> [URL] { + [] + } + + func readContent(at fileURL: URL) throws -> String { + if let error { + throw error + } + + if let content = readResults[fileURL] { + return content + } + + throw MockError.readFailed + } + + func createConfig(named filename: String, content: String) throws -> URL { + URL(fileURLWithPath: "/tmp/\(filename)") + } + + func updateContent(at fileURL: URL, content: String) throws {} + + func deleteConfig(at fileURL: URL) {} +} + +private enum MockError: Error { + case readFailed +} diff --git a/SpotierTests/SpotierConfigCodecTests.swift b/SpotierTests/SpotierConfigCodecTests.swift new file mode 100644 index 0000000..43208da --- /dev/null +++ b/SpotierTests/SpotierConfigCodecTests.swift @@ -0,0 +1,191 @@ +import XCTest +@testable import Spotier + +final class SpotierConfigCodecTests: XCTestCase { + func testParseBuildsModelFromStructuredToml() throws { + let content = """ + instance_name = "office-node" + instance_id = "fixed-id" + dhcp = false + ipv4 = "10.20.30.40/16" + listeners = ["tcp://0.0.0.0:12010", "udp://0.0.0.0:12010"] + mapped_listeners = ["tcp://198.51.100.1:12010"] + socks5_proxy = "socks5://0.0.0.0:2080" + exit_nodes = ["exit-a", "exit-b"] + routes = ["10.0.0.0/24", "10.0.1.0/24"] + + [network_identity] + network_name = "teamnet" + network_secret = "topsecret" + + [[peer]] + uri = "tcp://peer-1:11010" + + [[peer]] + uri = "udp://peer-2:11010" + + [flags] + mtu = 1440 + latency_first = true + disable_ipv6 = true + disable_encryption = true + use_smoltcp = true + no_tun = true + disable_p2p = true + p2p_only = true + disable_udp_hole_punching = true + enable_exit_node = true + bind_device = true + enable_kcp_proxy = true + disable_kcp_input = true + enable_quic_proxy = true + disable_quic_input = true + relay_all_peer_rpc = true + multi_thread = false + proxy_forward_by_system = true + disable_sym_hole_punching = true + enable_magic_dns = true + enable_private_mode = true + relay_network_whitelist = "corp office" + + [vpn_portal_config] + client_cidr = "10.14.14.0/24" + wireguard_listen = "0.0.0.0:22022" + + [[proxy_network]] + cidr = "192.168.0.0/24" + + [[port_forward]] + proto = "udp" + bind_addr = "0.0.0.0:8080" + dst_addr = "10.0.0.8:80" + """ + + let model = SpotierConfigCodec.parse(content) + + XCTAssertEqual(model.instanceName, "office-node") + XCTAssertEqual(model.instanceId, "fixed-id") + XCTAssertFalse(model.dhcp) + XCTAssertEqual(model.ipv4, "10.20.30.40") + XCTAssertEqual(model.cidr, "16") + XCTAssertEqual(model.networkName, "teamnet") + XCTAssertEqual(model.networkSecret, "topsecret") + XCTAssertEqual(model.listeners.values, ["tcp://0.0.0.0:12010", "udp://0.0.0.0:12010"]) + XCTAssertEqual(model.mappedListeners.values, ["tcp://198.51.100.1:12010"]) + XCTAssertTrue(model.enableSocks5) + XCTAssertEqual(model.socks5Port, 2080) + XCTAssertEqual(model.exitNodes.values, ["exit-a", "exit-b"]) + XCTAssertTrue(model.enableManualRoutes) + XCTAssertEqual(model.manualRoutes.values, ["10.0.0.0/24", "10.0.1.0/24"]) + XCTAssertEqual(model.peerMode, .manual) + XCTAssertEqual(model.manualPeers.values, ["tcp://peer-1:11010", "udp://peer-2:11010"]) + XCTAssertEqual(model.mtu, 1440) + XCTAssertTrue(model.latencyFirst) + XCTAssertFalse(model.enableIPv6) + XCTAssertFalse(model.enableEncryption) + XCTAssertTrue(model.useSmoltcp) + XCTAssertTrue(model.noTun) + XCTAssertTrue(model.disableP2P) + XCTAssertTrue(model.onlyP2P) + XCTAssertTrue(model.disableUdpHolePunching) + XCTAssertTrue(model.enableExitNode) + XCTAssertTrue(model.bindDevice) + XCTAssertTrue(model.enableKcpProxy) + XCTAssertTrue(model.disableKcpInput) + XCTAssertTrue(model.enableQuicProxy) + XCTAssertTrue(model.disableQuicInput) + XCTAssertTrue(model.relayAllPeerRpc) + XCTAssertFalse(model.multiThread) + XCTAssertTrue(model.proxyForwardBySystem) + XCTAssertTrue(model.disableSymHolePunching) + XCTAssertTrue(model.enableMagicDns) + XCTAssertTrue(model.enablePrivateMode) + XCTAssertTrue(model.enableRelayNetworkWhitelist) + XCTAssertEqual(model.relayNetworkWhitelist.values, ["corp", "office"]) + XCTAssertTrue(model.enableVpnPortal) + XCTAssertEqual(model.vpnPortalClientCidr, "10.14.14.0/24") + XCTAssertEqual(model.vpnPortalListenPort, 22022) + XCTAssertEqual(model.proxySubnets.map(\.cidr), ["192.168.0.0/24"]) + XCTAssertEqual(model.portForwards.count, 1) + XCTAssertEqual(model.portForwards[0].protocolType, "UDP") + XCTAssertEqual(model.portForwards[0].bindIp, "0.0.0.0") + XCTAssertEqual(model.portForwards[0].bindPort, "8080") + XCTAssertEqual(model.portForwards[0].targetIp, "10.0.0.8") + XCTAssertEqual(model.portForwards[0].targetPort, "80") + } + + func testGenerateWritesExpectedSectionsFromModel() { + var model = SpotierConfigModel() + model.instanceName = "generated-node" + model.instanceId = "generated-id" + model.dhcp = false + model.ipv4 = "10.99.0.5" + model.cidr = "24" + model.networkName = "prodnet" + model.networkSecret = "secret" + model.listeners = .init(values: ["tcp://0.0.0.0:11010", ""]) + model.mappedListeners = .init(values: ["tcp://203.0.113.8:11010"]) + model.enableSocks5 = true + model.socks5Port = 1088 + model.exitNodes = .init(values: ["exit-1"]) + model.enableManualRoutes = true + model.manualRoutes = .init(values: ["10.8.0.0/16"]) + model.peerMode = .manual + model.manualPeers = .init(values: ["tcp://peer-a:11010", ""]) + model.mtu = 1500 + model.latencyFirst = true + model.enableIPv6 = false + model.enableEncryption = false + model.useSmoltcp = true + model.noTun = true + model.disableP2P = true + model.onlyP2P = true + model.disableUdpHolePunching = true + model.enableExitNode = true + model.bindDevice = true + model.enableKcpProxy = true + model.disableKcpInput = true + model.enableQuicProxy = true + model.disableQuicInput = true + model.relayAllPeerRpc = true + model.multiThread = false + model.proxyForwardBySystem = true + model.disableSymHolePunching = true + model.enableMagicDns = true + model.enablePrivateMode = true + model.enableRelayNetworkWhitelist = true + model.relayNetworkWhitelist = .init(values: ["corp", "office"]) + model.enableVpnPortal = true + model.vpnPortalClientCidr = "10.14.14.0/24" + model.vpnPortalListenPort = 22022 + model.proxySubnets = [.init(cidr: "192.168.1.0/24")] + model.portForwards = [ + PortForwardRule(protocolType: "TCP", bindIp: "0.0.0.0", bindPort: "8080", targetIp: "10.0.0.8", targetPort: "80") + ] + + let generated = SpotierConfigCodec.generate(from: model, peers: model.manualPeers.values) + + XCTAssertTrue(generated.contains("instance_name = \"generated-node\"")) + XCTAssertTrue(generated.contains("instance_id = \"generated-id\"")) + XCTAssertTrue(generated.contains("dhcp = false")) + XCTAssertTrue(generated.contains("listeners = [\"tcp://0.0.0.0:11010\"]")) + XCTAssertTrue(generated.contains("mapped_listeners = [\"tcp://203.0.113.8:11010\"]")) + XCTAssertTrue(generated.contains("ipv4 = \"10.99.0.5/24\"")) + XCTAssertTrue(generated.contains("socks5_proxy = \"socks5://0.0.0.0:1088\"")) + XCTAssertTrue(generated.contains("exit_nodes = [\"exit-1\"]")) + XCTAssertTrue(generated.contains("routes = [\"10.8.0.0/16\"]")) + XCTAssertTrue(generated.contains("[network_identity]")) + XCTAssertTrue(generated.contains("network_name = \"prodnet\"")) + XCTAssertTrue(generated.contains("network_secret = \"secret\"")) + XCTAssertTrue(generated.contains("[[peer]]\nuri = \"tcp://peer-a:11010\"")) + XCTAssertTrue(generated.contains("[flags]")) + XCTAssertTrue(generated.contains("mtu = 1500")) + XCTAssertTrue(generated.contains("multi_thread = false")) + XCTAssertTrue(generated.contains("relay_network_whitelist = \"corp office\"")) + XCTAssertTrue(generated.contains("[vpn_portal_config]")) + XCTAssertTrue(generated.contains("wireguard_listen = \"0.0.0.0:22022\"")) + XCTAssertTrue(generated.contains("[[proxy_network]]\ncidr = \"192.168.1.0/24\"")) + XCTAssertTrue(generated.contains("[[port_forward]]\nproto = \"tcp\"")) + XCTAssertFalse(generated.contains("\"\"")) + } +} diff --git a/plans/refactor_checklist_plan.md b/plans/refactor_checklist_plan.md new file mode 100644 index 0000000..f106db7 --- /dev/null +++ b/plans/refactor_checklist_plan.md @@ -0,0 +1,334 @@ +# Spotier 重构实施 Checklist Plan + +更新日期:2026-03-21 + +## 执行状态(2026-03-22) + +本计划对应的结构性改造已完成,代码侧已落地到仓库并通过自动化回归。 + +已完成: + +1. 共享运行时常量、App Group、日志等级和日志容器路径已统一到单一入口。 +2. 配置目录访问已拆成目录行为、目录访问、文件 repository、CloudKit sync coordinator,多处页面已移除直接文件写删。 +3. `SpotierRunner` 已具备显式 session 生命周期,并把 session 轮换规则抽成可测行为模块。 +4. `ContentView`、配置生成器、日志页、列表页和节点卡片已按“页面壳 + 可测行为层/原语层”拆分。 +5. 日志事件格式化、配置编解码、配置目录规则、动态表单行为、连接用例、session 行为等都已补 app 侧测试。 + +当前仅剩人工 smoke checklist 未逐项手动执行;自动化验证已完成。 + +## 1. 目标与边界 + +本计划用于把当前审查中暴露出的结构问题,落成一份可执行的重构实施清单。目标不是局部修补,而是按项目 `AGENTS.md` 的约束,把页面职责、行为逻辑、布局规则、文件访问和跨进程共享配置拆回各自的单一职责层。 + +本轮重构的核心目标: + +1. 建立单一事实来源,消除 App Group、会话状态、配置目录权限、日志等级读取的多份定义。 +2. 让页面文件只保留组合、状态绑定和事件接线,不再直接负责文件 I/O、复杂状态派生和细节布局规则。 +3. 把可复用行为提炼为纯逻辑模块,把配置文件访问、日志访问、配置生成/解析拆成可测试服务。 +4. 修复当前已确认的根因问题: + - App Group 标识不一致 + - 自定义配置目录的安全域读写/删除不完整 + - 配置生成器的动态数组索引绑定风险 + - `sessionID` 从未轮换导致的会话重建失效 +5. 为关键映射补齐自动化测试,至少覆盖配置目录访问、配置编解码、日志配置共享、会话标识轮换。 + +本轮不包含: + +1. `EasyTierCore/easytier-patched/` vendored 上游代码的深入改造。 +2. 视觉风格重设计。 +3. 与 NetworkExtension 架构无关的产品功能扩展。 + +## 2. 目标架构 + +建议把现有实现收敛为 6 个顶层关注点: + +1. 运行时共享配置层 + - 负责 App Bundle / App Group / iCloud / 日志文件名等常量与共享默认值访问 +2. 配置文件访问层 + - 负责安全域书签、目录访问、创建/读取/更新/删除配置、CloudKit 同步触发 +3. 主界面状态层 + - 负责 VPN 连接状态、运行数据快照、会话标识、主界面 overlay route +4. 配置生成器领域层 + - 负责草稿、TOML 编解码、动态数组字段模型、保存/加载工作流 +5. 日志与事件层 + - 负责日志文件路径解析、日志等级共享配置、纯解析逻辑、UI 数据源 +6. SwiftUI 页面与原子组件层 + - 负责页面组合壳、统一 Header、统一浮动按钮、可复制明细行、动态列表字段行 + +## 3. 实施阶段 Checklist + +### Phase 0: 基线与保护措施 + +- [ ] 新建分支并记录当前 `xcodebuild test -project Spotier.xcodeproj -scheme Spotier -destination 'platform=macOS'` 基线结果 +- [ ] 补一份手工 smoke checklist,至少覆盖: + - 自定义目录下创建配置 + - 自定义目录下删除配置 + - 配置生成器新增/删除监听地址、路由、出口节点 + - 修改日志等级并重启隧道 + - 重连 VPN 后节点列表动画/详情状态是否正确重置 +- [ ] 在计划执行期间禁止新增业务功能,避免把结构改造和行为变更耦合在同一批提交 + +### Phase 1: 根因修复与单一事实来源收敛 + +#### 1.1 统一 App Group / 共享配置入口 + +- [ ] 把 `APP_BUNDLE_ID` / `APP_GROUP_ID` / `ICLOUD_CONTAINER_ID` / `LOG_FILENAME` 收敛到单一共享定义 +- [ ] 清除所有硬编码 `group.com.alick.swiftier` +- [ ] 抽一个共享 `UserDefaults(suiteName: APP_GROUP_ID)` 访问入口,避免各文件重复拼接 +- [ ] 确认主 App、NE、日志解析器、日志视图、设置页都读同一份日志等级 + +#### 1.2 收敛配置目录访问 + +- [ ] 把“列目录 / 读文件 / 创建文件 / 写文件 / 删除文件”全部收进同一安全域访问入口 +- [ ] 禁止页面层直接 `write(to:)` 或 `removeItem(at:)` +- [ ] 保留 CloudKit 触发逻辑,但将其挂到 repository/service 层,而不是页面层 + +#### 1.3 修正会话标识模型 + +- [ ] 在 `SpotierRunner` 中显式定义会话开始/结束 +- [ ] 每次 VPN 新连接成功时轮换 `sessionID` +- [ ] 断开时清空与会话绑定的瞬时状态 +- [ ] `ContentView` 不再维护 runner 派生的本地镜像状态;主界面只绑定一个权威状态源 + +### Phase 2: 主界面与运行状态解耦 + +#### 2.1 拆薄 `ContentView` + +- [ ] 把 overlay route 从多个 `Bool/URL?` 收敛为单一枚举状态 +- [ ] 把布局常量和几何映射从页面文件提取到独立 layout rules +- [ ] 把“创建配置”“删除配置”“按钮中心定位”“选中配置同步”移出页面主体 +- [ ] 保留 `ContentView` 只负责: + - 页面结构 + - 绑定 state + - 分发事件 + +#### 2.2 约束 `SpotierRunner` / `VPNManager` 边界 + +- [ ] `SpotierRunner` 只负责运行信息轮询、派生快照和会话状态 +- [ ] `VPNManager` 只负责 NE 生命周期、provider message、On Demand 管理 +- [ ] 把 UI 文本、弹窗、副作用从 runner 中移出,改为错误事件/状态输出 + +### Phase 3: 配置生成器领域拆分 + +#### 3.1 把配置模型与视图拆开 + +- [ ] 把 `SpotierConfigModel`、`PortForwardRule`、`PeerMode` 拆到独立 domain 文件 +- [ ] 把 `ConfigDraftManager` 拆成草稿存储模块 +- [ ] 把 `parseTOML` / `generateTOML` 抽成 codec/service +- [ ] 把 `loadContent` / `loadFromFile` / `generateAndSave` 抽成 generator store/use case + +#### 3.2 替换索引驱动的动态列表 + +- [ ] 用 `Identifiable` 行模型替换所有 `ForEach(indices)` 驱动的可变表单 +- [ ] 把字符串数组包装成稳定行对象,避免删除时 stale binding +- [ ] 对 `listeners` / `manualRoutes` / `exitNodes` / `manualPeers` / `overrideDns` / 通用字符串列表统一使用一套行为原语 + +#### 3.3 拆分配置生成器页面 + +- [ ] `mainView` 拆成基础网络、节点、DNS、监听地址、功能开关等 section 子视图 +- [ ] `advancedView` 拆成 VPN Portal、代理网段、路由、出口节点、映射等 section 子视图 +- [ ] `portForwardingView` 拆成独立 scene shell +- [ ] `header(...)` 改为复用统一容器原语,不再重复实现页面头部 + +### Phase 4: 日志/设置/列表 UI 原语统一 + +#### 4.1 统一日志访问与解析 + +- [ ] 日志文件路径解析从 `LogView` / `LogParser` 移出 +- [ ] `LogParser` 只保留解析与缓冲逻辑,文件路径和日志等级由独立依赖注入 +- [ ] 让设置页修改日志等级后,NE 与主 App 都消费同一来源 + +#### 4.2 统一页面 Header、浮动按钮、可复制行 + +- [ ] 扩展 `UnifiedHeader` 或建立新的 scene shell primitive +- [ ] `EventListView` 和 `LogListView` 的右下角滚动按钮收敛为共享 primitive +- [ ] `PeerCard.DetailRow` 的复制行为改成可访问的可复用行组件 +- [ ] 减少 `onTapGesture` 驱动的交互,优先用 `Button` + +### Phase 5: 测试与验收补齐 + +- [ ] 新增 app 侧测试 target(建议 `SpotierTests`),不要把 UI/domain 测试继续堆到 `SpotierNETests` +- [ ] 为配置目录访问补测试 +- [ ] 为 TOML 编解码补测试 +- [ ] 为会话标识轮换补测试 +- [ ] 为 App Group / 日志等级共享补测试 +- [ ] 为动态数组字段新增/删除顺序补测试 + +## 4. 受影响文件与方法清单 + +### 4.1 现有文件影响矩阵 + +| 文件 | 受影响方法 / 属性 | 所需修改 | +| --- | --- | --- | +| `Spotier/ContentView.swift` | `body`, `headerView`, `contentArea`, `buttonCenterY(in:)`, `createConfig()`, `deleteSelectedConfig()`, `SpeedDashboard.body`, `PeerListArea.body` | 拆成页面壳;移除文件 I/O;把 overlay 状态改为单一 route;把 layout 规则提取到独立模块;改为绑定权威状态对象,不再镜像 `runner` 状态 | +| `Spotier/SpotierRunner.swift` | `sessionID`, `currentSessionID`, `handleVPNStatusChange(_:)`, `toggleService(configPath:)`, `startMonitoring()`, `resetSpeedCounters()`, `processRunningInfo(_:)` | 引入显式 session 生命周期;在连接建立时轮换 session;移除 UI 级副作用和弹窗;输出稳定运行时快照供视图消费 | +| `Spotier/VPNManager.swift` | `saveConfigToAppGroup(configContent:)`, `startVPN(configContent:)`, `disableOnDemandAndStop()`, `updateStatusSync()`, `processPendingStartIfNeeded()` | 保持 NE 生命周期职责;如有需要抽出 provider message / tunnel state adapter;避免与 `SpotierRunner` 重复承担页面语义 | +| `Spotier/ConfigManager.swift` | `refreshConfigs(skipCloudSync:)`, `readConfigContent(_:)`, `deleteConfig(_:)`, `selectCustomFolder()`, `triggerCloudSyncIfNeeded(force:)`, `directoryFromUserPreference()` | 演进为配置文件访问服务或被新 repository 包装;补齐 create/update/delete 的安全域访问;保留 CloudKit 同步但下沉到服务边界 | +| `Spotier/ConfigEditorView.swift` | `loadContent()`, `saveContent()`, `body` | 改为调用统一配置 repository;移除本地安全域访问细节;页面只保留编辑器壳层 | +| `Spotier/ConfigGeneratorView.swift` | `body`, `loadContent(forceReset:)`, `advancedView`, `mainView`, `portForwardingView`, `stringListSection(list:placeholder:)`, `safePeerBinding(at:)`, `header(...)`, `generateAndSave()`, `loadFromFile()`, `parseTOML(_:)`, `generateTOML(peers:)`, `IPv4CidrField`, `IPv4Field` | 拆出 domain model、draft store、codec、section 子视图、动态列表原语;去掉索引驱动可变绑定;保留视图层只做组合与绑定 | +| `Spotier/SettingsView.swift` | `@AppStorage("logLevel"...`, `body`, `checkLaunchAtLogin()`, `toggleLaunchAtLogin(enabled:)` | 切到统一 App Group 配置入口;如引入 `LogSettingsStore`,则改为绑定 store;设置页只负责展示与交互 | +| `Spotier/LogView.swift` | `logPath`, `body` | 移除硬编码 App Group 路径;接入日志容器解析服务;复用统一 Header primitive | +| `Spotier/LogParser.swift` | `logPath`, `quickParseLogs(_:)`, `readNewRawLines()`, `startMonitoring()`, `updateEventsFromRunningInfo(_:)` | 把文件路径解析和日志等级来源注入;保留纯解析逻辑;为 parser 增加可测试边界 | +| `Spotier/SharedComponents.swift` | `UnifiedHeader` | 扩展为真正的页面壳 primitive,统一 header/标题/左右操作区,不再让各页面重复实现相似结构 | +| `Spotier/PeerCard.swift` | `body`, `ScrollingText.body`, `DetailRow.body`, `ScrollingTextValue.body`, `localNodeSections(_:)`, `remotePeerSections(_:)` | 提取滚动文本、Tag、可复制行原语;把交互从 `onTapGesture` 改为更可访问的按钮型组件;把宽度规则集中定义 | +| `Spotier/EventListView.swift` | `body`, `FlatCircleButtonModifier.body(content:)` | 提取统一的滚动到顶部浮动按钮 primitive;收敛空态/列表态样式规则 | +| `Spotier/LogListView.swift` | `body`, `LogListRow.body`, `LogDetailView.body` | 收敛浮动按钮与行选中行为;避免页面内部重复交互原语 | +| `Spotier/SpotierControlApp.swift` | `SpotierControlApp.body`, `MenuBarIconState.handleRunningStateChange(isRunning:)`, `updateTimerState()` | 若引入统一运行状态 store,需要让菜单栏图标消费同一权威状态;删除无效或冗余状态持有 | +| `Spotier/CliClient.swift` | `PeerInfo.sessionID`, `PeerInfo.id`, `fetchPeers(sessionID:)`, `parseCLITable(_:sessionID:)`, `parseCLIJSON(_:sessionID:)` | 若保留 CLI 路径,需与新的 session 轮换语义一致;注释和 ID 生成规则要与 `SpotierRunner` 对齐 | +| `Spotier/EasyTierShared.swift` | `APP_BUNDLE_ID`, `APP_GROUP_ID`, `ICLOUD_CONTAINER_ID`, `LOG_FILENAME`, `connectWithManager(_:)` | 作为主 App 侧共享常量来源;补一个统一 defaults/container helper,禁止各处硬编码 | +| `SpotierNE/EasyTierShared.swift` | `APP_BUNDLE_ID`, `APP_GROUP_ID`, `ICLOUD_CONTAINER_ID`, `LOG_FILENAME`, `connectWithManager(_:)` | 与主 App 侧保持完全一致;必要时合并为真正共享文件,避免双份常量漂移 | +| `SpotierNE/PacketTunnelProvider.swift` | `loadConfig()`, `parseConfigHints(_:)`, `applyNetworkSettings(_:)`, `buildSettings()`, 读取 `UserDefaults(suiteName: APP_GROUP_ID)` 的相关逻辑 | 使用统一共享配置入口;确保日志等级与共享常量统一;避免主 App 与扩展配置源分叉 | +| `SpotierNE/TunnelHelper.swift` | `initRustLogger(level:)`, `fetchRunningInfo()` | 使用统一容器/日志配置常量;必要时让日志初始化走共享 helper | +| `Spotier.xcodeproj/project.pbxproj` | target/file references/build phases | 挂接新增文件;若新增 `SpotierTests` target,也需要更新 project 配置 | + +### 4.2 建议新增文件 + +以下文件名为建议命名,可按现有目录风格微调,但职责边界应保持不变。 + +| 建议新增文件 | 目的 | +| --- | --- | +| `Spotier/AppRuntimeConfig.swift` | App Group、Bundle ID、日志文件名、iCloud 容器等统一定义;提供默认值/容器访问 helper | +| `Spotier/Config/ConfigDirectoryAccess.swift` | 封装安全域书签、目录解析、`withScopedAccess` | +| `Spotier/Config/ConfigFileRepository.swift` | 统一 create/read/update/delete/list;对上层暴露 typed API | +| `Spotier/Main/MainDashboardState.swift` | 主界面权威状态,收敛 `runner`/`vpnManager` 派生值与 overlay route | +| `Spotier/Main/MainOverlayRoute.swift` | 用枚举表达日志/设置/生成器/编辑器/新建弹窗等路由 | +| `Spotier/Main/DashboardLayoutMetrics.swift` | `windowWidth`、`windowHeight`、按钮中心 Y、节点区高度等布局规则单一来源 | +| `Spotier/Main/MainDashboardHeader.swift` | 主界面顶部菜单与操作区组合 | +| `Spotier/Main/CreateConfigPrompt.swift` | 新建配置弹窗,脱离 `ContentView` | +| `Spotier/ConfigGenerator/SpotierConfigDraft.swift` | `SpotierConfigModel`、`PortForwardRule`、`PeerMode` | +| `Spotier/ConfigGenerator/SpotierConfigCodec.swift` | TOML 解析与生成 | +| `Spotier/ConfigGenerator/ConfigDraftStore.swift` | 草稿读写与生命周期 | +| `Spotier/ConfigGenerator/StringListFieldRow.swift` | 动态字符串字段行原语,替代索引驱动 `ForEach(indices)` | +| `Spotier/ConfigGenerator/ConfigGeneratorStore.swift` | 处理 load/save/generate 工作流 | +| `Spotier/Logging/LogContainerResolver.swift` | 统一日志文件路径和 App Group 解析 | +| `Spotier/Logging/LogSettingsStore.swift` | 日志等级的统一设置入口 | +| `Spotier/UI/FloatingScrollTopButton.swift` | 统一右下角滚动到顶部按钮 | +| `Spotier/UI/CopyableDetailRow.swift` | 可访问、可复制的明细行组件 | +| `SpotierTests/AppRuntimeConfigTests.swift` | 校验 App Group / defaults / log path 统一配置 | +| `SpotierTests/ConfigFileRepositoryTests.swift` | 校验安全域路径下的 list/create/update/delete 流程 | +| `SpotierTests/SpotierConfigCodecTests.swift` | 校验 TOML parse/generate | +| `SpotierTests/SpotierRunnerSessionTests.swift` | 校验 session 轮换与 reconnect 行为 | +| `SpotierTests/ConfigGeneratorCollectionTests.swift` | 校验动态列表新增删除后绑定稳定性 | + +## 5. 文件级实施清单 + +### A. 共享常量与 App Group 收敛 + +- [ ] 修改 `Spotier/EasyTierShared.swift` +- [ ] 修改 `SpotierNE/EasyTierShared.swift` +- [ ] 修改 `Spotier/SettingsView.swift` +- [ ] 修改 `Spotier/LogView.swift` +- [ ] 修改 `Spotier/LogParser.swift` +- [ ] 修改 `SpotierNE/PacketTunnelProvider.swift` +- [ ] 修改 `SpotierNE/TunnelHelper.swift` +- [ ] 全仓搜索并清零硬编码 `group.com.alick.swiftier` + +### B. 配置目录访问重构 + +- [ ] 新增 `ConfigDirectoryAccess.swift` +- [ ] 新增 `ConfigFileRepository.swift` +- [ ] 修改 `Spotier/ConfigManager.swift`,收缩为 facade 或迁移职责 +- [ ] 修改 `Spotier/ContentView.swift` 的 `createConfig()` / `deleteSelectedConfig()` +- [ ] 修改 `Spotier/ConfigEditorView.swift` 的 `saveContent()` +- [ ] 修改 `Spotier/ConfigGeneratorView.swift` 的 `generateAndSave()` / `loadFromFile()` + +### C. 主界面状态与布局规则重构 + +- [ ] 新增 `MainDashboardState.swift` +- [ ] 新增 `MainOverlayRoute.swift` +- [ ] 新增 `DashboardLayoutMetrics.swift` +- [ ] 修改 `Spotier/ContentView.swift` +- [ ] 修改 `Spotier/SpotierRunner.swift` +- [ ] 评估并修改 `Spotier/VPNManager.swift` +- [ ] 视需要修改 `Spotier/SpotierControlApp.swift` +- [ ] 视需要修改 `Spotier/CliClient.swift` + +### D. 配置生成器重构 + +- [ ] 新增 `SpotierConfigDraft.swift` +- [ ] 新增 `SpotierConfigCodec.swift` +- [ ] 新增 `ConfigDraftStore.swift` +- [ ] 新增 `ConfigGeneratorStore.swift` +- [ ] 新增动态列表字段原语文件 +- [ ] 大幅拆分 `Spotier/ConfigGeneratorView.swift` +- [ ] 校验 `IPv4Field` / `IPv4CidrField` 是否继续保留在原文件,还是提到公共字段文件 + +### E. UI 原语统一 + +- [ ] 扩展或重构 `Spotier/SharedComponents.swift` +- [ ] 新增 `FloatingScrollTopButton.swift` +- [ ] 新增 `CopyableDetailRow.swift` +- [ ] 修改 `Spotier/EventListView.swift` +- [ ] 修改 `Spotier/LogListView.swift` +- [ ] 修改 `Spotier/PeerCard.swift` +- [ ] 修改 `Spotier/LogView.swift` + +### F. 测试与工程接线 + +- [ ] 修改 `Spotier.xcodeproj/project.pbxproj` +- [ ] 新增 `SpotierTests` target 或同等 app 侧测试承载 +- [ ] 添加 4~5 个领域测试文件 +- [ ] 保留并继续运行 `SpotierNETests` + +## 6. 实施顺序建议 + +必须按以下顺序推进,避免一边拆 UI 一边改底层契约: + +1. 统一共享常量与 App Group +2. 收敛配置目录访问与安全域写删能力 +3. 修复 session 生命周期与主界面状态源 +4. 拆 `ContentView` +5. 拆 `ConfigGeneratorView` +6. 统一日志/设置/列表原语 +7. 补测试并做回归 + +原因: + +1. App Group 和配置目录访问属于底层契约,必须先固定,否则上层抽象仍会建立在错误前提上。 +2. `sessionID` 和主界面状态源会直接决定后续页面壳如何设计。 +3. `ConfigGeneratorView` 依赖文件访问和保存流程,必须在 repository 定型后再拆。 + +## 7. 验收标准 + +### 功能验收 + +- [ ] 设置页修改日志等级后,NE 和主 App 读取到同一份值 +- [ ] 日志视图能够打开正确的共享日志文件 +- [ ] 自定义目录下创建配置成功 +- [ ] 自定义目录下删除配置成功 +- [ ] 配置编辑器保存成功 +- [ ] 配置生成器新增/删除任意动态行时不崩溃、不串值 +- [ ] VPN 重连后,节点卡片按新 session 正确重建,旧详情/动画状态不泄漏 + +### 结构验收 + +- [ ] `ContentView.swift` 不再直接负责文件读写 +- [ ] `ConfigGeneratorView.swift` 不再同时承担 model/store/codec/UI +- [ ] App Group / 日志文件名 / iCloud 容器只存在一个权威定义源 +- [ ] 关键 layout 规则集中到单独模块 +- [ ] 关键行为有对应测试 + +## 8. 风险与回滚 + +主要风险: + +1. 配置生成器拆分过程中,字段映射可能出现回归。 +2. 安全域访问集中化后,如果接口设计不完整,可能影响 CloudKit 模式与自定义目录模式切换。 +3. 主界面状态整合后,菜单栏图标、节点列表、速度卡片刷新节奏可能出现联动问题。 + +回滚策略: + +1. 每个 Phase 独立提交,禁止把多个 Phase 混到一个 commit。 +2. Phase 1 完成后先做一次可运行回归,再进入 Phase 2。 +3. `ConfigGeneratorView` 拆分采用“小步迁移”:先保留原渲染结构,只替换 store/codec;再拆 section;最后替换动态列表原语。 + +## 9. 建议的提交切分 + +1. `refactor: unify runtime config and app group access` +2. `refactor: centralize scoped config file operations` +3. `fix: rotate runner session identity on reconnect` +4. `refactor: thin content view into dashboard shell` +5. `refactor: extract config generator domain and codec` +6. `refactor: replace index-based dynamic form bindings` +7. `refactor: unify log and list ui primitives` +8. `test: add app-side refactor coverage` diff --git a/plans/review_fix_checklist.md b/plans/review_fix_checklist.md new file mode 100644 index 0000000..e743678 --- /dev/null +++ b/plans/review_fix_checklist.md @@ -0,0 +1,12 @@ +# Review Fix Checklist + +- [x] Fix CloudKit sync conflict resolution so different contents never stall when timestamps are too close. +- [x] Remove hidden CloudKit local-only fallback when the schema is missing; surface a real failure and turn off the misleading enabled state. +- [x] Fail tunnel startup when no TUN file descriptor can be obtained instead of reporting a false success. +- [x] Persist config selection and use it for launch auto-connect instead of always picking the first config file. +- [x] Add or update tests for the changed behavior. +- [x] Remove dead CLI peer-fetching code and other unused runner methods. +- [x] Remove hardcoded log path fallback and string-path log API. +- [x] Remove redundant VPN profile second-save logic. +- [x] Simplify VPN/config state management further where duplicated branches remain. +- [x] Re-run repository tests after the broader cleanup pass. From 23d86213b53de7c16a072c54d060ba9eb8f9fa4c Mon Sep 17 00:00:00 2001 From: Alick Huang Date: Thu, 26 Mar 2026 11:20:17 +0800 Subject: [PATCH 30/30] Align build numbers for release archive --- Spotier.xcodeproj/project.pbxproj | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Spotier.xcodeproj/project.pbxproj b/Spotier.xcodeproj/project.pbxproj index ca3446c..0dda0d2 100644 --- a/Spotier.xcodeproj/project.pbxproj +++ b/Spotier.xcodeproj/project.pbxproj @@ -696,7 +696,7 @@ CODE_SIGN_ENTITLEMENTS = SpotierNE/SpotierNE.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = KLU8GF65GP; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -756,7 +756,7 @@ CODE_SIGN_ENTITLEMENTS = SpotierNE/SpotierNE.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = KLU8GF65GP; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -814,7 +814,7 @@ buildSettings = { BUNDLE_LOADER = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = KLU8GF65GP; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; @@ -831,7 +831,7 @@ buildSettings = { BUNDLE_LOADER = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = KLU8GF65GP; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; @@ -848,7 +848,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = KLU8GF65GP; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; @@ -866,7 +866,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = KLU8GF65GP; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.2;