Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 31 additions & 11 deletions Sources/CopyCat/CopyCatApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand Down Expand Up @@ -76,8 +82,7 @@ private struct CopyCatMenu: View {
Divider()

Button("Settings…") {
NSApp.activate(ignoringOtherApps: true)
openSettings()
AppDelegate.shared?.openSettings()
}
.keyboardShortcut(",")

Expand Down Expand Up @@ -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") {
Expand All @@ -154,8 +158,7 @@ private struct BroadcastHostsMenu: View {
Divider()
Button("Configure…") {
SettingsNavigation.shared.selectedTab = .broadcast
NSApp.activate(ignoringOtherApps: true)
openSettings()
AppDelegate.shared?.openSettings()
}
}
}
Expand Down Expand Up @@ -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
Expand All @@ -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
}
}
3 changes: 3 additions & 0 deletions Sources/CopyCat/Settings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = [
Expand Down Expand Up @@ -60,13 +61,15 @@ enum Settings {
static let enableLocalPaste = "enableLocalPaste"
static let enableBroadcast = "enableBroadcast"
static let launchAtLogin = "launchAtLogin"
static let showMenuBarIcon = "showMenuBarIcon"
}

static func registerDefaults() {
ud.register(defaults: [
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,
])
Expand Down
35 changes: 20 additions & 15 deletions Sources/CopyCat/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand All @@ -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") {
Expand All @@ -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 {
Expand Down
100 changes: 100 additions & 0 deletions Sources/CopyCat/SettingsWindow.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading