From e45dfebe69fb34cb718c797633a49092dda94002 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Fri, 18 Sep 2026 15:57:23 -0300 Subject: [PATCH 1/7] CI: run listbox_demo on the iOS simulator through the driver (#22) Phase 1e2 on the macOS leg: `ae build --target=-ios-simulator --emit=staticlib` compiles the Aether runtime for the simulator (tests/ios/runtime_seed.ae), aetherc emits listbox_demo's portable C, clang links app + UIKit backend + driver against that archive and the frameworks, the binary goes into a bundle (tests/ios/Info.plist), the bundle into a booted iPhone simulator, and tests/listbox_demo's spec runs against the app over the driver port, unchanged from the desktop backends. This is the acceptance #22 set out and could not meet while the toolchain half (aether #1385) was open: an aether-ui app linked against the UIKit backend and an iOS build of libaether, running on iOS, widgets rendered, taps firing their closures. --- ci.sh | 147 ++++++++++++++++++++++++++++++++++++++ tests/ios/Info.plist | 44 ++++++++++++ tests/ios/runtime_seed.ae | 8 +++ 3 files changed, 199 insertions(+) create mode 100644 tests/ios/Info.plist create mode 100644 tests/ios/runtime_seed.ae diff --git a/ci.sh b/ci.sh index 13708aa7..da089cba 100755 --- a/ci.sh +++ b/ci.sh @@ -738,6 +738,153 @@ else echo " SKIP no iOS SDK (install Xcode + the iOS platform to enable)" fi +echo +echo "=== Phase 1e2: iOS simulator run (listbox_demo through the driver) ===" +# Compiling, linking and a Catalyst render prove the backend builds and +# paints; they say nothing about an app RUNNING on iOS. `ae build +# --target=-ios-simulator --emit=staticlib` (aether 0.6xx, the +# prerequisite #22 named) builds the Aether runtime for the simulator, so an +# example can now be linked for real -- its portable C from aetherc, the +# UIKit backend, the driver -- wrapped in a bundle (tests/ios/Info.plist), +# installed in a booted simulator and driven over the same AetherUIDriver +# port every other backend answers: tests/listbox_demo's spec runs against +# it unchanged. Rows rendered by UIKit, a tap selecting one through the +# DSL's closure, selection surviving an update: that is #22's acceptance, +# on every push. +# +# The simulator shares the host's loopback, so the spec talks to 127.0.0.1 +# as usual. Environment reaches the app through SIMCTL_CHILD_*. +IOS_SIM_UDID="" +ios_sim_pick() { + # An available iPhone, booted if one already is (a stale boot is fine to + # reuse), else the first listed. Prints the UDID. + xcrun simctl list devices available -j 2>/dev/null | jq -r ' + [.devices | to_entries[] + | select(.key | contains("iOS")) + | .value[] | select(.name | startswith("iPhone"))] + | (map(select(.state == "Booted")) + .) | .[0].udid // empty' +} +ios_sim_cleanup() { + [ -n "$IOS_SIM_UDID" ] || return 0 + xcrun simctl terminate "$IOS_SIM_UDID" dev.aether.ui.simprobe > /dev/null 2>&1 || true + xcrun simctl uninstall "$IOS_SIM_UDID" dev.aether.ui.simprobe > /dev/null 2>&1 || true + xcrun simctl shutdown "$IOS_SIM_UDID" > /dev/null 2>&1 || true +} +if [ "$PLATFORM" != "macos" ] || [ -z "$IOS_SDK" ] || [ ! -d "$IOS_SDK" ]; then + echo " SKIP needs a Mac with the iOS simulator SDK" +elif [ "$ios_fail" -ne 0 ]; then + echo " SKIP Phase 1e failed" +elif ! ae build --help 2>&1 | grep -q "ios-simulator"; then + echo " SKIP this ae has no iOS target (aether #1385)" +else + sim_fail=0 + SIM_DIR="$ROOT/build/ios" + SIM_APP="$SIM_DIR/AetherUIProbe.app" + rm -rf "$SIM_DIR"; mkdir -p "$SIM_APP" + case "$(uname -m)" in + arm64) SIM_AE_TARGET="aarch64-ios-simulator"; SIM_CLANG_TGT="arm64-apple-ios17.0-simulator" ;; + *) SIM_AE_TARGET="x86_64-ios-simulator"; SIM_CLANG_TGT="x86_64-apple-ios17.0-simulator" ;; + esac + # 1. The runtime, compiled for the simulator (see tests/ios/runtime_seed.ae). + if ! AETHER_IOS_MIN=17.0 ae build --target="$SIM_AE_TARGET" --emit=staticlib \ + "$ROOT/tests/ios/runtime_seed.ae" -o "$SIM_DIR/libaether_sim.a" \ + > /tmp/ci_ios_sim_runtime.log 2>&1; then + echo " FAIL runtime archive for $SIM_AE_TARGET" + tail -15 /tmp/ci_ios_sim_runtime.log | sed 's/^/ /' + sim_fail=1 + else + echo " OK runtime archive for $SIM_AE_TARGET" + fi + # 2. The example's portable C, exactly as build.sh emits it for the host. + if [ "$sim_fail" -eq 0 ] && ! aetherc "$ROOT/examples/listbox_demo/listbox_demo.ae" \ + "$SIM_DIR/listbox_demo.c" > /tmp/ci_ios_sim_aetherc.log 2>&1; then + echo " FAIL aetherc listbox_demo" + tail -15 /tmp/ci_ios_sim_aetherc.log | sed 's/^/ /' + sim_fail=1 + fi + # 3. Link it for the simulator: the app, the UIKit backend, the driver, the + # runtime archive, the frameworks. The same clang line as Phase 1e's + # link check, with the real runtime where tests/ios/link_stub.c stood. + if [ "$sim_fail" -eq 0 ]; then + if "$IOS_CLANG" -fobjc-arc -target "$SIM_CLANG_TGT" -isysroot "$IOS_SDK" \ + $(ae cflags | tr ' ' '\n' | grep -E '^-I' | tr '\n' ' ') -Ibackend \ + "$SIM_DIR/listbox_demo.c" \ + "$ROOT/backend/aether_ui_uikit.m" \ + "$ROOT/backend/aether_ui_test_server.c" \ + "$ROOT/backend/aether_ui_system_extras.c" \ + "$SIM_DIR/libaether_sim.a" \ + -framework UIKit -framework Foundation -framework QuartzCore \ + -framework CoreGraphics -framework CoreText -framework ImageIO \ + -framework UserNotifications -lpthread -lm \ + -o "$SIM_APP/AetherUIProbe" > /tmp/ci_ios_sim_link.log 2>&1; then + echo " OK listbox_demo links against the simulator runtime" + else + echo " FAIL link listbox_demo for the simulator" + grep -iE "undefined|error:" /tmp/ci_ios_sim_link.log | head -15 | sed 's/^/ /' + sim_fail=1 + fi + fi + # 4. The bundle, and a simulator to put it in. + if [ "$sim_fail" -eq 0 ]; then + cp "$ROOT/tests/ios/Info.plist" "$SIM_APP/Info.plist" + IOS_SIM_UDID="$(ios_sim_pick)" + if [ -z "$IOS_SIM_UDID" ]; then + echo " FAIL no available iPhone simulator on this host" + xcrun simctl list devices available 2>&1 | head -20 | sed 's/^/ /' + sim_fail=1 + fi + fi + if [ "$sim_fail" -eq 0 ]; then + xcrun simctl boot "$IOS_SIM_UDID" > /tmp/ci_ios_sim_boot.log 2>&1 || true + if ! xcrun simctl bootstatus "$IOS_SIM_UDID" -b >> /tmp/ci_ios_sim_boot.log 2>&1; then + echo " FAIL simulator $IOS_SIM_UDID did not boot" + tail -10 /tmp/ci_ios_sim_boot.log | sed 's/^/ /' + sim_fail=1 + elif ! xcrun simctl install "$IOS_SIM_UDID" "$SIM_APP" > /tmp/ci_ios_sim_install.log 2>&1; then + echo " FAIL install into the simulator" + tail -10 /tmp/ci_ios_sim_install.log | sed 's/^/ /' + sim_fail=1 + else + echo " OK booted $IOS_SIM_UDID and installed the bundle" + fi + fi + # 5. Launch with the driver armed, then run the listbox spec against it as + # Phase 5f does on the desktop. + if [ "$sim_fail" -eq 0 ]; then + if curl -sf -o /dev/null "http://127.0.0.1:$PORT/widgets" 2>/dev/null; then + echo " FAIL port $PORT already answering (stray app?)" + sim_fail=1 + elif ! SIMCTL_CHILD_AETHER_UI_TEST_PORT="$PORT" \ + SIMCTL_CHILD_AETHER_UI_NO_ANIMATION=1 \ + xcrun simctl launch --stdout=/tmp/ci_ios_sim.app.log --stderr=/tmp/ci_ios_sim.app.err \ + "$IOS_SIM_UDID" dev.aether.ui.simprobe > /tmp/ci_ios_sim_launch.log 2>&1; then + echo " FAIL launch" + tail -10 /tmp/ci_ios_sim_launch.log | sed 's/^/ /' + sim_fail=1 + else + up=0 + for _ in $(seq 1 100); do + if curl -sf -o /dev/null "http://127.0.0.1:$PORT/widgets"; then up=1; break; fi + sleep 0.2 + done + if [ "$up" -ne 1 ]; then + echo " FAIL the app's driver server never answered from the simulator" + tail -20 /tmp/ci_ios_sim.app.log /tmp/ci_ios_sim.app.err 2>/dev/null | sed 's/^/ /' + sim_fail=1 + elif UI_SPEC=listbox_demo/spec_listbox_demo "$SCRIPT_DIR/tests/run_spec.sh" "$PORT"; then + echo " OK listbox_demo spec passes on the iOS simulator" + else + echo " FAIL listbox_demo spec on the iOS simulator" + tail -20 /tmp/ci_ios_sim.app.log /tmp/ci_ios_sim.app.err 2>/dev/null | sed 's/^/ /' + sim_fail=1 + fi + curl -sf -m 2 -X POST "http://127.0.0.1:$PORT/shutdown" > /dev/null 2>&1 || true + fi + fi + ios_sim_cleanup + [ "$sim_fail" -eq 0 ] || FAIL=$((FAIL + 1)) +fi + echo echo "=== Phase 2: smoke-launch non-driver examples ===" for ex in "${SMOKE_EXAMPLES[@]}"; do diff --git a/tests/ios/Info.plist b/tests/ios/Info.plist new file mode 100644 index 00000000..568f9ccb --- /dev/null +++ b/tests/ios/Info.plist @@ -0,0 +1,44 @@ + + + + + + CFBundleIdentifier + dev.aether.ui.simprobe + CFBundleName + AetherUIProbe + CFBundleExecutable + AetherUIProbe + CFBundlePackageType + APPL + CFBundleVersion + 1 + CFBundleShortVersionString + 1.0 + CFBundleSupportedPlatforms + + iPhoneSimulator + + DTPlatformName + iphonesimulator + DTSDKName + iphonesimulator + MinimumOSVersion + 15.0 + LSRequiresIPhoneOS + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + + diff --git a/tests/ios/runtime_seed.ae b/tests/ios/runtime_seed.ae new file mode 100644 index 00000000..fe0ba22e --- /dev/null +++ b/tests/ios/runtime_seed.ae @@ -0,0 +1,8 @@ +// The program `ae build --target=-ios-simulator --emit=staticlib` is +// given so that it compiles the Aether runtime for the simulator: the archive +// it writes holds this program's objects together with the runtime, and it +// is the runtime ci.sh Phase 1e2 links an aether-ui example against. Nothing +// here is ever called; the example brings its own main(). +main() { + println("aether runtime, built for the iOS simulator") +} From 741c301cecf50b128548cdf5a674e1510f2bdd2f Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Fri, 18 Sep 2026 16:18:24 -0300 Subject: [PATCH 2/7] CI iOS leg: say what the simulator knows when the app does not answer The first run got as far as a booted simulator with the bundle installed and launched, and no driver answer in 20s, with nothing on the app's stdout or stderr. The phase now waits 40s and, on a silent app, prints whether the process is alive, the unified log for it, and the head of its crash report if it left one. --- ci.sh | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/ci.sh b/ci.sh index da089cba..5ceaee96 100755 --- a/ci.sh +++ b/ci.sh @@ -764,6 +764,30 @@ ios_sim_pick() { | .value[] | select(.name | startswith("iPhone"))] | (map(select(.state == "Booted")) + .) | .[0].udid // empty' } +# What the simulator knows when the app did not answer: is the process +# alive (a simulator app is a host process), what it logged, and whether it +# left a crash report -- the exception and the thread that raised it are in +# the report's first lines. +ios_sim_diagnose() { + echo " -- process:" + pgrep -fl AetherUIProbe 2>/dev/null | sed 's/^/ /' || echo " (not running)" + echo " -- app stdout/stderr:" + tail -20 /tmp/ci_ios_sim.app.log /tmp/ci_ios_sim.app.err 2>/dev/null | sed 's/^/ /' + echo " -- unified log (last 2 minutes, the app's process):" + xcrun simctl spawn "$IOS_SIM_UDID" log show --last 2m --style compact \ + --predicate 'process == "AetherUIProbe"' 2>/dev/null | tail -40 | sed 's/^/ /' + echo " -- crash reports:" + local rep + rep="$(ls -t ~/Library/Logs/DiagnosticReports/AetherUIProbe* 2>/dev/null | head -1)" + if [ -n "$rep" ]; then + echo " $rep" + # .ips: a JSON header line, then the JSON report; the useful keys + # are near the top and the crashing thread's frames follow. + head -c 6000 "$rep" | tr ',' '\n' | grep -E '"(exception|termination|faultingThread|procName|asi|name|imageOffset|symbol|sourceFile|sourceLine)"' | head -60 | sed 's/^/ /' + else + echo " (none)" + fi +} ios_sim_cleanup() { [ -n "$IOS_SIM_UDID" ] || return 0 xcrun simctl terminate "$IOS_SIM_UDID" dev.aether.ui.simprobe > /dev/null 2>&1 || true @@ -863,19 +887,19 @@ else sim_fail=1 else up=0 - for _ in $(seq 1 100); do + for _ in $(seq 1 200); do if curl -sf -o /dev/null "http://127.0.0.1:$PORT/widgets"; then up=1; break; fi sleep 0.2 done if [ "$up" -ne 1 ]; then echo " FAIL the app's driver server never answered from the simulator" - tail -20 /tmp/ci_ios_sim.app.log /tmp/ci_ios_sim.app.err 2>/dev/null | sed 's/^/ /' + ios_sim_diagnose sim_fail=1 elif UI_SPEC=listbox_demo/spec_listbox_demo "$SCRIPT_DIR/tests/run_spec.sh" "$PORT"; then echo " OK listbox_demo spec passes on the iOS simulator" else echo " FAIL listbox_demo spec on the iOS simulator" - tail -20 /tmp/ci_ios_sim.app.log /tmp/ci_ios_sim.app.err 2>/dev/null | sed 's/^/ /' + ios_sim_diagnose sim_fail=1 fi curl -sf -m 2 -X POST "http://127.0.0.1:$PORT/shutdown" > /dev/null 2>&1 || true From 72c0333df166a328a1cb974a905ed25e14415507 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Fri, 18 Sep 2026 17:14:38 -0300 Subject: [PATCH 3/7] UIKit: the full AetherUIDriver adapter, serviced on the main thread The simulator leg's first run found the driver's partial table: the app launched, the server bound, and the first /widgets request walked the view tree from the HTTP thread and died in -[__NSArrayM objectAtIndex:]. UIKit, like GTK4, is not for reading off its thread. The adapter now supplies run_on_ui_thread, so the shared server services every request on the main queue, and the rest of the table AppKit and Win32 fill: text, visibility, parent, children, rect, enabled, classes, focus, hover/pressed readback, screenshot, and dispatch_action for click, set_text, toggle, set_value, set_state, focus, key, split, tabs, context menu, pick, hover/press/ release, the canvas events and shutdown. What iOS has no counterpart for (window resize, menu bar, tray) answers 404 rather than pretending. Two parity fixes on the way: on_click keeps its closure addressable by handle so a driver click on a plain container (a listbox row) fires it, and picker_set_selected fires the change callback as it does on the other backends. The crash-report extraction in the leg's diagnostics now prints the exception, its backtrace and the faulting thread with image names. --- backend/aether_ui_uikit.m | 485 +++++++++++++++++++++++++++++++++++++- ci.sh | 32 ++- 2 files changed, 506 insertions(+), 11 deletions(-) diff --git a/backend/aether_ui_uikit.m b/backend/aether_ui_uikit.m index a5ad51cc..62a659cd 100644 --- a/backend/aether_ui_uikit.m +++ b/backend/aether_ui_uikit.m @@ -1148,6 +1148,10 @@ void aether_ui_picker_set_selected(int handle, int index) { p.selectedIndex = index; [p setTitle:p.items[index] forState:UIControlStateNormal]; [p rebuildMenu]; + // A programmatic selection fires the change callback, as it does on + // GTK4 and AppKit; the menu action above fires it for a tap. + if (p.closure && p.closure->fn) + ((void(*)(void*, intptr_t))p.closure->fn)(p.closure->env, (intptr_t)index); } int aether_ui_picker_get_selected(int handle) { @@ -3139,6 +3143,7 @@ void aether_ui_bind_value(int state_handle, int widget_handle) { // Pass 6 wave 3 — events (tap/double-tap/hover), zstack, focus, sealing, misc. // =========================================================================== static const char kDblClosure; +static const char kClickClosure; static const char kSealed; @interface AeuiTapTarget : NSObject @@ -3165,6 +3170,10 @@ void aether_ui_on_click_impl(int handle, void* boxed_closure) { v.userInteractionEnabled = YES; [v addGestureRecognizer:tap]; aeui_own_helper(v, t); + // Addressable by handle too, so the driver's click on a plain container + // (a listbox row) fires what a tap would, as on_double_click below. + objc_setAssociatedObject(v, &kClickClosure, [NSValue valueWithPointer:boxed_closure], + OBJC_ASSOCIATION_RETAIN_NONATOMIC); } void aether_ui_on_double_click_impl(int handle, void* boxed_closure) { @@ -3688,6 +3697,7 @@ int aether_ui_overlay_open_impl(int win_handle, int content_handle, [[UITapGestureRecognizer alloc] initWithTarget:t action:@selector(tap)]]; scrim.userInteractionEnabled = YES; aeui_own_helper(scrim, t); + objc_setAssociatedObject(scrim, "aeui-scrim", @(1), OBJC_ASSOCIATION_RETAIN_NONATOMIC); e->scrim = scrim; } content.translatesAutoresizingMaskIntoConstraints = NO; @@ -4553,21 +4563,178 @@ int aether_ui_fire_appearance(int dark) { return 1; } -// --- AetherUIDriver hooks --------------------------------------------------- -// Enough for the server to run and serve the canvas pixel routes (which call -// aether_ui_canvas_read_pixel_impl directly, no hook) plus the cheap scalar -// queries. The rest stay NULL — the shared server treats a NULL hook as 501 and -// omits the field, so a partial table is safe. A full driver (widget geometry, -// dispatch_action) is a later pass. Every hook here reads only plain state, no -// off-main UIView mutation. +// --- AetherUIDriver — UIKit adapter ------------------------------------------ +// The shared HTTP server (aether_ui_test_server.c) does the parsing, routing +// and JSON; this table tells it how to read and drive UIKit widgets, as the +// AppKit and Win32 tables do for theirs. Route parity is structural: a route +// added to the shared server lands here at once. +// +// Threading: UIKit, like GTK and unlike AppKit, does not tolerate reads of +// the view tree from another thread (the accessibility and layout paths +// walk NSArrays the main thread is mutating, and an out-of-range index in +// -[__NSArrayM objectAtIndex:] on the HTTP thread was the first thing the +// simulator leg found). So run_on_ui_thread is supplied and the server +// services every request on the main queue, one hop per request; the +// mutations go through dispatch_action, which hops the same way. +// +// What iOS cannot do answers honestly rather than pretending: a window has +// no size to set (WIN_RESIZE), there is no menu bar or tray (MENU_ACTIVATE, +// TRAY_ACTIVATE) -- ctx->result = 3 and the route says 404. + static int hook_widget_count(void) { return widget_count; } -static const char* hook_widget_type(int handle) { return aeui_kind_name(get_widget_type(handle)); } + +static const char* hook_widget_type(int handle) { + if (handle < 1 || handle > widget_count) return "null"; + // A retired slot must read as null, not as its stale type. + if (!aether_ui_get_widget(handle)) return "null"; + return aeui_kind_name(get_widget_type(handle)); +} + +static void hook_widget_text_into(int handle, char* buf, int bufsize) { + buf[0] = '\0'; + UIView* v = (__bridge UIView*)aether_ui_get_widget(handle); + if (!v) return; + NSString* s = nil; + if ([v isKindOfClass:[UILabel class]]) { + s = [(UILabel*)v text]; + } else if ([v isKindOfClass:[UIButton class]]) { + // A picker is a UIButton whose title tracks its selection, so this + // reports the chosen item, as the other backends do. + s = [(UIButton*)v currentTitle]; + } else if ([v isKindOfClass:[UITextField class]]) { + s = [(UITextField*)v text]; + } else if ([v isKindOfClass:[UITextView class]]) { + s = [(UITextView*)v text]; + } + if (s) snprintf(buf, bufsize, "%s", [s UTF8String]); +} + +static int hook_widget_visible(int handle) { + UIView* v = (__bridge UIView*)aether_ui_get_widget(handle); + if (!v) return 0; + // The widget's OWN flag, not "is it on screen": parity with GTK's + // gtk_widget_get_visible and the win32 WS_VISIBLE read. Headless never + // maps a window, and an ancestry test would zero the whole app. + return v.hidden ? 0 : 1; +} + +static int hook_widget_parent(int handle) { + UIView* v = (__bridge UIView*)aether_ui_get_widget(handle); + if (!v) return 0; + // The nearest REGISTERED ancestor: a scroll view or a stack may + // interpose views of UIKit's own, and a raw superview would report an + // orphan for anything inside one. + for (UIView* p = v.superview; p; p = p.superview) { + int h = aether_ui_handle_for_widget((__bridge void*)p); + if (h > 0) return h; + } + return 0; +} + static int hook_toggle_active(int handle) { return aether_ui_toggle_get_active(handle); } static double hook_slider_value(int handle) { return aether_ui_slider_get_value(handle); } + +static double hook_progressbar_fraction(int handle) { + UIView* v = (__bridge UIView*)aether_ui_get_widget(handle); + if (!v || ![v isKindOfClass:[UIProgressView class]]) return 0.0; + return (double)[(UIProgressView*)v progress]; +} + +static int hook_widget_enabled(int handle) { + UIView* v = (__bridge UIView*)aether_ui_get_widget(handle); + if (!v) return 0; + // Non-controls (stacks, labels) have no enabled state: report them + // enabled, as GTK's get_sensitive does for a plain box. + if (![v isKindOfClass:[UIControl class]]) return 1; + return [(UIControl*)v isEnabled] ? 1 : 0; +} + +// The view geometry is reported against: the window's root view when there +// is a window, the app body's root widget headless. UIKit's y already grows +// downward, so this is the same frame GTK and Win32 report. +static UIView* aeui_driver_reference_view(void) { + UIWindow* w = aeui_key_window(); + if (w) return w.rootViewController ? w.rootViewController.view : (UIView*)w; + return (__bridge UIView*)aether_ui_get_widget(g_root_handle); +} + +static int hook_widget_rect(int handle, int* x, int* y, int* w, int* hgt) { + UIView* v = (__bridge UIView*)aether_ui_get_widget(handle); + if (!v) return -1; + UIView* ref = aeui_driver_reference_view(); + if (!ref) return -1; + CGRect r = v.superview ? [v.superview convertRect:v.frame toView:ref] + : [v convertRect:v.bounds toView:ref]; + // Sizes from the rounded edges, so a row of flexible children reported + // here still tiles its parent (the AppKit adapter's #101 lesson). + int x0 = (int)lround(r.origin.x), y0 = (int)lround(r.origin.y); + *x = x0; *y = y0; + *w = (int)lround(r.origin.x + r.size.width) - x0; + *hgt = (int)lround(r.origin.y + r.size.height) - y0; + return 0; +} + +static void hook_widget_classes_into(int handle, char* buf, int bufsize) { + buf[0] = '\0'; + if (handle < 1 || handle > widget_count) return; + const char* c = widget_classes[handle - 1]; + if (c) snprintf(buf, bufsize, "%s", c); +} + static void hook_widget_a11y(int handle, char* role, int rolesz, char* name, int namesz, char* desc, int descsz) { aether_ui_a11y_get_impl(handle, role, rolesz, name, namesz, desc, descsz); } + +static int hook_focused_widget(void) { return aether_ui_focused_widget(); } + +static int hook_widget_children(int handle, int* out, int max) { + UIView* v = (__bridge UIView*)aether_ui_get_widget(handle); + if (!v) return -1; + NSArray* subs = [v isKindOfClass:[UIStackView class]] + ? [(UIStackView*)v arrangedSubviews] : [v subviews]; + int n = 0; + for (UIView* c in subs) { + int ch = aether_ui_handle_for_widget((__bridge void*)c); + if (ch <= 0) continue; + if (out) { if (n >= max) break; out[n] = ch; } + n++; + } + return n; +} + +static int hook_widget_hovered(int handle) { + UIView* v = (__bridge UIView*)aether_ui_get_widget(handle); + return v && objc_getAssociatedObject(v, "aeui-hovered") ? 1 : 0; +} + +static int hook_widget_pressed(int handle) { + UIView* v = (__bridge UIView*)aether_ui_get_widget(handle); + return v && objc_getAssociatedObject(v, "aeui-pressed") ? 1 : 0; +} + +// The window as the user sees it, rendered through the view hierarchy the +// way a screenshot is. Headless there is no window, and the route says so. +static int hook_screenshot_png(unsigned char** out_data, size_t* out_len) { + UIWindow* w = aeui_key_window(); + if (!w) return 1; + CGRect b = w.bounds; + if (b.size.width < 1 || b.size.height < 1) return 1; + UIGraphicsImageRenderer* r = [[UIGraphicsImageRenderer alloc] initWithBounds:b]; + UIImage* img = [r imageWithActions:^(UIGraphicsImageRendererContext* ctx) { + (void)ctx; + [w drawViewHierarchyInRect:b afterScreenUpdates:YES]; + }]; + NSData* png = img ? UIImagePNGRepresentation(img) : nil; + if (!png || png.length == 0) return 1; + unsigned char* copy = (unsigned char*)malloc(png.length); + if (!copy) return 1; + memcpy(copy, png.bytes, png.length); + *out_data = copy; + *out_len = png.length; + return 0; +} + static int hook_canvas_debug(int canvas_id, int* area, int* commands, int* w, int* h) { CanvasState* cs = get_canvas_state(canvas_id); @@ -4578,6 +4745,7 @@ static int hook_canvas_debug(int canvas_id, int* area, int* commands, if (h) *h = cs->created_h; return 0; } + static int hook_canvas_paint_counters(int canvas_id, int* full_paints, int* clip_paints, int* last_clip_area) { CanvasState* cs = get_canvas_state(canvas_id); @@ -4588,14 +4756,315 @@ static int hook_canvas_paint_counters(int canvas_id, int* full_paints, return 0; } +// "ctrl+shift+s" -> the key name and the modifier bits window_key_deliver +// takes (1 shift, 2 ctrl, 4 alt, 8 cmd), the same spelling the specs use on +// every backend. +static int aeui_driver_split_combo(const char* combo, char* key, int keysz) { + int mods = 0; + key[0] = '\0'; + const char* p = combo ? combo : ""; + while (*p) { + const char* plus = strchr(p, '+'); + size_t n = plus ? (size_t)(plus - p) : strlen(p); + if (plus && n > 0) { + if (strncasecmp(p, "shift", n) == 0 && n == 5) mods |= 1; + else if ((strncasecmp(p, "ctrl", n) == 0 && n == 4) + || (strncasecmp(p, "control", n) == 0 && n == 7)) mods |= 2; + else if ((strncasecmp(p, "alt", n) == 0 && n == 3) + || (strncasecmp(p, "option", n) == 0 && n == 6)) mods |= 4; + else if ((strncasecmp(p, "cmd", n) == 0 && n == 3) + || (strncasecmp(p, "meta", n) == 0 && n == 4) + || (strncasecmp(p, "super", n) == 0 && n == 5)) mods |= 8; + p = plus + 1; + continue; + } + snprintf(key, (size_t)keysz, "%.*s", (int)n, p); + break; + } + return mods; +} + +// Every hover flag cleared and the resting colour restored, so a pointer +// that left everything leaves nothing wearing its hover colour. +static void aeui_driver_clear_hover(void) { + for (int i = 1; i <= widget_count; i++) { + UIView* pv = (__bridge UIView*)aether_ui_get_widget(i); + if (!pv || !objc_getAssociatedObject(pv, "aeui-hovered")) continue; + objc_setAssociatedObject(pv, "aeui-hovered", nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + id orig = objc_getAssociatedObject(pv, "aeui-hover-orig"); + if (orig) pv.backgroundColor = (orig == [NSNull null]) ? nil : orig; + } +} + +// A state colour (hover or active) put on the view the way the pointer +// recognizer does it: the resting colour is kept once, under +// "aeui-hover-orig", and comes back when the state ends. +static void aeui_driver_apply_state_colour(UIView* v, const char* style_key) { + NSNumber* n = objc_getAssociatedObject(v, style_key); + if (!n) return; + if (!objc_getAssociatedObject(v, "aeui-hover-orig")) + objc_setAssociatedObject(v, "aeui-hover-orig", v.backgroundColor ?: [NSNull null], + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + int p = n.intValue; + v.backgroundColor = [UIColor colorWithRed:((p >> 16) & 255) / 255.0 + green:((p >> 8) & 255) / 255.0 + blue:(p & 255) / 255.0 alpha:1.0]; +} + +// Mutation, on the main queue. +static void driver_perform(AetherDriverActionCtx* ctx) { + // Actions with no widget subject first. + switch (ctx->action) { + case AETHER_DRV_SET_STATE: { + switch (aether_ui_state_type(ctx->handle)) { + case 1: aether_ui_state_set_i(ctx->handle, atoi(ctx->sval)); break; + case 2: aether_ui_state_set_b(ctx->handle, + (strcmp(ctx->sval, "true") == 0 || atoi(ctx->sval) != 0)); break; + case 3: aether_ui_state_set_s(ctx->handle, ctx->sval); break; + default: aether_ui_state_set(ctx->handle, ctx->dval); + } + ctx->result = 0; + return; + } + case AETHER_DRV_WIN_RESIZE: + // An iOS window is the screen (or the scene the system sizes); + // nothing here can set it. 404 rather than a pretend success. + ctx->result = 3; + return; + case AETHER_DRV_WIN_KEY: { + char key[64]; + int mods = aeui_driver_split_combo(ctx->sval, key, sizeof(key)); + ctx->retval = aether_ui_window_key_deliver(key, mods); + ctx->result = 0; + return; + } + case AETHER_DRV_SHUTDOWN: + // iOS has no programmatic quit for an app the user runs, and the + // ABI's app_quit is a documented no-op for that reason. The + // driver is the test harness, and its shutdown is the end of a + // test run: the process exits, and the port with it. + fflush(stdout); + fflush(stderr); + exit(0); + case AETHER_DRV_PICK: { + // A real hit-test at window coordinates: the proof that a modal + // scrim blocks input by z-order rather than by an honour system. + ctx->retval = 0; + int want_scrim = 0; + UIView* ref = aeui_driver_reference_view(); + if (ref) { + UIView* hit = [ref hitTest:CGPointMake(ctx->ival, ctx->ival2) withEvent:nil]; + for (UIView* v = hit; v; v = v.superview) { + int h = aether_ui_handle_for_widget((__bridge void*)v); + if (h > 0) { + if (objc_getAssociatedObject(v, "aeui-scrim")) want_scrim = 1; + else ctx->retval = h; + break; + } + } + } + ctx->ival2 = want_scrim; // out-param: on_scrim + ctx->result = 0; + return; + } + case AETHER_DRV_SPLIT_POS: + if (ctx->ival >= 0) aether_ui_split_set_position_impl(ctx->handle, ctx->ival); + ctx->retval = aether_ui_split_position_impl(ctx->handle); + ctx->result = 0; + return; + case AETHER_DRV_TAB_SELECT: + aether_ui_tabs_select(ctx->handle, ctx->ival); + ctx->retval = aether_ui_tabs_selected(ctx->handle); + ctx->result = 0; + return; + case AETHER_DRV_CTX_MENU: { + UIView* v = (__bridge UIView*)aether_ui_get_widget(ctx->handle); + AeuiCtxMenuDelegate* d = v ? objc_getAssociatedObject(v, &kCtxDelegate) : nil; + ctx->retval = (d && d.items.count > 0) ? 1 : 0; + ctx->result = 0; + return; + } + case AETHER_DRV_CTX_ACTIVATE: { + UIView* v = (__bridge UIView*)aether_ui_get_widget(ctx->handle); + AeuiCtxMenuDelegate* d = v ? objc_getAssociatedObject(v, &kCtxDelegate) : nil; + ctx->retval = 0; + if (d && ctx->ival >= 0 && ctx->ival < (int)d.items.count) { + AeClosure* c = (AeClosure*)[d.items[(NSUInteger)ctx->ival][@"c"] pointerValue]; + if (c && c->fn) { ((void(*)(void*))c->fn)(c->env); ctx->retval = 1; } + } + ctx->result = 0; + return; + } + case AETHER_DRV_MENU_ACTIVATE: + case AETHER_DRV_TRAY_ACTIVATE: + // No menu bar and no tray on iOS; the registry these would + // reach is the desktop's. + ctx->result = 3; + return; + case AETHER_DRV_CANVAS_CLICK: + case AETHER_DRV_CANVAS_MOVE: + case AETHER_DRV_CANVAS_RELEASE: + case AETHER_DRV_CANVAS_KEY: + case AETHER_DRV_CANVAS_KEYUP: + case AETHER_DRV_CANVAS_SCROLL: { + CanvasState* cs = get_canvas_state(ctx->handle); + AeClosure* c = NULL; + if (cs) { + c = (ctx->action == AETHER_DRV_CANVAS_SCROLL) ? cs->on_scroll + : (ctx->action == AETHER_DRV_CANVAS_CLICK) ? cs->on_click + : (ctx->action == AETHER_DRV_CANVAS_MOVE) ? cs->on_move + : (ctx->action == AETHER_DRV_CANVAS_RELEASE) ? cs->on_release + : (ctx->action == AETHER_DRV_CANVAS_KEYUP) ? cs->on_key_release + : cs->on_key; + } + if (!c || !c->fn) { ctx->result = 3; return; } // 404: unwired, not missed + if (ctx->action == AETHER_DRV_CANVAS_KEY + || ctx->action == AETHER_DRV_CANVAS_KEYUP) { + ((void(*)(void*, const char*))c->fn)(c->env, ctx->sval); + } else { + ((void(*)(void*, double, double))c->fn)(c->env, ctx->dval, ctx->dval2); + } + ctx->result = 0; + return; + } + default: break; + } + + UIView* v = (__bridge UIView*)aether_ui_get_widget(ctx->handle); + if (!v) { + // hover(0) = "the pointer is over NOTHING": clear every hover. + if (ctx->action == AETHER_DRV_HOVER && ctx->handle == 0) { + aeui_driver_clear_hover(); + ctx->retval = 1; ctx->result = 0; return; + } + ctx->result = 3; return; + } + if (ctx->action == AETHER_DRV_FOCUS) { + aether_ui_focus_impl(ctx->handle); + ctx->result = 0; + return; + } + if (ctx->handle == aether_ui_test_server_banner_handle()) { ctx->result = 2; return; } + if (aether_ui_test_server_is_sealed(ctx->handle)) { ctx->result = 1; return; } + + switch (ctx->action) { + case AETHER_DRV_HOVER: + // The flag the readback reports and the colour the pointer + // recognizer would have put on: what a hover looks like here. + aeui_driver_clear_hover(); + objc_setAssociatedObject(v, "aeui-hovered", @(1), OBJC_ASSOCIATION_RETAIN_NONATOMIC); + aeui_driver_apply_state_colour(v, "aeui-hover-style"); + ctx->retval = 1; + break; + case AETHER_DRV_PRESS: + objc_setAssociatedObject(v, "aeui-pressed", @(1), OBJC_ASSOCIATION_RETAIN_NONATOMIC); + aeui_driver_apply_state_colour(v, "aeui-active-style"); + ctx->retval = 1; + break; + case AETHER_DRV_RELEASE: { + objc_setAssociatedObject(v, "aeui-pressed", nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + // Back to the hover colour if the pointer is still over it, + // else to rest. + id orig = objc_getAssociatedObject(v, "aeui-hover-orig"); + if (orig) v.backgroundColor = (orig == [NSNull null]) ? nil : orig; + if (objc_getAssociatedObject(v, "aeui-hovered")) + aeui_driver_apply_state_colour(v, "aeui-hover-style"); + ctx->retval = 1; + break; + } + case AETHER_DRV_CLICK: + if ([v isKindOfClass:[UIControl class]] && ![v isKindOfClass:[UITextField class]]) { + // The control's own action, as a tap would send it: a + // button's target, a switch's, a segmented control's. + UIControl* c = (UIControl*)v; + if ([v isKindOfClass:[UISwitch class]]) { + [(UISwitch*)v setOn:![(UISwitch*)v isOn] animated:NO]; + [c sendActionsForControlEvents:UIControlEventValueChanged]; + } else { + [c sendActionsForControlEvents:UIControlEventTouchUpInside]; + } + } else { + // Any widget carrying an on_click closure: listbox rows are + // plain containers, and a tap on one must still fire. + NSValue* nv = objc_getAssociatedObject(v, &kClickClosure); + AeClosure* c = nv ? (AeClosure*)nv.pointerValue : NULL; + if (c && c->fn) ((void(*)(void*))c->fn)(c->env); + } + break; + case AETHER_DRV_SET_TEXT: + if ([v isKindOfClass:[UITextField class]]) { + // Through the toolkit setter, so a two-way bind_value field + // mirrors driver input into its state, then the editing + // event a keystroke would have sent, so on_change fires: + // setting .text sends no event of its own. + aether_ui_textfield_set_text(ctx->handle, ctx->sval); + [(UITextField*)v sendActionsForControlEvents:UIControlEventEditingChanged]; + } else if ([v isKindOfClass:[UITextView class]]) { + aether_ui_textarea_set_text(ctx->handle, ctx->sval); + // Programmatic text sets do not call the delegate either. + id d = [(UITextView*)v delegate]; + if ([d respondsToSelector:@selector(textViewDidChange:)]) + [d textViewDidChange:(UITextView*)v]; + } else if ([v isKindOfClass:[UILabel class]]) { + aether_ui_text_set_string(ctx->handle, ctx->sval); + } + break; + case AETHER_DRV_TOGGLE: + if ([v isKindOfClass:[UISwitch class]]) { + [(UISwitch*)v setOn:![(UISwitch*)v isOn] animated:NO]; + [(UISwitch*)v sendActionsForControlEvents:UIControlEventValueChanged]; + } + break; + case AETHER_DRV_SET_VALUE: + if ([v isKindOfClass:[UISlider class]]) { + aether_ui_slider_set_value(ctx->handle, ctx->dval); + [(UISlider*)v sendActionsForControlEvents:UIControlEventValueChanged]; + } else if ([v isKindOfClass:[UIProgressView class]]) { + aether_ui_progressbar_set_fraction(ctx->handle, ctx->dval); + } else if (get_widget_type(ctx->handle) == AUI_PICKER) { + // set_selected fires the change callback, as on every backend. + aether_ui_picker_set_selected(ctx->handle, (int)ctx->dval); + } + break; + default: + break; + } + ctx->result = 0; +} + +static void hook_dispatch_action(AetherDriverActionCtx* ctx) { + if ([NSThread isMainThread]) driver_perform(ctx); + else dispatch_sync(dispatch_get_main_queue(), ^{ driver_perform(ctx); }); + ctx->done = 1; +} + +static void hook_run_on_ui_thread(void (*fn)(void*), void* arg) { + if ([NSThread isMainThread]) { fn(arg); return; } + dispatch_sync(dispatch_get_main_queue(), ^{ fn(arg); }); +} + static const AetherDriverHooks uikit_driver_hooks = { .widget_count = hook_widget_count, .widget_type = hook_widget_type, + .widget_text_into = hook_widget_text_into, + .widget_visible = hook_widget_visible, + .widget_hovered = hook_widget_hovered, + .widget_pressed = hook_widget_pressed, + .widget_parent = hook_widget_parent, .toggle_active = hook_toggle_active, .slider_value = hook_slider_value, + .progressbar_fraction = hook_progressbar_fraction, + .dispatch_action = hook_dispatch_action, + .widget_children = hook_widget_children, + .widget_enabled = hook_widget_enabled, + .widget_rect = hook_widget_rect, + .widget_classes_into = hook_widget_classes_into, + .focused_widget = hook_focused_widget, .widget_a11y = hook_widget_a11y, + .screenshot_png = hook_screenshot_png, .canvas_debug = hook_canvas_debug, .canvas_paint_counters = hook_canvas_paint_counters, + .run_on_ui_thread = hook_run_on_ui_thread, }; static int uikit_test_server_started = 0; diff --git a/ci.sh b/ci.sh index 5ceaee96..03319b39 100755 --- a/ci.sh +++ b/ci.sh @@ -781,9 +781,35 @@ ios_sim_diagnose() { rep="$(ls -t ~/Library/Logs/DiagnosticReports/AetherUIProbe* 2>/dev/null | head -1)" if [ -n "$rep" ]; then echo " $rep" - # .ips: a JSON header line, then the JSON report; the useful keys - # are near the top and the crashing thread's frames follow. - head -c 6000 "$rep" | tr ',' '\n' | grep -E '"(exception|termination|faultingThread|procName|asi|name|imageOffset|symbol|sourceFile|sourceLine)"' | head -60 | sed 's/^/ /' + # .ips: a JSON header line, then the JSON report. The exception, the + # uncaught NSException's own message and backtrace if there was + # one (that is where an out-of-range index was raised), and the + # faulting thread's frames, each with its image. + python3 - "$rep" <<'PY' 2>&1 | sed 's/^/ /' +import json, sys +raw = open(sys.argv[1]).read().split('\n', 1)[1] +r = json.loads(raw) +imgs = r.get('usedImages', []) +def frame(f): + i = f.get('imageIndex', -1) + img = imgs[i].get('name', '?') if 0 <= i < len(imgs) else '?' + return '%-28s %s %s%s' % (img, f.get('symbol', '+%d' % f.get('imageOffset', 0)), + f.get('sourceFile', ''), (':%d' % f['sourceLine']) if 'sourceLine' in f else '') +print('exception:', json.dumps(r.get('exception'))) +print('termination:', json.dumps(r.get('termination'))) +for k in ('asi', 'ktriageinfo'): + if r.get(k): print(k + ':', json.dumps(r[k])[:600]) +leb = r.get('lastExceptionBacktrace') +if leb: + print('last exception backtrace:') + for f in leb[:25]: print(' ', frame(f)) +ft = r.get('faultingThread', 0) +threads = r.get('threads', []) +if ft < len(threads): + t = threads[ft] + print('faulting thread %d %s %s:' % (ft, t.get('name', ''), t.get('queue', ''))) + for f in t.get('frames', [])[:25]: print(' ', frame(f)) +PY else echo " (none)" fi From 1d0dc5902ad2fc925fdaef72d1e3dc553595df4a Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Fri, 18 Sep 2026 17:50:53 -0300 Subject: [PATCH 4/7] UIKit retires what it removes; CHANGELOG for the iOS leg and the driver The simulator's first spec run passed the button clicks and failed every row-selection case: clear_children, remove_child, set_child and navstack_pop took views out of the tree and left them in the registry, so the driver listed the rows of every rebuild that was gone, found "person 2" in a retired one, and clicked a row that no longer existed. The same slots kept every retired view alive for the life of the process and never gave their closure boxes back. Retired views now leave the registry (aeui_unregister_view_tree, what AppKit's unregister_view_tree and win32's mark_subtree_dead do), and the boxes their helpers hold are released on the next turn of the main queue, since the closure retiring its own widget is on the stack when this runs (#145's lesson). AeuiClosureHolder is the protocol every closure-holding helper adopts so one walk finds them all. --- CHANGELOG.md | 40 ++++++++++++++ backend/aether_ui_uikit.m | 113 ++++++++++++++++++++++++++++++++++---- 2 files changed, 141 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 111fbf83..e51c231d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [current] +### Added + +- **An iOS simulator leg in CI (#22):** `ae build --target=-ios-simulator + --emit=staticlib` compiles the Aether runtime for the simulator + (`tests/ios/runtime_seed.ae`), `listbox_demo`'s portable C is linked + against it with the UIKit backend and the driver, the binary goes into a + bundle (`tests/ios/Info.plist`) and a booted iPhone simulator, and + `tests/listbox_demo`'s spec runs against it over the same driver port + every other backend answers (ci.sh Phase 1e2). The first time an + aether-ui app has run on iOS, and the acceptance #22 set out. +- **UIKit answers the whole AetherUIDriver:** the backend's driver table + had the canvas pixel routes and four scalar reads. It now has what the + AppKit and Win32 tables have -- text, visibility, parent, children, + rect, enabled, classes, focus, hover/pressed readback, screenshot -- and + `dispatch_action` for click, set_text, toggle, set_value, set_state, + focus, key, split, tabs, context menu, pick, hover/press/release, the + canvas events and shutdown. Every request is serviced on the main thread + (`run_on_ui_thread`): UIKit, like GTK4, does not tolerate reads of its + view tree from another thread, and the simulator's first `/widgets` + died in `-[__NSArrayM objectAtIndex:]` on the HTTP thread. What iOS has + no counterpart for (window resize, menu bar, tray) answers 404. + +### Fixed + +- **UIKit retires what it removes.** `clear_children`, `remove_child`, + `set_child` and `navstack_pop` took views out of the tree and left them + in the widget registry: alive for the life of the process (the array is + strong), listed by the driver with their stale text and parent -- the + simulator leg's first spec run found the rows of a rebuild that was gone + and clicked those -- and their closure boxes never given back. A rebuild + clears and repopulates, so all three grew with every rebuild. Retired + views now leave the registry, and the boxes their helpers hold are + released on the next turn of the main queue (a closure retiring its own + widget is on the stack when this runs; the main queue is the graveyard + win32 drains from its run loop, #145). `AeuiClosureHolder` is the + protocol every closure-holding helper adopts so one walk finds them all. +- **UIKit `picker_set_selected` fires the change callback**, as on GTK4 and + AppKit, and `on_click` keeps its closure addressable by handle so a + driver click on a plain container (a listbox row) fires what a tap would. + ### Changed - **win32 holds a stack's painting while a rebuild attaches into it diff --git a/backend/aether_ui_uikit.m b/backend/aether_ui_uikit.m index 4b9d52d7..873ba4d5 100644 --- a/backend/aether_ui_uikit.m +++ b/backend/aether_ui_uikit.m @@ -94,6 +94,15 @@ void* env; } AeClosure; +// Adopted by every helper that holds a boxed closure for its widget (a +// control's target, a tap target, a delegate, a layout hook, the picker), +// so retiring the widget can give every box back through one question. +@protocol AeuiClosureHolder +@property (nonatomic, assign) AeClosure* closure; +@end + +static void aeui_unregister_view_tree(UIView* v); + // AETHER_UI_HEADLESS — set by CI / smoke tests. Suppresses anything that would // spin a modal/user-input loop with no user present. static int aeui_is_headless(void) { @@ -236,7 +245,7 @@ static void aeui_own_helper(id owner, id helper) { [held addObject:helper]; } -@interface AeuiButtonTarget : NSObject +@interface AeuiButtonTarget : NSObject @property (nonatomic, assign) AeClosure* closure; @end @implementation AeuiButtonTarget @@ -246,7 +255,7 @@ - (void)fire { } @end -@interface AeuiToggleTarget : NSObject +@interface AeuiToggleTarget : NSObject @property (nonatomic, assign) AeClosure* closure; @end @implementation AeuiToggleTarget @@ -257,7 +266,7 @@ - (void)changed:(UISwitch*)sw { } @end -@interface AeuiSliderTarget : NSObject +@interface AeuiSliderTarget : NSObject @property (nonatomic, assign) AeClosure* closure; @end @implementation AeuiSliderTarget @@ -267,7 +276,7 @@ - (void)changed:(UISlider*)s { } @end -@interface AeuiFieldTarget : NSObject +@interface AeuiFieldTarget : NSObject @property (nonatomic, assign) AeClosure* closure; @end @implementation AeuiFieldTarget @@ -524,7 +533,7 @@ int aether_ui_surface_diag_count_impl(int container_handle) { // keeps its hooks in an array of its own, so a second on_layout on the same // stack adds a hook rather than replacing the first, and they all go with // the stack. -@interface AeuiLayoutHook : NSObject +@interface AeuiLayoutHook : NSObject @property (nonatomic, assign) AeClosure* closure; @property (nonatomic, assign) int lastW, lastH; @end @@ -643,17 +652,23 @@ void aether_ui_widget_add_child_ctx(void* parent_ctx, int child_handle) { } } +// Retired, not just detached: see aeui_unregister_view_tree. void aether_ui_remove_child_impl(int parent_handle, int child_handle) { (void)parent_handle; UIView* child = (__bridge UIView*)aether_ui_get_widget(child_handle); - if (child) [child removeFromSuperview]; // also removes it as arranged + if (!child) return; + aeui_unregister_view_tree(child); + [child removeFromSuperview]; // also removes it as arranged } void aether_ui_clear_children_impl(int handle) { UIView* v = (__bridge UIView*)aether_ui_get_widget(handle); if (!v) return; NSArray* kids = [v.subviews copy]; - for (UIView* k in kids) [k removeFromSuperview]; + for (UIView* k in kids) { + aeui_unregister_view_tree(k); + [k removeFromSuperview]; + } } // --------------------------------------------------------------------------- @@ -1073,7 +1088,7 @@ void aether_ui_progressbar_set_fraction(int handle, double fraction) { } // --- Text area — UITextView ------------------------------------------------- -@interface AeuiTextViewDelegate : NSObject +@interface AeuiTextViewDelegate : NSObject @property (nonatomic, assign) AeClosure* closure; @end @implementation AeuiTextViewDelegate @@ -1125,7 +1140,7 @@ int aether_ui_scrollview_create(void) { } // --- Picker — a UIButton driving a UIMenu (iPad-friendly; iOS 14+) ---------- -@interface AeuiPicker : UIButton +@interface AeuiPicker : UIButton @property (nonatomic, strong) NSMutableArray* items; @property (nonatomic, assign) int selectedIndex; @property (nonatomic, assign) AeClosure* closure; @@ -3182,7 +3197,7 @@ void aether_ui_bind_value(int state_handle, int widget_handle) { static const char kClickClosure; static const char kSealed; -@interface AeuiTapTarget : NSObject +@interface AeuiTapTarget : NSObject @property (nonatomic, assign) AeClosure* closure; @end @implementation AeuiTapTarget @@ -3305,7 +3320,11 @@ void aether_ui_widget_set_child_impl(int parent_handle, int child_handle) { UIView* parent = (__bridge UIView*)aether_ui_get_widget(parent_handle); UIView* child = (__bridge UIView*)aether_ui_get_widget(child_handle); if (!parent || !child) return; - for (UIView* k in [parent.subviews copy]) [k removeFromSuperview]; + for (UIView* k in [parent.subviews copy]) { + if (k == child) continue; + aeui_unregister_view_tree(k); + [k removeFromSuperview]; + } child.translatesAutoresizingMaskIntoConstraints = NO; [parent addSubview:child]; [NSLayoutConstraint activateConstraints:@[ @@ -3597,7 +3616,13 @@ void aether_ui_navstack_pop(int handle) { NSNumber* d = objc_getAssociatedObject(container, &kNavDepth); int depth = d ? d.intValue : 0; if (depth <= 0) return; // root: no-op - for (UIView* k in [container.subviews copy]) [k removeFromSuperview]; + // Retired as well as detached, so the driver's count shrinks with the + // page (the spec that caught this on win32 and AppKit: "pop SHRANK the + // widget tree"). + for (UIView* k in [container.subviews copy]) { + aeui_unregister_view_tree(k); + [k removeFromSuperview]; + } objc_setAssociatedObject(container, &kNavDepth, @(depth - 1), OBJC_ASSOCIATION_RETAIN_NONATOMIC); } @@ -4571,6 +4596,70 @@ int aether_ui_fire_appearance(int dark) { return 1; } +// --- Retiring widgets ------------------------------------------------------- +// removeFromSuperview takes a view out of the tree; the registry is ours, +// and until this a retired view stayed in it: alive for the life of the +// process (the registry's array is strong), listed by the driver with its +// stale text and parent -- the simulator leg's first spec run found the +// rows of a rebuild that was gone and clicked those -- and its closure +// boxes never given back. A rebuild clears and repopulates, so all three +// grew with every rebuild. This is what AppKit's unregister_view_tree and +// win32's mark_subtree_dead do for theirs. +extern void aether_closure_env_free(void* env); + +// A box is released on the next turn of the main queue, not here: the +// closure retiring its own widget (a row's click handler that calls +// listbox_update) is on the stack when this runs, and freeing its env under +// it is the use-after-free win32 met (#145) and answered with a graveyard +// drained by its run loop. The main queue is that graveyard here. +static void aeui_release_boxed_later(AeClosure* boxed) { + if (!boxed) return; + dispatch_async(dispatch_get_main_queue(), ^{ + aether_closure_env_free(boxed->env); + free(boxed); + }); +} + +static void aeui_unregister_view_tree(UIView* v) { + if (!v) return; + for (UIView* c in [v.subviews copy]) aeui_unregister_view_tree(c); + int h = aether_ui_handle_for_widget((__bridge void*)v); + if (h < 1) return; + widgets[h - 1] = nil; + widget_types[h - 1] = AUI_UNKNOWN; + free(widget_classes[h - 1]); + widget_classes[h - 1] = NULL; + // The boxes its helpers hold (tap targets, a control's target, a + // field's, a text view's delegate), and the by-handle aliases of the + // click and double-click ones, so nothing dangles. + NSMutableArray* held = objc_getAssociatedObject(v, "aeui_helpers"); + for (id helper in held) { + if (![helper conformsToProtocol:@protocol(AeuiClosureHolder)]) continue; + id hc = helper; + aeui_release_boxed_later(hc.closure); + hc.closure = NULL; + } + objc_setAssociatedObject(v, &kClickClosure, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + objc_setAssociatedObject(v, &kDblClosure, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + if ([v isKindOfClass:[AeuiStackView class]]) { + for (AeuiLayoutHook* hk in ((AeuiStackView*)v).layoutHooks) { + aeui_release_boxed_later(hk.closure); + hk.closure = NULL; + } + } + if ([v conformsToProtocol:@protocol(AeuiClosureHolder)]) { // the picker + id hc = (id)v; + aeui_release_boxed_later(hc.closure); + hc.closure = NULL; + } + AeuiCtxMenuDelegate* d = objc_getAssociatedObject(v, &kCtxDelegate); + if (d) { + for (NSDictionary* it in d.items) + aeui_release_boxed_later((AeClosure*)[it[@"c"] pointerValue]); + [d.items removeAllObjects]; + } +} + // --- AetherUIDriver — UIKit adapter ------------------------------------------ // The shared HTTP server (aether_ui_test_server.c) does the parsing, routing // and JSON; this table tells it how to read and drive UIKit widgets, as the From aed8bf831b2f65aa2afc4a31a137dfbbaf6fa8b9 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Fri, 18 Sep 2026 18:25:43 -0300 Subject: [PATCH 5/7] UIKit: set_focusable makes a stack able to take focus; hardware keys reach the window handler The simulator run now selects rows by tap and programmatically; the keyboard cases still failed because focus_impl on a row did nothing: UIView answers NO to canBecomeFirstResponder and has no setter, so _listbox_owns_focus never saw a row focused and the arrow keys were ignored. AeuiStackView answers from a flag set_focusable sets, as AppKit's AetherStackView does, and delivers hardware-keyboard presses to aether_ui_window_key_deliver with the desktop backends' key names. --- CHANGELOG.md | 7 ++++ backend/aether_ui_uikit.m | 71 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e51c231d..6ef9fd6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **UIKit `picker_set_selected` fires the change callback**, as on GTK4 and AppKit, and `on_click` keeps its closure addressable by handle so a driver click on a plain container (a listbox row) fires what a tap would. +- **UIKit `set_focusable` works on a stack.** `UIView` answers NO to + `canBecomeFirstResponder` and has no setter, so a container could never + take focus and a listbox's rows never owned it: Down, Up, Home and End + did nothing on iOS. `AeuiStackView` answers from a flag `set_focusable` + sets, as AppKit's `AetherStackView` does, and a focused stack delivers + hardware-keyboard presses (`pressesBegan:`) to the window's key handler + with the desktop backends' key names. ### Changed diff --git a/backend/aether_ui_uikit.m b/backend/aether_ui_uikit.m index 873ba4d5..bd9dede7 100644 --- a/backend/aether_ui_uikit.m +++ b/backend/aether_ui_uikit.m @@ -551,10 +551,74 @@ @implementation AeuiLayoutHook // left the layer deallocating with the observer registered when the stack // was retired. An override has nothing registered anywhere, so there is // nothing to unregister and no order to get wrong. +// +// It is also what set_focusable turns on: UIView answers NO to +// canBecomeFirstResponder and has no setter, so a plain container could +// never take focus however hard focus_impl tried, and a listbox's rows +// (focusable by contract, so arrow keys have somewhere to land) never +// owned it -- Down and Home did nothing on iOS. The flag defaults to NO, +// so an unmarked stack behaves as before; AppKit's AetherStackView does +// the same. A focused stack delivers hardware-keyboard presses the way +// the desktop backends deliver key events: to the window's key handler. @interface AeuiStackView : UIStackView @property (nonatomic, strong) NSMutableArray* layoutHooks; +@property (nonatomic, assign) BOOL aeuiFocusable; @end + +int aether_ui_window_key_deliver(const char* key_name, int mods); + +// A UIPress as the key name and modifier bits window_key_deliver takes: +// the names the desktop backends use for the navigation keys, and the +// character itself for the rest. 1 shift, 2 ctrl, 4 alt, 8 cmd. +static int aeui_press_key_name(UIPress* press, char* out, int outsz) API_AVAILABLE(ios(13.4)) { + UIKey* key = press.key; + if (!key) return 0; + const char* name = NULL; + switch (key.keyCode) { + case UIKeyboardHIDUsageKeyboardUpArrow: name = "Up"; break; + case UIKeyboardHIDUsageKeyboardDownArrow: name = "Down"; break; + case UIKeyboardHIDUsageKeyboardLeftArrow: name = "Left"; break; + case UIKeyboardHIDUsageKeyboardRightArrow: name = "Right"; break; + case UIKeyboardHIDUsageKeyboardHome: name = "Home"; break; + case UIKeyboardHIDUsageKeyboardEnd: name = "End"; break; + case UIKeyboardHIDUsageKeyboardPageUp: name = "Page_Up"; break; + case UIKeyboardHIDUsageKeyboardPageDown: name = "Page_Down"; break; + case UIKeyboardHIDUsageKeyboardReturnOrEnter: name = "Return"; break; + case UIKeyboardHIDUsageKeyboardEscape: name = "Escape"; break; + case UIKeyboardHIDUsageKeyboardTab: name = "Tab"; break; + case UIKeyboardHIDUsageKeyboardDeleteOrBackspace: name = "BackSpace"; break; + case UIKeyboardHIDUsageKeyboardDeleteForward: name = "Delete"; break; + case UIKeyboardHIDUsageKeyboardSpacebar: name = "space"; break; + default: break; + } + if (name) snprintf(out, (size_t)outsz, "%s", name); + else { + const char* c = key.charactersIgnoringModifiers.UTF8String; + if (!c || !c[0]) return 0; + snprintf(out, (size_t)outsz, "%s", c); + } + int mods = 0; + if (key.modifierFlags & UIKeyModifierShift) mods |= 1; + if (key.modifierFlags & UIKeyModifierControl) mods |= 2; + if (key.modifierFlags & UIKeyModifierAlternate) mods |= 4; + if (key.modifierFlags & UIKeyModifierCommand) mods |= 8; + return 1 | (mods << 1); +} + @implementation AeuiStackView +- (BOOL)canBecomeFirstResponder { return self.aeuiFocusable; } +- (void)pressesBegan:(NSSet*)presses withEvent:(UIPressesEvent*)event { + BOOL handled = NO; + if (@available(iOS 13.4, *)) { + for (UIPress* press in presses) { + char name[64]; + int r = aeui_press_key_name(press, name, sizeof(name)); + if (!r) continue; + if (aether_ui_window_key_deliver(name, r >> 1)) handled = YES; + } + } + if (!handled) [super pressesBegan:presses withEvent:event]; +} - (void)layoutSubviews { [super layoutSubviews]; if (!self.layoutHooks) return; @@ -3287,7 +3351,12 @@ int aether_ui_focused_widget(void) { } void aether_ui_set_focusable_impl(int handle, int on) { UIView* v = (__bridge UIView*)aether_ui_get_widget(handle); - if (v) v.userInteractionEnabled = (on != 0); + if (!v) return; + v.userInteractionEnabled = (on != 0); + // A stack answers canBecomeFirstResponder from this (see AeuiStackView); + // native controls accept focus by their own rules and need nothing here. + if ([v isKindOfClass:[AeuiStackView class]]) + [(AeuiStackView*)v setAeuiFocusable:(on != 0)]; } // --- window handle (single window on iOS; primary == 1) --------------------- From 252972d496d2f78f3e5964ae4a2c3830eec91ccb Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Fri, 18 Sep 2026 19:28:11 -0300 Subject: [PATCH 6/7] docs: iOS in the README's backend and platform tables; the UIKit header says what CI runs --- README.md | 4 +++- backend/aether_ui_uikit.m | 9 ++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a07c4478..76781678 100644 --- a/README.md +++ b/README.md @@ -590,7 +590,8 @@ capabilities the test harness is denied, not the other way around. | GTK4 backend | `backend/aether_ui_gtk4.c` | Linux + FreeBSD: GTK4 C API calls, Cairo canvas, test server | | macOS backend | `backend/aether_ui_macos.m` | macOS: AppKit Objective-C | | Win32 backend | `backend/aether_ui_win32.c` | Windows: USER32 + GDI+ + Common Controls | -| C header | `backend/aether_ui_backend.h` | Shared backend ABI — implemented by all three backends (four platforms; FreeBSD shares GTK4) | +| UIKit backend | `backend/aether_ui_uikit.m` | iOS / iPadOS: UIKit Objective-C, one `UIWindowScene` | +| C header | `backend/aether_ui_backend.h` | Shared backend ABI — implemented by all four backends (five platforms; FreeBSD shares GTK4) | | Build script | `build.sh` | Auto-detects platform (Darwin/Linux/FreeBSD/MinGW) | | Spec matrix | `tests/spec_matrix.sh` | Runs every AetherUIDriver spec, one app at a time | | Widget tests | `tests/test_widgets.c` | Cross-platform C-level smoke suite (40 assertions) | @@ -605,6 +606,7 @@ capabilities the test harness is denied, not the other way around. | macOS | AppKit (`backend/aether_ui_macos.m`) | Full — all widgets, canvas, events, styling, AetherUIDriver test server | | Windows | Native Win32 (`backend/aether_ui_win32.c`) | Full — USER32 + GDI+ + Common Controls v6 (themed); per-monitor DPI v2; follows the system's dark mode (title bar, ground, controls); AetherUIDriver via winsock2 | | FreeBSD | GTK4 (`backend/aether_ui_gtk4.c`) | Full — shares the Linux backend; clang build, private-Xvfb spec runs | +| iOS | UIKit (`backend/aether_ui_uikit.m`) | Whole ABI implemented; AetherUIDriver served on the main thread. CI links `listbox_demo` against `ae build --target=aarch64-ios-simulator --emit=staticlib`, installs it in a booted iPhone simulator and runs its driver spec there (ci.sh Phase 1e2). No menu bar, tray, or window resize (those routes answer 404); file pickers are async on iOS and answer an empty selection. | "Full" above means the backend implements the whole widget/canvas/event/ styling surface plus AetherUIDriver — not that every suite is green on every diff --git a/backend/aether_ui_uikit.m b/backend/aether_ui_uikit.m index bd9dede7..f17cff70 100644 --- a/backend/aether_ui_uikit.m +++ b/backend/aether_ui_uikit.m @@ -18,11 +18,14 @@ // that cannot be functional is the tray/menu-bar family (there is no iOS // status-bar tray); those are documented no-ops. A few carry a stated // limitation where iOS differs from the desktop ABI (synchronous file pickers → -// empty selection, since iOS pickers are async; live hardware-keyboard shortcut -// delivery is driver/registry-only pending UIKeyCommand responder wiring; a +// empty selection, since iOS pickers are async; hardware-keyboard presses +// reach the window's key handler through a focused stack (AeuiStackView +// pressesBegan:), not yet through UIKeyCommand for unfocused shortcuts; a // bg-gradient layer that doesn't track resize). Gated by the iOS SDK // compile+link+RENDER check in ci.sh (Phase 1e, which pixel-checks the canvas -// natively via Mac Catalyst). +// natively via Mac Catalyst) and RUN in the iOS simulator (Phase 1e2: +// listbox_demo linked against an `ae build --emit=staticlib` runtime for the +// simulator, installed in a booted iPhone, driven by its driver spec). // pass 1 — lifecycle, widget registry, stack layout, core widgets (text, // button, textfield/securefield, toggle, slider). // pass 2 — visibility/enablement, text getters+truncation, accessibility From 250059a18e6ff0783744d977972521948fabbc40 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Fri, 18 Sep 2026 19:28:44 -0300 Subject: [PATCH 7/7] tests/ios/link_stub.c: say why it stays now that Phase 1e2 links the real runtime --- tests/ios/link_stub.c | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/ios/link_stub.c b/tests/ios/link_stub.c index 48f7c01b..9c620b72 100644 --- a/tests/ios/link_stub.c +++ b/tests/ios/link_stub.c @@ -13,10 +13,13 @@ // calling into the runtime somewhere new. Add it with a comment naming the // call site, exactly as tests/win32/win32_runtime_test.c says for its half. // -// There is no iOS build of libaether on this box; when there is one, link -// against it instead of this stub and delete this file. Until then this is the -// difference between "the backend mirrors the others" and "is known to link -// against the iOS frameworks". +// The real link happens too: ci.sh Phase 1e2 builds the Aether runtime for +// the simulator (`ae build --target=-ios-simulator --emit=staticlib`, +// see tests/ios/runtime_seed.ae) and links an example against it, installs it +// in a booted simulator and drives it. This stub stays for Phase 1e's own +// check, which links the backend against the frameworks in seconds, before +// the runtime is built, and names the runtime symbol a backend change starts +// needing. #include #include