feat: load optional XDG TOML configuration with session overrides

This commit is contained in:
2026-10-11 14:41:11 +02:00
parent 0dce073287
commit 722a225da5
16 changed files with 461 additions and 89 deletions
+2 -6
View File
@@ -9,7 +9,6 @@ final class CompactWindowController: NSObject {
var snappingEnabled: Bool {
didSet {
guard snappingEnabled != oldValue else { return }
defaults.set(snappingEnabled, forKey: "compactSnappingEnabled")
onChange?()
}
}
@@ -45,7 +44,6 @@ final class CompactWindowController: NSObject {
private enum FullScreenTransition { case entering, exiting }
private let defaults: UserDefaults
private weak var window: NSWindow?
private var snapshot: Snapshot?
private var fullScreenTransition: FullScreenTransition?
@@ -57,10 +55,8 @@ final class CompactWindowController: NSObject {
private var videoAspect = 16.0 / 9.0
private var fittedAspect: Double?
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
defaults.register(defaults: ["compactSnappingEnabled": true])
snappingEnabled = defaults.bool(forKey: "compactSnappingEnabled")
init(snappingEnabled: Bool = true) {
self.snappingEnabled = snappingEnabled
super.init()
}
+28 -11
View File
@@ -1,9 +1,11 @@
import AppKit
import SwiftUI
import TerebiConfiguration
@MainActor
final class PlayerModel: ObservableObject {
static let shared = PlayerModel()
static var startupConfiguration = PlayerConfiguration()
static let shared = PlayerModel(configuration: startupConfiguration)
@Published private(set) var state = PlayerState()
@Published private(set) var fileName: String?
@@ -24,7 +26,7 @@ final class PlayerModel: ObservableObject {
@Published private(set) var controlsVisible = true
@Published private(set) var isFullScreen = false
@Published private(set) var isOnTop = false
let compactWindow = CompactWindowController()
let compactWindow: CompactWindowController
var isCompact: Bool { compactWindow.isCompact }
var canToggleCompact: Bool {
isCompact || compactWindow.isTransitioning ||
@@ -51,7 +53,6 @@ final class PlayerModel: ObservableObject {
private var youtubeTask: Task<Void, Never>?
private var youtubeRequest: UUID?
private var resolvingAutomaticQuality = false
private let defaults: UserDefaults
private let qualityClock: () -> Double
private var automaticQuality = AutomaticQuality()
private var qualityNow: Double { qualityClock() }
@@ -62,11 +63,12 @@ final class PlayerModel: ObservableObject {
private var lastSavedPosition: Double?
private var systemPlayback: SystemPlaybackControls?
init(defaults: UserDefaults = .standard, qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
self.defaults = defaults
init(defaults: UserDefaults = .standard, configuration: PlayerConfiguration = PlayerConfiguration(),
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
self.qualityClock = qualityClock
automaticYouTubeQuality = defaults.bool(forKey: "automaticYouTubeQuality")
showsBufferInfo = defaults.bool(forKey: "showsBufferInfo")
automaticYouTubeQuality = configuration.automaticQuality
showsBufferInfo = configuration.showBufferInfo
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges)
playbackProgress = PlaybackProgress(defaults: defaults)
let player = player_create()
handle = player
@@ -162,13 +164,11 @@ final class PlayerModel: ObservableObject {
func setShowsBufferInfo(_ enabled: Bool) {
showsBufferInfo = enabled
defaults.set(enabled, forKey: "showsBufferInfo")
revealControls()
}
func setAutomaticYouTubeQuality(_ enabled: Bool) {
automaticYouTubeQuality = enabled
defaults.set(enabled, forKey: "automaticYouTubeQuality")
automaticQuality = AutomaticQuality()
if !enabled && resolvingAutomaticQuality { cancelYouTubeResolution() }
revealControls()
@@ -1370,7 +1370,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
NSAppleEventManager.shared().setEventHandler(self, andSelector: #selector(openDocuments(_:withReplyEvent:)),
forEventClass: AEEventClass(kCoreEventClass), andEventID: AEEventID(kAEOpenDocuments))
let arguments = Array(CommandLine.arguments.dropFirst())
if arguments.count == 2, arguments[0] == "--open-media", let url = URL(string: arguments[1]) {
if let index = arguments.firstIndex(of: "--open-media"), index + 1 < arguments.count,
let url = URL(string: arguments[index + 1]) {
PlayerModel.shared.open(url)
}
}
@@ -1448,8 +1449,24 @@ struct TerebiApp: App {
#if !PLAYER_CHECK
@_cdecl("terebi_app_main")
#endif
@MainActor
public func terebiAppMain() {
do {
PlayerModel.startupConfiguration = try PlayerConfiguration.loadForApplication(
arguments: Array(CommandLine.arguments.dropFirst()),
environment: ProcessInfo.processInfo.environment,
homeDirectory: FileManager.default.homeDirectoryForCurrentUser)
} catch {
FileHandle.standardError.write(Data("terebi: \(error.localizedDescription)\n".utf8))
NSApplication.shared.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = "Could not load terebi configuration"
alert.informativeText = error.localizedDescription
alert.runModal()
exit(78)
}
TerebiApp.main()
}
#endif
+21 -10
View File
@@ -2,12 +2,12 @@ import Foundation
enum PlayerCommand {
case help
case launch(URL?, newInstance: Bool)
case launch(URL?, newInstance: Bool, noConfig: Bool)
static let usage = """
Usage: terebi [--new-instance] [FILE | YOUTUBE_URL]
terebi [--new-instance] --url YOUTUBE_URL
terebi [--new-instance] -- FILE
Usage: terebi [--new-instance] [--no-config] [FILE | YOUTUBE_URL]
terebi [--new-instance] [--no-config] --url YOUTUBE_URL
terebi [--new-instance] [--no-config] -- FILE
terebi --help
Open one local media file or YouTube video and start playback, resuming saved
@@ -15,6 +15,7 @@ enum PlayerCommand {
Quote paths and URLs containing spaces or shell metacharacters.
--new-instance Launch a separate player, leaving existing playback untouched.
--no-config Use built-in settings instead of config.toml on a fresh launch.
--url URL Open a YouTube video URL.
-- Treat the following argument as a filename, even with a leading '-'.
-h, --help Show this help without launching the player.
@@ -26,13 +27,23 @@ enum PlayerCommand {
static func parse(_ arguments: [String], directory: URL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)) throws -> PlayerCommand {
var inputs = arguments
let newInstance = inputs.first == "--new-instance"
if newInstance { inputs.removeFirst() }
if inputs.isEmpty { return .launch(nil, newInstance: newInstance) }
var newInstance = false
var noConfig = false
while let option = inputs.first, option == "--new-instance" || option == "--no-config" {
if option == "--new-instance" {
guard !newInstance else { throw ArgumentError("--new-instance may only be supplied once.") }
newInstance = true
} else {
guard !noConfig else { throw ArgumentError("--no-config may only be supplied once.") }
noConfig = true
}
inputs.removeFirst()
}
if inputs.isEmpty { return .launch(nil, newInstance: newInstance, noConfig: noConfig) }
if inputs == ["--help"] || inputs == ["-h"] { return .help }
if inputs.first == "--url" {
guard inputs.count == 2 else { throw ArgumentError("--url requires exactly one YouTube URL.") }
return .launch(try YouTubeResolver.validateURL(inputs[1]), newInstance: newInstance)
return .launch(try YouTubeResolver.validateURL(inputs[1]), newInstance: newInstance, noConfig: noConfig)
}
let filenameOnly = inputs.first == "--"
if filenameOnly {
@@ -52,7 +63,7 @@ enum PlayerCommand {
}
file = url
} else {
return .launch(try YouTubeResolver.validateURL(input), newInstance: newInstance)
return .launch(try YouTubeResolver.validateURL(input), newInstance: newInstance, noConfig: noConfig)
}
} else {
let base = directory.hasDirectoryPath ? directory : URL(fileURLWithPath: directory.path, isDirectory: true)
@@ -62,7 +73,7 @@ enum PlayerCommand {
FileManager.default.isReadableFile(atPath: file.path) else {
throw ArgumentError("File does not exist or is not a readable regular file.")
}
return .launch(file.standardizedFileURL, newInstance: newInstance)
return .launch(file.standardizedFileURL, newInstance: newInstance, noConfig: noConfig)
}
private struct ArgumentError: LocalizedError {
+14 -2
View File
@@ -1,5 +1,6 @@
import AppKit
import Darwin
import TerebiConfiguration
@main
@MainActor
@@ -14,7 +15,16 @@ enum TerebiCLI {
switch command {
case .help:
print(PlayerCommand.usage)
case .launch(let url, let newInstance):
case .launch(let url, let newInstance, let noConfig):
let configURL = PlayerConfiguration.fileURL(environment: ProcessInfo.processInfo.environment,
homeDirectory: FileManager.default.homeDirectoryForCurrentUser)
if !noConfig {
do {
_ = try PlayerConfiguration.load(from: configURL)
} catch {
fail(error.localizedDescription, code: 78)
}
}
guard let executable = Bundle.main.executableURL else {
fail("Could not locate the CLI executable.", code: 69)
}
@@ -25,8 +35,10 @@ enum TerebiCLI {
}
let configuration = NSWorkspace.OpenConfiguration()
configuration.createsNewApplicationInstance = newInstance
// LaunchServices does not inherit the invoking shell's XDG environment.
configuration.arguments = noConfig ? ["--no-config"] : ["--config-file", configURL.absoluteString]
if newInstance, let url {
configuration.arguments = ["--open-media", url.absoluteString]
configuration.arguments += ["--open-media", url.absoluteString]
}
do {
// Launch an application, not a document, so SwiftUI creates its singleton window.
@@ -0,0 +1,86 @@
import Foundation
import TOMLDecoder
public struct PlayerConfiguration: Equatable, Sendable {
public var showBufferInfo: Bool
public var automaticQuality: Bool
public var snapToEdges: Bool
public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, snapToEdges: Bool = true) {
self.showBufferInfo = showBufferInfo
self.automaticQuality = automaticQuality
self.snapToEdges = snapToEdges
}
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([String: Bool].self, from: data)
for key in values.keys {
switch key {
case "show_buffer_info", "automatic_quality", "snap_to_edges": break
default: throw ConfigurationError(message: "Unknown setting '\(key)'.")
}
}
var configuration = Self()
configuration.showBufferInfo = values["show_buffer_info"] ?? configuration.showBufferInfo
configuration.automaticQuality = values["automatic_quality"] ?? configuration.automaticQuality
configuration.snapToEdges = values["snap_to_edges"] ?? configuration.snapToEdges
return configuration
} catch {
throw ConfigurationError(message: "\(url.path): \(diagnostic(for: error))")
}
}
private static func diagnostic(for error: Error) -> String {
let context: DecodingError.Context
switch error {
case DecodingError.dataCorrupted(let value), DecodingError.typeMismatch(_, let value),
DecodingError.valueNotFound(_, let value), DecodingError.keyNotFound(_, let value): context = value
case let error as ConfigurationError: return error.message
default: return String(describing: error)
}
if let underlying = context.underlyingError { return String(describing: underlying) }
let path = context.codingPath.map(\.stringValue).joined(separator: ".")
return path.isEmpty ? context.debugDescription : "\(path): \(context.debugDescription)"
}
private struct ConfigurationError: LocalizedError {
let message: String
var errorDescription: String? { message }
}
}