From 0bbd4bb95d41f5cfc38b73e2e7e6e96a959ec6c7 Mon Sep 17 00:00:00 2001 From: neo Date: Mon, 27 Apr 2026 16:03:32 -0400 Subject: [PATCH] feat(ffi): expose wda and wda_bridge to C/C++ (#89) * feat(ffi): expose wda and wda_bridge to C/C++ tested on an iPhone 13 with WebDriverAgent running: status returns the right JSON screenshot saves a valid PNG bridge forwards a curlrequest to the device's WDA port. spent a while testing and writing this up do review before merging * chore: lint --- Cargo.lock | 1 + cpp/examples/wda.cpp | 136 +++ cpp/include/idevice++/wda.hpp | 128 +++ cpp/include/idevice++/wda_bridge.hpp | 56 ++ cpp/src/wda.cpp | 388 ++++++++ cpp/src/wda_bridge.cpp | 46 + ffi/Cargo.toml | 3 + ffi/src/lib.rs | 4 + ffi/src/wda.rs | 1360 ++++++++++++++++++++++++++ ffi/src/wda_bridge.rs | 180 ++++ 10 files changed, 2302 insertions(+) create mode 100644 cpp/examples/wda.cpp create mode 100644 cpp/include/idevice++/wda.hpp create mode 100644 cpp/include/idevice++/wda_bridge.hpp create mode 100644 cpp/src/wda.cpp create mode 100644 cpp/src/wda_bridge.cpp create mode 100644 ffi/src/wda.rs create mode 100644 ffi/src/wda_bridge.rs diff --git a/Cargo.lock b/Cargo.lock index 3e658e6..a1ccf8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1325,6 +1325,7 @@ dependencies = [ "once_cell", "plist", "plist_ffi", + "serde_json", "tokio", "tracing", "tracing-appender", diff --git a/cpp/examples/wda.cpp b/cpp/examples/wda.cpp new file mode 100644 index 0000000..486bd13 --- /dev/null +++ b/cpp/examples/wda.cpp @@ -0,0 +1,136 @@ +// Jackson Coxson + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +using namespace IdeviceFFI; + +static void print_usage(const char* prog) { + std::cerr << "Usage:\n" + << " " << prog << " status\n" + << " " << prog << " screenshot [bundle_id]\n" + << " " << prog << " bridge\n"; +} + +static Provider build_provider(const std::string& label) { + auto mux = UsbmuxdConnection::default_new(0).expect("failed to connect to usbmuxd"); + auto devices = mux.get_devices().expect("failed to list devices"); + if (devices.empty()) { + std::cerr << "no devices connected\n"; + std::exit(1); + } + auto& dev = devices[0]; + + auto udid = dev.get_udid(); + if (udid.is_none()) { + std::cerr << "device has no UDID\n"; + std::exit(1); + } + auto mux_id = dev.get_id(); + if (mux_id.is_none()) { + std::cerr << "device has no mux id\n"; + std::exit(1); + } + + auto addr = UsbmuxdAddr::default_new(); + const uint32_t tag = 0; + return Provider::usbmuxd_new(std::move(addr), tag, udid.unwrap(), mux_id.unwrap(), label) + .expect("failed to create provider"); +} + +int main(int argc, char** argv) { + idevice_init_logger(Debug, Disabled, NULL); + + // Usage: + // wda status + // wda screenshot [bundle_id] + // wda bridge + if (argc < 2) { + print_usage(argv[0]); + return 2; + } + + const std::string command = argv[1]; + + if (command == "status") { + if (argc != 2) { + print_usage(argv[0]); + return 2; + } + + auto wda = Wda::create(build_provider("wda-client")).expect("failed to create Wda client"); + + auto status = wda.status().expect("failed to fetch /status from WDA"); + std::cout << status << "\n"; + return 0; + } + + if (command == "screenshot") { + if (argc < 3 || argc > 4) { + print_usage(argv[0]); + return 2; + } + + const std::string out_path = argv[2]; + Option bundle_id; + if (argc == 4) { + bundle_id = Some(std::string(argv[3])); + } + + auto wda = Wda::create(build_provider("wda-client")).expect("failed to create Wda client"); + + auto session_id = wda.start_session(bundle_id).expect("failed to start WDA session"); + std::cout << "Session: " << session_id << "\n"; + + auto buf = wda.screenshot(None).expect("failed to capture screenshot"); + + wda.delete_session(session_id).expect("failed to delete WDA session"); + + std::ofstream out(out_path, std::ios::binary); + if (!out.is_open()) { + std::cerr << "failed to open output file: " << out_path << "\n"; + return 1; + } + out.write(reinterpret_cast(buf.data()), + static_cast(buf.size())); + out.close(); + + std::cout << "Screenshot saved to " << out_path << " (" << buf.size() << " bytes)\n"; + return 0; + } + + if (command == "bridge") { + if (argc != 2) { + print_usage(argv[0]); + return 2; + } + + auto bridge = WdaBridge::start(build_provider("wda-bridge")) + .expect("failed to start WDA bridge"); + auto endpoints = bridge.endpoints().expect("failed to read bridge endpoints"); + + if (endpoints.udid.is_some()) { + std::cout << "udid: " << endpoints.udid.unwrap() << "\n"; + } + std::cout << "wda_url: " << endpoints.wda_url << "\n" + << "mjpeg_url: " << endpoints.mjpeg_url << "\n" + << "device_http: " << endpoints.device_http << "\n" + << "device_mjpeg:" << endpoints.device_mjpeg << "\n"; + + std::cout << "\nForwarding active. Press Enter to stop.\n"; + std::string line; + std::getline(std::cin, line); + return 0; + } + + print_usage(argv[0]); + return 2; +} diff --git a/cpp/include/idevice++/wda.hpp b/cpp/include/idevice++/wda.hpp new file mode 100644 index 0000000..f92d494 --- /dev/null +++ b/cpp/include/idevice++/wda.hpp @@ -0,0 +1,128 @@ +// Jackson Coxson + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace IdeviceFFI { + +using WdaClientPtr = + std::unique_ptr>; + +/// Minimal WebDriverAgent client over a single device's direct connections. +/// +/// `Wda::create` consumes the `Provider` argument; subsequent calls reuse the +/// owned provider to open per-request sockets, matching the underlying Rust +/// `WdaClient` semantics. +class Wda { + public: + static Result create(Provider&& provider); + + // Builder-style configuration + Result set_ports(uint16_t http, uint16_t mjpeg); + Result set_timeout_ms(uint64_t ms); + Result, FfiError> get_ports() const; + Option session_id() const; + + // Status / session + Result status(); + Result wait_until_ready(uint64_t timeout_ms); + Result start_session(Option bundle_id); + Result delete_session(const std::string& session_id); + + // Element finding & state + Result find_element(const std::string& using_, + const std::string& value, + Option session_id); + Result, FfiError> find_elements(const std::string& using_, + const std::string& value, + Option session_id); + Result element_attribute(const std::string& element_id, + const std::string& name, + Option session_id); + Result element_text(const std::string& element_id, + Option session_id); + Result element_rect(const std::string& element_id, + Option session_id); + Result element_displayed(const std::string& element_id, + Option session_id); + Result element_enabled(const std::string& element_id, + Option session_id); + Result element_selected(const std::string& element_id, + Option session_id); + + // Interaction + Result click(const std::string& element_id, Option session_id); + Result send_keys(const std::string& text, Option session_id); + Result press_button(const std::string& name, Option session_id); + Result unlock(Option session_id); + Result swipe(int64_t start_x, + int64_t start_y, + int64_t end_x, + int64_t end_y, + double duration, + Option session_id); + Result tap(Option x, + Option y, + Option element_id, + Option session_id); + Result double_tap(Option x, + Option y, + Option element_id, + Option session_id); + Result touch_and_hold(double duration, + Option x, + Option y, + Option element_id, + Option session_id); + Result scroll(Option direction, + Option name, + Option predicate_string, + Option to_visible, + Option element_id, + Option session_id); + + // Output / app + Result source(Option session_id); + Result, FfiError> screenshot(Option session_id); + Result window_size(Option session_id); + Result viewport_rect(Option session_id); + Result orientation(Option session_id); + Result launch_app(const std::string& bundle_id, + const std::vector& arguments, + Option environment_json, + Option session_id); + Result activate_app(const std::string& bundle_id, + Option session_id); + Result terminate_app(const std::string& bundle_id, + Option session_id); + Result query_app_state(const std::string& bundle_id, + Option session_id); + Result background_app(Option seconds, + Option session_id); + Result is_locked(Option session_id); + + // RAII / moves + ~Wda() noexcept = default; + Wda(Wda&&) noexcept = default; + Wda& operator=(Wda&&) noexcept = default; + Wda(const Wda&) = delete; + Wda& operator=(const Wda&) = delete; + + WdaClientHandle* raw() const noexcept { return handle_.get(); } + static Wda adopt(WdaClientHandle* h) noexcept { return Wda(h); } + + private: + explicit Wda(WdaClientHandle* h) noexcept : handle_(h) {} + WdaClientPtr handle_{}; +}; + +} // namespace IdeviceFFI diff --git a/cpp/include/idevice++/wda_bridge.hpp b/cpp/include/idevice++/wda_bridge.hpp new file mode 100644 index 0000000..188ea5b --- /dev/null +++ b/cpp/include/idevice++/wda_bridge.hpp @@ -0,0 +1,56 @@ +// Jackson Coxson + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +namespace IdeviceFFI { + +using WdaBridgePtr = + std::unique_ptr>; + +/// Localhost endpoints exposed by a running WDA bridge. +struct WdaBridgeEndpoints { + Option udid; + std::string wda_url; + std::string mjpeg_url; + uint16_t local_http = 0; + uint16_t local_mjpeg = 0; + uint16_t device_http = 0; + uint16_t device_mjpeg = 0; +}; + +/// Dynamic localhost bridge for a single device's WDA endpoints. +/// +/// Both factories consume the `Provider` argument; dropping the bridge aborts +/// the underlying forwarder tasks. +class WdaBridge { + public: + static Result start(Provider&& provider); + static Result start_with_ports(Provider&& provider, + uint16_t device_http, + uint16_t device_mjpeg); + + Result endpoints() const; + + ~WdaBridge() noexcept = default; + WdaBridge(WdaBridge&&) noexcept = default; + WdaBridge& operator=(WdaBridge&&) noexcept = default; + WdaBridge(const WdaBridge&) = delete; + WdaBridge& operator=(const WdaBridge&) = delete; + + WdaBridgeHandle* raw() const noexcept { return handle_.get(); } + static WdaBridge adopt(WdaBridgeHandle* h) noexcept { return WdaBridge(h); } + + private: + explicit WdaBridge(WdaBridgeHandle* h) noexcept : handle_(h) {} + WdaBridgePtr handle_{}; +}; + +} // namespace IdeviceFFI diff --git a/cpp/src/wda.cpp b/cpp/src/wda.cpp new file mode 100644 index 0000000..ff46e34 --- /dev/null +++ b/cpp/src/wda.cpp @@ -0,0 +1,388 @@ +// Jackson Coxson + +#include + +namespace IdeviceFFI { + +namespace { + +inline const char* opt_cstr(const Option& s) { + return s.is_some() ? s.expect("checked").c_str() : nullptr; +} + +inline std::string adopt_cstring(char* raw) { + if (!raw) return {}; + std::string out(raw); + ::idevice_string_free(raw); + return out; +} + +} // namespace + +Result Wda::create(Provider&& provider) { + WdaClientHandle* out = nullptr; + FfiError e(::wda_client_new(provider.raw(), &out)); + if (e) { + // The provider was consumed by the FFI regardless of success. + provider.release(); + return Err(e); + } + provider.release(); + return Ok(Wda::adopt(out)); +} + +Result Wda::set_ports(uint16_t http, uint16_t mjpeg) { + FfiError e(::wda_client_set_ports(handle_.get(), http, mjpeg)); + if (e) return Err(e); + return Ok(); +} + +Result Wda::set_timeout_ms(uint64_t ms) { + FfiError e(::wda_client_set_timeout_ms(handle_.get(), ms)); + if (e) return Err(e); + return Ok(); +} + +Result, FfiError> Wda::get_ports() const { + uint16_t http = 0; + uint16_t mjpeg = 0; + FfiError e(::wda_client_get_ports(handle_.get(), &http, &mjpeg)); + if (e) return Err(e); + return Ok(std::make_pair(http, mjpeg)); +} + +Option Wda::session_id() const { + char* raw = nullptr; + FfiError e(::wda_client_session_id(handle_.get(), &raw)); + if (e || !raw) return None; + return Some(adopt_cstring(raw)); +} + +Result Wda::status() { + char* raw = nullptr; + FfiError e(::wda_client_status(handle_.get(), &raw)); + if (e) return Err(e); + return Ok(adopt_cstring(raw)); +} + +Result Wda::wait_until_ready(uint64_t timeout_ms) { + char* raw = nullptr; + FfiError e(::wda_client_wait_until_ready(handle_.get(), timeout_ms, &raw)); + if (e) return Err(e); + return Ok(adopt_cstring(raw)); +} + +Result Wda::start_session(Option bundle_id) { + char* raw = nullptr; + FfiError e(::wda_client_start_session(handle_.get(), opt_cstr(bundle_id), &raw)); + if (e) return Err(e); + return Ok(adopt_cstring(raw)); +} + +Result Wda::delete_session(const std::string& session_id) { + FfiError e(::wda_client_delete_session(handle_.get(), session_id.c_str())); + if (e) return Err(e); + return Ok(); +} + +Result Wda::find_element(const std::string& using_, + const std::string& value, + Option session_id) { + char* raw = nullptr; + FfiError e(::wda_client_find_element( + handle_.get(), using_.c_str(), value.c_str(), opt_cstr(session_id), &raw)); + if (e) return Err(e); + return Ok(adopt_cstring(raw)); +} + +Result, FfiError> Wda::find_elements(const std::string& using_, + const std::string& value, + Option session_id) { + char** arr = nullptr; + size_t count = 0; + FfiError e(::wda_client_find_elements(handle_.get(), + using_.c_str(), + value.c_str(), + opt_cstr(session_id), + &arr, + &count)); + if (e) return Err(e); + + std::vector out; + out.reserve(count); + for (size_t i = 0; i < count; ++i) { + out.emplace_back(arr[i] ? arr[i] : ""); + } + ::wda_client_string_array_free(arr, count); + return Ok(std::move(out)); +} + +Result Wda::element_attribute(const std::string& element_id, + const std::string& name, + Option session_id) { + char* raw = nullptr; + FfiError e(::wda_client_element_attribute( + handle_.get(), element_id.c_str(), name.c_str(), opt_cstr(session_id), &raw)); + if (e) return Err(e); + return Ok(adopt_cstring(raw)); +} + +Result Wda::element_text(const std::string& element_id, + Option session_id) { + char* raw = nullptr; + FfiError e(::wda_client_element_text( + handle_.get(), element_id.c_str(), opt_cstr(session_id), &raw)); + if (e) return Err(e); + return Ok(adopt_cstring(raw)); +} + +Result Wda::element_rect(const std::string& element_id, + Option session_id) { + char* raw = nullptr; + FfiError e(::wda_client_element_rect( + handle_.get(), element_id.c_str(), opt_cstr(session_id), &raw)); + if (e) return Err(e); + return Ok(adopt_cstring(raw)); +} + +Result Wda::element_displayed(const std::string& element_id, + Option session_id) { + bool out = false; + FfiError e(::wda_client_element_displayed( + handle_.get(), element_id.c_str(), opt_cstr(session_id), &out)); + if (e) return Err(e); + return Ok(out); +} + +Result Wda::element_enabled(const std::string& element_id, + Option session_id) { + bool out = false; + FfiError e(::wda_client_element_enabled( + handle_.get(), element_id.c_str(), opt_cstr(session_id), &out)); + if (e) return Err(e); + return Ok(out); +} + +Result Wda::element_selected(const std::string& element_id, + Option session_id) { + bool out = false; + FfiError e(::wda_client_element_selected( + handle_.get(), element_id.c_str(), opt_cstr(session_id), &out)); + if (e) return Err(e); + return Ok(out); +} + +Result Wda::click(const std::string& element_id, Option session_id) { + FfiError e(::wda_client_click(handle_.get(), element_id.c_str(), opt_cstr(session_id))); + if (e) return Err(e); + return Ok(); +} + +Result Wda::send_keys(const std::string& text, Option session_id) { + FfiError e(::wda_client_send_keys(handle_.get(), text.c_str(), opt_cstr(session_id))); + if (e) return Err(e); + return Ok(); +} + +Result Wda::press_button(const std::string& name, + Option session_id) { + FfiError e(::wda_client_press_button(handle_.get(), name.c_str(), opt_cstr(session_id))); + if (e) return Err(e); + return Ok(); +} + +Result Wda::unlock(Option session_id) { + FfiError e(::wda_client_unlock(handle_.get(), opt_cstr(session_id))); + if (e) return Err(e); + return Ok(); +} + +Result Wda::swipe(int64_t start_x, + int64_t start_y, + int64_t end_x, + int64_t end_y, + double duration, + Option session_id) { + FfiError e(::wda_client_swipe( + handle_.get(), start_x, start_y, end_x, end_y, duration, opt_cstr(session_id))); + if (e) return Err(e); + return Ok(); +} + +Result Wda::tap(Option x, + Option y, + Option element_id, + Option session_id) { + bool has_x = x.is_some(); + bool has_y = y.is_some(); + double xv = has_x ? x.expect("checked") : 0.0; + double yv = has_y ? y.expect("checked") : 0.0; + FfiError e(::wda_client_tap( + handle_.get(), has_x, xv, has_y, yv, opt_cstr(element_id), opt_cstr(session_id))); + if (e) return Err(e); + return Ok(); +} + +Result Wda::double_tap(Option x, + Option y, + Option element_id, + Option session_id) { + bool has_x = x.is_some(); + bool has_y = y.is_some(); + double xv = has_x ? x.expect("checked") : 0.0; + double yv = has_y ? y.expect("checked") : 0.0; + FfiError e(::wda_client_double_tap( + handle_.get(), has_x, xv, has_y, yv, opt_cstr(element_id), opt_cstr(session_id))); + if (e) return Err(e); + return Ok(); +} + +Result Wda::touch_and_hold(double duration, + Option x, + Option y, + Option element_id, + Option session_id) { + bool has_x = x.is_some(); + bool has_y = y.is_some(); + double xv = has_x ? x.expect("checked") : 0.0; + double yv = has_y ? y.expect("checked") : 0.0; + FfiError e(::wda_client_touch_and_hold(handle_.get(), + duration, + has_x, + xv, + has_y, + yv, + opt_cstr(element_id), + opt_cstr(session_id))); + if (e) return Err(e); + return Ok(); +} + +Result Wda::scroll(Option direction, + Option name, + Option predicate_string, + Option to_visible, + Option element_id, + Option session_id) { + bool has_to_visible = to_visible.is_some(); + bool to_visible_v = has_to_visible ? to_visible.expect("checked") : false; + FfiError e(::wda_client_scroll(handle_.get(), + opt_cstr(direction), + opt_cstr(name), + opt_cstr(predicate_string), + has_to_visible, + to_visible_v, + opt_cstr(element_id), + opt_cstr(session_id))); + if (e) return Err(e); + return Ok(); +} + +Result Wda::source(Option session_id) { + char* raw = nullptr; + FfiError e(::wda_client_source(handle_.get(), opt_cstr(session_id), &raw)); + if (e) return Err(e); + return Ok(adopt_cstring(raw)); +} + +Result, FfiError> Wda::screenshot(Option session_id) { + uint8_t* bytes = nullptr; + size_t len = 0; + FfiError e(::wda_client_screenshot(handle_.get(), opt_cstr(session_id), &bytes, &len)); + if (e) return Err(e); + + std::vector out; + if (bytes && len > 0) { + out.assign(bytes, bytes + len); + } + ::idevice_data_free(bytes, len); + return Ok(std::move(out)); +} + +Result Wda::window_size(Option session_id) { + char* raw = nullptr; + FfiError e(::wda_client_window_size(handle_.get(), opt_cstr(session_id), &raw)); + if (e) return Err(e); + return Ok(adopt_cstring(raw)); +} + +Result Wda::viewport_rect(Option session_id) { + char* raw = nullptr; + FfiError e(::wda_client_viewport_rect(handle_.get(), opt_cstr(session_id), &raw)); + if (e) return Err(e); + return Ok(adopt_cstring(raw)); +} + +Result Wda::orientation(Option session_id) { + char* raw = nullptr; + FfiError e(::wda_client_orientation(handle_.get(), opt_cstr(session_id), &raw)); + if (e) return Err(e); + return Ok(adopt_cstring(raw)); +} + +Result Wda::launch_app(const std::string& bundle_id, + const std::vector& arguments, + Option environment_json, + Option session_id) { + std::vector c_args; + c_args.reserve(arguments.size()); + for (const auto& a : arguments) c_args.push_back(a.c_str()); + + char* raw = nullptr; + FfiError e(::wda_client_launch_app(handle_.get(), + bundle_id.c_str(), + c_args.empty() ? nullptr : c_args.data(), + c_args.size(), + opt_cstr(environment_json), + opt_cstr(session_id), + &raw)); + if (e) return Err(e); + return Ok(adopt_cstring(raw)); +} + +Result Wda::activate_app(const std::string& bundle_id, + Option session_id) { + char* raw = nullptr; + FfiError e(::wda_client_activate_app( + handle_.get(), bundle_id.c_str(), opt_cstr(session_id), &raw)); + if (e) return Err(e); + return Ok(adopt_cstring(raw)); +} + +Result Wda::terminate_app(const std::string& bundle_id, + Option session_id) { + bool out = false; + FfiError e(::wda_client_terminate_app( + handle_.get(), bundle_id.c_str(), opt_cstr(session_id), &out)); + if (e) return Err(e); + return Ok(out); +} + +Result Wda::query_app_state(const std::string& bundle_id, + Option session_id) { + int64_t out = 0; + FfiError e(::wda_client_query_app_state( + handle_.get(), bundle_id.c_str(), opt_cstr(session_id), &out)); + if (e) return Err(e); + return Ok(out); +} + +Result Wda::background_app(Option seconds, + Option session_id) { + bool has_seconds = seconds.is_some(); + double seconds_v = has_seconds ? seconds.expect("checked") : 0.0; + char* raw = nullptr; + FfiError e(::wda_client_background_app( + handle_.get(), has_seconds, seconds_v, opt_cstr(session_id), &raw)); + if (e) return Err(e); + return Ok(adopt_cstring(raw)); +} + +Result Wda::is_locked(Option session_id) { + bool out = false; + FfiError e(::wda_client_is_locked(handle_.get(), opt_cstr(session_id), &out)); + if (e) return Err(e); + return Ok(out); +} + +} // namespace IdeviceFFI diff --git a/cpp/src/wda_bridge.cpp b/cpp/src/wda_bridge.cpp new file mode 100644 index 0000000..69e6caa --- /dev/null +++ b/cpp/src/wda_bridge.cpp @@ -0,0 +1,46 @@ +// Jackson Coxson + +#include + +namespace IdeviceFFI { + +Result WdaBridge::start(Provider&& provider) { + WdaBridgeHandle* out = nullptr; + FfiError e(::wda_bridge_start(provider.raw(), &out)); + // Provider is consumed by the FFI regardless of outcome. + provider.release(); + if (e) return Err(e); + return Ok(WdaBridge::adopt(out)); +} + +Result WdaBridge::start_with_ports(Provider&& provider, + uint16_t device_http, + uint16_t device_mjpeg) { + WdaBridgeHandle* out = nullptr; + FfiError e(::wda_bridge_start_with_ports(provider.raw(), device_http, device_mjpeg, &out)); + provider.release(); + if (e) return Err(e); + return Ok(WdaBridge::adopt(out)); +} + +Result WdaBridge::endpoints() const { + WdaBridgeEndpointsC* raw = nullptr; + FfiError e(::wda_bridge_endpoints(handle_.get(), &raw)); + if (e) return Err(e); + + WdaBridgeEndpoints out; + if (raw->udid) { + out.udid = Some(std::string(raw->udid)); + } + if (raw->wda_url) out.wda_url = raw->wda_url; + if (raw->mjpeg_url) out.mjpeg_url = raw->mjpeg_url; + out.local_http = raw->local_http; + out.local_mjpeg = raw->local_mjpeg; + out.device_http = raw->device_http; + out.device_mjpeg = raw->device_mjpeg; + + ::wda_bridge_endpoints_free(raw); + return Ok(std::move(out)); +} + +} // namespace IdeviceFFI diff --git a/ffi/Cargo.toml b/ffi/Cargo.toml index 7edf9c5..bfd69d3 100644 --- a/ffi/Cargo.toml +++ b/ffi/Cargo.toml @@ -16,6 +16,7 @@ libc = "0.2.171" plist = "1.7.1" plist_ffi = { version = "0.1.6" } uuid = { version = "1.12", features = ["v4"], optional = true } +serde_json = { version = "1", optional = true } [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_Networking_WinSock"] } @@ -66,6 +67,7 @@ tunnel_tcp_stack = ["idevice/tunnel_tcp_stack"] tss = ["idevice/tss"] tunneld = ["idevice/tunneld"] usbmuxd = ["idevice/usbmuxd"] +wda = ["idevice/wda", "dep:serde_json"] xpc = ["idevice/xpc"] screenshotr = ["idevice/screenshotr"] full = [ @@ -110,6 +112,7 @@ full = [ "tss", "tunneld", "usbmuxd", + "wda", "xpc", ] default = ["full", "aws-lc"] diff --git a/ffi/src/lib.rs b/ffi/src/lib.rs index 8d4b6a4..008cc9c 100644 --- a/ffi/src/lib.rs +++ b/ffi/src/lib.rs @@ -72,6 +72,10 @@ pub mod tunnel_provider; #[cfg(feature = "usbmuxd")] pub mod usbmuxd; pub mod util; +#[cfg(feature = "wda")] +pub mod wda; +#[cfg(feature = "wda")] +pub mod wda_bridge; pub use errors::*; pub use pairing_file::*; diff --git a/ffi/src/wda.rs b/ffi/src/wda.rs new file mode 100644 index 0000000..a21ef18 --- /dev/null +++ b/ffi/src/wda.rs @@ -0,0 +1,1360 @@ +// Jackson Coxson + +use std::{ + ffi::{CStr, CString, c_char}, + ptr::null_mut, + time::Duration, +}; + +use idevice::{ + IdeviceError, + provider::IdeviceProvider, + services::wda::{DEFAULT_WDA_MJPEG_PORT, DEFAULT_WDA_PORT, WdaClient, WdaPorts}, +}; +use serde_json::{Map, Value}; + +use crate::{IdeviceFfiError, ffi_err, provider::IdeviceProviderHandle, run_sync_local}; + +const DEFAULT_WDA_TIMEOUT: Duration = Duration::from_secs(10); + +/// Opaque handle wrapping the WDA client state. +/// +/// The handle owns the provider so that subsequent calls can open fresh +/// per-request connections without the caller juggling a separate +/// `IdeviceProviderHandle`. +pub struct WdaClientHandle { + provider: Box, + ports: WdaPorts, + timeout: Duration, + session_id: Option, +} + +impl WdaClientHandle { + fn build_client(&self) -> WdaClient<'_> { + WdaClient::new(&*self.provider) + .with_ports(self.ports) + .with_timeout(self.timeout) + } +} + +/// Creates a new WDA client bound to the given provider. +/// +/// # Arguments +/// * [`provider`] - An IdeviceProvider. The provider is consumed and may not +/// be used again, regardless of whether this call succeeds or fails. +/// * [`handle`] - On success, set to a newly allocated WdaClientHandle. +/// +/// # Returns +/// An IdeviceFfiError on error, null on success. +/// +/// # Safety +/// `provider` must be a valid pointer to a handle allocated by this library. +/// The provider is consumed, and may not be used again. +/// `handle` must be a valid, non-null pointer to a location where the handle will be stored. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_new( + provider: *mut IdeviceProviderHandle, + handle: *mut *mut WdaClientHandle, +) -> *mut IdeviceFfiError { + if provider.is_null() || handle.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + + let provider = unsafe { Box::from_raw(provider) }.0; + let boxed = Box::new(WdaClientHandle { + provider, + ports: WdaPorts { + http: DEFAULT_WDA_PORT, + mjpeg: DEFAULT_WDA_MJPEG_PORT, + }, + timeout: DEFAULT_WDA_TIMEOUT, + session_id: None, + }); + unsafe { *handle = Box::into_raw(boxed) }; + null_mut() +} + +/// Frees a WDA client handle. +/// +/// # Safety +/// `handle` must be a valid pointer to a handle allocated by this library or NULL. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_free(handle: *mut WdaClientHandle) { + if !handle.is_null() { + let _ = unsafe { Box::from_raw(handle) }; + } +} + +/// Sets the device-side WDA HTTP and MJPEG ports. +/// +/// # Safety +/// `handle` must be a valid pointer to a handle allocated by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_set_ports( + handle: *mut WdaClientHandle, + http: u16, + mjpeg: u16, +) -> *mut IdeviceFfiError { + if handle.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let h = unsafe { &mut *handle }; + h.ports = WdaPorts { http, mjpeg }; + null_mut() +} + +/// Sets the per-request timeout in milliseconds. +/// +/// # Safety +/// `handle` must be a valid pointer to a handle allocated by this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_set_timeout_ms( + handle: *mut WdaClientHandle, + ms: u64, +) -> *mut IdeviceFfiError { + if handle.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let h = unsafe { &mut *handle }; + h.timeout = Duration::from_millis(ms); + null_mut() +} + +/// Reads the configured device-side WDA ports. +/// +/// # Safety +/// All pointers must be valid and non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_get_ports( + handle: *mut WdaClientHandle, + out_http: *mut u16, + out_mjpeg: *mut u16, +) -> *mut IdeviceFfiError { + if handle.is_null() || out_http.is_null() || out_mjpeg.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let h = unsafe { &*handle }; + unsafe { + *out_http = h.ports.http; + *out_mjpeg = h.ports.mjpeg; + } + null_mut() +} + +/// Returns the currently tracked session id, or NULL if none. +/// +/// # Arguments +/// * [`handle`] - The WDA client handle. +/// * [`out_str`] - On success, set to a heap-allocated UTF-8 string, or NULL +/// if no session is tracked. Free with `idevice_string_free` if non-null. +/// +/// # Safety +/// All pointers must be valid; `out_str` must be non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_session_id( + handle: *mut WdaClientHandle, + out_str: *mut *mut c_char, +) -> *mut IdeviceFfiError { + if handle.is_null() || out_str.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let h = unsafe { &*handle }; + let raw = match &h.session_id { + Some(s) => CString::new(s.as_str()).unwrap_or_default().into_raw(), + None => null_mut(), + }; + unsafe { *out_str = raw }; + null_mut() +} + +/// Fetches `/status` from the WDA HTTP endpoint and returns the JSON response. +/// +/// # Arguments +/// * [`handle`] - The WDA client handle. +/// * [`out_json`] - On success, set to a heap-allocated JSON string. Free with +/// `idevice_string_free`. +/// +/// # Safety +/// All pointers must be valid and non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_status( + handle: *mut WdaClientHandle, + out_json: *mut *mut c_char, +) -> *mut IdeviceFfiError { + if handle.is_null() || out_json.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = run_sync_local(async move { client.status().await }); + write_json(res, out_json) +} + +/// Waits until WDA begins responding on its HTTP endpoint. +/// +/// # Safety +/// All pointers must be valid and non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_wait_until_ready( + handle: *mut WdaClientHandle, + timeout_ms: u64, + out_json: *mut *mut c_char, +) -> *mut IdeviceFfiError { + if handle.is_null() || out_json.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = run_sync_local(async move { + client + .wait_until_ready(Duration::from_millis(timeout_ms)) + .await + }); + write_json(res, out_json) +} + +/// Starts a WDA session and stores the resulting session id on the handle. +/// +/// # Arguments +/// * [`handle`] - The WDA client handle. +/// * [`bundle_id`] - Optional bundle identifier; pass NULL for an anonymous session. +/// * [`out_session_id`] - On success, set to a heap-allocated UTF-8 string. +/// Free with `idevice_string_free`. +/// +/// # Safety +/// `handle` and `out_session_id` must be valid and non-null. `bundle_id` may be NULL. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_start_session( + handle: *mut WdaClientHandle, + bundle_id: *const c_char, + out_session_id: *mut *mut c_char, +) -> *mut IdeviceFfiError { + if handle.is_null() || out_session_id.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let bundle_id = match cstr_to_opt(bundle_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + + let h = unsafe { &mut *handle }; + let mut client = h.build_client(); + let res = run_sync_local(async move { client.start_session(bundle_id.as_deref()).await }); + + match res { + Ok(sid) => { + h.session_id = Some(sid.clone()); + unsafe { *out_session_id = CString::new(sid).unwrap_or_default().into_raw() }; + null_mut() + } + Err(e) => ffi_err!(e), + } +} + +/// Deletes a WDA session. +/// +/// # Safety +/// `handle` and `session_id` must be valid and non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_delete_session( + handle: *mut WdaClientHandle, + session_id: *const c_char, +) -> *mut IdeviceFfiError { + if handle.is_null() || session_id.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let session_id = match cstr_to_string(session_id) { + Ok(s) => s, + Err(e) => return ffi_err!(e), + }; + let h = unsafe { &mut *handle }; + let client = h.build_client(); + let res = run_sync_local(async move { client.delete_session(&session_id).await }); + match res { + Ok(_) => { + h.session_id = None; + null_mut() + } + Err(e) => ffi_err!(e), + } +} + +/// Finds a single element and returns its WDA element id. +/// +/// # Safety +/// `handle`, `using`, `value`, and `out_element_id` must be valid and non-null. +/// `session_id` may be NULL to use the handle's tracked session. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_find_element( + handle: *mut WdaClientHandle, + using: *const c_char, + value: *const c_char, + session_id: *const c_char, + out_element_id: *mut *mut c_char, +) -> *mut IdeviceFfiError { + if handle.is_null() || using.is_null() || value.is_null() || out_element_id.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let using = match cstr_to_string(using) { + Ok(s) => s, + Err(e) => return ffi_err!(e), + }; + let value = match cstr_to_string(value) { + Ok(s) => s, + Err(e) => return ffi_err!(e), + }; + let session_id = match cstr_to_opt(session_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = run_sync_local(async move { + client + .find_element(&using, &value, session_id.as_deref()) + .await + }); + write_string(res, out_element_id) +} + +/// Finds multiple elements and returns their WDA element ids. +/// +/// # Arguments +/// * [`out_array`] - On success, set to a heap-allocated array of NUL-terminated strings. +/// * [`out_count`] - On success, set to the number of strings. +/// +/// Free the array with `wda_client_string_array_free`. +/// +/// # Safety +/// All non-optional pointers must be valid and non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_find_elements( + handle: *mut WdaClientHandle, + using: *const c_char, + value: *const c_char, + session_id: *const c_char, + out_array: *mut *mut *mut c_char, + out_count: *mut usize, +) -> *mut IdeviceFfiError { + if handle.is_null() + || using.is_null() + || value.is_null() + || out_array.is_null() + || out_count.is_null() + { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let using = match cstr_to_string(using) { + Ok(s) => s, + Err(e) => return ffi_err!(e), + }; + let value = match cstr_to_string(value) { + Ok(s) => s, + Err(e) => return ffi_err!(e), + }; + let session_id = match cstr_to_opt(session_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = run_sync_local(async move { + client + .find_elements(&using, &value, session_id.as_deref()) + .await + }); + + match res { + Ok(elements) => { + let mut raw: Box<[*mut c_char]> = elements + .into_iter() + .map(|s| CString::new(s).unwrap_or_default().into_raw()) + .collect::>() + .into_boxed_slice(); + unsafe { + *out_array = raw.as_mut_ptr(); + *out_count = raw.len(); + } + std::mem::forget(raw); + null_mut() + } + Err(e) => ffi_err!(e), + } +} + +/// Frees an array of strings allocated by `wda_client_find_elements`. +/// +/// # Safety +/// `arr` must be a pointer returned by `wda_client_find_elements` with the +/// matching `count`, or NULL. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_string_array_free(arr: *mut *mut c_char, count: usize) { + if arr.is_null() { + return; + } + let slice = unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(arr, count)) }; + for &p in slice.iter() { + if !p.is_null() { + let _ = unsafe { CString::from_raw(p) }; + } + } +} + +/// Returns a raw attribute value as a JSON-encoded string. +/// +/// # Safety +/// All non-optional pointers must be valid and non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_element_attribute( + handle: *mut WdaClientHandle, + element_id: *const c_char, + name: *const c_char, + session_id: *const c_char, + out_json: *mut *mut c_char, +) -> *mut IdeviceFfiError { + if handle.is_null() || element_id.is_null() || name.is_null() || out_json.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let element_id = match cstr_to_string(element_id) { + Ok(s) => s, + Err(e) => return ffi_err!(e), + }; + let name = match cstr_to_string(name) { + Ok(s) => s, + Err(e) => return ffi_err!(e), + }; + let session_id = match cstr_to_opt(session_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = run_sync_local(async move { + client + .element_attribute(&element_id, &name, session_id.as_deref()) + .await + }); + write_json(res, out_json) +} + +/// Returns the element text-like value as a string. +/// +/// # Safety +/// All non-optional pointers must be valid and non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_element_text( + handle: *mut WdaClientHandle, + element_id: *const c_char, + session_id: *const c_char, + out_str: *mut *mut c_char, +) -> *mut IdeviceFfiError { + if handle.is_null() || element_id.is_null() || out_str.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let element_id = match cstr_to_string(element_id) { + Ok(s) => s, + Err(e) => return ffi_err!(e), + }; + let session_id = match cstr_to_opt(session_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = run_sync_local(async move { + client + .element_text(&element_id, session_id.as_deref()) + .await + }); + write_string(res, out_str) +} + +/// Returns the element bounds rectangle as a JSON-encoded string. +/// +/// # Safety +/// All non-optional pointers must be valid and non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_element_rect( + handle: *mut WdaClientHandle, + element_id: *const c_char, + session_id: *const c_char, + out_json: *mut *mut c_char, +) -> *mut IdeviceFfiError { + if handle.is_null() || element_id.is_null() || out_json.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let element_id = match cstr_to_string(element_id) { + Ok(s) => s, + Err(e) => return ffi_err!(e), + }; + let session_id = match cstr_to_opt(session_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = run_sync_local(async move { + client + .element_rect(&element_id, session_id.as_deref()) + .await + }); + write_json(res, out_json) +} + +/// Returns whether an element is displayed. +/// +/// # Safety +/// All non-optional pointers must be valid and non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_element_displayed( + handle: *mut WdaClientHandle, + element_id: *const c_char, + session_id: *const c_char, + out_bool: *mut bool, +) -> *mut IdeviceFfiError { + if handle.is_null() || element_id.is_null() || out_bool.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let element_id = match cstr_to_string(element_id) { + Ok(s) => s, + Err(e) => return ffi_err!(e), + }; + let session_id = match cstr_to_opt(session_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = run_sync_local(async move { + client + .element_displayed(&element_id, session_id.as_deref()) + .await + }); + write_bool(res, out_bool) +} + +/// Returns whether an element is enabled. +/// +/// # Safety +/// All non-optional pointers must be valid and non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_element_enabled( + handle: *mut WdaClientHandle, + element_id: *const c_char, + session_id: *const c_char, + out_bool: *mut bool, +) -> *mut IdeviceFfiError { + if handle.is_null() || element_id.is_null() || out_bool.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let element_id = match cstr_to_string(element_id) { + Ok(s) => s, + Err(e) => return ffi_err!(e), + }; + let session_id = match cstr_to_opt(session_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = run_sync_local(async move { + client + .element_enabled(&element_id, session_id.as_deref()) + .await + }); + write_bool(res, out_bool) +} + +/// Returns whether an element is selected. +/// +/// # Safety +/// All non-optional pointers must be valid and non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_element_selected( + handle: *mut WdaClientHandle, + element_id: *const c_char, + session_id: *const c_char, + out_bool: *mut bool, +) -> *mut IdeviceFfiError { + if handle.is_null() || element_id.is_null() || out_bool.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let element_id = match cstr_to_string(element_id) { + Ok(s) => s, + Err(e) => return ffi_err!(e), + }; + let session_id = match cstr_to_opt(session_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = run_sync_local(async move { + client + .element_selected(&element_id, session_id.as_deref()) + .await + }); + write_bool(res, out_bool) +} + +/// Clicks an element by its WDA element id. +/// +/// # Safety +/// `handle` and `element_id` must be valid and non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_click( + handle: *mut WdaClientHandle, + element_id: *const c_char, + session_id: *const c_char, +) -> *mut IdeviceFfiError { + if handle.is_null() || element_id.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let element_id = match cstr_to_string(element_id) { + Ok(s) => s, + Err(e) => return ffi_err!(e), + }; + let session_id = match cstr_to_opt(session_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = run_sync_local(async move { client.click(&element_id, session_id.as_deref()).await }); + void_result(res) +} + +/// Sends text input to the currently focused element. +/// +/// # Safety +/// `handle` and `text` must be valid and non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_send_keys( + handle: *mut WdaClientHandle, + text: *const c_char, + session_id: *const c_char, +) -> *mut IdeviceFfiError { + if handle.is_null() || text.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let text = match cstr_to_string(text) { + Ok(s) => s, + Err(e) => return ffi_err!(e), + }; + let session_id = match cstr_to_opt(session_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = run_sync_local(async move { client.send_keys(&text, session_id.as_deref()).await }); + void_result(res) +} + +/// Presses a hardware button through WDA. +/// +/// # Safety +/// `handle` and `name` must be valid and non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_press_button( + handle: *mut WdaClientHandle, + name: *const c_char, + session_id: *const c_char, +) -> *mut IdeviceFfiError { + if handle.is_null() || name.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let name = match cstr_to_string(name) { + Ok(s) => s, + Err(e) => return ffi_err!(e), + }; + let session_id = match cstr_to_opt(session_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = + run_sync_local(async move { client.press_button(&name, session_id.as_deref()).await }); + void_result(res) +} + +/// Unlocks the device via WDA. +/// +/// # Safety +/// `handle` must be valid and non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_unlock( + handle: *mut WdaClientHandle, + session_id: *const c_char, +) -> *mut IdeviceFfiError { + if handle.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let session_id = match cstr_to_opt(session_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = run_sync_local(async move { client.unlock(session_id.as_deref()).await }); + void_result(res) +} + +/// Swipes from one coordinate to another. +/// +/// # Safety +/// `handle` must be valid and non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_swipe( + handle: *mut WdaClientHandle, + start_x: i64, + start_y: i64, + end_x: i64, + end_y: i64, + duration: f64, + session_id: *const c_char, +) -> *mut IdeviceFfiError { + if handle.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let session_id = match cstr_to_opt(session_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = run_sync_local(async move { + client + .swipe( + start_x, + start_y, + end_x, + end_y, + duration, + session_id.as_deref(), + ) + .await + }); + void_result(res) +} + +/// Performs a tap gesture. +/// +/// `Option` arguments are encoded as `(has, value)` pairs. When `has_*` +/// is false the underlying value is ignored. +/// +/// # Safety +/// `handle` must be valid and non-null. Optional pointers may be NULL. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_tap( + handle: *mut WdaClientHandle, + has_x: bool, + x: f64, + has_y: bool, + y: f64, + element_id: *const c_char, + session_id: *const c_char, +) -> *mut IdeviceFfiError { + if handle.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let element_id = match cstr_to_opt(element_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + let session_id = match cstr_to_opt(session_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + let x = if has_x { Some(x) } else { None }; + let y = if has_y { Some(y) } else { None }; + + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = run_sync_local(async move { + client + .tap(x, y, element_id.as_deref(), session_id.as_deref()) + .await + }); + void_result(res) +} + +/// Performs a double-tap gesture. +/// +/// # Safety +/// `handle` must be valid and non-null. Optional pointers may be NULL. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_double_tap( + handle: *mut WdaClientHandle, + has_x: bool, + x: f64, + has_y: bool, + y: f64, + element_id: *const c_char, + session_id: *const c_char, +) -> *mut IdeviceFfiError { + if handle.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let element_id = match cstr_to_opt(element_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + let session_id = match cstr_to_opt(session_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + let x = if has_x { Some(x) } else { None }; + let y = if has_y { Some(y) } else { None }; + + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = run_sync_local(async move { + client + .double_tap(x, y, element_id.as_deref(), session_id.as_deref()) + .await + }); + void_result(res) +} + +/// Performs a long-press gesture. +/// +/// # Safety +/// `handle` must be valid and non-null. Optional pointers may be NULL. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_touch_and_hold( + handle: *mut WdaClientHandle, + duration: f64, + has_x: bool, + x: f64, + has_y: bool, + y: f64, + element_id: *const c_char, + session_id: *const c_char, +) -> *mut IdeviceFfiError { + if handle.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let element_id = match cstr_to_opt(element_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + let session_id = match cstr_to_opt(session_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + let x = if has_x { Some(x) } else { None }; + let y = if has_y { Some(y) } else { None }; + + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = run_sync_local(async move { + client + .touch_and_hold(duration, x, y, element_id.as_deref(), session_id.as_deref()) + .await + }); + void_result(res) +} + +/// Scrolls the current view or an element using a WDA mobile command. +/// +/// `Option` arguments are encoded as `(has, value)`. +/// +/// # Safety +/// `handle` must be valid and non-null. Optional string arguments may be NULL. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_scroll( + handle: *mut WdaClientHandle, + direction: *const c_char, + name: *const c_char, + predicate_string: *const c_char, + has_to_visible: bool, + to_visible: bool, + element_id: *const c_char, + session_id: *const c_char, +) -> *mut IdeviceFfiError { + if handle.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let direction = match cstr_to_opt(direction) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + let name = match cstr_to_opt(name) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + let predicate_string = match cstr_to_opt(predicate_string) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + let element_id = match cstr_to_opt(element_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + let session_id = match cstr_to_opt(session_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + let to_visible = if has_to_visible { + Some(to_visible) + } else { + None + }; + + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = run_sync_local(async move { + client + .scroll( + direction.as_deref(), + name.as_deref(), + predicate_string.as_deref(), + to_visible, + element_id.as_deref(), + session_id.as_deref(), + ) + .await + }); + void_result(res) +} + +/// Returns the current UI source tree as XML. +/// +/// # Safety +/// `handle` and `out_str` must be valid and non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_source( + handle: *mut WdaClientHandle, + session_id: *const c_char, + out_str: *mut *mut c_char, +) -> *mut IdeviceFfiError { + if handle.is_null() || out_str.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let session_id = match cstr_to_opt(session_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = run_sync_local(async move { client.source(session_id.as_deref()).await }); + write_string(res, out_str) +} + +/// Returns a PNG screenshot as raw bytes. +/// +/// # Arguments +/// * [`out_bytes`] - On success, set to a heap-allocated PNG buffer. +/// * [`out_len`] - On success, set to the buffer length in bytes. +/// +/// Free the buffer with `idevice_data_free`. +/// +/// # Safety +/// All non-optional pointers must be valid and non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_screenshot( + handle: *mut WdaClientHandle, + session_id: *const c_char, + out_bytes: *mut *mut u8, + out_len: *mut usize, +) -> *mut IdeviceFfiError { + if handle.is_null() || out_bytes.is_null() || out_len.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let session_id = match cstr_to_opt(session_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = run_sync_local(async move { client.screenshot(session_id.as_deref()).await }); + + match res { + Ok(bytes) => { + let mut boxed = bytes.into_boxed_slice(); + unsafe { + *out_bytes = boxed.as_mut_ptr(); + *out_len = boxed.len(); + } + std::mem::forget(boxed); + null_mut() + } + Err(e) => ffi_err!(e), + } +} + +/// Returns the current window size payload from WDA. +/// +/// # Safety +/// All non-optional pointers must be valid and non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_window_size( + handle: *mut WdaClientHandle, + session_id: *const c_char, + out_json: *mut *mut c_char, +) -> *mut IdeviceFfiError { + if handle.is_null() || out_json.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let session_id = match cstr_to_opt(session_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = run_sync_local(async move { client.window_size(session_id.as_deref()).await }); + write_json(res, out_json) +} + +/// Returns the current viewport rectangle. +/// +/// # Safety +/// All non-optional pointers must be valid and non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_viewport_rect( + handle: *mut WdaClientHandle, + session_id: *const c_char, + out_json: *mut *mut c_char, +) -> *mut IdeviceFfiError { + if handle.is_null() || out_json.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let session_id = match cstr_to_opt(session_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = run_sync_local(async move { client.viewport_rect(session_id.as_deref()).await }); + write_json(res, out_json) +} + +/// Returns the current orientation as a string. +/// +/// # Safety +/// All non-optional pointers must be valid and non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_orientation( + handle: *mut WdaClientHandle, + session_id: *const c_char, + out_str: *mut *mut c_char, +) -> *mut IdeviceFfiError { + if handle.is_null() || out_str.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let session_id = match cstr_to_opt(session_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = run_sync_local(async move { client.orientation(session_id.as_deref()).await }); + write_string(res, out_str) +} + +/// Launches or activates an application via WDA. +/// +/// # Arguments +/// * [`bundle_id`] - The bundle identifier of the app to launch. +/// * [`arguments`] - Optional array of argument strings; pass NULL for none. +/// * [`arguments_count`] - Number of strings in `arguments`; ignored if NULL. +/// * [`environment_json`] - Optional JSON object string of environment variables; pass NULL for none. +/// +/// # Safety +/// `handle`, `bundle_id`, and `out_json` must be valid and non-null. Optional +/// pointers may be NULL. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_launch_app( + handle: *mut WdaClientHandle, + bundle_id: *const c_char, + arguments: *const *const c_char, + arguments_count: usize, + environment_json: *const c_char, + session_id: *const c_char, + out_json: *mut *mut c_char, +) -> *mut IdeviceFfiError { + if handle.is_null() || bundle_id.is_null() || out_json.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let bundle_id = match cstr_to_string(bundle_id) { + Ok(s) => s, + Err(e) => return ffi_err!(e), + }; + let session_id = match cstr_to_opt(session_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + + let args_vec: Option> = if arguments.is_null() { + None + } else { + let slice = unsafe { std::slice::from_raw_parts(arguments, arguments_count) }; + let mut v = Vec::with_capacity(slice.len()); + for &p in slice { + if p.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + match unsafe { CStr::from_ptr(p) }.to_str() { + Ok(s) => v.push(s.to_owned()), + Err(_) => return ffi_err!(IdeviceError::FfiInvalidString), + } + } + Some(v) + }; + + let env_map: Option> = match cstr_to_opt(environment_json) { + Ok(Some(s)) => match serde_json::from_str::(&s) { + Ok(Value::Object(map)) => Some(map), + _ => return ffi_err!(IdeviceError::FfiInvalidArg), + }, + Ok(None) => None, + Err(e) => return ffi_err!(e), + }; + + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = run_sync_local(async move { + client + .launch_app( + &bundle_id, + args_vec.as_deref(), + env_map.as_ref(), + session_id.as_deref(), + ) + .await + }); + write_json(res, out_json) +} + +/// Activates an already running application. +/// +/// # Safety +/// `handle`, `bundle_id`, and `out_json` must be valid and non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_activate_app( + handle: *mut WdaClientHandle, + bundle_id: *const c_char, + session_id: *const c_char, + out_json: *mut *mut c_char, +) -> *mut IdeviceFfiError { + if handle.is_null() || bundle_id.is_null() || out_json.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let bundle_id = match cstr_to_string(bundle_id) { + Ok(s) => s, + Err(e) => return ffi_err!(e), + }; + let session_id = match cstr_to_opt(session_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = + run_sync_local(async move { client.activate_app(&bundle_id, session_id.as_deref()).await }); + write_json(res, out_json) +} + +/// Terminates an application and returns whether termination succeeded. +/// +/// # Safety +/// `handle`, `bundle_id`, and `out_bool` must be valid and non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_terminate_app( + handle: *mut WdaClientHandle, + bundle_id: *const c_char, + session_id: *const c_char, + out_bool: *mut bool, +) -> *mut IdeviceFfiError { + if handle.is_null() || bundle_id.is_null() || out_bool.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let bundle_id = match cstr_to_string(bundle_id) { + Ok(s) => s, + Err(e) => return ffi_err!(e), + }; + let session_id = match cstr_to_opt(session_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = run_sync_local(async move { + client + .terminate_app(&bundle_id, session_id.as_deref()) + .await + }); + write_bool(res, out_bool) +} + +/// Queries the XCTest application state for the given bundle id. +/// +/// # Safety +/// `handle`, `bundle_id`, and `out_state` must be valid and non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_query_app_state( + handle: *mut WdaClientHandle, + bundle_id: *const c_char, + session_id: *const c_char, + out_state: *mut i64, +) -> *mut IdeviceFfiError { + if handle.is_null() || bundle_id.is_null() || out_state.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let bundle_id = match cstr_to_string(bundle_id) { + Ok(s) => s, + Err(e) => return ffi_err!(e), + }; + let session_id = match cstr_to_opt(session_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = run_sync_local(async move { + client + .query_app_state(&bundle_id, session_id.as_deref()) + .await + }); + match res { + Ok(v) => { + unsafe { *out_state = v }; + null_mut() + } + Err(e) => ffi_err!(e), + } +} + +/// Backgrounds the current app for the given number of seconds. +/// +/// `Option` is encoded as `(has_seconds, seconds)`. +/// +/// # Safety +/// `handle` and `out_json` must be valid and non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_background_app( + handle: *mut WdaClientHandle, + has_seconds: bool, + seconds: f64, + session_id: *const c_char, + out_json: *mut *mut c_char, +) -> *mut IdeviceFfiError { + if handle.is_null() || out_json.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let session_id = match cstr_to_opt(session_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + let seconds = if has_seconds { Some(seconds) } else { None }; + + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = + run_sync_local(async move { client.background_app(seconds, session_id.as_deref()).await }); + write_json(res, out_json) +} + +/// Returns whether the device is currently locked. +/// +/// # Safety +/// `handle` and `out_bool` must be valid and non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_client_is_locked( + handle: *mut WdaClientHandle, + session_id: *const c_char, + out_bool: *mut bool, +) -> *mut IdeviceFfiError { + if handle.is_null() || out_bool.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let session_id = match cstr_to_opt(session_id) { + Ok(o) => o, + Err(e) => return ffi_err!(e), + }; + let h = unsafe { &*handle }; + let client = h.build_client(); + let res = run_sync_local(async move { client.is_locked(session_id.as_deref()).await }); + write_bool(res, out_bool) +} + +// --- helpers (private) --------------------------------------------------- + +fn cstr_to_string(p: *const c_char) -> Result { + if p.is_null() { + return Err(IdeviceError::FfiInvalidArg); + } + unsafe { CStr::from_ptr(p) } + .to_str() + .map(ToOwned::to_owned) + .map_err(|_| IdeviceError::FfiInvalidString) +} + +fn cstr_to_opt(p: *const c_char) -> Result, IdeviceError> { + if p.is_null() { + Ok(None) + } else { + cstr_to_string(p).map(Some) + } +} + +fn write_string(res: Result, out: *mut *mut c_char) -> *mut IdeviceFfiError { + match res { + Ok(s) => { + unsafe { *out = CString::new(s).unwrap_or_default().into_raw() }; + null_mut() + } + Err(e) => ffi_err!(e), + } +} + +fn write_json(res: Result, out: *mut *mut c_char) -> *mut IdeviceFfiError { + match res { + Ok(v) => match serde_json::to_string(&v) { + Ok(s) => { + unsafe { *out = CString::new(s).unwrap_or_default().into_raw() }; + null_mut() + } + Err(_) => ffi_err!(IdeviceError::UnexpectedResponse( + "failed to serialize WDA response".into() + )), + }, + Err(e) => ffi_err!(e), + } +} + +fn write_bool(res: Result, out: *mut bool) -> *mut IdeviceFfiError { + match res { + Ok(b) => { + unsafe { *out = b }; + null_mut() + } + Err(e) => ffi_err!(e), + } +} + +fn void_result(res: Result<(), IdeviceError>) -> *mut IdeviceFfiError { + match res { + Ok(_) => null_mut(), + Err(e) => ffi_err!(e), + } +} diff --git a/ffi/src/wda_bridge.rs b/ffi/src/wda_bridge.rs new file mode 100644 index 0000000..f4b2575 --- /dev/null +++ b/ffi/src/wda_bridge.rs @@ -0,0 +1,180 @@ +// Jackson Coxson + +use std::{ + ffi::{CString, c_char}, + ptr::null_mut, + sync::Arc, +}; + +use idevice::{ + provider::IdeviceProvider, + services::{ + wda::WdaPorts, + wda_bridge::{WdaBridge, WdaBridgeEndpoints}, + }, +}; + +use crate::{IdeviceFfiError, ffi_err, provider::IdeviceProviderHandle, run_sync}; + +/// Opaque handle wrapping a [`WdaBridge`]. +pub struct WdaBridgeHandle(pub WdaBridge); + +/// Localhost endpoints exposed by a running WDA bridge. +/// +/// Pointers in this struct are heap-allocated and must be released with +/// `wda_bridge_endpoints_free`. +#[repr(C)] +pub struct WdaBridgeEndpointsC { + pub udid: *mut c_char, + pub wda_url: *mut c_char, + pub mjpeg_url: *mut c_char, + pub local_http: u16, + pub local_mjpeg: u16, + pub device_http: u16, + pub device_mjpeg: u16, +} + +/// Starts a localhost bridge to the device's default WDA ports. +/// +/// # Arguments +/// * [`provider`] - An IdeviceProvider. Provider ownership is transferred — +/// the caller must not free or reuse the IdeviceProviderHandle on success or failure. +/// * [`handle`] - On success, set to a newly allocated WdaBridgeHandle. +/// +/// # Returns +/// An IdeviceFfiError on error, null on success. +/// +/// # Safety +/// `provider` must be a valid pointer to a handle allocated by this library. +/// The provider is consumed, and may not be used again. +/// `handle` must be a valid, non-null pointer to a location where the handle will be stored. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_bridge_start( + provider: *mut IdeviceProviderHandle, + handle: *mut *mut WdaBridgeHandle, +) -> *mut IdeviceFfiError { + if provider.is_null() || handle.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let provider_box = unsafe { Box::from_raw(provider) }.0; + let arc: Arc = Arc::from(provider_box); + let res = run_sync(async move { WdaBridge::start(arc).await }); + match res { + Ok(bridge) => { + let boxed = Box::new(WdaBridgeHandle(bridge)); + unsafe { *handle = Box::into_raw(boxed) }; + null_mut() + } + Err(e) => ffi_err!(e), + } +} + +/// Starts a localhost bridge to custom device-side WDA ports. +/// +/// # Safety +/// Same requirements as [`wda_bridge_start`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_bridge_start_with_ports( + provider: *mut IdeviceProviderHandle, + device_http: u16, + device_mjpeg: u16, + handle: *mut *mut WdaBridgeHandle, +) -> *mut IdeviceFfiError { + if provider.is_null() || handle.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let provider_box = unsafe { Box::from_raw(provider) }.0; + let arc: Arc = Arc::from(provider_box); + let ports = WdaPorts { + http: device_http, + mjpeg: device_mjpeg, + }; + let res = run_sync(async move { WdaBridge::start_with_ports(arc, ports).await }); + match res { + Ok(bridge) => { + let boxed = Box::new(WdaBridgeHandle(bridge)); + unsafe { *handle = Box::into_raw(boxed) }; + null_mut() + } + Err(e) => ffi_err!(e), + } +} + +/// Reads the endpoints assigned to the running bridge. +/// +/// # Arguments +/// * [`handle`] - The bridge handle. +/// * [`out_endpoints`] - On success, set to a heap-allocated WdaBridgeEndpointsC. +/// Free with `wda_bridge_endpoints_free`. +/// +/// # Returns +/// An IdeviceFfiError on error, null on success. +/// +/// # Safety +/// All pointers must be valid and non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_bridge_endpoints( + handle: *mut WdaBridgeHandle, + out_endpoints: *mut *mut WdaBridgeEndpointsC, +) -> *mut IdeviceFfiError { + if handle.is_null() || out_endpoints.is_null() { + return ffi_err!(IdeviceError::FfiInvalidArg); + } + let bridge = unsafe { &(*handle).0 }; + let endpoints = bridge.endpoints(); + let c_endpoints = endpoints_to_c(endpoints); + unsafe { *out_endpoints = Box::into_raw(Box::new(c_endpoints)) }; + null_mut() +} + +/// Frees a WdaBridgeEndpointsC struct and its heap-allocated string fields. +/// +/// # Safety +/// `endpoints` must be a pointer returned by `wda_bridge_endpoints` or NULL. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_bridge_endpoints_free(endpoints: *mut WdaBridgeEndpointsC) { + if endpoints.is_null() { + return; + } + let e = unsafe { Box::from_raw(endpoints) }; + if !e.udid.is_null() { + let _ = unsafe { CString::from_raw(e.udid) }; + } + if !e.wda_url.is_null() { + let _ = unsafe { CString::from_raw(e.wda_url) }; + } + if !e.mjpeg_url.is_null() { + let _ = unsafe { CString::from_raw(e.mjpeg_url) }; + } +} + +/// Frees a WDA bridge handle. Dropping aborts the underlying forwarder tasks. +/// +/// # Safety +/// `handle` must be a pointer returned by this library or NULL. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wda_bridge_free(handle: *mut WdaBridgeHandle) { + if !handle.is_null() { + let _ = unsafe { Box::from_raw(handle) }; + } +} + +fn endpoints_to_c(e: &WdaBridgeEndpoints) -> WdaBridgeEndpointsC { + let udid = match &e.udid { + Some(s) => CString::new(s.as_str()).unwrap_or_default().into_raw(), + None => null_mut(), + }; + WdaBridgeEndpointsC { + udid, + wda_url: CString::new(e.wda_url.as_str()) + .unwrap_or_default() + .into_raw(), + mjpeg_url: CString::new(e.mjpeg_url.as_str()) + .unwrap_or_default() + .into_raw(), + local_http: e.local_ports.http, + local_mjpeg: e.local_ports.mjpeg, + device_http: e.device_ports.http, + device_mjpeg: e.device_ports.mjpeg, + } +}