feat: install release builds and report missing FFmpeg
Add make release and make install PREFIX=/Users/valdemar/.local. Keep optimized no-debug release artifacts separate; install adjacent CLI/app under lib/terebi and link bin/terebi. Preflight FFmpeg/pkg-config before compiling or installing, including cached builds. Record the player's exact linked FFmpeg ABI paths. AppKit-only CLI/bootstrap validate native loadability and transitive dependencies before entering the FFmpeg-linked terebi-player. CLI fails with status 69 and remediation; Finder bootstrap shows a native alert and execs the player without losing initial events or process identity. Verified staged installation with spaces, reinstall preservation, absent build dependencies, missing/corrupt/transitively broken dylibs, real modal error surface, installed CLI and Finder startup across exec, local/YouTube resume and window reuse, and full make check.
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
struct FFmpegRuntimeChecks {
|
||||
static func require(_ condition: Bool, _ message: String) {
|
||||
if !condition { fatalError("FAIL: " + message) }
|
||||
}
|
||||
|
||||
static func main() throws {
|
||||
let realApp = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("terebi.ffmpeg-check.\(UUID().uuidString)", isDirectory: true)
|
||||
let app = directory.appendingPathComponent("terebi.app", isDirectory: true)
|
||||
let resources = app.appendingPathComponent("Contents/Resources", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: resources, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let manifest = resources.appendingPathComponent("ffmpeg-libraries.txt")
|
||||
let required = try String(contentsOf: realApp.appendingPathComponent("Contents/Resources/ffmpeg-libraries.txt"), encoding: .utf8)
|
||||
.split(separator: "\n").map(String.init)
|
||||
|
||||
require(FFmpegRuntime.error(for: app) != nil, "missing dependency metadata blocks startup")
|
||||
var unavailable = required
|
||||
let family = URL(fileURLWithPath: required[0]).lastPathComponent.split(separator: ".")[0]
|
||||
unavailable[0] = directory.appendingPathComponent("\(family).999999.dylib").path
|
||||
try (unavailable.joined(separator: "\n") + "\n").write(to: manifest, atomically: true, encoding: .utf8)
|
||||
require(FFmpegRuntime.error(for: app) != nil, "an unavailable ABI library blocks startup")
|
||||
try Data("not a dynamic library".utf8).write(to: URL(fileURLWithPath: unavailable[0]))
|
||||
require(FFmpegRuntime.error(for: app) != nil, "an existing but unloadable library also blocks startup")
|
||||
try Array(repeating: required[0], count: 5).joined(separator: "\n").write(to: manifest, atomically: true, encoding: .utf8)
|
||||
require(FFmpegRuntime.error(for: app) != nil, "duplicated entries cannot omit required library families")
|
||||
try (required.joined(separator: "\n") + "\n").write(to: manifest, atomically: true, encoding: .utf8)
|
||||
require(FFmpegRuntime.error(for: app) == nil, "all recorded native libraries load successfully")
|
||||
print("FFmpeg runtime checks passed")
|
||||
}
|
||||
}
|
||||
@@ -1,108 +1,151 @@
|
||||
CXX := xcrun clang++
|
||||
SWIFT := xcrun swiftc
|
||||
FFMPEG := libavformat libavcodec libavutil libswresample libswscale
|
||||
FF_CFLAGS := $(shell pkg-config --cflags $(FFMPEG))
|
||||
FF_LIBS := $(shell pkg-config --libs $(FFMPEG))
|
||||
FF_CFLAGS = $(shell pkg-config --cflags $(FFMPEG))
|
||||
FF_LIBS = $(shell pkg-config --libs $(FFMPEG))
|
||||
ARCH := $(shell uname -m)
|
||||
CXXFLAGS := -std=c++20 -mmacosx-version-min=15.0 -O2 -g -Wall -Wextra -Wno-unused-parameter $(FF_CFLAGS)
|
||||
BUILD_DIR ?= .build
|
||||
DEBUG_FLAGS ?= -g
|
||||
PREFIX ?= $(HOME)/.local
|
||||
CXXFLAGS = -std=c++20 -mmacosx-version-min=15.0 -O2 $(DEBUG_FLAGS) -Wall -Wextra -Wno-unused-parameter $(FF_CFLAGS)
|
||||
FRAMEWORKS := -framework AppKit -framework AudioToolbox -framework CoreAudio -framework Metal -framework MetalKit -framework QuartzCore
|
||||
OBJECTS := .build/player.o .build/Output.o
|
||||
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
|
||||
APP := .build/terebi.app
|
||||
RUNTIME_SOURCE := Sources/Launcher/FFmpegRuntime.swift
|
||||
CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift $(RUNTIME_SOURCE)
|
||||
APP := $(BUILD_DIR)/terebi.app
|
||||
PLAYER := $(APP)/Contents/MacOS/terebi-player
|
||||
BOOTSTRAP := $(APP)/Contents/MacOS/terebi
|
||||
MANIFEST := $(APP)/Contents/Resources/ffmpeg-libraries.txt
|
||||
|
||||
.PHONY: all run check check-fixtures check-audio check-window check-youtube check-network check-progress check-cli
|
||||
all: .build/terebi $(APP)/Contents/Info.plist
|
||||
.PHONY: all release install check-dependencies run check check-fixtures check-audio check-window check-youtube check-network check-progress check-cli check-runtime
|
||||
all: check-dependencies $(BUILD_DIR)/terebi $(PLAYER) $(BOOTSTRAP) $(MANIFEST) $(APP)/Contents/Info.plist
|
||||
|
||||
.build:
|
||||
mkdir -p $@
|
||||
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; }
|
||||
@pkg-config --exists $(FFMPEG) || { printf '%s\n' 'Error: FFmpeg development libraries are unavailable. Run: brew install ffmpeg pkg-config' 'If FFmpeg is already installed, check PKG_CONFIG_PATH, then rebuild/reinstall terebi.' >&2; exit 1; }
|
||||
|
||||
.build/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
|
||||
release:
|
||||
$(MAKE) BUILD_DIR=.build/release DEBUG_FLAGS= all
|
||||
|
||||
install: release
|
||||
mkdir -p "$(PREFIX)/lib/terebi" "$(PREFIX)/bin"
|
||||
rm -f "$(PREFIX)/lib/terebi/terebi"
|
||||
rm -rf "$(PREFIX)/lib/terebi/terebi.app"
|
||||
cp ".build/release/terebi" "$(PREFIX)/lib/terebi/terebi"
|
||||
cp -R ".build/release/terebi.app" "$(PREFIX)/lib/terebi/terebi.app"
|
||||
ln -sfn "$$(cd "$(PREFIX)" && pwd)/lib/terebi/terebi" "$(PREFIX)/bin/terebi"
|
||||
|
||||
$(BUILD_DIR):
|
||||
mkdir -p "$@"
|
||||
|
||||
$(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/Output.o: Sources/macOS/Output.mm Sources/Core/output.hpp Makefile | .build
|
||||
$(BUILD_DIR)/Output.o: Sources/macOS/Output.mm Sources/Core/output.hpp Makefile | $(BUILD_DIR) check-dependencies
|
||||
$(CXX) $(CXXFLAGS) -fobjc-arc -c $< -o $@
|
||||
|
||||
$(APP)/Contents/MacOS/terebi: $(SWIFT_SOURCES) $(OBJECTS) Makefile
|
||||
mkdir -p $(APP)/Contents/MacOS
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O -g -import-objc-header Sources/Core/player_macos.h $(SWIFT_SOURCES) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
|
||||
$(PLAYER): $(SWIFT_SOURCES) $(OBJECTS) Makefile | check-dependencies
|
||||
mkdir -p "$(APP)/Contents/MacOS"
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h $(SWIFT_SOURCES) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
|
||||
|
||||
$(MANIFEST): $(PLAYER) Makefile
|
||||
mkdir -p "$(APP)/Contents/Resources"
|
||||
@otool -L "$<" | sed -nE 's#^[[:space:]]+(/.*/lib(avformat|avcodec|avutil|swresample|swscale)(\.[^/[:space:]]+)?\.dylib) \(.*#\1#p' > "$@.tmp"; \
|
||||
if [ "$$(wc -l < "$@.tmp" | tr -d ' ')" != 5 ]; then \
|
||||
rm -f "$@.tmp"; \
|
||||
printf '%s\n' 'Error: could not record all five absolute FFmpeg dylib paths from terebi-player. Rebuild with Homebrew FFmpeg: brew install ffmpeg pkg-config' >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
mv "$@.tmp" "$@"
|
||||
|
||||
$(BOOTSTRAP): Sources/App/Bootstrap.swift $(RUNTIME_SOURCE) Makefile | check-dependencies
|
||||
mkdir -p "$(APP)/Contents/MacOS"
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Sources/App/Bootstrap.swift $(RUNTIME_SOURCE) -framework AppKit -o $@
|
||||
|
||||
$(APP)/Contents/Info.plist: Resources/Info.plist
|
||||
mkdir -p $(APP)/Contents
|
||||
cp $< $@
|
||||
mkdir -p "$(APP)/Contents"
|
||||
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 $@
|
||||
$(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 $@
|
||||
|
||||
run: all
|
||||
open $(APP)
|
||||
open "$(APP)"
|
||||
|
||||
.build/playback-check: Checks/playback.mm $(OBJECTS) Makefile
|
||||
$(BUILD_DIR)/playback-check: Checks/playback.mm $(OBJECTS) Makefile | check-dependencies
|
||||
$(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-cli
|
||||
.build/playback-check .build/fixtures
|
||||
check: check-dependencies $(BUILD_DIR)/playback-check check-fixtures check-audio check-window check-youtube check-network check-progress check-cli check-runtime
|
||||
$(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures
|
||||
|
||||
check-fixtures:
|
||||
mkdir -p .build/fixtures
|
||||
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=320x180:rate=24 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 4 -c:v mpeg4 -bf 2 -c:a aac .build/fixtures/av.mp4
|
||||
ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/av.mp4 -an -c:v copy .build/fixtures/video.mp4
|
||||
ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/av.mp4 -an -vf setsar=3/2 -c:v mpeg4 .build/fixtures/anamorphic.mp4
|
||||
ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/av.mp4 -vn -c:a pcm_s16le .build/fixtures/audio.wav
|
||||
check-fixtures: | check-dependencies
|
||||
mkdir -p $(BUILD_DIR)/fixtures
|
||||
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=320x180:rate=24 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 4 -c:v mpeg4 -bf 2 -c:a aac $(BUILD_DIR)/fixtures/av.mp4
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/av.mp4 -an -c:v copy $(BUILD_DIR)/fixtures/video.mp4
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/av.mp4 -an -vf setsar=3/2 -c:v mpeg4 $(BUILD_DIR)/fixtures/anamorphic.mp4
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/av.mp4 -vn -c:a pcm_s16le $(BUILD_DIR)/fixtures/audio.wav
|
||||
# Literal frame PTS: 0,.1,.2,.3,.4,.65,.9,1.15,1.4,1.5,1.6,1.7; reordered B-frames across short GOPs.
|
||||
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=320x180:rate=10:duration=1.2 -f lavfi -i sine=frequency=440:sample_rate=44100:duration=2 -vf "settb=1/1000,setpts='if(lt(N,5),N*100,if(lt(N,9),400+(N-4)*250,1400+(N-8)*100))'" -fps_mode vfr -enc_time_base:v 1:1000 -c:v mpeg4 -g 4 -bf 2 -video_track_timescale 1000 -c:a aac .build/fixtures/frame-step.mp4
|
||||
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=160x90:rate=12 -t 1 -vf 'format=yuv420p10le,setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc' -c:v ffv1 .build/fixtures/hdr.mkv
|
||||
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=320x180:rate=10:duration=1.2 -f lavfi -i sine=frequency=440:sample_rate=44100:duration=2 -vf "settb=1/1000,setpts='if(lt(N,5),N*100,if(lt(N,9),400+(N-4)*250,1400+(N-8)*100))'" -fps_mode vfr -enc_time_base:v 1:1000 -c:v mpeg4 -g 4 -bf 2 -video_track_timescale 1000 -c:a aac $(BUILD_DIR)/fixtures/frame-step.mp4
|
||||
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=160x90:rate=12 -t 1 -vf 'format=yuv420p10le,setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc' -c:v ffv1 $(BUILD_DIR)/fixtures/hdr.mkv
|
||||
|
||||
.build/Output-check.o: Sources/macOS/Output.mm Sources/Core/output.hpp Makefile | .build
|
||||
$(BUILD_DIR)/Output-check.o: Sources/macOS/Output.mm Sources/Core/output.hpp Makefile | $(BUILD_DIR) check-dependencies
|
||||
$(CXX) $(CXXFLAGS) -fobjc-arc -DPLAYER_AUDIO_CHECK -c $< -o $@
|
||||
|
||||
.build/audio-continuity-check: Checks/audio-continuity.cpp .build/player.o .build/Output-check.o Makefile
|
||||
$(CXX) $(CXXFLAGS) $< .build/player.o .build/Output-check.o $(FF_LIBS) $(FRAMEWORKS) -o $@
|
||||
$(BUILD_DIR)/audio-continuity-check: Checks/audio-continuity.cpp $(BUILD_DIR)/player.o $(BUILD_DIR)/Output-check.o Makefile | check-dependencies
|
||||
$(CXX) $(CXXFLAGS) $< $(BUILD_DIR)/player.o $(BUILD_DIR)/Output-check.o $(FF_LIBS) $(FRAMEWORKS) -o $@
|
||||
|
||||
check-audio: .build/audio-continuity-check
|
||||
mkdir -p .build/fixtures
|
||||
ffmpeg -hide_banner -loglevel error -y -f lavfi -i sine=frequency=440:sample_rate=44100 -t 2 -c:a pcm_s16le .build/fixtures/tone441.wav
|
||||
ffmpeg -hide_banner -loglevel error -y -f lavfi -i sine=frequency=440:sample_rate=48000 -t 2 -c:a pcm_s16le .build/fixtures/tone480.wav
|
||||
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=160x90:rate=24:duration=2 -i .build/fixtures/tone480.wav -c:v mpeg4 -g 48 -bf 2 -c:a copy .build/fixtures/tone-step.mov
|
||||
ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/tone441.wav -c:a copy .build/fixtures/tone441.mka
|
||||
ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/tone441.wav -c:a aac .build/fixtures/tone-aac.m4a
|
||||
ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/tone441.wav -af "asetpts='PTS+gte(T,1)*0.25/TB'" -c:a pcm_s16le .build/fixtures/tone-gap.mka
|
||||
ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/tone441.wav -ar 48000 -ac 2 -f f32le .build/fixtures/tone441.wav.f32
|
||||
ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/tone480.wav -ar 48000 -ac 2 -f f32le .build/fixtures/tone480.wav.f32
|
||||
ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/tone441.mka -ar 48000 -ac 2 -f f32le .build/fixtures/tone441.mka.f32
|
||||
ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/tone-aac.m4a -ar 48000 -ac 2 -f f32le .build/fixtures/tone-aac.m4a.f32
|
||||
.build/audio-continuity-check .build/fixtures
|
||||
check-audio: $(BUILD_DIR)/audio-continuity-check
|
||||
mkdir -p $(BUILD_DIR)/fixtures
|
||||
ffmpeg -hide_banner -loglevel error -y -f lavfi -i sine=frequency=440:sample_rate=44100 -t 2 -c:a pcm_s16le $(BUILD_DIR)/fixtures/tone441.wav
|
||||
ffmpeg -hide_banner -loglevel error -y -f lavfi -i sine=frequency=440:sample_rate=48000 -t 2 -c:a pcm_s16le $(BUILD_DIR)/fixtures/tone480.wav
|
||||
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=160x90:rate=24:duration=2 -i $(BUILD_DIR)/fixtures/tone480.wav -c:v mpeg4 -g 48 -bf 2 -c:a copy $(BUILD_DIR)/fixtures/tone-step.mov
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/tone441.wav -c:a copy $(BUILD_DIR)/fixtures/tone441.mka
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/tone441.wav -c:a aac $(BUILD_DIR)/fixtures/tone-aac.m4a
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/tone441.wav -af "asetpts='PTS+gte(T,1)*0.25/TB'" -c:a pcm_s16le $(BUILD_DIR)/fixtures/tone-gap.mka
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/tone441.wav -ar 48000 -ac 2 -f f32le $(BUILD_DIR)/fixtures/tone441.wav.f32
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/tone480.wav -ar 48000 -ac 2 -f f32le $(BUILD_DIR)/fixtures/tone480.wav.f32
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/tone441.mka -ar 48000 -ac 2 -f f32le $(BUILD_DIR)/fixtures/tone441.mka.f32
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/tone-aac.m4a -ar 48000 -ac 2 -f f32le $(BUILD_DIR)/fixtures/tone-aac.m4a.f32
|
||||
$(BUILD_DIR)/audio-continuity-check $(BUILD_DIR)/fixtures
|
||||
|
||||
.build/compact-window-check: Checks/compact-window.swift Sources/App/CompactWindow.swift Makefile | .build
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O -g Checks/compact-window.swift Sources/App/CompactWindow.swift -framework AppKit -o $@
|
||||
$(BUILD_DIR)/compact-window-check: Checks/compact-window.swift Sources/App/CompactWindow.swift Makefile | $(BUILD_DIR) check-dependencies
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/compact-window.swift Sources/App/CompactWindow.swift -framework AppKit -o $@
|
||||
|
||||
check-window: .build/compact-window-check
|
||||
.build/compact-window-check
|
||||
check-window: $(BUILD_DIR)/compact-window-check
|
||||
$(BUILD_DIR)/compact-window-check
|
||||
|
||||
.build/youtube-resolver-check: Checks/youtube-resolver.swift Sources/App/YouTubeResolver.swift Makefile | .build
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O -g Checks/youtube-resolver.swift Sources/App/YouTubeResolver.swift -framework Foundation -o $@
|
||||
$(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 $@
|
||||
|
||||
check-youtube: .build/youtube-resolver-check
|
||||
.build/youtube-resolver-check
|
||||
check-youtube: $(BUILD_DIR)/youtube-resolver-check
|
||||
$(BUILD_DIR)/youtube-resolver-check
|
||||
|
||||
.build/network-playback-check: Checks/network-playback.mm $(OBJECTS) Makefile
|
||||
$(BUILD_DIR)/network-playback-check: Checks/network-playback.mm $(OBJECTS) Makefile | check-dependencies
|
||||
$(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@
|
||||
|
||||
check-network: .build/network-playback-check check-fixtures
|
||||
ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/frame-step.mp4 -an -c:v copy .build/fixtures/frame-step-video.mp4
|
||||
ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/frame-step.mp4 -vn -c:a copy .build/fixtures/frame-step-audio.m4a
|
||||
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=320x180:rate=24 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 30 -c:v mpeg4 -q:v 2 -bf 2 -c:a aac -movflags +faststart .build/fixtures/streaming.mp4
|
||||
ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/av.mp4 -c:v libx264 -g 24 -keyint_min 24 -sc_threshold 0 -c:a aac -hls_time 1 -hls_playlist_type vod -hls_segment_filename .build/fixtures/network-vod-%d.ts .build/fixtures/network-vod.m3u8
|
||||
python3 Checks/streaming.py .build/network-playback-check .build/fixtures
|
||||
check-network: $(BUILD_DIR)/network-playback-check check-fixtures
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/frame-step.mp4 -an -c:v copy $(BUILD_DIR)/fixtures/frame-step-video.mp4
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/frame-step.mp4 -vn -c:a copy $(BUILD_DIR)/fixtures/frame-step-audio.m4a
|
||||
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=320x180:rate=24 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 30 -c:v mpeg4 -q:v 2 -bf 2 -c:a aac -movflags +faststart $(BUILD_DIR)/fixtures/streaming.mp4
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/av.mp4 -c:v libx264 -g 24 -keyint_min 24 -sc_threshold 0 -c:a aac -hls_time 1 -hls_playlist_type vod -hls_segment_filename $(BUILD_DIR)/fixtures/network-vod-%d.ts $(BUILD_DIR)/fixtures/network-vod.m3u8
|
||||
python3 Checks/streaming.py $(BUILD_DIR)/network-playback-check $(BUILD_DIR)/fixtures
|
||||
|
||||
.build/playback-progress-check: Checks/playback-progress.swift Sources/App/PlaybackProgress.swift Makefile | .build
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O -g Checks/playback-progress.swift Sources/App/PlaybackProgress.swift -framework Foundation -o $@
|
||||
$(BUILD_DIR)/playback-progress-check: Checks/playback-progress.swift Sources/App/PlaybackProgress.swift Makefile | $(BUILD_DIR) check-dependencies
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/playback-progress.swift Sources/App/PlaybackProgress.swift -framework Foundation -o $@
|
||||
|
||||
check-progress: .build/playback-progress-check
|
||||
.build/playback-progress-check
|
||||
check-progress: $(BUILD_DIR)/playback-progress-check
|
||||
$(BUILD_DIR)/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 $@
|
||||
$(BUILD_DIR)/player-command-check: Checks/player-command.swift Sources/CLI/PlayerCommand.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/player-command.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift -framework Foundation -o $@
|
||||
|
||||
check-cli: .build/player-command-check
|
||||
.build/player-command-check
|
||||
check-cli: $(BUILD_DIR)/player-command-check
|
||||
$(BUILD_DIR)/player-command-check
|
||||
|
||||
$(BUILD_DIR)/ffmpeg-runtime-check: Checks/ffmpeg-runtime.swift $(RUNTIME_SOURCE) Makefile | $(BUILD_DIR)
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/ffmpeg-runtime.swift $(RUNTIME_SOURCE) -framework Foundation -o $@
|
||||
|
||||
check-runtime: $(BUILD_DIR)/ffmpeg-runtime-check $(MANIFEST)
|
||||
$(BUILD_DIR)/ffmpeg-runtime-check "$(APP)"
|
||||
|
||||
@@ -12,10 +12,26 @@ make
|
||||
make run
|
||||
```
|
||||
|
||||
The local app is `.build/terebi.app`. It links the installed FFmpeg libraries; it is not a standalone distributable bundle. After upgrading FFmpeg, rebuild with `make -B`.
|
||||
The developer app is `.build/terebi.app`. It links the installed FFmpeg libraries; it is not a standalone distributable bundle. After upgrading FFmpeg, rebuild with `make -B` or reinstall with `make -B install`.
|
||||
|
||||
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.
|
||||
|
||||
## Release and installation
|
||||
|
||||
```sh
|
||||
make release # optimized build in .build/release, without -g
|
||||
make install # default PREFIX=$HOME/.local
|
||||
make install PREFIX="/custom/path" # optional installation prefix
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
terebi --help
|
||||
```
|
||||
|
||||
Installation puts the CLI at `~/.local/lib/terebi/terebi` and the app beside it at `~/.local/lib/terebi/terebi.app`; `~/.local/bin/terebi` is a symlink to the CLI. A custom `PREFIX` uses the same `lib/terebi` and `bin` layout. The default needs no sudo. Release artifacts are separate from developer builds, and reinstall replaces the installed app bundle without touching unrelated prefix contents. Close a running player and relaunch after installing an update.
|
||||
|
||||
FFmpeg is still external. Builds and installs—including cached release installs—check `pkg-config` and all five FFmpeg development packages before compiling or copying. Missing dependencies report `brew install ffmpeg pkg-config`.
|
||||
|
||||
At runtime, both the CLI and the app's lightweight bootstrap check the exact FFmpeg dylibs linked by the player, including their transitive dependencies. Missing, incompatible, or broken libraries report `brew install ffmpeg` and explain that library-version changes require rebuilding/reinstalling terebi. The CLI exits **69**; Finder launches show a native error dialog instead of failing in dyld before the UI starts. Missing or invalid dependency metadata also reports rebuild/reinstall. CLI help remains available without FFmpeg.
|
||||
|
||||
## Command line
|
||||
|
||||
`make` also builds `.build/terebi`, a native CLI launcher:
|
||||
@@ -32,7 +48,7 @@ One local file (including a local `file://` URL) or YouTube video URL is accepte
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## YouTube
|
||||
|
||||
@@ -99,7 +115,8 @@ Only implemented actions have controls; there are no placeholder playlist, subti
|
||||
- `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/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.
|
||||
- `Sources/Launcher/FFmpegRuntime.swift` and `Sources/App/Bootstrap.swift`: shared native dependency preflight and the app's FFmpeg-independent entrypoint. The bootstrap execs `terebi-player` without changing its PID/arguments or consuming initial launch events. `Contents/Resources/ffmpeg-libraries.txt` records the payload'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.
|
||||
|
||||
## Runnable check
|
||||
|
||||
@@ -121,6 +138,8 @@ Generates short fixtures and exercises the real playback interface and native ou
|
||||
|
||||
`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`.
|
||||
|
||||
`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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import AppKit
|
||||
import Darwin
|
||||
|
||||
@main
|
||||
@MainActor
|
||||
enum TerebiBootstrap {
|
||||
static func main() {
|
||||
let app = Bundle.main.bundleURL
|
||||
if let error = FFmpegRuntime.error(for: app) {
|
||||
fail(error)
|
||||
}
|
||||
let payload = app.appendingPathComponent("Contents/MacOS/terebi-player")
|
||||
let errorCode = payload.path.withCString { path in
|
||||
_ = execv(path, CommandLine.unsafeArgv)
|
||||
return errno
|
||||
}
|
||||
let detail = String(cString: strerror(errorCode))
|
||||
fail("Could not start the player: \(detail)\n\nPlayer executable: \(payload.path)\nRebuild and reinstall terebi.")
|
||||
}
|
||||
|
||||
private static func fail(_ message: String) -> Never {
|
||||
FileHandle.standardError.write(Data("terebi: \(message)\n".utf8))
|
||||
let application = NSApplication.shared
|
||||
application.setActivationPolicy(.regular)
|
||||
application.activate(ignoringOtherApps: true)
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .critical
|
||||
alert.messageText = "terebi could not start"
|
||||
alert.informativeText = message
|
||||
alert.addButton(withTitle: "Quit")
|
||||
alert.runModal()
|
||||
exit(69)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@ enum TerebiCLI {
|
||||
}
|
||||
let app = executable.resolvingSymlinksInPath().deletingLastPathComponent()
|
||||
.appendingPathComponent("terebi.app", isDirectory: true)
|
||||
if let error = FFmpegRuntime.error(for: app) {
|
||||
fail(error, code: 69)
|
||||
}
|
||||
let configuration = NSWorkspace.OpenConfiguration()
|
||||
do {
|
||||
// Launch without media first so SwiftUI creates its singleton window.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
enum FFmpegRuntime {
|
||||
static func error(for app: URL) -> String? {
|
||||
let manifest = app.appendingPathComponent("Contents/Resources/ffmpeg-libraries.txt")
|
||||
let metadataError = "This app's FFmpeg library metadata is missing or invalid. Rebuild and reinstall terebi."
|
||||
guard let contents = try? String(contentsOf: manifest, encoding: .utf8) else {
|
||||
return metadataError
|
||||
}
|
||||
var paths = contents.components(separatedBy: "\n")
|
||||
if paths.last == "" { paths.removeLast() }
|
||||
let libraries: Set<String> = ["libavformat", "libavcodec", "libavutil", "libswresample", "libswscale"]
|
||||
guard paths.count == libraries.count else { return metadataError }
|
||||
var found = Set<String>()
|
||||
for path in paths {
|
||||
guard path.hasPrefix("/"), !path.contains("\0"), !path.contains("\r") else {
|
||||
return metadataError
|
||||
}
|
||||
let parts = URL(fileURLWithPath: path).lastPathComponent.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard parts.count >= 2, parts.last == "dylib" else { return metadataError }
|
||||
let library = String(parts[0])
|
||||
guard libraries.contains(library),
|
||||
parts.dropFirst().dropLast().allSatisfy({ !$0.isEmpty && $0.utf8.allSatisfy { (48...57).contains($0) } }),
|
||||
found.insert(library).inserted else {
|
||||
return metadataError
|
||||
}
|
||||
}
|
||||
for path in paths {
|
||||
_ = dlerror()
|
||||
guard dlopen(path, RTLD_NOW | RTLD_LOCAL) != nil else {
|
||||
let detail = dlerror().map { String(cString: $0) } ?? "The dynamic loader could not load this library."
|
||||
return "The FFmpeg libraries required by this build are unavailable or broken.\n\nRequired library: \(path)\n\(detail)\n\nInstall FFmpeg with `brew install ffmpeg`. If FFmpeg was upgraded or its library versions changed, rebuild and reinstall terebi."
|
||||
}
|
||||
// Deliberately never dlclose: retain successful loads until exit or bootstrap exec.
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user