Apple: UI asking user to grant VPN permissions (#2959)

Expect this to fix #2850 and #2928.

When the app detects that there are no tunnels configured, it shows a UI
with a "Grant VPN Permission" button. On clicking that, the OS prompt
asking to allow VPN is shown.
This commit is contained in:
Roopesh Chander
2023-12-20 10:54:49 +05:30
committed by GitHub
parent f284e06014
commit baae3bd693
12 changed files with 421 additions and 238 deletions

View File

@@ -11,22 +11,54 @@ import SwiftUI
struct FirezoneApp: App {
#if os(macOS)
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject var askPermissionViewModel: AskPermissionViewModel
#endif
#if os(iOS)
@StateObject var model = AppViewModel()
@StateObject var appViewModel: AppViewModel
#endif
@StateObject var appStore = AppStore()
init() {
let appStore = AppStore()
self._appStore = StateObject(wrappedValue: appStore)
#if os(macOS)
self._askPermissionViewModel =
StateObject(wrappedValue: AskPermissionViewModel(tunnelStore: appStore.tunnelStore))
appDelegate.appStore = appStore
#elseif os(iOS)
self._appViewModel =
StateObject(wrappedValue: AppViewModel(appStore: appStore))
#endif
}
var body: some Scene {
#if os(iOS)
WindowGroup {
AppView(model: model)
AppView(model: appViewModel)
}
#else
WindowGroup("Settings", id: "firezone-settings") {
SettingsView(model: appDelegate.settingsViewModel)
WindowGroup(
"Firezone (VPN Permission)",
id: AppStore.WindowDefinition.askPermission.identifier
) {
AskPermissionView(model: askPermissionViewModel)
}
.handlesExternalEvents(matching: ["settings"])
.handlesExternalEvents(
matching: [AppStore.WindowDefinition.askPermission.externalEventMatchString]
)
WindowGroup(
"Settings",
id: AppStore.WindowDefinition.settings.identifier
) {
SettingsView(model: appStore.settingsViewModel)
}
.handlesExternalEvents(
matching: [AppStore.WindowDefinition.settings.externalEventMatchString]
)
#endif
}
}
@@ -34,15 +66,30 @@ struct FirezoneApp: App {
#if os(macOS)
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
let settingsViewModel = SettingsViewModel()
private var menuBar: MenuBar!
private var isAppLaunched = false
private var menuBar: MenuBar?
public var appStore: AppStore? {
didSet {
if self.isAppLaunched {
// This is not expected to happen because appStore
// should be set before the app finishes launching.
// This code is only a contingency.
if let appStore = self.appStore {
self.menuBar = MenuBar(appStore: appStore)
}
}
}
}
func applicationDidFinishLaunching(_: Notification) {
menuBar = MenuBar(settingsViewModel: settingsViewModel)
self.isAppLaunched = true
if let appStore = self.appStore {
self.menuBar = MenuBar(appStore: appStore)
}
// SwiftUI will show the first window group, so close it on launch
let window = NSApp.windows[0]
window.close()
_ = AppStore.WindowDefinition.allCases.map { $0.window()?.close() }
}
func applicationWillTerminate(_: Notification) {}

View File

@@ -15,13 +15,9 @@ import SwiftUINavigationCore
public final class AppViewModel: ObservableObject {
@Published var welcomeViewModel: WelcomeViewModel?
public init() {
public init(appStore: AppStore) {
Task {
self.welcomeViewModel = WelcomeViewModel(
appStore: AppStore(
tunnelStore: TunnelStore.shared
)
)
self.welcomeViewModel = WelcomeViewModel(appStore: appStore)
}
}
}
@@ -42,10 +38,4 @@ import SwiftUINavigationCore
}
}
}
struct AppView_Previews: PreviewProvider {
static var previews: some View {
AppView(model: AppViewModel())
}
}
#endif

View File

@@ -0,0 +1,151 @@
//
// AskPermissionView.swift
// (c) 2023 Firezone, Inc.
// LICENSE: Apache-2.0
//
import Combine
import Foundation
import SwiftUI
@MainActor
public final class AskPermissionViewModel: ObservableObject {
public var tunnelStore: TunnelStore
private var cancellables: Set<AnyCancellable> = []
@Published var needsTunnelPermission = false {
didSet {
#if os(macOS)
Task {
await MainActor.run {
AppStore.WindowDefinition.askPermission.bringAlreadyOpenWindowFront()
}
}
#endif
}
}
public init(tunnelStore: TunnelStore) {
self.tunnelStore = tunnelStore
tunnelStore.$tunnelAuthStatus
.filter { $0.isInitialized }
.sink { [weak self] tunnelAuthStatus in
guard let self = self else { return }
Task {
await MainActor.run {
if case .noTunnelFound = tunnelAuthStatus {
self.needsTunnelPermission = true
} else {
self.needsTunnelPermission = false
}
}
}
}
.store(in: &cancellables)
}
func grantPermissionButtonTapped() {
Task {
do {
try await self.tunnelStore.createTunnel()
} catch {
#if os(macOS)
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) {
AppStore.WindowDefinition.askPermission.bringAlreadyOpenWindowFront()
}
#endif
}
}
}
#if os(macOS)
func closeAskPermissionWindow() {
AppStore.WindowDefinition.askPermission.window()?.close()
}
#endif
}
public struct AskPermissionView: View {
@ObservedObject var model: AskPermissionViewModel
public init(model: AskPermissionViewModel) {
self.model = model
}
public var body: some View {
VStack(
alignment: .center,
content: {
Spacer()
Image("LogoText")
Spacer()
if $model.needsTunnelPermission.wrappedValue {
#if os(macOS)
Text(
"Firezone requires your permission to create VPN tunnels.\nUntil it has that permission, all functionality will be disabled."
)
.font(.body)
.multilineTextAlignment(.center)
#elseif os(iOS)
Text(
"Firezone requires your permission to create VPN tunnels. Until it has that permission, all functionality will be disabled."
)
.font(.body)
.multilineTextAlignment(.center)
#endif
Spacer()
Button("Grant VPN Permission") {
model.grantPermissionButtonTapped()
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
Spacer()
.frame(maxHeight: 20)
#if os(macOS)
Text(
"After clicking on the above button,\nclick on 'Allow' when prompted."
)
.font(.caption)
.multilineTextAlignment(.center)
#elseif os(iOS)
Text(
"After tapping on the above button, tap on 'Allow' when prompted."
)
.font(.caption)
.multilineTextAlignment(.center)
#endif
} else {
#if os(macOS)
Text(
"You can sign in to Firezone by clicking on the Firezone icon in the macOS menu bar.\nYou may now close this window."
)
.font(.body)
.multilineTextAlignment(.center)
Spacer()
Button("Close this Window") {
model.closeAskPermissionWindow()
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
Spacer()
.frame(maxHeight: 20)
Text(
"Firezone will continue running after this window is closed.\nIt will be available from the macOS menu bar."
)
.font(.caption)
.multilineTextAlignment(.center)
#endif
}
Spacer()
})
}
}

View File

@@ -12,12 +12,17 @@ import XCTestDynamicOverlay
@MainActor
final class AuthViewModel: ObservableObject {
@Dependency(\.authStore) private var authStore
let authStore: AuthStore
var settingsUndefined: () -> Void = unimplemented("\(AuthViewModel.self).settingsUndefined")
private var cancellables = Set<AnyCancellable>()
init(authStore: AuthStore) {
self.authStore = authStore
}
func signInButtonTapped() async {
do {
try await authStore.signIn()
@@ -48,9 +53,3 @@ struct AuthView: View {
})
}
}
struct AuthView_Previews: PreviewProvider {
static var previews: some View {
AuthView(model: AuthViewModel())
}
}

View File

@@ -29,26 +29,26 @@ import SwiftUI
}
private func setupObservers() {
appStore.auth.$loginStatus
appStore.authStore.$loginStatus
.receive(on: mainQueue)
.sink { [weak self] loginStatus in
self?.loginStatus = loginStatus
}
.store(in: &cancellables)
appStore.tunnel.$status
appStore.tunnelStore.$status
.receive(on: mainQueue)
.sink { [weak self] status in
self?.tunnelStatus = status
if status == .connected {
self?.appStore.tunnel.beginUpdatingResources()
self?.appStore.tunnelStore.beginUpdatingResources()
} else {
self?.appStore.tunnel.endUpdatingResources()
self?.appStore.tunnelStore.endUpdatingResources()
}
}
.store(in: &cancellables)
appStore.tunnel.$resources
appStore.tunnelStore.$resources
.receive(on: mainQueue)
.sink { [weak self] resources in
guard let self = self else { return }
@@ -61,20 +61,20 @@ import SwiftUI
func signOutButtonTapped() {
Task {
await appStore.auth.signOut()
await appStore.authStore.signOut()
}
}
func startTunnel() async {
if case .signedIn = self.loginStatus {
appStore.auth.startTunnel()
appStore.authStore.startTunnel()
}
}
func stopTunnel() {
Task {
do {
try await appStore.tunnel.stop()
try await appStore.tunnelStore.stop()
} catch {
logger.error("\(#function): Error stopping tunnel: \(error)")
}
@@ -112,6 +112,8 @@ import SwiftUI
Text("Signed Out")
case .uninitialized:
Text("Initializing…")
case .needsTunnelCreationPermission:
Text("Requires VPN permission")
}
}
}
@@ -150,16 +152,4 @@ import SwiftUI
pasteboard.string = resource.location
}
}
struct MainView_Previews: PreviewProvider {
static var previews: some View {
MainView(
model: MainViewModel(
appStore: AppStore(
tunnelStore: TunnelStore.shared
)
)
)
}
}
#endif

View File

@@ -18,7 +18,7 @@ enum SettingsViewError: Error {
public final class SettingsViewModel: ObservableObject {
private let logger = Logger.make(for: SettingsViewModel.self)
@Dependency(\.authStore) private var authStore
let authStore: AuthStore
var tunnelAuthStatus: TunnelAuthStatus {
authStore.tunnelStore.tunnelAuthStatus
@@ -29,7 +29,8 @@ public final class SettingsViewModel: ObservableObject {
public var onSettingsSaved: () -> Void = unimplemented()
private var cancellables = Set<AnyCancellable>()
public init() {
public init(authStore: AuthStore) {
self.authStore = authStore
advancedSettings = AdvancedSettings.defaultValue
loadSettings()
}
@@ -827,9 +828,3 @@ struct FormTextField: View {
}
}
#endif
struct SettingsView_Previews: PreviewProvider {
static var previews: some View {
SettingsView(model: SettingsViewModel())
}
}

View File

@@ -17,23 +17,19 @@ import SwiftUINavigationCore
private var cancellables = Set<AnyCancellable>()
enum Destination {
case settings(SettingsViewModel)
case undefinedSettingsAlert(AlertState<UndefinedSettingsAlertAction>)
}
enum UndefinedSettingsAlertAction {
case confirmDefineSettingsButtonTapped
}
enum State {
case uninitialized
case needsPermission(AskPermissionViewModel)
case unauthenticated(AuthViewModel)
case authenticated(MainViewModel)
}
@Published var destination: Destination? {
didSet {
bindDestination()
var shouldDisableSettings: Bool {
switch self {
case .uninitialized: return true
case .needsPermission: return true
case .unauthenticated: return false
case .authenticated: return false
}
}
}
@@ -45,17 +41,19 @@ import SwiftUINavigationCore
private let appStore: AppStore
let settingsViewModel: SettingsViewModel
@Published var isSettingsSheetPresented = false
init(appStore: AppStore) {
self.appStore = appStore
self.settingsViewModel = appStore.settingsViewModel
appStore.objectWillChange
.receive(on: mainQueue)
.sink { [weak self] in self?.objectWillChange.send() }
.store(in: &cancellables)
defer { bindDestination() }
appStore.auth.$loginStatus
appStore.authStore.$loginStatus
.receive(on: mainQueue)
.sink(receiveValue: { [weak self] loginStatus in
guard let self else {
@@ -65,45 +63,31 @@ import SwiftUINavigationCore
switch loginStatus {
case .signedIn:
self.state = .authenticated(MainViewModel(appStore: self.appStore))
default:
self.state = .unauthenticated(AuthViewModel())
case .signedOut:
self.state = .unauthenticated(AuthViewModel(authStore: self.appStore.authStore))
case .needsTunnelCreationPermission:
self.state = .needsPermission(
AskPermissionViewModel(tunnelStore: self.appStore.tunnelStore)
)
case .uninitialized:
self.state = .uninitialized
}
})
.store(in: &cancellables)
}
func settingsButtonTapped() {
destination = .settings(SettingsViewModel())
}
func handleUndefinedSettingsAlertAction(_ action: UndefinedSettingsAlertAction) {
switch action {
case .confirmDefineSettingsButtonTapped:
destination = .settings(SettingsViewModel())
}
}
private func bindDestination() {
switch destination {
case .settings(let model):
model.onSettingsSaved = { [weak self] in
self?.destination = nil
self?.state = .unauthenticated(AuthViewModel())
}
case .undefinedSettingsAlert, .none:
break
}
isSettingsSheetPresented = true
}
private func bindState() {
switch state {
case .unauthenticated(let model):
model.settingsUndefined = { [weak self] in
self?.destination = .undefinedSettingsAlert(.undefinedSettings)
self?.isSettingsSheetPresented = true
}
case .authenticated, .none:
case .authenticated, .uninitialized, .needsPermission, .none:
break
}
}
@@ -116,6 +100,10 @@ import SwiftUINavigationCore
NavigationView {
Group {
switch model.state {
case .uninitialized:
Image("LogoText")
case .needsPermission(let model):
AskPermissionView(model: model)
case .unauthenticated(let model):
AuthView(model: model)
case .authenticated(let model):
@@ -132,26 +120,13 @@ import SwiftUINavigationCore
} label: {
Label("Settings", systemImage: "gear")
}
.disabled(model.state?.shouldDisableSettings ?? true)
}
}
}
.sheet(unwrapping: $model.destination, case: /WelcomeViewModel.Destination.settings) {
$model in
SettingsView(model: model)
.sheet(isPresented: $model.isSettingsSheetPresented) {
SettingsView(model: model.settingsViewModel)
}
.alert(
unwrapping: $model.destination,
case: /WelcomeViewModel.Destination.undefinedSettingsAlert,
action: model.handleUndefinedSettingsAlertAction
)
}
}
struct WelcomeView_Previews: PreviewProvider {
static var previews: some View {
WelcomeView(
model: WelcomeViewModel(appStore: AppStore(tunnelStore: TunnelStore.shared))
)
}
}
#endif

View File

@@ -8,35 +8,98 @@ import Combine
import Dependencies
import OSLog
#if os(macOS)
import AppKit
#endif
@MainActor
final class AppStore: ObservableObject {
public final class AppStore: ObservableObject {
private let logger = Logger.make(for: AppStore.self)
@Dependency(\.authStore) var auth
@Dependency(\.mainQueue) var mainQueue
#if os(macOS)
public enum WindowDefinition: String, CaseIterable {
case askPermission = "ask-permission"
case settings = "settings"
public var identifier: String { "firezone-\(rawValue)" }
public var externalEventMatchString: String { rawValue }
public var externalEventOpenURL: URL { URL(string: "firezone://\(rawValue)")! }
@MainActor
public func bringAlreadyOpenWindowFront() {
if let window = NSApp.windows.first(where: {
$0.identifier?.rawValue.hasPrefix(identifier) ?? false
}) {
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(self)
}
}
@MainActor
public func openWindow() {
if let window = NSApp.windows.first(where: {
$0.identifier?.rawValue.hasPrefix(identifier) ?? false
}) {
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(self)
} else {
NSWorkspace.shared.open(externalEventOpenURL)
}
}
@MainActor
public func window() -> NSWindow? {
NSApp.windows.first { window in
if let windowId = window.identifier?.rawValue {
return windowId.hasPrefix(self.identifier)
}
return false
}
}
public static func allIdentifiers() -> [String] {
AppStore.WindowDefinition.allCases.map { $0.identifier }
}
}
#endif
public let authStore: AuthStore
public let tunnelStore: TunnelStore
public let settingsViewModel: SettingsViewModel
let tunnel: TunnelStore
private var cancellables: Set<AnyCancellable> = []
init(tunnelStore: TunnelStore) {
tunnel = tunnelStore
public init() {
let tunnelStore = TunnelStore()
let authStore = AuthStore(tunnelStore: tunnelStore)
let settingsViewModel = SettingsViewModel(authStore: authStore)
self.authStore = authStore
self.tunnelStore = tunnelStore
self.settingsViewModel = settingsViewModel
#if os(macOS)
tunnelStore.$tunnelAuthStatus
.sink { tunnelAuthStatus in
if case .noTunnelFound = tunnelAuthStatus {
Task {
await MainActor.run {
WindowDefinition.askPermission.openWindow()
}
}
}
}
.store(in: &cancellables)
#endif
Publishers.Merge(
auth.objectWillChange,
tunnel.objectWillChange
)
.receive(on: mainQueue)
.sink { [weak self] in
self?.objectWillChange.send()
}
.store(in: &cancellables)
}
private func signOutAndStopTunnel() {
Task {
do {
try await tunnel.stop()
await auth.signOut()
try await self.tunnelStore.stop()
await self.authStore.signOut()
} catch {
logger.error("\(#function): Error stopping tunnel: \(String(describing: error))")
}

View File

@@ -10,25 +10,13 @@ import Foundation
import NetworkExtension
import OSLog
extension AuthStore: DependencyKey {
static var liveValue: AuthStore = .shared
}
extension DependencyValues {
var authStore: AuthStore {
get { self[AuthStore.self] }
set { self[AuthStore.self] = newValue }
}
}
@MainActor
final class AuthStore: ObservableObject {
public final class AuthStore: ObservableObject {
private let logger = Logger.make(for: AuthStore.self)
static let shared = AuthStore(tunnelStore: TunnelStore.shared)
enum LoginStatus: CustomStringConvertible {
case uninitialized
case needsTunnelCreationPermission
case signedOut
case signedIn(actorName: String)
@@ -36,6 +24,8 @@ final class AuthStore: ObservableObject {
switch self {
case .uninitialized:
return "uninitialized"
case .needsTunnelCreationPermission:
return "needsTunnelCreationPermission"
case .signedOut:
return "signedOut"
case .signedIn(let actorName):
@@ -64,7 +54,7 @@ final class AuthStore: ObservableObject {
private let reconnectDelaySecs = 1
private var reconnectionAttemptsRemaining = maxReconnectionAttemptCount
private init(tunnelStore: TunnelStore) {
init(tunnelStore: TunnelStore) {
self.tunnelStore = tunnelStore
self.loginStatus = .uninitialized
@@ -113,8 +103,10 @@ final class AuthStore: ObservableObject {
private func getLoginStatus(from tunnelAuthStatus: TunnelAuthStatus) async -> LoginStatus {
switch tunnelAuthStatus {
case .tunnelUninitialized:
case .uninitialized:
return .uninitialized
case .noTunnelFound:
return .needsTunnelCreationPermission
case .signedOut:
return .signedOut
case .signedIn(let tunnelAuthBaseURL, let tokenReference):
@@ -246,6 +238,8 @@ final class AuthStore: ObservableObject {
try await tunnelStore.saveAuthStatus(.signedOut)
}
}
case .needsTunnelCreationPermission:
break
case .uninitialized:
break
}

View File

@@ -23,13 +23,11 @@ public struct TunnelProviderKeys {
public static let keyConnlibLogFilter = "connlibLogFilter"
}
final class TunnelStore: ObservableObject {
public final class TunnelStore: ObservableObject {
private static let logger = Logger.make(for: TunnelStore.self)
static let shared = TunnelStore()
@Published private var tunnel: NETunnelProviderManager?
@Published private(set) var tunnelAuthStatus: TunnelAuthStatus = .tunnelUninitialized
@Published private(set) var tunnelAuthStatus: TunnelAuthStatus = .uninitialized
@Published private(set) var status: NEVPNStatus {
didSet { TunnelStore.logger.info("status changed: \(self.status.description)") }
@@ -46,9 +44,9 @@ final class TunnelStore: ObservableObject {
private var stopTunnelContinuation: CheckedContinuation<(), Error>?
private var cancellables = Set<AnyCancellable>()
init() {
public init() {
self.tunnel = nil
self.tunnelAuthStatus = .tunnelUninitialized
self.tunnelAuthStatus = .uninitialized
self.status = .invalid
Task {
@@ -77,13 +75,7 @@ final class TunnelStore: ObservableObject {
self.tunnelAuthStatus = tunnel.authStatus()
self.status = tunnel.connection.status
} else {
let tunnel = NETunnelProviderManager()
tunnel.localizedDescription = "Firezone"
tunnel.protocolConfiguration = basicProviderProtocol()
try await tunnel.saveToPreferences()
Self.logger.log("\(#function): Tunnel created")
self.tunnel = tunnel
self.tunnelAuthStatus = .signedOut
self.tunnelAuthStatus = .noTunnelFound
}
setupTunnelObservers()
Self.logger.log("\(#function): TunnelStore initialized")
@@ -92,10 +84,23 @@ final class TunnelStore: ObservableObject {
}
}
func createTunnel() async throws {
guard self.tunnel == nil else {
return
}
let tunnel = NETunnelProviderManager()
tunnel.localizedDescription = "Firezone"
tunnel.protocolConfiguration = basicProviderProtocol()
try await tunnel.saveToPreferences()
Self.logger.log("\(#function): Tunnel created")
self.tunnel = tunnel
self.tunnelAuthStatus = tunnel.authStatus()
}
func saveAuthStatus(_ tunnelAuthStatus: TunnelAuthStatus) async throws {
Self.logger.log("TunnelStore.\(#function) \(tunnelAuthStatus, privacy: .public)")
guard let tunnel = tunnel else {
fatalError("Tunnel not initialized yet")
fatalError("No tunnel yet. Can't save auth status.")
}
let tunnelStatus = tunnel.connection.status
@@ -111,7 +116,7 @@ final class TunnelStore: ObservableObject {
func saveAdvancedSettings(_ advancedSettings: AdvancedSettings) async throws {
Self.logger.log("TunnelStore.\(#function) \(advancedSettings, privacy: .public)")
guard let tunnel = tunnel else {
fatalError("Tunnel not initialized yet")
fatalError("No tunnel yet. Can't save advanced settings.")
}
let tunnelStatus = tunnel.connection.status
@@ -126,7 +131,7 @@ final class TunnelStore: ObservableObject {
func advancedSettings() -> AdvancedSettings? {
guard let tunnel = tunnel else {
Self.logger.log("\(#function): Tunnel not initialized yet")
Self.logger.log("\(#function): No tunnel created yet")
return nil
}
@@ -148,7 +153,7 @@ final class TunnelStore: ObservableObject {
func start() async throws {
guard let tunnel = tunnel else {
Self.logger.log("\(#function): TunnelStore is not initialized")
Self.logger.log("\(#function): No tunnel created yet")
return
}
@@ -179,7 +184,7 @@ final class TunnelStore: ObservableObject {
func stop() async throws {
guard let tunnel = tunnel else {
Self.logger.log("\(#function): TunnelStore is not initialized")
Self.logger.log("\(#function): No tunnel created yet")
return
}
@@ -201,7 +206,7 @@ final class TunnelStore: ObservableObject {
func signOut() async throws -> Keychain.PersistentRef? {
guard let tunnel = tunnel else {
Self.logger.log("\(#function): TunnelStore is not initialized")
Self.logger.log("\(#function): No tunnel created yet")
return nil
}
@@ -249,7 +254,7 @@ final class TunnelStore: ObservableObject {
private func updateResources() {
guard let tunnel = tunnel else {
Self.logger.log("\(#function): TunnelStore is not initialized")
Self.logger.log("\(#function): No tunnel created yet")
return
}
@@ -333,7 +338,7 @@ final class TunnelStore: ObservableObject {
func removeProfile() async throws {
TunnelStore.logger.trace("\(#function)")
guard let tunnel = tunnel else {
Self.logger.log("\(#function): TunnelStore is not initialized")
Self.logger.log("\(#function): No tunnel created yet")
return
}
@@ -342,21 +347,24 @@ final class TunnelStore: ObservableObject {
}
enum TunnelAuthStatus: Equatable, CustomStringConvertible {
case tunnelUninitialized
case uninitialized
case noTunnelFound
case signedOut
case signedIn(authBaseURL: URL, tokenReference: Data)
var isInitialized: Bool {
switch self {
case .tunnelUninitialized: return false
case .uninitialized: return false
default: return true
}
}
var description: String {
switch self {
case .tunnelUninitialized:
case .uninitialized:
return "tunnel uninitialized"
case .noTunnelFound:
return "no tunnel found"
case .signedOut:
return "signedOut"
case .signedIn(let authBaseURL, _):
@@ -413,9 +421,9 @@ extension NETunnelProviderManager {
var providerConfig: [String: Any] = protocolConfiguration.providerConfiguration ?? [:]
switch authStatus {
case .tunnelUninitialized:
protocolConfiguration.passwordReference = nil
break
case .uninitialized, .noTunnelFound:
return
case .signedOut:
protocolConfiguration.passwordReference = nil
break

View File

@@ -1,20 +0,0 @@
//
// Alerts.swift
// (c) 2023 Firezone, Inc.
// LICENSE: Apache-2.0
//
import SwiftUINavigationCore
#if os(iOS)
extension AlertState where Action == WelcomeViewModel.UndefinedSettingsAlertAction {
static let undefinedSettings = AlertState(
title: TextState("No settings found."),
message: TextState("To sign in, you first need to configure portal settings."),
dismissButton: .default(
TextState("Define settings"),
action: .send(.confirmDefineSettingsButtonTapped)
)
)
}
#endif

View File

@@ -16,12 +16,6 @@
let logger = Logger.make(for: MenuBar.self)
@Dependency(\.mainQueue) private var mainQueue
private var appStore: AppStore? {
didSet {
setupObservers()
}
}
private var cancellables: Set<AnyCancellable> = []
private var statusItem: NSStatusItem
private var orderedResources: [DisplayableResources.Resource] = []
@@ -39,34 +33,30 @@
private var connectingAnimationImageIndex: Int = 0
private var connectingAnimationTimer: Timer?
let settingsViewModel: SettingsViewModel
private var appStore: AppStore
private var settingsViewModel: SettingsViewModel
private var loginStatus: AuthStore.LoginStatus = .signedOut
private var tunnelStatus: NEVPNStatus = .invalid
public init(settingsViewModel: SettingsViewModel) {
self.settingsViewModel = settingsViewModel
public init(appStore: AppStore) {
self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
settingsViewModel.onSettingsSaved = {
// TODO: close settings window and sign in
}
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
self.appStore = appStore
self.settingsViewModel = appStore.settingsViewModel
super.init()
createMenu()
setupObservers()
if let button = statusItem.button {
button.image = signedOutIcon
}
Task {
self.appStore = AppStore(tunnelStore: TunnelStore.shared)
updateStatusItemIcon()
}
updateStatusItemIcon()
}
private func setupObservers() {
appStore?.auth.$loginStatus
appStore.authStore.$loginStatus
.receive(on: mainQueue)
.sink { [weak self] loginStatus in
self?.loginStatus = loginStatus
@@ -75,7 +65,7 @@
}
.store(in: &cancellables)
appStore?.tunnel.$status
appStore.tunnelStore.$status
.receive(on: mainQueue)
.sink { [weak self] status in
self?.tunnelStatus = status
@@ -85,7 +75,7 @@
}
.store(in: &cancellables)
appStore?.tunnel.$resources
appStore.tunnelStore.$resources
.receive(on: mainQueue)
.sink { [weak self] resources in
guard let self = self else { return }
@@ -147,7 +137,7 @@
menu,
title: "Settings",
action: #selector(settingsButtonTapped),
target: self
target: nil
)
private lazy var quitMenuItem: NSMenuItem = {
let menuItem = createMenuItem(
@@ -200,15 +190,15 @@
}
@objc private func reconnectButtonTapped() {
if case .signedIn = appStore?.auth.loginStatus {
appStore?.auth.startTunnel()
if case .signedIn = appStore.authStore.loginStatus {
appStore.authStore.startTunnel()
}
}
@objc private func signInButtonTapped() {
Task {
do {
try await appStore?.auth.signIn()
try await appStore.authStore.signIn()
} catch {
logger.error("Error signing in: \(String(describing: error))")
}
@@ -217,12 +207,12 @@
@objc private func signOutButtonTapped() {
Task {
await appStore?.auth.signOut()
await appStore.authStore.signOut()
}
}
@objc private func settingsButtonTapped() {
openSettingsWindow()
AppStore.WindowDefinition.settings.openWindow()
}
@objc private func aboutButtonTapped() {
@@ -233,7 +223,7 @@
@objc private func quitButtonTapped() {
Task {
do {
try await appStore?.tunnel.stop()
try await appStore.tunnelStore.stop()
} catch {
logger.error("\(#function): Error stopping tunnel: \(error)")
}
@@ -241,21 +231,10 @@
}
}
private func openSettingsWindow() {
if let settingsWindow = NSApp.windows.first(where: {
$0.identifier?.rawValue.hasPrefix("firezone-settings") ?? false
}) {
NSApp.activate(ignoringOtherApps: true)
settingsWindow.makeKeyAndOrderFront(self)
} else {
NSWorkspace.shared.open(URL(string: "firezone://settings")!)
}
}
private func updateStatusItemIcon() {
self.statusItem.button?.image = {
switch self.loginStatus {
case .signedOut, .uninitialized:
case .signedOut, .uninitialized, .needsTunnelCreationPermission:
return self.signedOutIcon
case .signedIn:
switch self.tunnelStatus {
@@ -308,15 +287,23 @@
signInMenuItem.title = "Initializing"
signInMenuItem.target = nil
signOutMenuItem.isHidden = true
settingsMenuItem.target = nil
case .needsTunnelCreationPermission:
signInMenuItem.title = "Requires VPN permission"
signInMenuItem.target = nil
signOutMenuItem.isHidden = true
settingsMenuItem.target = nil
case .signedOut:
signInMenuItem.title = "Sign In"
signInMenuItem.target = self
signInMenuItem.isEnabled = true
signOutMenuItem.isHidden = true
settingsMenuItem.target = self
case .signedIn(let actorName):
signInMenuItem.title = actorName.isEmpty ? "Signed in" : "Signed in as \(actorName)"
signInMenuItem.target = nil
signOutMenuItem.isHidden = false
settingsMenuItem.target = self
}
// Update resources "header" menu items
switch (self.loginStatus, self.tunnelStatus) {
@@ -325,6 +312,11 @@
resourcesUnavailableMenuItem.isHidden = true
resourcesUnavailableReasonMenuItem.isHidden = true
resourcesSeparatorMenuItem.isHidden = true
case (.needsTunnelCreationPermission, _):
resourcesTitleMenuItem.isHidden = true
resourcesUnavailableMenuItem.isHidden = true
resourcesUnavailableReasonMenuItem.isHidden = true
resourcesSeparatorMenuItem.isHidden = true
case (.signedOut, _):
resourcesTitleMenuItem.isHidden = true
resourcesUnavailableMenuItem.isHidden = true
@@ -378,12 +370,11 @@
}
private func handleMenuVisibilityOrStatusChanged() {
guard let appStore = appStore else { return }
let status = appStore.tunnel.status
let status = appStore.tunnelStore.status
if isMenuVisible && status == .connected {
appStore.tunnel.beginUpdatingResources()
appStore.tunnelStore.beginUpdatingResources()
} else {
appStore.tunnel.endUpdatingResources()
appStore.tunnelStore.endUpdatingResources()
}
}