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:
Mariusz Klochowicz
2025-11-06 08:28:20 +10:30
committed by GitHub
parent 936b095391
commit b5048ad779
5 changed files with 145 additions and 134 deletions

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 {

View File

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

View File

@@ -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