mirror of
https://github.com/lahfir/agent-desktop.git
synced 2026-07-27 01:22:16 +00:00
feat(ffi): Phase B and C — Python smoke harness, parity gates, build.rs codegen (#77)
Completes the FFI plan: U9 adds a Python ctypes smoke harness proving the C ABI from a non-Rust host; U10 adds cross-platform parity CI gates for header drift, codegen drift, panic-unwind, and stub-adapter passthrough; U11 generates the command-backed wrappers from build.rs templates with a committed drift-gated output. The C ABI stays byte-identical and the surface is Windows and Linux ready with no new FFI code.
This commit is contained in:
parent
6e86f1c299
commit
9023f331b3
28 changed files with 2457 additions and 366 deletions
|
|
@ -28,8 +28,14 @@ else
|
|||
HAS_RUST_CHANGES=0
|
||||
fi
|
||||
|
||||
if [ "$HAS_RUST_CHANGES" -eq 0 ]; then
|
||||
echo "pre-commit: no Rust changes staged, skipping cargo checks"
|
||||
if git diff --cached --name-only --diff-filter=ACMR | grep -q '^crates/ffi/'; then
|
||||
HAS_FFI_CHANGES=1
|
||||
else
|
||||
HAS_FFI_CHANGES=0
|
||||
fi
|
||||
|
||||
if [ "$HAS_RUST_CHANGES" -eq 0 ] && [ "$HAS_FFI_CHANGES" -eq 0 ]; then
|
||||
echo "pre-commit: no Rust or FFI changes staged, skipping cargo checks"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
|
@ -65,4 +71,51 @@ run "cargo fmt --all -- --check" cargo fmt --all -- --check
|
|||
run "cargo clippy --all-targets -- -D warnings" cargo clippy --all-targets -- -D warnings
|
||||
run "cargo test --lib --workspace" cargo test --lib --workspace
|
||||
|
||||
# ── FFI-specific gates ────────────────────────────────────────────────────────
|
||||
# Mirrors CI jobs: ffi-codegen-drift, ffi-passthrough, ffi-header-drift.
|
||||
# Fast-skipped in full when no crates/ffi/** files are staged.
|
||||
#
|
||||
# Scope: only the fast structural gates run locally (codegen drift, stub
|
||||
# passthrough, optional header drift). panic_spike (requires a release-ffi
|
||||
# example build) and the Python smoke harness (requires a full cdylib build
|
||||
# plus a Python runtime) are CI-only — jobs ffi-panic-guard and
|
||||
# ffi-python-smoke — to keep commits fast.
|
||||
if [ "$HAS_FFI_CHANGES" -eq 1 ]; then
|
||||
# Gate 1: Codegen drift — build triggers build.rs which regenerates
|
||||
# generated.rs in-source; diff detects a stale staged copy.
|
||||
run "FFI codegen build" cargo build --locked -p agent-desktop-ffi --features stub-adapter
|
||||
if ! git diff --quiet crates/ffi/src/commands/generated.rs; then
|
||||
printf '\033[1;31m✗ FFI codegen drift\033[0m\n' >&2
|
||||
printf '\n' >&2
|
||||
printf 'pre-commit: crates/ffi/src/commands/generated.rs is stale.\n' >&2
|
||||
printf ' The hook already regenerated it — just stage the updated file:\n' >&2
|
||||
printf ' git add crates/ffi/src/commands/generated.rs\n' >&2
|
||||
printf '\npre-commit: refusing the commit. Fix the issues above or rerun with SKIP_PRECOMMIT=1 to bypass.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Gate 2: Stub-adapter passthrough tests.
|
||||
run "FFI stub passthrough" cargo test --locked -p agent-desktop-ffi --features stub-adapter --test c_abi_passthrough
|
||||
|
||||
# Gate 3: Header drift — graceful skip if cbindgen is absent or not 0.29.4.
|
||||
# The hard gate is CI job ffi-header-drift; local absence is not a commit blocker.
|
||||
export PATH="${HOME}/.cargo/bin:${PATH}"
|
||||
_cbindgen_cmd=""
|
||||
if command -v cbindgen >/dev/null 2>&1; then
|
||||
_cbindgen_cmd="cbindgen"
|
||||
fi
|
||||
if [ -n "${_cbindgen_cmd}" ]; then
|
||||
_cbindgen_ver=$("${_cbindgen_cmd}" --version 2>&1 | head -1)
|
||||
if [[ "${_cbindgen_ver}" == "cbindgen 0.29.4" ]]; then
|
||||
run "FFI header drift (cbindgen --verify)" \
|
||||
"${_cbindgen_cmd}" crates/ffi --config crates/ffi/cbindgen.toml \
|
||||
--output crates/ffi/include/agent_desktop.h --verify
|
||||
else
|
||||
printf 'pre-commit: cbindgen version mismatch (want 0.29.4, got: %s) — skipping header drift check (CI gate)\n' "${_cbindgen_ver}"
|
||||
fi
|
||||
else
|
||||
printf 'pre-commit: cbindgen not found in PATH or ~/.cargo/bin — skipping header drift check (CI gate)\n'
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '\033[1;32m✓ pre-commit checks passed\033[0m\n'
|
||||
|
|
|
|||
224
.github/workflows/ci.yml
vendored
224
.github/workflows/ci.yml
vendored
|
|
@ -134,3 +134,227 @@ jobs:
|
|||
throw new Error('agent-desktop npm wrapper did not return version JSON');
|
||||
}
|
||||
"
|
||||
|
||||
ffi-python-smoke:
|
||||
name: FFI Python Smoke
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Install Rust toolchain
|
||||
run: rustup show
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'src/Cargo.toml') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-
|
||||
|
||||
- name: Build FFI dylib (stub adapter)
|
||||
run: >
|
||||
cargo build --locked --profile release-ffi
|
||||
-p agent-desktop-ffi --features stub-adapter
|
||||
|
||||
- name: Locate dylib
|
||||
id: dylib
|
||||
run: |
|
||||
DYLIB=$(find target/release-ffi -name 'libagent_desktop_ffi.*' \
|
||||
\( -name '*.dylib' -o -name '*.so' -o -name '*.dll' \) | head -1)
|
||||
if [ -z "$DYLIB" ]; then
|
||||
echo "FAIL: dylib not found under target/release-ffi"
|
||||
exit 1
|
||||
fi
|
||||
echo "path=$DYLIB" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Run FFI smoke harness
|
||||
env:
|
||||
AD_EXPECT_STUB: "1"
|
||||
run: >
|
||||
python3 tests/ffi-python/smoke.py
|
||||
"${{ steps.dylib.outputs.path }}"
|
||||
crates/ffi/include/agent_desktop.h
|
||||
|
||||
# Gate 1 — Header-drift: the committed crates/ffi/include/agent_desktop.h must
|
||||
# match what cbindgen 0.29.4 would generate from the current source. A stale
|
||||
# header means a consumer compiled against it sees different symbols/layouts
|
||||
# than the dylib ships. cbindgen --verify exits non-zero when there is a diff.
|
||||
#
|
||||
# The cbindgen version is pinned so a generator upgrade cannot produce a
|
||||
# false-positive failure — only a real header change triggers this gate.
|
||||
ffi-header-drift:
|
||||
name: FFI Header Drift
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Install Rust toolchain
|
||||
run: rustup show
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'src/Cargo.toml') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-
|
||||
|
||||
- name: Install pinned cbindgen 0.29.4
|
||||
run: cargo install cbindgen --version 0.29.4 --locked
|
||||
|
||||
- name: Verify header matches source (cbindgen --verify)
|
||||
run: >
|
||||
cbindgen crates/ffi
|
||||
--config crates/ffi/cbindgen.toml
|
||||
--output crates/ffi/include/agent_desktop.h
|
||||
--verify
|
||||
|
||||
# Gate 2 — Panic-trap guard: the release-ffi profile must keep panic="unwind"
|
||||
# so the trap_panic boundary in the shipped dylib actually catches panics. A
|
||||
# flip to panic="abort" silently defeats every catch_unwind in the cdylib and
|
||||
# turns any Rust panic into an abort that kills the host process.
|
||||
#
|
||||
# This job proves the contract in two steps:
|
||||
# 1. Assert the Cargo.toml profile setting directly (simpler and more
|
||||
# reliable than a build.rs env check for a dual cdylib+rlib crate).
|
||||
# 2. Build and run crates/ffi/examples/panic_spike under the release-ffi
|
||||
# profile: an extern-C fn panics, catch_unwind catches it, and the
|
||||
# example prints "PANIC CAUGHT OK (code = -1)" and exits 0. If the
|
||||
# profile were flipped to panic="abort" the process would SIGABRT
|
||||
# instead, making this step a runtime proof — not just a string check.
|
||||
ffi-panic-guard:
|
||||
name: FFI Panic Guard
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Install Rust toolchain
|
||||
run: rustup show
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'src/Cargo.toml') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-
|
||||
|
||||
- name: Assert release-ffi profile keeps panic=unwind
|
||||
run: |
|
||||
if ! grep -A5 '\[profile\.release-ffi\]' Cargo.toml | grep -q 'panic.*=.*"unwind"'; then
|
||||
echo "FAIL: [profile.release-ffi] must set panic=\"unwind\" — a flip to"
|
||||
echo " panic=\"abort\" defeats every trap_panic boundary in the dylib."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: release-ffi profile retains panic=\"unwind\""
|
||||
|
||||
- name: Prove catch_unwind survives the release-ffi profile
|
||||
run: cargo run --locked --profile release-ffi --example panic_spike -p agent-desktop-ffi
|
||||
|
||||
# Gate 3 — Stub-adapter passthrough tests: the Family-B command-backed
|
||||
# entrypoints (ad_snapshot, ad_status, ad_wait, ad_execute_by_ref,
|
||||
# ad_version) plus adapter lifecycle (ad_init / ad_destroy) and
|
||||
# ad_check_permissions are exercised against the stub adapter. Each must
|
||||
# return a structured PLATFORM_NOT_SUPPORTED envelope — or, for
|
||||
# ad_check_permissions, the documented ErrPermDenied result. These tests are
|
||||
# gated behind #[cfg(feature = "stub-adapter")] so the normal build is
|
||||
# unaffected.
|
||||
#
|
||||
# Scope note: the ~35 Family-A entrypoints (ad_find, ad_execute_action,
|
||||
# ad_list_windows, ad_screenshot, clipboard, notifications, etc.) are NOT
|
||||
# covered by this passthrough job. Broader Family-A stub coverage is a
|
||||
# documented follow-up.
|
||||
#
|
||||
# Per-target build coverage:
|
||||
# - PR CI: this job runs on Linux (no main-thread guard off macOS, so the
|
||||
# stub adapter is reached directly) to assert PLATFORM_NOT_SUPPORTED for
|
||||
# every covered entrypoint — directly proving the non-macOS parity
|
||||
# U10 targets.
|
||||
# - Release: release.yml build-ffi matrix covers macOS×2 + Linux×2 +
|
||||
# Windows×1 for the real cdylib. PR builds do not duplicate that
|
||||
# cross-compile matrix to avoid bloating CI on every push.
|
||||
ffi-passthrough:
|
||||
name: FFI Stub-Adapter Passthrough
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Install Rust toolchain
|
||||
run: rustup show
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'src/Cargo.toml') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-
|
||||
|
||||
- name: Run stub-adapter passthrough tests
|
||||
run: >
|
||||
cargo test --locked -p agent-desktop-ffi
|
||||
--features stub-adapter --test c_abi_passthrough
|
||||
|
||||
# Gate 4 — Codegen drift: the committed src/commands/generated.rs must
|
||||
# match what build.rs produces from the current templates. A stale committed
|
||||
# file means the source diverged from the templates — either a hand-edit
|
||||
# bypassed the generator or a template was changed without rebuilding.
|
||||
ffi-codegen-drift:
|
||||
name: FFI Codegen Drift
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Install Rust toolchain
|
||||
run: rustup show
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'src/Cargo.toml') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-
|
||||
|
||||
- name: Regenerate FFI command wrappers via build.rs
|
||||
run: cargo build --locked -p agent-desktop-ffi --features stub-adapter
|
||||
|
||||
- name: Assert no drift in commands/generated.rs
|
||||
run: |
|
||||
if ! git diff --exit-code crates/ffi/src/commands/generated.rs; then
|
||||
echo ""
|
||||
echo "FAIL: crates/ffi/src/commands/generated.rs is out of sync with build.rs templates."
|
||||
echo " Run 'cargo build -p agent-desktop-ffi' locally and commit the updated file."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: commands/generated.rs matches build.rs output"
|
||||
|
|
|
|||
102
.github/workflows/release.yml
vendored
102
.github/workflows/release.yml
vendored
|
|
@ -239,6 +239,24 @@ jobs:
|
|||
key: ${{ runner.os }}-${{ matrix.target }}-ffi-cargo-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'src/Cargo.toml') }}
|
||||
restore-keys: ${{ runner.os }}-${{ matrix.target }}-ffi-cargo-
|
||||
|
||||
# NEVER add --features stub-adapter to the release FFI build — it ships a no-op dylib
|
||||
# where every adapter call returns PLATFORM_NOT_SUPPORTED. The guard step below enforces this.
|
||||
- name: Guard — release dylib must not be a stub build
|
||||
shell: bash
|
||||
run: |
|
||||
# Co-occurrence of the release profile token and the stub flag on the same
|
||||
# line is the discriminator. Legitimate stub uses in this file (codegen regen,
|
||||
# passthrough test) omit the release-ffi profile, so they do not trigger.
|
||||
PROFILE_TOKEN="release-ffi"
|
||||
STUB_TOKEN="stub-adapter"
|
||||
if grep -nE "${PROFILE_TOKEN}.*${STUB_TOKEN}|${STUB_TOKEN}.*${PROFILE_TOKEN}" \
|
||||
.github/workflows/release.yml; then
|
||||
echo "FAIL: release-ffi build path must not enable"
|
||||
echo " the stub-adapter feature — that would ship a no-op dylib"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: no stub flag on the shipping release-ffi build path"
|
||||
|
||||
- name: Build FFI cdylib (release-ffi profile)
|
||||
run: cargo build --locked --profile release-ffi -p agent-desktop-ffi --target ${{ matrix.target }}
|
||||
|
||||
|
|
@ -307,9 +325,91 @@ jobs:
|
|||
agent-desktop-ffi-v*.zip.sha256
|
||||
retention-days: 1
|
||||
|
||||
# Re-runs the three FFI integrity gates at release time so a hotfix or a PR
|
||||
# that bypassed ci.yml cannot ship a dylib with a stale committed header or
|
||||
# stale generated.rs. Gate 1 (header drift) must run on macos-latest because
|
||||
# the committed header is macOS-generated and cbindgen may omit macOS-gated
|
||||
# symbols on Linux. Gates 2 and 3 are consolidated here for a single job.
|
||||
ffi-release-gates:
|
||||
name: FFI Release Gates
|
||||
needs: release-please
|
||||
if: needs.release-please.outputs.release_created == 'true'
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ needs.release-please.outputs.tag_name }}
|
||||
|
||||
- name: Install Rust toolchain
|
||||
run: rustup show
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
key: ${{ runner.os }}-ffi-gates-cargo-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'src/Cargo.toml') }}
|
||||
restore-keys: ${{ runner.os }}-ffi-gates-cargo-
|
||||
|
||||
- name: Install pinned cbindgen 0.29.4
|
||||
run: cargo install cbindgen --version 0.29.4 --locked
|
||||
|
||||
# Gate 1 — Header drift: committed crates/ffi/include/agent_desktop.h must
|
||||
# match what cbindgen 0.29.4 generates from the current source. Exits
|
||||
# non-zero when there is any diff.
|
||||
- name: Header drift check (cbindgen --verify)
|
||||
run: >
|
||||
cbindgen crates/ffi
|
||||
--config crates/ffi/cbindgen.toml
|
||||
--output crates/ffi/include/agent_desktop.h
|
||||
--verify
|
||||
|
||||
# Gate 2 — Codegen drift: build.rs must produce the exact same
|
||||
# crates/ffi/src/commands/generated.rs that is committed.
|
||||
- name: Regenerate FFI command wrappers via build.rs
|
||||
run: cargo build --locked -p agent-desktop-ffi --features stub-adapter
|
||||
|
||||
- name: Codegen drift check (generated.rs)
|
||||
run: |
|
||||
if ! git diff --exit-code crates/ffi/src/commands/generated.rs; then
|
||||
echo ""
|
||||
echo "FAIL: crates/ffi/src/commands/generated.rs is out of sync with build.rs templates."
|
||||
echo " Run 'cargo build -p agent-desktop-ffi' locally and commit the updated file."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: commands/generated.rs matches build.rs output"
|
||||
|
||||
# Gate 3 — Stub passthrough: command-backed entrypoints must return
|
||||
# structured PLATFORM_NOT_SUPPORTED envelopes via the stub adapter.
|
||||
- name: Stub passthrough test
|
||||
run: >
|
||||
cargo test --locked -p agent-desktop-ffi
|
||||
--features stub-adapter --test c_abi_passthrough
|
||||
|
||||
# Gate 4 — Panic-boundary proof: mirrors ffi-panic-guard in ci.yml so a
|
||||
# tag-only flip to panic=abort cannot slip past release gates. Two steps:
|
||||
# (a) assert the Cargo.toml profile setting; (b) run the panic_spike example
|
||||
# which exits 0 if catch_unwind caught the panic, SIGABRTs if panic=abort.
|
||||
- name: Assert release-ffi profile keeps panic=unwind
|
||||
run: |
|
||||
if ! grep -A5 '\[profile\.release-ffi\]' Cargo.toml | grep -q 'panic.*=.*"unwind"'; then
|
||||
echo "FAIL: [profile.release-ffi] must set panic=\"unwind\" — a flip to"
|
||||
echo " panic=\"abort\" defeats every trap_panic boundary in the dylib."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: release-ffi profile retains panic=\"unwind\""
|
||||
|
||||
- name: Prove catch_unwind survives the release-ffi profile
|
||||
run: cargo run --locked --profile release-ffi --example panic_spike -p agent-desktop-ffi
|
||||
|
||||
publish-github:
|
||||
name: Publish to GitHub Release
|
||||
needs: [release-please, build, build-ffi]
|
||||
needs: [release-please, build, build-ffi, ffi-release-gates]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
|
|
|
|||
|
|
@ -171,10 +171,10 @@ Phases 2–4 add adapters, transports, and production readiness work. Nothing in
|
|||
|
||||
### File Rules
|
||||
|
||||
- **400 LOC hard limit per file.** If approaching 400, split by responsibility. No exceptions.
|
||||
- **400 LOC hard limit per file.** If approaching 400, split by responsibility. No exceptions. _Exception: files bearing an `@generated` marker that are produced by `build.rs` codegen and validated by a CI drift gate are exempt — the limit applies to the hand-written templates and the build script itself, not the generated output. Do not hand-edit these files; fix the generator instead._
|
||||
- **No inline comments.** Code must be self-documenting through naming. Only Rust doc-comments (`///`) on public items when the name alone is insufficient.
|
||||
- **One struct/enum per file** for domain types. `node.rs` defines `AccessibilityNode`. `action.rs` defines `Action`.
|
||||
- **One command per file.** Each CLI command lives in its own file under `commands/`. Filename matches the command name.
|
||||
- **One command per file.** Each CLI command lives in its own file under `commands/`. Filename matches the command name. _This rule scopes to hand-written command files; a single `@generated` wrapper file that consolidates multiple command entrypoints is not a violation._
|
||||
- **No God objects.** No struct with more than 7 fields. No function with more than 5 parameters. Use builder patterns or config structs.
|
||||
- **Explicit pub boundaries.** Only `lib.rs` re-exports public items. Internal modules use `pub(crate)`. No wildcard re-exports.
|
||||
|
||||
|
|
|
|||
25
README.md
25
README.md
|
|
@ -93,35 +93,18 @@ Permission fields are explicit objects, for example:
|
|||
|
||||
## Language bindings (FFI)
|
||||
|
||||
Every GitHub Release ships a prebuilt C-ABI cdylib alongside the CLI tarballs. Hosts that need in-process calls (Python agents, Swift apps, Go services, Node tools, Ruby scripts, C/C++ code) `dlopen` the dylib and call the functions declared in `agent_desktop.h` — no fork-exec per command.
|
||||
|
||||
| Platform | Artifact |
|
||||
|----------------------|----------|
|
||||
| macOS arm64 | `agent-desktop-ffi-v<ver>-aarch64-apple-darwin.tar.gz` |
|
||||
| macOS x86_64 | `agent-desktop-ffi-v<ver>-x86_64-apple-darwin.tar.gz` |
|
||||
| Linux x86_64 (glibc) | `agent-desktop-ffi-v<ver>-x86_64-unknown-linux-gnu.tar.gz` |
|
||||
| Linux arm64 (glibc) | `agent-desktop-ffi-v<ver>-aarch64-unknown-linux-gnu.tar.gz` |
|
||||
| Windows x86_64 (MSVC)| `agent-desktop-ffi-v<ver>-x86_64-pc-windows-msvc.zip` |
|
||||
|
||||
Each archive contains `lib/libagent_desktop_ffi.{dylib,so,dll}`, `include/agent_desktop.h`, `LICENSE`, and a short README. Verify the download with the release's `checksums.txt`:
|
||||
|
||||
```bash
|
||||
shasum -a 256 -c checksums.txt
|
||||
gh attestation verify agent-desktop-ffi-v*.tar.gz --repo lahfir/agent-desktop # Sigstore provenance
|
||||
```
|
||||
|
||||
Minimal Python round-trip:
|
||||
Every GitHub Release ships a prebuilt C-ABI cdylib (`libagent_desktop_ffi`) for macOS, Linux, and Windows alongside the CLI tarballs. `dlopen` it and call the functions declared in `agent_desktop.h` for in-process calls instead of fork-exec per command.
|
||||
|
||||
```python
|
||||
import ctypes
|
||||
lib = ctypes.CDLL("./lib/libagent_desktop_ffi.dylib")
|
||||
lib.ad_adapter_create.restype = ctypes.c_void_p
|
||||
lib.ad_init(1) # verify ABI major (AD_ABI_VERSION_MAJOR) before any call
|
||||
adapter = lib.ad_adapter_create()
|
||||
# ... call ad_list_apps / ad_get_tree / ad_execute_action, see docs below
|
||||
# observe -> act: ad_snapshot -> parse an @e ref -> ad_execute_by_ref ...
|
||||
lib.ad_adapter_destroy(adapter)
|
||||
```
|
||||
|
||||
Full consumer guide — error-handling contract, ownership rules, threading constraints, every entrypoint with Safety docs: [`skills/agent-desktop-ffi/`](skills/agent-desktop-ffi/).
|
||||
Full consumer guide — entrypoints, ownership, threading, error-handling, build/link, release archives, and verification: **[`skills/agent-desktop-ffi/`](skills/agent-desktop-ffi/)**.
|
||||
|
||||
## Core Workflow for AI
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ edition.workspace = true
|
|||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[features]
|
||||
stub-adapter = []
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
|
|
|
|||
72
crates/ffi/README.md
Normal file
72
crates/ffi/README.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# agent-desktop-ffi
|
||||
|
||||
C-ABI cdylib over [`PlatformAdapter`](../../crates/core/src/adapter.rs). Exposes
|
||||
`libagent_desktop_ffi.{dylib,so,dll}` to Python (ctypes), Swift, Go (cgo),
|
||||
Node (ffi-napi), and C++ consumers without spawning a CLI subprocess per call.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
cargo build --profile release-ffi -p agent-desktop-ffi
|
||||
```
|
||||
|
||||
The `release-ffi` profile keeps `panic = "unwind"`, which is required for the
|
||||
`catch_unwind` traps that prevent panics from crossing the `extern "C"` boundary.
|
||||
Do **not** use `--release` — that profile sets `panic = "abort"`, which silently
|
||||
defeats every trap.
|
||||
|
||||
For the CI / stub-adapter path (no AX permission required):
|
||||
|
||||
```bash
|
||||
cargo build --profile release-ffi -p agent-desktop-ffi --features stub-adapter
|
||||
```
|
||||
|
||||
`--features stub-adapter` replaces the real platform adapter with a no-op that
|
||||
returns `PLATFORM_NOT_SUPPORTED` for every adapter call.
|
||||
|
||||
## ABI Surface
|
||||
|
||||
| Group | Key symbols |
|
||||
|-------|-------------|
|
||||
| ABI handshake | `ad_abi_version()`, `ad_init(expected_major)` |
|
||||
| Adapter lifecycle | `ad_adapter_create()`, `ad_adapter_destroy()` |
|
||||
| Command-backed JSON entrypoints | `ad_snapshot`, `ad_execute_by_ref`, `ad_wait`, `ad_version`, `ad_status` |
|
||||
| Log callback | `ad_set_log_callback(fn(level, msg))` |
|
||||
| Errno last-error | `ad_last_error_code()`, `ad_last_error_message()`, `ad_last_error_suggestion()`, `ad_last_error_platform_detail()` |
|
||||
| Type accessors / size getters | `ad_*_size()`, `ad_*_list_{count,get,free}()`, `ad_image_buffer_*()` |
|
||||
| Free helpers | `ad_free_string()`, `ad_free_handle()`, `ad_free_tree()`, `ad_free_action_result()` |
|
||||
|
||||
The full declaration list is in [`include/agent_desktop.h`](include/agent_desktop.h).
|
||||
Consumers must call `ad_init(AD_ABI_VERSION_MAJOR)` after `dlopen` to verify the
|
||||
loaded dylib matches the header the consumer was built against.
|
||||
|
||||
## Codegen
|
||||
|
||||
The 5 command-backed JSON entrypoints (`ad_snapshot`, `ad_execute_by_ref`,
|
||||
`ad_wait`, `ad_version`, `ad_status`) are **generated by `build.rs`** from the
|
||||
template files in [`codegen_templates/`](codegen_templates/) into
|
||||
`src/commands/generated.rs`. Do not hand-edit `generated.rs`; edit the
|
||||
corresponding `.rs.in` template instead. The build panics if a template file
|
||||
exists in `codegen_templates/` but is not registered in `build.rs`, so stale
|
||||
templates cannot silently accumulate.
|
||||
|
||||
## Thread Safety (macOS)
|
||||
|
||||
Every adapter-touching entry point must be called from the process's **main
|
||||
thread**. The guard fires at runtime in all build profiles; a worker-thread call
|
||||
returns `AD_RESULT_ERR_INTERNAL` with a `'static` diagnostic string. Operations
|
||||
safe from any thread: `ad_abi_version`, `ad_init`, adapter create/destroy,
|
||||
`ad_last_error_*` readers, and all accessor / free helpers.
|
||||
|
||||
## Error Model
|
||||
|
||||
Every `AdResult`-returning function sets thread-local last-error state on failure.
|
||||
Read it with `ad_last_error_code()` / `ad_last_error_message()` /
|
||||
`ad_last_error_suggestion()`. The pointer returned by `ad_last_error_message()`
|
||||
stays valid until the next *failing* call on the same thread — successful calls
|
||||
leave it untouched (POSIX errno semantics).
|
||||
|
||||
## Full Documentation
|
||||
|
||||
`skills/agent-desktop-ffi/` — build-and-link guide, ownership model, threading
|
||||
rules, and error-handling patterns.
|
||||
|
|
@ -14,9 +14,126 @@
|
|||
//! `CARGO_CFG_PANIC = "unwind"` in the build script regardless of the active
|
||||
//! profile's `panic` setting. The invariant is therefore documented rather
|
||||
//! than machine-enforced; CI gates the cdylib via `--profile release-ffi` only.
|
||||
//!
|
||||
//! # FFI command codegen
|
||||
//!
|
||||
//! Family-B wrappers (command-backed, JSON-returning) are generated from
|
||||
//! per-command template files under `codegen_templates/`. Each file is named
|
||||
//! `<command>.rs.in` and loaded via `include_str!` in `command_templates()`.
|
||||
//! The template set is the single source of truth for the command universe.
|
||||
//!
|
||||
//! Adding a new Family-B command requires exactly these steps:
|
||||
//! 1. Create `codegen_templates/<name>.rs.in` with the wrapper body.
|
||||
//! 2. Add `m.insert("<name>", include_str!("codegen_templates/<name>.rs.in"))` to
|
||||
//! `command_templates()` in this file.
|
||||
//! 3. Add `"<name>"` to `EXPECTED_COMMANDS` in
|
||||
//! `tests/codegen_exhaustiveness.rs`.
|
||||
//!
|
||||
//! The build fails at step 3 if steps 1 and 2 are done without step 3.
|
||||
//! An orphan `.rs.in` file (present in the dir but not in the map) triggers
|
||||
//! a build-time panic so stale templates cannot silently accumulate.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const GENERATED_HEADER_STATIC: &str = "\
|
||||
//! @generated — produced by crates/ffi/build.rs codegen.
|
||||
//! Edit the templates under crates/ffi/codegen_templates/, not this file.
|
||||
";
|
||||
|
||||
/// Map from command name to its wrapper template (alphabetical by convention).
|
||||
///
|
||||
/// This map is the single source of truth for which Family-B commands exist.
|
||||
/// Every `.rs.in` file in `codegen_templates/` must appear here; the build
|
||||
/// panics otherwise.
|
||||
fn command_templates() -> BTreeMap<&'static str, &'static str> {
|
||||
let mut m = BTreeMap::new();
|
||||
m.insert(
|
||||
"execute_by_ref",
|
||||
include_str!("codegen_templates/execute_by_ref.rs.in"),
|
||||
);
|
||||
m.insert("snapshot", include_str!("codegen_templates/snapshot.rs.in"));
|
||||
m.insert("status", include_str!("codegen_templates/status.rs.in"));
|
||||
m.insert("version", include_str!("codegen_templates/version.rs.in"));
|
||||
m.insert("wait", include_str!("codegen_templates/wait.rs.in"));
|
||||
m
|
||||
}
|
||||
|
||||
fn main() {
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") {
|
||||
println!("cargo:rustc-cdylib-link-arg=-Wl,-install_name,@rpath/libagent_desktop_ffi.dylib");
|
||||
}
|
||||
|
||||
let manifest_dir =
|
||||
PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"));
|
||||
|
||||
let templates_dir = manifest_dir.join("codegen_templates");
|
||||
let generated_path = manifest_dir.join("src/commands/generated.rs");
|
||||
|
||||
let templates = command_templates();
|
||||
|
||||
for entry in std::fs::read_dir(&templates_dir)
|
||||
.unwrap_or_else(|e| panic!("failed to read {}: {e}", templates_dir.display()))
|
||||
{
|
||||
let entry = entry.unwrap_or_else(|e| panic!("dir entry error in codegen_templates: {e}"));
|
||||
let fname = entry.file_name();
|
||||
let fname_str = fname.to_string_lossy();
|
||||
if let Some(name) = fname_str.strip_suffix(".rs.in") {
|
||||
if !templates.contains_key(name) {
|
||||
panic!(
|
||||
"orphan template file `{fname_str}` found in codegen_templates/ but not \
|
||||
registered in `command_templates()` in build.rs — add an entry or delete \
|
||||
the file."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for name in templates.keys() {
|
||||
println!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
templates_dir.join(format!("{name}.rs.in")).display()
|
||||
);
|
||||
}
|
||||
|
||||
let command_list = templates.keys().copied().collect::<Vec<_>>().join(", ");
|
||||
let dynamic_comment = format!("//! Commands in alphabetical order: {command_list}.\n");
|
||||
|
||||
let mut output = String::from(GENERATED_HEADER_STATIC);
|
||||
output.push_str(&dynamic_comment);
|
||||
|
||||
let use_block = "\
|
||||
\nuse crate::AdAdapter;\
|
||||
\nuse crate::actions::conversion::action_from_c;\
|
||||
\nuse crate::commands::app_error_to_adapter;\
|
||||
\nuse crate::commands::envelope_out::write_command_envelope;\
|
||||
\nuse crate::convert::string::{\
|
||||
\n decode_optional_filter, optional_adapter_string, required_adapter_string,\
|
||||
\n};\
|
||||
\nuse crate::convert::surface::snapshot_surface_from_c;\
|
||||
\nuse crate::error::{self, AdResult, set_last_error};\
|
||||
\nuse crate::ffi_try::trap_panic;\
|
||||
\nuse crate::main_thread::require_main_thread;\
|
||||
\nuse crate::pointer_guard::guard_non_null;\
|
||||
\nuse crate::types::wait_args::AdWaitArgs;\
|
||||
\nuse crate::types::{AdAction, AdPolicyKind};\
|
||||
\nuse agent_desktop_core::commands::snapshot::SnapshotArgs;\
|
||||
\nuse agent_desktop_core::commands::status::execute_with_report_with_context;\
|
||||
\nuse agent_desktop_core::commands::wait::{WaitArgs, WaitModeArgs, WaitPredicateArgs};\
|
||||
\nuse agent_desktop_core::error::{AdapterError, AppError, ErrorCode};\
|
||||
\nuse agent_desktop_core::refs::validate_ref_id;\
|
||||
\nuse std::ffi::c_char;\
|
||||
\nuse std::ptr;\n";
|
||||
|
||||
output.push_str(use_block);
|
||||
|
||||
for name in templates.keys() {
|
||||
output.push_str(templates[name]);
|
||||
}
|
||||
|
||||
let existing = std::fs::read_to_string(&generated_path).unwrap_or_default();
|
||||
if existing != output {
|
||||
std::fs::write(&generated_path, &output)
|
||||
.unwrap_or_else(|e| panic!("failed to write {}: {e}", generated_path.display()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,3 @@
|
|||
use crate::AdAdapter;
|
||||
use crate::actions::conversion::action_from_c;
|
||||
use crate::commands::app_error_to_adapter;
|
||||
use crate::commands::envelope_out::write_command_envelope;
|
||||
use crate::convert::string::{optional_adapter_string, required_adapter_string};
|
||||
use crate::error::{AdResult, set_last_error};
|
||||
use crate::ffi_try::trap_panic;
|
||||
use crate::main_thread::require_main_thread;
|
||||
use crate::pointer_guard::guard_non_null;
|
||||
use crate::types::{AdAction, AdPolicyKind};
|
||||
use agent_desktop_core::error::{AdapterError, ErrorCode};
|
||||
use agent_desktop_core::refs::validate_ref_id;
|
||||
use std::ffi::c_char;
|
||||
use std::ptr;
|
||||
|
||||
/// Drives a ref action (`@e5`, action) through the canonical ref-action
|
||||
/// pipeline: `RefStore` load → `RefMap` lookup (→ `STALE_REF` on missing) →
|
||||
|
|
@ -148,77 +134,3 @@ pub unsafe extern "C" fn ad_execute_by_ref(
|
|||
unsafe { write_command_envelope("execute_by_ref", result, out) }
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use agent_desktop_core::action::Action;
|
||||
use agent_desktop_core::interaction_policy::InteractionPolicy;
|
||||
|
||||
#[test]
|
||||
fn policy_kind_headless_maps_to_headless() {
|
||||
assert_eq!(
|
||||
AdPolicyKind::Headless.to_interaction_policy(),
|
||||
InteractionPolicy::headless()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_kind_focus_fallback_maps_to_focus_fallback() {
|
||||
assert_eq!(
|
||||
AdPolicyKind::FocusFallback.to_interaction_policy(),
|
||||
InteractionPolicy::focus_fallback()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_kind_headed_maps_to_headed() {
|
||||
assert_eq!(
|
||||
AdPolicyKind::Headed.to_interaction_policy(),
|
||||
InteractionPolicy::headed()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_text_base_plus_headless_caller_gives_focus_fallback() {
|
||||
let base = Action::TypeText("hi".into()).base_interaction_policy();
|
||||
let effective = base.join(InteractionPolicy::headless());
|
||||
assert_eq!(effective, InteractionPolicy::focus_fallback());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_base_plus_headless_caller_stays_headless() {
|
||||
let base = Action::Click.base_interaction_policy();
|
||||
let effective = base.join(InteractionPolicy::headless());
|
||||
assert_eq!(effective, InteractionPolicy::headless());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_base_plus_headed_caller_becomes_headed() {
|
||||
let base = Action::Click.base_interaction_policy();
|
||||
let effective = base.join(InteractionPolicy::headed());
|
||||
assert_eq!(effective, InteractionPolicy::headed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_text_base_plus_headed_caller_becomes_headed() {
|
||||
let base = Action::TypeText("x".into()).base_interaction_policy();
|
||||
let effective = base.join(InteractionPolicy::headed());
|
||||
assert_eq!(effective, InteractionPolicy::headed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_caller_cannot_downgrade_type_text_base() {
|
||||
let base = Action::TypeText("x".into()).base_interaction_policy();
|
||||
assert_eq!(base, InteractionPolicy::focus_fallback());
|
||||
let effective = base.join(InteractionPolicy::headless());
|
||||
assert_eq!(effective, InteractionPolicy::focus_fallback());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_base_plus_focus_fallback_caller_gives_focus_fallback() {
|
||||
let base = Action::Click.base_interaction_policy();
|
||||
let effective = base.join(InteractionPolicy::focus_fallback());
|
||||
assert_eq!(effective, InteractionPolicy::focus_fallback());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,3 @@
|
|||
use crate::AdAdapter;
|
||||
use crate::commands::app_error_to_adapter;
|
||||
use crate::commands::envelope_out::write_command_envelope;
|
||||
use crate::convert::string::decode_optional_filter;
|
||||
use crate::convert::surface::snapshot_surface_from_c;
|
||||
use crate::error::{AdResult, set_last_error};
|
||||
use crate::ffi_try::trap_panic;
|
||||
use crate::main_thread::require_main_thread;
|
||||
use crate::pointer_guard::guard_non_null;
|
||||
use agent_desktop_core::commands::snapshot::SnapshotArgs;
|
||||
use std::ffi::c_char;
|
||||
use std::ptr;
|
||||
|
||||
/// Takes a full CLI-format snapshot of the target application window,
|
||||
/// allocates `@e` refs for all interactive elements, persists the refmap
|
||||
|
|
@ -1,11 +1,3 @@
|
|||
use crate::commands::app_error_to_adapter;
|
||||
use crate::commands::envelope_out::write_command_envelope;
|
||||
use crate::error::{self, AdResult};
|
||||
use crate::ffi_try::trap_panic;
|
||||
use crate::pointer_guard::guard_non_null;
|
||||
use agent_desktop_core::commands::status::execute_with_report_with_context;
|
||||
use agent_desktop_core::error::AppError;
|
||||
use std::ffi::c_char;
|
||||
|
||||
/// Returns the adapter's current health and permission state as a JSON
|
||||
/// envelope matching the `agent-desktop status` CLI output.
|
||||
|
|
@ -34,7 +26,7 @@ pub unsafe extern "C" fn ad_status(
|
|||
out: *mut *mut c_char,
|
||||
) -> AdResult {
|
||||
guard_non_null!(out, c"out is null");
|
||||
unsafe { *out = std::ptr::null_mut() };
|
||||
unsafe { *out = ptr::null_mut() };
|
||||
guard_non_null!(adapter, c"adapter is null");
|
||||
|
||||
trap_panic(|| {
|
||||
|
|
@ -1,8 +1,3 @@
|
|||
use crate::commands::envelope_out::write_command_envelope;
|
||||
use crate::error::AdResult;
|
||||
use crate::ffi_try::trap_panic;
|
||||
use crate::pointer_guard::guard_non_null;
|
||||
use std::os::raw::c_char;
|
||||
|
||||
/// Returns the `agent-desktop` version envelope as an owned JSON C string.
|
||||
///
|
||||
|
|
@ -18,7 +13,7 @@ use std::os::raw::c_char;
|
|||
pub unsafe extern "C" fn ad_version(out: *mut *mut c_char) -> AdResult {
|
||||
trap_panic(|| unsafe {
|
||||
guard_non_null!(out, c"out is null");
|
||||
*out = std::ptr::null_mut();
|
||||
*out = ptr::null_mut();
|
||||
let result = agent_desktop_core::commands::version::execute();
|
||||
write_command_envelope("version", result, out)
|
||||
})
|
||||
|
|
@ -1,14 +1,3 @@
|
|||
use crate::adapter::AdAdapter;
|
||||
use crate::commands::app_error_to_adapter;
|
||||
use crate::commands::envelope_out::write_command_envelope;
|
||||
use crate::convert::string::decode_optional_filter;
|
||||
use crate::error::{self, AdResult};
|
||||
use crate::ffi_try::trap_panic;
|
||||
use crate::main_thread::require_main_thread;
|
||||
use crate::pointer_guard::guard_non_null;
|
||||
use crate::types::wait_args::AdWaitArgs;
|
||||
use agent_desktop_core::commands::wait::{WaitArgs, WaitModeArgs, WaitPredicateArgs};
|
||||
use std::ffi::c_char;
|
||||
|
||||
/// Runs `wait` with the given args, blocking the calling thread until the
|
||||
/// condition is met or `timeout_ms` elapses.
|
||||
|
|
@ -48,7 +37,7 @@ pub unsafe extern "C" fn ad_wait(
|
|||
out: *mut *mut c_char,
|
||||
) -> AdResult {
|
||||
guard_non_null!(out, c"out is null");
|
||||
unsafe { *out = std::ptr::null_mut() };
|
||||
unsafe { *out = ptr::null_mut() };
|
||||
guard_non_null!(args, c"args is null");
|
||||
|
||||
trap_panic(|| {
|
||||
|
|
@ -1,14 +1,26 @@
|
|||
//! Regression example proving the custom `release-ffi` profile keeps
|
||||
//! `catch_unwind` effective under the optimized cdylib build.
|
||||
//! Regression guard against a `panic = "abort"` regression in the `release-ffi`
|
||||
//! Cargo profile.
|
||||
//!
|
||||
//! ## What this proves
|
||||
//!
|
||||
//! The `release-ffi` profile keeps `panic = "unwind"`, so
|
||||
//! `std::panic::catch_unwind` — the same primitive that
|
||||
//! `crate::ffi_try::trap_panic` relies on — remains effective under that
|
||||
//! optimized profile. If `panic = "abort"` were accidentally restored, this
|
||||
//! example would SIGABRT instead of catching: a loud, immediate regression
|
||||
//! signal.
|
||||
//!
|
||||
//! ## What this does NOT prove
|
||||
//!
|
||||
//! This example does NOT dlopen the shipped cdylib and trigger a panic inside
|
||||
//! a real `ad_*` entrypoint's `trap_panic` fence. A full cdylib-dlopen
|
||||
//! panic-injection test is a tracked follow-up (Phase C+).
|
||||
//!
|
||||
//! ## Build and run
|
||||
//!
|
||||
//! Build and run with:
|
||||
//! cargo run --profile release-ffi --example panic_spike -p agent-desktop-ffi
|
||||
//!
|
||||
//! Expected: prints "PANIC CAUGHT OK (code = -1)" and exits with code 0.
|
||||
//!
|
||||
//! If someone accidentally flips `panic = "abort"` back on the `release-ffi`
|
||||
//! profile (or removes the profile), this example will SIGABRT instead of
|
||||
//! catching — a loud regression signal.
|
||||
//! Expected output: `PANIC CAUGHT OK (code = -1)`, exit code 0.
|
||||
|
||||
use std::panic::AssertUnwindSafe;
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,25 @@ pub struct AdAdapter {
|
|||
pub(crate) session_id: Option<String>,
|
||||
}
|
||||
|
||||
/// A no-op adapter used under the `stub-adapter` Cargo feature.
|
||||
///
|
||||
/// Every method delegates to the `PlatformAdapter` trait defaults, all of
|
||||
/// which return `not_supported()` errors. `permission_report()` returns
|
||||
/// `Denied` via the trait default, so `ad_check_permissions` yields
|
||||
/// `ErrPermDenied` on a stub build — the expected signal for a CI runner
|
||||
/// that has no OS accessibility permission.
|
||||
#[cfg(feature = "stub-adapter")]
|
||||
struct StubAdapter;
|
||||
|
||||
#[cfg(feature = "stub-adapter")]
|
||||
impl PlatformAdapter for StubAdapter {}
|
||||
|
||||
#[cfg(feature = "stub-adapter")]
|
||||
fn build_adapter() -> Box<dyn PlatformAdapter> {
|
||||
Box::new(StubAdapter)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "stub-adapter"))]
|
||||
fn build_adapter() -> Box<dyn PlatformAdapter> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
|
|
|
|||
437
crates/ffi/src/commands/generated.rs
Normal file
437
crates/ffi/src/commands/generated.rs
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
//! @generated — produced by crates/ffi/build.rs codegen.
|
||||
//! Edit the templates under crates/ffi/codegen_templates/, not this file.
|
||||
//! Commands in alphabetical order: execute_by_ref, snapshot, status, version, wait.
|
||||
|
||||
use crate::AdAdapter;
|
||||
use crate::actions::conversion::action_from_c;
|
||||
use crate::commands::app_error_to_adapter;
|
||||
use crate::commands::envelope_out::write_command_envelope;
|
||||
use crate::convert::string::{
|
||||
decode_optional_filter, optional_adapter_string, required_adapter_string,
|
||||
};
|
||||
use crate::convert::surface::snapshot_surface_from_c;
|
||||
use crate::error::{self, AdResult, set_last_error};
|
||||
use crate::ffi_try::trap_panic;
|
||||
use crate::main_thread::require_main_thread;
|
||||
use crate::pointer_guard::guard_non_null;
|
||||
use crate::types::wait_args::AdWaitArgs;
|
||||
use crate::types::{AdAction, AdPolicyKind};
|
||||
use agent_desktop_core::commands::snapshot::SnapshotArgs;
|
||||
use agent_desktop_core::commands::status::execute_with_report_with_context;
|
||||
use agent_desktop_core::commands::wait::{WaitArgs, WaitModeArgs, WaitPredicateArgs};
|
||||
use agent_desktop_core::error::{AdapterError, AppError, ErrorCode};
|
||||
use agent_desktop_core::refs::validate_ref_id;
|
||||
use std::ffi::c_char;
|
||||
use std::ptr;
|
||||
|
||||
/// Drives a ref action (`@e5`, action) through the canonical ref-action
|
||||
/// pipeline: `RefStore` load → `RefMap` lookup (→ `STALE_REF` on missing) →
|
||||
/// strict element resolution (→ `STALE_REF`/`AMBIGUOUS_TARGET`) → live
|
||||
/// actionability preflight → dispatch → handle release.
|
||||
///
|
||||
/// Policy: `TypeText` defaults to `focus_fallback` (matching the CLI `type`
|
||||
/// command); `PressKey` shares that `focus_fallback` base (a ref-targeted key
|
||||
/// press may need the target focused); every other action defaults to
|
||||
/// `headless`. An explicit `policy` discriminant may *elevate* to headed but
|
||||
/// must not downgrade an action below its base. Base and elevation are computed
|
||||
/// by `agent_desktop_core::commands::execute_by_ref::execute` via
|
||||
/// `Action::base_interaction_policy` + `InteractionPolicy::join`, so CLI and
|
||||
/// FFI share a single source of policy truth.
|
||||
///
|
||||
/// `ref_id` tri-state: null → `ErrInvalidArgs`; non-null invalid UTF-8 →
|
||||
/// `ErrInvalidArgs`; valid UTF-8 but bad `@e{N}` format → `ErrInvalidArgs`.
|
||||
///
|
||||
/// `snapshot_id` tri-state: null → use the latest snapshot for the session
|
||||
/// (CLI `--snapshot` omitted); valid UTF-8 → pin to that snapshot id; non-null
|
||||
/// invalid UTF-8 → `ErrInvalidArgs`.
|
||||
///
|
||||
/// `policy` is an `AdPolicyKind` discriminant (0=Headless, 1=FocusFallback,
|
||||
/// 2=Headed). An out-of-range value returns `ErrInvalidArgs`. `Headless (0)`
|
||||
/// accepts the action's own CLI base (so `TypeText` still uses
|
||||
/// `focus_fallback`). `Headed (2)` opts in to cursor-based fallbacks.
|
||||
///
|
||||
/// On success `*out` is set to a NUL-terminated JSON envelope (command
|
||||
/// `"execute_by_ref"`); free with `ad_free_string`. On guard or decode
|
||||
/// failure (invalid args before the command runs) `*out` remains null.
|
||||
/// On a command-level error (STALE_REF, AMBIGUOUS_TARGET, etc.) `*out`
|
||||
/// holds the error JSON envelope and must still be freed with
|
||||
/// `ad_free_string`. The last-error slot is populated on all failures.
|
||||
///
|
||||
/// **Dispatch-before-serialize ordering**: the action is dispatched (and any
|
||||
/// side effects committed) before the result JSON is serialized. In the
|
||||
/// near-impossible event that serialization of an already-valid
|
||||
/// `ActionResult` fails, `*out` is null and `ErrInternal` is returned while
|
||||
/// the side effect has already occurred. No pre-validation machinery is
|
||||
/// needed because serialization of a valid envelope effectively never fails.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// `adapter` must be a non-null pointer from `ad_adapter_create[_with_session]`.
|
||||
/// `ref_id` must be a non-null pointer to a NUL-terminated C string within
|
||||
/// `AD_MAX_STRING_BYTES + 1` bytes; null is **not** optional — it is defined
|
||||
/// behaviour (no UB) but is rejected immediately with `ErrInvalidArgs`.
|
||||
/// `snapshot_id` may be null (meaning: use the latest snapshot for this
|
||||
/// session) or a non-null NUL-terminated C string within
|
||||
/// `AD_MAX_STRING_BYTES + 1` bytes. `action` must be a non-null pointer to a
|
||||
/// valid `AdAction`. `out` must be a non-null writable pointer. All pointers
|
||||
/// must remain valid for the duration of the call. Must be called from the
|
||||
/// main thread on macOS.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn ad_execute_by_ref(
|
||||
adapter: *const AdAdapter,
|
||||
ref_id: *const c_char,
|
||||
snapshot_id: *const c_char,
|
||||
action: *const AdAction,
|
||||
policy: i32,
|
||||
out: *mut *mut c_char,
|
||||
) -> AdResult {
|
||||
guard_non_null!(out, c"out is null");
|
||||
unsafe { *out = ptr::null_mut() };
|
||||
trap_panic(|| {
|
||||
if let Err(rc) = require_main_thread() {
|
||||
return rc;
|
||||
}
|
||||
guard_non_null!(adapter, c"adapter is null");
|
||||
guard_non_null!(action, c"action is null");
|
||||
|
||||
let ref_str = match required_adapter_string(ref_id, "ref_id") {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
set_last_error(&e);
|
||||
return AdResult::ErrInvalidArgs;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(app_err) = validate_ref_id(&ref_str) {
|
||||
let ae = app_error_to_adapter(app_err);
|
||||
set_last_error(&ae);
|
||||
return crate::error::last_error_code();
|
||||
}
|
||||
|
||||
let snapshot_str = match optional_adapter_string(snapshot_id, "snapshot_id") {
|
||||
Ok(opt) => opt,
|
||||
Err(e) => {
|
||||
set_last_error(&e);
|
||||
return AdResult::ErrInvalidArgs;
|
||||
}
|
||||
};
|
||||
|
||||
let caller_policy = match AdPolicyKind::from_c(policy) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
set_last_error(&AdapterError::new(
|
||||
ErrorCode::InvalidArgs,
|
||||
"invalid policy kind discriminant",
|
||||
));
|
||||
return AdResult::ErrInvalidArgs;
|
||||
}
|
||||
};
|
||||
|
||||
let core_action = match unsafe { action_from_c(&*action) } {
|
||||
Ok(a) => a,
|
||||
Err(msg) => {
|
||||
set_last_error(&AdapterError::new(ErrorCode::InvalidArgs, msg));
|
||||
return AdResult::ErrInvalidArgs;
|
||||
}
|
||||
};
|
||||
|
||||
let caller_ip = caller_policy.to_interaction_policy();
|
||||
|
||||
let adapter_ref = unsafe { &*adapter };
|
||||
let context = match adapter_ref.command_context() {
|
||||
Ok(ctx) => ctx,
|
||||
Err(e) => {
|
||||
let ae = app_error_to_adapter(e);
|
||||
set_last_error(&ae);
|
||||
return crate::error::last_error_code();
|
||||
}
|
||||
};
|
||||
|
||||
let result = agent_desktop_core::commands::execute_by_ref::execute(
|
||||
&ref_str,
|
||||
snapshot_str.as_deref(),
|
||||
core_action,
|
||||
caller_ip,
|
||||
adapter_ref.inner.as_ref(),
|
||||
&context,
|
||||
);
|
||||
|
||||
unsafe { write_command_envelope("execute_by_ref", result, out) }
|
||||
})
|
||||
}
|
||||
|
||||
/// Takes a full CLI-format snapshot of the target application window,
|
||||
/// allocates `@e` refs for all interactive elements, persists the refmap
|
||||
/// to disk, and writes the JSON envelope into `*out`.
|
||||
///
|
||||
/// The JSON shape matches `agent-desktop snapshot`:
|
||||
/// `{"version":"2.0","ok":true,"command":"snapshot","data":{"app":"...","window":{...},"ref_count":N,"snapshot_id":"...","tree":{...}}}`.
|
||||
///
|
||||
/// **`*out` ownership and error behaviour:**
|
||||
/// - On success (`AD_RESULT_OK`): `*out` is a heap-allocated JSON string with `"ok":true`.
|
||||
/// Caller must free it with `ad_free_string`.
|
||||
/// - On a command-level error (e.g. app not found, snapshot failure): `*out` is a
|
||||
/// heap-allocated JSON string with `"ok":false` and an `"error"` payload. Caller
|
||||
/// must still free it with `ad_free_string`. The last-error slot is also set.
|
||||
/// - On an argument or infrastructure error (null adapter, off-main-thread, invalid
|
||||
/// UTF-8, bad surface discriminant, context failure): `*out` is set to null and no
|
||||
/// allocation is made. Only the last-error slot is set.
|
||||
///
|
||||
/// `app` is tri-state:
|
||||
/// - null — snapshot the currently focused window (same as running the command with no `--app`).
|
||||
/// - valid UTF-8 string — snapshot the named application's focused window.
|
||||
/// - non-null but invalid UTF-8 or exceeding `AD_MAX_STRING_BYTES` — returns `ErrInvalidArgs`.
|
||||
///
|
||||
/// `surface` is an `AdSnapshotSurface` discriminant (0 = Window, 1 = Focused, …).
|
||||
/// An out-of-range value returns `ErrInvalidArgs`.
|
||||
///
|
||||
/// This entrypoint always targets the active focused window of the requested
|
||||
/// application; explicit window targeting (`window_id`) is not yet exposed
|
||||
/// over the ABI. Progressive traversal (skeleton mode and `--root` drill-down)
|
||||
/// is likewise not exposed here. Both are planned fast-follows to this
|
||||
/// entrypoint — agents needing them should use the CLI in the meantime.
|
||||
///
|
||||
/// **Dispatch-before-serialize ordering**: the snapshot and refmap persistence
|
||||
/// occur before the result JSON is serialised. In the near-impossible event
|
||||
/// that serialisation of an already-valid result fails, `*out` is set to null
|
||||
/// and `ErrInternal` is returned while the refmap is already written.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// `adapter` must be a non-null pointer from `ad_adapter_create` or
|
||||
/// `ad_adapter_create_with_session`. `out` must be a non-null writable
|
||||
/// `*mut *mut c_char`. `app` must be null or a NUL-terminated string within
|
||||
/// `AD_MAX_STRING_BYTES + 1` bytes. All pointers must remain valid for the
|
||||
/// duration of the call. `adapter` must be used from the main thread on macOS.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn ad_snapshot(
|
||||
adapter: *const AdAdapter,
|
||||
app: *const c_char,
|
||||
surface: i32,
|
||||
max_depth: u8,
|
||||
interactive_only: bool,
|
||||
compact: bool,
|
||||
out: *mut *mut c_char,
|
||||
) -> AdResult {
|
||||
guard_non_null!(out, c"out is null");
|
||||
unsafe { *out = ptr::null_mut() };
|
||||
trap_panic(|| {
|
||||
if let Err(rc) = require_main_thread() {
|
||||
return rc;
|
||||
}
|
||||
guard_non_null!(adapter, c"adapter is null");
|
||||
|
||||
let app_filter = unsafe { decode_optional_filter!(app, "app") };
|
||||
|
||||
let core_surface = match snapshot_surface_from_c(surface, "surface") {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
set_last_error(&e);
|
||||
return AdResult::ErrInvalidArgs;
|
||||
}
|
||||
};
|
||||
|
||||
let adapter_ref = unsafe { &*adapter };
|
||||
let context = match adapter_ref.command_context() {
|
||||
Ok(ctx) => ctx,
|
||||
Err(e) => {
|
||||
let ae = app_error_to_adapter(e);
|
||||
set_last_error(&ae);
|
||||
return crate::error::last_error_code();
|
||||
}
|
||||
};
|
||||
|
||||
let args = SnapshotArgs {
|
||||
app: app_filter,
|
||||
window_id: None,
|
||||
max_depth,
|
||||
include_bounds: false,
|
||||
interactive_only,
|
||||
compact,
|
||||
surface: core_surface,
|
||||
skeleton: false,
|
||||
root_ref: None,
|
||||
snapshot_id: None,
|
||||
};
|
||||
|
||||
let result = agent_desktop_core::commands::snapshot::execute(
|
||||
args,
|
||||
adapter_ref.inner.as_ref(),
|
||||
&context,
|
||||
);
|
||||
|
||||
unsafe { write_command_envelope("snapshot", result, out) }
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the adapter's current health and permission state as a JSON
|
||||
/// envelope matching the `agent-desktop status` CLI output.
|
||||
///
|
||||
/// `ad_status` does not query the accessibility tree; it reads the
|
||||
/// permission report and ref-store metadata only, so it is safe to call
|
||||
/// from any thread (unlike tree-traversal commands that require the
|
||||
/// macOS main thread). On success `*out` is a NUL-terminated,
|
||||
/// heap-allocated JSON string freed with `ad_free_string`.
|
||||
///
|
||||
/// On a command-level failure `*out` is set to a heap-allocated JSON string
|
||||
/// with `"ok":false` and an `"error"` payload. The caller must still release
|
||||
/// it with `ad_free_string(*out)`. The last-error slot is also set.
|
||||
///
|
||||
/// On an argument or infrastructure failure (null adapter, null out, context
|
||||
/// error) `*out` is zeroed and only the last-error slot is populated.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// `adapter` must be a non-null pointer returned by `ad_adapter_create`
|
||||
/// that has not been destroyed. `out` must be a non-null writable
|
||||
/// `*mut *mut c_char`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn ad_status(
|
||||
adapter: *const crate::AdAdapter,
|
||||
out: *mut *mut c_char,
|
||||
) -> AdResult {
|
||||
guard_non_null!(out, c"out is null");
|
||||
unsafe { *out = ptr::null_mut() };
|
||||
guard_non_null!(adapter, c"adapter is null");
|
||||
|
||||
trap_panic(|| {
|
||||
let adapter = unsafe { &*adapter };
|
||||
|
||||
let ctx = match adapter.command_context() {
|
||||
Ok(c) => c,
|
||||
Err(app_err) => {
|
||||
let ae = app_error_to_adapter(app_err);
|
||||
error::set_last_error(&ae);
|
||||
return error::last_error_code();
|
||||
}
|
||||
};
|
||||
|
||||
let report = adapter.inner.permission_report();
|
||||
|
||||
let result: Result<serde_json::Value, AppError> =
|
||||
execute_with_report_with_context(&*adapter.inner, &report, &ctx);
|
||||
|
||||
unsafe { write_command_envelope("status", result, out) }
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the `agent-desktop` version envelope as an owned JSON C string.
|
||||
///
|
||||
/// The returned string has the same `{version, ok, command, data}` shape
|
||||
/// as `agent-desktop version` on the CLI. Free it with `ad_free_string`.
|
||||
///
|
||||
/// On success `*out` points to the envelope JSON.
|
||||
/// On error `*out` is null and the last-error slot is populated.
|
||||
///
|
||||
/// # Safety
|
||||
/// `out` must be a non-null writable `*mut *mut c_char`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn ad_version(out: *mut *mut c_char) -> AdResult {
|
||||
trap_panic(|| unsafe {
|
||||
guard_non_null!(out, c"out is null");
|
||||
*out = ptr::null_mut();
|
||||
let result = agent_desktop_core::commands::version::execute();
|
||||
write_command_envelope("version", result, out)
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs `wait` with the given args, blocking the calling thread until the
|
||||
/// condition is met or `timeout_ms` elapses.
|
||||
///
|
||||
/// On success `*out` is set to a freshly allocated JSON string containing the
|
||||
/// CLI-format wait envelope (`{version, ok, command, data}`). The caller must
|
||||
/// release the string with `ad_free_string(*out)`.
|
||||
///
|
||||
/// On a command-level failure (e.g. `TIMEOUT`, `ELEMENT_NOT_FOUND`) `*out` is
|
||||
/// set to a freshly allocated JSON string with `"ok":false` and an `"error"`
|
||||
/// payload. The caller must still release it with `ad_free_string(*out)`. The
|
||||
/// last-error slot is also set.
|
||||
///
|
||||
/// On an argument or infrastructure failure (null adapter, null args, null out,
|
||||
/// off-main-thread, invalid UTF-8 field) `*out` is zeroed, the last-error slot
|
||||
/// is set, and a negative `AdResult` code is returned. No allocation is made.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// `adapter` must be a non-null pointer returned by `ad_adapter_create` that
|
||||
/// has not been destroyed. `args` must be non-null and point to a valid
|
||||
/// zero-initialized `AdWaitArgs`. `out` must be non-null and point to a
|
||||
/// writable `*mut c_char`.
|
||||
///
|
||||
/// All `*const c_char` fields inside `AdWaitArgs` must be null or point to
|
||||
/// readable, NUL-terminated memory within `AD_MAX_STRING_BYTES + 1` bytes.
|
||||
///
|
||||
/// `ad_wait` blocks the calling thread for up to `timeout_ms` milliseconds
|
||||
/// while it holds a live reference into the adapter's allocation. The adapter
|
||||
/// must outlive the call: do not call `ad_adapter_destroy` on this handle from
|
||||
/// another thread while `ad_wait` is running — that is a use-after-free. Ensure
|
||||
/// the wait has returned before destroying the adapter.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn ad_wait(
|
||||
adapter: *const AdAdapter,
|
||||
args: *const AdWaitArgs,
|
||||
out: *mut *mut c_char,
|
||||
) -> AdResult {
|
||||
guard_non_null!(out, c"out is null");
|
||||
unsafe { *out = ptr::null_mut() };
|
||||
guard_non_null!(args, c"args is null");
|
||||
|
||||
trap_panic(|| {
|
||||
if let Err(rc) = require_main_thread() {
|
||||
return rc;
|
||||
}
|
||||
guard_non_null!(adapter, c"adapter is null");
|
||||
|
||||
let args = unsafe { &*args };
|
||||
let adapter_ref = unsafe { &*adapter };
|
||||
|
||||
let ms = args.has_ms.then_some(args.ms);
|
||||
|
||||
let element = unsafe { decode_optional_filter!(args.element, "element") };
|
||||
let window = unsafe { decode_optional_filter!(args.window, "window") };
|
||||
let text = unsafe { decode_optional_filter!(args.text, "text") };
|
||||
let snapshot_id = unsafe { decode_optional_filter!(args.snapshot_id, "snapshot_id") };
|
||||
let predicate = unsafe { decode_optional_filter!(args.predicate, "predicate") };
|
||||
let value = unsafe { decode_optional_filter!(args.value, "value") };
|
||||
let action_field = unsafe { decode_optional_filter!(args.action, "action") };
|
||||
let app = unsafe { decode_optional_filter!(args.app, "app") };
|
||||
|
||||
let wait_args = WaitArgs {
|
||||
mode: WaitModeArgs {
|
||||
ms,
|
||||
element,
|
||||
window,
|
||||
text,
|
||||
menu: args.menu,
|
||||
menu_closed: args.menu_closed,
|
||||
notification: args.notification,
|
||||
},
|
||||
predicate: WaitPredicateArgs {
|
||||
snapshot_id,
|
||||
predicate,
|
||||
value,
|
||||
action: action_field,
|
||||
count: args.has_count.then_some(args.count),
|
||||
},
|
||||
timeout_ms: args.timeout_ms,
|
||||
app,
|
||||
};
|
||||
|
||||
let ctx = match adapter_ref.command_context() {
|
||||
Ok(c) => c,
|
||||
Err(app_err) => {
|
||||
let adapter_err = app_error_to_adapter(app_err);
|
||||
error::set_last_error(&adapter_err);
|
||||
return error::last_error_code();
|
||||
}
|
||||
};
|
||||
|
||||
let result = agent_desktop_core::commands::wait::execute(
|
||||
wait_args,
|
||||
adapter_ref.inner.as_ref(),
|
||||
&ctx,
|
||||
);
|
||||
|
||||
unsafe { write_command_envelope("wait", result, out) }
|
||||
})
|
||||
}
|
||||
|
|
@ -1,9 +1,5 @@
|
|||
pub(crate) mod envelope_out;
|
||||
pub mod execute_by_ref;
|
||||
pub mod snapshot;
|
||||
pub(crate) mod status;
|
||||
pub(crate) mod version;
|
||||
pub mod wait;
|
||||
pub(crate) mod generated;
|
||||
|
||||
use agent_desktop_core::error::{AdapterError, AppError, ErrorCode};
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ impl AdPolicyKind {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use agent_desktop_core::interaction_policy::InteractionPolicy;
|
||||
|
||||
#[test]
|
||||
fn discriminants_are_abi_stable() {
|
||||
|
|
@ -30,4 +31,28 @@ mod tests {
|
|||
assert_eq!(AdPolicyKind::FocusFallback as i32, 1);
|
||||
assert_eq!(AdPolicyKind::Headed as i32, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_kind_headless_maps_to_headless() {
|
||||
assert_eq!(
|
||||
AdPolicyKind::Headless.to_interaction_policy(),
|
||||
InteractionPolicy::headless()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_kind_focus_fallback_maps_to_focus_fallback() {
|
||||
assert_eq!(
|
||||
AdPolicyKind::FocusFallback.to_interaction_policy(),
|
||||
InteractionPolicy::focus_fallback()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_kind_headed_maps_to_headed() {
|
||||
assert_eq!(
|
||||
AdPolicyKind::Headed.to_interaction_policy(),
|
||||
InteractionPolicy::headed()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
322
crates/ffi/tests/c_abi_passthrough.rs
Normal file
322
crates/ffi/tests/c_abi_passthrough.rs
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
/// Stub-adapter passthrough tests for Family-B command-backed entrypoints.
|
||||
/// Gate with `--features stub-adapter`.
|
||||
///
|
||||
/// # Coverage
|
||||
///
|
||||
/// This file exercises:
|
||||
/// - **Family-B command-backed entrypoints**: `ad_snapshot`, `ad_status`,
|
||||
/// `ad_wait`, `ad_execute_by_ref`, `ad_version`
|
||||
/// - **Adapter lifecycle**: `ad_adapter_create`, `ad_adapter_destroy`
|
||||
/// - **Permissions**: `ad_check_permissions`
|
||||
///
|
||||
/// The ~35 Family-A entrypoints (`ad_find`, `ad_execute_action`,
|
||||
/// `ad_list_windows`, `ad_screenshot`, clipboard, notifications, etc.) are
|
||||
/// **not covered here**; broader Family-A passthrough is a documented
|
||||
/// follow-up.
|
||||
///
|
||||
/// # Why the stub returns `not_supported`
|
||||
///
|
||||
/// The stub's `PlatformAdapter` impl delegates all methods to the trait
|
||||
/// defaults, which uniformly return `AdapterError::not_supported(…)` →
|
||||
/// `ErrorCode::PlatformNotSupported`. The JSON envelope therefore carries
|
||||
/// `"ok":false` and `"error":{"code":"PLATFORM_NOT_SUPPORTED","suggestion":…}`.
|
||||
///
|
||||
/// # Exception — `ad_check_permissions`
|
||||
///
|
||||
/// The stub's `permission_report()` returns `PermissionState::Denied` (the
|
||||
/// trait default), not `Unknown`. The FFI maps `Denied` to `ErrPermDenied
|
||||
/// (-1)`, not `ErrPlatformNotSupported (-8)`. This is the documented signal
|
||||
/// that permissions are unavailable on the platform; callers should treat
|
||||
/// both `ErrPermDenied` and `ErrPlatformNotSupported` as "adapter not
|
||||
/// operational here".
|
||||
///
|
||||
/// # Main-thread tolerance
|
||||
///
|
||||
/// `ad_snapshot`, `ad_wait`, and `ad_execute_by_ref` each call
|
||||
/// `require_main_thread()` before touching the adapter. The libtest harness
|
||||
/// spawns each `#[test]` on a worker thread, so on macOS those guards fire
|
||||
/// first and return `ErrInternal` with no JSON envelope. The tests accept
|
||||
/// either outcome:
|
||||
/// - `ErrInternal` (off-main-thread on macOS, no envelope produced) — ok.
|
||||
/// - `ErrPlatformNotSupported` + valid JSON envelope — ok.
|
||||
///
|
||||
/// Both outcomes are correct; neither is a regression.
|
||||
///
|
||||
/// Commands gated by `#[cfg(feature = "stub-adapter")]` so they compile only
|
||||
/// when the feature is active. The normal test build (`cargo test -p
|
||||
/// agent-desktop-ffi --tests`) never compiles or runs this file.
|
||||
#[cfg(feature = "stub-adapter")]
|
||||
mod common;
|
||||
|
||||
#[cfg(feature = "stub-adapter")]
|
||||
use common::{
|
||||
AdResult, AdWaitArgs, CStr, ad_adapter_create, ad_adapter_destroy, ad_check_permissions,
|
||||
ad_execute_by_ref, ad_free_string, ad_last_error_code, ad_last_error_message, ad_snapshot,
|
||||
ad_status, ad_version, ad_wait, default_action, with_adapter,
|
||||
};
|
||||
|
||||
/// A helper that parses the JSON envelope written to `*out` and asserts
|
||||
/// `PLATFORM_NOT_SUPPORTED` shape. Returns `true` when the envelope was
|
||||
/// present and verified; `false` when `*out` is null (meaning the function
|
||||
/// returned early — e.g. due to the macOS off-main-thread guard firing before
|
||||
/// the adapter was reached).
|
||||
#[cfg(feature = "stub-adapter")]
|
||||
unsafe fn assert_platform_not_supported_envelope(out: *mut std::os::raw::c_char) -> bool {
|
||||
if out.is_null() {
|
||||
return false;
|
||||
}
|
||||
let json_str = unsafe { CStr::from_ptr(out) }
|
||||
.to_str()
|
||||
.expect("envelope must be valid UTF-8");
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(json_str).expect("envelope must be valid JSON");
|
||||
assert_eq!(
|
||||
parsed["ok"].as_bool(),
|
||||
Some(false),
|
||||
"stub adapter must produce ok:false envelope — got: {json_str}"
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["error"]["code"].as_str(),
|
||||
Some("PLATFORM_NOT_SUPPORTED"),
|
||||
"error.code must be PLATFORM_NOT_SUPPORTED — got: {json_str}"
|
||||
);
|
||||
let suggestion = parsed["error"]["suggestion"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
!suggestion.is_empty(),
|
||||
"error.suggestion must be non-empty — got: {json_str}"
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
/// `ad_version` has no adapter dependency and must always succeed even on a
|
||||
/// stub build.
|
||||
#[cfg(feature = "stub-adapter")]
|
||||
#[test]
|
||||
fn stub_ad_version_always_succeeds() {
|
||||
unsafe {
|
||||
let mut out: *mut std::os::raw::c_char = std::ptr::null_mut();
|
||||
let rc = ad_version(&mut out);
|
||||
assert_eq!(
|
||||
rc,
|
||||
AdResult::Ok,
|
||||
"ad_version must succeed regardless of adapter (no adapter dependency)"
|
||||
);
|
||||
assert!(!out.is_null(), "out must be non-null on success");
|
||||
let json_str = CStr::from_ptr(out).to_str().expect("valid UTF-8");
|
||||
let parsed: serde_json::Value = serde_json::from_str(json_str).expect("valid JSON");
|
||||
assert_eq!(parsed["ok"].as_bool(), Some(true));
|
||||
assert!(parsed["data"]["version"].is_string());
|
||||
ad_free_string(out);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ad_check_permissions` maps the stub adapter's `Denied` permission state
|
||||
/// to `ErrPermDenied (-1)`, not `ErrPlatformNotSupported (-8)`. This is the
|
||||
/// documented exception. Cross-platform callers must treat both codes as
|
||||
/// "adapter not operational here".
|
||||
#[cfg(feature = "stub-adapter")]
|
||||
#[test]
|
||||
fn stub_ad_check_permissions_returns_err_perm_denied() {
|
||||
with_adapter(|adapter| unsafe {
|
||||
let rc = ad_check_permissions(adapter);
|
||||
assert_eq!(
|
||||
rc,
|
||||
AdResult::ErrPermDenied,
|
||||
"stub adapter permission_report() returns Denied → ErrPermDenied (-1), \
|
||||
not ErrPlatformNotSupported (-8). Both mean the adapter is not operational."
|
||||
);
|
||||
let msg = ad_last_error_message();
|
||||
assert!(
|
||||
!msg.is_null(),
|
||||
"last-error message must be set on ErrPermDenied"
|
||||
);
|
||||
assert_eq!(ad_last_error_code(), AdResult::ErrPermDenied);
|
||||
});
|
||||
}
|
||||
|
||||
/// `ad_status` is not main-thread gated. Under the stub adapter it returns a
|
||||
/// valid JSON envelope with `ok:true` because `execute_with_report_with_context`
|
||||
/// reports the Denied permission state as a valid (non-error) status payload.
|
||||
///
|
||||
/// This test asserts the envelope is produced and is valid JSON — the specific
|
||||
/// permission values are stub-specific but the shape must always match the CLI.
|
||||
#[cfg(feature = "stub-adapter")]
|
||||
#[test]
|
||||
fn stub_ad_status_returns_valid_envelope() {
|
||||
with_adapter(|adapter| unsafe {
|
||||
let mut out: *mut std::os::raw::c_char = std::ptr::null_mut();
|
||||
let rc = ad_status(adapter, &mut out);
|
||||
assert!(
|
||||
!out.is_null(),
|
||||
"ad_status must always produce an envelope (ok or error) — rc={rc:?}"
|
||||
);
|
||||
let json_str = CStr::from_ptr(out)
|
||||
.to_str()
|
||||
.expect("envelope must be valid UTF-8");
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(json_str).expect("envelope must be valid JSON");
|
||||
assert!(
|
||||
parsed["ok"].is_boolean(),
|
||||
"envelope must carry ok field — got: {json_str}"
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["command"].as_str(),
|
||||
Some("status"),
|
||||
"command field must be 'status'"
|
||||
);
|
||||
let _ = rc;
|
||||
ad_free_string(out);
|
||||
});
|
||||
}
|
||||
|
||||
/// `ad_snapshot` calls `require_main_thread()` before reaching the adapter.
|
||||
/// On macOS the libtest worker thread fires the guard first, returning
|
||||
/// `ErrInternal` with no envelope. On non-macOS the call proceeds to the stub
|
||||
/// adapter and must produce a `PLATFORM_NOT_SUPPORTED` error envelope.
|
||||
#[cfg(feature = "stub-adapter")]
|
||||
#[test]
|
||||
fn stub_ad_snapshot_platform_not_supported_or_off_main_thread() {
|
||||
with_adapter(|adapter| unsafe {
|
||||
let mut out: *mut std::os::raw::c_char = std::ptr::null_mut();
|
||||
let rc = ad_snapshot(adapter, std::ptr::null(), 0, 6, false, false, &mut out);
|
||||
match rc {
|
||||
AdResult::ErrInternal => {
|
||||
assert!(
|
||||
out.is_null(),
|
||||
"ErrInternal (off-main-thread guard) must leave out null"
|
||||
);
|
||||
}
|
||||
AdResult::ErrPlatformNotSupported => {
|
||||
let had_envelope = assert_platform_not_supported_envelope(out);
|
||||
assert!(
|
||||
had_envelope,
|
||||
"ErrPlatformNotSupported must be accompanied by an error envelope"
|
||||
);
|
||||
ad_free_string(out);
|
||||
}
|
||||
other => {
|
||||
panic!(
|
||||
"stub ad_snapshot must return ErrInternal (macOS off-main-thread) or \
|
||||
ErrPlatformNotSupported, got {other:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// `ad_wait` is exercised in its adapter-free `ms` mode: it sleeps for `ms`
|
||||
/// and returns an Ok envelope without touching the adapter, proving the
|
||||
/// entrypoint is callable and structured under the stub without crashing (or
|
||||
/// `ErrInternal` off the main thread on macOS). ad_wait's adapter-dependent
|
||||
/// element/predicate modes need a real refmap from a successful snapshot,
|
||||
/// which the stub cannot produce, so PLATFORM_NOT_SUPPORTED parity for those
|
||||
/// modes is covered by the real E2E harness rather than this stub gate.
|
||||
#[cfg(feature = "stub-adapter")]
|
||||
#[test]
|
||||
fn stub_ad_wait_ms_mode_callable_under_stub() {
|
||||
with_adapter(|adapter| unsafe {
|
||||
let args = AdWaitArgs {
|
||||
ms: 1,
|
||||
has_ms: true,
|
||||
element: std::ptr::null(),
|
||||
window: std::ptr::null(),
|
||||
text: std::ptr::null(),
|
||||
menu: false,
|
||||
menu_closed: false,
|
||||
notification: false,
|
||||
snapshot_id: std::ptr::null(),
|
||||
predicate: std::ptr::null(),
|
||||
value: std::ptr::null(),
|
||||
action: std::ptr::null(),
|
||||
count: 0,
|
||||
has_count: false,
|
||||
timeout_ms: 200,
|
||||
app: std::ptr::null(),
|
||||
};
|
||||
let mut out: *mut std::os::raw::c_char = std::ptr::null_mut();
|
||||
let rc = ad_wait(adapter, &args, &mut out);
|
||||
match rc {
|
||||
AdResult::Ok => {
|
||||
if !out.is_null() {
|
||||
ad_free_string(out);
|
||||
}
|
||||
}
|
||||
AdResult::ErrInternal => {
|
||||
assert!(
|
||||
out.is_null(),
|
||||
"ErrInternal (off-main-thread guard) must leave out null"
|
||||
);
|
||||
}
|
||||
other => {
|
||||
panic!(
|
||||
"stub ad_wait (ms mode) must return Ok (adapter-free timer) or \
|
||||
ErrInternal (macOS off-main-thread), got {other:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// `ad_execute_by_ref` calls `require_main_thread()` before reaching the
|
||||
/// adapter. Same main-thread tolerance applies. Uses a syntactically valid
|
||||
/// ref-id so arg-decode succeeds before the thread check.
|
||||
#[cfg(feature = "stub-adapter")]
|
||||
#[test]
|
||||
fn stub_ad_execute_by_ref_platform_not_supported_or_off_main_thread() {
|
||||
with_adapter(|adapter| unsafe {
|
||||
let ref_id = std::ffi::CString::new("@e1").unwrap();
|
||||
let action = default_action();
|
||||
let mut out: *mut std::os::raw::c_char = std::ptr::null_mut();
|
||||
let rc = ad_execute_by_ref(
|
||||
adapter,
|
||||
ref_id.as_ptr(),
|
||||
std::ptr::null(),
|
||||
&action,
|
||||
0,
|
||||
&mut out,
|
||||
);
|
||||
match rc {
|
||||
AdResult::ErrInternal => {
|
||||
assert!(
|
||||
out.is_null(),
|
||||
"ErrInternal (off-main-thread guard) must leave out null"
|
||||
);
|
||||
}
|
||||
AdResult::ErrPlatformNotSupported
|
||||
| AdResult::ErrSnapshotNotFound
|
||||
| AdResult::ErrStaleRef => {
|
||||
if !out.is_null() {
|
||||
let json_str = CStr::from_ptr(out)
|
||||
.to_str()
|
||||
.expect("envelope must be valid UTF-8");
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(json_str).expect("envelope must be valid JSON");
|
||||
assert_eq!(parsed["ok"].as_bool(), Some(false));
|
||||
assert!(!parsed["error"]["code"].as_str().unwrap_or("").is_empty());
|
||||
ad_free_string(out);
|
||||
}
|
||||
}
|
||||
other => {
|
||||
panic!(
|
||||
"stub ad_execute_by_ref must return ErrInternal, ErrPlatformNotSupported, \
|
||||
ErrSnapshotNotFound, or ErrStaleRef, got {other:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Confirm that `ad_adapter_create` itself does not panic under the stub
|
||||
/// feature and produces a non-null handle that can be destroyed cleanly.
|
||||
#[cfg(feature = "stub-adapter")]
|
||||
#[test]
|
||||
fn stub_adapter_create_and_destroy_round_trip() {
|
||||
unsafe {
|
||||
let adapter = ad_adapter_create();
|
||||
assert!(
|
||||
!adapter.is_null(),
|
||||
"stub ad_adapter_create must not return null"
|
||||
);
|
||||
ad_adapter_destroy(adapter);
|
||||
}
|
||||
}
|
||||
127
crates/ffi/tests/codegen_exhaustiveness.rs
Normal file
127
crates/ffi/tests/codegen_exhaustiveness.rs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
/// Codegen exhaustiveness + per-command policy pin tests.
|
||||
///
|
||||
/// Independently verifies that:
|
||||
/// 1. Every expected Family-B command has a generated `ad_<name>` wrapper in
|
||||
/// `src/commands/generated.rs` (the committed output).
|
||||
/// 2. Each command's interaction-policy pin is preserved across refactors.
|
||||
///
|
||||
/// Adding a new Family-B command requires updating EXPECTED_COMMANDS here
|
||||
/// and adding a template to build.rs. This test fails the build if either
|
||||
/// side is out of sync with the generated file.
|
||||
mod common;
|
||||
|
||||
use agent_desktop_core::action::Action;
|
||||
use agent_desktop_core::interaction_policy::InteractionPolicy;
|
||||
|
||||
/// Known Family-B commands — the exhaustive set of command-backed JSON
|
||||
/// wrappers that must appear in `src/commands/generated.rs`.
|
||||
const EXPECTED_COMMANDS: &[&str] = &["execute_by_ref", "snapshot", "status", "version", "wait"];
|
||||
|
||||
#[test]
|
||||
fn generated_file_contains_all_expected_wrappers() {
|
||||
let src = include_str!("../src/commands/generated.rs");
|
||||
|
||||
for name in EXPECTED_COMMANDS {
|
||||
let fn_sig = format!("pub unsafe extern \"C\" fn ad_{name}(");
|
||||
assert!(
|
||||
src.contains(&fn_sig),
|
||||
"generated src/commands/generated.rs is missing `ad_{name}` — \
|
||||
check templates in build.rs and run cargo build to regenerate"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expected_command_count_matches_generated_wrapper_count() {
|
||||
let src = include_str!("../src/commands/generated.rs");
|
||||
let actual_count = src
|
||||
.lines()
|
||||
.filter(|l| l.contains("pub unsafe extern \"C\" fn ad_"))
|
||||
.count();
|
||||
assert_eq!(
|
||||
actual_count,
|
||||
EXPECTED_COMMANDS.len(),
|
||||
"generated file has {actual_count} wrappers but EXPECTED_COMMANDS has {} — \
|
||||
update EXPECTED_COMMANDS to match the templates in build.rs",
|
||||
EXPECTED_COMMANDS.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_wrappers_are_in_alphabetical_order() {
|
||||
let src = include_str!("../src/commands/generated.rs");
|
||||
let fn_names: Vec<&str> = src
|
||||
.lines()
|
||||
.filter(|l| l.contains("pub unsafe extern \"C\" fn ad_"))
|
||||
.filter_map(|l| {
|
||||
l.split("fn ad_")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split('(').next())
|
||||
})
|
||||
.collect();
|
||||
let sorted = {
|
||||
let mut s = fn_names.clone();
|
||||
s.sort_unstable();
|
||||
s
|
||||
};
|
||||
assert_eq!(
|
||||
fn_names, sorted,
|
||||
"generated wrappers must appear in alphabetical order"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_type_text_base_is_focus_fallback() {
|
||||
let base = Action::TypeText("hi".into()).base_interaction_policy();
|
||||
assert_eq!(
|
||||
base,
|
||||
InteractionPolicy::focus_fallback(),
|
||||
"TypeText base policy must be focus_fallback (KTD6)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_click_base_is_headless() {
|
||||
let base = Action::Click.base_interaction_policy();
|
||||
assert_eq!(
|
||||
base,
|
||||
InteractionPolicy::headless(),
|
||||
"Click base policy must be headless (KTD6)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_headless_caller_cannot_downgrade_type_text() {
|
||||
let base = Action::TypeText("x".into()).base_interaction_policy();
|
||||
let effective = base.join(InteractionPolicy::headless());
|
||||
assert_eq!(
|
||||
effective,
|
||||
InteractionPolicy::focus_fallback(),
|
||||
"headless caller must not downgrade TypeText below focus_fallback"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_headed_caller_elevates_click_to_headed() {
|
||||
let base = Action::Click.base_interaction_policy();
|
||||
let effective = base.join(InteractionPolicy::headed());
|
||||
assert_eq!(
|
||||
effective,
|
||||
InteractionPolicy::headed(),
|
||||
"headed caller must elevate Click to headed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_base_plus_focus_fallback_caller_gives_focus_fallback() {
|
||||
let base = Action::Click.base_interaction_policy();
|
||||
let effective = base.join(InteractionPolicy::focus_fallback());
|
||||
assert_eq!(effective, InteractionPolicy::focus_fallback());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_text_base_plus_headed_caller_becomes_headed() {
|
||||
let base = Action::TypeText("x".into()).base_interaction_policy();
|
||||
let effective = base.join(InteractionPolicy::headed());
|
||||
assert_eq!(effective, InteractionPolicy::headed());
|
||||
}
|
||||
|
|
@ -591,9 +591,14 @@ Regular `release` profile keeps `panic = "abort"` for the CLI binary, so a panic
|
|||
|
||||
### CI Hooks Added
|
||||
|
||||
- `cargo build --profile release-ffi -p agent-desktop-ffi` on every PR
|
||||
- `cargo test -p agent-desktop-ffi --tests` runs the 3 integration suites
|
||||
- FFI header contract check compiles the committed header from C tests. Header regeneration is an explicit maintainer action via `scripts/update-ffi-header.sh`, not part of ordinary builds.
|
||||
Current gates in `.github/workflows/ci.yml` on every PR:
|
||||
|
||||
- `cargo build --profile release-ffi -p agent-desktop-ffi` + `cargo test -p agent-desktop-ffi --tests` (`c_abi_harness`, `c_header_compile`, `error_lifetime` integration suites) in the main `test` job
|
||||
- `ffi-python-smoke` — Python ctypes smoke harness (`tests/ffi-python/smoke.py`) with `AD_EXPECT_STUB=1`
|
||||
- `ffi-header-drift` — `cbindgen --verify` (pinned 0.29.4) against the committed header; exits non-zero on any diff
|
||||
- `ffi-panic-guard` — asserts `panic = "unwind"` in `Cargo.toml` and runs the `panic_spike` example to prove `catch_unwind` survives the `release-ffi` profile
|
||||
- `ffi-passthrough` — `--test c_abi_passthrough --features stub-adapter` confirms command entrypoints return `PLATFORM_NOT_SUPPORTED` envelopes
|
||||
- `ffi-codegen-drift` — regenerates `src/commands/generated.rs` and checks for drift against the committed file
|
||||
|
||||
### New Dependencies
|
||||
|
||||
|
|
@ -608,14 +613,19 @@ Regular `release` profile keeps `panic = "abort"` for the CLI binary, so a panic
|
|||
- Any new `PlatformAdapter` method that lands in Phase 2/3 must add a matching `ad_*` FFI wrapper in the same PR that adds the adapter method.
|
||||
- MCP server mode (Phase 4) is a parallel transport, not an FFI consumer — it calls `PlatformAdapter` directly.
|
||||
|
||||
### Known Gaps (surfaced by 2026-04-17 research)
|
||||
### Gap Status (from 2026-04-17 research)
|
||||
|
||||
- `ad_abi_version()` export is still missing (consumers have no runtime compat check)
|
||||
- CLI-flagship primitives (`snapshot` with refs + refmap, `batch`, `wait`, `version`, `status`) are not wired through FFI — consumers today cannot replay the `click @e5` idiom without shelling out to the CLI
|
||||
- No `tracing::` log callback — in-process consumers lose debug output
|
||||
- No `pyo3` / `maturin` wheel or `cffi` wrapper ships with the repo
|
||||
**Resolved:**
|
||||
|
||||
These items are tracked under P2-O16 below: registry migration via `build.rs` filesystem enumeration, `ad_set_log_callback` with redaction, and `ad_execute_by_ref` + descriptor confirms.
|
||||
- `ad_abi_version()` and `ad_init(expected_major)` now ship; consumers call `ad_init` after `dlopen` for a runtime compat check.
|
||||
- `ad_snapshot`, `ad_execute_by_ref`, `ad_wait`, `ad_version`, and `ad_status` are now exported. The 5 command-backed wrappers are generated by `build.rs` from `codegen_templates/*.rs.in` into the committed `src/commands/generated.rs`; do not hand-edit that file.
|
||||
- `ad_set_log_callback(fn(level, msg))` ships; in-process consumers can install a tracing layer for debug output.
|
||||
|
||||
**Still open:**
|
||||
|
||||
- No `pyo3` / `maturin` wheel or `cffi` wrapper ships with the repo — the Python consumer path is ctypes. Potential Phase 2 follow-up.
|
||||
|
||||
P2-O16 (below) scopes to the remaining registry migration: full `CommandDescriptor`-registry codegen so adding a CLI command automatically generates an FFI entry, versus the current 5-command template set.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
name: agent-desktop-ffi
|
||||
version: 0.3.0
|
||||
version: 0.4.0
|
||||
tags: ffi, c-bindings, cdylib, python, swift, node, go, rust-ffi
|
||||
requirements:
|
||||
- agent-desktop-ffi
|
||||
|
|
@ -8,7 +8,10 @@ description: >
|
|||
C-ABI bindings over agent-desktop's PlatformAdapter. Consumers
|
||||
(Python ctypes, Swift, Node ffi-napi, Go cgo, C++, Ruby fiddle)
|
||||
link libagent_desktop_ffi.{dylib,so,dll} and call `ad_*` functions
|
||||
directly instead of spawning the CLI binary per call.
|
||||
directly instead of spawning the CLI binary per call. The canonical
|
||||
observe-act workflow is: ad_init → ad_adapter_create[_with_session]
|
||||
→ ad_snapshot → parse @e refs → ad_execute_by_ref → ad_free_string
|
||||
→ ad_adapter_destroy.
|
||||
---
|
||||
|
||||
# agent-desktop-ffi
|
||||
|
|
@ -24,6 +27,11 @@ The output is `target/release-ffi/libagent_desktop_ffi.dylib`
|
|||
(`.so` on Linux, `.dll` on Windows) plus a committed C header at
|
||||
`crates/ffi/include/agent_desktop.h`.
|
||||
|
||||
A Python ctypes smoke harness lives at `tests/ffi-python/smoke.py` and
|
||||
serves as a worked end-to-end example covering the ABI handshake, struct
|
||||
size validation, `ad_version`, and the snapshot pipeline leg. See
|
||||
`tests/ffi-python/README.md` for usage.
|
||||
|
||||
Four reference topics, loaded as needed:
|
||||
|
||||
- [ownership.md](references/ownership.md) — who allocates / who frees,
|
||||
|
|
@ -33,84 +41,141 @@ Four reference topics, loaded as needed:
|
|||
- [threading.md](references/threading.md) — macOS main-thread rule,
|
||||
AXIsProcessTrusted inheritance when Python/Node dlopens the cdylib,
|
||||
and the single-owner handle invariant.
|
||||
- [build-and-link.md](references/build-and-link.md) — minimum working
|
||||
example for Python ctypes and a C program that links the dylib.
|
||||
- [build-and-link.md](references/build-and-link.md) — ABI handshake,
|
||||
struct size validation, minimal C and Python examples, observe-act
|
||||
workflow, and prebuilt archive locations.
|
||||
|
||||
## ⚠ Core constraints before you integrate
|
||||
## Observe-act workflow (canonical path)
|
||||
|
||||
```
|
||||
ad_init(AD_ABI_VERSION_MAJOR) // verify header ↔ dylib match
|
||||
adapter = ad_adapter_create_with_session("s1") // or ad_adapter_create()
|
||||
rc = ad_snapshot(adapter, "Finder", 0, 10, false, false, &json_out)
|
||||
// parse json_out: locate @e refs in data.tree, note data.snapshot_id
|
||||
ad_free_string(json_out)
|
||||
// build action:
|
||||
AdAction act = {0}; act.kind = AD_ACTION_KIND_CLICK;
|
||||
rc = ad_execute_by_ref(adapter, "@e5", snapshot_id, &act, 0, &result_out)
|
||||
ad_free_string(result_out)
|
||||
ad_adapter_destroy(adapter)
|
||||
```
|
||||
|
||||
`ad_snapshot` returns a `{version, ok, command, data}` JSON envelope
|
||||
identical to the CLI output. The `data.tree` field contains `@e`-prefixed
|
||||
ref IDs for interactive elements. Pass a ref ID and the `snapshot_id`
|
||||
to `ad_execute_by_ref` to drive the CLI-semantics ref-action pipeline
|
||||
(RefStore load → strict resolution → actionability preflight → dispatch).
|
||||
|
||||
## Core constraints
|
||||
|
||||
- **ABI handshake.** Call `ad_init(AD_ABI_VERSION_MAJOR)` once after loading the
|
||||
dylib. A mismatch between the compiled-in constant and the loaded dylib returns
|
||||
`AD_RESULT_ERR_INVALID_ARGS` — abort rather than proceed. You can also read the
|
||||
raw dylib major via `ad_abi_version()` for diagnostic display. New `ad_*` symbols
|
||||
and new error codes are additive (no bump required); removed or layout-changed
|
||||
symbols increment the major.
|
||||
|
||||
- **Session adapters.** `ad_adapter_create_with_session("session-id")` associates
|
||||
the adapter with a named session for refmap persistence. Passing the same session
|
||||
ID across adapter lifetimes lets `ad_execute_by_ref` with `snapshot_id=NULL`
|
||||
target the latest snapshot from that session. Session IDs: 1–64 chars, ASCII
|
||||
alphanumeric / `-` / `_`. Invalid IDs return null (check `ad_last_error_*`).
|
||||
|
||||
- **Main thread only (macOS).** Call every adapter-touching entrypoint
|
||||
(`ad_get_tree`, `ad_resolve_element`, `ad_execute_action`,
|
||||
`ad_screenshot`, clipboard, launch/close, window ops, observation,
|
||||
notifications, etc.) from the process's main thread. The FFI enforces
|
||||
this at runtime in **every build profile** — a worker-thread call
|
||||
returns `AD_RESULT_ERR_INTERNAL` with a diagnostic last-error. On
|
||||
non-macOS platforms the check is a compile-time true; there is no
|
||||
runtime cost.
|
||||
(`ad_snapshot`, `ad_execute_by_ref`, `ad_wait`, `ad_get_tree`, `ad_find`,
|
||||
`ad_get`, `ad_is`, `ad_resolve_element`, `ad_execute_action`,
|
||||
`ad_execute_action_with_policy`, `ad_execute_ref_action_with_policy`,
|
||||
`ad_screenshot`, clipboard get/set/clear, mouse, drag, launch, close, focus,
|
||||
window-op, list-apps/windows/surfaces, notification list/dismiss/action)
|
||||
from the process's main thread. macOS accessibility and Cocoa APIs require
|
||||
this. The FFI enforces this at runtime in every build profile — a worker-thread
|
||||
call returns `AD_RESULT_ERR_INTERNAL` with a diagnostic last-error. On
|
||||
non-macOS platforms the check is a compile-time true; there is no runtime cost.
|
||||
|
||||
- **Release profile.** `cargo build --release` produces
|
||||
`panic = "abort"` — any Rust panic inside an `extern "C"` fn will
|
||||
`SIGABRT` the host. Use `--profile release-ffi` to get the correct
|
||||
`panic = "unwind"` profile. CI enforces this.
|
||||
- **Release profile.** `cargo build --release` produces `panic = "abort"` —
|
||||
any Rust panic inside an `extern "C"` fn will `SIGABRT` the host. Use
|
||||
`--profile release-ffi` to get the correct `panic = "unwind"` profile. CI
|
||||
enforces this.
|
||||
|
||||
- **Last-error lifetime.** Pointers returned by `ad_last_error_*`
|
||||
remain valid across any number of subsequent *successful* FFI calls
|
||||
on the same thread. Only the next failing call rotates them. Cache
|
||||
the pointer once, read it as many times as you need.
|
||||
- **Last-error lifetime.** Pointers returned by `ad_last_error_*` remain valid
|
||||
across any number of subsequent *successful* FFI calls on the same thread.
|
||||
Only the next failing call rotates them. Cache the pointer once, read it as
|
||||
many times as you need.
|
||||
|
||||
- **Handle release.** Every `ad_resolve_element` result must be
|
||||
released with `ad_free_handle(adapter, handle)` on the same adapter
|
||||
that produced it before that adapter is destroyed. On macOS this
|
||||
balances the internal `CFRetain`; on Windows/Linux the call is a no-op
|
||||
but safe to issue.
|
||||
- **ad_last_error_details.** A fourth accessor, `ad_last_error_details()`,
|
||||
returns a borrowed JSON string carrying structured details (e.g. the
|
||||
actionability check report on `ACTION_FAILED`, candidate summaries on
|
||||
`AMBIGUOUS_TARGET`). The details may contain element names, values, and window
|
||||
titles from the user's screen — treat as sensitive diagnostics and avoid routing
|
||||
to shared log surfaces.
|
||||
|
||||
- **Action policy.** `ad_execute_action` uses headless policy by default.
|
||||
`ad_execute_ref_action_with_policy` should also use headless for semantic
|
||||
ref actions that must fail closed, except `AD_ACTION_KIND_TYPE_TEXT`: use
|
||||
`AD_POLICY_KIND_FOCUS_FALLBACK` for CLI `type` parity because typing needs
|
||||
focus but not cursor movement. Use `AD_POLICY_KIND_HEADED` only for explicit
|
||||
headed input semantics.
|
||||
- **Handle release.** Every `ad_resolve_element` / `ad_find` result must be
|
||||
released with `ad_free_handle(adapter, &handle)` on the same adapter that
|
||||
produced it, before that adapter is destroyed. On macOS this balances the
|
||||
internal `CFRetain`; on Windows/Linux the call is a no-op but safe to issue.
|
||||
`ad_free_handle` zeroes `handle.ptr` so a follow-up call is a safe no-op.
|
||||
|
||||
- **Ref-action preflight.** `ad_execute_ref_action_with_policy` resolves the
|
||||
ref strictly and runs the live actionability preflight (visible, stable,
|
||||
enabled, supported action, policy, editable) before dispatching — a disabled
|
||||
or unsupported target fails before any platform call. On
|
||||
`AD_RESULT_ERR_ACTION_FAILED`, the structured check report is available as
|
||||
JSON via `ad_last_error_details()`. Details may carry element names, values,
|
||||
and window titles from the user's screen — treat them as sensitive
|
||||
diagnostics and keep them out of shared log surfaces.
|
||||
- **Primary ref-action path.** `ad_execute_by_ref` is the recommended entrypoint
|
||||
for the observe-act loop: it loads the RefStore, looks up the ref in the refmap
|
||||
(STALE_REF on miss), runs strict element re-identification (STALE_REF / AMBIGUOUS_TARGET),
|
||||
runs the live actionability preflight, then dispatches. TypeText and PressKey
|
||||
default to `focus_fallback` policy (matching CLI `type`/`press-key`); all other
|
||||
actions default to `headless`. Pass `AD_POLICY_KIND_HEADED` (2) to opt in to
|
||||
cursor-based fallbacks.
|
||||
|
||||
- **Action result steps.** `AdActionResult.steps` mirrors the CLI `steps`
|
||||
array for activation-chain actions. Each entry has `label` and `outcome`
|
||||
strings and is owned by the result; release it with
|
||||
`ad_free_action_result(&out)`.
|
||||
- **Low-level action paths.** `ad_execute_action` (headless, no preflight) and
|
||||
`ad_execute_action_with_policy` are raw escape hatches for callers holding a live
|
||||
`AdNativeHandle` from `ad_resolve_element` / `ad_find`. Use them when you need
|
||||
to bypass the ref-action pipeline. `ad_execute_ref_action_with_policy` accepts a
|
||||
pre-resolved `AdRefEntry` instead of a ref string — useful when you have
|
||||
serialized an entry outside the RefStore pipeline.
|
||||
|
||||
- **Tracing.** CLI `--trace` is not inherited by the C ABI; FFI hosts should
|
||||
record `AdResult`, `ad_last_error_*`, action results, and host correlation IDs
|
||||
in their own logs.
|
||||
- **Ref-action preflight.** `ad_execute_by_ref` and `ad_execute_ref_action_with_policy`
|
||||
both resolve the element strictly and run the live actionability preflight
|
||||
(visible, stable, enabled, supported action, policy, editable) before dispatching
|
||||
— a disabled or unsupported target fails before any platform call. On
|
||||
`AD_RESULT_ERR_ACTION_FAILED`, the structured check report is available as JSON
|
||||
via `ad_last_error_details()`.
|
||||
|
||||
- **No wait surface.** The CLI's `wait` command (element predicates including
|
||||
`--predicate actionable --action ...`, window/text/menu/notification waits)
|
||||
is not exposed over the C ABI. FFI hosts own their own polling loops; the
|
||||
actionability preflight inside `ad_execute_ref_action_with_policy` is the
|
||||
equivalent per-call readiness check.
|
||||
- **Action result steps.** `AdActionResult.steps` mirrors the CLI `steps` array
|
||||
for activation-chain actions. Each entry has `label` and `outcome` strings and
|
||||
is owned by the result; release with `ad_free_action_result(&out)`.
|
||||
|
||||
- **Tracing / log callback.** `ad_set_log_callback(cb)` installs a `tracing`
|
||||
subscriber layer that delivers events as JSON to your callback. `cb` receives an
|
||||
int32_t level (1=ERROR … 5=TRACE) and a `const char *msg` valid only for the
|
||||
duration of the call. Pass `NULL` to unregister. The layer is installed on the
|
||||
first non-null call; if a foreign global subscriber already owns the process at
|
||||
that point, the install fails with `AD_RESULT_ERR_INTERNAL` and no events are
|
||||
ever delivered. Sensitive field values (password, token, text, …) are replaced
|
||||
with `{"redacted":true}` before formatting. A panicking callback is caught and
|
||||
silently discarded. The callback may fire from threads other than the registering
|
||||
thread, and may still fire briefly after a `NULL` unregister — keep the callback
|
||||
and any data it captures valid for the process lifetime.
|
||||
|
||||
- **Wait.** `ad_wait(adapter, args, &out)` runs the full CLI `wait` command
|
||||
(element-appear, window-appear, text-appear, menu-open/close, notification,
|
||||
element predicates). Zero-initialize `AdWaitArgs`, set the fields you need, and
|
||||
validate the struct size against `AD_WAIT_ARGS_SIZE` / `ad_wait_args_size()` before
|
||||
calling. The output is a `{version, ok, command, data}` JSON envelope freed with
|
||||
`ad_free_string`. `ad_wait` blocks the calling thread up to `timeout_ms` ms —
|
||||
ensure the adapter is not destroyed from another thread while it is running.
|
||||
|
||||
- **Text input privacy.** On macOS, focus-fallback or headed text insertion may
|
||||
briefly use the clipboard for non-ASCII text. For sensitive text, prefer
|
||||
`AD_ACTION_KIND_SET_VALUE` with `AD_POLICY_KIND_HEADLESS` when the target
|
||||
supports settable values. Do not use headless `AD_ACTION_KIND_TYPE_TEXT` as
|
||||
CLI-parity ref input; actionability rejects it before dispatch.
|
||||
supports settable values.
|
||||
|
||||
- **Enum discriminants.** Every `#[repr(i32)]` enum field is validated
|
||||
at the C boundary — invalid discriminants return
|
||||
`AD_RESULT_ERR_INVALID_ARGS` instead of undefined behavior.
|
||||
- **Enum discriminants.** Every `#[repr(i32)]` enum field is validated at the C
|
||||
boundary — invalid discriminants return `AD_RESULT_ERR_INVALID_ARGS` instead of
|
||||
undefined behavior.
|
||||
|
||||
- **ABI is unstable before 1.0.** The header lists the exact current
|
||||
shapes. Anything added or reordered in a later patch is a breaking
|
||||
change; pin the version of libagent_desktop_ffi you link against.
|
||||
- **ABI stability.** The major version in `AD_ABI_VERSION_MAJOR` increments on any
|
||||
breaking change (removed symbol, incompatible layout). Additive changes (new
|
||||
symbols, new error codes) do not bump it. Before 1.0, pin the exact version of
|
||||
libagent_desktop_ffi you link against.
|
||||
|
||||
- **`ad_get_tree` returns a raw adapter tree, not the CLI snapshot.**
|
||||
Ref IDs are always null, no skeleton/drill-down pipeline is wired
|
||||
through, and `interactive_only` / `compact` follow adapter
|
||||
semantics which may diverge slightly from the CLI's post-processed
|
||||
shape. Use `ad_find` + `ad_get` / `ad_is` for point lookups, or
|
||||
invoke the CLI if you need CLI-parity JSON snapshots.
|
||||
- **`ad_get_tree` vs `ad_snapshot`.** `ad_get_tree` returns a raw flat BFS tree
|
||||
without `@e` refs, no refmap persistence, and no JSON envelope — use it for
|
||||
custom traversal or UI inspection. For observe-act agents that drive actions via
|
||||
`ad_execute_by_ref`, always start with `ad_snapshot`.
|
||||
|
|
|
|||
|
|
@ -8,14 +8,113 @@ cargo build --profile release-ffi -p agent-desktop-ffi
|
|||
|
||||
Output:
|
||||
|
||||
- macOS: `target/release-ffi/libagent_desktop_ffi.dylib` (~470 KB)
|
||||
- macOS: `target/release-ffi/libagent_desktop_ffi.dylib`
|
||||
- Linux: `target/release-ffi/libagent_desktop_ffi.so`
|
||||
- Windows: `target/release-ffi/agent_desktop_ffi.dll`
|
||||
|
||||
The generated header is at `crates/ffi/include/agent_desktop.h`.
|
||||
CI validates that the committed header matches what `cargo build`
|
||||
The generated header is at `crates/ffi/include/agent_desktop.h`. CI
|
||||
validates that the committed header matches what `cargo build`
|
||||
regenerates — if you change a type in `crates/ffi/src/`, rebuild
|
||||
locally and commit the header.
|
||||
locally and commit the updated header.
|
||||
|
||||
`--profile release-ffi` keeps `panic = "unwind"`, which is required for
|
||||
the `catch_unwind` traps inside every `extern "C"` entrypoint. The default
|
||||
`release` profile uses `panic = "abort"` (for CLI binary-size reasons) and
|
||||
silently defeats those traps.
|
||||
|
||||
## Prebuilt archives
|
||||
|
||||
Every GitHub release ships prebuilt archives for:
|
||||
|
||||
- macOS arm64 and x86_64
|
||||
- Linux x64 and arm64 (glibc)
|
||||
- Windows x64 MSVC
|
||||
|
||||
Each archive contains the dylib/so/dll, `include/agent_desktop.h`, and
|
||||
`LICENSE`. Integrity: compare against `checksums.txt` in the release
|
||||
assets. Supply-chain verification: each release is signed via Sigstore
|
||||
attestation — verify with `cosign verify-blob` before deploying.
|
||||
|
||||
## ABI handshake (do this first)
|
||||
|
||||
After `dlopen` / `LoadLibrary`, compare the dylib major to the header you
|
||||
compiled against before calling anything else:
|
||||
|
||||
```c
|
||||
AdResult rc = ad_init(AD_ABI_VERSION_MAJOR);
|
||||
if (rc != AD_RESULT_OK) {
|
||||
// header and dylib have incompatible major versions
|
||||
fprintf(stderr, "ABI mismatch: %s\n", ad_last_error_message());
|
||||
return -1;
|
||||
}
|
||||
```
|
||||
|
||||
Alternatively, read the raw dylib major and compare yourself:
|
||||
|
||||
```c
|
||||
uint32_t dylib_major = ad_abi_version();
|
||||
if (dylib_major != AD_ABI_VERSION_MAJOR) {
|
||||
fprintf(stderr, "ABI major: header=%u dylib=%u\n",
|
||||
AD_ABI_VERSION_MAJOR, dylib_major);
|
||||
abort();
|
||||
}
|
||||
```
|
||||
|
||||
`ad_init` returns `AD_RESULT_ERR_INVALID_ARGS` on mismatch (with a
|
||||
diagnostic in `ad_last_error_message`). A mismatch means the header you
|
||||
compiled against and the loaded dylib are incompatible — do not call
|
||||
anything further.
|
||||
|
||||
## Struct size validation
|
||||
|
||||
Languages whose struct layout may diverge from C (Python ctypes, Go cgo,
|
||||
JNI, etc.) must validate every size-pinned struct before passing it to
|
||||
the library. The FFI exposes three validation layers:
|
||||
|
||||
1. **Header macros**: `AD_ACTION_SIZE`, `AD_WAIT_ARGS_SIZE`,
|
||||
`AD_REF_ENTRY_SIZE`, `AD_DRAG_PARAMS_SIZE`, `AD_ACTION_RESULT_SIZE`,
|
||||
`AD_ACTION_STEP_SIZE`, `AD_ELEMENT_STATE_SIZE`.
|
||||
2. **Runtime getters**: `ad_action_size()`, `ad_wait_args_size()`,
|
||||
`ad_ref_entry_size()`, `ad_drag_params_size()`, `ad_action_result_size()`,
|
||||
`ad_action_step_size()`, `ad_element_state_size()` — each returns the
|
||||
same value the macro encodes, compiled from the Rust side.
|
||||
3. **C11 static asserts** in the header (`#ifndef AGENT_DESKTOP_ABI_ASSERTS`)
|
||||
catch mismatches at C compile time.
|
||||
|
||||
Compare your binding's `sizeof` equivalent against the getter at load
|
||||
time, before building or passing any of these structs. The Python smoke
|
||||
harness (`tests/ffi-python/smoke.py` Leg 2) demonstrates this check for
|
||||
all size-pinned structs in a single loop.
|
||||
|
||||
## Worked example: Python ctypes smoke harness
|
||||
|
||||
`tests/ffi-python/smoke.py` is the canonical reference for Python
|
||||
consumers. It covers:
|
||||
|
||||
- Leg 1: `ad_abi_version()` vs `AD_ABI_VERSION_MAJOR` (header/dylib match)
|
||||
- Leg 2: every `ad_*_size()` getter vs its `AD_*_SIZE` macro (struct layout)
|
||||
- Leg 3: `ad_version()` → parse JSON → `ad_free_string` (basic pipeline)
|
||||
- Leg 4: `ad_adapter_create` → `ad_snapshot` → `ad_free_string` →
|
||||
`ad_adapter_destroy` (full adapter lifecycle, stub passes through
|
||||
`PLATFORM_NOT_SUPPORTED`)
|
||||
|
||||
Run it:
|
||||
|
||||
```bash
|
||||
python3 tests/ffi-python/smoke.py \
|
||||
target/release-ffi/libagent_desktop_ffi.dylib \
|
||||
crates/ffi/include/agent_desktop.h
|
||||
```
|
||||
|
||||
Or with environment variables:
|
||||
|
||||
```bash
|
||||
AD_DYLIB_PATH=target/release-ffi/libagent_desktop_ffi.dylib \
|
||||
AD_HEADER_PATH=crates/ffi/include/agent_desktop.h \
|
||||
python3 tests/ffi-python/smoke.py
|
||||
```
|
||||
|
||||
No `pip install` required — `smoke.py` uses the Python standard library only.
|
||||
|
||||
## Minimal C example
|
||||
|
||||
|
|
@ -24,9 +123,20 @@ locally and commit the header.
|
|||
#include "agent_desktop.h"
|
||||
|
||||
int main(void) {
|
||||
AdAdapter *adapter = ad_adapter_create();
|
||||
if (!adapter) return 1;
|
||||
// 1. ABI handshake
|
||||
if (ad_init(AD_ABI_VERSION_MAJOR) != AD_RESULT_OK) {
|
||||
fprintf(stderr, "ABI mismatch: %s\n", ad_last_error_message());
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 2. Create adapter
|
||||
AdAdapter *adapter = ad_adapter_create();
|
||||
if (!adapter) {
|
||||
fprintf(stderr, "adapter_create failed: %s\n", ad_last_error_message());
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 3. Check permissions
|
||||
AdResult rc = ad_check_permissions(adapter);
|
||||
if (rc != AD_RESULT_OK) {
|
||||
fprintf(stderr, "permission denied: %s\n", ad_last_error_message());
|
||||
|
|
@ -34,20 +144,26 @@ int main(void) {
|
|||
return 1;
|
||||
}
|
||||
|
||||
/* Opaque list handle — walk via _count / _get, free with _free. */
|
||||
AdAppList *list = NULL;
|
||||
rc = ad_list_apps(adapter, &list);
|
||||
if (rc == AD_RESULT_OK) {
|
||||
uint32_t count = ad_app_list_count(list);
|
||||
for (uint32_t i = 0; i < count; i++) {
|
||||
const AdAppInfo *app = ad_app_list_get(list, i);
|
||||
if (app) {
|
||||
printf("%s (pid %d)\n", app->name, app->pid);
|
||||
}
|
||||
}
|
||||
ad_app_list_free(list);
|
||||
// 4. Snapshot the focused window
|
||||
char *json_out = NULL;
|
||||
rc = ad_snapshot(adapter,
|
||||
NULL, // app (null = focused window)
|
||||
0, // surface = Window
|
||||
10, // max_depth
|
||||
false, // interactive_only
|
||||
false, // compact
|
||||
&json_out);
|
||||
if (rc == AD_RESULT_OK && json_out) {
|
||||
printf("%s\n", json_out);
|
||||
// parse JSON to find @e refs in data.tree
|
||||
ad_free_string(json_out);
|
||||
} else if (json_out) {
|
||||
// command-level error — ok:false envelope, still must free
|
||||
fprintf(stderr, "snapshot error: %s\n", json_out);
|
||||
ad_free_string(json_out);
|
||||
} else {
|
||||
fprintf(stderr, "list_apps failed: %s\n", ad_last_error_message());
|
||||
// infrastructure error — *out is null
|
||||
fprintf(stderr, "snapshot failed: %s\n", ad_last_error_message());
|
||||
}
|
||||
|
||||
ad_adapter_destroy(adapter);
|
||||
|
|
@ -60,41 +176,102 @@ Compile:
|
|||
```sh
|
||||
clang -I./crates/ffi/include main.c \
|
||||
-L./target/release-ffi -lagent_desktop_ffi \
|
||||
-o list_apps
|
||||
-o snapshot_demo
|
||||
install_name_tool -change \
|
||||
libagent_desktop_ffi.dylib \
|
||||
@executable_path/target/release-ffi/libagent_desktop_ffi.dylib \
|
||||
list_apps
|
||||
snapshot_demo
|
||||
```
|
||||
|
||||
## Minimal Python ctypes example
|
||||
## Observe-act workflow in C
|
||||
|
||||
```python
|
||||
import ctypes
|
||||
from ctypes import c_int32, c_char_p, POINTER, Structure, c_uint32
|
||||
After parsing the snapshot JSON and extracting a ref ID (e.g. `"@e5"`):
|
||||
|
||||
lib = ctypes.CDLL("./target/release-ffi/libagent_desktop_ffi.dylib")
|
||||
```c
|
||||
AdAction act = {0}; // zero-init before setting any field
|
||||
act.kind = AD_ACTION_KIND_CLICK;
|
||||
|
||||
# Opaque adapter handle
|
||||
class AdAdapter(ctypes.Structure):
|
||||
pass
|
||||
char *result = NULL;
|
||||
AdResult rc = ad_execute_by_ref(
|
||||
adapter,
|
||||
"@e5", // ref ID from snapshot data.tree
|
||||
NULL, // snapshot_id — NULL = latest for this session
|
||||
&act,
|
||||
0, // policy = Headless
|
||||
&result
|
||||
);
|
||||
if (result) {
|
||||
// parse JSON — ok:true on success, ok:false on STALE_REF etc.
|
||||
printf("%s\n", result);
|
||||
ad_free_string(result);
|
||||
}
|
||||
if (rc != AD_RESULT_OK) {
|
||||
const char *det = ad_last_error_details(); // may be NULL; treat as sensitive
|
||||
fprintf(stderr, "execute_by_ref failed (%d): %s\n",
|
||||
(int)rc, ad_last_error_message());
|
||||
}
|
||||
```
|
||||
|
||||
lib.ad_adapter_create.restype = POINTER(AdAdapter)
|
||||
lib.ad_adapter_destroy.argtypes = [POINTER(AdAdapter)]
|
||||
lib.ad_check_permissions.restype = c_int32
|
||||
lib.ad_check_permissions.argtypes = [POINTER(AdAdapter)]
|
||||
lib.ad_last_error_message.restype = c_char_p
|
||||
To type text, set the action kind and text field:
|
||||
|
||||
adapter = lib.ad_adapter_create()
|
||||
rc = lib.ad_check_permissions(adapter)
|
||||
if rc != 0:
|
||||
msg = lib.ad_last_error_message()
|
||||
print("permission denied:", msg.decode() if msg else "(no message)")
|
||||
lib.ad_adapter_destroy(adapter)
|
||||
```c
|
||||
AdAction type_act = {0};
|
||||
type_act.kind = AD_ACTION_KIND_TYPE_TEXT;
|
||||
type_act.text = "hello";
|
||||
// TypeText defaults to focus_fallback via ad_execute_by_ref; explicit policy:
|
||||
rc = ad_execute_by_ref(adapter, "@e3", NULL, &type_act,
|
||||
AD_POLICY_KIND_FOCUS_FALLBACK, &result);
|
||||
```
|
||||
|
||||
## Call graph reminder
|
||||
|
||||
All FFI calls above must run on the **main thread** on macOS. For
|
||||
Python that typically means the script's entry point, not a worker
|
||||
All adapter-touching FFI calls must run on the **main thread** on macOS.
|
||||
For Python that typically means the script's entry point, not a worker
|
||||
spawned via `threading`. See [threading.md](threading.md).
|
||||
|
||||
## Minimal Python ctypes example
|
||||
|
||||
```python
|
||||
import ctypes, json
|
||||
from ctypes import c_int, c_int32, c_uint8, c_bool, c_char_p, POINTER, c_void_p
|
||||
|
||||
lib = ctypes.CDLL("./target/release-ffi/libagent_desktop_ffi.dylib")
|
||||
|
||||
# ABI handshake
|
||||
lib.ad_init.restype = c_int32
|
||||
lib.ad_init.argtypes = [ctypes.c_uint32]
|
||||
AD_ABI_VERSION_MAJOR = 1 # sync with header macro
|
||||
rc = lib.ad_init(AD_ABI_VERSION_MAJOR)
|
||||
assert rc == 0, f"ABI mismatch: rc={rc}"
|
||||
|
||||
# Adapter lifecycle
|
||||
lib.ad_adapter_create.restype = c_void_p
|
||||
lib.ad_adapter_create.argtypes = []
|
||||
lib.ad_adapter_destroy.restype = None
|
||||
lib.ad_adapter_destroy.argtypes = [c_void_p]
|
||||
|
||||
# Snapshot
|
||||
lib.ad_snapshot.restype = c_int32
|
||||
lib.ad_snapshot.argtypes = [c_void_p, c_char_p, c_int32, c_uint8, c_bool, c_bool,
|
||||
POINTER(c_char_p)]
|
||||
lib.ad_free_string.restype = None
|
||||
lib.ad_free_string.argtypes = [c_char_p]
|
||||
|
||||
lib.ad_last_error_message.restype = c_char_p
|
||||
lib.ad_last_error_message.argtypes = []
|
||||
|
||||
adapter = lib.ad_adapter_create()
|
||||
assert adapter, "ad_adapter_create() returned null"
|
||||
|
||||
out = c_char_p()
|
||||
rc = lib.ad_snapshot(adapter, None, 0, 10, False, False, ctypes.byref(out))
|
||||
if out.value:
|
||||
envelope = json.loads(out.value)
|
||||
lib.ad_free_string(out)
|
||||
print("ok:", envelope.get("ok"))
|
||||
else:
|
||||
msg = lib.ad_last_error_message()
|
||||
print("error:", msg.decode() if msg else "(no message)")
|
||||
|
||||
lib.ad_adapter_destroy(adapter)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -21,12 +21,30 @@ if (rc != AD_RESULT_OK) {
|
|||
ad_release_window_fields(&win);
|
||||
```
|
||||
|
||||
## Last-error accessors
|
||||
|
||||
Four accessors share the same per-thread lifetime contract:
|
||||
|
||||
| Accessor | Returns |
|
||||
|---------------------------------|----------------------------------------------------------------|
|
||||
| `ad_last_error_code()` | The `AdResult` code of the last failure, or `AD_RESULT_OK` |
|
||||
| `ad_last_error_message()` | Human-readable description, or null |
|
||||
| `ad_last_error_suggestion()` | Recovery hint, or null |
|
||||
| `ad_last_error_platform_detail()` | OS-specific diagnostic (AX codes, HRESULTs, AT-SPI), or null |
|
||||
| `ad_last_error_details()` | Structured JSON details, or null — **sensitive** (see below) |
|
||||
|
||||
`ad_last_error_details()` returns a JSON string with structured context:
|
||||
the actionability check report on `ACTION_FAILED`, candidate element
|
||||
summaries on `AMBIGUOUS_TARGET`, the last observed state on a `wait`
|
||||
`TIMEOUT`, etc. The details may contain element names, values, and window
|
||||
titles from the user's screen. Treat as sensitive diagnostics and avoid
|
||||
routing to shared log surfaces.
|
||||
|
||||
## Lifetime contract
|
||||
|
||||
The pointer returned by `ad_last_error_message()`,
|
||||
`ad_last_error_suggestion()`, and `ad_last_error_platform_detail()`
|
||||
remains valid across any number of subsequent **successful** FFI calls.
|
||||
Only the next **failing** call rotates the slot.
|
||||
The pointer returned by any `ad_last_error_*` accessor remains valid
|
||||
across any number of subsequent **successful** FFI calls. Only the next
|
||||
**failing** call rotates the slot.
|
||||
|
||||
Consequence: you can cache the pointer right after a failure and keep
|
||||
reading it until the next failure — equivalent to POSIX `errno` /
|
||||
|
|
@ -41,43 +59,72 @@ ad_check_permissions(adapter); // success
|
|||
printf("%s\n", msg); // still valid
|
||||
```
|
||||
|
||||
`ad_check_permissions` does not treat `Unknown` as success. Stub adapters that
|
||||
cannot answer permission probes return `AD_RESULT_ERR_PLATFORM_NOT_SUPPORTED`.
|
||||
The macOS adapter reports `AD_RESULT_ERR_INTERNAL` only if the platform probe
|
||||
itself is ambiguous; read `ad_last_error_*` for the diagnostic.
|
||||
|
||||
Failure-path calls rotate: if a subsequent call fails, the prior
|
||||
pointer may dangle. Read it before the next potentially-failing call.
|
||||
|
||||
Last-error is per-thread (thread-local storage) — Thread A's failure
|
||||
does not affect Thread B's slot.
|
||||
|
||||
`ad_check_permissions` does not treat `Unknown` as success. Stub adapters
|
||||
that cannot answer permission probes return
|
||||
`AD_RESULT_ERR_PLATFORM_NOT_SUPPORTED`. The macOS adapter reports
|
||||
`AD_RESULT_ERR_INTERNAL` only if the platform probe itself is ambiguous;
|
||||
read `ad_last_error_*` for the diagnostic.
|
||||
|
||||
## Error codes
|
||||
|
||||
Numeric values are ABI-stable. New codes are appended; existing values are not renumbered.
|
||||
Numeric values are ABI-stable. New codes are appended; existing values
|
||||
are not renumbered. Always handle values outside this list — future
|
||||
releases may add codes.
|
||||
|
||||
| Name | i32 | Meaning |
|
||||
|--------------------------------------|-------|---------------------------------------|
|
||||
| `AD_RESULT_OK` | 0 | Success |
|
||||
| `AD_RESULT_ERR_PERM_DENIED` | -1 | Accessibility / input permission missing |
|
||||
| `AD_RESULT_ERR_ELEMENT_NOT_FOUND` | -2 | Ref resolve / find miss |
|
||||
| `AD_RESULT_ERR_APP_NOT_FOUND` | -3 | Bundle/PID lookup miss |
|
||||
| `AD_RESULT_ERR_ACTION_FAILED` | -4 | Action dispatched but rejected |
|
||||
| `AD_RESULT_ERR_ACTION_NOT_SUPPORTED` | -5 | Platform cannot perform this action |
|
||||
| `AD_RESULT_ERR_STALE_REF` | -6 | Handle pre-dates a DOM change |
|
||||
| `AD_RESULT_ERR_WINDOW_NOT_FOUND` | -7 | Window filter matched nothing |
|
||||
| `AD_RESULT_ERR_PLATFORM_NOT_SUPPORTED`| -8 | API unavailable on this OS |
|
||||
| `AD_RESULT_ERR_TIMEOUT` | -9 | Wait exceeded deadline |
|
||||
| `AD_RESULT_ERR_INVALID_ARGS` | -10 | Null pointer, bad enum, invalid UTF-8 |
|
||||
| `AD_RESULT_ERR_NOTIFICATION_NOT_FOUND`| -11 | Notification index out of range |
|
||||
| `AD_RESULT_ERR_INTERNAL` | -12 | Internal failure or ambiguous platform probe |
|
||||
| `AD_RESULT_ERR_SNAPSHOT_NOT_FOUND` | -13 | Requested snapshot ref store is missing |
|
||||
| `AD_RESULT_ERR_POLICY_DENIED` | -14 | Current action policy blocks fallback |
|
||||
| Name | i32 | Meaning |
|
||||
|---------------------------------------|-------|--------------------------------------------|
|
||||
| `AD_RESULT_OK` | 0 | Success |
|
||||
| `AD_RESULT_ERR_PERM_DENIED` | -1 | Accessibility / input permission missing |
|
||||
| `AD_RESULT_ERR_ELEMENT_NOT_FOUND` | -2 | Ref resolve / find miss |
|
||||
| `AD_RESULT_ERR_APP_NOT_FOUND` | -3 | Bundle/PID lookup miss |
|
||||
| `AD_RESULT_ERR_ACTION_FAILED` | -4 | Action dispatched but rejected |
|
||||
| `AD_RESULT_ERR_ACTION_NOT_SUPPORTED` | -5 | Platform cannot perform this action |
|
||||
| `AD_RESULT_ERR_STALE_REF` | -6 | Ref predates a UI change; re-snapshot |
|
||||
| `AD_RESULT_ERR_WINDOW_NOT_FOUND` | -7 | Window filter matched nothing |
|
||||
| `AD_RESULT_ERR_PLATFORM_NOT_SUPPORTED`| -8 | API unavailable on this OS |
|
||||
| `AD_RESULT_ERR_TIMEOUT` | -9 | Wait exceeded deadline |
|
||||
| `AD_RESULT_ERR_INVALID_ARGS` | -10 | Null pointer, bad enum, invalid UTF-8 |
|
||||
| `AD_RESULT_ERR_NOTIFICATION_NOT_FOUND`| -11 | Notification index out of range or reordered |
|
||||
| `AD_RESULT_ERR_INTERNAL` | -12 | Internal failure, off-main-thread, or foreign-subscriber conflict |
|
||||
| `AD_RESULT_ERR_SNAPSHOT_NOT_FOUND` | -13 | Requested snapshot ref store is missing |
|
||||
| `AD_RESULT_ERR_POLICY_DENIED` | -14 | Current action policy blocks this fallback |
|
||||
| `AD_RESULT_ERR_AMBIGUOUS_TARGET` | -15 | Strict re-identification found multiple candidates; re-snapshot |
|
||||
|
||||
## Enum validation
|
||||
|
||||
Every `#[repr(i32)]` enum field is validated at the C boundary. An
|
||||
out-of-range discriminant returns `AD_RESULT_ERR_INVALID_ARGS` with
|
||||
diagnostic last-error text. This prevents the consumer from
|
||||
accidentally triggering undefined behavior by stuffing an arbitrary
|
||||
`int32_t` into an enum slot.
|
||||
diagnostic last-error text. This prevents the consumer from accidentally
|
||||
triggering undefined behavior by stuffing an arbitrary `int32_t` into an
|
||||
enum slot. Affected fields: `AdAction.kind` (`AdActionKind`),
|
||||
`AdMouseEvent.kind` (`AdMouseEventKind`), `AdMouseEvent.button`
|
||||
(`AdMouseButton`), `AdScrollParams.direction` (`AdDirection`),
|
||||
`AdTreeOptions.surface` (`AdSnapshotSurface`), `AdScreenshotTarget.kind`
|
||||
(`AdScreenshotKind`), `AdWindowOp.kind` (`AdWindowOpKind`), and the
|
||||
`policy` parameter of `ad_execute_by_ref` / `ad_execute_action_with_policy`
|
||||
/ `ad_execute_ref_action_with_policy` (`AdPolicyKind`).
|
||||
|
||||
## Command-backed JSON entrypoints: dual-failure modes
|
||||
|
||||
`ad_snapshot`, `ad_execute_by_ref`, `ad_wait`, `ad_status`, and
|
||||
`ad_version` have two distinct failure modes:
|
||||
|
||||
- **Argument / infrastructure failure** (null adapter, off-main-thread,
|
||||
invalid UTF-8, bad discriminant, context error): `*out` is set to null,
|
||||
no allocation is made, and the last-error slot is the only failure
|
||||
indication.
|
||||
- **Command-level failure** (app not found, STALE_REF, TIMEOUT, etc.):
|
||||
`*out` is set to a heap-allocated JSON string with `"ok":false` and an
|
||||
`"error"` payload. The caller **must still free** it with
|
||||
`ad_free_string(*out)`. The last-error slot is also set.
|
||||
|
||||
Always check `*out` for null before deciding whether to free.
|
||||
|
||||
## Panic safety
|
||||
|
||||
|
|
|
|||
|
|
@ -7,25 +7,68 @@ free function. Always call it; the allocator the FFI uses is Rust's
|
|||
|
||||
## Allocation / release table
|
||||
|
||||
| Allocates | Frees with |
|
||||
|---------------------------------------------------------|-----------------------------------------|
|
||||
| `ad_adapter_create()` | `ad_adapter_destroy(adapter)` |
|
||||
| `ad_list_apps(adapter, &list)` | `ad_app_list_free(list)` |
|
||||
| `ad_list_windows(adapter, app, focused, &list)` | `ad_window_list_free(list)` |
|
||||
| `ad_list_surfaces(adapter, pid, &list)` | `ad_surface_list_free(list)` |
|
||||
| `ad_list_notifications(adapter, filter, &list)` | `ad_notification_list_free(list)` |
|
||||
| `ad_dismiss_all_notifications(adapter, f, &ok, &fail)` | `ad_notification_list_free` on each (or `ad_dismiss_all_notifications_free(ok, fail)`) |
|
||||
| `ad_launch_app(adapter, id, timeout, &out)` | `ad_release_window_fields(&out)` (free interior strings; struct itself lives on caller's stack) |
|
||||
| `ad_get_tree(adapter, win, opts, &out)` | `ad_free_tree(&out)` |
|
||||
| `ad_resolve_element(adapter, entry, &handle)` | `ad_free_handle(adapter, &handle)` — `*mut AdNativeHandle`; the call zeroes `handle.ptr` on success so a follow-up call is a no-op |
|
||||
| `ad_find(adapter, win, query, &handle)` | same as `ad_resolve_element` |
|
||||
| `ad_execute_action(adapter, handle, action, &out)` | `ad_free_action_result(&out)` |
|
||||
| `ad_execute_action_with_policy(adapter, handle, action, policy, &out)` | `ad_free_action_result(&out)` |
|
||||
| `ad_execute_ref_action_with_policy(adapter, entry, action, policy, &out)` | `ad_free_action_result(&out)` |
|
||||
| `ad_notification_action(adapter, idx, expected_app, expected_title, name, &out)` | `ad_free_action_result(&out)` — pass the `app_name`/`title` you observed in `ad_list_notifications` (either may be null) so NC reorder between list and press is caught with `ERR_NOTIFICATION_NOT_FOUND` instead of pressing a different notification |
|
||||
| `ad_screenshot(adapter, target, &buf)` | `ad_image_buffer_free(buf)` (buf is opaque; read via `ad_image_buffer_{data,size,width,height,format}`) |
|
||||
| `ad_get_clipboard(adapter, &text)` | `ad_free_string(text)` |
|
||||
| `ad_get(adapter, handle, property, &text)` | `ad_free_string(text)` (text may be null on "property absent"; `ad_free_string(NULL)` is a no-op) |
|
||||
### Command-backed JSON strings
|
||||
|
||||
These entrypoints write an owned, NUL-terminated JSON envelope into
|
||||
`*out`; free with `ad_free_string`. See error-handling.md for the
|
||||
dual-failure mode (command-level errors write `ok:false` JSON into
|
||||
`*out`; infrastructure errors leave `*out` null with no allocation).
|
||||
|
||||
| Allocates | Frees with |
|
||||
|-----------------------------------------------------------------------------------|-------------------------|
|
||||
| `ad_version(&out)` | `ad_free_string(out)` |
|
||||
| `ad_status(adapter, &out)` | `ad_free_string(out)` |
|
||||
| `ad_snapshot(adapter, app, surface, max_depth, interactive_only, compact, &out)` | `ad_free_string(out)` |
|
||||
| `ad_execute_by_ref(adapter, ref_id, snapshot_id, action, policy, &out)` | `ad_free_string(out)` |
|
||||
| `ad_wait(adapter, args, &out)` | `ad_free_string(out)` |
|
||||
|
||||
### Adapter lifecycle
|
||||
|
||||
| Allocates | Frees with |
|
||||
|------------------------------------------------------|-----------------------------------------|
|
||||
| `ad_adapter_create()` | `ad_adapter_destroy(adapter)` |
|
||||
| `ad_adapter_create_with_session(session)` | `ad_adapter_destroy(adapter)` |
|
||||
|
||||
### Opaque list handles
|
||||
|
||||
| Allocates | Frees with |
|
||||
|--------------------------------------------------------------|-----------------------------------------|
|
||||
| `ad_list_apps(adapter, &list)` | `ad_app_list_free(list)` |
|
||||
| `ad_list_windows(adapter, app, focused, &list)` | `ad_window_list_free(list)` |
|
||||
| `ad_list_surfaces(adapter, pid, &list)` | `ad_surface_list_free(list)` |
|
||||
| `ad_list_notifications(adapter, filter, &list)` | `ad_notification_list_free(list)` |
|
||||
| `ad_dismiss_all_notifications(adapter, f, &ok, &fail)` | `ad_notification_list_free` on each, or `ad_dismiss_all_notifications_free(ok, fail)` |
|
||||
|
||||
### App / window lifecycle
|
||||
|
||||
| Allocates | Frees with |
|
||||
|-----------------------------------------------------------|---------------------------------------------------------------------|
|
||||
| `ad_launch_app(adapter, id, timeout, &out)` | `ad_release_window_fields(&out)` — frees interior strings only; the `AdWindowInfo` struct lives on the caller's stack |
|
||||
|
||||
### Raw tree and element access
|
||||
|
||||
| Allocates | Frees with |
|
||||
|----------------------------------------------------------------------------------------|-----------------------------------------|
|
||||
| `ad_get_tree(adapter, win, opts, &out)` | `ad_free_tree(&out)` |
|
||||
| `ad_resolve_element(adapter, entry, &handle)` | `ad_free_handle(adapter, &handle)` — zeroes `handle.ptr` so a follow-up call is a no-op |
|
||||
| `ad_find(adapter, win, query, &handle)` | same as `ad_resolve_element` |
|
||||
|
||||
### Action results
|
||||
|
||||
| Allocates | Frees with |
|
||||
|----------------------------------------------------------------------------------------|------------------------------|
|
||||
| `ad_execute_action(adapter, handle, action, &out)` | `ad_free_action_result(&out)` |
|
||||
| `ad_execute_action_with_policy(adapter, handle, action, policy, &out)` | `ad_free_action_result(&out)` |
|
||||
| `ad_execute_ref_action_with_policy(adapter, entry, action, policy, &out)` | `ad_free_action_result(&out)` |
|
||||
| `ad_notification_action(adapter, idx, expected_app, expected_title, name, &out)` — pass the `app_name`/`title` from `ad_list_notifications` (either may be null) so NC reorder between list and press returns `ERR_NOTIFICATION_NOT_FOUND` instead of pressing a different notification | `ad_free_action_result(&out)` |
|
||||
|
||||
### Clipboard and image buffers
|
||||
|
||||
| Allocates | Frees with |
|
||||
|---------------------------------------------|---------------------------------------------------------------------|
|
||||
| `ad_get_clipboard(adapter, &text)` | `ad_free_string(text)` |
|
||||
| `ad_get(adapter, handle, property, &text)` | `ad_free_string(text)` — text may be null when the property is absent; `ad_free_string(NULL)` is a no-op |
|
||||
| `ad_screenshot(adapter, target, &buf)` | `ad_image_buffer_free(buf)` (buf is opaque; read via `ad_image_buffer_{data,size,width,height,format}`) |
|
||||
|
||||
## Rules
|
||||
|
||||
|
|
@ -37,20 +80,21 @@ free function. Always call it; the allocator the FFI uses is Rust's
|
|||
opaque wrappers are allocated by `Box::into_raw`; the second call
|
||||
would invoke `Box::from_raw` on a freed allocation. Always set the
|
||||
pointer to `NULL` after freeing.
|
||||
- **`ad_free_handle` is safe to double-call** — it zeroes
|
||||
`handle.ptr` after the platform release, so a follow-up call sees
|
||||
`NULL` and returns `AD_RESULT_OK` without re-entering `CFRelease`.
|
||||
- **`ad_free_handle` is safe to double-call** — it zeroes `handle.ptr`
|
||||
after the platform release, so a follow-up call sees `NULL` and
|
||||
returns `AD_RESULT_OK` without re-entering `CFRelease`.
|
||||
- **Adapters must outlive their handles.** Free every handle with the
|
||||
same adapter that produced it before calling `ad_adapter_destroy`.
|
||||
Destroying the adapter first and later freeing its handles is
|
||||
undefined behavior for FFI hosts.
|
||||
undefined behavior.
|
||||
- Pointers inside a struct (`.id`, `.title`, `.app_name`, each
|
||||
`AdNotificationInfo.body`, etc.) are freed by the struct's owning
|
||||
free function (list_free / release_fields) — do not
|
||||
`ad_free_string()` them individually.
|
||||
- `AdActionResult` owns `action`, `ref_id`, `post_state`, `post_state.states`,
|
||||
`steps`, and each `steps[i].label` / `steps[i].outcome`; free all of them
|
||||
only through `ad_free_action_result(&out)`. Treat the returned counts as
|
||||
free function (list_free / release_fields) — do not call
|
||||
`ad_free_string()` on them individually.
|
||||
- `AdActionResult` owns `action`, `ref_id`, `post_state`,
|
||||
`post_state.states`, `steps`, and each `steps[i].label` /
|
||||
`steps[i].outcome`; free all of them only through
|
||||
`ad_free_action_result(&out)`. Treat the returned counts as
|
||||
read-only metadata; release the unmodified result struct.
|
||||
- Ownership does **not** transfer back to Rust after you free. Keep a
|
||||
local `NULL` to prevent accidental reuse.
|
||||
|
|
@ -72,5 +116,9 @@ In particular:
|
|||
struct is a no-op.
|
||||
- `ad_screenshot` writes `*out = NULL` before allocating the image
|
||||
buffer — no stale pointer when the screenshot fails.
|
||||
- `ad_snapshot`, `ad_execute_by_ref`, `ad_wait`, `ad_status`,
|
||||
`ad_version` write `*out = NULL` before any guard, so on
|
||||
infrastructure failures no allocation is made and `ad_free_string(NULL)`
|
||||
is a safe no-op.
|
||||
- `ad_*_list` and `ad_resolve_element` / `ad_find` all apply the same
|
||||
pattern to their handle / list out-params.
|
||||
|
|
|
|||
|
|
@ -2,12 +2,23 @@
|
|||
|
||||
## macOS: main-thread rule
|
||||
|
||||
Every adapter-touching entrypoint (`ad_get_tree`, `ad_find`, `ad_get`,
|
||||
`ad_is`, `ad_resolve_element`, `ad_execute_action`, `ad_screenshot`,
|
||||
clipboard get/set/clear, mouse, drag, launch, close, focus, window-op,
|
||||
list-apps/windows/surfaces, notification list/dismiss/action)
|
||||
**must be invoked on the process's main thread**. macOS accessibility
|
||||
and Cocoa APIs require this.
|
||||
Every adapter-touching entrypoint **must be invoked on the process's
|
||||
main thread**. macOS accessibility and Cocoa APIs require this.
|
||||
|
||||
Entrypoints subject to the main-thread guard:
|
||||
|
||||
- `ad_snapshot`, `ad_execute_by_ref`, `ad_wait`
|
||||
- `ad_get_tree`, `ad_find`, `ad_get`, `ad_is`, `ad_resolve_element`
|
||||
- `ad_execute_action`, `ad_execute_action_with_policy`,
|
||||
`ad_execute_ref_action_with_policy`
|
||||
- `ad_screenshot`
|
||||
- Clipboard: `ad_get_clipboard`, `ad_set_clipboard`, `ad_clear_clipboard`
|
||||
- Input: `ad_mouse_event`, `ad_drag`
|
||||
- App / window: `ad_launch_app`, `ad_close_app`, `ad_focus_window`,
|
||||
`ad_window_op`
|
||||
- Listing: `ad_list_apps`, `ad_list_windows`, `ad_list_surfaces`
|
||||
- Notifications: `ad_list_notifications`, `ad_dismiss_notification`,
|
||||
`ad_dismiss_all_notifications`, `ad_notification_action`
|
||||
|
||||
The check runs at **runtime, in every build profile** — worker-thread
|
||||
calls return `AD_RESULT_ERR_INTERNAL` with a `'static` diagnostic
|
||||
|
|
@ -18,26 +29,46 @@ UB window in release builds.
|
|||
On non-macOS targets the check is a compile-time `true` and has zero
|
||||
runtime cost.
|
||||
|
||||
Operations that are **safe off-main-thread** (no runtime guard):
|
||||
## Operations safe off the main thread
|
||||
|
||||
- `ad_adapter_create` / `ad_adapter_destroy`
|
||||
- `ad_last_error_{code,message,suggestion,platform_detail}`
|
||||
- `ad_check_permissions` (pure process-wide query)
|
||||
- `ad_app_list_count` / `_get` / `_free`
|
||||
- `ad_window_list_count` / `_get` / `_free`
|
||||
- `ad_surface_list_count` / `_get` / `_free`
|
||||
- `ad_notification_list_count` / `_get` / `_free`
|
||||
These functions carry no runtime main-thread guard:
|
||||
|
||||
- `ad_adapter_create` / `ad_adapter_create_with_session` / `ad_adapter_destroy`
|
||||
- `ad_init`, `ad_abi_version`
|
||||
- `ad_version` (no adapter; pure serialization)
|
||||
- `ad_status` (reads permission report and ref-store metadata only; no AX tree query)
|
||||
- `ad_check_permissions` (pure process-wide AX trust query)
|
||||
- `ad_set_log_callback`
|
||||
- `ad_last_error_code`, `ad_last_error_message`, `ad_last_error_suggestion`,
|
||||
`ad_last_error_platform_detail`, `ad_last_error_details`
|
||||
- List accessors: `ad_app_list_count` / `_get` / `_free`,
|
||||
`ad_window_list_count` / `_get` / `_free`,
|
||||
`ad_surface_list_count` / `_get` / `_free`,
|
||||
`ad_notification_list_count` / `_get` / `_free`
|
||||
- `ad_image_buffer_data` / `_size` / `_width` / `_height` / `_format` / `_free`
|
||||
- `ad_release_window_fields`
|
||||
- `ad_free_handle` (invokes `CFRelease` which is thread-safe) — but
|
||||
still prefer calling from the thread that produced the handle.
|
||||
- `ad_free_handle` (invokes `CFRelease` which is thread-safe — but
|
||||
still prefer calling from the thread that produced the handle)
|
||||
- `ad_free_tree`, `ad_free_action_result`, `ad_free_string`
|
||||
|
||||
## Log callback threading
|
||||
|
||||
`ad_set_log_callback(cb)` is safe to call from any thread (no AX
|
||||
involvement). However, the installed callback may be invoked from threads
|
||||
other than the registering thread — tracing events can originate from any
|
||||
thread that calls an `ad_*` function.
|
||||
|
||||
A callback unregistered via `NULL` may still receive one or more
|
||||
invocations from a concurrent thread for a brief window after this call
|
||||
returns. The callback (and any data it captures) must remain valid for the
|
||||
process lifetime, or the caller must quiesce all active adapter calls
|
||||
before unregistering.
|
||||
|
||||
## Python consumers
|
||||
|
||||
CPython's GIL serializes calls but does not pin them to the main
|
||||
thread. If you're calling from anything other than the main interpreter
|
||||
thread you will silently corrupt state on macOS.
|
||||
CPython's GIL serializes calls but does not pin them to the main thread.
|
||||
If you're calling from anything other than the main interpreter thread
|
||||
you will silently corrupt state on macOS.
|
||||
|
||||
Two patterns work:
|
||||
|
||||
|
|
@ -48,10 +79,9 @@ Two patterns work:
|
|||
|
||||
## AXIsProcessTrusted inheritance
|
||||
|
||||
⚠ **Privilege-escalation vector.** `ad_check_permissions` calls macOS's
|
||||
`AXIsProcessTrusted()`, which returns the trust status of the **hosting
|
||||
executable** — i.e., the `python3` / `node` / `swift` process, not
|
||||
`agent-desktop` itself.
|
||||
`ad_check_permissions` calls macOS's `AXIsProcessTrusted()`, which
|
||||
returns the trust status of the **hosting executable** — i.e., the
|
||||
`python3` / `node` / `swift` process, not `agent-desktop` itself.
|
||||
|
||||
Consequence: granting accessibility permission to one Python script's
|
||||
Python interpreter grants it to every Python script that dlopens
|
||||
|
|
@ -61,9 +91,9 @@ rather than relying on macOS's binary-level grant.
|
|||
|
||||
## Thread-ownership of handles
|
||||
|
||||
`ad_resolve_element` returns an opaque `AdNativeHandle` that wraps a
|
||||
platform pointer. The handle is **single-owner, single-thread** by FFI
|
||||
contract:
|
||||
`ad_resolve_element` and `ad_find` return an opaque `AdNativeHandle`
|
||||
that wraps a platform pointer. The handle is **single-owner,
|
||||
single-thread** by FFI contract:
|
||||
|
||||
- Create it on thread A → free it on thread A with `ad_free_handle`.
|
||||
Transferring the handle to thread B is undefined behavior.
|
||||
|
|
@ -74,3 +104,11 @@ contract:
|
|||
Every thread has its own last-error slot. Thread A's failure does not
|
||||
set thread B's last-error; `ad_last_error_*` accessors always see the
|
||||
calling thread's state.
|
||||
|
||||
## ad_wait lifecycle
|
||||
|
||||
`ad_wait` blocks the calling thread for up to `timeout_ms` milliseconds
|
||||
while it holds a live reference into the adapter's allocation. Do not
|
||||
call `ad_adapter_destroy` on the same handle from another thread while
|
||||
`ad_wait` is running — that is a use-after-free. Ensure the wait has
|
||||
returned before destroying the adapter.
|
||||
|
|
|
|||
68
tests/ffi-python/README.md
Normal file
68
tests/ffi-python/README.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# FFI Python Smoke Harness
|
||||
|
||||
Proves the C ABI works from a non-Rust host (Python ctypes) and gates it in CI.
|
||||
|
||||
## What it tests
|
||||
|
||||
| Leg | Check |
|
||||
|-----|-------|
|
||||
| 1 | `ad_abi_version()` equals `AD_ABI_VERSION_MAJOR` from the header (catches header/dylib mismatch at import) |
|
||||
| 2 | Every `ad_*_size()` getter equals the corresponding `AD_*_SIZE` macro (catches struct layout drift) |
|
||||
| 3 | `ad_version()` returns `ok:true` JSON with `data.version` (basic command pipeline) |
|
||||
| 4 | `ad_adapter_create` → `ad_snapshot` → `ad_free_string` → `ad_adapter_destroy` without crash or leak; stub adapter yields a clean `PLATFORM_NOT_SUPPORTED` envelope |
|
||||
|
||||
## Building the dylib
|
||||
|
||||
```bash
|
||||
# From the repo root:
|
||||
cargo build --locked --profile release-ffi -p agent-desktop-ffi --features stub-adapter
|
||||
```
|
||||
|
||||
The dylib lands at `target/release-ffi/libagent_desktop_ffi.dylib` (macOS) or
|
||||
`target/release-ffi/libagent_desktop_ffi.so` (Linux).
|
||||
|
||||
`--profile release-ffi` keeps `panic = "unwind"`, which is required for the
|
||||
`catch_unwind` traps inside the FFI layer. Using the default `release` profile
|
||||
(which has `panic = "abort"`) would silently defeat those traps.
|
||||
|
||||
`--features stub-adapter` replaces the real platform adapter with a no-op that
|
||||
returns `PLATFORM_NOT_SUPPORTED` for every adapter call. This lets CI run the
|
||||
harness without requiring macOS Accessibility permissions.
|
||||
|
||||
## Running locally
|
||||
|
||||
```bash
|
||||
# macOS (arm64 or x86_64):
|
||||
python3 tests/ffi-python/smoke.py \
|
||||
target/release-ffi/libagent_desktop_ffi.dylib \
|
||||
crates/ffi/include/agent_desktop.h
|
||||
```
|
||||
|
||||
Alternatively, use environment variables:
|
||||
|
||||
```bash
|
||||
export AD_DYLIB_PATH=target/release-ffi/libagent_desktop_ffi.dylib
|
||||
export AD_HEADER_PATH=crates/ffi/include/agent_desktop.h
|
||||
python3 tests/ffi-python/smoke.py
|
||||
```
|
||||
|
||||
## Stub enforcement (`AD_EXPECT_STUB=1`)
|
||||
|
||||
Set `AD_EXPECT_STUB=1` when running the harness against a stub-adapter build.
|
||||
With this variable set, the harness fails if `ad_snapshot()` returns `ok:true`,
|
||||
which would indicate the dylib was **not** built with `--features stub-adapter`.
|
||||
CI always sets this variable so a real-adapter dylib cannot accidentally pass the
|
||||
stub-only gate.
|
||||
|
||||
## Dependencies
|
||||
|
||||
`smoke.py` uses only the Python standard library (`ctypes`, `json`, `re`,
|
||||
`pathlib`, `sys`). No `pip install` required.
|
||||
|
||||
## Real-adapter happy path
|
||||
|
||||
The smoke harness gates the AX-independent surface (ABI version, struct sizes,
|
||||
`ad_version`) and the `PLATFORM_NOT_SUPPORTED` passthrough path. The real-adapter
|
||||
happy path (a successful `ad_snapshot` with `ok:true`) is covered by the E2E
|
||||
harness (`tests/e2e/run.sh`), which requires AX permission and a release build
|
||||
without the stub feature.
|
||||
272
tests/ffi-python/smoke.py
Normal file
272
tests/ffi-python/smoke.py
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
"""
|
||||
FFI smoke harness: proves the C ABI works from a non-Rust host (Python ctypes).
|
||||
|
||||
Usage:
|
||||
python3 tests/ffi-python/smoke.py <dylib_path> <header_path>
|
||||
|
||||
Environment variables (override positional args):
|
||||
AD_DYLIB_PATH path to libagent_desktop_ffi.{dylib,so,dll}
|
||||
AD_HEADER_PATH path to crates/ffi/include/agent_desktop.h
|
||||
|
||||
Exit codes:
|
||||
0 all legs passed
|
||||
1 assertion failure or load error (message printed to stderr)
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from ctypes import (
|
||||
POINTER,
|
||||
c_bool,
|
||||
c_char_p,
|
||||
c_int,
|
||||
c_size_t,
|
||||
c_uint,
|
||||
c_uint8,
|
||||
c_void_p,
|
||||
byref,
|
||||
)
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def fail(msg: str) -> None:
|
||||
print(f"FAIL: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def ok(msg: str) -> None:
|
||||
print(f" ok: {msg}")
|
||||
|
||||
|
||||
def bind(lib: ctypes.CDLL, name: str, restype, argtypes: list) -> ctypes.CFUNCTYPE:
|
||||
"""Bind one symbol; raises AttributeError with a clear name if missing."""
|
||||
try:
|
||||
fn = getattr(lib, name)
|
||||
except AttributeError:
|
||||
fail(f"symbol not found in dylib: {name}")
|
||||
fn.restype = restype
|
||||
fn.argtypes = argtypes
|
||||
return fn
|
||||
|
||||
|
||||
def parse_header_constants(header_path: str) -> dict:
|
||||
"""
|
||||
Parse numeric #define constants from the header.
|
||||
|
||||
Returns a dict mapping macro name → int value.
|
||||
Only plain integer literals are handled (no expressions).
|
||||
"""
|
||||
text = Path(header_path).read_text()
|
||||
result = {}
|
||||
for m in re.finditer(r"#define\s+(\w+)\s+(\d+)", text):
|
||||
result[m.group(1)] = int(m.group(2))
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def resolve_paths() -> tuple[str, str]:
|
||||
dylib = os.environ.get("AD_DYLIB_PATH") or (sys.argv[1] if len(sys.argv) > 1 else "")
|
||||
header = os.environ.get("AD_HEADER_PATH") or (sys.argv[2] if len(sys.argv) > 2 else "")
|
||||
if not dylib:
|
||||
fail("dylib path required: pass as argv[1] or set AD_DYLIB_PATH")
|
||||
if not header:
|
||||
fail("header path required: pass as argv[2] or set AD_HEADER_PATH")
|
||||
if not Path(dylib).exists():
|
||||
fail(f"dylib not found: {dylib}")
|
||||
if not Path(header).exists():
|
||||
fail(f"header not found: {header}")
|
||||
return dylib, header
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
dylib_path, header_path = resolve_paths()
|
||||
|
||||
print(f"\nLoading dylib: {dylib_path}")
|
||||
try:
|
||||
lib = ctypes.CDLL(dylib_path)
|
||||
except OSError as exc:
|
||||
fail(f"failed to load dylib: {exc}")
|
||||
|
||||
constants = parse_header_constants(header_path)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Bind all symbols upfront with explicit restype + argtypes.
|
||||
# A missing or wrong-arity symbol surfaces an AttributeError here,
|
||||
# before any assertion runs.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
ad_abi_version = bind(lib, "ad_abi_version", c_uint, [])
|
||||
|
||||
# Size getters — restype c_size_t, no args
|
||||
ad_action_size = bind(lib, "ad_action_size", c_size_t, [])
|
||||
ad_action_result_size = bind(lib, "ad_action_result_size", c_size_t, [])
|
||||
ad_action_step_size = bind(lib, "ad_action_step_size", c_size_t, [])
|
||||
ad_drag_params_size = bind(lib, "ad_drag_params_size", c_size_t, [])
|
||||
ad_element_state_size = bind(lib, "ad_element_state_size", c_size_t, [])
|
||||
ad_ref_entry_size = bind(lib, "ad_ref_entry_size", c_size_t, [])
|
||||
ad_wait_args_size = bind(lib, "ad_wait_args_size", c_size_t, [])
|
||||
|
||||
# String management
|
||||
ad_version = bind(lib, "ad_version", c_int, [POINTER(c_char_p)])
|
||||
ad_free_string = bind(lib, "ad_free_string", None, [c_char_p])
|
||||
|
||||
# Adapter lifecycle
|
||||
ad_adapter_create = bind(lib, "ad_adapter_create", c_void_p, [])
|
||||
ad_adapter_destroy = bind(lib, "ad_adapter_destroy", None, [c_void_p])
|
||||
|
||||
# Snapshot (adapter leg)
|
||||
ad_snapshot = bind(
|
||||
lib, "ad_snapshot", c_int,
|
||||
[c_void_p, c_char_p, c_int, c_uint8, c_bool, c_bool, POINTER(c_char_p)],
|
||||
)
|
||||
|
||||
print("\nLeg 1 — ABI version handshake")
|
||||
expected_major = constants.get("AD_ABI_VERSION_MAJOR")
|
||||
if expected_major is None:
|
||||
fail("AD_ABI_VERSION_MAJOR not found in header")
|
||||
|
||||
got_major = ad_abi_version()
|
||||
if got_major != expected_major:
|
||||
fail(
|
||||
f"ad_abi_version() returned {got_major}, "
|
||||
f"expected {expected_major} (AD_ABI_VERSION_MAJOR from header)"
|
||||
)
|
||||
ok(f"ad_abi_version() == AD_ABI_VERSION_MAJOR == {expected_major}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
print("\nLeg 2 — struct size getters vs. header macros")
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Explicit table: (getter_fn, macro_name) — no name-mangling magic.
|
||||
size_pairs = [
|
||||
(ad_action_size, "AD_ACTION_SIZE"),
|
||||
(ad_action_result_size, "AD_ACTION_RESULT_SIZE"),
|
||||
(ad_action_step_size, "AD_ACTION_STEP_SIZE"),
|
||||
(ad_drag_params_size, "AD_DRAG_PARAMS_SIZE"),
|
||||
(ad_element_state_size, "AD_ELEMENT_STATE_SIZE"),
|
||||
(ad_ref_entry_size, "AD_REF_ENTRY_SIZE"),
|
||||
(ad_wait_args_size, "AD_WAIT_ARGS_SIZE"),
|
||||
]
|
||||
|
||||
for getter, macro in size_pairs:
|
||||
expected = constants.get(macro)
|
||||
if expected is None:
|
||||
fail(f"{macro} not found in header")
|
||||
got = getter()
|
||||
if got != expected:
|
||||
fail(f"{macro}: dylib getter returned {got}, header says {expected} (struct layout drift)")
|
||||
ok(f"{macro}: {got} bytes")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
print("\nLeg 3 — ad_version returns parseable JSON with data.version")
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
out = c_char_p()
|
||||
rc = ad_version(byref(out))
|
||||
if rc != 0:
|
||||
fail(f"ad_version() returned {rc}, expected 0 (AD_RESULT_OK)")
|
||||
if out.value is None:
|
||||
fail("ad_version() returned OK but *out is null")
|
||||
|
||||
raw = out.value # bytes — read before freeing
|
||||
ad_free_string(out)
|
||||
|
||||
try:
|
||||
envelope = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
fail(f"ad_version() output is not valid JSON: {exc}\nraw: {raw!r}")
|
||||
|
||||
if not envelope.get("ok"):
|
||||
fail(f"ad_version() envelope has ok!=true: {envelope}")
|
||||
|
||||
data = envelope.get("data")
|
||||
if not isinstance(data, dict) or "version" not in data:
|
||||
fail(f"ad_version() envelope missing data.version: {envelope}")
|
||||
|
||||
ok(f"ad_version() -> data.version = {data['version']!r}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
print("\nLeg 4 — adapter leg: create → snapshot → assert PLATFORM_NOT_SUPPORTED → destroy")
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
expect_stub = os.environ.get("AD_EXPECT_STUB") == "1"
|
||||
|
||||
adapter = ad_adapter_create()
|
||||
if not adapter:
|
||||
fail("ad_adapter_create() returned null")
|
||||
ok("ad_adapter_create() non-null")
|
||||
|
||||
snap_out = c_char_p()
|
||||
rc = ad_snapshot(
|
||||
adapter, # adapter pointer
|
||||
None, # app (null = focused window)
|
||||
0, # surface = Window
|
||||
10, # max_depth
|
||||
False, # interactive_only
|
||||
False, # compact
|
||||
byref(snap_out),
|
||||
)
|
||||
|
||||
# Under the stub adapter, a command-level error writes the error
|
||||
# envelope to *out rather than nulling it. Parse regardless of rc.
|
||||
if snap_out.value is not None:
|
||||
raw_snap = snap_out.value
|
||||
ad_free_string(snap_out)
|
||||
|
||||
try:
|
||||
snap_env = json.loads(raw_snap)
|
||||
except json.JSONDecodeError as exc:
|
||||
ad_adapter_destroy(adapter)
|
||||
fail(f"ad_snapshot() output is not valid JSON: {exc}\nraw: {raw_snap!r}")
|
||||
|
||||
if snap_env.get("ok") is True:
|
||||
if expect_stub:
|
||||
ad_adapter_destroy(adapter)
|
||||
fail(
|
||||
"ad_snapshot() returned ok:true but AD_EXPECT_STUB=1 — "
|
||||
"the dylib appears to be built without --features stub-adapter; "
|
||||
"a stub build cannot successfully complete a real snapshot"
|
||||
)
|
||||
# Real adapter succeeded (e.g. local run with AX grant + real adapter).
|
||||
ok("ad_snapshot() -> ok:true (real adapter path)")
|
||||
else:
|
||||
# Expected stub-adapter path: ok:false + PLATFORM_NOT_SUPPORTED.
|
||||
err = snap_env.get("error", {})
|
||||
code = err.get("code", "")
|
||||
if code != "PLATFORM_NOT_SUPPORTED":
|
||||
ad_adapter_destroy(adapter)
|
||||
fail(
|
||||
f"ad_snapshot() envelope error.code = {code!r}, "
|
||||
"expected 'PLATFORM_NOT_SUPPORTED' (stub adapter)"
|
||||
)
|
||||
ok(f"ad_snapshot() -> ok:false, error.code = {code!r}")
|
||||
else:
|
||||
# *out null means an argument/infrastructure error — rc is the code.
|
||||
ad_adapter_destroy(adapter)
|
||||
fail(
|
||||
f"ad_snapshot() set *out=null (rc={rc}); "
|
||||
"expected the error envelope to be written to *out"
|
||||
)
|
||||
|
||||
ad_adapter_destroy(adapter)
|
||||
ok("ad_adapter_destroy() — no crash, no leak")
|
||||
|
||||
print("\nAll legs passed.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in a new issue