wai/wai-sink-swift: the native sink for the platforms whose native player is reached from Swift. WaiSink wraps the C interface (every call JSON in, JSON out); WaiRelay is the loopback server the player is given in place of the origin: it fetches each object, gives the sink a playlist with its receipt and the chain's parents, and answers the player only with bytes the sink presents (502 otherwise), every URI of a playlist rewritten to it; WaiPlayerSession drives the platform's player through it, observes it every 200 ms, logs its first frame, stalls and end, ends a session nobody can see, and posts the bundle to the owner's collector; WaiViewAttention reads the platform's signals of who can see the player. scripts/build-xcframework.sh builds WaiFFI.xcframework from wai-rs. Tests: the corpus through the wrapper, the relay over the reference floor, and the player end to end over a presentation made with the platform's own encoder. A native host's player and its requests run on threads of their own, so an object can reach the sink with a time before the log's last entry. delivery-telemetry §7.1 has a log's times never decrease and nothing after end or abandon: the sink logs such an entry at the last entry's time, and takes no entry after end or abandon, while an object received then is still verified and acknowledged (native-sink §5.2). The corpus gains log-order, and the second implementation checks both rules. Co-Authored-By: Claude Opus 5.5 <[email protected]>
231 lines
10 KiB
Swift
231 lines
10 KiB
Swift
// The native sink's C interface (wai/extensions/native-sink.md §9), wrapped for Swift: one
|
|
// `WaiSink` is one playback session. Every call passes JSON across the boundary as the
|
|
// extension's tables list it, and every answer comes back as the JSON the library wrote.
|
|
import Foundation
|
|
import WaiFFI
|
|
|
|
/// An error the library returned: its status and `wai_last_error`'s message, which starts with
|
|
/// the reason for an input that does not read (`native_input_invalid`, …).
|
|
public struct WaiSinkError: Error, CustomStringConvertible, Equatable {
|
|
public let status: Int32
|
|
public let message: String
|
|
/// The reason code the message starts with.
|
|
public var reason: String { String(message.split(separator: ":", maxSplits: 1).first ?? "") }
|
|
public var description: String { "wai_sink status \(status): \(message)" }
|
|
}
|
|
|
|
/// One session of the native sink (native-sink §3).
|
|
public final class WaiSink: @unchecked Sendable {
|
|
/// What a session requires of what it is given (native-sink §3.1).
|
|
public struct Config: Equatable {
|
|
public var channel: String
|
|
public var owner: String
|
|
public var presentation: String?
|
|
public var windowUs: UInt64?
|
|
public var adClasses: [String]
|
|
public var contentAddressed: Bool
|
|
public var requireSigned: Bool
|
|
public var unseenGraceMs: UInt64?
|
|
public var idleMs: UInt64?
|
|
|
|
public init(channel: String, owner: String, presentation: String? = nil, windowUs: UInt64? = nil, adClasses: [String] = [],
|
|
contentAddressed: Bool = false, requireSigned: Bool = false, unseenGraceMs: UInt64? = nil, idleMs: UInt64? = nil) {
|
|
self.channel = channel
|
|
self.owner = owner
|
|
self.presentation = presentation
|
|
self.windowUs = windowUs
|
|
self.adClasses = adClasses
|
|
self.contentAddressed = contentAddressed
|
|
self.requireSigned = requireSigned
|
|
self.unseenGraceMs = unseenGraceMs
|
|
self.idleMs = idleMs
|
|
}
|
|
|
|
/// The configuration JSON of §3.1.
|
|
public var json: [String: Any] {
|
|
var o: [String: Any] = ["channel": channel, "owner": owner, "ad_classes": adClasses, "content_addressed": contentAddressed,
|
|
"require": requireSigned ? "signed" : "nothing"]
|
|
if let p = presentation { o["presentation"] = p }
|
|
if let w = windowUs { o["window_us"] = w }
|
|
if let g = unseenGraceMs { o["unseen_grace_ms"] = g }
|
|
if let i = idleMs { o["idle_ms"] = i }
|
|
return o
|
|
}
|
|
}
|
|
|
|
/// How a session ends (stream-audit §8.2).
|
|
public enum Ended: String, Sendable {
|
|
case ended, user, hidden, idle, error
|
|
}
|
|
|
|
private let handle: OpaquePointer
|
|
|
|
/// The ABI version the linked library implements.
|
|
public static var abiVersion: UInt32 { wai_sink_abi_version() }
|
|
|
|
/// The host's clock, in microseconds since 1970.
|
|
public static var nowUs: UInt64 { wai_sink_now_us() }
|
|
|
|
/// Open a session from a configuration JSON object. `seed` is for conformance vectors and
|
|
/// tests only: a viewer's session takes a fresh key (`seed` nil).
|
|
public init(configJSON: [String: Any], seed: Data? = nil, startUs: UInt64 = WaiSink.nowUs) throws {
|
|
guard WaiSink.abiVersion == UInt32(WAI_SINK_ABI_VERSION) else {
|
|
throw WaiSinkError(status: -1, message: "native_abi_mismatch: the linked library implements ABI \(WaiSink.abiVersion)")
|
|
}
|
|
let cfg = try WaiSink.text(configJSON)
|
|
var out: OpaquePointer?
|
|
let st: Int32 = cfg.withCString { c in
|
|
if let seed {
|
|
return seed.withUnsafeBytes { p in wai_sink_open(c, p.bindMemory(to: UInt8.self).baseAddress, UInt(seed.count), startUs, &out) }
|
|
}
|
|
return wai_sink_open(c, nil, 0, startUs, &out)
|
|
}
|
|
guard st == 0, let h = out else { throw WaiSink.error(st) }
|
|
handle = h
|
|
}
|
|
|
|
public convenience init(config: Config, seed: Data? = nil, startUs: UInt64 = WaiSink.nowUs) throws {
|
|
try self.init(configJSON: config.json, seed: seed, startUs: startUs)
|
|
}
|
|
|
|
deinit { wai_sink_free(handle) }
|
|
|
|
// MARK: calls
|
|
|
|
/// The session's identity: `channel`, `owner`, `player`, `session`, `start_us`,
|
|
/// `presentation`, `ended`.
|
|
public func info() throws -> [String: Any] {
|
|
try WaiSink.object(try call { wai_sink_info(handle, &$0) })
|
|
}
|
|
|
|
/// The receipt file to fetch beside a playlist, relative to it: `mr/<BLAKE3>.json`.
|
|
public static func receiptName(_ playlist: Data) -> String {
|
|
var out = wai_buffer_t(data: nil, len: 0, cap: 0)
|
|
let st = playlist.withUnsafeBytes { p in wai_sink_receipt_name(p.bindMemory(to: UInt8.self).baseAddress, UInt(playlist.count), &out) }
|
|
defer { wai_buffer_free(out) }
|
|
guard st == 0 else { return "" }
|
|
return String(decoding: WaiSink.bytes(out), as: UTF8.self)
|
|
}
|
|
|
|
/// A playlist the player fetched, with its receipt where one was served (§4.2).
|
|
public func playlist(url: String, bytes: Data, receipt: Data?) throws -> [String: Any] {
|
|
try WaiSink.object(try call { out in
|
|
url.withCString { u in
|
|
bytes.withUnsafeBytes { b in
|
|
let bp = b.bindMemory(to: UInt8.self).baseAddress
|
|
if let receipt {
|
|
return receipt.withUnsafeBytes { r in
|
|
wai_sink_playlist(handle, u, bp, UInt(bytes.count), r.bindMemory(to: UInt8.self).baseAddress ?? UnsafePointer(bitPattern: 1), UInt(receipt.count), &out)
|
|
}
|
|
}
|
|
return wai_sink_playlist(handle, u, bp, UInt(bytes.count), nil, 0, &out)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
/// The parent receipt a verdict named in `need_parent`, or nil when it was not served.
|
|
public func playlistParent(url: String, name: String, bytes: Data?) throws {
|
|
let st: Int32 = url.withCString { u in
|
|
name.withCString { n in
|
|
if let bytes {
|
|
return bytes.withUnsafeBytes { b in wai_sink_playlist_parent(handle, u, n, b.bindMemory(to: UInt8.self).baseAddress ?? UnsafePointer(bitPattern: 1), UInt(bytes.count)) }
|
|
}
|
|
return wai_sink_playlist_parent(handle, u, n, nil, 0)
|
|
}
|
|
}
|
|
guard st == 0 else { throw WaiSink.error(st) }
|
|
}
|
|
|
|
/// A response body the player received, its content codings removed (§4.3).
|
|
public func object(meta: [String: Any], bytes: Data) throws -> [String: Any] {
|
|
let m = try WaiSink.text(meta)
|
|
return try WaiSink.object(try call { out in
|
|
m.withCString { c in
|
|
bytes.withUnsafeBytes { b in wai_sink_object(handle, c, b.bindMemory(to: UInt8.self).baseAddress, UInt(bytes.count), &out) }
|
|
}
|
|
})
|
|
}
|
|
|
|
/// A log event (§5.2).
|
|
public func event(_ name: String, atUs: UInt64) throws {
|
|
let st = name.withCString { wai_sink_event(handle, $0, atUs) }
|
|
guard st == 0 else { throw WaiSink.error(st) }
|
|
}
|
|
|
|
/// An observation (§5.1): the ending the termination rule gives, if any (§6.1).
|
|
@discardableResult
|
|
public func observe(_ obs: [String: Any]) throws -> Ended? {
|
|
let o = try WaiSink.text(obs)
|
|
let v = try WaiSink.object(try call { out in o.withCString { wai_sink_observe(handle, $0, &out) } })
|
|
return (v["end"] as? String).flatMap(Ended.init(rawValue:))
|
|
}
|
|
|
|
/// A role's energy entry (§7).
|
|
public func energy(_ entry: [String: Any]) throws {
|
|
let e = try WaiSink.text(entry)
|
|
let st = e.withCString { wai_sink_energy(handle, $0) }
|
|
guard st == 0 else { throw WaiSink.error(st) }
|
|
}
|
|
|
|
/// Attach another signed record of the session (stream-audit §8.3).
|
|
public func attach(name: String, bytes: Data) throws {
|
|
let st = name.withCString { n in bytes.withUnsafeBytes { wai_sink_attach(handle, n, $0.bindMemory(to: UInt8.self).baseAddress, UInt(bytes.count)) } }
|
|
guard st == 0 else { throw WaiSink.error(st) }
|
|
}
|
|
|
|
/// The Event-mode CMCD batch of the log since the last call.
|
|
public func takeCMCD() throws -> String {
|
|
String(decoding: try call { wai_sink_take_cmcd(handle, &$0) }, as: UTF8.self)
|
|
}
|
|
|
|
/// End the session: the bundle (stream-audit §9), the JSON a host posts to the collector.
|
|
public func end(_ ended: Ended, atUs: UInt64) throws -> Data {
|
|
try call { out in ended.rawValue.withCString { wai_sink_end(handle, $0, atUs, &out) } }
|
|
}
|
|
|
|
/// Verify a bundle as the owner's collector does (stream-audit §10): the outcome JSON.
|
|
public static func verifyBundle(_ bundle: Data, owner: Data, channels: [String]) throws -> [String: Any] {
|
|
precondition(owner.count == 32, "an owner key is 32 bytes")
|
|
let ch = try text(channels)
|
|
var out = wai_buffer_t(data: nil, len: 0, cap: 0)
|
|
let st: Int32 = ch.withCString { c in
|
|
bundle.withUnsafeBytes { b in
|
|
owner.withUnsafeBytes { o in wai_audit_verify_bundle(b.bindMemory(to: UInt8.self).baseAddress, UInt(bundle.count), o.bindMemory(to: UInt8.self).baseAddress, c, &out) }
|
|
}
|
|
}
|
|
defer { wai_buffer_free(out) }
|
|
guard st == 0 else { throw error(st) }
|
|
return try object(bytes(out))
|
|
}
|
|
|
|
// MARK: plumbing
|
|
|
|
private func call(_ f: (inout wai_buffer_t) -> Int32) throws -> Data {
|
|
var out = wai_buffer_t(data: nil, len: 0, cap: 0)
|
|
let st = f(&out)
|
|
defer { wai_buffer_free(out) }
|
|
guard st == 0 else { throw WaiSink.error(st) }
|
|
return WaiSink.bytes(out)
|
|
}
|
|
|
|
static func bytes(_ b: wai_buffer_t) -> Data {
|
|
guard let p = b.data, b.len > 0 else { return Data() }
|
|
return Data(bytes: p, count: Int(b.len))
|
|
}
|
|
|
|
static func error(_ st: Int32) -> WaiSinkError {
|
|
WaiSinkError(status: st, message: wai_last_error().map { String(cString: $0) } ?? "")
|
|
}
|
|
|
|
static func text(_ v: Any) throws -> String {
|
|
String(decoding: try JSONSerialization.data(withJSONObject: v, options: [.fragmentsAllowed]), as: UTF8.self)
|
|
}
|
|
|
|
static func object(_ d: Data) throws -> [String: Any] {
|
|
guard let o = try JSONSerialization.jsonObject(with: d, options: [.fragmentsAllowed]) as? [String: Any] else {
|
|
throw WaiSinkError(status: -1, message: "native_answer_unreadable")
|
|
}
|
|
return o
|
|
}
|
|
}
|