Add custom on-demand rules support
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public class ExtensionEnvironments: ObservableObject {
|
||||
@Published public var commandClient = CommandClient([.log, .status, .groups, .clashMode, .connections])
|
||||
@Published public var extensionProfileLoading = true
|
||||
@@ -13,8 +14,10 @@ public class ExtensionEnvironments: ObservableObject {
|
||||
|
||||
public init() {}
|
||||
|
||||
deinit {
|
||||
commandClient.disconnect()
|
||||
nonisolated deinit {
|
||||
Task { @MainActor in
|
||||
commandClient.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
public func postReload() {
|
||||
@@ -23,7 +26,6 @@ public class ExtensionEnvironments: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func reload() async {
|
||||
if let newProfile = try? await ExtensionProfile.load() {
|
||||
if extensionProfile == nil || extensionProfile?.status == .invalid {
|
||||
|
||||
@@ -248,12 +248,12 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc
|
||||
}
|
||||
|
||||
private func onUpdateDefaultInterface(_ listener: LibboxInterfaceUpdateListenerProtocol, _ path: Network.NWPath) {
|
||||
if path.status == .unsatisfied {
|
||||
guard path.status != .unsatisfied,
|
||||
let defaultInterface = path.availableInterfaces.first else {
|
||||
listener.updateDefaultInterface("", interfaceIndex: -1, isExpensive: false, isConstrained: false)
|
||||
} else {
|
||||
let defaultInterface = path.availableInterfaces.first!
|
||||
listener.updateDefaultInterface(defaultInterface.name, interfaceIndex: Int32(defaultInterface.index), isExpensive: path.isExpensive, isConstrained: path.isConstrained)
|
||||
return
|
||||
}
|
||||
listener.updateDefaultInterface(defaultInterface.name, interfaceIndex: Int32(defaultInterface.index), isExpensive: path.isExpensive, isConstrained: path.isConstrained)
|
||||
}
|
||||
|
||||
public func closeDefaultInterfaceMonitor(_: LibboxInterfaceUpdateListenerProtocol?) throws {
|
||||
@@ -356,6 +356,18 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc
|
||||
#endif
|
||||
}
|
||||
|
||||
public func readWIFISSID() -> String? {
|
||||
#if os(iOS)
|
||||
return runBlocking {
|
||||
await NEHotspotNetwork.fetchCurrent()?.ssid
|
||||
}
|
||||
#elseif os(macOS)
|
||||
return CWWiFiClient.shared().interface()?.ssid()
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
|
||||
public func serviceStop() throws {
|
||||
tunnel.stopService()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import Foundation
|
||||
import Libbox
|
||||
import NetworkExtension
|
||||
|
||||
@MainActor
|
||||
public class ExtensionProfile: ObservableObject {
|
||||
public static let controlKind = "io.nekohasekai.sfavt.widget.ServiceToggle"
|
||||
|
||||
@@ -28,9 +29,12 @@ public class ExtensionProfile: ObservableObject {
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
self.connection = notification.object as! NEVPNConnection
|
||||
self.status = self.connection.status
|
||||
self.connectedDate = self.connection.connectedDate
|
||||
guard let connection = notification.object as? NEVPNConnection else {
|
||||
return
|
||||
}
|
||||
self.connection = connection
|
||||
self.status = connection.status
|
||||
self.connectedDate = connection.connectedDate
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,17 +44,31 @@ 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]
|
||||
nonisolated deinit {
|
||||
if let observer {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
public func updateAlwaysOn(_ newState: Bool) async throws {
|
||||
manager.isOnDemandEnabled = newState
|
||||
setOnDemandRules()
|
||||
private static func makeDefaultOnDemandRules() -> [NEOnDemandRule] {
|
||||
let rule = NEOnDemandRuleConnect()
|
||||
rule.interfaceTypeMatch = .any
|
||||
rule.probeURL = URL(string: "http://captive.apple.com")
|
||||
return [rule]
|
||||
}
|
||||
|
||||
private func setOnDemandRules(useDefaultRules: Bool) async {
|
||||
if useDefaultRules {
|
||||
manager.onDemandRules = Self.makeDefaultOnDemandRules()
|
||||
} else {
|
||||
let rules = await SharedPreferences.onDemandRules.get()
|
||||
manager.onDemandRules = rules.isEmpty ? Self.makeDefaultOnDemandRules() : rules.map { $0.toNERule() }
|
||||
}
|
||||
}
|
||||
|
||||
public func updateOnDemand(enabled: Bool, useDefaultRules: Bool) async throws {
|
||||
manager.isOnDemandEnabled = enabled
|
||||
await setOnDemandRules(useDefaultRules: useDefaultRules)
|
||||
try await manager.saveToPreferences()
|
||||
}
|
||||
|
||||
@@ -62,9 +80,11 @@ public class ExtensionProfile: ObservableObject {
|
||||
public func start() async throws {
|
||||
await fetchProfile()
|
||||
manager.isEnabled = true
|
||||
if await SharedPreferences.alwaysOn.get() {
|
||||
let alwaysOn = await SharedPreferences.alwaysOn.get()
|
||||
let onDemandEnabled = await SharedPreferences.onDemandEnabled.get()
|
||||
if alwaysOn || onDemandEnabled {
|
||||
manager.isOnDemandEnabled = true
|
||||
setOnDemandRules()
|
||||
await setOnDemandRules(useDefaultRules: alwaysOn)
|
||||
}
|
||||
#if !os(tvOS)
|
||||
if let protocolConfiguration = manager.protocolConfiguration {
|
||||
@@ -80,11 +100,14 @@ public class ExtensionProfile: ObservableObject {
|
||||
if Variant.useSystemExtension {
|
||||
try manager.connection.startVPNTunnel(options: [
|
||||
"username": NSString(string: NSUserName()),
|
||||
"manualStart": NSNumber(value: true),
|
||||
])
|
||||
return
|
||||
}
|
||||
#endif
|
||||
try manager.connection.startVPNTunnel()
|
||||
try manager.connection.startVPNTunnel(options: [
|
||||
"manualStart": NSNumber(value: true),
|
||||
])
|
||||
}
|
||||
|
||||
public func fetchProfile() async {
|
||||
@@ -94,7 +117,9 @@ public class ExtensionProfile: ObservableObject {
|
||||
_ = try profile.read()
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
NSLog("fetchProfile error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
public func stop() async throws {
|
||||
@@ -104,14 +129,16 @@ public class ExtensionProfile: ObservableObject {
|
||||
}
|
||||
do {
|
||||
try LibboxNewStandaloneCommandClient()!.serviceClose()
|
||||
} catch {}
|
||||
} catch {
|
||||
NSLog("serviceClose error: \(error.localizedDescription)")
|
||||
}
|
||||
manager.connection.stopVPNTunnel()
|
||||
}
|
||||
|
||||
public func restart() async throws {
|
||||
try await stop()
|
||||
var waitSeconds = 0
|
||||
while await MainActor.run(body: { status }) != .disconnected {
|
||||
while status != .disconnected {
|
||||
try await Task.sleep(nanoseconds: NSEC_PER_SEC)
|
||||
waitSeconds += 1
|
||||
if waitSeconds >= 5 {
|
||||
|
||||
@@ -13,7 +13,7 @@ open class ExtensionProvider: NEPacketTunnelProvider {
|
||||
private var commandServer: LibboxCommandServer!
|
||||
private var platformInterface: ExtensionPlatformInterface!
|
||||
|
||||
override open func startTunnel(options _: [String: NSObject]?) async throws {
|
||||
override open func startTunnel(options startOptions: [String: NSObject]?) async throws {
|
||||
let options = LibboxSetupOptions()
|
||||
options.basePath = FilePath.sharedDirectory.relativePath
|
||||
options.workingPath = FilePath.workingDirectory.relativePath
|
||||
@@ -68,9 +68,10 @@ open class ExtensionProvider: NEPacketTunnelProvider {
|
||||
}
|
||||
|
||||
private func startService() async throws {
|
||||
let profileID = await SharedPreferences.selectedProfileID.get()
|
||||
let profile: Profile?
|
||||
do {
|
||||
profile = try await ProfileManager.get(Int64(SharedPreferences.selectedProfileID.get()))
|
||||
profile = try await ProfileManager.get(profileID)
|
||||
} catch {
|
||||
throw ExtensionStartupError("(packet-tunnel) error: read selected profile: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import Foundation
|
||||
import NetworkExtension
|
||||
|
||||
public enum OnDemandRuleAction: Int, Codable, CaseIterable, Identifiable {
|
||||
case connect = 1
|
||||
case disconnect = 2
|
||||
case evaluateConnection = 3
|
||||
case ignore = 4
|
||||
|
||||
public var id: Int { rawValue }
|
||||
|
||||
public var name: String {
|
||||
switch self {
|
||||
case .connect:
|
||||
return NSLocalizedString("Connect", comment: "")
|
||||
case .disconnect:
|
||||
return NSLocalizedString("Disconnect", comment: "")
|
||||
case .evaluateConnection:
|
||||
return NSLocalizedString("Evaluate Connection", comment: "")
|
||||
case .ignore:
|
||||
return NSLocalizedString("Ignore", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
public var actionDescription: String {
|
||||
switch self {
|
||||
case .connect:
|
||||
return NSLocalizedString("Start the VPN connection when conditions match.", comment: "")
|
||||
case .disconnect:
|
||||
return NSLocalizedString("Stop the VPN connection when conditions match.", comment: "")
|
||||
case .evaluateConnection:
|
||||
return NSLocalizedString("Evaluate the destination host before deciding to connect.", comment: "")
|
||||
case .ignore:
|
||||
return NSLocalizedString("Leave the VPN connection in its current state.", comment: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum OnDemandRuleInterfaceType: Int, Codable, Identifiable {
|
||||
case any = 0
|
||||
#if os(macOS) || os(tvOS)
|
||||
case ethernet = 1
|
||||
#endif
|
||||
case wifi = 2
|
||||
#if os(iOS)
|
||||
case cellular = 3
|
||||
#endif
|
||||
|
||||
public var id: Int { rawValue }
|
||||
|
||||
public var name: String {
|
||||
switch self {
|
||||
case .any:
|
||||
return NSLocalizedString("Any", comment: "")
|
||||
#if os(macOS) || os(tvOS)
|
||||
case .ethernet:
|
||||
return NSLocalizedString("Ethernet", comment: "")
|
||||
#endif
|
||||
case .wifi:
|
||||
return NSLocalizedString("Wi-Fi", comment: "")
|
||||
#if os(iOS)
|
||||
case .cellular:
|
||||
return NSLocalizedString("Cellular", comment: "")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
public static var availableCases: [OnDemandRuleInterfaceType] {
|
||||
#if os(iOS)
|
||||
return [.any, .wifi, .cellular]
|
||||
#elseif os(macOS)
|
||||
return [.any, .ethernet, .wifi]
|
||||
#elseif os(tvOS)
|
||||
return [.any, .ethernet, .wifi]
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
public enum EvaluateConnectionRuleAction: Int, Codable, CaseIterable, Identifiable {
|
||||
case connectIfNeeded = 1
|
||||
case neverConnect = 2
|
||||
|
||||
public var id: Int { rawValue }
|
||||
|
||||
public var name: String {
|
||||
switch self {
|
||||
case .connectIfNeeded:
|
||||
return NSLocalizedString("Connect If Needed", comment: "")
|
||||
case .neverConnect:
|
||||
return NSLocalizedString("Never Connect", comment: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct EvaluateConnectionRule: Codable, Identifiable, Hashable {
|
||||
public var id = UUID()
|
||||
public var action: EvaluateConnectionRuleAction = .connectIfNeeded
|
||||
public var matchDomains: [String] = []
|
||||
public var useDNSServers: [String] = []
|
||||
public var probeURL: String = ""
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case action
|
||||
case matchDomains
|
||||
case useDNSServers
|
||||
case probeURL
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
public init(action: EvaluateConnectionRuleAction, matchDomains: [String], useDNSServers: [String] = [], probeURL: String = "") {
|
||||
self.action = action
|
||||
self.matchDomains = matchDomains
|
||||
self.useDNSServers = useDNSServers
|
||||
self.probeURL = probeURL
|
||||
}
|
||||
|
||||
func toNERule() -> NEEvaluateConnectionRule {
|
||||
let neAction: NEEvaluateConnectionRuleAction
|
||||
switch action {
|
||||
case .connectIfNeeded:
|
||||
neAction = .connectIfNeeded
|
||||
case .neverConnect:
|
||||
neAction = .neverConnect
|
||||
}
|
||||
let rule = NEEvaluateConnectionRule(matchDomains: matchDomains, andAction: neAction)
|
||||
if !useDNSServers.isEmpty {
|
||||
rule.useDNSServers = useDNSServers
|
||||
}
|
||||
if !probeURL.isEmpty, let url = URL(string: probeURL) {
|
||||
rule.probeURL = url
|
||||
}
|
||||
return rule
|
||||
}
|
||||
}
|
||||
|
||||
public struct OnDemandRule: Codable, Identifiable, Hashable {
|
||||
public var id = UUID()
|
||||
public var action: OnDemandRuleAction = .connect
|
||||
public var interfaceType: OnDemandRuleInterfaceType = .any
|
||||
public var ssidMatch: [String] = []
|
||||
public var dnsSearchDomainMatch: [String] = []
|
||||
public var dnsServerAddressMatch: [String] = []
|
||||
public var probeURL: String = ""
|
||||
public var connectionRules: [EvaluateConnectionRule] = []
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case action
|
||||
case interfaceType
|
||||
case ssidMatch
|
||||
case dnsSearchDomainMatch
|
||||
case dnsServerAddressMatch
|
||||
case probeURL
|
||||
case connectionRules
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
public init(
|
||||
action: OnDemandRuleAction,
|
||||
interfaceType: OnDemandRuleInterfaceType = .any,
|
||||
ssidMatch: [String] = [],
|
||||
dnsSearchDomainMatch: [String] = [],
|
||||
dnsServerAddressMatch: [String] = [],
|
||||
probeURL: String = "",
|
||||
connectionRules: [EvaluateConnectionRule] = []
|
||||
) {
|
||||
self.action = action
|
||||
self.interfaceType = interfaceType
|
||||
self.ssidMatch = ssidMatch
|
||||
self.dnsSearchDomainMatch = dnsSearchDomainMatch
|
||||
self.dnsServerAddressMatch = dnsServerAddressMatch
|
||||
self.probeURL = probeURL
|
||||
self.connectionRules = connectionRules
|
||||
}
|
||||
|
||||
func toNERule() -> NEOnDemandRule {
|
||||
let rule: NEOnDemandRule
|
||||
switch action {
|
||||
case .connect:
|
||||
rule = NEOnDemandRuleConnect()
|
||||
case .disconnect:
|
||||
rule = NEOnDemandRuleDisconnect()
|
||||
case .ignore:
|
||||
rule = NEOnDemandRuleIgnore()
|
||||
case .evaluateConnection:
|
||||
let evalRule = NEOnDemandRuleEvaluateConnection()
|
||||
let validRules = connectionRules.filter { !$0.matchDomains.isEmpty }
|
||||
if !validRules.isEmpty {
|
||||
evalRule.connectionRules = validRules.map { $0.toNERule() }
|
||||
}
|
||||
rule = evalRule
|
||||
}
|
||||
|
||||
switch interfaceType {
|
||||
case .any:
|
||||
rule.interfaceTypeMatch = .any
|
||||
#if os(macOS) || os(tvOS)
|
||||
case .ethernet:
|
||||
rule.interfaceTypeMatch = .ethernet
|
||||
#endif
|
||||
case .wifi:
|
||||
rule.interfaceTypeMatch = .wiFi
|
||||
#if os(iOS)
|
||||
case .cellular:
|
||||
rule.interfaceTypeMatch = .cellular
|
||||
#endif
|
||||
}
|
||||
|
||||
if !ssidMatch.isEmpty {
|
||||
rule.ssidMatch = ssidMatch
|
||||
}
|
||||
|
||||
if !dnsSearchDomainMatch.isEmpty {
|
||||
rule.dnsSearchDomainMatch = dnsSearchDomainMatch
|
||||
}
|
||||
|
||||
if !dnsServerAddressMatch.isEmpty {
|
||||
rule.dnsServerAddressMatch = dnsServerAddressMatch
|
||||
}
|
||||
|
||||
if !probeURL.isEmpty, let url = URL(string: probeURL) {
|
||||
rule.probeURL = url
|
||||
}
|
||||
|
||||
return rule
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user