- Adaptive iPhone/iPad layout with NavigationSplitView sidebar - Auto-detect SSL-pinned domains, fall back to passthrough - Certificate install via local HTTP server (Safari profile flow) - App Group-backed CA, per-domain leaf cert LRU cache - DB-backed config repository, Darwin notification throttling - Rules engine, breakpoint rules, pinned domain tracking - os.Logger instrumentation across tunnel/proxy/mitm/capture/cert/rules/db/ipc/ui - Fix dyld framework embed, race conditions, thread safety
162 lines
5.3 KiB
Swift
162 lines
5.3 KiB
Swift
import SwiftUI
|
|
@preconcurrency import NetworkExtension
|
|
import LocalAuthentication
|
|
import ProxyCore
|
|
import GRDB
|
|
|
|
@Observable
|
|
@MainActor
|
|
final class AppState {
|
|
var vpnStatus: NEVPNStatus = .disconnected
|
|
var isCertificateInstalled: Bool = false
|
|
var isCertificateTrusted: Bool = false
|
|
var isLocked: Bool = false
|
|
var runtimeStatus = ProxyRuntimeStatus()
|
|
|
|
var isAppLockEnabled: Bool {
|
|
get { UserDefaults.standard.bool(forKey: "appLockEnabled") }
|
|
set { UserDefaults.standard.set(newValue, forKey: "appLockEnabled") }
|
|
}
|
|
|
|
private var vpnManager: NETunnelProviderManager?
|
|
private var statusObservation: NSObjectProtocol?
|
|
@ObservationIgnored private var runtimeObservation: AnyDatabaseCancellable?
|
|
private let runtimeStatusRepo = RuntimeStatusRepository()
|
|
|
|
init() {
|
|
if UserDefaults.standard.bool(forKey: "appLockEnabled") {
|
|
isLocked = true
|
|
}
|
|
isCertificateInstalled = CertificateManager.shared.hasCA
|
|
Task {
|
|
await loadVPNManager()
|
|
}
|
|
observeRuntimeStatus()
|
|
}
|
|
|
|
func authenticate() {
|
|
let context = LAContext()
|
|
var error: NSError?
|
|
guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else {
|
|
isLocked = false
|
|
return
|
|
}
|
|
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: "Unlock Proxy") { [weak self] success, _ in
|
|
Task { @MainActor in
|
|
if success {
|
|
self?.isLocked = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func loadVPNManager() async {
|
|
do {
|
|
let managers = try await NETunnelProviderManager.loadAllFromPreferences()
|
|
if let existing = managers.first {
|
|
vpnManager = existing
|
|
} else {
|
|
let manager = NETunnelProviderManager()
|
|
let proto = NETunnelProviderProtocol()
|
|
proto.providerBundleIdentifier = ProxyConstants.extensionBundleIdentifier
|
|
proto.serverAddress = ProxyConstants.proxyHost
|
|
manager.protocolConfiguration = proto
|
|
manager.localizedDescription = "Proxy"
|
|
manager.isEnabled = true
|
|
try await manager.saveToPreferences()
|
|
try await manager.loadFromPreferences()
|
|
vpnManager = manager
|
|
}
|
|
observeVPNStatus()
|
|
vpnStatus = vpnManager?.connection.status ?? .disconnected
|
|
} catch {
|
|
print("[AppState] Failed to load VPN manager: \(error)")
|
|
}
|
|
}
|
|
|
|
func toggleVPN() async {
|
|
guard let manager = vpnManager else {
|
|
await loadVPNManager()
|
|
return
|
|
}
|
|
|
|
switch manager.connection.status {
|
|
case .connected, .connecting:
|
|
manager.connection.stopVPNTunnel()
|
|
case .disconnected, .invalid:
|
|
do {
|
|
// Ensure saved and fresh before starting
|
|
manager.isEnabled = true
|
|
try await manager.saveToPreferences()
|
|
try await manager.loadFromPreferences()
|
|
try manager.connection.startVPNTunnel()
|
|
} catch {
|
|
print("[AppState] Failed to start VPN: \(error)")
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
var isVPNConnected: Bool {
|
|
vpnStatus == .connected
|
|
}
|
|
|
|
var hasSharedCertificate: Bool {
|
|
guard let localFingerprint = CertificateManager.shared.caFingerprint else { return false }
|
|
return localFingerprint == runtimeStatus.caFingerprint
|
|
}
|
|
|
|
var isHTTPSInspectionVerified: Bool {
|
|
runtimeStatus.lastSuccessfulMITMAt != nil
|
|
}
|
|
|
|
var lastRuntimeError: String? {
|
|
runtimeStatus.lastMITMError ?? runtimeStatus.lastConnectError ?? runtimeStatus.lastProxyError
|
|
}
|
|
|
|
var vpnStatusText: String {
|
|
switch vpnStatus {
|
|
case .connected: "Connected"
|
|
case .connecting: "Connecting..."
|
|
case .disconnecting: "Disconnecting..."
|
|
case .disconnected: "Disconnected"
|
|
case .invalid: "Not Configured"
|
|
case .reasserting: "Reconnecting..."
|
|
@unknown default: "Unknown"
|
|
}
|
|
}
|
|
|
|
private func observeVPNStatus() {
|
|
guard let manager = vpnManager else { return }
|
|
|
|
// Remove existing observer
|
|
if let existing = statusObservation {
|
|
NotificationCenter.default.removeObserver(existing)
|
|
}
|
|
|
|
statusObservation = NotificationCenter.default.addObserver(
|
|
forName: .NEVPNStatusDidChange,
|
|
object: manager.connection,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
Task { @MainActor in
|
|
self?.vpnStatus = manager.connection.status
|
|
}
|
|
}
|
|
}
|
|
|
|
private func observeRuntimeStatus() {
|
|
runtimeObservation = runtimeStatusRepo.observeStatus()
|
|
.start(in: DatabaseManager.shared.dbPool) { error in
|
|
ProxyLogger.ui.error("AppState runtime observation error: \(error.localizedDescription)")
|
|
} onChange: { [weak self] status in
|
|
Task { @MainActor in
|
|
self?.runtimeStatus = status
|
|
self?.isCertificateInstalled = CertificateManager.shared.hasCA
|
|
self?.isCertificateTrusted = status.lastSuccessfulMITMAt != nil
|
|
}
|
|
}
|
|
}
|
|
}
|