Last active
February 21, 2026 19:01
-
-
Save ajpc500/13566fd48647885845cd2c91746ed852 to your computer and use it in GitHub Desktop.
macOS Pasteboard Monitor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ClipboardMonitor.m | |
| // | |
| // Simple pasteboard monitor that logs clipboard and drag pasteboard activity | |
| // with source and destination attribution. | |
| // | |
| // Compile: | |
| // clang -fobjc-arc -framework Cocoa -framework ApplicationServices -framework Security \ | |
| // -o ClipboardMonitor ClipboardMonitor.m | |
| // | |
| // Run (requires Accessibility permission): | |
| // ./ClipboardMonitor | |
| #import <Cocoa/Cocoa.h> | |
| #import <ApplicationServices/ApplicationServices.h> | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| #pragma mark - Configuration | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| static NSSet<NSString *> *gTerminalBundleIDs; | |
| static NSSet<NSString *> *gBrowserBundleIDs; | |
| static void InitConfig(void) { | |
| gTerminalBundleIDs = [NSSet setWithArray:@[ | |
| @"com.apple.Terminal", | |
| @"com.googlecode.iterm2", | |
| @"com.mitchellh.ghostty", | |
| @"net.kovidgoyal.kitty", | |
| @"dev.warp.Warp-Stable", | |
| @"com.github.wez.wezterm", | |
| @"co.zeit.hyper", | |
| ]]; | |
| gBrowserBundleIDs = [NSSet setWithArray:@[ | |
| @"com.apple.Safari", | |
| @"com.google.Chrome", | |
| @"org.mozilla.firefox", | |
| @"com.microsoft.edgemac", | |
| @"com.brave.Browser", | |
| @"company.thebrowser.Browser", | |
| @"com.operasoftware.Opera", | |
| @"com.vivaldi.Vivaldi", | |
| @"app.zen-browser.zen", | |
| ]]; | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| #pragma mark - Signing ID lookup | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| static NSMutableDictionary<NSString *, NSString *> *gSigningIDCache; | |
| static NSString *SigningID(NSRunningApplication *app) { | |
| if (!app || !app.bundleURL) return @"<unknown>"; | |
| NSString *cacheKey = app.bundleURL.absoluteString; | |
| if (!gSigningIDCache) gSigningIDCache = [NSMutableDictionary new]; | |
| NSString *cached = gSigningIDCache[cacheKey]; | |
| if (cached) return cached; | |
| SecStaticCodeRef staticCode = NULL; | |
| OSStatus status = SecStaticCodeCreateWithPath( | |
| (__bridge CFURLRef)app.bundleURL, kSecCSDefaultFlags, &staticCode); | |
| if (status != errSecSuccess || !staticCode) return @"<unsigned>"; | |
| CFDictionaryRef info = NULL; | |
| status = SecCodeCopySigningInformation(staticCode, kSecCSSigningInformation, &info); | |
| CFRelease(staticCode); | |
| if (status != errSecSuccess || !info) return @"<unsigned>"; | |
| NSDictionary *sigInfo = (__bridge_transfer NSDictionary *)info; | |
| NSString *identifier = sigInfo[(__bridge NSString *)kSecCodeInfoIdentifier]; | |
| NSString *result = identifier ?: @"<unsigned>"; | |
| gSigningIDCache[cacheKey] = result; | |
| return result; | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| #pragma mark - App categorisation helpers | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| static NSString *AppCategory(NSString *bundleID) { | |
| if (!bundleID) return @"unknown"; | |
| if ([gTerminalBundleIDs containsObject:bundleID]) return @"terminal"; | |
| if ([gBrowserBundleIDs containsObject:bundleID]) return @"browser"; | |
| return @"other"; | |
| } | |
| static NSString *AppLabel(NSRunningApplication *app) { | |
| if (!app) return @"<unknown>"; | |
| return [NSString stringWithFormat:@"%@ (%@)", | |
| app.localizedName ?: @"?", | |
| app.bundleIdentifier ?: @"?"]; | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| #pragma mark - Shared preview helper | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| static const NSUInteger kPreviewMaxLen = 120; | |
| static NSString *Preview(NSString *text) { | |
| if (!text || text.length == 0) return @"<empty>"; | |
| NSUInteger len = MIN(kPreviewMaxLen, text.length); | |
| NSString *preview = [text substringToIndex:len]; | |
| preview = [preview stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"]; | |
| preview = [preview stringByReplacingOccurrencesOfString:@"\r" withString:@"\\r"]; | |
| if (text.length > len) preview = [preview stringByAppendingString:@"β¦"]; | |
| return preview; | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| #pragma mark - Clipboard source tracking | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| static NSString *gCopySourceBundleID = nil; | |
| static NSString *gCopySourceAppName = nil; | |
| static NSString *gCopySourceSigningID = nil; | |
| static NSString *gCopySourceCategory = nil; | |
| static void RecordCopySource(void) { | |
| NSRunningApplication *front = NSWorkspace.sharedWorkspace.frontmostApplication; | |
| gCopySourceBundleID = front.bundleIdentifier ?: @"<unknown>"; | |
| gCopySourceAppName = front.localizedName ?: @"<unknown>"; | |
| gCopySourceSigningID = SigningID(front); | |
| gCopySourceCategory = AppCategory(gCopySourceBundleID); | |
| NSString *clipText = [NSPasteboard.generalPasteboard stringForType:NSPasteboardTypeString]; | |
| NSUInteger len = clipText.length; | |
| NSString *preview = Preview(clipText); | |
| // Only log if source is a terminal or browser | |
| if ([gCopySourceCategory isEqualToString:@"other"]) return; | |
| NSLog(@"π COPY | source: %@ [%@] signing_id:%@ category:%@ | %lu chars | \"%@\"", | |
| gCopySourceAppName, gCopySourceBundleID, gCopySourceSigningID, | |
| gCopySourceCategory, (unsigned long)len, preview); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| #pragma mark - Paste logging | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| static void LogPaste(NSString *trigger) { | |
| NSRunningApplication *dest = NSWorkspace.sharedWorkspace.frontmostApplication; | |
| NSString *destBundleID = dest.bundleIdentifier ?: @"<unknown>"; | |
| NSString *destAppName = dest.localizedName ?: @"<unknown>"; | |
| NSString *destSigningID = SigningID(dest); | |
| NSString *destCategory = AppCategory(destBundleID); | |
| // Only log if source or destination is a terminal/browser | |
| BOOL sourceInteresting = gCopySourceCategory && ![gCopySourceCategory isEqualToString:@"other"]; | |
| BOOL destInteresting = ![destCategory isEqualToString:@"other"]; | |
| if (!sourceInteresting && !destInteresting) return; | |
| NSString *clipText = [NSPasteboard.generalPasteboard stringForType:NSPasteboardTypeString]; | |
| NSUInteger len = clipText.length; | |
| NSString *preview = Preview(clipText); | |
| NSLog(@"π PASTE | trigger: %@ | source: %@ [%@] (%@) β dest: %@ [%@] (%@) | %lu chars | \"%@\"", | |
| trigger, | |
| gCopySourceAppName ?: @"<unknown>", gCopySourceBundleID ?: @"<unknown>", gCopySourceCategory ?: @"unknown", | |
| destAppName, destBundleID, destCategory, | |
| (unsigned long)len, preview); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| #pragma mark - Drag pasteboard monitoring | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| static NSInteger gLastDragChangeCount = 0; | |
| static void CheckDragPasteboard(void) { | |
| NSPasteboard *dragPB = [NSPasteboard pasteboardWithName:NSPasteboardNameDrag]; | |
| NSInteger current = dragPB.changeCount; | |
| if (current == gLastDragChangeCount) return; | |
| gLastDragChangeCount = current; | |
| NSString *dragText = [dragPB stringForType:NSPasteboardTypeString]; | |
| if (!dragText || dragText.length == 0) return; | |
| NSUInteger len = dragText.length; | |
| NSString *preview = Preview(dragText); | |
| NSRunningApplication *front = NSWorkspace.sharedWorkspace.frontmostApplication; | |
| NSString *appName = front.localizedName ?: @"<unknown>"; | |
| NSString *bundleID = front.bundleIdentifier ?: @"<unknown>"; | |
| NSString *category = AppCategory(bundleID); | |
| // Only log if the drag involves a terminal or browser | |
| if ([category isEqualToString:@"other"]) return; | |
| NSLog(@"π¦ DRAG | app: %@ [%@] (%@) | %lu chars | \"%@\"", | |
| appName, bundleID, category, (unsigned long)len, preview); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| #pragma mark - AX terminal watcher (drop detection) | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| static NSMutableDictionary<NSNumber *, NSValue *> *gAXObservers; | |
| static NSInteger gLastSeenDragPBChangeCount = 0; | |
| static void AXCallback(AXObserverRef observer, AXUIElementRef element, | |
| CFStringRef notification, void *refcon) | |
| { | |
| NSString *notifName = (__bridge NSString *)notification; | |
| if (![notifName isEqualToString:(__bridge NSString *)kAXValueChangedNotification]) | |
| return; | |
| pid_t termPID = 0; | |
| AXUIElementGetPid(element, &termPID); | |
| if (termPID <= 0) return; | |
| // Check if the drag pasteboard changed since we last saw it β indicates a fresh drop | |
| NSPasteboard *dragPB = [NSPasteboard pasteboardWithName:NSPasteboardNameDrag]; | |
| NSInteger dragPBCount = dragPB.changeCount; | |
| if (dragPBCount == gLastSeenDragPBChangeCount) return; | |
| gLastSeenDragPBChangeCount = dragPBCount; | |
| NSString *dragText = [dragPB stringForType:NSPasteboardTypeString]; | |
| if (!dragText || dragText.length == 0) return; | |
| NSRunningApplication *termApp = | |
| [NSRunningApplication runningApplicationWithProcessIdentifier:termPID]; | |
| NSString *destBundleID = termApp.bundleIdentifier ?: @"<unknown>"; | |
| NSString *destAppName = termApp.localizedName ?: @"<unknown>"; | |
| NSUInteger len = dragText.length; | |
| NSString *preview = Preview(dragText); | |
| NSLog(@"π― DROP | source: drag pasteboard β dest: %@ [%@] (%@) | %lu chars | \"%@\"", | |
| destAppName, destBundleID, AppCategory(destBundleID), | |
| (unsigned long)len, preview); | |
| } | |
| static void WatchTerminal(NSRunningApplication *app) { | |
| pid_t pid = app.processIdentifier; | |
| NSNumber *pidKey = @(pid); | |
| if (gAXObservers[pidKey]) return; | |
| AXObserverRef observer = NULL; | |
| AXError err = AXObserverCreate(pid, AXCallback, &observer); | |
| if (err != kAXErrorSuccess || !observer) return; | |
| AXUIElementRef appElement = AXUIElementCreateApplication(pid); | |
| AXObserverAddNotification(observer, appElement, kAXValueChangedNotification, NULL); | |
| CFRelease(appElement); | |
| CFRunLoopAddSource(CFRunLoopGetMain(), | |
| AXObserverGetRunLoopSource(observer), | |
| kCFRunLoopCommonModes); | |
| gAXObservers[pidKey] = [NSValue valueWithPointer:observer]; | |
| } | |
| static void StartTerminalWatchers(void) { | |
| gAXObservers = [NSMutableDictionary new]; | |
| gLastSeenDragPBChangeCount = [NSPasteboard pasteboardWithName:NSPasteboardNameDrag].changeCount; | |
| // Watch existing terminals | |
| for (NSRunningApplication *app in NSWorkspace.sharedWorkspace.runningApplications) { | |
| if (app.bundleIdentifier && [gTerminalBundleIDs containsObject:app.bundleIdentifier]) { | |
| WatchTerminal(app); | |
| NSLog(@" π Watching %@ (pid %d)", app.bundleIdentifier, app.processIdentifier); | |
| } | |
| } | |
| // Watch for new terminal launches | |
| [NSWorkspace.sharedWorkspace.notificationCenter | |
| addObserverForName:NSWorkspaceDidLaunchApplicationNotification | |
| object:nil | |
| queue:NSOperationQueue.mainQueue | |
| usingBlock:^(NSNotification *note) { | |
| NSRunningApplication *app = note.userInfo[NSWorkspaceApplicationKey]; | |
| if (app.bundleIdentifier && [gTerminalBundleIDs containsObject:app.bundleIdentifier]) { | |
| WatchTerminal(app); | |
| NSLog(@" π Now watching %@ (pid %d)", app.bundleIdentifier, app.processIdentifier); | |
| } | |
| }]; | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| #pragma mark - Main | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| int main(int argc, const char *argv[]) { | |
| @autoreleasepool { | |
| NSApplication *app = [NSApplication sharedApplication]; | |
| [app setActivationPolicy:NSApplicationActivationPolicyAccessory]; | |
| InitConfig(); | |
| // Check Accessibility permission | |
| NSDictionary *opts = @{(__bridge id)kAXTrustedCheckOptionPrompt: @YES}; | |
| if (!AXIsProcessTrustedWithOptions((__bridge CFDictionaryRef)opts)) { | |
| NSLog(@"β οΈ Grant Accessibility permission in System Settings, then re-run."); | |
| while (!AXIsProcessTrusted()) { | |
| [NSThread sleepForTimeInterval:1.0]; | |
| } | |
| NSLog(@"β Accessibility permission granted!"); | |
| } | |
| NSLog(@"ββββββββββββββββββββββββββββββββββββββββββββ"); | |
| NSLog(@"β Clipboard & Drag Pasteboard Monitor β"); | |
| NSLog(@"β @ajpc500 / delivr.to β"); | |
| NSLog(@"ββββββββββββββββββββββββββββββββββββββββββββ"); | |
| NSLog(@""); | |
| NSLog(@"Monitored terminals:"); | |
| for (NSString *bid in [gTerminalBundleIDs.allObjects sortedArrayUsingSelector:@selector(compare:)]) { | |
| NSLog(@" β’ %@", bid); | |
| } | |
| NSLog(@""); | |
| NSLog(@"Monitored browsers:"); | |
| for (NSString *bid in [gBrowserBundleIDs.allObjects sortedArrayUsingSelector:@selector(compare:)]) { | |
| NSLog(@" β’ %@", bid); | |
| } | |
| NSLog(@""); | |
| NSLog(@"Filtering to events involving the above apps only."); | |
| NSLog(@"Monitoring clipboard copies, pastes (β+V), and drag pasteboard changesβ¦"); | |
| // ββ 1. Poll general pasteboard for copy detection ββ | |
| __block NSInteger lastChangeCount = NSPasteboard.generalPasteboard.changeCount; | |
| [NSTimer scheduledTimerWithTimeInterval:0.3 repeats:YES block:^(NSTimer *t) { | |
| NSInteger current = NSPasteboard.generalPasteboard.changeCount; | |
| if (current != lastChangeCount) { | |
| lastChangeCount = current; | |
| RecordCopySource(); | |
| } | |
| }]; | |
| // ββ 2. Poll drag pasteboard for drag activity ββ | |
| gLastDragChangeCount = [NSPasteboard pasteboardWithName:NSPasteboardNameDrag].changeCount; | |
| [NSTimer scheduledTimerWithTimeInterval:0.3 repeats:YES block:^(NSTimer *t) { | |
| CheckDragPasteboard(); | |
| }]; | |
| // ββ 3. AX watchers on terminals for drop detection ββ | |
| NSLog(@""); | |
| NSLog(@"Terminal AX watchers:"); | |
| StartTerminalWatchers(); | |
| NSLog(@""); | |
| // ββ 4. Global key monitor for β+V (paste detection) ββ | |
| [NSEvent addGlobalMonitorForEventsMatchingMask:NSEventMaskKeyDown | |
| handler:^(NSEvent *event) { | |
| BOOL cmdDown = (event.modifierFlags & NSEventModifierFlagCommand) != 0; | |
| NSString *chars = [event.charactersIgnoringModifiers lowercaseString]; | |
| if (cmdDown && [chars isEqualToString:@"v"]) { | |
| LogPaste(@"β+V"); | |
| } | |
| }]; | |
| [app run]; | |
| } | |
| return 0; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment