refactor: modernize Cast discovery and app activation
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -3173,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())
|
||||
@@ -3229,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"
|
||||
@@ -3254,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"
|
||||
|
||||
Reference in New Issue
Block a user