[feat] add privacy unlock controls and remote relocking

This commit is contained in:
dijunkun
2026-10-27 10:22:25 +08:00
parent d1e4a31efb
commit ea3a2e1faa
41 changed files with 470 additions and 72 deletions
@@ -63,10 +63,12 @@ class LinuxPrivacyBackend final : public PrivacyBackend {
: "Checking Linux privacy support"};
}
bool Enable(bool block_input, const PrivacyScreenText&,
bool Enable(bool block_input, const PrivacyScreenText& text,
std::string& error) override {
failure_.clear();
emergency_ = false;
UpdateText(text);
if (!failure_.empty()) { error = failure_; return false; }
if (!Send(block_input ? LinuxPrivacyCommand::enable_blocking
: LinuxPrivacyCommand::enable)) {
error = failure_;
@@ -80,6 +82,11 @@ class LinuxPrivacyBackend final : public PrivacyBackend {
return true;
}
void UpdateText(const PrivacyScreenText& text) override {
if (socket_ >= 0 && !Send(text.allow_local_unlock ? LinuxPrivacyCommand::allow_local_unlock
: LinuxPrivacyCommand::forbid_local_unlock))
failure_ = "Cannot update the local privacy unlock setting";
}
void Recover() override {
ReadReplies();
if (recovered_) {
@@ -129,6 +129,8 @@ class NativePrivacy {
"blocking is unavailable");
}
void SetLocalUnlockAllowed(bool allowed) { allow_local_unlock_ = allowed; }
void Enable(bool block_input) {
++reply_.generation;
reply_.failure[0] = '\0';
@@ -570,7 +572,7 @@ class NativePrivacy {
shift |= symbol == XK_Shift_L || symbol == XK_Shift_R;
}
}
if (down && ctrl && alt && shift &&
if (allow_local_unlock_ && down && ctrl && alt && shift &&
XkbKeycodeToKeysym(display_, keycode, 0, 0) == XK_F12) {
reply_.emergency = true;
Recover();
@@ -624,6 +626,7 @@ class NativePrivacy {
std::vector<Window> hidden_roots_;
std::map<int, std::array<bool, 256>> keys_;
LinuxPrivacyReply reply_;
bool allow_local_unlock_ = true;
Clock::time_point checked_{};
};
} // namespace
@@ -676,6 +679,12 @@ int RunLinuxPrivacyGuard() {
case LinuxPrivacyCommand::recover:
privacy.Recover();
break;
case LinuxPrivacyCommand::allow_local_unlock:
privacy.SetLocalUnlockAllowed(true);
break;
case LinuxPrivacyCommand::forbid_local_unlock:
privacy.SetLocalUnlockAllowed(false);
break;
case LinuxPrivacyCommand::heartbeat:
break;
default:
@@ -22,6 +22,8 @@ enum class LinuxPrivacyCommand : uint8_t {
enable_blocking,
recover,
heartbeat,
allow_local_unlock,
forbid_local_unlock,
};
struct LinuxPrivacyReply {
@@ -200,6 +200,7 @@ class MacPrivacyBackend final : public PrivacyBackend {
const auto caps = Query();
if (!caps.overlay) { error = caps.reason; return false; }
block_input_ = block_input;
allow_local_unlock_ = text.allow_local_unlock;
emergency_ = false;
tap_failed_ = false;
CGEventMask mask = CGEventMaskBit(kCGEventKeyDown) | CGEventMaskBit(kCGEventKeyUp) |
@@ -239,7 +240,8 @@ class MacPrivacyBackend final : public PrivacyBackend {
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()]);
[NSString stringWithUTF8String:(text.allow_local_unlock ? text.unlock_hint : text.locked_hint).c_str()],
text.allow_local_unlock);
if (!content) {
state->failure = "Could not load the CrossDesk privacy screen logo";
break;
@@ -260,6 +262,17 @@ class MacPrivacyBackend final : public PrivacyBackend {
});
return true;
}
void UpdateText(const PrivacyScreenText& text) override {
allow_local_unlock_ = text.allow_local_unlock;
auto state = state_;
OnMain([state, text] {
std::lock_guard lock(state->mutex);
for (const auto& monitor : state->monitors)
UpdateMacPrivacyCoverView(monitor.cover.contentView,
[NSString stringWithUTF8String:(text.allow_local_unlock ? text.unlock_hint : text.locked_hint).c_str()],
text.allow_local_unlock);
});
}
void Recover() override {
if (tap_) { CGEventTapEnable(tap_, false); CFMachPortInvalidate(tap_); }
if (tap_source_) {
@@ -359,7 +372,7 @@ class MacPrivacyBackend final : public PrivacyBackend {
CGEventGetIntegerValueField(event, kCGEventSourceUnixProcessID) == getpid()) return event;
const auto flags = CGEventGetFlags(event);
const auto chord = kCGEventFlagMaskControl | kCGEventFlagMaskAlternate | kCGEventFlagMaskShift;
if (type == kCGEventKeyDown && (flags & chord) == chord &&
if (self->allow_local_unlock_ && type == kCGEventKeyDown && (flags & chord) == chord &&
CGEventGetIntegerValueField(event, kCGKeyboardEventKeycode) == 53) {
self->emergency_ = true;
return nullptr;
@@ -372,6 +385,7 @@ class MacPrivacyBackend final : public PrivacyBackend {
CFMachPortRef tap_ = nullptr;
CFRunLoopSourceRef tap_source_ = nullptr;
bool block_input_ = false;
bool allow_local_unlock_ = true;
bool emergency_ = false;
bool tap_failed_ = false;
};
@@ -11,7 +11,8 @@
namespace crossdesk {
// Returns nil if the CrossDesk icon resource cannot be loaded.
NSView* CreateMacPrivacyCoverView(NSRect frame, NSString* unlock_hint);
NSView* CreateMacPrivacyCoverView(NSRect frame, NSString* hint, bool allow_local_unlock = true);
void UpdateMacPrivacyCoverView(NSView* view, NSString* hint, bool allow_local_unlock);
}
#endif
@@ -52,6 +52,7 @@ CGFloat TextHeight(NSAttributedString* text, CGFloat width) {
@interface CrossDeskPrivacyCoverView : NSView
@property(nonatomic, strong) NSImage* logo;
@property(nonatomic, copy) NSString* unlockHint;
@property(nonatomic) BOOL allowLocalUnlock;
@end
@implementation CrossDeskPrivacyCoverView
@@ -77,7 +78,7 @@ CGFloat TextHeight(NSAttributedString* text, CGFloat width) {
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 shortcut_height = self.allowLocalUnlock ? TextHeight(shortcut, text_width) : 0;
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)
@@ -86,19 +87,26 @@ CGFloat TextHeight(NSAttributedString* text, CGFloat width) {
[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,
[hint drawWithRect:NSMakeRect(text_left, bottom - shortcut_height - (self.allowLocalUnlock ? 12 * scale : 0) - hint_height,
text_width, hint_height) options:kTextOptions];
[shortcut drawWithRect:NSMakeRect(text_left, bottom - shortcut_height,
if (self.allowLocalUnlock) [shortcut drawWithRect:NSMakeRect(text_left, bottom - shortcut_height,
text_width, shortcut_height) options:kTextOptions];
}
@end
namespace crossdesk {
NSView* CreateMacPrivacyCoverView(NSRect frame, NSString* unlock_hint) {
void UpdateMacPrivacyCoverView(NSView* view, NSString* hint, bool allow_local_unlock) {
auto* cover = static_cast<CrossDeskPrivacyCoverView*>(view);
cover.unlockHint = hint;
cover.allowLocalUnlock = allow_local_unlock;
cover.needsDisplay = YES;
}
NSView* CreateMacPrivacyCoverView(NSRect frame, NSString* unlock_hint, bool allow_local_unlock) {
NSImage* logo = LoadPrivacyLogo();
if (!logo) return nil;
auto* view = [[CrossDeskPrivacyCoverView alloc] initWithFrame:frame];
view.logo = logo;
view.allowLocalUnlock = allow_local_unlock;
view.unlockHint = unlock_hint ?: @"To turn off the privacy screen, press";
view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
return view;
@@ -190,6 +190,10 @@ class Windows {
return TRUE;
}
if (message == WM_CLOSE) return 0;
if (message == kPrivacyTextChanged) {
InvalidateRect(hwnd, nullptr, TRUE);
return 0;
}
if ((message == WM_DISPLAYCHANGE || message == WM_DPICHANGED) && self) {
self->changed = true;
InvalidateRect(hwnd, nullptr, TRUE);
@@ -200,8 +204,12 @@ class Windows {
HDC dc = BeginPaint(hwnd, &paint);
RECT rect{};
GetClientRect(hwnd, &rect);
const bool allow_unlock = self &&
InterlockedCompareExchange(&self->shared_->allow_local_unlock, 0, 0);
if (self && !DrawPrivacyCover(dc, rect, PrivacyWindowDpi(hwnd), module,
self->shared_->unlock_hint)) {
allow_unlock ? self->shared_->unlock_hint
: self->shared_->locked_hint,
allow_unlock)) {
self->paint_failed_ = true;
self->Fail(L"Paint privacy logo and unlock instructions");
}
@@ -37,7 +37,7 @@ bool Select(HDC dc, HGDIOBJ object) {
} // namespace
bool DrawPrivacyCover(HDC dc, const RECT& rect, UINT dpi, HINSTANCE module,
const wchar_t* unlock_hint) {
const wchar_t* unlock_hint, bool allow_local_unlock) {
if (!FillRect(dc, &rect, static_cast<HBRUSH>(GetStockObject(BLACK_BRUSH))))
return false;
const int width = rect.right - rect.left;
@@ -68,6 +68,7 @@ bool DrawPrivacyCover(HDC dc, const RECT& rect, UINT dpi, HINSTANCE module,
}};
constexpr UINT flags = DT_LEFT | DT_WORDBREAK | DT_NOPREFIX;
for (size_t i = 0; i < lines.size(); ++i) {
if (i == 1 && !allow_local_unlock) continue;
auto& line = lines[i];
resources.fonts[i] =
CreateFontW(-px(line.size), 0, 0, 0, line.weight, FALSE, FALSE, FALSE,
@@ -81,7 +82,7 @@ bool DrawPrivacyCover(HDC dc, const RECT& rect, UINT dpi, HINSTANCE module,
}
const int content_height =
static_cast<int>(lines[0].bounds.bottom + lines[1].bounds.bottom) +
px(12);
(allow_local_unlock ? px(12) : 0);
// Match the icon width to the name; each side keeps its own height.
const int brand_gap = px(6);
const int brand_width = static_cast<int>(lines[kBrandLine].bounds.right);
@@ -110,6 +111,7 @@ bool DrawPrivacyCover(HDC dc, const RECT& rect, UINT dpi, HINSTANCE module,
DT_CENTER | DT_SINGLELINE | DT_NOPREFIX))
return false;
for (size_t i = 0; i < kInstructionLines; ++i) {
if (i == 1 && !allow_local_unlock) continue;
const auto& line = lines[i];
if (!Select(dc, resources.fonts[i])) return false;
RECT bounds{left, top, left + text_width, top + line.bounds.bottom};
@@ -13,7 +13,7 @@ namespace crossdesk {
// Paints the local, opaque cover. The caller owns the HWND and its DPI context.
bool DrawPrivacyCover(HDC dc, const RECT& rect, UINT dpi, HINSTANCE module,
const wchar_t* unlock_hint);
const wchar_t* unlock_hint, bool allow_local_unlock = true);
} // namespace crossdesk
@@ -120,6 +120,7 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
}
if (!Initialize(error)) return false;
emergency_ = false;
allow_local_unlock_ = text.allow_local_unlock;
health_error_.clear();
if (!RegisterHotKey(sink_, kHotkeyId,
MOD_CONTROL | MOD_ALT | MOD_SHIFT | MOD_NOREPEAT,
@@ -149,6 +150,10 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
return true;
}
void UpdateText(const PrivacyScreenText& text) override {
allow_local_unlock_ = text.allow_local_unlock;
band_guard_.SetLocalUnlockAllowed(text.allow_local_unlock);
}
void Recover() override {
for (auto& hook : window_events_) {
if (UnhookWinEvent(hook))
@@ -475,7 +480,8 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
auto self = reinterpret_cast<WindowsPrivacyBackend*>(
GetWindowLongPtrW(hwnd, GWLP_USERDATA));
if (msg == WM_HOTKEY || msg == kEmergency) {
if (self && hwnd == self->sink_) self->emergency_ = true;
if (self && hwnd == self->sink_ && self->allow_local_unlock_)
self->emergency_ = true;
return 0;
}
if (msg == WM_MOUSEACTIVATE) return MA_NOACTIVATE;
@@ -522,6 +528,7 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
std::unique_ptr<ScopedWindowsPhysicalCoordinates> dpi_scope_;
bool registered_ = false;
bool hotkey_ = false;
bool allow_local_unlock_ = true;
bool emergency_ = false;
bool layout_changed_ = false;
bool topology_pending_ = false;
@@ -153,6 +153,11 @@ bool PrivacyBandGuard::Start(const PrivacyScreenText& text,
text.unlock_hint.c_str(), -1, shared_->unlock_hint,
static_cast<int>(std::size(shared_->unlock_hint))))
return Failed("Encode privacy screen text", error);
if (!MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS,
text.locked_hint.c_str(), -1, shared_->locked_hint,
static_cast<int>(std::size(shared_->locked_hint))))
return Failed("Encode locked privacy screen text", error);
shared_->allow_local_unlock = text.allow_local_unlock;
shared_->parent_pid = GetCurrentProcessId();
HANDLE handles[] = {parent.value, stop_, ready_, mapping_};
std::wstring bootstrap;
@@ -299,6 +304,16 @@ bool PrivacyBandGuard::Windows(std::vector<PrivacyBandWindow>& windows,
return retry();
}
void PrivacyBandGuard::SetLocalUnlockAllowed(bool allowed) {
if (!shared_ || InterlockedExchange(&shared_->allow_local_unlock, allowed) == allowed) return;
std::vector<PrivacyBandWindow> windows;
std::string error;
if (Windows(windows, error))
for (const auto& window : windows)
PostMessageW(reinterpret_cast<HWND>(static_cast<uintptr_t>(window.hwnd)),
kPrivacyTextChanged, 0, 0);
}
bool PrivacyBandGuard::Stop(std::string& error) {
bool ok = true;
if (process_) {
@@ -27,6 +27,7 @@ class PrivacyBandGuard {
~PrivacyBandGuard();
static bool Available(std::string& error);
bool Start(const PrivacyScreenText& text, std::string& error);
void SetLocalUnlockAllowed(bool allowed);
bool Stop(std::string& error);
bool Healthy(std::string& error);
bool Windows(std::vector<PrivacyBandWindow>& windows, std::string& error);
@@ -14,7 +14,8 @@
namespace crossdesk {
constexpr uint32_t kPrivacyBandMagic = 0x43534231;
constexpr uint32_t kPrivacyBandVersion = 3;
constexpr uint32_t kPrivacyBandVersion = 4;
constexpr UINT kPrivacyTextChanged = WM_APP + 72;
constexpr DWORD kPrivacyWindowBand = 18; // ZBID_ABOVELOCK_UX, current desktop.
constexpr size_t kPrivacyMaxMonitors = 64;
constexpr wchar_t kPrivacyBandBootstrap[] = L"CROSSDESK_PRIVACY_BAND_BOOTSTRAP";
@@ -40,6 +41,8 @@ struct PrivacyBandShared {
alignas(8) volatile LONG64 heartbeat = 0;
wchar_t operation[128]{};
wchar_t unlock_hint[128]{};
wchar_t locked_hint[128]{};
volatile LONG allow_local_unlock = 1;
uint32_t count = 0;
PrivacyBandWindow windows[kPrivacyMaxMonitors]{};
};