diff --git a/ApplicationLibrary/Views/Dashboard/ClashModeView.swift b/ApplicationLibrary/Views/Dashboard/ClashModeView.swift new file mode 100644 index 0000000..448018c --- /dev/null +++ b/ApplicationLibrary/Views/Dashboard/ClashModeView.swift @@ -0,0 +1,58 @@ +import Libbox +import Library +import SwiftUI + +public struct ClashModeView: View { + @Environment(\.scenePhase) private var scenePhase + @StateObject private var commandClient = CommandClient(.clashMode) + @State private var clashMode = "" + @State private var alert: Alert? + + public init() {} + public var body: some View { + VStack { + if commandClient.clashModeList.count > 1 { + Picker("", selection: Binding(get: { + clashMode + }, set: { newMode in + clashMode = newMode + Task.detached { + await setMode(newMode) + } + }), content: { + ForEach(commandClient.clashModeList, id: \.self) { it in + Text(it) + } + }) + .pickerStyle(.segmented) + .padding([.top], 8) + } + } + .onReceive(commandClient.$clashMode) { newMode in + clashMode = newMode + } + .padding([.leading, .trailing]) + .onAppear { + commandClient.connect() + } + .onDisappear { + commandClient.disconnect() + } + .onChangeCompat(of: scenePhase) { newValue in + if newValue == .active { + commandClient.connect() + } else { + commandClient.disconnect() + } + } + .alertBinding($alert) + } + + private func setMode(_ newMode: String) { + do { + try LibboxNewStandaloneCommandClient()!.setClashMode(newMode) + } catch { + alert = Alert(error) + } + } +} diff --git a/ApplicationLibrary/Views/Dashboard/ExtensionStatusView.swift b/ApplicationLibrary/Views/Dashboard/ExtensionStatusView.swift index a1f2345..689f5de 100644 --- a/ApplicationLibrary/Views/Dashboard/ExtensionStatusView.swift +++ b/ApplicationLibrary/Views/Dashboard/ExtensionStatusView.swift @@ -3,6 +3,7 @@ import Library import SwiftUI public struct ExtensionStatusView: View { + @Environment(\.scenePhase) private var scenePhase @StateObject private var commandClient = CommandClient(.status) @State private var columnCount: Int = 4 @State private var alert: Alert? @@ -83,6 +84,13 @@ public struct ExtensionStatusView: View { .onDisappear { commandClient.disconnect() } + .onChangeCompat(of: scenePhase) { newValue in + if newValue == .active { + commandClient.connect() + } else { + commandClient.disconnect() + } + } .alertBinding($alert) } diff --git a/ApplicationLibrary/Views/Dashboard/OverviewView.swift b/ApplicationLibrary/Views/Dashboard/OverviewView.swift index b377ae9..9afef4c 100644 --- a/ApplicationLibrary/Views/Dashboard/OverviewView.swift +++ b/ApplicationLibrary/Views/Dashboard/OverviewView.swift @@ -6,7 +6,6 @@ import SwiftUI public struct OverviewView: View { public static let NotificationUpdateSelectedProfile = Notification.Name("update-selected-profile") - @Environment(\.scenePhase) var scenePhase @Environment(\.selection) private var selection @EnvironmentObject private var profile: ExtensionProfile @Binding private var profileList: [Profile] @@ -24,6 +23,7 @@ public struct OverviewView: View { VStack { if ApplicationLibrary.inPreview || profile.status.isConnected { ExtensionStatusView() + ClashModeView() } if profileList.isEmpty { Text("Empty profiles") diff --git a/ApplicationLibrary/Views/Groups/GroupListView.swift b/ApplicationLibrary/Views/Groups/GroupListView.swift index 40e3744..9a46d64 100644 --- a/ApplicationLibrary/Views/Groups/GroupListView.swift +++ b/ApplicationLibrary/Views/Groups/GroupListView.swift @@ -3,6 +3,7 @@ import Library import SwiftUI public struct GroupListView: View { + @Environment(\.scenePhase) private var scenePhase @State private var isLoading = true @StateObject private var commandClient = CommandClient(.groups) @State private var groups: [OutboundGroup] = [] @@ -31,6 +32,13 @@ public struct GroupListView: View { .onDisappear { commandClient.disconnect() } + .onChangeCompat(of: scenePhase) { newValue in + if newValue == .active { + commandClient.connect() + } else { + commandClient.disconnect() + } + } .onReceive(commandClient.$groups, perform: { groups in if let groups { setGroups(groups) diff --git a/ApplicationLibrary/Views/Setting/SettingView.swift b/ApplicationLibrary/Views/Setting/SettingView.swift index edcce66..ce4445a 100644 --- a/ApplicationLibrary/Views/Setting/SettingView.swift +++ b/ApplicationLibrary/Views/Setting/SettingView.swift @@ -20,6 +20,7 @@ public struct SettingView: View { @State private var keepMenuBarInBackground = false #endif + @State private var alwaysOn = false @State private var disableMemoryLimit = false @State private var version = "" @State private var dataSize = "" @@ -66,6 +67,13 @@ public struct SettingView: View { } #endif Section("Packet Tunnel") { + Toggle("Always On", isOn: $alwaysOn) + .onChangeCompat(of: alwaysOn) { newValue in + Task.detached { + SharedPreferences.alwaysOn = newValue + await updateAlwaysOn(newValue) + } + } Toggle("Disable Memory Limit", isOn: $disableMemoryLimit) .onChangeCompat(of: disableMemoryLimit) { newValue in Task.detached { @@ -155,6 +163,7 @@ public struct SettingView: View { startAtLogin = SMAppService.mainApp.status == .enabled keepMenuBarInBackground = SharedPreferences.menuBarExtraInBackground #endif + alwaysOn = SharedPreferences.alwaysOn disableMemoryLimit = SharedPreferences.disableMemoryLimit version = LibboxVersion() if ApplicationLibrary.inPreview { @@ -173,6 +182,17 @@ public struct SettingView: View { try? FileManager.default.removeItem(at: FilePath.workingDirectory) isLoading = true } + + private func updateAlwaysOn(_ newState: Bool) async { + guard let profile = try? await ExtensionProfile.load() else { + return + } + do { + try await profile.updateAlwaysOn(newState) + } catch { + alert = Alert(error) + } + } } private extension URL { diff --git a/Extension/Info.plist b/Extension/Info.plist index a188818..3059459 100644 --- a/Extension/Info.plist +++ b/Extension/Info.plist @@ -2,10 +2,6 @@ - UIRequiredDeviceCapabilities - - arm64 - NSExtension NSExtensionPointIdentifier diff --git a/Library/Database/SharedPreferences.swift b/Library/Database/SharedPreferences.swift index fb40cc9..89c3dd5 100644 --- a/Library/Database/SharedPreferences.swift +++ b/Library/Database/SharedPreferences.swift @@ -11,6 +11,7 @@ public enum SharedPreferences { @Preference("disable_memory_limit", defaultValue: disableMemoryLimitByDefault) public static var disableMemoryLimit @Preference("max_log_lines", defaultValue: 300) public static var maxLogLines + @Preference("always_on", defaultValue: false) public static var alwaysOn #if os(macOS) @Preference("show_menu_bar_extra", defaultValue: true) public static var showMenuBarExtra diff --git a/Library/Network/CommandClient.swift b/Library/Network/CommandClient.swift index da948c9..c47c5da 100644 --- a/Library/Network/CommandClient.swift +++ b/Library/Network/CommandClient.swift @@ -6,6 +6,7 @@ public class CommandClient: ObservableObject { case status case groups case log + case clashMode } private let connectionType: ConnectionType @@ -17,11 +18,15 @@ public class CommandClient: ObservableObject { @Published public var status: LibboxStatusMessage? @Published public var groups: [LibboxOutboundGroup]? @Published public var logList: [String] + @Published public var clashModeList: [String] + @Published public var clashMode: String public init(_ connectionType: ConnectionType, logMaxLines: Int = 300) { self.connectionType = connectionType self.logMaxLines = logMaxLines logList = [] + clashModeList = [] + clashMode = "" isConnected = false } @@ -57,6 +62,8 @@ public class CommandClient: ObservableObject { clientOptions.command = LibboxCommandGroup case .log: clientOptions.command = LibboxCommandLog + case .clashMode: + clientOptions.command = LibboxCommandClashMode } clientOptions.statusInterval = Int64(2 * NSEC_PER_SEC) let client = LibboxNewCommandClient(clientHandler(self), clientOptions)! @@ -68,6 +75,7 @@ public class CommandClient: ObservableObject { try client.connect() commandClient = client return + } catch { } try Task.checkCancellation() } @@ -127,5 +135,18 @@ public class CommandClient: ObservableObject { self.commandClient.groups = newGroups } } + + func initializeClashMode(_ modeList: LibboxStringIteratorProtocol?, currentMode: String?) { + DispatchQueue.main.sync { + self.commandClient.clashModeList = modeList!.toArray() + self.commandClient.clashMode = currentMode! + } + } + + func updateClashMode(_ newMode: String?) { + DispatchQueue.main.sync { + self.commandClient.clashMode = newMode! + } + } } } diff --git a/Library/Network/ExtensionPlatformInterface.swift b/Library/Network/ExtensionPlatformInterface.swift index c26732b..372d463 100644 --- a/Library/Network/ExtensionPlatformInterface.swift +++ b/Library/Network/ExtensionPlatformInterface.swift @@ -5,6 +5,7 @@ import NetworkExtension public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol { private let tunnel: NEPacketTunnelProvider private let commandServer: LibboxCommandServer + private var networkSettings: NEPacketTunnelNetworkSettings? init(_ tunnel: NEPacketTunnelProvider, _ logServer: LibboxCommandServer) { self.tunnel = tunnel @@ -88,6 +89,7 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc settings.proxySettings = proxySettings } + networkSettings = settings try runBlocking { [self] in try await tunnel.setTunnelNetworkSettings(settings) } @@ -153,4 +155,21 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc public func underNetworkExtension() -> Bool { true } + + public func clearDNSCache() { + guard let networkSettings else { + return + } + tunnel.reasserting = true + tunnel.setTunnelNetworkSettings(nil) { _ in + } + tunnel.setTunnelNetworkSettings(networkSettings) { _ in + } + tunnel.reasserting = false + } + + public func closeTun() throws { + tunnel.setTunnelNetworkSettings(nil) { _ in + } + } } diff --git a/Library/Network/ExtensionProfile.swift b/Library/Network/ExtensionProfile.swift index 6cbf2ed..7fe3503 100644 --- a/Library/Network/ExtensionProfile.swift +++ b/Library/Network/ExtensionProfile.swift @@ -38,8 +38,26 @@ public class ExtensionProfile: ObservableObject { } } + private func setOnDemandRules() { + let interfaceRule = NEOnDemandRuleConnect() + interfaceRule.interfaceTypeMatch = .any + let probeRule = NEOnDemandRuleConnect() + probeRule.probeURL = URL(string: "http://captive.apple.com") + manager.onDemandRules = [interfaceRule, probeRule] + } + + public func updateAlwaysOn(_ newState: Bool) async throws { + manager.isOnDemandEnabled = newState + setOnDemandRules() + try await manager.saveToPreferences() + } + public func start() async throws { manager.isEnabled = true + if SharedPreferences.alwaysOn { + manager.isOnDemandEnabled = true + setOnDemandRules() + } try await manager.saveToPreferences() #if os(macOS) if Variant.useSystemExtension { @@ -53,6 +71,7 @@ public class ExtensionProfile: ObservableObject { } public func stop() { + manager.isOnDemandEnabled = false manager.connection.stopVPNTunnel() } diff --git a/sing-box.xcodeproj/project.pbxproj b/sing-box.xcodeproj/project.pbxproj index 635ee6b..bae08a9 100644 --- a/sing-box.xcodeproj/project.pbxproj +++ b/sing-box.xcodeproj/project.pbxproj @@ -51,6 +51,7 @@ 3A4EAD362A4FEB9C005435B3 /* ProfileUpdateTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A55F9592A4D1554003C4EF4 /* ProfileUpdateTask.swift */; }; 3A4EAD372A4FEC20005435B3 /* ApplicationLibrary.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A4EAD102A4FEAE6005435B3 /* ApplicationLibrary.framework */; }; 3A4EAD3C2A4FECCE005435B3 /* NEVPNStatus+isConnected.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A4EAD3B2A4FECCE005435B3 /* NEVPNStatus+isConnected.swift */; }; + 3A4F68B02A97602C003D66D3 /* ClashModeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A4F68AF2A97602C003D66D3 /* ClashModeView.swift */; }; 3A4FB1572A73467F007012B9 /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; }; 3A4FB1582A73467F007012B9 /* Library.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3A4FB15C2A73468C007012B9 /* ApplicationLibrary.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A4EAD102A4FEAE6005435B3 /* ApplicationLibrary.framework */; }; @@ -447,6 +448,7 @@ 3A4EAD102A4FEAE6005435B3 /* ApplicationLibrary.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ApplicationLibrary.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3A4EAD202A4FEB3C005435B3 /* ApplicationLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationLibrary.swift; sourceTree = ""; }; 3A4EAD3B2A4FECCE005435B3 /* NEVPNStatus+isConnected.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NEVPNStatus+isConnected.swift"; sourceTree = ""; }; + 3A4F68AF2A97602C003D66D3 /* ClashModeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClashModeView.swift; sourceTree = ""; }; 3A4FB1642A73568E007012B9 /* SFT.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SFT.entitlements; sourceTree = ""; }; 3A4FB1652A73574B007012B9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 3A4FB1672A7358C9007012B9 /* ApplicationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationDelegate.swift; sourceTree = ""; }; @@ -963,6 +965,7 @@ 3AE4D0C02A6E4852009FEA9E /* InstallSystemExtensionButton.swift */, 3A0C6D3B2A79D46500A4DF2B /* OverviewView.swift */, 3A0C6D3D2A79D6A600A4DF2B /* DashboardPage.swift */, + 3A4F68AF2A97602C003D66D3 /* ClashModeView.swift */, ); path = Dashboard; sourceTree = ""; @@ -1432,6 +1435,7 @@ 3A4EAD242A4FEB65005435B3 /* InstallProfileButton.swift in Sources */, 3AE4D0C12A6E4852009FEA9E /* InstallSystemExtensionButton.swift in Sources */, 3A4EAD232A4FEB5A005435B3 /* EnvironmentValues.swift in Sources */, + 3A4F68B02A97602C003D66D3 /* ClashModeView.swift in Sources */, 3A4EAD282A4FEB65005435B3 /* ActiveDashboardView.swift in Sources */, 3A1CF2F02A50E5EE000A8289 /* GroupListView.swift in Sources */, 3A4EAD322A4FEB7B005435B3 /* ServiceLogView.swift in Sources */, @@ -1699,6 +1703,7 @@ INFOPLIST_FILE = Extension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Extension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_UIRequiredDeviceCapabilities = arm64; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -1738,6 +1743,7 @@ INFOPLIST_FILE = Extension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Extension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_UIRequiredDeviceCapabilities = arm64; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -2017,13 +2023,13 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; - SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator"; + SUPPORTED_PLATFORMS = "appletvos appletvsimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,3"; + TARGETED_DEVICE_FAMILY = 3; }; name = Debug; }; @@ -2049,13 +2055,13 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; - SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator"; + SUPPORTED_PLATFORMS = "appletvos appletvsimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,3"; + TARGETED_DEVICE_FAMILY = 3; VALIDATE_PRODUCT = YES; }; name = Release; @@ -2475,7 +2481,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.4.0-beta.5"; + MARKETING_VERSION = 1.4.0; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfa.system; PRODUCT_NAME = "$(inherited)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2511,7 +2517,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.4.0-beta.5"; + MARKETING_VERSION = 1.4.0; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfa.system; PRODUCT_NAME = "$(inherited)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2551,7 +2557,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.4.0-beta.6"; + MARKETING_VERSION = 1.4.0; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfa.independent; PRODUCT_NAME = SFM; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2590,7 +2596,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = "1.4.0-beta.6"; + MARKETING_VERSION = 1.4.0; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfa.independent; PRODUCT_NAME = SFM; PROVISIONING_PROFILE_SPECIFIER = "";