mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-13 20:25:53 +08:00
[feat] add macOS privacy screen and keep remote sessions running
This commit is contained in:
1 parent
23bf2ff8b8
commit
a6e325f190
38 files changed
+1161
-1046
No files matched your search
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user