Files
terebi/Sources/Configuration/PlayerConfiguration.swift
T

222 lines
10 KiB
Swift

import Foundation
import TOMLDecoder
public enum CompactTitleProgress: String, Decodable, Sendable {
case time, percentage, none
}
public struct PlayerConfiguration: Equatable, Sendable {
public var showBufferInfo: Bool
public var automaticQuality: Bool
public var snapToEdges: Bool
public var compactTitleProgress: CompactTitleProgress
public var keys: KeyBindings
public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, snapToEdges: Bool = true,
compactTitleProgress: CompactTitleProgress = .time, keys: KeyBindings = KeyBindings()) {
self.showBufferInfo = showBufferInfo
self.automaticQuality = automaticQuality
self.snapToEdges = snapToEdges
self.compactTitleProgress = compactTitleProgress
self.keys = keys
}
public static var defaultTOML: String {
let defaults = Self()
let encoder = JSONEncoder()
encoder.outputFormatting = .withoutEscapingSlashes
var lines = [
"[playback]",
"automatic_quality = \(defaults.automaticQuality)",
"", "[ui]",
"buffer_info = \(defaults.showBufferInfo)",
"compact_snap_to_edges = \(defaults.snapToEdges)",
"compact_title_progress = \"\(defaults.compactTitleProgress.rawValue)\"",
"", "[keys]"
]
for (name, action) in PlayerAction.namedActions {
let chords = defaults.keys.shortcuts(for: action).map(\.description)
// JSON string arrays are valid TOML arrays; preserve escaping for printable keys.
let value = String(decoding: try! encoder.encode(chords), as: UTF8.self)
lines.append("\(name) = \(value)")
}
return lines.joined(separator: "\n") + "\n"
}
public static func fileURL(environment: [String: String], homeDirectory: URL) -> URL {
let directory: URL
if let path = environment["XDG_CONFIG_HOME"], path.hasPrefix("/") {
directory = URL(fileURLWithPath: path, isDirectory: true)
} else {
directory = homeDirectory.appendingPathComponent(".config", isDirectory: true)
}
return directory.appendingPathComponent("terebi/config.toml")
}
public static func loadForApplication(arguments: [String], environment: [String: String],
homeDirectory: URL) throws -> Self {
if arguments.contains("--no-config") { return Self() }
if let index = arguments.firstIndex(of: "--config-file") {
guard index + 1 < arguments.count, let url = URL(string: arguments[index + 1]), url.isFileURL,
url.host == nil || url.host == "" || url.host == "localhost" else {
throw ConfigurationError(message: "--config-file requires a local file URL.")
}
return try load(from: url)
}
return try load(from: fileURL(environment: environment, homeDirectory: homeDirectory))
}
public static func load(from url: URL) throws -> Self {
let data: Data
do {
data = try Data(contentsOf: url)
} catch {
let failure = error as NSError
let underlying = failure.userInfo[NSUnderlyingErrorKey] as? NSError
if failure.domain == NSCocoaErrorDomain, failure.code == CocoaError.fileReadNoSuchFile.rawValue,
underlying?.domain == NSPOSIXErrorDomain, underlying?.code == Int(ENOENT) {
return Self()
}
throw ConfigurationError(message: "\(url.path): \(error.localizedDescription)")
}
do {
let values = try TOMLDecoder(isLenient: false).decode(FileConfiguration.self, from: data)
return Self(showBufferInfo: values.ui?.bufferInfo ?? false,
automaticQuality: values.playback?.automaticQuality ?? false,
snapToEdges: values.ui?.compactSnapToEdges ?? true,
compactTitleProgress: values.ui?.compactTitleProgress ?? .time,
keys: try KeyBindings(overrides: values.keys?.overrides ?? [:]))
} catch {
throw ConfigurationError(message: "\(url.path): \(diagnostic(for: error, data: data))")
}
}
private static func diagnostic(for error: Error, data: Data) -> String {
let context: DecodingError.Context
var missingKey: String?
switch error {
case DecodingError.dataCorrupted(let value), DecodingError.typeMismatch(_, let value),
DecodingError.valueNotFound(_, let value): context = value
case DecodingError.keyNotFound(let key, let value):
context = value
missingKey = key.stringValue
case let error as ConfigurationError: return error.message
case let error as BindingError: return error.message
default: return String(describing: error)
}
var path = context.codingPath.map(\.stringValue)
if let missingKey, path.last != missingKey { path.append(missingKey) }
let detail = context.underlyingError.map { String(describing: $0) } ?? context.debugDescription
if !path.isEmpty { return "\(path.joined(separator: ".")): \(detail)" }
// TOML syntax failures supply a line number, but not always the setting.
if let text = String(data: data, encoding: .utf8),
let marker = detail.range(of: "(Line "),
let end = detail[marker.upperBound...].firstIndex(of: ")"),
let line = Int(detail[marker.upperBound..<end]), line > 0 {
let lines = text.components(separatedBy: "\n")
let index = min(line - 1, lines.count - 1)
let source = lines[...index].last(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty })
if let source { return "\(detail) [\(source)]" }
}
return detail
}
// An enum CodingKey would silently hide unknown TOML keys from allKeys.
private struct SettingKey: CodingKey {
let stringValue: String
var intValue: Int? { nil }
init(_ value: String) { stringValue = value }
init?(stringValue: String) { self.init(stringValue) }
init?(intValue: Int) { return nil }
}
private static func validate(_ container: KeyedDecodingContainer<SettingKey>, allowed: Set<String>) throws {
for key in container.allKeys.sorted(by: { $0.stringValue < $1.stringValue }) {
guard allowed.contains(key.stringValue) else {
let path = (container.codingPath + [key]).map(\.stringValue).joined(separator: ".")
throw ConfigurationError(message: "Unknown setting '\(path)'.")
}
}
}
private struct FileConfiguration: Decodable {
let playback: PlaybackConfiguration?
let ui: UIConfiguration?
let keys: KeysConfiguration?
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: SettingKey.self)
try validate(container, allowed: ["playback", "ui", "keys"])
if container.contains(SettingKey("playback")) {
playback = try PlaybackConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("playback")))
} else { playback = nil }
if container.contains(SettingKey("ui")) {
ui = try UIConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("ui")))
} else { ui = nil }
if container.contains(SettingKey("keys")) {
keys = try KeysConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("keys")))
} else { keys = nil }
}
}
private struct PlaybackConfiguration {
let automaticQuality: Bool?
init(container: KeyedDecodingContainer<SettingKey>) throws {
try validate(container, allowed: ["automatic_quality"])
automaticQuality = try container.decodeIfPresent(Bool.self, forKey: SettingKey("automatic_quality"))
}
}
private struct UIConfiguration {
let bufferInfo: Bool?
let compactSnapToEdges: Bool?
let compactTitleProgress: CompactTitleProgress?
init(container: KeyedDecodingContainer<SettingKey>) throws {
try validate(container, allowed: ["buffer_info", "compact_snap_to_edges", "compact_title_progress"])
bufferInfo = try container.decodeIfPresent(Bool.self, forKey: SettingKey("buffer_info"))
compactSnapToEdges = try container.decodeIfPresent(Bool.self, forKey: SettingKey("compact_snap_to_edges"))
compactTitleProgress = try container.decodeIfPresent(CompactTitleProgress.self,
forKey: SettingKey("compact_title_progress"))
}
}
private struct KeysConfiguration {
let overrides: [String: [String]]
init(container: KeyedDecodingContainer<SettingKey>) throws {
var values: [String: [String]] = [:]
for key in container.allKeys.sorted(by: { $0.stringValue < $1.stringValue }) {
guard PlayerAction(name: key.stringValue) != nil else {
throw ConfigurationError(message: "Unknown action 'keys.\(key.stringValue)'.")
}
values[key.stringValue] = try container.decode(ShortcutList.self, forKey: key).chords
}
overrides = values
}
}
private struct ShortcutList: Decodable {
let chords: [String]
init(from decoder: Decoder) throws {
if let chord = try? String(from: decoder) {
chords = [chord]
} else {
do { chords = try [String](from: decoder) }
catch {
throw DecodingError.typeMismatch([String].self,
DecodingError.Context(codingPath: decoder.codingPath,
debugDescription: "Expected a shortcut string or an array of shortcut strings."))
}
}
}
}
private struct ConfigurationError: LocalizedError {
let message: String
var errorDescription: String? { message }
}
}