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:
2026-10-11 01:17:14 +02:00
parent 230949bb95
commit c6d2958744
6 changed files with 245 additions and 73 deletions
+34
View File
@@ -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")
}
}
+113 -70
View File
@@ -1,108 +1,151 @@
CXX := xcrun clang++ CXX := xcrun clang++
SWIFT := xcrun swiftc SWIFT := xcrun swiftc
FFMPEG := libavformat libavcodec libavutil libswresample libswscale FFMPEG := libavformat libavcodec libavutil libswresample libswscale
FF_CFLAGS := $(shell pkg-config --cflags $(FFMPEG)) FF_CFLAGS = $(shell pkg-config --cflags $(FFMPEG))
FF_LIBS := $(shell pkg-config --libs $(FFMPEG)) FF_LIBS = $(shell pkg-config --libs $(FFMPEG))
ARCH := $(shell uname -m) 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 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 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 .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: .build/terebi $(APP)/Contents/Info.plist all: check-dependencies $(BUILD_DIR)/terebi $(PLAYER) $(BOOTSTRAP) $(MANIFEST) $(APP)/Contents/Info.plist
.build: check-dependencies:
mkdir -p $@ @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 $@ $(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 $@ $(CXX) $(CXXFLAGS) -fobjc-arc -c $< -o $@
$(APP)/Contents/MacOS/terebi: $(SWIFT_SOURCES) $(OBJECTS) Makefile $(PLAYER): $(SWIFT_SOURCES) $(OBJECTS) Makefile | check-dependencies
mkdir -p $(APP)/Contents/MacOS 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 $@ $(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 $(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 $(BUILD_DIR)/terebi: $(CLI_SOURCES) $(PLAYER) $(BOOTSTRAP) $(MANIFEST) $(APP)/Contents/Info.plist Makefile | check-dependencies
rm -f $@ 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 $@ $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) $(CLI_SOURCES) -framework AppKit -o $@
run: all 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 $@ $(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 check: check-dependencies $(BUILD_DIR)/playback-check check-fixtures check-audio check-window check-youtube check-network check-progress check-cli check-runtime
.build/playback-check .build/fixtures $(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures
check-fixtures: check-fixtures: | check-dependencies
mkdir -p .build/fixtures 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/fixtures/av.mp4 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/fixtures/av.mp4 -an -c:v copy .build/fixtures/video.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/fixtures/av.mp4 -an -vf setsar=3/2 -c:v mpeg4 .build/fixtures/anamorphic.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/fixtures/av.mp4 -vn -c:a pcm_s16le .build/fixtures/audio.wav 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. # 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=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/fixtures/hdr.mkv 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 $@ $(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 $(BUILD_DIR)/audio-continuity-check: Checks/audio-continuity.cpp $(BUILD_DIR)/player.o $(BUILD_DIR)/Output-check.o Makefile | check-dependencies
$(CXX) $(CXXFLAGS) $< .build/player.o .build/Output-check.o $(FF_LIBS) $(FRAMEWORKS) -o $@ $(CXX) $(CXXFLAGS) $< $(BUILD_DIR)/player.o $(BUILD_DIR)/Output-check.o $(FF_LIBS) $(FRAMEWORKS) -o $@
check-audio: .build/audio-continuity-check check-audio: $(BUILD_DIR)/audio-continuity-check
mkdir -p .build/fixtures 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/fixtures/tone441.wav 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/fixtures/tone480.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/fixtures/tone480.wav -c:v mpeg4 -g 48 -bf 2 -c:a copy .build/fixtures/tone-step.mov 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/fixtures/tone441.wav -c:a copy .build/fixtures/tone441.mka 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/fixtures/tone441.wav -c:a aac .build/fixtures/tone-aac.m4a 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/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_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/fixtures/tone441.wav -ar 48000 -ac 2 -f f32le .build/fixtures/tone441.wav.f32 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/fixtures/tone480.wav -ar 48000 -ac 2 -f f32le .build/fixtures/tone480.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/fixtures/tone441.mka -ar 48000 -ac 2 -f f32le .build/fixtures/tone441.mka.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/fixtures/tone-aac.m4a -ar 48000 -ac 2 -f f32le .build/fixtures/tone-aac.m4a.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/audio-continuity-check .build/fixtures $(BUILD_DIR)/audio-continuity-check $(BUILD_DIR)/fixtures
.build/compact-window-check: Checks/compact-window.swift Sources/App/CompactWindow.swift Makefile | .build $(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 -g Checks/compact-window.swift Sources/App/CompactWindow.swift -framework AppKit -o $@ $(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 check-window: $(BUILD_DIR)/compact-window-check
.build/compact-window-check $(BUILD_DIR)/compact-window-check
.build/youtube-resolver-check: Checks/youtube-resolver.swift Sources/App/YouTubeResolver.swift Makefile | .build $(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 -g Checks/youtube-resolver.swift Sources/App/YouTubeResolver.swift -framework Foundation -o $@ $(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 check-youtube: $(BUILD_DIR)/youtube-resolver-check
.build/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 $@ $(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@
check-network: .build/network-playback-check check-fixtures check-network: $(BUILD_DIR)/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_DIR)/fixtures/frame-step.mp4 -an -c:v copy $(BUILD_DIR)/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 -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/fixtures/streaming.mp4 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/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 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/network-playback-check .build/fixtures 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 $(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 -g Checks/playback-progress.swift Sources/App/PlaybackProgress.swift -framework Foundation -o $@ $(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 check-progress: $(BUILD_DIR)/playback-progress-check
.build/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 $(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 -g Checks/player-command.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift -framework Foundation -o $@ $(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 check-cli: $(BUILD_DIR)/player-command-check
.build/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)"
+22 -3
View File
@@ -12,10 +12,26 @@ make
make run 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. 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 ## Command line
`make` also builds `.build/terebi`, a native CLI launcher: `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. 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 ## 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/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. - `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 ## 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-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. 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.
+34
View File
@@ -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)
}
}
+3
View File
@@ -20,6 +20,9 @@ enum TerebiCLI {
} }
let app = executable.resolvingSymlinksInPath().deletingLastPathComponent() let app = executable.resolvingSymlinksInPath().deletingLastPathComponent()
.appendingPathComponent("terebi.app", isDirectory: true) .appendingPathComponent("terebi.app", isDirectory: true)
if let error = FFmpegRuntime.error(for: app) {
fail(error, code: 69)
}
let configuration = NSWorkspace.OpenConfiguration() let configuration = NSWorkspace.OpenConfiguration()
do { do {
// Launch without media first so SwiftUI creates its singleton window. // Launch without media first so SwiftUI creates its singleton window.
+39
View File
@@ -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
}
}