Der Upload zu App Store Connect scheiterte am Namen. „Halo" ist eine eingetragene Marke von Microsoft; im App Store heisst zwar keine App exakt so, aber reservierte und markenrechtlich belegte Namen tauchen in keiner Suche auf. Der einzige verlässliche Test war die Eingabe dort, und die ging daneben. **Der Name wurde diesmal vorher geprüft.** Zwanzig Kandidaten gegen den deutschen und amerikanischen App Store, auf exakte Treffer und auf ähnliche Namen. „Kavo" wäre dieselbe Wand gewesen — exakt belegt in beiden Stores, in Deutschland von KaVo Dental GmbH. „Flare" ist mit 42 ähnlichen Apps je Store der am dichtesten besetzte Name der ganzen Runde. **Flaro** ist in beiden frei und liest sich als eigenes Wort, nicht als Flare mit Tippfehler. Das bleibt eine Store-Suche und keine Markenrecherche — reservierte Namen ohne veröffentlichte App sieht sie nicht. Aber es ist mehr Prüfung, als beim letzten Mal stattfand. --- 2160 Textvorkommen in 273 Dateien, 49 Pfade. Betroffen ist diesmal auch der Server: `Flaro.Api`, `Flaro.Core`, `Flaro.Data`, `Flaro.Media`, `Flaro.Metadata`, `FlaroContext`, `FlaroPaths`, `flaro.db`, die Variablen `FLARO_DATA`, `FLARO_CACHE`, `FLARO_TRANSCODE`, `FLARO_WRITE_SIDECARS`, und das Image. **Vor dem Ersetzen geprüft**, ob „halo" irgendwo als Teil eines anderen Wortes steckt — und ob es im TMDB-Token vorkommt. Tat es nicht; ein blindes Ersetzen hätte den sonst zerlegt. `docs/superpowers/specs/` bleibt wie beim letzten Mal unangetastet: datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen. Das Server-Dokument hat stattdessen einen Nachtrag bekommen, damit es niemanden in die Irre führt. **Die Bundle-ID wandert mit**, zum zweiten Mal an einem Tag: `com.scarriffleservices.flaro`. Wer die vorige Fassung installiert hat, hat jetzt zwei Karteileichen auf dem Gerät — beide löschen, dann ist Ruhe. 308 Server-Tests grün, 368 im Swift-Paket, null Warnungen. iOS und tvOS bauen, das Image baut, und ein frischer Container läuft durch Einrichtung und Anmeldung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
190 lines
6.3 KiB
Swift
190 lines
6.3 KiB
Swift
import Foundation
|
|
import MediaKit
|
|
|
|
/// Wer angemeldet ist.
|
|
public struct JellyfinAccount: Sendable, Equatable {
|
|
public let userID: String
|
|
public let userName: String
|
|
public let token: String
|
|
/// Ob dieses Konto den Server verwalten darf.
|
|
///
|
|
/// Entscheidet, ob der Verwaltungsbereich überhaupt erscheint. Ihn zu
|
|
/// zeigen und bei jedem Tipp „keine Berechtigung" zu melden, wäre die
|
|
/// schlechtere Antwort.
|
|
public let isAdministrator: Bool
|
|
|
|
public init(userID: String, userName: String, token: String, isAdministrator: Bool) {
|
|
self.userID = userID
|
|
self.userName = userName
|
|
self.token = token
|
|
self.isAdministrator = isAdministrator
|
|
}
|
|
}
|
|
|
|
/// Was ein Server über sich verrät, bevor man angemeldet ist.
|
|
public struct JellyfinServerInfo: Sendable, Equatable, Decodable {
|
|
public let serverName: String
|
|
public let version: String?
|
|
public let id: String?
|
|
/// Ob die Einrichtung abgeschlossen ist.
|
|
public let startupWizardCompleted: Bool?
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case serverName = "ServerName"
|
|
case version = "Version"
|
|
case id = "Id"
|
|
case startupWizardCompleted = "StartupWizardCompleted"
|
|
}
|
|
}
|
|
|
|
/// Der Code, mit dem sich die App ohne Passwort anmeldet.
|
|
public struct QuickConnectRequest: Sendable, Equatable, Decodable {
|
|
public let secret: String
|
|
public let code: String
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case secret = "Secret"
|
|
case code = "Code"
|
|
}
|
|
}
|
|
|
|
/// Meldet an einem Jellyfin-Server an.
|
|
///
|
|
/// Zwei Wege, und die Reihenfolge ist Absicht.
|
|
///
|
|
/// **Quick Connect** ist der erste: die App zeigt einen sechsstelligen Code,
|
|
/// den man in einem bereits angemeldeten Client bestätigt. Das Passwort
|
|
/// erreicht Flaro dabei nie — dieselbe Zusage, die bei Plex gilt, wo die
|
|
/// Anmeldemaske von Plex selbst kommt.
|
|
///
|
|
/// **Benutzername und Passwort** sind der Rückfall, falls der Server Quick
|
|
/// Connect abgeschaltet hat. Dann hält die App das Passwort für die Dauer
|
|
/// einer Anfrage — sie speichert es nicht, protokolliert es nicht und legt es
|
|
/// nirgends ab. Gespeichert wird nur der Token, und der im Schlüsselbund.
|
|
public actor JellyfinAuthenticator {
|
|
private let client: JellyfinClient
|
|
|
|
public init(client: JellyfinClient) {
|
|
self.client = client
|
|
}
|
|
|
|
/// Fragt den Server, ob er überhaupt einer ist.
|
|
public func serverInfo() async throws(MediaBackendError) -> JellyfinServerInfo {
|
|
try await client.send(
|
|
JellyfinRequest(path: "/System/Info/Public", timeout: .seconds(10)),
|
|
as: JellyfinServerInfo.self
|
|
)
|
|
}
|
|
|
|
/// Ob der Server die Anmeldung per Code anbietet.
|
|
public func supportsQuickConnect() async -> Bool {
|
|
// Schweigt er oder antwortet mit einem Fehler, gilt es als
|
|
// abgeschaltet. Ein Code, den niemand bestätigen kann, wäre eine
|
|
// Sackgasse mit Wartezeit.
|
|
guard let data = try? await client.send(JellyfinRequest(path: "/QuickConnect/Enabled")) else {
|
|
return false
|
|
}
|
|
let text = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return text.lowercased() == "true"
|
|
}
|
|
|
|
public func beginQuickConnect() async throws(MediaBackendError) -> QuickConnectRequest {
|
|
try await client.send(
|
|
JellyfinRequest(method: .post, path: "/QuickConnect/Initiate"),
|
|
as: QuickConnectRequest.self
|
|
)
|
|
}
|
|
|
|
/// Sieht nach, ob der Code inzwischen bestätigt wurde.
|
|
///
|
|
/// Gibt `nil` zurück, solange er es nicht ist — der Aufrufer führt seine
|
|
/// eigene Schleife, weil er nebenher auf Abbruch achten muss.
|
|
public func completeQuickConnect(
|
|
secret: String
|
|
) async throws(MediaBackendError) -> JellyfinAccount? {
|
|
let state = try await client.send(
|
|
JellyfinRequest(
|
|
path: "/QuickConnect/Connect",
|
|
query: [URLQueryItem(name: "secret", value: secret)]
|
|
),
|
|
as: QuickConnectState.self
|
|
)
|
|
guard state.authenticated else { return nil }
|
|
|
|
let body = try encode(["Secret": secret])
|
|
return try await authenticate(
|
|
JellyfinRequest(method: .post, path: "/Users/AuthenticateWithQuickConnect", body: body)
|
|
)
|
|
}
|
|
|
|
/// Der Rückfall, wenn Quick Connect abgeschaltet ist.
|
|
public func signIn(
|
|
userName: String,
|
|
password: String
|
|
) async throws(MediaBackendError) -> JellyfinAccount {
|
|
let body = try encode(["Username": userName, "Pw": password])
|
|
return try await authenticate(
|
|
JellyfinRequest(method: .post, path: "/Users/AuthenticateByName", body: body)
|
|
)
|
|
}
|
|
|
|
private func authenticate(
|
|
_ request: JellyfinRequest
|
|
) async throws(MediaBackendError) -> JellyfinAccount {
|
|
let result = try await client.send(request, as: AuthenticationResult.self)
|
|
await client.setToken(result.accessToken)
|
|
return JellyfinAccount(
|
|
userID: result.user.id,
|
|
userName: result.user.name,
|
|
token: result.accessToken,
|
|
isAdministrator: result.user.policy?.isAdministrator ?? false
|
|
)
|
|
}
|
|
|
|
private func encode(_ fields: [String: String]) throws(MediaBackendError) -> Data {
|
|
do {
|
|
return try JSONSerialization.data(withJSONObject: fields)
|
|
} catch {
|
|
throw .decoding(description: String(describing: error))
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct QuickConnectState: Decodable, Sendable {
|
|
let authenticated: Bool
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case authenticated = "Authenticated"
|
|
}
|
|
}
|
|
|
|
private struct AuthenticationResult: Decodable, Sendable {
|
|
let accessToken: String
|
|
let user: User
|
|
|
|
struct User: Decodable, Sendable {
|
|
let id: String
|
|
let name: String
|
|
let policy: Policy?
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case id = "Id"
|
|
case name = "Name"
|
|
case policy = "Policy"
|
|
}
|
|
}
|
|
|
|
struct Policy: Decodable, Sendable {
|
|
let isAdministrator: Bool?
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case isAdministrator = "IsAdministrator"
|
|
}
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case accessToken = "AccessToken"
|
|
case user = "User"
|
|
}
|
|
}
|