diff --git a/CHANGELOG.md b/CHANGELOG.md index b53fd384..38f6b7b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,49 @@ Full module documentation: [hexdocs.pm/mob](https://hexdocs.pm/mob). ## [Unreleased] +### Added +- **Several windows of one app on iPad, one BEAM (MOB-245, iPad half).** Each + `UIWindowScene` gets its own `Mob.Router`, navigation and screens; the app's + root router keeps `:mob_screen` and renders to native's default scene, so a + single-window app runs the same path as before. New `Mob.Scenes` (scene + registry and lifecycle), `Mob.Scene` (`request_new/0`, `supported?/0`, + `of/1`), `Mob.Test.screens/1`, `scene:` on every `Mob.Test` helper that + addresses the current screen, and `Mob.Test.MultipleScenesError` when + several windows are live and no `scene:` is given. Native: a tap set and + view model per scene, scene-routed back gesture, size class and alert + results, scene-aware `safe_area/1`, `size_class/1`, `alert_show/4`, + `action_sheet_show/3`, `clear_taps/1`, `scenes/0`, `scene_request/0`, + `scene_multiple_supported/0`. Only application-role window scenes count, + so an AirPlay or cable display is not a second window. An alert or action + sheet appears in the window of the screen that showed it, even when another + window is in front. `Mob.WebView`, the back gesture's WebView history step + and `Mob.Keyboard.dismiss/1` act on the calling screen's window + (`webview_*` and `dismiss_keyboard` NIFs gained a scene arity; with window + scenes attached, `dismiss_keyboard/0` now looks only in the primary's + window, not in any visible one), and on + that window's shown WebView, never the previous screen's parked one. A + WebView's messages and blocked URLs carry the scene its tree was built for, + so they reach that window's screen even before the view is in a window. + Every window event with a known scene, the window-connected notice + included, goes through `Mob.Scenes`, which drops one no router shows. + `scene: nil` in `Mob.Test` addresses the window the app started in. Launch + notifications and links (`Mob.Link`) go to the root router only, and to + whichever router takes over `:mob_screen`. A frame in flight for a window + that closes is dropped rather than shown in the window that takes over its + tap set, and a closing window's router is stopped without holding up the + other windows' events. A + further window's router mounts its root screen after starting, so a + `mount/3` may call `Mob.Scene.list/0`; `:mob_screen` moves to a remaining + window before the closing primary stops; a new session takes over a kept + 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 + `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`. + ## [0.9.26] - 2026-10-24 ### Added diff --git a/decisions/2026-10-02-one-router-per-window-scene.md b/decisions/2026-10-02-one-router-per-window-scene.md new file mode 100644 index 00000000..7c4e74cf --- /dev/null +++ b/decisions/2026-10-02-one-router-per-window-scene.md @@ -0,0 +1,326 @@ +# One router per window scene, one BEAM for all of them + +- Date: 2026-10-02 +- Status: accepted (iPad half; run on iPad and iPhone simulators, see Verification) +- Tickets: MOB-245 (multi-instance, iPad half), epic MOB-200 (iPhone Duo). + Android follow-up filed separately (see Android). + +## Context + +iPadOS has run several windows of one app since iOS 13: each window is a +`UIWindowScene`, and the user opens more from the app switcher, Split View, +Stage Manager or the app's own "New Window" affordance. iPhone Duo inherits +the same model with iOS 27.1 (`decisions/2026-10-01-fold-aware-layouts.md` +item 8: `UIWindowSceneActivationAction` and +`UIWindowSceneActivationInteraction` are iOS 15 API, so nothing here needs +27.1). + +mob assumed one window: + +- `Mob.Router` is the navigation owner. It registers `:mob_screen`, which + native resolves with `enif_whereis_pid` for the back gesture, alert results, + size-class changes, `{:mob_window, :connected}` and notifications, and + which `Mob.Test` sends and calls into. `Mob.Test.screen/1` returns *the* + screen. +- `Mob.Sender` commits the tree of one active screen ref. Everything else is + dropped as "not on screen" (`decisions/2026-08-28-screen-processes-and-supervision.md`, + `decisions/2026-08-29-router-off-the-hot-path.md`). +- `set_root` has no window key. `ios/mob_nif.m` keeps one double-buffered tap + registry, one `g_transition`, and pushes every tree to the singleton + `MobViewModel.shared`, which every `MobHostingController` observes. Two + windows would show the same tree, and a render for one would invalidate the + other's tap handles. +- The mob_new template hardcodes `UIApplicationSupportsMultipleScenes=false`. + +## Decision + +### 1. A scene id is native's, and it is `UISceneSession.persistentIdentifier` + +The scene id is the session's `persistentIdentifier`, a string. iPadOS keeps +the session when it discards a backgrounded scene to save memory and hands +the same identifier back when the user returns, so the id survives +disconnect/reconnect. Native assigns it; the BEAM never invents one. Elixir +carries it as a binary. Platforms with no scenes (Android today) and a router +that has not met a scene yet report `nil`. + +### 2. One `Mob.Router` per scene, not one router with per-scene graphs + +Each scene gets its own `Mob.Router`, with its own navigation stacks and its +own `Mob.Screen.Server` processes. All of them run in the one BEAM and share +`Mob.State`, `Mob.Sender`, `Mob.Listener`, plugins and the app's processes. + +One router holding a map of graphs was the alternative. It would put every +window's navigation, restarts and crash recovery in one process and one +state map, and every router function would grow a scene argument. A router +per scene reuses the router as it is: navigation, restart ceilings, +`on_no_live_screen`, nav hooks and the MOB-112 isolation all apply per window +unchanged, and a window whose router is wedged in a slow navigation does not +stall the other. The cost is a registry, which is item 3. + +### 3. `Mob.Scenes` is the registry and owns the lifecycle + +`Mob.Scenes` (a named GenServer, started by the first rendering router like +`Mob.Sender`) maps scene ids to routers: + +- **The primary router** is the one the app starts with + `Mob.Screen.start_root/2` in `on_start/0`. It registers with `Mob.Scenes` + and hands over its root module and params: the template every later scene + starts from. +- **Native reports scenes**: `{:mob_scene, :connected, id, default?}` and + `{:mob_scene, :disconnected, id}`, from `UISceneWillConnectNotification` / + `UISceneDidDisconnectNotification` observers mob installs itself (no + template change needed). Only application-role window scenes count: an + external display's scene is not a window of the app. Native can't reach the + BEAM before it is up, so `Mob.Scenes` also pulls `mob_nif:scenes/0` when it + starts; both paths are idempotent, and a scene already recorded is not + announced to its router again. +- **A connecting scene** that already has a router keeps it; native tells + that router once the window exists (item 5). Otherwise, if it is native's + *default* scene and the primary is unbound, it is the primary's window. + Otherwise `Mob.Scenes` starts a new router, bound to the id, from the + primary's root module and params. The router mounts its root screen after + it has started (`handle_continue`), so `Mob.Scenes` never waits on an + app's `mount/3`: a mount may call `Mob.Scene.list/0`, and every window's + events pass through `Mob.Scenes`. +- **A session replacing the kept one** (item 6) arrives as `{:mob_scene, + :replaced, old_id, new_id, default?}` and moves the old id's router to the + new id. A plain `connected` for an unknown id never adopts a kept router. +- **`Mob.Scenes.list/0`** and **`screens/0`** answer which scenes exist and + what each shows. + +### 4. The primary router is unbound and renders to native's default scene + +The primary router never carries a scene id. It renders exactly as before: +`clear_taps/0`, a JSON root with no `"scene"` key, `set_root/1`. Native sends +such a frame to its **default scene**: the scene holding tap set 0 and +`MobViewModel.shared`, which is the first scene to connect when none is +attached. A single-window app (every iPhone, every iPad app without the +opt-in, Android) therefore runs the same code path as before this change, +natively and in `Mob.Sender`. + +Routers `Mob.Scenes` starts for further scenes are **bound**: their screens +render through `clear_taps/1` with the scene id, and `Mob.Renderer` adds +`"scene": id` to the JSON root. Native looks the id up and builds, commits +and displays into that scene's own tap set and view model. + +`Mob.Sender` keeps one active ref per scene: the existing fields hold the +unbound (default) scene, and a `scenes` map holds each bound scene's active +ref, reserved transition, activation gate and active module. A flush commits +the pending tree of every active ref, each to its own scene. Two windows +navigating at once each keep their transition. + +### 5. `:mob_screen` stays, and means "the primary scene's router" + +`:mob_screen` stays registered to the primary router, so native callers, +`Mob.Test` and plugins keep working unchanged in a single-scene app. When the +primary's window closes for good while other windows remain, `Mob.Scenes` +first makes sure every remaining window has a router, registers +`:mob_screen` to the oldest remaining one (which then drains notifications +native stored meanwhile) and only then stops the primary, so app-wide +messages and `Mob.Test` without `scene:` always find a live router. + +Native routes the events that belong to a window by the window they came +from: + +- **Back gesture, size class, alert results, WebView messages and the + window-connected notice** come from a known scene (`MobHostingController`, + `MobRootView`, the presenting alert, the scene a WebView's tree was built + for, and the window that just connected carry it). Whenever the scene id is + known and `Mob.Scenes` runs, native sends `{:mob_scene_event, id, message}` to + `Mob.Scenes`, which forwards `message` to the router showing that scene, + however many windows are attached: with the primary's window kept but + detached, the one attached window may be a newer one with its own router, + and `:mob_screen` would hand its events to the primary. An event for a + scene no router shows (a window over the tap-set limit, or one whose router + failed to start) is dropped; only before the primary has registered does + it go to `:mob_screen`. Without a scene id, or without `Mob.Scenes` (a mob + from before this change), native sends to `:mob_screen` as before. +- **Alerts** and action sheets shown by a screen in a bound scene go through + `alert_show/4` and `action_sheet_show/3`, which take the scene id, present + on that scene's window and route the result back to it. Unbound screens + use the existing NIFs, which present on the primary's window (the scene + holding tap set 0) even while another window is in front, and nowhere + while that window is detached and another is attached. With no scene + attached at all they present as before scenes. +- **WebView and keyboard calls** act on the calling screen's window. + `Mob.WebView.eval_js/2`, `post_message/2`, `Mob.Keyboard.dismiss/1` and + the router's back handling pass a bound screen's scene id + (`webview_eval_js/2`, `webview_post_message/2`, `webview_can_go_back/1`, + `webview_go_back/1`, `dismiss_keyboard/1`); native looks up the WebView or + first responder in that window only. The original arities are the unbound + primary's and look only in its window, so a back swipe in one window never + steps another window's WebView history. Of a window's WebViews, only one on + the shown screen counts: the two-slot presenter keeps the previous screen + parked (MOB-129), and its page is not the one back, JS or a posted message + is meant for. A WebView's messages and blocked URLs carry the scene its + tree was built for, captured when the view is made rather than read from + its window, which is nil before the view is attached and while it is torn + down. +- **`{:mob_window, :connected}`** comes from native's SceneDelegate + (`mob_notify_window_connected`) once the window exists, as an event of the + scene whose window it is, so it reaches that window's router only. Before, + it went to `:mob_screen`, and with the primary's window detached a new + window's connection made the primary re-read its insets from the new + window. `Mob.Scenes` does not send it itself, so no router hears it twice. +- **Size class and safe area reads** at mount take the scene id + (`size_class/1`, `safe_area/1`) for a bound screen. The unbound reads + prefer the default scene's window, so a second window cannot hand the + primary its insets. +- **Notifications and links are app-wide.** They go to `:mob_screen`, i.e. + the primary scene's current screen, unless a process registered through + `mob_notify` (or `Mob.Link.register/1`), as before. Only the primary takes + what native stored at launch, and a router that takes over `:mob_screen` + takes what native stored while the name moved. +- **Taps need no routing.** `Mob.Listener` already delivers each tap to the + screen pid baked into its handle; what changes is that each scene has its + own native tap set, so one window's render no longer invalidates the + other's handles. Native event blocks capture the tap set they were built + for, so a handle resolves only against its own window's table, including + the identity fallback text fields use (a handle from an older frame whose + slot still routes to the same place). + +### 6. Disconnect: stop the window's router unless it is the last + +iPadOS disconnects a scene both when the user closes a window and when it +discards a background one to reclaim memory; the app cannot tell which at +that point. When a scene disconnects: + +- If other scenes remain attached, its router is stopped. Its screens + terminate normally, so persisted screens dump their state. The stop runs + off `Mob.Scenes` (killed after the stop timeout if wedged), so a router + slow to stop holds up no other window's events, and the scene's + `Mob.Sender` slot goes once it has stopped. An unbound primary's slot goes + before it stops: its frames carry no scene and native would commit them + into tap set 0, which the next window to connect may take. A scene with no + router (out of restarts) has its slot dropped when it disconnects. If the + scene comes back (same id), `Mob.Scenes` starts a fresh router for it from + the root template: navigation inside a discarded secondary window is not + restored. +- If no other scene is attached (decided by attached scenes, as native does, + not by how many routers exist), its router is kept, bound and alive, so a + reconnect of the same id shows the same screens with their assigns, as a + single-window app does today. +- If a different id connects while no scene is attached, native hands it the + kept entry's tap set and model only when the kept entry's session is gone + from `UIApplication.openSessions` (the user really discarded it), and says + so with `replaced`; the kept router then shows the new id. Otherwise the + kept session may still come back (iPadOS restoring a split of discarded + windows in any order), so the new id gets a fresh set and router, and the + kept ones wait for their own id. +- A kept entry whose session native finds gone from `openSessions` (checked + whenever a scene attaches or activates) is dropped natively and reported as + `{:mob_scene, :discarded, id}`; its router stops, unless it is the app's + only router, and `:mob_screen` moves on as above if it held it. + +Native mirrors this: a disconnecting scene with others still attached +releases its tap set (every per-slot field reset) and model; the last one +keeps them. A release bumps the set's epoch, which `clear_taps` records for +the frame it starts: `set_root` commits the frame's handlers only if the +epoch is unchanged (checked under the lock the release takes), and the main +thread shows its tree only if it is still unchanged then (releases run on the +main thread too). A frame already in flight for a window that closes is +therefore dropped, not shown with live handlers in the window that takes +over its set number. + +### 7. `Mob.Test` addresses a scene with `scene:` + +- `Mob.Test.screens(node)` returns `[{scene_id, screen_module, pid}]`, one + entry per router, primary first. On a single-scene app it is one entry (the + scene id is `nil` on Android). +- `Mob.Test.screen/1`, and every helper that addresses "the" screen + (`assigns`, `inspect`, `tree`, `find`, `tap`, `back`, `select`, + `send_message`, the navigation helpers), keeps working with one scene and + raises `Mob.Test.MultipleScenesError` when several are live and no + `scene:` is given. Each takes `scene: id`, which addresses that scene's + router. Guessing a window for an agent would turn a wrong target into a + silent no-op. +- `Mob.Test.settle/2` settles every scene. +- `Mob.ScreenCase` stays single-scene: it mounts one screen with no router. + +### 8. Opening a window + +`Mob.Scene.request_new/0` asks iOS for a new window +(`activateSceneSessionForRequest:errorHandler:`, iOS 17). It returns `:ok`, +or `{:error, :unsupported}` when the app or device does not support multiple +scenes; an asynchronous refusal (Duo's outer display, a system limit) arrives +as `{:mob_scene, :request_failed, reason}` at the caller. +`Mob.Scene.supported?/0` answers whether a "New Window" button should be +shown at all. A native `UIWindowSceneActivationAction` affordance would hide +itself automatically, but mob's UI is SwiftUI rendered from the BEAM, so the +documented API is the affordance for now. + +The opt-in is `config :mob_dev, multi_window: true` in `mob.exs`. mob_dev +stamps `UIApplicationSupportsMultipleScenes` into the built bundle's +Info.plist with PlistBuddy, the way it already applies `ios_target_devices` +(`MobDev.IosLayoutPlist`). The template's `ios/Info.plist` keeps `false`, so +existing apps are unaffected until they opt in. + +## Consequences + +- **Persisted screen state is per module, shared by scenes.** Two windows + showing the same `persist: true` screen dump to the same `Mob.ScreenState` + key; the last dump wins and a new window's screen restores it. +- **Native-reading test helpers read the default scene.** `view_tree/1`, + `screenshot/2`, `element_frames/1` and `tap_id/2` were written against one + window. Secondary scenes do not take part in the element frame registry or + the navigation frame generation; giving them their own is follow-up work. +- **Share sheet, file picker and toast pick their own window.** + `share_text`, `files_pick` and `toast_show` present in the first, the + default or the front window, not the calling screen's, so one opened from + a second window can appear in another. A picked file still reaches the + calling pid. Giving them scene arities, as `alert_show/4` does for alerts + in this change, is follow-up work (MOB-503). +- **`on_no_live_screen` applies to the primary only.** A secondary router + whose screen cannot be restarted leaves that window on its last frame + instead of ending the app. +- **A tap set per scene is capped** (`MOB_SCENE_LIMIT` in `mob_nif.m`). A + scene beyond the cap renders nothing and logs why. +- **Hot code push.** `Mob.Sender` reads its new `scenes` field with a + default, so a sender started before this change keeps working; routers + started before it are unbound primaries, which is what they were. + +## Android + +Out of scope for this change. `g_activity` (a single global `jobject` in the +generated `beam_jni.c`) and `android:launchMode="singleTop"` assume one +activity; what multi-instance would take there is written up in the +follow-up issue. The Elixir model above is platform-neutral: an Android +implementation reports activities as scenes and implements the scene NIFs. + +## Verification + +Host tests cover the routing, `Mob.Scenes` lifecycle, `Mob.Sender`'s +per-scene commits, `Mob.Test.screens/1`, `MultipleScenesError` and `scene:`, +and fail with the change reverted (with `Mob.Scenes.register_primary/4` +removed from the router, 25 of the 29 tests in `test/mob/scenes_test.exs` +fail). + +On the iPad Pro 11-inch (M5) simulator (iOS 27.0), with a `mix mob.new` app +built with `multi_window: true`: `Mob.Scene.request_new/0` opened a second +window in the same BEAM, and `Mob.Test.screens/1` listed both. `tap/3`, +`navigate/4` and `assigns/2` with `scene:` acted on that window only, and +leaving out `scene:` raised `MultipleScenesError`. A real tap in the front +window reached only its screen. Each window's edge-pan handler popped only +its own stack. A horizontal size class override on one window's hosting +controller reached only that window's `size_class`. Each window's alert +appeared in its own window and returned its result there, and so did a +sheet's dismissal. With the second window closed, navigation, a sheet and an +alert worked as before. The run found one bug, now fixed: an alert from the +unbound primary was shown in whichever window was in front. On the iPhone 18 +Pro simulator, `Mob.Test.screens/1` returned one entry, and +`screen/1`, `tap/2`, navigation, the edge-pan back, a sheet and an alert +worked unchanged. Synthetic touches (XCUITest) don't fire +`UIScreenEdgePanGestureRecognizer`, so the back runs invoked the hosting +controller's handler with the recognizer in `.ended` from lldb. + +A second iPad run checked the routing fixes, calling `mob_handle_back`, +`mob_notify_size_class` and the alert action's handler from lldb. With the +primary's window detached and kept and a new window the only one attached, +that window's back popped only its own stack, its size class reached only +it, its alert showed in it and answered it, and an alert from the primary +showed nowhere. With two windows, the primary's alert showed in its own +background window, not the front one. A back in one window stepped that +window's WebView history and left the other's alone, and a page's +`window.mob.send` reached only its window's screen. The single-window iPad +and the iPhone 18 Pro (`supported?/0` false, one `screens/1` entry) behaved +as before, including a WebView message through the original arities. diff --git a/ios/MobDemo-Bridging-Header.h b/ios/MobDemo-Bridging-Header.h index 960c4b90..6d5d72f4 100644 --- a/ios/MobDemo-Bridging-Header.h +++ b/ios/MobDemo-Bridging-Header.h @@ -5,14 +5,29 @@ #import "MobNode.h" -// Called from MobHostingController to signal a back gesture to the BEAM. -// Implemented in mob_nif.m; looks up :mob_screen and sends {:mob, :back}. -void mob_handle_back(void); +// Called from MobHostingController to signal a back gesture to the BEAM, with +// the window scene id it happened in (NULL before the controller knows it). +// Implemented in mob_nif.m; sends {:mob, :back} to that window's router. +void mob_handle_back(const char *scene_id); -// Called from MobRootView.swift WebView delegate when JS sends a message or a URL is blocked. -// Implemented in mob_nif.m; looks up :mob_screen and sends the appropriate tuple. -void mob_deliver_webview_message(const char *json_utf8); -void mob_deliver_webview_blocked(const char *url_utf8); +// Registers window scene `scene` with mob (MOB-245) and returns the +// MobViewModel its root view shows: the shared one for the default scene, one +// of its own for every further window. Main thread. Idempotent; implemented in +// mob_nif.m. +NSObject *mob_scene_attach(UIWindowScene *scene); + +// Called from MobRootView.swift WebView delegate when JS sends a message or a +// URL is blocked, with the window scene id the WebView's tree was built for +// (NULL before scenes). Implemented in mob_nif.m; sends the tuple to that +// window's router. +void mob_deliver_webview_message(const char *scene_id, const char *json_utf8); +void mob_deliver_webview_blocked(const char *scene_id, const char *url_utf8); + +// Called from MobRootView.swift as a WebView is created or updated: registers +// it (held weakly) with whether its screen is shown or parked, so the webview +// NIFs find the one shown in a given window. Main thread. Implemented in +// mob_nif.m. +void mob_webview_attach(WKWebView *webview, BOOL shown); // Called from MobNativeViewRegistry.send closure when a native view fires an event. // Implemented in mob_nif.m; looks up the component pid by handle and delivers @@ -26,9 +41,10 @@ void mob_notify_color_scheme(const char *scheme); // Called from MobRootView.swift when its horizontal/vertical size class pair // first appears and whenever it changes (rotation, Split View, Slide Over, -// Stage Manager). Each is "compact" or "regular". Sends {:mob_size_class, H, V} -// to the :mob_screen router. Implemented in mob_nif.m. -void mob_notify_size_class(const char *horizontal, const char *vertical); +// Stage Manager). Each is "compact" or "regular"; `scene_id` is the root +// view's window scene (NULL before it knows). Sends {:mob_size_class, H, V} +// to that window's router. Implemented in mob_nif.m. +void mob_notify_size_class(const char *scene_id, const char *horizontal, const char *vertical); // Called from MobFrameTracker (SwiftUI) as a tagged element lays out, recording // its on-screen frame (logical points) keyed by the element's :id. Read back via diff --git a/ios/MobNode.h b/ios/MobNode.h index cc6664e6..1281ea73 100644 --- a/ios/MobNode.h +++ b/ios/MobNode.h @@ -15,9 +15,6 @@ // isn't activated — the symbol resolves to NULL and the preview shows black. extern AVCaptureSession *_Nullable g_preview_session __attribute__((weak)); -// Shared WebView — set by MobWebView when created, read by webview NIFs. -extern WKWebView *_Nullable g_webview; - typedef NS_ENUM(NSInteger, MobNodeType) { MobNodeTypeColumn, MobNodeTypeRow, diff --git a/ios/MobRootView.swift b/ios/MobRootView.swift index 1e54f3ec..0160c6d6 100644 --- a/ios/MobRootView.swift +++ b/ios/MobRootView.swift @@ -881,6 +881,10 @@ private struct MobFrameTracker: ViewModifier { // navigation destroyed the outgoing tree, so a tracker only ever existed on // the live screen and this question could not arise. @Environment(\.mobScreenIsActive) private var isActive + // Whether this window takes part in the element frame registry: only the + // default scene's does (MOB-245). A second window showing the same screen + // carries the same :ids and would overwrite the first window's frames. + @Environment(\.mobTracksFrames) private var tracksFrames func body(content: Content) -> some View { // A sheet's own switch-case view is a zero-size anchor used only to @@ -978,6 +982,7 @@ private struct MobFrameTracker: ViewModifier { } private func record(_ id: String, _ frame: CGRect) { + guard tracksFrames else { return } // Capture lazily as well as in onAppear: the ordering of onAppear // against onChange(initial: true) isn't contractual, and a write // stamped 0 is accepted rather than refused as stale. @@ -1740,10 +1745,18 @@ private struct MobWebView: View { private struct MobWKWebView: UIViewRepresentable { let node: MobNode + // Whether this WebView's screen is the one on show rather than the one the + // two-slot presenter keeps parked: the webview NIFs act on the shown one. + @Environment(\.mobScreenIsActive) private var isActive + // The window scene this tree was built for (MOB-245). Captured here, not + // read from webView.window in a callback: that is nil before the view is + // attached (the first load starts in makeUIView) and while it is torn down. + @Environment(\.mobSceneId) private var sceneId func makeCoordinator() -> Coordinator { Coordinator(node: node) } func makeUIView(context: Context) -> WKWebView { + context.coordinator.sceneId = sceneId let config = WKWebViewConfiguration() config.userContentController.add(context.coordinator, name: "mob") let shim = WKUserScript(source: kMobJsShimSwift, @@ -1752,7 +1765,7 @@ private struct MobWKWebView: UIViewRepresentable { config.userContentController.addUserScript(shim) let wv = WKWebView(frame: .zero, configuration: config) wv.navigationDelegate = context.coordinator - g_webview = wv + mob_webview_attach(wv, isActive) if let urlStr = node.webViewUrl, let url = URL(string: urlStr) { wv.load(URLRequest(url: url)) } @@ -1760,20 +1773,24 @@ private struct MobWKWebView: UIViewRepresentable { } func updateUIView(_ wv: WKWebView, context: Context) { - g_webview = wv + mob_webview_attach(wv, isActive) context.coordinator.node = node + context.coordinator.sceneId = sceneId } class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { var node: MobNode + var sceneId: String? init(node: MobNode) { self.node = node } - // JS → Elixir: window.mob.send(data) arrives here. - // Delegates to mob_deliver_webview_message() in mob_nif.m. + // JS → Elixir: window.mob.send(data) arrives here. Delegates to + // mob_deliver_webview_message() in mob_nif.m with the window scene this + // WebView's tree was built for, so the message reaches that window's + // screen (MOB-245). func userContentController(_ ucc: WKUserContentController, didReceive message: WKScriptMessage) { guard message.name == "mob", let json = message.body as? String else { return } - mob_deliver_webview_message(json) + mobWithSceneId(sceneId) { mob_deliver_webview_message($0, json) } } // URL whitelist enforcement. @@ -1790,7 +1807,7 @@ private struct MobWKWebView: UIViewRepresentable { decisionHandler(.allow) } else { decisionHandler(.cancel) - mob_deliver_webview_blocked(url) + mobWithSceneId(sceneId) { mob_deliver_webview_blocked($0, url) } } } } @@ -2251,11 +2268,28 @@ struct MobScreenIsActiveKey: EnvironmentKey { static let defaultValue: Bool = true } +/// Whether frame trackers in this tree write to the element frame registry, +/// which belongs to the default window scene (MOB-245). Set by `MobRootView`. +struct MobTracksFramesKey: EnvironmentKey { + static let defaultValue: Bool = true +} + +/// The window scene id this tree is shown in (MOB-245): the root view's model's +/// `sceneId`, nil before any scene exists. Set by `MobRootView`. +private struct MobSceneIdKey: EnvironmentKey { + static let defaultValue: String? = nil +} + extension EnvironmentValues { var mobScreenIsActive: Bool { get { self[MobScreenIsActiveKey.self] } set { self[MobScreenIsActiveKey.self] = newValue } } + + var mobTracksFrames: Bool { + get { self[MobTracksFramesKey.self] } + set { self[MobTracksFramesKey.self] = newValue } + } } private extension EnvironmentValues { @@ -2263,6 +2297,11 @@ private extension EnvironmentValues { get { self[MobAvailableSheetHeightKey.self] } set { self[MobAvailableSheetHeightKey.self] = newValue } } + + var mobSceneId: String? { + get { self[MobSceneIdKey.self] } + set { self[MobSceneIdKey.self] = newValue } + } } // A class, like MobFrameBox: the dismiss callback can run after this view is @@ -2841,7 +2880,9 @@ private struct MobImage: View { // ── Root view — observed by the hosting controller ───────────────────────── public struct MobRootView: View { - @ObservedObject var model = MobViewModel.shared + // The window scene's own view model (MOB-245): `MobViewModel.shared` for + // the default scene, a model of its own for every further window. + @ObservedObject var model: MobViewModel @Environment(\.colorScheme) private var colorScheme // The hosting window's trait size classes (MOB-204). Read as one pair so a // rotation, which flips both axes in one trait update, is reported once @@ -2898,7 +2939,9 @@ public struct MobRootView: View { /// rather than an unrecognisable full-screen cover. private static let sheetHeightCeilingFraction: CGFloat = 0.9 - public init() {} + public init(model: MobViewModel = .shared) { + self.model = model + } public var body: some View { ZStack { @@ -2971,6 +3014,11 @@ public struct MobRootView: View { .overlayPreferenceValue(MobAnchoredKey.self) { entries in MobAnchoredPanelHost(entries: entries) } + // Only the default scene's window takes part in the element frame + // registry (MOB-245). Applied after the anchored-panel overlay so it + // reaches a further window's open popover or select panel too. + .environment(\.mobTracksFrames, model === MobViewModel.shared) + .environment(\.mobSceneId, model.sceneId) .ignoresSafeArea(.container, edges: [.bottom, .horizontal]) .onChange(of: model.rootVersion) { applyRoot( @@ -2993,7 +3041,7 @@ public struct MobRootView: View { // placeholder; the BEAM drops a value a screen already holds. .onChange(of: MobSizeClassPair(horizontalSizeClass, verticalSizeClass), initial: true) { _, pair in - pair.notify() + pair.notify(sceneId: model.sceneId) } } @@ -3156,9 +3204,9 @@ private struct MobSizeClassPair: Equatable { self.vertical = vertical } - func notify() { + func notify(sceneId: String?) { guard let h = Self.name(horizontal), let v = Self.name(vertical) else { return } - mob_notify_size_class(h, v) + mobWithSceneId(sceneId) { mob_notify_size_class($0, h, v) } } private static func name(_ sizeClass: UserInterfaceSizeClass?) -> String? { diff --git a/ios/MobViewModel.swift b/ios/MobViewModel.swift index 60c4b8d2..d2100ddd 100644 --- a/ios/MobViewModel.swift +++ b/ios/MobViewModel.swift @@ -32,9 +32,22 @@ import QuartzCore @Published public var startupPhase: String = "Starting…" /// Non-nil when a fatal startup error has occurred; the error screen stalls here. @Published public var startupError: String? + /// The window scene this model is shown in (MOB-245), set by mob_nif's + /// scene registry: its `UISceneSession.persistentIdentifier`. Nil until + /// the model meets a scene. Read on the main thread to tag what the window + /// reports (back gesture, size class) with the scene it came from. + @objc public var sceneId: String? - override init() { + override convenience init() { + self.init(watchdog: true) + } + + /// `watchdog: false` for a further window's model: the BEAM is already up + /// when that window connects, so a slow first frame there is not a stuck + /// boot. + @objc public init(watchdog: Bool) { super.init() + guard watchdog else { return } DispatchQueue.main.asyncAfter(deadline: .now() + Self.bootWatchdogSeconds) { [weak self] in guard let self, self.root == nil, self.startupError == nil else { return } let info = Bundle.main.infoDictionary @@ -56,8 +69,14 @@ import QuartzCore /// changes independently. public var replacesStack: Bool = false - @objc public func setRoot(_ node: MobNode?, transition: String, replacesStack: Bool) { + /// `current`, when given, is asked on the main thread right before the + /// tree is applied; `false` drops it. set_root passes whether the tree's + /// tap set still belongs to the window it was built for (MOB-245). + @objc public func setRoot( + _ node: MobNode?, transition: String, replacesStack: Bool, current: (() -> Bool)? + ) { DispatchQueue.main.async { + if let current, !current() { return } self.replacesStack = replacesStack let measuring = mob_native_stats_enabled() != 0 let start = measuring ? CACurrentMediaTime() : 0 @@ -137,11 +156,26 @@ import QuartzCore } } +/// Call `body` with `sceneId` as a C string, or with nil, for the C functions +/// that take a scene id (mob_handle_back, mob_notify_size_class). +func mobWithSceneId(_ sceneId: String?, _ body: (UnsafePointer?) -> Void) { + if let sceneId { + sceneId.withCString { body($0) } + } else { + body(nil) + } +} + // UIHostingController subclass that intercepts the left-edge swipe gesture // and forwards it to the BEAM as {:mob, :back}. // Using UIScreenEdgePanGestureRecognizer rather than a SwiftUI DragGesture // because it integrates cleanly with scroll views and doesn't require // threading gesture priority through the view tree. +// +// Each window scene has its own hosting controller showing its own view model +// (MOB-245). The SceneDelegate creates the controller before it is in a +// window, so it starts on the shared model and switches to its scene's model +// once it knows the scene, before anything is drawn. public class MobHostingController: UIHostingController { override public func viewDidLoad() { super.viewDidLoad() @@ -151,9 +185,29 @@ public class MobHostingController: UIHostingController { view.addGestureRecognizer(edgePan) } + // `view.window` is guaranteed here (unlike viewWillAppear), and it runs + // before the first frame is drawn, so a second window never shows the + // first one's tree. + override public func viewIsAppearing(_ animated: Bool) { + super.viewIsAppearing(animated) + mobAdoptSceneModel() + } + + /// Show the model mob_nif's scene registry holds for this window. Also + /// called by mob_nif (`mob_scene_show_model`) when a window that opened + /// with every tap set taken is later given set 0, which brings the shared + /// model with it. + @objc func mobAdoptSceneModel() { + guard let scene = viewIfLoaded?.window?.windowScene, + let model = mob_scene_attach(scene) as? MobViewModel, + model !== rootView.model + else { return } + rootView = MobRootView(model: model) + } + @objc private func handleEdgePan(_ gesture: UIScreenEdgePanGestureRecognizer) { if gesture.state == .ended { - mob_handle_back() + mobWithSceneId(rootView.model.sceneId) { mob_handle_back($0) } } } } diff --git a/ios/mob_beam.h b/ios/mob_beam.h index 431ab8aa..bdf4b494 100644 --- a/ios/mob_beam.h +++ b/ios/mob_beam.h @@ -16,6 +16,11 @@ void mob_init_ui(void); // without mob_init_ui; same before-launch-finishes requirement. void mob_install_notification_delegate(void); +// What mob_init_ui calls to track window scenes (MOB-245): every UIWindowScene +// that connects gets its own tap set and view model, and the BEAM hears +// {:mob_scene, :connected | :disconnected, ...}. Main thread. Idempotent. +void mob_install_scene_observers(void); + // Call mob_start_beam on a background thread — erl_start never returns. // app_module: Erlang module name, e.g. "mob_demo" void mob_start_beam(const char *app_module); diff --git a/ios/mob_beam.m b/ios/mob_beam.m index fe7ee8e3..535e5ed6 100644 --- a/ios/mob_beam.m +++ b/ios/mob_beam.m @@ -137,6 +137,7 @@ void mob_init_ui(void) { // No UIViewController reference needed here; the hosting controller is // created by MobUIFactory in AppDelegate.m. mob_install_notification_delegate(); + mob_install_scene_observers(); NSLog(@"[MobBeam] mob_init_ui: SwiftUI mode ready"); } diff --git a/ios/mob_nif.m b/ios/mob_nif.m index 2109f923..c4c8d9a0 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -44,6 +44,7 @@ #import #import #import +#import #import #include @@ -115,29 +116,81 @@ void mob_set_startup_error(const char *error) { } TapHandle; // Double-buffered tap registry (see android/jni/mob_nif.zig for full rationale). -// `tap_handles`/`tap_handle_next` point at the ACTIVE table + its committed -// count — readers (mob_send_*) keep using them unchanged. A render builds into -// the INACTIVE table via register_tap (tap_build_count) and set_root swaps it in -// atomically under tap_mutex, so a concurrent high-frequency send (drag/scroll) -// never observes a half-rebuilt table. -static TapHandle *tap_tables[2] = {NULL, NULL}; -static int tap_table_capacity[2] = {0, 0}; -static int tap_active = 0; -static TapHandle *tap_handles = NULL; // active table (readers use this) -static int tap_handle_next = 0; // active committed count (readers' bound) -static int tap_build_count = 0; // cursor into the building table -static uint32_t tap_table_generations[2] = {0, 0}; -// How many slots of each table were actually written, so clear_taps only walks -// those. Walking the whole table every frame is wasted work at any cap and -// would scale with the cap if it were ever raised. -static int tap_table_used[2] = {0, 0}; +// A render builds into a set's INACTIVE table via register_tap (build_count) +// and set_root swaps it in atomically under tap_mutex, so a concurrent +// high-frequency send (drag/scroll) never observes a half-rebuilt table. +// +// One set per window scene (MOB-245). Each scene shows its own tree, so each +// needs its own committed table: with one registry a render for one window +// would retire the handles the other window is still showing. Set 0 belongs to +// native's default scene, the one the unbound primary router renders into, so +// a single-window app uses set 0 and nothing else. clear_taps picks the set a +// frame is built for (clear_taps/0: set 0; clear_taps/1: the scene's), and +// register_tap and set_root work on that set. The extra set at +// MOB_TAP_SCRATCH_SET takes frames for a scene that is gone, so they are built +// and dropped without touching a live table. +// +// Native event blocks capture the set they were deserialised for (see +// MobEventRef), so a handle only ever resolves against its own window's table. +#define MOB_SCENE_LIMIT 8 +#define MOB_TAP_SCRATCH_SET MOB_SCENE_LIMIT + +typedef struct { + TapHandle *tables[2]; + int capacity[2]; + int active; + int handle_next; // active committed count (readers' bound) + int build_count; // cursor into the building table + uint32_t generations[2]; + // How many slots of each table were actually written, so clear_taps only + // walks those. Walking the whole table every frame is wasted work at any + // cap and would scale with the cap if it were ever raised. + int used[2]; + uint32_t build_generation; + // Bumped, under tap_mutex, each time the set is released for another + // window (mob_release_tap_set). A frame records it when clear_taps starts + // it and commits only if it is unchanged: a frame resolved for a window + // that closed while it was being built must not land in the set the next + // window took over, whose number is the same. + uint64_t epoch; +} MobTapSet; + +static MobTapSet tap_sets[MOB_SCENE_LIMIT + 1]; +// The set the last clear_taps prepared, and that set's epoch then. Mob.Sender +// serialises every clear_taps -> register_tap* -> set_root sequence, so one +// cursor suffices. +static int tap_build_set = 0; +static uint64_t tap_build_epoch = 0; // Exhausted registrations in the frame being built. Counted rather than logged // per call: a dense screen overflows the pool hundreds of times per frame, and // NSLog is a synchronous write to the system log. static int tap_exhausted_count = 0; -static uint32_t tap_build_generation = 0; static ErlNifMutex *tap_mutex = NULL; +// An event handle as native event blocks hold it: the tap set in the high 32 +// bits, the BEAM-visible handle ((generation << 12) | slot) in the low 32. The +// BEAM never sees this form; it only exists so a block built for one window's +// tree resolves against that window's set. +typedef int64_t MobEventRef; + +// The set the tree being deserialised belongs to. Written by nif_set_root +// before it calls mob_node_from_dict, on the one thread Mob.Sender renders +// from, and read only by mob_event_ref during that call. +static int g_node_tap_set = 0; + +static MobEventRef mob_event_ref(int handle) { + return ((MobEventRef)g_node_tap_set << 32) | (MobEventRef)(uint32_t)handle; +} + +static MobTapSet *mob_ref_set(MobEventRef ref) { + int set = (int)(ref >> 32); + return set >= 0 && set <= MOB_SCENE_LIMIT ? &tap_sets[set] : NULL; +} + +static int mob_ref_handle(MobEventRef ref) { + return (int)(uint32_t)(ref & 0xffffffffLL); +} + static uint32_t mob_next_handle_generation(uint32_t generation) { return generation == 0 || generation >= MAX_EVENT_GENERATION ? 1 : generation + 1; } @@ -160,47 +213,66 @@ static int mob_generation_within_identity(uint32_t handle_generation, } // Grow one table to hold at least `needed` slots. Caller holds tap_mutex, which -// is what makes this safe: every reader of tap_handles resolves under the same -// lock, so no one can be holding a pointer into a table while it moves. +// is what makes this safe: every reader of a set's tables resolves under the +// same lock, so no one can be holding a pointer into a table while it moves. // // Only the table being built is ever grown mid-frame; the active table is left -// alone until the swap in set_root repoints tap_handles at it. -static int mob_tap_grow_locked(int which, int needed) { - if (needed <= tap_table_capacity[which]) +// alone until the swap in set_root makes it active. +static int mob_tap_grow_locked(MobTapSet *ts, int which, int needed) { + if (needed <= ts->capacity[which]) return 1; if (needed > MOB_TAP_SLOT_LIMIT) return 0; - int cap = tap_table_capacity[which] ? tap_table_capacity[which] : MOB_TAP_INITIAL_CAPACITY; + int cap = ts->capacity[which] ? ts->capacity[which] : MOB_TAP_INITIAL_CAPACITY; while (cap < needed) cap *= 2; if (cap > MOB_TAP_SLOT_LIMIT) cap = MOB_TAP_SLOT_LIMIT; - TapHandle *grown = realloc(tap_tables[which], (size_t)cap * sizeof(TapHandle)); + TapHandle *grown = realloc(ts->tables[which], (size_t)cap * sizeof(TapHandle)); if (!grown) return 0; // Zero the new tail: clear_taps and the resolvers both key off tag_env // being NULL to decide a slot is free, and realloc leaves it uninitialised. - memset(grown + tap_table_capacity[which], 0, - (size_t)(cap - tap_table_capacity[which]) * sizeof(TapHandle)); + memset(grown + ts->capacity[which], 0, (size_t)(cap - ts->capacity[which]) * sizeof(TapHandle)); // …then restore the two fields whose "unset" value is 1, not 0. clear_taps // resets leading/trailing to 1 for reused slots, so without this a slot's // default would depend on whether it arrived by growth or by reuse. Nobody // re-derives that when these finally get a reader. - for (int i = tap_table_capacity[which]; i < cap; i++) { + for (int i = ts->capacity[which]; i < cap; i++) { grown[i].leading = 1; grown[i].trailing = 1; } - tap_tables[which] = grown; - tap_table_capacity[which] = cap; - if (which == tap_active) - tap_handles = grown; + ts->tables[which] = grown; + ts->capacity[which] = cap; return 1; } +// Hand one slot back: free its tag and reset its throttle state. Shared by +// clear_taps, which frees the building table for the next frame, and +// mob_release_tap_set, which frees a closed window's set for the next window. +// register_tap writes only the routing fields, so whatever this leaves is what +// the slot's next handler starts with. +static void mob_tap_slot_free_locked(TapHandle *slot) { + if (slot->tag_env) { + enif_free_env(slot->tag_env); + slot->tag_env = NULL; + } + slot->throttle_configured = 0; + slot->throttle_ms = 0; + slot->debounce_ms = 0; + slot->delta_threshold = 0; + slot->leading = 1; + slot->trailing = 1; + slot->last_emit_ns = 0; + slot->last_x = 0; + slot->last_y = 0; + slot->seq = 0; +} + static int mob_encode_event_handle(uint32_t generation, int slot) { if (generation == 0 || generation > MAX_EVENT_GENERATION || slot < 0 || slot >= MOB_TAP_SLOT_LIMIT) @@ -223,14 +295,15 @@ static int mob_decode_event_handle(int handle, uint32_t *generation, int *slot) uint64_t seq; } TapSnap; -static TapHandle *mob_resolve_active_tap_locked(int handle) { +static TapHandle *mob_resolve_active_tap_locked(MobTapSet *ts, int handle) { uint32_t generation; int slot; - if (!mob_decode_event_handle(handle, &generation, &slot) || - generation != tap_table_generations[tap_active] || slot >= tap_handle_next || - !tap_handles[slot].tag_env) + TapHandle *active = ts ? ts->tables[ts->active] : NULL; + if (!active || !mob_decode_event_handle(handle, &generation, &slot) || + generation != ts->generations[ts->active] || slot >= ts->handle_next || + !active[slot].tag_env) return NULL; - return &tap_handles[slot]; + return &active[slot]; } // Resolve a handle against the table currently being BUILT, not the active one. @@ -238,25 +311,26 @@ static int mob_decode_event_handle(int handle, uint32_t *generation, int *slot) // Needed because throttle config arrives during deserialisation. set_root walks // the JSON — populating the building table's config as it goes — and only swaps // that table in ~50 lines later. The handles in that JSON therefore carry -// tap_build_generation, while mob_resolve_active_tap_locked compares against -// tap_table_generations[tap_active], which is still the PREVIOUS frame's -// generation (nif_clear_taps zeroed the building slot's). Every lookup returned -// NULL and every `if (tap)` body was skipped, silently, on every frame — so an -// app's throttle/debounce settings never reached a live slot and only the -// built-in defaults ever applied (MOB-134). -static TapHandle *mob_resolve_build_tap_locked(int handle) { +// the set's build_generation, while mob_resolve_active_tap_locked compares +// against the active table's generation, which is still the PREVIOUS frame's +// (nif_clear_taps zeroed the building slot's). Every lookup returned NULL and +// every `if (tap)` body was skipped, silently, on every frame — so an app's +// throttle/debounce settings never reached a live slot and only the built-in +// defaults ever applied (MOB-134). +static TapHandle *mob_resolve_build_tap_locked(MobTapSet *ts, int handle) { uint32_t generation; int slot; - TapHandle *build = tap_tables[1 - tap_active]; + TapHandle *build = ts ? ts->tables[1 - ts->active] : NULL; if (!build || !mob_decode_event_handle(handle, &generation, &slot) || - generation != tap_build_generation || slot >= tap_build_count || !build[slot].tag_env) + generation != ts->build_generation || slot >= ts->build_count || !build[slot].tag_env) return NULL; return &build[slot]; } -static int mob_snap_tap(int handle, ErlNifEnv *msg_env, TapSnap *snap) { +static int mob_snap_tap(MobEventRef ref, ErlNifEnv *msg_env, TapSnap *snap) { + int handle = mob_ref_handle(ref); enif_mutex_lock(tap_mutex); - TapHandle *active = mob_resolve_active_tap_locked(handle); + TapHandle *active = mob_resolve_active_tap_locked(mob_ref_set(ref), handle); if (!active) { enif_mutex_unlock(tap_mutex); LOGD(@"rejected stale event handle %d", handle); @@ -269,17 +343,20 @@ static int mob_snap_tap(int handle, ErlNifEnv *msg_env, TapSnap *snap) { return 1; } -static int mob_snap_change_tap_locked(int handle, ErlNifEnv *msg_env, TapSnap *snap) { - TapHandle *active = mob_resolve_active_tap_locked(handle); +static int mob_snap_change_tap_locked(MobEventRef ref, ErlNifEnv *msg_env, TapSnap *snap) { + int handle = mob_ref_handle(ref); + MobTapSet *ts = mob_ref_set(ref); + TapHandle *active = mob_resolve_active_tap_locked(ts, handle); if (!active) { uint32_t generation; int slot; - if (!mob_decode_event_handle(handle, &generation, &slot)) + if (!ts || !mob_decode_event_handle(handle, &generation, &slot)) return 0; - active = slot >= 0 && slot < tap_handle_next ? &tap_handles[slot] : NULL; + TapHandle *table = ts->tables[ts->active]; + active = table && slot >= 0 && slot < ts->handle_next ? &table[slot] : NULL; if (!active || !active->tag_env || !mob_generation_within_identity(generation, active->identity_start_generation, - tap_table_generations[tap_active])) + ts->generations[ts->active])) return 0; } snap->pid = active->pid; @@ -288,12 +365,12 @@ static int mob_snap_change_tap_locked(int handle, ErlNifEnv *msg_env, TapSnap *s return 1; } -static int mob_snap_change_tap(int handle, ErlNifEnv *msg_env, TapSnap *snap) { +static int mob_snap_change_tap(MobEventRef ref, ErlNifEnv *msg_env, TapSnap *snap) { enif_mutex_lock(tap_mutex); - int ok = mob_snap_change_tap_locked(handle, msg_env, snap); + int ok = mob_snap_change_tap_locked(ref, msg_env, snap); enif_mutex_unlock(tap_mutex); if (!ok) - LOGD(@"rejected stale event handle %d", handle); + LOGD(@"rejected stale event handle %d", mob_ref_handle(ref)); return ok; } @@ -307,15 +384,17 @@ static uint64_t mob_now_ns(void) { // Set throttle config for a handle. Called from the prop deserialiser when // it sees a *_config sibling prop. Idempotent — safe to call multiple times. -static void mob_set_throttle_config(int handle, int throttle_ms, int debounce_ms, +static void mob_set_throttle_config(MobEventRef ref, int throttle_ms, int debounce_ms, double delta_threshold, int leading, int trailing) { + int handle = mob_ref_handle(ref); + MobTapSet *ts = mob_ref_set(ref); enif_mutex_lock(tap_mutex); // Building table first: the only caller is the set_root prop deserialiser, // which runs before the swap. The active-table fallback keeps any future // caller outside a build working. - TapHandle *tap = mob_resolve_build_tap_locked(handle); + TapHandle *tap = mob_resolve_build_tap_locked(ts, handle); if (!tap) - tap = mob_resolve_active_tap_locked(handle); + tap = mob_resolve_active_tap_locked(ts, handle); if (tap) { tap->throttle_configured = 1; tap->throttle_ms = throttle_ms; @@ -333,10 +412,10 @@ static void mob_set_throttle_config(int handle, int throttle_ms, int debounce_ms // Defaults (when throttle/delta unset on a handle): use reasonable per-event // fallbacks so widgets that opt in without explicit config still get sane // gating. -static int mob_throttle_check(int handle, double x, double y, int default_throttle_ms, +static int mob_throttle_check(MobEventRef ref, double x, double y, int default_throttle_ms, double default_delta) { enif_mutex_lock(tap_mutex); - TapHandle *h = mob_resolve_active_tap_locked(handle); + TapHandle *h = mob_resolve_active_tap_locked(mob_ref_set(ref), mob_ref_handle(ref)); if (!h) { enif_mutex_unlock(tap_mutex); return 0; @@ -376,9 +455,9 @@ static int mob_throttle_check(int handle, double x, double y, int default_thrott } // Read current seq + ts for a handle (for envelope construction). -static void mob_handle_meta(int handle, uint64_t *seq_out, uint64_t *ts_out) { +static void mob_handle_meta(MobEventRef ref, uint64_t *seq_out, uint64_t *ts_out) { enif_mutex_lock(tap_mutex); - TapHandle *tap = mob_resolve_active_tap_locked(handle); + TapHandle *tap = mob_resolve_active_tap_locked(mob_ref_set(ref), mob_ref_handle(ref)); if (tap) { *seq_out = tap->seq; *ts_out = mob_now_ns() / 1000000ULL; // ms since boot @@ -436,7 +515,7 @@ static void mob_note_ui_event(void) { // Called from node onTap blocks — routes tap to BEAM via enif_send. // identity_tolerant picks the lookup: NO (mob_send_tap) is generation-strict, // YES (mob_send_press_tap) accepts the same slot, PID and tag across renders. -static void mob_send_tap_with(int handle, BOOL identity_tolerant) { +static void mob_send_tap_with(MobEventRef handle, BOOL identity_tolerant) { ErlNifEnv *msg_env = enif_alloc_env(); if (!msg_env) return; @@ -457,7 +536,7 @@ static void mob_send_tap_with(int handle, BOOL identity_tolerant) { // Generation-strict: a handle from an earlier render is dropped, so a // positional tag (Mob.List's {:select, id, index}) never reaches a row that // moved under it. -static void mob_send_tap(int handle) { +static void mob_send_tap(MobEventRef handle) { mob_send_tap_with(handle, NO); } @@ -468,14 +547,14 @@ static void mob_send_tap(int handle) { // the lift that completes the tap fires the old node's handle; a strict check // dropped those taps (measured on Android, 1-6 of 20). The identity check still // rejects a handle whose slot now means another handler. Plain taps stay strict. -static void mob_send_press_tap(int handle) { +static void mob_send_press_tap(MobEventRef handle) { mob_send_tap_with(handle, YES); } // ── Focus / blur / submit senders ──────────────────────────────────────────── // Called from MobTextField SwiftUI view when focus state changes or return key tapped. -static void mob_send_event(int handle, const char *atom) { +static void mob_send_event(MobEventRef handle, const char *atom) { ErlNifEnv *msg_env = enif_alloc_env(); if (!msg_env) return; @@ -491,7 +570,7 @@ static void mob_send_event(int handle, const char *atom) { enif_free_env(msg_env); } -static void mob_send_identity_event(int handle, const char *atom) { +static void mob_send_identity_event(MobEventRef handle, const char *atom) { ErlNifEnv *msg_env = enif_alloc_env(); if (!msg_env) return; @@ -507,26 +586,26 @@ static void mob_send_identity_event(int handle, const char *atom) { enif_free_env(msg_env); } -static void mob_send_focus(int handle) { +static void mob_send_focus(MobEventRef handle) { mob_send_event(handle, "focus"); } -static void mob_send_blur(int handle) { +static void mob_send_blur(MobEventRef handle) { mob_send_event(handle, "blur"); } -static void mob_send_submit(int handle) { +static void mob_send_submit(MobEventRef handle) { mob_send_event(handle, "submit"); } -static void mob_send_select(int handle) { +static void mob_send_select(MobEventRef handle) { mob_send_event(handle, "select"); } -static void mob_send_dismiss(int handle) { +static void mob_send_dismiss(MobEventRef handle) { mob_send_identity_event(handle, "dismiss"); } // IME composition. Sends {compose, tag, %{text: ..., phase: ...}} where // phase is one of began/updating/committed/cancelled. Called from the // text-input layer when marked-text state changes. -static void mob_send_compose(int handle, const char *text, const char *phase) { +static void mob_send_compose(MobEventRef handle, const char *text, const char *phase) { ErlNifEnv *msg_env = enif_alloc_env(); if (!msg_env) return; @@ -564,27 +643,27 @@ static void mob_send_compose(int handle, const char *text, const char *phase) { // Each fires {atom, tag} just like tap. SwiftUI converts gesture recognizers // into onLongPress/onDoubleTap/onSwipe* callbacks on the MobNode. -static void mob_send_long_press(int handle) { +static void mob_send_long_press(MobEventRef handle) { mob_send_event(handle, "long_press"); } -static void mob_send_double_tap(int handle) { +static void mob_send_double_tap(MobEventRef handle) { mob_send_event(handle, "double_tap"); } -static void mob_send_swipe_left(int handle) { +static void mob_send_swipe_left(MobEventRef handle) { mob_send_event(handle, "swipe_left"); } -static void mob_send_swipe_right(int handle) { +static void mob_send_swipe_right(MobEventRef handle) { mob_send_event(handle, "swipe_right"); } -static void mob_send_swipe_up(int handle) { +static void mob_send_swipe_up(MobEventRef handle) { mob_send_event(handle, "swipe_up"); } -static void mob_send_swipe_down(int handle) { +static void mob_send_swipe_down(MobEventRef handle) { mob_send_event(handle, "swipe_down"); } // Generic on_swipe with direction: emits {swipe, tag, direction} where direction is an atom. -static void mob_send_swipe_with_direction(int handle, const char *direction) { +static void mob_send_swipe_with_direction(MobEventRef handle, const char *direction) { ErlNifEnv *msg_env = enif_alloc_env(); if (!msg_env) return; @@ -652,7 +731,7 @@ static void mob_send_pending_press_out(MobPendingPressOut *pending) { } // Returns 1 if {press_in, tag} was sent. -static int mob_send_press_in(int handle) { +static int mob_send_press_in(MobEventRef handle) { ErlNifEnv *msg_env = enif_alloc_env(); if (!msg_env) return 0; @@ -668,12 +747,12 @@ static int mob_send_press_in(int handle) { return 1; } -// in_handle / out_handle are 0 when the node has no such prop (a real handle -// is never 0: its generation bits are non-zero). Returns the release block, or -// nil when there is nothing to send at release. -static void (^mob_begin_press(int in_handle, int out_handle))(void) { +// in_handle / out_handle carry handle 0 when the node has no such prop (a real +// handle is never 0: its generation bits are non-zero). Returns the release +// block, or nil when there is nothing to send at release. +static void (^mob_begin_press(MobEventRef in_handle, MobEventRef out_handle))(void) { MobPendingPressOut *pending = nil; - if (out_handle != 0) { + if (mob_ref_handle(out_handle) != 0) { ErlNifEnv *env = enif_alloc_env(); if (!env) return nil; @@ -688,7 +767,7 @@ static void (^mob_begin_press(int in_handle, int out_handle))(void) { pending->tag = snap.tag; } // `pending` is released (and its env freed) on this early return. - if (in_handle != 0 && !mob_send_press_in(in_handle)) + if (mob_ref_handle(in_handle) != 0 && !mob_send_press_in(in_handle)) return nil; if (!pending) return nil; @@ -733,7 +812,7 @@ static ERL_NIF_TERM mob_build_scroll_payload(ErlNifEnv *env, double x, double y, // Send a throttled high-frequency event. Phase is one of: // "began" | "dragging" | "decelerating" | "ended" -static void mob_send_scroll(int handle, double x, double y, double dx, double dy, double vx, +static void mob_send_scroll(MobEventRef handle, double x, double y, double dx, double dy, double vx, double vy, const char *phase) { // Force-emit for began/ended phases regardless of throttle (semantic // boundaries are too important to drop). @@ -760,7 +839,8 @@ static void mob_send_scroll(int handle, double x, double y, double dx, double dy enif_free_env(msg_env); } -static void mob_send_drag(int handle, double x, double y, double dx, double dy, const char *phase) { +static void mob_send_drag(MobEventRef handle, double x, double y, double dx, double dy, + const char *phase) { int is_phase_boundary = (strcmp(phase, "began") == 0) || (strcmp(phase, "ended") == 0); if (!is_phase_boundary && !mob_throttle_check(handle, x, y, 16, 1.0)) return; @@ -796,7 +876,7 @@ static void mob_send_drag(int handle, double x, double y, double dx, double dy, enif_free_env(msg_env); } -static void mob_send_pinch(int handle, double scale, double velocity, const char *phase) { +static void mob_send_pinch(MobEventRef handle, double scale, double velocity, const char *phase) { int is_phase_boundary = (strcmp(phase, "began") == 0) || (strcmp(phase, "ended") == 0); if (!is_phase_boundary && !mob_throttle_check(handle, scale, 0, 16, 0.01)) return; @@ -829,7 +909,8 @@ static void mob_send_pinch(int handle, double scale, double velocity, const char enif_free_env(msg_env); } -static void mob_send_rotate(int handle, double degrees, double velocity, const char *phase) { +static void mob_send_rotate(MobEventRef handle, double degrees, double velocity, + const char *phase) { int is_phase_boundary = (strcmp(phase, "began") == 0) || (strcmp(phase, "ended") == 0); if (!is_phase_boundary && !mob_throttle_check(handle, degrees, 0, 16, 1.0)) return; @@ -862,7 +943,7 @@ static void mob_send_rotate(int handle, double degrees, double velocity, const c enif_free_env(msg_env); } -static void mob_send_pointer_move(int handle, double x, double y) { +static void mob_send_pointer_move(MobEventRef handle, double x, double y) { if (!mob_throttle_check(handle, x, y, 33, 4.0)) return; @@ -897,43 +978,680 @@ static void mob_send_pointer_move(int handle, double x, double y) { } // ── Batch 5 Tier 2 senders — semantic single-fire scroll events ───────────── -static void mob_send_scroll_began(int handle) { +static void mob_send_scroll_began(MobEventRef handle) { mob_send_event(handle, "scroll_began"); } -static void mob_send_scroll_ended(int handle) { +static void mob_send_scroll_ended(MobEventRef handle) { mob_send_event(handle, "scroll_ended"); } -static void mob_send_scroll_settled(int handle) { +static void mob_send_scroll_settled(MobEventRef handle) { mob_send_event(handle, "scroll_settled"); } -static void mob_send_top_reached(int handle) { +static void mob_send_top_reached(MobEventRef handle) { mob_send_event(handle, "top_reached"); } -static void mob_send_scrolled_past(int handle) { +static void mob_send_scrolled_past(MobEventRef handle) { mob_send_event(handle, "scrolled_past"); } +// ── Window scenes (MOB-245) ─────────────────────────────────────────────────── +// +// One entry per UIWindowScene mob has seen, keyed by the session's +// persistentIdentifier (the scene id the BEAM sees). Each entry owns a tap set +// and a MobViewModel. Set 0 and MobViewModel.shared go to native's DEFAULT +// scene: whichever scene connects while no other is attached. It is the one a +// frame with no "scene" key renders into, i.e. the one the unbound primary +// router shows, so a single-window app runs exactly as before scenes existed. +// +// Only application scenes are registered. An AirPlay or cable display +// connects a UIWindowSceneSessionRoleExternalDisplayNonInteractive scene to +// every scene-based app; mob provides no content for it (the display +// mirrors), and registering it would turn a single-window app into a +// two-scene one. +// +// Lifecycle, mirrored by Mob.Scenes on the BEAM side: +// * connect, id known -> re-attached (iPadOS discarded and restored it) +// * connect, id new -> if no scene is attached and a kept entry's +// session is gone (iPadOS made a new session instead +// of restoring it), the new scene takes it over, its +// set and model with it (the default one if it is +// among them). A kept entry whose session is still +// open may yet be restored, so it is left alone. +// With nothing to take over, the scene gets the +// lowest free set, set 0 bringing the shared model +// * disconnect -> released if other scenes are attached; kept, with +// its set and model, if it was the last +// * session gone -> a kept entry whose session is no longer in +// UIApplication.openSessions (the user closed the +// window in the app switcher) is released; checked on +// every attach and whenever an application scene +// activates +// * set freed -> a window that connected with every set taken +// (MOB_SCENE_LIMIT) gets it; so does such a window +// when it re-attaches +// The BEAM hears, at registered Mob.Scenes, {mob_scene, connected, Id, +// IsDefault} for a new or re-attached scene, {mob_scene, replaced, OldId, +// NewId, IsDefault} for a takeover (OldId's router moves to NewId), +// {mob_scene, disconnected, Id}, and {mob_scene, discarded, Id} for a released +// kept entry (its router stops). A window without a set is not announced +// until it gets one. Mob.Scenes also asks scenes/0 when it starts, since +// nothing can be sent before erts is up. +// +// Entries are written on the main thread and read from NIF threads, under +// @synchronized(mob_scene_lock()). + +@interface MobSceneEntry : NSObject +@property(nonatomic, copy) NSString *sceneId; +@property(nonatomic, weak) UIWindowScene *scene; +@property(nonatomic, strong) MobViewModel *model; +@property(nonatomic) int tapSet; +@property(nonatomic) BOOL attached; +// Whether mob_notify_window_connected has told this window's router its +// window exists since the scene last (re)connected. +@property(nonatomic) BOOL windowNotified; +@end + +@implementation MobSceneEntry +@end + +static NSMutableArray *g_scene_entries = nil; // connection order + +static NSObject *mob_scene_lock(void) { + static NSObject *lock; + static dispatch_once_t once; + dispatch_once(&once, ^{ + lock = [NSObject new]; + g_scene_entries = [NSMutableArray array]; + }); + return lock; +} + +static MobSceneEntry *mob_scene_entry_locked(NSString *sceneId) { + if (!sceneId) + return nil; + for (MobSceneEntry *entry in g_scene_entries) + if ([entry.sceneId isEqualToString:sceneId]) + return entry; + return nil; +} + +static MobSceneEntry *mob_scene_entry_for_set_locked(int set) { + for (MobSceneEntry *entry in g_scene_entries) + if (entry.tapSet == set) + return entry; + return nil; +} + +// Whether `scene` is a window scene mob registers: an application one, not +// an external display's (see the role note above). +static BOOL mob_scene_is_application(UIScene *scene) { + return [scene isKindOfClass:[UIWindowScene class]] && + [scene.session.role isEqualToString:UIWindowSceneSessionRoleApplication]; +} + +// The lowest tap set no entry holds, or -1 when all MOB_SCENE_LIMIT are taken. +// `excluded` are sets removed from the registry but not yet released: their +// tables still hold the gone window's handles, and the BEAM has not yet been +// told that window is gone, so its router may still render into them. +static int mob_scene_free_set_locked(NSArray *excluded) { + for (int candidate = 0; candidate < MOB_SCENE_LIMIT; candidate++) + if (!mob_scene_entry_for_set_locked(candidate) && ![excluded containsObject:@(candidate)]) + return candidate; + return -1; +} + +// Give `entry`, a window that connected with every set taken, the lowest free +// set, if there is one now. Set 0 brings the shared model, as it does for a new +// scene; any other keeps the entry's own model, with its error screen cleared. +static BOOL mob_scene_assign_set_locked(MobSceneEntry *entry, NSArray *excluded) { + int set = mob_scene_free_set_locked(excluded); + if (set < 0) + return NO; + entry.tapSet = set; + if (set == 0) + entry.model = MobViewModel.shared; + else + [entry.model setStartupError:nil]; + entry.model.sceneId = entry.sceneId; + return YES; +} + +static int mob_scene_attached_count(void) { + int count = 0; + @synchronized(mob_scene_lock()) { + for (MobSceneEntry *entry in g_scene_entries) + if (entry.attached) + count++; + } + return count; +} + +// The tap set of scene `sceneId`, or -1 for a scene native doesn't know (it +// went away while the BEAM was rendering for it). +static int mob_scene_tap_set(NSString *sceneId) { + @synchronized(mob_scene_lock()) { + MobSceneEntry *entry = mob_scene_entry_locked(sceneId); + return entry ? entry.tapSet : -1; + } +} + +// The view model frames for `set` are shown in; set 0 always has the shared +// one, even before any scene exists (a prewarmed or background launch). +static MobViewModel *mob_scene_model_for_set(int set) { + if (set == 0) + return MobViewModel.shared; + @synchronized(mob_scene_lock()) { + return mob_scene_entry_for_set_locked(set).model; + } +} + +// Drop every handle a released set holds, so a later scene starts it clean: +// its tag environments are not leaked and no slot keeps the closed window's +// throttle state. Before nif_load there is neither a mutex nor a handle. +// +// build_generation is deliberately left alone. Zeroing generations[] makes +// every handle unmatchable until the next window's first set_root, which +// commits a build_generation past every one the closed window was handed out +// (clear_taps only ever advances it), so a stale event block from the closed +// window can't resolve against the reused set, by exact match or by the +// identity fallback. +// +// The epoch bump is what stops a frame already in flight for the closed +// window: set_root commits its table, and the main thread shows its tree, only +// while the set's epoch is the one clear_taps recorded (mob_tap_set_leased). +static void mob_release_tap_set(int set) { + if (!tap_mutex) + return; + enif_mutex_lock(tap_mutex); + MobTapSet *ts = &tap_sets[set]; + for (int which = 0; which < 2; which++) { + TapHandle *table = ts->tables[which]; + for (int i = 0; table && i < ts->used[which]; i++) + mob_tap_slot_free_locked(&table[i]); + ts->used[which] = 0; + ts->generations[which] = 0; + } + ts->handle_next = 0; + ts->build_count = 0; + ts->epoch++; + enif_mutex_unlock(tap_mutex); +} + +// Whether `set` has not been released since a frame recorded `epoch` for it. +// Checked by that frame's tree on the main thread right before it is shown: +// releases run on the main thread too, so a release either came first (and +// the tree is dropped) or comes after (and clears what the tree showed). +static BOOL mob_tap_set_leased(int set, uint64_t epoch) { + enif_mutex_lock(tap_mutex); + BOOL leased = tap_sets[set].epoch == epoch; + enif_mutex_unlock(tap_mutex); + return leased; +} + +// Release `set` for the next scene. The shared model outlives its scene; a +// later default scene must not come up showing this one's last tree. +static void mob_scene_release_set(int set) { + mob_release_tap_set(set); + if (set == 0) + [MobViewModel.shared setRoot:nil transition:@"none" replacesStack:NO current:nil]; +} + +static ERL_NIF_TERM mob_make_string(ErlNifEnv *env, NSString *string) { + const char *utf8 = [string UTF8String] ?: ""; + size_t len = strlen(utf8); + ERL_NIF_TERM term; + unsigned char *bytes = enif_make_new_binary(env, len, &term); + if (bytes && len > 0) + memcpy(bytes, utf8, len); + return term; +} + +typedef enum { + MOB_SCENE_CONNECTED, + MOB_SCENE_REPLACED, + MOB_SCENE_DISCONNECTED, + MOB_SCENE_DISCARDED +} MobSceneChange; + +// Tell registered Mob.Scenes about scene `sceneId`. `replacedId` is the id +// whose kept entry a REPLACED scene took over. +static void mob_tell_scenes(MobSceneChange change, NSString *sceneId, NSString *replacedId, + BOOL isDefault) { + if (!mob_runtime_up()) + return; + ErlNifEnv *env = enif_alloc_env(); + ErlNifPid pid; + if (enif_whereis_pid(env, enif_make_atom(env, "Elixir.Mob.Scenes"), &pid)) { + ERL_NIF_TERM tag = enif_make_atom(env, "mob_scene"); + ERL_NIF_TERM id = mob_make_string(env, sceneId); + ERL_NIF_TERM def = enif_make_atom(env, isDefault ? "true" : "false"); + ERL_NIF_TERM msg; + switch (change) { + case MOB_SCENE_CONNECTED: + msg = enif_make_tuple4(env, tag, enif_make_atom(env, "connected"), id, def); + break; + case MOB_SCENE_REPLACED: + msg = enif_make_tuple5(env, tag, enif_make_atom(env, "replaced"), + mob_make_string(env, replacedId), id, def); + break; + case MOB_SCENE_DISCONNECTED: + msg = enif_make_tuple3(env, tag, enif_make_atom(env, "disconnected"), id); + break; + case MOB_SCENE_DISCARDED: + msg = enif_make_tuple3(env, tag, enif_make_atom(env, "discarded"), id); + break; + } + enif_send(NULL, &pid, env, msg); + } + enif_free_env(env); +} + +// Implemented by MobHostingController (Swift): re-read the model the registry +// holds for its window and show it. +@protocol MobSceneModelHost +- (void)mobAdoptSceneModel; +@end + +// Make `scene`'s window show its entry's current model. Only needed when that +// model changes under a window already on screen; a new window asks +// mob_scene_attach itself. Main thread. +static void mob_scene_show_model(UIWindowScene *scene) { + for (UIWindow *window in scene.windows) { + UIViewController *controller = window.rootViewController; + if ([controller respondsToSelector:@selector(mobAdoptSceneModel)]) + [(id)controller mobAdoptSceneModel]; + } +} + +// The persistentIdentifier of every session iOS still holds for this app, +// including those whose scene is disconnected. Main thread. +static NSSet *mob_scene_open_ids(void) { + NSMutableSet *openIds = [NSMutableSet set]; + for (UISceneSession *session in UIApplication.sharedApplication.openSessions) + if (session.persistentIdentifier) + [openIds addObject:session.persistentIdentifier]; + return openIds; +} + +// Remove every kept (unattached) entry but `spare` whose session is not in +// `openIds`: that window is not coming back. Its set goes to `released` and, +// if the BEAM ever heard of it, its id to `discarded`. +static void mob_scene_prune_gone_locked(NSSet *openIds, MobSceneEntry *spare, + NSMutableArray *released, + NSMutableArray *discarded) { + for (MobSceneEntry *gone in [g_scene_entries copy]) { + if (gone == spare || gone.attached || [openIds containsObject:gone.sceneId]) + continue; + if (gone.tapSet >= 0) { + [released addObject:@(gone.tapSet)]; + [discarded addObject:gone.sceneId]; + } + [g_scene_entries removeObject:gone]; + } +} + +// Give the first attached window still without a set (it connected with all +// MOB_SCENE_LIMIT taken) a set that was just released, or nil. +static MobSceneEntry *mob_scene_promote_over_limit(void) { + @synchronized(mob_scene_lock()) { + for (MobSceneEntry *entry in g_scene_entries) + if (entry.attached && entry.tapSet < 0) + return mob_scene_assign_set_locked(entry, nil) ? entry : nil; + } + return nil; +} + +// Outside the lock, finish removing entries: release their sets, tell the BEAM +// which kept windows are gone for good, then hand the freed sets to windows +// waiting for one. Released before they are handed on, so the window that gets +// one starts on a clean set. Main thread. +static void mob_scene_finish_removal(NSArray *released, + NSArray *discarded) { + for (NSNumber *set in released) + mob_scene_release_set(set.intValue); + for (NSString *sceneId in discarded) + mob_tell_scenes(MOB_SCENE_DISCARDED, sceneId, nil, NO); + if (released.count == 0) + return; + MobSceneEntry *promoted; + while ((promoted = mob_scene_promote_over_limit())) { + // Set 0 swaps the window's model for the shared one under a window + // already on screen. + if (promoted.tapSet == 0) + mob_scene_show_model(promoted.scene); + mob_tell_scenes(MOB_SCENE_CONNECTED, promoted.sceneId, nil, promoted.tapSet == 0); + } +} + +// Release kept entries whose session the user has closed since. Run when an +// application scene activates: closing a window in the app switcher discards +// its session without connecting or disconnecting anything. Main thread. +static void mob_scene_prune_discarded(void) { + NSSet *openIds = mob_scene_open_ids(); + NSMutableArray *released = [NSMutableArray array]; + NSMutableArray *discarded = [NSMutableArray array]; + @synchronized(mob_scene_lock()) { + mob_scene_prune_gone_locked(openIds, nil, released, discarded); + } + mob_scene_finish_removal(released, discarded); +} + +// Attach `scene`, returning the view model its root view shows. Main thread. +// Called from the scene-connect observer and from MobHostingController (the +// generated SceneDelegate builds the hosting controller before or after the +// notification, depending on the iOS version), so repeats are no-ops. +NSObject *mob_scene_attach(UIWindowScene *scene) { + NSString *sceneId = scene.session.persistentIdentifier; + if (!sceneId || !mob_scene_is_application(scene)) + return MobViewModel.shared; + + NSSet *openIds = mob_scene_open_ids(); + MobSceneEntry *entry; + NSString *replacedId = nil; + BOOL announce = NO; + BOOL hasSet = NO; // under the lock, i.e. before finish_removal can promote it + NSMutableArray *released = [NSMutableArray array]; + NSMutableArray *discarded = [NSMutableArray array]; + + @synchronized(mob_scene_lock()) { + entry = mob_scene_entry_locked(sceneId); + BOOL anyAttached = NO; + for (MobSceneEntry *other in g_scene_entries) + anyAttached = anyAttached || other.attached; + + // With no scene attached, a new id whose kept entry's session is gone + // means iPadOS made this new session instead of restoring that one, + // so the new scene takes the entry over and the window comes up on + // the same tree with live taps. The lowest set wins, so the default + // scene's entry, the primary's window, is the one taken. A kept entry + // whose session is still open may yet reconnect (a split of two + // windows comes back one window at a time), and taking it over would + // show that window's tree in this one. + MobSceneEntry *kept = nil; + if (!entry && !anyAttached) + for (MobSceneEntry *gone in g_scene_entries) + if (![openIds containsObject:gone.sceneId] && gone.tapSet >= 0 && + (!kept || gone.tapSet < kept.tapSet)) + kept = gone; + // Every other kept entry whose session is gone is released. + mob_scene_prune_gone_locked(openIds, entry ?: kept, released, discarded); + + if (entry) { + announce = !entry.attached; + } else if (kept) { + entry = kept; + replacedId = entry.sceneId; + entry.sceneId = sceneId; + entry.model.sceneId = sceneId; + announce = YES; + } + + // Not from a set this attach just pruned (see + // mob_scene_free_set_locked). If that leaves none, the scene waits + // without one, and mob_scene_finish_removal below hands the pruned set + // to a waiting window once it is released and the BEAM has heard its + // old window is gone. + if (!entry) { + int set = mob_scene_free_set_locked(released); + entry = [MobSceneEntry new]; + entry.sceneId = sceneId; + entry.tapSet = set; + entry.model = + set == 0 ? MobViewModel.shared : [[MobViewModel alloc] initWithWatchdog:NO]; + entry.model.sceneId = sceneId; + if (set < 0) { + LOGE(@"scene %@: more than %d windows; this one shows nothing", sceneId, + MOB_SCENE_LIMIT); + [entry.model setStartupError:@"This app can't open more windows."]; + } + [g_scene_entries addObject:entry]; + announce = YES; + } else if (entry.tapSet < 0 && mob_scene_assign_set_locked(entry, released)) { + // A window that connected over the limit, re-attaching now that a + // set is free. The BEAM has never heard of it. + announce = YES; + } + entry.scene = scene; + entry.attached = YES; + // A window that (re)connects, or a new session taking an entry over, + // is news to its router again once the window exists. + if (announce) + entry.windowNotified = NO; + hasSet = entry.tapSet >= 0; + } + + // A scene that waited for a pruned set was announced when + // finish_removal promoted it. + mob_scene_finish_removal(released, discarded); + if (announce && hasSet) + mob_tell_scenes(replacedId ? MOB_SCENE_REPLACED : MOB_SCENE_CONNECTED, sceneId, replacedId, + entry.tapSet == 0); + return entry.model; +} + +static void mob_scene_detach(UIWindowScene *scene) { + if (!mob_scene_is_application(scene)) + return; + NSString *sceneId = scene.session.persistentIdentifier; + int released = -1; + BOOL announced = NO; + + @synchronized(mob_scene_lock()) { + MobSceneEntry *entry = mob_scene_entry_locked(sceneId); + if (!entry || !entry.attached) + return; + // A window without a set was never announced. + announced = entry.tapSet >= 0; + BOOL othersAttached = NO; + for (MobSceneEntry *other in g_scene_entries) + othersAttached = othersAttached || (other != entry && other.attached); + + if (othersAttached) { + released = entry.tapSet; + [g_scene_entries removeObject:entry]; + } else { + entry.attached = NO; + } + } + + if (announced) + mob_tell_scenes(MOB_SCENE_DISCONNECTED, sceneId, nil, NO); + mob_scene_finish_removal(released >= 0 ? @[ @(released) ] : @[], @[]); +} + +void mob_install_scene_observers(void) { + static dispatch_once_t once; + dispatch_once(&once, ^{ + NSNotificationCenter *center = NSNotificationCenter.defaultCenter; + [center addObserverForName:UISceneWillConnectNotification + object:nil + queue:NSOperationQueue.mainQueue + usingBlock:^(NSNotification *note) { + if (mob_scene_is_application(note.object)) + mob_scene_attach((UIWindowScene *)note.object); + }]; + [center addObserverForName:UISceneDidDisconnectNotification + object:nil + queue:NSOperationQueue.mainQueue + usingBlock:^(NSNotification *note) { + if (mob_scene_is_application(note.object)) + mob_scene_detach((UIWindowScene *)note.object); + }]; + [center addObserverForName:UISceneDidActivateNotification + object:nil + queue:NSOperationQueue.mainQueue + usingBlock:^(NSNotification *note) { + if (mob_scene_is_application(note.object)) + mob_scene_prune_discarded(); + }]; + // A scene that connected before this ran (mob_init_ui reached from a + // scene delegate rather than from launch). + for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) + if (mob_scene_is_application(scene)) + mob_scene_attach((UIWindowScene *)scene); + }); +} + +// The window scene frames with no "scene" key are shown in: the default +// scene's, else the first application window scene. Main thread. +static UIWindowScene *mob_default_window_scene(void) { + @synchronized(mob_scene_lock()) { + MobSceneEntry *entry = mob_scene_entry_for_set_locked(0); + if (entry.attached && entry.scene) + return entry.scene; + } + for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) + if (mob_scene_is_application(scene)) + return (UIWindowScene *)scene; + return nil; +} + +// Scene `sceneId`'s window scene, or the default one for nil. Main thread. +static UIWindowScene *mob_window_scene_for(NSString *sceneId) { + if (!sceneId) + return mob_default_window_scene(); + @synchronized(mob_scene_lock()) { + MobSceneEntry *entry = mob_scene_entry_locked(sceneId); + return entry.attached ? entry.scene : nil; + } +} + +// The window the unbound primary router's screens are in: the scene holding +// tap set 0. nil when that window is detached (kept, or gone) or when no +// scene is attached at all; callers fall back to their single-window +// behaviour only for the latter (mob_scene_attached_count() == 0), because +// with set 0 detached any attached window shows another router's screens +// (MOB-245). Main thread. +static UIWindowScene *mob_primary_window_scene(void) { + @synchronized(mob_scene_lock()) { + MobSceneEntry *entry = mob_scene_entry_for_set_locked(0); + return entry.attached ? entry.scene : nil; + } +} + +static NSString *mob_scene_id_from_term(ErlNifEnv *env, ERL_NIF_TERM term) { + ErlNifBinary bin; + if (!enif_inspect_binary(env, term, &bin) && !enif_inspect_iolist_as_binary(env, term, &bin)) + return nil; + return [[NSString alloc] initWithBytes:bin.data length:bin.size encoding:NSUTF8StringEncoding]; +} + +// Deliver a message that belongs to one window — the back gesture, a size +// class change, an alert result, a WebView message. With the window known and +// Mob.Scenes running, Mob.Scenes gets {mob_scene_event, SceneId, Msg} and +// hands Msg to the router showing that window, however many windows are +// attached: with the primary's window kept but detached, the one attached +// window shows another router (MOB-245). Without a scene id, or without +// Mob.Scenes (a mob from before scenes), :mob_screen as before. +static void mob_send_window_message(NSString *sceneId, ErlNifEnv *env, ERL_NIF_TERM msg) { + ErlNifPid pid; + if (sceneId && enif_whereis_pid(env, enif_make_atom(env, "Elixir.Mob.Scenes"), &pid)) { + enif_send(NULL, &pid, env, + enif_make_tuple3(env, enif_make_atom(env, "mob_scene_event"), + mob_make_string(env, sceneId), msg)); + return; + } + if (enif_whereis_pid(env, enif_make_atom(env, "mob_screen"), &pid)) + enif_send(NULL, &pid, env, msg); +} + // ── Back gesture sender ─────────────────────────────────────────────────────── -// Called from MobHostingController when the left-edge-pan gesture fires. -// Looks up the :mob_screen registered process and sends {:mob, :back}. -// Non-static so Swift can call it via the bridging header. +// Called from MobHostingController when the left-edge-pan gesture fires, with +// the id of the scene the gesture happened in (NULL when it isn't known yet). +// Sends {:mob, :back} to that window's router. Non-static so Swift can call it +// via the bridging header. -void mob_handle_back(void) { +void mob_handle_back(const char *scene_id) { // A left-edge swipe during the boot splash lands here before erts is up // (the pan recognizer is installed by the root controller the SceneDelegate // creates before mob_boot_runtime). No screen to tell yet (MOB-199). if (!mob_runtime_up()) return; ErlNifEnv *env = enif_alloc_env(); - ErlNifPid pid; - if (enif_whereis_pid(env, enif_make_atom(env, "mob_screen"), &pid)) { - ERL_NIF_TERM msg = - enif_make_tuple2(env, enif_make_atom(env, "mob"), enif_make_atom(env, "back")); - enif_send(NULL, &pid, env, msg); - } + ERL_NIF_TERM msg = + enif_make_tuple2(env, enif_make_atom(env, "mob"), enif_make_atom(env, "back")); + mob_send_window_message(scene_id ? [NSString stringWithUTF8String:scene_id] : nil, env, msg); enif_free_env(env); } +// ── NIF: scenes/0 ───────────────────────────────────────────────────────────── +// [{SceneId, IsDefault}] for every attached scene, oldest first. Mob.Scenes +// asks once when it starts, for the scenes that connected before it existed. + +static ERL_NIF_TERM nif_scenes(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + ERL_NIF_TERM list = enif_make_list(env, 0); + @synchronized(mob_scene_lock()) { + for (MobSceneEntry *entry in [g_scene_entries reverseObjectEnumerator]) { + if (!entry.attached || entry.tapSet < 0) + continue; + ERL_NIF_TERM item = + enif_make_tuple2(env, mob_make_string(env, entry.sceneId), + enif_make_atom(env, entry.tapSet == 0 ? "true" : "false")); + list = enif_make_list_cell(env, item, list); + } + } + return list; +} + +// ── NIF: scene_multiple_supported/0 ─────────────────────────────────────────── +// UIApplication.supportsMultipleScenes: the app's Info.plist opts in +// (mob.exs multi_window: true) and the device can show several windows. +// Read on the main thread with the same bounded wait as nif_safe_area, so a +// main thread that has not reached an idle tick can't hang the caller. + +static ERL_NIF_TERM nif_scene_multiple_supported(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { + __block BOOL supported = NO; + dispatch_semaphore_t done = dispatch_semaphore_create(0); + dispatch_async(dispatch_get_main_queue(), ^{ + supported = UIApplication.sharedApplication.supportsMultipleScenes; + 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_atom(env, "false"); + return enif_make_atom(env, supported ? "true" : "false"); +} + +// ── NIF: scene_request/0 ────────────────────────────────────────────────────── +// Ask iOS for a new window of this app (UISceneSessionActivationRequest, +// iOS 17, the deployment floor). The request is asynchronous: `ok` means it +// was made, and the new scene then connects like any other. iOS can still +// refuse it — iPhone Duo's outer display, a system window limit — and then +// the caller gets {mob_scene, request_failed, Reason}. Without +// supportsMultipleScenes the request would only fail, so it is refused here. + +static ERL_NIF_TERM nif_scene_request(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifPid caller; + enif_self(env, &caller); + __block BOOL requested = NO; + dispatch_semaphore_t done = dispatch_semaphore_create(0); + dispatch_async(dispatch_get_main_queue(), ^{ + UIApplication *app = UIApplication.sharedApplication; + if (app.supportsMultipleScenes) { + requested = YES; + [app activateSceneSessionForRequest:[UISceneSessionActivationRequest request] + errorHandler:^(NSError *error) { + if (!mob_runtime_up()) + return; + ErlNifEnv *msg_env = enif_alloc_env(); + ERL_NIF_TERM msg = enif_make_tuple3( + msg_env, enif_make_atom(msg_env, "mob_scene"), + enif_make_atom(msg_env, "request_failed"), + mob_make_string(msg_env, error.localizedDescription)); + enif_send(NULL, &caller, msg_env, msg); + enif_free_env(msg_env); + }]; + } + 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 || !requested) + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "unsupported")); + return enif_make_atom(env, "ok"); +} + // ── Window-connected sender ─────────────────────────────────────────────────── // Called from SceneDelegate scene:willConnectToSession: once the window exists. // @@ -945,20 +1663,46 @@ void mob_handle_back(void) { // keeps its placeholder insets until something incidental repaints it, which on // a prewarmed launch means the user's first visible frame is wrong. // -// Sending to :mob_screen reaches the router, whose catch-all forwards to the -// current screen; Mob.Screen.Server invalidates its cached insets and repaints. +// The message goes to the router of the window that connected (MOB-245), not +// to :mob_screen: with the primary's window detached, :mob_screen's screens +// would re-read their insets from another window. The call has no argument +// (the generated SceneDelegate makes it), so the window is found as the +// attached scene whose window now exists and that has not been told yet. +// The SceneDelegate calls this before MobHostingController attaches its +// scene (viewIsAppearing) and before UISceneWillConnectNotification arrives, +// so every application scene with a window is attached here first (attach +// is idempotent, and announces the scene to Mob.Scenes before this message). +// Without a tracked scene, :mob_screen as before. The router's catch-all +// forwards the message to its current screen; Mob.Screen.Server invalidates +// its cached insets and repaints. Main thread. void mob_notify_window_connected(void) { // Before the BEAM is up there is no screen to tell, and the boot path reads // the window's insets itself. Calling into erts here would crash (MOB-199). if (!mob_runtime_up()) return; + for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) + if (mob_scene_is_application(scene) && ((UIWindowScene *)scene).windows.count > 0) + mob_scene_attach((UIWindowScene *)scene); + NSMutableArray *connected = [NSMutableArray array]; + BOOL tracked = NO; + @synchronized(mob_scene_lock()) { + tracked = g_scene_entries.count > 0; + for (MobSceneEntry *entry in g_scene_entries) { + if (!entry.attached || entry.tapSet < 0 || entry.windowNotified || + entry.scene.windows.count == 0) + continue; + entry.windowNotified = YES; + [connected addObject:entry.sceneId]; + } + } + NSArray *targets = tracked ? connected : @[ [NSNull null] ]; ErlNifEnv *env = enif_alloc_env(); - ErlNifPid pid; - if (enif_whereis_pid(env, enif_make_atom(env, "mob_screen"), &pid)) { + for (id target in targets) { + // enif_send clears env, so each message is built afresh. ERL_NIF_TERM msg = enif_make_tuple2(env, enif_make_atom(env, "mob_window"), enif_make_atom(env, "connected")); - enif_send(NULL, &pid, env, msg); + mob_send_window_message(target == [NSNull null] ? nil : target, env, msg); } enif_free_env(env); } @@ -966,7 +1710,7 @@ void mob_notify_window_connected(void) { // ── Change senders ──────────────────────────────────────────────────────────── // Called from MobNode onChange blocks when an input widget fires. -static void mob_send_change(int handle, ERL_NIF_TERM value_term) { +static void mob_send_change(MobEventRef handle, ERL_NIF_TERM value_term) { ErlNifEnv *msg_env = enif_alloc_env(); if (!msg_env) return; @@ -983,7 +1727,7 @@ static void mob_send_change(int handle, ERL_NIF_TERM value_term) { enif_free_env(msg_env); } -static void mob_send_change_str(int handle, const char *utf8) { +static void mob_send_change_str(MobEventRef handle, const char *utf8) { ErlNifEnv *tmp = enif_alloc_env(); ErlNifBinary bin; size_t len = strlen(utf8); @@ -994,14 +1738,14 @@ static void mob_send_change_str(int handle, const char *utf8) { enif_free_env(tmp); } -static void mob_send_change_bool(int handle, int bool_val) { +static void mob_send_change_bool(MobEventRef handle, int bool_val) { ErlNifEnv *tmp = enif_alloc_env(); ERL_NIF_TERM term = enif_make_atom(tmp, bool_val ? "true" : "false"); mob_send_change(handle, term); enif_free_env(tmp); } -static void mob_send_change_float(int handle, double value) { +static void mob_send_change_float(MobEventRef handle, double value) { ErlNifEnv *tmp = enif_alloc_env(); ERL_NIF_TERM term = enif_make_double(tmp, value); mob_send_change(handle, term); @@ -1012,12 +1756,14 @@ static void mob_send_change_float(int handle, double value) { // Snapshot every route under one lock before sending that first message, so // submit and blur still target the field that originated this delegate turn // even when the repaint removes it. -static void mob_send_text_final(int change_handle, int submit_handle, int blur_handle, - const char *utf8, BOOL send_change, BOOL send_submit, - BOOL send_blur) { - ErlNifEnv *change_env = send_change && change_handle > 0 ? enif_alloc_env() : NULL; - ErlNifEnv *submit_env = send_submit && submit_handle > 0 ? enif_alloc_env() : NULL; - ErlNifEnv *blur_env = send_blur && blur_handle > 0 ? enif_alloc_env() : NULL; +static void mob_send_text_final(MobEventRef change_handle, MobEventRef submit_handle, + MobEventRef blur_handle, const char *utf8, BOOL send_change, + BOOL send_submit, BOOL send_blur) { + ErlNifEnv *change_env = + send_change && mob_ref_handle(change_handle) > 0 ? enif_alloc_env() : NULL; + ErlNifEnv *submit_env = + send_submit && mob_ref_handle(submit_handle) > 0 ? enif_alloc_env() : NULL; + ErlNifEnv *blur_env = send_blur && mob_ref_handle(blur_handle) > 0 ? enif_alloc_env() : NULL; TapSnap change_snap; TapSnap submit_snap; @@ -1512,7 +2258,7 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { node.activeTab = [activeTab description]; id onTabSelect = pv[MOB_PROP_on_tab_select]; if (onTabSelect && [onTabSelect isKindOfClass:[NSNumber class]]) { - int handle = [onTabSelect intValue]; + MobEventRef handle = mob_event_ref([onTabSelect intValue]); node.onTabSelect = ^(NSString *tabId) { mob_send_change_str(handle, [tabId UTF8String]); }; @@ -1612,7 +2358,7 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { id onTap = pv[MOB_PROP_on_tap]; if (onTap && [onTap isKindOfClass:[NSNumber class]]) { - int handle = [onTap intValue]; + MobEventRef handle = mob_event_ref([onTap intValue]); // A node that observes press in/out re-renders mid-touch, so its // tap resolves identity-tolerantly; every other tap stays strict. BOOL pressNode = [pv[MOB_PROP_on_press_in] isKindOfClass:[NSNumber class]] || @@ -1661,7 +2407,7 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { id onFocus = pv[MOB_PROP_on_focus]; if (onFocus && [onFocus isKindOfClass:[NSNumber class]]) { - int handle = [onFocus intValue]; + MobEventRef handle = mob_event_ref([onFocus intValue]); node.onFocus = ^{ mob_send_focus(handle); }; @@ -1669,7 +2415,7 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { id onBlur = pv[MOB_PROP_on_blur]; if (onBlur && [onBlur isKindOfClass:[NSNumber class]]) { - int handle = [onBlur intValue]; + MobEventRef handle = mob_event_ref([onBlur intValue]); node.onBlur = ^{ mob_send_blur(handle); }; @@ -1677,7 +2423,7 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { id onSubmit = pv[MOB_PROP_on_submit]; if (onSubmit && [onSubmit isKindOfClass:[NSNumber class]]) { - int handle = [onSubmit intValue]; + MobEventRef handle = mob_event_ref([onSubmit intValue]); node.onSubmit = ^{ mob_send_submit(handle); }; @@ -1685,7 +2431,7 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { id onCompose = pv[MOB_PROP_on_compose]; if (onCompose && [onCompose isKindOfClass:[NSNumber class]]) { - int handle = [onCompose intValue]; + MobEventRef handle = mob_event_ref([onCompose intValue]); node.onCompose = ^(NSString *text, NSString *phase) { mob_send_compose(handle, text ? [text UTF8String] : "", phase ? [phase UTF8String] : "updating"); @@ -1694,7 +2440,7 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { id onSelect = pv[MOB_PROP_on_select]; if (onSelect && [onSelect isKindOfClass:[NSNumber class]]) { - int handle = [onSelect intValue]; + MobEventRef handle = mob_event_ref([onSelect intValue]); node.onSelect = ^{ mob_send_select(handle); }; @@ -1703,7 +2449,7 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { // ── Gestures (Batch 4) ── id onLongPress = pv[MOB_PROP_on_long_press]; if (onLongPress && [onLongPress isKindOfClass:[NSNumber class]]) { - int handle = [onLongPress intValue]; + MobEventRef handle = mob_event_ref([onLongPress intValue]); node.onLongPress = ^{ mob_send_long_press(handle); }; @@ -1714,8 +2460,8 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { BOOL hasPressIn = onPressIn && [onPressIn isKindOfClass:[NSNumber class]]; BOOL hasPressOut = onPressOut && [onPressOut isKindOfClass:[NSNumber class]]; if (hasPressIn || hasPressOut) { - int inHandle = hasPressIn ? [onPressIn intValue] : 0; - int outHandle = hasPressOut ? [onPressOut intValue] : 0; + MobEventRef inHandle = mob_event_ref(hasPressIn ? [onPressIn intValue] : 0); + MobEventRef outHandle = mob_event_ref(hasPressOut ? [onPressOut intValue] : 0); node.onPress = ^void(^_Nullable(void))(void) { return mob_begin_press(inHandle, outHandle); }; @@ -1723,7 +2469,7 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { id onDoubleTap = pv[MOB_PROP_on_double_tap]; if (onDoubleTap && [onDoubleTap isKindOfClass:[NSNumber class]]) { - int handle = [onDoubleTap intValue]; + MobEventRef handle = mob_event_ref([onDoubleTap intValue]); node.onDoubleTap = ^{ mob_send_double_tap(handle); }; @@ -1731,7 +2477,7 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { id onSwipe = pv[MOB_PROP_on_swipe]; if (onSwipe && [onSwipe isKindOfClass:[NSNumber class]]) { - int handle = [onSwipe intValue]; + MobEventRef handle = mob_event_ref([onSwipe intValue]); node.onSwipe = ^(NSString *direction) { mob_send_swipe_with_direction(handle, [direction UTF8String]); }; @@ -1739,7 +2485,7 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { id onSwipeLeft = pv[MOB_PROP_on_swipe_left]; if (onSwipeLeft && [onSwipeLeft isKindOfClass:[NSNumber class]]) { - int handle = [onSwipeLeft intValue]; + MobEventRef handle = mob_event_ref([onSwipeLeft intValue]); node.onSwipeLeft = ^{ mob_send_swipe_left(handle); }; @@ -1747,7 +2493,7 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { id onSwipeRight = pv[MOB_PROP_on_swipe_right]; if (onSwipeRight && [onSwipeRight isKindOfClass:[NSNumber class]]) { - int handle = [onSwipeRight intValue]; + MobEventRef handle = mob_event_ref([onSwipeRight intValue]); node.onSwipeRight = ^{ mob_send_swipe_right(handle); }; @@ -1755,7 +2501,7 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { id onSwipeUp = pv[MOB_PROP_on_swipe_up]; if (onSwipeUp && [onSwipeUp isKindOfClass:[NSNumber class]]) { - int handle = [onSwipeUp intValue]; + MobEventRef handle = mob_event_ref([onSwipeUp intValue]); node.onSwipeUp = ^{ mob_send_swipe_up(handle); }; @@ -1763,7 +2509,7 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { id onSwipeDown = pv[MOB_PROP_on_swipe_down]; if (onSwipeDown && [onSwipeDown isKindOfClass:[NSNumber class]]) { - int handle = [onSwipeDown intValue]; + MobEventRef handle = mob_event_ref([onSwipeDown intValue]); node.onSwipeDown = ^{ mob_send_swipe_down(handle); }; @@ -1787,7 +2533,7 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { id onScroll = pv[MOB_PROP_on_scroll]; if ([onScroll isKindOfClass:[NSNumber class]]) { - int handle = [onScroll intValue]; + MobEventRef handle = mob_event_ref([onScroll intValue]); MOB_APPLY_THROTTLE(handle, @"scroll_config"); node.onScroll = ^(CGFloat dx, CGFloat dy, CGFloat x, CGFloat y, CGFloat vx, CGFloat vy, NSString *phase) { @@ -1798,7 +2544,7 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { id onDrag = pv[MOB_PROP_on_drag]; if ([onDrag isKindOfClass:[NSNumber class]]) { - int handle = [onDrag intValue]; + MobEventRef handle = mob_event_ref([onDrag intValue]); MOB_APPLY_THROTTLE(handle, @"drag_config"); node.onDrag = ^(CGFloat dx, CGFloat dy, CGFloat x, CGFloat y, NSString *phase) { mob_send_drag(handle, x, y, dx, dy, phase ? [phase UTF8String] : "dragging"); @@ -1807,7 +2553,7 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { id onPinch = pv[MOB_PROP_on_pinch]; if ([onPinch isKindOfClass:[NSNumber class]]) { - int handle = [onPinch intValue]; + MobEventRef handle = mob_event_ref([onPinch intValue]); MOB_APPLY_THROTTLE(handle, @"pinch_config"); node.onPinch = ^(CGFloat scale, CGFloat velocity, NSString *phase) { mob_send_pinch(handle, scale, velocity, phase ? [phase UTF8String] : "dragging"); @@ -1816,7 +2562,7 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { id onRotate = pv[MOB_PROP_on_rotate]; if ([onRotate isKindOfClass:[NSNumber class]]) { - int handle = [onRotate intValue]; + MobEventRef handle = mob_event_ref([onRotate intValue]); MOB_APPLY_THROTTLE(handle, @"rotate_config"); node.onRotate = ^(CGFloat degrees, CGFloat velocity, NSString *phase) { mob_send_rotate(handle, degrees, velocity, phase ? [phase UTF8String] : "dragging"); @@ -1825,7 +2571,7 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { id onPointerMove = pv[MOB_PROP_on_pointer_move]; if ([onPointerMove isKindOfClass:[NSNumber class]]) { - int handle = [onPointerMove intValue]; + MobEventRef handle = mob_event_ref([onPointerMove intValue]); MOB_APPLY_THROTTLE(handle, @"pointer_config"); node.onPointerMove = ^(CGFloat x, CGFloat y) { mob_send_pointer_move(handle, x, y); @@ -1837,7 +2583,7 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { // ── Batch 5 Tier 2: semantic single-fire scroll events ── id onScrollBegan = pv[MOB_PROP_on_scroll_began]; if ([onScrollBegan isKindOfClass:[NSNumber class]]) { - int handle = [onScrollBegan intValue]; + MobEventRef handle = mob_event_ref([onScrollBegan intValue]); node.onScrollBegan = ^{ mob_send_scroll_began(handle); }; @@ -1845,7 +2591,7 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { id onScrollEnded = pv[MOB_PROP_on_scroll_ended]; if ([onScrollEnded isKindOfClass:[NSNumber class]]) { - int handle = [onScrollEnded intValue]; + MobEventRef handle = mob_event_ref([onScrollEnded intValue]); node.onScrollEnded = ^{ mob_send_scroll_ended(handle); }; @@ -1853,7 +2599,7 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { id onScrollSettled = pv[MOB_PROP_on_scroll_settled]; if ([onScrollSettled isKindOfClass:[NSNumber class]]) { - int handle = [onScrollSettled intValue]; + MobEventRef handle = mob_event_ref([onScrollSettled intValue]); node.onScrollSettled = ^{ mob_send_scroll_settled(handle); }; @@ -1861,7 +2607,7 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { id onTopReached = pv[MOB_PROP_on_top_reached]; if ([onTopReached isKindOfClass:[NSNumber class]]) { - int handle = [onTopReached intValue]; + MobEventRef handle = mob_event_ref([onTopReached intValue]); node.onTopReached = ^{ mob_send_top_reached(handle); }; @@ -1869,7 +2615,7 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { id onScrolledPast = pv[MOB_PROP_on_scrolled_past]; if ([onScrolledPast isKindOfClass:[NSNumber class]]) { - int handle = [onScrolledPast intValue]; + MobEventRef handle = mob_event_ref([onScrolledPast intValue]); node.onScrolledPast = ^{ mob_send_scrolled_past(handle); }; @@ -2059,7 +2805,7 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { id onDismiss = pv[MOB_PROP_on_dismiss]; if (onDismiss && [onDismiss isKindOfClass:[NSNumber class]]) { - int handle = [onDismiss intValue]; + MobEventRef handle = mob_event_ref([onDismiss intValue]); node.onDismiss = ^{ mob_send_dismiss(handle); }; @@ -2099,7 +2845,7 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { id onEndReached = pv[MOB_PROP_on_end_reached]; if (onEndReached && [onEndReached isKindOfClass:[NSNumber class]]) { - int handle = [onEndReached intValue]; + MobEventRef handle = mob_event_ref([onEndReached intValue]); node.onTap = ^{ mob_send_tap(handle); }; @@ -2110,7 +2856,7 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { id onChange = pv[MOB_PROP_on_change]; if (onChange && [onChange isKindOfClass:[NSNumber class]]) { - int handle = [onChange intValue]; + MobEventRef handle = mob_event_ref([onChange intValue]); switch (node.nodeType) { case MobNodeTypeTextField: node.onChangeStr = ^(NSString *v) { @@ -2137,9 +2883,12 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { int submit_handle = [onSubmit isKindOfClass:[NSNumber class]] ? [onSubmit intValue] : 0; int blur_handle = [onBlur isKindOfClass:[NSNumber class]] ? [onBlur intValue] : 0; if (change_handle > 0 || submit_handle > 0 || blur_handle > 0) { + MobEventRef change_ref = mob_event_ref(change_handle); + MobEventRef submit_ref = mob_event_ref(submit_handle); + MobEventRef blur_ref = mob_event_ref(blur_handle); node.onFinalizeText = ^(NSString *text, BOOL send_change, BOOL send_submit, BOOL send_blur) { - mob_send_text_final(change_handle, submit_handle, blur_handle, + mob_send_text_final(change_ref, submit_ref, blur_ref, text ? [text UTF8String] : "", send_change, send_submit, send_blur); }; @@ -2846,19 +3595,19 @@ static ERL_NIF_TERM nif_device_keep_awake(ErlNifEnv *env, int argc, const ERL_NI // answer", and the caller retries on a later paint instead of caching it. Never // blocking forever matters more than either — an app that never boots is a far // worse bug than a screen that pads wrongly for one frame. +// +// safe_area/1 takes a scene id (MOB-245) and reads that scene's window; a +// scene native doesn't know answers no_window. safe_area/0 reads the default +// scene's window, so a second window can't hand the primary router its insets. static ERL_NIF_TERM nif_safe_area(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); __block UIEdgeInsets insets = UIEdgeInsetsZero; __block BOOL had_window = NO; dispatch_semaphore_t done = dispatch_semaphore_create(0); dispatch_async(dispatch_get_main_queue(), ^{ - UIWindow *window = nil; - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if ([scene isKindOfClass:[UIWindowScene class]]) { - UIWindowScene *ws = (UIWindowScene *)scene; - window = ws.windows.firstObject; - break; - } - } + UIWindow *window = mob_window_scene_for(sceneId).windows.firstObject; if (window) { insets = window.safeAreaInsets; had_window = YES; @@ -2906,20 +3655,20 @@ static ERL_NIF_TERM nif_safe_area(ErlNifEnv *env, int argc, const ERL_NIF_TERM a } } +// size_class/1 takes a scene id and reads that scene's window, like +// safe_area/1; size_class/0 reads the default scene's. static ERL_NIF_TERM nif_size_class(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); __block UIUserInterfaceSizeClass h = UIUserInterfaceSizeClassUnspecified; __block UIUserInterfaceSizeClass v = UIUserInterfaceSizeClassUnspecified; dispatch_semaphore_t done = dispatch_semaphore_create(0); dispatch_async(dispatch_get_main_queue(), ^{ - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if ([scene isKindOfClass:[UIWindowScene class]]) { - UIWindow *window = ((UIWindowScene *)scene).windows.firstObject; - if (window) { - h = window.traitCollection.horizontalSizeClass; - v = window.traitCollection.verticalSizeClass; - } - break; - } + UIWindow *window = mob_window_scene_for(sceneId).windows.firstObject; + if (window) { + h = window.traitCollection.horizontalSizeClass; + v = window.traitCollection.verticalSizeClass; } dispatch_semaphore_signal(done); }); @@ -2937,10 +3686,11 @@ static ERL_NIF_TERM nif_size_class(ErlNifEnv *env, int argc, const ERL_NIF_TERM } // Called from MobRootView.swift when its horizontal/vertical size class pair -// changes, and once when it first appears. Sends {:mob_size_class, H, V} to -// the :mob_screen router, which hands it to every live screen. Screens drop a -// value they already hold, so reporting the same pair twice is harmless. -void mob_notify_size_class(const char *horizontal, const char *vertical) { +// changes, and once when it first appears, with the id of the scene the root +// view is in (NULL before it knows). Sends {:mob_size_class, H, V} to that +// window's router, which hands it to every live screen. Screens drop a value +// they already hold, so reporting the same pair twice is harmless. +void mob_notify_size_class(const char *scene_id, const char *horizontal, const char *vertical) { if (!horizontal || !vertical) return; // Before the BEAM is up there is no screen to tell; screens mounted later @@ -2949,13 +3699,10 @@ void mob_notify_size_class(const char *horizontal, const char *vertical) { if (!mob_runtime_up()) return; ErlNifEnv *env = enif_alloc_env(); - ErlNifPid pid; - if (enif_whereis_pid(env, enif_make_atom(env, "mob_screen"), &pid)) { - ERL_NIF_TERM msg = - enif_make_tuple3(env, enif_make_atom(env, "mob_size_class"), - enif_make_atom(env, horizontal), enif_make_atom(env, vertical)); - enif_send(NULL, &pid, env, msg); - } + ERL_NIF_TERM msg = + enif_make_tuple3(env, enif_make_atom(env, "mob_size_class"), + enif_make_atom(env, horizontal), enif_make_atom(env, vertical)); + mob_send_window_message(scene_id ? [NSString stringWithUTF8String:scene_id] : nil, env, msg); enif_free_env(env); } @@ -3083,6 +3830,27 @@ static ERL_NIF_TERM nif_set_root(ErlNifEnv *env, int argc, const ERL_NIF_TERM ar return enif_make_atom(env, "error"); } + // Which window this frame is for (MOB-245). A root with no "scene" key + // comes from the unbound primary router and goes to the default scene's + // set 0, as every frame did before scenes existed. A scene native no + // longer knows was built into the scratch set by clear_taps/1 and is + // committed there and shown nowhere. + id sceneRaw = ((NSDictionary *)json)[@"scene"]; + NSString *sceneId = [sceneRaw isKindOfClass:[NSString class]] ? sceneRaw : nil; + int set = sceneId ? mob_scene_tap_set(sceneId) : 0; + if (set < 0) + set = MOB_TAP_SCRATCH_SET; + if (set != tap_build_set) { + // clear_taps prepared another set, so the handles in this tree name + // that set's generation. Committing either way would wire the tree to + // the wrong table. + LOGE(@"set_root: frame for %@ was built for tap set %d, not %d; dropped", + sceneId ?: @"the default scene", tap_build_set, set); + return enif_make_atom(env, "ok"); + } + MobTapSet *ts = &tap_sets[set]; + + g_node_tap_set = set; MobNode *node = mob_node_from_dict((NSDictionary *)json); if (!node) return enif_make_atom(env, "error"); @@ -3102,7 +3870,11 @@ static ERL_NIF_TERM nif_set_root(ErlNifEnv *env, int argc, const ERL_NIF_TERM ar // is dispatched to the main thread async — the teardown animation // outlives this call). Elements that stay in the tree but stop being // laid out are handled separately, via mob_unregister_frame. - mob_adopt_frame_ids(mob_collect_frame_ids(node)); + // + // The registry belongs to the default scene (set 0): further windows do + // not take part in it (decisions/2026-10-02-one-router-per-window-scene.md). + if (set == 0) + mob_adopt_frame_ids(mob_collect_frame_ids(node)); // Snapshot and reset the transition enif_mutex_lock(tap_mutex); @@ -3110,34 +3882,48 @@ static ERL_NIF_TERM nif_set_root(ErlNifEnv *env, int argc, const ERL_NIF_TERM ar strncpy(transition, g_transition, sizeof(transition) - 1); transition[sizeof(transition) - 1] = 0; strncpy(g_transition, "none", sizeof(g_transition)); + // The window this frame was resolved for closed while it was being built, + // and its set was released (and may already be another window's): commit + // nothing, show nothing. Checked under the lock the release takes, so the + // release either came first or frees what is committed here. + uint64_t epoch = tap_build_epoch; + if (ts->epoch != epoch) { + tap_exhausted_count = 0; + ts->build_count = 0; + enif_mutex_unlock(tap_mutex); + LOGE(@"set_root: tap set %d was released while a frame for %@ was built; dropped", set, + sceneId ?: @"the default scene"); + return enif_make_atom(env, "ok"); + } // Commit the freshly-built tap table: register_tap wrote this frame's - // handlers into 1 - tap_active; make that table active now so events for the - // new tree resolve against it (readers see a consistent pair under the lock). - TapHandle *previous = tap_tables[tap_active]; - TapHandle *build = tap_tables[1 - tap_active]; + // handlers into 1 - ts->active; make that table active now so events for + // the new tree resolve against it (readers see a consistent pair under the + // lock). + TapHandle *previous = ts->tables[ts->active]; + TapHandle *build = ts->tables[1 - ts->active]; // Bound the commit by what the tables can actually hold, not by - // tap_build_count alone. + // ts->build_count alone. // - // Only clear_taps resets tap_build_count, so a set_root that arrives without + // Only clear_taps resets build_count, so a set_root that arrives without // an intervening clear_taps carries the previous frame's count. That used to // be merely wrong — both tables were a fixed 256, so the worst case was // committing the wrong handlers. Now the two tables are separate heap // allocations that can differ in size, and an unbounded loop over a stale - // count reads (and, once tap_handle_next is set from it, writes) past the + // count reads (and, once handle_next is set from it, writes) past the // end of whichever is smaller. // // Mob.Sender is the single writer today, so this needs two screens racing // clear/register/set_root to reach — the race Mob.Sender's own moduledoc // describes, whose documented consequence is a mixed-up table. It should // stay a correctness bug, not become memory corruption. - int build_cap = tap_table_capacity[1 - tap_active]; - int prev_cap = tap_table_capacity[tap_active]; - int committed = tap_build_count < build_cap ? tap_build_count : build_cap; + int build_cap = ts->capacity[1 - ts->active]; + int prev_cap = ts->capacity[ts->active]; + int committed = ts->build_count < build_cap ? ts->build_count : build_cap; if (build) { for (int slot = 0; slot < committed; slot++) { - if (previous && slot < tap_handle_next && slot < prev_cap && previous[slot].tag_env && + if (previous && slot < ts->handle_next && slot < prev_cap && previous[slot].tag_env && previous[slot].pid.pid == build[slot].pid.pid && enif_compare(previous[slot].tag, build[slot].tag) == 0) build[slot].identity_start_generation = previous[slot].identity_start_generation; @@ -3150,14 +3936,13 @@ static ERL_NIF_TERM nif_set_root(ErlNifEnv *env, int argc, const ERL_NIF_TERM ar int exhausted_this_frame = tap_exhausted_count; tap_exhausted_count = 0; - tap_active = 1 - tap_active; - tap_handles = tap_tables[tap_active]; - tap_handle_next = committed; - tap_table_generations[tap_active] = tap_build_generation; + ts->active = 1 - ts->active; + ts->handle_next = committed; + ts->generations[ts->active] = ts->build_generation; // Reset here as well as in clear_taps, so a second set_root without an // intervening clear_taps commits an empty table rather than re-committing // this frame's handlers against whatever the other table now holds. - tap_build_count = 0; + ts->build_count = 0; enif_mutex_unlock(tap_mutex); // Bump the frame generation on a real navigation, so trackers belonging to @@ -3173,7 +3958,7 @@ static ERL_NIF_TERM nif_set_root(ErlNifEnv *env, int argc, const ERL_NIF_TERM ar // a screen you pop back to would be refused for ever. (`.move` transitions change their global // frames continuously, so they keep firing onChange the whole way out; tree membership alone // can't reject them when both screens tag the same :id.) - if (strcmp(transition, "none") != 0) + if (set == 0 && strcmp(transition, "none") != 0) mob_bump_frame_generation(); if (exhausted_this_frame > 0) { @@ -3197,7 +3982,17 @@ static ERL_NIF_TERM nif_set_root(ErlNifEnv *env, int argc, const ERL_NIF_TERM ar id replacesRaw = ((NSDictionary *)json)[@"replaces_stack"]; BOOL replacesStack = [replacesRaw isKindOfClass:[NSNumber class]] && [replacesRaw boolValue]; - [[MobViewModel shared] setRoot:node transition:transitionStr replacesStack:replacesStack]; + // Shown only if the set is still leased when the main thread gets to it: + // the window can close between the commit above and then, and the next + // window to take the set must not come up on this tree (the model looked + // up here may already be that window's). + MobViewModel *model = set == MOB_TAP_SCRATCH_SET ? nil : mob_scene_model_for_set(set); + [model setRoot:node + transition:transitionStr + replacesStack:replacesStack + current:^BOOL { + return mob_tap_set_leased(set, epoch); + }]; return enif_make_atom(env, "ok"); } @@ -3221,8 +4016,9 @@ static ERL_NIF_TERM nif_register_tap(ErlNifEnv *env, int argc, const ERL_NIF_TER } enif_mutex_lock(tap_mutex); - if (tap_build_count >= MOB_TAP_SLOT_LIMIT || - !mob_tap_grow_locked(1 - tap_active, tap_build_count + 1)) { + MobTapSet *ts = &tap_sets[tap_build_set]; + if (ts->build_count >= MOB_TAP_SLOT_LIMIT || + !mob_tap_grow_locked(ts, 1 - ts->active, ts->build_count + 1)) { // Counted under the mutex, like the Zig side: set_root reads and resets // this under the same lock. Mob.Sender serialises every caller today, so // an unguarded read-modify-write would be benign — but nothing else in @@ -3245,12 +4041,12 @@ static ERL_NIF_TERM nif_register_tap(ErlNifEnv *env, int argc, const ERL_NIF_TER // count is reported once per frame from set_root instead. return enif_make_int(env, -1); } - TapHandle *build = tap_tables[1 - tap_active]; - int slot = tap_build_count; - int handle = mob_encode_event_handle(tap_build_generation, slot); + TapHandle *build = ts->tables[1 - ts->active]; + int slot = ts->build_count; + int handle = mob_encode_event_handle(ts->build_generation, slot); if (handle < 0) { enif_mutex_unlock(tap_mutex); - LOGE(@"register_tap: invalid generation %u", tap_build_generation); + LOGE(@"register_tap: invalid generation %u", ts->build_generation); return enif_make_int(env, -1); } ErlNifEnv *tag_env = enif_alloc_env(); @@ -3262,8 +4058,8 @@ static ERL_NIF_TERM nif_register_tap(ErlNifEnv *env, int argc, const ERL_NIF_TER build[slot].pid = pid; build[slot].tag_env = tag_env; build[slot].tag = enif_make_copy(build[slot].tag_env, tag_term); - build[slot].identity_start_generation = tap_build_generation; - tap_build_count++; + build[slot].identity_start_generation = ts->build_generation; + ts->build_count++; // The high-water mark has to be raised HERE, not in set_root. clear_taps // frees exactly `used` slots, and a frame can register taps and then never // reach set_root — Mob.Renderer.render/4 calls clear_taps, then prepare, @@ -3271,47 +4067,51 @@ static ERL_NIF_TERM nif_register_tap(ErlNifEnv *env, int argc, const ERL_NIF_TER // that raises in between. Recording the mark only at set_root left those // slots' tag_envs uncleared and unreachable: one leaked ErlNifEnv per tap, // per failed frame, forever, on a path deliberately designed to survive. - tap_table_used[1 - tap_active] = tap_build_count; + ts->used[1 - ts->active] = ts->build_count; enif_mutex_unlock(tap_mutex); return enif_make_int(env, handle); } -// ── NIF: clear_taps/0 ───────────────────────────────────────────────────────── +// ── NIF: clear_taps/0, clear_taps/1 ─────────────────────────────────────────── +// clear_taps/0 starts a frame for the default scene (set 0); clear_taps/1 one +// for the scene whose id it is given (MOB-245), or for the scratch set when +// native no longer knows that scene. register_tap and set_root work on the set +// chosen here. static ERL_NIF_TERM nif_clear_taps(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + int set = 0; + if (argc == 1) { + NSString *sceneId = mob_scene_id_from_term(env, argv[0]); + if (!sceneId) + return enif_make_badarg(env); + set = mob_scene_tap_set(sceneId); + if (set < 0) + set = MOB_TAP_SCRATCH_SET; + } + enif_mutex_lock(tap_mutex); - tap_build_generation = mob_next_handle_generation(tap_build_generation); - tap_table_generations[1 - tap_active] = 0; + tap_build_set = set; + MobTapSet *ts = &tap_sets[set]; + // The lease this frame holds: set_root commits it, and the main thread + // shows it, only while the set has not been released since. + tap_build_epoch = ts->epoch; + ts->build_generation = mob_next_handle_generation(ts->build_generation); + ts->generations[1 - ts->active] = 0; // Prepare the INACTIVE (building) table for a fresh frame; leave the active // table intact so concurrent mob_send_* keep resolving the last committed // frame. The freshly built table is swapped in at set_root. - TapHandle *build = tap_tables[1 - tap_active]; - int used = tap_table_used[1 - tap_active]; - for (int i = 0; i < used; i++) { - if (build[i].tag_env) { - enif_free_env(build[i].tag_env); - build[i].tag_env = NULL; - } - // Reset throttle state — slots get reused across renders. - build[i].throttle_configured = 0; - build[i].throttle_ms = 0; - build[i].debounce_ms = 0; - build[i].delta_threshold = 0; - build[i].leading = 1; - build[i].trailing = 1; - build[i].last_emit_ns = 0; - build[i].last_x = 0; - build[i].last_y = 0; - build[i].seq = 0; - } - tap_table_used[1 - tap_active] = 0; + TapHandle *build = ts->tables[1 - ts->active]; + int used = ts->used[1 - ts->active]; + for (int i = 0; i < used; i++) + mob_tap_slot_free_locked(&build[i]); + ts->used[1 - ts->active] = 0; // Reset here, not only in set_root. set_root reports and clears the count, // but a frame that overflows and then never reaches set_root would otherwise // carry its overflow into the next frame's report — which claims to describe // "this frame". clear_taps is the one entry point every frame runs. tap_exhausted_count = 0; - tap_build_count = 0; + ts->build_count = 0; enif_mutex_unlock(tap_mutex); return enif_make_atom(env, "ok"); } @@ -3743,17 +4543,12 @@ static void mob_send3(const ErlNifPid *pid, const char *a1, const char *a2, cons enif_free_env(e); } -// Return the root view controller of the key window in the first active scene. +// Return the root view controller of the default scene's key window (the +// window the primary router shows; MOB-245), else of the first window scene. static UIViewController *mob_root_vc(void) { - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if ([scene isKindOfClass:[UIWindowScene class]]) { - UIWindowScene *ws = (UIWindowScene *)scene; - UIWindow *w = ws.keyWindow ?: ws.windows.firstObject; - if (w.rootViewController) - return w.rootViewController; - } - } - return nil; + UIWindowScene *ws = mob_default_window_scene(); + UIWindow *w = ws.keyWindow ?: ws.windows.firstObject; + return w.rootViewController; } // ── Stored for the router ────────────────────────────────────────────────── @@ -6991,12 +7786,18 @@ static ERL_NIF_TERM nif_delete_backward(ErlNifEnv *env, int argc, const ERL_NIF_ enif_make_atom(env, "no_first_responder")); } -// ─── dismiss_keyboard/0 — take the keyboard down from the BEAM ─────────────── +// ─── dismiss_keyboard/0,1 — take the keyboard down from the BEAM ───────────── // -// Resigns the first responder found in any visible window of a connected -// scene — 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. +// 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 @@ -7008,12 +7809,19 @@ static ERL_NIF_TERM nif_delete_backward(ErlNifEnv *env, int argc, const ERL_NIF_ // {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; @@ -8220,35 +9028,88 @@ static ERL_NIF_TERM nif_storage_external_files_dir(ErlNifEnv *env, int argc, } // ── WebView ─────────────────────────────────────────────────────────────────── -// g_webview is set by MobWebView (MobRootView.swift) when the component is created. -// mob_deliver_webview_message / _blocked are called from Swift (via bridging header). - -static void deliver_webview_binary(const char *tag, const char *utf8) { +// MobWebView (MobRootView.swift) registers every WKWebView it shows with +// mob_webview_attach, saying each time whether its screen is the one on show +// or the one the two-slot presenter keeps parked (MOB-129). The webview NIFs +// act on one window's shown WebView (MOB-245): the arities with a leading +// scene id take the calling screen's window, the original ones the unbound +// primary's (tap set 0's window), and with no scene attached at all, any +// window, as before scenes. Swift calls mob_deliver_webview_message / +// _blocked with the scene the WebView's tree was built for, and the message +// goes to the router showing that window. + +static void deliver_webview_binary(const char *scene_id, const char *tag, const char *utf8) { ErlNifEnv *env = enif_alloc_env(); - ErlNifPid pid; - if (!enif_whereis_pid(env, enif_make_atom(env, "mob_screen"), &pid)) { - enif_free_env(env); - return; - } size_t len = strlen(utf8); ErlNifBinary bin; enif_alloc_binary(len, &bin); memcpy(bin.data, utf8, len); ERL_NIF_TERM msg = enif_make_tuple3(env, enif_make_atom(env, "webview"), enif_make_atom(env, tag), enif_make_binary(env, &bin)); - enif_send(NULL, &pid, env, msg); + mob_send_window_message(scene_id ? [NSString stringWithUTF8String:scene_id] : nil, env, msg); enif_free_env(env); } -void mob_deliver_webview_message(const char *json_utf8) { - deliver_webview_binary("message", json_utf8); +void mob_deliver_webview_message(const char *scene_id, const char *json_utf8) { + deliver_webview_binary(scene_id, "message", json_utf8); +} + +void mob_deliver_webview_blocked(const char *scene_id, const char *url_utf8) { + deliver_webview_binary(scene_id, "blocked", url_utf8); +} + +// Every WKWebView MobWebView shows, held weakly. Each carries, as an +// associated object, the order of its latest attach on a shown screen, or 0 +// while parked. Main thread only. +static NSHashTable *g_webviews; +static uint64_t g_webview_order; +static char kMobWebViewShownOrder; + +void mob_webview_attach(WKWebView *webview, BOOL shown) { + if (!g_webviews) + g_webviews = [NSHashTable weakObjectsHashTable]; + [g_webviews addObject:webview]; + objc_setAssociatedObject(webview, &kMobWebViewShownOrder, @(shown ? ++g_webview_order : 0), + OBJC_ASSOCIATION_RETAIN_NONATOMIC); } -void mob_deliver_webview_blocked(const char *url_utf8) { - deliver_webview_binary("blocked", url_utf8); +// The WebView shown in scene `sceneId`'s window (nil: the unbound primary's), +// the most recently attached if that screen has several; nil if the window +// shows none. A parked screen's WebView is never picked: back, JS and posted +// messages are for the page the user sees. Main thread. +static WKWebView *mob_webview_in(NSString *sceneId) { + UIWindowScene *scene = nil; + if (sceneId || mob_scene_attached_count() > 0) { + scene = sceneId ? mob_window_scene_for(sceneId) : mob_primary_window_scene(); + if (!scene) + return nil; + } + WKWebView *shown = nil; + uint64_t shownOrder = 0; + for (WKWebView *webview in g_webviews) { + if (scene && webview.window.windowScene != scene) + continue; + uint64_t order = + [objc_getAssociatedObject(webview, &kMobWebViewShownOrder) unsignedLongLongValue]; + if (order > shownOrder) { + shown = webview; + shownOrder = order; + } + } + return shown; } -WKWebView *g_webview = nil; +// The scene id argument of a webview NIF's scene arity (argc == scene_argc), +// else nil. *bad is set for a scene argument that is not a string. +static NSString *mob_webview_scene_arg(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[], + int scene_argc, BOOL *bad) { + *bad = NO; + if (argc != scene_argc) + return nil; + NSString *sceneId = mob_scene_id_from_term(env, argv[0]); + *bad = sceneId == nil; + return sceneId; +} // Camera preview session — OWNED by the mob_camera plugin (its NIF supplies the // strong definition and drives start/stop_preview). Defined weak here so core @@ -8261,19 +9122,42 @@ void mob_deliver_webview_blocked(const char *url_utf8) { // ── Alert delivery (called from UIAlertAction blocks) ──────────────────────── -static void mob_deliver_alert_action(const char *action) { +// `sceneId` is the window the alert was shown in (nil for the default scene's +// screens): the result goes back to that window's router. +static void mob_deliver_alert_action(NSString *sceneId, const char *action) { ErlNifEnv *env = enif_alloc_env(); - ErlNifPid pid; - if (enif_whereis_pid(env, enif_make_atom(env, "mob_screen"), &pid)) { - ERL_NIF_TERM msg = - enif_make_tuple2(env, enif_make_atom(env, "alert"), enif_make_atom(env, action)); - enif_send(NULL, &pid, env, msg); - } + ERL_NIF_TERM msg = + enif_make_tuple2(env, enif_make_atom(env, "alert"), enif_make_atom(env, action)); + mob_send_window_message(sceneId, env, msg); enif_free_env(env); } -// Returns the topmost presented view controller in a foreground scene. +// The topmost presented view controller of one window scene. +static UIViewController *mob_top_vc_in(UIWindowScene *window_scene) { + UIViewController *vc = window_scene.keyWindow.rootViewController; + if (!vc) { + for (UIWindow *window in window_scene.windows) { + if (window.rootViewController) { + vc = window.rootViewController; + break; + } + } + } + while (vc.presentedViewController) + vc = vc.presentedViewController; + return vc; +} + +// Returns the topmost presented view controller in a foreground scene: the +// default scene's when it is in the foreground (several windows, MOB-245), +// else the first foreground scene's. static UIViewController *root_vc(void) { + UIWindowScene *preferred = mob_default_window_scene(); + if (preferred.activationState == UISceneActivationStateForegroundActive) { + UIViewController *vc = mob_top_vc_in(preferred); + if (vc) + return vc; + } for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { if (![scene isKindOfClass:[UIWindowScene class]] || scene.activationState != UISceneActivationStateForegroundActive) @@ -8301,9 +9185,30 @@ static void mob_deliver_alert_action(const char *action) { return nil; } -// ── NIF: alert_show/3 ──────────────────────────────────────────────────────── +// Where an alert or action sheet shown for `sceneId` is presented: a bound +// screen's own window. The unbound primary router's screens (nil) are in tap +// set 0's window, so their alert goes there even while another window is in +// front, and nowhere while that window is detached and another is attached: +// presenting it in another window would answer one window's screen from +// another's (MOB-245). With no scene attached at all, root_vc() as before. +static UIViewController *mob_alert_presenter(NSString *sceneId) { + if (sceneId) + return mob_top_vc_in(mob_window_scene_for(sceneId)); + if (mob_scene_attached_count() == 0) + return root_vc(); + UIWindowScene *primary = mob_primary_window_scene(); + return primary ? mob_top_vc_in(primary) : nil; +} + +// ── NIF: alert_show/3, alert_show/4 ────────────────────────────────────────── +// alert_show/4 takes the scene id of the screen showing it first (MOB-245): the +// alert is presented in that window and its result routed back to it. static ERL_NIF_TERM nif_alert_show(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + NSString *sceneId = argc == 4 ? mob_scene_id_from_term(env, argv[0]) : nil; + if (argc == 4 && !sceneId) + return enif_make_badarg(env); + argv += argc - 3; ErlNifBinary title_bin, msg_bin, btns_bin; if (!enif_inspect_binary(env, argv[0], &title_bin) && !enif_inspect_iolist_as_binary(env, argv[0], &title_bin)) @@ -8348,19 +9253,25 @@ static ERL_NIF_TERM nif_alert_show(ErlNifEnv *env, int argc, const ERL_NIF_TERM [ac addAction:[UIAlertAction actionWithTitle:label style:as handler:^(UIAlertAction *_) { - mob_deliver_alert_action([action UTF8String]); + mob_deliver_alert_action(sceneId, + [action UTF8String]); }]]; } - UIViewController *vc = root_vc(); + UIViewController *vc = mob_alert_presenter(sceneId); if (vc) [vc presentViewController:ac animated:YES completion:nil]; }); return enif_make_atom(env, "ok"); } -// ── NIF: action_sheet_show/2 ───────────────────────────────────────────────── +// ── NIF: action_sheet_show/2, action_sheet_show/3 ──────────────────────────── +// action_sheet_show/3 takes a scene id first, like alert_show/4. static ERL_NIF_TERM nif_action_sheet_show(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + NSString *sceneId = argc == 3 ? mob_scene_id_from_term(env, argv[0]) : nil; + if (argc == 3 && !sceneId) + return enif_make_badarg(env); + argv += argc - 2; ErlNifBinary title_bin, btns_bin; if (!enif_inspect_binary(env, argv[0], &title_bin) && !enif_inspect_iolist_as_binary(env, argv[0], &title_bin)) @@ -8396,10 +9307,11 @@ static ERL_NIF_TERM nif_action_sheet_show(ErlNifEnv *env, int argc, const ERL_NI [ac addAction:[UIAlertAction actionWithTitle:label style:as handler:^(UIAlertAction *_) { - mob_deliver_alert_action([action UTF8String]); + mob_deliver_alert_action(sceneId, + [action UTF8String]); }]]; } - UIViewController *vc = root_vc(); + UIViewController *vc = mob_alert_presenter(sceneId); if (!vc) return; // iPad requires a source view for action sheets @@ -8480,24 +9392,32 @@ static ERL_NIF_TERM nif_toast_show(ErlNifEnv *env, int argc, const ERL_NIF_TERM return enif_make_atom(env, "ok"); } +// webview_eval_js/1,2, webview_post_message/1,2, webview_can_go_back/0,1 and +// webview_go_back/0,1: the scene arity takes the calling screen's scene id +// first; see mob_webview_in. + static ERL_NIF_TERM nif_webview_eval_js(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + BOOL bad; + NSString *sceneId = mob_webview_scene_arg(env, argc, argv, 2, &bad); ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) + if (bad || (!enif_inspect_binary(env, argv[argc - 1], &bin) && + !enif_inspect_iolist_as_binary(env, argv[argc - 1], &bin))) return enif_make_badarg(env); NSString *code = [[NSString alloc] initWithBytes:bin.data length:bin.size encoding:NSUTF8StringEncoding]; dispatch_async(dispatch_get_main_queue(), ^{ - [g_webview evaluateJavaScript:code completionHandler:nil]; + [mob_webview_in(sceneId) evaluateJavaScript:code completionHandler:nil]; }); return enif_make_atom(env, "ok"); } static ERL_NIF_TERM nif_webview_post_message(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + BOOL bad; + NSString *sceneId = mob_webview_scene_arg(env, argc, argv, 2, &bad); ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) + if (bad || (!enif_inspect_binary(env, argv[argc - 1], &bin) && + !enif_inspect_iolist_as_binary(env, argv[argc - 1], &bin))) return enif_make_badarg(env); NSString *json = [[NSString alloc] initWithBytes:bin.data length:bin.size @@ -8507,26 +9427,34 @@ static ERL_NIF_TERM nif_webview_post_message(ErlNifEnv *env, int argc, const ERL escaped = [escaped stringByReplacingOccurrencesOfString:@"'" withString:@"\\'"]; NSString *js = [NSString stringWithFormat:@"window.mob&&window.mob._dispatch('%@')", escaped]; dispatch_async(dispatch_get_main_queue(), ^{ - [g_webview evaluateJavaScript:js completionHandler:nil]; + [mob_webview_in(sceneId) evaluateJavaScript:js completionHandler:nil]; }); return enif_make_atom(env, "ok"); } static ERL_NIF_TERM nif_webview_can_go_back(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + BOOL bad; + NSString *sceneId = mob_webview_scene_arg(env, argc, argv, 1, &bad); + if (bad) + return enif_make_badarg(env); // dispatch_sync blocks this BEAM scheduler thread until the main queue drains. // Intentional — the caller (Mob.Screen back handler) needs the boolean before deciding // whether to pop the nav stack. Same pattern as clipboard_get and safe_area. // The main thread is expected to be idle during a back gesture. __block BOOL result = NO; dispatch_sync(dispatch_get_main_queue(), ^{ - result = g_webview ? [g_webview canGoBack] : NO; + result = [mob_webview_in(sceneId) canGoBack]; }); return enif_make_atom(env, result ? "true" : "false"); } static ERL_NIF_TERM nif_webview_go_back(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + BOOL bad; + NSString *sceneId = mob_webview_scene_arg(env, argc, argv, 1, &bad); + if (bad) + return enif_make_badarg(env); dispatch_async(dispatch_get_main_queue(), ^{ - [g_webview goBack]; + [mob_webview_in(sceneId) goBack]; }); return enif_make_atom(env, "ok"); } @@ -9340,11 +10268,18 @@ static ERL_NIF_TERM nif_post_mortem_android_drain(ErlNifEnv *env, int argc, {"set_theme", 1, nif_set_theme, ERL_NIF_DIRTY_JOB_IO_BOUND}, {"register_tap", 1, nif_register_tap, 0}, {"clear_taps", 0, nif_clear_taps, 0}, + {"clear_taps", 1, nif_clear_taps, 0}, {"exit_app", 0, nif_exit_app, 0}, {"safe_area", 0, nif_safe_area, ERL_NIF_DIRTY_JOB_IO_BOUND}, + {"safe_area", 1, nif_safe_area, ERL_NIF_DIRTY_JOB_IO_BOUND}, {"size_class", 0, nif_size_class, ERL_NIF_DIRTY_JOB_IO_BOUND}, + {"size_class", 1, nif_size_class, ERL_NIF_DIRTY_JOB_IO_BOUND}, + {"scenes", 0, nif_scenes, 0}, + {"scene_request", 0, nif_scene_request, ERL_NIF_DIRTY_JOB_IO_BOUND}, + {"scene_multiple_supported", 0, nif_scene_multiple_supported, ERL_NIF_DIRTY_JOB_IO_BOUND}, {"haptic", 1, nif_haptic, 0}, {"dismiss_keyboard", 0, nif_dismiss_keyboard, ERL_NIF_DIRTY_JOB_IO_BOUND}, + {"dismiss_keyboard", 1, nif_dismiss_keyboard, ERL_NIF_DIRTY_JOB_IO_BOUND}, {"torch", 1, nif_torch, 0}, {"clipboard_put", 1, nif_clipboard_put, 0}, {"clipboard_get", 0, nif_clipboard_get, ERL_NIF_DIRTY_JOB_IO_BOUND}, @@ -9379,12 +10314,18 @@ static ERL_NIF_TERM nif_post_mortem_android_drain(ErlNifEnv *env, int argc, {"storage_save_to_media_store", 2, nif_storage_save_to_media_store, 0}, {"storage_external_files_dir", 1, nif_storage_external_files_dir, 0}, {"alert_show", 3, nif_alert_show, 0}, + {"alert_show", 4, nif_alert_show, 0}, {"action_sheet_show", 2, nif_action_sheet_show, 0}, + {"action_sheet_show", 3, nif_action_sheet_show, 0}, {"toast_show", 2, nif_toast_show, 0}, {"webview_eval_js", 1, nif_webview_eval_js, 0}, + {"webview_eval_js", 2, nif_webview_eval_js, 0}, {"webview_post_message", 1, nif_webview_post_message, 0}, + {"webview_post_message", 2, nif_webview_post_message, 0}, {"webview_can_go_back", 0, nif_webview_can_go_back, ERL_NIF_DIRTY_JOB_IO_BOUND}, + {"webview_can_go_back", 1, nif_webview_can_go_back, ERL_NIF_DIRTY_JOB_IO_BOUND}, {"webview_go_back", 0, nif_webview_go_back, 0}, + {"webview_go_back", 1, nif_webview_go_back, 0}, {"register_component", 1, nif_register_component, 0}, {"deregister_component", 1, nif_deregister_component, 0}, // ── Mob.Peripheral.VendorUsb (iOS stubs — emit :unsupported) ────────────── diff --git a/lib/mob/alert.ex b/lib/mob/alert.ex index 3fe418b1..5ccc75e0 100644 --- a/lib/mob/alert.ex +++ b/lib/mob/alert.ex @@ -65,26 +65,40 @@ defmodule Mob.Alert do Result arrives as `{:alert, action_atom}` in `handle_info/2`. Dismissing the dialog without tapping a button (e.g. Android back gesture) sends `{:alert, :dismiss}`. + + In an app with several windows (`Mob.Scene`) the alert appears in the + window of the screen that showed it, and the result comes back there. """ @spec alert(Mob.Socket.t(), keyword()) :: Mob.Socket.t() def alert(socket, opts) do title = to_string(opts[:title] || "") message = to_string(opts[:message] || "") - buttons = opts[:buttons] || [[label: "OK", style: :cancel]] - :mob_nif.alert_show(title, message, encode_buttons(buttons)) + buttons = encode_buttons(opts[:buttons] || [[label: "OK", style: :cancel]]) + + case Mob.Scene.of(socket) do + nil -> :mob_nif.alert_show(title, message, buttons) + scene -> :mob_nif.alert_show(scene, title, message, buttons) + end + socket end @doc """ Show a bottom-anchored action sheet. - Result arrives as `{:alert, action_atom}` in `handle_info/2`. + Result arrives as `{:alert, action_atom}` in `handle_info/2`. Shown in the + screen's own window, like `alert/2`. """ @spec action_sheet(Mob.Socket.t(), keyword()) :: Mob.Socket.t() def action_sheet(socket, opts) do title = to_string(opts[:title] || "") - buttons = opts[:buttons] || [] - :mob_nif.action_sheet_show(title, encode_buttons(buttons)) + buttons = encode_buttons(opts[:buttons] || []) + + case Mob.Scene.of(socket) do + nil -> :mob_nif.action_sheet_show(title, buttons) + scene -> :mob_nif.action_sheet_show(scene, title, buttons) + end + socket end diff --git a/lib/mob/keyboard.ex b/lib/mob/keyboard.ex index 0a056cf4..bf06440b 100644 --- a/lib/mob/keyboard.ex +++ b/lib/mob/keyboard.ex @@ -20,8 +20,9 @@ defmodule Mob.Keyboard do A `value:` assigned in the same handler reaches the field, and the field's `on_blur` arrives, including when that handler re-renders. - iOS resigns the first responder found in any visible window of a connected - scene — the view the keyboard is up for — and returns once the main thread + iOS resigns the first responder found in a visible window of the screen's + own window scene (with several iPad windows, `Mob.Scene`, only that one's) + — the view the keyboard is up for — and returns once the main thread has done it, so the handler's re-render applies to a field that is no longer focused (a focused field keeps what the user typed rather than take a new `value:`). Android calls `MobBridge.dismissKeyboard()`, which hides @@ -46,7 +47,14 @@ defmodule Mob.Keyboard do # Guarded, unlike Mob.Haptic: this sits in handlers Mob.ScreenCase drives # on the host, where no NIF library loads and the call raises — a screen # that chains it has to stay testable there. - if function_exported?(nif, :dismiss_keyboard, 0), do: nif.dismiss_keyboard() + case Mob.Scene.of(socket) do + nil -> + if function_exported?(nif, :dismiss_keyboard, 0), do: nif.dismiss_keyboard() + + scene -> + if function_exported?(nif, :dismiss_keyboard, 1), do: nif.dismiss_keyboard(scene) + end + socket end end diff --git a/lib/mob/renderer.ex b/lib/mob/renderer.ex index b98ef8a9..bc9948c1 100644 --- a/lib/mob/renderer.ex +++ b/lib/mob/renderer.ex @@ -247,10 +247,18 @@ defmodule Mob.Renderer do Callers never construct the tuple; `Mob.Socket` validates the public `:push | :pop | :reset` vocabulary. + + `scene` is the window scene a router bound to one renders into (MOB-245): + the frame is built with `clear_taps/1` and carries `"scene"` on the JSON + root, so native builds, commits and shows it in that scene's own tap set + and view model. `nil`, the default, is the unbound path every + single-window app takes: `clear_taps/0` and no key, which native shows in + its default scene. See + `decisions/2026-10-02-one-router-per-window-scene.md`. """ - @spec render(map(), atom(), module() | atom(), atom() | {atom(), :replace}) :: + @spec render(map(), atom(), module() | atom(), atom() | {atom(), :replace}, String.t() | nil) :: {:ok, :json_tree} | {:error, term()} - def render(tree, platform, nif \\ @default_nif, transition \\ :none) do + def render(tree, platform, nif \\ @default_nif, transition \\ :none, scene \\ nil) do theme = Theme.current() ctx = %{ @@ -281,7 +289,7 @@ defmodule Mob.Renderer do anim -> {anim, false} end - nif.clear_taps() + if scene, do: nif.clear_taps(scene), else: nif.clear_taps() nif.set_transition(animation) prepared = Mob.RenderStats.time(:prepare_us, fn -> prepare(tree, nif, platform, ctx) end) @@ -290,6 +298,7 @@ defmodule Mob.Renderer do # read named keys and ignore the rest, so this is additive on the wire: # a host that does not know it behaves exactly as it did. prepared = if replaces_stack?, do: Map.put(prepared, "replaces_stack", true), else: prepared + prepared = if scene, do: Map.put(prepared, "scene", scene), else: prepared json = Mob.RenderStats.time(:encode_us, fn -> diff --git a/lib/mob/router.ex b/lib/mob/router.ex index dbc84253..4f19db80 100644 --- a/lib/mob/router.ex +++ b/lib/mob/router.ex @@ -39,6 +39,16 @@ defmodule Mob.Router do logical screen. See `decisions/2026-08-28-screen-processes-and-supervision.md`. + + ## One router per window scene + + An app with several windows (iPad scenes, MOB-245) runs one router per + window. The router `start_root/3` starts is the **primary**: it registers + `:mob_screen`, is unbound to any scene id and renders to native's default + scene, exactly as a single-window app always has. `Mob.Scenes` starts a + router per further scene with `start_scene/3`; those are **bound** to their + scene id, which their screens render into and read their window from. See + `decisions/2026-10-02-one-router-per-window-scene.md`. """ use GenServer @@ -68,11 +78,41 @@ defmodule Mob.Router do GenServer.start_link( __MODULE__, - {screen_module, params, :no_render, :android, nif, nil}, + {screen_module, params, :no_render, :android, nif, nil, %{primary: false, scene: nil}}, opts ) end + @doc """ + Start the router of a further window scene, bound to scene id `scene` + (MOB-245). `Mob.Scenes` calls this when a scene connects that no router + shows; apps don't. + + It renders like `start_root/3` but into `scene`, does not register + `:mob_screen` and does not end the app when it is left with no live screen: + that window keeps its last frame. Takes `:nif`. + """ + @spec start_scene(module(), map(), keyword()) :: GenServer.on_start() + def start_scene(screen_module, params, opts) do + {scene, opts} = Keyword.pop!(opts, :scene) + {nif, opts} = Keyword.pop(opts, :nif, :mob_nif) + roles = %{primary: false, scene: scene} + init_arg = {screen_module, params, :render, nif.platform(), nif, nil, roles} + + case GenServer.start_link(__MODULE__, init_arg, opts) do + {:error, reason} = error -> + Logger.error( + "[mob] the screen of window scene #{inspect(scene)} (#{inspect(screen_module)}) " <> + "failed to start: " <> Mob.CrashReport.format(reason) + ) + + error + + started -> + started + end + end + @doc """ Return the module of the currently active screen in the navigation stack. Intended for testing and debugging. @@ -129,7 +169,10 @@ defmodule Mob.Router do Keyword.pop(opts, :on_no_live_screen, default_on_no_live_screen(nif)) platform = nif.platform() - init_arg = {screen_module, params, :render, platform, nif, on_no_live_screen} + + init_arg = + {screen_module, params, :render, platform, nif, on_no_live_screen, + %{primary: true, scene: nil}} case GenServer.start_link(__MODULE__, init_arg, opts) do {:error, reason} = error -> @@ -232,7 +275,14 @@ defmodule Mob.Router do # ── GenServer callbacks ─────────────────────────────────────────────────── @impl GenServer + # The six-element form is what a caller compiled before MOB-245 passes (hot + # code push): a render-mode router started that way was the app's root. def init({screen_module, params, render_mode, platform, nif, on_no_live_screen}) do + roles = %{primary: render_mode == :render, scene: nil} + init({screen_module, params, render_mode, platform, nif, on_no_live_screen, roles}) + end + + def init({screen_module, params, render_mode, platform, nif, on_no_live_screen, roles}) do # Linked *and* trapping. Linking alone makes the owner die with any screen # it stops or that crashes; trapping alone orphans every screen when the # owner dies — and an orphaned persisted screen keeps dumping to @@ -240,8 +290,10 @@ defmodule Mob.Router do # owner sees each exit as a message and screens still come down with it. Process.flag(:trap_exit, true) + primary? = render_mode == :render and roles.primary + if render_mode == :render do - Process.register(self(), :mob_screen) + if primary?, do: Process.register(self(), :mob_screen) # Renders are casts, so a missing sender would blank the screen silently. Mob.Sender.ensure_started() # Started before the first render: that render is what bakes the @@ -249,6 +301,14 @@ defmodule Mob.Router do Mob.Listener.ensure_started() end + # The app's root: every further window scene starts from the same screen + # and params (MOB-245). A cast, so Mob.Scenes can tell this router which + # scene it now shows without calling into a process still in init. + if primary? do + Mob.Scenes.ensure_started() + Mob.Scenes.register_primary(self(), screen_module, params, nif) + end + # Notifications native stored while no router was registered: the tap # that launched the app from a killed state, and anything that arrived # during boot. Taken now, right after registering (see @@ -256,12 +316,13 @@ defmodule Mob.Router do # mounted, from init itself: a notification sent live while the screen # mounts waits in the mailbox, so it still arrives after these. stored_notifications = - if render_mode == :render, do: take_stored_notifications(nif), else: [] + if primary?, do: take_stored_notifications(nif), else: [] # Links native stored the same way (Mob.Link): the one that launched the # app, and any that arrived during boot. Taken now and delivered from init - # after the root screen mounts, for the same reasons. - stored_links = if render_mode == :render, do: take_stored_links(nif), else: [] + # after the root screen mounts, for the same reasons. Like notifications, + # only the app's root router takes them, not a further window's (MOB-245). + stored_links = if primary?, do: take_stored_links(nif), else: [] # Seed the stacks this app declared. The screen we are about to mount # becomes the active stack's current screen; every other declared stack @@ -274,24 +335,55 @@ defmodule Mob.Router do render_mode: render_mode, platform: platform, nif: nif, + scene: roles.scene, screens: %{}, restarts: %{}, on_no_live_screen: on_no_live_screen } - case start_screen(screen_module, params, state) do - {:ok, entry, state} -> - state = make_current(state, entry, :none) + if roles.scene do + # A further window's router (MOB-245) is started from Mob.Scenes, which + # must not wait on the root screen's mount: that mount may call + # Mob.Scene.list/0, and every window's events route through Mob.Scenes. + # Calls to this router queue behind the continue. + {:ok, state, {:continue, {:mount_root, screen_module, params}}} + else + case mount_root(screen_module, params, state) do + {:ok, state} -> + state = deliver_stored_notifications(stored_notifications, state) + {:ok, deliver_links(stored_links, :launch, state)} - if render_mode == :render do - paint(entry, :none, state) - end + {:error, reason} -> + {:stop, reason} + end + end + end - state = deliver_stored_notifications(stored_notifications, state) - {:ok, deliver_links(stored_links, :launch, state)} + @impl GenServer + def handle_continue({:mount_root, screen_module, params}, state) do + case mount_root(screen_module, params, state) do + {:ok, state} -> + {:noreply, state} {:error, reason} -> - {:stop, reason} + Logger.error( + "[mob] the screen of window scene #{inspect(state.scene)} (#{inspect(screen_module)}) " <> + "failed to start: " <> Mob.CrashReport.format(reason) + ) + + {:stop, reason, state} + end + end + + defp mount_root(screen_module, params, state) do + case start_screen(screen_module, params, state) do + {:ok, entry, state} -> + state = make_current(state, entry, :none) + if state.render_mode == :render, do: paint(entry, :none, state) + {:ok, state} + + {:error, _reason} = error -> + error end end @@ -418,10 +510,11 @@ defmodule Mob.Router do # System back gesture (Android hardware/swipe, iOS edge-pan). Handled here so # every screen gets back navigation without implementing anything. If a - # WebView is present and has internal history, navigate within it first. + # WebView is present in this router's window and has internal history, + # navigate within it first. def handle_info({:mob, :back}, state) do - if state.render_mode == :render && state.nif.webview_can_go_back() do - state.nif.webview_go_back() + if state.render_mode == :render && webview_can_go_back?(state) do + webview_go_back(state) {:noreply, state} else case {Mob.Nav.history(state.nav), Mob.Nav.back_target(state.nav)} do @@ -449,6 +542,23 @@ defmodule Mob.Router do {:noreply, state} end + # Mob.Scenes moved this router to another window scene (MOB-245): the scene + # it showed was the last and went away, and iPadOS connected a new session + # instead of bringing it back. The screens hear first, so they read the new + # window from here on; then the current one is re-activated under the new + # scene, which drops the old scene from the sender, and repainted into it. + def handle_info({:mob_scene_bind, scene}, state) when is_binary(scene) do + old = Map.get(state, :scene) + state = Map.put(state, :scene, scene) + Enum.each(all_entries(state), &send(&1.pid, {:mob_scene, :bound, scene})) + + if is_binary(old) and old != scene, do: Mob.Sender.deactivate_scene(old) + + state = make_current(state, state.current, :none) + paint(state.current, :none, state) + {:noreply, 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 @@ -494,7 +604,9 @@ defmodule Mob.Router do render_mode: state.render_mode, platform: state.platform, nif: state.nif, - restore_persisted_state: Keyword.get(screen_opts, :restore_persisted_state, true) + restore_persisted_state: Keyword.get(screen_opts, :restore_persisted_state, true), + # Map.get: a router started before MOB-245 (hot code push) is unbound. + scene: Map.get(state, :scene) ] case Mob.Screen.Server.start_link(opts) do @@ -510,6 +622,13 @@ defmodule Mob.Router do # The single place `current` changes. The sender is told here and nowhere # else, so only the screen the user is looking at can commit a frame. defp make_current(state, entry, transition) do + case Map.get(state, :scene) do + nil -> make_current_unbound(state, entry, transition) + scene -> make_current_in_scene(state, entry, transition, scene) + end + end + + defp make_current_unbound(state, entry, transition) do # Before activating, from this process, so the sender has it when this # screen's first frame commits (:after_first_render names the screen). if function_exported?(Mob.Sender, :note_active_screen, 2), @@ -526,6 +645,31 @@ defmodule Mob.Router do %{state | current: Map.put(entry, :activation_token, activation_token)} end + # A router bound to a window scene (MOB-245) activates per scene, so its + # screen and every other window's are committed side by side. Bound routers + # only exist with a sender that knows scenes, so no fallback is needed. + defp make_current_in_scene(state, entry, transition, scene) do + Mob.Sender.note_active_screen(entry.ref, entry.module, scene) + activation_token = Mob.Sender.activate_frame(entry.ref, transition, scene) + %{state | current: Map.put(entry, :activation_token, activation_token)} + end + + # A bound router asks about the WebView in its own window (MOB-245): the back + # gesture of one window must not step another window's page history. + defp webview_can_go_back?(state) do + case Map.get(state, :scene) do + nil -> state.nif.webview_can_go_back() + scene -> state.nif.webview_can_go_back(scene) + end + end + + defp webview_go_back(state) do + case Map.get(state, :scene) do + nil -> state.nif.webview_go_back() + scene -> state.nif.webview_go_back(scene) + end + end + # During a code push modules are loaded independently. Only enter the token # protocol once every participant can carry it end-to-end; otherwise use the # established activation/render API until the next navigation. diff --git a/lib/mob/scene.ex b/lib/mob/scene.ex new file mode 100644 index 00000000..2a2dfe10 --- /dev/null +++ b/lib/mob/scene.ex @@ -0,0 +1,132 @@ +defmodule Mob.Scene do + @moduledoc """ + Window scenes: several windows of one app (MOB-245). + + On iPad every window of an app is a scene, and an app can show several side + by side (Split View, Stage Manager) or switch between them. In a mob app + they all run in the one BEAM, and each has its own router, navigation and + screens: the window the app started in runs the root screen from + `Mob.Screen.start_root/2`, and every further window starts on that same + screen with the same params, then navigates on its own. + + ## Opting in + + iOS shows one window per app unless `Info.plist` says otherwise. Set it in + `mob.exs`: + + config :mob_dev, multi_window: true + + mob_dev writes `UIApplicationSupportsMultipleScenes` into the built app's + `Info.plist` (dev builds and `mix mob.release`). Rebuild natively after + changing it. + + ## Opening a window + + The user can always open one from the app switcher or by dragging. To offer + a button, show it only where it can work. `supported?/0` asks the main + thread, and the answer never changes while the app runs, so read it once in + `mount/3`, not in `render/1`: + + def mount(_params, _session, socket) do + {:ok, Mob.Socket.assign(socket, :multi_window, Mob.Scene.supported?())} + end + + def render(assigns) do + ~MOB\"\"\" + +