mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
fix(apple/macos): Install system extension on app launch (#7459)
- Installs the system extension on app launch instead of each time we start the tunnel, as [recommended by Apple](https://developer.apple.com/documentation/systemextensions/installing-system-extensions-and-drivers). This will typically happen when the app is installed for the first time, or upgraded / downgraded. - Changes the completion handler functionality for observing the system extension status to an observed property on the class. This allows us to update the MenuBar based on the status of the installation, preventing the user from attempting to sign in unless the system extension has been installed. ~~This PR exposes a new, subtle issue - since we don't reinstall the system extension on each startTunnel, the process stays running. This is expected. However, now the logging handle needs to be maintained across connlib sessions, similar to the Android tunnel lifetime.~~ Fixed in #7460 Expect one or two more PRs to handle further edge cases with improved UX as more testing with the release build and upgrade/downgrade workflows are attempted.
This commit is contained in:
@@ -73,6 +73,10 @@ struct FirezoneApp: App {
|
||||
menuBar = MenuBar(model: SessionViewModel(favorites: favorites, store: store))
|
||||
}
|
||||
|
||||
// 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
|
||||
SystemExtensionManager.shared.installSystemExtension(identifier: TunnelManager.bundleIdentifier)
|
||||
|
||||
// SwiftUI will show the first window group, so close it on launch
|
||||
_ = AppViewModel.WindowDefinition.allCases.map { $0.window()?.close() }
|
||||
}
|
||||
|
||||
@@ -7,20 +7,19 @@
|
||||
#if os(macOS)
|
||||
import SystemExtensions
|
||||
|
||||
enum SystemExtensionError: Error {
|
||||
case UnexpectedResult(result: OSSystemExtensionRequest.Result)
|
||||
case NeedsUserApproval
|
||||
}
|
||||
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()
|
||||
|
||||
public class SystemExtensionManager: NSObject, OSSystemExtensionRequestDelegate {
|
||||
private var completionHandler: ((Error?) -> Void)?
|
||||
@Published public var status: ExtensionStatus = .unknown
|
||||
|
||||
public func installSystemExtension(
|
||||
identifier: String?,
|
||||
completionHandler: @escaping (Error?) -> Void
|
||||
) {
|
||||
guard let identifier = identifier else { return }
|
||||
self.completionHandler = completionHandler
|
||||
public enum ExtensionStatus {
|
||||
case awaitingUserApproval
|
||||
case installed
|
||||
case unknown
|
||||
}
|
||||
|
||||
public func installSystemExtension(identifier: String) {
|
||||
|
||||
let request = OSSystemExtensionRequest.activationRequest(forExtensionWithIdentifier: identifier, queue: .main)
|
||||
request.delegate = self
|
||||
@@ -33,23 +32,21 @@ public class SystemExtensionManager: NSObject, OSSystemExtensionRequestDelegate
|
||||
|
||||
public func request(_ request: OSSystemExtensionRequest, didFinishWithResult result: OSSystemExtensionRequest.Result) {
|
||||
guard result == .completed else {
|
||||
completionHandler?(SystemExtensionError.UnexpectedResult(result: result))
|
||||
status = .awaitingUserApproval
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Success
|
||||
completionHandler?(nil)
|
||||
status = .installed
|
||||
}
|
||||
|
||||
public func request(_ request: OSSystemExtensionRequest, didFailWithError error: Error) {
|
||||
completionHandler?(error)
|
||||
status = .awaitingUserApproval
|
||||
}
|
||||
|
||||
public func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) {
|
||||
completionHandler?(SystemExtensionError.NeedsUserApproval)
|
||||
|
||||
// TODO: Inform the user to approve the system extension in System Preferences > Security & Privacy.
|
||||
status = .awaitingUserApproval
|
||||
}
|
||||
|
||||
public func request(_ request: OSSystemExtensionRequest, actionForReplacingExtension existing: OSSystemExtensionProperties, withExtension ext: OSSystemExtensionProperties) -> OSSystemExtensionRequest.ReplacementAction {
|
||||
|
||||
@@ -93,11 +93,7 @@ public class TunnelManager {
|
||||
// Encoder used to send messages to the tunnel
|
||||
private let encoder = PropertyListEncoder()
|
||||
|
||||
#if os(macOS)
|
||||
private let systemExtensionManager = SystemExtensionManager()
|
||||
#endif
|
||||
|
||||
private let bundleIdentifier = "\(Bundle.main.bundleIdentifier!).network-extension"
|
||||
public static let bundleIdentifier: String = "\(Bundle.main.bundleIdentifier!).network-extension"
|
||||
private let bundleDescription = "Firezone"
|
||||
|
||||
init() {
|
||||
@@ -111,7 +107,7 @@ public class TunnelManager {
|
||||
let settings = Settings.defaultValue
|
||||
|
||||
protocolConfiguration.providerConfiguration = settings.toProviderConfiguration()
|
||||
protocolConfiguration.providerBundleIdentifier = bundleIdentifier
|
||||
protocolConfiguration.providerBundleIdentifier = TunnelManager.bundleIdentifier
|
||||
protocolConfiguration.serverAddress = settings.apiURL
|
||||
manager.localizedDescription = bundleDescription
|
||||
manager.protocolConfiguration = protocolConfiguration
|
||||
@@ -142,7 +138,7 @@ public class TunnelManager {
|
||||
Log.app.log("\(#function): \(managers.count) tunnel managers found")
|
||||
for manager in managers {
|
||||
if let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol,
|
||||
protocolConfiguration.providerBundleIdentifier == bundleIdentifier,
|
||||
protocolConfiguration.providerBundleIdentifier == TunnelManager.bundleIdentifier,
|
||||
let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String]
|
||||
{
|
||||
// Found it
|
||||
@@ -230,29 +226,6 @@ public class TunnelManager {
|
||||
options = ["token": token as NSObject]
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
// On macOS we use System Extensions, and we need to wait for them to be activated
|
||||
// before we can continue starting the tunnel. Otherwise, the tunnel will come up,
|
||||
// but then be killed immediately after since the system will reap its process as
|
||||
// the system extension is moved in place. This is more of an issue in development
|
||||
// where the system will replace the extension on each call to this API, ignoring the
|
||||
// version check that typically prevents extensions from being reactivated if they have the
|
||||
// same marketing version.
|
||||
systemExtensionManager.installSystemExtension(identifier: bundleIdentifier) { error in
|
||||
if let error = error {
|
||||
Log.app.error("\(#function): Installing system extension failed! \(error.localizedDescription)")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
self.startTunnel(options: options)
|
||||
}
|
||||
#else
|
||||
startTunnel(options: options)
|
||||
#endif
|
||||
}
|
||||
|
||||
func startTunnel(options: [String: NSObject]?) {
|
||||
do {
|
||||
try session().startTunnel(options: options)
|
||||
} catch {
|
||||
|
||||
@@ -27,6 +27,7 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
private var vpnStatus: NEVPNStatus = .disconnected
|
||||
private var extensionStatus: SystemExtensionManager.ExtensionStatus = .unknown
|
||||
|
||||
private var updateChecker: UpdateChecker = UpdateChecker()
|
||||
private var updateMenuDisplayed: Bool = false
|
||||
@@ -96,6 +97,16 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
self.updateStatusItemIcon()
|
||||
self.refreshUpdateItem()
|
||||
}).store(in: &cancellables)
|
||||
|
||||
SystemExtensionManager.shared.$status
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveValue: { [weak self] status in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.extensionStatus = status
|
||||
self.handleTunnelStatusOrResourcesChanged()
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private lazy var menu = NSMenu()
|
||||
@@ -271,6 +282,18 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func installSystemExtensionButtonTapped() {
|
||||
Task {
|
||||
SystemExtensionManager.shared.installSystemExtension(identifier: TunnelManager.bundleIdentifier)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func grantVPNPermissionButtonTapped() {
|
||||
Task {
|
||||
model.store.createVPNProfile()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func settingsButtonTapped() {
|
||||
AppViewModel.WindowDefinition.settings.openWindow()
|
||||
}
|
||||
@@ -366,25 +389,34 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
let resources = model.resources
|
||||
let status = model.status
|
||||
// Update "Sign In" / "Sign Out" menu items
|
||||
switch status {
|
||||
case .invalid:
|
||||
signInMenuItem.title = "Requires VPN permission"
|
||||
signInMenuItem.target = nil
|
||||
switch (extensionStatus, status) {
|
||||
case (.awaitingUserApproval, _):
|
||||
signInMenuItem.title = "Enable the system extension to sign in…"
|
||||
signInMenuItem.target = self
|
||||
signInMenuItem.action = #selector(installSystemExtensionButtonTapped)
|
||||
signOutMenuItem.isHidden = true
|
||||
settingsMenuItem.target = nil
|
||||
case .disconnected:
|
||||
case (_, .invalid):
|
||||
signInMenuItem.title = "Allow the VPN permission to sign in…"
|
||||
signInMenuItem.target = self
|
||||
signInMenuItem.action = #selector(grantVPNPermissionButtonTapped)
|
||||
signOutMenuItem.isHidden = true
|
||||
settingsMenuItem.target = nil
|
||||
case (_, .disconnected):
|
||||
signInMenuItem.title = "Sign In"
|
||||
signInMenuItem.target = self
|
||||
signInMenuItem.action = #selector(signInButtonTapped)
|
||||
signInMenuItem.isEnabled = true
|
||||
signOutMenuItem.isHidden = true
|
||||
settingsMenuItem.target = self
|
||||
case .disconnecting:
|
||||
signInMenuItem.title = "Signing out..."
|
||||
case (_, .disconnecting):
|
||||
signInMenuItem.title = "Signing out…"
|
||||
signInMenuItem.target = self
|
||||
signInMenuItem.action = #selector(signInButtonTapped)
|
||||
signInMenuItem.isEnabled = false
|
||||
signOutMenuItem.isHidden = true
|
||||
settingsMenuItem.target = self
|
||||
case .connected, .reasserting, .connecting:
|
||||
case (_, .connected), (_, .reasserting), (_, .connecting):
|
||||
let title = "Signed in as \(model.store.actorName ?? "Unknown User")"
|
||||
signInMenuItem.title = title
|
||||
signInMenuItem.target = nil
|
||||
|
||||
@@ -133,6 +133,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
adapter?.stop()
|
||||
|
||||
cancelTunnelWithError(nil)
|
||||
super.stopTunnel(with: reason, completionHandler: completionHandler)
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
override func handleAppMessage(_ message: Data, completionHandler: ((Data?) -> Void)? = nil) {
|
||||
|
||||
Reference in New Issue
Block a user