Kein Code mehr zum Abtippen. Die Anmeldemaske kommt von Plex und öffnet sich über ASWebAuthenticationSession in einem Fenster innerhalb der App; nach dem Anmelden schickt Plex über forwardUrl auf ein eigenes Schema zurück, das Fenster schließt sich von selbst. Der PIN steckt weiterhin darunter — er ist der einzige Weg, auf dem Plex einen Token herausgibt. Sichtbar ist er nicht mehr. Was das bringt: - Kein Fernseher-Gefühl mehr. - Zwei-Faktor läuft mit, weil Plex die Seite selbst ausliefert. - Das Passwort erreicht Flixy nie. Steht auch so unter dem Knopf. - prefersEphemeralWebBrowserSession = false: wer in Safari schon bei Plex angemeldet ist, braucht nur zu bestätigen. Zwei Details, die sonst still scheitern: - Die Rückadresse wird strenger kodiert als die übrigen Werte. `:` und `/` lässt urlQueryAllowed durch, und für Plex endete der Wert dann beim ersten Schrägstrich — der Rücksprung käme nie. - Das Schema muss in CFBundleURLTypes stehen, sonst kennt iOS es nicht und das Fenster bliebe offen stehen. Als Schema die Bundle-Kennung statt eines kurzen Worts wie „flixy": ein kurzes könnte eine andere App beanspruchen und die Anmeldung abfangen. 132 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
246 lines
8.4 KiB
Swift
246 lines
8.4 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"
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
await connect(accountToken: token)
|
|
}
|
|
|
|
// 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: Plex liefert
|
|
/// seine eigene Anmeldemaske aus und schickt die App danach über
|
|
/// `forwardUrl` zurück.
|
|
func beginSignIn() async {
|
|
phase = .authenticating
|
|
|
|
do {
|
|
let pin = try await authenticator.requestPin()
|
|
|
|
guard let url = pin.authorizationURL(
|
|
clientIdentifier: identity.clientIdentifier,
|
|
product: Branding.plexProduct,
|
|
forwardURL: Branding.authCallbackURL
|
|
) else {
|
|
phase = .failed(message: String(localized: "signIn.windowFailed"))
|
|
return
|
|
}
|
|
|
|
try await webAuthenticator.authenticate(
|
|
url: url,
|
|
callbackScheme: Branding.urlScheme
|
|
)
|
|
|
|
// Das Fenster ist zu, jetzt liegt der Token bereit. Kurz
|
|
// nachgefragt statt sofort erwartet: zwischen dem Rücksprung und
|
|
// dem Eintrag bei Plex liegen manchmal ein, zwei Sekunden.
|
|
let token = try await authenticator.waitForAuthorization(
|
|
pin: pin,
|
|
pollInterval: .milliseconds(500),
|
|
timeout: .seconds(20)
|
|
)
|
|
await connect(accountToken: token)
|
|
|
|
} catch PlexWebAuthenticator.Failure.cancelled {
|
|
// Fenster geschlossen. Kein Fehler — kommentarlos zurück.
|
|
phase = .signedOut
|
|
} catch let error as PlexWebAuthenticator.Failure {
|
|
phase = .failed(message: webAuthenticationMessage(for: error))
|
|
} catch {
|
|
phase = .failed(message: message(for: error))
|
|
}
|
|
}
|
|
|
|
func cancelSignIn() {
|
|
authorizationTask?.cancel()
|
|
authorizationTask = nil
|
|
phase = .signedOut
|
|
}
|
|
|
|
private func webAuthenticationMessage(for failure: PlexWebAuthenticator.Failure) -> String {
|
|
switch failure {
|
|
case .cancelled: String(localized: "signIn.cancelled")
|
|
case .presentationFailed(let description): description
|
|
}
|
|
}
|
|
|
|
func signOut() {
|
|
authorizationTask?.cancel()
|
|
authenticator.signOut()
|
|
defaults.removeObject(forKey: Key.preferredServer)
|
|
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
|
|
defaults.set(resource.clientIdentifier, forKey: Key.preferredServer)
|
|
|
|
do {
|
|
libraries = try await server.libraries().filter(\.isSupported)
|
|
phase = .ready
|
|
} catch {
|
|
phase = .failed(message: message(for: error))
|
|
}
|
|
}
|
|
|
|
/// 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 }
|
|
}
|