Files
plezy/tvos/Runner/SystemShelfPlugin.swift
edde746 84bc4b2618 fix(i18n): translate the strings that still reached the UI in English
Non-English users saw English text in a dozen places and blank labels in
sixteen more.

The English came from sites that produce their copy away from the widget
that renders it, which is what the structural hardcoded-string check
cannot see: picture-in-picture refused with a raw literal instead of the
pipErrors.notSupported key that already existed; the two Jellyfin/Emby
auth throws missing display: rendered their developer message on the
add-server form; ServerParsingException.toString() fed its English into
the localized "Failed to load servers" wrapper; Watch Together
interpolated the whole PeerError, so a failed create read "Failed to
create session: PeerError(PeerErrorType.timeout): Timed out creating
session" and join printed its prefix twice; the hub and playlist
continuation footers rendered exception.toString(); shader rows showed an
English title over an already translated subtitle; the player queue fell
back to the raw Dart enum name; a Plex home user with no title showed
"Unknown"; a failed player start showed "Exception: Failed to initialize
player"; and the tvOS top-shelf header was hardcoded in an extension that
has no Flutter engine.

The blanks came from three recent features that added English keys
without translations. clean_translations.py filled all 21 siblings with
empty strings, so the Android TV resolution switch, every Jellyfin/Emby
recording-rule field, the demuxer row, the Companion Remote address
caption and the Seerr blocklist pill rendered nothing at all.

Two fixes are structural rather than key swaps. ContinuationStatusSliver
now takes an errorContext and calls a new non-logging
localizedLoadErrorText, so no future throw can leak through it. lib/mpv
stays free of user-facing copy: it raises a PlayerInitializationException
sentinel and a PlayerError.playerInitFailed cause tag that the player
screen resolves to localized text. The tvOS section title travels in the
shelf payload, additively, so an older cache still renders.
2026-08-24 05:23:05 +02:00

1116 lines
39 KiB
Swift

import CryptoKit
import Foundation
import ImageIO
import Security
import TVServices
#if os(tvOS)
import Flutter
struct SystemShelfMutationEnvelope {
let ownerId: String
let generation: Int64
let engineEpoch: UInt64
}
struct SystemShelfPruneBatch {
let token: UInt64
let candidates: Set<URL>
let delay: TimeInterval
}
struct SystemShelfMutationState {
private(set) var activeEngineEpoch: UInt64 = 0
private(set) var ownerId = ""
private(set) var generation: Int64 = 0
private static let maximumPruneCandidatesPerBatch = 64
private var nextPruneToken: UInt64 = 0
private var pendingPrunes: [URL: UInt64] = [:]
mutating func beginEngineSession() -> UInt64 {
activeEngineEpoch &+= 1
ownerId = ""
generation = 0
pendingPrunes.removeAll()
return activeEngineEpoch
}
func accepts(_ envelope: SystemShelfMutationEnvelope, clearing: Bool = false) -> Bool {
guard envelope.engineEpoch == activeEngineEpoch else { return false }
if envelope.generation < generation { return false }
if envelope.generation > generation { return true }
return clearing
? ownerId.isEmpty || ownerId == envelope.ownerId
: ownerId == envelope.ownerId
}
mutating func commit(
_ envelope: SystemShelfMutationEnvelope,
clearing: Bool = false,
operation: () -> Bool
) -> Bool {
guard accepts(envelope, clearing: clearing), operation() else { return false }
ownerId = clearing ? "" : envelope.ownerId
generation = envelope.generation
return true
}
mutating func adoptPersistedOwner(_ persistedOwnerId: String) -> SystemShelfMutationEnvelope? {
guard !persistedOwnerId.isEmpty, ownerId.isEmpty, generation == 0 else { return nil }
ownerId = persistedOwnerId
return SystemShelfMutationEnvelope(
ownerId: persistedOwnerId,
generation: 0,
engineEpoch: activeEngineEpoch
)
}
mutating func cancelPruning(keeping files: Set<URL>) {
for file in files {
pendingPrunes.removeValue(forKey: file.standardizedFileURL)
}
}
mutating func cancelAllPruning() {
pendingPrunes.removeAll()
}
mutating func preparePruning(
removing files: Set<URL>,
after delay: TimeInterval
) -> [SystemShelfPruneBatch] {
let candidates = Set(files.map(\.standardizedFileURL))
.sorted { $0.path < $1.path }
guard !candidates.isEmpty else { return [] }
var batches: [SystemShelfPruneBatch] = []
batches.reserveCapacity(
(candidates.count + Self.maximumPruneCandidatesPerBatch - 1)
/ Self.maximumPruneCandidatesPerBatch
)
for start in stride(
from: 0,
to: candidates.count,
by: Self.maximumPruneCandidatesPerBatch
) {
let end = min(start + Self.maximumPruneCandidatesPerBatch, candidates.count)
nextPruneToken &+= 1
let batchCandidates = Set(candidates[start..<end])
for file in batchCandidates {
pendingPrunes[file] = nextPruneToken
}
batches.append(
SystemShelfPruneBatch(
token: nextPruneToken,
candidates: batchCandidates,
delay: max(0, delay)
)
)
}
return batches
}
mutating func claimPruning(_ batch: SystemShelfPruneBatch) -> Set<URL> {
var claimed = Set<URL>()
for file in batch.candidates {
let candidate = file.standardizedFileURL
guard pendingPrunes[candidate] == batch.token else { continue }
pendingPrunes.removeValue(forKey: candidate)
claimed.insert(candidate)
}
return claimed
}
}
struct BoundedArtworkDownload {
let data: Data
let mimeType: String
}
final class BoundedArtworkLoader: NSObject, URLSessionDataDelegate {
private let maximumBytes: Int
private let configuration: URLSessionConfiguration
private let terminalSemaphore = DispatchSemaphore(value: 0)
private let stateLock = NSLock()
private var buffer: Data
private var responseMimeType: String?
private var terminalResult: BoundedArtworkDownload?
private var isFinished = false
private var _peakBufferedBytes = 0
private var _exceededLimit = false
init(maximumBytes: Int, configuration: URLSessionConfiguration = .ephemeral) {
precondition(maximumBytes > 0)
self.maximumBytes = maximumBytes
self.configuration = configuration
buffer = Data()
super.init()
}
var peakBufferedBytes: Int {
stateLock.lock()
defer { stateLock.unlock() }
return _peakBufferedBytes
}
var exceededLimit: Bool {
stateLock.lock()
defer { stateLock.unlock() }
return _exceededLimit
}
func load(url: URL, timeout: TimeInterval) -> BoundedArtworkDownload? {
let delegateQueue = OperationQueue()
delegateQueue.maxConcurrentOperationCount = 1
let session = URLSession(
configuration: configuration,
delegate: self,
delegateQueue: delegateQueue
)
let task = session.dataTask(with: url)
task.resume()
if terminalSemaphore.wait(timeout: .now() + timeout) == .timedOut {
finish(nil)
task.cancel()
session.invalidateAndCancel()
} else {
session.finishTasksAndInvalidate()
}
stateLock.lock()
defer { stateLock.unlock() }
return terminalResult
}
func urlSession(
_ session: URLSession,
dataTask: URLSessionDataTask,
didReceive response: URLResponse,
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
) {
guard let http = response as? HTTPURLResponse,
(200...299).contains(http.statusCode),
["http", "https"].contains(http.url?.scheme?.lowercased() ?? ""),
let mimeType = http.mimeType?.lowercased(),
http.expectedContentLength < 0 || http.expectedContentLength <= Int64(maximumBytes)
else {
completionHandler(.cancel)
return
}
stateLock.lock()
responseMimeType = mimeType
stateLock.unlock()
completionHandler(.allow)
}
func urlSession(
_ session: URLSession,
dataTask: URLSessionDataTask,
didReceive data: Data
) {
var exceeded = false
stateLock.lock()
if !isFinished {
if data.count > maximumBytes - buffer.count {
_exceededLimit = true
exceeded = true
} else {
buffer.append(data)
_peakBufferedBytes = max(_peakBufferedBytes, buffer.count)
}
}
stateLock.unlock()
if exceeded {
finish(nil)
dataTask.cancel()
}
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?
) {
stateLock.lock()
let result: BoundedArtworkDownload?
if error == nil, !_exceededLimit, let mimeType = responseMimeType {
result = BoundedArtworkDownload(data: buffer, mimeType: mimeType)
} else {
result = nil
}
stateLock.unlock()
finish(result)
}
private func finish(_ result: BoundedArtworkDownload?) {
stateLock.lock()
guard !isFinished else {
stateLock.unlock()
return
}
isFinished = true
terminalResult = result
stateLock.unlock()
terminalSemaphore.signal()
}
}
struct SystemShelfSyncEnvironment {
typealias ArtworkLoader = (URL, Int, TimeInterval) -> BoundedArtworkDownload?
let defaults: UserDefaults
let artworkRoot: URL
let now: () -> Date
let loadArtwork: ArtworkLoader
let schedulePrune: ([SystemShelfPruneBatch]) -> Void
let notifyChange: () -> Void
}
final class SystemShelfPlugin: NSObject, FlutterPlugin {
static let schemaVersion = 3
static let appGroupIdentifier = "group.com.edde746.plezy"
static let cacheDataKey = "PlezySystemShelfCacheData"
static let sourcesKey = "PlezySystemShelfSources"
static let tokenKeychainService = "com.edde746.plezy.systemshelf.tokens"
static let artworkDirectoryName = "SystemShelfArtwork"
private static let maxItems = 20
/// English fallback for caches written before the localized title existed.
private static let fallbackSectionTitle = "Continue Watching"
private static let maxImageBytes = 2 * 1024 * 1024
private static let maxSyncBytes = 8 * 1024 * 1024
private static let syncTimeout: TimeInterval = 8
private static let imageTimeout: TimeInterval = 2.5
private static let staleArtworkGracePeriod: TimeInterval = 60
private static let mutationQueue = DispatchQueue(label: "com.plezy.system-shelf", qos: .utility)
private static let deepLinkDelivery = TvosDeepLinkDeliveryCoordinator()
private static var methodChannel: FlutterMethodChannel?
private static var mutationState = SystemShelfMutationState()
private let engineEpoch: UInt64
private init(engineEpoch: UInt64) {
self.engineEpoch = engineEpoch
super.init()
}
static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(name: "com.plezy/system_shelf", binaryMessenger: registrar.messenger())
let engineEpoch = mutationQueue.sync {
mutationState.beginEngineSession()
}
methodChannel = channel
deepLinkDelivery.bindEngine()
registrar.addMethodCallDelegate(SystemShelfPlugin(engineEpoch: engineEpoch), channel: channel)
mutationQueue.async { scrubLegacyPayload() }
}
static func handleOpenURL(_ url: URL) -> Bool {
guard let contentId = contentId(from: url) else { return false }
switch deepLinkDelivery.receive(contentId: contentId) {
case let .deliver(event):
deliverLive(event)
case .retained, .duplicate:
break
}
return true
}
private static func contentId(from url: URL) -> String? {
guard url.scheme == "plezy", url.host == "play" else { return nil }
return URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?.first { $0.name == "content_id" }?.value
}
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "isSupported":
result(Self.sharedDefaults != nil && Self.artworkRoot != nil)
case "sync":
guard let envelope = Self.envelope(call.arguments, engineEpoch: engineEpoch),
let raw = call.arguments as? [String: Any],
let items = raw["items"] as? [[String: Any]],
items.count <= Self.maxItems
else {
result(FlutterError(code: "INVALID_ARGS", message: "Invalid shelf envelope", details: nil))
return
}
let sectionTitle = raw["sectionTitle"] as? String
Self.perform(result) {
Self.sync(envelope: envelope, rawItems: items, sectionTitle: sectionTitle)
}
case "updateSources":
guard let envelope = Self.envelope(call.arguments, engineEpoch: engineEpoch),
let raw = call.arguments as? [String: Any],
let servers = raw["servers"] as? [[String: Any]]
else {
result(FlutterError(code: "INVALID_ARGS", message: "Invalid shelf envelope", details: nil))
return
}
let maxItems = (raw["maxItems"] as? NSNumber)?.intValue
let sectionTitle = raw["sectionTitle"] as? String
Self.perform(result) {
Self.updateSources(
envelope: envelope,
rawServers: servers,
maxItems: maxItems,
sectionTitle: sectionTitle
)
}
case "clear":
guard let envelope = Self.envelope(call.arguments, engineEpoch: engineEpoch) else {
result(FlutterError(code: "INVALID_ARGS", message: "Invalid shelf envelope", details: nil))
return
}
Self.perform(result) { Self.clearCache(envelope: envelope) }
case "remove":
guard let envelope = Self.envelope(call.arguments, engineEpoch: engineEpoch),
let raw = call.arguments as? [String: Any],
let contentId = raw["contentId"] as? String
else {
result(FlutterError(code: "INVALID_ARGS", message: "Invalid shelf envelope", details: nil))
return
}
Self.perform(result) { Self.removeItem(envelope: envelope, contentId: contentId) }
case "getInitialDeepLink":
let event = Self.deepLinkDelivery.beginLiveDelivery()
result(event?.contentId)
if let event, let next = Self.deepLinkDelivery.complete(event, succeeded: true) {
Self.deliverLive(next)
}
default:
result(FlutterMethodNotImplemented)
}
}
private static func deliverLive(_ event: TvosDeepLinkDeliveryCoordinator.Event) {
guard let channel = methodChannel else { return }
channel.invokeMethod("onShelfItemTap", arguments: event.flutterArguments) { reply in
let succeeded = flutterDeliverySucceeded(reply)
if let next = deepLinkDelivery.complete(event, succeeded: succeeded) {
deliverLive(next)
}
}
}
private static func flutterDeliverySucceeded(_ reply: Any?) -> Bool {
reply as? Bool == true
}
private static func perform(_ result: @escaping FlutterResult, operation: @escaping () -> Bool) {
mutationQueue.async {
let value = operation()
DispatchQueue.main.async { result(value) }
}
}
private static func envelope(
_ arguments: Any?,
engineEpoch: UInt64
) -> SystemShelfMutationEnvelope? {
guard let args = arguments as? [String: Any],
(args["schemaVersion"] as? NSNumber)?.intValue == schemaVersion,
let owner = args["ownerId"] as? String,
!owner.isEmpty,
let generation = (args["generation"] as? NSNumber)?.int64Value,
generation > 0
else { return nil }
return SystemShelfMutationEnvelope(
ownerId: owner,
generation: generation,
engineEpoch: engineEpoch
)
}
static var sharedDefaults: UserDefaults? { UserDefaults(suiteName: appGroupIdentifier) }
static var artworkRoot: URL? {
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)?
.appendingPathComponent(artworkDirectoryName, isDirectory: true)
}
private struct CommittedArtwork {
let sourceHash: String?
let key: String
}
private static func sync(
envelope: SystemShelfMutationEnvelope,
rawItems: [[String: Any]],
sectionTitle: String?
) -> Bool {
guard let defaults = sharedDefaults, let root = artworkRoot else { return false }
let environment = SystemShelfSyncEnvironment(
defaults: defaults,
artworkRoot: root,
now: Date.init,
loadArtwork: { url, maximumBytes, timeout in
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = timeout
configuration.timeoutIntervalForResource = timeout
return BoundedArtworkLoader(
maximumBytes: maximumBytes,
configuration: configuration
).load(url: url, timeout: timeout)
},
schedulePrune: { batches in
schedulePrune(batches)
},
notifyChange: {
TVTopShelfContentProvider.topShelfContentDidChange()
}
)
return sync(
envelope: envelope,
rawItems: rawItems,
sectionTitle: sectionTitle,
state: &mutationState,
environment: environment
)
}
static func sync(
envelope: SystemShelfMutationEnvelope,
rawItems: [[String: Any]],
sectionTitle: String?,
state: inout SystemShelfMutationState,
environment: SystemShelfSyncEnvironment
) -> Bool {
let defaults = environment.defaults
let root = environment.artworkRoot
guard state.accepts(envelope) else { return false }
let previouslyCommittedArtwork = committedArtworkFiles(defaults: defaults, root: root)
let deadline = environment.now().addingTimeInterval(syncTimeout)
let ownerDirectory = root.appendingPathComponent(ownerHash(envelope.ownerId), isDirectory: true)
do {
try FileManager.default.createDirectory(at: ownerDirectory, withIntermediateDirectories: true)
} catch {
return false
}
let priorArtwork = committedArtworkByContentId(
defaults: defaults,
ownerId: envelope.ownerId,
directory: ownerDirectory
)
var remaining = maxSyncBytes
var files = Set<String>()
var materializedFiles = Set<String>()
let items = rawItems.compactMap { raw -> [String: Any]? in
guard let contentId = raw["contentId"] as? String, !contentId.isEmpty,
let title = raw["title"] as? String
else { return nil }
var item: [String: Any] = ["contentId": contentId, "title": title]
for key in [
"episodeTitle", "description", "type", "duration", "lastPlaybackPosition",
"lastEngagementTime", "seriesTitle", "seasonNumber", "episodeNumber",
] {
if let value = sanitizedJSONValue(raw[key] as Any) { item[key] = value }
}
if let source = raw["posterSourceUri"] as? String,
let sourceHash = artworkSourceHash(source)
{
item["artworkSourceHash"] = sourceHash
let prior = priorArtwork[contentId]
if let prior, prior.sourceHash == sourceHash {
item["artworkKey"] = prior.key
files.insert(prior.key)
} else {
let materialized =
environment.now() < deadline
? materialize(
source: source,
directory: ownerDirectory,
remaining: &remaining,
deadline: deadline,
now: environment.now,
loader: environment.loadArtwork
)
: nil
if let materialized {
item["artworkKey"] = materialized
files.insert(materialized)
materializedFiles.insert(materialized)
} else if let prior {
// Retain the last committed image on a transient download failure.
// Preserve its source identity when known so a changed source is
// retried, and leave legacy identity absent for the same reason.
if let priorSourceHash = prior.sourceHash {
item["artworkSourceHash"] = priorSourceHash
} else {
item.removeValue(forKey: "artworkSourceHash")
}
item["artworkKey"] = prior.key
files.insert(prior.key)
}
}
}
return item
}
let resolvedSectionTitle = sectionTitle.flatMap { $0.isEmpty ? nil : $0 } ?? fallbackSectionTitle
let payload: [String: Any] = [
"schemaVersion": schemaVersion,
"ownerId": envelope.ownerId,
"updatedAt": Date().timeIntervalSince1970,
"sections": [["id": "continue_watching", "title": resolvedSectionTitle, "items": items]],
]
guard
state.commit(
envelope,
operation: {
writePayload(payload, defaults: defaults)
})
else {
pruneRejectedSync(ownerDirectory: ownerDirectory, removing: materializedFiles)
return false
}
let committedArtwork = Set(
files.map { ownerDirectory.appendingPathComponent($0, isDirectory: false) }
)
state.cancelPruning(keeping: committedArtwork)
environment.schedulePrune(
state.preparePruning(
removing: previouslyCommittedArtwork.subtracting(committedArtwork),
after: staleArtworkGracePeriod
)
)
environment.notifyChange()
return true
}
private static func updateSources(
envelope: SystemShelfMutationEnvelope,
rawServers: [[String: Any]],
maxItems: Int?,
sectionTitle: String?
) -> Bool {
guard let defaults = sharedDefaults else { return false }
return updateSources(
envelope: envelope,
rawServers: rawServers,
maxItems: maxItems,
sectionTitle: sectionTitle,
state: &mutationState,
defaults: defaults,
storeTokens: storeSourceTokens,
notifyChange: {
TVTopShelfContentProvider.topShelfContentDidChange()
}
)
}
/// Persists token-free source descriptors to app-group defaults and the
/// serverId-to-token map to the keychain so the Top Shelf extension can
/// fetch Continue Watching live. Tokens never touch defaults or logs.
static func updateSources(
envelope: SystemShelfMutationEnvelope,
rawServers: [[String: Any]],
maxItems: Int?,
sectionTitle: String?,
state: inout SystemShelfMutationState,
defaults: UserDefaults,
storeTokens: (String, [String: String]) -> Bool,
notifyChange: () -> Void
) -> Bool {
guard state.accepts(envelope) else { return false }
var descriptors: [[String: Any]] = []
var tokens: [String: String] = [:]
for raw in rawServers {
guard let serverId = raw["serverId"] as? String, !serverId.isEmpty,
tokens[serverId] == nil,
let kind = raw["kind"] as? String, ["plex", "jellyfin", "emby"].contains(kind),
let name = raw["name"] as? String,
let baseUrl = raw["baseUrl"] as? String, isHttpUrl(baseUrl),
let token = raw["token"] as? String, !token.isEmpty
else { continue }
var descriptor: [String: Any] = [
"serverId": serverId, "kind": kind, "name": name, "baseUrl": baseUrl,
]
if let userId = raw["userId"] as? String, !userId.isEmpty {
descriptor["userId"] = userId
}
descriptors.append(descriptor)
tokens[serverId] = token
}
var payload: [String: Any] = [
"schemaVersion": schemaVersion,
"ownerId": envelope.ownerId,
"updatedAt": Date().timeIntervalSince1970,
"maxItems": min(max(maxItems ?? Self.maxItems, 1), Self.maxItems),
"servers": descriptors,
]
if let sectionTitle, !sectionTitle.isEmpty {
payload["sectionTitle"] = sectionTitle
}
guard
state.commit(
envelope,
operation: {
guard JSONSerialization.isValidJSONObject(payload),
let data = try? JSONSerialization.data(withJSONObject: payload),
storeTokens(envelope.ownerId, tokens)
else { return false }
defaults.set(data, forKey: sourcesKey)
defaults.synchronize()
return true
}
)
else { return false }
notifyChange()
return true
}
private static func isHttpUrl(_ value: String) -> Bool {
guard let url = URL(string: value) else { return false }
return ["http", "https"].contains(url.scheme?.lowercased() ?? "")
&& url.host?.isEmpty == false
}
private static func materialize(
source: String,
directory: URL,
remaining: inout Int,
deadline: Date,
now: () -> Date,
loader: SystemShelfSyncEnvironment.ArtworkLoader
) -> String? {
guard remaining > 0, let url = URL(string: source), ["http", "https"].contains(url.scheme?.lowercased() ?? "")
else { return nil }
let available = min(maxImageBytes, remaining)
let timeout = min(imageTimeout, deadline.timeIntervalSince(now()))
guard timeout > 0,
let download = loader(url, available, timeout),
download.data.count <= available,
isSupportedImage(download.data, mimeType: download.mimeType)
else { return nil }
let data = download.data
let key = UUID().uuidString.lowercased().replacingOccurrences(of: "-", with: "") + ".art"
let destination = directory.appendingPathComponent(key)
do {
try data.write(
to: destination,
options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]
)
remaining -= data.count
return key
} catch {
return nil
}
}
private static func artworkSourceHash(_ source: String) -> String? {
guard let url = URL(string: source),
["http", "https"].contains(url.scheme?.lowercased() ?? "")
else { return nil }
return SHA256.hash(data: Data(source.utf8))
.map { String(format: "%02x", $0) }
.joined()
}
private static func committedArtworkByContentId(
defaults: UserDefaults,
ownerId: String,
directory: URL
) -> [String: CommittedArtwork] {
guard let data = defaults.data(forKey: cacheDataKey),
let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(payload["schemaVersion"] as? NSNumber)?.intValue == schemaVersion,
payload["ownerId"] as? String == ownerId,
let sections = payload["sections"] as? [[String: Any]]
else { return [:] }
var artwork: [String: CommittedArtwork] = [:]
for item in sections.flatMap({ $0["items"] as? [[String: Any]] ?? [] }) {
guard let contentId = item["contentId"] as? String, !contentId.isEmpty,
let key = item["artworkKey"] as? String,
validatedArtworkFile(key: key, directory: directory) != nil
else { continue }
let sourceHash: String?
if let persistedHash = item["artworkSourceHash"] as? String {
guard
persistedHash.range(
of: "^[a-f0-9]{64}$",
options: .regularExpression
) != nil
else { continue }
sourceHash = persistedHash
} else {
sourceHash = nil
}
artwork[contentId] = CommittedArtwork(sourceHash: sourceHash, key: key)
}
return artwork
}
private static func validatedArtworkFile(key: String, directory: URL) -> URL? {
guard isArtworkKey(key) else { return nil }
let canonicalDirectory = directory.standardizedFileURL.resolvingSymlinksInPath()
let candidate = directory.appendingPathComponent(key, isDirectory: false).standardizedFileURL
guard
let values = try? candidate.resourceValues(
forKeys: [.isRegularFileKey, .isSymbolicLinkKey]
),
values.isRegularFile == true,
values.isSymbolicLink != true
else { return nil }
let canonicalCandidate = candidate.resolvingSymlinksInPath()
guard canonicalCandidate.deletingLastPathComponent() == canonicalDirectory else { return nil }
return canonicalCandidate
}
private static func isArtworkKey(_ key: String) -> Bool {
key.range(of: "^[a-f0-9]{32}\\.art$", options: .regularExpression) != nil
}
private static func isSupportedImage(_ data: Data, mimeType: String) -> Bool {
let supportedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"]
guard supportedTypes.contains(mimeType),
let source = CGImageSourceCreateWithData(data as CFData, nil),
CGImageSourceGetCount(source) > 0,
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
let width = properties[kCGImagePropertyPixelWidth] as? NSNumber,
let height = properties[kCGImagePropertyPixelHeight] as? NSNumber
else { return false }
let w = width.int64Value
let h = height.int64Value
return (1...4096).contains(w) && (1...4096).contains(h) && w * h <= 16_777_216
}
static func committedArtworkKeys(defaults: UserDefaults, ownerId: String) -> Set<String> {
guard let data = defaults.data(forKey: cacheDataKey),
let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(payload["schemaVersion"] as? NSNumber)?.intValue == schemaVersion,
payload["ownerId"] as? String == ownerId,
let sections = payload["sections"] as? [[String: Any]]
else { return [] }
return Set(
sections.flatMap { section in
(section["items"] as? [[String: Any]])?.compactMap { item in
guard let key = item["artworkKey"] as? String, isArtworkKey(key) else { return nil }
return key
} ?? []
})
}
private static func committedArtworkFiles(defaults: UserDefaults, root: URL) -> Set<URL> {
guard let data = defaults.data(forKey: cacheDataKey),
let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(payload["schemaVersion"] as? NSNumber)?.intValue == schemaVersion,
let ownerId = payload["ownerId"] as? String,
!ownerId.isEmpty
else { return [] }
let ownerDirectory = root.appendingPathComponent(ownerHash(ownerId), isDirectory: true)
return Set(
committedArtworkKeys(defaults: defaults, ownerId: ownerId).map {
ownerDirectory.appendingPathComponent($0, isDirectory: false)
}
)
}
private static func writePayload(_ payload: [String: Any], defaults: UserDefaults) -> Bool {
guard JSONSerialization.isValidJSONObject(payload),
let data = try? JSONSerialization.data(withJSONObject: payload)
else { return false }
defaults.set(data, forKey: cacheDataKey)
defaults.synchronize()
return true
}
private static func clearCache(envelope: SystemShelfMutationEnvelope) -> Bool {
guard let defaults = sharedDefaults else { return false }
return clearCache(
envelope: envelope,
state: &mutationState,
defaults: defaults,
artworkRoot: artworkRoot,
clearSourceTokens: {
deleteAllSourceTokens()
},
notifyChange: {
TVTopShelfContentProvider.topShelfContentDidChange()
}
)
}
static func clearCache(
envelope: SystemShelfMutationEnvelope,
state: inout SystemShelfMutationState,
defaults: UserDefaults,
artworkRoot: URL?,
clearSourceTokens: () -> Void = {},
notifyChange: () -> Void
) -> Bool {
guard
state.commit(
envelope,
clearing: true,
operation: {
defaults.removeObject(forKey: cacheDataKey)
defaults.removeObject(forKey: sourcesKey)
defaults.synchronize()
return true
}
)
else { return false }
state.cancelAllPruning()
clearSourceTokens()
if let artworkRoot {
try? FileManager.default.removeItem(at: artworkRoot)
}
notifyChange()
return true
}
private static func removeItem(
envelope: SystemShelfMutationEnvelope,
contentId: String
) -> Bool {
guard let defaults = sharedDefaults, let root = artworkRoot else { return false }
return removeItem(
envelope: envelope,
contentId: contentId,
state: &mutationState,
defaults: defaults,
artworkRoot: root,
schedulePrune: schedulePrune,
notifyChange: {
TVTopShelfContentProvider.topShelfContentDidChange()
}
)
}
static func removeItem(
envelope: SystemShelfMutationEnvelope,
contentId: String,
state: inout SystemShelfMutationState,
defaults: UserDefaults,
artworkRoot root: URL,
schedulePrune: ([SystemShelfPruneBatch]) -> Void,
notifyChange: () -> Void
) -> Bool {
guard state.accepts(envelope),
let data = defaults.data(forKey: cacheDataKey),
var payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(payload["schemaVersion"] as? NSNumber)?.intValue == schemaVersion,
payload["ownerId"] as? String == envelope.ownerId,
let sections = payload["sections"] as? [[String: Any]]
else { return false }
let previouslyCommittedArtwork = committedArtworkFiles(defaults: defaults, root: root)
var removed = false
payload["sections"] = sections.map { section -> [String: Any] in
var next = section
if let items = section["items"] as? [[String: Any]] {
let filtered = items.filter { $0["contentId"] as? String != contentId }
removed = removed || filtered.count != items.count
next["items"] = filtered
}
return next
}
guard removed,
state.commit(
envelope,
operation: {
writePayload(payload, defaults: defaults)
}
)
else { return false }
let committedArtwork = committedArtworkFiles(defaults: defaults, root: root)
state.cancelPruning(keeping: committedArtwork)
schedulePrune(
state.preparePruning(
removing: previouslyCommittedArtwork.subtracting(committedArtwork),
after: staleArtworkGracePeriod
)
)
notifyChange()
return true
}
private static func scrubLegacyPayload() {
guard let defaults = sharedDefaults else { return }
scrubLegacySources(defaults: defaults)
var validOwner: String?
if let data = defaults.data(forKey: cacheDataKey),
let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(payload["schemaVersion"] as? NSNumber)?.intValue == schemaVersion,
let owner = payload["ownerId"] as? String,
!owner.isEmpty
{
validOwner = owner
}
if let validOwner {
if let root = artworkRoot {
pruneStaging(root: root)
let keeping = committedArtworkFiles(defaults: defaults, root: root)
mutationState.cancelPruning(keeping: keeping)
if mutationState.adoptPersistedOwner(validOwner) != nil {
let recovered = Set(
recoverableArtwork(
root: root,
keeping: keeping,
now: Date()
).keys
)
schedulePrune(
mutationState.preparePruning(
removing: recovered,
after: staleArtworkGracePeriod
)
)
}
}
return
}
mutationState.cancelAllPruning()
defaults.removeObject(forKey: cacheDataKey)
defaults.synchronize()
if let root = artworkRoot { try? FileManager.default.removeItem(at: root) }
TVTopShelfContentProvider.topShelfContentDidChange()
}
private static func ownerHash(_ owner: String) -> String {
SHA256.hash(data: Data(owner.utf8)).map { String(format: "%02x", $0) }.joined()
}
/// Removes stale live-fetch sources (and their keychain tokens) whose
/// persisted payload no longer matches the current schema or lost its
/// owner; valid sources are left alone even when the item cache is empty.
private static func scrubLegacySources(defaults: UserDefaults) {
guard let data = defaults.data(forKey: sourcesKey) else { return }
if let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(payload["schemaVersion"] as? NSNumber)?.intValue == schemaVersion,
let owner = payload["ownerId"] as? String,
!owner.isEmpty
{
return
}
defaults.removeObject(forKey: sourcesKey)
defaults.synchronize()
deleteAllSourceTokens()
}
private static func storeSourceTokens(ownerId: String, tokens: [String: String]) -> Bool {
guard JSONSerialization.isValidJSONObject(tokens),
let data = try? JSONSerialization.data(withJSONObject: tokens)
else { return false }
var attributes: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: tokenKeychainService,
kSecAttrAccessGroup as String: appGroupIdentifier,
kSecAttrAccount as String: ownerHash(ownerId),
]
SecItemDelete(attributes as CFDictionary)
attributes[kSecValueData as String] = data
attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
return SecItemAdd(attributes as CFDictionary, nil) == errSecSuccess
}
private static func deleteAllSourceTokens() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: tokenKeychainService,
kSecAttrAccessGroup as String: appGroupIdentifier,
]
SecItemDelete(query as CFDictionary)
}
static func pruneRejectedSync(ownerDirectory: URL, removing: Set<String>) {
for key in removing {
let file = ownerDirectory.appendingPathComponent(key, isDirectory: false)
try? FileManager.default.removeItem(at: file)
}
}
private static func schedulePrune(_ batches: [SystemShelfPruneBatch]) {
for batch in batches {
mutationQueue.asyncAfter(deadline: .now() + batch.delay) {
executePrune(batch)
}
}
}
private static func executePrune(_ batch: SystemShelfPruneBatch) {
let candidates = mutationState.claimPruning(batch)
guard !candidates.isEmpty, let defaults = sharedDefaults, let root = artworkRoot else {
return
}
pruneUnreferenced(candidates, defaults: defaults, root: root)
}
static func recoverableArtwork(
root: URL,
keeping: Set<URL>,
now: Date
) -> [URL: Date] {
guard
let enumerator = FileManager.default.enumerator(
at: root,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]
)
else { return [:] }
var recovered: [URL: Date] = [:]
let deadline = now.addingTimeInterval(staleArtworkGracePeriod)
for case let file as URL in enumerator
where file.pathExtension == "art" && !keeping.contains(file) {
// Supersession may have happened immediately before termination, so
// creation/mtime cannot safely shorten recovery grace.
recovered[file] = deadline
}
return recovered
}
static func pruneUnreferenced(
_ candidates: Set<URL>,
defaults: UserDefaults,
root: URL
) {
let lexicalRoot = root.standardizedFileURL
let lexicalRootPrefix =
lexicalRoot.path.hasSuffix("/")
? lexicalRoot.path
: lexicalRoot.path + "/"
let canonicalRoot = lexicalRoot.resolvingSymlinksInPath()
let canonicalRootPrefix =
canonicalRoot.path.hasSuffix("/")
? canonicalRoot.path
: canonicalRoot.path + "/"
let committed = Set(
committedArtworkFiles(defaults: defaults, root: root)
.map(\.standardizedFileURL)
)
let removable = Set(
candidates.lazy
.map(\.standardizedFileURL)
.filter {
let canonicalCandidate = $0.resolvingSymlinksInPath()
return $0.pathExtension == "art"
&& $0.path.hasPrefix(lexicalRootPrefix)
&& canonicalCandidate.path.hasPrefix(canonicalRootPrefix)
&& !committed.contains($0)
}
)
for file in removable {
try? FileManager.default.removeItem(at: file)
}
}
private static func pruneStaging(root: URL) {
guard let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil) else { return }
for case let file as URL in enumerator where file.lastPathComponent.hasPrefix(".") {
try? FileManager.default.removeItem(at: file)
}
}
private static func sanitizedJSONValue(_ value: Any) -> Any? {
if value is NSNull { return nil }
if let value = value as? String { return value }
if let value = value as? NSNumber {
if CFGetTypeID(value) == CFBooleanGetTypeID() { return value.boolValue }
return value.doubleValue.isFinite ? value : nil
}
if let value = value as? Bool { return value }
if let value = value as? Int { return value }
if let value = value as? Int64 { return value }
if let value = value as? Double { return value.isFinite ? value : nil }
return nil
}
}
#endif