diff --git a/.gitignore b/.gitignore index 8ddee05..b498828 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,10 @@ Module.symvers # Vim swap files .*.swp +# Python +__pycache__/ +*.py[cod] + # Autotools objects .deps .dirstamp diff --git a/config.h.meson b/config.h.meson index 2f7f7ab..1b2c694 100644 --- a/config.h.meson +++ b/config.h.meson @@ -32,6 +32,9 @@ /* Define to the location of the local state directory */ #mesondefine LOCALSTATEDIR +/* Define to the installation data directory (e.g. $prefix/share) */ +#mesondefine DATADIR + /* Define to the location of the log directory */ #define LOGDIR LOCALSTATEDIR "/log" diff --git a/daemon/command.c b/daemon/command.c index 79c555f..af746c4 100644 --- a/daemon/command.c +++ b/daemon/command.c @@ -277,6 +277,7 @@ static void cmd_logging(char *buffer, int *buflen, int argc, char **argv) [X52D_MOD_COMMAND] = "command", [X52D_MOD_CLIENT] = "client", [X52D_MOD_NOTIFY] = "notify", + [X52D_MOD_KEYBOARD_LAYOUT] = "keyboard_layout", }; // This corresponds to the levels in pinelog diff --git a/daemon/config.def b/daemon/config.def index 7517e2a..0f224cd 100644 --- a/daemon/config.def +++ b/daemon/config.def @@ -105,4 +105,8 @@ CFG(Profiles, ClutchEnabled, clutch_enabled, bool, false) // be held down to remain in clutch mode. CFG(Profiles, ClutchLatched, clutch_latched, bool, false) +// KeyboardLayout is the default keyboard layout used when mapping +// profile keys to keyboard events. +CFG(Profiles, KeyboardLayout, profile_keyboard_layout, string, us) + #undef CFG diff --git a/daemon/config.h b/daemon/config.h index d6ba410..200bf11 100644 --- a/daemon/config.h +++ b/daemon/config.h @@ -49,6 +49,8 @@ struct x52d_config { bool clutch_latched; char profiles_dir[NAME_MAX]; + + char profile_keyboard_layout[NAME_MAX]; }; /* Callback functions for configuration */ @@ -84,6 +86,7 @@ void x52d_cfg_set_Mouse_Deadzone(int param); void x52d_cfg_set_Profiles_Directory(char* param); void x52d_cfg_set_Profiles_ClutchEnabled(bool param); void x52d_cfg_set_Profiles_ClutchLatched(bool param); +void x52d_cfg_set_Profiles_KeyboardLayout(char *param); int x52d_config_process_kv(void *user, const char *section, const char *key, const char *value); const char *x52d_config_get_param(struct x52d_config *cfg, const char *section, const char *key); diff --git a/daemon/constants.h b/daemon/constants.h index 5a24e6b..68fd123 100644 --- a/daemon/constants.h +++ b/daemon/constants.h @@ -36,6 +36,7 @@ enum { X52D_MOD_COMMAND, X52D_MOD_CLIENT, X52D_MOD_NOTIFY, + X52D_MOD_KEYBOARD_LAYOUT, X52D_MOD_MAX }; diff --git a/daemon/crc32.c b/daemon/crc32.c new file mode 100644 index 0000000..5e00c1b --- /dev/null +++ b/daemon/crc32.c @@ -0,0 +1,75 @@ +/* + * Saitek X52 Pro MFD & LED driver - CRC-32 (zlib / Python zlib.crc32 compatible) + * + * Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org) + * + * SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0 + */ + +#include + +#include + +/* Table matches zlib's crc_table (reflect, poly 0xEDB88320). */ +static const uint32_t x52_crc32_table[256] = { + 0x00000000u, 0x77073096u, 0xee0e612cu, 0x990951bau, 0x076dc419u, 0x706af48fu, 0xe963a535u, + 0x9e6495a3u, 0x0edb8832u, 0x79dcb8a4u, 0xe0d5e91eu, 0x97d2d988u, 0x09b64c2bu, 0x7eb17cbdu, + 0xe7b82d07u, 0x90bf1d91u, 0x1db71064u, 0x6ab020f2u, 0xf3b97148u, 0x84be41deu, 0x1adad47du, + 0x6ddde4ebu, 0xf4d4b551u, 0x83d385c7u, 0x136c9856u, 0x646ba8c0u, 0xfd62f97au, 0x8a65c9ecu, + 0x14015c4fu, 0x63066cd9u, 0xfa0f3d63u, 0x8d080df5u, 0x3b6e20c8u, 0x4c69105eu, 0xd56041e4u, + 0xa2677172u, 0x3c03e4d1u, 0x4b04d447u, 0xd20d85fdu, 0xa50ab56bu, 0x35b5a8fau, 0x42b2986cu, + 0xdbbbc9d6u, 0xacbcf940u, 0x32d86ce3u, 0x45df5c75u, 0xdcd60dcfu, 0xabd13d59u, 0x26d930acu, + 0x51de003au, 0xc8d75180u, 0xbfd06116u, 0x21b4f4b5u, 0x56b3c423u, 0xcfba9599u, 0xb8bda50fu, + 0x2802b89eu, 0x5f058808u, 0xc60cd9b2u, 0xb10be924u, 0x2f6f7c87u, 0x58684c11u, 0xc1611dabu, + 0xb6662d3du, 0x76dc4190u, 0x01db7106u, 0x98d220bcu, 0xefd5102au, 0x71b18589u, 0x06b6b51fu, + 0x9fbfe4a5u, 0xe8b8d433u, 0x7807c9a2u, 0x0f00f934u, 0x9609a88eu, 0xe10e9818u, 0x7f6a0dbbu, + 0x086d3d2du, 0x91646c97u, 0xe6635c01u, 0x6b6b51f4u, 0x1c6c6162u, 0x856530d8u, 0xf262004eu, + 0x6c0695edu, 0x1b01a57bu, 0x8208f4c1u, 0xf50fc457u, 0x65b0d9c6u, 0x12b7e950u, 0x8bbeb8eau, + 0xfcb9887cu, 0x62dd1ddfu, 0x15da2d49u, 0x8cd37cf3u, 0xfbd44c65u, 0x4db26158u, 0x3ab551ceu, + 0xa3bc0074u, 0xd4bb30e2u, 0x4adfa541u, 0x3dd895d7u, 0xa4d1c46du, 0xd3d6f4fbu, 0x4369e96au, + 0x346ed9fcu, 0xad678846u, 0xda60b8d0u, 0x44042d73u, 0x33031de5u, 0xaa0a4c5fu, 0xdd0d7cc9u, + 0x5005713cu, 0x270241aau, 0xbe0b1010u, 0xc90c2086u, 0x5768b525u, 0x206f85b3u, 0xb966d409u, + 0xce61e49fu, 0x5edef90eu, 0x29d9c998u, 0xb0d09822u, 0xc7d7a8b4u, 0x59b33d17u, 0x2eb40d81u, + 0xb7bd5c3bu, 0xc0ba6cadu, 0xedb88320u, 0x9abfb3b6u, 0x03b6e20cu, 0x74b1d29au, 0xead54739u, + 0x9dd277afu, 0x04db2615u, 0x73dc1683u, 0xe3630b12u, 0x94643b84u, 0x0d6d6a3eu, 0x7a6a5aa8u, + 0xe40ecf0bu, 0x9309ff9du, 0x0a00ae27u, 0x7d079eb1u, 0xf00f9344u, 0x8708a3d2u, 0x1e01f268u, + 0x6906c2feu, 0xf762575du, 0x806567cbu, 0x196c3671u, 0x6e6b06e7u, 0xfed41b76u, 0x89d32be0u, + 0x10da7a5au, 0x67dd4accu, 0xf9b9df6fu, 0x8ebeeff9u, 0x17b7be43u, 0x60b08ed5u, 0xd6d6a3e8u, + 0xa1d1937eu, 0x38d8c2c4u, 0x4fdff252u, 0xd1bb67f1u, 0xa6bc5767u, 0x3fb506ddu, 0x48b2364bu, + 0xd80d2bdau, 0xaf0a1b4cu, 0x36034af6u, 0x41047a60u, 0xdf60efc3u, 0xa867df55u, 0x316e8eefu, + 0x4669be79u, 0xcb61b38cu, 0xbc66831au, 0x256fd2a0u, 0x5268e236u, 0xcc0c7795u, 0xbb0b4703u, + 0x220216b9u, 0x5505262fu, 0xc5ba3bbeu, 0xb2bd0b28u, 0x2bb45a92u, 0x5cb36a04u, 0xc2d7ffa7u, + 0xb5d0cf31u, 0x2cd99e8bu, 0x5bdeae1du, 0x9b64c2b0u, 0xec63f226u, 0x756aa39cu, 0x026d930au, + 0x9c0906a9u, 0xeb0e363fu, 0x72076785u, 0x05005713u, 0x95bf4a82u, 0xe2b87a14u, 0x7bb12baeu, + 0x0cb61b38u, 0x92d28e9bu, 0xe5d5be0du, 0x7cdcefb7u, 0x0bdbdf21u, 0x86d3d2d4u, 0xf1d4e242u, + 0x68ddb3f8u, 0x1fda836eu, 0x81be16cdu, 0xf6b9265bu, 0x6fb077e1u, 0x18b74777u, 0x88085ae6u, + 0xff0f6a70u, 0x66063bcau, 0x11010b5cu, 0x8f659effu, 0xf862ae69u, 0x616bffd3u, 0x166ccf45u, + 0xa00ae278u, 0xd70dd2eeu, 0x4e048354u, 0x3903b3c2u, 0xa7672661u, 0xd06016f7u, 0x4969474du, + 0x3e6e77dbu, 0xaed16a4au, 0xd9d65adcu, 0x40df0b66u, 0x37d83bf0u, 0xa9bcae53u, 0xdebb9ec5u, + 0x47b2cf7fu, 0x30b5ffe9u, 0xbdbdf21cu, 0xcabac28au, 0x53b39330u, 0x24b4a3a6u, 0xbad03605u, + 0xcdd70693u, 0x54de5729u, 0x23d967bfu, 0xb3667a2eu, 0xc4614ab8u, 0x5d681b02u, 0x2a6f2b94u, + 0xb40bbe37u, 0xc30c8ea1u, 0x5a05df1bu, 0x2d02ef8du, +}; + +uint32_t x52_crc32_init(void) +{ + return X52_CRC32_INIT; +} + +uint32_t x52_crc32_update(uint32_t crc, const void *data, size_t len) +{ + const unsigned char *p = data; + + if (len == 0) { + return crc; + } + + crc = ~crc; + + while (len != 0) { + crc = x52_crc32_table[(crc ^ *p++) & 0xffu] ^ (crc >> 8); + len--; + } + + return ~crc; +} diff --git a/daemon/crc32.h b/daemon/crc32.h new file mode 100644 index 0000000..45c4c96 --- /dev/null +++ b/daemon/crc32.h @@ -0,0 +1,57 @@ +/* + * Saitek X52 Pro MFD & LED driver - CRC-32 (zlib / Python zlib.crc32 compatible) + * + * Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org) + * + * SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0 + */ + +/** + * @file crc32.h + * @brief IEEE/ZIP CRC-32 matching @c zlib.crc32 / Python @c zlib.crc32 (polynomial 0xEDB88320). + * + * Incremental use: @code + * uint32_t crc = x52_crc32_init(); + * crc = x52_crc32_update(crc, chunk0, len0); + * crc = x52_crc32_update(crc, chunk1, len1); + * // crc is final value (unsigned 32-bit, same encoding as Python after & 0xFFFFFFFF) + * @endcode + */ +#ifndef X52D_CRC32_H +#define X52D_CRC32_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** Initial accumulator value (same as first argument to zlib @c crc32 for a new stream). */ +#define X52_CRC32_INIT 0u + +/** Start a new CRC computation. */ +uint32_t x52_crc32_init(void); + +/** + * Feed zero or more bytes into a running CRC. + * + * @param crc Value from @ref x52_crc32_init or a prior @ref x52_crc32_update + * @param data Input bytes; must be non-NULL if @p len is non-zero + * @param len Number of bytes + * @return Updated CRC-32 (same as Python zlib.crc32(data, crc) & 0xFFFFFFFF + * when @p data is the new chunk only) + */ +uint32_t x52_crc32_update(uint32_t crc, const void *data, size_t len); + +/** Alias for @ref x52_crc32_update (zlib-style name). */ +static inline uint32_t x52_crc32(uint32_t crc, const void *data, size_t len) +{ + return x52_crc32_update(crc, data, len); +} + +#ifdef __cplusplus +} +#endif + +#endif /* X52D_CRC32_H */ diff --git a/daemon/crc32_test.c b/daemon/crc32_test.c new file mode 100644 index 0000000..e845a55 --- /dev/null +++ b/daemon/crc32_test.c @@ -0,0 +1,131 @@ +/* + * Saitek X52 Pro MFD & LED driver - CRC-32 unit tests + * + * Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org) + * + * SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0 + */ + +#include "build-config.h" +#include +#include +#include +#include +#include + +#include + +/* Golden values: Python zlib.crc32(data) & 0xFFFFFFFF */ + +static void test_init_zero(void **state) +{ + (void)state; + assert_int_equal((int)x52_crc32_init(), 0); + assert_int_equal((int)X52_CRC32_INIT, 0); +} + +static void test_empty(void **state) +{ + (void)state; + uint32_t crc = x52_crc32_init(); + crc = x52_crc32_update(crc, NULL, 0); + assert_true(crc == 0u); + crc = x52_crc32_update(crc, "", 0); + assert_true(crc == 0u); +} + +static void test_len0_preserves(void **state) +{ + (void)state; + const char *s = "abc"; + uint32_t crc = x52_crc32_update(0, s, 3); + uint32_t again = x52_crc32_update(crc, s, 0); + assert_true(again == crc); +} + +static void test_string_123456789(void **state) +{ + (void)state; + static const char s[] = "123456789"; + uint32_t crc = x52_crc32_update(0, s, sizeof(s) - 1u); + assert_true(crc == 0xcbf43926u); +} + +static void test_bytes_0_to_255(void **state) +{ + (void)state; + unsigned char buf[256]; + for (unsigned i = 0; i < 256; i++) { + buf[i] = (unsigned char)i; + } + uint32_t crc = x52_crc32_update(0, buf, sizeof(buf)); + assert_true(crc == 0x29058c73u); +} + +static void test_incremental_matches_one_shot(void **state) +{ + (void)state; + static const char s[] = "123456789"; + uint32_t a = x52_crc32_update(0, s, sizeof(s) - 1u); + + uint32_t b = x52_crc32_init(); + b = x52_crc32_update(b, s, 3); + b = x52_crc32_update(b, s + 3, 3); + b = x52_crc32_update(b, s + 6, 3); + + assert_true(b == a); +} + +static void test_chaining_second_segment(void **state) +{ + (void)state; + static const char h[] = "hello"; + static const char w[] = "world"; + static const char hw[] = "helloworld"; + + uint32_t c = x52_crc32_update(0, h, sizeof(h) - 1u); + c = x52_crc32_update(c, w, sizeof(w) - 1u); + + uint32_t whole = x52_crc32_update(0, hw, sizeof(hw) - 1u); + assert_true(c == whole); + assert_true(c == 0xf9eb20adu); +} + +static void test_one_byte_at_a_time(void **state) +{ + (void)state; + static const char s[] = "123456789"; + uint32_t expect = x52_crc32_update(0, s, sizeof(s) - 1u); + uint32_t crc = x52_crc32_init(); + for (size_t i = 0; i < sizeof(s) - 1u; i++) { + crc = x52_crc32_update(crc, s + i, 1); + } + assert_true(crc == expect); +} + +static void test_x52_crc32_alias(void **state) +{ + (void)state; + static const char s[] = "123456789"; + uint32_t u = x52_crc32_update(0, s, sizeof(s) - 1u); + uint32_t v = x52_crc32(0, s, sizeof(s) - 1u); + assert_true(u == v); +} + +int main(void) +{ + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_init_zero), + cmocka_unit_test(test_empty), + cmocka_unit_test(test_len0_preserves), + cmocka_unit_test(test_string_123456789), + cmocka_unit_test(test_bytes_0_to_255), + cmocka_unit_test(test_incremental_matches_one_shot), + cmocka_unit_test(test_chaining_second_segment), + cmocka_unit_test(test_one_byte_at_a_time), + cmocka_unit_test(test_x52_crc32_alias), + }; + + cmocka_set_message_output(CM_OUTPUT_TAP); + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/daemon/keyboard_layout.c b/daemon/keyboard_layout.c new file mode 100644 index 0000000..fce2258 --- /dev/null +++ b/daemon/keyboard_layout.c @@ -0,0 +1,99 @@ +/* + * Saitek X52 Pro MFD & LED driver - keyboard layout from config + * + * Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org) + * + * SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0 + */ + +#include "build-config.h" + +#define PINELOG_MODULE X52D_MOD_KEYBOARD_LAYOUT +#include "pinelog.h" +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +static x52_layout *active_layout; + +/** + * Normal install: @c $DATADIR/x52d/.x52l + * + * If @c X52D_LAYOUT_DIR is set (non-empty), load @c $X52D_LAYOUT_DIR/.x52l instead so + * uninstalled tests can point at the Meson build directory. + */ +static int load_layout_for_basename(const char *basename, x52_layout **out) +{ + const char *layout_dir = getenv("X52D_LAYOUT_DIR"); + + if (layout_dir != NULL && layout_dir[0] != '\0') { + char path[PATH_MAX]; + int n = snprintf(path, sizeof path, "%s/%s.x52l", layout_dir, basename); + if (n < 0) { + return EIO; + } + if ((size_t)n >= sizeof path) { + return ENAMETOOLONG; + } + return x52_layout_load_path(path, out); + } + + return x52_layout_load_datadir(DATADIR, basename, out); +} + +const x52_layout *x52d_keyboard_layout_get(void) +{ + return active_layout; +} + +void x52d_keyboard_layout_fini(void) +{ + x52_layout_free(active_layout); + active_layout = NULL; +} + +void x52d_keyboard_layout_reload(char *profile_keyboard_layout_value) +{ + char basename[256]; + bool rejected = false; + + x52_layout_normalize_keyboard_basename(profile_keyboard_layout_value, basename, sizeof basename, &rejected); + if (rejected) { + PINELOG_WARN(_("Invalid Profiles.KeyboardLayout value; using default layout basename 'us'")); + } + + x52_layout *new_layout = NULL; + int err = load_layout_for_basename(basename, &new_layout); + + if (err != 0 && strcmp(basename, "us") != 0) { + PINELOG_WARN( + _("Keyboard layout '%s' could not be loaded (%s); loading default 'us'"), + basename, strerror(err > 0 ? err : EIO)); + err = load_layout_for_basename("us", &new_layout); + } + + if (err != 0) { + PINELOG_FATAL(_("Could not load keyboard layout from %s/x52d (%s)"), DATADIR, + strerror(err > 0 ? err : EIO)); + } + + x52_layout *old_layout = active_layout; + active_layout = new_layout; + x52_layout_free(old_layout); + + const char *desc = x52_layout_description(active_layout); + PINELOG_INFO(_("Keyboard layout ready: %s (%s)"), x52_layout_name(active_layout), + desc[0] != '\0' ? desc : _("no description")); +} + +void x52d_cfg_set_Profiles_KeyboardLayout(char *param) +{ + x52d_keyboard_layout_reload(param); +} diff --git a/daemon/keyboard_layout.h b/daemon/keyboard_layout.h new file mode 100644 index 0000000..bc29c80 --- /dev/null +++ b/daemon/keyboard_layout.h @@ -0,0 +1,27 @@ +/* + * Saitek X52 Pro MFD & LED driver - keyboard layout from config + * + * Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org) + * + * SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0 + */ + +#ifndef X52D_KEYBOARD_LAYOUT_H +#define X52D_KEYBOARD_LAYOUT_H + +#include + +/** + * @brief Load or reload layout from @c profile_keyboard_layout (@c Profiles.KeyboardLayout). + * + * Resolves @c $datadir/x52d/.x52l with basename hardening; falls back to @c us once if the + * requested file is missing. Exits the process if no layout can be loaded. + */ +void x52d_keyboard_layout_reload(char *profile_keyboard_layout_value); + +/** Active layout after @ref x52d_keyboard_layout_reload, or @c NULL before the first apply. */ +const x52_layout *x52d_keyboard_layout_get(void); + +void x52d_keyboard_layout_fini(void); + +#endif /* X52D_KEYBOARD_LAYOUT_H */ diff --git a/daemon/layout_format.h b/daemon/layout_format.h new file mode 100644 index 0000000..430d3a1 --- /dev/null +++ b/daemon/layout_format.h @@ -0,0 +1,197 @@ +/* + * Saitek X52 Pro MFD & LED driver - x52layout v1 binary format + * + * Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org) + * + * SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0 + */ + +/** + * @file layout_format.h + * @brief On-disk keyboard layout (@c .x52l) v1: constants, documentation, load and lookup API. + * + * The file is a dense index: entry @c entries[c] maps Unicode scalar @c c when + * @c 0 <= c < codepoint_limit. For @c c >= codepoint_limit there is no mapping. + * + * **Header (128 bytes, all multi-byte integers big-endian / network order):** + * - **0..3:** magic @c 'X' @c '5' @c '2' @c 'L' + * - **4..5:** @c version (must be @ref X52_LAYOUT_FORMAT_VERSION) + * - **6..7:** @c flags (v1: only @ref X52_LAYOUT_FLAG_NAME_TRUNCATED and/or + * @ref X52_LAYOUT_FLAG_DESCRIPTION_TRUNCATED; other bits are reserved and must be zero) + * - **8..11:** @c codepoint_limit — exclusive end of range; number of two-byte rows in @c entries + * - **12..15:** @c checksum — CRC-32 (ZIP/IEEE, same as Python @c zlib.crc32) over the full + * file with bytes 12..15 taken as zero when computing the digest + * - **16..47:** @c layout_name (required: at least one character before @c NUL; remainder zero) + * - **48..111:** @c description (optional, NUL-terminated, remainder zero) + * - **112..127:** reserved (ignored on read in v1) + * + * **128+:** @c entries[] — pairs @c (modifiers, usage_key) for HID page 0x07; @c (0, 0) is empty. + * + * **File size:** exactly @c 128 + 2 * @c codepoint_limit bytes. + */ +#ifndef X52D_LAYOUT_FORMAT_H +#define X52D_LAYOUT_FORMAT_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** Four-byte magic at offset 0 (not NUL-terminated in the file). */ +#define X52_LAYOUT_MAGIC_0 'X' +#define X52_LAYOUT_MAGIC_1 '5' +#define X52_LAYOUT_MAGIC_2 '2' +#define X52_LAYOUT_MAGIC_3 'L' + +/** Total header size and byte offset of the @c entries table. */ +#define X52_LAYOUT_HEADER_BYTES 128u + +#define X52_LAYOUT_FORMAT_VERSION 1u + +/** Inclusive maximum for @c codepoint_limit - 1 (Unicode scalar space + sentinel ceiling). */ +#define X52_LAYOUT_CODEPOINT_LIMIT_MAX 0x110000u + +/** @c layout_name field size in the header (offset 16). */ +#define X52_LAYOUT_NAME_FIELD_BYTES 32u + +/** @c description field size in the header (offset 48). */ +#define X52_LAYOUT_DESCRIPTION_FIELD_BYTES 64u + +/** + * v1 header @c flags (big-endian on disk). @ref x52_layout_flags returns the host-endian value. + */ +#define X52_LAYOUT_FLAG_NAME_TRUNCATED 1u +#define X52_LAYOUT_FLAG_DESCRIPTION_TRUNCATED 2u + +/** Bitmask of flags defined for v1; other bits must be zero. */ +#define X52_LAYOUT_FLAGS_KNOWN (X52_LAYOUT_FLAG_NAME_TRUNCATED | X52_LAYOUT_FLAG_DESCRIPTION_TRUNCATED) + +/** Loaded layout snapshot (opaque): full file copy, validated at load time. */ +typedef struct x52_layout x52_layout; + +/** + * @brief Read a layout file into a malloc'd snapshot and validate it (no @c mmap). + * + * @param path Path to the @c .x52l file; must not be @c NULL (otherwise @c EINVAL) + * @param out On success, receives a new @ref x52_layout; must not be @c NULL; caller must @ref x52_layout_free + * + * @returns 0 on success, or a positive @c errno value (@c EINVAL, @c ENOMEM, @c EIO, @c ENOENT, …) + */ +int x52_layout_load_path(const char *path, x52_layout **out); + +/** + * @brief Turn @c Profiles.KeyboardLayout INI value into a safe layout basename. + * + * Empty or @c NULL yields @c "us" with @p rejected_out @c false. Values containing @c '/' , + * @c '\\' , @c ".." , disallowed characters, or oversize strings are rejected: @p out becomes + * @c "us" and @p rejected_out is @c true (caller should log once). Requires @p out_sz @c >= 3. + * + * Allowed characters: ASCII alphanumeric, @c '_' , @c '-'. + */ +void x52_layout_normalize_keyboard_basename(const char *cfg_value, char *out, size_t out_sz, bool *rejected_out); + +/** + * @brief Build @c /x52d/.x52l into @p path. + * + * @returns 0 on success, or @c ENAMETOOLONG if the path does not fit + */ +int x52_layout_join_file_path(char *path, size_t path_sz, const char *datadir, const char *basename); + +/** + * @brief Load @c join(datadir, "x52d", basename + ".x52l") after the same validation as @ref x52_layout_load_path. + * + * @returns 0 on success, or a positive @c errno (e.g. @c ENOENT, @c EINVAL, @c ENAMETOOLONG) + */ +int x52_layout_load_datadir(const char *datadir, const char *basename, x52_layout **out); + +/** + * @brief Copy @p data into an owned buffer and validate it. + * + * Same validation rules as @ref x52_layout_load_path (magic, version, flags, size, CRC-32, entries, + * non-empty @c layout_name, etc.). + * + * @param data Layout file bytes; may be @c NULL only if @p len is zero (otherwise @c EINVAL) + * @param len Number of bytes in @p data + * @param out On success, receives a new @ref x52_layout; must not be @c NULL; caller must @ref x52_layout_free + * + * @returns 0 on success, or a positive @c errno value (@c EINVAL, @c ENOMEM, …) + */ +int x52_layout_load_memory(const void *data, size_t len, x52_layout **out); + +/** + * @brief Release a layout loaded by @ref x52_layout_load_path or @ref x52_layout_load_memory. + * + * @param layout Layout to free; @c NULL is a no-op + */ +void x52_layout_free(x52_layout *layout); + +/** + * @brief Exclusive end of the Unicode scalar range covered by @c entries (same as on-disk @c codepoint_limit). + * + * Lookups for @c code_point >= this value are not in the table. + * + * @param layout Loaded layout, or @c NULL + * + * @returns The limit value, or @c 0 if @p layout is @c NULL + */ +uint32_t x52_layout_codepoint_limit(const x52_layout *layout); + +/** + * @brief Host-endian copy of the on-disk @c flags field at header offset 6. + * + * Only bits in @ref X52_LAYOUT_FLAGS_KNOWN may be set in valid files; the loader rejects unknown bits. + * + * @param layout Loaded layout, or @c NULL + * + * @returns Flag word, or @c 0 if @p layout is @c NULL + */ +uint16_t x52_layout_flags(const x52_layout *layout); + +/** + * @brief Layout name from the header @c layout_name field. + * + * If @ref X52_LAYOUT_FLAG_NAME_TRUNCATED is set, the returned string is the on-disk name plus + * @c "". The pointer remains valid until @ref x52_layout_free; do not modify the string. + * + * @param layout Loaded layout, or @c NULL + * + * @returns Read-only NUL-terminated UTF-8 string; empty string if @p layout is @c NULL + */ +const char *x52_layout_name(const x52_layout *layout); + +/** + * @brief Optional description from the header @c description field. + * + * If @ref X52_LAYOUT_FLAG_DESCRIPTION_TRUNCATED is set, the returned string is the on-disk text plus + * @c "". The pointer remains valid until @ref x52_layout_free; do not modify the string. + * + * @param layout Loaded layout, or @c NULL + * + * @returns Read-only NUL-terminated UTF-8 string, or empty string if @p layout is @c NULL or the field is empty + */ +const char *x52_layout_description(const x52_layout *layout); + +/** + * @brief O(1) lookup of the HID chord for Unicode scalar @p code_point. + * + * Returns @c false when @p code_point is out of range, when the slot is empty @c (modifiers, usage_key) = (0, 0), + * or when any argument is invalid. On @c false, @p modifiers_out and @p usage_key_out are left unchanged. + * + * @param layout Loaded layout; if @c NULL, returns @c false + * @param code_point Unicode scalar value + * @param[out] modifiers_out HID keyboard modifier byte (@ref vkm_key_modifiers bits); if @c NULL, returns @c false + * @param[out] usage_key_out HID usage (page 0x07); if @c NULL, returns @c false + * + * @returns @c true if a non-empty mapping exists and was written to @p modifiers_out and @p usage_key_out + */ +bool x52_layout_lookup(const x52_layout *layout, uint32_t code_point, uint8_t *modifiers_out, + uint8_t *usage_key_out); + +#ifdef __cplusplus +} +#endif + +#endif /* X52D_LAYOUT_FORMAT_H */ diff --git a/daemon/layout_load.c b/daemon/layout_load.c new file mode 100644 index 0000000..a2af7f0 --- /dev/null +++ b/daemon/layout_load.c @@ -0,0 +1,392 @@ +/* + * Saitek X52 Pro MFD & LED driver - x52layout v1 loader + * + * Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org) + * + * SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0 + */ + +#include "build-config.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +struct x52_layout { + uint8_t *data; + size_t size; + uint32_t codepoint_limit; + uint16_t flags; + char *name; + char *description; +}; + +static size_t meta_field_len(const uint8_t *field, size_t nbytes) +{ + size_t i; + + for (i = 0; i < nbytes; i++) { + if (field[i] == '\0') { + break; + } + } + return i; +} + +static char *copy_meta_string(const uint8_t *field, size_t nbytes, bool add_trunc_suffix) +{ + static const char suf[] = ""; + const size_t suf_len = sizeof(suf) - 1u; + size_t base_len = meta_field_len(field, nbytes); + size_t total = base_len + (add_trunc_suffix ? suf_len : 0); + char *s = (char *)malloc(total + 1u); + + if (s == NULL) { + return NULL; + } + if (base_len != 0) { + memcpy(s, field, base_len); + } + if (add_trunc_suffix) { + memcpy(s + base_len, suf, suf_len); + } + s[total] = '\0'; + return s; +} + +static uint16_t read_be16(const uint8_t *p) +{ + return (uint16_t)((uint16_t)p[0] << 8 | (uint16_t)p[1]); +} + +static uint32_t read_be32(const uint8_t *p) +{ + return (uint32_t)p[0] << 24 | (uint32_t)p[1] << 16 | (uint32_t)p[2] << 8 | (uint32_t)p[3]; +} + +static uint32_t x52_layout_file_crc(const uint8_t *buf, size_t len) +{ + uint32_t crc = x52_crc32_init(); + crc = x52_crc32_update(crc, buf, 12u); + static const uint8_t zero_chk[4] = {0, 0, 0, 0}; + crc = x52_crc32_update(crc, zero_chk, 4u); + if (len > 16u) { + crc = x52_crc32_update(crc, buf + 16u, len - 16u); + } + return crc; +} + +static int validate_entries(const uint8_t *buf, uint32_t codepoint_limit) +{ + const uint8_t *base = buf + X52_LAYOUT_HEADER_BYTES; + + for (uint32_t i = 0; i < codepoint_limit; i++) { + uint8_t usage = base[2u * (size_t)i + 1u]; + if (usage == 0) { + continue; + } + if (!x52_layout_usage_key_allowed(usage)) { + return EINVAL; + } + } + return 0; +} + +static int layout_validate_and_adopt(uint8_t *buf, size_t len, x52_layout **out) +{ + if (out == NULL) { + free(buf); + return EINVAL; + } + *out = NULL; + + if (len < X52_LAYOUT_HEADER_BYTES) { + free(buf); + return EINVAL; + } + + if (memcmp(buf, "X52L", 4) != 0) { + free(buf); + return EINVAL; + } + + uint16_t version = read_be16(buf + 4); + uint16_t flags = read_be16(buf + 6); + uint32_t codepoint_limit = read_be32(buf + 8); + uint32_t stored_crc = read_be32(buf + 12); + + if (version != X52_LAYOUT_FORMAT_VERSION) { + free(buf); + return EINVAL; + } + if ((flags & (uint16_t)~X52_LAYOUT_FLAGS_KNOWN) != 0) { + free(buf); + return EINVAL; + } + if (codepoint_limit > X52_LAYOUT_CODEPOINT_LIMIT_MAX) { + free(buf); + return EINVAL; + } + + size_t body = (size_t)codepoint_limit * 2u; + size_t expected = X52_LAYOUT_HEADER_BYTES + body; + if (expected < X52_LAYOUT_HEADER_BYTES || len != expected) { + free(buf); + return EINVAL; + } + + if (meta_field_len(buf + 16, X52_LAYOUT_NAME_FIELD_BYTES) == 0) { + free(buf); + return EINVAL; + } + + uint32_t calc = x52_layout_file_crc(buf, len); + if (calc != stored_crc) { + free(buf); + return EINVAL; + } + + int en = validate_entries(buf, codepoint_limit); + if (en != 0) { + free(buf); + return en; + } + + x52_layout *L = (x52_layout *)malloc(sizeof *L); + if (L == NULL) { + free(buf); + return ENOMEM; + } + L->data = buf; + L->size = len; + L->codepoint_limit = codepoint_limit; + L->flags = flags; + L->name = copy_meta_string(buf + 16, X52_LAYOUT_NAME_FIELD_BYTES, + (flags & X52_LAYOUT_FLAG_NAME_TRUNCATED) != 0); + if (L->name == NULL) { + free(L); + free(buf); + return ENOMEM; + } + L->description = copy_meta_string(buf + 48, X52_LAYOUT_DESCRIPTION_FIELD_BYTES, + (flags & X52_LAYOUT_FLAG_DESCRIPTION_TRUNCATED) != 0); + if (L->description == NULL) { + free(L->name); + free(L); + free(buf); + return ENOMEM; + } + *out = L; + return 0; +} + +void x52_layout_normalize_keyboard_basename(const char *cfg_value, char *out, size_t out_sz, bool *rejected_out) +{ + *rejected_out = false; + if (out_sz < 3) { + if (out_sz > 0) { + out[0] = '\0'; + } + return; + } + + if (cfg_value == NULL || cfg_value[0] == '\0') { + out[0] = 'u'; + out[1] = 's'; + out[2] = '\0'; + return; + } + + if (strchr(cfg_value, '/') != NULL || strchr(cfg_value, '\\') != NULL || + strstr(cfg_value, "..") != NULL) { + goto failed_normalization; + } + + size_t len = strlen(cfg_value); + if (len >= out_sz) { + goto failed_normalization; + } + + for (size_t i = 0; i < len; i++) { + unsigned char c = (unsigned char)cfg_value[i]; + if (!isalnum((int)c) && c != '_' && c != '-') { + goto failed_normalization; + } + } + + memcpy(out, cfg_value, len + 1u); + return; + +failed_normalization: + *rejected_out = true; + out[0] = 'u'; + out[1] = 's'; + out[2] = '\0'; +} + +int x52_layout_join_file_path(char *path, size_t path_sz, const char *datadir, const char *basename) +{ + if (path == NULL || datadir == NULL || basename == NULL || path_sz == 0) { + return EINVAL; + } + + int n = snprintf(path, path_sz, "%s/x52d/%s.x52l", datadir, basename); + if (n < 0) { + return EIO; + } + if ((size_t)n >= path_sz) { + return ENAMETOOLONG; + } + return 0; +} + +int x52_layout_load_datadir(const char *datadir, const char *basename, x52_layout **out) +{ + char path[PATH_MAX]; + + int rc = x52_layout_join_file_path(path, sizeof path, datadir, basename); + if (rc != 0) { + return rc; + } + return x52_layout_load_path(path, out); +} + +int x52_layout_load_path(const char *path, x52_layout **out) +{ + if (path == NULL) { + return EINVAL; + } + + FILE *fp = fopen(path, "rb"); + if (fp == NULL) { + return errno != 0 ? errno : EIO; + } + + if (fseek(fp, 0, SEEK_END) != 0) { + int e = errno != 0 ? errno : EIO; + fclose(fp); + return e; + } + + long pos = ftell(fp); + if (pos < 0) { + int e = errno != 0 ? errno : EIO; + fclose(fp); + return e; + } + if (fseek(fp, 0, SEEK_SET) != 0) { + int e = errno != 0 ? errno : EIO; + fclose(fp); + return e; + } + + size_t len = (size_t)pos; + if ((long)len != pos || pos < (long)X52_LAYOUT_HEADER_BYTES) { + fclose(fp); + return EINVAL; + } + + uint8_t *buf = (uint8_t *)malloc(len); + if (buf == NULL) { + fclose(fp); + return ENOMEM; + } + + size_t n = fread(buf, 1, len, fp); + fclose(fp); + if (n != len) { + free(buf); + return EIO; + } + + return layout_validate_and_adopt(buf, len, out); +} + +int x52_layout_load_memory(const void *data, size_t len, x52_layout **out) +{ + if (data == NULL && len != 0) { + return EINVAL; + } + + uint8_t *buf = (uint8_t *)malloc(len); + if (buf == NULL) { + return ENOMEM; + } + if (len != 0) { + memcpy(buf, data, len); + } + return layout_validate_and_adopt(buf, len, out); +} + +void x52_layout_free(x52_layout *layout) +{ + if (layout == NULL) { + return; + } + free(layout->name); + free(layout->description); + free(layout->data); + free(layout); +} + +uint32_t x52_layout_codepoint_limit(const x52_layout *layout) +{ + if (layout == NULL) { + return 0; + } + return layout->codepoint_limit; +} + +uint16_t x52_layout_flags(const x52_layout *layout) +{ + if (layout == NULL) { + return 0; + } + return layout->flags; +} + +const char *x52_layout_name(const x52_layout *layout) +{ + if (layout == NULL || layout->name == NULL) { + return ""; + } + return layout->name; +} + +const char *x52_layout_description(const x52_layout *layout) +{ + if (layout == NULL || layout->description == NULL) { + return ""; + } + return layout->description; +} + +bool x52_layout_lookup(const x52_layout *layout, uint32_t code_point, uint8_t *modifiers_out, + uint8_t *usage_key_out) +{ + if (layout == NULL || modifiers_out == NULL || usage_key_out == NULL) { + return false; + } + if (code_point >= layout->codepoint_limit) { + return false; + } + + size_t off = X52_LAYOUT_HEADER_BYTES + 2u * (size_t)code_point; + uint8_t mod = layout->data[off]; + uint8_t usage = layout->data[off + 1u]; + if (usage == 0) { + return false; + } + *modifiers_out = mod; + *usage_key_out = usage; + return true; +} diff --git a/daemon/layout_load_test.c b/daemon/layout_load_test.c new file mode 100644 index 0000000..7fe32c5 --- /dev/null +++ b/daemon/layout_load_test.c @@ -0,0 +1,463 @@ +/* + * Saitek X52 Pro MFD & LED driver - x52layout loader tests + * + * Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org) + * + * SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0 + */ + +#include "build-config.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +static void write_be16(uint8_t *p, uint16_t v) +{ + p[0] = (uint8_t)(v >> 8); + p[1] = (uint8_t)v; +} + +static void write_be32(uint8_t *p, uint32_t v) +{ + p[0] = (uint8_t)(v >> 24); + p[1] = (uint8_t)(v >> 16); + p[2] = (uint8_t)(v >> 8); + p[3] = (uint8_t)v; +} + +static uint32_t read_be32(const uint8_t *p) +{ + return (uint32_t)p[0] << 24 | (uint32_t)p[1] << 16 | (uint32_t)p[2] << 8 | (uint32_t)p[3]; +} + +/** Patch checksum (offsets 12..15) after building the rest; @p len is full file size. */ +static void finalize_crc(uint8_t *buf, size_t len) +{ + uint32_t crc = x52_crc32_init(); + crc = x52_crc32_update(crc, buf, 12u); + static const uint8_t z[4] = {0, 0, 0, 0}; + crc = x52_crc32_update(crc, z, 4u); + if (len > 16u) { + crc = x52_crc32_update(crc, buf + 16u, len - 16u); + } + write_be32(buf + 12, crc); +} + +/** Python @c zlib.crc32 over @c minimal v1 layout (@c limit=1, name @c "x", empty entry). */ +#define X52_LAYOUT_TEST_MINIMAL_ZLIB_CRC32 0xc951bfaau + +static void test_load_minimal_lookup(void **state) +{ + (void)state; + const uint32_t limit = 98u; /* enough for 'a' at 97 */ + size_t len = X52_LAYOUT_HEADER_BYTES + 2u * (size_t)limit; + uint8_t *buf = (uint8_t *)calloc(1, len); + assert_non_null(buf); + + memcpy(buf, "X52L", 4); + write_be16(buf + 4, X52_LAYOUT_FORMAT_VERSION); + write_be16(buf + 6, 0); + write_be32(buf + 8, limit); + memcpy(buf + 16, "minimal", 8); + /* chord for 'a': VKM_KEY_A */ + size_t off = X52_LAYOUT_HEADER_BYTES + 2u * 97u; + buf[off] = 0x02; /* LSHIFT */ + buf[off + 1] = (uint8_t)VKM_KEY_A; + finalize_crc(buf, len); + + x52_layout *L = NULL; + assert_int_equal(x52_layout_load_memory(buf, len, &L), 0); + assert_non_null(L); + assert_int_equal((int)x52_layout_codepoint_limit(L), (int)limit); + + uint8_t m = 0xff; + uint8_t u = 0xff; + assert_true(x52_layout_lookup(L, 97u, &m, &u)); + assert_int_equal((int)m, 0x02); + assert_int_equal((int)u, (int)VKM_KEY_A); + + m = 0; + u = 0; + assert_false(x52_layout_lookup(L, 0u, &m, &u)); + assert_false(x52_layout_lookup(L, limit, &m, &u)); + + assert_int_equal((int)x52_layout_flags(L), 0); + assert_string_equal(x52_layout_name(L), "minimal"); + assert_string_equal(x52_layout_description(L), ""); + + x52_layout_free(L); + free(buf); +} + +static void test_reject_bad_checksum(void **state) +{ + (void)state; + const uint32_t limit = 1u; + size_t len = X52_LAYOUT_HEADER_BYTES + 2u; + uint8_t *buf = (uint8_t *)calloc(1, len); + assert_non_null(buf); + memcpy(buf, "X52L", 4); + write_be16(buf + 4, X52_LAYOUT_FORMAT_VERSION); + write_be16(buf + 6, 0); + write_be32(buf + 8, limit); + memcpy(buf + 16, "x", 2); + write_be32(buf + 12, 0xdeadbeefu); + + x52_layout *L = NULL; + assert_int_equal(x52_layout_load_memory(buf, len, &L), EINVAL); + assert_null(L); + free(buf); +} + +static void test_layout_crc_matches_python_zlib_minimal(void **state) +{ + (void)state; + const uint32_t limit = 1u; + size_t len = X52_LAYOUT_HEADER_BYTES + 2u; + uint8_t *buf = (uint8_t *)calloc(1, len); + assert_non_null(buf); + memcpy(buf, "X52L", 4); + write_be16(buf + 4, X52_LAYOUT_FORMAT_VERSION); + write_be16(buf + 6, 0); + write_be32(buf + 8, limit); + memcpy(buf + 16, "x", 2); + finalize_crc(buf, len); + assert_int_equal((int)read_be32(buf + 12), (int)X52_LAYOUT_TEST_MINIMAL_ZLIB_CRC32); + + x52_layout *L = NULL; + assert_int_equal(x52_layout_load_memory(buf, len, &L), 0); + assert_non_null(L); + assert_string_equal(x52_layout_name(L), "x"); + x52_layout_free(L); + free(buf); +} + +static void test_reject_tampered_checksum_byte(void **state) +{ + (void)state; + const uint32_t limit = 1u; + size_t len = X52_LAYOUT_HEADER_BYTES + 2u; + uint8_t *buf = (uint8_t *)calloc(1, len); + assert_non_null(buf); + memcpy(buf, "X52L", 4); + write_be16(buf + 4, X52_LAYOUT_FORMAT_VERSION); + write_be16(buf + 6, 0); + write_be32(buf + 8, limit); + memcpy(buf + 16, "x", 2); + finalize_crc(buf, len); + buf[12] ^= 0x01u; + + x52_layout *L = NULL; + assert_int_equal(x52_layout_load_memory(buf, len, &L), EINVAL); + assert_null(L); + free(buf); +} + +static void test_reject_codepoint_limit_not_big_endian(void **state) +{ + (void)state; + /* Little-endian uint32_t 1 in the codepoint_limit field: read_be32 → 0x01000000. */ + const uint32_t limit = 1u; + size_t len = X52_LAYOUT_HEADER_BYTES + 2u * (size_t)limit; + uint8_t *buf = (uint8_t *)calloc(1, len); + assert_non_null(buf); + memcpy(buf, "X52L", 4); + write_be16(buf + 4, X52_LAYOUT_FORMAT_VERSION); + write_be16(buf + 6, 0); + buf[8] = 0x01u; + buf[9] = 0x00u; + buf[10] = 0x00u; + buf[11] = 0x00u; + memcpy(buf + 16, "x", 2); + finalize_crc(buf, len); + + x52_layout *L = NULL; + assert_int_equal(x52_layout_load_memory(buf, len, &L), EINVAL); + assert_null(L); + free(buf); +} + +static void test_reject_version_word_not_big_endian_one(void **state) +{ + (void)state; + const uint32_t limit = 1u; + size_t len = X52_LAYOUT_HEADER_BYTES + 2u; + uint8_t *buf = (uint8_t *)calloc(1, len); + assert_non_null(buf); + memcpy(buf, "X52L", 4); + /* Native little-endian 0x0001 would appear as 01 00 — not BE version 1 (00 01). */ + buf[4] = 0x01u; + buf[5] = 0x00u; + write_be16(buf + 6, 0); + write_be32(buf + 8, limit); + memcpy(buf + 16, "x", 2); + finalize_crc(buf, len); + + x52_layout *L = NULL; + assert_int_equal(x52_layout_load_memory(buf, len, &L), EINVAL); + assert_null(L); + free(buf); +} + +static void test_reject_size_mismatch(void **state) +{ + (void)state; + const uint32_t limit = 4u; + size_t len = X52_LAYOUT_HEADER_BYTES + 2u * (size_t)limit; + uint8_t *buf = (uint8_t *)calloc(1, len); + assert_non_null(buf); + memcpy(buf, "X52L", 4); + write_be16(buf + 4, X52_LAYOUT_FORMAT_VERSION); + write_be16(buf + 6, 0); + write_be32(buf + 8, limit); + memcpy(buf + 16, "x", 2); + finalize_crc(buf, len); + + x52_layout *L = NULL; + assert_int_equal(x52_layout_load_memory(buf, len - 1u, &L), EINVAL); + assert_null(L); + free(buf); +} + +static void test_reject_disallowed_usage(void **state) +{ + (void)state; + const uint32_t limit = 1u; + size_t len = X52_LAYOUT_HEADER_BYTES + 2u; + uint8_t *buf = (uint8_t *)calloc(1, len); + assert_non_null(buf); + memcpy(buf, "X52L", 4); + write_be16(buf + 4, X52_LAYOUT_FORMAT_VERSION); + write_be16(buf + 6, 0); + write_be32(buf + 8, limit); + memcpy(buf + 16, "x", 2); + buf[X52_LAYOUT_HEADER_BYTES + 1u] = 0x3A; /* VKM_KEY_F1 — not in allowlist */ + finalize_crc(buf, len); + + x52_layout *L = NULL; + assert_int_equal(x52_layout_load_memory(buf, len, &L), EINVAL); + assert_null(L); + free(buf); +} + +static void test_metadata_plain(void **state) +{ + (void)state; + const uint32_t limit = 1u; + size_t len = X52_LAYOUT_HEADER_BYTES + 2u; + uint8_t *buf = (uint8_t *)calloc(1, len); + assert_non_null(buf); + memcpy(buf, "X52L", 4); + write_be16(buf + 4, X52_LAYOUT_FORMAT_VERSION); + write_be16(buf + 6, 0); + write_be32(buf + 8, limit); + memcpy(buf + 16, "us", 3); + memcpy(buf + 48, "US QWERTY", 10); + finalize_crc(buf, len); + + x52_layout *L = NULL; + assert_int_equal(x52_layout_load_memory(buf, len, &L), 0); + assert_non_null(L); + assert_string_equal(x52_layout_name(L), "us"); + assert_string_equal(x52_layout_description(L), "US QWERTY"); + x52_layout_free(L); + free(buf); +} + +static void test_metadata_truncated_suffix(void **state) +{ + (void)state; + const uint32_t limit = 1u; + size_t len = X52_LAYOUT_HEADER_BYTES + 2u; + uint8_t *buf = (uint8_t *)calloc(1, len); + assert_non_null(buf); + memcpy(buf, "X52L", 4); + write_be16(buf + 4, X52_LAYOUT_FORMAT_VERSION); + write_be16(buf + 6, + (uint16_t)(X52_LAYOUT_FLAG_NAME_TRUNCATED | X52_LAYOUT_FLAG_DESCRIPTION_TRUNCATED)); + write_be32(buf + 8, limit); + memcpy(buf + 16, "longish", 8); + memcpy(buf + 48, "desc", 5); + finalize_crc(buf, len); + + x52_layout *L = NULL; + assert_int_equal(x52_layout_load_memory(buf, len, &L), 0); + assert_non_null(L); + assert_int_equal((int)x52_layout_flags(L), + (int)(X52_LAYOUT_FLAG_NAME_TRUNCATED | X52_LAYOUT_FLAG_DESCRIPTION_TRUNCATED)); + assert_string_equal(x52_layout_name(L), "longish"); + assert_string_equal(x52_layout_description(L), "desc"); + x52_layout_free(L); + free(buf); +} + +static void test_metadata_name_truncated_flag_only(void **state) +{ + (void)state; + const uint32_t limit = 1u; + size_t len = X52_LAYOUT_HEADER_BYTES + 2u; + uint8_t *buf = (uint8_t *)calloc(1, len); + assert_non_null(buf); + memcpy(buf, "X52L", 4); + write_be16(buf + 4, X52_LAYOUT_FORMAT_VERSION); + write_be16(buf + 6, X52_LAYOUT_FLAG_NAME_TRUNCATED); + write_be32(buf + 8, limit); + memcpy(buf + 16, "nm", 3); + memcpy(buf + 48, "plain", 6); + finalize_crc(buf, len); + + x52_layout *L = NULL; + assert_int_equal(x52_layout_load_memory(buf, len, &L), 0); + assert_non_null(L); + assert_int_equal((int)x52_layout_flags(L), (int)X52_LAYOUT_FLAG_NAME_TRUNCATED); + assert_string_equal(x52_layout_name(L), "nm"); + assert_string_equal(x52_layout_description(L), "plain"); + x52_layout_free(L); + free(buf); +} + +static void test_reject_unknown_flags(void **state) +{ + (void)state; + const uint32_t limit = 1u; + size_t len = X52_LAYOUT_HEADER_BYTES + 2u; + uint8_t *buf = (uint8_t *)calloc(1, len); + assert_non_null(buf); + memcpy(buf, "X52L", 4); + write_be16(buf + 4, X52_LAYOUT_FORMAT_VERSION); + write_be16(buf + 6, 0x8000); + write_be32(buf + 8, limit); + memcpy(buf + 16, "x", 2); + finalize_crc(buf, len); + + x52_layout *L = NULL; + assert_int_equal(x52_layout_load_memory(buf, len, &L), EINVAL); + assert_null(L); + free(buf); +} + +static void test_reject_empty_name(void **state) +{ + (void)state; + const uint32_t limit = 1u; + size_t len = X52_LAYOUT_HEADER_BYTES + 2u; + uint8_t *buf = (uint8_t *)calloc(1, len); + assert_non_null(buf); + memcpy(buf, "X52L", 4); + write_be16(buf + 4, X52_LAYOUT_FORMAT_VERSION); + write_be16(buf + 6, 0); + write_be32(buf + 8, limit); + finalize_crc(buf, len); + + x52_layout *L = NULL; + assert_int_equal(x52_layout_load_memory(buf, len, &L), EINVAL); + assert_null(L); + free(buf); +} + +static void test_basename_normalize_and_join(void **state) +{ + (void)state; + char out[256]; + bool rej; + + x52_layout_normalize_keyboard_basename(NULL, out, sizeof out, &rej); + assert_false(rej); + assert_string_equal(out, "us"); + + x52_layout_normalize_keyboard_basename("", out, sizeof out, &rej); + assert_false(rej); + assert_string_equal(out, "us"); + + x52_layout_normalize_keyboard_basename("de", out, sizeof out, &rej); + assert_false(rej); + assert_string_equal(out, "de"); + + x52_layout_normalize_keyboard_basename("ab_cd-9", out, sizeof out, &rej); + assert_false(rej); + assert_string_equal(out, "ab_cd-9"); + + x52_layout_normalize_keyboard_basename("../x", out, sizeof out, &rej); + assert_true(rej); + assert_string_equal(out, "us"); + + x52_layout_normalize_keyboard_basename("a/b", out, sizeof out, &rej); + assert_true(rej); + assert_string_equal(out, "us"); + + x52_layout_normalize_keyboard_basename("bad name", out, sizeof out, &rej); + assert_true(rej); + assert_string_equal(out, "us"); + + x52_layout_normalize_keyboard_basename("a\\b", out, sizeof out, &rej); + assert_true(rej); + assert_string_equal(out, "us"); + + x52_layout_normalize_keyboard_basename("x..y", out, sizeof out, &rej); + assert_true(rej); + assert_string_equal(out, "us"); + + memset(out, 0, sizeof out); + x52_layout_normalize_keyboard_basename("almost..", out, sizeof out, &rej); + assert_true(rej); + assert_string_equal(out, "us"); + + char path[PATH_MAX]; + assert_int_equal(x52_layout_join_file_path(path, sizeof path, "/usr/share", "us"), 0); + assert_string_equal(path, "/usr/share/x52d/us.x52l"); + assert_int_equal(x52_layout_join_file_path(path, 20, "/usr/share", "us"), ENAMETOOLONG); +} + +static void test_reject_version(void **state) +{ + (void)state; + const uint32_t limit = 1u; + size_t len = X52_LAYOUT_HEADER_BYTES + 2u; + uint8_t *buf = (uint8_t *)calloc(1, len); + assert_non_null(buf); + memcpy(buf, "X52L", 4); + write_be16(buf + 4, 99); + write_be16(buf + 6, 0); + write_be32(buf + 8, limit); + memcpy(buf + 16, "v", 2); + finalize_crc(buf, len); + + x52_layout *L = NULL; + assert_int_equal(x52_layout_load_memory(buf, len, &L), EINVAL); + assert_null(L); + free(buf); +} + +int main(void) +{ + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_load_minimal_lookup), + cmocka_unit_test(test_metadata_plain), + cmocka_unit_test(test_metadata_truncated_suffix), + cmocka_unit_test(test_metadata_name_truncated_flag_only), + cmocka_unit_test(test_reject_unknown_flags), + cmocka_unit_test(test_reject_empty_name), + cmocka_unit_test(test_reject_bad_checksum), + cmocka_unit_test(test_layout_crc_matches_python_zlib_minimal), + cmocka_unit_test(test_reject_tampered_checksum_byte), + cmocka_unit_test(test_reject_codepoint_limit_not_big_endian), + cmocka_unit_test(test_reject_version_word_not_big_endian_one), + cmocka_unit_test(test_reject_size_mismatch), + cmocka_unit_test(test_reject_disallowed_usage), + cmocka_unit_test(test_reject_version), + cmocka_unit_test(test_basename_normalize_and_join), + }; + + cmocka_set_message_output(CM_OUTPUT_TAP); + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/daemon/layout_usage_allowlist.c b/daemon/layout_usage_allowlist.c new file mode 100644 index 0000000..1ef40cb --- /dev/null +++ b/daemon/layout_usage_allowlist.c @@ -0,0 +1,32 @@ +/* + * Saitek X52 Pro MFD & LED driver - Keyboard layout HID usage allowlist + * + * Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org) + * + * SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0 + */ + +#include + +#include + +#include + +/* layout binary and compiler must stay aligned with vkm_key numeric values. */ +_Static_assert((unsigned)VKM_KEY_A == 0x04u, "vkm_key main block start"); +_Static_assert((unsigned)VKM_KEY_CAPS_LOCK == 0x39u, "vkm_key main block end"); +_Static_assert((unsigned)VKM_KEY_INTL_BACKSLASH == 0x64u, "vkm_key ISO backslash"); + +bool x52_layout_usage_key_allowed(uint8_t usage) +{ + if (usage == 0) { + return false; + } + if (usage >= 0x04 && usage <= 0x39) { + return true; + } + if (usage == 0x64) { + return true; + } + return false; +} diff --git a/daemon/layout_usage_allowlist.h b/daemon/layout_usage_allowlist.h new file mode 100644 index 0000000..1b8054f --- /dev/null +++ b/daemon/layout_usage_allowlist.h @@ -0,0 +1,42 @@ +/* + * Saitek X52 Pro MFD & LED driver - Keyboard layout HID usage allowlist + * + * Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org) + * + * SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0 + */ + +/** + * @file layout_usage_allowlist.h + * @brief HID keyboard/page (0x07) usages permitted as layout chord \c usage_key bytes. + * + * The set is the USB HID "main block" (usages @c 0x04-@c 0x39, i.e. @c VKM_KEY_A + * through @c VKM_KEY_CAPS_LOCK) plus @c VKM_KEY_INTL_BACKSLASH (@c 0x64). It excludes + * @c VKM_KEY_NONE, modifiers, function row, navigation cluster, keypad, and all other + * @ref vkm_key values; same rule as @c tools/x52compile_layout.py. + */ +#ifndef X52D_LAYOUT_USAGE_ALLOWLIST_H +#define X52D_LAYOUT_USAGE_ALLOWLIST_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Return whether @p usage may appear as the non-modifier byte in a layout entry. + * + * @param usage HID usage ID (page 0x07), same encoding as @ref vkm_key. + * + * @returns true if @p usage is in the main-block allowlist; false for @c VKM_KEY_NONE + * and for any disallowed usage. + */ +bool x52_layout_usage_key_allowed(uint8_t usage); + +#ifdef __cplusplus +} +#endif + +#endif /* X52D_LAYOUT_USAGE_ALLOWLIST_H */ diff --git a/daemon/layout_usage_allowlist_test.c b/daemon/layout_usage_allowlist_test.c new file mode 100644 index 0000000..1f7672b --- /dev/null +++ b/daemon/layout_usage_allowlist_test.c @@ -0,0 +1,55 @@ +/* + * Saitek X52 Pro MFD & LED driver - layout HID usage allowlist tests + * + * Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org) + * + * SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0 + */ + +#include "build-config.h" +#include +#include +#include +#include +#include +#include + +#include +#include + +static void test_allows_main_block(void **state) +{ + (void)state; + + assert_true(x52_layout_usage_key_allowed(VKM_KEY_A)); + assert_true(x52_layout_usage_key_allowed(VKM_KEY_Z)); + assert_true(x52_layout_usage_key_allowed(VKM_KEY_1)); + assert_true(x52_layout_usage_key_allowed(VKM_KEY_0)); + assert_true(x52_layout_usage_key_allowed(VKM_KEY_SPACE)); + assert_true(x52_layout_usage_key_allowed(VKM_KEY_CAPS_LOCK)); + assert_true(x52_layout_usage_key_allowed(VKM_KEY_INTL_BACKSLASH)); + assert_true(x52_layout_usage_key_allowed(VKM_KEY_NONUS_HASH)); +} + +static void test_rejects_disallowed(void **state) +{ + (void)state; + + assert_false(x52_layout_usage_key_allowed(0)); + assert_false(x52_layout_usage_key_allowed(VKM_KEY_F1)); + assert_false(x52_layout_usage_key_allowed(VKM_KEY_LEFT_CTRL)); + assert_false(x52_layout_usage_key_allowed(VKM_KEY_KEYPAD_1)); + assert_false(x52_layout_usage_key_allowed(0x03)); + assert_false(x52_layout_usage_key_allowed(0x3A)); +} + +int main(void) +{ + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_allows_main_block), + cmocka_unit_test(test_rejects_disallowed), + }; + + cmocka_set_message_output(CM_OUTPUT_TAP); + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/daemon/main.c b/daemon/main.c index 7330b98..fd134f5 100644 --- a/daemon/main.c +++ b/daemon/main.c @@ -26,6 +26,7 @@ #include #include #include +#include #include #include "pinelog.h" @@ -364,6 +365,7 @@ int main(int argc, char **argv) PINELOG_INFO(_("Received termination signal %s"), strsignal(flag_quit)); cleanup: + x52d_keyboard_layout_fini(); // Stop device threads x52d_clock_exit(); x52d_dev_exit(); diff --git a/daemon/meson.build b/daemon/meson.build index c0ad8cf..5e6f696 100644 --- a/daemon/meson.build +++ b/daemon/meson.build @@ -35,6 +35,10 @@ x52d_sources = [ 'command.c', 'io.c', 'mouse_handler.c', + 'layout_usage_allowlist.c', + 'layout_load.c', + 'keyboard_layout.c', + 'crc32.c', ] dep_threads = dependency('threads') @@ -60,6 +64,19 @@ exe_x52ctl = executable('x52ctl', 'daemon_control.c', install_data('x52d.conf', install_dir: join_paths(get_option('sysconfdir'), 'x52d')) +us_x52l = custom_target( + 'us-x52l', + input: files('../data/layouts/us.layout'), + output: 'us.x52l', + command: [ + python, + join_paths(meson.project_source_root(), 'tools', 'x52compile_layout.py'), + '@INPUT@', + '@OUTPUT@', + ], + install: true, + install_dir: join_paths(get_option('datadir'), 'x52d')) + test('daemon-communication', files('test_daemon_comm.py')[0], depends: [exe_x52d, exe_x52ctl], protocol: 'tap') @@ -70,6 +87,43 @@ x52d_mouse_test = executable('x52d-mouse-test', x52d_mouse_test_sources, test('x52d-mouse-test', x52d_mouse_test, protocol: 'tap') +layout_usage_allowlist_test = executable('layout-usage-allowlist-test', + 'layout_usage_allowlist_test.c', + 'layout_usage_allowlist.c', + build_by_default: false, + include_directories: includes, + dependencies: [dep_cmocka, dep_config_h]) + +test('layout-usage-allowlist', layout_usage_allowlist_test, protocol: 'tap') + +crc32_test = executable('crc32-test', 'crc32_test.c', 'crc32.c', + build_by_default: false, + include_directories: includes, + dependencies: [dep_cmocka, dep_config_h]) + +test('crc32', crc32_test, protocol: 'tap') + +layout_load_test = executable('layout-load-test', + 'layout_load_test.c', + 'layout_load.c', + 'layout_usage_allowlist.c', + 'crc32.c', + build_by_default: false, + include_directories: includes, + dependencies: [dep_cmocka, dep_config_h]) + +test('layout-load', layout_load_test, protocol: 'tap') + +pymod_daemon = import('python') +python_layout_test = pymod_daemon.find_installation('python3') +test('layout-usage-allowlist-sync', python_layout_test, + args: [join_paths(meson.project_source_root(), 'tools', 'test_layout_allowlist_sync.py')], + protocol: 'tap') + +test('layout-compile-py', python_layout_test, + args: [join_paths(meson.project_source_root(), 'tools', 'test_x52compile_layout.py')], + protocol: 'tap') + # Install service file if dep_systemd.found() systemd_system_unit_dir = get_option('systemd-unit-dir') diff --git a/daemon/test_daemon_comm.py b/daemon/test_daemon_comm.py index 4956b82..50ec7b4 100755 --- a/daemon/test_daemon_comm.py +++ b/daemon/test_daemon_comm.py @@ -134,7 +134,11 @@ class Test: with open(daemon_cmdline[4], 'w', encoding='utf-8'): pass - self.daemon = subprocess.Popen(daemon_cmdline) # pylint: disable=consider-using-with + env = os.environ.copy() + # Uninstalled build: us.x52l lives next to the x52d binary (see daemon/meson.build). + env['X52D_LAYOUT_DIR'] = os.path.dirname(self.program) + + self.daemon = subprocess.Popen(daemon_cmdline, env=env) # pylint: disable=consider-using-with print("# Sleeping 2 seconds for daemon to start") time.sleep(2) diff --git a/daemon/x52d.conf b/daemon/x52d.conf index bf6e464..342dc49 100644 --- a/daemon/x52d.conf +++ b/daemon/x52d.conf @@ -139,6 +139,10 @@ ClutchEnabled=no # be held down to remain in clutch mode. ClutchLatched=no +# KeyboardLayout is a basename only (alphanumeric, underscore, hyphen), not a path. +# Resolves to $datadir/x52d/.x52l; default us uses the installed us.x52l pack. +KeyboardLayout=us + ################## #X52 Input Servic# #Version 0.3.3 # diff --git a/data/layouts/us.layout b/data/layouts/us.layout new file mode 100644 index 0000000..0906713 --- /dev/null +++ b/data/layouts/us.layout @@ -0,0 +1,111 @@ +# US QWERTY — main block (letters, digits, punctuation, space, controls) +name: us +description: US QWERTY main block + +U+0008 BACKSPACE +U+0009 TAB +U+000A ENTER +U+000D ENTER +U+001B ESCAPE +U+0020 SPACE + +a a +b b +c c +d d +e e +f f +g g +h h +i i +j j +k k +l l +m m +n n +o o +p p +q q +r r +s s +t t +u u +v v +w w +x x +y y +z z + +A SHIFT+a +B SHIFT+b +C SHIFT+c +D SHIFT+d +E SHIFT+e +F SHIFT+f +G SHIFT+g +H SHIFT+h +I SHIFT+i +J SHIFT+j +K SHIFT+k +L SHIFT+l +M SHIFT+m +N SHIFT+n +O SHIFT+o +P SHIFT+p +Q SHIFT+q +R SHIFT+r +S SHIFT+s +T SHIFT+t +U SHIFT+u +V SHIFT+v +W SHIFT+w +X SHIFT+x +Y SHIFT+y +Z SHIFT+z + +1 1 +2 2 +3 3 +4 4 +5 5 +6 6 +7 7 +8 8 +9 9 +0 0 +! SHIFT+1 +@ SHIFT+2 +# SHIFT+3 +$ SHIFT+4 +% SHIFT+5 +^ SHIFT+6 +& SHIFT+7 +* SHIFT+8 +( SHIFT+9 +) SHIFT+0 +_ SHIFT+MINUS ++ SHIFT+EQUAL + +[ LEFT_BRACKET +{ SHIFT+LEFT_BRACKET +] RIGHT_BRACKET +} SHIFT+RIGHT_BRACKET +\ BACKSLASH +| SHIFT+BACKSLASH + +; SEMICOLON +: SHIFT+SEMICOLON +' APOSTROPHE +" SHIFT+APOSTROPHE +` GRAVE +~ SHIFT+GRAVE + +, COMMA +< SHIFT+COMMA +. PERIOD +> SHIFT+PERIOD +/ SLASH +? SHIFT+SLASH + +- MINUS += EQUAL diff --git a/meson.build b/meson.build index 5be1135..5ce62dc 100644 --- a/meson.build +++ b/meson.build @@ -50,6 +50,7 @@ cdata.set_quoted('PACKAGE_NAME', meson.project_name()) cdata.set_quoted('LOCALEDIR', get_option('prefix') / get_option('localedir')) cdata.set_quoted('SYSCONFDIR', get_option('prefix') / get_option('sysconfdir')) cdata.set_quoted('LOCALSTATEDIR', get_option('prefix') / get_option('localstatedir')) +cdata.set_quoted('DATADIR', get_option('prefix') / get_option('datadir')) cdata.set_quoted('PACKAGE_VERSION', meson.project_version()) cdata.set_quoted('VERSION', meson.project_version()) cdata.set10('ENABLE_NLS', not get_option('nls').disabled()) diff --git a/po/POTFILES.in b/po/POTFILES.in index e49abed..4ca8843 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -23,6 +23,7 @@ daemon/config_parser.c daemon/daemon_control.c daemon/device.c daemon/io.c +daemon/keyboard_layout.c daemon/main.c daemon/mouse.c daemon/mouse_handler.c diff --git a/po/libx52.pot b/po/libx52.pot index 79678f0..70bac5b 100644 --- a/po/libx52.pot +++ b/po/libx52.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: libx52 0.3.3\n" "Report-Msgid-Bugs-To: https://github.com/nirenjan/libx52/issues\n" -"POT-Creation-Date: 2026-04-03 23:44-0700\n" +"POT-Creation-Date: 2026-04-04 23:11-0700\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -599,41 +599,41 @@ msgstr "" msgid "Shutting down X52 clock manager thread" msgstr "" -#: daemon/command.c:379 +#: daemon/command.c:380 #, c-format msgid "Error reading from client %d: %s" msgstr "" -#: daemon/command.c:390 +#: daemon/command.c:391 #, c-format msgid "Short write to client %d; expected %d bytes, wrote %d bytes" msgstr "" -#: daemon/command.c:415 +#: daemon/command.c:416 #, c-format msgid "Error %d during command loop: %s" msgstr "" -#: daemon/command.c:442 +#: daemon/command.c:443 #, c-format msgid "Error creating command socket: %s" msgstr "" -#: daemon/command.c:450 +#: daemon/command.c:451 #, c-format msgid "Error marking command socket as nonblocking: %s" msgstr "" -#: daemon/command.c:456 +#: daemon/command.c:457 #, c-format msgid "Error listening on command socket: %s" msgstr "" -#: daemon/command.c:460 +#: daemon/command.c:461 msgid "Starting command processing thread" msgstr "" -#: daemon/command.c:478 +#: daemon/command.c:479 msgid "Shutting down command processing thread" msgstr "" @@ -774,17 +774,41 @@ msgstr "" msgid "Shutting down X52 I/O driver thread" msgstr "" -#: daemon/main.c:67 +#: daemon/keyboard_layout.c:69 +msgid "" +"Invalid Profiles.KeyboardLayout value; using default layout basename 'us'" +msgstr "" + +#: daemon/keyboard_layout.c:77 +#, c-format +msgid "Keyboard layout '%s' could not be loaded (%s); loading default 'us'" +msgstr "" + +#: daemon/keyboard_layout.c:83 +#, c-format +msgid "Could not load keyboard layout from %s/x52d (%s)" +msgstr "" + +#: daemon/keyboard_layout.c:92 +#, c-format +msgid "Keyboard layout ready: %s (%s)" +msgstr "" + +#: daemon/keyboard_layout.c:93 +msgid "no description" +msgstr "" + +#: daemon/main.c:68 #, c-format msgid "Error %d setting log file: %s\n" msgstr "" -#: daemon/main.c:83 +#: daemon/main.c:84 #, c-format msgid "Error %d installing handler for signal %d: %s" msgstr "" -#: daemon/main.c:94 +#: daemon/main.c:95 #, c-format msgid "" "Usage: %s [-f] [-v] [-q]\n" @@ -794,88 +818,88 @@ msgid "" "\t[-b notify-socket-path]\n" msgstr "" -#: daemon/main.c:129 +#: daemon/main.c:130 #, c-format msgid "Daemon is already running as PID %u" msgstr "" -#: daemon/main.c:271 +#: daemon/main.c:272 #, c-format msgid "Unable to parse configuration override '%s'\n" msgstr "" -#: daemon/main.c:303 +#: daemon/main.c:304 #, c-format msgid "Foreground = %s" msgstr "" -#: daemon/main.c:303 daemon/main.c:304 +#: daemon/main.c:304 daemon/main.c:305 msgid "true" msgstr "" -#: daemon/main.c:303 daemon/main.c:304 +#: daemon/main.c:304 daemon/main.c:305 msgid "false" msgstr "" -#: daemon/main.c:304 -#, c-format -msgid "Quiet = %s" -msgstr "" - #: daemon/main.c:305 #, c-format -msgid "Verbosity = %d" +msgid "Quiet = %s" msgstr "" #: daemon/main.c:306 #, c-format -msgid "Log file = %s" +msgid "Verbosity = %d" msgstr "" #: daemon/main.c:307 #, c-format -msgid "Config file = %s" +msgid "Log file = %s" msgstr "" #: daemon/main.c:308 #, c-format -msgid "PID file = %s" +msgid "Config file = %s" msgstr "" #: daemon/main.c:309 #, c-format -msgid "Command socket = %s" +msgid "PID file = %s" msgstr "" #: daemon/main.c:310 #, c-format +msgid "Command socket = %s" +msgstr "" + +#: daemon/main.c:311 +#, c-format msgid "Notify socket = %s" msgstr "" -#: daemon/main.c:321 +#: daemon/main.c:322 #, c-format msgid "Error %d blocking signals on child threads: %s" msgstr "" -#: daemon/main.c:338 +#: daemon/main.c:339 #, c-format msgid "Error %d unblocking signals on child threads: %s" msgstr "" -#: daemon/main.c:351 +#: daemon/main.c:352 msgid "Reloading X52 configuration" msgstr "" -#: daemon/main.c:358 +#: daemon/main.c:359 msgid "Saving X52 configuration to disk" msgstr "" -#: daemon/main.c:364 +#: daemon/main.c:365 #, c-format msgid "Received termination signal %s" msgstr "" -#: daemon/main.c:379 +#: daemon/main.c:381 msgid "Shutting down X52 daemon" msgstr "" diff --git a/po/xx_PL.po b/po/xx_PL.po index 420fcd0..5568357 100644 --- a/po/xx_PL.po +++ b/po/xx_PL.po @@ -5,10 +5,10 @@ # msgid "" msgstr "" -"Project-Id-Version: libx52 0.2.3\n" +"Project-Id-Version: libx52 0.3.3\n" "Report-Msgid-Bugs-To: https://github.com/nirenjan/libx52/issues\n" -"POT-Creation-Date: 2026-04-03 23:44-0700\n" -"PO-Revision-Date: 2026-04-01 20:50-0700\n" +"POT-Creation-Date: 2026-04-04 23:11-0700\n" +"PO-Revision-Date: 2026-04-04 12:00-0700\n" "Last-Translator: Nirenjan Krishnan \n" "Language-Team: Dummy Language for testing i18n\n" "Language: xx_PL\n" @@ -645,42 +645,42 @@ msgstr "Erroray %d initializingay ockclay eadthray: %s" msgid "Shutting down X52 clock manager thread" msgstr "Uttingshay ownday X52 ockclay anagermay eadthray" -#: daemon/command.c:379 +#: daemon/command.c:380 #, c-format msgid "Error reading from client %d: %s" msgstr "Erroray eadingray omfray ientclay %d: %s" -#: daemon/command.c:390 +#: daemon/command.c:391 #, c-format msgid "Short write to client %d; expected %d bytes, wrote %d bytes" msgstr "" "Ortshay itewray otay ientclay %d; expecteday %d ytesbay, otewray %d ytesbay" -#: daemon/command.c:415 +#: daemon/command.c:416 #, c-format msgid "Error %d during command loop: %s" msgstr "Erroray %d uringday ommandcay ooplay: %s" -#: daemon/command.c:442 +#: daemon/command.c:443 #, c-format msgid "Error creating command socket: %s" msgstr "Erroray eatingcray ommandcay ocketsay: %s" -#: daemon/command.c:450 +#: daemon/command.c:451 #, c-format msgid "Error marking command socket as nonblocking: %s" msgstr "Erroray arkingmay ommandcay ocketsay asay onblockingnay: %s" -#: daemon/command.c:456 +#: daemon/command.c:457 #, c-format msgid "Error listening on command socket: %s" msgstr "Erroray isteninglay onay ommandcay ocketsay: %s" -#: daemon/command.c:460 +#: daemon/command.c:461 msgid "Starting command processing thread" msgstr "Artingstay ommandcay ocessingpray eadthray" -#: daemon/command.c:478 +#: daemon/command.c:479 msgid "Shutting down command processing thread" msgstr "Uttingshay ownday ommandcay ocessingpray eadthray" @@ -821,17 +821,46 @@ msgstr "Erroray %d initializingay I/O iverdray eadthray: %s" msgid "Shutting down X52 I/O driver thread" msgstr "Uttingshay ownday X52 I/O iverdray eadthray" -#: daemon/main.c:67 +#: daemon/keyboard_layout.c:69 +msgid "" +"Invalid Profiles.KeyboardLayout value; using default layout basename 'us'" +msgstr "" +"Invaliday Ofilespray.EyboardKayayoutLay aluevay; usingay efaultday ayoutlay " +"asenambay 'us'" + +#: daemon/keyboard_layout.c:77 +#, c-format +msgid "Keyboard layout '%s' could not be loaded (%s); loading default 'us'" +msgstr "" +"EyboardKay ayoutlay '%s' ouldcay otnay ebay oadedlay (%s); oadinglay " +"efaultday 'us'" + +#: daemon/keyboard_layout.c:83 +#, c-format +msgid "Could not load keyboard layout from %s/x52d (%s)" +msgstr "" +"ouldCay otnay oadlay eyboardkay ayoutlay omfray %s/x52d (%s)" + +#: daemon/keyboard_layout.c:92 +#, c-format +msgid "Keyboard layout ready: %s (%s)" +msgstr "EyboardKay ayoutlay eadyray: %s (%s)" + +#: daemon/keyboard_layout.c:93 +msgid "no description" +msgstr "onay escriptionday" + +#: daemon/main.c:68 #, c-format msgid "Error %d setting log file: %s\n" msgstr "Erroray %d ettingsay oglay ilefay: %s\n" -#: daemon/main.c:83 +#: daemon/main.c:84 #, c-format msgid "Error %d installing handler for signal %d: %s" msgstr "Erroray %d installingay andlerhay orfay ignalsay %d: %s" -#: daemon/main.c:94 +#: daemon/main.c:95 #, c-format msgid "" "Usage: %s [-f] [-v] [-q]\n" @@ -847,88 +876,88 @@ msgstr "" "\t[-b otifynay-ocketsay-athpay]\n" "\n" -#: daemon/main.c:129 +#: daemon/main.c:130 #, c-format msgid "Daemon is already running as PID %u" msgstr "Aemonday isay alreadyay unningray asay IDPay %u" -#: daemon/main.c:271 +#: daemon/main.c:272 #, c-format msgid "Unable to parse configuration override '%s'\n" msgstr "Unableay otay arsepay onfigurationcay overrideay '%s'\n" -#: daemon/main.c:303 +#: daemon/main.c:304 #, c-format msgid "Foreground = %s" msgstr "Oregroundfay = %s" -#: daemon/main.c:303 daemon/main.c:304 +#: daemon/main.c:304 daemon/main.c:305 msgid "true" msgstr "uetray" -#: daemon/main.c:303 daemon/main.c:304 +#: daemon/main.c:304 daemon/main.c:305 msgid "false" msgstr "alsefay" -#: daemon/main.c:304 +#: daemon/main.c:305 #, c-format msgid "Quiet = %s" msgstr "Uietqay = %s" -#: daemon/main.c:305 +#: daemon/main.c:306 #, c-format msgid "Verbosity = %d" msgstr "Erbosityvay = %d" -#: daemon/main.c:306 +#: daemon/main.c:307 #, c-format msgid "Log file = %s" msgstr "Oglay ilefay = %s" -#: daemon/main.c:307 +#: daemon/main.c:308 #, c-format msgid "Config file = %s" msgstr "Onfigcay ilefay = %s" -#: daemon/main.c:308 +#: daemon/main.c:309 #, c-format msgid "PID file = %s" msgstr "IDPay ilefay = %s" -#: daemon/main.c:309 +#: daemon/main.c:310 #, c-format msgid "Command socket = %s" msgstr "Ommandcay ocketsay = %s" -#: daemon/main.c:310 +#: daemon/main.c:311 #, c-format msgid "Notify socket = %s" msgstr "Otifynay ocketsay = %s" -#: daemon/main.c:321 +#: daemon/main.c:322 #, c-format msgid "Error %d blocking signals on child threads: %s" msgstr "Erroray %d ockingblay ignalssay onay ildchay eadsthray: %s" -#: daemon/main.c:338 +#: daemon/main.c:339 #, c-format msgid "Error %d unblocking signals on child threads: %s" msgstr "Erroray %d unblockingay ignalssay onay ildchay eadsthray: %s" -#: daemon/main.c:351 +#: daemon/main.c:352 msgid "Reloading X52 configuration" msgstr "Eloadingray X52 onfigurationcay" -#: daemon/main.c:358 +#: daemon/main.c:359 msgid "Saving X52 configuration to disk" msgstr "Avingsay X52 onfigurationcay otay iskday" -#: daemon/main.c:364 +#: daemon/main.c:365 #, c-format msgid "Received termination signal %s" msgstr "Eceivedray erminationtay ignalsay %s" -#: daemon/main.c:379 +#: daemon/main.c:381 msgid "Shutting down X52 daemon" msgstr "Uttingshay ownday X52 aemonday" diff --git a/tools/test_layout_allowlist_sync.py b/tools/test_layout_allowlist_sync.py new file mode 100755 index 0000000..e621ad9 --- /dev/null +++ b/tools/test_layout_allowlist_sync.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org) +# +# SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0 +"""Regression checks for layout HID allowlist and token maps (see vkm.h / layout_usage_allowlist.c).""" + +import pathlib +import re +import sys + + +def _emit_tap(test_cases): + """test_cases: iterable of (description, callable). Callable raises on failure.""" + case_list = list(test_cases) + print("TAP version 13") + print("1..%d" % len(case_list)) + failed = 0 + for i, (desc, fn) in enumerate(case_list, 1): + try: + fn() + except AssertionError as e: + failed += 1 + print("not ok %d - %s" % (i, desc)) + msg = str(e) if str(e) else "assertion failed" + for line in msg.splitlines(): + print("# %s" % line) + else: + print("ok %d - %s" % (i, desc)) + return 1 if failed else 0 + + +def main(): + root = pathlib.Path(__file__).resolve().parent.parent + sys.path.insert(0, str(root / "tools")) + + import x52compile_layout as x + + def check_allowed_set(): + expected = frozenset(range(0x04, 0x3A)) | frozenset((0x64,)) + assert x.ALLOWED_KEY_USAGES == expected, "%r != %r" % (x.ALLOWED_KEY_USAGES, expected) + + def check_allowlist_c_range(): + src = (root / "daemon" / "layout_usage_allowlist.c").read_text(encoding="utf-8") + assert "usage >= 0x04 && usage <= 0x39" in src, ( + "daemon/layout_usage_allowlist.c: expected main-block range check" + ) + + def check_allowlist_c_intl(): + src = (root / "daemon" / "layout_usage_allowlist.c").read_text(encoding="utf-8") + assert "usage == 0x64" in src, ( + "daemon/layout_usage_allowlist.c: expected INTL_BACKSLASH (0x64) check" + ) + + def check_vkm_modifier_symbols(): + vkm = (root / "vkm" / "vkm.h").read_text(encoding="utf-8") + for name in ( + "VKM_KEY_MOD_LCTRL", + "VKM_KEY_MOD_LSHIFT", + "VKM_KEY_MOD_LALT", + "VKM_KEY_MOD_LGUI", + "VKM_KEY_MOD_RCTRL", + "VKM_KEY_MOD_RSHIFT", + "VKM_KEY_MOD_RALT", + "VKM_KEY_MOD_RGUI", + ): + assert re.search(r"%s\s*=\s*\([^)]+\)" % re.escape(name), vkm), ( + "could not find %s in vkm.h" % name + ) + + def check_modifier_bits(): + assert x.MODIFIER_TOKEN_TO_BIT["LCTRL"] == 1 << 0 and x.MODIFIER_TOKEN_TO_BIT["RALT"] == 1 << 6, ( + "modifier map mismatch vs expected HID bit layout" + ) + + def check_tokens_in_allowlist(): + for _tok, usage in x.KEY_TOKEN_TO_USAGE.items(): + assert usage in x.ALLOWED_KEY_USAGES, ( + "token maps outside allowlist: %r -> %#x" % (_tok, usage) + ) + + def check_usage_from_key_token_a(): + assert x.usage_from_key_token("a") == 0x04 + + def check_usage_from_key_token_space(): + assert x.usage_from_key_token("SPACE") == 0x2C + + def check_modifier_mask_ctrl_shift(): + assert x.modifier_mask_from_tokens("CTRL+SHIFT") == (1 << 0) | (1 << 1) + + tests = ( + ("ALLOWED_KEY_USAGES matches main block + INTL_BACKSLASH", check_allowed_set), + ("layout_usage_allowlist.c main-block range", check_allowlist_c_range), + ("layout_usage_allowlist.c INTL_BACKSLASH (0x64)", check_allowlist_c_intl), + ("vkm.h modifier symbol names", check_vkm_modifier_symbols), + ("MODIFIER_TOKEN_TO_BIT LCTRL/RALT bits", check_modifier_bits), + ("KEY_TOKEN_TO_USAGE subset of allowlist", check_tokens_in_allowlist), + ("usage_from_key_token(a)", check_usage_from_key_token_a), + ("usage_from_key_token(SPACE)", check_usage_from_key_token_space), + ("modifier_mask_from_tokens(CTRL+SHIFT)", check_modifier_mask_ctrl_shift), + ) + + return _emit_tap(tests) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/test_x52compile_layout.py b/tools/test_x52compile_layout.py new file mode 100755 index 0000000..ac47132 --- /dev/null +++ b/tools/test_x52compile_layout.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org) +# +# SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0 +"""TAP tests for ``tools/x52compile_layout.py`` (v1 ``.x52l`` compiler).""" + +import pathlib +import struct +import subprocess +import sys +import tempfile +import zlib + + +def _emit_tap(test_cases): + """test_cases: iterable of (description, callable). Callable raises on failure.""" + case_list = list(test_cases) + print("TAP version 13") + print("1..%d" % len(case_list)) + failed = 0 + for i, (desc, fn) in enumerate(case_list, 1): + try: + fn() + except AssertionError as e: + failed += 1 + print("not ok %d - %s" % (i, desc)) + msg = str(e) if str(e) else "assertion failed" + for line in msg.splitlines(): + print("# %s" % line) + else: + print("ok %d - %s" % (i, desc)) + return 1 if failed else 0 + + +def _x52_crc32_verify(buf): + """CRC-32 over file with checksum field (bytes 12..15) taken as zero.""" + return zlib.crc32(buf[:12] + b"\0\0\0\0" + buf[16:]) & 0xFFFFFFFF + + +def main(): + root = pathlib.Path(__file__).resolve().parent.parent + sys.path.insert(0, str(root / "tools")) + + import x52compile_layout as x + + def check_layout_compile_error_is_syntax_error(): + assert issubclass(x.LayoutCompileError, SyntaxError) + + def check_parse_chord_simple(): + mod, usage = x.parse_chord("a") + assert mod == 0 and usage == 0x04 + + def check_parse_chord_shifted_letter(): + mod, usage = x.parse_chord("SHIFT+a") + assert mod == (1 << 1) and usage == 0x04 + + def check_parse_chord_space_token(): + mod, usage = x.parse_chord("SPACE") + assert mod == 0 and usage == 0x2C + + def check_compile_minimal_header_and_entry(): + src = "name: t\na a\n" + buf = x.compile_layout_source("minimal.layout", src) + assert buf[0:4] == b"X52L" + assert struct.unpack_from("!H", buf, 4)[0] == x.FORMAT_VERSION + assert struct.unpack_from("!H", buf, 6)[0] == 0 + limit = struct.unpack_from("!I", buf, 8)[0] + assert limit == 98 + assert len(buf) == x.HEADER_BYTES + 2 * limit + off = x.HEADER_BYTES + 2 * ord('a') + assert buf[off] == 0 and buf[off + 1] == 0x04 + stored = struct.unpack_from("!I", buf, 12)[0] + assert stored == _x52_crc32_verify(buf) + + def check_compile_name_and_description_fields(): + src = "name: demo\ndescription: hello\nU+0020 SPACE\n" + buf = x.compile_layout_source("meta.layout", src) + name_end = buf.index(b"\0", 16) + assert buf[16:name_end] == b"demo" + desc_end = buf.index(b"\0", 48) + assert buf[48:desc_end] == b"hello" + space_off = x.HEADER_BYTES + 2 * 0x20 + assert buf[space_off + 1] == 0x2C + + def check_compile_shift_uppercase_mapping(): + src = "name: u\nA SHIFT+a\n" + buf = x.compile_layout_source("up.layout", src) + limit = struct.unpack_from("!I", buf, 8)[0] + assert limit == ord("A") + 1 + off = x.HEADER_BYTES + 2 * ord("A") + assert buf[off] == (1 << 1) and buf[off + 1] == 0x04 + + def check_reject_missing_name(): + try: + x.compile_layout_source("bad.layout", "a a\n") + except x.LayoutCompileError as e: + assert "missing name" in str(e) + else: + assert False, "expected LayoutCompileError" + + def check_reject_no_mappings(): + try: + x.compile_layout_source("bad.layout", "name: x\n") + except x.LayoutCompileError as e: + assert "no mappings" in str(e) + else: + assert False, "expected LayoutCompileError" + + def check_reject_duplicate_codepoint(): + src = "name: x\na a\na b\n" + try: + x.compile_layout_source("dup.layout", src) + except x.LayoutCompileError as e: + assert "duplicate" in str(e) and "U+0061" in str(e) + assert "line" in str(e) + else: + assert False, "expected LayoutCompileError" + + def check_reject_invalid_scalar(): + src = "name: x\nU+D800 SPACE\n" + try: + x.compile_layout_source("surrogate.layout", src) + except x.LayoutCompileError as e: + assert "invalid Unicode scalar" in str(e) + assert e.filename == "surrogate.layout" + assert e.lineno == 2 + else: + assert False, "expected LayoutCompileError" + + def check_reject_scalar_above_unicode_max(): + src = "name: x\nU+110000 SPACE\n" + try: + x.compile_layout_source("high.layout", src) + except x.LayoutCompileError as e: + assert "invalid Unicode scalar" in str(e) + else: + assert False, "expected LayoutCompileError" + + def check_reject_bad_chord_line(): + src = "name: x\na NOT_A_KEY\n" + try: + x.compile_layout_source("chord.layout", src) + except x.LayoutCompileError as e: + assert "unknown key token" in str(e) + assert e.lineno == 2 + else: + assert False, "expected LayoutCompileError" + + def check_reject_oversize_name(): + long_name = "n" * 32 + src = "name: %s\na a\n" % long_name + try: + x.compile_layout_source("long.layout", src) + except x.LayoutCompileError as e: + assert "exceeds" in str(e) and "UTF-8" in str(e) + else: + assert False, "expected LayoutCompileError" + + def check_default_allows_name_at_31_bytes(): + name = "n" * 31 + src = "name: %s\na a\n" % name + buf = x.compile_layout_source("ok31.layout", src) + name_end = buf.index(b"\0", 16) + assert buf[16:name_end] == name.encode("utf-8") + assert struct.unpack_from("!H", buf, 6)[0] == 0 + + def check_default_allows_description_at_63_bytes(): + desc = "d" * 63 + src = "name: ok\ndescription: %s\na a\n" % desc + buf = x.compile_layout_source("ok63.layout", src) + desc_end = buf.index(b"\0", 48) + assert buf[48:desc_end] == desc.encode("utf-8") + assert struct.unpack_from("!H", buf, 6)[0] == 0 + + def check_reject_invalid_layout_name(): + for bad in ("bad/name", "a b", "caf\u00e9", "dot."): + src = "name: %s\na a\n" % bad + try: + x.compile_layout_source("name.layout", src) + except x.LayoutCompileError as e: + assert "ASCII letters" in str(e) or "hyphen" in str(e) + else: + assert False, "expected LayoutCompileError for %r" % bad + + def check_compile_name_hyphen_underscore(): + src = "name: a-b_9\na a\n" + buf = x.compile_layout_source("hy.layout", src) + name_end = buf.index(b"\0", 16) + assert buf[16:name_end] == b"a-b_9" + + def check_reject_oversize_description(): + long_desc = "d" * 64 + src = "name: x\ndescription: %s\na a\n" % long_desc + try: + x.compile_layout_source("longdesc.layout", src) + except x.LayoutCompileError as e: + assert "description exceeds" in str(e) + else: + assert False, "expected LayoutCompileError" + + def check_truncate_metadata_sets_name_flag_and_field(): + long_name = "n" * 32 + src = "name: %s\na a\n" % long_name + buf = x.compile_layout_source("trunc.layout", src, truncate_metadata=True) + assert struct.unpack_from("!H", buf, 6)[0] == x.FLAG_NAME_TRUNCATED + name_end = buf.index(b"\0", 16) + assert buf[16:name_end] == (b"n" * 31) + assert struct.unpack_from("!I", buf, 12)[0] == _x52_crc32_verify(buf) + + def check_truncate_metadata_sets_description_flag(): + long_desc = "d" * 64 + src = "name: ok\ndescription: %s\na a\n" % long_desc + buf = x.compile_layout_source("truncdesc.layout", src, truncate_metadata=True) + assert struct.unpack_from("!H", buf, 6)[0] == x.FLAG_DESCRIPTION_TRUNCATED + desc_end = buf.index(b"\0", 48) + assert buf[48:desc_end] == (b"d" * 63) + + def check_truncate_metadata_sets_both_flags_when_both_long(): + long_name = "n" * 32 + long_desc = "d" * 64 + src = "name: %s\ndescription: %s\na a\n" % (long_name, long_desc) + buf = x.compile_layout_source("bothtrunc.layout", src, truncate_metadata=True) + f = struct.unpack_from("!H", buf, 6)[0] + assert f == (x.FLAG_NAME_TRUNCATED | x.FLAG_DESCRIPTION_TRUNCATED) + name_end = buf.index(b"\0", 16) + assert buf[16:name_end] == (b"n" * 31) + desc_end = buf.index(b"\0", 48) + assert buf[48:desc_end] == (b"d" * 63) + assert struct.unpack_from("!I", buf, 12)[0] == _x52_crc32_verify(buf) + + def check_truncate_metadata_utf8_safe_no_split(): + # Names are ASCII-only; exercise UTF-8 trim on description (32 × "é" → 63-byte cap). + long_desc = "\u00e9" * 32 + src = "name: ok\ndescription: %s\na a\n" % long_desc + buf = x.compile_layout_source("utf8.layout", src, truncate_metadata=True) + assert struct.unpack_from("!H", buf, 6)[0] == x.FLAG_DESCRIPTION_TRUNCATED + desc_end = buf.index(b"\0", 48) + assert buf[48:desc_end].decode("utf-8") == "\u00e9" * 31 + + def check_data_layouts_us_layout_compiles(): + us_path = root / "data" / "layouts" / "us.layout" + if not us_path.is_file(): + return + text = us_path.read_text(encoding="utf-8") + buf = x.compile_layout_source(str(us_path), text) + assert buf[0:4] == b"X52L" + limit = struct.unpack_from("!I", buf, 8)[0] + assert len(buf) == x.HEADER_BYTES + 2 * limit + assert struct.unpack_from("!I", buf, 12)[0] == _x52_crc32_verify(buf) + + def check_compile_layout_file_rejects_mismatched_output_basename(): + with tempfile.TemporaryDirectory() as td: + src = pathlib.Path(td) / "in.layout" + src.write_text("name: us\na a\n", encoding="utf-8") + bad = pathlib.Path(td) / "other.x52l" + try: + x.compile_layout_file(str(src), str(bad)) + except x.LayoutCompileError as e: + assert "us.x52l" in str(e) and "other.x52l" in str(e) + else: + assert False, "expected LayoutCompileError" + + def check_compile_layout_file_accepts_matching_basename(): + with tempfile.TemporaryDirectory() as td: + src = pathlib.Path(td) / "in.layout" + src.write_text("name: us\na a\n", encoding="utf-8") + out = pathlib.Path(td) / "us.x52l" + x.compile_layout_file(str(src), str(out)) + assert out.is_file() + assert out.stat().st_size == x.HEADER_BYTES + 2 * 98 + + def check_compile_layout_file_truncated_name_sets_output_basename(): + long_name = "n" * 32 + with tempfile.TemporaryDirectory() as td: + src = pathlib.Path(td) / "in.layout" + src.write_text("name: %s\na a\n" % long_name, encoding="utf-8") + # Stored name is 31 × "n" → file must be nnn... (31) + .x52l + out = pathlib.Path(td) / ("n" * 31 + ".x52l") + x.compile_layout_file(str(src), str(out), truncate_metadata=True) + assert out.is_file() + + def check_cli_truncate_metadata_sets_flags_in_output(): + long_name = "n" * 32 + long_desc = "d" * 64 + script = root / "tools" / "x52compile_layout.py" + with tempfile.TemporaryDirectory() as td: + td = pathlib.Path(td) + src = td / "in.layout" + src.write_text( + "name: %s\ndescription: %s\na a\n" % (long_name, long_desc), + encoding="utf-8", + ) + out = td / ("n" * 31 + ".x52l") + r = subprocess.run( + [sys.executable, str(script), "--truncate-metadata", str(src), str(out)], + cwd=str(root), + capture_output=True, + text=True, + check=False, + ) + assert r.returncode == 0, r.stderr + buf = out.read_bytes() + f = struct.unpack_from("!H", buf, 6)[0] + assert f == (x.FLAG_NAME_TRUNCATED | x.FLAG_DESCRIPTION_TRUNCATED) + assert struct.unpack_from("!I", buf, 12)[0] == _x52_crc32_verify(buf) + + tests = ( + ("LayoutCompileError subclasses SyntaxError", check_layout_compile_error_is_syntax_error), + ("parse_chord(a)", check_parse_chord_simple), + ("parse_chord(SHIFT+a)", check_parse_chord_shifted_letter), + ("parse_chord(SPACE)", check_parse_chord_space_token), + ("compile minimal layout: header, size, entry, CRC", check_compile_minimal_header_and_entry), + ("compile name/description and U+ mapping", check_compile_name_and_description_fields), + ("compile SHIFT+a for uppercase letter", check_compile_shift_uppercase_mapping), + ("reject missing name:", check_reject_missing_name), + ("reject no mappings", check_reject_no_mappings), + ("reject duplicate codepoint", check_reject_duplicate_codepoint), + ("reject surrogate scalar U+D800", check_reject_invalid_scalar), + ("reject scalar U+110000", check_reject_scalar_above_unicode_max), + ("reject unknown key token with lineno", check_reject_bad_chord_line), + ("reject name longer than 31 UTF-8 bytes", check_reject_oversize_name), + ("default: name length 31 UTF-8 bytes accepted", check_default_allows_name_at_31_bytes), + ("default: description length 63 UTF-8 bytes accepted", check_default_allows_description_at_63_bytes), + ("reject layout name outside ASCII alnum hyphen underscore", check_reject_invalid_layout_name), + ("compile name with hyphen and underscore", check_compile_name_hyphen_underscore), + ("reject description longer than 63 UTF-8 bytes", check_reject_oversize_description), + ("--truncate-metadata: name flag and 31-byte field", check_truncate_metadata_sets_name_flag_and_field), + ("truncate_metadata: description flag and 63-byte field", check_truncate_metadata_sets_description_flag), + ("truncate_metadata: both long name and description set both flags", check_truncate_metadata_sets_both_flags_when_both_long), + ("truncate_metadata: UTF-8 safe trim (no split codepoint)", check_truncate_metadata_utf8_safe_no_split), + ("CLI --truncate-metadata sets composite flags and valid CRC", check_cli_truncate_metadata_sets_flags_in_output), + ("data/layouts/us.layout compiles with valid CRC", check_data_layouts_us_layout_compiles), + ("compile_layout_file rejects wrong output basename", check_compile_layout_file_rejects_mismatched_output_basename), + ("compile_layout_file writes when basename matches name:", check_compile_layout_file_accepts_matching_basename), + ("compile_layout_file: truncated stored name defines output basename", check_compile_layout_file_truncated_name_sets_output_basename), + ) + + return _emit_tap(tests) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/x52compile_layout.py b/tools/x52compile_layout.py new file mode 100755 index 0000000..26b5daa --- /dev/null +++ b/tools/x52compile_layout.py @@ -0,0 +1,436 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org) +# +# SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0 +"""Keyboard layout source (``.layout``) compiler and HID token maps. + +Compiles human-readable ``.layout`` files into v1 ``.x52l`` binaries (see +``daemon/layout_format.h``). The output file basename must be +``{name}.x52l`` with ``name`` taken from the ``name:`` metadata (after any +``--truncate-metadata`` trimming), matching ``Profiles.KeyboardLayout``. +The non-modifier key set matches +``x52_layout_usage_key_allowed`` in ``daemon/layout_usage_allowlist.c`` and +``vkm_key`` / ``vkm_key_modifiers`` in ``vkm/vkm.h``. +""" + +import argparse +import os +import re +import struct +import sys +import zlib +from typing import Dict, List, Optional, Tuple + +# --------------------------------------------------------------------------- +# HID page 0x07 usages allowed as the layout RHS key (one byte, non-modifier). +# Main block: 0x04 (A) through 0x39 (Caps Lock); plus ISO third row backslash (0x64). +# --------------------------------------------------------------------------- + +ALLOWED_KEY_USAGES = frozenset(range(0x04, 0x3A)) | frozenset((0x64,)) + + +def _letter_usage(ch: str) -> int: + o = ord(ch) + if not (ord("A") <= o <= ord("Z") or ord("a") <= o <= ord("z")): + raise ValueError(ch) + return 0x04 + (ord(ch.upper()) - ord("A")) + + +def _build_key_token_map() -> Dict[str, int]: + m: Dict[str, int] = {} + + for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ": + u = _letter_usage(c) + m[c] = u + m[c.lower()] = u + + digit_usages = [0x1E, 0x1F, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27] + for d, u in zip("1234567890", digit_usages): + m[d] = u + + named = { + "ENTER": 0x28, + "ESCAPE": 0x29, + "ESC": 0x29, + "BACKSPACE": 0x2A, + "TAB": 0x2B, + "SPACE": 0x2C, + "MINUS": 0x2D, + "EQUAL": 0x2E, + "LEFT_BRACKET": 0x2F, + "RIGHT_BRACKET": 0x30, + "BACKSLASH": 0x31, + "NONUS_HASH": 0x32, + "SEMICOLON": 0x33, + "APOSTROPHE": 0x34, + "GRAVE_ACCENT": 0x35, + "GRAVE": 0x35, + "COMMA": 0x36, + "PERIOD": 0x37, + "SLASH": 0x38, + "CAPS_LOCK": 0x39, + "CAPS": 0x39, + "INTL_BACKSLASH": 0x64, + } + m.update(named) + + bad = {k: v for k, v in m.items() if v not in ALLOWED_KEY_USAGES} + if bad: + raise RuntimeError("internal error: token maps to disallowed usage: %r" % bad) + return m + + +KEY_TOKEN_TO_USAGE: Dict[str, int] = _build_key_token_map() + +# Matches vkm_key_modifiers in vkm/vkm.h (HID modifier byte bits). +MODIFIER_TOKEN_TO_BIT: Dict[str, int] = { + "LCTRL": 1 << 0, + "LSHIFT": 1 << 1, + "LALT": 1 << 2, + "LGUI": 1 << 3, + "RCTRL": 1 << 4, + "RSHIFT": 1 << 5, + "RALT": 1 << 6, + "RGUI": 1 << 7, + # Convenience aliases — same as VKM_KEY_MOD_* macros in vkm.h + "CTRL": 1 << 0, + "SHIFT": 1 << 1, + "ALT": 1 << 2, + "GUI": 1 << 3, +} + +_MOD_KNOWN_BITS = 0 +for _b in MODIFIER_TOKEN_TO_BIT.values(): + _MOD_KNOWN_BITS |= _b +if _MOD_KNOWN_BITS != 0xFF: + raise RuntimeError("internal error: modifier map must cover HID bits 0-7") + + +def modifier_mask_from_tokens(tokens: str) -> int: + """OR together modifier bits for plus-separated tokens (e.g. ``'CTRL+SHIFT'``).""" + mask = 0 + for raw in tokens.replace(" ", "").split("+"): + if not raw: + continue + try: + bit = MODIFIER_TOKEN_TO_BIT[raw.upper()] + except KeyError as e: + raise KeyError("unknown modifier token %r" % raw) from e + mask |= bit + return mask + + +def usage_from_key_token(token: str) -> int: + """Resolve a single key token string to a HID usage (page 0x07).""" + key = token.strip() + if key.isalpha() and len(key) == 1: + u = KEY_TOKEN_TO_USAGE[key] + else: + u = KEY_TOKEN_TO_USAGE[key.upper()] + return u + + +class LayoutCompileError(SyntaxError): + """Invalid layout source or oversize metadata. + + Subclasses :class:`SyntaxError` so errors can carry ``filename`` and ``lineno`` + and render as ``message (path, line N)``. Pass ``path`` and ``lineno`` only + when ``message`` is the detail alone; for whole-file problems pass one fully + formatted string and leave location arguments unset. + """ + + def __init__(self, message, path=None, lineno=None): + if path is not None and lineno is not None: + SyntaxError.__init__(self, message, (path, lineno, None, None)) + else: + SyntaxError.__init__(self, message) + + +HEADER_BYTES = 128 +FORMAT_VERSION = 1 +CODEPOINT_LIMIT_MAX = 0x110000 +NAME_MAX_UTF8 = 31 +DESC_MAX_UTF8 = 63 + +# v1 header flags (big-endian on disk); must match daemon/layout_format.h +FLAG_NAME_TRUNCATED = 1 +FLAG_DESCRIPTION_TRUNCATED = 2 +FLAGS_KNOWN_V1 = FLAG_NAME_TRUNCATED | FLAG_DESCRIPTION_TRUNCATED + +# Fixed 128-byte header: magic, version, flags, codepoint_limit, checksum (BE). +_HEADER_PREFIX = struct.Struct("!4sHHII") + +# Layout basename / ``name:`` field: matches safe config tokens (see Profiles.KeyboardLayout). +_LAYOUT_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$") + + +def _validate_layout_name(name: str, path: str) -> None: + if not _LAYOUT_NAME_RE.fullmatch(name): + raise LayoutCompileError( + "%s: layout name must contain only ASCII letters, digits, hyphen, or underscore" + % path + ) + + +def _utf8_trim_to_max_bytes(text: str, max_bytes: int) -> Tuple[str, bool]: + """Return ``(s, truncated)`` with ``len(s.encode('utf-8')) <= max_bytes``.""" + raw = text.encode("utf-8") + if len(raw) <= max_bytes: + return text, False + cut = raw[:max_bytes] + while cut: + try: + return cut.decode("utf-8"), True + except UnicodeDecodeError: + cut = cut[:-1] + return "", True + + +def parse_chord( + chord: str, path: Optional[str] = None, lineno: Optional[int] = None +) -> Tuple[int, int]: + """Return ``(modifiers_byte, usage)`` for a plus-separated HID chord. + + If ``path`` and ``lineno`` are given, :exc:`LayoutCompileError` includes that + location (via :class:`SyntaxError`); omit them for programmatic chord checks. + """ + parts = [p.strip() for p in chord.replace(" ", "").split("+") if p.strip()] + if not parts: + raise LayoutCompileError("empty chord", path, lineno) + key_tok = parts[-1] + mod_parts = parts[:-1] + try: + mod = modifier_mask_from_tokens("+".join(mod_parts)) if mod_parts else 0 + except KeyError as e: + raise LayoutCompileError("unknown modifier in %r" % chord, path, lineno) from e + try: + usage = usage_from_key_token(key_tok) + except KeyError as e: + raise LayoutCompileError("unknown key token in %r" % chord, path, lineno) from e + if usage not in ALLOWED_KEY_USAGES: + raise LayoutCompileError("disallowed key usage for %r" % chord, path, lineno) + return mod & 0xFF, usage & 0xFF + + +_U_LINE = re.compile(r"^[Uu]\+([0-9A-Fa-f]{1,6})\s+(.+)$") +_META_NAME = re.compile(r"(?i)^name:\s*(.*)$") +_META_DESC = re.compile(r"(?i)^description:\s*(.*)$") + + +def _parse_layout_source( + path: str, text: str +) -> Tuple[str, str, List[Tuple[int, int, str]]]: + """First pass: metadata and mapping lines. Returns ``(name, description, raw_maps)``.""" + name = "" # type: str + description = "" # type: str + raw_maps = [] # type: List[Tuple[int, int, str]] + + for lineno, line in enumerate(text.splitlines(), 1): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + mn = _META_NAME.match(stripped) + if mn: + name = mn.group(1).strip() + continue + md = _META_DESC.match(stripped) + if md: + description = md.group(1).strip() + continue + cp, chord = _parse_mapping_line(path, lineno, stripped) + raw_maps.append((cp, lineno, chord)) + + if not name: + raise LayoutCompileError("%s: missing name: metadata" % path) + + return name, description, raw_maps + + +def _layout_stored_name_from_bytes(buf: bytes) -> str: + """UTF-8 layout name as stored in the v1 header field (offset 16, up to first NUL).""" + end = buf.index(0, 16, 48) + return buf[16:end].decode("utf-8") + + +def _parse_mapping_line(path: str, lineno: int, line: str) -> Tuple[int, str]: + s = line.strip() + m = _U_LINE.match(s) + if m: + cp = int(m.group(1), 16) + if cp < 0 or cp > 0x10FFFF or (cp >= 0xD800 and cp <= 0xDFFF): + raise LayoutCompileError("invalid Unicode scalar", path, lineno) + return cp, m.group(2).strip() + if not s: + raise LayoutCompileError("empty line", path, lineno) + ch = s[0] + rest = s[1:].lstrip() + if not rest: + raise LayoutCompileError("missing chord after character", path, lineno) + return ord(ch), rest + + +def compile_layout_source(path: str, text: str, *, truncate_metadata: bool = False) -> bytes: + """Parse ``.layout`` source and return ``.x52l`` bytes. + + ``codepoint_limit`` is ``max_mapped_scalar + 1`` (dense table size). + + If ``truncate_metadata`` is false (default), oversize ``name:`` / ``description:`` + values are errors. If true, UTF-8 strings are trimmed to the field limits + (no split codepoints) and ``FLAG_NAME_TRUNCATED`` / ``FLAG_DESCRIPTION_TRUNCATED`` + are set in the on-disk ``flags`` field. + """ + name, description, raw_maps = _parse_layout_source(path, text) + + flags = 0 + if truncate_metadata: + name, name_t = _utf8_trim_to_max_bytes(name, NAME_MAX_UTF8) + if name_t: + flags |= FLAG_NAME_TRUNCATED + if not name: + raise LayoutCompileError("%s: name: empty after truncation to metadata limit" % path) + description, desc_t = _utf8_trim_to_max_bytes(description, DESC_MAX_UTF8) + if desc_t: + flags |= FLAG_DESCRIPTION_TRUNCATED + else: + name_b = name.encode("utf-8") + if len(name_b) > NAME_MAX_UTF8: + raise LayoutCompileError( + "%s: layout name exceeds %d UTF-8 bytes" % (path, NAME_MAX_UTF8) + ) + desc_b = description.encode("utf-8") + if len(desc_b) > DESC_MAX_UTF8: + raise LayoutCompileError( + "%s: description exceeds %d UTF-8 bytes" % (path, DESC_MAX_UTF8) + ) + + _validate_layout_name(name, path) + + name_b = name.encode("utf-8") + desc_b = description.encode("utf-8") + assert len(name_b) <= NAME_MAX_UTF8 + assert len(desc_b) <= DESC_MAX_UTF8 + if flags & ~FLAGS_KNOWN_V1: + raise RuntimeError("internal error: unknown layout flags bits") + + if not raw_maps: + raise LayoutCompileError("%s: no mappings" % path) + + by_cp = {} # type: Dict[int, Tuple[int, int, int]] + for cp, lineno, chord in raw_maps: + if cp in by_cp: + raise LayoutCompileError( + "duplicate mapping for U+%04X (also at line %d)" % (cp, by_cp[cp][2]), + path, + lineno, + ) + mod, usage = parse_chord(chord, path, lineno) + if usage == 0: + raise LayoutCompileError("chord must not resolve to usage 0", path, lineno) + by_cp[cp] = (mod, usage, lineno) + + max_cp = max(by_cp.keys()) + limit = max_cp + 1 + if limit > CODEPOINT_LIMIT_MAX: + raise LayoutCompileError("%s: codepoint_limit would exceed 0x110000" % path) + + body = limit * 2 + buf = bytearray(HEADER_BYTES + body) + + _HEADER_PREFIX.pack_into(buf, 0, b"X52L", FORMAT_VERSION, flags, limit, 0) + buf[16 : 16 + len(name_b)] = name_b + buf[48 : 48 + len(desc_b)] = desc_b + + base = HEADER_BYTES + for cp, (mod, usage, _) in by_cp.items(): + off = base + 2 * cp + buf[off] = mod + buf[off + 1] = usage + + crc_input = bytes(buf[0:12]) + b"\0\0\0\0" + bytes(buf[16:]) + crc = zlib.crc32(crc_input, 0) & 0xFFFFFFFF + struct.pack_into("!I", buf, 12, crc) + return bytes(buf) + + +def compile_layout_file( + src_path: str, dst_path: str, *, truncate_metadata: bool = False +) -> None: + """Compile ``src_path`` to ``dst_path``. + + The destination basename must be ``{name}.x52l`` where ``name`` is the layout name + stored in the binary (from the ``name:`` line, after any ``--truncate-metadata`` + trimming) so it matches ``Profiles.KeyboardLayout``. + """ + with open(src_path, "r", encoding="utf-8-sig") as f: + text = f.read() + out = compile_layout_source(src_path, text, truncate_metadata=truncate_metadata) + stored_name = _layout_stored_name_from_bytes(out) + expected_base = stored_name + ".x52l" + if os.path.basename(dst_path) != expected_base: + raise LayoutCompileError( + "%s: output file must be named %r (from layout name:), got %r" + % (src_path, expected_base, os.path.basename(dst_path)) + ) + with open(dst_path, "wb") as f: + f.write(out) + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser( + description=( + "Compile human-readable .layout source into a v1 .x52l binary " + "(see daemon/layout_format.h)." + ) + ) + parser.add_argument( + "--truncate-metadata", + action="store_true", + help=( + "Trim oversize name:/description: to header field limits and set truncation flags." + ), + ) + parser.add_argument( + "source", + metavar="layout.source", + help="Input .layout file.", + ) + parser.add_argument( + "output", + nargs="?", + metavar="out.x52l", + help=( + "Output path; basename must be {name}.x52l from layout metadata. " + "If omitted, write ./{name}.x52l in the current directory." + ), + ) + parsed = parser.parse_args(argv) + + truncate_metadata = bool(parsed.truncate_metadata) + src_path = parsed.source + + try: + if parsed.output is None: + with open(src_path, "r", encoding="utf-8-sig") as f: + text = f.read() + out = compile_layout_source( + src_path, text, truncate_metadata=truncate_metadata + ) + stored_name = _layout_stored_name_from_bytes(out) + dst_path = os.path.join(os.getcwd(), stored_name + ".x52l") + with open(dst_path, "wb") as f: + f.write(out) + else: + compile_layout_file( + src_path, parsed.output, truncate_metadata=truncate_metadata + ) + except LayoutCompileError as e: + sys.stderr.write("%s\n" % e) + return 1 + except OSError as e: + sys.stderr.write("%s\n" % e) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main())