diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/SystemExtensionManager.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/SystemExtensionManager.swift index 7d5dbec0f..3505728f1 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/SystemExtensionManager.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/SystemExtensionManager.swift @@ -19,14 +19,12 @@ public enum SystemExtensionError: Error { } public class SystemExtensionManager: NSObject, OSSystemExtensionRequestDelegate, ObservableObject { - // Maintain a static handle to the extension manager for tracking the state of the extension activation. - public static let shared = SystemExtensionManager() - - private var continuation: CheckedContinuation? + // Delegate methods complete with either a true or false outcome or an Error + private var continuation: CheckedContinuation? public func installSystemExtension( identifier: String, - continuation: CheckedContinuation + continuation: CheckedContinuation ) { self.continuation = continuation @@ -37,21 +35,48 @@ public class SystemExtensionManager: NSObject, OSSystemExtensionRequestDelegate, OSSystemExtensionManager.shared.submitRequest(request) } + public func isInstalled( + identifier: String, + continuation: CheckedContinuation + ) { + self.continuation = continuation + + let request = OSSystemExtensionRequest.propertiesRequest( + forExtensionWithIdentifier: identifier, + queue: .main + ) + request.delegate = self + + // Send request + OSSystemExtensionManager.shared.submitRequest(request) + } + // MARK: - OSSystemExtensionRequestDelegate + // Result of system extension installation public func request(_ request: OSSystemExtensionRequest, didFinishWithResult result: OSSystemExtensionRequest.Result) { guard result == .completed else { - consumeContinuation(throwing: SystemExtensionError.unknownResult(result)) + resume(throwing: SystemExtensionError.unknownResult(result)) return } - // Success - consumeContinuation() + // Installation succeeded + resume(returning: true) + } + + // Result of properties request + public func request( + _ request: OSSystemExtensionRequest, + foundProperties properties: [OSSystemExtensionProperties] + ) { + // Returns true if we find any extension installed matching the bundle id + // Otherwise false + continuation?.resume(returning: properties.contains { $0.isEnabled }) } public func request(_ request: OSSystemExtensionRequest, didFailWithError error: Error) { - consumeContinuation(throwing: error) + resume(throwing: error) } public func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) { @@ -62,13 +87,13 @@ public class SystemExtensionManager: NSObject, OSSystemExtensionRequestDelegate, return .replace } - private func consumeContinuation(throwing error: Error) { + private func resume(throwing error: Error) { self.continuation?.resume(throwing: error) self.continuation = nil } - private func consumeContinuation() { - self.continuation?.resume() + private func resume(returning val: Bool) { + self.continuation?.resume(returning: val) self.continuation = nil } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift index 91f4506e3..81705a4f3 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift @@ -24,6 +24,9 @@ public final class Store: ObservableObject { // to observe @Published private(set) var status: NEVPNStatus? + // Track whether our system extension has been installed (macOS) + @Published private(set) var isInstalled: Bool = false + // This is not currently updated after it is initialized, but // we could periodically update it if we need to. @Published private(set) var decision: UNAuthorizationStatus @@ -110,20 +113,39 @@ public final class Store: ObservableObject { } } - func grantVPNPermissions() async throws { + func checkedIfInstalled() async throws { #if os(macOS) - // Apple recommends installing the system extension as early as possible after app launch. - // See https://developer.apple.com/documentation/systemextensions/installing-system-extensions-and-drivers - try await withCheckedThrowingContinuation { - (continuation: CheckedContinuation) in + let checker = SystemExtensionManager() - SystemExtensionManager.shared.installSystemExtension( + self.isInstalled = try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + + checker.isInstalled( identifier: VPNProfileManager.bundleIdentifier, continuation: continuation ) } #endif + } + func installSystemExtension() async throws { +#if os(macOS) + let installer = SystemExtensionManager() + + // Apple recommends installing the system extension as early as possible after app launch. + // See https://developer.apple.com/documentation/systemextensions/installing-system-extensions-and-drivers + self.isInstalled = try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + + installer.installSystemExtension( + identifier: VPNProfileManager.bundleIdentifier, + continuation: continuation + ) + } +#endif + } + + func grantVPNPermission() async throws { // Create a new VPN profile in system settings. try await self.vpnProfileManager.create() diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift index 8a920109b..374622ed1 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift @@ -37,7 +37,9 @@ public class AppViewModel: ObservableObject { try await self.store.bindToVPNProfileUpdates() #if os(macOS) - if self.store.status == .invalid { + try await self.store.checkedIfInstalled() + + if !self.store.isInstalled || self.store.status == .invalid { // Show the main Window if VPN permission needs to be granted AppViewModel.WindowDefinition.main.openWindow() @@ -104,10 +106,10 @@ public struct AppView: View { } } #elseif os(macOS) - switch model.status { - case nil: + switch (model.store.isInstalled, model.status) { + case (_, nil): ProgressView() - case .invalid: + case (false, _), (_, .invalid): GrantVPNView(model: GrantVPNViewModel(store: model.store)) default: FirstTimeView() diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/GrantVPNView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/GrantVPNView.swift index aaa2a4b26..2168bac2f 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/GrantVPNView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/GrantVPNView.swift @@ -6,20 +6,52 @@ // import SwiftUI +import Combine @MainActor final class GrantVPNViewModel: ObservableObject { + @Published var isInstalled: Bool = false + private let store: Store + private var cancellables: Set = [] init(store: Store) { self.store = store + + store.$isInstalled + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] isInstalled in + self?.isInstalled = isInstalled + }).store(in: &cancellables) + } + + func installSystemExtensionButtonTapped() { + Task { + do { + try await store.installSystemExtension() + +#if os(macOS) + // The window has a tendency to go to the background after installing + // the system extension + NSApp.activate(ignoringOtherApps: true) +#endif + } catch { + Log.error(error) + } + } } func grantPermissionButtonTapped() { Log.log("\(#function)") Task { do { - try await store.grantVPNPermissions() + try await store.grantVPNPermission() + +#if os(macOS) + // The window has a tendency to go to the background after allowing the + // VPN profile + NSApp.activate(ignoringOtherApps: true) +#endif } catch { Log.error(error) } @@ -31,6 +63,7 @@ struct GrantVPNView: View { @ObservedObject var model: GrantVPNViewModel var body: some View { +#if os(iOS) VStack( alignment: .center, content: { @@ -42,7 +75,7 @@ struct GrantVPNView: View { .padding(.horizontal, 10) Spacer() Text( - "Firezone requires your permission to create VPN tunnels. Until it has that permission, all functionality will be disabled." + "Firezone requires your permission to create VPN profiles. Until it has that permission, all functionality will be disabled." ) .font(.body) .multilineTextAlignment(.center) @@ -63,5 +96,78 @@ struct GrantVPNView: View { Spacer() } ) +#elseif os(macOS) + VStack( + alignment: .center, + content: { + Spacer() + Image("LogoText") + .resizable() + .scaledToFit() + .frame(maxWidth: 200) + .padding(.horizontal, 10) + Spacer() + Spacer() + Text(""" + Firezone needs you to enable a System Extension and allow a VPN profile in order to function. + """) + .font(.title2) + .multilineTextAlignment(.center) + .padding(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 15)) + Spacer() + Spacer() + HStack(alignment: .top) { + Spacer() + VStack(alignment: .center) { + Text("Step 1: Enable the system extension") + .font(.title) + .strikethrough(model.isInstalled, color: .primary) + Text(""" + 1. Click the "Enable System Extension" button below. + 2. Click "Open System Settings" in the dialog that appears. + 3. Ensure the FirezoneNetworkExtension is toggled ON. + 4. Click Done. + """) + .font(.body) + .padding(.vertical, 10) + .opacity(model.isInstalled ? 0.5 : 1.0) + Spacer() + Button(action: { + model.installSystemExtensionButtonTapped() + }) { + Label("Enable System Extension", systemImage: "gearshape") + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(model.isInstalled) + } + Spacer() + VStack(alignment: .center) { + Text("Step 2: Allow the VPN profile") + .font(.title) + Text(""" + 1. Click the "Grant VPN Permission" button below. + 2. Click "Allow" in the dialog that appears. + """) + .font(.body) + .padding(.vertical, 10) + Spacer() + Button(action: { + model.grantPermissionButtonTapped() + }) { + Label("Grant VPN Permission", systemImage: "network.badge.shield.half.filled") + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(!model.isInstalled) + }.opacity(model.isInstalled ? 1.0 : 0.5) + Spacer() + } + Spacer() + Spacer() + } + ) + Spacer() +#endif } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift index 2268e44aa..138b1a542 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift @@ -261,7 +261,6 @@ public final class MenuBar: NSObject, ObservableObject { } @objc private func signInButtonTapped() { - NSApp.activate(ignoringOtherApps: true) WebAuthSession.signIn(store: model.store) } @@ -274,7 +273,12 @@ public final class MenuBar: NSObject, ObservableObject { @objc private func grantPermissionMenuItemTapped() { Task { do { - try await model.store.grantVPNPermissions() + // If we get here, it means either system extension got disabled or + // our VPN profile got removed. Since we don't know which, reinstall + // the system extension here too just in case. It's a no-op if already + // installed. + try await model.store.installSystemExtension() + try await model.store.grantVPNPermission() } catch { Log.error(error) }