fix(apple): Explicitly check if system extension is installed (#7691)

In #7680, I stated that a VPN profile's status goes to `.invalid` when
its associated system extension is yanked. That's not always true. I
observed cases where the profile was still in the `.disconnected` state
with the system extension disabled or removed.

So, now we explicitly check on startup for two distinct states:

- whether the system extension is installed
- whether the VPN profile is valid

Based on this, we show an improved GrantVPNView for macOS only with
clear steps the user must perform to get set up.

If the user accidentally closes this window, they can open the Menubar
and click "Allow VPN permission to sign in" which will both (re)install
the extension and allow the profile.

<img width="1012" alt="Screenshot 2025-01-07 at 5 23 19 PM"
src="https://github.com/user-attachments/assets/c36b078e-835b-4c6e-a186-bc2e5fef7799"
/>
 
<img width="1012" alt="Screenshot 2025-01-07 at 5 24 06 PM"
src="https://github.com/user-attachments/assets/23d84af4-4fdb-4f03-b8f9-07a1e09da891"
/>

<img width="1012" alt="Screenshot 2025-01-07 at 5 31 41 PM"
src="https://github.com/user-attachments/assets/5b88dfa4-1725-45f2-bd6e-1939b5639cf4"
/>
This commit is contained in:
Jamil
2025-01-08 06:34:22 -08:00
committed by GitHub
parent 071849e2c2
commit 6413a45589
5 changed files with 185 additions and 26 deletions

View File

@@ -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<Void, Error>?
// Delegate methods complete with either a true or false outcome or an Error
private var continuation: CheckedContinuation<Bool, Error>?
public func installSystemExtension(
identifier: String,
continuation: CheckedContinuation<Void, Error>
continuation: CheckedContinuation<Bool, Error>
) {
self.continuation = continuation
@@ -37,21 +35,48 @@ public class SystemExtensionManager: NSObject, OSSystemExtensionRequestDelegate,
OSSystemExtensionManager.shared.submitRequest(request)
}
public func isInstalled(
identifier: String,
continuation: CheckedContinuation<Bool, Error>
) {
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
}
}

View File

@@ -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<Void, Error>) in
let checker = SystemExtensionManager()
SystemExtensionManager.shared.installSystemExtension(
self.isInstalled = try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Bool, Error>) 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<Bool, Error>) 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()

View File

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

View File

@@ -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<AnyCancellable> = []
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
}
}

View File

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