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>
351 lines
13 KiB
Swift
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 }
|
|
}
|