/* 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