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:
@@ -0,0 +1,41 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct PlayerCommandChecks {
|
||||||
|
static func require(_ condition: Bool, _ message: String) {
|
||||||
|
if !condition { fatalError("FAIL: " + message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func main() throws {
|
||||||
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("terebi.cli-check.\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
|
let movie = directory.appendingPathComponent("video with spaces.mp4")
|
||||||
|
let optionFile = directory.appendingPathComponent("--help")
|
||||||
|
let colonFile = directory.appendingPathComponent("part:1.mp4")
|
||||||
|
for file in [movie, optionFile, colonFile] { try Data().write(to: file) }
|
||||||
|
|
||||||
|
func opened(_ arguments: [String]) throws -> URL? {
|
||||||
|
guard case .launch(let url) = try PlayerCommand.parse(arguments, directory: directory) else {
|
||||||
|
fatalError("FAIL: expected a launch command")
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
require(try opened([movie.lastPathComponent]) == movie, "relative paths resolve against invocation directory, preserving spaces")
|
||||||
|
require(try opened([movie.absoluteString]) == movie, "encoded local file URLs preserve filename identity")
|
||||||
|
require(try opened(["--", "--help"]) == optionFile, "option terminator allows filenames that look like commands")
|
||||||
|
require(try opened([colonFile.path]) == colonFile, "colons in filesystem paths are not URL schemes")
|
||||||
|
let canonical = URL(string: "https://www.youtube.com/watch?v=jNQXAC9IVRw")!
|
||||||
|
require(try opened(["https://youtu.be/jNQXAC9IVRw"]) == canonical, "YouTube positional URLs canonicalize for resume identity")
|
||||||
|
|
||||||
|
for arguments in [["--url"], ["--url", canonical.absoluteString, movie.path], ["--"],
|
||||||
|
[movie.path, movie.path], ["--unknown"], ["missing.mp4"], [directory.path],
|
||||||
|
["file://remote-host/video.mp4"], ["https://example.com/video.mp4"]] {
|
||||||
|
do {
|
||||||
|
_ = try PlayerCommand.parse(arguments, directory: directory)
|
||||||
|
fatalError("FAIL: accepted an invalid command")
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
print("Player command checks passed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,8 +10,8 @@ OBJECTS := .build/player.o .build/Output.o
|
|||||||
SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/CompactWindow.swift Sources/App/YouTubeResolver.swift Sources/App/PlaybackProgress.swift
|
SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/CompactWindow.swift Sources/App/YouTubeResolver.swift Sources/App/PlaybackProgress.swift
|
||||||
APP := .build/terebi.app
|
APP := .build/terebi.app
|
||||||
|
|
||||||
.PHONY: all run check check-fixtures check-audio check-window check-youtube check-network check-progress
|
.PHONY: all run check check-fixtures check-audio check-window check-youtube check-network check-progress check-cli
|
||||||
all: $(APP)/Contents/MacOS/terebi $(APP)/Contents/Info.plist
|
all: .build/terebi $(APP)/Contents/Info.plist
|
||||||
|
|
||||||
.build:
|
.build:
|
||||||
mkdir -p $@
|
mkdir -p $@
|
||||||
@@ -30,13 +30,17 @@ $(APP)/Contents/Info.plist: Resources/Info.plist
|
|||||||
mkdir -p $(APP)/Contents
|
mkdir -p $(APP)/Contents
|
||||||
cp $< $@
|
cp $< $@
|
||||||
|
|
||||||
|
.build/terebi: Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift $(APP)/Contents/MacOS/terebi $(APP)/Contents/Info.plist Makefile
|
||||||
|
rm -f $@
|
||||||
|
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O -g Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift -framework AppKit -o $@
|
||||||
|
|
||||||
run: all
|
run: all
|
||||||
open $(APP)
|
open $(APP)
|
||||||
|
|
||||||
.build/playback-check: Checks/playback.mm $(OBJECTS) Makefile
|
.build/playback-check: Checks/playback.mm $(OBJECTS) Makefile
|
||||||
$(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@
|
$(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@
|
||||||
|
|
||||||
check: .build/playback-check check-fixtures check-audio check-window check-youtube check-network check-progress
|
check: .build/playback-check check-fixtures check-audio check-window check-youtube check-network check-progress check-cli
|
||||||
.build/playback-check .build/fixtures
|
.build/playback-check .build/fixtures
|
||||||
|
|
||||||
check-fixtures:
|
check-fixtures:
|
||||||
@@ -96,3 +100,9 @@ check-network: .build/network-playback-check check-fixtures
|
|||||||
|
|
||||||
check-progress: .build/playback-progress-check
|
check-progress: .build/playback-progress-check
|
||||||
.build/playback-progress-check
|
.build/playback-progress-check
|
||||||
|
|
||||||
|
.build/player-command-check: Checks/player-command.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Makefile | .build
|
||||||
|
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O -g Checks/player-command.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift -framework Foundation -o $@
|
||||||
|
|
||||||
|
check-cli: .build/player-command-check
|
||||||
|
.build/player-command-check
|
||||||
|
|||||||
@@ -16,6 +16,24 @@ The local app is `.build/terebi.app`. It links the installed FFmpeg libraries; i
|
|||||||
|
|
||||||
Open a local file with **⌘O**, drop it onto the window, or use Finder's **Open With**. Successfully opened files start playback automatically, including when replacing the current file. **⇧⌘W** closes the file; closing the window quits and joins the worker.
|
Open a local file with **⌘O**, drop it onto the window, or use Finder's **Open With**. Successfully opened files start playback automatically, including when replacing the current file. **⇧⌘W** closes the file; closing the window quits and joins the worker.
|
||||||
|
|
||||||
|
## Command line
|
||||||
|
|
||||||
|
`make` also builds `.build/terebi`, a native CLI launcher:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.build/terebi "/path/to/video.mp4"
|
||||||
|
.build/terebi 'https://youtu.be/jNQXAC9IVRw'
|
||||||
|
.build/terebi --url 'https://www.youtube.com/watch?v=jNQXAC9IVRw'
|
||||||
|
.build/terebi -- -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. 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, and uses macOS application/URL delivery to reuse the running player and its existing window. 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. Relaunch an older running build once to load the new external-open handling.
|
||||||
|
|
||||||
|
Help exits with status **0** without opening the GUI. Invalid arguments, unsupported URLs, missing files, and directories report to stderr and exit **64**; 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.
|
||||||
|
|
||||||
## YouTube
|
## YouTube
|
||||||
|
|
||||||
YouTube playback optionally requires [yt-dlp](https://github.com/yt-dlp/yt-dlp), its EJS components, and a supported JavaScript runtime. Homebrew provides the extractor and its components; Deno is recommended:
|
YouTube playback optionally requires [yt-dlp](https://github.com/yt-dlp/yt-dlp), its EJS components, and a supported JavaScript runtime. Homebrew provides the extractor and its components; Deno is recommended:
|
||||||
@@ -80,6 +98,7 @@ Only implemented actions have controls; there are no placeholder playlist, subti
|
|||||||
- `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/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/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, magnetic edge snapping, and its persisted preference.
|
||||||
- `Sources/App/YouTubeResolver.swift`: YouTube URL and metadata validation, external yt-dlp/runtime discovery, SDR stream selection and headers, bounded nonblocking process output, sanitized extraction errors, and request-local cancellation.
|
- `Sources/App/YouTubeResolver.swift`: YouTube URL and metadata validation, external yt-dlp/runtime discovery, SDR stream selection 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.
|
||||||
- `Makefile`: compiler/linker commands and app bundle construction. No generated Xcode project or additional build dependency.
|
- `Makefile`: compiler/linker commands and app bundle construction. No generated Xcode project or additional build dependency.
|
||||||
|
|
||||||
## Runnable check
|
## Runnable check
|
||||||
@@ -100,6 +119,8 @@ Generates short fixtures and exercises the real playback interface and native ou
|
|||||||
|
|
||||||
`make check-progress` verifies persisted fractional positions, independent local/YouTube identities, symlink aliases, completion clearing, invalid-position rejection, and isolated preference domains. It is included in `make check` and does not touch real playback history.
|
`make check-progress` verifies persisted fractional positions, independent local/YouTube identities, symlink aliases, completion clearing, invalid-position rejection, and isolated preference domains. 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, argument cardinality, unknown options, missing files/directories, non-local file URLs, and unsupported stream URLs. It is included in `make check`.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
Volume persists across open, seek, and close. Live gain changes ramp over five milliseconds without callback allocation or locking; a stopped output adopts the requested gain before playback starts, so muted startup cannot leak audio.
|
Volume persists across open, seek, and close. Live gain changes ramp over five milliseconds without callback allocation or locking; a stopped output adopts the requested gain before playback starts, so muted startup cannot leak audio.
|
||||||
|
|||||||
@@ -1115,11 +1115,31 @@ private struct WindowChrome: NSViewRepresentable {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
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) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
NSApp.setActivationPolicy(.regular)
|
NSApp.setActivationPolicy(.regular)
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
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]) {
|
func application(_ application: NSApplication, open urls: [URL]) {
|
||||||
if let url = urls.first { PlayerModel.shared.open(url) }
|
if let url = urls.first { PlayerModel.shared.open(url) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user