refactor: modernize Cast discovery and app activation
This commit is contained in:
+40
-7
@@ -102,6 +102,31 @@ def check_window_layout(root, app, fixtures):
|
|||||||
f"{report['animatedQueueTransitions']} queue animations, stable video size, responsive reversals, "
|
f"{report['animatedQueueTransitions']} queue animations, stable video size, responsive reversals, "
|
||||||
"screen-edge fitting, fullscreen fixed bounds, minimum sizes and exact normal geometry restored")
|
"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):
|
def check_cli_instances(root, app, fixtures):
|
||||||
cli = root / "terebi"
|
cli = root / "terebi"
|
||||||
@@ -349,8 +374,9 @@ def check_cli_instances(root, app, fixtures):
|
|||||||
|
|
||||||
invoke([str(movie)])
|
invoke([str(movie)])
|
||||||
first = wait_states(1, lambda states: all(state["media"] == movie.name and state["status"] == 3
|
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()),
|
and state["position"] > 0 and state["hasVideo"] and state["active"]
|
||||||
"cold default CLI opens one actual player window")
|
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))
|
first_pid = next(iter(first))
|
||||||
renderer = first[first_pid]["window"]
|
renderer = first[first_pid]["window"]
|
||||||
if (not renderer.get("rendererAttached") or renderer.get("rendererHidden")
|
if (not renderer.get("rendererAttached") or renderer.get("rendererHidden")
|
||||||
@@ -382,8 +408,10 @@ def check_cli_instances(root, app, fixtures):
|
|||||||
if settings(first[first_pid]) != (False, False, True) or config.parent.exists():
|
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}")
|
raise RuntimeError(f"missing config must use defaults without creating a file: {first}")
|
||||||
invoke([])
|
invoke([])
|
||||||
wait_states(1, lambda states: states[first_pid]["media"] == movie.name,
|
wait_states(1, lambda states: states[first_pid]["media"] == movie.name
|
||||||
"warm default CLI reuses the existing process without replacing media")
|
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",
|
input_request(first_pid, 1, lambda state: state["status"] == 2, "default Space pauses once",
|
||||||
events=[{"key": " ", "code": 49}])
|
events=[{"key": " ", "code": 49}])
|
||||||
input_request(first_pid, 1, lambda state: state["status"] == 2, "held Space cannot oscillate",
|
input_request(first_pid, 1, lambda state: state["status"] == 2, "held Space cannot oscillate",
|
||||||
@@ -660,8 +688,10 @@ def check_cli_instances(root, app, fixtures):
|
|||||||
shutil.copy2(fixtures / "av.mp4", replacement)
|
shutil.copy2(fixtures / "av.mp4", replacement)
|
||||||
invoke([str(replacement)])
|
invoke([str(replacement)])
|
||||||
after = wait_states(count, lambda states: sum(state["media"] == replacement.name
|
after = wait_states(count, lambda states: sum(state["media"] == replacement.name
|
||||||
for state in states.values()) == 1,
|
for state in states.values()) == 1 and all(
|
||||||
"default media delivery still reuses exactly one existing process")
|
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"]]
|
changed = [pid for pid in before if before[pid]["media"] != after[pid]["media"]]
|
||||||
if len(changed) != 1:
|
if len(changed) != 1:
|
||||||
raise RuntimeError(f"default media open changed multiple players: {before}; {after}")
|
raise RuntimeError(f"default media open changed multiple players: {before}; {after}")
|
||||||
@@ -674,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:])
|
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 Startup Check.app"
|
||||||
shutil.copytree(app_source, app)
|
shutil.copytree(app_source, app)
|
||||||
identifier = "local.personal.terebi.startup-check." + uuid.uuid4().hex
|
identifier = "local.personal.terebi.startup-check." + uuid.uuid4().hex
|
||||||
info_path = app / "Contents/Info.plist"
|
info_path = app / "Contents/Info.plist"
|
||||||
info = plistlib.loads(info_path.read_bytes())
|
info = plistlib.loads(info_path.read_bytes())
|
||||||
info["CFBundleIdentifier"] = identifier
|
info["CFBundleIdentifier"] = identifier
|
||||||
|
info["CFBundleName"] = "Terebi Startup Check"
|
||||||
|
info["CFBundleDisplayName"] = "Terebi Startup Check"
|
||||||
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"),
|
subprocess.run([sys.executable, str(Path(__file__).resolve().parent.parent / "Scripts/bundle.py"),
|
||||||
@@ -702,6 +734,7 @@ with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
|
|||||||
if launch.returncode != 0 or not result["passed"] or result["bundleIdentifier"] != identifier:
|
if launch.returncode != 0 or not result["passed"] or result["bundleIdentifier"] != identifier:
|
||||||
raise RuntimeError(f"packaged startup failed: {result}; {stdout}{stderr}")
|
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")
|
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)
|
check_overlay_hover(root, app, fixtures)
|
||||||
if os.environ.get("TEREBI_CHECK_HOVER_ONLY") == "1":
|
if os.environ.get("TEREBI_CHECK_HOVER_ONLY") == "1":
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import ObjectiveC
|
|||||||
@_cdecl("terebi_app_main")
|
@_cdecl("terebi_app_main")
|
||||||
@MainActor
|
@MainActor
|
||||||
public func checkAppStartup() {
|
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") {
|
if let index = CommandLine.arguments.firstIndex(of: "--check-overlay-hover") {
|
||||||
OverlayHoverCheck.run(result: URL(fileURLWithPath: CommandLine.arguments[index + 1]))
|
OverlayHoverCheck.run(result: URL(fileURLWithPath: CommandLine.arguments[index + 1]))
|
||||||
return
|
return
|
||||||
@@ -30,6 +34,49 @@ public func checkAppStartup() {
|
|||||||
AppStartupCheck.run()
|
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
|
@MainActor
|
||||||
private enum OverlayHoverCheck {
|
private enum OverlayHoverCheck {
|
||||||
static var timer: Timer?
|
static var timer: Timer?
|
||||||
|
|||||||
@@ -366,6 +366,87 @@ private func testIdentity() throws -> (SecIdentity, URL) {
|
|||||||
@main
|
@main
|
||||||
@MainActor
|
@MainActor
|
||||||
struct CastSessionCheck {
|
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 {
|
static func codec() throws {
|
||||||
let payload = Data("{\"type\":\"PING\"}".utf8)
|
let payload = Data("{\"type\":\"PING\"}".utf8)
|
||||||
let message = CastWire.Message(source: "sender-0", destination: "receiver-0",
|
let message = CastWire.Message(source: "sender-0", destination: "receiver-0",
|
||||||
@@ -921,10 +1002,15 @@ struct CastSessionCheck {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func main() async throws {
|
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" {
|
if CommandLine.arguments.count == 6, CommandLine.arguments[1] == "--peer" {
|
||||||
try await realPeer(CommandLine.arguments)
|
try await realPeer(CommandLine.arguments)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
try await discovery()
|
||||||
try codec()
|
try codec()
|
||||||
let (identity, directory) = try testIdentity()
|
let (identity, directory) = try testIdentity()
|
||||||
defer { try? FileManager.default.removeItem(at: directory) }
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
|
|||||||
@@ -29,12 +29,12 @@ Audited on 2026-10-22 with Xcode 27.0, macOS SDK 27.0 and Swift 6.4, in Swift 6
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 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). |
|
| 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. |
|
| 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 | The clearest platform migration candidate is `NetServiceBrowser`/`NetService` in `Sources/App/CastSession.swift`. SDK 27's `Foundation/NSNetServices.h` recommends Network framework using **future-deprecation** annotations (`API_TO_BE_DEPRECATED`), not a reached removal date. Prefer [`NWBrowser`](https://developer.apple.com/documentation/network/nwbrowser) in a scoped migration that retains TXT identity/name records, device removal/deduplication, resolved endpoints and receiver-host routing for the media server. Current discovery still compiles without warnings. |
|
| 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 | `Sources/App/Bootstrap.swift` and `Sources/App/TerebiApp.swift` use `activate(ignoringOtherApps:)`. SDK 27's `AppKit/NSApplication.h` recommends [`activate()`](https://developer.apple.com/documentation/appkit/nsapplication/activate()), available from macOS 14, with a future-deprecation annotation. Migrate deliberately: the replacement does not guarantee foreground activation, so launch, focus and error-alert behavior need native verification. |
|
| 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). |
|
| 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. |
|
| 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: forced recompilation of the Swift player, Objective-C++ renderer, bootstrap and CLI against the active SDK passed without compiler diagnostics; bundling/signature verification, CLI `--help`, and loading the rebuilt player dylib with its exported entry point passed. This was an API/toolchain audit, not a visual review, an exhaustive certification of every backend symbol, or a runtime test on macOS 15. No behavioral migrations were made.
|
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
|
### Install
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ enum TerebiBootstrap {
|
|||||||
FileHandle.standardError.write(Data("terebi: \(message)\n".utf8))
|
FileHandle.standardError.write(Data("terebi: \(message)\n".utf8))
|
||||||
let application = NSApplication.shared
|
let application = NSApplication.shared
|
||||||
application.setActivationPolicy(.regular)
|
application.setActivationPolicy(.regular)
|
||||||
application.activate(ignoringOtherApps: true)
|
application.activate()
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
alert.alertStyle = .critical
|
alert.alertStyle = .critical
|
||||||
alert.messageText = "Terebi could not start"
|
alert.messageText = "Terebi could not start"
|
||||||
|
|||||||
@@ -11,35 +11,120 @@ struct CastDevice: Identifiable, Equatable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class CastDiscovery: NSObject, ObservableObject, @preconcurrency NetServiceBrowserDelegate,
|
final class CastDiscovery: ObservableObject {
|
||||||
@preconcurrency NetServiceDelegate {
|
|
||||||
@Published private(set) var devices: [CastDevice] = []
|
@Published private(set) var devices: [CastDevice] = []
|
||||||
@Published private(set) var error: String?
|
@Published private(set) var error: String?
|
||||||
private var browser: NetServiceBrowser?
|
private struct Service {
|
||||||
private var services: [String: NetService] = [:]
|
let result: NWBrowser.Result
|
||||||
private var resolved: [String: CastDevice] = [:]
|
let connection: NWConnection
|
||||||
|
var timeout: Task<Void, Never>?
|
||||||
|
}
|
||||||
|
private var browser: NWBrowser?
|
||||||
|
private var services: [NWEndpoint: Service] = [:]
|
||||||
|
private var resolved: [NWEndpoint: CastDevice] = [:]
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
if error != nil { stop() }
|
if error != nil { stop() }
|
||||||
guard browser == nil else { return }
|
guard browser == nil else { return }
|
||||||
error = nil
|
error = nil
|
||||||
let browser = NetServiceBrowser()
|
let browser = NWBrowser(for: .bonjourWithTXTRecord(type: "_googlecast._tcp", domain: "local."),
|
||||||
|
using: .tcp)
|
||||||
self.browser = browser
|
self.browser = browser
|
||||||
browser.delegate = self
|
browser.stateUpdateHandler = { [weak self, weak browser] state in
|
||||||
browser.searchForServices(ofType: "_googlecast._tcp.", inDomain: "local.")
|
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() {
|
func stop() {
|
||||||
browser?.stop()
|
browser?.cancel()
|
||||||
browser?.delegate = nil
|
|
||||||
browser = nil
|
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()
|
services.removeAll()
|
||||||
resolved.removeAll()
|
resolved.removeAll()
|
||||||
devices.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() {
|
private func publish() {
|
||||||
// Multiple advertised interfaces may resolve to the same receiver ID.
|
// Multiple advertised interfaces may resolve to the same receiver ID.
|
||||||
var unique: [String: CastDevice] = [:]
|
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 }
|
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 {
|
struct CastStatus: Sendable {
|
||||||
|
|||||||
@@ -3173,7 +3173,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
NSApp.setActivationPolicy(.regular)
|
NSApp.setActivationPolicy(.regular)
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate()
|
||||||
NSAppleEventManager.shared().setEventHandler(self, andSelector: #selector(openDocuments(_:withReplyEvent:)),
|
NSAppleEventManager.shared().setEventHandler(self, andSelector: #selector(openDocuments(_:withReplyEvent:)),
|
||||||
forEventClass: AEEventClass(kCoreEventClass), andEventID: AEEventID(kAEOpenDocuments))
|
forEventClass: AEEventClass(kCoreEventClass), andEventID: AEEventID(kAEOpenDocuments))
|
||||||
let arguments = Array(CommandLine.arguments.dropFirst())
|
let arguments = Array(CommandLine.arguments.dropFirst())
|
||||||
@@ -3229,7 +3229,7 @@ public func terebiAppMain() {
|
|||||||
} catch {
|
} catch {
|
||||||
FileHandle.standardError.write(Data("terebi: \(error.localizedDescription)\n".utf8))
|
FileHandle.standardError.write(Data("terebi: \(error.localizedDescription)\n".utf8))
|
||||||
NSApplication.shared.setActivationPolicy(.regular)
|
NSApplication.shared.setActivationPolicy(.regular)
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate()
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
alert.alertStyle = .critical
|
alert.alertStyle = .critical
|
||||||
alert.messageText = "Could not load Terebi configuration"
|
alert.messageText = "Could not load Terebi configuration"
|
||||||
@@ -3254,7 +3254,7 @@ public func terebiAppMain() {
|
|||||||
PlayerModel.shared.shutdown()
|
PlayerModel.shared.shutdown()
|
||||||
FileHandle.standardError.write(Data("terebi: \(error)\n".utf8))
|
FileHandle.standardError.write(Data("terebi: \(error)\n".utf8))
|
||||||
NSApp.setActivationPolicy(.regular)
|
NSApp.setActivationPolicy(.regular)
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate()
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
alert.alertStyle = .critical
|
alert.alertStyle = .critical
|
||||||
alert.messageText = "Could not initialize playback progress reporting"
|
alert.messageText = "Could not initialize playback progress reporting"
|
||||||
|
|||||||
@@ -19,4 +19,5 @@
|
|||||||
- 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
|
- 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
|
||||||
- [x] check whether we're using appropriately new apple ui APIs / SDKs for the project.
|
- [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
|
- we should strive to use modern and idiomatic SDKs and APIs including SwiftUI among others
|
||||||
- audited SDK 27.0 / Swift 6.4 with a consistent macOS 15 minimum; SwiftUI already owns the UI, and native bridges retain required behavior. Scoped migration candidates: Network-framework Cast discovery and cooperative app activation; Observation and native Liquid Glass remain elective. Forced Swift/native builds, Mach-O SDK/minimum checks, CLI and dylib-load smoke passed; findings and Apple references are in README's Apple SDK and UI API audit. No runtime/UI behavior changed; macOS 15 runtime and visual verification were not performed.
|
- 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.
|
||||||
Reference in New Issue
Block a user