Das leere Gerüst: eigene Wurzel FlixyTVApp, eigenes Target FlixyTV in project.yml, eigener Asset-Katalog. Es beweist Target, Icon, Signierung und Paketauflösung auf einen Schlag — bevor eine Zeile Logik daran hängt. **Das Icon von Hand gebaut.** Icon Composer deckt iOS, macOS und watchOS ab, tvOS nicht: der Fernseher verlangt gestapelte Bilder, damit die Parallaxe beim Wischen etwas zu bewegen hat. Zwei Ebenen aus derselben Marke — Verlauf hinten, Dreieck vorn —, dazu App-Store-Icon und beide Top-Shelf-Bilder. Ohne das breite lehnt App Store Connect den Upload mit 90513 ab. **Zwei Annahmen aus dem Plan haben sich beim Bauen erledigt.** PlexWebAuthenticator übersetzt nicht: ASWebAuthenticationSession steht zwar im tvOS-SDK, aber presentationContextProvider, cancel() und der Rest sind dort nicht verfügbar. Der Typ bleibt trotzdem stehen — als Attrappe, die sofort „ging nicht" meldet. Ihn herauszuschneiden hiesse, die Zustandsmaschine der Anmeldung für eine Plattform zu spalten, die diesen Zweig nie betritt. Dieselbe Bauform wie bei OrientationLock. NowPlaying wartet auf den Player in Schritt sieben. **Und eine Messung statt einer Vermutung.** Der Plan führte zwei offene Annahmen über den Sicherheitsrand. Der Simulator beantwortet beide: SwiftUI legt ihn von selbst an, und er misst sechzig oben und unten, achtzig seitlich — nicht neunzig. Nutzfläche 1760×960. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
153 lines
5.9 KiB
Swift
153 lines
5.9 KiB
Swift
#if os(tvOS)
|
|
import Foundation
|
|
|
|
/// **Auf dem Fernseher gibt es kein Anmeldefenster.**
|
|
///
|
|
/// `ASWebAuthenticationSession` steht im tvOS-SDK, aber fast alles daran ist
|
|
/// als nicht verfügbar markiert: kein `presentationContextProvider`, kein
|
|
/// `prefersEphemeralWebBrowserSession`, nicht einmal `cancel()`. Ein
|
|
/// Fernseher hat auch keinen Browser, in dem sich jemand bei Plex anmelden
|
|
/// würde — und Plex ist in der ersten Fassung ohnehin nicht dabei.
|
|
///
|
|
/// **Der Typ bleibt trotzdem stehen.** `FlixySession` hält ihn als Feld und
|
|
/// ruft ihn an sechs Stellen; ihn herauszuschneiden hiesse, die
|
|
/// Zustandsmaschine der Anmeldung für eine Plattform zu spalten, die diesen
|
|
/// Zweig nie betritt. Dieselbe Bauform wie bei `OrientationLock`: derselbe
|
|
/// Name, dieselben Aufrufe, und nichts passiert.
|
|
@MainActor
|
|
final class PlexWebAuthenticator {
|
|
enum Failure: Error, Equatable {
|
|
case cancelled
|
|
case presentationFailed(String)
|
|
}
|
|
|
|
/// Immer „ging nicht": wer hier landet, soll nicht warten.
|
|
private(set) var outcome: Failure?
|
|
private(set) var didReturn = false
|
|
|
|
func present(url: URL, callbackScheme: String) {
|
|
outcome = .presentationFailed(String(localized: "signIn.windowFailed"))
|
|
didReturn = true
|
|
}
|
|
|
|
func dismiss() {}
|
|
}
|
|
#else
|
|
import AuthenticationServices
|
|
import Foundation
|
|
#if canImport(UIKit)
|
|
import UIKit
|
|
#else
|
|
import AppKit
|
|
#endif
|
|
|
|
/// Öffnet die Anmeldeseite von Plex in einem Fenster innerhalb der App.
|
|
///
|
|
/// `ASWebAuthenticationSession` ist dafür der vorgesehene Weg: die Seite kommt
|
|
/// von Plex, das Passwort wird dort eingegeben und erreicht die App nie. Nach
|
|
/// dem Anmelden schickt Plex den Browser auf eine Adresse mit unserem eigenen
|
|
/// Schema, das Fenster schließt sich von selbst, und die App weiß, dass sie
|
|
/// den Token abholen kann.
|
|
///
|
|
/// Ein einfacher `SFSafariViewController` täte es nicht: er meldet nicht, wann
|
|
/// die Rückadresse erreicht wurde, und müsste von Hand geschlossen werden.
|
|
@MainActor
|
|
final class PlexWebAuthenticator: NSObject {
|
|
enum Failure: Error, Equatable {
|
|
/// Der Nutzer hat das Fenster geschlossen. Kein Fehler im eigentlichen
|
|
/// Sinn — die Oberfläche soll darauf kommentarlos zurückgehen.
|
|
case cancelled
|
|
case presentationFailed(String)
|
|
}
|
|
|
|
/// Das laufende Fenster — damit es sich auch von außen schließen lässt.
|
|
///
|
|
/// Nötig, weil Plex den Rücksprung nicht immer ausführt: manchmal landet
|
|
/// der Browser nach dem Anmelden auf watch.plex.tv und bleibt dort stehen.
|
|
/// Der Token liegt dann längst bereit, nur das Fenster weiß es nicht.
|
|
private var session: ASWebAuthenticationSession?
|
|
|
|
/// Wie das Fenster ausgegangen ist.
|
|
///
|
|
/// `nil`, solange es noch offen ist.
|
|
private(set) var outcome: Failure?
|
|
private(set) var didReturn = false
|
|
|
|
/// Öffnet das Anmeldefenster und kehrt **sofort** zurück.
|
|
///
|
|
/// Bewusst nicht abwartend: 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 längst
|
|
/// bereit, aber ein abwartender Aufruf hinge auf einen Rückruf, der nie
|
|
/// kommt. Der Aufrufer fragt deshalb parallel beim Server nach und sieht
|
|
/// hier nur nach, ob das Fenster zwischendurch geschlossen wurde.
|
|
func present(url: URL, callbackScheme: String) {
|
|
outcome = nil
|
|
didReturn = false
|
|
|
|
let session = ASWebAuthenticationSession(
|
|
url: url,
|
|
callbackURLScheme: callbackScheme
|
|
) { [weak self] _, error in
|
|
// ASWebAuthenticationSession ruft den Abschlussblock auf dem
|
|
// Hauptthread auf — anders als VLCKit, wo dieselbe Annahme die App
|
|
// zum Absturz gebracht hat. Hier ist sie dokumentiert.
|
|
MainActor.assumeIsolated {
|
|
guard let self else { return }
|
|
if let error = error as? ASWebAuthenticationSessionError,
|
|
error.code == .canceledLogin {
|
|
self.outcome = .cancelled
|
|
} else if let error {
|
|
self.outcome = .presentationFailed(error.localizedDescription)
|
|
}
|
|
self.didReturn = true
|
|
self.session = nil
|
|
}
|
|
}
|
|
|
|
session.presentationContextProvider = self
|
|
// `false`, damit eine bestehende Plex-Anmeldung in Safari mitgenutzt
|
|
// wird: wer dort schon angemeldet ist, braucht nur zu bestätigen.
|
|
session.prefersEphemeralWebBrowserSession = false
|
|
|
|
guard session.start() else {
|
|
outcome = .presentationFailed(String(localized: "signIn.windowFailed"))
|
|
didReturn = true
|
|
return
|
|
}
|
|
self.session = session
|
|
}
|
|
}
|
|
|
|
extension PlexWebAuthenticator {
|
|
/// Schließt das Anmeldefenster.
|
|
///
|
|
/// Wird gerufen, sobald der Token auf anderem Weg eingetroffen ist — sonst
|
|
/// bliebe der Browser mit der Plex-Seite offen stehen, obwohl die App
|
|
/// schon angemeldet ist.
|
|
func dismiss() {
|
|
session?.cancel()
|
|
session = nil
|
|
}
|
|
}
|
|
|
|
extension PlexWebAuthenticator: ASWebAuthenticationPresentationContextProviding {
|
|
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
|
// Das Fenster der App. Gesucht statt gehalten, weil eine SwiftUI-App
|
|
// keinen festen Verweis darauf hat — und ein frisch erzeugtes Fenster
|
|
// erschiene hinter dem sichtbaren.
|
|
#if canImport(UIKit)
|
|
let scene = UIApplication.shared.connectedScenes
|
|
.compactMap { $0 as? UIWindowScene }
|
|
.first { $0.activationState == .foregroundActive }
|
|
|
|
return scene?.keyWindow ?? ASPresentationAnchor()
|
|
#else
|
|
return NSApplication.shared.keyWindow
|
|
?? NSApplication.shared.windows.first
|
|
?? ASPresentationAnchor()
|
|
#endif
|
|
}
|
|
}
|
|
#endif
|