From 722a225da5a062f6d62686d149596a75e3c2171c Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 11 Oct 2026 14:41:11 +0200 Subject: [PATCH] feat: load optional XDG TOML configuration with session overrides --- Checks/app-startup.py | 55 ++++++-- Checks/app-startup.swift | 10 +- Checks/automatic-quality-playback.swift | 10 +- Checks/compact-window.swift | 19 +-- Checks/configuration.swift | 124 ++++++++++++++++++ Checks/player-command.swift | 19 ++- Checks/system-playback.swift | 16 ++- Makefile | 44 +++++-- Package.resolved | 15 +++ Package.swift | 12 ++ README.md | 46 +++++-- Sources/App/CompactWindow.swift | 8 +- Sources/App/TerebiApp.swift | 39 ++++-- Sources/CLI/PlayerCommand.swift | 31 +++-- Sources/CLI/TerebiCLI.swift | 16 ++- .../Configuration/PlayerConfiguration.swift | 86 ++++++++++++ 16 files changed, 461 insertions(+), 89 deletions(-) create mode 100644 Checks/configuration.swift create mode 100644 Package.resolved create mode 100644 Package.swift create mode 100644 Sources/Configuration/PlayerConfiguration.swift diff --git a/Checks/app-startup.py b/Checks/app-startup.py index 2533d0d..52a9c70 100644 --- a/Checks/app-startup.py +++ b/Checks/app-startup.py @@ -21,10 +21,14 @@ def check_cli_instances(root, app, fixtures): runtime = shutil.which("deno") or shutil.which("node") canonical = "https://www.youtube.com/watch?v=aqz-KE-bpKQ" - def invoke(arguments): - result = subprocess.run([str(cli), *arguments], capture_output=True, text=True, timeout=20) - if result.returncode: - raise RuntimeError(f"CLI {arguments} failed: {result.returncode}; {result.stdout}{result.stderr}") + config = root / "xdg/terebi/config.toml" + environment = dict(os.environ, XDG_CONFIG_HOME=str(config.parent.parent)) + + def invoke(arguments, expected_code=0): + result = subprocess.run([str(cli), *arguments], capture_output=True, text=True, timeout=20, + env=environment) + if result.returncode != expected_code: + raise RuntimeError(f"CLI {arguments} returned {result.returncode}, expected {expected_code}; {result.stdout}{result.stderr}") return result def wait_states(count, ready, message): @@ -41,6 +45,8 @@ def check_cli_instances(root, app, fixtures): return states time.sleep(0.02) # Infrastructure deadline; assertions use native events/PTS, not wall dates. raise RuntimeError(f"{message}: native state deadline: {states}") + def settings(state): + return (state["showBufferInfo"], state["automaticQuality"], state["snapToEdges"]) with StreamingServer(fixtures) as server: if extractor and runtime: @@ -58,8 +64,8 @@ def check_cli_instances(root, app, fixtures): + " --load-info-json " + shlex.quote(str(metadata)) + ' "${args[@]:0:${#args[@]}-2}"\n') wrapper.chmod(0o755) - settings = {"directory": str(root), "path": str(root) + os.pathsep + os.environ.get("PATH", "")} - (app / "Contents/Resources/cli-check.json").write_text(json.dumps(settings)) + runtime_settings = {"directory": str(root), "path": str(root) + os.pathsep + os.environ.get("PATH", "")} + (app / "Contents/Resources/cli-check.json").write_text(json.dumps(runtime_settings)) movie = fixtures / "cache.mp4" audio = root / "audio with spaces.wav" shutil.copy2(fixtures / "audio.wav", audio) @@ -69,15 +75,23 @@ def check_cli_instances(root, app, fixtures): and state["position"] > 0 and state["hasVideo"] for state in states.values()), "cold default CLI opens one actual player window") first_pid = next(iter(first)) + if settings(first[first_pid]) != (False, False, True) or config.parent.exists(): + raise RuntimeError(f"missing config must use defaults without creating a file: {first}") invoke([]) wait_states(1, lambda states: states[first_pid]["media"] == movie.name, "warm default CLI reuses the existing process without replacing media") + config.parent.mkdir(parents=True) + config_text = '"show_buffer_info" = true # optional display\n automatic_quality = true\nsnap_to_edges = false\n' + config.write_text(config_text) invoke(["--new-instance"]) second = wait_states(2, lambda states: any(state["media"] == "" and state["status"] == 0 + and settings(state) == (True, True, False) for pid, state in states.items() if pid != first_pid), "empty independent CLI creates exactly one extra player") empty_pid = next(pid for pid in second if pid != first_pid) + if settings(second[first_pid]) != (False, False, True) or config.read_text() != config_text: + raise RuntimeError(f"config loading changed an existing session or wrote the file: {second}") invoke(["--new-instance", str(audio)]) third = wait_states(3, lambda states: any(state["media"] == audio.name and state["status"] == 3 and state["hasAudio"] and not state["hasVideo"] @@ -85,17 +99,34 @@ def check_cli_instances(root, app, fixtures): "independent local media reaches only the freshly launched process") if third[first_pid]["media"] != movie.name or third[first_pid]["status"] != 3 or third[empty_pid]["media"]: raise RuntimeError(f"independent launch disturbed an existing player: {third}") - count = 3 + config.write_text("show_buffer_info = [\n") + failure = invoke(["--new-instance"], expected_code=78) + if str(config) not in failure.stderr: + raise RuntimeError(f"configuration failure omitted its path: {failure.stderr}") + invoke(["--no-config"]) + unchanged = wait_states(3, lambda _: True, "bypass reuses an existing process without resetting its settings") + if any(settings(unchanged[pid]) != settings(third[pid]) for pid in third): + raise RuntimeError(f"warm bypass changed an existing session: {third}; {unchanged}") + invoke(["--no-config", "--new-instance", str(movie)]) + bypass = wait_states(4, lambda states: any(state["media"] == movie.name and state["status"] == 3 + and settings(state) == (False, False, True) + for pid, state in states.items() if pid not in third), + "fresh bypass skips invalid config and plays with built-in settings") + invoke(["--no-config", "--new-instance", "--help"]) + wait_states(4, lambda _: True, "help skips invalid config without starting another process") + config.write_text(config_text) + count = 4 if extractor and runtime: invoke(["--new-instance", "--url", canonical]) - fourth = wait_states(4, lambda states: any( + fourth = wait_states(5, lambda states: any( state["media"] == "Independent YouTube fixture" and state["status"] == 3 and state["position"] > 0 and state["hasAudio"] and state["hasVideo"] - for pid, state in states.items() if pid not in third), + and settings(state) == (True, True, False) + for pid, state in states.items() if pid not in bypass), "independent YouTube playback reaches only one new registered process") if fourth[first_pid]["media"] != movie.name or fourth[first_pid]["status"] != 3 or fourth[empty_pid]["media"]: raise RuntimeError(f"independent YouTube launch disturbed existing playback: {fourth}") - count = 4 + count = 5 else: print("SKIP independent YouTube playback: yt-dlp and a supported JS runtime are required") @@ -111,7 +142,9 @@ def check_cli_instances(root, app, fixtures): changed = [pid for pid in before if before[pid]["media"] != after[pid]["media"]] if len(changed) != 1: raise RuntimeError(f"default media open changed multiple players: {before}; {after}") - print("PASS actual CLI instances: cold/default reuse, independent empty/local/YouTube, one window per PID, targeted media, original playback retained, help, warm replacement") + if config.read_text() != config_text: + raise RuntimeError("native app startup modified config.toml") + print("PASS actual CLI instances: native startup config/defaults/invalid-file error/bypass/read-only settings, cold/default reuse, independent empty/local/YouTube, one window per PID, targeted media, original playback retained, help, warm replacement") diff --git a/Checks/app-startup.swift b/Checks/app-startup.swift index 86e64ff..3ddf2d9 100644 --- a/Checks/app-startup.swift +++ b/Checks/app-startup.swift @@ -71,7 +71,10 @@ private enum CLIInstanceCheck { let directory = URL(fileURLWithPath: settings["directory"]!, isDirectory: true) setenv("PATH", settings["path"]!, 1) _ = NSApplication.shared - PlayerModel.shared.setVolume(0) + _ = NotificationCenter.default.addObserver(forName: NSApplication.willFinishLaunchingNotification, + object: nil, queue: .main) { _ in + MainActor.assumeIsolated { PlayerModel.shared.setVolume(0) } + } // Exercise the real SwiftUI scene and delegate without displaying test windows. _ = NotificationCenter.default.addObserver(forName: NSWindow.didUpdateNotification, object: nil, queue: .main) { _ in MainActor.assumeIsolated { for window in NSApp.windows { window.alphaValue = 0 } } @@ -95,6 +98,9 @@ private enum CLIInstanceCheck { "hasAudio": model.state.has_audio, "hasVideo": model.state.has_video, "error": model.errorText ?? "", + "showBufferInfo": model.showsBufferInfo, + "automaticQuality": model.automaticYouTubeQuality, + "snapToEdges": model.compactWindow.snappingEnabled, ] do { let data = try JSONSerialization.data(withJSONObject: snapshot) @@ -102,6 +108,6 @@ private enum CLIInstanceCheck { } catch { fatalError("Could not publish CLI instance check: \(error)") } } } - TerebiApp.main() + terebiAppMain() } } diff --git a/Checks/automatic-quality-playback.swift b/Checks/automatic-quality-playback.swift index 74b0796..95553dd 100644 --- a/Checks/automatic-quality-playback.swift +++ b/Checks/automatic-quality-playback.swift @@ -1,4 +1,5 @@ import AppKit +import TerebiConfiguration @MainActor private final class FixedQualityClock { var now = 1000.0 } @@ -65,15 +66,16 @@ struct AutomaticQualityPlaybackChecks { "reselecting the current manual resolution disables auto without disrupting playback") model.setAutomaticYouTubeQuality(true) let reopened = PlayerModel(defaults: defaults, qualityClock: { clock.now }) - require(reopened.automaticYouTubeQuality, "explicit opt-in survives player restart") + require(!reopened.automaticYouTubeQuality, "session opt-in does not survive player restart") reopened.shutdown() model.setYouTubeQuality(nil) require(!model.automaticYouTubeQuality && model.youtubeQuality == nil, "default quality is also a manual override") - let manual = PlayerModel(defaults: defaults, qualityClock: { clock.now }) - require(!manual.automaticYouTubeQuality, "manual override persists after restart") + let manual = PlayerModel(defaults: defaults, configuration: PlayerConfiguration(automaticQuality: true), + qualityClock: { clock.now }) + require(manual.automaticYouTubeQuality, "manual session override does not replace startup configuration") manual.shutdown() model.closeFile() - print("PASS native adaptive playback: default-off/stalled downshift/healthy upshift/progress preservation/current+default manual override/opt-in persistence") + print("PASS native adaptive playback: default-off/stalled downshift/healthy upshift/progress preservation/current+default manual override/session-only settings") } } diff --git a/Checks/compact-window.swift b/Checks/compact-window.swift index a248b4c..75da976 100644 --- a/Checks/compact-window.swift +++ b/Checks/compact-window.swift @@ -93,7 +93,7 @@ struct CompactWindowChecks { "target width shrinks to usable bounds without distorting ordinary aspect") } - static func windowBehavior(defaults: UserDefaults) throws { + static func windowBehavior() throws { _ = NSApplication.shared guard let screen = NSScreen.main else { throw Failure(description: "FAIL: native window checks require a macOS GUI session with a screen") @@ -127,7 +127,7 @@ struct CompactWindowChecks { let originalHides = window.hidesOnDeactivate let originalDrag = window.isMovableByWindowBackground let identity = ObjectIdentifier(window) - let controller = CompactWindowController(defaults: defaults) + let controller = CompactWindowController() defer { controller.restore(resumeFullScreen: false) } controller.attach(window) try require(!controller.isCompact && !controller.isTransitioning, "attached window starts ordinary") @@ -182,14 +182,14 @@ struct CompactWindowChecks { try require(close(window.frame.minX, visible.minX + 16) && close(window.frame.minY, visible.minY + 16) && window.frame.size == nearEdge.size, "enabled preference snaps the actual moved window") controller.snappingEnabled = false - let second = CompactWindowController(defaults: defaults) - try require(!second.snappingEnabled, "disabled preference persists across controller instances") + let second = CompactWindowController(snappingEnabled: false) + try require(!second.snappingEnabled, "configuration can disable snapping for a new controller") window.setFrame(nearEdge, display: false) controller.snapAfterDrag() try require(sameFrame(window.frame, nearEdge), "disabled preference leaves an actual moved window untouched") second.snappingEnabled = true - let third = CompactWindowController(defaults: defaults) - try require(third.snappingEnabled, "re-enabled preference persists across controller instances") + let third = CompactWindowController(snappingEnabled: false) + try require(!third.snappingEnabled, "a session override does not change the next controller's settings") controller.snappingEnabled = true let resized = NSRect(x: nearEdge.minX, y: nearEdge.minY, width: 240, height: 135) window.setFrame(resized, display: false) @@ -218,13 +218,8 @@ struct CompactWindowChecks { } static func main() throws { - let suite = "terebi.compact-window-checks.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suite) else { - throw Failure(description: "FAIL: cannot create isolated preference suite") - } - defer { defaults.removePersistentDomain(forName: suite) } try geometry() - try windowBehavior(defaults: defaults) + try windowBehavior() print("Compact window checks passed") } } diff --git a/Checks/configuration.swift b/Checks/configuration.swift new file mode 100644 index 0000000..5d571b6 --- /dev/null +++ b/Checks/configuration.swift @@ -0,0 +1,124 @@ +import Foundation +import TerebiConfiguration + +@main +struct ConfigurationChecks { + struct Failure: Error, CustomStringConvertible { + let description: String + } + + static func require(_ condition: Bool, _ message: String) throws { + if !condition { throw Failure(description: "FAIL: " + message) } + } + + static func requireLoadError(at url: URL, _ message: String) throws { + do { + _ = try PlayerConfiguration.load(from: url) + } catch let caught { + let diagnostic = caught.localizedDescription + " " + String(describing: caught) + try require(diagnostic.contains(url.path), message + " identifies the actual config path") + return + } + throw Failure(description: "FAIL: " + message + " was accepted") + } + + static func main() throws { + let files = FileManager.default + let root = files.temporaryDirectory.appendingPathComponent("terebi.configuration-check.\(UUID().uuidString)") + try files.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? files.removeItem(at: root) } + let home = root.appendingPathComponent("home") + let xdg = root.appendingPathComponent("xdg") + let fallback = home.appendingPathComponent(".config/terebi/config.toml") + let config = xdg.appendingPathComponent("terebi/config.toml") + let defaults = PlayerConfiguration() + try require(!defaults.showBufferInfo && !defaults.automaticQuality && defaults.snapToEdges, + "built-in defaults hide buffer info, disable automatic quality, and enable snapping") + try require(PlayerConfiguration.fileURL(environment: ["XDG_CONFIG_HOME": xdg.path], homeDirectory: home) == config, + "absolute XDG_CONFIG_HOME selects terebi/config.toml") + for environment in [[:], ["XDG_CONFIG_HOME": ""], ["XDG_CONFIG_HOME": "relative/config"]] { + try require(PlayerConfiguration.fileURL(environment: environment, homeDirectory: home) == fallback, + "unset, empty, and relative XDG_CONFIG_HOME fall back to ~/.config") + } + try require(try PlayerConfiguration.load(from: fallback) == defaults, "missing files use built-in defaults") + try require(try PlayerConfiguration.loadForApplication(arguments: [], environment: [:], homeDirectory: home) == defaults, + "application startup uses defaults when its resolved config is missing") + try require(!files.fileExists(atPath: home.path), "missing-file loading creates neither home, config directory, nor file") + + try files.createDirectory(at: config.deletingLastPathComponent(), withIntermediateDirectories: true) + let original = Data(""" + # Root keys can be quoted, with comments and whitespace. + "show_buffer_info" = true # session display default + 'automatic_quality' = true + snap_to_edges = false + """.utf8) + try original.write(to: config) + let configured = PlayerConfiguration(showBufferInfo: true, automaticQuality: true, snapToEdges: false) + let environment = ["XDG_CONFIG_HOME": xdg.path] + try require(try PlayerConfiguration.load(from: config) == configured, + "TOML comments, whitespace, quoted root keys, and both boolean values load") + var session = try PlayerConfiguration.loadForApplication(arguments: [], environment: environment, homeDirectory: home) + try require(session == configured, "application startup automatically loads the XDG config") + session.showBufferInfo = false + session.automaticQuality = false + session.snapToEdges = true + try require(session == defaults, "controls can override all settings within the session") + try require(try Data(contentsOf: config) == original, "loading and session overrides do not change config bytes") + try require(try PlayerConfiguration.load(from: config) == configured, "a fresh load resets session overrides to file values") + + let partial = root.appendingPathComponent("partial.toml") + try Data("automatic_quality = true\n".utf8).write(to: partial) + try require(try PlayerConfiguration.load(from: partial) == PlayerConfiguration(automaticQuality: true), + "partial configuration retains defaults for omitted settings") + let empty = root.appendingPathComponent("empty.toml") + try Data("# Defaults only\n\n".utf8).write(to: empty) + try require(try PlayerConfiguration.load(from: empty) == defaults, "empty/comment-only TOML uses defaults") + try require(try PlayerConfiguration.loadForApplication(arguments: ["--config-file", partial.absoluteString], + environment: environment, homeDirectory: home) + == PlayerConfiguration(automaticQuality: true), "explicit file URL overrides the resolved XDG config") + + let invalid: [(String, String)] = [ + ("syntax", "show_buffer_info = [\n"), + ("duplicate", "show_buffer_info = true\n\"show_buffer_info\" = false\n"), + ("unknown", "unknown_setting = true\n"), + ("string", "show_buffer_info = \"true\"\n"), + ("integer", "automatic_quality = 1\n"), + ("array", "snap_to_edges = [true]\n"), + ("table", "[show_buffer_info]\nenabled = true\n") + ] + for (name, text) in invalid { + let url = root.appendingPathComponent(name + ".toml") + let bytes = Data(text.utf8) + try bytes.write(to: url) + try requireLoadError(at: url, "invalid \(name) configuration") + try require(try Data(contentsOf: url) == bytes, "rejected \(name) config remains unchanged") + } + let invalidUTF8 = root.appendingPathComponent("invalid-utf8.toml") + let invalidBytes = Data([0xff, 0xfe, 0x80]) + try invalidBytes.write(to: invalidUTF8) + try requireLoadError(at: invalidUTF8, "invalid UTF-8 configuration") + try require(try Data(contentsOf: invalidUTF8) == invalidBytes, "invalid UTF-8 config remains unchanged") + let directory = root.appendingPathComponent("directory.toml") + try files.createDirectory(at: directory, withIntermediateDirectories: false) + try requireLoadError(at: directory, "directory instead of config file") + + let malformed = root.appendingPathComponent("syntax.toml") + let inaccessible = partial.appendingPathComponent("config.toml") // A regular file cannot be traversed as a directory. + for url in [malformed, inaccessible, directory] { + try require(try PlayerConfiguration.loadForApplication(arguments: ["--config-file", url.absoluteString, "--no-config"], + environment: environment, homeDirectory: home) == defaults, + "--no-config bypasses malformed, inaccessible, and directory config paths") + } + let badXDG = root.appendingPathComponent("bad-xdg") + let badConfig = badXDG.appendingPathComponent("terebi/config.toml") + try files.createDirectory(at: badConfig.deletingLastPathComponent(), withIntermediateDirectories: true) + let malformedBytes = Data("snap_to_edges =\n".utf8) + try malformedBytes.write(to: badConfig) + try require(try PlayerConfiguration.loadForApplication(arguments: ["--no-config"], + environment: ["XDG_CONFIG_HOME": badXDG.path], homeDirectory: home) == defaults, + "--no-config also bypasses automatic XDG loading") + try require(try Data(contentsOf: badConfig) == malformedBytes, "bypassing config never repairs or rewrites it") + try require(!files.fileExists(atPath: home.path), "all checks leave the isolated fallback home uncreated") + print("Configuration checks passed") + } +} diff --git a/Checks/player-command.swift b/Checks/player-command.swift index ac119dd..ba25925 100644 --- a/Checks/player-command.swift +++ b/Checks/player-command.swift @@ -16,11 +16,12 @@ struct PlayerCommandChecks { let instanceFile = directory.appendingPathComponent("--new-instance") for file in [movie, optionFile, colonFile, instanceFile] { try Data().write(to: file) } - func opened(_ arguments: [String], newInstance: Bool = false) throws -> URL? { - guard case .launch(let url, let independent) = try PlayerCommand.parse(arguments, directory: directory) else { + func opened(_ arguments: [String], newInstance: Bool = false, noConfig: Bool = false) throws -> URL? { + guard case .launch(let url, let independent, let bypass) = try PlayerCommand.parse(arguments, directory: directory) else { fatalError("FAIL: expected a launch command") } require(independent == newInstance, "launch mode preserves default reuse or explicit independence") + require(bypass == noConfig, "configuration loading defaults on and can be explicitly bypassed") return url } require(try opened([movie.lastPathComponent]) == movie, "relative paths resolve against invocation directory, preserving spaces") @@ -40,6 +41,17 @@ struct PlayerCommandChecks { "independent launch preserves the filename terminator") require(try opened(["--", "--new-instance"]) == instanceFile, "an escaped instance flag is a filename, not a launch option") + require(try opened(["--no-config"], noConfig: true) == nil, "configuration bypass can omit media") + require(try opened(["--no-config", movie.path], noConfig: true) == movie, + "configuration bypass retains local media delivery") + require(try opened(["--new-instance", "--no-config", "--url", canonical.absoluteString], + newInstance: true, noConfig: true) == canonical, + "independent bypass retains explicit YouTube identity") + require(try opened(["--no-config", "--new-instance", "--", "--help"], + newInstance: true, noConfig: true) == optionFile, "launch options work in either order") + guard case .help = try PlayerCommand.parse(["--no-config", "--new-instance", "--help"]) else { + fatalError("FAIL: help with launch options must not launch a player") + } guard case .help = try PlayerCommand.parse(["--new-instance", "--help"]) else { fatalError("FAIL: help must not launch an independent player") } @@ -49,7 +61,8 @@ struct PlayerCommandChecks { ["file://remote-host/video.mp4"], ["https://example.com/video.mp4"], ["--new-instance", "--new-instance"], ["--new-instance", "--url"], ["--new-instance", "--"], ["--new-instance", movie.path, movie.path], - ["--new-instance", "--unknown"]] { + ["--new-instance", "--unknown"], ["--no-config", "--no-config"], + ["--no-config", "--new-instance", "--no-config"]] { do { _ = try PlayerCommand.parse(arguments, directory: directory) fatalError("FAIL: accepted an invalid command") diff --git a/Checks/system-playback.swift b/Checks/system-playback.swift index f0deba6..c79f442 100644 --- a/Checks/system-playback.swift +++ b/Checks/system-playback.swift @@ -1,5 +1,6 @@ import AppKit import MediaPlayer +import TerebiConfiguration @main @MainActor @@ -29,13 +30,20 @@ struct SystemPlaybackChecks { "buffer readout can be enabled before playback and reveals controls") initialModel.shutdown() let restoredModel = PlayerModel(defaults: defaults) - require(restoredModel.showsBufferInfo, "enabled buffer readout survives model recreation") - restoredModel.setShowsBufferInfo(false) - require(!restoredModel.showsBufferInfo, "buffer readout can be explicitly disabled") + require(!restoredModel.showsBufferInfo, "session opt-in does not survive model recreation") restoredModel.shutdown() + let configuredModel = PlayerModel(defaults: defaults, configuration: + PlayerConfiguration(showBufferInfo: true, automaticQuality: true, snapToEdges: false)) + require(configuredModel.showsBufferInfo && configuredModel.automaticYouTubeQuality + && !configuredModel.compactWindow.snappingEnabled, "configuration initializes all three controls") + configuredModel.setShowsBufferInfo(false) + configuredModel.setAutomaticYouTubeQuality(false) + configuredModel.compactWindow.snappingEnabled = true + configuredModel.shutdown() let model = PlayerModel(defaults: defaults) defer { model.shutdown() } - require(!model.showsBufferInfo, "explicitly disabled buffer readout survives model recreation") + require(!model.showsBufferInfo && !model.automaticYouTubeQuality && model.compactWindow.snappingEnabled, + "session overrides never replace built-in settings") model.setVolume(0) let directory = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) let center = MPNowPlayingInfoCenter.default() diff --git a/Makefile b/Makefile index c4a58dc..2a2b719 100644 --- a/Makefile +++ b/Makefile @@ -13,13 +13,17 @@ OBJECTS := $(BUILD_DIR)/player.o $(BUILD_DIR)/Output.o SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/CompactWindow.swift Sources/App/YouTubeResolver.swift Sources/App/PlaybackProgress.swift Sources/App/SystemPlaybackControls.swift Sources/App/AutomaticQuality.swift RUNTIME_SOURCE := Sources/Launcher/FFmpegRuntime.swift CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift $(RUNTIME_SOURCE) +CONFIG_BUILD := .build/configuration +CONFIG_BIN := $(shell xcrun swift build --scratch-path $(CONFIG_BUILD) -c release --show-bin-path) +CONFIG_LIBRARY := $(CONFIG_BIN)/libTerebiConfiguration.a +CONFIG_FLAGS := -I $(CONFIG_BIN) -I $(CONFIG_BIN)/Modules $(CONFIG_LIBRARY) APP := $(BUILD_DIR)/terebi.app PLAYER := $(APP)/Contents/Frameworks/libterebi-player.dylib BOOTSTRAP := $(APP)/Contents/MacOS/terebi MANIFEST := $(APP)/Contents/Resources/ffmpeg-libraries.txt -.PHONY: all release install check-dependencies run check check-fixtures check-audio check-window check-youtube check-quality check-adaptive check-network check-progress check-cli check-runtime check-media check-startup -all: check-dependencies $(BUILD_DIR)/terebi $(PLAYER) $(BOOTSTRAP) $(MANIFEST) $(APP)/Contents/Info.plist +.PHONY: all release install check-dependencies run check check-config check-fixtures check-audio check-window check-youtube check-quality check-adaptive check-network check-progress check-cli check-runtime check-media check-startup +all: check-dependencies $(BUILD_DIR)/terebi $(PLAYER) $(BOOTSTRAP) $(MANIFEST) $(APP)/Contents/Info.plist $(APP)/Contents/Resources/TOMLDecoder-LICENSE.txt check-dependencies: @command -v pkg-config >/dev/null 2>&1 || { printf '%s\n' 'Error: pkg-config is required to build terebi. Run: brew install ffmpeg pkg-config' >&2; exit 1; } @@ -39,16 +43,26 @@ install: release $(BUILD_DIR): mkdir -p "$@" +Package.resolved: Package.swift + xcrun swift package --scratch-path $(CONFIG_BUILD) resolve + +$(CONFIG_LIBRARY): Package.swift Package.resolved Sources/Configuration/PlayerConfiguration.swift + xcrun swift build --scratch-path $(CONFIG_BUILD) -c release --product TerebiConfiguration + +$(APP)/Contents/Resources/TOMLDecoder-LICENSE.txt: $(CONFIG_LIBRARY) + mkdir -p "$(APP)/Contents/Resources" + cp "$(CONFIG_BUILD)/checkouts/TOMLDecoder/LICENSE.md" "$@" + $(BUILD_DIR)/player.o: Sources/Core/player.cpp Sources/Core/player.h Sources/Core/player_macos.h Sources/Core/output.hpp Sources/Core/network_input.hpp Makefile | $(BUILD_DIR) check-dependencies $(CXX) $(CXXFLAGS) -c $< -o $@ $(BUILD_DIR)/Output.o: Sources/macOS/Output.mm Sources/Core/output.hpp Makefile | $(BUILD_DIR) check-dependencies $(CXX) $(CXXFLAGS) -fobjc-arc -c $< -o $@ -$(PLAYER): $(SWIFT_SOURCES) $(OBJECTS) Makefile | check-dependencies +$(PLAYER): $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies mkdir -p "$(APP)/Contents/Frameworks" rm -f "$(APP)/Contents/MacOS/terebi-player" - $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -emit-library -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h $(SWIFT_SOURCES) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -Xlinker -install_name -Xlinker @rpath/libterebi-player.dylib -o $@ + $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -emit-library -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -Xlinker -install_name -Xlinker @rpath/libterebi-player.dylib -o $@ $(MANIFEST): $(PLAYER) Makefile mkdir -p "$(APP)/Contents/Resources" @@ -70,7 +84,7 @@ $(APP)/Contents/Info.plist: Resources/Info.plist $(BUILD_DIR)/terebi: $(CLI_SOURCES) $(PLAYER) $(BOOTSTRAP) $(MANIFEST) $(APP)/Contents/Info.plist Makefile | check-dependencies rm -f "$@" - $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) $(CLI_SOURCES) -framework AppKit -o $@ + $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) $(CLI_SOURCES) $(CONFIG_FLAGS) -framework AppKit -o $@ run: all open "$(APP)" @@ -78,7 +92,7 @@ run: all $(BUILD_DIR)/playback-check: Checks/playback.mm $(OBJECTS) Makefile | check-dependencies $(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@ -check: check-dependencies $(BUILD_DIR)/playback-check check-fixtures check-audio check-window check-youtube check-quality check-adaptive check-network check-progress check-cli check-runtime check-media check-startup +check: check-dependencies $(BUILD_DIR)/playback-check check-fixtures check-audio check-window check-config check-youtube check-quality check-adaptive check-network check-progress check-cli check-runtime check-media check-startup $(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures check-fixtures: | check-dependencies @@ -118,6 +132,12 @@ $(BUILD_DIR)/compact-window-check: Checks/compact-window.swift Sources/App/Compa check-window: $(BUILD_DIR)/compact-window-check $(BUILD_DIR)/compact-window-check +$(BUILD_DIR)/configuration-check: Checks/configuration.swift $(CONFIG_LIBRARY) Makefile | $(BUILD_DIR) + $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) $< $(CONFIG_FLAGS) -o $@ + +check-config: $(BUILD_DIR)/configuration-check + $(BUILD_DIR)/configuration-check + $(BUILD_DIR)/youtube-resolver-check: Checks/youtube-resolver.swift Sources/App/YouTubeResolver.swift Makefile | $(BUILD_DIR) check-dependencies $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/youtube-resolver.swift Sources/App/YouTubeResolver.swift -framework Foundation -o $@ @@ -130,8 +150,8 @@ $(BUILD_DIR)/automatic-quality-check: Checks/automatic-quality.swift Sources/App check-quality: $(BUILD_DIR)/automatic-quality-check $(BUILD_DIR)/automatic-quality-check -$(BUILD_DIR)/automatic-quality-playback-check: Checks/automatic-quality-playback.swift $(SWIFT_SOURCES) $(OBJECTS) Makefile | check-dependencies - $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h $< $(SWIFT_SOURCES) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@ +$(BUILD_DIR)/automatic-quality-playback-check: Checks/automatic-quality-playback.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies + $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h $< $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@ check-adaptive: $(BUILD_DIR)/automatic-quality-playback-check mkdir -p $(BUILD_DIR)/fixtures @@ -167,14 +187,14 @@ $(BUILD_DIR)/ffmpeg-runtime-check: Checks/ffmpeg-runtime.swift $(RUNTIME_SOURCE) check-runtime: $(BUILD_DIR)/ffmpeg-runtime-check $(MANIFEST) $(BUILD_DIR)/ffmpeg-runtime-check "$(APP)" -$(BUILD_DIR)/system-playback-check: Checks/system-playback.swift $(SWIFT_SOURCES) $(OBJECTS) Makefile | check-dependencies - $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h Checks/system-playback.swift $(SWIFT_SOURCES) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@ +$(BUILD_DIR)/system-playback-check: Checks/system-playback.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies + $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h Checks/system-playback.swift $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@ check-media: $(BUILD_DIR)/system-playback-check check-fixtures $(BUILD_DIR)/system-playback-check $(BUILD_DIR)/fixtures -$(BUILD_DIR)/app-startup-check.dylib: Checks/app-startup.swift $(SWIFT_SOURCES) $(OBJECTS) Makefile | check-dependencies - $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -emit-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h Checks/app-startup.swift $(SWIFT_SOURCES) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@ +$(BUILD_DIR)/app-startup-check.dylib: Checks/app-startup.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies + $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -emit-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h Checks/app-startup.swift $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@ check-startup: all $(BUILD_DIR)/app-startup-check.dylib check-fixtures python3 Checks/app-startup.py "$(APP)" "$(BUILD_DIR)/app-startup-check.dylib" "$(BUILD_DIR)/fixtures" diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..96e17d8 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "95099b9cc8143f6f9e6723cb66f03f98d8152a6a18ba6bb76fcde17633818949", + "pins" : [ + { + "identity" : "tomldecoder", + "kind" : "remoteSourceControl", + "location" : "https://github.com/dduan/TOMLDecoder.git", + "state" : { + "revision" : "a2bbd2796fe3064e107de18cb56031052c4fa899", + "version" : "0.4.5" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..a113115 --- /dev/null +++ b/Package.swift @@ -0,0 +1,12 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "TerebiConfiguration", + platforms: [.macOS(.v15)], + products: [.library(name: "TerebiConfiguration", type: .static, targets: ["TerebiConfiguration"])], + dependencies: [.package(url: "https://github.com/dduan/TOMLDecoder.git", exact: "0.4.5")], + targets: [.target(name: "TerebiConfiguration", dependencies: [ + .product(name: "TOMLDecoder", package: "tomldecoder") + ], path: "Sources/Configuration")] +) diff --git a/README.md b/README.md index 5956a6f..e2c39c9 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ A personal native macOS player whose playback code is small enough to own and mo Requires macOS 15+, Xcode's command-line tools with Swift 6, and Homebrew FFmpeg/pkg-config: +The first build fetches pinned [TOMLDecoder 0.4.5](https://github.com/dduan/TOMLDecoder/tree/0.4.5) through SwiftPM. It is statically linked into the CLI/player; running the installed app needs no separate TOML runtime. Its MIT notice is included in the app's Resources. + ```sh brew install ffmpeg pkg-config make @@ -45,14 +47,31 @@ The declared app executable stays running after preflight and loads `Contents/Fr .build/terebi -- -video.mp4 .build/terebi --new-instance .build/terebi --new-instance --url 'https://www.youtube.com/watch?v=jNQXAC9IVRw' +.build/terebi --new-instance --no-config "/path/to/video.mp4" .build/terebi --help ``` One local file (including a local `file://` URL) or YouTube video URL is accepted. Paths are relative to the shell's current directory; quote spaces and URL query strings. `--` treats the following argument as a filename. With no arguments, the CLI opens or activates the player without replacing its media. Put **`--new-instance` before the media arguments** to launch a separate player process/window instead: it works without media, with a local file or positional YouTube URL, and with `--url` or `--`. Existing playback stays untouched. To use `terebi` by name, add the build directory to your shell's PATH: `export PATH="$PWD/.build:$PATH"`. -The launcher finds `terebi.app` beside its own executable, following symlinks. By default, macOS application/URL delivery reuses a running player and its existing window. With `--new-instance`, one fresh application launch carries any media URL as a startup argument, so it reaches that process without a second app launch or changing another player's media. Keep the executable and app bundle together; this is not a standalone distribution. Media opens use the same autoplay and saved-progress behavior as the UI. Instances share preferences and saved progress; each has independent playback and a window. Quit all running players after installing this update so every process uses the updated startup/history handling. +The launcher finds `terebi.app` beside its own executable, following symlinks. By default, macOS application/URL delivery reuses a running player and its existing window. With `--new-instance`, one fresh application launch carries any media URL as a startup argument, so it reaches that process without a second app launch or changing another player's media. Keep the executable and app bundle together; this is not a standalone distribution. Media opens use the same autoplay and saved-progress behavior as the UI. Instances share saved progress, but configuration and control overrides belong to each session. Quit all running players after installing this update so every process uses the updated configuration/startup handling. -Help exits with status **0** without opening the GUI or loading FFmpeg. Invalid arguments, unsupported URLs, missing files, and directories report to stderr and exit **64**; unavailable FFmpeg libraries and application-launch failures exit **69**. A successful launch exits **0** immediately after macOS accepts the request, not when playback finishes. Later extraction/decoding failures appear in the player window; this is a GUI launcher, not a headless player or remote playback-control interface. +Help exits with status **0** without opening the GUI, reading configuration, or loading FFmpeg. Invalid arguments, unsupported URLs, missing media files, and directories report to stderr and exit **64**; unavailable FFmpeg libraries and application-launch failures exit **69**; unreadable or invalid configuration exits **78** with the file path and diagnostic. A successful launch exits **0** immediately after macOS accepts the request, not when playback finishes. Later extraction/decoding failures appear in the player window; this is a GUI launcher, not a headless player or remote playback-control interface. + +## Configuration + +Every fresh app startup automatically reads **`$XDG_CONFIG_HOME/terebi/config.toml`**, falling back to **`~/.config/terebi/config.toml`** when `XDG_CONFIG_HOME` is unset, empty, or not absolute. The CLI forwards its resolved path because Finder/LaunchServices does not inherit the shell's XDG environment. Set `XDG_CONFIG_HOME` in the GUI launch environment if Finder should use a different directory. + +The file is optional: a missing file means built-in defaults, and terebi never creates a file or directory on its own. These are all supported settings and their defaults: + +```toml +show_buffer_info = false +automatic_quality = false +snap_to_edges = true +``` + +Omitted keys retain their defaults. Values must be TOML booleans; unknown keys, malformed TOML, wrong types, unreadable files, and a directory in place of the file are errors, not silent defaults. GUI startup errors show a native alert. Settings are read once at startup; control/menu changes are **session-only** and never write the file. Edit it yourself and start a fresh player to apply changes. + +Put **`--no-config` before the media arguments** to skip reading the file entirely and use built-in settings. Combine it with `--new-instance` in either order to guarantee a fresh session. Reusing a running player neither reloads its file nor resets its current settings, even with `--no-config`. Playback history remains separate in UserDefaults; legacy UserDefaults settings are no longer read or written. ## YouTube @@ -70,11 +89,11 @@ Seekable network playback targets **60 seconds ahead and 30 seconds behind** usi Recorded direct HTTP inputs use FFmpeg 9's **10 MiB bounded range requests**, continuing sequentially and reusing persistent connections when the server permits. This avoids the open-ended-request throttling seen on some YouTube CDNs; URL extraction alone does not apply yt-dlp's download chunking. Native [`request_size` and `multiple_requests`](https://github.com/FFmpeg/FFmpeg/blob/n9.0.1/doc/protocols.texi#L462-L485) handle continuation without a separate downloader. Large `short_seek_size` read-ahead stays disabled: an uncached seek must escape a stalled response, not wait to drain it. Required headers, fifteen-second I/O deadlines, cache limits, and cancellation remain intact. Live sources keep their existing transport behavior. This removes avoidable request-level throttling, not bandwidth/CDN limits: a minute is a target, not a startup requirement or guaranteed reserve. -**gear → Show Buffer Info** enables the optional bottom readout in both player modes; it is **disabled by default** and persists across restarts. When enabled, actual retained times appear as **Buffer: −30s +60s** (compact mode omits the prefix). Negative time is reusable history, positive time is playable reserve ahead—not desired targets or percentages downloaded. The normal seek bar shades the retained interval on both sides of the playhead regardless of this preference; its tooltip and VoiceOver retain fractional seconds and the buffered-through timestamp. **Buffer: —** means no estimate is available, including during pending seeks or for live/unknown-duration media; local playback displays no network indicator. Hiding the readout does not affect caching or automatic quality. Quality/source changes and close discard the old cache; cached seeks preserve it. Required audio and video constrain the reported range. +**gear → Show Buffer Info** enables the optional bottom readout in both player modes; it is **disabled by default**, configurable with `show_buffer_info`, and its menu toggle is session-only. When enabled, actual retained times appear as **Buffer: −30s +60s** (compact mode omits the prefix). Negative time is reusable history, positive time is playable reserve ahead—not desired targets or percentages downloaded. The normal seek bar shades the retained interval on both sides of the playhead regardless of this preference; its tooltip and VoiceOver retain fractional seconds and the buffered-through timestamp. **Buffer: —** means no estimate is available, including during pending seeks or for live/unknown-duration media; local playback displays no network indicator. Hiding the readout does not affect caching or automatic quality. Quality/source changes and close discard the old cache; cached seeks preserve it. Required audio and video constrain the reported range. While playing YouTube, choose **gear → Quality** in either normal or compact mode. The dropdown lists the video's supported SDR resolutions, including 1440p/4K when available, and shows the current resolution. **Default (up to 1080p)** restores the cap. Switching resolves fresh stream URLs and briefly pauses while reopening, preserving the position for seekable recorded videos, play/pause state, and the same window, size, and mode. An extraction failure leaves the previous stream selected and restores its playback state. Live/non-seekable streams reopen without position restoration. New video opens use the default again; manual quality choices are not persisted. -**gear → Quality → Automatic Quality (up to 1080p)** is **disabled by default**. Explicit opt-in persists across restarts; choosing any manual/default resolution, even the current one, disables it. Auto moves down one available rung after reserve stays below four seconds for six seconds or playback stalls for three seconds. It moves up one rung after at least twenty seconds of reserve remains healthy for sixty seconds, never above 1080p; live/unknown reserve uses 120 seconds of stable playback instead. Switches have a thirty-second cooldown. Paused/opening/seeking media and the final fifteen seconds of a recorded video do not trigger changes. Failed automatic extraction leaves the previous playback running and backs off for 120 seconds. The old stream continues while automatic URLs resolve; applying a new quality still briefly reopens the stream, preserving the latest seekable position and play state rather than providing a seamless segment-level switch. +**gear → Quality → Automatic Quality (up to 1080p)** is **disabled by default**, configurable with `automatic_quality`, and its menu toggle is session-only. Choosing any manual/default resolution, even the current one, disables it for that session. Auto moves down one available rung after reserve stays below four seconds for six seconds or playback stalls for three seconds. It moves up one rung after at least twenty seconds of reserve remains healthy for sixty seconds, never above 1080p; live/unknown reserve uses 120 seconds of stable playback instead. Switches have a thirty-second cooldown. Paused/opening/seeking media and the final fifteen seconds of a recorded video do not trigger changes. Failed automatic extraction leaves the previous playback running and backs off for 120 seconds. The old stream continues while automatic URLs resolve; applying a new quality still briefly reopens the stream, preserving the latest seekable position and play state rather than providing a seamless segment-level switch. The app ignores yt-dlp configuration and plugins, does not read browser cookies, and does not update or fetch extractor components automatically. Private, age-restricted, region-blocked, or sign-in-required videos can fail. If YouTube extraction changes, run `brew upgrade yt-dlp deno`; local playback needs neither dependency. @@ -94,7 +113,7 @@ Opening video fits the window to its display aspect ratio, including non-square **Compact mode** uses the same window, Metal view, and playback session—not a second window or macOS system PiP. Use the PiP-shaped button in the normal playback controls, **⌃⌘P**, or **Playback Actions → Enter Compact Mode**. The compact-mode button replaces the fullscreen button; fullscreen remains available through its menu, keyboard shortcuts, and double-clicking normal-mode video. Compact mode starts at the current screen's top-right corner with a 16-point inset and a preferred width of 320 points, preserving display aspect where usable screen bounds and the 160 × 90-point minimum allow; extreme ratios retain letterboxing. Compact mode floats above ordinary windows, joins all Spaces, and is configured to join eligible other-app fullscreen spaces and Stage Manager sets. Small hover controls provide play/pause, restore, and Playback Actions; keyboard playback controls remain available. -Drag the compact video to move the window; on release, each axis snaps independently when within 24 points of an inset usable-screen edge. **Snap to Screen Edges** is enabled by default and remembers its toggle across launches. Disabling snapping permits free placement. Exiting compact mode restores the previous normal geometry and window behavior, including keep-on-top; entry from fullscreen waits for the native exit animation, and restoring that session returns to fullscreen. Double-clicking compact video also restores the previous mode. Video replacement updates the compact aspect; audio-only replacement, playback errors, and closing the file leave compact mode. Closing the window still quits the app. +Drag the compact video to move the window; on release, each axis snaps independently when within 24 points of an inset usable-screen edge. **Snap to Screen Edges** is enabled by default, configurable with `snap_to_edges`, and its toggle is session-only. Disabling snapping permits free placement. Exiting compact mode restores the previous normal geometry and window behavior, including keep-on-top; entry from fullscreen waits for the native exit animation, and restoring that session returns to fullscreen. Double-clicking compact video also restores the previous mode. Video replacement updates the compact aspect; audio-only replacement, playback errors, and closing the file leave compact mode. Closing the window still quits the app. | Key | Action | |---|---| @@ -132,11 +151,12 @@ Only implemented actions have controls; there are no placeholder playlist, subti - `Sources/App/AutomaticQuality.swift`: clock-injected quality decisions using reserve/stall hysteresis, stepwise resolution choices, cooldown and extraction-failure backoff. Ordinary state revisions do not reset health; source/seek/pause transitions do. - `Sources/App/PlaybackProgress.swift`: local per-video position persistence and canonical file identities, using injectable UserDefaults. The player model checkpoints authoritative positions and defers resumed autoplay until the saved seek is queued against the ready source. - `Sources/App/SystemPlaybackControls.swift`: native remote-command registration, main-thread dispatch, Now Playing publication, seekability gating, and target cleanup. -- `Sources/App/CompactWindow.swift`: same-window compact geometry, saved normal window state, asynchronous fullscreen transitions and failure recovery, all-Spaces/floating behavior, magnetic edge snapping, and its persisted preference. +- `Sources/App/CompactWindow.swift`: same-window compact geometry, saved normal window state, asynchronous fullscreen transitions and failure recovery, all-Spaces/floating behavior, and magnetic edge snapping with a session-only toggle. - `Sources/App/YouTubeResolver.swift`: YouTube URL and metadata validation, external yt-dlp/runtime discovery, capped-default or explicit-resolution SDR selection, playable resolution discovery and headers, bounded nonblocking process output, sanitized extraction errors, and request-local cancellation. - `Sources/CLI/PlayerCommand.swift` and `Sources/CLI/TerebiCLI.swift`: command-line validation/help and native application discovery/launching. Launch the app without media first to establish its singleton window, then deliver the selected URL; the app owns Apple events to prevent SwiftUI's default external-document routing from replacing that window. - `Sources/Launcher/FFmpegRuntime.swift` and `Sources/App/Bootstrap.swift`: shared native dependency preflight and the app's FFmpeg-independent entrypoint. The bootstrap loads the player library and calls its exported `terebi_app_main` entry point without replacing the executable, consuming initial launch events, or losing macOS app identity. `Contents/Resources/ffmpeg-libraries.txt` records the library's actual linked FFmpeg ABI paths. -- `Makefile`: compiler/linker commands, dependency preflight, generated runtime manifest, developer/release bundles, installation, and checks. No generated Xcode project or additional build dependency. +- `Sources/Configuration/PlayerConfiguration.swift`: built-in settings, XDG path resolution, read-only strict TOML loading, and startup bypass. `Package.swift`/`Package.resolved` pin the statically linked decoder. +- `Makefile`: compiler/linker commands, SwiftPM configuration-library build, dependency preflight, generated runtime manifest, developer/release bundles, installation, and checks. No generated Xcode project. ## Runnable check @@ -148,23 +168,25 @@ Generates short fixtures and exercises the real playback interface and native ou `make check-audio` runs just the audio-quality check. A check-only hook captures actual Core Audio callback PCM into preallocated storage and compares every sample against FFmpeg's reference output for PCM/AAC, 44.1/48 kHz, and coarse container timestamps. It verifies that repeated forward/backward stepping stays silent and that resumed audio matches the exact selected-frame PCM suffix. It also checks fractional gain, silent muted startup, smooth live volume changes, and unchanged playback completion. A separate gapped fixture verifies that real timestamp gaps remain silent. Release builds contain no capture hook. -`make check-window` runs the compact-mode check, also included in `make check`. It exercises magnetic thresholds/corners, negative-origin screens, portrait/extreme aspect bounds, isolated preference persistence, actual hidden-window entry/resizing/snapping/restoration, view-host identity, keyboard eligibility, and a replay of AppKit's fullscreen-failure lifecycle. It requires a graphical macOS session but does not display windows or play audio. Visible fullscreen transitions, cross-Space/Stage Manager behavior, and hover/drag usability still require a check in the app. +`make check-window` runs the compact-mode check, also included in `make check`. It exercises magnetic thresholds/corners, negative-origin screens, portrait/extreme aspect bounds, configured/session-only snapping, actual hidden-window entry/resizing/snapping/restoration, view-host identity, keyboard eligibility, and a replay of AppKit's fullscreen-failure lifecycle. It requires a graphical macOS session but does not display windows or play audio. Visible fullscreen transitions, cross-Space/Stage Manager behavior, and hover/drag usability still require a check in the app. + +`make check-config` checks isolated XDG/fallback paths, missing-file defaults without creation, partial/quoted/commented TOML, invalid syntax/types/keys/UTF-8, duplicate keys, read errors, explicit path delivery, `--no-config` bypass, and unchanged file bytes/session reset. It needs neither FFmpeg nor a graphical session and never reads the user's configuration. `make check-youtube` checks URL/metadata/header trust boundaries, resolution choices and the default cap, and real subprocess output, bounded pipe draining, cancellation, and request isolation using isolated extractor fixtures. If yt-dlp is installed, it also runs the actual selector against offline metadata to verify 1080p priority over extractor preferences, combined-stream selection, lower-resolution fallback, and explicit 4K. It requires neither Deno nor external network access; the actual-selector portion is skipped when yt-dlp is absent. `make check-network` uses a range-capable loopback HTTP server and actual native playback. It checks combined/separate streams, identical stream indices across inputs, selected headers, coordinated seeks, exact VFR/B-frame stepping, asymmetric EOF and audio-tail seeks, recorded native HLS seeks, live/non-range seek rejection, buffering before the entire file arrives, permanently stalled primary/secondary reads, cancellation/replacement/destruction, HTTP failures, and recovery. A CDN fixture withholds oversized/open-ended responses: combined and split playback must build a minute of reserve using bounded continuation, retain required headers, seek successfully, and consume the final partial range to EOF. The combined case also verifies one persistent connection across sequential responses; split inputs exercise servers that close connections. Retained-range checks cover a minute of read-ahead, a thirty-second rewind window, eviction, cached bidirectional seeks without new HTTP requests while a future response stays gated, cache-miss refill, FFprobe-derived supplied-packet timestamp bounds/B-frame dependencies, required-track constraints, gate-release refill, shorter-track EOF tails, and clearing on close/error/local replacement. It is included in `make check`. -`make check-quality` runs deterministic policy checks with explicit monotonic clock values: downward/upward hysteresis, sparse available resolutions, the 1080p cap, cooldown, pause/seek/open/local/default-off suppression, recorded EOF, live/unknown reserve, frozen progress, and failure backoff. `make check-adaptive` exercises the actual app model with gated loopback 1080p/720p media and offline yt-dlp selection: default-off behavior, a sustained-stall downshift, healthy upshift, position preservation, current/default manual overrides, and opt-in persistence. The quality clock is injected; tests use isolated preferences and muted playback. Both targets are included in `make check`; the native adaptive portion is skipped if yt-dlp is absent. No external network is required. +`make check-quality` runs deterministic policy checks with explicit monotonic clock values: downward/upward hysteresis, sparse available resolutions, the 1080p cap, cooldown, pause/seek/open/local/default-off suppression, recorded EOF, live/unknown reserve, frozen progress, and failure backoff. `make check-adaptive` exercises the actual app model with gated loopback 1080p/720p media and offline yt-dlp selection: default-off behavior, a sustained-stall downshift, healthy upshift, position preservation, current/default manual overrides, and session-only opt-in/configuration restoration. The quality clock is injected; tests use isolated playback history and muted playback. Both targets are included in `make check`; the native adaptive portion is skipped if yt-dlp is absent. No external network is required. `make check-progress` verifies persisted fractional positions, independent local/YouTube identities, symlink aliases, completion clearing, invalid-position rejection, isolated preference domains, independently initialized writers preserving each other's videos, and migration of existing bookmarks. It is included in `make check` and does not touch real playback history. -`make check-cli` checks relative/escaped paths, option-like filenames, YouTube identity, default reuse versus explicit `--new-instance`, independent empty/local/YouTube launches, help without launch, argument cardinality, unknown/duplicate options, missing files/directories, non-local file URLs, and unsupported stream URLs. It is included in `make check`. +`make check-cli` checks relative/escaped paths, option-like filenames, YouTube identity, default reuse versus explicit `--new-instance`, `--no-config` alone and combined in either order, independent empty/local/YouTube launches, help without launch, argument cardinality, unknown/duplicate options, missing files/directories, non-local file URLs, and unsupported stream URLs. It is included in `make check`. `make check-runtime` exercises missing/invalid dependency metadata, unavailable ABI libraries, existing but unloadable dylibs, omitted library families, and successful native library loading using isolated app metadata. It is included in `make check`; no installed FFmpeg files are modified. -`make check-media` exercises actual playback through the app model, idempotent play/pause, paused seeking and start-boundary clamping, non-seekable state transitions, immediate hiding of a stale buffered snapshot on accepted close, and Now Playing cleanup on close/error/shutdown. It also checks default-off buffer readout, persisted opt-in/explicit disable, and retained-range independence from the display preference. It is included in `make check` and uses isolated playback history with muted audio. Native MediaPlayer command dispatch was also smoke-checked; physical media-key routing and macOS's choice among competing media apps still need a keyboard check in the running app. +`make check-media` exercises actual playback through the app model, idempotent play/pause, paused seeking and start-boundary clamping, non-seekable state transitions, immediate hiding of a stale buffered snapshot on accepted close, and Now Playing cleanup on close/error/shutdown. It also checks default-off/configured settings, session-only overrides, and retained-range independence from the display preference. It is included in `make check` and uses isolated playback history with muted audio. Native MediaPlayer command dispatch was also smoke-checked; physical media-key routing and macOS's choice among competing media apps still need a keyboard check in the running app. -`make check-startup` launches an isolated app bundle through LaunchServices using the production bootstrap and a check-only player library with actual native playback. It verifies the registered app PID/bundle identity, then runs the real CLI against the production SwiftUI scene and delegate: cold/default reuse, independent empty/local/YouTube launches, exactly one window per PID, media delivered only to the new process, original playback retained, help without launch, and warm default replacement. YouTube uses gated-capable loopback media and actual offline yt-dlp selection; that portion requires yt-dlp and a supported JS runtime but no external network. It is included in `make check`; windows are transparent, audio muted, and bundle/preferences isolated. It does not require Vorssaint or touch real playback history. Finder/Space behavior and Vorssaint discovery still need a check in the visible app. +`make check-startup` launches an isolated app bundle through LaunchServices using the production bootstrap and a check-only player library with actual native playback. It verifies the registered app PID/bundle identity, then runs the real CLI against the production startup/SwiftUI scene/delegate: missing-config defaults without creation, configured controls, malformed-file exit 78, cold/warm `--no-config`, unchanged file bytes and existing session settings, cold/default reuse, independent empty/local/YouTube launches, exactly one window per PID, media delivered only to the new process, original playback retained, help without launch, and warm default replacement. YouTube uses loopback media and actual offline yt-dlp selection; that portion requires yt-dlp and a supported JS runtime but no external network. It is included in `make check`; windows are transparent, audio muted, and configuration/bundle/history isolated. It does not require Vorssaint or touch real playback history. Finder/Space behavior and Vorssaint discovery still need a check in the visible app. Continuous audio uses resampled sample counts for its output timeline; packet timestamp quantization and rounded resampler delay must not insert silence or drop samples between blocks. diff --git a/Sources/App/CompactWindow.swift b/Sources/App/CompactWindow.swift index a6aa7f5..f70e569 100644 --- a/Sources/App/CompactWindow.swift +++ b/Sources/App/CompactWindow.swift @@ -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() } diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index cd0143c..4cd952a 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -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? 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 diff --git a/Sources/CLI/PlayerCommand.swift b/Sources/CLI/PlayerCommand.swift index 88837af..fd48034 100644 --- a/Sources/CLI/PlayerCommand.swift +++ b/Sources/CLI/PlayerCommand.swift @@ -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 { diff --git a/Sources/CLI/TerebiCLI.swift b/Sources/CLI/TerebiCLI.swift index a6d1085..a161a27 100644 --- a/Sources/CLI/TerebiCLI.swift +++ b/Sources/CLI/TerebiCLI.swift @@ -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. diff --git a/Sources/Configuration/PlayerConfiguration.swift b/Sources/Configuration/PlayerConfiguration.swift new file mode 100644 index 0000000..8bfa1e5 --- /dev/null +++ b/Sources/Configuration/PlayerConfiguration.swift @@ -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 } + } +}