From a2c357167294a31aab934f7c3a7d2d20c826c5dd Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Sat, 3 Oct 2026 22:37:44 -0500 Subject: [PATCH] Rebase the Cua Hyprland plugin on Driver 0.32.0 for Hyprland 0.56.2-4 (#772) * 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 * 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 --------- Co-authored-by: Codex XHigh --- .../DOWNSTREAM-PROVENANCE.json | 37 +- pkgbuilds/cua-hyprland-plugin/PKGBUILD | 70 +- pkgbuilds/cua-hyprland-plugin/PROFILE.json | 18 +- pkgbuilds/cua-hyprland-plugin/README.md | 40 +- .../cua-hyprland-plugin/downstream.patch | 317 +++++ .../independent-keymaps.patch | 1127 ----------------- 6 files changed, 403 insertions(+), 1206 deletions(-) create mode 100644 pkgbuilds/cua-hyprland-plugin/downstream.patch delete mode 100644 pkgbuilds/cua-hyprland-plugin/independent-keymaps.patch diff --git a/pkgbuilds/cua-hyprland-plugin/DOWNSTREAM-PROVENANCE.json b/pkgbuilds/cua-hyprland-plugin/DOWNSTREAM-PROVENANCE.json index 46f9a08..0001573 100644 --- a/pkgbuilds/cua-hyprland-plugin/DOWNSTREAM-PROVENANCE.json +++ b/pkgbuilds/cua-hyprland-plugin/DOWNSTREAM-PROVENANCE.json @@ -1,57 +1,60 @@ { "files": { - "CMakeLists.txt": "7e874a595e1abd708cb0626bd9f0f58d79b4b4bbc6d99b45a0cbe1c5c53b43bc", + "CMakeLists.txt": "be5a67cf11bdd3195082e8b4225b1cd9c9bfe7fd66572a6aa5774c654b72f7f1", "LICENSE.md": "c0779290c1d4783169aa3dbfb55feb505e563ef8a004bbf55298ceffcfbda8d9", - "SOURCE-PROVENANCE.json": "1753557e38e218e329c1b2e028ed1b03341b746b06f78d984323847c471a1fc5", + "SOURCE-PROVENANCE.json": "292ac8fa09bec96cbd499d5d57cc8ea9b1c00fe6347162fb6960c45f972bdb9d", "cmake/DetectHyprlandAPI.cmake": "216133ec0eb141c3696bf3770a23e63e46521c9e91a0245a7cb75c20a75ba2c5", "cmake/VerifyRuntime.cmake": "5869f79a418e7aa6178d2b9166c36cd01c3093c2579b647624968244db57b761", "include/cua_hyprland/protocol.hpp": "7051463b3c61a2dc93c388e66b6136b7bb524f8694350f249f9d2b5c55826493", "include/cua_hyprland/session.hpp": "e6a968e4f2ac28122cb7413a0e318f6222d2a0a1b7b0c45f4ab419a78639ebea", "include/cua_hyprland/status.hpp": "56a9656647c0f4eeb0c588cd4b98a77df198d1f90421f973e9a80e6802f61495", "src/drag_geometry.hpp": "c5b783d15ff197f22938f08f8d176bab5d45544fe989d150f15cb99295acedb8", - "src/foreground_route.hpp": "4aa016c237b33c15e352a9f5f64bbbca7e1a9c1671ffd593a95a0d87994fb519", + "src/foreground_route.hpp": "4544bb0086137aa8de71e9233832ffd021f2207921c4b788484396a3c9b9c9ef", + "src/ime_popup_guard.cpp": "7a15a88a45866559206d83b0eb6dbd4b9ece9025ded07bfea90a726ebd05cec0", + "src/ime_popup_guard.hpp": "71e6cfa650b755f78d4593adc0ee5cab340c3c277ea4540c2b1e08a6b28ff276", "src/inject_server.cpp": "0935283580c50fcf0e4ad956f858885700536002b1d86d2f078da9c4404ee9e8", "src/inject_server.hpp": "67de008b4d6983371207bb22a57bab154b1dedda9b38d1207d3ac8cb379382eb", "src/input_client_deadline.hpp": "00a91a789ff698820607449ff7152e2fb50d0f315e0fa9c5c3855752bfffe2ef", - "src/input_experiment.cpp": "7017748c782b64b0bc1d257f4ade1a8d46fd19ce940ecc21f22d628614fcef37", + "src/input_experiment.cpp": "543a11341abaffe13e44b1a308b5a115e15f05b835990d693169d4d6b8f59f0f", "src/input_experiment.hpp": "9da2ddab7f0de6e9cf02aea53e9119acef17ef8513af849bb5aa3d137ebd12c3", "src/input_grant.hpp": "90b544b2f559bacd920b85ab5915f09ff201de3c05a052ce1b71ff71f767e6a6", - "src/keyboard_layout.hpp": "bc2ec039ac1974caebbb66fe4acb7a2a81832c9054b3aa644a9adcc8954a2467", + "src/keymap_equivalence.hpp": "29b929e8f12da686ddd1c438285cf635847dc121b7e19871de50ebb550d802b9", "src/owned_socket_path.hpp": "8e784656d944c700f3ded383c93a8cadf846dabdaa4cc12673bcb637c26d9fd1", "src/passive_pointer_target.hpp": "ede36fd9fd6e95ae5923c751f12591084392be9d2270eb06ad64eb4f245169fa", - "src/plugin.cpp": "712fd73ef8e9046e0fd91531b7bf5da50ce37ccca9df174160137e1924a74b09", + "src/plugin.cpp": "763a04135a6b525f7d1e6fa65400998454757131da062121507eae82e1c9db82", "src/primary_trace.cpp": "e9468d1a3f3be2a90d47bf8c4a638ad8a60fe10b6297582ab7825751cb707aa9", "src/primary_trace.hpp": "8d62535fb0b24a02bb80d9a8dcd540b39204afb2f3b4bcb5cabd5275c3b5eaa7", "src/protocol.cpp": "bd083d65efb05e80946566dc535b1a6fadaa581c66ec327eff41796feba795b1", "src/seat_lifetime.hpp": "386cf5c72c178f8eec0824f2a7d46fa755b0bb000861f9a6e00802f6b81fb779", "src/session.cpp": "0105c7ba5f9e2dbdd9a21f48be0bc1f2bde930f6aa19f77e2216403d7790e4df", "src/status.cpp": "e46e81e5e8ae3b1f50af5dcaa6c1776e236321c788fe61b402c9923c797b1270", - "tests/agent_keymap_test.py": "9b112f520a97a77a0d55f1009cd2594988d3c1e8e7bcaa39f213ffad2644dc56", + "tests/agent_key_repeat_test.py": "59aff13081387f5218ee68b21d537f04e8108e19f4a7e60ac8b30a6b87601363", "tests/cmake-api/CMakeLists.txt": "6a66c8f98023f029998af917fcd3ec6b1388607bdf7acaf5bdfaef9141962685", "tests/cmake-api/include/src/plugins/PluginAPI.hpp": "86c8ad51e668908d18928cef1b04e8e6d32a33894525640b52f0b31769c870a0", - "tests/desktop_fault_policy_fixture.cpp": "ac24d675ebc64cc98148e852eb5aba5858bffc06332678d14276d04b317aaf15", - "tests/desktop_fault_policy_test.py": "c3f624c0239036babd23eaaf1bb6b722f3a0c3321a5d3d6668ae7ab4910ca95c", + "tests/desktop_fault_policy_fixture.cpp": "bdf9a924421d1845e7f106b8f416d01ad200027e86ea8d7c00b0e8e4f3582bd2", + "tests/desktop_fault_policy_test.py": "5f06ad4ffae3b6ec5061091ddca09ad6611c4e559f0181d8d69258540b21f898", "tests/drag_geometry_test.cpp": "d32ea649d008fc051fe18555d6fbc54ba5d057b61880b648c5df8aa076a53fbc", - "tests/foreground_modifiers_test.py": "abf0ddde2d51c6639c8bdca8fdda51cdc8f922b57575a00fc8925c683ccc3bb2", - "tests/foreground_route_test.cpp": "1024168828b13ee6abd8242941e73c042e9381b39108e3ada74823039c7e7932", + "tests/foreground_modifiers_test.py": "65e963c8465e14e1e7a5127f90f533cc9b35c5556f44ca8f4af756273423dcbb", + "tests/foreground_route_test.cpp": "8edf9eb9f6ddee1f445664864c61a4bf3136b3982f92a90dd80dddc04666d45d", "tests/input_client_deadline_test.cpp": "d62373a7815d531f1269c9a838773595f43e8bcef6482fa140edb162e59a6cac", "tests/input_grant_test.cpp": "1f327b7ee678189ebad6a50bb1b9bd06767521cebc9cfb478d92de4a8bf7e7fe", - "tests/keyboard_layout_test.cpp": "3bb0fade4675d7ad92a81eb4a1c5201dd1d01bcc418b7ea59a4284dc235e5fb2", + "tests/keyboard_layout_test.cpp": "8044e2d537567cefffc1cf5becab0998402c264d9e973e9904dfcea02db08b56", + "tests/keymap_equivalence_test.cpp": "58df28fbd05ef4645cf0fe91ddcd28da0fa4182a8ebda370d7b3f8a616bc7845", "tests/mock-hyprland/mock.hpp": "3aeb1a4b9d6b83506b66c129d3fa812330fad4509fe218a0dbb99dd2bb5b6319", "tests/mock-hyprland/src/config/values/types/BoolValue.hpp": "47cf2cca89f71a273573968cb9b8ba46a1496841d6c892756ebf123500a7ecb3", "tests/mock-hyprland/src/plugins/PluginAPI.hpp": "5654d90ec9090a88bea3d31f8a79617d4c79742b09068648e64448319d395110", "tests/owned_socket_path_test.cpp": "eaff6b5c6f148eca6c8650ee3dc212a5e892f002ee4f68b2a1290c7205ee7e42", "tests/passive_pointer_target_test.cpp": "2aeef1de1dc8932b26ab8c41b83fb16a4289ff96c177a5816088f07b9a168948", - "tests/plugin_api_test.cpp": "1e7e200c309996ee945c88e172273ae942be2837e24564d422dee79d8b77d8a2", - "tests/plugin_input_lifetime_test.cpp": "82e57b335ea1216ea24cca07fe4feebafebdebb779785fde20b8dab6ee222e1b", + "tests/plugin_api_test.cpp": "013236fbbf8232b577e53893893344c3e13f32ee904a4f89430f885dd58d948b", + "tests/plugin_input_lifetime_test.cpp": "1d7af6477b444c7f342b6a7275781967c1ac910e335f5df2cff8c48f952ea1ce", "tests/protocol_test.cpp": "119cfe0df81c0c00036a2d181764eda7601d6ee72459c2275a96226d4f670447", "tests/seat_lifetime_test.cpp": "b07570edbe0a142f97c54560eeb93e8327c435ab3b8cbc7496d55175e387b78a", "tests/status_test.cpp": "b8990efc53ec3820cfe498c920b9220c4b70615ad585468558e032e7619e32f3", "tests/transport_test.cpp": "deef114a950a27eaff0a530165ddf7db0bfc0fe7e8fb55bdbb66135c8e0c04c9", "verify.py": "fb35d62313ff4661f892f88666919b33b160f8b6d4fb2d5d52610708bf7f4a54" }, - "patch_sha256": "e74d9021111a5c217b12f3e13c1bbdd260e829c60db5fc2f7293ba4846e1a7d7", + "patch_sha256": "87d3f560cfbd195748321a7e7ad2cb921c77cc022877de7890d3a711940f6e3c", "schema": 1, - "upstream_manifest_sha256": "1753557e38e218e329c1b2e028ed1b03341b746b06f78d984323847c471a1fc5", - "upstream_revision": "fc188250b4ca8549b8e61f937fdb1fb560770e86" + "upstream_manifest_sha256": "292ac8fa09bec96cbd499d5d57cc8ea9b1c00fe6347162fb6960c45f972bdb9d", + "upstream_revision": "58aba84b5b83d77e7e2b0f006547699eb594e50d" } diff --git a/pkgbuilds/cua-hyprland-plugin/PKGBUILD b/pkgbuilds/cua-hyprland-plugin/PKGBUILD index a82dbf1..b960a36 100644 --- a/pkgbuilds/cua-hyprland-plugin/PKGBUILD +++ b/pkgbuilds/cua-hyprland-plugin/PKGBUILD @@ -1,22 +1,22 @@ -# Verified upstream kit plus a separately pinned Omarchy keyboard-remap patch. +# Verified upstream kit plus a separately pinned Omarchy patch. # Normal reruns need a fresh build directory; makepkg -e reuses verified extracted trees. # Profile kit: original source bytes and separately committed packaging tooling. # shellcheck shell=bash disable=SC2034,SC2154 pkgname=cua-hyprland-plugin -pkgver=0.28.2 +pkgver=0.32.0 # Profile validation starts package releases at 2, above upstream's generic -1 build of the same Driver. -pkgrel=2 -pkgdesc='Cua input candidate for reviewed profile omarchy-edge-20260928-remaps' +pkgrel=3 +pkgdesc='Cua input candidate for reviewed profile omarchy-hyprland-0562r4-remaps' arch=('x86_64') url='https://github.com/trycua/cua' license=('MIT') -depends=('hyprland=0.56.2-3' 'aquamarine=0.15.1-1' 'glibc=2.44+r50+g1848099f063e-1' 'hyprcursor=0.1.13-7' 'hyprgraphics=0.5.1-4' 'hyprlang=0.6.8-5' 'hyprutils=0.14.2-1' 'libgcc=16.2.1+r23+gd564253eb6c8-1' 'libstdc++=16.2.1+r23+gd564253eb6c8-1' 'libxkbcommon=1.13.2-1' 'wayland=1.26.0-1' 'python>=3.11' 'binutils') +depends=('hyprland=0.56.2-4' 'aquamarine=0.15.1-1' 'glibc=2.44+r50+g1848099f063e-1' 'hyprcursor=0.1.13-7' 'hyprgraphics=0.5.1-4' 'hyprlang=0.6.8-5' 'hyprutils=0.14.2-1' 'libgcc=16.2.1+r23+gd564253eb6c8-1' 'libstdc++=16.2.1+r23+gd564253eb6c8-1' 'libxkbcommon=1.13.2-1' 'wayland=1.26.0-1' 'python>=3.11' 'binutils') makedepends=('cmake>=3.30' 'ninja' 'pkgconf' 'gcc' 'patch') options=('!strip' '!debug' '!lto') -_stem='cua-hyprland-plugin-0.28.2-fc188250b4ca8549b8e61f937fdb1fb560770e86' -_archive_sha256='c5b44066e2d80b2e309ef567560c1015d3ec82423c380dd152a337f39aaed1d8' -_kit_sha256='593e0b59f4d50059e30d32d5f6ac1472862dd17a573229cfbfbda74779fb8f82' -_profile_sha256='33581ad88d2f21995f0ec4d2855f9e9870fedd8c4de66b6383e4fde5cebf7f4f' +_stem='cua-hyprland-plugin-0.32.0-58aba84b5b83d77e7e2b0f006547699eb594e50d' +_archive_sha256='25bc3b2d353ba6bb6ecd17e4b67b11b0d6b731a5ae01025197442c97e50acf7a' +_kit_sha256='3c2141dcb2583d918ef2cdb6ff3805dec62119a439a29c811a93cc980d621423' +_profile_sha256='b20d98979137fb5216768d95215f80d372d9bfd2269b541d7bcf664a12f80b97' _verifier_sha256='480f649cbd35a0ddc7f49dc0a3a44785402c9e0ec9653b0f8ea4965d52d7f900' _cxx="${CUA_RELEASE_CXX:-/usr/bin/g++}" _download_name='cua-hyprland-plugin-0.26.1-cc54254464c0c9aebfd6547fe7e4a0ceaf0456d7-profile-omarchy-stable-20260910-kit-1.1.0-5dacea79a6a5927e59fca7b51e6c04e94fa45133ac1b8fb7f4e1f442d113eb9a-67f89ceb47edd973aa748820eadd02ddbfb3d3e4.tar.gz' @@ -26,18 +26,18 @@ source=('https://github.com/trycua/cua/releases/download/cua-hyprland-kit-v1.1.0 'PROFILE.json') noextract=("$_download_name" "${_stem}.tar.gz") sha256sums=('a89bfa7f6490f598719dfabdd2a5badc137e27b0764fd25e6a05e1f969433520' - 'c5b44066e2d80b2e309ef567560c1015d3ec82423c380dd152a337f39aaed1d8' - '33581ad88d2f21995f0ec4d2855f9e9870fedd8c4de66b6383e4fde5cebf7f4f') + '25bc3b2d353ba6bb6ecd17e4b67b11b0d6b731a5ae01025197442c97e50acf7a' + 'b20d98979137fb5216768d95215f80d372d9bfd2269b541d7bcf664a12f80b97') # Downstream inputs are also checked explicitly when makepkg integrity is skipped. declare -gA _downstream_sha256=( - ['independent-keymaps.patch']='e74d9021111a5c217b12f3e13c1bbdd260e829c60db5fc2f7293ba4846e1a7d7' - ['DOWNSTREAM-PROVENANCE.json']='af7c4a8dfb38573ebb5352b03ad4274c10cb4ea45c57243c231fd241dc8a6f3f' + ['downstream.patch']='87d3f560cfbd195748321a7e7ad2cb921c77cc022877de7890d3a711940f6e3c' + ['DOWNSTREAM-PROVENANCE.json']='814e136f4684886107b5ccfa7ffc773a1ad0cea7dcddebd977c6f35c6e87ce00' ['downstream.py']='7c9725805ad038737bd4d346c85f27991ce9ddfff436a32ebf515cad1bf98ca1' ['downstream_test.py']='7b9a992979b22a5e061fdf011ac6be59a3f1ae6e0d7c0a8ff631f74e71984c5a' ) -source+=('independent-keymaps.patch' 'DOWNSTREAM-PROVENANCE.json' 'downstream.py' 'downstream_test.py') -sha256sums+=('e74d9021111a5c217b12f3e13c1bbdd260e829c60db5fc2f7293ba4846e1a7d7' 'af7c4a8dfb38573ebb5352b03ad4274c10cb4ea45c57243c231fd241dc8a6f3f' '7c9725805ad038737bd4d346c85f27991ce9ddfff436a32ebf515cad1bf98ca1' '7b9a992979b22a5e061fdf011ac6be59a3f1ae6e0d7c0a8ff631f74e71984c5a') +source+=('downstream.patch' 'DOWNSTREAM-PROVENANCE.json' 'downstream.py' 'downstream_test.py') +sha256sums+=('87d3f560cfbd195748321a7e7ad2cb921c77cc022877de7890d3a711940f6e3c' '814e136f4684886107b5ccfa7ffc773a1ad0cea7dcddebd977c6f35c6e87ce00' '7c9725805ad038737bd4d346c85f27991ce9ddfff436a32ebf515cad1bf98ca1' '7b9a992979b22a5e061fdf011ac6be59a3f1ae6e0d7c0a8ff631f74e71984c5a') _verify_download() { python3 -I - "$SRCDEST/$_download_name" "$_download_sha256" "$srcdir" "$1" "$SRCDEST/PROFILE.json" \ @@ -51,7 +51,7 @@ import tarfile expected = {'KIT-PROVENANCE.json': '7beb736adfd334eed52e84070177634269e3a09f8bb25971b38606933ff4c997', 'PKGBUILD': 'b945a6a6eda13d0e382770edd5419485e3196041956663eb38f5183b05d30db3', 'PROFILE-PKGBUILD.in': 'c350d1b2375946cb0166893d67a1fc01344ee5e3215bbe801b4d54bb361d6bce', 'PROFILE-USAGE.md': 'c14d8e8103fccab59e558758f4249f86bce700a8723cd4ba1b97b1102e02bbd2', 'PROFILE.json': '5dacea79a6a5927e59fca7b51e6c04e94fa45133ac1b8fb7f4e1f442d113eb9a', 'SHA256SUMS': '34a2126bcfab983f171382aafac3ef218475b652f4583c58f4a04088044cb935', 'SOURCE-PROVENANCE.json': '54f514664c84e1358a435f29cd6befd5661b0b133d76997191c000b10f021a75', 'cua-hyprland-plugin-0.26.1-cc54254464c0c9aebfd6547fe7e4a0ceaf0456d7.tar.gz': '47bca9e018f32f4fcfe683f91c7475c60368f3b65d318cc35c1f2de88a4ee9ab', 'lifecycle.py': 'b18dceb8b8e05b93586ddd3a2f1d90d70ae1c36088e54fc05c89e08585290990', 'profile_bundle.py': 'ac883883814787da477c037f017939ee92c9fb5f46f373af7de1331b24f1f6da', 'profile_verify.py': '480f649cbd35a0ddc7f49dc0a3a44785402c9e0ec9653b0f8ea4965d52d7f900'} kit_stem = 'cua-hyprland-plugin-0.26.1-cc54254464c0c9aebfd6547fe7e4a0ceaf0456d7' -stem = 'cua-hyprland-plugin-0.28.2-fc188250b4ca8549b8e61f937fdb1fb560770e86' +stem = 'cua-hyprland-plugin-0.32.0-58aba84b5b83d77e7e2b0f006547699eb594e50d' def require(condition, message): if not condition: @@ -76,13 +76,14 @@ with tarfile.open(fileobj=io.BytesIO(data), mode='r:gz') as contents: require(digest(content) == expected[member.name], 'outer kit member checksum mismatch') payload[member.name] = content require(payload.keys() == expected.keys(), 'outer kit inventory mismatch') -# Rebase the kit onto the Driver 0.28.2 source release, whose plugin files are -# byte-identical to 0.26.1, and derive the reviewed Omarchy edge profile for it. -# Tooling and compiler, compositor, header and runtime hashes stay upstream's. +# Rebase the kit onto the Driver 0.32.0 source release, whose manifest has the fields +# this verifier checks, and derive the reviewed Omarchy edge profile for it. +# Tooling, compiler and runtime hashes stay upstream's. The compositor and its +# headers are re-measured from Arch's 0.56.2-4 rebuild of the same Hyprland release. source_file = Path(source_path) require(source_file.is_file() and not source_file.is_symlink(), 'source archive must be a regular file') source_data = source_file.read_bytes() -require(digest(source_data) == 'c5b44066e2d80b2e309ef567560c1015d3ec82423c380dd152a337f39aaed1d8', +require(digest(source_data) == '25bc3b2d353ba6bb6ecd17e4b67b11b0d6b731a5ae01025197442c97e50acf7a', 'source archive checksum mismatch') with tarfile.open(fileobj=io.BytesIO(source_data), mode='r:gz') as contents: manifest_data = contents.extractfile(stem + '/SOURCE-PROVENANCE.json').read() @@ -93,13 +94,16 @@ payload['SOURCE-PROVENANCE.json'] = manifest_data identity = {'revision': manifest['source_revision'], 'driver_version': manifest['driver_version'], 'archive_sha256': digest(source_data), 'manifest_sha256': digest(manifest_data)} profile_data = Path(profile_path).read_bytes() -require(digest(profile_data) == '33581ad88d2f21995f0ec4d2855f9e9870fedd8c4de66b6383e4fde5cebf7f4f', +require(digest(profile_data) == 'b20d98979137fb5216768d95215f80d372d9bfd2269b541d7bcf664a12f80b97', 'local profile checksum mismatch') profile = json.loads(payload['PROFILE.json']) -profile.update(profile_id='omarchy-edge-20260928-remaps', package_release=2, source=identity) -profile['hyprland']['package_version'] = '0.56.2-3' +profile.update(profile_id='omarchy-hyprland-0562r4-remaps', package_release=3, source=identity) +profile['hyprland'].update(package_version='0.56.2-4', + sha256='55da553be71222566ee73b973d2f56dbb9939044d8f22f81aa54b66c83f2f6d1', + headers_sha256='1fdefe6ac027a159d04a5dfee4928ec7ebd15544a9a25b5f66d2f5a46fcf364a') profile['runtime']['packages'].update(aquamarine='0.15.1-1', glibc='2.44+r50+g1848099f063e-1') -require(json.loads(profile_data) == profile, 'local profile changes more than the reviewed source and package versions') +require(json.loads(profile_data) == profile, + 'local profile changes more than the reviewed source, package versions and re-measured compositor') payload['PROFILE.json'] = profile_data provenance = json.loads(payload['KIT-PROVENANCE.json']) provenance.update(profile_sha256=digest(profile_data), source=identity) @@ -119,12 +123,12 @@ payload['PKGBUILD'] = recipe.encode() payload['SHA256SUMS'] = ''.join(f'{digest(body)} {name}\n' for name, body in sorted(payload.items()) if name != 'SHA256SUMS').encode() del expected[kit_stem + '.tar.gz'] -expected.update({stem + '.tar.gz': 'c5b44066e2d80b2e309ef567560c1015d3ec82423c380dd152a337f39aaed1d8', - 'SOURCE-PROVENANCE.json': '1753557e38e218e329c1b2e028ed1b03341b746b06f78d984323847c471a1fc5', - 'PROFILE.json': '33581ad88d2f21995f0ec4d2855f9e9870fedd8c4de66b6383e4fde5cebf7f4f', - 'KIT-PROVENANCE.json': '593e0b59f4d50059e30d32d5f6ac1472862dd17a573229cfbfbda74779fb8f82', - 'PKGBUILD': '502c3e91e524516e6e42b3c0d4c2db4214b850ee5699abc9e2a4fda91a6551d4', - 'SHA256SUMS': '00a1271037f0facd0b9d2d00ce55e7303fac6622e766bc0760fac3942c122cbd'}) +expected.update({stem + '.tar.gz': '25bc3b2d353ba6bb6ecd17e4b67b11b0d6b731a5ae01025197442c97e50acf7a', + 'SOURCE-PROVENANCE.json': '292ac8fa09bec96cbd499d5d57cc8ea9b1c00fe6347162fb6960c45f972bdb9d', + 'PROFILE.json': 'b20d98979137fb5216768d95215f80d372d9bfd2269b541d7bcf664a12f80b97', + 'KIT-PROVENANCE.json': '3c2141dcb2583d918ef2cdb6ff3805dec62119a439a29c811a93cc980d621423', + 'PKGBUILD': '610d426d6f88238fc6fae56afc1d54ba7866800d14e97777d9b47ddc9b347cc0', + 'SHA256SUMS': '093dc8708a9b440977662d7f00bccace24b0b9253e6bf0d1decad2655b886881'}) require(payload.keys() == expected.keys(), 'derived kit inventory mismatch') for name, content in payload.items(): require(digest(content) == expected[name], 'derived kit checksum mismatch: ' + name) @@ -175,12 +179,12 @@ _verify() { _downstream() { local name - for name in independent-keymaps.patch DOWNSTREAM-PROVENANCE.json downstream.py downstream_test.py; do + for name in downstream.patch DOWNSTREAM-PROVENANCE.json downstream.py downstream_test.py; do printf '%s %s\n' "${_downstream_sha256[$name]}" "$SRCDEST/$name" | sha256sum -c - || return 1 done python3 -B "$SRCDEST/downstream.py" "$1" \ --pristine "$srcdir/$_stem" --source "$srcdir/omarchy-source" \ - --patch "$SRCDEST/independent-keymaps.patch" --manifest "$SRCDEST/DOWNSTREAM-PROVENANCE.json" \ + --patch "$SRCDEST/downstream.patch" --manifest "$SRCDEST/DOWNSTREAM-PROVENANCE.json" \ --kit "$srcdir/cua-profile-kit" --kit-sha256 "$_kit_sha256" \ --archive "$srcdir/cua-profile-kit/${_stem}.tar.gz" --cxx "$_cxx" "${@:2}" } @@ -229,7 +233,7 @@ package() { for name in KIT-PROVENANCE.json PROFILE.json profile_verify.py; do install -Dm644 "$srcdir/cua-profile-kit/$name" "$pkgdir/usr/share/$pkgname/$name" || return 1 done - for name in DOWNSTREAM-PROVENANCE.json independent-keymaps.patch; do + for name in DOWNSTREAM-PROVENANCE.json downstream.patch; do install -Dm644 "$SRCDEST/$name" "$pkgdir/usr/share/$pkgname/$name" || return 1 done } diff --git a/pkgbuilds/cua-hyprland-plugin/PROFILE.json b/pkgbuilds/cua-hyprland-plugin/PROFILE.json index 6744554..9b9114c 100644 --- a/pkgbuilds/cua-hyprland-plugin/PROFILE.json +++ b/pkgbuilds/cua-hyprland-plugin/PROFILE.json @@ -7,13 +7,13 @@ }, "hyprland": { "header_version": "0.56.2", - "headers_sha256": "88a6875af00203627b264a5c1f9908781be4ad8d9cee4e577fef174e72dd0e28", - "package_version": "0.56.2-3", - "sha256": "da8fcacf347bcbed83edc40108c6e2298da095e22246bd764e9bb382786cebb2" + "headers_sha256": "1fdefe6ac027a159d04a5dfee4928ec7ebd15544a9a25b5f66d2f5a46fcf364a", + "package_version": "0.56.2-4", + "sha256": "55da553be71222566ee73b973d2f56dbb9939044d8f22f81aa54b66c83f2f6d1" }, "kit_version": "1.1.0", - "package_release": 2, - "profile_id": "omarchy-edge-20260928-remaps", + "package_release": 3, + "profile_id": "omarchy-hyprland-0562r4-remaps", "runtime": { "basename": "libstdc++.so.6.0.36", "packages": { @@ -32,9 +32,9 @@ }, "schema": 2, "source": { - "archive_sha256": "c5b44066e2d80b2e309ef567560c1015d3ec82423c380dd152a337f39aaed1d8", - "driver_version": "0.28.2", - "manifest_sha256": "1753557e38e218e329c1b2e028ed1b03341b746b06f78d984323847c471a1fc5", - "revision": "fc188250b4ca8549b8e61f937fdb1fb560770e86" + "archive_sha256": "25bc3b2d353ba6bb6ecd17e4b67b11b0d6b731a5ae01025197442c97e50acf7a", + "driver_version": "0.32.0", + "manifest_sha256": "292ac8fa09bec96cbd499d5d57cc8ea9b1c00fe6347162fb6960c45f972bdb9d", + "revision": "58aba84b5b83d77e7e2b0f006547699eb594e50d" } } diff --git a/pkgbuilds/cua-hyprland-plugin/README.md b/pkgbuilds/cua-hyprland-plugin/README.md index dd37f0c..1263264 100644 --- a/pkgbuilds/cua-hyprland-plugin/README.md +++ b/pkgbuilds/cua-hyprland-plugin/README.md @@ -1,29 +1,25 @@ # Optional Cua Hyprland plugin -This package targets **Omarchy x86_64**, with Inkscape `1.4.4-6` and two independent background-input lanes. Release `0.28.2-2` is an edge candidate built from the plugin source published with Driver `0.28.2`, the version `cua-driver-bin` ships, for glibc `2.44+r50+g1848099f063e-1`; it retains the keyboard-remap patch introduced in release `0.26.1-5`, which includes the Omarchy patch for independent agent keymaps, operation-specific foreground checks, and compatible Num Lock state; the upstream native qualification below covers the unpatched source, not this change. Cua's native qualification is recorded in [the kit's qualification record](https://github.com/trycua/cua/releases/download/cua-hyprland-kit-v1.1.0-omarchy-stable-20260910/QUALIFICATION.md) and [Cua #3698](https://github.com/trycua/cua/pull/3698). Omabot replay and Omarchy's merge decision are recorded in [omarchy-pkgs #346](https://github.com/omacom/omarchy-pkgs/pull/346). Scheduling the recipe does not expand the qualified stable target. +This package targets **Omarchy x86_64**, with Inkscape `1.4.4-6` and two independent background-input lanes. Release `0.32.0-3` is an edge candidate built from the plugin source published with Driver `0.32.0`, paired with `cua-driver-bin` `0.28.2`. That source carries the independent agent keymaps and compatible Num Lock handling that releases `0.26.1-5` through `0.28.2-2` applied as an Omarchy patch; this release applies a smaller one, `downstream.patch`, so foreground typing keeps working with modifier and Compose remaps (see *Keyboard behavior*) and so that, with its guard active, restarting fcitx5 does not crash Hyprland (see *Input-method popups*). The upstream native qualification below covers older, unpatched source. Cua's native qualification is recorded in [the kit's qualification record](https://github.com/trycua/cua/releases/download/cua-hyprland-kit-v1.1.0-omarchy-stable-20260910/QUALIFICATION.md) and [Cua #3698](https://github.com/trycua/cua/pull/3698). Omabot replay and Omarchy's merge decision are recorded in [omarchy-pkgs #346](https://github.com/omacom/omarchy-pkgs/pull/346). Scheduling the recipe does not expand the qualified stable target. The plugin is optional. Cua Driver works independently, and installation does not load the plugin or enable input. The package follows the normal edge-to-RC-to-stable promotion path instead of the fast release ring. Its PKGBUILD limits builds to x86_64. The upstream qualification covers the original stable profile; the updated Aquamarine profile needs its own Omabot validation before promotion. ## Source and build profile -The package uses the [Driver 0.28.2 plugin source](https://github.com/trycua/cua/releases/tag/cua-driver-rs-v0.28.2), including the [desktop-fault cleanup repair](https://github.com/trycua/cua/pull/3702). Every plugin file in it is byte-identical to the Driver 0.26.1 and 0.27.0 sources; only the release identity in `SOURCE-PROVENANCE.json` differs. It is not a repackaging of the unmodified 0.24.0 plugin. +The package uses the [Driver 0.32.0 plugin source](https://github.com/trycua/cua/releases/tag/cua-driver-rs-v0.32.0). Against the Driver 0.26.1 source the profile kit was qualified with, it adds the keyboard behaviour below ([Cua #3970](https://github.com/trycua/cua/pull/3970), adapted from [#473](https://github.com/omacom/omarchy-pkgs/pull/473)) and gives agent keyboards the user seat's key repeat rate instead of zero ([Cua #4358](https://github.com/trycua/cua/pull/4358)), which crashed single-seat clients such as imv that bound an agent seat ([Cua #4257](https://github.com/trycua/cua/issues/4257)). The input protocol header is unchanged. -The qualified upstream Driver pairing is `cua-driver-bin 0.27.0-1`, with input protocol v3. Driver 0.27.0 contains the bounded stale-geometry retry validated with the upstream module. This package pairs with `cua-driver-bin 0.28.2`, which speaks the same input protocol v3 to the same plugin source; Driver finds the plugin only through its versioned input socket, not through the plugin's provenance. Discovery protocol v2 is separate. The 0.28.2 pairing is a changed pairing and requires affected replay before promotion. +The qualified upstream Driver pairing is `cua-driver-bin 0.27.0-1`, with input protocol v3. Driver 0.27.0 contains the bounded stale-geometry retry validated with the upstream module. This package pairs with `cua-driver-bin 0.28.2`, which speaks the same input protocol v3 to this source; Driver finds the plugin only through its versioned input socket, not through the plugin's provenance. Discovery protocol v2 is separate. `cua-driver-bin` stays at 0.28.2 because Driver 0.28.3 through 0.32.0 refuse desktop capture on Hyprland with more than one output or with one output away from the origin ([Cua #4161](https://github.com/trycua/cua/issues/4161)), where 0.28.2 captures. This pairing is a changed pairing and requires affected replay before promotion. -Profile `omarchy-edge-20260928-remaps`, kit tooling `1.1.0`, and package release `2` pin: +Profile `omarchy-hyprland-0562r4-remaps`, kit tooling `1.1.0`, and package release `3` pin: -- Hyprland `0.56.2-3`, headers `0.56.2`, and measured executable/header hashes. +- Hyprland `0.56.2-4`, headers `0.56.2`, and measured executable/header hashes. - GCC `16.2.1 20260810`, including compiler bytes and emitted ELF identity. - Shared runtime `libstdc++.so.6.0.36`, its bytes, and exact ABI package versions, including Aquamarine `0.15.1-1` and glibc `2.44+r50+g1848099f063e-1`. -This profile derives from Cua's `omarchy-stable-20260910` profile. Arch's -Hyprland `-3` package splits out `hyprpm` and changes package dependencies; -its compositor executable and all 498 header/pkg-config files are byte-identical -to `-2`. Both executables have SHA-256 -`da8fcacf347bcbed83edc40108c6e2298da095e22246bd764e9bb382786cebb2`. -The checked-in `PROFILE.json` changes only the profile name, package release, source release, and exact Hyprland, Aquamarine and glibc package versions. Compiler, libstdc++ runtime, compositor executable, and header identities remain unchanged; the separately recorded patch changes the build source. The download wrapper verifies the original kit and the Driver 0.28.2 source archive before substituting that archive and its manifest into the kit, deriving the updated profile and provenance, and rendering the recipe from the kit's own `PROFILE-PKGBUILD.in`. It then verifies every derived member against its recorded digest, so the result is the kit Cua's `profile_verify.py` accepts as complete for this profile. +This profile derives from Cua's `omarchy-stable-20260910` profile. Arch's Hyprland `0.56.2-4` is a rebuild of the same 0.56.2 release against vulkan-sdk 1.4.363 and glslang, built with the same GCC `16.2.1 20260810` and linked to the same `libstdc++`. Its executable (SHA-256 `55da553be71222566ee73b973d2f56dbb9939044d8f22f81aa54b66c83f2f6d1`) and two of its 497 headers differ from `-3`: `src/version.h` now names the Aquamarine `0.15.1` and hyprutils `0.14.2` it was built against, and `protocols/hyprland-input-capture-v1.hpp` gains a destroy handler. The header inventory is re-measured the way `profile_verify.py` measures it, giving `1fdefe6ac027a159d04a5dfee4928ec7ebd15544a9a25b5f66d2f5a46fcf364a`; that method reproduces the kit's `-2`/`-3` values exactly. +The checked-in `PROFILE.json` changes only the profile name, package release, source release, exact Hyprland, Aquamarine and glibc package versions, and the re-measured compositor executable and header hashes. Compiler and libstdc++ runtime identities remain Cua's; the separately recorded patch changes the build source. Cua publishes the profile tooling only in the 0.26.1 kit, and the Driver 0.32.0 source manifest has exactly the fields that kit's `profile_verify.py` checks. The download wrapper verifies the original kit and the Driver 0.32.0 source archive before substituting that archive and its manifest into the kit, deriving the updated profile and provenance, and rendering the recipe from the kit's own `PROFILE-PKGBUILD.in`. It then verifies every derived member against its recorded digest, so the result is the kit Cua's `profile_verify.py` accepts as complete for this profile. -The native qualification below was recorded with package `0.26.1-2` and Hyprland `-2`. The downstream keymap change, the Aquamarine and glibc updates, and the Driver 0.28.2 pairing need their own application and Driver replay before promotion. Hyprland `0.56.2-3`, Aquamarine `0.15.1-1` and glibc `2.44+r50+g1848099f063e-1` must all reach a destination channel before this artifact can be installed there; publication still follows edge → RC → stable. +The native qualification below was recorded with package `0.26.1-2` and Hyprland `-2`. The 0.32.0 source and its Omarchy patch, the Aquamarine and glibc updates, and the Driver 0.28.2 pairing need their own application and Driver replay before promotion. Hyprland `0.56.2-4`, Aquamarine `0.15.1-1` and glibc `2.44+r50+g1848099f063e-1` must all reach a destination channel before this artifact can be installed there; publication still follows edge → RC → stable. The generated `PKGBUILD` identifies the immutable kit download, outer checksum, and member checksums. The kit records the full source and tooling revisions, @@ -38,24 +34,28 @@ and production flags remain mandatory. Packaging runs all bundled CTests even with `--nocheck` or `--repackage`; `--skipinteg` does not bypass recipe checks. Production input is built in; experimental signed input and tracing are off. -The pristine upstream archive, manifest, and verifier remain unchanged. `independent-keymaps.patch` is applied to a separate source tree, and `DOWNSTREAM-PROVENANCE.json` pins the patch and every resulting source file. Build, check, and package revalidate both trees, including when makepkg integrity checks are skipped. `BUILD-PROVENANCE.json` records the upstream base under `source`, the applied change under `downstream`, and the final module digest; the downstream manifest and patch are installed beside it. This preserves the existing compiler, headers, runtime, and consumer checks without representing the modified module as an unmodified upstream build. +The pristine upstream archive, manifest, and verifier remain unchanged. `downstream.patch` is applied to a separate source tree, and `DOWNSTREAM-PROVENANCE.json` pins the patch and every resulting source file. Build, check, and package revalidate both trees, including when makepkg integrity checks are skipped. `BUILD-PROVENANCE.json` records the upstream base under `source`, the applied change under `downstream`, and the final module digest; the downstream manifest and patch are installed beside it. This preserves the existing compiler, headers, runtime, and consumer checks without representing the modified module as an unmodified upstream build. ## Dependency refresh -Every ABI package is pinned exactly, so an Arch update to any of them makes the published plugin uninstallable until a new profile lands. Release `0.26.1-5` required Aquamarine `0.15.0-2` and stopped resolving when the mirror moved to `0.15.1-1`; release `0.26.1-6` required glibc `2.44+r24+g16be1518495f-1` and stopped resolving when core moved to `2.44+r50+g1848099f063e-1`. In both cases pacman refuses the plugin with `unable to satisfy dependency`, even after a full database refresh. Release `0.28.2-2` derives a new profile from the same verified upstream kit and pins the current versions in both the package dependencies and the installed compatibility verifier. Patch, compiler, compositor, headers, and libstdc++ hashes remain pinned; the original upstream qualification does not establish compatibility with the changed Aquamarine or glibc packages. +Every ABI package is pinned exactly, so an Arch update to any of them makes the published plugin uninstallable until a new profile lands. Release `0.26.1-5` required Aquamarine `0.15.0-2` and stopped resolving when the mirror moved to `0.15.1-1`; release `0.26.1-6` required glibc `2.44+r24+g16be1518495f-1` and stopped resolving when core moved to `2.44+r50+g1848099f063e-1`. In both cases pacman refuses the plugin with `unable to satisfy dependency`, even after a full database refresh. Releases since `0.28.2-2` derive their profile from the same verified upstream kit and pin the current versions in both the package dependencies and the installed compatibility verifier. Compiler, compositor, headers, and libstdc++ hashes remain pinned; the original upstream qualification does not establish compatibility with the changed Aquamarine or glibc packages. An Arch rebuild of the same Hyprland release, with the same header version, compiler and runtime, is re-measured here: the derivation pins the new compositor executable and header inventory, and the build's verifier checks both against the installed package. A new Hyprland release, compiler, or `libstdc++` still needs a new kit from Cua. A package release bump alone cannot repair future dependency drift: the checked-in profile and derived kit checksums must agree with the new environment, and affected native checks must pass before publication. Do not remove exact dependencies or selectively downgrade a library to bypass a mismatch. ## Keyboard behavior -Each background lane owns a canonical US keymap and independent modifier state. The physical keyboard keeps its layout, Compose key, and remaps. No installation or activation step edits `input:kb_*`. Existing Driver keycodes are interpreted by the agent keyboard, so this does not add Unicode, IME, or new Driver text routes. +Driver 0.31.0 and later implement this upstream. Each background lane owns a canonical US keymap and independent modifier state. The physical keyboard keeps its layout, Compose key, and remaps. No installation or activation step edits `input:kb_*`. Existing Driver keycodes are interpreted by the agent keyboard, so this does not add Unicode, IME, or new Driver text routes. -Plain click, scroll, drag, and foreground activation do not require a canonical keyboard layout. Foreground keys still use the primary seat: the plugin checks the requested key and modifier sequence against its actual XKB map before activation or input. Unrelated remaps are accepted; a sequence whose symbols or modifier/lock transitions differ from the canonical meaning is refused with `unsupported_layout`. Arbitrary foreground layout translation remains outside protocol v3. +Plain click, scroll, drag, and foreground activation do not require a canonical keyboard layout. Foreground keys still use the primary seat, so foreground typing needs a physical keymap under which every chord Driver types text with (the US main-block digits, letters and punctuation with and without Shift, plus Space, Tab and Return) means what it means on the canonical US map, with Num Lock off and on. A different layout, or a remap of one of those keys or of Shift, is refused with `unsupported_layout` before a string starts. Upstream also requires every other key the KEY command can press to match, which refuses all foreground typing under remaps like `ctrl:swapcaps`, `compose:ralt`, `altwin:swap_alt_win`, or `compose:102`; `downstream.patch` limits the up-front check to the text chords and leaves other keys to the per-chord check, which simulates each requested key and modifier sequence against the actual XKB map before activation or input. Under `ctrl:swapcaps`, plain text types and Ctrl+A is refused. Arbitrary foreground layout translation remains outside protocol v3. -Foreground typing preserves Num Lock and admits a requested key sequence only when its symbols and shortcut semantics still match the canonical meaning. Num Lock does not block unaffected letters, top-row digits, Enter, or compatible shortcuts; a keypad sequence whose meaning changes is refused. Caps Lock, other unsupported lock states, held or latched modifiers, and nonzero layout groups remain guarded. +Foreground typing preserves Num Lock and admits a requested key sequence only when its symbols and shortcut semantics still match the canonical meaning. Num Lock does not block unaffected letters, top-row digits, Enter, or compatible shortcuts; a keypad sequence whose meaning changes is refused with `foreground_keyboard_numlock_keypad`. Caps Lock is refused with `foreground_keyboard_caps_lock`; other unsupported lock states, held or latched modifiers, and nonzero layout groups remain guarded. Both routes retain target/conflict checks and cancellation on desktop/keymap changes. `hyprctl -j cua:status` exposes `keyboard_layout_independent: true` and `foreground_numlock_compatible: true` for installers to distinguish this implementation from an older mapped module. The marker does not identify every future package revision; plugin updates still require a fresh desktop session. +## Input-method popups + +With input enabled, the agent seats are ordinary `wl_seat` globals, and fcitx5 requests an input method for every seat it sees: one is accepted and the others are refused. When fcitx5 then exits, which Omarchy's `omarchy-restart-xcompose` does, Hyprland 0.56.2 can be left with an input-method popup that is still mapped although its `wl_surface` is already gone, and it updates every popup as the input method goes away. `CInputPopup::updateBox` dereferences the missing surface and Hyprland crashes into safe mode. Hyprland never marks such a popup unmapped, and its renderer reaches the same missing surface through `CInputPopup::shouldBeRendered`. `downstream.patch` hooks `CInputPopup::updateBox` and `CInputPopup::shouldBeRendered` through Hyprland's function-hook API so that a popup whose `getSurface()` is empty is neither placed nor rendered. The hooks are installed when the module loads and removed when it unloads, and `hyprctl -j cua:status` reports `ime_popup_guard: true` when both are active. If either exact symbol is not found, the plugin loads without the guard and reports `false`. The guard is in the module only; the bundled tests compile `plugin.cpp` against mocks without it. + ## Historical upstream qualification The initial app scope is native Wayland Inkscape `1.4.4-6` with the canonical @@ -129,7 +129,7 @@ rollout and qualify any changed pairing explicitly. The package installs the module at `/usr/lib/cua/hyprland/cua-hyprland-plugin.so` and provenance plus the consumer -verifier under `/usr/share/cua-hyprland-plugin/`. There are no hooks, autoloading, +verifier under `/usr/share/cua-hyprland-plugin/`. There are no package-manager hooks, autoloading, configuration edits, or hot replacement. Save your work and exit Hyprland before installing, replacing, or removing the @@ -140,7 +140,7 @@ kit-provenance digest: ```sh python3 /usr/share/cua-hyprland-plugin/profile_verify.py \ --kit /usr/share/cua-hyprland-plugin \ - --kit-sha256 593e0b59f4d50059e30d32d5f6ac1472862dd17a573229cfbfbda74779fb8f82 \ + --kit-sha256 3c2141dcb2583d918ef2cdb6ff3805dec62119a439a29c811a93cc980d621423 \ --consumer /usr/lib/cua/hyprland/cua-hyprland-plugin.so ``` @@ -172,7 +172,7 @@ hyprctl reload hyprctl -j cua:status ``` -Continue only when status reports `keyboard_layout_independent: true`, `foreground_numlock_compatible: true`, input protocol v3, input capability, socket paths, and the expected compositor identity. Do not change `kb_layout`, `kb_options`, or NumLock for background input. If you previously followed the stock-US override instructions, remove only that Cua-specific override and reload to restore your underlying personal settings. +Continue only when status reports `keyboard_layout_independent: true`, `foreground_numlock_compatible: true`, `ime_popup_guard: true`, input protocol v3, input capability, socket paths, and the expected compositor identity. Do not change `kb_layout`, `kb_options`, or NumLock for background input. If you previously followed the stock-US override instructions, remove only that Cua-specific override and reload to restore your underlying personal settings. Start Driver with `CUA_DRIVER_RS_ENABLE_WAYLAND=1`. In a new disposable Inkscape document, test an admitted background key operation and pointer operation, then verify the result in both a fresh snapshot and a saved/reopened SVG. Driver text-route restrictions still apply. Never test against an existing document or automatically replay an action with a partial or unknown outcome. diff --git a/pkgbuilds/cua-hyprland-plugin/downstream.patch b/pkgbuilds/cua-hyprland-plugin/downstream.patch new file mode 100644 index 0000000..adfe247 --- /dev/null +++ b/pkgbuilds/cua-hyprland-plugin/downstream.patch @@ -0,0 +1,317 @@ +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 ++#include ++#include ++ ++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(popup)->getSurface()) ++ return; ++ reinterpret_cast(g_update_box->m_original)(popup); ++} ++ ++bool guarded_should_be_rendered(void* popup) { ++ if (!static_cast(popup)->getSurface()) ++ return false; ++ return reinterpret_cast(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(&guarded_update_box)); ++ g_should_be_rendered = hook_exact(plugin, "_ZN11CInputPopup16shouldBeRenderedEv", ++ reinterpret_cast(&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; + + 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 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(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 { [ 1, at ] };", false, "Shift level of a digit"}, ++ {"key { [ Shift_R ] };", false, "Shift key symbol"}, ++ {"key { [ Multi_key ] };", false, "Return as Compose"}, ++ {"key { [ dead_acute ] };", false, "Tab as a dead key"}, ++ {"key { [ 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 { 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); diff --git a/pkgbuilds/cua-hyprland-plugin/independent-keymaps.patch b/pkgbuilds/cua-hyprland-plugin/independent-keymaps.patch deleted file mode 100644 index 245ba60..0000000 --- a/pkgbuilds/cua-hyprland-plugin/independent-keymaps.patch +++ /dev/null @@ -1,1127 +0,0 @@ -diff --git a/CMakeLists.txt b/CMakeLists.txt -index 8a80de2..2d48237 100644 ---- a/CMakeLists.txt -+++ b/CMakeLists.txt -@@ -39,8 +39,28 @@ target_compile_options(cua_hyprland_protocol PRIVATE -Wall -Wextra -Wpedantic -W - cua_hyprland_harden(cua_hyprland_protocol) - set_target_properties(cua_hyprland_protocol PROPERTIES POSITION_INDEPENDENT_CODE ON) - -+if(BUILD_TESTING OR CUA_HYPRLAND_INPUT OR CUA_HYPRLAND_TEST_INPUT) -+ find_package(PkgConfig REQUIRED) -+ pkg_check_modules(XKBCOMMON REQUIRED IMPORTED_TARGET xkbcommon) -+endif() -+ - if(BUILD_TESTING) -+ add_executable(cua_hyprland_keyboard_layout_test tests/keyboard_layout_test.cpp) -+ target_compile_features(cua_hyprland_keyboard_layout_test PRIVATE cxx_std_26) -+ target_include_directories(cua_hyprland_keyboard_layout_test PRIVATE src) -+ target_link_libraries(cua_hyprland_keyboard_layout_test PRIVATE PkgConfig::XKBCOMMON) -+ target_compile_options(cua_hyprland_keyboard_layout_test PRIVATE -Wall -Wextra -Wpedantic -Werror) -+ add_test(NAME cua_hyprland_keyboard_layout_test COMMAND cua_hyprland_keyboard_layout_test) -+ - find_package(Python3 REQUIRED COMPONENTS Interpreter) -+ add_test(NAME cua_hyprland_agent_keymap_test -+ COMMAND ${Python3_EXECUTABLE} -B ${CMAKE_CURRENT_SOURCE_DIR}/tests/agent_keymap_test.py) -+ set_tests_properties(cua_hyprland_agent_keymap_test PROPERTIES -+ ENVIRONMENT "CXX=${CMAKE_CXX_COMPILER}") -+ add_test(NAME cua_hyprland_foreground_modifiers_test -+ COMMAND ${Python3_EXECUTABLE} -B ${CMAKE_CURRENT_SOURCE_DIR}/tests/foreground_modifiers_test.py) -+ set_tests_properties(cua_hyprland_foreground_modifiers_test PROPERTIES -+ ENVIRONMENT "CXX=${CMAKE_CXX_COMPILER}") - add_test(NAME cua_hyprland_desktop_fault_policy_test - COMMAND ${Python3_EXECUTABLE} -B - ${CMAKE_CURRENT_SOURCE_DIR}/tests/desktop_fault_policy_test.py) -@@ -218,6 +238,7 @@ if(CUA_HYPRLAND_BUILD_PLUGIN) - message(FATAL_ERROR "Input is pinned to Hyprland 0.56.2") - endif() - target_sources(cua_hyprland_plugin PRIVATE src/input_experiment.cpp) -+ target_link_libraries(cua_hyprland_plugin PRIVATE PkgConfig::XKBCOMMON) - endif() - if(CUA_HYPRLAND_INPUT) - target_compile_definitions(cua_hyprland_plugin PRIVATE CUA_HYPRLAND_INPUT=1) -diff --git a/src/foreground_route.hpp b/src/foreground_route.hpp -index 0b675d3..c3af461 100644 ---- a/src/foreground_route.hpp -+++ b/src/foreground_route.hpp -@@ -68,11 +68,13 @@ struct ForegroundSeatBindings { - bool unique() const { return primary_candidates == 1; } - }; - --inline ForegroundFailureReason foreground_key_modifier_failure(const std::array& modifiers) { -- // The KEY mapping assumes a neutral US state, including layout group zero. -+inline ForegroundFailureReason foreground_key_modifier_failure(const std::array& modifiers, -+ std::uint32_t allowed_locked = 0) { -+ // The caller may admit a keymap-resolved ambient Num Lock mask. All other -+ // human modifiers and nonzero layout groups remain unsupported. - if (modifiers[0]) return ForegroundFailureReason::keyboard_depressed; - if (modifiers[1]) return ForegroundFailureReason::keyboard_latched; -- if (modifiers[2]) return ForegroundFailureReason::keyboard_locked; -+ if (modifiers[2] & ~allowed_locked) return ForegroundFailureReason::keyboard_locked; - if (modifiers[3]) return ForegroundFailureReason::keyboard_group; - return ForegroundFailureReason::none; - } -diff --git a/src/input_experiment.cpp b/src/input_experiment.cpp -index fc7e740..5d80d3e 100644 ---- a/src/input_experiment.cpp -+++ b/src/input_experiment.cpp -@@ -9,6 +9,8 @@ - #include "seat_lifetime.hpp" - #include "owned_socket_path.hpp" - #include "foreground_route.hpp" -+#include "keyboard_layout.hpp" -+#include - - #include - #include -@@ -214,7 +216,11 @@ struct InputExperiment::Impl { - xkb_keymap* keymap = nullptr; - xkb_state* keyboard_state = nullptr; - int keymap_fd = -1; -- bool retired = false, suspended = true, us_keymap = false, physical_keymap_present = false; -+ // Foreground state uses the actual primary map; never share it with agent seats. -+ xkb_keymap* physical_keymap = nullptr; -+ xkb_state* physical_state = nullptr; -+ std::string physical_keymap_text; -+ bool retired = false, suspended = true, physical_keymap_present = false; - WP physical_keyboard; - CHyprSignalListener keymap_listener; - unsigned lane; -@@ -243,23 +249,37 @@ struct InputExperiment::Impl { - // No private key or input-enabled default exists in this component. - } - -- static bool canonical_us_keymap(xkb_context* context, xkb_keymap* map) { -- // Compare canonical compiled content, not a layout display name. This -- // deliberately excludes variants, options, remaps, and multiple groups. -- const xkb_rule_names names{"evdev", "pc105", "us", "", ""}; -- auto* reference = xkb_keymap_new_from_names(context, &names, XKB_KEYMAP_COMPILE_NO_FLAGS); -- if (!reference) return false; -- char* actual = xkb_keymap_get_as_string(map, XKB_KEYMAP_FORMAT_TEXT_V1); -- char* expected = xkb_keymap_get_as_string(reference, XKB_KEYMAP_FORMAT_TEXT_V1); -- const bool matches = actual && expected && std::strcmp(actual, expected) == 0; -- std::free(actual); std::free(expected); xkb_keymap_unref(reference); -- return matches; -- } -- bool layout_qualified() const { -- if (!kProduction) return true; -+ bool physical_layout_ready() const { - const auto keyboard = g_pSeatManager->m_keyboard.lock(); -- return physical_keymap_present && us_keymap && keyboard_state && keyboard && -- keyboard->m_xkbKeymapV1FD.get() >= 0 && keyboard->m_xkbKeymapV1String == keymap_text; -+ return physical_keymap_present && physical_state && keyboard && -+ keyboard->m_xkbKeymapV1FD.get() >= 0 && keyboard->m_xkbKeymapV1String == physical_keymap_text; -+ } -+ bool layout_qualified(InputRoute route, std::uint64_t capability) const { -+ return keyboard_layout_ready(route, capability, keyboard_state && keymap_fd >= 0, physical_layout_ready()); -+ } -+ void initialize_agent_keymap() { -+ if (keymap) return; -+ xkb_context_ = xkb_context_new(XKB_CONTEXT_NO_FLAGS); -+ keymap = xkb_context_ ? agent_keymap(xkb_context_) : nullptr; -+ keyboard_state = keymap ? xkb_state_new(keymap) : nullptr; -+ char* text = keymap ? xkb_keymap_get_as_string(keymap, XKB_KEYMAP_FORMAT_TEXT_V1) : nullptr; -+ if (!keyboard_state || !text) { -+ std::free(text); -+ throw std::runtime_error("agent XKB keymap unavailable"); -+ } -+ keymap_text = text; -+ std::free(text); -+ keymap_fd = memfd_create("cua-agent-keymap", MFD_CLOEXEC | MFD_ALLOW_SEALING); -+ if (keymap_fd < 0) throw std::runtime_error("agent keymap fd unavailable"); -+ std::size_t offset = 0; -+ while (offset < keymap_text.size() + 1) { -+ const auto count = write(keymap_fd, keymap_text.c_str() + offset, keymap_text.size() + 1 - offset); -+ if (count < 0 && errno == EINTR) continue; -+ if (count <= 0) throw std::runtime_error("agent keymap write failed"); -+ offset += static_cast(count); -+ } -+ if (fcntl(keymap_fd, F_ADD_SEALS, F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE | F_SEAL_SEAL) < 0) -+ throw std::runtime_error("agent keymap sealing failed"); - } - void sync_keymap() { - const auto keyboard = g_pSeatManager->m_keyboard.lock(); -@@ -278,38 +298,20 @@ struct InputExperiment::Impl { - return; - } - physical_keymap_present = true; -- if (keyboard_state && keymap_text == keyboard->m_xkbKeymapV1String) return; -- // Prepare a complete replacement before retiring the old independent -- // state. Keep our own fd: primary keyboard replacement must not leave -- // later seat bindings referring to a closed compositor fd. -- auto* context = xkb_context_new(XKB_CONTEXT_NO_FLAGS); -- auto* map = context ? xkb_keymap_new_from_string(context, keyboard->m_xkbKeymapV1String.c_str(), -- XKB_KEYMAP_FORMAT_TEXT_V1, XKB_KEYMAP_COMPILE_NO_FLAGS) : nullptr; -- auto* state = map ? xkb_state_new(map) : nullptr; -- const auto fd = fcntl(keyboard->m_xkbKeymapV1FD.get(), F_DUPFD_CLOEXEC, 0); -- if (!state || fd < 0) { -- if (fd >= 0) close(fd); -- if (state) xkb_state_unref(state); -- if (map) xkb_keymap_unref(map); -- if (context) xkb_context_unref(context); -- throw std::runtime_error("independent XKB state unavailable"); -- } -+ if (physical_keymap_text == keyboard->m_xkbKeymapV1String) return; -+ // Physical changes still revoke authority, but never replace the map -+ // advertised by either background keyboard. - desktop_transition(); -- if (keyboard_state) xkb_state_unref(keyboard_state); -- if (keymap) xkb_keymap_unref(keymap); -- if (xkb_context_) xkb_context_unref(xkb_context_); -- if (keymap_fd >= 0) close(keymap_fd); -- keyboard_state = state; keymap = map; xkb_context_ = context; keymap_fd = fd; -- us_keymap = !kProduction || canonical_us_keymap(context, map); -- keymap_text = keyboard->m_xkbKeymapV1String; -- for (auto& k : keyboards) -- if (!k->dead && k->wl->resource()) -- k->wl->sendKeymap(WL_KEYBOARD_KEYMAP_FORMAT_XKB_V1, keymap_fd, keymap_text.size() + 1); -- for (auto& seat : seats) -- if (!seat->dead && seat->wl->resource()) -- seat->wl->sendCapabilities(static_cast(WL_SEAT_CAPABILITY_POINTER | WL_SEAT_CAPABILITY_KEYBOARD)); -+ auto* map = xkb_keymap_new_from_string(xkb_context_, keyboard->m_xkbKeymapV1String.c_str(), -+ XKB_KEYMAP_FORMAT_TEXT_V1, XKB_KEYMAP_COMPILE_NO_FLAGS); -+ auto* state = map ? xkb_state_new(map) : nullptr; -+ if (physical_state) xkb_state_unref(physical_state); -+ if (physical_keymap) xkb_keymap_unref(physical_keymap); -+ physical_state = state; physical_keymap = map; -+ physical_keymap_text = keyboard->m_xkbKeymapV1String; - } - void start() { -+ initialize_agent_keymap(); - sync_keymap(); - timer = wl_event_loop_add_timer(g_pCompositor->m_wlEventLoop, tick, this); - if (!timer) throw std::runtime_error("input timer registration failed"); -@@ -389,6 +391,8 @@ struct InputExperiment::Impl { - cleanup_socket(); - if (keyboard_state) xkb_state_unref(keyboard_state); - if (keymap) xkb_keymap_unref(keymap); -+ if (physical_state) xkb_state_unref(physical_state); -+ if (physical_keymap) xkb_keymap_unref(physical_keymap); - if (xkb_context_) xkb_context_unref(xkb_context_); - if (keymap_fd >= 0) close(keymap_fd); - } -@@ -782,12 +786,40 @@ struct InputExperiment::Impl { - .exact_pointer_focus = root && g_pSeatManager->m_state.pointerFocus == root, - }; - } -+ std::array capture_foreground_modifiers(bool needs_keyboard) const { -+ const auto physical = g_pSeatManager->m_keyboard.lock(); -+ if (!physical) throw ForegroundFailure{ForegroundFailureReason::physical_keyboard}; -+ std::array result{}; -+ const auto observe = [&](const auto& kb, bool primary) { -+ const std::array state{kb->m_modifiersState.depressed, kb->m_modifiersState.latched, -+ kb->m_modifiersState.locked, kb->m_modifiersState.group}; -+ if (needs_keyboard) { -+ const auto failure = foreground_key_modifier_failure(state, foreground_numlock_mask(kb->m_xkbKeymap)); -+ if (failure != ForegroundFailureReason::none) throw ForegroundFailure{failure}; -+ // Hyprland combines shared raw masks. A lock from a different -+ // encoding must not be reinterpreted as the primary Num Lock. -+ if (state[2] && state[2] != foreground_numlock_mask(physical_keymap)) -+ throw ForegroundFailure{ForegroundFailureReason::keyboard_locked}; -+ } -+ for (unsigned i = 0; i < 3; ++i) result[i] |= state[i]; -+ if (primary) result[3] = state[3]; -+ }; -+ observe(physical, true); -+ for (const auto& kb : g_pInputManager->m_keyboards) { -+ if (kb == physical || !kb->m_enabled || !kb->shareStates() || -+ (kb->isVirtual() && g_pInputManager->shouldIgnoreVirtualKeyboard(kb))) continue; -+ observe(kb, false); -+ } -+ return result; -+ } - void require_foreground(Client& c) { - if (lease != &c) throw ForegroundFailure{ForegroundFailureReason::lease}; - if (c.dead) throw ForegroundFailure{ForegroundFailureReason::client_dead}; - if (!available()) throw ForegroundFailure{ForegroundFailureReason::session_unavailable}; -- if (!layout_qualified()) throw ForegroundFailure{ForegroundFailureReason::unsupported_layout}; -+ if (foreground_keyboard_used && !physical_layout_ready()) throw ForegroundFailure{ForegroundFailureReason::unsupported_layout}; - if (Clock::now() >= expires) throw ForegroundFailure{ForegroundFailureReason::lease_expired}; -+ if (foreground_keyboard_used && capture_foreground_modifiers(true) != foreground_modifiers) -+ throw ForegroundFailure{ForegroundFailureReason::keyboard_state}; - const auto failure = foreground_guard(c).dispatch_failure(foreground_needs_pointer); - if (failure != ForegroundFailureReason::none) throw ForegroundFailure{failure}; - } -@@ -803,24 +835,31 @@ struct InputExperiment::Impl { - p->sendButton(event_ms(), held_button, WL_POINTER_BUTTON_STATE_RELEASED); - p->sendFrame(); - } -+ // Synthetic events only change our private state. If human input -+ // cancelled the action, restore the current real state, not stale locks. -+ const auto restore_modifiers = g_pSeatManager->m_keyboard.lock() ? -+ capture_foreground_modifiers(false) : foreground_modifiers; - if (foreground_keyboard_used && root && root->good() && g_pSeatManager->m_state.keyboardFocus == root) - for (const auto& weak : foreground_keyboards) - if (const auto k = weak.lock(); k && k->good()) { - for (auto code : held_keys) k->sendKey(event_ms(), code, WL_KEYBOARD_KEY_STATE_RELEASED); -- k->sendMods(foreground_modifiers[0], foreground_modifiers[1], foreground_modifiers[2], foreground_modifiers[3]); -+ k->sendMods(restore_modifiers[0], restore_modifiers[1], restore_modifiers[2], restore_modifiers[3]); - } - held_button = 0; held_keys.clear(); - foreground_pointers.clear(); foreground_keyboards.clear(); foreground_surface.reset(); foreground_seat.reset(); - foreground_started = false; - foreground_keyboard_used = false; -+ if (physical_state) xkb_state_unref(physical_state); -+ physical_state = physical_keymap ? xkb_state_new(physical_keymap) : nullptr; - } -- void start_foreground(Client& c, double x, double y, bool needs_pointer, bool needs_keyboard) { -+ void start_foreground(Client& c, double x, double y, bool needs_pointer, bool needs_keyboard, -+ const std::array& modifiers) { - const auto root = c.surface.lock(); - const auto physical = g_pSeatManager->m_keyboard.lock(); - const auto failure = foreground_guard(c).activation_failure(); - if (failure != ForegroundFailureReason::none) throw ForegroundFailure{failure}; - if (!physical) throw ForegroundFailure{ForegroundFailureReason::physical_keyboard}; -- if (!keyboard_state) throw ForegroundFailure{ForegroundFailureReason::keyboard_state}; -+ if (needs_keyboard && !physical_layout_ready()) throw ForegroundFailure{ForegroundFailureReason::keyboard_state}; - if (needs_pointer && !g_pSeatManager->m_mouse) throw ForegroundFailure{ForegroundFailureReason::physical_pointer}; - const Vector2D local{x + c.geometry[0] - c.geometry[4], y + c.geometry[1] - c.geometry[5]}; - if (needs_pointer && (!point(c, x, y) || root->at(local, true).first != root)) throw ForegroundFailure{ForegroundFailureReason::pointer_target}; -@@ -831,24 +870,16 @@ struct InputExperiment::Impl { - for (const auto& k : seat->m_keyboards) if (k && k->good()) foreground_keyboards.push_back(k); - if (needs_pointer && foreground_pointers.empty()) throw ForegroundFailure{ForegroundFailureReason::pointer_resources}; - if (foreground_keyboards.empty()) throw ForegroundFailure{ForegroundFailureReason::keyboard_resources}; -- foreground_modifiers = {physical->m_modifiersState.depressed, physical->m_modifiersState.latched, -- physical->m_modifiersState.locked, physical->m_modifiersState.group}; -- for (const auto& kb : g_pInputManager->m_keyboards) { -- if (!kb->m_enabled || !kb->shareStates() || (kb->isVirtual() && g_pInputManager->shouldIgnoreVirtualKeyboard(kb))) continue; -- foreground_modifiers[0] |= kb->m_modifiersState.depressed; -- foreground_modifiers[1] |= kb->m_modifiersState.latched; -- foreground_modifiers[2] |= kb->m_modifiersState.locked; -- } -- if (needs_keyboard) { -- const auto modifier_failure = foreground_key_modifier_failure(foreground_modifiers); -- if (modifier_failure != ForegroundFailureReason::none) throw ForegroundFailure{modifier_failure}; -- } -- xkb_state_update_mask(keyboard_state, foreground_modifiers[0], foreground_modifiers[1], foreground_modifiers[2], 0, 0, foreground_modifiers[3]); -+ foreground_modifiers = modifiers; -+ if (needs_keyboard && capture_foreground_modifiers(true) != foreground_modifiers) -+ throw ForegroundFailure{ForegroundFailureReason::keyboard_state}; -+ if (needs_keyboard) xkb_state_update_mask(physical_state, foreground_modifiers[0], foreground_modifiers[1], foreground_modifiers[2], 0, 0, foreground_modifiers[3]); - foreground_surface = root; - foreground_seat = seat; - foreground_needs_pointer = needs_pointer; - c.foreground_attempted = true; - foreground_started = true; -+ foreground_keyboard_used = needs_keyboard; - foreground_activating = true; - // Activation intentionally persists. Never save, borrow, or restore focus. - if (g_pSeatManager->m_state.keyboardFocus != root || Desktop::focusState()->window() != c.window.lock() || -@@ -858,7 +889,16 @@ struct InputExperiment::Impl { - if (lease != &c) throw ForegroundFailure{ForegroundFailureReason::lease}; - const auto focus_failure = foreground_guard(c).dispatch_failure(false); - if (focus_failure != ForegroundFailureReason::none) throw ForegroundFailure{focus_failure}; -- if (!needs_pointer) { require_foreground(c); return; } -+ if (!needs_pointer) { -+ require_foreground(c); -+ if (needs_keyboard) -+ for (const auto& weak : foreground_keyboards) { -+ const auto k = weak.lock(); -+ if (!k || !k->good()) throw ForegroundFailure{ForegroundFailureReason::keyboard_resources}; -+ k->sendMods(foreground_modifiers[0], foreground_modifiers[1], foreground_modifiers[2], foreground_modifiers[3]); -+ } -+ return; -+ } - foreground_activating = true; - ::Pointer::mgr()->warpTo({x + c.geometry[0], y + c.geometry[1]}); - if (lease != &c) throw ForegroundFailure{ForegroundFailureReason::lease}; -@@ -893,16 +933,17 @@ struct InputExperiment::Impl { - } - void foreground_key(Client& c, std::uint32_t code, bool pressed) { - require_foreground(c); -+ if (!physical_layout_ready()) throw ForegroundFailure{ForegroundFailureReason::unsupported_layout}; - foreground_keyboard_used = true; -- xkb_state_update_key(keyboard_state, code + 8, pressed ? XKB_KEY_DOWN : XKB_KEY_UP); -+ xkb_state_update_key(physical_state, code + 8, pressed ? XKB_KEY_DOWN : XKB_KEY_UP); - if (pressed) held_keys.push_back(code); else std::erase(held_keys, code); - for (const auto& weak : foreground_keyboards) { - const auto k = weak.lock(); if (!k || !k->good()) throw ForegroundFailure{ForegroundFailureReason::keyboard_resources}; - k->sendKey(event_ms(), code, pressed ? WL_KEYBOARD_KEY_STATE_PRESSED : WL_KEYBOARD_KEY_STATE_RELEASED); -- k->sendMods(xkb_state_serialize_mods(keyboard_state, XKB_STATE_MODS_DEPRESSED), -- xkb_state_serialize_mods(keyboard_state, XKB_STATE_MODS_LATCHED), -- xkb_state_serialize_mods(keyboard_state, XKB_STATE_MODS_LOCKED), -- xkb_state_serialize_layout(keyboard_state, XKB_STATE_LAYOUT_EFFECTIVE)); -+ k->sendMods(xkb_state_serialize_mods(physical_state, XKB_STATE_MODS_DEPRESSED), -+ xkb_state_serialize_mods(physical_state, XKB_STATE_MODS_LATCHED), -+ xkb_state_serialize_mods(physical_state, XKB_STATE_MODS_LOCKED), -+ xkb_state_serialize_layout(physical_state, XKB_STATE_LAYOUT_EFFECTIVE)); - } - } - bool pointer_enter(Client& c, double x, double y) { -@@ -942,8 +983,8 @@ struct InputExperiment::Impl { - held_button = pressed ? value : 0; - } - bool keyboard_enter(Client& c) { -- const auto root = c.surface.lock(); const auto physical = g_pSeatManager->m_keyboard.lock(); -- if (!root || !physical || physical->m_xkbKeymapV1String != keymap_text || !keyboard_state) return false; -+ const auto root = c.surface.lock(); -+ if (!root || !keyboard_state || keymap_fd < 0) return false; - unsigned count = 0; - for (auto& k : keyboards) { - if (k->dead || !k->wl->resource() || k->wl->client() != root->client()) continue; -@@ -1030,7 +1071,7 @@ struct InputExperiment::Impl { - if (kProduction && (!InputGrant::single_operation(requested_cap) || - (requested_cap == 16 && route != InputRoute::primary_foreground))) { invalidate(c); send(c, refusal("unsupported")); return; } - if (kProduction && !available()) { invalidate(c, false); send(c, refusal("session_unavailable")); return; } -- if (!layout_qualified()) { invalidate(c, false); send(c, refusal("unsupported_layout")); return; } -+ if (!layout_qualified(route, requested_cap)) { invalidate(c, false); send(c, refusal("unsupported_layout")); return; } - const auto pid = number(f[1]); const auto address = number(f[2], 16); - PHLWINDOW window; - for (const auto& w : Desktop::windowState()->windows()) -@@ -1090,7 +1131,7 @@ struct InputExperiment::Impl { - if (c.token.empty() || f[2] != c.token || !refresh(c)) { send(c, refusal("stale_target")); return; } - if (number(f[3]) != c.revision) { if (kProduction) revoke("stale_geometry"); send(c, refusal("stale_geometry")); return; } - if (!available()) { revoke("session_unavailable", true); send(c, refusal("session_unavailable")); return; } -- if (!layout_qualified()) { revoke("unsupported_layout", true); send(c, refusal("unsupported_layout")); return; } -+ if (!layout_qualified(c.route, cap)) { revoke("unsupported_layout", true); send(c, refusal("unsupported_layout")); return; } - if (lease && Clock::now() >= expires) revoke("lease_expired"); - if (drag) { send(c, refusal("lease_busy")); return; } - if (lease != &c || !(capabilities & cap) || (kProduction && !grant.permits(cap, Clock::now()))) { -@@ -1184,8 +1225,13 @@ struct InputExperiment::Impl { - if (Clock::now() + std::chrono::milliseconds(duration + 50) >= expires) { send(c, refusal("lease_expired")); return; } - } - } -+ const auto modifiers = capture_foreground_modifiers(command == "KEY"); -+ if (command == "KEY" && !foreground_chord_compatible(physical_keymap, keymap, code, mods, modifiers)) { -+ revoke("unsupported_layout", true); -+ send(c, refusal("unsupported_layout")); return; -+ } - if (!consume_grant(c, cap)) return; -- start_foreground(c, x, y, command != "KEY" && command != "ACTIVATE", command == "KEY"); -+ start_foreground(c, x, y, command != "KEY" && command != "ACTIVATE", command == "KEY", modifiers); - if (command == "KEY") { - const std::array keys{42, 29, 56, 125}; - for (unsigned i = 0; i < 4; ++i) if ((mods & (1u << i)) && keys[i] != code) foreground_key(c, keys[i], true); -@@ -1227,7 +1273,7 @@ struct InputExperiment::Impl { - if (lease) { - if (lease->dead) revoke("disconnected", true); - else if (Clock::now() >= expires) revoke("lease_expired"); -- else if (!available() || !layout_qualified()) revoke("cancelled", true); -+ else if (!available() || !layout_qualified(lease->route, capabilities)) revoke("cancelled", true); - else if (!refresh(*lease) || primary_conflict(*lease) || agent_conflict(*lease) || - (drag && !drag->geometry.matches(lease->revision))) revoke("cancelled"); - } -diff --git a/src/keyboard_layout.hpp b/src/keyboard_layout.hpp -new file mode 100644 -index 0000000..f5f51c8 ---- /dev/null -+++ b/src/keyboard_layout.hpp -@@ -0,0 +1,127 @@ -+#pragma once -+ -+#include "foreground_route.hpp" -+#include -+#include -+#include -+#include -+#include -+ -+namespace cua::hyprland { -+// Wire v3 carries evdev key positions plus fixed Shift/Ctrl/Alt/Super bits. -+// Background seats advertise this map, independently of the human keyboard. -+inline xkb_keymap* agent_keymap(xkb_context* context) { -+ const xkb_rule_names names{"evdev", "pc105", "us", "", ""}; -+ return xkb_keymap_new_from_names(context, &names, XKB_KEYMAP_COMPILE_NO_FLAGS); -+} -+ -+inline bool keyboard_layout_ready(InputRoute route, std::uint64_t capability, -+ bool agent_ready, bool physical_ready) { -+ if (!(capability & 2)) return true; // Pointer/activation needs no key mapping. -+ return route == InputRoute::primary_foreground ? physical_ready : agent_ready; -+} -+ -+inline bool same_key_symbols(xkb_state* actual, xkb_state* expected, xkb_keycode_t key) { -+ const xkb_keysym_t *a = nullptr, *b = nullptr; -+ const int na = xkb_state_key_get_syms(actual, key, &a); -+ const int nb = xkb_state_key_get_syms(expected, key, &b); -+ if (na != nb) return false; -+ for (int i = 0; i < na; ++i) if (a[i] != b[i]) return false; -+ return true; -+} -+ -+inline bool same_consumed_modifiers(xkb_state* actual, xkb_state* expected, xkb_keycode_t key) { -+ // Toolkits subtract consumed modifiers when matching shortcuts. Matching -+ // symbols and effective state alone does not preserve shortcut meaning. -+ for (auto* state : {actual, expected}) { -+ auto* map = xkb_state_get_keymap(state); -+ for (xkb_mod_index_t i = 0; i < xkb_keymap_num_mods(map); ++i) { -+ const char* name = xkb_keymap_mod_get_name(map, i); -+ if (xkb_state_mod_name_is_active(state, name, XKB_STATE_MODS_EFFECTIVE) <= 0) continue; -+ const auto ai = xkb_keymap_mod_get_index(xkb_state_get_keymap(actual), name); -+ const auto bi = xkb_keymap_mod_get_index(xkb_state_get_keymap(expected), name); -+ for (auto mode : {XKB_CONSUMED_MODE_XKB, XKB_CONSUMED_MODE_GTK}) -+ if ((xkb_state_mod_index_is_consumed2(actual, key, ai, mode) > 0) != -+ (xkb_state_mod_index_is_consumed2(expected, key, bi, mode) > 0)) return false; -+ } -+ } -+ return true; -+} -+ -+inline bool same_modifier_state(xkb_state* actual, xkb_state* expected) { -+ // Modifier indices can differ between maps. Compare names, in both directions, -+ // including nonstandard modifiers and lock/latch changes on key release. -+ for (auto* state : {actual, expected}) { -+ auto* map = xkb_state_get_keymap(state); -+ for (xkb_mod_index_t i = 0; i < xkb_keymap_num_mods(map); ++i) { -+ const char* name = xkb_keymap_mod_get_name(map, i); -+ for (auto component : {XKB_STATE_MODS_DEPRESSED, XKB_STATE_MODS_LATCHED, -+ XKB_STATE_MODS_LOCKED, XKB_STATE_MODS_EFFECTIVE}) { -+ const bool a = xkb_state_mod_name_is_active(actual, name, component) > 0; -+ const bool b = xkb_state_mod_name_is_active(expected, name, component) > 0; -+ if (a != b) return false; -+ } -+ } -+ } -+ for (auto component : {XKB_STATE_LAYOUT_DEPRESSED, XKB_STATE_LAYOUT_LATCHED, -+ XKB_STATE_LAYOUT_LOCKED, XKB_STATE_LAYOUT_EFFECTIVE}) -+ if (xkb_state_serialize_layout(actual, component) != xkb_state_serialize_layout(expected, component)) -+ return false; -+ return true; -+} -+ -+// Resolve the virtual modifier through this map; Num Lock is not necessarily -+// encoded as Mod2. Ambiguous encodings are deliberately not admitted. -+inline xkb_mod_mask_t foreground_numlock_mask(xkb_keymap* map) { -+ const auto mask = map ? xkb_keymap_mod_get_mask(map, XKB_VMOD_NAME_NUM) : 0; -+ return mask && !(mask & (mask - 1)) && -+ !(mask & xkb_keymap_mod_get_mask(map, XKB_MOD_NAME_CAPS)) ? mask : 0; -+} -+ -+// Simulate the complete chord before delivering any events or changing focus. -+// This is compatibility checking, not layout translation: physical key positions -+// remain unchanged. Unrelated remaps are harmless, requested remaps fail closed. -+inline bool foreground_chord_compatible(xkb_keymap* physical, xkb_keymap* canonical, -+ std::uint32_t code, std::uint32_t mods, -+ const std::array& modifiers) { -+ if (!physical || !canonical) return false; -+ if (foreground_key_modifier_failure(modifiers, foreground_numlock_mask(physical)) != -+ ForegroundFailureReason::none) return false; -+ using State = std::unique_ptr; -+ State actual{xkb_state_new(physical), xkb_state_unref}; -+ State expected{xkb_state_new(canonical), xkb_state_unref}; -+ // The client retains Num Lock, but wire keys still mean the neutral US -+ // chord. A third state rejects keypad/navigation changes caused by the lock. -+ State intended{xkb_state_new(canonical), xkb_state_unref}; -+ if (!actual || !expected || !intended) return false; -+ if (modifiers[2]) { -+ const auto canonical_lock = foreground_numlock_mask(canonical); -+ if (!canonical_lock) return false; -+ xkb_state_update_mask(actual.get(), 0, 0, modifiers[2], 0, 0, 0); -+ xkb_state_update_mask(expected.get(), 0, 0, canonical_lock, 0, 0, 0); -+ if (!same_modifier_state(actual.get(), expected.get())) return false; -+ } -+ const auto event = [&](std::uint32_t key, xkb_key_direction direction) { -+ // A client interprets the press using the preceding modifiers event. -+ // Stock both_capslock_cancel gives Shift a Caps_Lock symbol at its -+ // shifted level; that level is not another press. Releases pair by -+ // keycode, but their lock/latch/group effects still must agree. -+ if (direction == XKB_KEY_DOWN && -+ (!same_key_symbols(actual.get(), expected.get(), key + 8) || -+ !same_consumed_modifiers(actual.get(), expected.get(), key + 8) || -+ !same_key_symbols(expected.get(), intended.get(), key + 8) || -+ !same_consumed_modifiers(expected.get(), intended.get(), key + 8))) return false; -+ xkb_state_update_key(actual.get(), key + 8, direction); -+ xkb_state_update_key(expected.get(), key + 8, direction); -+ xkb_state_update_key(intended.get(), key + 8, direction); -+ return same_modifier_state(actual.get(), expected.get()); -+ }; -+ constexpr std::array keys{42, 29, 56, 125}; -+ for (unsigned i = 0; i < keys.size(); ++i) -+ if ((mods & (1u << i)) && keys[i] != code && !event(keys[i], XKB_KEY_DOWN)) return false; -+ if (!event(code, XKB_KEY_DOWN) || !event(code, XKB_KEY_UP)) return false; -+ for (int i = 3; i >= 0; --i) -+ if ((mods & (1u << i)) && keys[i] != code && !event(keys[i], XKB_KEY_UP)) return false; -+ return true; -+} -+} // namespace cua::hyprland -diff --git a/src/plugin.cpp b/src/plugin.cpp -index 88b9900..ae7fc7c 100644 ---- a/src/plugin.cpp -+++ b/src/plugin.cpp -@@ -218,6 +218,11 @@ std::string status_output(bool json) { - - if (json) { - auto result = cua::hyprland::render_status_json(report); -+#ifdef CUA_HYPRLAND_INPUT -+ // Installation checks compiled support before enabling input seats. -+ result.pop_back(); -+ result += ",\"keyboard_layout_independent\":true,\"foreground_numlock_compatible\":true}"; -+#endif - #if defined(CUA_HYPRLAND_TEST_INPUT) || defined(CUA_HYPRLAND_INPUT) - if (g_experiment) { - #ifdef CUA_HYPRLAND_INPUT -diff --git a/tests/agent_keymap_test.py b/tests/agent_keymap_test.py -new file mode 100644 -index 0000000..78a3603 ---- /dev/null -+++ b/tests/agent_keymap_test.py -@@ -0,0 +1,86 @@ -+"""Exercise the production map initializer without a compositor or desktop changes.""" -+import os -+from pathlib import Path -+import re -+import shlex -+import subprocess -+import tempfile -+import unittest -+ -+ROOT = Path(__file__).resolve().parents[1] -+ -+ -+class AgentKeymapTest(unittest.TestCase): -+ def test_production_keymap_and_fd(self): -+ source = (ROOT / 'src/input_experiment.cpp').read_text() -+ body = re.search(r'^ void initialize_agent_keymap\(\).*?^ }', source, re.M | re.S) -+ self.assertIsNotNone(body) -+ fixture = r''' -+#include "keyboard_layout.hpp" -+#include -+#include -+#include -+#include -+#include -+#include -+#include -+#include -+#include -+using namespace cua::hyprland; -+void check(bool v, const char* m) { if (!v) { std::cerr << m; std::exit(1); } } -+struct Lane { -+ xkb_context* xkb_context_ = nullptr; -+ xkb_keymap* keymap = nullptr; -+ xkb_state* keyboard_state = nullptr; -+ int keymap_fd = -1; -+ std::string keymap_text; -+ // INITIALIZER -+ ~Lane() { -+ if (keyboard_state) xkb_state_unref(keyboard_state); -+ if (keymap) xkb_keymap_unref(keymap); -+ if (xkb_context_) xkb_context_unref(xkb_context_); -+ if (keymap_fd >= 0) close(keymap_fd); -+ } -+}; -+int main() { -+ Lane a, b; -+ a.initialize_agent_keymap(); b.initialize_agent_keymap(); -+ check(a.keymap_fd != b.keymap_fd && a.keymap != b.keymap && a.keyboard_state != b.keyboard_state, -+ "lanes share owned map resources"); -+ check(a.keymap_text == b.keymap_text, "lanes advertise different layouts"); -+ for (Lane* lane : {&a, &b}) { -+ struct stat status{}; -+ check(fstat(lane->keymap_fd, &status) == 0 && status.st_size == (off_t)lane->keymap_text.size() + 1, -+ "keymap fd is not terminated serialized map"); -+ std::string text(status.st_size, '\0'); -+ check(pread(lane->keymap_fd, text.data(), text.size(), 0) == status.st_size, "keymap fd cannot be read"); -+ check(text == lane->keymap_text + '\0', "keymap fd differs from advertised map"); -+ check(fcntl(lane->keymap_fd, F_GETFD) & FD_CLOEXEC, "keymap fd inherited by exec"); -+ const int seals = F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE | F_SEAL_SEAL; -+ check((fcntl(lane->keymap_fd, F_GET_SEALS) & seals) == seals, "keymap fd not immutable"); -+ const int fd = lane->keymap_fd; -+ lane->initialize_agent_keymap(); -+ check(lane->keymap_fd == fd, "initialization replaced advertised map"); -+ check(xkb_state_key_get_one_sym(lane->keyboard_state, 21 + 8) == XKB_KEY_y, -+ "background lane did not initialize US map"); -+ } -+ xkb_state_update_key(a.keyboard_state, 42 + 8, XKB_KEY_DOWN); -+ check(xkb_state_key_get_one_sym(a.keyboard_state, 30 + 8) == XKB_KEY_A, "lane modifier not applied"); -+ check(xkb_state_key_get_one_sym(b.keyboard_state, 30 + 8) == XKB_KEY_a, "lane modifier leaked"); -+} -+'''.replace('// INITIALIZER', body.group()) -+ compiler = shlex.split(os.environ.get('CXX', 'c++')) -+ flags = shlex.split(subprocess.check_output(['pkg-config', '--cflags', '--libs', 'xkbcommon'], text=True)) -+ with tempfile.TemporaryDirectory(prefix='cua-agent-keymap-') as directory: -+ cpp, binary = Path(directory) / 'fixture.cpp', Path(directory) / 'fixture' -+ cpp.write_text(fixture) -+ build = subprocess.run([*compiler, '-std=c++20', '-Wall', '-Wextra', '-Wpedantic', '-Werror', -+ '-I', str(ROOT / 'src'), str(cpp), '-o', str(binary), *flags], -+ capture_output=True, text=True, timeout=60) -+ self.assertEqual(build.returncode, 0, build.stdout + build.stderr) -+ result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) -+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr) -+ -+ -+if __name__ == '__main__': -+ unittest.main() -diff --git a/tests/desktop_fault_policy_fixture.cpp b/tests/desktop_fault_policy_fixture.cpp -index 45c48ef..36be139 100644 ---- a/tests/desktop_fault_policy_fixture.cpp -+++ b/tests/desktop_fault_policy_fixture.cpp -@@ -1,6 +1,7 @@ - // Used by desktop_fault_policy_test.py, which inserts actual production bodies. - // Transport effects are counted here; native protocol/app behavior is separate. - #include "drag_geometry.hpp" -+#include "keyboard_layout.hpp" - #include "input_grant.hpp" - #include "passive_pointer_target.hpp" - -@@ -18,6 +19,7 @@ struct Window {}; - struct Surface { int client() const { return 1; } }; - using Target = PassivePointerTarget, std::weak_ptr>; - struct Client { -+ InputRoute route = InputRoute::independent; - bool dead = false; - void* source = nullptr; - int fd = -1; -@@ -34,8 +36,6 @@ struct Pointer { - }; - struct Drag { Client* client; DragGeometry geometry{1}; }; - struct Trace { void mark(const char*, unsigned) {} }; --enum class ForegroundFailureReason { none }; --struct ForegroundFailure { ForegroundFailureReason reason; }; - void wl_event_source_remove(void*) {} - int close(int) { return 0; } - std::string refusal(std::string_view reason) { return std::string(reason); } -@@ -79,7 +79,7 @@ struct Lane { - drag.emplace(lease); - } - bool available() const { return !suspended && session; } -- bool layout_qualified() const { return layout; } -+ bool layout_qualified(InputRoute route, uint64_t cap) const { return keyboard_layout_ready(route, cap, layout, layout); } - bool refresh(Client&) const { return refresh_ok; } - template bool primary_conflict(const T&) const { return primary_busy; } - template bool agent_conflict(const T&) const { return peer_busy; } -@@ -122,7 +122,7 @@ int main() { - for (bool session_fault : {true, false}) { - for (int path = 0; path < 3; ++path) { - Lane l; -- if (session_fault) l.session = false; else l.layout = false; -+ if (session_fault) l.session = false; else { l.layout = false; l.capabilities = 2; } - if (path == 0) l.guard_targets(); - if (path == 1) l.target_refusal(*l.lease); - if (path == 2) l.action_refusal(*l.lease); -@@ -133,6 +133,19 @@ int main() { - l.session = true; l.layout = true; l.guard_targets(); l.check_inert(); - } - } -+ // Pointer admission, dispatch and ongoing drag ignore keyboard layout readiness. -+ for (auto route : {InputRoute::independent, InputRoute::primary_foreground}) { -+ for (uint64_t cap : {1, 4, 8, 16}) { -+ Lane l; l.layout = false; l.lease->route = route; l.capabilities = cap; -+ l.target_refusal(*l.lease, route, cap); -+ check(l.lease && l.responses.empty(), "pointer admission gated on layout"); -+ l.action_refusal(*l.lease, cap); -+ check(l.lease && l.responses.empty(), "pointer dispatch gated on layout"); -+ l.guard_targets(); -+ check(l.lease && l.drag && l.held_button && l.responses.empty(), -+ "pointer operation was gated by keyboard layout"); -+ } -+ } - // Every passive safety guard still applies during either desktop fault. - for (bool session_fault : {true, false}) { - for (int bad = 0; bad < 8; ++bad) { -diff --git a/tests/desktop_fault_policy_test.py b/tests/desktop_fault_policy_test.py -index 68ca6c8..7a4d1b4 100644 ---- a/tests/desktop_fault_policy_test.py -+++ b/tests/desktop_fault_policy_test.py -@@ -35,10 +35,11 @@ def fixture(source): - ): - block = source.split(start, 1)[1].split(end, 1)[0] - refusals = [line.strip() for line in block.splitlines() -- if 'if (' in line and ('!available()' in line or '!layout_qualified()' in line)] -+ if 'if (' in line and ('!available()' in line or '!layout_qualified(' in line)] - if len(refusals) != 2: - raise AssertionError(f'production refusal branches not found: {label}') -- methods += '\nvoid ' + label + '(Client& c) {\n' + '\n'.join(refusals) + '\n}' -+ params = ', InputRoute route = InputRoute::independent, uint64_t requested_cap = 2' if label == 'target_refusal' else ', uint64_t cap = 2' -+ methods += '\nvoid ' + label + '(Client& c' + params + ') {\n' + '\n'.join(refusals) + '\n}' - return (ROOT / 'tests/desktop_fault_policy_fixture.cpp').read_text().replace( - '// PRODUCTION_METHODS', methods) - -diff --git a/tests/foreground_modifiers_test.py b/tests/foreground_modifiers_test.py -new file mode 100644 -index 0000000..013c9bf ---- /dev/null -+++ b/tests/foreground_modifiers_test.py -@@ -0,0 +1,225 @@ -+"""Exercise production foreground state admission, dispatch and unwind with XKB.""" -+import os -+from pathlib import Path -+import re -+import shlex -+import subprocess -+import tempfile -+import unittest -+ -+ROOT = Path(__file__).resolve().parents[1] -+ -+ -+class ForegroundModifiersTest(unittest.TestCase): -+ def test_production_modifiers(self): -+ source = (ROOT / 'src/input_experiment.cpp').read_text() -+ methods = [] -+ for name in ('capture_foreground_modifiers', 'require_foreground', 'finish_foreground', -+ 'start_foreground', 'foreground_key'): -+ body = re.search(r'^ \S[^\n]*\b' + name + r'\(.*?^ }', source, re.M | re.S) -+ self.assertIsNotNone(body, name) -+ methods.append(body.group()) -+ preflight = re.search(r' const auto modifiers = capture_foreground_modifiers\(command == "KEY"\);.*?' -+ r' start_foreground\([^\n]*;', source, re.S) -+ self.assertIsNotNone(preflight) -+ fixture = r''' -+#include "keyboard_layout.hpp" -+#include -+#include -+#include -+#include -+#include -+#include -+#include -+using namespace cua::hyprland; -+using Clock = std::chrono::steady_clock; -+void check(bool ok, const char* why) { if (!ok) { std::cerr << why; std::exit(1); } } -+struct Vector2D { double x, y; }; -+constexpr int WL_KEYBOARD_KEY_STATE_PRESSED=1, WL_KEYBOARD_KEY_STATE_RELEASED=0; -+constexpr int WL_POINTER_BUTTON_STATE_RELEASED=0; -+struct Root : std::enable_shared_from_this { -+ bool good() const { return true; } -+ int client() const { return 0; } -+ auto at(Vector2D, bool) { return std::pair{shared_from_this(), 0}; } -+}; -+struct Resource { -+ xkb_state* client = nullptr; -+ std::vector symbols; -+ std::array last{}; -+ std::vector> history; -+ bool good() const { return true; } -+ void sendKey(unsigned, unsigned code, unsigned down) { -+ if (down) symbols.push_back(xkb_state_key_get_one_sym(client, code + 8)); -+ } -+ void sendMods(unsigned d, unsigned l, unsigned k, unsigned g) { -+ last={d,l,k,g}; history.push_back(last); xkb_state_update_mask(client,d,l,k,0,0,g); -+ } -+ void sendButton(unsigned, unsigned, unsigned) {} -+ void sendFrame() {} -+}; -+struct Keyboard { -+ struct { unsigned depressed=0,latched=0,locked=0,group=0; } m_modifiersState; -+ xkb_keymap* m_xkbKeymap = nullptr; -+ bool m_enabled=true, shared=true, virt=false; -+ bool shareStates() const { return shared; } -+ bool isVirtual() const { return virt; } -+}; -+struct Seat { -+ std::vector> m_keyboards, m_pointers; -+ bool good() const { return true; } -+}; -+struct SeatManager { -+ std::weak_ptr m_keyboard; -+ bool m_mouse=true; -+ struct { std::shared_ptr keyboardFocus, pointerFocus; } m_state; -+ std::shared_ptr seat; -+ auto seatResourceForClient(int) { return seat; } -+ void setPointerFocus(std::shared_ptr root, Vector2D) { m_state.pointerFocus=root; } -+} manager, *g_pSeatManager=&manager; -+struct InputManager { -+ std::vector> m_keyboards; -+ bool shouldIgnoreVirtualKeyboard(const std::shared_ptr&) { return false; } -+} input, *g_pInputManager=&input; -+namespace Desktop { -+constexpr int FOCUS_REASON_OTHER=0; -+struct Focus { -+ std::shared_ptr root; -+ std::shared_ptr change_on_focus; -+ auto window() { return root; } -+ auto surface() { return root; } -+ void fullWindowFocus(std::shared_ptr, int, std::shared_ptr r) { -+ root=r; manager.m_state.keyboardFocus=r; -+ if (change_on_focus) change_on_focus->m_modifiersState.locked=0; -+ } -+} focus; -+auto focusState() { return &focus; } -+} -+namespace Pointer { struct Manager { void warpTo(Vector2D) {} } pointer; auto mgr(){ return &pointer; } } -+struct Client { -+ bool dead=false, foreground_attempted=false; -+ std::weak_ptr surface, window; -+ std::array geometry{}; -+}; -+struct Lane { -+ xkb_keymap* physical_keymap=nullptr; -+ xkb_keymap* keymap=nullptr; -+ xkb_state* physical_state=nullptr; -+ std::array foreground_modifiers{}; -+ std::vector> foreground_keyboards, foreground_pointers; -+ std::weak_ptr foreground_surface; -+ std::weak_ptr foreground_seat; -+ std::vector held_keys; -+ unsigned held_button=0; -+ bool foreground_started=false,foreground_activating=false,foreground_keyboard_used=false; -+ bool foreground_needs_pointer=false, layout_ready=true, physical_held=false; -+ Client* lease=nullptr; -+ Clock::time_point expires=Clock::now()+std::chrono::hours(1); -+ unsigned consumed=0, refused=0; -+ bool available() { return true; } -+ bool physical_layout_ready() { return layout_ready; } -+ bool point(Client&, double,double) { return true; } -+ unsigned event_ms() { return 0; } -+ void foreground_motion(Client&,double,double) {} -+ ForegroundGuard foreground_guard(Client&) { -+ return {.exact_root=true,.physical_keys=physical_held,.exact_keyboard_focus=true,.exact_pointer_focus=true}; -+ } -+ void revoke(const char*, bool) {} -+ auto refusal(const char*) { return std::string{}; } -+ void send(Client&, const std::string&) { ++refused; } -+ bool consume_grant(Client&, unsigned) { ++consumed; return true; } -+ // METHODS -+ void preflight(Client& c, unsigned code, unsigned mods) { -+ const std::string command="KEY"; -+ const unsigned cap=2; -+ const double x=0,y=0; -+ // PREFLIGHT -+ } -+ ~Lane() { if (physical_state) xkb_state_unref(physical_state); } -+}; -+int main() { -+ auto context=xkb_context_new(XKB_CONTEXT_NO_FLAGS); -+ const xkb_rule_names names{"evdev","pc105","us","","ctrl:nocaps"}; -+ auto physical=xkb_keymap_new_from_names(context,&names,XKB_KEYMAP_COMPILE_NO_FLAGS); -+ auto canonical=agent_keymap(context); -+ auto keyboard=std::make_shared(); keyboard->m_xkbKeymap=physical; -+ manager.m_keyboard=keyboard; input.m_keyboards={keyboard}; -+ auto root=std::make_shared(); -+ manager.m_state.keyboardFocus=root; manager.m_state.pointerFocus=root; Desktop::focus.root=root; -+ manager.seat=std::make_shared(); auto resource=std::make_shared(); -+ resource->client=xkb_state_new(physical); manager.seat->m_keyboards={resource}; -+ Client client; client.surface=root; client.window=root; -+ Lane lane; lane.physical_keymap=physical; lane.keymap=canonical; -+ lane.physical_state=xkb_state_new(physical); lane.lease=&client; -+ const auto num=foreground_numlock_mask(physical); -+ keyboard->m_modifiersState.locked=num; -+ lane.preflight(client,30,1); -+ check(lane.foreground_started && lane.foreground_modifiers[2]==num && resource->last[2]==num, -+ "actual Num Lock state was not captured and published before input"); -+ lane.foreground_key(client,42,true); lane.foreground_key(client,30,true); -+ lane.foreground_key(client,30,false); lane.foreground_key(client,42,false); -+ check(resource->symbols.back()==XKB_KEY_A,"client did not receive intended shifted text"); -+ for (const auto& state : resource->history) -+ check(state[2]==num,"dispatch transiently cleared Num Lock"); -+ lane.finish_foreground(); -+ check(resource->last==std::array{0,0,num,0} && keyboard->m_modifiersState.locked==num, -+ "completion changed real or client Num Lock state"); -+ const auto consumed=lane.consumed; -+ lane.preflight(client,79,0); -+ check(lane.refused==1 && lane.consumed==consumed && !lane.foreground_started, -+ "caller checked neutral state and admitted Num Lock keypad semantics"); -+ keyboard->m_modifiersState.locked=0; -+ lane.preflight(client,79,0); lane.foreground_key(client,79,true); lane.foreground_key(client,79,false); -+ check(resource->symbols.back()==XKB_KEY_KP_End,"neutral keypad contract changed"); lane.finish_foreground(); -+ keyboard->m_modifiersState.locked=num; lane.preflight(client,30,1); lane.foreground_key(client,42,true); -+ keyboard->m_modifiersState.locked=0; -+ try { lane.foreground_key(client,30,true); check(false,"ambient state change accepted"); } -+ catch (ForegroundFailure f) { check(f.reason==ForegroundFailureReason::keyboard_state,"wrong state change refusal"); } -+ lane.finish_foreground(); check(resource->last==std::array{},"cancellation restored stale Num Lock"); -+ keyboard->m_modifiersState.locked=num; lane.preflight(client,30,1); lane.foreground_key(client,42,true); -+ keyboard->m_modifiersState.locked=xkb_keymap_mod_get_mask(physical,XKB_MOD_NAME_CAPS); -+ keyboard->m_modifiersState.depressed=xkb_keymap_mod_get_mask(physical,XKB_MOD_NAME_CTRL); -+ lane.finish_foreground(); -+ check(resource->last[0]==keyboard->m_modifiersState.depressed && resource->last[2]==keyboard->m_modifiersState.locked, -+ "cancellation failed to restore current human Caps and held Control"); -+ keyboard->m_modifiersState.depressed=0; keyboard->m_modifiersState.locked=num; -+ lane.physical_held=true; -+ try { lane.preflight(client,30,0); check(false,"held physical key admitted"); } -+ catch (ForegroundFailure f) { check(f.reason==ForegroundFailureReason::physical_keys,"wrong held-key refusal"); } -+ lane.physical_held=false; -+ auto shared=std::make_shared(); shared->m_xkbKeymap=physical; -+ shared->m_modifiersState.locked=num; -+ keyboard->m_modifiersState.locked=0; -+ input.m_keyboards.push_back(shared); -+ lane.preflight(client,30,0); -+ check(lane.foreground_modifiers[2]==num && resource->last[2]==num,"shared Num Lock was ignored"); -+ lane.foreground_key(client,30,true); lane.foreground_key(client,30,false); lane.finish_foreground(); -+ check(resource->last[2]==num,"shared Num Lock not restored"); -+ shared->m_modifiersState.locked=xkb_keymap_mod_get_mask(physical,XKB_MOD_NAME_CAPS); -+ try { lane.preflight(client,30,0); check(false,"shared Caps state admitted"); } -+ catch (ForegroundFailure f) { check(f.reason==ForegroundFailureReason::keyboard_locked,"wrong shared lock refusal"); } -+ shared->shared=false; lane.preflight(client,30,0); lane.finish_foreground(); -+ // Focus callbacks can change real state between preflight and first event. -+ keyboard->m_modifiersState.locked=num; -+ Desktop::focus.root.reset(); Desktop::focus.change_on_focus=keyboard; -+ try { lane.preflight(client,30,0); check(false,"focus-time state change accepted"); } -+ catch (ForegroundFailure f) { check(f.reason==ForegroundFailureReason::keyboard_state,"wrong focus change refusal"); } -+ lane.finish_foreground(); -+ check(resource->last[2]==0,"focus-time cancellation restored stale lock"); -+ xkb_state_unref(resource->client); xkb_keymap_unref(physical); xkb_keymap_unref(canonical); xkb_context_unref(context); -+} -+'''.replace('// METHODS', '\n'.join(methods)).replace('// PREFLIGHT', preflight.group()) -+ compiler = shlex.split(os.environ.get('CXX', 'c++')) -+ flags = shlex.split(subprocess.check_output(['pkg-config', '--cflags', '--libs', 'xkbcommon'], text=True)) -+ with tempfile.TemporaryDirectory(prefix='cua-foreground-modifiers-') as directory: -+ cpp, binary = Path(directory) / 'fixture.cpp', Path(directory) / 'fixture' -+ cpp.write_text(fixture) -+ build = subprocess.run([*compiler, '-std=c++20', '-Wall', '-Wextra', '-Wpedantic', '-Werror', -+ '-I', str(ROOT / 'src'), str(cpp), '-o', str(binary), *flags], -+ capture_output=True, text=True, timeout=60) -+ self.assertEqual(build.returncode, 0, build.stdout + build.stderr) -+ result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) -+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr) -+ -+ -+if __name__ == '__main__': -+ unittest.main() -diff --git a/tests/keyboard_layout_test.cpp b/tests/keyboard_layout_test.cpp -new file mode 100644 -index 0000000..6d57616 ---- /dev/null -+++ b/tests/keyboard_layout_test.cpp -@@ -0,0 +1,130 @@ -+#include "keyboard_layout.hpp" -+#include -+#include -+#include -+ -+using namespace cua::hyprland; -+void check(bool condition, const char* message) { -+ if (!condition) { std::cerr << message << '\n'; std::exit(1); } -+} -+int main() { -+ using Context = std::unique_ptr; -+ using Map = std::unique_ptr; -+ using State = std::unique_ptr; -+ Context context{xkb_context_new(XKB_CONTEXT_NO_FLAGS), xkb_context_unref}; -+ Map agent{agent_keymap(context.get()), xkb_keymap_unref}; -+ Map second{agent_keymap(context.get()), xkb_keymap_unref}; -+ check(bool(agent) && bool(second), "canonical maps unavailable"); -+ const auto map = [&](const char* layout, const char* options) { -+ const xkb_rule_names names{"evdev", "pc105", layout, "", options}; -+ return Map{xkb_keymap_new_from_names(context.get(), &names, XKB_KEYMAP_COMPILE_NO_FLAGS), xkb_keymap_unref}; -+ }; -+ auto stock = map("us", "compose:caps,shift:both_capslock_cancel"); -+ auto nocaps = map("us", "ctrl:nocaps"); -+ auto swapctrl = map("us", "ctrl:swapcaps"); -+ auto swapalt = map("us", "altwin:swap_alt_win"); -+ auto german = map("de", ""); -+ check(stock && nocaps && swapctrl && swapalt && german, "test keymaps unavailable"); -+ for (auto* physical : {stock.get(), nocaps.get(), swapctrl.get(), swapalt.get(), german.get()}) { -+ check(foreground_chord_compatible(physical, agent.get(), 30, 0, {}), "unrelated remap blocked A"); -+ check(foreground_chord_compatible(physical, agent.get(), 30, 1, {}), "unrelated remap blocked Shift+A"); -+ check(foreground_chord_compatible(physical, agent.get(), 28, 0, {}), "unrelated remap blocked Return"); -+ } -+ check(foreground_chord_compatible(stock.get(), agent.get(), 30, 2, {}), "stock Omarchy blocked Ctrl+A"); -+ check(foreground_chord_compatible(nocaps.get(), agent.get(), 30, 2, {}), "Caps to Ctrl blocked Ctrl+A"); -+ check(!foreground_chord_compatible(swapctrl.get(), agent.get(), 30, 2, {}), "Ctrl/Caps swap sent wrong Ctrl+A"); -+ check(!foreground_chord_compatible(swapctrl.get(), agent.get(), 29, 0, {}), "remapped modifier key accepted"); -+ check(!foreground_chord_compatible(swapalt.get(), agent.get(), 30, 4, {}), "Alt/Super swap sent wrong Alt+A"); -+ check(!foreground_chord_compatible(swapalt.get(), agent.get(), 30, 8, {}), "Alt/Super swap sent wrong Super+A"); -+ check(!foreground_chord_compatible(german.get(), agent.get(), 21, 0, {}), "German Z accepted as US Y"); -+ check(!foreground_chord_compatible(german.get(), agent.get(), 3, 1, {}), "German shifted punctuation accepted"); -+ check(!foreground_chord_compatible(nullptr, agent.get(), 30, 0, {}), "missing physical map accepted"); -+ for (auto* physical : {agent.get(), stock.get(), nocaps.get()}) { -+ const auto numlock = foreground_numlock_mask(physical); -+ check(numlock != 0, "Num Lock encoding missing"); -+ for (const auto locked : {0u, numlock}) { -+ const std::array ambient{0, 0, locked, 0}; -+ for (const auto key : {30u, 48u, 44u, 2u, 11u, 28u, 57u}) -+ for (const auto mods : {0u, 1u, 2u, 3u}) -+ check(foreground_chord_compatible(physical, agent.get(), key, mods, ambient), -+ "Num Lock blocked ordinary text or shortcut"); -+ for (const auto key : {71u, 72u, 75u, 79u, 82u, 83u}) -+ check(foreground_chord_compatible(physical, agent.get(), key, 0, ambient) == !locked, -+ "Num Lock keypad semantic change was ignored"); -+ } -+ const auto caps = xkb_keymap_mod_get_mask(physical, XKB_MOD_NAME_CAPS); -+ for (const auto locked : {caps, caps | numlock, 0x80000000u}) -+ check(!foreground_chord_compatible(physical, agent.get(), 30, 0, {0, 0, locked, 0}), -+ "unsupported lock accepted"); -+ check(!foreground_chord_compatible(physical, agent.get(), 30, 0, {numlock, 0, numlock, 0}), -+ "held modifier accepted with Num Lock"); -+ check(!foreground_chord_compatible(physical, agent.get(), 30, 0, {0, numlock, numlock, 0}), -+ "latched modifier accepted with Num Lock"); -+ check(!foreground_chord_compatible(physical, agent.get(), 30, 0, {0, 0, numlock, 1}), -+ "nonzero group accepted with Num Lock"); -+ } -+ const auto numlock = foreground_numlock_mask(agent.get()); -+ check(!foreground_chord_compatible(swapctrl.get(), agent.get(), 30, 2, {0, 0, numlock, 0}), -+ "Num Lock hid Ctrl remap"); -+ check(!foreground_chord_compatible(german.get(), agent.get(), 21, 0, {0, 0, numlock, 0}), -+ "Num Lock hid layout mismatch"); -+ // All supported wire chords remain compatible on the independent map. -+ for (unsigned code = 1; code <= 247; ++code) -+ if (code != 58 && code != 69 && code != 70) -+ for (unsigned mods = 0; mods < 16; ++mods) -+ check(foreground_chord_compatible(agent.get(), agent.get(), code, mods, {}), "canonical chord rejected"); -+ // Two lanes never share modifier state and never mutate the human state. -+ State first{xkb_state_new(agent.get()), xkb_state_unref}; -+ State other{xkb_state_new(second.get()), xkb_state_unref}; -+ State human{xkb_state_new(german.get()), xkb_state_unref}; -+ xkb_state_update_key(first.get(), 42 + 8, XKB_KEY_DOWN); -+ check(xkb_state_key_get_one_sym(first.get(), 30 + 8) == XKB_KEY_A, "agent shift not active"); -+ check(xkb_state_key_get_one_sym(other.get(), 30 + 8) == XKB_KEY_a, "agent shift leaked across lanes"); -+ check(xkb_state_key_get_one_sym(human.get(), 21 + 8) == XKB_KEY_z, "human layout changed"); -+ // Same symbols can hide a changed XKB action. Simulate an A that sets Mod3. -+ char* serialized = xkb_keymap_get_as_string(agent.get(), XKB_KEYMAP_FORMAT_TEXT_V1); -+ std::string changed = serialized; -+ std::free(serialized); -+ const auto at = changed.find("key "); -+ const auto end = changed.find(';', at); -+ check(at != std::string::npos && end != std::string::npos, "test action fixture unavailable"); -+ changed.replace(at, end - at + 1, -+ "key { type=\"ALPHABETIC\", symbols[Group1]=[a,A], actions[Group1]=[LockMods(modifiers=Mod3),LockMods(modifiers=Mod3)] };"); -+ Map lock{xkb_keymap_new_from_string(context.get(), changed.c_str(), XKB_KEYMAP_FORMAT_TEXT_V1, XKB_KEYMAP_COMPILE_NO_FLAGS), xkb_keymap_unref}; -+ check(bool(lock), "action fixture failed to compile"); -+ check(!foreground_chord_compatible(lock.get(), agent.get(), 30, 0, {}), "hidden lock action accepted"); -+ check(!foreground_chord_compatible(lock.get(), agent.get(), 30, 0, {0, 0, numlock, 0}), -+ "Num Lock hid an unexpected lock action"); -+ // Equal symbols and modifier state can still alter toolkit shortcut matching. -+ serialized = xkb_keymap_get_as_string(agent.get(), XKB_KEYMAP_FORMAT_TEXT_V1); -+ changed = serialized; -+ std::free(serialized); -+ const auto type_at = changed.find("type \"ALPHABETIC\""); -+ const auto type_end = changed.find("};", type_at); -+ check(type_at != std::string::npos && type_end != std::string::npos, "key type fixture unavailable"); -+ changed.insert(type_end, "preserve[Shift] = Shift;\n"); -+ Map preserved{xkb_keymap_new_from_string(context.get(), changed.c_str(), XKB_KEYMAP_FORMAT_TEXT_V1, XKB_KEYMAP_COMPILE_NO_FLAGS), xkb_keymap_unref}; -+ check(bool(preserved), "preserved modifier fixture failed to compile"); -+ check(!foreground_chord_compatible(preserved.get(), agent.get(), 30, 1, {}), "consumed modifier mismatch accepted"); -+ check(!foreground_chord_compatible(preserved.get(), agent.get(), 30, 1, {0, 0, numlock, 0}), -+ "Num Lock hid changed shortcut consumption"); -+ // A modifier that preserves Shift state but emits another symbol must fail. -+ serialized = xkb_keymap_get_as_string(agent.get(), XKB_KEYMAP_FORMAT_TEXT_V1); -+ changed = serialized; -+ std::free(serialized); -+ const auto shift_at = changed.find("key "); -+ const auto shift_end = changed.find(';', shift_at); -+ check(shift_at != std::string::npos && shift_end != std::string::npos, "shift fixture unavailable"); -+ changed.replace(shift_at, shift_end - shift_at + 1, -+ "key { symbols[Group1]=[Delete], actions[Group1]=[SetMods(modifiers=Shift)] };"); -+ Map badshift{xkb_keymap_new_from_string(context.get(), changed.c_str(), XKB_KEYMAP_FORMAT_TEXT_V1, XKB_KEYMAP_COMPILE_NO_FLAGS), xkb_keymap_unref}; -+ check(bool(badshift), "shift fixture failed to compile"); -+ check(!foreground_chord_compatible(badshift.get(), agent.get(), 30, 1, {}), "modifier press symbols not checked"); -+ for (auto route : {InputRoute::independent, InputRoute::primary_foreground}) { -+ for (uint64_t cap : {1, 4, 8, 16}) check(keyboard_layout_ready(route, cap, false, false), "pointer gated on layout"); -+ check(!keyboard_layout_ready(route, 2, false, false), "key accepted without map"); -+ } -+ check(keyboard_layout_ready(InputRoute::independent, 2, true, false), "background depends on human map"); -+ check(!keyboard_layout_ready(InputRoute::primary_foreground, 2, true, false), "foreground uses agent readiness"); -+ std::cout << "keyboard layout tests passed\n"; -+} -diff --git a/tests/plugin_input_lifetime_test.cpp b/tests/plugin_input_lifetime_test.cpp -index ff09451..456da44 100644 ---- a/tests/plugin_input_lifetime_test.cpp -+++ b/tests/plugin_input_lifetime_test.cpp -@@ -51,12 +51,17 @@ int main() { - check(setenv("XDG_RUNTIME_DIR", directory, 1) == 0 && - setenv("HYPRLAND_INSTANCE_SIGNATURE", "mock", 1) == 0, - "select runtime"); -- Config::Values::configured_bool_override = true; -+ Config::Values::configured_bool_override = false; - static_cast(pluginInit(nullptr)); - const auto toggle = [](bool enabled) { - HyprlandAPI::registered_bool->set_mock_value(enabled); - Event::bus()->m_events.config.reloaded.emit(); - }; -+#ifdef CUA_HYPRLAND_INPUT -+ const auto initial_status = HyprlandAPI::registered_legacy_command->fn(FORMAT_JSON, {}); -+ check(created == 0 && initial_status.find("\"configured\":false") != std::string::npos && initial_status.find("\"keyboard_layout_independent\":true") != std::string::npos && initial_status.find("\"foreground_numlock_compatible\":true") != std::string::npos, -+ "disabled production module advertises compiled keyboard support before enable"); -+#endif - for (unsigned i = 0; i < 20; ++i) { - toggle(true); - #ifdef CUA_HYPRLAND_INPUT -@@ -64,6 +69,8 @@ int main() { - check(status.find("\"state\":\"input_v3_candidate\"") != std::string::npos && - status.find("trusted_local_per_action") != std::string::npos && - status.find("\"input\":{}") != std::string::npos && -+ status.find("\"keyboard_layout_independent\":true") != std::string::npos && -+ status.find("\"foreground_numlock_compatible\":true") != std::string::npos && - status.find("operator") == std::string::npos, - "v3 status advertises its actual admission mode"); - #endif