fix(ios): lock iPhone to portrait + move chat-start preflight off MainActor

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) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-01 16:03:28 +02:00
parent 1dbdf9d079
commit cb164f07f9
2 changed files with 20 additions and 9 deletions
+18 -7
View File
@@ -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
+2 -2
View File
@@ -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)",