Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,22 @@ 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.
# 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

Expand Down
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,22 @@ 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
`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
Expand Down
3 changes: 3 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
135 changes: 69 additions & 66 deletions ios/mob_nif.m
Original file line number Diff line number Diff line change
Expand Up @@ -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).
// ════════════════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions lib/mob/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/mob/test.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions test/mob/scenes_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
9 changes: 7 additions & 2 deletions test/native/Makefile
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 $@

Expand Down
37 changes: 37 additions & 0 deletions test/native/ios_nif_compile.sh
Original file line number Diff line number Diff line change
@@ -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
Loading