From 5b3517a6975679e326505b8961a2ea1a02e3db72 Mon Sep 17 00:00:00 2001 From: uniplanck <198168437+uniplanck@users.noreply.github.com> Date: Sun, 12 Jul 2026 13:28:04 +0900 Subject: [PATCH] Fix paste counting and panel interaction delays --- Clipy/Sources/AppDelegate.swift | 28 ++++- Clipy/Sources/Constants.swift | 1 + Clipy/Sources/Managers/MenuManager.swift | 104 +++++++++++++++--- .../Services/AccessibilityService.swift | 11 +- Clipy/Sources/Services/HotKeyService.swift | 33 +++++- .../Services/PasteCountInputService.swift | 66 ++++++++--- Clipy/Sources/Services/PasteCountStore.swift | 9 ++ ClipyTests/EntitlementGateTests.swift | 32 ++++++ ClipyTests/HotKeyServiceTests.swift | 26 ++++- 9 files changed, 268 insertions(+), 42 deletions(-) diff --git a/Clipy/Sources/AppDelegate.swift b/Clipy/Sources/AppDelegate.swift index 938d184..f8aaf90 100644 --- a/Clipy/Sources/AppDelegate.swift +++ b/Clipy/Sources/AppDelegate.swift @@ -25,6 +25,7 @@ class AppDelegate: NSObject, NSMenuItemValidation { // MARK: - Properties private(set) var updaterController: SPUStandardUpdaterController? private let screenshotObserver = ScreenShotObserver() + private var screenshotObserverThread: Thread? private let disposeBag = DisposeBag() // MARK: - Init @@ -140,6 +141,7 @@ class AppDelegate: NSObject, NSMenuItemValidation { } func terminateApplication() { + screenshotObserverThread?.cancel() NSApplication.shared.terminate(nil) } @@ -254,17 +256,37 @@ private extension AppDelegate { .filter { $0 } .take(1) .subscribe(onNext: { [weak self] _ in - self?.screenshotObserver.start() + self?.startScreenshotObserverIfNeeded() }) .disposed(by: disposeBag) } + + func startScreenshotObserverIfNeeded() { + guard screenshotObserverThread == nil else { return } + let thread = Thread { [weak self] in + guard let self else { return } + autoreleasepool { + self.screenshotObserver.start() + while !Thread.current.isCancelled { + RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.5)) + } + self.screenshotObserver.stop() + } + } + thread.name = "com.uniplanck.BoardMan.ScreenShotObserver" + thread.qualityOfService = .utility + screenshotObserverThread = thread + thread.start() + } } // MARK: - ScreenShotObserver Delegate extension AppDelegate: ScreenShotObserverDelegate { func screenShotObserver(_ observer: ScreenShotObserver, addedItem item: NSMetadataItem) { guard let path = item.value(forAttribute: NSMetadataItemPathKey) as? String else { return } - guard let image = NSImage(contentsOfFile: path) else { return } - AppEnvironment.current.clipService.create(with: image) + DispatchQueue.main.async { + guard let image = NSImage(contentsOfFile: path) else { return } + AppEnvironment.current.clipService.create(with: image) + } } } diff --git a/Clipy/Sources/Constants.swift b/Clipy/Sources/Constants.swift index 185a580..9316901 100644 --- a/Clipy/Sources/Constants.swift +++ b/Clipy/Sources/Constants.swift @@ -119,6 +119,7 @@ struct Constants { static let historyKeyCombo = "kCPYHotKeyHistoryKeyCombo" static let snippetKeyCombo = "kCPYHotKeySnippetKeyCombo" static let migrateNewKeyCombo = "kCPYMigrateNewKeyCombo" + static let migrateOpenBoardManCommandOptionV = "kCPYMigrateOpenBoardManCommandOptionV" static let folderKeyCombos = "kCPYFolderKeyCombos" static let clearHistoryKeyCombo = "kCPYClearHistoryKeyCombo" } diff --git a/Clipy/Sources/Managers/MenuManager.swift b/Clipy/Sources/Managers/MenuManager.swift index e818b60..3f00cfb 100644 --- a/Clipy/Sources/Managers/MenuManager.swift +++ b/Clipy/Sources/Managers/MenuManager.swift @@ -32,6 +32,7 @@ final class MenuManager: NSObject { fileprivate var currentStatusType: StatusType? fileprivate var boardManPanel: BoardManPanel? fileprivate var previousFrontmostApplication: NSRunningApplication? + fileprivate var panelItemsNeedRefresh = true // Icon Cache fileprivate let folderIcon = NSImage(resource: .iconFolder) fileprivate let snippetIcon = NSImage(resource: .iconText) @@ -104,17 +105,14 @@ extension MenuManager { } } boardManPanel?.onRefreshRequested = { [weak self] in - guard let strongSelf = self, let panel = strongSelf.boardManPanel else { return } - panel.reloadHistoryItems(strongSelf.boardManPanelItems()) + guard let self, let panel = self.boardManPanel else { return } + self.reloadBoardManPanelItems(panel) } } if let panel = boardManPanel { // V4B-13: position and show first. Heavy Realm/defaults reload happens after first paint. let panelSize = NSSize(width: BoardManPanel.preferredPanelWidth(), height: BoardManPanel.preferredPanelHeight()) let finalFrame = BoardManPanel.cursorRelativeFrame(size: panelSize, anchorPoint: anchorPoint) - if panel.presentationItemScope == .complete { - panel.reloadHistoryItems(boardManPanelItems()) - } panel.prepareForFirstVisibleOrder(frame: finalFrame) NSApp.activate(ignoringOtherApps: true) panel.makeKeyAndOrderFront(nil) @@ -123,10 +121,8 @@ extension MenuManager { DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(80)) { [weak self, weak panel] in guard let self, let panel else { return } - let items = panel.presentationItemScope == .complete - ? self.boardManPanelItems() - : self.boardManInitialPanelItems() - panel.reloadHistoryItems(items) + guard self.panelItemsNeedRefresh || !panel.hasLoadedPanelItems else { return } + self.reloadBoardManPanelItems(panel) panel.focusTableForKeyboard() } } @@ -141,7 +137,9 @@ extension MenuManager { showBoardManPanel() guard let panel = boardManPanel else { return } panel.openSnippetsManagerMode(categoryIdentifier: folderIdentifier) - panel.reloadHistoryItems(boardManPanelItems()) + if !panel.hasLoadedSnippetItems { + panelItemsNeedRefresh = true + } panel.focusTableForKeyboard() } @@ -225,6 +223,14 @@ extension MenuManager { return boardManHistoryItems() } + fileprivate func reloadBoardManPanelItems(_ panel: BoardManPanel) { + let items = panel.presentationItemScope == .complete + ? boardManPanelItems() + : boardManInitialPanelItems() + panel.reloadHistoryItems(items) + panelItemsNeedRefresh = false + } + fileprivate func boardManHistoryItems() -> [BoardManHistoryItem] { let startedAt = CFAbsoluteTimeGetCurrent() let maxHistory = max(1, AppEnvironment.current.defaults.integer(forKey: Constants.UserDefaults.maxHistorySize)) @@ -236,6 +242,7 @@ extension MenuManager { let showUsageCount = defaults.object(forKey: Constants.UserDefaults.boardManShowUsageCount) as? Bool ?? true let pinStore = PinnedSnippetStore.shared + let pasteCounts = PasteCountStore.shared.countsSnapshot() let panelHistoryLimit = min(maxHistory, 120) // Keep panel launch fast; full history remains in Realm. let items = Array(clipResults.prefix(panelHistoryLimit)).enumerated().map { index, clip in let rawTitle = clip.title.trimmingCharacters(in: .whitespacesAndNewlines) @@ -243,7 +250,7 @@ extension MenuManager { let title = rawTitle.isEmpty ? (isImageClip ? BoardManPanel.imageClipTitle(for: clip) : "(empty clipboard item)") : rawTitle let firstLine = title.components(separatedBy: .newlines).first ?? title let clipped = firstLine.count > 120 ? String(firstLine.prefix(117)) + "..." : firstLine - let pasteCount = PasteCountStore.shared.count(for: clip) + let pasteCount = PasteCountStore.shared.count(for: clip, in: pasteCounts) let isPinned = pinStore.isPinned(clip.dataHash) var parts: [String] = [] if showRowNumbers { @@ -457,6 +464,7 @@ private extension MenuManager { } func createClipMenu() { + panelItemsNeedRefresh = true createBoardManLiteMenu() } @@ -1447,6 +1455,47 @@ private final class BoardManHistoryRowView: NSTableRowView { } } +final class BoardManSettingsCategoryButton: NSButton { + private var hoverTrackingArea: NSTrackingArea? + private(set) var isHovering = false + var hoverDidChange: (() -> Void)? + + override func updateTrackingAreas() { + if let hoverTrackingArea { + removeTrackingArea(hoverTrackingArea) + } + let trackingArea = NSTrackingArea( + rect: bounds, + options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], + owner: self, + userInfo: nil + ) + addTrackingArea(trackingArea) + hoverTrackingArea = trackingArea + super.updateTrackingAreas() + } + + override func mouseEntered(with event: NSEvent) { + setHovering(true) + super.mouseEntered(with: event) + } + + override func mouseExited(with event: NSEvent) { + setHovering(false) + super.mouseExited(with: event) + } + + func setHoveringForTesting(_ value: Bool) { + setHovering(value) + } + + private func setHovering(_ value: Bool) { + guard isHovering != value else { return } + isHovering = value + hoverDidChange?() + } +} + final class BoardManHeaderSegmentedControl: NSSegmentedControl { private var hoverTrackingArea: NSTrackingArea? private(set) var hoveredSegment = -1 @@ -1804,6 +1853,14 @@ class BoardManPanel: NSPanel { return historyItems.count } + var hasLoadedPanelItems: Bool { + return !allItems.isEmpty + } + + var hasLoadedSnippetItems: Bool { + return allItems.contains { $0.source == .snippet } + } + var presentationItemScope: BoardManPresentationItemScope { return activeTab == .snippets ? .complete : .historyOnly } @@ -2232,7 +2289,7 @@ class BoardManPanel: NSPanel { settingsSidebarView = sidebar settingsCategoryButtons = BoardManInlineSettingsCategory.allCases.map { category in - let button = NSButton(title: category.title, target: self, action: #selector(settingsCategoryButtonPressed(_:))) + let button = BoardManSettingsCategoryButton(title: category.title, target: self, action: #selector(settingsCategoryButtonPressed(_:))) button.tag = category.rawValue button.isBordered = false button.alignment = .left @@ -2246,6 +2303,9 @@ class BoardManPanel: NSPanel { named: category.symbolName, title: category.title ) + button.hoverDidChange = { [weak self] in + self?.updateSettingsSidebarSelection() + } sidebar.addSubview(button) return button } @@ -3111,7 +3171,7 @@ class BoardManPanel: NSPanel { let rightWidth = max(0, width - tabsWidth - headerGap) let searchWidth = max(isCompact ? 130 : 170, rightWidth - snippetButtonsWidth - (showsSnippetButtons ? headerGap : 0)) - let searchFrame = NSIntegralRect(NSRect(x: rightX, y: headerY - 2, width: searchWidth, height: 40)) + let searchFrame = NSIntegralRect(NSRect(x: rightX, y: headerY, width: searchWidth, height: 36)) searchField?.frame = searchFrame snippetAddButton?.isHidden = !showsSnippetButtons snippetEditButton?.isHidden = !showsSnippetButtons @@ -3264,12 +3324,20 @@ class BoardManPanel: NSPanel { settingsPageDescriptionLabel?.stringValue = activeSettingsCategory.detail for button in settingsCategoryButtons { let isSelected = button.tag == activeSettingsCategory.rawValue + let isHovering = (button as? BoardManSettingsCategoryButton)?.isHovering == true button.state = isSelected ? .on : .off - button.layer?.backgroundColor = isSelected - ? themeAccentColor.withAlphaComponent(isThemeLightenEnabled ? 0.10 : 0.16).cgColor - : NSColor.clear.cgColor - button.layer?.borderWidth = isSelected ? 1 : 0 - button.layer?.borderColor = themeAccentColor.withAlphaComponent(0.26).cgColor + if isSelected { + button.layer?.backgroundColor = themeAccentColor + .withAlphaComponent(isThemeLightenEnabled ? 0.12 : 0.18).cgColor + } else if isHovering { + button.layer?.backgroundColor = themeAccentColor + .withAlphaComponent(isThemeLightenEnabled ? 0.07 : 0.11).cgColor + } else { + button.layer?.backgroundColor = NSColor.clear.cgColor + } + button.layer?.borderWidth = (isSelected || isHovering) ? 1 : 0 + button.layer?.borderColor = themeAccentColor + .withAlphaComponent(isSelected ? 0.32 : 0.20).cgColor } } diff --git a/Clipy/Sources/Services/AccessibilityService.swift b/Clipy/Sources/Services/AccessibilityService.swift index 8115e8c..f250a79 100644 --- a/Clipy/Sources/Services/AccessibilityService.swift +++ b/Clipy/Sources/Services/AccessibilityService.swift @@ -64,9 +64,13 @@ extension AccessibilityService { NSApp.activate(ignoringOtherApps: true) if alert.runModal() == NSApplication.ModalResponse.alertFirstButtonReturn { - guard !openAccessibilitySettingWindow() else { return } if !isAccessibilityEnabled(isPrompt: false) { + guard !openAccessibilitySettingWindow() else { return } isAccessibilityEnabled(isPrompt: true) + return + } + if !isListenEventAccessEnabled() { + _ = openInputMonitoringSettingWindow() } } } @@ -75,4 +79,9 @@ extension AccessibilityService { guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") else { return false } return NSWorkspace.shared.open(url) } + + func openInputMonitoringSettingWindow() -> Bool { + guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent") else { return false } + return NSWorkspace.shared.open(url) + } } diff --git a/Clipy/Sources/Services/HotKeyService.swift b/Clipy/Sources/Services/HotKeyService.swift index e0f9ba9..a937e8d 100644 --- a/Clipy/Sources/Services/HotKeyService.swift +++ b/Clipy/Sources/Services/HotKeyService.swift @@ -12,6 +12,7 @@ import Foundation import Cocoa +import Carbon import Magnet import RealmSwift @@ -19,12 +20,12 @@ final class HotKeyService: NSObject { // MARK: - Properties static var defaultKeyCombos: [String: Any] = { - // MainMenu: ⌘ + Shift + V + // MainMenu: ⌘ + Option + V // HistoryMenu: ⌘ + Control + V - // SnipeetMenu: ⌘ + Shift B - return [Constants.Menu.clip: ["keyCode": 9, "modifiers": 768], - Constants.Menu.history: ["keyCode": 9, "modifiers": 4352], - Constants.Menu.snippet: ["keyCode": 11, "modifiers": 768]] + // SnippetMenu: ⌘ + Shift + B + return [Constants.Menu.clip: ["keyCode": 9, "modifiers": Int(cmdKey) | Int(optionKey)], + Constants.Menu.history: ["keyCode": 9, "modifiers": Int(cmdKey) | Int(controlKey)], + Constants.Menu.snippet: ["keyCode": 11, "modifiers": Int(cmdKey) | Int(shiftKey)]] }() fileprivate(set) var mainKeyCombo: KeyCombo? @@ -63,6 +64,7 @@ extension HotKeyService { AppEnvironment.current.defaults.set(true, forKey: Constants.HotKey.migrateNewKeyCombo) AppEnvironment.current.defaults.synchronize() } + migrateLegacyMainShortcutIfNeeded() // Snippet hotkey setupSnippetHotKeys() @@ -159,6 +161,27 @@ private extension HotKeyService { guard let keyCode = combos["keyCode"] as? Int, let modifiers = combos["modifiers"] as? Int else { return nil } return (keyCode, modifiers) } + + func migrateLegacyMainShortcutIfNeeded() { + let defaults = AppEnvironment.current.defaults + guard !defaults.bool(forKey: Constants.HotKey.migrateOpenBoardManCommandOptionV) else { return } + defer { + defaults.set(true, forKey: Constants.HotKey.migrateOpenBoardManCommandOptionV) + defaults.synchronize() + } + + guard let current = savedKeyCombo(forKey: Constants.HotKey.mainKeyCombo), + current.QWERTYKeyCode == 9, + current.modifiers == (Int(cmdKey) | Int(shiftKey)), + current.doubledModifiers == false, + let replacement = KeyCombo( + QWERTYKeyCode: 9, + carbonModifiers: Int(cmdKey) | Int(optionKey) + ) else { + return + } + defaults.set(replacement.archive(), forKey: Constants.HotKey.mainKeyCombo) + } } // MARK: - Snippet HotKey diff --git a/Clipy/Sources/Services/PasteCountInputService.swift b/Clipy/Sources/Services/PasteCountInputService.swift index 0bd7e90..226b541 100644 --- a/Clipy/Sources/Services/PasteCountInputService.swift +++ b/Clipy/Sources/Services/PasteCountInputService.swift @@ -7,8 +7,26 @@ import AppKit import Carbon final class PasteCountInputService { + enum EventTapMode: Equatable { + case listenOnly + case accessibilityFallback + + var options: CGEventTapOptions { + switch self { + case .listenOnly: return .listenOnly + case .accessibilityFallback: return .defaultTap + } + } + } + static let shared = PasteCountInputService() + static func eventTapMode(accessibilityTrusted: Bool, listenEventAccess: Bool) -> EventTapMode? { + if listenEventAccess { return .listenOnly } + if accessibilityTrusted { return .accessibilityFallback } + return nil + } + private var globalMonitor: Any? private var localMonitor: Any? private var eventTap: CFMachPort? @@ -137,29 +155,50 @@ final class PasteCountInputService { private func createAndRunCGEventTap(reason: String) { autoreleasepool { // Manual paste tracking is intentionally limited to keyboard events. - // Generic context-menu or app Edit > Paste commands do not emit a reliable - // cross-app paste signal without invasive AX text inspection. - guard CGPreflightListenEventAccess() else { + // Prefer a listen-only tap when Input Monitoring is granted. When it is not, + // fall back to an accessibility-authorized pass-through tap and return every + // event unchanged. This keeps Cmd+V counting functional with the permission + // Board-Man already requires for paste automation. + let accessibilityTrusted = AXIsProcessTrusted() + let listenEventAccess = CGPreflightListenEventAccess() + guard let mode = Self.eventTapMode( + accessibilityTrusted: accessibilityTrusted, + listenEventAccess: listenEventAccess + ) else { DispatchQueue.main.async { [weak self] in self?.isStartingEventTap = false - self?.log("cg event tap skipped reason=listen_event_access_missing trigger=\(reason)") + self?.log("cg event tap skipped reason=permission_missing trigger=\(reason)") } return } let mask = CGEventMask(1 << CGEventType.keyDown.rawValue) - guard let tap = CGEvent.tapCreate( - tap: .cgSessionEventTap, - place: .tailAppendEventTap, - options: .listenOnly, - eventsOfInterest: mask, - callback: Self.eventTapCallback, - userInfo: Unmanaged.passUnretained(self).toOpaque() - ) else { + let candidateLocations: [CGEventTapLocation] = mode == .listenOnly + ? [.cgSessionEventTap] + : [.cgSessionEventTap, .cgAnnotatedSessionEventTap] + var selectedLocation: CGEventTapLocation? + var createdTap: CFMachPort? + for location in candidateLocations { + if let tap = CGEvent.tapCreate( + tap: location, + place: .tailAppendEventTap, + options: mode.options, + eventsOfInterest: mask, + callback: Self.eventTapCallback, + userInfo: Unmanaged.passUnretained(self).toOpaque() + ) { + selectedLocation = location + createdTap = tap + break + } + } + + guard let tap = createdTap else { DispatchQueue.main.async { [weak self] in self?.isStartingEventTap = false self?.log("cg event tap failed accessibilityTrusted=\(AXIsProcessTrusted()) listenEventAccess=\(CGPreflightListenEventAccess())") + AppEnvironment.current.accessibilityService.showAccessibilityAuthenticationAlert() } return } @@ -182,7 +221,8 @@ final class PasteCountInputService { self?.eventTapSource = source self?.eventTapRunLoop = runLoop self?.isStartingEventTap = false - self?.log("cg event tap started enabled=\(CGEvent.tapIsEnabled(tap: tap)) trigger=\(reason)") + let locationText = selectedLocation.map { String($0.rawValue) } ?? "none" + self?.log("cg event tap started mode=\(mode) location=\(locationText) enabled=\(CGEvent.tapIsEnabled(tap: tap)) trigger=\(reason)") } CFRunLoopRun() diff --git a/Clipy/Sources/Services/PasteCountStore.swift b/Clipy/Sources/Services/PasteCountStore.swift index 648a1ab..80bb363 100644 --- a/Clipy/Sources/Services/PasteCountStore.swift +++ b/Clipy/Sources/Services/PasteCountStore.swift @@ -42,6 +42,15 @@ final class PasteCountStore { return count(forKey: key(for: clip)) } + func count(for clip: CPYClip, in snapshot: [String: NSNumber]) -> Int { + return snapshot[key(for: clip)]?.intValue ?? 0 + } + + func countsSnapshot() -> [String: NSNumber] { + lock.lock(); defer { lock.unlock() } + return counts() + } + func label(for clip: CPYClip) -> String { return "\(count(for: clip)) " } diff --git a/ClipyTests/EntitlementGateTests.swift b/ClipyTests/EntitlementGateTests.swift index b622d7b..200e666 100644 --- a/ClipyTests/EntitlementGateTests.swift +++ b/ClipyTests/EntitlementGateTests.swift @@ -406,6 +406,25 @@ final class LegacySnippetMigrationTests { } } +@Suite +struct PasteCountInputServiceTests { + @Test + func eventTapUsesAccessibilityFallbackWithoutInputMonitoring() { + #expect(PasteCountInputService.eventTapMode( + accessibilityTrusted: true, + listenEventAccess: false + ) == .accessibilityFallback) + #expect(PasteCountInputService.eventTapMode( + accessibilityTrusted: false, + listenEventAccess: true + ) == .listenOnly) + #expect(PasteCountInputService.eventTapMode( + accessibilityTrusted: false, + listenEventAccess: false + ) == nil) + } +} + @MainActor @Suite(.serialized) final class BoardManPanelLayoutTests { @@ -445,9 +464,19 @@ final class BoardManPanelLayoutTests { #expect(panel.presentationItemScope == .historyOnly) #expect(categories.count == expectedTitles.count, "Settings sidebar did not create all categories.") for category in categories { + #expect(category is BoardManSettingsCategoryButton, + "Settings category is missing hover-aware button behavior.") #expect((category.image?.size.width ?? 0) >= 20, "Settings sidebar icon is missing its leading content inset.") } + if let hoverTarget = categories.first(where: { $0.tag != 0 }) as? BoardManSettingsCategoryButton { + #expect((hoverTarget.layer?.borderWidth ?? 0) == 0) + hoverTarget.setHoveringForTesting(true) + #expect(hoverTarget.isHovering) + #expect((hoverTarget.layer?.borderWidth ?? 0) == 1) + hoverTarget.setHoveringForTesting(false) + #expect((hoverTarget.layer?.borderWidth ?? 0) == 0) + } for category in categories { _ = category.sendAction(category.action, to: category.target) @@ -490,6 +519,9 @@ final class BoardManPanelLayoutTests { "Search field is not using the native AppKit search cell.") #expect((search.layer?.borderWidth ?? 0) == 0, "Search field has a second custom layer border.") + #expect(abs(search.frame.midY - tabs.frame.midY) <= 0.5, + "Search input is not vertically centered with the header tabs.") + #expect(search.frame.height == tabs.frame.height) } } diff --git a/ClipyTests/HotKeyServiceTests.swift b/ClipyTests/HotKeyServiceTests.swift index b72e73b..d822cec 100644 --- a/ClipyTests/HotKeyServiceTests.swift +++ b/ClipyTests/HotKeyServiceTests.swift @@ -10,6 +10,7 @@ final class HotKeyServiceTests { let defaults = UserDefaults.standard defaults.removeObject(forKey: Constants.UserDefaults.hotKeys) defaults.removeObject(forKey: Constants.HotKey.migrateNewKeyCombo) + defaults.removeObject(forKey: Constants.HotKey.migrateOpenBoardManCommandOptionV) defaults.removeObject(forKey: Constants.HotKey.mainKeyCombo) defaults.removeObject(forKey: Constants.HotKey.historyKeyCombo) defaults.removeObject(forKey: Constants.HotKey.snippetKeyCombo) @@ -22,6 +23,7 @@ final class HotKeyServiceTests { let defaults = UserDefaults.standard defaults.removeObject(forKey: Constants.UserDefaults.hotKeys) defaults.removeObject(forKey: Constants.HotKey.migrateNewKeyCombo) + defaults.removeObject(forKey: Constants.HotKey.migrateOpenBoardManCommandOptionV) defaults.removeObject(forKey: Constants.HotKey.mainKeyCombo) defaults.removeObject(forKey: Constants.HotKey.historyKeyCombo) defaults.removeObject(forKey: Constants.HotKey.snippetKeyCombo) @@ -44,7 +46,7 @@ final class HotKeyServiceTests { let mainKeyCombo = try #require(service.mainKeyCombo) #expect(mainKeyCombo.QWERTYKeyCode == 9) - #expect(mainKeyCombo.modifiers == 768) + #expect(mainKeyCombo.modifiers == (Int(cmdKey) | Int(optionKey))) #expect(mainKeyCombo.doubledModifiers == false) #expect(mainKeyCombo.keyEquivalent.uppercased() == "V") @@ -153,6 +155,7 @@ final class HotKeyServiceTests { func unarchiveSavedKeyCombos() throws { let defaults = UserDefaults.standard defaults.set(true, forKey: Constants.HotKey.migrateNewKeyCombo) + defaults.set(true, forKey: Constants.HotKey.migrateOpenBoardManCommandOptionV) let mainKeyCombo = try #require(KeyCombo(QWERTYKeyCode: 9, carbonModifiers: 768)) let historyKeyCombo = try #require(KeyCombo(doubledCocoaModifiers: .command)) @@ -188,6 +191,25 @@ final class HotKeyServiceTests { #expect(savedSnippetKeyCombo.keyEquivalent.uppercased() == "A") } + @Test + func migratesLegacyDefaultMainShortcutToCommandOptionV() throws { + let defaults = UserDefaults.standard + defaults.set(true, forKey: Constants.HotKey.migrateNewKeyCombo) + let legacy = try #require(KeyCombo( + QWERTYKeyCode: 9, + carbonModifiers: Int(cmdKey) | Int(shiftKey) + )) + defaults.setArchiveData(legacy, forKey: Constants.HotKey.mainKeyCombo) + + let service = HotKeyService() + service.setupDefaultHotKeys() + + let migrated = try #require(service.mainKeyCombo) + #expect(migrated.QWERTYKeyCode == 9) + #expect(migrated.modifiers == (Int(cmdKey) | Int(optionKey))) + #expect(defaults.bool(forKey: Constants.HotKey.migrateOpenBoardManCommandOptionV)) + } + @Test func defaultKeyCombos() { let keyCombos = HotKeyService.defaultKeyCombos @@ -196,7 +218,7 @@ final class HotKeyServiceTests { let snippetCombos = keyCombos[Constants.Menu.snippet] as? [String: Int] #expect(mainCombos?["keyCode"] == 9) - #expect(mainCombos?["modifiers"] == 768) + #expect(mainCombos?["modifiers"] == (Int(cmdKey) | Int(optionKey))) #expect(historyCombos?["keyCode"] == 9) #expect(historyCombos?["modifiers"] == 4352)