[feat] add Linux X11 privacy screen and automatic recovery

This commit is contained in:
dijunkun
2026-09-13 03:49:17 +08:00
parent 3c403f6162
commit c6635fbdc8
12 changed files with 1073 additions and 9 deletions
+1 -1
View File
@@ -161,7 +161,7 @@ Architecture: $DEBIAN_ARCH
Maintainer: $MAINTAINER
Description: $DESCRIPTION
Depends: libc6 (>= 2.31), libstdc++6 (>= 10), libx11-6, libxext6,
libxrender1, libxft2, libxrandr2, libxfixes3, libxcursor1, libxcb1, libxcb-randr0,
libxrender1, libxft2, libxrandr2, libxfixes3, libxcursor1, libxi6, libxcb1, libxcb-randr0,
libxcb-xtest0, libxcb-xinerama0, libxcb-shape0, libxcb-xkb1,
libxcb-xfixes0, libxv1, libxtst6, $ALSA_RUNTIME_DEP, libsndio7.0,
libxcb-shm0, libpulse0, libdrm2, libdbus-1-3, libgl1
+13 -1
View File
@@ -24,6 +24,10 @@
#include "path_manager.h"
#include "render.h"
#ifdef __linux__
#include "platform/linux/privacy/privacy_guard.h"
#endif
#ifdef _WIN32
namespace {
@@ -175,6 +179,14 @@ int HandleServiceCliCommand(const std::string& command) {
#endif
int main(int argc, char* argv[]) {
#ifdef __linux__
// Run before GUI, configuration, logging or daemon initialization. The
// guard owns native privacy resources and restores them if this app exits.
if (argc == 2 &&
std::strcmp(argv[1], crossdesk::kLinuxPrivacyGuardArgument) == 0) {
return crossdesk::RunLinuxPrivacyGuard();
}
#endif
#ifdef _WIN32
if (argc == 2 &&
std::strcmp(argv[1], crossdesk::kSlintRendererProbeArgument) == 0) {
@@ -233,4 +245,4 @@ int main(int argc, char* argv[]) {
crossdesk::Render render;
render.Run();
return 0;
}
}
@@ -1241,7 +1241,7 @@ void GuiApplication::ResetSettingsUi() {
ui_->main->set_capture_method_index(
static_cast<int>(config_center_->GetScreenCaptureMethod()));
#endif
#if defined(_WIN32) || defined(__APPLE__)
#if defined(_WIN32) || defined(__APPLE__) || defined(__linux__)
ui_->main->set_privacy_setting_available(true);
#else
ui_->main->set_privacy_setting_available(false);
@@ -2815,11 +2815,11 @@ void GuiApplication::SyncServerWindow() {
});
}
// Windows and macOS cover the local panel with capture-excluded privacy
// windows. Keep the panel alive throughout privacy transitions so remote
// Privacy protects local display output while preserving capture.
// Keep the panel alive throughout privacy transitions so remote
// controllers can still see and operate it without resetting its UI state.
bool show_controller_window = has_connected_controller;
#if !defined(_WIN32) && !defined(__APPLE__)
#if !defined(_WIN32) && !defined(__APPLE__) && !defined(__linux__)
// Preserve the existing panel behavior on other platforms.
const auto privacy_status = privacy_.Snapshot();
const bool privacy_covering = privacy_status.overlay_active ||
@@ -3025,7 +3025,7 @@ void GuiApplication::SaveSettingsFromUi() {
main->set_capture_method_index(
static_cast<int>(config_center_->GetScreenCaptureMethod()));
#endif
#if defined(_WIN32) || defined(__APPLE__)
#if defined(_WIN32) || defined(__APPLE__) || defined(__linux__)
if (config_center_->SetPrivacyScreen(main->get_privacy_on_connect_enabled()) !=
0) {
main->set_privacy_on_connect_enabled(
@@ -405,7 +405,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
runtime->show_connection_status_window_ = true;
{
std::unique_lock lock(runtime->connection_status_mutex_);
#if defined(_WIN32) || defined(__APPLE__)
#if defined(_WIN32) || defined(__APPLE__) || defined(__linux__)
if (status == ConnectionStatus::Connected &&
runtime->config_center_->IsEnablePrivacyScreen() &&
std::none_of(runtime->connection_status_.begin(),
@@ -0,0 +1,244 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-13
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _PRIVACY_BACKEND_LINUX_H_
#define _PRIVACY_BACKEND_LINUX_H_
#include <poll.h>
#include <spawn.h>
#include <sys/eventfd.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <unistd.h>
#include <algorithm>
#include <cerrno>
#include <chrono>
#include <cstring>
#include <thread>
#include "privacy_backend.h"
#include "privacy_guard.h"
extern char** environ;
namespace crossdesk {
namespace {
using Clock = std::chrono::steady_clock;
class LinuxPrivacyBackend final : public PrivacyBackend {
public:
LinuxPrivacyBackend() : wake_(eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK)) {}
~LinuxPrivacyBackend() override {
// EOF also recovers after a crash, SIGKILL, or a normal shutdown. Never
// terminate the guard: it must finish restoring the saved display ramps.
if (socket_ >= 0) close(socket_);
if (wake_ >= 0) close(wake_);
if (child_ > 0) {
const pid_t child = child_;
std::thread([child] {
while (waitpid(child, nullptr, 0) < 0 && errno == EINTR) {
}
}).detach();
}
}
PrivacyCapabilities Query() override {
if (wake_ < 0)
return {false, false, "Cannot create Linux privacy wake event"};
if (!StartGuard()) return {false, false, failure_};
if (!Send(LinuxPrivacyCommand::query)) return {false, false, failure_};
// The first query also serves automatic privacy on the first connection.
// Bound process startup so an unavailable helper cannot stall the GUI.
if (!discovered_) {
pollfd fd{socket_, POLLIN, 0};
poll(&fd, 1, 1000);
}
ReadReplies();
return {reply_.supported, reply_.input_supported,
reply_.capability_reason[0] ? reply_.capability_reason
: "Checking Linux privacy support"};
}
bool Enable(bool block_input, const PrivacyScreenText&,
std::string& error) override {
failure_.clear();
emergency_ = false;
if (!Send(block_input ? LinuxPrivacyCommand::enable_blocking
: LinuxPrivacyCommand::enable)) {
error = failure_;
return false;
}
engaged_ = true;
++generation_;
last_reply_ = Clock::now();
recovered_ = false;
reply_.active = false;
return true;
}
void Recover() override {
ReadReplies();
if (recovered_) {
failure_.clear();
return;
}
if (recovering_) return;
if (Send(LinuxPrivacyCommand::recover)) recovering_ = true;
}
bool IsRecovered() const override { return recovered_; }
bool RecoveryPending() const override { return recovering_; }
PrivacyHealth Poll() override {
ReadReplies();
if (engaged_ && Clock::now() - last_reply_ > std::chrono::seconds(1))
failure_ = "Linux privacy guard stopped responding";
if (engaged_ &&
Clock::now() - last_heartbeat_ >= std::chrono::milliseconds(100)) {
Send(LinuxPrivacyCommand::heartbeat);
last_heartbeat_ = Clock::now();
}
const bool emergency = emergency_;
emergency_ = false;
return {failure_, emergency, reply_.active};
}
void WaitForEvents(unsigned timeout_ms) override {
pollfd fds[] = {{wake_, POLLIN, 0}, {socket_, POLLIN, 0}};
poll(fds, 2, static_cast<int>(timeout_ms));
uint64_t value;
if (fds[0].revents & POLLIN) {
while (read(wake_, &value, sizeof(value)) == sizeof(value)) {
}
}
}
void Wake() override {
const uint64_t value = 1;
if (wake_ >= 0) {
const auto ignored = write(wake_, &value, sizeof(value));
(void)ignored;
}
}
private:
bool StartGuard() {
if (socket_ >= 0) return true;
if (child_ > 0) {
const auto result = waitpid(child_, nullptr, WNOHANG);
if (result == 0) return false;
child_ = -1;
}
int sockets[2];
if (socketpair(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC | SOCK_NONBLOCK, 0,
sockets) != 0) {
failure_ = "Cannot create Linux privacy guard socket";
return false;
}
posix_spawn_file_actions_t actions;
int result = posix_spawn_file_actions_init(&actions);
if (result == 0) {
// Close the parent end first: it may occupy the guard's destination fd.
result = posix_spawn_file_actions_addclose(&actions, sockets[0]);
if (result == 0)
result = posix_spawn_file_actions_adddup2(&actions, sockets[1],
kLinuxPrivacyGuardSocket);
if (result == 0 && sockets[1] != kLinuxPrivacyGuardSocket)
result = posix_spawn_file_actions_addclose(&actions, sockets[1]);
if (result == 0) {
char executable[] = "/proc/self/exe";
char* args[] = {executable,
const_cast<char*>(kLinuxPrivacyGuardArgument), nullptr};
result =
posix_spawn(&child_, executable, &actions, nullptr, args, environ);
}
posix_spawn_file_actions_destroy(&actions);
}
close(sockets[1]);
if (result != 0) {
close(sockets[0]);
child_ = -1;
failure_ = std::string("Cannot start Linux privacy guard: ") +
std::strerror(result);
return false;
}
socket_ = sockets[0];
reply_ = {};
discovered_ = false;
generation_ = 0;
failure_.clear();
return true;
}
void LostGuard() {
if (socket_ >= 0) close(socket_);
socket_ = -1;
reply_ = {};
recovering_ = false;
failure_ =
"Linux privacy guard disconnected; local protection is unavailable";
// Do not claim successful recovery after an unexpected guard failure.
// A normal guard reports restoration before closing its socket.
}
bool Send(LinuxPrivacyCommand command) {
if (socket_ < 0) return false;
if (send(socket_, &command, sizeof(command), MSG_NOSIGNAL) ==
sizeof(command))
return true;
if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) LostGuard();
return false;
}
void ReadReplies() {
if (socket_ < 0) return;
for (;;) {
LinuxPrivacyReply next;
const auto bytes = recv(socket_, &next, sizeof(next), 0);
if (bytes < 0 &&
(errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR))
return;
if (bytes != sizeof(next)) {
LostGuard();
return;
}
next.capability_reason[sizeof(next.capability_reason) - 1] = '\0';
next.failure[sizeof(next.failure) - 1] = '\0';
discovered_ = true;
// Ignore queued capability/heartbeat replies from before this enable.
// A matching generation can also acknowledge watchdog-driven recovery.
if (next.generation != generation_) continue;
reply_ = next;
last_reply_ = Clock::now();
emergency_ = emergency_ || next.emergency;
if (next.failure[0]) failure_ = next.failure;
recovered_ = next.recovered;
if (next.recovered) {
recovering_ = false;
engaged_ = false;
}
}
}
int wake_ = -1;
int socket_ = -1;
pid_t child_ = -1;
LinuxPrivacyReply reply_;
uint64_t generation_ = 0;
bool engaged_ = false;
bool discovered_ = false;
bool recovering_ = false;
bool recovered_ = true;
bool emergency_ = false;
std::string failure_;
Clock::time_point last_heartbeat_{};
Clock::time_point last_reply_{};
};
} // namespace
std::unique_ptr<PrivacyBackend> CreateLinuxPrivacyBackend() {
return std::make_unique<LinuxPrivacyBackend>();
}
} // namespace crossdesk
#endif
@@ -0,0 +1,721 @@
/* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. */
#include "privacy_guard.h"
#include <X11/XKBlib.h>
#include <X11/Xatom.h>
#include <X11/extensions/XInput.h>
#include <X11/extensions/XInput2.h>
#include <X11/extensions/XTest.h>
#include <X11/extensions/Xfixes.h>
#include <X11/extensions/Xrandr.h>
#include <X11/keysym.h>
#include <fcntl.h>
#include <poll.h>
#include <sys/socket.h>
#include <unistd.h>
#include <algorithm>
#include <array>
#include <cerrno>
#include <chrono>
#include <csignal>
#include <cstdlib>
#include <cstring>
#include <map>
#include <memory>
#include <string>
#include <tuple>
#include <vector>
namespace crossdesk {
namespace {
using Clock = std::chrono::steady_clock;
using Gamma = std::unique_ptr<XRRCrtcGamma, decltype(&XRRFreeGamma)>;
using Resources =
std::unique_ptr<XRRScreenResources, decltype(&XRRFreeScreenResources)>;
using CrtcInfo = std::unique_ptr<XRRCrtcInfo, decltype(&XRRFreeCrtcInfo)>;
volatile std::sig_atomic_t stopping = 0;
int x_error = 0;
void StopGuard(int) { stopping = 1; }
int OnXError(Display*, XErrorEvent* event) {
x_error = event->error_code;
return 0;
}
template <size_t N>
void SetText(char (&dest)[N], const std::string& text) {
std::strncpy(dest, text.c_str(), N - 1);
dest[N - 1] = '\0';
}
struct Output {
Window root = 0;
RRCrtc crtc = 0;
RRMode mode = 0;
int x = 0, y = 0;
unsigned width = 0, height = 0;
Rotation rotation = 0;
std::vector<RROutput> connectors;
Gamma saved{nullptr, XRRFreeGamma};
bool SameLayout(const Output& other) const {
return std::tie(root, crtc, mode, x, y, width, height, rotation,
connectors) == std::tie(other.root, other.crtc, other.mode,
other.x, other.y, other.width,
other.height, other.rotation,
other.connectors);
}
};
struct Device {
int id = 0, use = 0, attachment = 0;
bool keyboard = false, touch = false;
std::string name;
bool operator==(const Device& other) const {
return std::tie(id, use, attachment, keyboard, touch, name) ==
std::tie(other.id, other.use, other.attachment, other.keyboard,
other.touch, other.name);
}
};
bool IsBlack(const XRRCrtcGamma* gamma) {
if (!gamma || gamma->size <= 0) return false;
for (int i = 0; i < gamma->size; ++i)
if (gamma->red[i] || gamma->green[i] || gamma->blue[i]) return false;
return true;
}
// This class lives only in the exec'd guard process. Its Xlib error handler is
// isolated from the GUI and capture threads' process-global error handlers.
class NativePrivacy {
public:
~NativePrivacy() {
Recover();
if (display_) XCloseDisplay(display_);
}
void Query() {
reply_.supported = reply_.input_supported = false;
std::string error;
if (!Initialize(error)) {
SetText(reply_.capability_reason, error);
return;
}
std::vector<Output> outputs;
std::vector<Device> devices;
if (!ReadOutputs(outputs, true, error) || !ReadDevices(devices, error)) {
SetText(reply_.capability_reason, error);
return;
}
const bool keyboard = std::any_of(devices.begin(), devices.end(),
[](const auto& d) { return d.keyboard; });
if (!keyboard) {
SetText(reply_.capability_reason,
"No local keyboard available for privacy recovery");
return;
}
reply_.supported = true;
// XIGrabDevice does not suppress XI 2.2 touch delivery. Do not advertise
// input protection for touchscreens without a separate touch grab.
reply_.input_supported = std::none_of(
devices.begin(), devices.end(), [](const auto& d) { return d.touch; });
SetText(reply_.capability_reason,
reply_.input_supported
? "X11 privacy black screen available; local recovery: "
"Ctrl+Alt+Shift+F12"
: "X11 privacy black screen available; touchscreen input "
"blocking is unavailable");
}
void Enable(bool block_input) {
++reply_.generation;
reply_.failure[0] = '\0';
reply_.emergency = false;
if (!reply_.recovered) {
Fail("Linux privacy resources are still in use");
return;
}
Query();
if (!reply_.supported || (block_input && !reply_.input_supported)) {
Fail(reply_.capability_reason);
return;
}
std::string error;
keys_.clear();
if (!Acquire(error) || !ReadOutputs(outputs_, true, error) ||
!ReadDevices(devices_, error) || !ProtectInput(block_input, error)) {
Fail(error);
return;
}
// No display ramp is changed until every original ramp has been saved.
// Gamma is applied at scanout, after XGetImage/DRM framebuffer capture.
// There is deliberately no cover window to contaminate remote frames.
x_error = 0;
for (const auto& output : outputs_) {
Gamma black(XRRAllocGamma(output.saved->size), XRRFreeGamma);
if (!black) {
Fail("Cannot allocate Linux privacy display ramp");
return;
}
std::fill_n(black->red, black->size, 0);
std::fill_n(black->green, black->size, 0);
std::fill_n(black->blue, black->size, 0);
XRRSetCrtcGamma(display_, output.crtc, black.get());
}
for (int i = 0; i < ScreenCount(display_); ++i) {
XFixesHideCursor(display_, RootWindow(display_, i));
hidden_roots_.push_back(RootWindow(display_, i));
}
XSync(display_, False);
if (x_error) {
Fail("Cannot blank Linux displays or hide the local cursor");
return;
}
// Active slave grabs temporarily detach devices. Snapshot after grabbing,
// and monitor subsequent hardware/hierarchy changes during protection.
if (!ReadDevices(devices_, error)) {
Fail(error);
return;
}
reply_.active = true;
Check();
}
void Recover() {
reply_.active = false;
if (!display_) {
reply_.recovered = true;
return;
}
for (int id : grabbed_) XIUngrabDevice(display_, id, CurrentTime);
grabbed_.clear();
for (Window root : hidden_roots_) XFixesShowCursor(display_, root);
hidden_roots_.clear();
// Keep failed restorations for retry. Removed CRTCs no longer own a local
// output; all still-present CRTCs must acknowledge the original ramp.
for (auto it = outputs_.begin(); it != outputs_.end();) {
x_error = 0;
Resources resources(XRRGetScreenResourcesCurrent(display_, it->root),
XRRFreeScreenResources);
if (!resources) {
++it;
continue;
}
const bool exists =
std::find(resources->crtcs, resources->crtcs + resources->ncrtc,
it->crtc) != resources->crtcs + resources->ncrtc;
if (exists && it->saved) {
XRRSetCrtcGamma(display_, it->crtc, it->saved.get());
XSync(display_, False);
if (x_error) {
++it;
continue;
}
}
it = outputs_.erase(it);
}
keys_.clear();
if (outputs_.empty() && owner_) {
XDestroyWindow(display_, owner_);
owner_ = 0;
}
XSync(display_, False);
reply_.recovered = outputs_.empty() && !owner_;
}
void Fail(const std::string& reason) {
SetText(reply_.failure, reason);
Recover();
}
void Poll() {
if (!display_) return;
while (XPending(display_)) {
XEvent event{};
XNextEvent(display_, &event);
if (event.type != GenericEvent || event.xcookie.extension != xi_opcode_ ||
!XGetEventData(display_, &event.xcookie))
continue;
if (reply_.active) {
const int type = event.xcookie.evtype;
if (type == XI_KeyPress || type == XI_KeyRelease) {
const auto* key = static_cast<XIDeviceEvent*>(event.xcookie.data);
Key(key->sourceid, key->detail, type == XI_KeyPress);
} else if (!blocking_ &&
(type == XI_RawKeyPress || type == XI_RawKeyRelease)) {
const auto* key = static_cast<XIRawEvent*>(event.xcookie.data);
Key(key->sourceid, key->detail, type == XI_RawKeyPress);
}
}
XFreeEventData(display_, &event.xcookie);
}
if (reply_.active &&
Clock::now() - checked_ >= std::chrono::milliseconds(100))
Check();
if (!reply_.active && !reply_.recovered) Recover();
}
const LinuxPrivacyReply& Reply() const { return reply_; }
int Connection() const { return display_ ? ConnectionNumber(display_) : -1; }
void AcknowledgeEmergency() { reply_.emergency = false; }
private:
bool Initialize(std::string& error) {
if (initialized_) return true;
const char* session = std::getenv("XDG_SESSION_TYPE");
const char* wayland = std::getenv("WAYLAND_DISPLAY");
if ((session && std::strcmp(session, "wayland") == 0) ||
(wayland && *wayland)) {
error =
"Linux privacy screen requires an X11 session; Wayland is "
"unsupported";
return false;
}
const char* address = std::getenv("DISPLAY");
if (!address ||
(*address != ':' && std::strncmp(address, "unix:", 5) != 0)) {
error = "Linux privacy screen requires a local X11 display";
return false;
}
if (!display_) display_ = XOpenDisplay(nullptr);
if (!display_) {
error = "Cannot open the local X11 display";
return false;
}
int event = 0, code = 0, opcode = 0;
int major = 1, minor = 3;
if (XQueryExtension(display_, "XWAYLAND", &opcode, &event, &code)) {
error = "XWayland cannot protect native Wayland displays";
return false;
}
if (!XRRQueryExtension(display_, &event, &code) ||
!XRRQueryVersion(display_, &major, &minor) || major < 1 ||
(major == 1 && minor < 3)) {
error = "Linux privacy screen requires RandR 1.3 display gamma control";
return false;
}
major = 4;
minor = 0;
if (!XFixesQueryExtension(display_, &event, &code) ||
!XFixesQueryVersion(display_, &major, &minor) || major < 4) {
error = "Linux privacy screen requires XFixes 4 cursor hiding";
return false;
}
major = 2;
minor = 2;
if (!XQueryExtension(display_, "XInputExtension", &xi_opcode_, &event,
&code) ||
XIQueryVersion(display_, &major, &minor) != Success || major < 2) {
error = "Linux privacy screen requires XInput 2 local input monitoring";
return false;
}
xtest_property_ = XInternAtom(display_, "XTEST Device", True);
if (!xtest_property_ ||
!XTestQueryExtension(display_, &event, &code, &major, &minor)) {
error = "Cannot identify X11 remote input devices";
return false;
}
selection_ = XInternAtom(display_, "_CROSSDESK_PRIVACY_GUARD", False);
std::array<unsigned char, XIMaskLen(XI_LASTEVENT)> bits{};
XISetMask(bits.data(), XI_RawKeyPress);
XISetMask(bits.data(), XI_RawKeyRelease);
XIEventMask mask{XIAllDevices, static_cast<int>(bits.size()), bits.data()};
x_error = 0;
for (int i = 0; i < ScreenCount(display_); ++i)
XISelectEvents(display_, RootWindow(display_, i), &mask, 1);
XSync(display_, False);
if (x_error) {
error = "Cannot monitor the local privacy recovery shortcut";
return false;
}
initialized_ = true;
return true;
}
bool ReadOutputs(std::vector<Output>& outputs, bool save,
std::string& error) {
outputs.clear();
x_error = 0;
for (int screen = 0; screen < ScreenCount(display_); ++screen) {
const Window root = RootWindow(display_, screen);
Resources resources(XRRGetScreenResourcesCurrent(display_, root),
XRRFreeScreenResources);
if (!resources) {
error = "Cannot enumerate Linux display outputs";
return false;
}
for (int i = 0; i < resources->ncrtc; ++i) {
CrtcInfo info(
XRRGetCrtcInfo(display_, resources.get(), resources->crtcs[i]),
XRRFreeCrtcInfo);
if (!info) {
error = "Cannot inspect Linux display controller";
return false;
}
if (!info->mode || !info->noutput) continue;
Output output;
output.root = root;
output.crtc = resources->crtcs[i];
output.mode = info->mode;
output.x = info->x;
output.y = info->y;
output.width = info->width;
output.height = info->height;
output.rotation = info->rotation;
output.connectors.assign(info->outputs, info->outputs + info->noutput);
std::sort(output.connectors.begin(), output.connectors.end());
if (save) {
output.saved.reset(XRRGetCrtcGamma(display_, output.crtc));
if (!output.saved || output.saved->size <= 0 ||
IsBlack(output.saved.get())) {
error =
"Display gamma control is unavailable or the display is "
"already blank";
return false;
}
}
outputs.push_back(std::move(output));
}
}
XSync(display_, False);
if (x_error || outputs.empty()) {
error = "No controllable Linux display outputs";
return false;
}
return true;
}
bool ReadDevices(std::vector<Device>& devices, std::string& error) {
devices.clear();
x_error = 0;
int count = 0;
XIDeviceInfo* info = XIQueryDevice(display_, XIAllDevices, &count);
if (!info) {
error = "Cannot enumerate local X11 input devices";
return false;
}
for (int i = 0; i < count; ++i) {
const auto& d = info[i];
if (!d.enabled || d.use == XIMasterPointer || d.use == XIMasterKeyboard)
continue;
Atom type = None;
int format = 0;
unsigned long items = 0, remaining = 0;
unsigned char* value = nullptr;
const int result =
XIGetProperty(display_, d.deviceid, xtest_property_, 0, 1, False,
XA_INTEGER, &type, &format, &items, &remaining, &value);
const bool injected = result == Success && type == XA_INTEGER &&
format == 8 && items == 1 && value && value[0] == 1;
if (value) XFree(value);
if (injected) continue;
Device device;
device.id = d.deviceid;
device.use = d.use;
device.attachment = d.attachment;
device.name = d.name ? d.name : "";
for (int c = 0; c < d.num_classes; ++c) {
device.keyboard |= d.classes[c]->type == XIKeyClass;
device.touch |= d.classes[c]->type == XITouchClass;
}
devices.push_back(std::move(device));
}
XIFreeDeviceInfo(info);
XSync(display_, False);
if (x_error) {
error = "Cannot verify local X11 input devices";
return false;
}
return true;
}
bool Acquire(std::string& error) {
// A second instance must not save the first instance's black ramps as its
// originals. Selection ownership and creation are atomic on this server.
XGrabServer(display_);
if (XGetSelectionOwner(display_, selection_) == None) {
owner_ = XCreateSimpleWindow(display_, DefaultRootWindow(display_), 0, 0,
1, 1, 0, 0, 0);
XSetSelectionOwner(display_, selection_, owner_, CurrentTime);
}
XUngrabServer(display_);
XSync(display_, False);
if (!owner_ || XGetSelectionOwner(display_, selection_) != owner_) {
error = "Another CrossDesk instance owns Linux privacy protection";
return false;
}
reply_.recovered = false;
return true;
}
bool ProtectInput(bool block_input, std::string& error) {
blocking_ = block_input;
if (!block_input) return true;
for (const auto& device : devices_) {
if (device.touch) {
error = "Touchscreen privacy input blocking is unavailable";
return false;
}
if (device.keyboard) {
// Preserve held local modifiers for the emergency chord independently
// of the releases sent to the master keyboard below.
XDevice* keyboard = XOpenDevice(display_, device.id);
XDeviceState* state =
keyboard ? XQueryDeviceState(display_, keyboard) : nullptr;
if (!state) {
if (keyboard) XCloseDevice(display_, keyboard);
error = "Cannot inspect held local keyboard input";
return false;
}
XInputClass* input = state->data;
for (int i = 0; i < state->num_classes; ++i) {
if (input->c_class == KeyClass) {
const auto* keys = reinterpret_cast<XKeyState*>(input);
for (int key = 0; key < 256; ++key)
keys_[device.id][key] = keys->keys[key / 8] & (1 << (key % 8));
}
input = reinterpret_cast<XInputClass*>(
reinterpret_cast<char*>(input) + input->length);
}
XFreeDeviceState(state);
XCloseDevice(display_, keyboard);
}
std::array<unsigned char, XIMaskLen(XI_LASTEVENT)> bits{};
for (int type : {XI_KeyPress, XI_KeyRelease, XI_ButtonPress,
XI_ButtonRelease, XI_Motion})
XISetMask(bits.data(), type);
XIEventMask mask{device.id, static_cast<int>(bits.size()), bits.data()};
const int result = XIGrabDevice(
display_, device.id, DefaultRootWindow(display_), CurrentTime, None,
GrabModeAsync, GrabModeAsync, False, &mask);
if (result != GrabSuccess) {
error = "Cannot grab every local X11 input device";
return false;
}
grabbed_.push_back(device.id);
}
return ReleaseHeldInput(error);
}
bool ReleaseHeldInput(std::string& error) {
// Slave grabs float physical devices, but their previous presses can
// remain on the master. Release them after all grabs are installed so a
// held local Ctrl/button cannot alter the remote controller's next input.
int original_pointer = 0, count = 0;
XIGetClientPointer(display_, None, &original_pointer);
XIDeviceInfo* masters = XIQueryDevice(display_, XIAllMasterDevices, &count);
if (!masters) {
error = "Cannot inspect held X11 input";
return false;
}
x_error = 0;
bool ok = true;
for (int i = 0; i < count; ++i) {
if (masters[i].use != XIMasterPointer || !masters[i].enabled) continue;
XISetClientPointer(display_, None, masters[i].deviceid);
char keys[32]{};
XQueryKeymap(display_, keys);
for (int key = 8; key < 256; ++key)
if (keys[key / 8] & (1 << (key % 8)))
ok &= XTestFakeKeyEvent(display_, key, False, CurrentTime) != 0;
Window root = None, child = None;
double root_x = 0, root_y = 0, win_x = 0, win_y = 0;
XIButtonState buttons{};
XIModifierState modifiers{};
XIGroupState group{};
XIQueryPointer(display_, masters[i].deviceid, DefaultRootWindow(display_),
&root, &child, &root_x, &root_y, &win_x, &win_y, &buttons,
&modifiers, &group);
for (int button = 1; button < buttons.mask_len * 8; ++button)
if (XIMaskIsSet(buttons.mask, button))
ok &= XTestFakeButtonEvent(display_, button, False, CurrentTime) != 0;
if (buttons.mask) XFree(buttons.mask);
XSync(display_, False);
XQueryKeymap(display_, keys);
for (char pressed : keys) ok &= pressed == 0;
buttons = {};
XIQueryPointer(display_, masters[i].deviceid, DefaultRootWindow(display_),
&root, &child, &root_x, &root_y, &win_x, &win_y, &buttons,
&modifiers, &group);
for (int byte = 0; byte < buttons.mask_len; ++byte)
ok &= buttons.mask[byte] == 0;
if (buttons.mask) XFree(buttons.mask);
}
XIFreeDeviceInfo(masters);
if (original_pointer) XISetClientPointer(display_, None, original_pointer);
XSync(display_, False);
if (!ok || x_error) {
error = "Cannot release held X11 input for privacy";
return false;
}
return true;
}
void Key(int source, int keycode, bool down) {
if (keycode < 0 || keycode >= 256 ||
std::none_of(devices_.begin(), devices_.end(), [source](const auto& d) {
return d.id == source && d.keyboard;
}))
return;
keys_[source][keycode] = down;
bool ctrl = false, alt = false, shift = false;
for (const auto& [id, keys] : keys_) {
for (int code = 0; code < 256; ++code) {
if (!keys[code]) continue;
const KeySym symbol = XkbKeycodeToKeysym(display_, code, 0, 0);
ctrl |= symbol == XK_Control_L || symbol == XK_Control_R;
alt |= symbol == XK_Alt_L || symbol == XK_Alt_R;
shift |= symbol == XK_Shift_L || symbol == XK_Shift_R;
}
}
if (down && ctrl && alt && shift &&
XkbKeycodeToKeysym(display_, keycode, 0, 0) == XK_F12) {
reply_.emergency = true;
Recover();
}
}
void Check() {
checked_ = Clock::now();
std::string error;
std::vector<Output> current;
std::vector<Device> devices;
if (!ReadOutputs(current, false, error) || !ReadDevices(devices, error)) {
Fail(error);
return;
}
if (current.size() != outputs_.size() ||
!std::equal(
current.begin(), current.end(), outputs_.begin(),
[](const auto& a, const auto& b) { return a.SameLayout(b); })) {
Fail("Display topology changed; Linux privacy has been turned off");
return;
}
if (devices != devices_) {
Fail("Local input devices changed; Linux privacy has been turned off");
return;
}
x_error = 0;
for (const auto& output : outputs_) {
Gamma ramp(XRRGetCrtcGamma(display_, output.crtc), XRRFreeGamma);
if (!IsBlack(ramp.get())) {
Fail(
"Display gamma protection was changed; Linux privacy is "
"unavailable");
return;
}
}
XSync(display_, False);
if (x_error || XGetSelectionOwner(display_, selection_) != owner_)
Fail("Cannot verify Linux privacy protection");
}
Display* display_ = nullptr;
bool initialized_ = false;
bool blocking_ = false;
int xi_opcode_ = 0;
Atom xtest_property_ = None, selection_ = None;
Window owner_ = 0;
std::vector<Output> outputs_;
std::vector<Device> devices_;
std::vector<int> grabbed_;
std::vector<Window> hidden_roots_;
std::map<int, std::array<bool, 256>> keys_;
LinuxPrivacyReply reply_;
Clock::time_point checked_{};
};
} // namespace
int RunLinuxPrivacyGuard() {
const int socket = kLinuxPrivacyGuardSocket;
int type = 0;
socklen_t length = sizeof(type);
if (getsockopt(socket, SOL_SOCKET, SO_TYPE, &type, &length) != 0 ||
type != SOCK_SEQPACKET)
return 1;
fcntl(socket, F_SETFD, FD_CLOEXEC);
fcntl(socket, F_SETFL, O_NONBLOCK);
struct sigaction action {};
action.sa_handler = StopGuard;
sigemptyset(&action.sa_mask);
sigaction(SIGTERM, &action, nullptr);
sigaction(SIGINT, &action, nullptr);
sigaction(SIGHUP, &action, nullptr);
XSetErrorHandler(OnXError);
NativePrivacy privacy;
auto heartbeat = Clock::now();
bool connected = true;
bool dirty = false;
while (connected && !stopping) {
pollfd fds[] = {{socket, POLLIN, 0}, {privacy.Connection(), POLLIN, 0}};
poll(fds, 2, privacy.Reply().active ? 25 : 250);
if (fds[0].revents & (POLLERR | POLLHUP | POLLNVAL)) break;
for (int i = 0; i < 64; ++i) {
LinuxPrivacyCommand command;
const auto bytes = recv(socket, &command, sizeof(command), 0);
if (bytes < 0 &&
(errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR))
break;
if (bytes != sizeof(command)) {
connected = false;
break;
}
heartbeat = Clock::now();
switch (command) {
case LinuxPrivacyCommand::query:
if (!privacy.Reply().active) privacy.Query();
break;
case LinuxPrivacyCommand::enable:
privacy.Enable(false);
break;
case LinuxPrivacyCommand::enable_blocking:
privacy.Enable(true);
break;
case LinuxPrivacyCommand::recover:
privacy.Recover();
break;
case LinuxPrivacyCommand::heartbeat:
break;
default:
connected = false;
break;
}
dirty = true;
}
if (!connected || stopping) break;
const auto before = privacy.Reply();
privacy.Poll();
if (privacy.Reply().active &&
Clock::now() - heartbeat > std::chrono::seconds(3))
privacy.Fail(
"Privacy controller stopped responding; local desktop and input "
"restored");
const auto& after = privacy.Reply();
dirty |= before.active != after.active ||
before.recovered != after.recovered ||
before.emergency != after.emergency ||
std::strcmp(before.failure, after.failure) != 0;
if (dirty) {
const auto& reply = privacy.Reply();
if (send(socket, &reply, sizeof(reply), MSG_NOSIGNAL) == sizeof(reply)) {
dirty = false;
privacy.AcknowledgeEmergency();
} else if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR)
break;
}
}
// Keep trying if the X server temporarily rejected restoration, even after
// the parent exited. Disconnecting releases XI grabs and cursor hiding too.
do {
privacy.Recover();
if (!privacy.Reply().recovered) poll(nullptr, 0, 250);
} while (!privacy.Reply().recovered);
const auto& reply = privacy.Reply();
const auto ignored = send(socket, &reply, sizeof(reply), MSG_NOSIGNAL);
(void)ignored;
close(socket);
return 0;
}
} // namespace crossdesk
@@ -0,0 +1,42 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-13
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _PRIVACY_GUARD_H_
#define _PRIVACY_GUARD_H_
#include <cstdint>
namespace crossdesk {
inline constexpr char kLinuxPrivacyGuardArgument[] = "--linux-privacy-guard";
inline constexpr int kLinuxPrivacyGuardSocket = 3;
// Private protocol over an inherited SOCK_SEQPACKET socket. No listening
// socket, shell, elevated privileges or separately installed helper is needed.
enum class LinuxPrivacyCommand : uint8_t {
query,
enable,
enable_blocking,
recover,
heartbeat,
};
struct LinuxPrivacyReply {
uint64_t generation = 0;
bool supported = false;
bool input_supported = false;
bool active = false;
bool recovered = true;
bool emergency = false;
char capability_reason[256]{};
char failure[256]{};
};
int RunLinuxPrivacyGuard();
} // namespace crossdesk
#endif
@@ -8,6 +8,7 @@
#include <display_stream_id.h>
#include "platform.h"
#include "privacy_controller.h"
#include "rd_log.h"
#if defined(CROSSDESK_HAS_DRM) && CROSSDESK_HAS_DRM
#include "screen_capturer_drm.h"
@@ -64,6 +65,13 @@ int ScreenCapturerLinux::Init(const int fps, cb_desktop_data cb) {
return;
}
invalid_stream_id_logged_.store(false, std::memory_order_relaxed);
{
std::lock_guard lock(privacy_mutex_);
if (privacy_ && !capture_paused_ && !capture_announced_) {
capture_announced_ = true;
privacy_->CaptureChanged(true);
}
}
if (callback_orig_) {
callback_orig_(data, size, width, height, mapped_stream_id.c_str(),
native_frame);
@@ -145,6 +153,7 @@ int ScreenCapturerLinux::Init(const int fps, cb_desktop_data cb) {
}
int ScreenCapturerLinux::Destroy() {
Stop();
if (impl_) {
impl_->Destroy();
impl_.reset();
@@ -169,6 +178,11 @@ int ScreenCapturerLinux::Start(bool show_cursor) {
return -1;
}
{
std::lock_guard lock(privacy_mutex_);
capture_paused_ = false;
}
// X11 output names, DRM connectors and Wayland stream handles may all change
// after an HDMI hotplug, so rebuild the current backend before each session.
const int refresh_ret = RefreshCurrentBackend();
@@ -226,6 +240,11 @@ int ScreenCapturerLinux::Stop() {
return 0;
}
const int ret = impl_->Stop();
{
std::lock_guard lock(privacy_mutex_);
capture_announced_ = false;
if (privacy_) privacy_->CaptureChanged(false);
}
UpdateAliasesFromBackend(impl_.get());
return ret;
}
@@ -234,13 +253,25 @@ int ScreenCapturerLinux::Pause(int monitor_index) {
if (!impl_) {
return -1;
}
return impl_->Pause(monitor_index);
const int ret = impl_->Pause(monitor_index);
{
// An in-flight frame must not announce running again after a pause.
std::lock_guard lock(privacy_mutex_);
capture_paused_ = true;
capture_announced_ = false;
if (privacy_) privacy_->CaptureChanged(false);
}
return ret;
}
int ScreenCapturerLinux::Resume(int monitor_index) {
if (!impl_) {
return -1;
}
{
std::lock_guard lock(privacy_mutex_);
capture_paused_ = false;
}
return impl_->Resume(monitor_index);
}
@@ -25,6 +25,7 @@ class ScreenCapturerLinux : public ScreenCapturer {
public:
int Init(const int fps, cb_desktop_data cb) override;
void SetPrivacyController(PrivacyController* privacy) override { privacy_ = privacy; }
int Destroy() override;
int Start(bool show_cursor) override;
int Stop() override;
@@ -53,6 +54,10 @@ class ScreenCapturerLinux : public ScreenCapturer {
private:
std::unique_ptr<ScreenCapturer> impl_;
PrivacyController* privacy_ = nullptr;
std::mutex privacy_mutex_;
bool capture_announced_ = false;
bool capture_paused_ = false;
BackendType backend_ = BackendType::kNone;
int fps_ = 60;
cb_desktop_data callback_;
@@ -57,6 +57,9 @@ std::unique_ptr<PrivacyBackend> CreateMacPrivacyBackend();
// Flush queued AppKit cleanup after joining the controller at app shutdown.
void FlushMacPrivacyTasks();
#endif
#ifdef __linux__
std::unique_ptr<PrivacyBackend> CreateLinuxPrivacyBackend();
#endif
} // namespace crossdesk
@@ -16,6 +16,8 @@ PrivacyController::PrivacyController(Factory factory) {
if (!factory) factory = CreateWindowsPrivacyBackend;
#elif defined(__APPLE__)
if (!factory) factory = CreateMacPrivacyBackend;
#elif defined(__linux__)
if (!factory) factory = CreateLinuxPrivacyBackend;
#endif
thread_ = std::thread([this, factory] { Run(factory); });
}
+4
View File
@@ -171,6 +171,9 @@ function setup_targets()
add_files("apps/desktop/src/platform/macos/privacy/*.mm")
add_mxxflags("-fobjc-arc")
add_frameworks("AppKit", "ApplicationServices", {public = true})
elseif is_os("linux") then
add_files("apps/desktop/src/platform/linux/privacy/*.cpp")
add_syslinks("Xi", {public = true})
end
if is_os("windows") then
@@ -209,6 +212,7 @@ function setup_targets()
"apps/desktop/src/platform/macos/screen_capturer/*.mm")
add_includedirs("apps/desktop/src/platform/macos/screen_capturer")
elseif is_os("linux") then
add_deps("privacy")
add_packages("libyuv")
add_files("apps/desktop/src/platform/linux/screen_capturer/screen_capturer_linux.cpp")
add_files("apps/desktop/src/platform/linux/screen_capturer/screen_capturer_x11.cpp")