Files
sing-box-for-apple/Library/Network/ExtensionProfile.swift
T
世界 efa6088884 Fix widget toggle not disabling On Demand before stopping VPN
Store wasOnDemandEnabled flag in providerConfiguration so the widget
can restore On Demand state on next start. Clear the flag when On
Demand is explicitly disabled in settings or when starting from the
main app.
2026-03-09 13:20:53 +08:00

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()
}
}