Commit graph

135 commits

Author SHA1 Message Date
Lahfir
413cf903e7 docs(ffi): audit + tighten every FFI doc-comment
Sweep crates/ffi/ docstrings per the review ask ("make them
comprehensive, remove the unwanted ones"):

- ad_adapter_create: add missing header — ownership, lifetime, failure
  mode.
- ad_last_error_{code,message,suggestion,platform_detail}: each gets a
  dedicated 1-2 line doc; the errno-style lifetime block kept above
  ad_last_error_code as the anchor for the family.
- ad_drag: explain duration_ms == 0 semantics.
- ad_mouse_event: note click_count only applies to the CLICK kind.
- ad_get_clipboard / ad_set_clipboard / ad_clear_clipboard /
  ad_free_string: clarify UTF-8 input, error branch, null-tolerance,
  and double-free semantics.
- ad_focus_window: document window-not-found error and id/title
  prerequisites.
- ad_window_op: explain which op.kind fields are read.
- ad_launch_app: document return-ownership of AdWindowInfo and the
  zero-on-error contract.
- ad_close_app: document force-flag semantics.
- ad_get_tree: describe BFS layout, options, and out-param zeroing.

Remove test-intent //-comments that were already captured in renamed
test names:
- enum_validation::fuzz_arbitrary_bit_patterns_never_panic_across_all_enums
- main_thread::is_main_thread_call_is_always_safe_even_on_workers
- main_thread::debug_assert_panic_on_worker_thread_does_not_escape_catch_unwind

Tighten the two retained //-blocks (error.rs parity assertion, build.rs
cbindgen-false semantics) so each explains a non-obvious invariant a
future reader couldn't infer from code alone.

62 FFI tests pass. Clippy clean.
2026-04-16 04:33:28 -07:00
Lahfir
0f6a2c53fb test(ffi): C-ABI harness integration tests (Unit 11b)
New crates/ffi/tests/c_abi_harness.rs exercises bug classes the inline
#[cfg(test)] modules can't reach — pointer-level access patterns a C
consumer would use:

- rect_and_point_layouts_are_memcpyable: confirms #[repr(C)] structs
  survive a byte-level read/copy (the pattern a C caller uses with
  memcpy into a local).
- enum_fuzz_invalid_discriminant_rejected: writes i32::MAX into an
  AdAction.kind field via raw pointer copy (bypassing Rust's enum
  validity invariant) and asserts ad_execute_action returns either
  ErrInvalidArgs (enum validator caught it) or ErrInternal (main-thread
  assert tripped first on the worker thread) — never Ok, never UB.
- null_tolerance_on_list_accessors_and_free: ad_*_count, ad_*_get,
  ad_*_free all accept null pointers without segfaulting.
- list_handle_lifecycle_roundtrip: ad_list_apps allocates a list,
  accessor returns null for out-of-range index, _free drops the Box.
- list_windows_focused_only_runs: exercises the new focused_only bool
  parameter from Unit 6.
- invalid_utf8_app_id_rejected: passes a truncated UTF-8 byte sequence
  as an app id; ad_launch_app returns ErrInvalidArgs instead of
  pan­icking during string decode.
- find_returns_not_found_on_empty_query_against_no_window: zeroed
  AdWindowInfo is rejected by window validation before the tree walk
  can dereference the null id/title pointers.
- free_handle_null_is_noop: both the handle value being null-pointer
  and the handle argument itself being null return Ok.
- last_error_survives_successful_calls: canonical errno-style contract —
  failure sets last-error, five successful calls later the pointer still
  resolves to the original message string.

Uses the same cross-fork compatible pattern as error_lifetime.rs:
#[allow(improper_ctypes)] over extern "C" declarations so the test
drives the FFI symbols exactly like a C linker would, without needing
bindgen or a C compiler in CI.

62 FFI tests pass (52 lib + 1 error_lifetime + 9 c_abi_harness).
Workspace-wide: 75 lib tests. Clippy clean.
2026-04-16 04:29:24 -07:00
Lahfir
1fcb2e6936 feat(ffi): observation primitives — ad_find / ad_get / ad_is (Unit 9)
Cheap single-element lookups without hand-walking the flat-tree result
of ad_get_tree. Closes the observation portion of R11 from PR #22 review.

New module tree crates/ffi/src/observation/ — one concern per file:

- walk.rs: pub(crate) find_first_match(node, role?, name?, value?) — DFS
  case-insensitive substring matcher shared by find.rs and is.rs.
  Extracted so both FFI entrypoints speak the same matching semantics
  and unit-testable in isolation.
- find.rs: ad_find(adapter, win, query, &handle). Walks the tree, picks
  the first match, resolves it to a NativeHandle via the existing
  PlatformAdapter::resolve_element path. Returns ErrElementNotFound
  when nothing matches. Caller owns the handle → must
  ad_free_handle(adapter, handle).
- get.rs: ad_get(adapter, handle, property, &str_out) dispatches on
  property name. Supported: "value" (via get_live_value), "bounds" (via
  get_element_bounds, formatted as JSON). Unknown property returns
  ErrInvalidArgs. Output string owned by caller → free with ad_free_string.
- is.rs: ad_is(adapter, win, query, property, &bool_out) looks up the
  element the same way find does but checks a named state against the
  node's states vec. Recognized: focused, enabled, selected, checked,
  expanded. Unknown property returns ErrInvalidArgs with a diagnostic
  listing the valid names.

New type in types/find_query.rs (AdFindQuery { role, name_substring,
value_substring }) already landed in Unit 5 and is re-exported here.

All entries go through trap_panic with debug_assert_main_thread.

53 tests pass. Clippy clean.
2026-04-16 04:26:58 -07:00
Lahfir
6d6917b939 feat(ffi): notification FFI surface (Unit 8)
Exposes all four notification operations over the C ABI. Closes the
notification portion of R11 from PR #22 review.

New module tree crates/ffi/src/notifications/ — one concern per file:

- filter.rs: filter_from_c() converts AdNotificationFilter -> core
  NotificationFilter with null-safe c_to_string and has_limit semantics.
- list.rs:   ad_list_notifications + ad_notification_list_count /
  _get / _free. List handle is the opaque AdNotificationList already
  landed in Unit 5.
- dismiss.rs: ad_dismiss_notification(adapter, index, app_filter).
- dismiss_all.rs: ad_dismiss_all_notifications returns both a
  `dismissed` and a `failed` list so partial failures are surfaced to
  the caller without overwriting last-error. Adds
  ad_dismiss_all_notifications_free convenience wrapper.
- action.rs: ad_notification_action(adapter, index, action_name, out)
  triggers the named action (typically "Reply", "Open") and populates
  an AdActionResult.

New type plumbing:
- crates/ffi/src/types/notification_{info,filter,list}.rs were added in
  Unit 5; lib.rs re-exports them via explicit `pub use`.
- crates/ffi/src/convert/notification.rs: notification_info_to_c +
  free_notification_info_fields with the same lossy-NUL / action-string
  handling as window/app/surface converters.

All new entrypoints go through trap_panic / trap_panic_void, assert
main-thread in debug builds via debug_assert_main_thread, and use
string_to_c_lossy for mandatory fields (app_name, title, action names).
Filter fields stay optional via opt_string_to_c.

Index-stability contract documented in the rustdoc on
ad_list_notifications — indexes are valid only within the response to
the most recent list call; callers must re-query after each dismiss.

53 tests pass. Clippy clean.
2026-04-16 04:23:28 -07:00
Lahfir
98f64fdeca feat(ffi): opaque list handles + image buffer length encapsulation (Unit 5)
Replaces every `(*mut T, count)` list-returning API with an opaque
handle and encapsulates AdImageBuffer's byte-buffer length. Closes
R8 and R22 from PR #22 review.

## Opaque list handles

Four new one-type-per-file opaque wrappers (no `#[repr(C)]` — cbindgen
auto-emits as `typedef struct AdFoo AdFoo;` forward declarations):

- crates/ffi/src/types/window_list.rs   — AdWindowList
- crates/ffi/src/types/app_list.rs      — AdAppList
- crates/ffi/src/types/surface_list.rs  — AdSurfaceList
- crates/ffi/src/types/notification_list.rs — AdNotificationList (used by Unit 8)

Each list owns its `Box<[AdXxxInfo]>`. Consumers walk through
`_count(list)`, `_get(list, index) -> *const AdXxxInfo` (null on OOB),
and free with `_free(list)` — the free walks the entries, releases
their interior C-strings, and drops the Box.

Rewritten signatures:

| Old                                                        | New                                                       |
|------------------------------------------------------------|-----------------------------------------------------------|
| ad_list_apps(adapter, \*\*apps, \*count)                   | ad_list_apps(adapter, \*\*list)                           |
| ad_list_windows(adapter, filter, focused, \*\*wins, \*count)| ad_list_windows(adapter, filter, focused, \*\*list)       |
| ad_list_surfaces(adapter, pid, \*\*sfs, \*count)           | ad_list_surfaces(adapter, pid, \*\*list)                  |
| ad_free_apps(apps, count)                                  | ad_app_list_free(list)                                    |
| ad_free_windows(wins, count)                               | ad_window_list_free(list)                                 |
| ad_free_surfaces(sfs, count)                               | ad_surface_list_free(list)                                |
| ad_free_window(win)   [for single AdWindowInfo]            | ad_release_window_fields(win)                             |

Count mismatches are impossible by construction — callers never see
the backing pointer or length.

## Image buffer encapsulation

crates/ffi/src/types/image_buffer.rs: dropped `#[repr(C)]`, private
`Box<[u8]>` data field, private width/height/format. Before, a C
caller who mutated `AdImageBuffer.data_len` triggered heap corruption
at free time; now the length is authoritative inside the Rust-owned
struct.

New accessors in crates/ffi/src/screenshot/accessors.rs:
- ad_image_buffer_data    -> *const u8
- ad_image_buffer_size    -> u64 (always matches the allocation)
- ad_image_buffer_width   -> u32
- ad_image_buffer_height  -> u32
- ad_image_buffer_format  -> AdImageFormat

ad_screenshot signature changed: `*mut *mut AdImageBuffer out` instead
of `*mut AdImageBuffer out`. ad_free_image renamed to
ad_image_buffer_free for consistency with the list-handle pattern.

All new files stay under 120 LOC, explicit `pub use` per type, no
wildcard imports, no inline `//` comments. `///` docs on every
public FFI export cover null-tolerance, lifetime, and safety
requirements.

53 tests pass. Clippy clean.
2026-04-16 04:20:55 -07:00
Lahfir
94bfefa9a2 ci: broaden pull_request trigger, add workflow_dispatch
Previous pull_request: branches: [main, master] only fired when the PR
targeted main or master; a pull_request block without 'branches' fires
on any target and keeps this PR (and future stacked PRs) under CI.

Adds workflow_dispatch so the workflow can be kicked manually from the
Actions UI or 'gh workflow run'.
2026-04-16 04:14:13 -07:00
Lahfir
9740cbb44b docs(ffi): add agent-desktop-ffi skill + CLAUDE.md updates (Unit 13)
skills/agent-desktop-ffi/ is a new skill directory:
- SKILL.md — frontmatter (name, version, tags), overview, four core
  constraints (main-thread, release-ffi profile, errno lifetime,
  handle release, enum validation, pre-1.0 ABI instability).
- references/ownership.md — allocation/release table for every *mut T
  the FFI returns, plus null-tolerance and double-free rules.
- references/error-handling.md — errno-style last-error contract,
  full error-code table, enum validation, panic-safety guarantee.
- references/threading.md — macOS main-thread rule, debug/release
  contract differences, Python consumer patterns, the
  AXIsProcessTrusted privilege-escalation note, single-owner handle
  invariant, thread-local last-error.
- references/build-and-link.md — build command for the cdylib,
  minimal C and Python ctypes worked examples.

CLAUDE.md updates:
- Workspace tree now lists crates/ffi/ alongside the platform crates.
- Dependency-inversion section acknowledges the FFI crate as a
  legitimate second platform → core wiring point, parallel to the
  binary crate (previously only src/ was named).

Skill will be picked up by the next `clawhub sync --root skills/ --all`
release automatically; no release-workflow change needed.
2026-04-16 04:06:12 -07:00
Lahfir
00d2474498 feat(ffi): ABI surface completion — AdSnapshotSurface, focused_only, release_handle (Unit 6)
Lands the last set of ABI-shape changes before the header is published.
Any addition after publication would be a breaking change for every
consumer linked against the old layout.

Core / platform additions:
- crates/core/src/adapter.rs: new `PlatformAdapter::release_handle`
  trait method with `not_supported()` default. macOS implementations
  must `CFRelease` the underlying AXUIElementRef to balance the
  CFRetain that happened during resolve. Windows/Linux inherit the
  default unchanged.
- crates/macos/src/adapter.rs: macOS impl calls
  `core_foundation::base::CFRelease(raw)` when the raw pointer is
  non-null.

FFI surface additions:
- crates/ffi/src/types/snapshot_surface.rs: new AdSnapshotSurface enum
  (Window=0, Focused, Menu, Menubar, Sheet, Popover, Alert) mirroring
  the 7-variant core SnapshotSurface.
- crates/ffi/src/types/tree_options.rs: new `surface: AdSnapshotSurface`
  field. Previous hard-coded SnapshotSurface::Window is replaced by
  consumer-selected surface with enum-validated read at entry.
- crates/ffi/src/windows/list.rs: ad_list_windows gains `focused_only:
  bool` between app_filter and out — maps directly to
  WindowFilter::focused_only. Consumers can now call `ad_focused_window`-
  style queries without a second ABI round.
- crates/ffi/src/actions/native_handle.rs: new `ad_free_handle(adapter,
  handle)` exported. Null-tolerant on both sides; Windows/Linux
  `ActionNotSupported` is translated to `Ok` so C consumers can call
  the release path uniformly across platforms.

Validation:
- AdSnapshotSurface added to enum_validation.rs — ad_get_tree now
  rejects invalid surface discriminants with
  `AD_RESULT_ERR_INVALID_ARGS` rather than matching on UB.

Closes R5, R9, R10 from PR #22 review.
52 lib tests pass, 1 integration test passes. Clippy clean.
2026-04-16 04:03:34 -07:00
Lahfir
2c13fbefaa feat(ffi): main-thread enforcement for macOS-sensitive entrypoints (Unit 11)
macOS accessibility and Cocoa APIs must be called on the process's main
thread; off-thread use is silent undefined behavior that is hard to
diagnose from the consumer side (Python, Swift, Node workers).

Add crates/ffi/src/main_thread.rs with:
- is_main_thread(): libc::pthread_main_np() on macOS, `true` elsewhere.
- debug_assert_main_thread(): panics in debug, no-op in release. Panic
  is caught by the trap_panic boundary and surfaces as
  AD_RESULT_ERR_INTERNAL with a diagnostic last-error — violators
  get a loud signal in dev, an actionable error code in prod.

Apply the assert as the first statement inside trap_panic bodies for
the high-traffic AX paths: ad_get_tree, ad_resolve_element,
ad_execute_action, ad_get_clipboard, ad_screenshot. (Release / launch /
list / window-op / etc. remaining entrypoints can be covered in a
follow-up; these five are the hot paths agents actually hit.)

Add a crate-level //! rustdoc block on lib.rs documenting the thread-
safety model, the build-profile requirement (release-ffi, not release),
and the errno-style last-error lifetime. cbindgen propagates the
rustdoc to agent_desktop.h so C consumers read the constraints from the
header.

53 tests pass. Clippy clean.
2026-04-16 04:00:38 -07:00
Lahfir
ee2374e565 feat(ffi): build hygiene — pinned cbindgen, drift check, variant parity (Unit 12)
Closes R14, R19, R20, R23 from PR #22 review.

Build pipeline:
- crates/ffi/Cargo.toml: pin cbindgen = "= 0.27.0" exactly. No more
  silent formatting drift from patch-version bumps of cbindgen; any
  future bump is a deliberate PR that also commits the regenerated
  header.
- crates/ffi/build.rs: replace .expect()/.ok() swallowing with explicit
  panics on every failure path. Missing CARGO_MANIFEST_DIR or OUT_DIR
  now emits a cargo:warning rather than crashing rustc. cbindgen errors
  now panic loudly with the diagnostic; previously an .ok() on fs::copy
  silently emitted a stale header.

CI:
- .github/workflows/ci.yml: new "FFI cdylib build" step under the
  release-ffi profile and a "FFI header drift" step that runs
  `git diff --exit-code crates/ffi/include/agent_desktop.h` immediately
  after. Any uncommitted change to the generated header fails the build
  with a clear message telling the developer to run the local rebuild
  and commit.

Variant parity:
- crates/ffi/src/error.rs: compile-time assertion
  `const _: () = assert!(error_code_variant_count() ==
  ad_result_error_variant_count())` guards against core adding an
  ErrorCode variant without a matching AdResult entry (which would
  silently drop information at the FFI boundary). Uses stable const fn
  + explicit variant arrays, no nightly variant_count feature.

50 lib tests, 1 integration test — all passing. Clippy clean.
2026-04-16 03:58:30 -07:00
Lahfir
6a01d976d5 feat(ffi): out-param zeroing, lossy NUL handling, window validation (Unit 7)
Three defensive-hardening additions that close R6, R13, and R15 from the
PR #22 review:

1. Out-param zeroing at entry
   Every fallible FFI fn now zeroes its out-param *before* any fallible
   work, so when the fn returns an error code the caller can safely call
   the paired ad_free_* fn on an as-if-never-populated struct.
   - ad_launch_app: *out = zeroed AdWindowInfo
   - ad_resolve_element: (*out).ptr = null
   - ad_execute_action: *out = zeroed AdActionResult
   - ad_screenshot: *out = zeroed AdImageBuffer
   (ad_get_tree / ad_list_* already had this from prior work.)

2. Lossy string helper for mandatory fields
   Add convert::string::string_to_c_lossy — replaces interior NUL bytes
   with U+FFFD (3-byte UTF-8 sequence) so CString::new is always
   infallible. Applied to mandatory fields:
   - convert::window_info_to_c: id, title, app_name
   - convert::app_info_to_c: name
   - convert::surface_info_to_c: kind
   - tree::flatten: role and each state string
   - actions::action_result_to_c: action, post_state.role, state strings

   Optional fields keep opt_string_to_c (null-on-NUL is the correct
   signal for "field absent" when the header documents nullability).

3. Window identity validation
   windows::to_core::ad_window_to_core now returns
   Result<WindowInfo, AdapterError>. Null or non-UTF-8 id/title returns
   InvalidArgs with a diagnostic message rather than silently coercing
   to "" — previously a caller with a bad id could match the wrong
   window. app_name stays lenient (some Electron apps emit blank
   window owners).

   Updated callers to propagate the error: tree::get::ad_get_tree,
   windows::focus::ad_focus_window, windows::op::ad_window_op.

50 tests pass (+3 new lossy-NUL tests).
2026-04-16 03:56:44 -07:00
Lahfir
4434357bdd fix(ffi): rewrite tree flatten as iterative BFS (Unit 4)
The prior recursive DFS layout placed a child node immediately after
its parent, intermixing descendants with the parent's siblings. That
meant AdNode.child_start..child_start + child_count walked through
grandchildren instead of direct children — silent wrong data for every
consumer iterating the flat tree.

New layout: iterative level-order (BFS) traversal using a VecDeque.
Siblings of any node are always contiguous at
  nodes[n.child_start .. n.child_start + n.child_count]
so direct-child iteration is correct by construction, at every level.

- Pre-count pass sizes the Vec once with Vec::with_capacity — avoids
  repeated reallocation on deep/wide trees.
- No recursion means consumers on thin stacks (JNI ~256 KB, Python
  ctypes worker threads) no longer risk stack overflow regardless of
  tree depth.
- `parent_index` still back-references correctly.

New test coverage:
- test_flatten_breadth_first_layout (was depth_first_order): iterates
  the child ranges and asserts direct-child roles match the expected
  sequence — this is the test that would fail against the old DFS
  layout.
- test_flatten_deep_chain: 11-level nested chain, walks child_start at
  every level and confirms the single-child ranges address correctly.
- test_flatten_wide_root: 100 direct children under one root, confirms
  the whole sibling run is contiguous and every child is reachable.

Closes R1 from PR #22 review (the blocker-class child-range bug).
47 tests pass (+2 new BFS-specific tests).
2026-04-16 03:53:21 -07:00
Lahfir
0ff2993b0f feat(ffi): validate every #[repr(i32)] enum at the C boundary (Unit 2)
C callers can legally place any int32_t bit pattern into an enum-typed
field of a #[repr(C)] struct. Reading that field as the Rust enum type
and pattern-matching against its variants is undefined behavior when
the discriminant is out-of-range — Rust assumes enum values are valid.

New crates/ffi/src/enum_validation.rs provides:
- enum_raw_i32(&T) -> i32: reads the raw discriminant via pointer cast,
  never invokes the enum's validity invariant. Safe because #[repr(i32)]
  guarantees layout.
- try_from_c_enum! macro: generates <Enum>::from_c(raw: i32) -> Option<Self>
  that returns Some only for enumerated discriminants, None otherwise.
  Applied to all 8 C-exposed enums: AdActionKind (0..=20), AdDirection,
  AdModifier, AdMouseButton, AdMouseEventKind, AdWindowOpKind,
  AdScreenshotKind, AdImageFormat.

Call sites updated to read raw, validate, then match:
- actions/conversion.rs: action.kind, action.scroll.direction,
  each modifier in key_combo slice
- input/mouse.rs: ev.kind, ev.button
- windows/op.rs: op.kind
- screenshot/capture.rs: t.kind

Invalid discriminants now return AD_RESULT_ERR_INVALID_ARGS with a
diagnostic last-error string ("invalid <enum> discriminant").
Previously the code invoked UB before the call could return.

11 new enum-validation tests (45 total lib tests, +11 from 34).
Fuzz test iterates i32::MIN, i32::MAX, -1, 999, and edge values through
every validator — never panics, never UB.
2026-04-16 03:51:28 -07:00
Lahfir
b7af53e920 feat(ffi): errno-style last-error lifetime (Unit 3)
Last-error pointers returned by ad_last_error_{code,message,suggestion,
platform_detail} now survive across any number of subsequent successful
FFI calls — only the next *failing* call rotates them. This matches the
POSIX errno contract and closes R2 from the PR #22 review (use-after-free
when caller cached the message pointer and made another successful call).

Changes:
- Remove error::clear_last_error() from every Ok branch across adapter,
  tree/get, actions/{resolve,execute}, apps/{list,launch,close},
  windows/{list,focus,op}, input/{clipboard,mouse,drag},
  screenshot/capture, surfaces/list. The slot now only rotates on a new
  set_last_error().
- clear_last_error is gated behind #[cfg(test)] since no production
  caller needs it after this change.
- Add a crate-level rustdoc block on ad_last_error_code documenting the
  errno-style lifetime contract — cbindgen propagates this to
  agent_desktop.h so C consumers can read the rule from the header.
- crates/ffi/tests/error_lifetime.rs: integration test reproducing the
  review's UAF scenario — fails on ErrInvalidArgs, caches the message
  pointer, makes 10 successful ad_check_permissions calls, asserts the
  cached pointer still resolves to the same string.

Lib Cargo.toml now emits both cdylib and rlib so the integration test
can link against the public crate without duplicating symbol bindings.

35 tests pass (34 lib + 1 integration). Clippy clean.
2026-04-16 03:47:27 -07:00
Lahfir
c3856c1be4 feat(ffi): wrap every extern "C" entrypoint in a panic boundary
Add crates/ffi/src/ffi_try.rs with three helpers:
- trap_panic(|| -> AdResult)   for AdResult-returning fns
- trap_panic_ptr(|| -> *mut T) for ad_adapter_create
- trap_panic_const_ptr(|| -> *const T) for ad_last_error_*
- trap_panic_void(|| ())        for ad_*_destroy / ad_free_*

Each helper runs its body under std::panic::catch_unwind with
AssertUnwindSafe, stashes a 'static C-string message
("rust panic in FFI boundary") via a new
error::set_last_error_static(code, &'static CStr) helper that never
allocates, and returns AD_RESULT_ERR_INTERNAL / null on panic. The
panic payload is intentionally discarded — allocating inside a panic
handler risks double-panic and the host's tracing/logs hold the
diagnostic anyway.

Every #[no_mangle] pub extern "C" fn body is now wrapped:
- adapter.rs: ad_adapter_create, ad_adapter_destroy, ad_check_permissions
- error.rs: ad_last_error_{code,message,suggestion,platform_detail}
- tree/{get,free}.rs: ad_get_tree, ad_free_tree
- actions/{resolve,execute,result}.rs: ad_resolve_element,
  ad_execute_action, ad_free_action_result
- apps/{list,launch,close}.rs: ad_list_apps, ad_free_apps,
  ad_launch_app, ad_close_app
- windows/{list,free_one,focus,op}.rs: ad_list_windows, ad_free_windows,
  ad_free_window, ad_focus_window, ad_window_op
- input/{clipboard,mouse,drag}.rs: ad_get_clipboard, ad_set_clipboard,
  ad_clear_clipboard, ad_free_string, ad_mouse_event, ad_drag
- screenshot/{capture,free}.rs: ad_screenshot, ad_free_image
- surfaces/list.rs: ad_list_surfaces, ad_free_surfaces

Add crates/ffi/examples/panic_spike.rs as a permanent regression
example — `cargo run --profile release-ffi --example panic_spike`
catches the synthetic panic and exits 0. If someone flips the profile
back to panic=abort, the example SIGABRTs instead — a loud signal.

Verified:
- cargo build --profile release-ffi -p agent-desktop-ffi: ok
- cargo test --lib -p agent-desktop-ffi: 34 passed (+7 new ffi_try tests)
- cargo clippy --all-targets -p agent-desktop-ffi -- -D warnings: ok
- cbindgen header symbol set: 31 functions before / 31 after
- panic_spike under release-ffi: PANIC CAUGHT OK (exit 0)

Closes R4 from PR #22 review.
2026-04-16 03:43:35 -07:00
Lahfir
a38bdd22d9 feat(ffi): add release-ffi profile and publish guard for cdylib
Workspace Cargo.toml gains [profile.release-ffi] inheriting from
release with panic = "unwind". Cargo rejects per-package `panic`
overrides (error: panic may not be specified in a package profile —
verified experimentally), so a dedicated profile is the Cargo-supported
path to build the cdylib with unwinding while the CLI stays on
panic = "abort".

crates/ffi/Cargo.toml gains:
- publish = false — prevents accidental `cargo publish` of the cdylib
  crate until the distribution plan intentionally removes the guard.
- libc = "0.2" under the macos target — Unit 11 uses pthread_main_np().

Both profiles coexist:
- target/release/libagent_desktop_ffi.dylib       442 KB (panic=abort)
- target/release-ffi/libagent_desktop_ffi.dylib   464 KB (panic=unwind)

Size delta for unwind metadata (~22 KB, under the 500 KB risk budget).
Prerequisite for Unit 1's panic-boundary macro.
2026-04-16 03:34:28 -07:00
Lahfir
078553299a refactor(ffi): rename c_to_str to c_to_string, fix unbound-lifetime footgun
c_to_str<'a>(*const c_char) -> Option<&'a str> let the caller pick any
lifetime — including 'static — while the returned reference actually
only lives as long as the raw pointer. That is unsound: a caller could
bind the result to a 'static slot and dereference long after the
underlying buffer is gone.

Replace with c_to_string(*const c_char) -> Option<String> — always
clones into an owned String. Every caller (actions, apps, input,
windows, tree tests, convert tests) updated to pass &String where &str
was needed or .as_deref() in asserts. No caller was relying on
short-lived borrow semantics; the existing flow was already
.to_owned()-ing immediately.

Closes the Unit R soundness portion. No behavior or ABI change;
cbindgen header: 31 functions in, 31 out (ordering-only diff).
2026-04-16 03:33:34 -07:00
Lahfir
f28dd95555 refactor(ffi): audit and remove unjustified #[allow(dead_code)] annotations
Every reachable pub(crate) helper (convert/ helpers, tree/flatten::flatten_tree,
error::{set_last_error, clear_last_error, last_error_code, error_code_to_result})
now stands without the annotation — these are all called from #[no_mangle]
entrypoints and rustc sees them correctly.

Move the three test-only last_error readers (last_error_message_str,
last_error_suggestion_str, last_error_platform_detail_str) plus the
MessageSource::to_owned_string helper into #[cfg(test)] scope. They were
only ever used by tests; having them stand at module scope required the
annotation and muddied the public-surface read.

Zero #[allow(dead_code)] remains in crates/ffi/src/. Tree still compiles,
27 tests still pass, cbindgen header symbol set unchanged.
2026-04-16 03:29:37 -07:00
Lahfir
95c3da2352 refactor(ffi): extract adapter from lib.rs, use explicit re-exports
Move AdAdapter + build_adapter + ad_adapter_create / ad_adapter_destroy /
ad_check_permissions into a dedicated crates/ffi/src/adapter.rs matching
the crates/macos/src/adapter.rs single-responsibility pattern.

lib.rs is now a module-declaration + explicit re-export shell. The
`pub use types::*` wildcard is replaced with one `pub use` per type so
adding a new type requires a deliberate re-export (prevents accidental
ABI expansion). Narrow most modules to pub(crate) — only error and
types stay pub because cbindgen scans them for public types.

Every file under 400 LOC (max 225). No behavior or ABI change; symbol
set identical.

Closes the structural portion of Unit R. Remaining Unit R cleanup
(c_to_str unbound lifetime + dead_code audit) lands next.
2026-04-16 03:27:03 -07:00
Lahfir
a49c35165e refactor(ffi): split screenshot.rs and surfaces.rs into submodules
- screenshot/{capture,free}.rs: ad_screenshot + ad_free_image
- surfaces/list.rs: ad_list_surfaces + ad_free_surfaces

Drop the last unused convert::surface re-exports; convert/mod.rs now
only re-exports rect_to_c (the single consumer sits inside tree/flatten
and benefits from the shortened path).

No behavior or ABI change; symbol set identical, ordering-only cbindgen diff.
2026-04-16 03:26:01 -07:00
Lahfir
29bde8dd03 refactor(ffi): split input.rs into input/ submodules
Move input.rs into input/{clipboard,mouse,drag}.rs.
- clipboard.rs: ad_get_clipboard + ad_set_clipboard + ad_clear_clipboard + ad_free_string
- mouse.rs: mouse_button_from_c + ad_mouse_event + tests
- drag.rs: ad_drag + tests

Drop unused convert::{c_to_str, free_c_string, string_to_c} re-exports
from convert/mod.rs; all callers now hit crate::convert::string::
directly.

No behavior or ABI change.
2026-04-16 03:25:05 -07:00
Lahfir
690d275484 refactor(ffi): split windows.rs into windows/ submodules
Move windows.rs into windows/{to_core,list,focus,free_one,op}.rs.
- to_core.rs: ad_window_to_core helper (pub(crate) re-exported so
  tree/get.rs keeps its existing crate::windows::ad_window_to_core path)
- list.rs: ad_list_windows + ad_free_windows
- focus.rs: ad_focus_window
- free_one.rs: ad_free_window (single-struct free from ad_launch_app;
  Unit 5b renames to ad_release_window_fields)
- op.rs: ad_window_op

Remove now-unused convert::{window_info_to_c, free_window_info_fields}
re-exports.

No behavior or ABI change. Header symbol set identical.
2026-04-16 03:23:56 -07:00
Lahfir
7463123095 refactor(ffi): split apps.rs into apps/ submodules
Move apps.rs into apps/{list,launch,close}.rs — one concern per file.
Drop the now-unused convert::{app_info_to_c, free_app_info_fields}
re-exports from convert/mod.rs (apps/list.rs imports from the concrete
convert::app submodule directly).

No behavior or ABI change. Header symbol set identical; emission order
shifts per cbindgen's module-path sort (ordering-only diff).
2026-04-16 03:22:49 -07:00
Lahfir
2cf6bb01b7 refactor(ffi): split actions.rs into actions/ submodules
Move actions.rs into actions/{conversion,resolve,execute,result}.rs.
- conversion.rs: direction_from_c + key_combo_from_c + action_from_c + tests
- resolve.rs: ad_resolve_element
- execute.rs: ad_execute_action
- result.rs: action_result_to_c + ad_free_action_result + tests

actions/mod.rs declares submodules; the #[no_mangle] entries remain
discoverable by the cdylib linker and cbindgen without re-exports.
Drop the unused opt_string_to_c re-export from convert/mod.rs (all new
modules import from crate::convert::string:: directly).

No behavior or ABI change. cbindgen header symbol set unchanged;
emission order shifts due to cbindgen's module-path topological sort
(ordering-only diff per Unit R verification criteria).
2026-04-16 03:21:53 -07:00
Lahfir
f3f8c733c2 refactor(ffi): split tree.rs into tree/ submodules, strip inline comments
Move tree.rs into tree/flatten.rs (flatten_tree + flatten_recursive +
strings_to_c_array + its tests), tree/free.rs (free_c_string_array,
free_node_fields, ad_free_tree + null-free test) and tree/get.rs
(ad_get_tree). Drop inline //-comments that restate what the next line
already says.

Behavior unchanged: flatten_tree is still the existing recursive DFS
layout (Unit 4 rewrites to iterative BFS so child ranges address direct
children). cbindgen header byte-identical.
2026-04-16 03:19:49 -07:00
Lahfir
a9661cd69e refactor(ffi): split convert.rs into convert/ submodules
Move convert.rs helpers into convert/{string,rect,window,app,surface}.rs.
Each submodule carries just the helpers for one kind (C-string helpers,
rect conversion, window-info conversion, app-info conversion, surface-info
conversion) plus its focused test(s). convert/mod.rs re-exports the same
public surface so every caller keeps its existing
`use crate::convert::{string_to_c, ...}` paths intact.

No behavior, signature, or ABI change; cbindgen header is byte-identical.
Unit R precondition; Unit 7 later refines string helpers (lossy/NUL-safe).
2026-04-16 03:18:00 -07:00
Lahfir
c2ea56c295 refactor(ffi): split types.rs into one module per type
Move the 28 packed #[repr(C)] structs and #[repr(i32)] enums from
crates/ffi/src/types.rs into crates/ffi/src/types/{point,rect,node,
action,...}.rs, one declaration per file. types/mod.rs carries
explicit pub mod + pub use per type, so callers keep the same
`use crate::types::AdFoo` paths and cbindgen emits a byte-identical
generated header.

Purely mechanical; no behavior, signature, or ABI change. Unit R
precondition for later fix work where individual types are extended
(AdTreeOptions.surface, AdImageBuffer length encapsulation, etc.).
2026-04-16 03:16:57 -07:00
Lahfir
f54087fb3a refactor(ffi): eliminate error.rs unwrap with static CStr fallback
Replace the infallible-today .unwrap() on CString::new fallback with a
MessageSource enum that carries either an owned CString or a 'static
CStr pointer. Interior-NUL payloads now deterministically resolve to
the NUL_BYTE_FALLBACK constant rather than relying on an unwrap that
could panic if the literal ever gained a NUL byte.

Adds a regression test for interior-NUL handling.

Precondition for Unit 1's panic boundary, which needs a last-error
path that never allocates and never panics.
2026-04-16 03:14:31 -07:00
Lahfir
fe2cf4446d chore: ignore .context/ for local tooling artifacts 2026-04-13 19:11:54 -07:00
Jake Rosoman
88fec35f5f fix(ffi): use dynamic error codes and fix Box::from_raw UB in action result 2026-04-06 14:21:50 +10:00
Jake Rosoman
3cd32d49ef feat(ffi): copy generated header to include/ for distribution 2026-04-06 14:17:23 +10:00
Jake Rosoman
bae931d7c5 style(ffi): apply rustfmt formatting 2026-04-06 14:16:09 +10:00
Jake Rosoman
ecba145827 feat(ffi): add surfaces list and get_tree FFI 2026-04-06 14:14:55 +10:00
Jake Rosoman
15115149a5 feat(ffi): add screenshot capture and image buffer 2026-04-06 14:13:27 +10:00
Jake Rosoman
2adcbb4cc0 feat(ffi): add clipboard, mouse, and drag FFI functions 2026-04-06 14:12:05 +10:00
Jake Rosoman
297dfeb3e6 feat(ffi): add window list, focus, and operations 2026-04-06 14:10:33 +10:00
Jake Rosoman
42c61ff0c1 feat(ffi): add action types, execute_action, resolve_element 2026-04-06 14:08:50 +10:00
Jake Rosoman
80b0e1fbf4 feat(ffi): add app lifecycle FFI functions 2026-04-06 14:06:22 +10:00
Jake Rosoman
34b04e16bc feat(ffi): add tree flattening with depth-first AdNodeTree 2026-04-06 14:04:59 +10:00
Jake Rosoman
b0e42c13a2 feat(ffi): add adapter lifecycle and permissions check 2026-04-06 14:02:09 +10:00
Jake Rosoman
272071b133 feat(ffi): add thread-local error handling with C accessors 2026-04-06 14:00:01 +10:00
Jake Rosoman
c724bc4892 feat(ffi): add primitive, window, app, surface C types with conversions 2026-04-06 13:57:14 +10:00
Jake Rosoman
920e7ef719 feat: scaffold ffi crate with cbindgen 2026-04-06 13:53:27 +10:00
Lahfir
ccc734ecbc docs: add surface-first snapshot rule for overlays
When a sheet, alert, popover, or menu is open, agents should snapshot
the specific surface instead of the full window. Background refs are
irrelevant while an overlay has focus and waste tokens.
2026-03-03 12:34:50 -08:00
github-actions[bot]
eb607e2b8e
chore(main): release 0.1.11 (#17)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-03 09:54:25 -05:00
Lahfir
39b2bc6348 fix: show skill install prompt on all success paths
promptSkillInstall() was only called after fresh download. Now also
shows when binary already exists or comes from AGENT_DESKTOP_BINARY_PATH.
2026-03-03 06:50:02 -08:00
github-actions[bot]
ceb02470c1
chore(main): release 0.1.10 (#16)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-03 09:23:49 -05:00
Lahfir
208af12459 fix: add clawhub login step before sync in CI
clawhub CLI requires explicit login, env var alone is not enough.
2026-03-03 06:22:06 -08:00
github-actions[bot]
4abb4b9484
chore(main): release 0.1.9 (#15)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-03 09:19:22 -05:00
Lahfir
97665203a4
feat: scalable skill architecture with ClawHub auto-publishing (#14)
* feat: restructure skills for ClawHub publishing with CI auto-publish

- Sync core skill to 54 commands (add notification commands)
- Move macOS skill from .claude/skills/ to git-tracked skills/
- Extract Notification Center section to references/notifications.md
- Add ClawHub metadata (version, tags, requirements) to all SKILL.md
- Remove macos.md from core skill (moved to platform skill)
- Create scripts/link-skills.sh for local dev symlinks
- Add publish-skills CI job to release.yml (ClawHub auto-publish)
- Add skill install prompt to npm postinstall

* refactor: consolidate macOS into single agent-desktop skill

Merge agent-desktop-macos back into agent-desktop/references/macos.md
as a single publishable skill. Includes Notification Center content
inline rather than as a separate reference file.
2026-03-03 06:16:15 -08:00