Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions .github/actions/build-linux/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
30 changes: 28 additions & 2 deletions .github/actions/build-windows-mingw/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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
81 changes: 57 additions & 24 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ on:
workflow_dispatch:
# no inputs

env:
INTEGRATION_PACKAGE_VERSION: run-${{ github.run_id }}

permissions:
contents: write

Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
55 changes: 51 additions & 4 deletions build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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"
Expand All @@ -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:
Expand All @@ -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(
Expand All @@ -332,6 +374,7 @@ def parse_args() -> argparse.Namespace:
"test-integration",
"unit-binaries",
"integration-binaries",
"package-integration-tests",
"example",
"clean",
"package",
Expand Down Expand Up @@ -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}")


Expand Down
49 changes: 49 additions & 0 deletions docs/consumers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading