Und dein Argument dafür war das beste: auf dem Mac kann ich selbst hinsehen. Kein Simulator-Tunnel, der mitten im Prüfen abstürzt — `screencapture` auf das Fenster der App, und zwar nur auf ihres, nicht auf den Bildschirm. **Ein eigenes Ziel, nicht ein gemeinsames.** Die Info.plist der iOS-App ist voller iOS-Angaben: Startbildschirm, erlaubte Ausrichtungen, Live Activities. Die alle bedingt zu machen hieße, dass jede spätere Änderung an der iOS-Seite über eine Stelle geht, die auch den Mac betrifft. Zwei Ziele sind ein paar Zeilen mehr und eine Sorge weniger. **Was den Unterschied ausmacht, steht an einer Stelle.** Ein neuer `PlatformShims` in FlixyDesign übersetzt, was auf beiden Seiten verschieden heißt: schmale Titelzeile, halbhohes Blatt, Eingabefelder ohne Großschreibung, die Plätze in der Werkzeugleiste, die Zwischenablage. Zwanzig Ansichten benutzen ihn, statt zwanzigmal `#if os(iOS)` zu tragen — sonst wandert die Fallunterscheidung in jede neue Datei und wird beim übernächsten Bildschirm vergessen. Wirklich verschieden sind nur fünf Stellen: die Bildfläche (`UIView` gegen `NSView`), der AirPlay-Knopf, die Ausrichtungssperre (auf dem Mac gibt es nichts zu sperren), der App-Delegat für Hintergrund-Downloads und das Anmeldefenster, das ein anderes Fenster als Anker braucht. **Das Karussell blättert jetzt mit einem einrastenden Streifen** statt mit `TabView(.page)` — den Seitenstil gibt es auf dem Mac nicht. Das ist nebenbei die bessere Lösung: das verschachtelte TabView hatte die Eigenschaft, beim Wechsel kurz zwei Seiten gleichzeitig zu zeigen. **Was auf dem Mac fehlt und warum:** VLC. Das Binärpaket ist auf iOS festgelegt; sein xcframework enthält zwar Mac-Teile, aber das ist eine eigene Runde. Solange fällt die Wiedergabe dort auf den Systemplayer zurück — MKV und DTS gehen also noch nicht. Live Activities gibt es auf dem Mac nicht, Bild-in- Bild startet von Hand statt beim Verlassen der App. Gesehen: die App startet, der Anmeldebildschirm steht, beide Wege sind da. Angemeldet habe ich mich auf dem Mac **nicht** — das ginge nur, indem ich einen Token in deinen Schlüsselbund schreibe, und dann fragt macOS dich beim ersten Zugriff nach dem Passwort. Das machst du mit einem Klick selbst. iOS baut unverändert, 263 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
117 lines
4.6 KiB
Swift
117 lines
4.6 KiB
Swift
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
|
|
}
|
|
}
|