diff --git a/pkgbuilds/cua-hyprland-plugin/DOWNSTREAM-PROVENANCE.json b/pkgbuilds/cua-hyprland-plugin/DOWNSTREAM-PROVENANCE.json new file mode 100644 index 00000000..7cf8f238 --- /dev/null +++ b/pkgbuilds/cua-hyprland-plugin/DOWNSTREAM-PROVENANCE.json @@ -0,0 +1,57 @@ +{ + "files": { + "CMakeLists.txt": "7e874a595e1abd708cb0626bd9f0f58d79b4b4bbc6d99b45a0cbe1c5c53b43bc", + "LICENSE.md": "c0779290c1d4783169aa3dbfb55feb505e563ef8a004bbf55298ceffcfbda8d9", + "SOURCE-PROVENANCE.json": "54f514664c84e1358a435f29cd6befd5661b0b133d76997191c000b10f021a75", + "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/inject_server.cpp": "0935283580c50fcf0e4ad956f858885700536002b1d86d2f078da9c4404ee9e8", + "src/inject_server.hpp": "67de008b4d6983371207bb22a57bab154b1dedda9b38d1207d3ac8cb379382eb", + "src/input_client_deadline.hpp": "00a91a789ff698820607449ff7152e2fb50d0f315e0fa9c5c3855752bfffe2ef", + "src/input_experiment.cpp": "7017748c782b64b0bc1d257f4ade1a8d46fd19ce940ecc21f22d628614fcef37", + "src/input_experiment.hpp": "9da2ddab7f0de6e9cf02aea53e9119acef17ef8513af849bb5aa3d137ebd12c3", + "src/input_grant.hpp": "90b544b2f559bacd920b85ab5915f09ff201de3c05a052ce1b71ff71f767e6a6", + "src/keyboard_layout.hpp": "bc2ec039ac1974caebbb66fe4acb7a2a81832c9054b3aa644a9adcc8954a2467", + "src/owned_socket_path.hpp": "8e784656d944c700f3ded383c93a8cadf846dabdaa4cc12673bcb637c26d9fd1", + "src/passive_pointer_target.hpp": "ede36fd9fd6e95ae5923c751f12591084392be9d2270eb06ad64eb4f245169fa", + "src/plugin.cpp": "712fd73ef8e9046e0fd91531b7bf5da50ce37ccca9df174160137e1924a74b09", + "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/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/drag_geometry_test.cpp": "d32ea649d008fc051fe18555d6fbc54ba5d057b61880b648c5df8aa076a53fbc", + "tests/foreground_modifiers_test.py": "abf0ddde2d51c6639c8bdca8fdda51cdc8f922b57575a00fc8925c683ccc3bb2", + "tests/foreground_route_test.cpp": "1024168828b13ee6abd8242941e73c042e9381b39108e3ada74823039c7e7932", + "tests/input_client_deadline_test.cpp": "d62373a7815d531f1269c9a838773595f43e8bcef6482fa140edb162e59a6cac", + "tests/input_grant_test.cpp": "1f327b7ee678189ebad6a50bb1b9bd06767521cebc9cfb478d92de4a8bf7e7fe", + "tests/keyboard_layout_test.cpp": "3bb0fade4675d7ad92a81eb4a1c5201dd1d01bcc418b7ea59a4284dc235e5fb2", + "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/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", + "schema": 1, + "upstream_manifest_sha256": "54f514664c84e1358a435f29cd6befd5661b0b133d76997191c000b10f021a75", + "upstream_revision": "cc54254464c0c9aebfd6547fe7e4a0ceaf0456d7" +} diff --git a/pkgbuilds/cua-hyprland-plugin/PKGBUILD b/pkgbuilds/cua-hyprland-plugin/PKGBUILD index cfcf4724..9fba2d21 100644 --- a/pkgbuilds/cua-hyprland-plugin/PKGBUILD +++ b/pkgbuilds/cua-hyprland-plugin/PKGBUILD @@ -1,21 +1,21 @@ -# Verified upstream kit with a local package-revision profile; source/tooling are unchanged. +# Verified upstream kit plus a separately pinned Omarchy keyboard-remap 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.26.1 -pkgrel=3 -pkgdesc='Cua input candidate for reviewed profile omarchy-hyprland-0562r3' +pkgrel=5 +pkgdesc='Cua input candidate for reviewed profile omarchy-hyprland-0562r3-remaps' arch=('x86_64') url='https://github.com/trycua/cua' license=('MIT') depends=('hyprland=0.56.2-3' 'aquamarine=0.15.0-2' 'glibc=2.44+r24+g16be1518495f-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') +makedepends=('cmake>=3.30' 'ninja' 'pkgconf' 'gcc' 'patch') options=('!strip' '!debug' '!lto') _stem='cua-hyprland-plugin-0.26.1-cc54254464c0c9aebfd6547fe7e4a0ceaf0456d7' _archive_sha256='47bca9e018f32f4fcfe683f91c7475c60368f3b65d318cc35c1f2de88a4ee9ab' -_kit_sha256='1391084a903254fb16d251522dba41ae34a06cfa4b2f6b7ba91a3cceeb895921' -_profile_sha256='eebba812a7513b471cf8969364334a4bde1eaa9c4519157701c771c1b6cd1b07' +_kit_sha256='089f447e11cacd8c2d3d6cd56528776417c51d353b9677c47c42bba1ef79c9f9' +_profile_sha256='fc3034649af98f7f81178ef575660143c249664b9ff5d0e1ccaf79d7580df17b' _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' @@ -24,7 +24,17 @@ source=('https://github.com/trycua/cua/releases/download/cua-hyprland-kit-v1.1.0 'PROFILE.json') noextract=("$_download_name") sha256sums=('a89bfa7f6490f598719dfabdd2a5badc137e27b0764fd25e6a05e1f969433520' - 'eebba812a7513b471cf8969364334a4bde1eaa9c4519157701c771c1b6cd1b07') + 'fc3034649af98f7f81178ef575660143c249664b9ff5d0e1ccaf79d7580df17b') + +# Downstream inputs are also checked explicitly when makepkg integrity is skipped. +declare -gA _downstream_sha256=( + ['independent-keymaps.patch']='e74d9021111a5c217b12f3e13c1bbdd260e829c60db5fc2f7293ba4846e1a7d7' + ['DOWNSTREAM-PROVENANCE.json']='e0c95350ec3ff2dd54a05e9377d79ddffffdb1cf494d05553a90207a3a919f7e' + ['downstream.py']='7c9725805ad038737bd4d346c85f27991ce9ddfff436a32ebf515cad1bf98ca1' + ['downstream_test.py']='7b9a992979b22a5e061fdf011ac6be59a3f1ae6e0d7c0a8ff631f74e71984c5a' +) +source+=('independent-keymaps.patch' 'DOWNSTREAM-PROVENANCE.json' 'downstream.py' 'downstream_test.py') +sha256sums+=('e74d9021111a5c217b12f3e13c1bbdd260e829c60db5fc2f7293ba4846e1a7d7' 'e0c95350ec3ff2dd54a05e9377d79ddffffdb1cf494d05553a90207a3a919f7e' '7c9725805ad038737bd4d346c85f27991ce9ddfff436a32ebf515cad1bf98ca1' '7b9a992979b22a5e061fdf011ac6be59a3f1ae6e0d7c0a8ff631f74e71984c5a') _verify_download() { python3 -I - "$SRCDEST/$_download_name" "$_download_sha256" "$srcdir" "$1" "$SRCDEST/PROFILE.json" <<'CUA_DOWNLOAD_PY' @@ -65,10 +75,10 @@ require(payload.keys() == expected.keys(), 'outer kit inventory mismatch') # files as -2. Derive a version-only profile with the original source/tooling # and byte checks intact; record its own profile and kit provenance digests. profile_data = Path(profile_path).read_bytes() -require(digest(profile_data) == 'eebba812a7513b471cf8969364334a4bde1eaa9c4519157701c771c1b6cd1b07', +require(digest(profile_data) == 'fc3034649af98f7f81178ef575660143c249664b9ff5d0e1ccaf79d7580df17b', 'local profile checksum mismatch') profile = json.loads(payload['PROFILE.json']) -profile.update(profile_id='omarchy-hyprland-0562r3', package_release=3) +profile.update(profile_id='omarchy-hyprland-0562r3-remaps', package_release=5) profile['hyprland']['package_version'] = '0.56.2-3' require(json.loads(profile_data) == profile, 'local profile changes more than the reviewed package revision') payload['PROFILE.json'] = profile_data @@ -76,7 +86,7 @@ provenance = json.loads(payload['KIT-PROVENANCE.json']) provenance['profile_sha256'] = digest(profile_data) payload['KIT-PROVENANCE.json'] = (json.dumps(provenance, sort_keys=True, indent=2) + '\n').encode() recipe = payload['PKGBUILD'].decode() -for old, new in [('pkgrel=2\n', 'pkgrel=3\n'), ('omarchy-stable-20260910', profile['profile_id']), +for old, new in [('pkgrel=2\n', 'pkgrel=5\n'), ('omarchy-stable-20260910', profile['profile_id']), ('hyprland=0.56.2-2', 'hyprland=0.56.2-3'), ('5dacea79a6a5927e59fca7b51e6c04e94fa45133ac1b8fb7f4e1f442d113eb9a', digest(profile_data)), ('7beb736adfd334eed52e84070177634269e3a09f8bb25971b38606933ff4c997', digest(payload['KIT-PROVENANCE.json']))]: @@ -84,10 +94,10 @@ for old, new in [('pkgrel=2\n', 'pkgrel=3\n'), ('omarchy-stable-20260910', profi payload['PKGBUILD'] = recipe.encode() payload['SHA256SUMS'] = ''.join(f'{digest(body)} {name}\n' for name, body in sorted(payload.items()) if name != 'SHA256SUMS').encode() -expected.update({'PROFILE.json': 'eebba812a7513b471cf8969364334a4bde1eaa9c4519157701c771c1b6cd1b07', - 'KIT-PROVENANCE.json': '1391084a903254fb16d251522dba41ae34a06cfa4b2f6b7ba91a3cceeb895921', - 'PKGBUILD': '4c5ef50c5a8d556d461afe283b13b228fb09d2558b37fb76257e13e25846325b', - 'SHA256SUMS': '2ea22af60f8c86df588fd7db103d9f444d9ac4018b704a48ddff2545ff7ce999'}) +expected.update({'PROFILE.json': 'fc3034649af98f7f81178ef575660143c249664b9ff5d0e1ccaf79d7580df17b', + 'KIT-PROVENANCE.json': '089f447e11cacd8c2d3d6cd56528776417c51d353b9677c47c42bba1ef79c9f9', + 'PKGBUILD': '0cbf2cd34c3c5038a5ed51e6bf84acd81844e4c2bb08ad7203959201bba61da9', + 'SHA256SUMS': 'd01b9e0be4c5bcf84cc2ecef9f11f44cedfc1ca5b31afbfcf52aa2efbd636a09'}) for name, content in payload.items(): require(digest(content) == expected[name], 'derived kit checksum mismatch: ' + name) require(srcdir.is_dir() and not srcdir.is_symlink(), 'srcdir must be a real directory') @@ -132,17 +142,31 @@ _verify() { printf '%s %s\n' "$_profile_sha256" "$srcdir/cua-profile-kit/PROFILE.json" | sha256sum -c - || return 1 printf '%s %s\n' "$_verifier_sha256" "$srcdir/cua-profile-kit/profile_verify.py" | sha256sum -c - || return 1 python3 "$srcdir/cua-profile-kit/profile_verify.py" --kit "$srcdir/cua-profile-kit" --kit-sha256 "$_kit_sha256" \ - --archive "$srcdir/cua-profile-kit/${_stem}.tar.gz" --source "$srcdir/$_stem" --cxx "$_cxx" "$@" + --archive "$srcdir/cua-profile-kit/${_stem}.tar.gz" --source "$srcdir/$_stem" --cxx "$_cxx" +} + +_downstream() { + local name + for name in independent-keymaps.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" \ + --kit "$srcdir/cua-profile-kit" --kit-sha256 "$_kit_sha256" \ + --archive "$srcdir/cua-profile-kit/${_stem}.tar.gz" --cxx "$_cxx" "${@:2}" } prepare() { _verify_download extract || return 1 - _verify + _verify || return 1 + _downstream prepare } build() { _verify || return 1 - cmake -S "$srcdir/$_stem" -B "$srcdir/build" -G Ninja \ + _downstream check || return 1 + cmake -S "$srcdir/omarchy-source" -B "$srcdir/build" -G Ninja \ -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER="$_cxx" \ -DPKG_CONFIG_EXECUTABLE=/usr/bin/pkgconf -DPKG_CONFIG_ARGN= \ -DPKG_CONFIG_USE_CMAKE_PREFIX_PATH=OFF -DCMAKE_PREFIX_PATH= \ @@ -155,6 +179,8 @@ build() { check() { _verify || return 1 + _downstream check || return 1 + python3 -B "$SRCDEST/downstream_test.py" || return 1 ( unset LD_PRELOAD FAKEROOTKEY FAKED_MODE ctest --test-dir "$srcdir/build" --output-on-failure --no-tests=error @@ -163,7 +189,7 @@ check() { package() { check || return 1 - _verify --build "$srcdir/build" --output "$srcdir/BUILD-PROVENANCE.json" || return 1 + _downstream build --build "$srcdir/build" --output "$srcdir/BUILD-PROVENANCE.json" || return 1 install -Dm755 "$srcdir/build/cua-hyprland-plugin.so" \ "$pkgdir/usr/lib/cua/hyprland/cua-hyprland-plugin.so" || return 1 install -Dm644 "$srcdir/$_stem/LICENSE.md" \ @@ -175,4 +201,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 + 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 ec78d11e..fe5eff22 100644 --- a/pkgbuilds/cua-hyprland-plugin/PROFILE.json +++ b/pkgbuilds/cua-hyprland-plugin/PROFILE.json @@ -12,8 +12,8 @@ "sha256": "da8fcacf347bcbed83edc40108c6e2298da095e22246bd764e9bb382786cebb2" }, "kit_version": "1.1.0", - "package_release": 3, - "profile_id": "omarchy-hyprland-0562r3", + "package_release": 5, + "profile_id": "omarchy-hyprland-0562r3-remaps", "runtime": { "basename": "libstdc++.so.6.0.36", "packages": { diff --git a/pkgbuilds/cua-hyprland-plugin/README.md b/pkgbuilds/cua-hyprland-plugin/README.md index 15820ef3..61946b48 100644 --- a/pkgbuilds/cua-hyprland-plugin/README.md +++ b/pkgbuilds/cua-hyprland-plugin/README.md @@ -1,6 +1,6 @@ # Optional Cua Hyprland plugin -This package targets **Omarchy stable x86_64**, with Inkscape `1.4.4-6` and two independent background-input lanes. 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 stable x86_64**, with Inkscape `1.4.4-6` and two independent background-input lanes. Package release `5` 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. 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; only stable x86_64 is a qualified target. @@ -10,9 +10,9 @@ The package uses the [Driver 0.26.1 plugin source](https://github.com/trycua/cua including the [desktop-fault cleanup repair](https://github.com/trycua/cua/pull/3702). It is not a repackaging of the unmodified 0.24.0 plugin. -The qualified 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 this pinned module; its production plugin source is unchanged from the source used here. Discovery protocol v2 is separate. A newer Driver release 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; its production plugin source is the base for the downstream patch used here. Discovery protocol v2 is separate. A newer Driver release is a changed pairing and requires affected replay before promotion. -Profile `omarchy-hyprland-0562r3`, kit tooling `1.1.0`, and package release `3` pin: +Profile `omarchy-hyprland-0562r3-remaps`, kit tooling `1.1.0`, and package release `5` pin: - Hyprland `0.56.2-3`, headers `0.56.2`, and measured executable/header hashes. - GCC `16.2.1 20260810`, including compiler bytes and emitted ELF identity. @@ -24,14 +24,13 @@ 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, -and exact Hyprland package version. Compiler, runtime, source, executable, -and header identities remain unchanged. The download wrapper verifies the +and exact Hyprland package version. Compiler, runtime, upstream source, executable, +and header identities remain unchanged; the separately recorded patch changes the build source. The download wrapper verifies the original kit before deriving the updated profile, recipe, and provenance, then verifies every derived member against its recorded digest. The native qualification below was recorded with package release `2` and -Hyprland `-2`. This packaging update does not claim a new application or -Driver replay. The `-3` dependency must reach a destination channel before +Hyprland `-2`. The downstream keymap change needs its own application and Driver replay before promotion. The `-3` dependency must 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, @@ -47,7 +46,19 @@ 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. -## What is qualified +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. + +## 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. + +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. + +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. + +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. + +## Historical upstream qualification The initial app scope is native Wayland Inkscape `1.4.4-6` with the canonical US keymap. Two lanes require independent Driver processes and distinct native @@ -93,7 +104,7 @@ background refusal never authorizes a hidden foreground fallback or unlock. Build the unsigned candidate in edge: ```sh -bin/repo build --package cua-hyprland-plugin --arch x86_64 --mirror edge +./bin/build --package cua-hyprland-plugin --arch x86_64 --mirror edge ``` In a fresh worker matching the reviewed profile: @@ -131,7 +142,7 @@ kit-provenance digest: ```sh python3 /usr/share/cua-hyprland-plugin/profile_verify.py \ --kit /usr/share/cua-hyprland-plugin \ - --kit-sha256 1391084a903254fb16d251522dba41ae34a06cfa4b2f6b7ba91a3cceeb895921 \ + --kit-sha256 089f447e11cacd8c2d3d6cd56528776417c51d353b9677c47c42bba1ef79c9f9 \ --consumer /usr/lib/cua/hyprland/cua-hyprland-plugin.so ``` @@ -146,98 +157,28 @@ hyprctl plugin load /usr/lib/cua/hyprland/cua-hyprland-plugin.so hyprctl -j cua:status ``` -Loading alone does not enable input. Before opting in, save open work and save -the exact current personal input configuration. The backup command refuses a -symlinked input file and refuses to replace an earlier backup: +Loading alone does not enable input. Use Omarchy's explicit Cua Input toggle when available; it verifies the installed profile and loaded capability and removes the legacy copied toggle's keyboard override. If it reports an older mapped plugin, disable Cua Input and log out and back in before enabling it again. Never hot-unload and reload the module. -```sh -test -f "$HOME/.config/hypr/input.lua" && \ - test ! -L "$HOME/.config/hypr/input.lua" && \ - test ! -e "$HOME/.config/hypr/input.lua.cua-before" && \ - cp --archive -- "$HOME/.config/hypr/input.lua" \ - "$HOME/.config/hypr/input.lua.cua-before" -``` - -If `input.lua` is a symlink, stop here: back up and later restore its resolved -target explicitly instead of using the commands below. - -The background-input admission guard requires the exact XKB keymap -`rules=evdev`, `model=pc105`, `layout=us`, empty variant and options, and no -custom keymap file. Stock Omarchy 4.0.3 English (US) is not that literal -configuration: it leaves rules and model empty and sets -`compose:caps,shift:both_capslock_cancel`. Those options make Caps Lock the -Compose key and both Shift keys the Caps Lock/cancel chord. The required empty -options restore ordinary Caps Lock behavior and remove both stock shortcuts -while Cua input is enabled. Any other effective value is intentionally refused -as `unsupported_layout`; do not weaken or bypass that admission guard. - -Append this override to `~/.config/hypr/input.lua` so it follows any existing -input settings. It both selects the exact admitted keymap and enables the -trusted local transport: +For manual activation, add only this plugin setting to a sourced Hyprland Lua configuration file, preserving all existing input settings: ```lua hl.config({ - input = { - kb_rules = "evdev", - kb_model = "pc105", - kb_layout = "us", - kb_variant = "", - kb_options = "", - kb_file = "", - }, plugin = { cua = { enabled = true } }, }) ``` -Reload, then read back every keymap value rather than relying on the source -file alone: +Then reload and inspect status: ```sh hyprctl reload -for name in kb_rules kb_model kb_layout kb_variant kb_options kb_file; do - value=$(hyprctl -j getoption "input:$name" | jq -r '.str') - printf '%s=%s\n' "$name" "$value" -done -hyprctl -j cua:status -``` - -The keymap readback must be exactly: - -```text -kb_rules=evdev -kb_model=pc105 -kb_layout=us -kb_variant= -kb_options= -kb_file= -``` - -A runtime keyword or Lua evaluation without `hyprctl reload` does not reconcile -the input sockets. Continue only when status also reports input protocol v3, -input capability, socket paths, and the expected compositor identity. Do not -disable NumLock; that was required only by a strict qualification observer, not -by the demonstrated background-input contract. - -Start Driver with `CUA_DRIVER_RS_ENABLE_WAYLAND=1`. For the activation check, -use background input in a new disposable Inkscape document to create a text -object containing `CUA activation check`. Save it under a new temporary -filename, then verify the text in both a fresh Driver snapshot and the reopened -saved SVG. Never test against an existing document, and do not automatically -replay an action with a partial or unknown outcome. - -After the check, restore the exact saved configuration and reload it: - -```sh -command mv --force -- "$HOME/.config/hypr/input.lua.cua-before" \ - "$HOME/.config/hypr/input.lua" -hyprctl reload hyprctl -j cua:status ``` -Confirm that the prior keymap values are back and status reports input disabled. -Retained inert agent pointers can remain until the compositor exits; disabling -input does not unload the mapped module. If you intentionally keep activation, -retain the backup until you are ready to perform this exact restoration. +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. + +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. + +To disable input, turn the Cua Input toggle off, or remove the manual `plugin.cua.enabled` setting and reload. Confirm that status reports input disabled. Retained inert agent pointers can remain until the compositor exits; disabling input does not unload the mapped module. Before an incompatible desktop update, remove operator-added plugin activation settings, save work, and exit the graphical session. From a text console, run diff --git a/pkgbuilds/cua-hyprland-plugin/downstream.py b/pkgbuilds/cua-hyprland-plugin/downstream.py new file mode 100644 index 00000000..429b7f03 --- /dev/null +++ b/pkgbuilds/cua-hyprland-plugin/downstream.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Verify the Omarchy patch separately from the unchanged upstream source kit.""" + +import argparse +import hashlib +import importlib.util +import json +from pathlib import Path, PurePosixPath +import shutil +import subprocess + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def inventory(root): + require(root.is_dir() and not root.is_symlink(), "source must be a real directory") + result = {} + for path in root.rglob("*"): + require(not path.is_symlink() and (path.is_dir() or path.is_file()), "nonregular source entry") + if path.is_file(): + result[path.relative_to(root).as_posix()] = digest(path) + return result + + +def verify_inputs(pristine, patch, manifest): + require(manifest["schema"] == 1, "unsupported downstream schema") + require(patch.is_file() and not patch.is_symlink() and digest(patch) == manifest["patch_sha256"], + "downstream patch checksum mismatch") + require(digest(pristine / "SOURCE-PROVENANCE.json") == manifest["upstream_manifest_sha256"], + "downstream base manifest mismatch") + require(manifest["files"], "empty downstream inventory") + for name in manifest["files"]: + path = PurePosixPath(name) + require(name and not path.is_absolute() and path.as_posix() == name and + ".." not in path.parts and "\\" not in name, "invalid downstream path") + + +def verify_tree(source, manifest): + require(inventory(source) == manifest["files"], "patched source inventory/checksum mismatch") + + +def prepare(pristine, source, patch, manifest): + require(not source.exists() and not source.is_symlink(), "patched source requires a fresh destination") + shutil.copytree(pristine, source) + subprocess.run(["patch", "--batch", "--fuzz=0", "-p1", "-i", str(patch.resolve())], cwd=source, check=True) + verify_tree(source, manifest) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("mode", choices=("prepare", "check", "build")) + parser.add_argument("--pristine", required=True, type=Path) + parser.add_argument("--source", required=True, type=Path) + parser.add_argument("--patch", required=True, type=Path) + parser.add_argument("--manifest", required=True, type=Path) + parser.add_argument("--kit", required=True, type=Path) + parser.add_argument("--kit-sha256", required=True) + parser.add_argument("--archive", required=True, type=Path) + parser.add_argument("--cxx", required=True, type=Path) + parser.add_argument("--build", type=Path) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + try: + # PKGBUILD authenticates the verifier and this helper before execution. + spec = importlib.util.spec_from_file_location("upstream_profile", args.kit / "profile_verify.py") + upstream = importlib.util.module_from_spec(spec) + spec.loader.exec_module(upstream) + profile, kit = upstream.verify_kit(args.kit, args.kit_sha256) + base = upstream.verify_archive(args.archive, profile) + require(upstream.verify_source(args.pristine, profile) == base, "upstream source identity mismatch") + manifest = upstream.read_json(args.manifest.read_bytes()) + verify_inputs(args.pristine, args.patch, manifest) + if args.mode == "prepare": + prepare(args.pristine, args.source, args.patch, manifest) + else: + verify_tree(args.source, manifest) + if args.mode == "build": + require(args.build is not None and args.output is not None, "build evidence requires output") + native = upstream.verify_native(args.cxx, profile) + native["module_sha256"] = upstream.verify_build(args.build, args.source, args.cxx, profile) + native["module_runtime_sha256"] = profile["runtime"]["sha256"] + args.output.write_bytes(upstream.json_bytes(dict(native, source=base, profile=profile, + kit=kit, downstream=manifest))) + except (ValueError, KeyError, TypeError, OSError, subprocess.CalledProcessError) as error: + parser.exit(1, f"error: {error}\n") + + +if __name__ == "__main__": + main() diff --git a/pkgbuilds/cua-hyprland-plugin/downstream_test.py b/pkgbuilds/cua-hyprland-plugin/downstream_test.py new file mode 100644 index 00000000..60586d28 --- /dev/null +++ b/pkgbuilds/cua-hyprland-plugin/downstream_test.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Exercise downstream integrity with real patch application and tampering.""" + +import hashlib +from pathlib import Path +import tempfile +import unittest + +import downstream + + +class DownstreamTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.pristine = self.root / "pristine" + self.pristine.mkdir() + (self.pristine / "SOURCE-PROVENANCE.json").write_text("upstream\n") + (self.pristine / "input.cpp").write_text("old\n") + self.patch = self.root / "change.patch" + self.patch.write_text("--- a/input.cpp\n+++ b/input.cpp\n@@ -1 +1 @@\n-old\n+new\n") + self.source = self.root / "patched" + self.manifest = { + "schema": 1, + "patch_sha256": downstream.digest(self.patch), + "upstream_manifest_sha256": downstream.digest(self.pristine / "SOURCE-PROVENANCE.json"), + "files": {"SOURCE-PROVENANCE.json": downstream.digest(self.pristine / "SOURCE-PROVENANCE.json"), + "input.cpp": hashlib.sha256(b"new\n").hexdigest()}, + } + + def test_applies_patch_without_changing_upstream(self): + downstream.verify_inputs(self.pristine, self.patch, self.manifest) + downstream.prepare(self.pristine, self.source, self.patch, self.manifest) + self.assertEqual((self.pristine / "input.cpp").read_text(), "old\n") + self.assertEqual((self.source / "input.cpp").read_text(), "new\n") + + def test_changed_patch_refuses(self): + self.patch.write_text(self.patch.read_text().replace("+new", "+bad")) + with self.assertRaisesRegex(ValueError, "patch checksum"): + downstream.verify_inputs(self.pristine, self.patch, self.manifest) + + def test_changed_base_manifest_refuses(self): + (self.pristine / "SOURCE-PROVENANCE.json").write_text("different\n") + with self.assertRaisesRegex(ValueError, "base manifest"): + downstream.verify_inputs(self.pristine, self.patch, self.manifest) + + def test_tampered_missing_and_extra_files_refuse(self): + downstream.prepare(self.pristine, self.source, self.patch, self.manifest) + file = self.source / "input.cpp" + for content in ("tampered\n", None): + if content is None: + file.unlink() + else: + file.write_text(content) + with self.assertRaisesRegex(ValueError, "inventory/checksum"): + downstream.verify_tree(self.source, self.manifest) + file.write_text("new\n") + (self.source / "unexpected.cpp").write_text("extra\n") + with self.assertRaisesRegex(ValueError, "inventory/checksum"): + downstream.verify_tree(self.source, self.manifest) + + def test_symlink_and_reused_destination_refuse(self): + downstream.prepare(self.pristine, self.source, self.patch, self.manifest) + with self.assertRaisesRegex(ValueError, "fresh destination"): + downstream.prepare(self.pristine, self.source, self.patch, self.manifest) + file = self.source / "input.cpp" + file.unlink() + file.symlink_to(self.pristine / "input.cpp") + with self.assertRaisesRegex(ValueError, "nonregular"): + downstream.verify_tree(self.source, self.manifest) + + +if __name__ == "__main__": + unittest.main() diff --git a/pkgbuilds/cua-hyprland-plugin/independent-keymaps.patch b/pkgbuilds/cua-hyprland-plugin/independent-keymaps.patch new file mode 100644 index 00000000..245ba600 --- /dev/null +++ b/pkgbuilds/cua-hyprland-plugin/independent-keymaps.patch @@ -0,0 +1,1127 @@ +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