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:
Jamil
2025-02-23 18:28:29 -08:00
committed by GitHub
parent 4cb2b01c26
commit 83b2c7a71a
13 changed files with 870 additions and 1009 deletions

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -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
))
}
}
}
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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))
}

View File

@@ -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)

View File

@@ -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