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