- SwiftUI popover: apps grid w/ live search, sources, glass remote, gradient volume slider, animations - Persistent python protocol bridge (vidaa-control, MQTT/TLS :36669) - Wake-on-LAN power-on, one-time PIN pairing with token auto-refresh - MCP server for AI agents (11 tv_* tools) + live test script - Gitea Actions CI workflow
135 lines
4.9 KiB
Swift
135 lines
4.9 KiB
Swift
import Foundation
|
|
|
|
/// JSON dict from the bridge; immutable after parse, safe to pass across isolation.
|
|
struct BridgeReply: @unchecked Sendable {
|
|
let dict: [String: Any]
|
|
subscript(_ key: String) -> Any? { dict[key] }
|
|
}
|
|
|
|
/// Actor owning the persistent python bridge process (line-JSON over stdio).
|
|
actor BridgeTV {
|
|
enum BridgeError: Error, LocalizedError {
|
|
case spawn(String)
|
|
case timeout
|
|
case closed
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .spawn(let why): return "bridge start failed: \(why)"
|
|
case .timeout: return "bridge timeout"
|
|
case .closed: return "bridge closed"
|
|
}
|
|
}
|
|
}
|
|
|
|
static let shared = BridgeTV()
|
|
|
|
private var process: Process?
|
|
private var nextID = 1
|
|
private var waiters: [Int: CheckedContinuation<BridgeReply, Error>] = [:]
|
|
private var buffer = Data()
|
|
private var eventHandler: (@Sendable (BridgeReply) -> Void)?
|
|
|
|
private let venvSite = "/Users/ahmedmohammed/Documents/rae/.venv/lib/python3.9/site-packages"
|
|
private let python = "/Users/ahmedmohammed/Documents/rae/.venv/bin/python3"
|
|
|
|
private var scriptURL: URL {
|
|
if let bundled = Bundle.main.url(forResource: "bridge", withExtension: "py") { return bundled }
|
|
return URL(fileURLWithPath: "/Users/ahmedmohammed/Documents/rae/HisenseBar/bridge.py")
|
|
}
|
|
|
|
func setEventHandler(_ handler: @escaping @Sendable (BridgeReply) -> Void) {
|
|
eventHandler = handler
|
|
}
|
|
|
|
private func ensureProcess() throws -> Process {
|
|
if let p = process, p.isRunning { return p }
|
|
waiters.values.forEach { $0.resume(throwing: BridgeError.closed) }
|
|
waiters.removeAll()
|
|
buffer.removeAll()
|
|
|
|
let p = Process()
|
|
p.executableURL = URL(fileURLWithPath: python)
|
|
p.arguments = [scriptURL.path,
|
|
UserDefaults.standard.string(forKey: "hisensebar.ip") ?? "192.168.1.146",
|
|
UserDefaults.standard.string(forKey: "hisensebar.mac") ?? "",
|
|
NSString(string: "~/Library/Application Support/HisenseBar/tokens.json").expandingTildeInPath]
|
|
var env = ProcessInfo.processInfo.environment
|
|
env["VIDAA_SITE"] = venvSite
|
|
env["PYTHONUNBUFFERED"] = "1"
|
|
p.environment = env
|
|
let inPipe = Pipe(), outPipe = Pipe(), errPipe = Pipe()
|
|
p.standardInput = inPipe
|
|
p.standardOutput = outPipe
|
|
p.standardError = errPipe
|
|
do { try p.run() } catch { throw BridgeError.spawn("\(error)") }
|
|
process = p
|
|
|
|
outPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
|
let data = handle.availableData
|
|
guard !data.isEmpty else {
|
|
handle.readabilityHandler = nil
|
|
Task { await self?.handleClosed() }
|
|
return
|
|
}
|
|
Task { await self?.feed(data) }
|
|
}
|
|
return p
|
|
}
|
|
|
|
private func feed(_ data: Data) {
|
|
buffer.append(data)
|
|
while let nl = buffer.firstIndex(of: UInt8(ascii: "\n")) {
|
|
let lineData = buffer.subdata(in: buffer.startIndex..<nl)
|
|
buffer.removeSubrange(buffer.startIndex...nl)
|
|
guard let obj = (try? JSONSerialization.jsonObject(with: lineData)) as? [String: Any] else { continue }
|
|
let reply = BridgeReply(dict: obj)
|
|
if let id = obj["id"] as? Int, let cont = waiters.removeValue(forKey: id) {
|
|
cont.resume(returning: reply)
|
|
} else if let handler = eventHandler {
|
|
handler(reply)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleClosed() {
|
|
waiters.values.forEach { $0.resume(throwing: BridgeError.closed) }
|
|
waiters.removeAll()
|
|
process = nil
|
|
}
|
|
|
|
func request(_ cmd: [String: Any], timeout: TimeInterval = 30) async throws -> BridgeReply {
|
|
let p = try ensureProcess()
|
|
let id = nextID
|
|
nextID += 1
|
|
var msg = cmd
|
|
msg["id"] = id
|
|
let payload = try JSONSerialization.data(withJSONObject: msg) + Data("\n".utf8)
|
|
|
|
return try await withCheckedThrowingContinuation { cont in
|
|
waiters[id] = cont
|
|
guard let pipe = p.standardInput as? Pipe else {
|
|
waiters.removeValue(forKey: id)
|
|
cont.resume(throwing: BridgeError.closed)
|
|
return
|
|
}
|
|
do {
|
|
try pipe.fileHandleForWriting.write(contentsOf: payload)
|
|
} catch {
|
|
waiters.removeValue(forKey: id)
|
|
cont.resume(throwing: BridgeError.closed)
|
|
}
|
|
Task {
|
|
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
|
|
await self.timeoutID(id)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func timeoutID(_ id: Int) {
|
|
if let cont = waiters.removeValue(forKey: id) {
|
|
cont.resume(throwing: BridgeError.timeout)
|
|
}
|
|
}
|
|
}
|