Files
hisence-mac-app/Sources/HisenseBar/BridgeTV.swift
T
ahmed cc2018aae7
build / sanity (push) Waiting to run
build / macos-build (push) Waiting to run
HisenseBar v1.0.0: native menu-bar remote for Hisense VIDAA TVs
- 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
2026-10-13 18:20:11 +03:00

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)
}
}
}