Halo heisst ab hier Flaro — die Marke stand im Weg
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f98b995050
commit
a7cf6c5def
@@ -0,0 +1,166 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
public typealias PlatformImage = UIImage
|
||||
#else
|
||||
import AppKit
|
||||
public typealias PlatformImage = NSImage
|
||||
#endif
|
||||
|
||||
/// Lädt Bilder vom Server — einmal, und behält sie.
|
||||
///
|
||||
/// `AsyncImage` konnte das nicht: es lädt bei **jedem** Erscheinen neu, kennt
|
||||
/// keinen Zwischenspeicher und legt für jedes Bild eine eigene Anfrage an. Eine
|
||||
/// Startseite zeigt leicht sechzig Kacheln; dazu ein Aufmacher, der ein großes
|
||||
/// Bild anfordert, das der Server erst umrechnen muss. Mit sechs gleichzeitigen
|
||||
/// Verbindungen je Gegenstelle — der Voreinstellung — stauen sich die übrigen
|
||||
/// dahinter, und wer wegscrollt und zurückkommt, stellt sich hinten wieder an.
|
||||
///
|
||||
/// Auf dem iPad fiel das nicht auf, auf dem Mac schon: dort blieben der
|
||||
/// Aufmacher und die Hälfte der Plakate grau.
|
||||
///
|
||||
/// Zwei Speicher übereinander: die entpackten Bilder im Arbeitsspeicher, die
|
||||
/// Rohdaten auf der Platte. Der zweite überlebt einen Neustart der App —
|
||||
/// dieselbe Startseite kostet dann keine einzige Anfrage mehr.
|
||||
public actor ArtworkLoader {
|
||||
public static let shared = ArtworkLoader()
|
||||
|
||||
private let session: URLSession
|
||||
/// Der Speicher liegt **außerhalb** des Actors, damit er sich ohne
|
||||
/// `await` lesen lässt.
|
||||
///
|
||||
/// Das klingt nach einer Kleinigkeit und ist der ganze Punkt: ein `await`
|
||||
/// kostet immer mindestens einen Durchlauf, und genau der war beim
|
||||
/// Überblenden des Aufmachers zu sehen. Das neue Bild erschien im ersten
|
||||
/// Bild noch ohne Schriftzug, im zweiten mit — der Schriftzug sprang also
|
||||
/// getrennt herein, obwohl er längst da war. Wer das Bild schon hat, soll
|
||||
/// es im selben Augenblick zeigen können.
|
||||
private let cache = ImageMemoryCache()
|
||||
/// Läuft eine Anfrage schon, wird sie geteilt statt ein zweites Mal
|
||||
/// gestellt. Ohne das lädt eine Reihe, die dasselbe Plakat zweimal zeigt,
|
||||
/// es auch zweimal.
|
||||
private var inFlight: [URL: Task<PlatformImage?, Never>] = [:]
|
||||
|
||||
init() {
|
||||
let configuration = URLSessionConfiguration.default
|
||||
configuration.urlCache = URLCache(
|
||||
memoryCapacity: 32 * 1_024 * 1_024,
|
||||
diskCapacity: 512 * 1_024 * 1_024
|
||||
)
|
||||
configuration.requestCachePolicy = .returnCacheDataElseLoad
|
||||
// Bilder sind klein und viele. Sechs gleichzeitige Verbindungen sind
|
||||
// für eine Seite mit sechzig Kacheln zu wenig.
|
||||
configuration.httpMaximumConnectionsPerHost = 12
|
||||
configuration.timeoutIntervalForRequest = 30
|
||||
session = URLSession(configuration: configuration)
|
||||
|
||||
cache.countLimit = 400
|
||||
}
|
||||
|
||||
/// Das Bild, wenn es schon im Arbeitsspeicher liegt — ohne Warten.
|
||||
///
|
||||
/// `nonisolated`, damit eine Ansicht es beim Zeichnen aufrufen kann.
|
||||
public nonisolated static func cachedImage(for url: URL) -> PlatformImage? {
|
||||
shared.cache.image(for: url)
|
||||
}
|
||||
|
||||
public func image(for url: URL) async -> PlatformImage? {
|
||||
if let cached = cache.image(for: url) { return cached }
|
||||
if let running = inFlight[url] { return await running.value }
|
||||
|
||||
let task = Task<PlatformImage?, Never> { [session] in
|
||||
await Self.fetch(url, using: session)
|
||||
}
|
||||
inFlight[url] = task
|
||||
|
||||
let image = await task.value
|
||||
inFlight[url] = nil
|
||||
if let image { cache.store(image, for: url) }
|
||||
return image
|
||||
}
|
||||
|
||||
/// Holt ein Bild — und gibt nicht beim ersten Nein auf.
|
||||
///
|
||||
/// Der Grund ist ein Fehlerbild, das lange rätselhaft aussah: auf einem
|
||||
/// großen Fenster blieb die **zweite** Reihe fast vollständig grau,
|
||||
/// während die erste geladen hatte. Es lag nicht am Server und nicht an
|
||||
/// fehlenden Bildern, sondern an der Gleichzeitigkeit — sechzig sichtbare
|
||||
/// Kacheln teilen sich zwölf Verbindungen, die hinteren stehen so lange
|
||||
/// in der Schlange, dass sie in den Zeitablauf laufen.
|
||||
///
|
||||
/// Oben fragt niemand ein zweites Mal: `task(id:)` läuft einmal je
|
||||
/// Adresse. Ein einziger verpasster Versuch war damit endgültig.
|
||||
///
|
||||
/// **Nur wiederholt, was sich zu wiederholen lohnt.** Ein 404 heißt, dass
|
||||
/// es das Bild nicht gibt — dreimal zu fragen macht es nicht vorhanden
|
||||
/// und kostet den Server drei Antworten. Ein Zeitablauf oder ein
|
||||
/// abgerissener Strang dagegen sagt nur, dass es *gerade* nicht ging.
|
||||
private static func fetch(
|
||||
_ url: URL,
|
||||
using session: URLSession,
|
||||
attempts: Int = 3
|
||||
) async -> PlatformImage? {
|
||||
for attempt in 1...attempts {
|
||||
if Task.isCancelled { return nil }
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(from: url)
|
||||
guard let http = response as? HTTPURLResponse else { return nil }
|
||||
|
||||
if (200..<300).contains(http.statusCode) {
|
||||
return PlatformImage(data: data)
|
||||
}
|
||||
// Der Server hat geantwortet und nein gesagt. Bei 5xx und 429
|
||||
// lohnt ein zweiter Versuch, sonst nicht.
|
||||
guard http.statusCode >= 500 || http.statusCode == 429 else { return nil }
|
||||
} catch is CancellationError {
|
||||
return nil
|
||||
} catch {
|
||||
// Zeitablauf, abgerissene Verbindung: der Fall, für den das
|
||||
// alles hier gebaut ist.
|
||||
}
|
||||
|
||||
guard attempt < attempts else { return nil }
|
||||
// Wachsender Abstand: ist die Leitung gerade verstopft, hilft es
|
||||
// nicht, sofort noch einmal zu drängeln.
|
||||
try? await Task.sleep(for: .milliseconds(300 * attempt * attempt))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Bildspeicher, der sich von überall lesen lässt.
|
||||
///
|
||||
/// `NSCache` ist laut Apples Dokumentation von sich aus für den Zugriff aus
|
||||
/// mehreren Strängen gebaut — der Compiler weiß das nur nicht. Deshalb hier
|
||||
/// von Hand versichert statt den Speicher in einen Actor zu sperren, was ihn
|
||||
/// genau um die Eigenschaft brächte, für die er hier steht: ohne Warten
|
||||
/// lesbar zu sein.
|
||||
private final class ImageMemoryCache: @unchecked Sendable {
|
||||
private let storage = NSCache<NSURL, PlatformImage>()
|
||||
|
||||
var countLimit: Int {
|
||||
get { storage.countLimit }
|
||||
set { storage.countLimit = newValue }
|
||||
}
|
||||
|
||||
func image(for url: URL) -> PlatformImage? {
|
||||
storage.object(forKey: url as NSURL)
|
||||
}
|
||||
|
||||
func store(_ image: PlatformImage, for url: URL) {
|
||||
storage.setObject(image, forKey: url as NSURL)
|
||||
}
|
||||
}
|
||||
|
||||
extension Image {
|
||||
/// Aus einem Plattformbild, ohne dass die Ansicht wissen muss, welches.
|
||||
init(platform image: PlatformImage) {
|
||||
#if canImport(UIKit)
|
||||
self.init(uiImage: image)
|
||||
#else
|
||||
self.init(nsImage: image)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user