fix: preserve app identity through FFmpeg preflight

This commit is contained in:
2026-10-11 02:10:43 +02:00
parent cf24a0fc4f
commit 5f0287f489
7 changed files with 155 additions and 17 deletions
+59
View File
@@ -0,0 +1,59 @@
import json
import os
from pathlib import Path
import plistlib
import selectors
import shutil
import subprocess
import sys
import tempfile
import uuid
app_source, library, fixtures = map(lambda path: Path(path).resolve(), sys.argv[1:])
with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
root = Path(directory)
app = root / "terebi.app"
shutil.copytree(app_source, app)
identifier = "local.personal.terebi.startup-check." + uuid.uuid4().hex
info_path = app / "Contents/Info.plist"
info = plistlib.loads(info_path.read_bytes())
info["CFBundleIdentifier"] = identifier
info_path.write_bytes(plistlib.dumps(info))
shutil.copy2(library, app / "Contents/Frameworks/libterebi-player.dylib")
pipe = root / "result.fifo"
os.mkfifo(pipe)
descriptor = os.open(pipe, os.O_RDONLY | os.O_NONBLOCK)
launch = None
try:
with selectors.DefaultSelector() as selector:
selector.register(descriptor, selectors.EVENT_READ)
launch = subprocess.Popen(
["open", "-g", "-n", "-W", str(app), "--args", str(pipe), str(fixtures)],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
)
if not selector.select(timeout=30):
raise RuntimeError("packaged app did not report its startup state")
result = json.loads(os.read(descriptor, 4096))
stdout, stderr = launch.communicate(timeout=10)
if launch.returncode != 0 or not result["passed"] or result["bundleIdentifier"] != identifier:
raise RuntimeError(f"packaged startup failed: {result}; {stdout}{stderr}")
print("PASS packaged bootstrap: registered app PID/identity, in-process player load, actual playback and Now Playing")
finally:
os.close(descriptor)
subprocess.run(
["xcrun", "swift", "-e",
'import AppKit; for app in NSWorkspace.shared.runningApplications { '
'if app.bundleIdentifier == CommandLine.arguments[1] { app.forceTerminate() } }',
identifier],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
)
if launch is not None and launch.poll() is None:
launch.terminate()
launch.communicate(timeout=5)
subprocess.run(
["/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister", "-u", str(app)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
)
for domain in [identifier, identifier + ".history"]:
subprocess.run(["defaults", "delete", domain], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
+59
View File
@@ -0,0 +1,59 @@
import AppKit
import MediaPlayer
@_cdecl("terebi_app_main")
@MainActor
public func checkAppStartup() {
AppStartupCheck.run()
}
@MainActor
private enum AppStartupCheck {
static var model: PlayerModel?
static var timer: Timer?
static let deadline = ContinuousClock.now.advanced(by: .seconds(15))
static let historyDomain = Bundle.main.bundleIdentifier! + ".history"
static func run() {
_ = NSApplication.shared
NSApp.setActivationPolicy(.regular)
let defaults = UserDefaults(suiteName: historyDomain)!
model = PlayerModel(defaults: defaults)
model!.setVolume(0)
model!.open(URL(fileURLWithPath: CommandLine.arguments[2]).appendingPathComponent("av.mp4"))
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
MainActor.assumeIsolated { finishWhenReady() }
}
NSApp.run()
}
static func finishWhenReady() {
guard let model else { return }
let ready = model.canTogglePlayback && model.state.status == PLAYER_PLAYING
guard ready || model.state.status == PLAYER_ERROR || ContinuousClock.now >= deadline else { return }
timer?.invalidate()
let pid = ProcessInfo.processInfo.processIdentifier
let application = NSRunningApplication.current
let recognized = application.processIdentifier == pid &&
NSWorkspace.shared.runningApplications.contains { $0.processIdentifier == pid && $0.bundleURL == Bundle.main.bundleURL }
let result: [String: Any] = [
"passed": ready && recognized && MPNowPlayingInfoCenter.default().playbackState == .playing,
"pid": pid,
"registeredPID": application.processIdentifier,
"bundleIdentifier": application.bundleIdentifier ?? "nil",
"playing": ready,
]
do {
let pipe = try FileHandle(forWritingTo: URL(fileURLWithPath: CommandLine.arguments[1]))
try pipe.write(contentsOf: JSONSerialization.data(withJSONObject: result))
try pipe.write(contentsOf: Data([10]))
try pipe.close()
} catch {
FileHandle.standardError.write(Data("Startup check could not report: \(error)\n".utf8))
}
model.shutdown()
UserDefaults(suiteName: historyDomain)!.removePersistentDomain(forName: historyDomain)
self.model = nil
NSApp.terminate(nil)
}
}
+13 -6
View File
@@ -14,11 +14,11 @@ SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/CompactWindow.swift Sou
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
PLAYER := $(APP)/Contents/Frameworks/libterebi-player.dylib
BOOTSTRAP := $(APP)/Contents/MacOS/terebi
MANIFEST := $(APP)/Contents/Resources/ffmpeg-libraries.txt
.PHONY: all release install check-dependencies run check check-fixtures check-audio check-window check-youtube check-network check-progress check-cli check-runtime check-media
.PHONY: all release install check-dependencies run check check-fixtures check-audio check-window check-youtube check-network check-progress check-cli check-runtime check-media check-startup
all: check-dependencies $(BUILD_DIR)/terebi $(PLAYER) $(BOOTSTRAP) $(MANIFEST) $(APP)/Contents/Info.plist
check-dependencies:
@@ -46,15 +46,16 @@ $(BUILD_DIR)/Output.o: Sources/macOS/Output.mm Sources/Core/output.hpp Makefile
$(CXX) $(CXXFLAGS) -fobjc-arc -c $< -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 $@
mkdir -p "$(APP)/Contents/Frameworks"
rm -f "$(APP)/Contents/MacOS/terebi-player"
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -emit-library -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h $(SWIFT_SOURCES) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -Xlinker -install_name -Xlinker @rpath/libterebi-player.dylib -o $@
$(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; \
printf '%s\n' 'Error: could not record all five absolute FFmpeg dylib paths from the player library. Rebuild with Homebrew FFmpeg: brew install ffmpeg pkg-config' >&2; \
exit 1; \
fi; \
mv "$@.tmp" "$@"
@@ -77,7 +78,7 @@ run: all
$(BUILD_DIR)/playback-check: Checks/playback.mm $(OBJECTS) Makefile | check-dependencies
$(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@
check: check-dependencies $(BUILD_DIR)/playback-check check-fixtures check-audio check-window check-youtube check-network check-progress check-cli check-runtime check-media
check: check-dependencies $(BUILD_DIR)/playback-check check-fixtures check-audio check-window check-youtube check-network check-progress check-cli check-runtime check-media check-startup
$(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures
check-fixtures: | check-dependencies
@@ -155,3 +156,9 @@ $(BUILD_DIR)/system-playback-check: Checks/system-playback.swift $(SWIFT_SOURCES
check-media: $(BUILD_DIR)/system-playback-check check-fixtures
$(BUILD_DIR)/system-playback-check $(BUILD_DIR)/fixtures
$(BUILD_DIR)/app-startup-check.dylib: Checks/app-startup.swift $(SWIFT_SOURCES) $(OBJECTS) Makefile | check-dependencies
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -emit-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h Checks/app-startup.swift $(SWIFT_SOURCES) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
check-startup: all $(BUILD_DIR)/app-startup-check.dylib check-fixtures
python3 Checks/app-startup.py "$(APP)" "$(BUILD_DIR)/app-startup-check.dylib" "$(BUILD_DIR)/fixtures"
+5 -1
View File
@@ -32,6 +32,8 @@ FFmpeg is still external. Builds and installs—including cached release install
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.
The declared app executable stays running after preflight and loads `Contents/Frameworks/libterebi-player.dylib` in-process. It does not `exec` another executable: LaunchServices retains the real app PID and identity, so third-party Now Playing source lists such as Vorssaint can discover it. Missing/unloadable player libraries or an invalid entry point also show the native rebuild/reinstall error dialog.
## Command line
`make` also builds `.build/terebi`, a native CLI launcher:
@@ -118,7 +120,7 @@ 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.
- `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.
- `Sources/Launcher/FFmpegRuntime.swift` and `Sources/App/Bootstrap.swift`: shared native dependency preflight and the app's FFmpeg-independent entrypoint. The bootstrap loads the player library and calls its exported `terebi_app_main` entry point without replacing the executable, consuming initial launch events, or losing macOS app identity. `Contents/Resources/ffmpeg-libraries.txt` records the library's actual linked FFmpeg ABI paths.
- `Makefile`: compiler/linker commands, dependency preflight, generated runtime manifest, developer/release bundles, installation, and checks. No generated Xcode project or additional build dependency.
## Runnable check
@@ -145,6 +147,8 @@ Generates short fixtures and exercises the real playback interface and native ou
`make check-media` exercises actual playback through the app model, idempotent play/pause, paused seeking and start-boundary clamping, non-seekable state transitions, and Now Playing cleanup on close/error/shutdown. It is included in `make check` and uses isolated playback history with muted audio. Native MediaPlayer command dispatch was also smoke-checked; physical media-key routing and macOS's choice among competing media apps still need a keyboard check in the running app.
`make check-startup` launches an isolated app bundle through LaunchServices using the production bootstrap and a check-only player library with actual native playback. It verifies that the playing process retains a real registered app PID and bundle identity—the prerequisite for third-party Now Playing discovery. It is included in `make check`; it does not require Vorssaint, show a playback window, or touch real playback history. Cold Finder/CLI opens, warm local/YouTube opens, saved-position restoration, same-window reuse, and Vorssaint's adapter discovery were additionally smoke-checked through the real GUI.
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.
+10 -6
View File
@@ -9,13 +9,17 @@ enum TerebiBootstrap {
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 payload = app.appendingPathComponent("Contents/Frameworks/libterebi-player.dylib")
guard let library = dlopen(payload.path, RTLD_NOW | RTLD_LOCAL) else {
let detail = dlerror().map { String(cString: $0) } ?? "The dynamic loader could not load the player."
fail("Could not load the player: \(detail)\n\nPlayer library: \(payload.path)\nRebuild and reinstall terebi.")
}
let detail = String(cString: strerror(errorCode))
fail("Could not start the player: \(detail)\n\nPlayer executable: \(payload.path)\nRebuild and reinstall terebi.")
guard let entry = dlsym(library, "terebi_app_main") else {
fail("The player library is missing its application entry point. Rebuild and reinstall terebi.")
}
// Keep the declared app executable alive so LaunchServices retains its identity.
typealias PlayerMain = @convention(c) () -> Void
unsafeBitCast(entry, to: PlayerMain.self)()
}
private static func fail(_ message: String) -> Never {
+8 -3
View File
@@ -1167,9 +1167,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}
#if !PLAYER_CHECK
@main
#endif
@MainActor
struct TerebiApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
@@ -1228,3 +1225,11 @@ struct TerebiApp: App {
}
}
}
#if !PLAYER_CHECK
@_cdecl("terebi_app_main")
@MainActor
public func terebiAppMain() {
TerebiApp.main()
}
#endif
+1 -1
View File
@@ -32,7 +32,7 @@ enum FFmpegRuntime {
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.
// Deliberately never dlclose: retain successful loads until exit.
}
return nil
}