From c6635fbdc858bf4fd96225b3b5bf651f0f00864c Mon Sep 17 00:00:00 2001 From: dijunkun Date: Sun, 13 Sep 2026 03:49:17 +0800 Subject: [PATCH] [feat] add Linux X11 privacy screen and automatic recovery --- apps/desktop/scripts/linux/package_deb.sh | 2 +- apps/desktop/src/app/main.cpp | 14 +- .../src/gui/application/gui_application.cpp | 10 +- .../src/gui/runtime/peer_event_handler.cpp | 2 +- .../linux/privacy/privacy_backend_linux.cpp | 244 ++++++ .../platform/linux/privacy/privacy_guard.cpp | 721 ++++++++++++++++++ .../platform/linux/privacy/privacy_guard.h | 42 + .../screen_capturer/screen_capturer_linux.cpp | 33 +- .../screen_capturer/screen_capturer_linux.h | 5 + apps/desktop/src/privacy/privacy_backend.h | 3 + .../src/privacy/privacy_controller.cpp | 2 + apps/desktop/xmake/targets.lua | 4 + 12 files changed, 1073 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/src/platform/linux/privacy/privacy_backend_linux.cpp create mode 100644 apps/desktop/src/platform/linux/privacy/privacy_guard.cpp create mode 100644 apps/desktop/src/platform/linux/privacy/privacy_guard.h diff --git a/apps/desktop/scripts/linux/package_deb.sh b/apps/desktop/scripts/linux/package_deb.sh index 2cc6dece..b9feed9f 100755 --- a/apps/desktop/scripts/linux/package_deb.sh +++ b/apps/desktop/scripts/linux/package_deb.sh @@ -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 diff --git a/apps/desktop/src/app/main.cpp b/apps/desktop/src/app/main.cpp index 6e2d3e1b..2f44b7cf 100644 --- a/apps/desktop/src/app/main.cpp +++ b/apps/desktop/src/app/main.cpp @@ -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; -} \ No newline at end of file +} diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index 41332e4a..5b518230 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -1241,7 +1241,7 @@ void GuiApplication::ResetSettingsUi() { ui_->main->set_capture_method_index( static_cast(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(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( diff --git a/apps/desktop/src/gui/runtime/peer_event_handler.cpp b/apps/desktop/src/gui/runtime/peer_event_handler.cpp index f8a33b69..1df37626 100644 --- a/apps/desktop/src/gui/runtime/peer_event_handler.cpp +++ b/apps/desktop/src/gui/runtime/peer_event_handler.cpp @@ -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(), diff --git a/apps/desktop/src/platform/linux/privacy/privacy_backend_linux.cpp b/apps/desktop/src/platform/linux/privacy/privacy_backend_linux.cpp new file mode 100644 index 00000000..6eb8fd9a --- /dev/null +++ b/apps/desktop/src/platform/linux/privacy/privacy_backend_linux.cpp @@ -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 +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#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(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(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 CreateLinuxPrivacyBackend() { + return std::make_unique(); +} +} // namespace crossdesk + +#endif diff --git a/apps/desktop/src/platform/linux/privacy/privacy_guard.cpp b/apps/desktop/src/platform/linux/privacy/privacy_guard.cpp new file mode 100644 index 00000000..3df50c1f --- /dev/null +++ b/apps/desktop/src/platform/linux/privacy/privacy_guard.cpp @@ -0,0 +1,721 @@ +/* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. */ +#include "privacy_guard.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace crossdesk { +namespace { +using Clock = std::chrono::steady_clock; +using Gamma = std::unique_ptr; +using Resources = + std::unique_ptr; +using CrtcInfo = std::unique_ptr; +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 +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 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 outputs; + std::vector 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(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(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 bits{}; + XISetMask(bits.data(), XI_RawKeyPress); + XISetMask(bits.data(), XI_RawKeyRelease); + XIEventMask mask{XIAllDevices, static_cast(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& 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& 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(input); + for (int key = 0; key < 256; ++key) + keys_[device.id][key] = keys->keys[key / 8] & (1 << (key % 8)); + } + input = reinterpret_cast( + reinterpret_cast(input) + input->length); + } + XFreeDeviceState(state); + XCloseDevice(display_, keyboard); + } + std::array bits{}; + for (int type : {XI_KeyPress, XI_KeyRelease, XI_ButtonPress, + XI_ButtonRelease, XI_Motion}) + XISetMask(bits.data(), type); + XIEventMask mask{device.id, static_cast(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 current; + std::vector 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 outputs_; + std::vector devices_; + std::vector grabbed_; + std::vector hidden_roots_; + std::map> 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 diff --git a/apps/desktop/src/platform/linux/privacy/privacy_guard.h b/apps/desktop/src/platform/linux/privacy/privacy_guard.h new file mode 100644 index 00000000..8f8be543 --- /dev/null +++ b/apps/desktop/src/platform/linux/privacy/privacy_guard.h @@ -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 + +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 \ No newline at end of file diff --git a/apps/desktop/src/platform/linux/screen_capturer/screen_capturer_linux.cpp b/apps/desktop/src/platform/linux/screen_capturer/screen_capturer_linux.cpp index 19e9d68d..df06bd60 100644 --- a/apps/desktop/src/platform/linux/screen_capturer/screen_capturer_linux.cpp +++ b/apps/desktop/src/platform/linux/screen_capturer/screen_capturer_linux.cpp @@ -8,6 +8,7 @@ #include #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); } diff --git a/apps/desktop/src/platform/linux/screen_capturer/screen_capturer_linux.h b/apps/desktop/src/platform/linux/screen_capturer/screen_capturer_linux.h index 523f0c23..ec9b1b27 100644 --- a/apps/desktop/src/platform/linux/screen_capturer/screen_capturer_linux.h +++ b/apps/desktop/src/platform/linux/screen_capturer/screen_capturer_linux.h @@ -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 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_; diff --git a/apps/desktop/src/privacy/privacy_backend.h b/apps/desktop/src/privacy/privacy_backend.h index 3865cecb..1973993a 100644 --- a/apps/desktop/src/privacy/privacy_backend.h +++ b/apps/desktop/src/privacy/privacy_backend.h @@ -57,6 +57,9 @@ std::unique_ptr CreateMacPrivacyBackend(); // Flush queued AppKit cleanup after joining the controller at app shutdown. void FlushMacPrivacyTasks(); #endif +#ifdef __linux__ +std::unique_ptr CreateLinuxPrivacyBackend(); +#endif } // namespace crossdesk diff --git a/apps/desktop/src/privacy/privacy_controller.cpp b/apps/desktop/src/privacy/privacy_controller.cpp index 1353b21e..2bad0dc4 100644 --- a/apps/desktop/src/privacy/privacy_controller.cpp +++ b/apps/desktop/src/privacy/privacy_controller.cpp @@ -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); }); } diff --git a/apps/desktop/xmake/targets.lua b/apps/desktop/xmake/targets.lua index 7307cec0..a2cc62d5 100644 --- a/apps/desktop/xmake/targets.lua +++ b/apps/desktop/xmake/targets.lua @@ -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")