// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#import "base/mac/pasteboard_changed_observation.h"
#include <AppKit/AppKit.h>
#include <dispatch/dispatch.h>
#include <objc/runtime.h>
#include <string_view>
#include "base/callback_list.h"
#include "base/functional/callback.h"
#include "base/no_destructor.h"
#include "base/task/bind_post_task.h"
#include "base/task/sequenced_task_runner.h"
// There is no notification API on macOS for changes to the pasteboard (unlike
// on iOS where there is UIPasteboardChangedNotification). However...
//
// Each app has a cache of pasteboard contents, and the pasteboard daemon keeps
// track of those caches and which of them are stale. When a pasteboard copy
// happens in one app, the daemon determines which apps have stale pasteboard
// caches, and sends them an XPC message ("com.apple.pboard.invalidate-cache")
// to clear their caches. (Breakpoint on __CFPasteboardHandleMessageFromDaemon
// to see this in action.)
//
// This invalidation eventually trickles down to the cache class,
// _CFPasteboardCache, whose -setChangeCount: method is called. If the
// pasteboard is set within the app then the cache's change count is set to a
// valid value, but if an invalidation message comes from the daemon, the change
// count is set to -1, and the daemon will not send any further invalidation
// messages for copies outside the app, as the cache is now not dirty.
//
// Therefore, intercept -setChangeCount: messages sent to _CFPasteboardCache.
// After notifying all interested parties, access the NSPasteboard changeCount
// property. This will mark the cache as being dirty again from the daemon's
// perspective, and then the next pasteboard change from outside of the app will
// result in another cache invalidation message. As long as the changeCount
// continues to be accessed in response to the pasteboard daemon's request to
// clean the cache, the cache will continue to be dirty and the app will
// continue to be called back for pasteboard changes outside the app.
//
// Playing this invalidation game is a bit silly, but until an API is provided
// to do this (requested in FB18125171), as the wise Rick Astley says, "we know
// the game, and we're gonna play it."
namespace base {
namespace {
RepeatingClosureList& GetCallbackList() {
static NoDestructor<RepeatingClosureList> callbacks;
return *callbacks;
}
bool SwizzleInternalClass() {
Class pasteboard_cache_class = objc_getClass("_CFPasteboardCache");
if (!pasteboard_cache_class) {
return false;
}
SEL selector = @selector(setChangeCount:);
Method method = class_getInstanceMethod(pasteboard_cache_class, selector);
if (!method) {
return false;
}
std::string_view type_encoding(method_getTypeEncoding(method));
if (type_encoding != "v20@0:8i16") {
return false;
}
using ImpFunctionType = void (*)(id, SEL, int);
static ImpFunctionType g_old_imp;
IMP new_imp =
imp_implementationWithBlock(^(id object_self, int change_count) {
GetCallbackList().Notify();
// Dirty the app's pasteboard cache to ensure an invalidation callback
// for the next pasteboard change that occurs in other apps. Hop to the
// main thread, as the cache is processed on an internal CFPasteboard
// dispatch queue.
dispatch_async(dispatch_get_main_queue(), ^{
std::ignore = NSPasteboard.generalPasteboard.changeCount;
});
g_old_imp(object_self, selector, change_count);
});
g_old_imp = reinterpret_cast<ImpFunctionType>(
method_setImplementation(method, new_imp));
return !!g_old_imp;
}
} // namespace
CallbackListSubscription RegisterPasteboardChangedCallback(
RepeatingClosure callback) {
static bool swizzle_internal_class [[maybe_unused]] = SwizzleInternalClass();
// Intentionally DCHECK so that in the field it doesn't rely on that specific
// internal class (as listening for pasteboard changes isn't critical), but
// that it's noisy and noticeable on the beta bots so that if these internals
// ever change it will be noticed.
DCHECK(swizzle_internal_class);
return GetCallbackList().Add(
base::BindPostTask(SequencedTaskRunner::GetCurrentDefault(), callback));
}
} // namespace base