feat: add a native CLI launcher for local and YouTube playback

Build .build/terebi beside the app. Parse a single local file or YouTube URL, --url, --, and help; reject invalid commands before GUI launch. Resolve the adjacent app through symlinks and launch through NSWorkspace, reusing its process/window and saved progress.

Launch the app without media before delivering URL events. Raw media argv suppress SwiftUI's initial singleton window. Own warm document and internet Apple events because SwiftUI's default external-event routing closes that window; retain initial Finder document delivery to create it.

Verified actual CLI 0/64/69 exit behavior, PATH/symlink discovery from another directory, cold local/YouTube playback, warm local/YouTube replacement in the same native window/process, resume, Finder-style cold launch, and make all check.
This commit is contained in:
2026-10-11 00:04:37 +02:00
parent 0411064f0c
commit 230949bb95
6 changed files with 204 additions and 3 deletions
+20
View File
@@ -1115,11 +1115,31 @@ private struct WindowChrome: NSViewRepresentable {
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationWillFinishLaunching(_ notification: Notification) {
// Own external opens: SwiftUI's document routing can close the singleton Window.
let events = NSAppleEventManager.shared()
events.setEventHandler(self, andSelector: #selector(openInternetURL(_:withReplyEvent:)),
forEventClass: AEEventClass(kInternetEventClass), andEventID: AEEventID(kAEGetURL))
}
@objc private func openInternetURL(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) {
guard let text = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue else { return }
PlayerModel.shared.openYouTube(text)
}
@objc private func openDocuments(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) {
guard let url = event.paramDescriptor(forKeyword: keyDirectObject)?.atIndex(1)?.fileURLValue else { return }
PlayerModel.shared.open(url)
}
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
NSAppleEventManager.shared().setEventHandler(self, andSelector: #selector(openDocuments(_:withReplyEvent:)),
forEventClass: AEEventClass(kCoreEventClass), andEventID: AEEventID(kAEOpenDocuments))
}
// Let SwiftUI create the initial window for a Finder/document launch.
func application(_ application: NSApplication, open urls: [URL]) {
if let url = urls.first { PlayerModel.shared.open(url) }
}
+69
View File
@@ -0,0 +1,69 @@
import Foundation
enum PlayerCommand {
case help
case launch(URL?)
static let usage = """
Usage: terebi [FILE | YOUTUBE_URL]
terebi --url YOUTUBE_URL
terebi -- FILE
terebi --help
Open one local media file or YouTube video and start playback, resuming saved
video progress when available. With no arguments, open or activate the player.
Quote paths and URLs containing spaces or shell metacharacters.
--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.
Reuses the running player when available. The command exits once macOS accepts
the launch; playback and extraction errors appear in the player window.
"""
static func parse(_ arguments: [String], directory: URL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)) throws -> PlayerCommand {
if arguments.isEmpty { return .launch(nil) }
if arguments == ["--help"] || arguments == ["-h"] { return .help }
if arguments.first == "--url" {
guard arguments.count == 2 else { throw ArgumentError("--url requires exactly one YouTube URL.") }
return .launch(try YouTubeResolver.validateURL(arguments[1]))
}
var inputs = arguments
let filenameOnly = inputs.first == "--"
if filenameOnly {
inputs.removeFirst()
} else if inputs.first?.hasPrefix("-") == true {
throw ArgumentError("Unknown option. Use -- before filenames beginning with '-'.")
}
guard inputs.count == 1, let input = inputs.first, !input.isEmpty else {
throw ArgumentError("Expected exactly one file or YouTube URL.")
}
let file: URL
if !filenameOnly && input.contains("://") {
guard let url = URL(string: input) else { throw ArgumentError("Invalid media URL.") }
if url.isFileURL {
guard url.host == nil || url.host == "" || url.host == "localhost" else {
throw ArgumentError("File URLs must refer to this machine.")
}
file = url
} else {
return .launch(try YouTubeResolver.validateURL(input))
}
} else {
let base = directory.hasDirectoryPath ? directory : URL(fileURLWithPath: directory.path, isDirectory: true)
file = URL(fileURLWithPath: input, relativeTo: base).absoluteURL
}
guard (try? file.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true,
FileManager.default.isReadableFile(atPath: file.path) else {
throw ArgumentError("File does not exist or is not a readable regular file.")
}
return .launch(file.standardizedFileURL)
}
private struct ArgumentError: LocalizedError {
let message: String
init(_ message: String) { self.message = message }
var errorDescription: String? { message }
}
}
+40
View File
@@ -0,0 +1,40 @@
import AppKit
import Darwin
@main
@MainActor
enum TerebiCLI {
static func main() async {
let command: PlayerCommand
do {
command = try PlayerCommand.parse(Array(CommandLine.arguments.dropFirst()))
} catch {
fail(error.localizedDescription, code: 64)
}
switch command {
case .help:
print(PlayerCommand.usage)
case .launch(let url):
guard let executable = Bundle.main.executableURL else {
fail("Could not locate the CLI executable.", code: 69)
}
let app = executable.resolvingSymlinksInPath().deletingLastPathComponent()
.appendingPathComponent("terebi.app", isDirectory: true)
let configuration = NSWorkspace.OpenConfiguration()
do {
// Launch without media first so SwiftUI creates its singleton window.
_ = try await NSWorkspace.shared.openApplication(at: app, configuration: configuration)
if let url {
_ = try await NSWorkspace.shared.open([url], withApplicationAt: app, configuration: configuration)
}
} catch {
fail("Could not launch the player: \(error.localizedDescription)", code: 69)
}
}
}
private static func fail(_ message: String, code: Int32) -> Never {
FileHandle.standardError.write(Data("terebi: \(message)\n".utf8))
exit(code)
}
}