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.
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
panicking 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.
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.
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.
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.
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'.
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.
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.
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.
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.
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).
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).
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.
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.
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.
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.
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).
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.
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.
- 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.
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.
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.
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).
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).
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.
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).
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.).
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.
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.
* 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.