Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Labstream/Capabilities/Downloads/Core/DownloadManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = []
/// Exact owner of each compatibility `activeJobs` slot. The Set remains the UI-facing shape;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
19 changes: 18 additions & 1 deletion Labstream/Shared/App/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 +
Expand Down
19 changes: 19 additions & 0 deletions Labstream/Shared/Auth/AuthManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
127 changes: 127 additions & 0 deletions Labstream/Shared/UI/OfflineLaunchView.swift
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions LabstreamTests/SessionRestoreOutcomeTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
6 changes: 5 additions & 1 deletion PMSKit/Sources/PMSKit/Downloads/BrowseUIGate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
}
11 changes: 11 additions & 0 deletions PMSKit/Sources/PMSKit/Downloads/OfflineLaunchAvailability.swift
Original file line number Diff line number Diff line change
@@ -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) }
}
}
10 changes: 9 additions & 1 deletion PMSKit/Tests/PMSKitTests/BrowseUIGateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
24 changes: 24 additions & 0 deletions PMSKit/Tests/PMSKitTests/OfflineLaunchAvailabilityTests.swift
Original file line number Diff line number Diff line change
@@ -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 })
}
}
1 change: 1 addition & 0 deletions TESTING-CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | [ ] | [ ] | [ ] |

Expand Down
7 changes: 7 additions & 0 deletions docs/DOWNLOADS-OFFLINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down