From 59317f3c79d9ababf9a5148eee07785f3d850d07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 5 Jan 2026 01:10:20 +0800 Subject: [PATCH] refactor: Fix macOS standalone application --- .../Service/ProfileServer.swift | 4 +- .../Service/UIProfileUpdateTask.swift | 2 +- .../Views/Abstract/AlertState.swift | 117 +------- .../Views/Abstract/FormItem.swift | 12 + .../Views/Abstract/GlobalChecksModifier.swift | 250 ++++++++++++++++++ .../Views/Dashboard/Cards/ProfileCard.swift | 13 +- .../Dashboard/Cards/ProfilePickerSheet.swift | 5 +- .../Views/Dashboard/DashboardView.swift | 68 +---- .../Views/Dashboard/DashboardViewModel.swift | 84 +----- .../Overview/OverviewViewModel.swift | 6 +- ApplicationLibrary/Views/Log/LogView.swift | 8 +- .../Views/Log/LogViewModel.swift | 6 +- .../Profile/EditProfileContentViewModel.swift | 4 +- .../Profile/ImportProfileViewModel.swift | 6 +- .../Views/Profile/NewProfileMenuView.swift | 10 +- .../Views/Profile/NewProfileViewModel.swift | 4 +- .../Views/Profile/QRSDisplayView.swift | 68 ++--- .../Views/Scanner/QRScannerView.swift | 5 +- .../Views/Setting/CoreView.swift | 104 ++++++-- .../Views/Setting/MacAppView.swift | 76 ++++++ .../Views/Setting/OnDemandRulesView.swift | 2 +- .../Views/Setting/PacketTunnelView.swift | 3 +- .../Views/Setting/ProfileOverrideView.swift | 6 +- .../Views/Setting/SettingView.swift | 85 +++++- FileProviderExtension/FileProviderItem.swift | 8 + HelperService/ConnectionOwnerLookup.swift | 191 +++++++++++++ HelperService/Info.plist | 28 ++ .../io.nekohasekai.sfavt.helper.plist | 26 ++ HelperService/RootHelper.entitlements | 12 + HelperService/RootHelperService.swift | 109 ++++++++ HelperService/WorkingDirectoryManager.swift | 42 +++ HelperService/main.swift | 5 + IntentsExtension/Intents.swift | 15 +- Library/Database/Profile+Date.swift | 1 + Library/Database/Profile+Transferable.swift | 91 +++---- Library/Database/Profile+Update.swift | 2 +- .../Database/SharedPreferences+Database.swift | 7 +- Library/Network/CommandClient.swift | 14 +- Library/Network/CommandXPC.swift | 215 +++++++++++++++ Library/Network/ExtensionEnvironments.swift | 135 +++++++++- .../Network/ExtensionPlatformInterface.swift | 89 +++++-- Library/Network/ExtensionProfile.swift | 107 ++++++-- Library/Network/ExtensionProvider.swift | 197 ++++++++++---- Library/Network/ExtensionStartOptions.swift | 19 ++ Library/Network/HelperServiceManager.swift | 25 ++ Library/Network/RootHelperXPC.swift | 221 ++++++++++++++++ Library/Network/SystemExtension.swift | 7 +- .../UserServiceEndpointPublisher.swift | 204 ++++++++++++++ .../Network/UserServiceEndpointRegistry.swift | 28 ++ Library/Network/UserServiceXPC.swift | 159 +++++++++++ Library/Network/XPCConnectionValidator.swift | 80 ++++++ Library/Shared/AppConfiguration.swift | 12 + Library/Shared/Logger+Extension.swift | 8 + Localizable.xcstrings | 33 +++ MacLibrary/MainView.swift | 28 +- MacLibrary/MainViewModel.swift | 11 +- MacLibrary/MenuView.swift | 6 +- Makefile | 2 +- SFI/MainView.swift | 9 +- SFM.System/Info.plist | 8 + SFM.System/SFM.entitlements | 8 +- .../StandaloneApplicationDelegate.swift | 3 + SFT/MainView.swift | 5 + SystemExtension/PacketTunnelProvider.swift | 28 +- SystemExtension/SystemExtension.entitlements | 4 +- sing-box.xcodeproj/project.pbxproj | 234 ++++++++++++++++ 66 files changed, 2799 insertions(+), 585 deletions(-) create mode 100644 ApplicationLibrary/Views/Abstract/GlobalChecksModifier.swift create mode 100644 HelperService/ConnectionOwnerLookup.swift create mode 100644 HelperService/Info.plist create mode 100644 HelperService/LaunchDaemons/io.nekohasekai.sfavt.helper.plist create mode 100644 HelperService/RootHelper.entitlements create mode 100644 HelperService/RootHelperService.swift create mode 100644 HelperService/WorkingDirectoryManager.swift create mode 100644 HelperService/main.swift create mode 100644 Library/Network/CommandXPC.swift create mode 100644 Library/Network/ExtensionStartOptions.swift create mode 100644 Library/Network/HelperServiceManager.swift create mode 100644 Library/Network/RootHelperXPC.swift create mode 100644 Library/Network/UserServiceEndpointPublisher.swift create mode 100644 Library/Network/UserServiceEndpointRegistry.swift create mode 100644 Library/Network/UserServiceXPC.swift create mode 100644 Library/Network/XPCConnectionValidator.swift create mode 100644 Library/Shared/Logger+Extension.swift diff --git a/ApplicationLibrary/Service/ProfileServer.swift b/ApplicationLibrary/Service/ProfileServer.swift index 2cd10d2..6d0242b 100644 --- a/ApplicationLibrary/Service/ProfileServer.swift +++ b/ApplicationLibrary/Service/ProfileServer.swift @@ -100,7 +100,7 @@ public class ProfileServer { try await processProfileContentRequest(data) } default: - throw NSError(domain: "unexpected message type \(messageType)", code: 0) + throw NSError(domain: "ProfileServer", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Unexpected message type \(messageType)")]) } } @@ -113,7 +113,7 @@ public class ProfileServer { let profile = try await ProfileManager.get(request!.profileID) guard let profile else { - throw NSError(domain: "profile not found", code: 0) + throw NSError(domain: "ProfileServer", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Profile not found")]) } let content = LibboxProfileContent() content.name = profile.name diff --git a/ApplicationLibrary/Service/UIProfileUpdateTask.swift b/ApplicationLibrary/Service/UIProfileUpdateTask.swift index 387a342..7bb0c56 100644 --- a/ApplicationLibrary/Service/UIProfileUpdateTask.swift +++ b/ApplicationLibrary/Service/UIProfileUpdateTask.swift @@ -19,7 +19,7 @@ import Library } } if !success { - throw NSError(domain: "register task failed", code: 0) + throw NSError(domain: "UIProfileUpdateTask", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Register task failed")]) } registered = true } diff --git a/ApplicationLibrary/Views/Abstract/AlertState.swift b/ApplicationLibrary/Views/Abstract/AlertState.swift index ab6e3df..ca63d9d 100644 --- a/ApplicationLibrary/Views/Abstract/AlertState.swift +++ b/ApplicationLibrary/Views/Abstract/AlertState.swift @@ -1,121 +1,8 @@ +import Library +@_exported import struct Library.AlertState import SwiftUI -public struct AlertState: Equatable { - public var title: String - public var message: String - public var primaryButton: ButtonState? - public var secondaryButton: ButtonState? - public var onDismiss: (() -> Void)? - - public struct ButtonState: Equatable { - public var label: String - public var role: ButtonRole? - public var action: (() -> Void)? - - public init(label: String, role: ButtonRole? = nil, action: (() -> Void)? = nil) { - self.label = label - self.role = role - self.action = action - } - - public static func == (lhs: ButtonState, rhs: ButtonState) -> Bool { - lhs.label == rhs.label && lhs.role == rhs.role - } - - public static func `default`(_ label: String, action: (() -> Void)? = nil) -> ButtonState { - ButtonState(label: label, action: action) - } - - public static func cancel(_ label: String = String(localized: "Cancel"), action: (() -> Void)? = nil) -> ButtonState { - ButtonState(label: label, role: .cancel, action: action) - } - - public static func destructive(_ label: String, action: (() -> Void)? = nil) -> ButtonState { - ButtonState(label: label, role: .destructive, action: action) - } - } - - public init(error: Error, dismiss: (() -> Void)? = nil) { - self.init(errorMessage: error.localizedDescription, dismiss: dismiss) - } - - public init(errorMessage: String, dismiss: (() -> Void)? = nil) { - title = String(localized: "Error") - message = errorMessage - primaryButton = .default(String(localized: "Ok"), action: dismiss) - secondaryButton = nil - onDismiss = nil - } - - public init(title: String, message: String, dismissButton: ButtonState? = nil) { - self.title = title - self.message = message - primaryButton = dismissButton ?? .default(String(localized: "Ok")) - secondaryButton = nil - onDismiss = nil - } - - public init(title: String, message: String, primaryButton: ButtonState, secondaryButton: ButtonState) { - self.title = title - self.message = message - self.primaryButton = primaryButton - self.secondaryButton = secondaryButton - onDismiss = nil - } - - public init(title: String, message: String, primaryButton: ButtonState, secondaryButton: ButtonState, onDismiss: @escaping () -> Void) { - self.title = title - self.message = message - self.primaryButton = primaryButton - self.secondaryButton = secondaryButton - self.onDismiss = onDismiss - } - - public static func == (lhs: AlertState, rhs: AlertState) -> Bool { - lhs.title == rhs.title && lhs.message == rhs.message && - lhs.primaryButton == rhs.primaryButton && lhs.secondaryButton == rhs.secondaryButton - } -} - public extension View { - @ViewBuilder - func alert(_ binding: Binding) -> some View { - alert( - binding.wrappedValue?.title ?? "", - isPresented: Binding( - get: { binding.wrappedValue != nil }, - set: { newValue, _ in - if !newValue { - binding.wrappedValue?.onDismiss?() - binding.wrappedValue = nil - } - } - ), - presenting: binding.wrappedValue - ) { alertState in - if let secondary = alertState.secondaryButton { - Button(role: alertState.primaryButton?.role) { - alertState.primaryButton?.action?() - } label: { - Text(alertState.primaryButton?.label ?? "Ok") - } - Button(role: secondary.role) { - secondary.action?() - } label: { - Text(secondary.label) - } - } else if let primary = alertState.primaryButton { - Button(role: primary.role) { - primary.action?() - } label: { - Text(primary.label) - } - } - } message: { alertState in - Text(alertState.message) - } - } - @ViewBuilder func alert(_ binding: Binding, isLoading: Binding) -> some View { alert( diff --git a/ApplicationLibrary/Views/Abstract/FormItem.swift b/ApplicationLibrary/Views/Abstract/FormItem.swift index 05b22ed..d4a7793 100644 --- a/ApplicationLibrary/Views/Abstract/FormItem.swift +++ b/ApplicationLibrary/Views/Abstract/FormItem.swift @@ -153,3 +153,15 @@ public func FormNavigationLink(@ViewBuilder destination: () -> some View, @ViewB }, label: label) #endif } + +#if os(macOS) + public func FormNavigationLink(value: some Hashable, @ViewBuilder label: () -> some View) -> some View { + NavigationLink(value: value, label: label) + } + + public extension View { + func formNavigationDestination(for data: D.Type, @ViewBuilder destination: @escaping (D) -> some View) -> some View { + navigationDestination(for: data, destination: destination) + } + } +#endif diff --git a/ApplicationLibrary/Views/Abstract/GlobalChecksModifier.swift b/ApplicationLibrary/Views/Abstract/GlobalChecksModifier.swift new file mode 100644 index 0000000..e36f98c --- /dev/null +++ b/ApplicationLibrary/Views/Abstract/GlobalChecksModifier.swift @@ -0,0 +1,250 @@ +import Libbox +import Library +import NetworkExtension +import SwiftUI +#if os(macOS) + import CoreLocation +#endif + +#if os(macOS) + @MainActor + private final class WIFIStateLocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { + private let manager = CLLocationManager() + var onAuthorizationGranted: (() -> Void)? + private var pendingAuthorizationRequest = false + + override init() { + super.init() + manager.delegate = self + } + + func requestAuthorizationAndShowWarning() { + let status = manager.authorizationStatus + switch status { + case .notDetermined: + pendingAuthorizationRequest = true + manager.requestAlwaysAuthorization() + case .authorized, .authorizedAlways: + onAuthorizationGranted?() + default: + break + } + } + + nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + Task { @MainActor in + guard self.pendingAuthorizationRequest else { return } + self.pendingAuthorizationRequest = false + let status = manager.authorizationStatus + if status == .authorized || status == .authorizedAlways { + self.onAuthorizationGranted?() + } + } + } + } +#endif + +@MainActor +public struct GlobalChecksModifier: ViewModifier { + @EnvironmentObject private var environments: ExtensionEnvironments + @Environment(\.importProfile) private var importProfile + @Environment(\.importRemoteProfile) private var importRemoteProfile + @Environment(\.selection) private var selection + + @State private var alert: AlertState? + @State private var notStarted = false + @Environment(\.openURL) private var openURL + + #if os(macOS) + @StateObject private var wifiLocationManager = WIFIStateLocationManager() + #endif + + public init() {} + + public func body(content: Content) -> some View { + contentView(content) + .alert($alert) + .onAppear { + handleImportProfile() + handleImportRemoteProfile() + } + .onChangeCompat(of: importProfile.wrappedValue) { _ in + handleImportProfile() + } + .onChangeCompat(of: importRemoteProfile.wrappedValue) { _ in + handleImportRemoteProfile() + } + .onChangeCompat(of: environments.extensionProfile?.status) { status in + handleStatusChange(status) + } + } + + @ViewBuilder + private func contentView(_ content: Content) -> some View { + #if os(macOS) + content + .onReceive(NotificationCenter.default.publisher(for: .extensionRequiresWIFIState)) { _ in + handleWiFiStateNotification() + } + .onReceive(NotificationCenter.default.publisher(for: .extensionRequiresHelperService)) { _ in + handleHelperServiceNotification() + } + #else + content + #endif + } + + private func handleImportProfile() { + guard let profile = importProfile.wrappedValue else { return } + importProfile.wrappedValue = nil + alert = AlertState( + title: String(localized: "Import Profile"), + message: String(localized: "Are you sure to import profile \(profile.name)?"), + primaryButton: .default(String(localized: "Import")) { [weak environments, selection] in + selection.wrappedValue = .dashboard + Task { + do { + try await profile.importProfile() + } catch { + await MainActor.run { + alert = AlertState(error: error) + } + return + } + environments?.profileUpdate.send() + } + }, + secondaryButton: .cancel() + ) + } + + private func handleImportRemoteProfile() { + guard let remoteProfile = importRemoteProfile.wrappedValue else { return } + importRemoteProfile.wrappedValue = nil + alert = AlertState( + title: String(localized: "Import Remote Profile"), + message: String(localized: "Are you sure to import remote profile \(remoteProfile.name)? You will connect to \(remoteProfile.host) to download the configuration."), + primaryButton: .default(String(localized: "Import")) { [weak environments, selection] in + selection.wrappedValue = .dashboard + environments?.pendingImportRemoteProfile = ImportRemoteProfileRequest(name: remoteProfile.name, url: remoteProfile.url) + }, + secondaryButton: .cancel() + ) + } + + private func handleStatusChange(_ status: NEVPNStatus?) { + guard let status else { return } + switch status { + case .connected: + notStarted = false + Task { + await checkDeprecatedNotes() + } + case .connecting: + notStarted = true + case .disconnected: + if #available(iOS 16.0, macOS 13.0, tvOS 17.0, *) { + if notStarted, let profile = environments.extensionProfile { + Task { + await checkLastDisconnectError(profile: profile) + } + } + } + notStarted = false + default: + break + } + } + + @available(iOS 16.0, macOS 13.0, tvOS 17.0, *) + private nonisolated func checkLastDisconnectError(profile: ExtensionProfile) async { + if let alertState = await profile.checkLastDisconnectError() { + await MainActor.run { + alert = alertState + } + } + } + + private nonisolated func checkDeprecatedNotes() async { + 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 { + await MainActor.run { + alert = AlertState(error: error) + } + } + } + + private func showNextDeprecatedNote(_ reports: any LibboxDeprecatedNoteIteratorProtocol) { + guard reports.hasNext() else { return } + + let report = reports.next()! + let continueChain: () -> Void = { + Task.detached { + try? await Task.sleep(nanoseconds: 300 * NSEC_PER_MSEC) + await MainActor.run { + showNextDeprecatedNote(reports) + } + } + } + + 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 + ) + } + } + + #if os(macOS) + private func handleWiFiStateNotification() { + guard Variant.useSystemExtension else { return } + wifiLocationManager.onAuthorizationGranted = { + alert = AlertState( + title: String(localized: "WiFi State Access"), + message: String(localized: "In the standalone version of SFM, reading WiFi state requires this app to be running. After you quit the SFM app, the sing-box service cannot continue to provide `wifi_ssid` and `wifi_bssid` routing rules.") + ) + } + wifiLocationManager.requestAuthorizationAndShowWarning() + } + + private func handleHelperServiceNotification() { + guard Variant.useSystemExtension, HelperServiceManager.rootHelperStatus != .enabled else { return } + alert = AlertState( + title: String(localized: "Helper Service Required"), + message: String(localized: "The sing-box service requires Helper Service to provide process lookup functionality, which supports `process_name` and `process_path` routing rules."), + primaryButton: .default(String(localized: "App Settings")) { + NotificationCenter.default.post(name: .navigateToSettingsPage, object: SettingsPage.app) + }, + secondaryButton: .cancel() + ) + } + #endif +} + +public extension View { + func globalChecks() -> some View { + modifier(GlobalChecksModifier()) + } +} diff --git a/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift index 44bc59a..f1119ad 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift @@ -495,10 +495,10 @@ extension ProfileCard { @Published var profileToEdit: Profile? @Published var shareItemType: ShareItemType? #if !os(tvOS) - @Published var profileExportDocument: ProfileExportDocument? - @Published var showProfileExporter = false - @Published var profileJSONExportDocument: ProfileJSONExportDocument? - @Published var showJSONExporter = false + @Published var profileExportDocument: ProfileExportDocument? + @Published var showProfileExporter = false + @Published var profileJSONExportDocument: ProfileJSONExportDocument? + @Published var showJSONExporter = false #endif #if os(macOS) var shareButtonView: NSView? @@ -511,10 +511,7 @@ extension ProfileCard { try await profile.updateRemoteProfile() environments.profileUpdate.send() } catch { - alert = AlertState( - title: String(localized: "Update Failed"), - message: error.localizedDescription - ) + alert = AlertState(error: error) } } } diff --git a/ApplicationLibrary/Views/Dashboard/Cards/ProfilePickerSheet.swift b/ApplicationLibrary/Views/Dashboard/Cards/ProfilePickerSheet.swift index e70a0d4..80d431f 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/ProfilePickerSheet.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/ProfilePickerSheet.swift @@ -402,10 +402,7 @@ struct ProfilePickerSheet: View { try await profile.origin.updateRemoteProfile() environments.profileUpdate.send() } catch { - alert = AlertState( - title: String(localized: "Update Failed"), - message: error.localizedDescription - ) + alert = AlertState(error: error) } } diff --git a/ApplicationLibrary/Views/Dashboard/DashboardView.swift b/ApplicationLibrary/Views/Dashboard/DashboardView.swift index dc2713e..ac24a3d 100644 --- a/ApplicationLibrary/Views/Dashboard/DashboardView.swift +++ b/ApplicationLibrary/Views/Dashboard/DashboardView.swift @@ -4,12 +4,8 @@ import SwiftUI @MainActor public struct DashboardView: View { - @Environment(\.openURL) private var openURL - @Environment(\.importProfile) private var importProfile - @Environment(\.importRemoteProfile) private var importRemoteProfile @EnvironmentObject private var environments: ExtensionEnvironments @StateObject private var coordinator = DashboardViewModel() - @State private var importRemoteProfileRequest: NewProfileView.ImportRequest? #if os(macOS) @Environment(\.controlActiveState) private var controlActiveState @@ -19,24 +15,16 @@ public struct DashboardView: View { public var body: some View { content + .alert($coordinator.alert) .onAppear { - coordinator.setOpenURL { openURL($0) } coordinator.setEnvironments(environments) #if os(macOS) Task { await coordinator.reload() } #endif - handleImportProfile() - handleImportRemoteProfile() - } - .onChangeCompat(of: importProfile.wrappedValue) { _ in - handleImportProfile() - } - .onChangeCompat(of: importRemoteProfile.wrappedValue) { _ in - handleImportRemoteProfile() } #if os(tvOS) - .navigationDestination(item: $importRemoteProfileRequest) { request in - NewProfileView(request) + .navigationDestination(item: $environments.pendingImportRemoteProfile) { request in + NewProfileView(.init(name: request.name, url: request.url)) .environmentObject(environments) .onDisappear { environments.profileUpdate.send() @@ -48,7 +36,7 @@ public struct DashboardView: View { } } #else - .sheet(item: $importRemoteProfileRequest) { request in + .sheet(item: $environments.pendingImportRemoteProfile) { request in importRemoteProfileSheet(for: request) } #endif @@ -60,48 +48,12 @@ public struct DashboardView: View { #endif } - private func handleImportProfile() { - if let profile = importProfile.wrappedValue { - importProfile.wrappedValue = nil - coordinator.alert = AlertState( - title: String(localized: "Import Profile"), - message: String(localized: "Are you sure to import profile \(profile.name)?"), - primaryButton: .default(String(localized: "Import")) { - Task { - do { - try await profile.importProfile() - } catch { - coordinator.alert = AlertState(error: error) - return - } - environments.profileUpdate.send() - } - }, - secondaryButton: .cancel() - ) - } - } - - private func handleImportRemoteProfile() { - if let remoteProfile = importRemoteProfile.wrappedValue { - importRemoteProfile.wrappedValue = nil - coordinator.alert = AlertState( - title: String(localized: "Import Remote Profile"), - message: String(localized: "Are you sure to import remote profile \(remoteProfile.name)? You will connect to \(remoteProfile.host) to download the configuration."), - primaryButton: .default(String(localized: "Import")) { - importRemoteProfileRequest = .init(name: remoteProfile.name, url: remoteProfile.url) - }, - secondaryButton: .cancel() - ) - } - } - @ViewBuilder - private func importRemoteProfileSheet(for request: NewProfileView.ImportRequest) -> some View { + private func importRemoteProfileSheet(for request: ImportRemoteProfileRequest) -> some View { NavigationSheet(title: "Import Profile", onDismiss: { environments.profileUpdate.send() }, content: { - NewProfileView(request) + NewProfileView(.init(name: request.name, url: request.url)) .environmentObject(environments) }) } @@ -132,9 +84,13 @@ public struct DashboardView: View { } else if let profile = environments.extensionProfile { activeDashboardView .environmentObject(profile) - .alert($coordinator.alert) .onChangeCompat(of: profile.status) { status in - coordinator.handleStatusChange(status, profile: profile) + #if os(macOS) + if Variant.useSystemExtension, status == .connected { + UserServiceEndpointPublisher.shared.refreshEndpointRegistration() + UserServiceEndpointPublisher.shared.checkExtensionRequirements() + } + #endif } } else { FormView { diff --git a/ApplicationLibrary/Views/Dashboard/DashboardViewModel.swift b/ApplicationLibrary/Views/Dashboard/DashboardViewModel.swift index 400713a..c23e87f 100644 --- a/ApplicationLibrary/Views/Dashboard/DashboardViewModel.swift +++ b/ApplicationLibrary/Views/Dashboard/DashboardViewModel.swift @@ -2,12 +2,15 @@ import Foundation import Libbox import Library import NetworkExtension +import os import SwiftUI #if os(macOS) import AppKit #endif +private let logger = Logger(category: "DashboardViewModel") + @MainActor public final class DashboardViewModel: BaseViewModel { @Published public var profileList: [ProfilePreview] = [] @@ -15,14 +18,12 @@ public final class DashboardViewModel: BaseViewModel { @Published public var selection = DashboardPage.overview @Published public var systemProxyAvailable = false @Published public var systemProxyEnabled = false - @Published public var notStarted = false #if os(macOS) @Published public var systemExtensionInstalled = true #endif private weak var environments: ExtensionEnvironments? - private var openURL: ((URL) -> Void)? public func setEnvironments(_ environments: ExtensionEnvironments) { self.environments = environments @@ -33,10 +34,6 @@ public final class DashboardViewModel: BaseViewModel { isLoading = true } - public func setOpenURL(_ openURL: @escaping (URL) -> Void) { - self.openURL = openURL - } - public func reload() async { #if os(macOS) if Variant.useSystemExtension { @@ -86,84 +83,13 @@ public final class DashboardViewModel: BaseViewModel { systemProxyAvailable = status.available systemProxyEnabled = status.enabled } catch { - NSLog("reloadSystemProxy: \(error)") + logger.debug("reloadSystemProxy: \(error)") } } public func updateSelectedProfile() async { selectedProfileID = await SharedPreferences.selectedProfileID.get() } - - public func handleStatusChange(_ status: NEVPNStatus, profile: ExtensionProfile) { - if status == .connected { - notStarted = false - Task { await checkDeprecatedNotes() } - } else if status == .connecting { - notStarted = true - } else if status == .disconnected { - if #available(iOS 16.0, macOS 13.0, tvOS 17.0, *) { - if notStarted { - Task { await checkLastDisconnectError(profile: profile) } - } - } - } - } - - nonisolated func checkDeprecatedNotes() async { - let disableWarnings = await SharedPreferences.disableDeprecatedWarnings.get() - guard !disableWarnings else { return } - - do { - let reports = try LibboxNewStandaloneCommandClient()!.getDeprecatedNotes() - if reports.hasNext() { - await MainActor.run { - loopShowDeprecateNotes(reports) - } - } - } catch { - NSLog("checkDeprecatedNotes: \(error)") - } - } - - private func loopShowDeprecateNotes(_ reports: any LibboxDeprecatedNoteIteratorProtocol) { - guard reports.hasNext() else { return } - - let report = reports.next()! - let continueChain: () -> Void = { [weak self] in - _ = Task.detached { - try? await Task.sleep(nanoseconds: 300 * NSEC_PER_MSEC) - await self?.loopShowDeprecateNotes(reports) - } - } - - if report.migrationLink.isEmpty { - alert = AlertState( - title: String(localized: "Deprecated Warning"), - message: report.message(), - dismissButton: .cancel(String(localized: "Ok")) - ) - alert?.onDismiss = continueChain - } else { - alert = AlertState( - title: String(localized: "Deprecated Warning"), - message: report.message(), - primaryButton: .default(String(localized: "Documentation")) { - self.openURL?(URL(string: report.migrationLink)!) - }, - secondaryButton: .cancel(String(localized: "Ok")), - onDismiss: continueChain - ) - } - } - - @available(iOS 16.0, macOS 13.0, tvOS 17.0, *) - nonisolated func checkLastDisconnectError(profile: ExtensionProfile) async { - if let alertState = await profile.checkLastDisconnectError() { - await MainActor.run { - alert = alertState - } - } - } } @available(iOS 16.0, macOS 13.0, tvOS 17.0, *) @@ -184,7 +110,7 @@ extension ExtensionProfile { ) } #endif - return AlertState(title: String(localized: "Service Error"), message: nsError.localizedDescription) + return AlertState(error: nsError) } } diff --git a/ApplicationLibrary/Views/Dashboard/Overview/OverviewViewModel.swift b/ApplicationLibrary/Views/Dashboard/Overview/OverviewViewModel.swift index 012122e..f857d90 100644 --- a/ApplicationLibrary/Views/Dashboard/Overview/OverviewViewModel.swift +++ b/ApplicationLibrary/Views/Dashboard/Overview/OverviewViewModel.swift @@ -13,7 +13,7 @@ public final class OverviewViewModel: BaseViewModel { if profile.status.isConnected { do { - try await serviceReload() + try await profile.reloadService() } catch { alert = AlertState(error: error) } @@ -21,10 +21,6 @@ public final class OverviewViewModel: BaseViewModel { reasserting = false } - public nonisolated func serviceReload() async throws { - try LibboxNewStandaloneCommandClient()!.serviceReload() - } - public nonisolated func setSystemProxyEnabled(_ enabled: Bool, profile: ExtensionProfile) async { do { await SharedPreferences.systemProxyEnabled.set(enabled) diff --git a/ApplicationLibrary/Views/Log/LogView.swift b/ApplicationLibrary/Views/Log/LogView.swift index df5eeb0..262528c 100644 --- a/ApplicationLibrary/Views/Log/LogView.swift +++ b/ApplicationLibrary/Views/Log/LogView.swift @@ -86,7 +86,9 @@ private struct LogViewContent: View { let button = UIButton(type: .system) let config = UIImage.SymbolConfiguration(scale: .large) button.setImage(UIImage(systemName: "line.3.horizontal.circle", withConfiguration: config), for: .normal) - button.tintColor = colorScheme == .dark ? .white : .black + if #available(iOS 17.0, *) { + button.tintColor = colorScheme == .dark ? .white : .black + } button.showsMenuAsPrimaryAction = true button.menu = createMenu() button.setContentHuggingPriority(.required, for: .horizontal) @@ -96,7 +98,9 @@ private struct LogViewContent: View { func updateUIView(_ uiView: UIButton, context _: Context) { uiView.menu = createMenu() - uiView.tintColor = colorScheme == .dark ? .white : .black + if #available(iOS 17.0, *) { + uiView.tintColor = colorScheme == .dark ? .white : .black + } } private func createMenu() -> UIMenu { diff --git a/ApplicationLibrary/Views/Log/LogViewModel.swift b/ApplicationLibrary/Views/Log/LogViewModel.swift index 2cb7aa9..b5331fb 100644 --- a/ApplicationLibrary/Views/Log/LogViewModel.swift +++ b/ApplicationLibrary/Views/Log/LogViewModel.swift @@ -86,9 +86,11 @@ public class LogDataModel: ObservableObject { lastProcessedLogCount = 0 lastEffectiveLevel = nil lastSearchText = "" + filteredLogs = [] + visibleLogs = [] + commandClient.clearLogs() Task.detached { - let client = LibboxNewStandaloneCommandClient() - try? client?.clearLogs() + try? LibboxNewStandaloneCommandClient()!.clearLogs() } } diff --git a/ApplicationLibrary/Views/Profile/EditProfileContentViewModel.swift b/ApplicationLibrary/Views/Profile/EditProfileContentViewModel.swift index 887091a..afb3fe1 100644 --- a/ApplicationLibrary/Views/Profile/EditProfileContentViewModel.swift +++ b/ApplicationLibrary/Views/Profile/EditProfileContentViewModel.swift @@ -39,10 +39,10 @@ public final class EditProfileContentViewModel: BaseViewModel { private nonisolated func loadContentBackground() async throws { guard let profileID else { - throw NSError(domain: "Context destroyed", code: 0) + throw NSError(domain: "EditProfileContentViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Context destroyed")]) } guard let profile = try await ProfileManager.get(profileID) else { - throw NSError(domain: "Profile missing", code: 0) + throw NSError(domain: "EditProfileContentViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Profile missing")]) } let profileContent = try profile.read() await MainActor.run { diff --git a/ApplicationLibrary/Views/Profile/ImportProfileViewModel.swift b/ApplicationLibrary/Views/Profile/ImportProfileViewModel.swift index 7c7d5d3..5978530 100644 --- a/ApplicationLibrary/Views/Profile/ImportProfileViewModel.swift +++ b/ApplicationLibrary/Views/Profile/ImportProfileViewModel.swift @@ -61,7 +61,7 @@ do { message = try socket.read() } catch { - throw NSError(domain: "read from connection: \(error.localizedDescription)", code: 0) + throw NSError(domain: "ImportProfileViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Read from connection: \(error.localizedDescription)")]) } var error: NSError? switch Int64(message[0]) { @@ -71,7 +71,7 @@ throw error } if let message { - throw NSError(domain: "remote error: \(message.message)", code: 0) + throw NSError(domain: "ImportProfileViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Remote error: \(message.message)")]) } case LibboxMessageTypeProfileList: let decoder = LibboxProfileDecoder() @@ -97,7 +97,7 @@ try await importProfile(content!, environments: environments) return default: - throw NSError(domain: "unknown message type \(message[0])", code: 0) + throw NSError(domain: "ImportProfileViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Unknown message type \(message[0])")]) } } } diff --git a/ApplicationLibrary/Views/Profile/NewProfileMenuView.swift b/ApplicationLibrary/Views/Profile/NewProfileMenuView.swift index a662825..d813449 100644 --- a/ApplicationLibrary/Views/Profile/NewProfileMenuView.swift +++ b/ApplicationLibrary/Views/Profile/NewProfileMenuView.swift @@ -243,17 +243,11 @@ public struct NewProfileMenuView: View { var error: NSError? let remoteProfile = LibboxParseRemoteProfileImportLink(string, &error) if let error { - alert = AlertState( - title: String(localized: "Invalid QR Code"), - message: error.localizedDescription - ) + alert = AlertState(error: error) return } guard let remoteProfile else { - alert = AlertState( - title: String(localized: "Invalid QR Code"), - message: String(localized: "The QR code does not contain a valid profile import link.") - ) + alert = AlertState(errorMessage: String(localized: "The QR code does not contain a valid profile import link.")) return } importRequest = NewProfileView.ImportRequest(name: remoteProfile.name, url: remoteProfile.url) diff --git a/ApplicationLibrary/Views/Profile/NewProfileViewModel.swift b/ApplicationLibrary/Views/Profile/NewProfileViewModel.swift index c47cba2..31584d6 100644 --- a/ApplicationLibrary/Views/Profile/NewProfileViewModel.swift +++ b/ApplicationLibrary/Views/Profile/NewProfileViewModel.swift @@ -111,10 +111,10 @@ public final class NewProfileViewModel: BaseViewModel { let profileConfig = profileConfigDirectory.appendingPathComponent("config_\(nextProfileID).json") if fileImport { guard let fileURL else { - throw NSError(domain: "Missing file", code: 0) + throw NSError(domain: "NewProfileViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Missing file")]) } if !fileURL.startAccessingSecurityScopedResource() { - throw NSError(domain: "Missing access to selected file", code: 0) + throw NSError(domain: "NewProfileViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Missing access to selected file")]) } defer { fileURL.stopAccessingSecurityScopedResource() diff --git a/ApplicationLibrary/Views/Profile/QRSDisplayView.swift b/ApplicationLibrary/Views/Profile/QRSDisplayView.swift index 3202feb..2140824 100644 --- a/ApplicationLibrary/Views/Profile/QRSDisplayView.swift +++ b/ApplicationLibrary/Views/Profile/QRSDisplayView.swift @@ -48,54 +48,54 @@ public struct QRSDisplayView: View { Text(String(localized: "FPS")) Spacer() #if os(tvOS) - Button { - fps = max(1, fps - 1) - } label: { - Image(systemName: "minus") - } - Text(verbatim: "\(Int(fps))") - .foregroundStyle(.secondary) - .frame(minWidth: 50) - Button { - fps = min(60, fps + 1) - } label: { - Image(systemName: "plus") - } + Button { + fps = max(1, fps - 1) + } label: { + Image(systemName: "minus") + } + Text(verbatim: "\(Int(fps))") + .foregroundStyle(.secondary) + .frame(minWidth: 50) + Button { + fps = min(60, fps + 1) + } label: { + Image(systemName: "plus") + } #else - Text(verbatim: "\(Int(fps))") - .foregroundStyle(.secondary) + Text(verbatim: "\(Int(fps))") + .foregroundStyle(.secondary) #endif } #if !os(tvOS) - Slider(value: $fps, in: 1 ... 60, step: 1) + Slider(value: $fps, in: 1 ... 60, step: 1) #endif HStack { Text(String(localized: "Slice Size")) Spacer() #if os(tvOS) - Button { - sliceSize = max(100, sliceSize - 100) - } label: { - Image(systemName: "minus") - } - Text("\(Int(sliceSize))") - .foregroundStyle(.secondary) - .frame(minWidth: 50) - Button { - sliceSize = min(1500, sliceSize + 100) - } label: { - Image(systemName: "plus") - } + Button { + sliceSize = max(100, sliceSize - 100) + } label: { + Image(systemName: "minus") + } + Text("\(Int(sliceSize))") + .foregroundStyle(.secondary) + .frame(minWidth: 50) + Button { + sliceSize = min(1500, sliceSize + 100) + } label: { + Image(systemName: "plus") + } #else - Text("\(Int(sliceSize))") - .foregroundStyle(.secondary) + Text("\(Int(sliceSize))") + .foregroundStyle(.secondary) #endif } #if !os(tvOS) - Slider(value: $sliceSize, in: 100 ... 1500, step: 100) + Slider(value: $sliceSize, in: 100 ... 1500, step: 100) #endif } .padding(.horizontal) @@ -130,9 +130,9 @@ public struct QRSDisplayView: View { .padding(.horizontal) } #if os(macOS) - .padding() + .padding() #else - .padding([.horizontal, .bottom]) + .padding([.horizontal, .bottom]) #endif .onAppear { setupGenerator() diff --git a/ApplicationLibrary/Views/Scanner/QRScannerView.swift b/ApplicationLibrary/Views/Scanner/QRScannerView.swift index a1439cd..36c5fc8 100644 --- a/ApplicationLibrary/Views/Scanner/QRScannerView.swift +++ b/ApplicationLibrary/Views/Scanner/QRScannerView.swift @@ -145,10 +145,7 @@ message: String(localized: "Please enable camera access in Settings to scan QR codes.") ) default: - alert = AlertState( - title: String(localized: "Scanner Error"), - message: error.localizedDescription - ) + alert = AlertState(error: error) } } } diff --git a/ApplicationLibrary/Views/Setting/CoreView.swift b/ApplicationLibrary/Views/Setting/CoreView.swift index 1c54601..74875a0 100644 --- a/ApplicationLibrary/Views/Setting/CoreView.swift +++ b/ApplicationLibrary/Views/Setting/CoreView.swift @@ -4,6 +4,8 @@ import SwiftUI #if os(iOS) import FileProvider import UIKit +#elseif os(macOS) + import ServiceManagement #endif @MainActor @@ -15,7 +17,11 @@ public struct CoreView: View { @State private var disableDeprecatedWarnings = false @State private var version = "" - @State private var dataSize = "" + @State private var dataSize: String? + + #if os(macOS) + @State private var helperUnavailable = false + #endif public init() {} public var body: some View { @@ -29,7 +35,21 @@ public struct CoreView: View { } else { FormView { FormTextItem("Version", version) - FormTextItem("Data Size", dataSize) + if let dataSize { + FormTextItem("Data Size", dataSize) + } else { + #if os(macOS) + HStack { + Text("Data Size") + Spacer() + Text("Unavailable") + .foregroundStyle(.red) + .onTapGesture { + alert = helperRequiredAlert() + } + } + #endif + } if Variant.isBeta { Section {} @@ -40,10 +60,12 @@ public struct CoreView: View { Section("Working Directory") { #if os(macOS) - FormButton { - NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: FilePath.workingDirectory.relativePath) - } label: { - Label("Open", systemImage: "macwindow.and.cursorarrow") + if !Variant.useSystemExtension { + FormButton { + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: FilePath.workingDirectory.relativePath) + } label: { + Label("Open", systemImage: "macwindow.and.cursorarrow") + } } #elseif os(iOS) if #available(iOS 16.0, *) { @@ -85,7 +107,11 @@ public struct CoreView: View { } else { await MainActor.run { version = LibboxVersion() - dataSize = "Loading..." + #if os(macOS) + if Variant.useSystemExtension { + helperUnavailable = HelperServiceManager.rootHelperStatus != .enabled + } + #endif isLoading = false } await loadSettingsBackground() @@ -94,7 +120,20 @@ public struct CoreView: View { private nonisolated func loadSettingsBackground() async { let disableDeprecatedWarnings = await SharedPreferences.disableDeprecatedWarnings.get() - let dataSize = (try? FilePath.workingDirectory.formattedSize()) ?? "Unknown" + let dataSize: String? + #if os(macOS) + if Variant.useSystemExtension { + if let size = try? RootHelperClient.shared.getWorkingDirectorySize() { + dataSize = LibboxFormatBytes(size) + } else { + dataSize = nil + } + } else { + dataSize = (try? FilePath.workingDirectory.formattedSize()) ?? "Unknown" + } + #else + dataSize = (try? FilePath.workingDirectory.formattedSize()) ?? "Unknown" + #endif await MainActor.run { self.disableDeprecatedWarnings = disableDeprecatedWarnings self.dataSize = dataSize @@ -102,6 +141,12 @@ public struct CoreView: View { } private func confirmDestroyWorkingDirectory() async { + #if os(macOS) + if helperUnavailable { + alert = helperRequiredAlert() + return + } + #endif if environments.extensionProfile?.status.isConnected == true { alert = AlertState( title: String(localized: "Service is Running"), @@ -119,17 +164,44 @@ public struct CoreView: View { } private func stopServiceAndDestroy() async { - try? await environments.extensionProfile?.stop() - await destroyWorkingDirectory() - } - - private nonisolated func destroyWorkingDirectory() async { - try? FileManager.default.removeItem(at: FilePath.workingDirectory) - await MainActor.run { - isLoading = true + do { + try await environments.extensionProfile!.stop() + await destroyWorkingDirectory() + } catch { + alert = AlertState(error: error) } } + private func destroyWorkingDirectory() async { + do { + #if os(macOS) + if Variant.useSystemExtension { + try RootHelperClient.shared.cleanWorkingDirectory() + } else { + try FileManager.default.removeItem(at: FilePath.workingDirectory) + } + #else + try FileManager.default.removeItem(at: FilePath.workingDirectory) + #endif + isLoading = true + } catch { + alert = AlertState(error: error) + } + } + + #if os(macOS) + private func helperRequiredAlert() -> AlertState { + AlertState( + title: String(localized: "Helper Service Required"), + message: String(localized: "Managing working directory requires Helper Service."), + primaryButton: .default(String(localized: "App Settings")) { + NotificationCenter.default.post(name: .navigateToSettingsPage, object: SettingsPage.app) + }, + secondaryButton: .cancel(String(localized: "Ok")) + ) + } + #endif + #if os(iOS) @available(iOS 16.0, *) private nonisolated func openInFilesApp() async { diff --git a/ApplicationLibrary/Views/Setting/MacAppView.swift b/ApplicationLibrary/Views/Setting/MacAppView.swift index ff8685d..ddc8513 100644 --- a/ApplicationLibrary/Views/Setting/MacAppView.swift +++ b/ApplicationLibrary/Views/Setting/MacAppView.swift @@ -12,6 +12,11 @@ public struct AppView: View { @State private var startAtLogin = false @Environment(\.showMenuBarExtra) private var showMenuBarExtra @State private var menuBarExtraInBackground = false + #if os(macOS) + + @State private var rootHelperRegistrationStatus: SMAppService.Status = .notRegistered + + #endif @State private var alert: AlertState? @@ -67,6 +72,48 @@ public struct AppView: View { Label("Uninstall", systemImage: "trash.fill").foregroundColor(.red) } } + + Section { + if rootHelperRegistrationStatus == .enabled { + FormButton { + performHelperAction { + try HelperServiceManager.unregisterRootHelper() + try HelperServiceManager.registerRootHelper() + } + } label: { + Label("Update", systemImage: "arrow.down.doc.fill") + } + FormButton(role: .destructive) { + performHelperAction { + try HelperServiceManager.unregisterRootHelper() + } + } label: { + Label("Uninstall", systemImage: "trash.fill").foregroundColor(.red) + } + } else if rootHelperRegistrationStatus == .requiresApproval { + FormButton { + openHelperSettings() + } label: { + Label("Enable", systemImage: "switch.2") + } + } else { + FormButton { + performHelperAction { + try HelperServiceManager.registerRootHelper() + } + } label: { + Label("Install", systemImage: "square.and.arrow.down.fill") + } + } + } header: { + VStack(alignment: .leading, spacing: 4) { + Text("Helper Service") + Text("This helper service provides process lookup for `process_name` and `process_path` routing rules, and manages the working directory.") + .font(.caption) + .foregroundStyle(.secondary) + .textCase(nil) + } + } } #endif } @@ -83,6 +130,9 @@ public struct AppView: View { #if os(macOS) startAtLogin = SMAppService.mainApp.status == .enabled menuBarExtraInBackground = await SharedPreferences.menuBarExtraInBackground.get() + if Variant.useSystemExtension { + refreshHelperStatus() + } #endif isLoading = false } @@ -147,5 +197,31 @@ public struct AppView: View { } } + private func performHelperAction(_ action: () throws -> Void) { + do { + try action() + refreshHelperStatus() + } catch { + alert = AlertState(error: error) + } + } + + private func refreshHelperStatus() { + rootHelperRegistrationStatus = HelperServiceManager.rootHelperStatus + } + + private func openHelperSettings() { + if #available(macOS 13.0, *) { + SMAppService.openSystemSettingsLoginItems() + return + } + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.users?LoginItems"), + NSWorkspace.shared.open(url) + { + return + } + NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/System Preferences.app")) + } + #endif } diff --git a/ApplicationLibrary/Views/Setting/OnDemandRulesView.swift b/ApplicationLibrary/Views/Setting/OnDemandRulesView.swift index 978bd1d..51213b8 100644 --- a/ApplicationLibrary/Views/Setting/OnDemandRulesView.swift +++ b/ApplicationLibrary/Views/Setting/OnDemandRulesView.swift @@ -296,7 +296,7 @@ public struct OnDemandRulesView: View { await SharedPreferences.onDemandRules.set(rules) let savedRules = await SharedPreferences.onDemandRules.get() if savedRules != rules { - alert = AlertState(errorMessage: "Failed to save rules") + alert = AlertState(errorMessage: String(localized: "Failed to save rules")) return } await updateService() diff --git a/ApplicationLibrary/Views/Setting/PacketTunnelView.swift b/ApplicationLibrary/Views/Setting/PacketTunnelView.swift index 43d909f..1f78293 100644 --- a/ApplicationLibrary/Views/Setting/PacketTunnelView.swift +++ b/ApplicationLibrary/Views/Setting/PacketTunnelView.swift @@ -19,7 +19,7 @@ struct PacketTunnelView: View { Group { if isLoading { ProgressView().onAppear { - Task.detached { + Task { await loadSettings() } } @@ -115,6 +115,7 @@ struct PacketTunnelView: View { } } + @MainActor private func loadSettings() async { ignoreMemoryLimit = await SharedPreferences.ignoreMemoryLimit.get() #if !os(tvOS) diff --git a/ApplicationLibrary/Views/Setting/ProfileOverrideView.swift b/ApplicationLibrary/Views/Setting/ProfileOverrideView.swift index b353cc0..8d8ca25 100644 --- a/ApplicationLibrary/Views/Setting/ProfileOverrideView.swift +++ b/ApplicationLibrary/Views/Setting/ProfileOverrideView.swift @@ -1,4 +1,3 @@ -import Libbox import Library import SwiftUI @@ -15,7 +14,7 @@ public struct ProfileOverrideView: View { Group { if isLoading { ProgressView().onAppear { - Task.detached { + Task { await loadSettings() } } @@ -64,12 +63,13 @@ public struct ProfileOverrideView: View { return } do { - try LibboxNewStandaloneCommandClient()?.serviceReload() + try await profile.reloadService() } catch { alert = AlertState(error: error) } } + @MainActor private func loadSettings() async { excludeDefaultRoute = await SharedPreferences.excludeDefaultRoute.get() autoRouteUseSubRangesByDefault = await SharedPreferences.autoRouteUseSubRangesByDefault.get() diff --git a/ApplicationLibrary/Views/Setting/SettingView.swift b/ApplicationLibrary/Views/Setting/SettingView.swift index dffa4ac..663a8a2 100644 --- a/ApplicationLibrary/Views/Setting/SettingView.swift +++ b/ApplicationLibrary/Views/Setting/SettingView.swift @@ -1,6 +1,26 @@ import Library import SwiftUI +#if os(macOS) + private struct SettingsNavigationPathKey: EnvironmentKey { + static let defaultValue: Binding? = nil + } + + public extension EnvironmentValues { + var settingsNavigationPath: Binding? { + get { self[SettingsNavigationPathKey.self] } + set { self[SettingsNavigationPathKey.self] = newValue } + } + } +#endif + +#if os(macOS) + public enum SettingsPage: Hashable { + case app + case core, packetTunnel, onDemandRules, profileOverride, sponsors + } +#endif + public struct SettingView: View { private enum Tabs: Int, CaseIterable, Identifiable { var id: Self { @@ -13,6 +33,25 @@ public struct SettingView: View { case core, packetTunnel, onDemandRules, profileOverride, sponsors + #if os(macOS) + var page: SettingsPage { + switch self { + case .app: + return .app + case .core: + return .core + case .packetTunnel: + return .packetTunnel + case .onDemandRules: + return .onDemandRules + case .profileOverride: + return .profileOverride + case .sponsors: + return .sponsors + } + } + #endif + var label: some View { Label(title, systemImage: iconImage) } @@ -83,16 +122,45 @@ public struct SettingView: View { @MainActor var navigationLink: some View { - FormNavigationLink { - contentView - } label: { - label - } + #if os(macOS) + FormNavigationLink(value: page) { + label + } + #else + FormNavigationLink { + contentView + } label: { + label + } + #endif } } - @StateObject private var viewModel = SettingViewModel() + #if os(macOS) + @MainActor + @ViewBuilder + private static func destinationView(for page: SettingsPage) -> some View { + Group { + switch page { + case .app: + AppView() + case .core: + CoreView() + case .packetTunnel: + PacketTunnelView() + case .onDemandRules: + OnDemandRulesView() + case .profileOverride: + ProfileOverrideView() + case .sponsors: + SponsorsView() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + #endif + @StateObject private var viewModel = SettingViewModel() public init() {} public var body: some View { FormView { @@ -159,5 +227,10 @@ public struct SettingView: View { } } } + #if os(macOS) + .formNavigationDestination(for: SettingsPage.self) { page in + Self.destinationView(for: page) + } + #endif } } diff --git a/FileProviderExtension/FileProviderItem.swift b/FileProviderExtension/FileProviderItem.swift index 3ffaeb6..6bf5d29 100644 --- a/FileProviderExtension/FileProviderItem.swift +++ b/FileProviderExtension/FileProviderItem.swift @@ -93,6 +93,14 @@ class FileProviderItem: NSObject, NSFileProviderItem { return NSNumber(value: contents?.count ?? 0) } + // MARK: - Local File Status + + var isUploaded: Bool { true } + var isUploading: Bool { false } + var isDownloaded: Bool { true } + var isDownloading: Bool { false } + var isMostRecentVersionDownloaded: Bool { true } + private var isDirectory: Bool { var isDir: ObjCBool = false FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir) diff --git a/HelperService/ConnectionOwnerLookup.swift b/HelperService/ConnectionOwnerLookup.swift new file mode 100644 index 0000000..c894f2c --- /dev/null +++ b/HelperService/ConnectionOwnerLookup.swift @@ -0,0 +1,191 @@ +import Darwin +import Foundation +import os + +private let PROC_PIDPATHINFO_MAXSIZE: Int32 = 4096 +private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ConnectionOwnerLookup") + +enum ConnectionOwnerLookup { + struct Result { + let userId: Int32 + let userName: String + let processPath: String + } + + static func find( + ipProtocol: Int32, + sourceAddress: String, + sourcePort: Int32, + 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") + 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 + } +} diff --git a/HelperService/Info.plist b/HelperService/Info.plist new file mode 100644 index 0000000..304f178 --- /dev/null +++ b/HelperService/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + BasePackageIdentifier + $(BASE_PACKAGE_IDENTIFIER) + AppGroupIdentifier + $(APP_GROUP_IDENTIFIER) + + diff --git a/HelperService/LaunchDaemons/io.nekohasekai.sfavt.helper.plist b/HelperService/LaunchDaemons/io.nekohasekai.sfavt.helper.plist new file mode 100644 index 0000000..7654be6 --- /dev/null +++ b/HelperService/LaunchDaemons/io.nekohasekai.sfavt.helper.plist @@ -0,0 +1,26 @@ + + + + + Label + io.nekohasekai.sfavt.helper + BundleProgram + Contents/Helpers/RootHelper + MachServices + + 287TTNZF8L.io.nekohasekai.sfavt.helper + + + AssociatedBundleIdentifiers + + io.nekohasekai.sfavt.standalone + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + + diff --git a/HelperService/RootHelper.entitlements b/HelperService/RootHelper.entitlements new file mode 100644 index 0000000..11277e3 --- /dev/null +++ b/HelperService/RootHelper.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + $(TeamIdentifierPrefix)$(BASE_PACKAGE_IDENTIFIER) + + + diff --git a/HelperService/RootHelperService.swift b/HelperService/RootHelperService.swift new file mode 100644 index 0000000..39c6622 --- /dev/null +++ b/HelperService/RootHelperService.swift @@ -0,0 +1,109 @@ +import Foundation +import Library +import os + +private let logger = Logger(category: "RootHelper") + +class RootHelperService: NSObject { + private var listener: NSXPCListener? + + func start() { + setupLogging() + startXPCListener() + } + + private func setupLogging() { + let basePath = "/var/log/sing-box" + try? FileManager.default.createDirectory(atPath: basePath, withIntermediateDirectories: true) + + let logPath = basePath + "/roothelper.log" + freopen(logPath, "a", stderr) + } + + private func startXPCListener() { + let machServiceName = getMachServiceName() + listener = NSXPCListener(machServiceName: machServiceName) + listener?.delegate = self + listener?.resume() + } + + private func getMachServiceName() -> String { + if let identifier = Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String { + return "\(identifier).helper" + } + fatalError("Missing AppGroupIdentifier in Info.plist") + } +} + +extension RootHelperService: NSXPCListenerDelegate { + func listener(_: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool { + let allowedBundleIDs = [ + AppConfiguration.systemExtensionBundleID, + AppConfiguration.packageName + ".standalone", + ] + guard XPCConnectionValidator.validateConnection( + newConnection, + teamID: AppConfiguration.teamID, + allowedBundleIDs: allowedBundleIDs + ) else { + let info = XPCConnectionValidator.getConnectionInfo(newConnection) + logger.warning("Rejected XPC connection: pid=\(info.pid), bundleID=\(info.bundleID ?? "unknown"), teamID=\(info.teamID ?? "unknown")") + return false + } + + let exportedInterface = NSXPCInterface(with: RootHelperProtocol.self) + RootHelperXPC.configureInterface(exportedInterface) + newConnection.exportedInterface = exportedInterface + newConnection.exportedObject = self + newConnection.resume() + return true + } +} + +extension RootHelperService: RootHelperProtocol { + func findConnectionOwner( + ipProtocol: Int32, + sourceAddress: String, + sourcePort: Int32, + destinationAddress: String, + destinationPort: Int32, + reply: @escaping (ConnectionOwnerResult?, NSError?) -> Void + ) { + guard let result = ConnectionOwnerLookup.find( + ipProtocol: ipProtocol, + sourceAddress: sourceAddress, + sourcePort: sourcePort, + destinationAddress: destinationAddress, + destinationPort: destinationPort + ) else { + let error = NSError(domain: "RootHelper", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "Connection owner not found", + ]) + logger.error("findConnectionOwner: \(error.localizedDescription)") + reply(nil, error) + return + } + + let ownerResult = ConnectionOwnerResult( + userId: result.userId, + userName: result.userName, + processPath: result.processPath + ) + reply(ownerResult, nil) + } + + func getWorkingDirectorySize(reply: @escaping (Int64, NSError?) -> Void) { + let size = WorkingDirectoryManager.getSize() + reply(size, nil) + } + + func cleanWorkingDirectory(reply: @escaping (NSError?) -> Void) { + do { + try WorkingDirectoryManager.clean() + reply(nil) + } catch { + logger.error("cleanWorkingDirectory error: \(error.localizedDescription)") + reply(error as NSError) + } + } +} diff --git a/HelperService/WorkingDirectoryManager.swift b/HelperService/WorkingDirectoryManager.swift new file mode 100644 index 0000000..cc1fd09 --- /dev/null +++ b/HelperService/WorkingDirectoryManager.swift @@ -0,0 +1,42 @@ +import Foundation +import Library + +enum WorkingDirectoryManager { + private static var workingDirectoryPath: String { + "/var/root/Library/Containers/\(AppConfiguration.systemExtensionBundleID)/Data/Working" + } + + static func getSize() -> Int64 { + let path = workingDirectoryPath + guard FileManager.default.fileExists(atPath: path) else { + return 0 + } + + var totalSize: Int64 = 0 + let enumerator = FileManager.default.enumerator(atPath: path) + + while let file = enumerator?.nextObject() as? String { + let filePath = (path as NSString).appendingPathComponent(file) + if let attrs = try? FileManager.default.attributesOfItem(atPath: filePath), + let size = attrs[.size] as? Int64 + { + totalSize += size + } + } + + return totalSize + } + + static func clean() throws { + let path = workingDirectoryPath + guard FileManager.default.fileExists(atPath: path) else { + return + } + + let contents = try FileManager.default.contentsOfDirectory(atPath: path) + for item in contents { + let itemPath = (path as NSString).appendingPathComponent(item) + try FileManager.default.removeItem(atPath: itemPath) + } + } +} diff --git a/HelperService/main.swift b/HelperService/main.swift new file mode 100644 index 0000000..5ae3b8b --- /dev/null +++ b/HelperService/main.swift @@ -0,0 +1,5 @@ +import Foundation + +let service = RootHelperService() +service.start() +dispatchMain() diff --git a/IntentsExtension/Intents.swift b/IntentsExtension/Intents.swift index 0ec7bb3..248b8aa 100644 --- a/IntentsExtension/Intents.swift +++ b/IntentsExtension/Intents.swift @@ -1,6 +1,5 @@ import AppIntents import Foundation -import Libbox import Library struct StartServiceIntent: AppIntent { @@ -18,7 +17,7 @@ struct StartServiceIntent: AppIntent { func perform() async throws -> some IntentResult & ProvidesDialog { guard let extensionProfile = try await (ExtensionProfile.load()) else { - throw NSError(domain: "NetworkExtension not installed", code: 0) + throw NSError(domain: "IntentsExtension", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "NetworkExtension not installed")]) } let profileList = try await ProfileManager.list() let specifiedProfile = profileList.first { $0.name == profile } @@ -30,13 +29,13 @@ struct StartServiceIntent: AppIntent { profileChanged = true } } else if profile != "default" { - throw NSError(domain: "Specified profile not found: \(profile)", code: 0) + throw NSError(domain: "IntentsExtension", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Specified profile not found: \(profile)")]) } if await extensionProfile.status == .connected { if !profileChanged { return .result(dialog: "Service is already running") } - try LibboxNewStandaloneCommandClient()!.serviceReload() + try await extensionProfile.reloadService() } else if await extensionProfile.status.isConnected { try await extensionProfile.restart() } else { @@ -61,7 +60,7 @@ struct RestartServiceIntent: AppIntent { return .result(dialog: "Service is not installed") } if await extensionProfile.status == .connected { - try LibboxNewStandaloneCommandClient()!.serviceReload() + try await extensionProfile.reloadService() } else if await extensionProfile.status.isConnected { try await extensionProfile.restart() } else { @@ -145,7 +144,7 @@ struct GetCurrentProfile: AppIntent { func perform() async throws -> some IntentResult & ReturnsValue { guard let profile = try await ProfileManager.get(SharedPreferences.selectedProfileID.get()) else { - throw NSError(domain: "No profile selected", code: 0) + throw NSError(domain: "IntentsExtension", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "No profile selected")]) } return .result(value: profile.name) } @@ -167,10 +166,10 @@ struct UpdateProfileIntent: AppIntent { init() {} func perform() async throws -> some IntentResult & ProvidesDialog { guard let profile = try await ProfileManager.get(by: profile) else { - throw NSError(domain: "Specified profile not found: \(profile)", code: 0) + throw NSError(domain: "IntentsExtension", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Specified profile not found: \(profile)")]) } if profile.type != .remote { - throw NSError(domain: "Specified profile is not a remote profile", code: 0) + throw NSError(domain: "IntentsExtension", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Specified profile is not a remote profile")]) } try await profile.updateRemoteProfile() return .result(dialog: "Profile updated") diff --git a/Library/Database/Profile+Date.swift b/Library/Database/Profile+Date.swift index 866370d..4a661b9 100644 --- a/Library/Database/Profile+Date.swift +++ b/Library/Database/Profile+Date.swift @@ -10,6 +10,7 @@ public extension Date { var relativeFormat: String { let formatter = RelativeDateTimeFormatter() formatter.unitsStyle = .full + formatter.dateTimeStyle = .named return formatter.localizedString(for: self, relativeTo: Date()) } } diff --git a/Library/Database/Profile+Transferable.swift b/Library/Database/Profile+Transferable.swift index 7e6c140..b9cd5b6 100644 --- a/Library/Database/Profile+Transferable.swift +++ b/Library/Database/Profile+Transferable.swift @@ -119,61 +119,62 @@ public extension UTType { } #if !os(tvOS) -// MARK: - FileDocument for Export -public struct ProfileExportDocument: FileDocument { - public static var readableContentTypes: [UTType] { [.profile] } + // MARK: - FileDocument for Export - public let data: Data - public let filename: String + public struct ProfileExportDocument: FileDocument { + public static var readableContentTypes: [UTType] { [.profile] } - public init(content: LibboxProfileContent) throws { - guard let encoded = content.encode() else { - throw NSError(domain: "ProfileExportDocument", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to encode profile"]) + public let data: Data + public let filename: String + + public init(content: LibboxProfileContent) throws { + guard let encoded = content.encode() else { + throw NSError(domain: "ProfileExportDocument", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to encode profile"]) + } + data = encoded + filename = "\(content.name).bpf" } - data = encoded - filename = "\(content.name).bpf" - } - public init(configuration: ReadConfiguration) throws { - guard let data = configuration.file.regularFileContents else { - throw CocoaError(.fileReadCorruptFile) + public init(configuration: ReadConfiguration) throws { + guard let data = configuration.file.regularFileContents else { + throw CocoaError(.fileReadCorruptFile) + } + self.data = data + filename = "profile.bpf" } - self.data = data - filename = "profile.bpf" - } - public func fileWrapper(configuration _: WriteConfiguration) throws -> FileWrapper { - FileWrapper(regularFileWithContents: data) - } -} - -public struct ProfileJSONExportDocument: FileDocument { - public static var readableContentTypes: [UTType] { [.json] } - - public let content: String - public let filename: String - - public init(jsonContent: String, name: String) { - content = jsonContent - filename = "\(name).json" - } - - public init(configuration: ReadConfiguration) throws { - guard let data = configuration.file.regularFileContents, - let content = String(data: data, encoding: .utf8) - else { - throw CocoaError(.fileReadCorruptFile) + public func fileWrapper(configuration _: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: data) } - self.content = content - filename = "profile.json" } - public func fileWrapper(configuration _: WriteConfiguration) throws -> FileWrapper { - guard let data = content.data(using: .utf8) else { - throw CocoaError(.fileWriteInapplicableStringEncoding) + public struct ProfileJSONExportDocument: FileDocument { + public static var readableContentTypes: [UTType] { [.json] } + + public let content: String + public let filename: String + + public init(jsonContent: String, name: String) { + content = jsonContent + filename = "\(name).json" + } + + public init(configuration: ReadConfiguration) throws { + guard let data = configuration.file.regularFileContents, + let content = String(data: data, encoding: .utf8) + else { + throw CocoaError(.fileReadCorruptFile) + } + self.content = content + filename = "profile.json" + } + + public func fileWrapper(configuration _: WriteConfiguration) throws -> FileWrapper { + guard let data = content.data(using: .utf8) else { + throw CocoaError(.fileWriteInapplicableStringEncoding) + } + return FileWrapper(regularFileWithContents: data) } - return FileWrapper(regularFileWithContents: data) } -} #endif diff --git a/Library/Database/Profile+Update.swift b/Library/Database/Profile+Update.swift index 7a92757..9386aed 100644 --- a/Library/Database/Profile+Update.swift +++ b/Library/Database/Profile+Update.swift @@ -29,7 +29,7 @@ public extension Profile { if await SharedPreferences.selectedProfileID.get() == id { if let profile = try? await ExtensionProfile.load() { if await profile.status == .connected { - try LibboxNewStandaloneCommandClient()!.serviceReload() + try await profile.reloadService() } } } diff --git a/Library/Database/SharedPreferences+Database.swift b/Library/Database/SharedPreferences+Database.swift index a2b9345..2cfcf22 100644 --- a/Library/Database/SharedPreferences+Database.swift +++ b/Library/Database/SharedPreferences+Database.swift @@ -1,6 +1,9 @@ import BinaryCodable import Foundation import GRDB +import os + +private let logger = Logger(category: "SharedPreferences") extension SharedPreferences { public class Preference { @@ -16,7 +19,7 @@ extension SharedPreferences { do { return try await SharedPreferences.read(name) ?? defaultValue } catch { - NSLog("read preferences error: \(error)") + logger.error("read preferences error: \(error)") return defaultValue } } @@ -31,7 +34,7 @@ extension SharedPreferences { do { try await SharedPreferences.write(name, newValue) } catch { - NSLog("write preferences error: \(error)") + logger.error("write preferences error: \(error)") } } } diff --git a/Library/Network/CommandClient.swift b/Library/Network/CommandClient.swift index bef8787..56847eb 100644 --- a/Library/Network/CommandClient.swift +++ b/Library/Network/CommandClient.swift @@ -1,5 +1,8 @@ import Foundation import Libbox +import os + +private let logger = Logger(category: "CommandClient") public struct LogEntry: Identifiable { public let id = UUID() @@ -124,6 +127,13 @@ public class CommandClient: ObservableObject { } } + public func clearLogs() { + logBatchTimer?.cancel() + logBatchTimer = nil + pendingLogs.removeAll() + logList.removeAll() + } + public func filterConnectionsNow() { guard let message = rawConnections else { return @@ -222,7 +232,7 @@ public class CommandClient: ObservableObject { commandClient.isConnected = false } if let message { - NSLog("client disconnected: \(message)") + logger.debug("client disconnected: \(message)") } } @@ -234,7 +244,7 @@ public class CommandClient: ObservableObject { func clearLogs() { DispatchQueue.main.async { [self] in - commandClient.logList.removeAll() + commandClient.clearLogs() } } diff --git a/Library/Network/CommandXPC.swift b/Library/Network/CommandXPC.swift new file mode 100644 index 0000000..54d2347 --- /dev/null +++ b/Library/Network/CommandXPC.swift @@ -0,0 +1,215 @@ +#if os(macOS) + import Foundation + import Libbox + import os + + private let logger = Logger(category: "CommandXPC") + + @objc public protocol CommandXPCProtocol { + func connectToCommandServer(reply: @escaping (FileHandle?, NSError?) -> Void) + func registerUserServiceEndpoint(_ endpoint: NSXPCListenerEndpoint?, reply: @escaping (NSError?) -> Void) + func extensionRequirements(reply: @escaping (Bool, Bool, NSError?) -> Void) + } + + class CommandXPCService: NSObject, NSXPCListenerDelegate { + let socketPath: String + var commandServer: LibboxCommandServer? + + private let serviceReadyLock = NSLock() + private var _serviceReady = false + private var serviceReadyContinuations: [CheckedContinuation] = [] + + init(socketPath: String) { + self.socketPath = socketPath + } + + func waitForServiceReady() async { + serviceReadyLock.lock() + if _serviceReady { + serviceReadyLock.unlock() + return + } + await withCheckedContinuation { continuation in + serviceReadyContinuations.append(continuation) + serviceReadyLock.unlock() + } + } + + func markServiceReady() { + serviceReadyLock.lock() + _serviceReady = true + let continuations = serviceReadyContinuations + serviceReadyContinuations.removeAll() + serviceReadyLock.unlock() + for continuation in continuations { + continuation.resume() + } + } + + func markServiceNotReady() { + serviceReadyLock.lock() + _serviceReady = false + serviceReadyLock.unlock() + } + + func listener(_: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool { + let allowedBundleIDs = [AppConfiguration.packageName + ".standalone"] + guard XPCConnectionValidator.validateConnection( + newConnection, + teamID: AppConfiguration.teamID, + allowedBundleIDs: allowedBundleIDs + ) else { + let info = XPCConnectionValidator.getConnectionInfo(newConnection) + logger.warning("Rejected XPC connection: pid=\(info.pid), bundleID=\(info.bundleID ?? "unknown"), teamID=\(info.teamID ?? "unknown")") + return false + } + + let exportedInterface = NSXPCInterface(with: CommandXPCProtocol.self) + CommandXPC.configureInterface(exportedInterface) + newConnection.exportedInterface = exportedInterface + newConnection.exportedObject = CommandXPCHandler(service: self) + newConnection.resume() + return true + } + } + + private class CommandXPCHandler: NSObject, CommandXPCProtocol { + private let service: CommandXPCService + + init(service: CommandXPCService) { + self.service = service + } + + func connectToCommandServer(reply: @escaping (FileHandle?, NSError?) -> Void) { + do { + let handle = try connectToUnixSocket(path: service.socketPath) + reply(handle, nil) + } catch { + reply(nil, error as NSError) + } + } + + func registerUserServiceEndpoint(_ endpoint: NSXPCListenerEndpoint?, reply: @escaping (NSError?) -> Void) { + if let endpoint { + UserServiceEndpointRegistry.shared.update(endpoint) + } else { + UserServiceEndpointRegistry.shared.clear() + } + reply(nil) + } + + func extensionRequirements(reply: @escaping (Bool, Bool, NSError?) -> Void) { + Task { + await service.waitForServiceReady() + guard let commandServer = service.commandServer else { + reply(false, false, NSError(domain: "CommandXPC", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "Command server not available", + ])) + return + } + let needWIFI = commandServer.needWIFIState() + let needProcess = commandServer.needFindProcess() + reply(needWIFI, needProcess, nil) + } + } + + private func connectToUnixSocket(path: String) throws -> FileHandle { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw NSError(domain: "CommandXPC", code: Int(errno), userInfo: [ + NSLocalizedDescriptionKey: "Failed to create socket: \(String(cString: strerror(errno)))", + ]) + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let pathSize = MemoryLayout.size(ofValue: addr.sun_path) + withUnsafeMutableBytes(of: &addr.sun_path) { buffer in + _ = path.withCString { cString in + strncpy(buffer.baseAddress!.assumingMemoryBound(to: CChar.self), cString, pathSize - 1) + } + } + + let connectResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + connect(fd, sockaddrPtr, socklen_t(MemoryLayout.size)) + } + } + + guard connectResult >= 0 else { + close(fd) + throw NSError(domain: "CommandXPC", code: Int(errno), userInfo: [ + NSLocalizedDescriptionKey: "Failed to connect to \(path): \(String(cString: strerror(errno)))", + ]) + } + + return FileHandle(fileDescriptor: fd, closeOnDealloc: false) + } + } + + public class CommandXPCDialer: NSObject, LibboxXPCDialerProtocol { + public static let shared = CommandXPCDialer() + + public func dialXPC(_ ret0_: UnsafeMutablePointer?) throws { + let semaphore = DispatchSemaphore(value: 0) + var result: Int32 = -1 + var resultError: Error? + + let machServiceName = AppConfiguration.appGroupID + ".system" + let connection = NSXPCConnection(machServiceName: machServiceName) + let remoteInterface = NSXPCInterface(with: CommandXPCProtocol.self) + CommandXPC.configureInterface(remoteInterface) + connection.remoteObjectInterface = remoteInterface + connection.resume() + + let proxy = connection.remoteObjectProxyWithErrorHandler { error in + logger.error("XPC proxy error: \(error.localizedDescription)") + resultError = error + semaphore.signal() + } as! CommandXPCProtocol + + proxy.connectToCommandServer { handle, error in + if let error { + logger.error("connectToCommandServer error: \(error.localizedDescription)") + resultError = error + } else if let handle { + result = dup(handle.fileDescriptor) + } + semaphore.signal() + } + + semaphore.wait() + connection.invalidate() + + if let error = resultError { + throw error + } + if result < 0 { + logger.error("dialXPC failed: No file handle returned") + throw NSError(domain: "CommandXPCDialer", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "No file handle returned", + ]) + } + ret0_?.pointee = result + } + } + + public enum CommandXPC { + public static func configureInterface(_ interface: NSXPCInterface) { + let fileHandleClasses = NSSet(array: [FileHandle.self]) as! Set + interface.setClasses( + fileHandleClasses, + for: #selector(CommandXPCProtocol.connectToCommandServer(reply:)), + argumentIndex: 0, + ofReply: true + ) + let endpointClasses = NSSet(array: [NSXPCListenerEndpoint.self]) as! Set + interface.setClasses( + endpointClasses, + for: #selector(CommandXPCProtocol.registerUserServiceEndpoint(_:reply:)), + argumentIndex: 0, + ofReply: false + ) + } + } +#endif diff --git a/Library/Network/ExtensionEnvironments.swift b/Library/Network/ExtensionEnvironments.swift index e2c6fe6..581bc21 100644 --- a/Library/Network/ExtensionEnvironments.swift +++ b/Library/Network/ExtensionEnvironments.swift @@ -1,12 +1,141 @@ import Foundation import SwiftUI +public struct AlertState: Equatable { + public var title: String + public var message: String + public var primaryButton: ButtonState? + public var secondaryButton: ButtonState? + public var onDismiss: (() -> Void)? + + public struct ButtonState: Equatable { + public var label: String + public var role: ButtonRole? + public var action: (() -> Void)? + + public init(label: String, role: ButtonRole? = nil, action: (() -> Void)? = nil) { + self.label = label + self.role = role + self.action = action + } + + public static func == (lhs: ButtonState, rhs: ButtonState) -> Bool { + lhs.label == rhs.label && lhs.role == rhs.role + } + + public static func `default`(_ label: String, action: (() -> Void)? = nil) -> ButtonState { + ButtonState(label: label, action: action) + } + + public static func cancel(_ label: String = String(localized: "Cancel"), action: (() -> Void)? = nil) -> ButtonState { + ButtonState(label: label, role: .cancel, action: action) + } + + public static func destructive(_ label: String, action: (() -> Void)? = nil) -> ButtonState { + ButtonState(label: label, role: .destructive, action: action) + } + } + + public init(error: Error, dismiss: (() -> Void)? = nil) { + self.init(errorMessage: error.localizedDescription, dismiss: dismiss) + } + + public init(errorMessage: String, dismiss: (() -> Void)? = nil) { + title = String(localized: "Error") + message = errorMessage + primaryButton = .default(String(localized: "Ok"), action: dismiss) + secondaryButton = nil + onDismiss = nil + } + + public init(title: String, message: String, dismissButton: ButtonState? = nil) { + self.title = title + self.message = message + primaryButton = dismissButton ?? .default(String(localized: "Ok")) + secondaryButton = nil + onDismiss = nil + } + + public init(title: String, message: String, primaryButton: ButtonState, secondaryButton: ButtonState) { + self.title = title + self.message = message + self.primaryButton = primaryButton + self.secondaryButton = secondaryButton + onDismiss = nil + } + + public init(title: String, message: String, primaryButton: ButtonState, secondaryButton: ButtonState, onDismiss: @escaping () -> Void) { + self.title = title + self.message = message + self.primaryButton = primaryButton + self.secondaryButton = secondaryButton + self.onDismiss = onDismiss + } + + public static func == (lhs: AlertState, rhs: AlertState) -> Bool { + lhs.title == rhs.title && lhs.message == rhs.message && + lhs.primaryButton == rhs.primaryButton && lhs.secondaryButton == rhs.secondaryButton + } +} + +public extension View { + @ViewBuilder + func alert(_ binding: Binding) -> some View { + alert( + binding.wrappedValue?.title ?? "", + isPresented: Binding( + get: { binding.wrappedValue != nil }, + set: { newValue, _ in + if !newValue { + binding.wrappedValue?.onDismiss?() + binding.wrappedValue = nil + } + } + ), + presenting: binding.wrappedValue + ) { alertState in + if let secondary = alertState.secondaryButton { + Button(role: alertState.primaryButton?.role) { + alertState.primaryButton?.action?() + } label: { + Text(alertState.primaryButton?.label ?? "Ok") + } + Button(role: secondary.role) { + secondary.action?() + } label: { + Text(secondary.label) + } + } else if let primary = alertState.primaryButton { + Button(role: primary.role) { + primary.action?() + } label: { + Text(primary.label) + } + } + } message: { alertState in + Text(alertState.message) + } + } +} + +public struct ImportRemoteProfileRequest: Hashable, Identifiable { + public var id: String { url } + public let name: String + public let url: String + + public init(name: String, url: String) { + self.name = name + self.url = url + } +} + @MainActor public class ExtensionEnvironments: ObservableObject { @Published public var commandClient = CommandClient([.log, .status, .groups, .clashMode, .connections]) @Published public var extensionProfileLoading = true @Published public var extensionProfile: ExtensionProfile? @Published public var emptyProfiles = false + @Published public var pendingImportRemoteProfile: ImportRemoteProfileRequest? public let profileUpdate = ObjectWillChangePublisher() public let selectedProfileUpdate = ObjectWillChangePublisher() @@ -14,12 +143,6 @@ public class ExtensionEnvironments: ObservableObject { public init() {} - nonisolated deinit { - Task { @MainActor in - commandClient.disconnect() - } - } - public func postReload() { Task { await reload() diff --git a/Library/Network/ExtensionPlatformInterface.swift b/Library/Network/ExtensionPlatformInterface.swift index 2ce9d09..4cd1778 100644 --- a/Library/Network/ExtensionPlatformInterface.swift +++ b/Library/Network/ExtensionPlatformInterface.swift @@ -22,14 +22,17 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc private func openTun0(_ options: LibboxTunOptionsProtocol?, _ ret0_: UnsafeMutablePointer?) async throws { guard let options else { - throw NSError(domain: "nil options", code: 0) + throw NSError(domain: "ExtensionPlatformInterface", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Nil options")]) } guard let ret0_ else { - throw NSError(domain: "nil return pointer", code: 0) + throw NSError(domain: "ExtensionPlatformInterface", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Nil return pointer")]) } - let autoRouteUseSubRangesByDefault = await SharedPreferences.autoRouteUseSubRangesByDefault.get() - let excludeAPNs = await SharedPreferences.excludeAPNsRoute.get() + let prefs = tunnel.overridePreferences ?? ExtensionProvider.OverridePreferences() + let autoRouteUseSubRangesByDefault = prefs.autoRouteUseSubRangesByDefault + let excludeAPNs = prefs.excludeAPNsRoute + let excludeDefaultRoute = prefs.excludeDefaultRoute + let systemProxyEnabled = prefs.systemProxyEnabled let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1") if options.getAutoRoute() { @@ -78,7 +81,7 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc let ipv4RoutePrefix = inet4RouteExcludeAddressIterator.next()! ipv4ExcludeRoutes.append(NEIPv4Route(destinationAddress: ipv4RoutePrefix.address(), subnetMask: ipv4RoutePrefix.mask())) } - if await SharedPreferences.excludeDefaultRoute.get(), !ipv4Routes.isEmpty { + if excludeDefaultRoute, !ipv4Routes.isEmpty { if !ipv4ExcludeRoutes.contains(where: { it in it.destinationAddress == "0.0.0.0" && it.destinationSubnetMask == "255.255.255.254" }) { @@ -134,7 +137,7 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc ipv6ExcludeRoutes.append(NEIPv6Route(destinationAddress: ipv6RoutePrefix.address(), networkPrefixLength: NSNumber(value: ipv6RoutePrefix.prefix()))) } - if await SharedPreferences.excludeDefaultRoute.get(), !ipv6Routes.isEmpty { + if excludeDefaultRoute, !ipv6Routes.isEmpty { if !ipv6ExcludeRoutes.contains(where: { it in it.destinationAddress == "::" && it.destinationNetworkPrefixLength == 127 }) { @@ -152,7 +155,7 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc let proxyServer = NEProxyServer(address: options.getHTTPProxyServer(), port: Int(options.getHTTPProxyServerPort())) proxySettings.httpServer = proxyServer proxySettings.httpsServer = proxyServer - if await SharedPreferences.systemProxyEnabled.get() { + if systemProxyEnabled { proxySettings.httpEnabled = true proxySettings.httpsEnabled = true } @@ -194,7 +197,7 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc if tunFdFromLoop != -1 { ret0_.pointee = tunFdFromLoop } else { - throw NSError(domain: "missing file descriptor", code: 0) + throw NSError(domain: "ExtensionPlatformInterface", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Missing file descriptor")]) } } @@ -204,16 +207,29 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc public func autoDetectControl(_: Int32) throws {} - public func findConnectionOwner(_: Int32, sourceAddress _: String?, sourcePort _: Int32, destinationAddress _: String?, destinationPort _: Int32, ret0_ _: UnsafeMutablePointer?) throws { - throw NSError(domain: "not implemented", code: 0) - } - - public func packageName(byUid _: Int32, error _: NSErrorPointer) -> String { - "" - } - - public func uid(byPackageName _: String?, ret0_ _: UnsafeMutablePointer?) throws { - throw NSError(domain: "not implemented", code: 0) + public func findConnectionOwner(_ ipProtocol: Int32, sourceAddress: String?, sourcePort: Int32, destinationAddress: String?, destinationPort: Int32) throws -> LibboxConnectionOwner { + #if os(macOS) + if Variant.useSystemExtension { + guard let sourceAddress, let destinationAddress else { + throw NSError(domain: "findConnectionOwner", code: 0, userInfo: [ + NSLocalizedDescriptionKey: "Missing source or destination address", + ]) + } + let owner = try RootHelperClient.shared.findConnectionOwner( + ipProtocol: ipProtocol, + sourceAddress: sourceAddress, + sourcePort: sourcePort, + destinationAddress: destinationAddress, + destinationPort: destinationPort + ) + let result = LibboxConnectionOwner() + result.userId = owner.userId + result.userName = owner.userName + result.processPath = owner.processPath + return result + } + #endif + throw NSError(domain: "ExtensionPlatformInterface", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Not implemented")]) } public func useProcFS() -> Bool { @@ -264,7 +280,7 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc public func getInterfaces() throws -> LibboxNetworkInterfaceIteratorProtocol { guard let nwMonitor else { - throw NSError(domain: "NWMonitor not started", code: 0) + throw NSError(domain: "ExtensionPlatformInterface", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "NWMonitor not started")]) } let path = nwMonitor.currentPath if path.status == .unsatisfied { @@ -313,10 +329,10 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc } public func includeAllNetworks() -> Bool { - #if !os(tvOS) - return SharedPreferences.includeAllNetworks.getBlocking() - #else + #if os(tvOS) return false + #else + return tunnel.overridePreferences?.includeAllNetworks ?? false #endif } @@ -324,12 +340,20 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc guard let networkSettings else { return } - tunnel.reasserting = true - tunnel.setTunnelNetworkSettings(nil) { _ in + runBlocking { + self.tunnel.reasserting = true + defer { self.tunnel.reasserting = false } + await withCheckedContinuation { continuation in + self.tunnel.setTunnelNetworkSettings(nil) { _ in + continuation.resume() + } + } + await withCheckedContinuation { continuation in + self.tunnel.setTunnelNetworkSettings(networkSettings) { _ in + continuation.resume() + } + } } - tunnel.setTunnelNetworkSettings(networkSettings) { _ in - } - tunnel.reasserting = false } public func readWIFIState() -> LibboxWIFIState? { @@ -342,6 +366,9 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc } return LibboxWIFIState(network.ssid, wifiBSSID: network.bssid)! #elseif os(macOS) + if Variant.useSystemExtension { + return UserServiceClient.shared.readWIFIState() + } guard let interface = CWWiFiClient.shared().interface() else { return nil } @@ -425,6 +452,8 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc func reset() { networkSettings = nil + nwMonitor?.cancel() + nwMonitor = nil } public func send(_ notification: LibboxNotification?) throws { @@ -432,6 +461,12 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc guard let notification else { return } + #if os(macOS) + if Variant.useSystemExtension { + try UserServiceClient.shared.sendNotification(notification) + return + } + #endif let center = UNUserNotificationCenter.current() let content = UNMutableNotificationContent() diff --git a/Library/Network/ExtensionProfile.swift b/Library/Network/ExtensionProfile.swift index aa5cf2e..66b43f6 100644 --- a/Library/Network/ExtensionProfile.swift +++ b/Library/Network/ExtensionProfile.swift @@ -1,6 +1,9 @@ import Foundation import Libbox import NetworkExtension +import os + +private let logger = Logger(category: "ExtensionProfile") @MainActor public class ExtensionProfile: ObservableObject { @@ -24,17 +27,19 @@ public class ExtensionProfile: ObservableObject { observer = NotificationCenter.default.addObserver( forName: NSNotification.Name.NEVPNStatusDidChange, object: manager.connection, - queue: .main + queue: nil ) { [weak self] notification in - guard let self else { - return - } guard let connection = notification.object as? NEVPNConnection else { return } - self.connection = connection - self.status = connection.status - self.connectedDate = connection.connectedDate + Task { @MainActor in + guard let self else { + return + } + self.connection = connection + self.status = connection.status + self.connectedDate = connection.connectedDate + } } } @@ -78,7 +83,7 @@ public class ExtensionProfile: ObservableObject { } public func start() async throws { - await fetchProfile() + try await fetchProfile() manager.isEnabled = true let alwaysOn = await SharedPreferences.alwaysOn.get() let onDemandEnabled = await SharedPreferences.onDemandEnabled.get() @@ -96,29 +101,73 @@ public class ExtensionProfile: ObservableObject { } #endif try await manager.saveToPreferences() - #if os(macOS) - if Variant.useSystemExtension { - try manager.connection.startVPNTunnel(options: [ - "username": NSString(string: NSUserName()), - "manualStart": NSNumber(value: true), - ]) - return - } - #endif - try manager.connection.startVPNTunnel(options: [ - "manualStart": NSNumber(value: true), - ]) + let options = try await prepareStartOptions() + try manager.connection.startVPNTunnel(options: options) } - public func fetchProfile() async { - do { - if let profile = try await ProfileManager.get(Int64(SharedPreferences.selectedProfileID.get())) { - if profile.type == .icloud { - _ = try profile.read() + public func reloadService() async throws { + let options = try await prepareStartOptions() + let data = try ExtensionStartOptions.encode(options) + guard let session = connection as? NETunnelProviderSession else { + throw NSError(domain: "ExtensionStartOptions", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "Tunnel session unavailable", + ]) + } + let response = try await withCheckedThrowingContinuation { continuation in + do { + try session.sendProviderMessage(data) { response in + continuation.resume(returning: response) } + } catch { + continuation.resume(throwing: error) + } + } + if let response, !response.isEmpty { + let message = String(data: response, encoding: .utf8) ?? "Unknown error" + throw NSError(domain: "ExtensionStartOptions", code: -1, userInfo: [ + NSLocalizedDescriptionKey: message, + ]) + } + } + + private func prepareStartOptions() async throws -> [String: NSObject] { + var options: [String: NSObject] = [ + "manualStart": NSNumber(value: true), + ] + + let profileID = await SharedPreferences.selectedProfileID.get() + guard let profile = try await ProfileManager.get(profileID) else { + throw NSError(domain: "ExtensionProfile", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "Missing selected profile", + ]) + } + + let configContent = try profile.read() + options["configContent"] = NSString(string: configContent) + + options["ignoreMemoryLimit"] = await NSNumber(value: SharedPreferences.ignoreMemoryLimit.get()) + options["systemProxyEnabled"] = await NSNumber(value: SharedPreferences.systemProxyEnabled.get()) + options["excludeDefaultRoute"] = await NSNumber(value: SharedPreferences.excludeDefaultRoute.get()) + options["autoRouteUseSubRangesByDefault"] = await NSNumber(value: SharedPreferences.autoRouteUseSubRangesByDefault.get()) + options["excludeAPNsRoute"] = await NSNumber(value: SharedPreferences.excludeAPNsRoute.get()) + + #if !os(tvOS) + options["includeAllNetworks"] = await NSNumber(value: SharedPreferences.includeAllNetworks.get()) + #endif + + #if os(tvOS) + options["commandServerPort"] = await NSNumber(value: SharedPreferences.commandServerPort.get()) + options["commandServerSecret"] = await NSString(string: SharedPreferences.commandServerSecret.get()) + #endif + + return options + } + + public func fetchProfile() async throws { + if let profile = try await ProfileManager.get(Int64(SharedPreferences.selectedProfileID.get())) { + if profile.type == .icloud { + _ = try profile.read() } - } catch { - NSLog("fetchProfile error: \(error.localizedDescription)") } } @@ -130,7 +179,7 @@ public class ExtensionProfile: ObservableObject { do { try LibboxNewStandaloneCommandClient()!.serviceClose() } catch { - NSLog("serviceClose error: \(error.localizedDescription)") + logger.debug("serviceClose error: \(error.localizedDescription)") } manager.connection.stopVPNTunnel() } @@ -142,7 +191,7 @@ public class ExtensionProfile: ObservableObject { try await Task.sleep(nanoseconds: NSEC_PER_SEC) waitSeconds += 1 if waitSeconds >= 5 { - throw NSError(domain: "Restart service timeout", code: 0) + throw NSError(domain: "ExtensionProfile", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Restart service timeout")]) } } try await start() diff --git a/Library/Network/ExtensionProvider.swift b/Library/Network/ExtensionProvider.swift index 9799ea0..fa25e99 100644 --- a/Library/Network/ExtensionProvider.swift +++ b/Library/Network/ExtensionProvider.swift @@ -1,6 +1,7 @@ import Foundation import Libbox import NetworkExtension +import os.log #if os(iOS) import WidgetKit #endif @@ -9,20 +10,108 @@ import NetworkExtension #endif open class ExtensionProvider: NEPacketTunnelProvider { - public var username: String? - private var commandServer: LibboxCommandServer! + private static let logger = Logger(category: "ExtensionProvider") + + public private(set) var commandServer: LibboxCommandServer? private var platformInterface: ExtensionPlatformInterface! + public var tunnelOptions: [String: NSObject]? + private var startOptionsURL: URL? + + public struct OverridePreferences { + public var includeAllNetworks: Bool = false + public var systemProxyEnabled: Bool = true + public var excludeDefaultRoute: Bool = false + public var autoRouteUseSubRangesByDefault: Bool = false + public var excludeAPNsRoute: Bool = false + } + + public var overridePreferences: OverridePreferences? + + private func applyStartOptions(_ options: [String: NSObject]) { + tunnelOptions = options + var prefs = OverridePreferences() + prefs.includeAllNetworks = (options["includeAllNetworks"] as? NSNumber)?.boolValue ?? false + prefs.systemProxyEnabled = (options["systemProxyEnabled"] as? NSNumber)?.boolValue ?? true + prefs.excludeDefaultRoute = (options["excludeDefaultRoute"] as? NSNumber)?.boolValue ?? false + prefs.autoRouteUseSubRangesByDefault = (options["autoRouteUseSubRangesByDefault"] as? NSNumber)?.boolValue ?? false + prefs.excludeAPNsRoute = (options["excludeAPNsRoute"] as? NSNumber)?.boolValue ?? false + overridePreferences = prefs + } + + private func persistStartOptions(_ options: [String: NSObject]) throws { + guard let startOptionsURL else { + return + } + let data = try ExtensionStartOptions.encode(options) + try data.write(to: startOptionsURL, options: .atomic) + } + + #if os(macOS) + private var xpcListener: NSXPCListener? + private var xpcService: CommandXPCService? + private var locationManager: CLLocationManager? + private var locationDelegate: stubLocationDelegate? + #endif + + override open func startTunnel(options startOptions: [String: NSObject]?) async throws { + let basePath: String + let workingPath: String + let tempPath: String + + #if os(macOS) + if Variant.useSystemExtension { + let containerURL = FileManager.default.homeDirectoryForCurrentUser + basePath = containerURL.path + workingPath = containerURL.appendingPathComponent("Working").path + tempPath = containerURL.appendingPathComponent("Temp").path + } else { + basePath = FilePath.sharedDirectory.relativePath + workingPath = FilePath.workingDirectory.relativePath + tempPath = FilePath.cacheDirectory.relativePath + } + #else + basePath = FilePath.sharedDirectory.relativePath + workingPath = FilePath.workingDirectory.relativePath + tempPath = FilePath.cacheDirectory.relativePath + #endif + + startOptionsURL = URL(fileURLWithPath: basePath).appendingPathComponent(ExtensionStartOptions.snapshotFileName) + var effectiveOptions = startOptions + if let startOptions { + do { + try persistStartOptions(startOptions) + } catch { + throw ExtensionStartupError("(packet-tunnel) error: persist start options: \(error.localizedDescription)") + } + } else if let startOptionsURL, FileManager.default.fileExists(atPath: startOptionsURL.path) { + do { + let data = try Data(contentsOf: startOptionsURL) + effectiveOptions = try ExtensionStartOptions.decode(data) + } catch { + throw ExtensionStartupError("(packet-tunnel) error: load start options: \(error.localizedDescription)") + } + } else { + throw ExtensionStartupError("(packet-tunnel) error: missing start options") + } + + if let effectiveOptions { + applyStartOptions(effectiveOptions) + } - override open func startTunnel(options _: [String: NSObject]?) async throws { let options = LibboxSetupOptions() - options.basePath = FilePath.sharedDirectory.relativePath - options.workingPath = FilePath.workingDirectory.relativePath - options.tempPath = FilePath.cacheDirectory.relativePath + options.basePath = basePath + options.workingPath = workingPath + options.tempPath = tempPath + options.logMaxLines = 3000 #if os(tvOS) - options.commandServerListenPort = await SharedPreferences.commandServerPort.get() - options.commandServerSecret = await SharedPreferences.commandServerSecret.get() + if let port = effectiveOptions?["commandServerPort"] as? NSNumber { + options.commandServerListenPort = port.int32Value + } + if let secret = effectiveOptions?["commandServerSecret"] as? String { + options.commandServerSecret = secret + } #endif var setupError: NSError? @@ -31,13 +120,8 @@ open class ExtensionProvider: NEPacketTunnelProvider { throw ExtensionStartupError("(packet-tunnel) error: setup service: \(setupError.localizedDescription)") } - var stderrError: NSError? - LibboxRedirectStderr(FilePath.cacheDirectory.appendingPathComponent("stderr.log").relativePath, &stderrError) - if let stderrError { - throw ExtensionStartupError("(packet-tunnel) redirect stderr error: \(stderrError.localizedDescription)") - } - - await LibboxSetMemoryLimit(!SharedPreferences.ignoreMemoryLimit.get()) + let ignoreMemoryLimit = (effectiveOptions?["ignoreMemoryLimit"] as? NSNumber)?.boolValue ?? false + LibboxSetMemoryLimit(!ignoreMemoryLimit) if platformInterface == nil { platformInterface = ExtensionPlatformInterface(self) @@ -48,12 +132,31 @@ open class ExtensionProvider: NEPacketTunnelProvider { throw ExtensionStartupError("(packet-tunnel): create command server error: \(error.localizedDescription)") } do { - try commandServer.start() + try commandServer!.start() } catch { throw ExtensionStartupError("(packet-tunnel): start command server error: \(error.localizedDescription)") } + + #if os(macOS) + if Variant.useSystemExtension { + let socketPath = options.basePath + "/command.sock" + xpcService = CommandXPCService(socketPath: socketPath) + let machServiceName = AppConfiguration.appGroupID + ".system" + xpcListener = NSXPCListener(machServiceName: machServiceName) + xpcListener!.delegate = xpcService + xpcListener!.resume() + Self.logger.info("set Command Server") + xpcService!.commandServer = commandServer + } + #endif + writeMessage("(packet-tunnel): Here I stand") try await startService() + #if os(macOS) + if Variant.useSystemExtension { + xpcService!.markServiceReady() + } + #endif #if os(iOS) if #available(iOS 18.0, *) { ControlCenter.shared.reloadControls(ofKind: ExtensionProfile.controlKind) @@ -68,48 +171,28 @@ open class ExtensionProvider: NEPacketTunnelProvider { } private func startService() async throws { - let profileID = await SharedPreferences.selectedProfileID.get() - let profile: Profile? - do { - profile = try await ProfileManager.get(profileID) - } catch { - throw ExtensionStartupError("(packet-tunnel) error: read selected profile: \(error.localizedDescription)") - } - guard let profile else { - throw ExtensionStartupError("(packet-tunnel) error: missing selected profile") - } - let configContent: String - do { - configContent = try profile.read() - } catch { - throw ExtensionStartupError("(packet-tunnel) error: read config file \(profile.path): \(error.localizedDescription)") + guard let configContent = tunnelOptions?["configContent"] as? String else { + throw ExtensionStartupError("(packet-tunnel) error: missing configContent in tunnel options") } + let options = LibboxOverrideOptions() do { - try commandServer.startOrReloadService(configContent, options: options) + try commandServer!.startOrReloadService(configContent, options: options) } catch { throw ExtensionStartupError("(packet-tunnel) error: start service: \(error.localizedDescription)") } #if os(macOS) - await SharedPreferences.startedByUser.set(true) - if commandServer.needWIFIState() { - if !Variant.useSystemExtension { - locationManager = CLLocationManager() - locationDelegate = stubLocationDelegate() - locationManager?.delegate = locationDelegate - locationManager?.requestLocation() - } else { - writeMessage("(packet-tunnel) WIFI SSID and BSSID information is not currently available in the standalone version of SFM. We are working on resolving this issue.") - } + if !Variant.useSystemExtension, commandServer!.needWIFIState() { + locationManager = CLLocationManager() + locationDelegate = stubLocationDelegate() + locationManager?.delegate = locationDelegate + locationManager?.requestLocation() } #endif } #if os(macOS) - private var locationManager: CLLocationManager? - private var locationDelegate: stubLocationDelegate? - class stubLocationDelegate: NSObject, CLLocationManagerDelegate { func locationManagerDidChangeAuthorization(_: CLLocationManager) {} @@ -122,9 +205,9 @@ open class ExtensionProvider: NEPacketTunnelProvider { func stopService() { do { - try commandServer.closeService() + try commandServer?.closeService() } catch { - writeMessage("(packet-tunnel) error: stop service: \(error.localizedDescription)") + writeMessage("(packet-tunnel) stop service: \(error.localizedDescription)") } if let platformInterface { platformInterface.reset() @@ -149,9 +232,15 @@ open class ExtensionProvider: NEPacketTunnelProvider { commandServer = nil } #if os(macOS) - if reason == .userInitiated { - await SharedPreferences.startedByUser.set(reason == .userInitiated) + if Variant.useSystemExtension { + xpcListener?.invalidate() + xpcListener = nil + xpcService?.commandServer = nil + xpcService = nil + UserServiceEndpointRegistry.shared.clear() } + locationManager = nil + locationDelegate = nil #endif #if os(iOS) if #available(iOS 18.0, *) { @@ -161,7 +250,15 @@ open class ExtensionProvider: NEPacketTunnelProvider { } override open func handleAppMessage(_ messageData: Data) async -> Data? { - messageData + do { + let options = try ExtensionStartOptions.decode(messageData) + applyStartOptions(options) + try persistStartOptions(options) + try await reloadService() + return nil + } catch { + return error.localizedDescription.data(using: .utf8) + } } override open func sleep() async { diff --git a/Library/Network/ExtensionStartOptions.swift b/Library/Network/ExtensionStartOptions.swift new file mode 100644 index 0000000..44b596c --- /dev/null +++ b/Library/Network/ExtensionStartOptions.swift @@ -0,0 +1,19 @@ +import Foundation + +enum ExtensionStartOptions { + static let snapshotFileName = "start_options.plist" + + static func encode(_ options: [String: NSObject]) throws -> Data { + try PropertyListSerialization.data(fromPropertyList: options, format: .binary, options: 0) + } + + static func decode(_ data: Data) throws -> [String: NSObject] { + let plist = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) + guard let options = plist as? [String: NSObject] else { + throw NSError(domain: "ExtensionStartOptions", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "Invalid start options payload", + ]) + } + return options + } +} diff --git a/Library/Network/HelperServiceManager.swift b/Library/Network/HelperServiceManager.swift new file mode 100644 index 0000000..0c8d3ea --- /dev/null +++ b/Library/Network/HelperServiceManager.swift @@ -0,0 +1,25 @@ +#if os(macOS) + import Foundation + import ServiceManagement + + public enum HelperServiceManager { + private static var rootHelperService: SMAppService { + SMAppService.daemon(plistName: "\(AppConfiguration.rootHelperBundleID).plist") + } + + public static var rootHelperStatus: SMAppService.Status { + rootHelperService.status + } + + public static func registerRootHelper() throws { + if rootHelperService.status == .enabled { + try rootHelperService.unregister() + } + try rootHelperService.register() + } + + public static func unregisterRootHelper() throws { + try rootHelperService.unregister() + } + } +#endif diff --git a/Library/Network/RootHelperXPC.swift b/Library/Network/RootHelperXPC.swift new file mode 100644 index 0000000..bc96ea5 --- /dev/null +++ b/Library/Network/RootHelperXPC.swift @@ -0,0 +1,221 @@ +#if os(macOS) + import Foundation + import os + + private let logger = Logger(category: "RootHelperXPC") + + @objc public class ConnectionOwnerResult: NSObject, NSSecureCoding { + public static let supportsSecureCoding = true + + @objc public var userId: Int32 + @objc public var userName: String + @objc public var processPath: String + + public init(userId: Int32, userName: String, processPath: String) { + self.userId = userId + self.userName = userName + self.processPath = processPath + } + + public required init?(coder: NSCoder) { + userId = coder.decodeInt32(forKey: "userId") + userName = coder.decodeObject(of: NSString.self, forKey: "userName") as? String ?? "" + processPath = coder.decodeObject(of: NSString.self, forKey: "processPath") as? String ?? "" + } + + public func encode(with coder: NSCoder) { + coder.encode(userId, forKey: "userId") + coder.encode(userName as NSString, forKey: "userName") + coder.encode(processPath as NSString, forKey: "processPath") + } + } + + @objc public protocol RootHelperProtocol { + func findConnectionOwner( + ipProtocol: Int32, + sourceAddress: String, + sourcePort: Int32, + destinationAddress: String, + destinationPort: Int32, + reply: @escaping (ConnectionOwnerResult?, NSError?) -> Void + ) + + func getWorkingDirectorySize(reply: @escaping (Int64, NSError?) -> Void) + func cleanWorkingDirectory(reply: @escaping (NSError?) -> Void) + } + + public enum RootHelperXPC { + public static func configureInterface(_ interface: NSXPCInterface) { + let resultClasses = NSSet(array: [ConnectionOwnerResult.self, NSString.self]) as! Set + interface.setClasses( + resultClasses, + for: #selector(RootHelperProtocol.findConnectionOwner(ipProtocol:sourceAddress:sourcePort:destinationAddress:destinationPort:reply:)), + argumentIndex: 0, + ofReply: true + ) + } + } + + public class RootHelperClient { + public static let shared = RootHelperClient() + + private var connection: NSXPCConnection? + private let connectionLock = NSLock() + + private init() {} + + private func getConnection() -> NSXPCConnection { + connectionLock.lock() + defer { connectionLock.unlock() } + + if let existing = connection { + return existing + } + + let newConnection = NSXPCConnection(machServiceName: AppConfiguration.rootHelperMachService) + + let remoteInterface = NSXPCInterface(with: RootHelperProtocol.self) + RootHelperXPC.configureInterface(remoteInterface) + newConnection.remoteObjectInterface = remoteInterface + + newConnection.invalidationHandler = { [weak self] in + guard let self else { return } + connectionLock.lock() + connection = nil + connectionLock.unlock() + } + + newConnection.resume() + connection = newConnection + return newConnection + } + + private func performXPCCall( + _ operation: String, + call: (RootHelperProtocol, @escaping (T?, NSError?) -> Void) -> Void + ) throws -> T { + let semaphore = DispatchSemaphore(value: 0) + var result: T? + var resultError: NSError? + + let conn = getConnection() + guard let proxy = conn.remoteObjectProxyWithErrorHandler({ error in + logger.error("\(operation) 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", + ]) + } + + call(proxy) { value, error in + result = value + resultError = error + semaphore.signal() + } + + let timeout = DispatchTime.now() + .seconds(5) + if semaphore.wait(timeout: timeout) == .timedOut { + let error = NSError(domain: "RootHelper", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "\(operation) request timeout", + ]) + logger.error("\(operation): timeout") + throw error + } + + if let error = resultError { + logger.error("\(operation) error: \(error.localizedDescription)") + throw error + } + + guard let value = result else { + let error = NSError(domain: "RootHelper", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "\(operation) returned nil", + ]) + throw error + } + + return value + } + + private func performXPCCallVoid( + _ operation: String, + call: (RootHelperProtocol, @escaping (NSError?) -> Void) -> Void + ) throws { + let semaphore = DispatchSemaphore(value: 0) + var resultError: NSError? + + let conn = getConnection() + guard let proxy = conn.remoteObjectProxyWithErrorHandler({ error in + logger.error("\(operation) 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", + ]) + } + + call(proxy) { error in + resultError = error + semaphore.signal() + } + + let timeout = DispatchTime.now() + .seconds(5) + if semaphore.wait(timeout: timeout) == .timedOut { + let error = NSError(domain: "RootHelper", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "\(operation) request timeout", + ]) + logger.error("\(operation): timeout") + throw error + } + + if let error = resultError { + logger.error("\(operation) error: \(error.localizedDescription)") + throw error + } + } + + public func findConnectionOwner( + ipProtocol: Int32, + sourceAddress: String, + sourcePort: Int32, + destinationAddress: String, + destinationPort: Int32 + ) throws -> ConnectionOwnerResult { + try performXPCCall("findConnectionOwner") { proxy, reply in + proxy.findConnectionOwner( + ipProtocol: ipProtocol, + sourceAddress: sourceAddress, + sourcePort: sourcePort, + destinationAddress: destinationAddress, + destinationPort: destinationPort, + reply: reply + ) + } + } + + public func getWorkingDirectorySize() throws -> Int64 { + try performXPCCall("getWorkingDirectorySize") { proxy, reply in + proxy.getWorkingDirectorySize { size, error in + reply(size as Int64?, error) + } + } + } + + public func cleanWorkingDirectory() throws { + try performXPCCallVoid("cleanWorkingDirectory") { proxy, reply in + proxy.cleanWorkingDirectory(reply: reply) + } + } + } +#endif diff --git a/Library/Network/SystemExtension.swift b/Library/Network/SystemExtension.swift index 58c5eff..0baad9a 100644 --- a/Library/Network/SystemExtension.swift +++ b/Library/Network/SystemExtension.swift @@ -1,7 +1,10 @@ #if os(macOS) import Foundation + import os import SystemExtensions + private let logger = Logger(category: "SystemExtension") + public class SystemExtension: NSObject, OSSystemExtensionRequestDelegate { private let forceUpdate: Bool private let inBackground: Bool @@ -26,10 +29,10 @@ existing.bundleVersion == ext.bundleVersion, existing.bundleShortVersion == ext.bundleShortVersion { - NSLog("Skip update system extension") + logger.info("Skip update system extension") return .cancel } else { - NSLog("Update system extension") + logger.info("Update system extension") return .replace } } diff --git a/Library/Network/UserServiceEndpointPublisher.swift b/Library/Network/UserServiceEndpointPublisher.swift new file mode 100644 index 0000000..3b6a420 --- /dev/null +++ b/Library/Network/UserServiceEndpointPublisher.swift @@ -0,0 +1,204 @@ +#if os(macOS) + import CoreWLAN + import Dispatch + import Foundation + import os + import UserNotifications + + private let logger = Logger(category: "UserService") + + public extension Notification.Name { + static let extensionRequiresWIFIState = Notification.Name("extensionRequiresWIFIState") + static let extensionRequiresHelperService = Notification.Name("extensionRequiresHelperService") + static let navigateToSettingsPage = Notification.Name("navigateToSettingsPage") + } + + public final class UserServiceEndpointPublisher: NSObject, NSXPCListenerDelegate { + public static let shared = UserServiceEndpointPublisher() + + private var listener: NSXPCListener? + private let exportedObject = UserServiceHandler() + + public func start() { + guard listener == nil else { + return + } + let listener = NSXPCListener.anonymous() + listener.delegate = self + listener.resume() + self.listener = listener + registerEndpoint(listener.endpoint) + } + + public func stop() { + if let listener { + listener.invalidate() + self.listener = nil + } + registerEndpoint(nil) + } + + public func refreshEndpointRegistration() { + guard let listener else { + return + } + registerEndpoint(listener.endpoint) + } + + public func listener(_: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool { + let allowedBundleIDs = [AppConfiguration.systemExtensionBundleID] + guard XPCConnectionValidator.validateConnection( + newConnection, + teamID: AppConfiguration.teamID, + allowedBundleIDs: allowedBundleIDs + ) else { + let info = XPCConnectionValidator.getConnectionInfo(newConnection) + logger.warning("Rejected XPC connection: pid=\(info.pid), bundleID=\(info.bundleID ?? "unknown"), teamID=\(info.teamID ?? "unknown")") + return false + } + + newConnection.exportedInterface = NSXPCInterface(with: UserServiceProtocol.self) + newConnection.exportedObject = exportedObject + newConnection.resume() + return true + } + + public func checkExtensionRequirements() { + Task.detached { + let machServiceName = AppConfiguration.appGroupID + ".system" + let connection = NSXPCConnection(machServiceName: machServiceName) + let remoteInterface = NSXPCInterface(with: CommandXPCProtocol.self) + CommandXPC.configureInterface(remoteInterface) + connection.remoteObjectInterface = remoteInterface + connection.resume() + + guard let proxy = connection.remoteObjectProxyWithErrorHandler({ error in + logger.error("Extension requirements check error: \(error.localizedDescription)") + connection.invalidate() + }) as? CommandXPCProtocol else { + connection.invalidate() + return + } + + proxy.extensionRequirements { needWIFI, needProcess, error in + if let error { + logger.error("Extension requirements error: \(error.localizedDescription)") + connection.invalidate() + return + } + if needWIFI { + Task { @MainActor in + NotificationCenter.default.post(name: .extensionRequiresWIFIState, object: nil) + } + } + if needProcess { + Task { @MainActor in + NotificationCenter.default.post(name: .extensionRequiresHelperService, object: nil) + } + } + connection.invalidate() + } + } + } + + private func registerEndpoint(_ endpoint: NSXPCListenerEndpoint?) { + let machServiceName = AppConfiguration.appGroupID + ".system" + let connection = NSXPCConnection(machServiceName: machServiceName) + let remoteInterface = NSXPCInterface(with: CommandXPCProtocol.self) + CommandXPC.configureInterface(remoteInterface) + connection.remoteObjectInterface = remoteInterface + connection.resume() + + guard let proxy = connection.remoteObjectProxyWithErrorHandler({ error in + logger.error("UserService registration error: \(error.localizedDescription)") + connection.invalidate() + }) as? CommandXPCProtocol else { + connection.invalidate() + return + } + + proxy.registerUserServiceEndpoint(endpoint) { error in + if let error { + logger.error("UserService register failed: \(error.localizedDescription)") + } + connection.invalidate() + } + } + } + + private final class UserServiceHandler: NSObject, UserServiceProtocol { + func getWIFIState(reply: @escaping (String?, String?, NSError?) -> Void) { + let client = CWWiFiClient.shared() + guard let interface = client.interface() else { + reply(nil, nil, nil) + return + } + let ssid = interface.ssid() + let bssid = interface.bssid() + reply(ssid, bssid, nil) + } + + func sendNotification( + identifier: String, + typeName _: String, + typeID _: Int32, + title: String, + subtitle: String, + body: String, + openURL: String, + reply: @escaping (NSError?) -> Void + ) { + Task { + do { + let center = UNUserNotificationCenter.current() + + let settings = await center.notificationSettings() + + if settings.authorizationStatus == .notDetermined { + let granted = try await center.requestAuthorization(options: [.alert, .sound]) + if !granted { + let error = NSError(domain: "UserService", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "Notification permission denied", + ]) + logger.error("sendNotification error: \(error.localizedDescription)") + reply(error) + return + } + } else if settings.authorizationStatus == .denied { + let error = NSError(domain: "UserService", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "Notification permission denied", + ]) + logger.error("sendNotification error: \(error.localizedDescription)") + reply(error) + return + } + + let content = UNMutableNotificationContent() + content.title = title + if !subtitle.isEmpty { + content.subtitle = subtitle + } + content.body = body + content.sound = .default + + if !openURL.isEmpty { + content.userInfo["openURL"] = openURL + } + + let request = UNNotificationRequest( + identifier: identifier, + content: content, + trigger: nil + ) + + try await center.add(request) + reply(nil) + } catch { + let nsError = error as NSError + logger.error("sendNotification error: \(nsError.localizedDescription)") + reply(nsError) + } + } + } + } +#endif diff --git a/Library/Network/UserServiceEndpointRegistry.swift b/Library/Network/UserServiceEndpointRegistry.swift new file mode 100644 index 0000000..614fcd2 --- /dev/null +++ b/Library/Network/UserServiceEndpointRegistry.swift @@ -0,0 +1,28 @@ +#if os(macOS) + import Foundation + + final class UserServiceEndpointRegistry { + static let shared = UserServiceEndpointRegistry() + + private let lock = NSLock() + private var endpoint: NSXPCListenerEndpoint? + + func update(_ endpoint: NSXPCListenerEndpoint) { + lock.lock() + self.endpoint = endpoint + lock.unlock() + } + + func clear() { + lock.lock() + endpoint = nil + lock.unlock() + } + + func get() -> NSXPCListenerEndpoint? { + lock.lock() + defer { lock.unlock() } + return endpoint + } + } +#endif diff --git a/Library/Network/UserServiceXPC.swift b/Library/Network/UserServiceXPC.swift new file mode 100644 index 0000000..841104b --- /dev/null +++ b/Library/Network/UserServiceXPC.swift @@ -0,0 +1,159 @@ +#if os(macOS) + import Foundation + import Libbox + import os + + private let logger = Logger(category: "UserServiceXPC") + + @objc public protocol UserServiceProtocol { + func getWIFIState(reply: @escaping (String?, String?, NSError?) -> Void) + func sendNotification( + identifier: String, + typeName: String, + typeID: Int32, + title: String, + subtitle: String, + body: String, + openURL: String, + reply: @escaping (NSError?) -> Void + ) + } + + public class UserServiceClient { + public static let shared = UserServiceClient() + + private var connection: NSXPCConnection? + private let connectionLock = NSLock() + + private init() {} + + private func getConnection() -> NSXPCConnection? { + connectionLock.lock() + defer { connectionLock.unlock() } + + if let existing = connection { + return existing + } + + guard let endpoint = UserServiceEndpointRegistry.shared.get() else { + logger.error("UserService endpoint unavailable") + return nil + } + + let newConnection = NSXPCConnection(listenerEndpoint: endpoint) + newConnection.remoteObjectInterface = NSXPCInterface(with: UserServiceProtocol.self) + + newConnection.invalidationHandler = { [weak self] in + guard let self else { return } + connectionLock.lock() + connection = nil + connectionLock.unlock() + } + + newConnection.resume() + connection = newConnection + return newConnection + } + + private func getProxy() -> UserServiceProtocol? { + guard let conn = getConnection() else { + return nil + } + guard let proxy = conn.remoteObjectProxyWithErrorHandler { [weak self] error in + guard let self else { return } + logger.error("UserService XPC error: \(error.localizedDescription)") + connectionLock.lock() + connection = nil + connectionLock.unlock() + } as? UserServiceProtocol else { + connectionLock.lock() + connection = nil + connectionLock.unlock() + conn.invalidate() + return nil + } + return proxy + } + + private func performXPCCallVoid( + _ operation: String, + call: (UserServiceProtocol, @escaping (NSError?) -> Void) -> Void + ) throws { + let semaphore = DispatchSemaphore(value: 0) + var resultError: NSError? + + guard let proxy = getProxy() else { + throw NSError(domain: "UserService", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "UserService connection unavailable", + ]) + } + + call(proxy) { error in + resultError = error + semaphore.signal() + } + + let deadline = DispatchTime.now() + .seconds(5) + if semaphore.wait(timeout: deadline) == .timedOut { + let error = NSError(domain: "UserService", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "\(operation) request timeout", + ]) + logger.error("\(operation): timeout") + throw error + } + + if let error = resultError { + logger.error("\(operation) error: \(error.localizedDescription)") + throw error + } + } + + public func readWIFIState() -> LibboxWIFIState? { + let semaphore = DispatchSemaphore(value: 0) + var resultSSID: String? + var resultBSSID: String? + + guard let proxy = getProxy() else { + logger.error("readWIFIState: no UserService connection") + return nil + } + + proxy.getWIFIState { ssid, bssid, error in + if let error { + logger.error("readWIFIState error: \(error.localizedDescription)") + } else { + resultSSID = ssid + resultBSSID = bssid + } + semaphore.signal() + } + + let timeout = DispatchTime.now() + .seconds(5) + if semaphore.wait(timeout: timeout) == .timedOut { + logger.error("readWIFIState: timeout") + return nil + } + + guard let ssid = resultSSID, let bssid = resultBSSID else { + return nil + } + + return LibboxWIFIState(ssid, wifiBSSID: bssid) + } + + public func sendNotification(_ notification: LibboxNotification) throws { + try performXPCCallVoid("sendNotification") { proxy, reply in + proxy.sendNotification( + identifier: notification.identifier, + typeName: notification.typeName, + typeID: notification.typeID, + title: notification.title, + subtitle: notification.subtitle, + body: notification.body, + openURL: notification.openURL, + reply: reply + ) + } + } + } +#endif diff --git a/Library/Network/XPCConnectionValidator.swift b/Library/Network/XPCConnectionValidator.swift new file mode 100644 index 0000000..6b07c40 --- /dev/null +++ b/Library/Network/XPCConnectionValidator.swift @@ -0,0 +1,80 @@ +#if os(macOS) + import Foundation + import Security + + public struct XPCConnectionInfo { + public let pid: pid_t + public let bundleID: String? + public let teamID: String? + } + + public enum XPCConnectionValidator { + private static func getSecCode(for connection: NSXPCConnection) -> SecCode? { + let pid = connection.processIdentifier + var code: SecCode? + let attributes = [kSecGuestAttributePid: pid] as CFDictionary + guard SecCodeCopyGuestWithAttributes(nil, attributes, [], &code) == errSecSuccess else { + return nil + } + return code + } + + private static func getSigningInfo(_ code: SecCode) -> [String: Any]? { + var staticCode: SecStaticCode? + guard SecCodeCopyStaticCode(code, [], &staticCode) == errSecSuccess, + let staticCode + else { + return nil + } + + var info: CFDictionary? + guard SecCodeCopySigningInformation(staticCode, [], &info) == errSecSuccess else { + return nil + } + return info as? [String: Any] + } + + public static func getConnectionInfo(_ connection: NSXPCConnection) -> XPCConnectionInfo { + let pid = connection.processIdentifier + + guard let secCode = getSecCode(for: connection), + let signingInfo = getSigningInfo(secCode) + else { + return XPCConnectionInfo(pid: pid, bundleID: nil, teamID: nil) + } + + let bundleID = signingInfo[kSecCodeInfoIdentifier as String] as? String + let teamID = signingInfo[kSecCodeInfoTeamIdentifier as String] as? String + + return XPCConnectionInfo(pid: pid, bundleID: bundleID, teamID: teamID) + } + + public static func validateConnection( + _ connection: NSXPCConnection, + teamID: String, + allowedBundleIDs: [String] + ) -> Bool { + guard let secCode = getSecCode(for: connection) else { + return false + } + + let requirement = "anchor apple generic and certificate leaf[subject.OU] = \"\(teamID)\"" + var secRequirement: SecRequirement? + guard SecRequirementCreateWithString(requirement as CFString, [], &secRequirement) == errSecSuccess, + let req = secRequirement, + SecCodeCheckValidity(secCode, [], req) == errSecSuccess + else { + return false + } + + guard let signingInfo = getSigningInfo(secCode), + let bundleID = signingInfo[kSecCodeInfoIdentifier as String] as? String, + allowedBundleIDs.contains(bundleID) + else { + return false + } + + return true + } + } +#endif diff --git a/Library/Shared/AppConfiguration.swift b/Library/Shared/AppConfiguration.swift index 7e37bf2..ddffba1 100644 --- a/Library/Shared/AppConfiguration.swift +++ b/Library/Shared/AppConfiguration.swift @@ -15,6 +15,13 @@ public enum AppConfiguration { return value }() + public static var teamID: String { + guard let dotIndex = appGroupID.firstIndex(of: ".") else { + fatalError("Invalid appGroupID format: \(appGroupID)") + } + return String(appGroupID[.., Bool) -> AnyView = { text, isEditable in AnyView(CodeEditTextView(text: text, isEditable: isEditable)) @@ -21,11 +23,12 @@ public struct MainView: View { SidebarView(selection: $viewModel.selection) .navigationSplitViewColumnWidth(150) } detail: { - NavigationStack { + NavigationStack(path: $settingsNavigationPath) { viewModel.selection.contentView .navigationTitle(viewModel.selection.title) } .environment(\.cardConfigurationVersion, cardConfigurationVersion) + .environment(\.settingsNavigationPath, $settingsNavigationPath) .navigationSplitViewColumnWidth(650) } .frame(minHeight: 500) @@ -33,6 +36,7 @@ public struct MainView: View { viewModel.onAppear(environments: environments) } .alert($viewModel.alert) + .globalChecks() .toolbar { ToolbarItem(placement: .navigation) { StartStopButton() @@ -57,10 +61,32 @@ public struct MainView: View { } .onChangeCompat(of: viewModel.selection) { value in viewModel.onSelectionChange(value, environments: environments) + if value != .settings { + settingsNavigationPath = NavigationPath() + pendingSettingsPage = nil + return + } + if let page = pendingSettingsPage { + settingsNavigationPath = NavigationPath() + settingsNavigationPath.append(page) + pendingSettingsPage = nil + } } .onReceive(environments.openSettings) { viewModel.openSettings() } + .onReceive(NotificationCenter.default.publisher(for: .navigateToSettingsPage)) { notification in + guard let page = notification.object as? SettingsPage else { return } + pendingSettingsPage = page + if viewModel.selection == .settings { + settingsNavigationPath = NavigationPath() + settingsNavigationPath.append(page) + pendingSettingsPage = nil + } else { + viewModel.selection = .settings + } + } + .environment(\.selection, $viewModel.selection) .environment(\.importProfile, $viewModel.importProfile) .environment(\.importRemoteProfile, $viewModel.importRemoteProfile) .environment(\.profileEditor, profileEditor) diff --git a/MacLibrary/MainViewModel.swift b/MacLibrary/MainViewModel.swift index 168e5e8..fda83ad 100644 --- a/MacLibrary/MainViewModel.swift +++ b/MacLibrary/MainViewModel.swift @@ -39,11 +39,8 @@ public class MainViewModel: BaseViewModel { if url.host == "import-remote-profile" { var error: NSError? importRemoteProfile = LibboxParseRemoteProfileImportLink(url.absoluteString, &error) - if error != nil { - return - } - if selection != .dashboard { - selection = .dashboard + if let error { + alert = AlertState(error: error) } } else if url.pathExtension == "bpf" { Task { @@ -61,10 +58,6 @@ public class MainViewModel: BaseViewModel { url.stopAccessingSecurityScopedResource() } catch { alert = AlertState(error: error) - return - } - if selection != .dashboard { - selection = .dashboard } } diff --git a/MacLibrary/MenuView.swift b/MacLibrary/MenuView.swift index 454a638..dbeb91d 100644 --- a/MacLibrary/MenuView.swift +++ b/MacLibrary/MenuView.swift @@ -192,16 +192,12 @@ public struct MenuView: View { environments.selectedProfileUpdate.send() if profile.status.isConnected { do { - try await serviceReload() + try await profile.reloadService() } catch { alert = AlertState(error: error) } } reasserting = false } - - private nonisolated func serviceReload() async throws { - try LibboxNewStandaloneCommandClient()!.serviceReload() - } } } diff --git a/Makefile b/Makefile index d8ef546..87ce6e0 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ archive_ios: upload_ios: xcodebuild -exportArchive -archivePath build/SFI.xcarchive -exportOptionsPlist SFI/Upload.plist -allowProvisioningUpdates -release_maocs: archive_macos upload_macos +release_macos: archive_macos upload_macos archive_macos: xcodebuild archive -scheme SFM -configuration Release -archivePath build/SFM.xcarchive -allowProvisioningUpdates | xcbeautify | grep -A 10 -e "Archive Succeeded" -e " ARCHIVE FAILED" -e "❌" diff --git a/SFI/MainView.swift b/SFI/MainView.swift index e79d7aa..6be5d4c 100644 --- a/SFI/MainView.swift +++ b/SFI/MainView.swift @@ -123,6 +123,7 @@ struct MainView: View { environments.postReload() } .alert($alert) + .globalChecks() .onChangeCompat(of: scenePhase) { newValue in if newValue == .active { environments.postReload() @@ -185,10 +186,6 @@ struct MainView: View { importRemoteProfile = LibboxParseRemoteProfileImportLink(url.absoluteString, &error) if let error { alert = AlertState(error: error) - return - } - if selection != .dashboard { - selection = .dashboard } } else if url.pathExtension == "bpf" { do { @@ -197,10 +194,6 @@ struct MainView: View { url.stopAccessingSecurityScopedResource() } catch { alert = AlertState(error: error) - return - } - if selection != .dashboard { - selection = .dashboard } } else { alert = AlertState(errorMessage: String(localized: "Handled unknown URL \(url.absoluteString)")) diff --git a/SFM.System/Info.plist b/SFM.System/Info.plist index bd4fbeb..fcffb47 100644 --- a/SFM.System/Info.plist +++ b/SFM.System/Info.plist @@ -113,5 +113,13 @@ As a universal proxy platform, sing-box configures routing according to your configuration. NSCameraUsageDescription Camera access is required to scan QR codes for importing profiles. + NSLocationUsageDescription + sing-box uses the Location permission to provide users with routing based on WIFI SSID and BSSID rules, without reading your location. + NSLocationWhenInUseUsageDescription + sing-box uses the Location permission to provide users with routing based on WIFI SSID and BSSID rules, without reading your location. + NSLocationAlwaysUsageDescription + sing-box uses the Location permission to provide users with routing based on WIFI SSID and BSSID rules, without reading your location. + NSLocationAlwaysAndWhenInUseUsageDescription + sing-box uses the Location permission to provide users with routing based on WIFI SSID and BSSID rules, without reading your location. diff --git a/SFM.System/SFM.entitlements b/SFM.System/SFM.entitlements index d12ff84..cb7d832 100644 --- a/SFM.System/SFM.entitlements +++ b/SFM.System/SFM.entitlements @@ -20,17 +20,13 @@ iCloud.io.nekohasekai.sfavt - com.apple.security.app-sandbox - com.apple.security.application-groups $(TeamIdentifierPrefix)$(BASE_PACKAGE_IDENTIFIER) - com.apple.security.files.user-selected.read-write + com.apple.developer.networking.wifi-info - com.apple.security.network.client - - com.apple.security.device.camera + com.apple.security.personal-information.location diff --git a/SFM.System/StandaloneApplicationDelegate.swift b/SFM.System/StandaloneApplicationDelegate.swift index ad90f82..b05c660 100644 --- a/SFM.System/StandaloneApplicationDelegate.swift +++ b/SFM.System/StandaloneApplicationDelegate.swift @@ -1,5 +1,6 @@ import AppKit import Foundation +import Libbox import Library import MacLibrary @@ -7,6 +8,8 @@ class StandaloneApplicationDelegate: ApplicationDelegate { func applicationWillFinishLaunching(_: Notification) { Variant.useSystemExtension = true Variant.isBeta = false + LibboxSetXPCDialer(CommandXPCDialer.shared) + UserServiceEndpointPublisher.shared.start() Task { await setupSystemExtension() } diff --git a/SFT/MainView.swift b/SFT/MainView.swift index 22afea2..98f5516 100644 --- a/SFT/MainView.swift +++ b/SFT/MainView.swift @@ -7,6 +7,8 @@ struct MainView: View { @Environment(\.scenePhase) private var scenePhase @EnvironmentObject private var environments: ExtensionEnvironments @State private var selection = NavigationPage.dashboard + @State private var importProfile: LibboxProfileContent? + @State private var importRemoteProfile: LibboxImportRemoteProfile? var body: some View { TabView(selection: $selection) { @@ -32,6 +34,9 @@ struct MainView: View { environments.connect() } } + .globalChecks() .environment(\.selection, $selection) + .environment(\.importProfile, $importProfile) + .environment(\.importRemoteProfile, $importRemoteProfile) } } diff --git a/SystemExtension/PacketTunnelProvider.swift b/SystemExtension/PacketTunnelProvider.swift index abd8d68..8438dc3 100644 --- a/SystemExtension/PacketTunnelProvider.swift +++ b/SystemExtension/PacketTunnelProvider.swift @@ -1,30 +1,4 @@ -import Libbox import Library import NetworkExtension -import System -class PacketTunnelProvider: ExtensionProvider { - override func startTunnel(options: [String: NSObject]?) async throws { - guard let usernameObject = options?["username"] else { - throw ExtensionStartupError("missing start options") - } - let username = usernameObject as! NSString - FilePath.sharedDirectory = URL(filePath: "/Users/\(username)/Library/Group Containers/\(FilePath.groupName)") - FilePath.iCloudDirectory = URL(filePath: "/Users/\(username)/Library/Mobile Documents/iCloud~\(FilePath.packageName.replacingOccurrences(of: ".", with: "~"))").appendingPathComponent("Documents", isDirectory: true) - let databasePath = FilePath.sharedDirectory.appendingPathComponent("settings.db").relativePath - if !FileManager.default.isReadableFile(atPath: databasePath) { - do { - let fd = try FileDescriptor.open(databasePath, .readOnly) - try! fd.close() - NSLog("Can access \(databasePath)") - } catch { - NSLog("Can't access \(databasePath): \(error.localizedDescription)") - } - try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 100) - throw FullDiskAccessPermissionRequired.error - } - - self.username = String(username) - try await super.startTunnel(options: options) - } -} +class PacketTunnelProvider: ExtensionProvider {} diff --git a/SystemExtension/SystemExtension.entitlements b/SystemExtension/SystemExtension.entitlements index 6d43459..f567670 100644 --- a/SystemExtension/SystemExtension.entitlements +++ b/SystemExtension/SystemExtension.entitlements @@ -8,11 +8,11 @@ packet-tunnel-provider-systemextension + com.apple.security.app-sandbox + com.apple.security.application-groups $(TeamIdentifierPrefix)$(BASE_PACKAGE_IDENTIFIER) - com.apple.security.app-sandbox - diff --git a/sing-box.xcodeproj/project.pbxproj b/sing-box.xcodeproj/project.pbxproj index c42dec6..419fb77 100644 --- a/sing-box.xcodeproj/project.pbxproj +++ b/sing-box.xcodeproj/project.pbxproj @@ -42,6 +42,9 @@ 3AE396072C21A60C00647718 /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; }; 3AE4D0BD2A6E2DDC009FEA9E /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; }; 3AE4D0BE2A6E2DDC009FEA9E /* Library.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 3AE595822F08C3D400C13426 /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; }; + 3AE595AA2F08C61600C13426 /* RootHelper in Copy Helpers */ = {isa = PBXBuildFile; fileRef = 3AE595712F08C34000C13426 /* RootHelper */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + 3AE595B02F08D00000C13426 /* HelperService/LaunchDaemons/io.nekohasekai.sfavt.helper.plist in Copy LaunchDaemon */ = {isa = PBXBuildFile; fileRef = 3AE595AE2F08D00000C13426 /* HelperService/LaunchDaemons/io.nekohasekai.sfavt.helper.plist */; }; 3AEAEE992A4F16430059612D /* Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3A096F862A4ED3DE00D4A2ED /* Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3AEECBF22A6DF40A006A0E0C /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AF342B12A4AA520002B34AC /* NetworkExtension.framework */; }; 3AEECC1A2A6DFA79006A0E0C /* io.nekohasekai.sfavt.system.systemextension in Embed System Extensions */ = {isa = PBXBuildFile; fileRef = 3AEECBF12A6DF40A006A0E0C /* io.nekohasekai.sfavt.system.systemextension */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -183,6 +186,20 @@ remoteGlobalIDString = 3AEC211C2A459B4700A63465; remoteInfo = Library; }; + 3AE595842F08C3D400C13426 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3AEC20BD2A45991900A63465 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 3AEC211C2A459B4700A63465; + remoteInfo = Library; + }; + 3AE595A52F08C5C700C13426 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3AEC20BD2A45991900A63465 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 3AE595702F08C34000C13426; + remoteInfo = RootHelper; + }; 3AEAEE9A2A4F16430059612D /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 3AEC20BD2A45991900A63465 /* Project object */; @@ -343,6 +360,37 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; + 3AE5956F2F08C34000C13426 /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = /usr/share/man/man1/; + dstSubfolderSpec = 0; + files = ( + ); + runOnlyForDeploymentPostprocessing = 1; + }; + 3AE595A92F08C5E200C13426 /* Copy Helpers */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ../Helpers; + dstSubfolderSpec = 6; + files = ( + 3AE595AA2F08C61600C13426 /* RootHelper in Copy Helpers */, + ); + name = "Copy Helpers"; + runOnlyForDeploymentPostprocessing = 0; + }; + 3AE595AC2F08C63000C13426 /* Copy LaunchDaemon */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ../Library/LaunchDaemons; + dstSubfolderSpec = 7; + files = ( + 3AE595B02F08D00000C13426 /* HelperService/LaunchDaemons/io.nekohasekai.sfavt.helper.plist in Copy LaunchDaemon */, + ); + name = "Copy LaunchDaemon"; + runOnlyForDeploymentPostprocessing = 0; + }; 3AEECC212A6DFA79006A0E0C /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -384,6 +432,11 @@ 3AE395F22C21A5CA00647718 /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 3AE395F32C21A5CA00647718 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; 3AE395F52C21A5CA00647718 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 3AE595712F08C34000C13426 /* RootHelper */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = RootHelper; sourceTree = BUILT_PRODUCTS_DIR; }; + 3AE595A12F08C4E900C13426 /* CoreWLAN.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreWLAN.framework; path = System/Library/Frameworks/CoreWLAN.framework; sourceTree = SDKROOT; }; + 3AE595A32F08C4ED00C13426 /* UserNotifications.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotifications.framework; path = System/Library/Frameworks/UserNotifications.framework; sourceTree = SDKROOT; }; + 3AE595AE2F08D00000C13426 /* HelperService/LaunchDaemons/io.nekohasekai.sfavt.helper.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = HelperService/LaunchDaemons/io.nekohasekai.sfavt.helper.plist; sourceTree = ""; }; + 3AE595AF2F08D00000C13426 /* UserHelper/LaunchAgents/io.nekohasekai.sfavt.userhelper.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = UserHelper/LaunchAgents/io.nekohasekai.sfavt.userhelper.plist; sourceTree = ""; }; 3AEC20DB2A4599D000A63465 /* Libbox.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = Libbox.xcframework; sourceTree = ""; }; 3AEC20F32A459AB400A63465 /* sing-box.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "sing-box.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 3AEC21092A459B1900A63465 /* sing-box.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "sing-box.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -545,6 +598,7 @@ 3ADDCFC62E8B72B4009ACE1D /* IntentsExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3ADDCFC92E8B72B4009ACE1D /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = IntentsExtension; sourceTree = ""; }; 3ADDCFCD2E8B72B8009ACE1D /* TVExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3ADDCFCF2E8B72B8009ACE1D /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = TVExtension; sourceTree = ""; }; 3ADDCFD52E8B72BC009ACE1D /* WidgetExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3ADDCFDA2E8B72BC009ACE1D /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = WidgetExtension; sourceTree = ""; }; + 3AE595722F08C34000C13426 /* HelperService */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = HelperService; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -611,6 +665,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 3AE5956E2F08C34000C13426 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 3AE595822F08C3D400C13426 /* Library.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 3AEC20F02A459AB400A63465 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -676,6 +738,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 3AE5C9042F08E53400C13426 /* Recovered References */ = { + isa = PBXGroup; + children = ( + 3AE595AE2F08D00000C13426 /* HelperService/LaunchDaemons/io.nekohasekai.sfavt.helper.plist */, + 3AE595AF2F08D00000C13426 /* UserHelper/LaunchAgents/io.nekohasekai.sfavt.userhelper.plist */, + ); + name = "Recovered References"; + sourceTree = ""; + }; 3AEC20BC2A45991900A63465 = { isa = PBXGroup; children = ( @@ -694,8 +765,10 @@ 3ADDCFCD2E8B72B8009ACE1D /* TVExtension */, 3ADDCFD52E8B72BC009ACE1D /* WidgetExtension */, 3AAAFB232EF5218F004C69AD /* FileProviderExtension */, + 3AE595722F08C34000C13426 /* HelperService */, 3AEC20C72A45991900A63465 /* Products */, 3AEC21012A459AE300A63465 /* Frameworks */, + 3AE5C9042F08E53400C13426 /* Recovered References */, ); sourceTree = ""; }; @@ -715,6 +788,7 @@ 3AE171992A8128DD00393060 /* TVExtension.appex */, 3AE395F22C21A5CA00647718 /* WidgetExtension.appex */, 3AAAFB202EF5218F004C69AD /* FileProviderExtension.appex */, + 3AE595712F08C34000C13426 /* RootHelper */, ); name = Products; sourceTree = ""; @@ -722,6 +796,8 @@ 3AEC21012A459AE300A63465 /* Frameworks */ = { isa = PBXGroup; children = ( + 3AE595A32F08C4ED00C13426 /* UserNotifications.framework */, + 3AE595A12F08C4E900C13426 /* CoreWLAN.framework */, 3AAAFE162EF52772004C69AD /* FileProvider.framework */, 3AC72A2E2EED94DD0039DEA4 /* SystemConfiguration.framework */, 3AC72A302EED94F60039DEA4 /* SystemConfiguration.framework */, @@ -909,6 +985,29 @@ productReference = 3AE395F22C21A5CA00647718 /* WidgetExtension.appex */; productType = "com.apple.product-type.app-extension"; }; + 3AE595702F08C34000C13426 /* RootHelper */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3AE595752F08C34000C13426 /* Build configuration list for PBXNativeTarget "RootHelper" */; + buildPhases = ( + 3AE5956D2F08C34000C13426 /* Sources */, + 3AE5956E2F08C34000C13426 /* Frameworks */, + 3AE5956F2F08C34000C13426 /* CopyFiles */, + ); + buildRules = ( + ); + dependencies = ( + 3AE595852F08C3D400C13426 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 3AE595722F08C34000C13426 /* HelperService */, + ); + name = RootHelper; + packageProductDependencies = ( + ); + productName = RootHelper; + productReference = 3AE595712F08C34000C13426 /* RootHelper */; + productType = "com.apple.product-type.tool"; + }; 3AEC20F22A459AB400A63465 /* SFI */ = { isa = PBXNativeTarget; buildConfigurationList = 3AEC20FE2A459AB500A63465 /* Build configuration list for PBXNativeTarget "SFI" */; @@ -1023,10 +1122,13 @@ 3AEECC022A6DF9CA006A0E0C /* Resources */, 3AEECC212A6DFA79006A0E0C /* Embed Frameworks */, 3AEECC232A6DFA79006A0E0C /* Embed System Extensions */, + 3AE595A92F08C5E200C13426 /* Copy Helpers */, + 3AE595AC2F08C63000C13426 /* Copy LaunchDaemon */, ); buildRules = ( ); dependencies = ( + 3AE595A62F08C5C700C13426 /* PBXTargetDependency */, 3AEECC1C2A6DFA79006A0E0C /* PBXTargetDependency */, 3AEECC202A6DFA79006A0E0C /* PBXTargetDependency */, 3AEECC3D2A6DFDC6006A0E0C /* PBXTargetDependency */, @@ -1097,6 +1199,9 @@ 3AE395F12C21A5CA00647718 = { CreatedOnToolsVersion = 16.0; }; + 3AE595702F08C34000C13426 = { + CreatedOnToolsVersion = 26.2; + }; 3AEC20F22A459AB400A63465 = { CreatedOnToolsVersion = 15.0; }; @@ -1155,6 +1260,7 @@ 3A77016C2A4E6B34008F031F /* IntentsExtension */, 3AE395F12C21A5CA00647718 /* WidgetExtension */, 3AAAFB1F2EF5218F004C69AD /* FileProviderExtension */, + 3AE595702F08C34000C13426 /* RootHelper */, ); }; /* End PBXProject section */ @@ -1296,6 +1402,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 3AE5956D2F08C34000C13426 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 3AEC20EF2A459AB400A63465 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -1426,6 +1539,16 @@ target = 3AEC211C2A459B4700A63465 /* Library */; targetProxy = 3AE4D0BA2A6E2C55009FEA9E /* PBXContainerItemProxy */; }; + 3AE595852F08C3D400C13426 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 3AEC211C2A459B4700A63465 /* Library */; + targetProxy = 3AE595842F08C3D400C13426 /* PBXContainerItemProxy */; + }; + 3AE595A62F08C5C700C13426 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 3AE595702F08C34000C13426 /* RootHelper */; + targetProxy = 3AE595A52F08C5C700C13426 /* PBXContainerItemProxy */; + }; 3AEAEE9B2A4F16430059612D /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 3A096F852A4ED3DE00D4A2ED /* Extension */; @@ -1980,6 +2103,86 @@ }; name = Release; }; + 3AE595762F08C34000C13426 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APP_GROUP_IDENTIFIER = "$(TeamIdentifierPrefix)$(BASE_PACKAGE_IDENTIFIER)"; + AUTOMATION_APPLE_EVENTS = NO; + CODE_SIGN_ENTITLEMENTS = HelperService/RootHelper.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CREATE_INFOPLIST_SECTION_IN_BINARY = YES; + DEVELOPMENT_TEAM = 287TTNZF8L; + ENABLE_HARDENED_RUNTIME = NO; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; + INFOPLIST_FILE = HelperService/Info.plist; + INFOPLIST_KEY_WKSupportsLiveActivityLaunchAttributeTypes = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 13.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(BASE_PACKAGE_IDENTIFIER).helper"; + PRODUCT_NAME = "$(TARGET_NAME)"; + RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; + RUNTIME_EXCEPTION_ALLOW_JIT = NO; + RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; + RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; + RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; + RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 3AE595772F08C34000C13426 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APP_GROUP_IDENTIFIER = "$(TeamIdentifierPrefix)$(BASE_PACKAGE_IDENTIFIER)"; + AUTOMATION_APPLE_EVENTS = NO; + CODE_SIGN_ENTITLEMENTS = HelperService/RootHelper.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CREATE_INFOPLIST_SECTION_IN_BINARY = YES; + DEVELOPMENT_TEAM = 287TTNZF8L; + ENABLE_HARDENED_RUNTIME = NO; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; + INFOPLIST_FILE = HelperService/Info.plist; + INFOPLIST_KEY_WKSupportsLiveActivityLaunchAttributeTypes = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 13.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(BASE_PACKAGE_IDENTIFIER).helper"; + PRODUCT_NAME = "$(TARGET_NAME)"; + RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; + RUNTIME_EXCEPTION_ALLOW_JIT = NO; + RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; + RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; + RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; + RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; 3AEC20CB2A45991900A63465 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -2379,7 +2582,18 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=macosx*]" = 287TTNZF8L; + ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SystemExtension/Info.plist; @@ -2416,7 +2630,18 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=macosx*]" = 287TTNZF8L; + ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SystemExtension/Info.plist; @@ -2669,6 +2894,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 3AE595752F08C34000C13426 /* Build configuration list for PBXNativeTarget "RootHelper" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3AE595762F08C34000C13426 /* Debug */, + 3AE595772F08C34000C13426 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 3AEC20C02A45991900A63465 /* Build configuration list for PBXProject "sing-box" */ = { isa = XCConfigurationList; buildConfigurations = (