Files
Flaro/Flixy/Session/FlixySession.swift
T
GuidoandClaude Opus 5 a2b3f901ed Absturz beim Abspielen, und die Annahme dahinter
Der Crashlog vom Gerät:

    _dispatch_assert_queue_fail
    _swift_task_checkIsolatedSwift
    MainActor.assumeIsolated
    VLCPlayerEngine.mediaPlayerStateChanged(_:)
    VLCKit input_thread_Events

Im Code stand als Begründung für assumeIsolated, VLCKit stelle seine
Rückrufe über die Hauptwarteschlange zu. Das ist falsch — es ruft den
Delegaten von seinem eigenen Input-Thread. Die App brach deshalb hart
ab, sobald VLC den ersten Zustandswechsel meldete, also unmittelbar
beim Drücken auf Play.

Alle drei Delegat-Methoden springen jetzt mit Task { @MainActor in }
sauber hinüber. Die Reihenfolge bleibt erhalten: die Aufgaben werden aus
demselben Thread nacheinander eingereiht.

Dieselbe Annahme steckte im AVPlayer-Motor in den KVO-Beobachtern —
KVO meldet auf dem Thread, der die Änderung ausgelöst hat, und AVPlayer
ändert seinen Zustand auch aus internen Threads. Ebenfalls umgestellt.
Die beiden Rückrufe mit ausdrücklichem queue: .main behalten
assumeIsolated: dort ist die Annahme belegt statt geraten, und der
Zeitbeobachter feuert viermal je Sekunde.

Dazu die Anmeldung: Plex führt den Rücksprung über forwardUrl nicht
zuverlässig aus. Auf dem iPad landet der Browser nach dem Anmelden auf
watch.plex.tv und bleibt stehen — der Token liegt bereit, aber die App
wartete auf einen Rückruf, der nie kam. Das Fenster wird jetzt geöffnet,
ohne darauf zu warten; parallel wird im Sekundentakt beim Server
nachgefragt. Der Rücksprung ist damit eine Annehmlichkeit, keine
Voraussetzung.

132 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 21:25:17 +02:00

351 lines
13 KiB
Swift

import Foundation
import Observation
import PlexKit
/// Der Zustand der Verbindung zum Server.
///
/// Alles, was die App über „bin ich angemeldet und mit wem spreche ich" weiß,
/// steht hier. Die Views lesen nur `phase` — dadurch gibt es genau einen Ort,
/// an dem sich entscheidet, was auf dem Bildschirm erscheint.
@MainActor
@Observable
final class FlixySession {
enum Phase: Equatable {
/// Beim Start: liegt ein Token im Schlüsselbund?
case launching
case signedOut
/// Das Anmeldefenster von Plex ist offen.
case authenticating
/// Mehrere Server am Konto — einer muss gewählt werden.
case choosingServer([PlexResource])
/// Ein Weg zum Server wird gesucht.
case connecting(serverName: String)
case ready
case failed(message: String)
static func == (lhs: Phase, rhs: Phase) -> Bool {
switch (lhs, rhs) {
case (.launching, .launching), (.signedOut, .signedOut), (.ready, .ready):
true
case (.authenticating, .authenticating):
true
case (.choosingServer(let l), .choosingServer(let r)):
l.map(\.clientIdentifier) == r.map(\.clientIdentifier)
case (.connecting(let l), .connecting(let r)):
l == r
case (.failed(let l), .failed(let r)):
l == r
default:
false
}
}
}
private enum Key {
static let preferredServer = "flixy.server.clientIdentifier"
static let cachedConnectionURI = "flixy.server.connectionURI"
static let cachedServerName = "flixy.server.name"
static let cachedConnectionIsLocal = "flixy.server.connectionIsLocal"
}
/// Unter welchem Namen der Server-Token im Schlüsselbund liegt.
///
/// Nicht in den Voreinstellungen: er gibt vollen Zugriff auf die
/// Mediathek, genau wie der Konto-Token.
private static let serverTokenAccount = "plex.server"
/// Wird für die Anmelde-Adresse gebraucht: plex.tv ordnet den
/// bestätigten Code darüber diesem Gerät zu.
var clientIdentifier: String { identity.clientIdentifier }
private(set) var phase: Phase = .launching
private(set) var server: PlexServer?
/// Nur die Mediatheken, die Flixy darstellen kann — Musik und Fotos
/// erscheinen gar nicht erst.
private(set) var libraries: [PlexLibrary] = []
private let identity: PlexClientIdentity
private let tokenStore: any TokenStoring
private let authenticator: PlexAuthenticator
private let webAuthenticator = PlexWebAuthenticator()
private let defaults: UserDefaults
private var authorizationTask: Task<Void, Never>?
init(
identity: PlexClientIdentity,
tokenStore: any TokenStoring,
defaults: UserDefaults = .standard
) {
self.identity = identity
self.tokenStore = tokenStore
self.defaults = defaults
self.authenticator = PlexAuthenticator(identity: identity, tokenStore: tokenStore)
}
// MARK: - Start
/// Versucht, mit einem bereits gespeicherten Token weiterzumachen.
func restore() async {
guard let token = authenticator.storedToken else {
phase = .signedOut
return
}
// Erst der kurze Weg: die Verbindung, die beim letzten Mal
// funktioniert hat. Ohne ihn kostet jeder Start eine Anfrage an
// plex.tv plus eine neue Verbindungssuche — zusammen mehrere
// Sekunden, in denen nur ein Ladekreis zu sehen ist.
if await restoreFromRememberedConnection() { return }
await connect(accountToken: token)
}
/// Nimmt die zuletzt funktionierende Verbindung wieder auf.
///
/// Gibt `false` zurück, wenn nichts gespeichert ist oder der Server nicht
/// mehr darüber erreichbar ist — dann übernimmt die vollständige Suche.
/// Das ist der Fall, wenn man von zuhause weggeht: die lokale Adresse
/// antwortet nicht mehr, und der direkte Weg muss neu gefunden werden.
private func restoreFromRememberedConnection() async -> Bool {
guard let identifier = defaults.string(forKey: Key.preferredServer),
let uri = defaults.string(forKey: Key.cachedConnectionURI),
let serverToken = tokenStore.token(for: Self.serverTokenAccount)
else { return false }
let name = defaults.string(forKey: Key.cachedServerName) ?? ""
phase = .connecting(serverName: name)
let connection = PlexConnection(
uri: uri,
local: defaults.bool(forKey: Key.cachedConnectionIsLocal)
)
let resource = PlexResource(
name: name,
clientIdentifier: identifier,
accessToken: serverToken,
connections: [connection]
)
// Kurze Zeitgrenze: wenn die gemerkte Adresse nicht sofort antwortet,
// ist die vollständige Suche schneller als weiteres Warten.
let selector = PlexConnectionSelector(identity: identity, probeTimeout: .seconds(2))
guard await selector.selectConnection(for: resource) != nil,
let server = PlexServer(resource: resource, connection: connection, identity: identity)
else { return false }
self.server = server
do {
libraries = try await server.libraries().filter(\.isSupported)
phase = .ready
return true
} catch {
self.server = nil
return false
}
}
// MARK: - Anmeldung
/// Öffnet die Anmeldeseite von Plex.
///
/// Der PIN steckt weiterhin darunter — er ist der einzige Weg, auf dem
/// Plex einen Token herausgibt. Sichtbar ist er nicht mehr.
///
/// Das Fenster wird geöffnet, aber **nicht abgewartet**: Plex führt den
/// Rücksprung über `forwardUrl` nicht zuverlässig aus. Auf dem iPad landet
/// der Browser nach dem Anmelden auf watch.plex.tv und bleibt dort stehen
/// — der Token liegt längst bereit, aber die App wartete auf einen
/// Rückruf, der nie kam. Stattdessen wird hier im Sekundentakt beim Server
/// nachgefragt und nebenbei geprüft, ob das Fenster geschlossen wurde.
func beginSignIn() async {
phase = .authenticating
let pin: PlexPin
do {
pin = try await authenticator.requestPin()
} catch {
phase = .failed(message: message(for: error))
return
}
guard let url = pin.authorizationURL(
clientIdentifier: identity.clientIdentifier,
product: Branding.plexProduct,
forwardURL: Branding.authCallbackURL
) else {
phase = .failed(message: String(localized: "signIn.windowFailed"))
return
}
webAuthenticator.present(url: url, callbackScheme: Branding.urlScheme)
// Die Frist entspricht der Gültigkeit des Codes bei Plex.
for _ in 0..<900 {
try? await Task.sleep(for: .seconds(1))
if let token = try? await authenticator.token(for: pin) {
webAuthenticator.dismiss()
await connect(accountToken: token)
return
}
// Fenster geschlossen, ohne dass ein Token da ist.
if webAuthenticator.didReturn {
switch webAuthenticator.outcome {
case .cancelled, nil:
// Abgebrochen oder zurückgesprungen, ohne dass der Token
// schon bereitlag. Noch ein paar Sekunden nachfragen,
// bevor aufgegeben wird — zwischen Rücksprung und Eintrag
// bei Plex liegt manchmal eine Sekunde.
if let token = try? await graceTokenLookup(pin: pin) {
await connect(accountToken: token)
return
}
phase = .signedOut
case .presentationFailed(let description):
phase = .failed(message: description)
}
return
}
}
webAuthenticator.dismiss()
phase = .failed(message: String(localized: "signIn.timedOut"))
}
/// Fragt nach dem Schließen des Fensters noch ein paar Sekunden nach.
private func graceTokenLookup(pin: PlexPin) async throws -> String? {
for _ in 0..<5 {
if let token = try? await authenticator.token(for: pin) { return token }
try? await Task.sleep(for: .milliseconds(700))
}
return nil
}
func cancelSignIn() {
authorizationTask?.cancel()
authorizationTask = nil
phase = .signedOut
}
func signOut() {
authorizationTask?.cancel()
authenticator.signOut()
forgetRememberedConnection()
server = nil
libraries = []
phase = .signedOut
}
// MARK: - Verbinden
private func connect(accountToken: String) async {
phase = .connecting(serverName: "")
let directory = PlexServerDirectory(identity: identity, token: accountToken)
let servers: [PlexResource]
do {
servers = try await directory.servers()
} catch PlexError.unauthorized {
// Der Token wurde am Konto zurückgezogen. Ihn zu behalten würde
// bei jedem Start denselben Fehler erzeugen.
authenticator.signOut()
phase = .signedOut
return
} catch {
phase = .failed(message: message(for: error))
return
}
guard !servers.isEmpty else {
phase = .failed(message: String(localized: "error.noServers"))
return
}
// Beim zweiten Start soll niemand erneut wählen müssen.
let remembered = defaults.string(forKey: Key.preferredServer)
if let chosen = servers.first(where: { $0.clientIdentifier == remembered }) ?? servers.singleElement {
await connect(to: chosen)
} else {
phase = .choosingServer(servers)
}
}
func connect(to resource: PlexResource) async {
phase = .connecting(serverName: resource.name)
let selector = PlexConnectionSelector(identity: identity)
guard let connection = await selector.selectConnection(for: resource) else {
phase = .failed(message: String(localized: "error.serverUnreachable"))
return
}
guard let server = PlexServer(
resource: resource,
connection: connection,
identity: identity
) else {
phase = .failed(message: String(localized: "error.serverUnreachable"))
return
}
self.server = server
remember(resource: resource, connection: connection)
do {
libraries = try await server.libraries().filter(\.isSupported)
phase = .ready
} catch {
phase = .failed(message: message(for: error))
}
}
/// Merkt sich, worüber es funktioniert hat.
private func remember(resource: PlexResource, connection: PlexConnection) {
defaults.set(resource.clientIdentifier, forKey: Key.preferredServer)
defaults.set(connection.uri, forKey: Key.cachedConnectionURI)
defaults.set(connection.local, forKey: Key.cachedConnectionIsLocal)
defaults.set(resource.name, forKey: Key.cachedServerName)
if let token = resource.accessToken {
try? tokenStore.store(token, for: Self.serverTokenAccount)
}
}
/// Vergisst die gemerkte Verbindung.
private func forgetRememberedConnection() {
for key in [Key.preferredServer, Key.cachedConnectionURI,
Key.cachedConnectionIsLocal, Key.cachedServerName] {
defaults.removeObject(forKey: key)
}
tokenStore.remove(for: Self.serverTokenAccount)
}
/// Nach einem Fehler noch einmal versuchen, ohne die Anmeldung zu verlieren.
func retry() async {
if let token = authenticator.storedToken {
await connect(accountToken: token)
} else {
phase = .signedOut
}
}
private func message(for error: any Error) -> String {
guard let plexError = error as? PlexError else { return error.localizedDescription }
return switch plexError {
case .unauthorized: String(localized: "error.unauthorized")
case .notFound: String(localized: "error.notFound")
case .noReachableConnection: String(localized: "error.serverUnreachable")
case .server(let status): String(localized: "error.server \(status)")
case .transport(let description): description
case .decoding: String(localized: "error.unexpectedResponse")
}
}
}
extension Array {
/// Das einzige Element — oder `nil`, wenn es keines oder mehrere gibt.
var singleElement: Element? { count == 1 ? first : nil }
}