Compare commits

..

4 Commits

8 changed files with 414 additions and 73 deletions
+59 -7
View File
@@ -36,6 +36,22 @@ class StartupStreamingServer(StreamingServer):
super()._get(request)
def check_overlay_hover(root, app, fixtures):
result_path = root / "overlay-hover.json"
diagnostics = root / "overlay-hover.log"
result = subprocess.run(
["open", "-n", "-W", "--stderr", str(diagnostics), "-a", str(app),
str(fixtures / "cache.mp4"), "--args", "--no-config", "--check-overlay-hover", str(result_path)],
capture_output=True, text=True, timeout=25,
)
report = json.loads(result_path.read_text()) if result_path.exists() else {}
if result.returncode != 0 or not report.get("passed") or report.get("steps") != 7:
log = diagnostics.read_text(errors="replace") if diagnostics.exists() else ""
raise RuntimeError(f"native overlay hover failed: {report}; {result.stdout}{result.stderr}{log}")
print("PASS native overlay hover: video, title, playback, top-right and queue controls stay visible; "
"window exit hides and reentry reveals controls")
def check_window_drag(root, app, fixtures):
result_path = root / "window-drag.json"
diagnostics = root / "window-drag.log"
@@ -86,6 +102,31 @@ def check_window_layout(root, app, fixtures):
f"{report['animatedQueueTransitions']} queue animations, stable video size, responsive reversals, "
"screen-edge fitting, fullscreen fixed bounds, minimum sizes and exact normal geometry restored")
def check_startup_errors(root, app):
config = root / "invalid-startup.toml"
config.write_text("[ui]\nbuffer_info = [\n")
for name, arguments, title, detail in [
("configuration", ["--config-file", config.as_uri()],
"Could not load Terebi configuration", str(config)),
("progress", ["--no-config", "--progress-file", str(root / "missing-parent/progress.jsonl")],
"Could not initialize playback progress reporting", "progress"),
]:
report_path = root / f"startup-error-{name}.json"
diagnostics = root / f"startup-error-{name}.log"
result = subprocess.run(
["open", "-n", "-W", "--stderr", str(diagnostics), "-a", str(app), "--args",
"--check-startup-error", str(report_path), *arguments],
capture_output=True, text=True, timeout=25,
)
report = json.loads(report_path.read_text()) if report_path.exists() else {}
log = diagnostics.read_text(errors="replace") if diagnostics.exists() else ""
if (result.returncode != 0 or not report.get("passed") or title not in report.get("text", [])
or detail not in "\n".join(report.get("text", [])) or detail not in log):
raise RuntimeError(f"native {name} startup alert failed: {report}; "
f"open status {result.returncode}; {result.stdout}{result.stderr}{log}")
print("PASS native startup errors: configuration and progress failures show focused critical alerts with diagnostics")
def check_cli_instances(root, app, fixtures):
cli = root / "terebi"
@@ -333,8 +374,9 @@ def check_cli_instances(root, app, fixtures):
invoke([str(movie)])
first = wait_states(1, lambda states: all(state["media"] == movie.name and state["status"] == 3
and state["position"] > 0 and state["hasVideo"] for state in states.values()),
"cold default CLI opens one actual player window")
and state["position"] > 0 and state["hasVideo"] and state["active"]
and state["keyWindow"] and state["mainWindow"] for state in states.values()),
"cold default CLI opens and focuses one actual player window before synthetic input")
first_pid = next(iter(first))
renderer = first[first_pid]["window"]
if (not renderer.get("rendererAttached") or renderer.get("rendererHidden")
@@ -366,8 +408,10 @@ def check_cli_instances(root, app, fixtures):
if settings(first[first_pid]) != (False, False, True) or config.parent.exists():
raise RuntimeError(f"missing config must use defaults without creating a file: {first}")
invoke([])
wait_states(1, lambda states: states[first_pid]["media"] == movie.name,
"warm default CLI reuses the existing process without replacing media")
wait_states(1, lambda states: states[first_pid]["media"] == movie.name
and states[first_pid]["active"] and states[first_pid]["keyWindow"]
and states[first_pid]["mainWindow"],
"warm default CLI focuses the existing process without replacing media")
input_request(first_pid, 1, lambda state: state["status"] == 2, "default Space pauses once",
events=[{"key": " ", "code": 49}])
input_request(first_pid, 1, lambda state: state["status"] == 2, "held Space cannot oscillate",
@@ -644,8 +688,10 @@ def check_cli_instances(root, app, fixtures):
shutil.copy2(fixtures / "av.mp4", replacement)
invoke([str(replacement)])
after = wait_states(count, lambda states: sum(state["media"] == replacement.name
for state in states.values()) == 1,
"default media delivery still reuses exactly one existing process")
for state in states.values()) == 1 and all(
state["active"] and state["keyWindow"] and state["mainWindow"]
for state in states.values() if state["media"] == replacement.name),
"default document delivery focuses and reuses exactly one existing process")
changed = [pid for pid in before if before[pid]["media"] != after[pid]["media"]]
if len(changed) != 1:
raise RuntimeError(f"default media open changed multiple players: {before}; {after}")
@@ -658,12 +704,14 @@ def check_cli_instances(root, app, fixtures):
app_source, library, fixtures, dependencies_root = map(lambda path: Path(path).resolve(), sys.argv[1:])
with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
root = Path(directory)
app = root / "terebi.app"
app = root / "Terebi Startup Check.app"
shutil.copytree(app_source, app)
identifier = "local.personal.terebi.startup-check." + uuid.uuid4().hex
info_path = app / "Contents/Info.plist"
info = plistlib.loads(info_path.read_bytes())
info["CFBundleIdentifier"] = identifier
info["CFBundleName"] = "Terebi Startup Check"
info["CFBundleDisplayName"] = "Terebi Startup Check"
info_path.write_bytes(plistlib.dumps(info))
shutil.copy2(library, app / "Contents/Frameworks/libterebi-player.dylib")
subprocess.run([sys.executable, str(Path(__file__).resolve().parent.parent / "Scripts/bundle.py"),
@@ -686,6 +734,10 @@ with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
if launch.returncode != 0 or not result["passed"] or result["bundleIdentifier"] != identifier:
raise RuntimeError(f"packaged startup failed: {result}; {stdout}{stderr}")
print("PASS packaged bootstrap: registered app PID/identity, in-process player load, actual playback and Now Playing")
check_startup_errors(root, app)
check_overlay_hover(root, app, fixtures)
if os.environ.get("TEREBI_CHECK_HOVER_ONLY") == "1":
sys.exit(0)
check_window_drag(root, app, fixtures)
if os.environ.get("TEREBI_CHECK_DRAG_ONLY") != "1":
check_queue_drag(root, app, fixtures)
+134
View File
@@ -1,4 +1,5 @@
import AppKit
import Combine
import Darwin
import MediaPlayer
import ObjectiveC
@@ -6,6 +7,14 @@ import ObjectiveC
@_cdecl("terebi_app_main")
@MainActor
public func checkAppStartup() {
if let index = CommandLine.arguments.firstIndex(of: "--check-startup-error") {
StartupErrorCheck.run(result: URL(fileURLWithPath: CommandLine.arguments[index + 1]))
return
}
if let index = CommandLine.arguments.firstIndex(of: "--check-overlay-hover") {
OverlayHoverCheck.run(result: URL(fileURLWithPath: CommandLine.arguments[index + 1]))
return
}
if let index = CommandLine.arguments.firstIndex(of: "--check-window-drag") {
WindowDragCheck.run(result: URL(fileURLWithPath: CommandLine.arguments[index + 1]))
return
@@ -25,6 +34,131 @@ public func checkAppStartup() {
AppStartupCheck.run()
}
@MainActor
private enum StartupErrorCheck {
static var timer: Timer?
static func run(result: URL) {
let deadline = ContinuousClock.now.advanced(by: .seconds(15))
let observer = Timer(timeInterval: 0.05, repeats: true) { _ in
MainActor.assumeIsolated {
guard let window = NSApp.modalWindow, let content = window.contentView else {
precondition(ContinuousClock.now < deadline, "Startup failure must present a native modal alert")
return
}
@MainActor
func descendants(_ view: NSView) -> [NSView] {
[view] + view.subviews.flatMap { descendants($0) }
}
let views = descendants(content)
let text = views.compactMap { ($0 as? NSTextField)?.stringValue }.filter { !$0.isEmpty }
let button = views.compactMap { $0 as? NSButton }.first { $0.title == "OK" || $0.title == "Quit" }
let focused = NSApp.isActive && window.isKeyWindow
guard focused || ContinuousClock.now >= deadline else { return }
let report: [String: Any] = [
"passed": focused && window.isVisible && button?.isEnabled == true,
"active": NSApp.isActive, "keyWindow": window.isKeyWindow,
"visible": window.isVisible, "text": text,
]
do {
try JSONSerialization.data(withJSONObject: report).write(to: result, options: .atomic)
} catch {
fatalError("Could not report native startup alert: \(error)")
}
timer?.invalidate()
guard let button else { fatalError("Startup alert must have a dismissal button") }
button.performClick(nil)
}
}
timer = observer
RunLoop.main.add(observer, forMode: .modalPanel)
terebiAppMain()
fatalError("Invalid startup input must not start the player")
}
}
@MainActor
private enum OverlayHoverCheck {
static var timer: Timer?
static var observation: AnyCancellable?
static var resultURL: URL!
static var step = 0
static var sample = 0
static var hidden = 0
static var savedPointer = NSPoint.zero
static let names = ["video", "title", "playback controls", "top-right controls",
"queue controls", "window exit", "window reentry"]
// Infrastructure watchdog only; verdicts use native hover events and visibility changes.
static let deadline = ContinuousClock.now.advanced(by: .seconds(20))
static func run(result: URL) {
resultURL = result
_ = NSApplication.shared
savedPointer = NSEvent.mouseLocation
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
MainActor.assumeIsolated { advance() }
}
terebiAppMain()
}
static func finish(_ error: String = "") {
timer?.invalidate()
observation = nil
CGWarpMouseCursorPosition(CGPoint(x: savedPointer.x, y: NSScreen.screens[0].frame.maxY - savedPointer.y))
let report: [String: Any] = ["passed": error.isEmpty, "error": error, "steps": step]
try! JSONSerialization.data(withJSONObject: report).write(to: resultURL, options: .atomic)
PlayerModel.shared.shutdown()
NSApp.terminate(nil)
}
static func advance() {
guard ContinuousClock.now < deadline else { finish("Overlay hover timed out"); return }
let model = PlayerModel.shared
guard model.canTogglePlayback, model.state.has_video,
let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }),
let content = window.contentView else { return }
if observation == nil {
model.setVolume(0)
model.setPlayback(playing: false)
window.makeKeyAndOrderFront(nil)
window.makeMain()
NSApp.activate(ignoringOtherApps: true)
observation = model.$controlsVisible.removeDuplicates().dropFirst().sink { visible in
if !visible { hidden += 1 }
}
}
if step == names.count { finish(); return }
if step == 4 && !model.showsQueue { model.showsQueue = true; return }
guard !model.isQueueTransitioning else { return }
if sample == 0 {
let w = content.bounds.width
let h = content.bounds.height
let points = [NSPoint(x: w / 2, y: h / 2), NSPoint(x: 150, y: h - 16),
NSPoint(x: w / 2, y: 64), NSPoint(x: w - 46, y: h - 16),
NSPoint(x: w - 46, y: h - 16), NSPoint(x: -20, y: h / 2),
NSPoint(x: w - 46, y: h - 16)]
hidden = 0
let screen = window.convertPoint(toScreen: points[step])
CGWarpMouseCursorPosition(CGPoint(x: screen.x, y: NSScreen.screens[0].frame.maxY - screen.y))
let event = NSEvent.mouseEvent(with: .mouseMoved, location: points[step], modifierFlags: [],
timestamp: 0, windowNumber: window.windowNumber, context: nil,
eventNumber: step, clickCount: 0, pressure: 0)!
NSApp.postEvent(NSEvent(cgEvent: event.cgEvent!)!, atStart: false)
}
// Let native tracking and SwiftUI updates settle; no auto-hide timer is under test.
sample += 1
if sample == 20 {
let outside = step == 5
guard model.controlsVisible != outside, outside || hidden == 0 else {
finish("\(names[step]): visible=\(model.controlsVisible), hidden transitions=\(hidden)")
return
}
sample = 0
step += 1
}
}
}
@MainActor
private enum WindowDragCheck {
static var timer: Timer?
+86
View File
@@ -366,6 +366,87 @@ private func testIdentity() throws -> (SecIdentity, URL) {
@main
@MainActor
struct CastSessionCheck {
static func discovery() async throws {
let prefix = "terebi-discovery-" + UUID().uuidString
let firstID = prefix + "-first", secondID = prefix + "-second"
let discovery = CastDiscovery()
var listeners: [NWListener] = []
var accepted: [NWConnection] = []
defer {
discovery.stop()
for listener in listeners { listener.cancel() }
for connection in accepted { connection.cancel() }
}
func advertise(_ suffix: String, id: String, name: String) throws -> NWListener {
let listener = try NWListener(using: .tcp)
listener.service = .init(name: prefix + suffix, type: "_googlecast._tcp",
txtRecord: NWTXTRecord(["id": id, "fn": name]))
listener.newConnectionHandler = { connection in
MainActor.assumeIsolated {
accepted.append(connection)
connection.start(queue: .main)
}
}
listener.start(queue: .main)
listeners.append(listener)
return listener
}
let first = try advertise("-a", id: firstID, name: "TV 10")
let second = try advertise("-b", id: secondID, name: "TV 2")
let duplicate = try advertise("-c", id: firstID, name: "TV 10")
let fallback = try advertise("-fallback", id: "", name: "")
try await until("Bonjour publishers are ready") { listeners.allSatisfy { $0.state == .ready } }
let duplicatePort = duplicate.port!.rawValue
discovery.start()
discovery.start() // Repeated menu requests must not replace a running search.
func ownDevices() -> [CastDevice] {
discovery.devices.filter { $0.id == firstID || $0.id == secondID }
}
try await until("Bonjour TXT identity/name, deduplication and natural device order", attempts: 1500) {
ownDevices().map(\.id) == [secondID, firstID] &&
ownDevices().map(\.name) == ["TV 2", "TV 10"]
}
try await until("Empty TXT values fall back to the service name and identity", attempts: 1500) {
discovery.devices.contains { $0.name == prefix + "-fallback" && $0.port == fallback.port?.rawValue }
}
let fallbackID = discovery.devices.first { $0.name == prefix + "-fallback" }!.id
let device = ownDevices()[0]
require(device.port == second.port?.rawValue, "Discovery preserves the advertised Cast port")
let connection = NWConnection(host: NWEndpoint.Host(device.host),
port: NWEndpoint.Port(rawValue: device.port)!, using: .tcp)
defer { connection.cancel() }
connection.start(queue: .main)
try await until("Resolved host/port connects to the advertised receiver") {
connection.state == .ready && !accepted.isEmpty
}
second.service = .init(name: prefix + "-b", type: "_googlecast._tcp",
txtRecord: NWTXTRecord(["id": secondID, "fn": "TV 1"]))
try await until("TXT changes update the device without changing its identity", attempts: 1500) {
ownDevices().map(\.id) == [secondID, firstID] && ownDevices()[0].name == "TV 1"
}
first.cancel()
try await until("A duplicate advertisement keeps the receiver available", attempts: 1500) {
ownDevices().first(where: { $0.id == firstID })?.port == duplicatePort
}
duplicate.cancel()
try await until("Removing the last advertisement removes the receiver", attempts: 1500) {
ownDevices().map(\.id) == [secondID]
}
discovery.stop()
require(discovery.devices.isEmpty, "Stop clears published receivers")
discovery.start()
try await until("Restart ignores cancelled browser/resolver callbacks and rediscovers the receiver", attempts: 1500) {
ownDevices().map(\.id) == [secondID] && ownDevices()[0].name == "TV 1"
}
try await until("Service identity without TXT stays stable across restart", attempts: 1500) {
discovery.devices.contains { $0.id == fallbackID && $0.name == prefix + "-fallback" }
}
second.cancel()
try await until("Receiver removal after restart", attempts: 1500) { ownDevices().isEmpty }
require(discovery.error == nil, "Successful browsing leaves no permission/network error")
print("PASS: Bonjour discovery TXT identity/name/update, natural order, duplicate removal, routable host/port, stop/restart")
}
static func codec() throws {
let payload = Data("{\"type\":\"PING\"}".utf8)
let message = CastWire.Message(source: "sender-0", destination: "receiver-0",
@@ -921,10 +1002,15 @@ struct CastSessionCheck {
}
static func main() async throws {
if CommandLine.arguments.count == 2, CommandLine.arguments[1] == "--discovery" {
try await discovery()
return
}
if CommandLine.arguments.count == 6, CommandLine.arguments[1] == "--peer" {
try await realPeer(CommandLine.arguments)
return
}
try await discovery()
try codec()
let (identity, directory) = try testIdentity()
defer { try? FileManager.default.removeItem(at: directory) }
+22 -1
View File
@@ -19,6 +19,23 @@ With the macOS 27 SDK, the player uses a private `ViewState` alias for the `Swif
The resulting `.build/terebi.app` is self-contained: copy the whole app to a compatible Mac of the same architecture. No separate FFmpeg, Python, or yt-dlp installation is needed to run it.
### Apple SDK and UI API audit
Audited on 2026-10-22 with Xcode 27.0, macOS SDK 27.0 and Swift 6.4, in Swift 6 language mode. The build uses the active `xcrun` toolchain, not a pinned old SDK. `Makefile`, `Package.swift` and `Resources/Info.plist` consistently retain macOS 15 as the minimum; the rebuilt player and bootstrap report SDK 27.0 / minimum OS 15.0 in their Mach-O build metadata. A newer build SDK does not require raising the minimum OS; newer APIs still need availability guards.
**Keep the SwiftUI/AppKit architecture.** The app already uses SwiftUI `App`, `Window`, `Commands`, focus, typed URL drops, two-value `onChange` and `WindowDragGesture`. AppKit bridges serve specific native requirements, not a second legacy UI.
| Area | Assessment and decision |
| --- | --- |
| Native views and windows | Keep `NSViewRepresentable` bridges in `Sources/App/TerebiApp.swift` and `CompactWindowController`. They preserve the Metal renderer, buffered slider drawing, user tracking versus Cast feedback, native input routing and same-window compact/fullscreen geometry. [Apple documents this interoperability](https://developer.apple.com/documentation/swiftui/nsviewrepresentable). |
| State observation | `PlayerModel` and `CastDiscovery` use supported `ObservableObject`/`@Published`. [Observation](https://developer.apple.com/documentation/swiftui/migrating-from-the-observable-object-protocol-to-the-observable-macro) is available from macOS 14 and can narrow view invalidation. Consider it when profiling demonstrates unnecessary updates; no performance defect was measured here. A migration must preserve manual compact/Cast notifications, bindings and focused commands together. |
| Cast discovery | Uses [`NWBrowser`](https://developer.apple.com/documentation/network/nwbrowser) with TXT records. Receiver identity/name fallbacks, natural ordering, deduplication, removal and resolved host/port routing are retained. Discovery resolves endpoints without opening a Cast TCP/TLS session. |
| App activation | Production calls use [`activate()`](https://developer.apple.com/documentation/appkit/nsapplication/activate()), available from macOS 14. Activation is cooperative, not a guarantee of foreground focus; system permission dialogs and the lock screen take precedence. |
| Glass/chrome | `PlayerGlass` uses supported `NSVisualEffectView`, not native Liquid Glass. [`glassEffect`](https://developer.apple.com/documentation/swiftui/view/glasseffect(_:in:)) / [`NSGlassEffectView`](https://developer.apple.com/documentation/appkit/nsglasseffectview) require macOS 26; AppKit's `effectIsInteractive` requires 27. Optional adoption belongs on controls/chrome, not every content background, with the macOS 15 path retained and visual/accessibility verification. See [Apple's materials guidance](https://developer.apple.com/design/human-interface-guidelines/materials). |
| Playback backend | Keep MetalKit `MTKView` and the Core Audio output callback in `Sources/macOS/Output.mm`. They implement rendering and audio-clock synchronization; SwiftUI is not a replacement for that backend. |
Verification: SDK build/bundle checks, real Bonjour discovery, loopback Cast transitions and focused startup-error alerts passed. A production-app smoke with a cooperating foreground launcher passed cold start, CLI reuse/document handoff and keyboard pause/resume. The full CLI state-snapshot harness hit its existing no-snapshot timeout; macOS 15 runtime and physical-Chromecast verification remain unperformed. Native focus checks require an unlocked desktop and resolved Local Network permission prompts for disposable test copies.
### Install
```sh
@@ -63,6 +80,8 @@ Compact entry and exit animate asynchronously over 0.25 seconds, preserving the
The normal titlebar control container uses matching 4-point top and right margins. Its horizontal padding accounts for the buttons' internal spacing to visually match the title container.
Pointer tracking covers the entire player, including the top-right controls and queue sidebar. Crossing from the video into those overlays does not count as leaving the window or trigger a hide/show loop.
Drag the video or any noninteractive background to move the window, including the start screen, queue, playback chrome, loading/error screens, audio-only playback, casting and compact mode. Buttons, menus, sliders, editable/selectable text and queue rows retain their own mouse input. A single video click hides the controls; double-click toggles fullscreen. Playback controls stay anchored at the bottom center of the video. Native edges remain available for resizing; macOS fullscreen keeps its fixed window bounds.
Active playback prevents idle system sleep. Local video also keeps the display awake; audio-only playback allows display sleep. Pause, end, close, playback failure and quit release sleep prevention.
@@ -103,7 +122,7 @@ The configurable URL action is now `open_stream` (formerly `open_youtube`); cust
### Chromecast
Open media, select the **Cast** icon or **Playback → Cast to Device → Find Cast Devices…**, and choose a receiver on the same LAN. Allow macOS Local Network access and incoming firewall connections. Terebi keeps the Mac awake during the handoff and active Cast playback while allowing the screensaver and display sleep once local playback stops. Keep the Mac connected: it relays media to the receiver. Closing the lid, explicitly sleeping the Mac or critical-battery sleep can still interrupt playback.
Terebi starts Chromecast discovery automatically on launch. Open media, select the **Cast** icon or **Playback → Cast to Device**, and choose a receiver on the same LAN. Use **Find Cast Devices…** to retry discovery if needed. Allow macOS Local Network access and incoming firewall connections. Terebi keeps the Mac awake during the handoff and active Cast playback while allowing the screensaver and display sleep once local playback stops. Keep the Mac connected: it relays media to the receiver. Closing the lid, explicitly sleeping the Mac or critical-battery sleep can still interrupt playback.
Existing controls operate remote playback. **Play on This Mac** returns to local playback at the receiver's position without changing the local-file queue. Queue navigation and automatic advance keep the selected receiver; opening a replacement selection or different stream ends casting. Frame stepping and YouTube quality changes are local-only; live Cast playback is not seekable. Cast output is limited to 1080p/30 fps. The screen-and-triangle icon does not provide AirPlay support.
@@ -187,6 +206,8 @@ The full suite requires a graphical macOS session and a working audio device; it
The startup checks verify that playing video remains attached, unhidden and sized to its native drag surface, including with the queue open; playback-state checks alone do not establish renderer visibility.
`TEREBI_CHECK_HOVER_ONLY=1 make check-startup` runs packaged startup and the native overlay-hover regression: video, title, playback controls, top-right controls, queue controls, window exit and reentry. It temporarily moves the pointer and restores its original position.
`make check-local-seek` measures paused/playing local seek-to-picture latency and compares the resulting timestamps and pixels against full decoding, including B-frame targets. It covers a 1080p H.264 long-GOP file with audio and the six hardware/software decoder fixtures; timing is diagnostic, not a machine-dependent pass threshold.
Queue-open resize checks require positive native resize increments and exercise continuous horizontal/vertical resize-delegate sequences, including reversals, to preserve video aspect without snap-back. The driving axis is fixed for each live resize gesture so aspect corrections do not feed back into axis selection. Physical edge-drag resizing still needs manual verification.
+1 -1
View File
@@ -26,7 +26,7 @@ enum TerebiBootstrap {
FileHandle.standardError.write(Data("terebi: \(message)\n".utf8))
let application = NSApplication.shared
application.setActivationPolicy(.regular)
application.activate(ignoringOtherApps: true)
application.activate()
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = "Terebi could not start"
+97 -52
View File
@@ -11,35 +11,120 @@ struct CastDevice: Identifiable, Equatable, Sendable {
}
@MainActor
final class CastDiscovery: NSObject, ObservableObject, @preconcurrency NetServiceBrowserDelegate,
@preconcurrency NetServiceDelegate {
final class CastDiscovery: ObservableObject {
@Published private(set) var devices: [CastDevice] = []
@Published private(set) var error: String?
private var browser: NetServiceBrowser?
private var services: [String: NetService] = [:]
private var resolved: [String: CastDevice] = [:]
private struct Service {
let result: NWBrowser.Result
let connection: NWConnection
var timeout: Task<Void, Never>?
}
private var browser: NWBrowser?
private var services: [NWEndpoint: Service] = [:]
private var resolved: [NWEndpoint: CastDevice] = [:]
func start() {
if error != nil { stop() }
guard browser == nil else { return }
error = nil
let browser = NetServiceBrowser()
let browser = NWBrowser(for: .bonjourWithTXTRecord(type: "_googlecast._tcp", domain: "local."),
using: .tcp)
self.browser = browser
browser.delegate = self
browser.searchForServices(ofType: "_googlecast._tcp.", inDomain: "local.")
browser.stateUpdateHandler = { [weak self, weak browser] state in
MainActor.assumeIsolated {
guard let self, let browser, self.browser === browser else { return }
switch state {
case .ready: self.error = nil
case .waiting, .failed:
self.error = "Cast discovery failed. Check Local Network permission and your Wi-Fi connection."
default: break
}
}
}
browser.browseResultsChangedHandler = { [weak self, weak browser] results, _ in
MainActor.assumeIsolated {
guard let self, let browser, self.browser === browser else { return }
self.update(results)
}
}
browser.start(queue: .main)
}
func stop() {
browser?.stop()
browser?.delegate = nil
browser?.cancel()
browser = nil
for service in services.values { service.stop(); service.delegate = nil }
for service in services.values {
service.timeout?.cancel()
service.connection.cancel()
}
services.removeAll()
resolved.removeAll()
devices.removeAll()
}
private func key(_ service: NetService) -> String { service.domain + service.type + service.name }
private func update(_ results: Set<NWBrowser.Result>) {
let endpoints = Set(results.map(\.endpoint))
for endpoint in services.keys where !endpoints.contains(endpoint) {
let service = services.removeValue(forKey: endpoint)
service?.timeout?.cancel()
service?.connection.cancel()
resolved.removeValue(forKey: endpoint)
}
for result in results {
let endpoint = result.endpoint
if let previous = services[endpoint] {
guard previous.result != result else { continue }
previous.timeout?.cancel()
previous.connection.cancel()
resolved.removeValue(forKey: endpoint)
} else if services.count >= 128 {
continue
}
// UDP resolves the advertised SRV endpoint without opening a Cast TCP/TLS
// session or sending a datagram. Discovery must not require a responsive receiver.
let connection = NWConnection(to: endpoint, using: .udp)
services[endpoint] = Service(result: result, connection: connection)
connection.stateUpdateHandler = { [weak self, weak connection] state in
MainActor.assumeIsolated {
guard let self, let connection else { return }
switch state {
case .ready: self.finish(endpoint, connection: connection, ready: true)
case .failed: self.finish(endpoint, connection: connection, ready: false)
default: break
}
}
}
services[endpoint]?.timeout = Task { [weak self, weak connection] in
do { try await Task.sleep(for: .seconds(5)) }
catch { return }
guard let self, let connection else { return }
self.finish(endpoint, connection: connection, ready: false)
}
connection.start(queue: .main)
}
publish()
}
private func finish(_ endpoint: NWEndpoint, connection: NWConnection, ready: Bool) {
guard let service = services[endpoint], service.connection === connection else { return }
if ready, case let .service(name, type, domain, _) = endpoint,
case let .hostPort(host, port) = connection.currentPath?.remoteEndpoint, port.rawValue > 0 {
func text(_ key: String) -> String? {
guard case let .bonjour(txt) = service.result.metadata,
let data = txt.getEntry(for: key)?.data,
let value = String(data: data, encoding: .utf8), !value.isEmpty else { return nil }
return value
}
resolved[endpoint] = CastDevice(id: text("id") ?? domain + type + name,
name: text("fn") ?? name,
host: host.debugDescription, port: port.rawValue)
}
services[endpoint]?.timeout?.cancel()
services[endpoint]?.timeout = nil
connection.cancel()
publish()
}
private func publish() {
// Multiple advertised interfaces may resolve to the same receiver ID.
var unique: [String: CastDevice] = [:]
@@ -47,46 +132,6 @@ final class CastDiscovery: NSObject, ObservableObject, @preconcurrency NetServic
devices = unique.values.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
}
func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) {
guard browser === self.browser, services.count < 128 else { return }
let key = self.key(service)
guard services[key] == nil else { return }
services[key] = service
service.delegate = self
service.resolve(withTimeout: 5)
}
func netServiceBrowser(_ browser: NetServiceBrowser, didRemove service: NetService, moreComing: Bool) {
guard browser === self.browser else { return }
let key = self.key(service)
services.removeValue(forKey: key)?.stop()
resolved.removeValue(forKey: key)
publish()
}
func netServiceBrowser(_ browser: NetServiceBrowser, didNotSearch errorDict: [String: NSNumber]) {
guard browser === self.browser else { return }
error = "Cast discovery failed. Check Local Network permission and your Wi-Fi connection."
}
func netServiceDidResolveAddress(_ sender: NetService) {
let key = self.key(sender)
guard services[key] === sender, let host = sender.hostName,
sender.port > 0, sender.port <= Int(UInt16.max) else { return }
let txt = sender.txtRecordData().map(NetService.dictionary(fromTXTRecord:)) ?? [:]
func text(_ key: String) -> String? {
txt[key].flatMap { String(data: $0, encoding: .utf8) }.flatMap { $0.isEmpty ? nil : $0 }
}
resolved[key] = CastDevice(id: text("id") ?? key, name: text("fn") ?? sender.name,
host: host, port: UInt16(sender.port))
publish()
}
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
let key = self.key(sender)
guard services[key] === sender else { return }
services.removeValue(forKey: key)?.stop()
}
}
struct CastStatus: Sendable {
+11 -10
View File
@@ -177,6 +177,7 @@ final class PlayerModel: ObservableObject {
MainActor.assumeIsolated { self?.poll() }
}
systemPlayback = SystemPlaybackControls(model: self)
castDiscovery.start()
}
var isBusy: Bool { castConnecting || (!casting.active && transition.isBusy(state.status)) }
@@ -2838,6 +2839,13 @@ private struct PlayerContent: View {
.transition(.opacity)
}
}
.onContinuousHover { phase in
switch phase {
case .active:
model.setPointerInsideWindow(true)
case .ended: model.setPointerInsideWindow(false)
}
}
}
private var windowDragBackground: some View {
@@ -3024,13 +3032,6 @@ private struct PlayerContent: View {
dropTargeted = $0
model.revealControls()
}
.onContinuousHover { phase in
switch phase {
case .active:
model.setPointerInsideWindow(true)
case .ended: model.setPointerInsideWindow(false)
}
}
}
}
}
@@ -3172,7 +3173,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
NSApp.activate()
NSAppleEventManager.shared().setEventHandler(self, andSelector: #selector(openDocuments(_:withReplyEvent:)),
forEventClass: AEEventClass(kCoreEventClass), andEventID: AEEventID(kAEOpenDocuments))
let arguments = Array(CommandLine.arguments.dropFirst())
@@ -3228,7 +3229,7 @@ public func terebiAppMain() {
} catch {
FileHandle.standardError.write(Data("terebi: \(error.localizedDescription)\n".utf8))
NSApplication.shared.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
NSApp.activate()
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = "Could not load Terebi configuration"
@@ -3253,7 +3254,7 @@ public func terebiAppMain() {
PlayerModel.shared.shutdown()
FileHandle.standardError.write(Data("terebi: \(error)\n".utf8))
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
NSApp.activate()
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = "Could not initialize playback progress reporting"
+4 -2
View File
@@ -17,5 +17,7 @@
- 200 ms fade/scale with animated row reflow; the button and ⇧⌘O toggle the same inline input, including while editing; native focus, draft-preservation and rapid-reversal checks cover mouse/shortcut interoperation
- [x] change the youtube button to general streaming
- Open Stream routes YouTube videos and explicit barelystarted.miruro.tv episode links; live Miruro video/audio and VTT seek checks passed, with shared validated source metadata and unchanged Tubular source/progress contracts; CLI handoff emitted v2 playback/end facts, but its GUI-state checker timed out without snapshots; physical UI/drop/Cast verification remains unverified
- [ ] check whether we're using appropriately new apple ui APIs / SDKs for the project.
- we should strive to use modern and idiomatic SDKs and APIs including SwiftUI among others
- [x] check whether we're using appropriately new apple ui APIs / SDKs for the project.
- we should strive to use modern and idiomatic SDKs and APIs including SwiftUI among others
- audited SDK 27.0 / Swift 6.4 with the macOS 15 minimum retained; migrated Cast discovery to NWBrowser and production activation to activate(). Real Bonjour identity/name/update/order/deduplication/removal/restart and routable-endpoint checks, loopback Cast transitions, focused startup-error alerts and a cooperating-launcher production smoke for cold start/CLI reuse/document handoff/keyboard playback passed. The existing full CLI snapshot-harness timeout remains; macOS 15 runtime and physical-Cast verification remain unverified. Observation, glass and the existing native bridges were left unchanged.
- [ ] fix the readme. it's currently bloated with irrelevant implementation detail and other such nonsense.