From 0636b47d36e54087265f08a5073fed38800c6566 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 6 Mar 2026 09:19:29 +0800 Subject: [PATCH 01/37] Fix log pause not freezing display Snapshot log list when paused so new logs don't displace old entries. Search and level filter still work against the snapshot. --- .../Views/Log/LogViewModel.swift | 45 ++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/ApplicationLibrary/Views/Log/LogViewModel.swift b/ApplicationLibrary/Views/Log/LogViewModel.swift index 0d45c17..83ab307 100644 --- a/ApplicationLibrary/Views/Log/LogViewModel.swift +++ b/ApplicationLibrary/Views/Log/LogViewModel.swift @@ -18,6 +18,8 @@ public class LogDataModel: ObservableObject { private let commandClient: CommandClient private weak var viewModel: LogViewModel? + private var pausedLogSnapshot: [LogEntry]? + private var lastPaused = false private var lastProcessedLogCount = 0 private var lastEffectiveLevel: Int? private var lastSearchText = "" @@ -48,38 +50,59 @@ public class LogDataModel: ObservableObject { let debouncedSearchText = viewModel.$searchText .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) - Publishers.CombineLatest4( - commandClient.$logList, - commandClient.$defaultLogLevel, - viewModel.$selectedLogLevel, - debouncedSearchText + Publishers.CombineLatest( + Publishers.CombineLatest4( + commandClient.$logList, + commandClient.$defaultLogLevel, + viewModel.$selectedLogLevel, + debouncedSearchText + ), + viewModel.$isPaused ) .receive(on: DispatchQueue.main) - .sink { [weak self] logList, defaultLogLevel, selectedLogLevel, searchText in + .sink { [weak self] combined, isPaused in guard let self else { return } + let (logList, defaultLogLevel, selectedLogLevel, searchText) = combined let effectiveLevel = selectedLogLevel ?? defaultLogLevel + if isPaused, !self.lastPaused { + self.pausedLogSnapshot = logList + self.lastProcessedLogCount = 0 + } else if !isPaused, self.lastPaused { + self.pausedLogSnapshot = nil + self.lastProcessedLogCount = 0 + } + self.lastPaused = isPaused + + let sourceList = self.pausedLogSnapshot ?? logList + + if isPaused, effectiveLevel == self.lastEffectiveLevel, searchText == self.lastSearchText, + self.lastProcessedLogCount > 0 + { + return + } + let canIncrement = self.lastProcessedLogCount > 0 && - logList.count > self.lastProcessedLogCount && + sourceList.count > self.lastProcessedLogCount && effectiveLevel == self.lastEffectiveLevel && searchText == self.lastSearchText if canIncrement { - let newLogs = logList[self.lastProcessedLogCount...] + let newLogs = sourceList[self.lastProcessedLogCount...] let newFilteredLogs = newLogs.filter { log in log.level <= effectiveLevel && (searchText.isEmpty || log.message.contains(searchText)) } self.filteredLogs.append(contentsOf: newFilteredLogs) } else { - self.filteredLogs = logList.filter { log in + self.filteredLogs = sourceList.filter { log in log.level <= effectiveLevel && (searchText.isEmpty || log.message.contains(searchText)) } } self.updateVisibleLogs() - self.lastProcessedLogCount = logList.count + self.lastProcessedLogCount = sourceList.count self.lastEffectiveLevel = effectiveLevel self.lastSearchText = searchText } @@ -88,6 +111,8 @@ public class LogDataModel: ObservableObject { public func clearLogs() { viewModel?.isPaused = false + pausedLogSnapshot = nil + lastPaused = false lastProcessedLogCount = 0 lastEffectiveLevel = nil lastSearchText = "" From c7e1f667c30b4e8a3fc4b2b465f44ad74befe151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 6 Mar 2026 10:02:30 +0800 Subject: [PATCH 02/37] Persist log and connection search state across tabs --- .../Views/Connections/ConnectionListView.swift | 5 +++++ ApplicationLibrary/Views/Log/LogView.swift | 10 +++++++--- ApplicationLibrary/Views/Log/LogViewModel.swift | 4 +++- Library/Network/ExtensionEnvironments.swift | 3 +++ 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/ApplicationLibrary/Views/Connections/ConnectionListView.swift b/ApplicationLibrary/Views/Connections/ConnectionListView.swift index 2f5ef8b..84510f3 100644 --- a/ApplicationLibrary/Views/Connections/ConnectionListView.swift +++ b/ApplicationLibrary/Views/Connections/ConnectionListView.swift @@ -60,10 +60,15 @@ public struct ConnectionListView: View { #endif .alert($viewModel.alert) .onAppear { + if !environments.connectionSearchText.isEmpty { + viewModel.searchText = environments.connectionSearchText + viewModel.isSearching = true + } viewModel.connect() commandClient.connect() } .onDisappear { + environments.connectionSearchText = viewModel.searchText viewModel.disconnect() commandClient.disconnect() } diff --git a/ApplicationLibrary/Views/Log/LogView.swift b/ApplicationLibrary/Views/Log/LogView.swift index f9e29d2..de2b118 100644 --- a/ApplicationLibrary/Views/Log/LogView.swift +++ b/ApplicationLibrary/Views/Log/LogView.swift @@ -15,15 +15,16 @@ public struct LogView: View { public init() {} public var body: some View { - LogViewContent(commandClient: environments.commandClient) + LogViewContent(commandClient: environments.commandClient, initialSearchText: environments.logSearchText) } } private struct LogViewContent: View { + @EnvironmentObject private var environments: ExtensionEnvironments @StateObject private var viewModel: LogViewModel - init(commandClient: CommandClient) { - _viewModel = StateObject(wrappedValue: LogViewModel(commandClient: commandClient)) + init(commandClient: CommandClient, initialSearchText: String = "") { + _viewModel = StateObject(wrappedValue: LogViewModel(commandClient: commandClient, searchText: initialSearchText)) } var body: some View { @@ -35,6 +36,9 @@ private struct LogViewContent: View { toolbarButtons } } + .onDisappear { + environments.logSearchText = viewModel.searchText + } .alert($viewModel.alert) .background( LogExportView( diff --git a/ApplicationLibrary/Views/Log/LogViewModel.swift b/ApplicationLibrary/Views/Log/LogViewModel.swift index 83ab307..4bf34ca 100644 --- a/ApplicationLibrary/Views/Log/LogViewModel.swift +++ b/ApplicationLibrary/Views/Log/LogViewModel.swift @@ -176,8 +176,10 @@ public class LogViewModel: BaseViewModel { public let commandClient: CommandClient public private(set) var dataModel: LogDataModel! - public init(commandClient: CommandClient) { + public init(commandClient: CommandClient, searchText: String = "") { self.commandClient = commandClient + self.searchText = searchText + self.isSearching = !searchText.isEmpty super.init() dataModel = LogDataModel(commandClient: commandClient, viewModel: self) } diff --git a/Library/Network/ExtensionEnvironments.swift b/Library/Network/ExtensionEnvironments.swift index 7d7def7..d099d2e 100644 --- a/Library/Network/ExtensionEnvironments.swift +++ b/Library/Network/ExtensionEnvironments.swift @@ -180,6 +180,9 @@ public class ExtensionEnvironments: ObservableObject { @Published public var emptyProfiles = false @Published public var pendingImportRemoteProfile: ImportRemoteProfileRequest? + public var logSearchText = "" + public var connectionSearchText = "" + public let profileUpdate = ObjectWillChangePublisher() public let selectedProfileUpdate = ObjectWillChangePublisher() public let openSettings = ObjectWillChangePublisher() From c19945f65be76ae5d16fc684a166079877802641 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 7 Mar 2026 15:55:24 +0800 Subject: [PATCH 03/37] Bump version 1.13.2 --- sing-box.xcodeproj/project.pbxproj | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/sing-box.xcodeproj/project.pbxproj b/sing-box.xcodeproj/project.pbxproj index d84ec21..d5493d1 100644 --- a/sing-box.xcodeproj/project.pbxproj +++ b/sing-box.xcodeproj/project.pbxproj @@ -2235,7 +2235,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.1"; + MARKETING_VERSION = "1.13.2"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; SDKROOT = appletvos; @@ -2269,7 +2269,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.1"; + MARKETING_VERSION = "1.13.2"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; SDKROOT = appletvos; @@ -2662,7 +2662,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.1"; + MARKETING_VERSION = "1.13.2"; OTHER_CODE_SIGN_FLAGS = "--deep"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2704,7 +2704,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.1"; + MARKETING_VERSION = "1.13.2"; OTHER_CODE_SIGN_FLAGS = "--deep"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2744,7 +2744,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.1"; + MARKETING_VERSION = "1.13.2"; OTHER_CODE_SIGN_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2783,7 +2783,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.1"; + MARKETING_VERSION = "1.13.2"; OTHER_CODE_SIGN_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2925,7 +2925,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.1"; + MARKETING_VERSION = "1.13.2"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.system; PRODUCT_NAME = "$(inherited)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2973,7 +2973,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.1"; + MARKETING_VERSION = "1.13.2"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.system; PRODUCT_NAME = "$(inherited)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3016,7 +3016,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.1"; + MARKETING_VERSION = "1.13.2"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.standalone; PRODUCT_NAME = SFM; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3058,7 +3058,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.1"; + MARKETING_VERSION = "1.13.2"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.standalone; PRODUCT_NAME = SFM; PROVISIONING_PROFILE_SPECIFIER = ""; From efa608888431f554e588047c5842dd6f51609f7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 9 Mar 2026 13:17:48 +0800 Subject: [PATCH 04/37] Fix widget toggle not disabling On Demand before stopping VPN Store wasOnDemandEnabled flag in providerConfiguration so the widget can restore On Demand state on next start. Clear the flag when On Demand is explicitly disabled in settings or when starting from the main app. --- .../Views/Log/LogViewModel.swift | 2 +- Library/Network/ExtensionProfile.swift | 19 ++++++++++++++++++ WidgetExtension/WidgetTunnelControl.swift | 20 ++++++++++++++++++- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/ApplicationLibrary/Views/Log/LogViewModel.swift b/ApplicationLibrary/Views/Log/LogViewModel.swift index 4bf34ca..884da54 100644 --- a/ApplicationLibrary/Views/Log/LogViewModel.swift +++ b/ApplicationLibrary/Views/Log/LogViewModel.swift @@ -179,7 +179,7 @@ public class LogViewModel: BaseViewModel { public init(commandClient: CommandClient, searchText: String = "") { self.commandClient = commandClient self.searchText = searchText - self.isSearching = !searchText.isEmpty + isSearching = !searchText.isEmpty super.init() dataModel = LogDataModel(commandClient: commandClient, viewModel: self) } diff --git a/Library/Network/ExtensionProfile.swift b/Library/Network/ExtensionProfile.swift index 2a51960..b3b52bc 100644 --- a/Library/Network/ExtensionProfile.swift +++ b/Library/Network/ExtensionProfile.swift @@ -114,6 +114,14 @@ public class ExtensionProfile: ObservableObject { public func updateOnDemand(enabled: Bool, useDefaultRules: Bool) async throws { guard let manager else { return } manager.isOnDemandEnabled = enabled + if !enabled { + if let proto = manager.protocolConfiguration as? NETunnelProviderProtocol { + var config = proto.providerConfiguration ?? [:] + if config.removeValue(forKey: "wasOnDemandEnabled") != nil { + proto.providerConfiguration = config + } + } + } await setOnDemandRules(useDefaultRules: useDefaultRules) try await manager.saveToPreferences() } @@ -141,6 +149,12 @@ public class ExtensionProfile: ObservableObject { manager.isOnDemandEnabled = true await setOnDemandRules(useDefaultRules: alwaysOn) } + if let proto = manager.protocolConfiguration as? NETunnelProviderProtocol { + var config = proto.providerConfiguration ?? [:] + if config.removeValue(forKey: "wasOnDemandEnabled") != nil { + proto.providerConfiguration = config + } + } #if !os(tvOS) if let protocolConfiguration = manager.protocolConfiguration { let includeAllNetworks = await SharedPreferences.includeAllNetworks.get() @@ -239,6 +253,11 @@ public class ExtensionProfile: ObservableObject { } guard let manager else { return } if manager.isOnDemandEnabled { + if let proto = manager.protocolConfiguration as? NETunnelProviderProtocol { + var config = proto.providerConfiguration ?? [:] + config["wasOnDemandEnabled"] = true + proto.providerConfiguration = config + } manager.isOnDemandEnabled = false try await manager.saveToPreferences() } diff --git a/WidgetExtension/WidgetTunnelControl.swift b/WidgetExtension/WidgetTunnelControl.swift index 453ebb0..eddc985 100644 --- a/WidgetExtension/WidgetTunnelControl.swift +++ b/WidgetExtension/WidgetTunnelControl.swift @@ -51,8 +51,17 @@ enum WidgetTunnelControl { if started { if manager.isEnabled == false { manager.isEnabled = true - try await manager.saveToPreferences() } + if let proto = manager.protocolConfiguration as? NETunnelProviderProtocol, + let config = proto.providerConfiguration, + config["wasOnDemandEnabled"] as? Bool == true + { + var newConfig = config + newConfig.removeValue(forKey: "wasOnDemandEnabled") + proto.providerConfiguration = newConfig + manager.isOnDemandEnabled = true + } + try await manager.saveToPreferences() do { try manager.connection.startVPNTunnel() } catch { @@ -60,6 +69,15 @@ enum WidgetTunnelControl { throw error } } else { + if manager.isOnDemandEnabled { + if let proto = manager.protocolConfiguration as? NETunnelProviderProtocol { + var config = proto.providerConfiguration ?? [:] + config["wasOnDemandEnabled"] = true + proto.providerConfiguration = config + } + manager.isOnDemandEnabled = false + try await manager.saveToPreferences() + } manager.connection.stopVPNTunnel() } } From f9fa896326f4738aee7e3f0fd6bd8888a30f48cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 11 Mar 2026 17:53:46 +0800 Subject: [PATCH 05/37] Set DNS matchDomains when include routes have no default route Without this, the system ignores the NE's DNS server when IPv4 include routes don't contain 0.0.0.0/0 (e.g. segment routing). Only applied conditionally to avoid breaking macOS Internet Sharing. --- .../Views/Setting/ProfileOverrideView.swift | 1 + Library/Network/ExtensionPlatformInterface.swift | 8 ++++++++ Localizable.xcstrings | 10 +++++----- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/ApplicationLibrary/Views/Setting/ProfileOverrideView.swift b/ApplicationLibrary/Views/Setting/ProfileOverrideView.swift index 6049345..ede6aa6 100644 --- a/ApplicationLibrary/Views/Setting/ProfileOverrideView.swift +++ b/ApplicationLibrary/Views/Setting/ProfileOverrideView.swift @@ -28,6 +28,7 @@ public struct ProfileOverrideView: View { FormToggle("No Default Route", """ By default, segment routing is used in `auto_route` instead of global routing. If `` exists in the configuration, this item will not take effect on the corresponding network (commonly used to resolve HomeKit compatibility issues). + On macOS, enabling this option will cause Internet Sharing to not work properly. """, $autoRouteUseSubRangesByDefault) { newValue in await SharedPreferences.autoRouteUseSubRangesByDefault.set(newValue) await reloadService() diff --git a/Library/Network/ExtensionPlatformInterface.swift b/Library/Network/ExtensionPlatformInterface.swift index a985b77..cbb3797 100644 --- a/Library/Network/ExtensionPlatformInterface.swift +++ b/Library/Network/ExtensionPlatformInterface.swift @@ -146,6 +146,14 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc ipv6Settings.includedRoutes = ipv6Routes ipv6Settings.excludedRoutes = ipv6ExcludeRoutes settings.ipv6Settings = ipv6Settings + + let hasDefaultRoute = ipv4Routes.contains(where: { + $0.destinationAddress == "0.0.0.0" && $0.destinationSubnetMask == "0.0.0.0" + }) + if !hasDefaultRoute { + dnsSettings.matchDomains = [""] + dnsSettings.matchDomainsNoSearch = true + } } if options.isHTTPProxyEnabled() { diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 112ace2..6d0a360 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -1063,30 +1063,30 @@ } } }, - "By default, segment routing is used in `auto_route` instead of global routing.\nIf `` exists in the configuration, this item will not take effect on the corresponding network (commonly used to resolve HomeKit compatibility issues)." : { + "By default, segment routing is used in `auto_route` instead of global routing.\nIf `` exists in the configuration, this item will not take effect on the corresponding network (commonly used to resolve HomeKit compatibility issues).\nOn macOS, enabling this option will cause Internet Sharing to not work properly." : { "localizations" : { "fa" : { "stringUnit" : { "state" : "translated", - "value" : "به‌صورت پیش‌فرض در `auto_route` از مسیریابی بخشی استفاده می‌شود، نه مسیریابی سراسری.\nاگر `` در پیکربندی وجود داشته باشد، این گزینه در شبکه مربوطه اعمال نمی‌شود (معمولاً برای حل مشکلات سازگاری HomeKit استفاده می‌شود)." + "value" : "به‌صورت پیش‌فرض در `auto_route` از مسیریابی بخشی استفاده می‌شود، نه مسیریابی سراسری.\nاگر `` در پیکربندی وجود داشته باشد، این گزینه در شبکه مربوطه اعمال نمی‌شود (معمولاً برای حل مشکلات سازگاری HomeKit استفاده می‌شود).\nدر macOS، فعال کردن این گزینه باعث می‌شود Internet Sharing به درستی کار نکند." } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "По умолчанию в `auto_route` используется сегментная маршрутизация, а не глобальная.\nЕсли в конфигурации есть ``, этот пункт не вступит в силу в соответствующей сети (обычно используется для решения проблем совместимости с HomeKit)." + "value" : "По умолчанию в `auto_route` используется сегментная маршрутизация, а не глобальная.\nЕсли в конфигурации есть ``, этот пункт не вступит в силу в соответствующей сети (обычно используется для решения проблем совместимости с HomeKit).\nНа macOS включение этой опции приведёт к неработоспособности Общего интернета (Internet Sharing)." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "在默认情况下,`auto_route` 中使用分段路由,而非全局路由。\n若配置中存在 ``,则此项不会在对应网络上生效(常用于解决 HomeKit 兼容性问题)。" + "value" : "在默认情况下,`auto_route` 中使用分段路由,而非全局路由。\n若配置中存在 ``,则此项不会在对应网络上生效(常用于解决 HomeKit 兼容性问题)。\n在 macOS 上,启用此选项将导致互联网共享无法正常工作。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "在默認情況下,`auto_route` 中使用分段路由,而非全局路由。\n若配置中存在 ``,則此項不會在對應網絡上生效(常用於解決 HomeKit 兼容性問題)。" + "value" : "在默認情況下,`auto_route` 中使用分段路由,而非全局路由。\n若配置中存在 ``,則此項不會在對應網絡上生效(常用於解決 HomeKit 兼容性問題)。\n在 macOS 上,啟用此選項將導致網際網路共享無法正常工作。" } } } From f3b4b2238efd238fb1ec6ef2da88017b60a6cfa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 11 Mar 2026 17:54:09 +0800 Subject: [PATCH 06/37] Bump version 1.13.3 --- sing-box.xcodeproj/project.pbxproj | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/sing-box.xcodeproj/project.pbxproj b/sing-box.xcodeproj/project.pbxproj index d5493d1..1c3a47d 100644 --- a/sing-box.xcodeproj/project.pbxproj +++ b/sing-box.xcodeproj/project.pbxproj @@ -2235,7 +2235,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.2"; + MARKETING_VERSION = "1.13.3"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; SDKROOT = appletvos; @@ -2269,7 +2269,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.2"; + MARKETING_VERSION = "1.13.3"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; SDKROOT = appletvos; @@ -2662,7 +2662,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.2"; + MARKETING_VERSION = "1.13.3"; OTHER_CODE_SIGN_FLAGS = "--deep"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2704,7 +2704,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.2"; + MARKETING_VERSION = "1.13.3"; OTHER_CODE_SIGN_FLAGS = "--deep"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2744,7 +2744,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.2"; + MARKETING_VERSION = "1.13.3"; OTHER_CODE_SIGN_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2783,7 +2783,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.2"; + MARKETING_VERSION = "1.13.3"; OTHER_CODE_SIGN_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2925,7 +2925,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.2"; + MARKETING_VERSION = "1.13.3"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.system; PRODUCT_NAME = "$(inherited)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2973,7 +2973,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.2"; + MARKETING_VERSION = "1.13.3"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.system; PRODUCT_NAME = "$(inherited)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3016,7 +3016,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.2"; + MARKETING_VERSION = "1.13.3"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.standalone; PRODUCT_NAME = SFM; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3058,7 +3058,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.2"; + MARKETING_VERSION = "1.13.3"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.standalone; PRODUCT_NAME = SFM; PROVISIONING_PROFILE_SPECIFIER = ""; From da5c8e6d4a59b6ce6f6d6db4ae7afc3c381ac85f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 23 Mar 2026 16:49:01 +0800 Subject: [PATCH 07/37] Use libbox for RootHelper process lookup --- HelperService/ConnectionOwnerLookup.swift | 186 ++-------------------- 1 file changed, 15 insertions(+), 171 deletions(-) diff --git a/HelperService/ConnectionOwnerLookup.swift b/HelperService/ConnectionOwnerLookup.swift index c894f2c..dddafd2 100644 --- a/HelperService/ConnectionOwnerLookup.swift +++ b/HelperService/ConnectionOwnerLookup.swift @@ -1,9 +1,5 @@ -import Darwin import Foundation -import os - -private let PROC_PIDPATHINFO_MAXSIZE: Int32 = 4096 -private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ConnectionOwnerLookup") +import Libbox enum ConnectionOwnerLookup { struct Result { @@ -19,173 +15,21 @@ enum ConnectionOwnerLookup { destinationAddress: String, destinationPort: Int32 ) -> Result? { - let sourceAddr = parseAddress(sourceAddress) - let destAddr = parseAddress(destinationAddress) - - guard let sourceAddr, let destAddr else { - logger.error("find: failed to parse addresses") + var error: NSError? + guard let result = LibboxFindConnectionOwner( + ipProtocol, + sourceAddress, + sourcePort, + destinationAddress, + destinationPort, + &error + ) else { return nil } - - let pidCount = proc_listpids(UInt32(PROC_ALL_PIDS), 0, nil, 0) - guard pidCount > 0 else { - logger.error("find: no processes found") - return nil - } - - let pidBufferSize = Int(pidCount) * MemoryLayout.size - let pids = UnsafeMutablePointer.allocate(capacity: Int(pidCount)) - defer { pids.deallocate() } - - let actualCount = proc_listpids(UInt32(PROC_ALL_PIDS), 0, pids, Int32(pidBufferSize)) - guard actualCount > 0 else { - logger.error("find: failed to list processes") - return nil - } - - let numPids = Int(actualCount) / MemoryLayout.size - - for i in 0 ..< numPids { - let pid = pids[i] - if pid == 0 { continue } - - if let result = checkProcessForConnection( - pid: pid, - ipProtocol: ipProtocol, - sourceAddr: sourceAddr, - sourcePort: UInt16(sourcePort), - destAddr: destAddr, - destPort: UInt16(destinationPort) - ) { - return result - } - } - - return nil - } - - private static func checkProcessForConnection( - pid: pid_t, - ipProtocol: Int32, - sourceAddr: Data, - sourcePort: UInt16, - destAddr: Data, - destPort: UInt16 - ) -> Result? { - let bufferSize = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, nil, 0) - guard bufferSize > 0 else { return nil } - - let fdBuffer = UnsafeMutableRawPointer.allocate(byteCount: Int(bufferSize), alignment: MemoryLayout.alignment) - defer { fdBuffer.deallocate() } - - let actualSize = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, fdBuffer, bufferSize) - guard actualSize > 0 else { return nil } - - let fdCount = Int(actualSize) / MemoryLayout.size - - for i in 0 ..< fdCount { - let fd = fdBuffer.load(fromByteOffset: i * MemoryLayout.size, as: proc_fdinfo.self) - - guard fd.proc_fdtype == PROX_FDTYPE_SOCKET else { continue } - - var socketInfo = socket_fdinfo() - let socketInfoSize = Int32(MemoryLayout.size) - - let result = proc_pidfdinfo(pid, fd.proc_fd, PROC_PIDFDSOCKETINFO, &socketInfo, socketInfoSize) - guard result == socketInfoSize else { continue } - - let soi: in_sockinfo - if ipProtocol == IPPROTO_TCP { - guard socketInfo.psi.soi_kind == SOCKINFO_TCP else { continue } - soi = socketInfo.psi.soi_proto.pri_tcp.tcpsi_ini - } else if ipProtocol == IPPROTO_UDP { - guard socketInfo.psi.soi_kind == SOCKINFO_IN else { continue } - soi = socketInfo.psi.soi_proto.pri_in - } else { - continue - } - - if matchesConnection( - socketInfo: soi, - sourceAddr: sourceAddr, - sourcePort: sourcePort, - destAddr: destAddr, - destPort: destPort - ) { - return getProcessInfo(pid: pid) - } - } - - return nil - } - - private static func matchesConnection( - socketInfo: in_sockinfo, - sourceAddr: Data, - sourcePort: UInt16, - destAddr: Data, - destPort: UInt16 - ) -> Bool { - let localPort = UInt16(bigEndian: UInt16(truncatingIfNeeded: socketInfo.insi_lport)) - let remotePort = UInt16(bigEndian: UInt16(truncatingIfNeeded: socketInfo.insi_fport)) - - guard localPort == sourcePort, remotePort == destPort else { - return false - } - - var localAddr = socketInfo.insi_laddr - var remoteAddr = socketInfo.insi_faddr - - let localData: Data - let remoteData: Data - - if sourceAddr.count == 4 { - localData = Data(bytes: &localAddr.ina_46.i46a_addr4, count: 4) - remoteData = Data(bytes: &remoteAddr.ina_46.i46a_addr4, count: 4) - } else { - localData = Data(bytes: &localAddr.ina_6, count: 16) - remoteData = Data(bytes: &remoteAddr.ina_6, count: 16) - } - - return localData == sourceAddr && remoteData == destAddr - } - - private static func getProcessInfo(pid: pid_t) -> Result? { - let pathBuffer = UnsafeMutablePointer.allocate(capacity: Int(PROC_PIDPATHINFO_MAXSIZE)) - defer { pathBuffer.deallocate() } - - let pathLength = proc_pidpath(pid, pathBuffer, UInt32(PROC_PIDPATHINFO_MAXSIZE)) - let processPath = pathLength > 0 ? String(cString: pathBuffer) : "" - - var info = proc_bsdinfo() - let infoSize = Int32(MemoryLayout.size) - let result = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, infoSize) - - guard result == infoSize else { return nil } - - let uid = Int32(info.pbi_uid) - let userName: String - - if let pw = getpwuid(info.pbi_uid) { - userName = String(cString: pw.pointee.pw_name) - } else { - userName = String(uid) - } - - return Result(userId: uid, userName: userName, processPath: processPath) - } - - private static func parseAddress(_ address: String) -> Data? { - var addr4 = in_addr() - if inet_pton(AF_INET, address, &addr4) == 1 { - return Data(bytes: &addr4, count: 4) - } - - var addr6 = in6_addr() - if inet_pton(AF_INET6, address, &addr6) == 1 { - return Data(bytes: &addr6, count: 16) - } - - return nil + return Result( + userId: result.userId, + userName: result.userName, + processPath: result.processPath + ) } } From 6b790c7a80c38d572c23ff7a075b5e18c744edcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 23 Mar 2026 19:44:18 +0800 Subject: [PATCH 08/37] Bump version 1.13.4 --- sing-box.xcodeproj/project.pbxproj | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/sing-box.xcodeproj/project.pbxproj b/sing-box.xcodeproj/project.pbxproj index 1c3a47d..6b9fdd1 100644 --- a/sing-box.xcodeproj/project.pbxproj +++ b/sing-box.xcodeproj/project.pbxproj @@ -2235,7 +2235,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.3"; + MARKETING_VERSION = "1.13.4"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; SDKROOT = appletvos; @@ -2269,7 +2269,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.3"; + MARKETING_VERSION = "1.13.4"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; SDKROOT = appletvos; @@ -2662,7 +2662,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.3"; + MARKETING_VERSION = "1.13.4"; OTHER_CODE_SIGN_FLAGS = "--deep"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2704,7 +2704,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.3"; + MARKETING_VERSION = "1.13.4"; OTHER_CODE_SIGN_FLAGS = "--deep"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2744,7 +2744,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.3"; + MARKETING_VERSION = "1.13.4"; OTHER_CODE_SIGN_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2783,7 +2783,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.3"; + MARKETING_VERSION = "1.13.4"; OTHER_CODE_SIGN_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2925,7 +2925,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.3"; + MARKETING_VERSION = "1.13.4"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.system; PRODUCT_NAME = "$(inherited)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2973,7 +2973,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.3"; + MARKETING_VERSION = "1.13.4"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.system; PRODUCT_NAME = "$(inherited)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3016,7 +3016,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.3"; + MARKETING_VERSION = "1.13.4"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.standalone; PRODUCT_NAME = SFM; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3058,7 +3058,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.3"; + MARKETING_VERSION = "1.13.4"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.standalone; PRODUCT_NAME = SFM; PROVISIONING_PROFILE_SPECIFIER = ""; From 63d1d0fe3f8b9191ff514280020324bc94a97a4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 27 Mar 2026 15:50:57 +0800 Subject: [PATCH 09/37] Add GitHub update checker and installer --- .../Service/UpdateManager.swift | 204 +++++++++ .../Views/Setting/GitHubEmoji.swift | 194 ++++++++ .../Views/Setting/MacAppView.swift | 149 +++++- .../Views/Setting/UpdateSheet.swift | 75 ++++ Library/Database/SharedPreferences.swift | 10 + Library/Network/HTTPClient.swift | 34 ++ Library/Update/GitHubUpdateChecker.swift | 181 ++++++++ Library/Update/PKGDownloader.swift | 45 ++ Library/Update/PKGInstaller.swift | 181 ++++++++ Library/Update/UpdateInfo.swift | 26 ++ Library/Update/UpdateTrack.swift | 26 ++ Localizable.xcstrings | 423 ++++++++++++++++++ MacLibrary/MacApplication.swift | 71 +++ sing-box.xcodeproj/project.pbxproj | 17 + .../xcshareddata/swiftpm/Package.resolved | 29 +- 15 files changed, 1663 insertions(+), 2 deletions(-) create mode 100644 ApplicationLibrary/Service/UpdateManager.swift create mode 100644 ApplicationLibrary/Views/Setting/GitHubEmoji.swift create mode 100644 ApplicationLibrary/Views/Setting/UpdateSheet.swift create mode 100644 Library/Update/GitHubUpdateChecker.swift create mode 100644 Library/Update/PKGDownloader.swift create mode 100644 Library/Update/PKGInstaller.swift create mode 100644 Library/Update/UpdateInfo.swift create mode 100644 Library/Update/UpdateTrack.swift diff --git a/ApplicationLibrary/Service/UpdateManager.swift b/ApplicationLibrary/Service/UpdateManager.swift new file mode 100644 index 0000000..67d4aa8 --- /dev/null +++ b/ApplicationLibrary/Service/UpdateManager.swift @@ -0,0 +1,204 @@ +#if os(macOS) + + import AppKit + import Foundation + import Libbox + import Library + import os + import SwiftUI + + private let logger = Logger(category: "UpdateManager") + + @MainActor + public class UpdateManager: ObservableObject { + private static let minimumSemver = "0.0.0-0" + + @Published public var updateInfo: UpdateInfo? + @Published public var isUpdateSheetPresented = false + @Published public var isChecking = false + @Published public var isDownloading = false + @Published public var downloadProgress: Double = 0 + @Published public var alert: AlertState? + + public init() {} + + public func updateTrackChanged(to track: UpdateTrack) async { + await SharedPreferences.updateTrack.set(track.rawValue) + guard let updateInfo, !track.allows(updateInfo) else { + return + } + await setUpdateInfo(nil) + } + + @discardableResult + public func loadCachedUpdate() async -> Bool { + let cached = await SharedPreferences.cachedUpdateInfo.get() + guard !cached.isEmpty, + let data = cached.data(using: .utf8), + let info = try? JSONDecoder().decode(UpdateInfo.self, from: data) + else { + return false + } + + let track = await currentTrack() + guard track.allows(info), + shouldKeepCachedUpdate(info.versionName, track: track, currentVersion: Bundle.main.version) + else { + await setUpdateInfo(nil) + return false + } + + updateInfo = info + return await shouldAutomaticallyPresent(info) + } + + @discardableResult + public func checkForUpdate(presentIfFound: Bool = false, force: Bool = false, showsAlertOnFailure: Bool = true) async -> Bool { + do { + guard let info = try await refreshUpdateInfo(force: force, showsAlertOnFailure: showsAlertOnFailure) else { + return false + } + guard presentIfFound else { + return false + } + return await shouldAutomaticallyPresent(info) + } catch { + return false + } + } + + public func showUpdateSheet() async { + guard let updateInfo else { return } + await SharedPreferences.lastShownUpdateVersion.set(updateInfo.versionName) + isUpdateSheetPresented = true + } + + public func dismissUpdateSheet() { + guard isUpdateSheetPresented else { return } + isUpdateSheetPresented = false + } + + public func downloadAndInstall(environments: ExtensionEnvironments) async { + guard let updateInfo else { return } + + isDownloading = true + downloadProgress = 0 + alert = nil + + do { + let pkgURL = try await PKGDownloader.download(from: updateInfo.downloadURL, expectedSize: updateInfo.fileSize) { [weak self] progress in + Task { @MainActor in + self?.downloadProgress = progress + } + } + + let authRef = try PKGInstaller.authorize() + try await Task.detached { + try PKGInstaller.install(pkgPath: pkgURL.path, authorization: authRef) + }.value + + var profile = environments.extensionProfile + if profile == nil { + await environments.reload() + profile = environments.extensionProfile + } + if let profile, profile.status.isConnected { + try? await profile.stop() + var waitCount = 0 + while profile.status != .disconnected, waitCount < 10 { + try? await Task.sleep(nanoseconds: 500_000_000) + waitCount += 1 + } + } + + do { + try PKGInstaller.scheduleInstalledApplicationRelaunch() + } catch { + logger.warning("relaunch failed: \(error.localizedDescription)") + } + exit(0) + } catch PKGInstallerError.authorizationCancelled { + isDownloading = false + } catch { + isDownloading = false + logger.error("update failed: \(error.localizedDescription)") + alert = AlertState(action: "install update", error: error) + } + } + + func refreshUpdateInfo(force: Bool = false, showsAlertOnFailure: Bool = true) async throws -> UpdateInfo? { + guard !isChecking else { + throw CancellationError() + } + isChecking = true + if showsAlertOnFailure { + alert = nil + } + defer { isChecking = false } + + do { + let track = await currentTrack() + let info = try await GitHubUpdateChecker.checkAsync(track: track, force: force) + let currentTrack = await currentTrack() + guard track == currentTrack else { + throw CancellationError() + } + await setUpdateInfo(info) + return info + } catch is CancellationError { + throw CancellationError() + } catch { + logger.error("check for update failed: \(error.localizedDescription)") + if showsAlertOnFailure { + alert = AlertState(action: "check for update", error: error) + } + throw error + } + } + + private func currentTrack() async -> UpdateTrack { + let trackString = await SharedPreferences.updateTrack.get() + return UpdateTrack.resolved(from: trackString) + } + + private func shouldAutomaticallyPresent(_ updateInfo: UpdateInfo) async -> Bool { + let lastShownVersion = await SharedPreferences.lastShownUpdateVersion.get() + return lastShownVersion != updateInfo.versionName + } + + private func shouldKeepCachedUpdate(_ version: String, track: UpdateTrack, currentVersion: String) -> Bool { + guard Self.isValidSemver(version) else { + return false + } + if LibboxCompareSemver(version, currentVersion) { + return true + } + return track == .stable && Self.isValidPrereleaseSemver(currentVersion) + } + + private static func isValidSemver(_ version: String) -> Bool { + let trimmedVersion = version.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedVersion == minimumSemver || LibboxCompareSemver(trimmedVersion, minimumSemver) + } + + private static func isValidPrereleaseSemver(_ version: String) -> Bool { + let trimmedVersion = version.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedVersion.contains("-") && isValidSemver(trimmedVersion) + } + + private func setUpdateInfo(_ updateInfo: UpdateInfo?) async { + self.updateInfo = updateInfo + + guard let updateInfo, + let data = try? JSONEncoder().encode(updateInfo) + else { + dismissUpdateSheet() + await SharedPreferences.cachedUpdateInfo.set("") + await SharedPreferences.lastShownUpdateVersion.set("") + return + } + await SharedPreferences.cachedUpdateInfo.set(String(decoding: data, as: UTF8.self)) + } + } + +#endif diff --git a/ApplicationLibrary/Views/Setting/GitHubEmoji.swift b/ApplicationLibrary/Views/Setting/GitHubEmoji.swift new file mode 100644 index 0000000..e048ba8 --- /dev/null +++ b/ApplicationLibrary/Views/Setting/GitHubEmoji.swift @@ -0,0 +1,194 @@ +#if os(macOS) + + enum GitHubEmoji { + static func replaceShortcodes(in text: String) -> String { + text.replacing(/:([\w+-]+):/) { match in + shortcodes[String(match.1)] ?? String(match.0) + } + } + + /// Common GitHub / gitmoji shortcodes → Unicode emoji + private static let shortcodes: [String: String] = [ + // Gitmoji (commit conventions) + "art": "🎨", + "zap": "⚡", + "fire": "🔥", + "bug": "🐛", + "ambulance": "🚑", + "sparkles": "✨", + "memo": "📝", + "rocket": "🚀", + "lipstick": "💄", + "tada": "🎉", + "white_check_mark": "✅", + "lock": "🔒", + "closed_lock_with_key": "🔐", + "bookmark": "🔖", + "rotating_light": "🚨", + "construction": "🚧", + "green_heart": "💚", + "arrow_down": "⬇️", + "arrow_up": "⬆️", + "pushpin": "📌", + "construction_worker": "👷", + "chart_with_upwards_trend": "📈", + "recycle": "♻️", + "heavy_plus_sign": "➕", + "heavy_minus_sign": "➖", + "wrench": "🔧", + "hammer": "🔨", + "globe_with_meridians": "🌐", + "pencil2": "✏️", + "pencil": "📝", + "poop": "💩", + "rewind": "⏪", + "twisted_rightwards_arrows": "🔀", + "package": "📦", + "alien": "👽", + "truck": "🚚", + "page_facing_up": "📄", + "boom": "💥", + "bento": "🍱", + "wheelchair": "♿", + "bulb": "💡", + "beers": "🍻", + "speech_balloon": "💬", + "card_file_box": "🗃️", + "loud_sound": "🔊", + "mute": "🔇", + "busts_in_silhouette": "👥", + "children_crossing": "🚸", + "building_construction": "🏗️", + "iphone": "📱", + "clown_face": "🤡", + "egg": "🥚", + "see_no_evil": "🙈", + "camera_flash": "📸", + "alembic": "⚗️", + "mag": "🔍", + "label": "🏷️", + "seedling": "🌱", + "triangular_flag_on_post": "🚩", + "goal_net": "🥅", + "dizzy": "💫", + "wastebasket": "🗑️", + "passport_control": "🛂", + "adhesive_bandage": "🩹", + "monocle_face": "🧐", + "coffin": "⚰️", + "test_tube": "🧪", + "necktie": "👔", + "stethoscope": "🩺", + "bricks": "🧱", + "technologist": "🧑‍💻", + + // Common faces & people + "smile": "😄", + "laughing": "😆", + "blush": "😊", + "smiley": "😃", + "grinning": "😀", + "wink": "😉", + "heart_eyes": "😍", + "kissing_heart": "😘", + "sunglasses": "😎", + "thinking": "🤔", + "thumbsup": "👍", + "+1": "👍", + "thumbsdown": "👎", + "-1": "👎", + "clap": "👏", + "pray": "🙏", + "wave": "👋", + "raised_hands": "🙌", + "ok_hand": "👌", + "point_up": "☝️", + "point_down": "👇", + "point_left": "👈", + "point_right": "👉", + "muscle": "💪", + + // Hearts & symbols + "heart": "❤️", + "broken_heart": "💔", + "star": "⭐", + "star2": "🌟", + "warning": "⚠️", + "x": "❌", + "heavy_check_mark": "✔️", + "question": "❓", + "exclamation": "❗", + "bangbang": "‼️", + "interrobang": "⁉️", + "100": "💯", + + // Objects & nature + "gear": "⚙️", + "key": "🔑", + "link": "🔗", + "shield": "🛡️", + "bell": "🔔", + "no_bell": "🔕", + "clipboard": "📋", + "books": "📚", + "book": "📖", + "computer": "💻", + "desktop_computer": "🖥️", + "electric_plug": "🔌", + "battery": "🔋", + "floppy_disk": "💾", + "file_folder": "📁", + "open_file_folder": "📂", + "calendar": "📅", + "clock1": "🕐", + "hourglass": "⌛", + "stopwatch": "⏱️", + "timer_clock": "⏲️", + "inbox_tray": "📥", + "outbox_tray": "📤", + "envelope": "✉️", + "email": "📧", + "newspaper": "📰", + "scroll": "📜", + "trophy": "🏆", + "medal_sports": "🏅", + "gem": "💎", + "hammer_and_wrench": "🛠️", + "nut_and_bolt": "🔩", + "chains": "⛓️", + "magnet": "🧲", + "trash": "🗑️", + "world_map": "🗺️", + + // Arrows & indicators + "arrow_right": "➡️", + "arrow_left": "⬅️", + "arrow_upper_right": "↗️", + "arrow_lower_right": "↘️", + "arrows_counterclockwise": "🔄", + "back": "🔙", + "new": "🆕", + "up": "🆙", + "cool": "🆒", + "free": "🆓", + "information_source": "ℹ️", + + // Nature & weather + "sunny": "☀️", + "cloud": "☁️", + "snowflake": "❄️", + "rainbow": "🌈", + "ocean": "🌊", + "leaves": "🍃", + "four_leaf_clover": "🍀", + "evergreen_tree": "🌲", + "deciduous_tree": "🌳", + "cactus": "🌵", + "cherry_blossom": "🌸", + "rose": "🌹", + "sunflower": "🌻", + "herb": "🌿", + ] + } + +#endif diff --git a/ApplicationLibrary/Views/Setting/MacAppView.swift b/ApplicationLibrary/Views/Setting/MacAppView.swift index fccf76c..a1d1ead 100644 --- a/ApplicationLibrary/Views/Setting/MacAppView.swift +++ b/ApplicationLibrary/Views/Setting/MacAppView.swift @@ -31,6 +31,12 @@ public struct AppView: View { @State private var menuBarExtraInBackground = false @State private var helperStatusLoaded = false @State private var rootHelperRegistrationStatus: SMAppService.Status = .notRegistered + @EnvironmentObject private var environments: ExtensionEnvironments + @EnvironmentObject private var updateManager: UpdateManager + @State private var updateTrack: UpdateTrack = .stable + @State private var checkUpdateEnabled = false + @State private var cacheSize: Int64 = 0 + @State private var cacheSizeText = "" #endif @State private var alert: AlertState? @@ -91,6 +97,111 @@ public struct AppView: View { } if Variant.useSystemExtension { + FormTextItem("Cache Size", cacheSizeText) + if cacheSize > 0 { + // Safe: System Extension's working directory is in its own container + // (/var/root/Library/Containers/…), not under the app's cacheDirectory. + FormButton(role: .destructive) { + Task.detached { + let cacheDir = FilePath.cacheDirectory + if let contents = try? FileManager.default.contentsOfDirectory( + at: cacheDir, + includingPropertiesForKeys: nil + ) { + for item in contents { + try? FileManager.default.removeItem(at: item) + } + } + await MainActor.run { + cacheSize = 0 + cacheSizeText = ByteCountFormatter.string(fromByteCount: 0, countStyle: .file) + } + } + } label: { + Label("Clear Cache", systemImage: "trash") + .foregroundColor(.red) + } + } + } + + if Variant.useSystemExtension { + Section("Update Settings") { + Picker("Update Track", selection: $updateTrack) { + Text("Stable").tag(UpdateTrack.stable) + Text("Beta").tag(UpdateTrack.beta) + } + .onChangeCompat(of: updateTrack) { newValue in + Task { + await updateManager.updateTrackChanged(to: newValue) + } + } + + Toggle("Automatic Update Check", isOn: $checkUpdateEnabled) + .onChangeCompat(of: checkUpdateEnabled) { newValue in + Task { + await SharedPreferences.checkUpdateEnabled.set(newValue) + } + } + + FormButton { + Task { + do { + if try await updateManager.refreshUpdateInfo() != nil { + await updateManager.showUpdateSheet() + } else { + alert = AlertState( + title: String(localized: "Check Update"), + message: String(localized: "No updates available") + ) + } + } catch {} + } + } label: { + if updateManager.isChecking { + HStack(spacing: 6) { + ProgressView() + .controlSize(.small) + Text("Checking...") + } + } else { + Label("Check Update", systemImage: "arrow.triangle.2.circlepath") + } + } + .disabled(updateManager.isChecking) + .contextMenu { + Button("Force Show Latest Version as Update") { + Task { + do { + if try await updateManager.refreshUpdateInfo(force: true) != nil { + await updateManager.showUpdateSheet() + } else { + alert = AlertState( + title: String(localized: "Check Update"), + message: String(localized: "No updates available") + ) + } + } catch {} + } + } + .disabled(updateManager.isChecking) + } + + if let info = updateManager.updateInfo { + FormButton { + Task { + await updateManager.showUpdateSheet() + } + } label: { + HStack { + Label("Update", systemImage: "arrow.down.circle") + Spacer() + Text("v\(info.versionName)") + .foregroundStyle(.secondary) + } + } + } + } + Section("System Extension") { FormButton { Task { @@ -163,7 +274,10 @@ public struct AppView: View { } } .alert($alert) - .navigationTitle("App") + #if os(macOS) + .alert($updateManager.alert) + #endif + .navigationTitle("App") #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif @@ -174,12 +288,18 @@ public struct AppView: View { #if os(macOS) startAtLogin = SMAppService.mainApp.status == .enabled menuBarExtraInBackground = await SharedPreferences.menuBarExtraInBackground.get() + if Variant.useSystemExtension { + let trackString = await SharedPreferences.updateTrack.get() + updateTrack = UpdateTrack.resolved(from: trackString) + checkUpdateEnabled = await SharedPreferences.checkUpdateEnabled.get() + } #endif isLoading = false #if os(macOS) if Variant.useSystemExtension { refreshHelperStatus() helperStatusLoaded = true + refreshCacheSize() } #endif } @@ -332,5 +452,32 @@ public struct AppView: View { NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/System Preferences.app")) } + private func refreshCacheSize() { + Task.detached { + let size = Self.calculateDirSize(FilePath.cacheDirectory) + await MainActor.run { + cacheSize = size + cacheSizeText = ByteCountFormatter.string(fromByteCount: size, countStyle: .file) + } + } + } + + private static func calculateDirSize(_ dir: URL) -> Int64 { + guard let enumerator = FileManager.default.enumerator( + at: dir, + includingPropertiesForKeys: [.fileSizeKey], + options: [.skipsHiddenFiles] + ) else { + return 0 + } + var size: Int64 = 0 + for case let fileURL as URL in enumerator { + if let fileSize = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize { + size += Int64(fileSize) + } + } + return size + } + #endif } diff --git a/ApplicationLibrary/Views/Setting/UpdateSheet.swift b/ApplicationLibrary/Views/Setting/UpdateSheet.swift new file mode 100644 index 0000000..1a6f77f --- /dev/null +++ b/ApplicationLibrary/Views/Setting/UpdateSheet.swift @@ -0,0 +1,75 @@ +#if os(macOS) + + import AppKit + import Library + import MarkdownUI + import SwiftUI + + public struct UpdateSheet: View { + @ObservedObject var updateManager: UpdateManager + @EnvironmentObject private var environments: ExtensionEnvironments + + public init(updateManager: UpdateManager) { + self.updateManager = updateManager + } + + public var body: some View { + VStack(spacing: 16) { + Text("Check Update") + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + Text("New version available: \(updateManager.updateInfo?.versionName ?? "")") + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + if let releaseNotes = updateManager.updateInfo?.releaseNotes, !releaseNotes.isEmpty { + ScrollView { + Markdown(GitHubEmoji.replaceShortcodes(in: releaseNotes)) + .markdownTheme(.gitHub.text { + FontSize(10) + }) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 300) + } + + if updateManager.isDownloading { + ProgressView(value: updateManager.downloadProgress) + } + + HStack(spacing: 12) { + if let releaseURL = updateManager.updateInfo?.releaseURL, + let url = URL(string: releaseURL) + { + Button("View Release") { + NSWorkspace.shared.open(url) + } + } + + Spacer() + + Button("Cancel", role: .cancel) { + updateManager.dismissUpdateSheet() + } + .keyboardShortcut(.escape, modifiers: []) + .disabled(updateManager.isDownloading) + + Button("Update") { + Task { + await updateManager.downloadAndInstall(environments: environments) + } + } + .keyboardShortcut(.defaultAction) + .disabled(updateManager.isDownloading) + } + } + .padding(20) + .frame(minWidth: 480) + .interactiveDismissDisabled(updateManager.isDownloading) + .alert($updateManager.alert) + } + } + +#endif diff --git a/Library/Database/SharedPreferences.swift b/Library/Database/SharedPreferences.swift index b974d00..277a272 100644 --- a/Library/Database/SharedPreferences.swift +++ b/Library/Database/SharedPreferences.swift @@ -121,6 +121,16 @@ public enum SharedPreferences { try await batchDelete([alwaysOn.name, onDemandEnabled.name, onDemandRules.name]) } + // Update (macOS standalone) + + #if os(macOS) + public static let checkUpdateEnabled = Preference("check_update_enabled", defaultValue: false) + public static let updateCheckPrompted = Preference("update_check_prompted", defaultValue: false) + public static let updateTrack = Preference("update_track", defaultValue: "") + public static let cachedUpdateInfo = Preference("cached_update_info", defaultValue: "") + public static let lastShownUpdateVersion = Preference("last_shown_update_version", defaultValue: "") + #endif + // Core public static let disableDeprecatedWarnings = Preference("disable_deprecated_warnings", defaultValue: false) diff --git a/Library/Network/HTTPClient.swift b/Library/Network/HTTPClient.swift index ca40f73..1796389 100644 --- a/Library/Network/HTTPClient.swift +++ b/Library/Network/HTTPClient.swift @@ -45,7 +45,41 @@ public class HTTPClient { } } + public func writeTo(_ url: String?, path: String, progress: ((Int64, Int64) -> Void)? = nil) throws { + #if DEBUG + precondition(!Thread.isMainThread, "HTTPClient.writeTo(...) must not be called on the main thread") + #endif + let request = client.newRequest()! + request.setUserAgent(HTTPClient.userAgent) + try request.setURL(url) + let response = try request.execute() + if let progress { + let handler = WriteToProgressHandler(progress) + try response.writeTo(withProgress: path, handler: handler) + } else { + try response.write(to: path) + } + } + + public static func writeToAsync(_ url: String?, path: String, progress: ((Int64, Int64) -> Void)? = nil) async throws { + try await BlockingIO.run { + try HTTPClient().writeTo(url, path: path, progress: progress) + } + } + deinit { client.close() } } + +private class WriteToProgressHandler: NSObject, LibboxHTTPResponseWriteToProgressHandlerProtocol { + private let handler: (Int64, Int64) -> Void + + init(_ handler: @escaping (Int64, Int64) -> Void) { + self.handler = handler + } + + func update(_ progress: Int64, total: Int64) { + handler(progress, total) + } +} diff --git a/Library/Update/GitHubUpdateChecker.swift b/Library/Update/GitHubUpdateChecker.swift new file mode 100644 index 0000000..733fac2 --- /dev/null +++ b/Library/Update/GitHubUpdateChecker.swift @@ -0,0 +1,181 @@ +import Darwin +import Foundation +import Libbox + +public enum GitHubUpdateChecker { + private static let releasesURL = "https://api.github.com/repos/SagerNet/sing-box/releases" + private static let releasesPerPage = 100 + private static let minimumSemver = "0.0.0-0" + + public static func checkAsync(track: UpdateTrack, force: Bool = false) async throws -> UpdateInfo? { + try await BlockingIO.run { + try check(track: track, force: force) + } + } + + public static func check(track: UpdateTrack, force: Bool = false) throws -> UpdateInfo? { + let client = HTTPClient() + guard let releases = try fetchReleases(client: client, track: track) else { + return nil + } + let currentVersion = Bundle.main.version + + var bestRelease: GitHubRelease? + var bestVersion: String? + var bestAsset: GitHubAsset? + + for release in releases { + if release.draft { continue } + if track == .stable, release.prerelease { continue } + guard let pkgAsset = findPKGAsset(in: release.assets) else { continue } + + let version = release.tagName.hasPrefix("v") + ? String(release.tagName.dropFirst()) + : release.tagName + + guard shouldIncludeRelease( + version: version, + currentVersion: currentVersion, + track: track, + force: force + ) else { continue } + + if let best = bestVersion { + guard LibboxCompareSemver(version, best) else { continue } + } + + bestRelease = release + bestVersion = version + bestAsset = pkgAsset + } + + guard let release = bestRelease, + let version = bestVersion, + let pkgAsset = bestAsset + else { + return nil + } + + return UpdateInfo( + versionName: version, + releaseURL: release.htmlURL, + downloadURL: pkgAsset.browserDownloadURL, + releaseNotes: release.body, + isPrerelease: release.prerelease, + fileSize: pkgAsset.size + ) + } + + private static func findPKGAsset(in assets: [GitHubAsset]) -> GitHubAsset? { + let pkgAssets = assets.filter { $0.name.hasSuffix(".pkg") } + + let preferred = preferredPKGVariant() + + if let match = pkgAssets.first(where: { $0.name.contains(preferred) }) { + return match + } + if let universal = pkgAssets.first(where: { $0.name.contains("Universal") }) { + return universal + } + return pkgAssets.first + } + + private static func preferredPKGVariant() -> String { + if let hostSupportsArm64 = hostSupportsArm64() { + return hostSupportsArm64 ? "Apple" : "Intel" + } + + #if arch(arm64) + return "Apple" + #else + return "Intel" + #endif + } + + private static func hostSupportsArm64() -> Bool? { + var value: Int32 = 0 + var size = MemoryLayout.size(ofValue: value) + let result = withUnsafeMutablePointer(to: &value) { + sysctlbyname("hw.optional.arm64", $0, &size, nil, 0) + } + guard result == 0 else { + return nil + } + return value != 0 + } + + private static func shouldIncludeRelease( + version: String, + currentVersion: String, + track: UpdateTrack, + force: Bool + ) -> Bool { + guard isValidSemver(version) else { + return false + } + if force || LibboxCompareSemver(version, currentVersion) { + return true + } + return track == .stable && isValidPrereleaseSemver(currentVersion) + } + + private static func isValidSemver(_ version: String) -> Bool { + let trimmedVersion = version.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedVersion == minimumSemver || LibboxCompareSemver(trimmedVersion, minimumSemver) + } + + private static func isValidPrereleaseSemver(_ version: String) -> Bool { + let trimmedVersion = version.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedVersion.contains("-") && isValidSemver(trimmedVersion) + } + + private static func fetchReleases(client: HTTPClient, track: UpdateTrack) throws -> [GitHubRelease]? { + var allReleases: [GitHubRelease] = [] + var page = 1 + + while true { + let releasesJSON = try client.getString("\(releasesURL)?per_page=\(releasesPerPage)&page=\(page)") + guard let data = releasesJSON.data(using: .utf8) else { + return nil + } + + let pageReleases = try JSONDecoder().decode([GitHubRelease].self, from: data) + allReleases.append(contentsOf: pageReleases) + + if track != .stable || pageReleases.count < releasesPerPage { + return allReleases + } + page += 1 + } + } +} + +private struct GitHubRelease: Decodable { + let tagName: String + let htmlURL: String + let body: String? + let draft: Bool + let prerelease: Bool + let assets: [GitHubAsset] + + enum CodingKeys: String, CodingKey { + case tagName = "tag_name" + case htmlURL = "html_url" + case body + case draft + case prerelease + case assets + } +} + +private struct GitHubAsset: Decodable { + let name: String + let browserDownloadURL: String + let size: Int64 + + enum CodingKeys: String, CodingKey { + case name + case browserDownloadURL = "browser_download_url" + case size + } +} diff --git a/Library/Update/PKGDownloader.swift b/Library/Update/PKGDownloader.swift new file mode 100644 index 0000000..8151bb1 --- /dev/null +++ b/Library/Update/PKGDownloader.swift @@ -0,0 +1,45 @@ +#if os(macOS) + + import Foundation + + public enum PKGDownloader { + public static func download( + from url: String, + expectedSize: Int64, + progress: @escaping (Double) -> Void + ) async throws -> URL { + let updatesDir = FilePath.cacheDirectory.appendingPathComponent("updates", isDirectory: true) + try FileManager.default.createDirectory(at: updatesDir, withIntermediateDirectories: true) + + let filename = URL(string: url)!.lastPathComponent + let destination = updatesDir.appendingPathComponent(filename) + + if let attrs = try? FileManager.default.attributesOfItem(atPath: destination.path), + let fileSize = attrs[.size] as? Int64, + expectedSize > 0, fileSize == expectedSize + { + progress(1.0) + return destination + } + + // Clean old PKG files + if let contents = try? FileManager.default.contentsOfDirectory(at: updatesDir, includingPropertiesForKeys: nil) { + for file in contents where file.pathExtension == "pkg" && file.lastPathComponent != filename { + try? FileManager.default.removeItem(at: file) + } + } + + try? FileManager.default.removeItem(at: destination) + var lastReported = 0.0 + try await HTTPClient.writeToAsync(url, path: destination.path) { bytesWritten, totalBytes in + guard totalBytes > 0 else { return } + let current = Double(bytesWritten) / Double(totalBytes) + guard current - lastReported >= 0.01 || current >= 1.0 else { return } + lastReported = current + progress(current) + } + return destination + } + } + +#endif diff --git a/Library/Update/PKGInstaller.swift b/Library/Update/PKGInstaller.swift new file mode 100644 index 0000000..a25c7d4 --- /dev/null +++ b/Library/Update/PKGInstaller.swift @@ -0,0 +1,181 @@ +#if os(macOS) + + import Foundation + import Security + + public enum PKGInstaller { + private static let installerExitStatusMarker = "__PKG_INSTALLER_EXIT_STATUS__=" + + private typealias ExecuteWithPrivilegesFunc = @convention(c) ( + AuthorizationRef, + UnsafePointer, + AuthorizationFlags, + UnsafePointer?>, + UnsafeMutablePointer?>? + ) -> OSStatus + + public static func authorize() throws -> AuthorizationRef { + var authRef: AuthorizationRef? + var status = AuthorizationCreate(nil, nil, [], &authRef) + guard status == errAuthorizationSuccess, let authRef else { + throw PKGInstallerError.authorizationFailed + } + + let rightName = kAuthorizationRightExecute + var item = AuthorizationItem(name: rightName, valueLength: 0, value: nil, flags: 0) + withUnsafeMutablePointer(to: &item) { itemPtr in + var rights = AuthorizationRights(count: 1, items: itemPtr) + let flags: AuthorizationFlags = [.interactionAllowed, .extendRights, .preAuthorize] + status = AuthorizationCopyRights(authRef, &rights, nil, flags, nil) + } + guard status == errAuthorizationSuccess else { + if status == errAuthorizationCanceled { + AuthorizationFree(authRef, []) + throw PKGInstallerError.authorizationCancelled + } + AuthorizationFree(authRef, []) + throw PKGInstallerError.authorizationFailed + } + + return authRef + } + + public static func install(pkgPath: String, authorization authRef: AuthorizationRef) throws { + defer { AuthorizationFree(authRef, []) } + + guard let sym = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "AuthorizationExecuteWithPrivileges") else { + throw PKGInstallerError.authorizationFailed + } + let executeWithPrivileges = unsafeBitCast(sym, to: ExecuteWithPrivilegesFunc.self) + + let escapedPkgPath = shellQuote(pkgPath) + let command = "/usr/sbin/installer -pkg \(escapedPkgPath) -target / 2>&1; status=$?; printf '\\n\(installerExitStatusMarker)%d\\n' \"$status\"; exit \"$status\"" + let tool = "/bin/sh" + var cArgs: [UnsafeMutablePointer?] = [ + strdup("-c"), strdup(command), nil, + ] + defer { for i in 0 ..< cArgs.count - 1 { + free(cArgs[i]) + } } + + var pipe: UnsafeMutablePointer? + let status = executeWithPrivileges(authRef, tool, [], &cArgs, &pipe) + guard status == errAuthorizationSuccess else { + throw PKGInstallerError.authorizationFailed + } + + let output = pipe.map(readOutput(from:)) ?? "" + let (exitStatus, installerOutput) = parseInstallerOutput(output) + guard let exitStatus else { + throw PKGInstallerError.installationFailed(installerOutput.isEmpty ? "Installer exited without reporting a status" : installerOutput) + } + guard exitStatus == 0 else { + if installerOutput.isEmpty { + throw PKGInstallerError.installationFailed("Installer failed with exit status \(exitStatus)") + } + throw PKGInstallerError.installationFailed(installerOutput) + } + } + + public static func scheduleInstalledApplicationRelaunch() throws { + guard let appPath = findInstalledAppPath() else { + throw PKGInstallerError.relaunchFailed("Installed app not found in /Applications") + } + + let escapedApp = appPath.replacingOccurrences(of: "'", with: "'\\''") + let processID = ProcessInfo.processInfo.processIdentifier + let command = "while kill -0 \(processID) 2>/dev/null; do sleep 1; done; open '\(escapedApp)' >/dev/null 2>&1" + + let process = Process() + process.executableURL = URL(filePath: "/bin/sh") + process.arguments = ["-c", command] + if let nullHandle = FileHandle(forWritingAtPath: "/dev/null") { + process.standardOutput = nullHandle + process.standardError = nullHandle + } + + do { + try process.run() + } catch { + throw PKGInstallerError.relaunchFailed(error.localizedDescription) + } + } + + private static func findInstalledAppPath() -> String? { + if let bundleID = Bundle.main.bundleIdentifier, + let contents = try? FileManager.default.contentsOfDirectory(atPath: "/Applications") + { + for item in contents where item.hasSuffix(".app") { + let path = "/Applications/\(item)" + if let bundle = Bundle(path: path), bundle.bundleIdentifier == bundleID { + return path + } + } + } + let fallback = "/Applications/\(Bundle.main.bundleURL.lastPathComponent)" + if FileManager.default.fileExists(atPath: fallback) { + return fallback + } + return nil + } + + private static func shellQuote(_ value: String) -> String { + "'\(value.replacingOccurrences(of: "'", with: "'\\''"))'" + } + + private static func readOutput(from pipe: UnsafeMutablePointer) -> String { + defer { fclose(pipe) } + + var data = Data() + let bufferSize = 4096 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + + while true { + let count = fread(buffer, 1, bufferSize, pipe) + if count > 0 { + data.append(buffer, count: count) + } + if count < bufferSize { + if feof(pipe) != 0 || ferror(pipe) != 0 { + break + } + } + } + + return String(decoding: data, as: UTF8.self) + } + + private static func parseInstallerOutput(_ output: String) -> (Int32?, String) { + let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines) + guard let markerRange = output.range(of: installerExitStatusMarker, options: .backwards) else { + return (nil, trimmedOutput) + } + + let statusText = output[markerRange.upperBound...].prefix { $0.isNumber || $0 == "-" } + let installerOutput = String(output[.. Self { + guard !rawValue.isEmpty else { + return defaultForCurrentBuild + } + return Self(rawValue: rawValue) ?? defaultForCurrentBuild + } + + public func allows(_ updateInfo: UpdateInfo) -> Bool { + switch self { + case .stable: + return !updateInfo.isPrerelease + case .beta: + return true + } + } +} diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 6d0a360..87f8ac6 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -979,6 +979,34 @@ } } }, + "Automatic Update Check" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "بررسی خودکار به‌روزرسانی" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Автоматическая проверка обновлений" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自动检查更新" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "自動檢查更新" + } + } + } + }, "Automatically connect or disconnect VPN based on rules." : { "localizations" : { "fa" : { @@ -1035,6 +1063,34 @@ } } }, + "Beta" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "بتا" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Бета" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "测试版" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "測試版" + } + } + } + }, "Browse" : { "localizations" : { "fa" : { @@ -1091,6 +1147,34 @@ } } }, + "Cache Size" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "اندازه حافظه پنهان" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Размер кэша" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "缓存大小" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "快取大小" + } + } + } + }, "Camera" : { "localizations" : { "fa" : { @@ -1315,6 +1399,62 @@ } } }, + "Check Update" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "بررسی به‌روزرسانی" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Проверить обновления" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "检查更新" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "檢查更新" + } + } + } + }, + "Checking..." : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "در حال بررسی..." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Проверка..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "检查中..." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "檢查中..." + } + } + } + }, "Choose" : { "localizations" : { "fa" : { @@ -1371,6 +1511,34 @@ } } }, + "Clear Cache" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "پاک‌سازی حافظه پنهان" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Очистить кэш" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除缓存" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除快取" + } + } + } + }, "Clear Logs" : { "comment" : "Clear all logs", "localizations" : { @@ -3568,6 +3736,34 @@ } } }, + "Force Show Latest Version as Update" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "نمایش آخرین نسخه به‌عنوان به‌روزرسانی" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать последнюю версию как обновление" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "强制显示最新版本为更新" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "強制顯示最新版本為更新" + } + } + } + }, "Format" : { "localizations" : { "fa" : { @@ -5497,6 +5693,34 @@ } } }, + "New version available: %@" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "نسخه جدید موجود: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Доступна новая версия: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "新版本可用:%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "新版本可用:%@" + } + } + } + }, "No connection rules. Add rules to specify which domains trigger VPN connection." : { "localizations" : { "fa" : { @@ -5556,6 +5780,62 @@ "No documentation.\n\n[Apple Documentation](https://developer.apple.com/documentation/networkextension/nevpnprotocol/excludedevicecommunication)" : { "shouldTranslate" : false }, + "No updates available" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "به‌روزرسانی موجود نیست" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновлений не найдено" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有可用的更新" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "沒有可用的更新" + } + } + } + }, + "No, thanks" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "نه، ممنون" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет, спасибо" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "不,谢谢" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "不,謝謝" + } + } + } + }, "Ok" : { "localizations" : { "fa" : { @@ -7307,6 +7587,34 @@ } } }, + "Stable" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "پایدار" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стабильная" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "稳定版" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "穩定版" + } + } + } + }, "Start" : { "localizations" : { "fa" : { @@ -8254,6 +8562,62 @@ } } }, + "Update Settings" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "تنظیمات به‌روزرسانی" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки обновлений" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "更新设置" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "更新設定" + } + } + } + }, + "Update Track" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "کانال به‌روزرسانی" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Канал обновлений" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "更新通道" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "更新通道" + } + } + } + }, "Uplink" : { "localizations" : { "fa" : { @@ -8384,6 +8748,9 @@ } } }, + "v%@" : { + "shouldTranslate" : false + }, "Version" : { "localizations" : { "fa" : { @@ -8440,6 +8807,34 @@ } } }, + "View Release" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "مشاهده انتشار" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Просмотреть релиз" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "查看发布" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "查看發佈" + } + } + } + }, "VPN will not connect automatically." : { "localizations" : { "fa" : { @@ -8608,6 +9003,34 @@ } } }, + "Would you like to enable automatic update checking from **GitHub**?" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "آیا می‌خواهید بررسی خودکار به‌روزرسانی از **GitHub** را فعال کنید؟" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить автоматическую проверку обновлений через **GitHub**?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "是否启用从 **GitHub** 自动检查更新?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "是否啟用從 **GitHub** 自動檢查更新?" + } + } + } + }, "Wrong application location" : { "localizations" : { "fa" : { diff --git a/MacLibrary/MacApplication.swift b/MacLibrary/MacApplication.swift index 6267300..d6bcfe1 100644 --- a/MacLibrary/MacApplication.swift +++ b/MacLibrary/MacApplication.swift @@ -9,7 +9,9 @@ public struct MacApplication: Scene { @State private var showMenuBarExtra = false @State private var menuBarExtraSpeedMode = MenuBarExtraSpeedMode.enabled.rawValue @StateObject private var environments = ExtensionEnvironments() + @StateObject private var updateManager = UpdateManager() @State private var statusBarController: StatusBarController? + @State private var showUpdateCheckPrompt = false private let profileEditor: (Binding, Bool) -> AnyView = { text, isEditable in AnyView(ProfileEditorWrapperView(text: text, isEditable: isEditable)) @@ -27,6 +29,32 @@ public struct MacApplication: Scene { .environment(\.showMenuBarExtra, $showMenuBarExtra) .environment(\.menuBarExtraSpeedMode, $menuBarExtraSpeedMode) .environmentObject(environments) + .environmentObject(updateManager) + .alert( + "Check Update", + isPresented: $showUpdateCheckPrompt + ) { + Button("Ok") { + Task { + await SharedPreferences.updateCheckPrompted.set(true) + await SharedPreferences.checkUpdateEnabled.set(true) + await runAutomaticUpdateCheck() + } + } + Button("No, thanks", role: .cancel) { + Task { + await SharedPreferences.updateCheckPrompted.set(true) + } + } + } message: { + Text("Would you like to enable automatic update checking from **GitHub**?") + } + .sheet(isPresented: $updateManager.isUpdateSheetPresented, onDismiss: { + updateManager.dismissUpdateSheet() + }) { + UpdateSheet(updateManager: updateManager) + .environmentObject(environments) + } .onChangeCompat(of: showMenuBarExtra) { newValue in statusBarController?.updateVisibility(newValue) Task { @@ -81,6 +109,49 @@ public struct MacApplication: Scene { statusBarController = StatusBarController(environments: environments) statusBarController?.updateVisibility(showMenuBarExtra) statusBarController?.updateSpeedMode(menuBarExtraSpeedMode) + + if Variant.useSystemExtension { + let shouldPresentCachedUpdate = await updateManager.loadCachedUpdate() + let checkUpdateEnabled = await SharedPreferences.checkUpdateEnabled.get() + let prompted = await SharedPreferences.updateCheckPrompted.get() + if !prompted { + showUpdateCheckPrompt = true + } else if checkUpdateEnabled { + if shouldPresentCachedUpdate { + await presentUpdateSheet() + } + Task { + await runAutomaticUpdateCheck() + } + } + } + } + + private func runAutomaticUpdateCheck() async { + let shouldPresent = await updateManager.checkForUpdate(presentIfFound: true, showsAlertOnFailure: false) + if shouldPresent { + await presentUpdateSheet() + } + } + + private func presentUpdateSheet() async { + guard updateManager.updateInfo != nil else { return } + await openMainWindowIfNeeded() + await updateManager.showUpdateSheet() + } + + private func openMainWindowIfNeeded() async { + let mainWindow = NSApp.windows.first(where: { $0.identifier?.rawValue == "main" }) + let shouldActivate = NSApp.activationPolicy() == .accessory || !(mainWindow?.isVisible ?? false) || !NSApp.isActive + guard shouldActivate else { return } + + NSApp.setActivationPolicy(.regular) + mainWindow?.makeKeyAndOrderFront(nil) + if let dockApp = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.dock").first { + dockApp.activate() + try? await Task.sleep(for: .milliseconds(100)) + } + NSApp.activate(ignoringOtherApps: true) } private func hide(closeApp: Bool) { diff --git a/sing-box.xcodeproj/project.pbxproj b/sing-box.xcodeproj/project.pbxproj index 6b9fdd1..f4ca277 100644 --- a/sing-box.xcodeproj/project.pbxproj +++ b/sing-box.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 3A3AA7FF2A4EFDB3002F78AB /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; }; 3A3DEBEB2A4FFE2D00373BF4 /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A3DEBE62A4FFA6000373BF4 /* AppIntents.framework */; }; 3A4A020D2B53E3DC004EFB87 /* QRCode in Frameworks */ = {isa = PBXBuildFile; productRef = 3A4A020C2B53E3DC004EFB87 /* QRCode */; }; + 3A4CA8CC2F75381F009C36CA /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 3A4CA8CB2F75381F009C36CA /* MarkdownUI */; }; 3A4EAD1B2A4FEB02005435B3 /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; }; 3A4EAD372A4FEC20005435B3 /* ApplicationLibrary.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A4EAD102A4FEAE6005435B3 /* ApplicationLibrary.framework */; }; 3A4FB1572A73467F007012B9 /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; }; @@ -634,6 +635,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 3A4CA8CC2F75381F009C36CA /* MarkdownUI in Frameworks */, 3A4EAD1B2A4FEB02005435B3 /* Library.framework in Frameworks */, 3A4A020D2B53E3DC004EFB87 /* QRCode in Frameworks */, ); @@ -922,6 +924,7 @@ name = ApplicationLibrary; packageProductDependencies = ( 3A4A020C2B53E3DC004EFB87 /* QRCode */, + 3A4CA8CB2F75381F009C36CA /* MarkdownUI */, ); productName = ApplicationLibrary; productReference = 3A4EAD102A4FEAE6005435B3 /* ApplicationLibrary.framework */; @@ -1368,6 +1371,7 @@ 3A2E87F02ED5A91100644195 /* XCLocalSwiftPackageReference "Frameworks/Runestone" */, 3A2E87F92ED5ABCF00644195 /* XCLocalSwiftPackageReference "Frameworks/TreeSitterJSON5" */, 3ACE5E012EE1A91100644196 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, + 3A4CA8CA2F75381F009C36CA /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, ); productRefGroup = 3AEC20C72A45991900A63465 /* Products */; projectDirPath = ""; @@ -3342,6 +3346,14 @@ minimumVersion = 17.0.0; }; }; + 3A4CA8CA2F75381F009C36CA /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.4.1; + }; + }; 3A7E90362A46778E00D53052 /* XCRemoteSwiftPackageReference "BinaryCodable" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/christophhagen/BinaryCodable"; @@ -3381,6 +3393,11 @@ package = 3A4A020B2B53E3DC004EFB87 /* XCRemoteSwiftPackageReference "qrcode" */; productName = QRCode; }; + 3A4CA8CB2F75381F009C36CA /* MarkdownUI */ = { + isa = XCSwiftPackageProductDependency; + package = 3A4CA8CA2F75381F009C36CA /* XCRemoteSwiftPackageReference "swift-markdown-ui" */; + productName = MarkdownUI; + }; 3A7E90372A46778E00D53052 /* BinaryCodable */ = { isa = XCSwiftPackageProductDependency; package = 3A7E90362A46778E00D53052 /* XCRemoteSwiftPackageReference "BinaryCodable" */; diff --git a/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f858f4f..00d86e7 100644 --- a/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "1e51842f566cb5b99c8ac7575b8910b65467e19dda2da9562c06bf365e78c2e9", + "originHash" : "8da21fbbe117848311fb8e66b6d976022dc80720a4c3ace8c3db96e20d68e580", "pins" : [ { "identity" : "binarycodable", @@ -55,6 +55,15 @@ "version" : "6.29.3" } }, + { + "identity" : "networkimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/NetworkImage", + "state" : { + "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", + "version" : "6.0.1" + } + }, { "identity" : "qrcode", "kind" : "remoteSourceControl", @@ -73,6 +82,15 @@ "version" : "2.0.0" } }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark", + "state" : { + "revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe", + "version" : "0.7.1" + } + }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", @@ -82,6 +100,15 @@ "version" : "1.3.0" } }, + { + "identity" : "swift-markdown-ui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swift-markdown-ui", + "state" : { + "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", + "version" : "2.4.1" + } + }, { "identity" : "swift-qrcode-generator", "kind" : "remoteSourceControl", From ffbf405b5223c7537ac70f52d8cd39258b67942c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 30 Mar 2026 23:03:15 +0800 Subject: [PATCH 10/37] Bump version 1.13.5 --- sing-box.xcodeproj/project.pbxproj | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/sing-box.xcodeproj/project.pbxproj b/sing-box.xcodeproj/project.pbxproj index f4ca277..a1e8077 100644 --- a/sing-box.xcodeproj/project.pbxproj +++ b/sing-box.xcodeproj/project.pbxproj @@ -2239,7 +2239,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.4"; + MARKETING_VERSION = "1.13.5"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; SDKROOT = appletvos; @@ -2273,7 +2273,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.4"; + MARKETING_VERSION = "1.13.5"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; SDKROOT = appletvos; @@ -2666,7 +2666,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.4"; + MARKETING_VERSION = "1.13.5"; OTHER_CODE_SIGN_FLAGS = "--deep"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2708,7 +2708,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.4"; + MARKETING_VERSION = "1.13.5"; OTHER_CODE_SIGN_FLAGS = "--deep"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2748,7 +2748,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.4"; + MARKETING_VERSION = "1.13.5"; OTHER_CODE_SIGN_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2787,7 +2787,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.4"; + MARKETING_VERSION = "1.13.5"; OTHER_CODE_SIGN_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2929,7 +2929,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.4"; + MARKETING_VERSION = "1.13.5"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.system; PRODUCT_NAME = "$(inherited)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2977,7 +2977,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.4"; + MARKETING_VERSION = "1.13.5"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.system; PRODUCT_NAME = "$(inherited)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3020,7 +3020,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.4"; + MARKETING_VERSION = "1.13.5"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.standalone; PRODUCT_NAME = SFM; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3062,7 +3062,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.4"; + MARKETING_VERSION = "1.13.5"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.standalone; PRODUCT_NAME = SFM; PROVISIONING_PROFILE_SPECIFIER = ""; From 0354289c575653d896754dd74d1198f469b93949 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 14 Apr 2026 14:32:26 +0800 Subject: [PATCH 11/37] Fix build --- Frameworks/Runestone | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Frameworks/Runestone b/Frameworks/Runestone index 1e126c3..baeb3cb 160000 --- a/Frameworks/Runestone +++ b/Frameworks/Runestone @@ -1 +1 @@ -Subproject commit 1e126c3f316184c318c74c30803c4c098c3afbd8 +Subproject commit baeb3cbf8332d26b4f7b4c175a01f288ebbf3e7f From ad7434d6769382a9b9e6919fb925840f5cabe43e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 14 Apr 2026 14:32:53 +0800 Subject: [PATCH 12/37] Bump version 1.13.8 --- sing-box.xcodeproj/project.pbxproj | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/sing-box.xcodeproj/project.pbxproj b/sing-box.xcodeproj/project.pbxproj index a1e8077..3f7df49 100644 --- a/sing-box.xcodeproj/project.pbxproj +++ b/sing-box.xcodeproj/project.pbxproj @@ -2239,7 +2239,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.5"; + MARKETING_VERSION = "1.13.8"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; SDKROOT = appletvos; @@ -2273,7 +2273,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.5"; + MARKETING_VERSION = "1.13.8"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; SDKROOT = appletvos; @@ -2666,7 +2666,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.5"; + MARKETING_VERSION = "1.13.8"; OTHER_CODE_SIGN_FLAGS = "--deep"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2708,7 +2708,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.5"; + MARKETING_VERSION = "1.13.8"; OTHER_CODE_SIGN_FLAGS = "--deep"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2748,7 +2748,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.5"; + MARKETING_VERSION = "1.13.8"; OTHER_CODE_SIGN_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2787,7 +2787,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.5"; + MARKETING_VERSION = "1.13.8"; OTHER_CODE_SIGN_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2929,7 +2929,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.5"; + MARKETING_VERSION = "1.13.8"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.system; PRODUCT_NAME = "$(inherited)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2977,7 +2977,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.5"; + MARKETING_VERSION = "1.13.8"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.system; PRODUCT_NAME = "$(inherited)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3020,7 +3020,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.5"; + MARKETING_VERSION = "1.13.8"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.standalone; PRODUCT_NAME = SFM; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3062,7 +3062,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.5"; + MARKETING_VERSION = "1.13.8"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.standalone; PRODUCT_NAME = SFM; PROVISIONING_PROFILE_SPECIFIER = ""; From 8c0d910685be2fd83d83e8bdbf7356d2d563f14a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 20 Apr 2026 05:24:31 +0800 Subject: [PATCH 13/37] Fix JSON editor not updating on appearance change on macOS --- MacLibrary/CodeEditTextView.swift | 63 ++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/MacLibrary/CodeEditTextView.swift b/MacLibrary/CodeEditTextView.swift index dcfcbde..a35234d 100644 --- a/MacLibrary/CodeEditTextView.swift +++ b/MacLibrary/CodeEditTextView.swift @@ -40,31 +40,40 @@ private extension NSColor { } } -private func makeTheme() -> EditorTheme { - EditorTheme( - text: .init(color: NSColor.labelColor.forEditor), - insertionPoint: NSColor.labelColor.forEditor, - invisibles: .init(color: NSColor.tertiaryLabelColor.forEditor), - background: NSColor.textBackgroundColor.forEditor, - lineHighlight: NSColor.quaternaryLabelColor.forEditor, - selection: NSColor.selectedTextBackgroundColor.forEditor, - keywords: .init(color: NSColor.systemPurple.forEditor), - commands: .init(color: NSColor.systemCyan.forEditor), - types: .init(color: NSColor.systemCyan.forEditor), - attributes: .init(color: NSColor.systemCyan.forEditor), - variables: .init(color: NSColor.labelColor.forEditor), - values: .init(color: NSColor.systemOrange.forEditor), - numbers: .init(color: NSColor.systemOrange.forEditor), - strings: .init(color: NSColor.systemGreen.forEditor), - characters: .init(color: NSColor.systemGreen.forEditor), - comments: .init(color: NSColor.secondaryLabelColor.forEditor) - ) +private func makeTheme(for colorScheme: ColorScheme) -> EditorTheme { + var theme: EditorTheme! + let build = { + theme = EditorTheme( + text: .init(color: NSColor.labelColor.forEditor), + insertionPoint: NSColor.labelColor.forEditor, + invisibles: .init(color: NSColor.tertiaryLabelColor.forEditor), + background: NSColor.textBackgroundColor.forEditor, + lineHighlight: NSColor.quaternaryLabelColor.forEditor, + selection: NSColor.selectedTextBackgroundColor.forEditor, + keywords: .init(color: NSColor.systemPurple.forEditor), + commands: .init(color: NSColor.systemCyan.forEditor), + types: .init(color: NSColor.systemCyan.forEditor), + attributes: .init(color: NSColor.systemCyan.forEditor), + variables: .init(color: NSColor.labelColor.forEditor), + values: .init(color: NSColor.systemOrange.forEditor), + numbers: .init(color: NSColor.systemOrange.forEditor), + strings: .init(color: NSColor.systemGreen.forEditor), + characters: .init(color: NSColor.systemGreen.forEditor), + comments: .init(color: NSColor.secondaryLabelColor.forEditor) + ) + } + if let appearance = NSAppearance(named: colorScheme == .dark ? .darkAqua : .aqua) { + appearance.performAsCurrentDrawingAppearance(build) + } else { + build() + } + return theme } -private func makeConfiguration(isEditable: Bool) -> SourceEditorConfiguration { +private func makeConfiguration(isEditable: Bool, colorScheme: ColorScheme) -> SourceEditorConfiguration { SourceEditorConfiguration( appearance: .init( - theme: makeTheme(), + theme: makeTheme(for: colorScheme), font: .monospacedSystemFont(ofSize: 14, weight: .regular), lineHeightMultiple: 1.3, wrapLines: false @@ -85,6 +94,8 @@ struct CodeEditTextView: NSViewRepresentable { let isEditable: Bool let editorController: CodeEditEditorController? + @Environment(\.colorScheme) private var colorScheme + init(text: Binding, isEditable: Bool, editorController: CodeEditEditorController? = nil) { _text = text self.isEditable = isEditable @@ -95,7 +106,7 @@ struct CodeEditTextView: NSViewRepresentable { let controller = TextViewController( string: text, language: .json, - configuration: makeConfiguration(isEditable: isEditable), + configuration: makeConfiguration(isEditable: isEditable, colorScheme: colorScheme), cursorPositions: [] ) controller.loadView() @@ -115,6 +126,7 @@ struct CodeEditTextView: NSViewRepresentable { ]) context.coordinator.controller = controller + context.coordinator.lastColorScheme = colorScheme context.coordinator.setupObservation() editorController?.controller = controller Task { @MainActor in @@ -133,7 +145,11 @@ struct CodeEditTextView: NSViewRepresentable { controller.language = .json } if controller.configuration.behavior.isEditable != isEditable { - controller.configuration = makeConfiguration(isEditable: isEditable) + controller.configuration.behavior.isEditable = isEditable + } + if context.coordinator.lastColorScheme != colorScheme { + context.coordinator.lastColorScheme = colorScheme + controller.configuration.appearance.theme = makeTheme(for: colorScheme) } editorController?.controller = controller } @@ -144,6 +160,7 @@ struct CodeEditTextView: NSViewRepresentable { class Coordinator: NSObject { var controller: TextViewController? + var lastColorScheme: ColorScheme? @Binding var text: String private var observation: NSObjectProtocol? private weak var editorController: CodeEditEditorController? From 3d76b8924544e3b0318ac0019153b79f57974a9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 20 Apr 2026 03:12:41 +0800 Subject: [PATCH 14/37] Fix Connections card hit area on macOS --- ApplicationLibrary/Views/Connections/ConnectionView.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ApplicationLibrary/Views/Connections/ConnectionView.swift b/ApplicationLibrary/Views/Connections/ConnectionView.swift index ba98c88..c048ea2 100644 --- a/ApplicationLibrary/Views/Connections/ConnectionView.swift +++ b/ApplicationLibrary/Views/Connections/ConnectionView.swift @@ -79,10 +79,14 @@ public struct ConnectionView: View { } } .foregroundColor(.textColor) + #if !os(tvOS) + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + #endif } #if !os(tvOS) .buttonStyle(.plain) - .padding(16) .cardStyle() #endif .alert($alert) From f97fea7d4835693466e1c337342c8bcb08b3b43a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 15 Apr 2026 12:15:01 +0800 Subject: [PATCH 15/37] Fix Tools on macOS --- ApplicationLibrary/Views/Abstract/FormItem.swift | 4 +++- SFM/SFM.entitlements | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/ApplicationLibrary/Views/Abstract/FormItem.swift b/ApplicationLibrary/Views/Abstract/FormItem.swift index d4a7793..662bacb 100644 --- a/ApplicationLibrary/Views/Abstract/FormItem.swift +++ b/ApplicationLibrary/Views/Abstract/FormItem.swift @@ -80,7 +80,9 @@ public func FormItem(_ title: String, @ViewBuilder content: () -> some View) -> .layoutPriority(1) } #elseif os(macOS) - content() + LabeledContent(title) { + content() + } #endif } diff --git a/SFM/SFM.entitlements b/SFM/SFM.entitlements index 327a76d..6eaffc7 100644 --- a/SFM/SFM.entitlements +++ b/SFM/SFM.entitlements @@ -30,5 +30,7 @@ com.apple.security.device.camera + com.apple.security.network.server + From 66396e6add5c0b651fb4c72ce92f0e815fe07800 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 17 Apr 2026 14:18:15 +0800 Subject: [PATCH 16/37] Fix duplicate label in FormItem on macOS --- ApplicationLibrary/Views/Abstract/FormItem.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/ApplicationLibrary/Views/Abstract/FormItem.swift b/ApplicationLibrary/Views/Abstract/FormItem.swift index 662bacb..49e4b67 100644 --- a/ApplicationLibrary/Views/Abstract/FormItem.swift +++ b/ApplicationLibrary/Views/Abstract/FormItem.swift @@ -82,6 +82,7 @@ public func FormItem(_ title: String, @ViewBuilder content: () -> some View) -> #elseif os(macOS) LabeledContent(title) { content() + .labelsHidden() } #endif } From 142e82034efccb5bd5ffc975df186bb207249975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 18 Apr 2026 18:07:09 +0800 Subject: [PATCH 17/37] Hide Documentation button in deprecated warning on tvOS --- .../Views/Abstract/GlobalChecksModifier.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ApplicationLibrary/Views/Abstract/GlobalChecksModifier.swift b/ApplicationLibrary/Views/Abstract/GlobalChecksModifier.swift index fb29f0b..648dc5b 100644 --- a/ApplicationLibrary/Views/Abstract/GlobalChecksModifier.swift +++ b/ApplicationLibrary/Views/Abstract/GlobalChecksModifier.swift @@ -192,6 +192,15 @@ public struct GlobalChecksModifier: ViewModifier { } } + #if os(tvOS) + var state = AlertState( + title: String(localized: "Deprecated Warning"), + message: report.message(), + dismissButton: .cancel(String(localized: "Ok")) + ) + state.onDismiss = continueChain + alert = state + #else if report.migrationLink.isEmpty { var state = AlertState( title: String(localized: "Deprecated Warning"), @@ -211,6 +220,7 @@ public struct GlobalChecksModifier: ViewModifier { onDismiss: continueChain ) } + #endif } #if os(macOS) From a09170256d7f40e6a9dc99ea2798766ebd051a45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 20 Apr 2026 03:31:36 +0800 Subject: [PATCH 18/37] Minor fixes --- .../Views/Abstract/FormItem.swift | 34 ++++++++++------ .../Views/Abstract/GlobalChecksModifier.swift | 40 +++++++++---------- .../Abstract/NavigationSheetContent.swift | 4 +- .../Views/Setting/CoreView.swift | 4 +- Library/Database/SharedPreferences.swift | 6 --- Library/Shared/Variant.swift | 8 +++- MacLibrary/ApplicationDelegate.swift | 4 +- SFI/MainView.swift | 21 +++++----- .../StandaloneApplicationDelegate.swift | 1 - sing-box.xcodeproj/project.pbxproj | 8 ++-- 10 files changed, 69 insertions(+), 61 deletions(-) diff --git a/ApplicationLibrary/Views/Abstract/FormItem.swift b/ApplicationLibrary/Views/Abstract/FormItem.swift index 49e4b67..50f56b9 100644 --- a/ApplicationLibrary/Views/Abstract/FormItem.swift +++ b/ApplicationLibrary/Views/Abstract/FormItem.swift @@ -87,20 +87,26 @@ public func FormItem(_ title: String, @ViewBuilder content: () -> some View) -> #endif } -public func FormToggle(_ titleKey: LocalizedStringKey, _ subtitleKey: LocalizedStringKey, _ isOn: Binding, _ action: @escaping (_ newValue: Bool) async -> Void) -> some View { +public func FormToggle(_ titleKey: LocalizedStringKey, _ subtitleKey: LocalizedStringKey, _ isOn: Binding, header: LocalizedStringKey? = nil, _ action: @escaping (_ newValue: Bool) async -> Void) -> some View { #if os(macOS) - Toggle(isOn: isOn) { - VStack(alignment: .leading) { - Text(titleKey) - Spacer() - Text(subtitleKey) - .font(.subheadline) - .foregroundStyle(.secondary) + Section { + Toggle(isOn: isOn) { + VStack(alignment: .leading) { + Text(titleKey) + Spacer() + Text(subtitleKey) + .font(.subheadline) + .foregroundStyle(.secondary) + } } - } - .onChangeCompat(of: isOn.wrappedValue) { newValue in - Task { - await action(newValue) + .onChangeCompat(of: isOn.wrappedValue) { newValue in + Task { + await action(newValue) + } + } + } header: { + if let header { + Text(header) } } #else @@ -111,6 +117,10 @@ public func FormToggle(_ titleKey: LocalizedStringKey, _ subtitleKey: LocalizedS await action(newValue) } } + } header: { + if let header { + Text(header) + } } footer: { Text(subtitleKey) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/ApplicationLibrary/Views/Abstract/GlobalChecksModifier.swift b/ApplicationLibrary/Views/Abstract/GlobalChecksModifier.swift index 648dc5b..340529f 100644 --- a/ApplicationLibrary/Views/Abstract/GlobalChecksModifier.swift +++ b/ApplicationLibrary/Views/Abstract/GlobalChecksModifier.swift @@ -193,15 +193,6 @@ public struct GlobalChecksModifier: ViewModifier { } #if os(tvOS) - var state = AlertState( - title: String(localized: "Deprecated Warning"), - message: report.message(), - dismissButton: .cancel(String(localized: "Ok")) - ) - state.onDismiss = continueChain - alert = state - #else - if report.migrationLink.isEmpty { var state = AlertState( title: String(localized: "Deprecated Warning"), message: report.message(), @@ -209,17 +200,26 @@ public struct GlobalChecksModifier: ViewModifier { ) state.onDismiss = continueChain alert = state - } else { - alert = AlertState( - title: String(localized: "Deprecated Warning"), - message: report.message(), - primaryButton: .default(String(localized: "Documentation")) { - openURL(URL(string: report.migrationLink)!) - }, - secondaryButton: .cancel(String(localized: "Ok")), - onDismiss: continueChain - ) - } + #else + if report.migrationLink.isEmpty { + var state = AlertState( + title: String(localized: "Deprecated Warning"), + message: report.message(), + dismissButton: .cancel(String(localized: "Ok")) + ) + state.onDismiss = continueChain + alert = state + } else { + alert = AlertState( + title: String(localized: "Deprecated Warning"), + message: report.message(), + primaryButton: .default(String(localized: "Documentation")) { + openURL(URL(string: report.migrationLink)!) + }, + secondaryButton: .cancel(String(localized: "Ok")), + onDismiss: continueChain + ) + } #endif } diff --git a/ApplicationLibrary/Views/Abstract/NavigationSheetContent.swift b/ApplicationLibrary/Views/Abstract/NavigationSheetContent.swift index c086787..fb0c618 100644 --- a/ApplicationLibrary/Views/Abstract/NavigationSheetContent.swift +++ b/ApplicationLibrary/Views/Abstract/NavigationSheetContent.swift @@ -3,10 +3,10 @@ import SwiftUI @MainActor public struct SheetContent: View { - private let title: String + private let title: LocalizedStringKey private let content: Content - public init(_ title: String, @ViewBuilder content: () -> Content) { + public init(_ title: LocalizedStringKey, @ViewBuilder content: () -> Content) { self.title = title self.content = content() } diff --git a/ApplicationLibrary/Views/Setting/CoreView.swift b/ApplicationLibrary/Views/Setting/CoreView.swift index 86afc0d..4fa6676 100644 --- a/ApplicationLibrary/Views/Setting/CoreView.swift +++ b/ApplicationLibrary/Views/Setting/CoreView.swift @@ -60,8 +60,8 @@ public struct CoreView: View { } if Variant.isBeta { - Section {} - FormToggle("Disable Deprecated Warnings", "Do not show warnings about usages of deprecated features.", $disableDeprecatedWarnings) { newValue in + FormToggle("Disable Deprecated Warnings", "Do not show warnings about usages of deprecated features.", $disableDeprecatedWarnings, header: "Beta Settings") { + newValue in await SharedPreferences.disableDeprecatedWarnings.set(newValue) } } diff --git a/Library/Database/SharedPreferences.swift b/Library/Database/SharedPreferences.swift index 277a272..173f7f2 100644 --- a/Library/Database/SharedPreferences.swift +++ b/Library/Database/SharedPreferences.swift @@ -139,10 +139,4 @@ public enum SharedPreferences { public static let enabledDashboardCards = Preference<[String]>("enabled_dashboard_cards", defaultValue: []) public static let dashboardCardOrder = Preference<[String]>("dashboard_card_order", defaultValue: []) - - #if DEBUG - public static let inDebug = true - #else - public static let inDebug = false - #endif } diff --git a/Library/Shared/Variant.swift b/Library/Shared/Variant.swift index 0a35c58..dd7d620 100644 --- a/Library/Shared/Variant.swift +++ b/Library/Shared/Variant.swift @@ -16,7 +16,13 @@ public enum Variant { public static let applicationName = "SFT" #endif - public static var isBeta = LibboxVersion().contains("-") + public static let isBeta = LibboxVersion().contains("-") + + #if DEBUG + public static let inDebug = true + #else + public static let inDebug = false + #endif #if os(iOS) public static var debugNoIOS26 = false diff --git a/MacLibrary/ApplicationDelegate.swift b/MacLibrary/ApplicationDelegate.swift index 17135f1..db2a949 100644 --- a/MacLibrary/ApplicationDelegate.swift +++ b/MacLibrary/ApplicationDelegate.swift @@ -32,7 +32,7 @@ open class ApplicationDelegate: NSObject, NSApplicationDelegate, UNUserNotificat event?.eventID == kAEOpenApplication && event?.paramDescriptor(forKeyword: keyAEPropData)?.enumCodeValue == keyAELaunchedAsLogInItem let shouldShowWindow = Variant.screenshotMode || - SharedPreferences.inDebug || + Variant.inDebug || !launchedAsLogInItem || !SharedPreferences.showMenuBarExtra.getBlocking() || !SharedPreferences.menuBarExtraInBackground.getBlocking() @@ -74,7 +74,7 @@ open class ApplicationDelegate: NSObject, NSApplicationDelegate, UNUserNotificat } public func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { - SharedPreferences.inDebug || !SharedPreferences.menuBarExtraInBackground.getBlocking() + Variant.inDebug || !SharedPreferences.menuBarExtraInBackground.getBlocking() } public func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows flag: Bool) -> Bool { diff --git a/SFI/MainView.swift b/SFI/MainView.swift index cc7b429..15e9544 100644 --- a/SFI/MainView.swift +++ b/SFI/MainView.swift @@ -195,29 +195,28 @@ struct MainView: View { @ObservedObject var profile: ExtensionProfile var body: some View { - Text(statusText) + statusText .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(1) .fixedSize() } - private var statusText: String { + private var statusText: Text { switch profile.status { - case .invalid: - return String(localized: "Invalid") case .disconnected: - return String(localized: "Stopped") + return Text("Stopped") case .connecting: - return String(localized: "Starting") + return Text("Starting") case .connected: - return String(localized: "Started") + return Text("Started") case .reasserting: - return String(localized: "Reasserting") + return Text("Reasserting") case .disconnecting: - return String(localized: "Stopping") - @unknown default: - return String(localized: "Unknown") + return Text("Stopping") + default: + return Text("Unknown") + .foregroundColor(.red) } } } diff --git a/SFM.System/StandaloneApplicationDelegate.swift b/SFM.System/StandaloneApplicationDelegate.swift index d3cbf54..6b833b0 100644 --- a/SFM.System/StandaloneApplicationDelegate.swift +++ b/SFM.System/StandaloneApplicationDelegate.swift @@ -7,7 +7,6 @@ import MacLibrary class StandaloneApplicationDelegate: ApplicationDelegate { func applicationWillFinishLaunching(_: Notification) { Variant.useSystemExtension = true - Variant.isBeta = false LibboxSetXPCDialer(CommandXPCDialer.shared) UserServiceEndpointPublisher.shared.start() Task { diff --git a/sing-box.xcodeproj/project.pbxproj b/sing-box.xcodeproj/project.pbxproj index 3f7df49..43a1aef 100644 --- a/sing-box.xcodeproj/project.pbxproj +++ b/sing-box.xcodeproj/project.pbxproj @@ -2672,7 +2672,7 @@ PRODUCT_NAME = "sing-box"; PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = iphoneos; - SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -2714,7 +2714,7 @@ PRODUCT_NAME = "sing-box"; PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = iphoneos; - SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; @@ -2755,7 +2755,7 @@ PROVISIONING_PROFILE_SPECIFIER = ""; REEXPORTED_LIBRARY_PATHS = ""; SDKROOT = macosx; - SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; }; name = Debug; @@ -2794,7 +2794,7 @@ PROVISIONING_PROFILE_SPECIFIER = ""; REEXPORTED_LIBRARY_PATHS = ""; SDKROOT = macosx; - SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; }; name = Release; From d461714de829b37c133867523e299e9c2e52b291 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 9 Apr 2026 22:17:12 +0800 Subject: [PATCH 19/37] Fix alert copy --- Library/Network/ExtensionEnvironments.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Library/Network/ExtensionEnvironments.swift b/Library/Network/ExtensionEnvironments.swift index d099d2e..a73b2b5 100644 --- a/Library/Network/ExtensionEnvironments.swift +++ b/Library/Network/ExtensionEnvironments.swift @@ -84,8 +84,15 @@ public struct AlertState: Equatable { public init(errorMessage: String, dismiss: (() -> Void)? = nil) { title = String(localized: "Error") message = errorMessage - primaryButton = .default(String(localized: "Ok"), action: dismiss) - secondaryButton = nil + if Self.supportsErrorCopy { + primaryButton = .default(String(localized: "Copy")) { + Self.copyErrorMessage(errorMessage) + } + secondaryButton = .default(String(localized: "Ok"), action: dismiss) + } else { + primaryButton = .default(String(localized: "Ok"), action: dismiss) + secondaryButton = nil + } onDismiss = nil } From 5318c1b84c5f1fc20f31f97714925ca330bd4064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 20 Apr 2026 05:45:36 +0800 Subject: [PATCH 20/37] Fix thread-safety crash in ProfileUpdateTask Snapshot @Published properties before async suspension points to prevent SIGBUS from corrupted pointers due to concurrent access. --- ApplicationLibrary/Service/ProfileUpdateTask.swift | 5 +++-- Library/Database/Profile+Update.swift | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/ApplicationLibrary/Service/ProfileUpdateTask.swift b/ApplicationLibrary/Service/ProfileUpdateTask.swift index 1b649b1..7bd47fb 100644 --- a/ApplicationLibrary/Service/ProfileUpdateTask.swift +++ b/ApplicationLibrary/Service/ProfileUpdateTask.swift @@ -52,14 +52,15 @@ public enum ProfileUpdateTask { static func updateProfiles(_ profiles: [Profile]) async -> Bool { var success = true for profile in profiles { + let profileName = profile.name if profile.lastUpdated! > Date(timeIntervalSinceNow: -profile.autoUpdateIntervalOrDefault) { continue } do { try await profile.updateRemoteProfile() - NSLog("Updated profile \(profile.name)") + NSLog("Updated profile %@", profileName) } catch { - NSLog("Update profile \(profile.name) failed: \(error.localizedDescription)") + NSLog("Update profile %@ failed: %@", profileName, error.localizedDescription) success = false } } diff --git a/Library/Database/Profile+Update.swift b/Library/Database/Profile+Update.swift index b2b91f3..f130666 100644 --- a/Library/Database/Profile+Update.swift +++ b/Library/Database/Profile+Update.swift @@ -7,7 +7,8 @@ public extension Profile { if type != .remote { return } - let remoteContent = try await HTTPClient.getStringAsync(remoteURL) + let url = remoteURL + let remoteContent = try await HTTPClient.getStringAsync(url) try await BlockingIO.run { var error: NSError? LibboxCheckConfig(remoteContent, &error) @@ -15,7 +16,9 @@ public extension Profile { throw error } } - lastUpdated = Date() + await MainActor.run { + lastUpdated = Date() + } try await ProfileManager.update(self) do { let oldContent = try await readAsync() From 43a2bf467fb31e303b7dc988d87ad3ea77608405 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 20 Apr 2026 09:00:39 +0800 Subject: [PATCH 21/37] Bump version 1.13.9 --- sing-box.xcodeproj/project.pbxproj | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/sing-box.xcodeproj/project.pbxproj b/sing-box.xcodeproj/project.pbxproj index 43a1aef..72a08bc 100644 --- a/sing-box.xcodeproj/project.pbxproj +++ b/sing-box.xcodeproj/project.pbxproj @@ -2239,7 +2239,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.8"; + MARKETING_VERSION = "1.13.9"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; SDKROOT = appletvos; @@ -2273,7 +2273,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.8"; + MARKETING_VERSION = "1.13.9"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; SDKROOT = appletvos; @@ -2666,7 +2666,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.8"; + MARKETING_VERSION = "1.13.9"; OTHER_CODE_SIGN_FLAGS = "--deep"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2708,7 +2708,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.8"; + MARKETING_VERSION = "1.13.9"; OTHER_CODE_SIGN_FLAGS = "--deep"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2748,7 +2748,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.8"; + MARKETING_VERSION = "1.13.9"; OTHER_CODE_SIGN_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2787,7 +2787,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.8"; + MARKETING_VERSION = "1.13.9"; OTHER_CODE_SIGN_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2929,7 +2929,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.8"; + MARKETING_VERSION = "1.13.9"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.system; PRODUCT_NAME = "$(inherited)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2977,7 +2977,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.8"; + MARKETING_VERSION = "1.13.9"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.system; PRODUCT_NAME = "$(inherited)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3020,7 +3020,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.8"; + MARKETING_VERSION = "1.13.9"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.standalone; PRODUCT_NAME = SFM; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3062,7 +3062,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.8"; + MARKETING_VERSION = "1.13.9"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.standalone; PRODUCT_NAME = SFM; PROVISIONING_PROFILE_SPECIFIER = ""; From ba4ef994bdd71d236b585bf3f9eca96e46730157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 22 Apr 2026 13:35:22 +0800 Subject: [PATCH 22/37] Fix system extension update error --- SFM.System/StandaloneApplicationDelegate.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SFM.System/StandaloneApplicationDelegate.swift b/SFM.System/StandaloneApplicationDelegate.swift index 6b833b0..8f4b902 100644 --- a/SFM.System/StandaloneApplicationDelegate.swift +++ b/SFM.System/StandaloneApplicationDelegate.swift @@ -8,10 +8,10 @@ class StandaloneApplicationDelegate: ApplicationDelegate { func applicationWillFinishLaunching(_: Notification) { Variant.useSystemExtension = true LibboxSetXPCDialer(CommandXPCDialer.shared) - UserServiceEndpointPublisher.shared.start() Task { await setupSystemExtension() await HelperServiceManager.updateRootHelperIfNeeded() + UserServiceEndpointPublisher.shared.start() } } From 376f927ccd943e369c3af0b3e845418137251524 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 21 Apr 2026 17:34:31 +0800 Subject: [PATCH 23/37] Bump version 1.13.10 --- sing-box.xcodeproj/project.pbxproj | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/sing-box.xcodeproj/project.pbxproj b/sing-box.xcodeproj/project.pbxproj index 72a08bc..6d786c0 100644 --- a/sing-box.xcodeproj/project.pbxproj +++ b/sing-box.xcodeproj/project.pbxproj @@ -2239,7 +2239,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.9"; + MARKETING_VERSION = "1.13.10"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; SDKROOT = appletvos; @@ -2273,7 +2273,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.9"; + MARKETING_VERSION = "1.13.10"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; SDKROOT = appletvos; @@ -2666,7 +2666,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.9"; + MARKETING_VERSION = "1.13.10"; OTHER_CODE_SIGN_FLAGS = "--deep"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2708,7 +2708,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.9"; + MARKETING_VERSION = "1.13.10"; OTHER_CODE_SIGN_FLAGS = "--deep"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2748,7 +2748,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.9"; + MARKETING_VERSION = "1.13.10"; OTHER_CODE_SIGN_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2787,7 +2787,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.9"; + MARKETING_VERSION = "1.13.10"; OTHER_CODE_SIGN_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2929,7 +2929,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.9"; + MARKETING_VERSION = "1.13.10"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.system; PRODUCT_NAME = "$(inherited)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2977,7 +2977,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.9"; + MARKETING_VERSION = "1.13.10"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.system; PRODUCT_NAME = "$(inherited)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3020,7 +3020,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.9"; + MARKETING_VERSION = "1.13.10"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.standalone; PRODUCT_NAME = SFM; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3062,7 +3062,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.9"; + MARKETING_VERSION = "1.13.10"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.standalone; PRODUCT_NAME = SFM; PROVISIONING_PROFILE_SPECIFIER = ""; From 92282dee7465eada4d37e4bef50f050981abd5f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 23 Apr 2026 07:30:10 +0800 Subject: [PATCH 24/37] Fix system extension update error again --- ApplicationLibrary/Service/UpdateManager.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ApplicationLibrary/Service/UpdateManager.swift b/ApplicationLibrary/Service/UpdateManager.swift index 67d4aa8..4c5d0cd 100644 --- a/ApplicationLibrary/Service/UpdateManager.swift +++ b/ApplicationLibrary/Service/UpdateManager.swift @@ -93,9 +93,6 @@ } let authRef = try PKGInstaller.authorize() - try await Task.detached { - try PKGInstaller.install(pkgPath: pkgURL.path, authorization: authRef) - }.value var profile = environments.extensionProfile if profile == nil { @@ -111,6 +108,10 @@ } } + try await Task.detached { + try PKGInstaller.install(pkgPath: pkgURL.path, authorization: authRef) + }.value + do { try PKGInstaller.scheduleInstalledApplicationRelaunch() } catch { From e5d6ab4c77a2fab310633e440a84f24e6ec82b1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 23 Apr 2026 07:26:45 +0800 Subject: [PATCH 25/37] Bump version 1.13.11 --- sing-box.xcodeproj/project.pbxproj | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/sing-box.xcodeproj/project.pbxproj b/sing-box.xcodeproj/project.pbxproj index 6d786c0..eb42e0f 100644 --- a/sing-box.xcodeproj/project.pbxproj +++ b/sing-box.xcodeproj/project.pbxproj @@ -2239,7 +2239,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.10"; + MARKETING_VERSION = "1.13.11"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; SDKROOT = appletvos; @@ -2273,7 +2273,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.10"; + MARKETING_VERSION = "1.13.11"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; SDKROOT = appletvos; @@ -2666,7 +2666,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.10"; + MARKETING_VERSION = "1.13.11"; OTHER_CODE_SIGN_FLAGS = "--deep"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2708,7 +2708,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.10"; + MARKETING_VERSION = "1.13.11"; OTHER_CODE_SIGN_FLAGS = "--deep"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2748,7 +2748,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.10"; + MARKETING_VERSION = "1.13.11"; OTHER_CODE_SIGN_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2787,7 +2787,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.10"; + MARKETING_VERSION = "1.13.11"; OTHER_CODE_SIGN_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2929,7 +2929,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.10"; + MARKETING_VERSION = "1.13.11"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.system; PRODUCT_NAME = "$(inherited)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2977,7 +2977,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.10"; + MARKETING_VERSION = "1.13.11"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.system; PRODUCT_NAME = "$(inherited)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3020,7 +3020,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.10"; + MARKETING_VERSION = "1.13.11"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.standalone; PRODUCT_NAME = SFM; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3062,7 +3062,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.10"; + MARKETING_VERSION = "1.13.11"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.standalone; PRODUCT_NAME = SFM; PROVISIONING_PROFILE_SPECIFIER = ""; From 73180f02308350619df17bc42b261828c41e4cc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 6 Mar 2026 08:49:53 +0800 Subject: [PATCH 26/37] Add support for MAC and hostname rule items --- .../Views/Setting/CoreView.swift | 2 +- HelperService/RootHelperService.swift | 266 ++++++++++++++++++ .../Network/ExtensionPlatformInterface.swift | 97 +++++++ Library/Network/RootHelperXPC.swift | 68 +++++ 4 files changed, 432 insertions(+), 1 deletion(-) diff --git a/ApplicationLibrary/Views/Setting/CoreView.swift b/ApplicationLibrary/Views/Setting/CoreView.swift index 4fa6676..bc2afc6 100644 --- a/ApplicationLibrary/Views/Setting/CoreView.swift +++ b/ApplicationLibrary/Views/Setting/CoreView.swift @@ -175,7 +175,7 @@ public struct CoreView: View { self.helperUnavailable = helperUnavailable #endif self.dataSize = dataSize - self.dataSizeLoaded = true + dataSizeLoaded = true } } diff --git a/HelperService/RootHelperService.swift b/HelperService/RootHelperService.swift index 2069c64..4dbead3 100644 --- a/HelperService/RootHelperService.swift +++ b/HelperService/RootHelperService.swift @@ -1,11 +1,32 @@ import Foundation +import Libbox import Library +import Network import os private let logger = Logger(category: "RootHelper") +private class NeighborGoListener: NSObject, LibboxNeighborUpdateListenerProtocol { + private weak var service: RootHelperService? + + init(service: RootHelperService) { + self.service = service + } + + func updateNeighborTable(_ entries: (any LibboxNeighborEntryIteratorProtocol)?) { + guard let entries, let service else { return } + service.pushNeighborTable(entries: entries) + } +} + class RootHelperService: NSObject { private var listener: NSXPCListener? + private var neighborSubscription: LibboxNeighborSubscription? + private var neighborCallbackConnection: NSXPCConnection? + private var neighborLeaseWatcher: DispatchSourceFileSystemObject? + private var pathMonitor: NWPathMonitor? + private var pendingNATFlush: DispatchWorkItem? + private var tunInterfaceName: String? func start() { listener = NSXPCListener(machServiceName: AppConfiguration.rootHelperMachService) @@ -86,4 +107,249 @@ extension RootHelperService: RootHelperProtocol { func getVersion(reply: @escaping (String) -> Void) { reply(Bundle.main.version) } + + func startNeighborMonitor(callbackEndpoint: NSXPCListenerEndpoint, reply: @escaping (NSError?) -> Void) { + logger.info("startNeighborMonitor") + closeNeighborMonitorInternal() + + let callbackConnection = NSXPCConnection(listenerEndpoint: callbackEndpoint) + let listenerInterface = NSXPCInterface(with: NeighborTableListenerProtocol.self) + RootHelperXPC.configureListenerInterface(listenerInterface) + callbackConnection.remoteObjectInterface = listenerInterface + callbackConnection.resume() + neighborCallbackConnection = callbackConnection + + let goListener = NeighborGoListener(service: self) + var error: NSError? + let subscription = LibboxSubscribeNeighborTable(goListener, &error) + if let error { + logger.error("startNeighborMonitor: \(error.localizedDescription)") + callbackConnection.invalidate() + neighborCallbackConnection = nil + reply(error) + return + } + neighborSubscription = subscription + startLeaseFileWatcher() + startNATCleaner() + reply(nil) + } + + func registerMyInterface(name: String, reply: @escaping (NSError?) -> Void) { + logger.info("registerMyInterface: \(name)") + tunInterfaceName = name + flushInternetSharingNAT() + reply(nil) + } + + func closeNeighborMonitor(reply: @escaping (NSError?) -> Void) { + logger.info("closeNeighborMonitor") + closeNeighborMonitorInternal() + reply(nil) + } + + private func closeNeighborMonitorInternal() { + neighborSubscription?.close() + neighborSubscription = nil + neighborLeaseWatcher?.cancel() + neighborLeaseWatcher = nil + pendingNATFlush?.cancel() + pendingNATFlush = nil + pathMonitor?.cancel() + pathMonitor = nil + tunInterfaceName = nil + neighborCallbackConnection?.invalidate() + neighborCallbackConnection = nil + } + + func pushNeighborTable(entries: LibboxNeighborEntryIteratorProtocol) { + guard let callbackConnection = neighborCallbackConnection else { + logger.warning("pushNeighborTable: no callback connection") + return + } + guard let proxy = callbackConnection.remoteObjectProxyWithErrorHandler({ error in + logger.error("pushNeighborTable XPC error: \(error.localizedDescription)") + }) as? NeighborTableListenerProtocol else { + logger.warning("pushNeighborTable: failed to get proxy") + return + } + + let leaseIterator = LibboxReadBootpdLeases() + var leaseEntries: [NeighborEntryResult] = [] + var leaseHostnamesByMAC: [String: String] = [:] + var leaseHostnamesByIP: [String: String] = [:] + if let leaseIterator { + while leaseIterator.hasNext() { + guard let entry = leaseIterator.next() else { continue } + leaseEntries.append(NeighborEntryResult( + address: entry.address, + macAddress: entry.macAddress, + hostname: entry.hostname + )) + if !entry.hostname.isEmpty { + leaseHostnamesByMAC[entry.macAddress] = entry.hostname + leaseHostnamesByIP[entry.address] = entry.hostname + } + } + } + logger.debug("pushNeighborTable: leases=\(leaseEntries.count), hostnames=\(leaseHostnamesByMAC.count)") + + var results: [NeighborEntryResult] = [] + var seenAddresses: Set = [] + while entries.hasNext() { + guard let entry = entries.next() else { continue } + seenAddresses.insert(entry.address) + var hostname = entry.hostname + if hostname.isEmpty { + hostname = leaseHostnamesByIP[entry.address] ?? leaseHostnamesByMAC[entry.macAddress] ?? "" + } + results.append(NeighborEntryResult( + address: entry.address, + macAddress: entry.macAddress, + hostname: hostname + )) + } + for leaseEntry in leaseEntries { + if !seenAddresses.contains(leaseEntry.address) { + results.append(leaseEntry) + } + } + logger.debug("pushNeighborTable: \(results.count) entries") + proxy.updateNeighborTable(entries: results as NSArray) + } + + private func startNATCleaner() { + flushInternetSharingNAT() + let monitor = NWPathMonitor() + let queue = DispatchQueue(label: "nat-cleaner") + monitor.pathUpdateHandler = { [weak self] path in + guard let self else { return } + logger.debug("NATCleaner: path update, status=\(String(describing: path.status)), interfaces=\(path.availableInterfaces.map(\.name))") + self.pendingNATFlush?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.flushInternetSharingNAT() + } + self.pendingNATFlush = workItem + queue.asyncAfter(deadline: .now() + 2, execute: workItem) + } + monitor.start(queue: queue) + pathMonitor = monitor + } + + private func flushInternetSharingNAT() { + guard let tunName = tunInterfaceName, !tunName.isEmpty else { + logger.debug("flushInternetSharingNAT: no tun interface name set") + return + } + let anchors = [ + "com.apple.internet-sharing/shared_v4", + "com.apple.internet-sharing/shared_v6", + ] + let filter = " on \(tunName) " + for anchor in anchors { + removeNATRulesForInterface(anchor: anchor, filter: filter) + } + } + + private func removeNATRulesForInterface(anchor: String, filter: String) { + let readProcess = Process() + readProcess.executableURL = URL(fileURLWithPath: "/sbin/pfctl") + readProcess.arguments = ["-a", anchor, "-s", "nat"] + let readPipe = Pipe() + readProcess.standardOutput = readPipe + readProcess.standardError = FileHandle.nullDevice + do { + try readProcess.run() + } catch { + logger.error("removeNATRules: failed to read \(anchor): \(error.localizedDescription)") + return + } + let output = String(data: readPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + readProcess.waitUntilExit() + if readProcess.terminationStatus != 0 { + logger.warning("removeNATRules: pfctl -s nat exited with \(readProcess.terminationStatus) for \(anchor)") + return + } + if output.isEmpty { + logger.debug("removeNATRules: \(anchor) has no NAT rules") + return + } + guard output.contains(filter) else { + logger.debug("removeNATRules: \(anchor) has no rules matching \(filter)") + return + } + let lines = output.components(separatedBy: "\n") + let removed = lines.filter { $0.contains(filter) } + let remaining = lines.filter { !$0.contains(filter) }.joined(separator: "\n") + logger.info("removeNATRules: \(anchor): removing \(removed.count) rules matching \(filter), keeping \(lines.count - removed.count) rules") + for rule in removed { + logger.debug("removeNATRules: removing: \(rule)") + } + let writeProcess = Process() + writeProcess.executableURL = URL(fileURLWithPath: "/sbin/pfctl") + writeProcess.arguments = ["-a", anchor, "-N", "-f", "-"] + let writePipe = Pipe() + writePipe.fileHandleForWriting.write(remaining.data(using: .utf8) ?? Data()) + writePipe.fileHandleForWriting.closeFile() + writeProcess.standardInput = writePipe + writeProcess.standardOutput = FileHandle.nullDevice + let writeErrorPipe = Pipe() + writeProcess.standardError = writeErrorPipe + do { + try writeProcess.run() + } catch { + logger.error("removeNATRules: failed to write \(anchor): \(error.localizedDescription)") + return + } + let stderrData = writeErrorPipe.fileHandleForReading.readDataToEndOfFile() + writeProcess.waitUntilExit() + let stderrOutput = String(data: stderrData, encoding: .utf8) ?? "" + if writeProcess.terminationStatus != 0 { + logger.error("removeNATRules: pfctl -f exited with \(writeProcess.terminationStatus) for \(anchor), stderr: \(stderrOutput)") + } else { + logger.debug("removeNATRules: successfully updated \(anchor)") + } + } + + private func startLeaseFileWatcher() { + let leasePath = "/var/db/dhcpd_leases" + let fd = open(leasePath, O_EVTONLY) + guard fd >= 0 else { + logger.warning("startLeaseFileWatcher: failed to open \(leasePath), errno=\(errno)") + return + } + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .rename], + queue: DispatchQueue.global() + ) + source.setEventHandler { [weak self] in + guard let self, neighborSubscription != nil else { return } + guard let callbackConnection = neighborCallbackConnection else { return } + guard let proxy = callbackConnection.remoteObjectProxyWithErrorHandler({ error in + logger.error("leaseWatcher push error: \(error.localizedDescription)") + }) as? NeighborTableListenerProtocol else { + return + } + + let leaseIterator = LibboxReadBootpdLeases() + var results: [NeighborEntryResult] = [] + if let leaseIterator { + while leaseIterator.hasNext() { + guard let entry = leaseIterator.next() else { continue } + results.append(NeighborEntryResult( + address: entry.address, + macAddress: entry.macAddress, + hostname: entry.hostname + )) + } + } + proxy.updateNeighborTable(entries: results as NSArray) + } + source.setCancelHandler { + close(fd) + } + source.resume() + neighborLeaseWatcher = source + } } diff --git a/Library/Network/ExtensionPlatformInterface.swift b/Library/Network/ExtensionPlatformInterface.swift index cbb3797..43c40c0 100644 --- a/Library/Network/ExtensionPlatformInterface.swift +++ b/Library/Network/ExtensionPlatformInterface.swift @@ -460,6 +460,11 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc networkSettings = nil nwMonitor?.cancel() nwMonitor = nil + #if os(macOS) + neighborCallbackListener?.invalidate() + neighborCallbackListener = nil + neighborCallbackHandler = nil + #endif } public func send(_ notification: LibboxNotification?) throws { @@ -492,6 +497,50 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc #endif } + #if os(macOS) + private var neighborCallbackListener: NSXPCListener? + private var neighborCallbackHandler: NeighborCallbackHandler? + #endif + + public func startNeighborMonitor(_ listener: LibboxNeighborUpdateListenerProtocol?) throws { + #if os(macOS) + guard let listener else { return } + if Variant.useSystemExtension { + let handler = NeighborCallbackHandler(listener) + let xpcListener = NSXPCListener.anonymous() + xpcListener.delegate = handler + xpcListener.resume() + try RootHelperClient.shared.startNeighborMonitor( + callbackEndpoint: xpcListener.endpoint + ) + neighborCallbackListener = xpcListener + neighborCallbackHandler = handler + return + } + #endif + } + + public func registerMyInterface(_ name: String?) { + #if os(macOS) + guard let name, !name.isEmpty else { return } + if Variant.useSystemExtension { + try? RootHelperClient.shared.registerMyInterface(name: name) + } + #endif + } + + public func closeNeighborMonitor(_: LibboxNeighborUpdateListenerProtocol?) throws { + #if os(macOS) + if Variant.useSystemExtension { + try? RootHelperClient.shared.closeNeighborMonitor() + neighborCallbackListener?.invalidate() + neighborCallbackListener = nil + neighborCallbackHandler = nil + return + } + #endif + } + public func localDNSTransport() -> (any LibboxLocalDNSTransportProtocol)? { nil } @@ -500,3 +549,51 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc nil } } + +#if os(macOS) + private class NeighborCallbackHandler: NSObject, NSXPCListenerDelegate, NeighborTableListenerProtocol { + private let listener: LibboxNeighborUpdateListenerProtocol + + init(_ listener: LibboxNeighborUpdateListenerProtocol) { + self.listener = listener + } + + func listener(_: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool { + let exportedInterface = NSXPCInterface(with: NeighborTableListenerProtocol.self) + RootHelperXPC.configureListenerInterface(exportedInterface) + newConnection.exportedInterface = exportedInterface + newConnection.exportedObject = self + newConnection.resume() + return true + } + + func updateNeighborTable(entries: NSArray) { + let iterator = NeighborEntryArrayIterator(entries) + listener.updateNeighborTable(iterator) + } + } + + private class NeighborEntryArrayIterator: NSObject, LibboxNeighborEntryIteratorProtocol { + private var entries: [NeighborEntryResult] + private var index = 0 + + init(_ array: NSArray) { + entries = array.compactMap { $0 as? NeighborEntryResult } + } + + func hasNext() -> Bool { + index < entries.count + } + + func next() -> LibboxNeighborEntry? { + guard index < entries.count else { return nil } + let result = entries[index] + index += 1 + let entry = LibboxNeighborEntry() + entry.address = result.address + entry.macAddress = result.macAddress + entry.hostname = result.hostname + return entry + } + } +#endif diff --git a/Library/Network/RootHelperXPC.swift b/Library/Network/RootHelperXPC.swift index 623b1bd..3e75f44 100644 --- a/Library/Network/RootHelperXPC.swift +++ b/Library/Network/RootHelperXPC.swift @@ -4,6 +4,36 @@ private let logger = Logger(category: "RootHelperXPC") + @objc(NeighborEntryResult) public class NeighborEntryResult: NSObject, NSSecureCoding { + public static let supportsSecureCoding = true + + @objc public var address: String + @objc public var macAddress: String + @objc public var hostname: String + + public init(address: String, macAddress: String, hostname: String) { + self.address = address + self.macAddress = macAddress + self.hostname = hostname + } + + public required init?(coder: NSCoder) { + address = coder.decodeObject(of: NSString.self, forKey: "address") as? String ?? "" + macAddress = coder.decodeObject(of: NSString.self, forKey: "macAddress") as? String ?? "" + hostname = coder.decodeObject(of: NSString.self, forKey: "hostname") as? String ?? "" + } + + public func encode(with coder: NSCoder) { + coder.encode(address as NSString, forKey: "address") + coder.encode(macAddress as NSString, forKey: "macAddress") + coder.encode(hostname as NSString, forKey: "hostname") + } + } + + @objc public protocol NeighborTableListenerProtocol { + func updateNeighborTable(entries: NSArray) + } + @objc public class ConnectionOwnerResult: NSObject, NSSecureCoding { public static let supportsSecureCoding = true @@ -43,6 +73,9 @@ func getWorkingDirectorySize(reply: @escaping (Int64, NSError?) -> Void) func cleanWorkingDirectory(reply: @escaping (NSError?) -> Void) func getVersion(reply: @escaping (String) -> Void) + func startNeighborMonitor(callbackEndpoint: NSXPCListenerEndpoint, reply: @escaping (NSError?) -> Void) + func closeNeighborMonitor(reply: @escaping (NSError?) -> Void) + func registerMyInterface(name: String, reply: @escaping (NSError?) -> Void) } public enum RootHelperXPC { @@ -54,6 +87,23 @@ argumentIndex: 0, ofReply: true ) + let endpointClasses = NSSet(array: [NSXPCListenerEndpoint.self]) as! Set + interface.setClasses( + endpointClasses, + for: #selector(RootHelperProtocol.startNeighborMonitor(callbackEndpoint:reply:)), + argumentIndex: 0, + ofReply: false + ) + } + + public static func configureListenerInterface(_ interface: NSXPCInterface) { + let entryClasses = NSSet(array: [NSArray.self, NeighborEntryResult.self]) as! Set + interface.setClasses( + entryClasses, + for: #selector(NeighborTableListenerProtocol.updateNeighborTable(entries:)), + argumentIndex: 0, + ofReply: false + ) } } @@ -219,6 +269,24 @@ } } + public func startNeighborMonitor(callbackEndpoint: NSXPCListenerEndpoint) throws { + try performXPCCallVoid("startNeighborMonitor") { proxy, reply in + proxy.startNeighborMonitor(callbackEndpoint: callbackEndpoint, reply: reply) + } + } + + public func closeNeighborMonitor() throws { + try performXPCCallVoid("closeNeighborMonitor") { proxy, reply in + proxy.closeNeighborMonitor(reply: reply) + } + } + + public func registerMyInterface(name: String) throws { + try performXPCCallVoid("registerMyInterface") { proxy, reply in + proxy.registerMyInterface(name: name, reply: reply) + } + } + public func getVersion() throws -> String { let semaphore = DispatchSemaphore(value: 0) var result: String? From b48f4ad97fe8e9c76b3ec74cc0366f056a0add33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 3 Apr 2026 09:57:59 +0800 Subject: [PATCH 27/37] Tools View & Crash Report & OOM Report --- .../Service/ReportTransfer.swift | 91 ++ .../Service/ReportTransferServer.swift | 141 +++ .../Views/Abstract/MetadataFormView.swift | 92 ++ .../Views/Abstract/PlainTextView.swift | 95 ++ .../Views/Abstract/ShareButton.swift | 20 +- .../Views/Dashboard/Cards/ProfileCard.swift | 19 +- .../Views/Log/LogViewModel.swift | 2 +- ApplicationLibrary/Views/NavigationPage.swift | 11 +- .../Views/Setting/MacAppView.swift | 158 ++-- .../Views/Setting/PacketTunnelView.swift | 16 - .../Views/Setting/ServiceLogView.swift | 64 -- .../Views/Setting/ServiceLogViewModel.swift | 77 -- .../Views/Setting/SettingView.swift | 30 +- .../Views/Tools/CrashReportDetailView.swift | 164 ++++ .../Views/Tools/CrashReportListView.swift | 249 +++++ .../Views/Tools/ExportReportView.swift | 173 ++++ .../Views/Tools/OOMReportDetailView.swift | 166 ++++ .../Views/Tools/OOMReportListView.swift | 246 +++++ .../Views/Tools/ReportShared.swift | 127 +++ .../Views/Tools/ToolsView.swift | 97 ++ HelperService/RootHelperService.swift | 123 +++ HelperService/WorkingDirectoryManager.swift | 40 +- HelperService/main.swift | 17 + Library/Database/SharedPreferences.swift | 26 +- Library/Network/ExtensionEnvironments.swift | 20 + .../Network/ExtensionPlatformInterface.swift | 10 +- Library/Network/ExtensionProfile.swift | 6 +- Library/Network/ExtensionProvider.swift | 41 +- Library/Network/RootHelperXPC.swift | 225 ++++- Library/Shared/AppConfiguration.swift | 7 + Library/Shared/CrashReportArchive.swift | 258 +++++ Library/Shared/CrashReportManager.swift | 726 ++++++++++++++ Library/Shared/NativeCrashReporter.swift | 82 ++ Library/Shared/OOMReportArchive.swift | 60 ++ Library/Shared/OOMReportManager.swift | 164 ++++ Localizable.xcstrings | 890 ++++++++++++++---- MacLibrary/ApplicationDelegate.swift | 2 + MacLibrary/SidebarView.swift | 3 + SFI/ApplicationDelegate.swift | 13 + SFI/Info.plist | 4 + SFI/MainView.swift | 8 + SFT/ApplicationDelegate.swift | 2 + SFT/Info.plist | 11 + SFT/MainView.swift | 8 +- sing-box.xcodeproj/project.pbxproj | 17 + .../xcshareddata/swiftpm/Package.resolved | 11 +- 46 files changed, 4279 insertions(+), 533 deletions(-) create mode 100644 ApplicationLibrary/Service/ReportTransfer.swift create mode 100644 ApplicationLibrary/Service/ReportTransferServer.swift create mode 100644 ApplicationLibrary/Views/Abstract/MetadataFormView.swift create mode 100644 ApplicationLibrary/Views/Abstract/PlainTextView.swift delete mode 100644 ApplicationLibrary/Views/Setting/ServiceLogView.swift delete mode 100644 ApplicationLibrary/Views/Setting/ServiceLogViewModel.swift create mode 100644 ApplicationLibrary/Views/Tools/CrashReportDetailView.swift create mode 100644 ApplicationLibrary/Views/Tools/CrashReportListView.swift create mode 100644 ApplicationLibrary/Views/Tools/ExportReportView.swift create mode 100644 ApplicationLibrary/Views/Tools/OOMReportDetailView.swift create mode 100644 ApplicationLibrary/Views/Tools/OOMReportListView.swift create mode 100644 ApplicationLibrary/Views/Tools/ReportShared.swift create mode 100644 ApplicationLibrary/Views/Tools/ToolsView.swift create mode 100644 Library/Shared/CrashReportArchive.swift create mode 100644 Library/Shared/CrashReportManager.swift create mode 100644 Library/Shared/NativeCrashReporter.swift create mode 100644 Library/Shared/OOMReportArchive.swift create mode 100644 Library/Shared/OOMReportManager.swift diff --git a/ApplicationLibrary/Service/ReportTransfer.swift b/ApplicationLibrary/Service/ReportTransfer.swift new file mode 100644 index 0000000..9428d5e --- /dev/null +++ b/ApplicationLibrary/Service/ReportTransfer.swift @@ -0,0 +1,91 @@ +import BinaryCodable +import Foundation +import Library + +public enum ReportType: String, Codable { + case crash + case oom + + public var directoryName: String { + switch self { + case .crash: return "crash_reports" + case .oom: return "oom_reports" + } + } +} + +public enum ReportTransferMessageType: UInt8 { + case error = 0 + case report = 1 + case complete = 2 + case ack = 3 +} + +public struct ReportTransferPayload: Codable { + public var reportType: ReportType + public var timestamp: TimeInterval + public var files: [ReportTransferFile] + + public init(reportType: ReportType, timestamp: TimeInterval, files: [ReportTransferFile]) { + self.reportType = reportType + self.timestamp = timestamp + self.files = files + } +} + +public struct ReportTransferFile: Codable { + public var name: String + public var data: Data + + public init(name: String, data: Data) { + self.name = name + self.data = data + } +} + +public struct ReportTransferError: LocalizedError { + public let errorDescription: String? + + public init(_ message: String) { + errorDescription = message + } +} + +public enum ReportTransferService { + public static let applicationServiceName = "sing-box:report-transfer" +} + +public enum ReportTransferMessage { + public static func encodeReport(_ payload: ReportTransferPayload) throws -> Data { + var data = Data([ReportTransferMessageType.report.rawValue]) + try data.append(BinaryEncoder().encode(payload)) + return data + } + + public static func encodeComplete() -> Data { + Data([ReportTransferMessageType.complete.rawValue]) + } + + public static func encodeAck() -> Data { + Data([ReportTransferMessageType.ack.rawValue]) + } + + public static func encodeError(_ message: String) -> Data { + var data = Data([ReportTransferMessageType.error.rawValue]) + data.append(Data(message.utf8)) + return data + } + + public static func decodeType(_ data: Data) -> ReportTransferMessageType? { + guard !data.isEmpty else { return nil } + return ReportTransferMessageType(rawValue: data[0]) + } + + public static func decodeReport(_ data: Data) throws -> ReportTransferPayload { + try BinaryDecoder().decode(ReportTransferPayload.self, from: data.dropFirst()) + } + + public static func decodeError(_ data: Data) -> String { + String(data: data.dropFirst(), encoding: .utf8) ?? "Unknown error" + } +} diff --git a/ApplicationLibrary/Service/ReportTransferServer.swift b/ApplicationLibrary/Service/ReportTransferServer.swift new file mode 100644 index 0000000..4e91d31 --- /dev/null +++ b/ApplicationLibrary/Service/ReportTransferServer.swift @@ -0,0 +1,141 @@ +#if os(iOS) + + import Foundation + import Library + import Network + import os + import UIKit + + private let logger = Logger(category: "ReportTransferServer") + + public extension Notification.Name { + static let reportReceived = Notification.Name("reportReceived") + } + + public class ReportTransferServer { + private var listener: NWListener + + @available(iOS 16.0, *) + public init() throws { + listener = try NWListener(using: .applicationService) + listener.service = NWListener.Service(applicationService: ReportTransferService.applicationServiceName) + listener.newConnectionHandler = { connection in + connection.stateUpdateHandler = { state in + if state == .ready { + Task.detached { + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 100) + await ReportTransferConnection(connection).process() + } + } + } + connection.start(queue: .global()) + } + } + + public func start() { + listener.start(queue: .global()) + } + + public func cancel() { + listener.cancel() + } + + class ReportTransferConnection { + private let connection: NWSocket + private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid + + init(_ connection: NWConnection) { + self.connection = NWSocket(connection) + } + + func process() async { + beginBackgroundTask() + defer { endBackgroundTask() } + + var receivedCount = 0 + var lastReportType: ReportType? + do { + while true { + let message = try await connection.read() + guard let type = ReportTransferMessage.decodeType(message) else { + continue + } + switch type { + case .report: + let payload = try ReportTransferMessage.decodeReport(message) + try importReport(payload) + lastReportType = payload.reportType + receivedCount += 1 + case .complete: + logger.info("report transfer server: received \(receivedCount) report(s)") + if receivedCount > 0 { + let reportType = lastReportType + await MainActor.run { + NotificationCenter.default.post(name: .reportReceived, object: reportType) + } + } + try await connection.write(ReportTransferMessage.encodeAck()) + return + case .error: + let errorMsg = ReportTransferMessage.decodeError(message) + logger.warning("report transfer server: client error: \(errorMsg)") + return + case .ack: + return + } + } + } catch { + logger.warning("report transfer server: \(error.localizedDescription)") + await writeError(error.localizedDescription) + } + } + + private func importReport(_ payload: ReportTransferPayload) throws { + let reportsDir = FilePath.workingDirectory.appendingPathComponent(payload.reportType.directoryName, isDirectory: true) + try FileManager.default.createDirectory(at: reportsDir, withIntermediateDirectories: true) + + let date = Date(timeIntervalSince1970: payload.timestamp) + let artifactURL = ReportArchive.nextAvailableArtifactURL(in: reportsDir, for: date) + try FileManager.default.createDirectory(at: artifactURL, withIntermediateDirectories: true) + + for file in payload.files { + let fileURL = artifactURL.appendingPathComponent(file.name) + if file.name == ReportArchive.metadataFileName { + try writeMetadataWithDeviceOrigin(file.data, to: fileURL) + } else { + try file.data.write(to: fileURL, options: .atomic) + } + } + } + + private func writeMetadataWithDeviceOrigin(_ data: Data, to url: URL) throws { + guard var json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + try data.write(to: url, options: .atomic) + return + } + json["deviceOrigin"] = ReportArchive.tvOSDeviceOrigin + let patched = try JSONSerialization.data(withJSONObject: json) + try patched.write(to: url, options: .atomic) + } + + private func writeError(_ message: String) async { + try? await connection.write(ReportTransferMessage.encodeError(message)) + } + + private func beginBackgroundTask() { + backgroundTaskID = UIApplication.shared.beginBackgroundTask { [weak self] in + logger.warning("report transfer server: background task expiring") + self?.connection.cancel() + self?.endBackgroundTask() + } + } + + private func endBackgroundTask() { + guard backgroundTaskID != .invalid else { return } + UIApplication.shared.endBackgroundTask(backgroundTaskID) + backgroundTaskID = .invalid + } + } + } + +#endif diff --git a/ApplicationLibrary/Views/Abstract/MetadataFormView.swift b/ApplicationLibrary/Views/Abstract/MetadataFormView.swift new file mode 100644 index 0000000..e03aa96 --- /dev/null +++ b/ApplicationLibrary/Views/Abstract/MetadataFormView.swift @@ -0,0 +1,92 @@ +import SwiftUI + +private struct OrderedStringMap { + let entries: [(key: String, value: String)] + + init?(data: Data) { + guard let json = String(data: data, encoding: .utf8) else { return nil } + var entries: [(key: String, value: String)] = [] + var rest = json[...] + + func skip(_ ch: Character) -> Bool { + rest = rest.drop(while: \.isWhitespace) + guard rest.first == ch else { return false } + rest = rest.dropFirst() + return true + } + + func readString() -> String? { + rest = rest.drop(while: \.isWhitespace) + guard rest.first == "\"" else { return nil } + rest = rest.dropFirst() + var s = "" + while let ch = rest.first, ch != "\"" { + if ch == "\\" { rest = rest.dropFirst() } + if let c = rest.first { s.append(c); rest = rest.dropFirst() } + } + if !rest.isEmpty { rest = rest.dropFirst() } + return s + } + + guard skip("{") else { return nil } + while true { + guard let key = readString(), skip(":"), let value = readString() else { break } + if !value.isEmpty { entries.append((key: key, value: value)) } + if !skip(",") { break } + } + self.entries = entries + } +} + +@MainActor +public struct MetadataFormView: View { + @State private var entries: [(key: String, value: String)] = [] + @State private var isLoading = true + + let url: URL + let title: String + + public init(url: URL, title: String) { + self.url = url + self.title = title + } + + public var body: some View { + FormView { + if !isLoading { + Section { + ForEach(entries, id: \.key) { entry in + FormTextItem(LocalizedStringKey(entry.key), entry.value) + } + } + } + } + .overlay { + if isLoading { + ProgressView() + } else if entries.isEmpty { + Text("Empty") + .foregroundStyle(.secondary) + } + } + .onAppear { + Task.detached { + let loaded = loadEntries() + await MainActor.run { + entries = loaded + isLoading = false + } + } + } + .navigationTitle(title) + } + + private nonisolated func loadEntries() -> [(key: String, value: String)] { + guard let data = try? Data(contentsOf: url), + let map = OrderedStringMap(data: data) + else { + return [] + } + return map.entries + } +} diff --git a/ApplicationLibrary/Views/Abstract/PlainTextView.swift b/ApplicationLibrary/Views/Abstract/PlainTextView.swift new file mode 100644 index 0000000..b13cc24 --- /dev/null +++ b/ApplicationLibrary/Views/Abstract/PlainTextView.swift @@ -0,0 +1,95 @@ +#if canImport(UIKit) + import UIKit +#elseif canImport(AppKit) + import AppKit +#endif + +import SwiftUI + +#if os(tvOS) + struct PlainTextView: UIViewRepresentable { + let content: String + + private static let monoFont = UIFont.monospacedSystemFont(ofSize: 24, weight: .regular) + + func makeUIView(context _: Context) -> UITextView { + let textView = UITextView() + // isSelectable must be true for UITextView to be focusable on tvOS. + // Without focus, the Siri Remote cannot scroll the content. + // SwiftUI ScrollView + Text / LazyVStack + .focusable() do NOT work + // reliably inside navigation destinations on tvOS. + textView.isSelectable = true + textView.isUserInteractionEnabled = true + textView.isScrollEnabled = true + textView.backgroundColor = .clear + textView.textContainerInset = UIEdgeInsets(top: 40, left: 40, bottom: 40, right: 40) + textView.textContainer.lineFragmentPadding = 0 + textView.font = Self.monoFont + textView.textColor = .label + textView.text = content + textView.panGestureRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirect.rawValue)] + return textView + } + + func updateUIView(_: UITextView, context _: Context) {} + } + +#elseif os(iOS) + struct PlainTextView: UIViewRepresentable { + let content: String + + private static let monoFont = UIFont.monospacedSystemFont(ofSize: 12, weight: .regular) + + func makeUIView(context _: Context) -> UITextView { + let textView = UITextView() + textView.isEditable = false + textView.isSelectable = true + textView.isScrollEnabled = false + textView.backgroundColor = .clear + textView.textContainerInset = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + textView.textContainer.lineFragmentPadding = 0 + textView.font = Self.monoFont + textView.textColor = .label + textView.text = content + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return textView + } + + func updateUIView(_: UITextView, context _: Context) {} + } + +#elseif os(macOS) + struct PlainTextView: NSViewRepresentable { + let content: String + + private static let monoFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) + + func makeNSView(context _: Context) -> NSScrollView { + let scrollView = NSScrollView() + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + + let textView = NSTextView() + textView.isEditable = false + textView.isSelectable = true + textView.drawsBackground = false + textView.textContainerInset = NSSize(width: 16, height: 16) + textView.font = Self.monoFont + textView.textColor = .labelColor + textView.autoresizingMask = [.width] + textView.string = content + + if let textContainer = textView.textContainer { + textContainer.widthTracksTextView = true + textContainer.containerSize = NSSize(width: scrollView.contentSize.width, height: .greatestFiniteMagnitude) + textContainer.lineFragmentPadding = 0 + } + + scrollView.documentView = textView + return scrollView + } + + func updateNSView(_: NSScrollView, context _: Context) {} + } +#endif diff --git a/ApplicationLibrary/Views/Abstract/ShareButton.swift b/ApplicationLibrary/Views/Abstract/ShareButton.swift index 9c91fe5..5806365 100644 --- a/ApplicationLibrary/Views/Abstract/ShareButton.swift +++ b/ApplicationLibrary/Views/Abstract/ShareButton.swift @@ -82,7 +82,7 @@ public struct ShareButtonCompat: View { do { let shareItem = try await itemURL() await MainActor.run { - presentShareController(shareItem) + presentShareSheet(shareItem) } } catch { await MainActor.run { @@ -91,22 +91,6 @@ public struct ShareButtonCompat: View { } } - private func presentShareController(_ item: URL) { - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let rootViewController = windowScene.keyWindow?.rootViewController - else { - return - } - var topViewController = rootViewController - while let presented = topViewController.presentedViewController { - topViewController = presented - } - topViewController.present( - UIActivityViewController(activityItems: [item], applicationActivities: nil), - animated: true - ) - } - #elseif os(macOS) private nonisolated func shareItemAsync() async { do { @@ -125,7 +109,7 @@ public struct ShareButtonCompat: View { } #if os(macOS) - private struct SharingServicePicker: NSViewRepresentable { + struct SharingServicePicker: NSViewRepresentable { @Binding private var isPresented: Bool @Binding private var alert: AlertState? @Binding private var item: URL? diff --git a/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift index a33a157..06abae3 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift @@ -361,7 +361,7 @@ public struct ProfileCard: View { url = try await profile.origin.generateJSONShareFileAsync(name: "\(profile.name).json") } #if os(iOS) - presentShareController(url) + presentShareSheet(url) #elseif os(macOS) let anchorView = viewModel.shareButtonView ?? NSApp.keyWindow?.contentView ?? NSView() NSSharingServicePicker(items: [url]).show( @@ -400,23 +400,6 @@ public struct ProfileCard: View { } } - #if os(iOS) - private func presentShareController(_ item: URL) { - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let rootViewController = windowScene.keyWindow?.rootViewController - else { - return - } - var topViewController = rootViewController - while let presented = topViewController.presentedViewController { - topViewController = presented - } - topViewController.present( - UIActivityViewController(activityItems: [item], applicationActivities: nil), - animated: true - ) - } - #endif #endif private func prepareQRSShare(_ profile: ProfilePreview) { diff --git a/ApplicationLibrary/Views/Log/LogViewModel.swift b/ApplicationLibrary/Views/Log/LogViewModel.swift index 884da54..05128b4 100644 --- a/ApplicationLibrary/Views/Log/LogViewModel.swift +++ b/ApplicationLibrary/Views/Log/LogViewModel.swift @@ -155,7 +155,7 @@ public class LogDataModel: ObservableObject { do { let text = getLogsText() let dateString = Self.dateFormatter.string(from: Date()) - let tempDirectory = FileManager.default.temporaryDirectory + let tempDirectory = FilePath.cacheDirectory let fileURL = tempDirectory.appendingPathComponent("logs-\(dateString).txt") try text.write(to: fileURL, atomically: true, encoding: .utf8) logFileURL = fileURL diff --git a/ApplicationLibrary/Views/NavigationPage.swift b/ApplicationLibrary/Views/NavigationPage.swift index 6ff2771..b46833a 100644 --- a/ApplicationLibrary/Views/NavigationPage.swift +++ b/ApplicationLibrary/Views/NavigationPage.swift @@ -13,6 +13,7 @@ public enum NavigationPage: Int, CaseIterable, Identifiable { case connections #endif case logs + case tools case settings } @@ -23,6 +24,8 @@ public extension NavigationPage { self = .dashboard case "logs": self = .logs + case "tools": + self = .tools case "settings": self = .settings #if os(macOS) @@ -38,7 +41,7 @@ public extension NavigationPage { #if os(macOS) static var macosDefaultPages: [NavigationPage] { - [.logs, .settings] + [.logs, .tools, .settings] } #endif @@ -59,6 +62,8 @@ public extension NavigationPage { #endif case .logs: return String(localized: "Logs") + case .tools: + return String(localized: "Tools") case .settings: return String(localized: "Settings") } @@ -76,6 +81,8 @@ public extension NavigationPage { #endif case .logs: return "list.bullet.rectangle" + case .tools: + return "terminal.fill" case .settings: return "gear.circle.fill" } @@ -95,6 +102,8 @@ public extension NavigationPage { #endif case .logs: LogView() + case .tools: + ToolsView() case .settings: SettingView() } diff --git a/ApplicationLibrary/Views/Setting/MacAppView.swift b/ApplicationLibrary/Views/Setting/MacAppView.swift index a1d1ead..247e67c 100644 --- a/ApplicationLibrary/Views/Setting/MacAppView.swift +++ b/ApplicationLibrary/Views/Setting/MacAppView.swift @@ -23,20 +23,21 @@ public struct AppView: View { @State private var isLoading = true @State private var selectedLanguage: String? + @State private var cacheSize: Int64 = 0 + @State private var cacheSizeText = "" #if os(macOS) @State private var startAtLogin = false @Environment(\.showMenuBarExtra) private var showMenuBarExtra @Environment(\.menuBarExtraSpeedMode) private var menuBarExtraSpeedMode @State private var menuBarExtraInBackground = false + @State private var systemExtensionInstalled = false @State private var helperStatusLoaded = false @State private var rootHelperRegistrationStatus: SMAppService.Status = .notRegistered @EnvironmentObject private var environments: ExtensionEnvironments @EnvironmentObject private var updateManager: UpdateManager @State private var updateTrack: UpdateTrack = .stable @State private var checkUpdateEnabled = false - @State private var cacheSize: Int64 = 0 - @State private var cacheSizeText = "" #endif @State private var alert: AlertState? @@ -96,34 +97,37 @@ public struct AppView: View { } } - if Variant.useSystemExtension { - FormTextItem("Cache Size", cacheSizeText) - if cacheSize > 0 { - // Safe: System Extension's working directory is in its own container - // (/var/root/Library/Containers/…), not under the app's cacheDirectory. - FormButton(role: .destructive) { - Task.detached { - let cacheDir = FilePath.cacheDirectory - if let contents = try? FileManager.default.contentsOfDirectory( - at: cacheDir, - includingPropertiesForKeys: nil - ) { - for item in contents { - try? FileManager.default.removeItem(at: item) - } - } - await MainActor.run { - cacheSize = 0 - cacheSizeText = ByteCountFormatter.string(fromByteCount: 0, countStyle: .file) + #endif + + FormTextItem("Cache Size", cacheSizeText) + if cacheSize > 0 { + FormButton(role: .destructive) { + Task.detached { + let cacheDir = FilePath.cacheDirectory + let workingDir = FilePath.workingDirectory + if let contents = try? FileManager.default.contentsOfDirectory( + at: cacheDir, + includingPropertiesForKeys: nil + ) { + for item in contents { + if item.lastPathComponent == workingDir.lastPathComponent { + continue } + try? FileManager.default.removeItem(at: item) } - } label: { - Label("Clear Cache", systemImage: "trash") - .foregroundColor(.red) + } + await MainActor.run { + cacheSize = 0 + cacheSizeText = ByteCountFormatter.string(fromByteCount: 0, countStyle: .file) } } + } label: { + Label("Clear Cache", systemImage: "trash") + .foregroundColor(.red) } + } + #if os(macOS) if Variant.useSystemExtension { Section("Update Settings") { Picker("Update Track", selection: $updateTrack) { @@ -203,19 +207,29 @@ public struct AppView: View { } Section("System Extension") { - FormButton { - Task { - await updateSystemExtension() + if systemExtensionInstalled { + FormButton { + Task { + await updateSystemExtension() + } + } label: { + Label("Update", systemImage: "arrow.down.doc.fill") } - } label: { - Label("Update", systemImage: "arrow.down.doc.fill") - } - FormButton(role: .destructive) { - Task { - await uninstallSystemExtension() + FormButton(role: .destructive) { + Task { + await uninstallSystemExtension() + } + } label: { + Label("Uninstall", systemImage: "trash.fill").foregroundColor(.red) + } + } else { + FormButton { + Task { + await installSystemExtension() + } + } label: { + Label("Install", systemImage: "lock.doc.fill") } - } label: { - Label("Uninstall", systemImage: "trash.fill").foregroundColor(.red) } } @@ -289,6 +303,7 @@ public struct AppView: View { startAtLogin = SMAppService.mainApp.status == .enabled menuBarExtraInBackground = await SharedPreferences.menuBarExtraInBackground.get() if Variant.useSystemExtension { + systemExtensionInstalled = await SystemExtension.isInstalled() let trackString = await SharedPreferences.updateTrack.get() updateTrack = UpdateTrack.resolved(from: trackString) checkUpdateEnabled = await SharedPreferences.checkUpdateEnabled.get() @@ -299,9 +314,9 @@ public struct AppView: View { if Variant.useSystemExtension { refreshHelperStatus() helperStatusLoaded = true - refreshCacheSize() } #endif + refreshCacheSize() } private static func currentLanguage() -> String? { @@ -384,6 +399,20 @@ public struct AppView: View { } } + private func installSystemExtension() async { + do { + if let result = try await SystemExtension.install() { + if result == .willCompleteAfterReboot { + alert = AlertState(errorMessage: String(localized: "Need Reboot")) + return + } + } + systemExtensionInstalled = true + } catch { + alert = AlertState(action: "install system extension", error: error) + } + } + private func updateSystemExtension() async { do { if let result = try await SystemExtension.install(forceUpdate: true) { @@ -410,6 +439,7 @@ public struct AppView: View { if let result = try await SystemExtension.uninstall() { switch result { case .completed: + systemExtensionInstalled = false alert = AlertState( title: String(localized: "Uninstall"), message: String(localized: "System Extension removed.") @@ -452,32 +482,34 @@ public struct AppView: View { NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/System Preferences.app")) } - private func refreshCacheSize() { - Task.detached { - let size = Self.calculateDirSize(FilePath.cacheDirectory) - await MainActor.run { - cacheSize = size - cacheSizeText = ByteCountFormatter.string(fromByteCount: size, countStyle: .file) - } - } - } - - private static func calculateDirSize(_ dir: URL) -> Int64 { - guard let enumerator = FileManager.default.enumerator( - at: dir, - includingPropertiesForKeys: [.fileSizeKey], - options: [.skipsHiddenFiles] - ) else { - return 0 - } - var size: Int64 = 0 - for case let fileURL as URL in enumerator { - if let fileSize = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize { - size += Int64(fileSize) - } - } - return size - } - #endif + + private func refreshCacheSize() { + Task.detached { + let total = Self.calculateDirSize(FilePath.cacheDirectory) + let working = Self.calculateDirSize(FilePath.workingDirectory) + let size = max(total - working, 0) + await MainActor.run { + cacheSize = size + cacheSizeText = ByteCountFormatter.string(fromByteCount: size, countStyle: .file) + } + } + } + + private static func calculateDirSize(_ dir: URL) -> Int64 { + guard let enumerator = FileManager.default.enumerator( + at: dir, + includingPropertiesForKeys: [.fileSizeKey], + options: [.skipsHiddenFiles] + ) else { + return 0 + } + var size: Int64 = 0 + for case let fileURL as URL in enumerator { + if let fileSize = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize { + size += Int64(fileSize) + } + } + return size + } } diff --git a/ApplicationLibrary/Views/Setting/PacketTunnelView.swift b/ApplicationLibrary/Views/Setting/PacketTunnelView.swift index 8a4a29f..8cf32c3 100644 --- a/ApplicationLibrary/Views/Setting/PacketTunnelView.swift +++ b/ApplicationLibrary/Views/Setting/PacketTunnelView.swift @@ -6,10 +6,6 @@ struct PacketTunnelView: View { @State private var isLoading = true @State private var alert: AlertState? - #if !os(macOS) - @State private var ignoreMemoryLimit = false - #endif - @State private var includeAllNetworks = false @State private var excludeAPNs = false @State private var excludeCellularServices = false @@ -28,15 +24,6 @@ struct PacketTunnelView: View { } } else { FormView { - #if !os(macOS) - FormToggle("Ignore Memory Limit", """ - Do not enforce memory limits on sing-box. Will cause OOM on non-jailbroken devices. - """, $ignoreMemoryLimit) { newValue in - await SharedPreferences.ignoreMemoryLimit.set(newValue) - await restartService() - } - #endif - #if !os(tvOS) FormToggle("includeAllNetworks", """ If this property is true, the system routes network traffic through the tunnel except traffic for designated system services necessary for maintaining expected device functionality. You can exclude some types of traffic using the **excludeAPNs**, **excludeLocalNetworks**, and **excludeCellularServices** properties in combination with this property. @@ -135,9 +122,6 @@ struct PacketTunnelView: View { @MainActor private func loadSettings() async { - #if !os(macOS) - ignoreMemoryLimit = await SharedPreferences.ignoreMemoryLimit.get() - #endif #if !os(tvOS) includeAllNetworks = await SharedPreferences.includeAllNetworks.get() excludeLocalNetworks = await SharedPreferences.excludeLocalNetworks.get() diff --git a/ApplicationLibrary/Views/Setting/ServiceLogView.swift b/ApplicationLibrary/Views/Setting/ServiceLogView.swift deleted file mode 100644 index 1d06772..0000000 --- a/ApplicationLibrary/Views/Setting/ServiceLogView.swift +++ /dev/null @@ -1,64 +0,0 @@ -import Foundation -import Library -import SwiftUI - -@MainActor -public struct ServiceLogView: View { - @Environment(\.dismiss) private var dismiss - @StateObject private var viewModel = ServiceLogViewModel() - - private let logFont = Font.system(.caption, design: .monospaced) - - public init() {} - - public var body: some View { - Group { - if viewModel.isLoading { - ProgressView().onAppear { - Task { - await viewModel.loadContent() - } - } - } else { - if viewModel.isEmpty { - Text("Empty content") - } else { - ScrollView { - Text(viewModel.content) - .font(logFont) - .frame(maxWidth: .infinity, alignment: .topLeading) - } - .padding() - } - } - } - .toolbar { - if !viewModel.isEmpty { - #if !os(tvOS) - ShareButtonCompat($viewModel.alert) { - Label("Export", systemImage: "square.and.arrow.up.fill") - } itemURL: { - try await viewModel.generateShareFileAsync() - } - #endif - Button(role: .destructive) { - Task { - await viewModel.deleteContent(dismiss: dismiss) - } - } label: { - #if !os(tvOS) - Label("Delete", systemImage: "trash.fill") - #else - Image(systemName: "trash.fill") - .tint(.red) - #endif - } - } - } - .alert($viewModel.alert) - .navigationTitle("Service Log") - #if os(tvOS) - .focusable() - #endif - } -} diff --git a/ApplicationLibrary/Views/Setting/ServiceLogViewModel.swift b/ApplicationLibrary/Views/Setting/ServiceLogViewModel.swift deleted file mode 100644 index 0aa4124..0000000 --- a/ApplicationLibrary/Views/Setting/ServiceLogViewModel.swift +++ /dev/null @@ -1,77 +0,0 @@ -import Foundation -import Library -import SwiftUI - -@MainActor -final class ServiceLogViewModel: BaseViewModel { - @Published var content = "" - - override init() { - super.init() - isLoading = true - } - - var isEmpty: Bool { - content.isEmpty - } - - nonisolated func loadContent() async { - let primaryLogURL = FilePath.cacheDirectory.appendingPathComponent("stderr.log") - let secondaryLogURL = FilePath.cacheDirectory.appendingPathComponent("stderr.log.old") - var content = await BlockingIO.run { - if let primaryContent = try? String(contentsOf: primaryLogURL), !primaryContent.isEmpty { - return primaryContent - } - return (try? String(contentsOf: secondaryLogURL)) ?? "" - } - #if DEBUG - if content.isEmpty { - content = "Empty content" - } - #endif - if !content.isEmpty { - var systemInfo = utsname() - uname(&systemInfo) - let machineMirror = Mirror(reflecting: systemInfo.machine) - let machineName = machineMirror.children.reduce("") { identifier, element in - guard let value = element.value as? Int8, value != 0 else { return identifier } - return identifier + String(UnicodeScalar(UInt8(value))) - } - var deviceInfo = String("Machine: ") + machineName + "\n" - #if os(iOS) - await deviceInfo += String("System: ") + (UIDevice.current.systemName) + " " + (UIDevice.current.systemVersion) + "\n" - #elseif os(macOS) - deviceInfo += String("System: ") + "macOS " + ProcessInfo().operatingSystemVersionString + "\n" - #endif - content = deviceInfo + "\n" + content - } - await MainActor.run { [content] in - self.content = content - isLoading = false - } - } - - nonisolated func deleteContent(dismiss: DismissAction) async { - let primaryLogURL = FilePath.cacheDirectory.appendingPathComponent("stderr.log") - let secondaryLogURL = FilePath.cacheDirectory.appendingPathComponent("stderr.log.old") - await BlockingIO.run { - try? FileManager.default.removeItem(at: primaryLogURL) - try? FileManager.default.removeItem(at: secondaryLogURL) - } - await MainActor.run { - dismiss() - isLoading = true - } - } - - func generateShareFile() throws -> URL { - try content.generateShareFile(name: "service.log") - } - - func generateShareFileAsync() async throws -> URL { - let content = content - return try await BlockingIO.run { - try content.generateShareFile(name: "service.log") - } - } -} diff --git a/ApplicationLibrary/Views/Setting/SettingView.swift b/ApplicationLibrary/Views/Setting/SettingView.swift index 534b51a..cfe18fd 100644 --- a/ApplicationLibrary/Views/Setting/SettingView.swift +++ b/ApplicationLibrary/Views/Setting/SettingView.swift @@ -149,14 +149,17 @@ public struct SettingView: View { } #endif - @StateObject private var viewModel = SettingViewModel() public init() {} public var body: some View { FormView { Section { - ForEach([Tabs.app, Tabs.core, Tabs.packetTunnel, Tabs.onDemandRules, Tabs.profileOverride]) { it in - it.navigationLink - } + Tabs.app.navigationLink + Tabs.core.navigationLink + #if !os(tvOS) + Tabs.packetTunnel.navigationLink + #endif + Tabs.onDemandRules.navigationLink + Tabs.profileOverride.navigationLink } #if !os(tvOS) Section("About") { @@ -193,25 +196,6 @@ public struct SettingView: View { #endif } #endif - Section("Debug") { - FormNavigationLink { - ServiceLogView() - } label: { - Label("Service Log", systemImage: "doc.on.clipboard") - } - FormTextItem("Taiwan Flag Available", "touchid") { - if viewModel.isLoading { - Text("Loading...") - .onAppear { - Task.detached { - await viewModel.checkTaiwanFlagAvailability() - } - } - } else { - Text(viewModel.taiwanFlagAvailable.toString()) - } - } - } } #if os(macOS) .formNavigationDestination(for: SettingsPage.self) { page in diff --git a/ApplicationLibrary/Views/Tools/CrashReportDetailView.swift b/ApplicationLibrary/Views/Tools/CrashReportDetailView.swift new file mode 100644 index 0000000..f7a4a65 --- /dev/null +++ b/ApplicationLibrary/Views/Tools/CrashReportDetailView.swift @@ -0,0 +1,164 @@ +import Library +import SwiftUI + +@MainActor +public struct CrashReportDetailView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var environments: ExtensionEnvironments + + @State private var alert: AlertState? + @State private var files: [CrashReportFile] = [] + @State private var isLoading = true + + #if os(macOS) + @State private var sharePresented = false + @State private var shareItemURL: URL? + #elseif os(tvOS) + @State private var showExport = false + #endif + + let report: CrashReport + + public init(report: CrashReport) { + self.report = report + } + + private var manager: CrashReportManager { + environments.crashReportManager + } + + #if !os(tvOS) + private func shareReport(includeConfig: Bool) async { + do { + let zipURL = try await createReportZip( + reportID: report.id, fileURL: report.fileURL, + cacheSubdirectory: ReportType.crash.directoryName, includeConfig: includeConfig + ) + #if os(iOS) + presentShareSheet(zipURL) + #elseif os(macOS) + shareItemURL = zipURL + sharePresented = true + #endif + } catch { + alert = AlertState(action: "export crash reports", error: error) + } + } + #endif + + public var body: some View { + FormView { + if !isLoading, !files.isEmpty { + Section("Files") { + ForEach(files) { file in + if file.id == .metadata { + FormNavigationLink { + MetadataFormView(url: file.fileURL, title: file.displayName) + } label: { + Text(file.displayName) + } + } else { + FormNavigationLink { + ReportFileContentView(fileURL: file.fileURL, displayName: file.displayName) + } label: { + Text(file.displayName) + } + } + } + } + } + } + .overlay { + if isLoading { + ProgressView() + } else if files.isEmpty { + Text("Empty") + .foregroundStyle(.secondary) + } + } + .onAppear { + Task { + files = await manager.availableFiles(for: report) + manager.markAsRead(report) + isLoading = false + } + } + .alert($alert) + #if os(tvOS) + .navigationDestination(isPresented: $showExport) { + ExportReportView(reportType: .crash, reportURL: report.fileURL, reportDate: report.date) + .toolbar { + ToolbarItemGroup(placement: .topBarLeading) { + BackButton() + } + } + } + #elseif os(macOS) + .background(SharingServicePicker($sharePresented, $alert, $shareItemURL)) + #endif + .toolbar { + if !isLoading, !files.isEmpty { + #if os(tvOS) + ToolbarItem(placement: .confirmationAction) { + Button { + showExport = true + } label: { + Image(systemName: "square.and.arrow.up") + } + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + await manager.delete(report) + dismiss() + } + } label: { + Image(systemName: "trash.fill") + } + .tint(.red) + } + #else + if files.contains(where: { $0.id == .configContent }) { + Menu { + Button { + Task { + await shareReport(includeConfig: false) + } + } label: { + Label("Share", systemImage: "square.and.arrow.up") + } + Button { + Task { + await shareReport(includeConfig: true) + } + } label: { + Label("Share With Configuration", systemImage: "square.and.arrow.up.on.square") + } + } label: { + Label("Share", systemImage: "square.and.arrow.up") + } + } else { + Button { + Task { + await shareReport(includeConfig: false) + } + } label: { + Label("Share", systemImage: "square.and.arrow.up") + } + } + Button(role: .destructive) { + Task { + await manager.delete(report) + dismiss() + } + } label: { + Label("Delete", systemImage: "trash.fill") + .foregroundStyle(.red) + } + .tint(.red) + #endif + } + } + .navigationTitle(report.date.formatted(date: .abbreviated, time: .shortened)) + } +} diff --git a/ApplicationLibrary/Views/Tools/CrashReportListView.swift b/ApplicationLibrary/Views/Tools/CrashReportListView.swift new file mode 100644 index 0000000..20e0be3 --- /dev/null +++ b/ApplicationLibrary/Views/Tools/CrashReportListView.swift @@ -0,0 +1,249 @@ +import Libbox +import Library +import SwiftUI + +@MainActor +public struct CrashReportListView: View { + @EnvironmentObject private var environments: ExtensionEnvironments + @State private var isLoading = true + @State private var alert: AlertState? + #if os(tvOS) + @State private var showCrashTrigger = false + @State private var selectedReport: CrashReport? + #endif + + public init() {} + + private var manager: CrashReportManager { + environments.crashReportManager + } + + public var body: some View { + FormView { + if !isLoading { + Section { + if manager.reports.isEmpty { + Text("Empty") + .foregroundStyle(.secondary) + } else { + ForEach(manager.reports) { report in + #if os(tvOS) + Button { + selectedReport = report + } label: { + reportLabel(report) + } + #else + FormNavigationLink { + CrashReportDetailView(report: report) + } label: { + reportLabel(report) + } + #endif + } + } + } header: { + Text("Reports") + } footer: { + Text("You will receive a report when a crash occurs.") + } + } + } + .overlay { + if isLoading { + ProgressView() + } + } + .onAppear { + Task { + await manager.refresh() + isLoading = false + } + } + .navigationTitle("Crash Report") + .alert($alert) + #if os(tvOS) + .navigationDestination(item: $selectedReport) { report in + CrashReportDetailView(report: report) + .toolbar { + ToolbarItemGroup(placement: .topBarLeading) { + BackButton() + } + } + } + #endif + #if os(tvOS) + .navigationDestination(isPresented: $showCrashTrigger) { + CrashTriggerView() + } + .toolbar { + if SharedPreferences.inDebug { + ToolbarItem(placement: .confirmationAction) { + Button { + showCrashTrigger = true + } label: { + Image(systemName: "ant.fill") + } + } + } + if !manager.reports.isEmpty { + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + await manager.deleteAll() + } + } label: { + Image(systemName: "trash.fill") + } + .tint(.red) + } + } + } + #else + .toolbar { + if !manager.reports.isEmpty || SharedPreferences.inDebug { + Menu { + if SharedPreferences.inDebug { + Menu { + Menu("Application") { + Button("Go Crash") { + LibboxTriggerGoPanic() + } + Button("Native Crash") { + DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(200)) { + fatalError("debug native crash") + } + } + } + if let profile = environments.extensionProfile { + NetworkExtensionCrashMenu(profile: profile) + } + #if os(macOS) + RootHelperCrashMenu() + #endif + } label: { + Label("Crash Trigger", systemImage: "ant.fill") + } + } + if !manager.reports.isEmpty { + Button(role: .destructive) { + Task { + await manager.deleteAll() + } + } label: { + Label("Delete All", systemImage: "trash.fill") + } + } + } label: { + Label("Others", systemImage: "line.3.horizontal.circle") + } + } + } + #endif + } + + private func reportLabel(_ report: CrashReport) -> some View { + ReportLabel(date: report.date, isRead: report.isRead, origin: report.origin) + } +} + +#if os(tvOS) + private struct CrashTriggerView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var environments: ExtensionEnvironments + + var body: some View { + Form { + Section("Application") { + Button("Go Crash") { + LibboxTriggerGoPanic() + } + Button("Native Crash") { + DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(200)) { + fatalError("debug native crash") + } + } + } + if let profile = environments.extensionProfile, profile.status.isConnectedStrict { + Section("NetworkExtension") { + Button("Go Crash") { + try? LibboxNewStandaloneCommandClient()?.triggerGoCrash() + dismiss() + Task { + try? await Task.sleep(nanoseconds: NSEC_PER_SEC) + await environments.crashReportManager.refresh() + } + } + Button("Native Crash") { + try? LibboxNewStandaloneCommandClient()?.triggerNativeCrash() + dismiss() + Task { + try? await Task.sleep(nanoseconds: NSEC_PER_SEC) + await environments.crashReportManager.refresh() + } + } + } + } + } + .navigationTitle("Crash Trigger") + .toolbar { + ToolbarItemGroup(placement: .topBarLeading) { + BackButton() + } + } + } + } +#else + private struct NetworkExtensionCrashMenu: View { + @EnvironmentObject private var environments: ExtensionEnvironments + @ObservedObject var profile: ExtensionProfile + + var body: some View { + if profile.status.isConnectedStrict { + Menu("NetworkExtension") { + Button("Go Crash") { + try? LibboxNewStandaloneCommandClient()?.triggerGoCrash() + Task { + try? await Task.sleep(nanoseconds: NSEC_PER_SEC) + await environments.crashReportManager.refresh() + } + } + Button("Native Crash") { + try? LibboxNewStandaloneCommandClient()?.triggerNativeCrash() + Task { + try? await Task.sleep(nanoseconds: NSEC_PER_SEC) + await environments.crashReportManager.refresh() + } + } + } + } + } + } +#endif + +#if os(macOS) + private struct RootHelperCrashMenu: View { + @EnvironmentObject private var environments: ExtensionEnvironments + + var body: some View { + if Variant.useSystemExtension, HelperServiceManager.rootHelperStatus == .enabled { + Menu("RootHelper") { + Button("Go Crash") { + try? RootHelperClient.shared.triggerGoCrash() + Task { + try? await Task.sleep(nanoseconds: NSEC_PER_SEC) + await environments.crashReportManager.refresh() + } + } + Button("Native Crash") { + try? RootHelperClient.shared.triggerNativeCrash() + Task { + try? await Task.sleep(nanoseconds: NSEC_PER_SEC) + await environments.crashReportManager.refresh() + } + } + } + } + } + } +#endif diff --git a/ApplicationLibrary/Views/Tools/ExportReportView.swift b/ApplicationLibrary/Views/Tools/ExportReportView.swift new file mode 100644 index 0000000..ffdc96f --- /dev/null +++ b/ApplicationLibrary/Views/Tools/ExportReportView.swift @@ -0,0 +1,173 @@ +#if os(tvOS) + + import DeviceDiscoveryUI + import Library + import Network + import SwiftUI + + @MainActor + public struct ExportReportView: View { + @Environment(\.dismiss) private var dismiss + @StateObject private var viewModel = ExportReportViewModel() + + let reportType: ReportType + let reportURL: URL + let reportDate: Date + + public init(reportType: ReportType, reportURL: URL, reportDate: Date) { + self.reportType = reportType + self.reportURL = reportURL + self.reportDate = reportDate + } + + public var body: some View { + VStack(alignment: .center) { + if !viewModel.selected { + Form { + Section { + EmptyView() + } footer: { + Text("To export this report to your iPhone or iPad, make sure sing-box is the **same version** on both devices and **VPN is disabled**.") + } + + DevicePicker( + .applicationService(name: ReportTransferService.applicationServiceName) + ) { endpoint in + viewModel.selected = true + Task { + await viewModel.handleEndpoint(endpoint, reportType: reportType, reportURL: reportURL, reportDate: reportDate) + } + } label: { + Text("Select Device") + } fallback: { + EmptyView() + } parameters: { + .applicationService + } + } + } else if viewModel.exportComplete { + VStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 64)) + .foregroundStyle(.green) + Text("Export Complete") + .font(.headline) + } + } else { + VStack(spacing: 16) { + ProgressView() + Text("Sending...") + } + } + } + .focusSection() + .alert($viewModel.alert) + .navigationTitle("Export Report") + .onChange(of: viewModel.exportComplete) { newValue in + if newValue { + Task { + try? await Task.sleep(nanoseconds: NSEC_PER_SEC * 2) + dismiss() + } + } + } + } + } + + @MainActor + private final class ExportReportViewModel: BaseViewModel { + @Published var selected = false + @Published var exportComplete = false + + private var connection: NWConnection? + private var socket: NWSocket? + + func reset() { + cancelConnection() + selected = false + } + + private func cancelConnection() { + if let connection { + connection.stateUpdateHandler = nil + connection.cancel() + self.connection = nil + } + if let socket { + socket.cancel() + self.socket = nil + } + } + + func handleEndpoint(_ endpoint: NWEndpoint, reportType: ReportType, reportURL: URL, reportDate: Date) async { + let connection = NWConnection(to: endpoint, using: NWParameters.applicationService) + self.connection = connection + let socket = NWSocket(connection) + self.socket = socket + + connection.stateUpdateHandler = { state in + switch state { + case let .failed(error): + DispatchQueue.main.async { [self] in + reset() + alert = AlertState(action: "connect to device", error: error) + } + default: break + } + } + connection.start(queue: .global()) + + do { + try await sendReport(reportType: reportType, reportURL: reportURL, reportDate: reportDate, via: socket) + cancelConnection() + exportComplete = true + } catch { + alert = AlertState(action: "export report", error: error) + reset() + } + } + + private nonisolated func sendReport(reportType: ReportType, reportURL: URL, reportDate: Date, via socket: NWSocket) async throws { + let fm = FileManager.default + guard let fileURLs = try? fm.contentsOfDirectory( + at: reportURL, + includingPropertiesForKeys: nil, + options: .skipsHiddenFiles + ) else { + throw ReportTransferError("Report is empty") + } + + var files: [ReportTransferFile] = [] + for fileURL in fileURLs { + guard let data = try? Data(contentsOf: fileURL) else { continue } + files.append(ReportTransferFile(name: fileURL.lastPathComponent, data: data)) + } + + guard !files.isEmpty else { + throw ReportTransferError("Report is empty") + } + + let payload = ReportTransferPayload( + reportType: reportType, + timestamp: reportDate.timeIntervalSince1970, + files: files + ) + try await socket.write(ReportTransferMessage.encodeReport(payload)) + try await socket.write(ReportTransferMessage.encodeComplete()) + + let response = try await socket.read() + guard let responseType = ReportTransferMessage.decodeType(response) else { + throw NWSocketError.connectionClosed + } + switch responseType { + case .ack: + break + case .error: + throw ReportTransferError(ReportTransferMessage.decodeError(response)) + default: + throw NWSocketError.connectionClosed + } + } + } + +#endif diff --git a/ApplicationLibrary/Views/Tools/OOMReportDetailView.swift b/ApplicationLibrary/Views/Tools/OOMReportDetailView.swift new file mode 100644 index 0000000..d385c44 --- /dev/null +++ b/ApplicationLibrary/Views/Tools/OOMReportDetailView.swift @@ -0,0 +1,166 @@ +import Library +import SwiftUI + +@MainActor +public struct OOMReportDetailView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var environments: ExtensionEnvironments + + @State private var alert: AlertState? + @State private var files: [OOMReportFile] = [] + @State private var isLoading = true + + #if os(macOS) + @State private var sharePresented = false + @State private var shareItemURL: URL? + #elseif os(tvOS) + @State private var showExport = false + #endif + + let report: OOMReport + + public init(report: OOMReport) { + self.report = report + } + + private var manager: OOMReportManager { + environments.oomReportManager + } + + #if !os(tvOS) + private func shareReport(includeConfig: Bool) async { + do { + let zipURL = try await createReportZip( + reportID: report.id, fileURL: report.fileURL, + cacheSubdirectory: ReportType.oom.directoryName, includeConfig: includeConfig + ) + #if os(iOS) + presentShareSheet(zipURL) + #elseif os(macOS) + shareItemURL = zipURL + sharePresented = true + #endif + } catch { + alert = AlertState(action: "export OOM report", error: error) + } + } + #endif + + public var body: some View { + FormView { + if !isLoading, !files.isEmpty { + Section("Files") { + ForEach(files) { file in + if file.kind == .metadata { + FormNavigationLink { + MetadataFormView(url: file.fileURL, title: file.displayName) + } label: { + Text(file.displayName) + } + } else if file.kind == .configContent { + FormNavigationLink { + ReportFileContentView(fileURL: file.fileURL, displayName: file.displayName) + } label: { + Text(file.displayName) + } + } else { + Text(file.displayName) + } + } + } + } + } + .overlay { + if isLoading { + ProgressView() + } else if files.isEmpty { + Text("Empty") + .foregroundStyle(.secondary) + } + } + .onAppear { + Task { + files = await manager.availableFiles(for: report) + manager.markAsRead(report) + isLoading = false + } + } + .alert($alert) + #if os(tvOS) + .navigationDestination(isPresented: $showExport) { + ExportReportView(reportType: .oom, reportURL: report.fileURL, reportDate: report.date) + .toolbar { + ToolbarItemGroup(placement: .topBarLeading) { + BackButton() + } + } + } + #elseif os(macOS) + .background(SharingServicePicker($sharePresented, $alert, $shareItemURL)) + #endif + .toolbar { + if !isLoading, !files.isEmpty { + #if os(tvOS) + ToolbarItem(placement: .confirmationAction) { + Button { + showExport = true + } label: { + Image(systemName: "square.and.arrow.up") + } + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + await manager.delete(report) + dismiss() + } + } label: { + Image(systemName: "trash.fill") + } + .tint(.red) + } + #else + if files.contains(where: { $0.kind == .configContent }) { + Menu { + Button { + Task { + await shareReport(includeConfig: false) + } + } label: { + Label("Share", systemImage: "square.and.arrow.up") + } + Button { + Task { + await shareReport(includeConfig: true) + } + } label: { + Label("Share With Configuration", systemImage: "square.and.arrow.up.on.square") + } + } label: { + Label("Share", systemImage: "square.and.arrow.up") + } + } else { + Button { + Task { + await shareReport(includeConfig: false) + } + } label: { + Label("Share", systemImage: "square.and.arrow.up") + } + } + Button(role: .destructive) { + Task { + await manager.delete(report) + dismiss() + } + } label: { + Label("Delete", systemImage: "trash.fill") + .foregroundStyle(.red) + } + .tint(.red) + #endif + } + } + .navigationTitle(report.date.formatted(date: .abbreviated, time: .shortened)) + } +} diff --git a/ApplicationLibrary/Views/Tools/OOMReportListView.swift b/ApplicationLibrary/Views/Tools/OOMReportListView.swift new file mode 100644 index 0000000..e3a37ec --- /dev/null +++ b/ApplicationLibrary/Views/Tools/OOMReportListView.swift @@ -0,0 +1,246 @@ +import Libbox +import Library +import SwiftUI + +@MainActor +public struct OOMReportListView: View { + @EnvironmentObject private var environments: ExtensionEnvironments + @State private var isLoading = true + #if os(tvOS) + @State private var selectedReport: OOMReport? + #endif + #if os(macOS) + @State private var oomKillerEnabled = false + @State private var oomMemoryLimitMB = 50 + @State private var oomKillerKillConnections = false + @State private var alert: AlertState? + #endif + + public init() {} + + private var manager: OOMReportManager { + environments.oomReportManager + } + + public var body: some View { + FormView { + if !isLoading { + Section { + if manager.reports.isEmpty { + Text("Empty") + .foregroundStyle(.secondary) + } else { + ForEach(manager.reports) { report in + #if os(tvOS) + Button { + selectedReport = report + } label: { + reportLabel(report) + } + #else + FormNavigationLink { + OOMReportDetailView(report: report) + } label: { + reportLabel(report) + } + #endif + } + } + } header: { + Text("Reports") + } footer: { + #if os(macOS) + Text("When memory limit is enabled, you will receive a report if the service memory exceeds the limit. You can also manually trigger report collection.") + #else + Text("You will receive a report when the service runs out of memory. You can also manually trigger report collection.") + #endif + } + + #if os(macOS) + Section { + FormToggle("Enable Memory Limit", """ + Provide a soft memory limit for the service. The service will perform multiple processes to try to stay within this memory limit. + """, $oomKillerEnabled) { newValue in + await SharedPreferences.oomKillerEnabled.set(newValue) + await restartService() + } + + if oomKillerEnabled { + Picker("Memory Limit", selection: $oomMemoryLimitMB) { + ForEach(Self.memoryLimitOptions, id: \.self) { value in + Text(LibboxFormatMemoryBytes(Int64(value) * 1024 * 1024)) + .tag(value) + } + } + .onChange(of: oomMemoryLimitMB) { _ in + Task { + await SharedPreferences.oomMemoryLimitMB.set(oomMemoryLimitMB) + await restartService() + } + } + + FormToggle("Kill Connections", """ + Kill all connections to free memory when the service memory exceeds the limit. + """, $oomKillerKillConnections) { newValue in + await SharedPreferences.oomKillerKillConnections.set(newValue) + await restartService() + } + } + } header: { + Text("Settings") + } + #endif + } + } + .overlay { + if isLoading { + ProgressView() + } + } + .onAppear { + Task { + await manager.refresh() + #if os(macOS) + oomKillerEnabled = await SharedPreferences.oomKillerEnabled.get() + let storedLimit = await SharedPreferences.oomMemoryLimitMB.get() + if Self.memoryLimitOptions.contains(storedLimit) { + oomMemoryLimitMB = storedLimit + } else { + oomMemoryLimitMB = Self.memoryLimitOptions.first! + await SharedPreferences.oomMemoryLimitMB.set(oomMemoryLimitMB) + } + oomKillerKillConnections = await SharedPreferences.oomKillerKillConnections.get() + #endif + isLoading = false + } + } + .navigationTitle("OOM Report") + #if os(macOS) + .alert($alert) + #endif + #if os(tvOS) + .navigationDestination(item: $selectedReport) { report in + OOMReportDetailView(report: report) + .toolbar { + ToolbarItemGroup(placement: .topBarLeading) { + BackButton() + } + } + } + #endif + .toolbar { + #if os(tvOS) + if !manager.reports.isEmpty { + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + await manager.deleteAll() + } + } label: { + Image(systemName: "trash.fill") + } + .tint(.red) + } + } + if let profile = environments.extensionProfile { + ToolbarItem(placement: .confirmationAction) { + OOMReportTriggerButton(manager: manager, profile: profile) + } + } + #else + if let profile = environments.extensionProfile { + OOMReportToolbarMenu(manager: manager, profile: profile) + } else if !manager.reports.isEmpty { + Menu { + Button(role: .destructive) { + Task { + await manager.deleteAll() + } + } label: { + Label("Delete All", systemImage: "trash.fill") + } + } label: { + Label("Others", systemImage: "line.3.horizontal.circle") + } + } + #endif + } + } + + private func reportLabel(_ report: OOMReport) -> some View { + ReportLabel(date: report.date, isRead: report.isRead, origin: report.origin) + } + + #if os(macOS) + private static let memoryLimitOptions = [50, 100, 200, 300, 500, 750, 1024] + + private func restartService() async { + guard let profile = environments.extensionProfile, profile.status.isConnected else { + return + } + do { + try await profile.restart() + } catch { + alert = AlertState(action: "restart service", error: error) + } + } + #endif +} + +#if os(tvOS) + private struct OOMReportTriggerButton: View { + let manager: OOMReportManager + @ObservedObject var profile: ExtensionProfile + @State private var alert: AlertState? + + var body: some View { + Button { + triggerOOMReport(profile: profile, manager: manager, alert: &alert) + } label: { + Image(systemName: "memorychip") + } + .alert($alert) + } + } +#else + private struct OOMReportToolbarMenu: View { + let manager: OOMReportManager + @ObservedObject var profile: ExtensionProfile + @State private var alert: AlertState? + + var body: some View { + Menu { + Button { + triggerOOMReport(profile: profile, manager: manager, alert: &alert) + } label: { + Label("Fetch Memory Report", systemImage: "memorychip") + } + if !manager.reports.isEmpty { + Button(role: .destructive) { + Task { + await manager.deleteAll() + } + } label: { + Label("Delete All", systemImage: "trash.fill") + } + } + } label: { + Label("Others", systemImage: "line.3.horizontal.circle") + } + .alert($alert) + } + } +#endif + +@MainActor +private func triggerOOMReport(profile: ExtensionProfile, manager: OOMReportManager, alert: inout AlertState?) { + guard profile.status.isConnectedStrict else { + alert = AlertState(errorMessage: String(localized: "Service not started")) + return + } + try? LibboxNewStandaloneCommandClient()?.triggerOOMReport() + Task { + try? await Task.sleep(nanoseconds: NSEC_PER_SEC) + await manager.refresh() + } +} diff --git a/ApplicationLibrary/Views/Tools/ReportShared.swift b/ApplicationLibrary/Views/Tools/ReportShared.swift new file mode 100644 index 0000000..33957ff --- /dev/null +++ b/ApplicationLibrary/Views/Tools/ReportShared.swift @@ -0,0 +1,127 @@ +import Libbox +import Library +import SwiftUI + +#if canImport(UIKit) + import UIKit +#endif + +struct ReportLabel: View { + let date: Date + let isRead: Bool + let origin: String? + + var body: some View { + HStack(spacing: 8) { + Circle() + .fill(isRead ? .clear : .blue) + .frame(width: 10, height: 10) + VStack(alignment: .leading, spacing: 2) { + Text(date, format: .dateTime) + .fontWeight(isRead ? .regular : .semibold) + HStack(spacing: 4) { + Image(systemName: origin == ReportArchive.tvOSDeviceOrigin ? "appletv.fill" : Self.localDeviceIcon) + Text(origin == ReportArchive.tvOSDeviceOrigin ? "Apple TV" : "Local") + } + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + #if os(iOS) + private static let localDeviceIcon = "iphone" + #elseif os(macOS) + private static let localDeviceIcon = "desktopcomputer" + #elseif os(tvOS) + private static let localDeviceIcon = "appletv.fill" + #endif +} + +@MainActor +struct ReportFileContentView: View { + @State private var content = "" + @State private var isLoading = true + + let fileURL: URL + let displayName: String + + var body: some View { + Group { + if isLoading { + ProgressView() + .onAppear { + Task { + content = await Self.loadContent(fileURL: fileURL) + isLoading = false + } + } + } else if content.isEmpty { + Text("Empty") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + #if os(iOS) + ScrollView { + PlainTextView(content: content) + } + #else + PlainTextView(content: content) + #endif + } + } + .navigationTitle(displayName) + } + + private nonisolated static func loadContent(fileURL: URL) async -> String { + await BlockingIO.run { + guard let data = try? Data(contentsOf: fileURL) else { + return "" + } + return String(data: data, encoding: .utf8) ?? "" + } + } +} + +#if !os(tvOS) + @MainActor + func createReportZip(reportID: String, fileURL: URL, cacheSubdirectory: String, includeConfig: Bool) async throws -> URL { + try await BlockingIO.run { + let tempDir = FilePath.cacheDirectory.appendingPathComponent(cacheSubdirectory, isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let tempURL = tempDir.appendingPathComponent("\(reportID).zip") + try? FileManager.default.removeItem(at: tempURL) + let strippedURL = tempDir.appendingPathComponent(reportID, isDirectory: true) + try? FileManager.default.removeItem(at: strippedURL) + try FileManager.default.copyItem(at: fileURL, to: strippedURL) + try? FileManager.default.removeItem(at: strippedURL.appendingPathComponent(ReportArchive.readMarkerFileName)) + if !includeConfig { + try? FileManager.default.removeItem(at: strippedURL.appendingPathComponent(ReportArchive.configFileName)) + } + var error: NSError? + LibboxCreateZipArchive(strippedURL.path, tempURL.path, &error) + try? FileManager.default.removeItem(at: strippedURL) + if let error { throw error } + return tempURL + } + } + + #if os(iOS) + @MainActor + func presentShareSheet(_ item: URL) { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootViewController = windowScene.keyWindow?.rootViewController + else { + return + } + var topViewController = rootViewController + while let presented = topViewController.presentedViewController { + topViewController = presented + } + topViewController.present( + UIActivityViewController(activityItems: [item], applicationActivities: nil), + animated: true + ) + } + #endif +#endif diff --git a/ApplicationLibrary/Views/Tools/ToolsView.swift b/ApplicationLibrary/Views/Tools/ToolsView.swift new file mode 100644 index 0000000..e344291 --- /dev/null +++ b/ApplicationLibrary/Views/Tools/ToolsView.swift @@ -0,0 +1,97 @@ +import Library +import SwiftUI + +@MainActor +public struct ToolsView: View { + @EnvironmentObject private var environments: ExtensionEnvironments + @StateObject private var viewModel = SettingViewModel() + #if os(iOS) + @State private var showCrashReportList = false + @State private var showOOMReportList = false + #endif + + public init() {} + + public var body: some View { + FormView { + Section("Debug") { + #if os(iOS) + NavigationLink(isActive: $showCrashReportList) { + CrashReportListView() + } label: { + Label("Crash Report", systemImage: "ladybug.fill") + .badge(environments.crashReportManager.unreadCount) + } + .onReceive(NotificationCenter.default.publisher(for: .reportReceived)) { notification in + Task { + try? await Task.sleep(nanoseconds: NSEC_PER_MSEC * 300) + if let reportType = notification.object as? ReportType { + switch reportType { + case .crash: + showCrashReportList = true + case .oom: + showOOMReportList = true + } + } + } + } + NavigationLink(isActive: $showOOMReportList) { + OOMReportListView() + } label: { + Label("OOM Report", systemImage: "memorychip") + .badge(environments.oomReportManager.unreadCount) + } + #else + FormNavigationLink { + CrashReportListView() + } label: { + #if os(tvOS) + HStack { + Label("Crash Report", systemImage: "ladybug.fill") + Spacer() + if environments.crashReportManager.unreadCount > 0 { + Text("\(environments.crashReportManager.unreadCount)") + .foregroundStyle(.secondary) + } + } + #else + Label("Crash Report", systemImage: "ladybug.fill") + .badge(environments.crashReportManager.unreadCount) + #endif + } + #endif + #if !os(iOS) + FormNavigationLink { + OOMReportListView() + } label: { + #if os(tvOS) + HStack { + Label("OOM Report", systemImage: "memorychip") + Spacer() + if environments.oomReportManager.unreadCount > 0 { + Text("\(environments.oomReportManager.unreadCount)") + .foregroundStyle(.secondary) + } + } + #else + Label("OOM Report", systemImage: "memorychip") + .badge(environments.oomReportManager.unreadCount) + #endif + } + #endif + FormTextItem("Taiwan Flag Available", "touchid") { + if viewModel.isLoading { + Text("Loading...") + .onAppear { + Task.detached { + await viewModel.checkTaiwanFlagAvailability() + } + } + } else { + Text(viewModel.taiwanFlagAvailable.toString()) + } + } + } + } + } +} diff --git a/HelperService/RootHelperService.swift b/HelperService/RootHelperService.swift index 4dbead3..e6c7265 100644 --- a/HelperService/RootHelperService.swift +++ b/HelperService/RootHelperService.swift @@ -27,6 +27,7 @@ class RootHelperService: NSObject { private var pathMonitor: NWPathMonitor? private var pendingNATFlush: DispatchWorkItem? private var tunInterfaceName: String? + var pendingCrashLogs: [CrashLogFileResult] = [] func start() { listener = NSXPCListener(machServiceName: AppConfiguration.rootHelperMachService) @@ -142,6 +143,128 @@ extension RootHelperService: RootHelperProtocol { reply(nil) } + static func readCrashLogFiles() -> [CrashLogFileResult] { + var results: [CrashLogFileResult] = [] + + let crashLogSearchPaths: [(directory: String, fileNames: [String])] = [ + (WorkingDirectoryManager.extensionWorkingDirectoryPath, [ + "CrashReport-NetworkExtension.log", + "CrashReport-NetworkExtension.log.old", + ]), + (WorkingDirectoryManager.helperWorkingDirectoryPath, [ + "CrashReport-RootHelper.log", + "CrashReport-RootHelper.log.old", + ]), + (WorkingDirectoryManager.extensionBasePath, [ + "configuration.json", + ]), + (WorkingDirectoryManager.helperBasePath, [ + "configuration.json", + ]), + ] + + for searchPath in crashLogSearchPaths { + for fileName in searchPath.fileNames { + let filePath = (searchPath.directory as NSString).appendingPathComponent(fileName) + guard FileManager.default.fileExists(atPath: filePath), + let content = try? String(contentsOfFile: filePath, encoding: .utf8), + !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + continue + } + + let attrs = try? FileManager.default.attributesOfItem(atPath: filePath) + let modificationDate = (attrs?[.modificationDate] as? Date) ?? Date() + + results.append(CrashLogFileResult( + fileName: fileName, + content: content, + modificationDate: modificationDate + )) + + try? FileManager.default.removeItem(atPath: filePath) + } + } + + return results + } + + func collectAllCrashArtifacts(reply: @escaping (CrashArtifactsResult?, NSError?) -> Void) { + let result = CrashArtifactsResult() + + var crashLogs = pendingCrashLogs + pendingCrashLogs.removeAll() + crashLogs.append(contentsOf: Self.readCrashLogFiles()) + result.crashLogs = crashLogs + + result.helperNativeCrashData = NativeCrashReporter.loadAndPurgePendingCrashReportData() + + let extensionReportURL = CrashReportArchive.pendingNativeCrashReportURL( + basePath: URL(fileURLWithPath: WorkingDirectoryManager.extensionNativeCrashBasePath, isDirectory: true), + bundleIdentifier: AppConfiguration.systemExtensionBundleID + ) + if let data = try? Data(contentsOf: extensionReportURL), !data.isEmpty { + result.extensionNativeCrashData = data + try? FileManager.default.removeItem(at: extensionReportURL) + } + + reply(result, nil) + } + + func collectOOMReportArtifacts(reply: @escaping (OOMReportArtifactsResult?, NSError?) -> Void) { + let result = OOMReportArtifactsResult() + let oomReportsPath = WorkingDirectoryManager.extensionOOMReportsPath + let fm = FileManager.default + + guard fm.fileExists(atPath: oomReportsPath), + let entries = try? fm.contentsOfDirectory(atPath: oomReportsPath) + else { + reply(result, nil) + return + } + + for entry in entries { + let dirPath = (oomReportsPath as NSString).appendingPathComponent(entry) + var isDir: ObjCBool = false + guard fm.fileExists(atPath: dirPath, isDirectory: &isDir), isDir.boolValue else { + continue + } + + guard let fileNames = try? fm.contentsOfDirectory(atPath: dirPath) else { + continue + } + + var files: [OOMReportFileResult] = [] + for fileName in fileNames { + let filePath = (dirPath as NSString).appendingPathComponent(fileName) + guard let data = fm.contents(atPath: filePath) else { + continue + } + files.append(OOMReportFileResult(name: fileName, data: data)) + } + + if !files.isEmpty { + result.reports.append(OOMReportDirectoryResult(directoryName: entry, files: files)) + } + + try? fm.removeItem(atPath: dirPath) + } + + reply(result, nil) + } + + func triggerGoCrash(reply: @escaping (NSError?) -> Void) { + reply(nil) + LibboxTriggerGoPanic() + } + + func triggerNativeCrash(reply: @escaping (NSError?) -> Void) { + reply(nil) + DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(200)) { + fatalError("debug native crash") + } + } + func closeNeighborMonitor(reply: @escaping (NSError?) -> Void) { logger.info("closeNeighborMonitor") closeNeighborMonitorInternal() diff --git a/HelperService/WorkingDirectoryManager.swift b/HelperService/WorkingDirectoryManager.swift index cc1fd09..137ffd8 100644 --- a/HelperService/WorkingDirectoryManager.swift +++ b/HelperService/WorkingDirectoryManager.swift @@ -2,12 +2,44 @@ import Foundation import Library enum WorkingDirectoryManager { - private static var workingDirectoryPath: String { - "/var/root/Library/Containers/\(AppConfiguration.systemExtensionBundleID)/Data/Working" + static var extensionBasePath: String { + "/var/root/Library/Containers/\(AppConfiguration.systemExtensionBundleID)/Data" + } + + static var extensionWorkingDirectoryPath: String { + (extensionBasePath as NSString).appendingPathComponent("Working") + } + + static var tempDirectoryPath: String { + "/var/root/Library/Containers/\(AppConfiguration.systemExtensionBundleID)/Data/Temp" + } + + static var helperBasePath: String { + "/var/root/Library/Containers/\(AppConfiguration.rootHelperBundleID)/Data" + } + + static var helperWorkingDirectoryPath: String { + (helperBasePath as NSString).appendingPathComponent("Working") + } + + static var helperTempDirectoryPath: String { + (helperBasePath as NSString).appendingPathComponent("Temp") + } + + static var helperNativeCrashBasePath: String { + (helperBasePath as NSString).appendingPathComponent("NativeCrash") + } + + static var extensionNativeCrashBasePath: String { + "/var/root/Library/Containers/\(AppConfiguration.systemExtensionBundleID)/Data/NativeCrash" + } + + static var extensionOOMReportsPath: String { + (extensionWorkingDirectoryPath as NSString).appendingPathComponent("oom_reports") } static func getSize() -> Int64 { - let path = workingDirectoryPath + let path = extensionWorkingDirectoryPath guard FileManager.default.fileExists(atPath: path) else { return 0 } @@ -28,7 +60,7 @@ enum WorkingDirectoryManager { } static func clean() throws { - let path = workingDirectoryPath + let path = extensionWorkingDirectoryPath guard FileManager.default.fileExists(atPath: path) else { return } diff --git a/HelperService/main.swift b/HelperService/main.swift index 5ae3b8b..f7b01b2 100644 --- a/HelperService/main.swift +++ b/HelperService/main.swift @@ -1,5 +1,22 @@ import Foundation +import Libbox +import Library + +NativeCrashReporter.installForCurrentProcess( + basePath: URL(fileURLWithPath: WorkingDirectoryManager.helperNativeCrashBasePath, isDirectory: true) +) + +let pendingCrashLogs = RootHelperService.readCrashLogFiles() + +let setupOptions = LibboxSetupOptions() +setupOptions.basePath = WorkingDirectoryManager.helperBasePath +setupOptions.workingPath = WorkingDirectoryManager.helperWorkingDirectoryPath +setupOptions.tempPath = WorkingDirectoryManager.helperTempDirectoryPath +setupOptions.crashReportSource = "RootHelper" +var setupError: NSError? +LibboxSetup(setupOptions, &setupError) let service = RootHelperService() +service.pendingCrashLogs = pendingCrashLogs service.start() dispatchMain() diff --git a/Library/Database/SharedPreferences.swift b/Library/Database/SharedPreferences.swift index 173f7f2..e2c252f 100644 --- a/Library/Database/SharedPreferences.swift +++ b/Library/Database/SharedPreferences.swift @@ -22,10 +22,6 @@ import Foundation public enum SharedPreferences { public static let selectedProfileID = Preference("selected_profile_id", defaultValue: -1) - #if !os(macOS) - public static let ignoreMemoryLimit = Preference("ignore_memory_limit", defaultValue: false) - #endif - #if os(iOS) private static let excludeLocalNetworksByDefault = true #elseif os(macOS) @@ -43,7 +39,7 @@ public enum SharedPreferences { #endif public static func resetPacketTunnel() async { - #if os(macOS) + #if !os(tvOS) let names = [ includeAllNetworks.name, excludeAPNs.name, @@ -52,24 +48,18 @@ public enum SharedPreferences { enforceRoutes.name, excludeDeviceCommunication.name, ] - #elseif os(tvOS) - let names = [ignoreMemoryLimit.name] - #else - let names = [ - ignoreMemoryLimit.name, - includeAllNetworks.name, - excludeAPNs.name, - excludeLocalNetworks.name, - excludeCellularServices.name, - enforceRoutes.name, - excludeDeviceCommunication.name, - ] + try? await batchDelete(names) #endif - try? await batchDelete(names) } public static let maxLogLines = Preference("max_log_lines", defaultValue: 300) + #if os(macOS) + public static let oomKillerEnabled = Preference("oom_killer_enabled", defaultValue: false) + public static let oomMemoryLimitMB = Preference("oom_memory_limit_mb", defaultValue: 50) + public static let oomKillerKillConnections = Preference("oom_killer_kill_connections", defaultValue: false) + #endif + #if os(macOS) public static let showMenuBarExtra = Preference("show_menu_bar_extra", defaultValue: true) public static let menuBarExtraInBackground = Preference("menu_bar_extra_in_background", defaultValue: false) diff --git a/Library/Network/ExtensionEnvironments.swift b/Library/Network/ExtensionEnvironments.swift index a73b2b5..33dce00 100644 --- a/Library/Network/ExtensionEnvironments.swift +++ b/Library/Network/ExtensionEnvironments.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import SwiftUI #if canImport(UIKit) @@ -182,6 +183,12 @@ public struct ImportRemoteProfileRequest: Hashable, Identifiable { @MainActor public class ExtensionEnvironments: ObservableObject { @Published public var commandClient = CommandClient([.log, .status, .groups, .clashMode]) + public let crashReportManager = CrashReportManager() + public let oomReportManager = OOMReportManager() + public var totalUnreadReportCount: Int { + crashReportManager.unreadCount + oomReportManager.unreadCount + } + @Published public var extensionProfileLoading = true @Published public var extensionProfile: ExtensionProfile? @Published public var emptyProfiles = false @@ -193,8 +200,19 @@ public class ExtensionEnvironments: ObservableObject { public let profileUpdate = ObjectWillChangePublisher() public let selectedProfileUpdate = ObjectWillChangePublisher() public let openSettings = ObjectWillChangePublisher() + private var cancellables = Set() public init() { + crashReportManager.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + oomReportManager.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) if Variant.screenshotMode { extensionProfileLoading = false extensionProfile = .mock @@ -205,6 +223,8 @@ public class ExtensionEnvironments: ObservableObject { public func postReload() { Task { await reload() + await crashReportManager.refresh() + await oomReportManager.refresh() } } diff --git a/Library/Network/ExtensionPlatformInterface.swift b/Library/Network/ExtensionPlatformInterface.swift index 43c40c0..e6ce034 100644 --- a/Library/Network/ExtensionPlatformInterface.swift +++ b/Library/Network/ExtensionPlatformInterface.swift @@ -1,12 +1,14 @@ import Foundation import Libbox import NetworkExtension +import os import UserNotifications #if os(macOS) import CoreWLAN #endif public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol, LibboxCommandServerHandlerProtocol { + private static let logger = Logger(category: "ExtensionPlatformInterface") private let tunnel: ExtensionProvider private var networkSettings: NEPacketTunnelNetworkSettings? @@ -449,11 +451,17 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc } } + public func triggerNativeCrash() throws { + DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(200)) { + fatalError("debug native crash") + } + } + public func writeDebugMessage(_ message: String?) { guard let message else { return } - tunnel.writeMessage(message) + Self.logger.debug("\(message, privacy: .public)") } func reset() { diff --git a/Library/Network/ExtensionProfile.swift b/Library/Network/ExtensionProfile.swift index b3b52bc..c09f66c 100644 --- a/Library/Network/ExtensionProfile.swift +++ b/Library/Network/ExtensionProfile.swift @@ -216,8 +216,10 @@ public class ExtensionProfile: ObservableObject { let configContent = try await profile.readAsync() options["configContent"] = NSString(string: configContent) - #if !os(macOS) - options["ignoreMemoryLimit"] = await NSNumber(value: SharedPreferences.ignoreMemoryLimit.get()) + #if os(macOS) + options["oomKillerEnabled"] = await NSNumber(value: SharedPreferences.oomKillerEnabled.get()) + options["oomMemoryLimitMB"] = await NSNumber(value: SharedPreferences.oomMemoryLimitMB.get()) + options["oomKillerKillConnections"] = await NSNumber(value: SharedPreferences.oomKillerKillConnections.get()) #endif options["systemProxyEnabled"] = await NSNumber(value: SharedPreferences.systemProxyEnabled.get()) options["excludeDefaultRoute"] = await NSNumber(value: SharedPreferences.excludeDefaultRoute.get()) diff --git a/Library/Network/ExtensionProvider.swift b/Library/Network/ExtensionProvider.swift index 4e7f5f2..79c0264 100644 --- a/Library/Network/ExtensionProvider.swift +++ b/Library/Network/ExtensionProvider.swift @@ -80,6 +80,22 @@ open class ExtensionProvider: NEPacketTunnelProvider { private var locationDelegate: stubLocationDelegate? #endif + override public init() { + #if os(macOS) + if Variant.useSystemExtension { + NativeCrashReporter.installForCurrentProcess( + basePath: FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("NativeCrash") + ) + } else { + NativeCrashReporter.installForCurrentProcess() + } + #else + NativeCrashReporter.installForCurrentProcess() + #endif + super.init() + } + override open func startTunnel(options startOptions: [String: NSObject]?) async throws { let basePath: String let workingPath: String @@ -132,6 +148,8 @@ open class ExtensionProvider: NEPacketTunnelProvider { options.tempPath = tempPath options.logMaxLines = 3000 + options.debug = SharedPreferences.inDebug + options.crashReportSource = "NetworkExtension" #if os(tvOS) if let port = effectiveOptions["commandServerPort"] as? NSNumber { @@ -142,24 +160,21 @@ open class ExtensionProvider: NEPacketTunnelProvider { } #endif + #if os(macOS) + options.oomKillerEnabled = (effectiveOptions["oomKillerEnabled"] as? NSNumber)?.boolValue ?? false + let oomMemoryLimitMB = (effectiveOptions["oomMemoryLimitMB"] as? NSNumber)?.int64Value ?? 0 + options.oomMemoryLimit = oomMemoryLimitMB * 1024 * 1024 + options.oomKillerDisabled = !((effectiveOptions["oomKillerKillConnections"] as? NSNumber)?.boolValue ?? false) + #else + options.oomKillerEnabled = true + #endif + var setupError: NSError? LibboxSetup(options, &setupError) if let setupError { throw ExtensionStartupError("(packet-tunnel) error: setup service: \(setupError.localizedDescription)") } - let stderrPath = URL(fileURLWithPath: tempPath, isDirectory: true).appendingPathComponent("stderr.log").path - var stderrError: NSError? - LibboxRedirectStderr(stderrPath, &stderrError) - if let stderrError { - throw ExtensionStartupError("(packet-tunnel) redirect stderr error: \(stderrError.localizedDescription)") - } - - #if !os(macOS) - let ignoreMemoryLimit = (effectiveOptions["ignoreMemoryLimit"] as? NSNumber)?.boolValue ?? false - LibboxSetMemoryLimit(!ignoreMemoryLimit) - #endif - var error: NSError? commandServer = LibboxNewCommandServer(platformInterface, platformInterface, &error) if let error { @@ -179,7 +194,6 @@ open class ExtensionProvider: NEPacketTunnelProvider { } #endif - writeMessage("(packet-tunnel): Here I stand") do { try await startService() } catch { @@ -190,6 +204,7 @@ open class ExtensionProvider: NEPacketTunnelProvider { #endif throw error } + writeMessage("(packet-tunnel): Here I stand") #if os(macOS) if Variant.useSystemExtension { xpcService.markServiceReady() diff --git a/Library/Network/RootHelperXPC.swift b/Library/Network/RootHelperXPC.swift index 3e75f44..43eb7fd 100644 --- a/Library/Network/RootHelperXPC.swift +++ b/Library/Network/RootHelperXPC.swift @@ -60,6 +60,121 @@ } } + @objc(CrashLogFileResult) public class CrashLogFileResult: NSObject, NSSecureCoding { + public static let supportsSecureCoding = true + + @objc public var fileName: String + @objc public var content: String + @objc public var modificationDate: Date + + public init(fileName: String, content: String, modificationDate: Date) { + self.fileName = fileName + self.content = content + self.modificationDate = modificationDate + } + + public required init?(coder: NSCoder) { + fileName = coder.decodeObject(of: NSString.self, forKey: "fileName") as? String ?? "" + content = coder.decodeObject(of: NSString.self, forKey: "content") as? String ?? "" + modificationDate = coder.decodeObject(of: NSDate.self, forKey: "modificationDate") as? Date ?? Date() + } + + public func encode(with coder: NSCoder) { + coder.encode(fileName as NSString, forKey: "fileName") + coder.encode(content as NSString, forKey: "content") + coder.encode(modificationDate as NSDate, forKey: "modificationDate") + } + } + + @objc(CrashArtifactsResult) public class CrashArtifactsResult: NSObject, NSSecureCoding { + public static let supportsSecureCoding = true + + @objc public var crashLogs: [CrashLogFileResult] = [] + @objc public var helperNativeCrashData: Data? + @objc public var extensionNativeCrashData: Data? + + override public init() { + super.init() + } + + public required init?(coder: NSCoder) { + let logClasses = [NSArray.self, CrashLogFileResult.self] as [AnyClass] + crashLogs = coder.decodeObject(of: logClasses, forKey: "crashLogs") as? [CrashLogFileResult] ?? [] + helperNativeCrashData = coder.decodeObject(of: NSData.self, forKey: "helperNativeCrashData") as? Data + extensionNativeCrashData = coder.decodeObject(of: NSData.self, forKey: "extensionNativeCrashData") as? Data + } + + public func encode(with coder: NSCoder) { + coder.encode(crashLogs as NSArray, forKey: "crashLogs") + coder.encode(helperNativeCrashData as NSData?, forKey: "helperNativeCrashData") + coder.encode(extensionNativeCrashData as NSData?, forKey: "extensionNativeCrashData") + } + } + + @objc(OOMReportFileResult) public class OOMReportFileResult: NSObject, NSSecureCoding { + public static let supportsSecureCoding = true + + @objc public var name: String + @objc public var data: Data + + public init(name: String, data: Data) { + self.name = name + self.data = data + } + + public required init?(coder: NSCoder) { + name = coder.decodeObject(of: NSString.self, forKey: "name") as? String ?? "" + data = coder.decodeObject(of: NSData.self, forKey: "data") as? Data ?? Data() + } + + public func encode(with coder: NSCoder) { + coder.encode(name as NSString, forKey: "name") + coder.encode(data as NSData, forKey: "data") + } + } + + @objc(OOMReportDirectoryResult) public class OOMReportDirectoryResult: NSObject, NSSecureCoding { + public static let supportsSecureCoding = true + + @objc public var directoryName: String + @objc public var files: [OOMReportFileResult] = [] + + public init(directoryName: String, files: [OOMReportFileResult]) { + self.directoryName = directoryName + self.files = files + } + + public required init?(coder: NSCoder) { + directoryName = coder.decodeObject(of: NSString.self, forKey: "directoryName") as? String ?? "" + let fileClasses = [NSArray.self, OOMReportFileResult.self] as [AnyClass] + files = coder.decodeObject(of: fileClasses, forKey: "files") as? [OOMReportFileResult] ?? [] + } + + public func encode(with coder: NSCoder) { + coder.encode(directoryName as NSString, forKey: "directoryName") + coder.encode(files as NSArray, forKey: "files") + } + } + + @objc(OOMReportArtifactsResult) public class OOMReportArtifactsResult: NSObject, NSSecureCoding { + public static let supportsSecureCoding = true + + @objc public var reports: [OOMReportDirectoryResult] = [] + + override public init() { + super.init() + } + + public required init?(coder: NSCoder) { + let reportClasses = [NSArray.self, OOMReportDirectoryResult.self] as [AnyClass] + reports = coder.decodeObject(of: reportClasses, forKey: "reports") as? [OOMReportDirectoryResult] ?? [] + } + + public func encode(with coder: NSCoder) { + coder.encode(reports as NSArray, forKey: "reports") + } + } + @objc public protocol RootHelperProtocol { func findConnectionOwner( ipProtocol: Int32, @@ -76,6 +191,10 @@ func startNeighborMonitor(callbackEndpoint: NSXPCListenerEndpoint, reply: @escaping (NSError?) -> Void) func closeNeighborMonitor(reply: @escaping (NSError?) -> Void) func registerMyInterface(name: String, reply: @escaping (NSError?) -> Void) + func collectAllCrashArtifacts(reply: @escaping (CrashArtifactsResult?, NSError?) -> Void) + func collectOOMReportArtifacts(reply: @escaping (OOMReportArtifactsResult?, NSError?) -> Void) + func triggerGoCrash(reply: @escaping (NSError?) -> Void) + func triggerNativeCrash(reply: @escaping (NSError?) -> Void) } public enum RootHelperXPC { @@ -87,6 +206,24 @@ argumentIndex: 0, ofReply: true ) + let crashArtifactClasses = NSSet(array: [ + CrashArtifactsResult.self, NSArray.self, CrashLogFileResult.self, NSData.self, + ]) as! Set + interface.setClasses( + crashArtifactClasses, + for: #selector(RootHelperProtocol.collectAllCrashArtifacts(reply:)), + argumentIndex: 0, + ofReply: true + ) + let oomArtifactClasses = NSSet(array: [ + OOMReportArtifactsResult.self, NSArray.self, OOMReportDirectoryResult.self, OOMReportFileResult.self, NSData.self, + ]) as! Set + interface.setClasses( + oomArtifactClasses, + for: #selector(RootHelperProtocol.collectOOMReportArtifacts(reply:)), + argumentIndex: 0, + ofReply: true + ) let endpointClasses = NSSet(array: [NSXPCListenerEndpoint.self]) as! Set interface.setClasses( endpointClasses, @@ -141,10 +278,10 @@ return newConnection } - private func performXPCCall( + private func performXPCCallOptional( _ operation: String, call: (RootHelperProtocol, @escaping (T?, NSError?) -> Void) -> Void - ) throws -> T { + ) throws -> T? { let semaphore = DispatchSemaphore(value: 0) var result: T? var resultError: NSError? @@ -184,13 +321,18 @@ throw error } - guard let value = result else { - let error = NSError(domain: "RootHelper", code: -1, userInfo: [ + return result + } + + private func performXPCCall( + _ operation: String, + call: (RootHelperProtocol, @escaping (T?, NSError?) -> Void) -> Void + ) throws -> T { + guard let value: T = try performXPCCallOptional(operation, call: call) else { + throw NSError(domain: "RootHelper", code: -1, userInfo: [ NSLocalizedDescriptionKey: "\(operation) returned nil", ]) - throw error } - return value } @@ -287,51 +429,36 @@ } } + public func collectAllCrashArtifacts() throws -> CrashArtifactsResult { + try performXPCCall("collectAllCrashArtifacts") { proxy, reply in + proxy.collectAllCrashArtifacts(reply: reply) + } + } + + public func collectOOMReportArtifacts() throws -> OOMReportArtifactsResult { + try performXPCCall("collectOOMReportArtifacts") { proxy, reply in + proxy.collectOOMReportArtifacts(reply: reply) + } + } + + public func triggerGoCrash() throws { + try performXPCCallVoid("triggerGoCrash") { proxy, reply in + proxy.triggerGoCrash(reply: reply) + } + } + + public func triggerNativeCrash() throws { + try performXPCCallVoid("triggerNativeCrash") { proxy, reply in + proxy.triggerNativeCrash(reply: reply) + } + } + public func getVersion() throws -> String { - let semaphore = DispatchSemaphore(value: 0) - var result: String? - var resultError: NSError? - - let conn = getConnection() - guard let proxy = conn.remoteObjectProxyWithErrorHandler({ error in - logger.error("getVersion XPC error: \(error.localizedDescription)") - resultError = error as NSError - semaphore.signal() - }) as? RootHelperProtocol else { - connectionLock.lock() - connection = nil - connectionLock.unlock() - conn.invalidate() - throw NSError(domain: "RootHelper", code: -1, userInfo: [ - NSLocalizedDescriptionKey: "Failed to get RootHelper proxy", - ]) + try performXPCCall("getVersion") { proxy, reply in + proxy.getVersion { version in + reply(version as String?, nil) + } } - - proxy.getVersion { version in - result = version - semaphore.signal() - } - - let timeout = DispatchTime.now() + .seconds(5) - if semaphore.wait(timeout: timeout) == .timedOut { - let error = NSError(domain: "RootHelper", code: -1, userInfo: [ - NSLocalizedDescriptionKey: "getVersion request timeout", - ]) - logger.error("getVersion: timeout") - throw error - } - - if let error = resultError { - throw error - } - - guard let value = result else { - throw NSError(domain: "RootHelper", code: -1, userInfo: [ - NSLocalizedDescriptionKey: "getVersion returned nil", - ]) - } - - return value } } #endif diff --git a/Library/Shared/AppConfiguration.swift b/Library/Shared/AppConfiguration.swift index a5975c9..9c8c8d7 100644 --- a/Library/Shared/AppConfiguration.swift +++ b/Library/Shared/AppConfiguration.swift @@ -30,6 +30,13 @@ public enum AppConfiguration { "\(packageName).system" } + public static var packetTunnelBundleIDs: [String] { + if extensionBundleID == systemExtensionBundleID { + return [extensionBundleID] + } + return [extensionBundleID, systemExtensionBundleID] + } + public static var fileProviderDomainID: String { "\(packageName).workingdir" } diff --git a/Library/Shared/CrashReportArchive.swift b/Library/Shared/CrashReportArchive.swift new file mode 100644 index 0000000..c2ea597 --- /dev/null +++ b/Library/Shared/CrashReportArchive.swift @@ -0,0 +1,258 @@ +import Foundation + +public struct CrashReportMetadata: Codable, Sendable { + public var source: String? + public var bundleIdentifier: String? + public var processName: String? + public var processPath: String? + public var startedAt: String? + public var appVersion: String? + public var appMarketingVersion: String? + public var coreVersion: String? + public var goVersion: String? + public var crashedAt: String? + public var signalName: String? + public var signalCode: String? + public var exceptionName: String? + public var exceptionReason: String? + public var deviceOrigin: String? + + public init( + source: String? = nil, + bundleIdentifier: String? = nil, + processName: String? = nil, + processPath: String? = nil, + startedAt: String? = nil, + appVersion: String? = nil, + appMarketingVersion: String? = nil, + coreVersion: String? = nil, + goVersion: String? = nil, + crashedAt: String? = nil, + signalName: String? = nil, + signalCode: String? = nil, + exceptionName: String? = nil, + exceptionReason: String? = nil, + deviceOrigin: String? = nil + ) { + self.source = source + self.bundleIdentifier = bundleIdentifier + self.processName = processName + self.processPath = processPath + self.startedAt = startedAt + self.appVersion = appVersion + self.appMarketingVersion = appMarketingVersion + self.coreVersion = coreVersion + self.goVersion = goVersion + self.crashedAt = crashedAt + self.signalName = signalName + self.signalCode = signalCode + self.exceptionName = exceptionName + self.exceptionReason = exceptionReason + self.deviceOrigin = deviceOrigin + } +} + +public struct CrashReportArtifactContents { + public var goLog: String? + public var nativeLog: String? + public var configContent: String? + + public init(goLog: String? = nil, nativeLog: String? = nil, configContent: String? = nil) { + self.goLog = goLog + self.nativeLog = nativeLog + self.configContent = configContent + } + + public var isEmpty: Bool { + let goBody = goLog?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let nativeBody = nativeLog?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return goBody.isEmpty && nativeBody.isEmpty + } +} + +public enum ReportArchive { + public static let readMarkerFileName = ".read" + public static let metadataFileName = "metadata.json" + public static let configFileName = "configuration.json" + public static let tvOSDeviceOrigin = "tvOS" + + public static let timestampFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH-mm-ss" + formatter.timeZone = TimeZone(identifier: "UTC") + return formatter + }() + + public static func parseArtifactDate(for artifactURL: URL) -> Date? { + let name = artifactURL.lastPathComponent + let components = name.components(separatedBy: "-") + let baseName: String + if components.count > 5, let suffix = components.last, Int(suffix) != nil { + baseName = components.dropLast().joined(separator: "-") + } else { + baseName = components.joined(separator: "-") + } + return timestampFormatter.date(from: baseName) + } + + public static func nextAvailableArtifactURL(in directory: URL, for date: Date) -> URL { + let baseName = timestampFormatter.string(from: date) + var index = 0 + while true { + let suffix = index == 0 ? "" : "-\(index)" + let artifactURL = directory.appendingPathComponent(baseName + suffix, isDirectory: true) + if !FileManager.default.fileExists(atPath: artifactURL.path) { + return artifactURL + } + index += 1 + } + } + + static func removeArtifact(at artifactURL: URL) { + try? FileManager.default.removeItem(at: artifactURL) + } +} + +public enum CrashReportArchive { + static let pendingNativeCrashDirectoryName = "native_crash_pending" + static let pendingNativeCrashStorageDirectoryName = "com.plausiblelabs.crashreporter.data" + static let pendingNativeCrashReportFileName = "live_report.plcrash" + static let goLogFileName = "go.log" + static let nativeLogFileName = "native.log" + + static var crashReportsDirectory: URL { + FilePath.workingDirectory.appendingPathComponent("crash_reports", isDirectory: true) + } + + static var pendingNativeCrashBaseDirectory: URL { + FilePath.sharedDirectory.appendingPathComponent(pendingNativeCrashDirectoryName, isDirectory: true) + } + + static func metadataURL(for artifactURL: URL) -> URL { + artifactURL.appendingPathComponent(ReportArchive.metadataFileName) + } + + static func goLogURL(for artifactURL: URL) -> URL { + artifactURL.appendingPathComponent(goLogFileName) + } + + static func nativeLogURL(for artifactURL: URL) -> URL { + artifactURL.appendingPathComponent(nativeLogFileName) + } + + static func configURL(for artifactURL: URL) -> URL { + artifactURL.appendingPathComponent(ReportArchive.configFileName) + } + + static func pendingNativeCrashReportURL(bundleIdentifier: String) -> URL { + pendingNativeCrashReportURL(basePath: pendingNativeCrashBaseDirectory, bundleIdentifier: bundleIdentifier) + } + + public static func pendingNativeCrashReportURL(basePath: URL, bundleIdentifier: String) -> URL { + basePath + .appendingPathComponent(pendingNativeCrashStorageDirectoryName, isDirectory: true) + .appendingPathComponent(bundleIdentifier.replacingOccurrences(of: "/", with: "_"), isDirectory: true) + .appendingPathComponent(pendingNativeCrashReportFileName) + } + + public static func writeArchivedReport(contents: CrashReportArtifactContents, date: Date, metadata: CrashReportMetadata) throws -> URL { + guard !contents.isEmpty else { + throw NSError(domain: "CrashReportArchive", code: 1, userInfo: [NSLocalizedDescriptionKey: "Empty crash report"]) + } + + let dir = crashReportsDirectory + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let artifactURL = nextAvailableArtifactURL(for: date) + try rewriteArchivedReport(at: artifactURL, contents: contents, metadata: metadata) + return artifactURL + } + + static func rewriteArchivedReport(at artifactURL: URL, contents: CrashReportArtifactContents, metadata: CrashReportMetadata) throws { + guard !contents.isEmpty else { + throw NSError(domain: "CrashReportArchive", code: 1, userInfo: [NSLocalizedDescriptionKey: "Empty crash report"]) + } + + try FileManager.default.createDirectory(at: artifactURL, withIntermediateDirectories: true) + + if let goLog = contents.goLog?.trimmingCharacters(in: .whitespacesAndNewlines), !goLog.isEmpty { + try goLog.write(to: goLogURL(for: artifactURL), atomically: true, encoding: .utf8) + } else { + try? FileManager.default.removeItem(at: goLogURL(for: artifactURL)) + } + + if let nativeLog = contents.nativeLog?.trimmingCharacters(in: .whitespacesAndNewlines), !nativeLog.isEmpty { + try nativeLog.write(to: nativeLogURL(for: artifactURL), atomically: true, encoding: .utf8) + } else { + try? FileManager.default.removeItem(at: nativeLogURL(for: artifactURL)) + } + + if let configContent = contents.configContent?.trimmingCharacters(in: .whitespacesAndNewlines), !configContent.isEmpty { + try configContent.write(to: configURL(for: artifactURL), atomically: true, encoding: .utf8) + } else { + try? FileManager.default.removeItem(at: configURL(for: artifactURL)) + } + + let metadataData = try metadataEncoder.encode(metadata) + try metadataData.write(to: metadataURL(for: artifactURL), options: .atomic) + } + + public static func readMetadata(for artifactURL: URL) -> CrashReportMetadata? { + guard let data = try? Data(contentsOf: metadataURL(for: artifactURL)) else { + return nil + } + return try? JSONDecoder().decode(CrashReportMetadata.self, from: data) + } + + public static func readContents(for artifactURL: URL) -> CrashReportArtifactContents { + let goLog = try? String(contentsOf: goLogURL(for: artifactURL), encoding: .utf8) + let nativeLog = try? String(contentsOf: nativeLogURL(for: artifactURL), encoding: .utf8) + let configContent = try? String(contentsOf: configURL(for: artifactURL), encoding: .utf8) + return CrashReportArtifactContents(goLog: goLog, nativeLog: nativeLog, configContent: configContent) + } + + static func removeArtifact(at artifactURL: URL) { + ReportArchive.removeArtifact(at: artifactURL) + } + + static func crashDate(for artifactURL: URL) -> Date? { + ReportArchive.parseArtifactDate(for: artifactURL) + } + + static func iso8601String(from date: Date) -> String { + iso8601Formatter.string(from: date) + } + + static func displayContent(for contents: CrashReportArtifactContents) -> String { + let goBody = contents.goLog?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let nativeBody = contents.nativeLog?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + if nativeBody.isEmpty { + return goBody + } + if goBody.isEmpty { + return nativeBody + } + + var sections: [String] = [] + sections.append("===== Go Crash =====\n\n" + goBody) + sections.append("===== Native Crash =====\n\n" + nativeBody) + return sections.joined(separator: "\n\n") + } + + private static func nextAvailableArtifactURL(for date: Date) -> URL { + ReportArchive.nextAvailableArtifactURL(in: crashReportsDirectory, for: date) + } + + private static let iso8601Formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + }() + + private static let metadataEncoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [] + return encoder + }() +} diff --git a/Library/Shared/CrashReportManager.swift b/Library/Shared/CrashReportManager.swift new file mode 100644 index 0000000..14b0513 --- /dev/null +++ b/Library/Shared/CrashReportManager.swift @@ -0,0 +1,726 @@ +import CrashReporter +import Foundation +import Libbox +import os +import SwiftUI + +private let logger = Logger(category: "CrashReportManager") + +public struct CrashReport: Identifiable, Hashable, Sendable { + public let id: String + public let date: Date + public let fileURL: URL + public var isRead: Bool + public let origin: String? +} + +public struct CrashReportFile: Identifiable, Hashable, Sendable { + public enum Kind: String, Sendable { + case goLog + case nativeLog + case metadata + case configContent + } + + public let id: Kind + public let displayName: String + public let fileURL: URL +} + +@MainActor +public class CrashReportManager: ObservableObject { + @Published public private(set) var reports: [CrashReport] = [] + @Published public private(set) var unreadCount: Int = 0 + + public init() {} + + public nonisolated func refresh() async { + let reports = await BlockingIO.run { + Self.archivePendingCrashLogs() + Self.importPendingNativeCrashReports() + Self.coalesceArchivedCrashReports() + return Self.scanCrashReports() + } + await MainActor.run { + self.reports = reports + self.unreadCount = reports.filter { !$0.isRead }.count + } + } + + private nonisolated static func archivePendingCrashLogs() { + for source in ["NetworkExtension", "Application"] { + let url = FilePath.workingDirectory.appendingPathComponent("CrashReport-\(source).log") + let oldURL = FilePath.workingDirectory.appendingPathComponent("CrashReport-\(source).log.old") + archivePendingGoCrashLog(url, source: source) + archivePendingGoCrashLog(oldURL, source: source) + } + + #if os(macOS) + if Variant.useSystemExtension { + collectAndArchiveCrashArtifactsViaHelper() + } + #endif + } + + #if os(macOS) + private nonisolated static func collectAndArchiveCrashArtifactsViaHelper() { + guard HelperServiceManager.rootHelperStatus == .enabled else { + logger.debug("collectAndArchiveCrashArtifactsViaHelper: root helper not enabled, skipping") + return + } + + let artifacts: CrashArtifactsResult + do { + artifacts = try RootHelperClient.shared.collectAllCrashArtifacts() + } catch { + logger.warning("collectAndArchiveCrashArtifactsViaHelper: \(error.localizedDescription)") + return + } + + var configContent: String? + for crashLog in artifacts.crashLogs { + if crashLog.fileName == ReportArchive.configFileName { + let trimmed = crashLog.content.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + configContent = crashLog.content + } + continue + } + + guard !crashLog.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + continue + } + + let metadata: CrashReportMetadata + if crashLog.fileName.contains("RootHelper") { + metadata = CrashReportMetadataBuilder.normalized( + CrashReportMetadataBuilder.rootHelperGoMetadata(crashDate: crashLog.modificationDate), + content: crashLog.content + ) + } else { + metadata = CrashReportMetadataBuilder.normalized( + CrashReportMetadataBuilder.systemExtensionGoMetadata(crashDate: crashLog.modificationDate), + content: crashLog.content + ) + } + + _ = try? CrashReportArchive.writeArchivedReport( + contents: CrashReportArtifactContents(goLog: crashLog.content, configContent: configContent), + date: crashLog.modificationDate, + metadata: metadata + ) + } + + for (data, source) in [ + (artifacts.extensionNativeCrashData, "NetworkExtension"), + (artifacts.helperNativeCrashData, "RootHelper"), + ] { + guard let data, !data.isEmpty else { + continue + } + do { + let crashReport = try PLCrashReport(data: data) + guard let text = PLCrashReportTextFormatter.stringValue(for: crashReport, with: PLCrashReportTextFormatiOS), + !text.isEmpty + else { + continue + } + let crashDate = crashReport.systemInfo.timestamp ?? Date() + let metadata = CrashReportMetadataBuilder.nativeMetadata(for: crashReport, content: text, source: source) + _ = try CrashReportArchive.writeArchivedReport( + contents: CrashReportArtifactContents(nativeLog: text), + date: crashDate, + metadata: metadata + ) + } catch { + continue + } + } + } + #endif + + private nonisolated static func archivePendingGoCrashLog(_ url: URL, source: String) { + guard let content = try? String(contentsOf: url, encoding: .utf8), + !content.isEmpty else { return } + + guard !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + try? FileManager.default.removeItem(at: url) + return + } + + let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) + let crashDate = (attrs?[.modificationDate] as? Date) ?? Date() + let metadata = CrashReportMetadataBuilder.normalized( + CrashReportMetadataBuilder.goMetadata(source: source, crashDate: crashDate), + content: content + ) + + let configContent = readAndCleanConfigSnapshot() + + do { + _ = try CrashReportArchive.writeArchivedReport( + contents: CrashReportArtifactContents(goLog: content, configContent: configContent), + date: crashDate, + metadata: metadata + ) + try? FileManager.default.removeItem(at: url) + } catch { + return + } + } + + private nonisolated static func readAndCleanConfigSnapshot() -> String? { + let url = FilePath.workingDirectory.appendingPathComponent(ReportArchive.configFileName) + guard let content = try? String(contentsOf: url, encoding: .utf8), + !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + return nil + } + try? FileManager.default.removeItem(at: url) + return content + } + + private nonisolated static func scanCrashReports() -> [CrashReport] { + let dir = CrashReportArchive.crashReportsDirectory + guard let files = try? FileManager.default.contentsOfDirectory( + at: dir, includingPropertiesForKeys: [.contentModificationDateKey, .isDirectoryKey], + options: .skipsHiddenFiles + ) else { + return [] + } + + return files + .filter { + (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + } + .compactMap { url -> CrashReport? in + let date = CrashReportArchive.crashDate(for: url) + ?? (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) + ?? Date.distantPast + let origin = CrashReportArchive.readMetadata(for: url)?.deviceOrigin + return CrashReport( + id: url.lastPathComponent, + date: date, + fileURL: url, + isRead: FileManager.default.fileExists(atPath: url.appendingPathComponent(ReportArchive.readMarkerFileName).path), + origin: origin + ) + } + .sorted { $0.date > $1.date } + } + + private nonisolated static func importPendingNativeCrashReports() { + var pendingReports: [(bundleIdentifier: String, source: String)] = [] + for bundleIdentifier in AppConfiguration.packetTunnelBundleIDs { + pendingReports.append((bundleIdentifier, "NetworkExtension")) + } + if let appBundleIdentifier = Bundle.main.bundleIdentifier { + pendingReports.append((appBundleIdentifier, "Application")) + } + for (bundleIdentifier, source) in pendingReports { + let reportURL = CrashReportArchive.pendingNativeCrashReportURL(bundleIdentifier: bundleIdentifier) + guard let data = try? Data(contentsOf: reportURL), !data.isEmpty else { + continue + } + + do { + let crashReport = try PLCrashReport(data: data) + guard let text = PLCrashReportTextFormatter.stringValue(for: crashReport, with: PLCrashReportTextFormatiOS), + !text.isEmpty + else { + continue + } + + let attrs = try? FileManager.default.attributesOfItem(atPath: reportURL.path) + let crashDate = crashReport.systemInfo.timestamp + ?? (attrs?[.modificationDate] as? Date) + ?? Date() + let metadata = CrashReportMetadataBuilder.nativeMetadata(for: crashReport, content: text, source: source) + let configContent = readAndCleanConfigSnapshot() + _ = try CrashReportArchive.writeArchivedReport( + contents: CrashReportArtifactContents(nativeLog: text, configContent: configContent), + date: crashDate, + metadata: metadata + ) + try? FileManager.default.removeItem(at: reportURL) + } catch { + continue + } + } + } + + private nonisolated static func coalesceArchivedCrashReports() { + let records = loadArchivedReportRecords() + let goOnlyRecords = records.filter { $0.contents.goLog != nil && $0.contents.nativeLog == nil } + let nativeOnlyRecords = records.filter { $0.contents.nativeLog != nil && $0.contents.goLog == nil } + guard !goOnlyRecords.isEmpty, !nativeOnlyRecords.isEmpty else { + return + } + + var usedGoReportURLs: Set = [] + for nativeRecord in nativeOnlyRecords { + guard let goRecord = matchingGoReport(for: nativeRecord, among: goOnlyRecords, excluding: usedGoReportURLs) else { + continue + } + + let mergedMetadata = CrashReportMetadataBuilder.mergedMetadata( + go: goRecord.metadata, + goContent: goRecord.contents.goLog ?? "", + native: nativeRecord.metadata, + nativeContent: nativeRecord.contents.nativeLog ?? "" + ) + let mergedContents = CrashReportArtifactContents( + goLog: goRecord.contents.goLog, + nativeLog: nativeRecord.contents.nativeLog, + configContent: goRecord.contents.configContent ?? nativeRecord.contents.configContent + ) + + do { + try CrashReportArchive.rewriteArchivedReport( + at: goRecord.reportURL, + contents: mergedContents, + metadata: mergedMetadata + ) + CrashReportArchive.removeArtifact(at: nativeRecord.reportURL) + usedGoReportURLs.insert(goRecord.reportURL) + } catch { + continue + } + } + } + + public nonisolated func availableFiles(for report: CrashReport) async -> [CrashReportFile] { + await BlockingIO.run { + let fm = FileManager.default + var files: [CrashReportFile] = [] + let metadataURL = CrashReportArchive.metadataURL(for: report.fileURL) + if fm.fileExists(atPath: metadataURL.path) { + files.append(CrashReportFile(id: .metadata, displayName: "Metadata", fileURL: metadataURL)) + } + let nativeURL = CrashReportArchive.nativeLogURL(for: report.fileURL) + if fm.fileExists(atPath: nativeURL.path) { + files.append(CrashReportFile(id: .nativeLog, displayName: "Crash Report", fileURL: nativeURL)) + } + let goURL = CrashReportArchive.goLogURL(for: report.fileURL) + if fm.fileExists(atPath: goURL.path) { + files.append(CrashReportFile(id: .goLog, displayName: "Go Crash Log", fileURL: goURL)) + } + let configURL = CrashReportArchive.configURL(for: report.fileURL) + if fm.fileExists(atPath: configURL.path) { + files.append(CrashReportFile(id: .configContent, displayName: "Configuration", fileURL: configURL)) + } + return files + } + } + + public func markAsRead(_ report: CrashReport) { + FileManager.default.createFile(atPath: report.fileURL.appendingPathComponent(ReportArchive.readMarkerFileName).path, contents: nil) + if let idx = reports.firstIndex(where: { $0.id == report.id }), !reports[idx].isRead { + reports[idx].isRead = true + unreadCount = max(0, unreadCount - 1) + } + } + + public nonisolated func delete(_ report: CrashReport) async { + await BlockingIO.run { + CrashReportArchive.removeArtifact(at: report.fileURL) + } + await MainActor.run { + let wasUnread = reports.first { $0.id == report.id }.map { !$0.isRead } ?? false + reports.removeAll { $0.id == report.id } + if wasUnread { + unreadCount = max(0, unreadCount - 1) + } + } + } + + public nonisolated func deleteAll() async { + let dir = CrashReportArchive.crashReportsDirectory + await BlockingIO.run { + try? FileManager.default.removeItem(at: dir) + } + await MainActor.run { + reports.removeAll() + unreadCount = 0 + } + } + + private nonisolated static func loadArchivedReportRecords() -> [ArchivedCrashReportRecord] { + let dir = CrashReportArchive.crashReportsDirectory + guard let reportURLs = try? FileManager.default.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: [.contentModificationDateKey, .isDirectoryKey], + options: .skipsHiddenFiles + ) else { + return [] + } + + return reportURLs + .filter { + (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + } + .compactMap { reportURL in + let contents = CrashReportArchive.readContents(for: reportURL) + guard let metadata = CrashReportArchive.readMetadata(for: reportURL), + !contents.isEmpty + else { + return nil + } + let date = CrashReportArchive.crashDate(for: reportURL) + ?? (try? reportURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) + ?? Date.distantPast + return ArchivedCrashReportRecord( + reportURL: reportURL, + date: date, + contents: contents, + metadata: metadata + ) + } + } + + private nonisolated static func matchingGoReport( + for nativeRecord: ArchivedCrashReportRecord, + among goRecords: [ArchivedCrashReportRecord], + excluding excludedReportURLs: Set + ) -> ArchivedCrashReportRecord? { + goRecords + .filter { !excludedReportURLs.contains($0.reportURL) } + .filter { canMerge($0, nativeRecord) } + .min { lhs, rhs in + abs(lhs.date.timeIntervalSince(nativeRecord.date)) < abs(rhs.date.timeIntervalSince(nativeRecord.date)) + } + } + + private nonisolated static func canMerge(_ goRecord: ArchivedCrashReportRecord, _ nativeRecord: ArchivedCrashReportRecord) -> Bool { + if let goSource = goRecord.metadata.source, + let nativeSource = nativeRecord.metadata.source, + goSource != nativeSource + { + return false + } + + if let goBundleIdentifier = goRecord.metadata.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), + !goBundleIdentifier.isEmpty, + let nativeBundleIdentifier = nativeRecord.metadata.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), + !nativeBundleIdentifier.isEmpty, + goBundleIdentifier != nativeBundleIdentifier + { + return false + } + + return abs(goRecord.date.timeIntervalSince(nativeRecord.date)) <= 10 + } +} + +private struct ArchivedCrashReportRecord { + let reportURL: URL + let date: Date + let contents: CrashReportArtifactContents + let metadata: CrashReportMetadata +} + +enum CrashReportMetadataBuilder { + static func goMetadata(source: String, crashDate: Date) -> CrashReportMetadata { + CrashReportMetadata( + source: source, + crashedAt: CrashReportArchive.iso8601String(from: crashDate) + ) + } + + #if os(macOS) + static func systemExtensionGoMetadata(crashDate: Date) -> CrashReportMetadata { + CrashReportMetadata( + source: "NetworkExtension", + bundleIdentifier: AppConfiguration.systemExtensionBundleID, + crashedAt: CrashReportArchive.iso8601String(from: crashDate) + ) + } + + static func rootHelperGoMetadata(crashDate: Date) -> CrashReportMetadata { + CrashReportMetadata( + source: "RootHelper", + bundleIdentifier: AppConfiguration.rootHelperBundleID, + crashedAt: CrashReportArchive.iso8601String(from: crashDate) + ) + } + #endif + + static func mergedMetadata( + go: CrashReportMetadata, + goContent: String, + native: CrashReportMetadata, + nativeContent: String + ) -> CrashReportMetadata { + normalized( + CrashReportMetadata( + source: firstNonEmpty(native.source, go.source), + bundleIdentifier: firstNonEmpty(native.bundleIdentifier, go.bundleIdentifier), + processName: firstNonEmpty(native.processName, go.processName), + processPath: firstNonEmpty(native.processPath, go.processPath), + startedAt: firstNonEmpty(native.startedAt, go.startedAt), + appVersion: firstNonEmpty(go.appVersion, native.appVersion), + appMarketingVersion: firstNonEmpty(go.appMarketingVersion, native.appMarketingVersion), + coreVersion: firstNonEmpty(go.coreVersion, native.coreVersion), + goVersion: firstNonEmpty(go.goVersion, native.goVersion), + crashedAt: earliestTimestamp(go.crashedAt, native.crashedAt), + signalName: firstNonEmpty(native.signalName, go.signalName), + signalCode: firstNonEmpty(native.signalCode, go.signalCode), + exceptionName: firstNonEmpty(go.exceptionName, native.exceptionName), + exceptionReason: firstNonEmpty(go.exceptionReason, native.exceptionReason) + ), + content: CrashReportArchive.displayContent(for: CrashReportArtifactContents(goLog: goContent, nativeLog: nativeContent)) + ) + } + + static func nativeMetadata(for crashReport: PLCrashReport, content: String, source: String) -> CrashReportMetadata { + let processInfo = crashReport.hasProcessInfo ? crashReport.processInfo : nil + return normalized( + CrashReportMetadata( + source: source, + bundleIdentifier: crashReport.applicationInfo.applicationIdentifier, + processName: processInfo?.processName, + processPath: processInfo?.processPath, + startedAt: processInfo?.processStartTime.map(CrashReportArchive.iso8601String(from:)), + appVersion: crashReport.applicationInfo.applicationVersion, + appMarketingVersion: crashReport.applicationInfo.applicationMarketingVersion, + crashedAt: crashReport.systemInfo.timestamp.map(CrashReportArchive.iso8601String(from:)), + signalName: crashReport.signalInfo.name, + signalCode: crashReport.signalInfo.code, + exceptionName: crashReport.hasExceptionInfo ? crashReport.exceptionInfo.exceptionName : nil, + exceptionReason: crashReport.hasExceptionInfo ? crashReport.exceptionInfo.exceptionReason : nil + ), + content: content + ) + } + + static func normalized(_ metadata: CrashReportMetadata, content: String? = nil) -> CrashReportMetadata { + let bundleIdentifier = normalizedString(metadata.bundleIdentifier) + let processBundle = bundleIdentifier.flatMap(bundle(for:)) + let processPath = firstNonEmpty( + metadata.processPath, + normalizedString(processBundle?.executableURL?.path) + ) + let executableNameFromPath = processPath.flatMap { + normalizedString(URL(fileURLWithPath: $0).lastPathComponent) + } + let appBundle = containingAppBundle(for: processBundle) ?? currentAppBundle() + let parsedDetails = parseCrashDetails(from: content) + + return CrashReportMetadata( + source: metadata.source, + bundleIdentifier: bundleIdentifier, + processName: firstNonEmpty( + metadata.processName, + normalizedString(processBundle?.executableURL?.lastPathComponent), + executableNameFromPath, + bundleIdentifier + ), + processPath: processPath, + startedAt: normalizedString(metadata.startedAt), + appVersion: firstNonEmpty(bundleBuildVersion(appBundle), metadata.appVersion), + appMarketingVersion: firstNonEmpty(bundleMarketingVersion(appBundle), metadata.appMarketingVersion), + coreVersion: firstNonEmpty(metadata.coreVersion, normalizedString(LibboxVersion())), + goVersion: firstNonEmpty(metadata.goVersion, normalizedString(LibboxGoVersion())), + crashedAt: normalizedString(metadata.crashedAt), + signalName: firstNonEmpty(metadata.signalName, parsedDetails.signalName), + signalCode: firstNonEmpty(metadata.signalCode, parsedDetails.signalCode), + exceptionName: firstNonEmpty(metadata.exceptionName, parsedDetails.exceptionName), + exceptionReason: firstNonEmpty(metadata.exceptionReason, parsedDetails.exceptionReason) + ) + } + + private static func bundle(for bundleIdentifier: String) -> Bundle? { + if Bundle.main.bundleIdentifier == bundleIdentifier { + return Bundle.main + } + return discoveredBundles[bundleIdentifier] + } + + private static func currentAppBundle() -> Bundle { + containingAppBundle(for: Bundle.main) ?? Bundle.main + } + + private static func containingAppBundle(for bundle: Bundle?) -> Bundle? { + guard let bundle else { + return nil + } + + var currentURL = bundle.bundleURL + while currentURL.path != "/" { + if currentURL.pathExtension.lowercased() == "app" { + return Bundle(url: currentURL) + } + let parentURL = currentURL.deletingLastPathComponent() + if parentURL == currentURL { + break + } + currentURL = parentURL + } + + return nil + } + + private static func bundleBuildVersion(_ bundle: Bundle?) -> String? { + normalizedString(bundle?.infoDictionary?["CFBundleVersion"] as? String) + } + + private static func bundleMarketingVersion(_ bundle: Bundle?) -> String? { + normalizedString(bundle?.infoDictionary?["CFBundleShortVersionString"] as? String) + } + + private static func normalizedString(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty, + trimmed != "unknown" + else { + return nil + } + return trimmed + } + + private static func firstNonEmpty(_ values: String?...) -> String? { + for value in values { + if let value = normalizedString(value) { + return value + } + } + return nil + } + + private static let iso8601Formatter = ISO8601DateFormatter() + + private static func earliestTimestamp(_ values: String?...) -> String? { + let timestamps = values.compactMap { value -> (String, Date)? in + guard let value = normalizedString(value), + let date = iso8601Formatter.date(from: value) + else { + return nil + } + return (value, date) + } + if let earliest = timestamps.min(by: { $0.1 < $1.1 }) { + return earliest.0 + } + for value in values { + if let value = normalizedString(value) { + return value + } + } + return nil + } + + private static let discoveredBundles: [String: Bundle] = { + var bundles: [String: Bundle] = [:] + + func addBundle(_ bundle: Bundle?) { + guard let bundle, + let bundleIdentifier = bundle.bundleIdentifier + else { + return + } + bundles[bundleIdentifier] = bundle + } + + let appBundle = currentAppBundle() + addBundle(appBundle) + addBundle(Bundle.main) + + guard let enumerator = FileManager.default.enumerator( + at: appBundle.bundleURL, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { + return bundles + } + + let bundleExtensions: Set = ["app", "appex", "systemextension"] + for case let url as URL in enumerator { + let pathExtension = url.pathExtension.lowercased() + guard bundleExtensions.contains(pathExtension) else { + continue + } + addBundle(Bundle(url: url)) + enumerator.skipDescendants() + } + + return bundles + }() + + private struct ParsedCrashDetails { + var signalName: String? + var signalCode: String? + var exceptionName: String? + var exceptionReason: String? + } + + private static func parseCrashDetails(from content: String?) -> ParsedCrashDetails { + guard let content else { + return ParsedCrashDetails() + } + + var details = ParsedCrashDetails() + for rawLine in content.split(separator: "\n", omittingEmptySubsequences: false) { + let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !line.isEmpty else { + continue + } + + if details.exceptionReason == nil { + if line.hasPrefix("panic: ") { + details.exceptionName = "panic" + details.exceptionReason = normalizedString(String(line.dropFirst("panic: ".count))) + } else if line.hasPrefix("fatal error: ") { + details.exceptionName = "fatal error" + details.exceptionReason = normalizedString(String(line.dropFirst("fatal error: ".count))) + } + } + + if details.signalName == nil, + let parsedSignal = parseSignal(from: line) + { + details.signalName = parsedSignal.name + details.signalCode = parsedSignal.code + } + + if details.exceptionReason != nil, + details.signalName != nil + { + break + } + } + + return details + } + + private static func parseSignal(from line: String) -> (name: String?, code: String?)? { + let signalSection: Substring + if let range = line.range(of: "[signal ") { + signalSection = line[range.upperBound...] + } else if line.hasPrefix("signal ") { + signalSection = line.dropFirst("signal ".count) + } else { + return nil + } + + let signalName = normalizedString( + String(signalSection.prefix { character in + character != ":" && character != "]" && !character.isWhitespace + }) + ) + guard signalName != nil else { + return nil + } + + var signalCode: String? + if let codeRange = signalSection.range(of: " code=") { + let codeSection = signalSection[codeRange.upperBound...] + signalCode = normalizedString( + String(codeSection.prefix { character in + character != "]" && !character.isWhitespace + }) + ) + } + + return (signalName, signalCode) + } +} diff --git a/Library/Shared/NativeCrashReporter.swift b/Library/Shared/NativeCrashReporter.swift new file mode 100644 index 0000000..12ae675 --- /dev/null +++ b/Library/Shared/NativeCrashReporter.swift @@ -0,0 +1,82 @@ +import CrashReporter +import Foundation +import os + +public enum NativeCrashReporter { + private static let logger = Logger(category: "NativeCrashReporter") + private static let installLock = NSLock() + private static var reporter: PLCrashReporter? + + public static func installForCurrentProcess(basePath: URL? = nil) { + installLock.lock() + defer { + installLock.unlock() + } + + guard reporter == nil else { + return + } + + let crashBasePath = basePath ?? CrashReportArchive.pendingNativeCrashBaseDirectory + do { + try FileManager.default.createDirectory(at: crashBasePath, withIntermediateDirectories: true) + let config = PLCrashReporterConfig( + signalHandlerType: .BSD, + symbolicationStrategy: [], + basePath: crashBasePath.path + ) + guard let crashReporter = PLCrashReporter(configuration: config) else { + logger.warning("Failed to create PLCrashReporter instance") + return + } + try crashReporter.enableAndReturnError() + reporter = crashReporter + } catch { + logger.warning("Failed to enable native crash reporting: \(error.localizedDescription)") + } + } + + public static func loadAndPurgePendingCrashReportData() -> Data? { + installLock.lock() + guard let reporter else { + installLock.unlock() + return nil + } + installLock.unlock() + + guard reporter.hasPendingCrashReport() else { + return nil + } + + let data = try? reporter.loadPendingCrashReportDataAndReturnError() + reporter.purgePendingCrashReport() + return data + } + + public static func archiveLiveReportForCurrentProcess() { + installLock.lock() + guard let reporter else { + installLock.unlock() + return + } + installLock.unlock() + + do { + let data = try reporter.generateLiveReportAndReturnError() + let crashReport = try PLCrashReport(data: data) + guard let text = PLCrashReportTextFormatter.stringValue(for: crashReport, with: PLCrashReportTextFormatiOS), + !text.isEmpty + else { + return + } + let crashDate = crashReport.systemInfo.timestamp ?? Date() + _ = try CrashReportArchive.writeArchivedReport( + contents: CrashReportArtifactContents(nativeLog: text), + date: crashDate, + metadata: CrashReportMetadataBuilder.nativeMetadata(for: crashReport, content: text, source: "Application") + ) + } catch { + logger.warning("Failed to archive live native crash report: \(error.localizedDescription)") + } + } +} diff --git a/Library/Shared/OOMReportArchive.swift b/Library/Shared/OOMReportArchive.swift new file mode 100644 index 0000000..d178520 --- /dev/null +++ b/Library/Shared/OOMReportArchive.swift @@ -0,0 +1,60 @@ +import Foundation + +public struct OOMReportMetadata: Codable, Sendable { + public var source: String? + public var bundleIdentifier: String? + public var processName: String? + public var processPath: String? + public var startedAt: String? + public var appVersion: String? + public var appMarketingVersion: String? + public var coreVersion: String? + public var goVersion: String? + public var recordedAt: String? + public var memoryUsage: String? + public var availableMemory: String? + public var deviceOrigin: String? +} + +public enum OOMReportArchive { + static var reportsDirectory: URL { + FilePath.workingDirectory.appendingPathComponent("oom_reports", isDirectory: true) + } + + static func metadataURL(for artifactURL: URL) -> URL { + artifactURL.appendingPathComponent(ReportArchive.metadataFileName) + } + + static func configURL(for artifactURL: URL) -> URL { + artifactURL.appendingPathComponent(ReportArchive.configFileName) + } + + public static func readMetadata(for artifactURL: URL) -> OOMReportMetadata? { + guard let data = try? Data(contentsOf: metadataURL(for: artifactURL)) else { + return nil + } + return try? JSONDecoder().decode(OOMReportMetadata.self, from: data) + } + + static func profileFiles(for artifactURL: URL) -> [URL] { + guard let files = try? FileManager.default.contentsOfDirectory( + at: artifactURL, + includingPropertiesForKeys: [.fileSizeKey], + options: .skipsHiddenFiles + ) else { + return [] + } + let excluded: Set = [ReportArchive.metadataFileName, ReportArchive.configFileName] + return files + .filter { !excluded.contains($0.lastPathComponent) } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + } + + static func removeArtifact(at artifactURL: URL) { + ReportArchive.removeArtifact(at: artifactURL) + } + + static func reportDate(for artifactURL: URL) -> Date? { + ReportArchive.parseArtifactDate(for: artifactURL) + } +} diff --git a/Library/Shared/OOMReportManager.swift b/Library/Shared/OOMReportManager.swift new file mode 100644 index 0000000..f644e49 --- /dev/null +++ b/Library/Shared/OOMReportManager.swift @@ -0,0 +1,164 @@ +import Foundation +import os +import SwiftUI + +private let logger = Logger(category: "OOMReportManager") + +public struct OOMReport: Identifiable, Hashable, Sendable { + public let id: String + public let date: Date + public let fileURL: URL + public var isRead: Bool + public let origin: String? +} + +public struct OOMReportFile: Identifiable, Hashable, Sendable { + public enum Kind: String, Sendable { + case metadata + case configContent + case profile + } + + public let id: String + public let kind: Kind + public let displayName: String + public let fileURL: URL +} + +@MainActor +public class OOMReportManager: ObservableObject { + @Published public private(set) var reports: [OOMReport] = [] + @Published public private(set) var unreadCount: Int = 0 + + public init() {} + + public nonisolated func refresh() async { + let reports = await BlockingIO.run { + #if os(macOS) + if Variant.useSystemExtension { + Self.collectAndArchiveOOMReportsViaHelper() + } + #endif + return Self.scanReports() + } + await MainActor.run { + self.reports = reports + self.unreadCount = reports.filter { !$0.isRead }.count + } + } + + private nonisolated static func scanReports() -> [OOMReport] { + let dir = OOMReportArchive.reportsDirectory + guard let files = try? FileManager.default.contentsOfDirectory( + at: dir, includingPropertiesForKeys: [.contentModificationDateKey, .isDirectoryKey], + options: .skipsHiddenFiles + ) else { + return [] + } + + return files + .filter { + (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + } + .compactMap { url -> OOMReport? in + let date = OOMReportArchive.reportDate(for: url) + ?? (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) + ?? Date.distantPast + let origin = OOMReportArchive.readMetadata(for: url)?.deviceOrigin + return OOMReport( + id: url.lastPathComponent, + date: date, + fileURL: url, + isRead: FileManager.default.fileExists(atPath: url.appendingPathComponent(ReportArchive.readMarkerFileName).path), + origin: origin + ) + } + .sorted { $0.date > $1.date } + } + + public nonisolated func availableFiles(for report: OOMReport) async -> [OOMReportFile] { + await BlockingIO.run { + let fm = FileManager.default + var files: [OOMReportFile] = [] + + let metadataURL = OOMReportArchive.metadataURL(for: report.fileURL) + if fm.fileExists(atPath: metadataURL.path) { + files.append(OOMReportFile(id: "metadata", kind: .metadata, displayName: "Metadata", fileURL: metadataURL)) + } + + let configURL = OOMReportArchive.configURL(for: report.fileURL) + if fm.fileExists(atPath: configURL.path) { + files.append(OOMReportFile(id: "config", kind: .configContent, displayName: "Configuration", fileURL: configURL)) + } + + for profileURL in OOMReportArchive.profileFiles(for: report.fileURL) { + let name = profileURL.lastPathComponent + files.append(OOMReportFile(id: name, kind: .profile, displayName: name, fileURL: profileURL)) + } + + return files + } + } + + public func markAsRead(_ report: OOMReport) { + FileManager.default.createFile(atPath: report.fileURL.appendingPathComponent(ReportArchive.readMarkerFileName).path, contents: nil) + if let idx = reports.firstIndex(where: { $0.id == report.id }), !reports[idx].isRead { + reports[idx].isRead = true + unreadCount = max(0, unreadCount - 1) + } + } + + public nonisolated func delete(_ report: OOMReport) async { + await BlockingIO.run { + OOMReportArchive.removeArtifact(at: report.fileURL) + } + await MainActor.run { + let wasUnread = reports.first { $0.id == report.id }.map { !$0.isRead } ?? false + reports.removeAll { $0.id == report.id } + if wasUnread { + unreadCount = max(0, unreadCount - 1) + } + } + } + + public nonisolated func deleteAll() async { + let dir = OOMReportArchive.reportsDirectory + await BlockingIO.run { + try? FileManager.default.removeItem(at: dir) + } + await MainActor.run { + reports.removeAll() + unreadCount = 0 + } + } + + #if os(macOS) + private nonisolated static func collectAndArchiveOOMReportsViaHelper() { + guard HelperServiceManager.rootHelperStatus == .enabled else { + return + } + + let artifacts: OOMReportArtifactsResult + do { + artifacts = try RootHelperClient.shared.collectOOMReportArtifacts() + } catch { + logger.warning("collectOOMReportArtifacts: \(error.localizedDescription)") + return + } + + let reportsDir = OOMReportArchive.reportsDirectory + for report in artifacts.reports { + let destURL = reportsDir.appendingPathComponent(report.directoryName, isDirectory: true) + do { + try FileManager.default.createDirectory(at: destURL, withIntermediateDirectories: true) + for file in report.files { + let fileURL = destURL.appendingPathComponent(file.name) + try file.data.write(to: fileURL, options: .atomic) + } + } catch { + logger.warning("write OOM report \(report.directoryName): \(error.localizedDescription)") + } + } + } + #endif +} diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 87f8ac6..b8c0b2d 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -75,6 +75,9 @@ } } }, + "%@ (%lld)" : { + "shouldTranslate" : false + }, "%@ %@" : { "localizations" : { "en" : { @@ -833,6 +836,37 @@ } } }, + "Apple TV" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple TV" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple TV" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple TV" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple TV" + } + } + } + }, + "Application" : { + "shouldTranslate" : false + }, "Are you sure to import profile %@?" : { "localizations" : { "fa" : { @@ -2044,6 +2078,37 @@ } } }, + "Crash Report" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "گزارش خرابی" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отчёт о сбое" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "崩溃报告" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "當機報告" + } + } + } + }, + "Crash Trigger" : { + "shouldTranslate" : false + }, "Create" : { "localizations" : { "fa" : { @@ -2353,6 +2418,34 @@ } } }, + "Delete All" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "حذف همه" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить все" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "全部删除" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "全部刪除" + } + } + } + }, "Deprecated Warning" : { "localizations" : { "fa" : { @@ -2633,34 +2726,6 @@ } } }, - "Do not enforce memory limits on sing-box. Will cause OOM on non-jailbroken devices." : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "برای sing-box محدودیت اجباری حافظه اعمال نکنید. این کار در دستگاه‌های بدون جیلبریک باعث OOM می‌شود." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Не применять к sing-box принудительное ограничение памяти. На невзломанных устройствах это приведет к OOM." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "不要对 sing-box 强制执行内存限制。这将导致未越狱设备出现 OOM。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "不要對 sing-box 強制執行內存限制。這將導致未越獄設備出現 OOM。" - } - } - } - }, "Do not show warnings about usages of deprecated features." : { "localizations" : { "fa" : { @@ -3025,6 +3090,34 @@ } } }, + "Empty" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "خالی" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пусто" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "空" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "空" + } + } + } + }, "Empty connections" : { "localizations" : { "fa" : { @@ -3053,34 +3146,6 @@ } } }, - "Empty content" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "بدون محتوا" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Нет содержимого" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "无内容" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "無內容" - } - } - } - }, "Empty groups" : { "localizations" : { "fa" : { @@ -3221,6 +3286,34 @@ } } }, + "Enable Memory Limit" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "فعال‌سازی محدودیت حافظه" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить ограничение памяти" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用内存限制" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "啟用記憶體限制" + } + } + } + }, "Enabled" : { "localizations" : { "fa" : { @@ -3484,30 +3577,58 @@ }, "shouldTranslate" : false }, - "Export" : { + "Export Complete" : { "localizations" : { "fa" : { "stringUnit" : { "state" : "translated", - "value" : "خروجی" + "value" : "صادرات کامل شد" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Экспорт" + "value" : "Экспорт завершён" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "导出" + "value" : "导出完成" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "導出" + "value" : "匯出完成" + } + } + } + }, + "Export Report" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "صادرات گزارش" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Экспорт отчёта" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导出报告" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "匯出報告" } } } @@ -3596,6 +3717,34 @@ } } }, + "Fetch Memory Report" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "دریافت گزارش حافظه" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получить отчёт о памяти" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "获取内存报告" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "取得記憶體報告" + } + } + } + }, "File" : { "localizations" : { "fa" : { @@ -3708,6 +3857,34 @@ } } }, + "Files" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "فایل‌ها" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Файлы" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文件" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "檔案" + } + } + } + }, "Filter" : { "localizations" : { "fa" : { @@ -3899,6 +4076,9 @@ } } }, + "Go Crash" : { + "shouldTranslate" : false + }, "Goroutines" : { "localizations" : { "fa" : { @@ -4391,34 +4571,6 @@ } } }, - "Ignore Memory Limit" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "نادیده‌گرفتن محدودیت حافظه" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Игнорировать ограничение памяти" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "忽略内存限制" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "忽略內存限制" - } - } - } - }, "Import" : { "localizations" : { "fa" : { @@ -4890,6 +5042,62 @@ } } }, + "Kill all connections to free memory when the service memory exceeds the limit." : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "هنگام تجاوز حافظه سرویس از حد مجاز، تمام اتصالات را برای آزادسازی حافظه قطع کنید." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Завершить все соединения для освобождения памяти при превышении лимита памяти сервиса." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当服务内存超出限制时,终止所有连接以释放内存。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "當服務記憶體超出限制時,終止所有連線以釋放記憶體。" + } + } + } + }, + "Kill Connections" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "قطع اتصالات" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Завершить соединения" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "终止连接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "終止連線" + } + } + } + }, "Language" : { "localizations" : { "fa" : { @@ -5143,29 +5351,6 @@ } } }, - "Machine: " : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "Machine: " - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Machine: " - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "Machine: " - } - } - }, - "shouldTranslate" : false - }, "Managing working directory requires Helper Service." : { "localizations" : { "fa" : { @@ -5273,6 +5458,34 @@ } } }, + "Memory Limit" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "محدودیت حافظه" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ограничение памяти" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "内存限制" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "記憶體限制" + } + } + } + }, "Metadata" : { "localizations" : { "fa" : { @@ -5525,6 +5738,9 @@ } } }, + "Native Crash" : { + "shouldTranslate" : false + }, "Need Reboot" : { "localizations" : { "fa" : { @@ -5581,6 +5797,9 @@ } } }, + "NetworkExtension" : { + "shouldTranslate" : false + }, "Never Connect" : { "localizations" : { "fa" : { @@ -5920,6 +6139,34 @@ } } }, + "OOM Report" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "گزارش کمبود حافظه" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отчёт о нехватке памяти" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "内存不足报告" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "記憶體不足報告" + } + } + } + }, "Open" : { "localizations" : { "fa" : { @@ -6453,6 +6700,34 @@ } } }, + "Provide a soft memory limit for the service. The service will perform multiple processes to try to stay within this memory limit." : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "یک محدودیت نرم حافظه برای سرویس تعیین کنید. سرویس چندین فرآیند را انجام خواهد داد تا سعی کند در محدوده این محدودیت حافظه باقی بماند." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Задайте мягкое ограничение памяти для сервиса. Сервис будет выполнять различные процессы, чтобы оставаться в пределах этого ограничения." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "为服务提供软内存限制。服务将执行多个进程以尝试保持在此内存限制范围内。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "為服務提供軟記憶體限制。服務將執行多個程序以嘗試保持在此記憶體限制範圍內。" + } + } + } + }, "QRS" : { "localizations" : { "fa" : { @@ -6560,6 +6835,34 @@ } } }, + "Read from connection: %@" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "خواندن از اتصال: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Чтение из соединения: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "读取连接:%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "讀取連線:%@" + } + } + } + }, "Real-time Speed" : { "localizations" : { "fa" : { @@ -6723,6 +7026,62 @@ } } }, + "Remote error: %@" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "خطای راه دور: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка удалённого подключения: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "远程错误:%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "遠端錯誤:%@" + } + } + } + }, + "Reports" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "گزارش‌ها" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отчёты" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "报告" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "報告" + } + } + } + }, "Required" : { "localizations" : { "fa" : { @@ -6836,6 +7195,9 @@ } } }, + "RootHelper" : { + "shouldTranslate" : false + }, "Rules" : { "localizations" : { "fa" : { @@ -7088,6 +7450,34 @@ } } }, + "Sending..." : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "در حال ارسال..." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправка..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "发送中..." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "傳送中..." + } + } + } + }, "Service is Running" : { "localizations" : { "fa" : { @@ -7116,34 +7506,6 @@ } } }, - "Service Log" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "گزارش سرویس" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Журнал службы" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "服务日志" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "服務日誌" - } - } - } - }, "Service not started" : { "localizations" : { "fa" : { @@ -7368,6 +7730,34 @@ } } }, + "Share With Configuration" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "اشتراک‌گذاری با پیکربندی" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поделиться с конфигурацией" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "附带配置分享" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "附帶配置分享" + } + } + } + }, "Show in Menu Bar" : { "localizations" : { "fa" : { @@ -7979,29 +8369,6 @@ } } }, - "System: " : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "System: " - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "System: " - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "System: " - } - } - }, - "shouldTranslate" : false - }, "Taiwan Flag Available" : { "localizations" : { "fa" : { @@ -8198,6 +8565,34 @@ } } }, + "To export this report to your iPhone or iPad, make sure sing-box is the **same version** on both devices and **VPN is disabled**." : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "برای صادرات این گزارش به iPhone یا iPad، مطمئن شوید که sing-box در هر دو دستگاه **نسخه یکسان** است و **VPN غیرفعال** است." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Чтобы экспортировать этот отчёт на iPhone или iPad, убедитесь, что sing-box на обоих устройствах **одной версии** и **VPN отключён**." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "要将此报告导出到 iPhone 或 iPad,请确保两台设备上的 sing-box 为**相同版本**且**已关闭 VPN**。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "要將此報告匯出至 iPhone 或 iPad,請確認兩部裝置上的 sing-box 為**相同版本**且**已關閉 VPN**。" + } + } + } + }, "To File" : { "localizations" : { "fa" : { @@ -8254,6 +8649,34 @@ } } }, + "Tools" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ابزارها" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Инструменты" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "工具" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "工具" + } + } + } + }, "Traffic" : { "localizations" : { "fa" : { @@ -8310,6 +8733,35 @@ } } }, + "Trigger OOM Report" : { + "extractionState" : "stale", + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ایجاد گزارش کمبود حافظه" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вызвать отчёт о нехватке памяти" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "触发内存不足报告" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "觸發記憶體不足報告" + } + } + } + }, "true" : { "localizations" : { "fa" : { @@ -8506,6 +8958,34 @@ } } }, + "Unknown message type %u" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "نوع پیام ناشناخته %u" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неизвестный тип сообщения %u" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知消息类型 %u" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知訊息類型 %u" + } + } + } + }, "Unsaved Changes" : { "localizations" : { "fa" : { @@ -8919,6 +9399,34 @@ } } }, + "When memory limit is enabled, you will receive a report if the service memory exceeds the limit. You can also manually trigger report collection." : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "هنگامی که محدودیت حافظه فعال است، در صورت تجاوز حافظه سرویس از حد مجاز، گزارشی دریافت خواهید کرد. همچنین می‌توانید جمع‌آوری گزارش را به صورت دستی فعال کنید." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "При включённом ограничении памяти вы получите отчёт, если память сервиса превысит лимит. Вы также можете вручную запросить сбор отчёта." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用内存限制后,当服务内存超出限制时,您将会收到报告。您也可以手动触发收集报告。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "啟用記憶體限制後,當服務記憶體超出限制時,您將會收到報告。您也可以手動觸發收集報告。" + } + } + } + }, "Wi-Fi" : { "localizations" : { "fa" : { @@ -9058,6 +9566,62 @@ } } } + }, + "You will receive a report when a crash occurs." : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "هنگام وقوع خرابی، گزارشی دریافت خواهید کرد." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "При сбое приложения вы получите отчёт." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当遇到崩溃时,您将会收到报告。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "當遇到崩潰時,您將會收到報告。" + } + } + } + }, + "You will receive a report when the service runs out of memory. You can also manually trigger report collection." : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "هنگام کمبود حافظه سرویس، گزارشی دریافت خواهید کرد. همچنین می‌توانید جمع‌آوری گزارش را به صورت دستی فعال کنید." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "При нехватке памяти сервиса вы получите отчёт. Вы также можете вручную запросить сбор отчёта." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当服务内存不足时,您将会收到报告。您也可以手动触发收集报告。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "當服務記憶體不足時,您將會收到報告。您也可以手動觸發收集報告。" + } + } + } } }, "version" : "1.0" diff --git a/MacLibrary/ApplicationDelegate.swift b/MacLibrary/ApplicationDelegate.swift index db2a949..b1ce382 100644 --- a/MacLibrary/ApplicationDelegate.swift +++ b/MacLibrary/ApplicationDelegate.swift @@ -7,11 +7,13 @@ import UserNotifications open class ApplicationDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { public func applicationDidFinishLaunching(_: Notification) { + NativeCrashReporter.installForCurrentProcess() NSLog("Here I stand") let options = LibboxSetupOptions() options.basePath = FilePath.sharedDirectory.relativePath options.workingPath = FilePath.workingDirectory.relativePath options.tempPath = FilePath.cacheDirectory.relativePath + options.crashReportSource = "Application" var error: NSError? LibboxSetup(options, &error) LibboxSetLocale(Locale.current.identifier) diff --git a/MacLibrary/SidebarView.swift b/MacLibrary/SidebarView.swift index e6f7cc4..e9360e2 100644 --- a/MacLibrary/SidebarView.swift +++ b/MacLibrary/SidebarView.swift @@ -26,10 +26,12 @@ private struct SidebarContentView: View { } ForEach(NavigationPage.macosDefaultPages, id: \.self) { it in it.label + .badge(it == .tools ? environments.totalUnreadReportCount : 0) } } else { ForEach(NavigationPage.allCases.filter { $0.visible(profile) }, id: \.self) { it in it.label + .badge(it == .tools ? environments.totalUnreadReportCount : 0) } } } @@ -95,6 +97,7 @@ public struct SidebarView: View { List(selection: $localSelection) { ForEach(NavigationPage.allCases.filter { $0.visible(nil) }, id: \.self) { it in it.label + .badge(it == .tools ? environments.totalUnreadReportCount : 0) } } .listStyle(.sidebar) diff --git a/SFI/ApplicationDelegate.swift b/SFI/ApplicationDelegate.swift index 37e7733..04b5f4f 100644 --- a/SFI/ApplicationDelegate.swift +++ b/SFI/ApplicationDelegate.swift @@ -9,13 +9,16 @@ import UserNotifications class ApplicationDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { private var profileServer: ProfileServer? + private var reportTransferServer: ReportTransferServer? func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + NativeCrashReporter.installForCurrentProcess() NSLog("Here I stand") let options = LibboxSetupOptions() options.basePath = FilePath.sharedDirectory.relativePath options.workingPath = FilePath.workingDirectory.relativePath options.tempPath = FilePath.cacheDirectory.relativePath + options.crashReportSource = "Application" var error: NSError? LibboxSetup(options, &error) LibboxSetLocale(Locale.current.identifier) @@ -77,6 +80,16 @@ class ApplicationDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCe } catch { NSLog("setup profile server error: \(error.localizedDescription)") } + do { + let reportTransferServer = try ReportTransferServer() + reportTransferServer.start() + await MainActor.run { + self.reportTransferServer = reportTransferServer + } + NSLog("started report transfer server") + } catch { + NSLog("setup report transfer server error: \(error.localizedDescription)") + } registerFileProviderDomain() } } diff --git a/SFI/Info.plist b/SFI/Info.plist index 30bb80c..67b1cb2 100644 --- a/SFI/Info.plist +++ b/SFI/Info.plist @@ -58,6 +58,10 @@ NSApplicationServiceIdentifier sing-box:profile + + NSApplicationServiceIdentifier + sing-box:report-transfer + NSUbiquitousContainers diff --git a/SFI/MainView.swift b/SFI/MainView.swift index 15e9544..a8ccba2 100644 --- a/SFI/MainView.swift +++ b/SFI/MainView.swift @@ -73,6 +73,7 @@ struct MainView: View { } .tag(page) .tabItem { page.label } + .badge(page == .tools ? environments.totalUnreadReportCount : 0) } } } @@ -176,6 +177,13 @@ struct MainView: View { environments.connect() } } + .onReceive(NotificationCenter.default.publisher(for: .reportReceived)) { _ in + Task { + await environments.crashReportManager.refresh() + await environments.oomReportManager.refresh() + selection = .tools + } + } .environment(\.selection, $selection) .environment(\.importProfile, $importProfile) .environment(\.importRemoteProfile, $importRemoteProfile) diff --git a/SFT/ApplicationDelegate.swift b/SFT/ApplicationDelegate.swift index e58e9d3..7a0141c 100644 --- a/SFT/ApplicationDelegate.swift +++ b/SFT/ApplicationDelegate.swift @@ -6,6 +6,7 @@ import UIKit class ApplicationDelegate: NSObject, UIApplicationDelegate { func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + NativeCrashReporter.installForCurrentProcess() NSLog("Here I stand") let options = LibboxSetupOptions() options.basePath = FilePath.sharedDirectory.relativePath @@ -28,6 +29,7 @@ class ApplicationDelegate: NSObject, UIApplicationDelegate { } options.commandServerListenPort = port options.commandServerSecret = secret + options.crashReportSource = "Application" var error: NSError? LibboxSetup(options, &error) LibboxSetLocale(Locale.current.identifier) diff --git a/SFT/Info.plist b/SFT/Info.plist index 3ff9136..1f7a0ad 100644 --- a/SFT/Info.plist +++ b/SFT/Info.plist @@ -42,6 +42,17 @@ NSApplicationServiceUsageDescription Import sing-box profile from other devices + + NSApplicationServiceIdentifier + sing-box:report-transfer + NSApplicationServicePlatformSupport + + iOS + iPadOS + + NSApplicationServiceUsageDescription + Export crash reports to other devices + UIBackgroundModes diff --git a/SFT/MainView.swift b/SFT/MainView.swift index 9304d01..b7f91f5 100644 --- a/SFT/MainView.swift +++ b/SFT/MainView.swift @@ -28,7 +28,13 @@ struct MainView: View { .focusSection() } .tag(page) - .tabItem { page.label } + .tabItem { + if page == .tools, environments.totalUnreadReportCount > 0 { + Label("\(page.title) (\(environments.totalUnreadReportCount))", systemImage: "terminal.fill") + } else { + page.label + } + } } } .onAppear { diff --git a/sing-box.xcodeproj/project.pbxproj b/sing-box.xcodeproj/project.pbxproj index eb42e0f..6f5e7c6 100644 --- a/sing-box.xcodeproj/project.pbxproj +++ b/sing-box.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ 3A4FB1572A73467F007012B9 /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; }; 3A4FB1582A73467F007012B9 /* Library.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3A4FB15C2A73468C007012B9 /* ApplicationLibrary.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A4EAD102A4FEAE6005435B3 /* ApplicationLibrary.framework */; }; + 3A5AA1BA2F7DB10900BA2A0D /* CrashReporter in Frameworks */ = {isa = PBXBuildFile; productRef = 3A5AA1B92F7DB10900BA2A0D /* CrashReporter */; }; 3A5F26C82A503D4A00C27EDF /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; }; 3A5F26C92A503D4A00C27EDF /* Library.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3A648D542A4EF4C700D95A12 /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AF342B12A4AA520002B34AC /* NetworkExtension.framework */; }; @@ -743,6 +744,7 @@ 3A017F922A4AB2E4009149FA /* GRDB in Frameworks */, 3AFE19402EF5677100F61E06 /* SystemConfiguration.framework in Frameworks */, 3A76504C2A4F08BA003945C5 /* Libbox.xcframework in Frameworks */, + 3A5AA1BA2F7DB10900BA2A0D /* CrashReporter in Frameworks */, 3A7E90382A46778E00D53052 /* BinaryCodable in Frameworks */, 3AF3A3D22B2207F3001FD7C1 /* libresolv.tbd in Frameworks */, ); @@ -1205,6 +1207,7 @@ packageProductDependencies = ( 3A7E90372A46778E00D53052 /* BinaryCodable */, 3A017F912A4AB2E4009149FA /* GRDB */, + 3A5AA1B92F7DB10900BA2A0D /* CrashReporter */, ); productName = Library; productReference = 3AEC211D2A459B4700A63465 /* Library.framework */; @@ -1372,6 +1375,7 @@ 3A2E87F92ED5ABCF00644195 /* XCLocalSwiftPackageReference "Frameworks/TreeSitterJSON5" */, 3ACE5E012EE1A91100644196 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, 3A4CA8CA2F75381F009C36CA /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, + 3A5AA1B82F7DB10900BA2A0D /* XCRemoteSwiftPackageReference "plcrashreporter" */, ); productRefGroup = 3AEC20C72A45991900A63465 /* Products */; projectDirPath = ""; @@ -3354,6 +3358,14 @@ minimumVersion = 2.4.1; }; }; + 3A5AA1B82F7DB10900BA2A0D /* XCRemoteSwiftPackageReference "plcrashreporter" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/microsoft/plcrashreporter.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.12.2; + }; + }; 3A7E90362A46778E00D53052 /* XCRemoteSwiftPackageReference "BinaryCodable" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/christophhagen/BinaryCodable"; @@ -3398,6 +3410,11 @@ package = 3A4CA8CA2F75381F009C36CA /* XCRemoteSwiftPackageReference "swift-markdown-ui" */; productName = MarkdownUI; }; + 3A5AA1B92F7DB10900BA2A0D /* CrashReporter */ = { + isa = XCSwiftPackageProductDependency; + package = 3A5AA1B82F7DB10900BA2A0D /* XCRemoteSwiftPackageReference "plcrashreporter" */; + productName = CrashReporter; + }; 3A7E90372A46778E00D53052 /* BinaryCodable */ = { isa = XCSwiftPackageProductDependency; package = 3A7E90362A46778E00D53052 /* XCRemoteSwiftPackageReference "BinaryCodable" */; diff --git a/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 00d86e7..8a51177 100644 --- a/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "8da21fbbe117848311fb8e66b6d976022dc80720a4c3ace8c3db96e20d68e580", + "originHash" : "a5ae9234a9bc428c00d8f7d82f435f7ccec7ff57efcb585b429182b0f1b5f9a4", "pins" : [ { "identity" : "binarycodable", @@ -64,6 +64,15 @@ "version" : "6.0.1" } }, + { + "identity" : "plcrashreporter", + "kind" : "remoteSourceControl", + "location" : "https://github.com/microsoft/plcrashreporter.git", + "state" : { + "revision" : "0254f941c646b1ed17b243654723d0f071e990d0", + "version" : "1.12.2" + } + }, { "identity" : "qrcode", "kind" : "remoteSourceControl", From 6559720e0058be97b75db9e095c7cccc34824ee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 7 Apr 2026 20:37:38 +0800 Subject: [PATCH 28/37] Fix setupError not logged --- HelperService/main.swift | 3 +++ MacLibrary/ApplicationDelegate.swift | 9 ++++++++- SFI/ApplicationDelegate.swift | 13 ++++++++++--- SFT/ApplicationDelegate.swift | 9 ++++++++- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/HelperService/main.swift b/HelperService/main.swift index f7b01b2..937bfea 100644 --- a/HelperService/main.swift +++ b/HelperService/main.swift @@ -15,6 +15,9 @@ setupOptions.tempPath = WorkingDirectoryManager.helperTempDirectoryPath setupOptions.crashReportSource = "RootHelper" var setupError: NSError? LibboxSetup(setupOptions, &setupError) +if let setupError { + NSLog("setup service error: \(setupError.localizedDescription)") +} let service = RootHelperService() service.pendingCrashLogs = pendingCrashLogs diff --git a/MacLibrary/ApplicationDelegate.swift b/MacLibrary/ApplicationDelegate.swift index b1ce382..b2de6bc 100644 --- a/MacLibrary/ApplicationDelegate.swift +++ b/MacLibrary/ApplicationDelegate.swift @@ -16,7 +16,14 @@ open class ApplicationDelegate: NSObject, NSApplicationDelegate, UNUserNotificat options.crashReportSource = "Application" var error: NSError? LibboxSetup(options, &error) - LibboxSetLocale(Locale.current.identifier) + if let error { + NSLog("setup service error: \(error.localizedDescription)") + } + var localeError: NSError? + LibboxSetLocale(Locale.current.identifier, &localeError) + if let localeError { + NSLog("failed to set locale: \(localeError)") + } let notificationCenter = UNUserNotificationCenter.current() notificationCenter.setNotificationCategories([ UNNotificationCategory( diff --git a/SFI/ApplicationDelegate.swift b/SFI/ApplicationDelegate.swift index 04b5f4f..64771e6 100644 --- a/SFI/ApplicationDelegate.swift +++ b/SFI/ApplicationDelegate.swift @@ -19,9 +19,16 @@ class ApplicationDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCe options.workingPath = FilePath.workingDirectory.relativePath options.tempPath = FilePath.cacheDirectory.relativePath options.crashReportSource = "Application" - var error: NSError? - LibboxSetup(options, &error) - LibboxSetLocale(Locale.current.identifier) + var setupError: NSError? + LibboxSetup(options, &setupError) + if let setupError { + NSLog("setup service error: \(setupError.localizedDescription)") + } + var localeError: NSError? + LibboxSetLocale(Locale.current.identifier, &localeError) + if let localeError { + NSLog("failed to set locale: \(localeError)") + } let notificationCenter = UNUserNotificationCenter.current() notificationCenter.setNotificationCategories([ UNNotificationCategory( diff --git a/SFT/ApplicationDelegate.swift b/SFT/ApplicationDelegate.swift index 7a0141c..7b10219 100644 --- a/SFT/ApplicationDelegate.swift +++ b/SFT/ApplicationDelegate.swift @@ -32,7 +32,14 @@ class ApplicationDelegate: NSObject, UIApplicationDelegate { options.crashReportSource = "Application" var error: NSError? LibboxSetup(options, &error) - LibboxSetLocale(Locale.current.identifier) + if let error { + NSLog("setup service error: \(error.localizedDescription)") + } + var localeError: NSError? + LibboxSetLocale(Locale.current.identifier, &localeError) + if let localeError { + NSLog("failed to set locale: \(localeError)") + } setup() return true } From cd48f1abf641337d4adc18a16118312c2addf175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 8 Apr 2026 16:25:43 +0800 Subject: [PATCH 29/37] tools: Network Quality and STUN --- .../Views/Connections/ConnectionView.swift | 10 +- .../Dashboard/Cards/CardManagementSheet.swift | 6 +- .../Dashboard/Cards/DownloadTrafficCard.swift | 10 +- .../Views/Dashboard/Cards/HTTPProxyCard.swift | 2 +- .../Dashboard/Cards/UploadTrafficCard.swift | 10 +- .../Views/Groups/GroupListViewModel.swift | 8 +- .../Views/Groups/GroupView.swift | 2 +- .../Profile/EditProfileContentView.swift | 2 +- .../Views/Profile/QRSDisplayView.swift | 4 +- .../Views/Scanner/QRScannerView.swift | 2 +- .../Views/Setting/MacAppView.swift | 2 +- .../Views/Setting/OnDemandRulesView.swift | 4 +- .../Views/Tools/NetworkQualityView.swift | 136 ++ .../Views/Tools/NetworkQualityViewModel.swift | 239 ++++ .../Views/Tools/OutboundPickerView.swift | 127 ++ .../Views/Tools/STUNTestView.swift | 123 ++ .../Views/Tools/STUNTestViewModel.swift | 146 +++ .../Views/Tools/ToolsView.swift | 17 +- Library/Database/SharedPreferences.swift | 8 + Library/Network/CommandClient.swift | 17 + Library/Network/OutboundGroup.swift | 9 + Localizable.xcstrings | 1139 ++++++++++------- MacLibrary/EditProfileContentWindow.swift | 9 +- SFT/MainView.swift | 6 +- 24 files changed, 1537 insertions(+), 501 deletions(-) create mode 100644 ApplicationLibrary/Views/Tools/NetworkQualityView.swift create mode 100644 ApplicationLibrary/Views/Tools/NetworkQualityViewModel.swift create mode 100644 ApplicationLibrary/Views/Tools/OutboundPickerView.swift create mode 100644 ApplicationLibrary/Views/Tools/STUNTestView.swift create mode 100644 ApplicationLibrary/Views/Tools/STUNTestViewModel.swift diff --git a/ApplicationLibrary/Views/Connections/ConnectionView.swift b/ApplicationLibrary/Views/Connections/ConnectionView.swift index c048ea2..ca043c5 100644 --- a/ApplicationLibrary/Views/Connections/ConnectionView.swift +++ b/ApplicationLibrary/Views/Connections/ConnectionView.swift @@ -32,7 +32,7 @@ public struct ConnectionView: View { HStack { VStack(alignment: .leading) { HStack(alignment: .center) { - Text("\(connection.network.uppercased()) \(connection.displayDestination)") + Text(verbatim: "\(connection.network.uppercased()) \(connection.displayDestination)") Spacer() if connection.closedAt == nil { Text("Active").foregroundStyle(.green) @@ -45,8 +45,8 @@ public struct ConnectionView: View { HStack { if let closedAt = connection.closedAt { VStack(alignment: .leading) { - Text("↑ \(LibboxFormatBytes(connection.uploadTotal))") - Text("↓ \(LibboxFormatBytes(connection.downloadTotal))") + Text(verbatim: "↑ \(LibboxFormatBytes(connection.uploadTotal))") + Text(verbatim: "↓ \(LibboxFormatBytes(connection.downloadTotal))") } .font(.caption2) VStack(alignment: .leading) { @@ -60,8 +60,8 @@ public struct ConnectionView: View { } } else { VStack(alignment: .leading) { - Text("↑ \(LibboxFormatBytes(connection.upload))/s") - Text("↓ \(LibboxFormatBytes(connection.download))/s") + Text(verbatim: "↑ \(LibboxFormatBytes(connection.upload))/s") + Text(verbatim: "↓ \(LibboxFormatBytes(connection.download))/s") } .font(.caption2) VStack(alignment: .leading) { diff --git a/ApplicationLibrary/Views/Dashboard/Cards/CardManagementSheet.swift b/ApplicationLibrary/Views/Dashboard/Cards/CardManagementSheet.swift index e1dad8b..509e0d8 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/CardManagementSheet.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/CardManagementSheet.swift @@ -199,11 +199,11 @@ import SwiftUI Spacer() if !isProfileCard { - Toggle("", isOn: Binding( + Toggle(isOn: Binding( get: { isEnabled }, set: { _ in onToggle() } - )) - .labelsHidden() + )) {} + .labelsHidden() } Button { diff --git a/ApplicationLibrary/Views/Dashboard/Cards/DownloadTrafficCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/DownloadTrafficCard.swift index 1fb65b9..76f4e64 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/DownloadTrafficCard.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/DownloadTrafficCard.swift @@ -13,24 +13,24 @@ public struct DownloadTrafficCard: View { DashboardCardHeader(icon: "arrow.down.circle.fill", title: "Download") if Variant.screenshotMode { - Text("249 MB/s") + Text(verbatim: "249 MB/s") .font(.title2) .fontWeight(.medium) - Text("5.6 GB") + Text(verbatim: "5.6 GB") .font(.subheadline) .foregroundStyle(.secondary) } else if let message = commandClient.status, message.trafficAvailable { - Text("\(LibboxFormatBytes(message.downlink))/s") + Text(verbatim: "\(LibboxFormatBytes(message.downlink))/s") .font(.title2) .fontWeight(.medium) Text(LibboxFormatBytes(message.downlinkTotal)) .font(.subheadline) .foregroundStyle(.secondary) } else { - Text("...") + Text(verbatim: "...") .font(.title2) .fontWeight(.medium) - Text("...") + Text(verbatim: "...") .font(.subheadline) .foregroundStyle(.secondary) } diff --git a/ApplicationLibrary/Views/Dashboard/Cards/HTTPProxyCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/HTTPProxyCard.swift index 39d46b7..4ccf8e9 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/HTTPProxyCard.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/HTTPProxyCard.swift @@ -22,7 +22,7 @@ public struct HTTPProxyCard: View { HStack { DashboardCardHeader(icon: "network", title: "System HTTP Proxy") Spacer() - Toggle("", isOn: $systemProxyEnabled) + Toggle(isOn: $systemProxyEnabled) {} .labelsHidden() #if os(macOS) .toggleStyle(.switch) diff --git a/ApplicationLibrary/Views/Dashboard/Cards/UploadTrafficCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/UploadTrafficCard.swift index a37c0f0..524a07e 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/UploadTrafficCard.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/UploadTrafficCard.swift @@ -13,24 +13,24 @@ public struct UploadTrafficCard: View { DashboardCardHeader(icon: "arrow.up.circle.fill", title: "Upload") if Variant.screenshotMode { - Text("38 B/s") + Text(verbatim: "38 B/s") .font(.title2) .fontWeight(.medium) - Text("52 MB") + Text(verbatim: "52 MB") .font(.subheadline) .foregroundStyle(.secondary) } else if let message = commandClient.status, message.trafficAvailable { - Text("\(LibboxFormatBytes(message.uplink))/s") + Text(verbatim: "\(LibboxFormatBytes(message.uplink))/s") .font(.title2) .fontWeight(.medium) Text(LibboxFormatBytes(message.uplinkTotal)) .font(.subheadline) .foregroundStyle(.secondary) } else { - Text("...") + Text(verbatim: "...") .font(.title2) .fontWeight(.medium) - Text("...") + Text(verbatim: "...") .font(.subheadline) .foregroundStyle(.secondary) } diff --git a/ApplicationLibrary/Views/Groups/GroupListViewModel.swift b/ApplicationLibrary/Views/Groups/GroupListViewModel.swift index 8ffd687..644f905 100644 --- a/ApplicationLibrary/Views/Groups/GroupListViewModel.swift +++ b/ApplicationLibrary/Views/Groups/GroupListViewModel.swift @@ -41,13 +41,7 @@ public class GroupListViewModel: BaseViewModel { var items = [OutboundGroupItem]() let itemIterator = goGroup.getItems()! while itemIterator.hasNext() { - let goItem = itemIterator.next()! - items.append(OutboundGroupItem( - tag: goItem.tag, - type: goItem.type, - urlTestTime: Date(timeIntervalSince1970: Double(goItem.urlTestTime)), - urlTestDelay: UInt16(goItem.urlTestDelay) - )) + items.append(OutboundGroupItem(itemIterator.next()!)) } var selected = goGroup.selected diff --git a/ApplicationLibrary/Views/Groups/GroupView.swift b/ApplicationLibrary/Views/Groups/GroupView.swift index 75aa3e1..f466905 100644 --- a/ApplicationLibrary/Views/Groups/GroupView.swift +++ b/ApplicationLibrary/Views/Groups/GroupView.swift @@ -18,7 +18,7 @@ public struct GroupView: View { Text(group.displayType) .font(.subheadline) .foregroundColor(.secondary) - Text("\(group.items.count)") + Text(verbatim: "\(group.items.count)") .font(.subheadline) .padding(EdgeInsets(top: 2, leading: 4, bottom: 2, trailing: 4)) .background(Color.gray.opacity(0.5)) diff --git a/ApplicationLibrary/Views/Profile/EditProfileContentView.swift b/ApplicationLibrary/Views/Profile/EditProfileContentView.swift index da85386..f15060a 100644 --- a/ApplicationLibrary/Views/Profile/EditProfileContentView.swift +++ b/ApplicationLibrary/Views/Profile/EditProfileContentView.swift @@ -117,7 +117,7 @@ public struct EditProfileContentView: View { private var defaultEditorView: some View { #if os(tvOS) ScrollView { - TextField("", text: readOnly ? .constant(viewModel.profileContent) : $viewModel.profileContent, axis: .vertical) + TextField(text: readOnly ? .constant(viewModel.profileContent) : $viewModel.profileContent, axis: .vertical) {} .lineLimit(1000) .font(Font.system(.caption2, design: .monospaced)) .autocorrectionDisabled(true) diff --git a/ApplicationLibrary/Views/Profile/QRSDisplayView.swift b/ApplicationLibrary/Views/Profile/QRSDisplayView.swift index 2140824..8c5ccce 100644 --- a/ApplicationLibrary/Views/Profile/QRSDisplayView.swift +++ b/ApplicationLibrary/Views/Profile/QRSDisplayView.swift @@ -80,7 +80,7 @@ public struct QRSDisplayView: View { } label: { Image(systemName: "minus") } - Text("\(Int(sliceSize))") + Text(verbatim: "\(Int(sliceSize))") .foregroundStyle(.secondary) .frame(minWidth: 50) Button { @@ -89,7 +89,7 @@ public struct QRSDisplayView: View { Image(systemName: "plus") } #else - Text("\(Int(sliceSize))") + Text(verbatim: "\(Int(sliceSize))") .foregroundStyle(.secondary) #endif } diff --git a/ApplicationLibrary/Views/Scanner/QRScannerView.swift b/ApplicationLibrary/Views/Scanner/QRScannerView.swift index d7df5f0..9f25143 100644 --- a/ApplicationLibrary/Views/Scanner/QRScannerView.swift +++ b/ApplicationLibrary/Views/Scanner/QRScannerView.swift @@ -195,7 +195,7 @@ .animation(.easeInOut(duration: 0.2), value: progress) if total > 0 { - Text("\(min(99, Int(progress * 100)))%") + Text(verbatim: "\(min(99, Int(progress * 100)))%") .font(.system(size: 20, weight: .semibold)) .foregroundStyle(.white) } diff --git a/ApplicationLibrary/Views/Setting/MacAppView.swift b/ApplicationLibrary/Views/Setting/MacAppView.swift index 247e67c..96d78ca 100644 --- a/ApplicationLibrary/Views/Setting/MacAppView.swift +++ b/ApplicationLibrary/Views/Setting/MacAppView.swift @@ -199,7 +199,7 @@ public struct AppView: View { HStack { Label("Update", systemImage: "arrow.down.circle") Spacer() - Text("v\(info.versionName)") + Text(verbatim: "v\(info.versionName)") .foregroundStyle(.secondary) } } diff --git a/ApplicationLibrary/Views/Setting/OnDemandRulesView.swift b/ApplicationLibrary/Views/Setting/OnDemandRulesView.swift index d68379b..86186bd 100644 --- a/ApplicationLibrary/Views/Setting/OnDemandRulesView.swift +++ b/ApplicationLibrary/Views/Setting/OnDemandRulesView.swift @@ -793,7 +793,7 @@ private struct StringListSection: View { Text(title) Spacer() if !items.isEmpty { - Text("\(items.count)") + Text(verbatim: "\(items.count)") .foregroundStyle(.secondary) } } @@ -806,7 +806,7 @@ private struct StringListSection: View { Text(title) Spacer() if !items.isEmpty { - Text("\(items.count)") + Text(verbatim: "\(items.count)") .foregroundStyle(.secondary) } } diff --git a/ApplicationLibrary/Views/Tools/NetworkQualityView.swift b/ApplicationLibrary/Views/Tools/NetworkQualityView.swift new file mode 100644 index 0000000..cdd9024 --- /dev/null +++ b/ApplicationLibrary/Views/Tools/NetworkQualityView.swift @@ -0,0 +1,136 @@ +import Libbox +import Library +import SwiftUI + +@MainActor +public struct NetworkQualityView: View { + @EnvironmentObject private var environments: ExtensionEnvironments + @StateObject private var viewModel = NetworkQualityViewModel() + + public init() {} + + private var downloadActive: Bool { + (viewModel.isRunning && !viewModel.serial && viewModel.phase >= LibboxNetworkQualityPhaseDownload && viewModel.phase < LibboxNetworkQualityPhaseDone) + || viewModel.phase == LibboxNetworkQualityPhaseDownload + } + + private func accuracyLabel(_ value: Int32) -> (label: String, color: Color) { + switch value { + case LibboxNetworkQualityAccuracyHigh: + return (String(localized: "Confidence High"), .green) + case LibboxNetworkQualityAccuracyMedium: + return (String(localized: "Confidence Medium"), .yellow) + default: + return (String(localized: "Confidence Low"), .red) + } + } + + private var uploadActive: Bool { + (viewModel.isRunning && !viewModel.serial && viewModel.phase >= LibboxNetworkQualityPhaseDownload && viewModel.phase < LibboxNetworkQualityPhaseDone) + || viewModel.phase == LibboxNetworkQualityPhaseUpload + } + + @ViewBuilder + private func resultValue(_ value: String?, active: Bool, accuracy: (label: String, color: Color)? = nil) -> some View { + if let value { + HStack(spacing: 6) { + if viewModel.isRunning, active { + ProgressView() + .controlSize(.small) + } + Text(value) + if let accuracy { + Text(accuracy.label) + .font(.caption) + .foregroundColor(accuracy.color) + } + } + } else if viewModel.isRunning, active { + ProgressView() + .controlSize(.small) + } else { + Text(verbatim: "-") + } + } + + public var body: some View { + FormView { + Section("Configuration") { + #if os(tvOS) + FormTextItem("URL", "link") { + Text(viewModel.configURL) + } + #else + FormItem("URL") { + TextField(text: $viewModel.configURL) {} + .multilineTextAlignment(.trailing) + .autocorrectionDisabled() + #if os(iOS) + .textInputAutocapitalization(.never) + .keyboardType(.URL) + #endif + } + #endif + Toggle("Serial", isOn: $viewModel.serial) + .disabled(viewModel.isRunning) + Toggle("HTTP/3", isOn: $viewModel.http3) + .disabled(viewModel.isRunning) + Picker("Max Runtime", selection: $viewModel.maxRuntime) { + ForEach(MaxRuntimeOption.allCases) { option in + Text(option.label).tag(option) + } + } + .disabled(viewModel.isRunning) + if let profile = environments.extensionProfile { + ToolOutboundSection(profile: profile, viewModel: viewModel) + } + } + + Section("Action") { + if viewModel.isRunning { + FormButton { + viewModel.cancel() + } label: { + Label("Cancel Test", systemImage: "stop.fill") + } + } else { + FormButton { + viewModel.requestStartTest(vpnConnected: environments.extensionProfile?.status.isConnectedStrict == true) + } label: { + Label("Start Test", systemImage: "play.fill") + } + } + } + + if viewModel.phase >= 0 { + Section("Results") { + FormTextItem("Idle Latency", "timer") { + resultValue(viewModel.idleLatencyMs > 0 ? "\(viewModel.idleLatencyMs) ms" : nil, active: viewModel.phase == LibboxNetworkQualityPhaseIdle) + } + FormTextItem("Download", "arrow.down.circle") { + resultValue(viewModel.downloadCapacity > 0 ? LibboxFormatBitrate(viewModel.downloadCapacity) : nil, active: downloadActive, accuracy: viewModel.phase == LibboxNetworkQualityPhaseDone ? accuracyLabel(viewModel.downloadCapacityAccuracy) : nil) + } + FormTextItem("Download RPM", "arrow.down.to.line") { + resultValue(viewModel.downloadRPM > 0 ? "\(viewModel.downloadRPM)" : nil, active: downloadActive, accuracy: viewModel.phase == LibboxNetworkQualityPhaseDone ? accuracyLabel(viewModel.downloadRPMAccuracy) : nil) + } + FormTextItem("Upload", "arrow.up.circle") { + resultValue(viewModel.uploadCapacity > 0 ? LibboxFormatBitrate(viewModel.uploadCapacity) : nil, active: uploadActive, accuracy: viewModel.phase == LibboxNetworkQualityPhaseDone ? accuracyLabel(viewModel.uploadCapacityAccuracy) : nil) + } + FormTextItem("Upload RPM", "arrow.up.to.line") { + resultValue(viewModel.uploadRPM > 0 ? "\(viewModel.uploadRPM)" : nil, active: uploadActive, accuracy: viewModel.phase == LibboxNetworkQualityPhaseDone ? accuracyLabel(viewModel.uploadRPMAccuracy) : nil) + } + } + } + } + .navigationTitle("Network Quality") + .task { + await viewModel.loadPreferences() + } + .alert($viewModel.alert) + .onDisappear { + if viewModel.isRunning { + viewModel.cancel() + } + } + } +} diff --git a/ApplicationLibrary/Views/Tools/NetworkQualityViewModel.swift b/ApplicationLibrary/Views/Tools/NetworkQualityViewModel.swift new file mode 100644 index 0000000..58003dd --- /dev/null +++ b/ApplicationLibrary/Views/Tools/NetworkQualityViewModel.swift @@ -0,0 +1,239 @@ +import Foundation +import Libbox +import Library +import Network +import SwiftUI + +public enum MaxRuntimeOption: Int, CaseIterable, Identifiable { + case thirty = 30 + case sixty = 60 + + public var id: Int { + rawValue + } + + public var label: String { + "\(rawValue)s" + } +} + +@MainActor +public final class NetworkQualityViewModel: BaseViewModel, OutboundSelectable { + @Published public var phase: Int32 = -1 + @Published public var idleLatencyMs: Int32 = 0 + @Published public var downloadCapacity: Int64 = 0 + @Published public var uploadCapacity: Int64 = 0 + @Published public var downloadRPM: Int32 = 0 + @Published public var uploadRPM: Int32 = 0 + @Published public var downloadCapacityAccuracy: Int32 = 0 + @Published public var uploadCapacityAccuracy: Int32 = 0 + @Published public var downloadRPMAccuracy: Int32 = 0 + @Published public var uploadRPMAccuracy: Int32 = 0 + @Published public var isRunning = false + @Published public var selectedOutbound: String = "" + + @Published public var configURL: String = LibboxNetworkQualityDefaultConfigURL { + didSet { + guard !isLoadingPreferences else { return } + saveConfigURLTask?.cancel() + saveConfigURLTask = Task { + try? await Task.sleep(nanoseconds: 300_000_000) + guard !Task.isCancelled else { return } + await SharedPreferences.nqConfigURL.set(configURL) + } + } + } + + @Published public var serial: Bool = false { + didSet { + guard !isLoadingPreferences else { return } + Task { + await SharedPreferences.nqSerial.set(serial) + } + } + } + + @Published public var http3: Bool = false { + didSet { + guard !isLoadingPreferences else { return } + Task { + await SharedPreferences.nqHttp3.set(http3) + } + } + } + + @Published public var maxRuntime: MaxRuntimeOption = .thirty { + didSet { + guard !isLoadingPreferences else { return } + Task { + await SharedPreferences.nqMaxRuntime.set(maxRuntime.rawValue) + } + } + } + + private var isLoadingPreferences = false + private var saveConfigURLTask: Task? + private var standaloneTest: LibboxNetworkQualityTest? + private var runningTask: Task? + public func loadPreferences() async { + isLoadingPreferences = true + let savedURL = await SharedPreferences.nqConfigURL.get() + if !savedURL.isEmpty { + configURL = savedURL + } + serial = await SharedPreferences.nqSerial.get() + http3 = await SharedPreferences.nqHttp3.get() + let savedRuntime = await SharedPreferences.nqMaxRuntime.get() + maxRuntime = MaxRuntimeOption(rawValue: savedRuntime) ?? .thirty + isLoadingPreferences = false + } + + private func checkMeteredNetwork() async -> Bool { + await withCheckedContinuation { continuation in + let monitor = NWPathMonitor() + monitor.pathUpdateHandler = { path in + monitor.cancel() + continuation.resume(returning: path.isExpensive || path.usesInterfaceType(.cellular)) + } + monitor.start(queue: DispatchQueue.global()) + } + } + + public func requestStartTest(vpnConnected: Bool) { + Task { + let isMetered = await checkMeteredNetwork() + if isMetered { + alert = AlertState( + title: String(localized: "Metered Connection"), + message: String(localized: "You're on a metered connection. This test will use a significant amount of data."), + primaryButton: .cancel(), + secondaryButton: .destructive(String(localized: "Continue")) { [weak self] in + self?.startTest(vpnConnected: vpnConnected) + } + ) + } else { + startTest(vpnConnected: vpnConnected) + } + } + } + + public func startTest(vpnConnected: Bool) { + phase = -1 + idleLatencyMs = 0 + downloadCapacity = 0 + uploadCapacity = 0 + downloadRPM = 0 + uploadRPM = 0 + downloadCapacityAccuracy = 0 + uploadCapacityAccuracy = 0 + downloadRPMAccuracy = 0 + uploadRPMAccuracy = 0 + isRunning = true + + let configURL = configURL + let outboundTag = selectedOutbound + let serial = serial + let http3 = http3 + let maxRuntimeSeconds = Int32(maxRuntime.rawValue) + + if vpnConnected { + let handler = TestHandler(self) + runningTask = Task { [weak self] in + do { + try await Task.detached { + try LibboxNewStandaloneCommandClient()!.startNetworkQualityTest(configURL, outboundTag: outboundTag, serial: serial, maxRuntimeSeconds: maxRuntimeSeconds, http3: http3, handler: handler) + }.value + } catch { + guard let self else { return } + self.isRunning = false + self.alert = AlertState(action: "network quality test", error: error) + } + self?.runningTask = nil + } + } else { + let test = LibboxNewNetworkQualityTest()! + standaloneTest = test + let handler = TestHandler(self) + test.start(configURL, serial: serial, maxRuntimeSeconds: maxRuntimeSeconds, http3: http3, handler: handler) + } + } + + fileprivate func applyMetrics(phase: Int32, idleLatencyMs: Int32, downloadCapacity: Int64, uploadCapacity: Int64, downloadRPM: Int32, uploadRPM: Int32, downloadCapacityAccuracy: Int32, uploadCapacityAccuracy: Int32, downloadRPMAccuracy: Int32, uploadRPMAccuracy: Int32) { + self.phase = phase + self.idleLatencyMs = idleLatencyMs + self.downloadCapacity = downloadCapacity + self.uploadCapacity = uploadCapacity + self.downloadRPM = downloadRPM + self.uploadRPM = uploadRPM + self.downloadCapacityAccuracy = downloadCapacityAccuracy + self.uploadCapacityAccuracy = uploadCapacityAccuracy + self.downloadRPMAccuracy = downloadRPMAccuracy + self.uploadRPMAccuracy = uploadRPMAccuracy + } + + public func cancel() { + runningTask?.cancel() + runningTask = nil + standaloneTest?.cancel() + standaloneTest = nil + isRunning = false + } + + private final class TestHandler: NSObject, LibboxNetworkQualityTestHandlerProtocol, @unchecked Sendable { + private weak var viewModel: NetworkQualityViewModel? + + init(_ viewModel: NetworkQualityViewModel?) { + self.viewModel = viewModel + } + + func onProgress(_ progress: LibboxNetworkQualityProgress?) { + guard let progress else { return } + let phase = progress.phase + let idleLatencyMs = progress.idleLatencyMs + let downloadCapacity = progress.downloadCapacity + let uploadCapacity = progress.uploadCapacity + let downloadRPM = progress.downloadRPM + let uploadRPM = progress.uploadRPM + let downloadCapacityAccuracy = progress.downloadCapacityAccuracy + let uploadCapacityAccuracy = progress.uploadCapacityAccuracy + let downloadRPMAccuracy = progress.downloadRPMAccuracy + let uploadRPMAccuracy = progress.uploadRPMAccuracy + DispatchQueue.main.async { [self] in + guard let viewModel, viewModel.isRunning else { return } + viewModel.applyMetrics(phase: phase, idleLatencyMs: idleLatencyMs, downloadCapacity: downloadCapacity, uploadCapacity: uploadCapacity, downloadRPM: downloadRPM, uploadRPM: uploadRPM, downloadCapacityAccuracy: downloadCapacityAccuracy, uploadCapacityAccuracy: uploadCapacityAccuracy, downloadRPMAccuracy: downloadRPMAccuracy, uploadRPMAccuracy: uploadRPMAccuracy) + } + } + + func onResult(_ result: LibboxNetworkQualityResult?) { + guard let result else { return } + let idleLatencyMs = result.idleLatencyMs + let downloadCapacity = result.downloadCapacity + let uploadCapacity = result.uploadCapacity + let downloadRPM = result.downloadRPM + let uploadRPM = result.uploadRPM + let downloadCapacityAccuracy = result.downloadCapacityAccuracy + let uploadCapacityAccuracy = result.uploadCapacityAccuracy + let downloadRPMAccuracy = result.downloadRPMAccuracy + let uploadRPMAccuracy = result.uploadRPMAccuracy + DispatchQueue.main.async { [self] in + guard let viewModel, viewModel.isRunning else { return } + viewModel.applyMetrics(phase: LibboxNetworkQualityPhaseDone, idleLatencyMs: idleLatencyMs, downloadCapacity: downloadCapacity, uploadCapacity: uploadCapacity, downloadRPM: downloadRPM, uploadRPM: uploadRPM, downloadCapacityAccuracy: downloadCapacityAccuracy, uploadCapacityAccuracy: uploadCapacityAccuracy, downloadRPMAccuracy: downloadRPMAccuracy, uploadRPMAccuracy: uploadRPMAccuracy) + viewModel.isRunning = false + viewModel.runningTask = nil + viewModel.standaloneTest = nil + } + } + + func onError(_ message: String?) { + DispatchQueue.main.async { [self] in + guard let viewModel, viewModel.isRunning else { return } + viewModel.isRunning = false + viewModel.runningTask = nil + viewModel.standaloneTest = nil + if let message { + viewModel.alert = AlertState(errorMessage: message) + } + } + } + } +} diff --git a/ApplicationLibrary/Views/Tools/OutboundPickerView.swift b/ApplicationLibrary/Views/Tools/OutboundPickerView.swift new file mode 100644 index 0000000..f667efa --- /dev/null +++ b/ApplicationLibrary/Views/Tools/OutboundPickerView.swift @@ -0,0 +1,127 @@ +import Libbox +import Library +import SwiftUI + +@MainActor +public protocol OutboundSelectable: ObservableObject { + var selectedOutbound: String { get set } + var isRunning: Bool { get } + func cancel() +} + +public struct ToolOutboundSection: View { + @ObservedObject var profile: ExtensionProfile + @ObservedObject var viewModel: VM + + public var body: some View { + Group { + if profile.status.isConnectedStrict { + FormNavigationLink { + OutboundPickerView(selectedOutbound: $viewModel.selectedOutbound) + } label: { + HStack { + Text("Outbound") + Spacer() + Text(viewModel.selectedOutbound.isEmpty ? String(localized: "Default") : viewModel.selectedOutbound) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + } + .onChangeCompat(of: profile.status) { status in + if !status.isConnectedStrict { + if viewModel.isRunning { + viewModel.cancel() + } + viewModel.selectedOutbound = "" + } + } + } +} + +@MainActor +public struct OutboundPickerView: View { + @Binding var selectedOutbound: String + @StateObject private var commandClient = CommandClient(.outbounds) + @State private var outbounds: [OutboundGroupItem] = [] + @State private var searchText = "" + @Environment(\.dismiss) private var dismiss + + private var filteredOutbounds: [OutboundGroupItem] { + if searchText.isEmpty { + return outbounds + } + return outbounds.filter { $0.tag.localizedCaseInsensitiveContains(searchText) } + } + + public var body: some View { + List { + Button { + selectedOutbound = "" + dismiss() + } label: { + HStack { + Text("Default") + .foregroundStyle(.foreground) + Spacer() + if selectedOutbound.isEmpty { + Image(systemName: "checkmark") + .foregroundStyle(Color.accentColor) + } + } + } + #if os(macOS) + .buttonStyle(.plain) + #endif + ForEach(filteredOutbounds, id: \.tag) { item in + Button { + selectedOutbound = item.tag + dismiss() + } label: { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(item.tag) + .foregroundStyle(.foreground) + .lineLimit(1) + HStack { + Text(item.displayType) + .font(.caption) + .foregroundColor(.secondary) + Spacer(minLength: 0) + if item.urlTestDelay > 0 { + Text(item.delayString) + .font(.caption) + .foregroundColor(item.delayColor) + } + } + } + if selectedOutbound == item.tag { + Image(systemName: "checkmark") + .foregroundStyle(Color.accentColor) + } + } + } + #if os(macOS) + .buttonStyle(.plain) + #endif + } + } + #if os(iOS) + .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always)) + #else + .searchable(text: $searchText) + #endif + .navigationTitle("Outbound") + .onAppear { + commandClient.connect() + } + .onDisappear { + commandClient.disconnect() + } + .onReceive(commandClient.$outbounds) { goOutbounds in + guard let goOutbounds else { return } + outbounds = goOutbounds.map { OutboundGroupItem($0) } + } + } +} diff --git a/ApplicationLibrary/Views/Tools/STUNTestView.swift b/ApplicationLibrary/Views/Tools/STUNTestView.swift new file mode 100644 index 0000000..66eb330 --- /dev/null +++ b/ApplicationLibrary/Views/Tools/STUNTestView.swift @@ -0,0 +1,123 @@ +import Libbox +import Library +import SwiftUI + +@MainActor +public struct STUNTestView: View { + @EnvironmentObject private var environments: ExtensionEnvironments + @StateObject private var viewModel = STUNTestViewModel() + + public init() {} + + private func natMappingColor(_ value: Int32) -> Color { + switch value { + case LibboxNATMappingEndpointIndependent: .green + case LibboxNATMappingAddressDependent: .yellow + case LibboxNATMappingAddressAndPortDependent: .red + default: .primary + } + } + + private func natFilteringColor(_ value: Int32) -> Color { + switch value { + case LibboxNATFilteringEndpointIndependent: .green + case LibboxNATFilteringAddressDependent: .yellow + case LibboxNATFilteringAddressAndPortDependent: .red + default: .primary + } + } + + @ViewBuilder + private func resultValue(_ value: String?, active: Bool) -> some View { + if let value { + HStack(spacing: 6) { + if viewModel.isRunning, active { + ProgressView() + .controlSize(.small) + } + Text(value) + } + } else if viewModel.isRunning, active { + ProgressView() + .controlSize(.small) + } else { + Text(verbatim: "-") + } + } + + public var body: some View { + FormView { + Section("Configuration") { + #if os(tvOS) + FormTextItem("Server", "server.rack") { + Text(viewModel.server) + } + #else + FormItem(String(localized: "Server")) { + TextField(text: $viewModel.server) {} + .multilineTextAlignment(.trailing) + .autocorrectionDisabled() + #if os(iOS) + .textInputAutocapitalization(.never) + .keyboardType(.URL) + #endif + } + #endif + if let profile = environments.extensionProfile { + ToolOutboundSection(profile: profile, viewModel: viewModel) + } + } + + Section("Action") { + if viewModel.isRunning { + FormButton { + viewModel.cancel() + } label: { + Label("Cancel Test", systemImage: "stop.fill") + } + } else { + FormButton { + viewModel.startTest(vpnConnected: environments.extensionProfile?.status.isConnectedStrict == true) + } label: { + Label("Start Test", systemImage: "play.fill") + } + } + } + + if viewModel.phase >= 0 { + Section("Results") { + FormTextItem("External Address", "network") { + resultValue(viewModel.externalAddr.isEmpty ? nil : viewModel.externalAddr, active: viewModel.phase == LibboxSTUNPhaseBinding) + } + FormTextItem("Latency", "timer") { + resultValue(viewModel.latencyMs > 0 ? "\(viewModel.latencyMs) ms" : nil, active: viewModel.phase == LibboxSTUNPhaseBinding) + } + if viewModel.phase == LibboxSTUNPhaseDone, !viewModel.natTypeSupported { + FormTextItem("NAT Type Detection", "exclamationmark.triangle") { + Text("Not supported by server") + } + } else { + FormTextItem("NAT Mapping", "arrow.left.arrow.right") { + resultValue(viewModel.natMapping > 0 ? LibboxFormatNATMapping(viewModel.natMapping) : nil, active: viewModel.phase == LibboxSTUNPhaseNATMapping) + .foregroundStyle(viewModel.natMapping > 0 ? natMappingColor(viewModel.natMapping) : .primary) + } + FormTextItem("NAT Filtering", "line.3.horizontal.decrease") { + resultValue(viewModel.natFiltering > 0 ? LibboxFormatNATFiltering(viewModel.natFiltering) : nil, active: viewModel.phase == LibboxSTUNPhaseNATFiltering) + .foregroundStyle(viewModel.natFiltering > 0 ? natFilteringColor(viewModel.natFiltering) : .primary) + } + } + } + } + } + .navigationTitle("STUN Test") + .task { + await viewModel.loadPreferences() + } + .alert($viewModel.alert) + .onDisappear { + if viewModel.isRunning { + viewModel.cancel() + } + } + } +} diff --git a/ApplicationLibrary/Views/Tools/STUNTestViewModel.swift b/ApplicationLibrary/Views/Tools/STUNTestViewModel.swift new file mode 100644 index 0000000..66715e5 --- /dev/null +++ b/ApplicationLibrary/Views/Tools/STUNTestViewModel.swift @@ -0,0 +1,146 @@ +import Foundation +import Libbox +import Library +import SwiftUI + +@MainActor +public final class STUNTestViewModel: BaseViewModel, OutboundSelectable { + @Published public var phase: Int32 = -1 + @Published public var externalAddr: String = "" + @Published public var latencyMs: Int32 = 0 + @Published public var natMapping: Int32 = 0 + @Published public var natFiltering: Int32 = 0 + @Published public var natTypeSupported: Bool = false + @Published public var isRunning = false + @Published public var selectedOutbound: String = "" + + @Published public var server: String = LibboxSTUNDefaultServer { + didSet { + guard !isLoadingPreferences else { return } + saveServerTask?.cancel() + saveServerTask = Task { + try? await Task.sleep(nanoseconds: 300_000_000) + guard !Task.isCancelled else { return } + await SharedPreferences.stunServer.set(server) + } + } + } + + private var isLoadingPreferences = false + private var saveServerTask: Task? + private var standaloneTest: LibboxSTUNTest? + private var runningTask: Task? + + public func loadPreferences() async { + isLoadingPreferences = true + let saved = await SharedPreferences.stunServer.get() + if !saved.isEmpty { + server = saved + } + isLoadingPreferences = false + } + + public func startTest(vpnConnected: Bool) { + phase = -1 + externalAddr = "" + latencyMs = 0 + natMapping = 0 + natFiltering = 0 + natTypeSupported = false + isRunning = true + + let server = server + let outboundTag = selectedOutbound + + if vpnConnected { + let handler = TestHandler(self) + runningTask = Task { [weak self] in + do { + try await Task.detached { + try LibboxNewStandaloneCommandClient()!.startSTUNTest(server, outboundTag: outboundTag, handler: handler) + }.value + } catch { + guard let self else { return } + self.isRunning = false + self.alert = AlertState(action: "STUN test", error: error) + } + self?.runningTask = nil + } + } else { + let test = LibboxNewSTUNTest()! + standaloneTest = test + let handler = TestHandler(self) + test.start(server, handler: handler) + } + } + + public func cancel() { + runningTask?.cancel() + runningTask = nil + standaloneTest?.cancel() + standaloneTest = nil + isRunning = false + } + + private final class TestHandler: NSObject, LibboxSTUNTestHandlerProtocol, @unchecked Sendable { + private weak var viewModel: STUNTestViewModel? + + init(_ viewModel: STUNTestViewModel?) { + self.viewModel = viewModel + } + + func onProgress(_ progress: LibboxSTUNTestProgress?) { + guard let progress else { return } + let phase = progress.phase + let externalAddr = progress.externalAddr + let latencyMs = progress.latencyMs + let natMapping = progress.natMapping + let natFiltering = progress.natFiltering + DispatchQueue.main.async { [self] in + guard let viewModel, viewModel.isRunning else { return } + viewModel.phase = phase + if !externalAddr.isEmpty { + viewModel.externalAddr = externalAddr + } + if latencyMs > 0 { + viewModel.latencyMs = latencyMs + } + viewModel.natMapping = natMapping + viewModel.natFiltering = natFiltering + } + } + + func onResult(_ result: LibboxSTUNTestResult?) { + guard let result else { return } + let externalAddr = result.externalAddr + let latencyMs = result.latencyMs + let natMapping = result.natMapping + let natFiltering = result.natFiltering + let natTypeSupported = result.natTypeSupported + DispatchQueue.main.async { [self] in + guard let viewModel, viewModel.isRunning else { return } + viewModel.phase = LibboxSTUNPhaseDone + viewModel.externalAddr = externalAddr + viewModel.latencyMs = latencyMs + viewModel.natMapping = natMapping + viewModel.natFiltering = natFiltering + viewModel.natTypeSupported = natTypeSupported + viewModel.isRunning = false + viewModel.runningTask = nil + viewModel.standaloneTest = nil + } + } + + func onError(_ message: String?) { + DispatchQueue.main.async { [self] in + guard let viewModel, viewModel.isRunning else { return } + viewModel.isRunning = false + viewModel.runningTask = nil + viewModel.standaloneTest = nil + if let message { + viewModel.alert = AlertState(errorMessage: message) + } + } + } + } +} diff --git a/ApplicationLibrary/Views/Tools/ToolsView.swift b/ApplicationLibrary/Views/Tools/ToolsView.swift index e344291..d7f3fbf 100644 --- a/ApplicationLibrary/Views/Tools/ToolsView.swift +++ b/ApplicationLibrary/Views/Tools/ToolsView.swift @@ -14,6 +14,19 @@ public struct ToolsView: View { public var body: some View { FormView { + Section("Network") { + FormNavigationLink { + NetworkQualityView() + } label: { + Label("Network Quality", systemImage: "network") + } + FormNavigationLink { + STUNTestView() + } label: { + Label("STUN Test", systemImage: "arrow.triangle.swap") + } + } + Section("Debug") { #if os(iOS) NavigationLink(isActive: $showCrashReportList) { @@ -50,7 +63,7 @@ public struct ToolsView: View { Label("Crash Report", systemImage: "ladybug.fill") Spacer() if environments.crashReportManager.unreadCount > 0 { - Text("\(environments.crashReportManager.unreadCount)") + Text(verbatim: "\(environments.crashReportManager.unreadCount)") .foregroundStyle(.secondary) } } @@ -69,7 +82,7 @@ public struct ToolsView: View { Label("OOM Report", systemImage: "memorychip") Spacer() if environments.oomReportManager.unreadCount > 0 { - Text("\(environments.oomReportManager.unreadCount)") + Text(verbatim: "\(environments.oomReportManager.unreadCount)") .foregroundStyle(.secondary) } } diff --git a/Library/Database/SharedPreferences.swift b/Library/Database/SharedPreferences.swift index e2c252f..3aaa0ea 100644 --- a/Library/Database/SharedPreferences.swift +++ b/Library/Database/SharedPreferences.swift @@ -125,6 +125,14 @@ public enum SharedPreferences { public static let disableDeprecatedWarnings = Preference("disable_deprecated_warnings", defaultValue: false) + // Tools + + public static let nqConfigURL = Preference("nq_config_url", defaultValue: "") + public static let nqSerial = Preference("nq_serial", defaultValue: false) + public static let nqHttp3 = Preference("nq_http3", defaultValue: false) + public static let nqMaxRuntime = Preference("nq_max_runtime", defaultValue: 30) + public static let stunServer = Preference("stun_server", defaultValue: "") + // Dashboard public static let enabledDashboardCards = Preference<[String]>("enabled_dashboard_cards", defaultValue: []) diff --git a/Library/Network/CommandClient.swift b/Library/Network/CommandClient.swift index 9dea6ee..69d16a6 100644 --- a/Library/Network/CommandClient.swift +++ b/Library/Network/CommandClient.swift @@ -66,6 +66,7 @@ public class CommandClient: ObservableObject { case log case clashMode case connections + case outbounds } private let connectionTypes: [ConnectionType] @@ -88,6 +89,7 @@ public class CommandClient: ObservableObject { } @Published public var groups: [LibboxOutboundGroup]? + @Published public var outbounds: [LibboxOutboundGroupItem]? @Published public var logList: [LogEntry] @Published public var defaultLogLevel = 0 @Published public var selectedLogLevel: Int? @@ -246,6 +248,8 @@ public class CommandClient: ObservableObject { clientOptions.addCommand(LibboxCommandClashMode) case .connections: clientOptions.addCommand(LibboxCommandConnections) + case .outbounds: + clientOptions.addCommand(LibboxCommandOutbounds) } } clientOptions.statusInterval = Int64(NSEC_PER_SEC) @@ -384,6 +388,19 @@ public class CommandClient: ObservableObject { } } + func writeOutbounds(_ message: (any LibboxOutboundGroupItemIteratorProtocol)?) { + guard let message else { return } + guard isActiveConnection() else { return } + var newOutbounds: [LibboxOutboundGroupItem] = [] + while message.hasNext() { + newOutbounds.append(message.next()!) + } + DispatchQueue.main.async { [self] in + guard isActiveConnection() else { return } + commandClient.outbounds = newOutbounds + } + } + func initializeClashMode(_ modeList: LibboxStringIteratorProtocol?, currentMode: String?) { DispatchQueue.main.async { [self] in guard isActiveConnection() else { return } diff --git a/Library/Network/OutboundGroup.swift b/Library/Network/OutboundGroup.swift index 04891f0..a9441b7 100644 --- a/Library/Network/OutboundGroup.swift +++ b/Library/Network/OutboundGroup.swift @@ -48,6 +48,15 @@ public struct OutboundGroupItem: Codable, Hashable { self.urlTestDelay = urlTestDelay } + public init(_ item: LibboxOutboundGroupItem) { + self.init( + tag: item.tag, + type: item.type, + urlTestTime: Date(timeIntervalSince1970: Double(item.urlTestTime)), + urlTestDelay: UInt16(item.urlTestDelay) + ) + } + public var displayType: String { LibboxProxyDisplayType(type) } diff --git a/Localizable.xcstrings b/Localizable.xcstrings index b8c0b2d..3767efc 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -1,52 +1,6 @@ { "sourceLanguage" : "en", "strings" : { - "" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "" - } - } - }, - "shouldTranslate" : false - }, - "..." : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "..." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "..." - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "..." - } - } - }, - "shouldTranslate" : false - }, "**If I’ve defended your modern life, please consider sponsoring me.**" : { "localizations" : { "fa" : { @@ -75,84 +29,6 @@ } } }, - "%@ (%lld)" : { - "shouldTranslate" : false - }, - "%@ %@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$@ %2$@" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@ %2$@" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@ %2$@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@ %2$@" - } - } - }, - "shouldTranslate" : false - }, - "%@/s" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@/s" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@/s" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@/s" - } - } - }, - "shouldTranslate" : false - }, - "%lld" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld" - } - } - }, - "shouldTranslate" : false - }, "%lld Profiles" : { "localizations" : { "fa" : { @@ -181,213 +57,6 @@ } } }, - "%lld%%" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld%%" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld%%" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld%%" - } - } - }, - "shouldTranslate" : false - }, - "↑ %@" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "↑ %@" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "↑ %@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "↑ %@" - } - } - }, - "shouldTranslate" : false - }, - "↑ %@/s" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "↑ %@/s" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "↑ %@/s" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "↑ %@/s" - } - } - }, - "shouldTranslate" : false - }, - "↓ %@" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "↓ %@" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "↓ %@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "↓ %@" - } - } - }, - "shouldTranslate" : false - }, - "↓ %@/s" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "↓ %@/s" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "↓ %@/s" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "↓ %@/s" - } - } - }, - "shouldTranslate" : false - }, - "5.6 GB" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "5.6 GB" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "5.6 GB" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "5.6 GB" - } - } - }, - "shouldTranslate" : false - }, - "38 B/s" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "38 B/s" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "38 B/s" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "38 B/s" - } - } - }, - "shouldTranslate" : false - }, - "52 MB" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "52 MB" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "52 MB" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "52 MB" - } - } - }, - "shouldTranslate" : false - }, - "249 MB/s" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "249 MB/s" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "249 MB/s" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "249 MB/s" - } - } - }, - "shouldTranslate" : false - }, "About" : { "localizations" : { "fa" : { @@ -1349,6 +1018,34 @@ } } }, + "Cancel Test" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "لغو تست" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Остановить тест" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消测试" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消測試" + } + } + } + }, "Cellular" : { "localizations" : { "fa" : { @@ -1742,6 +1439,90 @@ } } }, + "Confidence High" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "اطمینان بالا" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Высокая уверенность" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "置信度高" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "置信度高" + } + } + } + }, + "Confidence Low" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "اطمینان پایین" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Низкая уверенность" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "置信度低" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "置信度低" + } + } + } + }, + "Confidence Medium" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "اطمینان متوسط" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Средняя уверенность" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "置信度中" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "置信度中" + } + } + } + }, "Configuration" : { "localizations" : { "fa" : { @@ -2022,6 +1803,34 @@ } } }, + "Continue" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ادامه" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Продолжить" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "继续" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "繼續" + } + } + } + }, "Copy" : { "localizations" : { "fa" : { @@ -2978,6 +2787,34 @@ } } }, + "Download RPM" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "RPM دانلود" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка RPM" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下载 RPM" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "下載 RPM" + } + } + } + }, "Edit" : { "localizations" : { "fa" : { @@ -3343,26 +3180,6 @@ } }, "enforceRoutes" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "enforceRoutes" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "enforceRoutes" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "enforceRoutes" - } - } - }, "shouldTranslate" : false }, "Error" : { @@ -3506,75 +3323,15 @@ } }, "excludeAPNs" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "excludeAPNs" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "excludeAPNs" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "excludeAPNs" - } - } - }, "shouldTranslate" : false }, "excludeCellularServices" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "excludeCellularServices" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "excludeCellularServices" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "excludeCellularServices" - } - } - }, "shouldTranslate" : false }, "excludeDeviceCommunication" : { "shouldTranslate" : false }, "excludeLocalNetworks" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "excludeLocalNetworks" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "excludeLocalNetworks" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "excludeLocalNetworks" - } - } - }, "shouldTranslate" : false }, "Export Complete" : { @@ -3633,6 +3390,34 @@ } } }, + "External Address" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "آدرس خارجی" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Внешний адрес" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "外部地址" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "外部地址" + } + } + } + }, "Failed to decode QRS data" : { "localizations" : { "fa" : { @@ -4292,6 +4077,9 @@ } }, "shouldTranslate" : false + }, + "HTTP/3" : { + }, "https://sing-box.sagernet.org/" : { "localizations" : { @@ -4400,6 +4188,34 @@ }, "shouldTranslate" : false }, + "Idle Latency" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "تأخیر بیکاری" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Задержка в простое" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "空闲延迟" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "閒置延遲" + } + } + } + }, "If set, a request is sent to this URL. If it doesn't return HTTP 200, VPN is started." : { "localizations" : { "fa" : { @@ -4824,26 +4640,6 @@ } }, "includeAllNetworks" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "includeAllNetworks" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "includeAllNetworks" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "includeAllNetworks" - } - } - }, "shouldTranslate" : false }, "Install" : { @@ -5182,6 +4978,34 @@ } } }, + "Latency" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "تأخیر" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Задержка" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "延迟" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "延遲" + } + } + } + }, "Launch the application when the system is logged in. If enabled at the same time as `Show in Menu Bar` and `Keep Menu Bar in Background`, the application interface will not be opened automatically." : { "localizations" : { "fa" : { @@ -5430,6 +5254,34 @@ }, "shouldTranslate" : false }, + "Max Runtime" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "حداکثر زمان اجرا" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Макс. время" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最大运行时间" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "最大執行時間" + } + } + } + }, "Memory" : { "localizations" : { "fa" : { @@ -5514,6 +5366,34 @@ } } }, + "Metered Connection" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "اتصال محدود" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Лимитное подключение" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "按流量计费连接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "按流量計費連線" + } + } + } + }, "Missing access to selected file" : { "localizations" : { "fa" : { @@ -5738,6 +5618,90 @@ } } }, + "NAT Filtering" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "فیلتر NAT" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "NAT-фильтрация" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "NAT 过滤" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "NAT 過濾" + } + } + } + }, + "NAT Mapping" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "نگاشت NAT" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "NAT-отображение" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "NAT 映射" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "NAT 映射" + } + } + } + }, + "NAT Type Detection" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "تشخیص نوع NAT" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Определение типа NAT" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "NAT 类型检测" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "NAT 類型偵測" + } + } + } + }, "Native Crash" : { "shouldTranslate" : false }, @@ -5797,6 +5761,34 @@ } } }, + "Network Quality" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "کیفیت شبکه" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Качество сети" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "网络质量" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "網路品質" + } + } + } + }, "NetworkExtension" : { "shouldTranslate" : false }, @@ -6055,6 +6047,34 @@ } } }, + "Not supported by server" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "پشتیبانی نمی‌شود توسط سرور" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не поддерживается сервером" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "服务器不支持" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "伺服器不支援" + } + } + } + }, "Ok" : { "localizations" : { "fa" : { @@ -7166,6 +7186,34 @@ } } }, + "Results" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "نتایج" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Результаты" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "结果" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "結果" + } + } + } + }, "Resume" : { "comment" : "Resume log auto-scroll", "localizations" : { @@ -7478,6 +7526,62 @@ } } }, + "Serial" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ترتیبی" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Последовательно" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "串行" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "序列" + } + } + } + }, + "Server" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "سرور" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сервер" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "服务器" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "伺服器" + } + } + } + }, "Service is Running" : { "localizations" : { "fa" : { @@ -8061,6 +8165,34 @@ } } }, + "Start Test" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "شروع تست" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Начать тест" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开始测试" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "開始測試" + } + } + } + }, "Start the VPN connection when conditions match." : { "localizations" : { "fa" : { @@ -8229,6 +8361,63 @@ } } }, + "STUN Server" : { + "extractionState" : "stale", + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "سرور STUN" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "STUN-сервер" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "STUN 服务器" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "STUN 伺服器" + } + } + } + }, + "STUN Test" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "تست STUN" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "STUN-тест" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "STUN 测试" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "STUN 測試" + } + } + } + }, "System Default" : { "localizations" : { "fa" : { @@ -8733,35 +8922,6 @@ } } }, - "Trigger OOM Report" : { - "extractionState" : "stale", - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ایجاد گزارش کمبود حافظه" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Вызвать отчёт о нехватке памяти" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "触发内存不足报告" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "觸發記憶體不足報告" - } - } - } - }, "true" : { "localizations" : { "fa" : { @@ -9154,6 +9314,34 @@ } } }, + "Upload RPM" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "RPM آپلود" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправка RPM" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上传 RPM" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "上傳 RPM" + } + } + } + }, "URL" : { "localizations" : { "fa" : { @@ -9228,9 +9416,6 @@ } } }, - "v%@" : { - "shouldTranslate" : false - }, "Version" : { "localizations" : { "fa" : { @@ -9622,6 +9807,34 @@ } } } + }, + "You're on a metered connection. This test will use a significant amount of data." : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "شما از اتصال محدود استفاده می‌کنید. این تست حجم زیادی از داده‌ها را مصرف خواهد کرد." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы используете лимитное подключение. Этот тест потребляет значительный объём данных." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "你正在使用按流量计费的连接。此测试将使用大量数据。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "你正在使用按流量計費的連線。此測試將使用大量數據。" + } + } + } } }, "version" : "1.0" diff --git a/MacLibrary/EditProfileContentWindow.swift b/MacLibrary/EditProfileContentWindow.swift index 4f6bd5e..4b31e68 100644 --- a/MacLibrary/EditProfileContentWindow.swift +++ b/MacLibrary/EditProfileContentWindow.swift @@ -21,7 +21,9 @@ struct EditProfileContentWindow: View { var body: some View { Group { - if viewModel.isLoading { + if context == nil { + Color.clear + } else if viewModel.isLoading { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) .task { @@ -37,8 +39,13 @@ struct EditProfileContentWindow: View { .frame(minWidth: 600, minHeight: 400) .background(WindowAccessor { window in guard let window else { return } + if context == nil { + window.close() + return + } if windowState.window == nil { windowState.window = window + window.isRestorable = false windowState.onClose = { [weak viewModel] in viewModel?.reset() } diff --git a/SFT/MainView.swift b/SFT/MainView.swift index b7f91f5..a62a5ff 100644 --- a/SFT/MainView.swift +++ b/SFT/MainView.swift @@ -30,7 +30,11 @@ struct MainView: View { .tag(page) .tabItem { if page == .tools, environments.totalUnreadReportCount > 0 { - Label("\(page.title) (\(environments.totalUnreadReportCount))", systemImage: "terminal.fill") + Label { + Text(verbatim: "\(page.title) (\(environments.totalUnreadReportCount))") + } icon: { + Image(systemName: "terminal.fill") + } } else { page.label } From 22e1941e5e65262628f9b02df169abff00d474e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 9 Apr 2026 19:35:31 +0800 Subject: [PATCH 30/37] Fix crash handler --- HelperService/main.swift | 2 ++ Library/Network/ExtensionProvider.swift | 2 ++ MacLibrary/ApplicationDelegate.swift | 2 ++ SFI/ApplicationDelegate.swift | 2 ++ SFT/ApplicationDelegate.swift | 2 ++ 5 files changed, 10 insertions(+) diff --git a/HelperService/main.swift b/HelperService/main.swift index 937bfea..45a5090 100644 --- a/HelperService/main.swift +++ b/HelperService/main.swift @@ -2,9 +2,11 @@ import Foundation import Libbox import Library +LibboxPrepareCrashSignalHandlers() NativeCrashReporter.installForCurrentProcess( basePath: URL(fileURLWithPath: WorkingDirectoryManager.helperNativeCrashBasePath, isDirectory: true) ) +LibboxReinstallCrashSignalHandlers() let pendingCrashLogs = RootHelperService.readCrashLogFiles() diff --git a/Library/Network/ExtensionProvider.swift b/Library/Network/ExtensionProvider.swift index 79c0264..ecdda75 100644 --- a/Library/Network/ExtensionProvider.swift +++ b/Library/Network/ExtensionProvider.swift @@ -81,6 +81,7 @@ open class ExtensionProvider: NEPacketTunnelProvider { #endif override public init() { + LibboxPrepareCrashSignalHandlers() #if os(macOS) if Variant.useSystemExtension { NativeCrashReporter.installForCurrentProcess( @@ -93,6 +94,7 @@ open class ExtensionProvider: NEPacketTunnelProvider { #else NativeCrashReporter.installForCurrentProcess() #endif + LibboxReinstallCrashSignalHandlers() super.init() } diff --git a/MacLibrary/ApplicationDelegate.swift b/MacLibrary/ApplicationDelegate.swift index b2de6bc..29f70ad 100644 --- a/MacLibrary/ApplicationDelegate.swift +++ b/MacLibrary/ApplicationDelegate.swift @@ -7,7 +7,9 @@ import UserNotifications open class ApplicationDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { public func applicationDidFinishLaunching(_: Notification) { + LibboxPrepareCrashSignalHandlers() NativeCrashReporter.installForCurrentProcess() + LibboxReinstallCrashSignalHandlers() NSLog("Here I stand") let options = LibboxSetupOptions() options.basePath = FilePath.sharedDirectory.relativePath diff --git a/SFI/ApplicationDelegate.swift b/SFI/ApplicationDelegate.swift index 64771e6..1683f0a 100644 --- a/SFI/ApplicationDelegate.swift +++ b/SFI/ApplicationDelegate.swift @@ -12,7 +12,9 @@ class ApplicationDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCe private var reportTransferServer: ReportTransferServer? func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + LibboxPrepareCrashSignalHandlers() NativeCrashReporter.installForCurrentProcess() + LibboxReinstallCrashSignalHandlers() NSLog("Here I stand") let options = LibboxSetupOptions() options.basePath = FilePath.sharedDirectory.relativePath diff --git a/SFT/ApplicationDelegate.swift b/SFT/ApplicationDelegate.swift index 7b10219..d6c6562 100644 --- a/SFT/ApplicationDelegate.swift +++ b/SFT/ApplicationDelegate.swift @@ -6,7 +6,9 @@ import UIKit class ApplicationDelegate: NSObject, UIApplicationDelegate { func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + LibboxPrepareCrashSignalHandlers() NativeCrashReporter.installForCurrentProcess() + LibboxReinstallCrashSignalHandlers() NSLog("Here I stand") let options = LibboxSetupOptions() options.basePath = FilePath.sharedDirectory.relativePath From d833ab870f905b12a54c52f8c6e49cdf0e93205a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 10 Apr 2026 11:35:09 +0800 Subject: [PATCH 31/37] tools: Tailscale status --- .../Views/Abstract/ViewModifiers.swift | 27 + .../Views/Tools/TailscaleEndpointView.swift | 116 +++++ .../Views/Tools/TailscalePeerView.swift | 224 ++++++++ .../Views/Tools/TailscalePingViewModel.swift | 89 ++++ .../Tools/TailscaleStatusViewModel.swift | 178 +++++++ .../Views/Tools/ToolsView.swift | 55 ++ Localizable.xcstrings | 484 +++++++++++++++++- 7 files changed, 1172 insertions(+), 1 deletion(-) create mode 100644 ApplicationLibrary/Views/Tools/TailscaleEndpointView.swift create mode 100644 ApplicationLibrary/Views/Tools/TailscalePeerView.swift create mode 100644 ApplicationLibrary/Views/Tools/TailscalePingViewModel.swift create mode 100644 ApplicationLibrary/Views/Tools/TailscaleStatusViewModel.swift diff --git a/ApplicationLibrary/Views/Abstract/ViewModifiers.swift b/ApplicationLibrary/Views/Abstract/ViewModifiers.swift index caaa99c..55db2c8 100644 --- a/ApplicationLibrary/Views/Abstract/ViewModifiers.swift +++ b/ApplicationLibrary/Views/Abstract/ViewModifiers.swift @@ -146,6 +146,33 @@ public extension View { } #endif +public struct ActionIconButton: View { + let systemImage: String + let action: () -> Void + + public init(_ systemImage: String, action: @escaping () -> Void) { + self.systemImage = systemImage + self.action = action + } + + public var body: some View { + Button(action: action) { + Image(systemName: systemImage) + .font(.system(size: 12)) + #if !os(tvOS) + .frame(width: 44, height: 32) + .background(Color.secondary.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + #endif + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + #if os(tvOS) + .actionButtonStyle() + #endif + } +} + public extension View { func cardStyle() -> some View { modifier(CardStyleModifier()) diff --git a/ApplicationLibrary/Views/Tools/TailscaleEndpointView.swift b/ApplicationLibrary/Views/Tools/TailscaleEndpointView.swift new file mode 100644 index 0000000..f0a6e42 --- /dev/null +++ b/ApplicationLibrary/Views/Tools/TailscaleEndpointView.swift @@ -0,0 +1,116 @@ +import Library +import SwiftUI + +@MainActor +public struct TailscaleEndpointView: View { + @ObservedObject var viewModel: TailscaleStatusViewModel + @Environment(\.dismiss) private var dismiss + @State private var showAuthURLQRCode = false + let endpointTag: String + + public init(viewModel: TailscaleStatusViewModel, endpointTag: String) { + self.viewModel = viewModel + self.endpointTag = endpointTag + } + + private var endpoint: TailscaleEndpointData? { + viewModel.endpoint(tag: endpointTag) + } + + public var body: some View { + FormView { + if let endpoint { + Section("Status") { + FormTextItem("State", "power") { + HStack(spacing: 6) { + Image(systemName: "circle.fill") + .font(.system(size: 8)) + .foregroundStyle(stateColor(endpoint.backendState)) + Text(endpoint.backendState) + } + } + if !endpoint.networkName.isEmpty { + FormTextItem("Network", "network") { + Text(endpoint.networkName) + } + } + if !endpoint.magicDNSSuffix.isEmpty { + FormTextItem("MagicDNS", "globe") { + Text(endpoint.magicDNSSuffix) + } + } + if !endpoint.authURL.isEmpty { + if let url = URL(string: endpoint.authURL) { + #if !os(tvOS) + Link(destination: url) { + Label("Open Auth URL", systemImage: "arrow.up.forward.app") + } + #endif + Button { + showAuthURLQRCode = true + } label: { + Label("Open Auth URL as QR Code", systemImage: "qrcode") + } + } + } + } + + if endpoint.backendState == "Running", let selfPeer = endpoint.selfPeer { + Section("This Device") { + peerLink(selfPeer, isSelf: true) + } + } + + ForEach(endpoint.userGroups) { group in + Section { + ForEach(group.peers) { peer in + peerLink(peer, isSelf: false) + } + } header: { + Text(group.displayName.isEmpty ? group.loginName : group.displayName) + } + } + } + } + .navigationTitle(endpointTag) + .sheet(isPresented: $showAuthURLQRCode) { + if let endpoint { + URLQRCodeSheet(url: endpoint.authURL, title: String(localized: "Auth URL")) + } + } + .onChangeCompat(of: endpoint == nil) { isNil in + if isNil { + dismiss() + } + } + } + + private func peerLink(_ peer: TailscalePeerData, isSelf: Bool) -> some View { + FormNavigationLink { + TailscalePeerView(peer: peer, endpointTag: endpointTag, isSelf: isSelf) + } label: { + HStack { + Image(systemName: "circle.fill") + .font(.system(size: 8)) + .foregroundStyle(peer.online ? .green : Color(.systemGray)) + VStack(alignment: .leading, spacing: 2) { + Text(peer.hostName) + if let firstIP = peer.tailscaleIPs.first { + Text(firstIP) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + } + + private func stateColor(_ state: String) -> Color { + switch state { + case "Running": .green + case "NeedsLogin", "NeedsMachineAuth": .orange + case "Starting": .yellow + default: Color(.systemGray) + } + } +} diff --git a/ApplicationLibrary/Views/Tools/TailscalePeerView.swift b/ApplicationLibrary/Views/Tools/TailscalePeerView.swift new file mode 100644 index 0000000..eda5f0e --- /dev/null +++ b/ApplicationLibrary/Views/Tools/TailscalePeerView.swift @@ -0,0 +1,224 @@ +import Libbox +import Library +import SwiftUI + +#if os(iOS) || os(tvOS) + import UIKit +#elseif os(macOS) + import AppKit +#endif + +@MainActor +public struct TailscalePeerView: View { + let peer: TailscalePeerData + let endpointTag: String + let isSelf: Bool + + @State private var copiedAddress: String? + @StateObject private var pingViewModel = TailscalePingViewModel() + + public init(peer: TailscalePeerData, endpointTag: String, isSelf: Bool) { + self.peer = peer + self.endpointTag = endpointTag + self.isSelf = isSelf + } + + public var body: some View { + FormView { + Section("Tailscale Addresses") { + if !peer.dnsName.isEmpty { + addressRow(LibboxFormatFQDN(peer.dnsName), label: "MagicDNS") + } + ForEach(Array(peer.tailscaleIPs.enumerated()), id: \.offset) { _, ip in + if ip.contains(":") { + addressRow(ip, label: "IPv6") + } else { + addressRow(ip, label: "IPv4") + } + } + } + + if !isSelf, peer.online, let peerIP = peer.tailscaleIPs.first { + Section { + if pingViewModel.hasResult { + connectionTypeRow + } + if pingViewModel.isRunning, pingViewModel.hasResult { + pingChartView + } + if !pingViewModel.hasResult { + Text("No data") + .foregroundStyle(.secondary) + } + } header: { + HStack { + Text("Ping") + Spacer() + ActionIconButton(pingViewModel.isRunning ? "stop.fill" : "play.fill") { + if pingViewModel.isRunning { + pingViewModel.stop() + } else { + pingViewModel.start(endpointTag: endpointTag, peerIP: peerIP) + } + } + .textCase(nil) + } + } + } + + if peer.keyExpiry > 0 || !peer.os.isEmpty || peer.exitNode { + Section("Details") { + if peer.keyExpiry > 0 { + FormTextItem("Key Expiry", "key") { + Text(keyExpiryText) + } + } + if !peer.os.isEmpty { + FormTextItem("OS", "desktopcomputer") { + Text(peer.os) + } + } + if peer.exitNode { + FormTextItem("Exit Node", "arrow.triangle.turn.up.right.diamond") { + Text("Active") + } + } + } + } + } + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .principal) { + VStack(spacing: 2) { + Text(peer.hostName) + .font(.headline) + HStack(spacing: 4) { + Image(systemName: "circle.fill") + .font(.system(size: 6)) + .foregroundStyle(peer.online ? .green : Color(.systemGray)) + Text(peer.online ? "Connected" : "Not Connected") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + .onDisappear { + if pingViewModel.isRunning { + pingViewModel.stop() + } + } + } + + private var connectionTypeRow: some View { + HStack(spacing: 8) { + if pingViewModel.isDirect { + Image(systemName: "arrow.right") + .foregroundStyle(.green) + Text("Direct connection") + .foregroundStyle(.green) + } else { + Image(systemName: "arrow.triangle.2.circlepath") + .foregroundStyle(.orange) + Text("DERP-relayed connection") + .foregroundStyle(.orange) + } + Spacer() + Text(verbatim: "\(Int(pingViewModel.latencyMs)) ms") + .font(.headline) + } + } + + private var pingChartView: some View { + #if os(tvOS) + let chartHeight: CGFloat = 160 + let labelWidth: CGFloat = 80 + #else + let chartHeight: CGFloat = 80 + let labelWidth: CGFloat = 50 + #endif + return HStack(alignment: .center) { + TrafficLineChart( + data: pingViewModel.latencyHistory, + lineColor: pingViewModel.isDirect ? .green : .blue, + chartHeight: chartHeight + ) + VStack(alignment: .trailing, spacing: 0) { + let maxMs = max(Int((pingViewModel.latencyHistory.max() ?? 1) * 1.2), 1) + Text(verbatim: "\(maxMs)ms") + Spacer() + Text(verbatim: "\(maxMs * 2 / 3)ms") + Spacer() + Text(verbatim: "\(maxMs / 3)ms") + Spacer() + Text(verbatim: "0ms") + } + .font(.caption2) + .foregroundStyle(.secondary) + .frame(width: labelWidth) + } + .frame(height: chartHeight) + #if os(tvOS) + .padding(.vertical, 8) + #endif + } + + private var keyExpiryText: String { + let date = Date(timeIntervalSince1970: TimeInterval(peer.keyExpiry)) + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + return formatter.localizedString(for: date, relativeTo: Date()) + } + + private func addressRow(_ address: String, label: String) -> some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(address) + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + #if !os(tvOS) + Button { + copyToClipboard(address) + } label: { + if copiedAddress == address { + Image(systemName: "checkmark") + .foregroundStyle(.secondary) + } else { + Image(systemName: "doc.on.doc") + .foregroundStyle(.blue) + } + } + #if os(macOS) + .buttonStyle(.plain) + #endif + #endif + } + } + + private func copyToClipboard(_ text: String) { + #if os(iOS) + UIPasteboard.general.string = text + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.success) + #elseif os(macOS) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + #endif + withAnimation { + copiedAddress = text + } + Task { + try? await Task.sleep(nanoseconds: NSEC_PER_SEC * 2) + withAnimation { + if copiedAddress == text { + copiedAddress = nil + } + } + } + } +} diff --git a/ApplicationLibrary/Views/Tools/TailscalePingViewModel.swift b/ApplicationLibrary/Views/Tools/TailscalePingViewModel.swift new file mode 100644 index 0000000..69cae6e --- /dev/null +++ b/ApplicationLibrary/Views/Tools/TailscalePingViewModel.swift @@ -0,0 +1,89 @@ +import Foundation +import Libbox +import Library +import SwiftUI + +@MainActor +public final class TailscalePingViewModel: BaseViewModel { + @Published public var isRunning = false + @Published public var latencyMs: Double = 0 + @Published public var isDirect: Bool = false + @Published public var derpRegionCode: String = "" + @Published public var endpoint: String = "" + @Published public var hasResult = false + @Published public var latencyHistory: [CGFloat] = [] + + private let maxHistorySize = 30 + private var commandClient: LibboxCommandClient? + private var runningTask: Task? + + public func start(endpointTag: String, peerIP: String) { + latencyHistory = [] + hasResult = false + isRunning = true + + let client = LibboxNewStandaloneCommandClient()! + commandClient = client + let handler = PingHandler(self) + + runningTask = Task { [weak self] in + await Task.detached { + try? client.startTailscalePing(endpointTag, peerIP: peerIP, handler: handler) + }.value + self?.runningTask = nil + } + } + + public func stop() { + runningTask?.cancel() + runningTask = nil + try? commandClient?.disconnect() + commandClient = nil + isRunning = false + } + + fileprivate func appendLatency(_ ms: Double) { + latencyHistory.append(CGFloat(ms)) + if latencyHistory.count > maxHistorySize { + latencyHistory.removeFirst() + } + } + + private final class PingHandler: NSObject, LibboxTailscalePingHandlerProtocol, @unchecked Sendable { + private weak var viewModel: TailscalePingViewModel? + + init(_ viewModel: TailscalePingViewModel?) { + self.viewModel = viewModel + } + + func onPingResult(_ result: LibboxTailscalePingResult?) { + guard let result else { return } + let latencyMs = result.latencyMs + let isDirect = result.isDirect + let derpRegionCode = result.derpRegionCode + let endpoint = result.endpoint + let error = result.error + DispatchQueue.main.async { [self] in + guard let viewModel, viewModel.isRunning else { return } + if !error.isEmpty { + return + } + viewModel.latencyMs = latencyMs + viewModel.isDirect = isDirect + viewModel.derpRegionCode = derpRegionCode + viewModel.endpoint = endpoint + viewModel.hasResult = true + viewModel.appendLatency(latencyMs) + } + } + + func onError(_: String?) { + DispatchQueue.main.async { [self] in + guard let viewModel, viewModel.isRunning else { return } + viewModel.isRunning = false + viewModel.commandClient = nil + viewModel.runningTask = nil + } + } + } +} diff --git a/ApplicationLibrary/Views/Tools/TailscaleStatusViewModel.swift b/ApplicationLibrary/Views/Tools/TailscaleStatusViewModel.swift new file mode 100644 index 0000000..185bb65 --- /dev/null +++ b/ApplicationLibrary/Views/Tools/TailscaleStatusViewModel.swift @@ -0,0 +1,178 @@ +import Foundation +import Libbox +import Library +import SwiftUI + +public struct TailscalePeerData: Identifiable { + public let id: String + public let hostName: String + public let dnsName: String + public let os: String + public let tailscaleIPs: [String] + public let online: Bool + public let exitNode: Bool + public let exitNodeOption: Bool + public let active: Bool + public let rxBytes: Int64 + public let txBytes: Int64 + public let keyExpiry: Int64 +} + +public struct TailscaleUserGroupData: Identifiable { + public let id: Int64 + public let loginName: String + public let displayName: String + public let profilePicURL: String + public let peers: [TailscalePeerData] +} + +public struct TailscaleEndpointData: Identifiable { + public let id: String + public let endpointTag: String + public let backendState: String + public let authURL: String + public let networkName: String + public let magicDNSSuffix: String + public let selfPeer: TailscalePeerData? + public let userGroups: [TailscaleUserGroupData] +} + +@MainActor +public final class TailscaleStatusViewModel: BaseViewModel { + @Published public var endpoints: [TailscaleEndpointData] = [] + @Published public var isSubscribed = false + + private var runningTask: Task? + + public func subscribe() { + guard !isSubscribed else { return } + isSubscribed = true + + let handler = StatusHandler(self) + runningTask = Task { [weak self] in + do { + try await Task.detached { + try LibboxNewStandaloneCommandClient()!.subscribeTailscaleStatus(handler) + }.value + } catch { + guard let self else { return } + self.isSubscribed = false + self.endpoints = [] + } + self?.runningTask = nil + } + } + + public func cancel() { + runningTask?.cancel() + runningTask = nil + isSubscribed = false + endpoints = [] + } + + public func endpoint(tag: String) -> TailscaleEndpointData? { + endpoints.first { $0.endpointTag == tag } + } + + private final class StatusHandler: NSObject, LibboxTailscaleStatusHandlerProtocol, @unchecked Sendable { + private weak var viewModel: TailscaleStatusViewModel? + + init(_ viewModel: TailscaleStatusViewModel?) { + self.viewModel = viewModel + } + + func onStatusUpdate(_ status: LibboxTailscaleStatusUpdate?) { + guard let status else { return } + let endpoints = Self.convertUpdate(status) + DispatchQueue.main.async { [self] in + guard let viewModel, viewModel.isSubscribed else { return } + viewModel.endpoints = endpoints + } + } + + func onError(_ message: String?) { + DispatchQueue.main.async { [self] in + guard let viewModel, viewModel.isSubscribed else { return } + viewModel.isSubscribed = false + viewModel.endpoints = [] + if let message { + viewModel.alert = AlertState(errorMessage: message) + } + } + } + + private static func convertUpdate(_ status: LibboxTailscaleStatusUpdate) -> [TailscaleEndpointData] { + var endpoints: [TailscaleEndpointData] = [] + if let iterator = status.endpoints() { + while iterator.hasNext() { + if let endpoint = iterator.next() { + endpoints.append(convertEndpoint(endpoint)) + } + } + } + return endpoints + } + + private static func convertEndpoint(_ endpoint: LibboxTailscaleEndpointStatus) -> TailscaleEndpointData { + var userGroups: [TailscaleUserGroupData] = [] + if let groupIterator = endpoint.userGroups() { + while groupIterator.hasNext() { + if let group = groupIterator.next() { + userGroups.append(convertUserGroup(group)) + } + } + } + return TailscaleEndpointData( + id: endpoint.endpointTag, + endpointTag: endpoint.endpointTag, + backendState: endpoint.backendState, + authURL: endpoint.authURL, + networkName: endpoint.networkName, + magicDNSSuffix: endpoint.magicDNSSuffix, + selfPeer: endpoint.self_ != nil ? convertPeer(endpoint.self_!) : nil, + userGroups: userGroups + ) + } + + private static func convertUserGroup(_ group: LibboxTailscaleUserGroup) -> TailscaleUserGroupData { + var peers: [TailscalePeerData] = [] + if let peerIterator = group.peers() { + while peerIterator.hasNext() { + if let peer = peerIterator.next() { + peers.append(convertPeer(peer)) + } + } + } + return TailscaleUserGroupData( + id: group.userID, + loginName: group.loginName, + displayName: group.displayName, + profilePicURL: group.profilePicURL, + peers: peers + ) + } + + private static func convertPeer(_ peer: LibboxTailscalePeer) -> TailscalePeerData { + var ips: [String] = [] + if let ipIterator = peer.tailscaleIPs() { + while ipIterator.hasNext() { + ips.append(ipIterator.next()) + } + } + return TailscalePeerData( + id: peer.dnsName.isEmpty ? peer.hostName : peer.dnsName, + hostName: peer.hostName, + dnsName: peer.dnsName, + os: peer.os, + tailscaleIPs: ips, + online: peer.online, + exitNode: peer.exitNode, + exitNodeOption: peer.exitNodeOption, + active: peer.active, + rxBytes: peer.rxBytes, + txBytes: peer.txBytes, + keyExpiry: peer.keyExpiry + ) + } + } +} diff --git a/ApplicationLibrary/Views/Tools/ToolsView.swift b/ApplicationLibrary/Views/Tools/ToolsView.swift index d7f3fbf..021076e 100644 --- a/ApplicationLibrary/Views/Tools/ToolsView.swift +++ b/ApplicationLibrary/Views/Tools/ToolsView.swift @@ -1,10 +1,12 @@ import Library +import NetworkExtension import SwiftUI @MainActor public struct ToolsView: View { @EnvironmentObject private var environments: ExtensionEnvironments @StateObject private var viewModel = SettingViewModel() + @StateObject private var tailscaleViewModel = TailscaleStatusViewModel() #if os(iOS) @State private var showCrashReportList = false @State private var showOOMReportList = false @@ -14,6 +16,22 @@ public struct ToolsView: View { public var body: some View { FormView { + if !tailscaleViewModel.endpoints.isEmpty { + Section("Endpoints") { + ForEach(tailscaleViewModel.endpoints) { endpoint in + FormNavigationLink { + TailscaleEndpointView(viewModel: tailscaleViewModel, endpointTag: endpoint.endpointTag) + } label: { + if tailscaleViewModel.endpoints.count == 1 { + Label("Tailscale", systemImage: "point.3.filled.connected.trianglepath.dotted") + } else { + Label("Tailscale: \(endpoint.endpointTag)", systemImage: "point.3.filled.connected.trianglepath.dotted") + } + } + } + } + } + Section("Network") { FormNavigationLink { NetworkQualityView() @@ -106,5 +124,42 @@ public struct ToolsView: View { } } } + .modifier(TailscaleStatusObserver(profile: environments.extensionProfile, viewModel: tailscaleViewModel)) + .alert($tailscaleViewModel.alert) + } +} + +private struct TailscaleStatusObserver: ViewModifier { + var profile: ExtensionProfile? + var viewModel: TailscaleStatusViewModel + + func body(content: Content) -> some View { + if let profile { + content + .modifier(ActiveObserver(profile: profile, viewModel: viewModel)) + } else { + content + } + } + + private struct ActiveObserver: ViewModifier { + @ObservedObject var profile: ExtensionProfile + var viewModel: TailscaleStatusViewModel + + func body(content: Content) -> some View { + content + .onChangeCompat(of: profile.status) { status in + if status.isConnectedStrict { + viewModel.subscribe() + } else { + viewModel.cancel() + } + } + .onAppear { + if profile.status.isConnectedStrict { + viewModel.subscribe() + } + } + } } } diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 3767efc..a39e81c 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -597,6 +597,9 @@ } } } + }, + "Auth URL" : { + }, "Authorize" : { "localizations" : { @@ -1635,6 +1638,34 @@ } } }, + "Connected" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "متصل" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подключено" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已连接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已連線" + } + } + } + }, "Connecting..." : { "localizations" : { "fa" : { @@ -2283,6 +2314,34 @@ } } }, + "DERP-relayed connection" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "اتصال رله‌شده DERP" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "DERP-ретранслируемое соединение" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "DERP 中继连接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "DERP 中繼連接" + } + } + } + }, "Destination" : { "localizations" : { "fa" : { @@ -2339,6 +2398,62 @@ } } }, + "Details" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "جزئیات" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подробности" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "详细信息" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "詳細資訊" + } + } + } + }, + "Direct connection" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "اتصال مستقیم" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Прямое соединение" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "直接连接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "直接連接" + } + } + } + }, "Disable Deprecated Warnings" : { "localizations" : { "fa" : { @@ -3179,6 +3294,34 @@ } } }, + "Endpoints" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "نقاط اتصال" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Точки подключения" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "端点" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "端點" + } + } + } + }, "enforceRoutes" : { "shouldTranslate" : false }, @@ -3334,6 +3477,34 @@ "excludeLocalNetworks" : { "shouldTranslate" : false }, + "Exit Node" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "گره خروجی" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выходной узел" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "出口节点" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "出口節點" + } + } + } + }, "Export Complete" : { "localizations" : { "fa" : { @@ -4079,7 +4250,7 @@ "shouldTranslate" : false }, "HTTP/3" : { - + "shouldTranslate" : false }, "https://sing-box.sagernet.org/" : { "localizations" : { @@ -4838,6 +5009,34 @@ } } }, + "Key Expiry" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "انقضای کلید" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Срок действия ключа" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "密钥过期" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "金鑰到期" + } + } + } + }, "Kill all connections to free memory when the service memory exceeds the limit." : { "localizations" : { "fa" : { @@ -5175,6 +5374,34 @@ } } }, + "MagicDNS" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "MagicDNS" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "MagicDNS" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "MagicDNS" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "MagicDNS" + } + } + } + }, "Managing working directory requires Helper Service." : { "localizations" : { "fa" : { @@ -5960,6 +6187,34 @@ } } }, + "No data" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "بدون داده" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет данных" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无数据" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無資料" + } + } + } + }, "No Default Route" : { "localizations" : { "fa" : { @@ -6047,6 +6302,34 @@ } } }, + "Not Connected" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "متصل نیست" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не подключено" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未连接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "未連線" + } + } + } + }, "Not supported by server" : { "localizations" : { "fa" : { @@ -6214,6 +6497,37 @@ } } } + }, + "Open Auth URL" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "باز کردن لینک احراز هویت" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открыть URL авторизации" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开认证链接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "開啟認證連結" + } + } + } + }, + "Open Auth URL as QR Code" : { + }, "Open Settings" : { "localizations" : { @@ -6243,6 +6557,34 @@ } } }, + "OS" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "سیستم‌عامل" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ОС" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "操作系统" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "作業系統" + } + } + } + }, "Other methods" : { "localizations" : { "fa" : { @@ -6468,6 +6810,34 @@ } } }, + "Ping" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "پینگ" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пинг" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ping" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ping" + } + } + } + }, "Please enable camera access in Settings to scan QR codes." : { "localizations" : { "fa" : { @@ -8558,6 +8928,90 @@ } } }, + "Tailscale" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tailscale" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tailscale" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tailscale" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tailscale" + } + } + } + }, + "Tailscale Addresses" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "آدرس‌های Tailscale" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Адреса Tailscale" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tailscale 地址" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tailscale 位址" + } + } + } + }, + "Tailscale: %@" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tailscale: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tailscale: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tailscale: %@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tailscale: %@" + } + } + } + }, "Taiwan Flag Available" : { "localizations" : { "fa" : { @@ -8698,6 +9152,34 @@ } } }, + "This Device" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "این دستگاه" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Это устройство" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此设备" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此裝置" + } + } + } + }, "This helper service provides process lookup for `process_name` and `process_path` routing rules, and manages the working directory." : { "localizations" : { "fa" : { From 8480bc3bb600d6bdc69938b5d710f3a6c6d66532 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 11 Apr 2026 12:04:48 +0800 Subject: [PATCH 32/37] Fix deprecated check error --- .../Views/Abstract/GlobalChecksModifier.swift | 12 +++------ .../Views/Tools/TailscalePeerView.swift | 26 +++++++++---------- 2 files changed, 16 insertions(+), 22 deletions(-) diff --git a/ApplicationLibrary/Views/Abstract/GlobalChecksModifier.swift b/ApplicationLibrary/Views/Abstract/GlobalChecksModifier.swift index 340529f..bea5a54 100644 --- a/ApplicationLibrary/Views/Abstract/GlobalChecksModifier.swift +++ b/ApplicationLibrary/Views/Abstract/GlobalChecksModifier.swift @@ -165,16 +165,10 @@ public struct GlobalChecksModifier: ViewModifier { let disableWarnings = await SharedPreferences.disableDeprecatedWarnings.get() guard !disableWarnings else { return } - do { - let reports = try LibboxNewStandaloneCommandClient()!.getDeprecatedNotes() - if reports.hasNext() { - await MainActor.run { - showNextDeprecatedNote(reports) - } - } - } catch { + guard let reports = try? LibboxNewStandaloneCommandClient()!.getDeprecatedNotes() else { return } + if reports.hasNext() { await MainActor.run { - alert = AlertState(action: "check deprecated notes", error: error) + showNextDeprecatedNote(reports) } } } diff --git a/ApplicationLibrary/Views/Tools/TailscalePeerView.swift b/ApplicationLibrary/Views/Tools/TailscalePeerView.swift index eda5f0e..bd9ff0e 100644 --- a/ApplicationLibrary/Views/Tools/TailscalePeerView.swift +++ b/ApplicationLibrary/Views/Tools/TailscalePeerView.swift @@ -182,20 +182,20 @@ public struct TailscalePeerView: View { } Spacer() #if !os(tvOS) - Button { - copyToClipboard(address) - } label: { - if copiedAddress == address { - Image(systemName: "checkmark") - .foregroundStyle(.secondary) - } else { - Image(systemName: "doc.on.doc") - .foregroundStyle(.blue) + Button { + copyToClipboard(address) + } label: { + if copiedAddress == address { + Image(systemName: "checkmark") + .foregroundStyle(.secondary) + } else { + Image(systemName: "doc.on.doc") + .foregroundStyle(.blue) + } } - } - #if os(macOS) - .buttonStyle(.plain) - #endif + #if os(macOS) + .buttonStyle(.plain) + #endif #endif } } From dd5bb31c3fff643b206774038981e70eac00718d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 13 Apr 2026 18:37:18 +0800 Subject: [PATCH 33/37] Fix tvOS report export --- ApplicationLibrary/Service/NWSocket.swift | 8 ++ .../Service/ReportTransfer.swift | 25 ++-- .../Service/ReportTransferServer.swift | 128 ++++++++++++------ .../Views/Tools/ExportReportView.swift | 68 +++++++--- 4 files changed, 161 insertions(+), 68 deletions(-) diff --git a/ApplicationLibrary/Service/NWSocket.swift b/ApplicationLibrary/Service/NWSocket.swift index 4a98838..b855f80 100644 --- a/ApplicationLibrary/Service/NWSocket.swift +++ b/ApplicationLibrary/Service/NWSocket.swift @@ -81,6 +81,14 @@ public final class NWSocket { try await sendAndAwait(content: LibboxEncodeChunkedMessage(data), timeout: timeout, phase: "write") } + public func readRaw(count: Int, timeout: TimeInterval = 60) async throws -> Data { + try await receiveExactly(count: count, timeout: timeout, phase: "read raw body") + } + + public func writeRaw(_ data: Data, timeout: TimeInterval = 30) async throws { + try await sendAndAwait(content: data, timeout: timeout, phase: "write raw body") + } + public func send(_ data: Data?) { guard let data else { return diff --git a/ApplicationLibrary/Service/ReportTransfer.swift b/ApplicationLibrary/Service/ReportTransfer.swift index 9428d5e..5a128fb 100644 --- a/ApplicationLibrary/Service/ReportTransfer.swift +++ b/ApplicationLibrary/Service/ReportTransfer.swift @@ -21,25 +21,27 @@ public enum ReportTransferMessageType: UInt8 { case ack = 3 } -public struct ReportTransferPayload: Codable { +public struct ReportTransferManifest: Codable { public var reportType: ReportType public var timestamp: TimeInterval - public var files: [ReportTransferFile] + public var totalBytes: UInt64 + public var files: [ReportTransferManifestFile] - public init(reportType: ReportType, timestamp: TimeInterval, files: [ReportTransferFile]) { + public init(reportType: ReportType, timestamp: TimeInterval, totalBytes: UInt64, files: [ReportTransferManifestFile]) { self.reportType = reportType self.timestamp = timestamp + self.totalBytes = totalBytes self.files = files } } -public struct ReportTransferFile: Codable { +public struct ReportTransferManifestFile: Codable { public var name: String - public var data: Data + public var size: UInt64 - public init(name: String, data: Data) { + public init(name: String, size: UInt64) { self.name = name - self.data = data + self.size = size } } @@ -53,12 +55,13 @@ public struct ReportTransferError: LocalizedError { public enum ReportTransferService { public static let applicationServiceName = "sing-box:report-transfer" + public static let fileChunkSize = 64 * 1024 } public enum ReportTransferMessage { - public static func encodeReport(_ payload: ReportTransferPayload) throws -> Data { + public static func encodeReport(_ manifest: ReportTransferManifest) throws -> Data { var data = Data([ReportTransferMessageType.report.rawValue]) - try data.append(BinaryEncoder().encode(payload)) + try data.append(BinaryEncoder().encode(manifest)) return data } @@ -81,8 +84,8 @@ public enum ReportTransferMessage { return ReportTransferMessageType(rawValue: data[0]) } - public static func decodeReport(_ data: Data) throws -> ReportTransferPayload { - try BinaryDecoder().decode(ReportTransferPayload.self, from: data.dropFirst()) + public static func decodeReport(_ data: Data) throws -> ReportTransferManifest { + try BinaryDecoder().decode(ReportTransferManifest.self, from: data.dropFirst()) } public static func decodeError(_ data: Data) -> String { diff --git a/ApplicationLibrary/Service/ReportTransferServer.swift b/ApplicationLibrary/Service/ReportTransferServer.swift index 4e91d31..9732e78 100644 --- a/ApplicationLibrary/Service/ReportTransferServer.swift +++ b/ApplicationLibrary/Service/ReportTransferServer.swift @@ -52,37 +52,25 @@ beginBackgroundTask() defer { endBackgroundTask() } - var receivedCount = 0 - var lastReportType: ReportType? do { - while true { - let message = try await connection.read() - guard let type = ReportTransferMessage.decodeType(message) else { - continue - } - switch type { - case .report: - let payload = try ReportTransferMessage.decodeReport(message) - try importReport(payload) - lastReportType = payload.reportType - receivedCount += 1 - case .complete: - logger.info("report transfer server: received \(receivedCount) report(s)") - if receivedCount > 0 { - let reportType = lastReportType - await MainActor.run { - NotificationCenter.default.post(name: .reportReceived, object: reportType) - } - } - try await connection.write(ReportTransferMessage.encodeAck()) - return - case .error: - let errorMsg = ReportTransferMessage.decodeError(message) - logger.warning("report transfer server: client error: \(errorMsg)") - return - case .ack: - return + let message = try await connection.read() + guard let type = ReportTransferMessage.decodeType(message) else { + throw ReportTransferError("Invalid report transfer message") + } + switch type { + case .report: + let manifest = try ReportTransferMessage.decodeReport(message) + try await importReport(manifest) + logger.info("report transfer server: received report") + await MainActor.run { + NotificationCenter.default.post(name: .reportReceived, object: manifest.reportType) } + try await connection.write(ReportTransferMessage.encodeAck()) + case .error: + let errorMsg = ReportTransferMessage.decodeError(message) + logger.warning("report transfer server: client error: \(errorMsg)") + case .complete, .ack: + throw ReportTransferError("Unexpected report transfer message") } } catch { logger.warning("report transfer server: \(error.localizedDescription)") @@ -90,21 +78,83 @@ } } - private func importReport(_ payload: ReportTransferPayload) throws { - let reportsDir = FilePath.workingDirectory.appendingPathComponent(payload.reportType.directoryName, isDirectory: true) + private func importReport(_ manifest: ReportTransferManifest) async throws { + guard !manifest.files.isEmpty else { + throw ReportTransferError("Report is empty") + } + + let expectedBytes = manifest.files.reduce(0) { $0 + $1.size } + guard expectedBytes == manifest.totalBytes else { + throw ReportTransferError("Invalid report manifest") + } + + let reportsDir = FilePath.workingDirectory.appendingPathComponent(manifest.reportType.directoryName, isDirectory: true) try FileManager.default.createDirectory(at: reportsDir, withIntermediateDirectories: true) - let date = Date(timeIntervalSince1970: payload.timestamp) + let date = Date(timeIntervalSince1970: manifest.timestamp) let artifactURL = ReportArchive.nextAvailableArtifactURL(in: reportsDir, for: date) - try FileManager.default.createDirectory(at: artifactURL, withIntermediateDirectories: true) + let stagingURL = nextAvailableStagingArtifactURL(in: reportsDir, for: artifactURL.lastPathComponent) + try FileManager.default.createDirectory(at: stagingURL, withIntermediateDirectories: true) - for file in payload.files { - let fileURL = artifactURL.appendingPathComponent(file.name) - if file.name == ReportArchive.metadataFileName { - try writeMetadataWithDeviceOrigin(file.data, to: fileURL) - } else { - try file.data.write(to: fileURL, options: .atomic) + do { + var receivedBytes: UInt64 = 0 + for file in manifest.files { + let fileURL = stagingURL.appendingPathComponent(file.name) + FileManager.default.createFile(atPath: fileURL.path, contents: nil) + do { + let handle = try FileHandle(forWritingTo: fileURL) + defer { try? handle.close() } + + var remaining = file.size + while remaining > 0 { + let chunkSize = Int(min(UInt64(ReportTransferService.fileChunkSize), remaining)) + let data = try await connection.readRaw(count: chunkSize) + try handle.write(contentsOf: data) + remaining -= UInt64(data.count) + receivedBytes += UInt64(data.count) + } + } } + + guard receivedBytes == manifest.totalBytes else { + throw ReportTransferError("Report transfer was incomplete") + } + + let completion = try await connection.read() + guard let completionType = ReportTransferMessage.decodeType(completion) else { + throw ReportTransferError("Invalid report transfer message") + } + switch completionType { + case .complete: + break + case .error: + throw ReportTransferError(ReportTransferMessage.decodeError(completion)) + case .report, .ack: + throw ReportTransferError("Unexpected report transfer message") + } + + let metadataURL = stagingURL.appendingPathComponent(ReportArchive.metadataFileName) + if FileManager.default.fileExists(atPath: metadataURL.path) { + let metadataData = try Data(contentsOf: metadataURL) + try writeMetadataWithDeviceOrigin(metadataData, to: metadataURL) + } + + try FileManager.default.moveItem(at: stagingURL, to: artifactURL) + } catch { + try? FileManager.default.removeItem(at: stagingURL) + throw error + } + } + + private func nextAvailableStagingArtifactURL(in directory: URL, for artifactName: String) -> URL { + var index = 0 + while true { + let suffix = index == 0 ? "" : "-\(index)" + let candidate = directory.appendingPathComponent(".\(artifactName).partial\(suffix)", isDirectory: true) + if !FileManager.default.fileExists(atPath: candidate.path) { + return candidate + } + index += 1 } } diff --git a/ApplicationLibrary/Views/Tools/ExportReportView.swift b/ApplicationLibrary/Views/Tools/ExportReportView.swift index ffdc96f..27abef5 100644 --- a/ApplicationLibrary/Views/Tools/ExportReportView.swift +++ b/ApplicationLibrary/Views/Tools/ExportReportView.swift @@ -5,6 +5,12 @@ import Network import SwiftUI + private struct StreamedReportFile: Sendable { + let name: String + let fileURL: URL + let size: UInt64 + } + @MainActor public struct ExportReportView: View { @Environment(\.dismiss) private var dismiss @@ -128,31 +134,23 @@ } private nonisolated func sendReport(reportType: ReportType, reportURL: URL, reportDate: Date, via socket: NWSocket) async throws { - let fm = FileManager.default - guard let fileURLs = try? fm.contentsOfDirectory( - at: reportURL, - includingPropertiesForKeys: nil, - options: .skipsHiddenFiles - ) else { - throw ReportTransferError("Report is empty") - } - - var files: [ReportTransferFile] = [] - for fileURL in fileURLs { - guard let data = try? Data(contentsOf: fileURL) else { continue } - files.append(ReportTransferFile(name: fileURL.lastPathComponent, data: data)) - } - + let files = try collectFiles(in: reportURL) guard !files.isEmpty else { throw ReportTransferError("Report is empty") } - let payload = ReportTransferPayload( + let totalBytes = files.reduce(0) { $0 + $1.size } + let manifest = ReportTransferManifest( reportType: reportType, timestamp: reportDate.timeIntervalSince1970, - files: files + totalBytes: totalBytes, + files: files.map { ReportTransferManifestFile(name: $0.name, size: $0.size) } ) - try await socket.write(ReportTransferMessage.encodeReport(payload)) + try await socket.write(ReportTransferMessage.encodeReport(manifest)) + + for file in files { + try await streamFile(file, via: socket) + } try await socket.write(ReportTransferMessage.encodeComplete()) let response = try await socket.read() @@ -168,6 +166,40 @@ throw NWSocketError.connectionClosed } } + + private nonisolated func collectFiles(in reportURL: URL) throws -> [StreamedReportFile] { + let fm = FileManager.default + let fileURLs = try fm.contentsOfDirectory( + at: reportURL, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], + options: .skipsHiddenFiles + ) + var files: [StreamedReportFile] = [] + for fileURL in fileURLs.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { + let values = try fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + guard values.isRegularFile == true else { + continue + } + let size = UInt64(values.fileSize ?? 0) + files.append(StreamedReportFile(name: fileURL.lastPathComponent, fileURL: fileURL, size: size)) + } + return files + } + + private nonisolated func streamFile(_ file: StreamedReportFile, via socket: NWSocket) async throws { + let handle = try FileHandle(forReadingFrom: file.fileURL) + defer { try? handle.close() } + + var remaining = file.size + while remaining > 0 { + let chunkSize = Int(min(UInt64(ReportTransferService.fileChunkSize), remaining)) + guard let data = try handle.read(upToCount: chunkSize), !data.isEmpty else { + throw ReportTransferError("Failed to read report file") + } + try await socket.writeRaw(data) + remaining -= UInt64(data.count) + } + } } #endif From 7a57cfeb88e29ec35af7e300aa2150c787048a79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 20 Apr 2026 03:31:36 +0800 Subject: [PATCH 34/37] Minor fixes --- .../Views/Tools/CrashReportListView.swift | 6 +- .../Views/Tools/ExportReportView.swift | 2 +- Library/Network/ExtensionProvider.swift | 2 +- Localizable.xcstrings | 254 +++++++++++++++++- 4 files changed, 255 insertions(+), 9 deletions(-) diff --git a/ApplicationLibrary/Views/Tools/CrashReportListView.swift b/ApplicationLibrary/Views/Tools/CrashReportListView.swift index 20e0be3..7f5fdc0 100644 --- a/ApplicationLibrary/Views/Tools/CrashReportListView.swift +++ b/ApplicationLibrary/Views/Tools/CrashReportListView.swift @@ -77,7 +77,7 @@ public struct CrashReportListView: View { CrashTriggerView() } .toolbar { - if SharedPreferences.inDebug { + if Variant.inDebug { ToolbarItem(placement: .confirmationAction) { Button { showCrashTrigger = true @@ -101,9 +101,9 @@ public struct CrashReportListView: View { } #else .toolbar { - if !manager.reports.isEmpty || SharedPreferences.inDebug { + if !manager.reports.isEmpty || Variant.inDebug { Menu { - if SharedPreferences.inDebug { + if Variant.inDebug { Menu { Menu("Application") { Button("Go Crash") { diff --git a/ApplicationLibrary/Views/Tools/ExportReportView.swift b/ApplicationLibrary/Views/Tools/ExportReportView.swift index 27abef5..b78bc2b 100644 --- a/ApplicationLibrary/Views/Tools/ExportReportView.swift +++ b/ApplicationLibrary/Views/Tools/ExportReportView.swift @@ -5,7 +5,7 @@ import Network import SwiftUI - private struct StreamedReportFile: Sendable { + private struct StreamedReportFile { let name: String let fileURL: URL let size: UInt64 diff --git a/Library/Network/ExtensionProvider.swift b/Library/Network/ExtensionProvider.swift index ecdda75..0b1882a 100644 --- a/Library/Network/ExtensionProvider.swift +++ b/Library/Network/ExtensionProvider.swift @@ -150,7 +150,7 @@ open class ExtensionProvider: NEPacketTunnelProvider { options.tempPath = tempPath options.logMaxLines = 3000 - options.debug = SharedPreferences.inDebug + options.debug = Variant.inDebug options.crashReportSource = "NetworkExtension" #if os(tvOS) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index a39e81c..c86793c 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -599,7 +599,32 @@ } }, "Auth URL" : { - + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "لینک احراز هویت" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL авторизации" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "认证链接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "認證連結" + } + } + } }, "Authorize" : { "localizations" : { @@ -786,13 +811,41 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "测试版" + "value" : "Beta 版" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "測試版" + "value" : "Beta 版" + } + } + } + }, + "Beta Settings" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "تنظیمات بتا" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки беты" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beta 版设置" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beta 版設置" } } } @@ -6527,7 +6580,32 @@ } }, "Open Auth URL as QR Code" : { - + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "باز کردن لینک احراز هویت به‌صورت کد QR" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открыть URL авторизации как QR-код" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "以二维码打开认证链接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "以二維碼開啟認證連結" + } + } + } }, "Open Settings" : { "localizations" : { @@ -7281,6 +7359,34 @@ } } }, + "Reasserting" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "در حال اتصال مجدد" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переподключение" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重新连接中" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "重新連接中" + } + } + } + }, "Reboot required." : { "localizations" : { "fa" : { @@ -8591,6 +8697,62 @@ } } }, + "Started" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "شروع‌شده" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запущено" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已启动" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已啟動" + } + } + } + }, + "Starting" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "در حال شروع" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запуск" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启动中" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "啟動中" + } + } + } + }, "State" : { "localizations" : { "fa" : { @@ -8731,6 +8893,62 @@ } } }, + "Stopped" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "متوقف‌شده" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Остановлено" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已停止" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已停止" + } + } + } + }, + "Stopping" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "در حال توقف" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Остановка" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "停止中" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "停止中" + } + } + } + }, "STUN Server" : { "extractionState" : "stale", "localizations" : { @@ -9600,6 +9818,34 @@ } } }, + "Unknown" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ناشناخته" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неизвестно" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知" + } + } + } + }, "Unknown message type %u" : { "localizations" : { "fa" : { From 3ed2341355e32b4a04afd4693e04687751f2e86d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 22 Apr 2026 01:53:35 +0800 Subject: [PATCH 35/37] Improve oom-killer --- HelperService/RootHelperService.swift | 5 +++++ Library/Network/ExtensionProfile.swift | 29 ++++++++++++++++++++++++- Library/Network/ExtensionProvider.swift | 1 + Library/Network/RootHelperXPC.swift | 7 ++++++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/HelperService/RootHelperService.swift b/HelperService/RootHelperService.swift index e6c7265..05ff785 100644 --- a/HelperService/RootHelperService.swift +++ b/HelperService/RootHelperService.swift @@ -253,6 +253,11 @@ extension RootHelperService: RootHelperProtocol { reply(result, nil) } + func promoteOOMDraft(reply: @escaping (NSError?) -> Void) { + LibboxPromoteOOMDraftAt(WorkingDirectoryManager.extensionWorkingDirectoryPath) + reply(nil) + } + func triggerGoCrash(reply: @escaping (NSError?) -> Void) { reply(nil) LibboxTriggerGoPanic() diff --git a/Library/Network/ExtensionProfile.swift b/Library/Network/ExtensionProfile.swift index c09f66c..986fc7a 100644 --- a/Library/Network/ExtensionProfile.swift +++ b/Library/Network/ExtensionProfile.swift @@ -62,6 +62,9 @@ public class ExtensionProfile: ObservableObject { self.connection = connection self.status = connection.status self.connectedDate = connection.connectedDate + if connection.status == .disconnected { + Self.schedulePromoteOOMDraft() + } #if os(iOS) if #available(iOS 16.0, *) { if connection.status == .connected || connection.status == .disconnected { @@ -73,6 +76,26 @@ public class ExtensionProfile: ObservableObject { } } + private static func schedulePromoteOOMDraft() { + Task.detached { + try? await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC) + #if os(macOS) + if Variant.useSystemExtension { + guard HelperServiceManager.rootHelperStatus == .enabled else { + return + } + do { + try RootHelperClient.shared.promoteOOMDraft() + } catch { + logger.warning("promote OOM draft: \(error.localizedDescription)") + } + return + } + #endif + LibboxPromoteOOMDraft() + } + } + #if os(iOS) @available(iOS 16.0, *) private static func signalFileProviderChanges() { @@ -291,7 +314,11 @@ public class ExtensionProfile: ObservableObject { if managers.isEmpty { return nil } - return ExtensionProfile(managers[0]) + let profile = ExtensionProfile(managers[0]) + if profile.status == .disconnected { + schedulePromoteOOMDraft() + } + return profile } public static func install() async throws { diff --git a/Library/Network/ExtensionProvider.swift b/Library/Network/ExtensionProvider.swift index 0b1882a..38c1ff9 100644 --- a/Library/Network/ExtensionProvider.swift +++ b/Library/Network/ExtensionProvider.swift @@ -176,6 +176,7 @@ open class ExtensionProvider: NEPacketTunnelProvider { if let setupError { throw ExtensionStartupError("(packet-tunnel) error: setup service: \(setupError.localizedDescription)") } + LibboxPromoteOOMDraft() var error: NSError? commandServer = LibboxNewCommandServer(platformInterface, platformInterface, &error) diff --git a/Library/Network/RootHelperXPC.swift b/Library/Network/RootHelperXPC.swift index 43eb7fd..348d7ab 100644 --- a/Library/Network/RootHelperXPC.swift +++ b/Library/Network/RootHelperXPC.swift @@ -193,6 +193,7 @@ func registerMyInterface(name: String, reply: @escaping (NSError?) -> Void) func collectAllCrashArtifacts(reply: @escaping (CrashArtifactsResult?, NSError?) -> Void) func collectOOMReportArtifacts(reply: @escaping (OOMReportArtifactsResult?, NSError?) -> Void) + func promoteOOMDraft(reply: @escaping (NSError?) -> Void) func triggerGoCrash(reply: @escaping (NSError?) -> Void) func triggerNativeCrash(reply: @escaping (NSError?) -> Void) } @@ -441,6 +442,12 @@ } } + public func promoteOOMDraft() throws { + try performXPCCallVoid("promoteOOMDraft") { proxy, reply in + proxy.promoteOOMDraft(reply: reply) + } + } + public func triggerGoCrash() throws { try performXPCCallVoid("triggerGoCrash") { proxy, reply in proxy.triggerGoCrash(reply: reply) From 82a42ec40a72167e8bf1e01ee941352855bdd950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 23 Apr 2026 08:33:30 +0800 Subject: [PATCH 36/37] Make start button more conspicuous on iOS --- .../Views/Dashboard/Components/StartStopButton.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/ApplicationLibrary/Views/Dashboard/Components/StartStopButton.swift b/ApplicationLibrary/Views/Dashboard/Components/StartStopButton.swift index 334274e..f41efeb 100644 --- a/ApplicationLibrary/Views/Dashboard/Components/StartStopButton.swift +++ b/ApplicationLibrary/Views/Dashboard/Components/StartStopButton.swift @@ -63,6 +63,7 @@ public struct StartStopButton: View { if !profile.status.isConnected { Label("Start", systemImage: "play.fill") + .padding(.horizontal, 12) } else { Label("Stop", systemImage: "stop.fill") } From e21ab4646121749592f47732cac052117ceacc69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 23 Apr 2026 08:13:08 +0800 Subject: [PATCH 37/37] Bump version 1.14.0-alpha.16 --- sing-box.xcodeproj/project.pbxproj | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/sing-box.xcodeproj/project.pbxproj b/sing-box.xcodeproj/project.pbxproj index 6f5e7c6..fa027f8 100644 --- a/sing-box.xcodeproj/project.pbxproj +++ b/sing-box.xcodeproj/project.pbxproj @@ -2243,7 +2243,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.11"; + MARKETING_VERSION = "1.14.0"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; SDKROOT = appletvos; @@ -2277,7 +2277,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.11"; + MARKETING_VERSION = "1.14.0"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; SDKROOT = appletvos; @@ -2670,7 +2670,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.11"; + MARKETING_VERSION = "1.14.0"; OTHER_CODE_SIGN_FLAGS = "--deep"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2712,7 +2712,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "1.13.11"; + MARKETING_VERSION = "1.14.0"; OTHER_CODE_SIGN_FLAGS = "--deep"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2752,7 +2752,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.11"; + MARKETING_VERSION = "1.14.0"; OTHER_CODE_SIGN_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2791,7 +2791,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.11"; + MARKETING_VERSION = "1.14.0"; OTHER_CODE_SIGN_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_NAME = "sing-box"; @@ -2933,7 +2933,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.11"; + MARKETING_VERSION = "1.14.0-alpha.17"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.system; PRODUCT_NAME = "$(inherited)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2981,7 +2981,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.11"; + MARKETING_VERSION = "1.14.0-alpha.17"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.system; PRODUCT_NAME = "$(inherited)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3024,7 +3024,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.11"; + MARKETING_VERSION = "1.14.0-alpha.17"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.standalone; PRODUCT_NAME = SFM; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3066,7 +3066,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.13.11"; + MARKETING_VERSION = "1.14.0-alpha.17"; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.standalone; PRODUCT_NAME = SFM; PROVISIONING_PROFILE_SPECIFIER = "";