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 miruroVideoProvider: String public var miruroCaptionProvider: String public var keys: KeyBindings public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, snapToEdges: Bool = true, compactTitleProgress: CompactTitleProgress = .time, miruroVideoProvider: String = "", miruroCaptionProvider: String = "", keys: KeyBindings = KeyBindings()) { self.showBufferInfo = showBufferInfo self.automaticQuality = automaticQuality self.snapToEdges = snapToEdges self.compactTitleProgress = compactTitleProgress self.miruroVideoProvider = miruroVideoProvider self.miruroCaptionProvider = miruroCaptionProvider self.keys = keys } public static var defaultTOML: String { let defaults = Self() let encoder = JSONEncoder() encoder.outputFormatting = .withoutEscapingSlashes var lines = [ "[playback]", "automatic_quality = \(defaults.automaticQuality)", "", "[miruro]", "video_provider = \"\(defaults.miruroVideoProvider)\"", "caption_provider = \"\(defaults.miruroCaptionProvider)\"", "", "[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, miruroVideoProvider: values.miruro?.videoProvider ?? "", miruroCaptionProvider: values.miruro?.captionProvider ?? "", 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.. 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, allowed: Set) 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? let miruro: MiruroConfiguration? init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: SettingKey.self) try validate(container, allowed: ["playback", "ui", "keys", "miruro"]) 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 } if container.contains(SettingKey("miruro")) { miruro = try MiruroConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("miruro"))) } else { miruro = nil } } } private struct PlaybackConfiguration { let automaticQuality: Bool? init(container: KeyedDecodingContainer) throws { try validate(container, allowed: ["automatic_quality"]) automaticQuality = try container.decodeIfPresent(Bool.self, forKey: SettingKey("automatic_quality")) } } private struct MiruroConfiguration { let videoProvider: String? let captionProvider: String? init(container: KeyedDecodingContainer) throws { try validate(container, allowed: ["video_provider", "caption_provider"]) videoProvider = try Self.provider("video_provider", in: container) captionProvider = try Self.provider("caption_provider", in: container) } private static func provider(_ name: String, in container: KeyedDecodingContainer) throws -> String? { let value = try container.decodeIfPresent(String.self, forKey: SettingKey(name)) if let value { guard value.utf8.count <= 128, value.utf8.allSatisfy({ (65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 95 || $0 == 45 }) else { throw ConfigurationError(message: "miruro.\(name): Expected an empty string (Auto) or up to 128 ASCII letters, digits, underscores or hyphens.") } } return value } } private struct UIConfiguration { let bufferInfo: Bool? let compactSnapToEdges: Bool? let compactTitleProgress: CompactTitleProgress? init(container: KeyedDecodingContainer) 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) 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 } } }