diff --git a/ios/MobViewModel.swift b/ios/MobViewModel.swift index 2816893..87be821 100644 --- a/ios/MobViewModel.swift +++ b/ios/MobViewModel.swift @@ -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 @@ -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 diff --git a/ios/mob_nif.m b/ios/mob_nif.m index 23ab1d6..22d5151 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -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]]) { @@ -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)); @@ -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},