Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@ _noctalia_sources = files(
'src/dbus/bluetooth/bluetooth_service.cpp',
'src/dbus/idle/screensaver_service.cpp',
'src/dbus/logind/logind_service.cpp',
'src/dbus/logind/logind_session.cpp',
'src/dbus/mpris/mpris_art.cpp',
'src/dbus/mpris/mpris_service.cpp',
'src/dbus/network/external_ip_service.cpp',
Expand Down Expand Up @@ -818,6 +819,7 @@ _noctalia_sources = files(
'src/system/day_night_schedule.cpp',
'src/system/gamma_service.cpp',
'src/system/location_service.cpp',
'src/capture/screencopy_blocking.cpp',
'src/capture/screencopy_capture.cpp',
'src/capture/screencopy_util.cpp',
'src/capture/screenshot_service.cpp',
Expand Down Expand Up @@ -1148,6 +1150,7 @@ if build_tests
'kde_color_scheme',
'log',
'location_service',
'logind_session',
'math_provider',
'monitor_selector',
'notification_filter',
Expand All @@ -1164,6 +1167,7 @@ if build_tests
'plugin_source_paths',
'process',
'scheme',
'screencopy_blocking',
'secret_store',
'security_primitives',
'state_store',
Expand Down
96 changes: 96 additions & 0 deletions src/capture/screencopy_blocking.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#include "capture/screencopy_blocking.h"

#include <algorithm>
#include <memory>
#include <utility>

namespace screencopy {

WaitOutcome waitForCapture(
const EventWaitOps& ops, const std::function<bool()>& done, std::chrono::steady_clock::time_point deadline
) {
while (!done()) {
const auto now = ops.now();
if (now >= deadline) {
return WaitOutcome::TimedOut;
}

// Hand the wait what is left of the budget, never more: this is the only
// thing standing between a silent compositor and a frozen main loop.
const auto remaining =
std::max(std::chrono::duration_cast<std::chrono::milliseconds>(deadline - now), std::chrono::milliseconds{0});
if (ops.waitAndDispatch(remaining) < 0) {
// The connection may still have handed us the completion on its way
// out — take it rather than reporting a failure we already recovered.
return done() ? WaitOutcome::Completed : WaitOutcome::Error;
}
}
return WaitOutcome::Completed;
}

bool runBlockingCapture(
const BlockingCaptureOps& capture, const EventWaitOps& wait, ScreencopyImage& out, std::string& error,
std::chrono::milliseconds timeout
) {
error.clear();

// Heap state, not the caller's stack: once we give up (timeout, dispatch
// error) a completion that still arrives must have nothing of ours left to
// write to. `abandoned` turns that late fire into a no-op instead of a
// use-after-return on `out` and `error`.
struct State {
bool finished = false;
bool abandoned = false;
std::optional<ScreencopyImage> image;
std::string error;
};
const auto state = std::make_shared<State>();

capture.start([state](std::optional<ScreencopyImage> image, std::string err) {
if (state->abandoned) {
return;
}
state->finished = true;
if (!err.empty() || !image.has_value()) {
state->error = err.empty() ? "screencopy capture failed" : std::move(err);
return;
}
state->image = std::move(image);
});

const auto done = [&state, &capture] { return state->finished || !capture.busy(); };
const auto outcome = waitForCapture(wait, done, wait.now() + timeout);

switch (outcome) {
case WaitOutcome::Completed:
break;
case WaitOutcome::TimedOut:
state->abandoned = true;
capture.cancel();
error = "screencopy capture timed out";
return false;
case WaitOutcome::Error:
state->abandoned = true;
capture.cancel();
error = "Wayland event dispatch failed";
return false;
}

if (!state->error.empty()) {
error = state->error;
return false;
}
if (!state->finished || !state->image.has_value()) {
error = "screencopy capture failed";
return false;
}

out = std::move(*state->image);
if (out.width <= 0 || out.height <= 0 || out.rgba.empty()) {
error = "screencopy capture returned an empty frame";
return false;
}
return true;
}

} // namespace screencopy
47 changes: 47 additions & 0 deletions src/capture/screencopy_blocking.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#pragma once

#include "capture/screencopy_capture.h"

#include <chrono>
#include <functional>
#include <string>

namespace screencopy {

// How long a blocking capture waits for the compositor before giving up.
inline constexpr std::chrono::milliseconds kBlockingCaptureTimeout{2000};

enum class WaitOutcome { Completed, TimedOut, Error };

// The wait side of a blocking capture, kept free of Wayland types so the
// deadline arithmetic is testable without a compositor.
//
// `waitAndDispatch` must block for AT MOST the timeout it is handed — the
// deadline can only be honoured if the wait itself is bounded — and return
// >0 when it dispatched events, 0 when the wait expired (or was interrupted)
// with no progress, <0 on a connection error.
struct EventWaitOps {
std::function<int(std::chrono::milliseconds)> waitAndDispatch;
std::function<std::chrono::steady_clock::time_point()> now = [] { return std::chrono::steady_clock::now(); };
};

[[nodiscard]] WaitOutcome waitForCapture(
const EventWaitOps& ops, const std::function<bool()>& done, std::chrono::steady_clock::time_point deadline
);

// The capture side, likewise free of Wayland types.
struct BlockingCaptureOps {
std::function<void(ScreencopyCapture::CompletionCallback)> start;
std::function<bool()> busy;
std::function<void()> cancel;
};

// Starts a capture and pumps `wait` until it settles or `timeout` elapses.
// On timeout or dispatch error the in-flight capture is cancelled and the
// completion callback is abandoned: a late completion writes nothing.
[[nodiscard]] bool runBlockingCapture(
const BlockingCaptureOps& capture, const EventWaitOps& wait, ScreencopyImage& out, std::string& error,
std::chrono::milliseconds timeout = kBlockingCaptureTimeout
);

} // namespace screencopy
112 changes: 77 additions & 35 deletions src/capture/screencopy_util.cpp
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
#include "capture/screencopy_util.h"

#include "capture/screencopy_blocking.h"
#include "capture/screencopy_capture.h"
#include "wayland/wayland_connection.h"

#include <algorithm>
#include <cerrno>
#include <chrono>
#include <cstring>
#include <poll.h>
#include <wayland-client-core.h>
#include <wayland-client-protocol.h>

Expand Down Expand Up @@ -194,51 +198,89 @@ namespace {
}
}

} // namespace

namespace screencopy {

bool captureOutputBlocking(
ScreencopyCapture& capture, WaylandConnection& wayland, wl_output* output, ScreencopyImage& out,
std::string& error, bool overlayCursor
) {
error.clear();
bool finished = false;
capture.capture(
output, std::nullopt, overlayCursor, [&](std::optional<ScreencopyImage> image, const std::string& err) {
finished = true;
if (!err.empty() || !image.has_value()) {
error = err.empty() ? "screencopy capture failed" : err;
return;
}
out = std::move(*image);
}
);
// Deadline-aware wait on the Wayland fd. wl_display_roundtrip() cannot be
// used here: it blocks until the server answers, so a compositor that never
// delivers ready/failed for a frame (seen 2026-08-04 with lock-screen
// snapshots on Hyprland) blocks it forever and no deadline checked around it
// can fire. Dispatch what is already queued, then poll the fd for at most
// the remaining budget and dispatch whatever arrived.
//
// Returns >0 when events were dispatched, 0 when the wait expired or was
// interrupted with no progress, <0 on a connection error.
[[nodiscard]] int waitAndDispatchWayland(wl_display* display, std::chrono::milliseconds timeout) {
if (display == nullptr) {
return -1;
}

if (!error.empty()) {
return false;
if (wl_display_dispatch_pending(display) < 0) {
return -1;
}

while (!finished && capture.busy()) {
if (wl_display_roundtrip(wayland.display()) < 0) {
error = "Wayland roundtrip failed";
return false;
// prepare_read/read_events rather than wl_display_dispatch(): only the
// latter pair lets us sit in our own poll() with a timeout.
while (wl_display_prepare_read(display) != 0) {
// prepare_read only refuses while the queue still holds events, so drain
// them; any progress goes back to the caller, which re-checks the
// deadline before waiting again.
const int dispatched = wl_display_dispatch_pending(display);
if (dispatched < 0) {
return -1;
}
if (dispatched > 0) {
return dispatched;
}
// Nothing drained and still cannot prepare: yield instead of spinning.
return 0;
}

if (!error.empty() || !finished) {
if (error.empty()) {
error = "screencopy capture failed";
}
return false;
if (wl_display_flush(display) < 0 && errno != EAGAIN) {
wl_display_cancel_read(display);
return -1;
}

if (out.width <= 0 || out.height <= 0 || out.rgba.empty()) {
error = "screencopy capture returned an empty frame";
return false;
pollfd pollFd{.fd = wl_display_get_fd(display), .events = POLLIN, .revents = 0};
const int ready = ::poll(&pollFd, 1, static_cast<int>(timeout.count()));
if (ready < 0) {
wl_display_cancel_read(display);
return errno == EINTR ? 0 : -1;
}
if (ready == 0) {
wl_display_cancel_read(display);
return 0;
}

return true;
if (wl_display_read_events(display) < 0) {
return -1;
}
return wl_display_dispatch_pending(display);
}

} // namespace

namespace screencopy {

bool captureOutputBlocking(
ScreencopyCapture& capture, WaylandConnection& wayland, wl_output* output, ScreencopyImage& out,
std::string& error, bool overlayCursor
) {
// The wait is bounded (see runBlockingCapture): a compositor that never
// delivers ready/failed for this capture used to spin here forever —
// inside the main loop, freezing the whole shell. On timeout the caller
// falls back to the wallpaper background.
const BlockingCaptureOps ops{
.start = [&](
ScreencopyCapture::CompletionCallback onComplete
) { capture.capture(output, std::nullopt, overlayCursor, std::move(onComplete)); },
.busy = [&] { return capture.busy(); },
.cancel = [&] { capture.cancelInFlight(); },
};
const EventWaitOps wait{
.waitAndDispatch = [&](std::chrono::milliseconds timeout) {
return waitAndDispatchWayland(wayland.display(), timeout);
},
};

return runBlockingCapture(ops, wait, out, error);
}

bool orientCaptureNative(ScreencopyImage& image, const WaylandConnection& wayland, wl_output* output) {
Expand Down
41 changes: 8 additions & 33 deletions src/dbus/logind/logind_service.cpp
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
#include "dbus/logind/logind_service.h"

#include "core/log.h"
#include "dbus/logind/logind_session.h"
#include "dbus/system_bus.h"

#include <cstdlib>
#include <fcntl.h>
#include <optional>
#include <sdbus-c++/Error.h>
Expand Down Expand Up @@ -35,34 +35,6 @@ namespace {
return ::fcntl(fd, F_SETFD, flags | FD_CLOEXEC) == 0;
}

[[nodiscard]] std::optional<sdbus::ObjectPath> resolveSessionPath(sdbus::IConnection& connection) {
try {
auto managerProxy = sdbus::createProxy(connection, kLogindBusName, kLogindObjectPath);

if (const char* sessionId = std::getenv("XDG_SESSION_ID"); sessionId != nullptr && sessionId[0] != '\0') {
try {
sdbus::ObjectPath sessionPath;
managerProxy->callMethod("GetSession")
.onInterface(kLogindManagerInterface)
.withArguments(std::string(sessionId))
.storeResultsTo(sessionPath);
return sessionPath;
} catch (const sdbus::Error& e) {
kLog.debug("failed to resolve logind session via XDG_SESSION_ID={}: {}", sessionId, e.what());
}
}

sdbus::ObjectPath sessionPath;
managerProxy->callMethod("GetSessionByPID")
.onInterface(kLogindManagerInterface)
.withArguments(static_cast<std::uint32_t>(::getpid()))
.storeResultsTo(sessionPath);
return sessionPath;
} catch (const sdbus::Error& e) {
kLog.warn("failed to resolve logind session: {}", e.what());
return std::nullopt;
}
}
} // namespace

LogindService::LogindService(SystemBus& bus) : m_bus(bus) {
Expand All @@ -84,13 +56,13 @@ void LogindService::ensureSessionLockMonitor() {
return;
}

const auto sessionPath = resolveSessionPath(m_bus.connection());
if (!sessionPath.has_value()) {
const auto session = logind::resolveSession(m_bus.connection());
if (!session.has_value()) {
kLog.warn("logind session lock monitor disabled: session path unavailable");
return;
}

m_sessionProxy = sdbus::createProxy(m_bus.connection(), kLogindBusName, *sessionPath);
m_sessionProxy = sdbus::createProxy(m_bus.connection(), kLogindBusName, session->path);
m_sessionProxy->uponSignal("Lock").onInterface(kLogindSessionInterface).call([this]() {
if (m_lockCallback) {
m_lockCallback();
Expand All @@ -101,7 +73,10 @@ void LogindService::ensureSessionLockMonitor() {
m_unlockCallback();
}
});
kLog.info("logind session lock monitor active ({})", std::string(sessionPath->c_str()));
kLog.info(
"logind session lock monitor active ({}, resolved via {})", std::string(session->path.c_str()),
logind::describe(session->source)
);
}

void LogindService::setSessionLockIntegrationEnabled(bool enabled) {
Expand Down
Loading