fix: preserve app identity through FFmpeg preflight
This commit is contained in:
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user