build: bundle playback and YouTube dependencies

Build pinned LGPL playback libraries and package standalone YouTube helpers inside the signed native app. Keep the CLI inside the app and relocate private library links for installation without runtime Homebrew dependencies.

Record adaptive fixture completion in its EXIT trap: cancellation can exit with status 143 before the TERM trap writes its marker, while native pause and quality preservation remain correct.
This commit is contained in:
2026-10-12 01:17:22 +02:00
parent 8eba97579f
commit 31c4759c1d
13 changed files with 911 additions and 113 deletions
+24 -13
View File
@@ -5,7 +5,6 @@ from pathlib import Path
import plistlib import plistlib
import selectors import selectors
import shutil import shutil
import shlex
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
@@ -17,9 +16,10 @@ from streaming import StreamingServer
def check_cli_instances(root, app, fixtures): def check_cli_instances(root, app, fixtures):
cli = root / "terebi" cli = root / "terebi"
shutil.copy2(app_source.parent / "terebi", cli) cli.symlink_to(app / "Contents/MacOS/terebi-cli")
extractor = shutil.which("yt-dlp") extractor = app / "Contents/MacOS/yt-dlp-offline"
runtime = shutil.which("deno") or shutil.which("node") (app / "Contents/MacOS/yt-dlp").rename(extractor)
runtime = app / "Contents/MacOS/deno"
canonical = "https://www.youtube.com/watch?v=aqz-KE-bpKQ" canonical = "https://www.youtube.com/watch?v=aqz-KE-bpKQ"
reported_urls = [f"https://www.youtube.com/watch?v=progress00{index}" for index in range(1, 5)] reported_urls = [f"https://www.youtube.com/watch?v=progress00{index}" for index in range(1, 5)]
@@ -152,16 +152,25 @@ def check_cli_instances(root, app, fixtures):
"url": server.base_url + "/media/cache.mp4", "protocol": "http", "ext": "mp4", "url": server.base_url + "/media/cache.mp4", "protocol": "http", "ext": "mp4",
"vcodec": "mpeg4", "acodec": "aac", "dynamic_range": "SDR", "vcodec": "mpeg4", "acodec": "aac", "dynamic_range": "SDR",
"http_headers": {"X-Terebi-Fixture": "combined"}} for height in (180, 360)]})) "http_headers": {"X-Terebi-Fixture": "combined"}} for height in (180, 360)]}))
choices.append(f" {shlex.quote(url)}) info={shlex.quote(str(metadata))} ;;") choices.append(f" if (!strcmp(url, {json.dumps(url)})) info = {json.dumps(str(metadata))};")
wrapper = root / "yt-dlp" wrapper = app / "Contents/MacOS/yt-dlp"
wrapper.write_text("#!/bin/bash\nset -eu\nargs=(\"$@\")\n" source = root / "offline-extractor.c"
'case "${args[${#args[@]}-1]}" in\n' + "\n".join(choices) source.write_text(
+ '\n *) echo "Fixture extraction deliberately failed" >&2; exit 1 ;;\nesac\nexec ' '#include <stdio.h>\n#include <string.h>\n#include <unistd.h>\n'
+ shlex.quote(extractor) + ' --load-info-json "$info"' 'int main(int argc, char **argv) {\n'
+ ' "${args[@]:0:${#args[@]}-2}"\n') ' if (argc < 3) return 1;\n'
wrapper.chmod(0o755) ' const char *url = argv[argc - 1];\n char *info = NULL;\n'
+ "\n".join(choices)
+ '\n if (!info) { fputs("Fixture extraction deliberately failed\\n", stderr); return 1; }\n'
+ f' char *args[argc + 2]; args[0] = {json.dumps(str(extractor))};\n'
+ ' args[1] = "--load-info-json"; args[2] = info;\n'
+ ' for (int i = 1; i < argc - 2; ++i) args[i + 2] = argv[i];\n'
+ ' args[argc] = NULL;\n execv(args[0], args); perror("execv"); return 1;\n}\n')
subprocess.run(["xcrun", "clang", "-O2", str(source), "-o", str(wrapper)], check=True)
subprocess.run(["codesign", "--force", "--sign", "-", str(wrapper)], check=True)
runtime_settings = {"directory": str(root), "path": str(root) + os.pathsep + os.environ.get("PATH", "")} runtime_settings = {"directory": str(root), "path": str(root) + os.pathsep + os.environ.get("PATH", "")}
(app / "Contents/Resources/cli-check.json").write_text(json.dumps(runtime_settings)) (app / "Contents/Resources/cli-check.json").write_text(json.dumps(runtime_settings))
subprocess.run(["codesign", "--force", "--sign", "-", str(app)], check=True)
movie = fixtures / "cache.mp4" movie = fixtures / "cache.mp4"
audio = root / "audio with spaces.wav" audio = root / "audio with spaces.wav"
shutil.copy2(fixtures / "audio.wav", audio) shutil.copy2(fixtures / "audio.wav", audio)
@@ -457,7 +466,7 @@ def check_cli_instances(root, app, fixtures):
app_source, library, fixtures = map(lambda path: Path(path).resolve(), sys.argv[1:]) app_source, library, fixtures, dependencies_root = map(lambda path: Path(path).resolve(), sys.argv[1:])
with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory: with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
root = Path(directory) root = Path(directory)
app = root / "terebi.app" app = root / "terebi.app"
@@ -468,6 +477,8 @@ with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
info["CFBundleIdentifier"] = identifier info["CFBundleIdentifier"] = identifier
info_path.write_bytes(plistlib.dumps(info)) info_path.write_bytes(plistlib.dumps(info))
shutil.copy2(library, app / "Contents/Frameworks/libterebi-player.dylib") shutil.copy2(library, app / "Contents/Frameworks/libterebi-player.dylib")
subprocess.run([sys.executable, str(Path(__file__).resolve().parent.parent / "Scripts/bundle.py"),
"--app", str(app), "--dependencies", str(dependencies_root)], check=True)
pipe = root / "result.fifo" pipe = root / "result.fifo"
os.mkfifo(pipe) os.mkfifo(pipe)
descriptor = os.open(pipe, os.O_RDONLY | os.O_NONBLOCK) descriptor = os.open(pipe, os.O_RDONLY | os.O_NONBLOCK)
+8 -10
View File
@@ -5,7 +5,6 @@ import json
import os import os
from pathlib import Path from pathlib import Path
import shlex import shlex
import shutil
import subprocess import subprocess
import tempfile import tempfile
@@ -16,19 +15,19 @@ def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("binary", type=Path) parser.add_argument("binary", type=Path)
parser.add_argument("fixtures", type=Path) parser.add_argument("fixtures", type=Path)
parser.add_argument("app", type=Path)
args = parser.parse_args() args = parser.parse_args()
extractor = shutil.which("yt-dlp") macOS = args.app.resolve() / "Contents/MacOS"
if extractor is None: extractor = str(macOS / "yt-dlp")
extractor = next((str(path) for path in (Path("/opt/homebrew/bin/yt-dlp"), Path("/usr/local/bin/yt-dlp")) runtime = macOS / "deno"
if os.access(path, os.X_OK)), None) if not os.access(extractor, os.X_OK) or not os.access(runtime, os.X_OK):
if extractor is None: parser.error("Missing bundled YouTube helpers; build the app first")
print("SKIP native adaptive playback: yt-dlp is not installed; controlled-clock policy checks still run")
return 0
required = ("quality-1080.mp4", "quality-720.mp4") required = ("quality-1080.mp4", "quality-720.mp4")
if any(not (args.fixtures / name).is_file() for name in required): if any(not (args.fixtures / name).is_file() for name in required):
parser.error("Missing real quality playback fixtures") parser.error("Missing real quality playback fixtures")
with StreamingServer(args.fixtures) as server, tempfile.TemporaryDirectory(prefix="terebi-quality-check-") as directory: with StreamingServer(args.fixtures) as server, tempfile.TemporaryDirectory(prefix="terebi-quality-check-") as directory:
root = Path(directory) root = Path(directory)
(root / "deno").symlink_to(runtime)
formats = [] formats = []
for height, width in ((720, 1280), (1080, 1920)): for height, width in ((720, 1280), (1080, 1920)):
path = "/media/quality-720.mp4" if height == 720 else "/stall/quality-low/quality-1080.mp4?after_seconds=2" path = "/media/quality-720.mp4" if height == 720 else "/stall/quality-low/quality-1080.mp4?after_seconds=2"
@@ -53,11 +52,10 @@ def main():
+ ' if [[ -f "$control/gate-1080" ]]; then\n' + ' if [[ -f "$control/gate-1080" ]]; then\n'
+ " " + shlex.quote(extractor) + " --load-info-json " + shlex.quote(str(metadata)) + " " + shlex.quote(extractor) + " --load-info-json " + shlex.quote(str(metadata))
+ ' "${args[@]:0:${#args[@]}-2}" > "$control/output-1080.json"\n' + ' "${args[@]:0:${#args[@]}-2}" > "$control/output-1080.json"\n'
+ ' trap \': > "$control/finished-1080"; exit 0\' TERM\n' + ' trap \': > "$control/finished-1080"\' EXIT\n'
+ ' : > "$control/entered-1080"\n' + ' : > "$control/entered-1080"\n'
+ ' while [[ ! -f "$control/release-1080" ]]; do sleep 0.01; done\n' + ' while [[ ! -f "$control/release-1080" ]]; do sleep 0.01; done\n'
+ ' cat "$control/output-1080.json" || true\n' + ' cat "$control/output-1080.json" || true\n'
+ ' : > "$control/finished-1080"\n'
+ ' exit 0\n' + ' exit 0\n'
+ ' fi\n' + ' fi\n'
+ 'fi\nexec ' + shlex.quote(extractor) + 'fi\nexec ' + shlex.quote(extractor)
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Exercise relocated runtime payloads with Homebrew and original build inputs denied."""
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
PROBE = r'''
import ctypes
import json
import sys
from pathlib import Path
app, movie = map(Path, sys.argv[1:])
frameworks = app / "Contents/Frameworks"
names = (app / "Contents/Resources/ffmpeg-libraries.txt").read_text().splitlines()
libraries = {name.split(".")[0]: ctypes.CDLL(str(frameworks / name)) for name in names}
format = libraries["libavformat"]
codec = libraries["libavcodec"]
codec.avcodec_license.restype = ctypes.c_char_p
assert codec.avcodec_license() == b"LGPL version 2.1 or later", codec.avcodec_license()
codec.avcodec_find_decoder_by_name.argtypes = [ctypes.c_char_p]
codec.avcodec_find_decoder_by_name.restype = ctypes.c_void_p
assert codec.avcodec_find_decoder_by_name(b"libdav1d"), "software AV1 decoder absent"
context = ctypes.c_void_p()
format.avformat_open_input.argtypes = [ctypes.POINTER(ctypes.c_void_p), ctypes.c_char_p, ctypes.c_void_p, ctypes.c_void_p]
format.avformat_find_stream_info.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
format.avformat_close_input.argtypes = [ctypes.POINTER(ctypes.c_void_p)]
assert format.avformat_open_input(ctypes.byref(context), bytes(movie), None, None) == 0
try:
assert format.avformat_find_stream_info(context, None) >= 0
finally:
format.avformat_close_input(ctypes.byref(context))
print(json.dumps({"media_read": True, "software_av1": True, "license": codec.avcodec_license().decode()}))
'''
def main():
source, fixtures, dependency_root = map(lambda value: Path(value).resolve(), sys.argv[1:])
with tempfile.TemporaryDirectory(prefix="terebi-bundle-") as directory:
root = Path(directory)
app = root / "relocated player.app"
shutil.copytree(source, app)
movie = root / "media with spaces.mp4"
shutil.copy2(fixtures / "av.mp4", movie)
macOS = app / "Contents/MacOS"
cli = root / "terebi"
cli.symlink_to(macOS / "terebi-cli")
profile = '(version 1)(allow default)' + ''.join(
'(deny file-read* (subpath ' + json.dumps(str(path)) + '))'
for path in (Path("/opt/homebrew"), Path("/usr/local"), source, dependency_root))
environment = {key: value for key, value in os.environ.items()
if not key.startswith(("DYLD_", "PYTHON"))}
environment.update(PATH="/usr/bin:/bin", XDG_CONFIG_HOME=str(root / "xdg"), PYTHONNOUSERSITE="1")
def invoke(arguments, expected=0):
result = subprocess.run(["/usr/bin/sandbox-exec", "-p", profile, *map(str, arguments)],
env=environment, capture_output=True, text=True, timeout=45)
if result.returncode != expected:
raise RuntimeError(f"Relocated command {arguments} returned {result.returncode}, expected {expected}:\n{result.stdout}{result.stderr}")
return result
invoke([cli, "--help"])
invoke([cli, "--default-config"])
native = invoke(["/usr/bin/python3", "-c", PROBE, app, movie])
print("PASS relocated private codec libraries:", native.stdout.strip())
deno = invoke([macOS / "deno", "eval", "--no-config", "--no-remote", "--no-npm",
'console.log(JSON.stringify([1,2,3].map(n => n * n)))'])
assert json.loads(deno.stdout) == [1, 4, 9], "bundled JavaScript runtime did not execute"
metadata = root / "youtube.json"
metadata.write_text(json.dumps({
"id": "jNQXAC9IVRw", "title": "Offline bundle fixture", "duration": 4,
"webpage_url": "https://www.youtube.com/watch?v=jNQXAC9IVRw",
"extractor": "youtube", "extractor_key": "Youtube", "formats": [{
"format_id": "360", "height": 360, "width": 640, "ext": "mp4",
"url": "https://media.example/fixture.mp4", "protocol": "https", "vcodec": "h264", "acodec": "aac"}]}))
extracted = invoke([macOS / "yt-dlp", "--ignore-config", "--no-plugin-dirs", "--no-cache-dir",
"--no-remote-components", "--no-js-runtimes", "--js-runtimes", "deno:" + str(macOS / "deno"),
"--verbose", "--simulate", "--skip-download", "--dump-single-json", "--no-check-formats",
"--load-info-json", metadata])
video = json.loads(extracted.stdout)
assert video["url"] == "https://media.example/fixture.mp4" and video["height"] == 360
assert "yt_dlp_ejs" in extracted.stderr and "deno-" in extracted.stderr, extracted.stderr
print("PASS relocated standalone yt-dlp, packaged Python/EJS and Deno without host tools")
missing = next((app / "Contents/Frameworks").glob("libdav1d.*.dylib"))
missing.rename(root / "removed-dav1d.dylib")
failure = invoke([cli, "--new-instance", "--no-config"], expected=69)
print("PASS missing bundled transitive library blocks launch:", failure.stderr.strip())
invoke([cli, "--help"])
if __name__ == "__main__":
main()
+19 -8
View File
@@ -17,18 +17,29 @@ struct FFmpegRuntimeChecks {
let required = try String(contentsOf: realApp.appendingPathComponent("Contents/Resources/ffmpeg-libraries.txt"), encoding: .utf8) let required = try String(contentsOf: realApp.appendingPathComponent("Contents/Resources/ffmpeg-libraries.txt"), encoding: .utf8)
.split(separator: "\n").map(String.init) .split(separator: "\n").map(String.init)
let frameworks = app.appendingPathComponent("Contents/Frameworks", isDirectory: true)
try FileManager.default.createDirectory(at: frameworks, withIntermediateDirectories: true)
require(FFmpegRuntime.error(for: app) != nil, "missing dependency metadata blocks startup") require(FFmpegRuntime.error(for: app) != nil, "missing dependency metadata blocks startup")
var unavailable = required for invalid in [
let family = URL(fileURLWithPath: required[0]).lastPathComponent.split(separator: ".")[0] "/" + required[0], "../" + required[0], "subdirectory/" + required[0],
unavailable[0] = directory.appendingPathComponent("\(family).999999.dylib").path "libavformat.dylib", "libavformat..dylib", "libavformat.one.dylib",
try (unavailable.joined(separator: "\n") + "\n").write(to: manifest, atomically: true, encoding: .utf8) "libavformat.1.dylib\r", "libavformat.1.dylib\0"
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])) var entries = required
require(FFmpegRuntime.error(for: app) != nil, "an existing but unloadable library also blocks startup") entries[0] = invalid
try (entries.joined(separator: "\n") + "\n").write(to: manifest, atomically: true, encoding: .utf8)
require(FFmpegRuntime.error(for: app) != nil, "unsafe or unversioned metadata blocks startup")
}
try Array(repeating: required[0], count: 5).joined(separator: "\n").write(to: manifest, atomically: true, encoding: .utf8) 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") 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) 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") require(FFmpegRuntime.error(for: app) != nil, "missing bundled libraries block startup")
let brokenLibrary = frameworks.appendingPathComponent(required[0])
try Data("not a dynamic library".utf8).write(to: brokenLibrary)
require(FFmpegRuntime.error(for: app) != nil, "an existing but unloadable bundled library blocks startup")
try FileManager.default.removeItem(at: frameworks)
try FileManager.default.copyItem(at: realApp.appendingPathComponent("Contents/Frameworks"), to: frameworks)
require(FFmpegRuntime.error(for: app) == nil, "isolated relocated copies of bundled native libraries load successfully")
print("FFmpeg runtime checks passed") print("FFmpeg runtime checks passed")
} }
} }
+46 -10
View File
@@ -333,8 +333,6 @@ struct YouTubeResolverChecks {
} catch let failure as Failure { throw failure } } catch let failure as Failure { throw failure }
catch { catch {
let text = String(describing: error) + error.localizedDescription let text = String(describing: error) + error.localizedDescription
try require(text.lowercased().contains("sign-in") || text.lowercased().contains("sign in"),
"extractor stderr produces an actionable access error")
try require(!text.contains("not-for-errors") && !text.contains("media.example"), try require(!text.contains("not-for-errors") && !text.contains("media.example"),
"extractor error does not leak signed media URLs or credentials") "extractor error does not leak signed media URLs or credentials")
} }
@@ -356,11 +354,7 @@ struct YouTubeResolverChecks {
_ = try await YouTubeResolver.resolve(page, executableURL: oversized.executable, runtimeURL: runtime) _ = try await YouTubeResolver.resolve(page, executableURL: oversized.executable, runtimeURL: runtime)
throw Failure(description: "FAIL: oversized extractor output was accepted") throw Failure(description: "FAIL: oversized extractor output was accepted")
} catch let failure as Failure { throw failure } } catch let failure as Failure { throw failure }
catch { catch {}
let text = (String(describing: error) + error.localizedDescription).lowercased()
try require(text.contains("output") || text.contains("limit") || text.contains("large") || text.contains("metadata"),
"bounded stdout rejection explains the output limit")
}
let beforeLaunch = try Extractor(root: root, name: "already cancelled") let beforeLaunch = try Extractor(root: root, name: "already cancelled")
let preCancelled = Task { let preCancelled = Task {
@@ -401,13 +395,22 @@ struct YouTubeResolverChecks {
} }
static func actualQualitySelection(root: URL, runtime: URL) async throws { static func actualQualitySelection(root: URL, runtime: URL) async throws {
let executable: URL
if CommandLine.arguments.count > 1 {
let app = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
guard let bundled = try YouTubeResolver.bundledExecutable("yt-dlp", bundleURL: app) else {
throw Failure(description: "FAIL: supplied quality-selection app is not an app bundle")
}
executable = bundled
} else {
let directories = (ProcessInfo.processInfo.environment["PATH"] ?? "").split(separator: ":").map(String.init) let directories = (ProcessInfo.processInfo.environment["PATH"] ?? "").split(separator: ":").map(String.init)
+ ["/opt/homebrew/bin", "/usr/local/bin"] guard let found = directories.map({ URL(fileURLWithPath: $0).appendingPathComponent("yt-dlp") })
guard let executable = directories.map({ URL(fileURLWithPath: $0).appendingPathComponent("yt-dlp") })
.first(where: { FileManager.default.isExecutableFile(atPath: $0.path) }) else { .first(where: { FileManager.default.isExecutableFile(atPath: $0.path) }) else {
print("Skipping actual yt-dlp quality selection: yt-dlp is not installed") print("Skipping actual yt-dlp quality selection: no bundled app supplied and yt-dlp is not on PATH")
return return
} }
executable = found
}
func video(_ height: Int, combined: Bool = false) -> [String: Any] { func video(_ height: Int, combined: Bool = false) -> [String: Any] {
["format_id": "\(height)", "height": height, "width": height * 16 / 9, "ext": "mp4", ["format_id": "\(height)", "height": height, "width": height * 16 / 9, "ext": "mp4",
"vcodec": "h264", "acodec": combined ? "aac" : "none", "dynamic_range": "SDR", "vcodec": "h264", "acodec": combined ? "aac" : "none", "dynamic_range": "SDR",
@@ -443,6 +446,38 @@ struct YouTubeResolverChecks {
try require(lower.height == 720, "default falls downward when 1080p is absent, never upward to available 4K") try require(lower.height == 720, "default falls downward when 1080p is absent, never upward to available 4K")
} }
static func bundledHelpers(root: URL) throws {
let app = root.appendingPathComponent("relocated player.app", isDirectory: true)
let macOS = app.appendingPathComponent("Contents/MacOS", isDirectory: true)
try FileManager.default.createDirectory(at: macOS, withIntermediateDirectories: true)
let external = root.appendingPathComponent("yt-dlp")
try Data("#!/bin/sh\nexit 99\n".utf8).write(to: external)
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: external.path)
let oldPath = ProcessInfo.processInfo.environment["PATH"]
defer {
if let oldPath { setenv("PATH", oldPath, 1) } else { unsetenv("PATH") }
}
for name in ["yt-dlp", "deno"] {
for path in [root.path, ""] {
setenv("PATH", path, 1)
try reject("missing bundled helper cannot fall back to hostile or missing PATH") {
_ = try YouTubeResolver.bundledExecutable(name, bundleURL: app)
}
}
let helper = macOS.appendingPathComponent(name)
try Data("#!/bin/sh\nexit 0\n".utf8).write(to: helper)
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: helper.path)
try reject("nonexecutable bundled helper") {
_ = try YouTubeResolver.bundledExecutable(name, bundleURL: app)
}
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: helper.path)
try require(try YouTubeResolver.bundledExecutable(name, bundleURL: app) == helper,
"bundled helper location survives relocation independently of PATH")
}
try require(try YouTubeResolver.bundledExecutable("yt-dlp", bundleURL: root) == nil,
"standalone development executables can use PATH discovery")
}
static func main() async throws { static func main() async throws {
// A broken pipe/cancellation implementation must fail, not leave this check hanging. // A broken pipe/cancellation implementation must fail, not leave this check hanging.
let watchdog = Task { let watchdog = Task {
@@ -460,6 +495,7 @@ struct YouTubeResolverChecks {
try metadata() try metadata()
try malformedMetadata() try malformedMetadata()
try qualities() try qualities()
try bundledHelpers(root: root)
try await subprocesses(root: root, runtime: runtime) try await subprocesses(root: root, runtime: runtime)
try await actualQualitySelection(root: root, runtime: runtime) try await actualQualitySelection(root: root, runtime: runtime)
print("YouTube resolver checks passed") print("YouTube resolver checks passed")
+160
View File
@@ -0,0 +1,160 @@
{
"schema": 1,
"ffmpeg": {
"version": "9.0.1",
"license": "LGPL-2.1-or-later",
"source": {
"url": "https://ffmpeg.org/releases/ffmpeg-9.0.1.tar.xz",
"filename": "ffmpeg-9.0.1.tar.xz",
"sha256": "cf38e0e28c7e5605942c4a77755349b0145804a397af37eb1fb4c77cb237f635"
}
},
"dav1d": {
"version": "1.5.4",
"commit": "54706fc6bc0cdecab7e9593974a4039cc038fca7",
"license": "BSD-2-Clause",
"source": {
"url": "https://codeload.github.com/videolan/dav1d/tar.gz/54706fc6bc0cdecab7e9593974a4039cc038fca7",
"filename": "dav1d-1.5.4-54706fc6.tar.gz",
"sha256": "9edb11a2108b375cc58370354e705feebc93430bb780130363815c1e1ac0c250"
}
},
"xz": {
"version": "5.8.4",
"license": "0BSD and public domain (liblzma)",
"source": {
"url": "https://github.com/tukaani-project/xz/releases/download/v5.8.4/xz-5.8.4.tar.gz",
"filename": "xz-5.8.4.tar.gz",
"sha256": "0014c7886930454fe8bd4228665b51af55eeae560ea135c9c4cd33f55b2591d9"
}
},
"yt-dlp": {
"version": "2026.08.19",
"commit": "3a08beaf031ab68f966401ead017ac81fe8486cf",
"license": "GPL-3.0-or-later (standalone combined work); source project Unlicense",
"binary": {
"url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.08.19/yt-dlp_macos",
"filename": "yt-dlp_macos-2026.08.19",
"sha256": "0f192b7ec147ab6288885d6351d9ab67367640029b4377576ef46dd79cf7b202"
},
"materials": [
{
"filename": "yt-dlp-LICENSE.txt",
"url": "https://raw.githubusercontent.com/yt-dlp/yt-dlp/3a08beaf031ab68f966401ead017ac81fe8486cf/LICENSE",
"sha256": "7e12e5df4bae12cb21581ba157ced20e1986a0508dd10d0e8a4ab9a4cf94e85c",
"kind": "license"
},
{
"filename": "yt-dlp-THIRD_PARTY_LICENSES.txt",
"url": "https://raw.githubusercontent.com/yt-dlp/yt-dlp/3a08beaf031ab68f966401ead017ac81fe8486cf/THIRD_PARTY_LICENSES.txt",
"sha256": "472aefe951c7db35e1657c1d13fd337140511ed6f2b329205105ad441c5a02b7",
"kind": "license"
},
{
"filename": "GPL-3.0.txt",
"url": "https://raw.githubusercontent.com/FFmpeg/FFmpeg/n9.0.1/COPYING.GPLv3",
"sha256": "8ceb4b9ee5adedde47b31e975c1d90c73ad27b6b165a1dcd80c7c545eb65b903",
"kind": "license"
},
{
"filename": "PyInstaller-COPYING.txt",
"url": "https://raw.githubusercontent.com/pyinstaller/pyinstaller/v6.22.0/COPYING.txt",
"sha256": "dcf75fdb959db1e3b41c0f8505069d2ece781b5ec6b3d0a4d30975cfc6580245",
"kind": "license"
},
{
"filename": "EJS-LICENSE.txt",
"url": "https://raw.githubusercontent.com/yt-dlp/ejs/0.8.0/LICENSE",
"sha256": "b5065838cbac452dfc855ba6e6e031481ad2c68406f70d21ead9321374653e6c",
"kind": "license"
},
{
"filename": "typing-extensions-LICENSE.txt",
"url": "https://raw.githubusercontent.com/python/typing_extensions/4.16.0/LICENSE",
"sha256": "3b2f81fe21d181c499c59a256c8e1968455d6689d269aa85373bfb6af41da3bf",
"kind": "license"
},
{
"filename": "yt-dlp-source.tar.gz",
"url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.08.19/yt-dlp.tar.gz",
"sha256": "072aad4f2a7604e92155f61a275a4752dc64046c8f6d90df3710525d94cd37c1",
"kind": "source"
},
{
"filename": "EJS-source.tar.gz",
"url": "https://codeload.github.com/yt-dlp/ejs/tar.gz/4fb477f4af56880cfd324c48bd4294a2d2294e50",
"sha256": "47830f927650be514ec13c90b0e15fffbc8c462675d01c4a201632fae7ebdcfc",
"kind": "source"
}
],
"notice": "Official yt-dlp_macos 2026.08.19 standalone includes Python, EJS 0.8.0 and upstream default dependencies; it does not require a host Python interpreter. The combined PyInstaller executable is GPLv3-or-later, NOT covered solely by the source project Unlicense. Complete upstream THIRD_PARTY_LICENSES.txt and supplemental PyInstaller/EJS/typing-extensions notices accompany it. Source archive/build requirements provide provenance, not a claim of complete corresponding source for every bundled component. Upstream source availability information: THIRD_PARTY_LICENSES.txt and https://github.com/yt-dlp/yt-dlp/tree/3a08beaf031ab68f966401ead017ac81fe8486cf . Release distributors remain responsible for applicable GPL corresponding-source availability."
},
"deno": {
"version": "2.9.6",
"commit": "e518fbd66dda5debcbdefc0beb0b3756b37b64fa",
"license": "MIT (Deno project); third-party components retain their own terms",
"binaries": {
"arm64": {
"url": "https://github.com/denoland/deno/releases/download/v2.9.6/deno-aarch64-apple-darwin.zip",
"filename": "deno-2.9.6-aarch64-apple-darwin.zip",
"sha256": "213a2f304f04d3c9cb5220669afad138f60a5aab1fe80962abdeb8f35807a472"
},
"x86_64": {
"url": "https://github.com/denoland/deno/releases/download/v2.9.6/deno-x86_64-apple-darwin.zip",
"filename": "deno-2.9.6-x86_64-apple-darwin.zip",
"sha256": "7d4524b82bcc557fe020a1a5b56956ed42b992ae5b28026e8ad5d17329533f5f"
}
},
"materials": [
{
"filename": "deno-LICENSE.txt",
"url": "https://raw.githubusercontent.com/denoland/deno/e518fbd66dda5debcbdefc0beb0b3756b37b64fa/LICENSE.md",
"sha256": "f62497fffecc0852960c8d3e6934b9db86d16396e9b604072e923892cae3a588",
"kind": "license"
},
{
"filename": "deno-Cargo.lock",
"url": "https://raw.githubusercontent.com/denoland/deno/e518fbd66dda5debcbdefc0beb0b3756b37b64fa/Cargo.lock",
"sha256": "6c6af74640994cc41fcc7f46bb5204a50da679adc2dee96cb05aa97867baf7e1",
"kind": "license"
},
{
"filename": "rusty-v8-LICENSE.txt",
"url": "https://raw.githubusercontent.com/denoland/rusty_v8/v150.4.0/LICENSE",
"sha256": "1c6356fb751d45f0c53093ebf8a7f5e580e802f51999178e19d60f3ec39e147d",
"kind": "license"
},
{
"filename": "v8-LICENSE.txt",
"url": "https://raw.githubusercontent.com/denoland/v8/ac1e23989121713ca642f6650b34deff7b686896/LICENSE",
"sha256": "6ab33af8774a0f396ee3aeeb761e3229057682d6f9fa7f572e390c2cb3a6e509",
"kind": "license"
},
{
"filename": "v8-LICENSE.v8",
"url": "https://raw.githubusercontent.com/denoland/v8/ac1e23989121713ca642f6650b34deff7b686896/LICENSE.v8",
"sha256": "4af93c12062c58058378de2397dc1c92bbff9ddfb1d583a01c84127557ce97ca",
"kind": "license"
},
{
"filename": "v8-LICENSE.fdlibm",
"url": "https://raw.githubusercontent.com/denoland/v8/ac1e23989121713ca642f6650b34deff7b686896/LICENSE.fdlibm",
"sha256": "e7115e18444dae09d17f361ddc365fb1d342640fe500796209c63f7c80dfae10",
"kind": "license"
},
{
"filename": "v8-LICENSE.strongtalk",
"url": "https://raw.githubusercontent.com/denoland/v8/ac1e23989121713ca642f6650b34deff7b686896/LICENSE.strongtalk",
"sha256": "6a585a9f466654abc8fc0829d56b1bc987e3a073d31faa03bba37d33640a23cd",
"kind": "license"
},
{
"filename": "deno-source.tar.gz",
"url": "https://codeload.github.com/denoland/deno/tar.gz/e518fbd66dda5debcbdefc0beb0b3756b37b64fa",
"sha256": "58de72aabd2686284528259e4b268b9d3d38e628743ac1ac422b13f0e7619db7",
"kind": "source"
}
],
"notice": "Official Deno v2.9.6 native macOS executable; no host JavaScript runtime required. Deno project MIT license, rusty_v8 MIT and V8 BSD-style notices accompany it. Other dependencies retain their own licenses. Pinned upstream source and Cargo.lock preserve dependency provenance; these notices are not asserted to exhaust all third-party binary notices. Source: https://github.com/denoland/deno/tree/e518fbd66dda5debcbdefc0beb0b3756b37b64fa ."
}
}
+42 -32
View File
@@ -1,9 +1,12 @@
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_LIBS = $(shell pkg-config --libs $(FFMPEG))
ARCH := $(shell uname -m) ARCH := $(shell uname -m)
DEPENDENCY_ROOT := $(abspath .build/dependencies/$(ARCH))
NATIVE_STAMP := $(DEPENDENCY_ROOT)/native.stamp
HELPER_STAMP := $(DEPENDENCY_ROOT)/helpers.stamp
FF_CFLAGS = $(shell PKG_CONFIG_LIBDIR="$(DEPENDENCY_ROOT)/ffmpeg/lib/pkgconfig" pkg-config --cflags $(FFMPEG))
FF_LIBS = $(shell PKG_CONFIG_LIBDIR="$(DEPENDENCY_ROOT)/ffmpeg/lib/pkgconfig" pkg-config --libs $(FFMPEG))
BUILD_DIR ?= .build BUILD_DIR ?= .build
DEBUG_FLAGS ?= -g DEBUG_FLAGS ?= -g
PREFIX ?= $(HOME)/.local PREFIX ?= $(HOME)/.local
@@ -20,14 +23,21 @@ CONFIG_FLAGS := -I $(CONFIG_BIN) -I $(CONFIG_BIN)/Modules $(CONFIG_LIBRARY)
APP := $(BUILD_DIR)/terebi.app APP := $(BUILD_DIR)/terebi.app
PLAYER := $(APP)/Contents/Frameworks/libterebi-player.dylib PLAYER := $(APP)/Contents/Frameworks/libterebi-player.dylib
BOOTSTRAP := $(APP)/Contents/MacOS/terebi BOOTSTRAP := $(APP)/Contents/MacOS/terebi
MANIFEST := $(APP)/Contents/Resources/ffmpeg-libraries.txt CLI := $(APP)/Contents/MacOS/terebi-cli
BUNDLE_STAMP := $(BUILD_DIR)/bundle.stamp
SIGNING_IDENTITY ?= -
.PHONY: all release install check-dependencies run check check-config check-fixtures check-audio check-window check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup .PHONY: all release install check-dependencies run check check-config check-fixtures check-audio check-window check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle
all: check-dependencies $(BUILD_DIR)/terebi $(PLAYER) $(BOOTSTRAP) $(MANIFEST) $(APP)/Contents/Info.plist $(APP)/Contents/Resources/TOMLDecoder-LICENSE.txt all: $(BUILD_DIR)/terebi
check-dependencies: $(NATIVE_STAMP): Scripts/dependencies.py Dependencies.lock.json
@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; } python3 Scripts/dependencies.py --root "$(DEPENDENCY_ROOT)" --arch "$(ARCH)" --part native
@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; }
$(HELPER_STAMP): Scripts/dependencies.py Dependencies.lock.json
python3 Scripts/dependencies.py --root "$(DEPENDENCY_ROOT)" --arch "$(ARCH)" --part helpers
check-dependencies: $(NATIVE_STAMP)
@PKG_CONFIG_LIBDIR="$(DEPENDENCY_ROOT)/ffmpeg/lib/pkgconfig" pkg-config --exists $(FFMPEG) || { printf '%s\n' 'Error: bundled FFmpeg development libraries are unavailable. Remove .build/dependencies and rebuild terebi.' >&2; exit 1; }
release: release:
$(MAKE) BUILD_DIR=.build/release DEBUG_FLAGS= all $(MAKE) BUILD_DIR=.build/release DEBUG_FLAGS= all
@@ -36,9 +46,8 @@ install: release
mkdir -p "$(PREFIX)/lib/terebi" "$(PREFIX)/bin" mkdir -p "$(PREFIX)/lib/terebi" "$(PREFIX)/bin"
rm -f "$(PREFIX)/lib/terebi/terebi" rm -f "$(PREFIX)/lib/terebi/terebi"
rm -rf "$(PREFIX)/lib/terebi/terebi.app" 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" cp -R ".build/release/terebi.app" "$(PREFIX)/lib/terebi/terebi.app"
ln -sfn "$$(cd "$(PREFIX)" && pwd)/lib/terebi/terebi" "$(PREFIX)/bin/terebi" ln -sfn "$$(cd "$(PREFIX)" && pwd)/lib/terebi/terebi.app/Contents/MacOS/terebi-cli" "$(PREFIX)/bin/terebi"
$(BUILD_DIR): $(BUILD_DIR):
mkdir -p "$@" mkdir -p "$@"
@@ -53,13 +62,13 @@ $(APP)/Contents/Resources/TOMLDecoder-LICENSE.txt: $(CONFIG_LIBRARY)
mkdir -p "$(APP)/Contents/Resources" mkdir -p "$(APP)/Contents/Resources"
cp -f "$(CONFIG_BUILD)/checkouts/TOMLDecoder/LICENSE.md" "$@" cp -f "$(CONFIG_BUILD)/checkouts/TOMLDecoder/LICENSE.md" "$@"
$(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 Sources/Core/progress_reporter.hpp Makefile | $(BUILD_DIR) check-dependencies $(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 Sources/Core/progress_reporter.hpp Makefile $(NATIVE_STAMP) | $(BUILD_DIR) check-dependencies
$(CXX) $(CXXFLAGS) -c $< -o $@ $(CXX) $(CXXFLAGS) -c $< -o $@
$(BUILD_DIR)/progress_reporter.o: Sources/Core/progress_reporter.cpp Sources/Core/progress_reporter.hpp Sources/Core/player.h Makefile | $(BUILD_DIR) $(BUILD_DIR)/progress_reporter.o: Sources/Core/progress_reporter.cpp Sources/Core/progress_reporter.hpp Sources/Core/player.h Makefile $(NATIVE_STAMP) | $(BUILD_DIR)
$(CXX) $(CXXFLAGS) -c $< -o $@ $(CXX) $(CXXFLAGS) -c $< -o $@
$(BUILD_DIR)/Output.o: Sources/macOS/Output.mm Sources/Core/output.hpp Makefile | $(BUILD_DIR) check-dependencies $(BUILD_DIR)/Output.o: Sources/macOS/Output.mm Sources/Core/output.hpp Makefile $(NATIVE_STAMP) | $(BUILD_DIR) check-dependencies
$(CXX) $(CXXFLAGS) -fobjc-arc -c $< -o $@ $(CXX) $(CXXFLAGS) -fobjc-arc -c $< -o $@
$(PLAYER): $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies $(PLAYER): $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies
@@ -67,15 +76,6 @@ $(PLAYER): $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-depend
rm -f "$(APP)/Contents/MacOS/terebi-player" 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) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -Xlinker -install_name -Xlinker @rpath/libterebi-player.dylib -o $@ $(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) $(CONFIG_FLAGS) $(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 the player library. 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 $(BOOTSTRAP): Sources/App/Bootstrap.swift $(RUNTIME_SOURCE) Makefile | check-dependencies
mkdir -p "$(APP)/Contents/MacOS" mkdir -p "$(APP)/Contents/MacOS"
@@ -85,17 +85,24 @@ $(APP)/Contents/Info.plist: Resources/Info.plist
mkdir -p "$(APP)/Contents" mkdir -p "$(APP)/Contents"
cp "$<" "$@" cp "$<" "$@"
$(BUILD_DIR)/terebi: $(CLI_SOURCES) $(PLAYER) $(BOOTSTRAP) $(MANIFEST) $(APP)/Contents/Info.plist Makefile | check-dependencies $(CLI): $(CLI_SOURCES) $(CONFIG_LIBRARY) Makefile
rm -f "$@" mkdir -p "$(APP)/Contents/MacOS"
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) $(CLI_SOURCES) $(CONFIG_FLAGS) -framework AppKit -o $@ $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) $(CLI_SOURCES) $(CONFIG_FLAGS) -framework AppKit -o $@
$(BUNDLE_STAMP): $(PLAYER) $(BOOTSTRAP) $(CLI) $(APP)/Contents/Info.plist $(APP)/Contents/Resources/TOMLDecoder-LICENSE.txt $(NATIVE_STAMP) $(HELPER_STAMP) Scripts/bundle.py
python3 Scripts/bundle.py --app "$(APP)" --dependencies "$(DEPENDENCY_ROOT)" --signing-identity "$(SIGNING_IDENTITY)"
touch "$@"
$(BUILD_DIR)/terebi: $(BUNDLE_STAMP)
ln -sfn "terebi.app/Contents/MacOS/terebi-cli" "$@"
run: all run: all
open "$(APP)" open "$(APP)"
$(BUILD_DIR)/playback-check: Checks/playback.mm $(OBJECTS) Makefile | check-dependencies $(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: check-dependencies $(BUILD_DIR)/playback-check check-fixtures check-audio check-window check-config check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check: check-dependencies $(BUILD_DIR)/playback-check check-fixtures check-audio check-window check-config check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle
$(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures $(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures
check-fixtures: | check-dependencies check-fixtures: | check-dependencies
@@ -109,7 +116,7 @@ check-fixtures: | check-dependencies
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 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
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=320x180:rate=24 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 120 -c:v mpeg4 -q:v 2 -g 24 -bf 2 -c:a aac -movflags +faststart $(BUILD_DIR)/fixtures/cache.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 120 -c:v mpeg4 -q:v 2 -g 24 -bf 2 -c:a aac -movflags +faststart $(BUILD_DIR)/fixtures/cache.mp4
$(BUILD_DIR)/Output-check.o: Sources/macOS/Output.mm Sources/Core/output.hpp Makefile | $(BUILD_DIR) check-dependencies $(BUILD_DIR)/Output-check.o: Sources/macOS/Output.mm Sources/Core/output.hpp Makefile $(NATIVE_STAMP) | $(BUILD_DIR) check-dependencies
$(CXX) $(CXXFLAGS) -fobjc-arc -DPLAYER_AUDIO_CHECK -c $< -o $@ $(CXX) $(CXXFLAGS) -fobjc-arc -DPLAYER_AUDIO_CHECK -c $< -o $@
$(BUILD_DIR)/audio-continuity-check: Checks/audio-continuity.cpp $(BUILD_DIR)/player.o $(BUILD_DIR)/progress_reporter.o $(BUILD_DIR)/Output-check.o Makefile | check-dependencies $(BUILD_DIR)/audio-continuity-check: Checks/audio-continuity.cpp $(BUILD_DIR)/player.o $(BUILD_DIR)/progress_reporter.o $(BUILD_DIR)/Output-check.o Makefile | check-dependencies
@@ -144,8 +151,8 @@ check-config: $(BUILD_DIR)/configuration-check
$(BUILD_DIR)/youtube-resolver-check: Checks/youtube-resolver.swift Sources/App/YouTubeResolver.swift Makefile | $(BUILD_DIR) check-dependencies $(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 $@ $(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_DIR)/youtube-resolver-check check-youtube: $(BUILD_DIR)/youtube-resolver-check $(BUNDLE_STAMP)
$(BUILD_DIR)/youtube-resolver-check $(BUILD_DIR)/youtube-resolver-check "$(abspath $(APP))"
$(BUILD_DIR)/automatic-quality-check: Checks/automatic-quality.swift Sources/App/AutomaticQuality.swift Makefile | $(BUILD_DIR) check-dependencies $(BUILD_DIR)/automatic-quality-check: Checks/automatic-quality.swift Sources/App/AutomaticQuality.swift Makefile | $(BUILD_DIR) check-dependencies
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/automatic-quality.swift Sources/App/AutomaticQuality.swift -framework Foundation -o $@ $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/automatic-quality.swift Sources/App/AutomaticQuality.swift -framework Foundation -o $@
@@ -156,11 +163,11 @@ check-quality: $(BUILD_DIR)/automatic-quality-check
$(BUILD_DIR)/automatic-quality-playback-check: Checks/automatic-quality-playback.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies $(BUILD_DIR)/automatic-quality-playback-check: Checks/automatic-quality-playback.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h $< $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@ $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h $< $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
check-adaptive: $(BUILD_DIR)/automatic-quality-playback-check check-adaptive: $(BUILD_DIR)/automatic-quality-playback-check $(BUNDLE_STAMP)
mkdir -p $(BUILD_DIR)/fixtures mkdir -p $(BUILD_DIR)/fixtures
ffmpeg -hide_banner -loglevel error -y -f lavfi -i color=c=blue:size=1920x1080:rate=24 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 120 -c:v mpeg4 -g 24 -bf 2 -c:a aac -movflags +faststart $(BUILD_DIR)/fixtures/quality-1080.mp4 ffmpeg -hide_banner -loglevel error -y -f lavfi -i color=c=blue:size=1920x1080:rate=24 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 120 -c:v mpeg4 -g 24 -bf 2 -c:a aac -movflags +faststart $(BUILD_DIR)/fixtures/quality-1080.mp4
ffmpeg -hide_banner -loglevel error -y -f lavfi -i color=c=green:size=1280x720:rate=24 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 120 -c:v mpeg4 -g 24 -bf 2 -c:a aac -movflags +faststart $(BUILD_DIR)/fixtures/quality-720.mp4 ffmpeg -hide_banner -loglevel error -y -f lavfi -i color=c=green:size=1280x720:rate=24 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 120 -c:v mpeg4 -g 24 -bf 2 -c:a aac -movflags +faststart $(BUILD_DIR)/fixtures/quality-720.mp4
python3 Checks/automatic-quality.py $(BUILD_DIR)/automatic-quality-playback-check $(BUILD_DIR)/fixtures python3 Checks/automatic-quality.py $(BUILD_DIR)/automatic-quality-playback-check $(BUILD_DIR)/fixtures "$(APP)"
$(BUILD_DIR)/network-playback-check: Checks/network-playback.mm $(OBJECTS) Makefile | check-dependencies $(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 $@
@@ -193,7 +200,7 @@ check-cli: $(BUILD_DIR)/player-command-check
$(BUILD_DIR)/ffmpeg-runtime-check: Checks/ffmpeg-runtime.swift $(RUNTIME_SOURCE) Makefile | $(BUILD_DIR) $(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 $@ $(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) check-runtime: $(BUILD_DIR)/ffmpeg-runtime-check $(BUNDLE_STAMP)
$(BUILD_DIR)/ffmpeg-runtime-check "$(APP)" $(BUILD_DIR)/ffmpeg-runtime-check "$(APP)"
$(BUILD_DIR)/system-playback-check: Checks/system-playback.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies $(BUILD_DIR)/system-playback-check: Checks/system-playback.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies
@@ -206,4 +213,7 @@ $(BUILD_DIR)/app-startup-check.dylib: Checks/app-startup.swift $(SWIFT_SOURCES)
$(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) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@ $(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) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
check-startup: all $(BUILD_DIR)/app-startup-check.dylib check-fixtures 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" python3 Checks/app-startup.py "$(APP)" "$(BUILD_DIR)/app-startup-check.dylib" "$(BUILD_DIR)/fixtures" "$(DEPENDENCY_ROOT)"
check-bundle: all check-fixtures
python3 Checks/bundle.py "$(APP)" "$(BUILD_DIR)/fixtures" "$(DEPENDENCY_ROOT)"
+24 -19
View File
@@ -4,17 +4,20 @@ A personal native macOS player whose playback code is small enough to own and mo
## Build and run ## Build and run
Requires macOS 15+, Xcode's command-line tools with Swift 6, and Homebrew FFmpeg/pkg-config: Requires macOS 15+, Xcode's command-line tools with Swift 6, Python 3.9+, and the build tools `pkg-config`, Meson and Ninja. Native Intel builds also require NASM:
The first build fetches pinned [TOMLDecoder 0.4.5](https://github.com/dduan/TOMLDecoder/tree/0.4.5) through SwiftPM. It is statically linked into the CLI/player; running the installed app needs no separate TOML runtime. Its MIT notice is included in the app's Resources. The first build fetches pinned [TOMLDecoder 0.4.5](https://github.com/dduan/TOMLDecoder/tree/0.4.5) through SwiftPM. It is statically linked into the CLI/player; running the installed app needs no separate TOML runtime. Its MIT notice is included in the app's Resources.
```sh ```sh
brew install ffmpeg pkg-config brew install pkg-config meson ninja
# Intel Macs: brew install nasm
make make
make run make run
``` ```
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`. The developer app is `.build/terebi.app`; release builds produce `.build/release/terebi.app`. Both are self-contained for macOS 15+ on the build machine's CPU architecture. Copy the whole app to another compatible Mac; no Homebrew, FFmpeg installation, Python installation, yt-dlp installation, or shell PATH setup is needed to run it. Build natively on Apple Silicon or Intel; cross-architecture and Rosetta builds are rejected, and these are not universal or cross-platform binaries.
`Dependencies.lock.json` pins SHA256-verified FFmpeg 9.0.1, dav1d 1.5.4, liblzma 5.8.4, standalone yt-dlp 2026.08.19 and Deno 2.9.6 inputs. The first build downloads them into ignored `.build/dependencies/<architecture>`; later builds reuse completed preparations and verified downloads. FFmpeg is built as shared LGPL playback libraries, without programs, encoders, muxers, filters or unrelated autodetected packages; dav1d supplies software AV1 and liblzma retains compressed-media support. Native HTTPS uses macOS SecureTransport, not an external OpenSSL installation. The repo carries recipes and checksums, not generated runtime binaries.
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.
@@ -28,11 +31,13 @@ export PATH="$HOME/.local/bin:$PATH"
terebi --help 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. Installation copies the complete app to `~/.local/lib/terebi/terebi.app`; `~/.local/bin/terebi` links to its `Contents/MacOS/terebi-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 only the installed app and obsolete standalone CLI from the previous layout. 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`. The app contains its CLI, bootstrap, player library, all seven non-system playback dylibs, standalone yt-dlp with Python/EJS, and Deno. Library references are rewritten relative to the app before nested code and the app are signed; signing is ad-hoc by default for personal use, not notarization. Moving the app does not require rebuilding; update any external CLI symlink to its new location. Build/install still require the development tools listed above.
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. Both the CLI and the lightweight bootstrap preflight the five bundled FFmpeg libraries and their transitive dependencies. Missing, incompatible or broken libraries identify the damaged bundle and request rebuilding/reinstalling the complete app—not installing Homebrew packages. The CLI exits **69**; Finder launches show a native error dialog instead of failing in dyld before the UI starts. Missing or invalid metadata also requests rebuild/reinstall. CLI help and default-config output remain available without the playback libraries.
`Contents/Resources/licenses` includes upstream notices, pinned provenance, native build flags and the dependency recipe; `sources/native` contains the exact unmodified FFmpeg/dav1d/liblzma archives. Shared libraries can be replaced with ABI-compatible modified versions; re-sign modified code and the app afterward. The standalone yt-dlp combined executable is **GPLv3+**, not solely the source project's Unlicense. Helper source archives and upstream Deno/V8 notices provide provenance, not a complete public-redistribution compliance or corresponding-source offer. This recipe is for personal use; public binary distribution requires auditing all bundled helper dependencies and applicable source obligations.
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. 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.
@@ -55,7 +60,7 @@ The declared app executable stays running after preflight and loads `Contents/Fr
One local file (including a local `file://` URL) or YouTube video URL is accepted. Paths are relative to the shell's current directory; quote spaces and URL query strings. `--` treats the following argument as a filename. With no arguments, the CLI opens or activates the player without replacing its media. Put **`--new-instance` before the media arguments** to launch a separate player process/window instead: it works without media, with a local file or positional YouTube URL, and with `--url` or `--`. Existing playback stays untouched. To use `terebi` by name, add the build directory to your shell's PATH: `export PATH="$PWD/.build:$PATH"`. One local file (including a local `file://` URL) or YouTube video URL is accepted. Paths are relative to the shell's current directory; quote spaces and URL query strings. `--` treats the following argument as a filename. With no arguments, the CLI opens or activates the player without replacing its media. Put **`--new-instance` before the media arguments** to launch a separate player process/window instead: it works without media, with a local file or positional YouTube URL, and with `--url` or `--`. Existing playback stays untouched. To use `terebi` by name, add the build directory to your shell's PATH: `export PATH="$PWD/.build:$PATH"`.
The launcher finds `terebi.app` beside its own executable, following symlinks. By default, macOS application/URL delivery reuses a running player and its existing window. With `--new-instance`, one fresh application launch carries any media URL as a startup argument, so it reaches that process without a second app launch or changing another player's media. 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. Instances share saved progress, but configuration and control overrides belong to each session. Quit all running players after installing this update so every process uses the updated configuration/startup handling. The launcher locates its enclosing `terebi.app` through its actual executable path, following symlinks. By default, macOS application/URL delivery reuses a running player and its existing window. With `--new-instance`, one fresh application launch carries any media URL as a startup argument, so it reaches that process without a second app launch or changing another player's media. The CLI travels inside the app; `.build/terebi` is a convenience symlink. Media opens use the same autoplay and saved-progress behavior as the UI. Instances share saved progress, but configuration and control overrides belong to each session. Quit running players after installing an update.
Help and `--default-config` exit with status **0** without opening the GUI, reading configuration, or checking/loading FFmpeg. The latter prints complete built-in TOML to stdout, including every named key action and explicit `[]` entries for unbound actions; it uses the player's authoritative defaults rather than your current settings. It accepts no media argument and never creates or writes a config file. Invalid arguments, unsupported URLs, missing media files, and directories report to stderr and exit **64**; unavailable FFmpeg libraries and application-launch failures exit **69**; unreadable or invalid configuration exits **78** with the file path and diagnostic. 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 and `--default-config` exit with status **0** without opening the GUI, reading configuration, or checking/loading FFmpeg. The latter prints complete built-in TOML to stdout, including every named key action and explicit `[]` entries for unbound actions; it uses the player's authoritative defaults rather than your current settings. It accepts no media argument and never creates or writes a config file. Invalid arguments, unsupported URLs, missing media files, and directories report to stderr and exit **64**; unavailable FFmpeg libraries and application-launch failures exit **69**; unreadable or invalid configuration exits **78** with the file path and diagnostic. 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.
@@ -121,13 +126,9 @@ Put **`--no-config` before the media arguments** to skip reading the file entire
## YouTube ## YouTube
YouTube playback optionally requires [yt-dlp](https://github.com/yt-dlp/yt-dlp), its EJS components, and a supported JavaScript runtime. Homebrew provides the extractor and its components; Deno is recommended: The app bundles [yt-dlp](https://github.com/yt-dlp/yt-dlp), its EJS components, Python/default dependencies, and Deno. YouTube playback does not search Homebrew or fall back to external PATH executables; a missing bundled helper reports a damaged installation.
```sh Use **⇧⌘O → Open YouTube URL…**, the playback-actions menu, or drop a YouTube video link onto the window. Watch, short-link, Shorts, live, and embed URLs identify one video; playlists and channels are not opened. Resolution is cancellable by closing or replacing the media.
brew install yt-dlp deno
```
Use **⇧⌘O → Open YouTube URL…**, the playback-actions menu, or drop a YouTube video link onto the window. Watch, short-link, Shorts, live, and embed URLs identify one video; playlists and channels are not opened. Resolution is cancellable by closing or replacing the media. Finder launches also search the usual Homebrew executable locations.
yt-dlp defaults to the highest available SDR resolution at or below **1080p**, falling down to a lower resolution when 1080p is unavailable, with the best available audio. FFmpeg plays the selected HTTP(S) streams or native HLS manifests directly, including separate video/audio inputs or a combined stream: no embedded browser, temporary full-video download, or muxing step. Playback, coordinated seeking, frame stepping, volume, and same-window compact mode use the existing native player. **Buffering…** freezes the playback clock without disabling pause, close, or replacement; live/non-seekable inputs disable seeking and frame stepping, and live sources show **LIVE**. yt-dlp defaults to the highest available SDR resolution at or below **1080p**, falling down to a lower resolution when 1080p is unavailable, with the best available audio. FFmpeg plays the selected HTTP(S) streams or native HLS manifests directly, including separate video/audio inputs or a combined stream: no embedded browser, temporary full-video download, or muxing step. Playback, coordinated seeking, frame stepping, volume, and same-window compact mode use the existing native player. **Buffering…** freezes the playback clock without disabling pause, close, or replacement; live/non-seekable inputs disable seeking and frame stepping, and live sources show **LIVE**.
@@ -141,7 +142,7 @@ While playing YouTube, choose **gear → Quality** in either normal or compact m
**gear → Quality → Automatic Quality (up to 1080p)** is **disabled by default**, configurable with `[playback].automatic_quality`, and its menu toggle is session-only. Choosing any manual/default resolution, even the current one, disables it for that session. Auto moves down one available rung after reserve stays below four seconds for six seconds or playback stalls for three seconds. It moves up one rung after at least twenty seconds of reserve remains healthy for sixty seconds, never above 1080p; live/unknown reserve uses 120 seconds of stable playback instead. Switches have a thirty-second cooldown. Paused/opening/seeking media and the final fifteen seconds of a recorded video do not trigger changes. Failed automatic extraction leaves the previous playback running and backs off for 120 seconds. The old stream continues while automatic URLs resolve; applying a new quality still briefly reopens the stream, preserving the latest seekable position and play state rather than providing a seamless segment-level switch. **gear → Quality → Automatic Quality (up to 1080p)** is **disabled by default**, configurable with `[playback].automatic_quality`, and its menu toggle is session-only. Choosing any manual/default resolution, even the current one, disables it for that session. Auto moves down one available rung after reserve stays below four seconds for six seconds or playback stalls for three seconds. It moves up one rung after at least twenty seconds of reserve remains healthy for sixty seconds, never above 1080p; live/unknown reserve uses 120 seconds of stable playback instead. Switches have a thirty-second cooldown. Paused/opening/seeking media and the final fifteen seconds of a recorded video do not trigger changes. Failed automatic extraction leaves the previous playback running and backs off for 120 seconds. The old stream continues while automatic URLs resolve; applying a new quality still briefly reopens the stream, preserving the latest seekable position and play state rather than providing a seamless segment-level switch.
The app ignores yt-dlp configuration and plugins, does not read browser cookies, and does not update or fetch extractor components automatically. Private, age-restricted, region-blocked, or sign-in-required videos can fail. If YouTube extraction changes, run `brew upgrade yt-dlp deno`; local playback needs neither dependency. The app ignores yt-dlp configuration and plugins, does not read browser cookies, and does not update or fetch extractor components automatically. Private, age-restricted, region-blocked, or sign-in-required videos can fail. If YouTube extraction changes, update the pinned helper versions/checksums and rebuild/reinstall terebi; upgrading an external Homebrew yt-dlp does not change the bundled extractor.
## Resume playback ## Resume playback
@@ -219,14 +220,16 @@ Playback terms are defined in [CONTEXT.md](CONTEXT.md).
- `Sources/App/PlaybackProgress.swift`: local per-video position persistence and canonical file identities, using injectable UserDefaults. The player model checkpoints authoritative positions, supplies saved YouTube positions as backend open intent, and queues local restoration against the ready source. - `Sources/App/PlaybackProgress.swift`: local per-video position persistence and canonical file identities, using injectable UserDefaults. The player model checkpoints authoritative positions, supplies saved YouTube positions as backend open intent, and queues local restoration against the ready source.
- `Sources/App/SystemPlaybackControls.swift`: native remote-command registration, main-thread dispatch, Now Playing publication, seekability gating, and target cleanup. - `Sources/App/SystemPlaybackControls.swift`: native remote-command registration, main-thread dispatch, Now Playing publication, seekability gating, and target cleanup.
- `Sources/App/CompactWindow.swift`: same-window compact geometry, saved normal window state, asynchronous fullscreen transitions and failure recovery, all-Spaces/floating behavior, and magnetic edge snapping with a session-only toggle. - `Sources/App/CompactWindow.swift`: same-window compact geometry, saved normal window state, asynchronous fullscreen transitions and failure recovery, all-Spaces/floating behavior, and magnetic edge snapping with a session-only toggle.
- `Sources/App/YouTubeResolver.swift`: YouTube URL and metadata validation, external yt-dlp/runtime discovery, capped-default or explicit-resolution SDR selection, playable resolution discovery and headers, bounded nonblocking process output, sanitized extraction errors, and request-local cancellation. - `Sources/App/YouTubeResolver.swift`: YouTube URL and metadata validation, strict bundled yt-dlp/Deno discovery, capped-default or explicit-resolution SDR selection, playable resolution discovery 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. Fresh instances receive media/config/reporting startup arguments in one launch; default reuse delivers the selected URL to an existing app. The app owns Apple events to prevent SwiftUI's default external-document routing from replacing its singleton window. - `Sources/CLI/PlayerCommand.swift` and `Sources/CLI/TerebiCLI.swift`: command-line validation/help and native application discovery/launching. Fresh instances receive media/config/reporting startup arguments in one launch; default reuse delivers the selected URL to an existing app. The app owns Apple events to prevent SwiftUI's default external-document routing from replacing its singleton window.
- `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. - `Sources/Launcher/FFmpegRuntime.swift` and `Sources/App/Bootstrap.swift`: shared bundled-library 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 five versioned library filenames relative to `Contents/Frameworks`.
- `Sources/Configuration/PlayerConfiguration.swift`: built-in settings, nested behavior/UI/key schema, XDG path resolution, read-only strict TOML loading, and startup bypass. `PlayerAction.swift` defines explicit supported actions/inverse pairs; `KeyBindings.swift` parses chords and validates/indexes the complete binding map. `Package.swift`/`Package.resolved` pin the statically linked decoder. - `Sources/Configuration/PlayerConfiguration.swift`: built-in settings, nested behavior/UI/key schema, XDG path resolution, read-only strict TOML loading, and startup bypass. `PlayerAction.swift` defines explicit supported actions/inverse pairs; `KeyBindings.swift` parses chords and validates/indexes the complete binding map. `Package.swift`/`Package.resolved` pin the statically linked decoder.
- `Makefile`: compiler/linker commands, SwiftPM configuration-library build, dependency preflight, generated runtime manifest, developer/release bundles, installation, and checks. No generated Xcode project. - `Makefile`, `Scripts/dependencies.py`, `Scripts/bundle.py` and `Dependencies.lock.json`: pinned/checksummed native library and helper preparation, compiler/linker commands, SwiftPM configuration-library build, private library relocation/signing, developer/release bundles, installation, and checks. No generated Xcode project.
## Runnable check ## Runnable check
The full suite generates media and reference PCM using the development-only `ffmpeg`/`ffprobe` command-line tools (`brew install ffmpeg`). They are not required to build, install, or run the app.
```sh ```sh
make check make check
``` ```
@@ -239,7 +242,7 @@ Generates short fixtures and exercises the real playback interface and native ou
`make check-config` checks isolated XDG/fallback paths, missing-file defaults without creation, partial/quoted/commented nested TOML, invalid syntax/types/keys/UTF-8, shortcut replacement/unbinding, chord normalization, inverse-pair sharing and unrelated conflicts, invalid action/quality names, duplicate keys, read errors, explicit path delivery, `--no-config` bypass, and unchanged file bytes/session reset. It needs neither FFmpeg nor a graphical session and never reads the user's configuration. `make check-config` checks isolated XDG/fallback paths, missing-file defaults without creation, partial/quoted/commented nested TOML, invalid syntax/types/keys/UTF-8, shortcut replacement/unbinding, chord normalization, inverse-pair sharing and unrelated conflicts, invalid action/quality names, duplicate keys, read errors, explicit path delivery, `--no-config` bypass, and unchanged file bytes/session reset. It needs neither FFmpeg nor a graphical session and never reads the user's configuration.
`make check-youtube` checks URL/metadata/header trust boundaries, resolution choices and the default cap, and real subprocess output, bounded pipe draining, cancellation, and request isolation using isolated extractor fixtures. If yt-dlp is installed, it also runs the actual selector against offline metadata to verify 1080p priority over extractor preferences, combined-stream selection, lower-resolution fallback, and explicit 4K. It requires neither Deno nor external network access; the actual-selector portion is skipped when yt-dlp is absent. `make check-youtube` checks URL/metadata/header trust boundaries, resolution choices and the default cap, real subprocess output, bounded pipe draining, cancellation, request isolation, and damaged/moved helper discovery using isolated fixtures. It also runs the actual bundled extractor against offline metadata to verify 1080p priority over extractor preferences, combined-stream selection, lower-resolution fallback, and explicit 4K; no external network access is required.
`make check-network` uses a range-capable loopback HTTP server and actual native playback. It checks combined/separate streams, identical stream indices across inputs, selected headers, coordinated seeks, exact VFR/B-frame stepping, asymmetric EOF and audio-tail seeks, recorded native HLS seeks, live/non-range seek rejection, buffering before the entire file arrives, permanently stalled primary/secondary reads, cancellation/replacement/destruction, HTTP failures, and recovery. A CDN fixture withholds oversized/open-ended responses: combined and split playback must build a minute of reserve using bounded continuation, retain required headers, seek successfully, and consume the final partial range to EOF. The combined case also verifies one persistent connection across sequential responses; split inputs exercise servers that close connections. Retained-range checks cover a minute of read-ahead, a thirty-second rewind window, eviction, cached bidirectional seeks without new HTTP requests while a future response stays gated, cache-miss refill, FFprobe-derived supplied-packet timestamp bounds/B-frame dependencies, required-track constraints, gate-release refill, shorter-track EOF tails, and clearing on close/error/local replacement. It is included in `make check`. `make check-network` uses a range-capable loopback HTTP server and actual native playback. It checks combined/separate streams, identical stream indices across inputs, selected headers, coordinated seeks, exact VFR/B-frame stepping, asymmetric EOF and audio-tail seeks, recorded native HLS seeks, live/non-range seek rejection, buffering before the entire file arrives, permanently stalled primary/secondary reads, cancellation/replacement/destruction, HTTP failures, and recovery. A CDN fixture withholds oversized/open-ended responses: combined and split playback must build a minute of reserve using bounded continuation, retain required headers, seek successfully, and consume the final partial range to EOF. The combined case also verifies one persistent connection across sequential responses; split inputs exercise servers that close connections. Retained-range checks cover a minute of read-ahead, a thirty-second rewind window, eviction, cached bidirectional seeks without new HTTP requests while a future response stays gated, cache-miss refill, FFprobe-derived supplied-packet timestamp bounds/B-frame dependencies, required-track constraints, gate-release refill, shorter-track EOF tails, and clearing on close/error/local replacement. It is included in `make check`.
@@ -253,7 +256,9 @@ Adaptive transition checks also gate a real extraction, cancel it by pausing, an
`make check-cli` checks relative/escaped paths, option-like filenames, YouTube identity, default reuse versus explicit `--new-instance`, `--no-config` alone and combined in either order, independent empty/local/YouTube launches, help without launch, argument cardinality, unknown/duplicate 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, default reuse versus explicit `--new-instance`, `--no-config` alone and combined in either order, independent empty/local/YouTube launches, help without launch, argument cardinality, unknown/duplicate 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. `make check-runtime` exercises missing/invalid dependency metadata, unsafe paths/versions, unavailable and unloadable dylibs, omitted library families, and successful native loading from an isolated relocated copy of the bundled Frameworks. It is included in `make check`; no installed Homebrew files are modified.
`make check-bundle` copies the complete app to a path with spaces and exercises its symlinked CLI, native media reading, software AV1 availability, actual standalone extractor/Python/EJS and Deno with Homebrew, original app and dependency-cache access denied by the macOS sandbox. Removing a transitive bundled library must block launch with exit 69 without breaking help output. `Scripts.dependencies.self_check()` additionally checks archive traversal/symlink rejection with isolated files.
`make check-media` exercises actual playback through the app model, idempotent play/pause, immediate and rapid opposite playback intents, held-key suppression, paused seeking and start-boundary clamping, non-seekable state transitions, immediate hiding of a stale buffered snapshot on accepted close, and Now Playing cleanup on close/error/shutdown. It also checks default-off/configured settings, session-only overrides, and retained-range independence from the display preference. 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-media` exercises actual playback through the app model, idempotent play/pause, immediate and rapid opposite playback intents, held-key suppression, paused seeking and start-boundary clamping, non-seekable state transitions, immediate hiding of a stale buffered snapshot on accepted close, and Now Playing cleanup on close/error/shutdown. It also checks default-off/configured settings, session-only overrides, and retained-range independence from the display preference. 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.
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Stage private runtime dependencies, relocate Mach-O links, and sign the app."""
import argparse
from collections import deque
from pathlib import Path
import platform
import re
import shutil
import subprocess
SYSTEM_PATHS = ("/usr/lib/", "/System/Library/", "/Library/Apple/System/")
FFMPEG_LIBRARY = re.compile(r"lib(?:avformat|avcodec|avutil|swresample|swscale)(?:\.\d+)+\.dylib\Z")
def run(*arguments):
return subprocess.run(arguments, check=True, text=True, stdout=subprocess.PIPE).stdout
def dependencies(path):
lines = run("otool", "-arch", platform.machine(), "-L", str(path)).splitlines()[1:]
if path.suffix == ".dylib":
lines = lines[1:] # LC_ID_DYLIB is not a dependency.
return [line.strip().split(" (compatibility version ", 1)[0] for line in lines]
def bundle(app, root, identity):
frameworks = app / "Contents/Frameworks"
executables = app / "Contents/MacOS"
resources = app / "Contents/Resources"
player = frameworks / "libterebi-player.dylib"
search_paths = [*(root / name / "lib" for name in ("ffmpeg", "dav1d", "xz")), frameworks]
for name in ("yt-dlp", "deno"):
shutil.copy2(root / "bin" / name, executables / name)
for name in ("licenses", "sources"):
destination = resources / name
if destination.exists():
shutil.rmtree(destination)
shutil.copytree(root / name, destination)
def resolve(reference, owner):
if reference.startswith("@loader_path/"):
candidates = [owner.parent / reference[len("@loader_path/"):]]
elif reference.startswith("@executable_path/"):
candidates = [executables / reference[len("@executable_path/"):]]
elif reference.startswith("@rpath/"):
candidates = [directory / reference[len("@rpath/"):] for directory in search_paths]
elif reference.startswith("/"):
candidates = [Path(reference)]
else:
raise RuntimeError(f"Unsupported library reference {reference!r} in {owner}")
for candidate in candidates:
if candidate.is_file():
source = candidate.resolve()
if not any(source.is_relative_to(directory.resolve()) for directory in search_paths):
raise RuntimeError(f"Unpinned external library {source} required by {owner}")
return source
raise RuntimeError(f"Cannot resolve {reference!r} required by {owner}")
modules = [player, *(executables / name for name in ("terebi", "terebi-cli", "yt-dlp", "deno"))]
pending = deque(modules)
staged = {player.resolve(): player}
links = {}
claimed = {player.name: player.resolve()}
ffmpeg = []
while pending:
owner = pending.popleft()
owner_links = []
for reference in dependencies(owner):
if reference.startswith(SYSTEM_PATHS):
continue
source = resolve(reference, owner)
if source not in staged:
name = Path(reference).name
previous = claimed.get(name)
if previous is not None and previous != source:
raise RuntimeError(f"Conflicting private library filename: {name}")
claimed[name] = source
destination = frameworks / name
staged[source] = destination
pending.append(source)
destination = staged[source]
owner_links.append((reference, destination))
if owner == player and FFMPEG_LIBRARY.fullmatch(destination.name):
ffmpeg.append(destination.name)
links[owner] = owner_links
if len(ffmpeg) != 5 or len({name.split(".")[0] for name in ffmpeg}) != 5:
raise RuntimeError("Player must link exactly the five required FFmpeg library families")
# Discover the complete closure before copying or editing any source file.
for source, destination in staged.items():
if source != destination.resolve():
shutil.copy2(source, destination)
destination.chmod(0o755)
retained = {destination.name for destination in staged.values()}
for old in frameworks.glob("*.dylib"):
if old.name not in retained:
old.unlink()
for owner, owner_links in links.items():
destination = staged.get(owner.resolve(), owner)
changes = []
if destination.suffix == ".dylib":
changes += ["-id", "@loader_path/" + destination.name]
for reference, dependency in owner_links:
relative = ("@loader_path/" if destination.parent == frameworks else "@executable_path/../Frameworks/") + dependency.name
if reference != relative:
changes += ["-change", reference, relative]
if changes:
run("install_name_tool", *changes, str(destination))
resources.mkdir(parents=True, exist_ok=True)
(resources / "ffmpeg-libraries.txt").write_text("\n".join(sorted(ffmpeg)) + "\n")
# Fail the build, not the user's launch, if any link still needs a build machine.
for module in [*staged.values(), *modules[1:]]:
for reference in dependencies(module):
if not reference.startswith((*SYSTEM_PATHS, "@loader_path/", "@executable_path/../Frameworks/")):
raise RuntimeError(f"Nonportable reference {reference!r} remains in {module}")
run("codesign", "--force", "--sign", identity, str(module))
run("codesign", "--force", "--sign", identity, str(app))
run("codesign", "--verify", "--deep", "--strict", str(app))
print(f"Bundled {len(staged) - 1} private libraries, yt-dlp and Deno in {app}")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--app", type=Path, required=True)
parser.add_argument("--dependencies", type=Path, required=True)
parser.add_argument("--signing-identity", default="-")
args = parser.parse_args()
try:
bundle(args.app.resolve(), args.dependencies.resolve(), args.signing_identity)
except (OSError, RuntimeError, subprocess.CalledProcessError) as error:
parser.exit(1, f"Error: could not bundle Terebi: {error}\n")
if __name__ == "__main__":
main()
+307
View File
@@ -0,0 +1,307 @@
#!/usr/bin/env python3
"""Prepare pinned native macOS libraries or standalone helpers; no runtime package manager."""
import argparse
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import platform
import shlex
import shutil
import subprocess
import sys
import tarfile
import tempfile
import urllib.error
import urllib.request
import zipfile
REPOSITORY = Path(__file__).resolve().parent.parent
LOCK = REPOSITORY / "Dependencies.lock.json"
def sha256(path):
digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def download(asset, directory):
directory.mkdir(parents=True, exist_ok=True)
target = directory / asset["filename"]
if target.is_file() and sha256(target) == asset["sha256"]:
return target
temporary = None
try:
with tempfile.NamedTemporaryFile(dir=directory, delete=False) as output:
temporary = Path(output.name)
request = urllib.request.Request(asset["url"], headers={"User-Agent": "Terebi-dependency-preparer"})
with urllib.request.urlopen(request, timeout=120) as response:
shutil.copyfileobj(response, output)
actual = sha256(temporary)
if actual != asset["sha256"]:
raise RuntimeError(f"SHA256 mismatch for {asset['url']}: expected {asset['sha256']}, got {actual}; no unverified download installed")
temporary.chmod(0o644)
os.replace(temporary, target)
return target
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
def archive_path(name, destination):
# Reject links altogether: the pinned source archives need none. This also
# prevents a preceding archive member from redirecting subsequent writes.
relative = PurePosixPath(name)
if relative.is_absolute() or ".." in relative.parts or "\\" in name:
raise RuntimeError(f"Unsafe archive member: {name!r}")
target = destination.joinpath(*relative.parts)
if not target.resolve().is_relative_to(destination.resolve()):
raise RuntimeError(f"Archive member escapes extraction directory: {name!r}")
return target
def extract(archive, destination):
destination.mkdir(parents=True, exist_ok=True)
if zipfile.is_zipfile(archive):
with zipfile.ZipFile(archive) as source:
for member in source.infolist():
target = archive_path(member.filename, destination)
mode = member.external_attr >> 16
if mode & 0o170000 not in (0, 0o100000, 0o040000):
raise RuntimeError(f"Unsupported archive member: {member.filename}")
if member.is_dir():
target.mkdir(parents=True, exist_ok=True)
else:
target.parent.mkdir(parents=True, exist_ok=True)
with source.open(member) as data, target.open("wb") as output:
shutil.copyfileobj(data, output)
target.chmod(0o755 if mode & 0o111 else 0o644)
else:
with tarfile.open(archive) as source:
for member in source:
target = archive_path(member.name, destination)
if member.isdir():
target.mkdir(parents=True, exist_ok=True)
elif member.isfile():
target.parent.mkdir(parents=True, exist_ok=True)
with source.extractfile(member) as data, target.open("wb") as output:
shutil.copyfileobj(data, output)
target.chmod(0o755 if member.mode & 0o111 else 0o644)
else:
raise RuntimeError(f"Unsupported archive member: {member.name}")
children = list(destination.iterdir())
return children[0] if len(children) == 1 and children[0].is_dir() else destination
def run(command, cwd=None, env=None):
print("+ " + shlex.join(map(str, command)), flush=True)
subprocess.run(list(map(str, command)), cwd=cwd, env=env, check=True)
def native(root, arch, lock):
for tool in ("xcrun", "make", "meson", "ninja", "pkg-config") + (("nasm",) if arch == "x86_64" else ()):
if shutil.which(tool) is None:
raise RuntimeError(f"Missing build prerequisite {tool}; install it before preparing native dependencies")
sdk = subprocess.check_output(["xcrun", "--sdk", "macosx", "--show-sdk-path"], text=True).strip()
clang = subprocess.check_output(["xcrun", "--find", "clang"], text=True).strip()
clangxx = subprocess.check_output(["xcrun", "--find", "clang++"], text=True).strip()
flags = shlex.join(["-arch", arch, "-isysroot", sdk, "-mmacosx-version-min=15.0"])
environment = dict(os.environ)
# Host packages must never leak into dependency discovery or compiler flags.
for name in ("CPATH", "C_INCLUDE_PATH", "CPLUS_INCLUDE_PATH", "LIBRARY_PATH", "SDKROOT", "ARCHFLAGS", "CPPFLAGS", "PKG_CONFIG_PATH", "PKG_CONFIG_SYSROOT_DIR", "DYLD_LIBRARY_PATH"):
environment.pop(name, None)
environment.update(CC=clang, CXX=clangxx, CFLAGS=flags, CXXFLAGS=flags,
OBJCFLAGS=flags, LDFLAGS=flags, MACOSX_DEPLOYMENT_TARGET="15.0",
PKG_CONFIG_LIBDIR=os.pathsep.join(str(root / name / "lib/pkgconfig") for name in ("dav1d", "xz")), PKG_CONFIG_PATH="")
work = root / "work/native"
shutil.rmtree(work, ignore_errors=True)
work.mkdir(parents=True)
for component in ("dav1d", "xz", "ffmpeg"):
shutil.rmtree(root / component, ignore_errors=True)
sources = root / "sources/native"
sources.mkdir(parents=True, exist_ok=True)
licenses = root / "licenses/native"
licenses.mkdir(parents=True, exist_ok=True)
trees = {}
for component in ("dav1d", "xz", "ffmpeg"):
archive = download(lock[component]["source"], root / "downloads")
shutil.copy2(archive, sources / archive.name)
trees[component] = extract(archive, work / component)
dav1d_command = ["meson", "setup", work / "dav1d-build", trees["dav1d"],
"--prefix=" + str(root / "dav1d"), "--libdir=lib", "--buildtype=release",
"--default-library=shared", "--wrap-mode=nodownload", "-Denable_tools=false",
"-Denable_tests=false", "-Denable_examples=false", "-Denable_docs=false"]
xz_command = [trees["xz"] / "configure", "--prefix=" + str(root / "xz"),
"--enable-shared", "--disable-static", "--disable-xz", "--disable-xzdec",
"--disable-lzmadec", "--disable-lzmainfo", "--disable-scripts", "--disable-doc", "--disable-nls"]
ffmpeg_command = [trees["ffmpeg"] / "configure", "--prefix=" + str(root / "ffmpeg"),
"--arch=" + arch, "--target-os=darwin", "--cc=" + clang, "--cxx=" + clangxx,
"--host-cc=" + clang, "--host-cflags=" + flags, "--host-ldflags=" + flags,
"--extra-cflags=" + flags + " " + shlex.quote("-I" + str(root / "xz/include")),
"--extra-ldflags=" + flags + " " + shlex.quote("-L" + str(root / "xz/lib")),
"--enable-shared", "--disable-static", "--disable-programs", "--disable-doc",
"--disable-encoders", "--disable-muxers", "--disable-avdevice", "--disable-avfilter",
"--disable-gpl", "--disable-nonfree", "--disable-version3", "--disable-autodetect",
"--enable-libdav1d", "--enable-securetransport", "--enable-videotoolbox",
"--enable-audiotoolbox", "--enable-zlib", "--enable-bzlib", "--enable-lzma"]
configuration = {"arch": arch, "deployment_target": "15.0", "sdk": sdk,
"environment": {key: environment[key] for key in ("CC", "CXX", "CFLAGS", "CXXFLAGS", "LDFLAGS", "MACOSX_DEPLOYMENT_TARGET", "PKG_CONFIG_LIBDIR", "PKG_CONFIG_PATH")},
"dav1d": list(map(str, dav1d_command)), "xz": list(map(str, xz_command)),
"ffmpeg": list(map(str, ffmpeg_command))}
(licenses / "build-configuration.json").write_text(json.dumps(configuration, indent=2) + "\n")
run(dav1d_command, env=environment)
run(["ninja", "-C", work / "dav1d-build"], env=environment)
run(["ninja", "-C", work / "dav1d-build", "install"], env=environment)
xz_build = work / "xz-build"
xz_build.mkdir()
run(xz_command, cwd=xz_build, env=environment)
run(["make", "-j", str(os.cpu_count() or 1)], cwd=xz_build, env=environment)
run(["make", "install"], cwd=xz_build, env=environment)
build = work / "ffmpeg-build"
build.mkdir()
run(ffmpeg_command, cwd=build, env=environment)
run(["make", "-j", str(os.cpu_count() or 1)], cwd=build, env=environment)
run(["make", "install"], cwd=build, env=environment)
for filename in ("COPYING.LGPLv2.1", "LICENSE.md"):
shutil.copy2(trees["ffmpeg"] / filename, licenses / ("FFmpeg-" + filename))
shutil.copy2(trees["dav1d"] / "COPYING", licenses / "dav1d-COPYING")
shutil.copy2(trees["xz"] / "COPYING", licenses / "liblzma-COPYING")
for filename in ("config.h", "ffbuild/config.mak"):
shutil.copy2(build / filename, licenses / ("FFmpeg-" + Path(filename).name))
(licenses / "NOTICE.txt").write_text(
"FFmpeg 9.0.1: LGPL-2.1-or-later; dav1d 1.5.4: BSD-2-Clause; liblzma 5.8.4: 0BSD/public domain.\n"
"Exact unmodified source archives accompany this distribution under sources/native.\n"
"Build commands and flags are in build-configuration.json; source changes: none.\n"
"This software is based in part on the work of the Independent JPEG Group.\n"
"FFmpeg JPEG source files were not modified. Apple system libraries are supplied by macOS.\n"
"You may replace these shared libraries with compatible modified versions; no static linking is used.\n")
def helpers(root, arch, lock):
binaries = root / "bin"
binaries.mkdir(parents=True, exist_ok=True)
sources = root / "sources/helpers"
sources.mkdir(parents=True, exist_ok=True)
licenses = root / "licenses/helpers"
licenses.mkdir(parents=True, exist_ok=True)
for name in ("yt-dlp", "deno"):
component = lock[name]
asset = component["binary"] if name == "yt-dlp" else component["binaries"][arch]
archive = download(asset, root / "downloads")
with tempfile.TemporaryDirectory(dir=root) as temporary:
stage = Path(temporary)
executable = archive if name == "yt-dlp" else extract(archive, stage) / "deno"
if not executable.is_file():
raise RuntimeError(f"Pinned {name} archive does not contain its expected executable")
target = stage / "installed-executable"
shutil.copyfile(executable, target)
target.chmod(0o755)
os.replace(target, binaries / name)
for material in component["materials"]:
path = download(material, root / "downloads")
shutil.copy2(path, (sources if material.get("kind") == "source" else licenses) / path.name)
(licenses / (name + "-provenance.json")).write_text(json.dumps(component, indent=2) + "\n")
(licenses / (name + "-NOTICE.txt")).write_text(component["notice"] + "\n")
def outputs_exist(root, part, lock):
if part == "helpers":
return (all((root / "bin" / name).is_file() and os.access(root / "bin" / name, os.X_OK)
and all(((root / "sources/helpers" if material.get("kind") == "source" else root / "licenses/helpers") / material["filename"]).is_file()
for material in lock[name]["materials"])
and (root / "licenses/helpers" / (name + "-NOTICE.txt")).is_file()
and (root / "licenses/helpers" / (name + "-provenance.json")).is_file()
for name in ("yt-dlp", "deno")))
return ((root / "ffmpeg/include/libavcodec/avcodec.h").is_file()
and all((root / "ffmpeg/lib/pkgconfig" / (name + ".pc")).is_file()
and any((root / "ffmpeg/lib").glob(name + ".*.dylib"))
for name in ("libavcodec", "libavformat", "libavutil", "libswscale", "libswresample"))
and any((root / "dav1d/lib").glob("libdav1d.*.dylib"))
and any((root / "xz/lib").glob("liblzma.*.dylib"))
and (root / "licenses/native/build-configuration.json").is_file()
and all((root / "sources/native" / lock[name]["source"]["filename"]).is_file()
for name in ("ffmpeg", "dav1d", "xz")))
def self_check():
"""Offline archive-boundary check: python3 -c 'from Scripts.dependencies import self_check; self_check()'."""
build = REPOSITORY / ".build"
build.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(dir=build) as temporary:
root = Path(temporary)
for name in ("../escape", "/absolute", "safe/../../escape", "safe\\escape"):
try:
archive_path(name, root)
except RuntimeError:
pass
else:
raise AssertionError(f"Unsafe path accepted: {name}")
assert archive_path("safe/file", root) == root / "safe/file"
archive = root / "links.tar"
with tarfile.open(archive, "w") as output:
link = tarfile.TarInfo("link")
link.type = tarfile.SYMTYPE
link.linkname = "../outside"
output.addfile(link)
try:
extract(archive, root / "extracted")
except RuntimeError:
pass
else:
raise AssertionError("Archive symlink accepted")
archive = root / "traversal.zip"
with zipfile.ZipFile(archive, "w") as output:
output.writestr("../escape", "unsafe")
try:
extract(archive, root / "zip-extracted")
except RuntimeError:
pass
else:
raise AssertionError("ZIP traversal accepted")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", required=True, type=Path)
parser.add_argument("--arch", required=True, choices=("arm64", "x86_64"))
parser.add_argument("--part", required=True, choices=("native", "helpers"))
args = parser.parse_args()
if platform.system() != "Darwin" or platform.machine() != args.arch:
parser.error(f"Native macOS {args.arch} host required; running {platform.system()} {platform.machine()}. Cross-architecture builds are unsupported (do not run under Rosetta).")
translated = subprocess.run(["/usr/sbin/sysctl", "-in", "sysctl.proc_translated"],
capture_output=True, text=True, check=False)
if translated.stdout.strip() == "1":
parser.error("Rosetta builds are unsupported; use native arm64 Python and build tools.")
if not args.root.is_absolute():
parser.error("--root must be an absolute path beneath the repository's ignored .build directory")
root = args.root.resolve()
if not root.is_relative_to((REPOSITORY / ".build").resolve()):
parser.error("--root must be beneath the repository's ignored .build directory")
lock = json.loads(LOCK.read_text())
fingerprint = hashlib.sha256(LOCK.read_bytes() + Path(__file__).read_bytes() + args.arch.encode() + args.part.encode()).hexdigest()
root.mkdir(parents=True, exist_ok=True)
stamp = root / (args.part + ".stamp")
if stamp.is_file() and stamp.read_text().strip() == fingerprint and outputs_exist(root, args.part, lock):
return 0
stamp.unlink(missing_ok=True)
try:
(native if args.part == "native" else helpers)(root, args.arch, lock)
if not outputs_exist(root, args.part, lock):
raise RuntimeError(f"{args.part} preparation completed without all required outputs")
shutil.copy2(LOCK, root / "licenses/Dependencies.lock.json")
shutil.copy2(Path(__file__), root / "licenses/dependencies.py")
with tempfile.NamedTemporaryFile(mode="w", dir=root, delete=False) as output:
output.write(fingerprint + "\n")
temporary = Path(output.name)
os.replace(temporary, stamp)
except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError, tarfile.TarError, zipfile.BadZipFile) as error:
print(f"Dependency preparation failed: {error}. Fix the prerequisite/network issue and rerun the same command; no readiness stamp was published.", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+25 -11
View File
@@ -68,7 +68,7 @@ enum YouTubeResolver {
do { do {
metadata = try JSONDecoder().decode(YouTubeMetadata.self, from: data) metadata = try JSONDecoder().decode(YouTubeMetadata.self, from: data)
} catch { } catch {
throw YouTubeResolutionError(message: "yt-dlp returned malformed video metadata. Update yt-dlp and try again.") throw YouTubeResolutionError(message: "The bundled yt-dlp returned malformed video metadata. Upgrade terebi or rebuild its bundled helpers and try again.")
} }
guard metadata._type == nil || metadata._type == "video" else { guard metadata._type == nil || metadata._type == "video" else {
throw YouTubeResolutionError(message: "yt-dlp did not resolve a single playable video.") throw YouTubeResolutionError(message: "yt-dlp did not resolve a single playable video.")
@@ -143,21 +143,26 @@ enum YouTubeResolver {
guard height == nil || height! > 0 else { guard height == nil || height! > 0 else {
throw YouTubeResolutionError(message: "Choose a positive video resolution.") throw YouTubeResolutionError(message: "Choose a positive video resolution.")
} }
let directories = searchDirectories() let bundle = Bundle.main.bundleURL
let directories = bundle.pathExtension == "app"
? [bundle.appendingPathComponent("Contents/MacOS").path]
: searchDirectories()
let executable = try executableURL.map { try executableFile($0, name: "yt-dlp") } let executable = try executableURL.map { try executableFile($0, name: "yt-dlp") }
?? bundledExecutable("yt-dlp")
?? findExecutable("yt-dlp", directories: directories) ?? findExecutable("yt-dlp", directories: directories)
guard let executable else { guard let executable else {
throw YouTubeResolutionError(message: "YouTube playback requires yt-dlp. Install it with ‘brew install yt-dlp deno’, then try again.") throw YouTubeResolutionError(message: "YouTube playback requires yt-dlp. Rebuild or reinstall terebi with its bundled helpers.")
} }
let runtime: URL? let runtime: URL?
if let runtimeURL { if let runtimeURL {
runtime = try executableFile(runtimeURL, name: "JavaScript runtime") runtime = try executableFile(runtimeURL, name: "JavaScript runtime")
} else { } else {
runtime = ["deno", "node", "qjs", "quickjs", "bun"].lazy.compactMap { findExecutable($0, directories: directories) }.first runtime = try bundledExecutable("deno")
?? ["deno", "node", "qjs", "quickjs", "bun"].lazy.compactMap { findExecutable($0, directories: directories) }.first
} }
guard let runtime, guard let runtime,
let runtimeName = ["deno": "deno", "node": "node", "qjs": "quickjs", "quickjs": "quickjs", "bun": "bun"][runtime.lastPathComponent] else { let runtimeName = ["deno": "deno", "node": "node", "qjs": "quickjs", "quickjs": "quickjs", "bun": "bun"][runtime.lastPathComponent] else {
throw YouTubeResolutionError(message: "YouTube playback requires a supported JavaScript runtime. Install Deno with ‘brew install deno’. Supported runtimes are Deno, Node, QuickJS, and Bun.") throw YouTubeResolutionError(message: "YouTube playback requires a supported JavaScript runtime. Rebuild or reinstall terebi with its bundled Deno helper.")
} }
var environment = ProcessInfo.processInfo.environment var environment = ProcessInfo.processInfo.environment
environment["PATH"] = directories.joined(separator: ":") environment["PATH"] = directories.joined(separator: ":")
@@ -209,9 +214,18 @@ enum YouTubeResolver {
return YouTubeStream(url: url.absoluteString, headers: headers) return YouTubeStream(url: url.absoluteString, headers: headers)
} }
// A missing bundled helper is an installation error, never permission to use PATH.
static func bundledExecutable(_ name: String, bundleURL: URL = Bundle.main.bundleURL) throws -> URL? {
guard bundleURL.pathExtension == "app" else { return nil }
let url = bundleURL.appendingPathComponent("Contents/MacOS").appendingPathComponent(name)
guard isExecutableFile(url) else {
throw YouTubeResolutionError(message: "This app's bundled \(name) helper is missing or is not executable. Rebuild or reinstall the complete terebi app.")
}
return url
}
private static func searchDirectories() -> [String] { private static func searchDirectories() -> [String] {
let paths = (ProcessInfo.processInfo.environment["PATH"] ?? "").split(separator: ":").map(String.init) let paths = (ProcessInfo.processInfo.environment["PATH"] ?? "").split(separator: ":").map(String.init)
+ ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]
var result: [String] = [] var result: [String] = []
for path in paths where path.hasPrefix("/") && !result.contains(path) { result.append(path) } for path in paths where path.hasPrefix("/") && !result.contains(path) { result.append(path) }
return result return result
@@ -240,10 +254,10 @@ enum YouTubeResolver {
fileprivate static func dependencyError(_ text: String) -> YouTubeResolutionError? { fileprivate static func dependencyError(_ text: String) -> YouTubeResolutionError? {
if text.contains("yt-dlp-ejs") || text.contains("challenge solving failed") || text.contains("no supported javascript") || text.contains("no javascript runtime") { if text.contains("yt-dlp-ejs") || text.contains("challenge solving failed") || text.contains("no supported javascript") || text.contains("no javascript runtime") {
return YouTubeResolutionError(message: "YouTube extraction requires current yt-dlp, its EJS components, and a supported JavaScript runtime. Run ‘brew upgrade yt-dlp deno’. For a pip installation, install ‘yt-dlp[default]’ and Deno.") return YouTubeResolutionError(message: "The bundled YouTube helpers could not solve YouTube's JavaScript challenge. Upgrade terebi or rebuild its bundled yt-dlp, EJS components, and Deno.")
} }
if text.contains("no such option") || text.contains("unrecognized arguments") { if text.contains("no such option") || text.contains("unrecognized arguments") {
return YouTubeResolutionError(message: "The installed yt-dlp is too old for YouTube streaming. Update it with ‘brew upgrade yt-dlp’.") return YouTubeResolutionError(message: "The bundled yt-dlp is too old for YouTube streaming. Upgrade terebi or rebuild its bundled helpers.")
} }
return nil return nil
} }
@@ -256,15 +270,15 @@ enum YouTubeResolver {
return YouTubeResolutionError(message: "YouTube requires sign-in or additional access for this video. terebi does not read browser cookies; try a publicly accessible video.") return YouTubeResolutionError(message: "YouTube requires sign-in or additional access for this video. terebi does not read browser cookies; try a publicly accessible video.")
} }
if text.contains("no video formats") || text.contains("no formats") || text.contains("requested format") { if text.contains("no video formats") || text.contains("no formats") || text.contains("requested format") {
return YouTubeResolutionError(message: "YouTube did not provide a supported SDR video and audio stream. Update yt-dlp and try again.") return YouTubeResolutionError(message: "YouTube did not provide a supported SDR video and audio stream. Upgrade terebi or rebuild its bundled helpers and try again.")
} }
if text.contains("not available") || text.contains("unavailable") || text.contains("removed") || text.contains("copyright") || text.contains("blocked") { if text.contains("not available") || text.contains("unavailable") || text.contains("removed") || text.contains("copyright") || text.contains("blocked") {
return YouTubeResolutionError(message: "This YouTube video is unavailable or restricted in your region.") return YouTubeResolutionError(message: "This YouTube video is unavailable or restricted in your region.")
} }
if text.contains("timed out") || text.contains("connection") || text.contains("http error") || text.contains("certificate") || text.contains("urlopen") { if text.contains("timed out") || text.contains("connection") || text.contains("http error") || text.contains("certificate") || text.contains("urlopen") {
return YouTubeResolutionError(message: "Could not reach YouTube. Check your network connection and try again; update yt-dlp if the problem persists.") return YouTubeResolutionError(message: "Could not reach YouTube. Check your network connection and try again; upgrade terebi if the problem persists.")
} }
return YouTubeResolutionError(message: "yt-dlp could not resolve this video (exit status \(status)). Update yt-dlp and Deno, then try again.") return YouTubeResolutionError(message: "The bundled yt-dlp could not resolve this video (exit status \(status)). Upgrade terebi or rebuild its bundled helpers, then try again.")
} }
} }
+14 -3
View File
@@ -36,11 +36,22 @@ enum TerebiCLI {
fail(error.localizedDescription, code: 78) fail(error.localizedDescription, code: 78)
} }
} }
guard let executable = Bundle.main.executableURL else { var size: UInt32 = 0
_ = _NSGetExecutablePath(nil, &size)
var path = [CChar](repeating: 0, count: Int(size))
guard _NSGetExecutablePath(&path, &size) == 0 else {
fail("Could not locate the CLI executable.", code: 69) fail("Could not locate the CLI executable.", code: 69)
} }
let app = executable.resolvingSymlinksInPath().deletingLastPathComponent() let executable = path.withUnsafeBufferPointer {
.appendingPathComponent("terebi.app", isDirectory: true) URL(fileURLWithPath: String(cString: $0.baseAddress!)).resolvingSymlinksInPath()
}
let macOS = executable.deletingLastPathComponent()
let contents = macOS.deletingLastPathComponent()
let app = contents.deletingLastPathComponent()
guard macOS.lastPathComponent == "MacOS", contents.lastPathComponent == "Contents",
app.pathExtension == "app" else {
fail("The CLI must be installed inside terebi.app. Rebuild and reinstall the bundled app.", code: 69)
}
if let error = FFmpegRuntime.error(for: app) { if let error = FFmpegRuntime.error(for: app) {
fail(error, code: 69) fail(error, code: 69)
} }
+6 -4
View File
@@ -14,11 +14,11 @@ enum FFmpegRuntime {
guard paths.count == libraries.count else { return metadataError } guard paths.count == libraries.count else { return metadataError }
var found = Set<String>() var found = Set<String>()
for path in paths { for path in paths {
guard path.hasPrefix("/"), !path.contains("\0"), !path.contains("\r") else { guard !path.contains("/"), !path.contains("\0"), !path.contains("\r") else {
return metadataError return metadataError
} }
let parts = URL(fileURLWithPath: path).lastPathComponent.split(separator: ".", omittingEmptySubsequences: false) let parts = URL(fileURLWithPath: path).lastPathComponent.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count >= 2, parts.last == "dylib" else { return metadataError } guard parts.count >= 3, parts.last == "dylib" else { return metadataError }
let library = String(parts[0]) let library = String(parts[0])
guard libraries.contains(library), guard libraries.contains(library),
parts.dropFirst().dropLast().allSatisfy({ !$0.isEmpty && $0.utf8.allSatisfy { (48...57).contains($0) } }), parts.dropFirst().dropLast().allSatisfy({ !$0.isEmpty && $0.utf8.allSatisfy { (48...57).contains($0) } }),
@@ -26,11 +26,13 @@ enum FFmpegRuntime {
return metadataError return metadataError
} }
} }
for path in paths { let frameworks = app.appendingPathComponent("Contents/Frameworks", isDirectory: true)
for filename in paths {
let path = frameworks.appendingPathComponent(filename).path
_ = dlerror() _ = dlerror()
guard dlopen(path, RTLD_NOW | RTLD_LOCAL) != nil else { guard dlopen(path, RTLD_NOW | RTLD_LOCAL) != nil else {
let detail = dlerror().map { String(cString: $0) } ?? "The dynamic loader could not load this library." 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." return "This app's bundled FFmpeg libraries are missing or broken.\n\nRequired library: \(filename)\n\(detail)\n\nRebuild and reinstall terebi, or reinstall a complete bundled app."
} }
// Deliberately never dlclose: retain successful loads until exit. // Deliberately never dlclose: retain successful loads until exit.
} }