Skip to content

Commit 21f05bc

Browse files
waveverclaude
andcommitted
feat: quick paste, rebindable shortcuts, dynamic island toast
- Adopt sindresorhus/KeyboardShortcuts so the global hotkeys (open main window, open quick paste) are user-rebindable from Settings instead of the hard-coded ⌘⇧V monitor. - Add a floating quick-paste panel that anchors at the cursor (or under the dynamic island), supports multi-select, and synthesises ⌘V into the previously-frontmost app via AutoPasteService. Falls back to a toast when Accessibility permission is missing. - Centralise the SwiftData store in AppContainer.shared so the popup reads the same history the main window writes. - Add an optional Dynamic Island pill that hugs the notch, briefly toasts each new clip, and opens the quick-paste list on tap. - Track internal pasteboard writes in ClipboardMonitor so re-copying a history item no longer bumps it to the top of the list. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 36b411e commit 21f05bc

11 files changed

Lines changed: 871 additions & 34 deletions

ClipBoardManager.xcodeproj/project.pbxproj

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@
3838
A10031 /* SnippetEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10031; };
3939
A10032 /* TrashPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10032; };
4040
A10033 /* MCPServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10033; };
41+
A10034 /* AppShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10034; };
42+
A10035 /* AutoPasteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10035; };
43+
A10036 /* QuickPasteController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10036; };
44+
A10037 /* QuickPasteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10037; };
45+
A10038 /* DynamicIslandController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10038; };
46+
A10039 /* DynamicIslandView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10039; };
47+
A20001 /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = P10001 /* KeyboardShortcuts */; };
4148
/* End PBXBuildFile section */
4249

4350
/* Begin PBXFileReference section */
@@ -72,6 +79,12 @@
7279
B10031 /* SnippetEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnippetEditorView.swift; sourceTree = "<group>"; };
7380
B10032 /* TrashPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrashPanelView.swift; sourceTree = "<group>"; };
7481
B10033 /* MCPServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCPServer.swift; sourceTree = "<group>"; };
82+
B10034 /* AppShortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppShortcuts.swift; sourceTree = "<group>"; };
83+
B10035 /* AutoPasteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoPasteService.swift; sourceTree = "<group>"; };
84+
B10036 /* QuickPasteController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPasteController.swift; sourceTree = "<group>"; };
85+
B10037 /* QuickPasteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPasteView.swift; sourceTree = "<group>"; };
86+
B10038 /* DynamicIslandController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicIslandController.swift; sourceTree = "<group>"; };
87+
B10039 /* DynamicIslandView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicIslandView.swift; sourceTree = "<group>"; };
7588
B10099 /* ClipBoardManager.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ClipBoardManager.app; sourceTree = BUILT_PRODUCTS_DIR; };
7689
/* End PBXFileReference section */
7790

@@ -80,6 +93,7 @@
8093
isa = PBXFrameworksBuildPhase;
8194
buildActionMask = 2147483647;
8295
files = (
96+
A20001 /* KeyboardShortcuts in Frameworks */,
8397
);
8498
runOnlyForDeploymentPostprocessing = 0;
8599
};
@@ -135,6 +149,8 @@
135149
B10029 /* StatsPanelView.swift */,
136150
B10031 /* SnippetEditorView.swift */,
137151
B10032 /* TrashPanelView.swift */,
152+
B10037 /* QuickPasteView.swift */,
153+
B10039 /* DynamicIslandView.swift */,
138154
);
139155
path = Views;
140156
sourceTree = "<group>";
@@ -160,6 +176,10 @@
160176
B10027 /* ImageStitcher.swift */,
161177
B10030 /* URLSanitizer.swift */,
162178
B10033 /* MCPServer.swift */,
179+
B10034 /* AppShortcuts.swift */,
180+
B10035 /* AutoPasteService.swift */,
181+
B10036 /* QuickPasteController.swift */,
182+
B10038 /* DynamicIslandController.swift */,
163183
);
164184
path = Services;
165185
sourceTree = "<group>";
@@ -188,6 +208,9 @@
188208
dependencies = (
189209
);
190210
name = ClipBoardManager;
211+
packageProductDependencies = (
212+
P10001 /* KeyboardShortcuts */,
213+
);
191214
productName = ClipBoardManager;
192215
productReference = B10099 /* ClipBoardManager.app */;
193216
productType = "com.apple.product-type.application";
@@ -212,6 +235,9 @@
212235
Base,
213236
);
214237
mainGroup = E10001;
238+
packageReferences = (
239+
R10001 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */,
240+
);
215241
productRefGroup = E10007 /* Products */;
216242
projectDirPath = "";
217243
projectRoot = "";
@@ -267,6 +293,12 @@
267293
A10031 /* SnippetEditorView.swift in Sources */,
268294
A10032 /* TrashPanelView.swift in Sources */,
269295
A10033 /* MCPServer.swift in Sources */,
296+
A10034 /* AppShortcuts.swift in Sources */,
297+
A10035 /* AutoPasteService.swift in Sources */,
298+
A10036 /* QuickPasteController.swift in Sources */,
299+
A10037 /* QuickPasteView.swift in Sources */,
300+
A10038 /* DynamicIslandController.swift in Sources */,
301+
A10039 /* DynamicIslandView.swift in Sources */,
270302
);
271303
runOnlyForDeploymentPostprocessing = 0;
272304
};
@@ -456,6 +488,25 @@
456488
defaultConfigurationName = Release;
457489
};
458490
/* End XCConfigurationList section */
491+
492+
/* Begin XCRemoteSwiftPackageReference section */
493+
R10001 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */ = {
494+
isa = XCRemoteSwiftPackageReference;
495+
repositoryURL = "https://github.com/sindresorhus/KeyboardShortcuts";
496+
requirement = {
497+
kind = upToNextMajorVersion;
498+
minimumVersion = 2.0.0;
499+
};
500+
};
501+
/* End XCRemoteSwiftPackageReference section */
502+
503+
/* Begin XCSwiftPackageProductDependency section */
504+
P10001 /* KeyboardShortcuts */ = {
505+
isa = XCSwiftPackageProductDependency;
506+
package = R10001 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */;
507+
productName = KeyboardShortcuts;
508+
};
509+
/* End XCSwiftPackageProductDependency section */
459510
};
460511
rootObject = 089C1665FE841187C02AAC07 /* Project object */;
461512
}

ClipBoardManager/ClipBoardManagerApp.swift

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import SwiftUI
22
import SwiftData
3+
import KeyboardShortcuts
34

45
@main
56
struct AppLauncher {
@@ -24,7 +25,7 @@ struct ClipBoardManagerApp: App {
2425
WindowGroup("ClipBoardManager", id: "main") {
2526
MainWindowView()
2627
.environmentObject(clipboardVM)
27-
.modelContainer(for: ClipboardItem.self)
28+
.modelContainer(AppContainer.shared)
2829
.onAppear {
2930
applyActivationPolicy()
3031
applyCaptureProtection()
@@ -41,7 +42,7 @@ struct ClipBoardManagerApp: App {
4142
MenuBarExtra("ClipBoard", systemImage: "doc.on.clipboard", isInserted: $menuBarIcon) {
4243
MenuBarView()
4344
.environmentObject(clipboardVM)
44-
.modelContainer(for: ClipboardItem.self)
45+
.modelContainer(AppContainer.shared)
4546
}
4647
.menuBarExtraStyle(.window)
4748
}
@@ -76,11 +77,12 @@ struct ClipBoardManagerApp: App {
7677
}
7778

7879
class AppDelegate: NSObject, NSApplicationDelegate {
79-
private var hotKeyMonitor: Any?
80-
8180
func applicationDidFinishLaunching(_ notification: Notification) {
8281
applyInitialActivationPolicy()
83-
setupGlobalHotKey()
82+
setupGlobalHotKeys()
83+
DynamicIslandController.shared.setEnabled(
84+
DynamicIslandController.shared.isEnabled
85+
)
8486
}
8587

8688
private func applyInitialActivationPolicy() {
@@ -92,22 +94,25 @@ class AppDelegate: NSObject, NSApplicationDelegate {
9294
NSApp.setActivationPolicy(showInDock ? .regular : .accessory)
9395
}
9496

95-
private func setupGlobalHotKey() {
96-
hotKeyMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { event in
97-
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
98-
if flags == [.command, .shift] && event.keyCode == 9 { // V key
99-
NSApp.activate(ignoringOtherApps: true)
100-
for window in NSApp.windows where window.title == "ClipBoardManager" {
101-
window.makeKeyAndOrderFront(nil)
102-
break
103-
}
97+
private func setupGlobalHotKeys() {
98+
KeyboardShortcuts.onKeyUp(for: .openMainWindow) {
99+
Task { @MainActor in
100+
AppDelegate.openMainWindow()
101+
}
102+
}
103+
KeyboardShortcuts.onKeyUp(for: .openQuickPaste) {
104+
Task { @MainActor in
105+
QuickPasteController.shared.toggle()
104106
}
105107
}
106108
}
107109

108-
deinit {
109-
if let monitor = hotKeyMonitor {
110-
NSEvent.removeMonitor(monitor)
110+
@MainActor
111+
private static func openMainWindow() {
112+
NSApp.activate(ignoringOtherApps: true)
113+
for window in NSApp.windows where window.title == "ClipBoardManager" {
114+
window.makeKeyAndOrderFront(nil)
115+
return
111116
}
112117
}
113118
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import Foundation
2+
import KeyboardShortcuts
3+
4+
extension KeyboardShortcuts.Name {
5+
static let openMainWindow = Self(
6+
"openMainWindow",
7+
default: .init(.v, modifiers: [.command, .shift])
8+
)
9+
10+
static let openQuickPaste = Self(
11+
"openQuickPaste",
12+
default: .init(.v, modifiers: [.command, .option])
13+
)
14+
}
15+
16+
enum AppShortcut: String, CaseIterable, Identifiable {
17+
case openMainWindow
18+
case openQuickPaste
19+
20+
var id: String { rawValue }
21+
22+
var name: KeyboardShortcuts.Name {
23+
switch self {
24+
case .openMainWindow: return .openMainWindow
25+
case .openQuickPaste: return .openQuickPaste
26+
}
27+
}
28+
29+
var displayName: String {
30+
switch self {
31+
case .openMainWindow: return "打开主窗口"
32+
case .openQuickPaste: return "弹出快速粘贴浮窗"
33+
}
34+
}
35+
36+
var subtitle: String {
37+
switch self {
38+
case .openMainWindow: return "全局热键,激活主窗口并置前"
39+
case .openQuickPaste: return "在鼠标位置弹出浮窗,多选后自动粘贴到当前应用"
40+
}
41+
}
42+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import AppKit
2+
import ApplicationServices
3+
4+
/// Synthesises a ⌘V keystroke targeted at whatever app is currently frontmost.
5+
/// Requires Accessibility permission; otherwise calls fall through silently and
6+
/// callers should surface a toast.
7+
@MainActor
8+
enum AutoPasteService {
9+
/// `true` when the app already has Accessibility permission.
10+
static var isTrusted: Bool {
11+
AXIsProcessTrusted()
12+
}
13+
14+
/// Show the system Accessibility prompt if we're not trusted yet.
15+
/// Does nothing when already trusted.
16+
static func requestTrust() {
17+
let key = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
18+
_ = AXIsProcessTrustedWithOptions([key: true] as CFDictionary)
19+
}
20+
21+
/// Post ⌘V to the system. Returns `false` if Accessibility is missing.
22+
@discardableResult
23+
static func paste() -> Bool {
24+
guard isTrusted else { return false }
25+
26+
let source = CGEventSource(stateID: .combinedSessionState)
27+
let vKey: CGKeyCode = 0x09 // kVK_ANSI_V
28+
29+
let down = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: true)
30+
down?.flags = .maskCommand
31+
let up = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: false)
32+
up?.flags = .maskCommand
33+
34+
down?.post(tap: .cghidEventTap)
35+
up?.post(tap: .cghidEventTap)
36+
return true
37+
}
38+
}

ClipBoardManager/Services/ClipboardMonitor.swift

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,23 @@ class ClipboardMonitor: ObservableObject {
99
private let pasteboard = NSPasteboard.general
1010
private var onNewContent: Callback?
1111

12+
/// `changeCount` values produced by our own writes (re-copy from history,
13+
/// quick-paste, merge, …). When the poller sees one of these, it should
14+
/// skip processing — otherwise the existing item gets its `createdAt`
15+
/// bumped to "now" and visibly jumps to the top of the list.
16+
private static var internalChangeCounts: Set<Int> = []
17+
18+
/// Call this **right after** writing to `NSPasteboard.general` from within
19+
/// the app so the monitor knows to ignore the resulting change-count tick.
20+
static func markInternalWrite() {
21+
internalChangeCounts.insert(NSPasteboard.general.changeCount)
22+
// Bound the buffer so a long-running session can't leak ints forever
23+
// if some write somehow never gets observed by the poller.
24+
if internalChangeCounts.count > 32 {
25+
internalChangeCounts.removeAll()
26+
}
27+
}
28+
1229
init() {
1330
lastChangeCount = pasteboard.changeCount
1431
}
@@ -19,17 +36,23 @@ class ClipboardMonitor: ObservableObject {
1936
self?.checkForChanges()
2037
}
2138
}
22-
39+
2340
func stopMonitoring() {
2441
timer?.invalidate()
2542
timer = nil
2643
}
27-
44+
2845
private func checkForChanges() {
2946
let currentChangeCount = pasteboard.changeCount
3047
guard currentChangeCount != lastChangeCount else { return }
3148
lastChangeCount = currentChangeCount
3249

50+
// Skip ticks we caused ourselves — re-copying a history item should
51+
// leave its position untouched, not boost it to the top.
52+
if Self.internalChangeCounts.remove(currentChangeCount) != nil {
53+
return
54+
}
55+
3356
let (sourceApp, bundleId): (String, String)
3457
if isRemoteClipboard() {
3558
// Universal Clipboard delivery (iPhone/iPad/other Mac via
@@ -85,7 +108,7 @@ class ClipboardMonitor: ObservableObject {
85108
return
86109
}
87110
}
88-
111+
89112
private func getActiveApp() -> (name: String, bundleId: String) {
90113
if let app = NSWorkspace.shared.frontmostApplication {
91114
return (app.localizedName ?? "未知", app.bundleIdentifier ?? "")
@@ -105,7 +128,7 @@ class ClipboardMonitor: ObservableObject {
105128
let marker = NSPasteboard.PasteboardType("com.apple.is-remote-clipboard")
106129
return pasteboard.types?.contains(marker) ?? false
107130
}
108-
131+
109132
deinit {
110133
stopMonitoring()
111134
}

0 commit comments

Comments
 (0)