// Screenshot watch: reacts to the system screenshot shortcuts only, never to
// any image copied to the clipboard. A listen-only event tap sees the
// shortcut (WindowServer swallows it before NSEvent monitors), then we wait
// for the screencapture process it starts to exit and take the clipboard if
// it changed meanwhile and holds nothing but image data: a screenshot puts
// only PNG and TIFF there, a browser's "copy image" adds HTML and a source
// URL. Escape ends screencapture without touching the clipboard.
#import <Cocoa/Cocoa.h>
#import <ApplicationServices/ApplicationServices.h>
#include <libproc.h>
#include <stdatomic.h>
#include <sys/event.h>
#include <unistd.h>
#include "darwin.h"
extern void chr_onScreenshot(void *bytes, int len);
typedef struct {
int64_t keycode;
uint64_t mods; // CGEventFlags shift/control/option/command bits
} CHRHotkey;
#define MOD_MASK 0x1E0000ULL
#define MAX_HOTKEYS 8
static CHRHotkey gHotkeys[MAX_HOTKEYS];
static int gHotkeyCount = 0;
static CFMachPortRef gTap = NULL;
static atomic_bool gWatching = false;
// loadHotkeys reads the screenshot shortcuts from the user's keyboard
// settings, falling back to macOS's defaults for ids the user never touched:
// 28 screen to file, 29 screen to clipboard, 30 area to file, 31 area to
// clipboard, 184 the screenshot toolbar. Shortcuts that save files are kept;
// they leave the clipboard alone, so nothing comes of them.
static void loadHotkeys(void) {
struct { int id; int64_t key; uint64_t mods; } defaults[] = {
{28, 20, 0x120000}, {29, 20, 0x160000}, {30, 21, 0x120000},
{31, 21, 0x160000}, {184, 23, 0x120000},
};
NSDictionary *all = CFBridgingRelease(CFPreferencesCopyAppValue(
CFSTR("AppleSymbolicHotKeys"), CFSTR("com.apple.symbolichotkeys")));
gHotkeyCount = 0;
for (size_t i = 0; i < sizeof defaults / sizeof defaults[0]; i++) {
CHRHotkey hk = {defaults[i].key, defaults[i].mods};
NSDictionary *e = [all isKindOfClass:[NSDictionary class]]
? all[[NSString stringWithFormat:@"%d", defaults[i].id]] : nil;
if ([e isKindOfClass:[NSDictionary class]]) {
if (![e[@"enabled"] boolValue]) continue;
NSArray *p = e[@"value"][@"parameters"];
if ([p isKindOfClass:[NSArray class]] && p.count >= 3) {
hk.keycode = [p[1] longLongValue];
hk.mods = [p[2] unsignedLongLongValue] & MOD_MASK;
}
}
gHotkeys[gHotkeyCount++] = hk;
}
}
static pid_t findScreencapture(void) {
pid_t pids[4096];
int n = proc_listpids(PROC_ALL_PIDS, 0, pids, sizeof pids) / (int)sizeof(pid_t);
for (int i = 0; i < n; i++) {
char name[64] = {0};
if (pids[i] > 0 && proc_name(pids[i], name, sizeof name) > 0 &&
strcmp(name, "screencapture") == 0) {
return pids[i];
}
}
return 0;
}
static bool waitExit(pid_t pid, int seconds) {
int kq = kqueue();
if (kq < 0) return false;
struct kevent ev;
EV_SET(&ev, pid, EVFILT_PROC, EV_ADD | EV_ONESHOT, NOTE_EXIT, 0, NULL);
struct timespec ts = {seconds, 0};
struct kevent out;
int n = kevent(kq, &ev, 1, &out, 1, &ts);
close(kq);
// ESRCH: it already exited between finding and watching it
return n > 0 || (n < 0 && errno == ESRCH);
}
// takeScreenshot returns the clipboard's image as PNG if the clipboard
// changed since before and holds image data only. Main thread only.
static NSData *takeScreenshot(NSInteger before) {
NSPasteboard *pb = [NSPasteboard generalPasteboard];
if (pb.changeCount == before) return nil;
NSSet *imageOnly = [NSSet setWithObjects:@"public.png", @"public.tiff",
@"Apple PNG pasteboard type", @"NeXT TIFF v4.0 pasteboard type", nil];
for (NSString *t in pb.types) {
if (![imageOnly containsObject:t]) return nil;
}
NSData *png = [pb dataForType:@"public.png"];
if (png) return png;
NSData *tiff = [pb dataForType:@"public.tiff"];
if (!tiff) return nil;
NSBitmapImageRep *rep = [NSBitmapImageRep imageRepWithData:tiff];
return [rep representationUsingType:NSBitmapImageFileTypePNG properties:@{}];
}
static void watchScreenshot(NSInteger before) {
if (atomic_exchange(&gWatching, true)) return; // one shortcut at a time
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
pid_t pid = 0;
for (int i = 0; i < 50 && !pid; i++) { // it starts within a few ms
pid = findScreencapture();
if (!pid) usleep(20000);
}
if (pid && waitExit(pid, 300)) {
__block NSData *png = nil;
dispatch_sync(dispatch_get_main_queue(), ^{
@autoreleasepool { png = takeScreenshot(before); }
});
if (png.length > 0) {
chr_onScreenshot((void *)png.bytes, (int)png.length);
}
}
atomic_store(&gWatching, false);
});
}
static CGEventRef tapCallback(CGEventTapProxy proxy, CGEventType type,
CGEventRef event, void *refcon) {
if (type == kCGEventTapDisabledByTimeout || type == kCGEventTapDisabledByUserInput) {
if (gTap) CGEventTapEnable(gTap, true);
return event;
}
if (type != kCGEventKeyDown) return event;
int64_t key = CGEventGetIntegerValueField(event, kCGKeyboardEventKeycode);
uint64_t mods = CGEventGetFlags(event) & MOD_MASK;
for (int i = 0; i < gHotkeyCount; i++) {
if (gHotkeys[i].keycode == key && gHotkeys[i].mods == mods) {
watchScreenshot([NSPasteboard generalPasteboard].changeCount);
break;
}
}
return event;
}
bool chr_startScreenshotWatch(void) {
__block bool ok = false;
void (^install)(void) = ^{
if (gTap) { ok = true; return; }
loadHotkeys();
gTap = CGEventTapCreate(kCGSessionEventTap, kCGHeadInsertEventTap,
kCGEventTapOptionListenOnly,
CGEventMaskBit(kCGEventKeyDown), tapCallback, NULL);
if (!gTap) {
CGRequestListenEventAccess(); // shows the Input Monitoring prompt
return;
}
CFRunLoopSourceRef src = CFMachPortCreateRunLoopSource(NULL, gTap, 0);
CFRunLoopAddSource(CFRunLoopGetMain(), src, kCFRunLoopCommonModes);
CFRelease(src);
CGEventTapEnable(gTap, true);
ok = true;
};
if ([NSThread isMainThread]) install();
else dispatch_sync(dispatch_get_main_queue(), install);
return ok;
}