mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
refactor(apple): Collapse ViewModels to app-wide Store (#8218)
Our application state is incredibly simple, only consisting of a handful of properties. Throughout our codebase, we use a singular global state store called `Store`. We then inject this as the singular piece of state into each view's model. This is unnecessary boilerplate and leads to lots of duplicated logic. Instead we refactor away (nearly) all of the application's view models and instead use an `@EnvironmentObject` to inject the store into each view. This convention drastically simplifies state tracking logic and boilerplate in the views.
This commit is contained in:
@@ -13,8 +13,6 @@ struct FirezoneApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
#endif
|
||||
|
||||
@StateObject var favorites: Favorites
|
||||
@StateObject var appViewModel: AppViewModel
|
||||
@StateObject var store: Store
|
||||
@StateObject private var errorHandler = GlobalErrorHandler()
|
||||
|
||||
@@ -22,47 +20,47 @@ struct FirezoneApp: App {
|
||||
// Initialize Telemetry as early as possible
|
||||
Telemetry.start()
|
||||
|
||||
let favorites = Favorites()
|
||||
let store = Store()
|
||||
_favorites = StateObject(wrappedValue: favorites)
|
||||
_store = StateObject(wrappedValue: store)
|
||||
_appViewModel = StateObject(wrappedValue: AppViewModel(favorites: favorites, store: store))
|
||||
|
||||
#if os(macOS)
|
||||
appDelegate.store = store
|
||||
appDelegate.favorites = favorites
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
#if os(iOS)
|
||||
WindowGroup {
|
||||
AppView(model: appViewModel).environmentObject(errorHandler)
|
||||
AppView()
|
||||
.environmentObject(errorHandler)
|
||||
.environmentObject(store)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
WindowGroup(
|
||||
"Welcome to Firezone",
|
||||
id: AppViewModel.WindowDefinition.main.identifier
|
||||
id: AppView.WindowDefinition.main.identifier
|
||||
) {
|
||||
if let menuBar = appDelegate.menuBar {
|
||||
// menuBar will be initialized by this point
|
||||
AppView(model: appViewModel).environmentObject(menuBar)
|
||||
} else {
|
||||
if appDelegate.menuBar == nil {
|
||||
ProgressView("Loading...")
|
||||
} else {
|
||||
// menuBar will be initialized by this point
|
||||
AppView()
|
||||
.environmentObject(store)
|
||||
}
|
||||
}
|
||||
.handlesExternalEvents(
|
||||
matching: [AppViewModel.WindowDefinition.main.externalEventMatchString]
|
||||
matching: [AppView.WindowDefinition.main.externalEventMatchString]
|
||||
)
|
||||
// macOS doesn't have Sheets, need to use another Window group to show settings
|
||||
WindowGroup(
|
||||
"Settings",
|
||||
id: AppViewModel.WindowDefinition.settings.identifier
|
||||
id: AppView.WindowDefinition.settings.identifier
|
||||
) {
|
||||
SettingsView(favorites: favorites, model: SettingsViewModel(store: store))
|
||||
SettingsView()
|
||||
.environmentObject(store)
|
||||
}
|
||||
.handlesExternalEvents(
|
||||
matching: [AppViewModel.WindowDefinition.settings.externalEventMatchString]
|
||||
matching: [AppView.WindowDefinition.settings.externalEventMatchString]
|
||||
)
|
||||
#endif
|
||||
}
|
||||
@@ -71,18 +69,16 @@ struct FirezoneApp: App {
|
||||
#if os(macOS)
|
||||
@MainActor
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
var favorites: Favorites?
|
||||
var menuBar: MenuBar?
|
||||
var store: Store?
|
||||
|
||||
func applicationDidFinishLaunching(_: Notification) {
|
||||
if let store,
|
||||
let favorites {
|
||||
menuBar = MenuBar(model: SessionViewModel(favorites: favorites, store: store))
|
||||
if let store {
|
||||
menuBar = MenuBar(store: store)
|
||||
}
|
||||
|
||||
// SwiftUI will show the first window group, so close it on launch
|
||||
_ = AppViewModel.WindowDefinition.allCases.map { $0.window()?.close() }
|
||||
_ = AppView.WindowDefinition.allCases.map { $0.window()?.close() }
|
||||
|
||||
// Show alert for macOS 15.0.x which has issues with Network Extensions.
|
||||
maybeShowOutdatedAlert()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
public class Favorites: ObservableObject {
|
||||
public final class Favorites: ObservableObject {
|
||||
private static let key = "favoriteResourceIDs"
|
||||
@Published private(set) var ids: Set<String>
|
||||
private var ids: Set<String>
|
||||
|
||||
public init() {
|
||||
ids = Favorites.load()
|
||||
@@ -30,6 +30,10 @@ public class Favorites: ObservableObject {
|
||||
save()
|
||||
}
|
||||
|
||||
func isEmpty() -> Bool {
|
||||
return ids.isEmpty
|
||||
}
|
||||
|
||||
private func save() {
|
||||
// It's a run-time exception if we pass the `Set` directly here
|
||||
let ids = Array(ids)
|
||||
|
||||
@@ -15,15 +15,19 @@ import AppKit
|
||||
|
||||
@MainActor
|
||||
public final class Store: ObservableObject {
|
||||
@Published private(set) var favorites = Favorites()
|
||||
@Published private(set) var resourceList: ResourceList = .loading
|
||||
@Published private(set) var actorName: String?
|
||||
|
||||
// Make our tunnel configuration convenient for SettingsView to consume
|
||||
@Published private(set) var settings: Settings
|
||||
@Published var settings: Settings
|
||||
|
||||
// Enacapsulate Tunnel status here to make it easier for other components
|
||||
// to observe
|
||||
@Published private(set) var status: NEVPNStatus?
|
||||
|
||||
@Published private(set) var decision: UNAuthorizationStatus?
|
||||
|
||||
#if os(macOS)
|
||||
// Track whether our system extension has been installed (macOS)
|
||||
@Published private(set) var systemExtensionStatus: SystemExtensionStatus?
|
||||
@@ -39,6 +43,44 @@ public final class Store: ObservableObject {
|
||||
self.settings = Settings.defaultValue
|
||||
self.sessionNotification = SessionNotification()
|
||||
self.vpnConfigurationManager = VPNConfigurationManager()
|
||||
|
||||
self.sessionNotification.signInHandler = {
|
||||
Task {
|
||||
do { try await WebAuthSession.signIn(store: self) } catch { Log.error(error) }
|
||||
}
|
||||
}
|
||||
|
||||
Task {
|
||||
// Load user's decision whether to allow / disallow notifications
|
||||
self.decision = await self.sessionNotification.loadAuthorizationStatus()
|
||||
|
||||
// Load VPN configuration and system extension status
|
||||
do {
|
||||
try await self.bindToVPNConfigurationUpdates()
|
||||
let vpnConfigurationStatus = self.status
|
||||
|
||||
#if os(macOS)
|
||||
let systemExtensionStatus = try await self.checkedSystemExtensionStatus()
|
||||
|
||||
if systemExtensionStatus != .installed
|
||||
|| vpnConfigurationStatus == .invalid {
|
||||
|
||||
// Show the main Window if VPN permission needs to be granted
|
||||
AppView.WindowDefinition.main.openWindow()
|
||||
} else {
|
||||
AppView.WindowDefinition.main.window()?.close()
|
||||
}
|
||||
#endif
|
||||
|
||||
if vpnConfigurationStatus == .disconnected {
|
||||
|
||||
// Try to connect on start
|
||||
try self.vpnConfigurationManager.start()
|
||||
}
|
||||
} catch {
|
||||
Log.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func internetResourceEnabled() -> Bool {
|
||||
@@ -61,6 +103,17 @@ public final class Store: ObservableObject {
|
||||
self.actorName = actorName
|
||||
}
|
||||
|
||||
if status == .connected {
|
||||
self.beginUpdatingResources { resourceList in
|
||||
self.resourceList = resourceList
|
||||
}
|
||||
}
|
||||
|
||||
if status == .disconnected {
|
||||
self.endUpdatingResources()
|
||||
self.resourceList = ResourceList.loading
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
// On macOS we must show notifications from the UI process. On iOS, we've already initiated the notification
|
||||
// from the tunnel process, because the UI process is not guaranteed to be alive.
|
||||
@@ -121,6 +174,10 @@ public final class Store: ObservableObject {
|
||||
try await bindToVPNConfigurationUpdates()
|
||||
}
|
||||
|
||||
func grantNotifications() async throws {
|
||||
self.decision = try await sessionNotification.askUserForNotificationPermissions()
|
||||
}
|
||||
|
||||
func authURL() -> URL? {
|
||||
return URL(string: settings.authBaseURL)
|
||||
}
|
||||
|
||||
@@ -18,123 +18,11 @@ import UserNotifications
|
||||
/// - macOS only shows the WelcomeView on first launch (like Windows/Linux)
|
||||
/// - iOS shows the WelcomeView as it main view for launching auth
|
||||
|
||||
@MainActor
|
||||
public class AppViewModel: ObservableObject {
|
||||
let favorites: Favorites
|
||||
let store: Store
|
||||
let sessionNotification: SessionNotification
|
||||
|
||||
@Published private(set) var status: NEVPNStatus?
|
||||
@Published private(set) var decision: UNAuthorizationStatus?
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
public init(favorites: Favorites, store: Store) {
|
||||
self.favorites = favorites
|
||||
self.store = store
|
||||
self.sessionNotification = SessionNotification()
|
||||
|
||||
self.sessionNotification.signInHandler = {
|
||||
Task {
|
||||
do { try await WebAuthSession.signIn(store: self.store) } catch { Log.error(error) }
|
||||
}
|
||||
}
|
||||
|
||||
Task {
|
||||
// Load user's decision whether to allow / disallow notifications
|
||||
let decision = await self.sessionNotification.loadAuthorizationStatus()
|
||||
updateNotificationDecision(to: decision)
|
||||
|
||||
// Load VPN configuration and system extension status
|
||||
do {
|
||||
try await self.store.bindToVPNConfigurationUpdates()
|
||||
let vpnConfigurationStatus = self.store.status
|
||||
|
||||
#if os(macOS)
|
||||
let systemExtensionStatus = try await self.store.checkedSystemExtensionStatus()
|
||||
|
||||
if systemExtensionStatus != .installed
|
||||
|| vpnConfigurationStatus == .invalid {
|
||||
|
||||
// Show the main Window if VPN permission needs to be granted
|
||||
AppViewModel.WindowDefinition.main.openWindow()
|
||||
} else {
|
||||
AppViewModel.WindowDefinition.main.window()?.close()
|
||||
}
|
||||
#endif
|
||||
|
||||
if vpnConfigurationStatus == .disconnected {
|
||||
|
||||
// Try to connect on start
|
||||
try self.store.vpnConfigurationManager.start()
|
||||
}
|
||||
} catch {
|
||||
Log.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
store.$status
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveValue: { [weak self] status in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.status = status
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func updateNotificationDecision(to newStatus: UNAuthorizationStatus) {
|
||||
self.decision = newStatus
|
||||
}
|
||||
}
|
||||
|
||||
public struct AppView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
public init(model: AppViewModel) {
|
||||
self.model = model
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
public var body: some View {
|
||||
#if os(iOS)
|
||||
switch (model.status, model.decision) {
|
||||
case (nil, _), (_, nil):
|
||||
ProgressView()
|
||||
case (.invalid, _):
|
||||
GrantVPNView(model: GrantVPNViewModel(store: model.store))
|
||||
case (_, .notDetermined):
|
||||
GrantNotificationsView(model: GrantNotificationsViewModel(
|
||||
sessionNotification: model.sessionNotification,
|
||||
onDecisionChanged: { decision in
|
||||
model.updateNotificationDecision(to: decision)
|
||||
}
|
||||
))
|
||||
case (.disconnected, _):
|
||||
iOSNavigationView(model: model) {
|
||||
WelcomeView(model: WelcomeViewModel(store: model.store))
|
||||
}
|
||||
case (_, _):
|
||||
iOSNavigationView(model: model) {
|
||||
SessionView(model: SessionViewModel(favorites: model.favorites, store: model.store))
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
switch (model.store.systemExtensionStatus, model.status) {
|
||||
case (nil, nil):
|
||||
ProgressView()
|
||||
case (.needsInstall, _), (_, .invalid):
|
||||
GrantVPNView(model: GrantVPNViewModel(store: model.store))
|
||||
default:
|
||||
FirstTimeView()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@EnvironmentObject var store: Store
|
||||
|
||||
#if os(macOS)
|
||||
public extension AppViewModel {
|
||||
enum WindowDefinition: String, CaseIterable {
|
||||
public enum WindowDefinition: String, CaseIterable {
|
||||
case main
|
||||
case settings
|
||||
|
||||
@@ -164,5 +52,38 @@ public extension AppViewModel {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
public init() {}
|
||||
|
||||
@ViewBuilder
|
||||
public var body: some View {
|
||||
#if os(iOS)
|
||||
switch (store.status, store.decision) {
|
||||
case (nil, _), (_, nil):
|
||||
ProgressView()
|
||||
case (.invalid, _):
|
||||
GrantVPNView()
|
||||
case (_, .notDetermined):
|
||||
GrantNotificationsView()
|
||||
case (.disconnected, _):
|
||||
iOSNavigationView {
|
||||
WelcomeView()
|
||||
}
|
||||
case (_, _):
|
||||
iOSNavigationView {
|
||||
SessionView()
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
switch (store.systemExtensionStatus, store.status) {
|
||||
case (nil, nil):
|
||||
ProgressView()
|
||||
case (.needsInstall, _), (_, .invalid):
|
||||
GrantVPNView()
|
||||
default:
|
||||
FirstTimeView()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,13 +31,13 @@ struct FirstTimeView: View {
|
||||
Spacer()
|
||||
HStack {
|
||||
Button("Close this window") {
|
||||
AppViewModel.WindowDefinition.main.window()?.close()
|
||||
AppView.WindowDefinition.main.window()?.close()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
Button("Open menu") {
|
||||
menuBar.showMenu()
|
||||
AppViewModel.WindowDefinition.main.window()?.close()
|
||||
AppView.WindowDefinition.main.window()?.close()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
|
||||
@@ -9,39 +9,8 @@ import Foundation
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
|
||||
@MainActor
|
||||
public final class GrantNotificationsViewModel: ObservableObject {
|
||||
private let sessionNotification: SessionNotification
|
||||
private let onDecisionChanged: (UNAuthorizationStatus) async -> Void
|
||||
|
||||
init(
|
||||
sessionNotification: SessionNotification,
|
||||
onDecisionChanged: @escaping (UNAuthorizationStatus) async -> Void
|
||||
) {
|
||||
self.sessionNotification = sessionNotification
|
||||
self.onDecisionChanged = onDecisionChanged
|
||||
}
|
||||
|
||||
func grantNotificationButtonTapped(errorHandler: GlobalErrorHandler) {
|
||||
Task {
|
||||
do {
|
||||
let decision = try await sessionNotification.askUserForNotificationPermissions()
|
||||
await onDecisionChanged(decision)
|
||||
} catch {
|
||||
Log.error(error)
|
||||
|
||||
errorHandler.handle(ErrorAlert(
|
||||
title: "Error granting notifications",
|
||||
error: error
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
struct GrantNotificationsView: View {
|
||||
@ObservedObject var model: GrantNotificationsViewModel
|
||||
@EnvironmentObject var store: Store
|
||||
@EnvironmentObject var errorHandler: GlobalErrorHandler
|
||||
|
||||
public var body: some View {
|
||||
@@ -66,7 +35,7 @@ struct GrantNotificationsView: View {
|
||||
.imageScale(.large)
|
||||
Spacer()
|
||||
Button("Grant Notification Permission") {
|
||||
model.grantNotificationButtonTapped(errorHandler: errorHandler)
|
||||
grantNotifications()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
@@ -80,4 +49,19 @@ struct GrantNotificationsView: View {
|
||||
Spacer()
|
||||
})
|
||||
}
|
||||
|
||||
func grantNotifications () {
|
||||
Task {
|
||||
do {
|
||||
try await store.grantNotifications()
|
||||
} catch {
|
||||
Log.error(error)
|
||||
|
||||
errorHandler.handle(ErrorAlert(
|
||||
title: "Error granting notifications",
|
||||
error: error
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,82 +8,8 @@
|
||||
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
|
||||
|
||||
#if os(macOS)
|
||||
store.$systemExtensionStatus
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveValue: { [weak self] status in
|
||||
self?.isInstalled = status == .installed
|
||||
}).store(in: &cancellables)
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
func installSystemExtensionButtonTapped() {
|
||||
Task {
|
||||
do {
|
||||
try await store.installSystemExtension()
|
||||
|
||||
// The window has a tendency to go to the background after installing
|
||||
// the system extension
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
|
||||
} catch {
|
||||
Log.error(error)
|
||||
await macOSAlert.show(for: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func grantPermissionButtonTapped() {
|
||||
Log.log("\(#function)")
|
||||
Task {
|
||||
do {
|
||||
try await store.grantVPNPermission()
|
||||
|
||||
// The window has a tendency to go to the background after allowing the
|
||||
// VPN configuration
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
} catch {
|
||||
Log.error(error)
|
||||
await macOSAlert.show(for: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
func grantPermissionButtonTapped(errorHandler: GlobalErrorHandler) {
|
||||
Log.log("\(#function)")
|
||||
Task {
|
||||
do {
|
||||
try await store.grantVPNPermission()
|
||||
} catch {
|
||||
Log.error(error)
|
||||
|
||||
errorHandler.handle(
|
||||
ErrorAlert(
|
||||
title: "Error installing VPN configuration",
|
||||
error: error
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
struct GrantVPNView: View {
|
||||
@ObservedObject var model: GrantVPNViewModel
|
||||
@EnvironmentObject var store: Store
|
||||
@EnvironmentObject var errorHandler: GlobalErrorHandler
|
||||
|
||||
var body: some View {
|
||||
@@ -110,7 +36,7 @@ struct GrantVPNView: View {
|
||||
.imageScale(.large)
|
||||
Spacer()
|
||||
Button("Grant VPN Permission") {
|
||||
model.grantPermissionButtonTapped(errorHandler: errorHandler)
|
||||
grantVPNPermission()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
@@ -146,7 +72,7 @@ struct GrantVPNView: View {
|
||||
VStack(alignment: .center) {
|
||||
Text("Step 1: Enable the system extension")
|
||||
.font(.title)
|
||||
.strikethrough(model.isInstalled, color: .primary)
|
||||
.strikethrough(isInstalled(), color: .primary)
|
||||
Text("""
|
||||
1. Click the "Enable System Extension" button below.
|
||||
2. Click "Open System Settings" in the dialog that appears.
|
||||
@@ -155,11 +81,11 @@ struct GrantVPNView: View {
|
||||
""")
|
||||
.font(.body)
|
||||
.padding(.vertical, 10)
|
||||
.opacity(model.isInstalled ? 0.5 : 1.0)
|
||||
.opacity(isInstalled() ? 0.5 : 1.0)
|
||||
Spacer()
|
||||
Button(
|
||||
action: {
|
||||
model.installSystemExtensionButtonTapped()
|
||||
installSystemExtension()
|
||||
},
|
||||
label: {
|
||||
Label("Enable System Extension", systemImage: "gearshape")
|
||||
@@ -167,7 +93,7 @@ struct GrantVPNView: View {
|
||||
)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.disabled(model.isInstalled)
|
||||
.disabled(isInstalled())
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .center) {
|
||||
@@ -182,7 +108,7 @@ struct GrantVPNView: View {
|
||||
Spacer()
|
||||
Button(
|
||||
action: {
|
||||
model.grantPermissionButtonTapped()
|
||||
grantVPNPermission()
|
||||
},
|
||||
label: {
|
||||
Label("Grant VPN Permission", systemImage: "network.badge.shield.half.filled")
|
||||
@@ -190,8 +116,8 @@ struct GrantVPNView: View {
|
||||
)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.disabled(!model.isInstalled)
|
||||
}.opacity(model.isInstalled ? 1.0 : 0.5)
|
||||
.disabled(!isInstalled())
|
||||
}.opacity(isInstalled() ? 1.0 : 0.5)
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
@@ -201,4 +127,53 @@ struct GrantVPNView: View {
|
||||
Spacer()
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
func installSystemExtension() {
|
||||
Task {
|
||||
do {
|
||||
try await store.installSystemExtension()
|
||||
|
||||
// The window has a tendency to go to the background after installing
|
||||
// the system extension
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
} catch {
|
||||
Log.error(error)
|
||||
await macOSAlert.show(for: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func grantVPNPermission() {
|
||||
Task {
|
||||
do {
|
||||
try await store.grantVPNPermission()
|
||||
} catch {
|
||||
Log.error(error)
|
||||
await macOSAlert.show(for: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isInstalled() -> Bool {
|
||||
return store.systemExtensionStatus == .installed
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
func grantVPNPermission() {
|
||||
Task {
|
||||
do {
|
||||
try await store.grantVPNPermission()
|
||||
} catch {
|
||||
Log.error(error)
|
||||
|
||||
errorHandler.handle(ErrorAlert(
|
||||
title: "Error granting VPN permission",
|
||||
error: error
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -30,12 +30,10 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
private var vpnStatus: NEVPNStatus?
|
||||
|
||||
private var updateChecker: UpdateChecker = UpdateChecker()
|
||||
private var updateMenuDisplayed: Bool = false
|
||||
|
||||
@ObservedObject var model: SessionViewModel
|
||||
let store: Store
|
||||
|
||||
private var signedOutIcon: NSImage?
|
||||
private var signedInConnectedIcon: NSImage?
|
||||
@@ -53,9 +51,9 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
private var connectingAnimationImageIndex: Int = 0
|
||||
private var connectingAnimationTimer: Timer?
|
||||
|
||||
public init(model: SessionViewModel) {
|
||||
public init(store: Store) {
|
||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
self.model = model
|
||||
self.store = store
|
||||
self.signedOutIcon = NSImage(named: "MenuBarIconSignedOut")
|
||||
self.signedInConnectedIcon = NSImage(named: "MenuBarIconSignedInConnected")
|
||||
self.signedOutIconNotification = NSImage(named: "MenuBarIconSignedOutNotification")
|
||||
@@ -79,38 +77,43 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
private func setupObservers() {
|
||||
model.favorites.$ids
|
||||
// Favorites explicitly sends objectWillChange for lifecycle events. The instance in Store never changes.
|
||||
store.favorites.objectWillChange
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveValue: { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
|
||||
// When the user clicks to add or remove a favorite, the menu will close anyway, so just recreate the whole
|
||||
// menu. This avoids complex logic when changing in and out of the "nothing is favorited" special case.
|
||||
self.populateResourceMenus([])
|
||||
self.populateResourceMenus(model.resources.asArray())
|
||||
self.populateResourceMenus(store.resourceList.asArray())
|
||||
}).store(in: &cancellables)
|
||||
|
||||
model.$resources
|
||||
store.$resourceList
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveValue: { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.populateResourceMenus(model.resources.asArray())
|
||||
|
||||
self.populateResourceMenus(store.resourceList.asArray())
|
||||
self.handleTunnelStatusOrResourcesChanged()
|
||||
}).store(in: &cancellables)
|
||||
|
||||
model.$status
|
||||
store.$status
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveValue: { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.vpnStatus = model.status
|
||||
|
||||
self.updateStatusItemIcon()
|
||||
self.handleTunnelStatusOrResourcesChanged()
|
||||
}).store(in: &cancellables)
|
||||
|
||||
updateChecker.$updateAvailable
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveValue: {[weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.updateStatusItemIcon()
|
||||
self.refreshUpdateItem()
|
||||
.sink(receiveValue: { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.updateStatusItemIcon()
|
||||
self.refreshUpdateItem()
|
||||
}).store(in: &cancellables)
|
||||
}
|
||||
|
||||
@@ -243,7 +246,7 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
menu.addItem(resourcesUnavailableReasonMenuItem)
|
||||
menu.addItem(resourcesSeparatorMenuItem)
|
||||
|
||||
if !model.favorites.ids.isEmpty {
|
||||
if !store.favorites.isEmpty() {
|
||||
menu.addItem(otherResourcesMenuItem)
|
||||
menu.addItem(otherResourcesSeparatorMenuItem)
|
||||
}
|
||||
@@ -279,7 +282,7 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
@objc private func signInButtonTapped() {
|
||||
Task {
|
||||
do {
|
||||
try await WebAuthSession.signIn(store: self.model.store)
|
||||
try await WebAuthSession.signIn(store: store)
|
||||
} catch {
|
||||
Log.error(error)
|
||||
await macOSAlert.show(for: error)
|
||||
@@ -288,7 +291,7 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
@objc private func signOutButtonTapped() {
|
||||
do { try self.model.store.signOut() } catch { Log.error(error) }
|
||||
do { try store.signOut() } catch { Log.error(error) }
|
||||
}
|
||||
|
||||
@objc private func grantPermissionMenuItemTapped() {
|
||||
@@ -298,8 +301,8 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
// our VPN configuration 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()
|
||||
try await store.installSystemExtension()
|
||||
try await store.grantVPNPermission()
|
||||
} catch {
|
||||
Log.error(error)
|
||||
await macOSAlert.show(for: error)
|
||||
@@ -308,11 +311,11 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
@objc private func settingsButtonTapped() {
|
||||
AppViewModel.WindowDefinition.settings.openWindow()
|
||||
AppView.WindowDefinition.settings.openWindow()
|
||||
}
|
||||
|
||||
@objc private func adminPortalButtonTapped() {
|
||||
guard let url = URL(string: model.store.settings.authBaseURL)
|
||||
guard let url = URL(string: store.settings.authBaseURL)
|
||||
else { return }
|
||||
|
||||
Task { await NSWorkspace.shared.openAsync(url) }
|
||||
@@ -341,7 +344,7 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
|
||||
@objc private func quitButtonTapped() {
|
||||
Task {
|
||||
do { try self.model.store.stop() } catch { Log.error(error) }
|
||||
do { try store.stop() } catch { Log.error(error) }
|
||||
NSApp.terminate(self)
|
||||
}
|
||||
}
|
||||
@@ -375,8 +378,8 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
private func updateStatusItemIcon() {
|
||||
updateAnimation(status: vpnStatus)
|
||||
statusItem.button?.image = getStatusIcon(status: vpnStatus, notification: updateChecker.updateAvailable)
|
||||
updateAnimation(status: store.status)
|
||||
statusItem.button?.image = getStatusIcon(status: store.status, notification: updateChecker.updateAvailable)
|
||||
}
|
||||
|
||||
private func startConnectingAnimation() {
|
||||
@@ -402,9 +405,9 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
connectingAnimationImageIndex = (connectingAnimationImageIndex + 1) % connectingAnimationImages.count
|
||||
}
|
||||
|
||||
private func updateSignInMenuItems(status: NEVPNStatus?) {
|
||||
private func updateSignInMenuItems() {
|
||||
// Update "Sign In" / "Sign Out" menu items
|
||||
switch status {
|
||||
switch store.status {
|
||||
case nil:
|
||||
signInMenuItem.title = "Loading VPN configurations from system settings…"
|
||||
signInMenuItem.action = nil
|
||||
@@ -431,7 +434,7 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
signOutMenuItem.isHidden = true
|
||||
settingsMenuItem.target = self
|
||||
case .connected, .reasserting, .connecting:
|
||||
let title = "Signed in as \(model.store.actorName ?? "Unknown User")"
|
||||
let title = "Signed in as \(store.actorName ?? "Unknown User")"
|
||||
signInMenuItem.title = title
|
||||
signInMenuItem.target = nil
|
||||
signOutMenuItem.isHidden = false
|
||||
@@ -441,9 +444,9 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func updateResourcesMenuItems(status: NEVPNStatus?, resources: ResourceList) {
|
||||
private func updateResourcesMenuItems() {
|
||||
// Update resources "header" menu items
|
||||
switch status {
|
||||
switch store.status {
|
||||
case .connecting:
|
||||
resourcesTitleMenuItem.isHidden = true
|
||||
resourcesUnavailableMenuItem.isHidden = false
|
||||
@@ -455,7 +458,7 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
resourcesTitleMenuItem.isHidden = false
|
||||
resourcesUnavailableMenuItem.isHidden = true
|
||||
resourcesUnavailableReasonMenuItem.isHidden = true
|
||||
resourcesTitleMenuItem.title = resourceMenuTitle(resources)
|
||||
resourcesTitleMenuItem.title = resourceMenuTitle(store.resourceList)
|
||||
resourcesSeparatorMenuItem.isHidden = false
|
||||
case .reasserting:
|
||||
resourcesTitleMenuItem.isHidden = true
|
||||
@@ -486,14 +489,11 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
private func handleTunnelStatusOrResourcesChanged() {
|
||||
let resources = model.resources
|
||||
let status = model.status
|
||||
|
||||
updateSignInMenuItems(status: status)
|
||||
updateResourcesMenuItems(status: status, resources: resources)
|
||||
updateSignInMenuItems()
|
||||
updateResourcesMenuItems()
|
||||
|
||||
quitMenuItem.title = {
|
||||
switch status {
|
||||
switch store.status {
|
||||
case .connected, .connecting:
|
||||
return "Disconnect and Quit"
|
||||
default:
|
||||
@@ -517,14 +517,14 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
|
||||
private func populateResourceMenus(_ newResources: [Resource]) {
|
||||
// If we have no favorites, then everything is a favorite
|
||||
let hasAnyFavorites = newResources.contains { model.favorites.contains($0.id) }
|
||||
let hasAnyFavorites = newResources.contains { store.favorites.contains($0.id) }
|
||||
let newFavorites = if hasAnyFavorites {
|
||||
newResources.filter { model.favorites.contains($0.id) || $0.isInternetResource() }
|
||||
newResources.filter { store.favorites.contains($0.id) || $0.isInternetResource() }
|
||||
} else {
|
||||
newResources
|
||||
}
|
||||
let newOthers: [Resource] = if hasAnyFavorites {
|
||||
newResources.filter { !model.favorites.contains($0.id) && !$0.isInternetResource() }
|
||||
newResources.filter { !store.favorites.contains($0.id) && !$0.isInternetResource() }
|
||||
} else {
|
||||
[]
|
||||
}
|
||||
@@ -538,7 +538,7 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
return false
|
||||
}
|
||||
|
||||
return wasInternetResourceEnabled != model.store.internetResourceEnabled()
|
||||
return wasInternetResourceEnabled != store.internetResourceEnabled()
|
||||
}
|
||||
|
||||
private func refreshUpdateItem() {
|
||||
@@ -571,7 +571,7 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
}
|
||||
}
|
||||
lastShownFavorites = newFavorites
|
||||
wasInternetResourceEnabled = model.store.internetResourceEnabled()
|
||||
wasInternetResourceEnabled = store.internetResourceEnabled()
|
||||
}
|
||||
|
||||
private func populateOtherResourcesMenu(_ newOthers: [Resource]) {
|
||||
@@ -599,7 +599,7 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
}
|
||||
}
|
||||
lastShownOthers = newOthers
|
||||
wasInternetResourceEnabled = model.store.internetResourceEnabled()
|
||||
wasInternetResourceEnabled = store.internetResourceEnabled()
|
||||
|
||||
}
|
||||
|
||||
@@ -624,7 +624,7 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
private func internetResourceTitle(resource: Resource) -> String {
|
||||
let status = model.store.internetResourceEnabled() ? StatusSymbol.enabled : StatusSymbol.disabled
|
||||
let status = store.internetResourceEnabled() ? StatusSymbol.enabled : StatusSymbol.disabled
|
||||
|
||||
return status + " " + resource.name
|
||||
}
|
||||
@@ -647,7 +647,7 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
private func internetResourceToggleTitle() -> String {
|
||||
model.isInternetResourceEnabled() ? "Disable this resource" : "Enable this resource"
|
||||
store.internetResourceEnabled() ? "Disable this resource" : "Enable this resource"
|
||||
}
|
||||
|
||||
// TODO: Refactor this when refactoring for macOS 13
|
||||
@@ -710,7 +710,7 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
|
||||
let toggleFavoriteItem = NSMenuItem()
|
||||
|
||||
if model.favorites.contains(resource.id) {
|
||||
if store.favorites.contains(resource.id) {
|
||||
toggleFavoriteItem.action = #selector(removeFavoriteTapped(_:))
|
||||
toggleFavoriteItem.title = "Remove from favorites"
|
||||
toggleFavoriteItem.toolTip = "Click to remove this Resource from Favorites"
|
||||
@@ -805,7 +805,7 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
@objc private func internetResourceToggle(_ sender: NSMenuItem) {
|
||||
Task {
|
||||
do {
|
||||
try await self.model.store.toggleInternetResource(enabled: !model.store.internetResourceEnabled())
|
||||
try await store.toggleInternetResource(enabled: !store.internetResourceEnabled())
|
||||
} catch {
|
||||
Log.error(error)
|
||||
}
|
||||
@@ -837,9 +837,9 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
|
||||
private func setFavorited(id: String, favorited: Bool) {
|
||||
if favorited {
|
||||
model.favorites.add(id)
|
||||
store.favorites.add(id)
|
||||
} else {
|
||||
model.favorites.remove(id)
|
||||
store.favorites.remove(id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,16 +14,16 @@ private func copyToClipboard(_ value: String) {
|
||||
}
|
||||
|
||||
struct ResourceView: View {
|
||||
@ObservedObject var model: SessionViewModel
|
||||
@EnvironmentObject var store: Store
|
||||
var resource: Resource
|
||||
@Environment(\.openURL) var openURL
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if resource.isInternetResource() {
|
||||
InternetResourceHeader(model: model, resource: resource)
|
||||
InternetResourceHeader(resource: resource)
|
||||
} else {
|
||||
NonInternetResourceHeader(model: model, resource: resource)
|
||||
NonInternetResourceHeader(resource: resource)
|
||||
}
|
||||
|
||||
if let site = resource.sites.first {
|
||||
@@ -98,7 +98,7 @@ struct ResourceView: View {
|
||||
}
|
||||
|
||||
struct NonInternetResourceHeader: View {
|
||||
@ObservedObject var model: SessionViewModel
|
||||
@EnvironmentObject var store: Store
|
||||
var resource: Resource
|
||||
@Environment(\.openURL) var openURL
|
||||
|
||||
@@ -170,10 +170,10 @@ struct NonInternetResourceHeader: View {
|
||||
}
|
||||
}
|
||||
|
||||
if model.favorites.ids.contains(resource.id) {
|
||||
if store.favorites.contains(resource.id) {
|
||||
Button(
|
||||
action: {
|
||||
model.favorites.remove(resource.id)
|
||||
store.favorites.remove(resource.id)
|
||||
},
|
||||
label: {
|
||||
HStack {
|
||||
@@ -186,7 +186,7 @@ struct NonInternetResourceHeader: View {
|
||||
} else {
|
||||
Button(
|
||||
action: {
|
||||
model.favorites.add(resource.id)
|
||||
store.favorites.add(resource.id)
|
||||
}, label: {
|
||||
HStack {
|
||||
Image(systemName: "star.fill")
|
||||
@@ -201,7 +201,7 @@ struct NonInternetResourceHeader: View {
|
||||
}
|
||||
|
||||
struct InternetResourceHeader: View {
|
||||
@ObservedObject var model: SessionViewModel
|
||||
@EnvironmentObject var store: Store
|
||||
var resource: Resource
|
||||
|
||||
var body: some View {
|
||||
@@ -225,17 +225,17 @@ struct InternetResourceHeader: View {
|
||||
Text("All network traffic")
|
||||
}
|
||||
|
||||
ToggleInternetResourceButton(resource: resource, model: model)
|
||||
ToggleInternetResourceButton(resource: resource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ToggleInternetResourceButton: View {
|
||||
var resource: Resource
|
||||
@ObservedObject var model: SessionViewModel
|
||||
@EnvironmentObject var store: Store
|
||||
|
||||
private func toggleResourceEnabledText() -> String {
|
||||
if model.isInternetResourceEnabled() {
|
||||
if store.internetResourceEnabled() {
|
||||
"Disable this resource"
|
||||
} else {
|
||||
"Enable this resource"
|
||||
@@ -247,7 +247,7 @@ struct ToggleInternetResourceButton: View {
|
||||
action: {
|
||||
Task {
|
||||
do {
|
||||
try await model.store.toggleInternetResource(enabled: !model.isInternetResourceEnabled())
|
||||
try await store.toggleInternetResource(enabled: !store.internetResourceEnabled())
|
||||
} catch {
|
||||
Log.error(error)
|
||||
}
|
||||
|
||||
@@ -9,96 +9,30 @@ import NetworkExtension
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public final class SessionViewModel: ObservableObject {
|
||||
@Published private(set) var actorName: String?
|
||||
@Published private(set) var favorites: Favorites
|
||||
@Published private(set) var resources: ResourceList = ResourceList.loading
|
||||
@Published private(set) var status: NEVPNStatus?
|
||||
|
||||
let store: Store
|
||||
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
public init(favorites: Favorites, store: Store) {
|
||||
self.favorites = favorites
|
||||
self.store = store
|
||||
|
||||
favorites.$ids
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveValue: { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.objectWillChange.send()
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
|
||||
store.$actorName
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveValue: { [weak self] actorName in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.actorName = actorName
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
|
||||
store.$status
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveValue: { [weak self] status in
|
||||
guard let self = self else { return }
|
||||
self.status = status
|
||||
|
||||
if status == .connected {
|
||||
store.beginUpdatingResources { resources in
|
||||
self.resources = resources
|
||||
}
|
||||
} else {
|
||||
store.endUpdatingResources()
|
||||
self.resources = ResourceList.loading
|
||||
}
|
||||
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
public func isInternetResourceEnabled() -> Bool {
|
||||
store.internetResourceEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
@MainActor
|
||||
struct SessionView: View {
|
||||
@ObservedObject var model: SessionViewModel
|
||||
@EnvironmentObject var store: Store
|
||||
|
||||
var body: some View {
|
||||
switch model.status {
|
||||
switch store.status {
|
||||
case .connected:
|
||||
switch model.resources {
|
||||
switch store.resourceList {
|
||||
case .loaded(let resources):
|
||||
if resources.isEmpty {
|
||||
Text("No Resources. Contact your admin to be granted access.")
|
||||
} else {
|
||||
List {
|
||||
let hasAnyFavorites = resources.contains { model.favorites.contains($0.id) }
|
||||
if hasAnyFavorites {
|
||||
if !store.favorites.isEmpty() {
|
||||
Section("Favorites") {
|
||||
ResourceSection(
|
||||
resources: resources.filter { model.favorites.contains($0.id) },
|
||||
model: model
|
||||
)
|
||||
ResourceSection(resources: favoriteResources())
|
||||
}
|
||||
|
||||
Section("Other Resources") {
|
||||
ResourceSection(
|
||||
resources: resources.filter { !model.favorites.contains($0.id) },
|
||||
model: model
|
||||
)
|
||||
ResourceSection(resources: nonFavoriteResources())
|
||||
}
|
||||
} else {
|
||||
ResourceSection(
|
||||
resources: resources,
|
||||
model: model
|
||||
)
|
||||
ResourceSection(resources: resources)
|
||||
}
|
||||
}
|
||||
.listStyle(GroupedListStyle())
|
||||
@@ -122,14 +56,32 @@ struct SessionView: View {
|
||||
Text("Unknown status. Please report this and attach your logs.")
|
||||
}
|
||||
}
|
||||
|
||||
func favoriteResources() -> [Resource] {
|
||||
switch store.resourceList {
|
||||
case .loaded(let resources):
|
||||
return resources.filter { store.favorites.contains($0.id) }
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
func nonFavoriteResources() -> [Resource] {
|
||||
switch store.resourceList {
|
||||
case .loaded(let resources):
|
||||
return resources.filter { !store.favorites.contains($0.id) }
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ResourceSection: View {
|
||||
let resources: [Resource]
|
||||
@ObservedObject var model: SessionViewModel
|
||||
@EnvironmentObject var store: Store
|
||||
|
||||
private func internetResourceTitle(resource: Resource) -> String {
|
||||
let status = model.store.internetResourceEnabled() ? StatusSymbol.enabled : StatusSymbol.disabled
|
||||
let status = store.internetResourceEnabled() ? StatusSymbol.enabled : StatusSymbol.disabled
|
||||
|
||||
return status + " " + resource.name
|
||||
}
|
||||
@@ -145,7 +97,7 @@ struct ResourceSection: View {
|
||||
var body: some View {
|
||||
ForEach(resources) { resource in
|
||||
HStack {
|
||||
NavigationLink { ResourceView(model: model, resource: resource) }
|
||||
NavigationLink { ResourceView(resource: resource) }
|
||||
label: {
|
||||
Text(resourceTitle(resource: resource))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,18 +8,9 @@ import AuthenticationServices
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class WelcomeViewModel: ObservableObject {
|
||||
let store: Store
|
||||
|
||||
init(store: Store) {
|
||||
self.store = store
|
||||
}
|
||||
}
|
||||
|
||||
struct WelcomeView: View {
|
||||
@EnvironmentObject var errorHandler: GlobalErrorHandler
|
||||
@ObservedObject var model: WelcomeViewModel
|
||||
@EnvironmentObject var store: Store
|
||||
|
||||
var body: some View {
|
||||
VStack(
|
||||
@@ -40,7 +31,7 @@ struct WelcomeView: View {
|
||||
Button("Sign in") {
|
||||
Task {
|
||||
do {
|
||||
try await WebAuthSession.signIn(store: model.store)
|
||||
try await WebAuthSession.signIn(store: store)
|
||||
} catch {
|
||||
Log.error(error)
|
||||
|
||||
|
||||
@@ -11,14 +11,13 @@ import SwiftUI
|
||||
#if os(iOS)
|
||||
struct iOSNavigationView<Content: View>: View { // swiftlint:disable:this type_name
|
||||
@State private var isSettingsPresented = false
|
||||
@ObservedObject var model: AppViewModel
|
||||
@EnvironmentObject var store: Store
|
||||
@Environment(\.openURL) var openURL
|
||||
@EnvironmentObject var errorHandler: GlobalErrorHandler
|
||||
|
||||
let content: Content
|
||||
|
||||
init(model: AppViewModel, @ViewBuilder content: () -> Content) {
|
||||
self.model = model
|
||||
init(@ViewBuilder content: () -> Content) {
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
@@ -41,7 +40,7 @@ struct iOSNavigationView<Content: View>: View { // swiftlint:disable:this type_n
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $isSettingsPresented) {
|
||||
SettingsView(favorites: model.favorites, model: SettingsViewModel(store: model.store))
|
||||
SettingsView()
|
||||
}
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
}
|
||||
@@ -55,13 +54,13 @@ struct iOSNavigationView<Content: View>: View { // swiftlint:disable:this type_n
|
||||
Label("Settings", systemImage: "gear")
|
||||
}
|
||||
)
|
||||
.disabled(model.status == .invalid)
|
||||
.disabled(store.status == .invalid)
|
||||
}
|
||||
|
||||
private var authMenu: some View {
|
||||
Menu {
|
||||
if model.status == .connected {
|
||||
Text("Signed in as \(model.store.actorName ?? "Unknown user")")
|
||||
if store.status == .connected {
|
||||
Text("Signed in as \(store.actorName ?? "Unknown user")")
|
||||
Button(
|
||||
action: {
|
||||
signOutButtonTapped()
|
||||
@@ -73,20 +72,8 @@ struct iOSNavigationView<Content: View>: View { // swiftlint:disable:this type_n
|
||||
} else {
|
||||
Button(
|
||||
action: {
|
||||
Task {
|
||||
do {
|
||||
try await WebAuthSession.signIn(store: model.store)
|
||||
} catch {
|
||||
Log.error(error)
|
||||
signInButtonTapped()
|
||||
|
||||
self.errorHandler.handle(
|
||||
ErrorAlert(
|
||||
title: "Error signing in",
|
||||
error: error
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
label: {
|
||||
Label("Sign in", systemImage: "person.crop.circle.fill.badge.plus")
|
||||
@@ -115,8 +102,36 @@ struct iOSNavigationView<Content: View>: View { // swiftlint:disable:this type_n
|
||||
}
|
||||
}
|
||||
|
||||
private func signOutButtonTapped() {
|
||||
do { try model.store.signOut() } catch { Log.error(error) }
|
||||
func signInButtonTapped() {
|
||||
Task {
|
||||
do {
|
||||
try await WebAuthSession.signIn(store: store)
|
||||
} catch {
|
||||
Log.error(error)
|
||||
|
||||
self.errorHandler.handle(
|
||||
ErrorAlert(
|
||||
title: "Error signing in",
|
||||
error: error
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func signOutButtonTapped() {
|
||||
do {
|
||||
try store.signOut()
|
||||
} catch {
|
||||
Log.error(error)
|
||||
|
||||
self.errorHandler.handle(
|
||||
ErrorAlert(
|
||||
title: "Error signing out",
|
||||
error: error
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user