diff --git a/CHANGELOG.md b/CHANGELOG.md index 111fbf83..6ef9fd6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,53 @@ 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. +- **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 - **win32 holds a stack's painting while a rebuild attaches into it 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 5876aa45..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 @@ -94,6 +97,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 +248,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 +258,7 @@ - (void)fire { } @end -@interface AeuiToggleTarget : NSObject +@interface AeuiToggleTarget : NSObject @property (nonatomic, assign) AeClosure* closure; @end @implementation AeuiToggleTarget @@ -257,7 +269,7 @@ - (void)changed:(UISwitch*)sw { } @end -@interface AeuiSliderTarget : NSObject +@interface AeuiSliderTarget : NSObject @property (nonatomic, assign) AeClosure* closure; @end @implementation AeuiSliderTarget @@ -267,7 +279,7 @@ - (void)changed:(UISlider*)s { } @end -@interface AeuiFieldTarget : NSObject +@interface AeuiFieldTarget : NSObject @property (nonatomic, assign) AeClosure* closure; @end @implementation AeuiFieldTarget @@ -524,7 +536,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 @@ -542,10 +554,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; @@ -643,17 +719,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 +1155,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 +1207,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; @@ -1184,6 +1266,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) { @@ -3175,9 +3261,10 @@ 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 +@interface AeuiTapTarget : NSObject @property (nonatomic, assign) AeClosure* closure; @end @implementation AeuiTapTarget @@ -3201,6 +3288,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) { @@ -3263,7 +3354,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) --------------------- @@ -3296,7 +3392,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:@[ @@ -3588,7 +3688,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); } @@ -3724,6 +3830,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; @@ -4561,21 +4668,242 @@ 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. +// --- 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 +// 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); @@ -4586,6 +4914,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); @@ -4596,14 +4925,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 5d64f424..2ab70dd6 100755 --- a/ci.sh +++ b/ci.sh @@ -738,6 +738,203 @@ 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' +} +# 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 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 +} +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 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" + 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" + 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 + fi + fi + ios_sim_cleanup + [ "$sim_fail" -eq 0 ] || FAIL=$((FAIL + 1)) +fi + echo echo "=== Phase 1f: AppKit label retention across list rebuilds (#139) ===" # tests/appkit_retention/retention_probe.ae rebuilds a 400-row listbox on a 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/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 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") +}