From 90d534403c0ae9238352ea072642b2214d8bdbcf Mon Sep 17 00:00:00 2001 From: Jonathan Lipworth Date: Fri, 21 Aug 2026 20:34:58 -0400 Subject: [PATCH] Expose completed downloads after disconnected cold launch --- .../Downloads/Core/DownloadManager.swift | 6 + .../Downloads/Core/OfflineLibraryView.swift | 4 +- Labstream/Shared/App/ContentView.swift | 19 ++- Labstream/Shared/Auth/AuthManager.swift | 19 +++ Labstream/Shared/UI/OfflineLaunchView.swift | 127 ++++++++++++++++++ .../SessionRestoreOutcomeTests.swift | 15 +++ .../PMSKit/Downloads/BrowseUIGate.swift | 6 +- .../Downloads/OfflineLaunchAvailability.swift | 11 ++ .../Tests/PMSKitTests/BrowseUIGateTests.swift | 10 +- .../OfflineLaunchAvailabilityTests.swift | 24 ++++ TESTING-CHECKLIST.md | 1 + docs/DOWNLOADS-OFFLINE.md | 7 + 12 files changed, 245 insertions(+), 4 deletions(-) create mode 100644 Labstream/Shared/UI/OfflineLaunchView.swift create mode 100644 LabstreamTests/SessionRestoreOutcomeTests.swift create mode 100644 PMSKit/Sources/PMSKit/Downloads/OfflineLaunchAvailability.swift create mode 100644 PMSKit/Tests/PMSKitTests/OfflineLaunchAvailabilityTests.swift diff --git a/Labstream/Capabilities/Downloads/Core/DownloadManager.swift b/Labstream/Capabilities/Downloads/Core/DownloadManager.swift index f02dea71..d5e05a33 100644 --- a/Labstream/Capabilities/Downloads/Core/DownloadManager.swift +++ b/Labstream/Capabilities/Downloads/Core/DownloadManager.swift @@ -252,6 +252,12 @@ public final class DownloadManager { /// observes a single value and row bodies stay manager-free. var offlineLibrarySnapshot: OfflineLibrarySnapshot = .empty + /// True only when a terminal row still owns a present local media file. A durable row by + /// itself is not enough to bypass the launch login gate. + var hasPlayableOfflineContent: Bool { + OfflineLaunchAvailability.hasPlayableDownload(records: records) + } + /// ratingKeys with an active (optimize or transfer) job in flight. public internal(set) var activeJobs: Set = [] /// Exact owner of each compatibility `activeJobs` slot. The Set remains the UI-facing shape; diff --git a/Labstream/Capabilities/Downloads/Core/OfflineLibraryView.swift b/Labstream/Capabilities/Downloads/Core/OfflineLibraryView.swift index d3797d7b..d379e7e3 100644 --- a/Labstream/Capabilities/Downloads/Core/OfflineLibraryView.swift +++ b/Labstream/Capabilities/Downloads/Core/OfflineLibraryView.swift @@ -764,7 +764,9 @@ public struct OfflineLibraryView: View { private func currentPlayableRecord( for identity: OfflineDownloadRowActionIdentity ) -> DownloadRecord? { - manager.currentRecord(for: identity).flatMap { $0.isComplete ? $0 : nil } + manager.currentRecord(for: identity).flatMap { + $0.isComplete && FileManager.default.fileExists(atPath: $0.localURL.path) ? $0 : nil + } } private func backendKind(for record: DownloadRecord) -> DownloadBackendKind { diff --git a/Labstream/Shared/App/ContentView.swift b/Labstream/Shared/App/ContentView.swift index 56773e55..8f11c677 100644 --- a/Labstream/Shared/App/ContentView.swift +++ b/Labstream/Shared/App/ContentView.swift @@ -59,11 +59,19 @@ struct ContentView: View { switch BrowseUIGate.state(isBrowseReady: appModel.isBrowseReady, isRestoring: bootstrap.isRestoring, isSwitchingBackend: appModel.isSwitchingBackend, - hasEverBeenBrowseReady: bootstrap.hasEverBeenBrowseReady) { + hasEverBeenBrowseReady: bootstrap.hasEverBeenBrowseReady, + canOpenOffline: canOpenOffline) { case .browse: RootView(runtime: runtime) case .restoringSplash: RestoringSessionView() + case .offline: + #if os(tvOS) + LoginView(authManager: authManager) + .environment(appModel) + #else + OfflineLaunchView(runtime: runtime) + #endif case .login: LoginView(authManager: authManager) .environment(appModel) @@ -191,6 +199,15 @@ struct ContentView: View { #endif } } + + private var canOpenOffline: Bool { + #if os(tvOS) + false + #else + authManager.sessionRestoreOutcome == .temporarilyUnavailable + && downloadManager.hasPlayableOfflineContent + #endif + } } /// Neutral launch splash shown while a saved session is being restored (token read + diff --git a/Labstream/Shared/Auth/AuthManager.swift b/Labstream/Shared/Auth/AuthManager.swift index bf81dce9..853b05b1 100644 --- a/Labstream/Shared/Auth/AuthManager.swift +++ b/Labstream/Shared/Auth/AuthManager.swift @@ -23,6 +23,18 @@ enum SessionRestorePerformanceOutcome: Equatable { var restoredField: Int { self == .usable ? 1 : 0 } } +enum SessionRestoreOutcome: Equatable { + case notAttempted + case restored + case temporarilyUnavailable + case requiresSignIn + + static func resolve(reportedRestored: Bool, hasUsableSession: Bool) -> Self { + if hasUsableSession { return .restored } + return reportedRestored ? .temporarilyUnavailable : .requiresSignIn + } +} + /// Drives the Plex PIN-OAuth login flow and persists the result. /// /// Flow: @@ -58,6 +70,7 @@ final class AuthManager { } private(set) var state: State = .idle + private(set) var sessionRestoreOutcome: SessionRestoreOutcome = .notAttempted private let appModel: AppModel private let keychain: KeychainStore @@ -209,6 +222,10 @@ final class AuthManager { reportedRestored: selectedRestored, hasUsableSession: appModel.backendSession(for: selected) != nil ) + sessionRestoreOutcome = SessionRestoreOutcome.resolve( + reportedRestored: selectedRestored, + hasUsableSession: appModel.backendSession(for: selected) != nil + ) restoreSpan.end(result: performanceOutcome.result, fields: ["restored": performanceOutcome.restoredField]) return selectedRestored @@ -1457,6 +1474,7 @@ final class AuthManager { signOutEmby() } state = .idle + sessionRestoreOutcome = .requiresSignIn } /// Coordinated local sign-out for every backend. Remote revocation remains best effort and @@ -1493,6 +1511,7 @@ final class AuthManager { } appModel.activeBackend = .plex state = .idle + sessionRestoreOutcome = .requiresSignIn // Shared system integration is intentionally updated once after the coordinated clear. SpotlightIndexer.deleteAll() diff --git a/Labstream/Shared/UI/OfflineLaunchView.swift b/Labstream/Shared/UI/OfflineLaunchView.swift new file mode 100644 index 00000000..a2c046da --- /dev/null +++ b/Labstream/Shared/UI/OfflineLaunchView.swift @@ -0,0 +1,127 @@ +#if !os(tvOS) +import SwiftUI + +/// Restricted cold-launch surface shown only when saved credentials were preserved after a +/// transient restore failure and at least one completed local media file still exists. +struct OfflineLaunchView: View { + let runtime: AppRuntime + + @State private var reconnecting = false + @State private var showingSignIn = false + @State private var showingSettings = false + #if os(macOS) + @State private var macPlayerPresenter = MacPlayerPresentationStore() + #endif + + private var appModel: AppModel { runtime.appModel } + private var authManager: AuthManager { runtime.authManager } + private var downloadManager: DownloadManager { runtime.downloadManager } + + var body: some View { + presentedContent + .environment(appModel) + .environment(downloadManager) + .environment(runtime.musicPlayer) + .environment(\.artworkPipeline, runtime.artworkPipeline) + .environment(\.artworkShimmerClock, runtime.artworkShimmerClock) + .sheet(isPresented: $showingSignIn) { + LoginView(authManager: authManager) + .environment(appModel) + } + .sheet(isPresented: $showingSettings) { + NavigationStack { + SettingsView(authManager: authManager, + catalogRepository: runtime.libraryCatalogRepository) + } + .environment(appModel) + .environment(downloadManager) + .environment(runtime.musicPlayer) + .environment(\.artworkPipeline, runtime.artworkPipeline) + .environment(\.artworkShimmerClock, runtime.artworkShimmerClock) + } + .accessibilityIdentifier("labstream.offline-launch.root") + } + + @ViewBuilder + private var presentedContent: some View { + #if os(macOS) + ZStack { + offlineNavigation + if let presentation = macPlayerPresenter.presentation { + presentation.content + .id(presentation.contentID) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) + .ignoresSafeArea() + .zIndex(10) + } + } + .environment(\.macPlayerPresentationStore, macPlayerPresenter) + #else + offlineNavigation + #endif + } + + private var offlineNavigation: some View { + NavigationStack { + #if os(visionOS) + OfflineLibraryView(manager: downloadManager, focusedRatingKey: .constant(nil)) + .toolbar { launchToolbar } + #else + OfflineLibraryView(manager: downloadManager) + .toolbar { launchToolbar } + #endif + } + .safeAreaInset(edge: .top) { + offlineStatusBanner + } + } + + private var offlineStatusBanner: some View { + HStack(spacing: 12) { + Image(systemName: "wifi.slash") + VStack(alignment: .leading, spacing: 2) { + Text("Offline mode") + .font(.headline) + Text("Your server could not be reached. Completed downloads remain available.") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button(reconnecting ? "Connecting…" : "Reconnect") { + reconnect() + } + .buttonStyle(.borderedProminent) + .disabled(reconnecting) + } + .padding(.horizontal) + .padding(.vertical, 10) + .background(.regularMaterial) + } + + @ToolbarContentBuilder + private var launchToolbar: some ToolbarContent { + ToolbarItemGroup { + Button { + showingSignIn = true + } label: { + Label("Sign In", systemImage: "person.crop.circle") + } + Button { + showingSettings = true + } label: { + Label("Settings", systemImage: "gearshape") + } + } + } + + private func reconnect() { + guard !reconnecting else { return } + reconnecting = true + Task { @MainActor in + _ = await authManager.restoreSession() + reconnecting = false + } + } +} +#endif diff --git a/LabstreamTests/SessionRestoreOutcomeTests.swift b/LabstreamTests/SessionRestoreOutcomeTests.swift new file mode 100644 index 00000000..ff09a378 --- /dev/null +++ b/LabstreamTests/SessionRestoreOutcomeTests.swift @@ -0,0 +1,15 @@ +import Testing +@testable import Labstream + +@Suite("Session restore UI outcome") +struct SessionRestoreOutcomeTests { + @Test("distinguishes usable, retained-unavailable, and sign-in-required restores") + func resolvesRestoreStates() { + #expect(SessionRestoreOutcome.resolve(reportedRestored: true, + hasUsableSession: true) == .restored) + #expect(SessionRestoreOutcome.resolve(reportedRestored: true, + hasUsableSession: false) == .temporarilyUnavailable) + #expect(SessionRestoreOutcome.resolve(reportedRestored: false, + hasUsableSession: false) == .requiresSignIn) + } +} diff --git a/PMSKit/Sources/PMSKit/Downloads/BrowseUIGate.swift b/PMSKit/Sources/PMSKit/Downloads/BrowseUIGate.swift index 2c89fa43..cfe254ea 100644 --- a/PMSKit/Sources/PMSKit/Downloads/BrowseUIGate.swift +++ b/PMSKit/Sources/PMSKit/Downloads/BrowseUIGate.swift @@ -19,6 +19,8 @@ public enum BrowseUIGate { case browse /// Show the neutral restore/connecting splash. case restoringSplash + /// Mount the deliberately restricted local-download surface. + case offline /// Show the sign-in screen. case login } @@ -35,12 +37,14 @@ public enum BrowseUIGate { public static func state(isBrowseReady: Bool, isRestoring: Bool, isSwitchingBackend: Bool, - hasEverBeenBrowseReady: Bool) -> State { + hasEverBeenBrowseReady: Bool, + canOpenOffline: Bool = false) -> State { if isBrowseReady { return .browse } // Keep an already-ready browse UI mounted through a switch instead of bouncing to the // splash — the new lane will flip `isBrowseReady` true again when it reports live. if isSwitchingBackend && hasEverBeenBrowseReady { return .browse } if isRestoring || isSwitchingBackend { return .restoringSplash } + if canOpenOffline { return .offline } return .login } } diff --git a/PMSKit/Sources/PMSKit/Downloads/OfflineLaunchAvailability.swift b/PMSKit/Sources/PMSKit/Downloads/OfflineLaunchAvailability.swift new file mode 100644 index 00000000..3b0f89ab --- /dev/null +++ b/PMSKit/Sources/PMSKit/Downloads/OfflineLaunchAvailability.swift @@ -0,0 +1,11 @@ +import Foundation + +/// Pure admission rule for the restricted cold-launch Offline surface. +public enum OfflineLaunchAvailability { + public static func hasPlayableDownload( + records: [DownloadRecord], + fileExists: (URL) -> Bool = { FileManager.default.fileExists(atPath: $0.path) } + ) -> Bool { + records.contains { $0.isComplete && fileExists($0.localURL) } + } +} diff --git a/PMSKit/Tests/PMSKitTests/BrowseUIGateTests.swift b/PMSKit/Tests/PMSKitTests/BrowseUIGateTests.swift index 061c2c04..87cb7473 100644 --- a/PMSKit/Tests/PMSKitTests/BrowseUIGateTests.swift +++ b/PMSKit/Tests/PMSKitTests/BrowseUIGateTests.swift @@ -31,7 +31,15 @@ struct BrowseUIGateTests { @Test("launch restore shows the splash") func restoringShowsSplash() { #expect(BrowseUIGate.state(isBrowseReady: false, isRestoring: true, - isSwitchingBackend: false, hasEverBeenBrowseReady: false) == .restoringSplash) + isSwitchingBackend: false, hasEverBeenBrowseReady: false, + canOpenOffline: true) == .restoringSplash) + } + + @Test("a completed local download is reachable after a transient restore failure") + func transientRestoreFailureShowsOffline() { + #expect(BrowseUIGate.state(isBrowseReady: false, isRestoring: false, + isSwitchingBackend: false, hasEverBeenBrowseReady: false, + canOpenOffline: true) == .offline) } @Test("a genuinely signed-out user sees login") diff --git a/PMSKit/Tests/PMSKitTests/OfflineLaunchAvailabilityTests.swift b/PMSKit/Tests/PMSKitTests/OfflineLaunchAvailabilityTests.swift new file mode 100644 index 00000000..e7750b86 --- /dev/null +++ b/PMSKit/Tests/PMSKitTests/OfflineLaunchAvailabilityTests.swift @@ -0,0 +1,24 @@ +import Foundation +import Testing +@testable import PMSKit + +@Suite("Offline launch availability") +struct OfflineLaunchAvailabilityTests { + private let file = URL(fileURLWithPath: "/tmp/offline-launch-test.mp4") + + @Test("requires a completed row whose local file still exists") + func requiresCompletedPresentFile() { + let complete = DownloadRecord(ratingKey: "plex:item", title: "Item", + localURL: file, status: .complete) + let unverified = DownloadRecord(ratingKey: "jellyfin:item", title: "Item", + localURL: file, status: .unverified) + let queued = DownloadRecord(ratingKey: "emby:item", title: "Item", + localURL: file, status: .queued) + + #expect(OfflineLaunchAvailability.hasPlayableDownload(records: [complete]) { _ in true }) + #expect(OfflineLaunchAvailability.hasPlayableDownload(records: [unverified]) { _ in true }) + #expect(!OfflineLaunchAvailability.hasPlayableDownload(records: [queued]) { _ in true }) + #expect(!OfflineLaunchAvailability.hasPlayableDownload(records: [complete]) { _ in false }) + #expect(!OfflineLaunchAvailability.hasPlayableDownload(records: []) { _ in true }) + } +} diff --git a/TESTING-CHECKLIST.md b/TESTING-CHECKLIST.md index 88132d0c..ba807dd1 100644 --- a/TESTING-CHECKLIST.md +++ b/TESTING-CHECKLIST.md @@ -53,6 +53,7 @@ skipping them. | Fresh sign-in reaches a browse-ready server without exposing credentials | [ ] | [ ] | [ ] | | Saved session restores through the neutral Connecting screen without flashing login | [ ] | [ ] | [ ] | | Invalid/revoked credentials return to a clear sign-in state | [ ] | [ ] | [ ] | +| Disconnected cold launch with a completed local file opens restricted Offline mode and plays without a server request | [ ] | [ ] | [ ] | | Explicit sign-out clears the active runtime lane and stops stale music | [ ] | [ ] | [ ] | | Reauthentication to the same server refreshes browse state and navigation identity | [ ] | [ ] | [ ] | diff --git a/docs/DOWNLOADS-OFFLINE.md b/docs/DOWNLOADS-OFFLINE.md index b91b059d..7749cbd2 100644 --- a/docs/DOWNLOADS-OFFLINE.md +++ b/docs/DOWNLOADS-OFFLINE.md @@ -284,6 +284,13 @@ transport, recovery work, persistence barriers, or background-completion durabil and files stay; `DownloadManager.pauseDownloadsForBackendSignOut` runs while credentials still exist so encoder teardown can use the right session. This is not cancel or delete. +If the selected saved session cannot be restored because its server or network is temporarily +unreachable, launch finishes in a restricted Offline surface when at least one completed media +file is still present. This surface exposes only the local library/player plus reconnect, +sign-in, Settings, resume-position, and deletion controls; it does not fabricate an authenticated +browse session. Missing or explicitly invalid credentials continue to show sign-in instead, and +tvOS never enters this path because it does not ship downloads. + Season confirmation captures one immutable draft containing new rows and exact retry attempts. The Store validates every owner and commits insertions plus retry admission markers in one schema-v4 snapshot before any lane starts; stale ownership, a normal persistence failure, and an indeterminate