Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c435f52
fix: restore capture runtime contract
abrichr Jul 23, 2026
e5895df
feat: add native input observer contract
abrichr Jul 23, 2026
334200b
feat: add native macOS input observer
abrichr Jul 23, 2026
1ea551c
fix: make input delivery bounded and fail loud
abrichr Jul 23, 2026
604bad3
fix: complete observer startup cleanup
abrichr Jul 23, 2026
3f3f346
fix: harden native observer lifecycle
abrichr Jul 23, 2026
109a779
fix: replace pynput with native input observers
abrichr Jul 23, 2026
27d4b3f
fix: preserve input receipt time and callback stops
abrichr Jul 23, 2026
07e2e3f
fix: preserve ordered input receipt timestamps
abrichr Jul 23, 2026
2ca57fe
feat: add nonblocking native Windows input observer
abrichr Jul 23, 2026
95ed128
fix: keep native observer source dependency-neutral
abrichr Jul 23, 2026
cf3433a
fix: keep dead-key text unverifiable
abrichr Jul 23, 2026
f583a84
fix: preserve Linux XKB text semantics
abrichr Jul 23, 2026
093f7c0
fix: bound Linux compose state
abrichr Jul 23, 2026
e13eb8e
test: scope platform-specific compatibility guards
abrichr Jul 23, 2026
dd6a781
fix: initialize Xlib and preserve mouse button API
abrichr Jul 23, 2026
b44ffec
fix: bind Windows text to event-time layout
abrichr Jul 23, 2026
1eec818
fix: preserve published mouse button runtime type
abrichr Jul 23, 2026
e722cd5
fix: bind Linux input to event-time state
abrichr Jul 23, 2026
ac5e395
test: remove obsolete Linux raw-event ABI contract
abrichr Jul 23, 2026
1676955
test: document X RECORD event ordering
abrichr Jul 23, 2026
7444be1
ci: gate capture publication on green main
abrichr Jul 23, 2026
02461fe
fix: drain Linux recording tail on shutdown
abrichr Jul 23, 2026
b638e36
fix: keep failed Linux capture starts transactional
abrichr Jul 23, 2026
f463fc0
fix: cancel Linux input startup transactionally
abrichr Jul 23, 2026
6e1e867
fix: commit input delivery after startup
abrichr Jul 23, 2026
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
134 changes: 115 additions & 19 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,60 +1,155 @@
name: Release and PyPI Publish

# Publication is a deliberate operation from protected main. Ordinary merges
# run Tests only; this workflow refuses to version or publish until that exact
# main commit has completed the full test.yml workflow (including the package
# archive contract).
#
# Requires ADMIN_TOKEN (GitHub PAT with repo scope) to push the release commit
# and tag through branch protection. PyPI publication uses Trusted Publishing.

on:
push:
branches:
- main
workflow_dispatch:
inputs:
operation:
description: "Release operation"
type: choice
required: true
default: semantic-release
options:
- semantic-release

concurrency:
group: release
cancel-in-progress: false

jobs:
release:
semantic-release:
name: Version, tag, and publish the green main release train
if: >-
github.event_name == 'workflow_dispatch' &&
inputs.operation == 'semantic-release' &&
github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
concurrency: release
permissions:
actions: read
id-token: write
contents: write
issues: write

steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Checkout protected main at the dispatched commit
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
token: ${{ secrets.ADMIN_TOKEN }}

- name: Check if should skip
- name: Refuse a release commit replay
id: check_skip
shell: bash
run: |
set -euo pipefail
author="$(git log -1 --pretty=format:'%an')"
subject="$(git log -1 --pretty=format:'%s')"
if [ "${author}" = "semantic-release" ] || printf '%s' "${subject}" | grep -q '^chore: release'; then
echo "skip=true" >> "${GITHUB_OUTPUT}"
fi

- name: Wait for exact-head Tests workflow
if: steps.check_skip.outputs.skip != 'true'
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
if [ "$(git log -1 --pretty=format:'%an')" = "semantic-release" ]; then
echo "skip=true" >> $GITHUB_OUTPUT
set -euo pipefail
deadline=$((SECONDS + 2700))
while [ "${SECONDS}" -lt "${deadline}" ]; do
response="$(
gh api --method GET \
"repos/${GITHUB_REPOSITORY}/actions/workflows/test.yml/runs" \
--raw-field head_sha="${GITHUB_SHA}" \
--raw-field event=push \
--raw-field per_page=20
)"
status="$(
printf '%s' "${response}" |
jq -r --arg sha "${GITHUB_SHA}" \
'[.workflow_runs[]
| select(.head_sha == $sha and .event == "push")]
| sort_by(.created_at) | last | .status // "missing"'
)"
conclusion="$(
printf '%s' "${response}" |
jq -r --arg sha "${GITHUB_SHA}" \
'[.workflow_runs[]
| select(.head_sha == $sha and .event == "push")]
| sort_by(.created_at) | last | .conclusion // "pending"'
)"
echo "test.yml for ${GITHUB_SHA}: ${status}/${conclusion}"
if [ "${status}" = "completed" ] && [ "${conclusion}" != "success" ]; then
echo "Refusing to publish because test.yml concluded ${conclusion}."
exit 1
fi
if [ "${status}" = "completed" ] && [ "${conclusion}" = "success" ]; then
break
fi
sleep 10
done
if [ "${status}" != "completed" ] || [ "${conclusion}" != "success" ]; then
echo "Refusing to publish: exact-head test.yml did not succeed within 45 minutes."
exit 1
fi

- name: Require dispatched head to remain current protected main
if: steps.check_skip.outputs.skip != 'true'
shell: bash
run: |
set -euo pipefail
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
current_main="$(git rev-parse refs/remotes/origin/main)"
if [ "${current_main}" != "${GITHUB_SHA}" ]; then
echo "Refusing stale release dispatch: main is ${current_main}, dispatched head is ${GITHUB_SHA}."
exit 1
fi

- name: Set up Python
if: steps.check_skip.outputs.skip != 'true'
uses: actions/setup-python@v5
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
python-version: "3.12"

- name: Install uv
- name: Install exact uv
if: steps.check_skip.outputs.skip != 'true'
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
version: "0.11.29"

- name: Python Semantic Release
if: steps.check_skip.outputs.skip != 'true'
id: release
uses: python-semantic-release/python-semantic-release@v10.6.1
uses: python-semantic-release/python-semantic-release@39dd2052f2ce8282a5d932c31d58a2ca06d2550e # v10.6.1
with:
github_token: ${{ secrets.ADMIN_TOKEN }}

- name: Build package
# Do not trust the semantic-release action's transient build directory.
# Rebuild the versioned tree and re-run the exact archive boundary guard
# before either external publication channel sees an artifact.
- name: Rebuild and verify release artifacts
if: steps.check_skip.outputs.skip != 'true' && steps.release.outputs.released == 'true'
run: uv build
shell: bash
run: |
set -euo pipefail
rm -rf dist
uv build --wheel --sdist
python scripts/verify_distribution.py dist/*

- name: Publish to PyPI
if: steps.check_skip.outputs.skip != 'true' && steps.release.outputs.released == 'true'
uses: pypa/gh-action-pypi-publish@release/v1
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1

- name: Publish to GitHub Releases
if: steps.check_skip.outputs.skip != 'true' && steps.release.outputs.released == 'true'
uses: python-semantic-release/publish-action@v9.15.2
uses: python-semantic-release/publish-action@5a5718ce47b892ef699f2972dae122297771d641 # v10.6.1
with:
github_token: ${{ secrets.ADMIN_TOKEN }}

Expand All @@ -64,6 +159,7 @@ jobs:
if: failure()
env:
GH_TOKEN: ${{ secrets.ADMIN_TOKEN }}
shell: bash
run: |
TITLE="Release workflow failed on main"
BODY="The release workflow failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
Expand Down
29 changes: 24 additions & 5 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,12 @@ jobs:

# Windows is the primary live-recording platform: this job runs the full
# unit suite AND the live Recorder integration tests (tests/test_performance.py,
# marked 'slow'), which start the real multiprocessing pipeline (pynput
# listeners -> writer processes -> per-capture SQLite + video) and verify
# marked 'slow'), which start the real multiprocessing pipeline (native
# observers -> writer processes -> per-capture SQLite + video) and verify
# clean startup, bounded shutdown/memory, and db creation. The three
# listener-dependent tests (roundtrip/reuse/throughput) skip here via
# OPENADAPT_CI_NO_INPUT_INJECTION: hosted runners execute jobs in a
# non-interactive session, so injected input never reaches pynput's hooks;
# non-interactive session, so injected input never reaches native hooks;
# run those on an interactive Windows desktop. These tests skip everywhere
# except Windows, so this job is the org's live-recording CI proof.
test-windows:
Expand All @@ -68,7 +68,7 @@ jobs:

- name: Run live recorder integration tests
env:
# Non-interactive session: SendInput never reaches pynput's
# Non-interactive session: SendInput never reaches native
# low-level hooks, so the event-capture tests skip (see
# tests/test_performance.py); pipeline start/stop/db/memory
# tests still run the live recorder for real.
Expand All @@ -79,7 +79,7 @@ jobs:
# macOS coverage: runs the unit suite (event processing, storage, video
# encode/decode via PyAV, headless-import invariant, Recorder API surface —
# CGWindowList-backed screen capture works on GitHub macOS runners). The
# 'slow' live-recording tests stay skipped here: pynput input injection
# 'slow' live-recording tests stay skipped here: synthetic input injection
# requires Accessibility permissions a hosted runner cannot grant, and the
# multiprocessing 'spawn' writer path is not yet validated on macOS (see
# tests/test_performance.py).
Expand Down Expand Up @@ -121,3 +121,22 @@ jobs:

- name: Run ruff
run: uv run ruff check openadapt_capture/

package-contract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"

- name: Set up Python
run: uv python install 3.12

- name: Build wheel and source distribution
run: uv build

- name: Verify release archive boundaries
run: python scripts/verify_distribution.py dist/*
24 changes: 9 additions & 15 deletions openadapt_capture/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
Platform-agnostic event streams with time-aligned media.
"""

__version__ = "0.1.0"
from importlib.metadata import PackageNotFoundError, version

try:
__version__ = version("openadapt-capture")
except PackageNotFoundError:
__version__ = "0+unknown"

# High-level APIs (primary interface)
from openadapt_capture.capture import Action, Capture, CaptureSession
Expand Down Expand Up @@ -68,20 +73,9 @@
remove_redundant_mouse_move_events,
)

# Recorder requires pynput which needs a display server (X11/Wayland/macOS/Windows).
# Make it optional so the package is importable in headless environments (CI, servers).
# The recorder must never take a screenshot at import time, but guard against a
# display/screenshot failure (mss ScreenShotError) as well as a missing dependency
# so a headless import degrades to ``Recorder = None`` instead of crashing.
try:
from mss.exception import ScreenShotError as _ScreenShotError
except ImportError: # pragma: no cover - mss is a hard dependency
_ScreenShotError = () # type: ignore[assignment,misc]

try:
from openadapt_capture.recorder import Recorder
except (ImportError, OSError, _ScreenShotError):
Recorder = None # type: ignore[assignment,misc]
# Recorder imports are display-side-effect free. Platform permissions and
# display availability are checked only when native observers start.
from openadapt_capture.recorder import Recorder

# Performance statistics
from openadapt_capture.stats import (
Expand Down
5 changes: 0 additions & 5 deletions openadapt_capture/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
KeyDownEvent,
KeyTypeEvent,
KeyUpEvent,
MouseButton,
MouseDownEvent,
MouseMoveEvent,
MouseScrollEvent,
Expand Down Expand Up @@ -63,10 +62,6 @@ def _convert_action_event(db_event) -> PydanticActionEvent | None:
)
elif db_event.name == "click":
button = db_event.mouse_button_name or "left"
try:
button = MouseButton(button)
except ValueError:
button = MouseButton.LEFT

if db_event.mouse_pressed is True:
return MouseDownEvent(
Expand Down
4 changes: 4 additions & 0 deletions openadapt_capture/comparison.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,10 @@ def plot_comparison(
PIL Image if neither output_path nor show, else None.
"""
try:
import matplotlib

if not show:
matplotlib.use("Agg")
import matplotlib.pyplot as plt
except ImportError:
raise ImportError(
Expand Down
10 changes: 5 additions & 5 deletions openadapt_capture/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ class MouseDownEvent(BaseEvent):
type: Literal[EventType.MOUSE_DOWN] = EventType.MOUSE_DOWN
x: float = Field(description="Mouse X position in pixels")
y: float = Field(description="Mouse Y position in pixels")
button: MouseButton = Field(description="Mouse button name")
button: str = Field(description="Native mouse button name")


class MouseUpEvent(BaseEvent):
Expand All @@ -99,7 +99,7 @@ class MouseUpEvent(BaseEvent):
type: Literal[EventType.MOUSE_UP] = EventType.MOUSE_UP
x: float = Field(description="Mouse X position in pixels")
y: float = Field(description="Mouse Y position in pixels")
button: MouseButton = Field(description="Mouse button name")
button: str = Field(description="Native mouse button name")


class MouseScrollEvent(BaseEvent):
Expand Down Expand Up @@ -202,7 +202,7 @@ class MouseClickEvent(BaseEvent):
type: Literal[EventType.MOUSE_SINGLECLICK] = EventType.MOUSE_SINGLECLICK
x: float = Field(description="Mouse X position in pixels")
y: float = Field(description="Mouse Y position in pixels")
button: MouseButton = Field(description="Mouse button name")
button: str = Field(description="Native mouse button name")
children: list[MouseDownEvent | MouseUpEvent] = Field(
default_factory=list, description="Child events that were merged"
)
Expand All @@ -218,7 +218,7 @@ class MouseDoubleClickEvent(BaseEvent):
type: Literal[EventType.MOUSE_DOUBLECLICK] = EventType.MOUSE_DOUBLECLICK
x: float = Field(description="Mouse X position in pixels")
y: float = Field(description="Mouse Y position in pixels")
button: MouseButton = Field(description="Mouse button name")
button: str = Field(description="Native mouse button name")
children: list[MouseDownEvent | MouseUpEvent] = Field(
default_factory=list, description="Child events that were merged"
)
Expand All @@ -236,7 +236,7 @@ class MouseDragEvent(BaseEvent):
y: float = Field(description="Starting Y position in pixels")
dx: float = Field(description="Horizontal displacement (end_x - start_x)")
dy: float = Field(description="Vertical displacement (end_y - start_y)")
button: MouseButton = Field(description="Mouse button name")
button: str = Field(description="Native mouse button name")
children: list[MouseDownEvent | MouseMoveEvent | MouseUpEvent] = Field(
default_factory=list, description="Child events that were merged"
)
Expand Down
Loading