Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
249 lines
9.1 KiB
Swift
249 lines
9.1 KiB
Swift
import Foundation
|
|
import JellyfinKit
|
|
import MediaKit
|
|
import Observation
|
|
|
|
/// Führt durch die Anmeldung an einem Jellyfin-Server.
|
|
///
|
|
/// Eigenes Modell statt eines weiteren Falls in `HaloSession.Phase`: die
|
|
/// Anmeldung besteht aus mehreren Schritten mit eigener Bedienung — Adresse
|
|
/// eintippen, Code bestätigen lassen, notfalls doch Benutzername und Passwort
|
|
/// —, und keiner davon interessiert den Rest der App. Was hier ankommt, ist am
|
|
/// Ende ein einziges Ergebnis: ein Konto und ein Weg dorthin.
|
|
///
|
|
/// **Quick Connect zuerst.** Der sechsstellige Code wird in einem bereits
|
|
/// angemeldeten Client bestätigt; das Passwort erreicht Halo dabei nie —
|
|
/// dieselbe Zusage wie bei Plex, wo die Anmeldemaske von Plex selbst kommt.
|
|
/// Benutzername und Passwort sind der Rückfall für Server, auf denen Quick
|
|
/// Connect abgeschaltet ist. Dann hält die App das Passwort für die Dauer
|
|
/// einer Anfrage und wirft es danach weg.
|
|
@MainActor
|
|
@Observable
|
|
final class JellyfinSignInModel {
|
|
enum Step: Equatable {
|
|
/// Die Adresse wird eingetippt.
|
|
case address
|
|
/// Die Adresse wird durchprobiert.
|
|
case checking
|
|
/// Der Code steht, jetzt wird auf die Bestätigung gewartet.
|
|
case quickConnect(code: String)
|
|
/// Der Rückfall: Benutzername und Passwort.
|
|
case credentials
|
|
case signingIn
|
|
}
|
|
|
|
/// Welcher Weg nach dem Fund zuerst angeboten wird.
|
|
///
|
|
/// **Am Telefon und am Fernseher verschieden herum.** Dort füllt ein
|
|
/// Passwortspeicher beide Felder in einer Sekunde; hier tippt man sie mit
|
|
/// einem Steuerkreuz, Buchstabe für Buchstabe, und der Code ist der
|
|
/// bequemere Weg. Welcher Fall gilt, weiß die Oberfläche — dieses Modell
|
|
/// nicht. Deshalb ein Wert im Initialisierer statt einer
|
|
/// Plattformbedingung mitten in `findServer()`.
|
|
enum PreferredMethod { case credentials, quickConnect }
|
|
|
|
var address = ""
|
|
var userName = ""
|
|
var password = ""
|
|
|
|
private(set) var step: Step = .address
|
|
private(set) var errorMessage: String?
|
|
/// Wie der gefundene Server heißt — die Bestätigung, dass die Adresse
|
|
/// stimmt, noch bevor jemand Anmeldedaten eintippt.
|
|
private(set) var serverName: String?
|
|
/// Ob der Server die Anmeldung per Code überhaupt anbietet.
|
|
private(set) var offersQuickConnect = false
|
|
|
|
/// Wird gerufen, sobald ein Konto feststeht.
|
|
///
|
|
/// Der Client kommt mit: er trägt den Token und die Gerätekennung bereits
|
|
/// und wird von der Sitzung unverändert weiterverwendet.
|
|
var onAuthenticated: ((JellyfinClient, JellyfinAccount, String) -> Void)?
|
|
|
|
private let identity: JellyfinIdentity
|
|
private let preferred: PreferredMethod
|
|
private var client: JellyfinClient?
|
|
private var authenticator: JellyfinAuthenticator?
|
|
private var pollTask: Task<Void, Never>?
|
|
|
|
/// Der Vorgabewert hält iOS unverändert.
|
|
init(identity: JellyfinIdentity, preferring: PreferredMethod = .credentials) {
|
|
self.identity = identity
|
|
self.preferred = preferring
|
|
}
|
|
|
|
// MARK: - Schritt 1: den Server finden
|
|
|
|
/// Probiert die plausiblen Ergänzungen der eingetippten Adresse durch.
|
|
///
|
|
/// Nicht eine raten, sondern der Reihe nach fragen: „192.168.1.50" kann
|
|
/// alles Mögliche heißen, und wer die Adresse vollständig eintippen muss,
|
|
/// tippt sie dreimal falsch.
|
|
func findServer() async {
|
|
pollTask?.cancel()
|
|
errorMessage = nil
|
|
|
|
let candidates = JellyfinAddress.candidates(for: address)
|
|
guard !candidates.isEmpty else {
|
|
errorMessage = String(localized: "signIn.jellyfin.addressMissing")
|
|
return
|
|
}
|
|
|
|
step = .checking
|
|
|
|
for candidate in candidates {
|
|
let client = JellyfinClient(baseURL: candidate, identity: identity)
|
|
let authenticator = JellyfinAuthenticator(client: client)
|
|
guard let info = try? await authenticator.serverInfo() else { continue }
|
|
|
|
self.client = client
|
|
self.authenticator = authenticator
|
|
serverName = info.serverName
|
|
address = candidate.absoluteString
|
|
offersQuickConnect = await authenticator.supportsQuickConnect()
|
|
|
|
// **Am Telefon Name und Passwort zuerst, am Fernseher der Code.**
|
|
//
|
|
// Hier sprang der Weg früher immer von selbst in den Code-Fluss,
|
|
// sobald der Server ihn anbietet. Das war gut gemeint — dabei
|
|
// läuft kein Passwort durch diese App —, geht am Telefon aber am
|
|
// Alltag vorbei: der Code muss in einem zweiten Gerät bestätigt
|
|
// werden, und wer gerade im Zug sitzt, hat das nicht offen. Ein
|
|
// Passwortspeicher füllt dagegen beide Felder in einer Sekunde.
|
|
//
|
|
// Vor einem Fernseher stimmt jedes Wort davon nicht mehr: dort
|
|
// gibt es keinen Passwortspeicher, dort tippt man mit dem
|
|
// Steuerkreuz, und das zweite Gerät liegt sowieso auf dem Sofa.
|
|
//
|
|
// Beide Wege bleiben erreichbar: `offersQuickConnect` steuert den
|
|
// Knopf darunter, und `useCredentials()` führt zurück.
|
|
if preferred == .quickConnect, offersQuickConnect {
|
|
await beginQuickConnect()
|
|
return
|
|
}
|
|
|
|
step = .credentials
|
|
return
|
|
}
|
|
|
|
step = .address
|
|
errorMessage = String(localized: "signIn.jellyfin.notFound")
|
|
}
|
|
|
|
// MARK: - Schritt 2a: Code bestätigen lassen
|
|
|
|
func beginQuickConnect() async {
|
|
guard let authenticator else { return }
|
|
errorMessage = nil
|
|
step = .checking
|
|
|
|
do {
|
|
let request = try await authenticator.beginQuickConnect()
|
|
step = .quickConnect(code: request.code)
|
|
startPolling(secret: request.secret, authenticator: authenticator)
|
|
} catch {
|
|
// Quick Connect abgeschaltet oder gestört: dann eben der andere
|
|
// Weg, statt hier stehen zu bleiben.
|
|
offersQuickConnect = false
|
|
step = .credentials
|
|
errorMessage = ErrorText.describe(error)
|
|
}
|
|
}
|
|
|
|
/// Fragt im Zweisekundentakt, ob der Code bestätigt wurde.
|
|
///
|
|
/// Fünf Minuten lang — so lange gilt ein Code bei Jellyfin in der
|
|
/// Voreinstellung. Danach wird nicht weiter gefragt, sondern gesagt, dass
|
|
/// er abgelaufen ist; eine Schleife, die ewig läuft, sieht aus wie eine
|
|
/// App, die hängt.
|
|
private func startPolling(secret: String, authenticator: JellyfinAuthenticator) {
|
|
pollTask?.cancel()
|
|
pollTask = Task { [weak self] in
|
|
for _ in 0..<150 {
|
|
try? await Task.sleep(for: .seconds(2))
|
|
if Task.isCancelled { return }
|
|
|
|
do {
|
|
if let account = try await authenticator.completeQuickConnect(secret: secret) {
|
|
self?.finish(with: account)
|
|
return
|
|
}
|
|
} catch MediaBackendError.notFound {
|
|
// Jellyfin kennt den Code nicht mehr: abgelaufen oder
|
|
// abgelehnt.
|
|
self?.expireQuickConnect()
|
|
return
|
|
} catch {
|
|
self?.errorMessage = ErrorText.describe(error)
|
|
return
|
|
}
|
|
}
|
|
self?.expireQuickConnect()
|
|
}
|
|
}
|
|
|
|
private func expireQuickConnect() {
|
|
errorMessage = String(localized: "signIn.jellyfin.codeExpired")
|
|
step = .credentials
|
|
}
|
|
|
|
// MARK: - Schritt 2b: Benutzername und Passwort
|
|
|
|
func signInWithPassword() async {
|
|
guard let authenticator else { return }
|
|
pollTask?.cancel()
|
|
errorMessage = nil
|
|
step = .signingIn
|
|
|
|
let secret = password
|
|
// Sofort aus dem Feld nehmen. Es wird nur noch für diese eine Anfrage
|
|
// gebraucht, und was nicht mehr dasteht, kann auch nicht in einem
|
|
// Bildschirmfoto landen.
|
|
password = ""
|
|
|
|
do {
|
|
let account = try await authenticator.signIn(userName: userName, password: secret)
|
|
finish(with: account)
|
|
} catch {
|
|
errorMessage = ErrorText.describe(error)
|
|
step = .credentials
|
|
}
|
|
}
|
|
|
|
// MARK: - Ende
|
|
|
|
private func finish(with account: JellyfinAccount) {
|
|
pollTask?.cancel()
|
|
guard let client else { return }
|
|
password = ""
|
|
onAuthenticated?(client, account, serverName ?? address)
|
|
}
|
|
|
|
/// Von der Code-Anzeige auf Benutzername und Passwort wechseln.
|
|
///
|
|
/// Der Weg ohne Passwort steht vorn, aber er ist kein Zwang: wer keinen
|
|
/// zweiten angemeldeten Client zur Hand hat, käme sonst nicht weiter.
|
|
func useCredentials() {
|
|
pollTask?.cancel()
|
|
errorMessage = nil
|
|
step = .credentials
|
|
}
|
|
|
|
/// Zurück zum Adressfeld — etwa nach einem Vertipper.
|
|
func startOver() {
|
|
pollTask?.cancel()
|
|
client = nil
|
|
authenticator = nil
|
|
serverName = nil
|
|
offersQuickConnect = false
|
|
password = ""
|
|
errorMessage = nil
|
|
step = .address
|
|
}
|
|
|
|
func cancel() {
|
|
pollTask?.cancel()
|
|
password = ""
|
|
}
|
|
}
|