Files
plezy/tvos/TopShelfExtension/ShelfSources.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

156 lines
5.3 KiB
Swift

import CryptoKit
import Foundation
import Security
// Live-fetch source descriptors persisted by SystemShelfPlugin.updateSources.
// This file must not import Flutter: it compiles into both TopShelfExtension
// and RunnerTests.
enum ShelfServerKind: String, Decodable {
case plex
case jellyfin
case emby
}
/// Token-free server descriptor as persisted in app-group defaults.
struct ShelfServerDescriptor: Decodable {
let serverId: String
let kind: ShelfServerKind
let name: String
let baseUrl: String
let userId: String?
}
/// A descriptor joined with its keychain token. Never persisted.
struct ShelfServerSource {
let descriptor: ShelfServerDescriptor
let token: String
}
extension ShelfServerSource: CustomStringConvertible, CustomDebugStringConvertible {
// The token must never leak through interpolation or reflection dumps.
var description: String {
"ShelfServerSource(serverId: \(descriptor.serverId), kind: \(descriptor.kind.rawValue))"
}
var debugDescription: String { description }
}
struct ShelfSourcesPayload: Decodable {
let schemaVersion: Int
let ownerId: String
let maxItems: Int?
let sectionTitle: String?
let servers: [ShelfServerDescriptor]
private enum CodingKeys: String, CodingKey {
case schemaVersion
case ownerId
case maxItems
case sectionTitle
case servers
}
/// One malformed server entry (e.g. an unknown kind written by a future
/// schema) must not sink the whole shelf; siblings decode best-effort.
private struct LossyServer: Decodable {
let descriptor: ShelfServerDescriptor?
init(from decoder: Decoder) throws {
descriptor = try? ShelfServerDescriptor(from: decoder)
}
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
schemaVersion = try container.decode(Int.self, forKey: .schemaVersion)
ownerId = try container.decode(String.self, forKey: .ownerId)
maxItems = try container.decodeIfPresent(Int.self, forKey: .maxItems)
sectionTitle = try container.decodeIfPresent(String.self, forKey: .sectionTitle)
servers = (try container.decodeIfPresent([LossyServer].self, forKey: .servers) ?? [])
.compactMap(\.descriptor)
}
}
enum ShelfSourceStore {
/// Must stay in lockstep with TopShelfShared.schemaVersion and
/// SystemShelfPlugin.schemaVersion (duplicated here so this file compiles
/// into RunnerTests without TopShelfProvider.swift).
static let schemaVersion = 3
static let appGroupIdentifier = "group.com.edde746.plezy"
static let sourcesKey = "PlezySystemShelfSources"
static let tokenKeychainService = "com.edde746.plezy.systemshelf.tokens"
static let defaultMaxItems = 20
static let maxItemsLimit = 20
struct Sources {
let ownerId: String
let maxItems: Int
let sectionTitle: String?
let servers: [ShelfServerSource]
}
static func load() -> Sources? {
guard let defaults = UserDefaults(suiteName: appGroupIdentifier),
let data = defaults.data(forKey: sourcesKey),
let payload = decodePayload(data)
else { return nil }
return join(payload: payload, tokensByServerId: loadTokens(ownerId: payload.ownerId))
}
/// Pure resolution used by tests: validates the persisted payload and
/// attaches externally supplied tokens.
static func resolve(payloadData: Data, tokensByServerId: [String: String]) -> Sources? {
guard let payload = decodePayload(payloadData) else { return nil }
return join(payload: payload, tokensByServerId: tokensByServerId)
}
static func ownerAccount(_ ownerId: String) -> String {
SHA256.hash(data: Data(ownerId.utf8)).map { String(format: "%02x", $0) }.joined()
}
private static func decodePayload(_ data: Data) -> ShelfSourcesPayload? {
guard let payload = try? JSONDecoder().decode(ShelfSourcesPayload.self, from: data),
payload.schemaVersion == schemaVersion,
!payload.ownerId.isEmpty
else { return nil }
return payload
}
private static func join(
payload: ShelfSourcesPayload,
tokensByServerId: [String: String]
) -> Sources? {
let servers = payload.servers.compactMap { descriptor -> ShelfServerSource? in
guard !descriptor.serverId.isEmpty, !descriptor.baseUrl.isEmpty,
let token = tokensByServerId[descriptor.serverId], !token.isEmpty
else { return nil }
return ShelfServerSource(descriptor: descriptor, token: token)
}
guard !servers.isEmpty else { return nil }
let maxItems = min(max(payload.maxItems ?? defaultMaxItems, 1), maxItemsLimit)
return Sources(
ownerId: payload.ownerId,
maxItems: maxItems,
sectionTitle: payload.sectionTitle,
servers: servers
)
}
private static func loadTokens(ownerId: String) -> [String: String] {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: tokenKeychainService,
kSecAttrAccessGroup as String: appGroupIdentifier,
kSecAttrAccount as String: ownerAccount(ownerId),
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data,
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return [:] }
return object.compactMapValues { $0 as? String }
}
}