From cb164f07f900ae824d9e326c4020ead66c9fc450 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Fri, 1 May 2026 16:03:28 +0200 Subject: [PATCH] fix(ios): lock iPhone to portrait + move chat-start preflight off MainActor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two iOS-specific crash classes from the v2.5.1 TestFlight feedback round: **Rotation crash** — locked the iPhone target to `UIInterfaceOrientationPortrait` only (was Portrait + LandscapeLeft + LandscapeRight). The phone can't rotate the app at all anymore, so any layout path that wasn't audited for size-class transitions is no longer reachable. iPad orientation list left alone (target device family is iPhone-only anyway). **"Crash while typing" / "trying to continue an existing conversation"** — `ChatController.passModelPreflight()` was doing a synchronous SSH read (`context.readText(configYAML)`) on `@MainActor` during chat-start. On a remote ScarfGo context that blocks the main thread for seconds; iOS's non-responsive-app watchdog kills the process around 10s. To the user this surfaces as a "crash" while they're typing — they kept tapping the keyboard while the connect was hung. Move the read to `Task.detached` and await it; the UI stays responsive while the SSH I/O drains. Three callers (`start`, `start(projectPath:)`, `startResuming`) updated to `await passModelPreflight(...)` — they were already async. Co-Authored-By: Claude Opus 4.7 (1M context) --- scarf/Scarf iOS/Chat/ChatView.swift | 25 ++++++++++++++++++------- scarf/scarf.xcodeproj/project.pbxproj | 4 ++-- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/scarf/Scarf iOS/Chat/ChatView.swift b/scarf/Scarf iOS/Chat/ChatView.swift index 738c9af..8a95f0a 100644 --- a/scarf/Scarf iOS/Chat/ChatView.swift +++ b/scarf/Scarf iOS/Chat/ChatView.swift @@ -1041,10 +1041,21 @@ final class ChatController { /// the start intent so the preflight sheet can replay it after the /// user picks a model. Reads via `context.readText` (transport- /// aware) and parses with the ScarfCore YAML parser — same path - /// `IOSSettingsViewModel.load` uses, just synchronous because the - /// preflight runs before any `state = .connecting` UI transition. - private func passModelPreflight(intent: PendingStart) -> Bool { - let raw = context.readText(context.paths.configYAML) ?? "" + /// `IOSSettingsViewModel.load` uses. + /// + /// **Off MainActor.** `context.readText` synchronously calls + /// `transport.fileExists` + `transport.readFile`; on a remote + /// ScarfGo context that's a blocking SSH round-trip that, before + /// this fix, ran on the controller's `@MainActor` and stalled the + /// UI for seconds during connect — long enough for iOS's + /// non-responsive-app watchdog to kill the process if the user + /// kept tapping (the typing TestFlight crash report). Reading + /// detached pushes the I/O off MainActor; the result and the + /// `pendingStartIntent` / `modelPreflightReason` writes hop back. + private func passModelPreflight(intent: PendingStart) async -> Bool { + let path = context.paths.configYAML + let ctx = context + let raw = await Task.detached { ctx.readText(path) ?? "" }.value let config = HermesConfig(yaml: raw) let result = ModelPreflight.check(config) if result.isConfigured { return true } @@ -1138,7 +1149,7 @@ final class ChatController { /// can type and hit send immediately. func start() async { if state == .connecting || state == .ready { return } - guard passModelPreflight(intent: .fresh) else { return } + guard await passModelPreflight(intent: .fresh) else { return } state = .connecting vm.reset() let client = ACPClient.forIOSApp( @@ -1651,7 +1662,7 @@ final class ChatController { } else { intent = .fresh } - guard passModelPreflight(intent: intent) else { return } + guard await passModelPreflight(intent: intent) else { return } state = .connecting let client = ACPClient.forIOSApp( context: context, @@ -1735,7 +1746,7 @@ final class ChatController { /// to `session/load` if the remote doesn't support `session/resume` /// (Hermes < 0.9.x). func startResuming(sessionID: String) async { - guard passModelPreflight(intent: .resume(sessionID: sessionID)) else { return } + guard await passModelPreflight(intent: .resume(sessionID: sessionID)) else { return } await stop() vm.reset() // Clear eagerly so a lingering project name from a prior diff --git a/scarf/scarf.xcodeproj/project.pbxproj b/scarf/scarf.xcodeproj/project.pbxproj index c69b2d1..cecad19 100644 --- a/scarf/scarf.xcodeproj/project.pbxproj +++ b/scarf/scarf.xcodeproj/project.pbxproj @@ -540,7 +540,7 @@ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; IPHONEOS_DEPLOYMENT_TARGET = 18.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -582,7 +582,7 @@ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; IPHONEOS_DEPLOYMENT_TARGET = 18.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)",