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, "
|
||||
"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"
|
||||
@@ -349,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")
|
||||
@@ -382,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",
|
||||
@@ -660,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}")
|
||||
@@ -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:])
|
||||
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"),
|
||||
@@ -702,6 +734,7 @@ 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)
|
||||
|
||||
@@ -7,6 +7,10 @@ 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
|
||||
@@ -30,6 +34,49 @@ 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?
|
||||
|
||||
@@ -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) }
|
||||
|
||||
Reference in New Issue
Block a user