feat(macos): initial privacy mode support [a simple try] (#14102)

* feat(macos): add privacy mode support for macOS

## Summary
Add privacy mode functionality for macOS platform, allowing remote
desktop sessions to hide the screen content from local users.

## Changes

### Core Implementation (src/platform/macos.mm)
- Implement screen blackout using CGDisplayGammaTable API
- Implement input blocking using CGEventTap to intercept keyboard/mouse
- Store and restore original gamma values for proper cleanup

### Privacy Mode Integration (src/privacy_mode.rs, src/privacy_mode/macos.rs)
- Add macOS privacy mode implementation with PrivacyMode trait
- Register macOS privacy mode in PRIVACY_MODE_CREATOR
- Set DEFAULT_PRIVACY_MODE_IMPL for macOS platform
- Implement get_supported_privacy_mode_impl() for macOS

### Connection Handling (src/server/connection.rs)
- Add supported_privacy_mode_impl to platform_additions for macOS
- Enable privacy mode toggle in client UI when connecting via LAN IP

### Localization (src/lang/*.rs)
- Add "privacy_mode_impl_macos_tip" translation for en/cn/tw

## Safety & Security
- Implements Drop trait to ensure cleanup on normal exit
- macOS system automatically restores gamma table on process termination
- CGEventTap is automatically released when process terminates
- Tested with SIGKILL to verify crash recovery

## Testing
- Verified privacy mode toggle works via both ID and LAN IP connection
- Verified screen recovery after process crash (kill -9)
- Verified input restoration after process termination

* refactor: use existing 'Privacy mode' translation key

* refactor: rename gamma channel variables for better readability - rename r/g/b to red/green/blue to avoid variable shadowing confusion

* fix: add error handling for gamma table restoration with fallback to system reset

* fix: add error handling for CGEventTapCreate failure in privacy mode

* fix: only set display to black if original gamma was saved successfully

* fix: add error handling for CGSetDisplayTransferByTable when setting display to black

* fix: improve event tap callback to properly distinguish remote input from local input

* fix: missing macos.rs

* Fix: Add display validation before restoring gamma values

* Fix: Add mutex lock for thread safety in MacSetPrivacyMode

* Fix: Handle return values and add missing mouse events in macos privacy mode

* fix: only set conn_id after privacy mode is successfully turned on

* fix: reimplement privacy mode with stable display identification

Address code review concern: original gamma values stored with DisplayID
as key could become stale if display list changes between privacy mode
activations (e.g., display reconnected with different ID).

Solution:
- Use UUID instead of DisplayID as storage key (stable across reconnections)
- Clear g_originalGammas when privacy mode is turned off
- Register CGDisplayReconfigurationCallback to handle hot-plug events
- Validate display state via FindDisplayIdByUUID() before restoration

Key features:
- UUID-based display identification (stable across reconnections)
- Hot-plug support via CGDisplayReconfigurationCallback
- EventTap auto re-enable on system timeout
- Fallback to CGDisplayRestoreColorSyncSettings() for recovery
- Detailed error logging with display name/ID/UUID

* fix: ensure EventTap runs on main thread and improve gamma restore error handling

- Add SetupEventTapOnMainThread() to create EventTap on main thread using dispatch_sync, avoiding potential issues when called from background threads

- Add TeardownEventTapOnMainThread() for consistent cleanup on main thread

- Check [NSThread isMainThread] to avoid deadlock when already on main thread

- Add error tracking for gamma restoration during cleanup

- Use CGDisplayRestoreColorSyncSettings() as fallback when individual gamma restoration fails

* fix: remove invalid eventMask bits that caused undefined behavior in input blocking

* fix: address code review comments for macos privacy mode implementation

Changes to src/privacy_mode/macos.rs:
- Add check_on_conn_id() in turn_on_privacy() to prevent duplicate activation
- Add check_off_conn_id() in turn_off_privacy() to validate connection ID
- Add self.conn_id = 0 in clear() to reset connection state

Changes to src/platform/macos.mm:
- Add link comment for ENIGO_INPUT_EXTRA_VALUE referencing libs/enigo/src/macos/macos_impl.rs
- Fix NSLog format string mismatch (5 placeholders vs 4 values)
- Make ApplyBlackoutToDisplay() return bool for proper error handling
- Return false when UUID is empty since privacy mode requires ALL displays
- Add else branches with logging for:
  - CGGetDisplayTransferByTable failures
  - Zero gamma table capacity (not supported)
  - Zero blackout capacity
- Remove unused g_uuidToDisplayId variable (was only written, never read)

* fix(macos): add early return with privacy mode exit on display hotplug failures

Why large-scale changes are needed:

The code review suggested adding early return when errors occur in
DisplayReconfigurationCallback. However, simply returning early is not
enough - when a newly connected display cannot be blacked out, we must
exit privacy mode entirely to maintain security guarantees.

The challenge is that DisplayReconfigurationCallback already holds
g_privacyModeMutex, so calling MacSetPrivacyMode(false) directly would
cause a deadlock. This necessitated:

1. Extract TurnOffPrivacyModeInternal() - a lock-free internal function
   that can be safely called from within the callback
2. Refactor MacSetPrivacyMode(false) branch to use this internal function
3. Add early returns with TurnOffPrivacyModeInternal() calls at each
   failure point in DisplayReconfigurationCallback

Changes in DisplayReconfigurationCallback:
- UUID empty: log + exit privacy mode + early return
- Gamma table capacity zero: log + exit privacy mode + early return
- CGGetDisplayTransferByTable fails: log + exit privacy mode + early return
- ApplyBlackoutToDisplay fails: log + exit privacy mode + early return

* fix(macos): address code review feedback and improve privacy mode stability

Code Review Fixes:
- Add detailed comments for potential deadlock scenarios in dispatch_sync
  with g_privacyModeMutex (SetupEventTapOnMainThread/TeardownEventTapOnMainThread)
- Use async dispatch for privacy mode shutdown from DisplayReconfigurationCallback
  to avoid unregistering callback from within itself
- Extract RestoreAllGammas() helper function to reduce code duplication
- Fix Drop implementation in macos.rs to call self.clear() for consistency
- Add comment explaining why _state parameter is ignored on macOS
- Define DISPLAY_RECONFIG_MONITOR_DURATION_MS and GAMMA_CHECK_INTERVAL_MS constants
- Add gamma restoration when UUID retrieval fails during privacy mode activation

Privacy Mode Stability Improvements (Continuous Resolution Changes):
- Implement continuous gamma value monitoring with timer polling after display
  reconfiguration to handle rapid successive resolution changes
- Monitor gamma values every 200ms for 5 seconds after each resolution change
- Automatically reapply blackout if system (ColorSync) restores gamma
- Add IsDisplayBlackedOut() to detect if display gamma has been restored
- Use timestamp-based debouncing: monitoring period automatically extends
  when new reconfig events occur during active monitoring
- Ensure blackout remains effective even under continuous resolution changes
  where macOS may asynchronously restore gamma values multiple times

This ensures privacy mode remains stable and effective when users rapidly
change display resolution multiple times in succession.

---------

Co-authored-by: libin <libin.chat@outlook.com>
This commit is contained in:
Bin Li
2026-01-27 16:38:37 +08:00
committed by GitHub
parent f05f2178e5
commit c76d10a438
4 changed files with 754 additions and 4 deletions

View File

@@ -4,6 +4,13 @@
#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;
@@ -292,3 +299,635 @@ extern "C" bool MacSetMode(CGDirectDisplayID display, uint32_t width, uint32_t h
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 get display name from DisplayID
static std::string GetDisplayName(CGDirectDisplayID displayId) {
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 == displayId) {
// localizedName is available on macOS 10.15+
if (@available(macOS 10.15, *)) {
NSString *name = [screen localizedName];
if (name) {
return std::string([name UTF8String]);
}
}
break;
}
}
return "Unknown";
}
// 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) {
std::string displayName = GetDisplayName(d);
NSLog(@"Failed to restore gamma for display (Name: %s, ID: %u, UUID: %s, error: %d)",
displayName.c_str(), (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) {
std::string displayName = GetDisplayName(d);
NSLog(@"MacSetPrivacyMode: Failed to blackout display (Name: %s, ID: %u, UUID: %s, error: %d)", displayName.c_str(), (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();
}
}

View File

@@ -23,6 +23,9 @@ pub mod win_mag;
#[cfg(windows)]
pub mod win_topmost_window;
#[cfg(target_os = "macos")]
pub mod macos;
#[cfg(windows)]
mod win_virtual_display;
#[cfg(windows)]
@@ -104,9 +107,16 @@ lazy_static::lazy_static! {
}.to_owned()
}
#[cfg(not(windows))]
{
#[cfg(target_os = "macos")]
{
macos::PRIVACY_MODE_IMPL.to_owned()
}
#[cfg(not(target_os = "macos"))]
{
"".to_owned()
}
}
};
static ref PRIVACY_MODE: Arc<Mutex<Option<Box<dyn PrivacyMode>>>> = {
@@ -127,7 +137,13 @@ pub type PrivacyModeCreator = fn(impl_key: &str) -> Box<dyn PrivacyMode>;
lazy_static::lazy_static! {
static ref PRIVACY_MODE_CREATOR: Arc<Mutex<HashMap<&'static str, PrivacyModeCreator>>> = {
#[cfg(not(windows))]
let map: HashMap<&'static str, PrivacyModeCreator> = HashMap::new();
let mut map: HashMap<&'static str, PrivacyModeCreator> = HashMap::new();
#[cfg(target_os = "macos")]
{
map.insert(macos::PRIVACY_MODE_IMPL, |impl_key: &str| {
Box::new(macos::PrivacyModeImpl::new(impl_key))
});
}
#[cfg(windows)]
let mut map: HashMap<&'static str, PrivacyModeCreator> = HashMap::new();
#[cfg(windows)]
@@ -333,7 +349,14 @@ pub fn get_supported_privacy_mode_impl() -> Vec<(&'static str, &'static str)> {
vec_impls
}
#[cfg(not(target_os = "windows"))]
#[cfg(target_os = "macos")]
{
// No translation is intended for privacy_mode_impl_macos_tip as it is a
// placeholder for macOS specific privacy mode implementation which currently
// doesn't provide multiple modes like Windows does.
vec![(macos::PRIVACY_MODE_IMPL, "privacy_mode_impl_macos_tip")]
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
Vec::new()
}

81
src/privacy_mode/macos.rs Normal file
View File

@@ -0,0 +1,81 @@
use super::{PrivacyMode, PrivacyModeState};
use hbb_common::{anyhow::anyhow, ResultType};
extern "C" {
fn MacSetPrivacyMode(on: bool) -> bool;
}
pub const PRIVACY_MODE_IMPL: &str = "privacy_mode_impl_macos";
pub struct PrivacyModeImpl {
impl_key: String,
conn_id: i32,
}
impl PrivacyModeImpl {
pub fn new(impl_key: &str) -> Self {
Self {
impl_key: impl_key.to_owned(),
conn_id: 0,
}
}
}
impl PrivacyMode for PrivacyModeImpl {
fn is_async_privacy_mode(&self) -> bool {
false
}
fn init(&self) -> ResultType<()> {
Ok(())
}
fn clear(&mut self) {
unsafe {
MacSetPrivacyMode(false);
}
self.conn_id = 0;
}
fn turn_on_privacy(&mut self, conn_id: i32) -> ResultType<bool> {
if self.check_on_conn_id(conn_id)? {
return Ok(true);
}
let success = unsafe { MacSetPrivacyMode(true) };
if !success {
return Err(anyhow!("Failed to turn on privacy mode"));
}
self.conn_id = conn_id;
Ok(true)
}
fn turn_off_privacy(&mut self, conn_id: i32, _state: Option<PrivacyModeState>) -> ResultType<()> {
// Note: The `_state` parameter is intentionally ignored on macOS.
// On Windows, it's used to notify the connection manager about privacy mode state changes
// (see win_topmost_window.rs). macOS currently has a simpler single-mode implementation
// without the need for such cross-component state synchronization.
self.check_off_conn_id(conn_id)?;
let success = unsafe { MacSetPrivacyMode(false) };
if !success {
return Err(anyhow!("Failed to turn off privacy mode"));
}
self.conn_id = 0;
Ok(())
}
fn pre_conn_id(&self) -> i32 {
self.conn_id
}
fn get_impl_key(&self) -> &str {
&self.impl_key
}
}
impl Drop for PrivacyModeImpl {
fn drop(&mut self) {
// Use the same cleanup logic as other code paths to keep conn_id consistent
// and ensure all cleanup is centralized in one place.
self.clear();
}
}

View File

@@ -1420,7 +1420,7 @@ impl Connection {
pi.platform = "Android".into();
}
#[cfg(all(target_os = "macos", not(feature = "unix-file-copy-paste")))]
let platform_additions = serde_json::Map::new();
let mut platform_additions = serde_json::Map::new();
#[cfg(any(
target_os = "windows",
target_os = "linux",
@@ -1453,6 +1453,13 @@ impl Connection {
json!(privacy_mode::get_supported_privacy_mode_impl()),
);
}
#[cfg(target_os = "macos")]
{
platform_additions.insert(
"supported_privacy_mode_impl".into(),
json!(privacy_mode::get_supported_privacy_mode_impl()),
);
}
#[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))]
{