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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
326 changes: 326 additions & 0 deletions decisions/2026-10-02-one-router-per-window-scene.md

Large diffs are not rendered by default.

36 changes: 26 additions & 10 deletions ios/MobDemo-Bridging-Header.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 0 additions & 3 deletions ios/MobNode.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
70 changes: 59 additions & 11 deletions ios/MobRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@
}
}

struct MobNodeView: View {

Check warning on line 270 in ios/MobRootView.swift

View workflow job for this annotation

GitHub Actions / Native formatters (clang-format + swiftlint)

Struct body should span 250 lines or less excluding comments and whitespace: currently spans 251 lines (type_body_length)
let node: MobNode
private let layoutWeightAxis: MobLayoutWeightAxis?
// Set only for the direct children of a VERTICAL `scroll` that opted in with
Expand Down Expand Up @@ -881,6 +881,10 @@
// 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
Expand Down Expand Up @@ -978,6 +982,7 @@
}

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.
Expand Down Expand Up @@ -1636,7 +1641,7 @@
// no manual frame management required.
private class CameraPreviewUIView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var cameraLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }

Check warning on line 1644 in ios/MobRootView.swift

View workflow job for this annotation

GitHub Actions / Native formatters (clang-format + swiftlint)

Force casts should be avoided (force_cast)
}

private struct MobCameraPreviewView: UIViewRepresentable {
Expand Down Expand Up @@ -1740,10 +1745,18 @@

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,
Expand All @@ -1752,28 +1765,32 @@
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))
}
return wv
}

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.
Expand All @@ -1790,7 +1807,7 @@
decisionHandler(.allow)
} else {
decisionHandler(.cancel)
mob_deliver_webview_blocked(url)
mobWithSceneId(sceneId) { mob_deliver_webview_blocked($0, url) }
}
}
}
Expand Down Expand Up @@ -2251,18 +2268,40 @@
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 {
var mobAvailableSheetHeight: CGFloat {
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
Expand Down Expand Up @@ -2841,7 +2880,9 @@
// ── 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
Expand Down Expand Up @@ -2898,7 +2939,9 @@
/// 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 {
Expand Down Expand Up @@ -2971,6 +3014,11 @@
.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(
Expand All @@ -2992,8 +3040,8 @@
// before the window existed (prewarmed launch) and so holds the
// placeholder; the BEAM drops a value a screen already holds.
.onChange(of: MobSizeClassPair(horizontalSizeClass, verticalSizeClass), initial: true) {
_, pair in

Check warning on line 3043 in ios/MobRootView.swift

View workflow job for this annotation

GitHub Actions / Native formatters (clang-format + swiftlint)

Closure parameters should be on the same line as opening brace (closure_parameter_position)

Check warning on line 3043 in ios/MobRootView.swift

View workflow job for this annotation

GitHub Actions / Native formatters (clang-format + swiftlint)

Closure parameters should be on the same line as opening brace (closure_parameter_position)
pair.notify()
pair.notify(sceneId: model.sceneId)
}
}

Expand Down Expand Up @@ -3156,9 +3204,9 @@
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? {
Expand Down Expand Up @@ -3471,4 +3519,4 @@
VStack(alignment: alignment, spacing: spacing) { content }
}
}
}

Check warning on line 3522 in ios/MobRootView.swift

View workflow job for this annotation

GitHub Actions / Native formatters (clang-format + swiftlint)

File should contain 3200 lines or less: currently contains 3522 (file_length)
60 changes: 57 additions & 3 deletions ios/MobViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<CChar>?) -> 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<MobRootView> {
override public func viewDidLoad() {
super.viewDidLoad()
Expand All @@ -151,9 +185,29 @@ public class MobHostingController: UIHostingController<MobRootView> {
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) }
}
}
}
Expand Down
Loading
Loading