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

+115
View File
@@ -0,0 +1,115 @@
import Foundation
import Synchronization
public struct CLIRunner: Sendable {
public struct Output: Sendable {
public var status: Int32
public var text: String
public var lastLine: String {
text.split(whereSeparator: \.isNewline).last.map(String.init) ?? "exit status \(status)"
}
}
public enum Failure: Error, Equatable {
case notInstalled
case timedOut([String])
case launchFailed(String)
}
// Fixed paths: apps launched from the Services menu get a minimal PATH.
public static let searchPaths = ["/usr/local/bin/container", "/opt/homebrew/bin/container"]
public let searchPaths: [String]
public init(searchPaths: [String] = CLIRunner.searchPaths) {
self.searchPaths = searchPaths
}
/// Looked up on every call, so a runtime installed while the app runs is found.
public var executable: String? {
searchPaths.first { FileManager.default.isExecutableFile(atPath: $0) }
}
public func run(_ args: [String], timeout: TimeInterval,
line: (@Sendable (String) -> Void)? = nil) async throws -> Output {
try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
continuation.resume(with: Result { try self.runBlocking(args, timeout: timeout, line: line) })
}
}
}
static let outputLimit = 1024 * 1024
private static let builds = Mutex(Set<Int32>())
/// Builds would outlive the app. Other commands are left to the quit path, which waits for them.
public static func stopBuilds() {
for pid in builds.withLock({ $0 }) { kill(pid, SIGTERM) }
}
public func runBlocking(_ args: [String], timeout: TimeInterval,
line: (@Sendable (String) -> Void)? = nil) throws -> Output {
guard let executable else { throw Failure.notInstalled }
let process = Process()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = args
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
// Only the tail is kept: a noisy build can print for minutes.
let limit = Self.outputLimit
let collected = Mutex(Data())
let pending = Mutex(Data())
pipe.fileHandleForReading.readabilityHandler = { handle in
let chunk = handle.availableData
guard !chunk.isEmpty else { return }
collected.withLock { buffer in
buffer.append(chunk)
if buffer.count > limit { buffer.removeFirst(buffer.count - limit) }
}
guard let line else { return }
let lines: [String] = pending.withLock { buffer in
buffer.append(chunk)
var out: [String] = []
while let newline = buffer.firstIndex(of: UInt8(ascii: "\n")) {
out.append(String(decoding: buffer[buffer.startIndex..<newline], as: UTF8.self))
buffer.removeSubrange(buffer.startIndex...newline)
}
if buffer.count > 16 * 1024 { // A line that never ends.
out.append(String(decoding: buffer.prefix(16 * 1024), as: UTF8.self))
buffer.removeAll()
}
return out
}
for text in lines where !text.trimmingCharacters(in: .whitespaces).isEmpty { line(text) }
}
let finished = DispatchSemaphore(value: 0)
process.terminationHandler = { _ in finished.signal() }
do {
try process.run()
} catch {
pipe.fileHandleForReading.readabilityHandler = nil
throw Failure.launchFailed(error.localizedDescription)
}
let pid = process.processIdentifier
let isBuild = args.first == "build"
if isBuild { Self.builds.withLock { _ = $0.insert(pid) } }
defer { if isBuild { Self.builds.withLock { _ = $0.remove(pid) } } }
if finished.wait(timeout: .now() + timeout) == .timedOut {
process.terminate()
if finished.wait(timeout: .now() + 1) == .timedOut {
kill(process.processIdentifier, SIGKILL)
finished.wait()
}
pipe.fileHandleForReading.readabilityHandler = nil
throw Failure.timedOut(args)
}
pipe.fileHandleForReading.readabilityHandler = nil
let rest = pipe.fileHandleForReading.readDataToEndOfFile()
let data = collected.withLock { ($0 + rest).suffix(limit) }
return Output(status: process.terminationStatus, text: String(decoding: data, as: UTF8.self))
}
}
@@ -0,0 +1,311 @@
import Foundation
import Synchronization
public final class ContainerCLIBackend: VMBackend {
public static let image = "local/knotchy-vm:latest"
public static let staleAfterDays = 14
private let cli: CLIRunner
private let vmSources: URL?
private let runDirectory: URL
private let startsInFlight = Mutex(0)
private let route = Mutex(NetworkRoute.direct)
private let startedRoute = Mutex(NetworkRoute.direct)
public var lastStartedRoute: NetworkRoute { startedRoute.withLock { $0 } }
public func setNetworkRoute(_ newRoute: NetworkRoute) {
route.withLock { $0 = newRoute }
}
/// Test-only (knotchyctl --allow-local-proxy); the app never sets it.
private let localProxyAllowed = Mutex(false)
public func allowLocalProxy() {
localProxyAllowed.withLock { $0 = true }
}
public init(vmSources: URL?, cli: CLIRunner = CLIRunner()) {
self.vmSources = vmSources
self.cli = cli
runDirectory = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("knotchy", isDirectory: true)
}
// MARK: Preflight
public func preflight() async -> PreflightResult {
guard cli.executable != nil else { return .issue(.cliMissing) }
if case .outdated(let found) = await runtimeStatus() { return .issue(.cliOutdated(found: found)) }
guard let status = try? await cli.run(["system", "status"], timeout: 15), status.status == 0,
field("status", in: status.text) == "running"
else { return .issue(.serviceStopped) }
let appRoot = field("paths.appRoot", in: status.text)
?? NSHomeDirectory() + "/Library/Application Support/com.apple.container/"
let kernel = URL(fileURLWithPath: appRoot).appendingPathComponent("kernels/default.kernel-arm64")
guard FileManager.default.fileExists(atPath: kernel.path) else { return .issue(.kernelMissing) }
guard let inspect = try? await cli.run(["image", "inspect", Self.image], timeout: 15), inspect.status == 0
else { return .issue(.imageMissing) }
let labels = Self.labels(in: inspect.text)
let found = labels["aculix.knotchy.protocol"].flatMap(Int.init) ?? 0
guard found == WireProtocol.version else {
return .issue(.imageOutdated(found: found, need: WireProtocol.version))
}
prepareRunDirectory()
return .ready(appRoot: appRoot, staleImage: Self.staleness(built: labels["aculix.knotchy.built"]))
}
/// Resolved on the Mac: a proxy route gives the VM no DNS, and a VPN's DNS needs the tunnel up.
static func resolveIPv4(_ host: String) throws -> String {
var address = host
if !WireGuardConfig.isIPv4(host) {
var hints = addrinfo()
hints.ai_family = AF_INET
hints.ai_socktype = SOCK_STREAM
var result: UnsafeMutablePointer<addrinfo>?
guard getaddrinfo(host, nil, &hints, &result) == 0, let first = result, let sockaddr = first.pointee.ai_addr else {
throw VMBackendError.failed("Couldn't find the server “\(host)”. Check Settings → Network.")
}
defer { freeaddrinfo(result) }
var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN))
var inAddr = sockaddr.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee.sin_addr }
guard inet_ntop(AF_INET, &inAddr, &buffer, socklen_t(buffer.count)) != nil else {
throw VMBackendError.failed("Couldn't find the server “\(host)”.")
}
address = String(cString: buffer)
}
if address.hasPrefix("127.") || address == "0.0.0.0" {
throw VMBackendError.failed("The VM can't reach this Mac's localhost (“\(host)”). A proxy on this Mac must listen on the Mac's network address instead.")
}
return address
}
public func imageStatus() async -> ImageStatus {
guard cli.executable != nil else { return .noContainerTool }
guard let inspect = try? await cli.run(["image", "inspect", Self.image], timeout: 15), inspect.status == 0
else { return .missing }
let built = Self.labels(in: inspect.text)["aculix.knotchy.built"].flatMap { ISO8601DateFormatter().date(from: $0) }
return .built(built)
}
public enum ImageStatus: Equatable, Sendable {
case noContainerTool
case missing
case built(Date?)
}
public func runtimeStatus() async -> RuntimeStatus {
guard cli.executable != nil else { return .missing }
let output = try? await cli.run(["--version"], timeout: 10)
return ContainerRuntime.status(versionOutput: output?.text ?? "")
}
public func stopService() async throws {
let result = try await cli.run(["system", "stop"], timeout: 60)
guard result.status == 0 else { throw VMBackendError.failed("Couldn't stop the container service: \(result.lastLine)") }
}
private func field(_ name: String, in text: String) -> String? {
for line in text.split(whereSeparator: \.isNewline) {
let parts = line.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
if parts.count == 2, parts[0] == name {
return parts[1].trimmingCharacters(in: .whitespaces)
}
}
return nil
}
static func labels(in json: String) -> [String: String] {
var found: [String: String] = [:]
func walk(_ value: Any) {
if let dict = value as? [String: Any] {
for (key, child) in dict {
if key.hasPrefix("aculix.knotchy."), let text = child as? String { found[key] = text }
walk(child)
}
} else if let array = value as? [Any] {
array.forEach(walk)
}
}
if let object = try? JSONSerialization.jsonObject(with: Data(json.utf8)) { walk(object) }
return found
}
static func staleness(built: String?, now: Date = Date()) -> StaleImage? {
let formatter = ISO8601DateFormatter()
guard let built, let date = formatter.date(from: built) else { return StaleImage(builtDaysAgo: nil) }
let days = Int(now.timeIntervalSince(date) / 86_400)
return days > staleAfterDays ? StaleImage(builtDaysAgo: days) : nil
}
private func prepareRunDirectory() {
let fm = FileManager.default
let path = runDirectory.path
var isDirectory: ObjCBool = false
let attributes = try? fm.attributesOfItem(atPath: path)
let isSymlink = (attributes?[.type] as? FileAttributeType) == .typeSymbolicLink
let owner = (attributes?[.ownerAccountID] as? NSNumber)?.uint32Value
let mode = (attributes?[.posixPermissions] as? NSNumber)?.intValue
if isSymlink || !fm.fileExists(atPath: path, isDirectory: &isDirectory) || !isDirectory.boolValue
|| owner != getuid() || mode != 0o700 {
try? fm.removeItem(atPath: path)
try? fm.createDirectory(atPath: path, withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700])
}
// Secrets left by a crash; a VM's init deletes its own within a second of booting.
let stale = Date().addingTimeInterval(-120)
for name in (try? fm.contentsOfDirectory(atPath: path)) ?? [] where name.hasSuffix(".secrets") || name.hasSuffix(".env") {
let item = runDirectory.appendingPathComponent(name)
if let modified = (try? fm.attributesOfItem(atPath: item.path))?[.modificationDate] as? Date, modified < stale {
try? fm.removeItem(at: item)
}
}
}
// MARK: Start and destroy
public func start(id: String, url: URL, renderSize: PixelSize, blockCIDRs: [String]) async throws -> VMSession {
startsInFlight.withLock { $0 += 1 }
defer { startsInFlight.withLock { $0 -= 1 } }
prepareRunDirectory()
let socket = runDirectory.appendingPathComponent("\(id).sock").path
unlink(socket)
let chosenRoute = route.withLock { $0 }
let launch = try chosenRoute.launch(resolve: Self.resolveIPv4, hostNetworks: blockCIDRs,
allowLocalProxy: localProxyAllowed.withLock { $0 })
var args = [
"run", "-d", "--rm", "--name", id,
"--cpus", "4", "--memory", "2g", "--shm-size", "512m",
"--dns", "1.1.1.1",
"--cap-add", "NET_ADMIN",
// WebKit's bwrap can't mount procfs over a masked /proc, and init writes /proc/sys.
"--masked-path", "NONE", "--read-only-path", "NONE",
"--publish-socket", "\(socket):/tmp/knotchy/knotchy.sock",
"-e", "KNOTCHY_URL=\(url.absoluteString)",
"-e", "KNOTCHY_SIZE=\(renderSize)",
"-e", "KNOTCHY_BLOCK_CIDRS=\(blockCIDRs.joined(separator: " "))",
]
for setting in launch.environment { args += ["-e", setting] }
if !launch.secrets.isEmpty {
// Init reads, deletes and unmounts this (needs SYS_ADMIN), then drops all capabilities.
let share = secretsDirectory(id)
try? FileManager.default.removeItem(at: share)
guard (try? FileManager.default.createDirectory(at: share, withIntermediateDirectories: false,
attributes: [.posixPermissions: 0o700])) != nil,
FileManager.default.createFile(atPath: share.appendingPathComponent("route").path,
contents: Data((launch.secrets.joined(separator: "\n") + "\n").utf8),
attributes: [.posixPermissions: 0o600])
else {
try? FileManager.default.removeItem(at: share)
throw VMBackendError.failed("Couldn't pass the network settings to the VM.")
}
args += ["--cap-add", "SYS_ADMIN", "--mount", "type=bind,source=\(share.path),target=/run/knotchy-secrets"]
}
args.append(Self.image)
let output: CLIRunner.Output
do {
output = try await cli.run(args, timeout: 60)
} catch {
throw VMBackendError.failed("Starting the VM timed out.")
}
guard output.status == 0 else {
cleanUp(id)
throw VMBackendError.failed("Couldn't start the VM: \(output.lastLine)")
}
startedRoute.withLock { $0 = chosenRoute }
return VMSession(id: id, socketPath: socket)
}
public func destroy(id: String) async throws {
_ = try? await cli.run(["stop", "-t", "2", id], timeout: 10)
if await waitUntilGone(id, for: 5) { return cleanUp(id) }
_ = try? await cli.run(["delete", "--force", id], timeout: 10)
if await waitUntilGone(id, for: 5) { return cleanUp(id) }
throw VMBackendError.destroyStuck(id: id)
}
public func forceDestroy(id: String) async throws {
_ = try? await cli.run(["delete", "--force", id], timeout: 10)
if await waitUntilGone(id, for: 5) { return cleanUp(id) }
throw VMBackendError.destroyStuck(id: id)
}
public func sweepOrphans(keeping: Set<String>) async {
guard let listed = try? await cli.run(["ls", "-a", "-q"], timeout: 10) else { return }
for id in Self.ids(in: listed.text) where Self.isSessionID(id) && !keeping.contains(id) {
_ = try? await cli.run(["delete", "--force", id], timeout: 10)
cleanUp(id)
}
}
static func isSessionID(_ id: String) -> Bool {
id.count == 16 && id.hasPrefix("knotchy-") && id.dropFirst(8).allSatisfy(\.isHexDigit)
}
static func ids(in text: String) -> [String] {
text.split(whereSeparator: \.isNewline).map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
}
private func isListed(_ id: String) async -> Bool? {
guard let listed = try? await cli.run(["ls", "-a", "-q"], timeout: 10), listed.status == 0 else { return nil }
return Self.ids(in: listed.text).contains(id)
}
private func waitUntilGone(_ id: String, for seconds: Double) async -> Bool {
let deadline = Date().addingTimeInterval(seconds)
repeat {
if await isListed(id) == false { return true }
try? await Task.sleep(for: .milliseconds(100))
} while Date() < deadline
return false
}
private func cleanUp(_ id: String) {
unlink(runDirectory.appendingPathComponent("\(id).sock").path)
try? FileManager.default.removeItem(at: secretsDirectory(id))
}
private func secretsDirectory(_ id: String) -> URL {
runDirectory.appendingPathComponent("\(id).secrets", isDirectory: true)
}
// MARK: Setup actions
public func fix(_ issue: SetupIssue, output: @escaping @Sendable (String) -> Void) async throws {
switch issue {
case .serviceStopped, .kernelMissing:
let result = try await cli.run(["system", "start", "--enable-kernel-install"], timeout: 120, line: output)
guard result.status == 0 else { throw VMBackendError.failed("Couldn't start the service: \(result.lastLine)") }
case .imageMissing, .imageOutdated, .imageStale:
guard let vmSources else { throw VMBackendError.failed("The VM sources are missing from the app.") }
let now = Date()
let day = DateFormatter()
day.dateFormat = "yyyy-MM-dd"
let args = [
"build", "--cpus", "4", "--memory", "4g", "--dns", "1.1.1.1", "--pull",
"--build-arg", "APT_REFRESH=\(day.string(from: now))",
"--build-arg", "BUILD_DATE=\(ISO8601DateFormatter().string(from: now))",
"-f", vmSources.appendingPathComponent("Containerfile").path,
"-t", Self.image, vmSources.path,
]
let result = try await cli.run(args, timeout: 15 * 60, line: output)
guard result.status == 0 else { throw VMBackendError.failed("The image build failed: \(result.lastLine)") }
case .cliMissing, .cliOutdated, .lowDisk:
throw VMBackendError.failed("There's no automatic fix for this.")
}
}
// MARK: Quit path
public func destroyBlocking(ids: [String], deadline: Duration) {
let end = Date().addingTimeInterval(TimeInterval(deadline.components.seconds))
// Wait for in-flight starts so a VM created at the last moment is destroyed too.
while startsInFlight.withLock({ $0 }) > 0, Date() < end { usleep(50_000) }
for id in ids {
let left = end.timeIntervalSinceNow
guard left > 0.5 else { return }
_ = try? cli.runBlocking(["delete", "--force", id], timeout: min(10, left))
cleanUp(id)
}
}
}
@@ -0,0 +1,35 @@
import Darwin
import Foundation
public enum HostNetworks {
/// Blocked in the VM on top of the private ranges, for LANs and VPNs on public addresses.
public static func current() -> [String] {
var head: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&head) == 0, let first = head else { return [] }
defer { freeifaddrs(head) }
var networks = Set<String>()
for pointer in sequence(first: first, next: { $0.pointee.ifa_next }) {
let interface = pointer.pointee
guard let address = interface.ifa_addr, let mask = interface.ifa_netmask,
address.pointee.sa_family == UInt8(AF_INET),
interface.ifa_flags & UInt32(IFF_LOOPBACK) == 0 else { continue }
let ip = address.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { UInt32(bigEndian: $0.pointee.sin_addr.s_addr) }
let bits = mask.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { UInt32(bigEndian: $0.pointee.sin_addr.s_addr) }
let prefix = bits.nonzeroBitCount
guard prefix >= 8 else { continue } // Never let a bogus /0 block the whole internet.
let network = ip & bits
networks.insert("\(network >> 24).\((network >> 16) & 0xFF).\((network >> 8) & 0xFF).\(network & 0xFF)/\(prefix)")
}
return networks.sorted()
}
}
public struct SystemDiskSpace: DiskSpaceChecking {
public init() {}
@concurrent
public func bytesFree(atPath path: String) async -> Int64? {
let values = try? URL(fileURLWithPath: path).resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
return values?.volumeAvailableCapacityForImportantUsage
}
}
@@ -0,0 +1,245 @@
import Foundation
/// Proxy and VPN routes fail closed: the VM's firewall lets nothing else out.
public enum NetworkRoute: Equatable, Sendable {
case direct
case proxy(ProxySettings)
case wireGuard(WireGuardConfig)
/// Sessions fail to start rather than quietly browsing directly.
case unusable(reason: String)
}
public struct ProxySettings: Equatable, Sendable {
public enum Kind: String, CaseIterable, Sendable {
case http
case socks5
}
public var kind: Kind
public var host: String
public var port: Int
public var username: String
public var password: String
public init(kind: Kind, host: String, port: Int, username: String = "", password: String = "") {
self.kind = kind
self.host = host
self.port = port
self.username = username
self.password = password
}
public func url(address: String) -> String {
var userinfo = ""
if !username.isEmpty {
userinfo = Self.encode(username) + (password.isEmpty ? "" : ":" + Self.encode(password)) + "@"
}
return "\(kind.rawValue)://\(userinfo)\(address):\(port)"
}
/// Must match the VM firewall's list.
static let localRanges = ["0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16",
"172.16.0.0/12", "192.168.0.0/16", "198.18.0.0/15"]
/// A local proxy is refused: pages could reach the Mac and its network through it.
public static func isLocal(_ address: String, hostNetworks: [String]) -> Bool {
guard let ip = ipv4Value(address) else { return true }
return (localRanges + hostNetworks).contains { cidr in
let parts = cidr.split(separator: "/")
guard parts.count == 2, let network = ipv4Value(String(parts[0])), let bits = Int(parts[1]), (0...32).contains(bits)
else { return false }
let mask: UInt32 = bits == 0 ? 0 : ~UInt32(0) << (32 - bits)
return ip & mask == network & mask
}
}
static func ipv4Value(_ text: String) -> UInt32? {
guard WireGuardConfig.isIPv4(text) else { return nil }
return text.split(separator: ".").reduce(UInt32(0)) { $0 << 8 | UInt32($1)! }
}
static func encode(_ text: String) -> String {
let unreserved = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
return text.addingPercentEncoding(withAllowedCharacters: unreserved) ?? ""
}
}
/// IPv4 only (the VM has IPv6 off). All traffic uses the tunnel, whatever AllowedIPs says.
public struct WireGuardConfig: Equatable, Sendable {
public var privateKey: String
public var addresses: [String]
/// Empty means 1.1.1.1 through the tunnel.
public var dns: [String]
public var mtu: Int?
public var peerPublicKey: String
public var presharedKey: String?
public var endpointHost: String
public var endpointPort: Int
public var persistentKeepalive: Int?
public var name: String?
public enum ParseError: Error, Equatable, Sendable {
case missing(String)
case invalid(String)
case multiplePeers
}
public static func parse(_ text: String) throws(ParseError) -> WireGuardConfig {
var section = ""
var interface: [String: String] = [:]
var peers: [[String: String]] = []
for rawLine in text.split(whereSeparator: \.isNewline) {
let line = rawLine.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)[0]
.trimmingCharacters(in: .whitespaces)
if line.isEmpty { continue }
if line.hasPrefix("[") {
section = line.lowercased()
if section == "[peer]" { peers.append([:]) }
continue
}
guard let equals = line.firstIndex(of: "=") else { throw .invalid(line) }
let key = line[..<equals].trimmingCharacters(in: .whitespaces).lowercased()
let value = line[line.index(after: equals)...].trimmingCharacters(in: .whitespaces)
switch section {
case "[interface]":
interface[key] = interface[key].map { $0 + "," + value } ?? value
case "[peer]":
peers[peers.count - 1][key] = value
default:
throw .invalid(line)
}
}
guard peers.count <= 1 else { throw .multiplePeers }
guard let peer = peers.first else { throw .missing("[Peer]") }
guard let privateKey = interface["privatekey"] else { throw .missing("PrivateKey") }
guard isKey(privateKey) else { throw .invalid("PrivateKey") }
guard let publicKey = peer["publickey"] else { throw .missing("PublicKey") }
guard isKey(publicKey) else { throw .invalid("PublicKey") }
let presharedKey = peer["presharedkey"]
if let presharedKey, !isKey(presharedKey) { throw .invalid("PresharedKey") }
let addresses = list(interface["address"]).compactMap(ipv4CIDR)
guard !addresses.isEmpty else { throw .missing("an IPv4 Address") }
let dns = list(interface["dns"]).filter(isIPv4)
let mtu = try interface["mtu"].map { (text: String) throws(ParseError) -> Int in
guard let mtu = Int(text), (576...9000).contains(mtu) else { throw .invalid("MTU") }
return mtu
}
let keepalive = try peer["persistentkeepalive"].flatMap { (text: String) throws(ParseError) -> Int? in
if text.lowercased() == "off" { return nil }
guard let seconds = Int(text), (0...65535).contains(seconds) else { throw .invalid("PersistentKeepalive") }
return seconds == 0 ? nil : seconds
}
guard let endpoint = peer["endpoint"] else { throw .missing("Endpoint") }
guard !endpoint.hasPrefix("["), let colon = endpoint.lastIndex(of: ":"),
let port = Int(endpoint[endpoint.index(after: colon)...]), (1...65535).contains(port)
else { throw .invalid("Endpoint (an IPv4 address or host name, and a port)") }
let host = String(endpoint[..<colon])
guard !host.isEmpty, host.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "." || $0 == "-" }) else {
throw .invalid("Endpoint")
}
return WireGuardConfig(privateKey: privateKey, addresses: addresses, dns: dns, mtu: mtu,
peerPublicKey: publicKey, presharedKey: presharedKey,
endpointHost: host, endpointPort: port, persistentKeepalive: keepalive)
}
public func setconf(endpointAddress address: String) -> String {
var lines = ["[Interface]", "PrivateKey = \(privateKey)", "", "[Peer]", "PublicKey = \(peerPublicKey)"]
if let presharedKey { lines.append("PresharedKey = \(presharedKey)") }
lines.append("Endpoint = \(address):\(endpointPort)")
lines.append("AllowedIPs = 0.0.0.0/0")
lines.append("PersistentKeepalive = \(persistentKeepalive ?? 25)")
return lines.joined(separator: "\n") + "\n"
}
// MARK: Validation
/// Curve25519: 32 bytes in base64.
static func isKey(_ text: String) -> Bool {
text.count == 44 && Data(base64Encoded: text)?.count == 32
}
static func list(_ text: String?) -> [String] {
(text ?? "").split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
}
static func isIPv4(_ text: String) -> Bool {
let parts = text.split(separator: ".", omittingEmptySubsequences: false)
return parts.count == 4 && parts.allSatisfy { part in
!part.isEmpty && part.count <= 3 && part.allSatisfy(\.isASCII) && part.allSatisfy(\.isNumber)
&& Int(part).map { $0 <= 255 } == true
}
}
static func ipv4CIDR(_ text: String) -> String? {
let parts = text.split(separator: "/", omittingEmptySubsequences: false)
guard parts.count <= 2, isIPv4(String(parts[0])) else { return nil }
guard parts.count == 2 else { return text + "/32" }
guard let prefix = Int(parts[1]), (0...32).contains(prefix), parts[1].allSatisfy(\.isNumber) else { return nil }
return text
}
}
extension NetworkRoute {
public var label: String? {
switch self {
case .direct, .unusable: nil
case .proxy(let proxy): proxy.kind == .socks5 ? "SOCKS5 proxy" : "HTTP proxy"
case .wireGuard(let config): config.name ?? "VPN"
}
}
}
extension WireGuardConfig.ParseError {
/// Finishes "The WireGuard config …".
public var message: String {
switch self {
case .missing(let what): "has no \(what)"
case .invalid(let what): "has an invalid \(what)"
case .multiplePeers: "has more than one peer; Knotchy needs one server per config"
}
}
}
public struct RouteLaunch: Equatable, Sendable {
/// Always includes KNOTCHY_ROUTE, so a VM missing its proxy or VPN settings stops.
public var environment: [String]
/// Never passed with `-e`: `container` saves the environment to disk and shows it in `inspect`.
public var secrets: [String]
}
extension NetworkRoute {
public func launch(resolve: (String) throws -> String, hostNetworks: [String] = [],
allowLocalProxy: Bool = false) throws -> RouteLaunch {
switch self {
case .direct:
return RouteLaunch(environment: ["KNOTCHY_ROUTE=direct"], secrets: [])
case .unusable(let reason):
throw VMBackendError.failed(reason)
case .proxy(let proxy):
let address = try resolve(proxy.host)
if !allowLocalProxy, ProxySettings.isLocal(address, hostNetworks: hostNetworks) {
let which = proxy.host == address ? address : "\(proxy.host) is \(address)"
throw VMBackendError.failed(
"Knotchy won't use a proxy on this Mac or your local network (\(which)): pages could reach your network through it.")
}
return RouteLaunch(environment: ["KNOTCHY_ROUTE=proxy", "KNOTCHY_PROXY_ADDR=\(address):\(proxy.port)"],
secrets: ["KNOTCHY_PROXY_URL=\(proxy.url(address: address))"])
case .wireGuard(let config):
let address = try resolve(config.endpointHost)
return RouteLaunch(
environment: [
"KNOTCHY_ROUTE=wireguard",
"KNOTCHY_WG_ENDPOINT=\(address):\(config.endpointPort)",
"KNOTCHY_WG_ADDRESS=\(config.addresses.joined(separator: " "))",
"KNOTCHY_WG_DNS=\(config.dns.joined(separator: " "))",
"KNOTCHY_WG_MTU=\(config.mtu ?? 1420)",
],
secrets: ["KNOTCHY_WG_SETCONF=\(Data(config.setconf(endpointAddress: address).utf8).base64EncodedString())"]
)
}
}
}
@@ -0,0 +1,65 @@
import Foundation
/// The controller serializes all calls except `destroyBlocking`, which the quit path may make
/// from any thread while an async call is still running.
public protocol VMBackend: Sendable {
func preflight() async -> PreflightResult
/// Must register as in flight before its first suspension so `destroyBlocking` waits for it.
func start(id: String, url: URL, renderSize: PixelSize, blockCIDRs: [String]) async throws -> VMSession
/// Returns only once the VM is confirmed gone; otherwise throws `.destroyStuck`.
func destroy(id: String) async throws
func forceDestroy(id: String) async throws
func sweepOrphans(keeping: Set<String>) async
func fix(_ issue: SetupIssue, output: @escaping @Sendable (String) -> Void) async throws
/// Not serialized by the controller: `container --version` doesn't use the service.
func runtimeStatus() async -> RuntimeStatus
func stopService() async throws
/// Blocking. Waits for in-flight starts first so a VM created at the last moment is destroyed too.
func destroyBlocking(ids: [String], deadline: Duration)
}
public struct VMSession: Equatable, Sendable {
public let id: String
public let socketPath: String
public init(id: String, socketPath: String) {
self.id = id
self.socketPath = socketPath
}
}
public enum SetupIssue: Equatable, Sendable {
case cliMissing
case cliOutdated(found: String)
case serviceStopped
case kernelMissing
case imageMissing
case imageOutdated(found: Int, need: Int)
case imageStale(builtDaysAgo: Int?)
case lowDisk(bytesFree: Int64)
}
public enum PreflightResult: Equatable, Sendable {
case ready(appRoot: String, staleImage: StaleImage?)
/// Never `.imageStale` (that rides on `.ready`) or `.lowDisk` (the controller checks disk).
case issue(SetupIssue)
}
public struct StaleImage: Equatable, Sendable {
public var builtDaysAgo: Int?
public init(builtDaysAgo: Int?) {
self.builtDaysAgo = builtDaysAgo
}
}
public enum VMBackendError: Error, Equatable, Sendable {
case destroyStuck(id: String)
/// The message is shown to the user.
case failed(String)
}
public protocol DiskSpaceChecking: Sendable {
/// Async: the query can block for tens of milliseconds and stall the panel's opening animation.
func bytesFree(atPath path: String) async -> Int64?
}
@@ -0,0 +1,117 @@
import CoreGraphics
/// Rects are in global screen coordinates (AppKit's: origin bottom left, y up).
public struct ScreenInfo: Equatable, Sendable {
public var frame: CGRect
public var visibleFrame: CGRect
public var safeAreaTop: CGFloat
public var auxLeft: CGRect
public var auxRight: CGRect
public var menuBarThickness: CGFloat
public init(frame: CGRect, visibleFrame: CGRect, safeAreaTop: CGFloat,
auxLeft: CGRect, auxRight: CGRect, menuBarThickness: CGFloat) {
self.frame = frame
self.visibleFrame = visibleFrame
self.safeAreaTop = safeAreaTop
self.auxLeft = auxLeft
self.auxRight = auxRight
self.menuBarThickness = menuBarThickness
}
public var hasNotch: Bool {
safeAreaTop > 0 && !auxLeft.isEmpty && !auxRight.isEmpty
}
}
public enum PanelMode: Sendable {
case peek
case expanded
}
/// Global screen coordinates, y up; convert them for SwiftUI views and tracking areas.
public struct PanelLayout: Equatable, Sendable {
public var notch: CGRect
public var pill: CGRect
/// Full size for its mode so the shape never clips; a Peek↔Expanded animation needs the larger.
public var panel: CGRect
public var toolbar: CGRect
public var content: CGRect
public var contentScale: CGFloat
}
public enum NotchGeometry {
public static let toolbarHeight: CGFloat = 34
public static let margin: CGFloat = 6
public static let peekContent = CGSize(width: 640, height: 400)
public static let expandedContent = CGSize(width: 1024, height: 640)
public static let renderWidth: CGFloat = 1024
public static let virtualNotchWidth: CGFloat = 200
public static let minimumNotchHeight: CGFloat = 24
public static let notchWidthRange: ClosedRange<CGFloat> = 120...300
public static let pillEarWidth: CGFloat = 72
public static let screenInset: CGFloat = 8
public static let minimumContentHeight: CGFloat = 50
public static func layout(for screen: ScreenInfo, mode: PanelMode) -> PanelLayout {
let notch = notchRect(for: screen)
let height = notch.height
let base = mode == .peek ? peekContent : expandedContent
// Ignores a side Dock: the panel may cover its top.
let maxWidth = screen.frame.width - 2 * screenInset - 2 * margin
let maxHeight = (screen.frame.maxY - screen.visibleFrame.minY) - height - toolbarHeight - margin - screenInset
// Keep exactly 16:10 in whole points: the height is a multiple of 5, the width 8/5 of it.
let fittingHeight = min(base.height, maxHeight, maxWidth * 5 / 8)
let contentHeight = max(minimumContentHeight, (fittingHeight / 5).rounded(.down) * 5)
let contentWidth = contentHeight * 8 / 5
let panelWidth = contentWidth + 2 * margin
let panelHeight = height + toolbarHeight + contentHeight + margin
let centeredX = (notch.midX - panelWidth / 2).rounded()
let panelX = min(max(centeredX, screen.frame.minX), screen.frame.maxX - panelWidth)
let panel = CGRect(x: panelX, y: screen.frame.maxY - panelHeight, width: panelWidth, height: panelHeight)
return PanelLayout(
notch: notch,
pill: notch.insetBy(dx: -pillEarWidth, dy: 0),
panel: panel,
toolbar: CGRect(x: panel.minX + margin, y: panel.maxY - height - toolbarHeight,
width: contentWidth, height: toolbarHeight),
content: CGRect(x: panel.minX + margin, y: panel.minY + margin,
width: contentWidth, height: contentHeight),
contentScale: contentWidth / renderWidth
)
}
/// `point` is in a flipped view. Nil on degenerate input: drop the event rather than click at (0, 0).
public static func videoPoint(_ point: CGPoint, surfaceSize: CGSize, renderSize: PixelSize) -> (x: UInt16, y: UInt16)? {
let pixels = 1...65536
guard point.x.isFinite, point.y.isFinite, surfaceSize.width.isFinite, surfaceSize.height.isFinite,
surfaceSize.width > 0, surfaceSize.height > 0,
pixels.contains(renderSize.width), pixels.contains(renderSize.height)
else { return nil }
let video = CGSize(width: renderSize.width, height: renderSize.height)
let scale = min(surfaceSize.width / video.width, surfaceSize.height / video.height)
guard scale > 0 else { return nil }
let originX = (surfaceSize.width - video.width * scale) / 2
let originY = (surfaceSize.height - video.height * scale) / 2
let x = min(max((point.x - originX) / scale, 0), video.width - 1)
let y = min(max((point.y - originY) / scale, 0), video.height - 1)
return (UInt16(x), UInt16(y))
}
private static func notchRect(for screen: ScreenInfo) -> CGRect {
if screen.hasNotch {
let left = screen.auxLeft.maxX
let right = screen.auxRight.minX
let width = min(max(right - left, notchWidthRange.lowerBound), notchWidthRange.upperBound)
return CGRect(x: (left + right) / 2 - width / 2, y: screen.frame.maxY - screen.safeAreaTop,
width: width, height: screen.safeAreaTop)
}
// The minimum keeps a virtual notch visible when the menu bar auto-hides.
let height = max(screen.frame.maxY - screen.visibleFrame.maxY, screen.menuBarThickness, minimumNotchHeight)
return CGRect(x: (screen.frame.midX - virtualNotchWidth / 2).rounded(), y: screen.frame.maxY - height,
width: virtualNotchWidth, height: height)
}
}
+131
View File
@@ -0,0 +1,131 @@
import Foundation
/// Input is untrusted and scanned inside the synchronous Services call, so work is linear and capped.
public enum LinkIntake {
/// UTF-8 bytes. Links reach the VM in an env var and in NAVIGATE messages (64 KiB limit).
public static let maxURLBytes = 16 * 1024
public static let maxScannedCharacters = 1024 * 1024
/// The first http(s) URL in `text`, without surrounding prose punctuation, quotes or Markdown.
public static func extractURL(from text: String) -> URL? {
let startLimit = text.index(text.startIndex, offsetBy: maxScannedCharacters, limitedBy: text.endIndex)
?? text.endIndex
// One past the limit, so an over-long link is rejected rather than truncated.
let readLimit = text.index(startLimit, offsetBy: maxURLBytes + 1, limitedBy: text.endIndex) ?? text.endIndex
var from = text.startIndex
while from < startLimit,
let match = text.range(of: "http", options: .caseInsensitive, range: from..<startLimit) {
let end = text[match.lowerBound..<readLimit].firstIndex(where: endsURL) ?? readLimit
let token = text[match.lowerBound..<end]
let before = match.lowerBound > text.startIndex ? text[text.index(before: match.lowerBound)] : nil
var parts = [(token, afterParenthesis: before == "(")]
if let markdown = token.range(of: "](") {
parts = [(token[..<markdown.lowerBound], before == "("), (token[markdown.upperBound...], true)]
}
for (whole, afterParenthesis) in parts {
let part = afterParenthesis ? upToUnbalancedClosingParenthesis(whole) : whole
let candidate = trimmingTrailingPunctuation(part, stripping: before == "*" ? ".,;:!?\"'*" : ".,;:!?\"'")
if candidate.utf8.count <= maxURLBytes,
let url = URL(string: String(candidate)),
let valid = validate(url) {
return valid
}
}
from = end
}
return nil
}
/// Tries `href` targets first: a selected hyperlink's visible text rarely contains its address.
public static func extractURL(fromHTML html: String) -> URL? {
let text = String(html.prefix(maxScannedCharacters))
let range = NSRange(text.startIndex..., in: text)
for match in hrefPattern.matches(in: text, range: range) {
for group in 1...3 {
guard let found = Range(match.range(at: group), in: text) else { continue }
let target = decodingEntities(text[found]).trimmingCharacters(in: .whitespacesAndNewlines)
if target.utf8.count <= maxURLBytes, let url = URL(string: target), let valid = validate(url) {
return valid
}
}
}
return extractURL(from: text)
}
private static let hrefPattern = try! NSRegularExpression(
pattern: #"href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))"#, options: [.caseInsensitive])
private static func decodingEntities(_ value: Substring) -> String {
var result = String(value)
for (entity, character) in [("&quot;", "\""), ("&#34;", "\""), ("&#39;", "'"), ("&apos;", "'"),
("&lt;", "<"), ("&gt;", ">"), ("&#38;", "&"), ("&amp;", "&")] {
result = result.replacingOccurrences(of: entity, with: character, options: .caseInsensitive)
}
return result
}
public static func validate(_ url: URL) -> URL? {
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https",
let host = url.host(percentEncoded: false), !host.isEmpty,
url.absoluteString.utf8.count <= maxURLBytes
else { return nil }
return url
}
/// Missed by the category check. IDNA would turn these full stops into dots in the host.
private static let cjkPunctuation: Set<Character> = ["。", ".", "。", "、", ",", "…", "!", "?", ":", ";"]
private static func endsURL(_ c: Character) -> Bool {
if c.isWhitespace || c == "<" || c == ">" || c == "\"" || c == "`" { return true }
guard let scalar = c.unicodeScalars.first, !scalar.isASCII else { return false }
switch scalar.properties.generalCategory {
case .initialPunctuation, .finalPunctuation, .openPunctuation, .closePunctuation:
return true
default:
return cjkPunctuation.contains(c)
}
}
private static func upToUnbalancedClosingParenthesis(_ part: Substring) -> Substring {
var depth = 0
for i in part.indices {
switch part[i] {
case "(":
depth += 1
case ")":
if depth == 0 { return part[..<i] }
depth -= 1
default:
break
}
}
return part
}
private static let openerOf: [Character: Character] = [")": "(", "]": "[", "}": "{"]
private static let closerOf: [Character: Character] = ["(": ")", "[": "]", "{": "}"]
private static func trimmingTrailingPunctuation(_ token: Substring, stripping punctuation: String) -> Substring {
// Closing minus opening brackets, keyed by the closing one.
var unbalanced: [Character: Int] = [:]
for c in token {
if openerOf[c] != nil {
unbalanced[c, default: 0] += 1
} else if let closer = closerOf[c] {
unbalanced[closer, default: 0] -= 1
}
}
var s = token
while let last = s.last {
if punctuation.contains(last) {
s.removeLast()
} else if openerOf[last] != nil, unbalanced[last, default: 0] > 0 {
unbalanced[last, default: 0] -= 1
s.removeLast()
} else {
break
}
}
return s
}
}
@@ -0,0 +1,49 @@
import Synchronization
/// Runs async jobs one at a time, in order, so backend operations never overlap.
@MainActor
final class BackendChain {
private var tail: Task<Void, Never>?
private(set) var isClosed = false
func enqueue(_ job: @escaping @MainActor () async -> Void) {
guard !isClosed else { return }
let previous = tail
tail = Task {
await previous?.value
guard !self.isClosed else { return }
await job()
}
}
func close() {
isClosed = true
}
func waitUntilIdle() async {
while let current = tail {
await current.value
if tail == current { return }
}
}
}
/// Every VM ID from its `start` being queued until its destroy is confirmed.
/// Lock-protected: the quit path reads it off the main actor.
public final class LiveIDs: Sendable {
private let ids = Mutex<Set<String>>([])
public init() {}
public var snapshot: Set<String> {
ids.withLock { $0 }
}
func insert(_ id: String) {
_ = ids.withLock { $0.insert(id) }
}
func remove(_ id: String) {
_ = ids.withLock { $0.remove(id) }
}
}
@@ -0,0 +1,956 @@
import Foundation
import Observation
/// The single-session state machine behind the notch UI. Inputs never block; backend calls run serially.
@MainActor
@Observable
public final class SessionController {
// MARK: - Timings and limits
public static let renderSize = PixelSize(width: 1024, height: 640)
public static let foldGrace: Duration = .milliseconds(400)
public static let neverEnteredFoldDelay: Duration = .seconds(30)
public static let defaultIdleDestroyDelay: Duration = .seconds(5 * 60)
public static let idleQuitDelay: Duration = .seconds(60)
public static let destroyedDisplay: Duration = .milliseconds(1500)
public static let endedDisplay: Duration = .seconds(3)
public static let destroyStuckDisplay: Duration = .seconds(30)
public static let noticeDisplay: Duration = .seconds(2)
public static let progressLinger: Duration = .seconds(2)
public static let minimumFreeBytes: Int64 = 2_000_000_000
public static let setupOutputLimit = 200
public static let stillBrokenMessage = "Setup finished, but the runtime still reports a problem."
// MARK: - Outputs
public private(set) var state: SessionState = .idle
// MARK: - Preferences
/// Takes effect the next time a session folds.
public var idleDestroyDelay: Duration = SessionController.defaultIdleDestroyDelay
public var opensExpanded = false
public var quitsWhenIdle = true
/// Opens the setup window; the app sets it.
@ObservationIgnored public var showSetup: @MainActor () -> Void = {}
public private(set) var setupFolded = false
@ObservationIgnored private var setupOpenedByHover = false
public private(set) var notice: Notice?
public private(set) var progress: Int?
public private(set) var openedURL: URL?
/// Reported by the VM, so untrusted: shown, never opened or retried.
public private(set) var pageURL: URL?
public private(set) var setupLog: String?
/// The latest setup action's last lines, for the setup window's details.
public private(set) var setupOutput: [String] = []
public private(set) var connection: (any StreamConnection)?
public private(set) var staleImage: StaleImage?
public private(set) var idleDeadline: ContinuousClock.Instant?
public var countdown: Duration? {
idleDeadline.map { max(.zero, $0 - clock.now) }
}
public nonisolated let liveIDs = LiveIDs()
// MARK: - Dependencies
private let backend: any VMBackend
private let connector: any StreamConnector
private let diskSpace: any DiskSpaceChecking
private let clock: any KnotchyClock
private let hostNetworks: () -> [String]
private let requestQuit: @MainActor () -> Void
private let makeSessionID: () -> String
// MARK: - Internal state
@MainActor
private final class Session {
let id: String
let url: URL
/// `backend.start` has been called, so a VM may exist.
var startBegan = false
var retired = false
var contentReady = false
var connection: (any StreamConnection)?
var tasks: [Task<Void, Never>] = []
init(id: String, url: URL) {
self.id = id
self.url = url
}
}
private enum TimerKey: Hashable, Sendable {
case foldGrace, neverEnteredFold, idleDestroy, transient, notice, progressHide, idleQuit
}
private enum RunMode: Equatable {
/// From the notch or the menu: stops at the next card, as before.
case link
/// From the setup window.
case window(continuing: Bool)
/// The setup window's check: starts the service, never builds.
case check
}
private struct SetupRun {
var mode: RunMode
/// The loop guard: each issue is fixed at most once per run.
var fixed: [SetupIssue]
var waiter: CheckedContinuation<SetupRunOutcome, Never>?
}
private let chain = BackendChain()
private let timers: DeadlineTimers<TimerKey>
// Invariants: at most one session isn't retired, and it is `current`; only `transition(to:)`
// writes `state` (and owns its timers); while `settingUp`, only the setup outcome changes it.
@ObservationIgnored private var current: Session?
@ObservationIgnored private var pendingURL: URL?
/// IDs whose destroy failed; they stay in `liveIDs`.
@ObservationIgnored private var pendingCleanup: [String] = []
@ObservationIgnored private var requestedImageUpdate: StaleImage?
@ObservationIgnored private var lastProgressAt: ContinuousClock.Instant?
@ObservationIgnored private var entered = false
@ObservationIgnored private var pointerInside = false
@ObservationIgnored private var focused = false
@ObservationIgnored private var run: SetupRun?
@ObservationIgnored private var settleWaiters: [CheckedContinuation<Void, Never>] = []
/// The issues started automatically for the link now waiting; each is tried once per request.
@ObservationIgnored private var autoFixed: (url: URL, issues: [SetupIssue])?
public init(
backend: any VMBackend,
connector: any StreamConnector,
diskSpace: any DiskSpaceChecking,
clock: any KnotchyClock = SystemClock(),
hostNetworks: @escaping () -> [String],
requestQuit: @escaping @MainActor () -> Void,
makeSessionID: @escaping () -> String = SessionController.randomSessionID
) {
self.backend = backend
self.connector = connector
self.diskSpace = diskSpace
self.clock = clock
self.hostNetworks = hostNetworks
self.requestQuit = requestQuit
self.makeSessionID = makeSessionID
timers = DeadlineTimers(clock: clock)
timers.onFire = { [weak self] key in self?.timerFired(key) }
enqueue { [backend] in await backend.sweepOrphans(keeping: []) }
timers.set(.idleQuit, after: Self.idleQuitDelay)
}
/// `knotchy-<8 hex>`: the only names the launch sweep deletes.
public nonisolated static func randomSessionID() -> String {
"knotchy-" + String(format: "%08x", UInt32.random(in: .min ... .max))
}
// MARK: - Links
/// A new link always wins. Anything but http/https shows "No web link found".
public func open(_ url: URL) {
guard let url = LinkIntake.validate(url) else { return show(.noWebLink) }
guard !chain.isClosed else { return }
autoFixed = nil
if case .settingUp = state {
pendingURL = url
setupFolded = false
} else {
startSession(url)
}
}
public func open(text: String) {
guard let url = LinkIntake.extractURL(from: text) else { return show(.noWebLink) }
open(url)
}
// MARK: - Toolbar
public func navigate(_ url: URL) {
guard let url = LinkIntake.validate(url) else { return show(.noWebLink) }
guard case .live = state, let connection else { return }
openedURL = url
pageURL = nil
connection.send(.navigate(url.absoluteString))
}
public func goBack() { runScript("history.back()") }
public func goForward() { runScript("history.forward()") }
public func reload() { runScript("location.reload()") }
private func runScript(_ javaScript: String) {
guard case .live = state else { return }
connection?.send(.script(javaScript))
}
// MARK: - Panel
public func expand() {
guard state == .live(.peek) else { return }
entered = true
focused = true
transition(to: .live(.expanded))
}
public func shrink() {
guard state == .live(.expanded) else { return }
transition(to: .live(.peek))
}
public func pointerEntered() {
pointerInside = true
timers.cancel(.foldGrace)
switch state {
case .settingUp:
if setupFolded { setupOpenedByHover = true }
setupFolded = false
case .starting(let step, folded: true):
unfold(to: .starting(step, folded: false))
case .live(.collapsed):
unfold(to: .live(.peek))
case .starting, .live:
entered = true
timers.cancel(.neverEnteredFold)
default:
break
}
}
public func pointerExited() {
pointerInside = false
if isPeekLike, entered, !focused {
timers.set(.foldGrace, after: Self.foldGrace)
}
if case .settingUp = state, setupOpenedByHover {
timers.set(.foldGrace, after: Self.foldGrace)
}
}
public func panelClicked() {
guard isOpenPanel else { return }
entered = true
focused = true
timers.cancel(.foldGrace)
timers.cancel(.neverEnteredFold)
}
public func clickedOutside() {
if isOpenPanel { fold() }
if case .settingUp = state { setupFolded = true }
}
public func foldSetup() {
if case .settingUp = state { setupFolded = true }
setupOpenedByHover = false
}
private var isPeekLike: Bool {
switch state {
case .starting(_, folded: false), .live(.peek): true
default: false
}
}
private var isOpenPanel: Bool {
isPeekLike || state == .live(.expanded)
}
private var isFolded: Bool {
if case .starting(_, folded: true) = state { true } else { false }
}
private func fold() {
focused = false
pointerInside = false
switch state {
case .starting(let step, folded: false):
transition(to: .starting(step, folded: true))
case .live(.peek), .live(.expanded):
transition(to: .live(.collapsed))
default:
break
}
}
private func unfold(to newState: SessionState) {
entered = true
focused = false
transition(to: newState)
}
// MARK: - Destroy, retry, dismiss
public func destroy() {
guard let session = current else { return }
switch state {
case .starting, .live:
transition(to: .destroying)
retire(session) { [weak self] gone in self?.destroyFinished(session, gone: gone) }
default:
break
}
}
public func retry() {
switch state {
case .failed(.start):
autoFixed = nil // A retry is a new request: it gets its own automatic start.
if let url = current?.url { startSession(url, pointerStays: true) }
case .failed(.destroyStuck):
guard let session = current else { return }
pendingCleanup.removeAll { $0 == session.id }
transition(to: .destroying)
enqueue { [weak self] in
await self?.runDestroy(session) { gone in self?.destroyFinished(session, gone: gone) }
}
default:
break
}
}
public func dismiss() {
switch state {
case .failed(.start):
enterIdle()
case .setupRequired:
pendingURL = nil
enterIdle()
default:
break
}
}
// MARK: - Setup
public func performSetupAction() {
guard case .setupRequired(let issue, _) = state else { return }
switch issue {
case .cliMissing, .cliOutdated:
showSetup()
case .lowDisk:
break
default:
startRun(issue, mode: .link, folded: false)
}
}
/// Rebuilds the image once this session ends.
public func requestImageUpdate() {
guard let stale = staleImage else { return }
staleImage = nil
requestedImageUpdate = stale
}
public func rebuildImage() {
switch state {
case .idle:
startRun(.imageStale(builtDaysAgo: nil), mode: .link, folded: false)
case .settingUp:
break
default:
staleImage = nil
requestedImageUpdate = StaleImage(builtDaysAgo: nil)
}
}
public func showPanel() {
switch state {
case .settingUp:
setupFolded = false
setupOpenedByHover = false
case .starting(let step, folded: true): unfold(to: .starting(step, folded: false))
case .live(.collapsed): unfold(to: .live(.peek))
default: break
}
}
public func dismissStaleImageWarning() {
staleImage = nil
}
/// Quit path: queued backend jobs never start after this. One already running may still call
/// the backend, which is safe because `destroyBlocking` then blocks the main thread until exit.
public func closeBackendChain() {
chain.close()
}
// MARK: - Setup window
/// Runs `issue`'s fix folded into the notch; when `continuing`, also each fixable issue preflight
/// finds next, each at most once. A waiting link opens only once everything is ready. nil when busy.
public func runSetup(_ issue: SetupIssue, continuing: Bool) async -> SetupRunOutcome? {
guard acceptsSetupWindow else { return nil }
return await withCheckedContinuation { continuation in
startRun(issue, mode: .window(continuing: continuing), folded: true, waiter: continuation)
}
}
/// Preflight for the setup window: starts a stopped service or missing kernel, never builds. nil when busy.
public func checkSetup() async -> SetupRunOutcome? {
guard acceptsSetupWindow else { return nil }
let preflight = await onChain { [backend] in await backend.preflight() }
guard acceptsSetupWindow else { return nil }
switch preflight {
case .ready:
// Fixed outside Knotchy while a card held a link: open it.
if case .setupRequired = state, let url = pendingURL { startSession(url, pointerStays: true) }
return .ready
case .issue(let issue) where Self.fixes(issue, in: .check):
return await withCheckedContinuation { continuation in
startRun(issue, mode: .check, folded: true, waiter: continuation)
}
case .issue(let issue):
return .needs(issue)
}
}
/// Around a runtime install: links wait, and an update stops the service first.
public func beginRuntimeInstall(replacing found: String?) async -> Result<Void, RuntimeHoldRefusal> {
guard acceptsSetupWindow else {
if case .settingUp = state { return .failure(.setupRunning) }
return .failure(.sessionOpen)
}
setupLog = nil
setupOutput = []
transition(to: .settingUp(found.map { .cliOutdated(found: $0) } ?? .cliMissing))
setupFolded = true
guard found != nil else { return .success(()) }
let stopped: String? = await onChain { [backend] in
do {
try await backend.stopService()
return nil
} catch {
return Self.message(for: error)
}
}
guard let stopped else { return .success(()) }
_ = await finishRuntimeInstall(.failed(stopped))
return .failure(.stopFailed(stopped))
}
/// After an install, starts the service and builds, like `runSetup(continuing: true)`.
public func finishRuntimeInstall(_ result: RuntimeInstallResult) async -> SetupRunOutcome {
guard case .settingUp(let issue) = state, Self.isRuntimeInstall(issue), run == nil else {
return .failed(.cliMissing, "No runtime install was in progress.")
}
switch result {
case .installed:
return await withCheckedContinuation { continuation in
startRun(.serviceStopped, mode: .window(continuing: true), folded: false, waiter: continuation)
}
case .cancelled:
endHold(issue, error: nil)
return .needs(issue)
case .failed(let message):
endHold(issue, error: message)
return .failed(issue, message)
}
}
private func endHold(_ issue: SetupIssue, error: String?) {
if pendingURL != nil { transition(to: .setupRequired(issue, error: error)) } else { enterIdle() }
}
private static func isRuntimeInstall(_ issue: SetupIssue) -> Bool {
switch issue {
case .cliMissing, .cliOutdated: true
default: false
}
}
public func waitWhileSettingUp() async {
guard case .settingUp = state else { return }
await withCheckedContinuation { settleWaiters.append($0) }
}
public func runtimeStatus() async -> RuntimeStatus {
await backend.runtimeStatus()
}
/// The states where no VM exists. A stuck destroy may still have one.
private var acceptsSetupWindow: Bool {
switch state {
case .idle, .setupRequired, .failed(.start), .failed(.ended), .destroyed: true
default: false
}
}
/// After `closeBackendChain()` the job never runs and this never returns; that only happens while quitting.
private func onChain<T: Sendable>(_ job: @escaping @MainActor () async -> T) async -> T {
await withCheckedContinuation { continuation in
enqueue { continuation.resume(returning: await job()) }
}
}
// MARK: - Session lifecycle
private func startSession(_ url: URL, pointerStays: Bool = false) {
let pointerOnPanel = pointerStays && pointerInside
let startsFolded: Bool
if case .settingUp = state { startsFolded = setupFolded } else { startsFolded = false }
if let old = current { retire(old) }
let session = Session(id: makeSessionID(), url: url)
current = session
pendingURL = nil
setupLog = nil
openedURL = url
pageURL = nil
connection = nil
staleImage = nil
progress = nil
lastProgressAt = nil
entered = pointerOnPanel
pointerInside = pointerOnPanel
focused = false
for key in [TimerKey.foldGrace, .neverEnteredFold, .progressHide] { timers.cancel(key) }
liveIDs.insert(session.id)
transition(to: .starting(.booting, folded: startsFolded))
enqueue { [weak self] in await self?.runStart(session) }
}
private func retire(_ session: Session, onGone: (@MainActor (Bool) -> Void)? = nil) {
guard !session.retired else { return }
session.retired = true
for task in session.tasks { task.cancel() }
session.connection?.close()
session.connection = nil
if current === session {
connection = nil
progress = nil
timers.cancel(.progressHide)
}
enqueue { [weak self] in await self?.runDestroy(session, onGone: onGone) }
}
private func runStart(_ session: Session) async {
guard !session.retired else { return }
let preflight = await backend.preflight()
guard !session.retired else { return }
switch preflight {
case .issue(let issue):
return needsSetup(issue, for: session)
case .ready(let appRoot, let stale):
let free = await diskSpace.bytesFree(atPath: appRoot)
guard !session.retired else { return }
if let free, free < Self.minimumFreeBytes {
return needsSetup(.lowDisk(bytesFree: free), for: session)
}
if requestedImageUpdate == nil { staleImage = stale }
}
session.startBegan = true
do {
let vm = try await backend.start(id: session.id, url: session.url,
renderSize: Self.renderSize, blockCIDRs: hostNetworks())
guard !session.retired else { return } // Superseded mid-start: its destroy job runs next.
transition(to: .starting(.connecting, folded: isFolded))
session.tasks.append(Task { [weak self] in await self?.connect(session, socketPath: vm.socketPath) })
} catch {
guard !session.retired else { return }
retire(session)
transition(to: .failed(.start(Self.message(for: error))))
}
}
/// No VM exists yet, so there is nothing to destroy. Starting the service needs no password, so it just happens.
private func needsSetup(_ issue: SetupIssue, for session: Session) {
session.retired = true
liveIDs.remove(session.id)
pendingURL = session.url
guard issue == .serviceStopped || issue == .kernelMissing else {
return transition(to: .setupRequired(issue, error: nil))
}
var tried = autoFixed?.url == session.url ? autoFixed?.issues ?? [] : []
guard !tried.contains(where: { Self.sameFix($0, issue) }) else {
return transition(to: .setupRequired(issue, error: Self.stillBrokenMessage))
}
tried.append(issue)
autoFixed = (session.url, tried)
startRun(issue, mode: .link, folded: false)
}
private func connect(_ session: Session, socketPath: String) async {
let outcome: Result<any StreamConnection, any Error>
do {
outcome = .success(try await connector.connect(socketPath: socketPath))
} catch {
outcome = .failure(error)
}
guard !session.retired else {
if case .success(let late) = outcome { late.close() }
return
}
switch outcome {
case .success(let stream):
session.connection = stream
connection = stream
transition(to: .starting(.loading, folded: isFolded))
session.tasks.append(Task { [weak self] in
for await event in stream.events { self?.handle(event, from: session) }
// A stream that finishes without `.ended` has still ended.
self?.handle(.ended(.closed), from: session)
})
case .failure(StreamError.versionMismatch(let found, let need)):
retire(session)
pendingURL = session.url
transition(to: .setupRequired(.imageOutdated(found: found, need: need), error: nil))
case .failure(let error):
retire(session)
transition(to: .failed(.start(Self.message(for: error))))
}
}
private func handle(_ event: StreamEvent, from session: Session) {
guard current === session, !session.retired else { return }
switch event {
case .hello:
break
case .progress(let percent):
progressChanged(percent)
case .pageURL(let url):
pageURL = url
case .contentReady:
contentReady(session)
case .ended(let reason):
let wasStarting = if case .starting = state { true } else { false }
retire(session)
transition(to: .failed(wasStarting ? .start(Self.message(for: reason)) : .ended(reason)))
}
}
private func contentReady(_ session: Session) {
guard case .starting(_, let folded) = state, !session.contentReady else { return }
session.contentReady = true
if folded {
transition(to: .live(.collapsed))
} else if opensExpanded {
transition(to: .live(.expanded))
} else {
transition(to: .live(.peek))
if !entered { timers.set(.neverEnteredFold, after: Self.neverEnteredFoldDelay) }
}
scheduleProgressHide()
}
private func runDestroy(_ session: Session, onGone: (@MainActor (Bool) -> Void)?) async {
guard session.startBegan else {
liveIDs.remove(session.id)
onGone?(true)
return
}
do {
try await backend.destroy(id: session.id)
pendingCleanup.removeAll { $0 == session.id }
liveIDs.remove(session.id)
onGone?(true)
} catch {
if !pendingCleanup.contains(session.id) { pendingCleanup.append(session.id) }
onGone?(false)
}
}
private func destroyFinished(_ session: Session, gone: Bool) {
guard current === session, state == .destroying else { return }
transition(to: gone ? .destroyed : .failed(.destroyStuck))
}
private func enterIdle() {
openedURL = nil
pageURL = nil
progress = nil
staleImage = nil
setupLog = nil
connection = nil
entered = false
pointerInside = false
focused = false
if let update = requestedImageUpdate {
requestedImageUpdate = nil
return startRun(.imageStale(builtDaysAgo: update.builtDaysAgo), mode: .link, folded: false)
}
transition(to: .idle)
}
// MARK: - Progress bar
private func progressChanged(_ percent: Int) {
lastProgressAt = clock.now
if percent >= 100 {
progress = nil
timers.cancel(.progressHide)
} else {
progress = percent
scheduleProgressHide()
}
}
/// Hides the bar 2 s after the last PROGRESS, so a load that never reports 100 doesn't leave it up.
private func scheduleProgressHide() {
guard current?.contentReady == true, progress != nil, let last = lastProgressAt else { return }
let hideAt = last + Self.progressLinger
if hideAt <= clock.now {
progress = nil
} else {
timers.set(.progressHide, at: hideAt)
}
}
// MARK: - Setup actions
private func runSetupAction(_ issue: SetupIssue, folded: Bool = false) {
setupLog = nil
setupOutput = []
transition(to: .settingUp(issue))
if folded { setupFolded = true }
let (lines, sink) = AsyncStream.makeStream(of: String.self)
let showLines = Task { [weak self] in
for await line in lines { self?.record(line) }
}
enqueue { [weak self, backend] in
let outcome: Result<Void, any Error>
do {
try await backend.fix(issue) { sink.yield($0) }
outcome = .success(())
} catch {
outcome = .failure(error)
}
sink.finish()
await showLines.value // Every line is shown before the outcome.
await self?.setupFinished(issue, outcome)
}
}
private func record(_ line: String) {
setupLog = line
setupOutput.append(line)
if setupOutput.count > Self.setupOutputLimit {
setupOutput.removeFirst(setupOutput.count - Self.setupOutputLimit)
}
}
private func startRun(_ issue: SetupIssue, mode: RunMode, folded: Bool,
waiter: CheckedContinuation<SetupRunOutcome, Never>? = nil) {
if run != nil { endRun(.needs(issue)) } // Never happens today; it keeps "every waiter resumes once" true.
run = SetupRun(mode: mode, fixed: [issue], waiter: waiter)
runSetupAction(issue, folded: folded)
}
private func endRun(_ outcome: SetupRunOutcome) {
let waiter = run?.waiter
run = nil
waiter?.resume(returning: outcome)
}
/// Link runs leave a card, as before; window runs leave one only for a waiting link.
private func stop(_ outcome: SetupRunOutcome, card: SessionState, mode: RunMode) {
endRun(outcome)
if mode == .link || pendingURL != nil { transition(to: card) } else { enterIdle() }
}
private static func fixes(_ issue: SetupIssue, in mode: RunMode) -> Bool {
switch (mode, issue) {
case (.window(continuing: true), .serviceStopped), (.window(continuing: true), .kernelMissing),
(.window(continuing: true), .imageMissing), (.window(continuing: true), .imageOutdated),
(.check, .serviceStopped), (.check, .kernelMissing):
true
default:
false
}
}
/// The backend runs one command for each group, so another issue from the same group means it didn't help.
private static func sameFix(_ a: SetupIssue, _ b: SetupIssue) -> Bool {
func group(_ issue: SetupIssue) -> Int? {
switch issue {
case .serviceStopped, .kernelMissing: 0
case .imageMissing, .imageOutdated, .imageStale: 1
default: nil
}
}
guard let first = group(a) else { return a == b }
return first == group(b)
}
private func setupFinished(_ issue: SetupIssue, _ outcome: Result<Void, any Error>) async {
guard state == .settingUp(issue) else { return }
let mode = run?.mode ?? .link
if case .failure(let error) = outcome {
let message = Self.message(for: error)
// A failed "Update now" rebuild never blocks a link.
if case .imageStale = issue, let url = pendingURL {
endRun(.failed(issue, message))
return startSession(url, pointerStays: true)
}
return stop(.failed(issue, message), card: .setupRequired(issue, error: message), mode: mode)
}
switch issue {
case .imageMissing, .imageOutdated, .imageStale: requestedImageUpdate = nil
default: break
}
if mode == .link, let url = pendingURL {
endRun(.ready)
return startSession(url, pointerStays: true)
}
let preflight = await backend.preflight()
guard state == .settingUp(issue) else { return }
switch preflight {
case .ready:
endRun(.ready)
if let url = pendingURL { return startSession(url, pointerStays: true) }
enterIdle()
case .issue(let next) where mode == .link:
endRun(.needs(next))
if let url = pendingURL { return startSession(url, pointerStays: true) }
transition(to: .setupRequired(next, error: nil))
case .issue(let next) where run?.fixed.contains(where: { Self.sameFix($0, next) }) == true:
stop(.failed(next, Self.stillBrokenMessage), card: .setupRequired(next, error: Self.stillBrokenMessage), mode: mode)
case .issue(let next) where Self.fixes(next, in: mode):
run?.fixed.append(next)
runSetupAction(next)
case .issue(let next):
stop(.needs(next), card: .setupRequired(next, error: nil), mode: mode)
}
}
// MARK: - Backend chain
/// After each job, one VM left by an earlier failed destroy gets a retry, taking turns.
private func enqueue(_ job: @escaping @MainActor () async -> Void) {
chain.enqueue { [weak self] in
let stuckBefore = self?.pendingCleanup ?? []
await job()
await self?.retryPendingCleanup(among: stuckBefore)
}
}
private func retryPendingCleanup(among candidates: [String]) async {
guard let id = candidates.first(where: pendingCleanup.contains) else { return }
do {
try await backend.forceDestroy(id: id)
pendingCleanup.removeAll { $0 == id }
liveIDs.remove(id)
} catch {
// Move it to the back so a VM stuck for good doesn't starve the others.
pendingCleanup.removeAll { $0 == id }
pendingCleanup.append(id)
}
}
// MARK: - State and timers
private func transition(to newState: SessionState) {
let oldState = state
guard newState != oldState else { return }
state = newState
if case .settingUp = newState {} else {
setupFolded = false
setupOpenedByHover = false
}
if !isPeekLike { timers.cancel(.foldGrace) }
if newState != .live(.peek) { timers.cancel(.neverEnteredFold) }
if newState == .live(.collapsed) {
timers.set(.idleDestroy, after: idleDestroyDelay)
idleDeadline = timers.deadline(of: .idleDestroy)
connection?.decodeEnabled = false
} else if oldState == .live(.collapsed) {
timers.cancel(.idleDestroy)
idleDeadline = nil
if case .live = newState { connection?.decodeEnabled = true }
}
switch newState {
case .destroyed: timers.set(.transient, after: Self.destroyedDisplay)
case .failed(.ended): timers.set(.transient, after: Self.endedDisplay)
case .failed(.destroyStuck): timers.set(.transient, after: Self.destroyStuckDisplay)
default: timers.cancel(.transient)
}
if newState == .idle {
timers.set(.idleQuit, after: Self.idleQuitDelay)
} else {
timers.cancel(.idleQuit)
}
if case .settingUp(let issue) = oldState {
if case .settingUp = newState {} else { settled(after: issue) }
}
}
/// So a waiting setup window never hangs, whatever ended the setup.
private func settled(after issue: SetupIssue) {
if run != nil { endRun(.needs(issue)) }
let waiters = settleWaiters
settleWaiters = []
for waiter in waiters { waiter.resume() }
}
private func show(_ newNotice: Notice) {
notice = newNotice
timers.set(.notice, after: Self.noticeDisplay)
}
private func timerFired(_ key: TimerKey) {
switch key {
case .foldGrace:
if isPeekLike, entered, !pointerInside, !focused { fold() }
if case .settingUp = state, setupOpenedByHover, !pointerInside {
setupOpenedByHover = false
setupFolded = true
}
case .neverEnteredFold:
if state == .live(.peek), !entered { fold() }
case .idleDestroy:
destroy()
case .transient:
enterIdle()
case .notice:
notice = nil
case .progressHide:
progress = nil
case .idleQuit:
if quitsWhenIdle { requestQuit() }
}
}
nonisolated static func message(for error: any Error) -> String {
switch error {
case VMBackendError.failed(let message): message
case StreamError.protocolViolation(let detail): "The VM sent a malformed message: \(detail)."
case StreamError.timedOut: "The VM didn't answer in time."
case StreamError.refused: "Couldn't connect to the VM."
default: String(describing: error)
}
}
nonisolated static func message(for reason: EndReason) -> String {
switch reason {
case .closed: "The VM closed the connection."
case .protocolViolation(let detail): "The VM sent a malformed message: \(detail)."
case .noFirstFrame: "The VM didn't send a first frame in time."
case .decodeErrors: "The video stream couldn't be decoded."
case .socketError(let detail): "The connection to the VM failed: \(detail)."
}
}
// MARK: - Test hooks
func fireDueTimers() {
timers.fireDue()
}
func waitForBackendIdle() async {
await chain.waitUntilIdle()
}
/// Call once the session is retired: a live session's events task runs until its stream ends.
func waitForSessionTasks() async {
for task in current?.tasks ?? [] { await task.value }
}
}
@@ -0,0 +1,36 @@
public enum SessionState: Equatable, Sendable {
case idle
case starting(StartStep, folded: Bool)
case live(LiveMode)
case destroying
case destroyed
case failed(SessionFailure)
case setupRequired(SetupIssue, error: String?)
case settingUp(SetupIssue)
}
public enum StartStep: Sendable {
case booting
/// Waiting for HELLO.
case connecting
/// Waiting for `ContentReadiness`.
case loading
}
public enum LiveMode: Sendable {
case peek
case expanded
case collapsed
}
public enum SessionFailure: Equatable, Sendable {
case start(String)
case ended(EndReason)
/// The VM stays on the pending-cleanup list.
case destroyStuck
}
/// A transient pill, shown whatever the state.
public enum Notice: Equatable, Sendable {
case noWebLink
}
@@ -0,0 +1,20 @@
/// How a setup run started by the setup window ended.
public enum SetupRunOutcome: Equatable, Sendable {
/// Preflight is ready; a waiting link may have started.
case ready
/// Stopped at an issue this kind of run doesn't fix.
case needs(SetupIssue)
case failed(SetupIssue, String)
}
public enum RuntimeHoldRefusal: Error, Equatable, Sendable {
case sessionOpen
case setupRunning
case stopFailed(String)
}
public enum RuntimeInstallResult: Equatable, Sendable {
case installed
case cancelled
case failed(String)
}
@@ -0,0 +1,55 @@
import Foundation
/// Apple's container runtime: the release Knotchy installs, and how an installed one is judged.
public enum ContainerRuntime {
public struct Release: Equatable, Sendable {
public let version: String
public let url: URL
public let sha256: String
public let size: Int64
public init(version: String, url: URL, sha256: String, size: Int64) {
self.version = version
self.url = url
self.sha256 = sha256
self.size = size
}
}
/// The only package Knotchy ever installs. Bump it only after testing a new release.
public static let pinned = Release(
version: "1.4.1",
url: URL(string: "https://github.com/apple/container/releases/download/1.4.1/container-1.4.1-installer-signed.pkg")!,
sha256: "c0d2716afefbb194c93fae662e9cae7cc186bcbcf746816608ec673dd648a6a4",
size: 117_773_865)
public static let minimumVersion = "1.4.0"
public static func version(in output: String) -> String? {
output.firstMatch(of: #/container CLI version (\d+(?:\.\d+)+)/#).map { String($0.1) }
}
/// Output Knotchy can't read counts as current, so a new format can never cause a downgrade.
public static func status(versionOutput: String?) -> RuntimeStatus {
guard let versionOutput else { return .missing }
guard let found = version(in: versionOutput) else { return .current(nil) }
return isOlder(found, than: minimumVersion) ? .outdated(found) : .current(found)
}
public static func isOlder(_ version: String, than other: String) -> Bool {
let lhs = version.split(separator: ".").map { Int($0) ?? 0 }
let rhs = other.split(separator: ".").map { Int($0) ?? 0 }
for index in 0..<max(lhs.count, rhs.count) {
let a = index < lhs.count ? lhs[index] : 0
let b = index < rhs.count ? rhs[index] : 0
if a != b { return a < b }
}
return false
}
}
public enum RuntimeStatus: Equatable, Sendable {
case missing
case outdated(String)
/// nil: installed, but its version couldn't be read.
case current(String?)
}
@@ -0,0 +1,44 @@
import Foundation
import Virtualization
/// Knotchy is built for Apple silicon only; macOS itself refuses to open it on an Intel Mac.
public enum Unsupported: Equatable, Sendable {
case noVirtualization
}
public struct HostFacts: Equatable, Sendable {
public var unsupported: Unsupported?
public var osVersion: String
public var bytesFree: Int64?
public init(unsupported: Unsupported?, osVersion: String, bytesFree: Int64?) {
self.unsupported = unsupported
self.osVersion = osVersion
self.bytesFree = bytesFree
}
}
public protocol HostChecking: Sendable {
func facts() async -> HostFacts
}
public struct SystemHost: HostChecking {
public init() {}
/// Synchronous, so the app can decide on minimal mode before it registers anything.
@MainActor
public static func unsupported() -> Unsupported? {
VZVirtualMachine.isSupported ? nil : .noVirtualization
}
public func facts() async -> HostFacts {
let unsupported = await Self.unsupported()
let version = ProcessInfo.processInfo.operatingSystemVersion
var os = "macOS \(version.majorVersion).\(version.minorVersion)"
if version.patchVersion > 0 { os += ".\(version.patchVersion)" }
// The runtime keeps its data under the home folder.
let values = try? URL(fileURLWithPath: NSHomeDirectory())
.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
return HostFacts(unsupported: unsupported, osVersion: os, bytesFree: values?.volumeAvailableCapacityForImportantUsage)
}
}
@@ -0,0 +1,209 @@
import AppKit
import CryptoKit
import Foundation
public struct DownloadedPackage: Equatable, Sendable {
public let directory: URL
/// Apple's own file name, which Installer shows.
public let name: String
public var file: URL { directory.appendingPathComponent(name) }
public init(directory: URL, name: String) {
self.directory = directory
self.name = name
}
}
public enum DownloadError: Error, Equatable, Sendable {
case failed(String)
/// Size or SHA-256 differ from the pinned release; the download is already deleted.
case mismatch
}
@MainActor
public protocol InstallerSession: AnyObject {
var hasQuit: Bool { get }
}
public enum InstallerOpening {
case opened(any InstallerSession)
/// Size or SHA-256 no longer match; the download is already deleted.
case changed
case failed(String)
}
public protocol RuntimeInstalling: Sendable {
func download(_ release: ContainerRuntime.Release,
progress: @escaping @Sendable (Int64) -> Void) async throws -> DownloadedPackage
/// Re-checks the size and hash off the main thread, then opens the package in Installer.
@MainActor func openInInstaller(_ package: DownloadedPackage, release: ContainerRuntime.Release) async -> InstallerOpening
func discard(_ package: DownloadedPackage)
}
public struct SystemRuntimeInstaller: RuntimeInstalling {
static let installerApp = URL(fileURLWithPath: "/System/Library/CoreServices/Installer.app")
public init() {}
public func download(_ release: ContainerRuntime.Release,
progress: @escaping @Sendable (Int64) -> Void) async throws -> DownloadedPackage {
Self.removeLeftovers()
let directory = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("knotchy-runtime-\(UUID().uuidString)", isDirectory: true)
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false,
attributes: [.posixPermissions: 0o700])
} catch {
throw DownloadError.failed(error.localizedDescription)
}
let package = DownloadedPackage(directory: directory, name: release.url.lastPathComponent)
do {
try await PackageDownload.fetch(release.url, to: package.file, progress: progress)
} catch {
discard(package)
throw DownloadError.failed(error.localizedDescription)
}
guard Self.matches(package.file, release) else {
discard(package)
throw DownloadError.mismatch
}
return package
}
public func discard(_ package: DownloadedPackage) {
try? FileManager.default.removeItem(at: package.directory)
}
/// Folders left by quitting mid-download or a crash.
public static func removeLeftovers() {
let temporary = URL(fileURLWithPath: NSTemporaryDirectory())
let names = (try? FileManager.default.contentsOfDirectory(atPath: temporary.path)) ?? []
for name in names where name.hasPrefix("knotchy-runtime-") {
try? FileManager.default.removeItem(at: temporary.appendingPathComponent(name))
}
}
@MainActor
public func openInInstaller(_ package: DownloadedPackage, release: ContainerRuntime.Release) async -> InstallerOpening {
let file = package.file
guard await Task.detached(operation: { Self.matches(file, release) }).value else {
discard(package)
return .changed
}
let configuration = NSWorkspace.OpenConfiguration()
configuration.activates = true
do {
let app = try await NSWorkspace.shared.open([file], withApplicationAt: Self.installerApp,
configuration: configuration)
return .opened(RunningInstaller(app: app))
} catch {
return .failed(error.localizedDescription)
}
}
static func matches(_ file: URL, _ release: ContainerRuntime.Release) -> Bool {
guard let size = (try? FileManager.default.attributesOfItem(atPath: file.path))?[.size] as? NSNumber,
size.int64Value == release.size,
let handle = try? FileHandle(forReadingFrom: file) else { return false }
defer { try? handle.close() }
var hash = SHA256()
while let chunk = try? handle.read(upToCount: 1 << 20), !chunk.isEmpty { hash.update(data: chunk) }
return hash.finalize().map { String(format: "%02x", $0) }.joined() == release.sha256
}
}
@MainActor
private final class RunningInstaller: InstallerSession {
let app: NSRunningApplication
init(app: NSRunningApplication) {
self.app = app
}
var hasQuit: Bool { app.isTerminated }
}
/// A data task streamed into the package file, so the partial download lives only in its own folder.
private final class PackageDownload: NSObject, URLSessionDataDelegate, @unchecked Sendable {
// Touched only on the session's serial delegate queue.
private var continuation: CheckedContinuation<Void, any Error>?
/// A completion that arrived before anyone waited: a task cancelled before it started.
private var outcome: Result<Void, any Error>?
private var handle: FileHandle?
private var written: Int64 = 0
private var writeError: (any Error)?
private let destination: URL
private let progress: @Sendable (Int64) -> Void
private init(destination: URL, progress: @escaping @Sendable (Int64) -> Void) {
self.destination = destination
self.progress = progress
}
static func fetch(_ url: URL, to destination: URL, progress: @escaping @Sendable (Int64) -> Void) async throws {
let delegate = PackageDownload(destination: destination, progress: progress)
let queue = OperationQueue()
queue.maxConcurrentOperationCount = 1
let session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: queue)
defer { session.finishTasksAndInvalidate() }
// Created up front: cancelling before it exists would invalidate the session, and creating it then throws.
let task = session.dataTask(with: url)
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
queue.addOperation {
if let outcome = delegate.outcome {
continuation.resume(with: outcome)
} else {
delegate.continuation = continuation
task.resume()
}
}
}
} onCancel: {
task.cancel()
}
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse,
completionHandler: @escaping @Sendable (URLSession.ResponseDisposition) -> Void) {
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
writeError = URLError(.badServerResponse)
return completionHandler(.cancel)
}
do {
guard FileManager.default.createFile(atPath: destination.path, contents: nil) else {
throw CocoaError(.fileWriteUnknown, userInfo: [NSFilePathErrorKey: destination.path])
}
handle = try FileHandle(forWritingTo: destination)
} catch {
writeError = error
return completionHandler(.cancel)
}
completionHandler(.allow)
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
guard let handle, writeError == nil else { return }
do {
try handle.write(contentsOf: data)
written += Int64(data.count)
progress(written)
} catch {
writeError = error
dataTask.cancel()
}
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) {
try? handle?.close()
handle = nil
// Ours comes first: cancelling after it adds a "cancelled" error.
let result: Result<Void, any Error> = if let failure = writeError ?? error { .failure(failure) } else { .success(()) }
if let waiting = continuation {
continuation = nil
waiting.resume(with: result)
} else {
outcome = result
}
}
}
@@ -0,0 +1,15 @@
/// What the setup flow needs from the session controller.
@MainActor
public protocol SetupDriving: AnyObject {
var state: SessionState { get }
var setupLog: String? { get }
var setupOutput: [String] { get }
func runtimeStatus() async -> RuntimeStatus
func checkSetup() async -> SetupRunOutcome?
func runSetup(_ issue: SetupIssue, continuing: Bool) async -> SetupRunOutcome?
func beginRuntimeInstall(replacing found: String?) async -> Result<Void, RuntimeHoldRefusal>
func finishRuntimeInstall(_ result: RuntimeInstallResult) async -> SetupRunOutcome
func waitWhileSettingUp() async
}
extension SessionController: SetupDriving {}
+360
View File
@@ -0,0 +1,360 @@
import Foundation
import Observation
/// The setup window's rows and the order of its steps. The window binds only to this.
@MainActor
@Observable
public final class SetupFlow {
public enum Status: Equatable, Sendable {
case pending(String)
case running(String, fraction: Double?)
case done(String)
case failed(String, details: [String])
}
public enum RuntimeAction: Equatable, Sendable {
case install
case update
}
public static let requiredFreeBytes: Int64 = 3_000_000_000
static let vmDetail = "About 3 minutes · downloads about 225 MB"
public private(set) var mac: Status = .pending("Checking…")
public private(set) var unsupported: Unsupported?
public private(set) var runtimeAction: RuntimeAction?
public private(set) var isRunning = false
/// Quitting now leaves a partial download behind.
@ObservationIgnored public private(set) var isDownloading = false
private var runtimeBase: Status = .pending("")
private var vmBase: Status = .pending(SetupFlow.vmDetail)
@ObservationIgnored private let host: any HostChecking
@ObservationIgnored private let installer: any RuntimeInstalling
@ObservationIgnored private let driver: any SetupDriving
@ObservationIgnored private let release: ContainerRuntime.Release
@ObservationIgnored private let retryDelay: Duration
@ObservationIgnored private var facts: HostFacts?
@ObservationIgnored private var runtimeStatus: RuntimeStatus?
public init(host: any HostChecking, installer: any RuntimeInstalling, driver: any SetupDriving,
release: ContainerRuntime.Release = ContainerRuntime.pinned, retryDelay: Duration = .milliseconds(500)) {
self.host = host
self.installer = installer
self.driver = driver
self.release = release
self.retryDelay = retryDelay
runtimeBase = .pending(Self.installDetail(release))
}
// MARK: - Rows
/// With the controller's live progress on top, so a setup the notch started shows here too.
public var runtime: Status {
if case .settingUp(let issue) = driver.state, issue == .serviceStopped || issue == .kernelMissing {
return .running("Starting the runtime…", fraction: nil)
}
return runtimeBase
}
public var vm: Status {
if case .settingUp(let issue) = driver.state, Self.isImageIssue(issue) {
return .running(driver.setupLog ?? "Starting the build…", fraction: nil)
}
return vmBase
}
public var allRequiredDone: Bool {
if case .done = mac, case .done = runtime, case .done = vm { return true }
return false
}
public var canSetUp: Bool {
guard !isRunning, unsupported == nil, !allRequiredDone else { return false }
if case .failed = mac { return false }
return true
}
// MARK: - Actions
@ObservationIgnored private var work: Task<Void, Never>?
/// For the Set Up and Try Again buttons: runs `setUp()` where quitting can cancel it.
public func start() {
guard !isRunning else { return }
work = Task { await setUp() }
}
/// Quitting: stops a download. An open Installer is left to finish.
public func cancel() {
work?.cancel()
}
/// Re-checks everything. Starts a stopped service, as a link would, but never installs or builds.
public func refresh() async {
guard !isRunning else { return }
isRunning = true
defer { isRunning = false }
await inspect()
}
/// The Set Up and Try Again buttons: runs whatever is left, in order.
public func setUp() async {
guard !isRunning else { return }
isRunning = true
defer { isRunning = false }
await inspect()
guard unsupported == nil else { return }
// The service didn't start: building would only try again.
if case .failed = runtimeBase { return }
if case .failed = mac { return }
if let action = runtimeAction {
await installRuntime(action)
} else if case .done = vmBase {
return
} else {
await buildVM()
}
updateMac()
}
// MARK: - Steps
private func inspect() async {
let facts = await host.facts()
self.facts = facts
unsupported = facts.unsupported
guard unsupported == nil else { return }
let status = await driver.runtimeStatus()
runtimeStatus = status
switch status {
case .missing:
runtimeAction = .install
runtimeBase = .pending(Self.installDetail(release))
vmBase = .pending(Self.vmDetail)
case .outdated(let found):
runtimeAction = .update
runtimeBase = .pending("container \(found) is too old · updates to \(release.version) with Apple's Installer")
vmBase = .pending(Self.vmDetail)
case .current(let version):
runtimeAction = nil
runtimeBase = .done(Self.installedText(version))
switch await driver.checkSetup() {
case .ready?: vmBase = .done("Ready")
case .needs(let issue)? where Self.isImageIssue(issue): vmBase = .pending(Self.vmDetail)
case .failed(let issue, let message)?: fail(issue, message)
case nil where Self.isBrowserOpen(driver.state): vmBase = .done("Ready") // A running VM has an image.
case .needs?, nil: break // Busy, or the runtime row's business: the live rows show it.
}
}
updateMac()
}
private func installRuntime(_ action: RuntimeAction) async {
switch driver.state {
case .starting, .live:
runtimeBase = .failed("Close the Knotchy browser first.", details: [])
return
case .destroying, .failed(.destroyStuck):
runtimeBase = .failed("Wait for the Knotchy browser to finish closing, then try again.", details: [])
return
default:
break
}
runtimeBase = .running(Self.downloadText(0, of: release.size), fraction: 0)
let package: DownloadedPackage
do {
package = try await download()
} catch DownloadError.mismatch {
runtimeBase = .failed("The download didn't match the expected package, so it wasn't installed.", details: [])
return
} catch {
runtimeBase = .failed("Couldn't download the runtime. Check your connection and try again.",
details: [String(describing: error)])
return
}
let found: String? = if case .outdated(let version) = runtimeStatus { version } else { nil }
let hold = await driver.beginRuntimeInstall(replacing: found)
if case .failure = hold { installer.discard(package) }
switch hold {
case .success:
break
case .failure(.sessionOpen):
runtimeBase = .failed("Close the Knotchy browser first.", details: [])
return
case .failure(.setupRunning):
runtimeBase = .failed("Wait for the current setup step to finish, then try again.", details: [])
return
case .failure(.stopFailed(let message)):
runtimeBase = .failed("Couldn't stop the runtime for the update.", details: [message])
return
}
runtimeBase = .running("Opening Installer…", fraction: nil)
let session: any InstallerSession
switch await installer.openInInstaller(package, release: release) {
case .opened(let opened):
session = opened
case .changed:
return await failInstall("The download changed after it was checked, so it wasn't opened.", details: [])
case .failed(let message):
installer.discard(package)
return await failInstall("Couldn't open Installer.", details: [message])
}
runtimeBase = .running("Finish the install in Installer…", fraction: nil)
let status = await waitForInstall(session)
// Installer reads the package until it quits.
Task { [installer] in
while !session.hasQuit { try? await Task.sleep(for: .seconds(2)) }
installer.discard(package)
}
// Quitting: Installer carries on by itself.
if Task.isCancelled { return }
guard case .current(let version)? = status else {
_ = await driver.finishRuntimeInstall(.cancelled)
runtimeBase = .pending("Installing was cancelled.")
return
}
runtimeStatus = status
runtimeAction = nil
runtimeBase = .done(Self.installedText(version))
apply(await driver.finishRuntimeInstall(.installed))
}
/// The current runtime once it appears, or nil when Installer quits without it.
private func waitForInstall(_ session: any InstallerSession) async -> RuntimeStatus? {
while !Task.isCancelled {
let status = await driver.runtimeStatus()
if case .current = status { return status }
if session.hasQuit {
// Installer may have finished between the two checks.
let last = await driver.runtimeStatus()
if case .current = last { return last }
return nil
}
try? await Task.sleep(for: retryDelay)
}
return nil
}
private func download() async throws -> DownloadedPackage {
isDownloading = true
defer { isDownloading = false }
return try await installer.download(release) { [weak self, release] bytes in
Task { @MainActor in self?.downloaded(bytes, of: release.size) }
}
}
private func failInstall(_ message: String, details: [String]) async {
_ = await driver.finishRuntimeInstall(.failed(message))
runtimeBase = .failed(message, details: details)
}
private func buildVM() async {
// A setup the notch started, or a session winding down, gets up to a minute to finish.
for _ in 0..<120 {
if Task.isCancelled { return }
guard let checked = await driver.checkSetup() else {
if case .settingUp = driver.state {
await driver.waitWhileSettingUp()
} else {
try? await Task.sleep(for: retryDelay)
}
continue
}
switch checked {
case .needs(let issue) where Self.isImageIssue(issue):
guard let result = await driver.runSetup(issue, continuing: true) else { continue }
return apply(result)
case .needs:
return await inspect()
default:
return apply(checked)
}
}
vmBase = .failed("Close the Knotchy browser first, then try again.", details: [])
}
private func apply(_ outcome: SetupRunOutcome) {
switch outcome {
case .ready:
vmBase = .done("Ready")
case .needs(let issue):
if Self.isImageIssue(issue) { vmBase = .pending(Self.vmDetail) }
case .failed(let issue, let message):
fail(issue, message)
}
}
private func fail(_ issue: SetupIssue, _ message: String) {
if Self.isImageIssue(issue) {
vmBase = .failed(message, details: driver.setupOutput)
} else {
runtimeBase = .failed(message, details: driver.setupOutput)
}
}
private func downloaded(_ bytes: Int64, of total: Int64) {
// Late callbacks mustn't overwrite what came after the download.
guard case .running(let text, _) = runtimeBase, text.hasPrefix("Downloading") else { return }
if bytes >= total {
runtimeBase = .running("Verifying…", fraction: nil)
} else {
runtimeBase = .running(Self.downloadText(bytes, of: total), fraction: Double(bytes) / Double(max(total, 1)))
}
}
private func updateMac() {
guard let facts else { return }
let free = facts.bytesFree.map { "\(Self.gigabytes($0)) GB free" }
if needsSpace, let bytes = facts.bytesFree, bytes < Self.requiredFreeBytes {
mac = .failed("Setting up needs about 3 GB free. \(Self.gigabytes(bytes)) GB is free.", details: [])
} else {
mac = .done([facts.osVersion, free].compactMap { $0 }.joined(separator: " · "))
}
}
/// Only the install and the build use space; starting the service doesn't.
private var needsSpace: Bool {
if runtimeAction != nil { return true }
if case .done = vmBase { return false }
return true
}
// MARK: - Text
static func installDetail(_ release: ContainerRuntime.Release) -> String {
"Apple's container \(release.version) · \(megabytes(release.size)) MB · installs with Apple's Installer"
}
static func installedText(_ version: String?) -> String {
version.map { "container \($0) installed" } ?? "container installed"
}
static func downloadText(_ bytes: Int64, of total: Int64) -> String {
"Downloading… \(megabytes(bytes)) of \(megabytes(total)) MB"
}
static func megabytes(_ bytes: Int64) -> Int {
Int((Double(bytes) / 1_000_000).rounded())
}
static func gigabytes(_ bytes: Int64) -> String {
bytes >= 10_000_000_000 ? "\(bytes / 1_000_000_000)" : String(format: "%.1f", Double(bytes) / 1_000_000_000)
}
static func isBrowserOpen(_ state: SessionState) -> Bool {
switch state {
case .starting, .live: true
default: false
}
}
static func isImageIssue(_ issue: SetupIssue) -> Bool {
switch issue {
case .imageMissing, .imageOutdated, .imageStale: true
default: false
}
}
}
@@ -0,0 +1,12 @@
/// When the setup window opens by itself at launch.
public enum SetupLaunchRule {
public static let completedKey = "setupCompleted"
public static func shouldOpen(hostSupported: Bool, setupCompleted: Bool, runtime: RuntimeStatus) -> Bool {
guard hostSupported, setupCompleted else { return true }
switch runtime {
case .missing, .outdated: return true
case .current: return false
}
}
}
@@ -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))
}
}
@@ -0,0 +1,67 @@
/// Tests call `fireDue()` after advancing a `TestClock`; firing is idempotent for a given `now`.
@MainActor
final class DeadlineTimers<Key: Hashable & Sendable> {
var onFire: @MainActor (Key) -> Void = { _ in }
private struct Entry {
var deadline: ContinuousClock.Instant
var order: Int
}
private let clock: any KnotchyClock
private var entries: [Key: Entry] = [:]
private var nextOrder = 0
private var driver: Task<Void, Never>?
init(clock: any KnotchyClock) {
self.clock = clock
}
isolated deinit {
driver?.cancel()
}
func set(_ key: Key, after duration: Duration) {
set(key, at: clock.now + duration)
}
func set(_ key: Key, at deadline: ContinuousClock.Instant) {
nextOrder += 1
entries[key] = Entry(deadline: deadline, order: nextOrder)
reschedule()
}
func cancel(_ key: Key) {
guard entries.removeValue(forKey: key) != nil else { return }
reschedule()
}
func deadline(of key: Key) -> ContinuousClock.Instant? {
entries[key]?.deadline
}
/// Keys `onFire` re-arms at or before `now` fire in this same pass; doing that every time loops forever.
func fireDue() {
let now = clock.now
while let (key, _) = entries
.filter({ $0.value.deadline <= now })
.min(by: { ($0.value.deadline, $0.value.order) < ($1.value.deadline, $1.value.order) }) {
entries[key] = nil
onFire(key)
}
reschedule()
}
private func reschedule() {
driver?.cancel()
guard let next = entries.values.map(\.deadline).min() else {
driver = nil
return
}
driver = Task { [weak self, clock] in
do { try await clock.sleep(until: next) } catch { return }
guard !Task.isCancelled else { return } // Replaced while waking up.
self?.fireDue()
}
}
}
@@ -0,0 +1,15 @@
/// `ContinuousClock` keeps counting while the Mac sleeps, so an expired idle countdown fires on wake.
public protocol KnotchyClock: Sendable {
var now: ContinuousClock.Instant { get }
func sleep(until deadline: ContinuousClock.Instant) async throws
}
public struct SystemClock: KnotchyClock {
public init() {}
public var now: ContinuousClock.Instant { .now }
public func sleep(until deadline: ContinuousClock.Instant) async throws {
try await Task.sleep(until: deadline, clock: .continuous)
}
}