diff --git a/README.md b/README.md index 0cf3ddf..3d67b71 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # ClipBored -ClipBored is a small native macOS clipboard manager. It runs without a Dock icon, captures local clipboard history, and opens a keyboard-first bottom panel for search, sorting, copy, paste, pinning, and deletion. +ClipBored is a small native macOS clipboard manager. It captures local clipboard history and opens a keyboard-first bottom panel for search, sorting, copy, paste, pinning, and deletion. It runs as a dockless menu-bar utility by default, with an optional Dock icon mode. The project is intentionally dependency-light: Swift Package Manager, AppKit, Carbon hotkeys, SQLite, and system frameworks only. ## Features -- Dockless menu-bar utility (`LSUIElement=true`) +- Dockless menu-bar utility by default (`LSUIElement=true`), with a Settings toggle for normal Dock presence - Right-click menu-bar status menu with capture state, history count, settings, pause/resume, and quit - Global shortcuts: - `Command + Option + V` toggles the clipboard panel @@ -18,7 +18,7 @@ The project is intentionally dependency-light: Swift Package Manager, AppKit, Ca - Custom named collections for organizing clips from the card context menu - Copy and paste actions with Accessibility permission fallback - Image thumbnail cache with byte and file-count pruning -- Configurable history length, cache limit, polling profile, ignored apps, content kinds, launch-at-login, and clear-on-quit behavior +- Configurable history length, cache limit, polling profile, ignored apps, content kinds, launch-at-login, Dock/menu-bar presence, and clear-on-quit behavior - Local-only storage, with optional sensitive-content exclusion for common secrets ## Requirements diff --git a/sources/clipbored/app/AppDelegate.swift b/sources/clipbored/app/AppDelegate.swift index 1c491f1..ffc09a3 100644 --- a/sources/clipbored/app/AppDelegate.swift +++ b/sources/clipbored/app/AppDelegate.swift @@ -1,6 +1,17 @@ import AppKit final class AppDelegate: NSObject, NSApplicationDelegate { + enum PresentationSurface: Equatable { + case menuBar + case dock + } + + struct PresentationPlan: Equatable { + let showMenuBarIcon: Bool + let showDockIcon: Bool + let activationPolicy: NSApplication.ActivationPolicy + } + struct StatusMenuPresentation: Equatable { let summary: String let detail: String? @@ -61,6 +72,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { settingsShortcut: settings.settingsShortcut ) bindSettings() + applyPresentation(changedSurface: nil) monitor.setPaused(settings.pauseCapture) monitor.start() shortcutManager.start() @@ -415,7 +427,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { case .launchAtLogin: applyLaunchAtLoginSetting(settings.launchAtLogin) case .showMenuBarIcon: - refreshStatusItem() + applyPresentation(changedSurface: .menuBar) + case .showDockIcon: + applyPresentation(changedSurface: .dock) case .pauseCapture: monitor.setPaused(settings.pauseCapture) if settings.showMenuBarIcon { @@ -430,6 +444,48 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + static func presentationPlan( + showMenuBarIcon: Bool, + showDockIcon: Bool, + changedSurface: PresentationSurface? + ) -> PresentationPlan { + var plannedMenuBarIcon = showMenuBarIcon + var plannedDockIcon = showDockIcon + + if !plannedMenuBarIcon && !plannedDockIcon { + if changedSurface == .menuBar { + plannedDockIcon = true + } else { + plannedMenuBarIcon = true + } + } + + return PresentationPlan( + showMenuBarIcon: plannedMenuBarIcon, + showDockIcon: plannedDockIcon, + activationPolicy: plannedDockIcon ? .regular : .accessory + ) + } + + private func applyPresentation(changedSurface: PresentationSurface?) { + let plan = Self.presentationPlan( + showMenuBarIcon: settings.showMenuBarIcon, + showDockIcon: settings.showDockIcon, + changedSurface: changedSurface + ) + + if settings.showMenuBarIcon != plan.showMenuBarIcon { + settings.showMenuBarIcon = plan.showMenuBarIcon + } + if settings.showDockIcon != plan.showDockIcon { + settings.showDockIcon = plan.showDockIcon + } + + NSApp.setActivationPolicy(plan.activationPolicy) + configureMainMenu() + refreshStatusItem() + } + private func applyLaunchAtLoginSetting(_ shouldLaunch: Bool) { let result = lifecycleService.applyLaunchAtLogin(shouldLaunch) switch result { diff --git a/sources/clipbored/models/SettingsModel.swift b/sources/clipbored/models/SettingsModel.swift index 042f018..fadb4b8 100644 --- a/sources/clipbored/models/SettingsModel.swift +++ b/sources/clipbored/models/SettingsModel.swift @@ -8,6 +8,7 @@ final class SettingsModel { case settingsShortcut case launchAtLogin case showMenuBarIcon + case showDockIcon case pauseCapture case pollProfile case captureStatus @@ -23,6 +24,7 @@ final class SettingsModel { static let pruneDuplicates = "pruneDuplicates" static let launchAtLogin = "launchAtLogin" static let showMenuBarIcon = "showMenuBarIcon" + static let showDockIcon = "showDockIcon" static let openShortcut = "openShortcut" static let settingsShortcut = "settingsShortcut" static let ignoredApps = "ignoredApps" @@ -56,6 +58,9 @@ final class SettingsModel { var showMenuBarIcon: Bool { didSet { if oldValue != showMenuBarIcon { storeAndNotify(.showMenuBarIcon) } } } + var showDockIcon: Bool { + didSet { if oldValue != showDockIcon { storeAndNotify(.showDockIcon) } } + } var openShortcut: ShortcutBinding { didSet { if oldValue != openShortcut { storeAndNotify(.openShortcut) } } } @@ -107,6 +112,7 @@ final class SettingsModel { pruneDuplicates = defaults.object(forKey: Keys.pruneDuplicates) as? Bool ?? true launchAtLogin = defaults.object(forKey: Keys.launchAtLogin) as? Bool ?? false showMenuBarIcon = defaults.object(forKey: Keys.showMenuBarIcon) as? Bool ?? true + showDockIcon = defaults.object(forKey: Keys.showDockIcon) as? Bool ?? false openShortcut = Self.readShortcut(from: defaults.string(forKey: Keys.openShortcut)) ?? AppConfiguration.defaultOpenShortcut settingsShortcut = Self.readShortcut(from: defaults.string(forKey: Keys.settingsShortcut)) ?? AppConfiguration.defaultSettingsShortcut ignoredApps = defaults.stringArray(forKey: Keys.ignoredApps) ?? AppConfiguration.defaultIgnoredApps @@ -135,6 +141,7 @@ final class SettingsModel { defaults.set(pruneDuplicates, forKey: Keys.pruneDuplicates) defaults.set(launchAtLogin, forKey: Keys.launchAtLogin) defaults.set(showMenuBarIcon, forKey: Keys.showMenuBarIcon) + defaults.set(showDockIcon, forKey: Keys.showDockIcon) defaults.set(openShortcut.encoded(), forKey: Keys.openShortcut) defaults.set(settingsShortcut.encoded(), forKey: Keys.settingsShortcut) defaults.set(ignoredApps, forKey: Keys.ignoredApps) diff --git a/sources/clipbored/views/SettingsWindowController.swift b/sources/clipbored/views/SettingsWindowController.swift index 0c216be..48fa710 100644 --- a/sources/clipbored/views/SettingsWindowController.swift +++ b/sources/clipbored/views/SettingsWindowController.swift @@ -13,6 +13,7 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD private let defaultSortPopup = NSPopUpButton() private let launchAtLoginButton = NSButton() private let showMenuBarIconButton = NSButton() + private let showDockIconButton = NSButton() private let launchStatusLabel = NSTextField(labelWithString: "") private var openShortcutControls: ShortcutControlSet? @@ -124,6 +125,7 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD } configureCheckbox(launchAtLoginButton, title: "Launch at login", action: #selector(launchAtLoginChanged)) configureCheckbox(showMenuBarIconButton, title: "Show ClipBored in the menu bar", action: #selector(showMenuBarIconChanged)) + configureCheckbox(showDockIconButton, title: "Show ClipBored in the Dock", action: #selector(showDockIconChanged)) configureStatusLabel(launchStatusLabel) return page([ @@ -138,6 +140,7 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD section("Lifecycle", [ launchAtLoginButton, showMenuBarIconButton, + showDockIconButton, launchStatusLabel ]) ]) @@ -392,6 +395,7 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD select(defaultSortPopup, rawValue: settings.defaultSortMode.rawValue) launchAtLoginButton.state = settings.launchAtLogin ? .on : .off showMenuBarIconButton.state = settings.showMenuBarIcon ? .on : .off + showDockIconButton.state = settings.showDockIcon ? .on : .off launchStatusLabel.stringValue = settings.launchAtLoginErrorMessage refreshShortcutControls(openShortcutControls, binding: settings.openShortcut) @@ -468,7 +472,19 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD } @objc private func showMenuBarIconChanged() { - settings.showMenuBarIcon = showMenuBarIconButton.state == .on + let shouldShowMenuBarIcon = showMenuBarIconButton.state == .on + settings.showMenuBarIcon = shouldShowMenuBarIcon + if !shouldShowMenuBarIcon && !settings.showDockIcon { + settings.showDockIcon = true + } + } + + @objc private func showDockIconChanged() { + let shouldShowDockIcon = showDockIconButton.state == .on + settings.showDockIcon = shouldShowDockIcon + if !shouldShowDockIcon && !settings.showMenuBarIcon { + settings.showMenuBarIcon = true + } } @objc private func shortcutChanged(_ sender: NSControl) { diff --git a/tests/clipboredtests/AppDelegateTests.swift b/tests/clipboredtests/AppDelegateTests.swift index 92b6495..882e1db 100644 --- a/tests/clipboredtests/AppDelegateTests.swift +++ b/tests/clipboredtests/AppDelegateTests.swift @@ -3,6 +3,46 @@ import XCTest @testable import ClipBored final class AppDelegateTests: XCTestCase { + func testPresentationPlanMapsDockPreferenceToActivationPolicy() { + let dockless = AppDelegate.presentationPlan( + showMenuBarIcon: true, + showDockIcon: false, + changedSurface: nil + ) + XCTAssertTrue(dockless.showMenuBarIcon) + XCTAssertFalse(dockless.showDockIcon) + XCTAssertEqual(dockless.activationPolicy, .accessory) + + let dockVisible = AppDelegate.presentationPlan( + showMenuBarIcon: true, + showDockIcon: true, + changedSurface: nil + ) + XCTAssertTrue(dockVisible.showMenuBarIcon) + XCTAssertTrue(dockVisible.showDockIcon) + XCTAssertEqual(dockVisible.activationPolicy, .regular) + } + + func testPresentationPlanKeepsOneVisibleEntryPoint() { + let hidingMenuBar = AppDelegate.presentationPlan( + showMenuBarIcon: false, + showDockIcon: false, + changedSurface: .menuBar + ) + XCTAssertFalse(hidingMenuBar.showMenuBarIcon) + XCTAssertTrue(hidingMenuBar.showDockIcon) + XCTAssertEqual(hidingMenuBar.activationPolicy, .regular) + + let hidingDock = AppDelegate.presentationPlan( + showMenuBarIcon: false, + showDockIcon: false, + changedSurface: .dock + ) + XCTAssertTrue(hidingDock.showMenuBarIcon) + XCTAssertFalse(hidingDock.showDockIcon) + XCTAssertEqual(hidingDock.activationPolicy, .accessory) + } + func testStatusItemMenuRoutingSeparatesLeftAndRightClick() { XCTAssertFalse(AppDelegate.shouldOpenStatusMenu(eventType: .leftMouseUp, modifierFlags: [])) XCTAssertTrue(AppDelegate.shouldOpenStatusMenu(eventType: .rightMouseUp, modifierFlags: [])) diff --git a/tests/clipboredtests/SettingsModelTests.swift b/tests/clipboredtests/SettingsModelTests.swift new file mode 100644 index 0000000..d050f7b --- /dev/null +++ b/tests/clipboredtests/SettingsModelTests.swift @@ -0,0 +1,28 @@ +import XCTest +@testable import ClipBored + +final class SettingsModelTests: XCTestCase { + func testShowDockIconPersistsAndNotifies() { + let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { + defaults.removePersistentDomain(forName: suiteName) + } + let settings = SettingsModel(defaults: defaults) + var changes: [SettingsModel.Change] = [] + settings.observe { changes.append($0) } + + XCTAssertFalse(settings.showDockIcon) + + settings.showDockIcon = true + + XCTAssertTrue(defaults.bool(forKey: SettingsModel.Keys.showDockIcon)) + XCTAssertEqual(changes.count, 1) + guard case .showDockIcon = changes.first else { + return XCTFail("Expected showDockIcon change notification") + } + + let restored = SettingsModel(defaults: defaults) + XCTAssertTrue(restored.showDockIcon) + } +}