mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
refactor(apple): Convert IPCClient from actor to stateless enum (#10797)
Refactors IPCClient from an actor to a stateless enum with static methods, removing unnecessary actor isolation and instance management. - IPCClient: Actor → enum with static methods taking session parameter - Store: Removed IPCClient instance caching, added resource list caching - Store: Moved resource fetching logic from IPCClient into Store - All call sites: Updated to pass session directly to static methods Store now directly manages resource list hashing and caching via fetchResources() method, using SHA256 hash optimisation to avoid redundant updates when resource lists haven't changed.
This commit is contained in:
committed by
GitHub
parent
936b095391
commit
b5048ad779
@@ -4,13 +4,12 @@
|
||||
// LICENSE: Apache-2.0
|
||||
//
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
@preconcurrency import NetworkExtension
|
||||
|
||||
// TODO: Use a more abstract IPC protocol to make this less terse
|
||||
|
||||
actor IPCClient {
|
||||
enum IPCClient {
|
||||
enum Error: Swift.Error {
|
||||
case decodeIPCDataFailed
|
||||
case noIPCData
|
||||
@@ -28,40 +27,26 @@ actor IPCClient {
|
||||
}
|
||||
}
|
||||
|
||||
// IPC only makes sense if there's a valid session. Session in this case refers to the `connection` field of
|
||||
// the NETunnelProviderManager instance.
|
||||
nonisolated let session: NETunnelProviderSession
|
||||
|
||||
// Track the "version" of the resource list so we can more efficiently
|
||||
// retrieve it from the Provider
|
||||
var resourceListHash = Data()
|
||||
|
||||
// Cache resources on this side of the IPC barrier so we can
|
||||
// return them to callers when they haven't changed.
|
||||
var resourcesListCache: ResourceList = ResourceList.loading
|
||||
|
||||
init(session: NETunnelProviderSession) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
// Encoder used to send messages to the tunnel
|
||||
nonisolated let encoder = PropertyListEncoder()
|
||||
nonisolated let decoder = PropertyListDecoder()
|
||||
static let encoder = PropertyListEncoder()
|
||||
static let decoder = PropertyListDecoder()
|
||||
|
||||
// Auto-connect
|
||||
@MainActor
|
||||
func start(configuration: Configuration) throws {
|
||||
static func start(session: NETunnelProviderSession, configuration: Configuration) throws {
|
||||
let tunnelConfiguration = configuration.toTunnelConfiguration()
|
||||
let configData = try encoder.encode(tunnelConfiguration)
|
||||
let options: [String: NSObject] = [
|
||||
"configuration": configData as NSObject
|
||||
]
|
||||
try session().startTunnel(options: options)
|
||||
try validateSession(session).startTunnel(options: options)
|
||||
}
|
||||
|
||||
// Sign in
|
||||
@MainActor
|
||||
func start(token: String, configuration: Configuration) throws {
|
||||
static func start(session: NETunnelProviderSession, token: String, configuration: Configuration)
|
||||
throws
|
||||
{
|
||||
let tunnelConfiguration = configuration.toTunnelConfiguration()
|
||||
let configData = try encoder.encode(tunnelConfiguration)
|
||||
let options: [String: NSObject] = [
|
||||
@@ -69,16 +54,16 @@ actor IPCClient {
|
||||
"configuration": configData as NSObject,
|
||||
]
|
||||
|
||||
try session().startTunnel(options: options)
|
||||
try validateSession(session).startTunnel(options: options)
|
||||
}
|
||||
|
||||
func signOut() async throws {
|
||||
try await sendMessageWithoutResponse(ProviderMessage.signOut)
|
||||
try stop()
|
||||
static func signOut(session: NETunnelProviderSession) async throws {
|
||||
try await sendMessageWithoutResponse(session: session, message: ProviderMessage.signOut)
|
||||
try stop(session: session)
|
||||
}
|
||||
|
||||
nonisolated func stop() throws {
|
||||
try session().stopTunnel()
|
||||
static func stop(session: NETunnelProviderSession) throws {
|
||||
try validateSession(session).stopTunnel()
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
@@ -86,72 +71,41 @@ actor IPCClient {
|
||||
// Since we rely on IPC for the GUI to function, we need to send a dummy `startTunnel` that doesn't actually
|
||||
// start the tunnel, but causes the system to wake the extension.
|
||||
@MainActor
|
||||
func dryStartStopCycle(configuration: Configuration) throws {
|
||||
static func dryStartStopCycle(session: NETunnelProviderSession, configuration: Configuration)
|
||||
throws
|
||||
{
|
||||
let tunnelConfiguration = configuration.toTunnelConfiguration()
|
||||
let configData = try encoder.encode(tunnelConfiguration)
|
||||
let options: [String: NSObject] = [
|
||||
"dryRun": true as NSObject,
|
||||
"configuration": configData as NSObject,
|
||||
]
|
||||
try session().startTunnel(options: options)
|
||||
try validateSession(session).startTunnel(options: options)
|
||||
}
|
||||
#endif
|
||||
|
||||
func setConfiguration(_ configuration: Configuration) async throws {
|
||||
static func setConfiguration(session: NETunnelProviderSession, _ configuration: Configuration)
|
||||
async throws
|
||||
{
|
||||
let tunnelConfiguration = await configuration.toTunnelConfiguration()
|
||||
let message = ProviderMessage.setConfiguration(tunnelConfiguration)
|
||||
|
||||
if sessionStatus() != .connected {
|
||||
if session.status != .connected {
|
||||
Log.trace("Not setting configuration whilst not connected")
|
||||
return
|
||||
}
|
||||
try await sendMessageWithoutResponse(message)
|
||||
try await sendMessageWithoutResponse(session: session, message: message)
|
||||
}
|
||||
|
||||
func fetchResources() async throws -> ResourceList {
|
||||
// Capture current hash before entering continuation
|
||||
let currentHash = resourceListHash
|
||||
|
||||
// Get data from the provider - continuation returns just the data
|
||||
let data = try await withCheckedThrowingContinuation { continuation in
|
||||
do {
|
||||
// Request list of resources from the provider. We send the hash of the resource list we already have.
|
||||
// If it differs, we'll get the full list in the callback. If not, we'll get nil.
|
||||
try session([.connected]).sendProviderMessage(
|
||||
encoder.encode(ProviderMessage.getResourceList(currentHash))
|
||||
) { data in
|
||||
continuation.resume(returning: data)
|
||||
}
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
|
||||
// Back on the actor - safe to access and mutate state directly
|
||||
guard let data = data else {
|
||||
// No data returned; Resources haven't changed
|
||||
return resourcesListCache
|
||||
}
|
||||
|
||||
// Save hash to compare against
|
||||
resourceListHash = Data(SHA256.hash(data: data))
|
||||
|
||||
// Decode and cache the new resource list
|
||||
let decoded = try decoder.decode([Resource].self, from: data)
|
||||
resourcesListCache = ResourceList.loaded(decoded)
|
||||
|
||||
return resourcesListCache
|
||||
static func clearLogs(session: NETunnelProviderSession) async throws {
|
||||
try await sendMessageWithoutResponse(session: session, message: ProviderMessage.clearLogs)
|
||||
}
|
||||
|
||||
func clearLogs() async throws {
|
||||
try await sendMessageWithoutResponse(ProviderMessage.clearLogs)
|
||||
}
|
||||
|
||||
func getLogFolderSize() async throws -> Int64 {
|
||||
static func getLogFolderSize(session: NETunnelProviderSession) async throws -> Int64 {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
|
||||
do {
|
||||
try session().sendProviderMessage(
|
||||
try validateSession(session).sendProviderMessage(
|
||||
encoder.encode(ProviderMessage.getLogFolderSize)
|
||||
) { data in
|
||||
|
||||
@@ -175,13 +129,14 @@ actor IPCClient {
|
||||
// Call this with a closure that will append each chunk to a buffer
|
||||
// of some sort, like a file. The completed buffer is a valid Apple Archive
|
||||
// in AAR format.
|
||||
nonisolated func exportLogs(
|
||||
static func exportLogs(
|
||||
session: NETunnelProviderSession,
|
||||
appender: @escaping (LogChunk) -> Void,
|
||||
errorHandler: @escaping (Error) -> Void
|
||||
) {
|
||||
func loop() {
|
||||
do {
|
||||
try session().sendProviderMessage(
|
||||
try validateSession(session).sendProviderMessage(
|
||||
encoder.encode(ProviderMessage.exportLogs)
|
||||
) { data in
|
||||
guard let data = data
|
||||
@@ -192,7 +147,7 @@ actor IPCClient {
|
||||
}
|
||||
|
||||
guard
|
||||
let chunk = try? self.decoder.decode(
|
||||
let chunk = try? decoder.decode(
|
||||
LogChunk.self, from: data
|
||||
)
|
||||
else {
|
||||
@@ -219,40 +174,35 @@ actor IPCClient {
|
||||
|
||||
// Subscribe to system notifications about our VPN status changing
|
||||
// and let our handler know about them.
|
||||
nonisolated func subscribeToVPNStatusUpdates(
|
||||
static func subscribeToVPNStatusUpdates(
|
||||
session: NETunnelProviderSession,
|
||||
handler: @escaping @MainActor (NEVPNStatus) async throws -> Void
|
||||
) {
|
||||
Task {
|
||||
for await notification in NotificationCenter.default.notifications(
|
||||
named: .NEVPNStatusDidChange)
|
||||
{
|
||||
guard let session = notification.object as? NETunnelProviderSession
|
||||
guard let notificationSession = notification.object as? NETunnelProviderSession
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
if session.status == .disconnected {
|
||||
// Reset resource list on disconnect
|
||||
await self.resetResourceList()
|
||||
// Only handle notifications for our session
|
||||
if notificationSession === session {
|
||||
do { try await handler(notificationSession.status) } catch { Log.error(error) }
|
||||
}
|
||||
|
||||
do { try await handler(session.status) } catch { Log.error(error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resetResourceList() {
|
||||
resourceListHash = Data()
|
||||
resourcesListCache = ResourceList.loading
|
||||
}
|
||||
|
||||
nonisolated func sessionStatus() -> NEVPNStatus {
|
||||
static func sessionStatus(session: NETunnelProviderSession) -> NEVPNStatus {
|
||||
return session.status
|
||||
}
|
||||
|
||||
nonisolated private func session(_ requiredStatuses: Set<NEVPNStatus> = []) throws
|
||||
-> NETunnelProviderSession
|
||||
{
|
||||
private static func validateSession(
|
||||
_ session: NETunnelProviderSession,
|
||||
requiredStatuses: Set<NEVPNStatus> = []
|
||||
) throws -> NETunnelProviderSession {
|
||||
if requiredStatuses.isEmpty || requiredStatuses.contains(session.status) {
|
||||
return session
|
||||
}
|
||||
@@ -260,10 +210,13 @@ actor IPCClient {
|
||||
throw Error.invalidStatus(session.status)
|
||||
}
|
||||
|
||||
private func sendMessageWithoutResponse(_ message: ProviderMessage) async throws {
|
||||
private static func sendMessageWithoutResponse(
|
||||
session: NETunnelProviderSession,
|
||||
message: ProviderMessage
|
||||
) async throws {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
do {
|
||||
try session().sendProviderMessage(encoder.encode(message)) { _ in
|
||||
try validateSession(session).sendProviderMessage(encoder.encode(message)) { _ in
|
||||
continuation.resume()
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import AppleArchive
|
||||
import Foundation
|
||||
@preconcurrency import NetworkExtension
|
||||
import System
|
||||
|
||||
/// Convenience module for smoothing over the differences between exporting logs on macOS and iOS.
|
||||
@@ -28,7 +29,7 @@ import System
|
||||
|
||||
static func export(
|
||||
to archiveURL: URL,
|
||||
with ipcClient: IPCClient
|
||||
session: NETunnelProviderSession
|
||||
) async throws {
|
||||
guard let logFolderURL = SharedAccess.logFolderURL
|
||||
else {
|
||||
@@ -54,7 +55,8 @@ import System
|
||||
|
||||
// 3. Await tunnel log export from tunnel process
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
ipcClient.exportLogs(
|
||||
IPCClient.exportLogs(
|
||||
session: session,
|
||||
appender: { chunk in
|
||||
do {
|
||||
// Append each chunk to the archive
|
||||
|
||||
@@ -101,7 +101,6 @@ public class VPNConfigurationManager: @unchecked Sendable {
|
||||
}
|
||||
|
||||
let configuration = Configuration.shared
|
||||
let ipcClient = IPCClient(session: session)
|
||||
|
||||
if let actorName = legacyConfiguration["actorName"] {
|
||||
UserDefaults.standard.set(actorName, forKey: "actorName")
|
||||
@@ -131,7 +130,7 @@ public class VPNConfigurationManager: @unchecked Sendable {
|
||||
configuration.internetResourceEnabled = internetResourceEnabled == "true"
|
||||
}
|
||||
|
||||
try await ipcClient.setConfiguration(configuration)
|
||||
try await IPCClient.setConfiguration(session: session, configuration)
|
||||
|
||||
// Remove fields to prevent confusion if the user sees these in System Settings and wonders why they're stale.
|
||||
if let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//
|
||||
|
||||
import Combine
|
||||
import CryptoKit
|
||||
import NetworkExtension
|
||||
import OSLog
|
||||
import UserNotifications
|
||||
@@ -24,6 +25,10 @@ public final class Store: ObservableObject {
|
||||
// Enacapsulate Tunnel status here to make it easier for other components to observe
|
||||
@Published private(set) var vpnStatus: NEVPNStatus?
|
||||
|
||||
// Hash for resource list optimisation
|
||||
private var resourceListHash = Data()
|
||||
private let decoder = PropertyListDecoder()
|
||||
|
||||
// User notifications
|
||||
@Published private(set) var decision: UNAuthorizationStatus?
|
||||
|
||||
@@ -42,9 +47,6 @@ public final class Store: ObservableObject {
|
||||
private var vpnConfigurationManager: VPNConfigurationManager?
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
// Cached IPCClient instance - one per Store instance
|
||||
private var cachedIPCClient: IPCClient?
|
||||
|
||||
// Track which session expired alerts have been shown to prevent duplicates
|
||||
private var shownAlertIds: Set<String>
|
||||
|
||||
@@ -70,7 +72,10 @@ public final class Store: ObservableObject {
|
||||
|
||||
if self.vpnConfigurationManager != nil {
|
||||
Task {
|
||||
do { try await self.ipcClient().setConfiguration(self.configuration) } catch {
|
||||
do {
|
||||
guard let session = try self.manager().session() else { return }
|
||||
try await IPCClient.setConfiguration(session: session, self.configuration)
|
||||
} catch {
|
||||
Log.error(error)
|
||||
}
|
||||
}
|
||||
@@ -114,9 +119,14 @@ public final class Store: ObservableObject {
|
||||
[weak self] status in
|
||||
try await self?.handleVPNStatusChange(newVPNStatus: status)
|
||||
}
|
||||
try ipcClient().subscribeToVPNStatusUpdates(handler: vpnStatusChangeHandler)
|
||||
|
||||
let initialStatus = try ipcClient().sessionStatus()
|
||||
guard let session = try manager().session() else {
|
||||
throw VPNConfigurationManagerError.managerNotInitialized
|
||||
}
|
||||
|
||||
IPCClient.subscribeToVPNStatusUpdates(session: session, handler: vpnStatusChangeHandler)
|
||||
|
||||
let initialStatus = IPCClient.sessionStatus(session: session)
|
||||
|
||||
// Handle initial status to ensure resources start loading if already connected
|
||||
try await handleVPNStatusChange(newVPNStatus: initialStatus)
|
||||
@@ -197,36 +207,19 @@ public final class Store: ObservableObject {
|
||||
private func maybeAutoConnect() async throws {
|
||||
if configuration.connectOnStart {
|
||||
try await manager().enable()
|
||||
try ipcClient().start(configuration: configuration)
|
||||
guard let session = try manager().session() else {
|
||||
throw VPNConfigurationManagerError.managerNotInitialized
|
||||
}
|
||||
try IPCClient.start(session: session, configuration: configuration)
|
||||
}
|
||||
}
|
||||
func installVPNConfiguration() async throws {
|
||||
// Create a new VPN configuration in system settings.
|
||||
self.vpnConfigurationManager = try await VPNConfigurationManager()
|
||||
|
||||
// Invalidate cached IPCClient since we have a new configuration
|
||||
cachedIPCClient = nil
|
||||
|
||||
try await setupTunnelObservers()
|
||||
}
|
||||
|
||||
func ipcClient() throws -> IPCClient {
|
||||
// Return cached instance if it exists
|
||||
if let cachedIPCClient = cachedIPCClient {
|
||||
return cachedIPCClient
|
||||
}
|
||||
|
||||
// Create new instance and cache it
|
||||
guard let session = try manager().session()
|
||||
else {
|
||||
throw VPNConfigurationManagerError.managerNotInitialized
|
||||
}
|
||||
|
||||
let client = IPCClient(session: session)
|
||||
cachedIPCClient = client
|
||||
return client
|
||||
}
|
||||
|
||||
func manager() throws -> VPNConfigurationManager {
|
||||
guard let vpnConfigurationManager
|
||||
else {
|
||||
@@ -241,16 +234,20 @@ public final class Store: ObservableObject {
|
||||
}
|
||||
|
||||
public func stop() async throws {
|
||||
guard let session = try manager().session() else {
|
||||
throw VPNConfigurationManagerError.managerNotInitialized
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
// On macOS, the system removes the utun interface on stop ONLY if the VPN is in a connected state.
|
||||
// So we need to do a dry run start-then-stop if we're not connected, to ensure the interface is removed.
|
||||
if vpnStatus == .connected || vpnStatus == .connecting || vpnStatus == .reasserting {
|
||||
try ipcClient().stop()
|
||||
try IPCClient.stop(session: session)
|
||||
} else {
|
||||
try ipcClient().dryStartStopCycle(configuration: configuration)
|
||||
try IPCClient.dryStartStopCycle(session: session, configuration: configuration)
|
||||
}
|
||||
#else
|
||||
try ipcClient().stop()
|
||||
try IPCClient.stop(session: session)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -272,15 +269,24 @@ public final class Store: ObservableObject {
|
||||
UserDefaults.standard.removeObject(forKey: "shownAlertIds")
|
||||
|
||||
// Bring the tunnel up and send it a token and configuration to start
|
||||
try ipcClient().start(token: authResponse.token, configuration: configuration)
|
||||
guard let session = try manager().session() else {
|
||||
throw VPNConfigurationManagerError.managerNotInitialized
|
||||
}
|
||||
try IPCClient.start(session: session, token: authResponse.token, configuration: configuration)
|
||||
}
|
||||
|
||||
func signOut() async throws {
|
||||
try await ipcClient().signOut()
|
||||
guard let session = try manager().session() else {
|
||||
throw VPNConfigurationManagerError.managerNotInitialized
|
||||
}
|
||||
try await IPCClient.signOut(session: session)
|
||||
}
|
||||
|
||||
func clearLogs() async throws {
|
||||
try await ipcClient().clearLogs()
|
||||
guard let session = try manager().session() else {
|
||||
throw VPNConfigurationManagerError.managerNotInitialized
|
||||
}
|
||||
try await IPCClient.clearLogs(session: session)
|
||||
}
|
||||
|
||||
// MARK: Private functions
|
||||
@@ -307,7 +313,8 @@ public final class Store: ObservableObject {
|
||||
self.resourceUpdateTask = Task {
|
||||
if !Task.isCancelled {
|
||||
do {
|
||||
self.resourceList = try await self.ipcClient().fetchResources()
|
||||
guard let session = try self.manager().session() else { return }
|
||||
try await self.fetchResources(session: session)
|
||||
} catch let error as NSError {
|
||||
// https://developer.apple.com/documentation/networkextension/nevpnerror-swift.struct/code
|
||||
if error.domain == "NEVPNErrorDomain" && error.code == 1 {
|
||||
@@ -341,5 +348,49 @@ public final class Store: ObservableObject {
|
||||
resourcesTimer?.invalidate()
|
||||
resourcesTimer = nil
|
||||
resourceList = ResourceList.loading
|
||||
resourceListHash = Data()
|
||||
}
|
||||
|
||||
/// Fetches resources from the tunnel provider, using hash-based optimisation.
|
||||
///
|
||||
/// If the resource list hash matches what the provider has, resources are unchanged.
|
||||
/// Otherwise, fetches and caches the new list.
|
||||
///
|
||||
/// - Parameter session: The tunnel provider session to communicate with
|
||||
/// - Throws: IPCClient.Error if IPC communication fails
|
||||
private func fetchResources(session: NETunnelProviderSession) async throws {
|
||||
// Capture current hash before IPC call
|
||||
let currentHash = resourceListHash
|
||||
|
||||
// Get data from the provider - if hash matches, provider returns nil
|
||||
let data = try await withCheckedThrowingContinuation {
|
||||
(continuation: CheckedContinuation<Data?, Error>) in
|
||||
do {
|
||||
guard session.status == .connected else {
|
||||
throw IPCClient.Error.invalidStatus(session.status)
|
||||
}
|
||||
|
||||
try session.sendProviderMessage(
|
||||
IPCClient.encoder.encode(ProviderMessage.getResourceList(currentHash))
|
||||
) { data in
|
||||
continuation.resume(returning: data)
|
||||
}
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
|
||||
// If no data returned, resources haven't changed - no update needed
|
||||
guard let data = data else {
|
||||
return
|
||||
}
|
||||
|
||||
// Compute new hash and decode resources
|
||||
let newHash = Data(SHA256.hash(data: data))
|
||||
let decoded = try decoder.decode([Resource].self, from: data)
|
||||
|
||||
// Update both hash and resource list
|
||||
resourceListHash = newHash
|
||||
resourceList = ResourceList.loaded(decoded)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -622,9 +622,12 @@ public struct SettingsView: View {
|
||||
|
||||
Task {
|
||||
do {
|
||||
guard let session = try store.manager().session() else {
|
||||
throw VPNConfigurationManagerError.managerNotInitialized
|
||||
}
|
||||
try await LogExporter.export(
|
||||
to: destinationURL,
|
||||
with: store.ipcClient()
|
||||
session: session
|
||||
)
|
||||
|
||||
window.contentViewController?.presentingViewController?.dismiss(self)
|
||||
@@ -707,7 +710,10 @@ public struct SettingsView: View {
|
||||
|
||||
do {
|
||||
#if os(macOS)
|
||||
let providerLogFolderSize = try await store.ipcClient().getLogFolderSize()
|
||||
guard let session = try store.manager().session() else {
|
||||
throw VPNConfigurationManagerError.managerNotInitialized
|
||||
}
|
||||
let providerLogFolderSize = try await IPCClient.getLogFolderSize(session: session)
|
||||
let totalSize = logFolderSize + providerLogFolderSize
|
||||
#else
|
||||
let totalSize = logFolderSize
|
||||
|
||||
Reference in New Issue
Block a user