From e09e71dd8c01e8552a9647957876b0a433f1a34c Mon Sep 17 00:00:00 2001 From: andyhtran Date: Wed, 29 Apr 2026 21:07:22 -0400 Subject: [PATCH] Add option to hide menu bar icon Replaces the SwiftUI Settings scene with an AppDelegate-owned NSWindowController so Settings stays reachable via Spotlight/Finder re-launch when the menu bar icon is hidden. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/CopyCat/CopyCatApp.swift | 42 ++++++++--- Sources/CopyCat/Settings.swift | 3 + Sources/CopyCat/SettingsView.swift | 35 ++++++---- Sources/CopyCat/SettingsWindow.swift | 100 +++++++++++++++++++++++++++ 4 files changed, 154 insertions(+), 26 deletions(-) create mode 100644 Sources/CopyCat/SettingsWindow.swift diff --git a/Sources/CopyCat/CopyCatApp.swift b/Sources/CopyCat/CopyCatApp.swift index c51af51..8fdf858 100644 --- a/Sources/CopyCat/CopyCatApp.swift +++ b/Sources/CopyCat/CopyCatApp.swift @@ -5,6 +5,12 @@ import SwiftUI struct CopyCatApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + // Bound directly to UserDefaults rather than via SettingsStore. Observing + // the whole store at App scope re-renders the MenuBarExtra subtree on + // every publish, and StatusHeader.body shells out to `tailscale status` + // synchronously — that combination produces a tight transaction loop. + @AppStorage("showMenuBarIcon") private var showMenuBarIcon: Bool = true + // Resolve once at startup. SwiftUI's MenuBarExtra(_:image:) form expects // an asset-catalog name, which we don't have — feeding NSImage directly // through the custom-label form sidesteps that lookup. @@ -23,21 +29,21 @@ struct CopyCatApp: App { }() var body: some Scene { - MenuBarExtra { + // Settings is hosted in an AppDelegate-owned NSWindowController, not + // a SwiftUI Settings scene. showSettingsWindow: dispatch is unreliable + // for LSUIElement apps — when the menu bar icon is hidden there's no + // key window in the responder chain, so applicationShouldHandleReopen + // can't surface it. + MenuBarExtra(isInserted: $showMenuBarIcon) { CopyCatMenu() } label: { Image(nsImage: menuBarIcon) .accessibilityLabel("CopyCat") } - - SwiftUI.Settings { - SettingsView() - } } } private struct CopyCatMenu: View { - @Environment(\.openSettings) private var openSettings @ObservedObject private var store = SettingsStore.shared var body: some View { @@ -76,8 +82,7 @@ private struct CopyCatMenu: View { Divider() Button("Settings…") { - NSApp.activate(ignoringOtherApps: true) - openSettings() + AppDelegate.shared?.openSettings() } .keyboardShortcut(",") @@ -138,7 +143,6 @@ private struct StatusHeader: View { private struct BroadcastHostsMenu: View { @ObservedObject private var store = SettingsStore.shared - @Environment(\.openSettings) private var openSettings var body: some View { Menu("SSH hosts") { @@ -154,8 +158,7 @@ private struct BroadcastHostsMenu: View { Divider() Button("Configure…") { SettingsNavigation.shared.selectedTab = .broadcast - NSApp.activate(ignoringOtherApps: true) - openSettings() + AppDelegate.shared?.openSettings() } } } @@ -196,6 +199,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { static var shared: AppDelegate? var pasteHandler: PasteHandler? + private var settingsWindowController: SettingsWindowController? + + func openSettings() { + if settingsWindowController == nil { + settingsWindowController = SettingsWindowController() + } + NSApp.activate(ignoringOtherApps: true) + settingsWindowController?.showWindow(nil) + } func applicationDidFinishLaunching(_ notification: Notification) { AppDelegate.shared = self @@ -219,4 +231,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { Log.app.info("CopyCat terminating") pasteHandler?.stop() } + + // Re-launching from Spotlight/Finder is the documented escape hatch when + // the menu bar icon is hidden. Always open Settings — it's the only + // visible surface we can offer, and matches Rectangle's pattern. + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + openSettings() + return true + } } diff --git a/Sources/CopyCat/Settings.swift b/Sources/CopyCat/Settings.swift index 29e6ff1..5b1ed4e 100644 --- a/Sources/CopyCat/Settings.swift +++ b/Sources/CopyCat/Settings.swift @@ -22,6 +22,7 @@ enum SettingsDefaults { static let enableLocalPaste = true static let enableBroadcast = false static let launchAtLogin = false + static let showMenuBarIcon = true // Common terminal bundle IDs. Users can add/remove via Settings. static let targetBundleIDs: Set = [ @@ -60,6 +61,7 @@ enum Settings { static let enableLocalPaste = "enableLocalPaste" static let enableBroadcast = "enableBroadcast" static let launchAtLogin = "launchAtLogin" + static let showMenuBarIcon = "showMenuBarIcon" } static func registerDefaults() { @@ -67,6 +69,7 @@ enum Settings { Key.enableLocalPaste: SettingsDefaults.enableLocalPaste, Key.enableBroadcast: SettingsDefaults.enableBroadcast, Key.launchAtLogin: SettingsDefaults.launchAtLogin, + Key.showMenuBarIcon: SettingsDefaults.showMenuBarIcon, Key.broadcastHotkey: SettingsDefaults.broadcastHotkey.rawValue, Key.cacheKeepCount: SettingsDefaults.cacheKeepCount, ]) diff --git a/Sources/CopyCat/SettingsView.swift b/Sources/CopyCat/SettingsView.swift index 8fc6d07..dce6e4a 100644 --- a/Sources/CopyCat/SettingsView.swift +++ b/Sources/CopyCat/SettingsView.swift @@ -21,22 +21,17 @@ struct SettingsView: View { @ObservedObject private var nav = SettingsNavigation.shared var body: some View { - TabView(selection: $nav.selectedTab) { - GeneralSettingsView() - .tag(SettingsTab.general) - .tabItem { Label("General", systemImage: "gearshape") } - AppsSettingsView() - .tag(SettingsTab.apps) - .tabItem { Label("Apps", systemImage: "app.badge") } - HostsSettingsView() - .tag(SettingsTab.broadcast) - .tabItem { Label("SSH hosts", systemImage: "antenna.radiowaves.left.and.right") } + // Tab switching is driven by the host window's NSToolbar + // (SettingsWindowController), which writes nav.selectedTab. Using a + // switch here instead of TabView avoids SwiftUI rendering its own + // segmented tab bar on top of the toolbar. + Group { + switch nav.selectedTab { + case .general: GeneralSettingsView() + case .apps: AppsSettingsView() + case .broadcast: HostsSettingsView() + } } - // Fixed size, not minWidth/minHeight: macOS persists the Settings - // window frame in UserDefaults under "NSWindow Frame - // com_apple_SwiftUI_Settings_window", and the persisted size always - // wins over a minimum constraint. Use frame(width:height:) to force - // the size we actually want. .frame(width: 460, height: 500) .windowFocusSink() } @@ -47,6 +42,12 @@ struct SettingsView: View { private struct GeneralSettingsView: View { @ObservedObject private var store = SettingsStore.shared + // Bound directly to the same UserDefaults key as the App scene's + // @AppStorage so writes from this toggle propagate to MenuBarExtra + // immediately. Routing through SettingsStore (raw UserDefaults.set) does + // not reliably notify @AppStorage observers. + @AppStorage("showMenuBarIcon") private var showMenuBarIcon: Bool = true + var body: some View { Form { Section("Behavior") { @@ -66,6 +67,10 @@ private struct GeneralSettingsView: View { .disabled(!store.enableBroadcast) } + Section("Appearance") { + Toggle("Show menu bar icon", isOn: $showMenuBarIcon) + } + Section("Cache") { LabeledContent("Local cache") { HStack { diff --git a/Sources/CopyCat/SettingsWindow.swift b/Sources/CopyCat/SettingsWindow.swift new file mode 100644 index 0000000..de8eb02 --- /dev/null +++ b/Sources/CopyCat/SettingsWindow.swift @@ -0,0 +1,100 @@ +import AppKit +import Combine +import SwiftUI + +// Hosts the SwiftUI SettingsView in a vanilla NSWindow, driving tab +// selection through an NSToolbar to match the look the SwiftUI Settings +// scene gives for free. SettingsNavigation.shared.selectedTab is the +// source of truth — both the toolbar and the SwiftUI content read from it. +@MainActor +final class SettingsWindowController: NSWindowController, NSToolbarDelegate { + private let nav = SettingsNavigation.shared + private var navObserver: AnyCancellable? + + // Single source of truth for tab → toolbar mapping. Adding a tab is one + // edit here plus a case in SettingsView's switch. + private struct TabSpec { + let tab: SettingsTab + let id: NSToolbarItem.Identifier + let label: String + let symbol: String + } + + private static let specs: [TabSpec] = [ + TabSpec(tab: .general, id: .init("general"), label: "General", + symbol: "gearshape"), + TabSpec(tab: .apps, id: .init("apps"), label: "Apps", + symbol: "app.badge"), + TabSpec(tab: .broadcast, id: .init("sshHosts"), label: "SSH hosts", + symbol: "antenna.radiowaves.left.and.right"), + ] + + private static func spec(for tab: SettingsTab) -> TabSpec? { + specs.first { $0.tab == tab } + } + + private static func spec(for id: NSToolbarItem.Identifier) -> TabSpec? { + specs.first { $0.id == id } + } + + convenience init() { + let host = NSHostingController(rootView: SettingsView()) + let window = NSWindow(contentViewController: host) + window.title = "CopyCat Settings" + window.styleMask = [.titled, .closable] + window.isReleasedWhenClosed = false + window.setFrameAutosaveName("CopyCatSettingsWindow") + window.center() + + self.init(window: window) + + let toolbar = NSToolbar(identifier: "CopyCatSettingsToolbar") + toolbar.delegate = self + toolbar.displayMode = .iconAndLabel + toolbar.allowsUserCustomization = false + toolbar.selectedItemIdentifier = Self.spec(for: nav.selectedTab)?.id + + window.toolbar = toolbar + window.toolbarStyle = .preference + + // Keep the toolbar in sync when something else (e.g. the "Configure…" + // submenu button) changes the active tab while the window is open. + navObserver = nav.$selectedTab.sink { [weak toolbar] tab in + let newId = Self.spec(for: tab)?.id + guard toolbar?.selectedItemIdentifier != newId else { return } + toolbar?.selectedItemIdentifier = newId + } + } + + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + Self.specs.map(\.id) + } + + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + Self.specs.map(\.id) + } + + func toolbarSelectableItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + Self.specs.map(\.id) + } + + func toolbar( + _ toolbar: NSToolbar, + itemForItemIdentifier id: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar flag: Bool + ) -> NSToolbarItem? { + guard let spec = Self.spec(for: id) else { return nil } + let item = NSToolbarItem(itemIdentifier: id) + item.target = self + item.action = #selector(selectTab(_:)) + item.label = spec.label + item.image = NSImage(systemSymbolName: spec.symbol, accessibilityDescription: spec.label) + return item + } + + @objc private func selectTab(_ sender: NSToolbarItem) { + if let spec = Self.spec(for: sender.itemIdentifier) { + nav.selectedTab = spec.tab + } + } +}