[feat] add macOS privacy screen and keep remote sessions running

This commit is contained in:
kunkundi
2026-09-13 00:38:40 +08:00
parent 23bf2ff8b8
commit a6e325f190
38 changed files with 1161 additions and 1046 deletions
@@ -11,6 +11,7 @@
#include <vector>
#include "runtime/cursor_position.h"
#include "../../privacy/privacy_cursor_mac.h"
namespace crossdesk {
namespace {
@@ -161,7 +162,9 @@ bool CursorStateProvider::Sample(const std::vector<DisplayInfo>& displays,
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
NSCursor* cursor = NSCursor.currentSystemCursor;
const bool visible = CGCursorIsVisible();
const bool privacy_hidden = IsMacPrivacyCursorHidden();
const bool visible = CGCursorIsVisible() || privacy_hidden;
if (!cursor && privacy_hidden) cursor = NSCursor.arrowCursor;
#pragma clang diagnostic pop
state->seq = 0;
@@ -4,6 +4,7 @@
#include <unordered_map>
#include "keyboard_converter.h"
#include "../mac_injected_event.h"
#include "rd_log.h"
namespace crossdesk {
@@ -11,7 +12,6 @@ namespace crossdesk {
static OnKeyAction g_on_key_action = nullptr;
static void* g_user_ptr = nullptr;
static std::unordered_map<int, int> g_unmapped_keycode_to_vk;
constexpr int64_t kCrossDeskInjectedKeyboardEvent = 0x43524f5353444553;
static int VkCodeFromUnicode(UniChar ch) {
if (ch >= 'a' && ch <= 'z') {
@@ -106,7 +106,7 @@ CGEventRef eventCallback(CGEventTapProxy proxy, CGEventType type,
CGEventRef event, void* userInfo) {
(void)proxy;
if (CGEventGetIntegerValueField(event, kCGEventSourceUserData) ==
kCrossDeskInjectedKeyboardEvent) {
kCrossDeskInjectedEvent) {
return event;
}
if (!g_on_key_action) {
@@ -309,7 +309,7 @@ int PlatformKeyboardCapturer::SendKeyboardCommand(int key_code, bool is_down,
CGEventSetFlags(event, ToCGEventFlags(injected_flags));
CGEventSetIntegerValueField(event, kCGEventSourceUserData,
kCrossDeskInjectedKeyboardEvent);
kCrossDeskInjectedEvent);
CGEventPost(kCGHIDEventTap, event);
CFRelease(event);
@@ -323,7 +323,7 @@ int PlatformKeyboardCapturer::SendKeyboardCommand(int key_code, bool is_down,
return -1;
}
CGEventSetIntegerValueField(fn_release_event, kCGEventSourceUserData,
kCrossDeskInjectedKeyboardEvent);
kCrossDeskInjectedEvent);
CGEventPost(kCGHIDEventTap, fn_release_event);
CFRelease(fn_release_event);
}
@@ -0,0 +1,17 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-13
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _MAC_INJECTED_EVENT_H_
#define _MAC_INJECTED_EVENT_H_
#include <cstdint>
namespace crossdesk {
// Shared by the keyboard/mouse injectors and the privacy input tap.
inline constexpr int64_t kCrossDeskInjectedEvent = 0x43524f5353444553;
}
#endif
@@ -1,4 +1,5 @@
#include "mouse_controller.h"
#include "../mac_injected_event.h"
#include <remote_action.h>
@@ -210,6 +211,8 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
}
if (mouse_event) {
CGEventSetIntegerValueField(mouse_event, kCGEventSourceUserData,
kCrossDeskInjectedEvent);
CGEventPost(kCGHIDEventTap, mouse_event);
CFRelease(mouse_event);
}
@@ -0,0 +1,400 @@
/* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. */
#import <AppKit/AppKit.h>
#import <ApplicationServices/ApplicationServices.h>
#include <algorithm>
#include <chrono>
#include <deque>
#include <functional>
#include <cstdlib>
#include <libproc.h>
#include <mutex>
#include <utility>
#include <unistd.h>
#include "privacy_backend.h"
#include "privacy_capture_state.h"
#include "privacy_cover_view.h"
#include "privacy_cursor_mac.h"
#include "rd_log.h"
#include "../input/mac_injected_event.h"
// These windows never take keyboard focus. Remote clicks pass through them;
// physical input is intercepted independently on the controller's run loop.
@interface CrossDeskPrivacyPanel : NSPanel
@end
@implementation CrossDeskPrivacyPanel
- (BOOL)canBecomeKeyWindow { return NO; }
- (BOOL)canBecomeMainWindow { return NO; }
@end
namespace crossdesk {
namespace {
using Clock = std::chrono::steady_clock;
std::mutex tasks_mutex;
std::deque<std::function<void()>> tasks;
std::mutex capture_mutex;
MacPrivacyCaptureState capture_state;
void OnMain(std::function<void()> task) {
{
std::lock_guard lock(tasks_mutex);
tasks.push_back(std::move(task));
}
dispatch_async(dispatch_get_main_queue(), ^{ FlushMacPrivacyTasks(); });
}
void PublishCovers(std::vector<uint32_t> windows, uint32_t cursor_window = 0) {
{
std::lock_guard lock(capture_mutex);
capture_state.excluded_windows = std::move(windows);
capture_state.cursor_window = cursor_window;
++capture_state.revision;
}
dispatch_async(dispatch_get_main_queue(), ^{
[NSNotificationCenter.defaultCenter postNotificationName:@"CrossDeskPrivacyCoversChanged"
object:nil];
});
}
struct Monitor {
CGDirectDisplayID id = 0;
CGRect bounds{}; // CoreGraphics desktop coordinates (points).
NSRect frame{}; // AppKit desktop coordinates (bottom left).
CrossDeskPrivacyPanel* __strong cover = nil;
};
struct WindowState {
std::mutex mutex;
std::vector<Monitor> monitors;
bool recovered = true;
bool recovering = false;
bool covers_shown = false;
bool check_queued = false;
MacPrivacyCursor cursor;
std::string failure;
Clock::time_point checked = Clock::now();
};
CrossDeskPrivacyPanel* MakePanel(NSRect frame, NSInteger level) {
auto* panel = [[CrossDeskPrivacyPanel alloc]
initWithContentRect:frame
styleMask:NSWindowStyleMaskBorderless | NSWindowStyleMaskNonactivatingPanel
backing:NSBackingStoreBuffered defer:NO];
panel.releasedWhenClosed = NO;
panel.hidesOnDeactivate = NO;
panel.opaque = YES;
panel.backgroundColor = NSColor.blackColor;
panel.hasShadow = NO;
panel.ignoresMouseEvents = YES;
panel.level = level;
panel.animationBehavior = NSWindowAnimationBehaviorNone;
panel.collectionBehavior = NSWindowCollectionBehaviorCanJoinAllSpaces |
NSWindowCollectionBehaviorFullScreenAuxiliary |
NSWindowCollectionBehaviorStationary |
NSWindowCollectionBehaviorIgnoresCycle;
// Exclusion is explicit in ScreenCaptureKit. SharingNone alone does not
// exclude windows on recent macOS releases, and can hide them from discovery.
panel.sharingType = NSWindowSharingReadOnly;
return panel;
}
bool IsSystemCursorPlane(NSDictionary* info, CGRect bounds) {
const auto layer = [info[(__bridge NSString*)kCGWindowLayer] intValue];
// WindowServer's cursor plane also carries small recording indicators.
// Validate both its geometry and executable identity before ignoring it.
if (layer != CGWindowLevelForKey(kCGCursorWindowLevelKey) ||
bounds.size.width <= 0 || bounds.size.height <= 0 ||
bounds.size.width > 512 || bounds.size.height > 512)
return false;
char executable[PROC_PIDPATHINFO_MAXSIZE]{};
const auto pid = [info[(__bridge NSString*)kCGWindowOwnerPID] intValue];
static const std::string system_executable = [] {
char resolved[PROC_PIDPATHINFO_MAXSIZE]{};
return realpath("/System/Library/PrivateFrameworks/SkyLight.framework/Resources/WindowServer",
resolved) ? std::string(resolved) : std::string();
}();
return proc_pidpath(pid, executable, sizeof(executable)) > 0 &&
!system_executable.empty() && system_executable == executable;
}
std::string CoverageFailure(const std::vector<Monitor>& monitors) {
uint32_t count = 0;
if (CGGetActiveDisplayList(0, nullptr, &count) != kCGErrorSuccess || !count)
return "No active macOS desktop; privacy screen unavailable";
std::vector<CGDirectDisplayID> displays(count);
if (CGGetActiveDisplayList(count, displays.data(), &count) != kCGErrorSuccess ||
count != monitors.size())
return "Display topology changed; turn privacy off to recover";
for (const auto& m : monitors) {
if (std::find(displays.begin(), displays.end(), m.id) == displays.end() ||
!CGRectEqualToRect(CGDisplayBounds(m.id), m.bounds) ||
!m.cover.visible || !m.cover.isOnActiveSpace ||
!NSEqualRects(m.cover.frame, m.frame) || m.cover.alphaValue != 1.0)
return "Privacy cover or display geometry changed; privacy screen unavailable";
}
NSDictionary* session = CFBridgingRelease(CGSessionCopyCurrentDictionary());
if (!session || ![session[(__bridge NSString*)kCGSessionOnConsoleKey] boolValue])
return "macOS session is not on console; privacy screen unavailable";
NSArray* windows = CFBridgingRelease(CGWindowListCopyWindowInfo(
kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements,
kCGNullWindowID));
if (!windows) return "Unable to verify macOS privacy window order";
for (const auto& m : monitors) {
bool found = false;
for (NSDictionary* info in windows) {
const auto window = [info[(__bridge NSString*)kCGWindowNumber] unsignedIntValue];
if (window == m.cover.windowNumber) { found = true; break; }
if ([info[(__bridge NSString*)kCGWindowAlpha] doubleValue] <= 0) continue;
CGRect bounds{};
if (!CGRectMakeWithDictionaryRepresentation(
(__bridge CFDictionaryRef)info[(__bridge NSString*)kCGWindowBounds], &bounds))
return "Unable to verify a window above the privacy cover";
if (CGRectIntersectsRect(bounds, m.bounds)) {
if (IsSystemCursorPlane(info, bounds)) continue;
LOG_WARN("macOS privacy overlap: window={}, pid={}, layer={}, alpha={}, "
"bounds=({}, {}, {}, {}), cover={}, cover_level={}",
window,
[info[(__bridge NSString*)kCGWindowOwnerPID] intValue],
[info[(__bridge NSString*)kCGWindowLayer] intValue],
[info[(__bridge NSString*)kCGWindowAlpha] doubleValue],
bounds.origin.x, bounds.origin.y, bounds.size.width, bounds.size.height,
m.cover.windowNumber, m.cover.level);
return "A system or application window overlaps the privacy cover; privacy screen unavailable";
}
}
if (!found) return "Privacy cover disappeared; privacy screen unavailable";
}
return {};
}
class MacPrivacyBackend final : public PrivacyBackend {
public:
MacPrivacyBackend() : state_(std::make_shared<WindowState>()) {
loop_ = CFRunLoopGetCurrent();
CFRetain(loop_);
CFRunLoopSourceContext context{};
wake_source_ = CFRunLoopSourceCreate(nullptr, 0, &context);
CFRunLoopAddSource(loop_, wake_source_, kCFRunLoopDefaultMode);
}
~MacPrivacyBackend() override {
Recover();
CFRunLoopRemoveSource(loop_, wake_source_, kCFRunLoopDefaultMode);
CFRelease(wake_source_);
CFRelease(loop_);
}
PrivacyCapabilities Query() override {
if (@available(macOS 14.0, *)) {
if (!CGPreflightScreenCaptureAccess())
return {false, false, "Grant Screen Recording permission to use privacy screen"};
if (!AXIsProcessTrusted())
return {false, false, "Grant Accessibility permission to protect local input and enable local recovery"};
if (!MacPrivacyCursor::Supported())
return {false, false, "Background cursor control unavailable on this macOS version"};
return {true, true, "macOS privacy screen available (ScreenCaptureKit)"};
}
return {false, false, "Privacy screen requires macOS 14 or newer"};
}
bool Enable(bool block_input, const PrivacyScreenText& text, std::string& error) override {
const auto caps = Query();
if (!caps.overlay) { error = caps.reason; return false; }
block_input_ = block_input;
emergency_ = false;
tap_failed_ = false;
CGEventMask mask = CGEventMaskBit(kCGEventKeyDown) | CGEventMaskBit(kCGEventKeyUp) |
CGEventMaskBit(kCGEventFlagsChanged) | CGEventMaskBit(kCGEventLeftMouseDown) |
CGEventMaskBit(kCGEventLeftMouseUp) | CGEventMaskBit(kCGEventRightMouseDown) |
CGEventMaskBit(kCGEventRightMouseUp) | CGEventMaskBit(kCGEventMouseMoved) |
CGEventMaskBit(kCGEventLeftMouseDragged) | CGEventMaskBit(kCGEventRightMouseDragged) |
CGEventMaskBit(kCGEventOtherMouseDown) | CGEventMaskBit(kCGEventOtherMouseUp) |
CGEventMaskBit(kCGEventOtherMouseDragged) | CGEventMaskBit(kCGEventScrollWheel) |
CGEventMaskBit(14); // NX_SYSDEFINED: media/brightness keys.
tap_ = CGEventTapCreate(kCGHIDEventTap, kCGHeadInsertEventTap,
kCGEventTapOptionDefault, mask, Input, this);
if (!tap_) { error = "Cannot install macOS privacy input tap"; return false; }
tap_source_ = CFMachPortCreateRunLoopSource(nullptr, tap_, 0);
if (!tap_source_) { error = "Cannot create macOS privacy input run loop"; return false; }
CFRunLoopAddSource(loop_, tap_source_, kCFRunLoopDefaultMode);
CGEventTapEnable(tap_, true);
{
std::lock_guard lock(state_->mutex);
state_->recovered = false;
state_->failure.clear();
state_->checked = Clock::now();
}
auto state = state_;
OnMain([state, text] {
@autoreleasepool {
std::lock_guard lock(state->mutex);
if (!state->cursor.Prepare()) {
state->failure = "Cannot prepare the privacy cursor";
return;
}
std::vector<uint32_t> covers;
for (NSScreen* screen in NSScreen.screens) {
Monitor m;
m.id = [screen.deviceDescription[@"NSScreenNumber"] unsignedIntValue];
m.bounds = CGDisplayBounds(m.id);
m.frame = screen.frame;
NSView* content = CreateMacPrivacyCoverView(
NSMakeRect(0, 0, m.frame.size.width, m.frame.size.height),
[NSString stringWithUTF8String:text.unlock_hint.c_str()]);
if (!content) {
state->failure = "Could not load the CrossDesk privacy screen logo";
break;
}
m.cover = MakePanel(m.frame, CGWindowLevelForKey(kCGScreenSaverWindowLevelKey) + 1);
m.cover.contentView = content;
// Register transparent windows first. Make them opaque only after
// SCK has acknowledged exclusion, avoiding a black remote frame.
m.cover.alphaValue = 0;
[m.cover orderFrontRegardless];
covers.push_back(static_cast<uint32_t>(m.cover.windowNumber));
state->monitors.push_back(std::move(m));
}
PublishCovers(std::move(covers), state->cursor.WindowId());
if (state->monitors.empty()) state->failure = "No active macOS desktop";
state->checked = Clock::now();
}
});
return true;
}
void Recover() override {
if (tap_) { CGEventTapEnable(tap_, false); CFMachPortInvalidate(tap_); }
if (tap_source_) {
CFRunLoopRemoveSource(loop_, tap_source_, kCFRunLoopDefaultMode);
CFRelease(tap_source_); tap_source_ = nullptr;
}
if (tap_) { CFRelease(tap_); tap_ = nullptr; }
{
std::lock_guard lock(state_->mutex);
if (state_->recovered || state_->recovering) return;
state_->recovering = true;
}
auto state = state_;
OnMain([state] {
std::lock_guard lock(state->mutex);
for (auto& m : state->monitors) {
[m.cover orderOut:nil]; [m.cover close]; m.cover = nil;
}
state->monitors.clear();
state->covers_shown = false;
state->cursor.Restore();
PublishCovers({});
state->failure.clear();
state->recovering = false;
state->recovered = true;
});
}
bool IsRecovered() const override {
std::lock_guard lock(state_->mutex);
return state_->recovered && !tap_;
}
bool RecoveryPending() const override {
std::lock_guard lock(state_->mutex);
return state_->recovering;
}
PrivacyHealth Poll() override {
if (emergency_) { emergency_ = false; return {{}, true}; }
if (tap_ && (tap_failed_ || !CGEventTapIsEnabled(tap_) || !AXIsProcessTrusted()))
return {"Local input protection interrupted; privacy screen unavailable", false};
auto state = state_;
std::lock_guard lock(state->mutex);
if (state->recovered || state->recovering) return {};
if (!state->failure.empty()) return {state->failure, false};
const auto age = Clock::now() - state->checked;
if (age > std::chrono::seconds(1))
return {"macOS privacy window checks stopped responding; privacy screen unavailable", false};
if (!state->check_queued && age > std::chrono::milliseconds(100)) {
state->check_queued = true;
OnMain([state] {
std::lock_guard lock(state->mutex);
if (!state->recovered && !state->recovering && state->failure.empty()) {
const auto capture = GetMacPrivacyCaptureState();
if (!state->covers_shown && !state->monitors.empty() &&
capture.revision == capture.applied_revision) {
if (!state->cursor.Hide()) {
state->failure = "Cannot hide the local privacy cursor";
state->checked = Clock::now();
state->check_queued = false;
return;
}
for (auto& m : state->monitors) {
m.cover.alphaValue = 1;
[m.cover displayIfNeeded];
[m.cover orderFrontRegardless];
}
state->covers_shown = true;
}
if (state->covers_shown) {
state->failure = state->cursor.Healthy() ? CoverageFailure(state->monitors)
: "Cannot keep the local privacy cursor hidden";
}
}
state->checked = Clock::now();
state->check_queued = false;
});
}
const auto capture = GetMacPrivacyCaptureState();
return {{}, false, state->covers_shown &&
capture.revision == capture.applied_revision};
}
void WaitForEvents(unsigned timeout_ms) override {
@autoreleasepool {
CFRunLoopRunInMode(kCFRunLoopDefaultMode, timeout_ms / 1000.0, true);
}
}
void Wake() override { CFRunLoopSourceSignal(wake_source_); CFRunLoopWakeUp(loop_); }
private:
static CGEventRef Input(CGEventTapProxy, CGEventType type, CGEventRef event, void* context) {
auto* self = static_cast<MacPrivacyBackend*>(context);
if (type == kCGEventTapDisabledByTimeout || type == kCGEventTapDisabledByUserInput) {
self->tap_failed_ = true;
CGEventTapEnable(self->tap_, true); // Keep the local recovery chord usable.
return event;
}
if (!event) return event;
if (CGEventGetIntegerValueField(event, kCGEventSourceUserData) == kCrossDeskInjectedEvent &&
CGEventGetIntegerValueField(event, kCGEventSourceUnixProcessID) == getpid()) return event;
const auto flags = CGEventGetFlags(event);
const auto chord = kCGEventFlagMaskControl | kCGEventFlagMaskAlternate | kCGEventFlagMaskShift;
if (type == kCGEventKeyDown && (flags & chord) == chord &&
CGEventGetIntegerValueField(event, kCGKeyboardEventKeycode) == 53) {
self->emergency_ = true;
return nullptr;
}
return self->block_input_ ? nullptr : event;
}
std::shared_ptr<WindowState> state_;
CFRunLoopRef loop_ = nullptr;
CFRunLoopSourceRef wake_source_ = nullptr;
CFMachPortRef tap_ = nullptr;
CFRunLoopSourceRef tap_source_ = nullptr;
bool block_input_ = false;
bool emergency_ = false;
bool tap_failed_ = false;
};
} // namespace
void FlushMacPrivacyTasks() {
if (![NSThread isMainThread]) return;
std::deque<std::function<void()>> pending;
{
std::lock_guard lock(tasks_mutex);
pending.swap(tasks);
}
@autoreleasepool { for (auto& task : pending) task(); }
}
MacPrivacyCaptureState GetMacPrivacyCaptureState() {
std::lock_guard lock(capture_mutex);
return capture_state;
}
void AcknowledgeMacPrivacyCapture(uint64_t revision) {
std::lock_guard lock(capture_mutex);
if (revision == capture_state.revision) capture_state.applied_revision = revision;
}
std::unique_ptr<PrivacyBackend> CreateMacPrivacyBackend() {
return std::make_unique<MacPrivacyBackend>();
}
} // namespace crossdesk
@@ -0,0 +1,23 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-13
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _PRIVACY_CAPTURE_STATE_H_
#define _PRIVACY_CAPTURE_STATE_H_
#include <cstdint>
#include <vector>
namespace crossdesk {
struct MacPrivacyCaptureState {
uint64_t revision = 0;
uint64_t applied_revision = 0;
std::vector<uint32_t> excluded_windows;
uint32_t cursor_window = 0;
};
MacPrivacyCaptureState GetMacPrivacyCaptureState();
void AcknowledgeMacPrivacyCapture(uint64_t revision);
}
#endif
@@ -0,0 +1,17 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-13
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _PRIVACY_COVER_VIEW_H_
#define _PRIVACY_COVER_VIEW_H_
#import <AppKit/AppKit.h>
namespace crossdesk {
// Returns nil if the CrossDesk icon resource cannot be loaded.
NSView* CreateMacPrivacyCoverView(NSRect frame, NSString* unlock_hint);
}
#endif
@@ -0,0 +1,106 @@
/* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. */
#include "privacy_cover_view.h"
#include <algorithm>
#include <cmath>
namespace {
NSImage* LoadPrivacyLogo() {
// The macOS installer currently packages the icon as "crossedesk.icns".
for (NSString* name in @[@"crossdesk", @"crossedesk"]) {
NSString* path = [NSBundle.mainBundle pathForResource:name ofType:@"icns"];
if (path) {
NSImage* image = [[NSImage alloc] initWithContentsOfFile:path];
if (image.valid) return image;
}
}
// Support unbundled development builds, including launches from outside
// the repository. No source-tree path is embedded in the executable.
NSString* resource = @"apps/desktop/resources/macos/crossdesk.icns";
NSString* directory = NSBundle.mainBundle.executablePath.stringByDeletingLastPathComponent;
NSMutableArray<NSString*>* paths = [NSMutableArray arrayWithObject:
[NSFileManager.defaultManager.currentDirectoryPath stringByAppendingPathComponent:resource]];
for (int depth = 0; directory.length && depth <= 4; ++depth) {
[paths addObject:[directory stringByAppendingPathComponent:resource]];
directory = directory.stringByDeletingLastPathComponent;
}
for (NSString* path in paths) {
NSImage* image = [[NSImage alloc] initWithContentsOfFile:path.stringByStandardizingPath];
if (image.valid) return image;
}
return nil;
}
NSAttributedString* Text(NSString* value, CGFloat size, NSFontWeight weight, NSColor* color) {
NSMutableParagraphStyle* paragraph = [[NSMutableParagraphStyle alloc] init];
paragraph.lineBreakMode = NSLineBreakByWordWrapping;
return [[NSAttributedString alloc] initWithString:value attributes:@{
NSFontAttributeName: [NSFont systemFontOfSize:size weight:weight],
NSForegroundColorAttributeName: color,
NSParagraphStyleAttributeName: paragraph}];
}
constexpr NSStringDrawingOptions kTextOptions =
NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading;
CGFloat TextHeight(NSAttributedString* text, CGFloat width) {
return std::ceil([text boundingRectWithSize:NSMakeSize(width, CGFLOAT_MAX)
options:kTextOptions].size.height);
}
}
@interface CrossDeskPrivacyCoverView : NSView
@property(nonatomic, strong) NSImage* logo;
@property(nonatomic, copy) NSString* unlockHint;
@end
@implementation CrossDeskPrivacyCoverView
- (BOOL)isOpaque { return YES; }
- (BOOL)isFlipped { return YES; }
- (void)drawRect:(NSRect)dirtyRect {
[NSColor.blackColor setFill];
NSRectFill(self.bounds);
const CGFloat width = NSWidth(self.bounds), height = NSHeight(self.bounds);
if (width <= 0 || height <= 0) return;
// Match the Windows cover's sizes, colors and bottom-left alignment.
// AppKit coordinates are points; Retina backing scale is applied by AppKit.
const CGFloat scale = std::min({CGFloat(1), width / 640, height / 200});
const CGFloat margin = 32 * scale, text_width = 398 * scale;
NSColor* foreground = [NSColor colorWithSRGBRed:245.0 / 255 green:247.0 / 255
blue:250.0 / 255 alpha:1];
NSColor* muted = [NSColor colorWithSRGBRed:181.0 / 255 green:190.0 / 255
blue:204.0 / 255 alpha:1];
NSAttributedString* brand = Text(@"CrossDesk", 14 * scale, NSFontWeightSemibold, foreground);
NSAttributedString* hint = Text(self.unlockHint, 16 * scale, NSFontWeightRegular, muted);
NSAttributedString* shortcut = Text(@"Control + Option + Shift + Esc", 24 * scale,
NSFontWeightSemibold, foreground);
const CGFloat logo_size = std::ceil(brand.size.width);
const CGFloat brand_height = TextHeight(brand, logo_size);
const CGFloat hint_height = TextHeight(hint, text_width);
const CGFloat shortcut_height = TextHeight(shortcut, text_width);
const CGFloat bottom = height - margin;
const CGFloat logo_top = bottom - brand_height - 6 * scale - logo_size;
[self.logo drawInRect:NSMakeRect(margin, logo_top, logo_size, logo_size)
fromRect:NSZeroRect operation:NSCompositingOperationSourceOver
fraction:1 respectFlipped:YES hints:@{NSImageHintInterpolation: @(NSImageInterpolationHigh)}];
[brand drawWithRect:NSMakeRect(margin, bottom - brand_height, logo_size, brand_height)
options:kTextOptions];
const CGFloat text_left = margin + logo_size + 24 * scale;
[hint drawWithRect:NSMakeRect(text_left, bottom - shortcut_height - 12 * scale - hint_height,
text_width, hint_height) options:kTextOptions];
[shortcut drawWithRect:NSMakeRect(text_left, bottom - shortcut_height,
text_width, shortcut_height) options:kTextOptions];
}
@end
namespace crossdesk {
NSView* CreateMacPrivacyCoverView(NSRect frame, NSString* unlock_hint) {
NSImage* logo = LoadPrivacyLogo();
if (!logo) return nil;
auto* view = [[CrossDeskPrivacyCoverView alloc] initWithFrame:frame];
view.logo = logo;
view.unlockHint = unlock_hint ?: @"To turn off the privacy screen, press";
view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
return view;
}
}
@@ -0,0 +1,35 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-13
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _PRIVACY_CURSOR_MAC_H_
#define _PRIVACY_CURSOR_MAC_H_
#include <memory>
#include <cstdint>
namespace crossdesk {
// Main-thread resource. Hides only for the lifetime of a local privacy cover;
// does not take focus, move the pointer, or suppress remote input.
class MacPrivacyCursor {
public:
MacPrivacyCursor();
~MacPrivacyCursor();
static bool Supported();
bool Prepare();
bool Hide();
bool Healthy() const;
uint32_t WindowId() const;
void Restore();
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
// Local hiding must not be reported as an invisible cursor to controllers.
bool IsMacPrivacyCursorHidden();
}
#endif
@@ -0,0 +1,157 @@
/* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. */
#include "privacy_cursor_mac.h"
#import <AppKit/AppKit.h>
#import <ApplicationServices/ApplicationServices.h>
#include <atomic>
#include <dlfcn.h>
@interface CrossDeskPrivacyCursorPanel : NSPanel
@end
@implementation CrossDeskPrivacyCursorPanel
- (BOOL)canBecomeKeyWindow { return NO; }
- (BOOL)canBecomeMainWindow { return NO; }
@end
namespace crossdesk {
namespace {
std::atomic<bool> hidden_by_privacy{false};
struct CursorApi {
using Connection = int (*)();
using Copy = CGError (*)(int, int, CFStringRef, CFTypeRef*);
using Set = CGError (*)(int, int, CFStringRef, CFTypeRef);
Connection connection = reinterpret_cast<Connection>(dlsym(RTLD_DEFAULT, "CGSMainConnectionID"));
Copy copy = reinterpret_cast<Copy>(dlsym(RTLD_DEFAULT, "CGSCopyConnectionProperty"));
Set set = reinterpret_cast<Set>(dlsym(RTLD_DEFAULT, "CGSSetConnectionProperty"));
};
const CursorApi& Api() { static const CursorApi api; return api; }
CFStringRef const kBackgroundCursor = CFSTR("SetsCursorInBackground");
}
struct MacPrivacyCursor::Impl {
int connection = 0;
bool previous_background = false;
bool background_changed = false;
unsigned hide_count = 0;
unsigned visible_ticks = 0;
bool healthy = true;
NSTimer* __strong timer = nil;
CrossDeskPrivacyCursorPanel* __strong panel = nil;
NSImageView* __strong view = nil;
void UpdateImage() {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
NSCursor* cursor = NSCursor.currentSystemCursor ?: NSCursor.arrowCursor;
#pragma clang diagnostic pop
NSImage* image = cursor.image;
if (!image || image.size.width <= 0 || image.size.height <= 0) return;
const NSPoint point = NSEvent.mouseLocation;
const NSSize size = image.size;
[panel setFrame:NSMakeRect(point.x - cursor.hotSpot.x,
point.y - size.height + cursor.hotSpot.y, size.width, size.height) display:NO];
view.image = image;
[panel displayIfNeeded];
}
bool KeepHidden() {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
if (hide_count && !CGCursorIsVisible()) { visible_ticks = 0; return true; }
#pragma clang diagnostic pop
if (++visible_ticks > 6 ||
CGDisplayHideCursor(kCGDirectMainDisplay) != kCGErrorSuccess) return false;
++hide_count;
return true;
}
};
MacPrivacyCursor::MacPrivacyCursor() : impl_(std::make_unique<Impl>()) {}
MacPrivacyCursor::~MacPrivacyCursor() { Restore(); }
bool MacPrivacyCursor::Supported() {
return Api().connection && Api().copy && Api().set;
}
bool MacPrivacyCursor::Prepare() {
if (impl_->panel) return true;
if (!Supported()) return false;
// A cursor sprite below the local cover is captured only for clients that
// request an embedded cursor. Desktop clients exclude it and use metadata.
impl_->panel = [[CrossDeskPrivacyCursorPanel alloc] initWithContentRect:NSMakeRect(0, 0, 32, 32)
styleMask:NSWindowStyleMaskBorderless | NSWindowStyleMaskNonactivatingPanel
backing:NSBackingStoreBuffered defer:NO];
auto* panel = impl_->panel;
panel.releasedWhenClosed = NO;
panel.hidesOnDeactivate = NO;
panel.opaque = NO;
panel.backgroundColor = NSColor.clearColor;
panel.hasShadow = NO;
panel.ignoresMouseEvents = YES;
panel.level = CGWindowLevelForKey(kCGScreenSaverWindowLevelKey);
panel.animationBehavior = NSWindowAnimationBehaviorNone;
panel.collectionBehavior = NSWindowCollectionBehaviorCanJoinAllSpaces |
NSWindowCollectionBehaviorFullScreenAuxiliary | NSWindowCollectionBehaviorStationary |
NSWindowCollectionBehaviorIgnoresCycle;
panel.sharingType = NSWindowSharingReadOnly;
impl_->view = [[NSImageView alloc] initWithFrame:panel.contentView.bounds];
impl_->view.imageScaling = NSImageScaleNone;
impl_->view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
panel.contentView = impl_->view;
panel.alphaValue = 0;
impl_->UpdateImage();
[panel orderFrontRegardless];
return panel != nil && panel.windowNumber > 0;
}
bool MacPrivacyCursor::Hide() {
if (impl_->timer) return true;
if (!Prepare()) return false;
impl_->connection = Api().connection();
impl_->previous_background = false;
CFTypeRef previous = nullptr;
if (Api().copy(impl_->connection, impl_->connection, kBackgroundCursor, &previous) == kCGErrorSuccess && previous)
impl_->previous_background = CFEqual(previous, kCFBooleanTrue);
if (previous) CFRelease(previous);
// This connection-scoped WindowServer property is also used by Deskflow.
// Resolve it dynamically: an unavailable API disables optional privacy only.
if (Api().set(impl_->connection, impl_->connection, kBackgroundCursor, kCFBooleanTrue) != kCGErrorSuccess)
return false;
impl_->background_changed = true;
impl_->healthy = true;
impl_->visible_ticks = 0;
hidden_by_privacy = true;
if (!impl_->KeepHidden()) { Restore(); return false; }
impl_->UpdateImage();
impl_->panel.alphaValue = 1;
auto* state = impl_.get();
impl_->timer = [NSTimer timerWithTimeInterval:1.0 / 60 repeats:YES block:^(NSTimer*) {
if (state->healthy) state->healthy = state->KeepHidden();
state->UpdateImage();
}];
[[NSRunLoop mainRunLoop] addTimer:impl_->timer forMode:NSRunLoopCommonModes];
return true;
}
bool MacPrivacyCursor::Healthy() const { return impl_->timer && impl_->healthy; }
uint32_t MacPrivacyCursor::WindowId() const {
return impl_->panel ? static_cast<uint32_t>(impl_->panel.windowNumber) : 0;
}
void MacPrivacyCursor::Restore() {
[impl_->timer invalidate];
impl_->timer = nil;
[impl_->panel orderOut:nil];
[impl_->panel close];
impl_->panel = nil;
impl_->view = nil;
// Balance only our own successful hide calls, including re-hides after
// WindowServer changes cursor ownership when a remote click activates an app.
while (impl_->hide_count) {
CGDisplayShowCursor(kCGDirectMainDisplay);
--impl_->hide_count;
}
hidden_by_privacy = false;
if (impl_->background_changed) {
Api().set(impl_->connection, impl_->connection, kBackgroundCursor,
impl_->previous_background ? kCFBooleanTrue : kCFBooleanFalse);
impl_->background_changed = false;
}
}
bool IsMacPrivacyCursorHidden() { return hidden_by_privacy.load(); }
}
@@ -7,6 +7,12 @@ namespace crossdesk {
ScreenCapturerSck::ScreenCapturerSck() {}
ScreenCapturerSck::~ScreenCapturerSck() {}
void ScreenCapturerSck::SetPrivacyController(PrivacyController* privacy) {
privacy_ = privacy;
if (screen_capturer_sck_impl_)
screen_capturer_sck_impl_->SetPrivacyController(privacy);
}
int ScreenCapturerSck::Init(const int fps, cb_desktop_data cb) {
if (cb) {
on_data_ = cb;
@@ -16,6 +22,7 @@ int ScreenCapturerSck::Init(const int fps, cb_desktop_data cb) {
}
screen_capturer_sck_impl_ = CreateScreenCapturerSck();
screen_capturer_sck_impl_->SetPrivacyController(privacy_);
const int ret = screen_capturer_sck_impl_->Init(fps, on_data_);
if (ret != 0) {
screen_capturer_sck_impl_.reset();
@@ -25,6 +25,7 @@ class ScreenCapturerSck : public ScreenCapturer {
public:
int Init(const int fps, cb_desktop_data cb) override;
void SetPrivacyController(PrivacyController* privacy) override;
int Destroy() override;
int Start(bool show_cursor) override;
int Stop() override;
@@ -57,6 +58,7 @@ class ScreenCapturerSck : public ScreenCapturer {
private:
std::unique_ptr<ScreenCapturer> screen_capturer_sck_impl_;
PrivacyController* privacy_ = nullptr;
};
} // namespace crossdesk
#endif
#endif
@@ -26,6 +26,9 @@
#include "display_info.h"
#include <display_stream_id.h>
#include "rd_log.h"
#include "privacy_controller.h"
#include "../privacy/privacy_capture_state.h"
#include <algorithm>
using namespace crossdesk;
@@ -110,13 +113,18 @@ API_AVAILABLE(macos(14.0))
- (instancetype)initWithCapturer:(ScreenCapturerSckImpl *)capturer;
- (void)onShareableContentCreated:(SCShareableContent *)content;
- (void)onShareableContentCreated:(SCShareableContent *)content generation:(uint64_t)generation
revision:(uint64_t)revision;
// Called just before the capturer is destroyed. This avoids a dangling pointer,
// and prevents any new calls into a deleted capturer. If any method-call on the
// capturer is currently running on a different thread, this blocks until it
// completes.
- (void)releaseCapturer;
- (void)privacyCoversChanged:(NSNotification*)notification;
- (void)configurationFinished:(SCStream*)stream generation:(uint64_t)generation
revision:(uint64_t)revision complete:(BOOL)complete
configuration:(SCStreamConfiguration*)configuration error:(NSError*)error;
@end
class API_AVAILABLE(macos(14.0)) ScreenCapturerSckImpl : public ScreenCapturer {
@@ -129,6 +137,7 @@ class API_AVAILABLE(macos(14.0)) ScreenCapturerSckImpl : public ScreenCapturer {
public:
int Init(const int fps, cb_desktop_data cb) override;
void SetPrivacyController(PrivacyController* privacy) override { privacy_ = privacy; }
int Start(bool show_cursor) override;
@@ -156,11 +165,30 @@ class API_AVAILABLE(macos(14.0)) ScreenCapturerSckImpl : public ScreenCapturer {
bool capture_requested_ = false;
bool invalid_stream_id_logged_ = false;
bool native_output_logged_ = false;
PrivacyController* privacy_ = nullptr;
uint64_t configuration_generation_ = 0;
bool configuration_in_flight_ = false;
bool reconfigure_pending_ = false;
SCStreamConfiguration* __strong applied_configuration_ = nil;
public:
// Called by SckHelper when shareable content is returned by ScreenCaptureKit. `content` will be
// nil if an error occurred. May run on an arbitrary thread.
void OnShareableContentCreated(SCShareableContent *content);
void OnShareableContentCreated(SCShareableContent *content, uint64_t generation,
uint64_t revision);
void ConfigurationFinished(SCStream* source, uint64_t generation, uint64_t revision,
bool complete, SCStreamConfiguration* configuration,
NSError* error);
void RetryPrivacyFilter();
void PrivacyCoversChanged() { StartOrReconfigureCapturer(); }
void CaptureStopped(SCStream* source, NSError* error) {
{
std::lock_guard lock(lock_);
if (!capture_requested_ || source != stream_) return;
}
LOG_ERROR("ScreenCaptureKit stream stopped: {}", NSErrorToString(error));
if (privacy_) privacy_->Fail("ScreenCaptureKit stream stopped");
}
// Called by SckHelper to notify of a newly captured frame. May run on an arbitrary thread.
// void OnNewIOSurface(IOSurfaceRef io_surface, CFDictionaryRef attachment);
void OnNewCVPixelBuffer(CVPixelBufferRef pixelBuffer, CFDictionaryRef attachment);
@@ -389,6 +417,7 @@ int ScreenCapturerSckImpl::Start(bool show_cursor) {
capture_requested_ = true;
invalid_stream_id_logged_ = false;
}
if (privacy_) privacy_->CaptureChanged(true);
StartOrReconfigureCapturer();
return 0;
}
@@ -446,6 +475,7 @@ int ScreenCapturerSckImpl::ResetToInitialMonitor() {
}
int ScreenCapturerSckImpl::Destroy() {
if (privacy_) privacy_->CaptureChanged(false);
SckHelper *helper_to_release = nil;
{
std::lock_guard<std::mutex> lock(lock_);
@@ -455,6 +485,9 @@ int ScreenCapturerSckImpl::Destroy() {
stream_ = nil;
}
capture_requested_ = false;
configuration_in_flight_ = reconfigure_pending_ = false;
applied_configuration_ = nil;
++configuration_generation_;
current_display_ = 0;
current_stream_id_.clear();
permanent_error_ = false;
@@ -469,8 +502,12 @@ int ScreenCapturerSckImpl::Destroy() {
}
int ScreenCapturerSckImpl::Stop() {
if (privacy_) privacy_->CaptureChanged(false);
std::lock_guard<std::mutex> lock(lock_);
capture_requested_ = false;
configuration_in_flight_ = reconfigure_pending_ = false;
applied_configuration_ = nil;
++configuration_generation_;
if (stream_) {
LOG_INFO("Stopping stream");
[stream_ stopCaptureWithCompletionHandler:nil];
@@ -481,24 +518,36 @@ int ScreenCapturerSckImpl::Stop() {
return 0;
}
void ScreenCapturerSckImpl::OnShareableContentCreated(SCShareableContent *content) {
void ScreenCapturerSckImpl::OnShareableContentCreated(SCShareableContent *content,
uint64_t generation, uint64_t revision) {
{
std::lock_guard<std::mutex> lock(lock_);
if (!capture_requested_) {
if (!capture_requested_ || generation != configuration_generation_) {
LOG_INFO("Ignoring stale ScreenCaptureKit display refresh after stop");
return;
}
}
// The window list belongs to the privacy revision present when the request
// was issued. A newer cover must be enumerated by a new request.
if (GetMacPrivacyCaptureState().revision != revision) {
StartOrReconfigureCapturer();
return;
}
if (!content) {
LOG_ERROR("getShareableContent failed");
permanent_error_ = true;
if (privacy_) privacy_->Fail("Could not refresh ScreenCaptureKit privacy content");
std::lock_guard lock(lock_);
if (!stream_) permanent_error_ = true;
return;
}
if (!content.displays || content.displays.count == 0) {
LOG_ERROR("getShareableContent returned no displays");
permanent_error_ = true;
if (privacy_) privacy_->Fail("No shareable displays available for privacy");
std::lock_guard lock(lock_);
if (!stream_) permanent_error_ = true;
return;
}
@@ -506,7 +555,7 @@ void ScreenCapturerSckImpl::OnShareableContentCreated(SCShareableContent *conten
bool show_cursor = false;
{
std::lock_guard<std::mutex> lock(lock_);
if (!capture_requested_) return;
if (!capture_requested_ || generation != configuration_generation_) return;
int logical_index = current_monitor_index_;
if (logical_index < 0 ||
@@ -576,8 +625,40 @@ void ScreenCapturerSckImpl::OnShareableContentCreated(SCShareableContent *conten
return;
}
const auto privacy_capture = GetMacPrivacyCaptureState();
// The cursor sprite is included only when this stream embeds the cursor.
// Other controllers receive the unchanged cursor metadata separately.
auto excluded_ids = privacy_capture.excluded_windows;
if (!show_cursor && privacy_capture.cursor_window)
excluded_ids.push_back(privacy_capture.cursor_window);
NSMutableArray<SCWindow*>* excluded = [NSMutableArray array];
for (SCWindow* window in content.windows) {
if (std::find(excluded_ids.begin(), excluded_ids.end(), window.windowID) != excluded_ids.end())
[excluded addObject:window];
}
// Newly registered transparent covers may need another enumeration. Never
// acknowledge a partial exclusion or remove an active cover's filter.
const bool privacy_filter_complete = excluded.count == excluded_ids.size();
LOG_INFO("macOS privacy filter: generation={}, revision={}, requested={}, excluded={}",
generation, privacy_capture.revision, excluded_ids.size(),
excluded.count);
if (!privacy_filter_complete) {
bool have_stream;
{ std::lock_guard lock(lock_); have_stream = stream_ != nil; }
if (privacy_capture.applied_revision == privacy_capture.revision) {
if (privacy_) privacy_->Fail("ScreenCaptureKit could not enumerate every privacy cover");
return; // Preserve the existing exclusion until recovery removes covers.
}
if (have_stream) {
RetryPrivacyFilter();
return; // Transparent covers are still being registered by WindowServer.
}
// Start ordinary capture even if a newly created, still-transparent cover
// is not enumerable yet. Retry exclusion after start; never acknowledge it.
[excluded removeAllObjects];
}
SCContentFilter *filter = [[SCContentFilter alloc] initWithDisplay:captured_display
excludingWindows:@[]];
excludingWindows:excluded];
if (!filter) {
LOG_ERROR("Failed to create SCContentFilter");
permanent_error_ = true;
@@ -598,13 +679,39 @@ void ScreenCapturerSckImpl::OnShareableContentCreated(SCShareableContent *conten
config.captureResolution = SCCaptureResolutionAutomatic;
config.minimumFrameInterval = CMTimeMake(1, fps_);
std::lock_guard<std::mutex> lock(lock_);
if (!capture_requested_) return;
std::unique_lock<std::mutex> lock(lock_);
if (!capture_requested_ || generation != configuration_generation_) return;
if (configuration_in_flight_) { reconfigure_pending_ = true; return; }
if (GetMacPrivacyCaptureState().revision != revision) {
lock.unlock();
RetryPrivacyFilter();
return;
}
configuration_in_flight_ = true;
if (stream_) {
LOG_INFO("Updating stream configuration");
[stream_ updateContentFilter:filter completionHandler:nil];
[stream_ updateConfiguration:config completionHandler:nil];
SckHelper* helper = helper_;
SCStream* source = stream_;
auto finished = ^(NSError* error) {
[helper configurationFinished:source generation:generation revision:revision
complete:privacy_filter_complete configuration:config error:error];
};
// Apply configuration before its filter and acknowledge the final update.
// Privacy-only toggles do not need a configuration change.
const bool same_config = applied_configuration_ &&
applied_configuration_.width == config.width &&
applied_configuration_.height == config.height &&
applied_configuration_.showsCursor == config.showsCursor &&
applied_configuration_.pixelFormat == config.pixelFormat &&
CMTimeCompare(applied_configuration_.minimumFrameInterval, config.minimumFrameInterval) == 0;
if (same_config) {
[source updateContentFilter:filter completionHandler:finished];
} else {
[source updateConfiguration:config completionHandler:^(NSError* error) {
if (error) finished(error);
else [source updateContentFilter:filter completionHandler:finished];
}];
}
} else {
stream_ = [[SCStream alloc] initWithFilter:filter configuration:config delegate:helper_];
@@ -618,28 +725,62 @@ void ScreenCapturerSckImpl::OnShareableContentCreated(SCShareableContent *conten
error:&add_stream_output_error];
if (!add_stream_output_result) {
configuration_in_flight_ = false;
stream_ = nil;
LOG_ERROR("addStreamOutput failed: {}", NSErrorToString(add_stream_output_error));
permanent_error_ = true;
return;
}
SckHelper* helper = helper_;
SCStream* source = stream_;
auto handler = ^(NSError *error) {
if (error) {
// It should be safe to access `this` here, because the C++ destructor
// calls stopCaptureWithCompletionHandler on the stream, which cancels
// this handler.
permanent_error_ = true;
LOG_ERROR("startCaptureWithCompletionHandler failed: {}", NSErrorToString(error));
} else {
LOG_INFO("Capture started");
}
[helper configurationFinished:source generation:generation revision:revision
complete:privacy_filter_complete configuration:config error:error];
};
[stream_ startCaptureWithCompletionHandler:handler];
}
}
void ScreenCapturerSckImpl::RetryPrivacyFilter() {
SckHelper* helper;
{
std::lock_guard lock(lock_);
if (!capture_requested_) return;
helper = helper_;
}
// The retained helper safely ignores this callback after Destroy().
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 50 * NSEC_PER_MSEC),
dispatch_get_main_queue(), ^{ [helper privacyCoversChanged:nil]; });
}
void ScreenCapturerSckImpl::ConfigurationFinished(SCStream* source, uint64_t generation,
uint64_t revision, bool complete,
SCStreamConfiguration* configuration,
NSError* error) {
bool refresh = false;
{
std::lock_guard lock(lock_);
if (!capture_requested_ || source != stream_) return;
configuration_in_flight_ = false;
const auto current = GetMacPrivacyCaptureState();
refresh = reconfigure_pending_ || current.revision != revision;
reconfigure_pending_ = false;
if (!error) {
applied_configuration_ = configuration;
if (complete && !refresh && generation == configuration_generation_)
AcknowledgeMacPrivacyCapture(revision);
refresh = refresh || !complete;
}
LOG_INFO("macOS privacy filter completed: generation={}, revision={}, complete={}, refresh={}, error={}",
generation, revision, complete, refresh, NSErrorToString(error));
}
if (error && privacy_)
privacy_->Fail("ScreenCaptureKit privacy filter: " + NSErrorToString(error));
if (refresh) RetryPrivacyFilter();
}
void ScreenCapturerSckImpl::OnNewCVPixelBuffer(CVPixelBufferRef pixelBuffer,
CFDictionaryRef attachment) {
(void)attachment;
@@ -727,7 +868,18 @@ void ScreenCapturerSckImpl::StartOrReconfigureCapturer() {
}
}
SckHelper *local_helper = helper_;
SckHelper *local_helper;
uint64_t generation;
uint64_t revision;
{
std::lock_guard lock(lock_);
if (!capture_requested_) return;
if (configuration_in_flight_) { reconfigure_pending_ = true; return; }
reconfigure_pending_ = false;
local_helper = helper_;
generation = ++configuration_generation_;
revision = GetMacPrivacyCaptureState().revision;
}
if (!local_helper) {
LOG_ERROR("Cannot reconfigure capturer: helper is null");
return;
@@ -736,12 +888,14 @@ void ScreenCapturerSckImpl::StartOrReconfigureCapturer() {
auto handler = ^(SCShareableContent *content, NSError *error) {
if (error) {
LOG_ERROR("getShareableContent failed: {}", NSErrorToString(error));
[local_helper onShareableContentCreated:nil];
[local_helper onShareableContentCreated:nil generation:generation revision:revision];
return;
}
[local_helper onShareableContentCreated:content];
[local_helper onShareableContentCreated:content generation:generation revision:revision];
};
[SCShareableContent getShareableContentWithCompletionHandler:handler];
[SCShareableContent getShareableContentExcludingDesktopWindows:NO
onScreenWindowsOnly:NO
completionHandler:handler];
}
@implementation SckHelper {
@@ -755,14 +909,28 @@ void ScreenCapturerSckImpl::StartOrReconfigureCapturer() {
self = [super init];
if (self) {
_capturer = capturer;
[NSNotificationCenter.defaultCenter addObserver:self selector:@selector(privacyCoversChanged:)
name:@"CrossDeskPrivacyCoversChanged" object:nil];
}
return self;
}
- (void)onShareableContentCreated:(SCShareableContent *)content {
- (void)privacyCoversChanged:(NSNotification*)notification {
std::lock_guard<std::mutex> lock(_capturer_lock);
if (_capturer) _capturer->PrivacyCoversChanged();
}
- (void)configurationFinished:(SCStream*)stream generation:(uint64_t)generation
revision:(uint64_t)revision complete:(BOOL)complete
configuration:(SCStreamConfiguration*)configuration error:(NSError*)error {
std::lock_guard<std::mutex> lock(_capturer_lock);
if (_capturer)
_capturer->ConfigurationFinished(stream, generation, revision, complete, configuration, error);
}
- (void)onShareableContentCreated:(SCShareableContent *)content generation:(uint64_t)generation
revision:(uint64_t)revision {
std::lock_guard<std::mutex> lock(_capturer_lock);
if (_capturer) {
_capturer->OnShareableContentCreated(content);
_capturer->OnShareableContentCreated(content, generation, revision);
} else {
LOG_ERROR("Invalid capturer");
}
@@ -797,10 +965,16 @@ void ScreenCapturerSckImpl::StartOrReconfigureCapturer() {
}
- (void)releaseCapturer {
[NSNotificationCenter.defaultCenter removeObserver:self];
std::lock_guard<std::mutex> lock(_capturer_lock);
_capturer = nullptr;
}
- (void)stream:(SCStream*)stream didStopWithError:(NSError*)error {
std::lock_guard<std::mutex> lock(_capturer_lock);
if (_capturer) _capturer->CaptureStopped(stream, error);
}
@end
std::unique_ptr<ScreenCapturer> ScreenCapturerSck::CreateScreenCapturerSck() {
@@ -115,8 +115,7 @@ void GuiRuntime::HandleWindowsServiceIntegration() {
bool force_broadcast = false;
if (pending_windows_service_sas_.exchange(false, std::memory_order_relaxed)) {
if (privacy_.Engaged()) {
privacy_.Fail("Queued security shortcut cancelled during privacy mode; remote operation paused");
return;
privacy_.Fail("Security shortcut requested; privacy screen will turn off");
}
const std::string response =
QueryCrossDeskService("sas", kWindowsServiceSasTimeoutMs);
@@ -11,7 +11,6 @@
#include <wtsapi32.h>
#include <algorithm>
#include <cstring>
#include <vector>
#include "../windows_thread_dpi.h"
@@ -19,8 +18,6 @@
#include "privacy_band_guard.h"
#include "privacy_cursor_guard.h"
#include "privacy_input_guard.h"
#include "privacy_probe_pattern.h"
#include "privacy_probe_renderer.h"
#include "privacy_window_visibility.h"
#include "rd_log.h"
@@ -118,7 +115,7 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
if (!PrivacyBandGuard::Available(high_band_error))
return {false, false, high_band_error};
return {true, true,
"High-band privacy; live capture exclusion will be verified"};
"High-band privacy screen available"};
}
bool Enable(bool block_input, const PrivacyScreenText& text,
@@ -169,19 +166,9 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
std::remove(window_events_.begin(), window_events_.end(), nullptr),
window_events_.end());
if (window_events_.empty()) event_owner_ = nullptr;
// Remove verification panels before exposing the local desktop.
for (auto& window : windows_) {
Destroy(window.probe);
}
std::string band_error;
band_guard_.Stop(band_error);
for (auto& window : windows_) {
if (band_guard_.stopped()) window.cover = nullptr;
}
windows_.erase(
std::remove_if(windows_.begin(), windows_.end(),
[](const auto& w) { return !w.cover && !w.probe; }),
windows_.end());
if (band_guard_.stopped()) windows_.clear();
input_guard_.Stop();
block_input_ = false;
if (hotkey_) {
@@ -231,12 +218,12 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
health_error_ = Error("MsgWaitForMultipleObjectsEx privacy thread");
}
PrivacyHealth Poll(bool force_check = false) override {
PrivacyHealth Poll() override {
MSG message{};
while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) {
if (message.message == WM_QUIT)
health_error_ =
"Privacy message thread is exiting; remote operation paused";
"Privacy message thread is exiting";
TranslateMessage(&message);
DispatchMessageW(&message);
}
@@ -251,7 +238,7 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
if (windows_.empty()) return {health_error_, false};
// Input messages wake the thread immediately; expensive health queries
// must retain their own cadence instead of running once per input event.
if (!force_check && !layout_changed_ &&
if (!layout_changed_ &&
GetTickCount64() - last_check_ < 100) {
if (coverage_dirty_ && last_health_.failure.empty()) {
coverage_dirty_ = false;
@@ -273,8 +260,8 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
std::string cursor_error;
cursor_guard_.Stop(cursor_error);
return {
"Secure desktop, lock screen or unavailable input desktop; remote "
"operation paused. Privacy windows cannot cover system security UI.",
"Secure desktop, lock screen or unavailable input desktop; "
"privacy screen will turn off",
false};
}
std::string cursor_error;
@@ -283,8 +270,7 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
if (FAILED(SHQueryUserNotificationState(&shell_state)) ||
shell_state == QUNS_RUNNING_D3D_FULL_SCREEN) {
return {
"Exclusive fullscreen or unknown display presentation; remote "
"operation paused",
"Exclusive fullscreen or unknown display presentation",
false};
}
MonitorEnumeration monitors;
@@ -304,16 +290,14 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
});
if (changed || layout_changed_) {
layout_changed_ = false;
// Retain old covers until new monitor regions are covered. Never
// automatically resume remote work after a topology/DPI transition.
// Retain old covers until the monitor check completes, then release
// privacy so capture can refresh its display mapping independently.
std::string error;
if (!ReconcileMonitors(error))
health_error_ = error;
else
health_error_ =
"Display layout/DPI changed; coverage rebuilt, remote operation "
"paused. Turn privacy off and reconnect to refresh display "
"mapping.";
"Display layout/DPI changed; privacy screen will turn off";
}
if (!health_error_.empty()) return {health_error_, false};
BOOL composed = FALSE;
@@ -349,8 +333,7 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
sizeof(cloaked))) ||
cloaked) {
return {
"Privacy window visibility, geometry or capture affinity was lost; "
"remote operation paused",
"Privacy window visibility, geometry or capture affinity was lost",
false};
}
}
@@ -369,7 +352,7 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
if (above_band == 0 || above_band == 1) continue;
if (std::any_of(windows_.begin(), windows_.end(),
[above](const auto& own) {
return above == own.cover || above == own.probe;
return above == own.cover;
}))
continue;
RECT bounds{}, intersection{};
@@ -390,8 +373,7 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
now - last_obstruction_log_ >= 1000) {
LOG_WARN(
"Potential privacy cover overlap: class={}, process={}, "
"band={}, bounds=({}, {})-({}, {}), exstyle=0x{:x}; "
"remote operation paused",
"band={}, bounds=({}, {})-({}, {}), exstyle=0x{:x}",
window_class, process_id, above_band, bounds.left, bounds.top,
bounds.right, bounds.bottom,
GetWindowLongPtrW(above, GWL_EXSTYLE));
@@ -399,10 +381,9 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
last_obstruction_log_ = now;
}
// HWND order cannot prove compositor order, but this window may
// display content. Retain protection and require explicit recovery.
// display content. Report failure so optional privacy is released.
return {
"A visible window may overlap privacy coverage; "
"remote operation paused. Turn privacy off to recover.",
"A visible window may overlap privacy coverage",
false};
}
}
@@ -410,100 +391,11 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
return {health_error_, false};
}
public:
bool PaintChallenge(uint32_t value, const std::string& title,
std::string& error) override {
using Pattern = PrivacyProbePattern;
for (auto& w : windows_) {
if (!w.probe && !CreateProbe(w, error)) return false;
PrivacyProbeImage image;
if (!RenderPrivacyProbe(title, PrivacyWindowDpi(w.probe),
w.rect.right - w.rect.left - 2 * Pattern::kOffset,
w.rect.bottom - w.rect.top - 2 * Pattern::kOffset,
value, image, error)) {
LOG_ERROR("Privacy verification panel: {}", error);
return false;
}
HDC screen = GetDC(nullptr);
HDC memory = CreateCompatibleDC(screen);
BITMAPINFO info{};
info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
info.bmiHeader.biWidth = image.width;
info.bmiHeader.biHeight = -image.height;
info.bmiHeader.biPlanes = 1;
info.bmiHeader.biBitCount = 32;
info.bmiHeader.biCompression = BI_RGB;
void* bits = nullptr;
HBITMAP bitmap =
CreateDIBSection(screen, &info, DIB_RGB_COLORS, &bits, nullptr, 0);
bool ok = screen && memory && bitmap && bits;
if (ok) {
std::memcpy(bits, image.pixels.data(),
image.pixels.size() * sizeof(uint32_t));
const auto old = SelectObject(memory, bitmap);
POINT position{w.rect.left + Pattern::kOffset,
w.rect.top + Pattern::kOffset},
source{};
SIZE size{image.width, image.height};
ok = old && old != HGDI_ERROR;
if (ok) {
ok = UpdateLayeredWindow(w.probe, screen, &position, &size, memory,
&source, 0, nullptr, ULW_OPAQUE);
SelectObject(memory, old);
}
}
if (!ok) error = Error("Present opaque privacy verification panel");
if (bitmap) DeleteObject(bitmap);
if (memory) DeleteDC(memory);
if (screen) ReleaseDC(nullptr, screen);
if (!ok) return false;
// Present only after the complete text and square bitmap is ready.
if (!IsWindowVisible(w.probe) &&
(!SetWindowPos(
w.probe, HWND_TOPMOST, 0, 0, 0, 0,
SWP_NOACTIVATE | SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW) ||
!IsWindowVisible(w.probe))) {
error = Error("Show privacy verification panel beneath cover");
return false;
}
}
return true;
}
bool ClearChallenges(std::string& error) override {
for (auto& w : windows_) {
Destroy(w.probe);
if (w.probe) {
error = "Could not remove the temporary capture verification window";
return false;
}
}
return true;
}
bool HasMonitor(int left, int top, int width, int height) const override {
// Metadata only; also serialized against Poll by PrivacyController.
return std::any_of(windows_.begin(), windows_.end(), [&](const auto& w) {
return w.rect.left == left && w.rect.top == top &&
w.rect.right - left == width && w.rect.bottom - top == height;
});
}
private:
struct Window {
RECT rect{};
HWND cover = nullptr;
HWND probe = nullptr;
};
static void Destroy(HWND& hwnd) {
if (hwnd && IsWindow(hwnd) && !DestroyWindow(hwnd)) {
Error("DestroyWindow");
return;
}
hwnd = nullptr;
}
bool Initialize(std::string& error) {
if (sink_) return true;
// This dedicated thread owns no existing application windows. Never alter
@@ -537,22 +429,6 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
return true;
}
bool CreateProbe(Window& w, std::string& error) {
constexpr DWORD styles = WS_EX_TOPMOST | WS_EX_TOOLWINDOW |
WS_EX_NOACTIVATE | WS_EX_LAYERED |
WS_EX_TRANSPARENT;
w.probe =
CreateWindowExW(styles, kClassName, L"CrossDesk capture verification",
WS_POPUP, w.rect.left + PrivacyProbePattern::kOffset,
w.rect.top + PrivacyProbePattern::kOffset, 1, 1,
nullptr, nullptr, GetModuleHandleW(nullptr), this);
if (!w.probe) {
error = Error("CreateWindowExW capture challenge");
return false;
}
return true;
}
bool ReconcileMonitors(std::string& error) {
MonitorEnumeration monitors;
if (!EnumDisplayMonitors(nullptr, nullptr, EnumMonitor,
@@ -576,26 +452,18 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
error = "High-band broker has not covered every physical monitor";
return false;
}
windows_.push_back({rect, reinterpret_cast<HWND>(cover->hwnd), nullptr});
windows_.push_back({rect, reinterpret_cast<HWND>(cover->hwnd)});
LOG_INFO(
"Privacy high-band cover ready: bounds=({}, {})-({}, {}), "
"affinity=0x11, alpha=255",
rect.left, rect.top, rect.right, rect.bottom);
}
for (auto it = windows_.begin(); it != windows_.end();) {
if (std::none_of(monitors.rects.begin(), monitors.rects.end(),
[&](const RECT& r) { return SameRect(it->rect, r); })) {
Destroy(it->probe);
it->cover = nullptr; // The broker owns cover destruction.
if (!it->probe)
it = windows_.erase(it);
else {
error = "Old privacy window could not be released";
return false;
}
} else
++it;
}
// Cover windows are owned and released by the broker.
windows_.erase(std::remove_if(windows_.begin(), windows_.end(),
[&](const auto& window) {
return std::none_of(monitors.rects.begin(), monitors.rects.end(),
[&](const RECT& rect) { return SameRect(window.rect, rect); });
}), windows_.end());
return true;
}
@@ -619,14 +487,6 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
}
if (msg == WM_NCHITTEST) return HTTRANSPARENT;
if (msg == WM_CLOSE) return 0;
if (msg == WM_PAINT) {
// UpdateLayeredWindow owns the complete probe image; WM_PAINT only
// validates the update region and never draws a second copy.
PAINTSTRUCT paint{};
BeginPaint(hwnd, &paint);
EndPaint(hwnd, &paint);
return 0;
}
if ((msg == WM_DISPLAYCHANGE || msg == WM_DPICHANGED) && self) {
self->layout_changed_ = true;
return 0;
@@ -641,7 +501,7 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
hwnd == self->sink_ || GetAncestor(hwnd, GA_ROOT) != hwnd)
return;
for (const auto& w : self->windows_)
if (hwnd == w.cover || hwnd == w.probe) return;
if (hwnd == w.cover) return;
// OUTOFCONTEXT delivers on this window thread. Only mark work here;
// monitor/session queries and Z-order changes remain in the control loop.
const auto get_band = reinterpret_cast<GetPrivacyWindowBand>(
@@ -135,7 +135,7 @@ bool PrivacyCursorGuard::Healthy(std::string& error) {
if (restore_pending_ || (process_ && !helper_failure_reported_))
RestoreAfterFailure(error);
helper_failure_reported_ = true;
error = "System cursor helper exited; remote operation paused";
error = "System cursor helper exited; privacy screen will turn off";
return false;
}
@@ -19,7 +19,7 @@
namespace crossdesk {
// The owner controls lifecycle; the dedicated message thread owns LL hooks.
// Rendering, capture verification and session queries never run on this thread.
// Rendering and session queries never run on this thread.
class PrivacyInputGuard {
public:
~PrivacyInputGuard();
@@ -1,142 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-11
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#include "privacy_probe_renderer.h"
#include <algorithm>
#include "privacy_probe_pattern.h"
namespace crossdesk {
namespace {
struct Canvas {
HDC dc = CreateCompatibleDC(nullptr);
HFONT font = nullptr;
HGDIOBJ old_font = nullptr;
HBITMAP bitmap = nullptr;
HGDIOBJ old_bitmap = nullptr;
~Canvas() {
if (old_bitmap) SelectObject(dc, old_bitmap);
if (old_font) SelectObject(dc, old_font);
if (bitmap) DeleteObject(bitmap);
if (font) DeleteObject(font);
if (dc) DeleteDC(dc);
}
};
bool Failed(const char* operation, std::string& error) {
error = std::string(operation) + " (Windows error " +
std::to_string(GetLastError()) + ")";
return false;
}
} // namespace
bool RenderPrivacyProbe(const std::string& title, UINT dpi, int maximum_width,
int maximum_height, uint32_t value,
PrivacyProbeImage& image, std::string& error) {
using Pattern = PrivacyProbePattern;
const int length =
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, title.data(),
static_cast<int>(title.size()), nullptr, 0);
if (length <= 0) return Failed("Decode privacy verification text", error);
std::wstring text(length, L'\0');
if (!MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, title.data(),
static_cast<int>(title.size()), text.data(), length))
return Failed("Decode privacy verification text", error);
const int available_text_width =
maximum_width - Pattern::kTextLeft - Pattern::kPadding;
if (available_text_width < 80 ||
maximum_height < Pattern::kSide + 2 * Pattern::kPadding) {
error = "Display is too small for the privacy verification panel";
return false;
}
Canvas canvas;
if (!canvas.dc)
return Failed("Create privacy verification drawing context", error);
canvas.font = CreateFontW(
-MulDiv(28, static_cast<int>(dpi), 96), 0, 0, 0, FW_SEMIBOLD, FALSE,
FALSE, FALSE, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS,
ANTIALIASED_QUALITY, DEFAULT_PITCH, L"Microsoft YaHei UI");
if (!canvas.font) return Failed("Create privacy verification font", error);
canvas.old_font = SelectObject(canvas.dc, canvas.font);
if (!canvas.old_font || canvas.old_font == HGDI_ERROR) {
canvas.old_font = nullptr;
return Failed("Select privacy verification font", error);
}
SIZE extent{};
if (!GetTextExtentPoint32W(canvas.dc, text.data(), length, &extent))
return Failed("Measure privacy verification text", error);
const int text_width =
(std::min)(available_text_width,
(std::min)(static_cast<int>(extent.cx) + 4,
MulDiv(520, static_cast<int>(dpi), 96)));
RECT text_bounds{0, 0, text_width, 0};
constexpr UINT text_flags = DT_LEFT | DT_WORDBREAK | DT_NOPREFIX;
if (!DrawTextW(canvas.dc, text.data(), length, &text_bounds,
text_flags | DT_CALCRECT))
return Failed("Lay out privacy verification text", error);
const int width = Pattern::kTextLeft + text_width + Pattern::kPadding;
const int height =
(std::max)(Pattern::kSide, static_cast<int>(text_bounds.bottom)) +
2 * Pattern::kPadding;
if (height > maximum_height || text_bounds.right > text_width) {
error =
"Display is too small for localized privacy verification text at this "
"DPI";
return false;
}
BITMAPINFO info{};
info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
info.bmiHeader.biWidth = width;
info.bmiHeader.biHeight = -height;
info.bmiHeader.biPlanes = 1;
info.bmiHeader.biBitCount = 32;
info.bmiHeader.biCompression = BI_RGB;
void* bits = nullptr;
canvas.bitmap =
CreateDIBSection(canvas.dc, &info, DIB_RGB_COLORS, &bits, nullptr, 0);
if (!canvas.bitmap || !bits)
return Failed("Create privacy verification image", error);
canvas.old_bitmap = SelectObject(canvas.dc, canvas.bitmap);
if (!canvas.old_bitmap || canvas.old_bitmap == HGDI_ERROR) {
canvas.old_bitmap = nullptr;
return Failed("Select privacy verification image", error);
}
auto pixels = static_cast<uint32_t*>(bits);
std::fill(pixels, pixels + static_cast<size_t>(width) * height, 0xFFF4F6F9u);
for (int y = 0; y < height; ++y)
for (int x = 0; x < width; ++x)
if (x == 0 || y == 0 || x == width - 1 || y == height - 1)
pixels[static_cast<size_t>(y) * width + x] = 0xFFD0D6E0u;
SetBkMode(canvas.dc, TRANSPARENT);
SetTextColor(canvas.dc, RGB(34, 46, 64));
RECT text_rect{Pattern::kTextLeft, (height - text_bounds.bottom) / 2,
width - Pattern::kPadding, (height + text_bounds.bottom) / 2};
// Explicit clipping makes font fallback/overhang unable to reach the code.
IntersectClipRect(canvas.dc, text_rect.left, text_rect.top, text_rect.right,
text_rect.bottom);
if (!DrawTextW(canvas.dc, text.data(), length, &text_rect, text_flags))
return Failed("Draw privacy verification text", error);
if (!GdiFlush()) return Failed("Flush privacy verification text", error);
for (size_t index = 0; index < static_cast<size_t>(width) * height; ++index)
pixels[index] |= 0xFF000000u;
for (int row = 0; row < Pattern::kRows; ++row)
for (int column = 0; column < Pattern::kColumns; ++column) {
const uint32_t color =
Pattern::White(value, column, row) ? 0xFFFFFFFFu : 0xFF000000u;
for (int y = 0; y < Pattern::kCell; ++y)
for (int x = 0; x < Pattern::kCell; ++x)
pixels[static_cast<size_t>(Pattern::kPadding + row * Pattern::kCell +
y) *
width +
Pattern::kPadding + column * Pattern::kCell + x] = color;
}
image.width = width;
image.height = height;
image.pixels.assign(pixels, pixels + static_cast<size_t>(width) * height);
return true;
}
} // namespace crossdesk
@@ -1,32 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-11
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _PRIVACY_PROBE_RENDERER_H_
#define _PRIVACY_PROBE_RENDERER_H_
#include <Windows.h>
#include <cstdint>
#include <string>
#include <vector>
namespace crossdesk {
struct PrivacyProbeImage {
int width = 0;
int height = 0;
std::vector<uint32_t> pixels;
};
// Produces an opaque BGRA panel. The localized text and the machine-readable
// square occupy disjoint rectangles. Caller runs on the owning window thread.
bool RenderPrivacyProbe(const std::string& title, UINT dpi, int maximum_width,
int maximum_height, uint32_t value,
PrivacyProbeImage& image, std::string& error);
} // namespace crossdesk
#endif // _PRIVACY_PROBE_RENDERER_H_
@@ -330,8 +330,7 @@ void ScreenCapturerDxgi::CaptureLoop() {
timeout_ms, &frame_info, desktop_resource.GetAddressOf());
if (hr == DXGI_ERROR_WAIT_TIMEOUT) {
// DXGI explicitly reports an unchanged desktop. Re-emit only a frame
// successfully acquired for this monitor, so a static screen still has
// a delivery heartbeat after temporary privacy probes are removed.
// successfully acquired for this monitor to keep delivering static frames.
if (cached_frame_valid && cached_generation == duplication_generation_ &&
cached_monitor == monitor_index_.load() && callback_ && nv12_frame_) {
const auto stream_id = MakeDisplayStreamId(cached_monitor);
@@ -342,7 +341,7 @@ void ScreenCapturerDxgi::CaptureLoop() {
continue;
}
// Never replay a cached image after an acquisition/conversion failure or
// a backend rebuild. Normal error/reset handling must pause privacy.
// a backend rebuild. Normal error/reset handling releases optional privacy.
cached_frame_valid = false;
if (FAILED(hr)) {
LOG_ERROR("DXGI: AcquireNextFrame failed, hr={}", (int)hr);
@@ -339,7 +339,7 @@ ScreenCapturerWin::~ScreenCapturerWin() { Destroy(); }
void ScreenCapturerWin::NotifyPrivacyCapture(bool running) {
if (privacy_)
privacy_->CaptureChanged(CaptureBackendName(impl_.get()), running);
privacy_->CaptureChanged(running);
}
int ScreenCapturerWin::Init(const int fps, cb_desktop_data cb) {
@@ -358,7 +358,7 @@ int ScreenCapturerWin::Init(const int fps, cb_desktop_data cb) {
const char* reported_stream_id,
const MiniRtcNativeVideoFrame* native_frame) {
if (size == ScreenCapturer::kBackendReset) {
if (privacy_) privacy_->CaptureInterrupted();
if (privacy_) privacy_->Fail("Capture restarted; privacy screen will turn off");
return;
}
if (secure_desktop_capture_active_.load(std::memory_order_relaxed)) {
@@ -367,7 +367,6 @@ int ScreenCapturerWin::Init(const int fps, cb_desktop_data cb) {
const char* raw_stream_id = reported_stream_id ? reported_stream_id : "";
std::string mapped_stream_id;
DisplayInfo privacy_display{"", 0, 0, 0, 0};
{
std::lock_guard<std::mutex> lock(alias_mutex_);
auto it = stream_id_alias_.find(raw_stream_id);
@@ -381,21 +380,9 @@ int ScreenCapturerWin::Init(const int fps, cb_desktop_data cb) {
mapped_stream_id = ResolveDisplayStreamId(
mapped_stream_id.c_str(), canonical_displays_.size(),
monitor_index_.load(std::memory_order_relaxed));
for (size_t index = 0; index < canonical_displays_.size(); ++index) {
if (mapped_stream_id == MakeDisplayStreamId(index)) {
privacy_display = canonical_displays_[index];
break;
}
}
}
if (privacy_) {
if (privacy_->Engaged() && !IsWindowsPrivacyDesktopAvailable()) {
privacy_->Fail("Secure or unavailable desktop; privacy cannot cover system security UI. Remote operation paused.");
return;
}
if (!privacy_->ObserveFrame(data, size > 0 ? static_cast<size_t>(size) : 0,
w, h, privacy_display.left, privacy_display.top,
privacy_display.width, privacy_display.height)) return;
if (privacy_ && privacy_->Engaged() && !IsWindowsPrivacyDesktopAvailable()) {
privacy_->Fail("Secure or unavailable desktop; privacy screen will turn off");
}
if (mapped_stream_id.empty()) {
if (!invalid_stream_id_logged_.exchange(true,
@@ -477,13 +464,12 @@ void ScreenCapturerWin::EmitCapturedFrame(
unsigned char* data, int size, int width, int height,
const char* stream_id, const MiniRtcNativeVideoFrame* native_frame,
bool from_secure_desktop) {
// A helper IPC already in flight when enable began must not bypass the
// verified ordinary-desktop callback when it completes later.
// Secure-desktop capture takes priority; remove privacy without dropping
// the helper frame or delaying the remote session.
if (from_secure_desktop && privacy_ && privacy_->Engaged()) {
privacy_->Fail("Secure desktop helper frame arrived during privacy; remote operation paused");
return;
privacy_->Fail("Secure desktop helper frame arrived; privacy screen will turn off");
}
if (!cb_orig_ || (privacy_ && !privacy_->RemoteAllowed())) {
if (!cb_orig_) {
return;
}
@@ -650,12 +636,11 @@ int ScreenCapturerWin::Resume(int monitor_index) {
int ScreenCapturerWin::SwitchTo(int monitor_index) {
if (!impl_) return -1;
if (privacy_) privacy_->DisplayChanging();
const int ret = impl_->SwitchTo(monitor_index);
if (ret == 0) {
monitor_index_.store(monitor_index, std::memory_order_relaxed);
} else if (privacy_) {
privacy_->Fail("Display switch failed; remote operation paused");
privacy_->Fail("Display switch failed; privacy screen will turn off");
}
return ret;
}
@@ -1128,13 +1113,11 @@ void ScreenCapturerWin::SecureDesktopCaptureLoop() {
std::vector<uint8_t> secure_frame;
while (running_.load(std::memory_order_relaxed)) {
// A privacy session must NEVER continue using the helper's secure-desktop
// GDI frames. Its HWNDs belong to the ordinary interactive desktop only.
// Privacy covers belong to the ordinary desktop. Drop privacy on a
// secure desktop, while continuing the normal helper capture path.
if (privacy_ && privacy_->Engaged()) {
if (!IsWindowsPrivacyDesktopAvailable())
privacy_->Fail("Secure desktop or lock screen; remote operation paused. Privacy cannot cover Windows security UI.");
std::this_thread::sleep_for(std::chrono::milliseconds(25));
continue;
privacy_->Fail("Secure desktop or lock screen; privacy screen will turn off. Privacy cannot cover Windows security UI.");
}
if (paused_.load(std::memory_order_relaxed)) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
@@ -45,7 +45,7 @@ class ScreenCapturerWin : public ScreenCapturer {
int ResetToInitialMonitor() override;
std::vector<DisplayInfo> GetDisplayInfoList() override;
void SetPrivacyController(PrivacyController* privacy) { privacy_ = privacy; }
void SetPrivacyController(PrivacyController* privacy) override { privacy_ = privacy; }
// Set before Init(), while capture is stopped.
void SetCaptureMethod(ScreenCaptureMethod method) { capture_method_ = method; }