Add native self-hosted instance connection to fluxer_desktop
Trimmed monorepo checkout (fluxer_desktop + packages/voice_engine_v2 + tools/ci) with a "Connect to a Different Server" menu item and popout that lets the desktop app switch to any self-hosted Fluxer instance, plus fixes for well-known discovery on single-domain self-hosted deployments and a false-positive ERR_ABORTED on same-origin client redirects during the switch. Defaults to chat.fluxr.chat and uses an isolated userData directory from the official build.
This commit is contained in:
+552
@@ -0,0 +1,552 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
|
||||
[[package]]
|
||||
name = "bitvec"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
|
||||
dependencies = [
|
||||
"funty",
|
||||
"radium",
|
||||
"tap",
|
||||
"wyz",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "ctor"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01334b89b69ff726750c5ce5073fc8bd860e99aa9a8fc5ca11b04730e3aee97a"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "evdev"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25b686663ba7f08d92880ff6ba22170f1df4e83629341cba34cf82cd65ebea99"
|
||||
dependencies = [
|
||||
"bitvec",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"nix 0.29.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluxer_linux_evdev"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"evdev",
|
||||
"napi",
|
||||
"napi-build",
|
||||
"napi-derive",
|
||||
"nix 0.31.3",
|
||||
"polling",
|
||||
"udev",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "funty"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "io-lifetimes"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2"
|
||||
dependencies = [
|
||||
"hermit-abi 0.3.9",
|
||||
"libc",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libudev-sys"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||
|
||||
[[package]]
|
||||
name = "napi"
|
||||
version = "3.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad513ff22558f1830b595ea6eb4091da48145d09a222ce157e781896f78be0b9"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"ctor",
|
||||
"futures",
|
||||
"napi-build",
|
||||
"napi-sys",
|
||||
"nohash-hasher",
|
||||
"rustc-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-build"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1"
|
||||
|
||||
[[package]]
|
||||
name = "napi-derive"
|
||||
version = "3.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89b3f766e04667e6da0e181e2da4f85475d5a6513b7cf6a80bea184e224a5b42"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"ctor",
|
||||
"napi-derive-backend",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-derive-backend"
|
||||
version = "5.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d5af30503edf933ce7377cf6d4c877a62b0f1107ea05585f1b5e430e88d5baf"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"semver",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-sys"
|
||||
version = "3.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f5bcdf71abd3a50d00b49c1c2c75251cb3c913777d6139cd37dabc093a5e400"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nohash-hasher"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"hermit-abi 0.5.2",
|
||||
"pin-project-lite",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "radium"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tap"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "udev"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af4e37e9ea4401fc841ff54b9ddfc9be1079b1e89434c1a6a865dd68980f7e9f"
|
||||
dependencies = [
|
||||
"io-lifetimes",
|
||||
"libc",
|
||||
"libudev-sys",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "wyz"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
|
||||
dependencies = [
|
||||
"tap",
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "fluxer_linux_evdev"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
napi = {version = "3.9.1", default-features = false, features = ["dyn-symbols", "napi8"]}
|
||||
napi-derive = "3.5.6"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
evdev = "0.13.2"
|
||||
nix = {version = "0.31.3", default-features = false, features = ["event"]}
|
||||
udev = "0.9.3"
|
||||
polling = "3.11.0"
|
||||
|
||||
[build-dependencies]
|
||||
napi-build = "2.3.2"
|
||||
@@ -0,0 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
fn main() {
|
||||
napi_build::setup();
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export interface EvdevKeyEvent {
|
||||
type: 'keydown' | 'keyup';
|
||||
keycode: number;
|
||||
keyName: string;
|
||||
ctrlKey: boolean;
|
||||
altKey: boolean;
|
||||
shiftKey: boolean;
|
||||
metaKey: boolean;
|
||||
}
|
||||
|
||||
export interface EvdevMouseEvent {
|
||||
type: 'mousedown' | 'mouseup';
|
||||
button: number;
|
||||
ctrlKey: boolean;
|
||||
altKey: boolean;
|
||||
shiftKey: boolean;
|
||||
metaKey: boolean;
|
||||
}
|
||||
|
||||
export type NativeEvdevEvent = EvdevKeyEvent | EvdevMouseEvent;
|
||||
|
||||
export declare class EvdevHook {
|
||||
constructor(onEvent: (event: NativeEvdevEvent) => void);
|
||||
|
||||
start(): boolean;
|
||||
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export declare function nameToEvdevKeycode(name: string): number;
|
||||
|
||||
export declare const loadError: Error | null;
|
||||
@@ -0,0 +1,64 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const {existsSync} = require('node:fs');
|
||||
const {join, sep} = require('node:path');
|
||||
const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs');
|
||||
const MODULE_NAME = '@fluxer/linux-evdev';
|
||||
const SKIP_NATIVE_PROBE_ENV = 'FLUXER_LINUX_EVDEV_SKIP_NATIVE_PROBE';
|
||||
|
||||
function resolveNativeRoot() {
|
||||
const asarSegment = `${sep}app.asar${sep}`;
|
||||
if (!__dirname.includes(asarSegment)) return __dirname;
|
||||
const unpackedDir = __dirname.replace(asarSegment, `${sep}app.asar.unpacked${sep}`);
|
||||
return existsSync(unpackedDir) ? unpackedDir : __dirname;
|
||||
}
|
||||
|
||||
function nativeFileName() {
|
||||
if (process.platform !== 'linux') {
|
||||
throw new Error(`@fluxer/linux-evdev is only supported on Linux, got ${process.platform}`);
|
||||
}
|
||||
switch (process.arch) {
|
||||
case 'x64':
|
||||
return 'linux-evdev.linux-x64-gnu.node';
|
||||
case 'arm64':
|
||||
return 'linux-evdev.linux-arm64-gnu.node';
|
||||
default:
|
||||
throw new Error(`Unsupported Linux architecture: ${process.arch}`);
|
||||
}
|
||||
}
|
||||
|
||||
let binding = null;
|
||||
let loadError = null;
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
try {
|
||||
const nativeRoot = resolveNativeRoot();
|
||||
const nativePath = join(nativeRoot, nativeFileName());
|
||||
const loaded = loadNativeBinding({
|
||||
moduleName: MODULE_NAME,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir: __dirname,
|
||||
skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV,
|
||||
});
|
||||
binding = loaded.binding;
|
||||
loadError = loaded.loadError;
|
||||
if (loadError) throw loadError;
|
||||
} catch (error) {
|
||||
loadError = createNativeLoadError({
|
||||
moduleName: MODULE_NAME,
|
||||
nativeRoot: resolveNativeRoot(),
|
||||
packageDir: __dirname,
|
||||
reason: 'native loader threw before binding load completed',
|
||||
cause: error,
|
||||
skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV,
|
||||
});
|
||||
throw loadError;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
EvdevHook: binding ? binding.EvdevHook : null,
|
||||
nameToEvdevKeycode: binding ? binding.nameToEvdevKeycode : null,
|
||||
loadError,
|
||||
};
|
||||
@@ -0,0 +1,524 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const {existsSync, readdirSync, readFileSync, statSync} = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const {basename} = require('node:path');
|
||||
const {spawnSync} = require('node:child_process');
|
||||
const NATIVE_LOAD_ERROR_MARKER = Symbol.for('fluxer.nativeLoadError');
|
||||
const MAX_TEXT_LENGTH = 6000;
|
||||
const MAX_DIRECTORY_ENTRIES = 80;
|
||||
|
||||
function trimText(value, limit = MAX_TEXT_LENGTH) {
|
||||
const text = Buffer.isBuffer(value) ? value.toString('utf8') : String(value ?? '');
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed.length > limit ? `${trimmed.slice(0, limit)}\n...<truncated>` : trimmed;
|
||||
}
|
||||
|
||||
function errorDiagnostic(error) {
|
||||
if (!error) return null;
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: error.name || 'Error',
|
||||
message: error.message,
|
||||
code: error.code || null,
|
||||
stack: trimText(error.stack || error.message),
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: typeof error,
|
||||
message: trimText(String(error)),
|
||||
code: null,
|
||||
stack: null,
|
||||
};
|
||||
}
|
||||
|
||||
function formatErrorDiagnostic(diagnostic) {
|
||||
if (!diagnostic) return null;
|
||||
const lines = [];
|
||||
if (diagnostic.code) lines.push(`code=${diagnostic.code}`);
|
||||
if (diagnostic.stack) lines.push(diagnostic.stack);
|
||||
else if (diagnostic.message) lines.push(diagnostic.message);
|
||||
return trimText(lines.join('\n'));
|
||||
}
|
||||
|
||||
function fileDiagnostic(filePath) {
|
||||
if (!filePath) return {path: null, exists: false, error: 'not resolved'};
|
||||
try {
|
||||
const stat = statSync(filePath);
|
||||
return {
|
||||
path: filePath,
|
||||
exists: true,
|
||||
size: stat.size,
|
||||
mode: `0${(stat.mode & 0o777).toString(8)}`,
|
||||
mtime: stat.mtime.toISOString(),
|
||||
isFile: stat.isFile(),
|
||||
isDirectory: stat.isDirectory(),
|
||||
};
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
return {path: filePath, exists: false, error: reason};
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileDiagnostic(diagnostic) {
|
||||
if (!diagnostic) return 'not resolved';
|
||||
if (!diagnostic.exists) return `exists=false, statError=${diagnostic.error || '<unknown>'}`;
|
||||
return [
|
||||
`exists=true`,
|
||||
`size=${diagnostic.size}`,
|
||||
`mode=${diagnostic.mode}`,
|
||||
`mtime=${diagnostic.mtime}`,
|
||||
`isFile=${diagnostic.isFile}`,
|
||||
].join(', ');
|
||||
}
|
||||
|
||||
function directoryDiagnostic(dirPath) {
|
||||
if (!dirPath) return {path: null, ok: false, error: 'not resolved', entries: [], total: 0, omitted: 0};
|
||||
try {
|
||||
const entries = readdirSync(dirPath, {withFileTypes: true}).map((entry) => ({
|
||||
name: entry.name,
|
||||
type: entry.isDirectory() ? 'directory' : entry.isFile() ? 'file' : 'other',
|
||||
}));
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const visible = entries.slice(0, MAX_DIRECTORY_ENTRIES);
|
||||
return {
|
||||
path: dirPath,
|
||||
ok: true,
|
||||
entries: visible,
|
||||
total: entries.length,
|
||||
omitted: Math.max(0, entries.length - visible.length),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
path: dirPath,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
entries: [],
|
||||
total: 0,
|
||||
omitted: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function formatDirectoryDiagnostic(diagnostic) {
|
||||
if (!diagnostic) return '<unavailable>';
|
||||
if (!diagnostic.ok) return `directory listing failed: ${diagnostic.error || '<unknown>'}`;
|
||||
const entries = diagnostic.entries.map((entry) => `${entry.name}${entry.type === 'directory' ? '/' : ''}`);
|
||||
const suffix = diagnostic.omitted > 0 ? [`...<${diagnostic.omitted} more entries>`] : [];
|
||||
return [...entries, ...suffix].join('\n') || '<empty>';
|
||||
}
|
||||
|
||||
function selectedEnvironmentNames(skipNativeProbeEnv) {
|
||||
const names = [
|
||||
'ELECTRON_RUN_AS_NODE',
|
||||
'FLUXER_NATIVE_MODULE_PREFLIGHT_CHILD',
|
||||
'LD_LIBRARY_PATH',
|
||||
'DYLD_LIBRARY_PATH',
|
||||
'DISPLAY',
|
||||
'WAYLAND_DISPLAY',
|
||||
'XDG_CURRENT_DESKTOP',
|
||||
'XDG_SESSION_TYPE',
|
||||
'DBUS_SESSION_BUS_ADDRESS',
|
||||
'PULSE_SERVER',
|
||||
'PIPEWIRE_REMOTE',
|
||||
'PATH',
|
||||
];
|
||||
if (skipNativeProbeEnv) names.push(skipNativeProbeEnv);
|
||||
return names;
|
||||
}
|
||||
|
||||
function environmentDiagnostics(skipNativeProbeEnv) {
|
||||
return Object.fromEntries(
|
||||
selectedEnvironmentNames(skipNativeProbeEnv).map((name) => [name, process.env[name] ?? null]),
|
||||
);
|
||||
}
|
||||
|
||||
function formatEnvironment(diagnostic) {
|
||||
return Object.entries(diagnostic)
|
||||
.map(([name, value]) => `${name}=${value ?? '<unset>'}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function runtimeDiagnostics() {
|
||||
const versions = process.versions || {};
|
||||
let reportHeader = null;
|
||||
if (process.report && typeof process.report.getReport === 'function') {
|
||||
try {
|
||||
reportHeader = process.report.getReport().header || null;
|
||||
} catch {
|
||||
reportHeader = null;
|
||||
}
|
||||
}
|
||||
const glibcRuntime = versions.glibcVersionRuntime || reportHeader?.glibcVersionRuntime || '<unknown>';
|
||||
const glibcCompiler = versions.glibcVersionCompiler || reportHeader?.glibcVersionCompiler || '<unknown>';
|
||||
return {
|
||||
node: versions.node || null,
|
||||
electron: versions.electron || null,
|
||||
modules: versions.modules || null,
|
||||
napi: versions.napi || null,
|
||||
v8: versions.v8 || null,
|
||||
uv: versions.uv || null,
|
||||
openssl: versions.openssl || null,
|
||||
glibcRuntime,
|
||||
glibcCompiler,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
osType: os.type(),
|
||||
osRelease: os.release(),
|
||||
osVersion: typeof os.version === 'function' ? os.version() : null,
|
||||
execPath: process.execPath,
|
||||
resourcesPath: process.resourcesPath || null,
|
||||
cwd: process.cwd(),
|
||||
};
|
||||
}
|
||||
|
||||
function formatRuntimeDiagnostics(diagnostic) {
|
||||
return [
|
||||
`node=${diagnostic.node || '<unknown>'}`,
|
||||
`electron=${diagnostic.electron || '<none>'}`,
|
||||
`modules=${diagnostic.modules || '<unknown>'}`,
|
||||
`napi=${diagnostic.napi || '<unknown>'}`,
|
||||
`v8=${diagnostic.v8 || '<unknown>'}`,
|
||||
`uv=${diagnostic.uv || '<unknown>'}`,
|
||||
`openssl=${diagnostic.openssl || '<unknown>'}`,
|
||||
`glibcRuntime=${diagnostic.glibcRuntime || '<unknown>'}`,
|
||||
`glibcCompiler=${diagnostic.glibcCompiler || '<unknown>'}`,
|
||||
`process=${diagnostic.platform}/${diagnostic.arch}`,
|
||||
`os=${diagnostic.osType} ${diagnostic.osRelease} ${diagnostic.osVersion || '<unknown>'}`,
|
||||
`execPath=${diagnostic.execPath}`,
|
||||
`resourcesPath=${diagnostic.resourcesPath || '<unknown>'}`,
|
||||
`cwd=${diagnostic.cwd}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const REDISTRIBUTABLE_RUNTIME_PATTERNS = [
|
||||
/^vcruntime\d+(?:_\d+)?\.dll$/i,
|
||||
/^msvcp\d+(?:_\d+)?\.dll$/i,
|
||||
/^msvcr\d+(?:_\d+)?\.dll$/i,
|
||||
/^concrt\d+\.dll$/i,
|
||||
/^vcamp\d+\.dll$/i,
|
||||
/^vcomp\d+\.dll$/i,
|
||||
];
|
||||
|
||||
function readPeImports(filePath) {
|
||||
let buffer;
|
||||
try {
|
||||
buffer = readFileSync(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (buffer.length < 0x40) return null;
|
||||
const peOffset = buffer.readUInt32LE(0x3c);
|
||||
if (peOffset <= 0 || peOffset + 24 >= buffer.length) return null;
|
||||
if (buffer.readUInt32LE(peOffset) !== 0x4550) return null;
|
||||
const coffOffset = peOffset + 4;
|
||||
const numberOfSections = buffer.readUInt16LE(coffOffset + 2);
|
||||
const sizeOfOptionalHeader = buffer.readUInt16LE(coffOffset + 16);
|
||||
const optionalHeaderOffset = coffOffset + 20;
|
||||
if (optionalHeaderOffset + sizeOfOptionalHeader > buffer.length) return null;
|
||||
const magic = buffer.readUInt16LE(optionalHeaderOffset);
|
||||
if (magic !== 0x10b && magic !== 0x20b) return null;
|
||||
const dataDirectoriesOffset = optionalHeaderOffset + (magic === 0x20b ? 112 : 96);
|
||||
const importEntryOffset = dataDirectoriesOffset + 8;
|
||||
if (importEntryOffset + 8 > buffer.length) return null;
|
||||
const importRva = buffer.readUInt32LE(importEntryOffset);
|
||||
if (importRva === 0) return [];
|
||||
const sections = [];
|
||||
const sectionTableOffset = optionalHeaderOffset + sizeOfOptionalHeader;
|
||||
for (let i = 0; i < numberOfSections; i++) {
|
||||
const base = sectionTableOffset + i * 40;
|
||||
if (base + 40 > buffer.length) return null;
|
||||
sections.push({
|
||||
virtualSize: buffer.readUInt32LE(base + 8),
|
||||
virtualAddress: buffer.readUInt32LE(base + 12),
|
||||
rawSize: buffer.readUInt32LE(base + 16),
|
||||
rawPointer: buffer.readUInt32LE(base + 20),
|
||||
});
|
||||
}
|
||||
const rvaToOffset = (rva) => {
|
||||
for (const s of sections) {
|
||||
const span = Math.max(s.virtualSize, s.rawSize);
|
||||
if (rva >= s.virtualAddress && rva < s.virtualAddress + span) {
|
||||
return rva - s.virtualAddress + s.rawPointer;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
const readCString = (offset) => {
|
||||
let end = offset;
|
||||
while (end < buffer.length && buffer[end] !== 0) end++;
|
||||
return buffer.toString('ascii', offset, end);
|
||||
};
|
||||
const importTableOffset = rvaToOffset(importRva);
|
||||
if (importTableOffset < 0) return [];
|
||||
const imports = new Set();
|
||||
for (let i = 0; i < 1024; i++) {
|
||||
const base = importTableOffset + i * 20;
|
||||
if (base + 20 > buffer.length) break;
|
||||
const lookupRva = buffer.readUInt32LE(base);
|
||||
const nameRva = buffer.readUInt32LE(base + 12);
|
||||
const iatRva = buffer.readUInt32LE(base + 16);
|
||||
if (lookupRva === 0 && nameRva === 0 && iatRva === 0) break;
|
||||
const nameOffset = rvaToOffset(nameRva);
|
||||
if (nameOffset < 0) continue;
|
||||
const name = readCString(nameOffset);
|
||||
if (name) imports.add(name);
|
||||
}
|
||||
return Array.from(imports);
|
||||
}
|
||||
|
||||
function windowsImportProbe(nativePath) {
|
||||
const imports = readPeImports(nativePath);
|
||||
if (imports === null) return null;
|
||||
const sortedImports = [...imports].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
const redistributable = sortedImports.filter((dll) =>
|
||||
REDISTRIBUTABLE_RUNTIME_PATTERNS.some((pattern) => pattern.test(dll)),
|
||||
);
|
||||
return {
|
||||
command: ['pe-imports', nativePath],
|
||||
status: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
stdout: sortedImports.join('\n') || null,
|
||||
stderr: null,
|
||||
missing: [],
|
||||
redistributable,
|
||||
};
|
||||
}
|
||||
|
||||
function dependencyProbe(nativePath) {
|
||||
if (!nativePath || !existsSync(nativePath)) return null;
|
||||
if (process.platform === 'win32') return windowsImportProbe(nativePath);
|
||||
const command =
|
||||
process.platform === 'linux'
|
||||
? ['ldd', nativePath]
|
||||
: process.platform === 'darwin'
|
||||
? ['otool', '-L', nativePath]
|
||||
: null;
|
||||
if (!command) return null;
|
||||
const [bin, ...args] = command;
|
||||
const result = spawnSync(bin, args, {
|
||||
encoding: 'utf8',
|
||||
timeout: 4000,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const stdout = trimText(result.stdout);
|
||||
const stderr = trimText(result.stderr);
|
||||
const missing =
|
||||
process.platform === 'linux' && stdout
|
||||
? stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.includes('not found'))
|
||||
: [];
|
||||
return {
|
||||
command,
|
||||
status: result.status,
|
||||
signal: result.signal || null,
|
||||
error: result.error ? result.error.message : null,
|
||||
stdout,
|
||||
stderr,
|
||||
missing,
|
||||
redistributable: [],
|
||||
};
|
||||
}
|
||||
|
||||
function formatDependencyProbe(diagnostic) {
|
||||
if (!diagnostic) return null;
|
||||
const status = diagnostic.error
|
||||
? `error=${diagnostic.error}`
|
||||
: diagnostic.signal
|
||||
? `signal=${diagnostic.signal}`
|
||||
: `status=${diagnostic.status}`;
|
||||
return [
|
||||
`$ ${diagnostic.command.join(' ')}`,
|
||||
status,
|
||||
diagnostic.missing?.length ? `missing:\n${diagnostic.missing.join('\n')}` : null,
|
||||
diagnostic.redistributable?.length
|
||||
? `redistributableRuntimeImports (require VC++ redist on host):\n${diagnostic.redistributable.join('\n')}`
|
||||
: null,
|
||||
diagnostic.stdout ? `stdout:\n${diagnostic.stdout}` : null,
|
||||
diagnostic.stderr ? `stderr:\n${diagnostic.stderr}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function formatExtraDiagnostic(diagnostic) {
|
||||
if (!diagnostic) return null;
|
||||
if (typeof diagnostic === 'string') return diagnostic;
|
||||
if (typeof diagnostic === 'object' && diagnostic.name && diagnostic.text) {
|
||||
return `${diagnostic.name}:\n${diagnostic.text}`;
|
||||
}
|
||||
return `extra:\n${trimText(JSON.stringify(diagnostic, null, 2))}`;
|
||||
}
|
||||
|
||||
function collectNativeDiagnostics({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason,
|
||||
cause,
|
||||
skipNativeProbeEnv,
|
||||
extraDiagnostics = [],
|
||||
}) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
moduleName,
|
||||
reason,
|
||||
target: {
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
},
|
||||
packageDir: packageDir || null,
|
||||
nativeRoot: nativeRoot || null,
|
||||
nativePath: nativePath || null,
|
||||
nativeFile: nativePath ? basename(nativePath) : null,
|
||||
nativeFileStat: fileDiagnostic(nativePath),
|
||||
runtime: runtimeDiagnostics(),
|
||||
environment: environmentDiagnostics(skipNativeProbeEnv),
|
||||
nativeRootEntries: directoryDiagnostic(nativeRoot),
|
||||
dependencyProbe: dependencyProbe(nativePath),
|
||||
extraDiagnostics: extraDiagnostics.filter(Boolean),
|
||||
cause: errorDiagnostic(cause),
|
||||
};
|
||||
}
|
||||
|
||||
function formatNativeDiagnostics(diagnostics) {
|
||||
const sections = [
|
||||
`module=${diagnostics.moduleName}`,
|
||||
`reason=${diagnostics.reason}`,
|
||||
`target=${diagnostics.target.platform}/${diagnostics.target.arch}`,
|
||||
`packageDir=${diagnostics.packageDir || '<unknown>'}`,
|
||||
`nativeRoot=${diagnostics.nativeRoot || '<unknown>'}`,
|
||||
`nativePath=${diagnostics.nativePath || '<unknown>'}`,
|
||||
`nativeFile=${diagnostics.nativeFile || '<unknown>'}`,
|
||||
`nativeFileStat=${formatFileDiagnostic(diagnostics.nativeFileStat)}`,
|
||||
`runtime:\n${formatRuntimeDiagnostics(diagnostics.runtime)}`,
|
||||
`environment:\n${formatEnvironment(diagnostics.environment)}`,
|
||||
`nativeRootEntries:\n${formatDirectoryDiagnostic(diagnostics.nativeRootEntries)}`,
|
||||
...diagnostics.extraDiagnostics.map(formatExtraDiagnostic).filter(Boolean),
|
||||
];
|
||||
const dependencyOutput = formatDependencyProbe(diagnostics.dependencyProbe);
|
||||
if (dependencyOutput) sections.push(`dependencyProbe:\n${dependencyOutput}`);
|
||||
const causeText = formatErrorDiagnostic(diagnostics.cause);
|
||||
if (causeText) sections.push(`cause:\n${causeText}`);
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
function isNativeLoadError(error) {
|
||||
return Boolean(error?.[NATIVE_LOAD_ERROR_MARKER]);
|
||||
}
|
||||
|
||||
function createNativeLoadError({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason,
|
||||
cause,
|
||||
skipNativeProbeEnv,
|
||||
extraDiagnostics = [],
|
||||
}) {
|
||||
if (isNativeLoadError(cause)) return cause;
|
||||
const diagnostics = collectNativeDiagnostics({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason,
|
||||
cause,
|
||||
skipNativeProbeEnv,
|
||||
extraDiagnostics,
|
||||
});
|
||||
const error = new Error(`${moduleName} native module failed to load.\n${formatNativeDiagnostics(diagnostics)}`);
|
||||
error.name = 'NativeModuleLoadError';
|
||||
error[NATIVE_LOAD_ERROR_MARKER] = true;
|
||||
error.nativeDiagnostics = diagnostics;
|
||||
error.toJSON = () => ({
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
nativeDiagnostics: diagnostics,
|
||||
});
|
||||
if (cause) error.cause = cause;
|
||||
return error;
|
||||
}
|
||||
|
||||
function probeNativeBinary({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv, timeoutMs = 5000}) {
|
||||
if (!skipNativeProbeEnv || process.env[skipNativeProbeEnv] === '1') {
|
||||
return null;
|
||||
}
|
||||
const result = spawnSync(process.execPath, ['-e', 'require(process.argv[1])', nativePath], {
|
||||
env: {...process.env, ELECTRON_RUN_AS_NODE: '1', [skipNativeProbeEnv]: '1'},
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
if (result.status === 0) return null;
|
||||
const reason = result.error
|
||||
? result.error.message
|
||||
: result.signal
|
||||
? `safety probe terminated by signal ${result.signal}`
|
||||
: `safety probe exited with code ${result.status}`;
|
||||
return createNativeLoadError({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason,
|
||||
skipNativeProbeEnv,
|
||||
extraDiagnostics: [
|
||||
result.stdout ? {name: 'probeStdout', text: trimText(result.stdout)} : null,
|
||||
result.stderr ? {name: 'probeStderr', text: trimText(result.stderr)} : null,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function loadNativeBinding({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv, probe = true}) {
|
||||
if (!existsSync(nativePath)) {
|
||||
return {
|
||||
binding: null,
|
||||
loadError: createNativeLoadError({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason: 'native binary not found',
|
||||
skipNativeProbeEnv,
|
||||
}),
|
||||
};
|
||||
}
|
||||
const nativeProbeError = probe
|
||||
? probeNativeBinary({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv})
|
||||
: null;
|
||||
if (nativeProbeError) {
|
||||
return {binding: null, loadError: nativeProbeError};
|
||||
}
|
||||
try {
|
||||
return {binding: require(nativePath), loadError: null};
|
||||
} catch (error) {
|
||||
return {
|
||||
binding: null,
|
||||
loadError: createNativeLoadError({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason: 'require(nativePath) threw',
|
||||
cause: error,
|
||||
skipNativeProbeEnv,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
collectNativeDiagnostics,
|
||||
createNativeLoadError,
|
||||
formatNativeDiagnostics,
|
||||
isNativeLoadError,
|
||||
loadNativeBinding,
|
||||
probeNativeBinary,
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@fluxer/linux-evdev",
|
||||
"version": "0.0.0",
|
||||
"description": "",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
],
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"files": [
|
||||
"index.js",
|
||||
"index.d.ts",
|
||||
"loader-diagnostics.cjs",
|
||||
"linux-evdev.linux-x64-gnu.node",
|
||||
"linux-evdev.linux-arm64-gnu.node"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "cargo run --locked --quiet --manifest-path ../../../tools/ci/Cargo.toml -- build-desktop-native-addon",
|
||||
"test": "cargo test --manifest-path Cargo.toml"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub const KEY_MAP: &[(u16, &str)] = &[
|
||||
(1, "Escape"),
|
||||
(2, "1"),
|
||||
(3, "2"),
|
||||
(4, "3"),
|
||||
(5, "4"),
|
||||
(6, "5"),
|
||||
(7, "6"),
|
||||
(8, "7"),
|
||||
(9, "8"),
|
||||
(10, "9"),
|
||||
(11, "0"),
|
||||
(12, "Minus"),
|
||||
(13, "Equal"),
|
||||
(14, "Backspace"),
|
||||
(15, "Tab"),
|
||||
(16, "Q"),
|
||||
(17, "W"),
|
||||
(18, "E"),
|
||||
(19, "R"),
|
||||
(20, "T"),
|
||||
(21, "Y"),
|
||||
(22, "U"),
|
||||
(23, "I"),
|
||||
(24, "O"),
|
||||
(25, "P"),
|
||||
(26, "BracketLeft"),
|
||||
(27, "BracketRight"),
|
||||
(28, "Enter"),
|
||||
(29, "ControlLeft"),
|
||||
(30, "A"),
|
||||
(31, "S"),
|
||||
(32, "D"),
|
||||
(33, "F"),
|
||||
(34, "G"),
|
||||
(35, "H"),
|
||||
(36, "J"),
|
||||
(37, "K"),
|
||||
(38, "L"),
|
||||
(39, "Semicolon"),
|
||||
(40, "Quote"),
|
||||
(41, "Backquote"),
|
||||
(42, "ShiftLeft"),
|
||||
(43, "Backslash"),
|
||||
(44, "Z"),
|
||||
(45, "X"),
|
||||
(46, "C"),
|
||||
(47, "V"),
|
||||
(48, "B"),
|
||||
(49, "N"),
|
||||
(50, "M"),
|
||||
(51, "Comma"),
|
||||
(52, "Period"),
|
||||
(53, "Slash"),
|
||||
(54, "ShiftRight"),
|
||||
(55, "NumpadMultiply"),
|
||||
(56, "AltLeft"),
|
||||
(57, "Space"),
|
||||
(58, "CapsLock"),
|
||||
(59, "F1"),
|
||||
(60, "F2"),
|
||||
(61, "F3"),
|
||||
(62, "F4"),
|
||||
(63, "F5"),
|
||||
(64, "F6"),
|
||||
(65, "F7"),
|
||||
(66, "F8"),
|
||||
(67, "F9"),
|
||||
(68, "F10"),
|
||||
(69, "NumLock"),
|
||||
(70, "ScrollLock"),
|
||||
(71, "Numpad7"),
|
||||
(72, "Numpad8"),
|
||||
(73, "Numpad9"),
|
||||
(74, "NumpadSubtract"),
|
||||
(75, "Numpad4"),
|
||||
(76, "Numpad5"),
|
||||
(77, "Numpad6"),
|
||||
(78, "NumpadAdd"),
|
||||
(79, "Numpad1"),
|
||||
(80, "Numpad2"),
|
||||
(81, "Numpad3"),
|
||||
(82, "Numpad0"),
|
||||
(83, "NumpadDecimal"),
|
||||
(86, "IntlBackslash"),
|
||||
(119, "Pause"),
|
||||
(87, "F11"),
|
||||
(88, "F12"),
|
||||
(89, "IntlRo"),
|
||||
(90, "Lang3"),
|
||||
(92, "Convert"),
|
||||
(93, "KanaMode"),
|
||||
(94, "NonConvert"),
|
||||
(96, "NumpadEnter"),
|
||||
(97, "ControlRight"),
|
||||
(98, "NumpadDivide"),
|
||||
(99, "PrintScreen"),
|
||||
(100, "AltRight"),
|
||||
(102, "Home"),
|
||||
(103, "ArrowUp"),
|
||||
(104, "PageUp"),
|
||||
(105, "ArrowLeft"),
|
||||
(106, "ArrowRight"),
|
||||
(107, "End"),
|
||||
(108, "ArrowDown"),
|
||||
(109, "PageDown"),
|
||||
(110, "Insert"),
|
||||
(111, "Delete"),
|
||||
(113, "AudioVolumeMute"),
|
||||
(114, "AudioVolumeDown"),
|
||||
(115, "AudioVolumeUp"),
|
||||
(116, "Power"),
|
||||
(117, "NumpadEqual"),
|
||||
(121, "NumpadComma"),
|
||||
(122, "Lang1"),
|
||||
(123, "Lang2"),
|
||||
(124, "IntlYen"),
|
||||
(125, "MetaLeft"),
|
||||
(126, "MetaRight"),
|
||||
(127, "ContextMenu"),
|
||||
(142, "Sleep"),
|
||||
(143, "WakeUp"),
|
||||
(148, "LaunchApp1"),
|
||||
(149, "LaunchApp2"),
|
||||
(155, "LaunchMail"),
|
||||
(158, "BrowserBack"),
|
||||
(159, "BrowserForward"),
|
||||
(163, "MediaTrackNext"),
|
||||
(164, "MediaPlayPause"),
|
||||
(165, "MediaTrackPrevious"),
|
||||
(166, "MediaStop"),
|
||||
(172, "BrowserHome"),
|
||||
(173, "BrowserRefresh"),
|
||||
(183, "F13"),
|
||||
(184, "F14"),
|
||||
(185, "F15"),
|
||||
(186, "F16"),
|
||||
(187, "F17"),
|
||||
(188, "F18"),
|
||||
(189, "F19"),
|
||||
(190, "F20"),
|
||||
(191, "F21"),
|
||||
(192, "F22"),
|
||||
(193, "F23"),
|
||||
(194, "F24"),
|
||||
(217, "BrowserSearch"),
|
||||
(226, "LaunchMediaPlayer"),
|
||||
(364, "BrowserFavorites"),
|
||||
];
|
||||
|
||||
pub const LEFT_CTRL: u16 = 29;
|
||||
pub const RIGHT_CTRL: u16 = 97;
|
||||
pub const LEFT_SHIFT: u16 = 42;
|
||||
pub const RIGHT_SHIFT: u16 = 54;
|
||||
pub const LEFT_ALT: u16 = 56;
|
||||
pub const RIGHT_ALT: u16 = 100;
|
||||
pub const LEFT_META: u16 = 125;
|
||||
pub const RIGHT_META: u16 = 126;
|
||||
|
||||
pub const BTN_LEFT: u16 = 0x110;
|
||||
pub const BTN_RIGHT: u16 = 0x111;
|
||||
pub const BTN_MIDDLE: u16 = 0x112;
|
||||
pub const BTN_SIDE: u16 = 0x113;
|
||||
pub const BTN_EXTRA: u16 = 0x114;
|
||||
pub const BTN_FORWARD: u16 = 0x115;
|
||||
pub const BTN_BACK: u16 = 0x116;
|
||||
|
||||
pub fn evdev_button_to_browser_button(code: u16) -> Option<u8> {
|
||||
match code {
|
||||
BTN_LEFT => Some(0),
|
||||
BTN_MIDDLE => Some(1),
|
||||
BTN_RIGHT => Some(2),
|
||||
BTN_SIDE | BTN_BACK => Some(3),
|
||||
BTN_EXTRA | BTN_FORWARD => Some(4),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn keycode_to_name(code: u16) -> Option<&'static str> {
|
||||
KEY_MAP
|
||||
.iter()
|
||||
.find(|(entry_code, _)| *entry_code == code)
|
||||
.map(|(_, name)| *name)
|
||||
}
|
||||
|
||||
pub fn name_to_keycode(name: &str) -> u16 {
|
||||
KEY_MAP
|
||||
.iter()
|
||||
.find(|(_, entry_name)| *entry_name == name)
|
||||
.map(|(code, _)| *code)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keycode_to_name_covers_canonical_letters_and_arrows() {
|
||||
assert_eq!(keycode_to_name(30), Some("A"));
|
||||
assert_eq!(keycode_to_name(44), Some("Z"));
|
||||
assert_eq!(keycode_to_name(119), Some("Pause"));
|
||||
assert_eq!(keycode_to_name(183), Some("F13"));
|
||||
assert_eq!(keycode_to_name(96), Some("NumpadEnter"));
|
||||
assert_eq!(keycode_to_name(113), Some("AudioVolumeMute"));
|
||||
assert_eq!(keycode_to_name(103), Some("ArrowUp"));
|
||||
assert_eq!(keycode_to_name(125), Some("MetaLeft"));
|
||||
assert_eq!(keycode_to_name(0), None);
|
||||
assert_eq!(keycode_to_name(0xffff), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_to_keycode_round_trips_every_entry() {
|
||||
for (code, name) in KEY_MAP {
|
||||
assert_eq!(name_to_keycode(name), *code);
|
||||
}
|
||||
assert_eq!(name_to_keycode("NoSuchKey"), 0);
|
||||
assert_eq!(name_to_keycode(""), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evdev_button_matches_dom_convention() {
|
||||
assert_eq!(evdev_button_to_browser_button(BTN_LEFT), Some(0));
|
||||
assert_eq!(evdev_button_to_browser_button(BTN_MIDDLE), Some(1));
|
||||
assert_eq!(evdev_button_to_browser_button(BTN_RIGHT), Some(2));
|
||||
assert_eq!(evdev_button_to_browser_button(BTN_SIDE), Some(3));
|
||||
assert_eq!(evdev_button_to_browser_button(BTN_BACK), Some(3));
|
||||
assert_eq!(evdev_button_to_browser_button(BTN_EXTRA), Some(4));
|
||||
assert_eq!(evdev_button_to_browser_button(BTN_FORWARD), Some(4));
|
||||
assert_eq!(evdev_button_to_browser_button(0x100), None);
|
||||
assert_eq!(evdev_button_to_browser_button(0xffff), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifier_constants_match_linux_input_event_codes() {
|
||||
assert_eq!(LEFT_CTRL, 29);
|
||||
assert_eq!(RIGHT_CTRL, 97);
|
||||
assert_eq!(LEFT_SHIFT, 42);
|
||||
assert_eq!(RIGHT_SHIFT, 54);
|
||||
assert_eq!(LEFT_ALT, 56);
|
||||
assert_eq!(RIGHT_ALT, 100);
|
||||
assert_eq!(LEFT_META, 125);
|
||||
assert_eq!(RIGHT_META, 126);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
mod keymap;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use linux::{EvdevHook, name_to_evdev_keycode};
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[napi_derive::napi(js_name = "nameToEvdevKeycode")]
|
||||
pub fn name_to_evdev_keycode(name: String) -> u32 {
|
||||
u32::from(crate::keymap::name_to_keycode(&name))
|
||||
}
|
||||
@@ -0,0 +1,731 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
os::fd::{AsFd, AsRawFd, BorrowedFd, RawFd},
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
thread::{self, JoinHandle},
|
||||
};
|
||||
|
||||
use evdev::{Device, EventType, KeyCode};
|
||||
use napi::{
|
||||
Env, Status,
|
||||
bindgen_prelude::{Function, Object, Result, ToNapiValue},
|
||||
sys,
|
||||
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode, UnknownReturnValue},
|
||||
};
|
||||
use napi_derive::napi;
|
||||
use nix::sys::eventfd::{EfdFlags, EventFd};
|
||||
use polling::{Event as PollEvent, Events, Poller};
|
||||
|
||||
use crate::keymap;
|
||||
|
||||
const EXIT_KEY: usize = 0;
|
||||
const MONITOR_KEY: usize = 1;
|
||||
const DEVICE_KEY_BASE: usize = 2;
|
||||
const DEV_INPUT_PREFIX: &str = "/dev/input/event";
|
||||
const EVENT_QUEUE_LIMIT: usize = 1024;
|
||||
|
||||
fn poll_key_for_device_fd(fd: RawFd) -> Option<usize> {
|
||||
usize::try_from(fd).ok()?.checked_add(DEVICE_KEY_BASE)
|
||||
}
|
||||
|
||||
fn device_fd_from_poll_key(key: usize) -> Option<RawFd> {
|
||||
let fd = key.checked_sub(DEVICE_KEY_BASE)?;
|
||||
RawFd::try_from(fd).ok()
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum NativeEvent {
|
||||
Key {
|
||||
kind: KeyKind,
|
||||
keycode: u16,
|
||||
key_name: &'static str,
|
||||
ctrl: bool,
|
||||
alt: bool,
|
||||
shift: bool,
|
||||
meta: bool,
|
||||
},
|
||||
Mouse {
|
||||
kind: MouseKind,
|
||||
button: u8,
|
||||
ctrl: bool,
|
||||
alt: bool,
|
||||
shift: bool,
|
||||
meta: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KeyKind {
|
||||
Down,
|
||||
Up,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum MouseKind {
|
||||
Down,
|
||||
Up,
|
||||
}
|
||||
|
||||
impl ToNapiValue for NativeEvent {
|
||||
unsafe fn to_napi_value(raw_env: sys::napi_env, event: Self) -> Result<sys::napi_value> {
|
||||
let env = Env::from_raw(raw_env);
|
||||
let mut object = Object::new(&env)?;
|
||||
match event {
|
||||
Self::Key {
|
||||
kind,
|
||||
keycode,
|
||||
key_name,
|
||||
ctrl,
|
||||
alt,
|
||||
shift,
|
||||
meta,
|
||||
} => {
|
||||
object.set(
|
||||
"type",
|
||||
match kind {
|
||||
KeyKind::Down => "keydown",
|
||||
KeyKind::Up => "keyup",
|
||||
},
|
||||
)?;
|
||||
object.set("keycode", u32::from(keycode))?;
|
||||
object.set("keyName", key_name)?;
|
||||
object.set("ctrlKey", ctrl)?;
|
||||
object.set("altKey", alt)?;
|
||||
object.set("shiftKey", shift)?;
|
||||
object.set("metaKey", meta)?;
|
||||
}
|
||||
Self::Mouse {
|
||||
kind,
|
||||
button,
|
||||
ctrl,
|
||||
alt,
|
||||
shift,
|
||||
meta,
|
||||
} => {
|
||||
object.set(
|
||||
"type",
|
||||
match kind {
|
||||
MouseKind::Down => "mousedown",
|
||||
MouseKind::Up => "mouseup",
|
||||
},
|
||||
)?;
|
||||
object.set("button", u32::from(button))?;
|
||||
object.set("ctrlKey", ctrl)?;
|
||||
object.set("altKey", alt)?;
|
||||
object.set("shiftKey", shift)?;
|
||||
object.set("metaKey", meta)?;
|
||||
}
|
||||
}
|
||||
unsafe { <Object<'_> as ToNapiValue>::to_napi_value(raw_env, object) }
|
||||
}
|
||||
}
|
||||
|
||||
type EventTsfn = Arc<
|
||||
ThreadsafeFunction<
|
||||
NativeEvent,
|
||||
UnknownReturnValue,
|
||||
NativeEvent,
|
||||
Status,
|
||||
false,
|
||||
true,
|
||||
EVENT_QUEUE_LIMIT,
|
||||
>,
|
||||
>;
|
||||
|
||||
struct ExitFd {
|
||||
fd: EventFd,
|
||||
}
|
||||
|
||||
impl ExitFd {
|
||||
fn new() -> std::io::Result<Self> {
|
||||
let fd = EventFd::from_value_and_flags(0, EfdFlags::EFD_CLOEXEC | EfdFlags::EFD_NONBLOCK)
|
||||
.map_err(std::io::Error::from)?;
|
||||
Ok(Self { fd })
|
||||
}
|
||||
|
||||
fn signal(&self) {
|
||||
let _ = self.fd.write(1);
|
||||
}
|
||||
|
||||
fn drain(&self) {
|
||||
let _ = self.fd.read();
|
||||
}
|
||||
|
||||
fn as_borrowed(&self) -> BorrowedFd<'_> {
|
||||
self.fd.as_fd()
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_seat() -> String {
|
||||
std::env::var("XDG_SEAT")
|
||||
.ok()
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "seat0".to_string())
|
||||
}
|
||||
|
||||
fn read_seat_from_device(device: &udev::Device) -> Option<String> {
|
||||
if let Some(value) = device.property_value("ID_SEAT") {
|
||||
return Some(value.to_string_lossy().into_owned());
|
||||
}
|
||||
let mut parent = device.parent();
|
||||
while let Some(p) = parent {
|
||||
if let Some(value) = p.property_value("ID_SEAT") {
|
||||
return Some(value.to_string_lossy().into_owned());
|
||||
}
|
||||
parent = p.parent();
|
||||
}
|
||||
Some("seat0".to_string())
|
||||
}
|
||||
|
||||
fn lookup_input_seat(sysname: &str) -> Option<String> {
|
||||
let mut enumerator = udev::Enumerator::new().ok()?;
|
||||
enumerator.match_subsystem("input").ok()?;
|
||||
enumerator.match_sysname(sysname).ok()?;
|
||||
let device = enumerator.scan_devices().ok()?.next()?;
|
||||
read_seat_from_device(&device)
|
||||
}
|
||||
|
||||
struct OpenedDevice {
|
||||
device: Device,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
struct Reader {
|
||||
poller: Arc<Poller>,
|
||||
exit_fd: Arc<ExitFd>,
|
||||
udev_handle_available: bool,
|
||||
seat: String,
|
||||
devices: HashMap<RawFd, OpenedDevice>,
|
||||
held_keys: HashSet<u16>,
|
||||
callback: EventTsfn,
|
||||
stop: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Reader {
|
||||
fn new(
|
||||
callback: EventTsfn,
|
||||
stop: Arc<AtomicBool>,
|
||||
exit_fd: Arc<ExitFd>,
|
||||
) -> std::io::Result<Self> {
|
||||
let poller = Arc::new(Poller::new()?);
|
||||
unsafe {
|
||||
poller.add(&exit_fd.as_borrowed(), PollEvent::readable(EXIT_KEY))?;
|
||||
}
|
||||
let seat = resolve_seat();
|
||||
let udev_handle_available = udev::Enumerator::new().is_ok();
|
||||
|
||||
Ok(Self {
|
||||
poller,
|
||||
exit_fd,
|
||||
udev_handle_available,
|
||||
seat,
|
||||
devices: HashMap::new(),
|
||||
held_keys: HashSet::new(),
|
||||
callback,
|
||||
stop,
|
||||
})
|
||||
}
|
||||
|
||||
fn try_attach_monitor(&self) -> Option<udev::MonitorSocket> {
|
||||
let socket = udev::MonitorBuilder::new()
|
||||
.and_then(|b| b.match_subsystem("input"))
|
||||
.and_then(|b| b.listen())
|
||||
.ok()?;
|
||||
let monitor_fd = socket.as_raw_fd();
|
||||
let borrowed = unsafe { BorrowedFd::borrow_raw(monitor_fd) };
|
||||
if unsafe { self.poller.add(&borrowed, PollEvent::readable(MONITOR_KEY)) }.is_ok() {
|
||||
Some(socket)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn device_is_on_our_seat(&self, sysname: &str) -> bool {
|
||||
if !self.udev_handle_available || self.seat.is_empty() {
|
||||
return true;
|
||||
}
|
||||
match lookup_input_seat(sysname) {
|
||||
Some(found) => found == self.seat,
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn open_all_devices(&mut self) {
|
||||
let entries = match std::fs::read_dir("/dev/input") {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let name_str = match name.to_str() {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
if !name_str.starts_with("event") {
|
||||
continue;
|
||||
}
|
||||
if !self.device_is_on_our_seat(name_str) {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
let _ = self.open_device(&path);
|
||||
}
|
||||
}
|
||||
|
||||
fn open_device(&mut self, path: &Path) -> std::io::Result<()> {
|
||||
if self
|
||||
.devices
|
||||
.values()
|
||||
.any(|opened| opened.path.as_path() == path)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let device = Device::open(path)?;
|
||||
if !device_has_routable_input(&device) {
|
||||
return Ok(());
|
||||
}
|
||||
let _ = device.set_nonblocking(true);
|
||||
let fd = device.as_raw_fd();
|
||||
let poll_key = poll_key_for_device_fd(fd).ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"input device fd cannot be represented as a poll key",
|
||||
)
|
||||
})?;
|
||||
unsafe {
|
||||
let borrowed = BorrowedFd::borrow_raw(fd);
|
||||
self.poller.add(&borrowed, PollEvent::readable(poll_key))?;
|
||||
}
|
||||
self.devices.insert(
|
||||
fd,
|
||||
OpenedDevice {
|
||||
device,
|
||||
path: path.to_path_buf(),
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn close_device_by_path(&mut self, path: &Path) {
|
||||
let fd = self
|
||||
.devices
|
||||
.iter()
|
||||
.find(|(_, opened)| opened.path.as_path() == path)
|
||||
.map(|(fd, _)| *fd);
|
||||
if let Some(fd) = fd {
|
||||
self.close_device_by_fd(fd);
|
||||
}
|
||||
}
|
||||
|
||||
fn close_device_by_fd(&mut self, fd: RawFd) {
|
||||
if let Some(opened) = self.devices.remove(&fd) {
|
||||
let borrowed = unsafe { BorrowedFd::borrow_raw(fd) };
|
||||
let _ = self.poller.delete(borrowed);
|
||||
drop(opened);
|
||||
}
|
||||
}
|
||||
|
||||
fn run(&mut self) {
|
||||
let monitor = self.try_attach_monitor();
|
||||
let mut events = Events::new();
|
||||
loop {
|
||||
if self.stop.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
events.clear();
|
||||
if self.poller.wait(&mut events, None).is_err() {
|
||||
break;
|
||||
}
|
||||
let mut device_fds_to_drain: Vec<RawFd> = Vec::new();
|
||||
let mut drain_monitor = false;
|
||||
let mut got_exit = false;
|
||||
for event in events.iter() {
|
||||
match event.key {
|
||||
EXIT_KEY => got_exit = true,
|
||||
MONITOR_KEY => drain_monitor = true,
|
||||
fd_key => {
|
||||
if let Some(fd) = device_fd_from_poll_key(fd_key) {
|
||||
device_fds_to_drain.push(fd);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if got_exit {
|
||||
self.exit_fd.drain();
|
||||
break;
|
||||
}
|
||||
|
||||
self.rearm(monitor.as_ref(), drain_monitor, &device_fds_to_drain);
|
||||
|
||||
if drain_monitor && let Some(monitor) = monitor.as_ref() {
|
||||
self.drain_monitor(monitor);
|
||||
}
|
||||
for fd in device_fds_to_drain {
|
||||
self.drain_device(fd);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(monitor) = monitor.as_ref() {
|
||||
let borrowed = unsafe { BorrowedFd::borrow_raw(monitor.as_raw_fd()) };
|
||||
let _ = self.poller.delete(borrowed);
|
||||
}
|
||||
}
|
||||
|
||||
fn rearm(
|
||||
&self,
|
||||
monitor: Option<&udev::MonitorSocket>,
|
||||
drain_monitor: bool,
|
||||
device_fds: &[RawFd],
|
||||
) {
|
||||
let _ = self
|
||||
.poller
|
||||
.modify(self.exit_fd.as_borrowed(), PollEvent::readable(EXIT_KEY));
|
||||
if drain_monitor && let Some(monitor) = monitor {
|
||||
let borrowed = unsafe { BorrowedFd::borrow_raw(monitor.as_raw_fd()) };
|
||||
let _ = self
|
||||
.poller
|
||||
.modify(borrowed, PollEvent::readable(MONITOR_KEY));
|
||||
}
|
||||
for fd in device_fds {
|
||||
if self.devices.contains_key(fd) {
|
||||
let Some(poll_key) = poll_key_for_device_fd(*fd) else {
|
||||
continue;
|
||||
};
|
||||
let borrowed = unsafe { BorrowedFd::borrow_raw(*fd) };
|
||||
let _ = self.poller.modify(borrowed, PollEvent::readable(poll_key));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_monitor(&mut self, monitor: &udev::MonitorSocket) {
|
||||
let mut pending: Vec<(String, PathBuf, Option<String>)> = Vec::new();
|
||||
for event in monitor.iter() {
|
||||
let action = match event.action() {
|
||||
Some(a) => a.to_string_lossy().into_owned(),
|
||||
None => continue,
|
||||
};
|
||||
let devnode = match event.devnode() {
|
||||
Some(p) => p.to_path_buf(),
|
||||
None => continue,
|
||||
};
|
||||
let subsystem = event.subsystem().map(|s| s.to_string_lossy().into_owned());
|
||||
if subsystem.as_deref() != Some("input") {
|
||||
continue;
|
||||
}
|
||||
let devnode_str = devnode.to_string_lossy().into_owned();
|
||||
if !devnode_str.starts_with(DEV_INPUT_PREFIX) {
|
||||
continue;
|
||||
}
|
||||
let seat = read_seat_from_device(&event);
|
||||
pending.push((action, devnode, seat));
|
||||
}
|
||||
for (action, devnode, seat) in pending {
|
||||
match action.as_str() {
|
||||
"add" => {
|
||||
if !self.seat.is_empty()
|
||||
&& let Some(seat) = seat.as_deref()
|
||||
&& seat != self.seat
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let _ = self.open_device(&devnode);
|
||||
}
|
||||
"remove" => self.close_device_by_path(&devnode),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_device(&mut self, fd: RawFd) {
|
||||
let mut decoded: Vec<(u16, i32)> = Vec::new();
|
||||
let mut device_dead = false;
|
||||
{
|
||||
let Some(opened) = self.devices.get_mut(&fd) else {
|
||||
return;
|
||||
};
|
||||
loop {
|
||||
let fetch_result = opened.device.fetch_events();
|
||||
match fetch_result {
|
||||
Ok(events) => {
|
||||
for ev in events {
|
||||
if ev.event_type() != EventType::KEY {
|
||||
continue;
|
||||
}
|
||||
decoded.push((ev.code(), ev.value()));
|
||||
}
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => break,
|
||||
Err(_) => {
|
||||
device_dead = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if device_dead {
|
||||
self.close_device_by_fd(fd);
|
||||
return;
|
||||
}
|
||||
for (code, value) in decoded {
|
||||
if value == 2 {
|
||||
continue;
|
||||
}
|
||||
self.translate_event(code, value == 1);
|
||||
}
|
||||
}
|
||||
|
||||
fn translate_event(&mut self, code: u16, is_press: bool) {
|
||||
if let Some(button) = keymap::evdev_button_to_browser_button(code) {
|
||||
let event = NativeEvent::Mouse {
|
||||
kind: if is_press {
|
||||
MouseKind::Down
|
||||
} else {
|
||||
MouseKind::Up
|
||||
},
|
||||
button,
|
||||
ctrl: self.modifier_state_ctrl(),
|
||||
alt: self.modifier_state_alt(),
|
||||
shift: self.modifier_state_shift(),
|
||||
meta: self.modifier_state_meta(),
|
||||
};
|
||||
self.dispatch(event);
|
||||
return;
|
||||
}
|
||||
let key_name = match keymap::keycode_to_name(code) {
|
||||
Some(name) => name,
|
||||
None => return,
|
||||
};
|
||||
if is_press {
|
||||
self.held_keys.insert(code);
|
||||
} else {
|
||||
self.held_keys.remove(&code);
|
||||
}
|
||||
let event = NativeEvent::Key {
|
||||
kind: if is_press { KeyKind::Down } else { KeyKind::Up },
|
||||
keycode: code,
|
||||
key_name,
|
||||
ctrl: self.modifier_state_ctrl(),
|
||||
alt: self.modifier_state_alt(),
|
||||
shift: self.modifier_state_shift(),
|
||||
meta: self.modifier_state_meta(),
|
||||
};
|
||||
self.dispatch(event);
|
||||
}
|
||||
|
||||
fn dispatch(&self, event: NativeEvent) {
|
||||
let status = self
|
||||
.callback
|
||||
.call(event, ThreadsafeFunctionCallMode::NonBlocking);
|
||||
if status == Status::Closing {
|
||||
self.stop.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
fn modifier_state_ctrl(&self) -> bool {
|
||||
self.held_keys.contains(&keymap::LEFT_CTRL) || self.held_keys.contains(&keymap::RIGHT_CTRL)
|
||||
}
|
||||
fn modifier_state_alt(&self) -> bool {
|
||||
self.held_keys.contains(&keymap::LEFT_ALT) || self.held_keys.contains(&keymap::RIGHT_ALT)
|
||||
}
|
||||
fn modifier_state_shift(&self) -> bool {
|
||||
self.held_keys.contains(&keymap::LEFT_SHIFT)
|
||||
|| self.held_keys.contains(&keymap::RIGHT_SHIFT)
|
||||
}
|
||||
fn modifier_state_meta(&self) -> bool {
|
||||
self.held_keys.contains(&keymap::LEFT_META) || self.held_keys.contains(&keymap::RIGHT_META)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_routable_key_code(code: u16) -> bool {
|
||||
keymap::keycode_to_name(code).is_some()
|
||||
|| keymap::evdev_button_to_browser_button(code).is_some()
|
||||
}
|
||||
|
||||
fn device_has_routable_input(device: &Device) -> bool {
|
||||
device.supported_keys().is_some_and(|keys| {
|
||||
keys.iter()
|
||||
.any(|key: KeyCode| is_routable_key_code(key.code()))
|
||||
})
|
||||
}
|
||||
|
||||
impl Drop for Reader {
|
||||
fn drop(&mut self) {
|
||||
let fds: Vec<RawFd> = self.devices.keys().copied().collect();
|
||||
for fd in fds {
|
||||
let borrowed = unsafe { BorrowedFd::borrow_raw(fd) };
|
||||
let _ = self.poller.delete(borrowed);
|
||||
}
|
||||
|
||||
let _ = self.poller.delete(self.exit_fd.as_borrowed());
|
||||
}
|
||||
}
|
||||
|
||||
struct HookInner {
|
||||
stop: Option<Arc<AtomicBool>>,
|
||||
exit_fd: Option<Arc<ExitFd>>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
opened: bool,
|
||||
}
|
||||
|
||||
impl HookInner {
|
||||
fn stop_and_join(&mut self) {
|
||||
if let Some(stop) = &self.stop {
|
||||
stop.store(true, Ordering::Release);
|
||||
}
|
||||
if let Some(exit_fd) = &self.exit_fd {
|
||||
exit_fd.signal();
|
||||
}
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
self.stop = None;
|
||||
self.exit_fd = None;
|
||||
self.opened = false;
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub struct EvdevHook {
|
||||
callback: EventTsfn,
|
||||
inner: Mutex<HookInner>,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl EvdevHook {
|
||||
#[napi(constructor)]
|
||||
pub fn new(on_event: Function<NativeEvent, UnknownReturnValue>) -> Result<Self> {
|
||||
let callback = Arc::new(
|
||||
on_event
|
||||
.build_threadsafe_function::<NativeEvent>()
|
||||
.weak::<true>()
|
||||
.callee_handled::<false>()
|
||||
.max_queue_size::<EVENT_QUEUE_LIMIT>()
|
||||
.build()
|
||||
.map_err(|err| {
|
||||
generic_error(format!("failed to build callback: {}", err.reason))
|
||||
})?,
|
||||
);
|
||||
Ok(Self {
|
||||
callback,
|
||||
inner: Mutex::new(HookInner {
|
||||
stop: None,
|
||||
exit_fd: None,
|
||||
thread: None,
|
||||
opened: false,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn start(&self) -> Result<bool> {
|
||||
let mut inner = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| generic_error("hook lock poisoned"))?;
|
||||
if inner.thread.is_some() {
|
||||
return Ok(inner.opened);
|
||||
}
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let exit_fd = Arc::new(
|
||||
ExitFd::new()
|
||||
.map_err(|err| generic_error(format!("failed to allocate eventfd: {err}")))?,
|
||||
);
|
||||
let mut reader = Reader::new(self.callback.clone(), stop.clone(), exit_fd.clone())
|
||||
.map_err(|err| generic_error(format!("evdev start failed: {err}")))?;
|
||||
|
||||
reader.open_all_devices();
|
||||
let opened = !reader.devices.is_empty();
|
||||
|
||||
let join = thread::Builder::new()
|
||||
.name("fluxer-linux-evdev-reader".to_string())
|
||||
.spawn(move || {
|
||||
let mut reader = reader;
|
||||
reader.run();
|
||||
})
|
||||
.map_err(|err| generic_error(format!("failed to spawn reader thread: {err}")))?;
|
||||
|
||||
inner.stop = Some(stop);
|
||||
inner.exit_fd = Some(exit_fd);
|
||||
inner.thread = Some(join);
|
||||
inner.opened = opened;
|
||||
Ok(opened)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn stop(&self) -> Result<()> {
|
||||
let mut inner = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| generic_error("hook lock poisoned"))?;
|
||||
inner.stop_and_join();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EvdevHook {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut inner) = self.inner.lock() {
|
||||
inner.stop_and_join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "nameToEvdevKeycode")]
|
||||
pub fn name_to_evdev_keycode(name: String) -> u32 {
|
||||
u32::from(keymap::name_to_keycode(&name))
|
||||
}
|
||||
|
||||
fn generic_error(reason: impl Into<String>) -> napi::Error {
|
||||
napi::Error::new(Status::GenericFailure, reason.into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn control_poll_keys_are_legal_and_outside_fd_range() {
|
||||
assert_eq!(EXIT_KEY, 0);
|
||||
assert_eq!(MONITOR_KEY, 1);
|
||||
assert_ne!(EXIT_KEY, MONITOR_KEY);
|
||||
assert_eq!(poll_key_for_device_fd(0), Some(DEVICE_KEY_BASE));
|
||||
assert_eq!(device_fd_from_poll_key(DEVICE_KEY_BASE), Some(0));
|
||||
assert_eq!(device_fd_from_poll_key(EXIT_KEY), None);
|
||||
assert_eq!(device_fd_from_poll_key(MONITOR_KEY), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poller_accepts_exit_control_key() {
|
||||
let poller = Poller::new().expect("create poller");
|
||||
let exit_fd = ExitFd::new().expect("create exit fd");
|
||||
unsafe {
|
||||
poller
|
||||
.add(&exit_fd.as_borrowed(), PollEvent::readable(EXIT_KEY))
|
||||
.expect("register exit fd");
|
||||
}
|
||||
poller
|
||||
.delete(exit_fd.as_borrowed())
|
||||
.expect("delete exit fd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routable_key_code_filter_keeps_keyboards_and_dom_mouse_buttons() {
|
||||
assert!(is_routable_key_code(KeyCode::KEY_A.code()));
|
||||
assert!(is_routable_key_code(KeyCode::KEY_LEFTCTRL.code()));
|
||||
assert!(is_routable_key_code(KeyCode::BTN_LEFT.code()));
|
||||
assert!(is_routable_key_code(KeyCode::BTN_FORWARD.code()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routable_key_code_filter_ignores_tablet_pad_and_tool_buttons() {
|
||||
assert!(!is_routable_key_code(KeyCode::BTN_0.code()));
|
||||
assert!(!is_routable_key_code(KeyCode::BTN_TOOL_PEN.code()));
|
||||
assert!(!is_routable_key_code(KeyCode::BTN_STYLUS.code()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user