From 5f0287f48994f95946c51df0684baf628e99a465 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 11 Oct 2026 02:10:43 +0200 Subject: [PATCH] fix: preserve app identity through FFmpeg preflight --- Checks/app-startup.py | 59 ++++++++++++++++++++++++++++ Checks/app-startup.swift | 59 ++++++++++++++++++++++++++++ Makefile | 19 ++++++--- README.md | 6 ++- Sources/App/Bootstrap.swift | 16 +++++--- Sources/App/TerebiApp.swift | 11 ++++-- Sources/Launcher/FFmpegRuntime.swift | 2 +- 7 files changed, 155 insertions(+), 17 deletions(-) create mode 100644 Checks/app-startup.py create mode 100644 Checks/app-startup.swift diff --git a/Checks/app-startup.py b/Checks/app-startup.py new file mode 100644 index 0000000..e8a8895 --- /dev/null +++ b/Checks/app-startup.py @@ -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) diff --git a/Checks/app-startup.swift b/Checks/app-startup.swift new file mode 100644 index 0000000..0db0b27 --- /dev/null +++ b/Checks/app-startup.swift @@ -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) + } +} diff --git a/Makefile b/Makefile index bb4abfa..cffa5d8 100644 --- a/Makefile +++ b/Makefile @@ -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" diff --git a/README.md b/README.md index d8e76c6..ed79b65 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Sources/App/Bootstrap.swift b/Sources/App/Bootstrap.swift index b2ad34a..1f1a358 100644 --- a/Sources/App/Bootstrap.swift +++ b/Sources/App/Bootstrap.swift @@ -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 { diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index a302c0b..347820c 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -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 diff --git a/Sources/Launcher/FFmpegRuntime.swift b/Sources/Launcher/FFmpegRuntime.swift index c33dbcf..24b039a 100644 --- a/Sources/Launcher/FFmpegRuntime.swift +++ b/Sources/Launcher/FFmpegRuntime.swift @@ -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 }