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