Skip to content
Open
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
21 changes: 21 additions & 0 deletions ios/MobViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ import Combine
@objc public class MobViewModel: NSObject, ObservableObject {
@objc public static let shared = MobViewModel()

// How long to wait for BEAM boot to produce a first screen before treating
// it as stuck. An indefinite spinner and a persistent error look identical
// to "frozen" from the outside — this turns a silent hang into an explicit,
// diagnosable state instead of leaving the app on the black startup screen
// forever (seen: App Store review reporting an indefinite/blank launch).
private static let bootWatchdogSeconds: TimeInterval = 15

@Published public var root: MobNode?
/// Increments on every setRoot call; views use onChange(of: rootVersion) to
/// trigger withAnimation rather than watching root directly (root identity
Expand All @@ -25,6 +32,20 @@ import Combine
/// Non-nil when a fatal startup error has occurred; the error screen stalls here.
@Published public var startupError: String?

override init() {
super.init()
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
let shortVersion = (info?["CFBundleShortVersionString"] as? String) ?? "?"
let build = (info?["CFBundleVersion"] as? String) ?? "?"
self.startupError =
"Startup is taking longer than expected — this usually means a " +
"native call blocked the main thread during boot. Please force-quit " +
"and relaunch the app.\n\nVersion \(shortVersion) (\(build))"
}
}

@objc public func setRoot(_ node: MobNode?, transition: String) {
DispatchQueue.main.async {
self.transition = transition
Expand Down
18 changes: 15 additions & 3 deletions ios/mob_nif.m
Original file line number Diff line number Diff line change
Expand Up @@ -1827,10 +1827,19 @@ static ERL_NIF_TERM nif_device_keep_awake(ErlNifEnv *env, int argc, const ERL_NI
// ── NIF: safe_area/0 ─────────────────────────────────────────────────────────
// Returns {Top, Right, Bottom, Left} in logical points (not pixels).
// Must read UIWindow.safeAreaInsets on the main thread.

//
// Called from Mob.Screen.init/1 — i.e. on the boot path, before the first
// screen ever mounts. A plain dispatch_sync here is a deadlock risk: if the
// main thread hasn't reached an idle run-loop tick yet (observed during
// scene-attachment on iPad, including the compatibility-mode window an
// iPhone-only app runs in there), this blocks the BEAM boot thread forever —
// the app never finishes launching. Bound the wait and fall back to zero
// insets on timeout: a screen with wrong insets once is a far smaller bug
// than an app that never boots.
static ERL_NIF_TERM nif_safe_area(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) {
__block UIEdgeInsets insets = UIEdgeInsetsZero;
dispatch_sync(dispatch_get_main_queue(), ^{
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]]) {
Expand All @@ -1841,7 +1850,10 @@ static ERL_NIF_TERM nif_safe_area(ErlNifEnv *env, int argc, const ERL_NIF_TERM a
}
if (window)
insets = window.safeAreaInsets;
dispatch_semaphore_signal(done);
});
dispatch_time_t deadline = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC));
dispatch_semaphore_wait(done, deadline);
return enif_make_tuple4(
env, enif_make_double(env, insets.top), enif_make_double(env, insets.right),
enif_make_double(env, insets.bottom), enif_make_double(env, insets.left));
Expand Down Expand Up @@ -6327,7 +6339,7 @@ static ERL_NIF_TERM nif_vendor_usb_close(ErlNifEnv *env, int argc, const ERL_NIF
{"register_tap", 1, nif_register_tap, 0},
{"clear_taps", 0, nif_clear_taps, 0},
{"exit_app", 0, nif_exit_app, 0},
{"safe_area", 0, nif_safe_area, 0},
{"safe_area", 0, nif_safe_area, ERL_NIF_DIRTY_JOB_IO_BOUND},
{"haptic", 1, nif_haptic, 0},
{"torch", 1, nif_torch, 0},
{"clipboard_put", 1, nif_clipboard_put, 0},
Expand Down
Loading