mirror of
https://github.com/aculix/knotchy.git
synced 2026-10-27 07:15:53 +00:00
Initial release
This commit is contained in:
commit
4b67728cce
108 files changed
+13310
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 [(""", "\""), (""", "\""), ("'", "'"), ("'", "'"),
|
||||
("<", "<"), (">", ">"), ("&", "&"), ("&", "&")] {
|
||||
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 {}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user