* Rebase the Cua Hyprland plugin on Driver 0.32.0 source Driver 0.31.0 took the independent agent keymaps and Num Lock handling from #473 upstream, so independent-keymaps.patch goes. Upstream also added an up-front check that every key the KEY command can press types the US keysym, modifier keys included, which refuses all foreground typing under ctrl:swapcaps, compose:ralt, altwin:swap_alt_win or compose:102. foreground-remaps.patch makes that check run the per-chord check every key already passes over exactly the chords Driver types text with, with Num Lock off and on, so a string is still admitted or refused before its first key and other remaps are left to the per-chord check. 0.32.0 also gives agent keyboards a real repeat rate, which stops single-seat clients like imv crashing on an agent seat (trycua/cua#4257). cua-driver-bin stays at 0.28.2, which speaks the same input protocol v3: Driver 0.28.3 through 0.32.0 refuse desktop capture on Hyprland with more than one output or with one away from the origin (trycua/cua#4161). Co-Authored-By: Codex XHigh <noreply@openai.com> * Keep restarting fcitx5 from crashing Hyprland under the Cua plugin With plugin input enabled, fcitx5 requests an input method for each of the three seats, and when it exits Hyprland 0.56.2 can still hold an input-method popup that is mapped although its wl_surface is gone: the popup's surface-destroy handler emits unmap but never clears m_mapped. Tearing down the input method then updates every popup, CInputPopup::updateBox dereferences the missing surface and Hyprland restarts in safe mode. Omarchy's omarchy-restart-xcompose reaches this in normal use. The plugin now hooks CInputPopup::updateBox and CInputPopup::shouldBeRendered so a popup whose getSurface() is empty is neither placed nor rendered, reports the guard in cua:status, and removes the hooks on unload. The guard is compiled into the module only, so the mock-based tests are unchanged. foreground-remaps.patch becomes downstream.patch now that it carries both changes. * Pin the Cua Hyprland plugin to Arch's hyprland 0.56.2-4 rebuild Arch rebuilt Hyprland 0.56.2 against vulkan-sdk 1.4.363 and glslang. The executable and two headers change, so the plugin's exact compositor pin and hashes would block hyprland upgrades for anyone with it installed, and Cua's kit has no profile for it (trycua/cua#4216). The release, compiler and runtime are unchanged, so the derivation now also takes the re-measured compositor executable and header inventory; the build's verifier checks both against the installed package. * Qualify the Cua plugin README on its input-method guard The guard is installed best effort, so the README promises the fix only while it is active, and the activation checklist now asks for ime_popup_guard: true. 'No hooks' meant package-manager hooks, which the plugin's new function hooks made ambiguous. Co-Authored-By: Codex XHigh <noreply@openai.com> --------- Co-authored-by: Codex XHigh <noreply@openai.com>
318 lines
14 KiB
Diff
318 lines
14 KiB
Diff
diff -ruN a/CMakeLists.txt b/CMakeLists.txt
|
|
--- a/CMakeLists.txt
|
|
+++ b/CMakeLists.txt
|
|
@@ -244,7 +244,8 @@
|
|
"Hyprland headers expose no supported hyprctl plugin command API")
|
|
endif()
|
|
|
|
- add_library(cua_hyprland_plugin MODULE src/plugin.cpp)
|
|
+ add_library(cua_hyprland_plugin MODULE src/plugin.cpp src/ime_popup_guard.cpp)
|
|
+ target_compile_definitions(cua_hyprland_plugin PRIVATE CUA_HYPRLAND_IME_POPUP_GUARD=1)
|
|
if(CUA_HYPRLAND_TEST_INPUT OR CUA_HYPRLAND_INPUT)
|
|
if(NOT HYPRLAND_VERSION STREQUAL "0.56.2")
|
|
message(FATAL_ERROR "Input is pinned to Hyprland 0.56.2")
|
|
diff -ruN a/src/ime_popup_guard.cpp b/src/ime_popup_guard.cpp
|
|
--- a/src/ime_popup_guard.cpp
|
|
+++ b/src/ime_popup_guard.cpp
|
|
@@ -0,0 +1,69 @@
|
|
+#include "ime_popup_guard.hpp"
|
|
+
|
|
+#include <src/managers/input/InputMethodPopup.hpp>
|
|
+#include <src/plugins/PluginAPI.hpp>
|
|
+#include <src/protocols/core/Compositor.hpp>
|
|
+
|
|
+namespace cua::hyprland {
|
|
+namespace {
|
|
+
|
|
+void* g_plugin = nullptr;
|
|
+CFunctionHook* g_update_box = nullptr;
|
|
+CFunctionHook* g_should_be_rendered = nullptr;
|
|
+
|
|
+// Hyprland 0.56.2 leaves an input-method popup mapped after its wl_surface is
|
|
+// destroyed, then places and renders it through the missing surface. fcitx5
|
|
+// registers an input method per wl_seat, which is how the agent seats make that
|
|
+// order reachable when it exits. A popup without a surface is neither placed
|
|
+// nor rendered.
|
|
+void guarded_update_box(void* popup) {
|
|
+ if (!static_cast<CInputPopup*>(popup)->getSurface())
|
|
+ return;
|
|
+ reinterpret_cast<void (*)(void*)>(g_update_box->m_original)(popup);
|
|
+}
|
|
+
|
|
+bool guarded_should_be_rendered(void* popup) {
|
|
+ if (!static_cast<CInputPopup*>(popup)->getSurface())
|
|
+ return false;
|
|
+ return reinterpret_cast<bool (*)(void*)>(g_should_be_rendered->m_original)(popup);
|
|
+}
|
|
+
|
|
+CFunctionHook* hook_exact(void* plugin, const std::string& symbol, void* replacement) {
|
|
+ const auto matches = HyprlandAPI::findFunctionsByName(plugin, symbol);
|
|
+ if (matches.size() != 1)
|
|
+ return nullptr;
|
|
+ auto* hook = HyprlandAPI::createFunctionHook(plugin, matches.front().address, replacement);
|
|
+ if (hook && !hook->hook()) {
|
|
+ HyprlandAPI::removeFunctionHook(plugin, hook);
|
|
+ return nullptr;
|
|
+ }
|
|
+ return hook;
|
|
+}
|
|
+
|
|
+} // namespace
|
|
+
|
|
+bool install_ime_popup_guard(void* plugin) {
|
|
+ if (ime_popup_guard_active())
|
|
+ return true;
|
|
+ g_plugin = plugin;
|
|
+ g_update_box = hook_exact(plugin, "_ZN11CInputPopup9updateBoxEv", reinterpret_cast<void*>(&guarded_update_box));
|
|
+ g_should_be_rendered = hook_exact(plugin, "_ZN11CInputPopup16shouldBeRenderedEv",
|
|
+ reinterpret_cast<void*>(&guarded_should_be_rendered));
|
|
+ if (!ime_popup_guard_active())
|
|
+ remove_ime_popup_guard();
|
|
+ return ime_popup_guard_active();
|
|
+}
|
|
+
|
|
+void remove_ime_popup_guard() {
|
|
+ for (auto* hook : {g_update_box, g_should_be_rendered})
|
|
+ if (hook)
|
|
+ HyprlandAPI::removeFunctionHook(g_plugin, hook);
|
|
+ g_update_box = g_should_be_rendered = nullptr;
|
|
+ g_plugin = nullptr;
|
|
+}
|
|
+
|
|
+bool ime_popup_guard_active() {
|
|
+ return g_update_box && g_should_be_rendered;
|
|
+}
|
|
+
|
|
+} // namespace cua::hyprland
|
|
diff -ruN a/src/ime_popup_guard.hpp b/src/ime_popup_guard.hpp
|
|
--- a/src/ime_popup_guard.hpp
|
|
+++ b/src/ime_popup_guard.hpp
|
|
@@ -0,0 +1,10 @@
|
|
+#pragma once
|
|
+
|
|
+namespace cua::hyprland {
|
|
+
|
|
+// Installs the guard described in ime_popup_guard.cpp. Returns whether it is active.
|
|
+bool install_ime_popup_guard(void* plugin);
|
|
+void remove_ime_popup_guard();
|
|
+bool ime_popup_guard_active();
|
|
+
|
|
+} // namespace cua::hyprland
|
|
diff -ruN a/src/input_experiment.cpp b/src/input_experiment.cpp
|
|
--- a/src/input_experiment.cpp
|
|
+++ b/src/input_experiment.cpp
|
|
@@ -369,9 +369,10 @@
|
|
if (physical_keymap) xkb_keymap_unref(physical_keymap);
|
|
if (physical_xkb_context) xkb_context_unref(physical_xkb_context);
|
|
physical_keyboard_state = state; physical_keymap = map; physical_xkb_context = context;
|
|
- // Keyboard qualification: every key the KEY command can press must type the
|
|
- // same keysym as the canonical agent keymap. Checked once per keymap, so a
|
|
- // different layout refuses before activation rather than mid-string.
|
|
+ // Keyboard qualification: every chord Driver types text with must mean the
|
|
+ // same under this keymap as under the canonical agent keymap. Checked once
|
|
+ // per keymap, so a different layout refuses before activation rather than
|
|
+ // mid-string.
|
|
typing_keymap = !kProduction || typing_keymap_equivalent(map, keymap);
|
|
physical_keymap_text = keyboard->m_xkbKeymapV1String;
|
|
}
|
|
diff -ruN a/src/keymap_equivalence.hpp b/src/keymap_equivalence.hpp
|
|
--- a/src/keymap_equivalence.hpp
|
|
+++ b/src/keymap_equivalence.hpp
|
|
@@ -31,34 +31,6 @@
|
|
}
|
|
}
|
|
|
|
-// True when typing through `physical` produces the same keysyms as `canonical`
|
|
-// for every key the foreground KEY command can press (evdev 1-247, minus the
|
|
-// lock keys): at the base level, and with Shift held for non-modifier keys.
|
|
-// Options that only change Caps Lock or modifier chords, such as compose:caps
|
|
-// and shift:both_capslock_cancel, qualify. A different layout, or a remap of a
|
|
-// letter, digit, punctuation, or modifier key, does not. This compares fresh
|
|
-// states, so the human keyboard's current locks do not affect it.
|
|
-inline bool typing_keymap_equivalent(xkb_keymap* physical, xkb_keymap* canonical) {
|
|
- if (!physical || !canonical) return false;
|
|
- auto* actual = xkb_state_new(physical);
|
|
- auto* expected = xkb_state_new(canonical);
|
|
- bool equal = actual && expected;
|
|
- constexpr xkb_keycode_t left_shift = 42 + 8;
|
|
- for (int shifted = 0; equal && shifted < 2; ++shifted) {
|
|
- if (shifted) {
|
|
- xkb_state_update_key(actual, left_shift, XKB_KEY_DOWN);
|
|
- xkb_state_update_key(expected, left_shift, XKB_KEY_DOWN);
|
|
- }
|
|
- for (std::uint32_t code = 1; equal && code <= 247; ++code) {
|
|
- if (foreground_lock_key(code) || (shifted && foreground_modifier_key(code))) continue;
|
|
- equal = xkb_state_key_get_one_sym(actual, code + 8) == xkb_state_key_get_one_sym(expected, code + 8);
|
|
- }
|
|
- }
|
|
- if (actual) xkb_state_unref(actual);
|
|
- if (expected) xkb_state_unref(expected);
|
|
- return equal;
|
|
-}
|
|
-
|
|
using ForegroundXkbState = std::unique_ptr<xkb_state, decltype(&xkb_state_unref)>;
|
|
|
|
inline ForegroundXkbState foreground_xkb_state(xkb_keymap* map) {
|
|
@@ -201,4 +173,35 @@
|
|
return ForegroundFailureReason::none;
|
|
}
|
|
|
|
+// The main-block keys Driver types ASCII text with: digits, letters, punctuation,
|
|
+// Tab, Return and Space (text_actions in Driver's hyprland_input.rs). Capitals and
|
|
+// shifted symbols add Shift; nothing else is sent for text.
|
|
+inline constexpr bool foreground_text_key(std::uint32_t code) {
|
|
+ return (code >= 2 && code <= 13) || (code >= 15 && code <= 28) || (code >= 30 && code <= 53 && code != 42) ||
|
|
+ code == 57;
|
|
+}
|
|
+
|
|
+// True when every chord Driver types text with passes foreground_chord_failure
|
|
+// with Num Lock off and on, the two keyboard states foreground typing admits, so
|
|
+// a type_text string is admitted or refused before its first key rather than
|
|
+// partway through. A different layout, or a remap of a key or of Shift that text
|
|
+// uses, refuses. Remaps of keys text never uses, such as ctrl:swapcaps,
|
|
+// compose:ralt or altwin:swap_alt_win, qualify, and the per-chord check still
|
|
+// refuses any requested chord whose meaning they change.
|
|
+inline bool typing_keymap_equivalent(xkb_keymap* physical, xkb_keymap* canonical) {
|
|
+ if (!physical || !canonical) return false;
|
|
+ const auto numlock = foreground_numlock_mask(physical);
|
|
+ for (std::uint32_t pass = 0; pass < (numlock ? 2u : 1u); ++pass) {
|
|
+ const std::array<std::uint32_t, 4> state{0, 0, pass ? numlock : 0, 0};
|
|
+ for (std::uint32_t code = 1; code <= 57; ++code) {
|
|
+ if (!foreground_text_key(code)) continue;
|
|
+ const bool shiftable = code != 15 && code != 28 && code != 57;
|
|
+ for (std::uint32_t mods = 0; mods <= (shiftable ? 1u : 0u); ++mods)
|
|
+ if (foreground_chord_failure(physical, canonical, code, mods, state) != ForegroundFailureReason::none)
|
|
+ return false;
|
|
+ }
|
|
+ }
|
|
+ return true;
|
|
+}
|
|
+
|
|
} // namespace cua::hyprland
|
|
diff -ruN a/src/plugin.cpp b/src/plugin.cpp
|
|
--- a/src/plugin.cpp
|
|
+++ b/src/plugin.cpp
|
|
@@ -2,6 +2,9 @@
|
|
#if defined(CUA_HYPRLAND_TEST_INPUT) || defined(CUA_HYPRLAND_INPUT)
|
|
#include "input_experiment.hpp"
|
|
#endif
|
|
+#ifdef CUA_HYPRLAND_IME_POPUP_GUARD
|
|
+#include "ime_popup_guard.hpp"
|
|
+#endif
|
|
|
|
#include "cua_hyprland/protocol.hpp"
|
|
#include "cua_hyprland/status.hpp"
|
|
@@ -230,6 +233,11 @@
|
|
result.pop_back();
|
|
result += R"(,"keyboard_layout_independent":true,"foreground_numlock_compatible":true})";
|
|
#endif
|
|
+#ifdef CUA_HYPRLAND_IME_POPUP_GUARD
|
|
+ // Whether the input-method popup guard is hooked into this compositor.
|
|
+ result.pop_back();
|
|
+ result += cua::hyprland::ime_popup_guard_active() ? R"(,"ime_popup_guard":true})" : R"(,"ime_popup_guard":false})";
|
|
+#endif
|
|
#if defined(CUA_HYPRLAND_TEST_INPUT) || defined(CUA_HYPRLAND_INPUT)
|
|
if (g_experiment) {
|
|
#ifdef CUA_HYPRLAND_INPUT
|
|
@@ -341,6 +349,11 @@
|
|
}
|
|
});
|
|
|
|
+#ifdef CUA_HYPRLAND_IME_POPUP_GUARD
|
|
+ // Best effort: without the hook the plugin still works, and status says so.
|
|
+ static_cast<void>(cua::hyprland::install_ime_popup_guard(handle));
|
|
+#endif
|
|
+
|
|
return {
|
|
"cua-hyprland-plugin",
|
|
"Discovery foundation for isolated background computer use",
|
|
@@ -351,6 +364,9 @@
|
|
|
|
APICALL EXPORT void PLUGIN_EXIT() {
|
|
g_config_listener.reset();
|
|
+#ifdef CUA_HYPRLAND_IME_POPUP_GUARD
|
|
+ cua::hyprland::remove_ime_popup_guard();
|
|
+#endif
|
|
#if defined(CUA_HYPRLAND_TEST_INPUT) || defined(CUA_HYPRLAND_INPUT)
|
|
// Retire input directly so an in-flight action receives plugin_shutdown,
|
|
// not the config-disable reason from stop_server()'s suspend path.
|
|
diff -ruN a/tests/keymap_equivalence_test.cpp b/tests/keymap_equivalence_test.cpp
|
|
--- a/tests/keymap_equivalence_test.cpp
|
|
+++ b/tests/keymap_equivalence_test.cpp
|
|
@@ -22,6 +22,14 @@
|
|
return xkb_keymap_new_from_names(context, &names, XKB_KEYMAP_COMPILE_NO_FLAGS);
|
|
}
|
|
|
|
+// A US keymap with hand-written key overrides, for remaps no stock option makes.
|
|
+xkb_keymap* custom(xkb_context* context, const char* symbols, const char* types = "") {
|
|
+ const std::string text = std::string("xkb_keymap { xkb_keycodes { include \"evdev+aliases(qwerty)\" }; ") +
|
|
+ "xkb_types { include \"complete\" " + types + " }; xkb_compat { include \"complete\" }; " +
|
|
+ "xkb_symbols { include \"pc+us+inet(evdev)\" " + symbols + " }; };";
|
|
+ return xkb_keymap_new_from_string(context, text.c_str(), XKB_KEYMAP_FORMAT_TEXT_V1, XKB_KEYMAP_COMPILE_NO_FLAGS);
|
|
+}
|
|
+
|
|
struct Case {
|
|
const char* layout;
|
|
const char* variant;
|
|
@@ -37,6 +45,9 @@
|
|
int main(int argc, char** argv) {
|
|
check(foreground_lock_key(58) && foreground_lock_key(69) && foreground_lock_key(70), "lock keys");
|
|
check(foreground_modifier_key(42) && !foreground_modifier_key(30), "modifier keys");
|
|
+ check(foreground_text_key(30) && foreground_text_key(28) && foreground_text_key(57), "text keys");
|
|
+ check(!foreground_text_key(42) && !foreground_text_key(29) && !foreground_text_key(86) && !foreground_text_key(1),
|
|
+ "non-text keys");
|
|
auto* context = xkb_context_new(XKB_CONTEXT_NO_FLAGS);
|
|
auto* us = context ? compile(context, "us", "", "") : nullptr;
|
|
if (!us) {
|
|
@@ -63,8 +74,20 @@
|
|
{"us", "", "caps:escape", true, "Caps Lock remap"},
|
|
{"de", "", "", false, "German layout"},
|
|
{"us", "dvorak", "", false, "Dvorak variant"},
|
|
- {"us", "", "altwin:swap_alt_win", false, "Alt/Super swap"},
|
|
- {"us", "", "ctrl:swapcaps", false, "Ctrl/Caps swap"},
|
|
+ {"us", "", "altwin:swap_alt_win", true, "Alt/Super swap"},
|
|
+ {"us", "", "ctrl:swapcaps", true, "Ctrl/Caps swap"},
|
|
+ {"us", "", "ctrl:swap_lalt_lctl", true, "Alt/Ctrl swap"},
|
|
+ {"us", "", "compose:ralt", true, "Compose on Right Alt"},
|
|
+ {"us", "", "compose:menu", true, "Compose on Menu"},
|
|
+ {"us", "", "compose:102", true, "Compose on the 102nd key"},
|
|
+ {"us", "", "lv3:lsgt_switch", true, "Level 3 on the 102nd key"},
|
|
+ {"us", "", "keypad:oss", true, "keypad operators"},
|
|
+ {"us", "", "caps:swapescape", true, "Caps/Escape swap"},
|
|
+ {"us", "", "compose:caps,shift:both_capslock_cancel,grp:alts_toggle", true, "Omarchy non-Latin options"},
|
|
+ {"us,ru", "", "grp:alts_toggle", true, "Omarchy non-Latin layout pair"},
|
|
+ {"us", "intl", "", false, "US international dead keys"},
|
|
+ {"fr", "", "", false, "French layout"},
|
|
+ {"us", "colemak", "", false, "Colemak variant"},
|
|
};
|
|
for (const auto& test : cases) {
|
|
auto* map = compile(context, test.layout, test.variant, test.options);
|
|
@@ -72,6 +95,27 @@
|
|
check(typing_keymap_equivalent(map, us) == test.equivalent, test.what);
|
|
xkb_keymap_unref(map);
|
|
}
|
|
+ const struct { const char* symbols; bool equivalent; const char* what; } overrides[] = {
|
|
+ {"", true, "unmodified custom keymap"},
|
|
+ {"key <AE01> { [ 1, at ] };", false, "Shift level of a digit"},
|
|
+ {"key <LFSH> { [ Shift_R ] };", false, "Shift key symbol"},
|
|
+ {"key <RTRN> { [ Multi_key ] };", false, "Return as Compose"},
|
|
+ {"key <TAB> { [ dead_acute ] };", false, "Tab as a dead key"},
|
|
+ {"key <LSGT> { [ Multi_key ] };", true, "key text never uses"},
|
|
+ };
|
|
+ for (const auto& test : overrides) {
|
|
+ auto* map = custom(context, test.symbols);
|
|
+ check(map != nullptr, test.what);
|
|
+ check(typing_keymap_equivalent(map, us) == test.equivalent, test.what);
|
|
+ xkb_keymap_unref(map);
|
|
+ }
|
|
+ // A letter whose level changes under Num Lock alone must refuse up front, not at that letter.
|
|
+ auto* numtext = custom(context, "key <AB05> { type=\"NUMTEXT\", [ b, B, x, X ] };",
|
|
+ "type \"NUMTEXT\" { modifiers = Shift+Mod2; map[None] = Level1; map[Shift] = Level2; "
|
|
+ "map[Mod2] = Level3; map[Shift+Mod2] = Level4; };");
|
|
+ check(numtext != nullptr, "Num Lock text type compiles");
|
|
+ check(!typing_keymap_equivalent(numtext, us), "letter changed by Num Lock");
|
|
+ xkb_keymap_unref(numtext);
|
|
check(!typing_keymap_equivalent(nullptr, us), "missing keymap");
|
|
xkb_keymap_unref(us);
|
|
xkb_context_unref(context);
|