179 lines
8.1 KiB
Swift
179 lines
8.1 KiB
Swift
import Foundation
|
|
import TOMLDecoder
|
|
|
|
public struct PlayerConfiguration: Equatable, Sendable {
|
|
public var showBufferInfo: Bool
|
|
public var automaticQuality: Bool
|
|
public var snapToEdges: Bool
|
|
public var keys: KeyBindings
|
|
|
|
public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, snapToEdges: Bool = true,
|
|
keys: KeyBindings = KeyBindings()) {
|
|
self.showBufferInfo = showBufferInfo
|
|
self.automaticQuality = automaticQuality
|
|
self.snapToEdges = snapToEdges
|
|
self.keys = keys
|
|
}
|
|
|
|
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.automaticQuality ?? false,
|
|
snapToEdges: values.ui?.compactSnapToEdges ?? true,
|
|
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 automaticQuality: Bool?
|
|
let ui: UIConfiguration?
|
|
let keys: KeysConfiguration?
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: SettingKey.self)
|
|
try validate(container, allowed: ["automatic_quality", "ui", "keys"])
|
|
automaticQuality = try container.decodeIfPresent(Bool.self, forKey: SettingKey("automatic_quality"))
|
|
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 UIConfiguration {
|
|
let bufferInfo: Bool?
|
|
let compactSnapToEdges: Bool?
|
|
|
|
init(container: KeyedDecodingContainer<SettingKey>) throws {
|
|
try validate(container, allowed: ["buffer_info", "compact_snap_to_edges"])
|
|
bufferInfo = try container.decodeIfPresent(Bool.self, forKey: SettingKey("buffer_info"))
|
|
compactSnapToEdges = try container.decodeIfPresent(Bool.self, forKey: SettingKey("compact_snap_to_edges"))
|
|
}
|
|
}
|
|
|
|
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 }
|
|
}
|
|
}
|