Store wasOnDemandEnabled flag in providerConfiguration so the widget can restore On Demand state on next start. Clear the flag when On Demand is explicitly disabled in settings or when starting from the main app.
310 lines
12 KiB
Swift
310 lines
12 KiB
Swift
import Foundation
|
|
import Libbox
|
|
import NetworkExtension
|
|
import os
|
|
#if os(iOS)
|
|
import FileProvider
|
|
#endif
|
|
|
|
private let logger = Logger(category: "ExtensionProfile")
|
|
|
|
@MainActor
|
|
public class ExtensionProfile: ObservableObject {
|
|
public static let controlKind = AppConfiguration.widgetControlKind
|
|
|
|
private let manager: NEVPNManager?
|
|
private var connection: NEVPNConnection?
|
|
private var observer: Any?
|
|
private let isMock: Bool
|
|
|
|
@Published public var status: NEVPNStatus
|
|
@Published public var connectedDate: Date?
|
|
|
|
public init(_ manager: NEVPNManager) {
|
|
self.manager = manager
|
|
connection = manager.connection
|
|
status = manager.connection.status
|
|
connectedDate = manager.connection.connectedDate
|
|
isMock = false
|
|
}
|
|
|
|
private init(mockStatus: NEVPNStatus, mockConnectedDate: Date?) {
|
|
manager = nil
|
|
connection = nil
|
|
status = mockStatus
|
|
connectedDate = mockConnectedDate
|
|
isMock = true
|
|
}
|
|
|
|
private static var _mock: ExtensionProfile?
|
|
|
|
public static var mock: ExtensionProfile {
|
|
if _mock == nil {
|
|
_mock = ExtensionProfile(mockStatus: .connected, mockConnectedDate: Date().addingTimeInterval(-3600))
|
|
}
|
|
return _mock!
|
|
}
|
|
|
|
public func register() {
|
|
guard !isMock, let manager else { return }
|
|
observer = NotificationCenter.default.addObserver(
|
|
forName: NSNotification.Name.NEVPNStatusDidChange,
|
|
object: manager.connection,
|
|
queue: nil
|
|
) { [weak self] notification in
|
|
guard let connection = notification.object as? NEVPNConnection else {
|
|
return
|
|
}
|
|
Task { @MainActor in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.connection = connection
|
|
self.status = connection.status
|
|
self.connectedDate = connection.connectedDate
|
|
#if os(iOS)
|
|
if #available(iOS 16.0, *) {
|
|
if connection.status == .connected || connection.status == .disconnected {
|
|
Self.signalFileProviderChanges()
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
}
|
|
|
|
#if os(iOS)
|
|
@available(iOS 16.0, *)
|
|
private static func signalFileProviderChanges() {
|
|
Task.detached {
|
|
guard let domain = try? await NSFileProviderManager.domains()
|
|
.first(where: { $0.identifier.rawValue == AppConfiguration.fileProviderDomainID }),
|
|
let manager = NSFileProviderManager(for: domain)
|
|
else {
|
|
return
|
|
}
|
|
try? await manager.signalEnumerator(for: .workingSet)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
nonisolated deinit {
|
|
if let observer {
|
|
NotificationCenter.default.removeObserver(observer)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
guard let manager else { return }
|
|
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 {
|
|
guard let manager else { return }
|
|
manager.isOnDemandEnabled = enabled
|
|
if !enabled {
|
|
if let proto = manager.protocolConfiguration as? NETunnelProviderProtocol {
|
|
var config = proto.providerConfiguration ?? [:]
|
|
if config.removeValue(forKey: "wasOnDemandEnabled") != nil {
|
|
proto.providerConfiguration = config
|
|
}
|
|
}
|
|
}
|
|
await setOnDemandRules(useDefaultRules: useDefaultRules)
|
|
try await manager.saveToPreferences()
|
|
}
|
|
|
|
@available(iOS 16.0, macOS 13.0, tvOS 17.0, *)
|
|
public func fetchLastDisconnectError() async throws {
|
|
guard let connection else { return }
|
|
try await connection.fetchLastDisconnectError()
|
|
}
|
|
|
|
public func start() async throws {
|
|
if isMock {
|
|
status = .connecting
|
|
try await Task.sleep(nanoseconds: 500_000_000)
|
|
status = .connected
|
|
connectedDate = Date()
|
|
return
|
|
}
|
|
guard let manager else { return }
|
|
try await fetchProfile()
|
|
manager.isEnabled = true
|
|
let alwaysOn = await SharedPreferences.alwaysOn.get()
|
|
let onDemandEnabled = await SharedPreferences.onDemandEnabled.get()
|
|
if alwaysOn || onDemandEnabled {
|
|
manager.isOnDemandEnabled = true
|
|
await setOnDemandRules(useDefaultRules: alwaysOn)
|
|
}
|
|
if let proto = manager.protocolConfiguration as? NETunnelProviderProtocol {
|
|
var config = proto.providerConfiguration ?? [:]
|
|
if config.removeValue(forKey: "wasOnDemandEnabled") != nil {
|
|
proto.providerConfiguration = config
|
|
}
|
|
}
|
|
#if !os(tvOS)
|
|
if let protocolConfiguration = manager.protocolConfiguration {
|
|
let includeAllNetworks = await SharedPreferences.includeAllNetworks.get()
|
|
protocolConfiguration.includeAllNetworks = includeAllNetworks
|
|
protocolConfiguration.excludeLocalNetworks = await SharedPreferences.excludeLocalNetworks.get()
|
|
protocolConfiguration.enforceRoutes = await SharedPreferences.enforceRoutes.get()
|
|
if #available(iOS 16.4, macOS 13.3, *) {
|
|
protocolConfiguration.excludeAPNs = await SharedPreferences.excludeAPNs.get()
|
|
protocolConfiguration.excludeCellularServices = await SharedPreferences.excludeCellularServices.get()
|
|
}
|
|
if #available(iOS 17.4, macOS 14.4, *) {
|
|
protocolConfiguration.excludeDeviceCommunication = await SharedPreferences.excludeDeviceCommunication.get()
|
|
}
|
|
}
|
|
#endif
|
|
try await manager.saveToPreferences()
|
|
let options = try await prepareStartOptions()
|
|
try manager.connection.startVPNTunnel(options: options)
|
|
}
|
|
|
|
public func reloadService() async throws {
|
|
if isMock { return }
|
|
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 await profile.readAsync()
|
|
options["configContent"] = NSString(string: configContent)
|
|
|
|
#if !os(macOS)
|
|
options["ignoreMemoryLimit"] = await NSNumber(value: SharedPreferences.ignoreMemoryLimit.get())
|
|
#endif
|
|
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 {
|
|
let profileID = await SharedPreferences.selectedProfileID.get()
|
|
if let profile = try await ProfileManager.get(profileID), profile.type == .icloud {
|
|
_ = try await profile.readAsync()
|
|
}
|
|
}
|
|
|
|
public func stop() async throws {
|
|
if isMock {
|
|
status = .disconnecting
|
|
try await Task.sleep(nanoseconds: 300_000_000)
|
|
status = .disconnected
|
|
connectedDate = nil
|
|
return
|
|
}
|
|
guard let manager else { return }
|
|
if manager.isOnDemandEnabled {
|
|
if let proto = manager.protocolConfiguration as? NETunnelProviderProtocol {
|
|
var config = proto.providerConfiguration ?? [:]
|
|
config["wasOnDemandEnabled"] = true
|
|
proto.providerConfiguration = config
|
|
}
|
|
manager.isOnDemandEnabled = false
|
|
try await manager.saveToPreferences()
|
|
}
|
|
do {
|
|
try await Task.detached(priority: .userInitiated) {
|
|
try LibboxNewStandaloneCommandClient()!.serviceClose()
|
|
}.value
|
|
} catch {
|
|
logger.debug("serviceClose error: \(error.localizedDescription)")
|
|
}
|
|
manager.connection.stopVPNTunnel()
|
|
}
|
|
|
|
public func restart() async throws {
|
|
try await stop()
|
|
var waitSeconds = 0
|
|
while status != .disconnected {
|
|
try await Task.sleep(nanoseconds: NSEC_PER_SEC)
|
|
waitSeconds += 1
|
|
if waitSeconds >= 5 {
|
|
throw NSError(domain: "ExtensionProfile", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Restart service timeout")])
|
|
}
|
|
}
|
|
try await start()
|
|
}
|
|
|
|
public static func load() async throws -> ExtensionProfile? {
|
|
let managers = try await NETunnelProviderManager.loadAllFromPreferences()
|
|
if managers.isEmpty {
|
|
return nil
|
|
}
|
|
return ExtensionProfile(managers[0])
|
|
}
|
|
|
|
public static func install() async throws {
|
|
let manager = NETunnelProviderManager()
|
|
manager.localizedDescription = Variant.applicationName
|
|
let tunnelProtocol = NETunnelProviderProtocol()
|
|
if Variant.useSystemExtension {
|
|
tunnelProtocol.providerBundleIdentifier = AppConfiguration.systemExtensionBundleID
|
|
} else {
|
|
tunnelProtocol.providerBundleIdentifier = AppConfiguration.extensionBundleID
|
|
}
|
|
tunnelProtocol.serverAddress = "sing-box"
|
|
manager.protocolConfiguration = tunnelProtocol
|
|
manager.isEnabled = true
|
|
try await manager.saveToPreferences()
|
|
}
|
|
}
|