From 546245571c0613d465f92b583a7f67c6f5720b83 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sat, 24 Oct 2026 19:01:26 -0600 Subject: [PATCH 1/3] iOS release builds compile again: dismiss_keyboard leaves the debug-only block (MOB-504) mob 0.9.26 defined nif_dismiss_keyboard and find_first_responder_in inside the test harness's #if !MOB_RELEASE block while the nif_funcs entries for dismiss_keyboard/0,1 sat outside it, so every iOS release build failed with 'use of undeclared identifier'. Both functions now sit above the harness block. test/native/ios_nif_compile.sh (make -C test/native ios-nif) generates MobApp-Swift.h and syntax-compiles ios/mob_nif.m as debug, release, and release with MOB_ENABLE_SCREENSHOT; CI's macOS job runs it. It fails on the 0.9.26 source and passes with this fix. --- .github/workflows/test.yml | 8 ++ CHANGELOG.md | 8 ++ RELEASE.md | 3 + ios/mob_nif.m | 135 +++++++++++++++++---------------- test/native/Makefile | 9 ++- test/native/ios_nif_compile.sh | 37 +++++++++ 6 files changed, 132 insertions(+), 68 deletions(-) create mode 100755 test/native/ios_nif_compile.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d3adc7e5..b933ba59 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -137,6 +137,14 @@ jobs: # themselves. Same toolchain as the clang-format step. run: make -C test/native run + - name: Install Erlang (erl_nif.h for the iOS NIF compile) + run: brew install erlang + + - name: iOS NIF compiles in debug and release (MOB-504) + # mob 0.9.26 shipped a release nif_funcs entry for a function defined + # only in debug builds. Only compiling the release variant catches that. + run: make -C test/native ios-nif + - name: Install swiftlint run: brew install swiftlint diff --git a/CHANGELOG.md b/CHANGELOG.md index 38f6b7b3..79a9b3e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,14 @@ Full module documentation: [hexdocs.pm/mob](https://hexdocs.pm/mob). iPad and iPhone simulators, not yet on a physical iPad. See `decisions/2026-10-02-one-router-per-window-scene.md`. +### Fixed +- **iOS release builds compile again (MOB-504).** In 0.9.26, `nif_dismiss_keyboard` and + the first-responder walk it uses were defined inside the debug-only test-harness block + (`#if !MOB_RELEASE`), while their `nif_funcs` entries were not, so every iOS release + build failed with "use of undeclared identifier 'nif_dismiss_keyboard'". Both now sit + outside the harness block. CI now compiles `ios/mob_nif.m` in debug, release, and + release with `MOB_ENABLE_SCREENSHOT` (`make -C test/native ios-nif`). + ## [0.9.26] - 2026-10-24 ### Added diff --git a/RELEASE.md b/RELEASE.md index 76633289..a9a2d461 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -192,6 +192,9 @@ Per-repo extras: repo that ships the scanner. The `hex_deps` layer applies to mob_dev itself; the gradle / swift / bundled_runtime layers no-op (mob_dev has no native surface). +- **`mob`**: also run `make -C test/native ios-nif` on a Mac. It compiles + `ios/mob_nif.m` in its release variants, which nothing else builds + before an app's own release build (MOB-504). - **`mob_new`**: generator tests need `MOB_DIR=/Users/kevin/code/mob` when running from a worktree; the path resolver looks for `mob` alongside the project. diff --git a/ios/mob_nif.m b/ios/mob_nif.m index 628bccd2..ea5f3161 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -5840,6 +5840,75 @@ void mob_notify_set_screen_pid(ErlNifPid pid) { }); } +// Shared with the debug-only harness below (delete_backward, key_press), but +// outside its guard: dismiss_keyboard is public API and ships in release +// builds (MOB-504). +static id find_first_responder_in(UIView *view) { + if (view.isFirstResponder) + return view; + for (UIView *sub in view.subviews) { + id fr = find_first_responder_in(sub); + if (fr) + return fr; + } + return nil; +} + +// ─── dismiss_keyboard/0,1 — take the keyboard down from the BEAM ───────────── +// +// Resigns the first responder found in a visible window — the walk +// delete_backward and key_press use — which is the view the keyboard is up +// for. Nothing focused is not an error; the keyboard is already down. +// +// Which windows (MOB-245): dismiss_keyboard/1 takes the scene id of the +// calling screen and looks only in that window scene. dismiss_keyboard/0 is +// the unbound primary's: it looks only in the primary's window (tap set 0), +// and nowhere while that window is detached and another is attached, so one +// window's handler never closes another window's keyboard. With no scene +// attached at all it looks in every connected scene, as before scenes. +// +// Waits (bounded) for the main thread to do it, so it is IO_BOUND. The caller +// is a handler that usually re-renders too, and MobTextField adopts a new +// `value:` only once it is no longer focused. Queued fire-and-forget, the +// handler's render reached the field while SwiftUI still had it focused, and +// the value assigned next to the dismiss was dropped in most runs after an +// edit (iPhone Air simulator, iOS 27.0). Returning once the resign has run +// queues the re-render behind it, and the value then landed every time. +// {error, timeout} if the main thread did not get to it within 2 s. + +static ERL_NIF_TERM nif_dismiss_keyboard(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + NSString *sceneId = argc == 1 ? mob_scene_id_from_term(env, argv[0]) : nil; + if (argc == 1 && !sceneId) + return enif_make_badarg(env); + dispatch_semaphore_t done = dispatch_semaphore_create(0); + dispatch_async(dispatch_get_main_queue(), ^{ + BOOL everywhere = !sceneId && mob_scene_attached_count() == 0; + UIWindowScene *only = sceneId ? mob_window_scene_for(sceneId) : mob_primary_window_scene(); + id fr = nil; + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + if (!everywhere && scene != only) + continue; + for (UIWindow *win in [(UIWindowScene *)scene windows]) { + if (win.isHidden) + continue; + fr = find_first_responder_in(win); + if (fr) + break; + } + if (fr) + break; + } + [fr resignFirstResponder]; + dispatch_semaphore_signal(done); + }); + dispatch_time_t deadline = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)); + if (dispatch_semaphore_wait(done, deadline) != 0) + return enif_make_tuple2(env, enif_make_atom(env, "error"), enif_make_atom(env, "timeout")); + return enif_make_atom(env, "ok"); +} + // ════════════════════════════════════════════════════════════════════════════ // TEST HARNESS — compiled out of release builds (MOB_RELEASE). // ════════════════════════════════════════════════════════════════════════════ @@ -7737,17 +7806,6 @@ static ERL_NIF_TERM nif_tap_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv #endif } -static id find_first_responder_in(UIView *view) { - if (view.isFirstResponder) - return view; - for (UIView *sub in view.subviews) { - id fr = find_first_responder_in(sub); - if (fr) - return fr; - } - return nil; -} - // ─── delete_backward/0 — delete one character behind the cursor ────────────── // // Calls deleteBackward: on the current first responder. Equivalent to pressing @@ -7786,61 +7844,6 @@ static ERL_NIF_TERM nif_delete_backward(ErlNifEnv *env, int argc, const ERL_NIF_ enif_make_atom(env, "no_first_responder")); } -// ─── dismiss_keyboard/0,1 — take the keyboard down from the BEAM ───────────── -// -// Resigns the first responder found in a visible window — the walk -// delete_backward and key_press use — which is the view the keyboard is up -// for. Nothing focused is not an error; the keyboard is already down. -// -// Which windows (MOB-245): dismiss_keyboard/1 takes the scene id of the -// calling screen and looks only in that window scene. dismiss_keyboard/0 is -// the unbound primary's: it looks only in the primary's window (tap set 0), -// and nowhere while that window is detached and another is attached, so one -// window's handler never closes another window's keyboard. With no scene -// attached at all it looks in every connected scene, as before scenes. -// -// Waits (bounded) for the main thread to do it, so it is IO_BOUND. The caller -// is a handler that usually re-renders too, and MobTextField adopts a new -// `value:` only once it is no longer focused. Queued fire-and-forget, the -// handler's render reached the field while SwiftUI still had it focused, and -// the value assigned next to the dismiss was dropped in most runs after an -// edit (iPhone Air simulator, iOS 27.0). Returning once the resign has run -// queues the re-render behind it, and the value then landed every time. -// {error, timeout} if the main thread did not get to it within 2 s. - -static ERL_NIF_TERM nif_dismiss_keyboard(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - NSString *sceneId = argc == 1 ? mob_scene_id_from_term(env, argv[0]) : nil; - if (argc == 1 && !sceneId) - return enif_make_badarg(env); - dispatch_semaphore_t done = dispatch_semaphore_create(0); - dispatch_async(dispatch_get_main_queue(), ^{ - BOOL everywhere = !sceneId && mob_scene_attached_count() == 0; - UIWindowScene *only = sceneId ? mob_window_scene_for(sceneId) : mob_primary_window_scene(); - id fr = nil; - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) - continue; - if (!everywhere && scene != only) - continue; - for (UIWindow *win in [(UIWindowScene *)scene windows]) { - if (win.isHidden) - continue; - fr = find_first_responder_in(win); - if (fr) - break; - } - if (fr) - break; - } - [fr resignFirstResponder]; - dispatch_semaphore_signal(done); - }); - dispatch_time_t deadline = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)); - if (dispatch_semaphore_wait(done, deadline) != 0) - return enif_make_tuple2(env, enif_make_atom(env, "error"), enif_make_atom(env, "timeout")); - return enif_make_atom(env, "ok"); -} - // ─── key_press/1 — send a special key to the focused text input ─────────────── // // Accepts an atom: diff --git a/test/native/Makefile b/test/native/Makefile index 0930487e..7cb7bac1 100644 --- a/test/native/Makefile +++ b/test/native/Makefile @@ -1,6 +1,8 @@ # Native-side checks that can run on the host (no simulator, no XCTest target). # -# make run build + run everything here +# make run build + run everything here +# make ios-nif compile ios/mob_nif.m in its debug and release variants +# (needs Xcode and Erlang; see ios_nif_compile.sh) # make clean # # Each test file's header says what it does and does not cover. @@ -11,10 +13,13 @@ LDFLAGS ?= -framework Foundation BINS := frame_registry_test dist_port_test dist_cookie_test stored_queue_test init_args_test node_host_test code_versions_test erts_dir_test -.PHONY: run clean +.PHONY: run ios-nif clean run: $(BINS) @for b in $(BINS); do echo "== $$b"; ./$$b || exit 1; done +ios-nif: + ./ios_nif_compile.sh + frame_registry_test: frame_registry_test.m $(CC) $(CFLAGS) $(LDFLAGS) $< -o $@ diff --git a/test/native/ios_nif_compile.sh b/test/native/ios_nif_compile.sh new file mode 100755 index 00000000..2d290101 --- /dev/null +++ b/test/native/ios_nif_compile.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Compiles ios/mob_nif.m (syntax only) in each build variant an app can ship: +# debug, release, and release with the screenshot opt-in. Most of the file +# sits behind `#if !MOB_RELEASE`, and only a real compile catches a release +# table entry that names a debug-only function: MOB-504 shipped in mob 0.9.26 +# because nothing built the release variant. +# +# test/native/ios_nif_compile.sh (from the repo root, or `make -C test/native ios-nif`) +# +# Needs Xcode (the iphoneos SDK) and an Erlang install for erl_nif.h; set +# ERL_INCLUDE to use another include dir. The Swift sources are compiled once +# to produce the MobApp-Swift.h header mob_nif.m imports. +set -euo pipefail + +cd "$(dirname "$0")/../.." +out=$(mktemp -d) +trap 'rm -rf "$out"' EXIT + +sdk=$(xcrun --sdk iphoneos --show-sdk-path) +target=arm64-apple-ios17.0 +erl_include=${ERL_INCLUDE:-$(erl -noshell -eval \ + 'io:format("~s", [filename:join([code:root_dir(), "erts-" ++ erlang:system_info(version), "include"])]), halt().')} + +xcrun swiftc -sdk "$sdk" -target "$target" -module-name MobApp -parse-as-library \ + -import-objc-header ios/MobDemo-Bridging-Header.h -I"$erl_include" -Iios \ + -emit-module -emit-module-path "$out/MobApp.swiftmodule" \ + -emit-objc-header-path "$out/MobApp-Swift.h" ios/*.swift + +status=0 +for flags in "" "-DMOB_RELEASE=1" "-DMOB_RELEASE=1 -DMOB_ENABLE_SCREENSHOT"; do + echo "== mob_nif.m ${flags:-(debug)}" + # shellcheck disable=SC2086 # $flags is a list of separate -D options + xcrun clang -fsyntax-only -fobjc-arc -fmodules -x objective-c \ + -target "$target" -isysroot "$sdk" -I"$erl_include" -Iios -I"$out" \ + $flags ios/mob_nif.m || status=1 +done +exit $status From 2581f6c8dd19d99ea7cb3466bbdd10a8eb514b6b Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sat, 24 Oct 2026 19:04:15 -0600 Subject: [PATCH 2/3] ci: compile the iOS NIF with the runner's newest Xcode 26 --- .github/workflows/test.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b933ba59..5cd2758b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -143,7 +143,15 @@ jobs: - name: iOS NIF compiles in debug and release (MOB-504) # mob 0.9.26 shipped a release nif_funcs entry for a function defined # only in debug builds. Only compiling the release variant catches that. - run: make -C test/native ios-nif + # The Swift sources need the iOS 26 SDK (glassEffect), and the runner's + # default Xcode is older, so pick its newest Xcode 26. + run: | + xcode=$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1) + if [ -z "$xcode" ]; then + echo "::error::no Xcode 26 on this runner"; ls -d /Applications/Xcode*.app; exit 1 + fi + echo "Using $xcode" + DEVELOPER_DIR="$xcode/Contents/Developer" make -C test/native ios-nif - name: Install swiftlint run: brew install swiftlint From 0005f0b96d3015ae555577c91d65abddae85c7f3 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sat, 24 Oct 2026 19:14:49 -0600 Subject: [PATCH 3/3] A deploy's reload repaints every window (MOB-245 follow-up from the 0.9.27 gate) mob_dev sends :__mob_hot_reload__ as a bare message to :mob_screen, which names one window's router; further windows kept drawing the old code until their next render. The router now handles the message itself: it repaints its own screens and casts the reload to every other router Mob.Scenes lists, from a spawned process so it never waits on Mob.Scenes. Also fixes the Mob.Test moduledoc example: screens/1 reports native's scene id on iOS. --- CHANGELOG.md | 4 +++- lib/mob/router.ex | 16 ++++++++++++++++ lib/mob/test.ex | 2 +- test/mob/scenes_test.exs | 12 ++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79a9b3e5..f331fc36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,7 +47,9 @@ Full module documentation: [hexdocs.pm/mob](https://hexdocs.pm/mob). window only when that window's session is gone (`{:mob_scene, :replaced, ...}`), and a kept window whose session is discarded stops (`{:mob_scene, :discarded, id}`). The by-tag press helpers - (`press_in/3`, `press_out/3`, `hold/4`) take `scene:` too. Opt in with + (`press_in/3`, `press_out/3`, `hold/4`) take `scene:` too. The reload + `mix mob.deploy` sends to `:mob_screen` after a hot push repaints every + window, not only the one whose router holds the name. Opt in with `config :mob_dev, multi_window: true` (mob_dev#114 stamps `UIApplicationSupportsMultipleScenes`). Needs a native rebuild. Run on the iPad and iPhone simulators, not yet on a physical iPad. See diff --git a/lib/mob/router.ex b/lib/mob/router.ex index 4f19db80..f22bb5e5 100644 --- a/lib/mob/router.ex +++ b/lib/mob/router.ex @@ -559,6 +559,22 @@ defmodule Mob.Router do {:noreply, state} end + # mob_dev sends a deploy's reload as a bare message to :mob_screen, which + # names one window's router. Every window repaints with the new code: this + # router's screens, and every other window's router (MOB-245). Asked from a + # separate process so this router never waits on Mob.Scenes. + def handle_info(:__mob_hot_reload__, state) do + me = self() + + spawn(fn -> + for {_scene, router} <- Mob.Scenes.list(), + router != me, + do: GenServer.cast(router, :__mob_hot_reload__) + end) + + handle_cast(:__mob_hot_reload__, state) + end + # Anything else addressed to :mob_screen — device events, notifications, # plugin messages — belongs to the screen the user is looking at. def handle_info(message, state) do diff --git a/lib/mob/test.ex b/lib/mob/test.ex index a380294f..7727646f 100644 --- a/lib/mob/test.ex +++ b/lib/mob/test.ex @@ -16,7 +16,7 @@ defmodule Mob.Test do Mob.Test.tree(node) #=> %{type: :column, ...} Mob.Test.find(node, "Save") #=> [{[0, 2], %{...}}] Mob.Test.inspect(node) #=> %{screen: ..., assigns: ..., tree: ...} - Mob.Test.screens(node) #=> [{nil, MyApp.HomeScreen, #PID<...>}] + Mob.Test.screens(node) #=> [{"8A3F…", MyApp.HomeScreen, #PID<...>}] # Interaction Mob.Test.tap(node, :increment) # tap a button by tag diff --git a/test/mob/scenes_test.exs b/test/mob/scenes_test.exs index 7a30c7cb..eda9c58d 100644 --- a/test/mob/scenes_test.exs +++ b/test/mob/scenes_test.exs @@ -396,6 +396,18 @@ defmodule Mob.ScenesTest do refute_receive {:alert, _, _}, 50 end + test "a deploy's reload, sent to :mob_screen, repaints every window" do + boot(["A"]) + connect("B") + flush_frames() + + # What mob_dev's deployer sends after a hot push. + send(:mob_screen, :__mob_hot_reload__) + + assert_receive {:frame, :default, nil, %{"props" => %{"text" => "home 0"}}} + assert_receive {:frame, "B", "B", %{"props" => %{"text" => "home 0"}}} + end + test "the back gesture steps the WebView history of its own window only" do boot(["A"]) connect("W")