mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user