diff --git a/.github/actions/build-linux/action.yml b/.github/actions/build-linux/action.yml index 6fc9706..34a84a0 100644 --- a/.github/actions/build-linux/action.yml +++ b/.github/actions/build-linux/action.yml @@ -21,12 +21,20 @@ inputs: description: Package headers, the static library, consumer docs, LICENSE, and vendor/licenses into packages/ required: false default: "false" + package-integration-tests: + description: Package integration binaries, consumer docs, licenses, and Linux helpers into packages/ + required: false + default: "false" upload-binaries: description: Upload built test binaries as artifacts required: false default: "false" + upload-integration-package: + description: Upload packaged integration test zip artifacts + required: false + default: "false" artifact-name: - description: Artifact name (required when upload-binaries=true) + description: Artifact name used when uploading outputs required: false version: description: Release version override (otherwise uses tag ref) @@ -57,7 +65,7 @@ runs: run: python build.py clean - name: Build library - if: ${{ inputs.run-tests != 'true' && inputs.build-integration-tests != 'true' && inputs.package-output != 'true' }} + if: ${{ inputs.run-tests != 'true' && inputs.build-integration-tests != 'true' && inputs.package-output != 'true' && inputs.package-integration-tests != 'true' }} shell: bash run: python build.py build @@ -116,6 +124,17 @@ runs: fi python build.py package --version "$TAG" --arch '${{ inputs.arch }}' + - name: Package integration tests (Linux) + if: ${{ inputs.package-integration-tests == 'true' }} + shell: bash + run: | + set -euo pipefail + TAG='${{ inputs.version }}' + if [ -z "$TAG" ]; then + TAG="dev" + fi + python build.py package-integration-tests --version "$TAG" --arch '${{ inputs.arch }}' + - name: Upload release package if: ${{ inputs.package-output == 'true' }} uses: actions/upload-artifact@v4 @@ -130,3 +149,11 @@ runs: name: ${{ inputs.artifact-name }} path: | build/linux/bin/test_* + + - name: Upload integration test package + if: ${{ inputs.upload-integration-package == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact-name }} + path: | + packages/*.zip diff --git a/.github/actions/build-windows-mingw/action.yml b/.github/actions/build-windows-mingw/action.yml index b2b0cc2..6a6195f 100644 --- a/.github/actions/build-windows-mingw/action.yml +++ b/.github/actions/build-windows-mingw/action.yml @@ -21,12 +21,20 @@ inputs: description: Package headers, the static library, consumer docs, LICENSE, and vendor/licenses into packages/ required: false default: "false" + package-integration-tests: + description: Package integration binaries, consumer docs, and licenses into packages/ + required: false + default: "false" upload-binaries: description: Upload built test binaries as artifacts required: false default: "false" + upload-integration-package: + description: Upload packaged integration test zip artifacts + required: false + default: "false" artifact-name: - description: Artifact name (required when upload-binaries=true) + description: Artifact name used when uploading outputs required: false version: description: Release version override (otherwise uses tag ref) @@ -45,7 +53,7 @@ runs: run: python build.py clean - name: Build library - if: ${{ inputs.run-tests != 'true' && inputs.build-integration-tests != 'true' && inputs.package-output != 'true' }} + if: ${{ inputs.run-tests != 'true' && inputs.build-integration-tests != 'true' && inputs.package-output != 'true' && inputs.package-integration-tests != 'true' }} shell: pwsh run: python build.py build @@ -72,6 +80,16 @@ runs: } python build.py package --version $tag --arch "${{ inputs.arch }}" + - name: Package integration tests (Windows) + if: ${{ inputs.package-integration-tests == 'true' }} + shell: pwsh + run: | + $tag = "${{ inputs.version }}" + if (-not $tag) { + $tag = "dev" + } + python build.py package-integration-tests --version $tag --arch "${{ inputs.arch }}" + - name: Upload release package if: ${{ inputs.package-output == 'true' }} uses: actions/upload-artifact@v4 @@ -86,3 +104,11 @@ runs: name: ${{ inputs.artifact-name }} path: | build/windows/bin/test_*.exe + + - name: Upload integration test package + if: ${{ inputs.upload-integration-package == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact-name }} + path: | + packages/*.zip diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 89fb552..e8cceb3 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -7,6 +7,9 @@ on: workflow_dispatch: # no inputs +env: + INTEGRATION_PACKAGE_VERSION: run-${{ github.run_id }} + permissions: contents: write @@ -19,10 +22,10 @@ jobs: - uses: ./.github/actions/build-linux with: arch: x64 - build-tests: "true" - build-integration-tests: "true" - upload-binaries: "true" - artifact-name: integration-test-binary-ubuntu-latest-x64 + package-integration-tests: "true" + upload-integration-package: "true" + artifact-name: integration-test-package-ubuntu-latest-x64 + version: ${{ env.INTEGRATION_PACKAGE_VERSION }} build-windows-x64: name: Build integration binaries (Windows x64) @@ -32,10 +35,10 @@ jobs: - uses: ./.github/actions/build-windows-mingw with: arch: x64 - build-tests: "true" - build-integration-tests: "true" - upload-binaries: "true" - artifact-name: integration-test-binary-windows-latest-x64 + package-integration-tests: "true" + upload-integration-package: "true" + artifact-name: integration-test-package-windows-latest-x64 + version: ${{ env.INTEGRATION_PACKAGE_VERSION }} release: name: Create integration test release @@ -58,28 +61,33 @@ jobs: - name: Prepare release assets run: | + set -euo pipefail mkdir -p release-assets if [ ! -d artifacts ]; then echo "No artifacts downloaded; skipping asset collection." exit 0 fi - while IFS= read -r -d '' file; do - artifact_dir=$(dirname "$file") - os_arch=$(basename "$artifact_dir" | sed 's/^integration-test-binary-//') - if [ -z "$os_arch" ]; then - os_arch=unknown - fi - artifact_name=$(basename "$file") - ext="${artifact_name##*.}" - if [ "$ext" = "$artifact_name" ]; then - base="$artifact_name" - ext="" - else - base="${artifact_name%.*}" - ext=".$ext" + + expected=( + "axidev-io-integration-tests-${INTEGRATION_PACKAGE_VERSION}-linux-x64.zip" + "axidev-io-integration-tests-${INTEGRATION_PACKAGE_VERSION}-windows-x64.zip" + ) + + missing=0 + for name in "${expected[@]}"; do + src="$(find artifacts -type f -name "$name" -print -quit || true)" + if [ -z "$src" ]; then + echo "::error::Missing expected integration-test asset: $name" + missing=1 + continue fi - cp "$file" "release-assets/${base}-${os_arch}${ext}" - done < <(find artifacts -type f \( -name 'test_integration_*' -o -name 'test_integration_*.exe' \) ! -name '*.o' ! -name '*.obj' -print0) + cp "$src" "release-assets/$name" + done + + if [ "$missing" -ne 0 ]; then + echo "::error::One or more integration-test assets are missing." + exit 1 + fi - name: Check for release assets id: check-assets @@ -90,6 +98,31 @@ jobs: echo "has_assets=false" >> $GITHUB_OUTPUT fi + - name: Verify packaged integration test assets + if: steps.check-assets.outputs.has_assets == 'true' + run: | + set -euo pipefail + + linux_asset="release-assets/axidev-io-integration-tests-${INTEGRATION_PACKAGE_VERSION}-linux-x64.zip" + windows_asset="release-assets/axidev-io-integration-tests-${INTEGRATION_PACKAGE_VERSION}-windows-x64.zip" + + unzip -Z1 "$linux_asset" | grep -Fx 'bin/test_integration_sender' + unzip -Z1 "$linux_asset" | grep -Fx 'bin/test_integration_listener' + unzip -Z1 "$linux_asset" | grep -Fx 'scripts/setup_uinput_permissions.sh' + unzip -Z1 "$linux_asset" | grep -Fx 'docs/consumers/README.md' + unzip -Z1 "$linux_asset" | grep -Fx 'LICENSE' + unzip -Z1 "$linux_asset" | grep -Fx 'vendor/licenses/stb.txt' + + zipinfo -l "$linux_asset" | awk '{print $1 " " $NF}' | grep -Fx -- '-rwxr-xr-x bin/test_integration_sender' + zipinfo -l "$linux_asset" | awk '{print $1 " " $NF}' | grep -Fx -- '-rwxr-xr-x bin/test_integration_listener' + zipinfo -l "$linux_asset" | awk '{print $1 " " $NF}' | grep -Fx -- '-rwxr-xr-x scripts/setup_uinput_permissions.sh' + + unzip -Z1 "$windows_asset" | grep -Fx 'bin/test_integration_sender.exe' + unzip -Z1 "$windows_asset" | grep -Fx 'bin/test_integration_listener.exe' + unzip -Z1 "$windows_asset" | grep -Fx 'docs/consumers/README.md' + unzip -Z1 "$windows_asset" | grep -Fx 'LICENSE' + unzip -Z1 "$windows_asset" | grep -Fx 'vendor/licenses/stb.txt' + - name: Generate release metadata if: steps.check-assets.outputs.has_assets == 'true' id: release-tag diff --git a/build.py b/build.py index 44505ed..872f1c0 100644 --- a/build.py +++ b/build.py @@ -43,6 +43,7 @@ Path("tests/test_integration_listener.c"), ] EXAMPLE_SOURCE = Path("examples/example_c.c") +LINUX_PERMISSION_HELPER = Path("scripts/setup_uinput_permissions.sh") def split_flags(value: str | None) -> list[str]: @@ -286,6 +287,22 @@ def copy_file(source: Path, destination: Path) -> None: shutil.copy2(source, destination) +def write_zip_tree(archive_path: Path, source_dir: Path) -> None: + with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for file_path in source_dir.rglob("*"): + if not file_path.is_file(): + continue + + archive_name = file_path.relative_to(source_dir).as_posix() + info = zipfile.ZipInfo.from_file(file_path, archive_name) + info.create_system = 3 + info.external_attr = (file_path.stat().st_mode & 0xFFFF) << 16 + info.compress_type = zipfile.ZIP_DEFLATED + + with file_path.open("rb") as handle: + archive.writestr(info, handle.read()) + + def package_output(config: BuildConfig, version: str, arch: str) -> Path: library_path = build_library(config) dist_dir = ROOT / "dist" @@ -306,10 +323,7 @@ def package_output(config: BuildConfig, version: str, arch: str) -> Path: if IS_WINDOWS: archive_path = packages_dir / f"{archive_base}.zip" - with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: - for file_path in dist_dir.rglob("*"): - if file_path.is_file(): - archive.write(file_path, file_path.relative_to(dist_dir)) + write_zip_tree(archive_path, dist_dir) else: archive_path = packages_dir / f"{archive_base}.tar.gz" with tarfile.open(archive_path, "w:gz") as archive: @@ -319,6 +333,34 @@ def package_output(config: BuildConfig, version: str, arch: str) -> Path: return archive_path +def package_integration_tests_output(config: BuildConfig, version: str, arch: str) -> Path: + binaries = [build_binary(config, source) for source in INTEGRATION_TEST_SOURCES] + dist_dir = ROOT / "dist" + packages_dir = ROOT / "packages" + archive_base = f"axidev-io-integration-tests-{version}-{PLATFORM_TAG}-{arch}" + + if dist_dir.exists(): + shutil.rmtree(dist_dir) + dist_dir.mkdir(parents=True, exist_ok=True) + packages_dir.mkdir(parents=True, exist_ok=True) + + for binary in binaries: + copy_file(binary, dist_dir / "bin" / binary.name) + + copy_tree(VENDOR_LICENSES_DIR, dist_dir / "vendor" / "licenses") + copy_file(ROOT / "docs" / "consumers" / "README.md", dist_dir / "docs" / "consumers" / "README.md") + copy_file(ROOT / "README.md", dist_dir / "README.md") + copy_file(ROOT / "LICENSE", dist_dir / "LICENSE") + + if not IS_WINDOWS: + copy_file(LINUX_PERMISSION_HELPER, dist_dir / "scripts" / LINUX_PERMISSION_HELPER.name) + + archive_path = packages_dir / f"{archive_base}.zip" + write_zip_tree(archive_path, dist_dir) + print(f"Created {archive_path}") + return archive_path + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Build axidev-io without make.") parser.add_argument( @@ -332,6 +374,7 @@ def parse_args() -> argparse.Namespace: "test-integration", "unit-binaries", "integration-binaries", + "package-integration-tests", "example", "clean", "package", @@ -400,6 +443,10 @@ def main() -> int: package_output(config, args.version, args.arch) return 0 + if args.command == "package-integration-tests": + package_integration_tests_output(config, args.version, args.arch) + return 0 + raise SystemExit(f"Unsupported command: {args.command}") diff --git a/docs/consumers/README.md b/docs/consumers/README.md index bdb2a3e..5b8a143 100644 --- a/docs/consumers/README.md +++ b/docs/consumers/README.md @@ -33,6 +33,55 @@ Example: cc main.c -laxidev-io -linput -ludev -lxkbcommon -lpthread ``` +## Linux Permissions + +Linux usually needs two different kinds of access: + +- injection needs write access to `/dev/uinput` +- listening needs read access to the relevant `/dev/input/event*` devices + +For injection, load the kernel module and grant a group access to `/dev/uinput` +through `udev`: + +```sh +sudo modprobe uinput +sudo groupadd -f input +sudo usermod -aG input "$USER" +``` + +The Linux integration-test bundle also includes +`scripts/setup_uinput_permissions.sh`, which applies the same setup steps. +Review it before running it on a target system. + +Create `/etc/udev/rules.d/70-axidev-io-uinput.rules` with: + +```udev +KERNEL=="uinput", MODE="0660", GROUP="input", OPTIONS+="static_node=uinput" +``` + +Then reload rules and re-login so the new group membership applies: + +```sh +sudo udevadm control --reload-rules +sudo udevadm trigger /dev/uinput +``` + +After that, `axidev_io_keyboard_initialize()` should be able to open +`/dev/uinput`. On Linux, `axidev_io_keyboard_request_permissions()` only checks +whether access is already available; it does not open a desktop permission +prompt. + +For listening, `libinput` opens device nodes such as `/dev/input/event*`. On +most desktop Linux systems this works when the process runs in the active local +session on `seat0`. If listener startup fails, first confirm the process is +running in a real local login session with access to the input seat. + +Avoid broad `udev` rules that make all `/dev/input/event*` nodes world-readable. +Those devices expose raw keyboard events and can capture sensitive input. If a +headless or service environment needs listener access, grant that access with a +dedicated service account or seat/session setup instead of relaxing permissions +globally. + ## Basic Usage ```c diff --git a/docs/developers/README.md b/docs/developers/README.md index ce8cfde..f6ef15b 100644 --- a/docs/developers/README.md +++ b/docs/developers/README.md @@ -12,6 +12,7 @@ Additional targets: - `python build.py test-unit` - `python build.py test-integration` +- `python build.py package-integration-tests --version run-123 --arch x64` - `python build.py clean` - `python build.py package --version v1.2.3` diff --git a/include/axidev-io/c_api.h b/include/axidev-io/c_api.h index 1cac6c5..229b25e 100644 --- a/include/axidev-io/c_api.h +++ b/include/axidev-io/c_api.h @@ -9,8 +9,8 @@ #ifndef AXIDEV_IO_VERSION #define AXIDEV_IO_VERSION "0.4.0" #define AXIDEV_IO_VERSION_MAJOR 0 -#define AXIDEV_IO_VERSION_MINOR 4 -#define AXIDEV_IO_VERSION_PATCH 0 +#define AXIDEV_IO_VERSION_MINOR 5 +#define AXIDEV_IO_VERSION_PATCH 1 #endif #ifndef AXIDEV_IO_API diff --git a/scripts/setup_uinput_permissions.sh b/scripts/setup_uinput_permissions.sh new file mode 100755 index 0000000..47a5666 --- /dev/null +++ b/scripts/setup_uinput_permissions.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +target_user="${SUDO_USER:-${USER:-}}" +if [ -z "$target_user" ] || [ "$target_user" = "root" ]; then + echo "Run this script as a normal user; it will use sudo for the privileged steps." >&2 + exit 1 +fi + +rule_path="/etc/udev/rules.d/70-axidev-io-uinput.rules" +rule_contents='KERNEL=="uinput", MODE="0660", GROUP="input", OPTIONS+="static_node=uinput"' + +sudo modprobe uinput +sudo groupadd -f input +sudo usermod -aG input "$target_user" +printf '%s\n' "$rule_contents" | sudo tee "$rule_path" >/dev/null +sudo udevadm control --reload-rules +sudo udevadm trigger /dev/uinput + +cat <