From 6110f712bd12eaea3cf5266e2174df2bb34503c8 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 9 Oct 2025 11:42:13 -0700 Subject: [PATCH] Fix WebSocket reconnection after app backgrounding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add scenePhase monitoring to ContentView to detect app lifecycle changes - Implement handleScenePhaseChange() to force WebSocket reconnection and full UI refresh when app returns to foreground from background - Update error handling in watchWebsocket() to suppress UI errors for backgrounding (error code 53) while still triggering reconnection - Simplify API.notifyError() to always report errors, letting UI layer decide what to display This fixes the issue where WebSocket connections would permanently disconnect after extended backgrounding, as iOS terminates background network connections after ~30 seconds. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- QueueCube/Backend/API.swift | 16 ++++--------- QueueCube/Views/ContentView.swift | 38 +++++++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/QueueCube/Backend/API.swift b/QueueCube/Backend/API.swift index 5e42b21..a4bdaf4 100644 --- a/QueueCube/Backend/API.swift +++ b/QueueCube/Backend/API.swift @@ -233,18 +233,12 @@ actor API private static func notifyError(_ error: any Swift.Error, continuation: AsyncStream.Continuation) { print("Websocket Error: \(error)") - - var shouldNotifyObservers = true + let nsError = error as NSError - if nsError.code == 53 { - // This is a "connection abort", caused by backgrounding. - // Don't notify UI, just silently reconnect. - shouldNotifyObservers = false - } - - if shouldNotifyObservers { - continuation.yield(.error(.websocketError(error))) - } + + // Always notify observers of WebSocket errors so reconnection can happen + // The UI layer can decide whether to show the error to the user + continuation.yield(.error(.websocketError(error))) } private func request() -> RequestBuilder { diff --git a/QueueCube/Views/ContentView.swift b/QueueCube/Views/ContentView.swift index 218cace..49c510c 100644 --- a/QueueCube/Views/ContentView.swift +++ b/QueueCube/Views/ContentView.swift @@ -11,12 +11,16 @@ struct ContentView: View { @State var model = MainViewModel() @State private var websocketRestartTrigger = 0 - + @Environment(\.scenePhase) private var scenePhase + var body: some View { MainView(model: $model) .task(id: websocketRestartTrigger) { await watchWebsocket() } .task { await refresh([.nowPlaying, .playlist, .favorites]) } .task { await watchForSettingsChanges() } + .onChange(of: scenePhase) { oldPhase, newPhase in + handleScenePhaseChange(from: oldPhase, to: newPhase) + } .sheet(isPresented: $model.isNowPlayingSheetPresented) { NowPlayingView(model: model.nowPlayingViewModel) .presentationBackground(.regularMaterial) @@ -50,6 +54,22 @@ struct ContentView: View extension ContentView { + private func handleScenePhaseChange(from oldPhase: ScenePhase, to newPhase: ScenePhase) { + // When app returns to active state from background, force reconnect and refresh + if oldPhase == .background && newPhase == .active { + Task { + // Force WebSocket reconnection + websocketRestartTrigger += 1 + + // Give the WebSocket a moment to reconnect + try? await Task.sleep(for: .milliseconds(100)) + + // Full UI refresh + await refresh([.nowPlaying, .playlist, .favorites]) + } + } + } + private func refresh(_ what: RefreshType) async { await model.withModificationsViaAPI { api in if what.contains(.nowPlaying) { @@ -93,7 +113,7 @@ extension ContentView private func watchWebsocket() async { guard let api = model.selectedServer?.api else { return } - + do { for await streamEvent in try await api.events() { switch streamEvent { @@ -101,13 +121,21 @@ extension ContentView await clearConnectionErrorIfNecessary() await handle(event: event) case .error(let error): - model.connectionError = error - + // Check if this is a backgrounding error (connection abort) + let nsError = error as NSError + let isBackgroundingError = nsError.code == 53 + + // Only show connection error to user if it's not a backgrounding error + if !isBackgroundingError { + model.connectionError = error + } + + // Always attempt reconnection after a delay Task { @MainActor in try await Task.sleep(for: .seconds(1.0)) websocketRestartTrigger += 1 } - + break } }