open-standards/wai/wai-sink-swift/Sources/WaiSink/Sink.swift
Transaction Science 643f86f8d7 wai: the Swift package of the native sink, and a log that keeps its order
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]>
2026-10-25 22:55:08 -04:00

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