diff --git a/CMakeLists.txt b/CMakeLists.txt index d8501f0..9797697 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -57,7 +57,7 @@ endif() # Dependencies # -------------------------------------------------------------------------- find_package(Threads REQUIRED) -find_package(Qt6 6.2 REQUIRED COMPONENTS Core Gui Widgets Network) +find_package(Qt6 6.2 REQUIRED COMPONENTS Core Gui Widgets Network DBus) find_package(PkgConfig REQUIRED) pkg_check_modules(SODIUM REQUIRED IMPORTED_TARGET libsodium) @@ -71,6 +71,10 @@ set(DISH_CORE_SOURCES src/Util/Hex.h src/Util/Hex.cpp src/Util/Endian.h + src/Util/DisplaySleepInhibitor.h + src/Util/DisplaySleepInhibitor.cpp + src/Util/ScreenWakeController.h + src/Util/ScreenWakeController.cpp src/Models/Models.h src/Models/Models.cpp src/Network/SatelliteClient.h @@ -100,7 +104,7 @@ add_library(dish_core STATIC ${DISH_CORE_SOURCES}) target_include_directories(dish_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src) target_link_libraries(dish_core PUBLIC - Qt6::Core Qt6::Network + Qt6::Core Qt6::Network Qt6::DBus PkgConfig::SODIUM PkgConfig::SDL2 Threads::Threads diff --git a/README.md b/README.md index f4e0f12..c1d7b08 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,44 @@ SDL thread publishes lock-free. - **`MSG_NOSIGNAL`** on every send so a server disconnect can't kill the process. +## Cross-platform behaviour parity + +The following behaviours mirror dish-android and dish-mac, so user-visible +behaviour stays predictable across platforms: + +- **Display-sleep inhibitor while streaming.** A `ScreenWakeController` reads + `hub.bindings × hub.connections`, derives a streaming-slot count, and flips + the `org.freedesktop.ScreenSaver.Inhibit` D-Bus cookie on every 0↔positive + transition. The cookie is released on the last unbind / disconnect, so a + forgotten session doesn't pin the display awake forever. Works under every + modern desktop environment that implements the freedesktop ScreenSaver + portal (GNOME, KDE, Xfce, MATE, Cinnamon, Sway/swayidle, …). +- **Connection state recovery.** `PairingClient` carries a `reachable` flag + on every `PairResponse` (true iff we received a JSON body). `classify(...)` + splits the outcome into `Success | AuthRequired | Unreachable`; the manager + fans those out to either `openSession`, a PIN dialog, or an error toast. + A moved/offline server now surfaces a clear + *"Server unreachable — has it moved networks?"* message instead of trapping + the user behind an unanswerable PIN prompt. Mirrors dish-android PR #43. +- **Auto-reconnect fast path.** `WifiConnectionManager::pairAndConnect` + skips the TCP pair handshake entirely when an empty PIN comes in and a + 64-char shared key is already on disk, going straight to `openSession`. + A moved server then fails fast in the HTTP layer rather than bouncing + through pair → `PairingRequired`. +- **Per-device deadzones.** `GamepadInputProcessor` carries a per-device + `Deadzones { stickFlat, triggerFlat }` table; reports are filtered + (`|v| <= flat → 0`) before they leave the processor. The default profile + (~10 % stick / ~5 % trigger) is installed by `SDLGamepadBridge` when each + controller attaches. SDL2 has no OS-level "flat" query equivalent to + Android's `InputDevice.getMotionRange(axis).getFlat()`, so the default + is the noise-floor we ship; future builds can read a per-device override + from the settings file. +- **Device-capability log on attach.** Every `SDL_CONTROLLERDEVICEADDED` logs + a one-shot `DEVCAPS` line via the `dish.input` Qt logging category, + carrying the stable id, controller name + type (SDL's `SDL_GameControllerType` + enum), USB VID / PID, and the SDL GUID. Aimed at users reporting *"my pad + doesn't work"* — same idea as Android's SatelliteJNI `DEVCAPS` log. + ## Requirements - A reasonably current Linux distro (Ubuntu 22.04+, Fedora 38+, Arch, …) @@ -77,10 +115,14 @@ SDL thread publishes lock-free. ```bash sudo apt install -y \ build-essential cmake ninja-build pkg-config \ - qt6-base-dev libsodium-dev libsdl2-dev \ + qt6-base-dev qt6-tools-dev libsodium-dev libsdl2-dev \ clang-format clang-tidy ``` +Note: `qt6-base-dev` already pulls in QtDBus on Debian/Ubuntu — required for +the `org.freedesktop.ScreenSaver.Inhibit` call that keeps the display awake +while streaming. + **Fedora (38+)** ```bash sudo dnf install -y \ @@ -183,10 +225,14 @@ ctest --test-dir build-debug --output-on-failure ``` Unit tests cover the hex/byte-packing utilities, the big-endian helpers, the -XUSB input mapping (axis and trigger scaling, button bitfield, zero-on-disconnect -fan-out), the lock-free atomic counter under contention, the lenient beacon -JSON decoder, and the model codable round-trips. They run in well under a -second and do not open sockets. +XUSB input mapping (axis and trigger scaling, button bitfield, per-device +deadzone application, zero-on-disconnect fan-out), the lock-free atomic +counter under contention, the lenient beacon JSON decoder, the model codable +round-trips, the `PairingClient::classify` outcome arms (Success / +AuthRequired / Unreachable), and the `ScreenWakeController` acquire/release +lifecycle via a fake `DisplaySleepInhibitor` (so the suite never has to +talk to a session bus). They run in well under a second and do not open +sockets. ## Development diff --git a/src/AppModel.cpp b/src/AppModel.cpp index f8c3b77..dbdcf00 100644 --- a/src/AppModel.cpp +++ b/src/AppModel.cpp @@ -6,11 +6,15 @@ namespace dish { AppModel::AppModel(QObject* parent) + : AppModel(std::make_unique(), parent) {} + +AppModel::AppModel(std::unique_ptr inhibitor, QObject* parent) : QObject(parent), store_(std::make_unique()), wifi_(new net::WifiConnectionManager(store_.get(), this)), hub_(new net::ConnectionHub(wifi_, store_.get(), this)), bridge_(new input::SDLGamepadBridge(&processor_, this)), - autoReconnectTimer_(new QTimer(this)) { + autoReconnectTimer_(new QTimer(this)), inhibitor_(std::move(inhibitor)), + wake_(inhibitor_.get()) { QObject::connect(hub_, &net::ConnectionHub::changed, this, &AppModel::onHubChanged); QObject::connect(bridge_, &input::SDLGamepadBridge::devicesChanged, this, &AppModel::onBridgeDevicesChanged); @@ -112,6 +116,17 @@ void AppModel::rebuild() { routing_ = std::move(nextRouting); } + // Drive the display-sleep inhibitor off bindings × hub.connections. The + // 0↔positive transitions inside ScreenWakeController acquire / release + // the D-Bus cookie; intermediate same-count emissions are no-ops so a + // noisy hub feed doesn't thrash the session bus. + QHash connectionStates; + for (const auto& summary : state_.connections) { + connectionStates.insert(summary.id, summary.live); + } + const int streamingCount = util::ScreenWakeController::streamingCount(bindings, connectionStates); + wake_.update(streamingCount); + emit stateChanged(); } diff --git a/src/AppModel.h b/src/AppModel.h index 9d43ec7..c6a414a 100644 --- a/src/AppModel.h +++ b/src/AppModel.h @@ -9,6 +9,8 @@ #include "Network/ConnectionHub.h" #include "Network/ConnectionStore.h" #include "Network/WifiConnectionManager.h" +#include "Util/DisplaySleepInhibitor.h" +#include "Util/ScreenWakeController.h" #include #include @@ -43,7 +45,10 @@ struct MainUiState { class AppModel : public QObject { Q_OBJECT public: + // Production constructor: builds a FreedesktopScreenSaverInhibitor under + // the hood. The unique_ptr overload below lets tests inject a fake. explicit AppModel(QObject* parent = nullptr); + AppModel(std::unique_ptr inhibitor, QObject* parent = nullptr); ~AppModel() override; net::ConnectionStore* store() { return store_.get(); } @@ -51,6 +56,7 @@ class AppModel : public QObject { net::ConnectionHub* hub() { return hub_; } input::GamepadInputProcessor* processor() { return &processor_; } input::SDLGamepadBridge* bridge() { return bridge_; } + util::ScreenWakeController* wake() { return &wake_; } // Single read-only accessor — the UI reads everything off this slice // and re-renders on stateChanged(). @@ -83,6 +89,11 @@ class AppModel : public QObject { input::GamepadInputProcessor processor_; input::SDLGamepadBridge* bridge_; QTimer* autoReconnectTimer_; + // Owned in unique_ptr so we can swap a FakeDisplaySleepInhibitor in + // tests. ScreenWakeController holds a raw back-pointer; lifetime is + // tied to the AppModel. + std::unique_ptr inhibitor_; + util::ScreenWakeController wake_; MainUiState state_; diff --git a/src/Input/GamepadInputProcessor.cpp b/src/Input/GamepadInputProcessor.cpp index afeec69..4ae9c66 100644 --- a/src/Input/GamepadInputProcessor.cpp +++ b/src/Input/GamepadInputProcessor.cpp @@ -13,18 +13,28 @@ void GamepadInputProcessor::setReportSender(ReportSender sender) { sender_ = std::move(sender); } +void GamepadInputProcessor::setDeadzones(const DeviceId& id, const Deadzones& dz) { + std::lock_guard lock(mtx_); + deadzones_[id] = dz; +} + void GamepadInputProcessor::publish(const DeviceId& id, const DeviceState& state) { ReportSender snapshot; + DeviceState filtered; { std::lock_guard lock(mtx_); - states_[id] = state; + Deadzones dz{}; + if (auto it = deadzones_.find(id); it != deadzones_.end()) { dz = it->second; } + filtered = applyDeadzones(state, dz); + states_[id] = filtered; ++telEvents_; ++telSends_; ++telTotalSent_; snapshot = sender_; } if (snapshot) { - snapshot(id, state.wButtons, state.lt, state.rt, state.lx, state.ly, state.rx, state.ry); + snapshot(id, filtered.wButtons, filtered.lt, filtered.rt, filtered.lx, filtered.ly, + filtered.rx, filtered.ry); } } @@ -47,6 +57,7 @@ void GamepadInputProcessor::zeroAndSendAll() { void GamepadInputProcessor::remove(const DeviceId& id) { std::lock_guard lock(mtx_); states_.erase(id); + deadzones_.erase(id); } GamepadInputProcessor::TelemetrySnapshot GamepadInputProcessor::drainTelemetry() { @@ -70,4 +81,17 @@ std::uint8_t scaleTrigger(float v) { return static_cast(std::clamp(scaled, 0, 255)); } +GamepadInputProcessor::DeviceState applyDeadzones(const GamepadInputProcessor::DeviceState& state, + const GamepadInputProcessor::Deadzones& dz) { + auto out = state; + const auto stickFlat = static_cast(dz.stickFlat); + if (std::abs(static_cast(out.lx)) <= stickFlat) { out.lx = 0; } + if (std::abs(static_cast(out.ly)) <= stickFlat) { out.ly = 0; } + if (std::abs(static_cast(out.rx)) <= stickFlat) { out.rx = 0; } + if (std::abs(static_cast(out.ry)) <= stickFlat) { out.ry = 0; } + if (out.lt <= dz.triggerFlat) { out.lt = 0; } + if (out.rt <= dz.triggerFlat) { out.rt = 0; } + return out; +} + } // namespace dish::input diff --git a/src/Input/GamepadInputProcessor.h b/src/Input/GamepadInputProcessor.h index e36858c..ba3fe34 100644 --- a/src/Input/GamepadInputProcessor.h +++ b/src/Input/GamepadInputProcessor.h @@ -58,6 +58,20 @@ class GamepadInputProcessor { } }; + // Per-axis deadzone thresholds. Values whose absolute magnitude is at or + // below the flat are zeroed before the report leaves the processor — + // mirrors the per-device `flat` values Android pulls out of + // `InputDevice.getMotionRange(axis).getFlat()`. SDL2 doesn't surface an + // OS-level equivalent, so SDLGamepadBridge installs a sensible default + // when each device attaches. + struct Deadzones { + std::int16_t stickFlat = 0; + std::uint8_t triggerFlat = 0; + bool operator==(const Deadzones& o) const { + return stickFlat == o.stickFlat && triggerFlat == o.triggerFlat; + } + }; + struct TelemetrySnapshot { int events = 0; int sends = 0; @@ -65,6 +79,7 @@ class GamepadInputProcessor { }; void setReportSender(ReportSender sender); + void setDeadzones(const DeviceId& id, const Deadzones& dz); void publish(const DeviceId& id, const DeviceState& state); void zeroAndSendAll(); void remove(const DeviceId& id); @@ -73,6 +88,7 @@ class GamepadInputProcessor { private: std::mutex mtx_; std::unordered_map states_; + std::unordered_map deadzones_; ReportSender sender_; int telEvents_ = 0; int telSends_ = 0; @@ -83,4 +99,10 @@ class GamepadInputProcessor { std::int16_t scaleAxis(float v, float maxMagnitude); std::uint8_t scaleTrigger(float v); +// Pure deadzone application. Sticks: `|v| <= flat → 0`. Triggers: `v <= flat +// → 0`. Buttons are passed through. Extracted as a free function so tests can +// pin the arithmetic without the processor's lock plumbing. +GamepadInputProcessor::DeviceState applyDeadzones(const GamepadInputProcessor::DeviceState& state, + const GamepadInputProcessor::Deadzones& dz); + } // namespace dish::input diff --git a/src/Input/SDLGamepadBridge.cpp b/src/Input/SDLGamepadBridge.cpp index a1d2271..5891dbc 100644 --- a/src/Input/SDLGamepadBridge.cpp +++ b/src/Input/SDLGamepadBridge.cpp @@ -5,6 +5,7 @@ #include +#include #include #include @@ -13,6 +14,15 @@ namespace dish::input { namespace { +Q_LOGGING_CATEGORY(lcDishInput, "dish.input") + +// Conservative noise-floor defaults applied to every newly-attached controller. +// ~10 % of the int16 stick range and ~5 % of the 0..255 trigger range. Mirrors +// the per-device flat values Android pulls out of +// `InputDevice.getMotionRange(axis).getFlat()`. SDL2 has no equivalent. +constexpr std::int16_t kDefaultStickFlat = 3277; +constexpr std::uint8_t kDefaultTriggerFlat = 13; + // SDL_GameController axes are int16 [-32768, 32767]; pass through directly. std::int16_t axisValue(SDL_GameController* gc, SDL_GameControllerAxis axis) { return SDL_GameControllerGetAxis(gc, axis); @@ -71,12 +81,35 @@ void SDLGamepadBridge::runLoop() { SDL_Joystick* js = SDL_GameControllerGetJoystick(gc); const int iid = SDL_JoystickInstanceID(js); const auto* name = SDL_GameControllerName(gc); + const QString deviceId = QStringLiteral("sdl:%1").arg(iid); + const QString deviceName = QString::fromUtf8(name != nullptr ? name : "Gamepad"); { std::lock_guard lock(mtx_); openControllers_[iid] = gc; - deviceIds_[iid] = QStringLiteral("sdl:%1").arg(iid); - deviceNames_[iid] = QString::fromUtf8(name != nullptr ? name : "Gamepad"); + deviceIds_[iid] = deviceId; + deviceNames_[iid] = deviceName; } + // One-shot device-capability dump — mirrors the SatelliteJNI + // DEVCAPS log on Android (PR #44/#47). SDL reports the controller + // type it negotiated (Xbox 360 / DualSense / generic), the vendor + // / product id, and the GUID; together that pins what mapping was + // applied so users reporting "my pad doesn't work" get a usable + // diagnostic without a debugger. + const auto type = SDL_GameControllerGetType(gc); + const auto vid = SDL_GameControllerGetVendor(gc); + const auto pid = SDL_GameControllerGetProduct(gc); + char guidBuf[64] = {0}; + SDL_JoystickGetGUIDString(SDL_JoystickGetGUID(js), guidBuf, sizeof(guidBuf)); + qCInfo(lcDishInput) << "DEVCAPS id=" << deviceId << "name=" << deviceName + << "type=" << static_cast(type) + << "vid=" << QString::number(vid, 16) + << "pid=" << QString::number(pid, 16) << "guid=" << guidBuf; + // Push the default deadzone profile so the processor filters + // out controller noise from the first event. The default lives + // inside the bridge (not the processor) because the bridge is + // the only thing that knows when a device shows up. + processor_->setDeadzones(deviceId.toStdString(), + {kDefaultStickFlat, kDefaultTriggerFlat}); QMetaObject::invokeMethod(this, "devicesChanged", Qt::QueuedConnection); rebuildState(iid); break; diff --git a/src/Models/Models.cpp b/src/Models/Models.cpp index 0a72c4a..60d9d5f 100644 --- a/src/Models/Models.cpp +++ b/src/Models/Models.cpp @@ -42,6 +42,10 @@ PairResponse PairResponse::fromJson(const QJsonObject& obj) { r.ok = obj.value("ok").toBool(false); if (auto e = optString(obj, "error"); !e.isEmpty()) { r.error = e; } if (auto k = optString(obj, "sharedKey"); !k.isEmpty()) { r.sharedKey = k; } + // We got far enough to parse a JSON body, so the server is reachable — + // even if ok=false. PairingClient sets reachable=false explicitly on + // every network-level error path. + r.reachable = true; return r; } diff --git a/src/Models/Models.h b/src/Models/Models.h index f8a7499..fd65140 100644 --- a/src/Models/Models.h +++ b/src/Models/Models.h @@ -41,6 +41,12 @@ struct PairResponse { bool ok = false; std::optional error; std::optional sharedKey; + // True iff we received any JSON body from the server. False for synthesized + // failure responses (socket / connect / send errors). Not on the wire — + // the server never sends this field; it's set client-side by + // `PairingClient::pair` so the manager can distinguish "moved networks" + // from "needs PIN". Mirrors dish-mac PairResponse.reachable. + bool reachable = false; static PairResponse fromJson(const QJsonObject& obj); }; diff --git a/src/Network/PairingClient.cpp b/src/Network/PairingClient.cpp index c58164a..1c1df68 100644 --- a/src/Network/PairingClient.cpp +++ b/src/Network/PairingClient.cpp @@ -26,11 +26,25 @@ models::PairResponse makeError(const char* msg) { models::PairResponse r; r.ok = false; r.error = QString::fromLatin1(msg); + // Synthesized network-error responses are unreachable by construction — + // we never made it far enough to receive a JSON body. fromJson flips this + // to true on the success path. + r.reachable = false; return r; } } // namespace +PairingClient::Outcome PairingClient::classify(const models::PairResponse& response) { + if (response.ok && response.sharedKey.has_value() && !response.sharedKey->isEmpty()) { + return Success{*response.sharedKey}; + } + if (response.reachable) { + return AuthRequired{}; + } + return Unreachable{response.error.value_or(QStringLiteral("Server unreachable"))}; +} + models::PairResponse PairingClient::pair(const QString& ip, int port, const QString& deviceId, const QString& deviceName, const QString& pin) { const int sock = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); diff --git a/src/Network/PairingClient.h b/src/Network/PairingClient.h index 5555df0..437619d 100644 --- a/src/Network/PairingClient.h +++ b/src/Network/PairingClient.h @@ -7,12 +7,32 @@ #include +#include + namespace dish::net { // Blocking TCP pair handshake on :9878. Mirrors dish-mac/Network/PairingClient.swift // and satellite_jni.cpp::pair. Single JSON request line, single JSON response. class PairingClient { public: + // Classification of a PairResponse — mirrors PairingClient.Outcome on + // dish-mac and the unreachable-vs-auth split introduced for dish-android + // PR #43. The manager fans the variant out to either an error toast, a + // PIN dialog, or the openSession path. Tagged union (variant) keeps the + // arms exhaustive and the success arm carries the shared key directly. + struct Success { + QString sharedKeyHex; + }; + struct AuthRequired {}; + struct Unreachable { + QString message; + }; + using Outcome = std::variant; + + // Pure classifier — driven only by fields on the response so it's + // trivially unit-testable. + static Outcome classify(const models::PairResponse& response); + static models::PairResponse pair(const QString& ip, int port, const QString& deviceId, const QString& deviceName, const QString& pin); }; diff --git a/src/Network/WifiConnectionManager.cpp b/src/Network/WifiConnectionManager.cpp index c24febb..13ea269 100644 --- a/src/Network/WifiConnectionManager.cpp +++ b/src/Network/WifiConnectionManager.cpp @@ -10,6 +10,9 @@ #include #include +#include +#include + namespace dish::net { namespace { @@ -83,6 +86,19 @@ void WifiConnectionManager::pairWithPin(const models::DiscoveredServer& server, void WifiConnectionManager::pairAndConnect(WifiConnection* conn, const models::DiscoveredServer& server, const QString& pin) { + // Auto-reconnect fast path (pin.isEmpty()): if we already have a shared + // key saved for this server, skip the TCP pair handshake entirely and + // go straight to openSession. A moved/offline server then fails fast in + // the HTTP layer instead of bouncing through pair → PairingRequired and + // trapping the user behind a PIN prompt that can't be satisfied. Mirrors + // dish-android PR #43. + if (pin.isEmpty()) { + const auto saved = store_->sharedKey(WifiConnection::idFor(server)); + if (saved.has_value() && saved->size() == 64) { + openSession(conn, server); + return; + } + } const QString did = deviceId_; const QString dname = deviceName_; auto* watcher = new QFutureWatcher(this); @@ -90,18 +106,29 @@ void WifiConnectionManager::pairAndConnect(WifiConnection* conn, watcher, &QFutureWatcherBase::finished, this, [this, watcher, conn, server, pin] { const auto pair = watcher->result(); watcher->deleteLater(); - if (!pair.ok || !pair.sharedKey.has_value()) { - conn->markDisconnected(); - if (pin.isEmpty()) { - emit connectionEvent(pairingRequired(server)); - } else { - emit connectionEvent( - makeError(pair.error.value_or(QStringLiteral("Pairing failed")))); - } - return; - } - store_->setSharedKey(*pair.sharedKey, WifiConnection::idFor(server)); - openSession(conn, server); + const auto outcome = PairingClient::classify(pair); + std::visit( + [&](auto&& arm) { + using T = std::decay_t; + if constexpr (std::is_same_v) { + store_->setSharedKey(arm.sharedKeyHex, WifiConnection::idFor(server)); + openSession(conn, server); + } else if constexpr (std::is_same_v) { + conn->markDisconnected(); + if (pin.isEmpty()) { + emit connectionEvent(pairingRequired(server)); + } else { + emit connectionEvent(makeError( + pair.error.value_or(QStringLiteral("Pairing failed")))); + } + } else if constexpr (std::is_same_v) { + conn->markDisconnected(); + emit connectionEvent(makeError( + QStringLiteral("Server unreachable — has it moved networks? (%1)") + .arg(arm.message))); + } + }, + outcome); }); watcher->setFuture(QtConcurrent::run([server, did, dname, pin] { return PairingClient::pair(server.ip, server.pairPort, did, dname, pin); diff --git a/src/Util/DisplaySleepInhibitor.cpp b/src/Util/DisplaySleepInhibitor.cpp new file mode 100644 index 0000000..cb24f42 --- /dev/null +++ b/src/Util/DisplaySleepInhibitor.cpp @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "DisplaySleepInhibitor.h" + +#include +#include +#include +#include + +namespace dish::util { + +namespace { + +Q_LOGGING_CATEGORY(lcDishWake, "dish.wake") + +QString screenSaverService() { return QStringLiteral("org.freedesktop.ScreenSaver"); } +QString screenSaverPath() { return QStringLiteral("/org/freedesktop/ScreenSaver"); } + +} // namespace + +FreedesktopScreenSaverInhibitor::FreedesktopScreenSaverInhibitor(QObject* parent) + : DisplaySleepInhibitor(parent) {} + +FreedesktopScreenSaverInhibitor::~FreedesktopScreenSaverInhibitor() { + // RAII: never leak a cookie even if the AppModel forgot to release. The + // session bus disappearing on logout is also fine — DBus drops cookies + // tied to dead peers automatically. + if (cookie_.has_value()) { + QDBusInterface iface(screenSaverService(), screenSaverPath(), screenSaverService(), + QDBusConnection::sessionBus()); + iface.call(QStringLiteral("UnInhibit"), *cookie_); + } +} + +void FreedesktopScreenSaverInhibitor::acquire(const QString& reason) { + if (cookie_.has_value()) { return; } + QDBusInterface iface(screenSaverService(), screenSaverPath(), screenSaverService(), + QDBusConnection::sessionBus()); + if (!iface.isValid()) { + qCWarning(lcDishWake) << "org.freedesktop.ScreenSaver unavailable on session bus:" + << iface.lastError().message(); + return; + } + const QDBusReply reply = + iface.call(QStringLiteral("Inhibit"), QStringLiteral("Dish"), reason); + if (!reply.isValid()) { + qCWarning(lcDishWake) << "ScreenSaver.Inhibit failed:" << reply.error().message(); + return; + } + cookie_ = reply.value(); + qCDebug(lcDishWake) << "ScreenSaver.Inhibit cookie=" << *cookie_ << "reason=" << reason; +} + +void FreedesktopScreenSaverInhibitor::release() { + if (!cookie_.has_value()) { return; } + QDBusInterface iface(screenSaverService(), screenSaverPath(), screenSaverService(), + QDBusConnection::sessionBus()); + if (iface.isValid()) { iface.call(QStringLiteral("UnInhibit"), *cookie_); } + qCDebug(lcDishWake) << "ScreenSaver.UnInhibit cookie=" << *cookie_; + cookie_.reset(); +} + +} // namespace dish::util diff --git a/src/Util/DisplaySleepInhibitor.h b/src/Util/DisplaySleepInhibitor.h new file mode 100644 index 0000000..d5b6e9b --- /dev/null +++ b/src/Util/DisplaySleepInhibitor.h @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#pragma once + +#include +#include + +#include + +namespace dish::util { + +// Keeps the system display awake while Dish is streaming. The Linux analogue +// of Android's WakeStateController (PARTIAL_WAKE_LOCK + FLAG_KEEP_SCREEN_ON) +// and dish-mac's IOPMAssertionCreateWithName. Implemented over D-Bus by +// calling org.freedesktop.ScreenSaver.Inhibit on the session bus — the +// portal every modern desktop environment honours (GNOME, KDE, Xfce, Cinnamon, +// MATE, Sway/swayidle, …). +// +// Tests use FakeDisplaySleepInhibitor so we can pin the acquire/release +// lifecycle without a session-bus dependency in CI. +class DisplaySleepInhibitor : public QObject { + Q_OBJECT + public: + explicit DisplaySleepInhibitor(QObject* parent = nullptr) : QObject(parent) {} + ~DisplaySleepInhibitor() override = default; + + // Idempotent: a second acquire while already held is a no-op so callers + // don't have to track state themselves. + virtual void acquire(const QString& reason) = 0; + // Idempotent: releasing while not held is a no-op. + virtual void release() = 0; + // True iff an inhibit cookie is currently held. + virtual bool isHeld() const = 0; +}; + +// Production implementation. Held in a dedicated class so the cookie lifetime +// is tied to the object's lifetime — destructor releases on dealloc. +class FreedesktopScreenSaverInhibitor : public DisplaySleepInhibitor { + Q_OBJECT + public: + explicit FreedesktopScreenSaverInhibitor(QObject* parent = nullptr); + ~FreedesktopScreenSaverInhibitor() override; + + void acquire(const QString& reason) override; + void release() override; + bool isHeld() const override { return cookie_.has_value(); } + + private: + std::optional cookie_; +}; + +} // namespace dish::util diff --git a/src/Util/ScreenWakeController.cpp b/src/Util/ScreenWakeController.cpp new file mode 100644 index 0000000..28dc824 --- /dev/null +++ b/src/Util/ScreenWakeController.cpp @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "ScreenWakeController.h" + +namespace dish::util { + +ScreenWakeController::ScreenWakeController(DisplaySleepInhibitor* inhibitor, QString reason) + : inhibitor_(inhibitor), reason_(std::move(reason)) {} + +int ScreenWakeController::streamingCount( + const QHash& bindings, + const QHash& connectionStates) { + int count = 0; + for (auto it = bindings.begin(); it != bindings.end(); ++it) { + if (connectionStates.value(it.value(), models::ConnectionLive::Idle) == + models::ConnectionLive::Connected) { + ++count; + } + } + return count; +} + +void ScreenWakeController::update(int streamingSlotCount) { + const int was = streamingSlotCount_; + streamingSlotCount_ = streamingSlotCount; + if (was == 0 && streamingSlotCount > 0) { + if (inhibitor_ != nullptr) { inhibitor_->acquire(reason_); } + } else if (was > 0 && streamingSlotCount == 0) { + if (inhibitor_ != nullptr) { inhibitor_->release(); } + } +} + +void ScreenWakeController::reset() { + streamingSlotCount_ = 0; + if (inhibitor_ != nullptr) { inhibitor_->release(); } +} + +} // namespace dish::util diff --git a/src/Util/ScreenWakeController.h b/src/Util/ScreenWakeController.h new file mode 100644 index 0000000..81631cd --- /dev/null +++ b/src/Util/ScreenWakeController.h @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#pragma once + +#include "DisplaySleepInhibitor.h" +#include "Models/Models.h" + +#include +#include + +namespace dish::util { + +// Owns the "are we streaming?" count and drives a DisplaySleepInhibitor off +// it. Mirrors dish-android :: WakeStateController and dish-mac :: +// ScreenWakeController — the inhibitor flips on the 0↔positive transition, +// same-count emissions are no-ops so a noisy hub feed doesn't thrash D-Bus. +// +// Pure logic, no Qt signal subscription. The AppModel chooses when to call +// update(...); the controller decides whether to call inhibitor.acquire / +// release. Lets tests pin the transition contract via FakeDisplaySleepInhibitor. +class ScreenWakeController { + public: + explicit ScreenWakeController( + DisplaySleepInhibitor* inhibitor, + QString reason = QStringLiteral("Dish is streaming gamepad input to Satellite")); + + int streamingSlotCount() const { return streamingSlotCount_; } + + // Pure helper that derives the count of bound + connected slots from the + // current binding table and the per-connection live state. Extracted so + // unit tests can pin the arithmetic without instantiating a controller. + static int streamingCount(const QHash& bindings, + const QHash& connectionStates); + + // Feed the controller a fresh streaming count. Acquires the inhibitor on + // the 0 → positive transition; releases on positive → 0. Same value twice + // is a no-op so callers can spam updates without thrash. + void update(int streamingSlotCount); + + // Backgrounding / shutdown path. Drops the inhibitor unconditionally and + // resets the count so the next update re-establishes from scratch. + void reset(); + + private: + DisplaySleepInhibitor* inhibitor_; + QString reason_; + int streamingSlotCount_ = 0; +}; + +} // namespace dish::util diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6861080..e62f2ef 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -20,7 +20,10 @@ set(DISH_TEST_SOURCES test_endian.cpp test_models.cpp test_beacon_parser.cpp - test_gamepad_input_processor.cpp) + test_gamepad_input_processor.cpp + test_pairing_client_classify.cpp + test_screen_wake_controller.cpp + test_freedesktop_screensaver_inhibitor.cpp) add_executable(DishTests ${DISH_TEST_SOURCES}) target_link_libraries(DishTests diff --git a/tests/test_freedesktop_screensaver_inhibitor.cpp b/tests/test_freedesktop_screensaver_inhibitor.cpp new file mode 100644 index 0000000..7264537 --- /dev/null +++ b/tests/test_freedesktop_screensaver_inhibitor.cpp @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// Pins the production DisplaySleepInhibitor implementation (the one backed +// by `org.freedesktop.ScreenSaver.Inhibit` via QtDBus). +// `test_screen_wake_controller.cpp` already covers the abstract +// DisplaySleepInhibitor contract via a fake; this file exercises the +// concrete impl too so the lifecycle isn't a "checked at runtime only" +// surface. +// +// Caveat: the freedesktop ScreenSaver service may not be present on a +// headless CI runner (no session bus, no DE). The inhibitor handles that +// gracefully — `acquire` silently fails when the QDBusInterface is +// invalid — so the contract we verify here is the bits that don't depend +// on the bus actually answering: default state, idempotent release, no-op +// re-acquire, and the destructor not crashing on a never-held instance. + +#include "Util/DisplaySleepInhibitor.h" + +#include + +#include + +#include + +using dish::util::FreedesktopScreenSaverInhibitor; + +namespace { + +// QDBusConnection::sessionBus() works without a QCoreApplication but +// produces stderr warnings. A single shared QCoreApplication for the +// whole test suite keeps those quiet without forcing every test file to +// create one. +class QtAppSingleton { + public: + static void ensure() { + if (QCoreApplication::instance() != nullptr) { return; } + static int argc = 0; + static char* argv[] = {nullptr}; + // Leak intentionally — Catch2 calls std::exit, which runs atexit + // handlers. Owning the QCoreApplication in a unique_ptr that the + // first test creates would tear it down in the middle of a later + // test's teardown if Catch2 reorders cases. + static auto* app = new QCoreApplication(argc, argv); + (void) app; + } +}; + +} // namespace + +TEST_CASE("Freedesktop inhibitor starts unheld", "[wake][linux]") { + QtAppSingleton::ensure(); + const FreedesktopScreenSaverInhibitor inh; + REQUIRE_FALSE(inh.isHeld()); +} + +TEST_CASE("Freedesktop inhibitor never crashes regardless of session-bus availability", + "[wake][linux]") { + QtAppSingleton::ensure(); + // The contract: regardless of whether the freedesktop ScreenSaver + // service is reachable, every method must be safe to call and the + // state must stay self-consistent. On a real desktop with a live + // session bus, isHeld() will be true after acquire; on CI without + // a bus it will be false — both branches are correct. + FreedesktopScreenSaverInhibitor inh; + REQUIRE_FALSE(inh.isHeld()); + inh.acquire(QStringLiteral("test reason")); + + // We don't assert isHeld() here — too environment-dependent. The + // important behaviour is that release() is safe whether or not + // acquire() succeeded. + inh.release(); + REQUIRE_FALSE(inh.isHeld()); + + // Idempotent re-release on an unheld inhibitor. + inh.release(); + REQUIRE_FALSE(inh.isHeld()); + + // Re-acquire/release cycle leaves us back at unheld. + inh.acquire(QStringLiteral("second")); + inh.release(); + REQUIRE_FALSE(inh.isHeld()); +} + +TEST_CASE("Freedesktop inhibitor destructor on a never-acquired instance is a no-op", + "[wake][linux]") { + QtAppSingleton::ensure(); + { + FreedesktopScreenSaverInhibitor inh; + REQUIRE_FALSE(inh.isHeld()); + // No acquire — dtor runs at scope exit and must NOT attempt a + // UnInhibit DBus call on a cookie that doesn't exist. + } + SUCCEED(); +} + +TEST_CASE("Freedesktop inhibitor when held: dtor cleans up the cookie", "[wake][linux]") { + QtAppSingleton::ensure(); + // Same caveat as above: on CI, isHeld() may stay false after acquire + // because there's no session bus. We still pin that the dtor path is + // safe in either case — if the cookie was real, dtor sends UnInhibit; + // if it was synthetic, dtor short-circuits on `!cookie_.has_value()`. + { + FreedesktopScreenSaverInhibitor inh; + inh.acquire(QStringLiteral("dies on scope exit")); + // dtor runs here regardless of isHeld() value. + } + SUCCEED(); +} diff --git a/tests/test_gamepad_input_processor.cpp b/tests/test_gamepad_input_processor.cpp index 65bc3fc..c188869 100644 --- a/tests/test_gamepad_input_processor.cpp +++ b/tests/test_gamepad_input_processor.cpp @@ -7,7 +7,10 @@ #include #include +#include +#include +using dish::input::applyDeadzones; using dish::input::GamepadInputProcessor; using dish::input::scaleAxis; using dish::input::scaleTrigger; @@ -89,3 +92,125 @@ TEST_CASE("drainTelemetry resets per-second counters and keeps lifetime total", REQUIRE(snap2.sends == 0); REQUIRE(snap2.totalSent == 3); } + +// --------------------------------------------------------------------------- +// Per-device deadzones — mirrors the dish-mac GamepadInputProcessor tests +// and the Android per-device `flat` pipeline. Pinning these here keeps the +// wire format identical across all three clients. +// --------------------------------------------------------------------------- + +TEST_CASE("applyDeadzones zeroes sticks at or below threshold", "[input]") { + GamepadInputProcessor::Deadzones dz{3277, 13}; + GamepadInputProcessor::DeviceState s; + s.lx = 1500; + s.ly = -2000; + s.rx = 3277; + s.ry = -3277; + const auto out = applyDeadzones(s, dz); + REQUIRE(out.lx == 0); + REQUIRE(out.ly == 0); + REQUIRE(out.rx == 0); + REQUIRE(out.ry == 0); +} + +TEST_CASE("applyDeadzones passes sticks above threshold", "[input]") { + GamepadInputProcessor::Deadzones dz{3277, 13}; + GamepadInputProcessor::DeviceState s; + s.lx = 3278; + s.ly = -3278; + s.rx = 32767; + s.ry = -32767; + const auto out = applyDeadzones(s, dz); + REQUIRE(out.lx == 3278); + REQUIRE(out.ly == -3278); + REQUIRE(out.rx == 32767); + REQUIRE(out.ry == -32767); +} + +TEST_CASE("applyDeadzones zeroes triggers at or below threshold", "[input]") { + GamepadInputProcessor::Deadzones dz{0, 13}; + GamepadInputProcessor::DeviceState s; + s.lt = 5; + s.rt = 13; + const auto out = applyDeadzones(s, dz); + REQUIRE(out.lt == 0); + REQUIRE(out.rt == 0); +} + +TEST_CASE("applyDeadzones passes triggers above threshold", "[input]") { + GamepadInputProcessor::Deadzones dz{0, 13}; + GamepadInputProcessor::DeviceState s; + s.lt = 14; + s.rt = 255; + const auto out = applyDeadzones(s, dz); + REQUIRE(out.lt == 14); + REQUIRE(out.rt == 255); +} + +TEST_CASE("applyDeadzones never touches buttons", "[input]") { + GamepadInputProcessor::Deadzones dz{32767, 255}; + GamepadInputProcessor::DeviceState s; + s.wButtons = 0xABCD; + const auto out = applyDeadzones(s, dz); + REQUIRE(out.wButtons == 0xABCD); +} + +TEST_CASE("publish uses per-device deadzones", "[input]") { + GamepadInputProcessor p; + std::int16_t lastLx = -1; + std::int16_t lastLy = -1; + std::uint8_t lastLt = 0xFF; + std::uint8_t lastRt = 0xFF; + p.setReportSender([&](const std::string&, std::uint16_t, std::uint8_t lt, std::uint8_t rt, + std::int16_t lx, std::int16_t ly, std::int16_t, std::int16_t) { + lastLx = lx; + lastLy = ly; + lastLt = lt; + lastRt = rt; + }); + p.setDeadzones("pad-1", {5000, 20}); + GamepadInputProcessor::DeviceState s; + s.lx = 4999; + s.ly = 5001; + s.lt = 18; + s.rt = 21; + p.publish("pad-1", s); + REQUIRE(lastLx == 0); + REQUIRE(lastLy == 5001); + REQUIRE(lastLt == 0); + REQUIRE(lastRt == 21); +} + +TEST_CASE("publish applies different deadzones per device", "[input]") { + GamepadInputProcessor p; + std::unordered_map byId; + p.setReportSender([&](const std::string& id, std::uint16_t, std::uint8_t, std::uint8_t, + std::int16_t lx, std::int16_t, std::int16_t, std::int16_t) { + byId[id] = lx; + }); + p.setDeadzones("lax", {0, 0}); + p.setDeadzones("strict", {10000, 0}); + GamepadInputProcessor::DeviceState s; + s.lx = 500; + p.publish("lax", s); + p.publish("strict", s); + REQUIRE(byId["lax"] == 500); + REQUIRE(byId["strict"] == 0); +} + +TEST_CASE("remove clears deadzones too", "[input]") { + GamepadInputProcessor p; + std::int16_t lastLx = -1; + p.setReportSender([&](const std::string&, std::uint16_t, std::uint8_t, std::uint8_t, + std::int16_t lx, std::int16_t, std::int16_t, std::int16_t) { + lastLx = lx; + }); + p.setDeadzones("pad", {5000, 0}); + p.remove("pad"); + // After remove, a fresh publish should not pull the old deadzone — small + // input passes through. + GamepadInputProcessor::DeviceState s; + s.lx = 100; + p.publish("pad", s); + REQUIRE(lastLx == 100); +} diff --git a/tests/test_pairing_client_classify.cpp b/tests/test_pairing_client_classify.cpp new file mode 100644 index 0000000..3035ab5 --- /dev/null +++ b/tests/test_pairing_client_classify.cpp @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "Models/Models.h" +#include "Network/PairingClient.h" + +#include + +#include + +using dish::net::PairingClient; +using dish::models::PairResponse; + +namespace { + +template +bool holds(const PairingClient::Outcome& o) { + return std::holds_alternative(o); +} + +} // namespace + +TEST_CASE("classify: ok + sharedKey returns Success", "[pairing]") { + PairResponse r; + r.ok = true; + r.sharedKey = QStringLiteral("abcd"); + r.reachable = true; + const auto o = PairingClient::classify(r); + REQUIRE(holds(o)); + REQUIRE(std::get(o).sharedKeyHex == "abcd"); +} + +TEST_CASE("classify: reachable but !ok returns AuthRequired", "[pairing]") { + PairResponse r; + r.ok = false; + r.reachable = true; + r.error = QStringLiteral("bad pin"); + REQUIRE(holds(PairingClient::classify(r))); +} + +TEST_CASE("classify: unreachable surfaces network error", "[pairing]") { + PairResponse r; + r.ok = false; + r.reachable = false; + r.error = QStringLiteral("connect timeout"); + const auto o = PairingClient::classify(r); + REQUIRE(holds(o)); + REQUIRE(std::get(o).message == "connect timeout"); +} + +TEST_CASE("classify: unreachable without error falls back to default", "[pairing]") { + PairResponse r; + r.ok = false; + r.reachable = false; + const auto o = PairingClient::classify(r); + REQUIRE(holds(o)); + REQUIRE(std::get(o).message == "Server unreachable"); +} + +TEST_CASE("classify: ok but empty sharedKey is AuthRequired, not Success", "[pairing]") { + // Defensive: a server that says ok=true but forgets to send a key should + // fall through to AuthRequired (we did reach it), never Success. Caching + // an empty string as the shared key would silently break every + // subsequent reconnect. + PairResponse r; + r.ok = true; + r.sharedKey = QStringLiteral(""); + r.reachable = true; + REQUIRE(holds(PairingClient::classify(r))); +} + +TEST_CASE("classify: ok with no sharedKey is AuthRequired", "[pairing]") { + PairResponse r; + r.ok = true; + r.sharedKey.reset(); + r.reachable = true; + REQUIRE(holds(PairingClient::classify(r))); +} + +TEST_CASE("PairResponse::fromJson sets reachable=true on a parsed body", "[pairing]") { + // The wire never carries `reachable` — it's set client-side by fromJson + // (success path) or by the synthesised error helpers in PairingClient + // (network-error paths). Pin both branches. + const QJsonObject body{{"ok", true}, {"sharedKey", "deadbeef"}}; + const auto r = PairResponse::fromJson(body); + REQUIRE(r.ok); + REQUIRE(r.reachable); + REQUIRE(r.sharedKey.has_value()); + REQUIRE(*r.sharedKey == "deadbeef"); +} + +TEST_CASE("PairResponse default-constructed is reachable=false", "[pairing]") { + PairResponse r; + REQUIRE_FALSE(r.reachable); +} diff --git a/tests/test_screen_wake_controller.cpp b/tests/test_screen_wake_controller.cpp new file mode 100644 index 0000000..5893714 --- /dev/null +++ b/tests/test_screen_wake_controller.cpp @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "Util/DisplaySleepInhibitor.h" +#include "Util/ScreenWakeController.h" + +#include + +#include +#include + +using dish::models::ConnectionLive; +using dish::util::DisplaySleepInhibitor; +using dish::util::ScreenWakeController; + +namespace { + +// A fake inhibitor that records the acquire/release lifecycle. The real +// FreedesktopScreenSaverInhibitor requires a running session bus, which is +// usually unavailable in CI containers — a fake keeps the tests self-contained. +class FakeInhibitor : public DisplaySleepInhibitor { + public: + void acquire(const QString& reason) override { + if (!held_) { + ++acquires_; + held_ = true; + lastReason_ = reason; + } + } + void release() override { + if (held_) { + ++releases_; + held_ = false; + } + } + bool isHeld() const override { return held_; } + + int acquires() const { return acquires_; } + int releases() const { return releases_; } + QString lastReason() const { return lastReason_; } + + private: + int acquires_ = 0; + int releases_ = 0; + bool held_ = false; + QString lastReason_; +}; + +} // namespace + +// --------------------------------------------------------------------------- +// streamingCount — pure derivation +// --------------------------------------------------------------------------- + +TEST_CASE("streamingCount: zero when nothing is bound", "[wake]") { + QHash bindings; + QHash states; + states.insert("c1", ConnectionLive::Connected); + REQUIRE(ScreenWakeController::streamingCount(bindings, states) == 0); +} + +TEST_CASE("streamingCount: ignores bindings to idle / connecting", "[wake]") { + QHash bindings{ + {"slot-a", "conn-1"}, {"slot-b", "conn-2"}, {"slot-c", "conn-3"}}; + QHash states{ + {"conn-1", ConnectionLive::Idle}, + {"conn-2", ConnectionLive::Connecting}, + {"conn-3", ConnectionLive::Connected}, + }; + REQUIRE(ScreenWakeController::streamingCount(bindings, states) == 1); +} + +TEST_CASE("streamingCount: counts multiple connected slots", "[wake]") { + QHash bindings{{"a", "c1"}, {"b", "c2"}, {"c", "c3"}}; + QHash states{ + {"c1", ConnectionLive::Connected}, + {"c2", ConnectionLive::Connected}, + {"c3", ConnectionLive::Idle}, + }; + REQUIRE(ScreenWakeController::streamingCount(bindings, states) == 2); +} + +TEST_CASE("streamingCount: unknown connection counts as idle", "[wake]") { + QHash bindings{{"a", "missing"}}; + QHash states; + REQUIRE(ScreenWakeController::streamingCount(bindings, states) == 0); +} + +// --------------------------------------------------------------------------- +// update() drives the inhibitor on 0↔positive transitions +// --------------------------------------------------------------------------- + +TEST_CASE("update: first stream acquires inhibitor", "[wake]") { + FakeInhibitor fake; + ScreenWakeController c(&fake, QStringLiteral("test reason")); + c.update(1); + REQUIRE(fake.acquires() == 1); + REQUIRE(fake.releases() == 0); + REQUIRE(fake.isHeld()); + REQUIRE(fake.lastReason() == "test reason"); +} + +TEST_CASE("update: 1 → 2 slots does not re-acquire", "[wake]") { + FakeInhibitor fake; + ScreenWakeController c(&fake); + c.update(1); + c.update(2); + REQUIRE(fake.acquires() == 1); +} + +TEST_CASE("update: positive → 0 releases", "[wake]") { + FakeInhibitor fake; + ScreenWakeController c(&fake); + c.update(2); + c.update(0); + REQUIRE(fake.acquires() == 1); + REQUIRE(fake.releases() == 1); + REQUIRE_FALSE(fake.isHeld()); +} + +TEST_CASE("update: staying at 0 is idempotent", "[wake]") { + FakeInhibitor fake; + ScreenWakeController c(&fake); + c.update(0); + c.update(0); + REQUIRE(fake.acquires() == 0); + REQUIRE(fake.releases() == 0); +} + +TEST_CASE("update: re-acquires after a drop", "[wake]") { + FakeInhibitor fake; + ScreenWakeController c(&fake); + c.update(1); + c.update(0); + c.update(1); + REQUIRE(fake.acquires() == 2); + REQUIRE(fake.releases() == 1); + REQUIRE(fake.isHeld()); +} + +TEST_CASE("reset: releases and zeros the count", "[wake]") { + FakeInhibitor fake; + ScreenWakeController c(&fake); + c.update(3); + c.reset(); + REQUIRE(c.streamingSlotCount() == 0); + REQUIRE(fake.releases() == 1); + REQUIRE_FALSE(fake.isHeld()); +} + +TEST_CASE("ScreenWakeController tolerates a null inhibitor", "[wake]") { + // Defensive: a future build flag or stripped-down packaging may pass + // nullptr (e.g. headless). The controller must still bookkeep its count + // without crashing — important because the same instance is then used by + // every connect/disconnect transition in the AppModel. + ScreenWakeController c(nullptr); + c.update(1); + c.update(0); + c.reset(); + REQUIRE(c.streamingSlotCount() == 0); +}