feat(client/macOS): Favorite Resources menu (#6186)

```[tasklist]
- [x] Update changelog
- [x] Hook into reset button
```

---------

Signed-off-by: Reactor Scram <ReactorScram@users.noreply.github.com>
This commit is contained in:
Reactor Scram
2024-08-16 15:21:48 -05:00
committed by GitHub
parent 127492f613
commit 7b73eeae36
8 changed files with 177 additions and 19 deletions

View File

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

View File

@@ -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)) ?? []
}

View File

@@ -0,0 +1,42 @@
import Foundation
public class Favorites: ObservableObject {
private static let key = "favoriteResourceIDs"
@Published private(set) var ids: Set<String> = 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<String>) {
// 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<String> {
if let ids = UserDefaults.standard.stringArray(forKey: key) {
return Set(ids)
}
return []
}
}

View File

@@ -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<AnyCancellable>()
public init(store: Store) {
public init(favorites: Favorites, store: Store) {
self.favorites = favorites
self.store = store
store.$status

View File

@@ -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<AnyCancellable> = []
@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()

View File

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

View File

@@ -29,7 +29,7 @@ struct iOSNavigationView<Content: View>: View {
.navigationBarItems(trailing: SettingsButton)
}
.sheet(isPresented: $isSettingsPresented) {
SettingsView(model: SettingsViewModel(store: model.store))
SettingsView(favorites: model.favorites, model: SettingsViewModel(store: model.store))
}
.navigationViewStyle(StackNavigationViewStyle())
}

View File

@@ -9,11 +9,14 @@ export default function Apple() {
href="https://apps.apple.com/us/app/firezone/id6443661826"
title="macOS / iOS"
>
{/* <Entry version="1.2.0" date={new Date(TODO)}>
{/* <Entry version="(TODO)" date={new Date(TODO)}>
<ul className="list-disc space-y-2 pl-4 mb-4">
<ChangeItem pull="5901">
Implements glob-like matching of domains for DNS resources.
</ChangeItem>
<ChangeItem pull="6186">
macOS: Adds the ability to mark Resources as favorites.
</ChangeItem>
</ul>
</Entry> */}
<Entry version="1.1.5" date={new Date("2024-08-13")}>