From 7b73eeae367ac271ae42b1f017ffc0b9e5f5e85f Mon Sep 17 00:00:00 2001 From: Reactor Scram Date: Fri, 16 Aug 2024 15:21:48 -0500 Subject: [PATCH] feat(client/macOS): Favorite Resources menu (#6186) ```[tasklist] - [x] Update changelog - [x] Hook into reset button ``` --------- Signed-off-by: Reactor Scram --- .../Firezone/Application/FirezoneApp.swift | 10 +- .../FirezoneKit/Managers/TunnelManager.swift | 2 +- .../FirezoneKit/Models/Favorites.swift | 42 ++++++ .../Sources/FirezoneKit/Views/AppView.swift | 4 +- .../Sources/FirezoneKit/Views/MenuBar.swift | 124 ++++++++++++++++-- .../FirezoneKit/Views/SettingsView.swift | 7 +- .../FirezoneKit/Views/iOSNavigationView.swift | 2 +- website/src/components/Changelog/Apple.tsx | 5 +- 8 files changed, 177 insertions(+), 19 deletions(-) create mode 100644 swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Favorites.swift diff --git a/swift/apple/Firezone/Application/FirezoneApp.swift b/swift/apple/Firezone/Application/FirezoneApp.swift index a24f7d63a..9a3f3489a 100644 --- a/swift/apple/Firezone/Application/FirezoneApp.swift +++ b/swift/apple/Firezone/Application/FirezoneApp.swift @@ -13,13 +13,16 @@ struct FirezoneApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate #endif + @StateObject var favorites: Favorites @StateObject var appViewModel: AppViewModel @StateObject var store: Store init() { + let favorites = Favorites() let store = Store() + _favorites = StateObject(wrappedValue: favorites) _store = StateObject(wrappedValue: store) - _appViewModel = StateObject(wrappedValue: AppViewModel(store: store)) + _appViewModel = StateObject(wrappedValue: AppViewModel(favorites: favorites, store: store)) #if os(macOS) appDelegate.store = store @@ -49,7 +52,7 @@ struct FirezoneApp: App { "Settings", id: AppViewModel.WindowDefinition.settings.identifier ) { - SettingsView(model: SettingsViewModel(store: store)) + SettingsView(favorites: appDelegate.favorites, model: SettingsViewModel(store: store)) } .handlesExternalEvents( matching: [AppViewModel.WindowDefinition.settings.externalEventMatchString] @@ -61,12 +64,13 @@ struct FirezoneApp: App { #if os(macOS) @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { + var favorites: Favorites = Favorites() var menuBar: MenuBar? public var store: Store? func applicationDidFinishLaunching(_: Notification) { if let store = store { - menuBar = MenuBar(model: SessionViewModel(store: store)) + menuBar = MenuBar(favorites: favorites, model: SessionViewModel(store: store)) } // SwiftUI will show the first window group, so close it on launch diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/TunnelManager.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/TunnelManager.swift index 83200460b..18a7caa73 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/TunnelManager.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/TunnelManager.swift @@ -279,7 +279,7 @@ public class TunnelManager { try session().sendProviderMessage(encoder.encode(TunnelMessage.getResourceList(resourceListHash))) { data in if let data = data { self.resourceListHash = Data(SHA256.hash(data: data)) - var decoder = JSONDecoder() + let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase self.resourcesListCache = (try? decoder.decode([Resource].self, from: data)) ?? [] } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Favorites.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Favorites.swift new file mode 100644 index 000000000..0690f2f3b --- /dev/null +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Favorites.swift @@ -0,0 +1,42 @@ +import Foundation + +public class Favorites: ObservableObject { + private static let key = "favoriteResourceIDs" + @Published private(set) var ids: Set = Favorites.load() + + public init() {} + + func contains(_ id: String) -> Bool { + return ids.contains(id) + } + + func reset() { + objectWillChange.send() + ids = Set() + Favorites.save(ids) + } + + func add(_ id: String) { + objectWillChange.send() + ids.insert(id) + Favorites.save(ids) + } + + func remove(_ id: String) { + objectWillChange.send() + ids.remove(id) + Favorites.save(ids) + } + + private static func save(_ ids: Set) { + // It's a run-time exception if we pass the `Set` directly here + UserDefaults.standard.set(Array(ids), forKey: key) + } + + private static func load() -> Set { + if let ids = UserDefaults.standard.stringArray(forKey: key) { + return Set(ids) + } + return [] + } +} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift index b103f4f0c..03427c42d 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift @@ -20,6 +20,7 @@ import UserNotifications @MainActor public class AppViewModel: ObservableObject { + let favorites: Favorites let store: Store @Published private(set) var status: NEVPNStatus? @@ -27,7 +28,8 @@ public class AppViewModel: ObservableObject { private var cancellables = Set() - public init(store: Store) { + public init(favorites: Favorites, store: Store) { + self.favorites = favorites self.store = store store.$status diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift index 03877c786..b47315cd2 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift @@ -18,9 +18,14 @@ import SwiftUI // https://developer.apple.com/documentation/swiftui/menubarextra public final class MenuBar: NSObject, ObservableObject { private var statusItem: NSStatusItem - private var resources: [Resource]? + private var resources: [Resource] = [] + + // Wish these could be `[String]` but diffing between different types is tricky + private var lastShownFavorites: [Resource] = [] + private var lastShownOthers: [Resource] = [] private var cancellables: Set = [] + @ObservedObject var favorites: Favorites @ObservedObject var model: SessionViewModel private lazy var signedOutIcon = NSImage(named: "MenuBarIconSignedOut") @@ -34,8 +39,9 @@ public final class MenuBar: NSObject, ObservableObject { private var connectingAnimationImageIndex: Int = 0 private var connectingAnimationTimer: Timer? - public init(model: SessionViewModel) { + public init(favorites: Favorites, model: SessionViewModel) { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + self.favorites = favorites self.model = model super.init() @@ -53,6 +59,13 @@ public final class MenuBar: NSObject, ObservableObject { } private func setupObservers() { + favorites.$ids + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] ids in + guard let self = self else { return } + favoritesChanged() + }).store(in: &cancellables) + model.store.$status .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] status in @@ -61,14 +74,14 @@ public final class MenuBar: NSObject, ObservableObject { if status == .connected { model.store.beginUpdatingResources { newResources in // Handle resource changes - self.populateResourceMenu(newResources) + self.populateResourceMenus(newResources) self.handleTunnelStatusOrResourcesChanged(status: status, resources: newResources) self.resources = newResources } } else { model.store.endUpdatingResources() - populateResourceMenu(nil) - resources = nil + populateResourceMenus([]) + resources = [] } // Handle status changes @@ -115,6 +128,13 @@ public final class MenuBar: NSObject, ObservableObject { target: self ) private lazy var resourcesSeparatorMenuItem = NSMenuItem.separator() + private var otherResourcesMenu: NSMenu = NSMenu() + private lazy var otherResourcesMenuItem: NSMenuItem = { + let menuItem = NSMenuItem(title: "Other Resources", action: nil, keyEquivalent: "") + menuItem.submenu = otherResourcesMenu + return menuItem + }() + private lazy var otherResourcesSeparatorMenuItem = NSMenuItem.separator() private lazy var aboutMenuItem: NSMenuItem = { let menuItem = createMenuItem( menu, @@ -190,6 +210,8 @@ public final class MenuBar: NSObject, ObservableObject { menu.addItem(resourcesUnavailableMenuItem) menu.addItem(resourcesUnavailableReasonMenuItem) menu.addItem(resourcesSeparatorMenuItem) + menu.addItem(otherResourcesMenuItem) + menu.addItem(otherResourcesSeparatorMenuItem) menu.addItem(aboutMenuItem) menu.addItem(adminPortalMenuItem) @@ -393,10 +415,28 @@ public final class MenuBar: NSObject, ObservableObject { } } - private func populateResourceMenu(_ newResources: [Resource]?) { - // the menu contains other things besides resources, so update it in-place - let diff = (newResources ?? []).difference( - from: resources ?? [], + private func populateResourceMenus(_ newResources: [Resource]) { + // If we have no favorites, then everything is a favorite + let hasAnyFavorites = newResources.contains { self.favorites.contains($0.id) } + let newFavorites = if (hasAnyFavorites) { + newResources.filter { self.favorites.contains($0.id) } + } else { + newResources + } + let newOthers: [Resource] = if hasAnyFavorites { + newResources.filter { !self.favorites.contains($0.id) } + } else { + [] + } + + populateFavoriteResourcesMenu(newFavorites, hasAnyFavorites) + populateOtherResourcesMenu(newOthers) + } + + private func populateFavoriteResourcesMenu(_ newFavorites: [Resource], _ hasAnyFavorites: Bool) { + // Update the menu in place so everything won't vanish if it's open when it updates + let diff = (newFavorites).difference( + from: lastShownFavorites, by: { $0 == $1 } ) let index = menu.index(of: resourcesTitleMenuItem) + 1 @@ -409,6 +449,28 @@ public final class MenuBar: NSObject, ObservableObject { menu.removeItem(at: index + offset) } } + lastShownFavorites = newFavorites + } + + private func populateOtherResourcesMenu(_ newOthers: [Resource]) { + otherResourcesMenuItem.isHidden = newOthers.isEmpty + otherResourcesSeparatorMenuItem.isHidden = newOthers.isEmpty + + // Update the menu in place so everything won't vanish if it's open when it updates + let diff = (newOthers).difference( + from: lastShownOthers, + by: { $0 == $1 } + ) + for change in diff { + switch change { + case .insert(let offset, let element, associatedWith: _): + let menuItem = createResourceMenuItem(resource: element) + otherResourcesMenu.insertItem(menuItem, at: offset) + case .remove(let offset, element: _, associatedWith: _): + otherResourcesMenu.removeItem(at: offset) + } + } + lastShownOthers = newOthers } private func createResourceMenuItem(resource: Resource) -> NSMenuItem { @@ -433,6 +495,7 @@ public final class MenuBar: NSObject, ObservableObject { let siteSectionItem = NSMenuItem() let siteNameItem = NSMenuItem() let siteStatusItem = NSMenuItem() + let toggleFavoriteItem = NSMenuItem() let enableToggle = NSMenuItem() @@ -485,7 +548,22 @@ public final class MenuBar: NSObject, ObservableObject { resourceAddressItem.target = self subMenu.addItem(resourceAddressItem) - // Resource toggle + if favorites.contains(resource.id) { + toggleFavoriteItem.action = #selector(removeFavoriteTapped(_:)) + toggleFavoriteItem.title = "Remove from favorites" + toggleFavoriteItem.toolTip = "Click to remove this Resource from Favorites" + } else { + toggleFavoriteItem.action = #selector(addFavoriteTapped(_:)) + toggleFavoriteItem.title = "Add to favorites" + toggleFavoriteItem.toolTip = "Click to add this Resource to Favorites" + } + toggleFavoriteItem.isEnabled = true + toggleFavoriteItem.representedObject = resource.id + toggleFavoriteItem.target = self + subMenu.addItem(toggleFavoriteItem) + + + // Resource enable / disable toggle if resource.canToggle { subMenu.addItem(NSMenuItem.separator()) enableToggle.action = #selector(resourceToggle(_:)) @@ -553,6 +631,32 @@ public final class MenuBar: NSObject, ObservableObject { } } + @objc private func addFavoriteTapped(_ sender: NSMenuItem) { + let id = sender.representedObject as! String + setFavorited(id: id, favorited: true) + } + + @objc private func removeFavoriteTapped(_ sender: NSMenuItem) { + let id = sender.representedObject as! String + setFavorited(id: id, favorited: false) + } + + private func setFavorited(id: String, favorited: Bool) { + if favorited { + favorites.add(id) + } else { + favorites.remove(id) + } + favoritesChanged() + } + + func favoritesChanged() { + // 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(resources) + } + private func copyToClipboard(_ string: String) { let pasteBoard = NSPasteboard.general pasteBoard.clearContents() diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift index c05d7e81e..231d45488 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift @@ -151,6 +151,7 @@ extension FileManager { } public struct SettingsView: View { + @ObservedObject var favorites: Favorites @ObservedObject var model: SettingsViewModel @Environment(\.dismiss) var dismiss @@ -199,7 +200,8 @@ public struct SettingsView: View { """) } - public init(model: SettingsViewModel) { + public init(favorites: Favorites, model: SettingsViewModel) { + self.favorites = favorites self.model = model } @@ -352,9 +354,10 @@ public struct SettingsView: View { "Reset to Defaults", action: { model.settings = Settings.defaultValue + favorites.reset() } ) - .disabled(model.settings == Settings.defaultValue) + .disabled(favorites.ids.isEmpty && model.settings == Settings.defaultValue) } .padding(.top, 5) } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/iOSNavigationView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/iOSNavigationView.swift index 090c55295..881cc49d3 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/iOSNavigationView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/iOSNavigationView.swift @@ -29,7 +29,7 @@ struct iOSNavigationView: View { .navigationBarItems(trailing: SettingsButton) } .sheet(isPresented: $isSettingsPresented) { - SettingsView(model: SettingsViewModel(store: model.store)) + SettingsView(favorites: model.favorites, model: SettingsViewModel(store: model.store)) } .navigationViewStyle(StackNavigationViewStyle()) } diff --git a/website/src/components/Changelog/Apple.tsx b/website/src/components/Changelog/Apple.tsx index 72a2415c6..a88154ed0 100644 --- a/website/src/components/Changelog/Apple.tsx +++ b/website/src/components/Changelog/Apple.tsx @@ -9,11 +9,14 @@ export default function Apple() { href="https://apps.apple.com/us/app/firezone/id6443661826" title="macOS / iOS" > - {/* + {/*
    Implements glob-like matching of domains for DNS resources. + + macOS: Adds the ability to mark Resources as favorites. +
*/}