Initial release

This commit is contained in:
aculix committed 2026-09-28 18:28:32 +05:30
commit 4b67728cce
108 files changed
+13310

No files matched your search

@@ -0,0 +1,39 @@
/// Decides when Starting ends. Frames and progress before NAVIGATED come from the agent's
/// about:blank bootstrap and never count; `afterHello` is a backstop if NAVIGATED never arrives.
public struct ContentReadiness: Sendable {
public static let afterNavigated: Duration = .seconds(15)
public static let afterHello: Duration = .seconds(25)
public private(set) var isReady = false
private var helloAt: ContinuousClock.Instant?
private var navigatedAt: ContinuousClock.Instant?
public init() {}
public mutating func hello(at now: ContinuousClock.Instant) {
if helloAt == nil { helloAt = now }
}
public mutating func navigated(at now: ContinuousClock.Instant) {
if navigatedAt == nil { navigatedAt = now }
}
public mutating func frame(isUniform: Bool) {
if navigatedAt != nil, !isUniform { isReady = true }
}
public mutating func progress(_ percent: Int) {
if navigatedAt != nil, percent >= 100 { isReady = true }
}
public mutating func tick(now: ContinuousClock.Instant) {
if let deadline = nextDeadline, now >= deadline { isReady = true }
}
public var nextDeadline: ContinuousClock.Instant? {
guard !isReady else { return nil }
return [navigatedAt.map { $0 + Self.afterNavigated }, helloAt.map { $0 + Self.afterHello }]
.compactMap { $0 }
.min()
}
}
@@ -0,0 +1,22 @@
/// Tells a blank frame from one with page content.
public enum FrameSampler {
public static let grid = 48
public static let maxDimension = 1 << 15
public static let maxBytesPerRow = 1 << 20
/// Malformed input counts as uniform so it can never pose as content; the caps prevent overflow.
public static func isUniform(_ pixels: UnsafeRawBufferPointer, width: Int, height: Int, bytesPerRow: Int) -> Bool {
guard (1...maxDimension).contains(width), (1...maxDimension).contains(height),
bytesPerRow >= width * 4, bytesPerRow <= maxBytesPerRow,
pixels.count >= (height - 1) * bytesPerRow + width * 4
else { return true }
let first = pixels.loadUnaligned(fromByteOffset: 0, as: UInt32.self)
for gy in 0..<grid {
for gx in 0..<grid {
let offset = (gy * height / grid) * bytesPerRow + (gx * width / grid) * 4
if pixels.loadUnaligned(fromByteOffset: offset, as: UInt32.self) != first { return false }
}
}
return true
}
}
+88
View File
@@ -0,0 +1,88 @@
import Foundation
public enum NALUnitType {
public static let slice: UInt8 = 1
public static let idrSlice: UInt8 = 5
public static let sei: UInt8 = 6
public static let sps: UInt8 = 7
public static let pps: UInt8 = 8
public static let accessUnitDelimiter: UInt8 = 9
}
public enum AnnexB {
/// Splits an Annex-B stream into NAL units. Trailing zeros are trimmed: a NAL unit never ends in 0x00.
public static func nalUnits(in stream: Data) -> [Data] {
let bytes = [UInt8](stream)
var starts: [(code: Int, payload: Int)] = []
var i = 0
while i + 2 < bytes.count {
if bytes[i] == 0, bytes[i + 1] == 0, bytes[i + 2] == 1 {
starts.append((code: i, payload: i + 3))
i += 3
} else {
i += 1
}
}
return starts.indices.compactMap { k in
var end = k + 1 < starts.count ? starts[k + 1].code : bytes.count
while end > starts[k].payload, bytes[end - 1] == 0 { end -= 1 }
return end > starts[k].payload ? Data(bytes[starts[k].payload..<end]) : nil
}
}
}
public struct ParameterSets: Equatable, Sendable {
public var sps: Data
public var pps: Data
public init(sps: Data, pps: Data) {
self.sps = sps
self.pps = pps
}
}
public struct ParsedAccessUnit: Equatable, Sendable {
/// AVCC-framed slice NAL units; empty if the access unit has none, so don't decode it.
public var sample = Data()
public var isIDR = false
/// Set only on the unit that changes the SPS/PPS; if that unit is dropped, check `H264Parser.parameterSets`.
public var newParameterSets: ParameterSets?
}
public struct H264Parser: Sendable {
public private(set) var parameterSets: ParameterSets?
public init() {}
public mutating func parse(_ accessUnit: Data) -> ParsedAccessUnit {
var unit = ParsedAccessUnit()
var sps: Data?
var pps: Data?
for nal in AnnexB.nalUnits(in: accessUnit) {
let header = nal[nal.startIndex]
guard header & 0x80 == 0 else { continue } // forbidden_zero_bit
let type = header & 0x1F
switch type {
case NALUnitType.sps:
sps = nal
case NALUnitType.pps:
pps = nal
case NALUnitType.slice, NALUnitType.idrSlice:
if type == NALUnitType.idrSlice { unit.isIDR = true }
unit.sample.appendBE32(UInt32(nal.count))
unit.sample.append(nal)
default:
break
}
}
// SPS and PPS must come from the same access unit; x264 repeats both on every IDR.
if let sps, let pps {
let sets = ParameterSets(sps: sps, pps: pps)
if sets != parameterSets {
parameterSets = sets
unit.newParameterSets = sets
}
}
return unit
}
}
@@ -0,0 +1,78 @@
import Foundation
public struct Modifiers: OptionSet, Hashable, Sendable {
public let rawValue: UInt8
public init(rawValue: UInt8) {
self.rawValue = rawValue
}
public static let shift = Modifiers(rawValue: 1 << 0)
public static let control = Modifiers(rawValue: 1 << 1)
public static let alt = Modifiers(rawValue: 1 << 2)
public static let meta = Modifiers(rawValue: 1 << 3)
public static let leftButton = Modifiers(rawValue: 1 << 4)
public static let rightButton = Modifiers(rawValue: 1 << 5)
public static let middleButton = Modifiers(rawValue: 1 << 6)
}
public enum PointerButton: UInt8, Sendable {
case left = 1
case right = 2
case middle = 3
}
/// A viewer → agent message. Coordinates are render pixels from the top left; negative scroll is down/right.
public enum InputEvent: Equatable, Sendable {
case pointerMove(x: UInt16, y: UInt16, mods: Modifiers)
case pointerButton(PointerButton, pressed: Bool, x: UInt16, y: UInt16, mods: Modifiers)
case scroll(ticksX: Int16, ticksY: Int16, x: UInt16, y: UInt16)
case key(pressed: Bool, mods: Modifiers, name: String)
case navigate(String)
case script(String)
case keyframe
/// Nil if the agent can't use it: a payload over 64 KiB, an empty KEY name, or a NUL in a string.
public func encoded() -> Data? {
var payload = Data()
let type: UInt8
switch self {
case .pointerMove(let x, let y, let mods):
type = MessageType.pointerMove
payload.appendBE16(x)
payload.appendBE16(y)
payload.append(mods.rawValue)
case .pointerButton(let button, let pressed, let x, let y, let mods):
type = MessageType.pointerButton
payload.append(button.rawValue)
payload.append(pressed ? 1 : 0)
payload.appendBE16(x)
payload.appendBE16(y)
payload.append(mods.rawValue)
case .scroll(let ticksX, let ticksY, let x, let y):
type = MessageType.scroll
payload.appendBE16(UInt16(bitPattern: ticksX))
payload.appendBE16(UInt16(bitPattern: ticksY))
payload.appendBE16(x)
payload.appendBE16(y)
case .key(let pressed, let mods, let name):
guard !name.isEmpty, !name.utf8.contains(0) else { return nil }
type = MessageType.key
payload.append(pressed ? 1 : 0)
payload.append(mods.rawValue)
payload.append(contentsOf: Array(name.utf8))
case .navigate(let url):
guard !url.utf8.contains(0) else { return nil }
type = MessageType.navigate
payload.append(contentsOf: Array(url.utf8))
case .script(let javaScript):
guard !javaScript.utf8.contains(0) else { return nil }
type = MessageType.script
payload.append(contentsOf: Array(javaScript.utf8))
case .keyframe:
type = MessageType.keyframe
}
guard payload.count <= WireProtocol.maxOtherPayload else { return nil }
return framed(type, payload)
}
}
@@ -0,0 +1,348 @@
import CoreVideo
import Darwin
import Foundation
public struct StreamClient: StreamConnector {
public let renderSize: PixelSize
public init(renderSize: PixelSize) {
self.renderSize = renderSize
}
public func connect(socketPath: String) async throws -> any StreamConnection {
let renderSize = renderSize
return try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
continuation.resume(with: Result { try Self.connectBlocking(path: socketPath, renderSize: renderSize) })
}
}
}
static func connectBlocking(path: String, renderSize: PixelSize) throws -> StreamSession {
let deadline = Date().addingTimeInterval(30)
var connectedOnce = false
while Date() < deadline {
if let fd = openSocket(path) {
connectedOnce = true
setReadTimeout(fd, seconds: 2)
var decoder = AgentMessageDecoder()
switch readHello(fd, into: &decoder) {
case .hello(let version, let size):
guard version == WireProtocol.version else {
Darwin.close(fd)
throw StreamError.versionMismatch(found: version, need: WireProtocol.version)
}
guard size == renderSize else {
Darwin.close(fd)
throw StreamError.protocolViolation("HELLO is \(size), asked for \(renderSize)")
}
setReadTimeout(fd, seconds: 0)
// A VM that stops reading input must not wedge the session's queue.
setSendTimeout(fd, seconds: 2)
return StreamSession(fd: fd, decoder: decoder, size: size)
case .violation(let detail):
Darwin.close(fd)
throw StreamError.protocolViolation(detail)
case .none:
Darwin.close(fd)
}
}
usleep(100_000)
}
throw connectedOnce ? StreamError.timedOut : StreamError.refused
}
private enum HelloOutcome {
case hello(version: Int, size: PixelSize)
case violation(String)
case none
}
private static func readHello(_ fd: Int32, into decoder: inout AgentMessageDecoder) -> HelloOutcome {
var buffer = [UInt8](repeating: 0, count: 64 * 1024)
while true {
let n = buffer.withUnsafeMutableBytes { read(fd, $0.baseAddress!, $0.count) }
if n < 0, errno == EINTR { continue }
guard n > 0 else { return .none }
decoder.append(Data(buffer[0..<n]))
do {
if case .hello(let version, let size)? = try decoder.next() { return .hello(version: version, size: size) }
} catch {
return .violation(error.description)
}
}
}
private static func openSocket(_ path: String) -> Int32? {
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { return nil }
var address = sockaddr_un()
address.sun_family = sa_family_t(AF_UNIX)
let bytes = Array(path.utf8)
guard bytes.count < MemoryLayout.size(ofValue: address.sun_path) else {
Darwin.close(fd)
return nil
}
withUnsafeMutableBytes(of: &address.sun_path) { $0.copyBytes(from: bytes) }
let size = socklen_t(MemoryLayout<sockaddr_un>.size)
let result = withUnsafePointer(to: &address) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.connect(fd, $0, size) }
}
guard result == 0 else {
Darwin.close(fd)
return nil
}
var on: Int32 = 1
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, socklen_t(MemoryLayout<Int32>.size))
return fd
}
static func setReadTimeout(_ fd: Int32, seconds: Int) {
var timeout = timeval.init(tv_sec: seconds, tv_usec: 0)
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size))
}
static func setSendTimeout(_ fd: Int32, seconds: Int) {
var timeout = timeval.init(tv_sec: seconds, tv_usec: 0)
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size))
}
}
/// Its reader thread must never stop (the VM's watchdog needs a connected viewer); all else runs on `queue`.
final class StreamSession: StreamConnection, @unchecked Sendable {
static let firstFrameTimeout: TimeInterval = 20
static let decodeErrorLimit = 10
let events: AsyncStream<StreamEvent>
private let continuation: AsyncStream<StreamEvent>.Continuation
private let queue = DispatchQueue(label: "aculix.knotchy.stream")
private let fd: Int32
private var decoder: AgentMessageDecoder
private var parser = H264Parser()
private var video: VideoDecoder?
private var readiness = ContentReadiness()
private var announcedReady = false
private var enabled = true
private var waitingForIDR = false
private var frameHandler: (@Sendable (DecodedFrame) -> Void)?
private var readinessTimer: DispatchSourceTimer?
private var firstFrameTimer: DispatchSourceTimer?
private var pendingPageURL: URL?
private var pageURLTimer: DispatchSourceTimer?
private var decodeErrors: [Date] = []
private var ended = false
init(fd: Int32, decoder: AgentMessageDecoder, size: PixelSize) {
self.fd = fd
self.decoder = decoder
let (stream, continuation) = AsyncStream.makeStream(of: StreamEvent.self)
events = stream
self.continuation = continuation
continuation.yield(.hello(version: WireProtocol.version, size: size))
queue.async { self.begin() }
let reader = Thread { [self] in readLoop() }
reader.name = "knotchy stream reader"
reader.start()
}
// MARK: StreamConnection
var decodeEnabled: Bool {
get { queue.sync { enabled } }
set {
queue.async {
if newValue, !self.enabled {
self.waitingForIDR = true
self.write(.keyframe)
}
self.enabled = newValue
}
}
}
func setFrameHandler(_ handler: @escaping @Sendable (DecodedFrame) -> Void) {
queue.async { self.frameHandler = handler }
}
func send(_ input: InputEvent) {
queue.async { self.write(input) }
}
func close() {
queue.async { self.end(nil) }
}
// MARK: Reading
private func readLoop() {
var buffer = [UInt8](repeating: 0, count: 64 * 1024)
while true {
let n = buffer.withUnsafeMutableBytes { read(fd, $0.baseAddress!, $0.count) }
if n < 0, errno == EINTR { continue }
guard n > 0 else { break }
let chunk = Data(buffer[0..<n])
queue.async { self.received(chunk) }
}
queue.async {
self.end(.closed)
Darwin.close(self.fd)
}
}
private func begin() {
readiness.hello(at: .now)
scheduleReadinessTick()
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now() + Self.firstFrameTimeout)
timer.setEventHandler { [weak self] in self?.end(.noFirstFrame) }
timer.resume()
firstFrameTimer = timer
received(Data()) // Messages that arrived together with HELLO.
}
private func received(_ chunk: Data) {
guard !ended else { return }
decoder.append(chunk)
do {
while let message = try decoder.next() { handle(message) }
} catch {
end(.protocolViolation(error.description))
}
}
private func handle(_ message: AgentMessage) {
switch message {
case .navigated:
readiness.navigated(at: .now)
scheduleReadinessTick()
case .progress(let percent):
readiness.progress(percent)
continuation.yield(.progress(percent))
announceIfReady()
case .video(_, let accessUnit):
firstFrameTimer?.cancel()
firstFrameTimer = nil
let unit = parser.parse(accessUnit)
if unit.newParameterSets != nil || video == nil, let sets = parser.parameterSets {
video = VideoDecoder(parameterSets: sets) { [weak self] frame, status in
guard let self else { return }
self.queue.async { self.decoded(frame, status: status) }
}
}
guard enabled, !unit.sample.isEmpty, let video else { return }
if waitingForIDR {
guard unit.isIDR else { return }
waitingForIDR = false
}
video.decode(unit.sample)
case .pageURL(let text):
guard let url = URL(string: text) else { return }
pendingPageURL = url
passOnPageURL()
case .hello, .unknown:
break
}
}
private func decoded(_ frame: DecodedFrame?, status: OSStatus) {
guard !ended else { return }
guard let frame else { return decodeFailed() }
if !readiness.isReady {
readiness.frame(isUniform: Self.isUniform(frame.pixelBuffer))
announceIfReady()
}
frameHandler?(frame)
}
private func decodeFailed() {
let now = Date()
decodeErrors = decodeErrors.filter { now.timeIntervalSince($0) < 60 } + [now]
if decodeErrors.count > Self.decodeErrorLimit { return end(.decodeErrors) }
waitingForIDR = true
write(.keyframe)
}
private static func isUniform(_ buffer: CVPixelBuffer) -> Bool {
CVPixelBufferLockBaseAddress(buffer, .readOnly)
defer { CVPixelBufferUnlockBaseAddress(buffer, .readOnly) }
guard let base = CVPixelBufferGetBaseAddress(buffer) else { return true }
let bytes = UnsafeRawBufferPointer(start: base, count: CVPixelBufferGetDataSize(buffer))
return FrameSampler.isUniform(bytes, width: CVPixelBufferGetWidth(buffer),
height: CVPixelBufferGetHeight(buffer),
bytesPerRow: CVPixelBufferGetBytesPerRow(buffer))
}
// MARK: Readiness
private func announceIfReady() {
guard readiness.isReady, !announcedReady else { return }
announcedReady = true
readinessTimer?.cancel()
readinessTimer = nil
continuation.yield(.contentReady)
}
private func scheduleReadinessTick() {
readinessTimer?.cancel()
readinessTimer = nil
guard let deadline = readiness.nextDeadline else { return }
let wait = deadline - .now
let seconds = max(0, Double(wait.components.seconds) + Double(wait.components.attoseconds) / 1e18)
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now() + seconds)
timer.setEventHandler { [weak self] in
guard let self else { return }
self.readiness.tick(now: .now)
self.announceIfReady()
}
timer.resume()
readinessTimer = timer
}
/// At most 10 a second, always including the last: a page can change its address in a loop.
private func passOnPageURL() {
guard pageURLTimer == nil, let url = pendingPageURL else { return }
pendingPageURL = nil
continuation.yield(.pageURL(url))
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now() + 0.1)
timer.setEventHandler { [weak self] in
guard let self, !self.ended else { return }
self.pageURLTimer = nil
self.passOnPageURL()
}
timer.resume()
pageURLTimer = timer
}
// MARK: Writing and ending
private func write(_ input: InputEvent) {
guard !ended, let data = input.encoded() else { return }
let stalled = data.withUnsafeBytes { raw -> Bool in
var offset = 0
while offset < raw.count {
let n = Darwin.write(fd, raw.baseAddress! + offset, raw.count - offset)
if n > 0 { offset += n } else if n < 0, errno == EINTR { continue } else {
return n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)
}
}
return false
}
if stalled { end(.socketError("The VM stopped taking input.")) }
}
private func end(_ reason: EndReason?) {
guard !ended else { return }
ended = true
shutdown(fd, SHUT_RDWR)
readinessTimer?.cancel()
firstFrameTimer?.cancel()
pageURLTimer?.cancel()
video = nil
frameHandler = nil
if let reason { continuation.yield(.ended(reason)) }
continuation.finish()
}
}
@@ -0,0 +1,50 @@
import CoreVideo
public protocol StreamConnector: Sendable {
/// Returns once a valid HELLO arrives; a version mismatch is reported before any other HELLO problem.
func connect(socketPath: String) async throws -> any StreamConnection
}
/// `@unchecked`: a decoded buffer is never written after VideoToolbox returns it.
public struct DecodedFrame: @unchecked Sendable {
public let pixelBuffer: CVPixelBuffer
public init(_ pixelBuffer: CVPixelBuffer) {
self.pixelBuffer = pixelBuffer
}
}
public protocol StreamConnection: AnyObject, Sendable {
/// Unbounded: reading starts only after `connect` returns, and early events must not be lost.
var events: AsyncStream<StreamEvent> { get }
var decodeEnabled: Bool { get set }
/// Called on the decode queue.
func setFrameHandler(_ handler: @escaping @Sendable (DecodedFrame) -> Void)
func send(_ input: InputEvent)
func close()
}
public enum StreamEvent: Equatable, Sendable {
case hello(version: Int, size: PixelSize)
case contentReady
case progress(Int)
/// Untrusted (from the VM): display only.
case pageURL(URL)
case ended(EndReason)
}
public enum EndReason: Equatable, Sendable {
/// EOF: the browser crashed, the firewall failed, or the VM's watchdog fired.
case closed
case protocolViolation(String)
case noFirstFrame
case decodeErrors
case socketError(String)
}
public enum StreamError: Error, Equatable, Sendable {
case versionMismatch(found: Int, need: Int)
case protocolViolation(String)
case timedOut
case refused
}
@@ -0,0 +1,74 @@
import CoreMedia
import CoreVideo
import Foundation
import VideoToolbox
final class VideoDecoder {
typealias Output = @Sendable (DecodedFrame?, OSStatus) -> Void
private let session: VTDecompressionSession
private let format: CMVideoFormatDescription
private let output: Output
init?(parameterSets: ParameterSets, output: @escaping Output) {
var description: CMFormatDescription?
let sps = [UInt8](parameterSets.sps)
let pps = [UInt8](parameterSets.pps)
let status = sps.withUnsafeBufferPointer { s in
pps.withUnsafeBufferPointer { p in
let pointers = [s.baseAddress!, p.baseAddress!]
return CMVideoFormatDescriptionCreateFromH264ParameterSets(
allocator: kCFAllocatorDefault, parameterSetCount: 2, parameterSetPointers: pointers,
parameterSetSizes: [sps.count, pps.count], nalUnitHeaderLength: 4, formatDescriptionOut: &description)
}
}
guard status == noErr, let description else { return nil }
let attributes: [CFString: Any] = [
kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_32BGRA,
kCVPixelBufferIOSurfacePropertiesKey: [CFString: Any]() as CFDictionary,
]
var created: VTDecompressionSession?
let sessionStatus = VTDecompressionSessionCreate(
allocator: kCFAllocatorDefault, formatDescription: description, decoderSpecification: nil,
imageBufferAttributes: attributes as CFDictionary, outputCallback: nil, decompressionSessionOut: &created)
guard sessionStatus == noErr, let created else { return nil }
session = created
format = description
self.output = output
}
deinit {
VTDecompressionSessionInvalidate(session)
}
func decode(_ sample: Data) {
var block: CMBlockBuffer?
guard CMBlockBufferCreateWithMemoryBlock(
allocator: kCFAllocatorDefault, memoryBlock: nil, blockLength: sample.count,
blockAllocator: kCFAllocatorDefault, customBlockSource: nil, offsetToData: 0,
dataLength: sample.count, flags: 0, blockBufferOut: &block) == noErr, let block
else { return output(nil, -1) }
sample.withUnsafeBytes { bytes in
_ = CMBlockBufferReplaceDataBytes(with: bytes.baseAddress!, blockBuffer: block,
offsetIntoDestination: 0, dataLength: sample.count)
}
var sampleBuffer: CMSampleBuffer?
var size = sample.count
guard CMSampleBufferCreateReady(
allocator: kCFAllocatorDefault, dataBuffer: block, formatDescription: format,
sampleCount: 1, sampleTimingEntryCount: 0, sampleTimingArray: nil,
sampleSizeEntryCount: 1, sampleSizeArray: &size, sampleBufferOut: &sampleBuffer) == noErr,
let sampleBuffer
else { return output(nil, -1) }
let output = self.output
let status = VTDecompressionSessionDecodeFrame(session, sampleBuffer: sampleBuffer, flags: [],
infoFlagsOut: nil) { status, _, image, _, _ in
if status == noErr, let image {
output(DecodedFrame(image), noErr)
} else if status != noErr {
output(nil, status)
}
}
if status != noErr { output(nil, status) }
}
}
@@ -0,0 +1,214 @@
import Foundation
/// knotchy-agent ↔ viewer framing, both directions: `[u8 type][u32 big-endian payload length][payload]`.
public enum WireProtocol {
/// Must equal `ARG KNOTCHY_PROTOCOL` in vm/Containerfile. 2: proxy/VPN routes. 3: PAGE_URL.
public static let version = 3
public static let headerSize = 5
public static let maxVideoPayload = 2 * 1024 * 1024
/// Also the agent's cap on viewer messages; unknown agent messages up to this size are skipped.
public static let maxOtherPayload = 64 * 1024
public static let maxPageURL = 16 * 1024
}
enum MessageType {
static let hello: UInt8 = 0x01
static let video: UInt8 = 0x02
static let progress: UInt8 = 0x03
static let navigated: UInt8 = 0x04
static let pageURL: UInt8 = 0x05
static let pointerMove: UInt8 = 0x10
static let pointerButton: UInt8 = 0x11
static let scroll: UInt8 = 0x12
static let key: UInt8 = 0x13
static let navigate: UInt8 = 0x14
static let script: UInt8 = 0x15
static let keyframe: UInt8 = 0x16
}
public struct PixelSize: Hashable, Sendable, CustomStringConvertible {
public var width: Int
public var height: Int
public init(width: Int, height: Int) {
self.width = width
self.height = height
}
public var description: String { "\(width)x\(height)" }
}
public enum AgentMessage: Equatable, Sendable {
/// Version 0 means a pre-v1 image, whose HELLO has no version byte.
case hello(version: Int, size: PixelSize)
case video(size: PixelSize, accessUnit: Data)
case progress(Int)
case navigated
case pageURL(String)
case unknown(type: UInt8)
}
extension AgentMessage {
/// For tests and fake agents: out-of-range values trap, so build malformed frames by hand.
func encoded() -> Data {
var payload = Data()
let type: UInt8
switch self {
case .hello(let version, let size):
type = MessageType.hello
if version != 0 { payload.append(UInt8(version)) }
payload.appendBE16(UInt16(size.width))
payload.appendBE16(UInt16(size.height))
case .video(let size, let accessUnit):
type = MessageType.video
payload.appendBE16(UInt16(size.width))
payload.appendBE16(UInt16(size.height))
payload.append(accessUnit)
case .progress(let percent):
type = MessageType.progress
payload.append(UInt8(percent))
case .navigated:
type = MessageType.navigated
case .pageURL(let url):
type = MessageType.pageURL
payload.append(Data(url.utf8))
case .unknown(let unknownType):
type = unknownType
}
return framed(type, payload)
}
}
func framed(_ type: UInt8, _ payload: Data) -> Data {
var message = Data([type])
message.appendBE32(UInt32(payload.count))
message.append(payload)
return message
}
extension Data {
mutating func appendBE16(_ value: UInt16) {
append(UInt8(value >> 8))
append(UInt8(truncatingIfNeeded: value))
}
mutating func appendBE32(_ value: UInt32) {
appendBE16(UInt16(value >> 16))
appendBE16(UInt16(truncatingIfNeeded: value))
}
}
/// Strict incremental parser; the VM is untrusted, so any bad message is a final violation.
/// Call `next()` until nil after each `append`: that keeps the buffer to one message plus one read.
public struct AgentMessageDecoder: Sendable {
public struct ProtocolViolation: Error, Equatable, Sendable, CustomStringConvertible {
public let description: String
init(_ description: String) {
self.description = description
}
}
public private(set) var helloSize: PixelSize?
private var buffer: [UInt8] = []
private var start = 0
private var violation: ProtocolViolation?
public init() {}
public mutating func append(_ bytes: Data) {
guard violation == nil else { return }
if start > 0 {
buffer.removeFirst(start)
start = 0
}
buffer.append(contentsOf: bytes)
}
public var bufferedByteCount: Int { buffer.count - start }
/// Includes bytes already returned; for tests of the bound.
var storedByteCount: Int { buffer.count }
public mutating func next() throws(ProtocolViolation) -> AgentMessage? {
if let violation { throw violation }
do {
return try decodeNext()
} catch {
violation = error
buffer = []
start = 0
throw error
}
}
private mutating func decodeNext() throws(ProtocolViolation) -> AgentMessage? {
guard bufferedByteCount >= WireProtocol.headerSize else { return nil }
let type = buffer[start]
let length = Int(be32(at: start + 1))
try checkHeader(type: type, length: length)
guard bufferedByteCount - WireProtocol.headerSize >= length else { return nil }
let p = start + WireProtocol.headerSize
start = p + length
switch type {
case MessageType.hello:
let version = length == 5 ? Int(buffer[p]) : 0
let sizeAt = p + length - 4
let size = PixelSize(width: Int(be16(at: sizeAt)), height: Int(be16(at: sizeAt + 2)))
helloSize = size
return .hello(version: version, size: size)
case MessageType.video:
let size = PixelSize(width: Int(be16(at: p)), height: Int(be16(at: p + 2)))
guard size == helloSize else {
throw ProtocolViolation("VIDEO is \(size) but HELLO said \(helloSize.map(\.description) ?? "nothing")")
}
return .video(size: size, accessUnit: Data(buffer[(p + 4)..<(p + length)]))
case MessageType.progress:
let percent = Int(buffer[p])
guard percent <= 100 else { throw ProtocolViolation("PROGRESS \(percent) is over 100") }
return .progress(percent)
case MessageType.navigated:
return .navigated
case MessageType.pageURL:
guard let url = String(validating: buffer[p..<(p + length)], as: UTF8.self) else {
throw ProtocolViolation("PAGE_URL isn't UTF-8")
}
return .pageURL(url)
default:
return .unknown(type: type)
}
}
private func checkHeader(type: UInt8, length: Int) throws(ProtocolViolation) {
func name() -> String { String(format: "type 0x%02x", type) }
if helloSize == nil, type != MessageType.hello {
throw ProtocolViolation("\(name()) before HELLO")
}
let valid: Bool
switch type {
case MessageType.hello:
guard helloSize == nil else { throw ProtocolViolation("a second HELLO") }
valid = length == 5 || length == 4
case MessageType.video:
valid = (4...WireProtocol.maxVideoPayload).contains(length)
case MessageType.progress:
valid = length == 1
case MessageType.navigated:
valid = length == 0
case MessageType.pageURL:
valid = (1...WireProtocol.maxPageURL).contains(length)
default:
valid = length <= WireProtocol.maxOtherPayload
}
guard valid else { throw ProtocolViolation("\(name()) with a \(length)-byte payload") }
}
private func be16(at i: Int) -> UInt16 {
UInt16(buffer[i]) << 8 | UInt16(buffer[i + 1])
}
private func be32(at i: Int) -> UInt32 {
UInt32(be16(at: i)) << 16 | UInt32(be16(at: i + 2))
}
}