Files
rustdesk/src/platform/macos.mm

912 lines
39 KiB
Plaintext

#import <AVFoundation/AVFoundation.h>
#import <AppKit/AppKit.h>
#import <IOKit/hidsystem/IOHIDLib.h>
#include <Security/Authorization.h>
#include <Security/AuthorizationTags.h>
#include <CoreGraphics/CoreGraphics.h>
#include <vector>
#include <map>
#include <set>
#include <mutex>
#include <string>
extern "C" bool CanUseNewApiForScreenCaptureCheck() {
#ifdef NO_InputMonitoringAuthStatus
return false;
#else
NSOperatingSystemVersion version = [[NSProcessInfo processInfo] operatingSystemVersion];
return version.majorVersion >= 11;
#endif
}
extern "C" uint32_t majorVersion() {
NSOperatingSystemVersion version = [[NSProcessInfo processInfo] operatingSystemVersion];
return version.majorVersion;
}
extern "C" bool IsCanScreenRecording(bool prompt) {
#ifdef NO_InputMonitoringAuthStatus
return false;
#else
bool res = CGPreflightScreenCaptureAccess();
if (!res && prompt) {
CGRequestScreenCaptureAccess();
}
return res;
#endif
}
// https://github.com/codebytere/node-mac-permissions/blob/main/permissions.mm
extern "C" bool InputMonitoringAuthStatus(bool prompt) {
#ifdef NO_InputMonitoringAuthStatus
return true;
#else
if (floor(NSAppKitVersionNumber) >= NSAppKitVersionNumber10_15) {
IOHIDAccessType theType = IOHIDCheckAccess(kIOHIDRequestTypeListenEvent);
NSLog(@"IOHIDCheckAccess = %d, kIOHIDAccessTypeGranted = %d", theType, kIOHIDAccessTypeGranted);
switch (theType) {
case kIOHIDAccessTypeGranted:
return true;
break;
case kIOHIDAccessTypeDenied: {
if (prompt) {
NSString *urlString = @"x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent";
[[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:urlString]];
}
break;
}
case kIOHIDAccessTypeUnknown: {
if (prompt) {
bool result = IOHIDRequestAccess(kIOHIDRequestTypeListenEvent);
NSLog(@"IOHIDRequestAccess result = %d", result);
}
break;
}
default:
break;
}
} else {
return true;
}
return false;
#endif
}
extern "C" bool Elevate(char* process, char** args) {
AuthorizationRef authRef;
OSStatus status;
status = AuthorizationCreate(NULL, kAuthorizationEmptyEnvironment,
kAuthorizationFlagDefaults, &authRef);
if (status != errAuthorizationSuccess) {
printf("Failed to create AuthorizationRef\n");
return false;
}
AuthorizationItem authItem = {kAuthorizationRightExecute, 0, NULL, 0};
AuthorizationRights authRights = {1, &authItem};
AuthorizationFlags flags = kAuthorizationFlagDefaults |
kAuthorizationFlagInteractionAllowed |
kAuthorizationFlagPreAuthorize |
kAuthorizationFlagExtendRights;
status = AuthorizationCopyRights(authRef, &authRights, kAuthorizationEmptyEnvironment, flags, NULL);
if (status != errAuthorizationSuccess) {
printf("Failed to authorize\n");
return false;
}
if (process != NULL) {
FILE *pipe = NULL;
status = AuthorizationExecuteWithPrivileges(authRef, process, kAuthorizationFlagDefaults, args, &pipe);
if (status != errAuthorizationSuccess) {
printf("Failed to run as root\n");
AuthorizationFree(authRef, kAuthorizationFlagDefaults);
return false;
}
}
AuthorizationFree(authRef, kAuthorizationFlagDefaults);
return true;
}
extern "C" bool MacCheckAdminAuthorization() {
return Elevate(NULL, NULL);
}
// https://gist.github.com/briankc/025415e25900750f402235dbf1b74e42
extern "C" float BackingScaleFactor(uint32_t display) {
@autoreleasepool {
NSArray<NSScreen *> *screens = [NSScreen screens];
for (NSScreen *screen in screens) {
NSDictionary *deviceDescription = [screen deviceDescription];
NSNumber *screenNumber = [deviceDescription objectForKey:@"NSScreenNumber"];
CGDirectDisplayID screenDisplayID = [screenNumber unsignedIntValue];
if (screenDisplayID == display) {
return [screen backingScaleFactor];
}
}
return 1;
}
}
// https://github.com/jhford/screenresolution/blob/master/cg_utils.c
// https://github.com/jdoupe/screenres/blob/master/setgetscreen.m
size_t bitDepth(CGDisplayModeRef mode) {
size_t depth = 0;
// Deprecated, same display same bpp?
// https://stackoverflow.com/questions/8210824/how-to-avoid-cgdisplaymodecopypixelencoding-to-get-bpp
// https://github.com/libsdl-org/SDL/pull/6628
CFStringRef pixelEncoding = CGDisplayModeCopyPixelEncoding(mode);
// my numerical representation for kIO16BitFloatPixels and kIO32bitFloatPixels
// are made up and possibly non-sensical
if (kCFCompareEqualTo == CFStringCompare(pixelEncoding, CFSTR(kIO32BitFloatPixels), kCFCompareCaseInsensitive)) {
depth = 96;
} else if (kCFCompareEqualTo == CFStringCompare(pixelEncoding, CFSTR(kIO64BitDirectPixels), kCFCompareCaseInsensitive)) {
depth = 64;
} else if (kCFCompareEqualTo == CFStringCompare(pixelEncoding, CFSTR(kIO16BitFloatPixels), kCFCompareCaseInsensitive)) {
depth = 48;
} else if (kCFCompareEqualTo == CFStringCompare(pixelEncoding, CFSTR(IO32BitDirectPixels), kCFCompareCaseInsensitive)) {
depth = 32;
} else if (kCFCompareEqualTo == CFStringCompare(pixelEncoding, CFSTR(kIO30BitDirectPixels), kCFCompareCaseInsensitive)) {
depth = 30;
} else if (kCFCompareEqualTo == CFStringCompare(pixelEncoding, CFSTR(IO16BitDirectPixels), kCFCompareCaseInsensitive)) {
depth = 16;
} else if (kCFCompareEqualTo == CFStringCompare(pixelEncoding, CFSTR(IO8BitIndexedPixels), kCFCompareCaseInsensitive)) {
depth = 8;
}
CFRelease(pixelEncoding);
return depth;
}
static bool isHiDPIMode(CGDisplayModeRef mode) {
// Check if the mode is HiDPI by comparing pixel width to width
// If pixel width is greater than width, it's a HiDPI mode
return CGDisplayModeGetPixelWidth(mode) > CGDisplayModeGetWidth(mode);
}
CFArrayRef getAllModes(CGDirectDisplayID display) {
// Create options dictionary to include HiDPI modes
CFMutableDictionaryRef options = CFDictionaryCreateMutable(
kCFAllocatorDefault,
0,
&kCFTypeDictionaryKeyCallBacks,
&kCFTypeDictionaryValueCallBacks);
// Include HiDPI modes
CFDictionarySetValue(options, kCGDisplayShowDuplicateLowResolutionModes, kCFBooleanTrue);
CFArrayRef allModes = CGDisplayCopyAllDisplayModes(display, options);
CFRelease(options);
return allModes;
}
extern "C" bool MacGetModeNum(CGDirectDisplayID display, uint32_t *numModes) {
CFArrayRef allModes = getAllModes(display);
if (allModes == NULL) {
return false;
}
*numModes = CFArrayGetCount(allModes);
CFRelease(allModes);
return true;
}
extern "C" bool MacGetModes(CGDirectDisplayID display, uint32_t *widths, uint32_t *heights, bool *hidpis, uint32_t max, uint32_t *numModes) {
CGDisplayModeRef currentMode = CGDisplayCopyDisplayMode(display);
if (currentMode == NULL) {
return false;
}
CFArrayRef allModes = getAllModes(display);
if (allModes == NULL) {
CGDisplayModeRelease(currentMode);
return false;
}
uint32_t allModeCount = CFArrayGetCount(allModes);
uint32_t realNum = 0;
for (uint32_t i = 0; i < allModeCount && realNum < max; i++) {
CGDisplayModeRef mode = (CGDisplayModeRef)CFArrayGetValueAtIndex(allModes, i);
if (CGDisplayModeGetRefreshRate(currentMode) == CGDisplayModeGetRefreshRate(mode) &&
bitDepth(currentMode) == bitDepth(mode)) {
widths[realNum] = (uint32_t)CGDisplayModeGetWidth(mode);
heights[realNum] = (uint32_t)CGDisplayModeGetHeight(mode);
hidpis[realNum] = isHiDPIMode(mode);
realNum++;
}
}
*numModes = realNum;
CGDisplayModeRelease(currentMode);
CFRelease(allModes);
return true;
}
extern "C" bool MacGetMode(CGDirectDisplayID display, uint32_t *width, uint32_t *height) {
CGDisplayModeRef mode = CGDisplayCopyDisplayMode(display);
if (mode == NULL) {
return false;
}
*width = (uint32_t)CGDisplayModeGetWidth(mode);
*height = (uint32_t)CGDisplayModeGetHeight(mode);
CGDisplayModeRelease(mode);
return true;
}
static bool setDisplayToMode(CGDirectDisplayID display, CGDisplayModeRef mode) {
CGError rc;
CGDisplayConfigRef config;
rc = CGBeginDisplayConfiguration(&config);
if (rc != kCGErrorSuccess) {
return false;
}
rc = CGConfigureDisplayWithDisplayMode(config, display, mode, NULL);
if (rc != kCGErrorSuccess) {
return false;
}
rc = CGCompleteDisplayConfiguration(config, kCGConfigureForSession);
if (rc != kCGErrorSuccess) {
return false;
}
return true;
}
// Set the display to a specific mode based on width and height.
// Returns true if the display mode was successfully changed, false otherwise.
// If no such mode is available, it will not change the display mode.
//
// If `tryHiDPI` is true, it will try to set the display to a HiDPI mode if available.
// If no HiDPI mode is available, it will fall back to a non-HiDPI mode with the same resolution.
// If `tryHiDPI` is false, it sets the display to the first mode with the same resolution, no matter if it's HiDPI or not.
extern "C" bool MacSetMode(CGDirectDisplayID display, uint32_t width, uint32_t height, bool tryHiDPI)
{
bool ret = false;
CGDisplayModeRef currentMode = CGDisplayCopyDisplayMode(display);
if (currentMode == NULL) {
return ret;
}
CFArrayRef allModes = getAllModes(display);
if (allModes == NULL) {
CGDisplayModeRelease(currentMode);
return ret;
}
int numModes = CFArrayGetCount(allModes);
CGDisplayModeRef preferredHiDPIMode = NULL;
CGDisplayModeRef fallbackMode = NULL;
for (int i = 0; i < numModes; i++) {
CGDisplayModeRef mode = (CGDisplayModeRef)CFArrayGetValueAtIndex(allModes, i);
if (width == CGDisplayModeGetWidth(mode) &&
height == CGDisplayModeGetHeight(mode) &&
CGDisplayModeGetRefreshRate(currentMode) == CGDisplayModeGetRefreshRate(mode) &&
bitDepth(currentMode) == bitDepth(mode)) {
if (isHiDPIMode(mode)) {
preferredHiDPIMode = mode;
break;
} else {
fallbackMode = mode;
if (!tryHiDPI) {
break;
}
}
}
}
if (preferredHiDPIMode) {
ret = setDisplayToMode(display, preferredHiDPIMode);
} else if (fallbackMode) {
ret = setDisplayToMode(display, fallbackMode);
}
CGDisplayModeRelease(currentMode);
CFRelease(allModes);
return ret;
}
static CFMachPortRef g_eventTap = NULL;
static CFRunLoopSourceRef g_runLoopSource = NULL;
static std::mutex g_privacyModeMutex;
static bool g_privacyModeActive = false;
// Flag to request asynchronous shutdown of privacy mode.
// This is set by DisplayReconfigurationCallback when an error occurs, instead of calling
// TurnOffPrivacyModeInternal() directly from within the callback. This avoids potential
// issues with unregistering a callback from within itself, which is not explicitly
// guaranteed to be safe by Apple documentation.
static bool g_privacyModeShutdownRequested = false;
// Timestamp of the last display reconfiguration event (in milliseconds).
// Used for debouncing rapid successive changes (e.g., multiple resolution changes).
static uint64_t g_lastReconfigTimestamp = 0;
// Flag indicating whether a delayed blackout reapplication is already scheduled.
// Prevents multiple concurrent delayed tasks from being created.
static bool g_blackoutReapplicationScheduled = false;
// Use CFStringRef (UUID) as key instead of CGDirectDisplayID for stability across reconnections
// CGDirectDisplayID can change when displays are reconnected, but UUID remains stable
static std::map<std::string, std::vector<CGGammaValue>> g_originalGammas;
// The event source user data value used by enigo library for injected events.
// This allows us to distinguish remote input (which should be allowed) from local physical input.
// See: libs/enigo/src/macos/macos_impl.rs - ENIGO_INPUT_EXTRA_VALUE
static const int64_t ENIGO_INPUT_EXTRA_VALUE = 100;
// Duration in milliseconds to monitor and enforce blackout after display reconfiguration.
// macOS may restore default gamma (via ColorSync) at unpredictable times after display changes,
// so we need to actively monitor and reapply blackout during this period.
static const int64_t DISPLAY_RECONFIG_MONITOR_DURATION_MS = 5000;
// Interval in milliseconds between gamma checks during the monitoring period.
static const int64_t GAMMA_CHECK_INTERVAL_MS = 200;
// Helper function to get UUID string from DisplayID
static std::string GetDisplayUUID(CGDirectDisplayID displayId) {
CFUUIDRef uuid = CGDisplayCreateUUIDFromDisplayID(displayId);
if (uuid == NULL) {
return "";
}
CFStringRef uuidStr = CFUUIDCreateString(kCFAllocatorDefault, uuid);
CFRelease(uuid);
if (uuidStr == NULL) {
return "";
}
char buffer[128];
if (CFStringGetCString(uuidStr, buffer, sizeof(buffer), kCFStringEncodingUTF8)) {
CFRelease(uuidStr);
return std::string(buffer);
}
CFRelease(uuidStr);
return "";
}
// Helper function to find DisplayID by UUID from current online displays
static CGDirectDisplayID FindDisplayIdByUUID(const std::string& targetUuid) {
uint32_t count = 0;
CGGetOnlineDisplayList(0, NULL, &count);
if (count == 0) return kCGNullDirectDisplay;
std::vector<CGDirectDisplayID> displays(count);
CGGetOnlineDisplayList(count, displays.data(), &count);
for (uint32_t i = 0; i < count; i++) {
std::string uuid = GetDisplayUUID(displays[i]);
if (uuid == targetUuid) {
return displays[i];
}
}
return kCGNullDirectDisplay;
}
// Helper function to restore gamma values for all displays in g_originalGammas.
// Returns true if all displays were restored successfully, false if any failed.
// Note: This function does NOT clear g_originalGammas - caller should do that if needed.
static bool RestoreAllGammas() {
bool allSuccess = true;
for (auto const& [uuid, gamma] : g_originalGammas) {
CGDirectDisplayID d = FindDisplayIdByUUID(uuid);
if (d == kCGNullDirectDisplay) {
NSLog(@"Display with UUID %s no longer online, skipping gamma restore", uuid.c_str());
continue;
}
uint32_t sampleCount = gamma.size() / 3;
if (sampleCount > 0) {
const CGGammaValue* red = gamma.data();
const CGGammaValue* green = red + sampleCount;
const CGGammaValue* blue = green + sampleCount;
CGError error = CGSetDisplayTransferByTable(d, sampleCount, red, green, blue);
if (error != kCGErrorSuccess) {
NSLog(@"Failed to restore gamma for display (ID: %u, UUID: %s, error: %d)", (unsigned)d, uuid.c_str(), error);
allSuccess = false;
}
}
}
return allSuccess;
}
// Helper function to apply blackout to a single display
static bool ApplyBlackoutToDisplay(CGDirectDisplayID display) {
uint32_t capacity = CGDisplayGammaTableCapacity(display);
if (capacity > 0) {
std::vector<CGGammaValue> zeros(capacity, 0.0f);
CGError error = CGSetDisplayTransferByTable(display, capacity, zeros.data(), zeros.data(), zeros.data());
if (error != kCGErrorSuccess) {
NSLog(@"ApplyBlackoutToDisplay: Failed to set gamma for display %u (error %d)", (unsigned)display, error);
return false;
}
return true;
}
NSLog(@"ApplyBlackoutToDisplay: Display %u has zero gamma table capacity, blackout not supported", (unsigned)display);
return false;
}
// Forward declaration - defined later in the file
// Must be called while holding g_privacyModeMutex
static bool TurnOffPrivacyModeInternal();
// Helper function to schedule asynchronous shutdown of privacy mode.
// This is called from DisplayReconfigurationCallback when an error occurs,
// instead of calling TurnOffPrivacyModeInternal() directly. This avoids
// potential issues with unregistering a callback from within itself.
// Note: This function should be called while holding g_privacyModeMutex.
static void ScheduleAsyncPrivacyModeShutdown(const char* reason) {
if (g_privacyModeShutdownRequested) {
// Already requested, no need to schedule again
return;
}
g_privacyModeShutdownRequested = true;
NSLog(@"Privacy mode shutdown requested: %s", reason);
// Schedule the actual shutdown on the main queue asynchronously
// This ensures we're outside the callback when we unregister it
dispatch_async(dispatch_get_main_queue(), ^{
std::lock_guard<std::mutex> lock(g_privacyModeMutex);
if (g_privacyModeShutdownRequested && g_privacyModeActive) {
NSLog(@"Executing deferred privacy mode shutdown");
TurnOffPrivacyModeInternal();
}
g_privacyModeShutdownRequested = false;
});
}
// Helper function to apply blackout to all online displays.
// Must be called while holding g_privacyModeMutex.
static void ApplyBlackoutToAllDisplays() {
uint32_t onlineCount = 0;
CGGetOnlineDisplayList(0, NULL, &onlineCount);
std::vector<CGDirectDisplayID> onlineDisplays(onlineCount);
CGGetOnlineDisplayList(onlineCount, onlineDisplays.data(), &onlineCount);
for (uint32_t i = 0; i < onlineCount; i++) {
ApplyBlackoutToDisplay(onlineDisplays[i]);
}
}
// Helper function to get current timestamp in milliseconds
static uint64_t GetCurrentTimestampMs() {
return (uint64_t)(CFAbsoluteTimeGetCurrent() * 1000.0);
}
// Helper function to check if a display's gamma is currently blacked out (all zeros).
// Returns true if gamma appears to be blacked out, false otherwise.
static bool IsDisplayBlackedOut(CGDirectDisplayID display) {
uint32_t capacity = CGDisplayGammaTableCapacity(display);
if (capacity == 0) {
return true; // Can't check, assume it's fine
}
std::vector<CGGammaValue> red(capacity), green(capacity), blue(capacity);
uint32_t sampleCount = 0;
if (CGGetDisplayTransferByTable(display, capacity, red.data(), green.data(), blue.data(), &sampleCount) != kCGErrorSuccess) {
return true; // Can't read, assume it's fine
}
// Check if all values are zero (or very close to zero)
for (uint32_t i = 0; i < sampleCount; i++) {
if (red[i] > 0.01f || green[i] > 0.01f || blue[i] > 0.01f) {
return false; // Not blacked out
}
}
return true;
}
// Internal function that monitors and enforces blackout for a period after display reconfiguration.
// This function checks gamma values periodically and reapplies blackout if needed.
// Must NOT be called while holding g_privacyModeMutex (it acquires the lock internally).
static void RunBlackoutMonitor() {
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(GAMMA_CHECK_INTERVAL_MS * NSEC_PER_MSEC)), dispatch_get_main_queue(), ^{
std::lock_guard<std::mutex> lock(g_privacyModeMutex);
if (!g_privacyModeActive) {
g_blackoutReapplicationScheduled = false;
return;
}
uint64_t now = GetCurrentTimestampMs();
// Calculate effective end time based on the last reconfig event
uint64_t effectiveEndTime = g_lastReconfigTimestamp + DISPLAY_RECONFIG_MONITOR_DURATION_MS;
// Check all displays and reapply blackout if any has been restored
uint32_t onlineCount = 0;
CGGetOnlineDisplayList(0, NULL, &onlineCount);
std::vector<CGDirectDisplayID> onlineDisplays(onlineCount);
CGGetOnlineDisplayList(onlineCount, onlineDisplays.data(), &onlineCount);
bool needsReapply = false;
for (uint32_t i = 0; i < onlineCount; i++) {
if (!IsDisplayBlackedOut(onlineDisplays[i])) {
needsReapply = true;
break;
}
}
if (needsReapply) {
NSLog(@"Gamma was restored by system, reapplying blackout");
ApplyBlackoutToAllDisplays();
}
// Continue monitoring if we haven't reached the end time
if (now < effectiveEndTime) {
RunBlackoutMonitor();
} else {
NSLog(@"Blackout monitoring period ended");
g_blackoutReapplicationScheduled = false;
}
});
}
// Helper function to start monitoring and enforcing blackout after display reconfiguration.
// This is used after display reconfiguration events because macOS may restore
// default gamma (via ColorSync) at unpredictable times after display changes.
// Note: This function should be called while holding g_privacyModeMutex.
static void ScheduleDelayedBlackoutReapplication(const char* reason) {
// Update timestamp to current time
g_lastReconfigTimestamp = GetCurrentTimestampMs();
NSLog(@"Starting blackout monitor: %s", reason);
// Only schedule if not already scheduled
if (!g_blackoutReapplicationScheduled) {
g_blackoutReapplicationScheduled = true;
RunBlackoutMonitor();
}
// If already scheduled, the running monitor will see the updated timestamp
// and extend its monitoring period
}
// Display reconfiguration callback to handle display connect/disconnect events
//
// IMPORTANT: When errors occur in this callback, we use ScheduleAsyncPrivacyModeShutdown()
// instead of calling TurnOffPrivacyModeInternal() directly. This is because:
// 1. TurnOffPrivacyModeInternal() calls CGDisplayRemoveReconfigurationCallback to unregister
// this callback, and unregistering a callback from within itself is not explicitly
// guaranteed to be safe by Apple documentation.
// 2. Using async dispatch ensures we're completely outside the callback context when
// performing the cleanup, avoiding any potential undefined behavior.
static void DisplayReconfigurationCallback(CGDirectDisplayID display, CGDisplayChangeSummaryFlags flags, void *userInfo) {
(void)userInfo;
// Note: We need to handle the callback carefully because:
// 1. macOS may call this callback multiple times during display reconfiguration
// 2. The system may restore ColorSync settings after our gamma change
// 3. We should not hold the lock for too long in the callback
// Skip begin configuration flag - wait for the actual change
if (flags & kCGDisplayBeginConfigurationFlag) {
return;
}
std::lock_guard<std::mutex> lock(g_privacyModeMutex);
if (!g_privacyModeActive) {
return;
}
if (flags & kCGDisplayAddFlag) {
// A display was added - apply blackout to it
NSLog(@"Display %u added during privacy mode, applying blackout", (unsigned)display);
std::string uuid = GetDisplayUUID(display);
if (uuid.empty()) {
NSLog(@"Failed to get UUID for newly added display %u, exiting privacy mode", (unsigned)display);
ScheduleAsyncPrivacyModeShutdown("Failed to get UUID for newly added display");
return;
}
// Save original gamma if not already saved for this UUID
if (g_originalGammas.find(uuid) == g_originalGammas.end()) {
uint32_t capacity = CGDisplayGammaTableCapacity(display);
if (capacity > 0) {
std::vector<CGGammaValue> red(capacity), green(capacity), blue(capacity);
uint32_t sampleCount = 0;
if (CGGetDisplayTransferByTable(display, capacity, red.data(), green.data(), blue.data(), &sampleCount) == kCGErrorSuccess) {
std::vector<CGGammaValue> all;
all.insert(all.end(), red.begin(), red.begin() + sampleCount);
all.insert(all.end(), green.begin(), green.begin() + sampleCount);
all.insert(all.end(), blue.begin(), blue.begin() + sampleCount);
g_originalGammas[uuid] = all;
} else {
NSLog(@"DisplayReconfigurationCallback: Failed to get gamma table for display %u (UUID: %s), exiting privacy mode", (unsigned)display, uuid.c_str());
ScheduleAsyncPrivacyModeShutdown("Failed to get gamma table for newly added display");
return;
}
} else {
NSLog(@"DisplayReconfigurationCallback: Display %u (UUID: %s) has zero gamma table capacity, exiting privacy mode", (unsigned)display, uuid.c_str());
ScheduleAsyncPrivacyModeShutdown("Newly added display has zero gamma table capacity");
return;
}
}
// Apply blackout to the new display immediately
if (!ApplyBlackoutToDisplay(display)) {
NSLog(@"DisplayReconfigurationCallback: Failed to blackout display %u (UUID: %s), exiting privacy mode", (unsigned)display, uuid.c_str());
ScheduleAsyncPrivacyModeShutdown("Failed to blackout newly added display");
return;
}
// Schedule a delayed re-application to handle ColorSync restoration
// macOS may restore default gamma for ALL displays after a new display is added,
// so we need to reapply blackout to all online displays, not just the new one
ScheduleDelayedBlackoutReapplication("after new display added");
} else if (flags & kCGDisplayRemoveFlag) {
// A display was removed - update our mapping and reapply blackout to remaining displays
NSLog(@"Display %u removed during privacy mode", (unsigned)display);
std::string uuid = GetDisplayUUID(display);
(void)uuid; // UUID retrieved for potential future use or logging
// When a display is removed, macOS may reconfigure other displays and restore their gamma.
// Schedule a delayed re-application of blackout to all remaining online displays.
ScheduleDelayedBlackoutReapplication("after display removal");
} else if (flags & kCGDisplaySetModeFlag) {
// Display mode changed (resolution change, ColorSync/Night Shift interference, etc.)
// macOS resets gamma to default when display mode changes, so we need to reapply blackout.
// Schedule a delayed re-application because ColorSync restoration happens asynchronously.
NSLog(@"Display %u mode changed during privacy mode, reapplying blackout", (unsigned)display);
ScheduleDelayedBlackoutReapplication("after display mode change");
}
}
CGEventRef MyEventTapCallback(CGEventTapProxy proxy, CGEventType type, CGEventRef event, void *refcon) {
(void)proxy;
(void)refcon;
// Handle EventTap being disabled by system timeout
if (type == kCGEventTapDisabledByTimeout) {
NSLog(@"EventTap was disabled by timeout, re-enabling");
if (g_eventTap) {
CGEventTapEnable(g_eventTap, true);
}
return event;
}
// Handle EventTap being disabled by user input
if (type == kCGEventTapDisabledByUserInput) {
NSLog(@"EventTap was disabled by user input, re-enabling");
if (g_eventTap) {
CGEventTapEnable(g_eventTap, true);
}
return event;
}
// Allow events explicitly injected by enigo (remote input), identified via custom user data.
int64_t userData = CGEventGetIntegerValueField(event, kCGEventSourceUserData);
if (userData == ENIGO_INPUT_EXTRA_VALUE) {
return event;
}
// Block local physical HID input.
if (CGEventGetIntegerValueField(event, kCGEventSourceStateID) == kCGEventSourceStateHIDSystemState) {
return NULL;
}
return event;
}
// Helper function to set up EventTap on the main thread
// Returns true if EventTap was successfully created and enabled
static bool SetupEventTapOnMainThread() {
__block bool success = false;
void (^setupBlock)(void) = ^{
if (g_eventTap) {
// Already set up
success = true;
return;
}
// Note: kCGEventTapDisabledByTimeout and kCGEventTapDisabledByUserInput are special
// notification types (0xFFFFFFFE and 0xFFFFFFFF) that are delivered via the callback's
// type parameter, not through the event mask. They should NOT be included in eventMask
// as bit-shifting by these values causes undefined behavior.
CGEventMask eventMask = (1 << kCGEventKeyDown) | (1 << kCGEventKeyUp) |
(1 << kCGEventLeftMouseDown) | (1 << kCGEventLeftMouseUp) |
(1 << kCGEventRightMouseDown) | (1 << kCGEventRightMouseUp) |
(1 << kCGEventOtherMouseDown) | (1 << kCGEventOtherMouseUp) |
(1 << kCGEventLeftMouseDragged) | (1 << kCGEventRightMouseDragged) |
(1 << kCGEventOtherMouseDragged) |
(1 << kCGEventMouseMoved) | (1 << kCGEventScrollWheel);
g_eventTap = CGEventTapCreate(kCGHIDEventTap, kCGHeadInsertEventTap, kCGEventTapOptionDefault,
eventMask, MyEventTapCallback, NULL);
if (g_eventTap) {
g_runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, g_eventTap, 0);
CFRunLoopAddSource(CFRunLoopGetMain(), g_runLoopSource, kCFRunLoopCommonModes);
CGEventTapEnable(g_eventTap, true);
success = true;
} else {
NSLog(@"MacSetPrivacyMode: Failed to create CGEventTap; input blocking not enabled.");
success = false;
}
};
// Execute on main thread to ensure CFRunLoop operations are safe.
// Use dispatch_sync if not on main thread, otherwise execute directly to avoid deadlock.
//
// IMPORTANT: Potential deadlock consideration:
// Using dispatch_sync while holding g_privacyModeMutex could deadlock if the main thread
// tries to acquire g_privacyModeMutex. Currently this is safe because:
// 1. MacSetPrivacyMode (which holds the mutex) is only called from background threads
// 2. The main thread never directly calls MacSetPrivacyMode
// If this assumption changes in the future, consider releasing the mutex before dispatch_sync
// or restructuring the locking strategy.
if ([NSThread isMainThread]) {
setupBlock();
} else {
dispatch_sync(dispatch_get_main_queue(), setupBlock);
}
return success;
}
// Helper function to tear down EventTap on the main thread
static void TeardownEventTapOnMainThread() {
void (^teardownBlock)(void) = ^{
if (g_eventTap) {
CGEventTapEnable(g_eventTap, false);
CFRunLoopRemoveSource(CFRunLoopGetMain(), g_runLoopSource, kCFRunLoopCommonModes);
CFRelease(g_runLoopSource);
CFRelease(g_eventTap);
g_eventTap = NULL;
g_runLoopSource = NULL;
}
};
// Execute on main thread to ensure CFRunLoop operations are safe.
//
// NOTE: We use dispatch_sync here instead of dispatch_async because:
// 1. TurnOffPrivacyModeInternal() expects EventTap to be fully torn down before
// proceeding with gamma restoration - using async would cause race conditions.
// 2. The caller (MacSetPrivacyMode) needs deterministic cleanup order.
//
// IMPORTANT: Potential deadlock consideration (same as SetupEventTapOnMainThread):
// Using dispatch_sync while holding g_privacyModeMutex could deadlock if the main thread
// tries to acquire g_privacyModeMutex. Currently this is safe because:
// 1. MacSetPrivacyMode (which holds the mutex) is only called from background threads
// 2. The main thread never directly calls MacSetPrivacyMode
// If this assumption changes in the future, consider releasing the mutex before dispatch_sync
// or restructuring the locking strategy.
if ([NSThread isMainThread]) {
teardownBlock();
} else {
dispatch_sync(dispatch_get_main_queue(), teardownBlock);
}
}
// Internal function to turn off privacy mode without acquiring the mutex
// Must be called while holding g_privacyModeMutex
static bool TurnOffPrivacyModeInternal() {
if (!g_privacyModeActive) {
return true;
}
// 1. Unregister display reconfiguration callback
CGDisplayRemoveReconfigurationCallback(DisplayReconfigurationCallback, NULL);
// 2. Input - restore (tear down EventTap on main thread)
TeardownEventTapOnMainThread();
// 3. Gamma - restore using UUID to find current DisplayID
bool restoreSuccess = RestoreAllGammas();
// 4. Fallback: Always call CGDisplayRestoreColorSyncSettings as a safety net
// This ensures displays return to normal even if our restoration failed or
// if the system (ColorSync/Night Shift) modified gamma during privacy mode
CGDisplayRestoreColorSyncSettings();
// Clean up
g_originalGammas.clear();
g_privacyModeActive = false;
g_privacyModeShutdownRequested = false;
g_lastReconfigTimestamp = 0;
g_blackoutReapplicationScheduled = false;
return restoreSuccess;
}
extern "C" bool MacSetPrivacyMode(bool on) {
std::lock_guard<std::mutex> lock(g_privacyModeMutex);
if (on) {
// Already in privacy mode
if (g_privacyModeActive) {
return true;
}
// 1. Input Blocking - set up EventTap on main thread
if (!SetupEventTapOnMainThread()) {
return false;
}
// 2. Register display reconfiguration callback to handle hot-plug events
CGDisplayRegisterReconfigurationCallback(DisplayReconfigurationCallback, NULL);
// 3. Gamma Blackout
uint32_t count = 0;
CGGetOnlineDisplayList(0, NULL, &count);
std::vector<CGDirectDisplayID> displays(count);
CGGetOnlineDisplayList(count, displays.data(), &count);
uint32_t blackoutSuccessCount = 0;
uint32_t blackoutAttemptCount = 0;
for (uint32_t i = 0; i < count; i++) {
CGDirectDisplayID d = displays[i];
std::string uuid = GetDisplayUUID(d);
if (uuid.empty()) {
NSLog(@"MacSetPrivacyMode: Failed to get UUID for display %u, privacy mode requires all displays", (unsigned)d);
// Privacy mode requires ALL connected displays to be successfully blacked out
// to ensure user privacy. If we can't identify a display (no UUID),
// we can't safely manage its state or restore it later.
// Therefore, we must abort the entire operation and clean up any resources
// already allocated (like event taps and reconfiguration callbacks).
CGDisplayRemoveReconfigurationCallback(DisplayReconfigurationCallback, NULL);
TeardownEventTapOnMainThread();
// Restore gamma for displays that were already blacked out before this failure
if (!RestoreAllGammas()) {
// If any display failed to restore, use system reset as fallback
CGDisplayRestoreColorSyncSettings();
}
g_originalGammas.clear();
return false;
}
// Save original gamma using UUID as key (stable across reconnections)
if (g_originalGammas.find(uuid) == g_originalGammas.end()) {
uint32_t capacity = CGDisplayGammaTableCapacity(d);
if (capacity > 0) {
std::vector<CGGammaValue> red(capacity), green(capacity), blue(capacity);
uint32_t sampleCount = 0;
if (CGGetDisplayTransferByTable(d, capacity, red.data(), green.data(), blue.data(), &sampleCount) == kCGErrorSuccess) {
std::vector<CGGammaValue> all;
all.insert(all.end(), red.begin(), red.begin() + sampleCount);
all.insert(all.end(), green.begin(), green.begin() + sampleCount);
all.insert(all.end(), blue.begin(), blue.begin() + sampleCount);
g_originalGammas[uuid] = all;
} else {
NSLog(@"MacSetPrivacyMode: Failed to get gamma table for display %u (UUID: %s)", (unsigned)d, uuid.c_str());
}
} else {
NSLog(@"MacSetPrivacyMode: Display %u (UUID: %s) has zero gamma table capacity, not supported", (unsigned)d, uuid.c_str());
}
}
// Set to black only if we have saved original gamma for this display
if (g_originalGammas.find(uuid) != g_originalGammas.end()) {
uint32_t capacity = CGDisplayGammaTableCapacity(d);
if (capacity > 0) {
std::vector<CGGammaValue> zeros(capacity, 0.0f);
blackoutAttemptCount++;
CGError error = CGSetDisplayTransferByTable(d, capacity, zeros.data(), zeros.data(), zeros.data());
if (error != kCGErrorSuccess) {
NSLog(@"MacSetPrivacyMode: Failed to blackout display (ID: %u, UUID: %s, error: %d)", (unsigned)d, uuid.c_str(), error);
} else {
blackoutSuccessCount++;
}
} else {
NSLog(@"MacSetPrivacyMode: Display %u (UUID: %s) has zero gamma table capacity for blackout", (unsigned)d, uuid.c_str());
}
}
}
// Return false if any display failed to blackout - privacy mode requires ALL displays to be blacked out
if (blackoutAttemptCount > 0 && blackoutSuccessCount < blackoutAttemptCount) {
NSLog(@"MacSetPrivacyMode: Failed to blackout all displays (%u/%u succeeded)", blackoutSuccessCount, blackoutAttemptCount);
// Clean up: unregister callback and disable event tap since we're failing
CGDisplayRemoveReconfigurationCallback(DisplayReconfigurationCallback, NULL);
TeardownEventTapOnMainThread();
// Restore gamma for displays that were successfully blacked out
if (!RestoreAllGammas()) {
// If any display failed to restore, use system reset as fallback
NSLog(@"Some displays failed to restore gamma during cleanup, using CGDisplayRestoreColorSyncSettings as fallback");
CGDisplayRestoreColorSyncSettings();
}
g_originalGammas.clear();
return false;
}
g_privacyModeActive = true;
return true;
} else {
return TurnOffPrivacyModeInternal();
}
}