[fix] retain failed keyboard releases per controller

This commit is contained in:
dijunkun
2026-10-22 16:10:44 +08:00
parent 5e494bfa3b
commit c25e7266e5
4 changed files with 161 additions and 47 deletions
@@ -0,0 +1,29 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-22
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _INPUT_OUTCOME_H_
#define _INPUT_OUTCOME_H_
namespace crossdesk {
// Applied means the native/service API accepted the operation. It does not
// assert that the foreground application has consumed the injected event.
enum class InputOutcome { applied, ignored, failed };
inline const char* InputOutcomeName(InputOutcome outcome) {
switch (outcome) {
case InputOutcome::applied:
return "applied";
case InputOutcome::ignored:
return "ignored";
case InputOutcome::failed:
return "failed";
}
return "failed";
}
} // namespace crossdesk
#endif
@@ -4,6 +4,7 @@
#include <SDL3/SDL.h>
#include <algorithm>
#include <cstdint>
#include <mutex>
#include <nlohmann/json.hpp>
@@ -249,14 +250,14 @@ bool KeyboardController::InjectRemoteKey(int key_code, bool is_down,
extended);
}
void KeyboardController::ApplyRemoteEvent(const std::string& remote_id,
InputOutcome KeyboardController::ApplyRemoteEvent(const std::string& remote_id,
const RemoteAction& action) {
std::lock_guard input_lock(remote_input_mutex_);
const int key_code = static_cast<int>(action.k.key_value);
const bool is_down = action.k.flag == KeyFlag::key_down;
const bool injected =
InjectRemoteKey(key_code, is_down, action.k.scan_code, action.k.extended);
if (injected) pending_key_releases_.erase(key_code);
if (injected) pending_key_releases_.Supersede(key_code);
auto& state = remote_states_[remote_id];
state.last_seen_tick = static_cast<uint32_t>(SDL_GetTicks());
@@ -266,24 +267,28 @@ void KeyboardController::ApplyRemoteEvent(const std::string& remote_id,
} else if (!is_down && injected) {
state.pressed_keys.erase(key_code);
}
return injected ? InputOutcome::applied : InputOutcome::failed;
}
void KeyboardController::ApplyRemoteText(const std::string& remote_id,
InputOutcome KeyboardController::ApplyRemoteText(const std::string& remote_id,
const std::string& text) {
std::lock_guard input_lock(remote_input_mutex_);
if (!owner_.IsAuthorizedController(remote_id) ||
!owner_.devices_.SupportsTextInput()) return;
if (!owner_.devices_.SendTextCommand(text))
if (!owner_.IsAuthorizedController(remote_id)) return InputOutcome::ignored;
if (!owner_.devices_.SupportsTextInput()) return InputOutcome::failed;
if (!owner_.devices_.SendTextCommand(text)) {
LOG_WARN("Failed to inject committed remote text");
return InputOutcome::failed;
}
return InputOutcome::applied;
}
void KeyboardController::ApplyRemoteState(const std::string& remote_id,
InputOutcome KeyboardController::ApplyRemoteState(const std::string& remote_id,
const RemoteAction& action) {
std::lock_guard input_lock(remote_input_mutex_);
auto& state = remote_states_[remote_id];
if (action.ks.seq != 0 && state.last_seq != 0 &&
static_cast<int32_t>(action.ks.seq - state.last_seq) <= 0) {
return;
return InputOutcome::ignored;
}
state.last_seq = action.ks.seq;
@@ -300,56 +305,60 @@ void KeyboardController::ApplyRemoteState(const std::string& remote_id,
}
const auto changes = ReconcileKeyboardState(state.pressed_keys, desired_keys);
bool can_press = true;
bool applied = true;
for (const auto& key : changes) {
if (key.is_down && !can_press) continue;
if (!InjectRemoteKey(key.key_code, key.is_down, key.scan_code, key.extended)) {
// Do not replay a shortcut with a stale/missing modifier. The next
// heartbeat will retry from the state that was actually injected.
can_press = false;
applied = false;
continue;
}
pending_key_releases_.erase(key.key_code);
pending_key_releases_.Supersede(key.key_code);
if (key.is_down)
state.pressed_keys[key.key_code] =
{key.key_code, key.scan_code, key.extended};
else
state.pressed_keys.erase(key.key_code);
}
return applied ? InputOutcome::applied : InputOutcome::failed;
}
void KeyboardController::ReleaseRemotePressedKeys(const std::string& remote_id,
bool KeyboardController::ReleaseRemotePressedKeys(const std::string& remote_id,
const char* reason) {
std::lock_guard input_lock(remote_input_mutex_);
ReleaseRemotePressedKeysLocked(remote_id, reason);
return ReleaseRemotePressedKeysLocked(remote_id, reason);
}
void KeyboardController::ReleaseRemotePressedKeysLocked(
bool KeyboardController::ReleaseRemotePressedKeysLocked(
std::string remote_id, const char* reason) {
const auto state_it = remote_states_.find(remote_id);
if (state_it == remote_states_.end()) return;
auto keys_to_release = std::move(state_it->second.pressed_keys);
remote_states_.erase(state_it);
if (!keys_to_release.empty()) {
LOG_WARN("Releasing {} remote keyboard keys for remote_id={}, reason={}",
keys_to_release.size(), remote_id, reason ? reason : "unknown");
}
for (const auto& [code, key] : keys_to_release) {
if (!InjectRemoteKey(key.key_code, false, key.scan_code, key.extended)) {
pending_key_releases_[code] = key;
} else {
pending_key_releases_.erase(code);
if (state_it != remote_states_.end()) {
const auto& keys = state_it->second.pressed_keys;
if (!keys.empty()) {
LOG_WARN("Releasing {} remote keyboard keys for remote_id={}, reason={}",
keys.size(), remote_id, reason ? reason : "unknown");
}
// Stage before erasing: subsequent dispatcher retries must still see the
// keys if injection fails, even though active/heartbeat state is gone.
pending_key_releases_.Queue(remote_id, keys);
remote_states_.erase(state_it);
}
return pending_key_releases_.ReleaseController(
remote_id, [this](const KeyboardKey& key) {
return InjectRemoteKey(key.key_code, false, key.scan_code, key.extended);
});
}
void KeyboardController::ReleaseAllRemotePressedKeys(const char* reason) {
{
std::lock_guard input_lock(remote_input_mutex_);
while (!remote_states_.empty())
ReleaseRemotePressedKeysLocked(remote_states_.begin()->first, reason);
}
CheckRemoteTimeouts();
std::lock_guard input_lock(remote_input_mutex_);
auto ids = pending_key_releases_.ControllerIds();
for (const auto& [id, _] : remote_states_) ids.push_back(id);
std::sort(ids.begin(), ids.end());
ids.erase(std::unique(ids.begin(), ids.end()), ids.end());
// A failure remains pending; shutdown must never spin until it succeeds.
for (const auto& id : ids) ReleaseRemotePressedKeysLocked(id, reason);
}
void KeyboardController::CheckRemoteTimeouts() {
@@ -359,7 +368,7 @@ void KeyboardController::CheckRemoteTimeouts() {
const uint32_t now = static_cast<uint32_t>(SDL_GetTicks());
// Retain failed releases in ordinary mode as well as privacy mode. Retry
// only on the ordinary desktop, never through the security UI helper.
if (!pending_key_releases_.empty() &&
if (!pending_key_releases_.Empty() &&
(last_release_retry_tick_ == 0 ||
now - last_release_retry_tick_ >= kReleaseRetryIntervalMs)
#ifdef _WIN32
@@ -367,15 +376,10 @@ void KeyboardController::CheckRemoteTimeouts() {
#endif
) {
last_release_retry_tick_ = now;
for (auto it = pending_key_releases_.begin();
it != pending_key_releases_.end();) {
const auto& key = it->second;
if (owner_.devices_.SendKeyboardCommand(
key.key_code, false, key.scan_code, key.extended))
it = pending_key_releases_.erase(it);
else
++it;
}
pending_key_releases_.RetryAll([this](const KeyboardKey& key) {
return owner_.devices_.SendKeyboardCommand(
key.key_code, false, key.scan_code, key.extended);
});
}
// Keep the timeout decision and release under the same lock, so a fresh
@@ -9,6 +9,8 @@
#include <remote_action.h>
#include "keyboard_state.h"
#include "input_outcome.h"
#include "pending_key_releases.h"
namespace crossdesk {
@@ -25,12 +27,12 @@ public:
void ForceReleasePressedKeys();
void RecoverCapturedState(const PressedKeys& pressed);
void SendHeartbeat(bool force);
void ApplyRemoteEvent(const std::string &remote_id,
InputOutcome ApplyRemoteEvent(const std::string &remote_id,
const RemoteAction &remote_action);
void ApplyRemoteText(const std::string& remote_id, const std::string& text);
void ApplyRemoteState(const std::string &remote_id,
InputOutcome ApplyRemoteText(const std::string& remote_id, const std::string& text);
InputOutcome ApplyRemoteState(const std::string &remote_id,
const RemoteAction &remote_action);
void ReleaseRemotePressedKeys(const std::string &remote_id,
bool ReleaseRemotePressedKeys(const std::string &remote_id,
const char *reason);
void ReleaseAllRemotePressedKeys(const char* reason);
void CheckRemoteTimeouts();
@@ -48,7 +50,7 @@ private:
bool InjectRemoteKey(int key_code, bool is_down, uint32_t scan_code,
bool extended);
// Caller holds remote_input_mutex_. Copy the id because this erases state.
void ReleaseRemotePressedKeysLocked(std::string remote_id, const char* reason);
bool ReleaseRemotePressedKeysLocked(std::string remote_id, const char* reason);
GuiRuntime &owner_;
PressedKeys pressed_keys_;
@@ -59,7 +61,7 @@ private:
// Guards all remote key state and serializes injection, cleanup and retries.
std::mutex remote_input_mutex_;
// Successful new input supersedes cleanup of an older press of that key.
PressedKeys pending_key_releases_;
PendingKeyReleases pending_key_releases_;
uint32_t last_release_retry_tick_ = 0;
};
@@ -0,0 +1,79 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-22
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _PENDING_KEY_RELEASES_H_
#define _PENDING_KEY_RELEASES_H_
#include <string>
#include <unordered_map>
#include <vector>
#include "keyboard_state.h"
namespace crossdesk {
// Caller serializes access with KeyboardController's remote-input mutex.
// Pending releases outlive the peer's active key/heartbeat state, but retain
// their controller identity so cleanup cannot report success prematurely.
class PendingKeyReleases {
public:
void Queue(const std::string& id, const PressedKeys& keys) {
if (keys.empty()) return;
auto& pending = peers_[id];
for (const auto& [code, key] : keys) pending[code] = key;
}
bool Empty() const { return peers_.empty(); }
bool Contains(const std::string& id) const { return peers_.count(id) != 0; }
std::vector<std::string> ControllerIds() const {
std::vector<std::string> ids;
ids.reserve(peers_.size());
for (const auto& [id, _] : peers_) ids.push_back(id);
return ids;
}
// A confirmed new event supersedes an older pending release of that key,
// including one belonging to a disconnected controller.
void Supersede(int key_code) {
for (auto it = peers_.begin(); it != peers_.end();) {
it->second.erase(key_code);
if (it->second.empty())
it = peers_.erase(it);
else
++it;
}
}
template <typename SendUp>
bool ReleaseController(const std::string& id, SendUp send_up) {
auto peer = peers_.find(id);
if (peer == peers_.end()) return true;
auto& keys = peer->second;
for (auto it = keys.begin(); it != keys.end();) {
if (send_up(it->second))
it = keys.erase(it);
else
++it;
}
if (!keys.empty()) return false;
peers_.erase(peer);
return true;
}
template <typename SendUp>
void RetryAll(SendUp send_up) {
// One bounded pass, even when every native release fails.
for (const auto& id : ControllerIds()) ReleaseController(id, send_up);
}
private:
std::unordered_map<std::string, PressedKeys> peers_;
};
} // namespace crossdesk
#endif